@wildorder/nightshift 0.13.0 → 0.14.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 (73) hide show
  1. package/README.md +46 -19
  2. package/dist/agent-probe.d.ts +21 -2
  3. package/dist/agent-probe.d.ts.map +1 -1
  4. package/dist/agent-probe.js +76 -22
  5. package/dist/agent-probe.js.map +1 -1
  6. package/dist/agent-runner.d.ts +90 -2
  7. package/dist/agent-runner.d.ts.map +1 -1
  8. package/dist/agent-runner.js +382 -24
  9. package/dist/agent-runner.js.map +1 -1
  10. package/dist/agent-summary.d.ts +15 -0
  11. package/dist/agent-summary.d.ts.map +1 -1
  12. package/dist/agent-summary.js +32 -2
  13. package/dist/agent-summary.js.map +1 -1
  14. package/dist/as-built.d.ts +6 -0
  15. package/dist/as-built.d.ts.map +1 -1
  16. package/dist/as-built.js +7 -0
  17. package/dist/as-built.js.map +1 -1
  18. package/dist/author.d.ts +9 -0
  19. package/dist/author.d.ts.map +1 -1
  20. package/dist/author.js +118 -24
  21. package/dist/author.js.map +1 -1
  22. package/dist/ci-init.d.ts.map +1 -1
  23. package/dist/ci-init.js +17 -12
  24. package/dist/ci-init.js.map +1 -1
  25. package/dist/cli.js +71 -8
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config.d.ts +11 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +13 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/crash-report.d.ts +58 -0
  32. package/dist/crash-report.d.ts.map +1 -0
  33. package/dist/crash-report.js +115 -0
  34. package/dist/crash-report.js.map +1 -0
  35. package/dist/decide.js +1 -1
  36. package/dist/decide.js.map +1 -1
  37. package/dist/decider-review.d.ts +33 -5
  38. package/dist/decider-review.d.ts.map +1 -1
  39. package/dist/decider-review.js +55 -9
  40. package/dist/decider-review.js.map +1 -1
  41. package/dist/decision-ledger.d.ts +44 -1
  42. package/dist/decision-ledger.d.ts.map +1 -1
  43. package/dist/decision-ledger.js +46 -1
  44. package/dist/decision-ledger.js.map +1 -1
  45. package/dist/decision-view.d.ts +10 -1
  46. package/dist/decision-view.d.ts.map +1 -1
  47. package/dist/decision-view.js +41 -0
  48. package/dist/decision-view.js.map +1 -1
  49. package/dist/manifest.d.ts +21 -1
  50. package/dist/manifest.d.ts.map +1 -1
  51. package/dist/manifest.js +221 -11
  52. package/dist/manifest.js.map +1 -1
  53. package/dist/permits.d.ts +135 -0
  54. package/dist/permits.d.ts.map +1 -0
  55. package/dist/permits.js +397 -0
  56. package/dist/permits.js.map +1 -0
  57. package/dist/publish.d.ts +35 -0
  58. package/dist/publish.d.ts.map +1 -1
  59. package/dist/publish.js +149 -16
  60. package/dist/publish.js.map +1 -1
  61. package/dist/run-program.d.ts +53 -2
  62. package/dist/run-program.d.ts.map +1 -1
  63. package/dist/run-program.js +529 -54
  64. package/dist/run-program.js.map +1 -1
  65. package/dist/whole-program-review.d.ts +4 -0
  66. package/dist/whole-program-review.d.ts.map +1 -1
  67. package/dist/whole-program-review.js +9 -1
  68. package/dist/whole-program-review.js.map +1 -1
  69. package/package.json +2 -2
  70. package/dist/verify-allowlist.d.ts +0 -23
  71. package/dist/verify-allowlist.d.ts.map +0 -1
  72. package/dist/verify-allowlist.js +0 -46
  73. package/dist/verify-allowlist.js.map +0 -1
@@ -2,27 +2,109 @@ import { execFile } from "node:child_process";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
5
+ import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveAuthorAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
6
6
  import { resolveSummary, summaryContract } from "./agent-summary.js";
7
7
  import { authorProgram } from "./author.js";
8
8
  import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
9
- import { appendLedgerEvents, readDecisionLedger, } from "./decision-ledger.js";
10
- import { escalatedRecords, renderRecord, } from "./decision-view.js";
9
+ import { appendLedgerEvents, denialFingerprint, readDecisionLedger, } from "./decision-ledger.js";
10
+ import { escalatedRecords, renderDenial, renderRecord, } from "./decision-view.js";
11
11
  import { reviewDecisions, triageFindings } from "./decider-review.js";
12
12
  import { fingerprint } from "./findings.js";
13
13
  import { findCycles, stableTopologicalOrder } from "./graph.js";
14
14
  import { extractFindings, findingsContract, findingsToLedgerEvents, hasRoutableEvidence, locateInRepo, renderPassReport, reviewerAbsentOutcome, runReviewPass, verifyEvidence, } from "./review-pass.js";
15
- import { loadManifest, saveManifest, } from "./manifest.js";
15
+ import { loadManifest, manifestMergeCount, saveManifest, } from "./manifest.js";
16
16
  import { detectDefaultBranch, programBranchName } from "./program-branch.js";
17
17
  import { defaultPrerequisiteRunner, runPreflight, } from "./preflight.js";
18
18
  import { restoreProgramsDir, snapshotProgramsDir } from "./programs-dir.js";
19
- import { verifyAccessWarning } from "./verify-allowlist.js";
19
+ import { buildPermitsContext } from "./permits.js";
20
20
  import { CouldNotStartError } from "./exit-codes.js";
21
21
  import { runReportPath } from "./report-path.js";
22
22
  import { AS_BUILT_PATH, runWholeProgramReview, renderWholeProgramReview, } from "./whole-program-review.js";
23
23
  const execFileAsync = promisify(execFile);
24
24
  /** Matches every wording git uses to report an empty commit attempt. */
25
25
  const NOTHING_TO_COMMIT = /nothing to commit|nothing added to commit|no changes added to commit/u;
26
+ /**
27
+ * Attributes a transcript denial's source: `"deny-rule"` when the command
28
+ * matches an operator-authored `permits.deny` rule, `"classifier"`
29
+ * otherwise. The Claude stream does not reliably distinguish the two
30
+ * mechanisms, so this is derived rather than carried from WS-02.
31
+ *
32
+ * A bounded matcher, not a permission-rules engine: it recognizes only the
33
+ * `Bash(...)` shapes nightshift itself emits and the CLI documents —
34
+ * colon-wildcard (`Bash(<prefix>:*)`), space-wildcard (`Bash(<prefix> *)`),
35
+ * and exact (`Bash(<cmd>)`), each with a word-boundary prefix match so
36
+ * `Bash(git push:*)` does not match `git pushx`. Every rule it does not
37
+ * recognize (a non-`Bash(...)` rule, or an inner shape matching none of the
38
+ * three) is skipped, defaulting the denial to `"classifier"` — the safe
39
+ * direction, since an over-attribution to `classifier` at worst prints an
40
+ * allow-rule remediation, the safe remediation to print when the mechanism
41
+ * is unconfirmed.
42
+ */
43
+ export function attributeDenialSource(command, denyRules) {
44
+ for (const rule of denyRules) {
45
+ const match = /^Bash\((.+)\)$/iu.exec(rule.trim());
46
+ if (!match)
47
+ continue;
48
+ const inner = match[1] ?? "";
49
+ let prefix;
50
+ if (inner.endsWith(":*"))
51
+ prefix = inner.slice(0, -2);
52
+ else if (inner.endsWith(" *"))
53
+ prefix = inner.slice(0, -2);
54
+ if (prefix !== undefined) {
55
+ if (command === prefix || command.startsWith(`${prefix} `))
56
+ return "deny-rule";
57
+ continue;
58
+ }
59
+ if (command === inner)
60
+ return "deny-rule";
61
+ }
62
+ return "classifier";
63
+ }
64
+ /**
65
+ * Correlates one spawn's observed transcript denials with the agent's own
66
+ * `needs-permission` self-reports into one `permission-denied` ledger event
67
+ * per command (SC-12). An agent report naming the same command as an
68
+ * observed denial attaches as `agentReason` on that observed record rather
69
+ * than creating a second one; an agent report with no matching observed
70
+ * denial (an unobserved provider, or a command the CLI denied without
71
+ * surfacing it) gets its own `agent-report` record. `needsPermission` is
72
+ * empty for a read-only reviewer/decider spawn, where an agent-report record
73
+ * never arises.
74
+ */
75
+ function buildDenialEvents(workstreamId, attempt, denials, needsPermission, denyRules, at) {
76
+ const events = [];
77
+ const observedCommands = new Set(denials.map((denial) => denial.command));
78
+ for (const denial of denials) {
79
+ const agentMatch = needsPermission.find((need) => need.command === denial.command);
80
+ events.push({
81
+ kind: "permission-denied",
82
+ at,
83
+ id: denialFingerprint(workstreamId, attempt, denial.command),
84
+ workstream: workstreamId,
85
+ attempt,
86
+ command: denial.command,
87
+ source: attributeDenialSource(denial.command, denyRules),
88
+ reason: denial.reason,
89
+ ...(agentMatch?.reason === undefined ? {} : { agentReason: agentMatch.reason }),
90
+ });
91
+ }
92
+ for (const need of needsPermission) {
93
+ if (observedCommands.has(need.command))
94
+ continue;
95
+ events.push({
96
+ kind: "permission-denied",
97
+ at,
98
+ id: denialFingerprint(workstreamId, attempt, need.command),
99
+ workstream: workstreamId,
100
+ attempt,
101
+ command: need.command,
102
+ source: "agent-report",
103
+ ...(need.reason === undefined ? {} : { agentReason: need.reason }),
104
+ });
105
+ }
106
+ return events;
107
+ }
26
108
  export const defaultGitOps = {
27
109
  async isRepository(cwd) {
28
110
  try {
@@ -198,7 +280,38 @@ function findingsRuledOnSection(records) {
198
280
  return [];
199
281
  return ["## Findings ruled on", "", ...lines, ""];
200
282
  }
201
- function implementerBrief(manifest, workstream, spec, ledger, priorFailure, priorDiagnosis) {
283
+ /**
284
+ * The Shell policy section (SC-11): the operator's own policy prose,
285
+ * verbatim, when set, and — always — the rule that turns a denied command
286
+ * into a reported fact rather than an obstacle to route around. Placed in
287
+ * the working contract (before the decision/summary contracts) so it reaches
288
+ * every provider alike: the Claude `auto` classifier judges an action
289
+ * against the request it can see, and codex has no classifier but still
290
+ * reads the same prose.
291
+ */
292
+ function shellPolicySection(shellPolicy) {
293
+ const policyLines = shellPolicy === undefined || shellPolicy.trim() === ""
294
+ ? []
295
+ : ["Your operator set this shell policy for this run:", "", shellPolicy, ""];
296
+ return [
297
+ "## Shell policy",
298
+ "",
299
+ ...policyLines,
300
+ "If a shell command you need is denied, do not work around it, retry it",
301
+ "a different way, or abandon the fix silently. Report it. In your",
302
+ "summary block, add one `needs-permission:` line naming the exact",
303
+ "command, and a following `because:` line naming why you needed it. One",
304
+ "pair per denied command:",
305
+ "",
306
+ "```summary",
307
+ "...",
308
+ "needs-permission: npm install left-pad",
309
+ "because: the fixture needs a dev dependency that isn't installed.",
310
+ "```",
311
+ "",
312
+ ];
313
+ }
314
+ function implementerBrief(manifest, workstream, spec, ledger, shellPolicy, priorFailure, priorDiagnosis) {
202
315
  const roster = manifest.workstreams
203
316
  .map((entry) => {
204
317
  const scope = entry.scope?.summary ?? entry.name;
@@ -261,6 +374,7 @@ function implementerBrief(manifest, workstream, spec, ledger, priorFailure, prio
261
374
  " independently afterwards and your claim of success is never trusted",
262
375
  " without it.",
263
376
  "",
377
+ ...shellPolicySection(shellPolicy),
264
378
  decisionContract(),
265
379
  "",
266
380
  summaryContract(),
@@ -332,6 +446,52 @@ export function downstreamCone(workstreams, rootIds) {
332
446
  cone.delete(id);
333
447
  return cone;
334
448
  }
449
+ /**
450
+ * The `permissions:` startup lines (WS-01 SC-07): one per distinct derived
451
+ * invocation, naming the role(s) that share it, the containment in effect,
452
+ * and the settings file it reads when one applies. `recovery.agent` and
453
+ * `resolveAuthorAgent(config)`'s agent both carry a guaranteed fallback to
454
+ * the implementer, so a solo setup with only `agent` configured naturally
455
+ * dedupes into one line covering all three roles.
456
+ */
457
+ async function permissionsStartupLines(config, permits, agent, recovery, decider, reviewer) {
458
+ const author = resolveAuthorAgent(config);
459
+ const roles = [
460
+ { role: "agent", agentConfig: agent },
461
+ { role: "recoveryAgent", agentConfig: recovery?.agent },
462
+ { role: "authorAgent", agentConfig: author?.agent },
463
+ { role: "deciderAgent", agentConfig: decider },
464
+ { role: "reviewerAgent", agentConfig: reviewer },
465
+ ];
466
+ const distinct = new Map();
467
+ for (const { role, agentConfig } of roles) {
468
+ if (!agentConfig)
469
+ continue;
470
+ const derived = await permits.derive(role, agentConfig);
471
+ const key = JSON.stringify([derived.agent.command, derived.agent.args, derived.agent.promptMode]);
472
+ const entry = distinct.get(key);
473
+ if (entry)
474
+ entry.roles.push(role);
475
+ else {
476
+ distinct.set(key, {
477
+ roles: [role],
478
+ label: derived.label,
479
+ ...(derived.settingsPath === undefined ? {} : { settingsPath: derived.settingsPath }),
480
+ ...(derived.note === undefined ? {} : { note: derived.note }),
481
+ });
482
+ }
483
+ }
484
+ const lines = [];
485
+ for (const entry of distinct.values()) {
486
+ const parts = [`permissions: ${entry.roles.join(", ")}: ${entry.label}`];
487
+ if (entry.settingsPath !== undefined)
488
+ parts.push(`settings: ${entry.settingsPath}`);
489
+ if (entry.note !== undefined)
490
+ parts.push(entry.note);
491
+ lines.push(parts.join(" — "));
492
+ }
493
+ return lines;
494
+ }
335
495
  /**
336
496
  * The decide-and-continue runner.
337
497
  *
@@ -343,6 +503,23 @@ export function downstreamCone(workstreams, rootIds) {
343
503
  * verify commands itself, and writes the run report last, whatever happened.
344
504
  */
345
505
  export async function runProgram(options) {
506
+ // A caller-supplied context is the caller's to dispose. One this run builds
507
+ // for itself is removed on every exit path — a could-not-start refusal, a
508
+ // crash mid-run, or the normal return — which is why the build sits in a
509
+ // thin wrapper around the run rather than inside it: the run's body
510
+ // declares its stage functions after its return statement, and a try/finally
511
+ // inside it would take every shared variable out of their scope.
512
+ if (options.permits !== undefined)
513
+ return runProgramWith(options, options.permits);
514
+ const permits = await buildPermitsContext({ config: options.config });
515
+ try {
516
+ return await runProgramWith(options, permits);
517
+ }
518
+ finally {
519
+ await permits.dispose?.();
520
+ }
521
+ }
522
+ async function runProgramWith(options, permits) {
346
523
  const root = resolve(options.cwd);
347
524
  const config = options.config;
348
525
  const agentRunner = options.agentRunner ?? defaultAgentRunner;
@@ -357,6 +534,11 @@ export async function runProgram(options) {
357
534
  catch (error) {
358
535
  throw new CouldNotStartError(error.message, { cause: error });
359
536
  }
537
+ // Snapshotted once, right after the initial load, rather than cleared on
538
+ // any later reload (e.g. the post-authoring reload below) — so a merge
539
+ // during authoring is still visible in the report-time comparison, and a
540
+ // second run in the same test process starts from its own baseline.
541
+ const mergesAtStart = manifestMergeCount(root, options.programId);
360
542
  const resolvedAgent = resolveAgent(config);
361
543
  if (!resolvedAgent) {
362
544
  throw new CouldNotStartError("No implementer configured. Set the `agent` block in nightshift.config.json.");
@@ -366,15 +548,21 @@ export async function runProgram(options) {
366
548
  const decider = resolveDeciderAgent(config);
367
549
  const reviewer = resolveReviewerAgent(config);
368
550
  log(`implementer: ${describeAgent(agent)}`);
369
- const sandboxWarning = verifyAccessWarning(agent, config.verify);
370
- if (sandboxWarning !== undefined)
371
- log(`warning: ${sandboxWarning}`);
372
551
  log(decider
373
552
  ? `decider: ${describeAgent(decider)}`
374
553
  : "decider: none configured — implementer defaults will stand unratified");
375
554
  log(reviewer
376
555
  ? `reviewer (test critique, whole-program review): ${describeAgent(reviewer)}`
377
556
  : "reviewer: none configured — test critique and the whole-program review are disabled");
557
+ // Built once, for this whole run (by the wrapper above): the containment
558
+ // nightshift derives for every agent spawn (WS-01). Threaded explicitly
559
+ // into every nested stage and every invokeAgent call this function owns,
560
+ // rather than rebuilt per stage — a per-stage rebuild would write several
561
+ // settings files and print a startup line describing a context later
562
+ // spawns do not actually use.
563
+ for (const line of await permissionsStartupLines(config, permits, agent, recovery, decider, reviewer)) {
564
+ log(line);
565
+ }
378
566
  const cycles = findCycles(manifest.workstreams);
379
567
  if (cycles.length > 0) {
380
568
  // A cyclic graph cannot be ordered; this is a planning defect, not a
@@ -522,7 +710,7 @@ export async function runProgram(options) {
522
710
  // mutates neither the manifest nor the ledger.
523
711
  if (preflight.events.length > 0) {
524
712
  await appendLedgerEvents(root, options.programId, preflight.events);
525
- await saveManifest(root, options.programId, manifest);
713
+ await saveManifest(root, options.programId, manifest, { log });
526
714
  }
527
715
  // Subject ids already sent to the decider this run — shared across the
528
716
  // authoring and build stages (a run is one process) and across the
@@ -545,6 +733,7 @@ export async function runProgram(options) {
545
733
  programId: options.programId,
546
734
  config,
547
735
  agentRunner,
736
+ permits,
548
737
  git,
549
738
  log,
550
739
  now,
@@ -620,7 +809,7 @@ export async function runProgram(options) {
620
809
  // partial regardless (see the intermission classification below).
621
810
  if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
622
811
  workstream.status = "awaiting_human";
623
- await saveManifest(root, options.programId, manifest);
812
+ await saveManifest(root, options.programId, manifest, { log });
624
813
  const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
625
814
  const reason = ownUnmet.length > 0
626
815
  ? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
@@ -680,13 +869,14 @@ export async function runProgram(options) {
680
869
  : intermission
681
870
  ? "awaiting_human"
682
871
  : "partial";
683
- await saveManifest(root, options.programId, manifest);
872
+ await saveManifest(root, options.programId, manifest, { log });
684
873
  const wholeProgram = await runWholeProgramStage({
685
874
  root,
686
875
  programId: options.programId,
687
876
  manifest,
688
877
  config,
689
878
  agentRunner,
879
+ permits,
690
880
  git,
691
881
  isRepository,
692
882
  results,
@@ -715,7 +905,7 @@ export async function runProgram(options) {
715
905
  // projected ledger carries no run identifier of its own. It excludes
716
906
  // ids that were merely sent but whose invocation failed or returned no
717
907
  // valid verdict.
718
- renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand), "utf8");
908
+ renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart), "utf8");
719
909
  if (isRepository) {
720
910
  await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
721
911
  }
@@ -740,7 +930,18 @@ export async function runProgram(options) {
740
930
  outcome: { status: "failed", reason: "not attempted" },
741
931
  decisionIds: [],
742
932
  decisionErrors: [],
933
+ buildAgentCommand: describeAgent(agent),
743
934
  };
935
+ /** Every build-role spawn's transcript, for the report's per-workstream
936
+ * Commands subsection (WS-02). Reviewer/decider spawns are excluded. */
937
+ function recordTranscript(transcript) {
938
+ if (transcript)
939
+ (base.transcripts ??= []).push(transcript);
940
+ }
941
+ /** Names the JSONL file under build-logs/<programId>/, distinctly per spawn label. */
942
+ function transcriptSink(label) {
943
+ return { root, programId: options.programId, label: `${workstream.id}-${label}`, log };
944
+ }
744
945
  let spec;
745
946
  try {
746
947
  spec = await readFile(join(root, workstream.taskFile), "utf8");
@@ -753,7 +954,7 @@ export async function runProgram(options) {
753
954
  return base;
754
955
  }
755
956
  workstream.status = "in_progress";
756
- await saveManifest(root, options.programId, manifest);
957
+ await saveManifest(root, options.programId, manifest, { log });
757
958
  const baseCommit = isRepository
758
959
  ? await git.currentCommit(root)
759
960
  : undefined;
@@ -761,10 +962,10 @@ export async function runProgram(options) {
761
962
  let priorDiagnosis;
762
963
  let implementerFingerprint;
763
964
  const attempts = [
764
- { agent, label: "implementer" },
965
+ { agent, label: "implementer", role: "agent" },
765
966
  ];
766
967
  if (recovery && !recovery.borrowedImplementer) {
767
- attempts.push({ agent: recovery.agent, label: "recovery" });
968
+ attempts.push({ agent: recovery.agent, label: "recovery", role: "recoveryAgent" });
768
969
  // The third seat exists only when a reviewer can inform it. An
769
970
  // uninformed retry has already been spent (recovery); running the
770
971
  // roster again blind is a coin flip the ledger should hear about
@@ -772,14 +973,31 @@ export async function runProgram(options) {
772
973
  // the reviewer's diagnosis in hand, a composition neither prior
773
974
  // attempt had.
774
975
  if (reviewer)
775
- attempts.push({ agent, label: "informed retry" });
976
+ attempts.push({ agent, label: "informed retry", role: "agent" });
776
977
  }
777
978
  for (const [index, attempt] of attempts.entries()) {
778
979
  log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
779
- const brief = implementerBrief(manifest, workstream, spec, ledgerAtStart, priorFailure, priorDiagnosis);
780
- const invocation = await invokeAgent(agentRunner, attempt.agent, brief, root);
980
+ const brief = implementerBrief(manifest, workstream, spec, ledgerAtStart, config.permits?.policy, priorFailure, priorDiagnosis);
981
+ let invocation;
982
+ let spawnFailure;
983
+ try {
984
+ invocation = await invokeAgent(agentRunner, attempt.agent, brief, root, permits, attempt.role, transcriptSink(attempt.label.replaceAll(" ", "-")));
985
+ }
986
+ catch (error) {
987
+ // The agent process never started — its command is wrong, missing,
988
+ // or unrunnable. Nothing was built; this is the captured
989
+ // silent-exit incident (SC-12). Make it a diagnosed failure via the
990
+ // existing retry/park machinery below, never a process exit.
991
+ spawnFailure =
992
+ `the ${attempt.label} agent could not be spawned: ` +
993
+ `${error.message}. The configured command ` +
994
+ `\`${describeAgent(attempt.agent)}\` did not start, so nothing was built.`;
995
+ invocation = { exitCode: 1, output: "" };
996
+ }
997
+ recordTranscript(invocation.transcript);
781
998
  const summary = resolveSummary(invocation.output);
782
999
  base.summary = summary.text;
1000
+ await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
783
1001
  const parsed = extractDecisions(invocation.output);
784
1002
  base.decisionErrors.push(...parsed.errors);
785
1003
  await journalDecisions(workstream, parsed.decisions, baseCommit);
@@ -789,6 +1007,13 @@ export async function runProgram(options) {
789
1007
  // no diagnosis is spent on them, because no read of the tree could add
790
1008
  // anything to what the runner already knows.
791
1009
  let deterministicFailure = false;
1010
+ // A spawn failure takes precedence over whatever verifyAttempt saw
1011
+ // (the untouched tree can verify green on its own) — no reviewer
1012
+ // diagnosis is spent reading an empty diff.
1013
+ if (spawnFailure !== undefined) {
1014
+ failure = spawnFailure;
1015
+ deterministicFailure = true;
1016
+ }
792
1017
  // A brief that never reached the agent's stdin means the process
793
1018
  // crashed at startup — whatever it printed was not an answer to the
794
1019
  // brief, even when it exited 0.
@@ -836,6 +1061,7 @@ export async function runProgram(options) {
836
1061
  spec,
837
1062
  config,
838
1063
  agentRunner,
1064
+ permits,
839
1065
  verifyRunner,
840
1066
  git,
841
1067
  reviewer,
@@ -844,12 +1070,18 @@ export async function runProgram(options) {
844
1070
  greenCommit: c0,
845
1071
  now,
846
1072
  log,
1073
+ transcriptSink,
847
1074
  })
848
1075
  : undefined;
849
1076
  // The manifest's single commit field records the workstream's final
850
1077
  // verified state — after any kept fix, that is the fix commit, not
851
1078
  // the earlier green one.
852
1079
  let finalCommit = critique?.finalCommit ?? c0;
1080
+ if (critique && critique.stageErrors.length > 0) {
1081
+ (base.stageErrors ??= []).push(...critique.stageErrors);
1082
+ }
1083
+ for (const transcript of critique?.transcripts ?? [])
1084
+ recordTranscript(transcript);
853
1085
  // Findings anchor to c0 (the green, pre-critique commit) — the
854
1086
  // honest rollback point — while the decider below diffs from
855
1087
  // baseCommit (pre-workstream), so it sees the whole workstream.
@@ -867,23 +1099,35 @@ export async function runProgram(options) {
867
1099
  workstream.status = "complete";
868
1100
  if (finalCommit !== undefined)
869
1101
  workstream.commit = finalCommit;
870
- await saveManifest(root, options.programId, manifest);
871
- await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1102
+ await saveManifest(root, options.programId, manifest, { log });
1103
+ {
1104
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1105
+ if (spawnErrors.length > 0)
1106
+ (base.stageErrors ??= []).push(...spawnErrors);
1107
+ }
872
1108
  // Findings are triaged against the pre-workstream diff (baseCommit),
873
1109
  // the same one the decider reviews decisions against — c0/finalCommit
874
1110
  // are the fix loop's own rollback anchors, a different thing.
875
- await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1111
+ {
1112
+ const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1113
+ if (spawnErrors.length > 0)
1114
+ (base.stageErrors ??= []).push(...spawnErrors);
1115
+ }
876
1116
  // A fix-now triage drives exactly one bounded fix attempt, here —
877
1117
  // after the triage that produced it, before the run advances. Only
878
1118
  // reachable when there is a green commit to attempt from and a
879
1119
  // decider that could have produced a fix-now verdict in the first
880
1120
  // place (SC-13).
881
1121
  if (finalCommit !== undefined && decider) {
882
- const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit);
883
- if (driven !== finalCommit) {
884
- finalCommit = driven;
1122
+ const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit, transcriptSink("fix-now-fix"));
1123
+ recordTranscript(driven.transcript);
1124
+ if (driven.spawnError !== undefined) {
1125
+ (base.stageErrors ??= []).push(driven.spawnError);
1126
+ }
1127
+ if (driven.commit !== finalCommit) {
1128
+ finalCommit = driven.commit;
885
1129
  workstream.commit = finalCommit;
886
- await saveManifest(root, options.programId, manifest);
1130
+ await saveManifest(root, options.programId, manifest, { log });
887
1131
  }
888
1132
  }
889
1133
  if (critique)
@@ -907,8 +1151,12 @@ export async function runProgram(options) {
907
1151
  !resumedIds.has(workstream.id) &&
908
1152
  failureFingerprint(failure) === baselineFingerprint) {
909
1153
  workstream.status = "failed";
910
- await saveManifest(root, options.programId, manifest);
911
- await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1154
+ await saveManifest(root, options.programId, manifest, { log });
1155
+ {
1156
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1157
+ if (spawnErrors.length > 0)
1158
+ (base.stageErrors ??= []).push(...spawnErrors);
1159
+ }
912
1160
  base.environmental = true;
913
1161
  base.outcome = {
914
1162
  status: "failed",
@@ -930,10 +1178,14 @@ export async function runProgram(options) {
930
1178
  failureFingerprint(failure) === implementerFingerprint;
931
1179
  if (index === attempts.length - 1 || stuck) {
932
1180
  workstream.status = "failed";
933
- await saveManifest(root, options.programId, manifest);
1181
+ await saveManifest(root, options.programId, manifest, { log });
934
1182
  // The work stays in the tree for a resume; decisions made on the way
935
1183
  // to a failure are still journaled and still reviewable.
936
- await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1184
+ {
1185
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1186
+ if (spawnErrors.length > 0)
1187
+ (base.stageErrors ??= []).push(...spawnErrors);
1188
+ }
937
1189
  base.outcome = {
938
1190
  status: "failed",
939
1191
  reason: stuck
@@ -953,19 +1205,30 @@ export async function runProgram(options) {
953
1205
  // a newer one. Deterministic failures (no-op, undelivered brief) are
954
1206
  // never diagnosed: the runner already knows exactly what happened,
955
1207
  // and a reviewer read of an empty diff could only muddy it.
956
- priorDiagnosis = reviewer && !deterministicFailure
957
- ? await diagnoseFailure({
1208
+ if (reviewer && !deterministicFailure) {
1209
+ const diagnosed = await diagnoseFailure({
958
1210
  root,
959
1211
  manifest,
960
1212
  workstream,
961
1213
  spec,
962
1214
  agentRunner,
1215
+ permits,
963
1216
  reviewer,
964
1217
  git,
965
1218
  baseCommit,
966
1219
  failure,
967
- })
968
- : undefined;
1220
+ observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
1221
+ });
1222
+ priorDiagnosis = diagnosed.diagnosis;
1223
+ if (diagnosed.spawnError !== undefined) {
1224
+ (base.stageErrors ??= []).push(diagnosed.spawnError);
1225
+ log(`${workstream.id}: ${diagnosed.spawnError}`);
1226
+ }
1227
+ await journalDenials(workstream.id, "reviewer", diagnosed.transcript);
1228
+ }
1229
+ else {
1230
+ priorDiagnosis = undefined;
1231
+ }
969
1232
  if (priorDiagnosis !== undefined) {
970
1233
  (base.failureDiagnoses ??= []).push({
971
1234
  attempt: attempt.label,
@@ -991,6 +1254,23 @@ export async function runProgram(options) {
991
1254
  log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
992
1255
  }
993
1256
  }
1257
+ /**
1258
+ * Journals `permission-denied` events for one spawn (WS-03) — every
1259
+ * workstream-attributed spawn calls this once, passing its own transcript
1260
+ * and (for build roles only) the agent's `needsPermission` self-reports.
1261
+ * A no-op when the spawn produced neither.
1262
+ */
1263
+ async function journalDenials(workstreamId, attempt, transcript, needsPermission = []) {
1264
+ const events = buildDenialEvents(workstreamId, attempt, transcript?.denials ?? [], needsPermission, config.permits?.deny ?? [], now().toISOString());
1265
+ if (events.length === 0)
1266
+ return;
1267
+ await appendLedgerEvents(root, options.programId, events);
1268
+ for (const event of events) {
1269
+ if (event.kind !== "permission-denied")
1270
+ continue;
1271
+ log(`${workstreamId} ${attempt}: denied \`${event.command}\` (${event.source})`);
1272
+ }
1273
+ }
994
1274
  function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
995
1275
  return reviewDecisions({
996
1276
  root,
@@ -1001,12 +1281,15 @@ export async function runProgram(options) {
1001
1281
  baseCommit,
1002
1282
  decider,
1003
1283
  agentRunner,
1284
+ permits,
1004
1285
  git,
1005
1286
  isRepository,
1006
1287
  reviewed,
1288
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1007
1289
  triaged,
1008
1290
  now,
1009
1291
  log,
1292
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1010
1293
  });
1011
1294
  }
1012
1295
  function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
@@ -1019,9 +1302,12 @@ export async function runProgram(options) {
1019
1302
  baseCommit,
1020
1303
  decider,
1021
1304
  agentRunner,
1305
+ permits,
1022
1306
  git,
1023
1307
  isRepository,
1024
1308
  reviewed,
1309
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1310
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1025
1311
  triaged,
1026
1312
  now,
1027
1313
  log,
@@ -1037,13 +1323,13 @@ export async function runProgram(options) {
1037
1323
  * entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
1038
1324
  * commit; never throws, never blocks the run (SC-15).
1039
1325
  */
1040
- async function driveFixNowFindings(workstream, spec, openFindings, greenCommit) {
1326
+ async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
1041
1327
  const ledger = await readDecisionLedger(root, options.programId);
1042
1328
  const fixNowIds = new Set(ledger.findings
1043
1329
  .filter((record) => record.workstream === workstream.id && record.status === "fix-now")
1044
1330
  .map((record) => record.id));
1045
1331
  if (fixNowIds.size === 0)
1046
- return greenCommit;
1332
+ return { commit: greenCommit };
1047
1333
  const toFix = [];
1048
1334
  const matchedIds = [];
1049
1335
  for (const candidate of openFindings) {
@@ -1056,7 +1342,7 @@ export async function runProgram(options) {
1056
1342
  // An id triaged fix-now but absent from the open set (should not
1057
1343
  // happen — it is where the id came from) is skipped, fail-open.
1058
1344
  if (toFix.length === 0)
1059
- return greenCommit;
1345
+ return { commit: greenCommit };
1060
1346
  const fix = await attemptFix({
1061
1347
  root,
1062
1348
  programId: options.programId,
@@ -1064,6 +1350,7 @@ export async function runProgram(options) {
1064
1350
  spec,
1065
1351
  config,
1066
1352
  agentRunner,
1353
+ permits,
1067
1354
  verifyRunner,
1068
1355
  git,
1069
1356
  agent,
@@ -1071,6 +1358,8 @@ export async function runProgram(options) {
1071
1358
  greenCommit,
1072
1359
  label: "fix-now fix",
1073
1360
  log,
1361
+ now,
1362
+ observe,
1074
1363
  });
1075
1364
  // "kept" only when the attempt both verified clean and actually landed a
1076
1365
  // commit — a clean-but-empty attempt is a decline, not a fix, and must
@@ -1094,7 +1383,12 @@ export async function runProgram(options) {
1094
1383
  log(kept
1095
1384
  ? `${workstream.id}: fix-now fix verified and committed`
1096
1385
  : `${workstream.id}: fix-now fix failed and was escalated — ${note}`);
1097
- return kept ? fix.greenCommit : greenCommit;
1386
+ const resultCommit = kept ? fix.greenCommit : greenCommit;
1387
+ return {
1388
+ commit: resultCommit,
1389
+ ...(fix.spawnError === undefined ? {} : { spawnError: fix.spawnError }),
1390
+ ...(fix.transcript === undefined ? {} : { transcript: fix.transcript }),
1391
+ };
1098
1392
  }
1099
1393
  }
1100
1394
  /**
@@ -1184,7 +1478,7 @@ function notBuiltRoster(results, authorResult) {
1184
1478
  * bug in this function's own body throws before the pass has even run.
1185
1479
  */
1186
1480
  export async function runWholeProgramStage(args) {
1187
- const { root, programId, manifest, config, agentRunner, git, isRepository, results, authorResult, runStartCommit, decider, reviewed, triaged, now, log, fs, } = args;
1481
+ const { root, programId, manifest, config, agentRunner, permits, git, isRepository, results, authorResult, runStartCommit, decider, reviewed, triaged, now, log, fs, } = args;
1188
1482
  const errors = [];
1189
1483
  let commit;
1190
1484
  let commitSkipped = false;
@@ -1215,6 +1509,7 @@ export async function runWholeProgramStage(args) {
1215
1509
  manifest,
1216
1510
  config,
1217
1511
  agentRunner,
1512
+ permits,
1218
1513
  diff,
1219
1514
  ...(base === undefined ? {} : { baseCommit: base }),
1220
1515
  ...(notBuilt.length === 0 ? {} : { notBuilt }),
@@ -1261,7 +1556,7 @@ export async function runWholeProgramStage(args) {
1261
1556
  }
1262
1557
  if (journaled && events.length > 0) {
1263
1558
  try {
1264
- await triageFindings({
1559
+ const spawnErrors = await triageFindings({
1265
1560
  root,
1266
1561
  programId,
1267
1562
  manifest,
@@ -1270,6 +1565,7 @@ export async function runWholeProgramStage(args) {
1270
1565
  baseCommit: reviewCommit,
1271
1566
  decider,
1272
1567
  agentRunner,
1568
+ permits,
1273
1569
  git,
1274
1570
  isRepository,
1275
1571
  reviewed,
@@ -1277,6 +1573,7 @@ export async function runWholeProgramStage(args) {
1277
1573
  now,
1278
1574
  log,
1279
1575
  });
1576
+ errors.push(...spawnErrors);
1280
1577
  }
1281
1578
  catch (error) {
1282
1579
  errors.push(`the decider failed to triage the whole-program findings: ${error.message}`);
@@ -1387,14 +1684,30 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
1387
1684
  * undefined and the retry proceeds on the raw verify output alone.
1388
1685
  */
1389
1686
  async function diagnoseFailure(options) {
1390
- const { root, manifest, workstream, spec, agentRunner, reviewer, git, baseCommit, failure } = options;
1687
+ const { root, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
1391
1688
  const rawDiff = baseCommit !== undefined ? await git.diffSince(root, baseCommit) : "";
1392
1689
  const brief = failureDiagnosisBrief(manifest, workstream, clipForReview(spec, "spec").text, clipForReview(rawDiff, "diff").text, failure);
1393
- const invocation = await invokeAgent(agentRunner, reviewer, brief, root);
1690
+ let invocation;
1691
+ try {
1692
+ invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe);
1693
+ }
1694
+ catch (error) {
1695
+ return {
1696
+ spawnError: `the reviewer could not be spawned while diagnosing the ${workstream.id} ` +
1697
+ `failure: ${error.message}; the next attempt proceeds undiagnosed`,
1698
+ };
1699
+ }
1700
+ // The transcript is captured whether or not the reviewer's reply parsed —
1701
+ // a denial it hit while reading is a fact regardless of what it concluded
1702
+ // (SC-12: every workstream-attributed spawn's denials are journaled).
1703
+ const transcript = invocation.transcript;
1394
1704
  if (invocation.exitCode !== 0)
1395
- return undefined;
1705
+ return transcript === undefined ? {} : { transcript };
1396
1706
  const text = invocation.output.trim();
1397
- return text === "" ? undefined : tail(text, 2000);
1707
+ return {
1708
+ ...(transcript === undefined ? {} : { transcript }),
1709
+ ...(text === "" ? {} : { diagnosis: tail(text, 2000) }),
1710
+ };
1398
1711
  }
1399
1712
  function successCriteriaLines(manifest) {
1400
1713
  if (manifest.successCriteria.length === 0) {
@@ -1554,10 +1867,35 @@ function testCritiqueFixBrief(workstream, spec, findings) {
1554
1867
  * whether (and how many times) this is called.
1555
1868
  */
1556
1869
  async function attemptFix(options) {
1557
- const { root, programId, workstream, spec, config, agentRunner, verifyRunner, git, agent, findings, greenCommit, label, } = options;
1870
+ const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, } = options;
1558
1871
  const brief = testCritiqueFixBrief(workstream, spec, findings);
1559
- const invocation = await invokeAgent(agentRunner, agent, brief, root);
1560
- const summary = resolveSummary(invocation.output).text;
1872
+ let invocation;
1873
+ try {
1874
+ invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe);
1875
+ }
1876
+ catch (error) {
1877
+ const spawnError = `the ${label} implementer could not be spawned: ${error.message}; ` +
1878
+ "the fix was not attempted and the green state was preserved.";
1879
+ return {
1880
+ outcome: "failed",
1881
+ greenCommit,
1882
+ summary: "(fix not attempted — the implementer could not be spawned)",
1883
+ failure: spawnError,
1884
+ spawnError,
1885
+ };
1886
+ }
1887
+ const transcript = invocation.transcript;
1888
+ const resolvedSummary = resolveSummary(invocation.output);
1889
+ const summary = resolvedSummary.text;
1890
+ const denialEvents = buildDenialEvents(workstream.id, label, transcript?.denials ?? [], resolvedSummary.needsPermission, config.permits?.deny ?? [], now().toISOString());
1891
+ if (denialEvents.length > 0) {
1892
+ await appendLedgerEvents(root, programId, denialEvents);
1893
+ for (const event of denialEvents) {
1894
+ if (event.kind !== "permission-denied")
1895
+ continue;
1896
+ log(`${workstream.id} ${label}: denied \`${event.command}\` (${event.source})`);
1897
+ }
1898
+ }
1561
1899
  const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
1562
1900
  if (failure === undefined) {
1563
1901
  // The runner's own pending ledger and manifest writes under
@@ -1579,6 +1917,7 @@ async function attemptFix(options) {
1579
1917
  greenCommit: commit ?? greenCommit,
1580
1918
  ...(commit === undefined ? {} : { commit }),
1581
1919
  summary,
1920
+ ...(transcript === undefined ? {} : { transcript }),
1582
1921
  };
1583
1922
  }
1584
1923
  // The same uncommitted bookkeeping the clean path guards against sweeping
@@ -1594,7 +1933,7 @@ async function attemptFix(options) {
1594
1933
  const snapshot = await snapshotProgramsDir(root);
1595
1934
  await git.resetHard(root, greenCommit);
1596
1935
  await restoreProgramsDir(root, snapshot);
1597
- return { outcome: "failed", greenCommit, summary, failure };
1936
+ return { outcome: "failed", greenCommit, summary, failure, ...(transcript === undefined ? {} : { transcript }) };
1598
1937
  }
1599
1938
  /**
1600
1939
  * The test-critique driver: wires WS-01's bounded review loop to the two
@@ -1604,11 +1943,19 @@ async function attemptFix(options) {
1604
1943
  * failure), so a review can never turn green work red.
1605
1944
  */
1606
1945
  async function runTestCritique(options) {
1607
- const { root, manifest, workstream, spec, config, agentRunner, verifyRunner, git, reviewer, agent, baseCommit, log, } = options;
1946
+ const { root, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, baseCommit, now, log, transcriptSink, } = options;
1608
1947
  let greenCommit = options.greenCommit;
1609
1948
  let diffClipped = false;
1949
+ const stageErrors = [];
1950
+ const transcripts = [];
1610
1951
  if (!reviewer) {
1611
- return { outcome: reviewerAbsentOutcome(), finalCommit: greenCommit, diffClipped };
1952
+ return {
1953
+ outcome: reviewerAbsentOutcome(),
1954
+ finalCommit: greenCommit,
1955
+ diffClipped,
1956
+ stageErrors,
1957
+ transcripts,
1958
+ };
1612
1959
  }
1613
1960
  const locate = (file) => locateInRepo(root, file);
1614
1961
  const review = async (_round, priorOpen) => {
@@ -1618,7 +1965,28 @@ async function runTestCritique(options) {
1618
1965
  if (diff.clipped || clippedSpec.clipped)
1619
1966
  diffClipped = true;
1620
1967
  const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
1621
- const invocation = await invokeAgent(agentRunner, reviewer, brief, root);
1968
+ let invocation;
1969
+ try {
1970
+ invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"));
1971
+ }
1972
+ catch (error) {
1973
+ // Fail open, matching the existing reviewer-error path: no findings
1974
+ // this round, the workstream outcome is unaffected, and the reason is
1975
+ // recorded durably rather than only logged (SC-12).
1976
+ stageErrors.push(`the reviewer could not be spawned during test critique: ${error.message}`);
1977
+ return { findings: [], errors: [], ran: false };
1978
+ }
1979
+ if (invocation.transcript) {
1980
+ const denialEvents = buildDenialEvents(workstream.id, "reviewer", invocation.transcript.denials, [], config.permits?.deny ?? [], now().toISOString());
1981
+ if (denialEvents.length > 0) {
1982
+ await appendLedgerEvents(root, manifest.program.id, denialEvents);
1983
+ for (const event of denialEvents) {
1984
+ if (event.kind !== "permission-denied")
1985
+ continue;
1986
+ log(`${workstream.id} reviewer: denied \`${event.command}\` (${event.source})`);
1987
+ }
1988
+ }
1989
+ }
1622
1990
  const parsed = extractFindings(invocation.output);
1623
1991
  const findings = verifyEvidence(parsed.findings, locate);
1624
1992
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
@@ -1632,6 +2000,7 @@ async function runTestCritique(options) {
1632
2000
  spec,
1633
2001
  config,
1634
2002
  agentRunner,
2003
+ permits,
1635
2004
  verifyRunner,
1636
2005
  git,
1637
2006
  agent,
@@ -1639,8 +2008,14 @@ async function runTestCritique(options) {
1639
2008
  greenCommit,
1640
2009
  label: "test critique fix",
1641
2010
  log,
2011
+ now,
2012
+ observe: transcriptSink("test-critique-fix"),
1642
2013
  });
1643
2014
  greenCommit = fix.greenCommit;
2015
+ if (fix.transcript)
2016
+ transcripts.push(fix.transcript);
2017
+ if (fix.spawnError !== undefined)
2018
+ stageErrors.push(fix.spawnError);
1644
2019
  if (fix.outcome === "kept") {
1645
2020
  log(`${workstream.id}: test critique fix verified and committed`);
1646
2021
  return { note: `${fix.summary} (fix verified and committed)` };
@@ -1649,7 +2024,7 @@ async function runTestCritique(options) {
1649
2024
  return { note: `${fix.summary} (fix failed verification and was discarded; the green state was preserved)` };
1650
2025
  };
1651
2026
  const outcome = await runReviewPass({ review, respond });
1652
- return { outcome, finalCommit: greenCommit, diffClipped };
2027
+ return { outcome, finalCommit: greenCommit, diffClipped, stageErrors, transcripts };
1653
2028
  }
1654
2029
  function renderSpecsSection(authorResult) {
1655
2030
  if (authorResult.results.length === 0)
@@ -1883,7 +2258,84 @@ function renderAwaitingHumanSection(manifest, preflight, resumeCommand) {
1883
2258
  lines.push(`Resume with: \`${resumeCommand ?? `npx --yes @wildorder/nightshift run ${manifest.program.id}`}\``, "");
1884
2259
  return lines;
1885
2260
  }
1886
- export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand) {
2261
+ /**
2262
+ * A workstream's Commands subsection (WS-02 SC-10): executed commands
2263
+ * deduplicated with counts, denied commands verbatim with the CLI's reason,
2264
+ * or — when nothing this workstream's build-role spawns produced was
2265
+ * observed (Codex, an unparsed stream, an operator-configured or
2266
+ * operator-`--output-format` role) — one honest line naming the agent
2267
+ * command so the reader knows why nothing is listed.
2268
+ */
2269
+ function renderWorkstreamCommands(result) {
2270
+ const observed = (result.transcripts ?? []).filter((transcript) => transcript.observed);
2271
+ if (observed.length === 0) {
2272
+ return [
2273
+ ` - Commands: not observable for \`${result.buildAgentCommand ?? "the configured agent"}\``,
2274
+ ];
2275
+ }
2276
+ const counts = new Map();
2277
+ for (const transcript of observed) {
2278
+ for (const entry of transcript.commands) {
2279
+ counts.set(entry.command, (counts.get(entry.command) ?? 0) + 1);
2280
+ }
2281
+ }
2282
+ const denials = observed.flatMap((transcript) => transcript.denials);
2283
+ // A line mid-stream that failed to parse could have been a command or a
2284
+ // denial — the transcript itself cannot say which — so this is surfaced
2285
+ // here rather than letting an incomplete transcript stand as authoritative.
2286
+ const corrupted = observed.some((transcript) => transcript.corrupted);
2287
+ if (counts.size === 0 && denials.length === 0 && !corrupted) {
2288
+ return [" - Commands: none observed"];
2289
+ }
2290
+ const lines = [" - Commands:"];
2291
+ if (corrupted) {
2292
+ lines.push(" - warning: a line in this transcript could not be parsed — it may be missing a command or denial");
2293
+ }
2294
+ for (const [command, count] of counts) {
2295
+ lines.push(` - \`${command}\`${count > 1 ? ` ×${count}` : ""}`);
2296
+ }
2297
+ for (const denial of denials) {
2298
+ lines.push(` - denied: \`${denial.command}\` — ${denial.reason}`);
2299
+ }
2300
+ return lines;
2301
+ }
2302
+ /**
2303
+ * The program-level `## Commands denied` section (SC-12): every
2304
+ * `permission-denied` ledger record, grouped by workstream in the run's own
2305
+ * workstream order, each rendered by the single `renderDenial` a human,
2306
+ * `decide`, and this report all share. Omits the heading entirely when the
2307
+ * ledger carries no denials, so a clean run's report is unchanged. Each
2308
+ * workstream group closes with the run's retry command, so the operator sees
2309
+ * the remediation to apply and the command to re-run in one place.
2310
+ */
2311
+ function renderCommandsDeniedSection(results, ledger, programId) {
2312
+ if (ledger.denials.length === 0)
2313
+ return [];
2314
+ const byWorkstream = new Map();
2315
+ for (const denial of ledger.denials) {
2316
+ const list = byWorkstream.get(denial.workstream) ?? [];
2317
+ list.push(denial);
2318
+ byWorkstream.set(denial.workstream, list);
2319
+ }
2320
+ const orderedIds = [
2321
+ ...results.map((result) => result.id),
2322
+ ...[...byWorkstream.keys()].filter((workstreamId) => !results.some((result) => result.id === workstreamId)),
2323
+ ];
2324
+ const lines = ["## Commands denied", ""];
2325
+ for (const workstreamId of orderedIds) {
2326
+ const denials = byWorkstream.get(workstreamId);
2327
+ if (denials === undefined || denials.length === 0)
2328
+ continue;
2329
+ const name = results.find((result) => result.id === workstreamId)?.name ?? workstreamId;
2330
+ lines.push(`### ${workstreamId} ${name}`, "");
2331
+ for (const denial of denials) {
2332
+ lines.push(...renderDenial(denial));
2333
+ }
2334
+ lines.push("", `Retry with: \`npx --yes @wildorder/nightshift run ${programId}\``, "");
2335
+ }
2336
+ return lines;
2337
+ }
2338
+ export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand, manifestAmended) {
1887
2339
  const programId = manifest.program.id;
1888
2340
  const built = results.filter((result) => result.outcome.status === "complete" ||
1889
2341
  result.outcome.status === "skipped").length;
@@ -1899,8 +2351,11 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1899
2351
  "",
1900
2352
  outcomeLine,
1901
2353
  "",
1902
- ...renderSpecsSection(authorResult),
1903
2354
  ];
2355
+ if (manifestAmended === true) {
2356
+ lines.push("## Manifest amended mid-run", "", "The manifest on disk was edited while this run held it. The edit was", "merged: the human's amendment was kept and the run's own status", "changes were preserved on top of it.", "");
2357
+ }
2358
+ lines.push(...renderSpecsSection(authorResult));
1904
2359
  const escalated = escalatedRecords(ledger);
1905
2360
  if (escalated.length > 0) {
1906
2361
  lines.push("## Needs your attention", "", "The decider reviewed these choices and findings and believes you", "might decide differently. Each is self-contained below: why it is", "here, the alternatives, and a command to flip it if you disagree.", "");
@@ -1925,6 +2380,12 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1925
2380
  for (const diagnosis of result.failureDiagnoses ?? []) {
1926
2381
  lines.push(`**Reviewer diagnosis (after the ${diagnosis.attempt} attempt):**`, "", diagnosis.verdict, "");
1927
2382
  }
2383
+ const workstreamDenials = ledger.denials.filter((denial) => denial.workstream === result.id);
2384
+ if (workstreamDenials.length > 0) {
2385
+ lines.push("Commands denied: " +
2386
+ workstreamDenials.map((denial) => `\`${denial.command}\``).join(", ") +
2387
+ " — see Commands denied below.", "");
2388
+ }
1928
2389
  lines.push(`Retry after fixing: \`npx --yes @wildorder/nightshift run ${manifest.program.id}\``, "(completed workstreams are skipped automatically).", "");
1929
2390
  }
1930
2391
  }
@@ -1949,6 +2410,9 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1949
2410
  lines.push(" The reviewer saw input clipped for length.");
1950
2411
  }
1951
2412
  }
2413
+ if (result.outcome.status === "complete" || result.outcome.status === "failed") {
2414
+ lines.push(...renderWorkstreamCommands(result));
2415
+ }
1952
2416
  }
1953
2417
  lines.push("");
1954
2418
  if (wholeProgram !== undefined) {
@@ -1964,11 +2428,22 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1964
2428
  lines.push(...renderRecord(record, { density: "compact", programId }), "");
1965
2429
  }
1966
2430
  }
2431
+ lines.push(...renderCommandsDeniedSection(results, ledger, programId));
1967
2432
  lines.push(...renderKnownLimitations(results, authorResult, ledger, escalated, triagedThisRun, programId, wholeProgram));
1968
2433
  const decisionErrors = results.flatMap((result) => result.decisionErrors);
1969
2434
  if (decisionErrors.length > 0) {
1970
2435
  lines.push("## Decision blocks the runner could not read", "", ...decisionErrors.map((error) => `- ${error}`), "");
1971
2436
  }
2437
+ const stageErrors = [
2438
+ ...results.flatMap((result) => result.stageErrors ?? []),
2439
+ ...authorResult.results.flatMap((result) => result.stageErrors ?? []),
2440
+ ];
2441
+ if (stageErrors.length > 0) {
2442
+ lines.push("## Agent stages that could not run", "", "A reviewer, decider, or fix implementer could not be spawned after " +
2443
+ "the workstream it concerns had already verified green; the " +
2444
+ "workstream's own outcome is unaffected, but the reason it fell " +
2445
+ "open belongs here.", "", ...stageErrors.map((error) => `- ${error}`), "");
2446
+ }
1972
2447
  return lines.join("\n");
1973
2448
  }
1974
2449
  //# sourceMappingURL=run-program.js.map