@staix/agent-hub 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
package/src/hub/tasks.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import { parseEvidenceRecord, parseEvidenceLink, evidenceLinkId, evidenceLinkCanonical, type EvidenceRecordInput, type EvidenceReceipt } from "./research-evidence.ts";
2
+ import type { ActionMeta, ReviewAnchor } from "@staix/agent-console/contract";
3
+ import { parseReviewAnchor, reviewAnchorErrorMessage, anchorStatus, type ReviewAnchorStatus } from "./review-anchor.ts";
4
+ import type { ReviewMaterialBatch } from "./review-material.ts";
5
+ import { isReviewObserverExcluded } from "./memory-policy.ts";
6
+ import { renderFindingAnchor } from "./review-brief.ts";
7
+ import { reviewCommentFields, reviewCommentNumber, reviewCommentId, reviewCommentIds, reviewCommentHead, reviewCommentBody, reviewCommentAuthor, privateReviewAnchor, reviewThreadProjection, renderReviewComment, REVIEW_PACKAGE_BYTES, REVIEW_COMMENT_LIMIT,
8
+ type ReviewCommentRecord, type ReviewCommentFence, type ReviewCommentMutation, type ReviewCommentCounts, type ReviewCommentView } from "./review-comments.ts";
1
9
  import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt } from "./research-runner.ts";
2
10
  import { approvalBinding, approvalText } from "./task-approval.ts";
3
11
  import type { InvocationRow } from "./invocations.ts";
@@ -12,7 +20,7 @@ import { parseExperimentProtocol, parseExperimentAttempt, parseExperimentEvent,
12
20
  import { parseComparisonPlan, comparisonPlanHash, comparisonProjection, evaluatorIdentityHash, protocolApprovalBindingMatches, type ComparisonPlanV1 } from "./research-experiments.ts";
13
21
  import { parseResearchHandoff, studyProjection, campaignHash, parseResearchCampaign, type ResearchCampaignContract, type ResearchCampaignStatus } from "./research-campaign.ts";
14
22
  import type { ResearchCampaignRecord, ResearchUnitBinding, PreparedResearchUnit, HandoffOutboundRow } from "./board.ts";
15
- import { HANDOFF_EVENTS, HandoffRefusal, followBackLine, handoffEventFor, parseHandoff, type HandoffEvent } from "./handoff.ts";
23
+ import { HANDOFF_EVENTS, HANDOFF_KEY, HandoffRefusal, handoffRequestDigest, duplicateIssueTasks, duplicateRefusalText, followBackLine, handoffEventFor, parseHandoff, type HandoffEvent } from "./handoff.ts";
16
24
  import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
17
25
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
18
26
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
@@ -46,6 +54,7 @@ import { foldSegment, isHubWritePath } from "./permission-mode.ts";
46
54
  import { correctionInput, resolveVisibleMemory } from "./memory-candidates.ts";
47
55
  import type { MemoryScreen } from "./read-tools.ts";
48
56
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
57
+ import { createMemorySyncPolicy } from "./memory-sync-policy.ts";
49
58
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
50
59
  import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
51
60
  import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, overridesDiff, parseCandidateCard, selectGroups, type CriterionGroup } from "./learning.ts";
@@ -63,8 +72,8 @@ import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverable
63
72
  import { isAbsolute, relative, resolve } from "node:path";
64
73
  import type { Briefs, NativeItem } from "../memory/brief.ts";
65
74
  import type { MemoryClient } from "../memory/client.ts";
66
- import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
67
- import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding, type SpecAck, type SpecTaskReport } from "./board.ts";
75
+ import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type NoteInput, type Scope, type Tier } from "@staix/agent-mem";
76
+ import { parseClaimVerdict, taskClaimIdentity, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding, type SpecAck, type SpecTaskReport } from "./board.ts";
68
77
  import type { Bus } from "./bus.ts";
69
78
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
70
79
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
@@ -85,6 +94,10 @@ import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type
85
94
  import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
86
95
  import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSdlcPrHead, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
87
96
 
97
+ /** #1176: refusals for an agent that reaches a person-run card by a path other than the pull gate. */
98
+ const PERSON_RUN_SPLIT = "a person-run card is the person's to split: its children would be unmarked cards an agent could take (the person's own split marks every child person-run)";
99
+ const PERSON_RUN_ACCEPT = (id: number) => `task #${id} is a person-run leg: no agent accepts it (it waits on the person: live receipts, their accounts or approvals). The person runs it or clears the mark with ahub backlog plan`;
100
+
88
101
  function requireLocalTaskPeer(peer: unknown): void {
89
102
  if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
90
103
  }
@@ -170,6 +183,10 @@ export interface TasksDeps {
170
183
  privateTurn?: (peer: PeerId) => boolean;
171
184
  /** #223 R4: the hub's reading of a verdict's context (`fresh` only for a reviewer session started for this review). */
172
185
  reviewContext?: (by: PeerId, task: number) => "fresh" | "continuing" | undefined;
186
+ /** Current-turn input carries review material or anchored findings; it expires on the next ordinary input. */
187
+ reviewObserverInput?: (peer: PeerId) => boolean;
188
+ /** Latch an anchor-bearing hub_review input before its first await or effect, even if the verdict is refused. */
189
+ onReviewObserverInput?: (peer: PeerId) => void;
173
190
  /** #852: what a reviewer reads. `code` (the fixed sandboxed reviewers by default, or a local `review_scope`) reads the code only; unset or `full` also runs tests and reads CI. */
174
191
  reviewScope?: (peer: PeerId) => ReviewScope;
175
192
  /** #223 R2: a reviewer-only instance gets review facts, not the builder's done summary. */
@@ -201,12 +218,16 @@ export interface TasksDeps {
201
218
  /** Hands one peer a line that rides on its next delivery, without a turn of its own (issue #6). */
202
219
  tell?: (peer: PeerId, line: string) => void;
203
220
  /** Structured overlap records for telemetry (issue #40); the notice line stays for the human. */
221
+ /** #1054: a completion check this peer owned just ended (its ownership is already cleared), so a run that waited on it may settle. Advisory: a throw is ignored. */
222
+ checkEnded?: (owner: PeerId) => void;
204
223
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
205
224
  /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
206
225
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
207
226
  /** Optional persisted task/run execution limits (issue #102). */
208
227
  executionBudget?: ExecutionBudget;
209
228
  /** Project-scoped immutable-source metadata, read only before a study transaction. */
229
+ /** Host-scoped immutable-head/material batch; supports host-staged completion refs/sdlc, never client-staged Task objects. The shared reader owns file authorization and budgets. */
230
+ reviewMaterials?: (task: Task, requests: readonly { path: string; side?: "base" | "head" }[]) => Promise<ReviewMaterialBatch>;
210
231
  studySource?: (id: string) => { id: string; sha256: string; private: boolean } | undefined;
211
232
  /** Host-only fixture clock; never read from model input or project configuration. */
212
233
  studyNow?: () => number;
@@ -450,7 +471,251 @@ export interface DeliverableRow {
450
471
  files: { id: string; path?: string; format: string; version?: string; sha256: string; bytes: number; private?: true; state?: DeliverableStatus["state"] }[];
451
472
  }
452
473
 
474
+ /** Detect an own anchor field without invoking model-provided accessors or proxy traps. */
475
+ function hasReviewAnchorInput(raw: unknown): boolean {
476
+ if (raw === undefined || !Array.isArray(raw)) return false;
477
+ if (types.isProxy(raw)) return true;
478
+ try {
479
+ if (Object.getPrototypeOf(raw) !== Array.prototype || Object.getOwnPropertySymbols(raw).length || raw.length > 64) return true;
480
+ const descriptors = Object.getOwnPropertyDescriptors(raw);
481
+ for (let index = 0; index < raw.length; index++) {
482
+ const entry = descriptors[String(index)];
483
+ if (!entry || !Object.hasOwn(entry, "value") || !entry.enumerable) return true;
484
+ const item: unknown = entry.value;
485
+ if (!item || typeof item !== "object") continue;
486
+ if (types.isProxy(item)) return true;
487
+ const prototype = Object.getPrototypeOf(item);
488
+ if (prototype !== Object.prototype && prototype !== null) return true;
489
+ if (Object.getOwnPropertyDescriptor(item, "anchor")) return true;
490
+ }
491
+ return false;
492
+ } catch { return true; }
493
+ }
494
+
453
495
  export class Tasks {
496
+ private readonly findingPlacements = new WeakMap<ReviewReadiness, { binding: string; placements: Map<string, ReviewAnchorStatus>; placementOnly: boolean }>();
497
+ private findingPlacementBinding(task: Task): string {
498
+ return JSON.stringify([task.id, task.created, this.reviewHead(task), task.refs.paths, task.plan, this.reviewFindingSets(task)]);
499
+ }
500
+ private async prepareFindingPlacements(task: Task, refs: TaskRefs | undefined, guard: () => unknown): Promise<Map<string, ReviewAnchorStatus>> {
501
+ const staged = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } }, placements = new Map<string, ReviewAnchorStatus>();
502
+ const findings = findingsForHead(this.reviewFindingSets(staged), this.findingHead(staged)).filter(item => item.anchor && !this.reviewPathPrivate(item.anchor.path) && this.findingTextAllowed(staged, [item]));
503
+ if (this.isPii(staged) || !findings.length || !this.d.reviewMaterials) return placements;
504
+ try {
505
+ const batch = await this.reviewBatch(staged, findings.map(item => item.anchor!));
506
+ guard();
507
+ if (batch.head === this.reviewHead(staged)) for (const item of findings) placements.set(item.id, this.reviewPlacement(item.anchor!, batch));
508
+ } catch { guard(); } // Optional placement failure is unavailable, never invented coordinates.
509
+ return placements;
510
+ }
511
+ private reviewObserverTurn(by: PeerId, task?: number): boolean {
512
+ try {
513
+ if (this.d.reviewObserverInput?.(by) === true || this.d.bus.lastDeliveredTo(by).some(isReviewObserverExcluded)) return true;
514
+ if (task === undefined) return false;
515
+ return this.d.board.hasReviewObserverScope(by, task) || this.d.bus.hasUnsettledReviewScope(by, task);
516
+ } catch { return true; }
517
+ }
518
+ async reviewCommentPut(by: PeerId, input: unknown, meta?: ActionMeta): Promise<ReviewCommentMutation> {
519
+ const author = this.reviewPerson(by, meta), check = this.requestCheck(by, () => this.reviewCommentHold());
520
+ const fields = this.reviewInput(input, ["task", "id", "revision", "commit", "anchor", "body"]);
521
+ let taskId: number, id: string, expected: number | undefined, commit: string, anchor: ReviewAnchor, body: string;
522
+ try {
523
+ taskId = reviewCommentNumber(fields.task, "task"); id = fields.id === undefined ? `comment-${crypto.randomUUID()}` : reviewCommentId(fields.id);
524
+ expected = fields.id === undefined ? undefined : reviewCommentNumber(fields.revision, "revision");
525
+ if (fields.id === undefined && fields.revision !== undefined) throw new Error("unexpected revision");
526
+ } catch { throw new PreEffectToolRefusal("review task, comment id or revision is invalid; refresh the task review and use its current id and revision, or omit both for a new draft"); }
527
+ try { commit = reviewCommentHead(fields.commit); }
528
+ catch { throw new PreEffectToolRefusal("review commit must be a current full lowercase SHA; refresh the task review before saving"); }
529
+ try { anchor = parseReviewAnchor(fields.anchor); }
530
+ catch (error) { throw new PreEffectToolRefusal(`${reviewAnchorErrorMessage(error)}; refresh the review and recreate the draft`); }
531
+ try { body = reviewCommentBody(fields.body); }
532
+ catch { throw new PreEffectToolRefusal("review comment body must be nonempty and at most 2048 UTF-8 bytes; edit the body before saving"); }
533
+ return this.commentWrite(async () => {
534
+ check(); const before = this.reviewContext(taskId), rows = this.d.board.reviewComments(taskId, before.task.created), old = rows.find(row => row.thread.id === id);
535
+ if (expected !== undefined && (!old || old.lifecycle !== "draft" || old.revision !== expected)) throw new PreEffectToolRefusal("review draft changed or was sent; refresh the draft list before editing");
536
+ if (old && old.commit !== commit) throw new PreEffectToolRefusal("draft is anchored to an earlier head; delete it and create a new draft against the current task head");
537
+ if (expected === undefined && rows.length >= REVIEW_COMMENT_LIMIT) throw new PreEffectToolRefusal("task review already has 64 comments; remove an unused draft before adding another");
538
+ const strip = () => { if (this.reviewPathPrivate(anchor.path)) { try { anchor = privateReviewAnchor(anchor); } catch { throw new PreEffectToolRefusal("private review path needs an existing lineRange or structural selector; select a safe anchor before retrying"); } } };
539
+ strip(); const privateRecord = this.reviewTaskPrivate(before.task) || this.reviewPathPrivate(anchor.path), batch = privateRecord ? undefined : await this.reviewBatch(before.task, [anchor]);
540
+ if (batch && batch.head !== commit) throw new PreEffectToolRefusal("review task head moved; delete this draft and recreate it against the current full commit");
541
+ if (!privateRecord) {
542
+ const material = batch!.materials.find(row => row.path === anchor.path && row.side === (anchor.side ?? "head"));
543
+ if (!material || material.state !== "available") throw new PreEffectToolRefusal("review anchor material is unavailable; refresh the task review and choose a currently available file before saving");
544
+ if (anchor.contentHash !== material.contentHash || JSON.stringify(anchor.projection ?? null) !== JSON.stringify(material.projection ?? null)) throw new PreEffectToolRefusal("review anchor content hash or projection differs from the immutable file; refresh the review and recreate the draft on the current material");
545
+ }
546
+ check(); const current = this.reviewContext(taskId);
547
+ if (current.key !== before.key || this.reviewHead(current.task) !== commit || batch && batch.head !== commit) throw new PreEffectToolRefusal("review task head changed; delete this draft and recreate it against the current head before saving");
548
+ strip(); const record: ReviewCommentRecord = { task: taskId, taskCreated: current.task.created, commit, lifecycle: "draft", revision: (old?.revision ?? 0) + 1, private: false,
549
+ thread: { id, anchor, body, author, at: new Date(this.d.now?.() ?? Date.now()).toISOString() } };
550
+ record.private = this.reviewRecordPrivate(current.task, record);
551
+ check(); const saved = this.d.board.putReviewComment(record, current.fence, expected);
552
+ return { task: taskId, id, revision: saved.revision, lifecycle: saved.lifecycle };
553
+ });
554
+ }
555
+ async reviewCommentDelete(by: PeerId, input: unknown, meta?: ActionMeta): Promise<ReviewCommentMutation> {
556
+ this.reviewPerson(by, meta); const check = this.requestCheck(by, () => this.reviewCommentHold()), fields = this.reviewInput(input, ["task", "id", "revision"]);
557
+ let task: number, id: string, revision: number;
558
+ try { task = reviewCommentNumber(fields.task, "task"); id = reviewCommentId(fields.id); revision = reviewCommentNumber(fields.revision, "revision"); }
559
+ catch { throw new PreEffectToolRefusal("review delete identity is invalid; refresh the list and use its current id and revision"); }
560
+ return this.commentWrite(async () => {
561
+ check(); const context = this.reviewContext(task), record = this.d.board.reviewComments(task, context.task.created).find(row => row.thread.id === id);
562
+ if (!record || record.lifecycle !== "dismissed" && record.revision !== revision) throw new PreEffectToolRefusal("review comment changed; refresh its current revision before deleting or dismissing");
563
+ const result = this.d.board.deleteReviewComment(id, revision, context.fence);
564
+ return { task, id, revision: result.record.revision, ...(result.deleted ? { deleted: true as const } : { lifecycle: result.record.lifecycle }) };
565
+ });
566
+ }
567
+ async reviewCommentList(by: PeerId, input: unknown): Promise<{ task: number; counts: ReviewCommentCounts; comments: ReviewCommentView[]; packages: import("./review-comments.ts").ReviewCommentPackageView[] }> {
568
+ if (this.commentsStopped) throw new PreEffectToolRefusal("review comments are stopping; reopen the task review after the hub restarts");
569
+ this.reviewPerson(by); const fields = this.reviewInput(input, ["task"]), taskId = reviewCommentNumber(fields.task, "task"), before = this.reviewContext(taskId);
570
+ const counts = this.d.board.reviewCommentCounts(taskId, before.task.created), records = this.d.board.reviewComments(taskId, before.task.created);
571
+ const packages = this.d.board.reviewCommentPackages(taskId, before.task.created);
572
+ if (this.reviewTaskPrivate(before.task)) return { task: taskId, counts, comments: [], packages };
573
+ const candidates = records.filter(row => !this.reviewRecordPrivate(before.task, row));
574
+ if (!candidates.length) return { task: taskId, counts, comments: [], packages };
575
+ let batch: ReviewMaterialBatch;
576
+ try { batch = await this.reviewBatch(before.task, candidates.map(row => row.thread.anchor)); } catch { batch = { head: "", materials: [] }; }
577
+ if (this.commentsStopped) return { task: taskId, counts, comments: [], packages };
578
+ const current = this.reviewContext(taskId);
579
+ if (current.key !== before.key) throw new PreEffectToolRefusal("review task changed while reading; refresh the task review");
580
+ const latest = this.d.board.reviewComments(taskId, current.task.created);
581
+ if (latest.length !== records.length || records.some(row => !latest.some(fresh => fresh.thread.id === row.thread.id && fresh.revision === row.revision))) throw new PreEffectToolRefusal("review comments changed while reading; refresh the task review");
582
+ const comments = candidates.flatMap<ReviewCommentView>(record => {
583
+ const placement = this.reviewPlacement(record.thread.anchor, batch);
584
+ if (this.reviewRecordPrivate(current.task, record)) return [];
585
+ if (placement.status === "unavailable") return [{ thread: reviewThreadProjection(record), task: taskId, commit: record.commit, lifecycle: record.lifecycle, revision: record.revision, ...(record.sentAt ? { sentAt: record.sentAt } : {}), placement: { status: "unavailable" } }];
586
+ return [{ thread: reviewThreadProjection(record), task: taskId, commit: record.commit, lifecycle: record.lifecycle, revision: record.revision, ...(record.sentAt ? { sentAt: record.sentAt } : {}), placement }];
587
+ });
588
+ return { task: taskId, counts, comments, packages };
589
+ }
590
+ async reviewCommentSend(by: PeerId, input: unknown, meta?: ActionMeta): Promise<{ task: number; packageId: string; count: number; delivery: "queued" | "unconfirmed" }> {
591
+ this.reviewPerson(by, meta); const check = this.requestCheck(by, () => this.reviewCommentHold()), fields = this.reviewInput(input, ["task", "ids", "packageId"]), taskId = reviewCommentNumber(fields.task, "task");
592
+ if (fields.packageId !== undefined) {
593
+ if (fields.ids !== undefined) throw new PreEffectToolRefusal("choose either saved packageId or draft ids; refresh the person review and retry one operation");
594
+ let packageId: string;
595
+ try { if (typeof fields.packageId !== "string" || !/^review-[a-f0-9-]{36}$/.test(fields.packageId)) throw new Error(); packageId = fields.packageId; }
596
+ catch { throw new PreEffectToolRefusal("review package id is invalid; select a saved package from the current person review list"); }
597
+ return this.commentWrite(async () => {
598
+ check(); const context = this.reviewContext(taskId), packet = this.d.board.reviewCommentPackage(packageId);
599
+ if (!packet || packet.task !== taskId || packet.taskCreated !== context.task.created || packet.owner !== context.task.owner) throw new PreEffectToolRefusal("saved review package is unavailable for this task owner; refresh the task review and inspect the existing package history");
600
+ if (!packet.body || !packet.envelopeId || !packet.trace || packet.ts === undefined) throw new PreEffectToolRefusal(`saved review package ${packageId} predates recoverable delivery; inspect the owner's queue and sent comments before drafting a new package`);
601
+ if (!context.task.owner || context.task.owner === USER || context.task.owner === HUB) throw new PreEffectToolRefusal("saved review package has no current agent owner; assign the open task before retrying");
602
+ if (isClosedTaskState(context.task.state)) throw new PreEffectToolRefusal("review package cannot be sent because the task is closed; reopen the task review before sending");
603
+ const env = { id: packet.envelopeId, trace: packet.trace, hop: 0, from: HUB, to: [packet.owner], kind: "review" as const, priority: "important" as const, body: packet.body,
604
+ refs: { task: String(taskId), taskRecipient: "owner" as const, reviewPackage: packageId }, ts: packet.ts };
605
+ check();
606
+ const status = this.d.bus.reviewEnvelopeStatus(env.id);
607
+ if (status === "unknown") throw new PreEffectToolRefusal("saved review package delivery history is unavailable; inspect ahub queue list --peer " + packet.owner + " and sent comments before retrying");
608
+ if (status === "failed" || status === "discarded") throw new PreEffectToolRefusal(`saved review package delivery is ${status}; inspect ahub queue list --peer ${packet.owner} and ahub queue show <delivery-id> before retrying`);
609
+ if (status === "absent") {
610
+ if (packet.publicationEvidenceVersion !== 1) throw new PreEffectToolRefusal(`saved review package ${packageId} has no retained delivery proof; inspect the owner's queue and sent comments before retrying`);
611
+ if (packet.visibleIds === undefined) throw new PreEffectToolRefusal("saved review package has no privacy binding; inspect the owner's queue and sent comments, then dismiss or recreate the package");
612
+ const records = this.d.board.reviewComments(taskId, context.task.created);
613
+ if (packet.ids.some(id => records.find(row => row.thread.id === id)?.lifecycle !== "sent")) throw new PreEffectToolRefusal("saved review package comments are no longer all sent; inspect the owner's queue and dismiss or recreate the package");
614
+ if (packet.visibleIds.length && (this.reviewTaskPrivate(context.task)
615
+ || packet.visibleIds.some(id => { const record = records.find(row => row.thread.id === id); return !record || this.reviewRecordPrivate(context.task, record); }))) {
616
+ throw new PreEffectToolRefusal("saved review package is private under current policy; inspect the owner's queue, dismiss the old comments, and create a new package only after review privacy is clear");
617
+ }
618
+ }
619
+ try {
620
+ const targets = this.d.bus.publishReviewEnvelope(env);
621
+ if (!targets.includes(packet.owner)) throw new Error("owner not queued");
622
+ } catch {
623
+ const current = this.d.bus.reviewEnvelopeStatus(env.id);
624
+ if (current === "failed" || current === "discarded") throw new PreEffectToolRefusal(`saved review package delivery is ${current}; inspect ahub queue list --peer ${packet.owner} and ahub queue show <delivery-id> before retrying`);
625
+ throw new PreEffectToolRefusal("saved review package delivery history is unavailable or publication failed; inspect ahub queue list --peer " + packet.owner + " and sent comments before retrying");
626
+ }
627
+ this.d.board.markReviewCommentPackageQueued(packageId);
628
+ return { task: taskId, packageId, count: packet.ids.length, delivery: "queued" };
629
+ });
630
+ }
631
+ let ids: string[] | undefined;
632
+ try { ids = fields.ids === undefined ? undefined : reviewCommentIds(fields.ids); } catch { throw new PreEffectToolRefusal("review send ids are invalid; select current distinct draft ids"); }
633
+ return this.commentWrite(async () => {
634
+ check(); const before = this.reviewContext(taskId), owner = before.task.owner;
635
+ if (!owner || owner === USER || owner === HUB) throw new PreEffectToolRefusal("review package needs an agent owner; assign the task before sending comments");
636
+ if (isClosedTaskState(before.task.state)) throw new PreEffectToolRefusal("review comments can only be sent while the task is open; reopen the task review before sending");
637
+ const records = this.d.board.reviewComments(taskId, before.task.created), selected = ids === undefined ? records.filter(row => row.lifecycle === "draft") : ids.map(id => records.find(row => row.thread.id === id));
638
+ if (!selected.length || selected.some(row => !row || row.lifecycle !== "draft")) throw new PreEffectToolRefusal("review send needs current draft comments; refresh the list and select draft ids");
639
+ const drafts = selected as ReviewCommentRecord[], privateTask = this.reviewTaskPrivate(before.task), publicDrafts = drafts.filter(row => !this.reviewRecordPrivate(before.task, row));
640
+ const batch = privateTask || !publicDrafts.length ? undefined : await this.reviewBatch(before.task, publicDrafts.map(row => row.thread.anchor));
641
+ check(); const current = this.reviewContext(taskId);
642
+ if (current.key !== before.key) throw new PreEffectToolRefusal("review task or owner changed; refresh the review before sending");
643
+ const latest = this.d.board.reviewComments(taskId, current.task.created);
644
+ if (drafts.some(row => { const fresh = latest.find(value => value.thread.id === row.thread.id); return !fresh || fresh.lifecycle !== "draft" || fresh.revision !== row.revision; })) throw new PreEffectToolRefusal("review drafts changed; refresh the list before sending");
645
+ const unavailable = privateTask ? [] : publicDrafts.filter(row => this.reviewPlacement(row.thread.anchor, batch!).status === "unavailable");
646
+ if (unavailable.length) throw new PreEffectToolRefusal(`review material is unavailable for ${unavailable.map(row => row.thread.id).join(", ")}; refresh the task review and retry after the anchored file is available`);
647
+ const packageId = `review-${crypto.randomUUID()}`, envelopeId = crypto.randomUUID(), trace = crypto.randomUUID(), ts = Date.now();
648
+ const body = privateTask ? `Task #${taskId}: ${drafts.length} person review comments are kept on the board; private text is withheld.`
649
+ : [`Person review comments for task #${taskId} (${drafts.length}). Address these comments; report resolved ids in hub_task_done.`, ...drafts.map(row => {
650
+ if (this.reviewRecordPrivate(current.task, row)) return renderReviewComment(row, { status: "unavailable" }, false);
651
+ return renderReviewComment(row, this.reviewPlacement(row.thread.anchor, batch!), true);
652
+ })].join("\n\n");
653
+ if (Buffer.byteLength(body, "utf8") > REVIEW_PACKAGE_BYTES) throw new PreEffectToolRefusal("review package exceeds 12000 presentation bytes; select fewer ids before sending");
654
+ let packageHead: string;
655
+ try { packageHead = reviewCommentHead(batch?.head ?? this.reviewHead(current.task)); }
656
+ catch { throw new PreEffectToolRefusal("review package has no current immutable head; refresh the task review before sending"); }
657
+ check(); this.d.board.sendReviewComments(drafts, { id: packageId, task: taskId, taskCreated: current.task.created, ids: drafts.map(row => row.thread.id),
658
+ visibleIds: privateTask ? [] : publicDrafts.map(row => row.thread.id), owner, head: packageHead, at: new Date(this.d.now?.() ?? Date.now()).toISOString(), body,
659
+ envelopeId, trace, ts, delivery: "unconfirmed", publicationEvidenceVersion: 1 }, current.fence);
660
+ let delivery: "queued" | "unconfirmed" = "unconfirmed";
661
+ this.releaseNotice(`task #${taskId}: review package ${packageId} publication unconfirmed; inspect ahub queue list --peer ${owner} before any retry`, () => {
662
+ try { const targets = this.d.bus.publishResult({ id: envelopeId, trace, hop: 0, from: HUB, to: [owner], kind: "review", priority: "important", body,
663
+ refs: { task: String(taskId), taskRecipient: "owner", reviewPackage: packageId }, ts }); if (!targets.targets.includes(owner)) throw new Error("not queued"); this.d.board.markReviewCommentPackageQueued(packageId); delivery = "queued"; }
664
+ catch { throw new Error("publication may be unconfirmed; inspect the saved queue before any explicit retry"); }
665
+ });
666
+ return { task: taskId, packageId, count: drafts.length, delivery };
667
+ });
668
+ }
669
+ private commentWrites = 0;
670
+ private commentsStopped = false;
671
+ private readonly commentDrain = new Set<() => void>();
672
+ reviewCommentsPending = (): number => this.commentWrites;
673
+ async stopReviewComments(): Promise<void> { this.commentsStopped = true; if (this.commentWrites) await new Promise<void>(resolve => this.commentDrain.add(resolve)); }
674
+ private reviewCommentHold(): string | undefined {
675
+ try { if (this.commentsStopped || this.d.cancellationHeld?.() || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return "review comment writes are held; wait for recovery or shutdown to settle before retrying"; }
676
+ catch { return "review comment admission is unavailable; inspect ahub status before retrying"; }
677
+ return undefined;
678
+ }
679
+ private async commentWrite<T>(write: () => Promise<T>): Promise<T> {
680
+ this.commentWrites++;
681
+ try { return await write(); } finally { if (--this.commentWrites === 0) { for (const resolve of this.commentDrain) resolve(); this.commentDrain.clear(); } }
682
+ }
683
+ private reviewPerson(by: PeerId, meta?: ActionMeta): string {
684
+ if (by !== USER) throw new PreEffectToolRefusal("only the console person may change review comments; use the person's task review surface");
685
+ try { return reviewCommentAuthor(meta); } catch { throw new PreEffectToolRefusal("verified person identity is unavailable; reopen the authenticated console review before retrying"); }
686
+ }
687
+ private reviewTaskPrivate(task: Task): boolean {
688
+ try { return task.signals.includes("pii") || task.signals.includes("pii_path") || this.isPii(task); } catch { return true; }
689
+ }
690
+ private reviewPathPrivate(path: string): boolean {
691
+ try { return isDenied(path, this.d.deny) || matchesPolicy(this.d.cwd, path, this.d.piiPaths ?? [], true) || this.publicMetadataText(path) !== true; } catch { return true; }
692
+ }
693
+ private reviewRecordPrivate(task: Task, record: ReviewCommentRecord): boolean {
694
+ try { return record.private || this.reviewTaskPrivate(task) || this.reviewPathPrivate(record.thread.anchor.path) || this.publicMetadataText(JSON.stringify(record.thread)) !== true; } catch { return true; }
695
+ }
696
+ private reviewContext(id: unknown): { task: Task; fence: ReviewCommentFence; key: string } {
697
+ const task = this.need(id, false, true, true), fence: ReviewCommentFence = { task: task.id, taskCreated: task.created, flow: task.history.length, owner: task.owner, ...(this.reviewHead(task) ? { head: this.reviewHead(task) } : {}) };
698
+ return { task, fence, key: JSON.stringify([fence, this.reviewTaskPrivate(task)]) };
699
+ }
700
+ private reviewInput(input: unknown, keys: readonly string[]): Record<string, unknown> {
701
+ try { return reviewCommentFields(input, keys); } catch { throw new PreEffectToolRefusal("review comment input is invalid; use only the operation's current task, ids, revision and bounded comment fields"); }
702
+ }
703
+ private async reviewBatch(task: Task, anchors: readonly ReviewAnchor[]): Promise<ReviewMaterialBatch> {
704
+ if (!this.d.reviewMaterials) throw new PreEffectToolRefusal("review material reader is unavailable; reopen the task review with the current hub before retrying");
705
+ const requests = [...new Map(anchors.map(anchor => [JSON.stringify([anchor.path, anchor.side ?? "head"]), { path: anchor.path, ...(anchor.side ? { side: anchor.side } : {}) }])).values()];
706
+ try { const batch = await this.d.reviewMaterials(task, requests); reviewCommentHead(batch.head); return batch; }
707
+ catch { throw new PreEffectToolRefusal("review material or immutable head is unavailable; refresh the task review before retrying"); }
708
+ }
709
+ private reviewPlacement(anchor: ReviewAnchor, batch: ReviewMaterialBatch): ReviewAnchorStatus {
710
+ const material = batch.materials.find(row => row.path === anchor.path && row.side === (anchor.side ?? "head"));
711
+ if (!material) return { status: "unavailable" };
712
+ try { return anchorStatus(anchor, material); } catch { return { status: "unavailable" }; }
713
+ }
714
+ private reviewCommentResolve(by: PeerId, task: Task, input: unknown): string[] | undefined {
715
+ if (input === undefined) return undefined;
716
+ try { const ids = reviewCommentIds(input); if (ids.length && by !== task.owner) throw new Error("not owner"); this.d.board.reviewCommentResolution(task, ids); return ids; }
717
+ catch { throw new PreEffectToolRefusal("comment resolution requires the task owner and sent comment ids; refresh the task review before hub_task_done"); }
718
+ }
454
719
  private readonly planReviewRequests = new Map<number, string>();
455
720
  private readonly repositories: TaskRepositories;
456
721
  private readonly incomingBacklog = new Set<number>();
@@ -757,7 +1022,8 @@ export class Tasks {
757
1022
 
758
1023
  private findingTextAllowed(task: Task, items: readonly ReviewFinding[]): boolean {
759
1024
  return this.candidateTextAllowed(JSON.stringify(items), task)
760
- && items.every(item => !item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true));
1025
+ && items.every(item => (!item.path || !matchesPolicy(this.d.cwd, item.path, this.d.piiPaths ?? [], true))
1026
+ && (!item.anchor || !this.reviewPathPrivate(item.anchor.path)));
761
1027
  }
762
1028
 
763
1029
  /** Host-authored proposals contain only cleared IDs, integer task references and fixed instruction targets. */
@@ -913,6 +1179,14 @@ export class Tasks {
913
1179
  for (const id of created) this.releaseNotice(`task #${id}: recurring review finding amendment proposed in the backlog`);
914
1180
  }
915
1181
 
1182
+ private publicClaimBindings(task: Task): ReturnType<typeof taskClaimIdentity>[] | undefined {
1183
+ const claims = task.plan?.claims;
1184
+ if (task.profile !== "research" || task.signals.includes("pii") || task.signals.includes("pii_path")
1185
+ || !Array.isArray(claims) || !claims.length || claims.length > 50 || claims.some(value => typeof value !== "string")) return undefined;
1186
+ try { return claims.map((_claim, index) => taskClaimIdentity(task, index + 1)); }
1187
+ catch { return undefined; } // Optional public metadata never exposes a partial or malformed binding.
1188
+ }
1189
+
916
1190
  /** A task as a cloud peer may see it. */
917
1191
 
918
1192
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
@@ -920,7 +1194,8 @@ export class Tasks {
920
1194
  const plannerBinding = history.findLast(h => h.planner === task.planner && h.plannerReason !== undefined);
921
1195
  const ownerBinding = history.findLast(h => h.owner === task.owner && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(h.event));
922
1196
  const reviewerBinding = history.findLast(h => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(h.event));
923
- const rest = { ...taskRest,
1197
+ const reviewComments = this.d.board.reviewCommentCounts(task.id, task.created);
1198
+ const rest = { ...taskRest, ...(Object.values(reviewComments).some(Boolean) ? { reviewComments } : {}),
924
1199
  ...(plannerBinding?.plannerReason && ["rotation", "named", "kept", "none"].includes(plannerBinding.plannerReason) ? { plannerReason: plannerBinding.plannerReason } : {}),
925
1200
  ...(ownerBinding?.reason && TASK_MOVE_REASONS.includes(ownerBinding.reason) ? { ownerReason: ownerBinding.reason === "manual" && ownerBinding.event === "reassigned" && ownerBinding.by !== HUB ? "named" : ownerBinding.reason } : {}),
926
1201
  ...(reviewerBinding?.reviewerReason && ["rotation", "implementer skip", "unavailable skip", "same-family fallback"].includes(reviewerBinding.reviewerReason) ? { reviewerReason: reviewerBinding.reviewerReason } : reviewerBinding?.event === "reviewer changed" && reviewerBinding.reason === "manual" ? { reviewerReason: "named" } : {}),
@@ -929,6 +1204,7 @@ export class Tasks {
929
1204
  if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
930
1205
  const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
931
1206
  }
1207
+ const claimBindings = this.publicClaimBindings(task);
932
1208
  const currentSpecReport = this.currentSpecReport(task);
933
1209
  const currentSpecEntry = currentSpecReport ? history.findLast(entry => entry.event === "done" || entry.event === "done (checking)") : undefined;
934
1210
  const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
@@ -964,7 +1240,7 @@ export class Tasks {
964
1240
  const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
965
1241
  const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
966
1242
  const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
967
- return { ...rest, ...(currentSpecReport ? { specReport: currentSpecReport } : {}), ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
1243
+ return { ...rest, ...(claimBindings ? { claims: claimBindings } : {}), ...(currentSpecReport ? { specReport: currentSpecReport } : {}), ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
968
1244
  }
969
1245
 
970
1246
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
@@ -1868,8 +2144,19 @@ export class Tasks {
1868
2144
  if (by !== USER && !this.d.roles?.[by]?.includes("planner") && this.d.operational?.(by) !== true) throw new HandoffRefusal("no_authority", "a handoff needs the person, a configured planner or the conductor");
1869
2145
  }
1870
2146
 
2147
+ /**
2148
+ * #734 (AC6): who may ask the origin to catch up now (`ahub task handoff --sync`). The person, or the conductor acting on the person's instruction: the same bar as `handoffOutAuthority`
2149
+ * without the planner role, because a sync starts no work and files nothing, but it does ask other projects' hubs, which a planner agent has no need to trigger.
2150
+ */
2151
+ handoffSyncAuthority(by: PeerId): void {
2152
+ requireLocalTaskPeer(by);
2153
+ if (by !== USER && this.d.operational?.(by) !== true) throw new HandoffRefusal("no_authority", "a handoff sync is the person's or the conductor's: ask them to run `ahub task handoff --sync`");
2154
+ }
2155
+
1871
2156
  /** ORIGIN side: remember what was handed over, once the target answered with its task id. */
1872
2157
  handoffSent(by: PeerId, target: { projectId: string }, remoteTask: number, title: string, localTask?: number): void {
2158
+ // #731: a row that already exists is never reset to "proposed": a replayed answer can arrive after the target's own event for the same task was recorded.
2159
+ if (this.d.board.handoffOutboundFor(target.projectId, remoteTask)) return;
1873
2160
  this.d.board.recordHandoffOutbound({ remoteProject: target.projectId, remoteTask, originPeer: by, ...(localTask === undefined ? {} : { localTask }), title: title.slice(0, 300), state: "proposed" });
1874
2161
  }
1875
2162
 
@@ -1878,7 +2165,7 @@ export class Tasks {
1878
2165
  * The request was parsed closed by the origin and is parsed again here, because a control message is data from another process: the body is bounded, control characters are refused and the
1879
2166
  * text is sanitized before it is stored. A `[pii]` result is refused whole, and nothing is left behind.
1880
2167
  */
1881
- async handoffIn(origin: { projectId: unknown; peer: unknown; taskId?: unknown }, input: unknown, fence: () => string | undefined = () => undefined): Promise<{ task: number; state: string; note?: string }> {
2168
+ async handoffIn(origin: { projectId: unknown; peer: unknown; taskId?: unknown }, input: unknown, fence: () => string | undefined = () => undefined): Promise<{ task: number; state: string; note?: string; replayed?: true }> {
1882
2169
  const request = parseHandoff({ ...(input && typeof input === "object" && !Array.isArray(input) ? input as Record<string, unknown> : { invalid: true }), project: "local" });
1883
2170
  if (typeof origin.projectId !== "string" || !origin.projectId || origin.projectId.length > 200 || typeof origin.peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(origin.peer)
1884
2171
  || origin.taskId !== undefined && (!Number.isSafeInteger(origin.taskId) || (origin.taskId as number) < 1)) throw new HandoffRefusal("invalid", "handoff origin is malformed");
@@ -1886,9 +2173,21 @@ export class Tasks {
1886
2173
  // The fence is asked right before the board write and after every await: a recovery that began while the PII screen was pending must find nothing written (agent-hub#1030, review).
1887
2174
  const held = (): void => { const reason = fence(); if (reason) throw new HandoffRefusal("invalid", reason); };
1888
2175
  held();
2176
+ // #731 (agent-hub#1203): a request key this origin already used answers with the task it made, before the duplicate check (which would refuse it as a duplicate of itself) and before any PII screen
2177
+ // or write. The same key for a different request is refused, never answered with a task the caller did not ask for.
2178
+ const key = request.handoffKey, digest = handoffRequestDigest(request, origin.taskId as number | undefined);
2179
+ const replay = (): { task: number; state: string; replayed: true } | undefined => {
2180
+ const existing = key === undefined ? undefined : this.d.board.handoffOriginByKey(origin.projectId as string, key);
2181
+ const task = existing && this.d.board.get(existing.taskId);
2182
+ if (!existing || !task) return undefined;
2183
+ if (existing.digest !== null && existing.digest !== digest) throw new HandoffRefusal("invalid", "that handoff key was already used for a different request: hand the task off again with a new key");
2184
+ return { task: task.id, state: task.state, replayed: true };
2185
+ };
2186
+ const early = replay();
2187
+ if (early) return early;
1889
2188
  // The spec issue the caller named is kept in the task itself, as its own line: the target's planner reads it from the detail, and `ahub task show` shows it there.
1890
2189
  const issueLine = request.issue === undefined ? "" : `spec issue: #${request.issue} in this repository`;
1891
- const detail = sanitize([request.detail, issueLine, `[handed off from project ${origin.projectId} by ${origin.peer}${origin.taskId === undefined ? "" : ` (their task #${origin.taskId})`}]`].filter(Boolean).join("\n\n"));
2190
+ const detail = sanitize([request.detail, issueLine, request.duplicate_ok === undefined ? "" : `duplicate acknowledged by the sender: ${request.duplicate_ok}`, `[handed off from project ${origin.projectId} by ${origin.peer}${origin.taskId === undefined ? "" : ` (their task #${origin.taskId})`}]`].filter(Boolean).join("\n\n"));
1892
2191
  const refs: TaskRefs = { ...(request.paths.length ? { paths: request.paths } : {}) };
1893
2192
  const signals = this.signalsFor({ title: request.title, detail, refs, plan: {} });
1894
2193
  if (signals.includes("pii")) throw new HandoffRefusal("pii_task", "the handed-off text matches a PII pattern; it is not stored here");
@@ -1896,8 +2195,20 @@ export class Tasks {
1896
2195
  const screened = screening ? await this.screenCall(screenText({ title: request.title, detail: [detail, ...request.paths].join("\n"), plan: {} })) : undefined;
1897
2196
  held();
1898
2197
  if (screening && (!screened || screened.label !== "clear")) throw new HandoffRefusal("pii_task", "the handed-off text could not be cleared by the PII screen; it is not stored here");
1899
- let task = this.d.board.propose(HUB, { title: request.title, detail, class: request.class, refs, plan: {}, signals });
1900
- this.d.board.setHandoffOrigin(task.id, { projectId: origin.projectId, peer: origin.peer, ...(origin.taskId === undefined ? {} : { originTask: origin.taskId as number }) });
2198
+ // The first attempt may have committed while this one waited for the screen (the origin's timeout does not stop it): ask again, with nothing awaited between here and the write below.
2199
+ const late = replay();
2200
+ if (late) return late;
2201
+ // #1175: a spec issue that already has open work here is refused before any row exists, so a duplicate is never offered to an owner or escalated by the sweep. This runs after the last
2202
+ // await and writes nothing before it, so the check and the proposal below see one board; a `[pii]` task is left out, so no private task id is named to another project.
2203
+ if (request.issue !== undefined && request.duplicate_ok === undefined) {
2204
+ const open = duplicateIssueTasks(this.d.board.list().filter(row => !this.isPii(row)).map(row => ({ id: row.id, state: row.state, title: row.title, detail: row.detail, refs: row.refs, external: row.external, sdlc: row.sdlc })), request.issue, this.d.repository);
2205
+ if (open.length) throw new HandoffRefusal("duplicate", duplicateRefusalText(request.issue, open));
2206
+ }
2207
+ // #731 (H9): the proposal and its origin row are one board transaction. A request with no key (an older origin) is written the same way, without a key, so it is never left without its origin either.
2208
+ const made = this.d.board.proposeHandoff(HUB, { title: request.title, detail, class: request.class, refs, plan: {}, signals },
2209
+ { projectId: origin.projectId, peer: origin.peer, ...(origin.taskId === undefined ? {} : { originTask: origin.taskId as number }), ...(key === undefined ? {} : { key, digest }) });
2210
+ if (made.replayed) return { task: made.task.id, state: made.task.state, replayed: true };
2211
+ let task = made.task;
1901
2212
  // From here the task and its origin are saved, so nothing below may fail the handoff: the origin must learn the task id, or the target keeps work nobody can track.
1902
2213
  try { task = this.linkOnWrite(task); } catch { /* the lifecycle link is advice */ }
1903
2214
  try { this.d.notify(`task ${this.publicTitle(task)} handed off from project ${origin.projectId} by ${origin.peer}`); } catch { /* never throw after a board write */ }
@@ -1916,20 +2227,34 @@ export class Tasks {
1916
2227
  return origin ? { projectId: origin.projectId, peer: origin.peer, originTask: origin.originTask } : undefined;
1917
2228
  }
1918
2229
 
1919
- /** ORIGIN side: the target reported a state change. Records it and tells the origin peer with one `kind: task` envelope. Idempotent: a state already recorded sends nothing. */
2230
+ /**
2231
+ * ORIGIN side: the target reported a state change. Records it and tells the origin peer with one `kind: task` envelope. Idempotent: a state already recorded sends nothing.
2232
+ * #733: an event may carry `seq`, the target task's flow revision. One that is not newer than the stored sequence changes nothing and sends nothing (an `in_review` that arrives after
2233
+ * `approved` cannot reopen the row), decided inside the board write; an event without one (an older target) applies as before.
2234
+ */
1920
2235
  handoffEvent(input: unknown): { recorded: boolean } {
1921
2236
  if (!input || typeof input !== "object" || Array.isArray(input)) throw new HandoffRefusal("invalid", "handoff event must be a data object");
1922
2237
  const e = input as Record<string, unknown>;
1923
2238
  if (typeof e.projectId !== "string" || !e.projectId || e.projectId.length > 200 || !Number.isSafeInteger(e.remoteTask) || (e.remoteTask as number) < 1
1924
- || typeof e.state !== "string" || !(HANDOFF_EVENTS as readonly string[]).includes(e.state)) throw new HandoffRefusal("invalid", "handoff event is malformed");
2239
+ || typeof e.state !== "string" || !(HANDOFF_EVENTS as readonly string[]).includes(e.state)
2240
+ || e.seq !== undefined && (!Number.isSafeInteger(e.seq) || (e.seq as number) < 1)) throw new HandoffRefusal("invalid", "handoff event is malformed");
1925
2241
  const row = this.d.board.handoffOutboundFor(e.projectId, e.remoteTask as number);
1926
2242
  if (!row) throw new HandoffRefusal("invalid", "no handoff of ours is recorded for that task");
2243
+ const seq = e.seq as number | undefined;
1927
2244
  const pr = Number.isSafeInteger(e.pr) && (e.pr as number) > 0 ? e.pr as number : undefined, head = typeof e.head === "string" && /^[0-9a-f]{40}$/.test(e.head) ? e.head : undefined;
1928
- if (row.state === e.state && (pr === undefined || pr === row.pr) && (head === undefined || head === row.head)) return { recorded: false };
1929
- this.d.board.recordHandoffOutbound({ remoteProject: row.remoteProject, remoteTask: row.remoteTask, originPeer: row.originPeer, ...(row.localTask === null ? {} : { localTask: row.localTask }), title: row.title, state: e.state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }) });
2245
+ if (seq !== undefined && row.seq !== null && seq <= row.seq) return { recorded: false };
2246
+ if (row.state === e.state && (pr === undefined || pr === row.pr) && (head === undefined || head === row.head)) {
2247
+ // Nothing to tell the peer, but a newer sequence is still kept, so a later stale event is judged against it.
2248
+ if (seq !== undefined && (row.seq === null || seq > row.seq)) this.d.board.recordHandoffOutbound({ remoteProject: row.remoteProject, remoteTask: row.remoteTask, originPeer: row.originPeer, ...(row.localTask === null ? {} : { localTask: row.localTask }), title: row.title, state: row.state, seq });
2249
+ return { recorded: false };
2250
+ }
2251
+ const written = this.d.board.recordHandoffOutbound({ remoteProject: row.remoteProject, remoteTask: row.remoteTask, originPeer: row.originPeer, ...(row.localTask === null ? {} : { localTask: row.localTask }), title: row.title, state: e.state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }), ...(seq === undefined ? {} : { seq }) });
2252
+ if (!written.applied) return { recorded: false }; // a newer event won the race between the read above and this write
1930
2253
  const body = followBackLine(row.remoteTask, e.state as HandoffEvent, { pr, head });
1931
2254
  if (row.originPeer !== USER && row.originPeer !== HUB) {
1932
- try { this.d.bus.publish(newEnvelope(HUB, body, { to: [row.originPeer], kind: "task", priority: "important", refs: { task: String(row.localTask ?? row.remoteTask) } })); }
2255
+ // #731 (H3): the envelope names the REMOTE task and no local one. `refs.task` made the bus judge it against whatever local task has that number (or the origin's own task it came from), so a
2256
+ // cancelled or approved local task of the same number dropped a state change that was about another project's task.
2257
+ try { this.d.bus.publish(newEnvelope(HUB, body, { to: [row.originPeer], kind: "task", priority: "important", refs: { handoff: { project: row.remoteProject, task: row.remoteTask } } })); }
1933
2258
  catch { /* never throw after a board write */ }
1934
2259
  }
1935
2260
  return { recorded: true };
@@ -1939,9 +2264,9 @@ export class Tasks {
1939
2264
  * TARGET side, catch-up (#1030 design 4): the origin asks for the current state of the tasks it handed us, by id, after it was down. Answers only for tasks whose recorded origin IS the asking
1940
2265
  * project, and only with the same fields a live event carries (the event name, a PR number, a full head), so an origin learns nothing it could not have been told live.
1941
2266
  */
1942
- handoffStatesFor(projectId: unknown, taskIds: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] {
2267
+ handoffStatesFor(projectId: unknown, taskIds: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string; seq: number }[] {
1943
2268
  if (typeof projectId !== "string" || !projectId || projectId.length > 200 || !Array.isArray(taskIds) || taskIds.length > 200 || !taskIds.every(id => Number.isSafeInteger(id) && id > 0)) throw new HandoffRefusal("invalid", "handoff state request is malformed");
1944
- const out: { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string }[] = [];
2269
+ const out: { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string; seq: number }[] = [];
1945
2270
  for (const id of taskIds as number[]) {
1946
2271
  const origin = this.d.board.handoffOrigin(id), task = origin?.projectId === projectId ? this.d.board.get(id) : undefined;
1947
2272
  if (!task) continue;
@@ -1949,17 +2274,28 @@ export class Tasks {
1949
2274
  const state = entry ? handoffEventFor(entry.event, task.state) ?? "proposed" : "proposed";
1950
2275
  const pr = typeof task.sdlc?.pr === "number" && Number.isSafeInteger(task.sdlc.pr) && task.sdlc.pr > 0 ? task.sdlc.pr : undefined;
1951
2276
  const rawHead = task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead, head = typeof rawHead === "string" && /^[0-9a-f]{40}$/.test(rawHead) ? rawHead : undefined; // the owner's commit, else the receipt's head; only a full SHA crosses
1952
- out.push({ task: id, state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }) });
2277
+ // #733: the task's current flow revision (its history length), the same number a live event carries, so a catch-up answer and a late live event are ordered against each other.
2278
+ out.push({ task: id, state, ...(pr === undefined ? {} : { pr }), ...(head === undefined ? {} : { head }), seq: Math.max(1, task.history.length) });
1953
2279
  }
1954
2280
  return out;
1955
2281
  }
1956
2282
 
2283
+ /**
2284
+ * TARGET side (#731): where the task a handoff key made stands, for an origin whose request got no answer. Answers only the asking project, and only the fields a live event carries; nothing is
2285
+ * written. `undefined` means this target holds no task for that key.
2286
+ */
2287
+ handoffStateByKey(projectId: unknown, key: unknown): { task: number; state: HandoffEvent | "proposed"; pr?: number; head?: string; seq: number } | undefined {
2288
+ if (typeof projectId !== "string" || !projectId || projectId.length > 200 || typeof key !== "string" || !HANDOFF_KEY.test(key)) throw new HandoffRefusal("invalid", "handoff key lookup is malformed");
2289
+ const found = this.d.board.handoffOriginByKey(projectId, key);
2290
+ return found ? this.handoffStatesFor(projectId, [found.taskId])[0] : undefined;
2291
+ }
2292
+
1957
2293
  /** ORIGIN side: record what a target answered to the catch-up, and tell the origin peer about a change this project missed while it was down. */
1958
- handoffCaughtUp(projectId: string, rows: { task: number; state: string; pr?: number; head?: string }[]): number {
2294
+ handoffCaughtUp(projectId: string, rows: { task: number; state: string; pr?: number; head?: string; seq?: number }[]): number {
1959
2295
  let changed = 0;
1960
2296
  for (const row of rows) {
1961
2297
  if (!(HANDOFF_EVENTS as readonly string[]).includes(row.state)) continue;
1962
- try { if (this.handoffEvent({ projectId, remoteTask: row.task, state: row.state, ...(row.pr === undefined ? {} : { pr: row.pr }), ...(row.head === undefined ? {} : { head: row.head }) }).recorded) changed++; }
2298
+ try { if (this.handoffEvent({ projectId, remoteTask: row.task, state: row.state, ...(row.pr === undefined ? {} : { pr: row.pr }), ...(row.head === undefined ? {} : { head: row.head }), ...(row.seq === undefined ? {} : { seq: row.seq }) }).recorded) changed++; }
1963
2299
  catch { /* a row we hold no handoff for is not ours */ }
1964
2300
  }
1965
2301
  return changed;
@@ -2562,7 +2898,7 @@ export class Tasks {
2562
2898
  private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
2563
2899
  const now = this.d.now?.() ?? Date.now();
2564
2900
  return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
2565
- && (planner === undefined || this.taskPlanner(card).includes(planner)) && !plannedStartPending(card, now) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
2901
+ && (planner === undefined || this.taskPlanner(card).includes(planner)) && !plannedStartPending(card, now) && !(pullable && this.isPii(card)) && !card.pinned && !card.needsPerson && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
2566
2902
  && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
2567
2903
  }
2568
2904
 
@@ -2669,6 +3005,8 @@ export class Tasks {
2669
3005
  const task = this.d.board.get(input.id);
2670
3006
  if (task?.draft) throw new PreEffectToolRefusal(`task #${task.id} is an intake draft; the person approves it first`);
2671
3007
  if (!task || (task.planningRevision ?? 0) !== input.revision || task.state !== "backlog" || task.owner || !taskCanPlan(task) || task.pinned) throw new PreEffectToolRefusal("planner pull changed, pinned or already started");
3008
+ // #1176: the one gate every agent pull passes (a planner's hub_backlog_pull and the opt-in auto pull alike): a person-run leg is never pulled into an agent's To Do.
3009
+ if (task.needsPerson) throw new PreEffectToolRefusal(`task #${task.id} is a person-run leg: it waits on the person (live receipts, their accounts or approvals), so no agent is offered it; the person runs it or clears the mark with ahub backlog plan`);
2672
3010
  this.planningAuthority(planner, task);
2673
3011
  const release = task.release == null ? undefined : this.d.board.release(task.release);
2674
3012
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
@@ -2896,7 +3234,11 @@ export class Tasks {
2896
3234
  if (!this.isPii(task) && !this.publicMetadataText(JSON.stringify(patch.sdlc))) throw new PreEffectToolRefusal("planning identity metadata is private or sensitive");
2897
3235
  }
2898
3236
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
3237
+ // #1176: marking a card an agent already owns (an offer in its queue, or work it accepted) would leave the agent on it: the offer is not withdrawn by a mark. Refused, with the way out named.
3238
+ if (patch.needsPerson === true && !task.needsPerson && (task.owner || !["backlog", "proposed"].includes(task.state))) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.owner ? `${task.state} with ${task.owner}` : task.state}: take it off the agent first (its owner declines the offer, or ahub task cancel ${task.id} --reason <text>), then mark it person-run`);
2899
3239
  if (by !== USER && (task.state !== "backlog" || !taskCanPlan(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
3240
+ // #1176: whether a leg is the person's to run is the person's call, set or cleared: an agent that could clear the mark would put the card back in front of an agent.
3241
+ if (by !== USER && (Object.hasOwn(patch, "needsPerson") || task.needsPerson)) throw new PreEffectToolRefusal("a person-run card is the person's to mark and plan: ask the person (ahub backlog plan <id> <revision> '{\"needsPerson\":true|false}' <reason>)");
2900
3242
  if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
2901
3243
  if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
2902
3244
  if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
@@ -3020,6 +3362,8 @@ export class Tasks {
3020
3362
  const validate = () => {
3021
3363
  const parent = this.d.board.get(id);
3022
3364
  if (!parent || parent.state !== "backlog" || parent.owner || taskHasStarted(parent) || (parent.planningRevision ?? 0) !== revision || by !== USER && parent.pinned) throw new PreEffectToolRefusal("split changed, pinned or already started");
3365
+ // #1176: children are unmarked cards an agent could take, so an agent's split would turn person-only work into agent work. Checked here, which runs before the screen and again after it, and in the board's transaction.
3366
+ if (by !== USER && parent.needsPerson) throw new PreEffectToolRefusal(PERSON_RUN_SPLIT);
3023
3367
  const release = parent.release == null ? undefined : this.d.board.release(parent.release);
3024
3368
  if (parent.release != null && (!release || ["shipped", "dropped"].includes(release.state) || by !== USER && release.state === "frozen")) throw new PreEffectToolRefusal("split release scope is frozen or final");
3025
3369
  authority(parent);
@@ -3039,9 +3383,12 @@ export class Tasks {
3039
3383
  if (direct.length + afterParts.length > 64) throw new PreEffectToolRefusal("split child dependencies exceed the 64-edge cap");
3040
3384
  while (pending.length) { const dependency = pending.pop()!; if (dependency === id) throw new PreEffectToolRefusal("split dependency reaches its parent"); if (seen.has(dependency)) continue; seen.add(dependency); const task = this.d.board.get(dependency); if (!task) throw new PreEffectToolRefusal("split dependency is missing"); pending.push(...(task.deps ?? [])); }
3041
3385
  const refs = part.refs === undefined ? parent.refs : cleanRefs(part.refs), plan = cleanPlan(part.plan, storedProfile);
3386
+ // #1161: a part's plan sources are registered ids, as at propose and accept (#1144); refused before any screen or board write.
3387
+ this.validatePlanSources(plan);
3042
3388
  const draft = { title: part.title.replace(/\s+/g, " ").trim(), detail: part.detail as string | undefined ?? "", class: cls, refs, plan, deps, afterParts: afterParts as number[], ...(parent.role && cls === parent.class ? { role: parent.role } : {}), ...(parent.profile ? { profile: parent.profile, profileClass: given as string, ...(parent.profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
3043
3389
  const fields: PlanningFields = {};
3044
3390
  for (const key of ["priority", "release", "plannedStart", "due"] as const) if (parent[key] !== undefined) fields[key] = parent[key];
3391
+ if (parent.needsPerson) fields.needsPerson = true; // #1176: only the person gets here (above), and each child of a person-run leg is a person-run leg until the person says otherwise
3045
3392
  return { ...draft, fields };
3046
3393
  });
3047
3394
  return { parent, drafts };
@@ -3715,6 +4062,8 @@ export class Tasks {
3715
4062
  catch (e) { this.releaseNotice(`task ${this.publicTitle(task)}: could not record the dropped study offer: ${e instanceof Error ? e.message : String(e)}`); }
3716
4063
  return false;
3717
4064
  }
4065
+ // #1176: an offer queued before the card was marked is dropped at delivery, not handed to the agent.
4066
+ if (env.kind === "task" && env.refs.taskOffer !== undefined && task?.needsPerson && task.owner === peer) return false;
3718
4067
  if (task?.state === "cancelled") return env.kind === "task" && typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision;
3719
4068
  if (env.refs.taskRecipient === "planner") return !!task && this.taskPlanner(task).includes(peer);
3720
4069
  if (env.refs.planReview !== undefined) return !!task && !isClosedTaskState(task.state) && (task.planningRevision ?? 0) === env.refs.planReview
@@ -4513,6 +4862,65 @@ export class Tasks {
4513
4862
  // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
4514
4863
  for (const peer of this.taskPlanner(task).filter(peer => this.d.bus.isRostered(peer))) this.releaseNotice(`task #${task.id}: stall notice unavailable`, () => this.d.tell?.(peer, noteLine(HUB, "finding", text)));
4515
4864
  }
4865
+ /** T1 records declared provenance only; no locator resolution or #901 judgment is manufactured. */
4866
+ async evidenceRecord(by: PeerId, input: unknown): Promise<EvidenceReceipt> {
4867
+ try { requireLocalTaskPeer(by); }
4868
+ catch { throw new PreEffectToolRefusal("remote peers cannot record local evidence; ask the current local research task owner to record it"); }
4869
+ const requestCheck = this.requestCheck(by, () => this.studyHold());
4870
+ let proposed: EvidenceRecordInput;
4871
+ try { proposed = parseEvidenceRecord(input); }
4872
+ catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid evidence record; check the bounded evidence fields before retrying"); }
4873
+ return this.studyWrite(async () => {
4874
+ const context = () => {
4875
+ requestCheck();
4876
+ let task: Task;
4877
+ try { task = this.studyParent(proposed.taskId); }
4878
+ catch { throw new PreEffectToolRefusal("evidence task is unavailable or outside this project; refresh ahub task show and select an active research task in this project"); }
4879
+ if (task.profile !== "research" || task.state !== "in_progress" || task.owner !== by) throw new PreEffectToolRefusal("evidence recording requires the current in-progress research task owner; accept an assigned research task before recording evidence");
4880
+ if (this.d.privateTurn?.(by) || this.isPii(task) || task.signals.includes("pii") || task.signals.includes("pii_path")) throw new PreEffectToolRefusal("private evidence recording is unavailable in T1; use a public research task outside a private turn");
4881
+ let identity;
4882
+ try { identity = taskClaimIdentity(task, Number(proposed.claimId.split(":").at(-1))); }
4883
+ catch { throw new PreEffectToolRefusal("evidence claim is unavailable; refresh hub_task_show or ahub task show --json and use a current claim binding"); }
4884
+ if (identity.claimId !== proposed.claimId || identity.claimRevision !== proposed.claimRevision) throw new PreEffectToolRefusal("evidence claim revision changed; refresh hub_task_show or ahub task show --json and record the current claim binding");
4885
+ const source = this.studySource(proposed.sourceId);
4886
+ if (!source || source.sha256 !== proposed.rawSha256) throw new PreEffectToolRefusal("evidence source is unknown or its raw hash changed; use ahub source list and read the registered snapshot before retrying");
4887
+ if (source.private) throw new PreEffectToolRefusal("private evidence sources are unavailable in T1; select a registered public source");
4888
+ return { task, key: JSON.stringify([task.created, task.history.length, task.owner, identity, source]) };
4889
+ };
4890
+ const before = context();
4891
+ const link = parseEvidenceLink({ ...proposed, id: proposed.id ?? evidenceLinkId(proposed, by), recordedBy: by });
4892
+ const canonical = evidenceLinkCanonical(link);
4893
+ const requirePublicMetadata = () => {
4894
+ let nameable = false;
4895
+ try { nameable = this.publicMetadataText(canonical) === true; } catch { /* unknown privacy is refused */ }
4896
+ if (!nameable) throw new PreEffectToolRefusal("evidence metadata is private or sensitive; remove sensitive metadata before recording a public link");
4897
+ };
4898
+ requirePublicMetadata();
4899
+ const requireRevision = () => {
4900
+ const latest = this.d.board.evidenceLink(this.d.project, link.id);
4901
+ const next = (latest?.revision ?? 0) + 1;
4902
+ if (latest && (latest.taskId !== link.taskId || latest.claimId !== link.claimId)) throw new PreEffectToolRefusal("evidence id belongs to another claim; use a new evidence id");
4903
+ const known = this.d.board.evidenceLink(this.d.project, link.id, link.revision);
4904
+ if (known && evidenceLinkCanonical(known) !== canonical) throw new PreEffectToolRefusal(`evidence revision already records different content; use next revision ${next} or a new evidence id`);
4905
+ if (!known && link.revision !== next) throw new PreEffectToolRefusal(`evidence revision is out of sequence; expected next revision ${next}; use that revision or replay an unchanged recorded revision`);
4906
+ return !!known;
4907
+ };
4908
+ const replay = requireRevision();
4909
+ if (!replay && this.screenOn()) {
4910
+ const screen = await this.screenCall(canonical);
4911
+ requestCheck();
4912
+ if (screen.label !== "clear") throw new PreEffectToolRefusal("evidence privacy screening did not clear the metadata; use public evidence metadata and retry when the screen is available");
4913
+ }
4914
+ requestCheck();
4915
+ const current = context();
4916
+ if (current.key !== before.key) throw new PreEffectToolRefusal("evidence task, claim or source changed during admission; refresh hub_task_show or ahub task show --json and registered source metadata before retrying");
4917
+ requirePublicMetadata();
4918
+ requireRevision();
4919
+ const saved = this.d.board.recordEvidenceLink(this.d.project, link, { taskFlowRevision: current.task.history.length, taskCreated: current.task.created, owner: by });
4920
+ return { id: saved.id, revision: saved.revision, taskId: saved.taskId };
4921
+ });
4922
+ }
4923
+
4516
4924
  /**
4517
4925
  * #901: the person's verdict on one declared claim of a document plan, with the model label they say produced it. Person only (an agent's word on its own claim is no verdict, and task
4518
4926
  * approval never supplies one). The claim is named by its 1-based slot in the task's current plan; its text is not stored, sent or echoed, so a PII task records the same counters and
@@ -4526,7 +4934,8 @@ export class Tasks {
4526
4934
  if (!claims) throw new Error(`task #${task.id} declares no plan claims: add them to its plan first`);
4527
4935
  const claim = parseClaimVerdict(input, claims);
4528
4936
  // Bound to the claim list it evaluated: a hash of the plan's claims (never their text), so a later replacement or reorder of the list leaves this verdict naming no claim of the new list.
4529
- return this.d.board.update(task.id, USER, "claim verdict", {}, undefined, { claim: { ...claim, list: claimListId(task.plan?.claims) } }); // no note: nothing a person typed beyond the closed fields is kept
4937
+ const identity = taskClaimIdentity(task, claim.slot);
4938
+ return this.d.board.update(task.id, USER, "claim verdict", {}, undefined, { claim: { ...claim, list: identity.claimRevision } }); // no note: nothing a person typed beyond the closed fields is kept
4530
4939
  }
4531
4940
 
4532
4941
  async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
@@ -4538,7 +4947,10 @@ export class Tasks {
4538
4947
  this.checkFlowRevision(task, input.expectedFlowRevision, true);
4539
4948
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal(`task #${task.id} is already ${task.state}`);
4540
4949
  if (by !== USER && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("cancellation on ordinary work is unavailable during a private turn");
4541
- if (by !== USER) {
4950
+ if (by !== USER && this.conductorHandoffCancel(by, task)) { /* #1175: the receiving conductor's own authority over a handed-off task nobody accepted; the board write below records it as the actor */ }
4951
+ else if (by !== USER) {
4952
+ // The conductor without the planner role is refused with its own rule (#1175): the planner sentence below would point it at a role it does not hold.
4953
+ if (this.configuredManualConductor(by) && !this.d.roles?.[by]?.includes("planner")) throw new PreEffectToolRefusal("the conductor may cancel only a task another project handed off that nobody has accepted (no owner, branch, commit, PR, check, done or verdict) while the conductor is on the roster; for any other task the person runs `ahub task cancel <id> --reason <text>`");
4542
4954
  this.planningAuthority(by, task);
4543
4955
  const proposer = task.history.find(h => h.event === "proposed" || h.event === "backlogged")?.by;
4544
4956
  // #812 AC5: back in proposed or backlog with no execution evidence (the `hub_backlog_return_untouched` test: no branch, commit, PR, local check, done, verdict or reopen), an accepted-then-declined task is cancellable; any such evidence keeps today's refusal.
@@ -4568,6 +4980,16 @@ export class Tasks {
4568
4980
  return next;
4569
4981
  }
4570
4982
 
4983
+ /**
4984
+ * #1175: the receiving project's configured conductor may cancel a task another project handed it, while nobody has accepted it and it has no execution evidence (the same bar as the
4985
+ * planner's own-work rule, `taskHasStarted` and `taskHasWorkEvidence`). It is re-asked inside both validations of `cancel`, so authority that ended during the privacy screen writes
4986
+ * nothing. The origin hears the cancel through the existing follow-back, which reads the history entry and carries no text.
4987
+ */
4988
+ private conductorHandoffCancel(by: PeerId, task: Task): boolean {
4989
+ return this.configuredManualConductor(by) && this.d.bus.isRostered(by) && this.d.board.handoffOrigin(task.id) !== undefined && ["proposed", "backlog"].includes(task.state)
4990
+ && !taskHasStarted(task) && !taskHasWorkEvidence(task) && !this.isPii(task);
4991
+ }
4992
+
4571
4993
  /** A slot freed for `peer` (cancel, untouched return): its held ready tasks are offered in priority order, and the cap stops the offers (#655). */
4572
4994
  private async releaseHeld(peer: PeerId | null | undefined): Promise<void> {
4573
4995
  if (!peer) return;
@@ -4583,6 +5005,7 @@ export class Tasks {
4583
5005
  const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
4584
5006
  for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
4585
5007
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
5008
+ if (t.needsPerson) { this.d.board.clearStall(t.id); continue; } // #1176: waiting on the person is the intent, not a stall: no owner is wanted, so none is reported missing
4586
5009
  const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
4587
5010
  if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
4588
5011
  const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
@@ -4613,6 +5036,7 @@ export class Tasks {
4613
5036
  try {
4614
5037
  const t = this.d.board.get(stale.id);
4615
5038
  if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
5039
+ if (t.needsPerson) return; // #1176: a person-run leg is never offered to an agent, whatever made it ready
4616
5040
  if (plannedStartPending(t, this.d.now?.() ?? Date.now())) return;
4617
5041
  const planned = this.plannerPullRecord(t)?.pull === true;
4618
5042
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
@@ -5789,6 +6213,7 @@ export class Tasks {
5789
6213
  const metadata = acceptRefs(refs);
5790
6214
  const task = this.need(id, false, true);
5791
6215
  this.mine(task, by, "owner", true);
6216
+ if (by !== USER && task.needsPerson) throw new PreEffectToolRefusal(PERSON_RUN_ACCEPT(task.id)); // #1176: before any spec or plan-review work is spent on it
5792
6217
  const proposedPlan = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
5793
6218
  this.validatePlanSources(proposedPlan && Object.keys(proposedPlan).length ? proposedPlan : task.plan);
5794
6219
  this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
@@ -5921,6 +6346,8 @@ export class Tasks {
5921
6346
  const task = this.need(id, false, true);
5922
6347
  this.mine(task, by, "owner", true);
5923
6348
  this.ready(task, true);
6349
+ // #1176: a person-run leg is never an agent's to accept, whatever offer or owner it carries from before it was marked.
6350
+ if (by !== USER && task.needsPerson) throw new PreEffectToolRefusal(PERSON_RUN_ACCEPT(task.id));
5924
6351
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
5925
6352
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
5926
6353
  const given = plan == null ? undefined : cleanPlan(plan, (task.profile ?? "dev") === (this.d.profile ?? "dev") ? this.d.profileData ?? task.profile ?? "dev" : task.profile ?? "dev");
@@ -6201,6 +6628,9 @@ export class Tasks {
6201
6628
  /** Checks queued or running, until their result is on the board: a recovery commit waits for them. */
6202
6629
  checksPending = () => this.pendingChecks;
6203
6630
 
6631
+ /** #1054: completion checks queued or running for work this peer owns, so a run is not settled while its own check is still going. */
6632
+ checksOwnedBy = (peer: PeerId): number => { let n = 0; for (const owner of this.checking.values()) if (owner === peer) n++; return n; };
6633
+
6204
6634
  /** Whether a completion check is still running for this task. */
6205
6635
  isChecking = (id: number) => this.checking.has(id);
6206
6636
 
@@ -6256,10 +6686,12 @@ export class Tasks {
6256
6686
  if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
6257
6687
  }
6258
6688
 
6259
- async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown): Promise<Task> {
6689
+ async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown, resolved?: unknown): Promise<Task> {
6260
6690
  const requestCheck = this.requestCheck(by);
6261
6691
  const initial = this.need(id, false, true);
6262
6692
  this.mine(initial, by, "owner", true);
6693
+ const reviewObserverTurn = this.reviewObserverTurn(by, initial.id);
6694
+ const resolvedReviewComments = this.reviewCommentResolve(by, initial, resolved);
6263
6695
  let refresh: CompletionRefresh | undefined;
6264
6696
  if (initial.state === "in_review" || initial.state === "approved") {
6265
6697
  const commit = cleanRefs(refs).commit;
@@ -6278,7 +6710,7 @@ export class Tasks {
6278
6710
  const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
6279
6711
  ? { recorded: this.ownerReviewHead(initial) } : undefined;
6280
6712
  const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
6281
- const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
6713
+ const valid = () => { requestCheck(); const current = this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); if (resolvedReviewComments !== undefined) this.reviewCommentResolve(by, current, resolvedReviewComments); return current; };
6282
6714
  if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
6283
6715
  // #509a: a bad claim (a claimed hash, an outside or denied path, a missing file) is refused before any effect, with nothing recorded.
6284
6716
  const claims = deliverables === undefined ? undefined : this.deliverableClaims(deliverables, valid());
@@ -6343,7 +6775,7 @@ export class Tasks {
6343
6775
  }
6344
6776
  if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
6345
6777
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
6346
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection);
6778
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection, false, resolvedReviewComments, reviewObserverTurn);
6347
6779
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
6348
6780
  if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
6349
6781
  this.checking.set(task.id, task.owner);
@@ -6359,7 +6791,7 @@ export class Tasks {
6359
6791
  const seen = { events: pending.history.length, owner: pending.owner };
6360
6792
  this.pendingChecks++;
6361
6793
  this.checkQueue = this.checkQueue
6362
- .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection))
6794
+ .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection, resolvedReviewComments, reviewObserverTurn))
6363
6795
  .catch((e: Error) => {
6364
6796
  this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
6365
6797
  this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
@@ -6462,9 +6894,11 @@ export class Tasks {
6462
6894
  return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
6463
6895
  }
6464
6896
 
6465
- private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean, specInspection: PreparedSpecInspection): Promise<void> {
6897
+ private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean, specInspection: PreparedSpecInspection, resolvedReviewComments?: string[], reviewObserverExcluded = false): Promise<void> {
6466
6898
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
6899
+ const checkOwner = this.checking.get(id);
6467
6900
  this.checking.delete(id);
6901
+ if (checkOwner) { try { queueMicrotask(() => { try { this.d.checkEnded?.(checkOwner); } catch { /* advisory */ } }); } catch { /* advisory */ } }
6468
6902
  const task = this.d.board.get(id);
6469
6903
  if (!task) return;
6470
6904
  const outcome = `${command} -> ${result.interrupted ? "interrupted by a hub stop" : result.timedOut ? "timed out" : `exit ${result.code ?? "?"}`}`;
@@ -6486,22 +6920,22 @@ export class Tasks {
6486
6920
  this.tell(task, `Task #${id}: its check passed, but the overlapping work or the files changed while it ran (turn-free integration). Call hub_task_done again.`, pii);
6487
6921
  return;
6488
6922
  }
6489
- this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
6923
+ this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), { ...this.checkProvenance(task, result.tail), ...(withheld ? { withheld: true } : {}) });
6490
6924
  if (refileForSpecs) { this.requestSpecRefile(task); return; }
6491
6925
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
6492
- await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld, undefined, undefined, undefined, undefined, undefined, undefined, specInspection, true);
6926
+ await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld, undefined, undefined, undefined, undefined, undefined, undefined, specInspection, true, resolvedReviewComments, reviewObserverExcluded);
6493
6927
  return;
6494
6928
  }
6495
- this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
6929
+ this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), { ...this.checkProvenance(task, result.tail), ...(withheld ? { withheld: true } : {}) });
6496
6930
  this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
6497
6931
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
6498
6932
  this.contradict(task);
6499
6933
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
6500
- this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
6934
+ this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii, "owner", this.reviewObserverTurn(by, id));
6501
6935
  }
6502
6936
 
6503
6937
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
6504
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh, specInspection?: PreparedSpecInspection, asyncCheckCompletion = false): Promise<Task> {
6938
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh, specInspection?: PreparedSpecInspection, asyncCheckCompletion = false, resolvedReviewComments?: string[], reviewObserverExcluded = false): Promise<Task> {
6505
6939
  requestCheck();
6506
6940
  if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
6507
6941
  // A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
@@ -6548,14 +6982,15 @@ export class Tasks {
6548
6982
  this.warmRepository({ ...task, refs: storedRefs });
6549
6983
  const noWrites = this.noWritesRecorded({ ...task, refs: storedRefs });
6550
6984
  const capacity = this.capacityBefore(task);
6551
- const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(specInspection?.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}), ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: storedRefs }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
6985
+ reviewObserverExcluded ||= this.reviewObserverTurn(by, task.id);
6986
+ const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...(resolvedReviewComments?.length ? { resolvedReviewComments: this.reviewCommentResolve(by, task, resolvedReviewComments) } : {}), ...this.planObservation(task), withheld: withheld || undefined, ...(specInspection?.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}), ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: storedRefs }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
6552
6987
  this.queueReviewWritten(next);
6553
6988
  this.writePlannerNotices(next, capacity, next.state === "approved");
6554
6989
  this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
6555
6990
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
6556
6991
  const memorySymbols = this.completionMemorySymbols(next);
6557
- this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld, memorySymbols);
6558
- this.tellCompleted(next, summary, withheld);
6992
+ this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\nChanged-symbol memory anchors: ${memorySymbols.state === "known" ? memorySymbols.nodes.length : "unknown (no complete code facts)"}\n${summary ?? ""}`, withheld || reviewObserverExcluded, memorySymbols);
6993
+ this.tellCompleted(next, summary, withheld || reviewObserverExcluded);
6559
6994
  if (!reviewer) {
6560
6995
  this.settleMemory(next);
6561
6996
  if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
@@ -6665,7 +7100,8 @@ export class Tasks {
6665
7100
  /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
6666
7101
  private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false, refileHead?: RefileHeadGuard): Promise<ReviewReadiness | undefined> {
6667
7102
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
6668
- if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
7103
+ const anchored = this.reviewFindingSets(task).some(set => set.items.some(item => item.anchor));
7104
+ if (mode === "off" && !this.d.prepareReviewBrief && !anchored) return undefined;
6669
7105
  let quietDrift = false;
6670
7106
  const guard = () => {
6671
7107
  if (actor !== USER && actor !== task.owner) {
@@ -6690,7 +7126,14 @@ export class Tasks {
6690
7126
  catch { /* Optional code facts must not refuse a review. */ }
6691
7127
  guard();
6692
7128
  }
6693
- if (mode === "off" || prepareOnly) return undefined;
7129
+ if (prepareOnly) return undefined;
7130
+ if (mode === "off") {
7131
+ if (!anchored) return undefined;
7132
+ const placements = await this.prepareFindingPlacements(task, refs, guard);
7133
+ const admission: ReviewReadiness = { ready: false, blockers: [] };
7134
+ this.findingPlacements.set(admission, { binding: this.findingPlacementBinding({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }), placements, placementOnly: true });
7135
+ return admission;
7136
+ }
6694
7137
  const expected = cleanRefs(refs).commit ?? task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead;
6695
7138
  const read: SdlcGh = (args, timeout, cwd, cap) => {
6696
7139
  guard();
@@ -6712,6 +7155,10 @@ export class Tasks {
6712
7155
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
6713
7156
  this.releaseNotice(message);
6714
7157
  }
7158
+ if (anchored) {
7159
+ const placements = await this.prepareFindingPlacements(task, refs, guard);
7160
+ this.findingPlacements.set(admitted, { binding: this.findingPlacementBinding({ ...task, refs: { ...task.refs, ...cleanRefs(refs) } }), placements, placementOnly: false });
7161
+ }
6715
7162
  return admitted;
6716
7163
  }
6717
7164
 
@@ -6721,7 +7168,7 @@ export class Tasks {
6721
7168
  * admission of a PII task being only a stub. Every word comes from the hub's own readiness read; blockers are fixed hub strings, never PR text.
6722
7169
  */
6723
7170
  private hubVerifiedBlock(task: Task, admission?: ReviewReadiness): string {
6724
- if (!admission || this.isPii(task)) return "";
7171
+ if (!admission || this.isPii(task) || this.findingPlacements.get(admission)?.placementOnly) return "";
6725
7172
  const head = admission.head ?? this.reviewHead(task) ?? "unknown head";
6726
7173
  if (admission.ready) {
6727
7174
  const names = admission.checks?.length ? admission.checks.join(", ") : "the required checks";
@@ -6803,7 +7250,13 @@ export class Tasks {
6803
7250
  const findings = reviewOnly ? task.history.filter(h => h.event === "changes_requested" && h.note).map(h => `- ${h.by}: ${this.screen(task, h.note!, "review note", reviewer, h.withheld)}`) : [];
6804
7251
  const carried = findingsForHead(this.reviewFindingSets(task), this.findingHead(task)).filter(item => item.severity === "important" && item.disposition === "open");
6805
7252
  const visible = this.isPii(task) ? [] : carried.filter(item => this.findingTextAllowed(task, [item]) && !task.history.some(entry => entry.withheld && entry.reviewFindings?.items.some(value => value.id === item.id)));
6806
- const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(({ id, pass, severity, path }) => JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) })).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
7253
+ const prepared = admission && this.findingPlacements.get(admission);
7254
+ const placements = prepared?.binding === this.findingPlacementBinding(task) ? prepared.placements : undefined;
7255
+ // ponytail: rare synchronous role re-offers without an async admission show unavailable until refreshed; no old location is guessed.
7256
+ const carriedText = carried.length ? `Open Important findings carried until explicitly dispositioned: ${carried.length}\n${visible.map(item => {
7257
+ const { id, pass, severity, path } = item, legacy = JSON.stringify({ id, pass, severity, ...(path ? { path } : {}) });
7258
+ return item.anchor ? legacy + "\n" + renderFindingAnchor(item, this.reviewPathPrivate(item.anchor.path) ? { status: "unavailable" } : placements?.get(id) ?? { status: "unavailable" }) : legacy;
7259
+ }).join("\n")}${visible.length < carried.length ? "\nSome finding details withheld." : ""}\n` : "";
6807
7260
  const guidance = this.roleGuidance(task, reviewer, true);
6808
7261
  let projection = "";
6809
7262
  try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
@@ -6812,7 +7265,10 @@ export class Tasks {
6812
7265
  // #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
6813
7266
  const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
6814
7267
  const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${last?.noWrites ? "no writes recorded since acceptance\n" : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${carriedText}${conformanceText}${rulingsBlock}${verified}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet, findings: [{id, pass: "bugs" | "security" | "compliance", severity: "important" | "nit", path?, disposition: "open" | "fixed" | "false_positive"}]}. Reuse finding ids when updating their disposition; only the person may accept a finding with a rationale.`;
6815
- return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
7268
+ const comments = this.d.board.reviewCommentCounts(task.id, task.created);
7269
+ const quoteMaterial = task.history.some(entry => entry.reviewFindings?.items.some(item => item.anchor !== undefined))
7270
+ || comments.sent + comments.resolved + comments.dismissed > 0;
7271
+ return this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(quoteMaterial ? { reviewPackage: `reviewer-request-${crypto.randomUUID()}` } : {}), ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) })).includes(reviewer);
6816
7272
  }
6817
7273
 
6818
7274
  /** #785 P1b: remember a review the hub held as premature, so a green CI at this exact head can re-offer it once. Also watches that head. */
@@ -6995,6 +7451,10 @@ export class Tasks {
6995
7451
  }
6996
7452
 
6997
7453
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
7454
+ if (by !== USER && hasReviewAnchorInput(findings)) {
7455
+ try { this.d.onReviewObserverInput?.(by); }
7456
+ catch { throw new PreEffectToolRefusal("the anchored review input could not be privately scoped; retry after ordinary new input"); }
7457
+ }
6998
7458
  const requestCheck = this.requestCheck(by);
6999
7459
  if (typeof id !== "number" && !(typeof id === "string" && /^\s*\d+\s*$/.test(id))) this.need(id, false, true);
7000
7460
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
@@ -7005,7 +7465,9 @@ export class Tasks {
7005
7465
  let conformanceIdentity: string | undefined;
7006
7466
  let reviewRound: number | undefined;
7007
7467
  let withdrawalRevision: number | undefined;
7008
- const withdrawing = this.need(id, false, true).state === "approved" && verdict === "changes_requested";
7468
+ const before = this.need(id, false, true);
7469
+ const reviewObserverTurn = this.reviewObserverTurn(by, before.id);
7470
+ const withdrawing = before.state === "approved" && verdict === "changes_requested";
7009
7471
  const valid = (): Task => {
7010
7472
  requestCheck();
7011
7473
  const refused = authority?.();
@@ -7035,6 +7497,10 @@ export class Tasks {
7035
7497
  let parsed: ReviewFinding[];
7036
7498
  try { parsed = parseReviewFindings(findings, { person: by === USER, maxNits: this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS }); }
7037
7499
  catch (error) { throw new PreEffectToolRefusal(error instanceof Error ? error.message : "invalid structured findings"); }
7500
+ const anchorPrivacy = new Map(parsed.flatMap(item => item.anchor ? [[item.anchor.path, this.reviewPathPrivate(item.anchor.path)] as const] : []));
7501
+ try { parsed = parsed.map(item => item.anchor && anchorPrivacy.get(item.anchor.path) ? { ...item, anchor: privateReviewAnchor(item.anchor) } : item); }
7502
+ catch { throw new PreEffectToolRefusal("private finding path needs an existing lineRange or structural selector; remove quoted selectors and retry"); }
7503
+ const observerExcluded = reviewObserverTurn || parsed.some(item => item.anchor !== undefined);
7038
7504
  // #852 AC3 (amended): a code-scope reviewer's changes_requested is always recorded, never refused. Without an open structured finding it is
7039
7505
  // evidence-only: it does not count toward rejections or escalation, the task returns to its owner as usual, and the conductor gets one ids-only
7040
7506
  // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
@@ -7052,6 +7518,7 @@ export class Tasks {
7052
7518
  const identity = (task: Task) => JSON.stringify({ owner: task.owner, reviewer: task.reviewer, commit: task.refs.commit, receipt: task.sdlc, resolvedHead: this.findingHead(task), private: this.isPii(task), findings: this.reviewFindingSets(task) });
7053
7519
  const original = identity(initial);
7054
7520
  findingGuard = task => {
7521
+ if ([...anchorPrivacy].some(([path, privatePath]) => this.reviewPathPrivate(path) !== privatePath)) throw new PreEffectToolRefusal("review path privacy changed; refresh the task review and retry");
7055
7522
  if (identity(task) !== original) throw new PreEffectToolRefusal("task review identity, privacy or findings changed during the verdict; review current work again");
7056
7523
  if (by !== USER && suppliedFindings && this.d.privateTurn?.(by) && !this.isPii(task)) throw new PreEffectToolRefusal("structured review of ordinary work is unavailable during a private turn");
7057
7524
  if (parsed.filter(item => item.severity === "nit").length > (this.d.sdlc?.config().maxNits ?? DEFAULT_MAX_NITS)) throw new PreEffectToolRefusal("review nit cap exceeded");
@@ -7089,7 +7556,12 @@ export class Tasks {
7089
7556
  }
7090
7557
  if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(`task #${initial.id}: supplied review head ${findingHead} differs from recorded task head ${resolvedHead}; owner must re-file hub_task_done with refs.commit set to the supplied full SHA before review`);
7091
7558
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
7092
- const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
7559
+ // The configured PII screen sees reviewer prose and anchor metadata, but never immutable source selectors or projection content.
7560
+ const screenFindings = parsed.map(({ anchor, ...finding }) => ({ ...finding, ...(anchor ? { anchor: {
7561
+ path: anchor.path, ...(anchor.side ? { side: anchor.side } : {}), ...(anchor.contentHash ? { contentHash: anchor.contentHash } : {}),
7562
+ ...(anchor.projection ? { projection: anchor.projection } : {}),
7563
+ } } : {}) }));
7564
+ const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: screenFindings }) : note, valid())
7093
7565
  || parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
7094
7566
  let task = valid();
7095
7567
  let reviewedHead: string | undefined;
@@ -7132,7 +7604,7 @@ export class Tasks {
7132
7604
  if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
7133
7605
  }
7134
7606
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
7135
- if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
7607
+ if (!observerExcluded && verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
7136
7608
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
7137
7609
  cause: "unknown: review unmet items do not establish a cause", retryWhen: "unknown: a person must review the unmet items before accepting",
7138
7610
  evidenceRefs: [`task:${task.id}`, `reviewed-head:${reviewedHead ?? task.refs.commit ?? "unknown"}`] };
@@ -7166,12 +7638,12 @@ export class Tasks {
7166
7638
  this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
7167
7639
  }
7168
7640
  }
7169
- this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
7641
+ this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}${observerExcluded ? "" : `\n${note ?? ""}`}`, withheld);
7170
7642
  this.settleMemory(next);
7171
7643
  this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
7172
- this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
7644
+ this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii, "owner", observerExcluded));
7173
7645
  await this.releaseDependents(next);
7174
- this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
7646
+ this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld || observerExcluded));
7175
7647
  // A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
7176
7648
  this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
7177
7649
  this.releaseNotice(`task #${next.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(next, by, withheld));
@@ -7179,17 +7651,17 @@ export class Tasks {
7179
7651
  }
7180
7652
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
7181
7653
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag, task.history.length);
7182
- void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
7654
+ void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld || observerExcluded);
7183
7655
  if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
7184
7656
  if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
7185
- if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
7657
+ if (!observerExcluded && candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
7186
7658
  try { this.d.memoryCheck?.(); this.d.candidates?.put({ ...candidate, evidenceRefs: [...candidate.evidenceRefs, `review:${rejected.id}:${rejected.history.length}`] }, "public"); } catch { /* a saved review remains authoritative if the optional sidecar fails */ }
7187
7659
  }
7188
7660
  this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
7189
7661
  // Evidence-only is not a verdict on the owner's work: no failed outcome, no contradiction of an earlier approval, and no escalation count.
7190
7662
  if (rejected.owner && !evidenceOnly) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
7191
7663
  if (!evidenceOnly) this.contradict(rejected);
7192
- this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
7664
+ this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}${observerExcluded ? "" : `\n${note ?? ""}`}`, withheld);
7193
7665
  if (evidenceOnly) this.tellConductorEvidenceOnly(rejected, by);
7194
7666
  if (withdrawing) {
7195
7667
  this.releaseNotice(`task ${this.publicTitle(rejected)}: could not tell its owner the approval was withdrawn`, () =>
@@ -7212,12 +7684,12 @@ export class Tasks {
7212
7684
  if (moved.owner !== rejected.owner) return moved;
7213
7685
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
7214
7686
  this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
7215
- this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii));
7687
+ this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii, "owner", observerExcluded));
7216
7688
  return moved;
7217
7689
  }
7218
7690
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
7219
7691
  this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
7220
- this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii));
7692
+ this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii, "owner", observerExcluded));
7221
7693
  return reopened;
7222
7694
  }
7223
7695
 
@@ -7345,6 +7817,8 @@ export class Tasks {
7345
7817
  queueAssignment?.current();
7346
7818
  const task = this.need(id, role === "owner", preEffect);
7347
7819
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
7820
+ // #1176: routing hands a person-run leg to nobody, so naming an owner would only be a silent no-op plus a stall report. Refused for the conductor and planner (they are agents) and for the person alike: the person clears the mark first.
7821
+ if (role === "owner" && task.needsPerson) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is a person-run leg: no agent is named its owner. Clear the mark first (ahub backlog plan ${task.id} <revision> '{"needsPerson":false}' <reason>), then assign it`);
7348
7822
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
7349
7823
  this.requireCapabilities(task, peer, role, preEffect);
7350
7824
  if (role === "reviewer") {
@@ -7552,6 +8026,11 @@ export class Tasks {
7552
8026
  if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: shared memory excludes private data");
7553
8027
  // Shared with every other peer as well, so text that matches a PII pattern is refused like a note about a PII task.
7554
8028
  const title = String(input.title ?? "").trim();
8029
+ if (by !== USER) {
8030
+ let scoped = false;
8031
+ try { scoped = this.reviewObserverTurn(by, task?.id); } catch { throw new PreEffectToolRefusal("shared memory is held because the open review scope (sent comments, anchored findings, unsettled delivery or current review input) could not be checked; inspect ahub task list after recovery"); }
8032
+ if (scoped) throw new PreEffectToolRefusal(`shared memory is held while an open review scope (sent comments, anchored findings, unsettled delivery or current review input) exists; complete or review the task, ask the person to dismiss comments if appropriate, inspect ahub queue list --peer ${by} and explicitly settle any delivery, then wait for ordinary new input after completion`);
8033
+ }
7555
8034
  if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: shared memory excludes private data");
7556
8035
  if (input.horizon !== undefined && input.horizon !== "short" && input.horizon !== "long") throw new Error('horizon is "short" or "long"');
7557
8036
  if (input.scope !== undefined && input.scope !== "project" && input.scope !== "shared") throw new Error('scope is "project" or "shared"');
@@ -7563,6 +8042,7 @@ export class Tasks {
7563
8042
  requestCheck();
7564
8043
  const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) as Kind : "finding";
7565
8044
  const native = this.saveNative(by, kind, text, title, task, input.horizon === "long" || input.horizon === "short" ? input.horizon : undefined, input.scope === "shared" ? "shared" : "project");
8045
+ if (native && "refused" in native) throw new PreEffectToolRefusal(`${native.refused}; save it with horizon "short" or correct the machine/repository privacy policy, then retry`);
7566
8046
  if (task && this.d.memory) this.markMemoryReference(task, by);
7567
8047
  const res = !native && this.d.memory ? await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } }) : undefined;
7568
8048
  if (!res && !native) return "memory store unavailable; nothing saved";
@@ -7617,14 +8097,16 @@ export class Tasks {
7617
8097
  * long, decisions, contracts and fails are long-term. Fail-open: a store that cannot be written costs one notice.
7618
8098
  * The caller has screened the text for PII; never a PII task's.
7619
8099
  */
7620
- private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | undefined {
8100
+ private saveNative(by: PeerId, kind: Kind, text: string, title: string, task: Task | undefined, horizon?: Tier, scope: Scope = "project", symbols?: MemorySymbolNodes): { id: string; tier: Tier } | { refused: string } | undefined {
7621
8101
  const st = this.d.store;
7622
8102
  if (!st || (task && this.isPii(task))) return undefined;
8103
+ let longTerm = horizon === "long" || (horizon !== "short" && !!task && task.state === "approved" && PROMOTED_KINDS.includes(kind));
7623
8104
  try { // never throws: callers run after a board write
7624
8105
  this.d.memoryCheck?.();
7625
8106
  // Approved, or settled in the store already: the snapshot the caller holds can predate an approval made while it awaited.
7626
8107
  const approved = !!task && (task.state === "approved" || st.store.settled(this.memoryKey(task)));
7627
8108
  const tier: Tier = horizon ?? (approved && PROMOTED_KINDS.includes(kind) ? "long" : "short");
8109
+ longTerm ||= tier === "long";
7628
8110
  const about = extractNodes(`${title}\n${text}`, { cwd: this.d.cwd, project: st.project, repo: st.repo, deny: st.deny, paths: [...(task?.refs.paths ?? []), ...(task?.plan?.paths ?? [])], symbols: symbols ? [] : task?.plan?.symbols?.map(normalizeMemorySymbolInput), ...(task ? { task: { id: task.id, created: task.created } } : {}) });
7629
8111
  // Existing notes remain immutable; the completion note connects actual symbols through the shared task node.
7630
8112
  if (symbols && task) {
@@ -7633,12 +8115,20 @@ export class Tasks {
7633
8115
  }
7634
8116
  // A short-term note on a task already approved (before hub memory existed, or late): the task is settled first, so
7635
8117
  // the note gets its own deadline and is never taken for a source waiting for promotion.
7636
- if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) st.store.settle(this.memoryKey(task), by);
8118
+ if (task && approved && tier === "short" && !st.store.settled(this.memoryKey(task))) this.settleMemory(task);
8119
+ const row: NoteInput = { op: "note", project: st.project, scope, tier, kind, peer: by, ...(title ? { title } : {}), text, ...(task ? { task: this.memoryKey(task) } : {}), about, source: by === USER ? "person" : "hub" };
8120
+ if (tier === "long") {
8121
+ try {
8122
+ const result = this.memorySyncPolicy().screen(row);
8123
+ if (!result.ok) return { refused: `long-term note declined by ${result.rule}` };
8124
+ } catch { return { refused: "machine privacy policy unavailable" }; }
8125
+ }
7637
8126
  if (task) this.markMemoryReference(task, by);
7638
- const id = st.store.note({ op: "note", project: st.project, scope, tier, kind, peer: by, ...(title ? { title } : {}), text, ...(task ? { task: this.memoryKey(task) } : {}), about, source: by === USER ? "person" : "hub" }, horizon === "short");
8127
+ const id = st.store.note(row, horizon === "short");
7639
8128
  st.onNote?.(id);
7640
8129
  return { id, tier };
7641
8130
  } catch (e) {
8131
+ if (longTerm) return { refused: "machine privacy policy unavailable" };
7642
8132
  this.releaseNotice(`a ${kind} note was not saved to hub memory (${(e as Error).message})`);
7643
8133
  return undefined;
7644
8134
  }
@@ -7654,17 +8144,40 @@ export class Tasks {
7654
8144
  let n = 0;
7655
8145
  for (const t of this.d.board.list("approved")) {
7656
8146
  if (this.isPii(t)) continue;
7657
- try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true })) { this.settleMemory(t); n++; } }
8147
+ try { this.d.memoryCheck?.(); const key = this.memoryKey(t); if (this.d.store.store.hasTask(key) && !this.d.store.store.settled(key, { includeLegacy: true }) && this.settleMemory(t)) n++; }
7658
8148
  catch { /* fail-open: tried again at the next start */ }
7659
8149
  }
7660
8150
  return n;
7661
8151
  }
7662
8152
 
7663
- private settleMemory(task: Task): void {
7664
- if (!this.d.store || this.isPii(task)) return;
7665
- // The store screens each note as it moves to the synced log (the promotion screen the daemon gives it).
7666
- try { this.d.memoryCheck?.(); this.d.store.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB); }
7667
- catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); }
8153
+ private settleMemory(task: Task): boolean {
8154
+ const st = this.d.store;
8155
+ if (!st || this.isPii(task)) return false;
8156
+ try {
8157
+ this.d.memoryCheck?.();
8158
+ st.store.settle(this.memoryKey(task), task.reviewer ?? task.owner ?? HUB);
8159
+ return true;
8160
+ } catch (e) { this.releaseNotice(`task #${task.id}: hub memory could not settle its notes (${(e as Error).message})`); return false; }
8161
+ }
8162
+
8163
+ /** Standalone outbound rules plus the hub's current routing and project-private-path policy. */
8164
+ private memorySyncPolicy() {
8165
+ const st = this.d.store!;
8166
+ const hostScreen = (row: Record<string, unknown>) => {
8167
+ const taskKeyValue = typeof row.task === "string" ? row.task : undefined;
8168
+ const match = taskKeyValue ? /#(\d+)@(\d+)$/.exec(taskKeyValue) : null;
8169
+ const task = match ? this.d.board.get(Number(match[1])) : undefined;
8170
+ const taskPaths = task && String(task.created) === match?.[2] ? [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])] : [];
8171
+ const title = typeof row.title === "string" ? row.title : "";
8172
+ const text = typeof row.text === "string" ? row.text : "";
8173
+ const about = Array.isArray(row.about) ? row.about.filter((value): value is string => typeof value === "string") : [];
8174
+ const extraText = JSON.stringify(Object.fromEntries(Object.entries(row).filter(([key]) => !["title", "text", "about", "task", "project"].includes(key))));
8175
+ const note = { title, text: `${text}\n${extraText}`, about: [...about, ...taskPaths.map(path => `path:${path}`)], ...(typeof row.task === "string" ? { task: row.task } : {}), ...(typeof row.project === "string" ? { project: row.project } : {}) };
8176
+ const safe = withoutPii([note], this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
8177
+ const privatePath = mentionsPrivatePath(`${title}\n${text}\n${extraText}`, note.about, this.d.piiPaths ?? [], this.d.cwd);
8178
+ return safe.length && !privatePath ? { ok: true as const } : { ok: false as const, rule: "hub privacy policy: PII pattern or private path" };
8179
+ };
8180
+ return createMemorySyncPolicy({ home: st.store.profile.home, cwd: this.d.cwd, denyPaths: st.deny, piiPatterns: this.d.routing().signals.pii_patterns, hostScreen });
7668
8181
  }
7669
8182
 
7670
8183
  /**
@@ -7688,15 +8201,16 @@ export class Tasks {
7688
8201
  const title = `agent-hub task #${task.id}: ${task.title}`.slice(0, 120);
7689
8202
  if (this.aboutPrivatePath(title, text, task)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (it is about a private path)`);
7690
8203
  const native = this.saveNative(by, kind, text, title, task, undefined, "project", symbols);
8204
+ if (native && "refused" in native) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${native.refused})`);
7691
8205
  if (!native && this.d.memory) {
7692
8206
  try { this.markMemoryReference(task, by); } catch { return this.releaseNotice(`task #${task.id}: memory reference could not be recorded; note was not saved`); }
7693
8207
  void this.d.memory.save({ text, title, project: this.d.project, metadata: { peer: by, task: task.id, kind } });
7694
8208
  }
7695
8209
  }
7696
8210
 
7697
- private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner"): void {
8211
+ private tell(task: Task, body: string, pii: boolean, audience: "owner" | "withdrawal" = "owner", reviewObserverExcluded = false): void {
7698
8212
  if (!task.owner || task.owner === USER) return;
7699
- this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
8213
+ this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: reviewObserverExcluded ? "review" : "task", priority: "important", refs: { task: String(task.id), ...(reviewObserverExcluded ? { reviewPackage: `finding-${crypto.randomUUID()}` } : {}), ...(audience === "owner" ? { taskRecipient: "owner" as const } : {}) }, ...(pii ? { private: true } : {}) }));
7700
8214
  }
7701
8215
  }
7702
8216