@wildorder/nightshift 0.13.0 → 0.15.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 +64 -26
  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 +13 -3
  19. package/dist/author.d.ts.map +1 -1
  20. package/dist/author.js +267 -30
  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 +74 -15
  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 +29 -1
  50. package/dist/manifest.d.ts.map +1 -1
  51. package/dist/manifest.js +236 -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 +539 -56
  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
@@ -389,10 +577,18 @@ export async function runProgram(options) {
389
577
  // workstreams are already committed and its awaiting ones were never
390
578
  // built, so a re-dispatch after one always starts on a clean tree and
391
579
  // needs neither softening — adding it here would loosen the dirty-start
392
- // refusal for no benefit.
580
+ // refusal for no benefit. `specStatus === "in_progress"` admits an
581
+ // interrupted authoring residue (WS-01 SC-02): an author agent interrupted
582
+ // mid-write leaves implementation `status` at `not_started` while stamping
583
+ // this marker and a dirty partial `taskFile` — without this clause the
584
+ // dirty-tree refusal below would kill the run before the authoring
585
+ // classifier ever gets a chance to re-author it. `complete` is
586
+ // deliberately not a resume signal: a completed spec's `taskFile` is
587
+ // already committed, so the tree is clean.
393
588
  const resuming = manifest.workstreams.some((workstream) => workstream.status === "failed" ||
394
589
  workstream.status === "in_progress" ||
395
- workstream.status === "parked");
590
+ workstream.status === "parked" ||
591
+ workstream.specStatus === "in_progress");
396
592
  const isRepository = await git.isRepository(root);
397
593
  if (!isRepository) {
398
594
  log("warning: not a git repository — commits, decision anchors, and replay are unavailable");
@@ -522,7 +718,7 @@ export async function runProgram(options) {
522
718
  // mutates neither the manifest nor the ledger.
523
719
  if (preflight.events.length > 0) {
524
720
  await appendLedgerEvents(root, options.programId, preflight.events);
525
- await saveManifest(root, options.programId, manifest);
721
+ await saveManifest(root, options.programId, manifest, { log });
526
722
  }
527
723
  // Subject ids already sent to the decider this run — shared across the
528
724
  // authoring and build stages (a run is one process) and across the
@@ -545,6 +741,7 @@ export async function runProgram(options) {
545
741
  programId: options.programId,
546
742
  config,
547
743
  agentRunner,
744
+ permits,
548
745
  git,
549
746
  log,
550
747
  now,
@@ -620,7 +817,7 @@ export async function runProgram(options) {
620
817
  // partial regardless (see the intermission classification below).
621
818
  if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
622
819
  workstream.status = "awaiting_human";
623
- await saveManifest(root, options.programId, manifest);
820
+ await saveManifest(root, options.programId, manifest, { log });
624
821
  const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
625
822
  const reason = ownUnmet.length > 0
626
823
  ? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
@@ -680,13 +877,14 @@ export async function runProgram(options) {
680
877
  : intermission
681
878
  ? "awaiting_human"
682
879
  : "partial";
683
- await saveManifest(root, options.programId, manifest);
880
+ await saveManifest(root, options.programId, manifest, { log });
684
881
  const wholeProgram = await runWholeProgramStage({
685
882
  root,
686
883
  programId: options.programId,
687
884
  manifest,
688
885
  config,
689
886
  agentRunner,
887
+ permits,
690
888
  git,
691
889
  isRepository,
692
890
  results,
@@ -715,7 +913,7 @@ export async function runProgram(options) {
715
913
  // projected ledger carries no run identifier of its own. It excludes
716
914
  // ids that were merely sent but whose invocation failed or returned no
717
915
  // valid verdict.
718
- renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand), "utf8");
916
+ renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart), "utf8");
719
917
  if (isRepository) {
720
918
  await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
721
919
  }
@@ -740,7 +938,18 @@ export async function runProgram(options) {
740
938
  outcome: { status: "failed", reason: "not attempted" },
741
939
  decisionIds: [],
742
940
  decisionErrors: [],
941
+ buildAgentCommand: describeAgent(agent),
743
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
+ }
744
953
  let spec;
745
954
  try {
746
955
  spec = await readFile(join(root, workstream.taskFile), "utf8");
@@ -753,7 +962,7 @@ export async function runProgram(options) {
753
962
  return base;
754
963
  }
755
964
  workstream.status = "in_progress";
756
- await saveManifest(root, options.programId, manifest);
965
+ await saveManifest(root, options.programId, manifest, { log });
757
966
  const baseCommit = isRepository
758
967
  ? await git.currentCommit(root)
759
968
  : undefined;
@@ -761,10 +970,10 @@ export async function runProgram(options) {
761
970
  let priorDiagnosis;
762
971
  let implementerFingerprint;
763
972
  const attempts = [
764
- { agent, label: "implementer" },
973
+ { agent, label: "implementer", role: "agent" },
765
974
  ];
766
975
  if (recovery && !recovery.borrowedImplementer) {
767
- attempts.push({ agent: recovery.agent, label: "recovery" });
976
+ attempts.push({ agent: recovery.agent, label: "recovery", role: "recoveryAgent" });
768
977
  // The third seat exists only when a reviewer can inform it. An
769
978
  // uninformed retry has already been spent (recovery); running the
770
979
  // roster again blind is a coin flip the ledger should hear about
@@ -772,14 +981,31 @@ export async function runProgram(options) {
772
981
  // the reviewer's diagnosis in hand, a composition neither prior
773
982
  // attempt had.
774
983
  if (reviewer)
775
- attempts.push({ agent, label: "informed retry" });
984
+ attempts.push({ agent, label: "informed retry", role: "agent" });
776
985
  }
777
986
  for (const [index, attempt] of attempts.entries()) {
778
987
  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);
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);
781
1006
  const summary = resolveSummary(invocation.output);
782
1007
  base.summary = summary.text;
1008
+ await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
783
1009
  const parsed = extractDecisions(invocation.output);
784
1010
  base.decisionErrors.push(...parsed.errors);
785
1011
  await journalDecisions(workstream, parsed.decisions, baseCommit);
@@ -789,6 +1015,13 @@ export async function runProgram(options) {
789
1015
  // no diagnosis is spent on them, because no read of the tree could add
790
1016
  // anything to what the runner already knows.
791
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
+ }
792
1025
  // A brief that never reached the agent's stdin means the process
793
1026
  // crashed at startup — whatever it printed was not an answer to the
794
1027
  // brief, even when it exited 0.
@@ -836,6 +1069,7 @@ export async function runProgram(options) {
836
1069
  spec,
837
1070
  config,
838
1071
  agentRunner,
1072
+ permits,
839
1073
  verifyRunner,
840
1074
  git,
841
1075
  reviewer,
@@ -844,12 +1078,18 @@ export async function runProgram(options) {
844
1078
  greenCommit: c0,
845
1079
  now,
846
1080
  log,
1081
+ transcriptSink,
847
1082
  })
848
1083
  : undefined;
849
1084
  // The manifest's single commit field records the workstream's final
850
1085
  // verified state — after any kept fix, that is the fix commit, not
851
1086
  // the earlier green one.
852
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);
853
1093
  // Findings anchor to c0 (the green, pre-critique commit) — the
854
1094
  // honest rollback point — while the decider below diffs from
855
1095
  // baseCommit (pre-workstream), so it sees the whole workstream.
@@ -867,23 +1107,35 @@ export async function runProgram(options) {
867
1107
  workstream.status = "complete";
868
1108
  if (finalCommit !== undefined)
869
1109
  workstream.commit = finalCommit;
870
- await saveManifest(root, options.programId, manifest);
871
- await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
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
+ }
872
1116
  // Findings are triaged against the pre-workstream diff (baseCommit),
873
1117
  // the same one the decider reviews decisions against — c0/finalCommit
874
1118
  // are the fix loop's own rollback anchors, a different thing.
875
- await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1119
+ {
1120
+ const spawnErrors = await reviewWorkstreamFindings(workstream.id, findingEvents, baseCommit);
1121
+ if (spawnErrors.length > 0)
1122
+ (base.stageErrors ??= []).push(...spawnErrors);
1123
+ }
876
1124
  // A fix-now triage drives exactly one bounded fix attempt, here —
877
1125
  // after the triage that produced it, before the run advances. Only
878
1126
  // reachable when there is a green commit to attempt from and a
879
1127
  // decider that could have produced a fix-now verdict in the first
880
1128
  // place (SC-13).
881
1129
  if (finalCommit !== undefined && decider) {
882
- const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit);
883
- if (driven !== finalCommit) {
884
- finalCommit = driven;
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;
885
1137
  workstream.commit = finalCommit;
886
- await saveManifest(root, options.programId, manifest);
1138
+ await saveManifest(root, options.programId, manifest, { log });
887
1139
  }
888
1140
  }
889
1141
  if (critique)
@@ -907,8 +1159,12 @@ export async function runProgram(options) {
907
1159
  !resumedIds.has(workstream.id) &&
908
1160
  failureFingerprint(failure) === baselineFingerprint) {
909
1161
  workstream.status = "failed";
910
- await saveManifest(root, options.programId, manifest);
911
- await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
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
+ }
912
1168
  base.environmental = true;
913
1169
  base.outcome = {
914
1170
  status: "failed",
@@ -930,10 +1186,14 @@ export async function runProgram(options) {
930
1186
  failureFingerprint(failure) === implementerFingerprint;
931
1187
  if (index === attempts.length - 1 || stuck) {
932
1188
  workstream.status = "failed";
933
- await saveManifest(root, options.programId, manifest);
1189
+ await saveManifest(root, options.programId, manifest, { log });
934
1190
  // The work stays in the tree for a resume; decisions made on the way
935
1191
  // to a failure are still journaled and still reviewable.
936
- await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1192
+ {
1193
+ const spawnErrors = await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
1194
+ if (spawnErrors.length > 0)
1195
+ (base.stageErrors ??= []).push(...spawnErrors);
1196
+ }
937
1197
  base.outcome = {
938
1198
  status: "failed",
939
1199
  reason: stuck
@@ -953,19 +1213,30 @@ export async function runProgram(options) {
953
1213
  // a newer one. Deterministic failures (no-op, undelivered brief) are
954
1214
  // never diagnosed: the runner already knows exactly what happened,
955
1215
  // and a reviewer read of an empty diff could only muddy it.
956
- priorDiagnosis = reviewer && !deterministicFailure
957
- ? await diagnoseFailure({
1216
+ if (reviewer && !deterministicFailure) {
1217
+ const diagnosed = await diagnoseFailure({
958
1218
  root,
959
1219
  manifest,
960
1220
  workstream,
961
1221
  spec,
962
1222
  agentRunner,
1223
+ permits,
963
1224
  reviewer,
964
1225
  git,
965
1226
  baseCommit,
966
1227
  failure,
967
- })
968
- : undefined;
1228
+ observe: transcriptSink(`diagnose-${attempt.label.replaceAll(" ", "-")}`),
1229
+ });
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;
1239
+ }
969
1240
  if (priorDiagnosis !== undefined) {
970
1241
  (base.failureDiagnoses ??= []).push({
971
1242
  attempt: attempt.label,
@@ -991,6 +1262,23 @@ export async function runProgram(options) {
991
1262
  log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
992
1263
  }
993
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")
1278
+ continue;
1279
+ log(`${workstreamId} ${attempt}: denied \`${event.command}\` (${event.source})`);
1280
+ }
1281
+ }
994
1282
  function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
995
1283
  return reviewDecisions({
996
1284
  root,
@@ -1001,12 +1289,15 @@ export async function runProgram(options) {
1001
1289
  baseCommit,
1002
1290
  decider,
1003
1291
  agentRunner,
1292
+ permits,
1004
1293
  git,
1005
1294
  isRepository,
1006
1295
  reviewed,
1296
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1007
1297
  triaged,
1008
1298
  now,
1009
1299
  log,
1300
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
1010
1301
  });
1011
1302
  }
1012
1303
  function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
@@ -1019,9 +1310,12 @@ export async function runProgram(options) {
1019
1310
  baseCommit,
1020
1311
  decider,
1021
1312
  agentRunner,
1313
+ permits,
1022
1314
  git,
1023
1315
  isRepository,
1024
1316
  reviewed,
1317
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1318
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
1025
1319
  triaged,
1026
1320
  now,
1027
1321
  log,
@@ -1037,13 +1331,13 @@ export async function runProgram(options) {
1037
1331
  * entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
1038
1332
  * commit; never throws, never blocks the run (SC-15).
1039
1333
  */
1040
- async function driveFixNowFindings(workstream, spec, openFindings, greenCommit) {
1334
+ async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
1041
1335
  const ledger = await readDecisionLedger(root, options.programId);
1042
1336
  const fixNowIds = new Set(ledger.findings
1043
1337
  .filter((record) => record.workstream === workstream.id && record.status === "fix-now")
1044
1338
  .map((record) => record.id));
1045
1339
  if (fixNowIds.size === 0)
1046
- return greenCommit;
1340
+ return { commit: greenCommit };
1047
1341
  const toFix = [];
1048
1342
  const matchedIds = [];
1049
1343
  for (const candidate of openFindings) {
@@ -1056,7 +1350,7 @@ export async function runProgram(options) {
1056
1350
  // An id triaged fix-now but absent from the open set (should not
1057
1351
  // happen — it is where the id came from) is skipped, fail-open.
1058
1352
  if (toFix.length === 0)
1059
- return greenCommit;
1353
+ return { commit: greenCommit };
1060
1354
  const fix = await attemptFix({
1061
1355
  root,
1062
1356
  programId: options.programId,
@@ -1064,6 +1358,7 @@ export async function runProgram(options) {
1064
1358
  spec,
1065
1359
  config,
1066
1360
  agentRunner,
1361
+ permits,
1067
1362
  verifyRunner,
1068
1363
  git,
1069
1364
  agent,
@@ -1071,6 +1366,8 @@ export async function runProgram(options) {
1071
1366
  greenCommit,
1072
1367
  label: "fix-now fix",
1073
1368
  log,
1369
+ now,
1370
+ observe,
1074
1371
  });
1075
1372
  // "kept" only when the attempt both verified clean and actually landed a
1076
1373
  // commit — a clean-but-empty attempt is a decline, not a fix, and must
@@ -1094,7 +1391,12 @@ export async function runProgram(options) {
1094
1391
  log(kept
1095
1392
  ? `${workstream.id}: fix-now fix verified and committed`
1096
1393
  : `${workstream.id}: fix-now fix failed and was escalated — ${note}`);
1097
- return kept ? fix.greenCommit : greenCommit;
1394
+ const resultCommit = kept ? fix.greenCommit : greenCommit;
1395
+ return {
1396
+ commit: resultCommit,
1397
+ ...(fix.spawnError === undefined ? {} : { spawnError: fix.spawnError }),
1398
+ ...(fix.transcript === undefined ? {} : { transcript: fix.transcript }),
1399
+ };
1098
1400
  }
1099
1401
  }
1100
1402
  /**
@@ -1184,7 +1486,7 @@ function notBuiltRoster(results, authorResult) {
1184
1486
  * bug in this function's own body throws before the pass has even run.
1185
1487
  */
1186
1488
  export async function runWholeProgramStage(args) {
1187
- const { root, programId, manifest, config, agentRunner, git, isRepository, results, authorResult, runStartCommit, decider, reviewed, triaged, now, log, fs, } = args;
1489
+ const { root, programId, manifest, config, agentRunner, permits, git, isRepository, results, authorResult, runStartCommit, decider, reviewed, triaged, now, log, fs, } = args;
1188
1490
  const errors = [];
1189
1491
  let commit;
1190
1492
  let commitSkipped = false;
@@ -1215,6 +1517,7 @@ export async function runWholeProgramStage(args) {
1215
1517
  manifest,
1216
1518
  config,
1217
1519
  agentRunner,
1520
+ permits,
1218
1521
  diff,
1219
1522
  ...(base === undefined ? {} : { baseCommit: base }),
1220
1523
  ...(notBuilt.length === 0 ? {} : { notBuilt }),
@@ -1261,7 +1564,7 @@ export async function runWholeProgramStage(args) {
1261
1564
  }
1262
1565
  if (journaled && events.length > 0) {
1263
1566
  try {
1264
- await triageFindings({
1567
+ const spawnErrors = await triageFindings({
1265
1568
  root,
1266
1569
  programId,
1267
1570
  manifest,
@@ -1270,6 +1573,7 @@ export async function runWholeProgramStage(args) {
1270
1573
  baseCommit: reviewCommit,
1271
1574
  decider,
1272
1575
  agentRunner,
1576
+ permits,
1273
1577
  git,
1274
1578
  isRepository,
1275
1579
  reviewed,
@@ -1277,6 +1581,7 @@ export async function runWholeProgramStage(args) {
1277
1581
  now,
1278
1582
  log,
1279
1583
  });
1584
+ errors.push(...spawnErrors);
1280
1585
  }
1281
1586
  catch (error) {
1282
1587
  errors.push(`the decider failed to triage the whole-program findings: ${error.message}`);
@@ -1387,14 +1692,30 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
1387
1692
  * undefined and the retry proceeds on the raw verify output alone.
1388
1693
  */
1389
1694
  async function diagnoseFailure(options) {
1390
- const { root, manifest, workstream, spec, agentRunner, reviewer, git, baseCommit, failure } = options;
1695
+ const { root, manifest, workstream, spec, agentRunner, permits, reviewer, git, baseCommit, failure, observe } = options;
1391
1696
  const rawDiff = baseCommit !== undefined ? await git.diffSince(root, baseCommit) : "";
1392
1697
  const brief = failureDiagnosisBrief(manifest, workstream, clipForReview(spec, "spec").text, clipForReview(rawDiff, "diff").text, failure);
1393
- const invocation = await invokeAgent(agentRunner, reviewer, brief, root);
1698
+ let invocation;
1699
+ try {
1700
+ invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", observe);
1701
+ }
1702
+ catch (error) {
1703
+ return {
1704
+ spawnError: `the reviewer could not be spawned while diagnosing the ${workstream.id} ` +
1705
+ `failure: ${error.message}; the next attempt proceeds undiagnosed`,
1706
+ };
1707
+ }
1708
+ // The transcript is captured whether or not the reviewer's reply parsed —
1709
+ // a denial it hit while reading is a fact regardless of what it concluded
1710
+ // (SC-12: every workstream-attributed spawn's denials are journaled).
1711
+ const transcript = invocation.transcript;
1394
1712
  if (invocation.exitCode !== 0)
1395
- return undefined;
1713
+ return transcript === undefined ? {} : { transcript };
1396
1714
  const text = invocation.output.trim();
1397
- return text === "" ? undefined : tail(text, 2000);
1715
+ return {
1716
+ ...(transcript === undefined ? {} : { transcript }),
1717
+ ...(text === "" ? {} : { diagnosis: tail(text, 2000) }),
1718
+ };
1398
1719
  }
1399
1720
  function successCriteriaLines(manifest) {
1400
1721
  if (manifest.successCriteria.length === 0) {
@@ -1554,10 +1875,35 @@ function testCritiqueFixBrief(workstream, spec, findings) {
1554
1875
  * whether (and how many times) this is called.
1555
1876
  */
1556
1877
  async function attemptFix(options) {
1557
- const { root, programId, workstream, spec, config, agentRunner, verifyRunner, git, agent, findings, greenCommit, label, } = options;
1878
+ const { root, programId, workstream, spec, config, agentRunner, permits, verifyRunner, git, agent, findings, greenCommit, label, log, now, observe, } = options;
1558
1879
  const brief = testCritiqueFixBrief(workstream, spec, findings);
1559
- const invocation = await invokeAgent(agentRunner, agent, brief, root);
1560
- const summary = resolveSummary(invocation.output).text;
1880
+ let invocation;
1881
+ try {
1882
+ invocation = await invokeAgent(agentRunner, agent, brief, root, permits, "agent", observe);
1883
+ }
1884
+ catch (error) {
1885
+ const spawnError = `the ${label} implementer could not be spawned: ${error.message}; ` +
1886
+ "the fix was not attempted and the green state was preserved.";
1887
+ return {
1888
+ outcome: "failed",
1889
+ greenCommit,
1890
+ summary: "(fix not attempted — the implementer could not be spawned)",
1891
+ failure: spawnError,
1892
+ spawnError,
1893
+ };
1894
+ }
1895
+ const transcript = invocation.transcript;
1896
+ const resolvedSummary = resolveSummary(invocation.output);
1897
+ const summary = resolvedSummary.text;
1898
+ const denialEvents = buildDenialEvents(workstream.id, label, transcript?.denials ?? [], resolvedSummary.needsPermission, config.permits?.deny ?? [], now().toISOString());
1899
+ if (denialEvents.length > 0) {
1900
+ await appendLedgerEvents(root, programId, denialEvents);
1901
+ for (const event of denialEvents) {
1902
+ if (event.kind !== "permission-denied")
1903
+ continue;
1904
+ log(`${workstream.id} ${label}: denied \`${event.command}\` (${event.source})`);
1905
+ }
1906
+ }
1561
1907
  const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
1562
1908
  if (failure === undefined) {
1563
1909
  // The runner's own pending ledger and manifest writes under
@@ -1579,6 +1925,7 @@ async function attemptFix(options) {
1579
1925
  greenCommit: commit ?? greenCommit,
1580
1926
  ...(commit === undefined ? {} : { commit }),
1581
1927
  summary,
1928
+ ...(transcript === undefined ? {} : { transcript }),
1582
1929
  };
1583
1930
  }
1584
1931
  // The same uncommitted bookkeeping the clean path guards against sweeping
@@ -1594,7 +1941,7 @@ async function attemptFix(options) {
1594
1941
  const snapshot = await snapshotProgramsDir(root);
1595
1942
  await git.resetHard(root, greenCommit);
1596
1943
  await restoreProgramsDir(root, snapshot);
1597
- return { outcome: "failed", greenCommit, summary, failure };
1944
+ return { outcome: "failed", greenCommit, summary, failure, ...(transcript === undefined ? {} : { transcript }) };
1598
1945
  }
1599
1946
  /**
1600
1947
  * The test-critique driver: wires WS-01's bounded review loop to the two
@@ -1604,11 +1951,19 @@ async function attemptFix(options) {
1604
1951
  * failure), so a review can never turn green work red.
1605
1952
  */
1606
1953
  async function runTestCritique(options) {
1607
- const { root, manifest, workstream, spec, config, agentRunner, verifyRunner, git, reviewer, agent, baseCommit, log, } = options;
1954
+ const { root, manifest, workstream, spec, config, agentRunner, permits, verifyRunner, git, reviewer, agent, baseCommit, now, log, transcriptSink, } = options;
1608
1955
  let greenCommit = options.greenCommit;
1609
1956
  let diffClipped = false;
1957
+ const stageErrors = [];
1958
+ const transcripts = [];
1610
1959
  if (!reviewer) {
1611
- return { outcome: reviewerAbsentOutcome(), finalCommit: greenCommit, diffClipped };
1960
+ return {
1961
+ outcome: reviewerAbsentOutcome(),
1962
+ finalCommit: greenCommit,
1963
+ diffClipped,
1964
+ stageErrors,
1965
+ transcripts,
1966
+ };
1612
1967
  }
1613
1968
  const locate = (file) => locateInRepo(root, file);
1614
1969
  const review = async (_round, priorOpen) => {
@@ -1618,7 +1973,28 @@ async function runTestCritique(options) {
1618
1973
  if (diff.clipped || clippedSpec.clipped)
1619
1974
  diffClipped = true;
1620
1975
  const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
1621
- const invocation = await invokeAgent(agentRunner, reviewer, brief, root);
1976
+ let invocation;
1977
+ try {
1978
+ invocation = await invokeAgent(agentRunner, reviewer, brief, root, permits, "reviewerAgent", transcriptSink("test-critique-reviewer"));
1979
+ }
1980
+ catch (error) {
1981
+ // Fail open, matching the existing reviewer-error path: no findings
1982
+ // this round, the workstream outcome is unaffected, and the reason is
1983
+ // recorded durably rather than only logged (SC-12).
1984
+ stageErrors.push(`the reviewer could not be spawned during test critique: ${error.message}`);
1985
+ return { findings: [], errors: [], ran: false };
1986
+ }
1987
+ if (invocation.transcript) {
1988
+ const denialEvents = buildDenialEvents(workstream.id, "reviewer", invocation.transcript.denials, [], config.permits?.deny ?? [], now().toISOString());
1989
+ if (denialEvents.length > 0) {
1990
+ await appendLedgerEvents(root, manifest.program.id, denialEvents);
1991
+ for (const event of denialEvents) {
1992
+ if (event.kind !== "permission-denied")
1993
+ continue;
1994
+ log(`${workstream.id} reviewer: denied \`${event.command}\` (${event.source})`);
1995
+ }
1996
+ }
1997
+ }
1622
1998
  const parsed = extractFindings(invocation.output);
1623
1999
  const findings = verifyEvidence(parsed.findings, locate);
1624
2000
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
@@ -1632,6 +2008,7 @@ async function runTestCritique(options) {
1632
2008
  spec,
1633
2009
  config,
1634
2010
  agentRunner,
2011
+ permits,
1635
2012
  verifyRunner,
1636
2013
  git,
1637
2014
  agent,
@@ -1639,8 +2016,14 @@ async function runTestCritique(options) {
1639
2016
  greenCommit,
1640
2017
  label: "test critique fix",
1641
2018
  log,
2019
+ now,
2020
+ observe: transcriptSink("test-critique-fix"),
1642
2021
  });
1643
2022
  greenCommit = fix.greenCommit;
2023
+ if (fix.transcript)
2024
+ transcripts.push(fix.transcript);
2025
+ if (fix.spawnError !== undefined)
2026
+ stageErrors.push(fix.spawnError);
1644
2027
  if (fix.outcome === "kept") {
1645
2028
  log(`${workstream.id}: test critique fix verified and committed`);
1646
2029
  return { note: `${fix.summary} (fix verified and committed)` };
@@ -1649,7 +2032,7 @@ async function runTestCritique(options) {
1649
2032
  return { note: `${fix.summary} (fix failed verification and was discarded; the green state was preserved)` };
1650
2033
  };
1651
2034
  const outcome = await runReviewPass({ review, respond });
1652
- return { outcome, finalCommit: greenCommit, diffClipped };
2035
+ return { outcome, finalCommit: greenCommit, diffClipped, stageErrors, transcripts };
1653
2036
  }
1654
2037
  function renderSpecsSection(authorResult) {
1655
2038
  if (authorResult.results.length === 0)
@@ -1883,7 +2266,84 @@ function renderAwaitingHumanSection(manifest, preflight, resumeCommand) {
1883
2266
  lines.push(`Resume with: \`${resumeCommand ?? `npx --yes @wildorder/nightshift run ${manifest.program.id}`}\``, "");
1884
2267
  return lines;
1885
2268
  }
1886
- export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand) {
2269
+ /**
2270
+ * A workstream's Commands subsection (WS-02 SC-10): executed commands
2271
+ * deduplicated with counts, denied commands verbatim with the CLI's reason,
2272
+ * or — when nothing this workstream's build-role spawns produced was
2273
+ * observed (Codex, an unparsed stream, an operator-configured or
2274
+ * operator-`--output-format` role) — one honest line naming the agent
2275
+ * command so the reader knows why nothing is listed.
2276
+ */
2277
+ function renderWorkstreamCommands(result) {
2278
+ const observed = (result.transcripts ?? []).filter((transcript) => transcript.observed);
2279
+ if (observed.length === 0) {
2280
+ return [
2281
+ ` - Commands: not observable for \`${result.buildAgentCommand ?? "the configured agent"}\``,
2282
+ ];
2283
+ }
2284
+ const counts = new Map();
2285
+ for (const transcript of observed) {
2286
+ for (const entry of transcript.commands) {
2287
+ counts.set(entry.command, (counts.get(entry.command) ?? 0) + 1);
2288
+ }
2289
+ }
2290
+ const denials = observed.flatMap((transcript) => transcript.denials);
2291
+ // A line mid-stream that failed to parse could have been a command or a
2292
+ // denial — the transcript itself cannot say which — so this is surfaced
2293
+ // here rather than letting an incomplete transcript stand as authoritative.
2294
+ const corrupted = observed.some((transcript) => transcript.corrupted);
2295
+ if (counts.size === 0 && denials.length === 0 && !corrupted) {
2296
+ return [" - Commands: none observed"];
2297
+ }
2298
+ const lines = [" - Commands:"];
2299
+ if (corrupted) {
2300
+ lines.push(" - warning: a line in this transcript could not be parsed — it may be missing a command or denial");
2301
+ }
2302
+ for (const [command, count] of counts) {
2303
+ lines.push(` - \`${command}\`${count > 1 ? ` ×${count}` : ""}`);
2304
+ }
2305
+ for (const denial of denials) {
2306
+ lines.push(` - denied: \`${denial.command}\` — ${denial.reason}`);
2307
+ }
2308
+ return lines;
2309
+ }
2310
+ /**
2311
+ * The program-level `## Commands denied` section (SC-12): every
2312
+ * `permission-denied` ledger record, grouped by workstream in the run's own
2313
+ * workstream order, each rendered by the single `renderDenial` a human,
2314
+ * `decide`, and this report all share. Omits the heading entirely when the
2315
+ * ledger carries no denials, so a clean run's report is unchanged. Each
2316
+ * workstream group closes with the run's retry command, so the operator sees
2317
+ * the remediation to apply and the command to re-run in one place.
2318
+ */
2319
+ function renderCommandsDeniedSection(results, ledger, programId) {
2320
+ if (ledger.denials.length === 0)
2321
+ return [];
2322
+ const byWorkstream = new Map();
2323
+ for (const denial of ledger.denials) {
2324
+ const list = byWorkstream.get(denial.workstream) ?? [];
2325
+ list.push(denial);
2326
+ byWorkstream.set(denial.workstream, list);
2327
+ }
2328
+ const orderedIds = [
2329
+ ...results.map((result) => result.id),
2330
+ ...[...byWorkstream.keys()].filter((workstreamId) => !results.some((result) => result.id === workstreamId)),
2331
+ ];
2332
+ const lines = ["## Commands denied", ""];
2333
+ for (const workstreamId of orderedIds) {
2334
+ const denials = byWorkstream.get(workstreamId);
2335
+ if (denials === undefined || denials.length === 0)
2336
+ continue;
2337
+ const name = results.find((result) => result.id === workstreamId)?.name ?? workstreamId;
2338
+ lines.push(`### ${workstreamId} ${name}`, "");
2339
+ for (const denial of denials) {
2340
+ lines.push(...renderDenial(denial));
2341
+ }
2342
+ lines.push("", `Retry with: \`npx --yes @wildorder/nightshift run ${programId}\``, "");
2343
+ }
2344
+ return lines;
2345
+ }
2346
+ export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram, preflight, resumeCommand, manifestAmended) {
1887
2347
  const programId = manifest.program.id;
1888
2348
  const built = results.filter((result) => result.outcome.status === "complete" ||
1889
2349
  result.outcome.status === "skipped").length;
@@ -1899,8 +2359,11 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1899
2359
  "",
1900
2360
  outcomeLine,
1901
2361
  "",
1902
- ...renderSpecsSection(authorResult),
1903
2362
  ];
2363
+ if (manifestAmended === true) {
2364
+ 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.", "");
2365
+ }
2366
+ lines.push(...renderSpecsSection(authorResult));
1904
2367
  const escalated = escalatedRecords(ledger);
1905
2368
  if (escalated.length > 0) {
1906
2369
  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 +2388,12 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1925
2388
  for (const diagnosis of result.failureDiagnoses ?? []) {
1926
2389
  lines.push(`**Reviewer diagnosis (after the ${diagnosis.attempt} attempt):**`, "", diagnosis.verdict, "");
1927
2390
  }
2391
+ const workstreamDenials = ledger.denials.filter((denial) => denial.workstream === result.id);
2392
+ if (workstreamDenials.length > 0) {
2393
+ lines.push("Commands denied: " +
2394
+ workstreamDenials.map((denial) => `\`${denial.command}\``).join(", ") +
2395
+ " — see Commands denied below.", "");
2396
+ }
1928
2397
  lines.push(`Retry after fixing: \`npx --yes @wildorder/nightshift run ${manifest.program.id}\``, "(completed workstreams are skipped automatically).", "");
1929
2398
  }
1930
2399
  }
@@ -1949,6 +2418,9 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1949
2418
  lines.push(" The reviewer saw input clipped for length.");
1950
2419
  }
1951
2420
  }
2421
+ if (result.outcome.status === "complete" || result.outcome.status === "failed") {
2422
+ lines.push(...renderWorkstreamCommands(result));
2423
+ }
1952
2424
  }
1953
2425
  lines.push("");
1954
2426
  if (wholeProgram !== undefined) {
@@ -1964,11 +2436,22 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1964
2436
  lines.push(...renderRecord(record, { density: "compact", programId }), "");
1965
2437
  }
1966
2438
  }
2439
+ lines.push(...renderCommandsDeniedSection(results, ledger, programId));
1967
2440
  lines.push(...renderKnownLimitations(results, authorResult, ledger, escalated, triagedThisRun, programId, wholeProgram));
1968
2441
  const decisionErrors = results.flatMap((result) => result.decisionErrors);
1969
2442
  if (decisionErrors.length > 0) {
1970
2443
  lines.push("## Decision blocks the runner could not read", "", ...decisionErrors.map((error) => `- ${error}`), "");
1971
2444
  }
2445
+ const stageErrors = [
2446
+ ...results.flatMap((result) => result.stageErrors ?? []),
2447
+ ...authorResult.results.flatMap((result) => result.stageErrors ?? []),
2448
+ ];
2449
+ if (stageErrors.length > 0) {
2450
+ lines.push("## Agent stages that could not run", "", "A reviewer, decider, or fix implementer could not be spawned after " +
2451
+ "the workstream it concerns had already verified green; the " +
2452
+ "workstream's own outcome is unaffected, but the reason it fell " +
2453
+ "open belongs here.", "", ...stageErrors.map((error) => `- ${error}`), "");
2454
+ }
1972
2455
  return lines.join("\n");
1973
2456
  }
1974
2457
  //# sourceMappingURL=run-program.js.map