@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
package/dist/author.js CHANGED
@@ -1,17 +1,23 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { defaultAgentRunner, describeAgent, invokeAgent, resolveAuthorAgent, resolveDeciderAgent, resolveReviewerAgent, } from "./agent-runner.js";
4
+ import { describeSessionDecline, spawnWithSessionContinuity, SESSION_CONTINUATION_PREAMBLE, } from "./agent-session.js";
4
5
  import { resolveSummary, summaryContract } from "./agent-summary.js";
5
6
  import { NOOP_PERMITS_CONTEXT } from "./permits.js";
6
7
  import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
7
- import { appendLedgerEvents } from "./decision-ledger.js";
8
+ import { appendLedgerEvents, readDecisionLedger } from "./decision-ledger.js";
8
9
  import { reviewDecisions, triageFindings } from "./decider-review.js";
9
- import { findCycles, stableTopologicalOrder, topologicalLevels } from "./graph.js";
10
+ import { fingerprint } from "./findings.js";
11
+ import { findCycles, stableTopologicalOrder } from "./graph.js";
10
12
  import { loadManifest, saveManifest, specInputsHash, } from "./manifest.js";
11
- import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, verifyEvidence, } from "./review-pass.js";
13
+ import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, specCritiqueBudget, verifyEvidence, } from "./review-pass.js";
12
14
  import { createBriefBuilder, demotedDependenciesPoint, promptComponentSizePoints, } from "./prompt-telemetry.js";
13
15
  import { commitEvidence, defaultGitOps, downstreamCone, timed } from "./run-program.js";
14
- import { NOOP_RUN_RECORDER } from "./run-analytics.js";
16
+ import { NOOP_RUN_RECORDER, recordWorkstreamWaiting, } from "./run-analytics.js";
17
+ import { schedulerLog, workstreamLog } from "./run-log.js";
18
+ import { createDeferred, createMutex } from "./concurrency.js";
19
+ import { normalizeMaxParallel, runReadySet } from "./scheduler.js";
20
+ import { enqueueProgramWrite, programWriteKey } from "./write-queue.js";
15
21
  /**
16
22
  * The authoring stage: for every workstream whose spec does not exist, a
17
23
  * clean agent writes it — human-first, grounded in the repository,
@@ -223,7 +229,7 @@ function prerequisitesSection(manifest, workstream) {
223
229
  lines.push("Acceptance criteria in this spec may assert only outcomes the executing", "agent and the runner control. An outcome that depends on one of the human", "actions above — the infrastructure it creates, the secret it sets — must", "not appear as a criterion this run is expected to turn green: no actor in", "this run can make it true.", "", "End-to-end tests that need a human action are still written now, but", "gated on the prerequisite's observable — skip the test when its", "verifyCommand would not pass, with a skip message that names the HP id", '(for example, "skipped: awaiting HP-01"). The gated test is the honest', "second check: it asserts the real outcome and runs for real once the", "human has acted.", "");
224
230
  return lines;
225
231
  }
226
- async function dependencySpecsSection(root, workstream, rosterById) {
232
+ async function dependencySpecsSection(root, workstream, rosterById, specFinal) {
227
233
  if (workstream.dependencies.length === 0) {
228
234
  return {
229
235
  section: ["## Dependency specs", "", "No dependencies declared.", ""],
@@ -234,10 +240,17 @@ async function dependencySpecsSection(root, workstream, rosterById) {
234
240
  const entries = await Promise.all(workstream.dependencies
235
241
  .map((id) => rosterById.get(id))
236
242
  .filter((entry) => entry !== undefined)
237
- .map(async (entry) => ({
238
- entry,
239
- content: await readFinishedSpec(root, entry),
240
- })));
243
+ .map(async (entry) => {
244
+ const raw = await readFinishedSpec(root, entry);
245
+ // A dependency whose flow is still running, or that settled without
246
+ // finishing (a parked protocol failure or throw — WS-03 §3.3), is
247
+ // never read as content, even when a non-empty file exists: its
248
+ // bytes on disk may still change (in flight) or were never trusted
249
+ // in the first place (interrupted). It renders as its roster line,
250
+ // exactly like a genuinely missing spec, below.
251
+ const content = raw !== undefined && (specFinal === undefined || specFinal(entry.id)) ? raw : undefined;
252
+ return { entry, content };
253
+ }));
241
254
  let total = entries.reduce((sum, e) => sum + (e.content?.length ?? 0), 0);
242
255
  const demoted = new Set();
243
256
  let demotedBytes = 0;
@@ -319,9 +332,23 @@ function dependenciesContract() {
319
332
  * component, with `components` summing exactly to the joined brief's byte
320
333
  * length.
321
334
  */
322
- async function authorBrief(root, manifest, workstream, rosterById, reauthorNote) {
335
+ async function authorBrief(root, manifest, workstream, rosterById, reauthorNote,
336
+ /**
337
+ * True when `id`'s spec is final: its authoring flow has settled *and* the
338
+ * spec on disk classifies `complete` or `legacy`. Both halves are load-
339
+ * bearing. A flow that is still running may rewrite its file during
340
+ * critique; a flow that settled on a protocol failure or a throw leaves a
341
+ * non-empty file the runner has explicitly refused to trust and marked
342
+ * `in_progress` (WS-03 §3.3). Either way the dependency renders as its
343
+ * roster line, never as the text currently on disk — a non-empty taskFile
344
+ * proves only that writing started, which is the distinction `classifySpec`
345
+ * already draws for the workstream's own spec. `authorProgram` always
346
+ * supplies this; it is optional only so `authorBrief`'s pure unit tests
347
+ * keep today's shape.
348
+ */
349
+ specFinal) {
323
350
  const programDoc = await programDocumentSection(root, manifest);
324
- const deps = await dependencySpecsSection(root, workstream, rosterById);
351
+ const deps = await dependencySpecsSection(root, workstream, rosterById, specFinal);
325
352
  const builder = createBriefBuilder();
326
353
  builder.push("framing", `# Workstream ${workstream.id}: ${workstream.name}`, "");
327
354
  builder.push("program-narrative", `Program: ${manifest.program.id} — ${manifest.program.name}`);
@@ -415,36 +442,76 @@ function specCritiqueReviewerBrief(root, manifest, workstream, rosterById, spec,
415
442
  ].join("\n");
416
443
  })();
417
444
  }
418
- function specCritiqueWriterBrief(workstream, spec, findings) {
419
- const findingsList = findings.length === 0
420
- ? ["(no findings — this should not happen; treat as a clean read.)"]
421
- : findings.map((finding) => {
422
- const evidence = finding.evidence
423
- .map((entry) => {
424
- if (entry.kind === "location") {
425
- return `${entry.file}:${entry.startLine}${entry.excerpt ? ` — ${entry.excerpt}` : ""}`;
426
- }
427
- if (entry.kind === "concern") {
428
- return entry.detail ? `${entry.named} ${entry.detail}` : entry.named;
429
- }
430
- return `${entry.metric}: ${entry.value}`;
431
- })
432
- .join("; ");
433
- return [
434
- `- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
435
- evidence ? ` Evidence: ${evidence}` : undefined,
436
- ]
437
- .filter((line) => line !== undefined)
438
- .join("\n");
439
- });
445
+ function findingsListLines(findings) {
446
+ return findings.map((finding) => {
447
+ const evidence = finding.evidence
448
+ .map((entry) => {
449
+ if (entry.kind === "location") {
450
+ return `${entry.file}:${entry.startLine}${entry.excerpt ? ` — ${entry.excerpt}` : ""}`;
451
+ }
452
+ if (entry.kind === "concern") {
453
+ return entry.detail ? `${entry.named} — ${entry.detail}` : entry.named;
454
+ }
455
+ return `${entry.metric}: ${entry.value}`;
456
+ })
457
+ .join("; ");
458
+ return [
459
+ `- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
460
+ evidence ? ` Evidence: ${evidence}` : undefined,
461
+ ]
462
+ .filter((line) => line !== undefined)
463
+ .join("\n");
464
+ });
465
+ }
466
+ /**
467
+ * The author's spec-revision re-brief (WS-09 §3.7.2): `binding` is the
468
+ * decider's `fix-now` triage — a required correction — and `advisory` is
469
+ * everything else the reviewer raised this cycle, left to the writer's
470
+ * judgment. Mirrors `testCritiqueFixBrief` on the build side.
471
+ */
472
+ function specCritiqueWriterBrief(workstream, spec, binding, advisory, resumed, deciderConfigured) {
473
+ const bindingSection = binding.length > 0
474
+ ? [
475
+ "## The decider ruled: fix these now",
476
+ "",
477
+ "An independent reviewer raised these and the decider triaged them",
478
+ "as worth one bounded fix attempt before the run proceeds. Apply",
479
+ "them.",
480
+ "",
481
+ ...findingsListLines(binding),
482
+ "",
483
+ ]
484
+ : [];
485
+ const advisorySection = advisory.length > 0
486
+ ? [
487
+ "## Also raised, for your judgment",
488
+ "",
489
+ ...(deciderConfigured
490
+ ? [
491
+ "The reviewer raised these too. The decider did not mark them",
492
+ "for an immediate fix. Weigh them and apply what you agree",
493
+ "with.",
494
+ ]
495
+ : [
496
+ "No decider is configured for this run, so none of these were",
497
+ "triaged. Weigh them and apply what you agree with.",
498
+ ]),
499
+ "",
500
+ ...findingsListLines(advisory),
501
+ "",
502
+ ]
503
+ : [];
504
+ const bothEmpty = binding.length === 0 && advisory.length === 0;
440
505
  return [
441
506
  `# Your spec for ${workstream.id}: ${workstream.name} was reviewed`,
442
507
  "",
508
+ ...(resumed ? [SESSION_CONTINUATION_PREAMBLE] : []),
443
509
  "An independent reviewer read the spec you wrote and raised the",
444
510
  "following:",
445
511
  "",
446
- ...findingsList,
447
- "",
512
+ ...(bothEmpty
513
+ ? ["(no findings — this should not happen; treat as a clean read.)", ""]
514
+ : [...bindingSection, ...advisorySection]),
448
515
  "Here is the spec as it currently stands:",
449
516
  "",
450
517
  "## Current spec",
@@ -463,13 +530,63 @@ function specCritiqueWriterBrief(workstream, spec, findings) {
463
530
  function hasFindingsBlock(output) {
464
531
  return /```findings/u.test(output);
465
532
  }
533
+ function createSpecGate(args) {
534
+ const { root, git, isRepository, workstream, programId, log, pushStageError } = args;
535
+ return {
536
+ async snapshot() {
537
+ if (!isRepository)
538
+ return { dirty: [], untracked: [] };
539
+ const [dirty, untracked] = await Promise.all([
540
+ git.dirtyPaths(root),
541
+ git.untrackedPaths(root),
542
+ ]);
543
+ return { dirty, untracked };
544
+ },
545
+ async check({ before, snapshot }) {
546
+ const after = await readFinishedSpec(root, workstream);
547
+ if (after === undefined) {
548
+ await writeFile(join(root, workstream.taskFile), before, "utf8");
549
+ const failure = `${workstream.id}: the spec-critique writer left the spec empty or ` +
550
+ "missing; the pre-fix spec was restored";
551
+ log(failure);
552
+ pushStageError(failure);
553
+ return { ok: false, failure, stray: [] };
554
+ }
555
+ let stray = [];
556
+ if (isRepository) {
557
+ const [dirtyNow, untrackedNow] = await Promise.all([
558
+ git.dirtyPaths(root),
559
+ git.untrackedPaths(root),
560
+ ]);
561
+ const before_ = new Set([...snapshot.dirty, ...snapshot.untracked]);
562
+ const now_ = new Set([...dirtyNow, ...untrackedNow]);
563
+ const taskPrefix = `tasks/${programId}/`;
564
+ stray = [...now_].filter((path) => !before_.has(path) &&
565
+ !path.startsWith(taskPrefix) &&
566
+ !path.startsWith("docs/programs/"));
567
+ if (stray.length > 0) {
568
+ const message = `${workstream.id}: the spec-critique writer touched path(s) outside ` +
569
+ `its own spec: ${stray.join(", ")}`;
570
+ log(message);
571
+ pushStageError(message);
572
+ }
573
+ }
574
+ return { ok: true, stray };
575
+ },
576
+ };
577
+ }
466
578
  async function runSpecCritique(options) {
467
579
  const reviewer = resolveReviewerAgent(options.config);
468
580
  if (!reviewer)
469
- return reviewerAbsentOutcome();
581
+ return { outcome: reviewerAbsentOutcome() };
470
582
  const recorder = options.recorder ?? NOOP_RUN_RECORDER;
471
583
  const locate = (file) => locateInRepo(options.root, file);
472
584
  const span = recorder.span({ stage: "spec-critique", workstream: options.workstream.id });
585
+ // Explicit parenting (WS-01 §3.3): the reviewer/writer spawns below nest
586
+ // under this span because the code says so, never because of what
587
+ // happens to be open on the implicit stack — the one other spot besides
588
+ // `run` itself that relied on implicit nesting.
589
+ const scope = span.scope();
473
590
  const review = async (round, priorOpen) => {
474
591
  const currentSpec = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
475
592
  const brief = await specCritiqueReviewerBrief(options.root, options.manifest, options.workstream, options.rosterById, currentSpec, priorOpen);
@@ -480,7 +597,7 @@ async function runSpecCritique(options) {
480
597
  programId: options.programId,
481
598
  label: `${options.workstream.id}-spec-critique-reviewer`,
482
599
  log: options.log,
483
- }, recorder, { workstream: options.workstream.id, attemptIndex: round });
600
+ }, scope, { workstream: options.workstream.id, attemptIndex: round });
484
601
  }
485
602
  catch (error) {
486
603
  // Fail open, matching the equivalent build-side guard in
@@ -497,17 +614,89 @@ async function runSpecCritique(options) {
497
614
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
498
615
  return { findings, errors: parsed.errors, ran };
499
616
  };
500
- const respond = async (round, findings) => {
501
- const currentSpec = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
502
- const brief = specCritiqueWriterBrief(options.workstream, currentSpec, findings);
503
- let invocation;
504
- try {
505
- invocation = await invokeAgent(options.agentRunner, options.author, brief, options.root, options.permits, "authorAgent", {
617
+ let sessionFallbackNote;
618
+ const respond = async (cycle, findings) => {
619
+ const before = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
620
+ // Step 2: record. Every routable finding this cycle raised reaches the
621
+ // ledger — not a post-critique residue (WS-09 §3.7.2), anchored to the
622
+ // same pre-workstream commit `authorWorkstream` uses elsewhere.
623
+ const routable = findings.filter(hasRoutableEvidence);
624
+ const events = findingsToLedgerEvents({
625
+ workstreamId: options.workstream.id,
626
+ findings: routable,
627
+ ...(options.baseCommit === undefined ? {} : { baseCommit: options.baseCommit }),
628
+ now: options.now,
629
+ });
630
+ await timed(scope, { stage: "ledger-persist", workstream: options.workstream.id }, () => appendLedgerEvents(options.root, options.programId, events));
631
+ // Step 3: triage — one decider spawn per routed finding, before the writer runs.
632
+ const triageErrors = await options.triage(events, options.baseCommit);
633
+ for (const message of triageErrors)
634
+ options.pushStageError(message);
635
+ // Step 4: split. Read the ledger projection *after* triage. A
636
+ // `human-decided` finding never reaches the writer; a `fix-now` finding
637
+ // with no fix attempt on it yet is binding; everything else is advisory.
638
+ const ledger = await readDecisionLedger(options.root, options.programId);
639
+ const recordsById = new Map(ledger.findings.map((record) => [record.id, record]));
640
+ const binding = [];
641
+ const bindingIds = [];
642
+ const advisory = [];
643
+ for (const finding of findings) {
644
+ const id = fingerprint({ ...finding, workstreamId: options.workstream.id });
645
+ const record = recordsById.get(id);
646
+ if (record?.status === "human-decided")
647
+ continue;
648
+ if (record?.status === "fix-now" && record.fixAttempt === undefined) {
649
+ binding.push(finding);
650
+ bindingIds.push(id);
651
+ }
652
+ else {
653
+ advisory.push(finding);
654
+ }
655
+ }
656
+ const gateSnapshot = await options.specGate.snapshot();
657
+ const spawnOnce = (resume, resumed) => {
658
+ const brief = specCritiqueWriterBrief(options.workstream, before, binding, advisory, resumed, options.deciderConfigured);
659
+ return invokeAgent(options.agentRunner, options.author, brief, options.root, options.permits, "authorAgent", {
506
660
  root: options.root,
507
661
  programId: options.programId,
508
662
  label: `${options.workstream.id}-spec-critique-writer`,
509
663
  log: options.log,
510
- }, recorder, { workstream: options.workstream.id, attemptIndex: round });
664
+ }, scope, { workstream: options.workstream.id, attemptIndex: cycle, attemptReason: "triaged-findings" }, resume === undefined ? {} : { handle: resume });
665
+ };
666
+ let invocation;
667
+ try {
668
+ const spawned = await spawnWithSessionContinuity({
669
+ resume: options.authorSession,
670
+ spawn: spawnOnce,
671
+ untouched: async () => (await readFinishedSpec(options.root, options.workstream)) === before,
672
+ log: options.log,
673
+ observeContinuity: (continuity) => {
674
+ try {
675
+ scope.point({
676
+ kind: "session-continuity",
677
+ coverage: "observed",
678
+ label: continuity.mode,
679
+ ...(continuity.declineReason ? { detail: continuity.declineReason } : {}),
680
+ dimensions: {
681
+ stage: "spec-critique",
682
+ workstream: options.workstream.id,
683
+ role: "authorAgent",
684
+ attemptIndex: cycle,
685
+ },
686
+ });
687
+ }
688
+ catch (error) {
689
+ options.log(`run-analytics: session continuity point failed: ${error.message}`);
690
+ }
691
+ if (continuity.mode === "fresh" && continuity.declineReason) {
692
+ sessionFallbackNote =
693
+ `The spec revision could not continue the author's own session ` +
694
+ `(${describeSessionDecline(continuity.declineReason)}) and a fresh agent ` +
695
+ `applied the findings instead.`;
696
+ }
697
+ },
698
+ });
699
+ invocation = spawned.result;
511
700
  }
512
701
  catch (error) {
513
702
  // Fail open: the spec is left as the reviewer last saw it and the
@@ -517,12 +706,42 @@ async function runSpecCritique(options) {
517
706
  `findings for ${options.workstream.id}: ${error.message}`);
518
707
  return { note: "(spec critique response not attempted — the author agent could not be spawned)" };
519
708
  }
709
+ // Step 5: the runner's own deterministic spec gate — artifact, scope, change.
710
+ const gate = await options.specGate.check({ before, snapshot: gateSnapshot });
711
+ // Step 6: journal the fix-now outcome, read *after* the gate so a
712
+ // restore is reflected. `kept` is the runner's own reading of the file —
713
+ // never inferred from the writer's prose.
714
+ const after = (await readFinishedSpec(options.root, options.workstream)) ?? "";
715
+ if (bindingIds.length > 0) {
716
+ const kept = gate.ok && after !== before;
717
+ const bindingSubjects = binding.map((finding) => finding.subject).join(", ");
718
+ const writerNote = resolveSummary(invocation.output).text;
719
+ const failureSuffix = gate.ok ? "the spec file was not changed" : (gate.failure ?? "the spec gate failed");
720
+ const note = kept
721
+ ? `(fix-now: ${bindingSubjects}) ${writerNote}`
722
+ : `(fix-now: ${bindingSubjects}) ${writerNote} (${failureSuffix})`;
723
+ const fixNowEvents = bindingIds.map((id) => ({
724
+ kind: "finding-fix-attempted",
725
+ at: options.now().toISOString(),
726
+ id,
727
+ outcome: kept ? "kept" : "failed",
728
+ note,
729
+ attemptedBy: "implementer",
730
+ }));
731
+ await timed(scope, { stage: "ledger-persist", workstream: options.workstream.id }, () => appendLedgerEvents(options.root, options.programId, fixNowEvents));
732
+ }
520
733
  return { note: resolveSummary(invocation.output).text };
521
734
  };
735
+ const budget = specCritiqueBudget(options.workstream.size);
522
736
  try {
523
- const outcome = await runReviewPass({ review, respond });
737
+ const outcome = await runReviewPass({
738
+ cycles: budget.cycles,
739
+ cycleNote: budget.note,
740
+ review,
741
+ respond,
742
+ });
524
743
  span.close({ outcome: "success" });
525
- return outcome;
744
+ return { outcome, ...(sessionFallbackNote ? { sessionFallbackNote } : {}) };
526
745
  }
527
746
  catch (error) {
528
747
  span.close({ outcome: "failed" });
@@ -572,24 +791,25 @@ function extractDependenciesDeclaration(output) {
572
791
  return { ids: json };
573
792
  }
574
793
  /**
575
- * The authoring stage: writes every missing workstream spec, one clean agent
576
- * at a time, in dependency-level order. Shaped like `runProgram`plain
577
- * inputs, injected boundaries, a structured result the CLI and `run` both
578
- * consume.
794
+ * Throws for exactly the reasons `authorProgram` throws today no author
795
+ * agent configured, a cyclic manifest, an unknown `--from` id and throws
796
+ * them before any agent spawns, so a caller that never reaches the scheduler
797
+ * fails identically to today.
579
798
  */
580
- export async function authorProgram(options) {
799
+ export async function prepareAuthoring(options) {
581
800
  const root = resolve(options.cwd);
582
801
  const config = options.config;
583
802
  const agentRunner = options.agentRunner ?? defaultAgentRunner;
584
803
  const permits = options.permits ?? NOOP_PERMITS_CONTEXT;
585
804
  const git = options.git ?? defaultGitOps;
586
- const log = options.log ?? ((line) => console.log(line));
805
+ const baseLog = options.log ?? ((line) => console.log(line));
806
+ const log = baseLog;
587
807
  const now = options.now ?? (() => new Date());
588
808
  const force = options.force === true;
589
809
  const reviewed = options.reviewed ?? new Set();
590
810
  const triaged = options.triaged ?? new Set();
591
811
  const recorder = options.recorder ?? NOOP_RUN_RECORDER;
592
- const manifest = await loadManifest(root, options.programId);
812
+ const manifest = options.manifest ?? (await loadManifest(root, options.programId));
593
813
  const resolvedAuthor = resolveAuthorAgent(config);
594
814
  if (!resolvedAuthor) {
595
815
  throw new Error("No author agent configured, and no implementer to fall back to. Set " +
@@ -612,6 +832,13 @@ export async function authorProgram(options) {
612
832
  .map((cycle) => cycle.join(" -> "))
613
833
  .join("; ")}. Re-plan with /plan-program.`);
614
834
  }
835
+ // Built once over the initial roster (ids never change; only individual
836
+ // `.dependencies` arrays mutate in place) and shared by every flow — see
837
+ // the manifest-object sharing rationale in the spec's §2. `rosterById`'s
838
+ // values are the same live `Workstream` objects `manifest.workstreams`
839
+ // holds, so a later mutation is visible through this map with no rebuild.
840
+ const rosterById = new Map(manifest.workstreams.map((w) => [w.id, w]));
841
+ const rosterIds = new Set(rosterById.keys());
615
842
  // The selected range (SC-05): computed once, up front, from the manifest
616
843
  // as loaded — before any agent spawns — so `--from` validation and the
617
844
  // selected set are deterministic, independent of dependency edges an
@@ -635,26 +862,182 @@ export async function authorProgram(options) {
635
862
  if (!isRepository) {
636
863
  log("warning: not a git repository — commits and decision anchors are unavailable");
637
864
  }
638
- const results = [];
639
- const processed = new Set();
640
865
  const blockedCone = new Set();
641
- while (processed.size < manifest.workstreams.length) {
642
- const remaining = manifest.workstreams.filter((w) => !processed.has(w.id));
643
- const levels = topologicalLevels(remaining);
644
- const next = levels[0]?.[0];
645
- if (!next)
646
- break;
647
- processed.add(next.id);
648
- results.push(await handle(next));
866
+ // The pipeline's run-scoped state (WS-05 §3.3): `dispatched` and
867
+ // `settledSet` are maintained by `authorWorkstream` itself, on entry and in
868
+ // its `finally`, rather than by a scheduler this module owns — WS-05's
869
+ // unified scheduler (or this module's own `authorProgram`, over
870
+ // `runReadySet`) decides *when* a workstream launches; readiness here only
871
+ // answers "has this dependency's spec finished", which `specFinal`/
872
+ // `specFinalSync` and the deferred re-author below still need. Ties no
873
+ // longer need breaking here — whichever scheduler owns dispatch order
874
+ // already breaks them against `manifest.workstreams`'s own array order.
875
+ const dispatched = new Set();
876
+ const settledSet = new Set();
877
+ const resultsById = new Map();
878
+ const settleDeferreds = new Map();
879
+ // Guards the merge critical section only (WS-05 §3.7): pushing a
880
+ // discovered dependency edge, saving the manifest, and checking for a
881
+ // cycle. No git operation ever runs while this is held, and this is never
882
+ // nested with `rootIndexMutex` or held across an `await` on it.
883
+ const graphMutex = createMutex();
884
+ // Guards every operation that takes `.git/index.lock` in the root
885
+ // checkout — this workstream's own spec commits, and (via
886
+ // `AuthorOptions.rootIndexMutex`) `runProgram`'s integration squashes and
887
+ // report commit (WS-05 §3.7). Injected by `runProgram` so both callers
888
+ // share one serializer; the standalone `author` CLI gets a private one,
889
+ // which costs nothing since nothing else commits there.
890
+ const rootIndexMutex = options.rootIndexMutex ?? createMutex();
891
+ const monotonicFn = options.monotonic ?? (() => performance.now());
892
+ const scopeFor = (id) => options.runSpan ? options.runSpan.scope({ workstream: id }) : recorder;
893
+ function settleDeferredFor(id) {
894
+ let deferred = settleDeferreds.get(id);
895
+ if (deferred === undefined) {
896
+ deferred = createDeferred();
897
+ settleDeferreds.set(id, deferred);
898
+ }
899
+ return deferred;
649
900
  }
650
- const complete = results.every((result) => result.outcome.status === "authored" || result.outcome.status === "kept");
651
- return {
652
- programId: options.programId,
653
- complete,
654
- results,
655
- borrowedImplementer,
656
- };
901
+ /**
902
+ * True when `id`'s spec is final: its authoring flow has settled *and*
903
+ * the spec now on disk classifies as `complete` or `legacy` — the same
904
+ * lifecycle test `classifySpec` already applies to a workstream's own
905
+ * spec, given no weaker a bar for a dependency (WS-03 §3.3). Settlement
906
+ * alone is not enough: a settled-but-parked dependency (a returned
907
+ * protocol failure or an unexpected throw) deliberately leaves
908
+ * `specStatus` at `in_progress` and can still have a non-empty draft file
909
+ * on disk, and a settled dependency that was kept out of selection or
910
+ * otherwise never authored has no file at all — both must classify as
911
+ * not-final, not merely "settled". This is the authoritative, async form
912
+ * used by the discovery path to decide the one re-author pass.
913
+ * `specFinalSync` below is this same test's cheaper half, valid only
914
+ * where the caller has already read the file itself and just needs to
915
+ * know whether that read may be trusted — the two must not drift apart,
916
+ * so both live here together.
917
+ */
918
+ async function specFinal(id) {
919
+ if (!settledSet.has(id))
920
+ return false;
921
+ const dep = rosterById.get(id);
922
+ if (!dep)
923
+ return false;
924
+ const spec = await readFinishedSpec(root, dep);
925
+ const classification = classifySpec(spec, dep);
926
+ return classification === "complete" || classification === "legacy";
927
+ }
928
+ /**
929
+ * The synchronous half of `specFinal`: settled and not `in_progress`,
930
+ * with no file read of its own. Safe only for `dependencySpecsSection`,
931
+ * which already read the file itself and gates on `raw !== undefined`
932
+ * before ever consulting this predicate — so the `missing` classification
933
+ * is already handled there and this only needs to catch `interrupted`.
934
+ */
935
+ function specFinalSync(id) {
936
+ if (!settledSet.has(id))
937
+ return false;
938
+ const dep = rosterById.get(id);
939
+ return dep !== undefined && dep.specStatus !== "in_progress";
940
+ }
941
+ /**
942
+ * WS-05 §3.3: wraps WS-03's `handle` in the dispatch/settle bookkeeping
943
+ * the deferred re-author (below, inside `handle`) still reads —
944
+ * `dispatched`/`settledSet` mark this workstream the instant *this*
945
+ * function is entered, whichever scheduler called it, and the settle
946
+ * deferred always resolves in a `finally` so a sibling awaiting it (or a
947
+ * drain after an error) can never hang. A caller is expected to invoke
948
+ * this exactly once per workstream in the roster.
949
+ */
950
+ async function authorWorkstream(workstream) {
951
+ dispatched.add(workstream.id);
952
+ try {
953
+ const result = await handle(workstream);
954
+ resultsById.set(workstream.id, result);
955
+ return result;
956
+ }
957
+ catch (error) {
958
+ // A throw (a discovered edge creating a cycle, or anything else
959
+ // unexpected) is not swallowed here — it propagates to the caller's
960
+ // own scheduler, which drains every other in-flight flow before
961
+ // rethrowing (WS-04's `runReadySet`). Logged here because this is the
962
+ // one place that still knows which siblings were in flight when it
963
+ // happened.
964
+ const siblings = [...dispatched].filter((id) => !settledSet.has(id) && id !== workstream.id);
965
+ schedulerLog(baseLog)(`authoring stopped after an error in ${workstream.id}: ${error.message}` +
966
+ (siblings.length > 0
967
+ ? `; draining in-flight workstream(s): ${siblings.join(", ")}`
968
+ : ""));
969
+ throw error;
970
+ }
971
+ finally {
972
+ // Resolved in a `finally` on every path, including a throw: a settle
973
+ // deferred left pending would hang every sibling waiting on it and
974
+ // hang the drain above — the one failure mode that produces a run
975
+ // which never terminates.
976
+ settledSet.add(workstream.id);
977
+ settleDeferredFor(workstream.id).resolve();
978
+ }
979
+ }
980
+ function haltAuthorWorkstream(workstream, reason) {
981
+ dispatched.add(workstream.id);
982
+ const result = {
983
+ id: workstream.id,
984
+ name: workstream.name,
985
+ outcome: { status: "parked", reason },
986
+ decisionIds: [],
987
+ decisionErrors: [],
988
+ mergedDependencies: [],
989
+ unknownDependencyIds: [],
990
+ demotedDependencies: [],
991
+ reauthored: false,
992
+ };
993
+ resultsById.set(workstream.id, result);
994
+ settledSet.add(workstream.id);
995
+ settleDeferredFor(workstream.id).resolve();
996
+ return result;
997
+ }
998
+ /**
999
+ * `results` is a contract (rendered in the report, pinned by tests): it is
1000
+ * ordered by the final dependency graph, never by completion or dispatch
1001
+ * order, both of which are timing-dependent above `cap: 1` (WS-05 §3.9).
1002
+ * `stableTopologicalOrder` is a pure function of the manifest with no
1003
+ * timing input at all, and it cannot throw here (the graph is acyclic) —
1004
+ * the fallback exists only so a surprising order beats a crashed run that
1005
+ * authored everything.
1006
+ */
1007
+ function finish() {
1008
+ let orderedIds;
1009
+ try {
1010
+ orderedIds = stableTopologicalOrder(manifest.workstreams).map((w) => w.id);
1011
+ }
1012
+ catch {
1013
+ orderedIds = [...resultsById.keys()];
1014
+ }
1015
+ const results = [];
1016
+ for (const id of orderedIds) {
1017
+ const result = resultsById.get(id);
1018
+ if (result === undefined) {
1019
+ // Unreachable when every workstream in the roster went through
1020
+ // `authorWorkstream` exactly once, which every caller of this
1021
+ // pipeline is required to do (WS-04's `runReadySet` settles every
1022
+ // dispatched item before it lets the caller proceed) — filtered
1023
+ // defensively anyway per SC-12's fail-open rule.
1024
+ schedulerLog(baseLog)(`warning: ${id} produced no author result — omitted from the report`);
1025
+ continue;
1026
+ }
1027
+ results.push(result);
1028
+ }
1029
+ const complete = results.every((result) => result.outcome.status === "authored" || result.outcome.status === "kept");
1030
+ return {
1031
+ programId: options.programId,
1032
+ complete,
1033
+ results,
1034
+ borrowedImplementer,
1035
+ };
1036
+ }
1037
+ return { borrowedImplementer, manifest, authorWorkstream, haltAuthorWorkstream, finish };
657
1038
  async function handle(workstream) {
1039
+ const log = workstreamLog(baseLog, workstream.id);
1040
+ const scope = scopeFor(workstream.id);
658
1041
  const base = {
659
1042
  id: workstream.id,
660
1043
  name: workstream.name,
@@ -668,7 +1051,7 @@ export async function authorProgram(options) {
668
1051
  };
669
1052
  if (blockedCone.has(workstream.id)) {
670
1053
  workstream.status = "parked";
671
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1054
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
672
1055
  base.outcome = {
673
1056
  status: "parked",
674
1057
  reason: "an upstream dependency's authoring failed; parked, not attempted",
@@ -695,9 +1078,8 @@ export async function authorProgram(options) {
695
1078
  workstream.specHash !== undefined &&
696
1079
  specInputsHash(manifest, workstream) === workstream.specHash) {
697
1080
  workstream.specStatus = "complete";
698
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
699
- log(`${workstream.id}: recognized as completed legacy authoring — ` +
700
- 'normalized to specStatus "complete"');
1081
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1082
+ log('recognized as completed legacy authoring — normalized to specStatus "complete"');
701
1083
  return base;
702
1084
  }
703
1085
  // Drift check: the spec exists and is about to be graded against, but
@@ -712,12 +1094,12 @@ export async function authorProgram(options) {
712
1094
  const current = specInputsHash(manifest, workstream);
713
1095
  if (current !== workstream.specHash) {
714
1096
  workstream.status = "parked";
715
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1097
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
716
1098
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
717
1099
  blockedCone.add(id);
718
1100
  }
719
- log(`${workstream.id}: the manifest entry drifted since its spec was ` +
720
- "authored — parked instead of building against stale spec text");
1101
+ log("the manifest entry drifted since its spec was authored — parked " +
1102
+ "instead of building against stale spec text");
721
1103
  base.outcome = {
722
1104
  status: "parked",
723
1105
  reason: `the manifest entry (name, scope, dependencies, or the program's ` +
@@ -730,9 +1112,7 @@ export async function authorProgram(options) {
730
1112
  }
731
1113
  return base;
732
1114
  }
733
- log(`${workstream.id} ${workstream.name}: authoring`);
734
- const rosterById = new Map(manifest.workstreams.map((w) => [w.id, w]));
735
- const rosterIds = new Set(rosterById.keys());
1115
+ log(`${workstream.name}: authoring`);
736
1116
  const baseCommit = isRepository ? await git.currentCommit(root) : undefined;
737
1117
  // Keyed by fingerprint, last-wins: a re-author pass can re-emit a
738
1118
  // decision with the same id but revised context/options/chosen (the
@@ -740,20 +1120,24 @@ export async function authorProgram(options) {
740
1120
  // workstream's current truth. `.set()` on each push naturally keeps the
741
1121
  // latest occurrence rather than the first.
742
1122
  const allDecisionsById = new Map();
743
- const first = await authorBrief(root, manifest, workstream, rosterById);
1123
+ // WS-08: last-wins across the author and (optional) re-author spawns
1124
+ // whichever pass most recently reported a session id is the one the
1125
+ // spec-critique writer resumes.
1126
+ let authorSession;
1127
+ const first = await authorBrief(root, manifest, workstream, rosterById, undefined, specFinalSync);
744
1128
  base.demotedDependencies = first.demoted;
745
1129
  for (const point of promptComponentSizePoints(first.components, { role: "authorAgent" })) {
746
- recorder.point(point);
1130
+ scope.point(point);
747
1131
  }
748
1132
  const firstDemotedPoint = demotedDependenciesPoint(first.demoted, first.demotedBytes, { role: "authorAgent" });
749
1133
  if (firstDemotedPoint)
750
- recorder.point(firstDemotedPoint);
1134
+ scope.point(firstDemotedPoint);
751
1135
  // Stamped before the agent can write anything (SC-01): the agent has
752
1136
  // whole-file write access, so a crash at any point after this line
753
1137
  // leaves a durable `in_progress` marker the next attempt classifies as
754
1138
  // interrupted, regardless of whether a partial file exists.
755
1139
  workstream.specStatus = "in_progress";
756
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1140
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
757
1141
  let invocation;
758
1142
  try {
759
1143
  invocation = await invokeAgent(agentRunner, author, first.brief, root, permits, "authorAgent", {
@@ -761,7 +1145,9 @@ export async function authorProgram(options) {
761
1145
  programId: options.programId,
762
1146
  label: `${workstream.id}-author`,
763
1147
  log,
764
- }, recorder, { stage: "author", workstream: workstream.id });
1148
+ }, scope, { stage: "author", workstream: workstream.id });
1149
+ if (invocation.session)
1150
+ authorSession = invocation.session;
765
1151
  }
766
1152
  catch (error) {
767
1153
  // A spawn error here means the author agent never started — the same
@@ -773,7 +1159,7 @@ export async function authorProgram(options) {
773
1159
  // stays `in_progress` — never cleared on a failure path — so a later
774
1160
  // retry resumes without needing --force (SC-07).
775
1161
  workstream.status = "parked";
776
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1162
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
777
1163
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
778
1164
  blockedCone.add(id);
779
1165
  }
@@ -790,7 +1176,7 @@ export async function authorProgram(options) {
790
1176
  // Park exactly like a spawn failure and leave `specStatus` at
791
1177
  // `in_progress` so a force-free retry re-authors.
792
1178
  workstream.status = "parked";
793
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1179
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
794
1180
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
795
1181
  blockedCone.add(id);
796
1182
  }
@@ -816,36 +1202,64 @@ export async function authorProgram(options) {
816
1202
  base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
817
1203
  const declaration = extractDependenciesDeclaration(invocation.output);
818
1204
  if (declaration.error)
819
- log(`${workstream.id}: ${declaration.error}`);
820
- const merge = mergeDependencies(workstream, rosterIds, declaration.ids);
1205
+ log(declaration.error);
1206
+ // The merge critical section (§3.3): pushing the discovered edges,
1207
+ // saving the manifest, and checking for a cycle run as one serialised
1208
+ // step through the graph mutex, so two concurrent merges cannot jointly
1209
+ // create a cycle. No git operation ever runs while this is held (WS-05
1210
+ // §3.7) — the commit below is a separate, later critical section on a
1211
+ // separate mutex. Nothing here calls `log` — the re-entrancy discipline
1212
+ // WS-01 applies inside its own write queue applies here too; the two
1213
+ // messages below are collected and emitted after the section returns.
1214
+ const merge = await graphMutex.runExclusive(async () => {
1215
+ const result = mergeDependencies(workstream, rosterIds, declaration.ids);
1216
+ if (result.merged.length > 0) {
1217
+ const originalDependencies = workstream.dependencies.filter((id) => !result.merged.includes(id));
1218
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1219
+ const cyclesNow = findCycles(manifest.workstreams);
1220
+ if (cyclesNow.length > 0) {
1221
+ workstream.dependencies = originalDependencies;
1222
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1223
+ throw new Error(`Dependency cycle(s) created by a discovered edge from ${workstream.id}: ` +
1224
+ `${cyclesNow.map((cycle) => cycle.join(" -> ")).join("; ")}. Re-plan with /plan-program.`);
1225
+ }
1226
+ }
1227
+ return result;
1228
+ });
821
1229
  base.unknownDependencyIds = merge.unknown;
822
1230
  if (merge.unknown.length > 0) {
823
- log(`${workstream.id}: unknown dependency id(s) declared, ignored: ${merge.unknown.join(", ")}`);
1231
+ log(`unknown dependency id(s) declared, ignored: ${merge.unknown.join(", ")}`);
824
1232
  }
825
1233
  if (merge.merged.length > 0) {
826
- const originalDependencies = workstream.dependencies.filter((id) => !merge.merged.includes(id));
827
1234
  base.mergedDependencies = merge.merged;
828
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
829
- log(`${workstream.id}: merged new dependency edge(s): ${merge.merged.join(", ")}`);
830
- const cyclesNow = findCycles(manifest.workstreams);
831
- if (cyclesNow.length > 0) {
832
- workstream.dependencies = originalDependencies;
833
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
834
- throw new Error(`Dependency cycle(s) created by a discovered edge from ${workstream.id}: ` +
835
- `${cyclesNow.map((cycle) => cycle.join(" -> ")).join("; ")}. Re-plan with /plan-program.`);
836
- }
1235
+ log(`merged new dependency edge(s): ${merge.merged.join(", ")}`);
1236
+ // Classify each merged dependency (§3.3): a sibling still in flight is
1237
+ // awaited on its settle deferred — never on a not-yet-dispatched one,
1238
+ // which would risk a deadlock at `cap: 1` — and only a spec that
1239
+ // classifies `complete` or `legacy` after settlement triggers the one
1240
+ // re-author pass. A settled-but-parked sibling (still `in_progress`),
1241
+ // a settled-but-missing sibling (never authored, e.g. an
1242
+ // out-of-selection keep), and one that was never dispatched at all,
1243
+ // all resolve to `false` through the same `specFinal` check.
837
1244
  const needsReauthor = [];
838
1245
  for (const id of merge.merged) {
839
1246
  const dep = rosterById.get(id);
840
1247
  if (!dep)
841
1248
  continue;
842
- if ((await readFinishedSpec(root, dep)) !== undefined)
1249
+ if (dispatched.has(id) && !settledSet.has(id)) {
1250
+ const waitStart = monotonicFn();
1251
+ await settleDeferredFor(id).promise;
1252
+ recordWorkstreamWaiting(recorder, workstream.id, {
1253
+ cause: "dependency",
1254
+ waitedMs: monotonicFn() - waitStart,
1255
+ });
1256
+ }
1257
+ if (await specFinal(id))
843
1258
  needsReauthor.push(id);
844
1259
  }
845
1260
  if (needsReauthor.length > 0) {
846
1261
  base.reauthored = true;
847
- log(`${workstream.id}: re-authoring once with newly discovered dependency spec(s): ` +
848
- needsReauthor.join(", "));
1262
+ log(`re-authoring once with newly discovered dependency spec(s): ${needsReauthor.join(", ")}`);
849
1263
  const note = [
850
1264
  "## You are being re-authored",
851
1265
  "",
@@ -856,19 +1270,21 @@ export async function authorProgram(options) {
856
1270
  "re-author pass.",
857
1271
  "",
858
1272
  ].join("\n");
859
- const second = await authorBrief(root, manifest, workstream, rosterById, note);
1273
+ const second = await authorBrief(root, manifest, workstream, rosterById, note, specFinalSync);
860
1274
  base.demotedDependencies = second.demoted;
861
1275
  for (const point of promptComponentSizePoints(second.components, { role: "authorAgent" })) {
862
- recorder.point(point);
1276
+ scope.point(point);
863
1277
  }
864
1278
  const secondDemotedPoint = demotedDependenciesPoint(second.demoted, second.demotedBytes, {
865
1279
  role: "authorAgent",
866
1280
  });
867
1281
  if (secondDemotedPoint)
868
- recorder.point(secondDemotedPoint);
1282
+ scope.point(secondDemotedPoint);
869
1283
  let reauthorInvocation;
870
1284
  try {
871
- reauthorInvocation = await invokeAgent(agentRunner, author, second.brief, root, permits, "authorAgent", { root, programId: options.programId, label: `${workstream.id}-author-reauthor`, log }, recorder, { stage: "re-author", workstream: workstream.id });
1285
+ reauthorInvocation = await invokeAgent(agentRunner, author, second.brief, root, permits, "authorAgent", { root, programId: options.programId, label: `${workstream.id}-author-reauthor`, log }, scope, { stage: "re-author", workstream: workstream.id });
1286
+ if (reauthorInvocation.session)
1287
+ authorSession = reauthorInvocation.session;
872
1288
  }
873
1289
  catch (error) {
874
1290
  // Fail open: the first pass already wrote a spec and journaled
@@ -890,7 +1306,7 @@ export async function authorProgram(options) {
890
1306
  // otherwise untouched; a returned failure carries no such
891
1307
  // guarantee.
892
1308
  workstream.status = "parked";
893
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1309
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
894
1310
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
895
1311
  blockedCone.add(id);
896
1312
  }
@@ -919,8 +1335,8 @@ export async function authorProgram(options) {
919
1335
  base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
920
1336
  const secondDeclaration = extractDependenciesDeclaration(invocation.output);
921
1337
  if (secondDeclaration.ids.length > 0) {
922
- log(`${workstream.id}: additional dependency declaration after the ` +
923
- `re-author pass was not honored: ${secondDeclaration.ids.join(", ")}`);
1338
+ log(`additional dependency declaration after the re-author pass was not ` +
1339
+ `honored: ${secondDeclaration.ids.join(", ")}`);
924
1340
  }
925
1341
  }
926
1342
  }
@@ -929,7 +1345,7 @@ export async function authorProgram(options) {
929
1345
  const final = await readFinishedSpec(root, workstream);
930
1346
  if (final === undefined) {
931
1347
  workstream.status = "parked";
932
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1348
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
933
1349
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
934
1350
  blockedCone.add(id);
935
1351
  }
@@ -953,24 +1369,32 @@ export async function authorProgram(options) {
953
1369
  agentRunner,
954
1370
  permits,
955
1371
  author,
1372
+ deciderConfigured: decider !== undefined,
1373
+ baseCommit,
1374
+ now,
956
1375
  log,
957
1376
  pushStageError: (message) => (base.stageErrors ??= []).push(message),
958
- recorder,
1377
+ recorder: scope,
1378
+ triage: (events, triageBaseCommit) => reviewWorkstreamFindings(workstream.id, events, triageBaseCommit),
1379
+ specGate: createSpecGate({
1380
+ root,
1381
+ git,
1382
+ isRepository,
1383
+ workstream,
1384
+ programId: options.programId,
1385
+ log,
1386
+ pushStageError: (message) => (base.stageErrors ??= []).push(message),
1387
+ }),
1388
+ ...(authorSession ? { authorSession } : {}),
959
1389
  });
960
- base.specCritique = critique;
1390
+ base.specCritique = critique.outcome;
1391
+ if (critique.sessionFallbackNote)
1392
+ (base.notes ??= []).push(critique.sessionFallbackNote);
961
1393
  const postCritique = await readFinishedSpec(root, workstream);
962
1394
  if (postCritique === undefined) {
963
1395
  await writeFile(join(root, workstream.taskFile), final, "utf8");
964
- log(`${workstream.id}: a critique round left the spec empty or missing; ` +
965
- "the pre-critique spec was restored");
1396
+ log("a critique round left the spec empty or missing; the pre-critique spec was restored");
966
1397
  }
967
- const critiqueEvents = findingsToLedgerEvents({
968
- workstreamId: workstream.id,
969
- findings: critique.open.filter(hasRoutableEvidence),
970
- ...(baseCommit === undefined ? {} : { baseCommit }),
971
- now,
972
- });
973
- await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, critiqueEvents));
974
1398
  // Stamped after every dependency merge above, so the hash records the
975
1399
  // manifest exactly as this spec's author last saw it — a later manifest
976
1400
  // edit is drift; authoring's own merges are not. `specStatus` is
@@ -980,10 +1404,18 @@ export async function authorProgram(options) {
980
1404
  // must still classify as interrupted on retry, not falsely complete with
981
1405
  // an unmade commit.
982
1406
  workstream.specHash = specInputsHash(manifest, workstream);
983
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1407
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1408
+ // Serialised through `rootIndexMutex` (WS-05 §3.7): concurrent authors
1409
+ // — and, once integration can run at the same time, WS-02's squash —
1410
+ // share one git index, and `git commit` has no queue of its own; two
1411
+ // commits at the same instant fail on `index.lock` rather than
1412
+ // serialising. Also routed through the manifest/ledger write queue,
1413
+ // keyed the same as `saveManifest`/`appendLedgerEvents`: this commit's
1414
+ // `git add` stages `docs/programs`, which a concurrent ledger append
1415
+ // could otherwise be caught mid-write.
984
1416
  let commit;
985
1417
  if (isRepository) {
986
- commit = await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitPaths(root, `nightshift(${options.programId}): author ${workstream.id} ${workstream.name}`, [workstream.taskFile, "docs/programs"]), commitEvidence);
1418
+ commit = await rootIndexMutex.runExclusive(() => enqueueProgramWrite(programWriteKey(root, options.programId), () => timed(scope, { stage: "git-commit", workstream: workstream.id }, () => git.commitPaths(root, `nightshift(${options.programId}): author ${workstream.id} ${workstream.name}`, [workstream.taskFile, "docs/programs"]), commitEvidence)));
987
1419
  }
988
1420
  // Outside a git repository there is no commit to wait for — the finished
989
1421
  // spec, its specHash, and this manifest save are already the durable
@@ -991,24 +1423,17 @@ export async function authorProgram(options) {
991
1423
  // only runs after `commitPaths` above resolves, so the transition is
992
1424
  // recoverable on both sides of the commit (SC-01, SC-02).
993
1425
  workstream.specStatus = "complete";
994
- await timed(recorder, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
1426
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
995
1427
  {
996
1428
  const spawnErrors = await reviewWorkstreamDecisions(workstream.id, [...allDecisionsById.values()], baseCommit);
997
1429
  if (spawnErrors.length > 0)
998
1430
  (base.stageErrors ??= []).push(...spawnErrors);
999
1431
  }
1000
- // Findings triage against the same pre-workstream diff base the decision
1001
- // review used above — a spec-critique finding has no separate green
1002
- // anchor the way a test-critique fix does.
1003
- {
1004
- const spawnErrors = await reviewWorkstreamFindings(workstream.id, critiqueEvents, baseCommit);
1005
- if (spawnErrors.length > 0)
1006
- (base.stageErrors ??= []).push(...spawnErrors);
1007
- }
1008
1432
  base.outcome = { status: "authored", ...(commit === undefined ? {} : { commit }) };
1009
1433
  return base;
1010
1434
  }
1011
1435
  async function journalDecisions(workstream, decisions, baseCommit) {
1436
+ const log = workstreamLog(baseLog, workstream.id);
1012
1437
  const events = decisions.map((decision) => ({
1013
1438
  kind: "decision-recorded",
1014
1439
  at: now().toISOString(),
@@ -1018,12 +1443,13 @@ export async function authorProgram(options) {
1018
1443
  ...(baseCommit === undefined ? {} : { baseCommit }),
1019
1444
  decidedBy: "implementer",
1020
1445
  }));
1021
- await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
1446
+ await timed(scopeFor(workstream.id), { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
1022
1447
  for (const decision of decisions) {
1023
- log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
1448
+ log(`decision: ${decision.title} -> ${decision.chosen}`);
1024
1449
  }
1025
1450
  }
1026
1451
  function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
1452
+ const log = workstreamLog(baseLog, workstreamId);
1027
1453
  return reviewDecisions({
1028
1454
  root,
1029
1455
  programId: options.programId,
@@ -1041,10 +1467,11 @@ export async function authorProgram(options) {
1041
1467
  now,
1042
1468
  log,
1043
1469
  observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1044
- recorder,
1470
+ recorder: scopeFor(workstreamId),
1045
1471
  });
1046
1472
  }
1047
1473
  function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
1474
+ const log = workstreamLog(baseLog, workstreamId);
1048
1475
  return triageFindings({
1049
1476
  root,
1050
1477
  programId: options.programId,
@@ -1062,8 +1489,41 @@ export async function authorProgram(options) {
1062
1489
  now,
1063
1490
  log,
1064
1491
  observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1065
- recorder,
1492
+ recorder: scopeFor(workstreamId),
1066
1493
  });
1067
1494
  }
1068
1495
  }
1496
+ /**
1497
+ * The authoring stage, standalone: writes every missing workstream spec, one
1498
+ * clean agent at a time, in dependency order, at `--max-parallel`'s cap —
1499
+ * `runReadySet` over `manifest.workstreams` directly, the same scheduler
1500
+ * `runProgram`'s unified pipeline (WS-05 §3.1) drives, just with only one
1501
+ * kind of item. `--max-parallel 1` reproduces the pre-WS-03 serial behaviour
1502
+ * exactly, including dispatch order and the absence of any deferred
1503
+ * re-author. This module no longer contains a hand-written scheduler of its
1504
+ * own (WS-04 §8, WS-05 §3.3): one implementation, two callers.
1505
+ */
1506
+ export async function authorProgram(options) {
1507
+ const pipeline = await prepareAuthoring(options);
1508
+ const baseLog = options.log ?? ((line) => console.log(line));
1509
+ const monotonicFn = options.monotonic ?? (() => performance.now());
1510
+ const cap = normalizeMaxParallel(options.maxParallel);
1511
+ schedulerLog(baseLog)(`authoring ${pipeline.manifest.workstreams.length} workstream(s)` +
1512
+ (cap === Infinity ? "" : ` (cap: ${cap})`));
1513
+ await runReadySet({
1514
+ items: pipeline.manifest.workstreams,
1515
+ cap,
1516
+ monotonic: monotonicFn,
1517
+ classify: () => "launch",
1518
+ launch: (workstream) => pipeline.authorWorkstream(workstream).then(() => undefined),
1519
+ // Genuinely unreachable on the standalone path: an environmental halt is
1520
+ // a property of a *run*, and this stage has no build phase to produce
1521
+ // one. The unified scheduler's author branch does classify "settle" on a
1522
+ // halt (WS-05 §3.1, §3.6); this one never can.
1523
+ settle: () => {
1524
+ throw new Error("unreachable: authoring items never classify settle");
1525
+ },
1526
+ });
1527
+ return pipeline.finish();
1528
+ }
1069
1529
  //# sourceMappingURL=author.js.map