@wildorder/nightshift 0.12.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 (90) 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 +161 -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 +144 -11
  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 +54 -1
  42. package/dist/decision-ledger.d.ts.map +1 -1
  43. package/dist/decision-ledger.js +53 -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/exit-codes.d.ts +16 -2
  50. package/dist/exit-codes.d.ts.map +1 -1
  51. package/dist/exit-codes.js +17 -2
  52. package/dist/exit-codes.js.map +1 -1
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/manifest.d.ts +68 -14
  58. package/dist/manifest.d.ts.map +1 -1
  59. package/dist/manifest.js +301 -22
  60. package/dist/manifest.js.map +1 -1
  61. package/dist/permits.d.ts +135 -0
  62. package/dist/permits.d.ts.map +1 -0
  63. package/dist/permits.js +397 -0
  64. package/dist/permits.js.map +1 -0
  65. package/dist/preflight.d.ts +73 -0
  66. package/dist/preflight.d.ts.map +1 -0
  67. package/dist/preflight.js +225 -0
  68. package/dist/preflight.js.map +1 -0
  69. package/dist/publish.d.ts +71 -0
  70. package/dist/publish.d.ts.map +1 -1
  71. package/dist/publish.js +319 -1
  72. package/dist/publish.js.map +1 -1
  73. package/dist/run-program.d.ts +70 -2
  74. package/dist/run-program.d.ts.map +1 -1
  75. package/dist/run-program.js +677 -60
  76. package/dist/run-program.js.map +1 -1
  77. package/dist/skill-roots.d.ts +11 -3
  78. package/dist/skill-roots.d.ts.map +1 -1
  79. package/dist/skill-roots.js +59 -12
  80. package/dist/skill-roots.js.map +1 -1
  81. package/dist/whole-program-review.d.ts +4 -0
  82. package/dist/whole-program-review.d.ts.map +1 -1
  83. package/dist/whole-program-review.js +9 -1
  84. package/dist/whole-program-review.js.map +1 -1
  85. package/package.json +2 -2
  86. package/skills/plan-program/SKILL.md +125 -5
  87. package/dist/verify-allowlist.d.ts +0 -23
  88. package/dist/verify-allowlist.d.ts.map +0 -1
  89. package/dist/verify-allowlist.js +0 -46
  90. package/dist/verify-allowlist.js.map +0 -1
@@ -2,26 +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
+ import { defaultPrerequisiteRunner, runPreflight, } from "./preflight.js";
17
18
  import { restoreProgramsDir, snapshotProgramsDir } from "./programs-dir.js";
18
- import { verifyAccessWarning } from "./verify-allowlist.js";
19
+ import { buildPermitsContext } from "./permits.js";
19
20
  import { CouldNotStartError } from "./exit-codes.js";
20
21
  import { runReportPath } from "./report-path.js";
21
22
  import { AS_BUILT_PATH, runWholeProgramReview, renderWholeProgramReview, } from "./whole-program-review.js";
22
23
  const execFileAsync = promisify(execFile);
23
24
  /** Matches every wording git uses to report an empty commit attempt. */
24
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
+ }
25
108
  export const defaultGitOps = {
26
109
  async isRepository(cwd) {
27
110
  try {
@@ -197,7 +280,38 @@ function findingsRuledOnSection(records) {
197
280
  return [];
198
281
  return ["## Findings ruled on", "", ...lines, ""];
199
282
  }
200
- 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) {
201
315
  const roster = manifest.workstreams
202
316
  .map((entry) => {
203
317
  const scope = entry.scope?.summary ?? entry.name;
@@ -260,6 +374,7 @@ function implementerBrief(manifest, workstream, spec, ledger, priorFailure, prio
260
374
  " independently afterwards and your claim of success is never trusted",
261
375
  " without it.",
262
376
  "",
377
+ ...shellPolicySection(shellPolicy),
263
378
  decisionContract(),
264
379
  "",
265
380
  summaryContract(),
@@ -331,6 +446,52 @@ export function downstreamCone(workstreams, rootIds) {
331
446
  cone.delete(id);
332
447
  return cone;
333
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
+ }
334
495
  /**
335
496
  * The decide-and-continue runner.
336
497
  *
@@ -342,6 +503,23 @@ export function downstreamCone(workstreams, rootIds) {
342
503
  * verify commands itself, and writes the run report last, whatever happened.
343
504
  */
344
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) {
345
523
  const root = resolve(options.cwd);
346
524
  const config = options.config;
347
525
  const agentRunner = options.agentRunner ?? defaultAgentRunner;
@@ -356,6 +534,11 @@ export async function runProgram(options) {
356
534
  catch (error) {
357
535
  throw new CouldNotStartError(error.message, { cause: error });
358
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);
359
542
  const resolvedAgent = resolveAgent(config);
360
543
  if (!resolvedAgent) {
361
544
  throw new CouldNotStartError("No implementer configured. Set the `agent` block in nightshift.config.json.");
@@ -365,15 +548,21 @@ export async function runProgram(options) {
365
548
  const decider = resolveDeciderAgent(config);
366
549
  const reviewer = resolveReviewerAgent(config);
367
550
  log(`implementer: ${describeAgent(agent)}`);
368
- const sandboxWarning = verifyAccessWarning(agent, config.verify);
369
- if (sandboxWarning !== undefined)
370
- log(`warning: ${sandboxWarning}`);
371
551
  log(decider
372
552
  ? `decider: ${describeAgent(decider)}`
373
553
  : "decider: none configured — implementer defaults will stand unratified");
374
554
  log(reviewer
375
555
  ? `reviewer (test critique, whole-program review): ${describeAgent(reviewer)}`
376
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
+ }
377
566
  const cycles = findCycles(manifest.workstreams);
378
567
  if (cycles.length > 0) {
379
568
  // A cyclic graph cannot be ordered; this is a planning defect, not a
@@ -384,6 +573,11 @@ export async function runProgram(options) {
384
573
  }
385
574
  // A resumed run legitimately starts on a leftover tree — the dirty-start
386
575
  // refusal and the baseline verification below both soften for it.
576
+ // Deliberately excludes `awaiting_human`: an intermission's built
577
+ // workstreams are already committed and its awaiting ones were never
578
+ // built, so a re-dispatch after one always starts on a clean tree and
579
+ // needs neither softening — adding it here would loosen the dirty-start
580
+ // refusal for no benefit.
387
581
  const resuming = manifest.workstreams.some((workstream) => workstream.status === "failed" ||
388
582
  workstream.status === "in_progress" ||
389
583
  workstream.status === "parked");
@@ -449,6 +643,26 @@ export async function runProgram(options) {
449
643
  // resumed run's merge-base with the default branch is unavailable or
450
644
  // equal to HEAD.
451
645
  const runStartCommit = isRepository ? await git.currentCommit(root) : undefined;
646
+ // The run's own identity — distinct from runStartCommit, which two
647
+ // invocations can share (the same commit) or lack entirely (no
648
+ // repository). Stamped on each `prerequisite-verified` ledger event so the
649
+ // ledger records which run verified a prerequisite.
650
+ const runId = now().toISOString();
651
+ // Preflight: every *pending* prerequisite's verifyCommand executes here —
652
+ // before baseline verification and before any agent spawns (SC-03). This
653
+ // mutates manifest.prerequisites in memory (pending -> satisfied on a met
654
+ // check) but is NOT yet persisted: a could-not-start baseline below must
655
+ // leave nothing durable, so the ledger append and manifest save are staged
656
+ // until baseline admits the run (see the block right after it).
657
+ const preflight = await runPreflight({
658
+ manifest,
659
+ cwd: root,
660
+ runner: options.prerequisiteRunner ?? defaultPrerequisiteRunner,
661
+ now,
662
+ runId,
663
+ runStartCommit,
664
+ log,
665
+ });
452
666
  // Baseline verification: the verify suite on the untouched tree, before
453
667
  // the first agent is spawned. A red baseline on a fresh start means the
454
668
  // environment is broken — halting here costs one verify pass instead of
@@ -486,6 +700,18 @@ export async function runProgram(options) {
486
700
  "as environmental instead of being charged for it");
487
701
  }
488
702
  }
703
+ // Only now — past the could-not-start baseline gate — do the preflight's
704
+ // durable effects land: the ledger append first, then the manifest save. A
705
+ // settled manifest fact (satisfied) must never outrun its journal record,
706
+ // since a satisfied prerequisite is never re-verified; journalling first
707
+ // makes the only crash-between-writes residue a harmless idempotent
708
+ // duplicate event, never a lost one. A could-not-start throw above has
709
+ // already unwound the process before reaching here, so an exit-3 run
710
+ // mutates neither the manifest nor the ledger.
711
+ if (preflight.events.length > 0) {
712
+ await appendLedgerEvents(root, options.programId, preflight.events);
713
+ await saveManifest(root, options.programId, manifest, { log });
714
+ }
489
715
  // Subject ids already sent to the decider this run — shared across the
490
716
  // authoring and build stages (a run is one process) and across the
491
717
  // decision and finding paths, so "once per subject per run" (SC-10) is
@@ -507,6 +733,7 @@ export async function runProgram(options) {
507
733
  programId: options.programId,
508
734
  config,
509
735
  agentRunner,
736
+ permits,
510
737
  git,
511
738
  log,
512
739
  now,
@@ -520,12 +747,26 @@ export async function runProgram(options) {
520
747
  const ledgerAtStart = await readDecisionLedger(root, options.programId);
521
748
  const ordered = stableTopologicalOrder(manifest.workstreams);
522
749
  const results = [];
523
- // Seeded with every workstream whose spec authoring failed or parked
524
- // their briefs would be missing a producer's spec, so the build stage
525
- // must not attempt them this run either.
526
- const blocked = new Set(authorResult.results
750
+ // Every workstream whose spec authoring failed or parked. Seeds `blocked`
751
+ // below (their briefs would be missing a producer's spec) and also guards
752
+ // the awaiting_human branch in the build loop: an authoring failure must
753
+ // never be repainted as a planned wait — see that branch's own comment.
754
+ const authoringFailed = new Set(authorResult.results
527
755
  .filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
528
756
  .map((entry) => entry.id));
757
+ const blocked = new Set(authoringFailed);
758
+ // The awaiting cone: every workstream directly referencing an unmet
759
+ // prerequisite, plus everything transitively downstream of one — the same
760
+ // downstream-cone machinery failure isolation uses, with the semantics
761
+ // inverted (no agent spawned, no retry, no diagnosis; see the build loop).
762
+ const unmetPrerequisiteIds = new Set(preflight.checks.filter((check) => !check.met).map((check) => check.id));
763
+ const directlyAwaiting = new Set(manifest.workstreams
764
+ .filter((workstream) => workstream.prerequisites.some((id) => unmetPrerequisiteIds.has(id)))
765
+ .map((workstream) => workstream.id));
766
+ const awaitingCone = new Set([
767
+ ...directlyAwaiting,
768
+ ...downstreamCone(manifest.workstreams, [...directlyAwaiting]),
769
+ ]);
529
770
  // Set when a workstream's verify failure reproduced the pre-run baseline:
530
771
  // the environment is broken, so every remaining workstream parks instead
531
772
  // of spending its budget on the same crash.
@@ -555,6 +796,33 @@ export async function runProgram(options) {
555
796
  });
556
797
  continue;
557
798
  }
799
+ // Authoring failures take precedence over awaiting: a workstream whose
800
+ // spec authoring failed or parked must fall through to the `blocked`
801
+ // branch below and render as the genuine failure it is, never be
802
+ // repainted `awaiting_human` — an authoring failure has no other
803
+ // representation in `results` than that branch's own `parked` result, so
804
+ // rewriting it here would erase the only trace of it and the run would
805
+ // wrongly look like a pure intermission. A workstream that is merely
806
+ // downstream of a *build* failure and also in the awaiting cone still
807
+ // resolves to awaiting_human here; that is safe because the build
808
+ // failure self-reports `failed` in `results`, so the run is classified a
809
+ // partial regardless (see the intermission classification below).
810
+ if (awaitingCone.has(workstream.id) && !authoringFailed.has(workstream.id)) {
811
+ workstream.status = "awaiting_human";
812
+ await saveManifest(root, options.programId, manifest, { log });
813
+ const ownUnmet = workstream.prerequisites.filter((id) => unmetPrerequisiteIds.has(id));
814
+ const reason = ownUnmet.length > 0
815
+ ? `waiting on human prerequisite ${ownUnmet.join(", ")}; not attempted.`
816
+ : "an upstream workstream is awaiting a human prerequisite; not attempted.";
817
+ results.push({
818
+ id: workstream.id,
819
+ name: workstream.name,
820
+ outcome: { status: "awaiting_human", reason },
821
+ decisionIds: [],
822
+ decisionErrors: [],
823
+ });
824
+ continue;
825
+ }
558
826
  if (blocked.has(workstream.id)) {
559
827
  workstream.status = "parked";
560
828
  results.push({
@@ -582,14 +850,33 @@ export async function runProgram(options) {
582
850
  }
583
851
  const complete = results.every((result) => result.outcome.status === "complete" ||
584
852
  result.outcome.status === "skipped");
585
- manifest.program.status = complete ? "complete" : "partial";
586
- await saveManifest(root, options.programId, manifest);
853
+ const anyAwaiting = results.some((result) => result.outcome.status === "awaiting_human");
854
+ const anyFailedOrParked = results.some((result) => result.outcome.status === "failed" || result.outcome.status === "parked");
855
+ // Author-stage failures are counted too: an authoring failure the awaiting
856
+ // guard did NOT catch — e.g. a workstream that authored fine but sits
857
+ // downstream of an authoring failure — must still sink the intermission.
858
+ // Same set that seeded `blocked`/guarded the awaiting branch above;
859
+ // classifying on it here as well makes "any genuine failure => ordinary
860
+ // partial" true independent of how the cone painted it.
861
+ const anyAuthoringFailure = authorResult.results.some((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked");
862
+ // A planned intermission: nothing failed anywhere (build or authoring), at
863
+ // least one workstream is waiting on a human, and not everything built. A
864
+ // genuine failure makes it an ordinary partial instead — the human should
865
+ // read a diagnosis, not a checklist.
866
+ const intermission = !complete && anyAwaiting && !anyFailedOrParked && !anyAuthoringFailure;
867
+ manifest.program.status = complete
868
+ ? "complete"
869
+ : intermission
870
+ ? "awaiting_human"
871
+ : "partial";
872
+ await saveManifest(root, options.programId, manifest, { log });
587
873
  const wholeProgram = await runWholeProgramStage({
588
874
  root,
589
875
  programId: options.programId,
590
876
  manifest,
591
877
  config,
592
878
  agentRunner,
879
+ permits,
593
880
  git,
594
881
  isRepository,
595
882
  results,
@@ -618,7 +905,7 @@ export async function runProgram(options) {
618
905
  // projected ledger carries no run identifier of its own. It excludes
619
906
  // ids that were merely sent but whose invocation failed or returned no
620
907
  // valid verdict.
621
- renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram), "utf8");
908
+ renderRunReport(manifest, results, ledger, triaged, authorResult, now(), wholeProgram, preflight, options.resumeCommand, manifestMergeCount(root, options.programId) > mergesAtStart), "utf8");
622
909
  if (isRepository) {
623
910
  await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
624
911
  }
@@ -630,6 +917,7 @@ export async function runProgram(options) {
630
917
  return {
631
918
  programId: options.programId,
632
919
  complete,
920
+ intermission,
633
921
  workstreams: results,
634
922
  escalations,
635
923
  reportPath,
@@ -642,7 +930,18 @@ export async function runProgram(options) {
642
930
  outcome: { status: "failed", reason: "not attempted" },
643
931
  decisionIds: [],
644
932
  decisionErrors: [],
933
+ buildAgentCommand: describeAgent(agent),
645
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
+ }
646
945
  let spec;
647
946
  try {
648
947
  spec = await readFile(join(root, workstream.taskFile), "utf8");
@@ -655,7 +954,7 @@ export async function runProgram(options) {
655
954
  return base;
656
955
  }
657
956
  workstream.status = "in_progress";
658
- await saveManifest(root, options.programId, manifest);
957
+ await saveManifest(root, options.programId, manifest, { log });
659
958
  const baseCommit = isRepository
660
959
  ? await git.currentCommit(root)
661
960
  : undefined;
@@ -663,10 +962,10 @@ export async function runProgram(options) {
663
962
  let priorDiagnosis;
664
963
  let implementerFingerprint;
665
964
  const attempts = [
666
- { agent, label: "implementer" },
965
+ { agent, label: "implementer", role: "agent" },
667
966
  ];
668
967
  if (recovery && !recovery.borrowedImplementer) {
669
- attempts.push({ agent: recovery.agent, label: "recovery" });
968
+ attempts.push({ agent: recovery.agent, label: "recovery", role: "recoveryAgent" });
670
969
  // The third seat exists only when a reviewer can inform it. An
671
970
  // uninformed retry has already been spent (recovery); running the
672
971
  // roster again blind is a coin flip the ledger should hear about
@@ -674,14 +973,31 @@ export async function runProgram(options) {
674
973
  // the reviewer's diagnosis in hand, a composition neither prior
675
974
  // attempt had.
676
975
  if (reviewer)
677
- attempts.push({ agent, label: "informed retry" });
976
+ attempts.push({ agent, label: "informed retry", role: "agent" });
678
977
  }
679
978
  for (const [index, attempt] of attempts.entries()) {
680
979
  log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
681
- const brief = implementerBrief(manifest, workstream, spec, ledgerAtStart, priorFailure, priorDiagnosis);
682
- 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);
683
998
  const summary = resolveSummary(invocation.output);
684
999
  base.summary = summary.text;
1000
+ await journalDenials(workstream.id, attempt.label, invocation.transcript, summary.needsPermission);
685
1001
  const parsed = extractDecisions(invocation.output);
686
1002
  base.decisionErrors.push(...parsed.errors);
687
1003
  await journalDecisions(workstream, parsed.decisions, baseCommit);
@@ -691,6 +1007,13 @@ export async function runProgram(options) {
691
1007
  // no diagnosis is spent on them, because no read of the tree could add
692
1008
  // anything to what the runner already knows.
693
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
+ }
694
1017
  // A brief that never reached the agent's stdin means the process
695
1018
  // crashed at startup — whatever it printed was not an answer to the
696
1019
  // brief, even when it exited 0.
@@ -738,6 +1061,7 @@ export async function runProgram(options) {
738
1061
  spec,
739
1062
  config,
740
1063
  agentRunner,
1064
+ permits,
741
1065
  verifyRunner,
742
1066
  git,
743
1067
  reviewer,
@@ -746,12 +1070,18 @@ export async function runProgram(options) {
746
1070
  greenCommit: c0,
747
1071
  now,
748
1072
  log,
1073
+ transcriptSink,
749
1074
  })
750
1075
  : undefined;
751
1076
  // The manifest's single commit field records the workstream's final
752
1077
  // verified state — after any kept fix, that is the fix commit, not
753
1078
  // the earlier green one.
754
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);
755
1085
  // Findings anchor to c0 (the green, pre-critique commit) — the
756
1086
  // honest rollback point — while the decider below diffs from
757
1087
  // baseCommit (pre-workstream), so it sees the whole workstream.
@@ -769,23 +1099,35 @@ export async function runProgram(options) {
769
1099
  workstream.status = "complete";
770
1100
  if (finalCommit !== undefined)
771
1101
  workstream.commit = finalCommit;
772
- await saveManifest(root, options.programId, manifest);
773
- 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
+ }
774
1108
  // Findings are triaged against the pre-workstream diff (baseCommit),
775
1109
  // the same one the decider reviews decisions against — c0/finalCommit
776
1110
  // are the fix loop's own rollback anchors, a different thing.
777
- 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
+ }
778
1116
  // A fix-now triage drives exactly one bounded fix attempt, here —
779
1117
  // after the triage that produced it, before the run advances. Only
780
1118
  // reachable when there is a green commit to attempt from and a
781
1119
  // decider that could have produced a fix-now verdict in the first
782
1120
  // place (SC-13).
783
1121
  if (finalCommit !== undefined && decider) {
784
- const driven = await driveFixNowFindings(workstream, spec, critique?.outcome.open ?? [], finalCommit);
785
- if (driven !== finalCommit) {
786
- 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;
787
1129
  workstream.commit = finalCommit;
788
- await saveManifest(root, options.programId, manifest);
1130
+ await saveManifest(root, options.programId, manifest, { log });
789
1131
  }
790
1132
  }
791
1133
  if (critique)
@@ -809,8 +1151,12 @@ export async function runProgram(options) {
809
1151
  !resumedIds.has(workstream.id) &&
810
1152
  failureFingerprint(failure) === baselineFingerprint) {
811
1153
  workstream.status = "failed";
812
- await saveManifest(root, options.programId, manifest);
813
- 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
+ }
814
1160
  base.environmental = true;
815
1161
  base.outcome = {
816
1162
  status: "failed",
@@ -832,10 +1178,14 @@ export async function runProgram(options) {
832
1178
  failureFingerprint(failure) === implementerFingerprint;
833
1179
  if (index === attempts.length - 1 || stuck) {
834
1180
  workstream.status = "failed";
835
- await saveManifest(root, options.programId, manifest);
1181
+ await saveManifest(root, options.programId, manifest, { log });
836
1182
  // The work stays in the tree for a resume; decisions made on the way
837
1183
  // to a failure are still journaled and still reviewable.
838
- 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
+ }
839
1189
  base.outcome = {
840
1190
  status: "failed",
841
1191
  reason: stuck
@@ -855,19 +1205,30 @@ export async function runProgram(options) {
855
1205
  // a newer one. Deterministic failures (no-op, undelivered brief) are
856
1206
  // never diagnosed: the runner already knows exactly what happened,
857
1207
  // and a reviewer read of an empty diff could only muddy it.
858
- priorDiagnosis = reviewer && !deterministicFailure
859
- ? await diagnoseFailure({
1208
+ if (reviewer && !deterministicFailure) {
1209
+ const diagnosed = await diagnoseFailure({
860
1210
  root,
861
1211
  manifest,
862
1212
  workstream,
863
1213
  spec,
864
1214
  agentRunner,
1215
+ permits,
865
1216
  reviewer,
866
1217
  git,
867
1218
  baseCommit,
868
1219
  failure,
869
- })
870
- : 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
+ }
871
1232
  if (priorDiagnosis !== undefined) {
872
1233
  (base.failureDiagnoses ??= []).push({
873
1234
  attempt: attempt.label,
@@ -893,6 +1254,23 @@ export async function runProgram(options) {
893
1254
  log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
894
1255
  }
895
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
+ }
896
1274
  function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
897
1275
  return reviewDecisions({
898
1276
  root,
@@ -903,12 +1281,15 @@ export async function runProgram(options) {
903
1281
  baseCommit,
904
1282
  decider,
905
1283
  agentRunner,
1284
+ permits,
906
1285
  git,
907
1286
  isRepository,
908
1287
  reviewed,
1288
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
909
1289
  triaged,
910
1290
  now,
911
1291
  log,
1292
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-decision`, log },
912
1293
  });
913
1294
  }
914
1295
  function reviewWorkstreamFindings(workstreamId, findings, baseCommit) {
@@ -921,9 +1302,12 @@ export async function runProgram(options) {
921
1302
  baseCommit,
922
1303
  decider,
923
1304
  agentRunner,
1305
+ permits,
924
1306
  git,
925
1307
  isRepository,
926
1308
  reviewed,
1309
+ observe: { root, programId: options.programId, label: `${workstreamId}-decider-finding`, log },
1310
+ onTranscript: (transcript) => journalDenials(workstreamId, "decider", transcript),
927
1311
  triaged,
928
1312
  now,
929
1313
  log,
@@ -939,13 +1323,13 @@ export async function runProgram(options) {
939
1323
  * entry, never a re-triage (SC-14). Returns the (possibly unchanged) green
940
1324
  * commit; never throws, never blocks the run (SC-15).
941
1325
  */
942
- async function driveFixNowFindings(workstream, spec, openFindings, greenCommit) {
1326
+ async function driveFixNowFindings(workstream, spec, openFindings, greenCommit, observe) {
943
1327
  const ledger = await readDecisionLedger(root, options.programId);
944
1328
  const fixNowIds = new Set(ledger.findings
945
1329
  .filter((record) => record.workstream === workstream.id && record.status === "fix-now")
946
1330
  .map((record) => record.id));
947
1331
  if (fixNowIds.size === 0)
948
- return greenCommit;
1332
+ return { commit: greenCommit };
949
1333
  const toFix = [];
950
1334
  const matchedIds = [];
951
1335
  for (const candidate of openFindings) {
@@ -958,7 +1342,7 @@ export async function runProgram(options) {
958
1342
  // An id triaged fix-now but absent from the open set (should not
959
1343
  // happen — it is where the id came from) is skipped, fail-open.
960
1344
  if (toFix.length === 0)
961
- return greenCommit;
1345
+ return { commit: greenCommit };
962
1346
  const fix = await attemptFix({
963
1347
  root,
964
1348
  programId: options.programId,
@@ -966,6 +1350,7 @@ export async function runProgram(options) {
966
1350
  spec,
967
1351
  config,
968
1352
  agentRunner,
1353
+ permits,
969
1354
  verifyRunner,
970
1355
  git,
971
1356
  agent,
@@ -973,6 +1358,8 @@ export async function runProgram(options) {
973
1358
  greenCommit,
974
1359
  label: "fix-now fix",
975
1360
  log,
1361
+ now,
1362
+ observe,
976
1363
  });
977
1364
  // "kept" only when the attempt both verified clean and actually landed a
978
1365
  // commit — a clean-but-empty attempt is a decline, not a fix, and must
@@ -996,7 +1383,12 @@ export async function runProgram(options) {
996
1383
  log(kept
997
1384
  ? `${workstream.id}: fix-now fix verified and committed`
998
1385
  : `${workstream.id}: fix-now fix failed and was escalated — ${note}`);
999
- 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
+ };
1000
1392
  }
1001
1393
  }
1002
1394
  /**
@@ -1043,13 +1435,24 @@ function stageFailureOutcome(reason) {
1043
1435
  reason,
1044
1436
  };
1045
1437
  }
1046
- /** Failed and parked workstreams only (SC-08) — a `skipped` result means already complete, i.e. built. */
1438
+ /**
1439
+ * Failed, parked, and awaiting-human workstreams (SC-08) — a `skipped`
1440
+ * result means already complete, i.e. built. An awaiting-human workstream is
1441
+ * incomplete, not a failure, but it must still appear here: this roster
1442
+ * feeds the whole-program review's brief, and on a pure intermission (the
1443
+ * only incomplete workstreams are awaiting_human) that brief must not claim
1444
+ * "every workstream in this program completed" — see runWholeProgramStage.
1445
+ * The run report's own "Not built" section is a separate, failure-only
1446
+ * filter and stays that way.
1447
+ */
1047
1448
  function notBuiltRoster(results, authorResult) {
1048
1449
  const authorById = new Map(authorResult.results.map((entry) => [entry.id, entry]));
1049
1450
  const notBuilt = [];
1050
1451
  for (const result of results) {
1051
1452
  const outcome = result.outcome;
1052
- if (outcome.status !== "failed" && outcome.status !== "parked")
1453
+ if (outcome.status !== "failed" &&
1454
+ outcome.status !== "parked" &&
1455
+ outcome.status !== "awaiting_human")
1053
1456
  continue;
1054
1457
  const authorOutcome = authorById.get(result.id)?.outcome;
1055
1458
  const reason = authorOutcome !== undefined &&
@@ -1075,7 +1478,7 @@ function notBuiltRoster(results, authorResult) {
1075
1478
  * bug in this function's own body throws before the pass has even run.
1076
1479
  */
1077
1480
  export async function runWholeProgramStage(args) {
1078
- 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;
1079
1482
  const errors = [];
1080
1483
  let commit;
1081
1484
  let commitSkipped = false;
@@ -1106,6 +1509,7 @@ export async function runWholeProgramStage(args) {
1106
1509
  manifest,
1107
1510
  config,
1108
1511
  agentRunner,
1512
+ permits,
1109
1513
  diff,
1110
1514
  ...(base === undefined ? {} : { baseCommit: base }),
1111
1515
  ...(notBuilt.length === 0 ? {} : { notBuilt }),
@@ -1152,7 +1556,7 @@ export async function runWholeProgramStage(args) {
1152
1556
  }
1153
1557
  if (journaled && events.length > 0) {
1154
1558
  try {
1155
- await triageFindings({
1559
+ const spawnErrors = await triageFindings({
1156
1560
  root,
1157
1561
  programId,
1158
1562
  manifest,
@@ -1161,6 +1565,7 @@ export async function runWholeProgramStage(args) {
1161
1565
  baseCommit: reviewCommit,
1162
1566
  decider,
1163
1567
  agentRunner,
1568
+ permits,
1164
1569
  git,
1165
1570
  isRepository,
1166
1571
  reviewed,
@@ -1168,6 +1573,7 @@ export async function runWholeProgramStage(args) {
1168
1573
  now,
1169
1574
  log,
1170
1575
  });
1576
+ errors.push(...spawnErrors);
1171
1577
  }
1172
1578
  catch (error) {
1173
1579
  errors.push(`the decider failed to triage the whole-program findings: ${error.message}`);
@@ -1278,14 +1684,30 @@ function failureDiagnosisBrief(manifest, workstream, spec, diff, failure) {
1278
1684
  * undefined and the retry proceeds on the raw verify output alone.
1279
1685
  */
1280
1686
  async function diagnoseFailure(options) {
1281
- 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;
1282
1688
  const rawDiff = baseCommit !== undefined ? await git.diffSince(root, baseCommit) : "";
1283
1689
  const brief = failureDiagnosisBrief(manifest, workstream, clipForReview(spec, "spec").text, clipForReview(rawDiff, "diff").text, failure);
1284
- 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;
1285
1704
  if (invocation.exitCode !== 0)
1286
- return undefined;
1705
+ return transcript === undefined ? {} : { transcript };
1287
1706
  const text = invocation.output.trim();
1288
- return text === "" ? undefined : tail(text, 2000);
1707
+ return {
1708
+ ...(transcript === undefined ? {} : { transcript }),
1709
+ ...(text === "" ? {} : { diagnosis: tail(text, 2000) }),
1710
+ };
1289
1711
  }
1290
1712
  function successCriteriaLines(manifest) {
1291
1713
  if (manifest.successCriteria.length === 0) {
@@ -1445,10 +1867,35 @@ function testCritiqueFixBrief(workstream, spec, findings) {
1445
1867
  * whether (and how many times) this is called.
1446
1868
  */
1447
1869
  async function attemptFix(options) {
1448
- 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;
1449
1871
  const brief = testCritiqueFixBrief(workstream, spec, findings);
1450
- const invocation = await invokeAgent(agentRunner, agent, brief, root);
1451
- 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
+ }
1452
1899
  const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
1453
1900
  if (failure === undefined) {
1454
1901
  // The runner's own pending ledger and manifest writes under
@@ -1470,6 +1917,7 @@ async function attemptFix(options) {
1470
1917
  greenCommit: commit ?? greenCommit,
1471
1918
  ...(commit === undefined ? {} : { commit }),
1472
1919
  summary,
1920
+ ...(transcript === undefined ? {} : { transcript }),
1473
1921
  };
1474
1922
  }
1475
1923
  // The same uncommitted bookkeeping the clean path guards against sweeping
@@ -1485,7 +1933,7 @@ async function attemptFix(options) {
1485
1933
  const snapshot = await snapshotProgramsDir(root);
1486
1934
  await git.resetHard(root, greenCommit);
1487
1935
  await restoreProgramsDir(root, snapshot);
1488
- return { outcome: "failed", greenCommit, summary, failure };
1936
+ return { outcome: "failed", greenCommit, summary, failure, ...(transcript === undefined ? {} : { transcript }) };
1489
1937
  }
1490
1938
  /**
1491
1939
  * The test-critique driver: wires WS-01's bounded review loop to the two
@@ -1495,11 +1943,19 @@ async function attemptFix(options) {
1495
1943
  * failure), so a review can never turn green work red.
1496
1944
  */
1497
1945
  async function runTestCritique(options) {
1498
- 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;
1499
1947
  let greenCommit = options.greenCommit;
1500
1948
  let diffClipped = false;
1949
+ const stageErrors = [];
1950
+ const transcripts = [];
1501
1951
  if (!reviewer) {
1502
- return { outcome: reviewerAbsentOutcome(), finalCommit: greenCommit, diffClipped };
1952
+ return {
1953
+ outcome: reviewerAbsentOutcome(),
1954
+ finalCommit: greenCommit,
1955
+ diffClipped,
1956
+ stageErrors,
1957
+ transcripts,
1958
+ };
1503
1959
  }
1504
1960
  const locate = (file) => locateInRepo(root, file);
1505
1961
  const review = async (_round, priorOpen) => {
@@ -1509,7 +1965,28 @@ async function runTestCritique(options) {
1509
1965
  if (diff.clipped || clippedSpec.clipped)
1510
1966
  diffClipped = true;
1511
1967
  const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
1512
- 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
+ }
1513
1990
  const parsed = extractFindings(invocation.output);
1514
1991
  const findings = verifyEvidence(parsed.findings, locate);
1515
1992
  const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
@@ -1523,6 +2000,7 @@ async function runTestCritique(options) {
1523
2000
  spec,
1524
2001
  config,
1525
2002
  agentRunner,
2003
+ permits,
1526
2004
  verifyRunner,
1527
2005
  git,
1528
2006
  agent,
@@ -1530,8 +2008,14 @@ async function runTestCritique(options) {
1530
2008
  greenCommit,
1531
2009
  label: "test critique fix",
1532
2010
  log,
2011
+ now,
2012
+ observe: transcriptSink("test-critique-fix"),
1533
2013
  });
1534
2014
  greenCommit = fix.greenCommit;
2015
+ if (fix.transcript)
2016
+ transcripts.push(fix.transcript);
2017
+ if (fix.spawnError !== undefined)
2018
+ stageErrors.push(fix.spawnError);
1535
2019
  if (fix.outcome === "kept") {
1536
2020
  log(`${workstream.id}: test critique fix verified and committed`);
1537
2021
  return { note: `${fix.summary} (fix verified and committed)` };
@@ -1540,7 +2024,7 @@ async function runTestCritique(options) {
1540
2024
  return { note: `${fix.summary} (fix failed verification and was discarded; the green state was preserved)` };
1541
2025
  };
1542
2026
  const outcome = await runReviewPass({ review, respond });
1543
- return { outcome, finalCommit: greenCommit, diffClipped };
2027
+ return { outcome, finalCommit: greenCommit, diffClipped, stageErrors, transcripts };
1544
2028
  }
1545
2029
  function renderSpecsSection(authorResult) {
1546
2030
  if (authorResult.results.length === 0)
@@ -1747,19 +2231,131 @@ function renderWholeProgramSection(wholeProgram, ledger) {
1747
2231
  lines.push("");
1748
2232
  return lines;
1749
2233
  }
1750
- export function renderRunReport(manifest, results, ledger, triagedThisRun, authorResult, at, wholeProgram) {
2234
+ /**
2235
+ * The "Awaiting Human Actions" section (SC-05): every prerequisite still
2236
+ * `pending` after this run's preflight, with its id, description, and
2237
+ * remediation verbatim, its captured failure reason, and the single command
2238
+ * that resumes the program. A manifest with no pending prerequisites emits
2239
+ * nothing — the no-op invariant a prerequisite-free run relies on.
2240
+ */
2241
+ function renderAwaitingHumanSection(manifest, preflight, resumeCommand) {
2242
+ const unmet = manifest.prerequisites.filter((prerequisite) => prerequisite.status === "pending");
2243
+ if (unmet.length === 0)
2244
+ return [];
2245
+ const checksById = new Map((preflight?.checks ?? []).map((check) => [check.id, check]));
2246
+ const lines = [
2247
+ "## Awaiting Human Actions",
2248
+ "",
2249
+ "Everything else this run could build is built. The following are",
2250
+ "unmet — perform each remediation, then resume with the command at the",
2251
+ "end of this section.",
2252
+ "",
2253
+ ];
2254
+ for (const prerequisite of unmet) {
2255
+ const reason = checksById.get(prerequisite.id)?.reason ?? "not verified this run.";
2256
+ lines.push(`### ${prerequisite.id}`, "", prerequisite.description, "", "Remediation:", "", prerequisite.remediation, "", `The verify command reported: ${reason}`, "");
2257
+ }
2258
+ lines.push(`Resume with: \`${resumeCommand ?? `npx --yes @wildorder/nightshift run ${manifest.program.id}`}\``, "");
2259
+ return lines;
2260
+ }
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) {
1751
2339
  const programId = manifest.program.id;
1752
2340
  const built = results.filter((result) => result.outcome.status === "complete" ||
1753
2341
  result.outcome.status === "skipped").length;
2342
+ const awaitingCount = results.filter((result) => result.outcome.status === "awaiting_human").length;
2343
+ const outcomeLine = awaitingCount > 0
2344
+ ? `**Outcome: ${built} of ${results.length} workstreams built; ` +
2345
+ `${awaitingCount} awaiting a human action.**`
2346
+ : `**Outcome: ${built} of ${results.length} workstreams built.**`;
1754
2347
  const lines = [
1755
2348
  `# Run report: ${manifest.program.name} (${manifest.program.id})`,
1756
2349
  "",
1757
2350
  `_${at.toISOString()}_`,
1758
2351
  "",
1759
- `**Outcome: ${built} of ${results.length} workstreams built.**`,
2352
+ outcomeLine,
1760
2353
  "",
1761
- ...renderSpecsSection(authorResult),
1762
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));
1763
2359
  const escalated = escalatedRecords(ledger);
1764
2360
  if (escalated.length > 0) {
1765
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.", "");
@@ -1772,6 +2368,7 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1772
2368
  }
1773
2369
  }
1774
2370
  }
2371
+ lines.push(...renderAwaitingHumanSection(manifest, preflight, resumeCommand));
1775
2372
  const failures = results.filter((result) => result.outcome.status === "failed" || result.outcome.status === "parked");
1776
2373
  if (failures.length > 0) {
1777
2374
  lines.push("## Not built", "");
@@ -1783,6 +2380,12 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1783
2380
  for (const diagnosis of result.failureDiagnoses ?? []) {
1784
2381
  lines.push(`**Reviewer diagnosis (after the ${diagnosis.attempt} attempt):**`, "", diagnosis.verdict, "");
1785
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
+ }
1786
2389
  lines.push(`Retry after fixing: \`npx --yes @wildorder/nightshift run ${manifest.program.id}\``, "(completed workstreams are skipped automatically).", "");
1787
2390
  }
1788
2391
  }
@@ -1807,6 +2410,9 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1807
2410
  lines.push(" The reviewer saw input clipped for length.");
1808
2411
  }
1809
2412
  }
2413
+ if (result.outcome.status === "complete" || result.outcome.status === "failed") {
2414
+ lines.push(...renderWorkstreamCommands(result));
2415
+ }
1810
2416
  }
1811
2417
  lines.push("");
1812
2418
  if (wholeProgram !== undefined) {
@@ -1822,11 +2428,22 @@ export function renderRunReport(manifest, results, ledger, triagedThisRun, autho
1822
2428
  lines.push(...renderRecord(record, { density: "compact", programId }), "");
1823
2429
  }
1824
2430
  }
2431
+ lines.push(...renderCommandsDeniedSection(results, ledger, programId));
1825
2432
  lines.push(...renderKnownLimitations(results, authorResult, ledger, escalated, triagedThisRun, programId, wholeProgram));
1826
2433
  const decisionErrors = results.flatMap((result) => result.decisionErrors);
1827
2434
  if (decisionErrors.length > 0) {
1828
2435
  lines.push("## Decision blocks the runner could not read", "", ...decisionErrors.map((error) => `- ${error}`), "");
1829
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
+ }
1830
2447
  return lines.join("\n");
1831
2448
  }
1832
2449
  //# sourceMappingURL=run-program.js.map