@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/bin/interlock
ADDED
|
@@ -0,0 +1,2655 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// interlock — the deterministic half of the workflow.
|
|
3
|
+
//
|
|
4
|
+
// Every decision a skill could get wrong by re-deriving it in prose lives here
|
|
5
|
+
// instead: which tasks run in which wave, whether a change touches UI, whether a
|
|
6
|
+
// gate blocks, whether a path has earned autonomy. Skills call these commands
|
|
7
|
+
// and act on the JSON; they do not recompute the answers.
|
|
8
|
+
//
|
|
9
|
+
// Claude Code puts a plugin's bin/ on the Bash tool's PATH, so skills invoke
|
|
10
|
+
// this as a bare `interlock` command.
|
|
11
|
+
//
|
|
12
|
+
// This file is also the *only* way the dynamic-workflow runtime reaches any of
|
|
13
|
+
// `lib/`. That runtime cannot `import()` and has no filesystem or shell of its
|
|
14
|
+
// own — only the agents it spawns run commands. So a policy module that is not
|
|
15
|
+
// wired to a subcommand here is a policy the model will improvise in prose.
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process'
|
|
18
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { performance } from 'node:perf_hooks'
|
|
21
|
+
import {
|
|
22
|
+
planWaves,
|
|
23
|
+
formatPlan,
|
|
24
|
+
createRunState,
|
|
25
|
+
nextStep,
|
|
26
|
+
recordBatchResult,
|
|
27
|
+
recordVerifyResult,
|
|
28
|
+
applyReplan,
|
|
29
|
+
formatRunState,
|
|
30
|
+
batchOutcomes
|
|
31
|
+
} from '../lib/waves.mjs'
|
|
32
|
+
import {
|
|
33
|
+
CLASSIFIED_PATH,
|
|
34
|
+
checkSequence,
|
|
35
|
+
readManifest,
|
|
36
|
+
runClassified,
|
|
37
|
+
runClose,
|
|
38
|
+
runCommit,
|
|
39
|
+
runJudge,
|
|
40
|
+
runNext,
|
|
41
|
+
runRecordBatch,
|
|
42
|
+
runRemediated,
|
|
43
|
+
runReplan,
|
|
44
|
+
runReviewed,
|
|
45
|
+
runStart,
|
|
46
|
+
runVerifyFinal
|
|
47
|
+
} from '../lib/run.mjs'
|
|
48
|
+
import { haltStep } from '../lib/run.mjs'
|
|
49
|
+
import { composeCheckpointMessage, postNtfy, readNotifyConfig, NOTIFY_TOPIC_ENV } from '../lib/notify.mjs'
|
|
50
|
+
import { classifySurface } from '../lib/surface.mjs'
|
|
51
|
+
import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
|
|
52
|
+
import { inspectChange, resolveChange, listChanges, formatInspection, tickTasks, planCoverage } from '../lib/artifacts.mjs'
|
|
53
|
+
import { record, recordCleanFlow, getLevel, getState } from '../lib/autonomy.mjs'
|
|
54
|
+
import {
|
|
55
|
+
LIMITS,
|
|
56
|
+
RUNTIME,
|
|
57
|
+
EVAL_CAPS,
|
|
58
|
+
MODEL_PRICES,
|
|
59
|
+
REPORT_CAPS,
|
|
60
|
+
EFFORT,
|
|
61
|
+
LANE_CAPS,
|
|
62
|
+
SOLO,
|
|
63
|
+
formatLimits
|
|
64
|
+
} from '../lib/limits.mjs'
|
|
65
|
+
import { planRemediation, formatRemediationPlan } from '../lib/remediate.mjs'
|
|
66
|
+
import {
|
|
67
|
+
resolveReview,
|
|
68
|
+
formatReview,
|
|
69
|
+
readReviewPolicy,
|
|
70
|
+
formatReviewPolicy
|
|
71
|
+
} from '../lib/review-core.mjs'
|
|
72
|
+
import { writeReviewMetrics } from '../lib/metrics.mjs'
|
|
73
|
+
import {
|
|
74
|
+
planVerification,
|
|
75
|
+
judgeVerification,
|
|
76
|
+
judgeUnitResult,
|
|
77
|
+
clusterFailures,
|
|
78
|
+
nextRepairAction,
|
|
79
|
+
formatVerification,
|
|
80
|
+
checkResultFieldSizes,
|
|
81
|
+
VERIFY_CONTEXTS
|
|
82
|
+
} from '../lib/verify.mjs'
|
|
83
|
+
import { spillBytes } from '../lib/spill.mjs'
|
|
84
|
+
import { classifyRisk, formatRisk } from '../lib/risk.mjs'
|
|
85
|
+
import { mergeDecision } from '../lib/merge-lanes.mjs'
|
|
86
|
+
import { analyzeDrift, formatDrift } from '../lib/drift.mjs'
|
|
87
|
+
import { buildChecklist, formatChecklist } from '../lib/conformance.mjs'
|
|
88
|
+
import { readLedger, summarize as summarizeLedger, formatLedger } from '../lib/ledger.mjs'
|
|
89
|
+
import {
|
|
90
|
+
computeFingerprint,
|
|
91
|
+
writeFingerprint,
|
|
92
|
+
resolvePlanReuse,
|
|
93
|
+
formatFingerprint,
|
|
94
|
+
formatPlanReuse,
|
|
95
|
+
PLAN_PATH,
|
|
96
|
+
FINGERPRINT_PATH,
|
|
97
|
+
NARROWED_PLAN_PATH
|
|
98
|
+
} from '../lib/plan-fingerprint.mjs'
|
|
99
|
+
|
|
100
|
+
import { assessReadiness, formatReadiness } from '../lib/ready.mjs'
|
|
101
|
+
import { buildReport, formatReport } from '../lib/report.mjs'
|
|
102
|
+
import { renderReportHtml } from '../lib/report-html.mjs'
|
|
103
|
+
import { diagnose, formatDoctor, TEST_PROFILE_PATH } from '../lib/doctor.mjs'
|
|
104
|
+
import { triage, formatTriage } from '../lib/evals-triage.mjs'
|
|
105
|
+
import { captureCase, formatCapture } from '../lib/evals-capture.mjs'
|
|
106
|
+
import { calibrate, readLabels, formatCalibration } from '../lib/evals-calibrate.mjs'
|
|
107
|
+
import { promote, formatPromotion } from '../lib/evals-promote.mjs'
|
|
108
|
+
import { readTouchedPaths, readPredictedPaths, formatPathSet } from '../lib/run-paths.mjs'
|
|
109
|
+
import { appendOutcome, observedFromReceipt, readOutcomes, formatOutcomes } from '../lib/outcomes.mjs'
|
|
110
|
+
import {
|
|
111
|
+
appendRunLogEvent,
|
|
112
|
+
readRunLog,
|
|
113
|
+
listRunLogs,
|
|
114
|
+
queryRunLog,
|
|
115
|
+
checkRunLog,
|
|
116
|
+
formatRunLog,
|
|
117
|
+
formatRunLogList,
|
|
118
|
+
formatRunLogCheck
|
|
119
|
+
} from '../lib/run-log.mjs'
|
|
120
|
+
|
|
121
|
+
const USAGE = `interlock — deterministic decisions for the spec-driven flow
|
|
122
|
+
|
|
123
|
+
Usage:
|
|
124
|
+
interlock waves --classified <file> Plan execution waves from classified tasks
|
|
125
|
+
[--mode solo|waves] (--mode forces the plan shape;
|
|
126
|
+
[--red-wave <n>] --red-wave/--tdd run a leading
|
|
127
|
+
[--tdd|--no-tdd] failing-test wave first)
|
|
128
|
+
interlock surface --changed <files...> Classify UI testability of changed files
|
|
129
|
+
interlock gate --findings <file> Decide whether a review gate blocks
|
|
130
|
+
interlock validate [change] Inspect OpenSpec change artifacts
|
|
131
|
+
(--change <name> is the same, as a flag)
|
|
132
|
+
interlock changes List active OpenSpec changes
|
|
133
|
+
interlock doctor Preflight the host before a run:
|
|
134
|
+
Node, plugin install, PATH, the
|
|
135
|
+
openspec CLI, git, the test profile,
|
|
136
|
+
the permission allowlist, and the
|
|
137
|
+
writability of the run-state dirs
|
|
138
|
+
interlock drift [--changed <files>] Report unarchived changes, broken spec
|
|
139
|
+
references, aging specs, and orphan code
|
|
140
|
+
interlock conformance [change] List the spec scenarios a change must satisfy
|
|
141
|
+
interlock report [--since <iso>] Indicators over the three recorded
|
|
142
|
+
[--change <name>] corpora — outcome records, run
|
|
143
|
+
trajectories, review metrics. Every
|
|
144
|
+
value carries its denominator, and
|
|
145
|
+
an unobserved value stays unobserved.
|
|
146
|
+
Gates nothing and always exits 0
|
|
147
|
+
interlock limits Print every cap the loop obeys
|
|
148
|
+
interlock notify --title <t> [--body <b>] Push one message so a run or a
|
|
149
|
+
[--priority high|default] checkpoint reached while nobody was
|
|
150
|
+
interlock notify checkpoint <change> watching still reaches someone. The
|
|
151
|
+
only outbound request this CLI makes.
|
|
152
|
+
Topic from INTERLOCK_NTFY_TOPIC,
|
|
153
|
+
server from INTERLOCK_NTFY_URL
|
|
154
|
+
(default https://ntfy.sh). Unset
|
|
155
|
+
topic prints "not configured" and
|
|
156
|
+
exits 0; a failed push exits 1.
|
|
157
|
+
--json emits { sent, reason }
|
|
158
|
+
interlock run start --change <name> The ship loop, as a program. Each
|
|
159
|
+
[--tdd|--no-tdd] (--tdd/--no-tdd override the task
|
|
160
|
+
shape read from tasks.md)
|
|
161
|
+
interlock run classified --classified <f> subcommand returns one step record
|
|
162
|
+
interlock run record-batch --results <f> (interlock.run-step/1): the agents
|
|
163
|
+
interlock run judge --context <ctx> --results <f>
|
|
164
|
+
interlock run verify-final to spawn — label, model, effort,
|
|
165
|
+
interlock run reviewed --results <f> agent type, tools, result schema and
|
|
166
|
+
interlock run remediated --round <n> --results <f>
|
|
167
|
+
interlock run commit --results <f> a briefing file with its sha256 —
|
|
168
|
+
interlock run replan --results <f> and the exact argv to call once they
|
|
169
|
+
interlock run next return. A host spawns what a step
|
|
170
|
+
interlock run close [--halt <reason>] names, writes the results, calls
|
|
171
|
+
[--host-banners <f>]
|
|
172
|
+
[--host-observed <f>]
|
|
173
|
+
\`then.argv\`, and repeats until
|
|
174
|
+
\`then\` is null. It decides nothing:
|
|
175
|
+
every branch the loop has (apply-only,
|
|
176
|
+
no-commit, skip-e2e, isolate-waves,
|
|
177
|
+
replan allowed, verify skipped, done)
|
|
178
|
+
is taken here from the run manifest
|
|
179
|
+
(.claude/ship/run.json) and the wave
|
|
180
|
+
state. The strict tail is part of
|
|
181
|
+
the program too: \`reviewed\`
|
|
182
|
+
adjudicates the findings and verdicts
|
|
183
|
+
the review step wrote and plans round
|
|
184
|
+
one, \`remediated --round <n>\`
|
|
185
|
+
re-adjudicates and asks for the next
|
|
186
|
+
round, the verdict or the halt, and
|
|
187
|
+
\`commit\` records what the handoff
|
|
188
|
+
wrote. No host carries the round
|
|
189
|
+
budget, and none may refuse a tail
|
|
190
|
+
flag. \`run close\` is the only one
|
|
191
|
+
that exits non-zero — on a halt, or
|
|
192
|
+
on a run whose trajectory cannot be
|
|
193
|
+
reconstructed
|
|
194
|
+
interlock merge-lanes --base <sha> --lanes <file>
|
|
195
|
+
Fold a batch's isolated lane worktrees
|
|
196
|
+
back into the shared tree (--isolate-waves
|
|
197
|
+
runs only). --lanes is a JSON array of
|
|
198
|
+
{ label, worktreePath, reportedFiles }.
|
|
199
|
+
Exits 0 on a clean fold, 1 on a halt
|
|
200
|
+
(a real path collision or a lane whose
|
|
201
|
+
worktree could not be located) — never
|
|
202
|
+
auto-resolves, never applies a guess
|
|
203
|
+
interlock evals triage --results <file> Classify an eval results run:
|
|
204
|
+
regression, variance, or no signal.
|
|
205
|
+
No model, no network. Exit code is
|
|
206
|
+
the verdict (1=regression,
|
|
207
|
+
2=no signal, 3=configuration)
|
|
208
|
+
interlock evals capture --run <id> --out <dir>
|
|
209
|
+
[--task <id>] Emit an eval-case skeleton from a
|
|
210
|
+
recorded run trajectory, with its
|
|
211
|
+
provenance. A DRAFT: every derived
|
|
212
|
+
value is marked CONFIRM. Refuses a
|
|
213
|
+
run that is not reconstructable, an
|
|
214
|
+
--out inside evals/, and an --out
|
|
215
|
+
that already holds a case — writing
|
|
216
|
+
nothing on any refusal (exit 1)
|
|
217
|
+
interlock evals calibrate --results <file>
|
|
218
|
+
[--labels <dir>] Report how often each judged grader
|
|
219
|
+
agreed with a stored human label, with
|
|
220
|
+
its denominator. No model, no network,
|
|
221
|
+
NO verdict — it names unmeasured
|
|
222
|
+
graders and unmatched items and always
|
|
223
|
+
exits 0. promote applies the floor
|
|
224
|
+
interlock evals promote --history <dir>
|
|
225
|
+
[--agreement <f>] Decide whether each case in the
|
|
226
|
+
recorded history may move from
|
|
227
|
+
advisory to blocking. The rule's
|
|
228
|
+
numbers come from interlock limits.
|
|
229
|
+
Exit code is the verdict (1=at least
|
|
230
|
+
one refused, 2=insufficient history)
|
|
231
|
+
interlock paths touched --commit <sha> Repository-relative paths one commit
|
|
232
|
+
touched, read from git
|
|
233
|
+
interlock paths predicted [--plan <file>] Paths the executed plan predicted its
|
|
234
|
+
tasks would touch, and whether every
|
|
235
|
+
task declared any. Both commands
|
|
236
|
+
always exit 0: an unreadable set is
|
|
237
|
+
reported unobserved with its reason,
|
|
238
|
+
never as an empty set and never as a
|
|
239
|
+
failed close
|
|
240
|
+
interlock remediate --findings <file> Plan one remediation round, or the verdict
|
|
241
|
+
interlock review --findings <f> --verdicts <f>
|
|
242
|
+
Resolve a review against skeptic verdicts.
|
|
243
|
+
Also reads repo-root REVIEW.md and drops
|
|
244
|
+
findings on its do-not-report paths
|
|
245
|
+
interlock review-policy [--json] Print the parsed repo-root REVIEW.md:
|
|
246
|
+
owner + prose (advice for the reviewer)
|
|
247
|
+
and any parse problems. Absent → empty
|
|
248
|
+
interlock risk [change] Classify blast radius for the continuity gate
|
|
249
|
+
interlock ledger [change] Summarize the decision ledger
|
|
250
|
+
interlock ready [change] --findings <f> Decide whether continuity may skip the human
|
|
251
|
+
interlock outcomes append --record <f> Append one run outcome to the corpus
|
|
252
|
+
interlock outcomes list Read the outcome corpus back
|
|
253
|
+
interlock run-log append --event <f> Append one ship-run trajectory event
|
|
254
|
+
interlock run-log list [--change <name>] List recorded ship runs
|
|
255
|
+
interlock run-log show <runId> Print one run's events in order
|
|
256
|
+
interlock run-log query --run <id> Filter one run's events
|
|
257
|
+
interlock run-log check --state <file> Reconstructability gate: exit 1 on
|
|
258
|
+
a gap, a missing start, or a
|
|
259
|
+
missing run-halt/run-complete
|
|
260
|
+
interlock tasks tick --ids <ids> Tick succeeded task checkboxes by id
|
|
261
|
+
interlock tasks coverage --classified <f> Halt if classified.json omits an unchecked task
|
|
262
|
+
interlock verify plan --profile <file> Decide which verification commands to run
|
|
263
|
+
interlock verify judge --plan <f> --results <f>
|
|
264
|
+
Judge reported results against the plan
|
|
265
|
+
interlock verify unit --result <file> Judge a unit result on its own
|
|
266
|
+
interlock verify spill --run-id <id> --kind <k> --input <f>
|
|
267
|
+
Spill oversized stdout/stderr to disk
|
|
268
|
+
interlock verify cluster --failures <f> Group failures by root-cause signature
|
|
269
|
+
interlock verify repair --state <file> Decide repair / halt / accept next
|
|
270
|
+
interlock plan fingerprint [change] Fingerprint a change's planning inputs
|
|
271
|
+
[--mode solo|waves] (the mode override is part of the print)
|
|
272
|
+
interlock plan reuse [change] Decide whether the stored plan may be
|
|
273
|
+
[--mode solo|waves] reused, and narrow it if so (a mode
|
|
274
|
+
override that differs from the stored
|
|
275
|
+
plan's forces a rebuild)
|
|
276
|
+
interlock wave-state create --plan <file> Start a run state from a wave plan
|
|
277
|
+
interlock wave-state next --state <file> Ask what the run should do next
|
|
278
|
+
interlock wave-state record-batch --state <f> --result <f>
|
|
279
|
+
interlock wave-state record-verify --state <f> --result <f>
|
|
280
|
+
interlock wave-state replan --state <f> --groups <f>
|
|
281
|
+
interlock autonomy level <path> Print the autonomy level of a path
|
|
282
|
+
interlock autonomy record <path> Record a run outcome
|
|
283
|
+
interlock autonomy clean <paths...> Credit a clean flow across paths
|
|
284
|
+
interlock autonomy state Dump the full ladder
|
|
285
|
+
|
|
286
|
+
Common options:
|
|
287
|
+
--json Emit JSON instead of human-readable text
|
|
288
|
+
--root <dir> Repo root (default: cwd)
|
|
289
|
+
Any <file> argument accepts "-" to read that input from stdin. Only one input
|
|
290
|
+
per invocation may be "-", since stdin can only be drained once.
|
|
291
|
+
|
|
292
|
+
Exit codes:
|
|
293
|
+
Every gating command exits 1 when it blocks, so the workflow branches on exit
|
|
294
|
+
status rather than on parsed prose:
|
|
295
|
+
gate surviving findings block the gate
|
|
296
|
+
doctor a preflight check would stop a zero-touch run
|
|
297
|
+
validate the change is not ready
|
|
298
|
+
remediate blockers survive the verdict round
|
|
299
|
+
verify judge a halting kind failed in this context, a result
|
|
300
|
+
field exceeded the preview budget, or (with
|
|
301
|
+
--state/--run-id) the trajectory append failed
|
|
302
|
+
verify unit the unit suite is red or was weakened, or a
|
|
303
|
+
result field exceeded the preview budget
|
|
304
|
+
verify repair the root-cause repair budget is spent
|
|
305
|
+
ledger the ledger is missing or unparseable, or
|
|
306
|
+
needs_human or invalid rows remain
|
|
307
|
+
ready the change may not skip the human checkpoint
|
|
308
|
+
merge-lanes a lane worktree collided with another on a real
|
|
309
|
+
path, or a lane's worktree could not be located —
|
|
310
|
+
a clean fold is the only zero exit
|
|
311
|
+
tasks tick an id could not be marked complete
|
|
312
|
+
wave-state next the run has halted, or the trajectory append failed
|
|
313
|
+
wave-state record-* the recorded result halted the run, or the
|
|
314
|
+
trajectory append failed
|
|
315
|
+
run-log check the run's trajectory is not reconstructable
|
|
316
|
+
evals triage at least one case is a regression (exit 1); a run
|
|
317
|
+
that yielded no signal (exit 2) or a configuration
|
|
318
|
+
problem (exit 3) is non-zero but distinct, so
|
|
319
|
+
neither reads as a clean pass
|
|
320
|
+
evals capture the capture was refused — the run is not
|
|
321
|
+
reconstructable, the output directory resolves
|
|
322
|
+
inside evals/, or it already holds a case. Nothing
|
|
323
|
+
was written on any of the three
|
|
324
|
+
evals promote at least one case may not yet be promoted (exit 1);
|
|
325
|
+
a history too thin to decide (exit 2) is non-zero
|
|
326
|
+
but distinct, because "not enough evidence" must
|
|
327
|
+
never read as "the evidence says no"
|
|
328
|
+
paths touched and paths predicted never exit non-zero either: a host calls
|
|
329
|
+
them on its way out, and a read that failed must leave the receipt's set
|
|
330
|
+
unobserved rather than fail the close.
|
|
331
|
+
risk never exits non-zero — branch on "continuityAllowed" instead, so a
|
|
332
|
+
high-risk classification is a checkpoint rather than a failed command.
|
|
333
|
+
evals calibrate never exits non-zero. That is a contract, not a convenience:
|
|
334
|
+
the calibration report measures judge/human agreement and applies no floor to
|
|
335
|
+
it, so an exit code would be a verdict it is forbidden to issue. Low agreement
|
|
336
|
+
is stated with its denominator and the command still succeeds; evals promote is
|
|
337
|
+
the only reader that turns that number into a decision.
|
|
338
|
+
drift, conformance and report never exit non-zero either. For report that is
|
|
339
|
+
a policy, not a convenience: §4.15(a) forbids gating on the recorded corpora,
|
|
340
|
+
and a non-zero status is exactly the comparison it forbids. Nothing in the
|
|
341
|
+
loop reads the report.
|
|
342
|
+
|
|
343
|
+
report:
|
|
344
|
+
Reads .claude/learning/outcomes.jsonl, .claude/ship/runs/*.jsonl and
|
|
345
|
+
.claude/metrics/*.json. Coverage first — how many trajectories carry a
|
|
346
|
+
receipt at all — because every receipt-derived indicator rests on it and a
|
|
347
|
+
thin corpus must read as thin rather than as clean. Metrics files are
|
|
348
|
+
classified by their "schema" key, never by filename: a skill also writes
|
|
349
|
+
review-artifacts-*.json into that directory, and it carries finding bodies
|
|
350
|
+
rather than counts. "Does the merged diff match the plan" is computed from the
|
|
351
|
+
two path sets the receipt records — touched paths that the plan predicted,
|
|
352
|
+
over touched paths — and every run it could not be computed over is excluded
|
|
353
|
+
and counted under the reason it was excluded, rather than approximated from a
|
|
354
|
+
nearby number.
|
|
355
|
+
--since <iso> Keep runs whose earliest record is at or after this
|
|
356
|
+
--change <name> Keep one change's runs, outcomes and metrics
|
|
357
|
+
--max-runs <n> Lower the scan bound; the published cap is the ceiling
|
|
358
|
+
(see interlock limits). Truncation is reported.
|
|
359
|
+
--html [path] Render the same report object as one self-contained
|
|
360
|
+
HTML document — no stylesheet, script or font is
|
|
361
|
+
fetched, so it opens offline. Bare, it writes to
|
|
362
|
+
stdout; given a path, it writes only there. Mutually
|
|
363
|
+
exclusive with --json. It is a generated file, not a
|
|
364
|
+
service, and like every other surface here it issues
|
|
365
|
+
no verdict: an unobserved indicator renders as
|
|
366
|
+
UNOBSERVED with its reason, never as a zero.
|
|
367
|
+
|
|
368
|
+
waves:
|
|
369
|
+
--classified <file> JSON: { "tasks": [{id, group, description, tier, model, isTestTask}] }
|
|
370
|
+
Use "-" to read from stdin.
|
|
371
|
+
--max-parallel <n> Cap agents spawned per batch (default ${LIMITS.maxParallel})
|
|
372
|
+
--mode solo|waves Force the plan shape. Absent, the classified input's own
|
|
373
|
+
recommendedMode decides inside the published envelope
|
|
374
|
+
(see interlock limits); default is waves. Passing both
|
|
375
|
+
solo and waves is rejected rather than resolved last-wins.
|
|
376
|
+
--red-wave <n> Run section <n> as a LEADING failing-test wave instead of
|
|
377
|
+
deferring its tests: they execute first and that wave's
|
|
378
|
+
inter-wave check is skipped, because the suite is expected
|
|
379
|
+
to fail there. Refused, with a warning, when the section
|
|
380
|
+
holds no test task or is not the first section.
|
|
381
|
+
--tdd Derive --red-wave from the classification: the lowest
|
|
382
|
+
section whose tasks are ALL test tasks. A run resolves it
|
|
383
|
+
from the tasks.md section heading instead.
|
|
384
|
+
--no-tdd Never treat any section as a red wave.
|
|
385
|
+
|
|
386
|
+
surface:
|
|
387
|
+
--changed <files...> Repo-relative paths. Use "-" to read newline-separated stdin.
|
|
388
|
+
|
|
389
|
+
gate:
|
|
390
|
+
--findings <file> JSON from the review dimensions. Use "-" for stdin.
|
|
391
|
+
--dismissed <t1,t2> Finding titles the skeptics verified away
|
|
392
|
+
--metrics <change> Also append the counts to .claude/metrics/ under this
|
|
393
|
+
change name, as "review" does. A metrics failure prints
|
|
394
|
+
its reason and is never fatal — the verdict and the exit
|
|
395
|
+
code are unaffected.
|
|
396
|
+
--min-quality <n> Quality-band floor (default ${TOLERANCE_BAND.minQualityToReport})
|
|
397
|
+
--no-tolerance Report every surviving finding, however weak
|
|
398
|
+
Findings the skeptics scored below the quality band are dropped before
|
|
399
|
+
counting, so a weakly-grounded finding never blocks; unscored findings are
|
|
400
|
+
always kept, and a skeptic disagreement wider than ${TOLERANCE_BAND.drift} keeps the finding.
|
|
401
|
+
Exits 1 when the gate blocks, so callers can branch on exit status.
|
|
402
|
+
|
|
403
|
+
limits:
|
|
404
|
+
Prints the caps the loop obeys and the workflow-runtime ceilings above them.
|
|
405
|
+
Cite these rather than restating a number in prose. With --json the per-tier
|
|
406
|
+
lane-cap table and the solo envelope are their own top-level groups (laneCaps,
|
|
407
|
+
solo), never entries of the iteration-count limits.
|
|
408
|
+
|
|
409
|
+
doctor:
|
|
410
|
+
The preflight. Every condition here would otherwise be discovered mid-run, by
|
|
411
|
+
which point a zero-touch run has already spent waves of agents to learn it —
|
|
412
|
+
or worse, has stopped on an approval prompt with nobody watching. Read-only:
|
|
413
|
+
it names a fix for every failure and applies none of them.
|
|
414
|
+
Checks: the Node version against the plugin floor and the OpenSpec CLI's
|
|
415
|
+
higher one; the INSTALLED plugin's workflow, agent types and binaries
|
|
416
|
+
(resolved from the plugin, never from the checkout you are standing in);
|
|
417
|
+
interlock / interlock-graph on PATH; the openspec CLI and whether this project
|
|
418
|
+
is initialised; git and a work tree; ${TEST_PROFILE_PATH};
|
|
419
|
+
the permission allowlist; and whether the run-state directories can be
|
|
420
|
+
written.
|
|
421
|
+
The allowlist check derives the commands it requires — the four the flow
|
|
422
|
+
always shells out to, plus whatever the test profile says this project runs —
|
|
423
|
+
and reports an uncovered command as a FAILURE, since an approval prompt in an
|
|
424
|
+
unattended run is a stopped run. A rule that exists but is narrower than the
|
|
425
|
+
command it would have to permit (\`Bash(interlock waves:*)\` for a requirement
|
|
426
|
+
of \`interlock\`) is reported as narrower, never counted as coverage. A "deny"
|
|
427
|
+
that overlaps a required command fails; an "ask" warns; defaultMode
|
|
428
|
+
"bypassPermissions" warns and checks nothing, because nothing will prompt.
|
|
429
|
+
Exits 1 when any check fails. Warnings never gate.
|
|
430
|
+
|
|
431
|
+
remediate:
|
|
432
|
+
--findings <file> Review output, in any shape gate accepts. "-" for stdin.
|
|
433
|
+
--round <n> 1-based (default 1). Rounds 1-${LIMITS.remediationRounds} are fix passes;
|
|
434
|
+
round ${LIMITS.remediationRounds + 1} is the verdict and is the only round that halts.
|
|
435
|
+
--min-quality <n> Quality-band floor, as for gate
|
|
436
|
+
--no-tolerance Disable the quality band
|
|
437
|
+
Exits 1 when the plan halts (blockers survived the verdict round).
|
|
438
|
+
|
|
439
|
+
review:
|
|
440
|
+
--findings <file> Findings raised by the review dimensions. "-" for stdin.
|
|
441
|
+
--verdicts <file> Flat JSON array of skeptic verdicts. "-" for stdin.
|
|
442
|
+
--metrics <change> Also append the counts to .claude/metrics/ under this
|
|
443
|
+
change name. A metrics failure prints its reason and is
|
|
444
|
+
never fatal — the exit code is unaffected.
|
|
445
|
+
--changed <p1,p2|-> The reviewed diff. A verdict that DISMISSES a finding
|
|
446
|
+
must cite "path:line" (or "path:start-end") naming a
|
|
447
|
+
path in this set — an uncited or unlocatable refutation
|
|
448
|
+
is a non-vote and the finding survives. Omit it and
|
|
449
|
+
only the citation shape is checked, never membership.
|
|
450
|
+
A verdict that a finding IS real needs no citation.
|
|
451
|
+
--min-quality <n> Quality-band floor, as for gate
|
|
452
|
+
--no-tolerance Disable the quality band
|
|
453
|
+
--changed also drives nothing else; REVIEW.md is located from --root (default
|
|
454
|
+
cwd). Findings on its do-not-report paths are dropped BEFORE the band, counted
|
|
455
|
+
as "droppedByPolicy" with the excluding path, and "policyProblems" reports a
|
|
456
|
+
malformed policy file rather than swallowing it.
|
|
457
|
+
Always exits 0; feed "surviving" to gate to decide whether it blocks.
|
|
458
|
+
"dismissalsRejected" counts refutations refused for want of a citation — a
|
|
459
|
+
high number means the skeptics are asserting rather than reading.
|
|
460
|
+
|
|
461
|
+
review-policy:
|
|
462
|
+
--root <dir> Repository root to read REVIEW.md from (default cwd).
|
|
463
|
+
--json Emit { prose, excludePaths, owner, problems }.
|
|
464
|
+
The prose is advice a reviewer reads; it can never lower survival. The band and
|
|
465
|
+
nit cap are NOT policy fields — a threshold-shaped key is reported, not adopted.
|
|
466
|
+
Always exits 0; an absent REVIEW.md is the empty policy, not an error.
|
|
467
|
+
|
|
468
|
+
risk:
|
|
469
|
+
[change] OpenSpec change name (resolved like validate when omitted)
|
|
470
|
+
--change <name> Same, as a flag
|
|
471
|
+
--paths <p1,p2> Changed or planned repo-relative paths. "-" reads
|
|
472
|
+
newline-separated stdin.
|
|
473
|
+
Artifact text is read from the change directory and classified alongside the
|
|
474
|
+
paths. Fails closed: no usable signal classifies as high, never low.
|
|
475
|
+
|
|
476
|
+
ledger:
|
|
477
|
+
[change] OpenSpec change name (resolved like validate when omitted)
|
|
478
|
+
Reads openspec/changes/<change>/decisions.md. Exits 1 when a needs_human row
|
|
479
|
+
remains, when a row is invalid, or when the ledger is MISSING — an absent
|
|
480
|
+
ledger is a failure of the audit, not an empty result, because an empty
|
|
481
|
+
result reads as "nothing needs a human". The JSON distinguishes missing,
|
|
482
|
+
unparseable, and present-and-empty.
|
|
483
|
+
An agent_resolved row is audited: it needs a written resolution, evidence a
|
|
484
|
+
reader can follow (a hedge such as "obvious" counts as blank), and its id must
|
|
485
|
+
appear in the change's design.md. An absent or unreadable design.md makes
|
|
486
|
+
every reference unresolvable rather than resolving all of them.
|
|
487
|
+
|
|
488
|
+
ready:
|
|
489
|
+
[change] OpenSpec change name (resolved like validate when omitted)
|
|
490
|
+
--findings <file|-> The artifact review's own findings output. The blocker
|
|
491
|
+
count is DERIVED from it by the same evaluation
|
|
492
|
+
"interlock gate" uses. REQUIRED — absent, empty and
|
|
493
|
+
unparseable all mean the review did not run, never
|
|
494
|
+
zero blockers.
|
|
495
|
+
--review <file|-> DEPRECATED: a transcribed { blockers, warnings } count.
|
|
496
|
+
Warns, and no longer satisfies the review-blockers
|
|
497
|
+
check on its own — the party writing that number is
|
|
498
|
+
the party being gated. When both are passed and they
|
|
499
|
+
disagree, --findings decides and the disagreement is
|
|
500
|
+
reported.
|
|
501
|
+
--paths <p1,p2|-> Changed or planned repo-relative paths, for the risk class
|
|
502
|
+
Fails closed: every check must affirmatively pass. A check that could not run
|
|
503
|
+
is never a pass, and an error while gathering evidence becomes a blocker.
|
|
504
|
+
Exits 1 when the change is not ready.
|
|
505
|
+
|
|
506
|
+
outcomes:
|
|
507
|
+
append --record <f|-> One outcome object; see .claude/learning/README.md.
|
|
508
|
+
"mode" must be "checkpoint" or "continue" — an
|
|
509
|
+
unrecognised mode is refused rather than guessed.
|
|
510
|
+
The record supplies the "reported" half only. The
|
|
511
|
+
"observed" half is derived here from the run's
|
|
512
|
+
receipt, found via --state <f> or --run-id <id>; an
|
|
513
|
+
"observed" group in the record file is refused, and
|
|
514
|
+
so is an observed field smuggled into "reported".
|
|
515
|
+
No receipt means the record says so — it never falls
|
|
516
|
+
back to the values the record file carried.
|
|
517
|
+
list Read .claude/learning/outcomes.jsonl back
|
|
518
|
+
Nothing gates on this corpus yet; it is recorded so that it can, later.
|
|
519
|
+
Always exits 0 — losing a corpus line must never fail the run that made it.
|
|
520
|
+
|
|
521
|
+
run-log:
|
|
522
|
+
append --event <f|-> One trajectory event; type must be one of run-start,
|
|
523
|
+
wave-action, cli-exit, agent-spawn, verify-judgement,
|
|
524
|
+
run-halt, run-complete, run-receipt. Written to
|
|
525
|
+
.claude/ship/runs/<runId>.jsonl, seq assigned by the
|
|
526
|
+
writer. Never throws — an unwritable log is reported
|
|
527
|
+
on stderr, not raised.
|
|
528
|
+
run-receipt is what the run observed about itself —
|
|
529
|
+
wave tallies, plan fingerprint, review counts,
|
|
530
|
+
degradations, commit identifier — so a trajectory can
|
|
531
|
+
be read without the repository beside it. Its fields
|
|
532
|
+
are copied by name like every other type's, so a run
|
|
533
|
+
may hand it a whole summary object. Absent means
|
|
534
|
+
unobserved, never zero: a run that halted before its
|
|
535
|
+
review reports no review counts rather than none.
|
|
536
|
+
list [--change <name>] List every run with a trajectory file: runId, change,
|
|
537
|
+
halted (with reason) or complete or in-progress, the
|
|
538
|
+
commit identifier, event/skipped counts, and whether a
|
|
539
|
+
receipt was recorded. Filter to one change with
|
|
540
|
+
--change. Tolerates a torn or garbage line the same
|
|
541
|
+
way "outcomes list" does — skips it, reports it.
|
|
542
|
+
show <runId> Print one run's events in sequence order.
|
|
543
|
+
query --run <id> [--type <t>] [--halted]
|
|
544
|
+
Filter one run's events. --type keeps only that event
|
|
545
|
+
type. --halted keeps only the events that explain a
|
|
546
|
+
halt — the run-halt line, plus any verify-judgement
|
|
547
|
+
that itself halted — combine with --type to narrow
|
|
548
|
+
further.
|
|
549
|
+
Session-query (list/show/query) is read-only and never-throws, like
|
|
550
|
+
"outcomes list": a missing run, a torn final line, or an unreadable log is
|
|
551
|
+
reported, never an exception. Always exits 0 — reading the trajectory is
|
|
552
|
+
never itself a gate.
|
|
553
|
+
check --state <file> | --run-id <id>
|
|
554
|
+
The reconstructability gate. Exits 1 unless the run's
|
|
555
|
+
trajectory has contiguous seq from 1, a run-start, a
|
|
556
|
+
run-halt or run-complete, and a cli-exit for every
|
|
557
|
+
wave-state / verify-judge invocation. A run-receipt is
|
|
558
|
+
not required — a run that died before its close could
|
|
559
|
+
not have written one — but two of them is a problem,
|
|
560
|
+
since a run has one close. Once this gate
|
|
561
|
+
is wired into "wave-state" and "verify judge" (see
|
|
562
|
+
those sections), a trajectory write failure on either
|
|
563
|
+
is itself a non-zero exit — the never-throw contract
|
|
564
|
+
above stays true only for "append"/"list"/"show"/
|
|
565
|
+
"query", not for the mutating commands the gate covers.
|
|
566
|
+
|
|
567
|
+
tasks:
|
|
568
|
+
tick --ids <ids> Comma-separated task ids (the leading token of a
|
|
569
|
+
checkbox, e.g. 1.1). Flips matching - [ ] to - [x].
|
|
570
|
+
Already-checked matches are reported, not rewritten.
|
|
571
|
+
Exits 1 and names every id it could not mark: a
|
|
572
|
+
completed task left silently unchecked reads
|
|
573
|
+
downstream as a failed task.
|
|
574
|
+
coverage --classified <f>
|
|
575
|
+
Remaining unchecked checkboxes vs classified.json.
|
|
576
|
+
Exits 1 when an unchecked box has no matching id, so
|
|
577
|
+
ship cannot start implementers on a partial plan.
|
|
578
|
+
[change] / --change OpenSpec change name (resolved like validate)
|
|
579
|
+
|
|
580
|
+
verify plan:
|
|
581
|
+
--profile <file> Parsed .claude/testing/profile.json. "-" for stdin.
|
|
582
|
+
--no-profile Plan without a profile (reports hasProfile:false and a
|
|
583
|
+
skip reason rather than guessing a command)
|
|
584
|
+
--typecheck-command <cmd> Typecheck command; skipped when absent, never invented
|
|
585
|
+
--lint-command <cmd> Lint command; skipped when absent
|
|
586
|
+
--e2e Opt in to e2e (off by default)
|
|
587
|
+
--no-coverage Skip coverage (advisory either way)
|
|
588
|
+
--no-lint Skip lint
|
|
589
|
+
--pre-existing The suite was already red before this change
|
|
590
|
+
--elapsed-ms <n> Verification time already spent this run
|
|
591
|
+
--budget-ms <n> Soft budget (default ${LIMITS.interWaveVerifyBudgetMs}ms); past it, typecheck only
|
|
592
|
+
--context <ctx> ${Object.keys(VERIFY_CONTEXTS).join('|')}. inter-wave omits e2e and
|
|
593
|
+
coverage by default (typecheck, unit, lint only).
|
|
594
|
+
--changed <files...> Repo-relative paths the last wave touched. When every
|
|
595
|
+
path is documentation, the plan emits no steps.
|
|
596
|
+
|
|
597
|
+
verify judge:
|
|
598
|
+
--plan <file> JSON from "interlock verify plan --json". "-" for stdin.
|
|
599
|
+
--results <file> JSON array of { kind, exitCode, ... } an agent reported
|
|
600
|
+
--context <ctx> ${Object.keys(VERIFY_CONTEXTS).join('|')} (default final). Load-bearing:
|
|
601
|
+
inter-wave halts on typecheck AND unit, final halts on
|
|
602
|
+
unit only, so a red typecheck stops the next wave but
|
|
603
|
+
does not stop the commit.
|
|
604
|
+
--baseline <file> Counts captured before the change, for the weakened-suite check
|
|
605
|
+
--pre-existing Excuse every failure as pre-existing
|
|
606
|
+
--pre-existing-signatures <file> JSON array of signatures known red beforehand
|
|
607
|
+
--state <file> Read "runId" from this wave-state file and append a
|
|
608
|
+
verify-judgement + cli-exit trajectory event pair
|
|
609
|
+
tagged with it. Omit --state and --run-id and no
|
|
610
|
+
event is logged; this command's exit code then never
|
|
611
|
+
depends on logging. Given either, a failed append now
|
|
612
|
+
ALSO exits 1 — the reconstructability gate (see
|
|
613
|
+
"run-log check") means a lost trajectory line here is
|
|
614
|
+
a halt, not a reported no-op.
|
|
615
|
+
--run-id <id> Same, when you have a run id but not a state file.
|
|
616
|
+
--state wins if both are given.
|
|
617
|
+
--change <name> Tag the logged event with this change name.
|
|
618
|
+
Exits 1 when the verdict halts, or when --state/--run-id was given and the
|
|
619
|
+
trajectory append failed.
|
|
620
|
+
|
|
621
|
+
verify unit / cluster / repair:
|
|
622
|
+
--result <file> verify unit: one unit result object. Exits 1 on halt.
|
|
623
|
+
--failures <file> verify cluster: JSON array of failure lines or objects
|
|
624
|
+
--state <file> verify repair: { iterationsUsed, clusters, halt, weakened,
|
|
625
|
+
preExisting, maxIterations }. Exits 1 when it halts.
|
|
626
|
+
|
|
627
|
+
verify judge / unit — the anti-swallow check:
|
|
628
|
+
Before judging, every result's "detail", "cliStdout", "reason", "stdout",
|
|
629
|
+
"stderr", "output", and each "failures[]" entry is checked against the
|
|
630
|
+
verify preview budget (\`interlock limits\`). A field over budget is
|
|
631
|
+
rejected outright — reason "oversized result field(s)" — rather than
|
|
632
|
+
judged: a verify agent must spill it with \`interlock verify spill\` and
|
|
633
|
+
pass the preview instead. Exits 1, same as any other halt.
|
|
634
|
+
|
|
635
|
+
verify spill:
|
|
636
|
+
--run-id <id> Run id the spilled output belongs to (required)
|
|
637
|
+
--kind <k> Step kind, e.g. unit/e2e/lint — becomes part of the
|
|
638
|
+
file name (required)
|
|
639
|
+
--input <file|-> Raw combined stdout/stderr to spill (required)
|
|
640
|
+
Writes the bytes to .claude/ship/spill/<runId>/<seq>-<kind>.log and prints
|
|
641
|
+
{ locator, bytes, preview, truncated, sha256 } — put locator + preview in the
|
|
642
|
+
result the workflow's verify prompt writes, never the full text. Preview is a
|
|
643
|
+
head/tail slice bounded by \`interlock limits\`' verify preview budget.
|
|
644
|
+
Always exits 0.
|
|
645
|
+
|
|
646
|
+
plan:
|
|
647
|
+
fingerprint [change] Hash the inputs a plan for this change is derived from:
|
|
648
|
+
proposal.md, design.md, tasks.md (with checkbox markers
|
|
649
|
+
normalized, so ticking a task does not invalidate the
|
|
650
|
+
plan), every delta spec, the change name, --max-parallel,
|
|
651
|
+
the per-tier lane-cap table, the cohesion tier ceiling,
|
|
652
|
+
the solo envelope, the --mode override (recorded as auto
|
|
653
|
+
when absent), and the plan format version. --write stores
|
|
654
|
+
it at ${FINGERPRINT_PATH}.
|
|
655
|
+
reuse [change] Compare a stored fingerprint against a fresh one and, on
|
|
656
|
+
a match, narrow the stored plan to incomplete tasks and
|
|
657
|
+
write it to --out (default ${NARROWED_PLAN_PATH}).
|
|
658
|
+
Reports "status" as one of match, inputs-changed,
|
|
659
|
+
no-plan, no-fingerprint, unreadable-plan,
|
|
660
|
+
unreadable-fingerprint, plan-format-changed,
|
|
661
|
+
check-failed — a named outcome, never a bare boolean.
|
|
662
|
+
"noRemainingWork" means every task in the plan is
|
|
663
|
+
already complete; do not create a run from it.
|
|
664
|
+
--plan <file> Stored plan to check (default ${PLAN_PATH})
|
|
665
|
+
--fingerprint <file> Stored fingerprint (default ${FINGERPRINT_PATH})
|
|
666
|
+
--out <file> Where "reuse" writes the narrowed plan
|
|
667
|
+
--max-parallel <n> The cap the plan was (or will be) built with; it is part
|
|
668
|
+
of the fingerprint, so asking for a different width
|
|
669
|
+
re-plans rather than reusing a differently-shaped plan.
|
|
670
|
+
--mode solo|waves The mode override the plan was (or will be) built under;
|
|
671
|
+
part of the fingerprint, so a stored plan built with a
|
|
672
|
+
different override (or none) is rebuilt rather than reused.
|
|
673
|
+
--red-wave <n> The leading failing-test section the plan was (or will be)
|
|
674
|
+
built under. Part of the fingerprint for the same reason,
|
|
675
|
+
and OMITTED from it entirely when absent — so no plan
|
|
676
|
+
predating the TDD task shape invalidates for a signal it
|
|
677
|
+
never used.
|
|
678
|
+
Never exits non-zero: every failure to establish a match — including an error
|
|
679
|
+
raised while checking — is a reason to re-plan, which is what happens today.
|
|
680
|
+
Branch on "reuse", and report "reason" either way.
|
|
681
|
+
|
|
682
|
+
wave-state:
|
|
683
|
+
The run state machine, passed as JSON on stdin/stdout because the workflow
|
|
684
|
+
script cannot hold state across agent calls — each agent reads the state in
|
|
685
|
+
and writes the next one out.
|
|
686
|
+
create --plan <file> Plan from "interlock waves --json"; --max-parallel <n>
|
|
687
|
+
next --state <file> --json emits the STEP to take
|
|
688
|
+
record-batch --state <f> --result <f> result: { tasks: [{id, ok, error?, handoff?}], replanSuggested? }
|
|
689
|
+
handoff: { schema: "interlock.wave-handoff/1", taskId,
|
|
690
|
+
status: ok|blocked|partial, summary, evidence[], next,
|
|
691
|
+
blocker }. A task claiming ok:true without a valid,
|
|
692
|
+
in-budget packet is recorded as failed ("invalid
|
|
693
|
+
handoff: ..."); see "interlock limits" for the cap.
|
|
694
|
+
The next wave's step carries these as previousHandoffs.
|
|
695
|
+
record-verify --state <f> --result <f> result: { ok, errors?, blocksNextWave? } or
|
|
696
|
+
{ skipped: true, reason }
|
|
697
|
+
replan --state <f> --groups <f> groups: [{ group, tasks: [...] }]
|
|
698
|
+
--write-state <file> record-* / replan: write the new state to <file>;
|
|
699
|
+
with --json, stdout is the NEXT STEP (same shape as "next")
|
|
700
|
+
--change <name> Tag the trajectory events below with this change name
|
|
701
|
+
(default "unnamed"); has no effect on the state itself.
|
|
702
|
+
With --json, create / record-* / replan emit the NEW STATE — pipe it into the
|
|
703
|
+
next call. Only "next" emits a step instead. --write-state flips record-* /
|
|
704
|
+
replan to that step-on-stdout shape so a ship agent need not call next again.
|
|
705
|
+
Every subcommand also appends a wave-action + cli-exit event to
|
|
706
|
+
.claude/ship/runs/<runId>.jsonl (runId read from the state). A failed append
|
|
707
|
+
is reported on stderr AND now exits 1 (the reconstructability gate) — a lost
|
|
708
|
+
trajectory line here is a halt, not a bookkeeping miss. "next" additionally
|
|
709
|
+
logs one agent-spawn per task on a run-batch/test-wave step, same gate.
|
|
710
|
+
|
|
711
|
+
autonomy record:
|
|
712
|
+
--blockers <n> Blockers caught by this gate (default 0)
|
|
713
|
+
--human-override Record that a human overrode the result
|
|
714
|
+
|
|
715
|
+
Examples:
|
|
716
|
+
interlock doctor || echo "fix these before starting a zero-touch run"
|
|
717
|
+
interlock doctor --json | jq '.failures'
|
|
718
|
+
interlock surface --changed src/Button.tsx docs/readme.md
|
|
719
|
+
interlock waves --classified plan.json --max-parallel 6 --json
|
|
720
|
+
interlock gate --findings review.json || echo "blocked"
|
|
721
|
+
interlock limits --json
|
|
722
|
+
interlock remediate --findings review.json --round 3 --json || echo "halted"
|
|
723
|
+
interlock review --findings raised.json --verdicts verdicts.json --metrics add-auth
|
|
724
|
+
interlock risk add-auth --paths src/auth/session.ts,src/api/login.ts --json
|
|
725
|
+
interlock ledger add-auth || echo "a human still owes a decision"
|
|
726
|
+
interlock verify plan --profile .claude/testing/profile.json --typecheck-command "npx tsc --noEmit" --json > vplan.json
|
|
727
|
+
interlock verify judge --plan vplan.json --results results.json --context inter-wave || echo "halt"
|
|
728
|
+
interlock plan reuse add-auth --json || true
|
|
729
|
+
interlock plan fingerprint add-auth --write --json
|
|
730
|
+
interlock waves --classified plan.json --json | interlock wave-state create --plan - --json > run.json
|
|
731
|
+
interlock wave-state next --state run.json --json
|
|
732
|
+
interlock wave-state record-batch --state run.json --result batch1.json --json > run2.json
|
|
733
|
+
interlock wave-state record-batch --state run.json --result batch1.json --write-state run.json --json
|
|
734
|
+
interlock autonomy level review-code
|
|
735
|
+
`
|
|
736
|
+
|
|
737
|
+
// Flags that never take a value. Everything else consumes the next argv entry
|
|
738
|
+
// unless that entry is itself a --flag.
|
|
739
|
+
const BOOLEAN_FLAGS = new Set([
|
|
740
|
+
'json',
|
|
741
|
+
'human-override',
|
|
742
|
+
'no-tolerance',
|
|
743
|
+
'e2e',
|
|
744
|
+
'no-coverage',
|
|
745
|
+
'no-lint',
|
|
746
|
+
'no-profile',
|
|
747
|
+
'pre-existing',
|
|
748
|
+
'halted',
|
|
749
|
+
'write',
|
|
750
|
+
// `run start`'s invocation flags. Each is a shape decision the driver already
|
|
751
|
+
// parsed; the run manifest is where they stop being host-specific.
|
|
752
|
+
'apply-only',
|
|
753
|
+
'no-commit',
|
|
754
|
+
'skip-e2e',
|
|
755
|
+
'skip-coverage',
|
|
756
|
+
'isolate-waves',
|
|
757
|
+
'continue',
|
|
758
|
+
// The task-shape override. `--red-wave` is deliberately NOT here: it takes a
|
|
759
|
+
// section number, and making the name boolean would swallow that value.
|
|
760
|
+
'tdd',
|
|
761
|
+
'no-tdd',
|
|
762
|
+
// `run close --notify`: both drivers pass it unconditionally, and the CLI —
|
|
763
|
+
// not the driver — decides whether a topic is configured (design D1).
|
|
764
|
+
'notify'
|
|
765
|
+
// `--review`, `--handoff`, `--conformance` and `--strict` are deliberately
|
|
766
|
+
// NOT here. `interlock ready --review <findings.json>` takes a value under
|
|
767
|
+
// the same spelling, and making the name boolean everywhere would silently
|
|
768
|
+
// turn that file path into a positional. On `run start` they take no value,
|
|
769
|
+
// and the parser already yields `true` for a flag followed by another flag or
|
|
770
|
+
// by nothing — which is the only way `run start` is ever invoked.
|
|
771
|
+
])
|
|
772
|
+
|
|
773
|
+
function parseArgs(argv) {
|
|
774
|
+
const out = { _: [], flags: {} }
|
|
775
|
+
for (let i = 0; i < argv.length; i++) {
|
|
776
|
+
const a = argv[i]
|
|
777
|
+
if (!a.startsWith('--')) {
|
|
778
|
+
out._.push(a)
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
const key = a.slice(2)
|
|
782
|
+
const next = argv[i + 1]
|
|
783
|
+
// Boolean flags take no value.
|
|
784
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
785
|
+
out.flags[key] = true
|
|
786
|
+
continue
|
|
787
|
+
}
|
|
788
|
+
if (key === 'changed') {
|
|
789
|
+
// Variadic: consume until the next --flag.
|
|
790
|
+
const vals = []
|
|
791
|
+
while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) vals.push(argv[++i])
|
|
792
|
+
out.flags.changed = vals
|
|
793
|
+
continue
|
|
794
|
+
}
|
|
795
|
+
if (key === 'mode') {
|
|
796
|
+
// Accumulated rather than last-wins: `--mode solo --mode waves` must reach
|
|
797
|
+
// `decideMode` as an array so it is rejected as contradictory, not silently
|
|
798
|
+
// resolved to the last flag. A single `--mode solo` stays a scalar string.
|
|
799
|
+
const val = next === undefined || next.startsWith('--') ? true : argv[++i]
|
|
800
|
+
out.flags.mode = out.flags.mode === undefined ? val : [].concat(out.flags.mode, val)
|
|
801
|
+
continue
|
|
802
|
+
}
|
|
803
|
+
if (next === undefined || next.startsWith('--')) {
|
|
804
|
+
out.flags[key] = true
|
|
805
|
+
continue
|
|
806
|
+
}
|
|
807
|
+
out.flags[key] = next
|
|
808
|
+
i++
|
|
809
|
+
}
|
|
810
|
+
return out
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// stdin can only be drained once, so a command asking for two "-" inputs would
|
|
814
|
+
// silently get an empty second one. Say so instead.
|
|
815
|
+
let stdinConsumed = false
|
|
816
|
+
|
|
817
|
+
function readStdin() {
|
|
818
|
+
if (stdinConsumed) {
|
|
819
|
+
die('only one input per invocation may be "-": stdin has already been read')
|
|
820
|
+
}
|
|
821
|
+
stdinConsumed = true
|
|
822
|
+
try {
|
|
823
|
+
return readFileSync(0, 'utf8')
|
|
824
|
+
} catch {
|
|
825
|
+
return ''
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function readInput(value, what) {
|
|
830
|
+
if (!value || value === true) die(`${what} is required`)
|
|
831
|
+
const raw = value === '-' ? readStdin() : readFileSync(value, 'utf8')
|
|
832
|
+
try {
|
|
833
|
+
return JSON.parse(raw)
|
|
834
|
+
} catch (err) {
|
|
835
|
+
die(`${what} is not valid JSON: ${err.message}`)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Like `readInput`, but for a payload that is bytes to spill rather than JSON
|
|
840
|
+
// to parse — `verify spill` hands this raw suite output, which is exactly the
|
|
841
|
+
// text the rest of the CLI must never try to JSON.parse.
|
|
842
|
+
function readRawInput(value, what) {
|
|
843
|
+
if (!value || value === true) die(`${what} is required`)
|
|
844
|
+
return value === '-' ? readStdin() : readFileSync(value, 'utf8')
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function die(message) {
|
|
848
|
+
process.stderr.write(`interlock: ${message}\n`)
|
|
849
|
+
process.exit(1)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function warn(message) {
|
|
853
|
+
process.stderr.write(`interlock: ${message}\n`)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* A run step in human form. The JSON is what a driver obeys; this is for the
|
|
858
|
+
* person running `interlock run` by hand to see what the program decided.
|
|
859
|
+
*/
|
|
860
|
+
function formatRunStep(step) {
|
|
861
|
+
const lines = [`${step.action}${step.wave === undefined ? '' : ` — wave ${step.wave}`}`]
|
|
862
|
+
if (step.reason) lines.push(` reason: ${step.reason}`)
|
|
863
|
+
for (const s of step.spawns || []) {
|
|
864
|
+
lines.push(
|
|
865
|
+
` spawn ${s.label} (${s.kind}${s.model ? `, ${s.model}` : ''}${s.effort ? `, ${s.effort}` : ''})` +
|
|
866
|
+
` → ${s.promptPath}`
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
for (const banner of step.banners || []) lines.push(` ${banner}`)
|
|
870
|
+
lines.push(step.then ? ` then: interlock ${step.then.argv.join(' ')}` : ' then: (terminal)')
|
|
871
|
+
return lines.join('\n') + '\n'
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function emit(json, obj, text) {
|
|
875
|
+
process.stdout.write(json ? JSON.stringify(obj, null, 2) + '\n' : text)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* record-* / replan: default stdout is the new state. --write-state writes that
|
|
880
|
+
* state to a file and stdout becomes nextStep() — the shape `wave-state next`
|
|
881
|
+
* already emits — so a ship agent can record and learn the next action in one turn.
|
|
882
|
+
*/
|
|
883
|
+
function emitRecordedState(json, flags, state, { root, change, logOk = true, source, recorded } = {}) {
|
|
884
|
+
const dest = flags['write-state']
|
|
885
|
+
if (dest === true) die('--write-state requires a file path')
|
|
886
|
+
if (dest === '-') die('--write-state cannot be "-"')
|
|
887
|
+
// The per-task verdicts this call recorded, reported to the caller that
|
|
888
|
+
// recorded them. Additive, on both paths: without it a host learns the next
|
|
889
|
+
// action and never learns that a task it was told succeeded was failed on
|
|
890
|
+
// arrival — so it ticks the box anyway. Never written to the state file: the
|
|
891
|
+
// outcomes describe one batch, and a resumed run must not read them as its
|
|
892
|
+
// own. (`mutable()` strips one that was piped back in.)
|
|
893
|
+
const carry = obj => (recorded ? { ...obj, recorded } : obj)
|
|
894
|
+
if (dest) {
|
|
895
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
896
|
+
writeFileSync(dest, JSON.stringify(state, null, 2) + '\n')
|
|
897
|
+
const step = nextStep(state)
|
|
898
|
+
const spawnOk = logAgentSpawns(root, { state, change, step, source })
|
|
899
|
+
emit(json, carry(step), formatRunState(state))
|
|
900
|
+
if (step.action === 'halt' || state.halt || !logOk || !spawnOk) process.exit(1)
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
emit(json, carry(state), formatRunState(state))
|
|
904
|
+
if (state.halt || !logOk) process.exit(1)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// `state.runId` travels on the frozen wave-state (design.md decision 1) — read
|
|
908
|
+
// the same defensive way `logWaveMutation` does, since a malformed state must
|
|
909
|
+
// not crash the CLI, only fail the (never-throw) trajectory append.
|
|
910
|
+
function runIdOf(state) {
|
|
911
|
+
return state && typeof state.runId === 'string' ? state.runId : undefined
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// `state.change` travels the same way `state.runId` does, and is read the same
|
|
915
|
+
// defensive way. It is the AUTHORITY over `--change` (design.md decision 2): the
|
|
916
|
+
// state's name was fixed when the run was created, while the flag is
|
|
917
|
+
// per-invocation and is the mechanism that demonstrably drifted. Preferring the
|
|
918
|
+
// flag would let one mislabeled call relabel part of a trajectory, producing a
|
|
919
|
+
// log whose lines disagree about which change they belong to — worse than a
|
|
920
|
+
// uniformly unnamed one, because it looks correct.
|
|
921
|
+
function changeOf(state) {
|
|
922
|
+
const name = state && typeof state.change === 'string' ? state.change.trim() : ''
|
|
923
|
+
return name || undefined
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// The name to stamp on this invocation's events: the state's, else the flag's
|
|
927
|
+
// (a state.json written before the name was carried on state). Neither leaves it
|
|
928
|
+
// undefined, and `appendRunLogEvent` writes the absent-name placeholder rather
|
|
929
|
+
// than failing — losing a trajectory label must not lose the run.
|
|
930
|
+
function changeFor(state, flagChange) {
|
|
931
|
+
const flag = typeof flagChange === 'string' && flagChange.trim() ? flagChange.trim() : undefined
|
|
932
|
+
return changeOf(state) || flag
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// `wave-state next` / `--write-state` both surface the state machine's next
|
|
936
|
+
// step. A `run-batch`/`test-wave` step carries `tasks[]` (the current batch, as
|
|
937
|
+
// LANES) and `remainingBatches` (rest of the wave, as batches of lanes). ship.js
|
|
938
|
+
// runs remainingBatches sequentially before the next record ping, so the
|
|
939
|
+
// reconstructable spawn list is remainingBatches — not only tasks[]. Mid-wave
|
|
940
|
+
// record-batch --write-state is cursor catch-up and MUST NOT duplicate those
|
|
941
|
+
// events.
|
|
942
|
+
//
|
|
943
|
+
// One spawn per LANE, because a lane is one agent. Counting per task would
|
|
944
|
+
// report four spawns for a wave that ran two, which is the fold this change
|
|
945
|
+
// exists for reported as if it never happened.
|
|
946
|
+
function spawnLanesOf(step) {
|
|
947
|
+
const lanes =
|
|
948
|
+
Array.isArray(step.remainingBatches) && step.remainingBatches.length
|
|
949
|
+
? step.remainingBatches.flat(1)
|
|
950
|
+
: Array.isArray(step.tasks)
|
|
951
|
+
? step.tasks
|
|
952
|
+
: []
|
|
953
|
+
return lanes
|
|
954
|
+
.map(lane => (Array.isArray(lane) ? lane.filter(t => t && typeof t === 'object') : []))
|
|
955
|
+
.filter(lane => lane.length)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// The label the workflow gives the same agent (`workflows/ship.js` laneLabel), so
|
|
959
|
+
// a trajectory line and the agent it describes carry one name rather than two.
|
|
960
|
+
function laneLabelOf(lane) {
|
|
961
|
+
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// The model the lane runs on: its hardest task's. A lane is one agent and that
|
|
965
|
+
// agent has to be capable of everything in it.
|
|
966
|
+
function laneModelOf(lane) {
|
|
967
|
+
const tier = lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
|
|
968
|
+
const hardest = lane.find(t => t.tier === tier) || lane[0]
|
|
969
|
+
return hardest && typeof hardest.model === 'string' ? hardest.model : undefined
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Takes the run's `state` rather than a runId, so the change name is resolved
|
|
973
|
+
// from it the same way `logWaveMutation` does instead of being threaded in by a
|
|
974
|
+
// caller who may not have been given one.
|
|
975
|
+
function logAgentSpawns(root, { state, change, step, source }) {
|
|
976
|
+
if (!step || (step.action !== 'run-batch' && step.action !== 'test-wave')) return true
|
|
977
|
+
if (source === 'record-batch' && Number(step.batchIndex) > 0) return true
|
|
978
|
+
const runId = runIdOf(state)
|
|
979
|
+
const changeName = changeFor(state, change)
|
|
980
|
+
let ok = true
|
|
981
|
+
for (const lane of spawnLanesOf(step)) {
|
|
982
|
+
const spawn = appendRunLogEvent(root, {
|
|
983
|
+
runId,
|
|
984
|
+
change: changeName,
|
|
985
|
+
type: 'agent-spawn',
|
|
986
|
+
label: laneLabelOf(lane),
|
|
987
|
+
model: laneModelOf(lane),
|
|
988
|
+
kind: 'implementer',
|
|
989
|
+
// The lane's anchor task. A multi-task lane's later ids are recoverable
|
|
990
|
+
// from the plan and from its per-task record-batch results; what this line
|
|
991
|
+
// is for is naming the AGENT, and there is one of those per lane.
|
|
992
|
+
taskId: typeof lane[0].id === 'string' ? lane[0].id : undefined
|
|
993
|
+
})
|
|
994
|
+
if (!spawn.written) {
|
|
995
|
+
process.stderr.write(`interlock: agent-spawn not recorded: ${spawn.reason}\n`)
|
|
996
|
+
ok = false
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return ok
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// The anti-swallow check (design.md §3.3): a verify agent that pasted a
|
|
1003
|
+
// spilled suite log into a result field instead of using the locator+preview
|
|
1004
|
+
// `interlock verify spill` handed it is rejected before its result reaches a
|
|
1005
|
+
// judgement, never judged as if the oversized text were a legitimate report.
|
|
1006
|
+
function rejectOversized(results, context) {
|
|
1007
|
+
const check = checkResultFieldSizes(results)
|
|
1008
|
+
if (check.ok) return null
|
|
1009
|
+
const reason =
|
|
1010
|
+
`oversized result field(s): ${check.violations
|
|
1011
|
+
.map(v => `${v.kind}.${v.field} (${v.length} chars, budget ${v.limit})`)
|
|
1012
|
+
.join(', ')} — spill it with \`interlock verify spill\` and pass the preview instead`
|
|
1013
|
+
return {
|
|
1014
|
+
halt: true,
|
|
1015
|
+
reason,
|
|
1016
|
+
context: context || null,
|
|
1017
|
+
haltingKinds: [],
|
|
1018
|
+
unit: null,
|
|
1019
|
+
e2e: null,
|
|
1020
|
+
coverage: null,
|
|
1021
|
+
signals: [{ level: 'halt', kind: 'oversized-result', message: reason }],
|
|
1022
|
+
banners: [reason],
|
|
1023
|
+
skipped: []
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// `verify judge` logs a trajectory event only when it can identify the run: a
|
|
1028
|
+
// `--run-id` given directly, or one read from `--state`'s frozen `runId` field
|
|
1029
|
+
// (design.md decision 4 — "the run id cannot drift" when it comes from the
|
|
1030
|
+
// same state file the caller is already threading through the loop). Neither
|
|
1031
|
+
// present means this judge call is not part of a logged ship run — e.g. a
|
|
1032
|
+
// person running `interlock verify judge` by hand — so nothing is logged and
|
|
1033
|
+
// nothing is required to be.
|
|
1034
|
+
function stateFromFlags(flags) {
|
|
1035
|
+
const stateFile = flags.state
|
|
1036
|
+
if (typeof stateFile !== 'string' || stateFile === '-') return undefined
|
|
1037
|
+
try {
|
|
1038
|
+
return JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
1039
|
+
} catch {
|
|
1040
|
+
// Unreadable or malformed state: no run identity to log against, and this
|
|
1041
|
+
// is not the command that should fail the run over a logging concern.
|
|
1042
|
+
return undefined
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function runIdFromFlags(flags) {
|
|
1047
|
+
if (typeof flags['run-id'] === 'string' && flags['run-id'].trim()) return flags['run-id'].trim()
|
|
1048
|
+
return runIdOf(stateFromFlags(flags))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// The mark `durationMs` on a `cli-exit` is measured from. Taken at module scope
|
|
1052
|
+
// so it covers everything this process does under its own name — flag parsing
|
|
1053
|
+
// and the lib/ call — rather than only the last few statements before the
|
|
1054
|
+
// append.
|
|
1055
|
+
//
|
|
1056
|
+
// It is CLI SELF-TIME and nothing else. This process runs BETWEEN agent turns
|
|
1057
|
+
// and never observes one, so the interval here is typically a millisecond or
|
|
1058
|
+
// two; `lib/run-log.mjs` states the same limit at the field, and wave elapsed
|
|
1059
|
+
// time comes from `deriveWaveElapsed` instead. Filling the field with a number
|
|
1060
|
+
// that looked like agent wall clock would be worse than leaving it null.
|
|
1061
|
+
const CLI_STARTED_AT = performance.now()
|
|
1062
|
+
|
|
1063
|
+
// `undefined` rather than `0` when the clock gives nothing usable: the writer
|
|
1064
|
+
// coerces an absent value to `null`, and `null` reads as "not measured" while a
|
|
1065
|
+
// `0` would assert an instantaneous command.
|
|
1066
|
+
function cliDurationMs() {
|
|
1067
|
+
const ms = performance.now() - CLI_STARTED_AT
|
|
1068
|
+
return Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : undefined
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// A `verify-judgement` line never carries the suite transcript: only the
|
|
1072
|
+
// verdict's own short reason and status, plus locators for whatever the
|
|
1073
|
+
// verify helper already spilled (task 3.3) — never the results' raw text.
|
|
1074
|
+
// Returns whether the trajectory was written, so the reconstructability gate
|
|
1075
|
+
// (task 6.2) can turn a failed append into a non-zero exit once it is wired
|
|
1076
|
+
// in — a caller with no runId gets `true` back, since nothing was owed.
|
|
1077
|
+
function logVerifyJudgement(root, { runId, change, verdict, results }) {
|
|
1078
|
+
if (!runId) return true
|
|
1079
|
+
const spill = Array.isArray(results)
|
|
1080
|
+
? results.filter(r => r && typeof r.locator === 'string' && r.locator).map(r => r.locator)
|
|
1081
|
+
: []
|
|
1082
|
+
const logged = appendRunLogEvent(root, {
|
|
1083
|
+
runId,
|
|
1084
|
+
change,
|
|
1085
|
+
type: 'verify-judgement',
|
|
1086
|
+
context: verdict.context,
|
|
1087
|
+
halt: verdict.halt,
|
|
1088
|
+
reason: verdict.reason || (verdict.halt ? 'verification halted' : 'verification ok'),
|
|
1089
|
+
unitStatus: verdict.unit ? verdict.unit.status : null,
|
|
1090
|
+
spill
|
|
1091
|
+
})
|
|
1092
|
+
if (!logged.written) {
|
|
1093
|
+
process.stderr.write(`interlock: verify-judgement not recorded: ${logged.reason}\n`)
|
|
1094
|
+
}
|
|
1095
|
+
// Mirrors what `logWaveMutation` does for `wave-state`: a `cli-exit` alongside
|
|
1096
|
+
// the semantic event, so the reconstructability check (task 6.1) can require
|
|
1097
|
+
// "a cli-exit per wave-state / verify judge invocation" uniformly, without
|
|
1098
|
+
// special-casing verify.
|
|
1099
|
+
const exit = appendRunLogEvent(root, {
|
|
1100
|
+
runId,
|
|
1101
|
+
change,
|
|
1102
|
+
type: 'cli-exit',
|
|
1103
|
+
command: 'verify judge',
|
|
1104
|
+
exitCode: verdict.halt ? 1 : 0,
|
|
1105
|
+
durationMs: cliDurationMs()
|
|
1106
|
+
})
|
|
1107
|
+
if (!exit.written) {
|
|
1108
|
+
process.stderr.write(`interlock: cli-exit not recorded: ${exit.reason}\n`)
|
|
1109
|
+
}
|
|
1110
|
+
return logged.written && exit.written
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// `wave-state create|next|record-*|replan` all append their own trajectory
|
|
1114
|
+
// events (design.md decision 1: `runId` is created once, in createRunState,
|
|
1115
|
+
// and travels on the frozen state — no new flag needed) so a ship agent never
|
|
1116
|
+
// spends a turn calling `run-log append` itself for a wave-state mutation.
|
|
1117
|
+
// `nextStep(state)` already knows the state-machine action, wave, and cursor
|
|
1118
|
+
// this event describes; callers that also need `step` for their own stdout
|
|
1119
|
+
// (e.g. `next`) get it back instead of computing it twice. `ok` reports
|
|
1120
|
+
// whether both appends were written, so the reconstructability gate (task
|
|
1121
|
+
// 6.2) can turn a failed trajectory write into a non-zero exit.
|
|
1122
|
+
function logWaveMutation(root, { source, change, state }) {
|
|
1123
|
+
const step = nextStep(state)
|
|
1124
|
+
const runId = runIdOf(state)
|
|
1125
|
+
const changeName = changeFor(state, change)
|
|
1126
|
+
const wave = appendRunLogEvent(root, {
|
|
1127
|
+
runId,
|
|
1128
|
+
change: changeName,
|
|
1129
|
+
type: 'wave-action',
|
|
1130
|
+
source,
|
|
1131
|
+
action: step.action,
|
|
1132
|
+
// `step.wave` is `wave.group` from lib/waves.mjs — a plan-assigned integer,
|
|
1133
|
+
// not a label — so it is stringified for the text field rather than dropped.
|
|
1134
|
+
wave: step.wave === undefined || step.wave === null ? null : String(step.wave),
|
|
1135
|
+
waveIndex: Number.isInteger(step.waveIndex) ? step.waveIndex : null,
|
|
1136
|
+
batchIndex: Number.isInteger(step.batchIndex) ? step.batchIndex : null,
|
|
1137
|
+
phase: state && state.cursor ? state.cursor.phase : null
|
|
1138
|
+
})
|
|
1139
|
+
if (!wave.written) {
|
|
1140
|
+
process.stderr.write(`interlock: wave-action not recorded: ${wave.reason}\n`)
|
|
1141
|
+
}
|
|
1142
|
+
const exit = appendRunLogEvent(root, {
|
|
1143
|
+
runId,
|
|
1144
|
+
change: changeName,
|
|
1145
|
+
type: 'cli-exit',
|
|
1146
|
+
command: `wave-state ${source}`,
|
|
1147
|
+
// `nextStep` returns action:'halt' exactly when state.halt is set, so this
|
|
1148
|
+
// mirrors the exit code every call site below actually produces.
|
|
1149
|
+
exitCode: state && state.halt ? 1 : 0,
|
|
1150
|
+
durationMs: cliDurationMs()
|
|
1151
|
+
})
|
|
1152
|
+
if (!exit.written) {
|
|
1153
|
+
process.stderr.write(`interlock: cli-exit not recorded: ${exit.reason}\n`)
|
|
1154
|
+
}
|
|
1155
|
+
return { step, ok: wave.written && exit.written }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* The changed-path set as VERSION CONTROL sees it, for auditing handoff
|
|
1160
|
+
* evidence at `record-batch`.
|
|
1161
|
+
*
|
|
1162
|
+
* Read here rather than in `lib/waves.mjs` because that module is pure, and
|
|
1163
|
+
* read at THIS moment because it is the only one that means anything: the
|
|
1164
|
+
* working tree still matches what the implementer just claimed. Audited later,
|
|
1165
|
+
* the tree has moved and a `confirmed` verdict is worthless.
|
|
1166
|
+
*
|
|
1167
|
+
* Never throws, following `lib/drift.mjs`'s `gitLastCommit` precedent. Missing
|
|
1168
|
+
* git, a non-repository directory, a worktree in a state git dislikes — all
|
|
1169
|
+
* mean "no observed set", which is a documented fallback to the task's own
|
|
1170
|
+
* report, never a failed batch. Recording a wave's work must not depend on git
|
|
1171
|
+
* being cooperative.
|
|
1172
|
+
*
|
|
1173
|
+
* `-z` because a path with a space, a quote or a tab is a path, not a parse
|
|
1174
|
+
* error; `--untracked-files=all` because a new file inside a new directory
|
|
1175
|
+
* would otherwise be reported as the directory and match no locator.
|
|
1176
|
+
*/
|
|
1177
|
+
function observedChangedPaths(root) {
|
|
1178
|
+
let raw
|
|
1179
|
+
try {
|
|
1180
|
+
raw = execFileSync('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {
|
|
1181
|
+
cwd: root,
|
|
1182
|
+
encoding: 'utf8',
|
|
1183
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1184
|
+
})
|
|
1185
|
+
} catch {
|
|
1186
|
+
return []
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const fields = raw.split('\0').filter(f => f.length)
|
|
1190
|
+
const paths = []
|
|
1191
|
+
for (let i = 0; i < fields.length; i++) {
|
|
1192
|
+
const field = fields[i]
|
|
1193
|
+
// "XY path". Anything shorter carries no path.
|
|
1194
|
+
if (field.length < 4) continue
|
|
1195
|
+
const status = field.slice(0, 2)
|
|
1196
|
+
const path = field.slice(3)
|
|
1197
|
+
if (path) paths.push(path)
|
|
1198
|
+
// A rename or copy carries its SOURCE in the following field. Both sides are
|
|
1199
|
+
// paths this run changed, so both are kept — and the source has to be
|
|
1200
|
+
// consumed here or the next iteration would read it as a status line and
|
|
1201
|
+
// slice three characters off the front of a real path.
|
|
1202
|
+
if (/[RC]/.test(status) && fields[i + 1]) {
|
|
1203
|
+
paths.push(fields[i + 1])
|
|
1204
|
+
i += 1
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return paths
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* A lane worktree's actual writes relative to the shared base commit, as git
|
|
1212
|
+
* sees them — never a prediction. `null` means git could not answer (bad
|
|
1213
|
+
* path, not a worktree, git failure of any kind), which the caller treats as
|
|
1214
|
+
* "this lane's worktree could not be located" rather than "wrote nothing".
|
|
1215
|
+
*
|
|
1216
|
+
* `-z` for the same reason `observedChangedPaths` uses it: a path with a
|
|
1217
|
+
* space, quote or tab is a path, not a field separator.
|
|
1218
|
+
*
|
|
1219
|
+
* @param {string} worktreePath
|
|
1220
|
+
* @param {string} base
|
|
1221
|
+
* @returns {Array<{status: 'A'|'M'|'D'|'R', path: string, oldPath?: string}>|null}
|
|
1222
|
+
*/
|
|
1223
|
+
function laneDiff(worktreePath, base) {
|
|
1224
|
+
let raw
|
|
1225
|
+
try {
|
|
1226
|
+
raw = execFileSync('git', ['-C', worktreePath, 'diff', '--name-status', '-z', base], {
|
|
1227
|
+
encoding: 'utf8',
|
|
1228
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1229
|
+
})
|
|
1230
|
+
} catch {
|
|
1231
|
+
return null
|
|
1232
|
+
}
|
|
1233
|
+
const fields = raw.split('\0').filter(f => f.length)
|
|
1234
|
+
const out = []
|
|
1235
|
+
for (let i = 0; i < fields.length; i++) {
|
|
1236
|
+
const status = fields[i]
|
|
1237
|
+
if (/^[AMD]/.test(status)) {
|
|
1238
|
+
const path = fields[++i]
|
|
1239
|
+
if (path) out.push({ status: status[0], path })
|
|
1240
|
+
} else if (/^[RC]/.test(status)) {
|
|
1241
|
+
// "R100\told\tnew": a rename/copy carries its source in the following
|
|
1242
|
+
// field, the same shape `observedChangedPaths` already accounts for.
|
|
1243
|
+
const oldPath = fields[++i]
|
|
1244
|
+
const newPath = fields[++i]
|
|
1245
|
+
if (newPath) out.push({ status: status[0], path: newPath, oldPath: oldPath || undefined })
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// `git diff <base>` never reports an untracked file — a lane's new file is
|
|
1250
|
+
// invisible to it exactly the way `observedChangedPaths` below has to work
|
|
1251
|
+
// around with `--untracked-files=all`. Add them as new writes explicitly,
|
|
1252
|
+
// rather than silently reporting a lane's new file as "no change".
|
|
1253
|
+
let untracked
|
|
1254
|
+
try {
|
|
1255
|
+
untracked = execFileSync(
|
|
1256
|
+
'git',
|
|
1257
|
+
['-C', worktreePath, 'ls-files', '--others', '--exclude-standard', '-z'],
|
|
1258
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
1259
|
+
)
|
|
1260
|
+
} catch {
|
|
1261
|
+
return null
|
|
1262
|
+
}
|
|
1263
|
+
for (const path of untracked.split('\0').filter(p => p.length)) {
|
|
1264
|
+
out.push({ status: 'A', path })
|
|
1265
|
+
}
|
|
1266
|
+
return out
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Apply one lane's already-decided-clean writes into the shared tree. Called
|
|
1271
|
+
* only after `mergeDecision` returned `status: 'clean'` — a collision or an
|
|
1272
|
+
* unresolved lane never reaches here, by construction of the caller.
|
|
1273
|
+
*/
|
|
1274
|
+
function applyLaneDiff(root, worktreePath, diffEntries) {
|
|
1275
|
+
for (const entry of diffEntries) {
|
|
1276
|
+
const destAbs = join(root, entry.path)
|
|
1277
|
+
if (entry.status === 'D') {
|
|
1278
|
+
if (existsSync(destAbs)) rmSync(destAbs)
|
|
1279
|
+
continue
|
|
1280
|
+
}
|
|
1281
|
+
const srcAbs = join(worktreePath, entry.path)
|
|
1282
|
+
if (!existsSync(srcAbs)) continue
|
|
1283
|
+
mkdirSync(dirname(destAbs), { recursive: true })
|
|
1284
|
+
copyFileSync(srcAbs, destAbs)
|
|
1285
|
+
if (entry.status === 'R' && entry.oldPath && entry.oldPath !== entry.path) {
|
|
1286
|
+
const oldAbs = join(root, entry.oldPath)
|
|
1287
|
+
if (existsSync(oldAbs)) rmSync(oldAbs)
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* The git plumbing half of `merge-lanes`: turns each lane's self-reported
|
|
1294
|
+
* `worktreePath` into an observed changed-file set, hands the decision to the
|
|
1295
|
+
* pure `mergeDecision`, and — on a clean fold only — applies the writes and
|
|
1296
|
+
* removes the lane worktrees uniformly. A collision or an unresolved lane
|
|
1297
|
+
* touches nothing and leaves every surviving worktree named on disk.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {string} root
|
|
1300
|
+
* @param {Array<{label: string, worktreePath?: string, reportedFiles?: string[]}>} laneEntries
|
|
1301
|
+
* @param {string} base the shared-tree commit every lane's diff is taken against
|
|
1302
|
+
*/
|
|
1303
|
+
function runMergeLanes(root, laneEntries, base) {
|
|
1304
|
+
const entries = Array.isArray(laneEntries) ? laneEntries : []
|
|
1305
|
+
const diffsByLabel = new Map()
|
|
1306
|
+
const changedByLane = {}
|
|
1307
|
+
const survivingInfo = []
|
|
1308
|
+
|
|
1309
|
+
const lanesForDecision = entries.map(l => ({
|
|
1310
|
+
label: l && typeof l.label === 'string' ? l.label.trim() : ''
|
|
1311
|
+
}))
|
|
1312
|
+
|
|
1313
|
+
for (const l of entries) {
|
|
1314
|
+
const label = l && typeof l.label === 'string' ? l.label.trim() : ''
|
|
1315
|
+
if (!label) continue
|
|
1316
|
+
const path = l && typeof l.worktreePath === 'string' ? l.worktreePath.trim() : ''
|
|
1317
|
+
survivingInfo.push({ label, worktreePath: path || null })
|
|
1318
|
+
if (!path) continue // no worktreePath reported at all — unresolved, named by mergeDecision
|
|
1319
|
+
|
|
1320
|
+
if (!existsSync(path)) {
|
|
1321
|
+
// The runtime auto-removes an unchanged worktree. A lane that reported
|
|
1322
|
+
// no changed files itself is a legitimate empty-write lane, not a lane
|
|
1323
|
+
// whose worktree "could not be located" — anything else missing this
|
|
1324
|
+
// path is unresolved.
|
|
1325
|
+
const reported = Array.isArray(l.reportedFiles) ? l.reportedFiles : null
|
|
1326
|
+
if (reported && reported.length === 0) {
|
|
1327
|
+
changedByLane[label] = []
|
|
1328
|
+
diffsByLabel.set(label, [])
|
|
1329
|
+
}
|
|
1330
|
+
continue
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const diff = laneDiff(path, base)
|
|
1334
|
+
if (diff === null) continue // git could not diff this worktree — unresolved
|
|
1335
|
+
diffsByLabel.set(label, diff)
|
|
1336
|
+
changedByLane[label] = diff.map(d => d.path)
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const decision = mergeDecision({ lanes: lanesForDecision, changedByLane, base })
|
|
1340
|
+
|
|
1341
|
+
if (decision.status !== 'clean') {
|
|
1342
|
+
const survivingWorktrees = survivingInfo.filter(s => s.worktreePath && existsSync(s.worktreePath))
|
|
1343
|
+
return { ...decision, survivingWorktrees }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
for (const fold of decision.folds) {
|
|
1347
|
+
const info = survivingInfo.find(s => s.label === fold.lane)
|
|
1348
|
+
if (!info || !info.worktreePath || !existsSync(info.worktreePath)) continue
|
|
1349
|
+
applyLaneDiff(root, info.worktreePath, diffsByLabel.get(fold.lane) || [])
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Uniform cleanup: an unchanged lane worktree the runtime would have
|
|
1353
|
+
// auto-removed anyway is removed here too, so post-fold state never depends
|
|
1354
|
+
// on which lanes happened to write something.
|
|
1355
|
+
const cleanupWarnings = []
|
|
1356
|
+
for (const info of survivingInfo) {
|
|
1357
|
+
if (!info.worktreePath || !existsSync(info.worktreePath)) continue
|
|
1358
|
+
try {
|
|
1359
|
+
execFileSync('git', ['worktree', 'remove', info.worktreePath, '--force'], {
|
|
1360
|
+
cwd: root,
|
|
1361
|
+
stdio: 'ignore'
|
|
1362
|
+
})
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
cleanupWarnings.push({ label: info.label, worktreePath: info.worktreePath, reason: err.message })
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return {
|
|
1369
|
+
...decision,
|
|
1370
|
+
survivingWorktrees: [],
|
|
1371
|
+
...(cleanupWarnings.length ? { cleanupWarnings } : {})
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function formatMergeLanes(result) {
|
|
1376
|
+
if (result.status === 'clean') {
|
|
1377
|
+
const rows = result.folds
|
|
1378
|
+
.map(f => ` ${f.lane}: ${f.files.length ? f.files.join(', ') : '(no changes)'}`)
|
|
1379
|
+
.join('\n')
|
|
1380
|
+
const warn = result.cleanupWarnings && result.cleanupWarnings.length
|
|
1381
|
+
? `\ncleanup warnings:\n${result.cleanupWarnings.map(w => ` ${w.label} (${w.worktreePath}): ${w.reason}`).join('\n')}\n`
|
|
1382
|
+
: ''
|
|
1383
|
+
return `CLEAN FOLD\n${rows}\n${warn}`
|
|
1384
|
+
}
|
|
1385
|
+
if (result.status === 'collision') {
|
|
1386
|
+
const rows = result.collisions
|
|
1387
|
+
.map(c => ` ${c.canonicalPath}: ${c.lanes.join(', ')}`)
|
|
1388
|
+
.join('\n')
|
|
1389
|
+
const survivors = result.survivingWorktrees
|
|
1390
|
+
.map(s => ` ${s.label}: ${s.worktreePath}`)
|
|
1391
|
+
.join('\n')
|
|
1392
|
+
return `HALT — path collision\n${rows}\nsurviving worktrees:\n${survivors}\n`
|
|
1393
|
+
}
|
|
1394
|
+
const rows = result.unresolved.map(u => ` ${u.label || '(no label)'}: ${u.reason}`).join('\n')
|
|
1395
|
+
const survivors = result.survivingWorktrees
|
|
1396
|
+
.map(s => ` ${s.label}: ${s.worktreePath}`)
|
|
1397
|
+
.join('\n')
|
|
1398
|
+
return `HALT — unresolved lane(s)\n${rows}\nsurviving worktrees:\n${survivors}\n`
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// --min-quality / --no-tolerance mean the same thing everywhere the tolerance
|
|
1402
|
+
// band applies, so the three call sites share one reading of them.
|
|
1403
|
+
function parseBand(flags) {
|
|
1404
|
+
if (flags['no-tolerance']) return null
|
|
1405
|
+
if (flags['min-quality'] === undefined) return undefined
|
|
1406
|
+
const min = flags['min-quality'] === true ? NaN : Number(flags['min-quality'])
|
|
1407
|
+
if (!Number.isFinite(min)) die('--min-quality requires a number')
|
|
1408
|
+
return { ...TOLERANCE_BAND, minQualityToReport: min }
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function num(flags, key) {
|
|
1412
|
+
if (flags[key] === undefined) return undefined
|
|
1413
|
+
const n = flags[key] === true ? NaN : Number(flags[key])
|
|
1414
|
+
if (!Number.isFinite(n)) die(`--${key} requires a number`)
|
|
1415
|
+
return n
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// The plan mode override, resolved for the planner. Absent → undefined (the
|
|
1419
|
+
// classifier's recommendation decides inside the envelope). A single value is
|
|
1420
|
+
// passed through as a string so `decideMode` can accept or reject it; two
|
|
1421
|
+
// contradictory flags are passed through as the array they arrived as, because
|
|
1422
|
+
// resolving them here would hide the contradiction the planner is meant to name.
|
|
1423
|
+
function modeFlag(flags) {
|
|
1424
|
+
const m = flags.mode
|
|
1425
|
+
if (m === undefined) return undefined
|
|
1426
|
+
const values = Array.isArray(m) ? m : [m]
|
|
1427
|
+
if (values.some(v => v === true)) die('--mode requires a value (solo|waves)')
|
|
1428
|
+
const distinct = [...new Set(values)]
|
|
1429
|
+
return distinct.length === 1 ? distinct[0] : distinct
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// `--tdd` / `--no-tdd`, resolved the way `modeFlag` resolves `--mode`: both at
|
|
1433
|
+
// once is refused rather than settled last-wins, because the two produce
|
|
1434
|
+
// different wave orders and different verifications, and picking one silently
|
|
1435
|
+
// would run the shape the operator did not ask for.
|
|
1436
|
+
function tddFlag(flags) {
|
|
1437
|
+
const on = flags.tdd === true || flags.tdd !== undefined
|
|
1438
|
+
const off = flags['no-tdd'] === true || flags['no-tdd'] !== undefined
|
|
1439
|
+
if (on && off) {
|
|
1440
|
+
die('contradictory task-shape flags: --tdd and --no-tdd were both passed — pass exactly one, or neither')
|
|
1441
|
+
}
|
|
1442
|
+
if (on) return 'tdd'
|
|
1443
|
+
if (off) return 'no-tdd'
|
|
1444
|
+
return null
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// The red wave for a bare `interlock waves` call. `--red-wave <n>` is the
|
|
1448
|
+
// explicit form; `--tdd` derives it from the classification — the lowest group
|
|
1449
|
+
// whose tasks are all test tasks — which is all this subcommand can see. The
|
|
1450
|
+
// planner adjudicates whatever comes back and refuses a claim the tasks
|
|
1451
|
+
// contradict, so nothing here has to validate the answer.
|
|
1452
|
+
function redWaveFlag(flags, input) {
|
|
1453
|
+
if (tddFlag(flags) === 'no-tdd') return undefined
|
|
1454
|
+
if (flags['red-wave'] !== undefined) {
|
|
1455
|
+
const n = Number(flags['red-wave'])
|
|
1456
|
+
if (!Number.isInteger(n)) die('--red-wave requires an integer section number')
|
|
1457
|
+
return n
|
|
1458
|
+
}
|
|
1459
|
+
if (tddFlag(flags) !== 'tdd') return undefined
|
|
1460
|
+
const tasks = Array.isArray(input && input.tasks) ? input.tasks : []
|
|
1461
|
+
const groups = [...new Set(tasks.map(t => t && t.group).filter(Number.isInteger))].sort(
|
|
1462
|
+
(a, b) => a - b
|
|
1463
|
+
)
|
|
1464
|
+
const lowest = groups[0]
|
|
1465
|
+
if (lowest === undefined) return undefined
|
|
1466
|
+
const inLowest = tasks.filter(t => t && t.group === lowest)
|
|
1467
|
+
return inLowest.length && inLowest.every(t => t.isTestTask === true) ? lowest : undefined
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function requestedName(positional, flags) {
|
|
1471
|
+
if (typeof flags.change === 'string' && flags.change.trim()) return flags.change.trim()
|
|
1472
|
+
return positional[1]
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// One resolution path for every command that names a change, so "which change?"
|
|
1476
|
+
// fails the same actionable way each time.
|
|
1477
|
+
function requireChange(root, requested, json) {
|
|
1478
|
+
const resolved = resolveChange(root, requested)
|
|
1479
|
+
if (!resolved.error) return resolved.name
|
|
1480
|
+
if (json) {
|
|
1481
|
+
emit(true, resolved, '')
|
|
1482
|
+
process.exit(1)
|
|
1483
|
+
}
|
|
1484
|
+
die(
|
|
1485
|
+
`${resolved.error}${
|
|
1486
|
+
resolved.candidates.length ? `\n candidates: ${resolved.candidates.join(', ')}` : ''
|
|
1487
|
+
}`
|
|
1488
|
+
)
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// risk.mjs is pure and takes artifact TEXT, so the filesystem read belongs here.
|
|
1492
|
+
function readChangeArtifacts(root, change) {
|
|
1493
|
+
const info = inspectChange(root, change)
|
|
1494
|
+
const artifacts = {}
|
|
1495
|
+
for (const name of info.present) {
|
|
1496
|
+
artifacts[name] = readFileSync(join(info.path, name), 'utf8')
|
|
1497
|
+
}
|
|
1498
|
+
for (const rel of info.specFiles) {
|
|
1499
|
+
const p = join(info.path, 'specs', rel)
|
|
1500
|
+
if (existsSync(p)) artifacts[join('specs', rel)] = readFileSync(p, 'utf8')
|
|
1501
|
+
}
|
|
1502
|
+
return artifacts
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function splitPaths(value) {
|
|
1506
|
+
if (value === undefined || value === true) return []
|
|
1507
|
+
const raw = value === '-' ? readStdin().split('\n') : String(value).split(',')
|
|
1508
|
+
return raw.map(p => p.trim()).filter(Boolean)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async function main() {
|
|
1512
|
+
const argv = process.argv.slice(2)
|
|
1513
|
+
if (!argv.length || argv[0] === '-h' || argv[0] === '--help') {
|
|
1514
|
+
process.stdout.write(USAGE)
|
|
1515
|
+
return
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const { _: positional, flags } = parseArgs(argv)
|
|
1519
|
+
const cmd = positional[0]
|
|
1520
|
+
const json = Boolean(flags.json)
|
|
1521
|
+
const root = typeof flags.root === 'string' ? flags.root : '.'
|
|
1522
|
+
|
|
1523
|
+
switch (cmd) {
|
|
1524
|
+
case 'waves': {
|
|
1525
|
+
const input = readInput(flags.classified, '--classified')
|
|
1526
|
+
const maxParallel = flags['max-parallel'] ? Number(flags['max-parallel']) : undefined
|
|
1527
|
+
// This subcommand sees only the classified input — it has no change name
|
|
1528
|
+
// and therefore no tasks.md to read the section marker from. So the red
|
|
1529
|
+
// wave is either named outright (`--red-wave`) or derived from the
|
|
1530
|
+
// classification's own structure (`--tdd`). A run resolves it from the
|
|
1531
|
+
// marker instead; see `resolveRedWave` in lib/run.mjs.
|
|
1532
|
+
const plan = planWaves(input, {
|
|
1533
|
+
maxParallel,
|
|
1534
|
+
mode: modeFlag(flags),
|
|
1535
|
+
redWave: redWaveFlag(flags, input)
|
|
1536
|
+
})
|
|
1537
|
+
emit(json, plan, formatPlan(plan))
|
|
1538
|
+
break
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
case 'surface': {
|
|
1542
|
+
let changed = flags.changed
|
|
1543
|
+
if (changed === '-' || (Array.isArray(changed) && changed[0] === '-')) {
|
|
1544
|
+
changed = readStdin().split('\n')
|
|
1545
|
+
}
|
|
1546
|
+
if (!Array.isArray(changed)) changed = changed ? [changed] : []
|
|
1547
|
+
const result = classifySurface(changed)
|
|
1548
|
+
const text =
|
|
1549
|
+
result.files.map(f => `${f.class}\t${f.path}\t(${f.reason})`).join('\n') +
|
|
1550
|
+
(result.files.length ? '\n' : '') +
|
|
1551
|
+
`\nui=${result.uiCount} indirect=${result.indirectCount} backend=${result.backendCount}\n` +
|
|
1552
|
+
`needsManualTestPlan=${result.needsManualTestPlan} needsDevopsReview=${result.needsDevopsReview}\n`
|
|
1553
|
+
emit(json, result, text)
|
|
1554
|
+
break
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
case 'gate': {
|
|
1558
|
+
const input = readInput(flags.findings, '--findings')
|
|
1559
|
+
const dismissed =
|
|
1560
|
+
typeof flags.dismissed === 'string'
|
|
1561
|
+
? flags.dismissed.split(',').map(s => s.trim()).filter(Boolean)
|
|
1562
|
+
: []
|
|
1563
|
+
// The band is on by default and applied inside evaluateGate; these flags
|
|
1564
|
+
// only tune or disable it.
|
|
1565
|
+
const band = parseBand(flags)
|
|
1566
|
+
const result = evaluateGate(input, { dismissed, band })
|
|
1567
|
+
|
|
1568
|
+
// Metrics are bookkeeping, exactly as in `review`: they report their own
|
|
1569
|
+
// failure and never touch the verdict or the exit code, so a read-only
|
|
1570
|
+
// checkout cannot fail a gate.
|
|
1571
|
+
//
|
|
1572
|
+
// `evaluateGate().counts` is a severity histogram (blocker/warning/
|
|
1573
|
+
// suggestion), NOT the four-way partition `writeReviewMetrics` records,
|
|
1574
|
+
// so it cannot be handed over the way `resolveReview().counts` can. The
|
|
1575
|
+
// four figures are derived here from the ones that produced the verdict —
|
|
1576
|
+
// arithmetic over the returned counts, never a second reading of the
|
|
1577
|
+
// input, which is what `review/metrics-emission` requires. A finding the
|
|
1578
|
+
// gate refused to interpret still reached the verdict (it blocks), so
|
|
1579
|
+
// `malformed` counts as surviving rather than vanishing from both sides.
|
|
1580
|
+
let metrics = null
|
|
1581
|
+
if (flags.metrics !== undefined) {
|
|
1582
|
+
if (flags.metrics === true) die('--metrics requires a change name')
|
|
1583
|
+
const surviving = result.total + result.malformed.length
|
|
1584
|
+
metrics = writeReviewMetrics(root, {
|
|
1585
|
+
change: String(flags.metrics),
|
|
1586
|
+
counts: {
|
|
1587
|
+
raised: result.dismissedCount + result.droppedByQuality + surviving,
|
|
1588
|
+
dismissed: result.dismissedCount,
|
|
1589
|
+
droppedByQuality: result.droppedByQuality,
|
|
1590
|
+
surviving
|
|
1591
|
+
}
|
|
1592
|
+
})
|
|
1593
|
+
if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const text =
|
|
1597
|
+
formatGate(result) +
|
|
1598
|
+
(metrics ? ` metrics: ${metrics.written ? metrics.path : `not written (${metrics.reason})`}\n` : '')
|
|
1599
|
+
emit(json, { ...result, ...(metrics ? { metrics } : {}) }, text)
|
|
1600
|
+
if (!result.passed) process.exit(1)
|
|
1601
|
+
break
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
case 'validate': {
|
|
1605
|
+
const name = requireChange(root, requestedName(positional, flags), json)
|
|
1606
|
+
const result = inspectChange(root, name)
|
|
1607
|
+
emit(json, result, formatInspection(result))
|
|
1608
|
+
if (!result.ready) process.exit(1)
|
|
1609
|
+
break
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
case 'changes': {
|
|
1613
|
+
const changes = listChanges(root)
|
|
1614
|
+
emit(json, { changes }, changes.length ? changes.join('\n') + '\n' : '(none)\n')
|
|
1615
|
+
break
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
case 'drift': {
|
|
1619
|
+
// No exit(1) here, ever. Unlike every other gating subcommand, drift
|
|
1620
|
+
// reports and does not block — see the header of lib/drift.mjs for why.
|
|
1621
|
+
//
|
|
1622
|
+
// `--changed` absent and `--changed` empty are different: absent means the
|
|
1623
|
+
// caller has no diff and orphan detection cannot run, empty means the diff
|
|
1624
|
+
// was genuinely empty. Only the first is "not checked".
|
|
1625
|
+
const changed = flags.changed === undefined ? null : splitPaths(flags.changed)
|
|
1626
|
+
const report = analyzeDrift(root, changed ? { changed } : {})
|
|
1627
|
+
emit(json, report, formatDrift(report) + '\n')
|
|
1628
|
+
break
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
case 'report': {
|
|
1632
|
+
// No exit(1) here, ever — and unlike `drift`, not because a signal is
|
|
1633
|
+
// inferred but because §4.15(a) forbids the comparison a non-zero status
|
|
1634
|
+
// would encode. Every value here is one `if` away from a threshold; the
|
|
1635
|
+
// absence of that `if` is the feature. See the header of lib/report.mjs.
|
|
1636
|
+
const report = buildReport(root, {
|
|
1637
|
+
since: typeof flags.since === 'string' ? flags.since : undefined,
|
|
1638
|
+
change: typeof flags.change === 'string' ? flags.change : undefined,
|
|
1639
|
+
maxRuns: num(flags, 'max-runs')
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
if (flags.html !== undefined) {
|
|
1643
|
+
// Refused rather than resolved by precedence. `emit` threads a single
|
|
1644
|
+
// `json` flag, so a rule about which surface wins would be invisible at
|
|
1645
|
+
// the call site and unlike every other subcommand here.
|
|
1646
|
+
if (json) die('--html and --json are mutually exclusive: choose one surface')
|
|
1647
|
+
|
|
1648
|
+
const document = renderReportHtml(report)
|
|
1649
|
+
|
|
1650
|
+
// Bare --html goes to stdout, so the document composes with a shell
|
|
1651
|
+
// redirect and no invocation writes to disk unasked. A named path is
|
|
1652
|
+
// the only thing that makes this command a writer.
|
|
1653
|
+
if (flags.html === true) {
|
|
1654
|
+
process.stdout.write(document)
|
|
1655
|
+
break
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const dest = String(flags.html)
|
|
1659
|
+
if (!dest.trim()) die('--html <path> requires a file path')
|
|
1660
|
+
// Written beside the destination and renamed into place, so a failure
|
|
1661
|
+
// partway through leaves nothing at the path the caller named — an
|
|
1662
|
+
// aborted render must not look like a report that says less than it
|
|
1663
|
+
// should. `rename` within a directory is atomic.
|
|
1664
|
+
const staging = `${dest}.interlock-partial`
|
|
1665
|
+
try {
|
|
1666
|
+
writeFileSync(staging, document)
|
|
1667
|
+
renameSync(staging, dest)
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
try {
|
|
1670
|
+
rmSync(staging, { force: true })
|
|
1671
|
+
} catch {
|
|
1672
|
+
// The cleanup of a file that may never have existed cannot itself
|
|
1673
|
+
// be the reported failure.
|
|
1674
|
+
}
|
|
1675
|
+
die(`could not write ${dest}: ${err && err.message}`)
|
|
1676
|
+
}
|
|
1677
|
+
process.stdout.write(`report written to ${dest}\n`)
|
|
1678
|
+
break
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
emit(json, report, formatReport(report))
|
|
1682
|
+
break
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
case 'conformance': {
|
|
1686
|
+
// Emits questions, never verdicts, and never exits non-zero — see the
|
|
1687
|
+
// header of lib/conformance.mjs.
|
|
1688
|
+
const name = typeof flags.change === 'string' ? flags.change : positional[1]
|
|
1689
|
+
const changed = flags.changed === undefined ? [] : splitPaths(flags.changed)
|
|
1690
|
+
const result = buildChecklist(root, name, { changed })
|
|
1691
|
+
emit(json, result, formatChecklist(result) + '\n')
|
|
1692
|
+
break
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
case 'limits': {
|
|
1696
|
+
// `laneCaps` and `solo` are their OWN top-level groups, not entries of
|
|
1697
|
+
// `limits`: LIMITS holds positive-integer loop counts (its invariant test
|
|
1698
|
+
// asserts it), whereas the lane-cap table is a tier→cap map and the solo
|
|
1699
|
+
// envelope is a separate object — the same reason `effort` is emitted apart.
|
|
1700
|
+
emit(
|
|
1701
|
+
json,
|
|
1702
|
+
{
|
|
1703
|
+
limits: LIMITS,
|
|
1704
|
+
runtime: RUNTIME,
|
|
1705
|
+
evals: EVAL_CAPS,
|
|
1706
|
+
// Its own group, beside `evals` rather than inside it: the eval caps
|
|
1707
|
+
// are scalars a reader compares a running total against, and this is
|
|
1708
|
+
// the table that produced the total. Its `id` is what a recorded
|
|
1709
|
+
// result row is keyed against.
|
|
1710
|
+
prices: MODEL_PRICES,
|
|
1711
|
+
report: REPORT_CAPS,
|
|
1712
|
+
effort: EFFORT,
|
|
1713
|
+
laneCaps: LANE_CAPS,
|
|
1714
|
+
solo: SOLO
|
|
1715
|
+
},
|
|
1716
|
+
formatLimits()
|
|
1717
|
+
)
|
|
1718
|
+
break
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
case 'notify': {
|
|
1722
|
+
// The CLI's ONE outbound request, and the only place a topic is ever
|
|
1723
|
+
// read (design D1, D2). Unconfigured is not a failure: a skill or a
|
|
1724
|
+
// driver calls this unconditionally, and an operator who never set a
|
|
1725
|
+
// topic gets a line saying so and exit 0, never a halted loop.
|
|
1726
|
+
const config = readNotifyConfig(process.env)
|
|
1727
|
+
// Order matters: a malformed topic yields `{invalid}` with no `topic`
|
|
1728
|
+
// key at all, and reporting that as "not configured" would tell an
|
|
1729
|
+
// operator who DID configure it that they had not.
|
|
1730
|
+
if (!config.invalid && !config.topic) {
|
|
1731
|
+
const notConfigured = { sent: false, reason: `not configured — set ${NOTIFY_TOPIC_ENV}` }
|
|
1732
|
+
emit(json, notConfigured, `push: not configured — set ${NOTIFY_TOPIC_ENV}\n`)
|
|
1733
|
+
break
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
let message
|
|
1737
|
+
if (positional[1] === 'checkpoint') {
|
|
1738
|
+
const change = typeof positional[2] === 'string' ? positional[2].trim() : ''
|
|
1739
|
+
if (!change) die('interlock notify checkpoint <change> — the change name is required')
|
|
1740
|
+
message = composeCheckpointMessage(change)
|
|
1741
|
+
} else {
|
|
1742
|
+
const title = typeof flags.title === 'string' ? flags.title.trim() : ''
|
|
1743
|
+
if (!title) die('--title <text> is required (or use: interlock notify checkpoint <change>)')
|
|
1744
|
+
const priority = flags.priority === 'high' ? 'high' : 'default'
|
|
1745
|
+
message = { title, body: typeof flags.body === 'string' ? flags.body : '', priority }
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
if (config.invalid) {
|
|
1749
|
+
const bad = { sent: false, reason: `invalid configuration: ${config.invalid}` }
|
|
1750
|
+
emit(json, bad, `push: failed — ${bad.reason}\n`)
|
|
1751
|
+
process.exit(1)
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const result = await postNtfy({ url: config.url, topic: config.topic, ...message })
|
|
1755
|
+
emit(json, result, result.sent ? 'push: sent (ntfy)\n' : `push: failed — ${result.reason}\n`)
|
|
1756
|
+
// A push nobody asked about is advisory; a push someone invoked directly
|
|
1757
|
+
// is a command, and a command that did not do its job exits non-zero.
|
|
1758
|
+
if (!result.sent) process.exit(1)
|
|
1759
|
+
break
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
case 'merge-lanes': {
|
|
1763
|
+
const base = typeof flags.base === 'string' ? flags.base.trim() : ''
|
|
1764
|
+
if (!base) die('--base <commit> is required')
|
|
1765
|
+
const input = readInput(flags.lanes, '--lanes <file|->')
|
|
1766
|
+
const laneEntries = Array.isArray(input) ? input : Array.isArray(input && input.lanes) ? input.lanes : null
|
|
1767
|
+
if (!laneEntries) die('--lanes must be a JSON array of { label, worktreePath, reportedFiles }')
|
|
1768
|
+
const result = runMergeLanes(root, laneEntries, base)
|
|
1769
|
+
emit(json, result, formatMergeLanes(result))
|
|
1770
|
+
// Exit code is the whole contract here: 0 is a clean fold, non-zero is a
|
|
1771
|
+
// halt (collision or an unresolved lane) — distinct so the workflow can
|
|
1772
|
+
// branch on exit status the same way every other gating command does.
|
|
1773
|
+
if (result.status !== 'clean') process.exit(1)
|
|
1774
|
+
break
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
case 'doctor': {
|
|
1778
|
+
// Read-only by construction — see the header of lib/doctor.mjs. The exit
|
|
1779
|
+
// code is the whole point: a preflight that only prints is a preflight a
|
|
1780
|
+
// zero-touch run walks straight past.
|
|
1781
|
+
const report = diagnose(root)
|
|
1782
|
+
emit(json, report, formatDoctor(report))
|
|
1783
|
+
if (!report.ok) process.exit(1)
|
|
1784
|
+
break
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
case 'evals': {
|
|
1788
|
+
const sub = positional[1]
|
|
1789
|
+
if (sub === 'triage') {
|
|
1790
|
+
const results = readInput(flags.results, '--results')
|
|
1791
|
+
const verdict = triage(results)
|
|
1792
|
+
emit(json, verdict, formatTriage(verdict))
|
|
1793
|
+
if (verdict.exitCode) process.exit(verdict.exitCode)
|
|
1794
|
+
} else if (sub === 'capture') {
|
|
1795
|
+
const runId = typeof flags.run === 'string' ? flags.run : ''
|
|
1796
|
+
if (!runId) die('evals capture requires --run <runId>')
|
|
1797
|
+
const outDir = typeof flags.out === 'string' ? flags.out : ''
|
|
1798
|
+
if (!outDir) die('evals capture requires --out <dir>')
|
|
1799
|
+
const result = captureCase(root, {
|
|
1800
|
+
runId,
|
|
1801
|
+
taskId: typeof flags.task === 'string' ? flags.task : undefined,
|
|
1802
|
+
outDir
|
|
1803
|
+
})
|
|
1804
|
+
emit(json, result, formatCapture(result))
|
|
1805
|
+
// A refusal is the contract, so it is an exit code rather than a
|
|
1806
|
+
// sentence: a run nobody can replay, an output directory inside the
|
|
1807
|
+
// suite, or one that already holds a case. Nothing was written on any
|
|
1808
|
+
// of the three.
|
|
1809
|
+
if (!result.ok) process.exit(1)
|
|
1810
|
+
} else if (sub === 'calibrate') {
|
|
1811
|
+
// Half the input is repository state (the committed human labels) and
|
|
1812
|
+
// half is runtime state (the judge's recorded votes), which is why this
|
|
1813
|
+
// is a command and the calibration PRESENCE check is a test.
|
|
1814
|
+
//
|
|
1815
|
+
// It issues no verdict and always exits 0, whatever the agreement says.
|
|
1816
|
+
// The floor is EVAL_CAPS.judgeAgreementFloor and only `evals promote`
|
|
1817
|
+
// applies it.
|
|
1818
|
+
const results = readInput(flags.results, '--results')
|
|
1819
|
+
const labelsDir = typeof flags.labels === 'string' ? flags.labels : join(root, 'evals')
|
|
1820
|
+
const { labels, problems } = readLabels(labelsDir)
|
|
1821
|
+
const report = calibrate({ labels, results })
|
|
1822
|
+
emit(json, { ...report, problems }, formatCalibration(report, problems))
|
|
1823
|
+
} else if (sub === 'promote') {
|
|
1824
|
+
const dir = typeof flags.history === 'string' ? flags.history : ''
|
|
1825
|
+
if (!dir) die('evals promote requires --history <dir>')
|
|
1826
|
+
// Every entry is carried into the decision, readable or not. An
|
|
1827
|
+
// unreadable file is passed through as `{ file, error }` so the module
|
|
1828
|
+
// can name it and state that it was excluded — dropping it here would
|
|
1829
|
+
// silently narrow the window, which is the one thing the promotion spec
|
|
1830
|
+
// forbids about this path.
|
|
1831
|
+
const entries = []
|
|
1832
|
+
if (existsSync(dir)) {
|
|
1833
|
+
for (const name of readdirSync(dir).filter(f => f.endsWith('.json')).sort()) {
|
|
1834
|
+
const file = join(dir, name)
|
|
1835
|
+
try {
|
|
1836
|
+
entries.push({ file: name, record: JSON.parse(readFileSync(file, 'utf8')) })
|
|
1837
|
+
} catch (err) {
|
|
1838
|
+
entries.push({ file: name, error: `unparseable (${err && err.message})` })
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
// Agreement is optional input: without it, a judged case is refused for
|
|
1843
|
+
// being unmeasured rather than promoted on run history alone.
|
|
1844
|
+
const agreement =
|
|
1845
|
+
typeof flags.agreement === 'string' ? readInput(flags.agreement, '--agreement') : {}
|
|
1846
|
+
const verdict = promote({ history: entries, agreement })
|
|
1847
|
+
emit(json, verdict, formatPromotion(verdict))
|
|
1848
|
+
if (verdict.exitCode) process.exit(verdict.exitCode)
|
|
1849
|
+
} else {
|
|
1850
|
+
die(`unknown evals subcommand: ${sub || '(none)'} — expected triage|capture|calibrate|promote`)
|
|
1851
|
+
}
|
|
1852
|
+
break
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
case 'paths': {
|
|
1856
|
+
// The two deterministic reads behind the receipt's path sets. Neither
|
|
1857
|
+
// ever exits non-zero: a host calls these on its way out, and a failed
|
|
1858
|
+
// read must leave the receipt unobserved rather than fail the close. The
|
|
1859
|
+
// reason is printed and carried in the payload.
|
|
1860
|
+
const sub = positional[1]
|
|
1861
|
+
if (sub === 'touched') {
|
|
1862
|
+
const commit = typeof flags.commit === 'string' ? flags.commit : positional[2]
|
|
1863
|
+
const result = readTouchedPaths(root, commit)
|
|
1864
|
+
emit(json, result, formatPathSet('paths touched', result))
|
|
1865
|
+
if (!result.observed && !json) process.stderr.write(`interlock: ${result.reason}\n`)
|
|
1866
|
+
} else if (sub === 'predicted') {
|
|
1867
|
+
const result = readPredictedPaths(root, {
|
|
1868
|
+
planPath: typeof flags.plan === 'string' ? flags.plan : undefined
|
|
1869
|
+
})
|
|
1870
|
+
emit(json, result, formatPathSet('paths predicted', result))
|
|
1871
|
+
if (!result.observed && !json) process.stderr.write(`interlock: ${result.reason}\n`)
|
|
1872
|
+
} else {
|
|
1873
|
+
die(`unknown paths subcommand: ${sub || '(none)'} — expected touched|predicted`)
|
|
1874
|
+
}
|
|
1875
|
+
break
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
case 'remediate': {
|
|
1879
|
+
const input = readInput(flags.findings, '--findings')
|
|
1880
|
+
const round = num(flags, 'round')
|
|
1881
|
+
const plan = planRemediation(input, { round, band: parseBand(flags) })
|
|
1882
|
+
emit(json, plan, formatRemediationPlan(plan))
|
|
1883
|
+
if (plan.halt) process.exit(1)
|
|
1884
|
+
break
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
case 'review': {
|
|
1888
|
+
const findings = readInput(flags.findings, '--findings')
|
|
1889
|
+
const verdicts = readInput(flags.verdicts, '--verdicts')
|
|
1890
|
+
// The reviewed diff. A dismissing verdict has to cite a path that is in
|
|
1891
|
+
// it — shape alone is satisfied by inventing `lib/nowhere.ts:1`. Optional
|
|
1892
|
+
// because a caller that cannot name the changed files must still get the
|
|
1893
|
+
// shape half rather than no gate at all.
|
|
1894
|
+
const changedPaths = splitPaths(flags.changed)
|
|
1895
|
+
|
|
1896
|
+
// The repo-root REVIEW.md, if any. Its do-not-report paths are enforced
|
|
1897
|
+
// here as a survival filter — never handed to a model as a request. Its
|
|
1898
|
+
// problems (a malformed file, a band-like key) are reported alongside the
|
|
1899
|
+
// review, so a broken policy is diagnosable rather than silent.
|
|
1900
|
+
const policy = readReviewPolicy(root)
|
|
1901
|
+
|
|
1902
|
+
const result = resolveReview(findings, verdicts, {
|
|
1903
|
+
band: parseBand(flags),
|
|
1904
|
+
changedPaths,
|
|
1905
|
+
excludePaths: policy.excludePaths
|
|
1906
|
+
})
|
|
1907
|
+
|
|
1908
|
+
// Metrics are bookkeeping: they report their own failure and never touch
|
|
1909
|
+
// the exit code, so a read-only checkout cannot fail a review.
|
|
1910
|
+
let metrics = null
|
|
1911
|
+
if (flags.metrics !== undefined) {
|
|
1912
|
+
if (flags.metrics === true) die('--metrics requires a change name')
|
|
1913
|
+
metrics = writeReviewMetrics(root, { change: String(flags.metrics), counts: result.counts })
|
|
1914
|
+
if (!metrics.written) warn(`metrics not written: ${metrics.reason}`)
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const policyProblems = policy.problems.length
|
|
1918
|
+
? ` policy: ${policy.problems.length} problem(s) in REVIEW.md\n` +
|
|
1919
|
+
policy.problems.map(p => ` - ${p}`).join('\n') + '\n'
|
|
1920
|
+
: ''
|
|
1921
|
+
const text =
|
|
1922
|
+
formatReview(result) +
|
|
1923
|
+
policyProblems +
|
|
1924
|
+
(metrics ? ` metrics: ${metrics.written ? metrics.path : `not written (${metrics.reason})`}\n` : '')
|
|
1925
|
+
emit(json, { ...result, policyProblems: policy.problems, ...(metrics ? { metrics } : {}) }, text)
|
|
1926
|
+
break
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
case 'review-policy': {
|
|
1930
|
+
// Read-only view of the parsed REVIEW.md, so the workflow can inject the
|
|
1931
|
+
// prose + owner into the reviewer instructions without re-parsing the file
|
|
1932
|
+
// (the runtime cannot import lib modules). Always exits 0: a missing or
|
|
1933
|
+
// malformed policy is reported, never fatal.
|
|
1934
|
+
const policy = readReviewPolicy(root)
|
|
1935
|
+
emit(json, policy, formatReviewPolicy(policy))
|
|
1936
|
+
break
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
case 'risk': {
|
|
1940
|
+
const requested = typeof flags.change === 'string' ? flags.change : positional[1]
|
|
1941
|
+
const changedPaths = splitPaths(flags.paths)
|
|
1942
|
+
|
|
1943
|
+
let change = null
|
|
1944
|
+
let artifacts = {}
|
|
1945
|
+
const resolved = resolveChange(root, requested)
|
|
1946
|
+
if (resolved.error) {
|
|
1947
|
+
// Paths alone are still an honest basis; without them there is nothing
|
|
1948
|
+
// to classify, so the unresolved change is the error.
|
|
1949
|
+
if (!changedPaths.length) {
|
|
1950
|
+
requireChange(root, requested, json)
|
|
1951
|
+
}
|
|
1952
|
+
warn(`${resolved.error} — classifying from --paths alone, with no artifact text`)
|
|
1953
|
+
} else {
|
|
1954
|
+
change = resolved.name
|
|
1955
|
+
artifacts = readChangeArtifacts(root, change)
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const result = classifyRisk({ changedPaths, artifacts })
|
|
1959
|
+
emit(json, { change, ...result }, formatRisk(result))
|
|
1960
|
+
break
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
case 'ledger': {
|
|
1964
|
+
const name = requireChange(root, requestedName(positional, flags), json)
|
|
1965
|
+
const parsed = readLedger(root, name)
|
|
1966
|
+
const summary = summarizeLedger(parsed)
|
|
1967
|
+
// `summary.invalid` is a count, so the rows behind it get their own key
|
|
1968
|
+
// rather than shadowing it.
|
|
1969
|
+
emit(
|
|
1970
|
+
json,
|
|
1971
|
+
{ change: name, path: parsed.path, ...summary, rows: parsed.rows, invalidRows: parsed.invalid },
|
|
1972
|
+
formatLedger(parsed)
|
|
1973
|
+
)
|
|
1974
|
+
// A missing ledger exits non-zero and says *missing*, not *empty*. The
|
|
1975
|
+
// audit's likeliest failure is that it was never written, and an empty
|
|
1976
|
+
// result reads as "nothing needs a human" — which is the one conclusion
|
|
1977
|
+
// an absent file cannot support. `summary.missing` / `summary.unparseable`
|
|
1978
|
+
// keep the three cases distinguishable in the JSON.
|
|
1979
|
+
if (summary.blocking) process.exit(1)
|
|
1980
|
+
break
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
case 'ready': {
|
|
1984
|
+
const name = requireChange(root, requestedName(positional, flags), json)
|
|
1985
|
+
// Read RAW, not parsed: an unreadable findings file must reach the gate
|
|
1986
|
+
// as "the review did not run" and become a blocker with a reason, rather
|
|
1987
|
+
// than dying in the argument parser with a message nobody records.
|
|
1988
|
+
const findings = flags.findings ? readRawInput(flags.findings, '--findings') : undefined
|
|
1989
|
+
const review = flags.review ? readInput(flags.review, '--review') : undefined
|
|
1990
|
+
if (review !== undefined && findings === undefined) {
|
|
1991
|
+
warn(
|
|
1992
|
+
'--review is deprecated and no longer satisfies the review-blockers check on its own: ' +
|
|
1993
|
+
'its provenance is the agent being gated. Pass the artifact review\'s own findings ' +
|
|
1994
|
+
'file to --findings instead.'
|
|
1995
|
+
)
|
|
1996
|
+
}
|
|
1997
|
+
const paths =
|
|
1998
|
+
typeof flags.paths === 'string'
|
|
1999
|
+
? (flags.paths === '-' ? readStdin() : flags.paths)
|
|
2000
|
+
.split(flags.paths === '-' ? '\n' : ',')
|
|
2001
|
+
.map(s => s.trim())
|
|
2002
|
+
.filter(Boolean)
|
|
2003
|
+
: undefined
|
|
2004
|
+
const result = assessReadiness(root, name, { findings, review, changedPaths: paths })
|
|
2005
|
+
emit(json, result, formatReadiness(result))
|
|
2006
|
+
// Fail closed: not-ready is a non-zero exit, so a caller that forgets to
|
|
2007
|
+
// read the JSON still cannot walk past the gate.
|
|
2008
|
+
if (!result.ready) process.exit(1)
|
|
2009
|
+
break
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
case 'outcomes': {
|
|
2013
|
+
const sub = positional[1]
|
|
2014
|
+
if (sub === 'append') {
|
|
2015
|
+
const record = readInput(flags.record, '--record <file|->')
|
|
2016
|
+
// The record file is composed by an agent on the workflow host, so the
|
|
2017
|
+
// observed half is never taken from it: it is derived here from the
|
|
2018
|
+
// run's own receipt. A record file that carries one is refused by name
|
|
2019
|
+
// rather than passed through — the prompt is documentation and the
|
|
2020
|
+
// writer is the mechanism.
|
|
2021
|
+
const supplied =
|
|
2022
|
+
record && typeof record === 'object' && !Array.isArray(record) ? record : {}
|
|
2023
|
+
const carriedObserved = Object.prototype.hasOwnProperty.call(supplied, 'observed')
|
|
2024
|
+
const { observed: _refused, ...reportedHalf } = supplied
|
|
2025
|
+
const runId = runIdFromFlags(flags)
|
|
2026
|
+
// No run identity, or no receipt on the trajectory, records the
|
|
2027
|
+
// absence. It deliberately does not fall back to the record file:
|
|
2028
|
+
// trusting the untrusted source exactly when the trusted one is
|
|
2029
|
+
// missing would defeat the partition on the runs most likely to need
|
|
2030
|
+
// it.
|
|
2031
|
+
const receipt = runId
|
|
2032
|
+
? queryRunLog(root, runId, { type: 'run-receipt' }).records.pop() || null
|
|
2033
|
+
: null
|
|
2034
|
+
const written = appendOutcome(root, { ...reportedHalf, observed: observedFromReceipt(receipt) })
|
|
2035
|
+
if (carriedObserved) {
|
|
2036
|
+
warn(
|
|
2037
|
+
'outcome record carried an "observed" group: refused. Observed values are derived ' +
|
|
2038
|
+
'from the run receipt, never supplied by the step that writes the record.'
|
|
2039
|
+
)
|
|
2040
|
+
}
|
|
2041
|
+
if (!written.written) {
|
|
2042
|
+
// Never fatal: losing a corpus line must not fail the run that
|
|
2043
|
+
// produced it. Say so on stderr and exit 0.
|
|
2044
|
+
process.stderr.write(`interlock: outcome not recorded: ${written.reason}\n`)
|
|
2045
|
+
} else if (written.reason) {
|
|
2046
|
+
// Written, but partly refused. Reported for the same reason: a
|
|
2047
|
+
// closing step whose payload was refused should be able to say so.
|
|
2048
|
+
warn(`outcome recorded with a refusal: ${written.reason}`)
|
|
2049
|
+
}
|
|
2050
|
+
emit(
|
|
2051
|
+
json,
|
|
2052
|
+
{ ...written, observedRefused: carriedObserved, receipt: Boolean(receipt) },
|
|
2053
|
+
`${written.written ? `recorded → ${written.path}` : `not recorded: ${written.reason}`}\n`
|
|
2054
|
+
)
|
|
2055
|
+
} else if (sub === 'list' || sub === undefined) {
|
|
2056
|
+
const result = readOutcomes(root)
|
|
2057
|
+
emit(json, result, formatOutcomes(result))
|
|
2058
|
+
} else {
|
|
2059
|
+
die(`unknown outcomes subcommand: ${sub}`)
|
|
2060
|
+
}
|
|
2061
|
+
break
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
case 'run-log': {
|
|
2065
|
+
const sub = positional[1]
|
|
2066
|
+
if (sub === 'append') {
|
|
2067
|
+
const record = readInput(flags.event, '--event <file|->')
|
|
2068
|
+
const { written, path, reason } = appendRunLogEvent(root, record)
|
|
2069
|
+
if (!written) {
|
|
2070
|
+
// Never fatal: losing a trajectory line must not fail the run that
|
|
2071
|
+
// produced it. Say so on stderr and exit 0.
|
|
2072
|
+
process.stderr.write(`interlock: event not recorded: ${reason}\n`)
|
|
2073
|
+
}
|
|
2074
|
+
emit(json, { written, path, reason }, `${written ? `recorded → ${path}` : `not recorded: ${reason}`}\n`)
|
|
2075
|
+
} else if (sub === 'list') {
|
|
2076
|
+
const change = typeof flags.change === 'string' ? flags.change : undefined
|
|
2077
|
+
const runs = listRunLogs(root, { change })
|
|
2078
|
+
emit(json, { runs }, formatRunLogList(runs))
|
|
2079
|
+
} else if (sub === 'show') {
|
|
2080
|
+
const runId = positional[2]
|
|
2081
|
+
if (!runId) die('run-log show requires a runId')
|
|
2082
|
+
const result = readRunLog(root, runId)
|
|
2083
|
+
emit(json, result, formatRunLog(result))
|
|
2084
|
+
} else if (sub === 'query') {
|
|
2085
|
+
const runId = typeof flags.run === 'string' ? flags.run : undefined
|
|
2086
|
+
if (!runId) die('--run is required')
|
|
2087
|
+
const result = queryRunLog(root, runId, {
|
|
2088
|
+
type: typeof flags.type === 'string' ? flags.type : undefined,
|
|
2089
|
+
halted: flags.halted === true
|
|
2090
|
+
})
|
|
2091
|
+
emit(json, result, formatRunLog(result))
|
|
2092
|
+
} else if (sub === 'check') {
|
|
2093
|
+
const runId = runIdFromFlags(flags)
|
|
2094
|
+
if (!runId) die('run-log check requires --state <file> (with a runId) or --run-id <id>')
|
|
2095
|
+
const result = checkRunLog(root, runId)
|
|
2096
|
+
emit(json, result, formatRunLogCheck(result))
|
|
2097
|
+
if (!result.ok) process.exit(1)
|
|
2098
|
+
} else {
|
|
2099
|
+
die(`unknown run-log subcommand: ${sub || '(none)'} — expected append|list|show|query|check`)
|
|
2100
|
+
}
|
|
2101
|
+
break
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
case 'tasks': {
|
|
2105
|
+
const sub = positional[1]
|
|
2106
|
+
if (sub === 'tick') {
|
|
2107
|
+
const name = requireChange(root, requestedName(positional, flags), json)
|
|
2108
|
+
const ids = splitPaths(flags.ids)
|
|
2109
|
+
if (!ids.length) die('--ids is required')
|
|
2110
|
+
const result = tickTasks(root, name, ids)
|
|
2111
|
+
const text =
|
|
2112
|
+
`ticked ${result.ticked.length}` +
|
|
2113
|
+
(result.already.length ? `, already ${result.already.length}` : '') +
|
|
2114
|
+
(result.missing.length ? `, missing ${result.missing.join(', ')}` : '') +
|
|
2115
|
+
`\n`
|
|
2116
|
+
emit(json, result, text)
|
|
2117
|
+
// An id that could not be marked exits non-zero and names itself. A
|
|
2118
|
+
// completed task left silently unchecked reads downstream as a failed
|
|
2119
|
+
// task, so the caller has to be able to see the difference — and an
|
|
2120
|
+
// empty id set is refused above, which keeps "nothing to mark" distinct
|
|
2121
|
+
// from "marking failed".
|
|
2122
|
+
if (result.missing.length) {
|
|
2123
|
+
process.stderr.write(
|
|
2124
|
+
`interlock: could not mark ${result.missing.length} task id(s) complete in ` +
|
|
2125
|
+
`${result.path}: ${result.missing.join(', ')} — no matching checkbox\n`
|
|
2126
|
+
)
|
|
2127
|
+
process.exit(1)
|
|
2128
|
+
}
|
|
2129
|
+
} else if (sub === 'coverage') {
|
|
2130
|
+
const name = requireChange(root, requestedName(positional, flags), json)
|
|
2131
|
+
const classified = readInput(flags.classified, '--classified')
|
|
2132
|
+
const inspection = inspectChange(root, name)
|
|
2133
|
+
const listed = Array.isArray(classified)
|
|
2134
|
+
? classified
|
|
2135
|
+
: Array.isArray(classified.tasks)
|
|
2136
|
+
? classified.tasks
|
|
2137
|
+
: []
|
|
2138
|
+
const result = { change: name, ...planCoverage(inspection.tasks.items, listed) }
|
|
2139
|
+
const text = result.ok
|
|
2140
|
+
? `COVERAGE OK — ${result.classified} classified, ${result.remaining} remaining\n`
|
|
2141
|
+
: `COVERAGE GAP — omitted: ${result.omitted.join(', ')}\n`
|
|
2142
|
+
emit(json, result, text)
|
|
2143
|
+
if (!result.ok) process.exit(1)
|
|
2144
|
+
} else {
|
|
2145
|
+
die(`unknown tasks subcommand: ${sub || '(none)'} — expected tick|coverage`)
|
|
2146
|
+
}
|
|
2147
|
+
break
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
case 'verify': {
|
|
2151
|
+
const sub = positional[1]
|
|
2152
|
+
if (sub === 'plan') {
|
|
2153
|
+
const profile = flags['no-profile']
|
|
2154
|
+
? null
|
|
2155
|
+
: readInput(flags.profile, '--profile <file|-> (or --no-profile)')
|
|
2156
|
+
const opts = {
|
|
2157
|
+
e2e: flags.e2e === true,
|
|
2158
|
+
coverage: flags['no-coverage'] !== true,
|
|
2159
|
+
lint: flags['no-lint'] !== true,
|
|
2160
|
+
typecheckCommand:
|
|
2161
|
+
typeof flags['typecheck-command'] === 'string' ? flags['typecheck-command'] : null,
|
|
2162
|
+
lintCommand: typeof flags['lint-command'] === 'string' ? flags['lint-command'] : null,
|
|
2163
|
+
preExistingFailures: flags['pre-existing'] === true
|
|
2164
|
+
}
|
|
2165
|
+
const elapsedMs = num(flags, 'elapsed-ms')
|
|
2166
|
+
if (elapsedMs !== undefined) opts.elapsedMs = elapsedMs
|
|
2167
|
+
const budgetMs = num(flags, 'budget-ms')
|
|
2168
|
+
if (budgetMs !== undefined) opts.budgetMs = budgetMs
|
|
2169
|
+
const context = flags.context === undefined ? undefined : String(flags.context)
|
|
2170
|
+
if (context !== undefined) {
|
|
2171
|
+
if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, context)) {
|
|
2172
|
+
die(
|
|
2173
|
+
`--context must be one of ${Object.keys(VERIFY_CONTEXTS).join('|')} (got "${context}")`
|
|
2174
|
+
)
|
|
2175
|
+
}
|
|
2176
|
+
opts.context = context
|
|
2177
|
+
if (context === 'inter-wave') opts.coverage = false
|
|
2178
|
+
}
|
|
2179
|
+
if (flags.changed !== undefined) {
|
|
2180
|
+
opts.changed = Array.isArray(flags.changed) ? flags.changed : splitPaths(flags.changed)
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
const plan = planVerification(profile, opts)
|
|
2184
|
+
emit(json, plan, formatVerification(plan))
|
|
2185
|
+
} else if (sub === 'judge') {
|
|
2186
|
+
const plan = readInput(flags.plan, '--plan <file|->')
|
|
2187
|
+
if (!plan || !Array.isArray(plan.steps) || !Array.isArray(plan.skipped)) {
|
|
2188
|
+
die('--plan must be the JSON output of `interlock verify plan --json`')
|
|
2189
|
+
}
|
|
2190
|
+
const results = readInput(flags.results, '--results <file|->')
|
|
2191
|
+
if (!Array.isArray(results)) {
|
|
2192
|
+
die('--results must be a JSON array of { kind, exitCode, ... } objects')
|
|
2193
|
+
}
|
|
2194
|
+
const context = flags.context === undefined ? 'final' : String(flags.context)
|
|
2195
|
+
if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, context)) {
|
|
2196
|
+
die(
|
|
2197
|
+
`--context must be one of ${Object.keys(VERIFY_CONTEXTS).join('|')} (got "${context}")`
|
|
2198
|
+
)
|
|
2199
|
+
}
|
|
2200
|
+
const rejection = rejectOversized(results, context)
|
|
2201
|
+
if (rejection) {
|
|
2202
|
+
emit(json, rejection, formatVerification(rejection))
|
|
2203
|
+
process.exit(1)
|
|
2204
|
+
}
|
|
2205
|
+
const opts = { context, ...verifyJudgeOpts(flags) }
|
|
2206
|
+
const verdict = judgeVerification(plan, results, opts)
|
|
2207
|
+
const logOk = logVerifyJudgement(root, {
|
|
2208
|
+
runId: runIdFromFlags(flags),
|
|
2209
|
+
// Same precedence as every wave-state append site: the state names the
|
|
2210
|
+
// run, the flag is only the pre-state-name fallback.
|
|
2211
|
+
change: changeFor(stateFromFlags(flags), flags.change),
|
|
2212
|
+
verdict,
|
|
2213
|
+
results
|
|
2214
|
+
})
|
|
2215
|
+
emit(json, verdict, formatVerification(verdict))
|
|
2216
|
+
if (verdict.halt || !logOk) process.exit(1)
|
|
2217
|
+
} else if (sub === 'unit') {
|
|
2218
|
+
const result = readInput(flags.result, '--result <file|->')
|
|
2219
|
+
const rejection = rejectOversized([result])
|
|
2220
|
+
if (rejection) {
|
|
2221
|
+
emit(json, rejection, formatVerification(rejection))
|
|
2222
|
+
process.exit(1)
|
|
2223
|
+
}
|
|
2224
|
+
const judgement = judgeUnitResult(result, verifyJudgeOpts(flags))
|
|
2225
|
+
emit(json, judgement, formatVerification(judgement))
|
|
2226
|
+
if (judgement.halt) process.exit(1)
|
|
2227
|
+
} else if (sub === 'spill') {
|
|
2228
|
+
const runId = typeof flags['run-id'] === 'string' ? flags['run-id'].trim() : ''
|
|
2229
|
+
if (!runId) die('--run-id is required')
|
|
2230
|
+
const kind = typeof flags.kind === 'string' ? flags.kind.trim() : ''
|
|
2231
|
+
if (!kind) die('--kind is required')
|
|
2232
|
+
const raw = readRawInput(flags.input, '--input <file|->')
|
|
2233
|
+
const result = spillBytes(root, { runId, kind, bytes: raw })
|
|
2234
|
+
emit(
|
|
2235
|
+
json,
|
|
2236
|
+
result,
|
|
2237
|
+
`spilled ${result.bytes} byte(s) → ${result.locator}${result.truncated ? ' (truncated preview)' : ''}\n`
|
|
2238
|
+
)
|
|
2239
|
+
} else if (sub === 'cluster') {
|
|
2240
|
+
const failures = readInput(flags.failures, '--failures <file|->')
|
|
2241
|
+
if (!Array.isArray(failures)) {
|
|
2242
|
+
die('--failures must be a JSON array of failure lines or objects')
|
|
2243
|
+
}
|
|
2244
|
+
const clusters = clusterFailures(failures)
|
|
2245
|
+
const text = clusters.length
|
|
2246
|
+
? clusters.map(c => ` x${c.count}\t${c.signature}`).join('\n') + '\n'
|
|
2247
|
+
: 'no failures\n'
|
|
2248
|
+
emit(json, clusters, text)
|
|
2249
|
+
} else if (sub === 'repair') {
|
|
2250
|
+
const state = readInput(flags.state, '--state <file|->')
|
|
2251
|
+
const action = nextRepairAction(state)
|
|
2252
|
+
emit(json, action, formatVerification(action))
|
|
2253
|
+
if (action.action === 'halt') process.exit(1)
|
|
2254
|
+
} else {
|
|
2255
|
+
die(
|
|
2256
|
+
`unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair`
|
|
2257
|
+
)
|
|
2258
|
+
}
|
|
2259
|
+
break
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
case 'plan': {
|
|
2263
|
+
const sub = positional[1]
|
|
2264
|
+
// `requestedName` reads positional[1], which here is the SUBCOMMAND — so
|
|
2265
|
+
// the change is positional[2] or --change, and a bare `interlock plan
|
|
2266
|
+
// reuse` resolves the single active change the same way validate does.
|
|
2267
|
+
const requested =
|
|
2268
|
+
typeof flags.change === 'string' && flags.change.trim() ? flags.change.trim() : positional[2]
|
|
2269
|
+
const maxParallel = num(flags, 'max-parallel')
|
|
2270
|
+
const mode = modeFlag(flags)
|
|
2271
|
+
// Part of the hash exactly as `--mode` is, and for the same reason: a
|
|
2272
|
+
// stored plan built under a different shape has to be rebuilt rather than
|
|
2273
|
+
// reused. Only the explicit form is accepted here — this subcommand does
|
|
2274
|
+
// not re-derive the shape, it reports the hash for the one it is told.
|
|
2275
|
+
const redWave = num(flags, 'red-wave')
|
|
2276
|
+
|
|
2277
|
+
if (sub === 'fingerprint') {
|
|
2278
|
+
const name = requireChange(root, requested, json)
|
|
2279
|
+
const fp = computeFingerprint(root, name, { maxParallel, mode, redWave })
|
|
2280
|
+
let written = null
|
|
2281
|
+
if (flags.write === true) {
|
|
2282
|
+
const dest = typeof flags.fingerprint === 'string' ? flags.fingerprint : undefined
|
|
2283
|
+
written = writeFingerprint(root, fp, { path: dest })
|
|
2284
|
+
if (!written.written) warn(`fingerprint not written: ${written.reason}`)
|
|
2285
|
+
}
|
|
2286
|
+
const text =
|
|
2287
|
+
formatFingerprint(fp) +
|
|
2288
|
+
(written
|
|
2289
|
+
? ` ${written.written ? `written → ${written.path}` : `not written (${written.reason})`}\n`
|
|
2290
|
+
: '')
|
|
2291
|
+
emit(json, written ? { ...fp, written } : fp, text)
|
|
2292
|
+
// Always exit 0. A fingerprint that could not be computed means the next
|
|
2293
|
+
// run re-plans, which is today's behaviour — not a reason to fail the run
|
|
2294
|
+
// that just produced a working plan.
|
|
2295
|
+
} else if (sub === 'reuse') {
|
|
2296
|
+
const name = requireChange(root, requested, json)
|
|
2297
|
+
const result = resolvePlanReuse(root, name, {
|
|
2298
|
+
maxParallel,
|
|
2299
|
+
mode,
|
|
2300
|
+
redWave,
|
|
2301
|
+
planPath: typeof flags.plan === 'string' ? flags.plan : undefined,
|
|
2302
|
+
fingerprintPath: typeof flags.fingerprint === 'string' ? flags.fingerprint : undefined
|
|
2303
|
+
})
|
|
2304
|
+
|
|
2305
|
+
// The narrowed plan is written to its OWN path: overwriting the stored
|
|
2306
|
+
// plan with a narrowed copy would make the next run's fingerprint check
|
|
2307
|
+
// compare against a plan that no longer covers the tasks it was hashed
|
|
2308
|
+
// for.
|
|
2309
|
+
let narrowedPath = null
|
|
2310
|
+
if (result.reuse && result.narrowed && !result.noRemainingWork) {
|
|
2311
|
+
narrowedPath =
|
|
2312
|
+
typeof flags.out === 'string' && flags.out.trim() ? flags.out.trim() : NARROWED_PLAN_PATH
|
|
2313
|
+
const dest = join(root, narrowedPath)
|
|
2314
|
+
try {
|
|
2315
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
2316
|
+
writeFileSync(dest, JSON.stringify(result.narrowed, null, 2) + '\n')
|
|
2317
|
+
} catch (err) {
|
|
2318
|
+
// Cannot stage the narrowed plan → cannot reuse it. Say which, and
|
|
2319
|
+
// fall back to the classifier rather than to a plan nobody can read.
|
|
2320
|
+
const failed = {
|
|
2321
|
+
change: name,
|
|
2322
|
+
reuse: false,
|
|
2323
|
+
status: 'check-failed',
|
|
2324
|
+
reason: `the narrowed plan could not be written to ${narrowedPath}: ${err.message}`,
|
|
2325
|
+
narrowedPath: null
|
|
2326
|
+
}
|
|
2327
|
+
emit(json, failed, formatPlanReuse(failed))
|
|
2328
|
+
break
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
const payload = {
|
|
2333
|
+
change: name,
|
|
2334
|
+
reuse: result.reuse,
|
|
2335
|
+
status: result.status,
|
|
2336
|
+
reason: result.reason,
|
|
2337
|
+
noRemainingWork: Boolean(result.noRemainingWork),
|
|
2338
|
+
narrowedPath,
|
|
2339
|
+
planPath: result.planPath,
|
|
2340
|
+
fingerprintPath: result.fingerprintPath,
|
|
2341
|
+
waveCount: result.waveCount ?? null,
|
|
2342
|
+
laneCount: result.laneCount ?? null,
|
|
2343
|
+
taskCount: result.taskCount ?? null,
|
|
2344
|
+
droppedTaskIds: result.droppedTaskIds || []
|
|
2345
|
+
}
|
|
2346
|
+
emit(
|
|
2347
|
+
json,
|
|
2348
|
+
payload,
|
|
2349
|
+
formatPlanReuse(result) +
|
|
2350
|
+
(narrowedPath ? ` narrowed plan → ${narrowedPath}\n` : '')
|
|
2351
|
+
)
|
|
2352
|
+
// Always exit 0, in both directions: not reusing is the normal, correct
|
|
2353
|
+
// outcome on a first run, and an error while checking is a reason to
|
|
2354
|
+
// re-plan rather than a reason to stop.
|
|
2355
|
+
} else {
|
|
2356
|
+
die(`unknown plan subcommand: ${sub || '(none)'} — expected fingerprint|reuse`)
|
|
2357
|
+
}
|
|
2358
|
+
break
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// The run program (`lib/run.mjs`). Every step of the lean ship loop is
|
|
2362
|
+
// emitted here: what to spawn, with which briefing, and which argv to call
|
|
2363
|
+
// next. A host obeys the record and decides nothing (design D3).
|
|
2364
|
+
//
|
|
2365
|
+
// `wave-state` below is untouched by this and stays the pure thing `run`
|
|
2366
|
+
// obeys. The three helpers `lib/run.mjs` cannot import — the git readers and
|
|
2367
|
+
// the trajectory loggers that live in this file — are handed in as `deps`
|
|
2368
|
+
// rather than written a second time there.
|
|
2369
|
+
case 'run': {
|
|
2370
|
+
const sub = positional[1]
|
|
2371
|
+
const ctx = {
|
|
2372
|
+
root,
|
|
2373
|
+
warn,
|
|
2374
|
+
deps: {
|
|
2375
|
+
headCommit: repoRoot => {
|
|
2376
|
+
try {
|
|
2377
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
2378
|
+
cwd: repoRoot,
|
|
2379
|
+
encoding: 'utf8',
|
|
2380
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
2381
|
+
}).trim() || null
|
|
2382
|
+
} catch {
|
|
2383
|
+
// An unreadable base is null, never a guess: `decorate` halts on
|
|
2384
|
+
// it rather than folding a batch against a commit nobody read.
|
|
2385
|
+
return null
|
|
2386
|
+
}
|
|
2387
|
+
},
|
|
2388
|
+
logWaveMutation,
|
|
2389
|
+
logAgentSpawns,
|
|
2390
|
+
observedChangedPaths,
|
|
2391
|
+
runMergeLanes
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// The sequence guard. The manifest records the argv the last step named as
|
|
2396
|
+
// its continuation; a driver that skipped a step, repeated one, or invented
|
|
2397
|
+
// a subcommand would otherwise advance the state machine twice or record a
|
|
2398
|
+
// batch against the wrong wave — both silent.
|
|
2399
|
+
//
|
|
2400
|
+
// `close` is exempt, in both directions. A driver that hit an error of its
|
|
2401
|
+
// own — a spawn that never returned, a transport that dropped — closes
|
|
2402
|
+
// from wherever it is, and refusing that would leave the run with no
|
|
2403
|
+
// receipt, no outcome record and no terminal event. A run nobody can
|
|
2404
|
+
// reconstruct is the failure this trajectory exists to prevent; a
|
|
2405
|
+
// mis-sequenced close is not.
|
|
2406
|
+
if (sub !== 'start' && sub !== 'close') {
|
|
2407
|
+
const manifest = readManifest(root)
|
|
2408
|
+
if (manifest) {
|
|
2409
|
+
// The RAW argv, not the parsed positionals: a step's continuation
|
|
2410
|
+
// names its flags (`--classified <path>`, `--context inter-wave`) and
|
|
2411
|
+
// those are part of what the program asked for.
|
|
2412
|
+
const problem = checkSequence(manifest, argv)
|
|
2413
|
+
if (problem) die(problem)
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
const results = flags.results ? readInput(flags.results, '--results <file|->') : []
|
|
2418
|
+
const resultList = Array.isArray(results) ? results : [results]
|
|
2419
|
+
|
|
2420
|
+
let step
|
|
2421
|
+
if (sub === 'start') {
|
|
2422
|
+
// A name that resolves to nothing is a HALT STEP, not a `die`. Every
|
|
2423
|
+
// other `run` subcommand answers with a step, and a host that got an
|
|
2424
|
+
// error object where it expected one would report "the relay could not
|
|
2425
|
+
// be read" — which is true of the transport and false of the run.
|
|
2426
|
+
const requested = requestedName(positional.slice(1), flags)
|
|
2427
|
+
const resolved = resolveChange(root, requested)
|
|
2428
|
+
if (resolved.error) {
|
|
2429
|
+
const step = haltStep(
|
|
2430
|
+
`validate failed: ${resolved.error}${
|
|
2431
|
+
Array.isArray(resolved.candidates) && resolved.candidates.length
|
|
2432
|
+
? ` (candidates: ${resolved.candidates.join(', ')})`
|
|
2433
|
+
: ''
|
|
2434
|
+
}`
|
|
2435
|
+
)
|
|
2436
|
+
emit(json, step, formatRunStep(step))
|
|
2437
|
+
break
|
|
2438
|
+
}
|
|
2439
|
+
const name = resolved.name
|
|
2440
|
+
const laneMode = modeFlag(flags)
|
|
2441
|
+
// What the host declared it cannot do (design D1). A driver that passed
|
|
2442
|
+
// only an id — `workflows/ship.js` does — is the Workflow host, and
|
|
2443
|
+
// `newManifest` assumes exactly that shape for it.
|
|
2444
|
+
let hostCapabilities
|
|
2445
|
+
if (typeof flags['host-capabilities'] === 'string') {
|
|
2446
|
+
try {
|
|
2447
|
+
hostCapabilities = JSON.parse(flags['host-capabilities'])
|
|
2448
|
+
} catch (err) {
|
|
2449
|
+
die(`--host-capabilities must be a JSON object of the host's declared capabilities: ${err.message}`)
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
step = runStart(ctx, {
|
|
2453
|
+
change: name,
|
|
2454
|
+
host: typeof flags.host === 'string' ? flags.host : undefined,
|
|
2455
|
+
hostCapabilities,
|
|
2456
|
+
flags: {
|
|
2457
|
+
applyOnly: flags['apply-only'] === true,
|
|
2458
|
+
noCommit: flags['no-commit'] === true,
|
|
2459
|
+
skipE2e: flags['skip-e2e'] === true,
|
|
2460
|
+
skipCoverage: flags['skip-coverage'] === true,
|
|
2461
|
+
isolateWaves: flags['isolate-waves'] === true,
|
|
2462
|
+
laneMode: laneMode || null,
|
|
2463
|
+
tddMode: tddFlag(flags),
|
|
2464
|
+
maxParallel: num(flags, 'max-parallel') ?? null,
|
|
2465
|
+
mode: flags.continue === true ? 'continue' : 'checkpoint',
|
|
2466
|
+
review: flags.review !== undefined || flags.strict !== undefined,
|
|
2467
|
+
handoff: flags.handoff !== undefined || flags.strict !== undefined,
|
|
2468
|
+
conformance: flags.conformance !== undefined || flags.strict !== undefined,
|
|
2469
|
+
strict: flags.strict !== undefined
|
|
2470
|
+
}
|
|
2471
|
+
})
|
|
2472
|
+
} else if (sub === 'classified') {
|
|
2473
|
+
const classified =
|
|
2474
|
+
typeof flags.classified === 'string' && flags.classified.trim()
|
|
2475
|
+
? flags.classified.trim()
|
|
2476
|
+
: CLASSIFIED_PATH
|
|
2477
|
+
step = runClassified(ctx, { classifiedPath: classified })
|
|
2478
|
+
} else if (sub === 'record-batch') {
|
|
2479
|
+
step = runRecordBatch(ctx, { results: resultList })
|
|
2480
|
+
} else if (sub === 'judge') {
|
|
2481
|
+
const context = flags.context === undefined ? 'final' : String(flags.context)
|
|
2482
|
+
if (context !== 'inter-wave' && context !== 'final') {
|
|
2483
|
+
die(`--context must be inter-wave|final (got "${context}")`)
|
|
2484
|
+
}
|
|
2485
|
+
step = runJudge(ctx, { context, results: resultList })
|
|
2486
|
+
} else if (sub === 'verify-final') {
|
|
2487
|
+
step = runVerifyFinal(ctx)
|
|
2488
|
+
} else if (sub === 'reviewed') {
|
|
2489
|
+
step = runReviewed(ctx, { results: resultList })
|
|
2490
|
+
} else if (sub === 'remediated') {
|
|
2491
|
+
const roundNum = num(flags, 'round')
|
|
2492
|
+
if (!Number.isInteger(roundNum)) die('run remediated requires --round <n>')
|
|
2493
|
+
step = runRemediated(ctx, { round: roundNum, results: resultList })
|
|
2494
|
+
} else if (sub === 'commit') {
|
|
2495
|
+
step = runCommit(ctx, { results: resultList })
|
|
2496
|
+
} else if (sub === 'replan') {
|
|
2497
|
+
step = runReplan(ctx, { results: resultList })
|
|
2498
|
+
} else if (sub === 'next') {
|
|
2499
|
+
step = runNext(ctx)
|
|
2500
|
+
} else if (sub === 'close') {
|
|
2501
|
+
const hostBanners = flags['host-banners']
|
|
2502
|
+
? readInput(flags['host-banners'], '--host-banners <file|->')
|
|
2503
|
+
: []
|
|
2504
|
+
const hostObserved = flags['host-observed']
|
|
2505
|
+
? readInput(flags['host-observed'], '--host-observed <file|->')
|
|
2506
|
+
: null
|
|
2507
|
+
step = await runClose(ctx, {
|
|
2508
|
+
results: resultList,
|
|
2509
|
+
halt: typeof flags.halt === 'string' ? flags.halt : null,
|
|
2510
|
+
hostBanners: Array.isArray(hostBanners) ? hostBanners : [],
|
|
2511
|
+
hostObserved,
|
|
2512
|
+
notify: flags.notify === true
|
|
2513
|
+
})
|
|
2514
|
+
emit(json, step, step.summary + '\n')
|
|
2515
|
+
if (step.exitCode !== 0) process.exit(step.exitCode)
|
|
2516
|
+
break
|
|
2517
|
+
} else {
|
|
2518
|
+
die(
|
|
2519
|
+
`unknown run subcommand: ${sub || '(none)'} — expected ` +
|
|
2520
|
+
'start|classified|record-batch|judge|verify-final|reviewed|remediated|commit|replan|next|close'
|
|
2521
|
+
)
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
emit(json, step, formatRunStep(step))
|
|
2525
|
+
// A halt step still exits 0: its `then` is the close, and a driver that
|
|
2526
|
+
// stopped here would leave the run with no receipt and no terminal event.
|
|
2527
|
+
// `run close` is the call that carries the non-zero exit.
|
|
2528
|
+
break
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
case 'wave-state': {
|
|
2532
|
+
const sub = positional[1]
|
|
2533
|
+
const change = typeof flags.change === 'string' ? flags.change : undefined
|
|
2534
|
+
if (sub === 'create') {
|
|
2535
|
+
const plan = readInput(flags.plan, '--plan <file|->')
|
|
2536
|
+
const maxParallel = num(flags, 'max-parallel')
|
|
2537
|
+
// The one place the name enters the run: from here it travels on the
|
|
2538
|
+
// state, and no later wave-state invocation needs the flag.
|
|
2539
|
+
const state = createRunState(plan, { maxParallel, change })
|
|
2540
|
+
const { ok: logOk } = logWaveMutation(root, { source: 'create', change, state })
|
|
2541
|
+
emit(json, state, formatRunState(state))
|
|
2542
|
+
if (!logOk) process.exit(1)
|
|
2543
|
+
} else if (sub === 'next') {
|
|
2544
|
+
const state = readInput(flags.state, '--state <file|->')
|
|
2545
|
+
const { step, ok: logOk } = logWaveMutation(root, { source: 'next', change, state })
|
|
2546
|
+
const spawnOk = logAgentSpawns(root, { state, change, step, source: 'next' })
|
|
2547
|
+
emit(json, step, formatRunState(state))
|
|
2548
|
+
if (step.action === 'halt' || !logOk || !spawnOk) process.exit(1)
|
|
2549
|
+
} else if (sub === 'record-batch' || sub === 'record-verify') {
|
|
2550
|
+
const state = readInput(flags.state, '--state <file|->')
|
|
2551
|
+
const result = readInput(flags.result, '--result <file|->')
|
|
2552
|
+
// The observed path set is passed as an argument, never read from
|
|
2553
|
+
// `result`: `result` is written by the agent being audited, and an audit
|
|
2554
|
+
// whose input its subject supplies is not an audit.
|
|
2555
|
+
const next =
|
|
2556
|
+
sub === 'record-batch'
|
|
2557
|
+
? recordBatchResult(state, result, { changedPaths: observedChangedPaths(root) })
|
|
2558
|
+
: recordVerifyResult(state, result)
|
|
2559
|
+
const { ok: logOk } = logWaveMutation(root, { source: sub, change, state: next })
|
|
2560
|
+
// Derived from the two states rather than returned by the recorder, so
|
|
2561
|
+
// `recordBatchResult`'s signature and every other caller are untouched.
|
|
2562
|
+
const recorded = sub === 'record-batch' ? batchOutcomes(state, next) : undefined
|
|
2563
|
+
emitRecordedState(json, flags, next, { root, change, logOk, source: sub, recorded })
|
|
2564
|
+
} else if (sub === 'replan') {
|
|
2565
|
+
const state = readInput(flags.state, '--state <file|->')
|
|
2566
|
+
const groups = readInput(flags.groups, '--groups <file|->')
|
|
2567
|
+
if (!Array.isArray(groups)) {
|
|
2568
|
+
die('--groups must be a JSON array of { group, tasks } revisions')
|
|
2569
|
+
}
|
|
2570
|
+
const next = applyReplan(state, groups)
|
|
2571
|
+
const { ok: logOk } = logWaveMutation(root, { source: 'replan', change, state: next })
|
|
2572
|
+
emitRecordedState(json, flags, next, { root, change, logOk, source: 'replan' })
|
|
2573
|
+
} else {
|
|
2574
|
+
die(
|
|
2575
|
+
`unknown wave-state subcommand: ${sub || '(none)'} — expected ` +
|
|
2576
|
+
'create|next|record-batch|record-verify|replan'
|
|
2577
|
+
)
|
|
2578
|
+
}
|
|
2579
|
+
break
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
case 'autonomy': {
|
|
2583
|
+
const sub = positional[1]
|
|
2584
|
+
const opts = { root: flags.root ? String(flags.root) : undefined }
|
|
2585
|
+
if (sub === 'level') {
|
|
2586
|
+
const path = positional[2]
|
|
2587
|
+
if (!path) die('autonomy level requires a path')
|
|
2588
|
+
const level = getLevel(path, opts)
|
|
2589
|
+
emit(json, { path, level }, `${level}\n`)
|
|
2590
|
+
} else if (sub === 'record') {
|
|
2591
|
+
const path = positional[2]
|
|
2592
|
+
if (!path) die('autonomy record requires a path')
|
|
2593
|
+
const outcome = flags['human-override']
|
|
2594
|
+
? { human_override: true }
|
|
2595
|
+
: { blockers: Number(flags.blockers || 0) }
|
|
2596
|
+
const result = record(path, outcome, opts)
|
|
2597
|
+
const text = result.promoted
|
|
2598
|
+
? `${path}: promoted ${result.from} → ${result.to}\n`
|
|
2599
|
+
: `${path}: ${result.current_level} (${result.consecutive_clean}/${result.needed} clean)\n`
|
|
2600
|
+
emit(json, { path, ...result }, text)
|
|
2601
|
+
} else if (sub === 'clean') {
|
|
2602
|
+
const paths = positional.slice(2)
|
|
2603
|
+
if (!paths.length) die('autonomy clean requires at least one path')
|
|
2604
|
+
const results = recordCleanFlow(paths, opts)
|
|
2605
|
+
const text = results
|
|
2606
|
+
.map(r =>
|
|
2607
|
+
r.promoted
|
|
2608
|
+
? `${r.path}: promoted ${r.from} → ${r.to}`
|
|
2609
|
+
: `${r.path}: ${r.current_level} (${r.consecutive_clean}/${r.needed} clean)`
|
|
2610
|
+
)
|
|
2611
|
+
.join('\n') + '\n'
|
|
2612
|
+
emit(json, results, text)
|
|
2613
|
+
} else if (sub === 'state') {
|
|
2614
|
+
const state = getState(opts)
|
|
2615
|
+
const text = Object.entries(state).length
|
|
2616
|
+
? Object.entries(state)
|
|
2617
|
+
.map(([p, e]) => `${e.current_level}\t${p}\truns=${e.runs} clean=${e.consecutive_clean} blockers=${e.blockers_caught}`)
|
|
2618
|
+
.join('\n') + '\n'
|
|
2619
|
+
: '(no recorded runs)\n'
|
|
2620
|
+
emit(json, state, text)
|
|
2621
|
+
} else {
|
|
2622
|
+
die(`unknown autonomy subcommand: ${sub || '(none)'}`)
|
|
2623
|
+
}
|
|
2624
|
+
break
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
default:
|
|
2628
|
+
process.stderr.write(`Unknown command: ${cmd}\n\n${USAGE}`)
|
|
2629
|
+
process.exit(1)
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
// `judge` and `unit` take the same two evidence inputs, so they read them the
|
|
2634
|
+
// same way.
|
|
2635
|
+
function verifyJudgeOpts(flags) {
|
|
2636
|
+
const opts = {}
|
|
2637
|
+
if (flags.baseline !== undefined) opts.baseline = readInput(flags.baseline, '--baseline <file|->')
|
|
2638
|
+
if (flags['pre-existing'] === true) opts.preExistingFailures = true
|
|
2639
|
+
if (flags['pre-existing-signatures'] !== undefined) {
|
|
2640
|
+
const sigs = readInput(flags['pre-existing-signatures'], '--pre-existing-signatures <file|->')
|
|
2641
|
+
if (!Array.isArray(sigs)) die('--pre-existing-signatures must be a JSON array of strings')
|
|
2642
|
+
opts.preExistingFailures = sigs
|
|
2643
|
+
}
|
|
2644
|
+
return opts
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// `main()` is async only because `run close` awaits one bounded `fetch`
|
|
2648
|
+
// (design D14). Every other case stays synchronous and returns before the
|
|
2649
|
+
// first await, so a rejection here reaches exactly the handler a throw used
|
|
2650
|
+
// to — same body, same `die()`, same exit code.
|
|
2651
|
+
main().catch(err => {
|
|
2652
|
+
if (err && err.userFacing) die(err.message)
|
|
2653
|
+
process.stderr.write(`interlock: ${err && err.message ? err.message : err}\n`)
|
|
2654
|
+
process.exit(1)
|
|
2655
|
+
})
|