diff --git a/adapters/postgres/continuity_repository.go b/adapters/postgres/continuity_repository.go
index 961973b..60c8cde 100644
--- a/adapters/postgres/continuity_repository.go
+++ b/adapters/postgres/continuity_repository.go
@@ -19,6 +19,9 @@ var (
ErrProspectiveRevisionConflict = errors.New("prospective item revision conflict")
ErrActionReceiptNotFound = errors.New("continuity action receipt not found")
ErrActionIntentConflict = errors.New("action intent was already used for a different command")
+ ErrAttentionNotFound = errors.New("attention item not found")
+ ErrAttentionStateConflict = errors.New("attention item state conflict")
+ ErrAttentionRevisionConflict = errors.New("attention item revision conflict")
)
type ContinuityConfig struct{ Host, Port, Database, User, Password string }
@@ -66,6 +69,165 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ
return items, nil
}
+func (r *ContinuityRepository) DueArmed(ctx context.Context, nowUnixMillis int64, limit int) ([]continuity.ProspectiveItem, error) {
+ if limit < 1 || limit > 100 {
+ return nil, fmt.Errorf("invalid due reminder limit")
+ }
+ rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision
+ FROM continuity.prospective_items
+ WHERE state=$1 AND trigger_at_unix_millis <= $2
+ ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $3`, continuity.ProspectiveArmed, nowUnixMillis, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list due prospective items: %w", err)
+ }
+ defer rows.Close()
+ items := make([]continuity.ProspectiveItem, 0)
+ for rows.Next() {
+ var item continuity.ProspectiveItem
+ if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil {
+ return nil, fmt.Errorf("scan due prospective item: %w", err)
+ }
+ items = append(items, item)
+ }
+ return items, rows.Err()
+}
+
+func (r *ContinuityRepository) ClaimDueReminder(ctx context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, nowUnixMillis int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) {
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ var triggered continuity.ProspectiveItem
+ err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
+ SET state=$1, revision=revision+1
+ WHERE prospective_item_id=$2 AND state=$3 AND revision=$4 AND trigger_at_unix_millis <= $5
+ RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
+ continuity.ProspectiveTriggered, id, continuity.ProspectiveArmed, revision, nowUnixMillis).Scan(
+ &triggered.ID, &triggered.CommitmentID, &triggered.OwnerPrincipalID, &triggered.ScopeID, &triggered.GoalID,
+ &triggered.SourceIntentID, &triggered.SourceUtteranceID, &triggered.Summary, &triggered.TriggerAtUnixMillis, &triggered.State, &triggered.Revision)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, ErrProspectiveRevisionConflict
+ }
+ if err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("trigger due reminder: %w", err)
+ }
+
+ var revalidating continuity.ProspectiveItem
+ err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
+ SET state=$1, revision=revision+1
+ WHERE prospective_item_id=$2 AND state=$3 AND revision=$4
+ RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
+ continuity.ProspectiveRevalidating, triggered.ID, continuity.ProspectiveTriggered, triggered.Revision).Scan(
+ &revalidating.ID, &revalidating.CommitmentID, &revalidating.OwnerPrincipalID, &revalidating.ScopeID, &revalidating.GoalID,
+ &revalidating.SourceIntentID, &revalidating.SourceUtteranceID, &revalidating.Summary, &revalidating.TriggerAtUnixMillis, &revalidating.State, &revalidating.Revision)
+ if err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("begin due reminder revalidation: %w", err)
+ }
+
+ var delivered continuity.ProspectiveItem
+ err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items
+ SET state=$1, revision=revision+1
+ WHERE prospective_item_id=$2 AND state=$3 AND revision=$4
+ RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`,
+ continuity.ProspectiveDelivered, revalidating.ID, continuity.ProspectiveRevalidating, revalidating.Revision).Scan(
+ &delivered.ID, &delivered.CommitmentID, &delivered.OwnerPrincipalID, &delivered.ScopeID, &delivered.GoalID,
+ &delivered.SourceIntentID, &delivered.SourceUtteranceID, &delivered.Summary, &delivered.TriggerAtUnixMillis, &delivered.State, &delivered.Revision)
+ if err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("finish due reminder revalidation: %w", err)
+ }
+
+ attention, err := continuitydomain.BuildDueReminderAttention(delivered, attentionID, nowUnixMillis)
+ if err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
+ }
+ err = tx.QueryRow(ctx, `INSERT INTO continuity.attention_items (
+ attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
+ due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,
+ action_attempt_id,delivery_outcome,revision)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,'','','','',1)
+ ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET source_prospective_item_id=EXCLUDED.source_prospective_item_id
+ RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
+ created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
+ attention.ID, attention.OwnerPrincipalID, attention.ScopeID, attention.SourceKind, attention.SourceProspectiveID,
+ attention.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan(
+ &attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID,
+ &attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis,
+ &attention.DeliveredAtUnixMillis, &attention.ActionIntentID, &attention.AuthorizedActionID, &attention.ActionAttemptID,
+ &attention.DeliveryOutcome, &attention.Revision)
+ if err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("create due reminder attention: %w", err)
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err
+ }
+ return delivered, attention, nil
+}
+
+func (r *ContinuityRepository) PendingAttention(ctx context.Context, limit int) ([]continuity.AttentionItem, error) {
+ if limit < 1 || limit > 100 {
+ return nil, fmt.Errorf("invalid attention limit")
+ }
+ rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
+ due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision
+ FROM continuity.attention_items WHERE state=$1 ORDER BY due_at_unix_millis, attention_item_id LIMIT $2`, continuity.AttentionPending, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list pending attention: %w", err)
+ }
+ defer rows.Close()
+ return scanAttentionRows(rows)
+}
+
+func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.AttentionItem, error) {
+ if owner == "" || limit < 1 || limit > 100 {
+ return nil, fmt.Errorf("invalid attention owner query")
+ }
+ rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,
+ due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision
+ FROM continuity.attention_items WHERE owner_principal_id=$1 ORDER BY due_at_unix_millis DESC, attention_item_id DESC LIMIT $2`, owner, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list attention by owner: %w", err)
+ }
+ defer rows.Close()
+ return scanAttentionRows(rows)
+}
+
+func (r *ContinuityRepository) MarkAttentionDelivering(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
+ var item continuity.AttentionItem
+ err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items SET state=$1, revision=revision+1
+ WHERE attention_item_id=$2 AND state=$3 AND revision=$4
+ RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
+ created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
+ continuity.AttentionDelivering, id, continuity.AttentionPending, revision).Scan(
+ &item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
+ &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
+ &item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return continuity.AttentionItem{}, ErrAttentionRevisionConflict
+ }
+ return item, err
+}
+
+func (r *ContinuityRepository) MarkAttentionDelivered(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAtUnixMillis int64) (continuity.AttentionItem, error) {
+ var item continuity.AttentionItem
+ err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items
+ SET state=$1, revision=revision+1, delivered_at_unix_millis=$2, action_intent_id=$3, authorized_action_id=$4,
+ action_attempt_id=$5, delivery_outcome=$6
+ WHERE attention_item_id=$7 AND state=$8 AND revision=$9
+ RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis,
+ created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`,
+ continuity.AttentionDelivered, deliveredAtUnixMillis, intentID, authorizedID, attemptID, outcome,
+ id, continuity.AttentionDelivering, revision).Scan(
+ &item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State,
+ &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID,
+ &item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return continuity.AttentionItem{}, ErrAttentionRevisionConflict
+ }
+ return item, err
+}
+
// SatisfyDelivered owns both the Prospective CAS and its idempotency receipt in
// one Continuity-local transaction. It never reaches Commit storage.
func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command continuity.SatisfyDeliveredCommand) (continuity.ActionReceipt, error) {
@@ -135,6 +297,30 @@ type continuityRowQuerier interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
+type continuityRows interface {
+ Next() bool
+ Scan(...any) error
+ Err() error
+}
+
+func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error) {
+ items := make([]continuity.AttentionItem, 0)
+ for rows.Next() {
+ var item continuity.AttentionItem
+ if err := rows.Scan(&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID,
+ &item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis,
+ &item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID, &item.ActionAttemptID,
+ &item.DeliveryOutcome, &item.Revision); err != nil {
+ return nil, fmt.Errorf("scan attention item: %w", err)
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("scan attention items: %w", err)
+ }
+ return items, nil
+}
+
func loadActionReceipt(ctx context.Context, querier continuityRowQuerier, intentID continuity.CommittedActionIntentID) (continuity.ActionReceipt, error) {
var receipt continuity.ActionReceipt
err := querier.QueryRow(ctx, `SELECT action_intent_id,authorized_action_id,owner_principal_id,prospective_item_id,
diff --git a/architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md b/architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md
new file mode 100644
index 0000000..5292c9a
--- /dev/null
+++ b/architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md
@@ -0,0 +1,46 @@
+# ADR-0014 — Assistant Presence, Attention, and Human Surface V1
+
+Status: Accepted
+
+Task: DEV-000065
+
+## Context
+
+Domovoy already records human utterances, original human intent, current focus, capability requirements, capability gaps, and Continuity-owned reminder state. Before this decision, creating a reminder only persisted an `ARMED` `ProspectiveItem`; it did not by itself return to the human when the due condition arrived.
+
+The system already has the correct outbound mutation foundation: Commit owns authorized external actions, Capability owns notification bindings, and the notification receiver persists idempotent receipts. DEV-000065 must therefore connect due Continuity state to the existing Commit/Executor path instead of creating a parallel reminder or notification subsystem.
+
+## Decision
+
+1. Continuity remains the canonical owner of future obligations and prospective state.
+2. Continuity also owns the V1 typed `AttentionItem` state because attention is the human-facing relevance decision derived from future obligations and other future internal events.
+3. `AttentionItem` V1 is deliberately narrow: it records source, owner, level, state, summary, due time, and delivery evidence references. It is not a task manager, dashboard model, or capability registry.
+4. A due reminder follows the existing prospective lifecycle: `ARMED → TRIGGERED → REVALIDATING → DELIVERED`. `DELIVERED` means the due obligation has reached the human-contact phase; it does not grant external mutation authority by itself.
+5. Human notification remains a Commit-owned external action using the existing `deliver-prospective-notification-v1` operation and Capability-owned active notification binding.
+6. Commit may authorize a prospective notification using a Continuity prospective item as the live justification. A Work `TaskTarget` is not mandatory for this operation when the operation is internally invoked for a validated due `ProspectiveItem`.
+7. The due processing runtime may poll canonical PostgreSQL state, but correctness must come from persisted state transitions and idempotency constraints, not from process-local timers.
+8. The Human Assistant web surface is separate from the Operator Console. Chat remains primary; Today/Attention and History are human-readable projections built through runtime APIs over canonical state.
+
+## Boundaries
+
+- Continuity writes only Continuity-owned prospective and attention state.
+- Commit writes only Commit-owned action/attempt state and invokes the existing notification executor.
+- Capability owns the notification binding and health/availability semantics.
+- Interaction owns conversations, utterances, interpretations, and original human intent.
+- No model/provider call is required for deterministic reminder firing.
+- Model output never directly mutates Continuity, Commit, or Capability state.
+
+## Non-goals
+
+- No Gmail, MCP, Codex Bridge, browser automation, mobile app, voice, or self-extension loop.
+- No new canonical owner.
+- No generic scheduler/event-bus/autonomous-agent loop.
+- No new third-party dependency.
+- No new notification channel beyond the existing receiver/Windows display path.
+
+## Consequences
+
+- Reminders survive runtime restart because due detection scans canonical `ProspectiveItem` rows.
+- Duplicate notification is prevented by Continuity CAS/unique attention source and Commit/receiver idempotency.
+- The first attention source is reminder due events; future sources can reuse the same attention path without changing ownership.
+- Human UI no longer needs internal UUIDs, binding IDs, provider IDs, or raw lifecycle enums for ordinary use.
diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json
index 5efa0aa..64eac60 100644
--- a/architecture/architecture-registry.json
+++ b/architecture/architecture-registry.json
@@ -98,7 +98,8 @@
"id": "mod.continuity",
"owns": [
"Commitment",
- "ProspectiveItem"
+ "ProspectiveItem",
+ "AttentionItem"
],
"database_schema": "continuity",
"boundary_class": "remote_capable",
diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml
index 04e4cd1..516a28a 100644
--- a/architecture/architecture-registry.yaml
+++ b/architecture/architecture-registry.yaml
@@ -41,7 +41,7 @@ modules:
continuity:
id: mod.continuity
- owns: [Commitment, ProspectiveItem]
+ owns: [Commitment, ProspectiveItem, AttentionItem]
database_schema: continuity
boundary_class: remote_capable
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
diff --git a/cmd/commit-runtime/creator_clients.go b/cmd/commit-runtime/creator_clients.go
index c7a29e7..f961062 100644
--- a/cmd/commit-runtime/creator_clients.go
+++ b/cmd/commit-runtime/creator_clients.go
@@ -183,3 +183,29 @@ func (c creatorContinuityClient) Item(ctx context.Context, authorization string,
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
}
}
+
+func (c creatorContinuityClient) InternalItem(ctx context.Context, id continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
+ target := *c.baseURL
+ target.Path = "/v1/prospective-items/" + url.PathEscape(string(id))
+ target.RawQuery = ""
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
+ if err != nil {
+ return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
+ }
+ response, err := c.client.Do(request)
+ if err != nil {
+ return continuity.ProspectiveItem{}, fmt.Errorf("%w: %v", errCreatorQueryUnavailable, err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode == http.StatusNotFound {
+ return continuity.ProspectiveItem{}, errCreatorSubjectMissing
+ }
+ if response.StatusCode != http.StatusOK {
+ return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
+ }
+ var item continuity.ProspectiveItem
+ if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(&item); err != nil {
+ return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
+ }
+ return item, nil
+}
diff --git a/cmd/commit-runtime/internal_notification.go b/cmd/commit-runtime/internal_notification.go
new file mode 100644
index 0000000..d90c86a
--- /dev/null
+++ b/cmd/commit-runtime/internal_notification.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "git.cin.su/m/Assistant/adapters/notificationexecutor"
+ commitcontract "git.cin.su/m/Assistant/contracts/commit"
+ "git.cin.su/m/Assistant/contracts/continuity"
+ "git.cin.su/m/Assistant/contracts/identity"
+ "git.cin.su/m/Assistant/contracts/work"
+ commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
+ "git.cin.su/m/Assistant/modules/commit/ports"
+ "git.cin.su/m/Assistant/platform/ids"
+)
+
+const prospectiveNotificationActor identity.ActorID = "domovoy-creator-actor"
+
+type internalNotificationContinuity interface {
+ InternalItem(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
+}
+
+type internalNotificationHandler struct {
+ core creatorSessionSource
+ continuity internalNotificationContinuity
+ authority creatorAuthoritySource
+ repository creatorCommitRepository
+ executor ports.Executor
+ newID func() (ids.ID, error)
+ now func() time.Time
+}
+
+type internalProspectiveNotificationRequest struct {
+ ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
+ ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
+}
+
+func registerInternalNotificationRoutes(mux *http.ServeMux, handler internalNotificationHandler, token string) {
+ mux.HandleFunc("POST /v1/internal/continuity/notify-due", requireCommitToken(token, handler.notifyDue))
+}
+
+func (h internalNotificationHandler) notifyDue(w http.ResponseWriter, r *http.Request) {
+ var request internalProspectiveNotificationRequest
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
+ http.Error(w, "invalid prospective notification request", http.StatusBadRequest)
+ return
+ }
+ item, err := h.continuity.InternalItem(r.Context(), request.ProspectiveItemID)
+ if errors.Is(err, errCreatorSubjectMissing) {
+ http.Error(w, "prospective item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ http.Error(w, "Continuity item unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.OwnerPrincipalID == "" || item.ScopeID == "" {
+ http.Error(w, "prospective item not deliverable", http.StatusConflict)
+ return
+ }
+ grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
+ if err != nil || scope.ID != item.ScopeID {
+ http.Error(w, "Creator authority unavailable", http.StatusConflict)
+ return
+ }
+ identityBinding, err := h.core.Binding(r.Context(), prospectiveNotificationActor, item.OwnerPrincipalID)
+ if err != nil {
+ http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
+ return
+ }
+ capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, item.OwnerPrincipalID)
+ if err != nil {
+ http.Error(w, "notification capability unavailable", http.StatusConflict)
+ return
+ }
+ intentID, authorizedID, attemptID, err := internalActionIDs(h.newID)
+ if err != nil {
+ http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ title := "Домовой: напоминание"
+ message := "Пора: " + strings.TrimSpace(item.Summary)
+ payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
+ digest, err := notificationexecutor.Digest(payload)
+ if err != nil {
+ http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
+ return
+ }
+ proposal := commitcontract.ActionProposal{
+ IntentID: intentID, ActorID: prospectiveNotificationActor, CapabilityID: commitcontract.CapabilityProspectiveNotification,
+ Target: work.TaskTarget{ScopeID: item.ScopeID, Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification,
+ NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest},
+ }
+ action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
+ if err != nil {
+ http.Error(w, "Creator authorization denied", http.StatusConflict)
+ return
+ }
+ if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
+ http.Error(w, "commit authorized action", http.StatusInternalServerError)
+ return
+ }
+ attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
+ if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
+ http.Error(w, "execute committed notification", http.StatusInternalServerError)
+ return
+ }
+ status := http.StatusCreated
+ if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
+ status = http.StatusAccepted
+ }
+ writeCreatorActionResponse(w, status, action, attempt)
+}
+
+func internalActionIDs(newID func() (ids.ID, error)) (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) {
+ intent, err := newID()
+ if err != nil {
+ return "", "", "", err
+ }
+ authorized, err := newID()
+ if err != nil {
+ return "", "", "", err
+ }
+ attempt, err := newID()
+ if err != nil {
+ return "", "", "", err
+ }
+ return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil
+}
diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go
index 8720752..2b328c9 100644
--- a/cmd/commit-runtime/main.go
+++ b/cmd/commit-runtime/main.go
@@ -89,6 +89,10 @@ func main() {
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
})
+ registerInternalNotificationRoutes(mux, internalNotificationHandler{
+ core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
+ executor: dispatcher, newID: ids.New, now: time.Now,
+ }, executorToken)
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
var request authorizationRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
diff --git a/cmd/continuity-runtime/attention.go b/cmd/continuity-runtime/attention.go
new file mode 100644
index 0000000..b0accd2
--- /dev/null
+++ b/cmd/continuity-runtime/attention.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "git.cin.su/m/Assistant/contracts/continuity"
+ "git.cin.su/m/Assistant/contracts/identity"
+)
+
+type attentionRepository interface {
+ AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error)
+}
+
+func registerCreatorAttentionRoutes(mux *http.ServeMux, identityClient creatorIdentityClient, repository attentionRepository) {
+ mux.HandleFunc("GET /v1/creator/attention", func(w http.ResponseWriter, r *http.Request) {
+ session, ok := creatorSessionForAttention(w, r, identityClient)
+ if !ok {
+ return
+ }
+ limit, err := parseListLimit(r.URL.Query().Get("limit"))
+ if err != nil {
+ http.Error(w, "invalid attention limit", http.StatusBadRequest)
+ return
+ }
+ items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, limit)
+ if err != nil {
+ http.Error(w, "attention unavailable", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(continuity.AttentionListResult{Items: items})
+ })
+ mux.HandleFunc("GET /v1/creator/today", func(w http.ResponseWriter, r *http.Request) {
+ session, ok := creatorSessionForAttention(w, r, identityClient)
+ if !ok {
+ return
+ }
+ items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, 50)
+ if err != nil {
+ http.Error(w, "today unavailable", http.StatusInternalServerError)
+ return
+ }
+ result := continuity.TodayResult{Items: make([]continuity.TodayItem, 0, len(items))}
+ for _, item := range items {
+ result.Items = append(result.Items, continuity.TodayItem{
+ Title: item.Summary,
+ WhenUnixMillis: item.DueAtUnixMillis,
+ AttentionLevel: item.Level,
+ AttentionState: item.State,
+ HumanState: humanAttentionState(item.State),
+ DeliveryState: humanDeliveryState(item),
+ SourceDescription: humanAttentionSource(item.SourceKind),
+ })
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(result)
+ })
+}
+
+func creatorSessionForAttention(w http.ResponseWriter, r *http.Request, identityClient creatorIdentityClient) (identity.CreatorSession, bool) {
+ session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization"))
+ if errors.Is(err, errCreatorSessionDenied) {
+ http.Error(w, "creator session denied", http.StatusUnauthorized)
+ return identity.CreatorSession{}, false
+ }
+ if err != nil {
+ http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
+ return identity.CreatorSession{}, false
+ }
+ return session, true
+}
+
+func humanAttentionState(state continuity.AttentionState) string {
+ switch state {
+ case continuity.AttentionPending:
+ return "ожидает внимания"
+ case continuity.AttentionDelivering:
+ return "доставляется"
+ case continuity.AttentionDelivered:
+ return "доставлено"
+ case continuity.AttentionSuppressed:
+ return "скрыто"
+ case continuity.AttentionExpired:
+ return "истекло"
+ case continuity.AttentionFailed:
+ return "не доставлено"
+ default:
+ return "состояние уточняется"
+ }
+}
+
+func humanDeliveryState(item continuity.AttentionItem) string {
+ if item.State == continuity.AttentionDelivered {
+ return "уведомление отправлено"
+ }
+ if item.State == continuity.AttentionDelivering {
+ return "уведомление отправляется"
+ }
+ return "уведомление ещё не отправлено"
+}
+
+func humanAttentionSource(kind continuity.AttentionSourceKind) string {
+ if kind == continuity.AttentionSourceProspectiveReminder {
+ return "напоминание"
+ }
+ return "событие Домового"
+}
diff --git a/cmd/continuity-runtime/due_worker.go b/cmd/continuity-runtime/due_worker.go
new file mode 100644
index 0000000..6bff266
--- /dev/null
+++ b/cmd/continuity-runtime/due_worker.go
@@ -0,0 +1,183 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "time"
+
+ commitcontract "git.cin.su/m/Assistant/contracts/commit"
+ "git.cin.su/m/Assistant/contracts/continuity"
+ "git.cin.su/m/Assistant/platform/ids"
+)
+
+type dueReminderRepository interface {
+ DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error)
+ ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error)
+ PendingAttention(context.Context, int) ([]continuity.AttentionItem, error)
+ MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error)
+ MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error)
+ Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
+}
+
+type dueNotificationClient struct {
+ baseURL *url.URL
+ token string
+ client *http.Client
+}
+
+type dueNotifier interface {
+ NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error)
+}
+
+type dueNotificationRequest struct {
+ ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
+ ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
+}
+
+type dueNotificationResponse struct {
+ AuthorizedActionID commitcontract.AuthorizedActionID `json:"AuthorizedActionID"`
+ ActionIntentID commitcontract.ActionIntentID `json:"ActionIntentID"`
+ ActionAttemptID commitcontract.ActionAttemptID `json:"ActionAttemptID"`
+ State commitcontract.ActionState `json:"State"`
+}
+
+func newDueNotificationClient(rawURL, token string, client *http.Client) (dueNotificationClient, error) {
+ base, err := url.Parse(rawURL)
+ if err != nil || base.Scheme == "" || base.Host == "" || token == "" {
+ return dueNotificationClient{}, fmt.Errorf("invalid Commit due notification configuration")
+ }
+ if client == nil {
+ client = http.DefaultClient
+ }
+ return dueNotificationClient{baseURL: base, token: token, client: client}, nil
+}
+
+func (c dueNotificationClient) NotifyDue(ctx context.Context, item continuity.ProspectiveItem) (dueNotificationResponse, error) {
+ target := *c.baseURL
+ target.Path = "/v1/internal/continuity/notify-due"
+ payload, err := json.Marshal(dueNotificationRequest{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision})
+ if err != nil {
+ return dueNotificationResponse{}, err
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
+ if err != nil {
+ return dueNotificationResponse{}, err
+ }
+ request.Header.Set("Authorization", "Bearer "+c.token)
+ request.Header.Set("Content-Type", "application/json")
+ response, err := c.client.Do(request)
+ if err != nil {
+ return dueNotificationResponse{}, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusAccepted {
+ return dueNotificationResponse{}, fmt.Errorf("commit due notification status %d", response.StatusCode)
+ }
+ var result dueNotificationResponse
+ if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+ return dueNotificationResponse{}, err
+ }
+ if result.ActionIntentID == "" || result.AuthorizedActionID == "" || result.ActionAttemptID == "" {
+ return dueNotificationResponse{}, fmt.Errorf("invalid commit due notification response")
+ }
+ return result, nil
+}
+
+type dueReminderWorker struct {
+ repository dueReminderRepository
+ commit dueNotifier
+ now func() time.Time
+ newID func() (ids.ID, error)
+ interval time.Duration
+ limit int
+}
+
+func (w dueReminderWorker) Start(ctx context.Context) {
+ if w.interval <= 0 {
+ w.interval = 15 * time.Second
+ }
+ if w.limit <= 0 {
+ w.limit = 10
+ }
+ go func() {
+ w.process(ctx)
+ ticker := time.NewTicker(w.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ w.process(ctx)
+ }
+ }
+ }()
+}
+
+func (w dueReminderWorker) process(ctx context.Context) {
+ now := w.now()
+ if err := w.claimDue(ctx, now); err != nil {
+ log.Printf("due reminder claim skipped: %v", err)
+ }
+ if err := w.deliverPending(ctx, now); err != nil {
+ log.Printf("due reminder delivery skipped: %v", err)
+ }
+}
+
+func (w dueReminderWorker) claimDue(ctx context.Context, now time.Time) error {
+ items, err := w.repository.DueArmed(ctx, now.UnixMilli(), w.limit)
+ if err != nil {
+ return err
+ }
+ for _, item := range items {
+ id, err := w.newID()
+ if err != nil {
+ return err
+ }
+ if _, _, err = w.repository.ClaimDueReminder(ctx, item.ID, item.Revision, continuity.AttentionItemID(id), now.UnixMilli()); err != nil {
+ log.Printf("due reminder %s not claimed: %v", item.ID, err)
+ }
+ }
+ return nil
+}
+
+func (w dueReminderWorker) deliverPending(ctx context.Context, now time.Time) error {
+ items, err := w.repository.PendingAttention(ctx, w.limit)
+ if err != nil {
+ return err
+ }
+ for _, attention := range items {
+ if attention.SourceKind != continuity.AttentionSourceProspectiveReminder || attention.SourceProspectiveID == "" {
+ continue
+ }
+ delivering, err := w.repository.MarkAttentionDelivering(ctx, attention.ID, attention.Revision)
+ if err != nil {
+ log.Printf("attention %s not marked delivering: %v", attention.ID, err)
+ continue
+ }
+ prospective, err := w.repository.Load(ctx, delivering.SourceProspectiveID)
+ if err != nil {
+ log.Printf("attention %s source unavailable: %v", attention.ID, err)
+ continue
+ }
+ result, err := w.commit.NotifyDue(ctx, prospective)
+ if err != nil {
+ log.Printf("attention %s notification unavailable: %v", attention.ID, err)
+ continue
+ }
+ _, err = w.repository.MarkAttentionDelivered(ctx, delivering.ID, delivering.Revision,
+ continuity.CommittedActionIntentID(result.ActionIntentID),
+ continuity.CommittedAuthorizedActionID(result.AuthorizedActionID),
+ continuity.CommittedActionAttemptID(result.ActionAttemptID),
+ string(result.State), now.UnixMilli())
+ if err != nil {
+ log.Printf("attention %s delivery outcome not persisted: %v", attention.ID, err)
+ }
+ }
+ return nil
+}
diff --git a/cmd/continuity-runtime/due_worker_test.go b/cmd/continuity-runtime/due_worker_test.go
new file mode 100644
index 0000000..0d1cec6
--- /dev/null
+++ b/cmd/continuity-runtime/due_worker_test.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "git.cin.su/m/Assistant/contracts/authority"
+ commitcontract "git.cin.su/m/Assistant/contracts/commit"
+ "git.cin.su/m/Assistant/contracts/continuity"
+ "git.cin.su/m/Assistant/contracts/identity"
+ "git.cin.su/m/Assistant/platform/ids"
+)
+
+type memoryDueRepo struct {
+ item continuity.ProspectiveItem
+ attention continuity.AttentionItem
+}
+
+func (r *memoryDueRepo) DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error) {
+ if r.item.State == continuity.ProspectiveArmed {
+ return []continuity.ProspectiveItem{r.item}, nil
+ }
+ return nil, nil
+}
+
+func (r *memoryDueRepo) ClaimDueReminder(_ context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, now int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) {
+ if r.item.ID != id || r.item.Revision != revision || r.item.State != continuity.ProspectiveArmed {
+ return continuity.ProspectiveItem{}, continuity.AttentionItem{}, nil
+ }
+ r.item.State = continuity.ProspectiveDelivered
+ r.item.Revision += 3
+ r.attention = continuity.AttentionItem{ID: attentionID, OwnerPrincipalID: r.item.OwnerPrincipalID, ScopeID: r.item.ScopeID, SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: r.item.ID, Level: continuity.AttentionDue, State: continuity.AttentionPending, Summary: r.item.Summary, DueAtUnixMillis: r.item.TriggerAtUnixMillis, CreatedAtUnixMillis: now, Revision: 1}
+ return r.item, r.attention, nil
+}
+
+func (r *memoryDueRepo) PendingAttention(context.Context, int) ([]continuity.AttentionItem, error) {
+ if r.attention.State == continuity.AttentionPending {
+ return []continuity.AttentionItem{r.attention}, nil
+ }
+ return nil, nil
+}
+
+func (r *memoryDueRepo) MarkAttentionDelivering(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) {
+ if r.attention.ID == id && r.attention.Revision == revision {
+ r.attention.State = continuity.AttentionDelivering
+ r.attention.Revision++
+ }
+ return r.attention, nil
+}
+
+func (r *memoryDueRepo) MarkAttentionDelivered(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAt int64) (continuity.AttentionItem, error) {
+ if r.attention.ID == id && r.attention.Revision == revision {
+ r.attention.State = continuity.AttentionDelivered
+ r.attention.Revision++
+ r.attention.ActionIntentID = intentID
+ r.attention.AuthorizedActionID = authorizedID
+ r.attention.ActionAttemptID = attemptID
+ r.attention.DeliveryOutcome = outcome
+ r.attention.DeliveredAtUnixMillis = deliveredAt
+ }
+ return r.attention, nil
+}
+
+func (r *memoryDueRepo) Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) {
+ return r.item, nil
+}
+
+type fakeDueNotifier struct{ calls int }
+
+func (n *fakeDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) {
+ n.calls++
+ return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionSucceeded}, nil
+}
+
+func TestDueReminderProducesAttentionAndNotificationOnce(t *testing.T) {
+ repo := &memoryDueRepo{item: continuity.ProspectiveItem{ID: "prospective", OwnerPrincipalID: identity.PrincipalID("owner"), ScopeID: authority.ScopeID("scope"), Summary: "проверить уведомление", TriggerAtUnixMillis: 10, State: continuity.ProspectiveArmed, Revision: 1}}
+ notifier := &fakeDueNotifier{}
+ now := time.UnixMilli(20)
+ worker := dueReminderWorker{repository: repo, commit: notifier, now: func() time.Time { return now }, newID: func() (ids.ID, error) { return ids.ID("attention"), nil }, limit: 10}
+ worker.process(context.Background())
+ worker.process(context.Background())
+ if repo.item.State != continuity.ProspectiveDelivered || repo.attention.State != continuity.AttentionDelivered {
+ t.Fatalf("unexpected states: prospective=%s attention=%s", repo.item.State, repo.attention.State)
+ }
+ if notifier.calls != 1 {
+ t.Fatalf("expected one notification call, got %d", notifier.calls)
+ }
+ if repo.attention.ActionIntentID == "" || repo.attention.DeliveredAtUnixMillis == 0 {
+ t.Fatal("delivery evidence was not persisted")
+ }
+}
diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go
index db520fa..62f46de 100644
--- a/cmd/continuity-runtime/main.go
+++ b/cmd/continuity-runtime/main.go
@@ -11,6 +11,7 @@ import (
"git.cin.su/m/Assistant/adapters/postgres"
"git.cin.su/m/Assistant/contracts/continuity"
+ "git.cin.su/m/Assistant/platform/ids"
"git.cin.su/m/Assistant/runtime/health"
)
@@ -34,6 +35,13 @@ func main() {
if executorToken == "" {
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
}
+ commitClient, err := newDueNotificationClient(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), executorToken, &http.Client{Timeout: 10 * time.Second})
+ if err != nil {
+ log.Fatal(err)
+ }
+ workerContext, cancelWorker := context.WithCancel(context.Background())
+ defer cancelWorker()
+ dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext)
registerInternalExecutorRoutes(mux, repository, executorToken)
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
var item continuity.ProspectiveItem
@@ -104,6 +112,7 @@ func main() {
Items []continuity.ProspectiveItem
}{Items: items})
})
+ registerCreatorAttentionRoutes(mux, identityClient, repository)
log.Printf("continuity-runtime listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
diff --git a/cmd/core-runtime/capability_chat.go b/cmd/core-runtime/capability_chat.go
index 8e19776..df00f28 100644
--- a/cmd/core-runtime/capability_chat.go
+++ b/cmd/core-runtime/capability_chat.go
@@ -145,6 +145,24 @@ func buildReminderRequest(message string, session identity.CreatorSession, inten
func parseReminderTime(message string, now time.Time) (time.Time, error) {
lower := strings.ToLower(message)
+ relative := regexp.MustCompile(`через\s+(\d{1,3})\s*(минуту|минуты|минут|час|часа|часов)`)
+ if match := relative.FindStringSubmatch(lower); len(match) > 0 {
+ value, err := strconv.Atoi(match[1])
+ if err != nil || value < 1 {
+ return time.Time{}, fmt.Errorf("invalid relative reminder time")
+ }
+ unit := match[2]
+ if strings.HasPrefix(unit, "час") {
+ if value > 72 {
+ return time.Time{}, fmt.Errorf("relative reminder time is too far")
+ }
+ return now.Add(time.Duration(value) * time.Hour), nil
+ }
+ if value > 24*60 {
+ return time.Time{}, fmt.Errorf("relative reminder time is too far")
+ }
+ return now.Add(time.Duration(value) * time.Minute), nil
+ }
day := now
if strings.Contains(lower, "послезавтра") {
day = now.AddDate(0, 0, 2)
@@ -187,6 +205,8 @@ func reminderSummary(message string) string {
for _, word := range []string{"послезавтра", "завтра", "сегодня"} {
clean = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(clean), word, ""))
}
+ relative := regexp.MustCompile(`(?i)через\s+\d{1,3}\s*(минуту|минуты|минут|час|часа|часов)`)
+ clean = strings.TrimSpace(relative.ReplaceAllString(clean, ""))
re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`)
clean = strings.TrimSpace(re.ReplaceAllString(clean, ""))
clean = strings.Trim(clean, " .,!?:;")
diff --git a/cmd/core-runtime/capability_chat_test.go b/cmd/core-runtime/capability_chat_test.go
index f64b866..deb0168 100644
--- a/cmd/core-runtime/capability_chat_test.go
+++ b/cmd/core-runtime/capability_chat_test.go
@@ -29,6 +29,21 @@ func TestBuildReminderRequestParsesTomorrowAtTen(t *testing.T) {
}
}
+func TestBuildReminderRequestParsesRelativeMinutes(t *testing.T) {
+ now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.FixedZone("MSK", 3*60*60))
+ request, err := buildReminderRequest("Напомни мне через 3 минуты проверить уведомление.", identity.CreatorSession{PrincipalID: "principal-1"}, interaction.OriginalHumanIntent{ID: "intent-1"}, interaction.HumanUtterance{ID: "utterance-1"}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ trigger := time.UnixMilli(request.TriggerAtUnixMillis).In(now.Location())
+ if !trigger.Equal(now.Add(3 * time.Minute)) {
+ t.Fatalf("unexpected trigger: %s", trigger)
+ }
+ if request.Summary != "проверить уведомление" {
+ t.Fatalf("unexpected summary: %q", request.Summary)
+ }
+}
+
func TestContinuityReminderClientDoesNotAcceptFalseSuccess(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/reminders" || r.Header.Get("Authorization") != "Bearer session" {
diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go
index bac078c..9125440 100644
--- a/cmd/creator-web/server.go
+++ b/cmd/creator-web/server.go
@@ -494,6 +494,10 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
}
case method == http.MethodGet && path == "/api/prospective-items":
return targetContinuity, "/v1/creator/prospective-items", true
+ case method == http.MethodGet && path == "/api/attention":
+ return targetContinuity, "/v1/creator/attention", true
+ case method == http.MethodGet && path == "/api/today":
+ return targetContinuity, "/v1/creator/today", true
case method == http.MethodGet && path == "/api/actions":
return targetCommit, "/v1/creator/authorized-actions", true
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css
index 095c303..4c703c7 100644
--- a/cmd/creator-web/static/assets/chat.css
+++ b/cmd/creator-web/static/assets/chat.css
@@ -29,12 +29,18 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
-.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr) auto; }
+.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr); }
.chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); }
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
.chat-header span { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
#active-focus { margin-left: auto; color: var(--good); }
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
+.human-nav { display: flex; align-items: center; gap: 8px; }
+.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
+.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
+.operator-link { color: var(--good); }
+.surface-panel { min-height: 0; position: relative; }
+.active-panel { display: grid; min-height: 0; }
.messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: auto; }
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #151e1a; }
@@ -43,8 +49,25 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
.composer { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
.composer button { height: 48px; align-self: end; }
.composer button:disabled { cursor: wait; filter: saturate(.3); }
+.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
+.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
+.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
+.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
+.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
+.attention-list, .history-list { display: grid; gap: 12px; }
+.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
+.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
+.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
+.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
+.history-item.user { border-color: #34443c; background: #151e1a; }
+.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
+.empty-state { margin: 24px 0 0; color: var(--muted); }
+.empty-state.error { color: var(--bad); }
@media (max-width: 640px) {
.composer { grid-template-columns: 1fr; }
.composer button { width: 100%; }
+ .chat-header { gap: 10px; padding: 0 12px; }
.chat-header span { display: none; }
+ .human-nav { overflow-x: auto; }
+ .operator-link, .nav-tab { padding: 0 9px; }
}
diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js
index ba27c6c..b50f58c 100644
--- a/cmd/creator-web/static/assets/chat.js
+++ b/cmd/creator-web/static/assets/chat.js
@@ -8,9 +8,14 @@ const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const logoutButton = document.querySelector("#logout");
const activeFocus = document.querySelector("#active-focus");
+const todayList = document.querySelector("#today-list");
+const refreshToday = document.querySelector("#refresh-today");
+const historyList = document.querySelector("#history-list");
+const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
let token = sessionStorage.getItem("domovoy.chat.token") || "";
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
+let localHistory = [];
function showChat() {
loginView.hidden = true;
@@ -37,6 +42,8 @@ function appendMessage(kind, text, meta = "") {
}
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
+ localHistory.push({kind, text, meta, at: new Date()});
+ renderHistory();
}
function updateActiveFocus(value, provenance = "") {
@@ -52,6 +59,91 @@ async function api(path, options = {}) {
return response.json();
}
+function humanMeta(result) {
+ if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам.";
+ if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена.";
+ if (result.work_planned) return "Я разложил это как работу.";
+ return "";
+}
+
+function renderHistory() {
+ if (!historyList) return;
+ historyList.replaceChildren();
+ const items = localHistory.slice(-30).reverse();
+ if (!items.length) {
+ const empty = document.createElement("p");
+ empty.className = "empty-state";
+ empty.textContent = "История появится после сообщений в этом окне.";
+ historyList.appendChild(empty);
+ return;
+ }
+ for (const item of items) {
+ const row = document.createElement("article");
+ row.className = `history-item ${item.kind}`;
+ const title = document.createElement("strong");
+ title.textContent = item.kind === "user" ? "Вы" : "Домовой";
+ const body = document.createElement("p");
+ body.textContent = item.text;
+ const time = document.createElement("span");
+ time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
+ row.append(title, body, time);
+ historyList.appendChild(row);
+ }
+}
+
+async function loadToday() {
+ if (!todayList || !token) return;
+ todayList.replaceChildren();
+ const loading = document.createElement("p");
+ loading.className = "empty-state";
+ loading.textContent = "Смотрю, что требует внимания...";
+ todayList.appendChild(loading);
+ try {
+ const result = await api("/api/today", {method: "GET"});
+ todayList.replaceChildren();
+ const items = result.Items || result.items || [];
+ if (!items.length) {
+ const empty = document.createElement("p");
+ empty.className = "empty-state";
+ empty.textContent = "Сейчас ничего срочного не требует внимания.";
+ todayList.appendChild(empty);
+ return;
+ }
+ for (const item of items) {
+ const card = document.createElement("article");
+ card.className = "attention-card";
+ const when = document.createElement("time");
+ const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
+ when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас";
+ const title = document.createElement("h2");
+ title.textContent = item.Title || item.title || "Событие Домового";
+ const state = document.createElement("p");
+ state.textContent = item.HumanState || item.human_state || "ожидает внимания";
+ const source = document.createElement("small");
+ source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
+ card.append(when, title, state, source);
+ todayList.appendChild(card);
+ }
+ } catch {
+ todayList.replaceChildren();
+ const error = document.createElement("p");
+ error.className = "empty-state error";
+ error.textContent = "Внимание сейчас недоступно. Чат можно продолжать.";
+ todayList.appendChild(error);
+ }
+}
+
+function activateTab(panelID) {
+ for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
+ for (const panel of document.querySelectorAll(".surface-panel")) {
+ const active = panel.id === panelID;
+ panel.hidden = !active;
+ panel.classList.toggle("active-panel", active);
+ }
+ if (panelID === "today-panel") loadToday();
+ if (panelID === "history-panel") renderHistory();
+}
+
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
loginError.textContent = "";
@@ -79,12 +171,11 @@ chatForm.addEventListener("submit", async (event) => {
conversationID = result.conversation_id;
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
updateActiveFocus(result.active_focus, result.project_provenance);
- const semanticMeta = [result.speech_act, `cognition=${result.cognition}`];
- if (result.work_planned) semanticMeta.push("work=planned");
- if (result.capability_status) semanticMeta.push(`capability=${result.capability_kind || "unknown"}:${result.capability_status}`);
- if (result.reminder_scheduled) semanticMeta.push("reminder=scheduled");
- if (result.clarification) semanticMeta.push(`clarification=${result.clarification}`);
- appendMessage("assistant", result.reply_text || "Сообщение записано.", semanticMeta.join("; "));
+ appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
+ if (/что сейчас требует моего внимания/i.test(text)) {
+ await loadToday();
+ activateTab("today-panel");
+ }
} catch {
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
} finally {
@@ -93,6 +184,12 @@ chatForm.addEventListener("submit", async (event) => {
}
});
+for (const tab of navTabs) {
+ tab.addEventListener("click", () => activateTab(tab.dataset.tab));
+}
+
+if (refreshToday) refreshToday.addEventListener("click", loadToday);
+
logoutButton.addEventListener("click", () => {
token = "";
conversationID = "";
diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html
index a502d29..605fe98 100644
--- a/cmd/creator-web/static/chat.html
+++ b/cmd/creator-web/static/chat.html
@@ -25,16 +25,38 @@
-
-
+
+
+
+
Сегодня / внимание
+
+
+
+
+
+
+
История
+
Последние сообщения текущего окна
+
+
+
diff --git a/contracts/continuity/types.go b/contracts/continuity/types.go
index ca32991..bae1d9f 100644
--- a/contracts/continuity/types.go
+++ b/contracts/continuity/types.go
@@ -9,9 +9,12 @@ import (
type CommitmentID ids.ID
type ProspectiveItemID ids.ID
+type AttentionItemID ids.ID
type ProspectiveRevision uint64
+type AttentionRevision uint64
type CommittedActionIntentID ids.ID
type CommittedAuthorizedActionID ids.ID
+type CommittedActionAttemptID ids.ID
type ProspectiveState string
@@ -26,6 +29,34 @@ const (
ProspectiveExpired ProspectiveState = "EXPIRED"
)
+type AttentionLevel string
+
+const (
+ AttentionUrgent AttentionLevel = "URGENT"
+ AttentionActionRequired AttentionLevel = "ACTION_REQUIRED"
+ AttentionDue AttentionLevel = "DUE"
+ AttentionImportantChange AttentionLevel = "IMPORTANT_CHANGE"
+ AttentionFYI AttentionLevel = "FYI"
+ AttentionBackground AttentionLevel = "BACKGROUND"
+)
+
+type AttentionState string
+
+const (
+ AttentionPending AttentionState = "PENDING"
+ AttentionDelivering AttentionState = "DELIVERING"
+ AttentionDelivered AttentionState = "DELIVERED"
+ AttentionSuppressed AttentionState = "SUPPRESSED"
+ AttentionExpired AttentionState = "EXPIRED"
+ AttentionFailed AttentionState = "FAILED"
+)
+
+type AttentionSourceKind string
+
+const (
+ AttentionSourceProspectiveReminder AttentionSourceKind = "PROSPECTIVE_REMINDER"
+)
+
// ProspectiveItem is canonical future relevance. TriggerAtUnixMillis is an
// absolute instant; scheduling engines are derived delivery mechanisms only.
type ProspectiveItem struct {
@@ -56,6 +87,46 @@ type ScheduleReminderResult struct {
Item ProspectiveItem
}
+// AttentionItem is Continuity-owned human-facing relevance state. It can be
+// rendered by UIs, but it is not a notification receipt and does not itself
+// grant authority for external mutation.
+type AttentionItem struct {
+ ID AttentionItemID
+ OwnerPrincipalID identity.PrincipalID
+ ScopeID authority.ScopeID
+ SourceKind AttentionSourceKind
+ SourceProspectiveID ProspectiveItemID
+ Level AttentionLevel
+ State AttentionState
+ Summary string
+ DueAtUnixMillis int64
+ CreatedAtUnixMillis int64
+ DeliveredAtUnixMillis int64
+ ActionIntentID CommittedActionIntentID
+ AuthorizedActionID CommittedAuthorizedActionID
+ ActionAttemptID CommittedActionAttemptID
+ DeliveryOutcome string
+ Revision AttentionRevision
+}
+
+type AttentionListResult struct {
+ Items []AttentionItem
+}
+
+type TodayItem struct {
+ Title string
+ WhenUnixMillis int64
+ AttentionLevel AttentionLevel
+ AttentionState AttentionState
+ HumanState string
+ DeliveryState string
+ SourceDescription string
+}
+
+type TodayResult struct {
+ Items []TodayItem
+}
+
// SatisfyDeliveredSubject is the complete serializable operation subject. It
// is distinct from a Work TaskTarget, which remains the live justification.
type SatisfyDeliveredSubject struct {
diff --git a/database/bootstrap/001_roles_and_schemas.sql b/database/bootstrap/001_roles_and_schemas.sql
index 9a42984..82fb43d 100644
--- a/database/bootstrap/001_roles_and_schemas.sql
+++ b/database/bootstrap/001_roles_and_schemas.sql
@@ -564,6 +564,34 @@ CREATE TABLE IF NOT EXISTS continuity.action_receipts (
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (expected_revision > 0), CHECK (result_revision > expected_revision)
);
+CREATE TABLE IF NOT EXISTS continuity.attention_items (
+ attention_item_id TEXT PRIMARY KEY,
+ owner_principal_id TEXT NOT NULL,
+ scope_id TEXT NOT NULL,
+ source_kind TEXT NOT NULL,
+ source_prospective_item_id TEXT NOT NULL,
+ level TEXT NOT NULL,
+ state TEXT NOT NULL,
+ summary TEXT NOT NULL,
+ due_at_unix_millis BIGINT NOT NULL,
+ created_at_unix_millis BIGINT NOT NULL,
+ delivered_at_unix_millis BIGINT NOT NULL DEFAULT 0,
+ action_intent_id TEXT NOT NULL DEFAULT '',
+ authorized_action_id TEXT NOT NULL DEFAULT '',
+ action_attempt_id TEXT NOT NULL DEFAULT '',
+ delivery_outcome TEXT NOT NULL DEFAULT '',
+ revision BIGINT NOT NULL,
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (source_kind, source_prospective_item_id),
+ CHECK (source_kind IN ('PROSPECTIVE_REMINDER')),
+ CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')),
+ CHECK (state IN ('PENDING','DELIVERING','DELIVERED','SUPPRESSED','EXPIRED','FAILED')),
+ CHECK (summary <> ''),
+ CHECK (due_at_unix_millis > 0),
+ CHECK (created_at_unix_millis > 0),
+ CHECK (revision > 0),
+ CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> ''))
+);
RESET ROLE;
SET ROLE interaction_role;
diff --git a/database/migrations/020_continuity_attention_items.sql b/database/migrations/020_continuity_attention_items.sql
new file mode 100644
index 0000000..91ee8f6
--- /dev/null
+++ b/database/migrations/020_continuity_attention_items.sql
@@ -0,0 +1,35 @@
+BEGIN;
+
+SET ROLE continuity_role;
+
+CREATE TABLE IF NOT EXISTS continuity.attention_items (
+ attention_item_id TEXT PRIMARY KEY,
+ owner_principal_id TEXT NOT NULL,
+ scope_id TEXT NOT NULL,
+ source_kind TEXT NOT NULL,
+ source_prospective_item_id TEXT NOT NULL,
+ level TEXT NOT NULL,
+ state TEXT NOT NULL,
+ summary TEXT NOT NULL,
+ due_at_unix_millis BIGINT NOT NULL,
+ created_at_unix_millis BIGINT NOT NULL,
+ delivered_at_unix_millis BIGINT NOT NULL DEFAULT 0,
+ action_intent_id TEXT NOT NULL DEFAULT '',
+ authorized_action_id TEXT NOT NULL DEFAULT '',
+ action_attempt_id TEXT NOT NULL DEFAULT '',
+ delivery_outcome TEXT NOT NULL DEFAULT '',
+ revision BIGINT NOT NULL,
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (source_kind, source_prospective_item_id),
+ CHECK (source_kind IN ('PROSPECTIVE_REMINDER')),
+ CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')),
+ CHECK (state IN ('PENDING','DELIVERING','DELIVERED','SUPPRESSED','EXPIRED','FAILED')),
+ CHECK (summary <> ''),
+ CHECK (due_at_unix_millis > 0),
+ CHECK (created_at_unix_millis > 0),
+ CHECK (revision > 0),
+ CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> ''))
+);
+
+RESET ROLE;
+COMMIT;
diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json
index 217204a..cd6c2d7 100644
--- a/dev/ACTIVE_TASK.json
+++ b/dev/ACTIVE_TASK.json
@@ -1,5 +1,5 @@
{
- "task_file": "dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json",
- "task_id": "DEV-000064",
+ "task_file": "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json",
+ "task_id": "DEV-000065",
"task_revision": 1
}
diff --git a/dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json b/dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json
new file mode 100644
index 0000000..b45a968
--- /dev/null
+++ b/dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json
@@ -0,0 +1,152 @@
+{
+ "task_id": "DEV-000065",
+ "task_revision": 1,
+ "architecture_revision": "ADR-0014",
+ "base_repo_revision": "6e73318e49991b40a5ed750c604792cbd95bd325",
+ "risk_tier": "E",
+ "intent": "Add assistant presence through Continuity-owned due reminder processing, typed Attention foundation, governed outbound notification delivery, and a human-facing Assistant surface with Chat, Today/Attention, and History while preserving the Operator Console.",
+ "requirement_ids": [
+ "ADR-0014",
+ "ADR-0005",
+ "INV-AUTH-001",
+ "INV-OWN-001",
+ "INV-CONT-001",
+ "INV-EPI-001",
+ "Capability availability is not Authority",
+ "Durable future obligations survive runtime restart"
+ ],
+ "allowed_change_scope": {
+ "modules": [
+ "continuity",
+ "commit",
+ "capability",
+ "interaction"
+ ],
+ "paths": [
+ "architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md",
+ "architecture/architecture-registry.yaml",
+ "architecture/architecture-registry.json",
+ "contracts/continuity/**",
+ "contracts/commit/**",
+ "contracts/capability/**",
+ "modules/continuity/**",
+ "modules/commit/**",
+ "adapters/postgres/**",
+ "database/bootstrap/**",
+ "database/migrations/**",
+ "cmd/continuity-runtime/**",
+ "cmd/commit-runtime/**",
+ "cmd/core-runtime/**",
+ "cmd/creator-web/**",
+ "tests/owner_acl/**",
+ "tests/cognitive_advisory/**",
+ "docs/PROJECT_STATUS.md",
+ "dev/ACTIVE_TASK.json",
+ "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json"
+ ]
+ },
+ "forbidden_change_scope": {
+ "paths": [
+ "architecture/ARCHITECTURE_CONSTITUTION.md",
+ "security/**",
+ "ci/**",
+ ".gitea/**"
+ ]
+ },
+ "allowed_architecture_delta": {
+ "new_cross_module_dependencies": 1,
+ "new_canonical_owners": 0,
+ "new_privileges": 0,
+ "new_third_party_dependencies": 0,
+ "new_provider_adapters": 0
+ },
+ "contracts": {
+ "consume": [
+ "Continuity ProspectiveItem",
+ "Commit prospective notification operation",
+ "Capability notification binding",
+ "Interaction ChatMessage",
+ "Authority Creator grant",
+ "CredentialRef Secret Resolver foundation"
+ ],
+ "may_change": [
+ "contracts/continuity@v1",
+ "contracts/commit@v1"
+ ]
+ },
+ "semantic_owner": "continuity",
+ "canonical_state": [
+ "ProspectiveItem",
+ "AttentionItem"
+ ],
+ "state_transitions": [
+ "ProspectiveItem ARMED -> TRIGGERED -> REVALIDATING -> DELIVERED for due reminder processing",
+ "AttentionItem PENDING -> DELIVERING -> DELIVERED for human contact outcome",
+ "No ACTIVE cognitive binding promotion",
+ "No OpenAI activation"
+ ],
+ "side_effects_and_privileges": [
+ "Continuity runtime polls canonical prospective state and does not own external mutation",
+ "Commit runtime remains the only outbound notification executor boundary",
+ "Notification delivery uses existing CapabilityBinding and idempotent receiver protocol",
+ "Human Assistant UI reads canonical runtime APIs and hides internal identifiers by default",
+ "Docker-test remains prohibited"
+ ],
+ "persistence_and_migrations": [
+ "continuity owner-local attention_items table",
+ "no cross-owner FK",
+ "no cross-owner SQL join dependency",
+ "Commit authorized action and attempt persistence remains Commit-owned",
+ "Notification receipt persistence remains receiver-owned delivery evidence"
+ ],
+ "failure_retry_restart": [
+ "Due detection reads ARMED rows from canonical PostgreSQL state",
+ "CAS/unique source constraints prevent duplicate AttentionItem creation",
+ "Successful delivery moves AttentionItem to DELIVERED and prevents repeated delivery",
+ "Unavailable LLM does not block deterministic reminder firing",
+ "Unavailable notification delivery records non-success without inventing completion"
+ ],
+ "assumptions": [
+ "DEV-000064 is production baseline",
+ "Continuity is canonical owner of future obligations and attention state for this generation",
+ "Commit and existing notification receiver remain the outbound mutation path",
+ "The first human notification channel is the existing production receiver/Windows display path",
+ "CPU CANARY may remain available but reminder firing is deterministic and LLM-free"
+ ],
+ "acceptance_scenarios": [
+ "Reminder becomes due",
+ "Due reminder produces governed AttentionItem",
+ "Attention can produce notification through Commit",
+ "Delivery outcome is persisted",
+ "No duplicate delivery after success",
+ "Runtime restart does not lose an ARMED reminder",
+ "Cancelled/suppressed reminder is not delivered",
+ "LLM unavailable does not prevent deterministic reminder delivery",
+ "No cognitive model is required merely to fire a known reminder",
+ "CapabilityGap is rendered human-readable",
+ "Human UI requires no internal IDs",
+ "Operator Console remains available",
+ "Authority boundaries remain unchanged",
+ "Owner ACL passes",
+ "LLM-free audit passes",
+ "Production live delayed reminder delivers once",
+ "Today/Attention surface is built from canonical state"
+ ],
+ "required_tests": [
+ "go test ./...",
+ "go vet ./...",
+ "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
+ "go run ./cmd/devctl check-architecture",
+ "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
+ "tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1",
+ "tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1",
+ "production live delayed reminder acceptance",
+ "production /chat and /healthz verification"
+ ],
+ "terminal_gap_states": [
+ "BLOCKED_BY_ARCHITECTURE_GAP",
+ "BLOCKED_BY_SPEC_GAP",
+ "BLOCKED_BY_MISSING_CAPABILITY",
+ "BLOCKED_BY_CONFLICT"
+ ]
+}
diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md
index 3f3de21..2d6745b 100644
--- a/docs/PROJECT_STATUS.md
+++ b/docs/PROJECT_STATUS.md
@@ -1,6 +1,22 @@
# Assistant project status
-Audit date: 2026-08-17. Active implementation: DEV-000064 revision 1.
+Audit date: 2026-08-17. Active implementation: DEV-000065 revision 1.
+
+## DEV-000065 checkpoint
+
+- `DEV_000065_STATUS = IN_PROGRESS`
+- `LAST_COMPLETED = ADR-0014 accepted; Continuity-owned AttentionItem contract and persistence; due reminder scanner; Commit internal prospective notification endpoint; human-facing Chat / Today / History surface; relative reminder parsing for "через N минут"; deterministic due-delivery regression tests`
+- `ADR_STATUS = ADR-0014 accepted`
+- `OWNERSHIP_STATUS = no new owner; AttentionItem belongs to Continuity; notification delivery remains Commit/Capability/receiver path`
+- `REMINDER_LIFECYCLE_STATUS = ARMED -> TRIGGERED -> REVALIDATING -> DELIVERED implemented for due scanner`
+- `REMINDER_DELIVERY_STATUS = implemented through Commit deliver-prospective-notification-v1 path; production live verification pending`
+- `ATTENTION_STATUS = typed AttentionItem PENDING -> DELIVERING -> DELIVERED foundation implemented`
+- `HUMAN_SURFACE_STATUS = /chat remains primary; Today/Attention and History added; Operator Console preserved at /`
+- `AUTHORITY_STATUS = external notification still requires Commit authorization, current Creator authority context, and active Capability notification binding`
+- `LLM_FREE_STATUS = deterministic reminder firing does not require cognitive model; LLM-free env policy regression PASS`
+- `TESTS_PASS_SO_FAR = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1`
+- `PRODUCTION_STATUS = pending deploy/live acceptance`
+- `NEXT_STEP = apply migration, deploy affected services, run owner ACL and live delayed reminder acceptance, then promote if PASS`
## DEV-000064 checkpoint
diff --git a/modules/commit/domain/commit.go b/modules/commit/domain/commit.go
index 694852d..1ed07fb 100644
--- a/modules/commit/domain/commit.go
+++ b/modules/commit/domain/commit.go
@@ -44,10 +44,17 @@ func Reconcile(action commitcontract.AuthorizedAction, previous commitcontract.A
// Authorize is the Commit boundary. A proposal alone never reaches Executor.
func Authorize(proposal commitcontract.ActionProposal, context AuthorizationContext, validator ports.AuthorityValidator, authorizedID commitcontract.AuthorizedActionID, authorizedAtUnixMillis int64) (commitcontract.AuthorizedAction, error) {
if proposal.IntentID == "" || proposal.ActorID == "" || proposal.CapabilityID == capability.CapabilityID("") ||
- proposal.Target.TaskID == "" || proposal.Target.GoalID == "" || proposal.Target.ScopeID == "" || proposal.Target.Revision == 0 || proposal.Operation == "" || authorizedAtUnixMillis <= 0 {
+ proposal.Operation == "" || authorizedAtUnixMillis <= 0 {
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
}
- if proposal.ActorID != context.Binding.ActorID || proposal.Target.ScopeID != context.Scope.ID {
+ if proposal.ActorID != context.Binding.ActorID {
+ return commitcontract.AuthorizedAction{}, ErrInvalidProposal
+ }
+ if proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
+ if proposal.Target.ScopeID != "" && proposal.Target.ScopeID != context.Scope.ID {
+ return commitcontract.AuthorizedAction{}, ErrInvalidProposal
+ }
+ } else if proposal.Target.TaskID == "" || proposal.Target.GoalID == "" || proposal.Target.ScopeID == "" || proposal.Target.Revision == 0 || proposal.Target.ScopeID != context.Scope.ID {
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
}
if !allowedOperation(proposal, context) {
diff --git a/modules/continuity/domain/prospective.go b/modules/continuity/domain/prospective.go
index 88fb848..d9cb360 100644
--- a/modules/continuity/domain/prospective.go
+++ b/modules/continuity/domain/prospective.go
@@ -13,6 +13,7 @@ var (
ErrInvalidTransition = errors.New("invalid prospective item transition")
ErrInvalidSatisfaction = errors.New("invalid delivered satisfaction command")
ErrInvalidReminder = errors.New("invalid reminder request")
+ ErrInvalidAttention = errors.New("invalid attention item")
)
type Store struct {
@@ -50,6 +51,29 @@ func BuildReminder(request contract.ScheduleReminderRequest, id contract.Prospec
}, nil
}
+func BuildDueReminderAttention(item contract.ProspectiveItem, id contract.AttentionItemID, nowUnixMillis int64) (contract.AttentionItem, error) {
+ if id == "" || item.ID == "" || item.OwnerPrincipalID == "" || item.ScopeID == "" ||
+ strings.TrimSpace(item.Summary) == "" || item.TriggerAtUnixMillis < 1 || nowUnixMillis < 1 {
+ return contract.AttentionItem{}, ErrInvalidAttention
+ }
+ if item.State != contract.ProspectiveDelivered && item.State != contract.ProspectiveRevalidating {
+ return contract.AttentionItem{}, ErrInvalidAttention
+ }
+ return contract.AttentionItem{
+ ID: id,
+ OwnerPrincipalID: item.OwnerPrincipalID,
+ ScopeID: item.ScopeID,
+ SourceKind: contract.AttentionSourceProspectiveReminder,
+ SourceProspectiveID: item.ID,
+ Level: contract.AttentionDue,
+ State: contract.AttentionPending,
+ Summary: item.Summary,
+ DueAtUnixMillis: item.TriggerAtUnixMillis,
+ CreatedAtUnixMillis: nowUnixMillis,
+ Revision: 1,
+ }, nil
+}
+
func (s *Store) SatisfyDelivered(id contract.ProspectiveItemID, revision contract.ProspectiveRevision, evidenceRef string) (contract.ProspectiveItem, error) {
item, err := s.current(id, revision)
if err != nil {
diff --git a/modules/continuity/ports/repository.go b/modules/continuity/ports/repository.go
index e465763..07a6738 100644
--- a/modules/continuity/ports/repository.go
+++ b/modules/continuity/ports/repository.go
@@ -4,9 +4,16 @@ import (
"context"
"git.cin.su/m/Assistant/contracts/continuity"
+ "git.cin.su/m/Assistant/contracts/identity"
)
type Repository interface {
Save(context.Context, continuity.ProspectiveItem) error
Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
+ DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error)
+ ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error)
+ PendingAttention(context.Context, int) ([]continuity.AttentionItem, error)
+ MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error)
+ MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error)
+ AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error)
}