@wildorder/nightshift 0.16.0 → 0.17.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 (50) hide show
  1. package/README.md +20 -0
  2. package/dist/agent-runner.d.ts +56 -4
  3. package/dist/agent-runner.d.ts.map +1 -1
  4. package/dist/agent-runner.js +253 -28
  5. package/dist/agent-runner.js.map +1 -1
  6. package/dist/author.d.ts +8 -0
  7. package/dist/author.d.ts.map +1 -1
  8. package/dist/author.js +84 -57
  9. package/dist/author.js.map +1 -1
  10. package/dist/causal-analysis.d.ts +212 -0
  11. package/dist/causal-analysis.d.ts.map +1 -0
  12. package/dist/causal-analysis.js +733 -0
  13. package/dist/causal-analysis.js.map +1 -0
  14. package/dist/decider-review.d.ts +6 -1
  15. package/dist/decider-review.d.ts.map +1 -1
  16. package/dist/decider-review.js +19 -7
  17. package/dist/decider-review.js.map +1 -1
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/preflight.d.ts +3 -0
  23. package/dist/preflight.d.ts.map +1 -1
  24. package/dist/preflight.js +73 -57
  25. package/dist/preflight.js.map +1 -1
  26. package/dist/prompt-telemetry.d.ts +64 -0
  27. package/dist/prompt-telemetry.d.ts.map +1 -0
  28. package/dist/prompt-telemetry.js +112 -0
  29. package/dist/prompt-telemetry.js.map +1 -0
  30. package/dist/provider-telemetry.d.ts +106 -0
  31. package/dist/provider-telemetry.d.ts.map +1 -0
  32. package/dist/provider-telemetry.js +423 -0
  33. package/dist/provider-telemetry.js.map +1 -0
  34. package/dist/run-analytics-report.d.ts +173 -0
  35. package/dist/run-analytics-report.d.ts.map +1 -0
  36. package/dist/run-analytics-report.js +650 -0
  37. package/dist/run-analytics-report.js.map +1 -0
  38. package/dist/run-analytics.d.ts +738 -0
  39. package/dist/run-analytics.d.ts.map +1 -0
  40. package/dist/run-analytics.js +545 -0
  41. package/dist/run-analytics.js.map +1 -0
  42. package/dist/run-program.d.ts +69 -1
  43. package/dist/run-program.d.ts.map +1 -1
  44. package/dist/run-program.js +1254 -714
  45. package/dist/run-program.js.map +1 -1
  46. package/dist/whole-program-review.d.ts +3 -0
  47. package/dist/whole-program-review.d.ts.map +1 -1
  48. package/dist/whole-program-review.js +8 -1
  49. package/dist/whole-program-review.js.map +1 -1
  50. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile, writeFile } from "node:fs/promises";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
4
  import { join, resolve } from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveAuthorAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
@@ -18,9 +19,21 @@ import { defaultPrerequisiteRunner, runPreflight, } from "./preflight.js";
18
19
  import { restoreProgramsDir, snapshotProgramsDir } from "./programs-dir.js";
19
20
  import { buildPermitsContext } from "./permits.js";
20
21
  import { CouldNotStartError } from "./exit-codes.js";
22
+ import { clippedInputPoint, createBriefBuilder, promptComponentSizePoints, } from "./prompt-telemetry.js";
23
+ import { NOOP_RUN_RECORDER, readPriorRunId, RunRecorder, } from "./run-analytics.js";
24
+ import { renderAnalyticsSection } from "./run-analytics-report.js";
21
25
  import { runReportPath } from "./report-path.js";
22
26
  import { AS_BUILT_PATH, runWholeProgramReview, renderWholeProgramReview, } from "./whole-program-review.js";
27
+ import { runCausalAnalysis, renderCausalAnalysisSection, } from "./causal-analysis.js";
23
28
  const execFileAsync = promisify(execFile);
29
+ /** The attempt-loop seat label to its stage (WS-02) — the role-derived
30
+ * `STAGE_FOR_ROLE` default in `agent-runner.ts` cannot distinguish these
31
+ * three, since `agent` drives both `implementer` and `informed-retry`. */
32
+ const STAGE_FOR_SEAT = {
33
+ implementer: "implementer",
34
+ recovery: "recovery",
35
+ "informed retry": "informed-retry",
36
+ };
24
37
  /** Matches every wording git uses to report an empty commit attempt. */
25
38
  const NOTHING_TO_COMMIT = /nothing to commit|nothing added to commit|no changes added to commit/u;
26
39
  /**
@@ -224,6 +237,38 @@ export const defaultGitOps = {
224
237
  await execFileAsync("git", ["update-ref", ref, commit], { cwd });
225
238
  },
226
239
  };
240
+ /**
241
+ * Fail-open span wrapper for a runner-owned git-and-persistence operation
242
+ * (WS-02): opens a span, awaits `fn`, closes it `success` on completion or
243
+ * `interrupted` on a throw (re-thrown unchanged, via `finally`) — this
244
+ * module's stage functions already guard the *run* error a wrapped call can
245
+ * raise, so this wrapper only ever adds an observation, never changes what
246
+ * the caller sees. `evidenceFor`, when given, derives an evidence reference
247
+ * from the successful result (e.g. a commit sha) rather than every call
248
+ * needing to know the schema's evidence-kind vocabulary itself. Shared by
249
+ * `run-program.ts`'s own git/manifest/ledger call sites and imported by
250
+ * `author.ts` and `decider-review.ts` for theirs, so every runner-owned
251
+ * commit, large diff, and manifest/ledger persist is instrumented through
252
+ * one implementation.
253
+ */
254
+ export async function timed(recorder, dims, fn, evidenceFor) {
255
+ const span = recorder.span(dims);
256
+ let result;
257
+ try {
258
+ result = await fn();
259
+ }
260
+ catch (error) {
261
+ span.close({ outcome: "interrupted" });
262
+ throw error;
263
+ }
264
+ const evidence = evidenceFor?.(result);
265
+ span.close({ outcome: "success", ...(evidence && evidence.length > 0 ? { evidence } : {}) });
266
+ return result;
267
+ }
268
+ /** `timed`'s `evidenceFor` for a git commit call: a defined, non-empty sha becomes a `commit` evidence ref; "nothing to commit" (`undefined`) carries none. */
269
+ export function commitEvidence(sha) {
270
+ return sha === undefined ? undefined : [{ kind: "commit", locality: "local", ref: sha }];
271
+ }
227
272
  /**
228
273
  * Ledger decisions for one workstream, projected into its brief. A
229
274
  * human-decided record is binding — the implementer is told to build that
@@ -311,6 +356,13 @@ function shellPolicySection(shellPolicy) {
311
356
  "",
312
357
  ];
313
358
  }
359
+ /**
360
+ * Builds the implementer's brief, byte-for-byte identical to the plain
361
+ * `array.join("\n")` this produced before WS-03 — but through a
362
+ * `BriefBuilder` so every line is also classified into an SC-07 prompt
363
+ * component, with `components` summing exactly to the joined brief's byte
364
+ * length.
365
+ */
314
366
  function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, priorFailure, priorDiagnosis) {
315
367
  const roster = manifest.workstreams
316
368
  .map((entry) => {
@@ -321,64 +373,46 @@ function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, prior
321
373
  const relevantDecisions = ledger.decisions.filter((record) => record.workstream === workstream.id &&
322
374
  (record.status === "human-decided" || record.status === "ratified"));
323
375
  const relevantFindings = ledger.findings.filter((record) => record.workstream === workstream.id && record.status === "human-decided");
324
- return [
325
- `# Workstream ${workstream.id}: ${workstream.name}`,
326
- "",
327
- `Program: ${manifest.program.id} ${manifest.program.name}`,
328
- "",
329
- "You are implementing exactly one workstream of a larger program. The",
330
- "full roster, so you know what exists and do not rebuild another",
331
- "workstream's work (conform only to your declared dependencies):",
332
- "",
333
- roster,
334
- "",
335
- ...decisionsRuledOnSection(relevantDecisions),
336
- ...findingsRuledOnSection(relevantFindings),
337
- ...(priorFailure
338
- ? [
339
- "## Previous attempt failed",
340
- "",
341
- "A previous attempt at this workstream failed. The verify output:",
342
- "",
343
- priorFailure,
344
- "",
345
- ...(priorDiagnosis
346
- ? [
347
- "An independent reviewer read the failing tree before you were",
348
- "spawned. Its diagnosis:",
349
- "",
350
- priorDiagnosis,
351
- "",
352
- "The tests in the tree were written by the failed attempt, not",
353
- "by a human. When the diagnosis says an assertion is wrong,",
354
- "rewrite or delete that assertion rather than bending the",
355
- "implementation to satisfy it — the spec, not the failing",
356
- "test, is the contract.",
357
- "",
358
- ]
359
- : []),
360
- "Start from the diagnosis; the working tree may already contain",
361
- "partial work from that attempt.",
362
- "",
363
- ]
364
- : []),
365
- "## Specification",
366
- "",
367
- spec.trim(),
368
- "",
369
- "## Rules",
370
- "",
371
- "- Never commit; the runner owns commits and commits only verified work.",
372
- "- Follow the repository's AGENTS.md if present.",
373
- "- Run the project's checks yourself as you work; the runner verifies",
374
- " independently afterwards and your claim of success is never trusted",
375
- " without it.",
376
- "",
377
- ...shellPolicySection(shellPolicy),
378
- decisionContract(),
379
- "",
380
- summaryContract(),
381
- ].join("\n");
376
+ const builder = createBriefBuilder();
377
+ builder.push("framing", `# Workstream ${workstream.id}: ${workstream.name}`, "");
378
+ builder.push("program-narrative", `Program: ${manifest.program.id} — ${manifest.program.name}`);
379
+ builder.push("framing", "", "You are implementing exactly one workstream of a larger program. The", "full roster, so you know what exists and do not rebuild another", "workstream's work (conform only to your declared dependencies):", "");
380
+ builder.push("roster", roster);
381
+ builder.push("framing", "");
382
+ builder.push("ledger-rulings", ...decisionsRuledOnSection(relevantDecisions), ...findingsRuledOnSection(relevantFindings));
383
+ builder.push("prior-diagnostics", ...(priorFailure
384
+ ? [
385
+ "## Previous attempt failed",
386
+ "",
387
+ "A previous attempt at this workstream failed. The verify output:",
388
+ "",
389
+ priorFailure,
390
+ "",
391
+ ...(priorDiagnosis
392
+ ? [
393
+ "An independent reviewer read the failing tree before you were",
394
+ "spawned. Its diagnosis:",
395
+ "",
396
+ priorDiagnosis,
397
+ "",
398
+ "The tests in the tree were written by the failed attempt, not",
399
+ "by a human. When the diagnosis says an assertion is wrong,",
400
+ "rewrite or delete that assertion rather than bending the",
401
+ "implementation to satisfy it — the spec, not the failing",
402
+ "test, is the contract.",
403
+ "",
404
+ ]
405
+ : []),
406
+ "Start from the diagnosis; the working tree may already contain",
407
+ "partial work from that attempt.",
408
+ "",
409
+ ]
410
+ : []));
411
+ builder.push("framing", "## Specification", "");
412
+ builder.push("workstream-spec", spec.trim());
413
+ builder.push("framing", "", "## Rules", "", "- Never commit; the runner owns commits and commits only verified work.", "- Follow the repository's AGENTS.md if present.", "- Run the project's checks yourself as you work; the runner verifies", " independently afterwards and your claim of success is never trusted", " without it.", "");
414
+ builder.push("runner-instructions", ...shellPolicySection(shellPolicy), decisionContract(), "", summaryContract());
415
+ return { brief: builder.join(), components: builder.components() };
382
416
  }
383
417
  /** Known `docs/programs/` artifact suffixes, longest-specific first is not required — each is checked independently. */
384
418
  const DOCS_PROGRAMS_SUFFIXES = [
@@ -386,6 +420,7 @@ const DOCS_PROGRAMS_SUFFIXES = [
386
420
  "-manifest.json",
387
421
  "-run-report.md",
388
422
  "-decisions.jsonl",
423
+ "-run-analytics.json",
389
424
  ];
390
425
  /**
391
426
  * The program id a planning-artifact path belongs to, or undefined when the
@@ -527,6 +562,46 @@ async function runProgramWith(options, permits) {
527
562
  const git = options.git ?? defaultGitOps;
528
563
  const log = options.log ?? ((line) => console.log(line));
529
564
  const now = options.now ?? (() => new Date());
565
+ const monotonic = options.monotonic ?? (() => performance.now());
566
+ // Read before anything durable happens (a plain read, safe before the
567
+ // could-not-start gate): a prior finalized artifact's run id becomes this
568
+ // run's `parentRunId`, so a resumed invocation carries explicit lineage
569
+ // instead of silently blending its elapsed time with a prior attempt
570
+ // (SC-14). Created here — before the gate — so pre-gate stages (preflight,
571
+ // baseline verification) can be buffered in memory; it stays disarmed,
572
+ // writing nothing durable, until `arm()` is called below.
573
+ const priorRunId = await readPriorRunId(root, options.programId);
574
+ const recorder = options.recorder ??
575
+ RunRecorder.create({
576
+ clock: { now, monotonic },
577
+ root,
578
+ programId: options.programId,
579
+ ...(priorRunId === undefined ? {} : { parentRunId: priorRunId }),
580
+ log,
581
+ });
582
+ // The run-level reference span (WS-01's own; the fourth reference span
583
+ // alongside the three named seams — see the WS-01/WS-02 emission-boundary
584
+ // decision). Opened here, at the very first monotonic tick this function
585
+ // can observe — before preflight, before baseline verification, before the
586
+ // could-not-start gate — so it covers the true run start, not merely the
587
+ // portion after arming. `span()` buffers in memory regardless of arm
588
+ // state, so a could-not-start throw before `arm()` simply discards it,
589
+ // same as every other pre-gate observation (SC-02). Its bucket is
590
+ // `unattributed` by construction — see STAGE_BUCKET's doc comment.
591
+ //
592
+ // It is deliberately never closed on the normal-completion path (see the
593
+ // `report-commit` span below for why): the reconciled numbers stop at the
594
+ // pre-render snapshot (SC-03, SC-14), but the run's true end is the report
595
+ // commit, and closing this span before the report is written and
596
+ // committed would stamp an end that has not happened — exactly the defect
597
+ // an earlier version of
598
+ // this design had, closing here and silently excluding the trailing write
599
+ // and commit from every span instead of from just this one. Leaving it
600
+ // open is not a special case for that fix: it is the same "true end not
601
+ // observed" semantics the schema already uses for a crashed run, applied
602
+ // honestly to the one boundary this recorder can never observe closing
603
+ // even on success.
604
+ const runSpan = recorder.span({ stage: "run" });
530
605
  let manifest;
531
606
  try {
532
607
  manifest = await loadManifest(root, options.programId);
@@ -654,8 +729,10 @@ async function runProgramWith(options, permits) {
654
729
  // The run's own identity — distinct from runStartCommit, which two
655
730
  // invocations can share (the same commit) or lack entirely (no
656
731
  // repository). Stamped on each `prerequisite-verified` ledger event so the
657
- // ledger records which run verified a prerequisite.
658
- const runId = now().toISOString();
732
+ // ledger records which run verified a prerequisite. Promoted from the
733
+ // recorder's identity (SC-14) rather than minted separately, so the ledger
734
+ // and the analytics artifact always carry the same run id.
735
+ const runId = recorder.runId;
659
736
  // Preflight: every *pending* prerequisite's verifyCommand executes here —
660
737
  // before baseline verification and before any agent spawns (SC-03). This
661
738
  // mutates manifest.prerequisites in memory (pending -> satisfied on a met
@@ -670,6 +747,7 @@ async function runProgramWith(options, permits) {
670
747
  runId,
671
748
  runStartCommit,
672
749
  log,
750
+ recorder,
673
751
  });
674
752
  // Baseline verification: the verify suite on the untouched tree, before
675
753
  // the first agent is spawned. A red baseline on a fresh start means the
@@ -690,7 +768,7 @@ async function runProgramWith(options, permits) {
690
768
  let baselineFingerprint;
691
769
  if (Object.keys(config.verify).length > 0) {
692
770
  log("baseline: running the verify commands on the untouched tree");
693
- const baselineFailure = await verifyAttempt(config, verifyRunner, root, 0);
771
+ const baselineFailure = await verifyAttempt(config, verifyRunner, root, 0, recorder, "baseline-verification", { programId: options.programId, phase: "baseline" });
694
772
  if (baselineFailure === undefined) {
695
773
  log("baseline: clean");
696
774
  }
@@ -715,10 +793,14 @@ async function runProgramWith(options, permits) {
715
793
  // makes the only crash-between-writes residue a harmless idempotent
716
794
  // duplicate event, never a lost one. A could-not-start throw above has
717
795
  // already unwound the process before reaching here, so an exit-3 run
718
- // mutates neither the manifest nor the ledger.
796
+ // mutates neither the manifest nor the ledger — nor, from this line on, the
797
+ // analytics artifact: `arm()` enables the recorder's durable writes, so a
798
+ // could-not-start run leaves no working log and no finalized artifact
799
+ // (SC-02).
800
+ recorder.arm();
719
801
  if (preflight.events.length > 0) {
720
- await appendLedgerEvents(root, options.programId, preflight.events);
721
- await saveManifest(root, options.programId, manifest, { log });
802
+ await timed(recorder, { stage: "ledger-persist" }, () => appendLedgerEvents(root, options.programId, preflight.events));
803
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
722
804
  }
723
805
  // Subject ids already sent to the decider this run — shared across the
724
806
  // authoring and build stages (a run is one process) and across the
@@ -732,671 +814,870 @@ async function runProgramWith(options, permits) {
732
814
  // report's "this run" triage ratio — see decider-review.ts's doc comment
733
815
  // on `triaged`.
734
816
  const triaged = new Set();
735
- // Authoring runs before building: every workstream whose spec is missing
736
- // gets one, in dependency order, before anything is implemented. It
737
- // reloads the manifest afterward because authoring may have merged
738
- // discovered dependency edges or parked workstreams it could not author.
739
- const authorResult = await authorProgram({
740
- cwd: options.cwd,
741
- programId: options.programId,
742
- config,
743
- agentRunner,
744
- permits,
745
- git,
746
- log,
747
- now,
748
- reviewed,
749
- triaged,
750
- });
751
- manifest = await loadManifest(root, options.programId);
752
- // Loaded once so every brief in this run projects the same picture of
753
- // human-decided and ratified choices; decisions this run itself journals
754
- // are picked up fresh by `readDecisionLedger` at the end, for escalations.
755
- const ledgerAtStart = await readDecisionLedger(root, options.programId);
756
- const ordered = stableTopologicalOrder(manifest.workstreams);
757
- const results = [];
758
- // Every workstream whose spec authoring failed or parked. Seeds `blocked`
759
- // below (their briefs would be missing a producer's spec) and also guards
760
- // the awaiting_human branch in the build loop: an authoring failure must
761
- // never be repainted as a planned wait — see that branch's own comment.
762
- const authoringFailed = new Set(authorResult.results
763
- .filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
764
- .map((entry) => entry.id));
765
- const blocked = new Set(authoringFailed);
766
- // The awaiting cone: every workstream directly referencing an unmet
767
- // prerequisite, plus everything transitively downstream of one — the same
768
- // downstream-cone machinery failure isolation uses, with the semantics
769
- // inverted (no agent spawned, no retry, no diagnosis; see the build loop).
770
- const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
771
- const directlyAwaiting = new Set(manifest.workstreams
772
- .filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
773
- .map((workstream) => workstream.id));
774
- const awaitingCone = new Set([
775
- ...directlyAwaiting,
776
- ...downstreamCone(manifest.workstreams, [...directlyAwaiting]),
777
- ]);
778
- // Set when a workstream's verify failure reproduced the pre-run baseline:
779
- // the environment is broken, so every remaining workstream parks instead
780
- // of spending its budget on the same crash.
781
- let environmentalHalt = false;
782
- for (const workstream of ordered) {
783
- if (workstream.status === "complete") {
784
- results.push({
785
- id: workstream.id,
786
- name: workstream.name,
787
- outcome: { status: "skipped", reason: "already complete" },
788
- decisionIds: [],
789
- decisionErrors: [],
790
- });
791
- continue;
792
- }
793
- if (environmentalHalt) {
794
- workstream.status = "parked";
795
- results.push({
796
- id: workstream.id,
797
- name: workstream.name,
798
- outcome: {
799
- status: "parked",
800
- reason: "the run halted on an environmental verification failure; parked, not attempted",
801
- },
802
- decisionIds: [],
803
- decisionErrors: [],
804
- });
805
- continue;
806
- }
807
- // Authoring failures take precedence over awaiting: a workstream whose
808
- // spec authoring failed or parked must fall through to the `blocked`
809
- // branch below and render as the genuine failure it is, never be
810
- // repainted `awaiting_human` — an authoring failure has no other
811
- // representation in `results` than that branch's own `parked` result, so
812
- // rewriting it here would erase the only trace of it and the run would
813
- // wrongly look like a pure intermission. A workstream that is merely
814
- // downstream of a *build* failure and also in the awaiting cone still
815
- // resolves to awaiting_human here; that is safe because the build
816
- // failure self-reports `failed` in `results`, so the run is classified a
817
- // partial regardless (see the intermission classification below).
818
- if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
819
- workstream.status = "awaiting_human";
820
- await saveManifest(root, options.programId, manifest, { log });
821
- const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
822
- const reason = ownUnmet.length > 0
823
- ? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
824
- : "an upstream workstream is awaiting a human prerequisite; not attempted.";
825
- results.push({
826
- id: workstream.id,
827
- name: workstream.name,
828
- outcome: { status: "awaiting_human", reason },
829
- decisionIds: [],
830
- decisionErrors: [],
831
- });
832
- continue;
833
- }
834
- if (blocked.has(workstream.id)) {
835
- workstream.status = "parked";
836
- results.push({
837
- id: workstream.id,
838
- name: workstream.name,
839
- outcome: {
840
- status: "parked",
841
- reason: "an upstream dependency failed; parked, not attempted",
842
- },
843
- decisionIds: [],
844
- decisionErrors: [],
845
- });
846
- continue;
847
- }
848
- const result = await runWorkstream(workstream);
849
- results.push(result);
850
- if (result.environmental === true)
851
- environmentalHalt = true;
852
- if (result.outcome.status === "failed") {
853
- // Failure isolation: park the downstream cone, keep building the rest.
854
- for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
855
- blocked.add(id);
856
- }
857
- }
858
- }
859
- const complete = results.every((result) => result.outcome.status === "complete" ||
860
- result.outcome.status === "skipped");
861
- const anyAwaiting = results.some((result) => result.outcome.status === "awaiting_human");
862
- const anyFailedOrParked = results.some((result) => result.outcome.status === "failed" || result.outcome.status === "parked");
863
- // Author-stage failures are counted too: an authoring failure the awaiting
864
- // guard did NOT catch — e.g. a workstream that authored fine but sits
865
- // downstream of an authoring failure — must still sink the intermission.
866
- // Same set that seeded `blocked`/guarded the awaiting branch above;
867
- // classifying on it here as well makes "any genuine failure => ordinary
868
- // partial" true independent of how the cone painted it.
869
- const anyAuthoringFailure = authorResult.results.some((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked");
870
- // A planned intermission: nothing failed anywhere (build or authoring), at
871
- // least one workstream is waiting on a human, and not everything built. A
872
- // genuine failure makes it an ordinary partial instead — the human should
873
- // read a diagnosis, not a checklist.
874
- const intermission = !complete && anyAwaiting && !anyFailedOrParked && !anyAuthoringFailure;
875
- manifest.program.status = complete
876
- ? "complete"
877
- : intermission
878
- ? "awaiting_human"
879
- : "partial";
880
- await saveManifest(root, options.programId, manifest, { log });
881
- const wholeProgram = await runWholeProgramStage({
882
- root,
883
- programId: options.programId,
884
- manifest,
885
- config,
886
- agentRunner,
887
- permits,
888
- git,
889
- isRepository,
890
- results,
891
- authorResult,
892
- runStartCommit,
893
- decider,
894
- reviewed,
895
- triaged,
896
- now,
897
- log,
898
- });
899
- const ledger = await readDecisionLedger(root, options.programId);
900
- const escalations = escalatedRecords(ledger);
901
- const reportPath = runReportPath(root, options.programId);
902
- // Every workstream verdict, `complete`, and the exit-code mapping are
903
- // already settled above; writing and committing the report is bookkeeping
904
- // that must not be able to reject `runProgram` after the fact (SC-05,
905
- // SC-12) — a full disk or an EISDIR here is a logged line, not a thrown
906
- // run.
817
+ // Everything from here through the run's return is wrapped in one more
818
+ // level of function nesting so a thrown crash still finalizes the
819
+ // analytics artifact before control leaves (SC-02) without pulling any of
820
+ // this region's locals out of scope of the stage functions declared after
821
+ // its `return` (they are hoisted declarations inside `runProgramBody`,
822
+ // exactly as they were inside `runProgramWith` before this wrapping).
823
+ // `finalize()` is fail-open and cannot itself throw or mask the original
824
+ // error. On the normal path the artifact is finalized explicitly, below,
825
+ // before the report is written and committed, so it rides into the same
826
+ // `docs/programs` commit as the report; this catch exists only for the
827
+ // crash path.
907
828
  try {
908
- await writeFile(reportPath,
909
- // `triaged` is the run-local set of subject ids the decider actually
910
- // ruled on this run (built up across the authoring, build, and
911
- // whole-program stages, see its declaration above) — exactly the
912
- // `triagedThisRun` basis the "this run" triage ratio needs, since the
913
- // projected ledger carries no run identifier of its own. It excludes
914
- // ids that were merely sent but whose invocation failed or returned no
915
- // valid verdict.
916
- renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart), "utf8");
917
- if (isRepository) {
918
- await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
919
- }
829
+ return await runProgramBody();
920
830
  }
921
831
  catch (error) {
922
- log(`run report: could not write or commit ${reportPath}: ${error.message}`);
923
- }
924
- log(`run report: ${reportPath}`);
925
- return {
926
- programId: options.programId,
927
- complete,
928
- intermission,
929
- workstreams: results,
930
- escalations,
931
- reportPath,
932
- wholeProgramReview: wholeProgram,
933
- };
934
- async function runWorkstream(workstream) {
935
- const base = {
936
- id: workstream.id,
937
- name: workstream.name,
938
- outcome: { status: "failed", reason: "not attempted" },
939
- decisionIds: [],
940
- decisionErrors: [],
941
- buildAgentCommand: describeAgent(agent),
942
- };
943
- /** Every build-role spawn's transcript, for the report's per-workstream
944
- * Commands subsection (WS-02). Reviewer/decider spawns are excluded. */
945
- function recordTranscript(transcript) {
946
- if (transcript)
947
- (base.transcripts ??= []).push(transcript);
948
- }
949
- /** Names the JSONL file under build-logs/<programId>/, distinctly per spawn label. */
950
- function transcriptSink(label) {
951
- return { root, programId: options.programId, label: `${workstream.id}-${label}`, log };
952
- }
953
- let spec;
954
- try {
955
- spec = await readFile(join(root, workstream.taskFile), "utf8");
956
- }
957
- catch {
958
- base.outcome = {
959
- status: "parked",
960
- reason: `spec not found at ${workstream.taskFile}`,
961
- };
962
- return base;
963
- }
964
- workstream.status = "in_progress";
965
- await saveManifest(root, options.programId, manifest, { log });
966
- const baseCommit = isRepository
967
- ? await git.currentCommit(root)
968
- : undefined;
969
- let priorFailure;
970
- let priorDiagnosis;
971
- let implementerFingerprint;
972
- const attempts = [
973
- { agent, label: "implementer", role: "agent" },
974
- ];
975
- if (recovery && !recovery.borrowedImplementer) {
976
- attempts.push({ agent: recovery.agent, label: "recovery", role: "recoveryAgent" });
977
- // The third seat exists only when a reviewer can inform it. An
978
- // uninformed retry has already been spent (recovery); running the
979
- // roster again blind is a coin flip the ledger should hear about
980
- // instead. Alternation is deliberate the implementer returns with
981
- // the reviewer's diagnosis in hand, a composition neither prior
982
- // attempt had.
983
- if (reviewer)
984
- attempts.push({ agent, label: "informed retry", role: "agent" });
985
- }
986
- for (const [index, attempt] of attempts.entries()) {
987
- log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
988
- const brief = implementerBrief(manifest, workstream, spec, ledgerAtStart, config.permits?.policy, priorFailure, priorDiagnosis);
989
- let invocation;
990
- let spawnFailure;
991
- try {
992
- invocation = await invokeAgent(agentRunner, attempt.agent, brief, root, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")));
993
- }
994
- catch (error) {
995
- // The agent process never started — its command is wrong, missing,
996
- // or unrunnable. Nothing was built; this is the captured
997
- // silent-exit incident (SC-12). Make it a diagnosed failure via the
998
- // existing retry/park machinery below, never a process exit.
999
- spawnFailure =
1000
- `the ${attempt.label} agent could not be spawned: ` +
1001
- `${error.message}. The configured command ` +
1002
- `\`${describeAgent(attempt.agent)}\` did not start, so nothing was built.`;
1003
- invocation = { exitCode: 1, output: "" };
1004
- }
1005
- recordTranscript(invocation.transcript);
1006
- const summary = resolveSummary(invocation.output);
1007
- base.summary = summary.text;
1008
- await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
1009
- const parsed = extractDecisions(invocation.output);
1010
- base.decisionErrors.push(...parsed.errors);
1011
- await journalDecisions(workstream, parsed.decisions, baseCommit);
1012
- base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
1013
- let failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
1014
- // Deterministic protocol failures the runner can see without a model:
1015
- // no diagnosis is spent on them, because no read of the tree could add
1016
- // anything to what the runner already knows.
1017
- let deterministicFailure = false;
1018
- // A spawn failure takes precedence over whatever verifyAttempt saw
1019
- // (the untouched tree can verify green on its own) — no reviewer
1020
- // diagnosis is spent reading an empty diff.
1021
- if (spawnFailure !== undefined) {
1022
- failure = spawnFailure;
1023
- deterministicFailure = true;
1024
- }
1025
- // A brief that never reached the agent's stdin means the process
1026
- // crashed at startup — whatever it printed was not an answer to the
1027
- // brief, even when it exited 0.
1028
- if (failure === undefined && invocation.inputError !== undefined) {
1029
- failure =
1030
- `the brief could not be delivered to the agent's stdin ` +
1031
- `(${invocation.inputError}) — the agent likely crashed at startup, ` +
1032
- `so its reply answered nothing.`;
1033
- deterministicFailure = true;
1034
- }
1035
- // A green verify over an unchanged tree is just the baseline passing
1036
- // again, not built work. Without this, an agent that crashes at
1037
- // startup while exiting 0 (or replies without working) sweeps only
1038
- // the runner's own in_progress manifest write into a metadata-only
1039
- // commit and walks through the gate as "complete". A reply that never
1040
- // produces the work is a protocol failure, not an empty result.
1041
- if (failure === undefined && isRepository) {
1042
- const dirty = await git.dirtyPaths(root);
1043
- const meaningful = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
1044
- if (!meaningful) {
1045
- failure =
1046
- "the attempt verified green but changed nothing outside " +
1047
- "docs/programs/ — nothing was built, and the passing verify was " +
1048
- "the untouched baseline. A no-op attempt is a protocol failure " +
1049
- "(the agent may have crashed at startup or replied without " +
1050
- "working), not a completed workstream.";
1051
- deterministicFailure = true;
1052
- }
1053
- }
1054
- if (failure === undefined) {
1055
- let c0;
1056
- if (isRepository) {
1057
- // Unlike the authoring, run-report, and replay commits, this one
1058
- // stays a whole-tree sweep: an implementing agent touches whatever
1059
- // files the work required, and that set is exactly what the
1060
- // runner cannot know in advance. c0 is the green anchor the test
1061
- // critique's fix loop resets to on a failing fix (SC-07).
1062
- c0 = await git.commitAll(root, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`);
1063
- }
1064
- const critique = isRepository && c0 !== undefined
1065
- ? await runTestCritique({
1066
- root,
1067
- manifest,
1068
- workstream,
1069
- spec,
1070
- config,
1071
- agentRunner,
1072
- permits,
1073
- verifyRunner,
1074
- git,
1075
- reviewer,
1076
- agent,
1077
- baseCommit,
1078
- greenCommit: c0,
1079
- now,
1080
- log,
1081
- transcriptSink,
1082
- })
1083
- : undefined;
1084
- // The manifest's single commit field records the workstream's final
1085
- // verified state — after any kept fix, that is the fix commit, not
1086
- // the earlier green one.
1087
- let finalCommit = critique?.finalCommit ?? c0;
1088
- if (critique && critique.stageErrors.length > 0) {
1089
- (base.stageErrors ??= []).push(...critique.stageErrors);
1090
- }
1091
- for (const transcript of critique?.transcripts ?? [])
1092
- recordTranscript(transcript);
1093
- // Findings anchor to c0 (the green, pre-critique commit) — the
1094
- // honest rollback point — while the decider below diffs from
1095
- // baseCommit (pre-workstream), so it sees the whole workstream.
1096
- const findingEvents = findingsToLedgerEvents({
1097
- workstreamId: workstream.id,
1098
- findings: (critique?.outcome.open ?? []).filter(hasRoutableEvidence),
1099
- ...(c0 === undefined ? {} : { baseCommit: c0 }),
1100
- now,
832
+ const finalizeStart = monotonic();
833
+ await recorder.finalize();
834
+ recorder.point({
835
+ kind: "analytics-overhead",
836
+ coverage: "observed",
837
+ value: monotonic() - finalizeStart,
838
+ unit: "ms",
839
+ label: "finalize",
840
+ });
841
+ throw error;
842
+ }
843
+ async function runProgramBody() {
844
+ // Authoring runs before building: every workstream whose spec is missing
845
+ // gets one, in dependency order, before anything is implemented. It
846
+ // reloads the manifest afterward because authoring may have merged
847
+ // discovered dependency edges or parked workstreams it could not author.
848
+ const authorResult = await authorProgram({
849
+ cwd: options.cwd,
850
+ programId: options.programId,
851
+ config,
852
+ agentRunner,
853
+ permits,
854
+ git,
855
+ log,
856
+ now,
857
+ reviewed,
858
+ triaged,
859
+ recorder,
860
+ });
861
+ manifest = await loadManifest(root, options.programId);
862
+ // Loaded once so every brief in this run projects the same picture of
863
+ // human-decided and ratified choices; decisions this run itself journals
864
+ // are picked up fresh by `readDecisionLedger` at the end, for escalations.
865
+ const ledgerAtStart = await readDecisionLedger(root, options.programId);
866
+ const ordered = stableTopologicalOrder(manifest.workstreams);
867
+ const results = [];
868
+ // Every workstream whose spec authoring failed or parked. Seeds `blocked`
869
+ // below (their briefs would be missing a producer's spec) and also guards
870
+ // the awaiting_human branch in the build loop: an authoring failure must
871
+ // never be repainted as a planned wait — see that branch's own comment.
872
+ const authoringFailed = new Set(authorResult.results
873
+ .filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
874
+ .map((entry) => entry.id));
875
+ const blocked = new Set(authoringFailed);
876
+ // The awaiting cone: every workstream directly referencing an unmet
877
+ // prerequisite, plus everything transitively downstream of one — the same
878
+ // downstream-cone machinery failure isolation uses, with the semantics
879
+ // inverted (no agent spawned, no retry, no diagnosis; see the build loop).
880
+ const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
881
+ const directlyAwaiting = new Set(manifest.workstreams
882
+ .filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
883
+ .map((workstream) => workstream.id));
884
+ const awaitingCone = new Set([
885
+ ...directlyAwaiting,
886
+ ...downstreamCone(manifest.workstreams, [...directlyAwaiting]),
887
+ ]);
888
+ // Set when a workstream's verify failure reproduced the pre-run baseline:
889
+ // the environment is broken, so every remaining workstream parks instead
890
+ // of spending its budget on the same crash.
891
+ let environmentalHalt = false;
892
+ for (const workstream of ordered) {
893
+ if (workstream.status === "complete") {
894
+ results.push({
895
+ id: workstream.id,
896
+ name: workstream.name,
897
+ outcome: { status: "skipped", reason: "already complete" },
898
+ decisionIds: [],
899
+ decisionErrors: [],
1101
900
  });
1102
- await appendLedgerEvents(root, options.programId, findingEvents);
1103
- // The commit's own sha cannot be part of the tree it commits, so the
1104
- // manifest records it only now — swept forward into whatever commits
1105
- // next. Replay reads the manifest's current state, not the commit
1106
- // that last touched it, so this lag is harmless.
1107
- workstream.status = "complete";
1108
- if (finalCommit !== undefined)
1109
- workstream.commit = finalCommit;
1110
- await saveManifest(root, options.programId, manifest, { log });
1111
- {
1112
- const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1113
- if (spawnErrors.length > 0)
1114
- (base.stageErrors ??= []).push(...spawnErrors);
1115
- }
1116
- // Findings are triaged against the pre-workstream diff (baseCommit),
1117
- // the same one the decider reviews decisions against — c0/finalCommit
1118
- // are the fix loop's own rollback anchors, a different thing.
1119
- {
1120
- const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1121
- if (spawnErrors.length > 0)
1122
- (base.stageErrors ??= []).push(...spawnErrors);
1123
- }
1124
- // A fix-now triage drives exactly one bounded fix attempt, here —
1125
- // after the triage that produced it, before the run advances. Only
1126
- // reachable when there is a green commit to attempt from and a
1127
- // decider that could have produced a fix-now verdict in the first
1128
- // place (SC-13).
1129
- if (finalCommit !== undefined && decider) {
1130
- const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit, transcriptSink("fix-now-fix"));
1131
- recordTranscript(driven.transcript);
1132
- if (driven.spawnError !== undefined) {
1133
- (base.stageErrors ??= []).push(driven.spawnError);
1134
- }
1135
- if (driven.commit !== finalCommit) {
1136
- finalCommit = driven.commit;
1137
- workstream.commit = finalCommit;
1138
- await saveManifest(root, options.programId, manifest, { log });
1139
- }
1140
- }
1141
- if (critique)
1142
- base.testCritique = critique.outcome;
1143
- base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
1144
- base.outcome = {
1145
- status: "complete",
1146
- ...(finalCommit === undefined ? {} : { commit: finalCommit }),
1147
- };
1148
- return base;
1149
- }
1150
- priorFailure = failure;
1151
- log(`${workstream.id}: ${attempt.label} attempt failed — ${failure}`);
1152
- // A failure identical to the pre-run baseline (up to counts and
1153
- // timings) was there before any workstream ran: environmental, not
1154
- // this workstream's work. No retry or diagnosis is spent on it, and
1155
- // the caller halts the run — every later workstream would be charged
1156
- // for the same broken environment. Workstreams resumed with their own
1157
- // leftover failure in the tree are exempt (see resumedIds).
1158
- if (baselineFingerprint !== undefined &&
1159
- !resumedIds.has(workstream.id) &&
1160
- failureFingerprint(failure) === baselineFingerprint) {
1161
- workstream.status = "failed";
1162
- await saveManifest(root, options.programId, manifest, { log });
1163
- {
1164
- const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1165
- if (spawnErrors.length > 0)
1166
- (base.stageErrors ??= []).push(...spawnErrors);
1167
- }
1168
- base.environmental = true;
1169
- base.outcome = {
1170
- status: "failed",
1171
- reason: failure +
1172
- "\n\nThis failure is identical (up to counts and timings) to the " +
1173
- "verification failure recorded on the untouched tree before the " +
1174
- "run began — the environment was broken before this workstream " +
1175
- "ran. No retry was spent, and the run halted here.",
1176
- };
1177
- return base;
1178
- }
1179
- // The informed retry is spent only when the diagnosed recovery attempt
1180
- // moved the failure at all. A failure reproduced identically after a
1181
- // diagnosis says the roster is stuck, not unlucky — that belongs in
1182
- // the ledger, not in a third spawn.
1183
- const stuck = attempt.label === "recovery" &&
1184
- index < attempts.length - 1 &&
1185
- implementerFingerprint !== undefined &&
1186
- failureFingerprint(failure) === implementerFingerprint;
1187
- if (index === attempts.length - 1 || stuck) {
1188
- workstream.status = "failed";
1189
- await saveManifest(root, options.programId, manifest, { log });
1190
- // The work stays in the tree for a resume; decisions made on the way
1191
- // to a failure are still journaled and still reviewable.
1192
- {
1193
- const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1194
- if (spawnErrors.length > 0)
1195
- (base.stageErrors ??= []).push(...spawnErrors);
1196
- }
1197
- base.outcome = {
1198
- status: "failed",
1199
- reason: stuck
1200
- ? failure +
1201
- "\n\nThe recovery attempt, briefed with a reviewer diagnosis, " +
1202
- "reproduced the implementer's failure (identical up to counts " +
1203
- "and timings), so the final retry was not spent."
1204
- : failure,
1205
- };
1206
- return base;
1207
- }
1208
- if (attempt.label === "implementer") {
1209
- implementerFingerprint = failureFingerprint(failure);
901
+ continue;
1210
902
  }
1211
- // Diagnose the failure for the next attempt's brief. Overwrites any
1212
- // prior diagnosis — a read of an older failure must not be pinned to
1213
- // a newer one. Deterministic failures (no-op, undelivered brief) are
1214
- // never diagnosed: the runner already knows exactly what happened,
1215
- // and a reviewer read of an empty diff could only muddy it.
1216
- if (reviewer && !deterministicFailure) {
1217
- const diagnosed = await diagnoseFailure({
1218
- root,
1219
- manifest,
1220
- workstream,
1221
- spec,
1222
- agentRunner,
1223
- permits,
1224
- reviewer,
1225
- git,
1226
- baseCommit,
1227
- failure,
1228
- observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
903
+ if (environmentalHalt) {
904
+ workstream.status = "parked";
905
+ results.push({
906
+ id: workstream.id,
907
+ name: workstream.name,
908
+ outcome: {
909
+ status: "parked",
910
+ reason: "the run halted on an environmental verification failure; parked, not attempted",
911
+ },
912
+ decisionIds: [],
913
+ decisionErrors: [],
1229
914
  });
1230
- priorDiagnosis = diagnosed.diagnosis;
1231
- if (diagnosed.spawnError !== undefined) {
1232
- (base.stageErrors ??= []).push(diagnosed.spawnError);
1233
- log(`${workstream.id}: ${diagnosed.spawnError}`);
1234
- }
1235
- await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
1236
- }
1237
- else {
1238
- priorDiagnosis = undefined;
915
+ continue;
1239
916
  }
1240
- if (priorDiagnosis !== undefined) {
1241
- (base.failureDiagnoses ??= []).push({
1242
- attempt: attempt.label,
1243
- verdict: priorDiagnosis,
917
+ // Authoring failures take precedence over awaiting: a workstream whose
918
+ // spec authoring failed or parked must fall through to the `blocked`
919
+ // branch below and render as the genuine failure it is, never be
920
+ // repainted `awaiting_human` — an authoring failure has no other
921
+ // representation in `results` than that branch's own `parked` result, so
922
+ // rewriting it here would erase the only trace of it and the run would
923
+ // wrongly look like a pure intermission. A workstream that is merely
924
+ // downstream of a *build* failure and also in the awaiting cone still
925
+ // resolves to awaiting_human here; that is safe because the build
926
+ // failure self-reports `failed` in `results`, so the run is classified a
927
+ // partial regardless (see the intermission classification below).
928
+ if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
929
+ workstream.status = "awaiting_human";
930
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
931
+ const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
932
+ const reason = ownUnmet.length > 0
933
+ ? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
934
+ : "an upstream workstream is awaiting a human prerequisite; not attempted.";
935
+ results.push({
936
+ id: workstream.id,
937
+ name: workstream.name,
938
+ outcome: { status: "awaiting_human", reason },
939
+ decisionIds: [],
940
+ decisionErrors: [],
1244
941
  });
1245
- log(`${workstream.id}: reviewer diagnosed the ${attempt.label} failure`);
942
+ continue;
1246
943
  }
1247
- }
1248
- return base;
1249
- }
1250
- async function journalDecisions(workstream, decisions, baseCommit) {
1251
- const events = decisions.map((decision) => ({
1252
- kind: "decision-recorded",
1253
- at: now().toISOString(),
1254
- id: decisionFingerprint(workstream.id, decision),
1255
- workstream: workstream.id,
1256
- decision,
1257
- ...(baseCommit === undefined ? {} : { baseCommit }),
1258
- decidedBy: "implementer",
1259
- }));
1260
- await appendLedgerEvents(root, options.programId, events);
1261
- for (const decision of decisions) {
1262
- log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
1263
- }
1264
- }
1265
- /**
1266
- * Journals `permission-denied` events for one spawn (WS-03) — every
1267
- * workstream-attributed spawn calls this once, passing its own transcript
1268
- * and (for build roles only) the agent's `needsPermission` self-reports.
1269
- * A no-op when the spawn produced neither.
1270
- */
1271
- async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
1272
- const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
1273
- if (events.length === 0)
1274
- return;
1275
- await appendLedgerEvents(root, options.programId, events);
1276
- for (const event of events) {
1277
- if (event.kind !== "permission-denied")
944
+ if (blocked.has(workstream.id)) {
945
+ workstream.status = "parked";
946
+ results.push({
947
+ id: workstream.id,
948
+ name: workstream.name,
949
+ outcome: {
950
+ status: "parked",
951
+ reason: "an upstream dependency failed; parked, not attempted",
952
+ },
953
+ decisionIds: [],
954
+ decisionErrors: [],
955
+ });
1278
956
  continue;
1279
- log(`${workstreamId} ${attempt}: denied \`${event.command}\` (${event.source})`);
957
+ }
958
+ const result = await runWorkstream(workstream);
959
+ results.push(result);
960
+ if (result.environmental === true)
961
+ environmentalHalt = true;
962
+ if (result.outcome.status === "failed") {
963
+ // Failure isolation: park the downstream cone, keep building the rest.
964
+ for (const id of downstreamCone(manifest.workstreams, [workstream.id])) {
965
+ blocked.add(id);
966
+ }
967
+ }
1280
968
  }
1281
- }
1282
- function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
1283
- return reviewDecisions({
969
+ const complete = results.every((result) => result.outcome.status === "complete" ||
970
+ result.outcome.status === "skipped");
971
+ const anyAwaiting = results.some((result) => result.outcome.status === "awaiting_human");
972
+ const anyFailedOrParked = results.some((result) => result.outcome.status === "failed" || result.outcome.status === "parked");
973
+ // Author-stage failures are counted too: an authoring failure the awaiting
974
+ // guard did NOT catch — e.g. a workstream that authored fine but sits
975
+ // downstream of an authoring failure — must still sink the intermission.
976
+ // Same set that seeded `blocked`/guarded the awaiting branch above;
977
+ // classifying on it here as well makes "any genuine failure => ordinary
978
+ // partial" true independent of how the cone painted it.
979
+ const anyAuthoringFailure = authorResult.results.some((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked");
980
+ // A planned intermission: nothing failed anywhere (build or authoring), at
981
+ // least one workstream is waiting on a human, and not everything built. A
982
+ // genuine failure makes it an ordinary partial instead — the human should
983
+ // read a diagnosis, not a checklist.
984
+ const intermission = !complete && anyAwaiting && !anyFailedOrParked && !anyAuthoringFailure;
985
+ manifest.program.status = complete
986
+ ? "complete"
987
+ : intermission
988
+ ? "awaiting_human"
989
+ : "partial";
990
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
991
+ const wholeProgram = await runWholeProgramStage({
1284
992
  root,
1285
993
  programId: options.programId,
1286
994
  manifest,
1287
- workstreamId,
1288
- decisions,
1289
- baseCommit,
1290
- decider,
995
+ config,
1291
996
  agentRunner,
1292
997
  permits,
1293
998
  git,
1294
999
  isRepository,
1000
+ results,
1001
+ authorResult,
1002
+ runStartCommit,
1003
+ decider,
1295
1004
  reviewed,
1296
- onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1297
1005
  triaged,
1298
1006
  now,
1299
1007
  log,
1300
- observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1008
+ recorder,
1301
1009
  });
1302
- }
1303
- function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
1304
- return triageFindings({
1010
+ const ledger = await readDecisionLedger(root, options.programId);
1011
+ const escalations = escalatedRecords(ledger);
1012
+ // Runs after the ledger read above, so its brief already sees this run's
1013
+ // own whole-program findings/rulings, and before report assembly, so its
1014
+ // section renders next to WS-04's own (§3.2 of the WS-05 spec). Read-only
1015
+ // and fail-open: nothing here can change `results`, `ledger`, the run's
1016
+ // outcome, or the exit code.
1017
+ const causalAnalysis = await runCausalAnalysisStage({
1305
1018
  root,
1306
1019
  programId: options.programId,
1307
1020
  manifest,
1308
- workstreamId,
1309
- findings,
1310
- baseCommit,
1311
- decider,
1021
+ config,
1312
1022
  agentRunner,
1313
1023
  permits,
1314
1024
  git,
1315
1025
  isRepository,
1316
- reviewed,
1317
- observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1318
- onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1319
- triaged,
1320
- now,
1026
+ results,
1027
+ runStartCommit,
1028
+ ledger,
1321
1029
  log,
1030
+ recorder,
1322
1031
  });
1323
- }
1324
- /**
1325
- * Drives the one bounded fix attempt a `fix-now` triage earns (WS-06).
1326
- * Selects findings from the ledger projection never from any in-memory
1327
- * disposition so a human-decided finding (status `"human-decided"`, not
1328
- * `"fix-now"`) is never handed to the fix seam (SC-14). A workstream's
1329
- * `fix-now` findings are fixed together, in one `attemptFix` call, then
1330
- * recorded as one `finding-fix-attempted` event per finding id — a ledger
1331
- * entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
1332
- * commit; never throws, never blocks the run (SC-15).
1333
- */
1334
- async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
1335
- const ledger = await readDecisionLedger(root, options.programId);
1336
- const fixNowIds = new Set(ledger.findings
1337
- .filter((record) => record.workstream === workstream.id && record.status === "fix-now")
1338
- .map((record) => record.id));
1339
- if (fixNowIds.size === 0)
1340
- return { commit: greenCommit };
1341
- const toFix = [];
1342
- const matchedIds = [];
1343
- for (const candidate of openFindings) {
1344
- const id = fingerprint({ ...candidate, workstreamId: workstream.id });
1345
- if (!fixNowIds.has(id))
1346
- continue;
1347
- toFix.push(candidate);
1348
- matchedIds.push(id);
1032
+ const reportPath = runReportPath(root, options.programId);
1033
+ // Rendering is the last stage the recorder can actually observe (SC-03):
1034
+ // captured as its own span, nested under the run span, rather than folded
1035
+ // silently into it. Kept in its own try/catch, separate from the write and
1036
+ // commit below, so a rendering bug still lets `finalize()` run and still
1037
+ // lets the write/commit attempt proceed against whatever report content
1038
+ // *is* available a rendering failure is a logged line, not a thrown run,
1039
+ // exactly like the write/commit failure path below (SC-05, SC-12).
1040
+ let reportContent;
1041
+ try {
1042
+ const reportSpan = recorder.span({ stage: "report-assembly" });
1043
+ // `triaged` is the run-local set of subject ids the decider actually
1044
+ // ruled on this run (built up across the authoring, build, and
1045
+ // whole-program stages, see its declaration above) — exactly the
1046
+ // `triagedThisRun` basis the "this run" triage ratio needs, since the
1047
+ // projected ledger carries no run identifier of its own. It excludes
1048
+ // ids that were merely sent but whose invocation failed or returned no
1049
+ // valid verdict.
1050
+ // Computed before renderRunReport (§3.2/§3.9): renderAnalyticsSection is
1051
+ // WS-04's own never-throwing isolation boundary, so an analytics bug can
1052
+ // never propagate into this try's catch below and leave reportContent
1053
+ // undefined which would suppress the entire report (SC-13). The
1054
+ // recorder's snapshot, not the finalized file, is the only artifact that
1055
+ // exists at this point in the sequence (finalize() runs later, below).
1056
+ // Passed as a thunk, not called eagerly here: `recorder.snapshot()` is
1057
+ // documented as non-throwing, but evaluating it as a plain argument would
1058
+ // run it *before* renderAnalyticsSection's own try/catch is reached, so a
1059
+ // violation of that contract would still escape this boundary.
1060
+ const analyticsSection = renderAnalyticsSection(() => recorder.snapshot());
1061
+ // The causal section's own "observed measurements" layer is rendered
1062
+ // from a *later* snapshot than the one the analyzer was briefed with
1063
+ // (`causalAnalysis` above) — one that now includes the analyzer's own
1064
+ // `causal-analysis` span — exactly as WS-04's own section above it does.
1065
+ // The two differ by exactly the analyzer's own cost; the section states
1066
+ // that boundary explicitly (§3.2).
1067
+ const causalAnalysisSection = renderCausalAnalysisSection(causalAnalysis.outcome, () => recorder.snapshot());
1068
+ reportContent = renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart, analyticsSection, causalAnalysisSection);
1069
+ reportSpan.close({ outcome: "success" });
1349
1070
  }
1350
- // An id triaged fix-now but absent from the open set (should not
1351
- // happen it is where the id came from) is skipped, fail-open.
1352
- if (toFix.length === 0)
1353
- return { commit: greenCommit };
1354
- const fix = await attemptFix({
1355
- root,
1356
- programId: options.programId,
1357
- workstream,
1358
- spec,
1359
- config,
1360
- agentRunner,
1361
- permits,
1362
- verifyRunner,
1363
- git,
1364
- agent,
1365
- findings: toFix,
1366
- greenCommit,
1367
- label: "fix-now fix",
1368
- log,
1369
- now,
1370
- observe,
1071
+ catch (error) {
1072
+ log(`run report: could not render ${reportPath}: ${error.message}`);
1073
+ }
1074
+ // The `report-commit` span (the stage STAGE_BUCKET already reserves for
1075
+ // exactly this) covers writing the report to disk and committing
1076
+ // docs/programs, nested explicitly under `runSpan` rather than the
1077
+ // implicit stack, since `reportSpan` above has already closed and popped
1078
+ // itself. Both this span and its parent `runSpan` are opened/left open
1079
+ // rather than closed here, because the write and the commit they describe
1080
+ // happen *after* `finalize()` below persists this very artifact — closing
1081
+ // them now would stamp an `endOffsetMs` that is a lie the moment the write
1082
+ // or commit takes any measurable time at all. An absent `endOffsetMs` is
1083
+ // exactly the schema's existing "true end not observed" semantics; the
1084
+ // run's actual outcome classification is recorded as a point observation
1085
+ // instead of a span-close result, since it — unlike an interval's end — is
1086
+ // a fact already known at this instant.
1087
+ runSpan.child({ stage: "report-commit" });
1088
+ recorder.point({
1089
+ kind: "run-outcome",
1090
+ coverage: "observed",
1091
+ dimensions: { stage: "run", outcome: complete ? "success" : intermission ? "no-op" : "failed" },
1371
1092
  });
1372
- // "kept" only when the attempt both verified clean and actually landed a
1373
- // commit a clean-but-empty attempt is a decline, not a fix, and must
1374
- // reach the human exactly like a failed verification does.
1375
- const kept = fix.outcome === "kept" && fix.commit !== undefined;
1376
- const note = kept
1377
- ? fix.summary
1378
- : fix.outcome === "kept"
1379
- ? `${fix.summary} (the attempt verified clean but made no change; no fix landed)`
1380
- : `${fix.summary} (fix failed verification and was discarded: ${fix.failure})`;
1381
- const events = matchedIds.map((id) => ({
1382
- kind: "finding-fix-attempted",
1383
- at: now().toISOString(),
1384
- id,
1385
- outcome: kept ? "kept" : "failed",
1386
- note,
1387
- ...(kept && fix.commit !== undefined ? { commit: fix.commit } : {}),
1388
- attemptedBy: "implementer",
1389
- }));
1390
- await appendLedgerEvents(root, options.programId, events);
1391
- log(kept
1392
- ? `${workstream.id}: fix-now fix verified and committed`
1393
- : `${workstream.id}: fix-now fix failed and was escalated ${note}`);
1394
- const resultCommit = kept ? fix.greenCommit : greenCommit;
1093
+ // Finalized once every stage the recorder can observe closing has closed
1094
+ // (report-assembly above; report-commit and run intentionally remain
1095
+ // open see above). Still runs before the report is written and
1096
+ // committed, so the artifact rides into the same `docs/programs` commit
1097
+ // (SC-02, SC-14). Fail-open: never throws, never delays or blocks the
1098
+ // report below.
1099
+ const finalizeStart = monotonic();
1100
+ await recorder.finalize();
1101
+ // `finalize()` seals the point/span arrays before its own duration is
1102
+ // known, so an overhead measure around it can never live in the artifact
1103
+ // it measures (§3.4.d) — emitted after, as a non-conserved sidecar-only
1104
+ // point: no `stage`, never summed into any bucket.
1105
+ recorder.point({
1106
+ kind: "analytics-overhead",
1107
+ coverage: "observed",
1108
+ value: monotonic() - finalizeStart,
1109
+ unit: "ms",
1110
+ label: "finalize",
1111
+ });
1112
+ // Every workstream verdict, `complete`, and the exit-code mapping are
1113
+ // already settled above; writing and committing the report is bookkeeping
1114
+ // that must not be able to reject `runProgram` after the fact (SC-05,
1115
+ // SC-12) a full disk or an EISDIR here is a logged line, not a thrown
1116
+ // run.
1117
+ if (reportContent !== undefined) {
1118
+ try {
1119
+ await writeFile(reportPath, reportContent, "utf8");
1120
+ if (isRepository) {
1121
+ await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
1122
+ }
1123
+ // A best-effort forensic breadcrumb for the one boundary the
1124
+ // already-committed canonical artifact can never describe: `point()`
1125
+ // still appends to the gitignored sidecar once armed even though
1126
+ // `finalize()` has already run, and never touches the canonical JSON
1127
+ // again, so the working tree stays clean after a normal run.
1128
+ recorder.point({
1129
+ kind: "report-commit-result",
1130
+ coverage: "observed",
1131
+ dimensions: { stage: "report-commit", outcome: "success" },
1132
+ });
1133
+ }
1134
+ catch (error) {
1135
+ log(`run report: could not write or commit ${reportPath}: ${error.message}`);
1136
+ recorder.point({
1137
+ kind: "report-commit-result",
1138
+ coverage: "observed",
1139
+ dimensions: { stage: "report-commit", outcome: "failed" },
1140
+ });
1141
+ }
1142
+ }
1143
+ log(`run report: ${reportPath}`);
1395
1144
  return {
1396
- commit: resultCommit,
1397
- ...(fix.spawnError === undefined ? {} : { spawnError: fix.spawnError }),
1398
- ...(fix.transcript === undefined ? {} : { transcript: fix.transcript }),
1145
+ programId: options.programId,
1146
+ complete,
1147
+ intermission,
1148
+ workstreams: results,
1149
+ escalations,
1150
+ reportPath,
1151
+ wholeProgramReview: wholeProgram,
1152
+ causalAnalysis,
1399
1153
  };
1154
+ async function runWorkstream(workstream) {
1155
+ const base = {
1156
+ id: workstream.id,
1157
+ name: workstream.name,
1158
+ outcome: { status: "failed", reason: "not attempted" },
1159
+ decisionIds: [],
1160
+ decisionErrors: [],
1161
+ buildAgentCommand: describeAgent(agent),
1162
+ };
1163
+ /** Every build-role spawn's transcript, for the report's per-workstream
1164
+ * Commands subsection (WS-02). Reviewer/decider spawns are excluded. */
1165
+ function recordTranscript(transcript) {
1166
+ if (transcript)
1167
+ (base.transcripts ??= []).push(transcript);
1168
+ }
1169
+ /** Names the JSONL file under build-logs/<programId>/, distinctly per spawn label. */
1170
+ function transcriptSink(label) {
1171
+ return { root, programId: options.programId, label: `${workstream.id}-${label}`, log };
1172
+ }
1173
+ let spec;
1174
+ try {
1175
+ spec = await readFile(join(root, workstream.taskFile), "utf8");
1176
+ }
1177
+ catch {
1178
+ base.outcome = {
1179
+ status: "parked",
1180
+ reason: `spec not found at ${workstream.taskFile}`,
1181
+ };
1182
+ return base;
1183
+ }
1184
+ workstream.status = "in_progress";
1185
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1186
+ const baseCommit = isRepository
1187
+ ? await git.currentCommit(root)
1188
+ : undefined;
1189
+ let priorFailure;
1190
+ let priorDiagnosis;
1191
+ let implementerFingerprint;
1192
+ const attempts = [
1193
+ { agent, label: "implementer", role: "agent" },
1194
+ ];
1195
+ if (recovery && !recovery.borrowedImplementer) {
1196
+ attempts.push({ agent: recovery.agent, label: "recovery", role: "recoveryAgent" });
1197
+ // The third seat exists only when a reviewer can inform it. An
1198
+ // uninformed retry has already been spent (recovery); running the
1199
+ // roster again blind is a coin flip the ledger should hear about
1200
+ // instead. Alternation is deliberate — the implementer returns with
1201
+ // the reviewer's diagnosis in hand, a composition neither prior
1202
+ // attempt had.
1203
+ if (reviewer)
1204
+ attempts.push({ agent, label: "informed retry", role: "agent" });
1205
+ }
1206
+ for (const [index, attempt] of attempts.entries()) {
1207
+ log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
1208
+ const { brief, components } = implementerBrief(manifest, workstream, spec, ledgerAtStart, config.permits?.policy, priorFailure, priorDiagnosis);
1209
+ for (const point of promptComponentSizePoints(components, { role: attempt.role })) {
1210
+ recorder.point(point);
1211
+ }
1212
+ // Why this repetition happened (WS-02, SC-05/SC-06): the first attempt
1213
+ // is always `initial`; a later seat is `informed-by-diagnosis` when the
1214
+ // prior attempt's failure was read by the reviewer, else a plain
1215
+ // `verify-failure` retry (a deterministic failure skips diagnosis —
1216
+ // see below — so the next seat still spawns, just undiagnosed).
1217
+ const seatStage = STAGE_FOR_SEAT[attempt.label] ?? "implementer";
1218
+ const attemptReason = index === 0 ? "initial" : priorDiagnosis !== undefined ? "informed-by-diagnosis" : "verify-failure";
1219
+ /** This attempt's eventual `attempt-outcome` disposition (WS-02, §3.3)
1220
+ * — distinct from the spawn span's own success/failed outcome (an
1221
+ * undelivered-brief or green-but-empty attempt spawns "successfully"
1222
+ * but disposes as `no-op`/`discarded`). Set once, at the point each
1223
+ * failure reason is detected below; `undefined` means the attempt was
1224
+ * kept. */
1225
+ let dispositionOutcome;
1226
+ const emitAttemptOutcome = (outcome, reasonOverride) => {
1227
+ recorder.point({
1228
+ kind: "attempt-outcome",
1229
+ coverage: "observed",
1230
+ dimensions: {
1231
+ stage: seatStage,
1232
+ workstream: workstream.id,
1233
+ role: attempt.role,
1234
+ attemptSeat: attempt.label,
1235
+ attemptIndex: index,
1236
+ attemptReason: reasonOverride ?? attemptReason,
1237
+ outcome,
1238
+ },
1239
+ });
1240
+ };
1241
+ let invocation;
1242
+ let spawnFailure;
1243
+ try {
1244
+ invocation = await invokeAgent(agentRunner, attempt.agent, brief, root, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")), recorder, { stage: seatStage, workstream: workstream.id, attemptSeat: attempt.label, attemptIndex: index, attemptReason });
1245
+ }
1246
+ catch (error) {
1247
+ // The agent process never started — its command is wrong, missing,
1248
+ // or unrunnable. Nothing was built; this is the captured
1249
+ // silent-exit incident (SC-12). Make it a diagnosed failure via the
1250
+ // existing retry/park machinery below, never a process exit.
1251
+ spawnFailure =
1252
+ `the ${attempt.label} agent could not be spawned: ` +
1253
+ `${error.message}. The configured command ` +
1254
+ `\`${describeAgent(attempt.agent)}\` did not start, so nothing was built.`;
1255
+ invocation = { exitCode: 1, output: "" };
1256
+ }
1257
+ recordTranscript(invocation.transcript);
1258
+ const summary = resolveSummary(invocation.output);
1259
+ base.summary = summary.text;
1260
+ await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
1261
+ const parsed = extractDecisions(invocation.output);
1262
+ base.decisionErrors.push(...parsed.errors);
1263
+ await journalDecisions(workstream, parsed.decisions, baseCommit);
1264
+ base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
1265
+ let failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
1266
+ programId: options.programId,
1267
+ phase: `post-${attempt.label.replaceAll(" ", "-")}`,
1268
+ workstream: workstream.id,
1269
+ role: attempt.role,
1270
+ attemptSeat: attempt.label,
1271
+ attemptIndex: index,
1272
+ });
1273
+ // Deterministic protocol failures the runner can see without a model:
1274
+ // no diagnosis is spent on them, because no read of the tree could add
1275
+ // anything to what the runner already knows.
1276
+ let deterministicFailure = false;
1277
+ // A spawn failure takes precedence over whatever verifyAttempt saw
1278
+ // (the untouched tree can verify green on its own) — no reviewer
1279
+ // diagnosis is spent reading an empty diff.
1280
+ if (spawnFailure !== undefined) {
1281
+ failure = spawnFailure;
1282
+ deterministicFailure = true;
1283
+ dispositionOutcome = "failed";
1284
+ }
1285
+ // A brief that never reached the agent's stdin means the process
1286
+ // crashed at startup — whatever it printed was not an answer to the
1287
+ // brief, even when it exited 0.
1288
+ if (failure === undefined && invocation.inputError !== undefined) {
1289
+ failure =
1290
+ `the brief could not be delivered to the agent's stdin ` +
1291
+ `(${invocation.inputError}) — the agent likely crashed at startup, ` +
1292
+ `so its reply answered nothing.`;
1293
+ deterministicFailure = true;
1294
+ dispositionOutcome = "no-op";
1295
+ }
1296
+ // A green verify over an unchanged tree is just the baseline passing
1297
+ // again, not built work. Without this, an agent that crashes at
1298
+ // startup while exiting 0 (or replies without working) sweeps only
1299
+ // the runner's own in_progress manifest write into a metadata-only
1300
+ // commit and walks through the gate as "complete". A reply that never
1301
+ // produces the work is a protocol failure, not an empty result.
1302
+ if (failure === undefined && isRepository) {
1303
+ const dirty = await git.dirtyPaths(root);
1304
+ const meaningful = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
1305
+ if (!meaningful) {
1306
+ failure =
1307
+ "the attempt verified green but changed nothing outside " +
1308
+ "docs/programs/ — nothing was built, and the passing verify was " +
1309
+ "the untouched baseline. A no-op attempt is a protocol failure " +
1310
+ "(the agent may have crashed at startup or replied without " +
1311
+ "working), not a completed workstream.";
1312
+ deterministicFailure = true;
1313
+ dispositionOutcome = "discarded";
1314
+ }
1315
+ }
1316
+ // A genuine verify failure (not spawn/inputError/green-empty) disposes
1317
+ // as `failed`, same as every other undetermined failure reason.
1318
+ if (failure !== undefined && dispositionOutcome === undefined) {
1319
+ dispositionOutcome = "failed";
1320
+ }
1321
+ if (failure === undefined) {
1322
+ let c0;
1323
+ if (isRepository) {
1324
+ // Unlike the authoring, run-report, and replay commits, this one
1325
+ // stays a whole-tree sweep: an implementing agent touches whatever
1326
+ // files the work required, and that set is exactly what the
1327
+ // runner cannot know in advance. c0 is the green anchor the test
1328
+ // critique's fix loop resets to on a failing fix (SC-07).
1329
+ c0 = await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(root, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`), commitEvidence);
1330
+ }
1331
+ const critique = isRepository && c0 !== undefined
1332
+ ? await runTestCritique({
1333
+ root,
1334
+ manifest,
1335
+ workstream,
1336
+ spec,
1337
+ config,
1338
+ agentRunner,
1339
+ permits,
1340
+ verifyRunner,
1341
+ git,
1342
+ reviewer,
1343
+ agent,
1344
+ baseCommit,
1345
+ greenCommit: c0,
1346
+ now,
1347
+ log,
1348
+ transcriptSink,
1349
+ recorder,
1350
+ })
1351
+ : undefined;
1352
+ // The manifest's single commit field records the workstream's final
1353
+ // verified state — after any kept fix, that is the fix commit, not
1354
+ // the earlier green one.
1355
+ let finalCommit = critique?.finalCommit ?? c0;
1356
+ if (critique && critique.stageErrors.length > 0) {
1357
+ (base.stageErrors ??= []).push(...critique.stageErrors);
1358
+ }
1359
+ for (const transcript of critique?.transcripts ?? [])
1360
+ recordTranscript(transcript);
1361
+ // Findings anchor to c0 (the green, pre-critique commit) — the
1362
+ // honest rollback point — while the decider below diffs from
1363
+ // baseCommit (pre-workstream), so it sees the whole workstream.
1364
+ const findingEvents = findingsToLedgerEvents({
1365
+ workstreamId: workstream.id,
1366
+ findings: (critique?.outcome.open ?? []).filter(hasRoutableEvidence),
1367
+ ...(c0 === undefined ? {} : { baseCommit: c0 }),
1368
+ now,
1369
+ });
1370
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, findingEvents));
1371
+ // The commit's own sha cannot be part of the tree it commits, so the
1372
+ // manifest records it only now — swept forward into whatever commits
1373
+ // next. Replay reads the manifest's current state, not the commit
1374
+ // that last touched it, so this lag is harmless.
1375
+ workstream.status = "complete";
1376
+ if (finalCommit !== undefined)
1377
+ workstream.commit = finalCommit;
1378
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1379
+ {
1380
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1381
+ if (spawnErrors.length > 0)
1382
+ (base.stageErrors ??= []).push(...spawnErrors);
1383
+ }
1384
+ // Findings are triaged against the pre-workstream diff (baseCommit),
1385
+ // the same one the decider reviews decisions against — c0/finalCommit
1386
+ // are the fix loop's own rollback anchors, a different thing.
1387
+ {
1388
+ const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1389
+ if (spawnErrors.length > 0)
1390
+ (base.stageErrors ??= []).push(...spawnErrors);
1391
+ }
1392
+ // A fix-now triage drives exactly one bounded fix attempt, here —
1393
+ // after the triage that produced it, before the run advances. Only
1394
+ // reachable when there is a green commit to attempt from and a
1395
+ // decider that could have produced a fix-now verdict in the first
1396
+ // place (SC-13).
1397
+ if (finalCommit !== undefined && decider) {
1398
+ const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit, transcriptSink("fix-now-fix"));
1399
+ recordTranscript(driven.transcript);
1400
+ if (driven.spawnError !== undefined) {
1401
+ (base.stageErrors ??= []).push(driven.spawnError);
1402
+ }
1403
+ if (driven.commit !== finalCommit) {
1404
+ finalCommit = driven.commit;
1405
+ workstream.commit = finalCommit;
1406
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1407
+ }
1408
+ }
1409
+ if (critique)
1410
+ base.testCritique = critique.outcome;
1411
+ base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
1412
+ base.outcome = {
1413
+ status: "complete",
1414
+ ...(finalCommit === undefined ? {} : { commit: finalCommit }),
1415
+ };
1416
+ emitAttemptOutcome("success");
1417
+ return base;
1418
+ }
1419
+ priorFailure = failure;
1420
+ log(`${workstream.id}: ${attempt.label} attempt failed — ${failure}`);
1421
+ // A failure identical to the pre-run baseline (up to counts and
1422
+ // timings) was there before any workstream ran: environmental, not
1423
+ // this workstream's work. No retry or diagnosis is spent on it, and
1424
+ // the caller halts the run — every later workstream would be charged
1425
+ // for the same broken environment. Workstreams resumed with their own
1426
+ // leftover failure in the tree are exempt (see resumedIds).
1427
+ if (baselineFingerprint !== undefined &&
1428
+ !resumedIds.has(workstream.id) &&
1429
+ failureFingerprint(failure) === baselineFingerprint) {
1430
+ workstream.status = "failed";
1431
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1432
+ {
1433
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1434
+ if (spawnErrors.length > 0)
1435
+ (base.stageErrors ??= []).push(...spawnErrors);
1436
+ }
1437
+ base.environmental = true;
1438
+ base.outcome = {
1439
+ status: "failed",
1440
+ reason: failure +
1441
+ "\n\nThis failure is identical (up to counts and timings) to the " +
1442
+ "verification failure recorded on the untouched tree before the " +
1443
+ "run began — the environment was broken before this workstream " +
1444
+ "ran. No retry was spent, and the run halted here.",
1445
+ };
1446
+ emitAttemptOutcome("failed", "environmental");
1447
+ return base;
1448
+ }
1449
+ // The informed retry is spent only when the diagnosed recovery attempt
1450
+ // moved the failure at all. A failure reproduced identically after a
1451
+ // diagnosis says the roster is stuck, not unlucky — that belongs in
1452
+ // the ledger, not in a third spawn.
1453
+ const stuck = attempt.label === "recovery" &&
1454
+ index < attempts.length - 1 &&
1455
+ implementerFingerprint !== undefined &&
1456
+ failureFingerprint(failure) === implementerFingerprint;
1457
+ if (index === attempts.length - 1 || stuck) {
1458
+ workstream.status = "failed";
1459
+ await timed(recorder, { stage: "manifest-persist" }, () => saveManifest(root, options.programId, manifest, { log }));
1460
+ // The work stays in the tree for a resume; decisions made on the way
1461
+ // to a failure are still journaled and still reviewable.
1462
+ {
1463
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1464
+ if (spawnErrors.length > 0)
1465
+ (base.stageErrors ??= []).push(...spawnErrors);
1466
+ }
1467
+ base.outcome = {
1468
+ status: "failed",
1469
+ reason: stuck
1470
+ ? failure +
1471
+ "\n\nThe recovery attempt, briefed with a reviewer diagnosis, " +
1472
+ "reproduced the implementer's failure (identical up to counts " +
1473
+ "and timings), so the final retry was not spent."
1474
+ : failure,
1475
+ };
1476
+ emitAttemptOutcome(stuck ? "failed" : (dispositionOutcome ?? "failed"), stuck ? "stuck" : undefined);
1477
+ return base;
1478
+ }
1479
+ if (attempt.label === "implementer") {
1480
+ implementerFingerprint = failureFingerprint(failure);
1481
+ }
1482
+ // Diagnose the failure for the next attempt's brief. Overwrites any
1483
+ // prior diagnosis — a read of an older failure must not be pinned to
1484
+ // a newer one. Deterministic failures (no-op, undelivered brief) are
1485
+ // never diagnosed: the runner already knows exactly what happened,
1486
+ // and a reviewer read of an empty diff could only muddy it.
1487
+ if (reviewer && !deterministicFailure) {
1488
+ const diagnosed = await diagnoseFailure({
1489
+ root,
1490
+ manifest,
1491
+ workstream,
1492
+ spec,
1493
+ agentRunner,
1494
+ permits,
1495
+ reviewer,
1496
+ git,
1497
+ baseCommit,
1498
+ failure,
1499
+ attemptSeat: attempt.label,
1500
+ attemptIndex: index,
1501
+ observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
1502
+ recorder,
1503
+ });
1504
+ priorDiagnosis = diagnosed.diagnosis;
1505
+ if (diagnosed.spawnError !== undefined) {
1506
+ (base.stageErrors ??= []).push(diagnosed.spawnError);
1507
+ log(`${workstream.id}: ${diagnosed.spawnError}`);
1508
+ }
1509
+ await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
1510
+ }
1511
+ else {
1512
+ priorDiagnosis = undefined;
1513
+ }
1514
+ if (priorDiagnosis !== undefined) {
1515
+ (base.failureDiagnoses ??= []).push({
1516
+ attempt: attempt.label,
1517
+ verdict: priorDiagnosis,
1518
+ });
1519
+ log(`${workstream.id}: reviewer diagnosed the ${attempt.label} failure`);
1520
+ }
1521
+ emitAttemptOutcome(dispositionOutcome ?? "failed");
1522
+ }
1523
+ return base;
1524
+ }
1525
+ async function journalDecisions(workstream, decisions, baseCommit) {
1526
+ const events = decisions.map((decision) => ({
1527
+ kind: "decision-recorded",
1528
+ at: now().toISOString(),
1529
+ id: decisionFingerprint(workstream.id, decision),
1530
+ workstream: workstream.id,
1531
+ decision,
1532
+ ...(baseCommit === undefined ? {} : { baseCommit }),
1533
+ decidedBy: "implementer",
1534
+ }));
1535
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
1536
+ for (const decision of decisions) {
1537
+ log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Journals `permission-denied` events for one spawn (WS-03) — every
1542
+ * workstream-attributed spawn calls this once, passing its own transcript
1543
+ * and (for build roles only) the agent's `needsPermission` self-reports.
1544
+ * A no-op when the spawn produced neither.
1545
+ */
1546
+ async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
1547
+ const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
1548
+ if (events.length === 0)
1549
+ return;
1550
+ await timed(recorder, { stage: "ledger-persist", workstream: workstreamId }, () => appendLedgerEvents(root, options.programId, events));
1551
+ for (const event of events) {
1552
+ if (event.kind !== "permission-denied")
1553
+ continue;
1554
+ log(`${workstreamId} ${attempt}: denied \`${event.command}\` (${event.source})`);
1555
+ }
1556
+ }
1557
+ function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
1558
+ return reviewDecisions({
1559
+ root,
1560
+ programId: options.programId,
1561
+ manifest,
1562
+ workstreamId,
1563
+ decisions,
1564
+ baseCommit,
1565
+ decider,
1566
+ agentRunner,
1567
+ permits,
1568
+ git,
1569
+ isRepository,
1570
+ reviewed,
1571
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1572
+ triaged,
1573
+ now,
1574
+ log,
1575
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1576
+ recorder,
1577
+ });
1578
+ }
1579
+ function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
1580
+ return triageFindings({
1581
+ root,
1582
+ programId: options.programId,
1583
+ manifest,
1584
+ workstreamId,
1585
+ findings,
1586
+ baseCommit,
1587
+ decider,
1588
+ agentRunner,
1589
+ permits,
1590
+ git,
1591
+ isRepository,
1592
+ reviewed,
1593
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1594
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1595
+ triaged,
1596
+ now,
1597
+ log,
1598
+ recorder,
1599
+ });
1600
+ }
1601
+ /**
1602
+ * Drives the one bounded fix attempt a `fix-now` triage earns (WS-06).
1603
+ * Selects findings from the ledger projection — never from any in-memory
1604
+ * disposition — so a human-decided finding (status `"human-decided"`, not
1605
+ * `"fix-now"`) is never handed to the fix seam (SC-14). A workstream's
1606
+ * `fix-now` findings are fixed together, in one `attemptFix` call, then
1607
+ * recorded as one `finding-fix-attempted` event per finding id — a ledger
1608
+ * entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
1609
+ * commit; never throws, never blocks the run (SC-15).
1610
+ */
1611
+ async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
1612
+ const ledger = await readDecisionLedger(root, options.programId);
1613
+ const fixNowIds = new Set(ledger.findings
1614
+ .filter((record) => record.workstream === workstream.id && record.status === "fix-now")
1615
+ .map((record) => record.id));
1616
+ if (fixNowIds.size === 0)
1617
+ return { commit: greenCommit };
1618
+ const toFix = [];
1619
+ const matchedIds = [];
1620
+ for (const candidate of openFindings) {
1621
+ const id = fingerprint({ ...candidate, workstreamId: workstream.id });
1622
+ if (!fixNowIds.has(id))
1623
+ continue;
1624
+ toFix.push(candidate);
1625
+ matchedIds.push(id);
1626
+ }
1627
+ // An id triaged fix-now but absent from the open set (should not
1628
+ // happen — it is where the id came from) is skipped, fail-open.
1629
+ if (toFix.length === 0)
1630
+ return { commit: greenCommit };
1631
+ const fix = await attemptFix({
1632
+ root,
1633
+ programId: options.programId,
1634
+ workstream,
1635
+ spec,
1636
+ config,
1637
+ agentRunner,
1638
+ permits,
1639
+ verifyRunner,
1640
+ git,
1641
+ agent,
1642
+ findings: toFix,
1643
+ greenCommit,
1644
+ label: "fix-now fix",
1645
+ log,
1646
+ now,
1647
+ observe,
1648
+ stage: "fix-now",
1649
+ attemptIndex: 0,
1650
+ recorder,
1651
+ });
1652
+ // "kept" only when the attempt both verified clean and actually landed a
1653
+ // commit — a clean-but-empty attempt is a decline, not a fix, and must
1654
+ // reach the human exactly like a failed verification does.
1655
+ const kept = fix.outcome === "kept" && fix.commit !== undefined;
1656
+ const note = kept
1657
+ ? fix.summary
1658
+ : fix.outcome === "kept"
1659
+ ? `${fix.summary} (the attempt verified clean but made no change; no fix landed)`
1660
+ : `${fix.summary} (fix failed verification and was discarded: ${fix.failure})`;
1661
+ const events = matchedIds.map((id) => ({
1662
+ kind: "finding-fix-attempted",
1663
+ at: now().toISOString(),
1664
+ id,
1665
+ outcome: kept ? "kept" : "failed",
1666
+ note,
1667
+ ...(kept && fix.commit !== undefined ? { commit: fix.commit } : {}),
1668
+ attemptedBy: "implementer",
1669
+ }));
1670
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, options.programId, events));
1671
+ log(kept
1672
+ ? `${workstream.id}: fix-now fix verified and committed`
1673
+ : `${workstream.id}: fix-now fix failed and was escalated — ${note}`);
1674
+ const resultCommit = kept ? fix.greenCommit : greenCommit;
1675
+ return {
1676
+ commit: resultCommit,
1677
+ ...(fix.spawnError === undefined ? {} : { spawnError: fix.spawnError }),
1678
+ ...(fix.transcript === undefined ? {} : { transcript: fix.transcript }),
1679
+ };
1680
+ }
1400
1681
  }
1401
1682
  }
1402
1683
  /**
@@ -1487,6 +1768,7 @@ function notBuiltRoster(results, authorResult) {
1487
1768
  */
1488
1769
  export async function runWholeProgramStage(args) {
1489
1770
  const { root, programId, manifest, config, agentRunner, permits, git, isRepository, results, authorResult, runStartCommit, decider, reviewed, triaged, now, log, fs, } = args;
1771
+ const recorder = args.recorder ?? NOOP_RUN_RECORDER;
1490
1772
  const errors = [];
1491
1773
  let commit;
1492
1774
  let commitSkipped = false;
@@ -1508,7 +1790,9 @@ export async function runWholeProgramStage(args) {
1508
1790
  try {
1509
1791
  safeLog("whole-program review: starting");
1510
1792
  const base = await resolveProgramDiffBase({ root, git, isRepository, runStartCommit });
1511
- const diff = base === undefined ? "" : await git.diffSince(root, base);
1793
+ const diff = base === undefined
1794
+ ? ""
1795
+ : await timed(recorder, { stage: "git-diff" }, () => git.diffSince(root, base));
1512
1796
  const notBuilt = notBuiltRoster(results, authorResult);
1513
1797
  const reviewCommit = isRepository ? await git.currentCommit(root) : undefined;
1514
1798
  outcome = await runWholeProgramReview({
@@ -1523,6 +1807,7 @@ export async function runWholeProgramStage(args) {
1523
1807
  ...(notBuilt.length === 0 ? {} : { notBuilt }),
1524
1808
  ...(fs === undefined ? {} : { fs }),
1525
1809
  log,
1810
+ recorder,
1526
1811
  });
1527
1812
  safeLog(outcome.status === "refreshed"
1528
1813
  ? `whole-program review: refreshed ${AS_BUILT_PATH}` +
@@ -1532,7 +1817,7 @@ export async function runWholeProgramStage(args) {
1532
1817
  if (outcome.writtenPaths.length > 0) {
1533
1818
  if (isRepository) {
1534
1819
  try {
1535
- commit = await git.commitPaths(root, `nightshift(${programId}): as-built snapshot`, outcome.writtenPaths);
1820
+ commit = await timed(recorder, { stage: "as-built-snapshot" }, () => git.commitPaths(root, `nightshift(${programId}): as-built snapshot`, outcome.writtenPaths), commitEvidence);
1536
1821
  }
1537
1822
  catch (error) {
1538
1823
  errors.push(`could not commit the as-built snapshot: ${error.message}`);
@@ -1554,7 +1839,7 @@ export async function runWholeProgramStage(args) {
1554
1839
  }
1555
1840
  else {
1556
1841
  try {
1557
- await appendLedgerEvents(root, programId, events);
1842
+ await timed(recorder, { stage: "ledger-persist", workstream: WHOLE_PROGRAM_SUBJECT }, () => appendLedgerEvents(root, programId, events));
1558
1843
  journaled = true;
1559
1844
  findingIds = events.map((event) => event.id);
1560
1845
  }
@@ -1580,6 +1865,7 @@ export async function runWholeProgramStage(args) {
1580
1865
  triaged,
1581
1866
  now,
1582
1867
  log,
1868
+ recorder,
1583
1869
  });
1584
1870
  errors.push(...spawnErrors);
1585
1871
  }
@@ -1599,16 +1885,195 @@ export async function runWholeProgramStage(args) {
1599
1885
  errors,
1600
1886
  };
1601
1887
  }
1602
- /** Undefined means the attempt verified clean; otherwise the diagnosis. */
1603
- async function verifyAttempt(config, verifyRunner, root, agentExitCode) {
1888
+ function causalAnalysisStageFailure(reason) {
1889
+ return {
1890
+ ran: false,
1891
+ status: "analyzer-error",
1892
+ reason,
1893
+ unresolvedQuestions: [],
1894
+ parseErrors: [],
1895
+ inputClipped: false,
1896
+ };
1897
+ }
1898
+ /**
1899
+ * Projects the run's own `WorkstreamResult[]` into WS-05's narrow,
1900
+ * causal-analysis-owned timeline shape (mirrors `notBuiltRoster`'s mapping
1901
+ * pattern) — `causal-analysis.ts` cannot import `WorkstreamResult` itself
1902
+ * without creating a cycle back into this module.
1903
+ */
1904
+ function causalAnalysisTimeline(results) {
1905
+ return results.map((result) => ({
1906
+ id: result.id,
1907
+ name: result.name,
1908
+ status: result.outcome.status,
1909
+ ...(result.outcome.status === "complete" ? {} : { reason: result.outcome.reason }),
1910
+ ...(result.failureDiagnoses === undefined ? {} : { failureDiagnoses: result.failureDiagnoses }),
1911
+ ...(result.testCritique === undefined
1912
+ ? {}
1913
+ : {
1914
+ testCritique: {
1915
+ stopReason: result.testCritique.stopReason,
1916
+ roundsRun: result.testCritique.roundsRun,
1917
+ openSubjects: result.testCritique.open.map((finding) => finding.subject),
1918
+ resolvedSubjects: result.testCritique.resolved.map((finding) => finding.subject),
1919
+ },
1920
+ }),
1921
+ }));
1922
+ }
1923
+ /**
1924
+ * The end-of-run causal-analysis stage (WS-05): resolves the same diff base
1925
+ * the whole-program stage uses, takes the analytics snapshot *before*
1926
+ * spawning the analyzer (so it never reasons about its own cost, §3.2),
1927
+ * calls the never-throwing pass exactly once, and returns its outcome. Every
1928
+ * risky step gets its own catch so a stage failure is a sentence in the
1929
+ * report, never a change to the run's outcome (SC-13) — see the outer catch
1930
+ * below for the belt-and-braces case where a bug in this function's own body
1931
+ * throws before the pass has even run. Read-only: writes no file, journals
1932
+ * no ledger event, and is never a gate.
1933
+ */
1934
+ export async function runCausalAnalysisStage(args) {
1935
+ const { root, programId, manifest, config, agentRunner, permits, git, isRepository, results, runStartCommit, ledger, log, } = args;
1936
+ const recorder = args.recorder ?? NOOP_RUN_RECORDER;
1937
+ const errors = [];
1938
+ let outcome = causalAnalysisStageFailure("the causal analysis stage did not complete");
1939
+ const safeLog = (line) => {
1940
+ try {
1941
+ log(line);
1942
+ }
1943
+ catch (error) {
1944
+ errors.push(`the causal analysis logger failed: ${error.message}`);
1945
+ }
1946
+ };
1947
+ try {
1948
+ safeLog("causal analysis: starting");
1949
+ const base = await resolveProgramDiffBase({ root, git, isRepository, runStartCommit });
1950
+ const diff = base === undefined
1951
+ ? ""
1952
+ : await timed(recorder, { stage: "git-diff" }, () => git.diffSince(root, base));
1953
+ // Taken before the analyzer spawns (§3.2 of the WS-05 spec): the pass is
1954
+ // briefed from the run as it stood at this instant, so it never reasons
1955
+ // about its own review time.
1956
+ const snapshot = recorder.snapshot();
1957
+ // The program document's own narrative — SC-12 requires the analyzer to
1958
+ // consume the plan, not just the manifest's roster/success-criteria
1959
+ // fields. Read fail-open: a missing or unreadable document is an honest
1960
+ // gap in the brief, never a thrown stage (SC-13).
1961
+ const programNarrative = await readFile(join(root, "docs", "programs", `${programId}-program.md`), "utf8").catch(() => undefined);
1962
+ // Every workstream's own spec file — SC-12 requires the analyzer to
1963
+ // consume the specifications themselves, not merely a path to them. Read
1964
+ // fail-open per workstream: a missing or unreadable spec is an honest
1965
+ // gap for that workstream, never a thrown stage (SC-13).
1966
+ const workstreamSpecs = await Promise.all(manifest.workstreams.map(async (workstream) => {
1967
+ const spec = await readFile(join(root, workstream.taskFile), "utf8").catch(() => undefined);
1968
+ return {
1969
+ id: workstream.id,
1970
+ name: workstream.name,
1971
+ taskFile: workstream.taskFile,
1972
+ ...(spec === undefined ? {} : { spec }),
1973
+ };
1974
+ }));
1975
+ outcome = await runCausalAnalysis({
1976
+ root,
1977
+ programId,
1978
+ manifest,
1979
+ config,
1980
+ agentRunner,
1981
+ permits,
1982
+ recorder,
1983
+ snapshot,
1984
+ ledger,
1985
+ workstreams: causalAnalysisTimeline(results),
1986
+ diff,
1987
+ workstreamSpecs,
1988
+ ...(base === undefined ? {} : { baseCommit: base }),
1989
+ ...(programNarrative === undefined ? {} : { programNarrative }),
1990
+ log,
1991
+ });
1992
+ safeLog(`causal analysis: ${outcome.status}${outcome.reason ? ` — ${outcome.reason}` : ""}`);
1993
+ }
1994
+ catch (error) {
1995
+ errors.push(`the causal analysis stage failed unexpectedly: ${error.message}`);
1996
+ }
1997
+ return { outcome, errors };
1998
+ }
1999
+ /**
2000
+ * Fail-open durable write of one *executed* verify command's captured output
2001
+ * (WS-02, SC-05) to a gitignored `build-logs/<programId>/verify/` file,
2002
+ * returned as a `transcript` evidence ref for that command's span. Never
2003
+ * throws: a write failure (full disk, EACCES) simply omits the evidence ref,
2004
+ * never the span or the run (SC-13).
2005
+ */
2006
+ async function writeVerifyOutputEvidence(root, programId, label, output) {
2007
+ try {
2008
+ const dir = join(root, "build-logs", programId, "verify");
2009
+ await mkdir(dir, { recursive: true });
2010
+ const fileName = `${label}-${randomBytes(4).toString("hex")}.log`;
2011
+ await writeFile(join(dir, fileName), output, "utf8");
2012
+ return {
2013
+ kind: "transcript",
2014
+ locality: "local",
2015
+ ref: `build-logs/${programId}/verify/${fileName}`,
2016
+ note: "verify output",
2017
+ };
2018
+ }
2019
+ catch {
2020
+ return undefined;
2021
+ }
2022
+ }
2023
+ /**
2024
+ * Runs every configured verify command once, first non-zero wins — same
2025
+ * behavior as before WS-02, but the emission model changed (§3.4.a): rather
2026
+ * than one span for the whole pass, this emits one `verification-command` /
2027
+ * `baseline-verification` span per *executed* command, as depth-1 tiling
2028
+ * siblings (never a pass wrapper), so repeated full-suite cost and its
2029
+ * evidence are visible per command. A non-zero `agentExitCode` runs no
2030
+ * commands and emits no span at all — that attempt's failure is already
2031
+ * carried by its own `attempt-outcome` point (§3.3), so there is no
2032
+ * verification observation to fabricate. A command short-circuited by an
2033
+ * earlier command's failure is likewise absent, never a measured zero.
2034
+ * `stage` distinguishes the untouched-tree baseline check (preflight bucket)
2035
+ * from every configured command that follows an agent's own attempt
2036
+ * (verification bucket); `phase` names the invocation (`baseline`,
2037
+ * `post-implementer`, `test-critique-fix`, …) as each command span's
2038
+ * `attemptReason`. `programId`, when given, durably writes each executed
2039
+ * command's captured output as `transcript` evidence; a caller with no
2040
+ * `programId` (a bare test double) simply carries no evidence ref.
2041
+ * `role`/`attemptSeat`/`attemptIndex` carry the triggering attempt's own
2042
+ * dimensions (SC-05/SC-06) so a repeated verify pass can be uniquely
2043
+ * correlated back to the attempt that provoked it, not just its phase.
2044
+ */
2045
+ async function verifyAttempt(config, verifyRunner, root, agentExitCode, recorder = NOOP_RUN_RECORDER, stage = "verification-command", opts = {}) {
1604
2046
  if (agentExitCode !== 0) {
1605
2047
  return `agent exited with code ${agentExitCode}`;
1606
2048
  }
2049
+ const { programId, phase, workstream, role, attemptSeat, attemptIndex } = opts;
1607
2050
  for (const [name, command] of Object.entries(config.verify)) {
1608
- const result = await verifyRunner(command, root);
2051
+ const span = recorder.span({
2052
+ stage,
2053
+ verifyCommand: name,
2054
+ ...(phase === undefined ? {} : { attemptReason: phase }),
2055
+ ...(workstream === undefined ? {} : { workstream }),
2056
+ ...(role === undefined ? {} : { role }),
2057
+ ...(attemptSeat === undefined ? {} : { attemptSeat }),
2058
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
2059
+ });
2060
+ let result;
2061
+ try {
2062
+ result = await verifyRunner(command, root);
2063
+ }
2064
+ catch (error) {
2065
+ span.close({ outcome: "failed" });
2066
+ throw error;
2067
+ }
2068
+ const evidenceRef = programId === undefined
2069
+ ? undefined
2070
+ : await writeVerifyOutputEvidence(root, programId, `${phase ?? stage}-${name}`, result.output);
2071
+ const evidence = evidenceRef ? [evidenceRef] : undefined;
1609
2072
  if (result.exitCode !== 0) {
2073
+ span.close({ outcome: "failed", ...(evidence ? { evidence } : {}) });
1610
2074
  return `verify \`${name}\` (${command}) exited ${result.exitCode}:\n${tail(result.output, 1500)}`;
1611
2075
  }
2076
+ span.close({ outcome: "success", ...(evidence ? { evidence } : {}) });
1612
2077
  }
1613
2078
  return undefined;
1614
2079
  }
@@ -1693,11 +2158,23 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
1693
2158
  */
1694
2159
  async function diagnoseFailure(options) {
1695
2160
  const { root, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
1696
- const rawDiff = baseCommit !== undefined ? await git.diffSince(root, baseCommit) : "";
1697
- const brief = failureDiagnosisBrief(manifest, workstream, clipForReview(spec, "spec").text, clipForReview(rawDiff, "diff").text, failure);
2161
+ const recorder = options.recorder ?? NOOP_RUN_RECORDER;
2162
+ const rawDiff = baseCommit !== undefined
2163
+ ? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(root, baseCommit))
2164
+ : "";
2165
+ const clippedSpec = clipForReview(spec, "spec");
2166
+ const clippedDiff = clipForReview(rawDiff, "diff");
2167
+ recorder.point(clippedInputPoint("failure-diagnosis-spec", spec, clippedSpec.text, { role: "reviewerAgent" }));
2168
+ recorder.point(clippedInputPoint("failure-diagnosis-diff", rawDiff, clippedDiff.text, { role: "reviewerAgent" }));
2169
+ const brief = failureDiagnosisBrief(manifest, workstream, clippedSpec.text, clippedDiff.text, failure);
1698
2170
  let invocation;
1699
2171
  try {
1700
- invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe);
2172
+ invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe, recorder, {
2173
+ stage: "failure-diagnosis",
2174
+ workstream: workstream.id,
2175
+ ...(options.attemptSeat === undefined ? {} : { attemptSeat: options.attemptSeat }),
2176
+ ...(options.attemptIndex === undefined ? {} : { attemptIndex: options.attemptIndex }),
2177
+ });
1701
2178
  }
1702
2179
  catch (error) {
1703
2180
  return {
@@ -1875,15 +2352,36 @@ function testCritiqueFixBrief(workstream, spec, findings) {
1875
2352
  * whether (and how many times) this is called.
1876
2353
  */
1877
2354
  async function attemptFix(options) {
1878
- const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, } = options;
2355
+ const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, stage, attemptIndex, } = options;
2356
+ const recorder = options.recorder ?? NOOP_RUN_RECORDER;
2357
+ const attemptReason = stage === "fix-now" ? "fix-now-finding" : "critique-finding";
1879
2358
  const brief = testCritiqueFixBrief(workstream, spec, findings);
1880
2359
  let invocation;
1881
2360
  try {
1882
- invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe);
2361
+ invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe, recorder, {
2362
+ stage,
2363
+ workstream: workstream.id,
2364
+ attemptSeat: label,
2365
+ attemptIndex,
2366
+ attemptReason,
2367
+ });
1883
2368
  }
1884
2369
  catch (error) {
1885
2370
  const spawnError = `the ${label} implementer could not be spawned: ${error.message}; ` +
1886
2371
  "the fix was not attempted and the green state was preserved.";
2372
+ recorder.point({
2373
+ kind: "attempt-outcome",
2374
+ coverage: "observed",
2375
+ dimensions: {
2376
+ stage,
2377
+ workstream: workstream.id,
2378
+ role: "agent",
2379
+ attemptSeat: label,
2380
+ attemptIndex,
2381
+ attemptReason,
2382
+ outcome: "failed",
2383
+ },
2384
+ });
1887
2385
  return {
1888
2386
  outcome: "failed",
1889
2387
  greenCommit,
@@ -1897,14 +2395,36 @@ async function attemptFix(options) {
1897
2395
  const summary = resolvedSummary.text;
1898
2396
  const denialEvents = buildDenialEvents(workstream.id, label, transcript?.denials ?? [], resolvedSummary.needsPermission, config.permits?.deny ?? [], now().toISOString());
1899
2397
  if (denialEvents.length > 0) {
1900
- await appendLedgerEvents(root, programId, denialEvents);
2398
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, programId, denialEvents));
1901
2399
  for (const event of denialEvents) {
1902
2400
  if (event.kind !== "permission-denied")
1903
2401
  continue;
1904
2402
  log(`${workstream.id} ${label}: denied \`${event.command}\` (${event.source})`);
1905
2403
  }
1906
2404
  }
1907
- const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
2405
+ const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode, recorder, "verification-command", {
2406
+ programId,
2407
+ phase: stage,
2408
+ workstream: workstream.id,
2409
+ role: "agent",
2410
+ attemptSeat: label,
2411
+ attemptIndex,
2412
+ });
2413
+ const emitOutcome = (outcome) => {
2414
+ recorder.point({
2415
+ kind: "attempt-outcome",
2416
+ coverage: "observed",
2417
+ dimensions: {
2418
+ stage,
2419
+ workstream: workstream.id,
2420
+ role: "agent",
2421
+ attemptSeat: label,
2422
+ attemptIndex,
2423
+ attemptReason,
2424
+ outcome,
2425
+ },
2426
+ });
2427
+ };
1908
2428
  if (failure === undefined) {
1909
2429
  // The runner's own pending ledger and manifest writes under
1910
2430
  // docs/programs sit uncommitted in the tree until the run's final
@@ -1918,8 +2438,9 @@ async function attemptFix(options) {
1918
2438
  const dirty = await git.dirtyPaths(root);
1919
2439
  const meaningfulChange = dirty.some((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
1920
2440
  const commit = meaningfulChange
1921
- ? await git.commitAll(root, `nightshift(${programId}): ${workstream.id} ${label}`)
2441
+ ? await timed(recorder, { stage: "git-commit", workstream: workstream.id }, () => git.commitAll(root, `nightshift(${programId}): ${workstream.id} ${label}`), commitEvidence)
1922
2442
  : undefined;
2443
+ emitOutcome(commit === undefined ? "discarded" : "success");
1923
2444
  return {
1924
2445
  outcome: "kept",
1925
2446
  greenCommit: commit ?? greenCommit,
@@ -1941,6 +2462,7 @@ async function attemptFix(options) {
1941
2462
  const snapshot = await snapshotProgramsDir(root);
1942
2463
  await git.resetHard(root, greenCommit);
1943
2464
  await restoreProgramsDir(root, snapshot);
2465
+ emitOutcome("failed");
1944
2466
  return { outcome: "failed", greenCommit, summary, failure, ...(transcript === undefined ? {} : { transcript }) };
1945
2467
  }
1946
2468
  /**
@@ -1952,6 +2474,7 @@ async function attemptFix(options) {
1952
2474
  */
1953
2475
  async function runTestCritique(options) {
1954
2476
  const { root, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, baseCommit, now, log, transcriptSink, } = options;
2477
+ const recorder = options.recorder ?? NOOP_RUN_RECORDER;
1955
2478
  let greenCommit = options.greenCommit;
1956
2479
  let diffClipped = false;
1957
2480
  const stageErrors = [];
@@ -1966,16 +2489,20 @@ async function runTestCritique(options) {
1966
2489
  };
1967
2490
  }
1968
2491
  const locate = (file) => locateInRepo(root, file);
1969
- const review = async (_round, priorOpen) => {
1970
- const rawDiff = baseCommit !== undefined ? await git.diffSince(root, baseCommit) : "";
2492
+ const review = async (round, priorOpen) => {
2493
+ const rawDiff = baseCommit !== undefined
2494
+ ? await timed(recorder, { stage: "git-diff", workstream: workstream.id }, () => git.diffSince(root, baseCommit))
2495
+ : "";
1971
2496
  const diff = clipForReview(rawDiff, "diff");
1972
2497
  const clippedSpec = clipForReview(spec, "spec");
1973
2498
  if (diff.clipped || clippedSpec.clipped)
1974
2499
  diffClipped = true;
2500
+ recorder.point(clippedInputPoint("test-critique-diff", rawDiff, diff.text, { role: "reviewerAgent" }));
2501
+ recorder.point(clippedInputPoint("test-critique-spec", spec, clippedSpec.text, { role: "reviewerAgent" }));
1975
2502
  const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
1976
2503
  let invocation;
1977
2504
  try {
1978
- invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"));
2505
+ invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"), recorder, { workstream: workstream.id, attemptIndex: round });
1979
2506
  }
1980
2507
  catch (error) {
1981
2508
  // Fail open, matching the existing reviewer-error path: no findings
@@ -1987,7 +2514,7 @@ async function runTestCritique(options) {
1987
2514
  if (invocation.transcript) {
1988
2515
  const denialEvents = buildDenialEvents(workstream.id, "reviewer", invocation.transcript.denials, [], config.permits?.deny ?? [], now().toISOString());
1989
2516
  if (denialEvents.length > 0) {
1990
- await appendLedgerEvents(root, manifest.program.id, denialEvents);
2517
+ await timed(recorder, { stage: "ledger-persist", workstream: workstream.id }, () => appendLedgerEvents(root, manifest.program.id, denialEvents));
1991
2518
  for (const event of denialEvents) {
1992
2519
  if (event.kind !== "permission-denied")
1993
2520
  continue;
@@ -2000,7 +2527,7 @@ async function runTestCritique(options) {
2000
2527
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
2001
2528
  return { findings, errors: parsed.errors, ran };
2002
2529
  };
2003
- const respond = async (_round, findings) => {
2530
+ const respond = async (round, findings) => {
2004
2531
  const fix = await attemptFix({
2005
2532
  root,
2006
2533
  programId: manifest.program.id,
@@ -2018,6 +2545,9 @@ async function runTestCritique(options) {
2018
2545
  log,
2019
2546
  now,
2020
2547
  observe: transcriptSink("test-critique-fix"),
2548
+ stage: "test-critique-fix",
2549
+ attemptIndex: round,
2550
+ recorder,
2021
2551
  });
2022
2552
  greenCommit = fix.greenCommit;
2023
2553
  if (fix.transcript)
@@ -2350,7 +2880,11 @@ function renderCommandsDeniedSection(results, ledger, programId) {
2350
2880
  }
2351
2881
  return lines;
2352
2882
  }
2353
- export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand, manifestAmended) {
2883
+ export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand, manifestAmended,
2884
+ /** Pre-rendered "Where the time went" lines (WS-04), computed and isolated by the caller via `renderAnalyticsSection`. Omitted entirely, this function's output is unchanged from before WS-04. */
2885
+ analyticsSection,
2886
+ /** Pre-rendered "Why the time went there" lines (WS-05), computed and isolated by the caller via `renderCausalAnalysisSection`. Omitted entirely, this function's output is unchanged from before WS-05. */
2887
+ causalAnalysisSection) {
2354
2888
  const programId = manifest.program.id;
2355
2889
  const built = results.filter((result) => result.outcome.status === "complete" ||
2356
2890
  result.outcome.status === "skipped").length;
@@ -2459,6 +2993,12 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
2459
2993
  "workstream's own outcome is unaffected, but the reason it fell " +
2460
2994
  "open belongs here.", "", ...stageErrors.map((error) => `- ${error}`), "");
2461
2995
  }
2996
+ if (analyticsSection !== undefined) {
2997
+ lines.push(...analyticsSection);
2998
+ }
2999
+ if (causalAnalysisSection !== undefined) {
3000
+ lines.push(...causalAnalysisSection);
3001
+ }
2462
3002
  return lines.join("\n");
2463
3003
  }
2464
3004
  //# sourceMappingURL=run-program.js.map