@staix/agent-hub 0.26.0 → 0.28.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 (101) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +13 -4
  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 +8 -4
  6. package/docs/agent-notes/tasks.md +7 -2
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +19 -2
  14. package/docs/operations.md +337 -15
  15. package/docs/profiles.md +2 -0
  16. package/docs/quickstart.md +15 -3
  17. package/docs/sdlc.md +4 -4
  18. package/docs/security.md +9 -4
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
  21. package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  24. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  25. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  26. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  27. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  28. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  30. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  31. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  32. package/docs/verified.json +13 -13
  33. package/package.json +2 -2
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +344 -116
  36. package/src/adapters/claude-channel.ts +12 -2
  37. package/src/adapters/compat.ts +1 -1
  38. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  40. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  41. package/src/adapters/jcode.ts +37 -4
  42. package/src/adapters/qwen-dual.ts +3 -2
  43. package/src/cli/agents.ts +4 -2
  44. package/src/cli/bench.ts +4 -2
  45. package/src/cli/board-read.ts +25 -0
  46. package/src/cli/claude-homes.ts +2 -2
  47. package/src/cli/console-state.ts +8 -3
  48. package/src/cli/help.ts +8 -6
  49. package/src/cli/identity.ts +14 -6
  50. package/src/cli/init-prompt.ts +179 -0
  51. package/src/cli/init-settings.ts +229 -0
  52. package/src/cli/init.ts +102 -9
  53. package/src/cli/launch.ts +3 -1
  54. package/src/cli/main.ts +112 -36
  55. package/src/cli/output.ts +14 -0
  56. package/src/cli/setup-homes.ts +18 -3
  57. package/src/cli/specs.ts +150 -0
  58. package/src/cli/study-output.ts +56 -0
  59. package/src/cli/study.ts +26 -3
  60. package/src/cli/terminal-recovery.ts +35 -11
  61. package/src/hub/action-policy.ts +5 -3
  62. package/src/hub/board.ts +56 -3
  63. package/src/hub/bus.ts +54 -7
  64. package/src/hub/child-process.ts +6 -4
  65. package/src/hub/config-apply.ts +4 -1
  66. package/src/hub/config-edit.ts +1 -1
  67. package/src/hub/config-tier.ts +1 -0
  68. package/src/hub/control-client.ts +28 -4
  69. package/src/hub/cost-governance.ts +22 -6
  70. package/src/hub/crash.ts +20 -0
  71. package/src/hub/daemon.ts +324 -64
  72. package/src/hub/delivery-hints.ts +6 -3
  73. package/src/hub/delivery-journal.ts +14 -3
  74. package/src/hub/events.ts +2 -1
  75. package/src/hub/hub-tools.ts +15 -9
  76. package/src/hub/no-writes.ts +1 -1
  77. package/src/hub/peers.ts +1 -1
  78. package/src/hub/project-runtime-state.ts +38 -13
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +7 -3
  81. package/src/hub/sdlc-release-authorization.ts +11 -5
  82. package/src/hub/session-identity.ts +1 -1
  83. package/src/hub/settings.ts +2 -0
  84. package/src/hub/specs-config.ts +43 -0
  85. package/src/hub/specs-task.ts +108 -0
  86. package/src/hub/specs.ts +1008 -0
  87. package/src/hub/task-list-page.ts +75 -0
  88. package/src/hub/task-sweep.ts +10 -1
  89. package/src/hub/tasks.ts +448 -40
  90. package/src/hub/terminal-open.ts +10 -5
  91. package/src/hub/tool-request.ts +131 -0
  92. package/src/ui/office-model.ts +17 -1
  93. package/src/ui/office-space.ts +1 -1
  94. package/templates/config.json +1 -0
  95. package/templates/presets/confirmation-matrix.json +5 -5
  96. package/templates/profiles/dev.json +1 -0
  97. package/templates/profiles/general.json +1 -0
  98. package/templates/profiles/research.json +1 -0
  99. package/templates/profiles/writing.json +1 -0
  100. package/templates/release-guard.block.md +2 -1
  101. 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,7 +64,7 @@ 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";
@@ -75,10 +75,12 @@ 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 { coverageGap, renderReviewBrief, renderSpecTaskReport, type BriefFile, type ReviewBrief } from "./review-brief.ts";
79
+ import { DEFAULT_SPECS_CONFIG, type SpecsConfig } from "./specs-config.ts";
80
+ import type { SpecsCheckReport, SpecsCoverReport } from "./specs.ts";
79
81
  import { definitionOf as builtinDefinitionOf } from "../adapters/registry.ts";
80
82
  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";
83
+ import { plannerGrooming, nextSweep, plannedStartPending, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
82
84
  import { CiWatchers, pollCi, type CiOutcome, type CiWatch } from "./ci-watch.ts";
83
85
  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";
84
86
 
@@ -88,6 +90,15 @@ function requireLocalTaskPeer(peer: unknown): void {
88
90
 
89
91
  export type TaskAssignmentRole = "owner" | "reviewer" | "planner";
90
92
 
93
+ interface PreparedSpecInspection {
94
+ report?: SpecTaskReport;
95
+ policyKey: string;
96
+ repo?: string;
97
+ head: string;
98
+ privateTurn: boolean;
99
+ pii: boolean;
100
+ }
101
+
91
102
  export interface ReleaseWindow { at: number; host: boolean; quota: boolean; idle: boolean; reasons: string[]; current?: () => boolean; close?: () => Promise<void>; }
92
103
 
93
104
  export interface TasksDeps {
@@ -134,6 +145,14 @@ export interface TasksDeps {
134
145
  /** #520: the review brief for a task's commit (git only, bounded); undefined when the task names no commit. */
135
146
  prepareReviewBrief?: (task: Task) => Promise<void>;
136
147
  reviewBrief?: (task: Task) => ReviewBrief | { unavailable: string } | undefined;
148
+ /** Current public coverage for a task's declared paths; absent when notices are disabled or no specs exist. */
149
+ specNotice?: (task: Task) => SpecsCoverReport | undefined;
150
+ /** Inspect spec metadata at a verified task head; undefined is authoritative quiet (no specs/stamps or review off). */
151
+ inspectSpecs?: (task: Task, head: string, changedFiles: readonly BriefFile[] | undefined, acks: readonly SpecAck[]) => Promise<SpecTaskReport | undefined>;
152
+ /** Effective shared and machine-local specs policy. */
153
+ specsConfig?: () => SpecsConfig;
154
+ /** Explicit, read-only inspection remains available when task notices are disabled. */
155
+ specsRead?: (action: "check" | "covers", paths: readonly string[]) => SpecsCheckReport | SpecsCoverReport;
137
156
  /** Host-verified existing symbols at this task's admitted completion head; never plan guesses. */
138
157
  memorySymbolNodes?: (task: Task) => MemorySymbolNodes | undefined;
139
158
  sdlc?: { config: () => SdlcConfig; gh?: SdlcGh };
@@ -375,7 +394,7 @@ function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan
375
394
  if (items.length) evidence[key] = items;
376
395
  }
377
396
  let scope: CostPlan;
378
- try { scope = cleanCostPlan(r); } catch { throw new PreEffectToolRefusal("invalid review or CI cost scope"); }
397
+ try { scope = cleanCostPlan(r); } catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
379
398
  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
399
  }
381
400
 
@@ -563,7 +582,7 @@ export class Tasks {
563
582
  for (const task of this.d.board.list()) {
564
583
  if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
565
584
  if (!task.owner && this.personUnassigned(task)) continue;
566
- const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0);
585
+ const finding = nextSweep(task, config, now, this.waitsFor(task).length === 0 && !plannedStartPending(task, now));
567
586
  if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
568
587
  if (finding.kind === "ready-unassigned") {
569
588
  this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
@@ -906,6 +925,8 @@ export class Tasks {
906
925
  if (rest.sdlc?.redFirst && !this.publicMetadataText(JSON.stringify(rest.sdlc.redFirst))) {
907
926
  const { redFirst: _withheld, ...receipt } = rest.sdlc; rest.sdlc = receipt;
908
927
  }
928
+ const currentSpecReport = this.currentSpecReport(task);
929
+ const currentSpecEntry = currentSpecReport ? history.findLast(entry => entry.event === "done" || entry.event === "done (checking)") : undefined;
909
930
  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
931
  const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
911
932
  at: h.at, by: h.by, event: h.event,
@@ -933,16 +954,23 @@ export class Tasks {
933
954
  ...(h.planning ? { planning: { revision: h.planning.revision, fields: h.planning.fields, ...(h.planning.split ? { children: h.planning.split.children.map(child => child.id) } : {}) } } : {}),
934
955
  ...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
935
956
  ...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
957
+ ...(h === currentSpecEntry && currentSpecReport ? { specReport: currentSpecReport } : {}),
936
958
  }));
937
959
  const sets = this.reviewFindingSets(task), current = findingsForHead(sets, this.findingHead(task));
938
960
  const symbolRepository = codeSymbols && this.codeSymbolsRepository(task);
939
961
  const publicSymbols = codeSymbols && symbolRepository ? publicTaskCodeSymbols(codeSymbols, this.reviewHead(task), symbolRepository.key, this.publicMetadataText) : undefined;
940
962
  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 };
963
+ 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
964
  }
943
965
 
944
966
  /** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
945
- resultLine(task: Task): string {
967
+ specReportText(task: Task, by?: PeerId): string {
968
+ if (by && this.d.privateTurn?.(by)) return "";
969
+ const report = this.currentSpecReport(task);
970
+ return report ? renderSpecTaskReport(report) : "";
971
+ }
972
+
973
+ resultLine(task: Task, by?: PeerId): string {
946
974
  const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
947
975
  const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
948
976
  ? 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 +979,250 @@ export class Tasks {
951
979
  const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
952
980
  const request = task.history.findLast(entry => ["done", "reviewer changed", "reviewer unavailable"].includes(entry.event));
953
981
  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("; ");
982
+ const specText = this.specReportText(task, by) || undefined;
983
+ return [base, noReview, poolBlocked, holdText, specText].filter(Boolean).join("; ");
984
+ }
985
+
986
+ private currentSpecReport(task: Task): SpecTaskReport | undefined {
987
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
988
+ if (this.isPii(task) || !config.enabled || config.review === "off") return;
989
+ const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
990
+ const binding = this.specRefBinding(task);
991
+ const latest = task.history.findLast(row => row.event === "done" || row.event === "done (checking)");
992
+ const report = latest?.specReport;
993
+ if (!latest || !report || !(report.head === head || report.head === "" && !report.complete) || latest.specRefBinding !== binding) return;
994
+ return this.publicSpecReport(report);
995
+ }
996
+
997
+ private specRefBinding(task: Pick<Task, "refs">): string {
998
+ return createHash("sha256").update(JSON.stringify([task.refs.repo ?? "", task.refs.branch ?? "", task.refs.commit ?? ""])).digest("hex");
999
+ }
1000
+
1001
+ private publicSpecReport(report: SpecTaskReport): SpecTaskReport | undefined {
1002
+ if (!report || typeof report.head !== "string" || report.head !== "" && !/^[0-9a-f]{40}$/.test(report.head)
1003
+ || report.complete && !/^[0-9a-f]{40}$/.test(report.head)
1004
+ || typeof report.complete !== "boolean" || !Number.isSafeInteger(report.errors) || report.errors < 0
1005
+ || !Array.isArray(report.unchanged) || !Array.isArray(report.changed)) return;
1006
+ const ref = (value: unknown): SpecTaskReport["unchanged"][number] | undefined => {
1007
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
1008
+ const row = value as Record<string, unknown>;
1009
+ if (typeof row.id !== "string" || typeof row.path !== "string" || typeof row.title !== "string"
1010
+ || !row.id.trim() || row.id.length > 160 || !row.path.trim()) return;
1011
+ // Missing titles are schema diagnostics, not a report-mode completion gate.
1012
+ const title = row.title.trim() ? row.title : row.id;
1013
+ const strings = [row.id, row.path, title, ...(typeof row.reason === "string" ? [row.reason] : [])];
1014
+ if (strings.some(text => text.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(text) || !this.publicMetadataText(text))) return;
1015
+ return { id: row.id, path: row.path, title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
1016
+ };
1017
+ const unchanged = report.unchanged.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
1018
+ const changed = report.changed.slice(0, 20).map(ref).filter((row): row is NonNullable<typeof row> => !!row);
1019
+ const privacyFiltered = unchanged.length < Math.min(report.unchanged.length, 20) || changed.length < Math.min(report.changed.length, 20);
1020
+ const reason = privacyFiltered ? "some spec metadata could not be safely shown"
1021
+ : typeof report.reason === "string" && report.reason.length <= 500 && this.publicMetadataText(report.reason) ? report.reason : undefined;
1022
+ return { head: report.head, complete: report.complete && !privacyFiltered, errors: report.errors, unchanged, changed,
1023
+ ...(report.truncated || report.unchanged.length > 20 || report.changed.length > 20 ? { truncated: true } : {}), ...(reason ? { reason } : {}) };
1024
+ }
1025
+
1026
+ private parseSpecAcks(value: unknown): SpecAck[] {
1027
+ if (value === undefined) return [];
1028
+ if (!Array.isArray(value) || value.length > 20) throw new PreEffectToolRefusal("spec_ack must contain at most 20 {id, reason} entries");
1029
+ const seen = new Set<string>();
1030
+ return value.map(raw => {
1031
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new PreEffectToolRefusal("each spec_ack entry needs an id and reason");
1032
+ const row = raw as Record<string, unknown>;
1033
+ 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)
1034
+ || 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");
1035
+ const id = row.id.trim();
1036
+ if (!this.publicMetadataText(id)) throw new PreEffectToolRefusal("spec_ack id is private or sensitive");
1037
+ if (seen.has(id)) throw new PreEffectToolRefusal(`spec_ack repeats ${id}`);
1038
+ seen.add(id);
1039
+ return { id, reason: row.reason.trim() };
1040
+ });
1041
+ }
1042
+
1043
+ private specsPolicyKey(config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG): string {
1044
+ return JSON.stringify([config.enabled, config.review, config.roots, config.stamps_file]);
1045
+ }
1046
+
1047
+ private assertSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): void {
1048
+ this.assertSpecTaskBinding(inspection, task);
1049
+ if (this.specsPolicyKey() !== inspection.policyKey || (this.d.privateTurn?.(by) === true) !== inspection.privateTurn || this.isPii(task) !== inspection.pii) {
1050
+ throw new PreEffectToolRefusal(`task #${task.id}: specs policy, task head or privacy context changed during inspection; retry hub_task_done`);
1051
+ }
1052
+ }
1053
+
1054
+ private assertSpecTaskBinding(inspection: PreparedSpecInspection, task: Task): void {
1055
+ const head = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
1056
+ if (task.refs.repo !== inspection.repo || head !== inspection.head) {
1057
+ throw new PreEffectToolRefusal(`task #${task.id}: task head or repository changed during spec inspection; retry hub_task_done`);
1058
+ }
1059
+ }
1060
+
1061
+ private refreshAsyncSpecInspection(inspection: PreparedSpecInspection, task: Task, by: PeerId): boolean {
1062
+ this.assertSpecTaskBinding(inspection, task);
1063
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
1064
+ const privateTurn = this.d.privateTurn?.(by) === true;
1065
+ const policyKey = this.specsPolicyKey(config), pii = this.isPii(task);
1066
+ if (inspection.report && policyKey !== inspection.policyKey && config.enabled && config.review === "require" && !privateTurn && !pii) return true;
1067
+ if (policyKey !== inspection.policyKey || privateTurn !== inspection.privateTurn || pii !== inspection.pii) {
1068
+ delete inspection.report;
1069
+ inspection.policyKey = policyKey;
1070
+ inspection.privateTurn = privateTurn;
1071
+ inspection.pii = pii;
1072
+ }
1073
+ return false;
1074
+ }
1075
+
1076
+ private requestSpecRefile(task: Task): void {
1077
+ 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));
1078
+ }
1079
+
1080
+ private specInspectionReason(cause: string): string {
1081
+ const safe = this.publicMetadataText(cause) ? sanitize(cause).replace(/[\r\n\t]+/g, " ").trim().slice(0, 350) : "verified spec evidence is unavailable";
1082
+ return `${safe}; split the task scope or ask the person to change specs.review`;
1083
+ }
1084
+
1085
+ private async inspectTaskSpecs(task: Task, by: PeerId, rawAcks: unknown, validate: () => Task): Promise<PreparedSpecInspection> {
1086
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
1087
+ const initialPrivate = this.d.privateTurn?.(by) === true, pii = this.isPii(task);
1088
+ const declaredHead = task.refs.commit && /^[0-9a-f]{40}$/.test(task.refs.commit) ? task.refs.commit : "";
1089
+ const inspection: PreparedSpecInspection = { policyKey: this.specsPolicyKey(config), ...(task.refs.repo ? { repo: task.refs.repo } : {}), head: declaredHead, privateTurn: initialPrivate, pii };
1090
+ const ackInput = Array.isArray(rawAcks) && rawAcks.length === 0 ? undefined : rawAcks;
1091
+ if (ackInput !== undefined && (pii || initialPrivate)) throw new PreEffectToolRefusal("spec_ack is unavailable for private work");
1092
+ if (ackInput !== undefined && (!config.enabled || config.review === "off")) throw new PreEffectToolRefusal("spec_ack is unavailable while specs review is disabled");
1093
+ if (!config.enabled || config.review === "off" || pii || initialPrivate) return inspection;
1094
+ if (!this.d.inspectSpecs) {
1095
+ 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`);
1096
+ if (ackInput !== undefined) throw new PreEffectToolRefusal("spec inspection is unavailable; cannot accept spec_ack");
1097
+ return inspection;
1098
+ }
1099
+ const acks = this.parseSpecAcks(ackInput);
1100
+ let changedFiles: BriefFile[] | undefined;
1101
+ let changedEvidenceReason: string | undefined;
1102
+ if (declaredHead) {
1103
+ const brief = this.d.reviewBrief?.({ ...task, refs: { ...task.refs, commit: declaredHead } });
1104
+ if (!brief) changedEvidenceReason = "the verified review brief is unavailable";
1105
+ else if ("unavailable" in brief) changedEvidenceReason = `review brief unavailable: ${brief.unavailable}`;
1106
+ else if (brief.head !== declaredHead) changedEvidenceReason = "the verified review brief does not match the submitted full head";
1107
+ else if (brief.withheld > 0) changedEvidenceReason = `the verified review brief withheld ${brief.withheld} changed file(s)`;
1108
+ else if (brief.unlisted.length > 0) changedEvidenceReason = `the verified review brief has ${brief.unlisted.length} unlisted changed file(s)`;
1109
+ else {
1110
+ const files = [...brief.reviewable, ...brief.excluded].flatMap(file => [file, ...(file.oldPath ? [{ path: file.oldPath, status: file.status }] : [])]);
1111
+ if (files.every(file => this.publicMetadataText(file.path))) {
1112
+ const byPath = new Map<string, BriefFile>();
1113
+ for (const file of files) if (!byPath.has(file.path)) byPath.set(file.path, file);
1114
+ changedFiles = [...byPath.values()];
1115
+ } else changedEvidenceReason = "the verified changed-file inventory includes private paths";
1116
+ }
1117
+ } else changedEvidenceReason = "a full task commit head was not supplied";
1118
+ const screenedAcks: SpecAck[] = [];
1119
+ for (const ack of acks) {
1120
+ if (!this.publicMetadataText(ack.reason)) throw new PreEffectToolRefusal("spec_ack reason is private or sensitive");
1121
+ 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`);
1122
+ screenedAcks.push(ack);
1123
+ this.assertSpecInspection(inspection, validate(), by);
1124
+ }
1125
+ this.assertSpecInspection(inspection, validate(), by);
1126
+ const report = await this.d.inspectSpecs(task, declaredHead, changedFiles, screenedAcks);
1127
+ this.assertSpecInspection(inspection, validate(), by);
1128
+ if (!report) {
1129
+ if (acks.length) throw new PreEffectToolRefusal("spec_ack was supplied but no spec requires acknowledgement");
1130
+ return inspection;
1131
+ }
1132
+ const safe = this.publicSpecReport(report);
1133
+ if (!safe) throw new PreEffectToolRefusal("spec inspection returned invalid or private metadata");
1134
+ if (safe.unchanged.length < Math.min(report.unchanged.length, 20) || safe.changed.length < Math.min(report.changed.length, 20)) {
1135
+ safe.complete = false;
1136
+ safe.reason = this.specInspectionReason("some spec metadata could not be safely shown");
1137
+ }
1138
+ if (safe.head !== declaredHead) {
1139
+ safe.complete = false;
1140
+ safe.head = "";
1141
+ safe.reason = this.specInspectionReason("the spec report does not match the verified task head");
1142
+ safe.unchanged = [];
1143
+ safe.changed = [];
1144
+ }
1145
+ if (report.truncated) {
1146
+ safe.complete = false;
1147
+ safe.reason = this.specInspectionReason("the submitted spec scan reached its safety limit; narrow specs.roots");
1148
+ } else if (changedFiles === undefined && changedEvidenceReason && safe.head === declaredHead) {
1149
+ safe.complete = false;
1150
+ safe.reason = this.specInspectionReason(changedEvidenceReason);
1151
+ } else if (!safe.complete && safe.reason) safe.reason = this.specInspectionReason(safe.reason);
1152
+ else if (!safe.complete) safe.reason = this.specInspectionReason(report.reason ?? "spec coverage could not be verified at the submitted task head");
1153
+ 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`);
1154
+ const pendingIds = new Set(safe.unchanged.map(spec => spec.id));
1155
+ 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`);
1156
+ const reasons = new Map(screenedAcks.map(ack => [ack.id, ack.reason]));
1157
+ safe.unchanged = safe.unchanged.map(spec => ({ ...spec, ...(reasons.has(spec.id) ? { reason: reasons.get(spec.id)! } : {}) }));
1158
+ if (config.review === "require") {
1159
+ if (!safe.complete || report.truncated) throw new PreEffectToolRefusal(`task #${task.id}: ${safe.reason ?? this.specInspectionReason("spec coverage is unknown at this submitted task head")}`);
1160
+ const unacknowledged = safe.unchanged.filter(spec => !reasons.has(spec.id));
1161
+ 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}]`);
1162
+ }
1163
+ inspection.report = safe;
1164
+ return inspection;
1165
+ }
1166
+
1167
+ /** Narrow public task-path notice used by the tool response after propose/accept. */
1168
+ specNotice(task: Task, by?: PeerId): string {
1169
+ const config = this.d.specsConfig?.() ?? DEFAULT_SPECS_CONFIG;
1170
+ if (!config.enabled || config.review === "off" || this.isPii(task) || by && this.d.privateTurn?.(by)) return "";
1171
+ let report: SpecsCoverReport | undefined;
1172
+ try { report = this.d.specNotice?.(task); } catch { return ""; }
1173
+ const current = this.d.board.get(task.id);
1174
+ if (!current || this.isPii(current) || by && this.d.privateTurn?.(by)
1175
+ || current.history.length !== task.history.length || current.owner !== task.owner || current.refs.repo !== task.refs.repo
1176
+ || JSON.stringify(current.refs.paths ?? []) !== JSON.stringify(task.refs.paths ?? [])) return "";
1177
+ if (!report) return "";
1178
+ const refs = report.specs.slice(0, 20).filter(spec => [spec.id, spec.path, spec.title].every(value => this.publicMetadataText(value)));
1179
+ const lines = refs.length ? [`Specs covering these paths: ${refs.map(spec => `${spec.id} (${spec.path})`).join(", ")}`] : [];
1180
+ if (report.errors) lines.push(`Spec coverage check reports ${report.errors} error${report.errors === 1 ? "" : "s"}; run ahub specs check.`);
1181
+ 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.");
1182
+ return sanitize(lines.join("\n"));
1183
+ }
1184
+
1185
+ specsRead(by: PeerId, action: "check" | "covers", rawPaths: unknown = []): Record<string, unknown> {
1186
+ if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
1187
+ if (!this.d.specsRead) throw new PreEffectToolRefusal("spec inspection unavailable");
1188
+ if (action !== "check" && action !== "covers") throw new PreEffectToolRefusal('hub_specs action must be "check" or "covers"');
1189
+ 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)
1190
+ || 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");
1191
+ const paths = rawPaths as string[];
1192
+ if (action === "check" && paths.length) throw new PreEffectToolRefusal('hub_specs "check" takes no paths');
1193
+ if (action === "covers" && paths.length === 0) throw new PreEffectToolRefusal('hub_specs "covers" needs at least one repository-relative path');
1194
+ const safe = (value: string) => sanitize(value);
1195
+ const diagnostic = (raw: SpecsCheckReport["items"][number]): Record<string, unknown> | undefined => {
1196
+ 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] : [])];
1197
+ if (fields.some(value => !this.publicMetadataText(value))) return;
1198
+ return { severity: raw.severity, code: safe(raw.code), message: safe(raw.message), cause: safe(raw.cause), nextSteps: safe(raw.nextSteps),
1199
+ ...(raw.path ? { path: safe(raw.path) } : {}), ...(raw.id ? { id: safe(raw.id) } : {}), ...(raw.field ? { field: safe(raw.field) } : {}), ...(raw.relatedId ? { relatedId: safe(raw.relatedId) } : {}) };
1200
+ };
1201
+ let report: SpecsCheckReport | SpecsCoverReport;
1202
+ try { report = this.d.specsRead(action, paths); }
1203
+ catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
1204
+ if (this.d.privateTurn?.(by)) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
1205
+ try {
1206
+ if ("freshness" in report) {
1207
+ const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
1208
+ const items = visibleItems.slice(0, 20);
1209
+ const remaining = Math.max(0, 20 - items.length);
1210
+ const visibleFreshness = report.freshness.filter(row => this.publicMetadataText(row.path) && (row.id === undefined || this.publicMetadataText(row.id)));
1211
+ const freshness = visibleFreshness.slice(0, remaining)
1212
+ .map(row => ({ path: safe(row.path), ...(row.id ? { id: safe(row.id) } : {}), state: row.state, commitsSince: row.commitsSince, dirty: row.dirty }));
1213
+ return { specCount: report.specCount, errors: report.errors, warnings: report.warnings, stale: report.stale,
1214
+ truncated: report.truncated || visibleItems.length > 20 || visibleItems.length !== report.items.length
1215
+ || visibleFreshness.length > freshness.length || visibleFreshness.length !== report.freshness.length, freshness, items };
1216
+ }
1217
+ const visibleSpecs = report.specs.filter(row => [row.id, row.path, row.title].every(value => this.publicMetadataText(value)));
1218
+ const specs = visibleSpecs.slice(0, 20)
1219
+ .map(({ id, path, title }) => ({ id: safe(id), path: safe(path), title: safe(title) }));
1220
+ const visibleItems = report.items.map(diagnostic).filter((row): row is Record<string, unknown> => !!row);
1221
+ const remaining = Math.max(0, 20 - specs.length);
1222
+ const items = visibleItems.slice(0, remaining);
1223
+ return { specs, errors: report.errors, truncated: report.truncated || visibleSpecs.length > specs.length || visibleSpecs.length !== report.specs.length
1224
+ || visibleItems.length > remaining || visibleItems.length !== report.items.length, items };
1225
+ } catch { throw new PreEffectToolRefusal("spec inspection unavailable; retry ahub specs check"); }
955
1226
  }
956
1227
 
957
1228
  private states(): Record<PeerId, PeerState> {
@@ -1100,6 +1371,25 @@ export class Tasks {
1100
1371
  return { ...(family ? { reviewerFamily: family } : {}), ...(reason ? { reviewerReason: reason } : {}), ...(assignment?.reviewBlocked ? { reviewBlocked: true } : {}), ...(queueReviewHeld ? { queueReviewHeld: true as const, ...(queueReviewBinding !== undefined ? { queueReviewBinding } : {}) } : {}) };
1101
1372
  }
1102
1373
 
1374
+ /**
1375
+ * #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
1376
+ * 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
1377
+ * sandboxed peer from accepting work, failing at `git worktree add` (#270) and leaving an empty branch ref behind (#621, #565). Unknown capability is
1378
+ * not a refusal, and a PII task skips the check as `requireCapabilities` does.
1379
+ */
1380
+ private declaredNeedCheck(task: Pick<Task, "signals" | "id"> & Partial<Pick<Task, "signals">>, peer: PeerId): void {
1381
+ if (!this.d.capabilities || this.isPii(task as Task)) return;
1382
+ const wantsNetwork = task.signals?.includes("needs_network") === true, wantsWorktree = task.signals?.includes("needs_worktree") === true;
1383
+ if (!wantsNetwork && !wantsWorktree) return;
1384
+ const caps = peerEntry(this.d.capabilities(), peer);
1385
+ if (wantsNetwork && caps?.network === false) {
1386
+ 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`);
1387
+ }
1388
+ if (wantsWorktree && caps?.worktree === false) {
1389
+ throw new PreEffectToolRefusal(`task #${task.id} needs its own git worktree 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`);
1390
+ }
1391
+ }
1392
+
1103
1393
  /** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
1104
1394
  private requireCapabilities(task: Parameters<typeof assign>[0], peer: PeerId, role: Exclude<TaskAssignmentRole, "planner"> = "owner", preEffect = true): void {
1105
1395
  requireLocalTaskPeer(peer);
@@ -1108,7 +1398,7 @@ export class Tasks {
1108
1398
  if ((!this.d.capabilities || this.isPii(task)) && !declaredExtra) return;
1109
1399
  const { state: _state, ...draft } = task; // backlog/readiness must not bypass an explicit reservation's admission
1110
1400
  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}`);
1401
+ 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
1402
  if (missing.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`cannot assign ${peer}: ${missing.join("; ")}`);
1113
1403
  }
1114
1404
 
@@ -2188,6 +2478,7 @@ export class Tasks {
2188
2478
  /** Write-driven wake-ups (#785): one unsettled ids-only task notice per planner, never a polling pull. */
2189
2479
  private writePlannerNotices(task: Task, before: ReadonlyMap<PeerId, number>, approved = false): void {
2190
2480
  this.releaseNotice(`task #${task.id}: could not notify planner about ready work`, () => {
2481
+ const now = this.d.now?.() ?? Date.now();
2191
2482
  const changes = new Map<PeerId, { capacity: Set<PeerId>; ready: Set<number> }>();
2192
2483
  const add = (planner: PeerId, peer?: PeerId, id?: number) => {
2193
2484
  if (!this.d.bus.isRostered(planner)) return;
@@ -2199,7 +2490,7 @@ export class Tasks {
2199
2490
  const released = new Set<PeerId>();
2200
2491
  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
2492
  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);
2493
+ 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
2494
  }
2204
2495
  for (const [planner, change] of changes) this.releaseNotice(`could not notify planner ${planner} about ready work`, () => {
2205
2496
  const pending = this.d.bus.pendingEnvelopes(planner).filter(env => env.from === HUB && env.kind === "task" && env.body.startsWith("Planner work ready:"));
@@ -2217,7 +2508,7 @@ export class Tasks {
2217
2508
  }
2218
2509
  for (const id of change.ready) {
2219
2510
  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}`);
2511
+ if (card?.state === "backlog" && this.taskPlanner(card).includes(planner) && !this.waitsFor(card).length && !plannedStartPending(card, now)) lines.push(`backlog_ready: #${id}`);
2221
2512
  else change.ready.delete(id);
2222
2513
  }
2223
2514
  for (const env of pending) this.d.bus.withdraw(env.id);
@@ -2231,8 +2522,9 @@ export class Tasks {
2231
2522
 
2232
2523
  /** 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
2524
  private topReservedReady(peer: PeerId, planner?: PeerId, pullable = false): Task | undefined {
2525
+ const now = this.d.now?.() ?? Date.now();
2234
2526
  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)
2527
+ && (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
2528
  && (card.release == null || ["planning", "active"].includes(this.d.board.release(card.release)?.state ?? ""))).sort(this.readyOrder)[0];
2237
2529
  }
2238
2530
 
@@ -2264,10 +2556,15 @@ export class Tasks {
2264
2556
  if (!planner || !this.d.bus.isRostered(planner)) return;
2265
2557
  const reason = `auto_pull: capacity released for ${peer}; hub acted for planner ${planner}`;
2266
2558
  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"); } });
2559
+ await this.pullPlanned(HUB, { id: card.id, revision: card.planningRevision ?? 0, reason }, { onBehalfOf: planner, guard: () => {
2560
+ 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");
2561
+ const current = this.d.board.get(card.id);
2562
+ if (current && plannedStartPending(current, this.d.now?.() ?? Date.now())) throw new PreEffectToolRefusal("auto pull is waiting for the planned start");
2563
+ } });
2268
2564
  } catch (e) {
2269
2565
  // A guard refusal is the setting or a hold doing its job, not a failure to report.
2270
- if (!this.autoPullAdmitted()) return;
2566
+ const current = this.d.board.get(card.id);
2567
+ if (!this.autoPullAdmitted() || current && plannedStartPending(current, this.d.now?.() ?? Date.now())) return;
2271
2568
  this.releaseNotice(`task ${this.publicTitle(card)}: auto_pull for ${peer} refused: ${e instanceof Error ? e.message : String(e)}`);
2272
2569
  }
2273
2570
  }
@@ -2982,7 +3279,7 @@ export class Tasks {
2982
3279
  return updated; // a failed readback may follow a durable release write
2983
3280
  }
2984
3281
 
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> {
3282
+ 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
3283
  const requestCheck = this.requestCheck(by, fence);
2987
3284
  requireLocalTaskPeer(input.owner);
2988
3285
  external = external ? { ...external } : undefined;
@@ -3026,6 +3323,12 @@ export class Tasks {
3026
3323
  const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
3027
3324
  // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
3028
3325
  if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
3326
+ // #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.
3327
+ 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");
3328
+ if (input.needs_network === true && !signals.includes("needs_network")) signals.push("needs_network");
3329
+ // #1081 (amended AC1): the same for a task worktree. Only an explicit true sets it.
3330
+ 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");
3331
+ if (input.needs_worktree === true && !signals.includes("needs_worktree")) signals.push("needs_worktree");
3029
3332
  // The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
3030
3333
  // board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
3031
3334
  const screening = this.screenOn();
@@ -3599,6 +3902,7 @@ export class Tasks {
3599
3902
  /** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
3600
3903
  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
3904
  opts.current?.();
3905
+ 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
3906
  if (task.state === "backlog" && !opts.plannerPull) throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
3603
3907
  const studyReason = this.studyDispatchReason(task, opts.namedOwner);
3604
3908
  if (studyReason) {
@@ -3616,6 +3920,7 @@ export class Tasks {
3616
3920
  if (opts.plannerPull) throw new PreEffectToolRefusal("planner pull changed during admission");
3617
3921
  return current ?? task;
3618
3922
  }
3923
+ 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
3924
  task = current; // Reviewer-only and quiet bookkeeping updates do not supersede ownership authority.
3620
3925
  if (gap) {
3621
3926
  this.requestIndependentPlanReview(current);
@@ -3673,7 +3978,9 @@ export class Tasks {
3673
3978
  const binding = this.plannerBinding(task, a, !!opts.plannerPull);
3674
3979
  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
3980
  opts.current?.();
3676
- this.studyDispatchCheck(this.d.board.get(task.id) ?? task, a.owner);
3981
+ const current = this.d.board.get(task.id) ?? task;
3982
+ 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");
3983
+ this.studyDispatchCheck(current, a.owner);
3677
3984
  const next = opts.plannerPull
3678
3985
  ? 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
3986
  : 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);
@@ -4038,6 +4345,12 @@ export class Tasks {
4038
4345
  if (!this.queuePressureAdmitted()) return;
4039
4346
  const task = this.d.board.get(stall.task);
4040
4347
  if (!this.ordinaryQueueProposal(stall, task)) { this.d.board.clearStall(stall.task); continue; }
4348
+ if (plannedStartPending(task, this.d.now?.() ?? Date.now())) {
4349
+ const scheduledOrigin = this.d.board.queuePressureStallPage(task.id - 1, 1).rows.find(row => row.task === task.id);
4350
+ if (scheduledOrigin) this.d.board.recordStall({ ...scheduledOrigin, replanRevision: task.planningRevision ?? 0 });
4351
+ pass.retained = true;
4352
+ continue;
4353
+ }
4041
4354
  // A planning revision asks for fresh admission, never erases an automatic offer.
4042
4355
  const namedOwner = stall.cause.peer;
4043
4356
  const retry = namedOwner ? { candidates: [namedOwner], namedOwner } : {};
@@ -4203,6 +4516,7 @@ export class Tasks {
4203
4516
  }
4204
4517
 
4205
4518
  async releaseReady(): Promise<void> {
4519
+ const now = this.d.now?.() ?? Date.now();
4206
4520
  const haveQueueOrigins = this.queuePressureKnown !== false && this.d.board.queuePressureStallPage(0, 1).afterTask > 0;
4207
4521
  for (const t of [...this.d.board.list("proposed")].sort(this.readyOrder)) {
4208
4522
  if (t.owner) { this.d.board.clearStall(t.id); continue; }
@@ -4214,6 +4528,7 @@ export class Tasks {
4214
4528
  const origin = this.d.board.queuePressureStallPage(t.id - 1, 1).rows.find(row => row.task === t.id);
4215
4529
  if (origin && this.ordinaryQueueProposal(origin, t)) { this.scheduleQueuePressureRecheck(); continue; }
4216
4530
  }
4531
+ if (plannedStartPending(t, now)) continue;
4217
4532
  const admission = planned ? this.plannerAssignment(t, true) : assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.weights(t.class, t) });
4218
4533
  if (!admission.owner) {
4219
4534
  // A diagnostic legacy pass is not evidence that an automatic proposal was attempted.
@@ -4235,6 +4550,7 @@ export class Tasks {
4235
4550
  try {
4236
4551
  const t = this.d.board.get(stale.id);
4237
4552
  if (!t || t.state !== "proposed" || t.owner || this.personUnassigned(t) || this.offered.has(t.id)) return;
4553
+ if (plannedStartPending(t, this.d.now?.() ?? Date.now())) return;
4238
4554
  const planned = this.plannerPullRecord(t)?.pull === true;
4239
4555
  const selected = planned ? this.plannerAssignment(t, true).owner : undefined;
4240
4556
  if (planned && !selected) return;
@@ -4251,6 +4567,7 @@ export class Tasks {
4251
4567
  } catch (e) {
4252
4568
  // A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
4253
4569
  // where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
4570
+ if (e instanceof PreEffectToolRefusal && e.message === "planned start is in the future") return;
4254
4571
  if (e instanceof AssignmentUndeliveredError) undelivered = e;
4255
4572
  else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
4256
4573
  } finally {
@@ -4419,7 +4736,7 @@ export class Tasks {
4419
4736
  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
4737
  if (this.waitsFor(task).length) blockers.push({ task: task.id, code: "dependency" });
4421
4738
  }
4422
- const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`,
4739
+ const digest = plannerGrooming(visible, { dependencies, dependencyTruncated, repository: this.d.repository ?? `path:${realPath(this.d.cwd)}`, now: this.d.now?.() ?? Date.now(),
4423
4740
  limits: Object.fromEntries(peers.map(peer => [peer, this.wipLimit(peer)])), wip: Object.fromEntries(peers.map(peer => [peer, this.wip(peer)])),
4424
4741
  states, issues, reviewedIssues, acceptedIssues, blockers });
4425
4742
  return { ...digest, truncated: digest.truncated || incomplete, ...(current.some(task => this.isPii(task)) ? { privateTasks: "withheld" } : {}) };
@@ -4819,8 +5136,8 @@ export class Tasks {
4819
5136
  if (isClosedTaskState(task.state)) throw new PreEffectToolRefusal("closed work is final");
4820
5137
  if ((task.planningRevision ?? 0) !== revision) throw new PreEffectToolRefusal("planning revision changed");
4821
5138
  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"); }
5139
+ 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" }); }
5140
+ catch (error) { throw new PreEffectToolRefusal(error instanceof CostScopeError ? error.message : "invalid review or CI cost scope"); }
4824
5141
  const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
4825
5142
  if (!scope.review || !scope.ci) {
4826
5143
  if (mode === "enforce") throw new PreEffectToolRefusal("review and CI cost scope required for a new plan receipt");
@@ -5391,7 +5708,7 @@ export class Tasks {
5391
5708
  const metadata = acceptRefs(refs);
5392
5709
  const task = this.need(id, false, true);
5393
5710
  this.mine(task, by, "owner", true);
5394
- this.acceptanceCapacity(task);
5711
+ this.acceptanceCapacity(task, 0, "check"); // #1080: an urgent task may still take unaccepted offers; `accept` does that, after its gates
5395
5712
  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
5713
  const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
5397
5714
  this.warmRepository(draft);
@@ -5420,7 +5737,52 @@ export class Tasks {
5420
5737
  if (gap) this.releaseNotice(`task ${this.publicTitle(current)}: ${gap}`);
5421
5738
  }
5422
5739
 
5423
- private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history">>, excludePending = 0): void {
5740
+ /**
5741
+ * 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
5742
+ * (state proposed), `reserved` is ownerless planner-pulled work held for it, and `accepted` is everything the owner is actually carrying
5743
+ * (in progress, changes requested, in review).
5744
+ */
5745
+ private wipSplit(owner: PeerId, except: number): { accepted: Task[]; offered: Task[]; reserved: Task[] } {
5746
+ const open = this.d.board.list().filter(other => other.id !== except && !["backlog", "approved", "cancelled"].includes(other.state)
5747
+ && (other.owner === owner || !other.owner && other.reserved === owner && this.plannerPullRecord(other)?.pull));
5748
+ return {
5749
+ accepted: open.filter(other => other.owner === owner && other.state !== "proposed"),
5750
+ offered: open.filter(other => other.owner === owner && other.state === "proposed"),
5751
+ reserved: open.filter(other => !other.owner),
5752
+ };
5753
+ }
5754
+ private static isUrgent(task: Pick<Task, "priority" | "signals">): boolean {
5755
+ return (task.priority ?? (task.signals?.includes("urgent") ? 0 : 2)) === 0;
5756
+ }
5757
+ /** #1080: an offer an urgent accept may take back. Never accepted or started work, never another urgent task, never person-pinned work. */
5758
+ private displaceable(offer: Task): boolean {
5759
+ return offer.state === "proposed" && !!offer.owner && !offer.pinned && !Tasks.isUrgent(offer) && !taskHasStarted(offer);
5760
+ }
5761
+ /**
5762
+ * #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
5763
+ * routing (ownerless, in To Do) and the planners are told. Called only from `accept`, immediately before its write.
5764
+ */
5765
+ private displaceOffers(urgent: Task, owner: PeerId, offers: readonly Task[]): void {
5766
+ for (const offer of offers) {
5767
+ // 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.
5768
+ const pulled = this.plannerPullRecord(offer)?.pull === true;
5769
+ 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);
5770
+ this.offered.delete(offer.id);
5771
+ this.releaseNotice(`task #${offer.id}: displacement cohort cleanup unavailable`, () => this.cohorts.leave(offer.id));
5772
+ this.withdrawRoleRequest(offer, next, "owner");
5773
+ this.withdrawRoleRequest(offer, next, "reviewer");
5774
+ this.releaseNotice(`task #${offer.id}: displacement notice unavailable`, () => {
5775
+ 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}.`;
5776
+ this.d.notify(body);
5777
+ 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 } : {}) }));
5778
+ });
5779
+ }
5780
+ }
5781
+ /**
5782
+ * `urgent`: "refuse" (default) never displaces; "check" only tells whether an urgent accept could (a pre-check before a later write); "displace"
5783
+ * performs it. Only `accept` displaces.
5784
+ */
5785
+ private acceptanceCapacity(task: Pick<Task, "id" | "owner" | "reserved" | "state"> & Partial<Pick<Task, "history" | "priority" | "signals">>, excludePending = 0, urgent: "refuse" | "check" | "displace" = "refuse"): void {
5424
5786
  const owner = task.owner ?? task.reserved;
5425
5787
  if (!owner) return;
5426
5788
  if ((task.state === "in_progress" || task.state === "changes_requested") && task.history) {
@@ -5434,10 +5796,30 @@ export class Tasks {
5434
5796
  if (task.history.slice(boundary).some(entry => entry.event === "accepted" && (entry.by === owner || entry.by === USER))) return;
5435
5797
  }
5436
5798
  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}` : ""}`);
5799
+ const used = this.wip(owner, task.id) + pending;
5800
+ if (used < limit) return;
5801
+ const split = this.wipSplit(owner, task.id), ids = (rows: readonly Task[]) => rows.map(row => `#${row.id}`).join(", ") || "none";
5802
+ // #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.
5803
+ if (urgent !== "refuse" && task.state === "proposed" && task.owner === owner && Tasks.isUrgent(task as Pick<Task, "priority" | "signals">)) {
5804
+ const need = used + 1 - limit;
5805
+ const takeable = split.offered.filter(offer => this.displaceable(offer))
5806
+ .sort((a, b) => handedAt(b) - handedAt(a) || b.id - a.id);
5807
+ if (need > 0 && takeable.length >= need) {
5808
+ if (urgent === "displace") {
5809
+ this.displaceOffers(task as Task, owner, takeable.slice(0, need));
5810
+ // 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.
5811
+ const after = this.wip(owner, task.id) + pending;
5812
+ 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`);
5813
+ }
5814
+ return;
5815
+ }
5816
+ }
5817
+ const open = [...split.accepted, ...split.offered, ...split.reserved].sort((a, b) => a.id - b.id);
5818
+ const parts = [`accepted ${ids(split.accepted)}`, `offered, not yet accepted ${ids(split.offered)}`, ...(split.reserved.length ? [`reserved, not yet offered ${ids(split.reserved)}`] : [])];
5819
+ const next = split.accepted.length >= limit
5820
+ ? `finish or hand back an accepted task first`
5821
+ : `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`;
5822
+ throw new PreEffectToolRefusal(`planner WIP capacity for ${owner}: limit ${limit}; open tasks ${ids(open)} (${parts.join("; ")})${pending ? `; pending claims ${pending}` : ""}; ${next}`);
5441
5823
  }
5442
5824
 
5443
5825
  /**
@@ -5452,7 +5834,8 @@ export class Tasks {
5452
5834
  this.ready(task, true);
5453
5835
  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
5836
  this.studyDispatchCheck(task, task.owner ?? undefined);
5455
- this.acceptanceCapacity(task);
5837
+ this.declaredNeedCheck(task, task.owner ?? by); // the owner's capability, also when the console user accepts on its behalf (`mine` admits USER)
5838
+ this.acceptanceCapacity(task, 0, "check");
5456
5839
  // S1 spec acceptance and S9 plan review are independent prerequisites; retain S1's missing-spec refusal first.
5457
5840
  if (this.specGated(task) && this.d.sdlc?.config().mode === "enforce" && specState(task.sdlc, undefined) === "missing") throw new PreEffectToolRefusal(this.specText(task, "missing", "blocked"));
5458
5841
  if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "enforce" && this.isBuildTask(task)) {
@@ -5480,6 +5863,7 @@ export class Tasks {
5480
5863
  const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
5481
5864
  const before = new Set(this.overlapHits(task).map((h) => h.task.id));
5482
5865
  this.studyDispatchCheck(task, task.owner ?? undefined);
5866
+ this.acceptanceCapacity(task, 0, "displace"); // #1080: every gate has passed; synchronous from here to the accept write
5483
5867
  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
5484
5868
  this.captureOwnerAcceptance(next);
5485
5869
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
@@ -5731,7 +6115,7 @@ export class Tasks {
5731
6115
  if (state !== "OPEN" && state !== "CLOSED") throw new PreEffectToolRefusal(`task #${task.id}: PR merge state unavailable; cannot reopen approval`);
5732
6116
  }
5733
6117
 
5734
- async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown): Promise<Task> {
6118
+ async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs, deliverables?: unknown, specAck?: unknown): Promise<Task> {
5735
6119
  const requestCheck = this.requestCheck(by);
5736
6120
  const initial = this.need(id, false, true);
5737
6121
  this.mine(initial, by, "owner", true);
@@ -5769,6 +6153,10 @@ export class Tasks {
5769
6153
  if (this.d.sdlc && this.d.sdlc.config().mode !== "off") { reviewEvidence = await this.lifecycleGate(task, "review"); task = current(); }
5770
6154
  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;
5771
6155
  task = current();
6156
+ const currentSpecTask = () => { const fresh = current(); return { ...fresh, refs: { ...fresh.refs, ...cleanRefs(refs) } }; };
6157
+ const specInspection = await this.inspectTaskSpecs(currentSpecTask(), by, specAck, currentSpecTask);
6158
+ task = current(); // the host head and task authority are revalidated after spec inspection
6159
+ this.assertSpecInspection(specInspection, { ...task, refs: { ...task.refs, ...cleanRefs(refs) } }, by);
5772
6160
  // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
5773
6161
  const cohort = this.cohorts.of(task.id);
5774
6162
  const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
@@ -5799,19 +6187,23 @@ export class Tasks {
5799
6187
  }
5800
6188
  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
6189
  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);
6190
+ if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission, link, records, requestCheck, refresh, specInspection);
5803
6191
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
5804
6192
  if (refresh?.state === "approved") { await this.unmergedForReopen(task, valid); task = current(); }
5805
6193
  this.checking.set(task.id, task.owner);
5806
6194
  const checkedRefs = cleanRefs(refs);
6195
+ if (specInspection.report?.head && !checkedRefs.commit) checkedRefs.commit = specInspection.report.head;
6196
+ if (specInspection.report && specInspection.repo && !checkedRefs.repo) checkedRefs.repo = specInspection.repo;
6197
+ const storedRefs = { ...task.refs, ...checkedRefs };
5807
6198
  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);
6199
+ this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
6200
+ 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
6201
  if (refresh) { this.ciWatchers.cancel(pending.id); this.heldReviews.delete(pending.id); }
5810
6202
  this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
5811
6203
  const seen = { events: pending.history.length, owner: pending.owner };
5812
6204
  this.pendingChecks++;
5813
6205
  this.checkQueue = this.checkQueue
5814
- .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
6206
+ .then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld, specInspection))
5815
6207
  .catch((e: Error) => {
5816
6208
  this.releaseNotice(`task #${pending.id}: its check result could not be recorded: ${e.message}`);
5817
6209
  this.releaseNotice(`task #${pending.id}: its owner retry notice could not be published`, () => {
@@ -5914,7 +6306,7 @@ export class Tasks {
5914
6306
  return { ...(key ? { checkKey: key } : {}), ...(match ? { missingKeys: Number(match[2]) } : {}) };
5915
6307
  }
5916
6308
 
5917
- private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
6309
+ 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
6310
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
5919
6311
  this.checking.delete(id);
5920
6312
  const task = this.d.board.get(id);
@@ -5930,6 +6322,7 @@ export class Tasks {
5930
6322
  return;
5931
6323
  }
5932
6324
  if (result.code === 0 && !result.timedOut) {
6325
+ const refileForSpecs = this.refreshAsyncSpecInspection(specInspection, task, by);
5933
6326
  // An integrating member's check counts only for the target it confirmed (issue #107).
5934
6327
  const cohort = this.cohorts.of(id);
5935
6328
  if (cohort?.silent && !this.cohorts.holds(id, ownerGen(task), this.tree(cohort))) {
@@ -5938,8 +6331,9 @@ export class Tasks {
5938
6331
  return;
5939
6332
  }
5940
6333
  this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
6334
+ if (refileForSpecs) { this.requestSpecRefile(task); return; }
5941
6335
  // 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);
6336
+ 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
6337
  return;
5944
6338
  }
5945
6339
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim(), this.checkProvenance(task, result.tail));
@@ -5951,8 +6345,9 @@ export class Tasks {
5951
6345
  }
5952
6346
 
5953
6347
  /** `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> {
6348
+ 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
6349
  requestCheck();
6350
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5956
6351
  // A check's completion carries the records its `done (checking)` bound: the same bytes the owner delivered, not a later read.
5957
6352
  records ??= latestPendingDeliverables(task);
5958
6353
  const submittedCommit = cleanRefs(refs).commit;
@@ -5964,6 +6359,7 @@ export class Tasks {
5964
6359
  reviewEvidence = await this.lifecycleGate(task, reviewer ? "review" : "approval");
5965
6360
  requestCheck();
5966
6361
  task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
6362
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5967
6363
  selection = this.poolReview(task, refs);
5968
6364
  reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
5969
6365
  }
@@ -5972,6 +6368,7 @@ export class Tasks {
5972
6368
  admission = await this.reviewRequestAdmission(task, refs, false, by, reviewEvidence, undefined, staged => this.withLink(this.completionDraft(staged, draft), link, refs), !reviewer);
5973
6369
  requestCheck();
5974
6370
  task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, false, refresh), draft), link, refs);
6371
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5975
6372
  }
5976
6373
  selection = this.poolReview(task, refs);
5977
6374
  reviewer = selection?.reviewer ?? task.reviewer ?? (refresh?.state === "approved" ? USER : undefined);
@@ -5981,12 +6378,21 @@ export class Tasks {
5981
6378
  if (refresh?.state === "approved") {
5982
6379
  await this.unmergedForReopen(task, () => { requestCheck(); return this.completionCurrent(task.id, by, true, refresh); });
5983
6380
  task = this.withLink(this.completionDraft(this.completionCurrent(task.id, by, true, refresh), draft), link, refs);
6381
+ if (specInspection && asyncCheckCompletion && this.refreshAsyncSpecInspection(specInspection, task, by)) { this.requestSpecRefile(task); return task; }
5984
6382
  }
5985
6383
  const completedRefs = cleanRefs(refs);
5986
- this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
5987
- const noWrites = this.noWritesRecorded({ ...task, refs: { ...task.refs, ...completedRefs } });
6384
+ if (specInspection?.report?.head && !completedRefs.commit) completedRefs.commit = specInspection.report.head;
6385
+ if (specInspection?.report && specInspection.repo && !completedRefs.repo) completedRefs.repo = specInspection.repo;
6386
+ const storedRefs = { ...task.refs, ...completedRefs };
6387
+ if (specInspection) {
6388
+ if (asyncCheckCompletion) {
6389
+ if (this.refreshAsyncSpecInspection(specInspection, { ...task, refs: storedRefs }, by)) { this.requestSpecRefile(task); return task; }
6390
+ } else this.assertSpecInspection(specInspection, { ...task, refs: storedRefs }, by);
6391
+ }
6392
+ this.warmRepository({ ...task, refs: storedRefs });
6393
+ const noWrites = this.noWritesRecorded({ ...task, refs: storedRefs });
5988
6394
  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);
6395
+ 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
6396
  this.queueReviewWritten(next);
5991
6397
  this.writePlannerNotices(next, capacity, next.state === "approved");
5992
6398
  this.ciWatchers.cancel(next.id); this.heldReviews.delete(next.id); // a newer head or a move ends the old head's watcher (#785 P1b)
@@ -6417,17 +6823,19 @@ export class Tasks {
6417
6823
  briefText(task: Task): string {
6418
6824
  if (this.isPii(task)) return "";
6419
6825
  const approval = approvalText(approvalBinding(task));
6420
- const prefix = (brief: string) => [approval, brief].filter(Boolean).join("\n");
6421
- if (!task.refs.commit) return approval;
6826
+ const spec = this.currentSpecReport(task);
6827
+ const prefix = (brief: string) => [approval, spec ? renderSpecTaskReport(spec) : "", brief].filter(Boolean).join("\n");
6828
+ if (!task.refs.commit) return prefix("");
6422
6829
  try {
6423
6830
  const brief = this.d.reviewBrief?.(task);
6424
- if (!brief) return approval;
6831
+ if (!brief) return prefix("");
6425
6832
  return prefix("unavailable" in brief ? `Review brief unavailable: ${brief.unavailable}.` : sanitize(renderReviewBrief(brief)));
6426
6833
  } catch { return prefix("Review brief unavailable: it could not be built."); }
6427
6834
  }
6428
6835
 
6429
6836
  async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown, head?: unknown, coverage?: unknown, findings?: unknown): Promise<Task> {
6430
6837
  const requestCheck = this.requestCheck(by);
6838
+ if (typeof id !== "number" && !(typeof id === "string" && /^\s*\d+\s*$/.test(id))) this.need(id, false, true);
6431
6839
  const authority = by === USER ? undefined : this.d.reviewAuthority?.(by, Number(id));
6432
6840
  let findingGuard: ((task: Task) => void) | undefined;
6433
6841
  // #520: what the coverage was checked against, pinned through every await: the commit and the lifecycle receipt (the