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