@staix/agent-hub 0.20.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 (175) hide show
  1. package/CHANGELOG.md +59 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  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 +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. 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";
@@ -52,6 +60,10 @@ import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type
52
60
  import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
53
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";
54
62
 
63
+ function requireLocalTaskPeer(peer: unknown): void {
64
+ if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
65
+ }
66
+
55
67
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
56
68
 
57
69
  export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
@@ -68,6 +80,10 @@ export interface TasksDeps {
68
80
  board: Board;
69
81
  bus: Bus;
70
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;
71
87
  /** Startup-bound project declarations; routing never discovers instances from peer names. */
72
88
  instances?: () => InstancesConfig;
73
89
  /** Host proof of each live extra runtime's actual isolated execution root; config paths alone are not proof. */
@@ -120,6 +136,11 @@ export interface TasksDeps {
120
136
  reviewAuthority?: (by: PeerId, task: number) => (() => string | undefined) | undefined;
121
137
  /** Host-captured current-child authority, rechecked before an admitted async tool first commits effects. */
122
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;
123
144
  memory?: MemoryClient;
124
145
  /** Captured native profile fence at our memory operation boundaries; no board mutation. */
125
146
  memoryCheck?: () => void;
@@ -129,6 +150,8 @@ export interface TasksDeps {
129
150
  notify: (line: string) => void;
130
151
  /** The completion check configured for a class, if any (local config only; issue #7). */
131
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;
132
155
  /** Runs one check; the hub runs them one at a time. */
133
156
  runCheck?: (command: string) => Promise<{ code: number | null; timedOut: boolean; interrupted?: boolean; tail: string }>;
134
157
  /** Hands a saved note to every other peer; it rides on their next delivery. */
@@ -159,6 +182,8 @@ export interface TasksDeps {
159
182
  roles?: Record<string, string[]>;
160
183
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
161
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 };
162
187
  /** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
163
188
  piiScreen?: (text: string) => Promise<PiiVerdict>;
164
189
  /** Each screen verdict, for the record: item, label, source and category, never the text. */
@@ -191,27 +216,43 @@ export interface TasksDeps {
191
216
  /** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
192
217
  recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
193
218
  sweep?: TaskSweepConfig;
194
- /** 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). */
195
222
  sweepHeld?: () => boolean;
196
223
  /** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
197
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;
198
234
  }
199
235
 
200
236
  /** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
201
237
  export const ESCALATE_AFTER = 3;
202
238
  // Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
203
239
  // reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
204
- // ponytail: fixed constants; make them config when someone needs to tune them.
205
- const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
206
- const DEMOTE_AT = 1.5;
207
- /** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
208
- 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;
209
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 }
210
250
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
211
251
  const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep", "memory reference started"]);
212
252
  QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits for its reserved owner's WIP slot.
213
253
  QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
214
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".
215
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.
216
257
  /** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
217
258
  const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
@@ -312,11 +353,11 @@ const planText = (plan: TaskPlan = {}) => ALL_PLAN_KEYS.filter((k) => plan[k]?.l
312
353
  /** What the PII screen reads of a task (#198): the text the patterns are checked against. */
313
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");
314
355
  /** Re-screens of one task whose verdict was unknown, per hub run (#198). */
315
- const RESCREEN_MAX = 10;
356
+ export const RESCREEN_MAX = 10;
316
357
  /** #758: the planner "board changed" notice. Its body starts with this, so an unsettled copy is found (and replaced) by the prefix. */
317
358
  const GROOM_NOTICE_PREFIX = "The board changed";
318
359
  /** #758: a board change that needs no plan review is recorded at most once per planner in this window. */
319
- const GROOM_FYI_WINDOW_MS = 10 * 60_000;
360
+ export const GROOM_FYI_WINDOW_MS = 10 * 60_000;
320
361
 
321
362
  /**
322
363
  * The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
@@ -424,15 +465,16 @@ export class Tasks {
424
465
  }
425
466
 
426
467
  /** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
427
- private screenOn = (): boolean => {
468
+ screenOn = (): boolean => {
428
469
  const r = this.d.routing();
429
470
  return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
430
471
  };
431
472
 
432
473
  /** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
433
- private requestCheck(by: PeerId): () => void {
474
+ private requestCheck(by: PeerId, fence?: () => string | undefined): () => void {
475
+ requireLocalTaskPeer(by);
434
476
  const authority = this.d.requestAuthority?.(by);
435
- const check = () => { const reason = authority?.(); if (reason) throw new PreEffectToolRefusal(reason); };
477
+ const check = () => { const reason = authority?.() ?? fence?.(); if (reason) throw new PreEffectToolRefusal(reason); };
436
478
  check(); return check;
437
479
  }
438
480
 
@@ -551,6 +593,7 @@ export class Tasks {
551
593
 
552
594
  /** Captured at native turn start, not supplied by a model. */
553
595
  ownerTurnTasks(peer: PeerId): { id: number; acceptance: number }[] {
596
+ requireLocalTaskPeer(peer);
554
597
  return this.d.board.list("in_progress").flatMap(task => {
555
598
  const accepted = task.owner === peer && ["implement", "bulk_edit"].includes(task.class) ? this.ownerAcceptance(task) : undefined;
556
599
  return accepted ? [{ id: task.id, acceptance: accepted.index }] : [];
@@ -558,6 +601,7 @@ export class Tasks {
558
601
  }
559
602
 
560
603
  recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
604
+ requireLocalTaskPeer(peer);
561
605
  this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
562
606
  const current = new Map(this.ownerTurnTasks(peer).map(binding => [binding.id, binding.acceptance]));
563
607
  for (const binding of bindings) {
@@ -662,6 +706,33 @@ export class Tasks {
662
706
  }
663
707
  return new Set(rows.map(row => find(row.id))).size;
664
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
+ }
665
736
  private proposeRecurringFindings(task: Task, by: PeerId, items: readonly ReviewFinding[], withheld: boolean): void {
666
737
  if (withheld || this.isPii(task) || this.d.privateTurn?.(by) || !items.length || !this.findingTextAllowed(task, items)) return;
667
738
  const rows = this.d.board.list();
@@ -698,7 +769,14 @@ export class Tasks {
698
769
 
699
770
  publicView(task: Task, fullHistory = false): Record<string, unknown> {
700
771
  const { title, detail, history, external, cancelled, codeSymbols, ...taskRest } = task;
701
- 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 } : {}) };
702
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]" };
703
781
  if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
704
782
  const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
@@ -723,6 +801,7 @@ export class Tasks {
723
801
  ...(h.unmetCriteria?.length ? { unmetCriteria: h.unmetCriteria } : {}),
724
802
  ...(h.deliverables?.length ? { deliverables: publicDeliverables(h.deliverables, this.nameable) } : {}),
725
803
  ...(h.reviewBlocked ? { reviewBlocked: true } : {}),
804
+ ...(h.queueReviewHeld ? { queueReviewHeld: true } : {}),
726
805
  ...(h.reviewerFailure ? { reviewerFailure: { peer: screenHistoryText(h.reviewerFailure.peer, "reviewer"), cause: h.reviewerFailure.cause, ...(h.reviewerFailure.head ? { head: screenHistoryText(h.reviewerFailure.head, "review head") } : {}) } } : {}),
727
806
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
728
807
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
@@ -757,14 +836,15 @@ export class Tasks {
757
836
  /** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
758
837
  demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
759
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;
760
840
  for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
761
- const w = 0.5 ** ((now - o.at) / DEMOTION_HALF_LIFE_MS);
841
+ const w = 0.5 ** ((now - o.at) / halfLifeMs);
762
842
  const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
763
843
  if (o.ok) s.good += w;
764
844
  else s.bad += w;
765
845
  sums.set(o.peer, s);
766
846
  }
767
- 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]));
768
848
  }
769
849
 
770
850
  /** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
@@ -801,20 +881,57 @@ export class Tasks {
801
881
  }
802
882
 
803
883
  /** Workload is board/queue data only: wip() never calls health() or weights(). */
804
- private instanceRouting(): InstanceRoutingOptions {
884
+ private instanceRouting(ownerIsolation?: Readonly<Record<string, boolean>>): InstanceRoutingOptions {
805
885
  const instances = this.d.instances?.();
806
886
  if (!instances || !Object.keys(instances).length) return { adapterCatalog: this.d.adapterCatalog };
807
887
  const peers = [...new Set([...(this.d.adapterCatalog?.definitions ?? ADAPTERS).map(adapter => adapter.kind), ...Object.keys(instances), ...this.d.bus.peers.keys()])];
808
- 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 } : {}) };
809
926
  }
810
927
 
811
928
  /** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
812
- private health() {
929
+ private health(ownerIsolation?: Readonly<Record<string, boolean>>) {
813
930
  const failing = this.d.failing?.();
814
931
  const held = this.d.held?.();
815
- const workload = this.instanceRouting();
932
+ const workload = this.instanceRouting(ownerIsolation);
816
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);
817
- 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 } : {}) };
818
935
  }
819
936
 
820
937
  private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
@@ -834,21 +951,30 @@ export class Tasks {
834
951
  private poolReview(task: Task, refs?: TaskRefs): Assignment | undefined {
835
952
  if (this.manuallyNamedReviewer(task) || this.isPii(task) || task.class === "review") return undefined;
836
953
  const draft = { ...task, refs: { ...task.refs, ...cleanRefs(refs) } };
837
- const selection = assign(draft, this.states(), this.d.routing(), { ...this.weights(task.class, draft), candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
838
- 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;
839
961
  }
840
962
 
841
- private reviewMetadata(assignment?: Assignment): ReviewerHistory {
963
+ private reviewMetadata(assignment?: Assignment, task?: Task): ReviewerHistory {
842
964
  const choice = assignment?.reviewSelection;
843
965
  const family = choice && assignment?.plannerFallback && assignment.reviewer ? (this.d.adapterCatalog?.familyOf ?? familyOf)(assignment.reviewer, this.d.instances?.()) : choice?.family;
844
966
  const reason = choice && assignment?.plannerFallback
845
967
  ? assignment.sameFamilyFallback ? "same-family fallback" as const : "unavailable skip" as const
846
968
  : choice?.reason;
847
- 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 } : {}) } : {}) };
848
973
  }
849
974
 
850
975
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
851
976
  private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
977
+ requireLocalTaskPeer(peer);
852
978
  const instances = this.d.instances?.(), adapter = (this.d.adapterCatalog?.definitionOf ?? definitionOf)(peer, instances);
853
979
  const declaredExtra = !!adapter && adapter.kind !== peer;
854
980
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
@@ -865,13 +991,14 @@ export class Tasks {
865
991
  * Selection stays in assign(); task bindings only keep that selection while the holder remains eligible.
866
992
  */
867
993
  private plannerHolders(): PeerId[] {
868
- 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();
869
995
  }
870
996
 
871
- private weights(cls: TaskClass, task?: Task) {
997
+ private weights(cls: TaskClass, task?: Task, projectedHealth?: ReturnType<Tasks["health"]>) {
872
998
  const now = Date.now();
873
999
  const quota = this.d.quota?.();
874
- 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 } } : {}) };
875
1002
  }
876
1003
 
877
1004
  /**
@@ -929,6 +1056,8 @@ export class Tasks {
929
1056
  peers,
930
1057
  observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
931
1058
  units: [unit, unit],
1059
+ ...(this.d.splitMinObservations ? { minObservations: this.d.splitMinObservations() } : {}),
1060
+ ...(this.d.splitMaxFailureShare ? { maxFailureShare: this.d.splitMaxFailureShare() } : {}),
932
1061
  profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
933
1062
  backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
934
1063
  // Busy is taking the task in question only in the turn that task started (an owner goes busy as its task is
@@ -946,11 +1075,12 @@ export class Tasks {
946
1075
  private contradict(failed: Task): void {
947
1076
  if (this.isPii(failed)) return; // its places are left out everywhere else too
948
1077
  const now = Date.now();
1078
+ const contradictionDays = this.d.contradictionWindowDays?.(), contradictionWindowMs = contradictionDays === undefined ? CONTRADICTION_WINDOW_MS : contradictionDays * 86_400_000;
949
1079
  const mine = this.places(failed);
950
1080
  for (const t of this.d.board.list("approved")) {
951
1081
  if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
952
1082
  const approval = [...t.history].reverse().find((h) => h.event === "approved");
953
- if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
1083
+ if (!approval || now - approval.at > contradictionWindowMs || approval.by === USER) continue;
954
1084
  const theirs = this.places(t);
955
1085
  // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
956
1086
  // were stored as written, so both sides are compared in one spelling.
@@ -968,12 +1098,13 @@ export class Tasks {
968
1098
 
969
1099
  /** A removed/off-roster binding falls back to legacy holders; a busy, paused or offline holder keeps authority. */
970
1100
  taskPlanner(task: Pick<Task, "planner">): PeerId[] {
971
- 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();
972
1102
  return task.planner && holders.includes(task.planner) && this.d.bus.isRostered(task.planner) ? [task.planner] : holders;
973
1103
  }
974
1104
 
975
1105
  /** Current planner rulings amend review context, never person-only plan-departure acceptance. */
976
1106
  plannerRulings(task: Task, recipient: PeerId): { historyIndex: number; by: PeerId; at: number; note: string }[] {
1107
+ requireLocalTaskPeer(recipient);
977
1108
  if (this.isPii(task)) return [];
978
1109
  const boundary = task.history.findLastIndex(entry => entry.event === "accepted" || entry.event === "claimed");
979
1110
  if (boundary < 0) return [];
@@ -991,13 +1122,15 @@ export class Tasks {
991
1122
  }
992
1123
 
993
1124
  private planningAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
1125
+ requireLocalTaskPeer(by);
994
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" : ""}`);
995
1127
  }
996
1128
 
997
1129
  /** #812 AC5: a peer holding the configured planner role. The conductor's task reads accept it; it grants no write. */
998
- 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"); }
999
1131
 
1000
1132
  private plannerWriteAuthority(by: PeerId, task?: Pick<Task, "planner">): void {
1133
+ requireLocalTaskPeer(by);
1001
1134
  if (by !== USER && this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("planner writes are unavailable during a private turn");
1002
1135
  this.planningAuthority(by, task);
1003
1136
  }
@@ -1063,6 +1196,7 @@ export class Tasks {
1063
1196
 
1064
1197
  /** Host-observed task branch/root binding (#845), never a tool-call branch claim or an execution grant. */
1065
1198
  bindShellBranch(peer: PeerId, id: number, binding: TaskShellBinding & { worktree: string }, current: () => boolean): boolean {
1199
+ requireLocalTaskPeer(peer);
1066
1200
  try {
1067
1201
  if (!Number.isSafeInteger(id) || id < 1 || !taskShellBindingValid(binding) || !this.publicMetadataText(binding.branch)
1068
1202
  || !current() || this.d.privateTurn?.(peer)) return false;
@@ -1143,7 +1277,7 @@ export class Tasks {
1143
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. */
1144
1278
  private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
1145
1279
  return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
1146
- && (planner === undefined || this.taskPlanner(card).includes(planner)) && !(pullable && this.isPii(card)) && !card.pinned && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(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)
1147
1281
  && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
1148
1282
  }
1149
1283
 
@@ -1207,6 +1341,7 @@ export class Tasks {
1207
1341
  plannerRefusal(task: Task): string {
1208
1342
  const parts: string[] = [];
1209
1343
  const excluded = [...new Set(this.excluded(task))];
1344
+ const pressure = this.queueRouting(Object.keys(this.states()));
1210
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`);
1211
1346
  for (const peer of Object.keys(this.states())) {
1212
1347
  if (excluded.includes(peer)) continue;
@@ -1214,14 +1349,16 @@ export class Tasks {
1214
1349
  if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
1215
1350
  else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
1216
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}`);
1217
1354
  }
1218
1355
  return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
1219
1356
  }
1220
- 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 {
1221
1358
  const states = this.states();
1222
1359
  const full = this.plannerFull(task);
1223
1360
  // Preview the To Do transition without routing or offering the persisted backlog row.
1224
- 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 } : {}) });
1225
1362
  }
1226
1363
 
1227
1364
  async pullPlanned(by: PeerId, input: { id: number; revision: number; reason: string }, opts: { onBehalfOf?: PeerId; guard?: () => void } = {}): Promise<Task> {
@@ -1229,13 +1366,18 @@ export class Tasks {
1229
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.
1230
1367
  if (opts.onBehalfOf !== undefined && by !== HUB) throw new PreEffectToolRefusal("only the hub pulls on a planner's behalf");
1231
1368
  const planner = opts.onBehalfOf ?? by;
1369
+ let queueAssignment: { current: () => void } | undefined;
1232
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
1233
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.
1234
- const authorized = () => { this.planningAuthority(planner); opts.guard?.(); };
1372
+ const authorized = () => { this.planningAuthority(planner); opts.guard?.(); queueAssignment?.current(); };
1235
1373
  authorized();
1236
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();
1237
1378
  const validate = () => {
1238
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`);
1239
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");
1240
1382
  this.planningAuthority(planner, task);
1241
1383
  const release = task.release == null ? undefined : this.d.board.release(task.release);
@@ -1253,7 +1395,7 @@ export class Tasks {
1253
1395
  })) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
1254
1396
  }
1255
1397
  if (task.reserved && this.excluded(task).includes(task.reserved)) throw new PreEffectToolRefusal(this.plannerRefusal(task));
1256
- const assignment = this.plannerAssignment(task, !!task.reserved);
1398
+ const assignment = this.plannerAssignment(task, !!task.reserved, this.d.routing(), queueAssignment ? "owner" : undefined);
1257
1399
  if (!assignment.owner) {
1258
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);
1259
1401
  throw new PreEffectToolRefusal(`${this.hasPersonReservation(task) ? "a person's reservation is retained; " : ""}${refusal}`);
@@ -1264,7 +1406,7 @@ export class Tasks {
1264
1406
  authorized();
1265
1407
  const current = validate();
1266
1408
  if (current.task.reserved) {
1267
- 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 } : {}),
1268
1410
  plannerPull: { revision: input.revision, note: input.reason.trim(), withheld, guard: () => {
1269
1411
  authorized();
1270
1412
  const checked = validate();
@@ -1286,7 +1428,7 @@ export class Tasks {
1286
1428
  const pending = this.plannerBacklog.get(peer) ?? new Set<number>();
1287
1429
  for (const id of ids) {
1288
1430
  const task = this.d.board.get(id);
1289
- 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);
1290
1432
  }
1291
1433
  if (pending.size) this.plannerBacklog.set(peer, pending);
1292
1434
  }
@@ -1327,11 +1469,13 @@ export class Tasks {
1327
1469
  scan.revision = revision;
1328
1470
  }
1329
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;
1330
1474
  for (const peer of planners) {
1331
1475
  const told = scan.sent.get(peer), pending = scan.pending.get(peer);
1332
1476
  const review = told === undefined || pending?.review === true;
1333
1477
  if (!review && !pending?.other) continue;
1334
- 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; }
1335
1479
  this.releaseNotice(`could not notify planner ${peer} about grooming`, () => {
1336
1480
  if (review) this.sendGroomNotice(peer, revision);
1337
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" }));
@@ -1351,7 +1495,7 @@ export class Tasks {
1351
1495
 
1352
1496
  /** One bounded ids-only batch per attached planner; failed or evicted copies stay pending. */
1353
1497
  flushPlannerNotices(): number {
1354
- 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);
1355
1499
  for (const peer of this.knownPlanners) if (!configured.includes(peer)) {
1356
1500
  this.knownPlanners.delete(peer); this.plannerBacklog.delete(peer); this.plannerBlockedQueue.delete(peer);
1357
1501
  this.groomScan.pending.delete(peer); this.groomScan.sent.delete(peer);
@@ -1542,7 +1686,7 @@ export class Tasks {
1542
1686
  }
1543
1687
 
1544
1688
  async importGitHub(by: PeerId, input: GitHubImportSelection, run?: SdlcGh): Promise<{ imported: number[]; existing: number[]; partial: boolean; complete: boolean; unconfirmed?: true }> {
1545
- 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");
1546
1690
  const deadline = performance.now() + 20_000;
1547
1691
  const page = await readGitHubImport(input, run ?? ((args, timeout, _cwd, bound) => sdlcGh(args, timeout, this.d.cwd, bound)));
1548
1692
  const imported: number[] = [], existing: number[] = [];
@@ -1554,7 +1698,7 @@ export class Tasks {
1554
1698
  if (prior) { existing.push(prior.id); continue; }
1555
1699
  const classes = profileData(this.d.profileData ?? this.d.profile ?? "dev").classes;
1556
1700
  const cls = Object.keys(classes).find(name => classes[name] === "implement") ?? Object.keys(classes)[0];
1557
- 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; });
1558
1702
  (created ? imported : existing).push(task.id);
1559
1703
  if (performance.now() >= deadline) return { imported, existing, partial: true, complete: false };
1560
1704
  } catch {
@@ -1570,8 +1714,10 @@ export class Tasks {
1570
1714
  return { imported, existing, partial: page.partial, complete: !page.partial };
1571
1715
  }
1572
1716
 
1573
- async splitBacklog(by: PeerId, input: { id: number; revision: number; parts: unknown; reason: string }): Promise<{ parent: Task; children: Task[] }> {
1574
- 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();
1575
1721
  const { id, revision } = input;
1576
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");
1577
1723
  const reason = input.reason.trim();
@@ -1584,7 +1730,9 @@ export class Tasks {
1584
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");
1585
1731
  const release = parent.release == null ? undefined : this.d.board.release(parent.release);
1586
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");
1587
- 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");
1588
1736
  const profile = parent.profile ?? "dev";
1589
1737
  const drafts = rows.map((part, index) => {
1590
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");
@@ -1619,7 +1767,7 @@ export class Tasks {
1619
1767
  let screened: Awaited<typeof attempt>;
1620
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); })]); }
1621
1769
  finally { if (deadline !== undefined) clearTimeout(deadline); }
1622
- 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
1623
1771
  const [screenedVerdicts, withheld] = screened, current = validate();
1624
1772
  const finalSignals = current.drafts.map(part => this.signalsFor({ ...part, detail: [part.detail, planText(part.plan)].join("\n") }));
1625
1773
  const deterministicPrivate = finalSignals.map((signals, index) => initialPrivate[index] || this.isPii({ signals }));
@@ -1627,6 +1775,8 @@ export class Tasks {
1627
1775
  const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
1628
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"] : [])])] }));
1629
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;
1630
1780
  const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
1631
1781
  if (this.d.coordination) {
1632
1782
  const mode = this.d.coordination();
@@ -1640,6 +1790,92 @@ export class Tasks {
1640
1790
  return result;
1641
1791
  }
1642
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
+
1643
1879
  /** Release assignment changes no execution role, dependency, reservation or state. */
1644
1880
  async assignRelease(by: PeerId, input: { id: number; revision: number; release: number | null; reason: string }): Promise<Task> {
1645
1881
  this.plannerWriteAuthority(by);
@@ -1789,12 +2025,14 @@ export class Tasks {
1789
2025
  return updated; // a failed readback may follow a durable release write
1790
2026
  }
1791
2027
 
1792
- 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> {
1793
- 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);
1794
2031
  external = external ? { ...external } : undefined;
1795
- 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");
1796
2033
  if (input.backlog !== undefined && typeof input.backlog !== "boolean") throw new PreEffectToolRefusal("backlog must be a boolean");
1797
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");
1798
2036
  const claiming = !backlog && input.owner === by;
1799
2037
  if (claiming) {
1800
2038
  this.acceptanceCapacity({ id: -1, owner: by, state: "proposed" });
@@ -1864,7 +2102,7 @@ export class Tasks {
1864
2102
  this.warmRepository({ refs: draft.refs, plan });
1865
2103
  const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
1866
2104
  requestCheck();
1867
- 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 } : {}) });
1868
2106
  if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
1869
2107
  if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
1870
2108
  created?.();
@@ -1934,6 +2172,7 @@ export class Tasks {
1934
2172
  if (!Number.isSafeInteger(n) || n <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
1935
2173
  const task = this.d.board.get(n);
1936
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`);
1937
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"}`);
1938
2177
  if (!pull && taskHasStarted(task)) throw new PreEffectToolRefusal(`task #${n}: started work cannot return to backlog`);
1939
2178
  const pulled = task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned");
@@ -1977,7 +2216,8 @@ export class Tasks {
1977
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";
1978
2217
  };
1979
2218
  const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
1980
- 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];
1981
2221
  if (!task) return;
1982
2222
  this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
1983
2223
  const text = screenText(task);
@@ -2117,6 +2357,7 @@ export class Tasks {
2117
2357
  * recipient, and carries `completedFor` so the bus replaces an earlier queued notice for the same `done` task and recipient.
2118
2358
  */
2119
2359
  whileOpenFor(to: PeerId, done: number, about: number[], body: string): void {
2360
+ requireLocalTaskPeer(to);
2120
2361
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(about[0]), taskRecipient: "owner", completedFor: done } });
2121
2362
  this.conditional.set(env.id, { peer: to, task: about[0]!, tasks: about, states: OPEN });
2122
2363
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
@@ -2124,6 +2365,7 @@ export class Tasks {
2124
2365
  }
2125
2366
 
2126
2367
  whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
2368
+ requireLocalTaskPeer(to);
2127
2369
  const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task), taskRecipient: "owner" } });
2128
2370
  this.conditional.set(env.id, { peer: to, task, states });
2129
2371
  if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
@@ -2262,6 +2504,7 @@ export class Tasks {
2262
2504
  * Only when some other peer can take a task: with nobody to hand it to, it stays with its owner.
2263
2505
  */
2264
2506
  async releaseFromGone(peer: PeerId, minutes: number): Promise<{ id: number; title: string; to: PeerId | null }[]> {
2507
+ requireLocalTaskPeer(peer);
2265
2508
  const moved: { id: number; title: string; to: PeerId | null }[] = [];
2266
2509
  const why = `owner ${peer} offline for ${minutes} min`;
2267
2510
  const ids = this.d.board.list().filter((t) => t.owner === peer && OPEN.includes(t.state)).map((t) => t.id);
@@ -2334,11 +2577,12 @@ export class Tasks {
2334
2577
  }
2335
2578
  }
2336
2579
 
2337
- /** 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. */
2338
2581
  private personUnassigned(task: Task): boolean {
2339
- 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));
2340
2584
  if (removed < 0) return false;
2341
- 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));
2342
2586
  }
2343
2587
 
2344
2588
  /**
@@ -2390,7 +2634,7 @@ export class Tasks {
2390
2634
  }
2391
2635
 
2392
2636
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
2393
- 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> {
2394
2638
  opts.current?.();
2395
2639
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
2396
2640
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
@@ -2420,7 +2664,9 @@ export class Tasks {
2420
2664
  const waits = this.waitsFor(task);
2421
2665
  const plannedMove = opts.planner || this.plannerPullRecord(task)?.pull === true && (by === HUB || opts.roster) && (opts.reason !== "manual" || opts.roster);
2422
2666
  const plannerFull = plannedMove ? this.plannerFull(task) : [];
2423
- 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 } : {}) });
2424
2670
 
2425
2671
  if (waits.length) {
2426
2672
  if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull waits for dependency approval");
@@ -2434,14 +2680,18 @@ export class Tasks {
2434
2680
  const why = a.refusals?.find(row => row.role === "owner" && row.peer === opts.namedOwner)?.reason ?? "not eligible";
2435
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" : ""}`);
2436
2682
  }
2437
- if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
2438
2683
  this.releaseNotice(opts.roster
2439
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>`
2440
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>`);
2441
2686
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
2442
2687
  if (opts.clearOnFail && task.owner) {
2443
2688
  this.cohorts.leave(task.id);
2444
- 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);
2445
2695
  }
2446
2696
  return task;
2447
2697
  }
@@ -2452,11 +2702,12 @@ export class Tasks {
2452
2702
  opts.plannerPull?.guard();
2453
2703
  const assignmentNote = this.assignmentNote(`${opts.note ?? `to ${a.owner}`}${passed}`, a);
2454
2704
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
2455
- 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 } : {}) };
2456
2706
  opts.current?.();
2457
2707
  const next = opts.plannerPull
2458
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)
2459
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);
2460
2711
  if (opts.plannerPull) this.planningRecorded({ by, task: next.id, fields: ["pull", "reserved"], revision: next.planningRevision! });
2461
2712
  // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
2462
2713
  // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
@@ -2523,11 +2774,16 @@ export class Tasks {
2523
2774
  return next;
2524
2775
  }
2525
2776
 
2526
- /** 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. */
2527
2781
  setRole(by: PeerId, id: unknown, raw: unknown): Task {
2528
- if (by !== USER) throw new PreEffectToolRefusal("only the person may change an existing task catalog role");
2529
2782
  if (raw === undefined) throw new PreEffectToolRefusal("name a catalog role id or explicit null to clear it");
2530
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`);
2531
2787
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("a closed task's role is historical; propose new work instead");
2532
2788
  let selected;
2533
2789
  try { selected = taskRole(raw === null ? undefined : raw, task.class, this.d.catalog ?? loadRoleCatalog(this.d.cwd)); }
@@ -2537,7 +2793,7 @@ export class Tasks {
2537
2793
  for (const peer of new Set([next.owner, next.state === "in_review" ? next.reviewer : null])) {
2538
2794
  if (!peer || peer === USER) continue;
2539
2795
  this.releaseNotice(`task ${this.publicTitle(next)}: catalog role update delivery unconfirmed`, () => this.d.bus.publish(newEnvelope(HUB,
2540
- `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."}`,
2541
2797
  { to: [peer], kind: "task", refs: { task: String(next.id), taskRecipient: peer === next.reviewer ? "reviewer" : "owner" }, ...(this.isPii(next) ? { private: true } : {}) })));
2542
2798
  }
2543
2799
  return next;
@@ -2545,6 +2801,7 @@ export class Tasks {
2545
2801
 
2546
2802
  /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
2547
2803
  claimRoleGuidance(task: Task, peer: PeerId): string {
2804
+ requireLocalTaskPeer(peer);
2548
2805
  return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
2549
2806
  }
2550
2807
 
@@ -2707,7 +2964,8 @@ export class Tasks {
2707
2964
  try { return this.d.offerRead?.(offer.peer, offer.envelopeId) !== true; } catch { return true; }
2708
2965
  }).map(offer => ({ ...offer }));
2709
2966
  }
2710
- 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[] {
2711
2969
  const revision = this.d.board.statusRevision();
2712
2970
  if (this.stallCandidates?.revision !== revision) this.stallCandidates = { revision, rows: this.d.board.stalls().flatMap(stall => {
2713
2971
  const task = this.d.board.get(stall.task);
@@ -2717,37 +2975,213 @@ export class Tasks {
2717
2975
  return this.waitsFor(task).length ? [] : [{ stall, task, planned: this.plannerPullRecord(task)?.pull === true }];
2718
2976
  }) };
2719
2977
  // Eligibility inputs are live; queue journal writes alone do not re-decode board histories.
2720
- 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 => {
2721
2980
  const queue = this.d.bus.queueSummary(peer);
2722
2981
  return [peer, this.wipLimit(peer), queue.heldBy, queue.needsReview];
2723
2982
  })]);
2724
- 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) };
2725
2984
  return structuredClone(this.stallView.stalls);
2726
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
+
2727
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. */
2728
3142
  namedOwnerNote(task: Task, named: PeerId | undefined, by: PeerId): string | undefined {
3143
+ requireLocalTaskPeer(by);
2729
3144
  if (!named || task.owner || task.state !== "proposed") return undefined;
2730
3145
  const stall = this.d.board.stalls().find(row => row.task === task.id);
2731
3146
  if (!stall) return undefined;
2732
3147
  const code = stall.cause.refusals?.find(row => row.peer === named)?.code ?? (stall.cause.peer === named ? stall.cause.code : undefined);
2733
3148
  return `${named} was not assigned${code ? ` (${code})` : ""}: ${stall.cause.kind}; assign it again with hub_task_assign once ${named} can take it`;
2734
3149
  }
2735
- private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
3150
+ private ownerStallCause(task: Task, admission: Assignment, automatic = true): TaskStall["cause"] {
2736
3151
  const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
2737
3152
  // Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
2738
- const persisted = (code: NonNullable<Assignment["refusals"]>[number]["code"]): TaskStall["cause"]["code"] => code === "instance" || code === "family" ? "policy" : code;
2739
- 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)) ?? [] };
2740
3156
  }
2741
3157
  private reportStall(task: Task, cause: TaskStall["cause"], now = Date.now(), description?: string): void {
2742
3158
  const stall: TaskStall = { task: task.id, cause, signature: JSON.stringify(cause), replanRevision: task.planningRevision ?? 0, at: now };
2743
3159
  if (!this.d.board.recordStall(stall)) return;
3160
+ if (cause.kind === "no-eligible-peer" && isQueuePressureCode(cause.code)) this.markQueuePressureCandidate();
2744
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.`;
2745
3162
  this.releaseNotice(text);
2746
3163
  try { this.d.stallNotice?.(stall); } catch { /* advisory never changes task authority */ }
2747
3164
  // Advice joins the planner's next delivery; it must not manufacture a turn beside a withheld backlog batch.
2748
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)));
2749
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
+
2750
3183
  async cancel(id: unknown, by: PeerId, input: { reason: string; supersededBy?: string; expectedFlowRevision?: number }): Promise<Task> {
3184
+ requireLocalTaskPeer(by);
2751
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");
2752
3186
  const validate = () => {
2753
3187
  if (this.d.cancellationHeld?.() || this.d.bus.isRecoveryHeld) throw new PreEffectToolRefusal("recovery or shutdown is holding cancellation");
@@ -2795,15 +3229,21 @@ export class Tasks {
2795
3229
  }
2796
3230
 
2797
3231
  async releaseReady(): Promise<void> {
3232
+ const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
2798
3233
  for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
2799
3234
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
2800
3235
  const cancelledDependency = t.deps?.find(id => this.d.board.get(id)?.state === "cancelled");
2801
3236
  if (cancelledDependency) { this.reportStall(t, { kind: "cancelled-dependency", dependency: cancelledDependency, code: "cancelled" }); continue; }
2802
3237
  const last = t.history.findLast(entry => !QUIET_EVENTS.has(entry.event))?.event, planned = this.plannerPullRecord(t)?.pull === true;
2803
3238
  if (this.personUnassigned(t) || this.offered.has(t.id) || this.waitsFor(t).length) continue;
2804
- 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) });
2805
3244
  if (!admission.owner) {
2806
- 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)));
2807
3247
  continue;
2808
3248
  }
2809
3249
  this.d.board.clearStall(t.id);
@@ -2815,7 +3255,8 @@ export class Tasks {
2815
3255
  }
2816
3256
 
2817
3257
  /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
2818
- private async offerReady(stale: Task, why: string): Promise<void> {
3258
+ private async offerReady(stale: Task, why: string, current?: () => void): Promise<void> {
3259
+ current?.();
2819
3260
  let offering = false, undelivered: AssignmentUndeliveredError | undefined, slot: PeerId | undefined;
2820
3261
  try {
2821
3262
  const t = this.d.board.get(stale.id);
@@ -2832,7 +3273,7 @@ export class Tasks {
2832
3273
  offering = true;
2833
3274
  const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
2834
3275
  this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
2835
- 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 } : {}) });
2836
3277
  } catch (e) {
2837
3278
  // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
2838
3279
  // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
@@ -2852,6 +3293,7 @@ export class Tasks {
2852
3293
  }
2853
3294
 
2854
3295
  private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
3296
+ requireLocalTaskPeer(by);
2855
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`);
2856
3298
  }
2857
3299
 
@@ -2874,6 +3316,31 @@ export class Tasks {
2874
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;
2875
3317
  }
2876
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
+
2877
3344
  /** #694: write the one issue the task itself names (the plan-review source) into its lifecycle receipt, once; never relinks. */
2878
3345
  private linkIssue(task: Task): Task {
2879
3346
  if (this.isPii(task) || task.sdlc?.issue) return task;
@@ -3028,7 +3495,53 @@ export class Tasks {
3028
3495
  const byKind: Record<string, number> = {};
3029
3496
  for (const stall of this.stalled()) if (visibleIds.has(stall.task)) byKind[stall.cause.kind] = (byKind[stall.cause.kind] ?? 0) + 1;
3030
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 };
3031
- 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;
3032
3545
  }
3033
3546
 
3034
3547
  /**
@@ -3047,10 +3560,14 @@ export class Tasks {
3047
3560
  bandsState(): { ready: boolean; signature: string } {
3048
3561
  const config = this.d.sdlc?.config();
3049
3562
  const configured = !!config && config.mode !== "off" && !!config.bands && Object.keys(config.bands).length > 0;
3050
- 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]) : "" };
3051
3567
  }
3052
3568
 
3053
3569
  proposeFromBands(by: PeerId = HUB): number[] {
3570
+ requireLocalTaskPeer(by);
3054
3571
  const config = this.d.sdlc?.config();
3055
3572
  if (!config || config.mode === "off" || !config.bands || !Object.keys(config.bands).length) return [];
3056
3573
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return [];
@@ -3064,22 +3581,24 @@ export class Tasks {
3064
3581
  // A card counts as open while its task is not closed: the planner has not yet decided on it.
3065
3582
  const open = new Set(bandRows.filter(row => !isClosedTaskState(row.state)).map(row => keyOf(row).split(":")[0]!));
3066
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}`;
3067
3586
  for (const band of report.bands as BandResult[]) {
3068
3587
  if (band.tier === "ok" || band.lastTask === null || band.episode === null) continue;
3069
3588
  const key = `${band.metric}:#${band.episode}`;
3070
3589
  if (band.tier === "log") {
3071
3590
  const marker = `${key}:log`;
3072
- 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)}`);
3073
3592
  continue;
3074
3593
  }
3075
3594
  if (seen.has(`${key}:propose`) || seen.has(`${key}:diagnose`) || open.has(band.metric)) continue;
3076
3595
  const tier = band.tier === "propose" ? "propose" : "diagnose";
3077
3596
  const full = `${key}:${tier}`;
3078
- 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}`;
3079
3598
  const diagnose = tier === "diagnose";
3080
3599
  const title = diagnose ? `Diagnose SDLC metric ${band.metric}: read-only triage` : `Review SDLC metric ${band.metric}: control band breach`;
3081
3600
  const detail = `Band breach key: ${full}\n${evidence}\n${diagnose
3082
- ? "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.`
3083
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."}`;
3084
3603
  const draft = { title, detail, refs: {} };
3085
3604
  const signals = this.signalsFor(draft);
@@ -3091,6 +3610,167 @@ export class Tasks {
3091
3610
  return created;
3092
3611
  }
3093
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
+
3094
3774
  /** S10 read-only planner report; S7 owns rolling bands and automatic board proposals. */
3095
3775
  async costReport(by: PeerId, repository?: unknown, scopePrs?: readonly number[]): Promise<Record<string, unknown>> {
3096
3776
  const authority = () => {
@@ -3130,7 +3810,7 @@ export class Tasks {
3130
3810
  const scoped = current.filter(task => visiblePrs.has(task.sdlc!.pr!) && !this.isPii(task));
3131
3811
  const scopedIds = new Set(scoped.map(task => task.id));
3132
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 };
3133
- 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" };
3134
3814
  }
3135
3815
 
3136
3816
  async planReviewGap(task: Task): Promise<string | undefined> {
@@ -3205,18 +3885,21 @@ export class Tasks {
3205
3885
  * and only then is the rest recorded as before. A receipt with deliverables and nothing else records just the deliverables.
3206
3886
  */
3207
3887
  async recordSdlc(by: PeerId, id: unknown, input: unknown): Promise<Task> {
3888
+ const requestCheck = this.requestCheck(by);
3208
3889
  if (!input || typeof input !== "object" || Array.isArray(input) || !("deliverables" in input)) return this.recordLifecycle(by, id, input);
3209
3890
  const { deliverables, ...receipt } = input as Record<string, unknown>;
3210
3891
  const task = this.need(id, false, true);
3211
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");
3212
3893
  const claims = this.deliverableClaims(deliverables, task);
3213
3894
  const records = await this.hashClaims(claims, task, true);
3895
+ requestCheck();
3214
3896
  const current = this.need(id, false, true);
3215
3897
  if (current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed while its deliverables were hashed; record them again");
3216
3898
  return this.recordLifecycle(by, id, receipt, records);
3217
3899
  }
3218
3900
 
3219
3901
  recordLifecycle(by: PeerId, id: unknown, input: unknown, deliverables?: DeliverableRecord[]): Task {
3902
+ requireLocalTaskPeer(by);
3220
3903
  const task = this.need(id, false, true);
3221
3904
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3222
3905
  const next = sdlcReceipt(input, task.sdlc, config, VERSION);
@@ -3257,6 +3940,25 @@ export class Tasks {
3257
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) } }));
3258
3941
  }
3259
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
+
3260
3962
  async deliveryQueue(repo: string) {
3261
3963
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3262
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 })));
@@ -3289,8 +3991,9 @@ export class Tasks {
3289
3991
  private readonly mergesInFlight = new Set<number>();
3290
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. */
3291
3993
  private mergeAuthority(by: PeerId): boolean {
3994
+ if (isRemotePeerId(by)) return false;
3292
3995
  if (by === USER || by === HUB) return true;
3293
- 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);
3294
3997
  return holders.length === 1 && holders[0] === by;
3295
3998
  }
3296
3999
  /**
@@ -3321,14 +4024,15 @@ export class Tasks {
3321
4024
  this.mergesInFlight.add(task.id);
3322
4025
  try {
3323
4026
  const links = this.deliveryLinks(config), subject = links.find(link => link.id === task.id)!;
3324
- const queueIdentity = workflowHash({ config, links });
3325
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,
3326
4028
  recordMerge: record=>this.recordMergeResult(task.id,by,record,artifactCurrent),
3327
- beforeMerge: () => {
4029
+ beforeMerge: queue => {
3328
4030
  if (!this.mergeAuthority(by)) return "merge authority changed during the merge check: nothing was run";
3329
- // 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.
3330
4033
  const currentConfig = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3331
- 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) })
3332
4036
  ? undefined : "merge queue inputs changed during the merge check: nothing was run";
3333
4037
  },
3334
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
@@ -3336,7 +4040,7 @@ export class Tasks {
3336
4040
  fresh: () => {
3337
4041
  const now = this.d.board.get(task.id), cfg = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3338
4042
  const current = now && this.deliveryLinks(cfg).find(link => link.id === now.id);
3339
- 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;
3340
4044
  } });
3341
4045
  if(mode==="on" && !outcome.ran && "refused" in outcome.decision)this.recordMergeRefusal(task.id,by,outcome.decision.refused);
3342
4046
  if(outcome.result?.state==="merged") {
@@ -3436,6 +4140,12 @@ export class Tasks {
3436
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; }
3437
4141
  return d;
3438
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;
3439
4149
  const trains = this.d.board.releases().filter(release => release.train && ["active", "frozen"].includes(release.state));
3440
4150
  if (trains.length) {
3441
4151
  const planned = trains.map(release => {
@@ -3564,11 +4274,12 @@ export class Tasks {
3564
4274
  const accepted = task.sdlc?.acceptance;
3565
4275
  const edit = accepted && !this.isPii(task) && refresh && config.mode !== "off" ? await readSpecEdit(accepted.repo, accepted.issue, read) : undefined;
3566
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;
3567
4278
  const current = valid();
3568
4279
  if (this.planIdentity(current) !== this.planIdentity(task) || current.history.length !== task.history.length) throw new PreEffectToolRefusal("task changed during lifecycle readback");
3569
4280
  this.capturePlanRead(current, evidence);
3570
4281
  return sdlcStatus(config, current.sdlc, evidence, this.workflowView(current), edit, this.isPii(current) ? [] : this.reviewFindingSets(current), current.refs.commit,
3571
- 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);
3572
4283
  }
3573
4284
 
3574
4285
  /** #481 S1: only build work starts from an accepted spec. */
@@ -3595,6 +4306,7 @@ export class Tasks {
3595
4306
 
3596
4307
  /** Build entry with a live readback of the spec's body edit time (#481 S1): enforce refuses, advise notifies, off does nothing. */
3597
4308
  async specGate(by: PeerId, id: unknown): Promise<void> {
4309
+ requireLocalTaskPeer(by);
3598
4310
  const task = this.need(id, false, true);
3599
4311
  if (!this.specGated(task) || task.owner !== by && by !== USER) return; // accept reports its own refusals
3600
4312
  const accepted = task.sdlc?.acceptance;
@@ -3605,6 +4317,7 @@ export class Tasks {
3605
4317
 
3606
4318
  /** #481 S1: a build stage change by an agent re-reads the body edit time first, as accept does. */
3607
4319
  async specStageGate(by: PeerId, id: unknown, input: unknown): Promise<void> {
4320
+ requireLocalTaskPeer(by);
3608
4321
  const task = this.need(id, false, true), config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3609
4322
  if (config.mode === "off" || by === USER || !input || typeof input !== "object" || !("stage" in input)) return;
3610
4323
  let next: ReturnType<typeof sdlcReceipt>;
@@ -3640,6 +4353,8 @@ export class Tasks {
3640
4353
  return { applicability: "unknown", redFirst: "unknown", fileState: "unknown", modifiedTests: [], blockers: [privateTask ? "test integrity: private evidence unavailable" : "test integrity: current evidence unknown"] };
3641
4354
  }
3642
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>();
3643
4358
  private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
3644
4359
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
3645
4360
  if (config.mode === "off") return;
@@ -3653,16 +4368,24 @@ export class Tasks {
3653
4368
  };
3654
4369
  const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
3655
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;
3656
4372
  const current = this.need(task.id, false, true);
3657
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`);
3658
4374
  // Advise may continue on the current task, but evidence from its previous link cannot admit that task's review.
3659
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;
3660
4376
  this.capturePlanRead(current, evidence);
3661
4377
  const planReport = this.planReport(current, evidence);
3662
- const status = sdlcStatus(config, receipt, evidence, this.workflowView(current), undefined, this.isPii(current) ? [] : findings ?? this.reviewFindingSets(current), current.refs.commit, planReport, integrity);
3663
- 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;
3664
4383
  if (config.mode === "enforce" && blockers.length) throw new PreEffectToolRefusal(`task #${task.id}: SDLC ${transition} blocked: ${blockers.join("; ")}`);
3665
- 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)])];
3666
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("; ")}`);
3667
4390
  return evidence;
3668
4391
  }
@@ -3746,6 +4469,7 @@ export class Tasks {
3746
4469
  * announced like one found at assignment (issue #31).
3747
4470
  */
3748
4471
  accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
4472
+ requireLocalTaskPeer(by);
3749
4473
  const metadata = acceptRefs(refs);
3750
4474
  const task = this.need(id, false, true);
3751
4475
  this.mine(task, by, "owner", true);
@@ -3891,9 +4615,10 @@ export class Tasks {
3891
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).
3892
4616
  * Every path goes through `publicDeliverables`, so a PII task or a path under `pii.paths` shows id, hash and size only.
3893
4617
  */
3894
- 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[]> {
3895
4619
  const rows: DeliverableRow[] = [];
3896
- 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);
3897
4622
  for (const task of tasks) {
3898
4623
  const delivered = latestDeliverables(task.history);
3899
4624
  if (!delivered) continue;
@@ -4081,6 +4806,7 @@ export class Tasks {
4081
4806
  * duplicate notice is the price of never losing one.
4082
4807
  */
4083
4808
  replayHeld(peer: PeerId): void {
4809
+ requireLocalTaskPeer(peer);
4084
4810
  const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
4085
4811
  for (const t of this.d.board.list()) {
4086
4812
  if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
@@ -4138,6 +4864,19 @@ export class Tasks {
4138
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");
4139
4865
  }
4140
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
+
4141
4880
  private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
4142
4881
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
4143
4882
  this.checking.delete(id);
@@ -4161,12 +4900,12 @@ export class Tasks {
4161
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);
4162
4901
  return;
4163
4902
  }
4164
- 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));
4165
4904
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
4166
4905
  await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
4167
4906
  return;
4168
4907
  }
4169
- 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));
4170
4909
  this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
4171
4910
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
4172
4911
  this.contradict(task);
@@ -4203,7 +4942,8 @@ export class Tasks {
4203
4942
  this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
4204
4943
  const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
4205
4944
  const capacity = this.capacityBefore(task);
4206
- 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);
4207
4947
  this.writePlannerNotices(next, capacity, next.state === "approved");
4208
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)
4209
4949
  this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
@@ -4323,7 +5063,7 @@ export class Tasks {
4323
5063
  let quietDrift = false;
4324
5064
  const guard = () => {
4325
5065
  if (actor !== USER && actor !== task.owner) {
4326
- 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);
4327
5067
  if (holders.length !== 1 || holders[0] !== actor) throw new (preEffect ? PreEffectToolRefusal : Error)("review admission authority changed");
4328
5068
  }
4329
5069
  // #694: the board copy seen through the same staged identity link the caller's task carries.
@@ -4532,6 +5272,7 @@ export class Tasks {
4532
5272
 
4533
5273
  /** Host-only admission for a queued review after asynchronous native preparation. */
4534
5274
  reviewBindingCurrent(by: PeerId, id: number, binding: { head?: string; flowRevision?: number }): boolean {
5275
+ requireLocalTaskPeer(by);
4535
5276
  const task = this.d.board.get(id);
4536
5277
  return !!task && task.state === "in_review" && task.reviewer === by && !this.isPii(task)
4537
5278
  && this.reviewRequestRevision(task) === binding.flowRevision && this.reviewHead(task) === binding.head;
@@ -4539,6 +5280,7 @@ export class Tasks {
4539
5280
 
4540
5281
  /** Host-only failure report, bound to the exact review request and its still-current child generation. */
4541
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);
4542
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");
4543
5285
  const valid = () => {
4544
5286
  const task = this.need(id, false, true);
@@ -4557,7 +5299,8 @@ export class Tasks {
4557
5299
  valid();
4558
5300
  const note = manual ? "named reviewer recorded no verdict; the person must review"
4559
5301
  : selection?.reviewSelection ? this.assignmentNote(`reviewer unavailable: ${failure.cause}`, selection) : "no declared reviewer pool can receive the fallback; the person must review";
4560
- 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);
4561
5304
  this.releaseNotice(`task ${this.publicTitle(next)}: former review request withdrawal incomplete`, () => this.withdrawRoleRequest(task, next, "reviewer"));
4562
5305
  let offered = false;
4563
5306
  if (reviewer === USER) {
@@ -4637,6 +5380,7 @@ export class Tasks {
4637
5380
  }
4638
5381
 
4639
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);
4640
5384
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
4641
5385
  let findingGuard: ((task: Task) => void) | undefined;
4642
5386
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
@@ -4644,6 +5388,7 @@ export class Tasks {
4644
5388
  let covered: { commit: string | undefined; receipt: string } | undefined;
4645
5389
  let conformanceIdentity: string | undefined;
4646
5390
  const valid = (): Task => {
5391
+ requestCheck();
4647
5392
  const refused = authority?.();
4648
5393
  if (refused) throw new PreEffectToolRefusal(refused); // rechecked after every await, right before each write
4649
5394
  const t = this.need(id, false, true);
@@ -4749,7 +5494,12 @@ export class Tasks {
4749
5494
  }
4750
5495
  }
4751
5496
  }
4752
- 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
+ }
4753
5503
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
4754
5504
  if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {
4755
5505
  const input: CandidateInput = { project: this.d.store?.project ?? this.d.project, kind: "fail", approach: this.publicTitle(task), symptom: items.join("\n"),
@@ -4792,12 +5542,14 @@ export class Tasks {
4792
5542
  this.autoMerge(next, this.mirrorVerdict(next, by, "approved", reviewedHead ?? next.sdlc?.reviewedHead, note, withheld));
4793
5543
  // A proposal storage failure cannot roll back the saved verdict; another matching verdict retries it.
4794
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));
4795
5546
  return next;
4796
5547
  }
4797
5548
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4798
5549
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: evidenceOnly ? task.rejections : task.rejections + 1 }, note, flag);
4799
5550
  void this.mirrorVerdict(rejected, by, "changes_requested", reviewedHead ?? rejected.sdlc?.reviewedHead, note, withheld);
4800
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));
4801
5553
  if (candidate && !pii && !this.d.privateTurn?.(by) && this.candidateTextAllowed(JSON.stringify({ candidate, refs: rejected.refs, plan: rejected.plan }), rejected)) {
4802
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 */ }
4803
5555
  }
@@ -4837,7 +5589,7 @@ export class Tasks {
4837
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.
4838
5590
  */
4839
5591
  private tellConductorEvidenceOnly(task: Task, reviewer: PeerId): void {
4840
- 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);
4841
5593
  const text = evidenceOnlyNotice(task.id, reviewer);
4842
5594
  if (!conductors.length) { this.releaseNotice(text); return; }
4843
5595
  this.releaseNotice(`task #${task.id}: could not tell the conductor a code-scope review was evidence-only`, () => {
@@ -4847,6 +5599,7 @@ export class Tasks {
4847
5599
 
4848
5600
  /** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
4849
5601
  async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
5602
+ requireLocalTaskPeer(by);
4850
5603
  let task = this.need(id, true, preEffect);
4851
5604
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
4852
5605
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
@@ -4908,8 +5661,33 @@ export class Tasks {
4908
5661
  });
4909
5662
  }
4910
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
+
4911
5686
  /** Caller authority is checked by the daemon; keep the real actor in task history. */
4912
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?.();
4913
5691
  if (role !== "owner" && role !== "reviewer" && role !== "planner") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner, reviewer or planner");
4914
5692
  if (role === "planner") {
4915
5693
  if (by !== USER) throw new PreEffectToolRefusal("only the person may name a task planner");
@@ -4923,9 +5701,11 @@ export class Tasks {
4923
5701
  return next;
4924
5702
  }
4925
5703
  if (role === "reviewer" && by !== USER) {
4926
- 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);
4927
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");
4928
5706
  }
5707
+ const queueAssignment = this.explicitQueueAssignment(by, role, preEffect);
5708
+ queueAssignment?.current();
4929
5709
  const task = this.need(id, role === "owner", preEffect);
4930
5710
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
4931
5711
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster; add it to the roster before assigning work`);
@@ -4933,20 +5713,23 @@ export class Tasks {
4933
5713
  if (role === "reviewer") {
4934
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`);
4935
5715
  this.ready(task, preEffect);
4936
- 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 } : {}) });
4937
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("; ")}`);
4938
5718
  const admission = task.state === "in_review" ? await this.reviewRequestAdmission(task, undefined, preEffect, by) : undefined;
4939
5719
  // Every runtime authority check precedes the final write, including revocations during the metadata await.
5720
+ manualCurrent?.();
5721
+ queueAssignment?.current();
4940
5722
  const current = this.need(task.id, false, preEffect);
4941
5723
  if (current.history.length !== task.history.length) throw new (preEffect ? PreEffectToolRefusal : Error)("task changed during reviewer admission");
4942
5724
  if (by !== USER) {
4943
- 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);
4944
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");
4945
5727
  }
4946
5728
  if (!this.d.bus.isRostered(peer)) throw new (preEffect ? PreEffectToolRefusal : Error)(`${peer} is off roster`);
4947
5729
  this.requireCapabilities(current, peer, role, preEffect);
4948
- 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 } : {}) });
4949
5731
  if (eligible.reviewer !== peer) throw new (preEffect ? PreEffectToolRefusal : Error)("reviewer is no longer eligible");
5732
+ queueAssignment?.current();
4950
5733
  const next = this.d.board.update(task.id, by, "reviewer changed", { reviewer: peer }, this.assignmentNote(`named reviewer ${peer}`, a), { reason: "manual" }, expectedFlowRevision);
4951
5734
  if (next.state === "in_review" && task.reviewer && task.reviewer !== peer) {
4952
5735
  this.withdrawRoleRequest(task, next, "reviewer");
@@ -4955,15 +5738,23 @@ export class Tasks {
4955
5738
  return next;
4956
5739
  }
4957
5740
  // Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
4958
- 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" });
4959
- 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);
4960
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`);
4961
5746
  return next;
4962
5747
  }
4963
5748
 
4964
- /** 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. */
4965
5750
  async unassign(id: unknown, by: PeerId = USER, preEffect = false, role: TaskAssignmentRole = "owner", expectedFlowRevision?: number): Promise<Task> {
4966
- 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?.();
4967
5758
  if (role !== "owner" && role !== "reviewer") throw new (preEffect ? PreEffectToolRefusal : Error)("role must be owner or reviewer");
4968
5759
  const task = this.need(id, false, preEffect);
4969
5760
  this.checkFlowRevision(task, expectedFlowRevision, preEffect);
@@ -4971,7 +5762,8 @@ export class Tasks {
4971
5762
  if (role === "owner") {
4972
5763
  if (!OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: its owner cannot be unassigned`);
4973
5764
  if (!task.owner && !task.reserved) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no owner or owner reservation to unassign`);
4974
- 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);
4975
5767
  this.cohorts.leave(task.id);
4976
5768
  this.withdrawRoleRequest(task, next, "owner");
4977
5769
  return next;
@@ -4979,7 +5771,8 @@ export class Tasks {
4979
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`);
4980
5772
  if (!task.reviewer) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: no reviewer to unassign`);
4981
5773
  if (task.reviewer === USER && this.isPii(task)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: the human reviewer is required for PII work`);
4982
- 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);
4983
5776
  this.withdrawRoleRequest(task, next, "reviewer");
4984
5777
  return next;
4985
5778
  }
@@ -4989,11 +5782,13 @@ export class Tasks {
4989
5782
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
4990
5783
  */
4991
5784
  async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
5785
+ requireLocalTaskPeer(peer);
4992
5786
  return this.reassignWork(peer, context, urgentOnly, false);
4993
5787
  }
4994
5788
 
4995
5789
  /** Read-only preview using the same assignment constraints as the roster relay. */
4996
5790
  rosterPreview(peer: PeerId): { id: number; title: string; role: "owner" | "reviewer"; to: PeerId | null }[] {
5791
+ requireLocalTaskPeer(peer);
4997
5792
  const routing = this.d.routing();
4998
5793
  return this.d.board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)).map(task => {
4999
5794
  const owner = task.owner === peer;
@@ -5007,6 +5802,7 @@ export class Tasks {
5007
5802
 
5008
5803
  /** A roster removal offers work through the quota relay's constraints, preserving assignments nobody can take. */
5009
5804
  async reassignForRoster(peer: PeerId, current?: () => void): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
5805
+ requireLocalTaskPeer(peer);
5010
5806
  return this.reassignWork(peer, undefined, false, true, current);
5011
5807
  }
5012
5808
 
@@ -5055,7 +5851,8 @@ export class Tasks {
5055
5851
  continue;
5056
5852
  }
5057
5853
  current?.();
5058
- 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);
5059
5856
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
5060
5857
  current?.();
5061
5858
  if (next.state === "in_review" && reviewer && reviewer !== USER) {
@@ -5090,11 +5887,13 @@ export class Tasks {
5090
5887
 
5091
5888
  /** Candidate only: the person remains the sole acceptance authority. */
5092
5889
  async correct(by: PeerId, input: unknown): Promise<string> {
5890
+ const requestCheck = this.requestCheck(by);
5093
5891
  const args = correctionInput(input);
5094
5892
  const st = this.d.store;
5095
5893
  if (!st || !this.d.candidates) throw new PreEffectToolRefusal("memory correction unavailable");
5096
5894
  const screen: MemoryScreen = rows => this.d.candidateScreen?.(rows) ?? withoutPii(rows, this.d.routing(), this.d.cwd, this.d.piiPaths ?? []);
5097
5895
  const valid = () => {
5896
+ requestCheck();
5098
5897
  this.d.memoryCheck?.();
5099
5898
  if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("memory correction unavailable during a private turn");
5100
5899
  if (!this.candidateTextAllowed(JSON.stringify(args))) throw new PreEffectToolRefusal("memory correction withheld by privacy policy");
@@ -5169,6 +5968,7 @@ export class Tasks {
5169
5968
  }
5170
5969
  /** A person links a board task of this project to a long-term task (`ahub goal link`). */
5171
5970
  linkGoal(by: PeerId, id: unknown, ref: unknown): string {
5971
+ requireLocalTaskPeer(by);
5172
5972
  const task = this.d.board.get(Number(id));
5173
5973
  if (!task) throw new Error(`no task #${String(id).slice(0, 12)}`);
5174
5974
  const goal = this.goalFor(by, ref);