@staix/agent-hub 0.27.0 → 0.29.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 (106) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +9 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +7 -0
  8. package/docs/agent-notes/tests.md +7 -5
  9. package/docs/design/adapters.md +76 -0
  10. package/docs/design/agent-hub-architecture.md +67 -0
  11. package/docs/design/agent-hub.md +52 -0
  12. package/docs/design/bus.md +48 -0
  13. package/docs/design/task-board.md +86 -0
  14. package/docs/dogfood.md +21 -3
  15. package/docs/operations.md +370 -12
  16. package/docs/profiles.md +1 -0
  17. package/docs/quickstart.md +19 -3
  18. package/docs/sdlc.md +2 -2
  19. package/docs/security.md +24 -4
  20. package/docs/smoke.md +2 -0
  21. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  24. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  25. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  26. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  27. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  30. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  31. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  32. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  33. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  34. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  35. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  36. package/docs/verified.json +14 -14
  37. package/package.json +2 -2
  38. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  39. package/plugins/agent-hub/server.js +857 -125
  40. package/src/adapters/claude-channel.ts +12 -2
  41. package/src/adapters/compat.ts +1 -1
  42. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  43. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  45. package/src/adapters/jcode.ts +37 -4
  46. package/src/adapters/qwen-dual.ts +3 -2
  47. package/src/cli/agents.ts +4 -2
  48. package/src/cli/bench.ts +4 -2
  49. package/src/cli/board-read.ts +25 -0
  50. package/src/cli/claude-homes.ts +2 -2
  51. package/src/cli/console-state.ts +8 -3
  52. package/src/cli/help.ts +6 -5
  53. package/src/cli/identity.ts +9 -1
  54. package/src/cli/init-prompt.ts +179 -0
  55. package/src/cli/init-settings.ts +229 -0
  56. package/src/cli/init.ts +102 -9
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +106 -35
  59. package/src/cli/output.ts +19 -0
  60. package/src/cli/recovery-package.ts +9 -0
  61. package/src/cli/setup-homes.ts +18 -3
  62. package/src/cli/specs.ts +150 -0
  63. package/src/cli/terminal-recovery.ts +35 -11
  64. package/src/hub/board.ts +59 -5
  65. package/src/hub/bus.ts +54 -7
  66. package/src/hub/child-process.ts +6 -4
  67. package/src/hub/config-apply.ts +4 -1
  68. package/src/hub/config-edit.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -0
  70. package/src/hub/control-client.ts +43 -4
  71. package/src/hub/control-write-trace.ts +409 -0
  72. package/src/hub/cost-governance.ts +22 -6
  73. package/src/hub/crash.ts +20 -0
  74. package/src/hub/daemon.ts +470 -98
  75. package/src/hub/delivery-hints.ts +6 -3
  76. package/src/hub/delivery-journal.ts +14 -3
  77. package/src/hub/hub-tools.ts +10 -6
  78. package/src/hub/peers.ts +1 -1
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +14 -3
  81. package/src/hub/runtime-capture-prune.ts +4 -1
  82. package/src/hub/sdlc-release-authorization.ts +13 -4
  83. package/src/hub/sdlc-release.ts +23 -10
  84. package/src/hub/sdlc.ts +15 -2
  85. package/src/hub/settings.ts +2 -0
  86. package/src/hub/specs-config.ts +43 -0
  87. package/src/hub/specs-task.ts +108 -0
  88. package/src/hub/specs.ts +1008 -0
  89. package/src/hub/task-list-page.ts +75 -0
  90. package/src/hub/task-sweep.ts +10 -1
  91. package/src/hub/task-worktree.ts +331 -0
  92. package/src/hub/tasks.ts +606 -53
  93. package/src/hub/terminal-open.ts +10 -5
  94. package/src/hub/tool-request.ts +131 -0
  95. package/src/local/deny.ts +49 -0
  96. package/src/local/tools.ts +4 -2
  97. package/src/memory/brief.ts +5 -0
  98. package/src/memory/client.ts +5 -2
  99. package/src/ui/office-model.ts +17 -1
  100. package/src/ui/office-space.ts +1 -1
  101. package/templates/config.json +1 -0
  102. package/templates/profiles/dev.json +1 -0
  103. package/templates/profiles/general.json +1 -0
  104. package/templates/profiles/research.json +1 -0
  105. package/templates/profiles/writing.json +1 -0
  106. package/templates/specs.block.md +5 -0
package/src/hub/tasks.ts CHANGED
@@ -20,7 +20,7 @@ import { publicTestIntegrity, readTestIntegrity, testIntegrityBlockers, type Tes
20
20
  import { evalAdvice, evalGapBlockers, evalRequiredChecks, readEvalFor } from "./eval-gate.ts";
21
21
  import { completePlanInventory, planConformance, planConformanceBlockers, planFingerprint, type PlanConformance } from "./plan-conformance.ts";
22
22
  import { RELEASE_TRAIN_HISTORY_LIMIT, releaseTrainData, releaseTrainReadiness, releaseMetrics } from "./sdlc-release.ts";
23
- import { cleanCostPlan, costMetrics, type CostPlan, type ReviewCost } from "./cost-governance.ts";
23
+ import { cleanCostPlan, costMetrics, CostScopeError, type CostPlan, type ReviewCost } from "./cost-governance.ts";
24
24
  import { qualifiedTitleRepos, siblingTitleRefs, specRefs } from "./spec-read.ts";
25
25
  import { folderDetail, MAX_NEW_PER_POLL, scheduleNext, SOURCE_ID, scheduledDetail, scheduleKeyOf, type FolderEntry, type FolderRunResult, type FolderScan, type FolderStorePort, type ScannedFile, type ScheduleEntry, type ScheduleRunResult, type ScheduleStore } from "./schedules.ts";
26
26
  import { INTAKE_MAX_SOURCES, INTAKE_MAX_TEXT, intakeDetail, intakeSystemPrompt, intakeUserMessage, parseIntakeDetail, parseIntakeSplit, plannerRequestBody } from "./intake.ts";
@@ -64,23 +64,26 @@ import { isAbsolute, relative, resolve } from "node:path";
64
64
  import type { Briefs, NativeItem } from "../memory/brief.ts";
65
65
  import type { MemoryClient } from "../memory/client.ts";
66
66
  import { candidateTargetRevision, type CandidateInput, type CandidateStore, extractNodes, looksSecret, PROMOTED_KINDS, taskKey, type GoalView, type Kind, type MemoryStore, type Scope, type Tier } from "@staix/agent-mem";
67
- import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding } from "./board.ts";
67
+ import { parseClaimVerdict, claimListId, CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskCanPlan, taskHasWorkEvidence, taskHasStarted, planningUndoEntry, isClosedTaskState, type TaskStall, type HistoryEntry, type ReviewerHistory, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs, type PlannerBinding, type SpecAck, type SpecTaskReport } from "./board.ts";
68
68
  import type { Bus } from "./bus.ts";
69
69
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
70
70
  import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
71
- import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
71
+ import { assign, isRemotePeerId, detectSignals, peerEntry, predictSplit, relayCandidates, writesRepository, type Assignment, type InstanceRoutingOptions, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
72
72
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
73
73
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
74
74
  import { realPath } from "./project.ts";
75
75
  import { TaskRepositories } from "./task-repository.ts";
76
76
  import { mainCheckoutWarning } from "./main-checkout.ts";
77
77
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
78
- import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
78
+ import type { TaskWorktreePort } from "./task-worktree.ts";
79
+ import { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
80
+ import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
81
+ import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
79
82
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
80
83
  import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
81
- import { plannerGrooming, nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
84
+ import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
82
85
  import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
83
- 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";
86
+ import { planningIssueReceipt, linkedReceipt, openPrFor, readPrCostHistory, readReviewReadiness, reviewReadinessFromEvidence, type PrEvidence, type ReviewReadiness, readPlanIssueMetadata, canonicalStage, checklistOf, readSdlcPr, readSdlcPrHead, readSpecEdit, sdlcConfig, sdlcReceipt, sdlcStatus, specState, SPEC_STATE_TEXT, stageDefinition, lifecycleStage, type SdlcConfig, type MergeRecord, type PlanIssueMetadata, sdlcGh, type SdlcGh, type SdlcStatus, type SpecEdit, type SpecState } from "./sdlc.ts";
84
87
 
85
88
  function requireLocalTaskPeer(peer: unknown): void {
86
89
  if (isRemotePeerId(peer)) throw new PreEffectToolRefusal("remote peers have no local task authority");
@@ -88,6 +91,15 @@ function requireLocalTaskPeer(peer: unknown): void {
88
91
 
89
92
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
90
93
 
94
+ interface PreparedSpecInspection {
95
+ report?: SpecTaskReport;
96
+ policyKey: string;
97
+ repo?: string;
98
+ head: string;
99
+ privateTurn: boolean;
100
+ pii: boolean;
101
+ }
102
+
91
103
  export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
92
104
 
93
105
  export interface TasksDeps {
@@ -134,6 +146,14 @@ export interface TasksDeps {
134
146
  /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
135
147
  prepareReviewBrief?: (task: Task) => Promise<void>;
136
148
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
149
+ /** Current public coverage for a task's declared paths; absent when notices are disabled or no specs exist. */
150
+ specNotice?: (task: Task) => SpecsCoverReport | undefined;
151
+ /** Inspect spec metadata at a verified task head; undefined is authoritative quiet (no specs/stamps or review off). */
152
+ inspectSpecs?: (task: Task, head: string, changedFiles: readonly BriefFile[] | undefined, acks: readonly SpecAck[]) => Promise<SpecTaskReport | undefined>;
153
+ /** Effective shared and machine-local specs policy. */
154
+ specsConfig?: () => SpecsConfig;
155
+ /** Explicit, read-only inspection remains available when task notices are disabled. */
156
+ specsRead?: (action: "check" | "covers", paths: readonly string[]) => SpecsCheckReport | SpecsCoverReport;
137
157
  /** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
138
158
  memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
139
159
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
@@ -198,6 +218,8 @@ export interface TasksDeps {
198
218
  review?: { adaptive: boolean; min_reviews: number; pool?: readonly string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
199
219
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
200
220
  capabilities?: () => Record<PeerId, PeerCapabilities>;
221
+ /** #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. Absent: such a peer keeps the #1081 refusal. */
222
+ taskWorktree?: TaskWorktreePort;
201
223
  repository?: string;
202
224
  offerRead?: (peer: PeerId, envelopeId: string) => boolean;
203
225
  offerPullOnly?: (peer: PeerId) => boolean;
@@ -375,7 +397,7 @@ function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan
375
397
  if (items.length) evidence[key] = items;
376
398
  }
377
399
  let scope: CostPlan;
378
- try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
400
+ try { scope = cleanCostPlan(r); } catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
379
401
  return { ...Object.fromEntries((profileData(profile).plan_keys as (keyof TaskPlan)[]).map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length)), ...evidence, ...scope };
380
402
  }
381
403
 
@@ -410,6 +432,7 @@ export interface ExperimentView { attempts: ExperimentAttemptView[]; counts: { a
410
432
  comparison?: { planHash: string; baselineAttemptId: string; variantAttemptId: string; approved: boolean; private: boolean; projection: ReturnType<typeof comparisonProjection> | "[pii]" } }
411
433
  interface ExperimentContext { task: Task; campaign: ResearchCampaignRecord; private: boolean; key: string; fence: ExperimentWriteFence }
412
434
  interface CompletionRefresh { revision: number; commit: string; state: "in_review" | "approved" }
435
+ interface RefileHeadGuard { recorded?: string }
413
436
  interface IdentityLink { repo: string; pr: number; basis: { commit?: string; branch?: string } }
414
437
 
415
438
  /** What one live check of a task's files says as a whole (#317): `unchanged` only when EVERY file measured unchanged. A change outranks a missing file, which outranks one the hub could not read. */
@@ -563,7 +586,7 @@ export class Tasks {
563
586
  for (const task of this.d.board.list()) {
564
587
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
565
588
  if (!task.owner && this.personUnassigned(task)) continue;
566
- const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
589
+ const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0 && !plannedStartPending(task, now));
567
590
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
568
591
  if (finding.kind === "ready-unassigned") {
569
592
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
@@ -906,6 +929,8 @@ export class Tasks {
906
929
  if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
907
930
  const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
908
931
  }
932
+ const currentSpecReport = this.currentSpecReport(task);
933
+ const currentSpecEntry = currentSpecReport ? history.findLast(entry => entry.event === "done" || entry.event === "done (checking)") : undefined;
909
934
  const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
910
935
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
911
936
  at: h.at, by: h.by, event: h.event,
@@ -933,16 +958,23 @@ export class Tasks {
933
958
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
934
959
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
935
960
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
961
+ ...(h === currentSpecEntry && currentSpecReport ? { specReport: currentSpecReport } : {}),
936
962
  }));
937
963
  const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
938
964
  const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
939
965
  const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
940
966
  const handedOff = this.d.board.handoffOrigin(task.id); // #604: where another project's handoff came from; two ids, never text
941
- return { ...rest, ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
967
+ return { ...rest, ...(currentSpecReport ? { specReport: currentSpecReport } : {}), ...(approvalBinding(task) ? { approval: approvalBinding(task) } : {}), ...(handedOff ? { handoffOrigin: { projectId: handedOff.projectId, peer: handedOff.peer, ...(handedOff.originTask === null ? {} : { task: handedOff.originTask }) } } : {}), ...(publicSymbols ? { codeSymbols: publicSymbols } : {}), ...(current.length ? { findings: findingTally(current) } : {}), ...(cancelled ? { cancelled: { ...cancelled, reason: screenHistoryText(cancelled.reason, "cancellation", cancelled.withheld), ...(cancelled.supersededBy ? { supersededBy: screenHistoryText(cancelled.supersededBy, "supersession", cancelled.withheld) } : {}) } } : {}), ...(external && this.nameable(external.url) ? { external } : {}), actualDates: actualTaskDates(task), ...(((this.d.sdlc?.config().mode ?? "off") !== "off" || this.d.sdlc?.config().workflow === "gsd-lite") ? { lifecycle: sdlcStatus(this.d.sdlc?.config() ?? sdlcConfig(undefined), task.sdlc, undefined, this.workflowView(task), undefined, sets, task.refs.commit) } : {}), title, detail, history: publicHistory };
942
968
  }
943
969
 
944
970
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
945
- resultLine(task: Task): string {
971
+ specReportText(task: Task, by?: PeerId): string {
972
+ if (by && this.d.privateTurn?.(by)) return "";
973
+ const report = this.currentSpecReport(task);
974
+ return report ? renderSpecTaskReport(report) : "";
975
+ }
976
+
977
+ resultLine(task: Task, by?: PeerId): string {
946
978
  const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
947
979
  const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
948
980
  ? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class, task) }))
@@ -951,7 +983,250 @@ export class Tasks {
951
983
  const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
952
984
  const request = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
953
985
  const poolBlocked = task.state === "in_review" && task.reviewer === USER && request?.reviewBlocked ? "blocked: no reviewer can run" : undefined;
954
- return [base, noReview, poolBlocked, holdText].filter(Boolean).join("; ");
986
+ const specText = this.specReportText(task, by) || undefined;
987
+ return [base, noReview, poolBlocked, holdText, specText].filter(Boolean).join("; ");
988
+ }
989
+
990
+ private currentSpecReport(task: Task): SpecTaskReport | undefined {
991
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
992
+ if (this.isPii(task) || !config.enabled || config.review === "off") return;
993
+ const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
994
+ const binding = this.specRefBinding(task);
995
+ const latest = task.history.findLast(row => row.event === "done" || row.event === "done (checking)");
996
+ const report = latest?.specReport;
997
+ if (!latest || !report || !(report.head === head || report.head === "" && !report.complete) || latest.specRefBinding !== binding) return;
998
+ return this.publicSpecReport(report);
999
+ }
1000
+
1001
+ private specRefBinding(task: Pick<Task, "refs">): string {
1002
+ return createHash("sha256").update(JSON.stringify([task.refs.repo ?? "", task.refs.branch ?? "", task.refs.commit ?? ""])).digest("hex");
1003
+ }
1004
+
1005
+ private publicSpecReport(report: SpecTaskReport): SpecTaskReport | undefined {
1006
+ if (!report || typeof report.head !== "string" || report.head !== "" && !/^[0-9a-f]{40}$/.test(report.head)
1007
+ || report.complete && !/^[0-9a-f]{40}$/.test(report.head)
1008
+ || typeof report.complete !== "boolean" || !Number.isSafeInteger(report.errors) || report.errors < 0
1009
+ || !Array.isArray(report.unchanged) || !Array.isArray(report.changed)) return;
1010
+ const ref = (value: unknown): SpecTaskReport["unchanged"][number] | undefined => {
1011
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
1012
+ const row = value as Record<string, unknown>;
1013
+ if (typeof row.id !== "string" || typeof row.path !== "string" || typeof row.title !== "string"
1014
+ || !row.id.trim() || row.id.length > 160 || !row.path.trim()) return;
1015
+ // Missing titles are schema diagnostics, not a report-mode completion gate.
1016
+ const title = row.title.trim() ? row.title : row.id;
1017
+ const strings = [row.id, row.path, title, ...(typeof row.reason === "string" ? [row.reason] : [])];
1018
+ if (strings.some(text => text.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(text) || !this.publicMetadataText(text))) return;
1019
+ return { id: row.id, path: row.path, title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
1020
+ };
1021
+ const unchanged = report.unchanged.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
1022
+ const changed = report.changed.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
1023
+ const privacyFiltered = unchanged.length < Math.min(report.unchanged.length, 20) || changed.length < Math.min(report.changed.length, 20);
1024
+ const reason = privacyFiltered ? "some spec metadata could not be safely shown"
1025
+ : typeof report.reason === "string" && report.reason.length <= 500 && this.publicMetadataText(report.reason) ? report.reason : undefined;
1026
+ return { head: report.head, complete: report.complete && !privacyFiltered, errors: report.errors, unchanged, changed,
1027
+ ...(report.truncated || report.unchanged.length > 20 || report.changed.length > 20 ? { truncated: true } : {}), ...(reason ? { reason } : {}) };
1028
+ }
1029
+
1030
+ private parseSpecAcks(value: unknown): SpecAck[] {
1031
+ if (value === undefined) return [];
1032
+ if (!Array.isArray(value) || value.length > 20) throw new PreEffectToolRefusal("spec_ack must contain at most 20 {id, reason} entries");
1033
+ const seen = new Set<string>();
1034
+ return value.map(raw => {
1035
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new PreEffectToolRefusal("each spec_ack entry needs an id and reason");
1036
+ const row = raw as Record<string, unknown>;
1037
+ if (Object.keys(row).some(key => key !== "id" && key !== "reason") || typeof row.id !== "string" || !row.id.trim() || row.id.length > 160 || /[\p{Cc}\p{Cf}]/u.test(row.id)
1038
+ || typeof row.reason !== "string" || !row.reason.trim() || row.reason.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.reason)) throw new PreEffectToolRefusal("each spec_ack entry needs a bounded id and reason");
1039
+ const id = row.id.trim();
1040
+ if (!this.publicMetadataText(id)) throw new PreEffectToolRefusal("spec_ack id is private or sensitive");
1041
+ if (seen.has(id)) throw new PreEffectToolRefusal(`spec_ack repeats ${id}`);
1042
+ seen.add(id);
1043
+ return { id, reason: row.reason.trim() };
1044
+ });
1045
+ }
1046
+
1047
+ private specsPolicyKey(config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG): string {
1048
+ return JSON.stringify([config.enabled, config.review, config.roots, config.stamps_file]);
1049
+ }
1050
+
1051
+ private assertSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): void {
1052
+ this.assertSpecTaskBinding(inspection, task);
1053
+ if (this.specsPolicyKey() !== inspection.policyKey || (this.d.privateTurn?.(by) === true) !== inspection.privateTurn || this.isPii(task) !== inspection.pii) {
1054
+ throw new PreEffectToolRefusal(`task #${task.id}: specs policy, task head or privacy context changed during inspection; retry hub_task_done`);
1055
+ }
1056
+ }
1057
+
1058
+ private assertSpecTaskBinding(inspection: PreparedSpecInspection, task: Task): void {
1059
+ const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
1060
+ if (task.refs.repo !== inspection.repo || head !== inspection.head) {
1061
+ throw new PreEffectToolRefusal(`task #${task.id}: task head or repository changed during spec inspection; retry hub_task_done`);
1062
+ }
1063
+ }
1064
+
1065
+ private refreshAsyncSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): boolean {
1066
+ this.assertSpecTaskBinding(inspection, task);
1067
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
1068
+ const privateTurn = this.d.privateTurn?.(by) === true;
1069
+ const policyKey = this.specsPolicyKey(config), pii = this.isPii(task);
1070
+ if (inspection.report && policyKey !== inspection.policyKey && config.enabled && config.review === "require" && !privateTurn && !pii) return true;
1071
+ if (policyKey !== inspection.policyKey || privateTurn !== inspection.privateTurn || pii !== inspection.pii) {
1072
+ delete inspection.report;
1073
+ inspection.policyKey = policyKey;
1074
+ inspection.privateTurn = privateTurn;
1075
+ inspection.pii = pii;
1076
+ }
1077
+ return false;
1078
+ }
1079
+
1080
+ private requestSpecRefile(task: Task): void {
1081
+ this.tell(task, `Task #${task.id}: its class check passed, but specs policy changed to require while it ran. Refile hub_task_done so coverage is checked under the current policy.`, this.isPii(task));
1082
+ }
1083
+
1084
+ private specInspectionReason(cause: string): string {
1085
+ const safe = this.publicMetadataText(cause) ? sanitize(cause).replace(/[\r\n\t]+/g, " ").trim().slice(0, 350) : "verified spec evidence is unavailable";
1086
+ return `${safe}; split the task scope or ask the person to change specs.review`;
1087
+ }
1088
+
1089
+ private async inspectTaskSpecs(task: Task, by: PeerId, rawAcks: unknown, validate: () => Task): Promise<PreparedSpecInspection> {
1090
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
1091
+ const initialPrivate = this.d.privateTurn?.(by) === true, pii = this.isPii(task);
1092
+ const declaredHead = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
1093
+ const inspection: PreparedSpecInspection = { policyKey: this.specsPolicyKey(config), ...(task.refs.repo ? { repo: task.refs.repo } : {}), head: declaredHead, privateTurn: initialPrivate, pii };
1094
+ const ackInput = Array.isArray(rawAcks) && rawAcks.length === 0 ? undefined : rawAcks;
1095
+ if (ackInput !== undefined && (pii || initialPrivate)) throw new PreEffectToolRefusal("spec_ack is unavailable for private work");
1096
+ if (ackInput !== undefined && (!config.enabled || config.review === "off")) throw new PreEffectToolRefusal("spec_ack is unavailable while specs review is disabled");
1097
+ if (!config.enabled || config.review === "off" || pii || initialPrivate) return inspection;
1098
+ if (!this.d.inspectSpecs) {
1099
+ if (config.review === "require" && declaredHead) throw new PreEffectToolRefusal(`task #${task.id}: spec coverage is unknown because the spec inspector is unavailable at the submitted head; restore specs support or explicitly change specs.review`);
1100
+ if (ackInput !== undefined) throw new PreEffectToolRefusal("spec inspection is unavailable; cannot accept spec_ack");
1101
+ return inspection;
1102
+ }
1103
+ const acks = this.parseSpecAcks(ackInput);
1104
+ let changedFiles: BriefFile[] | undefined;
1105
+ let changedEvidenceReason: string | undefined;
1106
+ if (declaredHead) {
1107
+ const brief = this.d.reviewBrief?.({ ...task, refs: { ...task.refs, commit: declaredHead } });
1108
+ if (!brief) changedEvidenceReason = "the verified review brief is unavailable";
1109
+ else if ("unavailable" in brief) changedEvidenceReason = `review brief unavailable: ${brief.unavailable}`;
1110
+ else if (brief.head !== declaredHead) changedEvidenceReason = "the verified review brief does not match the submitted full head";
1111
+ else if (brief.withheld > 0) changedEvidenceReason = `the verified review brief withheld ${brief.withheld} changed file(s)`;
1112
+ else if (brief.unlisted.length > 0) changedEvidenceReason = `the verified review brief has ${brief.unlisted.length} unlisted changed file(s)`;
1113
+ else {
1114
+ const files = [...brief.reviewable, ...brief.excluded].flatMap(file => [file, ...(file.oldPath ? [{ path: file.oldPath, status: file.status }] : [])]);
1115
+ if (files.every(file => this.publicMetadataText(file.path))) {
1116
+ const byPath = new Map<string, BriefFile>();
1117
+ for (const file of files) if (!byPath.has(file.path)) byPath.set(file.path, file);
1118
+ changedFiles = [...byPath.values()];
1119
+ } else changedEvidenceReason = "the verified changed-file inventory includes private paths";
1120
+ }
1121
+ } else changedEvidenceReason = "a full task commit head was not supplied";
1122
+ const screenedAcks: SpecAck[] = [];
1123
+ for (const ack of acks) {
1124
+ if (!this.publicMetadataText(ack.reason)) throw new PreEffectToolRefusal("spec_ack reason is private or sensitive");
1125
+ if (await this.withholds("note", ack.reason, task)) throw new PreEffectToolRefusal(`spec_ack reason for ${ack.id} was withheld; use a public, non-sensitive rationale`);
1126
+ screenedAcks.push(ack);
1127
+ this.assertSpecInspection(inspection, validate(), by);
1128
+ }
1129
+ this.assertSpecInspection(inspection, validate(), by);
1130
+ const report = await this.d.inspectSpecs(task, declaredHead, changedFiles, screenedAcks);
1131
+ this.assertSpecInspection(inspection, validate(), by);
1132
+ if (!report) {
1133
+ if (acks.length) throw new PreEffectToolRefusal("spec_ack was supplied but no spec requires acknowledgement");
1134
+ return inspection;
1135
+ }
1136
+ const safe = this.publicSpecReport(report);
1137
+ if (!safe) throw new PreEffectToolRefusal("spec inspection returned invalid or private metadata");
1138
+ if (safe.unchanged.length < Math.min(report.unchanged.length, 20) || safe.changed.length < Math.min(report.changed.length, 20)) {
1139
+ safe.complete = false;
1140
+ safe.reason = this.specInspectionReason("some spec metadata could not be safely shown");
1141
+ }
1142
+ if (safe.head !== declaredHead) {
1143
+ safe.complete = false;
1144
+ safe.head = "";
1145
+ safe.reason = this.specInspectionReason("the spec report does not match the verified task head");
1146
+ safe.unchanged = [];
1147
+ safe.changed = [];
1148
+ }
1149
+ if (report.truncated) {
1150
+ safe.complete = false;
1151
+ safe.reason = this.specInspectionReason("the submitted spec scan reached its safety limit; narrow specs.roots");
1152
+ } else if (changedFiles === undefined && changedEvidenceReason && safe.head === declaredHead) {
1153
+ safe.complete = false;
1154
+ safe.reason = this.specInspectionReason(changedEvidenceReason);
1155
+ } else if (!safe.complete && safe.reason) safe.reason = this.specInspectionReason(safe.reason);
1156
+ else if (!safe.complete) safe.reason = this.specInspectionReason(report.reason ?? "spec coverage could not be verified at the submitted task head");
1157
+ if (config.review === "require" && report.unchanged.length > 20) throw new PreEffectToolRefusal(`task #${task.id}: ${report.unchanged.length} covering specs are unchanged; update the specs or split the task scope so each task covers at most 20 specs`);
1158
+ const pendingIds = new Set(safe.unchanged.map(spec => spec.id));
1159
+ for (const ack of screenedAcks) if (!pendingIds.has(ack.id)) throw new PreEffectToolRefusal(`spec_ack ${ack.id} does not name a covering spec that is unchanged at this head`);
1160
+ const reasons = new Map(screenedAcks.map(ack => [ack.id, ack.reason]));
1161
+ safe.unchanged = safe.unchanged.map(spec => ({ ...spec, ...(reasons.has(spec.id) ? { reason: reasons.get(spec.id)! } : {}) }));
1162
+ if (config.review === "require") {
1163
+ if (!safe.complete || report.truncated) throw new PreEffectToolRefusal(`task #${task.id}: ${safe.reason ?? this.specInspectionReason("spec coverage is unknown at this submitted task head")}`);
1164
+ const unacknowledged = safe.unchanged.filter(spec => !reasons.has(spec.id));
1165
+ if (unacknowledged.length) throw new PreEffectToolRefusal(`task #${task.id}: spec not updated: ${unacknowledged.map(spec => spec.id).join(", ")}; update the covering spec or add spec_ack [{id, reason}]`);
1166
+ }
1167
+ inspection.report = safe;
1168
+ return inspection;
1169
+ }
1170
+
1171
+ /** Narrow public task-path notice used by the tool response after propose/accept. */
1172
+ specNotice(task: Task, by?: PeerId): string {
1173
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
1174
+ if (!config.enabled || config.review === "off" || this.isPii(task) || by && this.d.privateTurn?.(by)) return "";
1175
+ let report: SpecsCoverReport | undefined;
1176
+ try { report = this.d.specNotice?.(task); } catch { return ""; }
1177
+ const current = this.d.board.get(task.id);
1178
+ if (!current || this.isPii(current) || by && this.d.privateTurn?.(by)
1179
+ || current.history.length !== task.history.length || current.owner !== task.owner || current.refs.repo !== task.refs.repo
1180
+ || JSON.stringify(current.refs.paths ?? []) !== JSON.stringify(task.refs.paths ?? [])) return "";
1181
+ if (!report) return "";
1182
+ const refs = report.specs.slice(0, 20).filter(spec => [spec.id, spec.path, spec.title].every(value => this.publicMetadataText(value)));
1183
+ const lines = refs.length ? [`Specs covering these paths: ${refs.map(spec => `${spec.id} (${spec.path})`).join(", ")}`] : [];
1184
+ if (report.errors) lines.push(`Spec coverage check reports ${report.errors} error${report.errors === 1 ? "" : "s"}; run ahub specs check.`);
1185
+ if (report.truncated || report.specs.length > 20 || refs.length < Math.min(report.specs.length, 20)) lines.push("Spec coverage list truncated; run ahub specs covers for the full list.");
1186
+ return sanitize(lines.join("\n"));
1187
+ }
1188
+
1189
+ specsRead(by: PeerId, action: "check" | "covers", rawPaths: unknown = []): Record<string, unknown> {
1190
+ if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
1191
+ if (!this.d.specsRead) throw new PreEffectToolRefusal("spec inspection unavailable");
1192
+ if (action !== "check" && action !== "covers") throw new PreEffectToolRefusal('hub_specs action must be "check" or "covers"');
1193
+ if (!Array.isArray(rawPaths) || rawPaths.length > 32 || rawPaths.some(path => typeof path !== "string" || !path.trim() || path.length > 2000 || /[\p{Cc}\p{Cf}]/u.test(path)
1194
+ || path.startsWith("/") || path.includes("\\") || path.split("/").includes("..") || !this.publicMetadataText(path))) throw new PreEffectToolRefusal("hub_specs paths must be bounded, public repository-relative paths using forward slashes");
1195
+ const paths = rawPaths as string[];
1196
+ if (action === "check" && paths.length) throw new PreEffectToolRefusal('hub_specs "check" takes no paths');
1197
+ if (action === "covers" && paths.length === 0) throw new PreEffectToolRefusal('hub_specs "covers" needs at least one repository-relative path');
1198
+ const safe = (value: string) => sanitize(value);
1199
+ const diagnostic = (raw: SpecsCheckReport["items"][number]): Record<string, unknown> | undefined => {
1200
+ const fields = [raw.code, raw.message, raw.cause, raw.nextSteps, ...(raw.path ? [raw.path] : []), ...(raw.id ? [raw.id] : []), ...(raw.field ? [raw.field] : []), ...(raw.relatedId ? [raw.relatedId] : [])];
1201
+ if (fields.some(value => !this.publicMetadataText(value))) return;
1202
+ return { severity: raw.severity, code: safe(raw.code), message: safe(raw.message), cause: safe(raw.cause), nextSteps: safe(raw.nextSteps),
1203
+ ...(raw.path ? { path: safe(raw.path) } : {}), ...(raw.id ? { id: safe(raw.id) } : {}), ...(raw.field ? { field: safe(raw.field) } : {}), ...(raw.relatedId ? { relatedId: safe(raw.relatedId) } : {}) };
1204
+ };
1205
+ let report: SpecsCheckReport | SpecsCoverReport;
1206
+ try { report = this.d.specsRead(action, paths); }
1207
+ catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
1208
+ if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
1209
+ try {
1210
+ if ("freshness" in report) {
1211
+ const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
1212
+ const items = visibleItems.slice(0, 20);
1213
+ const remaining = Math.max(0, 20 - items.length);
1214
+ const visibleFreshness = report.freshness.filter(row => this.publicMetadataText(row.path) && (row.id === undefined || this.publicMetadataText(row.id)));
1215
+ const freshness = visibleFreshness.slice(0, remaining)
1216
+ .map(row => ({ path: safe(row.path), ...(row.id ? { id: safe(row.id) } : {}), state: row.state, commitsSince: row.commitsSince, dirty: row.dirty }));
1217
+ return { specCount: report.specCount, errors: report.errors, warnings: report.warnings, stale: report.stale,
1218
+ truncated: report.truncated || visibleItems.length > 20 || visibleItems.length !== report.items.length
1219
+ || visibleFreshness.length > freshness.length || visibleFreshness.length !== report.freshness.length, freshness, items };
1220
+ }
1221
+ const visibleSpecs = report.specs.filter(row => [row.id, row.path, row.title].every(value => this.publicMetadataText(value)));
1222
+ const specs = visibleSpecs.slice(0, 20)
1223
+ .map(({ id, path, title }) => ({ id: safe(id), path: safe(path), title: safe(title) }));
1224
+ const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
1225
+ const remaining = Math.max(0, 20 - specs.length);
1226
+ const items = visibleItems.slice(0, remaining);
1227
+ return { specs, errors: report.errors, truncated: report.truncated || visibleSpecs.length > specs.length || visibleSpecs.length !== report.specs.length
1228
+ || visibleItems.length > remaining || visibleItems.length !== report.items.length, items };
1229
+ } catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
955
1230
  }
956
1231
 
957
1232
  private states(): Record<PeerId, PeerState> {
@@ -1064,6 +1339,38 @@ export class Tasks {
1064
1339
 
1065
1340
  private reviewHead(task: Task): string | undefined { return task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; }
1066
1341
 
1342
+ private recordedReviewHead(task: Task): string | undefined {
1343
+ const isFullHead = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
1344
+ const historyHead = task.history.findLast(entry => (entry.event === "approved" || entry.event === "changes_requested") && isFullHead(entry.reviewedHead))?.reviewedHead;
1345
+ return [task.sdlc?.reviewedHead, historyHead].find(isFullHead);
1346
+ }
1347
+
1348
+ /** An owner ref, when present, outranks host-derived receipt.head values for review identity. */
1349
+ private ownerReviewHead(task: Task): string | undefined {
1350
+ return /^[a-f0-9]{40}$/.test(task.refs.commit ?? "") ? task.refs.commit : this.recordedReviewHead(task);
1351
+ }
1352
+
1353
+ private hasReviewHistory(task: Task): boolean {
1354
+ return task.state === "changes_requested" || !!task.sdlc?.reviewedHead || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
1355
+ }
1356
+
1357
+ private reviewHeadMoveRefusal(taskId: number, recorded: string | undefined, current?: string): PreEffectToolRefusal {
1358
+ const known = recorded ? `recorded review head ${recorded}` : "a recorded full review head";
1359
+ const cause = current ? `${known} differs from current head of PR ${current}`
1360
+ : `current PR head could not be verified against ${known}`;
1361
+ const next = current ? `refs.commit set to ${current}` : "refs.commit set to the current full SHA";
1362
+ return new PreEffectToolRefusal(`task #${taskId}: ${cause}; owner must re-file hub_task_done with ${next} before review`);
1363
+ }
1364
+
1365
+ private async currentPrHeadForReview(task: Task, validate: () => Task): Promise<string | undefined> {
1366
+ const receipt = task.sdlc, gh = this.d.sdlc?.gh;
1367
+ if (this.isPii(task) || !receipt?.repo || !receipt.pr || !gh) return undefined;
1368
+ const identity = (value: Task) => JSON.stringify([value.id, value.state, value.owner, value.reviewer, value.history.length, this.isPii(value), value.refs, value.sdlc]);
1369
+ const before = identity(task), head = await readSdlcPrHead(receipt, gh), current = validate();
1370
+ if (identity(current) !== before) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
1371
+ return head;
1372
+ }
1373
+
1067
1374
  private reviewPoolOptions(task: Task) {
1068
1375
  const head = this.reviewHead(task);
1069
1376
  const round = task.state === "in_review" ? task.history.slice(task.history.findLastIndex(entry => entry.event === "done") + 1) : [];
@@ -1100,6 +1407,27 @@ export class Tasks {
1100
1407
  return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
1101
1408
  }
1102
1409
 
1410
+ /**
1411
+ * #1081: a task that declares `needs_network` or `needs_worktree` is refused at accept when its owner cannot meet the need, before any write. Routing
1412
+ * already keeps such a task away from that peer; this covers an offer made before the sandbox network was switched off, and it is what stops a
1413
+ * sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
1414
+ * not a refusal, and a PII task skips the check as `requireCapabilities` does.
1415
+ */
1416
+ private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals" | "class">>, peer: PeerId): void {
1417
+ if (!this.d.capabilities || this.isPii(task as Task)) return;
1418
+ // #1129: write-class work needs the hub's worktree whether or not it says so, because a hub-sandboxed owner cannot commit.
1419
+ const declaredWorktree = task.signals?.includes("needs_worktree") === true;
1420
+ const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = declaredWorktree || (task.class !== undefined && writesRepository(task.class));
1421
+ if (!wantsNetwork && !wantsWorktree) return;
1422
+ const caps = peerEntry(this.d.capabilities(), peer);
1423
+ if (wantsNetwork && caps?.network === false) {
1424
+ throw new PreEffectToolRefusal(`task #${task.id} needs the network and ${peer} has none: ${caps.networkReason ?? "its shell cannot reach the network"}; decline it so the hub offers it to a peer with network, or ask the person to enable it`);
1425
+ }
1426
+ if (wantsWorktree && caps?.worktree === false) {
1427
+ throw new PreEffectToolRefusal(`task #${task.id} ${declaredWorktree ? "needs its own git worktree" : "changes the repository, so the hub must make its git worktree and commit for it"} and ${peer} cannot create one: ${caps.worktreeReason ?? "it cannot create the task worktree"}; nothing was created, so no branch ref is left behind; decline it so the hub offers it to a peer that can, this will not change by waiting`);
1428
+ }
1429
+ }
1430
+
1103
1431
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
1104
1432
  private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
1105
1433
  requireLocalTaskPeer(peer);
@@ -1108,7 +1436,7 @@ export class Tasks {
1108
1436
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
1109
1437
  const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
1110
1438
  const admission = assign(draft, this.states(), this.d.routing(), { candidates: role === "owner" ? [peer] : [], ...(role === "owner" ? { namedOwner: peer } : { namedReviewer: peer }), ...this.weights(task.class) });
1111
- const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
1439
+ const missing = (admission.refusals ?? []).filter(row => row.peer === peer && row.role === role && (row.code === "tests" || row.code === "spec" || row.code === "network" || row.code === "worktree" || declaredExtra && role === "owner" && row.code === "owner-admission")).map(row => ` ${role} candidate ${peer}: skipped, ${row.reason}`);
1112
1440
  if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
1113
1441
  }
1114
1442
 
@@ -2188,6 +2516,7 @@ export class Tasks {
2188
2516
  /** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
2189
2517
  private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
2190
2518
  this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
2519
+ const now = this.d.now?.() ?? Date.now();
2191
2520
  const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
2192
2521
  const add = (planner: PeerId, peer?: PeerId, id?: number) => {
2193
2522
  if (!this.d.bus.isRostered(planner)) return;
@@ -2199,7 +2528,7 @@ export class Tasks {
2199
2528
  const released = new Set<PeerId>();
2200
2529
  for (const [peer, used] of before) if (this.wip(peer) < used) { released.add(peer); for (const planner of this.taskPlanner(task)) add(planner, peer); }
2201
2530
  if (approved) for (const card of this.d.board.list("backlog")) {
2202
- if (card.deps?.includes(task.id) && !this.waitsFor(card).length) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
2531
+ if (card.deps?.includes(task.id) && !this.waitsFor(card).length && !plannedStartPending(card, now)) for (const planner of this.taskPlanner(card)) add(planner, undefined, card.id);
2203
2532
  }
2204
2533
  for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
2205
2534
  const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
@@ -2217,7 +2546,7 @@ export class Tasks {
2217
2546
  }
2218
2547
  for (const id of change.ready) {
2219
2548
  const card = this.d.board.get(id);
2220
- if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length) lines.push(`backlog_ready: #${id}`);
2549
+ if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length && !plannedStartPending(card, now)) lines.push(`backlog_ready: #${id}`);
2221
2550
  else change.ready.delete(id);
2222
2551
  }
2223
2552
  for (const env of pending) this.d.bus.withdraw(env.id);
@@ -2231,8 +2560,9 @@ export class Tasks {
2231
2560
 
2232
2561
  /** 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. */
2233
2562
  private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
2563
+ const now = this.d.now?.() ?? Date.now();
2234
2564
  return this.d.board.list().filter(card => (pullable ? card.state === "backlog" : ["backlog", "proposed"].includes(card.state)) && !card.owner && card.reserved === peer
2235
- && (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)
2565
+ && (planner === undefined || this.taskPlanner(card).includes(planner)) && !plannedStartPending(card, now) && !(pullable && this.isPii(card)) && !card.pinned && !card.draft && taskCanPlan(card) && !this.waitsFor(card).length && !this.excluded(card).includes(peer)
2236
2566
  && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
2237
2567
  }
2238
2568
 
@@ -2264,10 +2594,15 @@ export class Tasks {
2264
2594
  if (!planner || !this.d.bus.isRostered(planner)) return;
2265
2595
  const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
2266
2596
  try {
2267
- await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => { if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began"); } });
2597
+ await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => {
2598
+ if (!this.autoPullAdmitted()) throw new PreEffectToolRefusal("auto pull is no longer admitted: tasks.auto_pull was turned off or a recovery, shutdown or storage hold began");
2599
+ const current = this.d.board.get(card.id);
2600
+ if (current && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("auto pull is waiting for the planned start");
2601
+ } });
2268
2602
  } catch (e) {
2269
2603
  // A guard refusal is the setting or a hold doing its job, not a failure to report.
2270
- if (!this.autoPullAdmitted()) return;
2604
+ const current = this.d.board.get(card.id);
2605
+ if (!this.autoPullAdmitted() || current && plannedStartPending(current, this.d.now?.() ?? Date.now())) return;
2271
2606
  this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
2272
2607
  }
2273
2608
  }
@@ -2982,7 +3317,7 @@ export class Tasks {
2982
3317
  return updated; // a failed readback may follow a durable release write
2983
3318
  }
2984
3319
 
2985
- 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> {
3320
+ async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; role?: unknown; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown; needs_network?: unknown; needs_worktree?: unknown; backlog?: unknown; draft?: unknown; goal?: unknown }, external?: GitHubSource, created?: () => void, fence?: () => string | undefined): Promise<Task> {
2986
3321
  const requestCheck = this.requestCheck(by, fence);
2987
3322
  requireLocalTaskPeer(input.owner);
2988
3323
  external = external ? { ...external } : undefined;
@@ -3026,6 +3361,12 @@ export class Tasks {
3026
3361
  const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
3027
3362
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
3028
3363
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
3364
+ // #1081: a declared need is a signal like urgent, so routing and every later capability check read the same field. Only an explicit true sets it.
3365
+ if (input.needs_network !== undefined && typeof input.needs_network !== "boolean") throw new PreEffectToolRefusal("needs_network must be true or false; omit it when the work needs no network");
3366
+ if (input.needs_network === true && !signals.includes("needs_network")) signals.push("needs_network");
3367
+ // #1081 (amended AC1): the same for a task worktree. Only an explicit true sets it.
3368
+ if (input.needs_worktree !== undefined && typeof input.needs_worktree !== "boolean") throw new PreEffectToolRefusal("needs_worktree must be true or false; omit it when the work needs no task worktree");
3369
+ if (input.needs_worktree === true && !signals.includes("needs_worktree")) signals.push("needs_worktree");
3029
3370
  // The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
3030
3371
  // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
3031
3372
  const screening = this.screenOn();
@@ -3599,6 +3940,7 @@ export class Tasks {
3599
3940
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
3600
3941
  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> {
3601
3942
  opts.current?.();
3943
+ if (by === HUB && !task.owner && ["backlog", "proposed"].includes(task.state) && plannedStartPending(task, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("planned start is in the future");
3602
3944
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
3603
3945
  const studyReason = this.studyDispatchReason(task, opts.namedOwner);
3604
3946
  if (studyReason) {
@@ -3616,6 +3958,7 @@ export class Tasks {
3616
3958
  if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
3617
3959
  return current ?? task;
3618
3960
  }
3961
+ if (by === HUB && !current.owner && ["backlog", "proposed"].includes(current.state) && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("planned start is in the future");
3619
3962
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
3620
3963
  if (gap) {
3621
3964
  this.requestIndependentPlanReview(current);
@@ -3673,7 +4016,9 @@ export class Tasks {
3673
4016
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
3674
4017
  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 } : {}) };
3675
4018
  opts.current?.();
3676
- this.studyDispatchCheck(this.d.board.get(task.id) ?? task, a.owner);
4019
+ const current = this.d.board.get(task.id) ?? task;
4020
+ if (by === HUB && !current.owner && ["backlog", "proposed"].includes(current.state) && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("planned start is in the future");
4021
+ this.studyDispatchCheck(current, a.owner);
3677
4022
  const next = opts.plannerPull
3678
4023
  ? 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)
3679
4024
  : 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);
@@ -3737,7 +4082,17 @@ export class Tasks {
3737
4082
  }
3738
4083
  if (stagedClaimGap) return next; // An explicit claim may reserve its owner, but no offer or acceptance bypasses review.
3739
4084
  this.studyDispatchCheck(next, by);
3740
- const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
4085
+ // #1129: a self-claim of write-class work is an accept too, so a hub-sandboxed claimant gets the hub's worktree here, before the write. When the hub cannot make one the claim stays an assignment, like the missing-spec case above: `hub_task_accept` starts it and refuses with the same cause.
4086
+ let undoWorktree: (() => void) | undefined;
4087
+ try { undoWorktree = this.hubWorktreeFor(next, by); }
4088
+ catch (error) {
4089
+ if (!(error instanceof PreEffectToolRefusal)) throw error;
4090
+ this.releaseNotice(`${error.message}; the claim stays assigned to ${by} and is not started`);
4091
+ return next;
4092
+ }
4093
+ let claimed: Task;
4094
+ try { claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" }); }
4095
+ catch (error) { undoWorktree?.(); throw error; }
3741
4096
  this.captureOwnerAcceptance(claimed);
3742
4097
  this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
3743
4098
  return claimed;
@@ -3773,7 +4128,8 @@ export class Tasks {
3773
4128
  /** A self-claim has no assignment envelope; its tool result carries the same bounded guidance. */
3774
4129
  claimRoleGuidance(task: Task, peer: PeerId): string {
3775
4130
  requireLocalTaskPeer(peer);
3776
- return task.owner === peer && task.state === "in_progress" ? this.roleGuidance(task, peer) : "";
4131
+ // #1129: a claim by a hub-sandboxed peer carries the worktree note as an accept does, since the claim result is all it reads.
4132
+ return task.owner === peer && task.state === "in_progress" ? [this.roleGuidance(task, peer), this.worktreeNote(task, peer)].filter(Boolean).join("\n") : "";
3777
4133
  }
3778
4134
 
3779
4135
  private roleGuidance(task: Task, peer: PeerId, review = false): string {
@@ -4038,6 +4394,12 @@ export class Tasks {
4038
4394
  if (!this.queuePressureAdmitted()) return;
4039
4395
  const task = this.d.board.get(stall.task);
4040
4396
  if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
4397
+ if (plannedStartPending(task, this.d.now?.() ?? Date.now())) {
4398
+ const scheduledOrigin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
4399
+ if (scheduledOrigin) this.d.board.recordStall({ ...scheduledOrigin, replanRevision: task.planningRevision ?? 0 });
4400
+ pass.retained = true;
4401
+ continue;
4402
+ }
4041
4403
  // A planning revision asks for fresh admission, never erases an automatic offer.
4042
4404
  const namedOwner = stall.cause.peer;
4043
4405
  const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
@@ -4180,6 +4542,7 @@ export class Tasks {
4180
4542
  const capacity = this.capacityBefore(task);
4181
4543
  this.ciWatchers.cancel(task.id); this.heldReviews.delete(task.id);
4182
4544
  const next = this.d.board.update(task.id, by, "cancelled", { state: "cancelled", reserved: null, cancelled }, cancelled.reason, { ...(withheld ? { withheld: true as const } : {}) }, revision);
4545
+ this.releaseHubWorktree(task, task.owner);
4183
4546
  this.writePlannerNotices(next, capacity);
4184
4547
  this.releaseNotice(`task #${task.id}: cancellation cohort cleanup unavailable`, () => this.cohorts.leave(task.id));
4185
4548
  this.offered.delete(task.id);
@@ -4203,6 +4566,7 @@ export class Tasks {
4203
4566
  }
4204
4567
 
4205
4568
  async releaseReady(): Promise<void> {
4569
+ const now = this.d.now?.() ?? Date.now();
4206
4570
  const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
4207
4571
  for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
4208
4572
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
@@ -4214,6 +4578,7 @@ export class Tasks {
4214
4578
  const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
4215
4579
  if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
4216
4580
  }
4581
+ if (plannedStartPending(t, now)) continue;
4217
4582
  const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
4218
4583
  if (!admission.owner) {
4219
4584
  // A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
@@ -4235,6 +4600,7 @@ export class Tasks {
4235
4600
  try {
4236
4601
  const t = this.d.board.get(stale.id);
4237
4602
  if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
4603
+ if (plannedStartPending(t, this.d.now?.() ?? Date.now())) return;
4238
4604
  const planned = this.plannerPullRecord(t)?.pull === true;
4239
4605
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
4240
4606
  if (planned && !selected) return;
@@ -4251,6 +4617,7 @@ export class Tasks {
4251
4617
  } catch (e) {
4252
4618
  // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
4253
4619
  // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
4620
+ if (e instanceof PreEffectToolRefusal && e.message === "planned start is in the future") return;
4254
4621
  if (e instanceof AssignmentUndeliveredError) undelivered = e;
4255
4622
  else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
4256
4623
  } finally {
@@ -4419,7 +4786,7 @@ export class Tasks {
4419
4786
  if (!result.owner) for (const refusal of result.refusals ?? []) if (refusal.role === "owner") blockers.push({ task: task.id, code: refusal.code === "instance" || refusal.code === "family" ? "policy" : refusal.code });
4420
4787
  if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
4421
4788
  }
4422
- const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
4789
+ const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`, now: this.d.now?.() ?? Date.now(),
4423
4790
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
4424
4791
  states, issues, reviewedIssues, acceptedIssues, blockers });
4425
4792
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
@@ -4819,8 +5186,8 @@ export class Tasks {
4819
5186
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
4820
5187
  if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
4821
5188
  let scope: CostPlan;
4822
- try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }); }
4823
- catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
5189
+ try { scope = cleanCostPlan({ ...task.plan, ...("review" in raw ? { review: raw.review } : {}), ...("ci" in raw ? { ci: raw.ci } : {}) }, { review: "review" in raw ? "" : "plan", ci: "ci" in raw ? "" : "plan" }); }
5190
+ catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
4824
5191
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
4825
5192
  if (!scope.review || !scope.ci) {
4826
5193
  if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
@@ -5331,10 +5698,18 @@ export class Tasks {
5331
5698
 
5332
5699
  /** The eval record the last approval-time lifecycle read produced for a task; consumed by the verdict that follows it. */
5333
5700
  private readonly evalRecords = new Map<number, string>();
5334
- private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[]): Promise<PrEvidence | undefined> {
5701
+ private async lifecycleGate(task: Task, transition: "review" | "approval", reviewedHead?: string, findings?: readonly ReviewFindingSet[], refileHead?: RefileHeadGuard): Promise<PrEvidence | undefined> {
5335
5702
  const config = this.d.sdlc?.config() ?? sdlcConfig(undefined);
5336
5703
  if (config.mode === "off") return;
5337
5704
  const receipt = task.sdlc && { ...task.sdlc, ...(reviewedHead ? { reviewedHead } : {}) };
5705
+ const approvalHead = transition === "approval" ? reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task) : undefined;
5706
+ const guardedHead = !!refileHead || !!approvalHead && !!receipt?.repo && !!receipt.pr && !this.isPii(task);
5707
+ // Compare raw head identity on both sides; `task` may carry an uncommitted identityLink.
5708
+ // Completion state/history races stay with completionCurrent's existing post-readback fence.
5709
+ const identity = (value: Task) => JSON.stringify([value.id, value.owner, value.reviewer, this.isPii(value),
5710
+ value.refs.repo, value.refs.commit, value.refs.branch, value.sdlc?.repo, value.sdlc?.pr,
5711
+ value.sdlc?.head, value.sdlc?.localHead, value.sdlc?.reviewedHead]);
5712
+ const beforeHeadRead = guardedHead ? identity(this.need(task.id, false, true)) : undefined;
5338
5713
  const deadline = performance.now() + 20_000;
5339
5714
  const read: SdlcGh = (args, timeout, cwd, cap) => {
5340
5715
  const current = this.need(task.id, false, true);
@@ -5343,6 +5718,16 @@ export class Tasks {
5343
5718
  return remaining > 0 ? (this.d.sdlc?.gh ?? sdlcGh)(args, Math.min(timeout ?? 10_000, remaining), cwd, cap) : Promise.resolve({ code: 1, stdout: "" });
5344
5719
  };
5345
5720
  const evidence = receipt && !this.isPii(task) ? await readSdlcPr(receipt, read, true) : undefined;
5721
+ const currentHead = receipt && !this.isPii(task) && (refileHead || approvalHead) && !evidence?.head
5722
+ ? await readSdlcPrHead(receipt, read) : evidence?.head;
5723
+ if (beforeHeadRead !== undefined) {
5724
+ const current = this.need(task.id, false, true);
5725
+ if (identity(current) !== beforeHeadRead) throw new PreEffectToolRefusal(`task #${task.id}: task or review head changed during PR readback; reload and retry`);
5726
+ }
5727
+ if (refileHead) {
5728
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
5729
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
5730
+ } else if (approvalHead && currentHead && currentHead !== approvalHead) throw this.reviewHeadMoveRefusal(task.id, approvalHead, currentHead);
5346
5731
  const integrity = publicTestIntegrity(receipt && evidence && !this.isPii(task) ? await readTestIntegrity(receipt, evidence, read) : this.unknownTestIntegrity(this.isPii(task)), this.publicMetadataText);
5347
5732
  const evalResult = receipt && !this.isPii(task) ? await readEvalFor(config, evidence, receipt.repo, read) : undefined;
5348
5733
  const current = this.need(task.id, false, true);
@@ -5391,7 +5776,7 @@ export class Tasks {
5391
5776
  const metadata = acceptRefs(refs);
5392
5777
  const task = this.need(id, false, true);
5393
5778
  this.mine(task, by, "owner", true);
5394
- this.acceptanceCapacity(task);
5779
+ this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
5395
5780
  if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
5396
5781
  const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
5397
5782
  this.warmRepository(draft);
@@ -5420,7 +5805,52 @@ export class Tasks {
5420
5805
  if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
5421
5806
  }
5422
5807
 
5423
- private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history">>, excludePending = 0): void {
5808
+ /**
5809
+ * What counts toward `owner`'s WIP, split for a refusal and for #1080: `offered` is work handed to the owner that it has not accepted
5810
+ * (state proposed), `reserved` is ownerless planner-pulled work held for it, and `accepted` is everything the owner is actually carrying
5811
+ * (in progress, changes requested, in review).
5812
+ */
5813
+ private wipSplit(owner: PeerId, except: number): { accepted: Task[]; offered: Task[]; reserved: Task[] } {
5814
+ const open = this.d.board.list().filter(other => other.id !== except && !["backlog", "approved", "cancelled"].includes(other.state)
5815
+ && (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull));
5816
+ return {
5817
+ accepted: open.filter(other => other.owner === owner && other.state !== "proposed"),
5818
+ offered: open.filter(other => other.owner === owner && other.state === "proposed"),
5819
+ reserved: open.filter(other => !other.owner),
5820
+ };
5821
+ }
5822
+ private static isUrgent(task: Pick<Task, "priority" | "signals">): boolean {
5823
+ return (task.priority ?? (task.signals?.includes("urgent") ? 0 : 2)) === 0;
5824
+ }
5825
+ /** #1080: an offer an urgent accept may take back. Never accepted or started work, never another urgent task, never person-pinned work. */
5826
+ private displaceable(offer: Task): boolean {
5827
+ return offer.state === "proposed" && !!offer.owner && !offer.pinned && !Tasks.isUrgent(offer) && !taskHasStarted(offer);
5828
+ }
5829
+ /**
5830
+ * #1080: the owner is at its limit only because of offers it never accepted, so an urgent task takes the slot. The newest offers go back to
5831
+ * routing (ownerless, in To Do) and the planners are told. Called only from `accept`, immediately before its write.
5832
+ */
5833
+ private displaceOffers(urgent: Task, owner: PeerId, offers: readonly Task[]): void {
5834
+ for (const offer of offers) {
5835
+ // A planner-pulled offer also holds a counted reservation (`plannerWip` counts an ownerless proposed task reserved for the owner with pull history), so clearing the owner alone frees no slot. `decline` clears it the same way.
5836
+ const pulled = this.plannerPullRecord(offer)?.pull === true;
5837
+ const next = this.d.board.update(offer.id, HUB, "unassigned", { owner: null, reviewer: null, ...(pulled ? { reserved: null } : {}) }, `displaced by urgent task #${urgent.id}: ${owner} was at its WIP limit with this offer unaccepted`, {}, offer.history.length);
5838
+ this.offered.delete(offer.id);
5839
+ this.releaseNotice(`task #${offer.id}: displacement cohort cleanup unavailable`, () => this.cohorts.leave(offer.id));
5840
+ this.withdrawRoleRequest(offer, next, "owner");
5841
+ this.withdrawRoleRequest(offer, next, "reviewer");
5842
+ this.releaseNotice(`task #${offer.id}: displacement notice unavailable`, () => {
5843
+ const body = `Task #${offer.id} was taken back from ${owner}: urgent task #${urgent.id} (priority 0 or marked urgent) needed ${owner}'s WIP slot and #${offer.id} was only offered, never accepted. It is ownerless in To Do and routes again; plan it again if it should wait for ${owner}.`;
5844
+ this.d.notify(body);
5845
+ for (const planner of this.taskPlanner(offer)) this.d.bus.publish(newEnvelope(HUB, sanitize(body), { to: [planner], kind: "task", refs: { task: String(offer.id) }, ...(this.isPii(offer) ? { private: true } : {}) }));
5846
+ });
5847
+ }
5848
+ }
5849
+ /**
5850
+ * `urgent`: "refuse" (default) never displaces; "check" only tells whether an urgent accept could (a pre-check before a later write); "displace"
5851
+ * performs it. Only `accept` displaces.
5852
+ */
5853
+ private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history" | "priority" | "signals">>, excludePending = 0, urgent: "refuse" | "check" | "displace" = "refuse"): void {
5424
5854
  const owner = task.owner ?? task.reserved;
5425
5855
  if (!owner) return;
5426
5856
  if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
@@ -5434,10 +5864,30 @@ export class Tasks {
5434
5864
  if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
5435
5865
  }
5436
5866
  const limit = this.wipLimit(owner), pending = Math.max(0, (this.pendingClaims.get(owner) ?? 0) - excludePending);
5437
- if (this.wip(owner, task.id) + pending < limit) return;
5438
- const open = this.d.board.list().filter(other => other.id !== task.id && !["backlog", "approved", "cancelled"].includes(other.state)
5439
- && (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull)).map(other => `#${other.id}`);
5440
- throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${open.join(", ") || "none"}${pending ? `; pending claims ${pending}` : ""}`);
5867
+ const used = this.wip(owner, task.id) + pending;
5868
+ if (used < limit) return;
5869
+ const split = this.wipSplit(owner, task.id), ids = (rows: readonly Task[]) => rows.map(row => `#${row.id}`).join(", ") || "none";
5870
+ // #1080: an urgent task that is the owner's own offer may take back only as many never-accepted offers as it needs, newest first. Anything else refuses.
5871
+ if (urgent !== "refuse" && task.state === "proposed" && task.owner === owner && Tasks.isUrgent(task as Pick<Task, "priority" | "signals">)) {
5872
+ const need = used + 1 - limit;
5873
+ const takeable = split.offered.filter(offer => this.displaceable(offer))
5874
+ .sort((a, b) => handedAt(b) - handedAt(a) || b.id - a.id);
5875
+ if (need > 0 && takeable.length >= need) {
5876
+ if (urgent === "displace") {
5877
+ this.displaceOffers(task as Task, owner, takeable.slice(0, need));
5878
+ // Defense in depth (#1080): no displacement path may leave the owner over its limit. Clearing the reservation above makes this unreachable today; if it ever fires, the offers are already back in To Do and only the accept is refused.
5879
+ const after = this.wip(owner, task.id) + pending;
5880
+ if (after >= limit) throw new Error(`planner WIP capacity for ${owner}: limit ${limit}; the offers taken back for urgent task #${task.id} are back in To Do but did not free a slot (WIP is now ${after}), so it stays offered; ask the planner to check ${owner}'s reservations, then accept it again`);
5881
+ }
5882
+ return;
5883
+ }
5884
+ }
5885
+ const open = [...split.accepted, ...split.offered, ...split.reserved].sort((a, b) => a.id - b.id);
5886
+ const parts = [`accepted ${ids(split.accepted)}`, `offered, not yet accepted ${ids(split.offered)}`, ...(split.reserved.length ? [`reserved, not yet offered ${ids(split.reserved)}`] : [])];
5887
+ const next = split.accepted.length >= limit
5888
+ ? `finish or hand back an accepted task first`
5889
+ : `decline an offered task you will not take, or finish an accepted one; an urgent task may take back an offer you have not accepted`;
5890
+ throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
5441
5891
  }
5442
5892
 
5443
5893
  /**
@@ -5452,7 +5902,8 @@ export class Tasks {
5452
5902
  this.ready(task, true);
5453
5903
  if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
5454
5904
  this.studyDispatchCheck(task, task.owner ?? undefined);
5455
- this.acceptanceCapacity(task);
5905
+ this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
5906
+ this.acceptanceCapacity(task, 0, "check");
5456
5907
  // S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
5457
5908
  if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
5458
5909
  if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
@@ -5480,7 +5931,13 @@ export class Tasks {
5480
5931
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
5481
5932
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
5482
5933
  this.studyDispatchCheck(task, task.owner ?? undefined);
5483
- const next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
5934
+ // #1115: a hub-sandboxed peer cannot run `git worktree add`, so for work that declares a worktree the hub makes it here, before any write. Anything that throws below clears it again.
5935
+ const undoWorktree = this.hubWorktreeFor(task, task.owner ?? by);
5936
+ let next: Task;
5937
+ try {
5938
+ this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
5939
+ next = this.linkOnWrite(this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) })); // #723: refs.repo may have just arrived
5940
+ } catch (error) { undoWorktree?.(); throw error; }
5484
5941
  this.captureOwnerAcceptance(next);
5485
5942
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
5486
5943
  if (repositoryChanged) this.cohorts.leave(next.id);
@@ -5541,6 +5998,51 @@ export class Tasks {
5541
5998
  /** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
5542
5999
  checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
5543
6000
 
6001
+ /**
6002
+ * #1115, #1129: the hub makes the worktree of a hub-sandboxed owner for write-class work (`implement`, `bulk_edit`) and for any work that declares `needs_worktree`. Returns a function that clears it again, or
6003
+ * undefined when there is nothing to clear (another peer, a read-only class that did not declare it, a PII task, or a worktree an earlier accept already made). A failure refuses the
6004
+ * accept before any board write and names the cause and the next step; nothing is left behind.
6005
+ */
6006
+ private hubWorktreeFor(task: Task, peer: PeerId): (() => void) | undefined {
6007
+ const port = this.d.taskWorktree;
6008
+ if (!port || peer === USER || peer === HUB || !port.applies(peer) || this.isPii(task) || !(task.signals.includes("needs_worktree") || writesRepository(task.class))) return undefined;
6009
+ const made = port.create({ peer, task: task.id });
6010
+ if (!made.ok) throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree and the hub could not make one for ${peer}: ${made.reason}; nothing was created, so no branch ref is left behind. Decline the task so the hub offers it to a peer that makes its own worktree`);
6011
+ return made.created ? () => { try { port.remove({ peer, task: task.id }); } catch { /* the accept error that got us here is the one reported */ } } : undefined;
6012
+ }
6013
+
6014
+ /** The hub-made worktree of the task's owner, for the accept answer: where to edit, and what the hub does at `hub_task_done`. */
6015
+ worktreeNote(task: Task, peer: PeerId): string | undefined {
6016
+ const found = this.d.taskWorktree?.describe(peer, task.id);
6017
+ return found ? `Worktree: the hub made ${found.path} on branch ${found.branch} for this task. Edit files there only; do not run git. At hub_task_done the hub stages, commits and pushes it for you.` : undefined;
6018
+ }
6019
+
6020
+ /**
6021
+ * #1115: at `hub_task_done` the hub commits and pushes a hub-sandboxed peer's worktree and answers the refs the done carries. Undefined when the peer made no
6022
+ * hub worktree for this task (it commits itself). A refused commit or push is a pre-effect refusal that names the cause and the next step: the board is untouched.
6023
+ */
6024
+ async worktreeHandoff(by: PeerId, id: unknown, refs: unknown): Promise<TaskRefs | undefined> {
6025
+ const port = this.d.taskWorktree;
6026
+ if (!port || by === USER || by === HUB || !port.applies(by)) return undefined;
6027
+ const task = this.need(id, false, true);
6028
+ this.mine(task, by, "owner", true);
6029
+ if (this.isPii(task) || !port.describe(by, task.id)) return undefined;
6030
+ const result = await port.commitAndPush({ peer: by, task: task.id, title: task.title });
6031
+ if (!result.ok) throw new PreEffectToolRefusal(`hub_task_done for task #${task.id}: ${result.reason}`);
6032
+ if (result.note) this.doneNotes.set(task.id, result.note);
6033
+ return { ...cleanRefs(refs), commit: result.commit, branch: result.branch };
6034
+ }
6035
+
6036
+ /** Decline or cancel: clear the hub-made worktree and its empty branch. Uncommitted work is kept and reported, never force-removed. */
6037
+ private releaseHubWorktree(task: Task, peer: PeerId | null | undefined): void {
6038
+ const port = this.d.taskWorktree;
6039
+ if (!port || !peer || peer === USER || peer === HUB || !port.applies(peer)) return;
6040
+ this.releaseNotice(`task #${task.id}: the hub-made worktree could not be cleared`, () => {
6041
+ const removal = port.remove({ peer, task: task.id });
6042
+ if (removal.kept) this.d.notify(`task #${task.id}: ${removal.kept}`);
6043
+ });
6044
+ }
6045
+
5544
6046
  /**
5545
6047
  * `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
5546
6048
  * (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
@@ -5555,6 +6057,7 @@ export class Tasks {
5555
6057
  const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
5556
6058
  if (planned) this.offered.delete(task.id);
5557
6059
  this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
6060
+ this.releaseHubWorktree(task, task.owner ?? by);
5558
6061
  // A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
5559
6062
  try {
5560
6063
  return await this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
@@ -5731,18 +6234,27 @@ export class Tasks {
5731
6234
  if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
5732
6235
  }
5733
6236
 
5734
- async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
6237
+ async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown): Promise<Task> {
5735
6238
  const requestCheck = this.requestCheck(by);
5736
6239
  const initial = this.need(id, false, true);
5737
6240
  this.mine(initial, by, "owner", true);
5738
6241
  let refresh: CompletionRefresh | undefined;
5739
6242
  if (initial.state === "in_review" || initial.state === "approved") {
5740
6243
  const commit = cleanRefs(refs).commit;
5741
- if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === this.reviewHead(initial))
5742
- throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}; re-file requires a different full head SHA`);
6244
+ const recorded = this.ownerReviewHead(initial) ?? this.findingHead(initial);
6245
+ const currentReviewHead = this.ownerReviewHead(initial) ?? this.reviewHead(initial);
6246
+ if (!commit || !/^[a-f0-9]{40}$/.test(commit) || commit === currentReviewHead) {
6247
+ throw new PreEffectToolRefusal(`task #${initial.id} is already ${initial.state}${recorded ? ` with recorded review head ${recorded}` : ""}; re-file requires a different full head SHA. Owner must re-file hub_task_done with refs.commit set to the new full SHA`);
6248
+ }
5743
6249
  refresh = { revision: initial.history.length, commit, state: initial.state };
5744
6250
  }
5745
6251
  const submittedCommit = cleanRefs(refs).commit;
6252
+ if (submittedCommit && this.hasReviewHistory(initial) && !/^[a-f0-9]{40}$/.test(submittedCommit)) {
6253
+ const recorded = this.ownerReviewHead(initial);
6254
+ throw new PreEffectToolRefusal(`task #${initial.id}: re-file after review needs refs.commit to be a full lowercase SHA${recorded ? ` (recorded review head ${recorded})` : ""}; owner must re-file hub_task_done with the new full SHA in refs.commit`);
6255
+ }
6256
+ const refileHead: RefileHeadGuard | undefined = !submittedCommit && initial.sdlc?.repo && initial.sdlc.pr && this.hasReviewHistory(initial) && !this.isPii(initial)
6257
+ ? { recorded: this.ownerReviewHead(initial) } : undefined;
5746
6258
  const draft = refresh ?? (submittedCommit && /^[a-f0-9]{40}$/.test(submittedCommit) ? { commit: submittedCommit } : undefined);
5747
6259
  const valid = () => { requestCheck(); return this.completionDraft(this.completionCurrent(id, by, true, refresh), draft); };
5748
6260
  if (initial.state === "approved") { await this.unmergedForReopen(initial, valid); valid(); }
@@ -5766,9 +6278,19 @@ export class Tasks {
5766
6278
  const current = () => this.withLink(valid(), link, refs);
5767
6279
  task = current();
5768
6280
  let reviewEvidence: PrEvidence | undefined;
5769
- if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
5770
- const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer) : undefined;
6281
+ if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review", undefined, undefined, refileHead); task = current(); }
6282
+ const reviewAdmission = task.reviewer || this.d.prepareReviewBrief ? await this.reviewRequestAdmission(task, refs, true, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !task.reviewer, refileHead) : undefined;
5771
6283
  task = current();
6284
+ if (refileHead && (this.d.sdlc?.config().mode ?? "off") === "off") {
6285
+ const currentHead = await this.currentPrHeadForReview(task, current);
6286
+ task = current();
6287
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
6288
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
6289
+ }
6290
+ const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
6291
+ const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
6292
+ task = current(); // the host head and task authority are revalidated after spec inspection
6293
+ this.assertSpecInspection(specInspection, { ...task, refs: { ...task.refs, ...cleanRefs(refs) } }, by);
5772
6294
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
5773
6295
  const cohort = this.cohorts.of(task.id);
5774
6296
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -5799,19 +6321,23 @@ export class Tasks {
5799
6321
  }
5800
6322
  if (task.state === "proposed" || task.state === "changes_requested") task = this.withLink(this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE), link, refs); // done without a separate accept
5801
6323
  const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
5802
- if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh);
6324
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection);
5803
6325
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
5804
6326
  if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
5805
6327
  this.checking.set(task.id, task.owner);
5806
6328
  const checkedRefs = cleanRefs(refs);
6329
+ if (specInspection.report?.head && !checkedRefs.commit) checkedRefs.commit = specInspection.report.head;
6330
+ if (specInspection.report && specInspection.repo && !checkedRefs.repo) checkedRefs.repo = specInspection.repo;
6331
+ const storedRefs = { ...task.refs, ...checkedRefs };
5807
6332
  this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
5808
- const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), ...(refresh?.state === "approved" && !task.reviewer ? { reviewer: USER } : {}), codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...checkedRefs } }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
6333
+ this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
6334
+ const pending = this.d.board.update(task.id, by, "done (checking)", { ...(refresh ? { state: "in_progress" as const } : {}), ...(refresh?.state === "approved" && !task.reviewer ? { reviewer: USER } : {}), codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), refs: checkedRefs, ...(task.sdlc ? { sdlc: task.sdlc } : {}) }, summary, { withheld: withheld || undefined, ...(records ? { deliverables: records } : {}), ...(specInspection.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
5809
6335
  if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
5810
6336
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
5811
6337
  const seen = { events: pending.history.length, owner: pending.owner };
5812
6338
  this.pendingChecks++;
5813
6339
  this.checkQueue = this.checkQueue
5814
- .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
6340
+ .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection))
5815
6341
  .catch((e: Error) => {
5816
6342
  this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
5817
6343
  this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
@@ -5914,7 +6440,7 @@ export class Tasks {
5914
6440
  return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
5915
6441
  }
5916
6442
 
5917
- private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
6443
+ private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean, specInspection: PreparedSpecInspection): Promise<void> {
5918
6444
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
5919
6445
  this.checking.delete(id);
5920
6446
  const task = this.d.board.get(id);
@@ -5930,6 +6456,7 @@ export class Tasks {
5930
6456
  return;
5931
6457
  }
5932
6458
  if (result.code === 0 && !result.timedOut) {
6459
+ const refileForSpecs = this.refreshAsyncSpecInspection(specInspection, task, by);
5933
6460
  // An integrating member's check counts only for the target it confirmed (issue #107).
5934
6461
  const cohort = this.cohorts.of(id);
5935
6462
  if (cohort?.silent && !this.cohorts.holds(id, ownerGen(task), this.tree(cohort))) {
@@ -5938,8 +6465,9 @@ export class Tasks {
5938
6465
  return;
5939
6466
  }
5940
6467
  this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
6468
+ if (refileForSpecs) { this.requestSpecRefile(task); return; }
5941
6469
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
5942
- await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
6470
+ await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld, undefined, undefined, undefined, undefined, undefined, undefined, specInspection, true);
5943
6471
  return;
5944
6472
  }
5945
6473
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
@@ -5951,8 +6479,9 @@ export class Tasks {
5951
6479
  }
5952
6480
 
5953
6481
  /** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
5954
- private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh): Promise<Task> {
6482
+ private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review", admission?: ReviewReadiness, link?: IdentityLink, records?: DeliverableRecord[], requestCheck: () => void = () => {}, refresh?: CompletionRefresh, specInspection?: PreparedSpecInspection, asyncCheckCompletion = false): Promise<Task> {
5955
6483
  requestCheck();
6484
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5956
6485
  // A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
5957
6486
  records ??= latestPendingDeliverables(task);
5958
6487
  const submittedCommit = cleanRefs(refs).commit;
@@ -5964,6 +6493,7 @@ export class Tasks {
5964
6493
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
5965
6494
  requestCheck();
5966
6495
  task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
6496
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5967
6497
  selection = this.poolReview(task, refs);
5968
6498
  reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
5969
6499
  }
@@ -5972,6 +6502,7 @@ export class Tasks {
5972
6502
  admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !reviewer);
5973
6503
  requestCheck();
5974
6504
  task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
6505
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5975
6506
  }
5976
6507
  selection = this.poolReview(task, refs);
5977
6508
  reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
@@ -5981,12 +6512,21 @@ export class Tasks {
5981
6512
  if (refresh?.state === "approved") {
5982
6513
  await this.unmergedForReopen(task, () => { requestCheck(); return this.completionCurrent(task.id, by, true, refresh); });
5983
6514
  task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, true, refresh), draft), link, refs);
6515
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5984
6516
  }
5985
6517
  const completedRefs = cleanRefs(refs);
5986
- this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
5987
- const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
6518
+ if (specInspection?.report?.head && !completedRefs.commit) completedRefs.commit = specInspection.report.head;
6519
+ if (specInspection?.report && specInspection.repo && !completedRefs.repo) completedRefs.repo = specInspection.repo;
6520
+ const storedRefs = { ...task.refs, ...completedRefs };
6521
+ if (specInspection) {
6522
+ if (asyncCheckCompletion) {
6523
+ if (this.refreshAsyncSpecInspection(specInspection, { ...task, refs: storedRefs }, by)) { this.requestSpecRefile(task); return task; }
6524
+ } else this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
6525
+ }
6526
+ this.warmRepository({ ...task, refs: storedRefs });
6527
+ const noWrites = this.noWritesRecorded({ ...task, refs: storedRefs });
5988
6528
  const capacity = this.capacityBefore(task);
5989
- const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: { ...task.refs, ...completedRefs } }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: { ...task.refs, ...completedRefs } }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
6529
+ const next = this.d.board.update(task.id, by, "done", { codeSymbols: this.observedCodeSymbols({ ...task, refs: storedRefs }), state: reviewer ? "in_review" : "approved", ...(selection || refresh?.state === "approved" ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { ...this.planObservation(task), withheld: withheld || undefined, ...(specInspection?.report ? { specReport: specInspection.report, specRefBinding: this.specRefBinding({ refs: storedRefs }) } : {}), ...(noWrites ? { noWrites: true as const } : {}), ...this.reviewMetadata(selection, { ...task, refs: storedRefs }), ...(reviewCost ? { reviewCost } : {}), ...(records ? { deliverables: records } : {}) }, task.history.length, HOST_CODE_SYMBOLS);
5990
6530
  this.queueReviewWritten(next);
5991
6531
  this.writePlannerNotices(next, capacity, next.state === "approved");
5992
6532
  this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
@@ -6101,7 +6641,7 @@ export class Tasks {
6101
6641
 
6102
6642
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
6103
6643
  /** Review admission is separate from brief construction (#228). All refusals precede the requesting done's writes. */
6104
- private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false): Promise<ReviewReadiness | undefined> {
6644
+ private async reviewRequestAdmission(task: Task, refs?: TaskRefs, preEffect = true, actor: PeerId = task.owner ?? USER, previous?: PrEvidence, sameRequest?: () => boolean, stage: (task: Task) => Task = task => task, prepareOnly = false, refileHead?: RefileHeadGuard): Promise<ReviewReadiness | undefined> {
6105
6645
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
6106
6646
  if (mode === "off" && !this.d.prepareReviewBrief) return undefined;
6107
6647
  let quietDrift = false;
@@ -6138,8 +6678,13 @@ export class Tasks {
6138
6678
  const evidence: ReviewReadiness = this.isPii(task)
6139
6679
  ? { ready: false, blockers: ["private review readiness unavailable"] }
6140
6680
  : task.sdlc ? reviewReadinessFromEvidence(previous, expected, this.d.sdlc?.config().requiredChecks ?? []) ?? await readReviewReadiness(task.sdlc, expected, read, this.d.sdlc?.config().requiredChecks ?? []) : { ready: false, blockers: ["review PR identity missing"] };
6681
+ const currentHead = refileHead && !evidence.head && task.sdlc ? await readSdlcPrHead(task.sdlc, read) : evidence.head;
6141
6682
  const current = guard();
6142
6683
  const admitted: ReviewReadiness = quietDrift ? { ready: false, blockers: ["review readiness unknown: task changed during readback"] } : evidence;
6684
+ if (refileHead) {
6685
+ if (!refileHead.recorded || !currentHead) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded);
6686
+ if (currentHead !== refileHead.recorded) throw this.reviewHeadMoveRefusal(task.id, refileHead.recorded, currentHead);
6687
+ }
6143
6688
  if (!admitted.ready) {
6144
6689
  const message = `task ${this.publicTitle(current)}: premature review: ${admitted.blockers.join("; ")}`;
6145
6690
  if (mode === "enforce") throw new (preEffect ? PreEffectToolRefusal : Error)(message);
@@ -6417,17 +6962,19 @@ export class Tasks {
6417
6962
  briefText(task: Task): string {
6418
6963
  if (this.isPii(task)) return "";
6419
6964
  const approval = approvalText(approvalBinding(task));
6420
- const prefix = (brief: string) => [approval, brief].filter(Boolean).join("\n");
6421
- if (!task.refs.commit) return approval;
6965
+ const spec = this.currentSpecReport(task);
6966
+ const prefix = (brief: string) => [approval, spec ? renderSpecTaskReport(spec) : "", brief].filter(Boolean).join("\n");
6967
+ if (!task.refs.commit) return prefix("");
6422
6968
  try {
6423
6969
  const brief = this.d.reviewBrief?.(task);
6424
- if (!brief) return approval;
6970
+ if (!brief) return prefix("");
6425
6971
  return prefix("unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief)));
6426
6972
  } catch { return prefix("Review brief unavailable: it could not be built."); }
6427
6973
  }
6428
6974
 
6429
6975
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
6430
6976
  const requestCheck = this.requestCheck(by);
6977
+ if (typeof id !== "number" && !(typeof id === "string" && /^\s*\d+\s*$/.test(id))) this.need(id, false, true);
6431
6978
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
6432
6979
  let findingGuard: ((task: Task) => void) | undefined;
6433
6980
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the
@@ -6471,7 +7018,7 @@ export class Tasks {
6471
7018
  // notice. The rule is structural (a finding with disposition open), never a reading of the unmet text.
6472
7019
  const codeScope = by !== USER && this.d.reviewScope?.(by) === "code";
6473
7020
  const evidenceOnly = codeScope && verdict === "changes_requested" && !parsed.some(finding => finding.disposition === "open");
6474
- const resolvedHead = this.findingHead(initial);
7021
+ const resolvedHead = this.ownerReviewHead(initial) ?? this.findingHead(initial);
6475
7022
  let findingHead = resolvedHead;
6476
7023
  if (head !== undefined) {
6477
7024
  try { findingHead = sdlcReceipt({ reviewedHead: head }, initial.sdlc, this.d.sdlc?.config() ?? sdlcConfig(undefined)).reviewedHead; }
@@ -6518,7 +7065,7 @@ export class Tasks {
6518
7065
  if (gap.length) note = `${note ?? ""}\nCoverage gap (${gap.length}): ${list(gap)}`.trim();
6519
7066
  }
6520
7067
  }
6521
- if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal("supplied review head differs from the task resolved head");
7068
+ if (head !== undefined && resolvedHead && findingHead !== resolvedHead) throw new PreEffectToolRefusal(`task #${initial.id}: supplied review head ${findingHead} differs from recorded task head ${resolvedHead}; owner must re-file hub_task_done with refs.commit set to the supplied full SHA before review`);
6522
7069
  // The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
6523
7070
  const withheld = await this.withholds("review note", parsed.length ? JSON.stringify({ note, findings: parsed }) : note, valid())
6524
7071
  || parsed.length > 0 && !this.findingTextAllowed(initial, parsed);
@@ -6555,6 +7102,12 @@ export class Tasks {
6555
7102
  await this.lifecycleGate(task, "approval", reviewedHead, proposedSets(task)); task = valid();
6556
7103
  const record = this.evalRecords.get(task.id); this.evalRecords.delete(task.id);
6557
7104
  if (record) note = `${note ?? ""}\n${record}`.trim();
7105
+ } else if (verdict === "approved" && this.d.sdlc?.config().mode === "off" && this.d.sdlc?.gh && task.sdlc?.repo && task.sdlc.pr
7106
+ && this.ownerReviewHead(task) && !this.isPii(task)) {
7107
+ const expected = reviewedHead ?? this.ownerReviewHead(task) ?? this.findingHead(task);
7108
+ const currentHead = await this.currentPrHeadForReview(task, valid);
7109
+ task = valid();
7110
+ if (expected && currentHead && currentHead !== expected) throw this.reviewHeadMoveRefusal(task.id, expected, currentHead);
6558
7111
  }
6559
7112
  let candidate: Extract<CandidateInput, { kind: "fail" }> | undefined;
6560
7113
  if (verdict === "changes_requested" && !evidenceOnly && items.length && this.d.candidates && !withheld && !this.isPii(task) && !this.d.privateTurn?.(by)) {