@wildorder/nightshift 0.16.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 (109) hide show
  1. package/README.md +289 -1
  2. package/dist/agent-runner.d.ts +95 -5
  3. package/dist/agent-runner.d.ts.map +1 -1
  4. package/dist/agent-runner.js +442 -32
  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 +118 -4
  15. package/dist/author.d.ts.map +1 -1
  16. package/dist/author.js +650 -163
  17. package/dist/author.js.map +1 -1
  18. package/dist/causal-analysis.d.ts +216 -0
  19. package/dist/causal-analysis.d.ts.map +1 -0
  20. package/dist/causal-analysis.js +786 -0
  21. package/dist/causal-analysis.js.map +1 -0
  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 +13 -1
  29. package/dist/decider-review.d.ts.map +1 -1
  30. package/dist/decider-review.js +21 -7
  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 +8 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +8 -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 +3 -0
  53. package/dist/preflight.d.ts.map +1 -1
  54. package/dist/preflight.js +73 -57
  55. package/dist/preflight.js.map +1 -1
  56. package/dist/prompt-telemetry.d.ts +64 -0
  57. package/dist/prompt-telemetry.d.ts.map +1 -0
  58. package/dist/prompt-telemetry.js +112 -0
  59. package/dist/prompt-telemetry.js.map +1 -0
  60. package/dist/provider-telemetry.d.ts +106 -0
  61. package/dist/provider-telemetry.d.ts.map +1 -0
  62. package/dist/provider-telemetry.js +423 -0
  63. package/dist/provider-telemetry.js.map +1 -0
  64. package/dist/review-pass.d.ts +52 -10
  65. package/dist/review-pass.d.ts.map +1 -1
  66. package/dist/review-pass.js +114 -30
  67. package/dist/review-pass.js.map +1 -1
  68. package/dist/run-analytics-report.d.ts +286 -0
  69. package/dist/run-analytics-report.d.ts.map +1 -0
  70. package/dist/run-analytics-report.js +1242 -0
  71. package/dist/run-analytics-report.js.map +1 -0
  72. package/dist/run-analytics.d.ts +844 -0
  73. package/dist/run-analytics.d.ts.map +1 -0
  74. package/dist/run-analytics.js +777 -0
  75. package/dist/run-analytics.js.map +1 -0
  76. package/dist/run-log.d.ts +16 -0
  77. package/dist/run-log.d.ts.map +1 -0
  78. package/dist/run-log.js +32 -0
  79. package/dist/run-log.js.map +1 -0
  80. package/dist/run-program.d.ts +268 -4
  81. package/dist/run-program.d.ts.map +1 -1
  82. package/dist/run-program.js +2508 -762
  83. package/dist/run-program.js.map +1 -1
  84. package/dist/scheduler.d.ts +75 -0
  85. package/dist/scheduler.d.ts.map +1 -0
  86. package/dist/scheduler.js +126 -0
  87. package/dist/scheduler.js.map +1 -0
  88. package/dist/verify-cache.d.ts +64 -0
  89. package/dist/verify-cache.d.ts.map +1 -0
  90. package/dist/verify-cache.js +56 -0
  91. package/dist/verify-cache.js.map +1 -0
  92. package/dist/whole-program-review.d.ts +3 -0
  93. package/dist/whole-program-review.d.ts.map +1 -1
  94. package/dist/whole-program-review.js +8 -1
  95. package/dist/whole-program-review.js.map +1 -1
  96. package/dist/worktree.d.ts +124 -0
  97. package/dist/worktree.d.ts.map +1 -0
  98. package/dist/worktree.js +397 -0
  99. package/dist/worktree.js.map +1 -0
  100. package/dist/write-queue.d.ts +25 -0
  101. package/dist/write-queue.d.ts.map +1 -0
  102. package/dist/write-queue.js +52 -0
  103. package/dist/write-queue.js.map +1 -0
  104. package/package.json +2 -2
  105. package/skills/plan-program/SKILL.md +37 -5
  106. package/dist/worktree-guard.d.ts +0 -40
  107. package/dist/worktree-guard.d.ts.map +0 -1
  108. package/dist/worktree-guard.js +0 -91
  109. package/dist/worktree-guard.js.map +0 -1
package/dist/author.js CHANGED
@@ -1,15 +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";
12
- import { defaultGitOps, downstreamCone } from "./run-program.js";
13
+ import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, reviewerAbsentOutcome, runReviewPass, specCritiqueBudget, verifyEvidence, } from "./review-pass.js";
14
+ import { createBriefBuilder, demotedDependenciesPoint, promptComponentSizePoints, } from "./prompt-telemetry.js";
15
+ import { commitEvidence, defaultGitOps, downstreamCone, timed } from "./run-program.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";
13
21
  /**
14
22
  * The authoring stage: for every workstream whose spec does not exist, a
15
23
  * clean agent writes it — human-first, grounded in the repository,
@@ -221,22 +229,31 @@ function prerequisitesSection(manifest, workstream) {
221
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.", "");
222
230
  return lines;
223
231
  }
224
- async function dependencySpecsSection(root, workstream, rosterById) {
232
+ async function dependencySpecsSection(root, workstream, rosterById, specFinal) {
225
233
  if (workstream.dependencies.length === 0) {
226
234
  return {
227
235
  section: ["## Dependency specs", "", "No dependencies declared.", ""],
228
236
  demoted: [],
237
+ demotedBytes: 0,
229
238
  };
230
239
  }
231
240
  const entries = await Promise.all(workstream.dependencies
232
241
  .map((id) => rosterById.get(id))
233
242
  .filter((entry) => entry !== undefined)
234
- .map(async (entry) => ({
235
- entry,
236
- content: await readFinishedSpec(root, entry),
237
- })));
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
+ }));
238
254
  let total = entries.reduce((sum, e) => sum + (e.content?.length ?? 0), 0);
239
255
  const demoted = new Set();
256
+ let demotedBytes = 0;
240
257
  const bySizeDesc = entries
241
258
  .filter((e) => e.content !== undefined)
242
259
  .sort((a, b) => (b.content?.length ?? 0) - (a.content?.length ?? 0));
@@ -245,6 +262,7 @@ async function dependencySpecsSection(root, workstream, rosterById) {
245
262
  break;
246
263
  demoted.add(e.entry.id);
247
264
  total -= e.content?.length ?? 0;
265
+ demotedBytes += e.content === undefined ? 0 : Buffer.byteLength(e.content, "utf8");
248
266
  }
249
267
  const lines = ["## Dependency specs", ""];
250
268
  for (const { entry, content } of entries) {
@@ -262,7 +280,7 @@ async function dependencySpecsSection(root, workstream, rosterById) {
262
280
  lines.push(`### ${entry.id} ${entry.name}`, "", content.trim(), "");
263
281
  }
264
282
  lines.push("");
265
- return { section: lines, demoted: [...demoted] };
283
+ return { section: lines, demoted: [...demoted], demotedBytes };
266
284
  }
267
285
  const SPEC_CONTRACT = [
268
286
  "## Write a spec for a human",
@@ -307,42 +325,50 @@ block entirely when you have nothing to add.
307
325
  function dependenciesContract() {
308
326
  return DEPENDENCIES_CONTRACT;
309
327
  }
310
- async function authorBrief(root, manifest, workstream, rosterById, reauthorNote) {
328
+ /**
329
+ * Builds the author's brief, byte-for-byte identical to the plain
330
+ * `array.join("\n")` this produced before WS-03 — but through a
331
+ * `BriefBuilder` so every line is also classified into an SC-07 prompt
332
+ * component, with `components` summing exactly to the joined brief's byte
333
+ * length.
334
+ */
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) {
311
350
  const programDoc = await programDocumentSection(root, manifest);
312
- const deps = await dependencySpecsSection(root, workstream, rosterById);
313
- const brief = [
314
- `# Workstream ${workstream.id}: ${workstream.name}`,
315
- "",
316
- `Program: ${manifest.program.id}${manifest.program.name}`,
317
- "",
318
- "Write this workstream's specification document — the narrative file a",
319
- "human and a later implementing agent will both read to build it.",
320
- "",
321
- ...programDoc,
322
- ...successCriteriaSection(manifest),
323
- ...scopeSection(workstream),
324
- ...rosterSection(manifest),
325
- ...prerequisitesSection(manifest, workstream),
326
- ...deps.section,
327
- ...(reauthorNote ? [reauthorNote, ""] : []),
328
- SPEC_CONTRACT,
329
- GROUNDING_CONTRACT,
330
- dependenciesContract(),
331
- "",
332
- "## Rules",
333
- "",
334
- "- Write exactly one file: this workstream's taskFile (create parent",
335
- " directories as needed). Do not write or edit any other file.",
336
- "- Your reply is a separate channel from that file. The decision and",
337
- " summary blocks described below go in your reply, never inside the",
338
- " taskFile — the runner reads them from what you say, not what you wrote.",
339
- "- Never commit; the runner owns commits.",
340
- "",
341
- decisionContract(),
342
- "",
343
- summaryContract(),
344
- ].join("\n");
345
- return { brief, demoted: deps.demoted };
351
+ const deps = await dependencySpecsSection(root, workstream, rosterById, specFinal);
352
+ const builder = createBriefBuilder();
353
+ builder.push("framing", `# Workstream ${workstream.id}: ${workstream.name}`, "");
354
+ builder.push("program-narrative", `Program: ${manifest.program.id} — ${manifest.program.name}`);
355
+ builder.push("framing", "", "Write this workstream's specification document the narrative file a", "human and a later implementing agent will both read to build it.", "");
356
+ builder.push("program-narrative", ...programDoc);
357
+ builder.push("success-criteria", ...successCriteriaSection(manifest));
358
+ builder.push("scope", ...scopeSection(workstream));
359
+ builder.push("roster", ...rosterSection(manifest));
360
+ builder.push("prerequisites", ...prerequisitesSection(manifest, workstream));
361
+ builder.push("dependency-specs", ...deps.section);
362
+ builder.push("re-author-note", ...(reauthorNote ? [reauthorNote, ""] : []));
363
+ builder.push("runner-instructions", SPEC_CONTRACT, GROUNDING_CONTRACT, dependenciesContract());
364
+ builder.push("framing", "", "## Rules", "", "- Write exactly one file: this workstream's taskFile (create parent", " directories as needed). Do not write or edit any other file.", "- Your reply is a separate channel from that file. The decision and", " summary blocks described below go in your reply, never inside the", " taskFile — the runner reads them from what you say, not what you wrote.", "- Never commit; the runner owns commits.", "");
365
+ builder.push("runner-instructions", decisionContract(), "", summaryContract());
366
+ return {
367
+ brief: builder.join(),
368
+ demoted: deps.demoted,
369
+ demotedBytes: deps.demotedBytes,
370
+ components: builder.components(),
371
+ };
346
372
  }
347
373
  const SPEC_CRITIQUE_FRAMING = [
348
374
  "## Critique this spec",
@@ -416,36 +442,76 @@ function specCritiqueReviewerBrief(root, manifest, workstream, rosterById, spec,
416
442
  ].join("\n");
417
443
  })();
418
444
  }
419
- function specCritiqueWriterBrief(workstream, spec, findings) {
420
- const findingsList = findings.length === 0
421
- ? ["(no findings — this should not happen; treat as a clean read.)"]
422
- : findings.map((finding) => {
423
- const evidence = finding.evidence
424
- .map((entry) => {
425
- if (entry.kind === "location") {
426
- return `${entry.file}:${entry.startLine}${entry.excerpt ? ` — ${entry.excerpt}` : ""}`;
427
- }
428
- if (entry.kind === "concern") {
429
- return entry.detail ? `${entry.named} ${entry.detail}` : entry.named;
430
- }
431
- return `${entry.metric}: ${entry.value}`;
432
- })
433
- .join("; ");
434
- return [
435
- `- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
436
- evidence ? ` Evidence: ${evidence}` : undefined,
437
- ]
438
- .filter((line) => line !== undefined)
439
- .join("\n");
440
- });
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;
441
505
  return [
442
506
  `# Your spec for ${workstream.id}: ${workstream.name} was reviewed`,
443
507
  "",
508
+ ...(resumed ? [SESSION_CONTINUATION_PREAMBLE] : []),
444
509
  "An independent reviewer read the spec you wrote and raised the",
445
510
  "following:",
446
511
  "",
447
- ...findingsList,
448
- "",
512
+ ...(bothEmpty
513
+ ? ["(no findings — this should not happen; treat as a clean read.)", ""]
514
+ : [...bindingSection, ...advisorySection]),
449
515
  "Here is the spec as it currently stands:",
450
516
  "",
451
517
  "## Current spec",
@@ -464,12 +530,64 @@ function specCritiqueWriterBrief(workstream, spec, findings) {
464
530
  function hasFindingsBlock(output) {
465
531
  return /```findings/u.test(output);
466
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
+ }
467
578
  async function runSpecCritique(options) {
468
579
  const reviewer = resolveReviewerAgent(options.config);
469
580
  if (!reviewer)
470
- return reviewerAbsentOutcome();
581
+ return { outcome: reviewerAbsentOutcome() };
582
+ const recorder = options.recorder ?? NOOP_RUN_RECORDER;
471
583
  const locate = (file) => locateInRepo(options.root, file);
472
- const review = async (_round, priorOpen) => {
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();
590
+ const review = async (round, priorOpen) => {
473
591
  const currentSpec = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
474
592
  const brief = await specCritiqueReviewerBrief(options.root, options.manifest, options.workstream, options.rosterById, currentSpec, priorOpen);
475
593
  let invocation;
@@ -479,7 +597,7 @@ async function runSpecCritique(options) {
479
597
  programId: options.programId,
480
598
  label: `${options.workstream.id}-spec-critique-reviewer`,
481
599
  log: options.log,
482
- });
600
+ }, scope, { workstream: options.workstream.id, attemptIndex: round });
483
601
  }
484
602
  catch (error) {
485
603
  // Fail open, matching the equivalent build-side guard in
@@ -496,17 +614,89 @@ async function runSpecCritique(options) {
496
614
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
497
615
  return { findings, errors: parsed.errors, ran };
498
616
  };
499
- const respond = async (_round, findings) => {
500
- const currentSpec = (await readFinishedSpec(options.root, options.workstream)) ?? options.spec;
501
- const brief = specCritiqueWriterBrief(options.workstream, currentSpec, findings);
502
- let invocation;
503
- try {
504
- 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", {
505
660
  root: options.root,
506
661
  programId: options.programId,
507
662
  label: `${options.workstream.id}-spec-critique-writer`,
508
663
  log: options.log,
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
+ },
509
698
  });
699
+ invocation = spawned.result;
510
700
  }
511
701
  catch (error) {
512
702
  // Fail open: the spec is left as the reviewer last saw it and the
@@ -516,9 +706,47 @@ async function runSpecCritique(options) {
516
706
  `findings for ${options.workstream.id}: ${error.message}`);
517
707
  return { note: "(spec critique response not attempted — the author agent could not be spawned)" };
518
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
+ }
519
733
  return { note: resolveSummary(invocation.output).text };
520
734
  };
521
- return runReviewPass({ review, respond });
735
+ const budget = specCritiqueBudget(options.workstream.size);
736
+ try {
737
+ const outcome = await runReviewPass({
738
+ cycles: budget.cycles,
739
+ cycleNote: budget.note,
740
+ review,
741
+ respond,
742
+ });
743
+ span.close({ outcome: "success" });
744
+ return { outcome, ...(sessionFallbackNote ? { sessionFallbackNote } : {}) };
745
+ }
746
+ catch (error) {
747
+ span.close({ outcome: "failed" });
748
+ throw error;
749
+ }
522
750
  }
523
751
  function mergeDependencies(workstream, rosterIds, declaredIds) {
524
752
  const merged = [];
@@ -563,23 +791,25 @@ function extractDependenciesDeclaration(output) {
563
791
  return { ids: json };
564
792
  }
565
793
  /**
566
- * The authoring stage: writes every missing workstream spec, one clean agent
567
- * at a time, in dependency-level order. Shaped like `runProgram`plain
568
- * inputs, injected boundaries, a structured result the CLI and `run` both
569
- * 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.
570
798
  */
571
- export async function authorProgram(options) {
799
+ export async function prepareAuthoring(options) {
572
800
  const root = resolve(options.cwd);
573
801
  const config = options.config;
574
802
  const agentRunner = options.agentRunner ?? defaultAgentRunner;
575
803
  const permits = options.permits ?? NOOP_PERMITS_CONTEXT;
576
804
  const git = options.git ?? defaultGitOps;
577
- const log = options.log ?? ((line) => console.log(line));
805
+ const baseLog = options.log ?? ((line) => console.log(line));
806
+ const log = baseLog;
578
807
  const now = options.now ?? (() => new Date());
579
808
  const force = options.force === true;
580
809
  const reviewed = options.reviewed ?? new Set();
581
810
  const triaged = options.triaged ?? new Set();
582
- const manifest = await loadManifest(root, options.programId);
811
+ const recorder = options.recorder ?? NOOP_RUN_RECORDER;
812
+ const manifest = options.manifest ?? (await loadManifest(root, options.programId));
583
813
  const resolvedAuthor = resolveAuthorAgent(config);
584
814
  if (!resolvedAuthor) {
585
815
  throw new Error("No author agent configured, and no implementer to fall back to. Set " +
@@ -602,6 +832,13 @@ export async function authorProgram(options) {
602
832
  .map((cycle) => cycle.join(" -> "))
603
833
  .join("; ")}. Re-plan with /plan-program.`);
604
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());
605
842
  // The selected range (SC-05): computed once, up front, from the manifest
606
843
  // as loaded — before any agent spawns — so `--from` validation and the
607
844
  // selected set are deterministic, independent of dependency edges an
@@ -625,26 +862,182 @@ export async function authorProgram(options) {
625
862
  if (!isRepository) {
626
863
  log("warning: not a git repository — commits and decision anchors are unavailable");
627
864
  }
628
- const results = [];
629
- const processed = new Set();
630
865
  const blockedCone = new Set();
631
- while (processed.size < manifest.workstreams.length) {
632
- const remaining = manifest.workstreams.filter((w) => !processed.has(w.id));
633
- const levels = topologicalLevels(remaining);
634
- const next = levels[0]?.[0];
635
- if (!next)
636
- break;
637
- processed.add(next.id);
638
- 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;
639
900
  }
640
- const complete = results.every((result) => result.outcome.status === "authored" || result.outcome.status === "kept");
641
- return {
642
- programId: options.programId,
643
- complete,
644
- results,
645
- borrowedImplementer,
646
- };
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 };
647
1038
  async function handle(workstream) {
1039
+ const log = workstreamLog(baseLog, workstream.id);
1040
+ const scope = scopeFor(workstream.id);
648
1041
  const base = {
649
1042
  id: workstream.id,
650
1043
  name: workstream.name,
@@ -658,7 +1051,7 @@ export async function authorProgram(options) {
658
1051
  };
659
1052
  if (blockedCone.has(workstream.id)) {
660
1053
  workstream.status = "parked";
661
- await saveManifest(root, options.programId, manifest, { log });
1054
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
662
1055
  base.outcome = {
663
1056
  status: "parked",
664
1057
  reason: "an upstream dependency's authoring failed; parked, not attempted",
@@ -685,9 +1078,8 @@ export async function authorProgram(options) {
685
1078
  workstream.specHash !== undefined &&
686
1079
  specInputsHash(manifest, workstream) === workstream.specHash) {
687
1080
  workstream.specStatus = "complete";
688
- await saveManifest(root, options.programId, manifest, { log });
689
- log(`${workstream.id}: recognized as completed legacy authoring — ` +
690
- '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"');
691
1083
  return base;
692
1084
  }
693
1085
  // Drift check: the spec exists and is about to be graded against, but
@@ -702,12 +1094,12 @@ export async function authorProgram(options) {
702
1094
  const current = specInputsHash(manifest, workstream);
703
1095
  if (current !== workstream.specHash) {
704
1096
  workstream.status = "parked";
705
- await saveManifest(root, options.programId, manifest, { log });
1097
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
706
1098
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
707
1099
  blockedCone.add(id);
708
1100
  }
709
- log(`${workstream.id}: the manifest entry drifted since its spec was ` +
710
- "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");
711
1103
  base.outcome = {
712
1104
  status: "parked",
713
1105
  reason: `the manifest entry (name, scope, dependencies, or the program's ` +
@@ -720,9 +1112,7 @@ export async function authorProgram(options) {
720
1112
  }
721
1113
  return base;
722
1114
  }
723
- log(`${workstream.id} ${workstream.name}: authoring`);
724
- const rosterById = new Map(manifest.workstreams.map((w) => [w.id, w]));
725
- const rosterIds = new Set(rosterById.keys());
1115
+ log(`${workstream.name}: authoring`);
726
1116
  const baseCommit = isRepository ? await git.currentCommit(root) : undefined;
727
1117
  // Keyed by fingerprint, last-wins: a re-author pass can re-emit a
728
1118
  // decision with the same id but revised context/options/chosen (the
@@ -730,14 +1120,24 @@ export async function authorProgram(options) {
730
1120
  // workstream's current truth. `.set()` on each push naturally keeps the
731
1121
  // latest occurrence rather than the first.
732
1122
  const allDecisionsById = new Map();
733
- 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);
734
1128
  base.demotedDependencies = first.demoted;
1129
+ for (const point of promptComponentSizePoints(first.components, { role: "authorAgent" })) {
1130
+ scope.point(point);
1131
+ }
1132
+ const firstDemotedPoint = demotedDependenciesPoint(first.demoted, first.demotedBytes, { role: "authorAgent" });
1133
+ if (firstDemotedPoint)
1134
+ scope.point(firstDemotedPoint);
735
1135
  // Stamped before the agent can write anything (SC-01): the agent has
736
1136
  // whole-file write access, so a crash at any point after this line
737
1137
  // leaves a durable `in_progress` marker the next attempt classifies as
738
1138
  // interrupted, regardless of whether a partial file exists.
739
1139
  workstream.specStatus = "in_progress";
740
- await saveManifest(root, options.programId, manifest, { log });
1140
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
741
1141
  let invocation;
742
1142
  try {
743
1143
  invocation = await invokeAgent(agentRunner, author, first.brief, root, permits, "authorAgent", {
@@ -745,7 +1145,9 @@ export async function authorProgram(options) {
745
1145
  programId: options.programId,
746
1146
  label: `${workstream.id}-author`,
747
1147
  log,
748
- });
1148
+ }, scope, { stage: "author", workstream: workstream.id });
1149
+ if (invocation.session)
1150
+ authorSession = invocation.session;
749
1151
  }
750
1152
  catch (error) {
751
1153
  // A spawn error here means the author agent never started — the same
@@ -757,7 +1159,7 @@ export async function authorProgram(options) {
757
1159
  // stays `in_progress` — never cleared on a failure path — so a later
758
1160
  // retry resumes without needing --force (SC-07).
759
1161
  workstream.status = "parked";
760
- await saveManifest(root, options.programId, manifest, { log });
1162
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
761
1163
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
762
1164
  blockedCone.add(id);
763
1165
  }
@@ -774,7 +1176,7 @@ export async function authorProgram(options) {
774
1176
  // Park exactly like a spawn failure and leave `specStatus` at
775
1177
  // `in_progress` so a force-free retry re-authors.
776
1178
  workstream.status = "parked";
777
- await saveManifest(root, options.programId, manifest, { log });
1179
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
778
1180
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
779
1181
  blockedCone.add(id);
780
1182
  }
@@ -800,36 +1202,64 @@ export async function authorProgram(options) {
800
1202
  base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
801
1203
  const declaration = extractDependenciesDeclaration(invocation.output);
802
1204
  if (declaration.error)
803
- log(`${workstream.id}: ${declaration.error}`);
804
- 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
+ });
805
1229
  base.unknownDependencyIds = merge.unknown;
806
1230
  if (merge.unknown.length > 0) {
807
- log(`${workstream.id}: unknown dependency id(s) declared, ignored: ${merge.unknown.join(", ")}`);
1231
+ log(`unknown dependency id(s) declared, ignored: ${merge.unknown.join(", ")}`);
808
1232
  }
809
1233
  if (merge.merged.length > 0) {
810
- const originalDependencies = workstream.dependencies.filter((id) => !merge.merged.includes(id));
811
1234
  base.mergedDependencies = merge.merged;
812
- await saveManifest(root, options.programId, manifest, { log });
813
- log(`${workstream.id}: merged new dependency edge(s): ${merge.merged.join(", ")}`);
814
- const cyclesNow = findCycles(manifest.workstreams);
815
- if (cyclesNow.length > 0) {
816
- workstream.dependencies = originalDependencies;
817
- await saveManifest(root, options.programId, manifest, { log });
818
- throw new Error(`Dependency cycle(s) created by a discovered edge from ${workstream.id}: ` +
819
- `${cyclesNow.map((cycle) => cycle.join(" -> ")).join("; ")}. Re-plan with /plan-program.`);
820
- }
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.
821
1244
  const needsReauthor = [];
822
1245
  for (const id of merge.merged) {
823
1246
  const dep = rosterById.get(id);
824
1247
  if (!dep)
825
1248
  continue;
826
- 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))
827
1258
  needsReauthor.push(id);
828
1259
  }
829
1260
  if (needsReauthor.length > 0) {
830
1261
  base.reauthored = true;
831
- log(`${workstream.id}: re-authoring once with newly discovered dependency spec(s): ` +
832
- needsReauthor.join(", "));
1262
+ log(`re-authoring once with newly discovered dependency spec(s): ${needsReauthor.join(", ")}`);
833
1263
  const note = [
834
1264
  "## You are being re-authored",
835
1265
  "",
@@ -840,11 +1270,21 @@ export async function authorProgram(options) {
840
1270
  "re-author pass.",
841
1271
  "",
842
1272
  ].join("\n");
843
- const second = await authorBrief(root, manifest, workstream, rosterById, note);
1273
+ const second = await authorBrief(root, manifest, workstream, rosterById, note, specFinalSync);
844
1274
  base.demotedDependencies = second.demoted;
1275
+ for (const point of promptComponentSizePoints(second.components, { role: "authorAgent" })) {
1276
+ scope.point(point);
1277
+ }
1278
+ const secondDemotedPoint = demotedDependenciesPoint(second.demoted, second.demotedBytes, {
1279
+ role: "authorAgent",
1280
+ });
1281
+ if (secondDemotedPoint)
1282
+ scope.point(secondDemotedPoint);
845
1283
  let reauthorInvocation;
846
1284
  try {
847
- reauthorInvocation = await invokeAgent(agentRunner, author, second.brief, root, permits, "authorAgent", { root, programId: options.programId, label: `${workstream.id}-author-reauthor`, log });
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;
848
1288
  }
849
1289
  catch (error) {
850
1290
  // Fail open: the first pass already wrote a spec and journaled
@@ -866,7 +1306,7 @@ export async function authorProgram(options) {
866
1306
  // otherwise untouched; a returned failure carries no such
867
1307
  // guarantee.
868
1308
  workstream.status = "parked";
869
- await saveManifest(root, options.programId, manifest, { log });
1309
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
870
1310
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
871
1311
  blockedCone.add(id);
872
1312
  }
@@ -895,8 +1335,8 @@ export async function authorProgram(options) {
895
1335
  base.decisionIds.push(...parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision)));
896
1336
  const secondDeclaration = extractDependenciesDeclaration(invocation.output);
897
1337
  if (secondDeclaration.ids.length > 0) {
898
- log(`${workstream.id}: additional dependency declaration after the ` +
899
- `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(", ")}`);
900
1340
  }
901
1341
  }
902
1342
  }
@@ -905,7 +1345,7 @@ export async function authorProgram(options) {
905
1345
  const final = await readFinishedSpec(root, workstream);
906
1346
  if (final === undefined) {
907
1347
  workstream.status = "parked";
908
- await saveManifest(root, options.programId, manifest, { log });
1348
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
909
1349
  for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
910
1350
  blockedCone.add(id);
911
1351
  }
@@ -929,23 +1369,32 @@ export async function authorProgram(options) {
929
1369
  agentRunner,
930
1370
  permits,
931
1371
  author,
1372
+ deciderConfigured: decider !== undefined,
1373
+ baseCommit,
1374
+ now,
932
1375
  log,
933
1376
  pushStageError: (message) => (base.stageErrors ??= []).push(message),
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 } : {}),
934
1389
  });
935
- base.specCritique = critique;
1390
+ base.specCritique = critique.outcome;
1391
+ if (critique.sessionFallbackNote)
1392
+ (base.notes ??= []).push(critique.sessionFallbackNote);
936
1393
  const postCritique = await readFinishedSpec(root, workstream);
937
1394
  if (postCritique === undefined) {
938
1395
  await writeFile(join(root, workstream.taskFile), final, "utf8");
939
- log(`${workstream.id}: a critique round left the spec empty or missing; ` +
940
- "the pre-critique spec was restored");
1396
+ log("a critique round left the spec empty or missing; the pre-critique spec was restored");
941
1397
  }
942
- const critiqueEvents = findingsToLedgerEvents({
943
- workstreamId: workstream.id,
944
- findings: critique.open.filter(hasRoutableEvidence),
945
- ...(baseCommit === undefined ? {} : { baseCommit }),
946
- now,
947
- });
948
- await appendLedgerEvents(root, options.programId, critiqueEvents);
949
1398
  // Stamped after every dependency merge above, so the hash records the
950
1399
  // manifest exactly as this spec's author last saw it — a later manifest
951
1400
  // edit is drift; authoring's own merges are not. `specStatus` is
@@ -955,10 +1404,18 @@ export async function authorProgram(options) {
955
1404
  // must still classify as interrupted on retry, not falsely complete with
956
1405
  // an unmade commit.
957
1406
  workstream.specHash = specInputsHash(manifest, workstream);
958
- await 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.
959
1416
  let commit;
960
1417
  if (isRepository) {
961
- commit = await git.commitPaths(root, `nightshift(${options.programId}): author ${workstream.id} ${workstream.name}`, [workstream.taskFile, "docs/programs"]);
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)));
962
1419
  }
963
1420
  // Outside a git repository there is no commit to wait for — the finished
964
1421
  // spec, its specHash, and this manifest save are already the durable
@@ -966,24 +1423,17 @@ export async function authorProgram(options) {
966
1423
  // only runs after `commitPaths` above resolves, so the transition is
967
1424
  // recoverable on both sides of the commit (SC-01, SC-02).
968
1425
  workstream.specStatus = "complete";
969
- await saveManifest(root, options.programId, manifest, { log });
1426
+ await timed(scope, { stage: "manifest-persist", workstream: workstream.id }, () => saveManifest(root, options.programId, manifest, { log }));
970
1427
  {
971
1428
  const spawnErrors = await reviewWorkstreamDecisions(workstream.id, [...allDecisionsById.values()], baseCommit);
972
1429
  if (spawnErrors.length > 0)
973
1430
  (base.stageErrors ??= []).push(...spawnErrors);
974
1431
  }
975
- // Findings triage against the same pre-workstream diff base the decision
976
- // review used above — a spec-critique finding has no separate green
977
- // anchor the way a test-critique fix does.
978
- {
979
- const spawnErrors = await reviewWorkstreamFindings(workstream.id, critiqueEvents, baseCommit);
980
- if (spawnErrors.length > 0)
981
- (base.stageErrors ??= []).push(...spawnErrors);
982
- }
983
1432
  base.outcome = { status: "authored", ...(commit === undefined ? {} : { commit }) };
984
1433
  return base;
985
1434
  }
986
1435
  async function journalDecisions(workstream, decisions, baseCommit) {
1436
+ const log = workstreamLog(baseLog, workstream.id);
987
1437
  const events = decisions.map((decision) => ({
988
1438
  kind: "decision-recorded",
989
1439
  at: now().toISOString(),
@@ -993,12 +1443,13 @@ export async function authorProgram(options) {
993
1443
  ...(baseCommit === undefined ? {} : { baseCommit }),
994
1444
  decidedBy: "implementer",
995
1445
  }));
996
- await appendLedgerEvents(root, options.programId, events);
1446
+ await timed(scopeFor(workstream.id), { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
997
1447
  for (const decision of decisions) {
998
- log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
1448
+ log(`decision: ${decision.title} -> ${decision.chosen}`);
999
1449
  }
1000
1450
  }
1001
1451
  function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
1452
+ const log = workstreamLog(baseLog, workstreamId);
1002
1453
  return reviewDecisions({
1003
1454
  root,
1004
1455
  programId: options.programId,
@@ -1016,9 +1467,11 @@ export async function authorProgram(options) {
1016
1467
  now,
1017
1468
  log,
1018
1469
  observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1470
+ recorder: scopeFor(workstreamId),
1019
1471
  });
1020
1472
  }
1021
1473
  function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
1474
+ const log = workstreamLog(baseLog, workstreamId);
1022
1475
  return triageFindings({
1023
1476
  root,
1024
1477
  programId: options.programId,
@@ -1036,7 +1489,41 @@ export async function authorProgram(options) {
1036
1489
  now,
1037
1490
  log,
1038
1491
  observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1492
+ recorder: scopeFor(workstreamId),
1039
1493
  });
1040
1494
  }
1041
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
+ }
1042
1529
  //# sourceMappingURL=author.js.map