@yagni-app/code 0.3.1 → 0.3.3
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/dist/cli.js +13 -0
- package/dist/crashReport.d.ts +12 -0
- package/dist/crashReport.js +28 -1
- package/dist/extension/crashReport.d.ts +18 -0
- package/dist/extension/crashReport.js +35 -2
- package/dist/extension/footer.d.ts +1 -1
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +13 -6
- package/dist/extension/index.js +57 -7
- package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
- package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
- package/dist/extension/permission/dbReadPolicy.js +227 -0
- package/dist/extension/{execPolicy.js → permission/execPolicy.js} +99 -8
- package/dist/extension/{permission.d.ts → permission/gate.d.ts} +10 -3
- package/dist/extension/{permission.js → permission/gate.js} +156 -9
- package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +2 -2
- package/dist/extension/{guardian.js → permission/guardian.js} +1 -1
- package/dist/extension/permission/index.d.ts +14 -0
- package/dist/extension/permission/index.js +14 -0
- package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
- package/dist/extension/permission/packageManagerPolicy.js +170 -0
- package/dist/extension/pipeline/activityFeed.js +19 -5
- package/dist/extension/pipeline/checker.d.ts +99 -0
- package/dist/extension/pipeline/checker.js +238 -0
- package/dist/extension/pipeline/fanout.d.ts +116 -0
- package/dist/extension/pipeline/fanout.js +248 -0
- package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
- package/dist/extension/pipeline/fanoutBeats.js +86 -0
- package/dist/extension/pipeline/goCommand.d.ts +14 -0
- package/dist/extension/pipeline/goCommand.js +38 -1
- package/dist/extension/pipeline/headlessGo.d.ts +163 -0
- package/dist/extension/pipeline/headlessGo.js +333 -0
- package/dist/extension/pipeline/invocation.d.ts +31 -3
- package/dist/extension/pipeline/invocation.js +37 -3
- package/dist/extension/pipeline/mission.d.ts +55 -0
- package/dist/extension/pipeline/mission.js +70 -0
- package/dist/extension/pipeline/orchestrator.d.ts +48 -3
- package/dist/extension/pipeline/orchestrator.js +450 -9
- package/dist/extension/pipeline/personas.d.ts +16 -1
- package/dist/extension/pipeline/personas.js +118 -7
- package/dist/extension/pipeline/runSession.d.ts +45 -1
- package/dist/extension/pipeline/runState.d.ts +57 -12
- package/dist/extension/pipeline/runState.js +60 -18
- package/dist/extension/pipeline/runner.js +10 -1
- package/dist/extension/pipeline/stages.d.ts +84 -7
- package/dist/extension/pipeline/stages.js +166 -0
- package/dist/extension/pipeline/tierCap.d.ts +32 -0
- package/dist/extension/pipeline/tierCap.js +57 -0
- package/dist/extension/pipeline/types.d.ts +130 -1
- package/dist/extension/pipeline/types.js +17 -0
- package/dist/extension/pipeline/verify.d.ts +86 -3
- package/dist/extension/pipeline/verify.js +175 -6
- package/dist/extension/subagents.js +13 -0
- package/dist/extension/turnLog.d.ts +38 -0
- package/dist/extension/turnLog.js +93 -0
- package/dist/goHeadless.d.ts +75 -0
- package/dist/goHeadless.js +132 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +12 -0
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +1 -1
- package/package.json +2 -2
- /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
- /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Sequences `runStage` for the build stages (map → plan → implement), threading
|
|
5
5
|
* each stage's `finalOutput` into the next as `{previous}` (mirrors the subagent
|
|
6
|
-
* example's chain).
|
|
6
|
+
* example's chain). In MISSION mode (`deps.mission`, from the headless entry's
|
|
7
|
+
* `--plan-file` / `--memo-file`) the build half is entered part-way: an approved
|
|
8
|
+
* plan skips map + plan and seeds implement, an injected memo stands in for the
|
|
9
|
+
* map brief, and the review→fix loop below runs exactly as it always does — see
|
|
10
|
+
* mission.ts. Then it runs the bounded review→fix LOOP: each round fans the
|
|
7
11
|
* review stage out across the 3 lenses (parallel, bounded by MAX_CONCURRENCY),
|
|
8
12
|
* parses each lens with `parseFindings` (with ONE cheap format-recovery re-ask
|
|
9
13
|
* for a healthy lens that broke the findings contract — spec §3e), unions them,
|
|
@@ -21,13 +25,16 @@
|
|
|
21
25
|
* findings — i.e. a false "reviewed and clean").
|
|
22
26
|
*/
|
|
23
27
|
import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, EMPTY_RUN_USAGE, exceedsBudget } from "./budget.js";
|
|
28
|
+
import { attributeFindings, checkerFindings, composeFixNote, composeResidue, composeSynthesizerInput, mergeFindings, parseOpenFindings, renderFindings, stripOpenFindings, } from "./checker.js";
|
|
29
|
+
import { auditClaims, parsePartition } from "./fanout.js";
|
|
24
30
|
import { extractFindingsBlock, hasBlockingFindings, parseFindings, shouldStop, unionFindings } from "./findings.js";
|
|
31
|
+
import { missionSeed, missionSkippedStages, normalizeMission } from "./mission.js";
|
|
25
32
|
import { withResilience } from "./resilience.js";
|
|
26
33
|
import { runStage as defaultRunStage } from "./runner.js";
|
|
27
|
-
import { makeRunVerify, parseChangedPaths } from "./verify.js";
|
|
28
|
-
import { REQUIRED_LENSES, REVIEW_LENSES, reaskStage, reviewStage, selectStages } from "./stages.js";
|
|
34
|
+
import { makeRunVerify, makeWorkstreamCheck, parseChangedPaths, } from "./verify.js";
|
|
35
|
+
import { builderStage, fixerStage, orchestratorStage, partitionReaskStage, PARTITION_CALLER_LABEL, REQUIRED_LENSES, REVIEW_LENSES, reaskStage, reviewStage, selectStages, synthesizerFixStage, synthesizerStage, SYNTHESIZER_CALLER_LABEL, workstreamCallerLabel, } from "./stages.js";
|
|
29
36
|
import { snapshotWorkspace as defaultSnapshotWorkspace, workspaceChanged } from "./workspace.js";
|
|
30
|
-
import { DEFAULT_RESILIENCE_POLICY, MAX_CONCURRENCY, MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
|
|
37
|
+
import { DEFAULT_RESILIENCE_POLICY, MAX_CONCURRENCY, MAX_FANOUT_CONCURRENCY, MAX_FANOUT_CONCURRENCY_ULTRA, MAX_FIX_TURNS, MIN_TOOL_CALLS_FOR_HEALTH, TOOL_ERROR_FAIL_RATE, } from "./types.js";
|
|
31
38
|
/** Error thrown when a build stage fails; carries the partial run for the caller. */
|
|
32
39
|
export class PipelineStageError extends Error {
|
|
33
40
|
stageId;
|
|
@@ -134,6 +141,35 @@ function serializeBlocking(findings) {
|
|
|
134
141
|
})
|
|
135
142
|
.join("\n");
|
|
136
143
|
}
|
|
144
|
+
/** Sum a fan's per-child usage into the one figure the fan summary reports. */
|
|
145
|
+
function sumStageUsage(results) {
|
|
146
|
+
return results.reduce((acc, r) => ({
|
|
147
|
+
input: acc.input + r.usage.input,
|
|
148
|
+
output: acc.output + r.usage.output,
|
|
149
|
+
cacheRead: acc.cacheRead + r.usage.cacheRead,
|
|
150
|
+
cacheWrite: acc.cacheWrite + r.usage.cacheWrite,
|
|
151
|
+
cost: acc.cost + r.usage.cost,
|
|
152
|
+
turns: acc.turns + r.usage.turns,
|
|
153
|
+
}), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The implement handoff a FAN produces: the recorded decision, then each
|
|
157
|
+
* workstream's own report under its name and claims, then any edit that landed
|
|
158
|
+
* outside every claim. This is what `reviewInput` becomes today; the synthesizer
|
|
159
|
+
* (spec decision 5) takes it as its input and replaces it with a reconciled
|
|
160
|
+
* summary, so the shape is deliberately one a seam-reconciler can read.
|
|
161
|
+
*/
|
|
162
|
+
function composeFanHandoff(decision, workstreams, results, violations) {
|
|
163
|
+
const parts = [`Fanned ${workstreams.length} ways: ${decision.reason}`];
|
|
164
|
+
workstreams.forEach((w, i) => {
|
|
165
|
+
const body = results[i]?.finalOutput.trim() || "(this workstream reported nothing)";
|
|
166
|
+
parts.push(`## ${w.name} (${w.tier})\nClaimed: ${w.files.join(", ")}\n\n${body}`);
|
|
167
|
+
});
|
|
168
|
+
if (violations.length > 0) {
|
|
169
|
+
parts.push(["## Out-of-claim edits", "These paths changed outside every workstream's claims:", ...violations.map((p) => `- ${p}`)].join("\n"));
|
|
170
|
+
}
|
|
171
|
+
return parts.join("\n\n");
|
|
172
|
+
}
|
|
137
173
|
/** Bounded-concurrency map preserving input order (copied from the subagent example). */
|
|
138
174
|
async function mapWithConcurrencyLimit(items, concurrency, fn) {
|
|
139
175
|
if (items.length === 0)
|
|
@@ -165,6 +201,7 @@ export async function runPipeline(ticket, deps) {
|
|
|
165
201
|
});
|
|
166
202
|
const snapshotFn = deps.snapshotWorkspace ?? defaultSnapshotWorkspace;
|
|
167
203
|
const runVerifyFn = deps.runVerify ?? makeRunVerify();
|
|
204
|
+
const runWorkstreamCheckFn = deps.runWorkstreamCheck ?? makeWorkstreamCheck();
|
|
168
205
|
const progress = (p) => deps.onProgress?.(p);
|
|
169
206
|
// Count of record_decision tool calls that completed without error across the
|
|
170
207
|
// whole run, tapped off the NDJSON stream (spec §3c: the FINISH trailer's
|
|
@@ -185,13 +222,19 @@ export async function runPipeline(ticket, deps) {
|
|
|
185
222
|
// backend unreachable) — attribution then degrades to the caller label alone,
|
|
186
223
|
// never blocking the pipeline.
|
|
187
224
|
const runId = deps.checkpointMeta?.runId;
|
|
188
|
-
|
|
225
|
+
// `callerLabel` is set only by the implement diamond's children (the
|
|
226
|
+
// partitioner's `go:implement:partition` and each builder's
|
|
227
|
+
// `go:implement:<workstream>`), so per-workstream spend attributes for free the
|
|
228
|
+
// way review lenses do. Every other stage leaves it undefined and keeps the
|
|
229
|
+
// runner's own `go:<stage>` default.
|
|
230
|
+
const stageDepsFor = (tag, callerLabel) => {
|
|
189
231
|
const onEvent = deps.onEvent;
|
|
190
232
|
return {
|
|
191
233
|
cwd: deps.cwd,
|
|
192
234
|
signal: deps.signal,
|
|
193
235
|
...(deps.childEnv ? { env: deps.childEnv } : {}),
|
|
194
236
|
...(runId ? { attribution: { runId } } : {}),
|
|
237
|
+
...(callerLabel ? { callerLabel } : {}),
|
|
195
238
|
onEvent: (ev) => {
|
|
196
239
|
if (ev.type === "tool_execution_end" && ev.toolName === "record_decision" && !ev.isError) {
|
|
197
240
|
decisionsRecorded += 1;
|
|
@@ -212,7 +255,24 @@ export async function runPipeline(ticket, deps) {
|
|
|
212
255
|
const all = deps.stages ?? selectStages("full");
|
|
213
256
|
// R3-b: aggregate usage ceiling, checked at each stage boundary.
|
|
214
257
|
const budget = deps.budget ?? DEFAULT_RUN_BUDGET;
|
|
215
|
-
|
|
258
|
+
// Mission mode (spec: sandbox harness parity). The factory produced the plan
|
|
259
|
+
// and a human approved it at the mission's plan gate, so re-running the
|
|
260
|
+
// pipeline's own map/plan would silently discard that approval and hollow out
|
|
261
|
+
// the gate: an injected plan enters the pipeline at implement instead. An
|
|
262
|
+
// injected memo is the repo brief the map stage would have written, so map is
|
|
263
|
+
// skipped whenever either input is present. All three rules are pure (see
|
|
264
|
+
// mission.ts); the orchestrator only applies them to the stage list + seed.
|
|
265
|
+
const mission = normalizeMission(deps.mission);
|
|
266
|
+
const missionSkipped = missionSkippedStages(mission);
|
|
267
|
+
const buildStages = all.filter((s) => BUILD_STAGE_IDS.includes(s.id) && !missionSkipped.includes(s.id));
|
|
268
|
+
if (mission) {
|
|
269
|
+
log("mission_mode", {
|
|
270
|
+
plan: Boolean(mission.plan),
|
|
271
|
+
memo: Boolean(mission.memo),
|
|
272
|
+
skipped: missionSkipped,
|
|
273
|
+
entry: buildStages[0]?.id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
216
276
|
// The last build stage's handoff is the working-tree diff (checked by the
|
|
217
277
|
// no_changes guard), not its prose; only the earlier stages thread text forward.
|
|
218
278
|
const finalBuildId = buildStages[buildStages.length - 1]?.id;
|
|
@@ -270,6 +330,369 @@ export async function runPipeline(ticket, deps) {
|
|
|
270
330
|
const priorUsage = resume?.priorUsage ?? EMPTY_RUN_USAGE;
|
|
271
331
|
const currentUsage = () => addRunUsage(priorUsage, aggregateRunUsage(stages, rounds));
|
|
272
332
|
const overBudget = () => exceedsBudget(currentUsage(), budget);
|
|
333
|
+
// The map stage's brief, kept for the partitioner's repo context. Absent on a
|
|
334
|
+
// mission run (map is skipped there — the injected memo stands in) and on any
|
|
335
|
+
// run whose map stage did not produce one; the partition contract is explicit
|
|
336
|
+
// that thin context means single-writer, never a guessed partition.
|
|
337
|
+
let mapBrief;
|
|
338
|
+
/**
|
|
339
|
+
* The diamond's CHECKER (spec decision 6): each workstream's own claims
|
|
340
|
+
* typechecked in their package dirs (attribution), then ONE full verify —
|
|
341
|
+
* typecheck plus the repo's own tests, `baselinePaths`-scoped as always — on the
|
|
342
|
+
* merged tree. The full suite runs on the tree that ships, not once per child.
|
|
343
|
+
*
|
|
344
|
+
* Fail-open on both halves: a seam that throws records an honest log line and
|
|
345
|
+
* contributes no findings, because the candidate is real work sitting in the
|
|
346
|
+
* working tree and the review loop's own gate still runs downstream.
|
|
347
|
+
*/
|
|
348
|
+
const runChecker = async (targets) => {
|
|
349
|
+
if (deps.signal?.aborted)
|
|
350
|
+
return { scoped: [], verify: null };
|
|
351
|
+
let scoped = [];
|
|
352
|
+
if (targets.length > 0) {
|
|
353
|
+
try {
|
|
354
|
+
scoped = await runWorkstreamCheckFn(deps.cwd, targets, deps.signal);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
log("checker_scoped_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
let verify = null;
|
|
361
|
+
try {
|
|
362
|
+
verify = await runVerifyFn(deps.cwd, deps.signal, baselinePaths);
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
log("checker_verify_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
366
|
+
}
|
|
367
|
+
log("checker_done", {
|
|
368
|
+
scoped: scoped.map((s) => ({ name: s.name, ran: s.ran, findings: s.findings.length })),
|
|
369
|
+
verify: verify ? { ran: verify.ran, ok: verify.ok, findings: verify.findings.length } : null,
|
|
370
|
+
});
|
|
371
|
+
return { scoped, verify };
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* The checker → synthesizer → bounded fix loop (spec decisions 5, 6 and 7).
|
|
375
|
+
* Returns the text that becomes `reviewInput`.
|
|
376
|
+
*
|
|
377
|
+
* On a FAN the synthesizer always runs: with one shared worktree the union diff
|
|
378
|
+
* is already in place, so its job is the seams (plus the checker's findings and
|
|
379
|
+
* any out-of-claim edits the handoff already names), and its `finalOutput` IS
|
|
380
|
+
* the implement handoff. On the SINGLE-WRITER path there are no seams, so the
|
|
381
|
+
* builder's own output stays the handoff — byte-identical to today whenever
|
|
382
|
+
* verification is clean, which is the point.
|
|
383
|
+
*
|
|
384
|
+
* Then the fix loop: while verification (or the synthesizer's own "## Open
|
|
385
|
+
* findings") still reports concrete defects, re-engage the implicated builders
|
|
386
|
+
* at their ORIGINAL tier scoped to their claims, hand unattributable findings to
|
|
387
|
+
* the synthesizer (the one builder, on the single-writer path), and re-check.
|
|
388
|
+
* Capped at MAX_FIX_TURNS; findings still open at the cap are named in the
|
|
389
|
+
* handoff so the review loop inherits the truth, never a known-broken candidate
|
|
390
|
+
* dressed as clean.
|
|
391
|
+
*/
|
|
392
|
+
const runImplementChecker = async (args) => {
|
|
393
|
+
const { stage, workstreams, handoff } = args;
|
|
394
|
+
const fanned = workstreams.length > 0;
|
|
395
|
+
const targets = workstreams.map((w) => ({ name: w.name, files: w.files }));
|
|
396
|
+
let report = await runChecker(targets);
|
|
397
|
+
let body = handoff;
|
|
398
|
+
// Defects the SYNTHESIZER named in its latest summary, kept apart from the
|
|
399
|
+
// deterministic ones: they are only re-read when the synthesizer itself ran
|
|
400
|
+
// again, so a stale "## Open findings" section can never keep the loop alive
|
|
401
|
+
// after the builders have fixed what it pointed at.
|
|
402
|
+
let summaryDefects = [];
|
|
403
|
+
if (fanned) {
|
|
404
|
+
const synth = await runStageFn({ ...synthesizerStage(), tools: stage.tools }, { ticket: promptTicket, previous: composeSynthesizerInput(handoff, report), grounded }, stageDepsFor({ stageId: stage.id }, SYNTHESIZER_CALLER_LABEL));
|
|
405
|
+
stages.push(synth);
|
|
406
|
+
if (!isFailed(synth) && synth.finalOutput.trim()) {
|
|
407
|
+
body = synth.finalOutput;
|
|
408
|
+
summaryDefects = parseOpenFindings(body);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
// An unreconciled fan is still real work in the tree: keep the fan's own
|
|
412
|
+
// handoff (every workstream named) rather than dropping to nothing.
|
|
413
|
+
log("synthesizer_degraded", { exitCode: synth.exitCode, stopReason: synth.stopReason });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
let open = mergeFindings(checkerFindings(report), summaryDefects);
|
|
417
|
+
let turn = 0;
|
|
418
|
+
while (open.length > 0 && turn < MAX_FIX_TURNS) {
|
|
419
|
+
if (deps.signal?.aborted)
|
|
420
|
+
break;
|
|
421
|
+
const over = overBudget();
|
|
422
|
+
if (over) {
|
|
423
|
+
log("fix_loop_stop", { reason: "budget_exceeded", turn: turn + 1, detail: over });
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
turn += 1;
|
|
427
|
+
const { assigned, unassigned } = attributeFindings(open, workstreams);
|
|
428
|
+
const reengaged = [];
|
|
429
|
+
// The implicated builders, each at its original tier and scoped to its own
|
|
430
|
+
// claims. Parallel under the same ceiling the fan itself ran at, because
|
|
431
|
+
// they are the same writers on the same disjoint file sets.
|
|
432
|
+
if (assigned.length > 0) {
|
|
433
|
+
const ceiling = deps.isUltra?.() ? MAX_FANOUT_CONCURRENCY_ULTRA : MAX_FANOUT_CONCURRENCY;
|
|
434
|
+
const fixes = await mapWithConcurrencyLimit(assigned, ceiling, async (a) => {
|
|
435
|
+
const siblings = workstreams.filter((s) => s.name !== a.workstream.name);
|
|
436
|
+
progress({ kind: "stage_start", stageId: stage.id, workstream: a.workstream.name });
|
|
437
|
+
const fixed = await runStageFn({ ...fixerStage(a.workstream, renderFindings(a.findings), siblings), tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name)));
|
|
438
|
+
progress({
|
|
439
|
+
kind: "stage_done",
|
|
440
|
+
stageId: stage.id,
|
|
441
|
+
workstream: a.workstream.name,
|
|
442
|
+
...(isFailed(fixed) ? { degraded: "failed" } : {}),
|
|
443
|
+
});
|
|
444
|
+
return fixed;
|
|
445
|
+
});
|
|
446
|
+
stages.push(...fixes);
|
|
447
|
+
reengaged.push(...assigned.map((a) => a.workstream.name));
|
|
448
|
+
// A fix turn that CRASHED does not fail the stage: the merged candidate is
|
|
449
|
+
// still real work, the re-check below will simply still see the findings,
|
|
450
|
+
// and the cap bounds how long that can go on. Recorded, never silent.
|
|
451
|
+
const brokenFixes = fixes.map((r, i) => (isFailed(r) ? assigned[i].workstream.name : null)).filter(Boolean);
|
|
452
|
+
if (brokenFixes.length > 0)
|
|
453
|
+
log("fix_turn_child_failed", { turn, workstreams: brokenFixes });
|
|
454
|
+
}
|
|
455
|
+
// Unattributable findings are seam work: the synthesizer's on a fan, and the
|
|
456
|
+
// one builder's on the single-writer path (where nothing is attributable by
|
|
457
|
+
// construction). Run AFTER the builders, never beside them: the synthesizer
|
|
458
|
+
// is the one child with no claims, so it must not write while they do.
|
|
459
|
+
if (unassigned.length > 0) {
|
|
460
|
+
const findingsText = renderFindings(unassigned);
|
|
461
|
+
const fixStage = fanned ? synthesizerFixStage(findingsText) : fixerStage(undefined, findingsText);
|
|
462
|
+
const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined));
|
|
463
|
+
stages.push(fixed);
|
|
464
|
+
if (fanned) {
|
|
465
|
+
if (!isFailed(fixed) && fixed.finalOutput.trim()) {
|
|
466
|
+
body = fixed.finalOutput;
|
|
467
|
+
summaryDefects = parseOpenFindings(body);
|
|
468
|
+
}
|
|
469
|
+
reengaged.push("synthesizer");
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
reengaged.push("single writer");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
// Nobody re-read the summary this turn, so its findings were answered by
|
|
477
|
+
// the builders and the deterministic re-check below is the honest verdict.
|
|
478
|
+
// The section that named them goes with them: the synthesizer does not run
|
|
479
|
+
// again on this path, so an untouched "## Open findings" would ride into
|
|
480
|
+
// the review handoff beside a fix note calling the final check clean.
|
|
481
|
+
if (summaryDefects.length > 0)
|
|
482
|
+
body = stripOpenFindings(body);
|
|
483
|
+
summaryDefects = [];
|
|
484
|
+
}
|
|
485
|
+
progress({ kind: "fix_turn", turn, findings: open.length, reengaged });
|
|
486
|
+
log("fix_turn", { turn, findings: open.length, reengaged });
|
|
487
|
+
report = await runChecker(targets);
|
|
488
|
+
open = mergeFindings(checkerFindings(report), summaryDefects);
|
|
489
|
+
}
|
|
490
|
+
if (open.length > 0) {
|
|
491
|
+
log("fix_loop_open", { turns: turn, findings: open.length });
|
|
492
|
+
return `${body}\n\n${composeResidue(open, turn)}`;
|
|
493
|
+
}
|
|
494
|
+
if (turn > 0)
|
|
495
|
+
return `${body}\n\n${composeFixNote(turn)}`;
|
|
496
|
+
return body;
|
|
497
|
+
};
|
|
498
|
+
/**
|
|
499
|
+
* The implement diamond (spec "Diamond execution"). Returns the StageResult the
|
|
500
|
+
* build loop treats as "the implement stage". The diamond owns `stages` for the
|
|
501
|
+
* whole implement boundary: every real child (partitioner, builders,
|
|
502
|
+
* synthesizer, fixers, and the single writer itself) is pushed here, and the
|
|
503
|
+
* SYNTHETIC fan summary never is — its children already carry the spend.
|
|
504
|
+
*
|
|
505
|
+
* The single-writer path deliberately runs exactly today's `runStageFn` call,
|
|
506
|
+
* with today's arguments and today's untagged deps; the checker gate that
|
|
507
|
+
* follows it changes nothing downstream unless verification actually found
|
|
508
|
+
* something.
|
|
509
|
+
*/
|
|
510
|
+
const runImplementDiamond = async (stage, previous, beforeBuild) => {
|
|
511
|
+
const pinned = deps.fanout === "always";
|
|
512
|
+
const partitionCtx = {
|
|
513
|
+
...(mapBrief?.trim() ? { repoBrief: mapBrief } : {}),
|
|
514
|
+
...(mission?.memo ? { missionMemo: mission.memo } : {}),
|
|
515
|
+
...(pinned ? { pinned: true } : {}),
|
|
516
|
+
};
|
|
517
|
+
// The LANE owns the tool whitelist: the blind eval lane strips the grounding
|
|
518
|
+
// tools from its stage list, and a diamond child rebuilt from the default
|
|
519
|
+
// stage data would quietly hand them back. So the partitioner drops
|
|
520
|
+
// `ask_yagni` when the run is blind, and every builder inherits the lane's own
|
|
521
|
+
// implement whitelist rather than PIPELINE_V1's.
|
|
522
|
+
const partitionDef = orchestratorStage(partitionCtx);
|
|
523
|
+
const partitionStage = grounded
|
|
524
|
+
? partitionDef
|
|
525
|
+
: { ...partitionDef, tools: partitionDef.tools.filter((t) => t !== "ask_yagni") };
|
|
526
|
+
const partitionTag = { stageId: stage.id };
|
|
527
|
+
const runSingleWriter = async (decision) => {
|
|
528
|
+
recordDecision(decision, beforeBuild);
|
|
529
|
+
const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
530
|
+
stages.push(written);
|
|
531
|
+
// A failed or credit-starved writer never reaches the checker: the build
|
|
532
|
+
// loop's own guard turns it into `partial` or the honest stage error, and
|
|
533
|
+
// verifying a tree nobody finished writing would only add noise.
|
|
534
|
+
if (isFailed(written) || creditExhaustionReason(written) || deps.signal?.aborted)
|
|
535
|
+
return written;
|
|
536
|
+
// Decision 7: the fix loop applies identically here — the same checker gate,
|
|
537
|
+
// the same one builder re-engaged, the same cap.
|
|
538
|
+
const text = await runImplementChecker({ stage, workstreams: [], handoff: written.finalOutput });
|
|
539
|
+
return text === written.finalOutput ? written : { ...written, finalOutput: text };
|
|
540
|
+
};
|
|
541
|
+
const partition = await runStageFn(partitionStage, { ticket: promptTicket, previous, grounded }, stageDepsFor(partitionTag, PARTITION_CALLER_LABEL));
|
|
542
|
+
stages.push(partition);
|
|
543
|
+
// A credit-starved partitioner exits 0 with an empty transcript; stop with the
|
|
544
|
+
// actionable message rather than degrading into a fan of equally starved children.
|
|
545
|
+
const partitionCredit = creditExhaustionReason(partition);
|
|
546
|
+
if (partitionCredit) {
|
|
547
|
+
log("pipeline_failed", { stageId: stage.id, reason: partitionCredit });
|
|
548
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${partitionCredit}`, stages);
|
|
549
|
+
}
|
|
550
|
+
// Overlapping claims are the HARD rejection (spec decision 3): fail here,
|
|
551
|
+
// naming the colliding paths, so no run ever discovers two writers on one file
|
|
552
|
+
// at merge time. Everything else is soft: one cheap format re-ask, then the
|
|
553
|
+
// recorded single-writer degrade.
|
|
554
|
+
let parsed = isFailed(partition) ? null : parsePartition(partition.finalOutput);
|
|
555
|
+
if (parsed && !parsed.ok && parsed.hard) {
|
|
556
|
+
log("fanout_collision", { collisions: parsed.collisions });
|
|
557
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${parsed.reason}`, stages);
|
|
558
|
+
}
|
|
559
|
+
if (parsed && !parsed.ok && partition.finalOutput.trim() && !deps.signal?.aborted) {
|
|
560
|
+
const reask = await runStageFn(partitionReaskStage(partitionStage), { ticket: promptTicket, previous: partition.finalOutput, grounded }, stageDepsFor(partitionTag, PARTITION_CALLER_LABEL));
|
|
561
|
+
stages.push(reask);
|
|
562
|
+
log("fanout_reask", { reason: parsed.reason });
|
|
563
|
+
if (!isFailed(reask) && reask.finalOutput.trim()) {
|
|
564
|
+
const second = parsePartition(reask.finalOutput);
|
|
565
|
+
if (!second.ok && second.hard) {
|
|
566
|
+
log("fanout_collision", { collisions: second.collisions });
|
|
567
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${second.reason}`, stages);
|
|
568
|
+
}
|
|
569
|
+
parsed = second;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (!parsed || !parsed.ok) {
|
|
573
|
+
const why = !parsed
|
|
574
|
+
? "the partitioner did not finish, so the plan was built by one writer"
|
|
575
|
+
: `the partition could not be read (${parsed.reason}), so the plan was built by one writer`;
|
|
576
|
+
return runSingleWriter({ mode: "single", reason: why });
|
|
577
|
+
}
|
|
578
|
+
if (parsed.decision.mode === "single")
|
|
579
|
+
return runSingleWriter(parsed.decision);
|
|
580
|
+
const workstreams = parsed.decision.workstreams ?? [];
|
|
581
|
+
// Decision 8: the budget seam is consulted once, and only for a real fan. A
|
|
582
|
+
// pinned benchmark lane skips the consult by design; an absent seam allows,
|
|
583
|
+
// and a seam that THROWS degrades to the single writer rather than crashing
|
|
584
|
+
// a run whose map and plan spend is already sunk.
|
|
585
|
+
if (!pinned && deps.fanoutBudget) {
|
|
586
|
+
let verdict;
|
|
587
|
+
try {
|
|
588
|
+
verdict = await deps.fanoutBudget();
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
verdict = { allow: false, reason: "the fan-out budget check could not be consulted" };
|
|
592
|
+
}
|
|
593
|
+
if (!verdict.allow) {
|
|
594
|
+
return runSingleWriter({
|
|
595
|
+
mode: "single",
|
|
596
|
+
reason: verdict.reason ?? "the fan-out budget check declined this run",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
recordDecision(parsed.decision, beforeBuild);
|
|
601
|
+
// Width is what the partitioner chose; the session's parallel ceiling is what
|
|
602
|
+
// actually runs at once, so a width-8 partition runs 4-at-a-time outside ultra
|
|
603
|
+
// rather than being refused.
|
|
604
|
+
const ceiling = deps.isUltra?.() ? MAX_FANOUT_CONCURRENCY_ULTRA : MAX_FANOUT_CONCURRENCY;
|
|
605
|
+
log("fanout_start", { width: workstreams.length, concurrency: Math.min(workstreams.length, ceiling) });
|
|
606
|
+
const builders = await mapWithConcurrencyLimit(workstreams, ceiling, async (w) => {
|
|
607
|
+
const siblings = workstreams.filter((s) => s.name !== w.name);
|
|
608
|
+
// Per-builder lifecycle, exactly the review fan-out's shape: each child
|
|
609
|
+
// starts and finishes on its own timing, tagged so interleaved events stay
|
|
610
|
+
// attributed, and callers that ignore the tag still see one implement pair.
|
|
611
|
+
progress({ kind: "stage_start", stageId: stage.id, workstream: w.name });
|
|
612
|
+
const built = await runStageFn({ ...builderStage(w, siblings), tools: stage.tools }, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id, workstream: w.name }, workstreamCallerLabel(w.name)));
|
|
613
|
+
// A child that died says so on its own done beat (the `degraded` flag the
|
|
614
|
+
// review lenses already use), so no surface paints a dead builder as built.
|
|
615
|
+
progress({
|
|
616
|
+
kind: "stage_done",
|
|
617
|
+
stageId: stage.id,
|
|
618
|
+
workstream: w.name,
|
|
619
|
+
...(isFailed(built) ? { degraded: "failed" } : {}),
|
|
620
|
+
});
|
|
621
|
+
return built;
|
|
622
|
+
});
|
|
623
|
+
stages.push(...builders);
|
|
624
|
+
const builderCredit = builders.map((r) => creditExhaustionReason(r)).find((v) => Boolean(v));
|
|
625
|
+
if (builderCredit) {
|
|
626
|
+
log("pipeline_failed", { stageId: stage.id, reason: builderCredit });
|
|
627
|
+
throw new PipelineStageError(stage.id, `Pipeline stopped at ${stage.id}: ${builderCredit}`, stages);
|
|
628
|
+
}
|
|
629
|
+
// Claim audit (spec decision 4): claims are prefixes, so anything the fan
|
|
630
|
+
// changed outside all of them — minus the tree's pre-run dirt — is a named
|
|
631
|
+
// violation for the synthesizer to reconcile, never a silent edit.
|
|
632
|
+
const afterFan = await snapshotFn(deps.cwd, deps.signal);
|
|
633
|
+
const baseline = new Set(baselinePaths ?? []);
|
|
634
|
+
const changedByFan = parseChangedPaths(afterFan.status).filter((p) => !baseline.has(p));
|
|
635
|
+
const violations = auditClaims(changedByFan, workstreams);
|
|
636
|
+
if (violations.length > 0)
|
|
637
|
+
log("fanout_claim_violations", { paths: violations });
|
|
638
|
+
// Failure is honest and mirrors today's write-stage semantics (decision 9):
|
|
639
|
+
// the summary carries the failure, and the build loop's existing guard turns
|
|
640
|
+
// it into `partial` when the tree changed (siblings' work preserved, the
|
|
641
|
+
// failing workstream named) or the hard PipelineStageError when it did not.
|
|
642
|
+
const failed = builders
|
|
643
|
+
.map((r, i) => ({ r, name: workstreams[i].name }))
|
|
644
|
+
.filter((x) => isFailed(x.r));
|
|
645
|
+
const failureNote = failed
|
|
646
|
+
.map((f) => `workstream "${f.name}" failed: ${toolFailureReason(f.r) ?? f.r.errorMessage ?? f.r.stderr ?? "(no output)"}`)
|
|
647
|
+
.join("; ");
|
|
648
|
+
// The fan's own handoff: the decision, every workstream's report under its
|
|
649
|
+
// name and claims, and any out-of-claim edit. On a healthy fan it is the
|
|
650
|
+
// synthesizer's INPUT (decision 5) and the reconciled summary becomes the
|
|
651
|
+
// handoff; when a builder hard-failed it stands alone, because no partial
|
|
652
|
+
// merge is ever checked, reconciled, or handed to review.
|
|
653
|
+
const handoff = composeFanHandoff(parsed.decision, workstreams, builders, violations);
|
|
654
|
+
const finalOutput = failed.length > 0 ? handoff : await runImplementChecker({ stage, workstreams, handoff });
|
|
655
|
+
const summary = {
|
|
656
|
+
stageId: stage.id,
|
|
657
|
+
agent: stage.agent,
|
|
658
|
+
exitCode: failed.length > 0 ? 1 : 0,
|
|
659
|
+
finalOutput,
|
|
660
|
+
// Summed for honesty in logs and handoffs only: this object is NEVER pushed
|
|
661
|
+
// into `stages` (its children already are), so the run's usage is counted once.
|
|
662
|
+
usage: sumStageUsage(builders),
|
|
663
|
+
stderr: builders.map((r) => r.stderr).filter(Boolean).join("\n"),
|
|
664
|
+
toolCalls: builders.reduce((n, r) => n + r.toolCalls, 0),
|
|
665
|
+
toolErrors: builders.reduce((n, r) => n + r.toolErrors, 0),
|
|
666
|
+
...(failed.length > 0 ? { stopReason: "error", errorMessage: failureNote } : {}),
|
|
667
|
+
};
|
|
668
|
+
log("fanout_done", { width: workstreams.length, failed: failed.map((f) => f.name), violations: violations.length });
|
|
669
|
+
return summary;
|
|
670
|
+
};
|
|
671
|
+
/** Emit + journal the diamond's verdict the moment it resolves (decision 2). */
|
|
672
|
+
function recordDecision(decision, snapshot) {
|
|
673
|
+
const shape = decision.workstreams?.map((w) => ({
|
|
674
|
+
name: w.name,
|
|
675
|
+
tier: w.tier,
|
|
676
|
+
files: w.files.length,
|
|
677
|
+
}));
|
|
678
|
+
progress({
|
|
679
|
+
kind: "fanout",
|
|
680
|
+
mode: decision.mode,
|
|
681
|
+
...(decision.width ? { width: decision.width } : {}),
|
|
682
|
+
reason: decision.reason,
|
|
683
|
+
...(shape ? { workstreams: shape } : {}),
|
|
684
|
+
});
|
|
685
|
+
log("fanout_decision", { mode: decision.mode, width: decision.width, reason: decision.reason });
|
|
686
|
+
writeCheckpoint(mkRecord("fanout_decision", {
|
|
687
|
+
snapshot,
|
|
688
|
+
fanout: {
|
|
689
|
+
mode: decision.mode,
|
|
690
|
+
...(decision.width ? { width: decision.width } : {}),
|
|
691
|
+
reason: decision.reason,
|
|
692
|
+
...(shape ? { workstreams: shape } : {}),
|
|
693
|
+
},
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
273
696
|
if (resume) {
|
|
274
697
|
// Resume path: the build half already ran and its diff is in the working
|
|
275
698
|
// tree (the caller's planResume verified the tree still matches the
|
|
@@ -283,7 +706,11 @@ export async function runPipeline(ticket, deps) {
|
|
|
283
706
|
}
|
|
284
707
|
}
|
|
285
708
|
else {
|
|
286
|
-
|
|
709
|
+
// Mission mode seeds the first surviving build stage with work that already
|
|
710
|
+
// happened: the approved plan (plus the memo as labelled repo context), or
|
|
711
|
+
// the memo alone when only map was skipped. Absent on every interactive run,
|
|
712
|
+
// where the first stage's handoff is undefined exactly as before.
|
|
713
|
+
let previous = missionSeed(mission);
|
|
287
714
|
// Baseline the working tree BEFORE the (read-only) recon stages so the no-op
|
|
288
715
|
// guard below can attribute any change to the build half. A git-less / non-repo
|
|
289
716
|
// cwd yields an untracked snapshot and the guard fails open (see workspace.ts).
|
|
@@ -295,8 +722,18 @@ export async function runPipeline(ticket, deps) {
|
|
|
295
722
|
for (const stage of buildStages) {
|
|
296
723
|
progress({ kind: "stage_start", stageId: stage.id });
|
|
297
724
|
stageEvent(stage.id, "start");
|
|
298
|
-
|
|
299
|
-
|
|
725
|
+
// The implement stage is a DIAMOND (partition → parallel builders →
|
|
726
|
+
// checker → synthesizer → bounded fix loop); every other stage is the
|
|
727
|
+
// single child it always was. The diamond owns `stages` for its whole
|
|
728
|
+
// boundary, so nothing is pushed for it here.
|
|
729
|
+
let r;
|
|
730
|
+
if (stage.id === "implement") {
|
|
731
|
+
r = await runImplementDiamond(stage, previous, beforeBuild);
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
r = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
|
|
735
|
+
stages.push(r);
|
|
736
|
+
}
|
|
300
737
|
log("stage_done", { stageId: stage.id, exitCode: r.exitCode });
|
|
301
738
|
// Retry a non-final build stage ONCE when it exits cleanly but hands off an
|
|
302
739
|
// incomplete fragment (length-truncated or empty). A reasoning model that
|
|
@@ -374,6 +811,10 @@ export async function runPipeline(ticket, deps) {
|
|
|
374
811
|
// The plan finish carries its finalOutput so /go can record the plan onto the
|
|
375
812
|
// work item; no other boundary streams the full stage text.
|
|
376
813
|
stageEvent(stage.id, "finish", stage.id === "plan" ? { output: r.finalOutput } : undefined);
|
|
814
|
+
// The map brief is the partitioner's repo context on an interactive run
|
|
815
|
+
// (mission runs skip map and hand it the injected memo instead).
|
|
816
|
+
if (stage.id === "map")
|
|
817
|
+
mapBrief = r.finalOutput;
|
|
377
818
|
previous = r.finalOutput;
|
|
378
819
|
// R3-b: stop honestly if the build half alone blew the run budget.
|
|
379
820
|
const over = overBudget();
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* - reviewer (business-fit lens): call `review_business_match` and treat a
|
|
10
10
|
* conflict with a recorded decision as at least High.
|
|
11
11
|
*
|
|
12
|
+
* The implement diamond adds two more roles: `orchestrator` (the read-only
|
|
13
|
+
* partitioner, carrying the ```partition output contract `parsePartition` reads)
|
|
14
|
+
* and `synthesizer` (the seam-reconciler that writes the review handoff). Its
|
|
15
|
+
* parallel builders reuse the `worker` body plus a per-workstream clause from
|
|
16
|
+
* {@link builderPersonaClause}.
|
|
17
|
+
*
|
|
12
18
|
* The reviewer also carries the strict, parseable findings output contract so
|
|
13
19
|
* the `{previous}` handoff into the fix stage parses (see `findings.ts`).
|
|
14
20
|
*
|
|
@@ -17,6 +23,7 @@
|
|
|
17
23
|
* `--append-system-prompt` temp file. Inline TS constants (no .md copy step)
|
|
18
24
|
* keep the whole thing pure and unit-testable.
|
|
19
25
|
*/
|
|
26
|
+
import type { PartitionWorkstream } from "./fanout.js";
|
|
20
27
|
import type { PipelineStage } from "./types.js";
|
|
21
28
|
/** Persona body keyed by the agent name referenced in `stages.ts`. */
|
|
22
29
|
export declare const PERSONA_BODIES: Record<string, string>;
|
|
@@ -29,10 +36,18 @@ export declare const PERSONA_BODIES: Record<string, string>;
|
|
|
29
36
|
* measurement.
|
|
30
37
|
*/
|
|
31
38
|
export declare const BLIND_PERSONA_BODIES: Record<string, string>;
|
|
39
|
+
/**
|
|
40
|
+
* The per-workstream clause appended to a fan-out builder's (or fixer's) persona:
|
|
41
|
+
* the files it owns, what its siblings own, and the shared-tree discipline. With
|
|
42
|
+
* no workstream (the single-writer path, including its fix turns) only the
|
|
43
|
+
* shared-tree rules apply, since there is nothing to pin against.
|
|
44
|
+
*/
|
|
45
|
+
export declare function builderPersonaClause(workstream?: PartitionWorkstream, siblings?: PartitionWorkstream[]): string;
|
|
32
46
|
/**
|
|
33
47
|
* Resolve the system-prompt body for a stage. For non-review stages this is the
|
|
34
48
|
* role body; for the review stage it appends the lens-specific clause and the
|
|
35
|
-
* required findings output contract so the reviewer's output parses.
|
|
49
|
+
* required findings output contract so the reviewer's output parses. A stage
|
|
50
|
+
* carrying a `personaClause` (the fan-out builders) gets it appended last.
|
|
36
51
|
*
|
|
37
52
|
* `grounded` defaults true (every real /go stage). The M6 eval passes
|
|
38
53
|
* `grounded: false` to select the grounding-free bodies + lens clauses for the
|