@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.
- package/README.md +46 -19
- package/dist/agent-probe.d.ts +21 -2
- package/dist/agent-probe.d.ts.map +1 -1
- package/dist/agent-probe.js +76 -22
- package/dist/agent-probe.js.map +1 -1
- package/dist/agent-runner.d.ts +90 -2
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +382 -24
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-summary.d.ts +15 -0
- package/dist/agent-summary.d.ts.map +1 -1
- package/dist/agent-summary.js +32 -2
- package/dist/agent-summary.js.map +1 -1
- package/dist/as-built.d.ts +6 -0
- package/dist/as-built.d.ts.map +1 -1
- package/dist/as-built.js +7 -0
- package/dist/as-built.js.map +1 -1
- package/dist/author.d.ts +9 -0
- package/dist/author.d.ts.map +1 -1
- package/dist/author.js +161 -24
- package/dist/author.js.map +1 -1
- package/dist/ci-init.d.ts.map +1 -1
- package/dist/ci-init.js +17 -12
- package/dist/ci-init.js.map +1 -1
- package/dist/cli.js +144 -11
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/crash-report.d.ts +58 -0
- package/dist/crash-report.d.ts.map +1 -0
- package/dist/crash-report.js +115 -0
- package/dist/crash-report.js.map +1 -0
- package/dist/decide.js +1 -1
- package/dist/decide.js.map +1 -1
- package/dist/decider-review.d.ts +33 -5
- package/dist/decider-review.d.ts.map +1 -1
- package/dist/decider-review.js +55 -9
- package/dist/decider-review.js.map +1 -1
- package/dist/decision-ledger.d.ts +54 -1
- package/dist/decision-ledger.d.ts.map +1 -1
- package/dist/decision-ledger.js +53 -1
- package/dist/decision-ledger.js.map +1 -1
- package/dist/decision-view.d.ts +10 -1
- package/dist/decision-view.d.ts.map +1 -1
- package/dist/decision-view.js +41 -0
- package/dist/decision-view.js.map +1 -1
- package/dist/exit-codes.d.ts +16 -2
- package/dist/exit-codes.d.ts.map +1 -1
- package/dist/exit-codes.js +17 -2
- package/dist/exit-codes.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +68 -14
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +301 -22
- package/dist/manifest.js.map +1 -1
- package/dist/permits.d.ts +135 -0
- package/dist/permits.d.ts.map +1 -0
- package/dist/permits.js +397 -0
- package/dist/permits.js.map +1 -0
- package/dist/preflight.d.ts +73 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +225 -0
- package/dist/preflight.js.map +1 -0
- package/dist/publish.d.ts +71 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +319 -1
- package/dist/publish.js.map +1 -1
- package/dist/run-program.d.ts +70 -2
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +677 -60
- package/dist/run-program.js.map +1 -1
- package/dist/skill-roots.d.ts +11 -3
- package/dist/skill-roots.d.ts.map +1 -1
- package/dist/skill-roots.js +59 -12
- package/dist/skill-roots.js.map +1 -1
- package/dist/whole-program-review.d.ts +4 -0
- package/dist/whole-program-review.d.ts.map +1 -1
- package/dist/whole-program-review.js +9 -1
- package/dist/whole-program-review.js.map +1 -1
- package/package.json +2 -2
- package/skills/plan-program/SKILL.md +125 -5
- package/dist/verify-allowlist.d.ts +0 -23
- package/dist/verify-allowlist.d.ts.map +0 -1
- package/dist/verify-allowlist.js +0 -46
- package/dist/verify-allowlist.js.map +0 -1
package/dist/run-program.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
524
|
-
// their briefs would be missing a producer's spec
|
|
525
|
-
//
|
|
526
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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" &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1451
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|