@wildorder/nightshift 0.3.1 → 0.5.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 +48 -1
- package/dist/agent-runner.d.ts +18 -0
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +26 -0
- package/dist/agent-runner.js.map +1 -1
- package/dist/author.d.ts +64 -0
- package/dist/author.d.ts.map +1 -0
- package/dist/author.js +627 -0
- package/dist/author.js.map +1 -0
- package/dist/cli.js +110 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +14 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -1
- package/dist/config.js.map +1 -1
- package/dist/decide.d.ts +49 -0
- package/dist/decide.d.ts.map +1 -0
- package/dist/decide.js +178 -0
- package/dist/decide.js.map +1 -0
- package/dist/decider-review.d.ts +21 -0
- package/dist/decider-review.d.ts.map +1 -0
- package/dist/decider-review.js +98 -0
- package/dist/decider-review.js.map +1 -0
- package/dist/findings.d.ts +3 -21
- package/dist/findings.d.ts.map +1 -1
- package/dist/findings.js +0 -7
- package/dist/findings.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +2 -0
- package/dist/manifest.js.map +1 -1
- package/dist/review-pass.d.ts +64 -0
- package/dist/review-pass.d.ts.map +1 -0
- package/dist/review-pass.js +370 -0
- package/dist/review-pass.js.map +1 -0
- package/dist/run-program.d.ts +20 -0
- package/dist/run-program.d.ts.map +1 -1
- package/dist/run-program.js +445 -131
- package/dist/run-program.js.map +1 -1
- package/package.json +2 -2
package/dist/run-program.js
CHANGED
|
@@ -2,13 +2,18 @@ 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, resolveAgent, resolveDeciderAgent, resolveRecoveryAgent, tail, } from "./agent-runner.js";
|
|
5
|
+
import { defaultAgentRunner, defaultVerifyRunner, describeAgent, invokeAgent, resolveAgent, resolveDeciderAgent, resolveRecoveryAgent, resolveReviewerAgent, tail, } from "./agent-runner.js";
|
|
6
6
|
import { resolveSummary, summaryContract } from "./agent-summary.js";
|
|
7
|
+
import { authorProgram } from "./author.js";
|
|
7
8
|
import { decisionContract, decisionFingerprint, extractDecisions, } from "./decision.js";
|
|
8
9
|
import { appendLedgerEvents, readDecisionLedger, } from "./decision-ledger.js";
|
|
10
|
+
import { reviewDecisions } from "./decider-review.js";
|
|
9
11
|
import { findCycles, stableTopologicalOrder } from "./graph.js";
|
|
12
|
+
import { extractFindings, findingsContract, findingsToLedgerEvents, renderPassReport, reviewerAbsentOutcome, runReviewPass, } from "./review-pass.js";
|
|
10
13
|
import { loadManifest, saveManifest, } from "./manifest.js";
|
|
11
14
|
const execFileAsync = promisify(execFile);
|
|
15
|
+
/** Matches every wording git uses to report an empty commit attempt. */
|
|
16
|
+
const NOTHING_TO_COMMIT = /nothing to commit|nothing added to commit|no changes added to commit/u;
|
|
12
17
|
export const defaultGitOps = {
|
|
13
18
|
async isRepository(cwd) {
|
|
14
19
|
try {
|
|
@@ -36,7 +41,25 @@ export const defaultGitOps = {
|
|
|
36
41
|
catch (error) {
|
|
37
42
|
const output = String(error.stdout ?? "");
|
|
38
43
|
// An empty commit is not a failure; anything else is.
|
|
39
|
-
if (
|
|
44
|
+
if (NOTHING_TO_COMMIT.test(output)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return this.currentCommit(cwd);
|
|
50
|
+
},
|
|
51
|
+
async commitPaths(cwd, message, paths) {
|
|
52
|
+
await execFileAsync("git", ["add", "--", ...paths], { cwd });
|
|
53
|
+
try {
|
|
54
|
+
await execFileAsync("git", ["commit", "-m", message], { cwd });
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const output = String(error.stdout ?? "");
|
|
58
|
+
// An empty commit is not a failure; anything else is. Unlike
|
|
59
|
+
// commitAll's `add -A`, a scoped `add` can leave other files dirty,
|
|
60
|
+
// which makes git report "no changes added to commit" instead of
|
|
61
|
+
// "nothing to commit" — both mean the same thing here.
|
|
62
|
+
if (NOTHING_TO_COMMIT.test(output)) {
|
|
40
63
|
return undefined;
|
|
41
64
|
}
|
|
42
65
|
throw error;
|
|
@@ -64,15 +87,56 @@ export const defaultGitOps = {
|
|
|
64
87
|
return [];
|
|
65
88
|
}
|
|
66
89
|
},
|
|
90
|
+
async isAncestor(cwd, ancestor, descendant) {
|
|
91
|
+
try {
|
|
92
|
+
await execFileAsync("git", ["merge-base", "--is-ancestor", ancestor, descendant], { cwd });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async resetHard(cwd, commit) {
|
|
100
|
+
await execFileAsync("git", ["reset", "--hard", commit], { cwd });
|
|
101
|
+
},
|
|
102
|
+
async createRef(cwd, ref, commit) {
|
|
103
|
+
await execFileAsync("git", ["update-ref", ref, commit], { cwd });
|
|
104
|
+
},
|
|
67
105
|
};
|
|
68
|
-
|
|
69
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Ledger decisions for one workstream, projected into its brief. A
|
|
108
|
+
* human-decided record is binding — the implementer is told to build that
|
|
109
|
+
* choice, not re-decide it; a ratified record is context, what the decider
|
|
110
|
+
* already blessed. Escalated and unratified records carry no authority yet
|
|
111
|
+
* and are left out.
|
|
112
|
+
*/
|
|
113
|
+
function decisionsRuledOnSection(records) {
|
|
114
|
+
if (records.length === 0)
|
|
115
|
+
return [];
|
|
116
|
+
const lines = ["## Decisions already ruled on", ""];
|
|
117
|
+
for (const record of records) {
|
|
118
|
+
if (record.status === "human-decided") {
|
|
119
|
+
lines.push(`- **${record.decision.title}** — binding: the human ruled ` +
|
|
120
|
+
`\`${record.humanChosen}\` — ${record.humanReason}. Implement ` +
|
|
121
|
+
"that choice; it is not yours or the decider's to revisit.");
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
lines.push(`- **${record.decision.title}** — context: ratified as ` +
|
|
125
|
+
`\`${record.decision.chosen}\` — ${record.reviewRationale ?? record.decision.rationale}.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
lines.push("");
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
function implementerBrief(manifest, workstream, spec, ledger, priorFailure) {
|
|
70
132
|
const roster = manifest.workstreams
|
|
71
133
|
.map((entry) => {
|
|
72
134
|
const scope = entry.scope?.summary ?? entry.name;
|
|
73
135
|
return `- ${entry.id} ${entry.name}: ${scope}`;
|
|
74
136
|
})
|
|
75
137
|
.join("\n");
|
|
138
|
+
const relevantDecisions = ledger.decisions.filter((record) => record.workstream === workstream.id &&
|
|
139
|
+
(record.status === "human-decided" || record.status === "ratified"));
|
|
76
140
|
return [
|
|
77
141
|
`# Workstream ${workstream.id}: ${workstream.name}`,
|
|
78
142
|
"",
|
|
@@ -84,6 +148,7 @@ function implementerBrief(manifest, workstream, spec, priorFailure) {
|
|
|
84
148
|
"",
|
|
85
149
|
roster,
|
|
86
150
|
"",
|
|
151
|
+
...decisionsRuledOnSection(relevantDecisions),
|
|
87
152
|
...(priorFailure
|
|
88
153
|
? [
|
|
89
154
|
"## Previous attempt failed",
|
|
@@ -114,71 +179,6 @@ function implementerBrief(manifest, workstream, spec, priorFailure) {
|
|
|
114
179
|
summaryContract(),
|
|
115
180
|
].join("\n");
|
|
116
181
|
}
|
|
117
|
-
function deciderBrief(manifest, record, diff) {
|
|
118
|
-
const clippedDiff = diff.length > DIFF_LIMIT
|
|
119
|
-
? `${diff.slice(0, DIFF_LIMIT)}\n… (diff clipped at ${DIFF_LIMIT} characters)`
|
|
120
|
-
: diff;
|
|
121
|
-
return [
|
|
122
|
-
"# Review one decision",
|
|
123
|
-
"",
|
|
124
|
-
`Program: ${manifest.program.id} — ${manifest.program.name}`,
|
|
125
|
-
`Workstream: ${record.workstream}`,
|
|
126
|
-
"",
|
|
127
|
-
"An implementing agent made the judgment call below and continued. Your",
|
|
128
|
-
"job is to review that one decision — not the code style, not the whole",
|
|
129
|
-
"diff — from the program's point of view. You never edit anything.",
|
|
130
|
-
"",
|
|
131
|
-
"```json",
|
|
132
|
-
JSON.stringify(record.decision, null, 2),
|
|
133
|
-
"```",
|
|
134
|
-
"",
|
|
135
|
-
"The work that resulted:",
|
|
136
|
-
"",
|
|
137
|
-
"```diff",
|
|
138
|
-
clippedDiff.trim() === "" ? "(no diff available)" : clippedDiff,
|
|
139
|
-
"```",
|
|
140
|
-
"",
|
|
141
|
-
"Reply with exactly one verdict block:",
|
|
142
|
-
"",
|
|
143
|
-
"```verdict",
|
|
144
|
-
"{",
|
|
145
|
-
' "verdict": "ratify" | "escalate",',
|
|
146
|
-
' "rationale": "One or two sentences, written for the human who reads the run report."',
|
|
147
|
-
"}",
|
|
148
|
-
"```",
|
|
149
|
-
"",
|
|
150
|
-
"Ratify when the choice is defensible — it does not have to be the one",
|
|
151
|
-
"you would have made. Escalate when the choice materially affects",
|
|
152
|
-
"user-visible behavior, data, or a public contract AND you believe the",
|
|
153
|
-
"human would plausibly choose differently; say what you would ask them.",
|
|
154
|
-
"One review, one verdict: this is a judgment, not a dialogue.",
|
|
155
|
-
].join("\n");
|
|
156
|
-
}
|
|
157
|
-
function extractVerdict(output) {
|
|
158
|
-
const matches = [...output.matchAll(/```verdict[^\S\r\n]*\r?\n([\s\S]*?)```/gu)];
|
|
159
|
-
for (const match of matches.reverse()) {
|
|
160
|
-
try {
|
|
161
|
-
const parsed = JSON.parse(match[1] ?? "");
|
|
162
|
-
if ((parsed.verdict === "ratify" || parsed.verdict === "escalate") &&
|
|
163
|
-
typeof parsed.rationale === "string") {
|
|
164
|
-
return { verdict: parsed.verdict, rationale: parsed.rationale };
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
// Try an earlier block.
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return undefined;
|
|
172
|
-
}
|
|
173
|
-
async function invokeAgent(runner, agent, prompt, cwd) {
|
|
174
|
-
return runner({
|
|
175
|
-
command: agent.command,
|
|
176
|
-
args: agent.args,
|
|
177
|
-
prompt,
|
|
178
|
-
promptMode: agent.promptMode,
|
|
179
|
-
cwd,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
182
|
/** Every workstream in the transitive downstream cone of the given ids. */
|
|
183
183
|
export function downstreamCone(workstreams, rootIds) {
|
|
184
184
|
const cone = new Set(rootIds);
|
|
@@ -216,7 +216,7 @@ export async function runProgram(options) {
|
|
|
216
216
|
const git = options.git ?? defaultGitOps;
|
|
217
217
|
const log = options.log ?? ((line) => console.log(line));
|
|
218
218
|
const now = options.now ?? (() => new Date());
|
|
219
|
-
|
|
219
|
+
let manifest = await loadManifest(root, options.programId);
|
|
220
220
|
const resolvedAgent = resolveAgent(config);
|
|
221
221
|
if (!resolvedAgent) {
|
|
222
222
|
throw new Error("No implementer configured. Set the `agent` block in nightshift.config.json.");
|
|
@@ -224,10 +224,14 @@ export async function runProgram(options) {
|
|
|
224
224
|
const agent = resolvedAgent;
|
|
225
225
|
const recovery = resolveRecoveryAgent(config);
|
|
226
226
|
const decider = resolveDeciderAgent(config);
|
|
227
|
+
const reviewer = resolveReviewerAgent(config);
|
|
227
228
|
log(`implementer: ${describeAgent(agent)}`);
|
|
228
229
|
log(decider
|
|
229
230
|
? `decider: ${describeAgent(decider)}`
|
|
230
231
|
: "decider: none configured — implementer defaults will stand unratified");
|
|
232
|
+
log(reviewer
|
|
233
|
+
? `test critique reviewer: ${describeAgent(reviewer)}`
|
|
234
|
+
: "test critique reviewer: none configured — test critique disabled");
|
|
231
235
|
const cycles = findCycles(manifest.workstreams);
|
|
232
236
|
if (cycles.length > 0) {
|
|
233
237
|
// A cyclic graph cannot be ordered; this is a planning defect, not a
|
|
@@ -248,16 +252,41 @@ export async function runProgram(options) {
|
|
|
248
252
|
// The runner's own artifacts under docs/programs/ are exempt: they are
|
|
249
253
|
// output, not work in progress.
|
|
250
254
|
const dirty = (await git.dirtyPaths(root)).filter((path) => !path.replaceAll("\\", "/").startsWith("docs/programs/"));
|
|
251
|
-
const resuming = manifest.workstreams.some((workstream) => workstream.status === "failed" ||
|
|
255
|
+
const resuming = manifest.workstreams.some((workstream) => workstream.status === "failed" ||
|
|
256
|
+
workstream.status === "in_progress" ||
|
|
257
|
+
workstream.status === "parked");
|
|
252
258
|
if (dirty.length > 0 && !resuming) {
|
|
253
259
|
throw new Error(`The working tree has uncommitted changes the run would sweep into its commits:\n` +
|
|
254
260
|
dirty.map((path) => ` ${path}`).join("\n") +
|
|
255
261
|
`\nCommit or stash them, then re-run.`);
|
|
256
262
|
}
|
|
257
263
|
}
|
|
264
|
+
// Authoring runs before building: every workstream whose spec is missing
|
|
265
|
+
// gets one, in dependency order, before anything is implemented. It
|
|
266
|
+
// reloads the manifest afterward because authoring may have merged
|
|
267
|
+
// discovered dependency edges or parked workstreams it could not author.
|
|
268
|
+
const authorResult = await authorProgram({
|
|
269
|
+
cwd: options.cwd,
|
|
270
|
+
programId: options.programId,
|
|
271
|
+
config,
|
|
272
|
+
agentRunner,
|
|
273
|
+
git,
|
|
274
|
+
log,
|
|
275
|
+
now,
|
|
276
|
+
});
|
|
277
|
+
manifest = await loadManifest(root, options.programId);
|
|
278
|
+
// Loaded once so every brief in this run projects the same picture of
|
|
279
|
+
// human-decided and ratified choices; decisions this run itself journals
|
|
280
|
+
// are picked up fresh by `readDecisionLedger` at the end, for escalations.
|
|
281
|
+
const ledgerAtStart = await readDecisionLedger(root, options.programId);
|
|
258
282
|
const ordered = stableTopologicalOrder(manifest.workstreams);
|
|
259
283
|
const results = [];
|
|
260
|
-
|
|
284
|
+
// Seeded with every workstream whose spec authoring failed or parked —
|
|
285
|
+
// their briefs would be missing a producer's spec, so the build stage
|
|
286
|
+
// must not attempt them this run either.
|
|
287
|
+
const blocked = new Set(authorResult.results
|
|
288
|
+
.filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked")
|
|
289
|
+
.map((entry) => entry.id));
|
|
261
290
|
for (const workstream of ordered) {
|
|
262
291
|
if (workstream.status === "complete") {
|
|
263
292
|
results.push({
|
|
@@ -299,9 +328,9 @@ export async function runProgram(options) {
|
|
|
299
328
|
manifest.program.status = complete ? "complete" : "partial";
|
|
300
329
|
await saveManifest(root, options.programId, manifest);
|
|
301
330
|
const reportPath = join(root, "docs", "programs", `${options.programId}-run-report.md`);
|
|
302
|
-
await writeFile(reportPath, renderRunReport(manifest, results, ledger.decisions, escalations, now()), "utf8");
|
|
331
|
+
await writeFile(reportPath, renderRunReport(manifest, results, ledger.decisions, escalations, authorResult, now()), "utf8");
|
|
303
332
|
if (isRepository) {
|
|
304
|
-
await git.
|
|
333
|
+
await git.commitPaths(root, `nightshift(${options.programId}): run report and decision ledger`, ["docs/programs"]);
|
|
305
334
|
}
|
|
306
335
|
log(`run report: ${reportPath}`);
|
|
307
336
|
return {
|
|
@@ -344,7 +373,7 @@ export async function runProgram(options) {
|
|
|
344
373
|
}
|
|
345
374
|
for (const [index, attempt] of attempts.entries()) {
|
|
346
375
|
log(`${workstream.id} ${workstream.name}: ${attempt.label} attempt`);
|
|
347
|
-
const brief = implementerBrief(manifest, workstream, spec, priorFailure);
|
|
376
|
+
const brief = implementerBrief(manifest, workstream, spec, ledgerAtStart, priorFailure);
|
|
348
377
|
const invocation = await invokeAgent(agentRunner, attempt.agent, brief, root);
|
|
349
378
|
const summary = resolveSummary(invocation.output);
|
|
350
379
|
base.summary = summary.text;
|
|
@@ -352,18 +381,65 @@ export async function runProgram(options) {
|
|
|
352
381
|
base.decisionErrors.push(...parsed.errors);
|
|
353
382
|
await journalDecisions(workstream, parsed.decisions, baseCommit);
|
|
354
383
|
base.decisionIds = parsed.decisions.map((decision) => decisionFingerprint(workstream.id, decision));
|
|
355
|
-
const failure = await verifyAttempt(invocation.exitCode);
|
|
384
|
+
const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
|
|
356
385
|
if (failure === undefined) {
|
|
357
|
-
|
|
358
|
-
await saveManifest(root, options.programId, manifest);
|
|
359
|
-
let commit;
|
|
386
|
+
let c0;
|
|
360
387
|
if (isRepository) {
|
|
361
|
-
|
|
388
|
+
// Unlike the authoring, run-report, and replay commits, this one
|
|
389
|
+
// stays a whole-tree sweep: an implementing agent touches whatever
|
|
390
|
+
// files the work required, and that set is exactly what the
|
|
391
|
+
// runner cannot know in advance. c0 is the green anchor the test
|
|
392
|
+
// critique's fix loop resets to on a failing fix (SC-07).
|
|
393
|
+
c0 = await git.commitAll(root, `nightshift(${options.programId}): ${workstream.id} ${workstream.name}`);
|
|
362
394
|
}
|
|
363
|
-
|
|
395
|
+
const critique = isRepository && c0 !== undefined
|
|
396
|
+
? await runTestCritique({
|
|
397
|
+
root,
|
|
398
|
+
manifest,
|
|
399
|
+
workstream,
|
|
400
|
+
spec,
|
|
401
|
+
config,
|
|
402
|
+
agentRunner,
|
|
403
|
+
verifyRunner,
|
|
404
|
+
git,
|
|
405
|
+
reviewer,
|
|
406
|
+
agent,
|
|
407
|
+
baseCommit,
|
|
408
|
+
greenCommit: c0,
|
|
409
|
+
now,
|
|
410
|
+
log,
|
|
411
|
+
})
|
|
412
|
+
: undefined;
|
|
413
|
+
// The manifest's single commit field records the workstream's final
|
|
414
|
+
// verified state — after any kept fix, that is the fix commit, not
|
|
415
|
+
// the earlier green one.
|
|
416
|
+
const finalCommit = critique?.finalCommit ?? c0;
|
|
417
|
+
// Findings anchor to c0 (the green, pre-critique commit) — the
|
|
418
|
+
// honest rollback point — while the decider below diffs from
|
|
419
|
+
// baseCommit (pre-workstream), so it sees the whole workstream.
|
|
420
|
+
const findingEvents = findingsToLedgerEvents({
|
|
421
|
+
workstreamId: workstream.id,
|
|
422
|
+
findings: critique?.outcome.open ?? [],
|
|
423
|
+
...(c0 === undefined ? {} : { baseCommit: c0 }),
|
|
424
|
+
now,
|
|
425
|
+
});
|
|
426
|
+
await appendLedgerEvents(root, options.programId, findingEvents);
|
|
427
|
+
const findingDecisions = findingEvents.flatMap((event) => event.kind === "decision-recorded" ? [event.decision] : []);
|
|
428
|
+
// The commit's own sha cannot be part of the tree it commits, so the
|
|
429
|
+
// manifest records it only now — swept forward into whatever commits
|
|
430
|
+
// next. Replay reads the manifest's current state, not the commit
|
|
431
|
+
// that last touched it, so this lag is harmless.
|
|
432
|
+
workstream.status = "complete";
|
|
433
|
+
if (finalCommit !== undefined)
|
|
434
|
+
workstream.commit = finalCommit;
|
|
435
|
+
await saveManifest(root, options.programId, manifest);
|
|
436
|
+
await reviewWorkstreamDecisions(workstream.id, [...parsed.decisions, ...findingDecisions], baseCommit);
|
|
437
|
+
if (critique)
|
|
438
|
+
base.testCritique = critique.outcome;
|
|
439
|
+
base.testCritiqueDiffClipped = critique?.diffClipped ?? false;
|
|
364
440
|
base.outcome = {
|
|
365
441
|
status: "complete",
|
|
366
|
-
...(
|
|
442
|
+
...(finalCommit === undefined ? {} : { commit: finalCommit }),
|
|
367
443
|
};
|
|
368
444
|
return base;
|
|
369
445
|
}
|
|
@@ -374,26 +450,13 @@ export async function runProgram(options) {
|
|
|
374
450
|
await saveManifest(root, options.programId, manifest);
|
|
375
451
|
// The work stays in the tree for a resume; decisions made on the way
|
|
376
452
|
// to a failure are still journaled and still reviewable.
|
|
377
|
-
await
|
|
453
|
+
await reviewWorkstreamDecisions(workstream.id, parsed.decisions, baseCommit);
|
|
378
454
|
base.outcome = { status: "failed", reason: failure };
|
|
379
455
|
return base;
|
|
380
456
|
}
|
|
381
457
|
}
|
|
382
458
|
return base;
|
|
383
459
|
}
|
|
384
|
-
/** Undefined means the attempt verified clean; otherwise the diagnosis. */
|
|
385
|
-
async function verifyAttempt(agentExitCode) {
|
|
386
|
-
if (agentExitCode !== 0) {
|
|
387
|
-
return `agent exited with code ${agentExitCode}`;
|
|
388
|
-
}
|
|
389
|
-
for (const [name, command] of Object.entries(config.verify)) {
|
|
390
|
-
const result = await verifyRunner(command, root);
|
|
391
|
-
if (result.exitCode !== 0) {
|
|
392
|
-
return `verify \`${name}\` (${command}) exited ${result.exitCode}:\n${tail(result.output, 1500)}`;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return undefined;
|
|
396
|
-
}
|
|
397
460
|
async function journalDecisions(workstream, decisions, baseCommit) {
|
|
398
461
|
const events = decisions.map((decision) => ({
|
|
399
462
|
kind: "decision-recorded",
|
|
@@ -409,44 +472,286 @@ export async function runProgram(options) {
|
|
|
409
472
|
log(`${workstream.id} decision: ${decision.title} -> ${decision.chosen}`);
|
|
410
473
|
}
|
|
411
474
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
475
|
+
function reviewWorkstreamDecisions(workstreamId, decisions, baseCommit) {
|
|
476
|
+
return reviewDecisions({
|
|
477
|
+
root,
|
|
478
|
+
programId: options.programId,
|
|
479
|
+
manifest,
|
|
480
|
+
workstreamId,
|
|
481
|
+
decisions,
|
|
482
|
+
baseCommit,
|
|
483
|
+
decider,
|
|
484
|
+
agentRunner,
|
|
485
|
+
git,
|
|
486
|
+
isRepository,
|
|
487
|
+
now,
|
|
488
|
+
log,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Undefined means the attempt verified clean; otherwise the diagnosis. */
|
|
493
|
+
async function verifyAttempt(config, verifyRunner, root, agentExitCode) {
|
|
494
|
+
if (agentExitCode !== 0) {
|
|
495
|
+
return `agent exited with code ${agentExitCode}`;
|
|
496
|
+
}
|
|
497
|
+
for (const [name, command] of Object.entries(config.verify)) {
|
|
498
|
+
const result = await verifyRunner(command, root);
|
|
499
|
+
if (result.exitCode !== 0) {
|
|
500
|
+
return `verify \`${name}\` (${command}) exited ${result.exitCode}:\n${tail(result.output, 1500)}`;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
// A constant, never a config key — the charter keeps tuning knobs off the
|
|
506
|
+
// config surface. Larger than the decider's 20,000-character diff limit
|
|
507
|
+
// because the reviewer's whole job here is to read the tests.
|
|
508
|
+
const TEST_CRITIQUE_DIFF_LIMIT = 60_000;
|
|
509
|
+
/**
|
|
510
|
+
* Clips oversized reviewer input with a visible marker rather than silently
|
|
511
|
+
* truncating — a review of half a diff that does not say so is worse than no
|
|
512
|
+
* review at all.
|
|
513
|
+
*/
|
|
514
|
+
function clipForReview(text, label) {
|
|
515
|
+
if (text.length <= TEST_CRITIQUE_DIFF_LIMIT)
|
|
516
|
+
return { text, clipped: false };
|
|
517
|
+
return {
|
|
518
|
+
text: `${text.slice(0, TEST_CRITIQUE_DIFF_LIMIT)}\n… (${label} clipped at ` +
|
|
519
|
+
`${TEST_CRITIQUE_DIFF_LIMIT} characters — the reviewer saw a partial ${label})`,
|
|
520
|
+
clipped: true,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/** Presence of at least one fenced findings block, distinguishing "clean" from "no read at all". */
|
|
524
|
+
function hasFindingsBlock(output) {
|
|
525
|
+
return /```findings/u.test(output);
|
|
526
|
+
}
|
|
527
|
+
function successCriteriaLines(manifest) {
|
|
528
|
+
if (manifest.successCriteria.length === 0) {
|
|
529
|
+
return ["None recorded in the manifest."];
|
|
530
|
+
}
|
|
531
|
+
return manifest.successCriteria.map((criterion) => `- **${criterion.id}**: ${criterion.description}`);
|
|
532
|
+
}
|
|
533
|
+
function scopeLines(workstream) {
|
|
534
|
+
const scope = workstream.scope;
|
|
535
|
+
if (!scope)
|
|
536
|
+
return ["No scope recorded in the manifest."];
|
|
537
|
+
const lines = [scope.summary];
|
|
538
|
+
if (scope.includes.length > 0) {
|
|
539
|
+
lines.push("Includes:", ...scope.includes.map((entry) => `- ${entry}`));
|
|
540
|
+
}
|
|
541
|
+
if (scope.excludes.length > 0) {
|
|
542
|
+
lines.push("Excludes:", ...scope.excludes.map((entry) => `- ${entry}`));
|
|
543
|
+
}
|
|
544
|
+
return lines;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The test-critique reviewer's brief: this workstream's scope and the
|
|
548
|
+
* program's success criteria (so the reviewer can tell which are in scope),
|
|
549
|
+
* the spec, the full workstream diff, and the three questions from the spec
|
|
550
|
+
* (SC-06) — would a plausible wrong implementation pass, does every in-scope
|
|
551
|
+
* criterion have a test that could actually fail, and was any test weakened
|
|
552
|
+
* to reach green.
|
|
553
|
+
*/
|
|
554
|
+
function testCritiqueReviewerBrief(manifest, workstream, spec, diff, priorOpen) {
|
|
555
|
+
const priorSection = priorOpen.length > 0
|
|
556
|
+
? [
|
|
557
|
+
"## Findings from the last round",
|
|
558
|
+
"",
|
|
559
|
+
"Last round, another reviewer raised the following; check whether",
|
|
560
|
+
"the current diff addresses them:",
|
|
561
|
+
"",
|
|
562
|
+
...priorOpen.map((finding) => `- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`),
|
|
563
|
+
"",
|
|
564
|
+
]
|
|
565
|
+
: [];
|
|
566
|
+
return [
|
|
567
|
+
`# Test critique for ${workstream.id}: ${workstream.name}`,
|
|
568
|
+
"",
|
|
569
|
+
`Program: ${manifest.program.id} — ${manifest.program.name}`,
|
|
570
|
+
"",
|
|
571
|
+
"An implementer built this workstream and its own tests, and the",
|
|
572
|
+
"runner's own verify commands passed. The same agent wrote the code",
|
|
573
|
+
"and the tests, which makes \"the tests pass\" circular — a stub that",
|
|
574
|
+
"returns a fixed value can sail through a suite that never exercises",
|
|
575
|
+
"the real path. You are an independent second read: look at the diff,",
|
|
576
|
+
"the spec, and the program's success criteria, and judge whether the",
|
|
577
|
+
"tests actually prove anything.",
|
|
578
|
+
"",
|
|
579
|
+
"## Success criteria",
|
|
580
|
+
"",
|
|
581
|
+
...successCriteriaLines(manifest),
|
|
582
|
+
"",
|
|
583
|
+
"## This workstream's scope",
|
|
584
|
+
"",
|
|
585
|
+
...scopeLines(workstream),
|
|
586
|
+
"",
|
|
587
|
+
"## Specification",
|
|
588
|
+
"",
|
|
589
|
+
spec.trim(),
|
|
590
|
+
"",
|
|
591
|
+
"## Diff (implementation and tests together)",
|
|
592
|
+
"",
|
|
593
|
+
"```diff",
|
|
594
|
+
diff.trim() === "" ? "(no diff available)" : diff,
|
|
595
|
+
"```",
|
|
596
|
+
"",
|
|
597
|
+
...priorSection,
|
|
598
|
+
"## Questions",
|
|
599
|
+
"",
|
|
600
|
+
"1. Would a **plausible wrong implementation** pass this test suite?",
|
|
601
|
+
" Name the wrong implementation you have in mind and the test that",
|
|
602
|
+
" would let it through.",
|
|
603
|
+
"2. Does **every success criterion in this workstream's scope** have a",
|
|
604
|
+
" test that could **actually fail** — one that is not tautological,",
|
|
605
|
+
" not asserting a constant, not skipped? Call out by id any in-scope",
|
|
606
|
+
" criterion with no such test.",
|
|
607
|
+
"3. Were any tests **weakened, skipped, or deleted** to reach green —",
|
|
608
|
+
" an assertion loosened, a case `.skip`ped, a file removed — visible",
|
|
609
|
+
" in the diff?",
|
|
610
|
+
"",
|
|
611
|
+
"A suite that holds up is a fine answer: if you have no objection, say",
|
|
612
|
+
"so and emit an empty findings array. Severity is a routing hint for",
|
|
613
|
+
"where a finding goes, never a verdict. A genuinely structural",
|
|
614
|
+
"objection is a **blocker**; it routes to a decision the human weighs,",
|
|
615
|
+
"never a gate that stops anything here.",
|
|
616
|
+
"",
|
|
617
|
+
"Always end your reply with a findings block, using an empty array `[]`",
|
|
618
|
+
"when you have no objection, so a clean read and a skipped read stay",
|
|
619
|
+
"distinguishable.",
|
|
620
|
+
"",
|
|
621
|
+
findingsContract(),
|
|
622
|
+
].join("\n");
|
|
623
|
+
}
|
|
624
|
+
function findingEvidenceLine(finding) {
|
|
625
|
+
const evidence = finding.evidence
|
|
626
|
+
.map((entry) => {
|
|
627
|
+
if (entry.kind === "location") {
|
|
628
|
+
return `${entry.file}:${entry.startLine}${entry.excerpt ? ` — ${entry.excerpt}` : ""}`;
|
|
629
|
+
}
|
|
630
|
+
if (entry.kind === "concern") {
|
|
631
|
+
return entry.detail ? `${entry.named} — ${entry.detail}` : entry.named;
|
|
632
|
+
}
|
|
633
|
+
return `${entry.metric}: ${entry.value}`;
|
|
634
|
+
})
|
|
635
|
+
.join("; ");
|
|
636
|
+
return evidence === "" ? "" : ` Evidence: ${evidence}`;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The implementer's fix re-brief: this round's triaged findings in prose,
|
|
640
|
+
* inviting judgment rather than demanding compliance — the manual workflow's
|
|
641
|
+
* own framing (WS-03 design §2). Never asks the implementer to commit or to
|
|
642
|
+
* echo a block back.
|
|
643
|
+
*/
|
|
644
|
+
function testCritiqueFixBrief(workstream, spec, findings) {
|
|
645
|
+
const findingsList = findings.map((finding) => [
|
|
646
|
+
`- **${finding.severity}** (${finding.category}) ${finding.subject}: ${finding.message}`,
|
|
647
|
+
findingEvidenceLine(finding),
|
|
648
|
+
]
|
|
649
|
+
.filter((line) => line !== "")
|
|
650
|
+
.join("\n"));
|
|
651
|
+
return [
|
|
652
|
+
`# Your tests for ${workstream.id}: ${workstream.name} were reviewed`,
|
|
653
|
+
"",
|
|
654
|
+
"An independent reviewer read the diff you produced — implementation",
|
|
655
|
+
"and tests together — and raised the following:",
|
|
656
|
+
"",
|
|
657
|
+
...findingsList,
|
|
658
|
+
"",
|
|
659
|
+
"## Specification",
|
|
660
|
+
"",
|
|
661
|
+
spec.trim(),
|
|
662
|
+
"",
|
|
663
|
+
"Strengthen the tests, and the implementation where the tests exposed",
|
|
664
|
+
"a real gap, for what you agree with. State plainly what you decline",
|
|
665
|
+
"and why — declines are recorded and reported, never argued with. Do",
|
|
666
|
+
"not reach green by weakening the suite further: the finding is that",
|
|
667
|
+
"the tests are too weak, and loosening an assertion, skipping a case,",
|
|
668
|
+
"or deleting a test is the one fix that misses the point.",
|
|
669
|
+
"",
|
|
670
|
+
"Never commit — the runner owns commits, verifies your fix itself, and",
|
|
671
|
+
"either keeps it or discards it depending on whether it actually",
|
|
672
|
+
"verifies. Reply in prose, not a block: what you fixed and what you",
|
|
673
|
+
"declined, and why.",
|
|
674
|
+
"",
|
|
675
|
+
summaryContract(),
|
|
676
|
+
].join("\n");
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The test-critique driver: wires WS-01's bounded review loop to the two
|
|
680
|
+
* briefs above, and owns the green-state invariant (SC-07) — a closure
|
|
681
|
+
* variable holding the last verified commit, which every fix round either
|
|
682
|
+
* advances (on a clean re-verify) or falls back to (`resetHard`, on a
|
|
683
|
+
* failure), so a review can never turn green work red.
|
|
684
|
+
*/
|
|
685
|
+
async function runTestCritique(options) {
|
|
686
|
+
const { root, manifest, workstream, spec, config, agentRunner, verifyRunner, git, reviewer, agent, baseCommit, log, } = options;
|
|
687
|
+
let greenCommit = options.greenCommit;
|
|
688
|
+
let diffClipped = false;
|
|
689
|
+
if (!reviewer) {
|
|
690
|
+
return { outcome: reviewerAbsentOutcome(), finalCommit: greenCommit, diffClipped };
|
|
691
|
+
}
|
|
692
|
+
const review = async (_round, priorOpen) => {
|
|
693
|
+
const rawDiff = baseCommit !== undefined ? await git.diffSince(root, baseCommit) : "";
|
|
694
|
+
const diff = clipForReview(rawDiff, "diff");
|
|
695
|
+
const clippedSpec = clipForReview(spec, "spec");
|
|
696
|
+
if (diff.clipped || clippedSpec.clipped)
|
|
697
|
+
diffClipped = true;
|
|
698
|
+
const brief = testCritiqueReviewerBrief(manifest, workstream, clippedSpec.text, diff.text, priorOpen);
|
|
699
|
+
const invocation = await invokeAgent(agentRunner, reviewer, brief, root);
|
|
700
|
+
const parsed = extractFindings(invocation.output);
|
|
701
|
+
const ran = invocation.exitCode === 0 && hasFindingsBlock(invocation.output);
|
|
702
|
+
return { findings: parsed.findings, errors: parsed.errors, ran };
|
|
703
|
+
};
|
|
704
|
+
const respond = async (_round, findings) => {
|
|
705
|
+
const brief = testCritiqueFixBrief(workstream, spec, findings);
|
|
706
|
+
const invocation = await invokeAgent(agentRunner, agent, brief, root);
|
|
707
|
+
const summary = resolveSummary(invocation.output).text;
|
|
708
|
+
const failure = await verifyAttempt(config, verifyRunner, root, invocation.exitCode);
|
|
709
|
+
if (failure === undefined) {
|
|
710
|
+
const next = await git.commitAll(root, `nightshift(${manifest.program.id}): ${workstream.id} test critique fix`);
|
|
711
|
+
if (next !== undefined)
|
|
712
|
+
greenCommit = next;
|
|
713
|
+
log(`${workstream.id}: test critique fix verified and committed`);
|
|
714
|
+
return { note: `${summary} (fix verified and committed)` };
|
|
446
715
|
}
|
|
716
|
+
await git.resetHard(root, greenCommit);
|
|
717
|
+
log(`${workstream.id}: test critique fix failed verification and was discarded — ${failure}`);
|
|
718
|
+
return { note: `${summary} (fix failed verification and was discarded; the green state was preserved)` };
|
|
719
|
+
};
|
|
720
|
+
const outcome = await runReviewPass({ review, respond });
|
|
721
|
+
return { outcome, finalCommit: greenCommit, diffClipped };
|
|
722
|
+
}
|
|
723
|
+
function renderSpecsSection(authorResult) {
|
|
724
|
+
if (authorResult.results.length === 0)
|
|
725
|
+
return [];
|
|
726
|
+
const lines = ["## Specs", ""];
|
|
727
|
+
if (authorResult.borrowedImplementer) {
|
|
728
|
+
lines.push("No `authorAgent` was configured; the implementer agent wrote these", "specs instead. A cheap implementer model tends to write sparse,", "context-free specs — configure `authorAgent` for better ones.", "");
|
|
447
729
|
}
|
|
730
|
+
const authored = authorResult.results.filter((entry) => entry.outcome.status === "authored");
|
|
731
|
+
const kept = authorResult.results.filter((entry) => entry.outcome.status === "kept");
|
|
732
|
+
const failed = authorResult.results.filter((entry) => entry.outcome.status === "failed" || entry.outcome.status === "parked");
|
|
733
|
+
if (authored.length > 0) {
|
|
734
|
+
lines.push("Authored:", "", ...authored.map((entry) => `- ${entry.id} ${entry.name}`), "");
|
|
735
|
+
}
|
|
736
|
+
if (kept.length > 0) {
|
|
737
|
+
lines.push("Kept (already existed):", "", ...kept.map((entry) => `- ${entry.id} ${entry.name}`), "");
|
|
738
|
+
}
|
|
739
|
+
if (failed.length > 0) {
|
|
740
|
+
lines.push("Failed or parked:", "", ...failed.map((entry) => {
|
|
741
|
+
const reason = entry.outcome.status === "failed" || entry.outcome.status === "parked"
|
|
742
|
+
? entry.outcome.reason
|
|
743
|
+
: "";
|
|
744
|
+
return `- ${entry.id} ${entry.name} — ${reason}`;
|
|
745
|
+
}), "");
|
|
746
|
+
}
|
|
747
|
+
for (const entry of authored) {
|
|
748
|
+
if (!entry.specCritique)
|
|
749
|
+
continue;
|
|
750
|
+
lines.push(`### ${entry.id} ${entry.name}`, "", ...renderPassReport("Spec critique", entry.specCritique), "");
|
|
751
|
+
}
|
|
752
|
+
return lines;
|
|
448
753
|
}
|
|
449
|
-
function renderRunReport(manifest, results, decisions, escalations, at) {
|
|
754
|
+
function renderRunReport(manifest, results, decisions, escalations, authorResult, at) {
|
|
450
755
|
const built = results.filter((result) => result.outcome.status === "complete" ||
|
|
451
756
|
result.outcome.status === "skipped").length;
|
|
452
757
|
const lines = [
|
|
@@ -456,6 +761,7 @@ function renderRunReport(manifest, results, decisions, escalations, at) {
|
|
|
456
761
|
"",
|
|
457
762
|
`**Outcome: ${built} of ${results.length} workstreams built.**`,
|
|
458
763
|
"",
|
|
764
|
+
...renderSpecsSection(authorResult),
|
|
459
765
|
];
|
|
460
766
|
if (escalations.length > 0) {
|
|
461
767
|
lines.push("## Needs your attention", "", "The decider reviewed these choices and believes you might decide", "differently. Each is anchored to the commit before it was made:", "");
|
|
@@ -481,6 +787,14 @@ function renderRunReport(manifest, results, decisions, escalations, at) {
|
|
|
481
787
|
if (result.summary) {
|
|
482
788
|
lines.push(` - ${result.summary.replace(/\s+/gu, " ").trim()}`);
|
|
483
789
|
}
|
|
790
|
+
if (result.testCritique) {
|
|
791
|
+
for (const line of renderPassReport("Test critique", result.testCritique)) {
|
|
792
|
+
lines.push(line === "" ? "" : ` ${line}`);
|
|
793
|
+
}
|
|
794
|
+
if (result.testCritiqueDiffClipped) {
|
|
795
|
+
lines.push(" The reviewer saw input clipped for length.");
|
|
796
|
+
}
|
|
797
|
+
}
|
|
484
798
|
}
|
|
485
799
|
lines.push("");
|
|
486
800
|
if (decisions.length > 0) {
|