@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
package/src/hub/tasks.ts CHANGED
@@ -1,21 +1,28 @@
1
+ import type { ContextAdmission } from "./context-window.ts";
2
+ import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
1
3
  import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
2
4
  import { createHash } from "node:crypto";
3
5
  import { taskShellBindingValid, type TaskShellBinding } from "./task-shell-command.ts";
4
6
  import { buildTaskCodeSymbols, publicTaskCodeSymbols, taskCodeSymbolsDisjoint, HOST_CODE_SYMBOLS } from "./task-code-symbols.ts";
5
7
  import { normalizePlanSymbol, symbolsOverlap } from "./symbol-normalizer.ts";
6
8
  import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type TestIntegrity } from "./test-integrity.ts";
9
+ import { evalAdvice, evalGapBlockers, evalRequiredChecks, readEvalFor } from "./eval-gate.ts";
7
10
  import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
8
11
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
9
12
  import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
10
13
  import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
11
- import { sdlcMetrics, type BandResult, type MetricTask, type MetricsReport } from "./sdlc-metrics.ts";
14
+ import { folderDetail, MAX_NEW_PER_POLL, parseEvery, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
15
+ import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
16
+ import { DEFAULT_BAND, EPISODE_LOOKBACK, sdlcMetrics, type BandMetric, type BandResult, type MetricTask, type MetricsReport, type SeriesPoint } from "./sdlc-metrics.ts";
17
+ import { readSecurityScan } from "./security-baseline.ts";
18
+ import { cleanMergeCost } from "./cost-governance.ts";
12
19
  import type { InstancesConfig } from "./instances.ts";
13
20
  import { composeRememberText } from "./memory-fail.ts";
14
21
  import { VERSION } from "../version.ts";
15
22
  import { NoWriteTurns } from "./no-writes.ts";
16
23
  import { readGitHubImport, type GitHubSource, type GitHubImportSelection } from "./github-import.ts";
17
24
  import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS, actualTaskDates, planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
18
- import { readMergeQueue, type DeliveryTask } from "./sdlc-queue.ts";
25
+ import { readMergeQueue, type DeliveryTask, type MergeQueue } from "./sdlc-queue.ts";
19
26
  import { guardedMerge, mergeModeFor, mergeView, type MergeView } from "./sdlc-merge.ts";
20
27
  import { mirrorVerdict as mirrorToGitHub, type MirrorVerdict } from "./sdlc-mirror.ts";
21
28
  import { CODE_SCOPE_LINE, evidenceOnlyNotice, type ReviewScope } from "./review-scope.ts";
@@ -28,17 +35,18 @@ import type { MemoryScreen } from "./read-tools.ts";
28
35
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
29
36
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
30
37
  import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
38
+ import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, selectGroups, type CriterionGroup } from "./learning.ts";
31
39
  import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
32
40
  import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
33
41
  import { isAbsolute, relative } from "node:path";
34
42
  import type { Briefs, NativeItem } from "../memory/brief.ts";
35
43
  import type { MemoryClient } from "../memory/client.ts";
36
44
  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";
37
- import { 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 } from "./board.ts";
45
+ 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 } from "./board.ts";
38
46
  import type { Bus } from "./bus.ts";
39
47
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
40
48
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
41
- import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
49
+ import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
42
50
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
43
51
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
44
52
  import { realPath } from "./project.ts";
@@ -49,8 +57,13 @@ import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief
49
57
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
50
58
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
51
59
  import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
60
+ import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
52
61
  import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, 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";
53
62
 
63
+ function requireLocalTaskPeer(peer: unknown): void {
64
+ if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
65
+ }
66
+
54
67
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
55
68
 
56
69
  export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
@@ -67,6 +80,10 @@ export interface TasksDeps {
67
80
  board: Board;
68
81
  bus: Bus;
69
82
  routing: () => Routing;
83
+ /** #800: normalized machine-local admission policy; absent or both zero preserve the old queue behaviour. */
84
+ queueBackpressure?: () => QueueBackpressurePolicy;
85
+ /** Host-only native context snapshots, separate from checkpoint notification and quota. */
86
+ contextAdmission?: () => ContextAdmission;
70
87
  /** Startup-bound project declarations; routing never discovers instances from peer names. */
71
88
  instances?: () => InstancesConfig;
72
89
  /** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
@@ -98,6 +115,10 @@ export interface TasksDeps {
98
115
  /** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
99
116
  memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
100
117
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
118
+ /** #785 P1b: the per-head CI watcher's bounds; tests shorten them, the defaults are `CI_WATCH_*` in `ci-watch.ts`. */
119
+ ciWatch?: { deadlineMs?: number; pollMs?: number };
120
+ /** #785 P1c: `tasks.auto_pull`, read at each capacity release so a reload applies to the next one; absent or false pulls nothing. */
121
+ autoPull?: () => boolean;
101
122
  briefs?: Briefs;
102
123
  /** Screened live fail notes directly intersecting the task plan or refs; read-only advice. */
103
124
  planFails?: (task: Task) => NativeItem[];
@@ -115,6 +136,11 @@ export interface TasksDeps {
115
136
  reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
116
137
  /** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
117
138
  requestAuthority?: (by: PeerId) => (() => string | undefined) | undefined;
139
+ /**
140
+ * #933: whether `by` runs this call inside an admitted, audited conductor operation. The host rechecks the role on every call and throws
141
+ * its authority refusal when it ended; a peer outside such a call gets `false`. Host-only: never read from configuration or a tool.
142
+ */
143
+ operational?: (by: PeerId) => boolean;
118
144
  memory?: MemoryClient;
119
145
  /** Captured native profile fence at our memory operation boundaries; no board mutation. */
120
146
  memoryCheck?: () => void;
@@ -124,6 +150,8 @@ export interface TasksDeps {
124
150
  notify: (line: string) => void;
125
151
  /** The completion check configured for a class, if any (local config only; issue #7). */
126
152
  check?: (cls: TaskClass, task?: Task) => string | undefined;
153
+ /** #901: the key of the `checks` entry `check` chose for this task (the profile class alias, else the class): the same lookup, so the two cannot disagree. Absent: the key is unknown. */
154
+ checkKey?: (cls: TaskClass, task?: Task) => string | undefined;
127
155
  /** Runs one check; the hub runs them one at a time. */
128
156
  runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
129
157
  /** Hands a saved note to every other peer; it rides on their next delivery. */
@@ -154,6 +182,8 @@ export interface TasksDeps {
154
182
  roles?: Record<string, string[]>;
155
183
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
156
184
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
185
+ /** #334: natural-language intake. `split` is the hub model's one call, `partsSchema` the profile's `hub_backlog_split` parts schema, `sourceKnown` the source registry. */
186
+ intake?: { split: (system: string, user: string, opts: { campusOnly: boolean }) => Promise<string | undefined>; partsSchema: () => unknown; sourceKnown: (id: string) => boolean };
157
187
  /** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
158
188
  piiScreen?: (text: string) => Promise<PiiVerdict>;
159
189
  /** Each screen verdict, for the record: item, label, source and category, never the text. */
@@ -186,27 +216,44 @@ export interface TasksDeps {
186
216
  /** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
187
217
  recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
188
218
  sweep?: TaskSweepConfig;
189
- /** Shutdown or coordinator work holds task sweeps too. */
219
+ /** #798 D1: the project's declared submodule paths, project-relative. Absent: none are known, so no notice. */
220
+ submodules?: () => string[];
221
+ /** Shutdown or coordinator work (recovery) holds task sweeps, and a guarded merge at its dispatch (#491). */
190
222
  sweepHeld?: () => boolean;
191
223
  /** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
192
224
  escalateAfter?: () => number;
225
+ /** #489 H1: current host configuration, read at each planner-notice flush; no task/request override. */
226
+ groomFyiWindowMin?: () => number;
227
+ /** #489 H2: current host configuration, read at each use (no cache, no task/request override); absent is the constant each replaces. */
228
+ demoteAt?: () => number;
229
+ demotionHalfLifeH?: () => number;
230
+ contradictionWindowDays?: () => number;
231
+ rescreenMax?: () => number;
232
+ splitMinObservations?: () => number;
233
+ splitMaxFailureShare?: () => number;
193
234
  }
194
235
 
195
236
  /** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
196
237
  export const ESCALATE_AFTER = 3;
197
238
  // Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
198
239
  // reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
199
- // ponytail: fixed constants; make them config when someone needs to tune them.
200
- const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
201
- const DEMOTE_AT = 1.5;
202
- /** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
203
- const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
240
+ // #489 H2: `tasks.demotion_half_life_h` and `tasks.demote_at` set these; unset they are exactly the constants below (pinned in test/operational-settings.test.ts).
241
+ export const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
242
+ export const DEMOTE_AT = 1.5;
243
+ /** An approval counts as contradicted when work on the same places fails within this window (issue #35); `tasks.contradiction_window_days` sets it. */
244
+ export const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
204
245
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
246
+ const QUEUE_PRESSURE_CODES: readonly QueueBackpressureCode[] = ["queue-count", "queue-age", "queue-age-unknown"];
247
+ const isQueuePressureCode = (code: string): code is QueueBackpressureCode => (QUEUE_PRESSURE_CODES as readonly string[]).includes(code);
248
+ const QUEUE_RECHECK_PAGE = 32;
249
+ interface QueuePressurePass { ownerAfter: number; reviewAfter: number; ownerDone: boolean; reviewDone: boolean; retained: boolean; generation: number }
205
250
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
206
251
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
207
252
  QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
208
253
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
209
254
  QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
255
+ QUIET_EVENTS.add("claim verdict"); // #901: a person's bookkeeping on a plan claim moves nothing, so it must not turn a running check into "check finished late".
256
+ QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
210
257
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
211
258
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
212
259
  const checking = task.history.findLastIndex(entry => entry.event === "done (checking)");
@@ -306,11 +353,11 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
306
353
  /** What the PII screen reads of a task (#198): the text the patterns are checked against. */
307
354
  const screenText = (t: { title: string; detail: string; plan?: TaskPlan; external?: Task["external"] }) => [t.title, t.detail, planText(t.plan), t.external?.repo, t.external?.url].filter(value => value !== undefined).join("\n");
308
355
  /** Re-screens of one task whose verdict was unknown, per hub run (#198). */
309
- const RESCREEN_MAX = 10;
356
+ export const RESCREEN_MAX = 10;
310
357
  /** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
311
358
  const GROOM_NOTICE_PREFIX = "The board changed";
312
359
  /** #758: a board change that needs no plan review is recorded at most once per planner in this window. */
313
- const GROOM_FYI_WINDOW_MS = 10 * 60_000;
360
+ export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
314
361
 
315
362
  /**
316
363
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
@@ -354,8 +401,17 @@ export class Tasks {
354
401
  private sweepConfig: TaskSweepConfig;
355
402
  private readonly noWriteTurns = new NoWriteTurns();
356
403
  private sweeping = false;
404
+ /** #785 P1b: one bounded CI watcher per exact PR head, and the reviews the hub held as premature, by task id. */
405
+ private readonly ciWatchers: CiWatchers;
406
+ private readonly heldReviews = new Map<number, { head: string; reviewer: PeerId; revision: number }>();
357
407
 
358
408
  constructor(private readonly d: TasksDeps) {
409
+ this.ciWatchers = new CiWatchers({
410
+ ...d.ciWatch,
411
+ poll: watch => pollCi(watch, watch.head, d.sdlc?.config().requiredChecks ?? [], d.sdlc?.gh ?? sdlcGh),
412
+ current: watch => this.ciWatchCurrent(watch),
413
+ finish: (watch, outcome) => this.ciCompleted(watch, outcome),
414
+ });
359
415
  d.board.trackPlanRevisions = () => (d.sdlc?.config().mode ?? "off") !== "off";
360
416
  this.repositories = new TaskRepositories(d.cwd, d.repository);
361
417
  d.board.proposalPlanner = task => this.proposalPlanner(task);
@@ -409,15 +465,16 @@ export class Tasks {
409
465
  }
410
466
 
411
467
  /** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
412
- private screenOn = (): boolean => {
468
+ screenOn = (): boolean => {
413
469
  const r = this.d.routing();
414
470
  return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
415
471
  };
416
472
 
417
473
  /** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
418
- private requestCheck(by: PeerId): () => void {
474
+ private requestCheck(by: PeerId, fence?: () => string | undefined): () => void {
475
+ requireLocalTaskPeer(by);
419
476
  const authority = this.d.requestAuthority?.(by);
420
- const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
477
+ const check = () => { const reason = authority?.() ?? fence?.(); if (reason) throw new PreEffectToolRefusal(reason); };
421
478
  check(); return check;
422
479
  }
423
480
 
@@ -536,6 +593,7 @@ export class Tasks {
536
593
 
537
594
  /** Captured at native turn start, not supplied by a model. */
538
595
  ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
596
+ requireLocalTaskPeer(peer);
539
597
  return this.d.board.list("in_progress").flatMap(task => {
540
598
  const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
541
599
  return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
@@ -543,6 +601,7 @@ export class Tasks {
543
601
  }
544
602
 
545
603
  recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
604
+ requireLocalTaskPeer(peer);
546
605
  this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
547
606
  const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
548
607
  for (const binding of bindings) {
@@ -647,6 +706,33 @@ export class Tasks {
647
706
  }
648
707
  return new Set(rows.map(row => find(row.id))).size;
649
708
  }
709
+ /**
710
+ * #514a: the same recurrence path for recurring UNMET CRITERIA. A criterion id that reviews keep naming unmet on the same profile and class, on at least
711
+ * `threshold` tasks that are at least two separate pieces of work (`workUnits`), files one backlog card for a person to review, with the `profile_overrides`
712
+ * fragment as text. Nothing is applied. A PII task and a withheld or private-turn review are never evidence and never named; the card carries ids and counts only.
713
+ */
714
+ private proposeCriterionCandidates(task: Task, by: PeerId, withheld: boolean): void {
715
+ if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !task.history.at(-1)?.unmetCriteria?.length) return;
716
+ const rows = this.d.board.list();
717
+ const keys = new Set(rows.filter(row => row.class === "plan" && row.history[0]?.by === HUB)
718
+ .flatMap(row => row.detail?.split("\n").filter(line => line.startsWith("Criterion candidate key: ")) ?? []));
719
+ // Only the rows that may count: candidateTextAllowed refuses a PII row and one whose text the screen refuses, so those are evidence of nothing.
720
+ const eligible = rows.filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
721
+ const named = new Set(task.history.at(-1)!.unmetCriteria);
722
+ const created: number[] = [];
723
+ // No await separates the durable dedupe read from creation; concurrent verdicts see the first card.
724
+ const groups = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() });
725
+ const unitsOf = (group: CriterionGroup) => this.workUnits(eligible.filter(row => group.tasks.includes(row.id)));
726
+ for (const group of selectGroups(groups, { named, threshold: LEARNING_DEFAULTS.threshold, max: LEARNING_DEFAULTS.maxGroups, has: g => keys.has(criterionKey(g)), units: unitsOf })) {
727
+ const card = candidateCard(group, Math.round(LEARNING_DEFAULTS.windowMs / 86_400_000), unitsOf(group));
728
+ const draft = { title: card.title, detail: card.detail, refs: { paths: ["docs/profiles.md"] } };
729
+ const signals = this.signalsFor(draft);
730
+ if (this.isPii({ signals }) || !this.candidateTextAllowed(`${card.title}\n${card.detail}`)) continue;
731
+ const proposal = this.d.board.propose(HUB, { ...draft, class: "plan", backlog: true, signals });
732
+ this.incomingBacklog.add(proposal.id); keys.add(criterionKey(group)); created.push(proposal.id);
733
+ }
734
+ for (const id of created) this.releaseNotice(`task #${id}: recurring unmet criterion candidate proposed in the backlog`);
735
+ }
650
736
  private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
651
737
  if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
652
738
  const rows = this.d.board.list();
@@ -683,7 +769,14 @@ export class Tasks {
683
769
 
684
770
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
685
771
  const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
686
- const rest = { ...taskRest, ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
772
+ const plannerBinding = history.findLast(h => h.planner === task.planner && h.plannerReason !== undefined);
773
+ const ownerBinding = history.findLast(h => h.owner === task.owner && ["assigned", "reassigned", "escalated", "backlog pulled"].includes(h.event));
774
+ const reviewerBinding = history.findLast(h => ["assigned", "reassigned", "escalated", "done", "reviewer changed", "reviewer unavailable"].includes(h.event));
775
+ const rest = { ...taskRest,
776
+ ...(plannerBinding?.plannerReason && ["rotation", "named", "kept", "none"].includes(plannerBinding.plannerReason) ? { plannerReason: plannerBinding.plannerReason } : {}),
777
+ ...(ownerBinding?.reason && TASK_MOVE_REASONS.includes(ownerBinding.reason) ? { ownerReason: ownerBinding.reason === "manual" && ownerBinding.event === "reassigned" && ownerBinding.by !== HUB ? "named" : ownerBinding.reason } : {}),
778
+ ...(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" } : {}),
779
+ ...(history.some(h => h.by === USER && h.planDeparture) ? { planDepartureAccepted: true } : {}) };
687
780
  if (this.isPii(task)) return { ...rest, actualDates: actualTaskDates(task), refs: {}, plan: {}, ...(task.sdlc ? { sdlc: { stage: stageDefinition(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc.stage)?.base ?? "unknown" } } : {}), title: "[pii]", detail: "[pii]" };
688
781
  if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
689
782
  const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
@@ -708,6 +801,7 @@ export class Tasks {
708
801
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
709
802
  ...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
710
803
  ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
804
+ ...(h.queueReviewHeld ? { queueReviewHeld: true } : {}),
711
805
  ...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
712
806
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
713
807
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
@@ -742,14 +836,15 @@ export class Tasks {
742
836
  /** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
743
837
  demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
744
838
  const sums = new Map<PeerId, { bad: number; good: number }>();
839
+ const halfLifeH = this.d.demotionHalfLifeH?.(), halfLifeMs = halfLifeH === undefined ? DEMOTION_HALF_LIFE_MS : halfLifeH * 3_600_000, demoteAt = this.d.demoteAt?.() ?? DEMOTE_AT;
745
840
  for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
746
- const w = 0.5 ** ((now - o.at) / DEMOTION_HALF_LIFE_MS);
841
+ const w = 0.5 ** ((now - o.at) / halfLifeMs);
747
842
  const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
748
843
  if (o.ok) s.good += w;
749
844
  else s.bad += w;
750
845
  sums.set(o.peer, s);
751
846
  }
752
- return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
847
+ return Object.fromEntries([...sums].filter(([, s]) => s.bad >= demoteAt && s.bad > s.good).map(([p, s]) => [p, s.bad]));
753
848
  }
754
849
 
755
850
  /** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
@@ -786,20 +881,57 @@ export class Tasks {
786
881
  }
787
882
 
788
883
  /** Workload is board/queue data only: wip() never calls health() or weights(). */
789
- private instanceRouting(): InstanceRoutingOptions {
884
+ private instanceRouting(ownerIsolation?: Readonly<Record<string, boolean>>): InstanceRoutingOptions {
790
885
  const instances = this.d.instances?.();
791
886
  if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
792
887
  const peers = [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
793
- return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
888
+ return { adapterCatalog: this.d.adapterCatalog, instances, ownerIsolation: ownerIsolation ?? this.d.ownerIsolation?.() ?? {}, wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])), load: Object.fromEntries(peers.map(peer => [peer, this.d.bus.queued(peer)])) };
889
+ }
890
+
891
+ private queuePolicy(): QueueBackpressurePolicy | undefined {
892
+ const policy = queueBackpressurePolicy(this.d.queueBackpressure?.());
893
+ return policy.max_queued > 0 || policy.max_oldest_s > 0 ? policy : undefined;
894
+ }
895
+
896
+ /** Admission is pending metadata only; an unavailable read never invents an empty/fresh queue. */
897
+ private queueRouting(peers: readonly PeerId[], policy = this.queuePolicy()): Pick<InstanceRoutingOptions, "queues" | "queueBackpressure"> & { now?: number } {
898
+ if (!policy) return {};
899
+ const queues = Object.fromEntries([...new Set(peers)].map(peer => {
900
+ let value: QueuePressure;
901
+ try { value = this.d.bus.assignmentQueue(peer); } catch { value = { queued: NaN }; }
902
+ return [peer, value];
903
+ }));
904
+ return { queueBackpressure: policy, queues, now: this.d.now?.() ?? Date.now() };
905
+ }
906
+
907
+ private contextRouting(): Pick<InstanceRoutingOptions, "contextAdmission"> & { now?: number } {
908
+ const admission = this.d.contextAdmission?.();
909
+ if (!admission || !Number.isFinite(admission.policy.max_used) || admission.policy.max_used <= 0 || admission.policy.max_used > 1) return {};
910
+ return { contextAdmission: admission, now: this.d.now?.() ?? Date.now() };
911
+ }
912
+
913
+ /** Public ids/counts/reasons only; no board history, pending envelope body or task text is read. */
914
+ queuePressureStatus(peers: readonly string[]): QueueBackpressureStatus | undefined {
915
+ const policy = this.queuePolicy();
916
+ if (!policy) return undefined;
917
+ const all = [...new Set(peers)].filter(peer => /^[a-z][a-z0-9-]{0,31}$/.test(peer));
918
+ const bounded = all.slice(0, 4096), input = this.queueRouting(bounded, policy), now = input.now!;
919
+ const rows = Object.fromEntries(bounded.map(peer => {
920
+ const value = peerEntry(input.queues, peer), refusal = queueBackpressure(peer, policy, value, now);
921
+ const queued = value && Number.isSafeInteger(value.queued) && value.queued >= 0 ? value.queued : null;
922
+ const at = queued !== null && queued > 0 && typeof value?.oldestQueuedAt === "number" && Number.isSafeInteger(value.oldestQueuedAt) && value.oldestQueuedAt >= 0 && value.oldestQueuedAt <= now ? value.oldestQueuedAt : null;
923
+ return [peer, { queued, oldestQueuedAt: at, oldestAgeMs: at === null ? null : now - at, blocked: refusal !== undefined, ...(refusal ? { code: refusal.code, reason: refusal.reason } : {}) }];
924
+ }));
925
+ return { policy, peers: rows, ...(all.length > bounded.length ? { truncated: true as const } : {}) };
794
926
  }
795
927
 
796
928
  /** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
797
- private health() {
929
+ private health(ownerIsolation?: Readonly<Record<string, boolean>>) {
798
930
  const failing = this.d.failing?.();
799
931
  const held = this.d.held?.();
800
- const workload = this.instanceRouting();
932
+ const workload = this.instanceRouting(ownerIsolation);
801
933
  const peers = workload.instances ? [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(workload.instances), ...this.d.bus.peers.keys()])] : (this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind);
802
- return { ...workload, reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
934
+ return { ...workload, ...this.contextRouting(), ...this.queueRouting([...new Set([...peers, ...this.d.bus.peers.keys()])]), reviewPool: { pool: this.d.review?.pool ?? DEFAULT_REVIEW_POOL, rerotate: this.d.review?.rerotate, required: this.d.review?.pool !== undefined, ...(this.d.review?.same_family === "never" ? { sameFamily: "never" as const } : {}) }, ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer.id, workload.instances)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: peers.filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
803
935
  }
804
936
 
805
937
  private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
@@ -819,21 +951,30 @@ export class Tasks {
819
951
  private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
820
952
  if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
821
953
  const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
822
- const selection = assign(draft, this.states(), this.d.routing(), { ...this.weights(task.class, draft), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
823
- return selection.reviewSelection ? selection : undefined;
954
+ const weights = this.weights(task.class, draft);
955
+ const selection = assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
956
+ const heldSelection = task.reviewer === USER && task.history[this.reviewRequestRevision(task) - 1]?.queueReviewHeld === true;
957
+ const previous = task.reviewer && task.reviewer !== USER ? assign(draft, this.states(), this.d.routing(), { ...weights, candidates: [], namedReviewer: task.reviewer, ...(task.owner ? { notReviewer: task.owner } : {}) }) : undefined;
958
+ const queueRefused = previous?.refusals?.some(row => row.role === "reviewer" && row.peer === task.reviewer && isQueuePressureCode(row.code));
959
+ const newlyBlocked = selection.reviewBlocked && selection.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
960
+ return selection.reviewSelection || queueRefused || newlyBlocked || heldSelection ? selection : undefined;
824
961
  }
825
962
 
826
- private reviewMetadata(assignment?: Assignment): ReviewerHistory {
963
+ private reviewMetadata(assignment?: Assignment, task?: Task): ReviewerHistory {
827
964
  const choice = assignment?.reviewSelection;
828
965
  const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
829
966
  const reason = choice && assignment?.plannerFallback
830
967
  ? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
831
968
  : choice?.reason;
832
- return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}) };
969
+ const queueReviewHeld = task !== undefined && !this.isPii(task) && assignment?.reviewer === USER && assignment.reviewBlocked === true
970
+ && assignment.refusals?.some(row => row.role === "reviewer" && isQueuePressureCode(row.code));
971
+ const queueReviewBinding = task ? this.queueReviewBinding(task) : undefined;
972
+ return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
833
973
  }
834
974
 
835
975
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
836
976
  private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
977
+ requireLocalTaskPeer(peer);
837
978
  const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
838
979
  const declaredExtra = !!adapter && adapter.kind !== peer;
839
980
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
@@ -850,13 +991,14 @@ export class Tasks {
850
991
  * Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
851
992
  */
852
993
  private plannerHolders(): PeerId[] {
853
- return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
994
+ return Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("planner") && this.d.bus.isRostered(peer)).map(([peer]) => peer).sort();
854
995
  }
855
996
 
856
- private weights(cls: TaskClass, task?: Task) {
997
+ private weights(cls: TaskClass, task?: Task, projectedHealth?: ReturnType<Tasks["health"]>) {
857
998
  const now = Date.now();
858
999
  const quota = this.d.quota?.();
859
- return { ...this.health(), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task), ...(task.planner ? { keptPlanner: task.planner } : {}) } : {}), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
1000
+ const health = projectedHealth ?? this.health(), clock = health.now ?? now;
1001
+ return { ...health, ...(task && this.hasPersonReservation(task) && task.reserved ? { queueReservedHold: task.reserved } : {}), planners: this.plannerHolders(), ...(task ? { reviewPool: this.reviewPoolOptions(task), ...(task.planner ? { keptPlanner: task.planner } : {}) } : {}), now: clock, demoted: this.demoted(cls, clock), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
860
1002
  }
861
1003
 
862
1004
  /**
@@ -914,6 +1056,8 @@ export class Tasks {
914
1056
  peers,
915
1057
  observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
916
1058
  units: [unit, unit],
1059
+ ...(this.d.splitMinObservations ? { minObservations: this.d.splitMinObservations() } : {}),
1060
+ ...(this.d.splitMaxFailureShare ? { maxFailureShare: this.d.splitMaxFailureShare() } : {}),
917
1061
  profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
918
1062
  backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
919
1063
  // Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
@@ -931,11 +1075,12 @@ export class Tasks {
931
1075
  private contradict(failed: Task): void {
932
1076
  if (this.isPii(failed)) return; // its places are left out everywhere else too
933
1077
  const now = Date.now();
1078
+ const contradictionDays = this.d.contradictionWindowDays?.(), contradictionWindowMs = contradictionDays === undefined ? CONTRADICTION_WINDOW_MS : contradictionDays * 86_400_000;
934
1079
  const mine = this.places(failed);
935
1080
  for (const t of this.d.board.list("approved")) {
936
1081
  if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
937
1082
  const approval = [...t.history].reverse().find((h) => h.event === "approved");
938
- if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
1083
+ if (!approval || now - approval.at > contradictionWindowMs || approval.by === USER) continue;
939
1084
  const theirs = this.places(t);
940
1085
  // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
941
1086
  // were stored as written, so both sides are compared in one spelling.
@@ -953,12 +1098,13 @@ export class Tasks {
953
1098
 
954
1099
  /** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
955
1100
  taskPlanner(task: Pick<Task, "planner">): PeerId[] {
956
- const holders = Object.keys(this.d.roles ?? {}).filter(peer => this.d.roles?.[peer]?.includes("planner")).sort();
1101
+ const holders = Object.keys(this.d.roles ?? {}).filter(peer => !isRemotePeerId(peer) && this.d.roles?.[peer]?.includes("planner")).sort();
957
1102
  return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
958
1103
  }
959
1104
 
960
1105
  /** Current planner rulings amend review context, never person-only plan-departure acceptance. */
961
1106
  plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
1107
+ requireLocalTaskPeer(recipient);
962
1108
  if (this.isPii(task)) return [];
963
1109
  const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
964
1110
  if (boundary < 0) return [];
@@ -976,13 +1122,15 @@ export class Tasks {
976
1122
  }
977
1123
 
978
1124
  private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
1125
+ requireLocalTaskPeer(by);
979
1126
  if (by !== USER && (!this.d.roles?.[by]?.includes("planner") || task !== undefined && !this.taskPlanner(task).includes(by))) throw new PreEffectToolRefusal(`planning requires a person or configured planner role${task ? "; only the task's effective planner may act" : ""}`);
980
1127
  }
981
1128
 
982
1129
  /** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
983
- isConfiguredPlanner(peer: PeerId): boolean { return peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
1130
+ isConfiguredPlanner(peer: PeerId): boolean { return !isRemotePeerId(peer) && peer !== USER && peer !== HUB && !!this.d.roles?.[peer]?.includes("planner"); }
984
1131
 
985
1132
  private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
1133
+ requireLocalTaskPeer(by);
986
1134
  if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
987
1135
  this.planningAuthority(by, task);
988
1136
  }
@@ -1048,6 +1196,7 @@ export class Tasks {
1048
1196
 
1049
1197
  /** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
1050
1198
  bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
1199
+ requireLocalTaskPeer(peer);
1051
1200
  try {
1052
1201
  if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
1053
1202
  || !current() || this.d.privateTurn?.(peer)) return false;
@@ -1092,7 +1241,8 @@ export class Tasks {
1092
1241
  if (id !== undefined) change.ready.add(id);
1093
1242
  changes.set(planner, change);
1094
1243
  };
1095
- for (const [peer, used] of before) if (this.wip(peer) < used) for (const planner of this.taskPlanner(task)) add(planner, peer);
1244
+ const released = new Set<PeerId>();
1245
+ for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
1096
1246
  if (approved) for (const card of this.d.board.list("backlog")) {
1097
1247
  if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
1098
1248
  }
@@ -1107,9 +1257,7 @@ export class Tasks {
1107
1257
  }
1108
1258
  const lines: string[] = [];
1109
1259
  for (const peer of change.capacity) {
1110
- const top = this.d.board.list().filter(card => ["backlog", "proposed"].includes(card.state) && !card.owner && card.reserved === peer
1111
- && this.taskPlanner(card).includes(planner) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
1112
- && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1260
+ const top = this.topReservedReady(peer, planner);
1113
1261
  lines.push(`capacity_released: ${peer} WIP ${this.wip(peer)}; reserved ready ${top ? `#${top.id}` : "none"}`);
1114
1262
  }
1115
1263
  for (const id of change.ready) {
@@ -1121,9 +1269,54 @@ export class Tasks {
1121
1269
  const env = newEnvelope(HUB, sanitize(`Planner work ready:\n${lines.join("\n")}\nRead the current board before planning or pulling work.`), { to: [planner], kind: "task" });
1122
1270
  if (!this.d.bus.publish(env).includes(planner)) throw new Error("the notice was not queued");
1123
1271
  });
1272
+ // #785 P1c: only this write's own release, never a merged earlier one, and only when the person turned it on.
1273
+ if (this.d.autoPull?.() === true) this.queueAutoPulls(released);
1124
1274
  });
1125
1275
  }
1126
1276
 
1277
+ /** The top card explicitly reserved for `peer` that this planner may pull now; the notice and the opt-in pull read the same rule. `pullable` keeps the staged backlog cards only. */
1278
+ private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
1279
+ return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
1280
+ && (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
1281
+ && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1282
+ }
1283
+
1284
+ /**
1285
+ * #785 P1c: `tasks.auto_pull`. A slot freed for a peer pulls the one top backlog card explicitly reserved for it, through `pullPlanned` as the hub acting for that card's planner of
1286
+ * record: revision, dependency, reservation, pinned, frozen-release, P0 order and WIP checks all run there unchanged. One card per release. Never an unreserved card, never PII.
1287
+ * Serialized on one chain, so two releases cannot pick the same card; each job re-reads the board, so a card that moved meanwhile is skipped.
1288
+ */
1289
+ private autoPulls: Promise<void> = Promise.resolve();
1290
+ private autoPullsOpen = 0;
1291
+ /** Queued or running auto pulls, so a recovery commit waits for them the way it waits for `checksPending` (#785 P1c): one that is mid-admission would otherwise write after the commit's digest. */
1292
+ autoPullsPending = (): number => this.autoPullsOpen;
1293
+ private queueAutoPulls(released: ReadonlySet<PeerId>): void {
1294
+ for (const peer of released) {
1295
+ this.autoPullsOpen++;
1296
+ this.autoPulls = this.autoPulls.then(() => this.autoPull(peer)).catch(() => { /* autoPull reports its own refusals */ }).finally(() => { this.autoPullsOpen--; });
1297
+ }
1298
+ }
1299
+ /** Whether the hub may still start work on its own: the setting is on, and no recovery, shutdown or storage hold is active. Read at entry and again right before the assignment write. */
1300
+ private autoPullAdmitted(): boolean {
1301
+ return this.d.autoPull?.() === true && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError;
1302
+ }
1303
+ private async autoPull(peer: PeerId): Promise<void> {
1304
+ if (!this.autoPullAdmitted()) return;
1305
+ const card = this.topReservedReady(peer, undefined, true);
1306
+ if (!card) return;
1307
+ // The planner of record is the card's own: its bound planner, else the first current holder, and it must be rostered. A private card never gets here (topReservedReady skips it).
1308
+ const planner = this.taskPlanner(card)[0];
1309
+ if (!planner || !this.d.bus.isRostered(planner)) return;
1310
+ const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
1311
+ try {
1312
+ await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => { if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began"); } });
1313
+ } catch (e) {
1314
+ // A guard refusal is the setting or a hold doing its job, not a failure to report.
1315
+ if (!this.autoPullAdmitted()) return;
1316
+ this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
1317
+ }
1318
+ }
1319
+
1127
1320
  /** #784 AC3: the read-only breakdown of `wip` for status, the console and the dashboard: one grouped query for every peer. Admission still reads `wip()`. */
1128
1321
  wipBreakdown(peers: readonly PeerId[]): Record<string, { wip: number; active: number; reviewWait: number; reserved: number; wipLimit: number }> {
1129
1322
  const grouped = this.d.board.plannerWipBreakdown();
@@ -1148,6 +1341,7 @@ export class Tasks {
1148
1341
  plannerRefusal(task: Task): string {
1149
1342
  const parts: string[] = [];
1150
1343
  const excluded = [...new Set(this.excluded(task))];
1344
+ const pressure = this.queueRouting(Object.keys(this.states()));
1151
1345
  if (excluded.length) parts.push(`${excluded.join(", ")} declined or was escalated away from this task for good; only a person's ahub task assign lifts that`);
1152
1346
  for (const peer of Object.keys(this.states())) {
1153
1347
  if (excluded.includes(peer)) continue;
@@ -1155,25 +1349,37 @@ export class Tasks {
1155
1349
  if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
1156
1350
  else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
1157
1351
  else if (queue.needsReview > 0) parts.push(`${peer} has ${queue.needsReview} delivery(ies) in needs_review`);
1352
+ const refusal = queueBackpressure(peer, pressure.queueBackpressure, peerEntry(pressure.queues, peer), pressure.now ?? NaN);
1353
+ if (refusal) parts.push(`${peer} ${refusal.reason}`);
1158
1354
  }
1159
1355
  return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
1160
1356
  }
1161
- private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing()): Assignment {
1357
+ private plannerAssignment(task: Task, reservedOnly = false, routing: Routing = this.d.routing(), queueOverride?: "owner", projectedHealth?: ReturnType<Tasks["health"]>): Assignment {
1162
1358
  const states = this.states();
1163
1359
  const full = this.plannerFull(task);
1164
1360
  // Preview the To Do transition without routing or offering the persisted backlog row.
1165
- return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task) });
1361
+ return assign({ ...task, state: "proposed" }, states, routing, { exclude: this.excluded(task), excludeOwners: full, ...(reservedOnly && task.reserved ? { candidates: [task.reserved], namedOwner: task.reserved } : {}), waitsFor: this.waitsFor(task), ...this.weights(task.class, task, projectedHealth), ...(queueOverride ? { queueOverride, contextOverride: true } : {}) });
1166
1362
  }
1167
1363
 
1168
- async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }): Promise<Task> {
1364
+ async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
1169
1365
  // #455: one authority check, made before the screen and again after it (a person may move the planner meanwhile).
1170
- const authorized = () => this.planningAuthority(by);
1366
+ // #785 P1c: `by` is the actor the history records; the hub's opt-in auto pull names the planner it acts for, and that planner's authority is what is checked.
1367
+ if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
1368
+ const planner = opts.onBehalfOf ?? by;
1369
+ let queueAssignment: { current: () => void } | undefined;
1370
+ // `opts.guard` (the auto pull's own admission, #785 P1c) throws a PreEffectToolRefusal once the pull is no longer wanted; it runs wherever authority is rechecked, so it
1371
+ // also runs after the awaited PII screen and right before the assignment write, where a setting turned off or a recovery that began meanwhile must still stop it.
1372
+ const authorized = () => { this.planningAuthority(planner); opts.guard?.(); queueAssignment?.current(); };
1171
1373
  authorized();
1172
1374
  if (!Number.isSafeInteger(input.id) || input.id <= 0 || !Number.isSafeInteger(input.revision) || input.revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("planner pull needs an id, revision and bounded reason");
1375
+ queueAssignment = by !== HUB && opts.onBehalfOf === undefined && this.d.board.get(input.id)?.reserved
1376
+ ? this.explicitQueueAssignment(by, "owner", true) : undefined;
1377
+ queueAssignment?.current();
1173
1378
  const validate = () => {
1174
1379
  const task = this.d.board.get(input.id);
1380
+ if (task?.draft) throw new PreEffectToolRefusal(`task #${task.id} is an intake draft; the person approves it first`);
1175
1381
  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");
1176
- this.planningAuthority(by, task);
1382
+ this.planningAuthority(planner, task);
1177
1383
  const release = task.release == null ? undefined : this.d.board.release(task.release);
1178
1384
  if (task.release != null && (!release || ["frozen", "shipped", "dropped"].includes(release.state))) throw new PreEffectToolRefusal("planner pull requires unfrozen release scope");
1179
1385
  if (this.waitsFor(task).length) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -1189,7 +1395,7 @@ export class Tasks {
1189
1395
  })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
1190
1396
  }
1191
1397
  if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
1192
- const assignment = this.plannerAssignment(task, !!task.reserved);
1398
+ const assignment = this.plannerAssignment(task, !!task.reserved, this.d.routing(), queueAssignment ? "owner" : undefined);
1193
1399
  if (!assignment.owner) {
1194
1400
  const refusal = task.reserved ? `reserved peer ${task.reserved} cannot take the planner pull: ${assignment.refusals?.find(row => row.role === "owner" && row.peer === task.reserved)?.reason ?? "not eligible"}; ${this.plannerRefusal(task)}` : this.plannerRefusal(task);
1195
1401
  throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
@@ -1200,13 +1406,14 @@ export class Tasks {
1200
1406
  authorized();
1201
1407
  const current = validate();
1202
1408
  if (current.task.reserved) {
1203
- return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre",
1409
+ return this.assignOwner(current.task, by, { candidates: [current.owner], namedOwner: current.owner, planner: true, refuseNamed: "pre", ...(queueAssignment ? { queueOverride: "owner" as const, current: queueAssignment.current } : {}),
1204
1410
  plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
1205
1411
  authorized();
1206
1412
  const checked = validate();
1207
1413
  if (checked.task.history.length !== current.task.history.length || checked.owner !== current.owner) throw new PreEffectToolRefusal("planner pull changed during admission");
1208
1414
  } } });
1209
1415
  }
1416
+ opts.guard?.();
1210
1417
  const pulled = this.d.board.pullPlanned(current.task.id, by, current.owner, input.revision, input.reason.trim(), withheld, this.plannerBinding(current.task, current.assignment, true));
1211
1418
  this.planningRecorded({ by, task: pulled.id, fields: ["pull", "reserved"], revision: pulled.planningRevision! });
1212
1419
  return pulled;
@@ -1221,7 +1428,7 @@ export class Tasks {
1221
1428
  const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
1222
1429
  for (const id of ids) {
1223
1430
  const task = this.d.board.get(id);
1224
- if (task?.state === "backlog" && this.taskPlanner(task).includes(peer)) pending.add(id);
1431
+ if (task?.state === "backlog" && !task.draft && this.taskPlanner(task).includes(peer)) pending.add(id);
1225
1432
  }
1226
1433
  if (pending.size) this.plannerBacklog.set(peer, pending);
1227
1434
  }
@@ -1262,11 +1469,13 @@ export class Tasks {
1262
1469
  scan.revision = revision;
1263
1470
  }
1264
1471
  const now = this.d.now?.() ?? Date.now();
1472
+ const configuredFyiMin = this.d.groomFyiWindowMin?.();
1473
+ const fyiWindowMs = configuredFyiMin === undefined ? GROOM_FYI_WINDOW_MS : configuredFyiMin * 60_000;
1265
1474
  for (const peer of planners) {
1266
1475
  const told = scan.sent.get(peer), pending = scan.pending.get(peer);
1267
1476
  const review = told === undefined || pending?.review === true;
1268
1477
  if (!review && !pending?.other) continue;
1269
- if (!review && now - told!.fyiAt < GROOM_FYI_WINDOW_MS) { scan.pending.delete(peer); continue; }
1478
+ if (!review && now - told!.fyiAt < fyiWindowMs) { scan.pending.delete(peer); continue; }
1270
1479
  this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
1271
1480
  if (review) this.sendGroomNotice(peer, revision);
1272
1481
  else this.d.bus.publish(newEnvelope(HUB, `${GROOM_NOTICE_PREFIX} (revision ${revision}): state moves, reviews or assignments only. No plan review is needed.`, { to: [peer], kind: "task", priority: "fyi" }));
@@ -1286,7 +1495,7 @@ export class Tasks {
1286
1495
 
1287
1496
  /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
1288
1497
  flushPlannerNotices(): number {
1289
- const configured = Object.entries(this.d.roles ?? {}).filter(([, roles]) => roles.includes("planner")).map(([peer]) => peer);
1498
+ const configured = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && roles.includes("planner")).map(([peer]) => peer);
1290
1499
  for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
1291
1500
  this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
1292
1501
  this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
@@ -1477,7 +1686,7 @@ export class Tasks {
1477
1686
  }
1478
1687
 
1479
1688
  async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
1480
- if (by !== USER) throw new PreEffectToolRefusal("GitHub import is a person console command");
1689
+ if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("GitHub import is a person console command");
1481
1690
  const deadline = performance.now() + 20_000;
1482
1691
  const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
1483
1692
  const imported: number[] = [], existing: number[] = [];
@@ -1489,7 +1698,7 @@ export class Tasks {
1489
1698
  if (prior) { existing.push(prior.id); continue; }
1490
1699
  const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
1491
1700
  const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
1492
- const task = await this.propose(USER, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
1701
+ const task = await this.propose(by, { title: issue.title, detail: issue.detail, class: cls, backlog: true }, issue.source, () => { created = true; });
1493
1702
  (created ? imported : existing).push(task.id);
1494
1703
  if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
1495
1704
  } catch {
@@ -1505,8 +1714,10 @@ export class Tasks {
1505
1714
  return { imported, existing, partial: page.partial, complete: !page.partial };
1506
1715
  }
1507
1716
 
1508
- async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
1509
- this.planningAuthority(by);
1717
+ async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }, opts: { intake?: true } = {}): Promise<{ parent: Task; children: Task[] }> {
1718
+ // #334: the hub writes an intake draft's own model split; no other write by the hub passes as a planner.
1719
+ const authority = (task?: Pick<Task, "planner">) => { if (!(opts.intake && by === HUB)) this.planningAuthority(by, task); };
1720
+ authority();
1510
1721
  const { id, revision } = input;
1511
1722
  if (!Number.isSafeInteger(id) || id <= 0 || !Number.isSafeInteger(revision) || revision < 0 || typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 1000) throw new PreEffectToolRefusal("split needs an id, revision and bounded reason");
1512
1723
  const reason = input.reason.trim();
@@ -1519,7 +1730,9 @@ export class Tasks {
1519
1730
  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");
1520
1731
  const release = parent.release == null ? undefined : this.d.board.release(parent.release);
1521
1732
  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");
1522
- this.planningAuthority(by, parent);
1733
+ authority(parent);
1734
+ // #334: the hub writes a split only into a draft that is still a draft. Both validations run (before and after the awaited screen), and the board asks again in its transaction.
1735
+ if (opts.intake && by === HUB && !parent.draft) throw new PreEffectToolRefusal("split changed, pinned or already started");
1523
1736
  const profile = parent.profile ?? "dev";
1524
1737
  const drafts = rows.map((part, index) => {
1525
1738
  if (!part || typeof part !== "object" || Array.isArray(part) || Object.keys(part).some(key => !["title", "detail", "class", "refs", "plan", "after", "afterParts"].includes(key)) || typeof part.title !== "string" || !part.title.trim() || part.title.length > 300 || part.detail !== undefined && (typeof part.detail !== "string" || part.detail.length > 8000) || part.class !== undefined && typeof part.class !== "string") throw new PreEffectToolRefusal("invalid split part");
@@ -1554,7 +1767,7 @@ export class Tasks {
1554
1767
  let screened: Awaited<typeof attempt>;
1555
1768
  try { screened = await Promise.race([attempt, new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new PreEffectToolRefusal("split screening request deadline exceeded")), BACKLOG_SPLIT_SCREEN_TIMEOUT_MS); })]); }
1556
1769
  finally { if (deadline !== undefined) clearTimeout(deadline); }
1557
- this.planningAuthority(by); // #455: a person may have moved the planner while the screen ran
1770
+ authority(); // #455: a person may have moved the planner while the screen ran
1558
1771
  const [screenedVerdicts, withheld] = screened, current = validate();
1559
1772
  const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
1560
1773
  const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
@@ -1562,6 +1775,8 @@ export class Tasks {
1562
1775
  const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
1563
1776
  const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
1564
1777
  for (const draft of drafts) this.warmRepository(draft);
1778
+ // #334: the hub's intake split is only for a draft; the board asks for it again inside its transaction (a flag set for exactly this write, never left on).
1779
+ this.d.board.requireDraftForNextSplit = opts.intake === true && by === HUB;
1565
1780
  const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
1566
1781
  if (this.d.coordination) {
1567
1782
  const mode = this.d.coordination();
@@ -1575,6 +1790,92 @@ export class Tasks {
1575
1790
  return result;
1576
1791
  }
1577
1792
 
1793
+ /**
1794
+ * The draft and every open draft card split from it, at any depth, parent first. A planner may split an intake child again before approval, and
1795
+ * the grandchildren inherit the mark, so the walk follows `deps` through draft cards only: a dependency on ordinary work is never followed.
1796
+ */
1797
+ private intakeFamily(id: number): Task[] {
1798
+ const root = this.d.board.get(id);
1799
+ if (!root || !root.draft || root.state !== "backlog") throw new PreEffectToolRefusal(`task #${id} is not an open intake draft`);
1800
+ const family: Task[] = [root], seen = new Set([root.id]);
1801
+ for (let next = 0; next < family.length; next++) {
1802
+ for (const dep of family[next]!.deps ?? []) {
1803
+ if (seen.has(dep)) continue;
1804
+ const child = this.d.board.get(dep);
1805
+ if (!child || !child.draft || child.state !== "backlog") continue;
1806
+ seen.add(dep); family.push(child);
1807
+ }
1808
+ }
1809
+ return family;
1810
+ }
1811
+
1812
+ /**
1813
+ * #334 (#511a): the person's request becomes one parent draft, a backlog card that nothing offers or pulls. A planner is asked to split it with
1814
+ * `hub_backlog_split`; with none, or for a request that is PII, the hub's model proposes the split and the hub writes it. A split that does not
1815
+ * validate leaves the draft whole, with the reason on its history.
1816
+ */
1817
+ async intake(by: PeerId, input: { text: unknown; sources?: unknown }): Promise<{ draft: Task; children: Task[]; split: "planner" | "hub" | "unsplit"; reason?: string }> {
1818
+ if (by !== USER) throw new PreEffectToolRefusal("intake is a console command");
1819
+ const text = typeof input.text === "string" ? input.text.trim() : "";
1820
+ if (!text || text.length > INTAKE_MAX_TEXT) throw new PreEffectToolRefusal(`intake needs a request of 1 to ${INTAKE_MAX_TEXT} characters`);
1821
+ const given = input.sources ?? [];
1822
+ if (!Array.isArray(given) || given.length > INTAKE_MAX_SOURCES || given.some(id => typeof id !== "string" || !SOURCE_ID.test(id)) || new Set(given).size !== given.length) throw new PreEffectToolRefusal(`sources: at most ${INTAKE_MAX_SOURCES} distinct src:<16 hex> ids`);
1823
+ const sources = given as string[];
1824
+ for (const id of sources) if (!this.d.intake?.sourceKnown(id)) throw new PreEffectToolRefusal(`unknown source ${id}; register it with ahub source add`);
1825
+ const title = `Intake: ${text.split("\n")[0]!.replace(/\s+/g, " ").trim().slice(0, 80)}`;
1826
+ let draft = await this.propose(USER, { title, detail: intakeDetail(text, sources), class: "plan", backlog: true, draft: true });
1827
+ this.incomingBacklog.delete(draft.id);
1828
+ const priv = this.isPii(draft);
1829
+ const planner = priv ? undefined : this.taskPlanner(draft).find(peer => this.d.bus.isRostered(peer) && this.d.bus.peers.has(peer) && this.d.bus.stateOf(peer) !== "offline");
1830
+ if (planner) {
1831
+ const body = plannerRequestBody({ id: draft.id, revision: draft.planningRevision ?? 0, text: parseIntakeDetail(String(this.publicView(draft).detail)).text, sources });
1832
+ try {
1833
+ if (this.d.bus.publish(newEnvelope(HUB, sanitize(body), { to: [planner], kind: "task", refs: { task: String(draft.id), taskRecipient: "planner" } })).includes(planner)) return { draft, children: [], split: "planner" };
1834
+ } catch { /* the planner is not reachable: the hub model takes it */ }
1835
+ }
1836
+ return { ...(await this.intakeByHub(draft, text, sources, priv)), draft: this.d.board.get(draft.id) ?? draft };
1837
+ }
1838
+
1839
+ /** The hub model's split of a draft, validated against the profile's `hub_backlog_split` parts schema and written through `splitBacklog`. */
1840
+ private async intakeByHub(draft: Task, text: string, sources: string[], priv: boolean): Promise<{ children: Task[]; split: "hub" | "unsplit"; reason?: string }> {
1841
+ const unsplit = (reason: string) => { this.d.board.update(draft.id, HUB, "intake unsplit", {}, reason); return { children: [], split: "unsplit" as const, reason }; };
1842
+ if (!this.d.intake) return unsplit("no planner is attached and the hub has no model for a split");
1843
+ // A request that is PII goes only to a model reached on campus; without one it stays whole for the person.
1844
+ if (priv && !(await this.d.triage?.onCampus().catch(() => false))) return unsplit("the request is PII and no on-prem model was reached");
1845
+ let raw: string | undefined;
1846
+ try { raw = await this.d.intake.split(intakeSystemPrompt(Object.keys(profileData(this.d.profileData ?? this.d.profile ?? "dev").classes), this.d.intake.partsSchema()), intakeUserMessage(text, sources), { campusOnly: priv }); } catch { raw = undefined; }
1847
+ const parsed = parseIntakeSplit(raw, this.d.intake.partsSchema(), sources);
1848
+ if ("error" in parsed) return unsplit(parsed.error);
1849
+ const current = this.d.board.get(draft.id);
1850
+ if (!current || !current.draft || current.state !== "backlog") return unsplit("the draft changed while the model answered");
1851
+ try {
1852
+ const result = await this.splitBacklog(HUB, { id: draft.id, revision: current.planningRevision ?? 0, parts: parsed.parts, reason: "intake: split by the hub model" }, { intake: true });
1853
+ return { children: result.children, split: "hub" };
1854
+ } catch (error) { return unsplit(`the split was refused: ${error instanceof PreEffectToolRefusal ? sanitize(error.message).slice(0, 200) : "the board refused it"}`); }
1855
+ }
1856
+
1857
+ /** #334: the person approves a draft; it and its children leave draft and follow the normal backlog pull. */
1858
+ async approveIntake(by: PeerId, id: unknown): Promise<Task[]> {
1859
+ if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("intake approval is a console command");
1860
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
1861
+ const family = this.intakeFamily(id);
1862
+ let approved: Task[];
1863
+ try { approved = this.d.board.approveDrafts(family.map(task => task.id), by, by === USER ? "intake approved by the person" : `intake approved by the conductor ${by}`); }
1864
+ catch (error) { throw new PreEffectToolRefusal((error as Error).message); }
1865
+ for (const task of approved) this.incomingBacklog.add(task.id);
1866
+ this.releaseNotice(`intake draft #${id} approved: ${approved.length} backlog card${approved.length === 1 ? "" : "s"}`);
1867
+ return approved;
1868
+ }
1869
+
1870
+ /** #334: the person discards a draft; it and its children are cancelled through `cancel`. */
1871
+ async discardIntake(by: PeerId, id: unknown): Promise<Task[]> {
1872
+ if (by !== USER) throw new PreEffectToolRefusal("intake discard is a console command");
1873
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
1874
+ const cancelled: Task[] = [];
1875
+ for (const task of this.intakeFamily(id)) cancelled.push(await this.cancel(task.id, USER, { reason: "intake draft discarded by the person" }));
1876
+ return cancelled;
1877
+ }
1878
+
1578
1879
  /** Release assignment changes no execution role, dependency, reservation or state. */
1579
1880
  async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
1580
1881
  this.plannerWriteAuthority(by);
@@ -1724,12 +2025,14 @@ export class Tasks {
1724
2025
  return updated; // a failed readback may follow a durable release write
1725
2026
  }
1726
2027
 
1727
- async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void): Promise<Task> {
1728
- const requestCheck = this.requestCheck(by);
2028
+ async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
2029
+ const requestCheck = this.requestCheck(by, fence);
2030
+ requireLocalTaskPeer(input.owner);
1729
2031
  external = external ? { ...external } : undefined;
1730
- if (external && (by !== USER || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
2032
+ if (external && (!this.personOrConductor(by) || input.backlog !== true)) throw new PreEffectToolRefusal("external intake requires person-owned backlog staging");
1731
2033
  if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
1732
2034
  const backlog = input.backlog === true;
2035
+ if (input.draft !== undefined && (input.draft !== true || !backlog || by !== USER)) throw new PreEffectToolRefusal("only the person's intake stages a backlog draft");
1733
2036
  const claiming = !backlog && input.owner === by;
1734
2037
  if (claiming) {
1735
2038
  this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
@@ -1799,7 +2102,7 @@ export class Tasks {
1799
2102
  this.warmRepository({ refs: draft.refs, plan });
1800
2103
  const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
1801
2104
  requestCheck();
1802
- let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
2105
+ let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}), ...(input.draft === true ? { draft: true } : {}) });
1803
2106
  if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
1804
2107
  if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1805
2108
  created?.();
@@ -1869,6 +2172,7 @@ export class Tasks {
1869
2172
  if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
1870
2173
  const task = this.d.board.get(n);
1871
2174
  if (!task) throw new PreEffectToolRefusal(`no task #${n}`);
2175
+ if (pull && task.draft) throw new PreEffectToolRefusal(`task #${n} is an intake draft; approve it with ahub intake approve ${n} first`);
1872
2176
  if (task.state !== (pull ? "backlog" : "proposed") || task.owner) throw new PreEffectToolRefusal(`task #${n}: ${pull ? "only backlog may be pulled" : "only ownerless proposed work may return to backlog"}`);
1873
2177
  if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
1874
2178
  const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
@@ -1912,7 +2216,8 @@ export class Tasks {
1912
2216
  return t.state === "proposed" && !t.owner && !t.history.some((h) => h.owner) && t.signals.includes("pii") && last.startsWith("pii: unknown") && last !== "pii: unknown, too long";
1913
2217
  };
1914
2218
  const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
1915
- const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < RESCREEN_MAX).sort((a, b) => tries(a) - tries(b))[0];
2219
+ const rescreenMax = this.d.rescreenMax?.() ?? RESCREEN_MAX;
2220
+ const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < rescreenMax).sort((a, b) => tries(a) - tries(b))[0];
1916
2221
  if (!task) return;
1917
2222
  this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
1918
2223
  const text = screenText(task);
@@ -2052,6 +2357,7 @@ export class Tasks {
2052
2357
  * recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
2053
2358
  */
2054
2359
  whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
2360
+ requireLocalTaskPeer(to);
2055
2361
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
2056
2362
  this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
2057
2363
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
@@ -2059,6 +2365,7 @@ export class Tasks {
2059
2365
  }
2060
2366
 
2061
2367
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
2368
+ requireLocalTaskPeer(to);
2062
2369
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
2063
2370
  this.conditional.set(env.id, { peer: to, task, states });
2064
2371
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
@@ -2197,6 +2504,7 @@ export class Tasks {
2197
2504
  * Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
2198
2505
  */
2199
2506
  async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
2507
+ requireLocalTaskPeer(peer);
2200
2508
  const moved: { id: number; title: string; to: PeerId | null }[] = [];
2201
2509
  const why = `owner ${peer} offline for ${minutes} min`;
2202
2510
  const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
@@ -2269,11 +2577,12 @@ export class Tasks {
2269
2577
  }
2270
2578
  }
2271
2579
 
2272
- /** A person's owner removal sticks until a later explicit assignment or reservation, across hub runs. */
2580
+ /** Authorized manual owner removal sticks until a later explicit assignment/reservation, across hub runs. */
2273
2581
  private personUnassigned(task: Task): boolean {
2274
- const removed = task.history.findLastIndex((h) => h.event === "unassigned" && h.by === USER);
2582
+ const manual = (h: HistoryEntry) => h.by === USER || h.conductorManual === true;
2583
+ const removed = task.history.findLastIndex((h) => h.event === "unassigned" && manual(h));
2275
2584
  if (removed < 0) return false;
2276
- return !task.history.slice(removed + 1).some((h) => h.by === USER && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
2585
+ return !task.history.slice(removed + 1).some((h) => manual(h) && ["assigned", "reassigned", "escalated", "reserved", "backlog pulled"].includes(h.event));
2277
2586
  }
2278
2587
 
2279
2588
  /**
@@ -2325,7 +2634,7 @@ export class Tasks {
2325
2634
  }
2326
2635
 
2327
2636
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
2328
- private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
2637
+ private async assignOwner(task: Task, by: PeerId, opts: { current?: () => void; conductorManual?: true; queueOverride?: "owner"; candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain"; plannerPull?: { revision: number; note: string; withheld: boolean; guard: () => void } } = {}): Promise<Task> {
2329
2638
  opts.current?.();
2330
2639
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
2331
2640
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
@@ -2355,7 +2664,9 @@ export class Tasks {
2355
2664
  const waits = this.waitsFor(task);
2356
2665
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
2357
2666
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
2358
- const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}) });
2667
+ const a = assign(opts.plannerPull ? { ...task, state: "proposed" } : task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], excludeOwners: plannerFull, ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class, task), ...(opts.namedOwner ? { namedOwner: opts.namedOwner } : {}), // A self-claim hands over no envelope; reviewer admission remains automatic.
2668
+ contextOverride: !!(opts.queueOverride || opts.claim),
2669
+ ...(opts.queueOverride || opts.claim ? { queueOverride: opts.queueOverride ?? "owner" as const } : {}) });
2359
2670
 
2360
2671
  if (waits.length) {
2361
2672
  if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -2369,14 +2680,18 @@ export class Tasks {
2369
2680
  const why = a.refusals?.find(row => row.role === "owner" && row.peer === opts.namedOwner)?.reason ?? "not eligible";
2370
2681
  throw new (opts.refuseNamed === "pre" ? PreEffectToolRefusal : Error)(`cannot assign task #${task.id} to ${opts.namedOwner}: ${why}${/offline|not attached/.test(why) ? "; assign again once it is attached" : ""}`);
2371
2682
  }
2372
- if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
2373
2683
  this.releaseNotice(opts.roster
2374
2684
  ? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
2375
2685
  : `task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
2376
2686
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
2377
2687
  if (opts.clearOnFail && task.owner) {
2378
2688
  this.cohorts.leave(task.id);
2379
- return this.d.board.update(task.id, by, "unassigned", { owner: null });
2689
+ task = this.d.board.update(task.id, by, "unassigned", { owner: null });
2690
+ }
2691
+ if (!task.owner && task.state === "proposed") {
2692
+ const cause = this.ownerStallCause(task, a, !opts.claim);
2693
+ // A named proposal remains bound to that target when pressure clears.
2694
+ this.reportStall(task, cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code) && opts.namedOwner ? { ...cause, peer: opts.namedOwner } : cause);
2380
2695
  }
2381
2696
  return task;
2382
2697
  }
@@ -2387,11 +2702,12 @@ export class Tasks {
2387
2702
  opts.plannerPull?.guard();
2388
2703
  const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2389
2704
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2390
- const assignmentExtra = { ...this.reviewMetadata(a), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) };
2705
+ const assignmentExtra = { ...this.reviewMetadata(a, task), ...(binding ? { plannerReason: binding.plannerReason } : {}), ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}), ...(opts.conductorManual ? { conductorManual: true as const } : {}) };
2391
2706
  opts.current?.();
2392
2707
  const next = opts.plannerPull
2393
2708
  ? this.d.board.pullAssigned(task.id, by, a.owner, opts.plannerPull.revision, opts.plannerPull.note, opts.plannerPull.withheld, a.reviewer ?? null, assignmentNote, assignmentExtra, task.history.length, binding)
2394
2709
  : this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(binding ? { planner: binding.planner } : {}), ...(opts.roster && task.state === "in_progress" ? { state: "proposed" as const } : {}), ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, assignmentNote, assignmentExtra, opts.expectedFlowRevision);
2710
+ this.queueReviewWritten(next);
2395
2711
  if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
2396
2712
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
2397
2713
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
@@ -2458,11 +2774,16 @@ export class Tasks {
2458
2774
  return next;
2459
2775
  }
2460
2776
 
2461
- /** Person-only advice changes never change ownership, class or authority. */
2777
+ /** #933: the person, or the configured conductor inside an admitted operation (`TasksDeps.operational` rechecks the role). */
2778
+ private personOrConductor(by: PeerId): boolean { return by === USER || this.d.operational?.(by) === true; }
2779
+
2780
+ /** Advice changes by the person or, #933, the task's own owner never change ownership, class or authority. */
2462
2781
  setRole(by: PeerId, id: unknown, raw: unknown): Task {
2463
- if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
2464
2782
  if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
2465
2783
  const task = this.need(id, false, true);
2784
+ if (by !== USER && task.owner !== by) throw new PreEffectToolRefusal(`task #${task.id} is not yours: an agent changes the catalog role of its own task only`);
2785
+ // #933: under review the role is what the reviewer reviews against; only the person changes it then.
2786
+ if (by !== USER && task.state === "in_review") throw new PreEffectToolRefusal(`task #${task.id} is in review: only the person changes its catalog role until the verdict`);
2466
2787
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
2467
2788
  let selected;
2468
2789
  try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
@@ -2472,7 +2793,7 @@ export class Tasks {
2472
2793
  for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
2473
2794
  if (!peer || peer === USER) continue;
2474
2795
  this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
2475
- `Task #${next.id}: catalog role changed by the person. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
2796
+ `Task #${next.id}: catalog role changed by ${by === USER ? "the person" : by}. ${next.role ? this.roleGuidance(next, peer, peer === next.reviewer) : "Catalog guidance cleared; follow the task's existing plan and requirements."}`,
2476
2797
  { to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
2477
2798
  }
2478
2799
  return next;
@@ -2480,6 +2801,7 @@ export class Tasks {
2480
2801
 
2481
2802
  /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
2482
2803
  claimRoleGuidance(task: Task, peer: PeerId): string {
2804
+ requireLocalTaskPeer(peer);
2483
2805
  return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
2484
2806
  }
2485
2807
 
@@ -2642,7 +2964,8 @@ export class Tasks {
2642
2964
  try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
2643
2965
  }).map(offer => ({ ...offer }));
2644
2966
  }
2645
- stalled(): TaskStall[] {
2967
+ /** A status-only snapshot is advisory; all effect/admission callers retain fresh health by default. */
2968
+ stalled(projection?: { ownerIsolation: Readonly<Record<string, boolean>> }): TaskStall[] {
2646
2969
  const revision = this.d.board.statusRevision();
2647
2970
  if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
2648
2971
  const task = this.d.board.get(stall.task);
@@ -2652,37 +2975,213 @@ export class Tasks {
2652
2975
  return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
2653
2976
  }) };
2654
2977
  // Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
2655
- const key = JSON.stringify([revision, this.states(), this.d.routing(), this.health(), [...this.d.bus.peers.keys()].map(peer => {
2978
+ const health = this.health(projection?.ownerIsolation);
2979
+ const key = JSON.stringify([revision, this.states(), this.d.routing(), health, [...this.d.bus.peers.keys()].map(peer => {
2656
2980
  const queue = this.d.bus.queueSummary(peer);
2657
2981
  return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
2658
2982
  })]);
2659
- if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
2983
+ if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "no-writes" || stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true, this.d.routing(), undefined, health) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.weights(task.class, task, health), ...(stall.cause.kind === "no-eligible-peer" && isQueuePressureCode(stall.cause.code) && stall.cause.peer ? { candidates: [stall.cause.peer], namedOwner: stall.cause.peer } : {}) })).owner).map(({ stall }) => stall) };
2660
2984
  return structuredClone(this.stallView.stalls);
2661
2985
  }
2986
+ private queuePressureKnown: boolean | undefined;
2987
+ private queuePressureGeneration = 0;
2988
+ private queuePressureQueued = false;
2989
+ private queuePressureRunning = false;
2990
+ private queuePressureDirty = false;
2991
+ private queuePressureStopped = false;
2992
+ private queuePressurePass?: QueuePressurePass;
2993
+ private queuePressureContinuation?: ReturnType<typeof setImmediate>;
2994
+
2995
+ private markQueuePressureCandidate(): void {
2996
+ this.queuePressureKnown = true; this.queuePressureGeneration++;
2997
+ this.scheduleQueuePressureRecheck();
2998
+ }
2999
+ private queueReviewWritten(task: Task): void {
3000
+ if (task.state === "in_review" && task.reviewer === USER && task.history.at(-1)?.queueReviewHeld) this.markQueuePressureCandidate();
3001
+ }
3002
+ private queuePressureAdmitted(): boolean {
3003
+ try { return !this.queuePressureStopped && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError; }
3004
+ catch { return false; }
3005
+ }
3006
+ /** Run after the bus's synchronous queue checkpoint, coalescing notifications without polling. */
3007
+ scheduleQueuePressureRecheck(): void {
3008
+ if (this.queuePressureStopped || this.queuePressureKnown === false) return;
3009
+ this.queuePressureDirty = true;
3010
+ if (this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation) return;
3011
+ this.queuePressureQueued = true;
3012
+ queueMicrotask(() => {
3013
+ this.queuePressureQueued = false;
3014
+ if (!this.queuePressureAdmitted()) return;
3015
+ this.queuePressureDirty = false;
3016
+ const pass = this.queuePressurePass ??= { ownerAfter: 0, reviewAfter: 0, ownerDone: false, reviewDone: false, retained: false, generation: this.queuePressureGeneration };
3017
+ this.queuePressureRunning = true;
3018
+ let failed = false;
3019
+ void this.recheckQueuePressureBatch(pass).catch(() => {
3020
+ failed = true;
3021
+ // A failed/unknown metadata or authority read never creates an assignment.
3022
+ this.queuePressureKnown = true; this.queuePressurePass = undefined;
3023
+ }).finally(() => {
3024
+ this.queuePressureRunning = false;
3025
+ if (!this.queuePressureAdmitted()) { this.queuePressurePass = undefined; return; }
3026
+ if (failed) return;
3027
+ if (this.queuePressurePass && (!pass.ownerDone || !pass.reviewDone)) {
3028
+ this.queuePressureContinuation = setImmediate(() => { this.queuePressureContinuation = undefined; this.scheduleQueuePressureRecheck(); });
3029
+ this.queuePressureContinuation.unref?.();
3030
+ return;
3031
+ }
3032
+ this.queuePressurePass = undefined;
3033
+ const changed = pass.generation !== this.queuePressureGeneration;
3034
+ this.queuePressureKnown = pass.retained || changed;
3035
+ if ((this.queuePressureDirty || changed) && this.queuePressureKnown) this.scheduleQueuePressureRecheck();
3036
+ });
3037
+ });
3038
+ }
3039
+ /** Recovery waits for queued/running host writers; a stop fences a writer already in an await. */
3040
+ queuePressureRechecksPending = (): number => Number(this.queuePressureQueued || this.queuePressureRunning || this.queuePressureContinuation !== undefined);
3041
+ stopQueuePressureRechecks(): void {
3042
+ this.queuePressureStopped = true; this.queuePressureDirty = false; this.queuePressureQueued = false;
3043
+ if (this.queuePressureContinuation) clearImmediate(this.queuePressureContinuation);
3044
+ this.queuePressureContinuation = undefined; this.queuePressurePass = undefined;
3045
+ }
3046
+ private ordinaryQueueProposal(stall: TaskStall, task: Task | undefined): task is Task {
3047
+ return !!task && task.state === "proposed" && !task.owner && !task.draft
3048
+ && !this.waitsFor(task).length && !this.personUnassigned(task) && !this.plannerPullRecord(task)?.pull;
3049
+ }
3050
+ private queueReviewBinding(task: Task): string {
3051
+ return createHash("sha256").update(JSON.stringify([this.reviewHead(task) ?? null, task.refs.repo ?? null, task.sdlc?.repo ?? null, task.sdlc?.pr ?? null])).digest("hex");
3052
+ }
3053
+ private queueReviewHeld(task: Task): boolean {
3054
+ if (task.state !== "in_review" || task.reviewer !== USER || this.isPii(task) || this.manuallyNamedReviewer(task)) return false;
3055
+ const role = task.history[this.reviewRequestRevision(task) - 1];
3056
+ return role?.queueReviewHeld === true && role.queueReviewBinding === this.queueReviewBinding(task);
3057
+ }
3058
+ private async recheckQueuePressureBatch(pass: QueuePressurePass): Promise<void> {
3059
+ if (!this.queuePressureAdmitted()) return;
3060
+ if (!pass.ownerDone) {
3061
+ const page = this.d.board.queuePressureStallPage(pass.ownerAfter, QUEUE_RECHECK_PAGE);
3062
+ pass.ownerAfter = page.afterTask; pass.ownerDone = page.done;
3063
+ for (const stall of page.rows) {
3064
+ if (!this.queuePressureAdmitted()) return;
3065
+ const task = this.d.board.get(stall.task);
3066
+ if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
3067
+ // A planning revision asks for fresh admission, never erases an automatic offer.
3068
+ const namedOwner = stall.cause.peer;
3069
+ const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
3070
+ const choice = assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class, task), ...retry });
3071
+ if (choice.owner) {
3072
+ const current = () => { if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted"); };
3073
+ try {
3074
+ // Reserved ready work shares the existing WIP, in-flight slot and offered fences.
3075
+ if (task.reserved) await this.offerReady(task, "queue pressure cleared", current);
3076
+ else await this.assignOwner(task, HUB, { current, ...retry });
3077
+ } catch (error) {
3078
+ if (!(error instanceof AssignmentUndeliveredError) && this.queuePressureAdmitted()) pass.retained = true;
3079
+ }
3080
+ if (!this.queuePressureAdmitted()) return;
3081
+ } // Pure transient eligibility reads keep the origin; only a gate that owns retries replaces it.
3082
+ const latest = this.d.board.get(task.id);
3083
+ if (this.ordinaryQueueProposal(stall, latest)) {
3084
+ const origin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
3085
+ if (this.isHeld(latest)) this.d.board.clearStall(task.id); // WIP release owns its ready-held task now.
3086
+ else if (origin) {
3087
+ this.d.board.recordStall({ ...origin, replanRevision: latest.planningRevision ?? 0 });
3088
+ pass.retained = true;
3089
+ } else {
3090
+ const replacing = this.d.board.stall(task.id);
3091
+ const selected = choice.owner;
3092
+ const refusal = replacing && selected ? replacing.cause.peer === selected ? replacing.cause.code : replacing.cause.refusals?.find(row => row.peer === selected)?.code : undefined;
3093
+ const transient = replacing && (replacing.cause.kind === "no-eligible-peer" || replacing.cause.kind === "reservation") && replacing.cause.code !== "policy" && (refusal === "offline" || refusal === "not-attached");
3094
+ if (transient) {
3095
+ // The initially eligible owner vanished during admission; a policy gate never reaches this restore.
3096
+ this.d.board.recordStall({ ...stall, replanRevision: latest.planningRevision ?? 0 });
3097
+ pass.retained = true;
3098
+ }
3099
+ } // Other replacing gates keep their stall and own any later retry.
3100
+ } else this.d.board.clearStall(task.id);
3101
+ }
3102
+ }
3103
+ if (!pass.reviewDone && this.queuePressureAdmitted()) {
3104
+ const ids = this.d.board.humanReviewPage(pass.reviewAfter, QUEUE_RECHECK_PAGE);
3105
+ pass.reviewAfter = ids.at(-1) ?? pass.reviewAfter; pass.reviewDone = ids.length < QUEUE_RECHECK_PAGE;
3106
+ for (const id of ids) {
3107
+ if (!this.queuePressureAdmitted()) return;
3108
+ const task = this.d.board.get(id);
3109
+ if (!task || !this.queueReviewHeld(task)) continue;
3110
+ const reviewer = await this.recheckQueueReview(task);
3111
+ if (!this.queuePressureAdmitted()) return;
3112
+ const latest = this.d.board.get(id);
3113
+ if (!reviewer && latest && this.queueReviewHeld(latest)) pass.retained = true;
3114
+ }
3115
+ }
3116
+ }
3117
+ private async recheckQueueReview(before: Task): Promise<PeerId | undefined> {
3118
+ const revision = this.reviewRequestRevision(before), head = this.reviewHead(before), owner = before.owner;
3119
+ const valid = () => {
3120
+ if (!this.queuePressureAdmitted()) throw new PreEffectToolRefusal("queue-pressure retry is no longer admitted");
3121
+ const task = this.need(before.id, false, true);
3122
+ if (!this.queueReviewHeld(task) || this.reviewRequestRevision(task) !== revision || this.reviewHead(task) !== head || task.owner !== owner) throw new PreEffectToolRefusal("queue-held review changed");
3123
+ return task;
3124
+ };
3125
+ const choose = (task: Task) => assign(task, this.states(), this.d.routing(), { ...this.weights(task.class, task), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
3126
+ const initial = choose(valid());
3127
+ if (!initial.reviewer || initial.reviewer === USER || initial.reviewBlocked) return undefined;
3128
+ try {
3129
+ const admission = await this.reviewRequestAdmission(before, undefined, true, before.owner ?? USER, undefined, () => { valid(); return true; });
3130
+ const task = valid(), selection = choose(task), reviewer = selection.reviewer;
3131
+ if (!reviewer || reviewer === USER || selection.reviewBlocked) return undefined;
3132
+ valid();
3133
+ const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote("queue pressure cleared", selection), this.reviewMetadata(selection), task.history.length);
3134
+ this.heldReviews.delete(next.id);
3135
+ // The current head is unchanged; its watcher reads the live reviewer at completion.
3136
+ this.releaseNotice(`task #${next.id}: queued review request unavailable`, () => this.sendReview(next, reviewer, "", admission));
3137
+ return reviewer;
3138
+ } catch { return undefined; }
3139
+ }
3140
+
2662
3141
  /** Why a proposal's named owner (a self-claim included) did not get it, from the stall its proposal recorded (#601); undefined when it did. */
2663
3142
  namedOwnerNote(task: Task, named: PeerId | undefined, by: PeerId): string | undefined {
3143
+ requireLocalTaskPeer(by);
2664
3144
  if (!named || task.owner || task.state !== "proposed") return undefined;
2665
3145
  const stall = this.d.board.stalls().find(row => row.task === task.id);
2666
3146
  if (!stall) return undefined;
2667
3147
  const code = stall.cause.refusals?.find(row => row.peer === named)?.code ?? (stall.cause.peer === named ? stall.cause.code : undefined);
2668
3148
  return `${named} was not assigned${code ? ` (${code})` : ""}: ${stall.cause.kind}; assign it again with hub_task_assign once ${named} can take it`;
2669
3149
  }
2670
- private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
3150
+ private ownerStallCause(task: Task, admission: Assignment, automatic = true): TaskStall["cause"] {
2671
3151
  const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
2672
3152
  // Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
2673
- const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
2674
- return refusal ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
3153
+ const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "context-headroom" || code === "instance" || code === "family" ? "policy" : code;
3154
+ const pressure = automatic ? admission.refusals?.find(row => row.role === "owner" && isQueuePressureCode(row.code)) : undefined;
3155
+ return refusal && !(automatic && isQueuePressureCode(refusal.code)) ? { kind: "reservation", peer: refusal.peer, code: persisted(refusal.code) } : { kind: "no-eligible-peer", code: pressure ? persisted(pressure.code) : "unavailable", refusals: admission.refusals?.filter(row => row.role === "owner").map(({ peer, code }) => ({ peer, code: persisted(code) })).sort((a, b) => a.peer.localeCompare(b.peer)) ?? [] };
2675
3156
  }
2676
3157
  private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
2677
3158
  const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
2678
3159
  if (!this.d.board.recordStall(stall)) return;
3160
+ if (cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code)) this.markQueuePressureCandidate();
2679
3161
  const text = description ?? `Task #${task.id} stalled: ${cause.kind}${cause.peer ? ` ${cause.peer}` : ""}${cause.dependency ? ` #${cause.dependency}` : ""} (${cause.code}); configure the runner or peer, ahub task assign ${task.id} <peer>, re-plan dependencies, or cancel it.`;
2680
3162
  this.releaseNotice(text);
2681
3163
  try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
2682
3164
  // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
2683
3165
  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)));
2684
3166
  }
3167
+ /**
3168
+ * #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
3169
+ * 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
3170
+ * its history entry stays a stub. The entry is not a transition and moves nothing; a later verdict for the same slot supersedes the earlier one (the scorecard reads the last).
3171
+ */
3172
+ recordClaimVerdict(by: PeerId, id: unknown, input: unknown): Task {
3173
+ if (by !== USER) throw new Error("only the console user may record a claim verdict");
3174
+ if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new Error("recovery or shutdown is holding task writes");
3175
+ const task = this.need(id);
3176
+ const claims = task.plan?.claims?.length ?? 0;
3177
+ if (!claims) throw new Error(`task #${task.id} declares no plan claims: add them to its plan first`);
3178
+ const claim = parseClaimVerdict(input, claims);
3179
+ // 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.
3180
+ 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
3181
+ }
3182
+
2685
3183
  async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
3184
+ requireLocalTaskPeer(by);
2686
3185
  if (typeof input.reason !== "string" || !input.reason.trim() || Buffer.byteLength(input.reason) > 4000 || input.supersededBy !== undefined && (typeof input.supersededBy !== "string" || !input.supersededBy.trim() || Buffer.byteLength(input.supersededBy) > 1000)) throw new PreEffectToolRefusal("cancellation needs a bounded reason and supersession reference");
2687
3186
  const validate = () => {
2688
3187
  if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
@@ -2705,6 +3204,7 @@ export class Tasks {
2705
3204
  if (task.history.length !== revision) throw new PreEffectToolRefusal("task flow revision changed");
2706
3205
  const cancelled = { reason: input.reason.trim(), ...(input.supersededBy ? { supersededBy: input.supersededBy.trim() } : {}), flowRevision: revision + 1, ...(withheld ? { withheld: true as const } : {}) };
2707
3206
  const capacity = this.capacityBefore(task);
3207
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
2708
3208
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
2709
3209
  this.writePlannerNotices(next, capacity);
2710
3210
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
@@ -2729,15 +3229,21 @@ export class Tasks {
2729
3229
  }
2730
3230
 
2731
3231
  async releaseReady(): Promise<void> {
3232
+ const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
2732
3233
  for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
2733
3234
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
2734
3235
  const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
2735
3236
  if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
2736
3237
  const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
2737
3238
  if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
2738
- const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() });
3239
+ if (haveQueueOrigins) {
3240
+ const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
3241
+ if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
3242
+ }
3243
+ const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
2739
3244
  if (!admission.owner) {
2740
- this.reportStall(t, this.ownerStallCause(t, admission));
3245
+ // A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
3246
+ this.reportStall(t, this.ownerStallCause(t, admission, this.hasPersonReservation(t)));
2741
3247
  continue;
2742
3248
  }
2743
3249
  this.d.board.clearStall(t.id);
@@ -2749,7 +3255,8 @@ export class Tasks {
2749
3255
  }
2750
3256
 
2751
3257
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
2752
- private async offerReady(stale: Task, why: string): Promise<void> {
3258
+ private async offerReady(stale: Task, why: string, current?: () => void): Promise<void> {
3259
+ current?.();
2753
3260
  let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
2754
3261
  try {
2755
3262
  const t = this.d.board.get(stale.id);
@@ -2766,7 +3273,7 @@ export class Tasks {
2766
3273
  offering = true;
2767
3274
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
2768
3275
  this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
2769
- await this.assignOwner(ready, HUB, planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {});
3276
+ await this.assignOwner(ready, HUB, { current, ...(planned ? { candidates: [selected!], namedOwner: selected!, planner: true } : {}) });
2770
3277
  } catch (e) {
2771
3278
  // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
2772
3279
  // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
@@ -2786,6 +3293,7 @@ export class Tasks {
2786
3293
  }
2787
3294
 
2788
3295
  private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
3296
+ requireLocalTaskPeer(by);
2789
3297
  if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
2790
3298
  }
2791
3299
 
@@ -2808,6 +3316,31 @@ export class Tasks {
2808
3316
  return names.length ? `task #${task.id}: the title names ${names.join(", ")}; set refs.repo to that repository (owner/name) so its issue links and the lifecycle shows it` : undefined;
2809
3317
  }
2810
3318
 
3319
+ /**
3320
+ * #798 D1: a declared path (refs, plan paths, the file part of a section anchor) inside a git submodule of this project: snapshots, facts, overlap and undo do not observe
3321
+ * inside a submodule. A notice, never a refusal: the task stays valid. Advice only, so a PII task gets none, a path whose name matches a PII pattern is not named, and a
3322
+ * task bound to another repository is judged against that one's layout, which this project's `.gitmodules` does not describe (its scope is not the project's).
3323
+ */
3324
+ submoduleNotice(task: Task): string | undefined {
3325
+ if (this.isPii(task)) return undefined;
3326
+ let submodules: string[];
3327
+ try { submodules = this.d.submodules?.() ?? []; } catch { return undefined; }
3328
+ if (!submodules.length) return undefined;
3329
+ if (this.repositories.scope(task.refs) !== this.repositories.project) return undefined;
3330
+ const declared = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? []), ...(task.plan?.sections ?? []).map(anchor => anchor.split("#")[0]!).filter(Boolean)];
3331
+ const inside = new Set<string>();
3332
+ for (const path of declared) {
3333
+ let local: string;
3334
+ try { local = this.projectPath(path); } catch { continue; }
3335
+ if (local === ".." || local.startsWith("../") || isAbsolute(local)) continue;
3336
+ const hit = submodules.find(sub => local === sub || local.startsWith(`${sub}/`));
3337
+ if (hit) inside.add(hit);
3338
+ }
3339
+ const named = [...inside].filter(this.nameable).sort();
3340
+ if (!named.length) return inside.size ? "inside a submodule (path withheld, it matches a PII pattern): facts, overlap and undo do not observe it" : undefined;
3341
+ return named.map(path => `inside submodule ${path}: facts, overlap and undo do not observe it`).join("\n");
3342
+ }
3343
+
2811
3344
  /** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
2812
3345
  private linkIssue(task: Task): Task {
2813
3346
  if (this.isPii(task) || task.sdlc?.issue) return task;
@@ -2962,7 +3495,53 @@ export class Tasks {
2962
3495
  const byKind: Record<string, number> = {};
2963
3496
  for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
2964
3497
  const stalls = { open: Object.values(byKind).reduce((sum, n) => sum + n, 0), byKind, doneWithoutWrites: rows.filter(task => visibleIds.has(task.id) && task.history.findLast(entry => entry.event === "done")?.noWrites).length };
2965
- return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, ...(merged ?? {}) });
3498
+ // #481 S7: the merge-time readback the guarded merge recorded on each merged task's receipt (host-written, never a tool input), and the two series that are not made of tasks.
3499
+ const mergedAt: Record<number, number> = {}, ciReruns: Record<number, number | null> = {}, ciRuns: Record<number, number | null> = {}, reviews: Record<number, number | null> = {};
3500
+ for (const task of rows) {
3501
+ const merge = task.sdlc?.merge;
3502
+ if (!visibleIds.has(task.id) || merge?.state !== "merged") continue;
3503
+ const cost = cleanMergeCost(merge.cost);
3504
+ const at = cost?.mergedAt ?? (Number.isSafeInteger(merge.at) ? merge.at : null);
3505
+ if (at !== null) mergedAt[task.id] = at;
3506
+ if (cost) { ciReruns[task.id] = cost.ciReruns; ciRuns[task.id] = cost.ciRuns; reviews[task.id] = cost.reviews; }
3507
+ }
3508
+ return sdlcMetrics(tasks, { ...(cutoff !== undefined ? { since: cutoff } : {}), ...(this.d.sdlc?.config().bands ? { bands: this.d.sdlc.config().bands } : {}), stalls, mergedAt, ciReruns, ciRuns, reviews, external: this.externalSeries(), ...(merged ?? {}) });
3509
+ }
3510
+
3511
+ /** #481 S7: one point per shipped release (1 for a hotfix, 0 otherwise; a release with no recorded train is skipped, never a 0) and one per recorded security scan whose total is known. */
3512
+ private externalSeries(): Partial<Record<BandMetric, SeriesPoint[]>> {
3513
+ const rows = this.d.board.list(), privateRelease = (id: number) => rows.some(task => task.release === id && this.isPii(task));
3514
+ const hotfix = this.d.board.releases().filter(release => release.state === "shipped" && release.shippedAt !== null && release.train?.history.length && !privateRelease(release.id))
3515
+ .sort((a, b) => a.shippedAt! - b.shippedAt! || a.id - b.id).map(release => ({ value: release.train!.hotfix ? 1 : 0, task: release.id }));
3516
+ const config = this.d.sdlc?.config().securityScan, repo = config?.repo ?? this.d.repository;
3517
+ // A band needs `window` baseline points plus the newest one, and the episode walk looks back up to EPISODE_LOOKBACK more, so the series loads that many known
3518
+ // totals (unknown ones are filtered in SQL, never counted against the limit). The board caps one read at 1000 rows.
3519
+ const need = (this.d.sdlc?.config().bands?.security_open_alerts?.window ?? DEFAULT_BAND.window) + EPISODE_LOOKBACK + 2;
3520
+ const scans = config && typeof repo === "string" ? this.d.board.securityScans(repo, config.sources.join(","), need, true).map(scan => ({ value: scan.total!, task: scan.id })) : [];
3521
+ return { hotfix_rate: hotfix, security_open_alerts: scans };
3522
+ }
3523
+
3524
+ /**
3525
+ * #481 S7: the scheduled security-scan baseline. Reads the open-alert counts GitHub already holds (`security-baseline.ts`), records one row per interval and then runs the
3526
+ * same band path as every other metric, so a breach is one deduplicated card. Held like every host writer; a private repository identity, a mode off, no `[security_scan]`
3527
+ * table and a scan that is not yet due all do nothing. A source GitHub will not read (not enabled, not permitted) records an unknown total, never zero. Returns the scan id.
3528
+ */
3529
+ async runSecurityScan(now = Date.now()): Promise<number | undefined> {
3530
+ const config = this.d.sdlc?.config(), scan = config?.securityScan;
3531
+ if (!config || config.mode === "off" || !scan) return undefined;
3532
+ const repo = scan.repo ?? this.d.repository;
3533
+ if (typeof repo !== "string" || !this.nameable(repo)) return undefined;
3534
+ const held = () => !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError;
3535
+ if (held()) return undefined;
3536
+ const sources = scan.sources.join(","), last = this.d.board.securityScans(repo, sources, 1).at(-1);
3537
+ if (last && now - last.at < scan.everyH * 3_600_000) return undefined;
3538
+ const read = await readSecurityScan(repo, scan.sources, this.d.sdlc?.gh ?? sdlcGh);
3539
+ const after = this.d.sdlc?.config();
3540
+ // The read awaited: the lifecycle, the schedule or a hold may have changed meanwhile, so nothing is written from a stale decision.
3541
+ if (!read || held() || !after || after.mode === "off" || JSON.stringify(after.securityScan) !== JSON.stringify(scan)) return undefined;
3542
+ const id = this.d.board.recordSecurityScan({ repo, sources, counts: read.counts as Record<string, number | null>, total: read.total });
3543
+ this.proposeFromBands();
3544
+ return id;
2966
3545
  }
2967
3546
 
2968
3547
  /**
@@ -2981,10 +3560,14 @@ export class Tasks {
2981
3560
  bandsState(): { ready: boolean; signature: string } {
2982
3561
  const config = this.d.sdlc?.config();
2983
3562
  const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
2984
- return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError, signature: configured ? JSON.stringify([config!.mode, config!.bands]) : "" };
3563
+ // The release and scan series do not move the task board's revision, so what they last held is part of the signature: a shipped release or a new scan row is a reason to run again.
3564
+ const external = configured ? this.externalSeries() : {};
3565
+ return { ready: configured && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError,
3566
+ signature: configured ? JSON.stringify([config!.mode, config!.bands, config!.securityScan ?? null, external.hotfix_rate?.length ?? 0, external.security_open_alerts?.at(-1)?.task ?? 0]) : "" };
2985
3567
  }
2986
3568
 
2987
3569
  proposeFromBands(by: PeerId = HUB): number[] {
3570
+ requireLocalTaskPeer(by);
2988
3571
  const config = this.d.sdlc?.config();
2989
3572
  if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
2990
3573
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
@@ -2998,22 +3581,24 @@ export class Tasks {
2998
3581
  // A card counts as open while its task is not closed: the planner has not yet decided on it.
2999
3582
  const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
3000
3583
  const created: number[] = [];
3584
+ // Fixed words and a number: a release or a security scan is named by its id, a task by `task #id`.
3585
+ const subjectText = (band: BandResult, id: number) => band.subject === "release" ? `release #${id}` : band.subject === "scan" ? `security scan #${id}` : `task #${id}`;
3001
3586
  for (const band of report.bands as BandResult[]) {
3002
3587
  if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
3003
3588
  const key = `${band.metric}:#${band.episode}`;
3004
3589
  if (band.tier === "log") {
3005
3590
  const marker = `${key}:log`;
3006
- if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on task #${band.lastTask}`);
3591
+ if (!open.has(band.metric) && this.d.board.recordBandNotice(marker)) this.releaseNotice(`sdlc band: ${band.metric} is ${band.reason}; last value ${band.last} on ${subjectText(band, band.lastTask)}`);
3007
3592
  continue;
3008
3593
  }
3009
3594
  if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
3010
3595
  const tier = band.tier === "propose" ? "propose" : "diagnose";
3011
3596
  const full = `${key}:${tier}`;
3012
- const evidence = `metric ${band.metric}; newest value ${band.last} on task #${band.lastTask}; episode began at task #${band.episode}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} tasks; ${band.reason}`;
3597
+ const evidence = `metric ${band.metric}; newest value ${band.last} on ${subjectText(band, band.lastTask)}; episode began at ${subjectText(band, band.episode)}; baseline mean ${band.mean}, deviation ${band.sd} over ${band.baseline} ${band.subject === "release" ? "releases" : band.subject === "scan" ? "scans" : "tasks"}; ${band.reason}`;
3013
3598
  const diagnose = tier === "diagnose";
3014
3599
  const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
3015
3600
  const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
3016
- ? "Read-only triage for the planner: inspect the recent task histories behind this metric and report what changed. Do not edit files, branches or the board."
3601
+ ? `Read-only triage for the planner: inspect the recent ${band.subject === "release" ? "releases" : band.subject === "scan" ? "security scans" : "task histories"} behind this metric and report what changed. Do not edit files, branches or the board.`
3017
3602
  : "Backlog card for the planner: decide whether this breach needs a process change. It names the metric and its evidence; it opens no GitHub issue."}`;
3018
3603
  const draft = { title, detail, refs: {} };
3019
3604
  const signals = this.signalsFor(draft);
@@ -3025,6 +3610,167 @@ export class Tasks {
3025
3610
  return created;
3026
3611
  }
3027
3612
 
3613
+ /** The open task each schedule made: a hub-written card (first history entry by the hub) whose detail starts with the schedule marker and that is not closed. */
3614
+ openScheduled(): Map<string, number> {
3615
+ const open = new Map<string, number>();
3616
+ for (const row of this.d.board.list()) {
3617
+ const key = row.history[0]?.by === HUB && !isClosedTaskState(row.state) ? scheduleKeyOf(row.detail) : undefined;
3618
+ if (key !== undefined && !open.has(key)) open.set(key, row.id);
3619
+ }
3620
+ return open;
3621
+ }
3622
+
3623
+ /** Whether a scheduled run may write now: the same holds as every host writer (stopping or recovering, held by the daemon, a storage error). */
3624
+ schedulesHeld(): boolean { return !!this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || !!this.d.bus.storageError; }
3625
+
3626
+ private schedulesRunning = false;
3627
+ /** 1 while a schedules pass is running (its PII screen and triage are awaited), so a recovery commit waits for it like it waits for `checksPending` and `autoPullsPending`: a run mid-admission would write after the commit's digest. */
3628
+ schedulesPending = (): number => (this.schedulesRunning ? 1 : 0);
3629
+ /**
3630
+ * One due schedule's run: the hold, the open-task rule and the proposal, shared by the timer and `ahub schedule run`, so a manual run can never do what a due
3631
+ * one may not. `propose` asks the fence again right before its board write, so a run admitted and then overtaken by a hold writes nothing and counts as a skip.
3632
+ */
3633
+ private async runSchedule(store: ScheduleStore, entry: ScheduleEntry, now: number, extraHold: () => boolean, manual = false): Promise<ScheduleRunResult> {
3634
+ const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
3635
+ // A skipped tick is counted for the timer only: a person's refused run is not a missed period, so it never feeds the coalescing count.
3636
+ const skip = () => { if (!manual) store.skipped(entry.id); };
3637
+ if (hold()) { skip(); return { id: entry.id, outcome: "held" }; }
3638
+ const open = this.openScheduled().get(entry.id);
3639
+ if (open !== undefined) { skip(); return { id: entry.id, outcome: "waiting", task: open }; }
3640
+ // The PII screen and triage are awaited inside `propose`, and a hold can begin during them (shutdown, recovery, a storage error). `propose` asks this fence
3641
+ // again right before its board write, so a run that was admitted and then overtaken by a hold writes nothing and counts as a skip, like one held at the start.
3642
+ // The same fence also reads the schedule's pause generation (#335): a person who paused or resumed it after this run was admitted has changed what should happen,
3643
+ // so the run writes nothing. A timer run that finds it paused or resumed meanwhile is dropped (the next pass decides); a person's own run never races its own command.
3644
+ const admitted = store.epoch(entry.id);
3645
+ let overtaken = false, changed = false;
3646
+ const fence = () => {
3647
+ const reason = hold(); if (reason) overtaken = true;
3648
+ if (!reason && !manual && store.epoch(entry.id) !== admitted) { changed = true; return "the schedule was paused or resumed"; }
3649
+ return reason;
3650
+ };
3651
+ try {
3652
+ const task = await this.propose(HUB, { title: entry.title, detail: scheduledDetail(entry), class: entry.class, backlog: entry.queue === "backlog",
3653
+ ...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
3654
+ store.ran(entry.id, now, task.id);
3655
+ return { id: entry.id, outcome: "ran", task: task.id };
3656
+ } catch (error) {
3657
+ if (changed) return { id: entry.id, outcome: "changed" };
3658
+ if (overtaken) { try { skip(); } catch { /* the store closed with the hub */ } return { id: entry.id, outcome: "held" }; }
3659
+ store.failed(entry.id, sanitize((error as Error).message));
3660
+ return { id: entry.id, outcome: "failed" };
3661
+ }
3662
+ }
3663
+
3664
+ /**
3665
+ * #512a: one pass over the machine-local schedules. A schedule is due once `every` has passed since its period began (first sight, then its last run).
3666
+ * A due schedule proposes at most one task per pass, through `propose` like any proposal (the PII screen, triage, routing and notices all apply), and none
3667
+ * while the task its previous run made is still open. While the hub is held the whole pass skips and every due schedule counts the skip, so a long hold
3668
+ * ends in one run per schedule, never a burst. A run restarts the period at `now`: nothing a restart or a hold missed is replayed. Returns what each due schedule did.
3669
+ */
3670
+ async proposeFromSchedules(store: ScheduleStore, entries: readonly ScheduleEntry[], now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult[]> {
3671
+ if (this.schedulesRunning) return [];
3672
+ this.schedulesRunning = true;
3673
+ const out: ScheduleRunResult[] = [];
3674
+ try {
3675
+ store.prune(entries.map(entry => entry.id));
3676
+ for (const entry of entries) {
3677
+ const row = store.ensure(entry.id, now);
3678
+ if (row.paused) continue; // #335: a person's pause is neither a run nor a skipped tick
3679
+ if (now < row.anchor + parseEvery(entry.every)) continue;
3680
+ out.push(await this.runSchedule(store, entry, now, extraHold));
3681
+ }
3682
+ } finally { this.schedulesRunning = false; }
3683
+ return out;
3684
+ }
3685
+
3686
+ /**
3687
+ * #335: `ahub schedule run <id>`. The person runs one configured schedule now, whatever its period says. It takes the same pass guard as the timer (so it never
3688
+ * overlaps one), the same hold, open-task and `propose` path (`runSchedule`), and restarts that schedule's period on success like a due run. A paused schedule
3689
+ * runs too: a pause stops the timer, not the person. Only the person may.
3690
+ */
3691
+ async runScheduleNow(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, now: number, extraHold: () => boolean = () => false): Promise<ScheduleRunResult & { reason?: string }> {
3692
+ if (!this.personOrConductor(by)) throw new PreEffectToolRefusal("schedule run is a console command");
3693
+ const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
3694
+ if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
3695
+ if (this.schedulesRunning) throw new PreEffectToolRefusal("a schedules pass is running; try again in a moment");
3696
+ this.schedulesRunning = true;
3697
+ try {
3698
+ store.ensure(entry.id, now);
3699
+ // #933: the board-write fence asks the hold again after the PII screen, so a conductor whose role or connection ended meanwhile writes nothing.
3700
+ const result = await this.runSchedule(store, entry, now, () => extraHold() || !this.personOrConductor(by), true);
3701
+ const reason = result.outcome === "failed" ? store.get(entry.id)?.last_error ?? undefined : undefined;
3702
+ return reason ? { ...result, reason } : result;
3703
+ } finally { this.schedulesRunning = false; }
3704
+ }
3705
+
3706
+ /** #335: pause or resume one configured schedule. Only the person may; resuming starts a fresh period, so a pause is never replayed as a run. */
3707
+ setSchedulePaused(by: PeerId, store: ScheduleStore, entries: readonly ScheduleEntry[], id: unknown, paused: boolean, now: number): void {
3708
+ if (!this.personOrConductor(by)) throw new PreEffectToolRefusal(`schedule ${paused ? "pause" : "resume"} is a console command`);
3709
+ const entry = typeof id === "string" ? entries.find(candidate => candidate.id === id) : undefined;
3710
+ if (!entry) throw new PreEffectToolRefusal(`no schedule ${typeof id === "string" ? JSON.stringify(id.slice(0, 64)) : "named"}; ahub schedule list shows the configured ones`);
3711
+ store.setPaused(entry.id, paused, now);
3712
+ }
3713
+
3714
+ /**
3715
+ * #336 (#512c): one pass over the machine-local folder triggers, on the schedules timer. Each trigger's folder is read once (never watched). The first poll of a trigger
3716
+ * records what is already there and proposes nothing, so adding a trigger to a full inbox does not flood the board; after that a file the trigger has not recorded (by
3717
+ * real path and content hash) is registered as a source through `register` and proposes ONE task through `propose` as the hub, which names the source id and never the
3718
+ * file's name or path. A rename or a touch keeps the hash and proposes nothing. While the hub is held nothing is read or recorded, so the files wait for the next poll.
3719
+ * A file is recorded only after its task exists, so a failure or a hold between the two leaves it to be tried again (a crash in that window can repeat one task; it
3720
+ * never loses a file). At most MAX_NEW_PER_POLL files per trigger per pass.
3721
+ */
3722
+ async runFolderTriggers(store: FolderStorePort, entries: readonly FolderEntry[], scan: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean = () => false): Promise<FolderRunResult[]> {
3723
+ if (this.schedulesRunning) return [];
3724
+ this.schedulesRunning = true;
3725
+ const out: FolderRunResult[] = [];
3726
+ try {
3727
+ store.prune(entries.map(entry => entry.id));
3728
+ for (const entry of entries) out.push(await this.runFolderTrigger(store, entry, scan, register, now, extraHold));
3729
+ } finally { this.schedulesRunning = false; }
3730
+ return out;
3731
+ }
3732
+
3733
+ private async runFolderTrigger(store: FolderStorePort, entry: FolderEntry, scanOf: (entry: FolderEntry) => FolderScan, register: (file: ScannedFile) => { id: string; private: boolean }, now: number, extraHold: () => boolean): Promise<FolderRunResult> {
3734
+ const hold = () => this.schedulesHeld() || extraHold() ? "the hub is held" : undefined;
3735
+ if (hold()) return { id: entry.id, outcome: "held", tasks: [], files: 0 };
3736
+ const scan = scanOf(entry);
3737
+ // A refused folder never starts the trigger: when it becomes readable, what is in it then is the baseline, not news.
3738
+ if (scan.refused) { store.polled(entry.id, now, scan.refused); return { id: entry.id, outcome: "refused", tasks: [], files: 0, reason: scan.refused }; }
3739
+ // The baseline is every file already in the folder when the trigger starts. A folder too big to hash in one poll (the scan's budget, `truncated`) is baselined over
3740
+ // several polls: each records what it read and proposes nothing, and the poll that reaches the end of the folder completes it. Only then is a file news.
3741
+ if (!store.baselined(entry.id)) {
3742
+ store.begin(entry.id, now);
3743
+ for (const file of scan.files) if (!file.seen) store.record(entry.id, file, null, now);
3744
+ if (!scan.truncated) store.markBaselined(entry.id, now); else store.polled(entry.id, now);
3745
+ return { id: entry.id, outcome: "baseline", tasks: [], files: scan.files.length };
3746
+ }
3747
+ // A file the trigger already knows by content (a rename, a touch, a copy) proposes nothing, but its new path, size and mtime are recorded, so the next scan finds it with
3748
+ // `known` instead of hashing it again: otherwise such files would spend the hash budget on every poll and starve a genuinely new one behind them.
3749
+ for (const file of scan.files) if (!file.seen && !store.isNew(entry.id, file.sha256)) store.observe(entry.id, file, now);
3750
+ // `isNew` is asked again for each file below, not only here: two files with the same new bytes can arrive before one poll, and the first one's record must suppress the second.
3751
+ const fresh = scan.files.filter(file => !file.seen && store.isNew(entry.id, file.sha256));
3752
+ const tasks: number[] = [];
3753
+ let error: string | undefined;
3754
+ for (const file of fresh) {
3755
+ if (tasks.length >= MAX_NEW_PER_POLL || hold()) break;
3756
+ if (!store.isNew(entry.id, file.sha256)) { store.observe(entry.id, file, now); continue; } // a duplicate found inside the loop: recorded, never a second task
3757
+ let overtaken = false;
3758
+ const fence = () => { const reason = hold(); if (reason) overtaken = true; return reason; };
3759
+ try {
3760
+ const source = register(file);
3761
+ const task = await this.propose(HUB, { title: entry.title, detail: folderDetail(entry, source.id), class: entry.class, backlog: entry.queue === "backlog", ...(entry.owner ? { owner: entry.owner } : {}) }, undefined, undefined, fence);
3762
+ store.record(entry.id, file, task.id, now);
3763
+ tasks.push(task.id);
3764
+ } catch (caught) {
3765
+ if (overtaken) break;
3766
+ error = sanitize((caught as Error).message);
3767
+ break; // the next poll tries this file again; later files wait behind it so order is kept
3768
+ }
3769
+ }
3770
+ store.polled(entry.id, now, error);
3771
+ return { id: entry.id, outcome: error ? "failed" : tasks.length ? "ran" : "none", tasks, files: fresh.length, ...(scan.truncated ? { more: true as const } : {}), ...(error ? { reason: error } : {}) };
3772
+ }
3773
+
3028
3774
  /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
3029
3775
  async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
3030
3776
  const authority = () => {
@@ -3064,7 +3810,7 @@ export class Tasks {
3064
3810
  const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
3065
3811
  const scopedIds = new Set(scoped.map(task => task.id));
3066
3812
  const stalls = { noWrites: this.stalled().filter(stall => stall.cause.kind === "no-writes" && scopedIds.has(stall.task)).length, doneWithoutWrites: scoped.filter(task => task.history.findLast(entry => entry.event === "done")?.noWrites).length };
3067
- return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "S7 pending" };
3813
+ return { ...report, stalls, truncated: report.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}), bands: "see ahub sdlc metrics" };
3068
3814
  }
3069
3815
 
3070
3816
  async planReviewGap(task: Task): Promise<string | undefined> {
@@ -3139,18 +3885,21 @@ export class Tasks {
3139
3885
  * and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
3140
3886
  */
3141
3887
  async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
3888
+ const requestCheck = this.requestCheck(by);
3142
3889
  if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
3143
3890
  const { deliverables, ...receipt } = input as Record<string, unknown>;
3144
3891
  const task = this.need(id, false, true);
3145
3892
  if (by !== USER && by !== task.owner && by !== task.reviewer) throw new PreEffectToolRefusal("only the task owner, reviewer or console user may record lifecycle evidence");
3146
3893
  const claims = this.deliverableClaims(deliverables, task);
3147
3894
  const records = await this.hashClaims(claims, task, true);
3895
+ requestCheck();
3148
3896
  const current = this.need(id, false, true);
3149
3897
  if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
3150
3898
  return this.recordLifecycle(by, id, receipt, records);
3151
3899
  }
3152
3900
 
3153
3901
  recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
3902
+ requireLocalTaskPeer(by);
3154
3903
  const task = this.need(id, false, true);
3155
3904
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3156
3905
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
@@ -3191,6 +3940,25 @@ export class Tasks {
3191
3940
  return this.d.board.list().map(t => ({ id: t.id, after: t.deps ?? [], private: this.isPii(t), receipt: t.sdlc, phase: this.isPii(t) ? undefined : this.workflowView(t), findings: this.isPii(t) ? undefined : this.reviewFindingSets(t), plan: config.mode === "off" || this.isPii(t) ? undefined : { paths: this.planPaths(t), revision: t.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0, fingerprint: planFingerprint(t.plan ?? {}), accepted: this.validPlanDepartures(t) } }));
3192
3941
  }
3193
3942
 
3943
+ /**
3944
+ * The board links one `readMergeQueue` read used for a subject (#491): the subject, the holders of the PRs in the open set it read (repository names compare without
3945
+ * case, as the queue does), and the tasks those holders and the subject depend on. Nothing else reaches the queue: a proposal, a plan or finding elsewhere, a holder
3946
+ * of a closed or merged PR that nobody depends on, or a receipt in another repository cannot change what it saw. Pass the queue the decision read: its rows are the
3947
+ * open set. Without one (the read failed) every link counts.
3948
+ */
3949
+ private queueLinks(links: DeliveryTask[], subject: DeliveryTask, queue: MergeQueue | undefined): DeliveryTask[] {
3950
+ if (!queue) return links;
3951
+ const repo = subject.receipt?.repo?.toLowerCase(), open = new Set(queue.rows.map(row => row.pr));
3952
+ // The subject's own dependencies are also reached as a holder of its open PR; naming them here keeps the fence right if that PR were ever absent from the rows.
3953
+ const used = new Set<number>([subject.id, ...subject.after]);
3954
+ for (const link of links) {
3955
+ if (repo === undefined || link.receipt?.repo?.toLowerCase() !== repo || link.receipt.pr === undefined || !open.has(link.receipt.pr)) continue;
3956
+ used.add(link.id);
3957
+ for (const id of link.after) used.add(id);
3958
+ }
3959
+ return links.filter(link => used.has(link.id));
3960
+ }
3961
+
3194
3962
  async deliveryQueue(repo: string) {
3195
3963
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3196
3964
  const before = workflowHash(this.d.board.list().map(t => ({ id: t.id, state: t.state, after: t.deps, sdlc: t.sdlc, history: t.history.length })));
@@ -3223,8 +3991,9 @@ export class Tasks {
3223
3991
  private readonly mergesInFlight = new Set<number>();
3224
3992
  /** #786: who may ask for a guarded merge. The hub's own approval trigger acts as HUB; a peer must be the one configured conductor. */
3225
3993
  private mergeAuthority(by: PeerId): boolean {
3994
+ if (isRemotePeerId(by)) return false;
3226
3995
  if (by === USER || by === HUB) return true;
3227
- const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
3996
+ const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
3228
3997
  return holders.length === 1 && holders[0] === by;
3229
3998
  }
3230
3999
  /**
@@ -3255,14 +4024,15 @@ export class Tasks {
3255
4024
  this.mergesInFlight.add(task.id);
3256
4025
  try {
3257
4026
  const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
3258
- const queueIdentity = workflowHash({ config, links });
3259
4027
  const outcome = await guardedMerge({ mode, subject, state: task.state, taskState: n => this.d.board.get(n)?.state, config, tasks: links, run: this.d.sdlc?.gh ?? sdlcGh, readableMetadata: this.publicMetadataText,
3260
4028
  recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
3261
- beforeMerge: () => {
4029
+ beforeMerge: queue => {
3262
4030
  if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
3263
- // The queue used every link, not only the subject. A changed dependency artifact or ordering edge invalidates that remote evidence.
4031
+ // Only the links this queue read count: a change to one of those (a dependency artifact, an ordering edge, a relink, a new holder of an open PR) invalidates
4032
+ // that remote evidence, and any other task's change does not. Both sides are projected through the same queue, so a new holder of an open PR shows.
3264
4033
  const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3265
- return workflowHash({ config: currentConfig, links: this.deliveryLinks(currentConfig) }) === queueIdentity
4034
+ const currentLinks = this.deliveryLinks(currentConfig), currentSubject = currentLinks.find(link => link.id === task.id);
4035
+ return currentSubject && workflowHash({ config: currentConfig, links: this.queueLinks(currentLinks, currentSubject, queue) }) === workflowHash({ config, links: this.queueLinks(links, subject, queue) })
3266
4036
  ? undefined : "merge queue inputs changed during the merge check: nothing was run";
3267
4037
  },
3268
4038
  // Read synchronously at dispatch, after the last PR read: the task (state, receipt, privacy, dependencies, findings), the configuration and mode in force, and whether
@@ -3270,7 +4040,7 @@ export class Tasks {
3270
4040
  fresh: () => {
3271
4041
  const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3272
4042
  const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
3273
- return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
4043
+ return now && current ? { subject: current, state: now.state, config: cfg, mode: opts.dryRun ? "dry-run" : mergeModeFor(cfg, now.sdlc?.repo), allowed: this.mergeAuthority(by) && !this.d.sweepHeld?.() && !this.d.bus.isRecoveryHeld && !this.d.bus.storageError } : undefined;
3274
4044
  } });
3275
4045
  if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
3276
4046
  if(outcome.result?.state==="merged") {
@@ -3370,6 +4140,12 @@ export class Tasks {
3370
4140
  if (d.path && (!this.nameable(d.path) || matchesPolicy(this.d.cwd, d.path, this.d.piiPaths ?? [], true))) { const { path: _privatePath, ...metadata } = d; return metadata; }
3371
4141
  return d;
3372
4142
  });
4143
+ // #481 AC12: release interval, lead time from approval to release and hotfix rate, from the shipped releases' recorded history; unknown stays null (never 0).
4144
+ // Releases whose scope holds private work are left out, like the per-release report does.
4145
+ const rowsNow = this.d.board.list();
4146
+ const publicShipped = this.d.board.releases().filter(release => !rowsNow.some(task => task.release === release.id && this.isPii(task)));
4147
+ const metrics = publicShipped.some(release => release.state === "shipped") ? releaseMetrics(publicShipped, rowsNow.filter(task => !this.isPii(task))) : undefined;
4148
+ if (metrics) report.releaseMetrics = metrics;
3373
4149
  const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
3374
4150
  if (trains.length) {
3375
4151
  const planned = trains.map(release => {
@@ -3498,11 +4274,12 @@ export class Tasks {
3498
4274
  const accepted = task.sdlc?.acceptance;
3499
4275
  const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
3500
4276
  const integrity = config.mode !== "off" ? publicTestIntegrity(task.sdlc && evidence && !this.isPii(task) ? await readTestIntegrity(task.sdlc, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText) : undefined;
4277
+ const evalResult = task.sdlc && !this.isPii(task) ? await readEvalFor(config, evidence, task.sdlc.repo, read) : undefined;
3501
4278
  const current = valid();
3502
4279
  if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
3503
4280
  this.capturePlanRead(current, evidence);
3504
4281
  return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
3505
- config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity);
4282
+ config.mode !== "off" ? this.planReport(this.d.board.get(current.id)!, evidence) : undefined, integrity, evalResult);
3506
4283
  }
3507
4284
 
3508
4285
  /** #481 S1: only build work starts from an accepted spec. */
@@ -3529,6 +4306,7 @@ export class Tasks {
3529
4306
 
3530
4307
  /** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
3531
4308
  async specGate(by: PeerId, id: unknown): Promise<void> {
4309
+ requireLocalTaskPeer(by);
3532
4310
  const task = this.need(id, false, true);
3533
4311
  if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
3534
4312
  const accepted = task.sdlc?.acceptance;
@@ -3539,6 +4317,7 @@ export class Tasks {
3539
4317
 
3540
4318
  /** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
3541
4319
  async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
4320
+ requireLocalTaskPeer(by);
3542
4321
  const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3543
4322
  if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
3544
4323
  let next: ReturnType<typeof sdlcReceipt>;
@@ -3574,6 +4353,8 @@ export class Tasks {
3574
4353
  return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
3575
4354
  }
3576
4355
 
4356
+ /** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
4357
+ private readonly evalRecords = new Map<number, string>();
3577
4358
  private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
3578
4359
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3579
4360
  if (config.mode === "off") return;
@@ -3587,16 +4368,24 @@ export class Tasks {
3587
4368
  };
3588
4369
  const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
3589
4370
  const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
4371
+ const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
3590
4372
  const current = this.need(task.id, false, true);
3591
4373
  if (config.mode === "enforce" && (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state)) throw new PreEffectToolRefusal(`task #${task.id}: changed during lifecycle readback; retry on current work`);
3592
4374
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
3593
4375
  if (current.history.length !== task.history.length || current.owner !== task.owner || current.reviewer !== task.reviewer || current.state !== task.state || this.isPii(current) !== this.isPii(task)) return;
3594
4376
  this.capturePlanRead(current, evidence);
3595
4377
  const planReport = this.planReport(current, evidence);
3596
- const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
3597
- const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker) || blocker.startsWith("test integrity:")), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
4378
+ const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity, evalResult);
4379
+ // #481 S5: review entry keeps the eval gate's own blockers (its check, and in enforce its evidence gaps), like the premature-review rule does for the ordinary required checks.
4380
+ const evalNames = evalRequiredChecks(config, evidence).filter(name => !config.requiredChecks.includes(name));
4381
+ const evalBlocker = (blocker: string) => evalNames.some(name => blocker.startsWith(`required check ${name}:`)) || evalGapBlockers(status.evalGate).includes(blocker);
4382
+ const blockers = transition === "review" ? [...new Set([...status.blockers.filter(blocker => ["lifecycle stage definition unknown", "issue identity missing", "PR identity missing", "current PR evidence unknown", "PR is not open", "local gate on current head missing or failed"].includes(blocker) || blocker.startsWith("test integrity:") || evalBlocker(blocker)), ...(status.entry ?? []), ...(status.exit ?? [])])] : status.blockers;
3598
4383
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
3599
- const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity)])];
4384
+ // #481 S5: the recorded pass rate and suite version ride the approval note; a code-only PR, an unconfigured gate or an unreported value records nothing.
4385
+ const gate = status.evalGate;
4386
+ if (transition === "approval" && gate?.state === "passed" && gate.passRate !== undefined && gate.suite !== undefined && !this.isPii(task)) this.evalRecords.set(task.id, `Eval gate: ${gate.check} passed at ${evidence!.head.slice(0, 12)}, pass rate ${gate.passRate}, suite ${gate.suite}`);
4387
+ else this.evalRecords.delete(task.id);
4388
+ const advice = [...new Set([...blockers, ...planConformanceBlockers(planReport), ...testIntegrityBlockers(integrity), ...evalAdvice(status.evalGate)])];
3600
4389
  if (config.mode === "advise" && advice.length) this.d.notify(`task ${this.publicTitle(task)}: SDLC ${transition} advice: ${this.isPii(task) ? "private lifecycle evidence unknown" : advice.join("; ")}`);
3601
4390
  return evidence;
3602
4391
  }
@@ -3680,6 +4469,7 @@ export class Tasks {
3680
4469
  * announced like one found at assignment (issue #31).
3681
4470
  */
3682
4471
  accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
4472
+ requireLocalTaskPeer(by);
3683
4473
  const metadata = acceptRefs(refs);
3684
4474
  const task = this.need(id, false, true);
3685
4475
  this.mine(task, by, "owner", true);
@@ -3825,9 +4615,10 @@ export class Tasks {
3825
4615
  * Without `check` this reads the board only: no file is opened, so no row claims `changedSinceReview`. With `check` every file is measured now (one task at a time).
3826
4616
  * Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
3827
4617
  */
3828
- async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean } = {}): Promise<DeliverableRow[]> {
4618
+ async deliverableList(opts: { task?: number; approvedOnly?: boolean; check?: boolean; visible?: (task: Task) => boolean } = {}): Promise<DeliverableRow[]> {
3829
4619
  const rows: DeliverableRow[] = [];
3830
- const tasks = (opts.task !== undefined ? [this.d.board.get(opts.task)].filter((task): task is Task => !!task) : this.d.board.list()).sort((a, b) => b.id - a.id);
4620
+ // #933: `visible` drops a task before anything is read or measured (an agent's list holds only what hub_task_show shows it).
4621
+ const tasks = (opts.task !== undefined ? [this.d.board.get(opts.task)].filter((task): task is Task => !!task) : this.d.board.list()).filter(task => !opts.visible || opts.visible(task)).sort((a, b) => b.id - a.id);
3831
4622
  for (const task of tasks) {
3832
4623
  const delivered = latestDeliverables(task.history);
3833
4624
  if (!delivered) continue;
@@ -4015,6 +4806,7 @@ export class Tasks {
4015
4806
  * duplicate notice is the price of never losing one.
4016
4807
  */
4017
4808
  replayHeld(peer: PeerId): void {
4809
+ requireLocalTaskPeer(peer);
4018
4810
  const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
4019
4811
  for (const t of this.d.board.list()) {
4020
4812
  if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
@@ -4072,6 +4864,19 @@ export class Tasks {
4072
4864
  return [head, "The done counts once the files did not change between two calls and the others have stopped.", ...lines, facts?.text ?? ""].filter(Boolean).join("\n");
4073
4865
  }
4074
4866
 
4867
+ /**
4868
+ * #901: which check key produced this result, and how many citation keys the example citation check said were unresolved. The key is the hub's own selection (`Deps.checkKey`, the lookup
4869
+ * `Deps.check` makes), never read from output; without that dependency it is not recorded. The count is read only from the example check's own last line (`citations: N declared keys, M missing`, scripts/check-citations.ts);
4870
+ * a tail without that exact line, or with a number past the bound, records no count: unknown, never zero.
4871
+ */
4872
+ private checkProvenance(task: Task, tail: string): Pick<HistoryEntry, "checkKey" | "missingKeys"> {
4873
+ let key: string | undefined;
4874
+ try { key = this.d.checkKey?.(task.class, task); } catch { key = undefined; }
4875
+ const lines = tail.split("\n").map(line => line.trim()).filter(Boolean);
4876
+ const match = /^citations: (\d{1,6}) declared keys, (\d{1,6}) missing$/.exec(lines.at(-1) ?? "");
4877
+ return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
4878
+ }
4879
+
4075
4880
  private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
4076
4881
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
4077
4882
  this.checking.delete(id);
@@ -4095,12 +4900,12 @@ export class Tasks {
4095
4900
  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);
4096
4901
  return;
4097
4902
  }
4098
- this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
4903
+ this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
4099
4904
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
4100
4905
  await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
4101
4906
  return;
4102
4907
  }
4103
- this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
4908
+ this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
4104
4909
  this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
4105
4910
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
4106
4911
  this.contradict(task);
@@ -4137,8 +4942,10 @@ export class Tasks {
4137
4942
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
4138
4943
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
4139
4944
  const capacity = this.capacityBefore(task);
4140
- const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { 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, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
4945
+ const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection ? { 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, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
4946
+ this.queueReviewWritten(next);
4141
4947
  this.writePlannerNotices(next, capacity, next.state === "approved");
4948
+ this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
4142
4949
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
4143
4950
  const memorySymbols = this.completionMemorySymbols(next);
4144
4951
  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);
@@ -4151,6 +4958,7 @@ export class Tasks {
4151
4958
  }
4152
4959
  else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it in the ahub console (Tasks, select #${next.id}, press r) or with ahub review ${next.id} approved|changes_requested [note]`);
4153
4960
  else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer, "", admission));
4961
+ if (reviewer && !(admission?.ready && admission.head === this.reviewHead(next))) this.watchCi(next);
4154
4962
  return next;
4155
4963
  }
4156
4964
 
@@ -4255,7 +5063,7 @@ export class Tasks {
4255
5063
  let quietDrift = false;
4256
5064
  const guard = () => {
4257
5065
  if (actor !== USER && actor !== task.owner) {
4258
- const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
5066
+ const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
4259
5067
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
4260
5068
  }
4261
5069
  // #694: the board copy seen through the same staged identity link the caller's task carries.
@@ -4316,6 +5124,7 @@ export class Tasks {
4316
5124
  if (mode === "enforce" && !admission?.ready) {
4317
5125
  // Background roster/quota role changes are retained, but never invent a remotely admitted review request.
4318
5126
  this.releaseNotice(`task ${this.publicTitle(task)}: premature review held; retry the reviewer assignment after current CI and bot replies are verified`);
5127
+ this.holdReview(task, reviewer);
4319
5128
  return false;
4320
5129
  }
4321
5130
 
@@ -4395,6 +5204,67 @@ export class Tasks {
4395
5204
  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);
4396
5205
  }
4397
5206
 
5207
+ /** #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. */
5208
+ private holdReview(task: Task, reviewer: PeerId): void {
5209
+ try {
5210
+ const head = this.reviewHead(task);
5211
+ if (this.isPii(task) || !head || reviewer === USER) return;
5212
+ this.heldReviews.set(task.id, { head, reviewer, revision: this.reviewRequestRevision(task) });
5213
+ this.watchCi(task);
5214
+ } catch { /* optional bookkeeping never fails a review path */ }
5215
+ }
5216
+
5217
+ /** #785 P1b AC3: start the one watcher for this task's exact head. Not for private work, an unlinked PR or a lifecycle that is off; never throws. */
5218
+ private watchCi(task: Task): void {
5219
+ try {
5220
+ const head = this.reviewHead(task), repo = task.sdlc?.repo, pr = task.sdlc?.pr;
5221
+ if (!this.d.sdlc || this.d.sdlc.config().mode === "off" || this.isPii(task) || task.state !== "in_review" || !head || !/^[0-9a-f]{40}$/.test(head) || !repo || !pr) return;
5222
+ this.ciWatchers.start({ id: task.id, head, repo, pr });
5223
+ } catch { /* a watcher that cannot start leaves the review path as it was */ }
5224
+ }
5225
+
5226
+ /** The watcher's own check before every read and every notice: the task must still be in review at this exact head and PR. */
5227
+ private ciWatchCurrent(watch: CiWatch): boolean {
5228
+ const task = this.d.board.get(watch.id);
5229
+ return !!task && task.state === "in_review" && !this.isPii(task) && this.reviewHead(task) === watch.head && task.sdlc?.repo === watch.repo && task.sdlc?.pr === watch.pr
5230
+ && (this.d.sdlc?.config().mode ?? "off") !== "off";
5231
+ }
5232
+
5233
+ /**
5234
+ * #785 P1b: the result of the exact head's CI, told once to the owner and the reviewer (one ids-only task envelope each, `status` weight: `fyi` is recorded but never delivered to the peer),
5235
+ * then a review held as premature is offered once more after a fresh readiness read. A deadline tells the console only.
5236
+ */
5237
+ private async ciCompleted(watch: CiWatch, outcome: CiOutcome): Promise<void> {
5238
+ const task = this.d.board.get(watch.id);
5239
+ if (!task || !this.ciWatchCurrent(watch)) return;
5240
+ const label = `#${task.id} head ${watch.head.slice(0, 12)}`;
5241
+ if (outcome === "deadline") { this.releaseNotice(`task ${label}: CI did not finish within the watch deadline; no further reads`); return; }
5242
+ const line = `ci_completed: ${label} ${outcome}`;
5243
+ for (const peer of new Set([task.owner, task.reviewer])) {
5244
+ if (!peer || peer === HUB) continue;
5245
+ if (peer === USER) { this.releaseNotice(`task ${label}: CI ${outcome} at its exact head`); continue; }
5246
+ this.releaseNotice(`task ${label}: could not tell ${peer} the CI result`, () => this.d.bus.publish(newEnvelope(HUB, `${line}\nRead hub_task_show for the task before acting.`, { to: [peer], kind: "task", priority: "status", refs: { task: String(task.id), commit: watch.head } })));
5247
+ }
5248
+ if (outcome === "failed") return;
5249
+ await this.reofferHeldReview(task, watch);
5250
+ }
5251
+
5252
+ /** The held review of this exact head and request, once: only while it is still the same reviewer, head and revision, and a fresh admission says it is ready. */
5253
+ private async reofferHeldReview(task: Task, watch: CiWatch): Promise<void> {
5254
+ const held = this.heldReviews.get(task.id);
5255
+ if (!held || held.head !== watch.head || task.reviewer !== held.reviewer || this.reviewRequestRevision(task) !== held.revision) return;
5256
+ this.heldReviews.delete(task.id); // once: a later hold needs a new head or a new request
5257
+ try {
5258
+ const admission = await this.reviewRequestAdmission(task, undefined, false, task.owner ?? USER);
5259
+ const current = this.d.board.get(task.id);
5260
+ if (!admission?.ready || !current || !this.ciWatchCurrent(watch) || current.reviewer !== held.reviewer || this.reviewRequestRevision(current) !== held.revision) return;
5261
+ this.releaseNotice(`task ${this.publicTitle(current)}: could not re-offer the held review to ${held.reviewer}`, () => this.sendReview(current, held.reviewer, "(Held earlier because exact-head CI was not green; it is green now.)", admission));
5262
+ } catch { /* still not ready: the hold notice already named why; a reviewer reassignment retries it */ }
5263
+ }
5264
+
5265
+ /** Daemon shutdown: no watcher outlives the hub. */
5266
+ stopCiWatchers(): void { this.ciWatchers.stopAll(); this.heldReviews.clear(); }
5267
+
4398
5268
  private reviewRequestRevision(task: Task): number {
4399
5269
  // Role changes fence older requests; bookkeeping appended after the request does not.
4400
5270
  return task.history.findLastIndex(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event) || OWNERSHIP_EVENTS.has(entry.event)) + 1;
@@ -4402,6 +5272,7 @@ export class Tasks {
4402
5272
 
4403
5273
  /** Host-only admission for a queued review after asynchronous native preparation. */
4404
5274
  reviewBindingCurrent(by: PeerId, id: number, binding: { head?: string; flowRevision?: number }): boolean {
5275
+ requireLocalTaskPeer(by);
4405
5276
  const task = this.d.board.get(id);
4406
5277
  return !!task && task.state === "in_review" && task.reviewer === by && !this.isPii(task)
4407
5278
  && this.reviewRequestRevision(task) === binding.flowRevision && this.reviewHead(task) === binding.head;
@@ -4409,6 +5280,7 @@ export class Tasks {
4409
5280
 
4410
5281
  /** Host-only failure report, bound to the exact review request and its still-current child generation. */
4411
5282
  async retryReviewer(id: number, failedPeer: PeerId, failure: { head?: string; flowRevision?: number; cause: "start_failed" | "quota" | "no_verdict" }, current: () => boolean, onReoffered?: () => void): Promise<Task> {
5283
+ requireLocalTaskPeer(failedPeer);
4412
5284
  if (!failure || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || !/^[a-z][a-z0-9-]{0,63}$/.test(failedPeer) || !Number.isSafeInteger(failure.flowRevision) || failure.flowRevision! < 1 || failure.head !== undefined && (typeof failure.head !== "string" || text(failure.head) !== failure.head || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head))) throw new PreEffectToolRefusal("invalid reviewer failure binding");
4413
5285
  const valid = () => {
4414
5286
  const task = this.need(id, false, true);
@@ -4427,7 +5299,8 @@ export class Tasks {
4427
5299
  valid();
4428
5300
  const note = manual ? "named reviewer recorded no verdict; the person must review"
4429
5301
  : selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
4430
- const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
5302
+ const next = this.d.board.update(task.id, HUB, "reviewer unavailable", { reviewer }, note, { ...this.reviewMetadata(selection, task), ...(reviewer === USER ? { reviewBlocked: true } : {}), reviewerFailure: { peer: failedPeer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } }, task.history.length);
5303
+ this.queueReviewWritten(next);
4431
5304
  this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
4432
5305
  let offered = false;
4433
5306
  if (reviewer === USER) {
@@ -4507,6 +5380,7 @@ export class Tasks {
4507
5380
  }
4508
5381
 
4509
5382
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
5383
+ const requestCheck = this.requestCheck(by);
4510
5384
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
4511
5385
  let findingGuard: ((task: Task) => void) | undefined;
4512
5386
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
@@ -4514,6 +5388,7 @@ export class Tasks {
4514
5388
  let covered: { commit: string | undefined; receipt: string } | undefined;
4515
5389
  let conformanceIdentity: string | undefined;
4516
5390
  const valid = (): Task => {
5391
+ requestCheck();
4517
5392
  const refused = authority?.();
4518
5393
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
4519
5394
  const t = this.need(id, false, true);
@@ -4619,7 +5494,12 @@ export class Tasks {
4619
5494
  }
4620
5495
  }
4621
5496
  }
4622
- if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") { await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid(); }
5497
+ if (verdict === "approved" && this.d.sdlc && this.d.sdlc.config().mode !== "off") {
5498
+ this.evalRecords.delete(task.id);
5499
+ await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
5500
+ const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
5501
+ if (record) note = `${note ?? ""}\n${record}`.trim();
5502
+ }
4623
5503
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
4624
5504
  if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4625
5505
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
@@ -4643,6 +5523,7 @@ export class Tasks {
4643
5523
  const flag = { ...this.planObservation(task), withheld: withheld || undefined, ...(report ? { reviewFindings: report } : {}), ...(unmetCriteria.length ? { unmetCriteria } : {}), ...(boundDeliverables ? { deliverables: boundDeliverables } : {}), ...(reviewContext ? { reviewContext } : {}), ...(codeScope ? { reviewScope: "code" as const } : {}), ...(evidenceOnly ? { evidenceOnly: true as const } : {}), ...(reviewerFamily ? { reviewerFamily } : {}), ...(reviewerReason ? { reviewerReason } : {}) };
4644
5524
  if (verdict === "approved") {
4645
5525
  const capacity = this.capacityBefore(task);
5526
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4646
5527
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0, ...(reviewedHead && task.sdlc ? { sdlc: { ...task.sdlc, reviewedHead, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), "merge") } } : {}) }, note, flag);
4647
5528
  this.writePlannerNotices(next, capacity, next.state === "approved");
4648
5529
  if (next.owner) {
@@ -4661,11 +5542,14 @@ export class Tasks {
4661
5542
  this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
4662
5543
  // A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
4663
5544
  this.releaseNotice(`task #${next.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(next, by, parsed, withheld));
5545
+ this.releaseNotice(`task #${next.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(next, by, withheld));
4664
5546
  return next;
4665
5547
  }
5548
+ this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4666
5549
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
4667
5550
  void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
4668
5551
  if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-finding proposal unavailable`, () => this.proposeRecurringFindings(rejected, by, parsed, withheld));
5552
+ if (!evidenceOnly) this.releaseNotice(`task #${rejected.id}: review saved but recurring-criterion proposal unavailable`, () => this.proposeCriterionCandidates(rejected, by, withheld));
4669
5553
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
4670
5554
  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 */ }
4671
5555
  }
@@ -4705,7 +5589,7 @@ export class Tasks {
4705
5589
  * the review or the task reaches it; a PII task's notice is private. With no conductor the person sees one console notice instead.
4706
5590
  */
4707
5591
  private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
4708
- const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
5592
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor).filter(actor => actor !== reviewer);
4709
5593
  const text = evidenceOnlyNotice(task.id, reviewer);
4710
5594
  if (!conductors.length) { this.releaseNotice(text); return; }
4711
5595
  this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
@@ -4715,6 +5599,7 @@ export class Tasks {
4715
5599
 
4716
5600
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
4717
5601
  async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
5602
+ requireLocalTaskPeer(by);
4718
5603
  let task = this.need(id, true, preEffect);
4719
5604
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
4720
5605
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
@@ -4776,8 +5661,33 @@ export class Tasks {
4776
5661
  });
4777
5662
  }
4778
5663
 
5664
+ /** Preserve explicit intent across a policy reload; recheck conductor authority after every await. */
5665
+ private explicitQueueAssignment(by: PeerId, role: Exclude<TaskAssignmentRole, "planner">, preEffect: boolean): { role: "owner" | "reviewer"; current: () => void } | undefined {
5666
+ if (by === USER) return { role, current: () => {} };
5667
+ const current = () => {
5668
+ const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
5669
+ return holders.length === 1 && holders[0] === by && this.d.bus.isRostered(by);
5670
+ };
5671
+ if (!current()) return undefined;
5672
+ return { role, current: () => { if (!current()) throw new (preEffect ? PreEffectToolRefusal : Error)("only the current configured conductor may override assignment admission"); } };
5673
+ }
5674
+
5675
+ private configuredManualConductor(by: PeerId): boolean {
5676
+ if (by === USER || by === HUB || isRemotePeerId(by)) return false;
5677
+ const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([peer]) => peer);
5678
+ return holders.length === 1 && holders[0] === by;
5679
+ }
5680
+ private conductorManualCheck(by: PeerId, preEffect: boolean): () => void {
5681
+ return () => {
5682
+ if (!this.configuredManualConductor(by) || !this.d.bus.isRostered(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the current configured conductor may unassign a task role");
5683
+ };
5684
+ }
5685
+
4779
5686
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
4780
5687
  async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
5688
+ requireLocalTaskPeer(by); requireLocalTaskPeer(peer);
5689
+ const conductorManual = this.configuredManualConductor(by), manualCurrent = conductorManual ? this.conductorManualCheck(by, preEffect) : undefined;
5690
+ manualCurrent?.();
4781
5691
  if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
4782
5692
  if (role === "planner") {
4783
5693
  if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
@@ -4791,9 +5701,11 @@ export class Tasks {
4791
5701
  return next;
4792
5702
  }
4793
5703
  if (role === "reviewer" && by !== USER) {
4794
- const conductors = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
5704
+ const conductors = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
4795
5705
  if (conductors.length !== 1 || conductors[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
4796
5706
  }
5707
+ const queueAssignment = this.explicitQueueAssignment(by, role, preEffect);
5708
+ queueAssignment?.current();
4797
5709
  const task = this.need(id, role === "owner", preEffect);
4798
5710
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
4799
5711
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
@@ -4801,20 +5713,23 @@ export class Tasks {
4801
5713
  if (role === "reviewer") {
4802
5714
  if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer can no longer change`);
4803
5715
  this.ready(task, preEffect);
4804
- const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task) });
5716
+ const a = assign(task, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class, task), ...(queueAssignment ? { queueOverride: queueAssignment.role } : {}) });
4805
5717
  if (a.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot name ${peer} as reviewer: ${a.trace.filter(line => line.includes("reviewer") || line.includes("pii")).join("; ")}`);
4806
5718
  const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
4807
5719
  // Every runtime authority check precedes the final write, including revocations during the metadata await.
5720
+ manualCurrent?.();
5721
+ queueAssignment?.current();
4808
5722
  const current = this.need(task.id, false, preEffect);
4809
5723
  if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
4810
5724
  if (by !== USER) {
4811
- const holders = Object.entries(this.d.roles ?? {}).filter(([, roles]) => Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
5725
+ const holders = Object.entries(this.d.roles ?? {}).filter(([peer, roles]) => !isRemotePeerId(peer) && Array.isArray(roles) && roles.includes("conductor")).map(([actor]) => actor);
4812
5726
  if (holders.length !== 1 || holders[0] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user or the configured conductor may name a reviewer");
4813
5727
  }
4814
5728
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
4815
5729
  this.requireCapabilities(current, peer, role, preEffect);
4816
- const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current) });
5730
+ const eligible = assign(current, this.states(), this.d.routing(), { candidates: [], namedReviewer: peer, ...(current.owner ? { notReviewer: current.owner } : {}), ...this.weights(current.class, current), ...(queueAssignment ? { queueOverride: queueAssignment.role } : {}) });
4817
5731
  if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
5732
+ queueAssignment?.current();
4818
5733
  const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
4819
5734
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
4820
5735
  this.withdrawRoleRequest(task, next, "reviewer");
@@ -4823,15 +5738,23 @@ export class Tasks {
4823
5738
  return next;
4824
5739
  }
4825
5740
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
4826
- if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision, refuseNamed: preEffect ? "pre" : "plain" });
4827
- const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" }, expectedFlowRevision);
5741
+ if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision, refuseNamed: preEffect ? "pre" : "plain", ...(conductorManual ? { conductorManual: true as const } : {}), ...(queueAssignment ? { queueOverride: "owner" as const } : {}), current: () => { manualCurrent?.(); queueAssignment?.current(); } });
5742
+ manualCurrent?.();
5743
+ queueAssignment?.current();
5744
+ const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual", ...(conductorManual ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
4828
5745
  this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
4829
5746
  return next;
4830
5747
  }
4831
5748
 
4832
- /** Person-only removal without routing a replacement. The history marker suppresses later automatic owner offers. */
5749
+ /** Explicit removal without routing a replacement; host history preserves manual intent across role changes. */
4833
5750
  async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
4834
- if (by !== USER) throw new (preEffect ? PreEffectToolRefusal : Error)("only the console user may unassign a task role");
5751
+ const authority = by === USER ? undefined : this.conductorManualCheck(by, preEffect);
5752
+ const current = authority && (() => {
5753
+ authority();
5754
+ if (this.d.privateTurn?.(by)) throw new (preEffect ? PreEffectToolRefusal : Error)("conductor task mutations are unavailable during a private turn");
5755
+ if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) throw new (preEffect ? PreEffectToolRefusal : Error)("recovery or shutdown is holding task writes");
5756
+ });
5757
+ current?.();
4835
5758
  if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
4836
5759
  const task = this.need(id, false, preEffect);
4837
5760
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
@@ -4839,7 +5762,8 @@ export class Tasks {
4839
5762
  if (role === "owner") {
4840
5763
  if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
4841
5764
  if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
4842
- const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `person unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual" }, expectedFlowRevision);
5765
+ current?.();
5766
+ const next = this.d.board.update(task.id, by, "unassigned", { owner: null, reserved: null, ...(task.state === "proposed" ? {} : { state: "proposed" as const }) }, `${by === USER ? "person" : "conductor"} unassigned ${[task.owner, task.reserved].filter(Boolean).join(" and ")}`, { reason: "manual", ...(current ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
4843
5767
  this.cohorts.leave(task.id);
4844
5768
  this.withdrawRoleRequest(task, next, "owner");
4845
5769
  return next;
@@ -4847,7 +5771,8 @@ export class Tasks {
4847
5771
  if (!OPEN.includes(task.state) && task.state !== "in_review") throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its reviewer cannot be unassigned`);
4848
5772
  if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
4849
5773
  if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
4850
- const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `person unassigned reviewer ${task.reviewer}`, { reason: "manual" }, expectedFlowRevision);
5774
+ current?.();
5775
+ const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: null }, `${by === USER ? "person" : "conductor"} unassigned reviewer ${task.reviewer}`, { reason: "manual", ...(current ? { conductorManual: true as const } : {}) }, expectedFlowRevision);
4851
5776
  this.withdrawRoleRequest(task, next, "reviewer");
4852
5777
  return next;
4853
5778
  }
@@ -4857,11 +5782,13 @@ export class Tasks {
4857
5782
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
4858
5783
  */
4859
5784
  async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
5785
+ requireLocalTaskPeer(peer);
4860
5786
  return this.reassignWork(peer, context, urgentOnly, false);
4861
5787
  }
4862
5788
 
4863
5789
  /** Read-only preview using the same assignment constraints as the roster relay. */
4864
5790
  rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
5791
+ requireLocalTaskPeer(peer);
4865
5792
  const routing = this.d.routing();
4866
5793
  return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
4867
5794
  const owner = task.owner === peer;
@@ -4875,6 +5802,7 @@ export class Tasks {
4875
5802
 
4876
5803
  /** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
4877
5804
  async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
5805
+ requireLocalTaskPeer(peer);
4878
5806
  return this.reassignWork(peer, undefined, false, true, current);
4879
5807
  }
4880
5808
 
@@ -4923,7 +5851,8 @@ export class Tasks {
4923
5851
  continue;
4924
5852
  }
4925
5853
  current?.();
4926
- const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a));
5854
+ const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, this.assignmentNote(why, a), this.reviewMetadata(a, task));
5855
+ this.queueReviewWritten(next);
4927
5856
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
4928
5857
  current?.();
4929
5858
  if (next.state === "in_review" && reviewer && reviewer !== USER) {
@@ -4958,11 +5887,13 @@ export class Tasks {
4958
5887
 
4959
5888
  /** Candidate only: the person remains the sole acceptance authority. */
4960
5889
  async correct(by: PeerId, input: unknown): Promise<string> {
5890
+ const requestCheck = this.requestCheck(by);
4961
5891
  const args = correctionInput(input);
4962
5892
  const st = this.d.store;
4963
5893
  if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
4964
5894
  const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
4965
5895
  const valid = () => {
5896
+ requestCheck();
4966
5897
  this.d.memoryCheck?.();
4967
5898
  if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
4968
5899
  if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
@@ -5037,6 +5968,7 @@ export class Tasks {
5037
5968
  }
5038
5969
  /** A person links a board task of this project to a long-term task (`ahub goal link`). */
5039
5970
  linkGoal(by: PeerId, id: unknown, ref: unknown): string {
5971
+ requireLocalTaskPeer(by);
5040
5972
  const task = this.d.board.get(Number(id));
5041
5973
  if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
5042
5974
  const goal = this.goalFor(by, ref);