@wildorder/nightshift 0.17.0 → 0.18.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 (99) hide show
  1. package/README.md +269 -1
  2. package/dist/agent-runner.d.ts +41 -3
  3. package/dist/agent-runner.d.ts.map +1 -1
  4. package/dist/agent-runner.js +197 -12
  5. package/dist/agent-runner.js.map +1 -1
  6. package/dist/agent-session.d.ts +123 -0
  7. package/dist/agent-session.d.ts.map +1 -0
  8. package/dist/agent-session.js +114 -0
  9. package/dist/agent-session.js.map +1 -0
  10. package/dist/atomic-write.d.ts +18 -0
  11. package/dist/atomic-write.d.ts.map +1 -0
  12. package/dist/atomic-write.js +74 -0
  13. package/dist/atomic-write.js.map +1 -0
  14. package/dist/author.d.ts +112 -6
  15. package/dist/author.d.ts.map +1 -1
  16. package/dist/author.js +597 -137
  17. package/dist/author.js.map +1 -1
  18. package/dist/causal-analysis.d.ts +6 -2
  19. package/dist/causal-analysis.d.ts.map +1 -1
  20. package/dist/causal-analysis.js +55 -2
  21. package/dist/causal-analysis.js.map +1 -1
  22. package/dist/cli.js +22 -2
  23. package/dist/cli.js.map +1 -1
  24. package/dist/concurrency.d.ts +24 -0
  25. package/dist/concurrency.d.ts.map +1 -0
  26. package/dist/concurrency.js +28 -0
  27. package/dist/concurrency.js.map +1 -0
  28. package/dist/decider-review.d.ts +10 -3
  29. package/dist/decider-review.d.ts.map +1 -1
  30. package/dist/decider-review.js +6 -4
  31. package/dist/decider-review.js.map +1 -1
  32. package/dist/decision-ledger.d.ts +7 -0
  33. package/dist/decision-ledger.d.ts.map +1 -1
  34. package/dist/decision-ledger.js +25 -10
  35. package/dist/decision-ledger.js.map +1 -1
  36. package/dist/detect-package-manager.d.ts +10 -0
  37. package/dist/detect-package-manager.d.ts.map +1 -1
  38. package/dist/detect-package-manager.js +12 -0
  39. package/dist/detect-package-manager.js.map +1 -1
  40. package/dist/index.d.ts +3 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/manifest.d.ts +24 -5
  45. package/dist/manifest.d.ts.map +1 -1
  46. package/dist/manifest.js +254 -108
  47. package/dist/manifest.js.map +1 -1
  48. package/dist/permits.d.ts +18 -0
  49. package/dist/permits.d.ts.map +1 -1
  50. package/dist/permits.js +155 -0
  51. package/dist/permits.js.map +1 -1
  52. package/dist/preflight.d.ts +2 -2
  53. package/dist/preflight.d.ts.map +1 -1
  54. package/dist/preflight.js.map +1 -1
  55. package/dist/review-pass.d.ts +52 -10
  56. package/dist/review-pass.d.ts.map +1 -1
  57. package/dist/review-pass.js +114 -30
  58. package/dist/review-pass.js.map +1 -1
  59. package/dist/run-analytics-report.d.ts +114 -1
  60. package/dist/run-analytics-report.d.ts.map +1 -1
  61. package/dist/run-analytics-report.js +606 -14
  62. package/dist/run-analytics-report.js.map +1 -1
  63. package/dist/run-analytics.d.ts +107 -1
  64. package/dist/run-analytics.d.ts.map +1 -1
  65. package/dist/run-analytics.js +257 -25
  66. package/dist/run-analytics.js.map +1 -1
  67. package/dist/run-log.d.ts +16 -0
  68. package/dist/run-log.d.ts.map +1 -0
  69. package/dist/run-log.js +32 -0
  70. package/dist/run-log.js.map +1 -0
  71. package/dist/run-program.d.ts +203 -7
  72. package/dist/run-program.d.ts.map +1 -1
  73. package/dist/run-program.js +1526 -320
  74. package/dist/run-program.js.map +1 -1
  75. package/dist/scheduler.d.ts +75 -0
  76. package/dist/scheduler.d.ts.map +1 -0
  77. package/dist/scheduler.js +126 -0
  78. package/dist/scheduler.js.map +1 -0
  79. package/dist/verify-cache.d.ts +64 -0
  80. package/dist/verify-cache.d.ts.map +1 -0
  81. package/dist/verify-cache.js +56 -0
  82. package/dist/verify-cache.js.map +1 -0
  83. package/dist/whole-program-review.d.ts +2 -2
  84. package/dist/whole-program-review.d.ts.map +1 -1
  85. package/dist/whole-program-review.js.map +1 -1
  86. package/dist/worktree.d.ts +124 -0
  87. package/dist/worktree.d.ts.map +1 -0
  88. package/dist/worktree.js +397 -0
  89. package/dist/worktree.js.map +1 -0
  90. package/dist/write-queue.d.ts +25 -0
  91. package/dist/write-queue.d.ts.map +1 -0
  92. package/dist/write-queue.js +52 -0
  93. package/dist/write-queue.js.map +1 -0
  94. package/package.json +2 -2
  95. package/skills/plan-program/SKILL.md +37 -5
  96. package/dist/worktree-guard.d.ts +0 -40
  97. package/dist/worktree-guard.d.ts.map +0 -1
  98. package/dist/worktree-guard.js +0 -91
  99. package/dist/worktree-guard.js.map +0 -1
@@ -1,27 +1,34 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
4
5
  import { join, resolve } from "node:path";
5
6
  import { promisify } from "node:util";
6
7
  import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveAuthorAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
8
+ import { describeSessionDecline, spawnWithSessionContinuity, SESSION_CONTINUATION_PREAMBLE, } from "./agent-session.js";
7
9
  import { resolveSummary, summaryContract } from "./agent-summary.js";
8
- import { authorProgram } from "./author.js";
10
+ import { prepareAuthoring } from "./author.js";
9
11
  import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
10
12
  import { appendLedgerEvents, denialFingerprint, readDecisionLedger, } from "./decision-ledger.js";
11
13
  import { escalatedRecords, renderDenial, renderRecord, } from "./decision-view.js";
12
14
  import { reviewDecisions, triageFindings } from "./decider-review.js";
13
15
  import { fingerprint } from "./findings.js";
14
16
  import { findCycles, stableTopologicalOrder } from "./graph.js";
15
- import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, renderPassReport, reviewerAbsentOutcome, runReviewPass, verifyEvidence, } from "./review-pass.js";
17
+ import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, renderPassReport, reviewerAbsentOutcome, runReviewPass, verifyEvidence, TEST_CRITIQUE_CYCLES, TEST_CRITIQUE_CYCLE_NOTE, } from "./review-pass.js";
18
+ import { createVerifyCache, runnerOwnedVerifyPaths } from "./verify-cache.js";
16
19
  import { loadManifest, manifestMergeCount, saveManifest, } from "./manifest.js";
17
20
  import { detectDefaultBranch, programBranchName } from "./program-branch.js";
18
21
  import { defaultPrerequisiteRunner, runPreflight, } from "./preflight.js";
19
- import { restoreProgramsDir, snapshotProgramsDir } from "./programs-dir.js";
20
22
  import { buildPermitsContext } from "./permits.js";
21
23
  import { CouldNotStartError } from "./exit-codes.js";
22
24
  import { clippedInputPoint, createBriefBuilder, promptComponentSizePoints, } from "./prompt-telemetry.js";
23
- import { NOOP_RUN_RECORDER, readPriorRunId, RunRecorder, } from "./run-analytics.js";
25
+ import { NOOP_RUN_RECORDER, readPriorRunId, recordWorkstreamReady, recordWorkstreamScheduled, recordWorkstreamWaiting, RunRecorder, } from "./run-analytics.js";
24
26
  import { renderAnalyticsSection } from "./run-analytics-report.js";
27
+ import { drainProgramWrites, programWriteKey } from "./write-queue.js";
28
+ import { schedulerLog, workstreamLog } from "./run-log.js";
29
+ import { createMutex } from "./concurrency.js";
30
+ import { normalizeMaxParallel, runReadySet } from "./scheduler.js";
31
+ import { defaultWorktreeOps, isRunnerOwnedArtifactPath, resolveWorktreeBase, retainedRefName, worktreeBranchName, worktreeBranchPrefix, worktreeInstallCommand, worktreePathFor, } from "./worktree.js";
25
32
  import { runReportPath } from "./report-path.js";
26
33
  import { AS_BUILT_PATH, runWholeProgramReview, renderWholeProgramReview, } from "./whole-program-review.js";
27
34
  import { runCausalAnalysis, renderCausalAnalysisSection, } from "./causal-analysis.js";
@@ -146,8 +153,13 @@ export const defaultGitOps = {
146
153
  return undefined;
147
154
  }
148
155
  },
149
- async commitAll(cwd, message) {
150
- await execFileAsync("git", ["add", "-A"], { cwd });
156
+ async commitAll(cwd, message, excludePaths) {
157
+ if (excludePaths && excludePaths.length > 0) {
158
+ await execFileAsync("git", ["add", "-A", "--", ":(top)", ...excludePaths.map((path) => `:(top,exclude)${path}`)], { cwd });
159
+ }
160
+ else {
161
+ await execFileAsync("git", ["add", "-A"], { cwd });
162
+ }
151
163
  try {
152
164
  await execFileAsync("git", ["commit", "-m", message], { cwd });
153
165
  }
@@ -164,7 +176,12 @@ export const defaultGitOps = {
164
176
  async commitPaths(cwd, message, paths) {
165
177
  await execFileAsync("git", ["add", "--", ...paths], { cwd });
166
178
  try {
167
- await execFileAsync("git", ["commit", "-m", message], { cwd });
179
+ // The trailing pathspec is load-bearing, not decoration: without it
180
+ // this commits the *whole* index, sweeping in anything else staged in
181
+ // `cwd` at the time (an unrelated human `git add`, or the runner's own
182
+ // residue from a prior failed commit) — the same hazard squashIntegrate
183
+ // guards against for the workstream integration commit (WS-02 §2, §3.5).
184
+ await execFileAsync("git", ["commit", "-m", message, "--", ...paths], { cwd });
168
185
  }
169
186
  catch (error) {
170
187
  const output = String(error.stdout ?? "");
@@ -181,7 +198,10 @@ export const defaultGitOps = {
181
198
  },
182
199
  async diffSince(cwd, commit) {
183
200
  try {
184
- const { stdout } = await execFileAsync("git", ["diff", commit, "--", ".", ":!docs/programs"], { cwd, maxBuffer: 10 * 1024 * 1024 });
201
+ // `:(top)`-anchored pathspecs are repo-root-relative regardless of
202
+ // `cwd` — in a worktree, "repo root" is the worktree root, exactly the
203
+ // tree a reviewer or decider spawned there should see (WS-02).
204
+ const { stdout } = await execFileAsync("git", ["diff", commit, "--", ":(top)", ":(top,exclude)docs/programs"], { cwd, maxBuffer: 10 * 1024 * 1024 });
185
205
  return stdout;
186
206
  }
187
207
  catch {
@@ -190,11 +210,20 @@ export const defaultGitOps = {
190
210
  },
191
211
  async dirtyPaths(cwd) {
192
212
  try {
193
- const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd });
194
- return stdout
195
- .split(/\r?\n/u)
196
- .filter((line) => line.trim() !== "")
197
- .map((line) => line.slice(3).trim());
213
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-z"], { cwd });
214
+ // NUL-delimited so quoted and non-ASCII names come back verbatim and
215
+ // compare equal to `changedPaths`' `-z` output. A rename or copy entry
216
+ // carries its original path as a second token, which is skipped.
217
+ const tokens = stdout.split("\0").filter((token) => token !== "");
218
+ const paths = [];
219
+ for (let index = 0; index < tokens.length; index += 1) {
220
+ const token = tokens[index];
221
+ const code = token.slice(0, 2);
222
+ paths.push(token.slice(3));
223
+ if (code.includes("R") || code.includes("C"))
224
+ index += 1;
225
+ }
226
+ return paths;
198
227
  }
199
228
  catch {
200
229
  return [];
@@ -236,6 +265,43 @@ export const defaultGitOps = {
236
265
  async createRef(cwd, ref, commit) {
237
266
  await execFileAsync("git", ["update-ref", ref, commit], { cwd });
238
267
  },
268
+ async deleteRef(cwd, ref) {
269
+ try {
270
+ await execFileAsync("git", ["update-ref", "-d", ref], { cwd });
271
+ }
272
+ catch {
273
+ // Already absent — nothing to delete.
274
+ }
275
+ },
276
+ async treeHash(cwd, exclude) {
277
+ let tmpDir;
278
+ try {
279
+ tmpDir = await mkdtemp(join(tmpdir(), "nightshift-verify-cache-"));
280
+ const indexFile = join(tmpDir, "index");
281
+ const env = { ...process.env, GIT_INDEX_FILE: indexFile };
282
+ await execFileAsync("git", ["read-tree", "HEAD"], { cwd, env });
283
+ await execFileAsync("git", ["add", "-A", "--", ":/"], { cwd, env });
284
+ if (exclude.length > 0) {
285
+ await execFileAsync("git", ["rm", "--cached", "-q", "--ignore-unmatch", "--", ...exclude], { cwd, env });
286
+ }
287
+ const { stdout } = await execFileAsync("git", ["write-tree"], { cwd, env });
288
+ return stdout.trim();
289
+ }
290
+ catch {
291
+ return undefined;
292
+ }
293
+ finally {
294
+ if (tmpDir !== undefined) {
295
+ try {
296
+ await rm(tmpDir, { recursive: true, force: true });
297
+ }
298
+ catch {
299
+ // Best-effort cleanup; a leftover temp dir is not a correctness
300
+ // issue and must not mask the hash result above.
301
+ }
302
+ }
303
+ }
304
+ },
239
305
  };
240
306
  /**
241
307
  * Fail-open span wrapper for a runner-owned git-and-persistence operation
@@ -410,7 +476,7 @@ function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, prior
410
476
  : []));
411
477
  builder.push("framing", "## Specification", "");
412
478
  builder.push("workstream-spec", spec.trim());
413
- builder.push("framing", "", "## Rules", "", "- Never commit; the runner owns commits and commits only verified work.", "- Follow the repository's AGENTS.md if present.", "- Run the project's checks yourself as you work; the runner verifies", " independently afterwards and your claim of success is never trusted", " without it.", "");
479
+ builder.push("framing", "", "## Rules", "", "- Never commit; the runner owns commits and commits only verified work.", "- Follow the repository's AGENTS.md if present, with one exception,", " stated next.", "- Run only the checks that cover what you changed — the tests for the", " code you touched, and the command that was failing if you were given", " a failure above. Do not run the project's full build/type-check/test/", " lint suite: the runner runs it in full after you and gates on the", " result, so a full run of your own is duplicated work, not extra", " safety. This overrides AGENTS.md's \"verify before claiming", " completion\" directive for this spawn only. Your claim of success is", " never trusted either way, so you are not being asked to prove", " anything here — only to catch your own mistakes early.", "- You are working in a dedicated git worktree on a per-workstream", " branch. Do not switch branches, and do not edit `docs/programs/` —", " those are the runner's own artifacts and changes to them are", " discarded.", "");
414
480
  builder.push("runner-instructions", ...shellPolicySection(shellPolicy), decisionContract(), "", summaryContract());
415
481
  return { brief: builder.join(), components: builder.components() };
416
482
  }
@@ -527,6 +593,522 @@ async function permissionsStartupLines(config, permits, agent, recovery, decider
527
593
  }
528
594
  return lines;
529
595
  }
596
+ /**
597
+ * Reaps every worktree and per-workstream branch this program created,
598
+ * driven by `git worktree list --porcelain` filtered on the branch prefix —
599
+ * never on path, since git realpath-resolves the paths it reports (WS-02,
600
+ * §3.7). Runs on every run, not only a resume: a crashed prior run leaves the
601
+ * same debris. Retained `refs/nightshift/…` commits are never touched — they
602
+ * are the forensic record a human needs to salvage a parked integration.
603
+ */
604
+ export async function reapProgramWorktrees(root, programId, worktrees, log,
605
+ /** Workstreams already `complete` in the manifest: their leftover
606
+ * worktree (a crash between integration and cleanup) holds nothing the
607
+ * program branch lacks, so it is reaped without salvage. */
608
+ completeWorkstreamIds = new Set()) {
609
+ const prefix = worktreeBranchPrefix(programId);
610
+ const reaped = [];
611
+ const salvaged = [];
612
+ const list = await worktrees.list(root);
613
+ for (const info of list) {
614
+ const short = info.branch?.startsWith("refs/heads/")
615
+ ? info.branch.slice("refs/heads/".length)
616
+ : info.branch;
617
+ if (short === undefined || !short.startsWith(prefix))
618
+ continue;
619
+ const ref = await salvageWorktree(root, programId, short, info, worktrees, completeWorkstreamIds);
620
+ if (ref !== undefined)
621
+ salvaged.push(ref);
622
+ await worktrees.remove(root, info.path);
623
+ await worktrees.deleteBranch(root, short);
624
+ reaped.push(short);
625
+ }
626
+ await worktrees.prune(root);
627
+ const orphanBranches = await worktrees.branchesUnder(root, prefix);
628
+ for (const branch of orphanBranches) {
629
+ await worktrees.deleteBranch(root, branch);
630
+ if (!reaped.includes(branch))
631
+ reaped.push(branch);
632
+ }
633
+ if (salvaged.length > 0) {
634
+ log(`worktrees: salvaged unintegrated work from a prior run before reaping — inspect with ` +
635
+ `\`git log <ref>\` or \`git worktree add <path> <ref>\`: ${salvaged.join(", ")}`);
636
+ }
637
+ if (reaped.length > 0) {
638
+ log(`worktrees: reaped ${reaped.length} leftover branch(es) from a prior run: ${reaped.join(", ")}`);
639
+ }
640
+ }
641
+ /**
642
+ * Preserves a leftover worktree's work before it is reaped — the
643
+ * reversibility contract: a resume must never destroy the only copy of a
644
+ * failed attempt that the run report just told the human to inspect.
645
+ * Everything in the worktree is committed onto its own branch and, when
646
+ * that head is not already reachable from the program branch, pinned under
647
+ * `refs/nightshift/<program>/<workstream>-wip`. Returns the ref, or undefined
648
+ * when nothing needed salvaging (the workstream is complete, the directory
649
+ * is gone, or the `WorktreeOps` double has no salvage seam). Never throws:
650
+ * salvage is best-effort and must not block the run.
651
+ */
652
+ async function salvageWorktree(root, programId, shortBranch, info, worktrees, completeWorkstreamIds) {
653
+ if (!worktrees.commitEverything || !worktrees.updateRef || !worktrees.isAncestor)
654
+ return undefined;
655
+ if (info.prunable === true)
656
+ return undefined;
657
+ const workstreamId = shortBranch.slice(worktreeBranchPrefix(programId).length);
658
+ if (workstreamId === "")
659
+ return undefined;
660
+ const lowered = new Set([...completeWorkstreamIds].map((id) => id.toLowerCase()));
661
+ if (lowered.has(workstreamId))
662
+ return undefined;
663
+ try {
664
+ const head = await worktrees.commitEverything(info.path, `nightshift(${programId}): salvaged work in progress for ${workstreamId}`);
665
+ if (head === undefined)
666
+ return undefined;
667
+ if (await worktrees.isAncestor(root, head, "HEAD"))
668
+ return undefined;
669
+ const ref = `${retainedRefName(programId, workstreamId)}-wip`;
670
+ await worktrees.updateRef(root, ref, head);
671
+ return ref;
672
+ }
673
+ catch {
674
+ return undefined;
675
+ }
676
+ }
677
+ const INSTALL_TRIGGER_NAMES = [
678
+ "package.json",
679
+ "package-lock.json",
680
+ "pnpm-lock.yaml",
681
+ "yarn.lock",
682
+ "bun.lockb",
683
+ "bun.lock",
684
+ ];
685
+ /** True when any path in `paths` is (or ends with) a file that can change a worktree's installed dependencies (WS-02, §3.5 step 1). */
686
+ function touchesInstallFiles(paths) {
687
+ return paths.some((path) => INSTALL_TRIGGER_NAMES.some((name) => path === name || path.endsWith(`/${name}`)));
688
+ }
689
+ /**
690
+ * Runs a worktree's dependency install command, when a lockfile is present
691
+ * in the worktree root itself (WS-02, §3.3) — attributed to the caller's
692
+ * open `worktree-setup` span. A worktree with no lockfile installs nothing
693
+ * and is not a failure.
694
+ */
695
+ async function installWorktreeDependencies(worktreeRoot, setupRunner, log) {
696
+ const installCommand = worktreeInstallCommand(worktreeRoot);
697
+ if (installCommand === undefined) {
698
+ log("worktree: no lockfile — dependencies not installed");
699
+ return { ok: true };
700
+ }
701
+ let result;
702
+ try {
703
+ result = await setupRunner(installCommand, worktreeRoot);
704
+ }
705
+ catch (error) {
706
+ return { ok: false, reason: `dependency install could not run: ${error.message}` };
707
+ }
708
+ if (result.exitCode !== 0) {
709
+ return {
710
+ ok: false,
711
+ reason: `dependency install (\`${installCommand}\`) failed:\n${tail(result.output, 1500)}`,
712
+ };
713
+ }
714
+ log(`worktree: installed dependencies (${installCommand})`);
715
+ return { ok: true };
716
+ }
717
+ export async function prepareWorktree(args) {
718
+ const { root, programId, workstreamId, worktrees, worktreeBase, setupRunner, log, programTip } = args;
719
+ const branch = worktreeBranchName(programId, workstreamId);
720
+ const path = worktreePathFor(worktreeBase.base, programId, workstreamId, worktreeBase.repoHash);
721
+ try {
722
+ // Idempotent belt-and-braces against a crash-leftover from this same
723
+ // run; cross-run leftovers are reaped at run start.
724
+ await worktrees.remove(root, path);
725
+ await worktrees.deleteBranch(root, branch);
726
+ // `remove` is a no-op for a directory git no longer lists (a prior
727
+ // `worktree prune` after a crash); `add` would then refuse with
728
+ // "already exists". The path is program-owned, so clearing it is safe.
729
+ await rm(path, { recursive: true, force: true });
730
+ await worktrees.add(root, path, branch, programTip);
731
+ }
732
+ catch (error) {
733
+ await worktrees.remove(root, path);
734
+ await worktrees.deleteBranch(root, branch);
735
+ return { ok: false, reason: `could not create the worktree: ${error.message}`, created: false };
736
+ }
737
+ log(`worktree: created at ${path}`);
738
+ const install = await installWorktreeDependencies(path, setupRunner, log);
739
+ if (!install.ok) {
740
+ return { ok: false, reason: install.reason, created: true, cwd: path, branch };
741
+ }
742
+ return { ok: true, cwd: path, branch };
743
+ }
744
+ /**
745
+ * One integration attempt: discard runner debris, merge the program branch
746
+ * forward when its tip has moved, re-verify the merged tree, then squash
747
+ * onto the program branch as one path-limited commit (WS-02, §3.5). Never
748
+ * throws; every failure mode is a typed result the caller decides what to do
749
+ * with (retain and recover, or report).
750
+ */
751
+ async function attemptIntegration(args) {
752
+ const { root, programId, workstreamId, workstreamName, cwd, branch, finalCommit, worktrees, git, config, verifyRunner, setupRunner, scope, log, rootIndexMutex, rosterTaskFiles, } = args;
753
+ // The full refname, not the short branch name: `refs/nightshift/<p>/<ws>`
754
+ // (the retained ref) and `refs/heads/nightshift/<p>/<ws>` (this branch)
755
+ // are distinct refs that never collide, but git's own short-name
756
+ // disambiguation tries `refs/<name>` *before* `refs/heads/<name>` — a bare
757
+ // `nightshift/<p>/<ws>` passed to `git diff`/`git merge --squash` resolves
758
+ // ambiguously and can silently pick the retained ref instead of the
759
+ // branch. Every revision-parsing use below is spelled out in full; only
760
+ // `deleteBranch`, which operates on `refs/heads/` by construction, uses
761
+ // the short name.
762
+ const branchRef = `refs/heads/${branch}`;
763
+ // Steps 0-1: discard runner debris, then merge the program branch forward
764
+ // when its tip is not already an ancestor of this worktree's own commit.
765
+ const mergeSpan = scope.span({ stage: "integration", workstream: workstreamId });
766
+ const discarded = await worktrees.discardRunnerArtifacts(cwd);
767
+ if (discarded.length > 0) {
768
+ log(`integration: discarded agent edit(s) under docs/programs: ${discarded.join(", ")}`);
769
+ }
770
+ const tipNow = await worktrees.resolveCommit(root, "HEAD");
771
+ if (tipNow === undefined) {
772
+ mergeSpan.close({ outcome: "failed" });
773
+ return { ok: false, kind: "tip-unresolvable" };
774
+ }
775
+ let mergeRan = false;
776
+ if (!(await git.isAncestor(cwd, tipNow, finalCommit))) {
777
+ mergeRan = true;
778
+ const mergeResult = await worktrees.mergeInto(cwd, tipNow, `nightshift(${programId}): merge program branch into ${workstreamId}`);
779
+ if (!mergeResult.ok) {
780
+ await worktrees.abortMerge(cwd);
781
+ mergeSpan.close({ outcome: "failed" });
782
+ return { ok: false, kind: "conflict", output: mergeResult.output, tipNow };
783
+ }
784
+ log("integration: merged the program branch forward");
785
+ const changedByMerge = await worktrees.changedPaths(cwd, finalCommit, "HEAD");
786
+ if (touchesInstallFiles(changedByMerge)) {
787
+ const install = await installWorktreeDependencies(cwd, setupRunner, log);
788
+ if (!install.ok) {
789
+ mergeSpan.close({ outcome: "failed" });
790
+ return { ok: false, kind: "install", output: install.reason, tipNow };
791
+ }
792
+ }
793
+ }
794
+ mergeSpan.close({ outcome: "success" });
795
+ // Step 2: re-verify, but only when the merge actually ran — an unmoved tip
796
+ // means the tree is byte-identical to the one that just verified green.
797
+ if (mergeRan) {
798
+ log("integration: verifying the merged tree");
799
+ const verifyFailure = await verifyAttempt(config, verifyRunner, cwd, root, 0, scope, "verification-command", {
800
+ programId,
801
+ phase: "integration",
802
+ workstream: workstreamId,
803
+ ...(args.verifyCache ? { cache: args.verifyCache } : {}),
804
+ });
805
+ if (verifyFailure !== undefined) {
806
+ return { ok: false, kind: "verify", output: verifyFailure, tipNow };
807
+ }
808
+ }
809
+ // Step 3: squash onto the program branch, in the root checkout.
810
+ const squashSpan = scope.span({ stage: "integration", workstream: workstreamId });
811
+ const incoming = await worktrees.changedPaths(root, tipNow, branchRef);
812
+ if (incoming.length === 0) {
813
+ squashSpan.close({ outcome: "success" });
814
+ log("integration: nothing to integrate — the branch matches the tip");
815
+ return { ok: true };
816
+ }
817
+ // WS-05 §3.7.1: everything from here on runs inside `rootIndexMutex`,
818
+ // which (together with `integrationMutex`, already held by the caller for
819
+ // this whole flow) makes the re-resolved tip below stable for the rest of
820
+ // this step. The only thing that can have moved the program-branch tip
821
+ // since `tipNow` was resolved above is an authoring commit — it takes no
822
+ // `integrationMutex` and must not (§3.7) — so a movement here is always
823
+ // exactly that, never a sibling integration.
824
+ return await rootIndexMutex.runExclusive(async () => {
825
+ const tipAtSquash = await worktrees.resolveCommit(root, "HEAD");
826
+ if (tipAtSquash === undefined) {
827
+ squashSpan.close({ outcome: "failed" });
828
+ return { ok: false, kind: "tip-unresolvable" };
829
+ }
830
+ const movedPaths = tipAtSquash === tipNow ? [] : await worktrees.changedPaths(root, tipNow, tipAtSquash);
831
+ if (movedPaths.length > 0) {
832
+ // Branch 3: the movement collides with this workstream's own paths, or
833
+ // touches something an authoring commit has no business touching —
834
+ // never proceed past a movement like that; land it unverified would be
835
+ // exactly the unproven integration SC-02 forbids.
836
+ const intersecting = movedPaths.filter((path) => incoming.includes(path));
837
+ const disallowed = movedPaths.filter((path) => !isRunnerOwnedArtifactPath(path) && !rosterTaskFiles.has(path));
838
+ if (intersecting.length > 0 || disallowed.length > 0) {
839
+ squashSpan.close({ outcome: "failed" });
840
+ const parts = [];
841
+ if (intersecting.length > 0) {
842
+ parts.push(`path(s) this workstream also touched: ${intersecting.join(", ")}`);
843
+ }
844
+ if (disallowed.length > 0) {
845
+ parts.push(`path(s) outside the runner's own artifacts or a roster spec file: ${disallowed.join(", ")}`);
846
+ }
847
+ return {
848
+ ok: false,
849
+ kind: "moved",
850
+ tipNow,
851
+ tipAtSquash,
852
+ output: `the program branch moved from \`${tipNow}\` to \`${tipAtSquash}\` while this ` +
853
+ `workstream was integrating (an authoring commit landed); ${parts.join("; ")}.`,
854
+ };
855
+ }
856
+ }
857
+ // Branch 2 (movedPaths non-empty but disjoint from `incoming` and every
858
+ // path runner-owned or a roster spec file) falls through to the same
859
+ // squash as branch 1 (no movement) — only the post-condition below
860
+ // differs, relaxed to the subset form §3.7.1 requires.
861
+ const dirtyAtRoot = new Set(await git.dirtyPaths(root));
862
+ const overlap = incoming.filter((path) => dirtyAtRoot.has(path));
863
+ if (overlap.length > 0) {
864
+ squashSpan.close({ outcome: "failed" });
865
+ return {
866
+ ok: false,
867
+ kind: "refused",
868
+ output: `the workstream changed path(s) already uncommitted at the root checkout: ${overlap.join(", ")}`,
869
+ tipNow,
870
+ };
871
+ }
872
+ const squash = await worktrees.squashIntegrate(root, branchRef, `nightshift(${programId}): ${workstreamId} ${workstreamName}`, incoming);
873
+ if (!squash.ok) {
874
+ squashSpan.close({ outcome: "failed" });
875
+ return { ok: false, kind: "refused", output: squash.output, tipNow };
876
+ }
877
+ if (squash.commit !== undefined) {
878
+ // Post-condition: the integrated tree equals the branch's tree, up to
879
+ // paths the concurrent tip movement (if any) already explains — a
880
+ // path list that produced the commit was incomplete, or a movement
881
+ // that collided, is worse than a failed integration.
882
+ const remaining = await worktrees.changedPaths(root, squash.commit, branchRef);
883
+ const unexplained = remaining.filter((path) => !movedPaths.includes(path));
884
+ if (unexplained.length > 0) {
885
+ squashSpan.close({ outcome: "failed" });
886
+ return {
887
+ ok: false,
888
+ kind: "refused",
889
+ output: `the integration commit's tree does not match the branch's: ${unexplained.join(", ")}`,
890
+ tipNow,
891
+ };
892
+ }
893
+ }
894
+ squashSpan.close({ outcome: "success" });
895
+ log(squash.commit !== undefined
896
+ ? `integration: integrated as \`${squash.commit}\``
897
+ : "integration: nothing to commit");
898
+ return { ok: true, ...(squash.commit === undefined ? {} : { commit: squash.commit }) };
899
+ });
900
+ }
901
+ function integrationFailureText(result, finalCommit) {
902
+ if (result.kind === "tip-unresolvable") {
903
+ return "cannot resolve the program-branch tip";
904
+ }
905
+ // Every branch below names both commits the recovery brief and the
906
+ // outcome reason need: the verified commit this worktree built, and the
907
+ // program-branch tip it could not be reconciled with (WS-02 §3.6 step 6).
908
+ // "conflict" was the only kind that did this until the coverage review
909
+ // caught that a red integration verify, a failed post-merge reinstall, or
910
+ // a refused squash all left a diagnosis that named neither.
911
+ const verb = result.kind === "conflict"
912
+ ? `merging the program branch (now at \`${result.tipNow}\`) into the verified commit \`${finalCommit}\` conflicted`
913
+ : result.kind === "verify"
914
+ ? `the merged tree (program branch now at \`${result.tipNow}\`, verified commit \`${finalCommit}\`) failed re-verification`
915
+ : result.kind === "install"
916
+ ? `reinstalling dependencies after merging the program branch (now at \`${result.tipNow}\`) into the verified commit \`${finalCommit}\` failed`
917
+ // "moved" (WS-05 §3.7.1): a concurrent authoring commit moved the
918
+ // tip during this integration's own squash step, colliding with
919
+ // this workstream's paths or touching something it should not
920
+ // have — named by both shas, never retried in place.
921
+ : result.kind === "moved"
922
+ ? `landing the verified commit \`${finalCommit}\` (scheduled from tip \`${result.tipNow}\`) conflicted with an authoring commit that landed during integration (program branch now at \`${result.tipAtSquash}\`)`
923
+ : `the squash integration of the verified commit \`${finalCommit}\` onto the program branch (now at \`${result.tipNow}\`) failed`;
924
+ return `${verb}:\n${result.output}`;
925
+ }
926
+ function integrationRecoveryBrief(workstream, finalCommit, retainedRef, tipNow, failureText) {
927
+ return [
928
+ `# Integration recovery: ${workstream.id} ${workstream.name}`,
929
+ "",
930
+ "This workstream's build already verified green, as commit",
931
+ `\`${finalCommit}\`, reachable from this worktree as \`${retainedRef}\` — `,
932
+ "`git show`, `git diff`, and `git cherry-pick` against that ref all",
933
+ "work from here even though it is not checked out. Since then, the",
934
+ `program branch moved to a new tip, \`${tipNow}\`, and landing the`,
935
+ "verified work on top of it failed:",
936
+ "",
937
+ failureText,
938
+ "",
939
+ "Your job is to land the workstream's *intent* on top of the current",
940
+ "tip — not to reproduce the old commit byte for byte. Resolve whatever",
941
+ "conflicted, or fix whatever the merged tree's verification found,",
942
+ "however that takes: editing the code, editing the tests the failed",
943
+ "attempt wrote, or both.",
944
+ "",
945
+ "You are working in a dedicated git worktree on a per-workstream",
946
+ "branch. Never commit — the runner owns commits and verifies your fix",
947
+ "itself. Do not switch branches, and do not edit `docs/programs/` —",
948
+ "those are the runner's own artifacts and changes to them are",
949
+ "discarded.",
950
+ "",
951
+ summaryContract(),
952
+ ].join("\n");
953
+ }
954
+ /**
955
+ * Integrates one green worktree onto the program branch (WS-02, §3.5-§3.6):
956
+ * one attempt, and on failure, exactly one bounded recovery attempt in a
957
+ * fresh worktree from the current tip, briefed with both commits and the
958
+ * failure. The verified commit is retained under `refs/nightshift/` on
959
+ * every path, before any mutation, and deleted again only once this
960
+ * function returns success.
961
+ */
962
+ export async function integrateWorkstream(args) {
963
+ const { root, programId, workstream, cwd, branch, finalCommit, worktrees, git, config, verifyRunner, setupRunner, agentRunner, permits, recoveryAgent, worktreeBase, scope, log, transcriptSink, rootIndexMutex, rosterTaskFiles, } = args;
964
+ const ref = retainedRefName(programId, workstream.id);
965
+ const cleanup = [{ path: cwd, branch }];
966
+ // Full refname, not the short branch name: see attemptIntegration's own
967
+ // note on why a bare short name can resolve ambiguously once a
968
+ // same-named `refs/nightshift/...` ref exists (e.g. left over from a
969
+ // prior run's failed integration of this same workstream).
970
+ await git.createRef(root, ref, `refs/heads/${branch}`);
971
+ const first = await attemptIntegration({
972
+ root,
973
+ programId,
974
+ workstreamId: workstream.id,
975
+ workstreamName: workstream.name,
976
+ cwd,
977
+ branch,
978
+ finalCommit,
979
+ worktrees,
980
+ git,
981
+ config,
982
+ verifyRunner,
983
+ setupRunner,
984
+ ...(args.verifyCache ? { verifyCache: args.verifyCache } : {}),
985
+ scope,
986
+ log,
987
+ rootIndexMutex,
988
+ rosterTaskFiles,
989
+ });
990
+ if (first.ok) {
991
+ return { ok: true, ...(first.commit === undefined ? {} : { commit: first.commit }), transcripts: [], cleanup };
992
+ }
993
+ const firstReason = integrationFailureText(first, finalCommit);
994
+ log(`integration: ${firstReason}`);
995
+ const recoveryBranch = `${worktreeBranchName(programId, workstream.id)}-integration`;
996
+ const recoveryPath = worktreePathFor(worktreeBase.base, programId, `${workstream.id}-integration`, worktreeBase.repoHash);
997
+ const transcripts = [];
998
+ const recoveryTip = await worktrees.resolveCommit(root, "HEAD");
999
+ if (recoveryTip === undefined) {
1000
+ return {
1001
+ ok: false,
1002
+ reason: `${firstReason}\n\nRecovery could not start: the program-branch tip could not be resolved.`,
1003
+ retainedRef: ref,
1004
+ worktreePaths: [cwd],
1005
+ transcripts,
1006
+ };
1007
+ }
1008
+ try {
1009
+ await worktrees.remove(root, recoveryPath);
1010
+ await worktrees.deleteBranch(root, recoveryBranch);
1011
+ await worktrees.add(root, recoveryPath, recoveryBranch, recoveryTip);
1012
+ }
1013
+ catch (error) {
1014
+ return {
1015
+ ok: false,
1016
+ reason: `${firstReason}\n\nRecovery could not start: the recovery worktree could not be ` +
1017
+ `created (${error.message}).`,
1018
+ retainedRef: ref,
1019
+ worktreePaths: [cwd],
1020
+ transcripts,
1021
+ };
1022
+ }
1023
+ cleanup.push({ path: recoveryPath, branch: recoveryBranch });
1024
+ const install = await installWorktreeDependencies(recoveryPath, setupRunner, log);
1025
+ if (!install.ok) {
1026
+ return {
1027
+ ok: false,
1028
+ reason: `${firstReason}\n\nRecovery could not start: ${install.reason}`,
1029
+ retainedRef: ref,
1030
+ worktreePaths: [cwd, recoveryPath],
1031
+ transcripts,
1032
+ };
1033
+ }
1034
+ const cause = first.kind === "verify" || first.kind === "install" ? "integration-verify" : "integration-conflict";
1035
+ const failureTipNow = first.kind === "tip-unresolvable" ? recoveryTip : first.tipNow;
1036
+ const brief = integrationRecoveryBrief(workstream, finalCommit, ref, failureTipNow, firstReason);
1037
+ let invocation;
1038
+ try {
1039
+ invocation = await invokeAgent(agentRunner, recoveryAgent, brief, recoveryPath, permits, "recoveryAgent", transcriptSink("integration-recovery"), scope, { stage: "recovery", workstream: workstream.id, attemptSeat: "integration recovery", attemptReason: cause });
1040
+ if (invocation.transcript)
1041
+ transcripts.push(invocation.transcript);
1042
+ }
1043
+ catch (error) {
1044
+ return {
1045
+ ok: false,
1046
+ reason: `${firstReason}\n\nThe recovery agent could not be spawned: ${error.message}`,
1047
+ retainedRef: ref,
1048
+ worktreePaths: [cwd, recoveryPath],
1049
+ transcripts,
1050
+ };
1051
+ }
1052
+ const recoveryVerifyFailure = await verifyAttempt(config, verifyRunner, recoveryPath, root, invocation.exitCode, scope, "verification-command", {
1053
+ programId,
1054
+ phase: "integration-recovery",
1055
+ workstream: workstream.id,
1056
+ role: "recoveryAgent",
1057
+ attemptSeat: "integration recovery",
1058
+ });
1059
+ if (recoveryVerifyFailure !== undefined) {
1060
+ return {
1061
+ ok: false,
1062
+ reason: `${firstReason}\n\nThe recovery attempt failed verification: ${recoveryVerifyFailure}`,
1063
+ retainedRef: ref,
1064
+ worktreePaths: [cwd, recoveryPath],
1065
+ transcripts,
1066
+ };
1067
+ }
1068
+ const recoveryCommit = await git.commitAll(recoveryPath, `nightshift(${programId}): ${workstream.id} integration recovery`, ["docs/programs"]);
1069
+ if (recoveryCommit === undefined) {
1070
+ return {
1071
+ ok: false,
1072
+ reason: `${firstReason}\n\nThe recovery attempt verified green but produced no change.`,
1073
+ retainedRef: ref,
1074
+ worktreePaths: [cwd, recoveryPath],
1075
+ transcripts,
1076
+ };
1077
+ }
1078
+ await git.createRef(root, ref, `refs/heads/${recoveryBranch}`);
1079
+ const second = await attemptIntegration({
1080
+ root,
1081
+ programId,
1082
+ workstreamId: workstream.id,
1083
+ workstreamName: workstream.name,
1084
+ cwd: recoveryPath,
1085
+ branch: recoveryBranch,
1086
+ finalCommit: recoveryCommit,
1087
+ worktrees,
1088
+ git,
1089
+ config,
1090
+ verifyRunner,
1091
+ setupRunner,
1092
+ ...(args.verifyCache ? { verifyCache: args.verifyCache } : {}),
1093
+ scope,
1094
+ log,
1095
+ rootIndexMutex,
1096
+ rosterTaskFiles,
1097
+ });
1098
+ if (second.ok) {
1099
+ return { ok: true, ...(second.commit === undefined ? {} : { commit: second.commit }), transcripts, cleanup };
1100
+ }
1101
+ // Do not recurse — one bounded recovery attempt only (§3.6).
1102
+ const secondReason = integrationFailureText(second, recoveryCommit);
1103
+ return {
1104
+ ok: false,
1105
+ reason: `${firstReason}\n\nThe recovery attempt also failed to integrate: ${secondReason}\n\n` +
1106
+ `The recovery's own verified commit is \`${recoveryCommit}\`, retained as \`${ref}\`.`,
1107
+ retainedRef: ref,
1108
+ worktreePaths: [cwd, recoveryPath],
1109
+ transcripts,
1110
+ };
1111
+ }
530
1112
  /**
531
1113
  * The decide-and-continue runner.
532
1114
  *
@@ -560,7 +1142,26 @@ async function runProgramWith(options, permits) {
560
1142
  const agentRunner = options.agentRunner ?? defaultAgentRunner;
561
1143
  const verifyRunner = options.verifyRunner ?? defaultVerifyRunner;
562
1144
  const git = options.git ?? defaultGitOps;
563
- const log = options.log ?? ((line) => console.log(line));
1145
+ // One cache per run (WS-09, SC-14): an identical tree — everything the
1146
+ // verify commands can read except this program's own six bookkeeping
1147
+ // files (§3.3.2) — is never verified twice. No global, no module state: a
1148
+ // second `runProgram` in the same process gets its own cache.
1149
+ const verifyCache = createVerifyCache({
1150
+ git,
1151
+ commands: config.verify,
1152
+ exclude: runnerOwnedVerifyPaths(options.programId),
1153
+ });
1154
+ const worktrees = options.worktrees ?? defaultWorktreeOps;
1155
+ // A separate default from `verifyRunner` on purpose (WS-02, §3.3): tests
1156
+ // stub `verifyRunner` to always return green, and reusing it for the
1157
+ // worktree install step would make a failed install look like a success.
1158
+ const setupRunner = options.setupRunner ?? defaultVerifyRunner;
1159
+ // Resolved once, inside the isRepository branch below, and read from every
1160
+ // workstream flow via closure (WS-02) — undefined when this is not a git
1161
+ // repository, where no worktree is ever created (§3.9).
1162
+ let worktreeBase;
1163
+ const baseLog = options.log ?? ((line) => console.log(line));
1164
+ const log = baseLog;
564
1165
  const now = options.now ?? (() => new Date());
565
1166
  const monotonic = options.monotonic ?? (() => performance.now());
566
1167
  // Read before anything durable happens (a plain read, safe before the
@@ -688,7 +1289,7 @@ async function runProgramWith(options, permits) {
688
1289
  // interrupted, whose own leftover work is exactly what a resume needs.
689
1290
  // The runner's own artifacts under docs/programs/ are exempt: they are
690
1291
  // output, not work in progress.
691
- const dirty = (await git.dirtyPaths(root)).filter((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
1292
+ const dirty = (await git.dirtyPaths(root)).filter((path) => !isRunnerOwnedArtifactPath(path));
692
1293
  if (dirty.length > 0 && !resuming) {
693
1294
  throw new CouldNotStartError(`The working tree has uncommitted changes the run would sweep into its commits:\n` +
694
1295
  dirty.map((path) => ` ${path}`).join("\n") +
@@ -697,7 +1298,7 @@ async function runProgramWith(options, permits) {
697
1298
  // A run always commits to the program's own branch — never the default
698
1299
  // branch, and never another program's branch, since the runner commits
699
1300
  // with `git add -A` and a mismatched worktree would sweep this program's
700
- // work onto the wrong branch. These refuse before authorProgram below
1301
+ // work onto the wrong branch. These refuse before the pipeline below
701
1302
  // spawns the first agent; --force is the explicit escape hatch.
702
1303
  if (!options.force) {
703
1304
  const currentBranch = await git.currentBranch(root);
@@ -720,6 +1321,26 @@ async function runProgramWith(options, permits) {
720
1321
  `with \`${branchCommand}\`, or re-run with --force.`);
721
1322
  }
722
1323
  }
1324
+ // Every worktree and per-workstream branch this program left behind —
1325
+ // a crashed prior run, not only an ordinary resume — is reaped before
1326
+ // anything else runs (WS-02, §3.7). Retained `refs/nightshift/…` commits
1327
+ // are the forensic record a human needs to salvage a parked
1328
+ // integration and are never touched here.
1329
+ await reapProgramWorktrees(root, options.programId, worktrees, log, new Set(manifest.workstreams.filter((w) => w.status === "complete").map((w) => w.id)));
1330
+ worktreeBase = await resolveWorktreeBase(root, options.worktreeBaseDir);
1331
+ log(`worktrees: ${worktreeBase.base}` +
1332
+ (worktreeBase.usedFallback ? " (OS-temp fallback — the sibling location was not writable)" : ""));
1333
+ // A resumed run's own leftover work no longer sits in the root tree a
1334
+ // workstream's build reads from — that tree is now a fresh worktree from
1335
+ // the program-branch tip. Said once, plainly, rather than discovered
1336
+ // later as "my edit vanished" (WS-02, §3.7).
1337
+ if (resuming && dirty.length > 0) {
1338
+ log("warning: the working tree has uncommitted change(s) that a " +
1339
+ "worktree-isolated build will not see (it starts from the " +
1340
+ "program-branch tip), and an integration may refuse if a " +
1341
+ "workstream also touches the same path(s):\n" +
1342
+ dirty.map((path) => ` ${path}`).join("\n"));
1343
+ }
723
1344
  }
724
1345
  // HEAD as it stood before the run's first agent ran — the whole-program
725
1346
  // review stage's fallback diff base (see resolveProgramDiffBase) when a
@@ -768,7 +1389,7 @@ async function runProgramWith(options, permits) {
768
1389
  let baselineFingerprint;
769
1390
  if (Object.keys(config.verify).length > 0) {
770
1391
  log("baseline: running the verify commands on the untouched tree");
771
- const baselineFailure = await verifyAttempt(config, verifyRunner, root, 0, recorder, "baseline-verification", { programId: options.programId, phase: "baseline" });
1392
+ const baselineFailure = await verifyAttempt(config, verifyRunner, root, root, 0, recorder, "baseline-verification", { programId: options.programId, phase: "baseline", cache: verifyCache });
772
1393
  if (baselineFailure === undefined) {
773
1394
  log("baseline: clean");
774
1395
  }
@@ -841,11 +1462,28 @@ async function runProgramWith(options, permits) {
841
1462
  throw error;
842
1463
  }
843
1464
  async function runProgramBody() {
844
- // Authoring runs before building: every workstream whose spec is missing
845
- // gets one, in dependency order, before anything is implemented. It
846
- // reloads the manifest afterward because authoring may have merged
847
- // discovered dependency edges or parked workstreams it could not author.
848
- const authorResult = await authorProgram({
1465
+ // WS-05 §3.7: the total lock order for this whole run. `integrationMutex`
1466
+ // (WS-04's, unchanged) is held across a whole integration attempt,
1467
+ // including its re-verify pass, deliberately for minutes at a time.
1468
+ // `rootIndexMutex` (new) is held only for the git-index operations at
1469
+ // `root`: authoring's spec commits (inside `prepareAuthoring`), an
1470
+ // integration's step-3 tip re-resolve and squash (§3.7.1), and the final
1471
+ // report commit below. Beneath both sits the WS-01 write queue
1472
+ // (`enqueueProgramWrite`). Never taken in any other order, and never
1473
+ // nested with `src/author.ts`'s own `graphMutex` (its merge critical
1474
+ // section), which takes no git operation at all.
1475
+ const integrationMutex = createMutex();
1476
+ const rootIndexMutex = createMutex();
1477
+ // WS-05 §3.1: one dependency graph with two kinds of node instead of a
1478
+ // barrier between two loops. `prepareAuthoring` throws for exactly the
1479
+ // reasons `authorProgram` threw before this workstream — no author agent
1480
+ // configured, a cyclic manifest, an unknown `--from` id — and does so
1481
+ // before any agent spawns. The manifest object is this run's single
1482
+ // shared one (§3.4): authoring mutates and persists *this* object, so the
1483
+ // build scheduler sees a discovered dependency edge or a `specStatus`
1484
+ // transition the instant an author makes it, and no post-authoring reload
1485
+ // is needed or performed.
1486
+ const pipeline = await prepareAuthoring({
849
1487
  cwd: options.cwd,
850
1488
  programId: options.programId,
851
1489
  config,
@@ -857,65 +1495,90 @@ async function runProgramWith(options, permits) {
857
1495
  reviewed,
858
1496
  triaged,
859
1497
  recorder,
1498
+ runSpan,
1499
+ monotonic,
1500
+ manifest,
1501
+ rootIndexMutex,
1502
+ ...(options.maxParallel === undefined ? {} : { maxParallel: options.maxParallel }),
860
1503
  });
861
- manifest = await loadManifest(root, options.programId);
862
- // Loaded once so every brief in this run projects the same picture of
863
- // human-decided and ratified choices; decisions this run itself journals
864
- // are picked up fresh by `readDecisionLedger` at the end, for escalations.
865
- const ledgerAtStart = await readDecisionLedger(root, options.programId);
866
- const ordered = stableTopologicalOrder(manifest.workstreams);
867
- const results = [];
868
- // Every workstream whose spec authoring failed or parked. Seeds `blocked`
869
- // below (their briefs would be missing a producer's spec) and also guards
870
- // the awaiting_human branch in the build loop: an authoring failure must
871
- // never be repainted as a planned wait — see that branch's own comment.
872
- const authoringFailed = new Set(authorResult.results
873
- .filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
874
- .map((entry) => entry.id));
875
- const blocked = new Set(authoringFailed);
1504
+ // Every workstream's `taskFile` — one of the two path shapes a concurrent
1505
+ // authoring commit may safely have touched while an integration was in
1506
+ // flight (§3.7.1); fixed for the whole run, since only `dependencies`
1507
+ // mutate during authoring, never a workstream's own `taskFile`.
1508
+ const rosterTaskFiles = new Set(manifest.workstreams.map((workstream) => workstream.taskFile));
1509
+ const rosterIds = new Set(manifest.workstreams.map((workstream) => workstream.id));
876
1510
  // The awaiting cone: every workstream directly referencing an unmet
877
1511
  // prerequisite, plus everything transitively downstream of one — the same
878
1512
  // downstream-cone machinery failure isolation uses, with the semantics
879
- // inverted (no agent spawned, no retry, no diagnosis; see the build loop).
1513
+ // inverted (no agent spawned, no retry, no diagnosis; see `dispositionOf`).
1514
+ // `directlyAwaiting` is fixed for the run (authoring never mutates
1515
+ // `prerequisites`); its downstream cone is recomputed live on every call
1516
+ // (§3.5) — a dependency edge an author discovers mid-run can still route a
1517
+ // workstream into this cone after the run started.
880
1518
  const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
881
1519
  const directlyAwaiting = new Set(manifest.workstreams
882
1520
  .filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
883
1521
  .map((workstream) => workstream.id));
884
- const awaitingCone = new Set([
885
- ...directlyAwaiting,
886
- ...downstreamCone(manifest.workstreams, [...directlyAwaiting]),
887
- ]);
1522
+ function isAwaiting(id) {
1523
+ return (directlyAwaiting.has(id) ||
1524
+ downstreamCone(manifest.workstreams, [...directlyAwaiting]).has(id));
1525
+ }
1526
+ // Every workstream whose spec authoring failed or parked, and every
1527
+ // workstream whose *build* failed — grown live as author and build items
1528
+ // settle (§3.5), never frozen at a barrier that no longer exists.
1529
+ // `isBlocked` recomputes the downstream cone of both sets on every call:
1530
+ // by the time `build:X` is admissible, every ancestor's `author` item
1531
+ // *and* `build` item has already settled (`build:X` depends on `build:D`
1532
+ // for every roster dependency `D`, which itself depends on `author:D`), so
1533
+ // the live sets contain everything that could possibly block `X` at that
1534
+ // instant — the correctness lemma this replaces WS-04's frozen `blocked`
1535
+ // set with.
1536
+ const authoringFailed = new Set();
1537
+ const buildFailed = new Set();
1538
+ function isBlocked(id) {
1539
+ return (authoringFailed.has(id) ||
1540
+ downstreamCone(manifest.workstreams, [...authoringFailed, ...buildFailed]).has(id));
1541
+ }
888
1542
  // Set when a workstream's verify failure reproduced the pre-run baseline:
889
1543
  // the environment is broken, so every remaining workstream parks instead
890
- // of spending its budget on the same crash.
1544
+ // of spending its budget on the same crash — in *both* phases (§3.6):
1545
+ // in-flight authoring finishes and commits its spec, in-flight builds
1546
+ // finish and integrate, but no not-yet-dispatched author or build
1547
+ // launches. Mutated only from the scheduler's `launchBuild`, after a
1548
+ // result is in hand — the same place today's serial loop mutated it.
891
1549
  let environmentalHalt = false;
892
- for (const workstream of ordered) {
1550
+ /**
1551
+ * WS-04's five-branch cascade (minus the `runWorkstream` branch, which is
1552
+ * the absence of a short-circuit), extracted so the scheduler's `classify`
1553
+ * can consult it on every pass without re-implementing it. Precedence is
1554
+ * load-bearing and unchanged: complete, then environmental halt, then
1555
+ * awaiting (guarded against repainting an authoring failure), then
1556
+ * blocked. Evaluated at dispatch time, when every dependency's `build`
1557
+ * item has already settled (admissibility requires it), so `isBlocked` is
1558
+ * as authoritative here as it was over WS-04's frozen `blocked` set
1559
+ * (WS-05 §3.5).
1560
+ */
1561
+ function dispositionOf(workstream) {
893
1562
  if (workstream.status === "complete") {
894
- results.push({
895
- id: workstream.id,
896
- name: workstream.name,
1563
+ return {
1564
+ kind: "short-circuit",
897
1565
  outcome: { status: "skipped", reason: "already complete" },
898
- decisionIds: [],
899
- decisionErrors: [],
900
- });
901
- continue;
1566
+ persist: false,
1567
+ };
902
1568
  }
903
1569
  if (environmentalHalt) {
904
- workstream.status = "parked";
905
- results.push({
906
- id: workstream.id,
907
- name: workstream.name,
1570
+ return {
1571
+ kind: "short-circuit",
1572
+ status: "parked",
908
1573
  outcome: {
909
1574
  status: "parked",
910
1575
  reason: "the run halted on an environmental verification failure; parked, not attempted",
911
1576
  },
912
- decisionIds: [],
913
- decisionErrors: [],
914
- });
915
- continue;
1577
+ persist: false,
1578
+ };
916
1579
  }
917
1580
  // Authoring failures take precedence over awaiting: a workstream whose
918
- // spec authoring failed or parked must fall through to the `blocked`
1581
+ // spec authoring failed or parked must fall through to the `isBlocked`
919
1582
  // branch below and render as the genuine failure it is, never be
920
1583
  // repainted `awaiting_human` — an authoring failure has no other
921
1584
  // representation in `results` than that branch's own `parked` result, so
@@ -925,46 +1588,218 @@ async function runProgramWith(options, permits) {
925
1588
  // resolves to awaiting_human here; that is safe because the build
926
1589
  // failure self-reports `failed` in `results`, so the run is classified a
927
1590
  // partial regardless (see the intermission classification below).
928
- if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
929
- workstream.status = "awaiting_human";
930
- await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1591
+ if (isAwaiting(workstream.id) && !authoringFailed.has(workstream.id)) {
931
1592
  const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
932
1593
  const reason = ownUnmet.length > 0
933
1594
  ? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
934
1595
  : "an upstream workstream is awaiting a human prerequisite; not attempted.";
935
- results.push({
936
- id: workstream.id,
937
- name: workstream.name,
1596
+ return {
1597
+ kind: "short-circuit",
1598
+ status: "awaiting_human",
938
1599
  outcome: { status: "awaiting_human", reason },
939
- decisionIds: [],
940
- decisionErrors: [],
941
- });
942
- continue;
1600
+ persist: true,
1601
+ };
943
1602
  }
944
- if (blocked.has(workstream.id)) {
945
- workstream.status = "parked";
946
- results.push({
947
- id: workstream.id,
948
- name: workstream.name,
1603
+ if (isBlocked(workstream.id)) {
1604
+ return {
1605
+ kind: "short-circuit",
1606
+ status: "parked",
949
1607
  outcome: {
950
1608
  status: "parked",
951
1609
  reason: "an upstream dependency failed; parked, not attempted",
952
1610
  },
953
- decisionIds: [],
954
- decisionErrors: [],
955
- });
956
- continue;
1611
+ persist: false,
1612
+ };
957
1613
  }
958
- const result = await runWorkstream(workstream);
959
- results.push(result);
960
- if (result.environmental === true)
1614
+ return { kind: "build" };
1615
+ }
1616
+ // `classify` and `settle` are two separate scheduler calls, and `settle`
1617
+ // runs on a later microtask — a naive `settle` that recomputes
1618
+ // `dispositionOf` could observe a different answer than the one that sent
1619
+ // it there (a sibling can flip `environmentalHalt` in between). Memoising
1620
+ // the dispatching pass's answer and having `settle` read it back avoids
1621
+ // the hazard entirely rather than reasoning about it (WS-04, §3.2).
1622
+ const dispositionCache = new Map();
1623
+ function dispositionFor(workstream) {
1624
+ const fresh = dispositionOf(workstream);
1625
+ dispositionCache.set(workstream.id, fresh);
1626
+ return fresh;
1627
+ }
1628
+ async function applyShortCircuit(workstream) {
1629
+ const disposition = dispositionCache.get(workstream.id);
1630
+ if (disposition === undefined || disposition.kind !== "short-circuit") {
1631
+ // Unreachable: the scheduler only calls `settle` for an item whose
1632
+ // `classify` call (via `dispositionFor`) just returned "settle".
1633
+ throw new Error(`applyShortCircuit called for ${workstream.id} without a cached short-circuit disposition`);
1634
+ }
1635
+ if (disposition.status !== undefined)
1636
+ workstream.status = disposition.status;
1637
+ if (disposition.persist) {
1638
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1639
+ }
1640
+ return {
1641
+ id: workstream.id,
1642
+ name: workstream.name,
1643
+ outcome: disposition.outcome,
1644
+ decisionIds: [],
1645
+ decisionErrors: [],
1646
+ };
1647
+ }
1648
+ const byId = new Map();
1649
+ const cap = normalizeMaxParallel(options.maxParallel);
1650
+ const schedLog = schedulerLog(baseLog);
1651
+ schedLog(`pipelining authoring and building over ${manifest.workstreams.length} workstream(s)` +
1652
+ (cap === Infinity ? "" : `, at most ${cap} flow(s) at a time`));
1653
+ const authorItems = manifest.workstreams.map((workstream) => ({
1654
+ id: `author:${workstream.id}`,
1655
+ phase: "author",
1656
+ workstream,
1657
+ get dependencies() {
1658
+ return workstream.dependencies.map((id) => `author:${id}`);
1659
+ },
1660
+ }));
1661
+ const buildItems = manifest.workstreams.map((workstream) => ({
1662
+ id: `build:${workstream.id}`,
1663
+ phase: "build",
1664
+ workstream,
1665
+ get dependencies() {
1666
+ return [`author:${workstream.id}`, ...workstream.dependencies.map((id) => `build:${id}`)];
1667
+ },
1668
+ }));
1669
+ // Authors first, then builds, both in manifest order (§3.1): `runReadySet`
1670
+ // breaks ties by this input order, so at `cap: 1` every author drains
1671
+ // before any build is even considered — the pipeline rebuilds today's
1672
+ // barrier exactly rather than approximating it (§3.10).
1673
+ const items = [...authorItems, ...buildItems];
1674
+ // Settle instants, keyed by phased-item id, for the `spec-pending` wait
1675
+ // measurement below (§3.8); the scheduler does not expose these itself.
1676
+ const settledAt = new Map();
1677
+ const schedulerStart = monotonic();
1678
+ async function launchAuthor(item) {
1679
+ const result = await pipeline.authorWorkstream(item.workstream);
1680
+ settledAt.set(item.id, monotonic());
1681
+ if (result.outcome.status === "failed" || result.outcome.status === "parked") {
1682
+ authoringFailed.add(item.workstream.id);
1683
+ }
1684
+ }
1685
+ // The only path by which an author item settles without running (§3.6): a
1686
+ // halt-parked author never ran, so `specStatus` is left exactly as it
1687
+ // stood, and it does not enter `authoringFailed` — a downstream workstream
1688
+ // reports the halt reason, never "an upstream dependency's authoring
1689
+ // failed".
1690
+ async function settleHaltedAuthor(item) {
1691
+ pipeline.haltAuthorWorkstream(item.workstream, "the run halted on an environmental verification failure; parked, not attempted");
1692
+ settledAt.set(item.id, monotonic());
1693
+ }
1694
+ async function settleBuild(item) {
1695
+ byId.set(item.workstream.id, await applyShortCircuit(item.workstream));
1696
+ settledAt.set(item.id, monotonic());
1697
+ }
1698
+ async function launchBuild(item) {
1699
+ const workstream = item.workstream;
1700
+ // The scheduling instant (WS-04, §3.5): resolved first, with no
1701
+ // `await` between the read and the `workstream-scheduled` emission, so
1702
+ // no sibling can integrate in between — the point and the sha
1703
+ // therefore describe the same instant by construction. The per-flow
1704
+ // ledger read (§3.4) goes strictly after this emission, and nothing
1705
+ // else is inserted between them, so WS-04's
1706
+ // `scheduled − ready === concurrency-cap` identity stays exact.
1707
+ const scheduledFrom = isRepository
1708
+ ? await worktrees.resolveCommit(root, "HEAD")
1709
+ : undefined;
1710
+ recordWorkstreamScheduled(recorder, workstream.id);
1711
+ const ledger = await readDecisionLedger(root, options.programId);
1712
+ const result = await runWorkstream(workstream, ledger, scheduledFrom === undefined ? {} : { scheduledFrom });
1713
+ byId.set(workstream.id, result);
1714
+ settledAt.set(item.id, monotonic());
1715
+ if (result.environmental === true && !environmentalHalt) {
961
1716
  environmentalHalt = true;
962
- if (result.outcome.status === "failed") {
963
- // Failure isolation: park the downstream cone, keep building the rest.
964
- for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
965
- blocked.add(id);
1717
+ schedLog("environmental verification failure — halting further dispatch");
1718
+ schedLog("[run] the halt stops dispatch in both phases in-flight authoring finishes " +
1719
+ "and commits its spec, in-flight builds finish and integrate, and every " +
1720
+ "not-yet-dispatched author or build parks with the halt reason");
1721
+ }
1722
+ if (result.outcome.status === "failed" || result.outcome.status === "parked") {
1723
+ // A parked build (no spec on disk) leaves its dependents without a
1724
+ // producer just as a failure does; both seed the downstream cone.
1725
+ buildFailed.add(workstream.id);
1726
+ }
1727
+ }
1728
+ /** Emits `spec-pending` (§3.8): the wait between when this workstream's
1729
+ * build *could* have become ready — the latest settle of any roster
1730
+ * dependency's build item, or the scheduler's start when it has none —
1731
+ * and the instant it actually did. Authors emit no `workstream-ready`;
1732
+ * that point means "a build is ready" and must stay unique per
1733
+ * workstream. */
1734
+ function onBuildReady(item) {
1735
+ const readyAt = monotonic();
1736
+ const depsReadyAt = item.workstream.dependencies
1737
+ .filter((id) => rosterIds.has(id))
1738
+ .reduce((latest, id) => Math.max(latest, settledAt.get(`build:${id}`) ?? schedulerStart), schedulerStart);
1739
+ recordWorkstreamReady(recorder, item.workstream.id);
1740
+ const waited = readyAt - depsReadyAt;
1741
+ if (waited > 0) {
1742
+ recordWorkstreamWaiting(recorder, item.workstream.id, { cause: "spec-pending", waitedMs: waited });
1743
+ }
1744
+ }
1745
+ /** Emits `author-concurrency-cap` (§3.8) — the authoring half of the
1746
+ * shared `--max-parallel` budget, distinct from `concurrency-cap` so
1747
+ * per-(workstream, cause) uniqueness and WS-04's
1748
+ * `scheduled − ready === concurrency-cap` identity both stay exact. */
1749
+ function onAuthorLaunch(item, cappedMs) {
1750
+ if (cappedMs > 0) {
1751
+ recordWorkstreamWaiting(recorder, item.workstream.id, {
1752
+ cause: "author-concurrency-cap",
1753
+ waitedMs: cappedMs,
1754
+ });
1755
+ }
1756
+ }
1757
+ await runReadySet({
1758
+ items,
1759
+ cap,
1760
+ monotonic,
1761
+ classify: (item) => item.phase === "author"
1762
+ // The one short-circuit an author item has: the environmental halt
1763
+ // (§3.6). Authoring has no other short-circuit — the cone parks are
1764
+ // WS-03's own, inside `handle`.
1765
+ ? environmentalHalt
1766
+ ? "settle"
1767
+ : "launch"
1768
+ : dispositionFor(item.workstream).kind === "build"
1769
+ ? "launch"
1770
+ : "settle",
1771
+ launch: (item) => (item.phase === "author" ? launchAuthor(item) : launchBuild(item)),
1772
+ settle: (item) => (item.phase === "author" ? settleHaltedAuthor(item) : settleBuild(item)),
1773
+ onAdmissible: (item) => {
1774
+ if (item.phase === "build")
1775
+ onBuildReady(item);
1776
+ },
1777
+ onLaunch: (item, cappedMs) => {
1778
+ if (item.phase === "author") {
1779
+ onAuthorLaunch(item, cappedMs);
1780
+ }
1781
+ else if (cappedMs > 0) {
1782
+ recordWorkstreamWaiting(recorder, item.workstream.id, { cause: "concurrency-cap", waitedMs: cappedMs });
966
1783
  }
1784
+ },
1785
+ });
1786
+ const authorResult = pipeline.finish();
1787
+ // Wall-clock completion order is not deterministic; `ordered` is.
1788
+ // Computed only now, after the drain (WS-05 §3.9) — never before it, since
1789
+ // an author discovering a dependency mid-run can still change the graph
1790
+ // this walks. Rebuilding `results` from it is what keeps every existing
1791
+ // positional assertion (`results[0]`, `results[1]`, …) green under an
1792
+ // unbounded default. `runReadySet`'s settle-in-`finally` makes a missing
1793
+ // id impossible; filtered defensively anyway per SC-12's fail-open rule.
1794
+ const ordered = stableTopologicalOrder(manifest.workstreams);
1795
+ const results = [];
1796
+ for (const workstream of ordered) {
1797
+ const result = byId.get(workstream.id);
1798
+ if (result === undefined) {
1799
+ schedLog(`warning: ${workstream.id} produced no result — omitted from the report`);
1800
+ continue;
967
1801
  }
1802
+ results.push(result);
968
1803
  }
969
1804
  const complete = results.every((result) => result.outcome.status === "complete" ||
970
1805
  result.outcome.status === "skipped");
@@ -1030,6 +1865,10 @@ async function runProgramWith(options, permits) {
1030
1865
  recorder,
1031
1866
  });
1032
1867
  const reportPath = runReportPath(root, options.programId);
1868
+ // Under concurrency (WS-02+) this is what guarantees no manifest or
1869
+ // ledger write is still in flight while docs/programs is staged below;
1870
+ // today, with nothing concurrent, it is a no-op that must exist anyway.
1871
+ await drainProgramWrites(programWriteKey(root, options.programId));
1033
1872
  // Rendering is the last stage the recorder can actually observe (SC-03):
1034
1873
  // captured as its own span, nested under the run span, rather than folded
1035
1874
  // silently into it. Kept in its own try/catch, separate from the write and
@@ -1118,7 +1957,12 @@ async function runProgramWith(options, permits) {
1118
1957
  try {
1119
1958
  await writeFile(reportPath, reportContent, "utf8");
1120
1959
  if (isRepository) {
1121
- await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
1960
+ // Guarded by `rootIndexMutex` (WS-05 §3.7) like every other git-index
1961
+ // operation at root; by this point the unified scheduler has fully
1962
+ // drained, so nothing else can be contending for it, but the lock
1963
+ // discipline is stated once and applied everywhere rather than
1964
+ // special-cased at the one call site that "happens to" run alone.
1965
+ await rootIndexMutex.runExclusive(() => git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]));
1122
1966
  }
1123
1967
  // A best-effort forensic breadcrumb for the one boundary the
1124
1968
  // already-committed canonical artifact can never describe: `point()`
@@ -1151,7 +1995,22 @@ async function runProgramWith(options, permits) {
1151
1995
  wholeProgramReview: wholeProgram,
1152
1996
  causalAnalysis,
1153
1997
  };
1154
- async function runWorkstream(workstream) {
1998
+ async function runWorkstream(workstream,
1999
+ /**
2000
+ * The decision ledger, read once per build flow (WS-05 §3.4) rather
2001
+ * than once for the whole run — a build's brief then sees every
2002
+ * decision journalled before it started, including its own
2003
+ * dependencies' authoring, without breaking WS-04's
2004
+ * `scheduled − ready === concurrency-cap` identity (the read happens
2005
+ * strictly after the `workstream-scheduled` emission, in the caller).
2006
+ */
2007
+ ledger, flowOptions = {}) {
2008
+ // Explicit parenting (WS-01 §3.3): every span/point this flow opens
2009
+ // parents to the run span itself, never to whatever happens to be open
2010
+ // on the implicit stack — the seam future concurrent workstreams (WS-04)
2011
+ // stand on. `[WS-xx] `-prefixes every line this flow logs.
2012
+ const scope = runSpan.scope({ workstream: workstream.id });
2013
+ const log = workstreamLog(baseLog, workstream.id);
1155
2014
  const base = {
1156
2015
  id: workstream.id,
1157
2016
  name: workstream.name,
@@ -1182,13 +2041,78 @@ async function runProgramWith(options, permits) {
1182
2041
  return base;
1183
2042
  }
1184
2043
  workstream.status = "in_progress";
1185
- await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1186
- const baseCommit = isRepository
1187
- ? await git.currentCommit(root)
2044
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2045
+ // One read, not two (WS-04, §3.5): the program-branch tip is the
2046
+ // worktree's base *and* the decision anchor (`baseCommit`) — the same
2047
+ // fact under two names, resolved through `WorktreeOps`, never
2048
+ // `GitOps.currentCommit`. Concurrently, a second independent read here
2049
+ // could observe a sibling's integration land in between, giving this
2050
+ // workstream a `baseCommit` newer than the tree its worktree was
2051
+ // actually built from — the reviewer and decider would then see a
2052
+ // sibling's change as though this workstream had deleted it. Preferring
2053
+ // `flowOptions.scheduledFrom` when present means the scheduler's own
2054
+ // read (taken with no `await` before its `workstream-scheduled`
2055
+ // emission) is the only read on every scheduled path; the fallback here
2056
+ // exists only for the one caller with no scheduling instant to offer.
2057
+ const programTip = isRepository
2058
+ ? (flowOptions.scheduledFrom ?? (await worktrees.resolveCommit(root, "HEAD")))
1188
2059
  : undefined;
2060
+ const baseCommit = programTip;
2061
+ // The worktree lifecycle (WS-02, §3.3): every in-progress workstream
2062
+ // builds in its own dedicated worktree on its own branch, created from
2063
+ // the program-branch tip, never in the root checkout. `cwd` is what
2064
+ // every build-role spawn, verify command, and workstream-owned git
2065
+ // operation below uses from here on; `root` stays reserved for the
2066
+ // manifest, ledger, transcripts, and verify-output evidence.
2067
+ let cwd = root;
2068
+ let worktreeBranchOfThis;
2069
+ if (isRepository) {
2070
+ if (worktreeBase === undefined) {
2071
+ // Unreachable in production (worktreeBase is always resolved inside
2072
+ // the isRepository branch above, before any workstream runs) —
2073
+ // guarded rather than asserted so a future refactor fails loudly.
2074
+ throw new Error("worktree base was never resolved for a git repository run");
2075
+ }
2076
+ // Routed through the same `PrepareWorktreeResult` shape and the same
2077
+ // failure handling below as a real `prepareWorktree` failure, rather
2078
+ // than a second early-return with its own `saveManifest` call —
2079
+ // the tip could not resolve, so there is nothing to build from, but
2080
+ // that is still exactly one more reason a worktree could not be
2081
+ // prepared (WS-04, §3.5).
2082
+ const prepared = programTip === undefined
2083
+ ? { ok: false, reason: "cannot resolve the program-branch tip", created: false }
2084
+ : await prepareWorktree({
2085
+ root,
2086
+ programId: options.programId,
2087
+ workstreamId: workstream.id,
2088
+ worktrees,
2089
+ worktreeBase,
2090
+ setupRunner,
2091
+ log,
2092
+ programTip,
2093
+ });
2094
+ if (!prepared.ok) {
2095
+ workstream.status = "failed";
2096
+ if (prepared.created)
2097
+ base.worktreePath = prepared.cwd;
2098
+ base.outcome = { status: "failed", reason: prepared.reason };
2099
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2100
+ return base;
2101
+ }
2102
+ cwd = prepared.cwd;
2103
+ worktreeBranchOfThis = prepared.branch;
2104
+ base.worktreePath = cwd;
2105
+ }
1189
2106
  let priorFailure;
1190
2107
  let priorDiagnosis;
1191
2108
  let implementerFingerprint;
2109
+ // WS-08: last-wins across every attempt in this workstream's build —
2110
+ // the fix seats resume whichever attempt most recently reported a
2111
+ // session id, which is the attempt whose commit they are fixing.
2112
+ // Capturing on every attempt (including recovery) is not resuming: the
2113
+ // recovery seat still spawns fresh; its handle is only ever a candidate
2114
+ // for the fix seats, subject to invokeAgent's provider/invocation checks.
2115
+ let buildSession;
1192
2116
  const attempts = [
1193
2117
  { agent, label: "implementer", role: "agent" },
1194
2118
  ];
@@ -1204,10 +2128,10 @@ async function runProgramWith(options, permits) {
1204
2128
  attempts.push({ agent, label: "informed retry", role: "agent" });
1205
2129
  }
1206
2130
  for (const [index, attempt] of attempts.entries()) {
1207
- log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
1208
- const { brief, components } = implementerBrief(manifest, workstream, spec, ledgerAtStart, config.permits?.policy, priorFailure, priorDiagnosis);
2131
+ log(`${workstream.name}: ${attempt.label} attempt`);
2132
+ const { brief, components } = implementerBrief(manifest, workstream, spec, ledger, config.permits?.policy, priorFailure, priorDiagnosis);
1209
2133
  for (const point of promptComponentSizePoints(components, { role: attempt.role })) {
1210
- recorder.point(point);
2134
+ scope.point(point);
1211
2135
  }
1212
2136
  // Why this repetition happened (WS-02, SC-05/SC-06): the first attempt
1213
2137
  // is always `initial`; a later seat is `informed-by-diagnosis` when the
@@ -1224,7 +2148,7 @@ async function runProgramWith(options, permits) {
1224
2148
  * kept. */
1225
2149
  let dispositionOutcome;
1226
2150
  const emitAttemptOutcome = (outcome, reasonOverride) => {
1227
- recorder.point({
2151
+ scope.point({
1228
2152
  kind: "attempt-outcome",
1229
2153
  coverage: "observed",
1230
2154
  dimensions: {
@@ -1241,7 +2165,9 @@ async function runProgramWith(options, permits) {
1241
2165
  let invocation;
1242
2166
  let spawnFailure;
1243
2167
  try {
1244
- invocation = await invokeAgent(agentRunner, attempt.agent, brief, root, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")), recorder, { stage: seatStage, workstream: workstream.id, attemptSeat: attempt.label, attemptIndex: index, attemptReason });
2168
+ invocation = await invokeAgent(agentRunner, attempt.agent, brief, cwd, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")), scope, { stage: seatStage, workstream: workstream.id, attemptSeat: attempt.label, attemptIndex: index, attemptReason });
2169
+ if (invocation.session)
2170
+ buildSession = invocation.session;
1245
2171
  }
1246
2172
  catch (error) {
1247
2173
  // The agent process never started — its command is wrong, missing,
@@ -1257,18 +2183,19 @@ async function runProgramWith(options, permits) {
1257
2183
  recordTranscript(invocation.transcript);
1258
2184
  const summary = resolveSummary(invocation.output);
1259
2185
  base.summary = summary.text;
1260
- await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
2186
+ await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission, scope);
1261
2187
  const parsed = extractDecisions(invocation.output);
1262
2188
  base.decisionErrors.push(...parsed.errors);
1263
- await journalDecisions(workstream, parsed.decisions, baseCommit);
2189
+ await journalDecisions(workstream, parsed.decisions, baseCommit, scope);
1264
2190
  base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
1265
- let failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
2191
+ let failure = await verifyAttempt(config, verifyRunner, cwd, root, invocation.exitCode, scope, "verification-command", {
1266
2192
  programId: options.programId,
1267
2193
  phase: `post-${attempt.label.replaceAll(" ", "-")}`,
1268
2194
  workstream: workstream.id,
1269
2195
  role: attempt.role,
1270
2196
  attemptSeat: attempt.label,
1271
2197
  attemptIndex: index,
2198
+ cache: verifyCache,
1272
2199
  });
1273
2200
  // Deterministic protocol failures the runner can see without a model:
1274
2201
  // no diagnosis is spent on them, because no read of the tree could add
@@ -1300,8 +2227,8 @@ async function runProgramWith(options, permits) {
1300
2227
  // commit and walks through the gate as "complete". A reply that never
1301
2228
  // produces the work is a protocol failure, not an empty result.
1302
2229
  if (failure === undefined && isRepository) {
1303
- const dirty = await git.dirtyPaths(root);
1304
- const meaningful = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
2230
+ const dirty = await git.dirtyPaths(cwd);
2231
+ const meaningful = dirty.some((path) => !isRunnerOwnedArtifactPath(path));
1305
2232
  if (!meaningful) {
1306
2233
  failure =
1307
2234
  "the attempt verified green but changed nothing outside " +
@@ -1326,11 +2253,18 @@ async function runProgramWith(options, permits) {
1326
2253
  // files the work required, and that set is exactly what the
1327
2254
  // runner cannot know in advance. c0 is the green anchor the test
1328
2255
  // critique's fix loop resets to on a failing fix (SC-07).
1329
- c0 = await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(root, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`), commitEvidence);
2256
+ c0 = await timed(scope, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(cwd, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`, ["docs/programs"]), commitEvidence);
1330
2257
  }
2258
+ // Captured before the pass runs so a fallback's report line can name
2259
+ // the producer's command (§3.11) — an approximation good for the
2260
+ // common one-round case; a later round's true producer is whichever
2261
+ // round most recently resumed, which the pass itself tracks
2262
+ // internally but does not need to surface here.
2263
+ const criticEnteringSession = buildSession;
1331
2264
  const critique = isRepository && c0 !== undefined
1332
2265
  ? await runTestCritique({
1333
2266
  root,
2267
+ cwd,
1334
2268
  manifest,
1335
2269
  workstream,
1336
2270
  spec,
@@ -1341,83 +2275,134 @@ async function runProgramWith(options, permits) {
1341
2275
  git,
1342
2276
  reviewer,
1343
2277
  agent,
2278
+ deciderConfigured: decider !== undefined,
1344
2279
  baseCommit,
1345
2280
  greenCommit: c0,
1346
2281
  now,
1347
2282
  log,
1348
2283
  transcriptSink,
1349
- recorder,
2284
+ recorder: scope,
2285
+ verifyCache,
2286
+ triage: (events, triageBaseCommit) => reviewWorkstreamFindings(workstream.id, cwd, events, triageBaseCommit, scope),
2287
+ ...(buildSession ? { buildSession } : {}),
1350
2288
  })
1351
2289
  : undefined;
1352
- // The manifest's single commit field records the workstream's final
1353
- // verified state after any kept fix, that is the fix commit, not
1354
- // the earlier green one.
1355
- let finalCommit = critique?.finalCommit ?? c0;
2290
+ if (critique?.continuity) {
2291
+ (base.sessionContinuity ??= []).push(...critique.continuity.map((continuity) => ({
2292
+ seat: "test critique fix",
2293
+ continuity,
2294
+ producerCommand: criticEnteringSession?.command,
2295
+ seatCommand: agent.command,
2296
+ })));
2297
+ }
2298
+ // The workstream's own final verified commit, in its worktree —
2299
+ // after any kept fix, that is the fix commit, not the earlier green
2300
+ // one; already includes any triaged fix-now commit, since the merged
2301
+ // fix runs inside the critique loop itself (WS-09). Not what lands
2302
+ // in the manifest: under WS-02 that is the *integration* commit,
2303
+ // recorded only after integration succeeds, below.
2304
+ const finalCommit = critique?.finalCommit ?? c0;
1356
2305
  if (critique && critique.stageErrors.length > 0) {
1357
2306
  (base.stageErrors ??= []).push(...critique.stageErrors);
1358
2307
  }
1359
2308
  for (const transcript of critique?.transcripts ?? [])
1360
2309
  recordTranscript(transcript);
1361
- // Findings anchor to c0 (the green, pre-critique commit) — the
1362
- // honest rollback point — while the decider below diffs from
1363
- // baseCommit (pre-workstream), so it sees the whole workstream.
1364
- const findingEvents = findingsToLedgerEvents({
1365
- workstreamId: workstream.id,
1366
- findings: (critique?.outcome.open ?? []).filter(hasRoutableEvidence),
1367
- ...(c0 === undefined ? {} : { baseCommit: c0 }),
1368
- now,
1369
- });
1370
- await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, findingEvents));
1371
- // The commit's own sha cannot be part of the tree it commits, so the
1372
- // manifest records it only now — swept forward into whatever commits
1373
- // next. Replay reads the manifest's current state, not the commit
1374
- // that last touched it, so this lag is harmless.
1375
- workstream.status = "complete";
1376
- if (finalCommit !== undefined)
1377
- workstream.commit = finalCommit;
1378
- await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1379
2310
  {
1380
- const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
2311
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, cwd, parsed.decisions, baseCommit, scope);
1381
2312
  if (spawnErrors.length > 0)
1382
2313
  (base.stageErrors ??= []).push(...spawnErrors);
1383
2314
  }
1384
- // Findings are triaged against the pre-workstream diff (baseCommit),
1385
- // the same one the decider reviews decisions against — c0/finalCommit
1386
- // are the fix loop's own rollback anchors, a different thing.
1387
- {
1388
- const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1389
- if (spawnErrors.length > 0)
1390
- (base.stageErrors ??= []).push(...spawnErrors);
2315
+ if (critique)
2316
+ base.testCritique = critique.outcome;
2317
+ base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
2318
+ // Integration (WS-02, §3.5-§3.6): the last thing this flow does on
2319
+ // the green path. Not reached when this is not a git repository —
2320
+ // there is nothing to integrate, and `finalCommit`/`cwd` are already
2321
+ // the root's own.
2322
+ if (!isRepository || finalCommit === undefined) {
2323
+ workstream.status = "complete";
2324
+ if (finalCommit !== undefined)
2325
+ workstream.commit = finalCommit;
2326
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2327
+ base.outcome = {
2328
+ status: "complete",
2329
+ ...(finalCommit === undefined ? {} : { commit: finalCommit }),
2330
+ };
2331
+ emitAttemptOutcome("success");
2332
+ return base;
1391
2333
  }
1392
- // A fix-now triage drives exactly one bounded fix attempt, here —
1393
- // after the triage that produced it, before the run advances. Only
1394
- // reachable when there is a green commit to attempt from and a
1395
- // decider that could have produced a fix-now verdict in the first
1396
- // place (SC-13).
1397
- if (finalCommit !== undefined && decider) {
1398
- const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit, transcriptSink("fix-now-fix"));
1399
- recordTranscript(driven.transcript);
1400
- if (driven.spawnError !== undefined) {
1401
- (base.stageErrors ??= []).push(driven.spawnError);
2334
+ const recoveryAgentForIntegration = recovery && !recovery.borrowedImplementer ? recovery.agent : agent;
2335
+ // Serialised through the one run-scoped integration mutex (WS-04,
2336
+ // §3.5): the mutex spans the whole call, including the merge-forward
2337
+ // and its re-verify inside `integrateWorkstream` not just the
2338
+ // final squash — because the merge-forward is the proof that the
2339
+ // squash is a fast-forward, and a sibling landing between them would
2340
+ // void that proof.
2341
+ const mutexWaitStart = monotonic();
2342
+ const integration = await integrationMutex.runExclusive(async () => {
2343
+ const mutexWaitedMs = monotonic() - mutexWaitStart;
2344
+ if (mutexWaitedMs > 0) {
2345
+ recordWorkstreamWaiting(recorder, workstream.id, {
2346
+ cause: "integration-mutex",
2347
+ waitedMs: mutexWaitedMs,
2348
+ });
1402
2349
  }
1403
- if (driven.commit !== finalCommit) {
1404
- finalCommit = driven.commit;
1405
- workstream.commit = finalCommit;
1406
- await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2350
+ return integrateWorkstream({
2351
+ root,
2352
+ programId: options.programId,
2353
+ workstream,
2354
+ cwd,
2355
+ branch: worktreeBranchOfThis ?? worktreeBranchName(options.programId, workstream.id),
2356
+ finalCommit,
2357
+ worktrees,
2358
+ git,
2359
+ config,
2360
+ verifyRunner,
2361
+ setupRunner,
2362
+ verifyCache,
2363
+ agentRunner,
2364
+ permits,
2365
+ recoveryAgent: recoveryAgentForIntegration,
2366
+ worktreeBase: worktreeBase,
2367
+ scope,
2368
+ log,
2369
+ transcriptSink,
2370
+ rootIndexMutex,
2371
+ rosterTaskFiles,
2372
+ });
2373
+ });
2374
+ for (const transcript of integration.transcripts)
2375
+ recordTranscript(transcript);
2376
+ if (integration.ok) {
2377
+ workstream.status = "complete";
2378
+ if (integration.commit !== undefined)
2379
+ workstream.commit = integration.commit;
2380
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2381
+ for (const { path, branch: cleanupBranch } of integration.cleanup) {
2382
+ await worktrees.remove(root, path);
2383
+ await worktrees.deleteBranch(root, cleanupBranch);
1407
2384
  }
2385
+ await git.deleteRef(root, retainedRefName(options.programId, workstream.id));
2386
+ delete base.worktreePath;
2387
+ base.outcome = {
2388
+ status: "complete",
2389
+ ...(integration.commit === undefined ? {} : { commit: integration.commit }),
2390
+ };
2391
+ emitAttemptOutcome("success");
2392
+ return base;
1408
2393
  }
1409
- if (critique)
1410
- base.testCritique = critique.outcome;
1411
- base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
2394
+ workstream.status = "failed";
2395
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2396
+ base.retainedRef = integration.retainedRef;
1412
2397
  base.outcome = {
1413
- status: "complete",
1414
- ...(finalCommit === undefined ? {} : { commit: finalCommit }),
2398
+ status: "failed",
2399
+ reason: `${integration.reason}\n\nWorktree(s) retained: ${integration.worktreePaths.join(", ")}.`,
1415
2400
  };
1416
2401
  emitAttemptOutcome("success");
1417
2402
  return base;
1418
2403
  }
1419
2404
  priorFailure = failure;
1420
- log(`${workstream.id}: ${attempt.label} attempt failed — ${failure}`);
2405
+ log(`${attempt.label} attempt failed — ${failure}`);
1421
2406
  // A failure identical to the pre-run baseline (up to counts and
1422
2407
  // timings) was there before any workstream ran: environmental, not
1423
2408
  // this workstream's work. No retry or diagnosis is spent on it, and
@@ -1428,9 +2413,9 @@ async function runProgramWith(options, permits) {
1428
2413
  !resumedIds.has(workstream.id) &&
1429
2414
  failureFingerprint(failure) === baselineFingerprint) {
1430
2415
  workstream.status = "failed";
1431
- await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2416
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1432
2417
  {
1433
- const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
2418
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, cwd, parsed.decisions, baseCommit, scope);
1434
2419
  if (spawnErrors.length > 0)
1435
2420
  (base.stageErrors ??= []).push(...spawnErrors);
1436
2421
  }
@@ -1456,11 +2441,12 @@ async function runProgramWith(options, permits) {
1456
2441
  failureFingerprint(failure) === implementerFingerprint;
1457
2442
  if (index === attempts.length - 1 || stuck) {
1458
2443
  workstream.status = "failed";
1459
- await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1460
- // The work stays in the tree for a resume; decisions made on the way
1461
- // to a failure are still journaled and still reviewable.
2444
+ await timed(scope, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
2445
+ // The work stays in the retained worktree (a resume salvages it to a
2446
+ // `refs/nightshift/…-wip` ref before reaping); decisions made on the
2447
+ // way to a failure are still journaled and still reviewable.
1462
2448
  {
1463
- const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
2449
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, cwd, parsed.decisions, baseCommit, scope);
1464
2450
  if (spawnErrors.length > 0)
1465
2451
  (base.stageErrors ??= []).push(...spawnErrors);
1466
2452
  }
@@ -1486,7 +2472,7 @@ async function runProgramWith(options, permits) {
1486
2472
  // and a reviewer read of an empty diff could only muddy it.
1487
2473
  if (reviewer && !deterministicFailure) {
1488
2474
  const diagnosed = await diagnoseFailure({
1489
- root,
2475
+ cwd,
1490
2476
  manifest,
1491
2477
  workstream,
1492
2478
  spec,
@@ -1499,14 +2485,14 @@ async function runProgramWith(options, permits) {
1499
2485
  attemptSeat: attempt.label,
1500
2486
  attemptIndex: index,
1501
2487
  observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
1502
- recorder,
2488
+ recorder: scope,
1503
2489
  });
1504
2490
  priorDiagnosis = diagnosed.diagnosis;
1505
2491
  if (diagnosed.spawnError !== undefined) {
1506
2492
  (base.stageErrors ??= []).push(diagnosed.spawnError);
1507
- log(`${workstream.id}: ${diagnosed.spawnError}`);
2493
+ log(diagnosed.spawnError);
1508
2494
  }
1509
- await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
2495
+ await journalDenials(workstream.id, "reviewer", diagnosed.transcript, [], scope);
1510
2496
  }
1511
2497
  else {
1512
2498
  priorDiagnosis = undefined;
@@ -1516,13 +2502,16 @@ async function runProgramWith(options, permits) {
1516
2502
  attempt: attempt.label,
1517
2503
  verdict: priorDiagnosis,
1518
2504
  });
1519
- log(`${workstream.id}: reviewer diagnosed the ${attempt.label} failure`);
2505
+ log(`reviewer diagnosed the ${attempt.label} failure`);
1520
2506
  }
1521
2507
  emitAttemptOutcome(dispositionOutcome ?? "failed");
1522
2508
  }
1523
2509
  return base;
1524
2510
  }
1525
- async function journalDecisions(workstream, decisions, baseCommit) {
2511
+ async function journalDecisions(workstream, decisions, baseCommit,
2512
+ /** The workstream flow's span scope (SC-09) — see reviewWorkstreamDecisions. */
2513
+ flowScope = recorder) {
2514
+ const log = workstreamLog(baseLog, workstream.id);
1526
2515
  const events = decisions.map((decision) => ({
1527
2516
  kind: "decision-recorded",
1528
2517
  at: now().toISOString(),
@@ -1532,9 +2521,9 @@ async function runProgramWith(options, permits) {
1532
2521
  ...(baseCommit === undefined ? {} : { baseCommit }),
1533
2522
  decidedBy: "implementer",
1534
2523
  }));
1535
- await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
2524
+ await timed(flowScope, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
1536
2525
  for (const decision of decisions) {
1537
- log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
2526
+ log(`decision: ${decision.title} -> ${decision.chosen}`);
1538
2527
  }
1539
2528
  }
1540
2529
  /**
@@ -1543,20 +2532,26 @@ async function runProgramWith(options, permits) {
1543
2532
  * and (for build roles only) the agent's `needsPermission` self-reports.
1544
2533
  * A no-op when the spawn produced neither.
1545
2534
  */
1546
- async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
2535
+ async function journalDenials(workstreamId, attempt, transcript, needsPermission = [], flowScope = recorder) {
2536
+ const log = workstreamLog(baseLog, workstreamId);
1547
2537
  const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
1548
2538
  if (events.length === 0)
1549
2539
  return;
1550
- await timed(recorder, { stage: "ledger-persist", workstream: workstreamId }, () => appendLedgerEvents(root, options.programId, events));
2540
+ await timed(flowScope, { stage: "ledger-persist", workstream: workstreamId }, () => appendLedgerEvents(root, options.programId, events));
1551
2541
  for (const event of events) {
1552
2542
  if (event.kind !== "permission-denied")
1553
2543
  continue;
1554
- log(`${workstreamId} ${attempt}: denied \`${event.command}\` (${event.source})`);
2544
+ log(`${attempt}: denied \`${event.command}\` (${event.source})`);
1555
2545
  }
1556
2546
  }
1557
- function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
2547
+ function reviewWorkstreamDecisions(workstreamId, cwd, decisions, baseCommit,
2548
+ /** The workstream flow's span scope (SC-09): decider spans parent here,
2549
+ * never to whatever span another concurrent flow happens to have open. */
2550
+ flowScope = recorder) {
2551
+ const log = workstreamLog(baseLog, workstreamId);
1558
2552
  return reviewDecisions({
1559
2553
  root,
2554
+ cwd,
1560
2555
  programId: options.programId,
1561
2556
  manifest,
1562
2557
  workstreamId,
@@ -1568,17 +2563,19 @@ async function runProgramWith(options, permits) {
1568
2563
  git,
1569
2564
  isRepository,
1570
2565
  reviewed,
1571
- onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
2566
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript, [], flowScope),
1572
2567
  triaged,
1573
2568
  now,
1574
2569
  log,
1575
2570
  observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1576
- recorder,
2571
+ recorder: flowScope,
1577
2572
  });
1578
2573
  }
1579
- function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
2574
+ function reviewWorkstreamFindings(workstreamId, cwd, findings, baseCommit, flowScope = recorder) {
2575
+ const log = workstreamLog(baseLog, workstreamId);
1580
2576
  return triageFindings({
1581
2577
  root,
2578
+ cwd,
1582
2579
  programId: options.programId,
1583
2580
  manifest,
1584
2581
  workstreamId,
@@ -1591,92 +2588,12 @@ async function runProgramWith(options, permits) {
1591
2588
  isRepository,
1592
2589
  reviewed,
1593
2590
  observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1594
- onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
2591
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript, [], flowScope),
1595
2592
  triaged,
1596
2593
  now,
1597
2594
  log,
1598
- recorder,
1599
- });
1600
- }
1601
- /**
1602
- * Drives the one bounded fix attempt a `fix-now` triage earns (WS-06).
1603
- * Selects findings from the ledger projection — never from any in-memory
1604
- * disposition — so a human-decided finding (status `"human-decided"`, not
1605
- * `"fix-now"`) is never handed to the fix seam (SC-14). A workstream's
1606
- * `fix-now` findings are fixed together, in one `attemptFix` call, then
1607
- * recorded as one `finding-fix-attempted` event per finding id — a ledger
1608
- * entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
1609
- * commit; never throws, never blocks the run (SC-15).
1610
- */
1611
- async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
1612
- const ledger = await readDecisionLedger(root, options.programId);
1613
- const fixNowIds = new Set(ledger.findings
1614
- .filter((record) => record.workstream === workstream.id && record.status === "fix-now")
1615
- .map((record) => record.id));
1616
- if (fixNowIds.size === 0)
1617
- return { commit: greenCommit };
1618
- const toFix = [];
1619
- const matchedIds = [];
1620
- for (const candidate of openFindings) {
1621
- const id = fingerprint({ ...candidate, workstreamId: workstream.id });
1622
- if (!fixNowIds.has(id))
1623
- continue;
1624
- toFix.push(candidate);
1625
- matchedIds.push(id);
1626
- }
1627
- // An id triaged fix-now but absent from the open set (should not
1628
- // happen — it is where the id came from) is skipped, fail-open.
1629
- if (toFix.length === 0)
1630
- return { commit: greenCommit };
1631
- const fix = await attemptFix({
1632
- root,
1633
- programId: options.programId,
1634
- workstream,
1635
- spec,
1636
- config,
1637
- agentRunner,
1638
- permits,
1639
- verifyRunner,
1640
- git,
1641
- agent,
1642
- findings: toFix,
1643
- greenCommit,
1644
- label: "fix-now fix",
1645
- log,
1646
- now,
1647
- observe,
1648
- stage: "fix-now",
1649
- attemptIndex: 0,
1650
- recorder,
2595
+ recorder: flowScope,
1651
2596
  });
1652
- // "kept" only when the attempt both verified clean and actually landed a
1653
- // commit — a clean-but-empty attempt is a decline, not a fix, and must
1654
- // reach the human exactly like a failed verification does.
1655
- const kept = fix.outcome === "kept" && fix.commit !== undefined;
1656
- const note = kept
1657
- ? fix.summary
1658
- : fix.outcome === "kept"
1659
- ? `${fix.summary} (the attempt verified clean but made no change; no fix landed)`
1660
- : `${fix.summary} (fix failed verification and was discarded: ${fix.failure})`;
1661
- const events = matchedIds.map((id) => ({
1662
- kind: "finding-fix-attempted",
1663
- at: now().toISOString(),
1664
- id,
1665
- outcome: kept ? "kept" : "failed",
1666
- note,
1667
- ...(kept && fix.commit !== undefined ? { commit: fix.commit } : {}),
1668
- attemptedBy: "implementer",
1669
- }));
1670
- await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
1671
- log(kept
1672
- ? `${workstream.id}: fix-now fix verified and committed`
1673
- : `${workstream.id}: fix-now fix failed and was escalated — ${note}`);
1674
- const resultCommit = kept ? fix.greenCommit : greenCommit;
1675
- return {
1676
- commit: resultCommit,
1677
- ...(fix.spawnError === undefined ? {} : { spawnError: fix.spawnError }),
1678
- ...(fix.transcript === undefined ? {} : { transcript: fix.transcript }),
1679
- };
1680
2597
  }
1681
2598
  }
1682
2599
  }
@@ -1916,6 +2833,8 @@ function causalAnalysisTimeline(results) {
1916
2833
  roundsRun: result.testCritique.roundsRun,
1917
2834
  openSubjects: result.testCritique.open.map((finding) => finding.subject),
1918
2835
  resolvedSubjects: result.testCritique.resolved.map((finding) => finding.subject),
2836
+ fixesRun: result.testCritique.fixesRun,
2837
+ cyclesAllotted: result.testCritique.cyclesAllotted,
1919
2838
  },
1920
2839
  }),
1921
2840
  }));
@@ -2042,12 +2961,62 @@ async function writeVerifyOutputEvidence(root, programId, label, output) {
2042
2961
  * dimensions (SC-05/SC-06) so a repeated verify pass can be uniquely
2043
2962
  * correlated back to the attempt that provoked it, not just its phase.
2044
2963
  */
2045
- async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder = NOOP_RUN_RECORDER, stage = "verification-command", opts = {}) {
2964
+ async function verifyAttempt(config, verifyRunner,
2965
+ /** The tree the verify commands actually run against (WS-02) — a
2966
+ * workstream's worktree during a build, `root` for the baseline. Also the
2967
+ * tree the verify cache keys on (WS-09) — see the `cwd-in-key` decision. */
2968
+ cwd,
2969
+ /** Where verify-output evidence is written (WS-02) — always `root`, so a
2970
+ * worktree never contains runner-owned transcripts. */
2971
+ evidenceRoot, agentExitCode, recorder = NOOP_RUN_RECORDER, stage = "verification-command", opts = {}) {
2046
2972
  if (agentExitCode !== 0) {
2047
2973
  return `agent exited with code ${agentExitCode}`;
2048
2974
  }
2049
- const { programId, phase, workstream, role, attemptSeat, attemptIndex } = opts;
2050
- for (const [name, command] of Object.entries(config.verify)) {
2975
+ const { programId, phase, workstream, role, attemptSeat, attemptIndex, cache } = opts;
2976
+ const commands = Object.entries(config.verify);
2977
+ if (commands.length === 0)
2978
+ return undefined;
2979
+ const cacheDims = {
2980
+ stage,
2981
+ ...(phase === undefined ? {} : { attemptReason: phase }),
2982
+ ...(workstream === undefined ? {} : { workstream }),
2983
+ };
2984
+ const key = await cache?.keyFor(cwd);
2985
+ const hit = key === undefined ? undefined : cache?.get(key);
2986
+ if (hit !== undefined) {
2987
+ for (const [name] of commands) {
2988
+ const span = recorder.span({
2989
+ stage,
2990
+ verifyCommand: name,
2991
+ ...(phase === undefined ? {} : { attemptReason: phase }),
2992
+ ...(workstream === undefined ? {} : { workstream }),
2993
+ ...(role === undefined ? {} : { role }),
2994
+ ...(attemptSeat === undefined ? {} : { attemptSeat }),
2995
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
2996
+ });
2997
+ const cached = hit.evidence.get(name);
2998
+ const evidence = cached && cached.length > 0
2999
+ ? cached.map((ref) => ({
3000
+ ...ref,
3001
+ note: `verify output (cached from the ${hit.phase} pass)`,
3002
+ }))
3003
+ : undefined;
3004
+ span.close({ outcome: "skipped", ...(evidence ? { evidence } : {}) });
3005
+ }
3006
+ recorder.point({
3007
+ kind: "verify-cache",
3008
+ coverage: "observed",
3009
+ label: "hit",
3010
+ detail: hit.phase,
3011
+ dimensions: cacheDims,
3012
+ });
3013
+ return undefined;
3014
+ }
3015
+ if (key !== undefined) {
3016
+ recorder.point({ kind: "verify-cache", coverage: "observed", label: "miss", dimensions: cacheDims });
3017
+ }
3018
+ const evidenceByCommand = new Map();
3019
+ for (const [name, command] of commands) {
2051
3020
  const span = recorder.span({
2052
3021
  stage,
2053
3022
  verifyCommand: name,
@@ -2059,7 +3028,7 @@ async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder
2059
3028
  });
2060
3029
  let result;
2061
3030
  try {
2062
- result = await verifyRunner(command, root);
3031
+ result = await verifyRunner(command, cwd);
2063
3032
  }
2064
3033
  catch (error) {
2065
3034
  span.close({ outcome: "failed" });
@@ -2067,13 +3036,29 @@ async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder
2067
3036
  }
2068
3037
  const evidenceRef = programId === undefined
2069
3038
  ? undefined
2070
- : await writeVerifyOutputEvidence(root, programId, `${phase ?? stage}-${name}`, result.output);
3039
+ : await writeVerifyOutputEvidence(evidenceRoot, programId, `${phase ?? stage}-${name}`, result.output);
2071
3040
  const evidence = evidenceRef ? [evidenceRef] : undefined;
2072
3041
  if (result.exitCode !== 0) {
2073
3042
  span.close({ outcome: "failed", ...(evidence ? { evidence } : {}) });
2074
3043
  return `verify \`${name}\` (${command}) exited ${result.exitCode}:\n${tail(result.output, 1500)}`;
2075
3044
  }
2076
3045
  span.close({ outcome: "success", ...(evidence ? { evidence } : {}) });
3046
+ evidenceByCommand.set(name, evidence ?? []);
3047
+ }
3048
+ if (key !== undefined && cache !== undefined) {
3049
+ const settled = await cache.keyFor(cwd);
3050
+ if (settled === key) {
3051
+ cache.set(key, { evidence: evidenceByCommand, phase: phase ?? stage });
3052
+ }
3053
+ else {
3054
+ recorder.point({
3055
+ kind: "verify-cache",
3056
+ coverage: "observed",
3057
+ label: "not-cached",
3058
+ detail: "commands-changed-the-tree",
3059
+ dimensions: cacheDims,
3060
+ });
3061
+ }
2077
3062
  }
2078
3063
  return undefined;
2079
3064
  }
@@ -2157,10 +3142,10 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
2157
3142
  * undefined and the retry proceeds on the raw verify output alone.
2158
3143
  */
2159
3144
  async function diagnoseFailure(options) {
2160
- const { root, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
3145
+ const { cwd, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
2161
3146
  const recorder = options.recorder ?? NOOP_RUN_RECORDER;
2162
3147
  const rawDiff = baseCommit !== undefined
2163
- ? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(root, baseCommit))
3148
+ ? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(cwd, baseCommit))
2164
3149
  : "";
2165
3150
  const clippedSpec = clipForReview(spec, "spec");
2166
3151
  const clippedDiff = clipForReview(rawDiff, "diff");
@@ -2169,7 +3154,7 @@ async function diagnoseFailure(options) {
2169
3154
  const brief = failureDiagnosisBrief(manifest, workstream, clippedSpec.text, clippedDiff.text, failure);
2170
3155
  let invocation;
2171
3156
  try {
2172
- invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe, recorder, {
3157
+ invocation = await invokeAgent(agentRunner, reviewer, brief, cwd, permits, "reviewerAgent", observe, recorder, {
2173
3158
  stage: "failure-diagnosis",
2174
3159
  workstream: workstream.id,
2175
3160
  ...(options.attemptSeat === undefined ? {} : { attemptSeat: options.attemptSeat }),
@@ -2305,27 +3290,61 @@ function findingEvidenceLine(finding) {
2305
3290
  .join("; ");
2306
3291
  return evidence === "" ? "" : ` Evidence: ${evidence}`;
2307
3292
  }
2308
- /**
2309
- * The implementer's fix re-brief: this round's triaged findings in prose,
2310
- * inviting judgment rather than demanding compliance — the manual workflow's
2311
- * own framing (WS-03 design §2). Never asks the implementer to commit or to
2312
- * echo a block back.
2313
- */
2314
- function testCritiqueFixBrief(workstream, spec, findings) {
2315
- const findingsList = findings.map((finding) => [
3293
+ function findingsListLines(findings) {
3294
+ return findings.map((finding) => [
2316
3295
  `- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
2317
3296
  findingEvidenceLine(finding),
2318
3297
  ]
2319
3298
  .filter((line) => line !== "")
2320
3299
  .join("\n"));
3300
+ }
3301
+ /**
3302
+ * The implementer's fix re-brief (WS-09 §3.5.1): `binding` is the decider's
3303
+ * `fix-now` triage — a required correction — and `advisory` is everything
3304
+ * else the reviewer raised this cycle, left to the fixer's judgment. Never
3305
+ * asks the implementer to commit or to echo a block back.
3306
+ */
3307
+ function testCritiqueFixBrief(workstream, spec, binding, advisory, resumed, deciderConfigured) {
3308
+ const bindingSection = binding.length > 0
3309
+ ? [
3310
+ "## The decider ruled: fix these now",
3311
+ "",
3312
+ "An independent reviewer raised these and the decider triaged them",
3313
+ "as worth one bounded fix attempt before the run proceeds. Apply",
3314
+ "them.",
3315
+ "",
3316
+ ...findingsListLines(binding),
3317
+ "",
3318
+ ]
3319
+ : [];
3320
+ const advisorySection = advisory.length > 0
3321
+ ? [
3322
+ "## Also raised, for your judgment",
3323
+ "",
3324
+ ...(deciderConfigured
3325
+ ? [
3326
+ "The reviewer raised these too. The decider did not mark them",
3327
+ "for an immediate fix. Weigh them and apply what you agree",
3328
+ "with.",
3329
+ ]
3330
+ : [
3331
+ "No decider is configured for this run, so none of these were",
3332
+ "triaged. Weigh them and apply what you agree with.",
3333
+ ]),
3334
+ "",
3335
+ ...findingsListLines(advisory),
3336
+ "",
3337
+ ]
3338
+ : [];
2321
3339
  return [
2322
3340
  `# Your tests for ${workstream.id}: ${workstream.name} were reviewed`,
2323
3341
  "",
3342
+ ...(resumed ? [SESSION_CONTINUATION_PREAMBLE] : []),
2324
3343
  "An independent reviewer read the diff you produced — implementation",
2325
- "and tests together — and raised the following:",
2326
- "",
2327
- ...findingsList,
3344
+ "and tests together.",
2328
3345
  "",
3346
+ ...bindingSection,
3347
+ ...advisorySection,
2329
3348
  "## Specification",
2330
3349
  "",
2331
3350
  spec.trim(),
@@ -2339,12 +3358,26 @@ function testCritiqueFixBrief(workstream, spec, findings) {
2339
3358
  "",
2340
3359
  "Never commit — the runner owns commits, verifies your fix itself, and",
2341
3360
  "either keeps it or discards it depending on whether it actually",
2342
- "verifies. Reply in prose, not a block: what you fixed and what you",
2343
- "declined, and why.",
3361
+ "verifies. Run only the tests that cover what you changed; the runner",
3362
+ "runs the project's full verify suite after you and gates on it, which",
3363
+ "overrides AGENTS.md's \"verify before claiming completion\" directive",
3364
+ "for this spawn. Reply in prose, not a block: what you fixed and what",
3365
+ "you declined, and why.",
3366
+ "",
3367
+ "You are working in a dedicated git worktree on a per-workstream",
3368
+ "branch. Do not switch branches, and do not edit `docs/programs/` —",
3369
+ "those are the runner's own artifacts and changes to them are",
3370
+ "discarded.",
2344
3371
  "",
2345
3372
  summaryContract(),
2346
3373
  ].join("\n");
2347
3374
  }
3375
+ /** Sorted `dirtyPaths` + `untrackedPaths`, joined, for the §3.6
3376
+ * before/after comparison a fix seat's resumed-spawn fallback guards on. */
3377
+ async function treeFingerprint(git, cwd) {
3378
+ const [dirty, untracked] = await Promise.all([git.dirtyPaths(cwd), git.untrackedPaths(cwd)]);
3379
+ return [...dirty].sort().join("\n") + "\u0000" + [...untracked].sort().join("\n");
3380
+ }
2348
3381
  /**
2349
3382
  * The fix seam, in full: one brief carrying every finding, one implementer
2350
3383
  * invocation, one verification, one commit on green or one reset on
@@ -2352,21 +3385,58 @@ function testCritiqueFixBrief(workstream, spec, findings) {
2352
3385
  * whether (and how many times) this is called.
2353
3386
  */
2354
3387
  async function attemptFix(options) {
2355
- const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, stage, attemptIndex, } = options;
3388
+ const { root, cwd, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, binding, advisory, deciderConfigured, greenCommit, label, log, now, observe, stage, attemptReason, attemptIndex, } = options;
2356
3389
  const recorder = options.recorder ?? NOOP_RUN_RECORDER;
2357
- const attemptReason = stage === "fix-now" ? "fix-now-finding" : "critique-finding";
2358
- const brief = testCritiqueFixBrief(workstream, spec, findings);
2359
3390
  let invocation;
3391
+ let session;
3392
+ let continuity;
3393
+ const preSpawnTree = options.buildSession ? await treeFingerprint(git, cwd) : undefined;
2360
3394
  try {
2361
- invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe, recorder, {
2362
- stage,
2363
- workstream: workstream.id,
2364
- attemptSeat: label,
2365
- attemptIndex,
2366
- attemptReason,
3395
+ const spawned = await spawnWithSessionContinuity({
3396
+ resume: options.buildSession,
3397
+ spawn: (resume, resumed) => {
3398
+ const brief = testCritiqueFixBrief(workstream, spec, binding, advisory, resumed, deciderConfigured);
3399
+ return invokeAgent(agentRunner, agent, brief, cwd, permits, "agent", observe, recorder, { stage, workstream: workstream.id, attemptSeat: label, attemptIndex, attemptReason }, resume === undefined ? {} : { handle: resume });
3400
+ },
3401
+ untouched: async () => preSpawnTree !== undefined && (await treeFingerprint(git, cwd)) === preSpawnTree,
3402
+ log,
3403
+ observeContinuity: (result) => {
3404
+ continuity = result;
3405
+ try {
3406
+ recorder.point({
3407
+ kind: "session-continuity",
3408
+ coverage: "observed",
3409
+ label: result.mode,
3410
+ ...(result.declineReason ? { detail: result.declineReason } : {}),
3411
+ dimensions: { stage, workstream: workstream.id, role: "agent", attemptSeat: label, attemptIndex, attemptReason },
3412
+ });
3413
+ }
3414
+ catch (error) {
3415
+ log(`run-analytics: session continuity point failed: ${error.message}`);
3416
+ }
3417
+ },
2367
3418
  });
3419
+ invocation = spawned.result;
3420
+ session = spawned.result.session;
3421
+ continuity = spawned.continuity;
2368
3422
  }
2369
3423
  catch (error) {
3424
+ // A rethrow out of `spawnWithSessionContinuity` (WS-08 §3.6) means a
3425
+ // resumed spawn threw *and* it had already touched the tree — the one
3426
+ // case where reaching this catch does not mean "nothing was built".
3427
+ // Reset defensively so a half-applied fix is never left standing; a
3428
+ // plain spawn failure (no resume attempted, or the tree is unchanged)
3429
+ // makes this a no-op reset to the state already there.
3430
+ if (preSpawnTree !== undefined) {
3431
+ try {
3432
+ await git.resetHard(cwd, greenCommit);
3433
+ }
3434
+ catch {
3435
+ // Best-effort: the green state may already be intact, or the reset
3436
+ // itself failed for an unrelated reason; either way this must not
3437
+ // mask the original spawn error being reported below.
3438
+ }
3439
+ }
2370
3440
  const spawnError = `the ${label} implementer could not be spawned: ${error.message}; ` +
2371
3441
  "the fix was not attempted and the green state was preserved.";
2372
3442
  recorder.point({
@@ -2388,6 +3458,7 @@ async function attemptFix(options) {
2388
3458
  summary: "(fix not attempted — the implementer could not be spawned)",
2389
3459
  failure: spawnError,
2390
3460
  spawnError,
3461
+ ...(continuity ? { continuity } : {}),
2391
3462
  };
2392
3463
  }
2393
3464
  const transcript = invocation.transcript;
@@ -2399,16 +3470,17 @@ async function attemptFix(options) {
2399
3470
  for (const event of denialEvents) {
2400
3471
  if (event.kind !== "permission-denied")
2401
3472
  continue;
2402
- log(`${workstream.id} ${label}: denied \`${event.command}\` (${event.source})`);
3473
+ log(`${label}: denied \`${event.command}\` (${event.source})`);
2403
3474
  }
2404
3475
  }
2405
- const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
3476
+ const failure = await verifyAttempt(config, verifyRunner, cwd, root, invocation.exitCode, recorder, "verification-command", {
2406
3477
  programId,
2407
3478
  phase: stage,
2408
3479
  workstream: workstream.id,
2409
3480
  role: "agent",
2410
3481
  attemptSeat: label,
2411
3482
  attemptIndex,
3483
+ ...(options.verifyCache ? { cache: options.verifyCache } : {}),
2412
3484
  });
2413
3485
  const emitOutcome = (outcome) => {
2414
3486
  recorder.point({
@@ -2435,10 +3507,10 @@ async function attemptFix(options) {
2435
3507
  // outside docs/programs counts as the implementer having done anything;
2436
3508
  // with none, skip committing — the pending bookkeeping stays for a
2437
3509
  // later commit to pick up, nothing is lost.
2438
- const dirty = await git.dirtyPaths(root);
2439
- const meaningfulChange = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
3510
+ const dirty = await git.dirtyPaths(cwd);
3511
+ const meaningfulChange = dirty.some((path) => !isRunnerOwnedArtifactPath(path));
2440
3512
  const commit = meaningfulChange
2441
- ? await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(root, `nightshift(${programId}): ${workstream.id} ${label}`), commitEvidence)
3513
+ ? await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(cwd, `nightshift(${programId}): ${workstream.id} ${label}`, ["docs/programs"]), commitEvidence)
2442
3514
  : undefined;
2443
3515
  emitOutcome(commit === undefined ? "discarded" : "success");
2444
3516
  return {
@@ -2447,23 +3519,26 @@ async function attemptFix(options) {
2447
3519
  ...(commit === undefined ? {} : { commit }),
2448
3520
  summary,
2449
3521
  ...(transcript === undefined ? {} : { transcript }),
3522
+ ...(session === undefined ? {} : { session }),
3523
+ ...(continuity === undefined ? {} : { continuity }),
2450
3524
  };
2451
3525
  }
2452
- // The same uncommitted bookkeeping the clean path guards against sweeping
2453
- // into a commit is, on this path, what a whole-tree `git reset --hard`
2454
- // would silently throw away: by the time a fix-now attempt runs, this
2455
- // workstream's finding-recorded, finding-triaged and decision-reviewed
2456
- // events are on disk and not yet committed. Resetting without them would
2457
- // erase the finding this very attempt is about to report a failure on —
2458
- // and the `finding-fix-attempted` event appended afterwards would project
2459
- // against nothing, so the escalation would never reach the human (SC-15).
2460
- // Preserve the journal across the rollback exactly as `decide`'s replay
2461
- // does; the run's final commit picks it up.
2462
- const snapshot = await snapshotProgramsDir(root);
2463
- await git.resetHard(root, greenCommit);
2464
- await restoreProgramsDir(root, snapshot);
3526
+ // A worktree hazard the pre-WS-02 code guarded against with a
3527
+ // snapshot/restore of docs/programs no longer exists: the runner writes
3528
+ // nothing under docs/programs in a worktree, so a whole-tree
3529
+ // `git reset --hard` here throws away nothing the run's journal at `root`
3530
+ // depends on.
3531
+ await git.resetHard(cwd, greenCommit);
2465
3532
  emitOutcome("failed");
2466
- return { outcome: "failed", greenCommit, summary, failure, ...(transcript === undefined ? {} : { transcript }) };
3533
+ return {
3534
+ outcome: "failed",
3535
+ greenCommit,
3536
+ summary,
3537
+ failure,
3538
+ ...(transcript === undefined ? {} : { transcript }),
3539
+ ...(session === undefined ? {} : { session }),
3540
+ ...(continuity === undefined ? {} : { continuity }),
3541
+ };
2467
3542
  }
2468
3543
  /**
2469
3544
  * The test-critique driver: wires WS-01's bounded review loop to the two
@@ -2473,12 +3548,16 @@ async function attemptFix(options) {
2473
3548
  * failure), so a review can never turn green work red.
2474
3549
  */
2475
3550
  async function runTestCritique(options) {
2476
- const { root, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, baseCommit, now, log, transcriptSink, } = options;
3551
+ const { root, cwd, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, deciderConfigured, baseCommit, now, log, transcriptSink, triage, } = options;
2477
3552
  const recorder = options.recorder ?? NOOP_RUN_RECORDER;
2478
3553
  let greenCommit = options.greenCommit;
2479
3554
  let diffClipped = false;
2480
3555
  const stageErrors = [];
2481
3556
  const transcripts = [];
3557
+ // WS-08: last-wins across every fix round this pass runs — the next
3558
+ // round resumes whichever round most recently reported a session id.
3559
+ let buildSession = options.buildSession;
3560
+ const continuity = [];
2482
3561
  if (!reviewer) {
2483
3562
  return {
2484
3563
  outcome: reviewerAbsentOutcome(),
@@ -2486,12 +3565,13 @@ async function runTestCritique(options) {
2486
3565
  diffClipped,
2487
3566
  stageErrors,
2488
3567
  transcripts,
3568
+ ...(buildSession ? { buildSession } : {}),
2489
3569
  };
2490
3570
  }
2491
- const locate = (file) => locateInRepo(root, file);
3571
+ const locate = (file) => locateInRepo(cwd, file);
2492
3572
  const review = async (round, priorOpen) => {
2493
3573
  const rawDiff = baseCommit !== undefined
2494
- ? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(root, baseCommit))
3574
+ ? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(cwd, baseCommit))
2495
3575
  : "";
2496
3576
  const diff = clipForReview(rawDiff, "diff");
2497
3577
  const clippedSpec = clipForReview(spec, "spec");
@@ -2502,7 +3582,7 @@ async function runTestCritique(options) {
2502
3582
  const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
2503
3583
  let invocation;
2504
3584
  try {
2505
- invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"), recorder, { workstream: workstream.id, attemptIndex: round });
3585
+ invocation = await invokeAgent(agentRunner, reviewer, brief, cwd, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"), recorder, { workstream: workstream.id, attemptIndex: round });
2506
3586
  }
2507
3587
  catch (error) {
2508
3588
  // Fail open, matching the existing reviewer-error path: no findings
@@ -2518,7 +3598,7 @@ async function runTestCritique(options) {
2518
3598
  for (const event of denialEvents) {
2519
3599
  if (event.kind !== "permission-denied")
2520
3600
  continue;
2521
- log(`${workstream.id} reviewer: denied \`${event.command}\` (${event.source})`);
3601
+ log(`reviewer: denied \`${event.command}\` (${event.source})`);
2522
3602
  }
2523
3603
  }
2524
3604
  }
@@ -2527,9 +3607,56 @@ async function runTestCritique(options) {
2527
3607
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
2528
3608
  return { findings, errors: parsed.errors, ran };
2529
3609
  };
2530
- const respond = async (round, findings) => {
3610
+ const respond = async (cycle, findings) => {
3611
+ // Step 1: record. Every routable finding this cycle raised reaches the
3612
+ // ledger — not a post-fix residue (WS-09 §3.5), anchored to the same
3613
+ // durable pre-workstream commit the decider's own diff uses, so a
3614
+ // rendered reference still resolves after this workstream's worktree
3615
+ // and branch are gone (see the comment on `diffSince`'s docs/programs
3616
+ // exclusion for the same worktree-lifetime concern).
3617
+ const routable = findings.filter(hasRoutableEvidence);
3618
+ const events = findingsToLedgerEvents({
3619
+ workstreamId: workstream.id,
3620
+ findings: routable,
3621
+ ...(baseCommit === undefined ? {} : { baseCommit }),
3622
+ now,
3623
+ });
3624
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, manifest.program.id, events));
3625
+ // Step 2: triage — one decider spawn per routed finding, before the fix.
3626
+ stageErrors.push(...(await triage(events, baseCommit)));
3627
+ // Step 3: split. Read the ledger projection *after* triage so this
3628
+ // cycle's `finding-triaged` events are visible. A `human-decided`
3629
+ // finding is dropped entirely — never handed to the fix seam
3630
+ // (`run-program.ts` §2.3/§3.6's invariant, preserved); a `fix-now`
3631
+ // finding with no fix attempt on it yet is binding; everything else
3632
+ // (open, accepted, escalated, or no ledger record at all — an
3633
+ // advisory-only or unverifiable finding) is advisory.
3634
+ const ledger = await readDecisionLedger(root, manifest.program.id);
3635
+ const recordsById = new Map(ledger.findings.map((record) => [record.id, record]));
3636
+ const binding = [];
3637
+ const bindingIds = [];
3638
+ const advisory = [];
3639
+ for (const finding of findings) {
3640
+ const id = fingerprint({ ...finding, workstreamId: workstream.id });
3641
+ const record = recordsById.get(id);
3642
+ if (record?.status === "human-decided")
3643
+ continue;
3644
+ if (record?.status === "fix-now" && record.fixAttempt === undefined) {
3645
+ binding.push(finding);
3646
+ bindingIds.push(id);
3647
+ }
3648
+ else {
3649
+ advisory.push(finding);
3650
+ }
3651
+ }
3652
+ // Step 4: fix. Skipped only when every finding this cycle raised was
3653
+ // already human-decided.
3654
+ if (binding.length === 0 && advisory.length === 0) {
3655
+ return { note: "every finding this cycle raised was already human-decided; no fix pass ran" };
3656
+ }
2531
3657
  const fix = await attemptFix({
2532
3658
  root,
3659
+ cwd,
2533
3660
  programId: manifest.program.id,
2534
3661
  workstream,
2535
3662
  spec,
@@ -2539,30 +3666,83 @@ async function runTestCritique(options) {
2539
3666
  verifyRunner,
2540
3667
  git,
2541
3668
  agent,
2542
- findings,
3669
+ binding,
3670
+ advisory,
3671
+ deciderConfigured,
2543
3672
  greenCommit,
2544
3673
  label: "test critique fix",
2545
3674
  log,
2546
3675
  now,
2547
3676
  observe: transcriptSink("test-critique-fix"),
2548
3677
  stage: "test-critique-fix",
2549
- attemptIndex: round,
3678
+ attemptReason: "triaged-findings",
3679
+ attemptIndex: cycle,
2550
3680
  recorder,
3681
+ ...(options.verifyCache ? { verifyCache: options.verifyCache } : {}),
3682
+ ...(buildSession ? { buildSession } : {}),
2551
3683
  });
2552
3684
  greenCommit = fix.greenCommit;
2553
3685
  if (fix.transcript)
2554
3686
  transcripts.push(fix.transcript);
2555
3687
  if (fix.spawnError !== undefined)
2556
3688
  stageErrors.push(fix.spawnError);
3689
+ if (fix.session)
3690
+ buildSession = fix.session;
3691
+ if (fix.continuity)
3692
+ continuity.push(fix.continuity);
3693
+ // Step 5: journal the fix-now outcome. One `finding-fix-attempted` per
3694
+ // binding id, exactly `driveFixNowFindings`'s old rule: "kept" requires
3695
+ // both a clean verify and a landed commit — a clean-but-empty attempt is
3696
+ // a decline, not a fix. The merged fix also covers `advisory` findings
3697
+ // in the same spawn, so a commit does not prove any one binding finding
3698
+ // was addressed (the ledger projection already disclaims per-finding
3699
+ // proof); the note is prefixed with the binding subjects so a human
3700
+ // reading the ledger sees both the instruction and the reply, never
3701
+ // inferred from the fixer's prose.
3702
+ if (bindingIds.length > 0) {
3703
+ const kept = fix.outcome === "kept" && fix.commit !== undefined;
3704
+ const bindingSubjects = binding.map((finding) => finding.subject).join(", ");
3705
+ const note = kept
3706
+ ? `(fix-now: ${bindingSubjects}) ${fix.summary}`
3707
+ : fix.outcome === "kept"
3708
+ ? `(fix-now: ${bindingSubjects}) ${fix.summary} (the attempt verified clean but made no change; no fix landed)`
3709
+ : `(fix-now: ${bindingSubjects}) ${fix.summary} (fix failed verification and was discarded: ${fix.failure})`;
3710
+ const fixNowEvents = bindingIds.map((id) => ({
3711
+ kind: "finding-fix-attempted",
3712
+ at: now().toISOString(),
3713
+ id,
3714
+ outcome: kept ? "kept" : "failed",
3715
+ note,
3716
+ ...(kept && fix.commit !== undefined ? { commit: fix.commit } : {}),
3717
+ attemptedBy: "implementer",
3718
+ }));
3719
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, manifest.program.id, fixNowEvents));
3720
+ log(kept
3721
+ ? "fix-now finding(s) verified and committed"
3722
+ : `fix-now finding(s) failed and were escalated — ${note}`);
3723
+ }
2557
3724
  if (fix.outcome === "kept") {
2558
- log(`${workstream.id}: test critique fix verified and committed`);
3725
+ log("test critique fix verified and committed");
2559
3726
  return { note: `${fix.summary} (fix verified and committed)` };
2560
3727
  }
2561
- log(`${workstream.id}: test critique fix failed verification and was discarded — ${fix.failure}`);
3728
+ log(`test critique fix failed verification and was discarded — ${fix.failure}`);
2562
3729
  return { note: `${fix.summary} (fix failed verification and was discarded; the green state was preserved)` };
2563
3730
  };
2564
- const outcome = await runReviewPass({ review, respond });
2565
- return { outcome, finalCommit: greenCommit, diffClipped, stageErrors, transcripts };
3731
+ const outcome = await runReviewPass({
3732
+ cycles: TEST_CRITIQUE_CYCLES,
3733
+ cycleNote: TEST_CRITIQUE_CYCLE_NOTE,
3734
+ review,
3735
+ respond,
3736
+ });
3737
+ return {
3738
+ outcome,
3739
+ finalCommit: greenCommit,
3740
+ diffClipped,
3741
+ stageErrors,
3742
+ transcripts,
3743
+ ...(buildSession ? { buildSession } : {}),
3744
+ ...(continuity.length > 0 ? { continuity } : {}),
3745
+ };
2566
3746
  }
2567
3747
  function renderSpecsSection(authorResult) {
2568
3748
  if (authorResult.results.length === 0)
@@ -2597,7 +3777,7 @@ function renderSpecsSection(authorResult) {
2597
3777
  lines.push(...notes.map((note) => `- ${note}`), "");
2598
3778
  }
2599
3779
  if (entry.specCritique) {
2600
- lines.push(...renderPassReport("Spec critique", entry.specCritique), "");
3780
+ lines.push(...renderPassReport("Spec critique", entry.specCritique, "runner spec check"), "");
2601
3781
  }
2602
3782
  }
2603
3783
  return lines;
@@ -2844,6 +4024,25 @@ function renderWorkstreamCommands(result) {
2844
4024
  }
2845
4025
  return lines;
2846
4026
  }
4027
+ /**
4028
+ * States a fix seat's session-resume fallback where a human will see it
4029
+ * (WS-08 §3.11) — nothing is added when every requesting seat resumed
4030
+ * cleanly, so a clean run's report is unchanged.
4031
+ */
4032
+ function renderSessionContinuityFallbacks(result) {
4033
+ const lines = [];
4034
+ for (const entry of result.sessionContinuity ?? []) {
4035
+ if (entry.continuity.mode !== "fresh" || entry.continuity.declineReason === undefined)
4036
+ continue;
4037
+ const reason = describeSessionDecline(entry.continuity.declineReason, {
4038
+ producerCommand: entry.producerCommand,
4039
+ seatCommand: entry.seatCommand,
4040
+ });
4041
+ lines.push(` - The ${entry.seat} could not continue the implementer's session (${reason}); ` +
4042
+ "a fresh agent applied the findings instead.");
4043
+ }
4044
+ return lines;
4045
+ }
2847
4046
  /**
2848
4047
  * The program-level `## Commands denied` section (SC-12): every
2849
4048
  * `permission-denied` ledger record, grouped by workstream in the run's own
@@ -2929,6 +4128,12 @@ causalAnalysisSection) {
2929
4128
  for (const diagnosis of result.failureDiagnoses ?? []) {
2930
4129
  lines.push(`**Reviewer diagnosis (after the ${diagnosis.attempt} attempt):**`, "", diagnosis.verdict, "");
2931
4130
  }
4131
+ if (result.worktreePath !== undefined) {
4132
+ lines.push(`Worktree retained at \`${result.worktreePath}\`.`, "");
4133
+ }
4134
+ if (result.retainedRef !== undefined) {
4135
+ lines.push(`Verified commit retained at \`${result.retainedRef}\`.`, "");
4136
+ }
2932
4137
  const workstreamDenials = ledger.denials.filter((denial) => denial.workstream === result.id);
2933
4138
  if (workstreamDenials.length > 0) {
2934
4139
  lines.push("Commands denied: " +
@@ -2962,6 +4167,7 @@ causalAnalysisSection) {
2962
4167
  if (result.outcome.status === "complete" || result.outcome.status === "failed") {
2963
4168
  lines.push(...renderWorkstreamCommands(result));
2964
4169
  }
4170
+ lines.push(...renderSessionContinuityFallbacks(result));
2965
4171
  }
2966
4172
  lines.push("");
2967
4173
  if (wholeProgram !== undefined) {