@renzrollon/interlock 1.0.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/run.mjs
ADDED
|
@@ -0,0 +1,2921 @@
|
|
|
1
|
+
// The run program — every step of the lean ship loop, emitted by the CLI.
|
|
2
|
+
//
|
|
3
|
+
// Interlock has two drivers of one loop: `workflows/ship.js` on the Claude Code
|
|
4
|
+
// Workflow runtime and `bin/interlock-ship-acp` over ACP. Each used to carry its
|
|
5
|
+
// own copy of the loop's control flow and of the text every agent is handed, and
|
|
6
|
+
// policy was shared only by smuggling marked blocks of one driver's source
|
|
7
|
+
// through `new Function`. Every new classifier field, banner or prompt sentence
|
|
8
|
+
// had to be added twice, and this repository's own memory records the times it
|
|
9
|
+
// was added once.
|
|
10
|
+
//
|
|
11
|
+
// So the CLI emits the whole program. A step names the agents to spawn — with
|
|
12
|
+
// their label, model, effort, agent type, tool allowlist, result schema and
|
|
13
|
+
// briefing — and the exact `interlock` argv to call once those agents return.
|
|
14
|
+
// The interpreter on either host is: spawn everything in `spawns` in parallel,
|
|
15
|
+
// write the results, call `then.argv`, repeat until `then` is null. A driver
|
|
16
|
+
// never branches on a flag, a mode, a count or a verdict (design D3).
|
|
17
|
+
//
|
|
18
|
+
// This module decides; it does not spawn, and it does not print. It reads and
|
|
19
|
+
// writes the run's own files and calls the pure state machine, the planner, the
|
|
20
|
+
// verifier and the receipt builder. The three helpers it cannot import — the
|
|
21
|
+
// trajectory loggers and the git readers that live in `bin/interlock` — arrive
|
|
22
|
+
// through `ctx.deps` rather than being written a second time here.
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { dirname, join, resolve } from 'node:path'
|
|
27
|
+
|
|
28
|
+
import { LIMITS, EFFORT } from './limits.mjs'
|
|
29
|
+
import { spawnPrefix } from './host.mjs'
|
|
30
|
+
import { PLUGIN_ROOT } from './doctor.mjs'
|
|
31
|
+
import { RED_SECTION_MARKER, inspectChange, planCoverage, tickTasks } from './artifacts.mjs'
|
|
32
|
+
import { FINGERPRINT_PATH, computeFingerprint, resolvePlanReuse, writeFingerprint } from './plan-fingerprint.mjs'
|
|
33
|
+
import { clearStage } from './ship-stage.mjs'
|
|
34
|
+
import { appendRunLogEvent, checkRunLog } from './run-log.mjs'
|
|
35
|
+
import { appendOutcome, observedFromReceipt } from './outcomes.mjs'
|
|
36
|
+
import { readPredictedPaths, readTouchedPaths } from './run-paths.mjs'
|
|
37
|
+
import {
|
|
38
|
+
buildReceipt,
|
|
39
|
+
closingFromWaveState,
|
|
40
|
+
degradationLines,
|
|
41
|
+
formatRunSummary,
|
|
42
|
+
summaryHeadline,
|
|
43
|
+
tailFromManifest
|
|
44
|
+
} from './receipt.mjs'
|
|
45
|
+
import { detectUnarchived } from './drift.mjs'
|
|
46
|
+
import { projectSlug } from './project-slug.mjs'
|
|
47
|
+
import { composeCloseMessage, postNtfy, readNotifyConfig } from './notify.mjs'
|
|
48
|
+
import { SKIP_REASONS, planVerification, judgeVerification } from './verify.mjs'
|
|
49
|
+
import { resolveReview, readReviewPolicy } from './review-core.mjs'
|
|
50
|
+
import { writeReviewMetrics } from './metrics.mjs'
|
|
51
|
+
import { planRemediation } from './remediate.mjs'
|
|
52
|
+
import { classifySurface } from './surface.mjs'
|
|
53
|
+
import { buildChecklist } from './conformance.mjs'
|
|
54
|
+
import { record as recordAutonomy } from './autonomy.mjs'
|
|
55
|
+
import {
|
|
56
|
+
applyReplan,
|
|
57
|
+
batchOutcomes,
|
|
58
|
+
createRunState,
|
|
59
|
+
laneEffort,
|
|
60
|
+
laneLabel,
|
|
61
|
+
laneModel,
|
|
62
|
+
nextStep,
|
|
63
|
+
planWaves,
|
|
64
|
+
recordBatchResult,
|
|
65
|
+
recordVerifyResult
|
|
66
|
+
} from './waves.mjs'
|
|
67
|
+
import {
|
|
68
|
+
assembleCommitPrompt,
|
|
69
|
+
assembleHandoffPrompt,
|
|
70
|
+
assembleImplementerPrompt,
|
|
71
|
+
assemblePlannerPrompt,
|
|
72
|
+
assembleRemediatePrompt,
|
|
73
|
+
assembleReplanPrompt,
|
|
74
|
+
assembleReviewPrompt,
|
|
75
|
+
assembleVerdictPrompt,
|
|
76
|
+
assembleVerifyPrompt,
|
|
77
|
+
publishStageLine,
|
|
78
|
+
selectDimensions
|
|
79
|
+
} from './prompts/index.mjs'
|
|
80
|
+
import {
|
|
81
|
+
COMMIT_SCHEMA,
|
|
82
|
+
HANDOFF_RESULT_SCHEMA,
|
|
83
|
+
LANE_SCHEMA,
|
|
84
|
+
PLANNER_SCHEMA,
|
|
85
|
+
REMEDIATE_RESULT_SCHEMA,
|
|
86
|
+
REPLAN_SCHEMA,
|
|
87
|
+
REVIEW_RESULT_SCHEMA,
|
|
88
|
+
SINGLE_TASK_SCHEMA,
|
|
89
|
+
VERDICT_RESULT_SCHEMA,
|
|
90
|
+
VERIFY_RESULT_SCHEMA
|
|
91
|
+
} from './prompts/schemas.mjs'
|
|
92
|
+
|
|
93
|
+
// --- the shapes -------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** The run manifest's schema (design D4). */
|
|
96
|
+
export const RUN_SCHEMA = 'interlock.run/1'
|
|
97
|
+
|
|
98
|
+
/** The step record's schema (design D3). */
|
|
99
|
+
export const STEP_SCHEMA = 'interlock.run-step/1'
|
|
100
|
+
|
|
101
|
+
/** Root of the per-run state tree, shared with `lib/ship-stage.mjs`. */
|
|
102
|
+
export const WORK_DIR = join('.claude', 'ship')
|
|
103
|
+
export const MANIFEST_PATH = join(WORK_DIR, 'run.json')
|
|
104
|
+
export const STATE_PATH = join(WORK_DIR, 'state.json')
|
|
105
|
+
export const BRIEFINGS_DIR = join(WORK_DIR, 'briefings')
|
|
106
|
+
export const CLASSIFIED_PATH = join(WORK_DIR, 'classified.json')
|
|
107
|
+
export const PLAN_FILE_PATH = join(WORK_DIR, 'plan.json')
|
|
108
|
+
export const REPLAN_PATH = join(WORK_DIR, 'replan.json')
|
|
109
|
+
// Where the review/remediation worker writes what it found, so `run reviewed`
|
|
110
|
+
// and `run remediated` can adjudicate it themselves rather than trust a count
|
|
111
|
+
// the agent reports about itself (design D2).
|
|
112
|
+
export const FINDINGS_PATH = join(WORK_DIR, 'findings.json')
|
|
113
|
+
export const VERDICTS_PATH = join(WORK_DIR, 'verdicts.json')
|
|
114
|
+
|
|
115
|
+
/** The first line of every briefing file: what it is, whose it is, and its hash. */
|
|
116
|
+
export const BRIEFING_HEADER = (label, sha) =>
|
|
117
|
+
`<!-- interlock briefing v1 label=${label} sha256=${sha} -->`
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The actions a step may carry: the six the state machine already emits, plus
|
|
121
|
+
* the ones the program adds around them.
|
|
122
|
+
*
|
|
123
|
+
* `review`, `remediate` and `verdict` are the strict tail, emitted by the CLI
|
|
124
|
+
* itself (`emit-strict-tail-from-cli`, design D1) rather than handed back to a
|
|
125
|
+
* host that holds loop text of its own — the seam this change closes.
|
|
126
|
+
*/
|
|
127
|
+
export const ACTIONS = Object.freeze([
|
|
128
|
+
'run-batch',
|
|
129
|
+
'test-wave',
|
|
130
|
+
'verify',
|
|
131
|
+
'replan',
|
|
132
|
+
'done',
|
|
133
|
+
'halt',
|
|
134
|
+
'classify',
|
|
135
|
+
'verify-final',
|
|
136
|
+
'review',
|
|
137
|
+
'remediate',
|
|
138
|
+
'verdict',
|
|
139
|
+
'handoff',
|
|
140
|
+
'commit',
|
|
141
|
+
'close',
|
|
142
|
+
'complete'
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
// --- the manifest -----------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a run whose host declared no capabilities is assumed to be: the Workflow
|
|
149
|
+
* host, which owns its own lane worktrees and has everything else.
|
|
150
|
+
*
|
|
151
|
+
* Stated here rather than imported from `lib/host/registry.mjs` for two
|
|
152
|
+
* reasons. The run program is the only party that reads it — an adapter
|
|
153
|
+
* declares its own capabilities and never consults this default. And the CLI
|
|
154
|
+
* must not import the adapters: they spawn vendor binaries, and the policy
|
|
155
|
+
* engine has no business loading a transport in order to answer "which wave
|
|
156
|
+
* next".
|
|
157
|
+
*/
|
|
158
|
+
export const ASSUMED_CAPABILITIES = Object.freeze({
|
|
159
|
+
schemaEnforced: true,
|
|
160
|
+
modelSelect: 'flag',
|
|
161
|
+
worktree: 'runtime',
|
|
162
|
+
hooks: true,
|
|
163
|
+
usage: true,
|
|
164
|
+
// The one thing the Workflow host does NOT have, and the reason cache
|
|
165
|
+
// accounting is declared separately from usage at all: its runtime exposes
|
|
166
|
+
// `budget.spent()`, a single cumulative scalar with no decomposition, so a run
|
|
167
|
+
// there can report what it spent and never what it re-read or re-wrote. A
|
|
168
|
+
// manifest written before this key existed reads `false` here, which is the
|
|
169
|
+
// honest answer — that run measured no cache figures either.
|
|
170
|
+
cacheAccounting: false,
|
|
171
|
+
billing: 'claude-subscription-programmatic'
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const FLAG_FIELDS = Object.freeze([
|
|
175
|
+
'applyOnly',
|
|
176
|
+
'noCommit',
|
|
177
|
+
'skipE2e',
|
|
178
|
+
'skipCoverage',
|
|
179
|
+
'isolateWaves',
|
|
180
|
+
'laneMode',
|
|
181
|
+
'tddMode',
|
|
182
|
+
'maxParallel',
|
|
183
|
+
'mode',
|
|
184
|
+
'review',
|
|
185
|
+
'handoff',
|
|
186
|
+
'conformance',
|
|
187
|
+
'strict'
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Write the run manifest. Every later `run` subcommand reads it, which is what
|
|
192
|
+
* lets a driver pass its parsed flags once and never consult them again — the
|
|
193
|
+
* flags are host-specific in their delivery and identical in their meaning
|
|
194
|
+
* (design D4).
|
|
195
|
+
*/
|
|
196
|
+
export function writeManifest(root, manifest) {
|
|
197
|
+
const path = join(root, MANIFEST_PATH)
|
|
198
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
199
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n')
|
|
200
|
+
return manifest
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read the run manifest. Returns null when there is none — a `run` subcommand
|
|
205
|
+
* reached without a `run start` — so the caller can say that rather than
|
|
206
|
+
* defaulting to a run shape nobody asked for.
|
|
207
|
+
*/
|
|
208
|
+
export function readManifest(root) {
|
|
209
|
+
try {
|
|
210
|
+
const value = JSON.parse(readFileSync(join(root, MANIFEST_PATH), 'utf8'))
|
|
211
|
+
return value && typeof value === 'object' && value.schema === RUN_SCHEMA ? value : null
|
|
212
|
+
} catch {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A fresh manifest for `run start`.
|
|
219
|
+
*
|
|
220
|
+
* `host` is an object rather than the bare id it used to be, because every later
|
|
221
|
+
* step has to read what the host CANNOT do: whether it creates its own lane
|
|
222
|
+
* worktrees, whether the plugin's guards fire in it, whether it reports token
|
|
223
|
+
* usage, which account it spends. A step that branched on the id instead would
|
|
224
|
+
* have to know every adapter, which is the coupling `lib/host/registry.mjs`
|
|
225
|
+
* exists to remove (design D1).
|
|
226
|
+
*
|
|
227
|
+
* A run that declared no capabilities is the Workflow host: it owns its own
|
|
228
|
+
* worktrees and has everything. That default is what keeps `workflows/ship.js`
|
|
229
|
+
* — which passes `--host workflow` and nothing else — byte-for-byte unchanged.
|
|
230
|
+
*/
|
|
231
|
+
export function newManifest({ change, flags, host, hostCapabilities }) {
|
|
232
|
+
const carried = {}
|
|
233
|
+
for (const field of FLAG_FIELDS) carried[field] = flags && flags[field] !== undefined ? flags[field] : null
|
|
234
|
+
const capabilities =
|
|
235
|
+
hostCapabilities && typeof hostCapabilities === 'object' && !Array.isArray(hostCapabilities)
|
|
236
|
+
? { ...ASSUMED_CAPABILITIES, ...hostCapabilities }
|
|
237
|
+
: { ...ASSUMED_CAPABILITIES }
|
|
238
|
+
return {
|
|
239
|
+
schema: RUN_SCHEMA,
|
|
240
|
+
change,
|
|
241
|
+
flags: carried,
|
|
242
|
+
host: host ? { id: host, ...capabilities } : null,
|
|
243
|
+
// Per-spawn output tokens, accumulated as results arrive (design D9). A
|
|
244
|
+
// `null` entry is a spawn whose host reported nothing, and it makes its wave
|
|
245
|
+
// — and the run — unknown rather than smaller.
|
|
246
|
+
usage: [],
|
|
247
|
+
startedAt: new Date().toISOString(),
|
|
248
|
+
steps: 0,
|
|
249
|
+
// The monotonic stage-marker write count. It lives on the manifest rather
|
|
250
|
+
// than in a module-level counter because every `run` subcommand is a fresh
|
|
251
|
+
// process, and a marker index that restarted at 1 each call would let a
|
|
252
|
+
// guard read a leftover marker as current.
|
|
253
|
+
stageIndex: 0,
|
|
254
|
+
// The argv this run last emitted as a continuation. A `run` subcommand
|
|
255
|
+
// called out of sequence is refused against it (design: the sequence guard),
|
|
256
|
+
// so a driver that skips or repeats a step is told which call was expected
|
|
257
|
+
// rather than silently advancing the state machine twice.
|
|
258
|
+
lastThen: null,
|
|
259
|
+
// The shared-tree HEAD captured when an isolated batch was dispatched, held
|
|
260
|
+
// across the driver round trip so `record-batch` folds against the base the
|
|
261
|
+
// lanes actually forked from. The emitted step carries it for the driver;
|
|
262
|
+
// this is where the CLI reads it back, because the driver returns only
|
|
263
|
+
// results and the wave state has no such field.
|
|
264
|
+
mergeBase: null,
|
|
265
|
+
// The inter-wave verification budget's own clock (`interWaveVerifyBudgetMs`).
|
|
266
|
+
// Every `run` subcommand is a fresh process running BETWEEN agent turns, so
|
|
267
|
+
// the interval a verify agent spends is only measurable across two
|
|
268
|
+
// invocations: the step records when it dispatched, the judge folds the
|
|
269
|
+
// elapsed time in and clears the mark.
|
|
270
|
+
verifyStartedAt: null,
|
|
271
|
+
verifyElapsedMs: 0,
|
|
272
|
+
// Banners the CLI raised on earlier steps, carried to the close so the
|
|
273
|
+
// summary reports everything the run degraded on rather than only what the
|
|
274
|
+
// last step happened to hold.
|
|
275
|
+
banners: [],
|
|
276
|
+
// The per-batch summary rows the receipt reports, accumulated as batches are
|
|
277
|
+
// recorded. Held here for the same reason `banners` is: the close is a fresh
|
|
278
|
+
// process and cannot recompute what earlier steps observed.
|
|
279
|
+
waves: [],
|
|
280
|
+
plan: null,
|
|
281
|
+
commit: null,
|
|
282
|
+
commitSkipped: false,
|
|
283
|
+
halted: null,
|
|
284
|
+
notes: [],
|
|
285
|
+
runId: null,
|
|
286
|
+
// The strict tail (design D2, D4, D7). `reviewDimensions` is the CLI's
|
|
287
|
+
// selection plus any the reviewer added of its own accord; `review` and
|
|
288
|
+
// `remediation` are the adjudicated counts, never the agent's self-report;
|
|
289
|
+
// `fixRoundsRun` is what the run actually consumed, not the published cap.
|
|
290
|
+
reviewDimensions: null,
|
|
291
|
+
review: null,
|
|
292
|
+
remediation: null,
|
|
293
|
+
fixRoundsRun: 0,
|
|
294
|
+
// What the handoff writer reported about the artifacts it authored — the
|
|
295
|
+
// one tail figure the CLI cannot re-derive, because it is a fact about
|
|
296
|
+
// files an agent wrote rather than a judgement with a correct answer
|
|
297
|
+
// (design D5).
|
|
298
|
+
handoff: null,
|
|
299
|
+
// The autonomy-ladder record `run close` wrote, from the CLI's own
|
|
300
|
+
// surviving-blocker count (design D6). Storage only; nothing prints a level.
|
|
301
|
+
autonomy: null
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* One declared capability of the run's host, or the Workflow host's value.
|
|
307
|
+
*
|
|
308
|
+
* Every read goes through here so a manifest written before hosts declared
|
|
309
|
+
* anything — or by a driver that passed only an id — answers as the Workflow
|
|
310
|
+
* host rather than as `undefined`, which would read as "cannot" for every
|
|
311
|
+
* boolean capability and silently degrade an old run.
|
|
312
|
+
*
|
|
313
|
+
* @param {object|null} manifest
|
|
314
|
+
* @param {string} key
|
|
315
|
+
*/
|
|
316
|
+
export function hostCapability(manifest, key) {
|
|
317
|
+
const host = manifest && manifest.host && typeof manifest.host === 'object' ? manifest.host : null
|
|
318
|
+
if (host && host[key] !== undefined) return host[key]
|
|
319
|
+
return ASSUMED_CAPABILITIES[key]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Where the host publishes the identifier of the session a process is running
|
|
324
|
+
* under. A fact about Claude Code, not an Interlock threshold, so it is a named
|
|
325
|
+
* constant in the module that reads it — the convention every `INTERLOCK_*`
|
|
326
|
+
* reader already follows — and it appears in no driver: the no-policy sweep has
|
|
327
|
+
* no allowance, and D11 puts the acquisition in the CLI for exactly that reason.
|
|
328
|
+
*
|
|
329
|
+
* VERIFIED 2026-09-10, which D11 required before this could be recorded at all.
|
|
330
|
+
* The hazard was that on the Workflow host `interlock run start` executes inside
|
|
331
|
+
* a ping subagent, so the value in scope might be the subagent's rather than the
|
|
332
|
+
* parent session's — and a wrong identifier is worse than none, because it joins
|
|
333
|
+
* the trajectory to a transcript that is not the run's. Measured: a subagent's
|
|
334
|
+
* shell reports the SAME value as its parent conversation, and that value names
|
|
335
|
+
* the parent's transcript file (`~/.claude/projects/<slug>/<id>.jsonl`).
|
|
336
|
+
* Subagents inherit the session; they do not get one of their own. So the value
|
|
337
|
+
* observed on the Workflow host is the parent session's and is recordable.
|
|
338
|
+
*
|
|
339
|
+
* `CLAUDE_CODE_HOST_SESSION_ID` is a different identifier (`local_…`) that names
|
|
340
|
+
* no transcript, and is deliberately not what is read here.
|
|
341
|
+
*/
|
|
342
|
+
export const HOST_SESSION_ID_ENV = 'CLAUDE_CODE_SESSION_ID'
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The Workflow host's id, restated here rather than imported from
|
|
346
|
+
* `lib/host/registry.mjs` for the reason `ASSUMED_CAPABILITIES` is: importing
|
|
347
|
+
* from that module would load every vendor adapter, and the policy engine has no
|
|
348
|
+
* business loading a transport.
|
|
349
|
+
*/
|
|
350
|
+
const WORKFLOW_HOST = 'workflow'
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The host session this run executes under, or null.
|
|
354
|
+
*
|
|
355
|
+
* Read from the CLI's own environment — never carried or passed by a driver
|
|
356
|
+
* (D11): `workflows/ship.js` has no shell or filesystem, and `bin/interlock-run`
|
|
357
|
+
* is a plain Node process with no session at all.
|
|
358
|
+
*
|
|
359
|
+
* Recorded only on the host whose steps run INSIDE that session. On the runner
|
|
360
|
+
* the agents are separate vendor processes with sessions of their own, so a
|
|
361
|
+
* variable inherited from whatever shell launched `interlock-run` names a
|
|
362
|
+
* transcript that does not contain the run — the same wrong-identifier failure
|
|
363
|
+
* D11 guards against, arriving from the other direction. That is why this reads
|
|
364
|
+
* the host rather than the environment alone.
|
|
365
|
+
*/
|
|
366
|
+
export function hostSessionId(manifest, env = process.env) {
|
|
367
|
+
if (hostId(manifest) !== WORKFLOW_HOST) return null
|
|
368
|
+
const raw = env && typeof env[HOST_SESSION_ID_ENV] === 'string' ? env[HOST_SESSION_ID_ENV].trim() : ''
|
|
369
|
+
return raw || null
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The run's host id, or null for a run that named none. */
|
|
373
|
+
export function hostId(manifest) {
|
|
374
|
+
const host = manifest && manifest.host
|
|
375
|
+
if (host && typeof host === 'object' && typeof host.id === 'string') return host.id
|
|
376
|
+
return typeof host === 'string' && host ? host : null
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- the step budget (design D9) --------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Count this call against the run's step budget.
|
|
383
|
+
*
|
|
384
|
+
* Returns a `halt` step once the published cap is exceeded and null otherwise.
|
|
385
|
+
* The cap is `LIMITS.maxRunSteps`, read here rather than restated: it was a
|
|
386
|
+
* literal in both drivers, which is exactly what `openspec/specs/ship/
|
|
387
|
+
* cap-authority` forbids for a loop bound.
|
|
388
|
+
*/
|
|
389
|
+
export function countStep(root, manifest) {
|
|
390
|
+
manifest.steps = (Number.isInteger(manifest.steps) ? manifest.steps : 0) + 1
|
|
391
|
+
if (manifest.steps > LIMITS.maxRunSteps) {
|
|
392
|
+
manifest.halted = `the run exceeded the published run-step cap (maxRunSteps=${LIMITS.maxRunSteps}) without reaching a terminal state`
|
|
393
|
+
writeManifest(root, manifest)
|
|
394
|
+
return haltStep(manifest.halted)
|
|
395
|
+
}
|
|
396
|
+
return null
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- usage (design D9) ------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Fold one step's spawn results into the run's token accounting.
|
|
403
|
+
*
|
|
404
|
+
* Every results-taking `run` subcommand calls this, because a run's spend is the
|
|
405
|
+
* sum over every spawn it made — the review fan-out and the verification agent
|
|
406
|
+
* cost tokens exactly as a wave does, and a total that counted only waves would
|
|
407
|
+
* understate every strict run.
|
|
408
|
+
*
|
|
409
|
+
* UNKNOWN IS NOT ZERO, and it is contagious by design: one spawn whose host
|
|
410
|
+
* reported nothing makes its whole group unknown, because the alternative is a
|
|
411
|
+
* number that looks like a measurement and is actually a lower bound. A host
|
|
412
|
+
* that never reports usage therefore records `null` throughout, which is what
|
|
413
|
+
* the trajectory already means by "no token accounting".
|
|
414
|
+
*
|
|
415
|
+
* @param {object} manifest
|
|
416
|
+
* @param {Array<object|null>} results
|
|
417
|
+
* @param {number|null} [wave] the wave these spawns belonged to, or null for a
|
|
418
|
+
* step outside the waves (verification, review, remediation, the commit)
|
|
419
|
+
*/
|
|
420
|
+
export function recordUsage(manifest, results, wave = null) {
|
|
421
|
+
const list = Array.isArray(results) ? results : []
|
|
422
|
+
if (!list.length) return
|
|
423
|
+
if (!Array.isArray(manifest.usage)) manifest.usage = []
|
|
424
|
+
|
|
425
|
+
let outputTokens = 0
|
|
426
|
+
let known = true
|
|
427
|
+
const usages = []
|
|
428
|
+
for (const result of list) {
|
|
429
|
+
const usage = result && typeof result === 'object' ? result.usage : null
|
|
430
|
+
const out = usage && typeof usage === 'object' && Number.isFinite(usage.outputTokens)
|
|
431
|
+
? usage.outputTokens
|
|
432
|
+
: null
|
|
433
|
+
if (out === null) known = false
|
|
434
|
+
else outputTokens += out
|
|
435
|
+
usages.push(usage && typeof usage === 'object' ? usage : null)
|
|
436
|
+
}
|
|
437
|
+
manifest.usage.push({
|
|
438
|
+
wave: Number.isInteger(wave) ? wave : null,
|
|
439
|
+
outputTokens: known ? outputTokens : null,
|
|
440
|
+
// The input side, under exactly the rule the output side already holds. A
|
|
441
|
+
// host with no cache accounting supplies neither field on any spawn, so both
|
|
442
|
+
// of these come out `null` — unknown, which is a different fact from a wave
|
|
443
|
+
// that measured and found no cache activity.
|
|
444
|
+
...foldCacheUsage(usages)
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Sum one step's cache figures across its spawns, unknown-contagious.
|
|
450
|
+
*
|
|
451
|
+
* The tiers are NOT enumerated here. They are whatever the host reported them
|
|
452
|
+
* under, unioned across the step's spawns, so a run records the decomposition
|
|
453
|
+
* its host actually supplies rather than a list this module guessed — and so a
|
|
454
|
+
* tier is never invented for a host that does not have one. Restating the
|
|
455
|
+
* adapter's field names would also mean importing an adapter, which the policy
|
|
456
|
+
* engine does not do.
|
|
457
|
+
*
|
|
458
|
+
* A tier one spawn reported and another omitted is `null` for the group: the
|
|
459
|
+
* second spawn's figure was not measured, and adding only the first would
|
|
460
|
+
* produce a lower bound that reads as a total. Same rule, one type down, as the
|
|
461
|
+
* output-token fold above.
|
|
462
|
+
*/
|
|
463
|
+
function foldCacheUsage(usages) {
|
|
464
|
+
const list = usages.filter(u => u && typeof u === 'object')
|
|
465
|
+
if (!list.length) return { cacheReadInputTokens: null, cacheCreationInputTokens: null }
|
|
466
|
+
|
|
467
|
+
const read = list.reduce(
|
|
468
|
+
(sum, usage) =>
|
|
469
|
+
sum === null || !Number.isFinite(usage.cacheReadInputTokens) ? null : sum + usage.cacheReadInputTokens,
|
|
470
|
+
0
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
const tiers = new Set()
|
|
474
|
+
for (const usage of list) {
|
|
475
|
+
const creation = usage.cacheCreationInputTokens
|
|
476
|
+
if (creation && typeof creation === 'object' && !Array.isArray(creation)) {
|
|
477
|
+
for (const tier of Object.keys(creation)) tiers.add(tier)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
let creation = null
|
|
481
|
+
if (tiers.size) {
|
|
482
|
+
creation = {}
|
|
483
|
+
for (const tier of tiers) {
|
|
484
|
+
creation[tier] = list.reduce((sum, usage) => {
|
|
485
|
+
const block = usage.cacheCreationInputTokens
|
|
486
|
+
const value = block && typeof block === 'object' ? block[tier] : undefined
|
|
487
|
+
return sum === null || !Number.isFinite(value) ? null : sum + value
|
|
488
|
+
}, 0)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return { cacheReadInputTokens: read, cacheCreationInputTokens: creation }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Add one recorded entry's cache figures into a running group total, holding the
|
|
496
|
+
* unknown-contagious rule across entries the way `summarizeUsage` does for
|
|
497
|
+
* output tokens. `null` in, `null` out, permanently.
|
|
498
|
+
*/
|
|
499
|
+
function addCacheEntry(total, entry) {
|
|
500
|
+
const read =
|
|
501
|
+
total.cacheReadInputTokens === null || !Number.isFinite(entry.cacheReadInputTokens)
|
|
502
|
+
? null
|
|
503
|
+
: total.cacheReadInputTokens + entry.cacheReadInputTokens
|
|
504
|
+
|
|
505
|
+
const incoming =
|
|
506
|
+
entry.cacheCreationInputTokens && typeof entry.cacheCreationInputTokens === 'object'
|
|
507
|
+
? entry.cacheCreationInputTokens
|
|
508
|
+
: null
|
|
509
|
+
let creation = total.cacheCreationInputTokens
|
|
510
|
+
if (creation === null && incoming === null) {
|
|
511
|
+
// Nothing reported on either side; stays absent.
|
|
512
|
+
} else if (incoming === null) {
|
|
513
|
+
// A contributing entry reported no decomposition at all, so every tier the
|
|
514
|
+
// group has so far becomes unknown rather than being carried as a total.
|
|
515
|
+
creation = Object.fromEntries(Object.keys(creation).map(tier => [tier, null]))
|
|
516
|
+
} else if (creation === null) {
|
|
517
|
+
creation = { ...incoming }
|
|
518
|
+
} else {
|
|
519
|
+
const merged = {}
|
|
520
|
+
for (const tier of new Set([...Object.keys(creation), ...Object.keys(incoming)])) {
|
|
521
|
+
const a = creation[tier]
|
|
522
|
+
const b = incoming[tier]
|
|
523
|
+
merged[tier] = Number.isFinite(a) && Number.isFinite(b) ? a + b : null
|
|
524
|
+
}
|
|
525
|
+
creation = merged
|
|
526
|
+
}
|
|
527
|
+
return { cacheReadInputTokens: read, cacheCreationInputTokens: creation }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const EMPTY_CACHE_TOTAL = Object.freeze({ cacheReadInputTokens: 0, cacheCreationInputTokens: null })
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The receipt's `spend` rows and run total, summed from what was recorded.
|
|
534
|
+
*
|
|
535
|
+
* Per wave and per run, with `null` for either wherever a single contributing
|
|
536
|
+
* spawn went unmeasured. A wave that appears in several batches sums across
|
|
537
|
+
* them; a run with no entries at all reports nothing rather than zero.
|
|
538
|
+
*
|
|
539
|
+
* @param {object} manifest
|
|
540
|
+
* @returns {{spend: Array<object>, outputTokens: number|null|undefined,
|
|
541
|
+
* cacheReadInputTokens: number|null|undefined,
|
|
542
|
+
* cacheCreationInputTokens: object|null|undefined}}
|
|
543
|
+
*/
|
|
544
|
+
export function summarizeUsage(manifest) {
|
|
545
|
+
const entries = Array.isArray(manifest && manifest.usage) ? manifest.usage : []
|
|
546
|
+
if (!entries.length) {
|
|
547
|
+
return {
|
|
548
|
+
spend: [],
|
|
549
|
+
outputTokens: undefined,
|
|
550
|
+
cacheReadInputTokens: undefined,
|
|
551
|
+
cacheCreationInputTokens: undefined
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const byWave = new Map()
|
|
556
|
+
for (const entry of entries) {
|
|
557
|
+
if (!Number.isInteger(entry && entry.wave)) continue
|
|
558
|
+
const current = byWave.has(entry.wave)
|
|
559
|
+
? byWave.get(entry.wave)
|
|
560
|
+
: { outputTokens: 0, ...EMPTY_CACHE_TOTAL }
|
|
561
|
+
byWave.set(entry.wave, {
|
|
562
|
+
outputTokens:
|
|
563
|
+
current.outputTokens === null || entry.outputTokens === null
|
|
564
|
+
? null
|
|
565
|
+
: current.outputTokens + entry.outputTokens,
|
|
566
|
+
...addCacheEntry(current, entry)
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const runTotal = entries.reduce(
|
|
571
|
+
(sum, entry) => (sum === null || entry.outputTokens === null ? null : sum + entry.outputTokens),
|
|
572
|
+
0
|
|
573
|
+
)
|
|
574
|
+
const runCache = entries.reduce((total, entry) => addCacheEntry(total, entry), { ...EMPTY_CACHE_TOTAL })
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
spend: [...byWave.entries()]
|
|
578
|
+
.sort((a, b) => a[0] - b[0])
|
|
579
|
+
.map(([wave, totals]) => ({ wave, ...totals })),
|
|
580
|
+
outputTokens: runTotal,
|
|
581
|
+
...runCache
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- briefings (design D2) --------------------------------------------------
|
|
586
|
+
|
|
587
|
+
// --- lane worktrees (design D7) ---------------------------------------------
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Where a driver-owned lane worktree goes.
|
|
591
|
+
*
|
|
592
|
+
* Under `.claude/ship/`, which is git-ignored, so a lane's checkout is invisible
|
|
593
|
+
* to the `git status` sweep that reads what the batch actually changed — a
|
|
594
|
+
* worktree that showed up there would report every file in the repository as
|
|
595
|
+
* touched by the wave that created it.
|
|
596
|
+
*/
|
|
597
|
+
export const LANE_WORKTREES_DIR = join(WORK_DIR, 'worktrees')
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The path one lane's worktree takes, derived rather than reported.
|
|
601
|
+
*
|
|
602
|
+
* On the Workflow host the runtime picks the directory and the lane reports its
|
|
603
|
+
* own `pwd`, because an orchestrator predicting a path the runtime did not use
|
|
604
|
+
* is a fold that silently finds nothing. On a runner host the DRIVER creates the
|
|
605
|
+
* worktree, so the prediction and the reality are the same act — and deriving it
|
|
606
|
+
* here means the step that names it and the fold that removes it read one
|
|
607
|
+
* function rather than two conventions.
|
|
608
|
+
*
|
|
609
|
+
* The label is sanitised, not hashed: a directory named after its lane is one a
|
|
610
|
+
* halted run's operator can find, and lane labels are already CLI-safe ids.
|
|
611
|
+
*
|
|
612
|
+
* @param {number|string} wave
|
|
613
|
+
* @param {string} label
|
|
614
|
+
* @returns {string} repo-relative
|
|
615
|
+
*/
|
|
616
|
+
export function laneWorktreePath(wave, label) {
|
|
617
|
+
const safe = String(label || 'lane').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'lane'
|
|
618
|
+
return join(LANE_WORKTREES_DIR, `wave-${wave ?? 'x'}`, safe)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** The hash a host reports back to prove it read the briefing it was handed. */
|
|
622
|
+
export function briefingHash(text) {
|
|
623
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Write one briefing and return what a spawn carries: its path, its hash and
|
|
628
|
+
* its text.
|
|
629
|
+
*
|
|
630
|
+
* The Workflow host cannot hold a briefing — it has no filesystem, and asking a
|
|
631
|
+
* ping to copy several kilobytes into a result field would breach the published
|
|
632
|
+
* result-field cap — so it spawns the worker with a fixed bootstrap naming this
|
|
633
|
+
* path and requires the hash back. The ACP host reads `prompt` and sends it
|
|
634
|
+
* inline. One file serves both.
|
|
635
|
+
*/
|
|
636
|
+
export function writeBriefing(root, label, text) {
|
|
637
|
+
const promptSha256 = briefingHash(text)
|
|
638
|
+
const promptPath = join(BRIEFINGS_DIR, `${label}.md`)
|
|
639
|
+
const dest = join(root, promptPath)
|
|
640
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
641
|
+
writeFileSync(dest, `${BRIEFING_HEADER(label, promptSha256)}\n${text}`)
|
|
642
|
+
return { promptPath, promptSha256, prompt: text }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- step records (design D3) -----------------------------------------------
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The one thing every briefing says that is not about the work: there is nobody
|
|
649
|
+
* to ask. Both drivers appended it to every prompt they sent, in the same words;
|
|
650
|
+
* it is stated here so neither has to.
|
|
651
|
+
*/
|
|
652
|
+
const AUTONOMY_LINE =
|
|
653
|
+
'\n\nYou are one step of an automated ship run. Do not ask questions — there is ' +
|
|
654
|
+
'no one listening. If something is undecidable, put it in the result fields rather than ' +
|
|
655
|
+
'guessing at product intent.'
|
|
656
|
+
|
|
657
|
+
/** One agent a step asks for. */
|
|
658
|
+
function spawn(root, { label, kind, model, effort, schema, prompt, isolation, worktree }) {
|
|
659
|
+
return {
|
|
660
|
+
label,
|
|
661
|
+
kind,
|
|
662
|
+
model: model || null,
|
|
663
|
+
effort: effort === undefined ? null : effort,
|
|
664
|
+
...spawnPrefix(kind === 'ping' ? 'ping' : 'worker'),
|
|
665
|
+
schema,
|
|
666
|
+
isolation: isolation || null,
|
|
667
|
+
// Only meaningful under isolation, and null on the Workflow host even then:
|
|
668
|
+
// there the lane reports its own `pwd`, because the orchestrator predicting
|
|
669
|
+
// a worktree path and the runtime choosing a different one is a fold that
|
|
670
|
+
// silently finds nothing. On a runner host the driver creates the directory
|
|
671
|
+
// this names, so predicting it and using it are one act (design D7).
|
|
672
|
+
worktree: worktree || null,
|
|
673
|
+
...writeBriefing(root, label, prompt + AUTONOMY_LINE)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Assemble a step record. Every step the program emits goes through here. */
|
|
678
|
+
export function makeStep(fields) {
|
|
679
|
+
const { action, then = null, spawns = [], ...rest } = fields
|
|
680
|
+
if (!ACTIONS.includes(action)) {
|
|
681
|
+
throw new Error(`unknown run action "${action}" — expected one of ${ACTIONS.join(', ')}`)
|
|
682
|
+
}
|
|
683
|
+
return { schema: STEP_SCHEMA, action, then, spawns, ...rest }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The terminal step of a run that cannot continue. */
|
|
687
|
+
export function haltStep(reason, extra = {}) {
|
|
688
|
+
return makeStep({
|
|
689
|
+
action: 'halt',
|
|
690
|
+
reason,
|
|
691
|
+
// A halt still closes: the receipt, the outcome record and the trajectory's
|
|
692
|
+
// terminal event are what a halted run is FOR. `then` is the close, not
|
|
693
|
+
// null — a run that stopped without recording why is the one record this
|
|
694
|
+
// corpus most needs and least often has.
|
|
695
|
+
then: { argv: ['run', 'close', '--halt', reason] },
|
|
696
|
+
...extra
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// --- the wave-state fields a step carries through ---------------------------
|
|
701
|
+
|
|
702
|
+
const PASS_THROUGH = Object.freeze([
|
|
703
|
+
'wave',
|
|
704
|
+
'waveIndex',
|
|
705
|
+
'waveKind',
|
|
706
|
+
'batchIndex',
|
|
707
|
+
'batchCount',
|
|
708
|
+
'remainingBatches',
|
|
709
|
+
'previousHandoffs',
|
|
710
|
+
'changed',
|
|
711
|
+
'mode',
|
|
712
|
+
'runMode',
|
|
713
|
+
'reason',
|
|
714
|
+
'fixAttempt',
|
|
715
|
+
'fixAttemptsRemaining',
|
|
716
|
+
'errors',
|
|
717
|
+
'nextWave',
|
|
718
|
+
'nextWaveKind',
|
|
719
|
+
'revisableGroups',
|
|
720
|
+
'replansUsed',
|
|
721
|
+
'replansRemaining',
|
|
722
|
+
'maxParallel',
|
|
723
|
+
'summary',
|
|
724
|
+
'kind',
|
|
725
|
+
'failures',
|
|
726
|
+
'unresolved'
|
|
727
|
+
])
|
|
728
|
+
|
|
729
|
+
function passThrough(state) {
|
|
730
|
+
const out = {}
|
|
731
|
+
for (const field of PASS_THROUGH) {
|
|
732
|
+
if (state[field] !== undefined) out[field] = state[field]
|
|
733
|
+
}
|
|
734
|
+
return out
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// --- decoration: a wave-state step becomes a run step -----------------------
|
|
738
|
+
|
|
739
|
+
/** Every lane in the batch a step dispatches, as the wave-state emitted them. */
|
|
740
|
+
function lanesOf(step) {
|
|
741
|
+
const batches =
|
|
742
|
+
Array.isArray(step.remainingBatches) && step.remainingBatches.length
|
|
743
|
+
? step.remainingBatches
|
|
744
|
+
: [Array.isArray(step.tasks) ? step.tasks : []]
|
|
745
|
+
return Array.isArray(batches[0]) ? batches[0] : []
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Turn the pure state machine's step into the run step a driver obeys.
|
|
750
|
+
*
|
|
751
|
+
* This is where every branch the loop used to take in two drivers is taken
|
|
752
|
+
* once: apply-only, no-commit, isolate-waves, a tail flag, a skipped verify, a
|
|
753
|
+
* replan. The driver sees the result, never the question.
|
|
754
|
+
*
|
|
755
|
+
* @param {object} ctx { root, deps }
|
|
756
|
+
* @param {object} step the wave-state step (`nextStep(state)`)
|
|
757
|
+
* @param {object} manifest
|
|
758
|
+
* @param {object} [extra] fields to carry onto the emitted step (banners, notes)
|
|
759
|
+
*/
|
|
760
|
+
export function decorate(ctx, step, manifest, extra = {}) {
|
|
761
|
+
const { root } = ctx
|
|
762
|
+
const carried = { ...passThrough(step), ...extra }
|
|
763
|
+
const change = manifest.change
|
|
764
|
+
|
|
765
|
+
if (step.action === 'halt') {
|
|
766
|
+
return haltStep(step.reason || 'the run state halted', carried)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (step.action === 'run-batch' || step.action === 'test-wave') {
|
|
770
|
+
const lanes = lanesOf(step)
|
|
771
|
+
if (!lanes.length || lanes.some(lane => !Array.isArray(lane) || !lane.length)) {
|
|
772
|
+
return haltStep('the state machine asked for a batch with no dispatchable lane', carried)
|
|
773
|
+
}
|
|
774
|
+
const isolate = manifest.flags.isolateWaves === true
|
|
775
|
+
// Who creates the lane's worktree (design D1, D7). The Workflow runtime does
|
|
776
|
+
// it from an `isolation` field on the spawn; a runner host has no runtime to
|
|
777
|
+
// ask, so the step names the directory and the driver runs `git worktree
|
|
778
|
+
// add` itself. Both fold through the same `run record-batch`.
|
|
779
|
+
const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
|
|
780
|
+
const previousHandoffs = Array.isArray(step.previousHandoffs) ? step.previousHandoffs : []
|
|
781
|
+
// The stage line rides on the batch's briefings rather than on a step of its
|
|
782
|
+
// own: the agent doing the work is the one with a filesystem, and a marker
|
|
783
|
+
// written by anyone else would carry the wrong pid.
|
|
784
|
+
const stage = publishStageLine('implement', change, ++manifest.stageIndex)
|
|
785
|
+
const spawns = lanes.map(lane =>
|
|
786
|
+
spawn(root, {
|
|
787
|
+
label: laneLabel(lane),
|
|
788
|
+
kind: 'implementer',
|
|
789
|
+
model: laneModel(lane),
|
|
790
|
+
effort: laneEffort(lane),
|
|
791
|
+
schema: lane.length === 1 ? SINGLE_TASK_SCHEMA : LANE_SCHEMA,
|
|
792
|
+
isolation: isolate && !driverWorktrees ? 'worktree' : null,
|
|
793
|
+
worktree: driverWorktrees ? { path: laneWorktreePath(step.wave, laneLabel(lane)) } : null,
|
|
794
|
+
// `solo` comes off the STEP, not off the invocation flag: the planner
|
|
795
|
+
// decides the mode (a classifier recommendation inside the envelope
|
|
796
|
+
// reaches solo with no flag at all), the run state carries it, and
|
|
797
|
+
// `nextStep` echoes it. Reading the flag here would brief the agent for
|
|
798
|
+
// a shape the planner may not have built.
|
|
799
|
+
prompt:
|
|
800
|
+
stage +
|
|
801
|
+
'\n' +
|
|
802
|
+
assembleImplementerPrompt({
|
|
803
|
+
change,
|
|
804
|
+
lane,
|
|
805
|
+
previousHandoffs,
|
|
806
|
+
isolateWaves: isolate,
|
|
807
|
+
solo: step.mode === 'solo'
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
)
|
|
811
|
+
// The shared tree's HEAD before this batch's lanes fork off — read now, not
|
|
812
|
+
// after, so nothing else lands on the shared tree between the reading and
|
|
813
|
+
// the fold. Only under isolation; unset, a batch runs byte-for-byte as it
|
|
814
|
+
// did before worktree isolation existed.
|
|
815
|
+
let mergeBase = null
|
|
816
|
+
if (isolate) {
|
|
817
|
+
mergeBase = ctx.deps.headCommit(root)
|
|
818
|
+
if (!mergeBase) {
|
|
819
|
+
return haltStep(
|
|
820
|
+
'could not capture the shared-tree base commit before an isolated batch — merge-lanes ' +
|
|
821
|
+
'cannot fold lane worktrees back without one',
|
|
822
|
+
carried
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
// Onto the manifest as well as onto the step. The step goes to the driver
|
|
826
|
+
// and the driver returns only results; `record-batch` reads a pure
|
|
827
|
+
// wave-state step that carries no such field, so without this the fold
|
|
828
|
+
// would re-read HEAD *after* the lanes ran — a different commit whenever
|
|
829
|
+
// anything else landed on the shared tree in between.
|
|
830
|
+
manifest.mergeBase = mergeBase
|
|
831
|
+
}
|
|
832
|
+
return makeStep({
|
|
833
|
+
action: step.action,
|
|
834
|
+
...carried,
|
|
835
|
+
lanes: lanes.map(lane => lane.map(t => ({ id: t.id }))),
|
|
836
|
+
mergeBase,
|
|
837
|
+
then: { argv: ['run', 'record-batch'] },
|
|
838
|
+
spawns
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (step.action === 'verify') {
|
|
843
|
+
return verifyStep(ctx, manifest, {
|
|
844
|
+
context: 'inter-wave',
|
|
845
|
+
label: `inter-wave-verify-${manifest.steps}`,
|
|
846
|
+
changed: Array.isArray(step.changed) ? step.changed : [],
|
|
847
|
+
carried
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (step.action === 'replan') {
|
|
852
|
+
return makeStep({
|
|
853
|
+
action: 'replan',
|
|
854
|
+
...carried,
|
|
855
|
+
then: { argv: ['run', 'replan'] },
|
|
856
|
+
spawns: [
|
|
857
|
+
spawn(root, {
|
|
858
|
+
label: `replan-${manifest.steps}`,
|
|
859
|
+
kind: 'ping',
|
|
860
|
+
model: 'haiku',
|
|
861
|
+
effort: null,
|
|
862
|
+
schema: REPLAN_SCHEMA,
|
|
863
|
+
prompt: assembleReplanPrompt({ change, replanPath: REPLAN_PATH })
|
|
864
|
+
})
|
|
865
|
+
]
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (step.action === 'done') {
|
|
870
|
+
// Three ways a run of waves ends, decided here rather than in a driver.
|
|
871
|
+
if (manifest.flags.applyOnly === true) {
|
|
872
|
+
manifest.notes.push('--apply-only: stopped after the waves')
|
|
873
|
+
manifest.commitSkipped = true
|
|
874
|
+
return makeStep({ action: 'close', ...carried, then: { argv: ['run', 'close'] } })
|
|
875
|
+
}
|
|
876
|
+
// The strict tail (design D1): `--review` (or `--strict`, which implies
|
|
877
|
+
// it) fans out the adversarial review here, in the CLI. A run carrying
|
|
878
|
+
// only `--handoff` and/or `--conformance` has nothing to review, so it
|
|
879
|
+
// walks straight to final verification exactly as a lean run does — the
|
|
880
|
+
// handoff step is decided after that verdict passes (design D5), not here.
|
|
881
|
+
if (manifest.flags.review === true) {
|
|
882
|
+
return reviewStep(ctx, manifest, carried)
|
|
883
|
+
}
|
|
884
|
+
return finalVerifyStep(ctx, manifest, carried)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return haltStep(`unrecognized step from the state machine: ${step.action}`, carried)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The final verification step: the same verify spawn, judged in the `final` context. */
|
|
891
|
+
export function finalVerifyStep(ctx, manifest, carried = {}) {
|
|
892
|
+
return verifyStep(ctx, manifest, {
|
|
893
|
+
context: 'final',
|
|
894
|
+
label: 'verify',
|
|
895
|
+
changed: [],
|
|
896
|
+
carried,
|
|
897
|
+
action: 'verify-final'
|
|
898
|
+
})
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* A verification step — a spawn that runs the planned checks, or a skipped
|
|
903
|
+
* marker carrying the planner's own reason.
|
|
904
|
+
*
|
|
905
|
+
* The plan is built HERE and the verdict is rendered by `run judge`. The agent
|
|
906
|
+
* between them runs commands and reports what happened; it is never asked
|
|
907
|
+
* whether the run may continue. A plan with no steps emits no spawn at all: the
|
|
908
|
+
* step still names its continuation, so the interpreter spawns nothing, calls
|
|
909
|
+
* the argv and the skip is recorded by the CLI that decided it.
|
|
910
|
+
*/
|
|
911
|
+
function verifyStep(ctx, manifest, { context, label, changed, carried, action = 'verify' }) {
|
|
912
|
+
const { root } = ctx
|
|
913
|
+
const interWave = context === 'inter-wave'
|
|
914
|
+
const profile = readProfile(root)
|
|
915
|
+
const opts = { context, changed }
|
|
916
|
+
if (context === 'final') {
|
|
917
|
+
opts.e2e = manifest.flags.skipE2e !== true
|
|
918
|
+
opts.coverage = manifest.flags.skipCoverage !== true
|
|
919
|
+
} else {
|
|
920
|
+
// The budget's input (`interWaveVerifyBudgetMs`). Accumulated across
|
|
921
|
+
// invocations on the manifest, because this process runs BETWEEN agent turns
|
|
922
|
+
// and never sees the interval a verify agent spends; without it the
|
|
923
|
+
// comparison would be `0 >= budget` forever and the published cap would
|
|
924
|
+
// bound nothing.
|
|
925
|
+
opts.elapsedMs = numberOrUndefined(manifest.verifyElapsedMs) || 0
|
|
926
|
+
}
|
|
927
|
+
const plan = planVerification(profile, opts)
|
|
928
|
+
writeWork(root, `vplan-${context}.json`, plan)
|
|
929
|
+
|
|
930
|
+
if (!plan.steps.length) {
|
|
931
|
+
// A spent budget is the reason, not whichever kind happens to sit first in
|
|
932
|
+
// `skipped` — typecheck is skipped for its own missing command and would
|
|
933
|
+
// otherwise mask the budget as the cause of an empty plan.
|
|
934
|
+
const reason = plan.budgetExceeded
|
|
935
|
+
? SKIP_REASONS.BUDGET_EXCEEDED
|
|
936
|
+
: (plan.skipped[0] && plan.skipped[0].reason) || 'no detectable commands'
|
|
937
|
+
return makeStep({
|
|
938
|
+
action,
|
|
939
|
+
...carried,
|
|
940
|
+
context,
|
|
941
|
+
skipped: true,
|
|
942
|
+
reason,
|
|
943
|
+
banners: [...(carried.banners || []), `VERIFICATION SKIPPED: reason=${reason}`],
|
|
944
|
+
then: { argv: ['run', 'judge', '--context', context] },
|
|
945
|
+
spawns: []
|
|
946
|
+
})
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// The interval starts now: the driver is about to spawn an agent that runs
|
|
950
|
+
// these commands, and `run judge` — the next process — folds the elapsed time
|
|
951
|
+
// in. Only inter-wave; the final check is not budgeted.
|
|
952
|
+
if (interWave) manifest.verifyStartedAt = new Date().toISOString()
|
|
953
|
+
|
|
954
|
+
return makeStep({
|
|
955
|
+
action,
|
|
956
|
+
...carried,
|
|
957
|
+
context,
|
|
958
|
+
skipped: false,
|
|
959
|
+
then: { argv: ['run', 'judge', '--context', context] },
|
|
960
|
+
spawns: [
|
|
961
|
+
spawn(root, {
|
|
962
|
+
label,
|
|
963
|
+
kind: 'verify',
|
|
964
|
+
model: null,
|
|
965
|
+
effort: null,
|
|
966
|
+
schema: VERIFY_RESULT_SCHEMA,
|
|
967
|
+
prompt:
|
|
968
|
+
(context === 'final'
|
|
969
|
+
? // `fix-tests`, not `verify`: the final step repairs a red suite by
|
|
970
|
+
// root cause, and that repair is exactly when weakening a test is
|
|
971
|
+
// the hazard — so the marker it publishes puts guard-tests into
|
|
972
|
+
// deny for test files.
|
|
973
|
+
publishStageLine('fix-tests', manifest.change, ++manifest.stageIndex) + '\n'
|
|
974
|
+
: '') +
|
|
975
|
+
assembleVerifyPrompt({
|
|
976
|
+
change: manifest.change,
|
|
977
|
+
context,
|
|
978
|
+
steps: plan.steps,
|
|
979
|
+
runId: manifest.runId,
|
|
980
|
+
statePath: STATE_PATH,
|
|
981
|
+
// A retry's own facts. The state machine computed which attempt this
|
|
982
|
+
// is and what failed last time; briefing the second attempt
|
|
983
|
+
// identically to the first buys an agent that re-runs the same
|
|
984
|
+
// commands with no knowledge that they were already red. The final
|
|
985
|
+
// check has no attempt counter, so it carries none.
|
|
986
|
+
...(interWave
|
|
987
|
+
? {
|
|
988
|
+
fixAttempt: carried.fixAttempt,
|
|
989
|
+
fixAttemptsRemaining: carried.fixAttemptsRemaining,
|
|
990
|
+
errors: carried.errors
|
|
991
|
+
}
|
|
992
|
+
: {})
|
|
993
|
+
})
|
|
994
|
+
})
|
|
995
|
+
]
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// --- the strict tail (design D1, D2, D3, D4, D8) ----------------------------
|
|
1000
|
+
//
|
|
1001
|
+
// Adversarial review, bounded remediation, and the verdict — the sections
|
|
1002
|
+
// `workflows/ship.js` used to run inline behind a `host-tail` step (design D6,
|
|
1003
|
+
// now retired). Every judgement that has a correct answer moves here: which
|
|
1004
|
+
// dimensions run, what their criteria say, what a diff's findings survive to,
|
|
1005
|
+
// what a round fixes versus defers, and when the budget is spent. A driver
|
|
1006
|
+
// obeys the step and asks nothing.
|
|
1007
|
+
|
|
1008
|
+
/** Where a dimension's written criteria live, relative to the plugin itself —
|
|
1009
|
+
* not the target repository `ctx.root` names, which may hold no such file at
|
|
1010
|
+
* all. Mirrors `lib/doctor.mjs`'s own `PLUGIN_ROOT` resolution. */
|
|
1011
|
+
const REVIEW_DIMENSIONS_DIR = join('skills', 'review-code', 'dimensions')
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* A dimension's written criteria — the file every reviewer prompt used to be
|
|
1015
|
+
* told to open for itself (`RUBRIC_INSTRUCTIONS`, retired). Read here once so
|
|
1016
|
+
* the whole run states the degradation when it cannot, rather than a reviewer
|
|
1017
|
+
* silently proceeding on the dimension's name alone (design D3, rubric-
|
|
1018
|
+
* delivery). Never throws: an unreadable or absent file is `null`.
|
|
1019
|
+
*/
|
|
1020
|
+
function readDimensionRubric(name) {
|
|
1021
|
+
try {
|
|
1022
|
+
const text = readFileSync(join(PLUGIN_ROOT, REVIEW_DIMENSIONS_DIR, `${name}.md`), 'utf8')
|
|
1023
|
+
return text.trim() || null
|
|
1024
|
+
} catch {
|
|
1025
|
+
return null
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** `{ name, rubric }` for every dimension name, in order — what both prompt
|
|
1030
|
+
* assemblers take (design D3). */
|
|
1031
|
+
function resolveDimensionRubrics(names) {
|
|
1032
|
+
return (Array.isArray(names) ? names : []).map(name => ({ name, rubric: readDimensionRubric(name) }))
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The review step: one worker, every selected dimension fanned out inside it,
|
|
1037
|
+
* two skeptics per finding (design D1, D3, D4, D8).
|
|
1038
|
+
*
|
|
1039
|
+
* Dimensions are `selectDimensions`'s rule over the run's OBSERVED changed
|
|
1040
|
+
* paths — never a reviewer's judgement call — and a dimension whose criteria
|
|
1041
|
+
* could not be read is named on the step rather than dispatched silently on
|
|
1042
|
+
* its name alone.
|
|
1043
|
+
*/
|
|
1044
|
+
function reviewStep(ctx, manifest, carried = {}) {
|
|
1045
|
+
const { root } = ctx
|
|
1046
|
+
const change = manifest.change
|
|
1047
|
+
const changedPaths = ctx.deps.observedChangedPaths(root)
|
|
1048
|
+
const { dimensions: names, reasons } = selectDimensions(changedPaths)
|
|
1049
|
+
const dimensions = resolveDimensionRubrics(names)
|
|
1050
|
+
const policy = readReviewPolicy(root)
|
|
1051
|
+
|
|
1052
|
+
const banners = [...(carried.banners || [])]
|
|
1053
|
+
for (const d of dimensions) {
|
|
1054
|
+
if (d.rubric === null) {
|
|
1055
|
+
banners.push(`REVIEW RUBRIC UNAVAILABLE: ${d.name} — that reviewer works from the dimension name alone`)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Carried across every later `run reviewed`/`run remediated` call in this
|
|
1060
|
+
// run, each its own process: what the CLI selected, and why, so an agent-
|
|
1061
|
+
// added dimension can be told apart from one the CLI asked for (design D4).
|
|
1062
|
+
manifest.reviewDimensions = { selected: names, reasons }
|
|
1063
|
+
|
|
1064
|
+
const stageLine = publishStageLine('review', change, ++manifest.stageIndex)
|
|
1065
|
+
return makeStep({
|
|
1066
|
+
action: 'review',
|
|
1067
|
+
...carried,
|
|
1068
|
+
banners,
|
|
1069
|
+
then: { argv: ['run', 'reviewed'] },
|
|
1070
|
+
spawns: [
|
|
1071
|
+
spawn(root, {
|
|
1072
|
+
label: 'review',
|
|
1073
|
+
kind: 'review',
|
|
1074
|
+
model: null,
|
|
1075
|
+
// The skeptics reason inside this one worker (it fans out dimensions
|
|
1076
|
+
// and two skeptics per finding in its own context), so pinning this
|
|
1077
|
+
// spawn's effort is what pins them — fixed regardless of any lane tier
|
|
1078
|
+
// (design D8).
|
|
1079
|
+
effort: EFFORT.skeptic,
|
|
1080
|
+
schema: REVIEW_RESULT_SCHEMA,
|
|
1081
|
+
prompt: assembleReviewPrompt({
|
|
1082
|
+
change,
|
|
1083
|
+
dimensions,
|
|
1084
|
+
policyProse: policy.prose,
|
|
1085
|
+
findingsPath: FINDINGS_PATH,
|
|
1086
|
+
verdictsPath: VERDICTS_PATH,
|
|
1087
|
+
stageLine
|
|
1088
|
+
})
|
|
1089
|
+
})
|
|
1090
|
+
]
|
|
1091
|
+
})
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* One remediation-round step: a fixing round (`assembleRemediatePrompt`), or —
|
|
1096
|
+
* when the plan says this is the round after the cap — the verdict
|
|
1097
|
+
* (`assembleVerdictPrompt`), which fixes nothing and re-reviews nothing.
|
|
1098
|
+
*/
|
|
1099
|
+
function remediateStep(ctx, manifest, plan, extra = {}) {
|
|
1100
|
+
const { root } = ctx
|
|
1101
|
+
const change = manifest.change
|
|
1102
|
+
const then = { argv: ['run', 'remediated', '--round', String(plan.round)] }
|
|
1103
|
+
|
|
1104
|
+
if (plan.isFinalRound) {
|
|
1105
|
+
return makeStep({
|
|
1106
|
+
action: 'verdict',
|
|
1107
|
+
...extra,
|
|
1108
|
+
round: plan.round,
|
|
1109
|
+
then,
|
|
1110
|
+
spawns: [
|
|
1111
|
+
spawn(root, {
|
|
1112
|
+
label: `remediate-${plan.round}`,
|
|
1113
|
+
kind: 'remediate',
|
|
1114
|
+
model: null,
|
|
1115
|
+
effort: EFFORT.skeptic,
|
|
1116
|
+
schema: VERDICT_RESULT_SCHEMA,
|
|
1117
|
+
prompt: assembleVerdictPrompt({ change, round: plan.round })
|
|
1118
|
+
})
|
|
1119
|
+
]
|
|
1120
|
+
})
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const dimensions = resolveDimensionRubrics(plan.reReviewDimensions)
|
|
1124
|
+
const policyProse = readReviewPolicy(root).prose
|
|
1125
|
+
const stageLine = publishStageLine('remediation', change, ++manifest.stageIndex)
|
|
1126
|
+
return makeStep({
|
|
1127
|
+
action: 'remediate',
|
|
1128
|
+
...extra,
|
|
1129
|
+
round: plan.round,
|
|
1130
|
+
then,
|
|
1131
|
+
spawns: [
|
|
1132
|
+
spawn(root, {
|
|
1133
|
+
label: `remediate-${plan.round}`,
|
|
1134
|
+
kind: 'remediate',
|
|
1135
|
+
model: null,
|
|
1136
|
+
effort: EFFORT.skeptic,
|
|
1137
|
+
schema: REMEDIATE_RESULT_SCHEMA,
|
|
1138
|
+
prompt: assembleRemediatePrompt({ change, round: plan.round, plan, dimensions, policyProse, stageLine })
|
|
1139
|
+
})
|
|
1140
|
+
]
|
|
1141
|
+
})
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Wrap a `lib/remediate.mjs` call: its own validation throws a user-facing
|
|
1145
|
+
* message on a round out of range, which is a halt here, never a crash. */
|
|
1146
|
+
function planRemediationRound(surviving, round) {
|
|
1147
|
+
try {
|
|
1148
|
+
return { plan: planRemediation(surviving, { round }), error: null }
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
return { plan: null, error: (err && err.message) || String(err) }
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* A stage marker the agent could not publish, said out loud.
|
|
1156
|
+
*
|
|
1157
|
+
* The guards for that stage fail open — their documented default — so the run
|
|
1158
|
+
* continues, but a window in which `hooks/guard-tests.mjs` was not in force is
|
|
1159
|
+
* exactly the thing a reader of the trajectory must not have to infer. The
|
|
1160
|
+
* Workflow host used to raise this itself; the tail is the CLI's now, so the
|
|
1161
|
+
* CLI raises it.
|
|
1162
|
+
*/
|
|
1163
|
+
function stageMarkerBanners(results) {
|
|
1164
|
+
const out = []
|
|
1165
|
+
for (const entry of Array.isArray(results) ? results : []) {
|
|
1166
|
+
const warning =
|
|
1167
|
+
entry && typeof entry.stageMarkerWarning === 'string' ? entry.stageMarkerWarning.trim() : ''
|
|
1168
|
+
if (warning) {
|
|
1169
|
+
out.push(`STAGE MARKER NOT PUBLISHED: ${warning} — the guards for this stage fail open`)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return out
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Re-adjudicate the work files a review or fixer just wrote, write `review.json`
|
|
1177
|
+
* and the metrics file exactly as `interlock review --metrics <change>` would,
|
|
1178
|
+
* and record the counts on the manifest (design D2, D7).
|
|
1179
|
+
*/
|
|
1180
|
+
function adjudicateReview(ctx, manifest) {
|
|
1181
|
+
const { root } = ctx
|
|
1182
|
+
const findings = readJson(join(root, FINDINGS_PATH))
|
|
1183
|
+
const verdicts = readJson(join(root, VERDICTS_PATH))
|
|
1184
|
+
const changedPaths = ctx.deps.observedChangedPaths(root)
|
|
1185
|
+
const policy = readReviewPolicy(root)
|
|
1186
|
+
|
|
1187
|
+
const result = resolveReview(findings, verdicts, { changedPaths, excludePaths: policy.excludePaths })
|
|
1188
|
+
writeWork(root, 'review.json', result)
|
|
1189
|
+
|
|
1190
|
+
const banners = []
|
|
1191
|
+
const metrics = writeReviewMetrics(root, { change: manifest.change, counts: result.counts })
|
|
1192
|
+
if (!metrics.written) {
|
|
1193
|
+
banners.push(`REVIEW METRICS NOT WRITTEN: ${metrics.reason}`)
|
|
1194
|
+
}
|
|
1195
|
+
return { result, banners }
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* `run reviewed` — the continuation of the review step (design D2).
|
|
1200
|
+
*
|
|
1201
|
+
* Adjudicates `findings.json`/`verdicts.json` against the run's OWN observed
|
|
1202
|
+
* changed paths (never the agent's), writes `review.json` and the metrics
|
|
1203
|
+
* file, records any agent-added dimension, and asks `lib/remediate.mjs` for
|
|
1204
|
+
* round one: a fixing round, or straight to final verification when nothing
|
|
1205
|
+
* survived to fix.
|
|
1206
|
+
*/
|
|
1207
|
+
export function runReviewed(ctx, { results }) {
|
|
1208
|
+
const { root } = ctx
|
|
1209
|
+
const manifest = readManifest(root)
|
|
1210
|
+
if (!manifest) return noManifest()
|
|
1211
|
+
const capped = countStep(root, manifest)
|
|
1212
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
1213
|
+
|
|
1214
|
+
// Outside the waves, but not outside the run's spend (design D9).
|
|
1215
|
+
recordUsage(manifest, results)
|
|
1216
|
+
|
|
1217
|
+
const { result, banners } = adjudicateReview(ctx, manifest)
|
|
1218
|
+
banners.push(...stageMarkerBanners(results))
|
|
1219
|
+
|
|
1220
|
+
// A review step that reported nothing usable still adjudicates — the files
|
|
1221
|
+
// are read either way — but "the review did not complete" is a different
|
|
1222
|
+
// fact from "the review found nothing", and the summary must not blur them
|
|
1223
|
+
// (workflow-host spec, edge case: an agent that returns no findings file).
|
|
1224
|
+
const report = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
1225
|
+
if (!report || report.ok !== true) {
|
|
1226
|
+
manifest.notes.push(
|
|
1227
|
+
`review did not complete: ${(report && report.detail) || 'the step returned no usable result'}`
|
|
1228
|
+
)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// A dimension the reviewer added beyond the CLI's selection (design D4).
|
|
1232
|
+
// The CLI cannot know why; the one-line reason is the agent's own report,
|
|
1233
|
+
// carried here rather than inferred.
|
|
1234
|
+
const selected = new Set((manifest.reviewDimensions && manifest.reviewDimensions.selected) || [])
|
|
1235
|
+
const added = Array.isArray(report && report.addedDimensions)
|
|
1236
|
+
? report.addedDimensions.filter(d => d && typeof d.name === 'string' && !selected.has(d.name))
|
|
1237
|
+
: []
|
|
1238
|
+
if (added.length) {
|
|
1239
|
+
manifest.reviewDimensions = { ...(manifest.reviewDimensions || { selected: [], reasons: {} }), added }
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
manifest.banners.push(...banners)
|
|
1243
|
+
|
|
1244
|
+
const { plan, error } = planRemediationRound(result.surviving, 1)
|
|
1245
|
+
if (!plan) {
|
|
1246
|
+
return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${error}`, { banners }))
|
|
1247
|
+
}
|
|
1248
|
+
writeWork(root, 'remediate-1.json', plan)
|
|
1249
|
+
|
|
1250
|
+
// The first adjudication's counts, which is what the receipt reports as the
|
|
1251
|
+
// review. `blockers` is not one of `resolveReview`'s counts — severity is
|
|
1252
|
+
// resolved by the gate — so it comes from the round-one plan, computed from
|
|
1253
|
+
// the same surviving set and never from the agent's self-report.
|
|
1254
|
+
manifest.review = {
|
|
1255
|
+
counts: { ...result.counts, blockers: plan.counts.blockers },
|
|
1256
|
+
dimensions: manifest.reviewDimensions
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (!plan.fix.byFile.length && !plan.fix.unscoped.length) {
|
|
1260
|
+
// Round one found nothing that needs a fixer. Spending a remediation round
|
|
1261
|
+
// — or the verdict round after it — on a clean review is pure token cost,
|
|
1262
|
+
// so this is the one place the tail skips straight to final verification
|
|
1263
|
+
// (design D2) rather than walking the rest of the budget as later rounds do.
|
|
1264
|
+
manifest.remediation = { fixed: 0, deferred: plan.deferred.length, blockersRemaining: plan.counts.blockers }
|
|
1265
|
+
return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest, { banners }))
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
manifest.remediation = { fixed: 0, deferred: plan.deferred.length, blockersRemaining: plan.counts.blockers }
|
|
1269
|
+
return finishStep(ctx, manifest, remediateStep(ctx, manifest, plan, { banners }))
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* `run remediated --round N` — the continuation of a fixing or verdict round
|
|
1274
|
+
* (design D2).
|
|
1275
|
+
*
|
|
1276
|
+
* Re-adjudicates the (possibly rewritten) findings/verdicts files, records
|
|
1277
|
+
* fixed/deferred and bumps `fixRoundsRun` for a completed FIX round, then asks
|
|
1278
|
+
* `lib/remediate.mjs` for what comes next. A round that already cleared every
|
|
1279
|
+
* blocker jumps straight to the verdict round rather than spending the rest of
|
|
1280
|
+
* the fix budget on it (moved from `workflows/ship.js`'s retired
|
|
1281
|
+
* `remediationBudget`) — only the verdict round itself may halt.
|
|
1282
|
+
*/
|
|
1283
|
+
export function runRemediated(ctx, { round, results }) {
|
|
1284
|
+
const { root } = ctx
|
|
1285
|
+
const manifest = readManifest(root)
|
|
1286
|
+
if (!manifest) return noManifest()
|
|
1287
|
+
const capped = countStep(root, manifest)
|
|
1288
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
1289
|
+
|
|
1290
|
+
// Outside the waves, but not outside the run's spend (design D9).
|
|
1291
|
+
recordUsage(manifest, results)
|
|
1292
|
+
|
|
1293
|
+
const roundCap = LIMITS.remediationRounds
|
|
1294
|
+
const completedRound = Number.isInteger(round) ? round : null
|
|
1295
|
+
if (completedRound === null || completedRound < 1 || completedRound > roundCap + 1) {
|
|
1296
|
+
return finishStep(ctx, manifest, haltStep(
|
|
1297
|
+
`run remediated called with an invalid --round (got ${JSON.stringify(round)}, expected 1-${roundCap + 1})`
|
|
1298
|
+
))
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const { result, banners } = adjudicateReview(ctx, manifest)
|
|
1302
|
+
banners.push(...stageMarkerBanners(results))
|
|
1303
|
+
|
|
1304
|
+
if (completedRound === roundCap + 1) {
|
|
1305
|
+
// The verdict round just reported. It fixed and re-reviewed nothing, so
|
|
1306
|
+
// this re-adjudication reflects exactly what the last fix round left
|
|
1307
|
+
// standing — which is what decides the halt (design D2).
|
|
1308
|
+
const { plan, error } = planRemediationRound(result.surviving, completedRound)
|
|
1309
|
+
if (!plan) {
|
|
1310
|
+
return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${error}`, { banners }))
|
|
1311
|
+
}
|
|
1312
|
+
writeWork(root, `remediate-${completedRound}.json`, plan)
|
|
1313
|
+
manifest.remediation = {
|
|
1314
|
+
...(manifest.remediation || {}),
|
|
1315
|
+
deferred: (manifest.remediation && manifest.remediation.deferred) || 0,
|
|
1316
|
+
blockersRemaining: plan.counts.blockers
|
|
1317
|
+
}
|
|
1318
|
+
manifest.banners.push(...banners)
|
|
1319
|
+
if (plan.halt) {
|
|
1320
|
+
return finishStep(ctx, manifest, haltStep(plan.haltReason, { banners }))
|
|
1321
|
+
}
|
|
1322
|
+
return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest, { banners }))
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// A completed FIX round. What it attempted is the plan that put it on the
|
|
1326
|
+
// wire in the first place — read back rather than re-derived, so "fixed" is
|
|
1327
|
+
// never a fresh guess about the same round.
|
|
1328
|
+
const attemptedPlan = readJson(join(root, WORK_DIR, `remediate-${completedRound}.json`))
|
|
1329
|
+
const attempted = attemptedPlan ? attemptedPlan.counts.fixing : 0
|
|
1330
|
+
|
|
1331
|
+
let { plan: nextPlan, error } = planRemediationRound(result.surviving, completedRound + 1)
|
|
1332
|
+
if (!nextPlan) {
|
|
1333
|
+
return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${error}`, { banners }))
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Every blocker cleared: nothing left justifies spending the rest of the fix
|
|
1337
|
+
// budget, so the next round IS the verdict regardless of what its number
|
|
1338
|
+
// would otherwise be.
|
|
1339
|
+
if (nextPlan.counts.blockers === 0 && !nextPlan.isFinalRound) {
|
|
1340
|
+
const jumped = planRemediationRound(result.surviving, roundCap + 1)
|
|
1341
|
+
if (!jumped.plan) {
|
|
1342
|
+
return finishStep(ctx, manifest, haltStep(`remediation planning failed: ${jumped.error}`, { banners }))
|
|
1343
|
+
}
|
|
1344
|
+
nextPlan = jumped.plan
|
|
1345
|
+
}
|
|
1346
|
+
writeWork(root, `remediate-${nextPlan.round}.json`, nextPlan)
|
|
1347
|
+
|
|
1348
|
+
// Still broken, under the SAME dimensions this round touched (re-review ran
|
|
1349
|
+
// only `attemptedPlan.reReviewDimensions`, so `nextPlan`'s findings reflect
|
|
1350
|
+
// exactly those and nothing else) — what did not get fixed.
|
|
1351
|
+
const stillBroken = nextPlan.counts.blockers + nextPlan.counts.warnings
|
|
1352
|
+
const fixed = Math.max(0, attempted - stillBroken)
|
|
1353
|
+
|
|
1354
|
+
manifest.fixRoundsRun = (manifest.fixRoundsRun || 0) + 1
|
|
1355
|
+
manifest.remediation = {
|
|
1356
|
+
fixed: ((manifest.remediation && manifest.remediation.fixed) || 0) + fixed,
|
|
1357
|
+
deferred: ((manifest.remediation && manifest.remediation.deferred) || 0) + nextPlan.deferred.length,
|
|
1358
|
+
blockersRemaining: nextPlan.counts.blockers
|
|
1359
|
+
}
|
|
1360
|
+
manifest.banners.push(...banners)
|
|
1361
|
+
return finishStep(ctx, manifest, remediateStep(ctx, manifest, nextPlan, { banners }))
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// --- small file helpers -----------------------------------------------------
|
|
1365
|
+
|
|
1366
|
+
function readProfile(root) {
|
|
1367
|
+
try {
|
|
1368
|
+
return JSON.parse(readFileSync(join(root, '.claude', 'testing', 'profile.json'), 'utf8'))
|
|
1369
|
+
} catch {
|
|
1370
|
+
// No profile is not a degradation to report here — `planVerification` says
|
|
1371
|
+
// so itself, in the skip reasons it returns.
|
|
1372
|
+
return null
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function writeWork(root, name, value) {
|
|
1377
|
+
const path = join(root, WORK_DIR, name)
|
|
1378
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
1379
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + '\n')
|
|
1380
|
+
return join(WORK_DIR, name)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function readJson(path) {
|
|
1384
|
+
try {
|
|
1385
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
1386
|
+
} catch {
|
|
1387
|
+
return null
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
export function readState(root) {
|
|
1392
|
+
return readJson(join(root, STATE_PATH))
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
export function writeState(root, state) {
|
|
1396
|
+
const path = join(root, STATE_PATH)
|
|
1397
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
1398
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + '\n')
|
|
1399
|
+
return state
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// --- the sequence guard -----------------------------------------------------
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Refuse a `run` subcommand the program did not ask for next.
|
|
1406
|
+
*
|
|
1407
|
+
* The manifest records the argv the last step named as its continuation. A
|
|
1408
|
+
* driver that skipped a step, repeated one, or called a subcommand of its own
|
|
1409
|
+
* invention would otherwise advance the state machine twice or record a batch
|
|
1410
|
+
* against the wrong wave — both silent. Returns null when the call is in
|
|
1411
|
+
* sequence, and the reason it is not when it is not.
|
|
1412
|
+
*/
|
|
1413
|
+
export function checkSequence(manifest, argv) {
|
|
1414
|
+
const expected = manifest.lastThen
|
|
1415
|
+
if (!Array.isArray(expected) || !expected.length) return null
|
|
1416
|
+
// Compare what the program asked for as a PREFIX of what was called: the
|
|
1417
|
+
// driver appends `--results <file>` and may append `--json`, and neither is
|
|
1418
|
+
// part of the ask. Everything the step named must be present, in order.
|
|
1419
|
+
const strip = list => list.filter(a => a !== '--json')
|
|
1420
|
+
const want = strip(expected)
|
|
1421
|
+
const got = strip(argv)
|
|
1422
|
+
const matches = want.every((token, i) => got[i] === token)
|
|
1423
|
+
if (matches) return null
|
|
1424
|
+
return (
|
|
1425
|
+
`out of sequence: the run program's last step named \`interlock ${want.join(' ')}\` as its ` +
|
|
1426
|
+
`continuation, and this call was \`interlock ${got.join(' ')}\``
|
|
1427
|
+
)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/** Record the continuation this step named, so the next call can be checked against it. */
|
|
1431
|
+
export function recordThen(root, manifest, step) {
|
|
1432
|
+
manifest.lastThen = step && step.then && Array.isArray(step.then.argv) ? step.then.argv : null
|
|
1433
|
+
writeManifest(root, manifest)
|
|
1434
|
+
return step
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// --- trajectory -------------------------------------------------------------
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Log every agent this step asks for.
|
|
1441
|
+
*
|
|
1442
|
+
* `wave-state next` already logs the IMPLEMENTER spawns it names in its own
|
|
1443
|
+
* tasks[]; it has no way to know about the ping, the verifier or the committer
|
|
1444
|
+
* the program is about to ask for. Both drivers used to cover those with an
|
|
1445
|
+
* extra `run-log append` fused into each prompt — an instruction a model could
|
|
1446
|
+
* skip. The CLI that decided on the spawn records it.
|
|
1447
|
+
*/
|
|
1448
|
+
function logSpawns(ctx, manifest, step) {
|
|
1449
|
+
// No run identity yet — the classify step runs before there is a wave state,
|
|
1450
|
+
// so nothing is owed. The same rule `logVerifyJudgement` follows: a call with
|
|
1451
|
+
// no runId is not part of a logged run, and logging is not what should fail it.
|
|
1452
|
+
if (!manifest.runId) return step
|
|
1453
|
+
for (const s of step.spawns || []) {
|
|
1454
|
+
if (s.kind === 'implementer') continue // logged by wave-state, at the lane
|
|
1455
|
+
const written = appendRunLogEvent(ctx.root, {
|
|
1456
|
+
runId: manifest.runId || undefined,
|
|
1457
|
+
change: manifest.change,
|
|
1458
|
+
type: 'agent-spawn',
|
|
1459
|
+
label: s.label,
|
|
1460
|
+
model: s.model || undefined,
|
|
1461
|
+
kind: s.kind
|
|
1462
|
+
})
|
|
1463
|
+
if (!written.written) ctx.warn(`agent-spawn not recorded: ${written.reason}`)
|
|
1464
|
+
}
|
|
1465
|
+
return step
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function logCliExit(ctx, manifest, command, exitCode) {
|
|
1469
|
+
if (!manifest.runId) return
|
|
1470
|
+
const written = appendRunLogEvent(ctx.root, {
|
|
1471
|
+
runId: manifest.runId,
|
|
1472
|
+
change: manifest.change,
|
|
1473
|
+
type: 'cli-exit',
|
|
1474
|
+
command,
|
|
1475
|
+
exitCode
|
|
1476
|
+
})
|
|
1477
|
+
if (!written.written) ctx.warn(`cli-exit not recorded: ${written.reason}`)
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** The shared tree's HEAD. Never throws: an unreadable base is null, never a guess. */
|
|
1481
|
+
function headCommit(ctx) {
|
|
1482
|
+
return ctx.deps.headCommit(ctx.root)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// --- adjudication (moved from the RECORDED_VERDICT block) -------------------
|
|
1486
|
+
//
|
|
1487
|
+
// One authority for "what did this task do": the outcome `record-batch`
|
|
1488
|
+
// recorded, not the outcome the implementing agent reported for itself. The
|
|
1489
|
+
// claim is the input the state machine adjudicated — an invalid or over-budget
|
|
1490
|
+
// handoff packet fails its task — so where the two differ the recorded outcome
|
|
1491
|
+
// is the one a tick and a tally are built from.
|
|
1492
|
+
//
|
|
1493
|
+
// Both hosts read this from one place now. A second copy of "which of two
|
|
1494
|
+
// disagreeing observations is the run's" would drift, and both hosts would
|
|
1495
|
+
// still look correct.
|
|
1496
|
+
|
|
1497
|
+
/** The three outcomes a batch can record. Anything else is not a verdict. */
|
|
1498
|
+
const RECORDED_OUTCOMES = ['ok', 'failed', 'not-attempted']
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* The recorded verdicts `batchOutcomes` produced, validated.
|
|
1502
|
+
*
|
|
1503
|
+
* One malformed entry invalidates the whole payload rather than being skipped:
|
|
1504
|
+
* a partial verdict read as a complete one is the defect this exists to close,
|
|
1505
|
+
* one level down. `null` means "no verdict arrived" and puts the caller on the
|
|
1506
|
+
* loud claim-derived fallback.
|
|
1507
|
+
*/
|
|
1508
|
+
export function recordedOutcomes(raw) {
|
|
1509
|
+
if (!Array.isArray(raw) || !raw.length) return null
|
|
1510
|
+
const out = []
|
|
1511
|
+
for (const entry of raw) {
|
|
1512
|
+
if (!entry || typeof entry !== 'object') return null
|
|
1513
|
+
if (typeof entry.id !== 'string' || !entry.id.trim()) return null
|
|
1514
|
+
if (!RECORDED_OUTCOMES.includes(entry.outcome)) return null
|
|
1515
|
+
out.push({
|
|
1516
|
+
id: entry.id.trim(),
|
|
1517
|
+
outcome: entry.outcome,
|
|
1518
|
+
reason: typeof entry.reason === 'string' && entry.reason.trim() ? entry.reason.trim() : ''
|
|
1519
|
+
})
|
|
1520
|
+
}
|
|
1521
|
+
return out
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* What to tick, what to tally, and which claims the run had to override.
|
|
1526
|
+
*
|
|
1527
|
+
* @param {Array<Array<{id: string, ok: boolean, error?: string}>>} batches the
|
|
1528
|
+
* claims sent to `record-batch`, one array per batch, in command order
|
|
1529
|
+
* @param {Array<{id: string, outcome: string, reason: string}>|null} recorded
|
|
1530
|
+
* the verdicts those commands reported, concatenated in the same order
|
|
1531
|
+
*/
|
|
1532
|
+
export function adjudicateBatches(batches, recorded) {
|
|
1533
|
+
const claims = batches.flat()
|
|
1534
|
+
// Every claim must have a verdict, or there is no verdict: a payload covering
|
|
1535
|
+
// three of five tasks would otherwise tally the other two from the claim
|
|
1536
|
+
// while reading as fully recorded.
|
|
1537
|
+
const covered = Boolean(recorded) && claims.every(c => recorded.some(o => o.id === c.id))
|
|
1538
|
+
const verdicts = covered ? recorded : null
|
|
1539
|
+
const rowsOf = batch =>
|
|
1540
|
+
batch.map(claim => {
|
|
1541
|
+
const found = verdicts ? verdicts.find(o => o.id === claim.id) : null
|
|
1542
|
+
return {
|
|
1543
|
+
id: claim.id,
|
|
1544
|
+
outcome: found ? found.outcome : claim.ok ? 'ok' : 'failed',
|
|
1545
|
+
reason: found ? found.reason : typeof claim.error === 'string' ? claim.error : ''
|
|
1546
|
+
}
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
const perBatch = batches.map(rowsOf)
|
|
1550
|
+
const rows = perBatch.flat()
|
|
1551
|
+
return {
|
|
1552
|
+
claimDerived: !verdicts,
|
|
1553
|
+
tickIds: rows.filter(r => r.outcome === 'ok').map(r => r.id),
|
|
1554
|
+
waves: perBatch.map(batchRows => ({
|
|
1555
|
+
ok: batchRows.filter(r => r.outcome === 'ok').length,
|
|
1556
|
+
failed: batchRows.filter(r => r.outcome === 'failed').length,
|
|
1557
|
+
failedIds: batchRows.filter(r => r.outcome === 'failed').map(r => r.id),
|
|
1558
|
+
recordedNotAttempted: batchRows.filter(r => r.outcome === 'not-attempted').map(r => r.id)
|
|
1559
|
+
})),
|
|
1560
|
+
overrides: rows.filter((row, i) => verdicts && (row.outcome === 'ok') !== claims[i].ok)
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* An overridden claim is a finding, not a silent correction. A run that quietly
|
|
1566
|
+
* fixed up five bad handoffs is indistinguishable from a clean one; a run that
|
|
1567
|
+
* says it overrode five claims tells its reader the implementer prompt or the
|
|
1568
|
+
* handoff contract needs attention.
|
|
1569
|
+
*/
|
|
1570
|
+
export function overrideBanner(overrides) {
|
|
1571
|
+
const detail = overrides
|
|
1572
|
+
.map(o => `${o.id}: ${o.outcome}${o.reason ? ` — ${o.reason}` : ''}`)
|
|
1573
|
+
.join('; ')
|
|
1574
|
+
return (
|
|
1575
|
+
`CLAIM OVERRIDDEN: ${overrides.map(o => o.id).join(', ')} — the implementing agent reported ` +
|
|
1576
|
+
`otherwise and the run acted on what it recorded (${detail})`
|
|
1577
|
+
)
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* The fallback said out loud. Silent fallback would reintroduce the defect in
|
|
1582
|
+
* the one case nobody would think to check, and a count that was never read
|
|
1583
|
+
* must not read as one that came back clean.
|
|
1584
|
+
*/
|
|
1585
|
+
export function claimDerivedBanner() {
|
|
1586
|
+
return (
|
|
1587
|
+
'CLAIM-DERIVED TALLIES: record-batch reported no usable per-task outcomes, so this wave was ' +
|
|
1588
|
+
'ticked and counted from what the implementing agents claimed rather than from what the run recorded'
|
|
1589
|
+
)
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* Turn one lane's agent result into an outcome per task it was given.
|
|
1594
|
+
*
|
|
1595
|
+
* Fails CLOSED, in one direction only: a result that does not account for every
|
|
1596
|
+
* task in the lane fails all of them, because a lane that reported on two of its
|
|
1597
|
+
* three tasks has told us nothing about the third — and "nothing" read as
|
|
1598
|
+
* success is how a task silently ships unimplemented. `not-attempted` is the one
|
|
1599
|
+
* outcome that is neither: it is reported, ticked nowhere, and counted nowhere.
|
|
1600
|
+
*/
|
|
1601
|
+
export function laneOutcomes(lane, result) {
|
|
1602
|
+
const failAll = error => lane.map(t => ({ id: t.id, outcome: 'failed', error, handoff: null }))
|
|
1603
|
+
if (!result) return failAll('agent returned no result')
|
|
1604
|
+
|
|
1605
|
+
if (lane.length === 1) {
|
|
1606
|
+
const [only] = lane
|
|
1607
|
+
return [
|
|
1608
|
+
{
|
|
1609
|
+
id: result.id || only.id,
|
|
1610
|
+
outcome: result.ok ? 'ok' : 'failed',
|
|
1611
|
+
error: result.error,
|
|
1612
|
+
filesChanged: result.filesChanged,
|
|
1613
|
+
handoff: result.handoff
|
|
1614
|
+
}
|
|
1615
|
+
]
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const entries = Array.isArray(result.tasks) ? result.tasks : null
|
|
1619
|
+
if (!entries) return failAll('lane result carried no per-task outcomes')
|
|
1620
|
+
|
|
1621
|
+
const byId = new Map()
|
|
1622
|
+
for (const e of entries) {
|
|
1623
|
+
if (e && typeof e === 'object' && typeof e.id === 'string') byId.set(e.id, e)
|
|
1624
|
+
}
|
|
1625
|
+
const missing = lane.filter(t => !byId.has(t.id)).map(t => t.id)
|
|
1626
|
+
if (missing.length) return failAll(`lane result omitted an outcome for ${missing.join(', ')}`)
|
|
1627
|
+
|
|
1628
|
+
return lane.map(t => {
|
|
1629
|
+
const e = byId.get(t.id)
|
|
1630
|
+
if (e.outcome === 'ok') {
|
|
1631
|
+
return {
|
|
1632
|
+
id: t.id,
|
|
1633
|
+
outcome: 'ok',
|
|
1634
|
+
error: e.error,
|
|
1635
|
+
filesChanged: e.filesChanged,
|
|
1636
|
+
handoff: e.handoff
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (e.outcome === 'not-attempted') return { id: t.id, outcome: 'not-attempted' }
|
|
1640
|
+
// Anything else — including a value the schema let through — is a failure.
|
|
1641
|
+
// Guessing which of three named outcomes an unnamed one meant is exactly the
|
|
1642
|
+
// inference the schema exists to remove.
|
|
1643
|
+
return {
|
|
1644
|
+
id: t.id,
|
|
1645
|
+
outcome: 'failed',
|
|
1646
|
+
error: e.error || `lane reported outcome ${JSON.stringify(e.outcome)}`,
|
|
1647
|
+
filesChanged: e.filesChanged,
|
|
1648
|
+
handoff: e.handoff
|
|
1649
|
+
}
|
|
1650
|
+
})
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// --- run start (design D4, D2) ----------------------------------------------
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Begin a run: write the manifest, validate the change, and either adopt a
|
|
1657
|
+
* reusable plan or ask for a classification.
|
|
1658
|
+
*
|
|
1659
|
+
* `plan-waves` is the most expensive fixed step of a run — it reads proposal.md,
|
|
1660
|
+
* design.md, tasks.md and every delta spec in full — so the program first asks
|
|
1661
|
+
* whether the stored plan still matches the inputs it was built from. Reuse is
|
|
1662
|
+
* never assumed: anything other than an affirmative match is a rebuild,
|
|
1663
|
+
* including an error while checking. Failing to prove reuse costs a classifier
|
|
1664
|
+
* run; wrongly reusing would run the wrong plan.
|
|
1665
|
+
*
|
|
1666
|
+
* @param {{root: string, deps: object, warn: (m: string) => void}} ctx
|
|
1667
|
+
* @param {{change: string, flags: object, host?: string, hostCapabilities?: object}} input
|
|
1668
|
+
*/
|
|
1669
|
+
export function runStart(ctx, { change, flags, host, hostCapabilities }) {
|
|
1670
|
+
const { root } = ctx
|
|
1671
|
+
const manifest = newManifest({ change, flags, host, hostCapabilities })
|
|
1672
|
+
writeManifest(root, manifest)
|
|
1673
|
+
|
|
1674
|
+
const capped = countStep(root, manifest)
|
|
1675
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
1676
|
+
|
|
1677
|
+
const inspection = inspectChange(root, change)
|
|
1678
|
+
if (!inspection.ready) {
|
|
1679
|
+
const problems = (inspection.problems || []).map(p => (typeof p === 'string' ? p : p.detail || p.id))
|
|
1680
|
+
return finishStep(ctx, manifest, haltStep(
|
|
1681
|
+
`validate failed: ${problems.join('; ') || 'the change is not implementable'}`
|
|
1682
|
+
))
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// The marker is readable here; the structural fallback is not, because no
|
|
1686
|
+
// classification exists until the classify step runs. So a marker-shaped TDD
|
|
1687
|
+
// run — the ordinary one — matches its stored fingerprint and reuses its plan,
|
|
1688
|
+
// and a structurally-inferred one re-plans rather than reusing a plan built
|
|
1689
|
+
// for the other shape. `resolveRedWave` is called without `tasks` to say
|
|
1690
|
+
// exactly that, and stays silent about step 3 rather than claiming it failed.
|
|
1691
|
+
const reuseRed = resolveRedWave({
|
|
1692
|
+
tddMode: manifest.flags.tddMode || null,
|
|
1693
|
+
redSection: inspection.tasks.redSection
|
|
1694
|
+
})
|
|
1695
|
+
manifest.banners.push(...reuseRed.banners)
|
|
1696
|
+
|
|
1697
|
+
const reuse = resolvePlanReuse(root, change, {
|
|
1698
|
+
maxParallel: numberOrUndefined(manifest.flags.maxParallel),
|
|
1699
|
+
mode: manifest.flags.laneMode || undefined,
|
|
1700
|
+
redWave: reuseRed.group
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
if (reuse.reuse && reuse.noRemainingWork) {
|
|
1704
|
+
// Every task in the stored plan is already ticked. That is not an empty run
|
|
1705
|
+
// to dispatch — it is a change with nothing left to do, and creating a
|
|
1706
|
+
// zero-batch run would report a clean ship that implemented nothing.
|
|
1707
|
+
manifest.plan = { reused: true, status: reuse.status, reason: reuse.reason }
|
|
1708
|
+
manifest.notes.push(
|
|
1709
|
+
'NO REMAINING WORK: every task in the stored plan is already complete — nothing was dispatched'
|
|
1710
|
+
)
|
|
1711
|
+
manifest.commitSkipped = true
|
|
1712
|
+
return finishStep(ctx, manifest, makeStep({ action: 'close', then: { argv: ['run', 'close'] } }))
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (reuse.reuse && reuse.narrowed) {
|
|
1716
|
+
manifest.plan = { reused: true, status: reuse.status, reason: reuse.reason }
|
|
1717
|
+
writeWork(root, 'plan-reuse.json', reuse.narrowed)
|
|
1718
|
+
return finishStep(ctx, manifest, adoptPlan(ctx, manifest, reuse.narrowed))
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
manifest.plan = { reused: false, status: reuse.status, reason: reuse.reason }
|
|
1722
|
+
writeManifest(root, manifest)
|
|
1723
|
+
|
|
1724
|
+
// No reusable plan: one spawn, whose whole job is to write `classified.json`.
|
|
1725
|
+
// It never runs the CLI — `run classified` does coverage, the planner, the
|
|
1726
|
+
// plan file and the state creation afterwards (design D7).
|
|
1727
|
+
return finishStep(ctx, manifest, makeStep({
|
|
1728
|
+
action: 'classify',
|
|
1729
|
+
then: { argv: ['run', 'classified', '--classified', CLASSIFIED_PATH] },
|
|
1730
|
+
spawns: [
|
|
1731
|
+
spawn(root, {
|
|
1732
|
+
label: 'plan-waves',
|
|
1733
|
+
kind: 'planner',
|
|
1734
|
+
model: null,
|
|
1735
|
+
effort: null,
|
|
1736
|
+
schema: PLANNER_SCHEMA,
|
|
1737
|
+
prompt: assemblePlannerPrompt({ change, classifiedPath: CLASSIFIED_PATH })
|
|
1738
|
+
})
|
|
1739
|
+
]
|
|
1740
|
+
}))
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function numberOrUndefined(value) {
|
|
1744
|
+
return Number.isInteger(value) ? value : undefined
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/** Turn a plan into a run state, log the run start, and emit the first step. */
|
|
1748
|
+
function adoptPlan(ctx, manifest, plan) {
|
|
1749
|
+
const { root } = ctx
|
|
1750
|
+
let state
|
|
1751
|
+
try {
|
|
1752
|
+
state = createRunState(plan, {
|
|
1753
|
+
maxParallel: numberOrUndefined(manifest.flags.maxParallel),
|
|
1754
|
+
change: manifest.change
|
|
1755
|
+
})
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
return haltStep(`wave-state create failed: ${(err && err.message) || String(err)}`)
|
|
1758
|
+
}
|
|
1759
|
+
writeState(root, state)
|
|
1760
|
+
manifest.runId = typeof state.runId === 'string' ? state.runId : null
|
|
1761
|
+
|
|
1762
|
+
const started = appendRunLogEvent(root, {
|
|
1763
|
+
runId: manifest.runId || undefined,
|
|
1764
|
+
change: manifest.change,
|
|
1765
|
+
type: 'run-start',
|
|
1766
|
+
mode: manifest.flags.mode || 'checkpoint',
|
|
1767
|
+
strict: manifest.flags.strict === true,
|
|
1768
|
+
host: hostId(manifest) || undefined,
|
|
1769
|
+
// `undefined`, not a placeholder: a host with no session records absent, and
|
|
1770
|
+
// the run is unaffected either way — nothing downstream reads this to decide
|
|
1771
|
+
// anything, and `run-log check` does not require it.
|
|
1772
|
+
sessionId: hostSessionId(manifest, ctx.env) || undefined
|
|
1773
|
+
})
|
|
1774
|
+
if (!started.written) ctx.warn(`run-start not recorded: ${started.reason}`)
|
|
1775
|
+
|
|
1776
|
+
const { step, ok } = ctx.deps.logWaveMutation(root, {
|
|
1777
|
+
source: 'create',
|
|
1778
|
+
change: manifest.change,
|
|
1779
|
+
state
|
|
1780
|
+
})
|
|
1781
|
+
if (!ok) ctx.warn('the wave-state trajectory append failed on run start')
|
|
1782
|
+
ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' })
|
|
1783
|
+
return decorate(ctx, step, manifest)
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// --- run classified (design D7) ---------------------------------------------
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Which section, if any, runs as the leading failing-test wave.
|
|
1790
|
+
*
|
|
1791
|
+
* The execution half of the TDD task shape `/interlock:spec` authors. It is
|
|
1792
|
+
* resolved HERE rather than in the planner because it comes from `tasks.md` —
|
|
1793
|
+
* `lib/waves.mjs` is pure and cannot read a file — and rather than from the
|
|
1794
|
+
* classifier because the shape is a fact the author wrote down, not a judgement
|
|
1795
|
+
* worth re-taking on every run. That is also why nothing in the classifier
|
|
1796
|
+
* prompt changed for this: a change nobody authored for TDD is classified today
|
|
1797
|
+
* exactly as it was yesterday.
|
|
1798
|
+
*
|
|
1799
|
+
* Precedence, and every branch says what it did:
|
|
1800
|
+
* 1. `--no-tdd` refuses the shape outright.
|
|
1801
|
+
* 2. The explicit `tasks.md` marker wins, with no flag needed.
|
|
1802
|
+
* 3. `--tdd` with no marker falls back to STRUCTURE: the lowest section whose
|
|
1803
|
+
* classified tasks are all test tasks. This is what makes the flag useful
|
|
1804
|
+
* on a change written test-first without the exact heading.
|
|
1805
|
+
* 4. Otherwise there is no red wave, which is the ordinary case.
|
|
1806
|
+
*
|
|
1807
|
+
* @param {{tddMode: string|null, redSection: number|null, tasks?: Array}} input
|
|
1808
|
+
* `tasks` is the classification. OMIT it where no classification exists yet —
|
|
1809
|
+
* the plan-reuse check at `runStart` runs before the classifier — and step 3
|
|
1810
|
+
* is skipped silently rather than reported as unavailable, because "we cannot
|
|
1811
|
+
* answer that yet" is not the same claim as "there is no red wave here". The
|
|
1812
|
+
* cost is that a structurally-inferred shape does not match a stored
|
|
1813
|
+
* fingerprint and re-plans; the alternative is reusing a plan built for the
|
|
1814
|
+
* other shape, which is the failure the fingerprint exists to prevent.
|
|
1815
|
+
* @returns {{group: number|undefined, banners: string[]}}
|
|
1816
|
+
*/
|
|
1817
|
+
export function resolveRedWave({ tddMode, redSection, tasks }) {
|
|
1818
|
+
const banners = []
|
|
1819
|
+
const marker = Number.isInteger(redSection) ? redSection : null
|
|
1820
|
+
|
|
1821
|
+
if (tddMode === 'no-tdd') {
|
|
1822
|
+
if (marker !== null) {
|
|
1823
|
+
banners.push(
|
|
1824
|
+
`TDD SHAPE REFUSED: tasks.md section ${marker} is marked as a leading failing-test wave, ` +
|
|
1825
|
+
`and --no-tdd was passed — its tests defer to the trailing test wave like any other`
|
|
1826
|
+
)
|
|
1827
|
+
}
|
|
1828
|
+
return { group: undefined, banners }
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (marker !== null) return { group: marker, banners }
|
|
1832
|
+
|
|
1833
|
+
if (tddMode === 'tdd' && Array.isArray(tasks)) {
|
|
1834
|
+
const groups = [...new Set(tasks.map(t => t && t.group).filter(Number.isInteger))].sort(
|
|
1835
|
+
(a, b) => a - b
|
|
1836
|
+
)
|
|
1837
|
+
const lowest = groups[0]
|
|
1838
|
+
const inLowest = tasks.filter(t => t && t.group === lowest)
|
|
1839
|
+
if (lowest !== undefined && inLowest.length && inLowest.every(t => t.isTestTask === true)) {
|
|
1840
|
+
banners.push(
|
|
1841
|
+
`TDD SHAPE INFERRED: tasks.md carries no "${RED_SECTION_MARKER}" heading, but every task ` +
|
|
1842
|
+
`classified in section ${lowest} is a test task, so --tdd is honoured against that section`
|
|
1843
|
+
)
|
|
1844
|
+
return { group: lowest, banners }
|
|
1845
|
+
}
|
|
1846
|
+
banners.push(
|
|
1847
|
+
`TDD SHAPE UNAVAILABLE: --tdd was passed, but tasks.md has no "${RED_SECTION_MARKER}" ` +
|
|
1848
|
+
`heading and the first section is not all test tasks — there is no failing suite to run ` +
|
|
1849
|
+
`first, so this run is planned in the ordinary shape`
|
|
1850
|
+
)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
return { group: undefined, banners }
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* The mechanical work after the classifier: coverage, the planner, the plan
|
|
1858
|
+
* file, the run state and the first step.
|
|
1859
|
+
*
|
|
1860
|
+
* The Workflow driver used to ask the classifying agent to run all four commands
|
|
1861
|
+
* itself. Four CLI calls made by the party whose output they check is four
|
|
1862
|
+
* chances for a coverage gap to be reported as clean.
|
|
1863
|
+
*/
|
|
1864
|
+
export function runClassified(ctx, { classifiedPath }) {
|
|
1865
|
+
const { root } = ctx
|
|
1866
|
+
const manifest = readManifest(root)
|
|
1867
|
+
if (!manifest) return noManifest()
|
|
1868
|
+
const capped = countStep(root, manifest)
|
|
1869
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
1870
|
+
|
|
1871
|
+
const classified = readJson(join(root, classifiedPath)) || readJson(classifiedPath)
|
|
1872
|
+
if (!classified) {
|
|
1873
|
+
return finishStep(ctx, manifest, haltStep(
|
|
1874
|
+
`wave planning failed: no readable ${classifiedPath} from the classify step`
|
|
1875
|
+
))
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const listed = Array.isArray(classified)
|
|
1879
|
+
? classified
|
|
1880
|
+
: Array.isArray(classified.tasks)
|
|
1881
|
+
? classified.tasks
|
|
1882
|
+
: []
|
|
1883
|
+
const inspection = inspectChange(root, manifest.change)
|
|
1884
|
+
const coverage = planCoverage(inspection.tasks.items, listed)
|
|
1885
|
+
if (!coverage.ok) {
|
|
1886
|
+
return finishStep(ctx, manifest, haltStep(
|
|
1887
|
+
`plan omitted unchecked tasks: ${(coverage.omitted || []).join(', ') || 'classified.json does not cover remaining checkboxes'}`
|
|
1888
|
+
))
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Resolved before the planner and reused by the fingerprint, so a cached plan
|
|
1892
|
+
// is never reused across a change of shape. Both readers must be given the
|
|
1893
|
+
// SAME value — a plan built with a red wave and fingerprinted without one is a
|
|
1894
|
+
// plan the next run would reuse for the wrong shape.
|
|
1895
|
+
const red = resolveRedWave({
|
|
1896
|
+
tddMode: manifest.flags.tddMode || null,
|
|
1897
|
+
redSection: inspection.tasks.redSection,
|
|
1898
|
+
tasks: listed
|
|
1899
|
+
})
|
|
1900
|
+
// Deduplicated against what `runStart` already said. The marker and --no-tdd
|
|
1901
|
+
// branches are reachable from both sites and produce the same string, so
|
|
1902
|
+
// pushing blind would print the same banner twice in one run's close.
|
|
1903
|
+
for (const banner of red.banners) {
|
|
1904
|
+
if (!manifest.banners.includes(banner)) manifest.banners.push(banner)
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
let plan
|
|
1908
|
+
try {
|
|
1909
|
+
plan = planWaves(classified, {
|
|
1910
|
+
maxParallel: numberOrUndefined(manifest.flags.maxParallel),
|
|
1911
|
+
mode: manifest.flags.laneMode || undefined,
|
|
1912
|
+
redWave: red.group
|
|
1913
|
+
})
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
return finishStep(ctx, manifest, haltStep(`waves failed: ${(err && err.message) || String(err)}`))
|
|
1916
|
+
}
|
|
1917
|
+
// The planner adjudicates the claim against the classification and can refuse
|
|
1918
|
+
// it (a section with no test task, a section that is not first). Its refusal
|
|
1919
|
+
// is in `plan.warnings`; surfaced as a banner too, because a shape the
|
|
1920
|
+
// operator asked for and did not get must not be one line in a plan file.
|
|
1921
|
+
if (red.group !== undefined && plan.redWave === null) {
|
|
1922
|
+
for (const warning of plan.warnings) {
|
|
1923
|
+
if (warning.startsWith('red wave refused:')) {
|
|
1924
|
+
manifest.banners.push(`TDD SHAPE REFUSED: ${warning.slice('red wave refused: '.length)}`)
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
writeWork(root, 'plan.json', plan)
|
|
1929
|
+
|
|
1930
|
+
// Store the fingerprint of the artifacts this plan was derived from, so a
|
|
1931
|
+
// later run of the same unedited change reuses this plan instead of re-reading
|
|
1932
|
+
// everything. It never fails the run: a fingerprint that could not be written
|
|
1933
|
+
// costs the NEXT run a classifier pass, which is what every run used to pay —
|
|
1934
|
+
// and the failure is said out loud rather than discovered as a slow run.
|
|
1935
|
+
try {
|
|
1936
|
+
const written = writeFingerprint(
|
|
1937
|
+
root,
|
|
1938
|
+
computeFingerprint(root, manifest.change, {
|
|
1939
|
+
maxParallel: numberOrUndefined(manifest.flags.maxParallel),
|
|
1940
|
+
mode: manifest.flags.laneMode || undefined,
|
|
1941
|
+
// The RESOLVED wave, not the requested one: what this plan was actually
|
|
1942
|
+
// built under is what a later run has to match to reuse it.
|
|
1943
|
+
redWave: plan.redWave === null ? undefined : plan.redWave
|
|
1944
|
+
})
|
|
1945
|
+
)
|
|
1946
|
+
if (!written.written) {
|
|
1947
|
+
manifest.banners.push(
|
|
1948
|
+
'PLAN FINGERPRINT NOT STORED: this plan cannot be proven current later, so the next run ' +
|
|
1949
|
+
`will re-classify every artifact from scratch (${written.reason})`
|
|
1950
|
+
)
|
|
1951
|
+
}
|
|
1952
|
+
} catch (err) {
|
|
1953
|
+
manifest.banners.push(
|
|
1954
|
+
'PLAN FINGERPRINT NOT STORED: this plan cannot be proven current later, so the next run ' +
|
|
1955
|
+
`will re-classify every artifact from scratch (${(err && err.message) || String(err)})`
|
|
1956
|
+
)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
return finishStep(ctx, manifest, adoptPlan(ctx, manifest, plan))
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// --- run record-batch (design D5) -------------------------------------------
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Fold, adjudicate, record and tick — one CLI call where the loop used to spend
|
|
1966
|
+
* three agent turns.
|
|
1967
|
+
*
|
|
1968
|
+
* A lane with any failed task is neither folded nor ticked, and its worktree is
|
|
1969
|
+
* named rather than removed: folding a partial write from a lane that stopped
|
|
1970
|
+
* mid-way is exactly the silent-overwrite defect the merge policy refuses.
|
|
1971
|
+
*/
|
|
1972
|
+
export function runRecordBatch(ctx, { results }) {
|
|
1973
|
+
const { root } = ctx
|
|
1974
|
+
const manifest = readManifest(root)
|
|
1975
|
+
if (!manifest) return noManifest()
|
|
1976
|
+
const capped = countStep(root, manifest)
|
|
1977
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
1978
|
+
|
|
1979
|
+
const state = readState(root)
|
|
1980
|
+
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
1981
|
+
const pending = nextStep(state)
|
|
1982
|
+
const lanes = lanesOf(pending)
|
|
1983
|
+
if (!lanes.length) {
|
|
1984
|
+
return finishStep(ctx, manifest, haltStep(
|
|
1985
|
+
`record-batch was called while the run state was at "${pending.action}", which dispatches no lanes`
|
|
1986
|
+
))
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
const banners = []
|
|
1990
|
+
const reported = []
|
|
1991
|
+
const unattempted = []
|
|
1992
|
+
const laneFoldCandidates = []
|
|
1993
|
+
const laneWorktreesPreserved = []
|
|
1994
|
+
const isolate = manifest.flags.isolateWaves === true
|
|
1995
|
+
const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
|
|
1996
|
+
|
|
1997
|
+
recordUsage(manifest, results, pending.wave)
|
|
1998
|
+
|
|
1999
|
+
lanes.forEach((lane, i) => {
|
|
2000
|
+
const outcomes = laneOutcomes(lane, results[i])
|
|
2001
|
+
for (const o of outcomes) {
|
|
2002
|
+
if (o.outcome === 'not-attempted') unattempted.push(o.id)
|
|
2003
|
+
else {
|
|
2004
|
+
reported.push({
|
|
2005
|
+
id: o.id,
|
|
2006
|
+
ok: o.outcome === 'ok',
|
|
2007
|
+
error: o.error,
|
|
2008
|
+
handoff: o.handoff,
|
|
2009
|
+
filesChanged: o.filesChanged
|
|
2010
|
+
})
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
if (!isolate) return
|
|
2014
|
+
const label = laneLabel(lane)
|
|
2015
|
+
// On a runner host the path is DERIVED, not read off the result: the driver
|
|
2016
|
+
// created that exact directory from the step that named it, so asking the
|
|
2017
|
+
// agent where it ran would put the fold at the mercy of a field a vendor CLI
|
|
2018
|
+
// has no reason to fill in. The Workflow host still reports its own `pwd`.
|
|
2019
|
+
const worktreePath = driverWorktrees
|
|
2020
|
+
? join(root, laneWorktreePath(pending.wave, label))
|
|
2021
|
+
: results[i] && typeof results[i].worktreePath === 'string'
|
|
2022
|
+
? results[i].worktreePath.trim()
|
|
2023
|
+
: ''
|
|
2024
|
+
if (outcomes.some(o => o.outcome === 'failed')) {
|
|
2025
|
+
if (worktreePath) laneWorktreesPreserved.push({ label, worktreePath })
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
laneFoldCandidates.push({
|
|
2029
|
+
label,
|
|
2030
|
+
worktreePath: worktreePath || undefined,
|
|
2031
|
+
reportedFiles: outcomes.flatMap(o => (Array.isArray(o.filesChanged) ? o.filesChanged : []))
|
|
2032
|
+
})
|
|
2033
|
+
})
|
|
2034
|
+
|
|
2035
|
+
// A task nobody ran is not a task that failed. It is left unticked and
|
|
2036
|
+
// uncounted on purpose — spending the failure budget on the three tasks
|
|
2037
|
+
// sitting behind one real blocker would halt a run that has one problem.
|
|
2038
|
+
if (unattempted.length) {
|
|
2039
|
+
banners.push(
|
|
2040
|
+
`LANE STOPPED EARLY: ${unattempted.join(', ')} not attempted after an earlier task in ` +
|
|
2041
|
+
`the same lane failed — not counted as failures, and still unchecked in tasks.md`
|
|
2042
|
+
)
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Fold this batch's successful lanes back into the shared tree before moving
|
|
2046
|
+
// on — the NEXT batch's merge base is read off this tree.
|
|
2047
|
+
if (isolate && laneFoldCandidates.length) {
|
|
2048
|
+
// The base the lanes actually forked from, captured at dispatch and carried
|
|
2049
|
+
// on the manifest across the driver round trip. `pending` is a pure
|
|
2050
|
+
// wave-state step and never carries it. Re-reading HEAD here is the
|
|
2051
|
+
// fallback, and it is a DIFFERENT commit whenever anything landed on the
|
|
2052
|
+
// shared tree while the batch ran — so it is spoken rather than taken
|
|
2053
|
+
// silently.
|
|
2054
|
+
const stored = typeof manifest.mergeBase === 'string' && manifest.mergeBase ? manifest.mergeBase : null
|
|
2055
|
+
const base = stored || headCommit(ctx)
|
|
2056
|
+
if (!stored) {
|
|
2057
|
+
banners.push(
|
|
2058
|
+
`MERGE BASE RE-READ AFTER THE BATCH: the base captured at dispatch was not carried to the ` +
|
|
2059
|
+
`fold, so lane diffs are taken against the post-batch HEAD (${base || 'unreadable'})`
|
|
2060
|
+
)
|
|
2061
|
+
}
|
|
2062
|
+
const merged = base ? ctx.deps.runMergeLanes(root, laneFoldCandidates, base) : null
|
|
2063
|
+
if (Array.isArray(merged && merged.cleanupWarnings) && merged.cleanupWarnings.length) {
|
|
2064
|
+
banners.push(
|
|
2065
|
+
`LANE WORKTREE CLEANUP WARNING: ${JSON.stringify(merged.cleanupWarnings)} — the fold applied, ` +
|
|
2066
|
+
`removal of the worktree itself did not`
|
|
2067
|
+
)
|
|
2068
|
+
}
|
|
2069
|
+
if (!merged || merged.status !== 'clean') {
|
|
2070
|
+
const survivors = [
|
|
2071
|
+
...(merged && Array.isArray(merged.survivingWorktrees) ? merged.survivingWorktrees : []),
|
|
2072
|
+
...laneWorktreesPreserved
|
|
2073
|
+
]
|
|
2074
|
+
.map(w => `${w.label}: ${w.worktreePath}`)
|
|
2075
|
+
.join('; ')
|
|
2076
|
+
return finishStep(ctx, manifest, haltStep(
|
|
2077
|
+
`merge-lanes halted on wave ${pending.wave}: ` +
|
|
2078
|
+
(merged
|
|
2079
|
+
? merged.status === 'collision'
|
|
2080
|
+
? `real collision on ${JSON.stringify(merged.collisions)}`
|
|
2081
|
+
: `unresolved lane(s) ${JSON.stringify(merged.unresolved)}`
|
|
2082
|
+
: 'the shared-tree base commit could not be read, so no fold was attempted') +
|
|
2083
|
+
` — surviving worktrees: ${survivors || '(none)'}`,
|
|
2084
|
+
{ banners }
|
|
2085
|
+
))
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (isolate && laneWorktreesPreserved.length) {
|
|
2089
|
+
banners.push(
|
|
2090
|
+
`LANE WORKTREE PRESERVED: ${laneWorktreesPreserved.map(w => `${w.label} at ${w.worktreePath}`).join('; ')} ` +
|
|
2091
|
+
`— the lane failed and its writes were not folded into the shared tree`
|
|
2092
|
+
)
|
|
2093
|
+
}
|
|
2094
|
+
// Spent. The next isolated batch captures its own base at dispatch, and a base
|
|
2095
|
+
// left behind would be the previous batch's — older than the tree the next
|
|
2096
|
+
// batch's lanes fork from.
|
|
2097
|
+
manifest.mergeBase = null
|
|
2098
|
+
|
|
2099
|
+
// Record. The observed path set is read here, never taken from the results
|
|
2100
|
+
// file: that file is written by the agent being audited, and an audit whose
|
|
2101
|
+
// input its subject supplies is not an audit.
|
|
2102
|
+
const after = recordBatchResult(state, { tasks: reported }, {
|
|
2103
|
+
changedPaths: ctx.deps.observedChangedPaths(root)
|
|
2104
|
+
})
|
|
2105
|
+
writeState(root, after)
|
|
2106
|
+
const { step: nextRaw, ok: logOk } = ctx.deps.logWaveMutation(root, {
|
|
2107
|
+
source: 'record-batch',
|
|
2108
|
+
change: manifest.change,
|
|
2109
|
+
state: after
|
|
2110
|
+
})
|
|
2111
|
+
if (!logOk) ctx.warn('the wave-state trajectory append failed on record-batch')
|
|
2112
|
+
ctx.deps.logAgentSpawns(root, {
|
|
2113
|
+
state: after,
|
|
2114
|
+
change: manifest.change,
|
|
2115
|
+
step: nextRaw,
|
|
2116
|
+
source: 'record-batch'
|
|
2117
|
+
})
|
|
2118
|
+
|
|
2119
|
+
// One authority, read once, for the tick and for the tallies alike.
|
|
2120
|
+
const verdict = adjudicateBatches([reported], recordedOutcomes(batchOutcomes(state, after)))
|
|
2121
|
+
if (verdict.claimDerived) banners.push(claimDerivedBanner())
|
|
2122
|
+
if (verdict.overrides.length) banners.push(overrideBanner(verdict.overrides))
|
|
2123
|
+
|
|
2124
|
+
verdict.waves.forEach(counts => {
|
|
2125
|
+
manifest.waves.push({
|
|
2126
|
+
wave: pending.wave,
|
|
2127
|
+
kind: pending.action,
|
|
2128
|
+
lanes: lanes.length,
|
|
2129
|
+
ok: counts.ok,
|
|
2130
|
+
failed: counts.failed,
|
|
2131
|
+
failedIds: counts.failedIds,
|
|
2132
|
+
// The lane that stopped early, plus anything the recorder found no result
|
|
2133
|
+
// for at all. Neither is ticked and neither is a failure.
|
|
2134
|
+
notAttempted: [
|
|
2135
|
+
...unattempted,
|
|
2136
|
+
...counts.recordedNotAttempted.filter(id => !unattempted.includes(id))
|
|
2137
|
+
]
|
|
2138
|
+
})
|
|
2139
|
+
})
|
|
2140
|
+
|
|
2141
|
+
// Ticked from what the run RECORDED, never from what an agent claimed — a box
|
|
2142
|
+
// ticked from a claim is how unimplemented work ships behind a "[x]".
|
|
2143
|
+
if (verdict.tickIds.length) {
|
|
2144
|
+
let ticked
|
|
2145
|
+
try {
|
|
2146
|
+
ticked = tickTasks(root, manifest.change, verdict.tickIds)
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
ticked = { missing: verdict.tickIds, error: (err && err.message) || String(err) }
|
|
2149
|
+
}
|
|
2150
|
+
if (ticked.missing && ticked.missing.length) {
|
|
2151
|
+
banners.push(
|
|
2152
|
+
`TASK TICK FAILED: could not mark ${ticked.missing.join(', ')} ` +
|
|
2153
|
+
`complete — the work was done, the checkbox was not`
|
|
2154
|
+
)
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
manifest.banners.push(...banners)
|
|
2159
|
+
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// --- run judge --------------------------------------------------------------
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* Judge a verification's reported results and take the branch its verdict
|
|
2166
|
+
* implies.
|
|
2167
|
+
*
|
|
2168
|
+
* The verdict is rendered here and nowhere else. The agent that ran the suites
|
|
2169
|
+
* reports exit codes and counts; whether the run may continue is not its
|
|
2170
|
+
* question, and both drivers used to let it answer.
|
|
2171
|
+
*/
|
|
2172
|
+
export function runJudge(ctx, { context, results }) {
|
|
2173
|
+
const { root } = ctx
|
|
2174
|
+
const manifest = readManifest(root)
|
|
2175
|
+
if (!manifest) return noManifest()
|
|
2176
|
+
const capped = countStep(root, manifest)
|
|
2177
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
2178
|
+
|
|
2179
|
+
// Outside the waves, but not outside the run's spend (design D9).
|
|
2180
|
+
recordUsage(manifest, results)
|
|
2181
|
+
|
|
2182
|
+
const state = readState(root)
|
|
2183
|
+
const plan = readJson(join(root, WORK_DIR, `vplan-${context}.json`))
|
|
2184
|
+
const banners = []
|
|
2185
|
+
|
|
2186
|
+
// Close the interval the verify step opened. This is the other half of the
|
|
2187
|
+
// inter-wave verify budget: the step marked when it dispatched, and only this
|
|
2188
|
+
// process — the next one — can see how long the agent between them took. An
|
|
2189
|
+
// unparseable or negative mark contributes nothing rather than a wrong number,
|
|
2190
|
+
// and the mark is cleared either way so it can never be counted twice.
|
|
2191
|
+
if (manifest.verifyStartedAt) {
|
|
2192
|
+
const spent = Date.now() - Date.parse(manifest.verifyStartedAt)
|
|
2193
|
+
if (Number.isFinite(spent) && spent >= 0) {
|
|
2194
|
+
manifest.verifyElapsedMs = (numberOrUndefined(manifest.verifyElapsedMs) || 0) + Math.round(spent)
|
|
2195
|
+
}
|
|
2196
|
+
manifest.verifyStartedAt = null
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// A verification the CLI skipped: no agent ran, so there is nothing to judge.
|
|
2200
|
+
// The skip is recorded as the run's own, with the reason the planner gave — a
|
|
2201
|
+
// spent budget ahead of whichever kind happens to sit first in `skipped`, so
|
|
2202
|
+
// the recorded reason matches the one the step announced.
|
|
2203
|
+
const skipped = !plan || !Array.isArray(plan.steps) || !plan.steps.length
|
|
2204
|
+
const reason = skipped
|
|
2205
|
+
? (plan && plan.budgetExceeded ? SKIP_REASONS.BUDGET_EXCEEDED : null) ||
|
|
2206
|
+
(plan && plan.skipped && plan.skipped[0] && plan.skipped[0].reason) ||
|
|
2207
|
+
'no detectable commands'
|
|
2208
|
+
: null
|
|
2209
|
+
|
|
2210
|
+
let verdict = null
|
|
2211
|
+
if (!skipped) {
|
|
2212
|
+
const reportedSteps = flattenVerifyResults(results)
|
|
2213
|
+
// A planned step nobody reported on is NOT VERIFIED, and not verified is
|
|
2214
|
+
// not a pass. The judge scores what it is given, so an empty or partial
|
|
2215
|
+
// report would otherwise come back clean — which is the completion gate's
|
|
2216
|
+
// one failure direction: an absent verdict read as a passing one.
|
|
2217
|
+
const unreported = plan.steps
|
|
2218
|
+
.map(s => s.kind)
|
|
2219
|
+
.filter(kind => !reportedSteps.some(r => r && r.kind === kind))
|
|
2220
|
+
if (unreported.length) {
|
|
2221
|
+
const reason =
|
|
2222
|
+
`verification reported no result for ${unreported.join(', ')} — ` +
|
|
2223
|
+
`an unverified step is not a passing one, and an unverified run does not commit`
|
|
2224
|
+
banners.push(reason)
|
|
2225
|
+
manifest.banners.push(...banners)
|
|
2226
|
+
return finishStep(ctx, manifest, haltStep(reason, { banners }))
|
|
2227
|
+
}
|
|
2228
|
+
verdict = judgeVerification(plan, reportedSteps, { context })
|
|
2229
|
+
for (const banner of verdict.banners || []) banners.push(banner)
|
|
2230
|
+
logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
|
|
2231
|
+
} else {
|
|
2232
|
+
banners.push(`VERIFICATION SKIPPED: reason=${reason}`)
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
if (context === 'inter-wave') {
|
|
2236
|
+
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read', { banners }))
|
|
2237
|
+
const record = skipped
|
|
2238
|
+
? { skipped: true, reason }
|
|
2239
|
+
: {
|
|
2240
|
+
ok: !verdict.halt,
|
|
2241
|
+
errors: (verdict.signals || []).filter(s => s.level === 'halt').map(s => s.message),
|
|
2242
|
+
blocksNextWave: verdict.halt === true
|
|
2243
|
+
}
|
|
2244
|
+
const after = recordVerifyResult(state, record)
|
|
2245
|
+
writeState(root, after)
|
|
2246
|
+
const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
|
|
2247
|
+
source: 'record-verify',
|
|
2248
|
+
change: manifest.change,
|
|
2249
|
+
state: after
|
|
2250
|
+
})
|
|
2251
|
+
if (!ok) ctx.warn('the wave-state trajectory append failed on record-verify')
|
|
2252
|
+
manifest.banners.push(...banners)
|
|
2253
|
+
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// Final. The completion gate: an unverified run does not commit, and an absent
|
|
2257
|
+
// verdict is "not verified" rather than a passing one.
|
|
2258
|
+
if (verdict && verdict.e2e && verdict.e2e.status === 'failed') {
|
|
2259
|
+
banners.push(
|
|
2260
|
+
`E2E FAILED (non-blocking by policy): ${verdict.e2e.detail || 'see the run log'}`
|
|
2261
|
+
)
|
|
2262
|
+
}
|
|
2263
|
+
manifest.banners.push(...banners)
|
|
2264
|
+
if (verdict && verdict.halt) {
|
|
2265
|
+
return finishStep(ctx, manifest, haltStep(
|
|
2266
|
+
verdict.reason || 'final verification halted the run',
|
|
2267
|
+
{ banners }
|
|
2268
|
+
))
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// The handoff artifacts (design D5). They come AFTER final verification and
|
|
2272
|
+
// before the commit, exactly where the retired inline tail ran them, and the
|
|
2273
|
+
// two decisions they used to ask the agent to make for itself are made here.
|
|
2274
|
+
if (manifest.flags.handoff === true || manifest.flags.conformance === true) {
|
|
2275
|
+
return finishStep(ctx, manifest, handoffStep(ctx, manifest, banners))
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
return finishStep(ctx, manifest, commitStep(ctx, manifest, banners))
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* The handoff step: the manual-test-plan decision and the conformance
|
|
2283
|
+
* checklist, computed here and inlined (design D5).
|
|
2284
|
+
*
|
|
2285
|
+
* `interlock surface` and `interlock conformance` used to be commands the
|
|
2286
|
+
* handoff agent ran with `--changed <files changed by this run>` — a path list
|
|
2287
|
+
* the agent supplied about a diff it was writing artifacts for. Both engines
|
|
2288
|
+
* are called in-process now, over the run's OWN observed changed paths, and
|
|
2289
|
+
* the agent receives their answers rather than the questions.
|
|
2290
|
+
*/
|
|
2291
|
+
function handoffStep(ctx, manifest, banners = []) {
|
|
2292
|
+
const { root } = ctx
|
|
2293
|
+
const change = manifest.change
|
|
2294
|
+
const changedPaths = ctx.deps.observedChangedPaths(root)
|
|
2295
|
+
|
|
2296
|
+
// `null` when `--handoff` is off: the manual test plan, the code explanation
|
|
2297
|
+
// and the learnings capture belong to `handoff`, and a conformance-only run
|
|
2298
|
+
// asks for none of them (see `assembleHandoffPrompt`).
|
|
2299
|
+
let needsManualTestPlan = null
|
|
2300
|
+
let testPlanReason
|
|
2301
|
+
if (manifest.flags.handoff === true) {
|
|
2302
|
+
const surface = classifySurface(changedPaths)
|
|
2303
|
+
needsManualTestPlan = surface.needsManualTestPlan
|
|
2304
|
+
if (!needsManualTestPlan) {
|
|
2305
|
+
testPlanReason = changedPaths.length
|
|
2306
|
+
? `none of the ${changedPaths.length} changed path(s) is a UI-testable surface`
|
|
2307
|
+
: 'this run observed no changed paths, so there is no UI surface to test'
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
let scenarios = null
|
|
2312
|
+
const extra = [...banners]
|
|
2313
|
+
if (manifest.flags.conformance === true) {
|
|
2314
|
+
const checklist = buildChecklist(root, change, { changed: changedPaths })
|
|
2315
|
+
if (checklist.skipped) {
|
|
2316
|
+
// Said out loud rather than handed over as an empty checklist: "no
|
|
2317
|
+
// scenarios" and "the checklist could not be built" are different facts,
|
|
2318
|
+
// and only one of them is about the change.
|
|
2319
|
+
extra.push(`CONFORMANCE CHECKLIST UNAVAILABLE: ${checklist.reason}`)
|
|
2320
|
+
scenarios = []
|
|
2321
|
+
} else {
|
|
2322
|
+
scenarios = checklist.scenarios
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
return makeStep({
|
|
2327
|
+
action: 'handoff',
|
|
2328
|
+
banners: extra,
|
|
2329
|
+
needsManualTestPlan,
|
|
2330
|
+
scenarios: scenarios ? scenarios.length : null,
|
|
2331
|
+
then: { argv: ['run', 'commit'] },
|
|
2332
|
+
spawns: [
|
|
2333
|
+
spawn(root, {
|
|
2334
|
+
label: 'handoff',
|
|
2335
|
+
kind: 'handoff',
|
|
2336
|
+
model: null,
|
|
2337
|
+
effort: null,
|
|
2338
|
+
schema: HANDOFF_RESULT_SCHEMA,
|
|
2339
|
+
prompt: assembleHandoffPrompt({
|
|
2340
|
+
change,
|
|
2341
|
+
needsManualTestPlan,
|
|
2342
|
+
testPlanReason,
|
|
2343
|
+
conformance: scenarios,
|
|
2344
|
+
learnings: manifest.flags.handoff === true
|
|
2345
|
+
})
|
|
2346
|
+
})
|
|
2347
|
+
]
|
|
2348
|
+
})
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** The commit step, or the close a run told not to commit takes instead. */
|
|
2352
|
+
function commitStep(ctx, manifest, banners = []) {
|
|
2353
|
+
const { root } = ctx
|
|
2354
|
+
if (manifest.flags.noCommit === true) {
|
|
2355
|
+
manifest.notes.push('--no-commit: everything ran, the commit is yours')
|
|
2356
|
+
manifest.commitSkipped = true
|
|
2357
|
+
return makeStep({
|
|
2358
|
+
action: 'close',
|
|
2359
|
+
banners,
|
|
2360
|
+
then: { argv: ['run', 'close'] }
|
|
2361
|
+
})
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
return makeStep({
|
|
2365
|
+
action: 'commit',
|
|
2366
|
+
banners,
|
|
2367
|
+
then: { argv: ['run', 'close'] },
|
|
2368
|
+
spawns: [
|
|
2369
|
+
spawn(root, {
|
|
2370
|
+
label: 'commit',
|
|
2371
|
+
kind: 'commit',
|
|
2372
|
+
model: null,
|
|
2373
|
+
effort: null,
|
|
2374
|
+
schema: COMMIT_SCHEMA,
|
|
2375
|
+
prompt: assembleCommitPrompt({ change: manifest.change, stageIndex: ++manifest.stageIndex })
|
|
2376
|
+
})
|
|
2377
|
+
]
|
|
2378
|
+
})
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* `run commit` — the continuation of the handoff step.
|
|
2383
|
+
*
|
|
2384
|
+
* It records what the handoff writer reported about the artifacts it authored
|
|
2385
|
+
* (the one tail figure the CLI cannot re-derive) and emits the commit. A run
|
|
2386
|
+
* with no handoff step never calls it: `run judge --context final` emits the
|
|
2387
|
+
* commit directly.
|
|
2388
|
+
*/
|
|
2389
|
+
export function runCommit(ctx, { results }) {
|
|
2390
|
+
const { root } = ctx
|
|
2391
|
+
const manifest = readManifest(root)
|
|
2392
|
+
if (!manifest) return noManifest()
|
|
2393
|
+
const capped = countStep(root, manifest)
|
|
2394
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
2395
|
+
|
|
2396
|
+
// Outside the waves, but not outside the run's spend (design D9).
|
|
2397
|
+
recordUsage(manifest, results)
|
|
2398
|
+
|
|
2399
|
+
const reported = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
2400
|
+
const count = value => (Number.isInteger(value) ? value : undefined)
|
|
2401
|
+
manifest.handoff = reported
|
|
2402
|
+
? {
|
|
2403
|
+
ok: reported.ok === true,
|
|
2404
|
+
manualTestPlan: reported.manualTestPlan === true,
|
|
2405
|
+
skipReason: typeof reported.skipReason === 'string' ? reported.skipReason : undefined,
|
|
2406
|
+
learnings: count(reported.learnings),
|
|
2407
|
+
scenariosChecked: count(reported.scenariosChecked),
|
|
2408
|
+
scenariosUnconfirmed: count(reported.scenariosUnconfirmed)
|
|
2409
|
+
}
|
|
2410
|
+
: null
|
|
2411
|
+
if (!manifest.handoff) {
|
|
2412
|
+
// The step ran and reported nothing usable. Named, because a summary with
|
|
2413
|
+
// no handoff line is indistinguishable from a run that was never asked for
|
|
2414
|
+
// one.
|
|
2415
|
+
manifest.notes.push('handoff did not complete: the step returned no usable result')
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
return finishStep(ctx, manifest, commitStep(ctx, manifest))
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/** The `results` entries a verify spawn reports, flattened to the judge's shape. */
|
|
2422
|
+
function flattenVerifyResults(results) {
|
|
2423
|
+
const out = []
|
|
2424
|
+
for (const entry of Array.isArray(results) ? results : []) {
|
|
2425
|
+
if (!entry) continue
|
|
2426
|
+
if (Array.isArray(entry.results)) out.push(...entry.results.filter(Boolean))
|
|
2427
|
+
else if (typeof entry.kind === 'string') out.push(entry)
|
|
2428
|
+
}
|
|
2429
|
+
return out
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// --- run verify-final --------------------------------------------------------
|
|
2433
|
+
|
|
2434
|
+
/**
|
|
2435
|
+
* Emit final verification directly, without re-reading the wave state.
|
|
2436
|
+
*
|
|
2437
|
+
* Used to be the continuation a host called after running its own inline tail
|
|
2438
|
+
* (design D6, `host-tail`, retired by `emit-strict-tail-from-cli`) — the tail
|
|
2439
|
+
* now calls `finalVerifyStep` in-process from `runReviewed`/`runRemediated`
|
|
2440
|
+
* instead, so nothing in this module emits `run verify-final` as a
|
|
2441
|
+
* continuation any more. Left as a directly-callable entry point rather than
|
|
2442
|
+
* removed: `interlock run verify-final` still does exactly what it says.
|
|
2443
|
+
*/
|
|
2444
|
+
export function runVerifyFinal(ctx) {
|
|
2445
|
+
const { root } = ctx
|
|
2446
|
+
const manifest = readManifest(root)
|
|
2447
|
+
if (!manifest) return noManifest()
|
|
2448
|
+
const capped = countStep(root, manifest)
|
|
2449
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
2450
|
+
return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest))
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// --- run replan -------------------------------------------------------------
|
|
2454
|
+
|
|
2455
|
+
/** Apply a revision the replan step produced, or carry on with the next step. */
|
|
2456
|
+
export function runReplan(ctx, { results }) {
|
|
2457
|
+
const { root } = ctx
|
|
2458
|
+
const manifest = readManifest(root)
|
|
2459
|
+
if (!manifest) return noManifest()
|
|
2460
|
+
const capped = countStep(root, manifest)
|
|
2461
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
2462
|
+
|
|
2463
|
+
// Outside the waves, but not outside the run's spend (design D9).
|
|
2464
|
+
recordUsage(manifest, results)
|
|
2465
|
+
|
|
2466
|
+
const state = readState(root)
|
|
2467
|
+
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
2468
|
+
|
|
2469
|
+
const revised = results && results[0] && results[0].revised === true
|
|
2470
|
+
const groups = revised ? readJson(join(root, REPLAN_PATH)) : null
|
|
2471
|
+
if (!revised || !Array.isArray(groups)) {
|
|
2472
|
+
// Nothing to revise is the ordinary outcome, and re-reading the state is
|
|
2473
|
+
// how the loop moves past a replan offer it declined.
|
|
2474
|
+
return finishStep(ctx, manifest, decorate(ctx, nextStep(state), manifest))
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
let after
|
|
2478
|
+
try {
|
|
2479
|
+
after = applyReplan(state, groups)
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
|
|
2482
|
+
}
|
|
2483
|
+
writeState(root, after)
|
|
2484
|
+
const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
|
|
2485
|
+
source: 'replan',
|
|
2486
|
+
change: manifest.change,
|
|
2487
|
+
state: after
|
|
2488
|
+
})
|
|
2489
|
+
if (!ok) ctx.warn('the wave-state trajectory append failed on replan')
|
|
2490
|
+
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// --- run next ---------------------------------------------------------------
|
|
2494
|
+
|
|
2495
|
+
/** Re-read the state and re-emit its step. A pure re-read; it decides nothing. */
|
|
2496
|
+
export function runNext(ctx) {
|
|
2497
|
+
const { root } = ctx
|
|
2498
|
+
const manifest = readManifest(root)
|
|
2499
|
+
if (!manifest) return noManifest()
|
|
2500
|
+
const capped = countStep(root, manifest)
|
|
2501
|
+
if (capped) return recordThen(root, manifest, capped)
|
|
2502
|
+
const state = readState(root)
|
|
2503
|
+
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
2504
|
+
return finishStep(ctx, manifest, decorate(ctx, nextStep(state), manifest))
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// --- helpers used by every subcommand ---------------------------------------
|
|
2508
|
+
|
|
2509
|
+
function noManifest() {
|
|
2510
|
+
return haltStep(
|
|
2511
|
+
`no run manifest at ${MANIFEST_PATH} — a run subcommand was reached without \`interlock run start\``
|
|
2512
|
+
)
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/**
|
|
2516
|
+
* Persist what this step observed and record the continuation it names.
|
|
2517
|
+
*
|
|
2518
|
+
* Every step-yielding call goes through here, so a field an earlier step
|
|
2519
|
+
* accumulated (a banner, a wave row, the stage index) survives into the next
|
|
2520
|
+
* process — each `run` subcommand is its own.
|
|
2521
|
+
*/
|
|
2522
|
+
function finishStep(ctx, manifest, step) {
|
|
2523
|
+
logSpawns(ctx, manifest, step)
|
|
2524
|
+
return recordThen(ctx.root, manifest, step)
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// --- run close (design D10) -------------------------------------------------
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* Close the run: build the receipt, append the outcome, write the terminal
|
|
2531
|
+
* trajectory event, check that the run is reconstructable, and return the
|
|
2532
|
+
* summary both hosts print.
|
|
2533
|
+
*
|
|
2534
|
+
* A halt closes the same way a completion does. The receipt, the outcome record
|
|
2535
|
+
* and the terminal event are what a halted run is FOR — it is the most
|
|
2536
|
+
* informative record this corpus holds, and a run that stopped without writing
|
|
2537
|
+
* one leaves nothing behind to explain why.
|
|
2538
|
+
*
|
|
2539
|
+
* @returns {Promise<{action: 'complete'|'halt', exitCode: number, summary: string, banners: string[]}>}
|
|
2540
|
+
*/
|
|
2541
|
+
export async function runClose(
|
|
2542
|
+
ctx,
|
|
2543
|
+
{ results = [], halt = null, hostBanners = [], hostObserved = null, notify = false, env = process.env } = {}
|
|
2544
|
+
) {
|
|
2545
|
+
const { root } = ctx
|
|
2546
|
+
const absRoot = resolve(root)
|
|
2547
|
+
const slug = projectSlug(absRoot)
|
|
2548
|
+
const manifest = readManifest(root)
|
|
2549
|
+
if (!manifest) {
|
|
2550
|
+
// A halt BEFORE `run start` — a host that refused its own invocation, or a
|
|
2551
|
+
// relay that never reached the first call. There is no run to record, but
|
|
2552
|
+
// the reason is the whole content of this close and must not be dropped for
|
|
2553
|
+
// the absence of a manifest to file it against.
|
|
2554
|
+
const reason = typeof halt === 'string' && halt.trim() ? halt.trim() : null
|
|
2555
|
+
const headline = `SHIP HALTED — ${reason || 'the run ended before it started'}`
|
|
2556
|
+
// The reader who walked away needs THIS outcome most of all: the run never
|
|
2557
|
+
// started, so no trajectory, receipt or outcome record will ever explain it.
|
|
2558
|
+
// The change name is unknown here — there is no manifest to read it from —
|
|
2559
|
+
// so the message carries the headline and the absent run id alone.
|
|
2560
|
+
const push = await attemptPush(notify, env, { headline, change: null, runId: null })
|
|
2561
|
+
const rows = [
|
|
2562
|
+
' run: none — the run halted before a plan was adopted',
|
|
2563
|
+
` project: ${slug}`,
|
|
2564
|
+
` cwd: ${absRoot}`
|
|
2565
|
+
]
|
|
2566
|
+
if (push && push.sent !== undefined) {
|
|
2567
|
+
rows.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
|
|
2568
|
+
}
|
|
2569
|
+
return {
|
|
2570
|
+
schema: STEP_SCHEMA,
|
|
2571
|
+
action: 'halt',
|
|
2572
|
+
then: null,
|
|
2573
|
+
spawns: [],
|
|
2574
|
+
exitCode: 1,
|
|
2575
|
+
banners: Array.isArray(hostBanners) ? hostBanners : [],
|
|
2576
|
+
summary:
|
|
2577
|
+
`${headline}\n\n` +
|
|
2578
|
+
`There is no run manifest at ${MANIFEST_PATH}, so nothing was recorded for this run.\n` +
|
|
2579
|
+
rows.join('\n')
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
const haltReason = typeof halt === 'string' && halt.trim() ? halt.trim() : manifest.halted || null
|
|
2584
|
+
if (haltReason) manifest.halted = haltReason
|
|
2585
|
+
|
|
2586
|
+
// The commit result, when the close is the continuation of a commit step.
|
|
2587
|
+
const committed = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
2588
|
+
if (committed && typeof committed.ok === 'boolean') manifest.commit = committed
|
|
2589
|
+
|
|
2590
|
+
const state = readState(root)
|
|
2591
|
+
const closing = closingFromWaveState(state)
|
|
2592
|
+
const leftoverTaskIds = readLeftoverIds(root, manifest)
|
|
2593
|
+
|
|
2594
|
+
// What only the host could observe, carried in rather than recorded as
|
|
2595
|
+
// unknown: token spend, which comes off the runtime's own counter and which
|
|
2596
|
+
// this process — running BETWEEN agent turns — never sees.
|
|
2597
|
+
//
|
|
2598
|
+
// It used to carry the review, remediation and handoff figures too, because
|
|
2599
|
+
// the strict tail ran inside the Workflow host behind the `host-tail` seam.
|
|
2600
|
+
// The tail is a CLI-emitted program now (`emit-strict-tail-from-cli`), so
|
|
2601
|
+
// those are read off the manifest by `tailFromManifest` and this channel has
|
|
2602
|
+
// narrowed back to spend alone.
|
|
2603
|
+
const observed = hostObserved && typeof hostObserved === 'object' ? hostObserved : {}
|
|
2604
|
+
|
|
2605
|
+
// The tail's own figures, every one of them adjudicated or counted by this
|
|
2606
|
+
// CLI. An absent field stays absent — never zero. `buildReceipt` turns an
|
|
2607
|
+
// absent field into `null`, and `null` reads as unknown, which is a different
|
|
2608
|
+
// fact from a review that found nothing.
|
|
2609
|
+
const tail = tailFromManifest(manifest)
|
|
2610
|
+
|
|
2611
|
+
// Spend, from whichever party actually measured it (design D9). The Workflow
|
|
2612
|
+
// runtime counts tokens itself and carries them in through `--host-observed`;
|
|
2613
|
+
// a runner host reads them off each vendor CLI's envelope, and the run program
|
|
2614
|
+
// summed them as the results arrived. The host's own figures win where it has
|
|
2615
|
+
// any, because the runtime's counter sees turns this process never does.
|
|
2616
|
+
const counted = summarizeUsage(manifest)
|
|
2617
|
+
const hostSpend = Array.isArray(observed.spend) && observed.spend.length ? observed.spend : null
|
|
2618
|
+
const hostTotal =
|
|
2619
|
+
typeof observed.outputTokens === 'number' || observed.outputTokens === null
|
|
2620
|
+
? observed.outputTokens
|
|
2621
|
+
: undefined
|
|
2622
|
+
|
|
2623
|
+
// A host that reports no usage at all records `unknown` everywhere, and says
|
|
2624
|
+
// that once rather than leaving a reader to infer it from empty figures — the
|
|
2625
|
+
// same reason the degradation block is printed on a clean run.
|
|
2626
|
+
if (!hostSpend && hostTotal === undefined && hostCapability(manifest, 'usage') === false) {
|
|
2627
|
+
manifest.notes.push(
|
|
2628
|
+
`TOKEN USAGE NOT REPORTED: the ${hostId(manifest) || 'run'} host's CLI returns no token ` +
|
|
2629
|
+
`accounting, so every wave and the run total are recorded as unknown`
|
|
2630
|
+
)
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// The cache figures, folded from the same two sources on the same terms. A
|
|
2634
|
+
// host's own numbers win where it has any, because a runtime that counts for
|
|
2635
|
+
// itself sees turns this process never does.
|
|
2636
|
+
const hostCacheRead =
|
|
2637
|
+
typeof observed.cacheReadInputTokens === 'number' || observed.cacheReadInputTokens === null
|
|
2638
|
+
? observed.cacheReadInputTokens
|
|
2639
|
+
: undefined
|
|
2640
|
+
const hostCacheCreation =
|
|
2641
|
+
(observed.cacheCreationInputTokens && typeof observed.cacheCreationInputTokens === 'object') ||
|
|
2642
|
+
observed.cacheCreationInputTokens === null
|
|
2643
|
+
? observed.cacheCreationInputTokens
|
|
2644
|
+
: undefined
|
|
2645
|
+
|
|
2646
|
+
// Bannered on its own declaration, never on the host's id or name (design D6).
|
|
2647
|
+
// An absent metric nobody speaks reads as a measured zero, and the corpus
|
|
2648
|
+
// becomes quietly incomparable across hosts — which is the whole reason cache
|
|
2649
|
+
// accounting is declared separately from usage at all.
|
|
2650
|
+
if (
|
|
2651
|
+
hostCacheRead === undefined &&
|
|
2652
|
+
hostCacheCreation === undefined &&
|
|
2653
|
+
hostCapability(manifest, 'cacheAccounting') === false
|
|
2654
|
+
) {
|
|
2655
|
+
manifest.notes.push(
|
|
2656
|
+
`CACHE ACCOUNTING NOT REPORTED: the ${hostId(manifest) || 'run'} host's runtime exposes no ` +
|
|
2657
|
+
`cache decomposition, so every wave's cache-read and cache-creation figures are recorded ` +
|
|
2658
|
+
`as unknown rather than as a measured zero`
|
|
2659
|
+
)
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
const summary = {
|
|
2663
|
+
waves: manifest.waves || [],
|
|
2664
|
+
spend: hostSpend || counted.spend,
|
|
2665
|
+
outputTokens: hostTotal !== undefined ? hostTotal : counted.outputTokens,
|
|
2666
|
+
cacheReadInputTokens: hostCacheRead !== undefined ? hostCacheRead : counted.cacheReadInputTokens,
|
|
2667
|
+
cacheCreationInputTokens:
|
|
2668
|
+
hostCacheCreation !== undefined ? hostCacheCreation : counted.cacheCreationInputTokens,
|
|
2669
|
+
review: tail.review,
|
|
2670
|
+
remediation: tail.remediation,
|
|
2671
|
+
remediationRounds: tail.remediationRounds,
|
|
2672
|
+
handoff: tail.handoff,
|
|
2673
|
+
halted: haltReason,
|
|
2674
|
+
notes: [...(manifest.notes || []), ...(Array.isArray(observed.notes) ? observed.notes : [])],
|
|
2675
|
+
closing,
|
|
2676
|
+
plan: manifest.plan,
|
|
2677
|
+
commit: manifest.commit,
|
|
2678
|
+
commitSkipped: manifest.commitSkipped === true,
|
|
2679
|
+
paths: readPathSets(root, manifest)
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const degradations = degradationLines({
|
|
2683
|
+
banners: manifest.banners || [],
|
|
2684
|
+
closing,
|
|
2685
|
+
hostBanners: Array.isArray(hostBanners) ? hostBanners : []
|
|
2686
|
+
})
|
|
2687
|
+
|
|
2688
|
+
// The stage marker, on every terminal path: a completion leaves `commit` on
|
|
2689
|
+
// disk and a halt leaves `remediation` or `fix-tests`, and the marker's pid is
|
|
2690
|
+
// the long-lived session's — so a marker nobody cleared keeps the PreToolUse
|
|
2691
|
+
// guards armed for the rest of that session, denying tasks.md checkbox edits
|
|
2692
|
+
// and test-file edits in ordinary work after the run is over. A clear that
|
|
2693
|
+
// fails is spoken, never an exit code: the marker is a guard input, not a gate.
|
|
2694
|
+
const cleared = clearStage(manifest.change, { root })
|
|
2695
|
+
if (!cleared.ok) degradations.push(`STAGE MARKER NOT CLEARED: ${cleared.warning}`)
|
|
2696
|
+
|
|
2697
|
+
// The archive reminder's input (design D7, D8). `detectUnarchived` is called
|
|
2698
|
+
// directly rather than by re-execing `interlock drift`, which would also run
|
|
2699
|
+
// the inferred stale-spec signal and one `git log` per spec for a fact this
|
|
2700
|
+
// close already has the reader for.
|
|
2701
|
+
//
|
|
2702
|
+
// Wrapped exactly as `readLeftoverIds` wraps the same reader: `inspectChange`
|
|
2703
|
+
// reads and stats unguarded, so ONE unreadable sibling change must not turn a
|
|
2704
|
+
// clean close into exit 1. On failure the reminder is dropped and the reason
|
|
2705
|
+
// is spoken as a note — never swallowed.
|
|
2706
|
+
let unarchived = null
|
|
2707
|
+
try {
|
|
2708
|
+
const found = detectUnarchived(root)
|
|
2709
|
+
unarchived = {
|
|
2710
|
+
thisChange: found.some(u => u.change === manifest.change),
|
|
2711
|
+
others: found.filter(u => u.change !== manifest.change).length
|
|
2712
|
+
}
|
|
2713
|
+
} catch (err) {
|
|
2714
|
+
unarchived = null
|
|
2715
|
+
const note = `archive check skipped: ${(err && err.message) || String(err)}`
|
|
2716
|
+
manifest.notes.push(note)
|
|
2717
|
+
// `summary.notes` was snapshotted from `manifest.notes` above; the note has
|
|
2718
|
+
// to reach both or it is recorded in the manifest and printed nowhere.
|
|
2719
|
+
summary.notes.push(note)
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// The push (design D1, D5). Requested by `--notify` from both drivers; the
|
|
2723
|
+
// title is the summary's OWN first line, so the notification and the printed
|
|
2724
|
+
// report cannot disagree. A failed push is a degradation banner and never an
|
|
2725
|
+
// exit code: the run happened either way.
|
|
2726
|
+
const push = await attemptPush(notify, env, {
|
|
2727
|
+
headline: summaryHeadline({ change: manifest.change, halted: haltReason, leftoverTaskIds }),
|
|
2728
|
+
change: manifest.change,
|
|
2729
|
+
runId: manifest.runId
|
|
2730
|
+
})
|
|
2731
|
+
if (push && push.sent === false) degradations.push(`PUSH FAILED: ${push.reason}`)
|
|
2732
|
+
|
|
2733
|
+
// The plan fingerprint this run adopted, read back from where `run classified`
|
|
2734
|
+
// wrote it. Attributed only when the file names THIS change: a fingerprint
|
|
2735
|
+
// left by another change's run would otherwise be recorded as this run's, and
|
|
2736
|
+
// a wrong hash is worse than an absent one — the whole point of the field is
|
|
2737
|
+
// that a reader can check the plan-reuse story off a trajectory.
|
|
2738
|
+
const fingerprint = readJson(join(root, FINGERPRINT_PATH))
|
|
2739
|
+
const planFingerprint =
|
|
2740
|
+
fingerprint && typeof fingerprint.hash === 'string' && fingerprint.change === manifest.change
|
|
2741
|
+
? fingerprint.hash
|
|
2742
|
+
: undefined
|
|
2743
|
+
|
|
2744
|
+
const receipt = buildReceipt({
|
|
2745
|
+
change: manifest.change,
|
|
2746
|
+
summary,
|
|
2747
|
+
degradations,
|
|
2748
|
+
planFingerprint,
|
|
2749
|
+
leftoverTaskIds,
|
|
2750
|
+
host: manifest.host
|
|
2751
|
+
? {
|
|
2752
|
+
id: hostId(manifest),
|
|
2753
|
+
billing: hostCapability(manifest, 'billing'),
|
|
2754
|
+
hooks: hostCapability(manifest, 'hooks'),
|
|
2755
|
+
usage: hostCapability(manifest, 'usage'),
|
|
2756
|
+
cacheAccounting: hostCapability(manifest, 'cacheAccounting')
|
|
2757
|
+
}
|
|
2758
|
+
: undefined
|
|
2759
|
+
})
|
|
2760
|
+
|
|
2761
|
+
if (manifest.runId) {
|
|
2762
|
+
const written = appendRunLogEvent(root, {
|
|
2763
|
+
...receipt,
|
|
2764
|
+
runId: manifest.runId,
|
|
2765
|
+
change: manifest.change
|
|
2766
|
+
})
|
|
2767
|
+
if (!written.written) ctx.warn(`run-receipt not recorded: ${written.reason}`)
|
|
2768
|
+
|
|
2769
|
+
const terminal = appendRunLogEvent(root, {
|
|
2770
|
+
runId: manifest.runId,
|
|
2771
|
+
change: manifest.change,
|
|
2772
|
+
type: haltReason ? 'run-halt' : 'run-complete',
|
|
2773
|
+
reason: haltReason || undefined
|
|
2774
|
+
})
|
|
2775
|
+
if (!terminal.written) ctx.warn(`${haltReason ? 'run-halt' : 'run-complete'} not recorded: ${terminal.reason}`)
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// The outcome corpus. Its own write failure is reported and never touches the
|
|
2779
|
+
// exit code: losing a corpus line must not fail the run that produced it.
|
|
2780
|
+
const outcome = appendOutcome(root, {
|
|
2781
|
+
change: manifest.change,
|
|
2782
|
+
mode: manifest.flags.mode || 'checkpoint',
|
|
2783
|
+
observed: observedFromReceipt(receipt)
|
|
2784
|
+
})
|
|
2785
|
+
if (!outcome.written) ctx.warn(`outcome not recorded: ${outcome.reason}`)
|
|
2786
|
+
|
|
2787
|
+
// Reconstructability. A run nobody can reconstruct defeats the reason the
|
|
2788
|
+
// trajectory file exists, so this one IS fatal — the corpus-loss semantics
|
|
2789
|
+
// this repository deliberately keeps different per corpus.
|
|
2790
|
+
let reconstructable = true
|
|
2791
|
+
if (manifest.runId) {
|
|
2792
|
+
const check = checkRunLog(root, manifest.runId)
|
|
2793
|
+
reconstructable = check.ok !== false
|
|
2794
|
+
if (!reconstructable) {
|
|
2795
|
+
degradations.push(
|
|
2796
|
+
`RUN NOT RECONSTRUCTABLE: ${(check.problems || []).join('; ') || 'the trajectory is incomplete'}`
|
|
2797
|
+
)
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
// The autonomy ladder's record for this run's review (design D6). It used to
|
|
2802
|
+
// ride on the commit prompt, which asked the party being assessed to write
|
|
2803
|
+
// the assessment's input — the exact provenance rule
|
|
2804
|
+
// `openspec/specs/ship/outcome-provenance` states. The count is the CLI's own
|
|
2805
|
+
// last adjudication, and no agent is in its path.
|
|
2806
|
+
//
|
|
2807
|
+
// Only when an adjudication actually happened: a strict run that halted in
|
|
2808
|
+
// the waves never reviewed, and recording zero blockers for it would credit
|
|
2809
|
+
// the review path with a clean run it never had. Storage only — nothing here
|
|
2810
|
+
// or downstream prints a level.
|
|
2811
|
+
if (manifest.flags.strict === true && manifest.review) {
|
|
2812
|
+
const blockers = survivingBlockers(manifest)
|
|
2813
|
+
try {
|
|
2814
|
+
recordAutonomy('review-code', { blockers }, { root })
|
|
2815
|
+
manifest.autonomy = { path: 'review-code', blockers }
|
|
2816
|
+
} catch (err) {
|
|
2817
|
+
degradations.push(
|
|
2818
|
+
`AUTONOMY RECORD NOT WRITTEN: ${(err && err.message) || String(err)} — the ladder is ` +
|
|
2819
|
+
`experimental and gates nothing, so the run is unaffected`
|
|
2820
|
+
)
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
manifest.lastThen = null
|
|
2825
|
+
writeManifest(root, manifest)
|
|
2826
|
+
|
|
2827
|
+
return {
|
|
2828
|
+
schema: STEP_SCHEMA,
|
|
2829
|
+
action: haltReason ? 'halt' : 'complete',
|
|
2830
|
+
then: null,
|
|
2831
|
+
spawns: [],
|
|
2832
|
+
exitCode: haltReason || !reconstructable ? 1 : 0,
|
|
2833
|
+
banners: degradations,
|
|
2834
|
+
summary: formatRunSummary({
|
|
2835
|
+
change: manifest.change,
|
|
2836
|
+
summary,
|
|
2837
|
+
flags: manifest.flags,
|
|
2838
|
+
leftoverTaskIds,
|
|
2839
|
+
degradations,
|
|
2840
|
+
runId: manifest.runId,
|
|
2841
|
+
root: absRoot,
|
|
2842
|
+
projectSlug: slug,
|
|
2843
|
+
push,
|
|
2844
|
+
unarchived
|
|
2845
|
+
})
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* Attempt the close's push, or decide not to. Returns what `formatRunSummary`
|
|
2851
|
+
* takes as its `push` input: `null` when no push was attempted at all, or
|
|
2852
|
+
* `{sent}` when one was.
|
|
2853
|
+
*
|
|
2854
|
+
* The three outcomes are deliberately different facts (design D5):
|
|
2855
|
+
*
|
|
2856
|
+
* - `--notify` not passed, or no topic configured → `null`. An optional
|
|
2857
|
+
* feature left off is not a degraded run: no summary row, no banner.
|
|
2858
|
+
* - Configured but malformed → a failed push naming the VARIABLE, never its
|
|
2859
|
+
* value; the topic is the only auth a public relay has.
|
|
2860
|
+
* - Configured and attempted → whatever `postNtfy` resolves to, bounded by
|
|
2861
|
+
* `LIMITS.notifyTimeoutMs` and never throwing.
|
|
2862
|
+
*/
|
|
2863
|
+
async function attemptPush(notify, env, { headline, change, runId }) {
|
|
2864
|
+
if (notify !== true) return null
|
|
2865
|
+
const config = readNotifyConfig(env)
|
|
2866
|
+
if (config.invalid) return { sent: false, reason: `invalid configuration: ${config.invalid}` }
|
|
2867
|
+
if (!config.topic) return null
|
|
2868
|
+
return postNtfy({
|
|
2869
|
+
url: config.url,
|
|
2870
|
+
topic: config.topic,
|
|
2871
|
+
...composeCloseMessage({ headline, change, runId })
|
|
2872
|
+
})
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* The surviving-blocker count from the LAST adjudication the CLI performed.
|
|
2877
|
+
*
|
|
2878
|
+
* `remediation.blockersRemaining` is rewritten by every `run remediated`, so it
|
|
2879
|
+
* is the latest; the round-one review's count stands in when no remediation
|
|
2880
|
+
* round ever ran. Never the agent's report — both figures are `resolveReview`'s
|
|
2881
|
+
* own output over files the CLI read.
|
|
2882
|
+
*/
|
|
2883
|
+
function survivingBlockers(manifest) {
|
|
2884
|
+
const remediation = manifest.remediation
|
|
2885
|
+
if (remediation && Number.isInteger(remediation.blockersRemaining)) {
|
|
2886
|
+
return remediation.blockersRemaining
|
|
2887
|
+
}
|
|
2888
|
+
const counts = manifest.review && manifest.review.counts
|
|
2889
|
+
return counts && Number.isInteger(counts.blockers) ? counts.blockers : 0
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Every task id still unticked in tasks.md, read at close.
|
|
2894
|
+
*
|
|
2895
|
+
* "Left behind" is not "failed": a halt at verification leaves whole waves that
|
|
2896
|
+
* never ran, and a receipt built from the failure list reported none of them. The
|
|
2897
|
+
* failure list stays as the fallback for a run whose change cannot be read.
|
|
2898
|
+
*/
|
|
2899
|
+
function readLeftoverIds(root, manifest) {
|
|
2900
|
+
try {
|
|
2901
|
+
const inspection = inspectChange(root, manifest.change)
|
|
2902
|
+
const items = (inspection.tasks && inspection.tasks.items) || []
|
|
2903
|
+
return items.filter(t => t && t.done === false).map(t => t.id).filter(Boolean)
|
|
2904
|
+
} catch {
|
|
2905
|
+
return (manifest.waves || []).flatMap(w => w.failedIds || [])
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/** The two path sets the receipt records, read here rather than reported by an agent. */
|
|
2910
|
+
function readPathSets(root, manifest) {
|
|
2911
|
+
const sha = manifest.commit && manifest.commit.ok === true ? manifest.commit.sha : null
|
|
2912
|
+
const touched = sha ? readTouchedPaths(root, sha) : null
|
|
2913
|
+
const predicted = readPredictedPaths(root)
|
|
2914
|
+
return {
|
|
2915
|
+
touchedPaths: touched && touched.observed ? touched.paths : undefined,
|
|
2916
|
+
touchedPathsReason: touched && !touched.observed ? touched.reason : undefined,
|
|
2917
|
+
predictedPaths: predicted && predicted.observed ? predicted.paths : undefined,
|
|
2918
|
+
predictedPathsComplete: predicted ? predicted.complete === true : undefined,
|
|
2919
|
+
predictedPathsReason: predicted && !predicted.observed ? predicted.reason : undefined
|
|
2920
|
+
}
|
|
2921
|
+
}
|