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