@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# `--continue` — the continuity path
|
|
2
|
+
|
|
3
|
+
Only when the user passed `--continue`. Without it, the spec run ended at the human checkpoint (SKILL.md §6).
|
|
4
|
+
|
|
5
|
+
Continuity does not decide anything itself. It asks one machine one question — *may this change skip the human read?* — and obeys the answer. The judgement is `interlock ready`, which is code; this file is only the wiring.
|
|
6
|
+
|
|
7
|
+
**If `--force-checkpoint` was also passed, stop at SKILL.md §6 now.** Do not run readiness. The user opted back out, and the flag exists precisely so that changing your mind costs nothing.
|
|
8
|
+
|
|
9
|
+
## Ask
|
|
10
|
+
|
|
11
|
+
Hand the gate the artifact review's **own findings file** — the one `/interlock:review-artifacts` wrote. Do not compose a file containing a blocker count, and do not transcribe the count anywhere: the gate derives it, using the same evaluation `interlock gate` uses.
|
|
12
|
+
|
|
13
|
+
That is the whole point. The number that decides whether a human reads this spec must not be written by the agent being gated.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
interlock ready "<name>" --findings <path to the artifact review's findings JSON> --paths <planned paths> --json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If the artifact review wrote no findings file, **the review has not run.** Stop at the human checkpoint (SKILL.md §6) and say so. Do not synthesise a findings file, and do not fall back to a count — an absent, empty or unparseable findings file is a blocker, never zero blockers.
|
|
20
|
+
|
|
21
|
+
`--review <file>` (a hand-written `{blockers, warnings}`) is **deprecated**. It warns, and on its own it no longer satisfies the review-blockers check.
|
|
22
|
+
|
|
23
|
+
`--paths` is the repo-relative paths `tasks.md` and `design.md` say this change will touch. They need not exist yet — the classifier reads planned paths. Passing none makes the blast radius unclassifiable, which fails closed to `high` and stops continuity, so pass them.
|
|
24
|
+
|
|
25
|
+
## Branch on the exit code, never on the prose
|
|
26
|
+
|
|
27
|
+
**Exit 0** (`ready: true`) — invoke `/interlock:ship` (the trampoline skill). It launches the workflow; pass `mode: "continue"` so the outcome corpus is not misfiled as a checkpoint:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Workflow({
|
|
31
|
+
scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/ship.js",
|
|
32
|
+
args: { change: "<change-name>", mode: "continue" }
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If you invoke the skill instead, pass `mode=continue` in its arguments so the trampoline forwards `{ change, mode: "continue" }`.
|
|
37
|
+
|
|
38
|
+
Say what you are doing and why it was allowed: the risk class, and that continuity was requested. Then hand over; from that point nothing can ask the user anything. **Do not call Workflow again** after it returns. Leftover checkboxes are a report, not a second launch.
|
|
39
|
+
|
|
40
|
+
Ship is lean by default (waves → verify → commit). Continuity does **not** invent `--strict`. Pass `flags: ["strict"]` only when the user asked for the review/handoff tail.
|
|
41
|
+
|
|
42
|
+
The mode matters more than it looks. The corpus exists to compare continuity runs against checkpoint runs, and a continuity run filed as a checkpoint is worse than no record — it makes the comparison say the opposite of the truth.
|
|
43
|
+
|
|
44
|
+
**Exit 1** (`ready: false`) — **do not ship.** Present *only* what blocks:
|
|
45
|
+
|
|
46
|
+
- Every `needs_human` row from the ledger — id and question.
|
|
47
|
+
- Every entry in the readiness `blockers[]` — the message, and its evidence line.
|
|
48
|
+
|
|
49
|
+
Nothing else. **Do not dump the spec.** The user opted out of reading it; the point is to ask them the specific questions, not to hand back the reading they declined. Offer *"open the artifacts"* as a secondary action for anyone who wants it, and name the change directory so they can.
|
|
50
|
+
|
|
51
|
+
Title it plainly: **Continuity paused — N decisions need you.** `AskUserQuestion` is available here for the ledger rows; this is not a zero-touch skill, and this is the last place a question is possible.
|
|
52
|
+
|
|
53
|
+
## After the human answers
|
|
54
|
+
|
|
55
|
+
1. Write the resolutions into `openspec/changes/<name>/decisions.md` — edit each row in place per `${CLAUDE_PLUGIN_ROOT}/shared/DECISION-LEDGER.md`: flip the class, write the answer into `resolution`, cite the human in `evidence`. Never delete a row.
|
|
56
|
+
2. Update `design.md` where an answer changed a decision, **and record the resolved decision there by its id** (`D2`). `interlock ledger` now checks that: an `agent_resolved` row whose id appears nowhere in `design.md` is invalid and blocks exactly like `needs_human`. A resolution the design contradicts is worse than an open question; a resolution the design never mentions is one nobody can find.
|
|
57
|
+
3. Re-run the Ask command.
|
|
58
|
+
4. Passes → ship. Still blocked → present the remaining rows the same way. If the second run blocks on something the human cannot answer in a sentence — an artifact is not implementable, the risk class is too high — stop and route to the default checkpoint. Continuity is not a loop to grind against.
|
|
59
|
+
|
|
60
|
+
## The rules that make this safe enough
|
|
61
|
+
|
|
62
|
+
- **Fail closed. Any doubt stops.** `ready` exits non-zero when a check could not run, not only when one failed — an unread ledger, an absent review, a classifier that threw are all blockers. So branch on the **exit code**. Never on parsed prose, never on your own reading of the artifacts, and never on a partial pass because most checks were green.
|
|
63
|
+
- **`ship` cannot ask anything.** It is a dynamic workflow, and the workflow runtime accepts no mid-run user input at all. Every interrupt therefore happens *here*, before ship starts. There is no "we will confirm that during implementation".
|
|
64
|
+
- **Autonomy level does not imply continuity.** They answer different questions. Do not consult the ladder here, do not mention it as a reason, and do not offer `--continue` because a path is L3.
|
|
65
|
+
- **Never offer `--continue` yourself.** It is opt-in, and a suggestion from the tool is not an opt-in. If the user has not asked for it, SKILL.md §6 is the end of the run.
|
|
66
|
+
- **Continuity cannot catch a wrong idea.** Readiness proves the change is *implementable*, never that it is *right*. Only a person reading the spec can do that. Say so when a continuity run starts, rather than implying a green gate means a good change.
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'ship',
|
|
3
|
+
description:
|
|
4
|
+
'Take a reviewed OpenSpec change from tasks to commit in one uninterrupted run — dependency-ordered waves of parallel implementers, unit verification, and a commit. Asks nothing. Pass --strict for the previous default (adversarial review, handoff, conformance).'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// ship — the loop, INTERPRETED.
|
|
8
|
+
//
|
|
9
|
+
// This used to be `skills/ship/SKILL.md`: eleven numbered headings of prose a
|
|
10
|
+
// model was asked to follow in order. Then it became a script that held the
|
|
11
|
+
// loop, the branching and every agent's briefing — and `bin/interlock-ship-acp`
|
|
12
|
+
// held a second copy of the same, sharing this file's policy by evaluating
|
|
13
|
+
// marked blocks of its source through `new Function`. Every new classifier
|
|
14
|
+
// field, banner or prompt sentence had to be added twice, and this repository's
|
|
15
|
+
// own memory records the times it was added once.
|
|
16
|
+
//
|
|
17
|
+
// So the loop moved into the CLI (`lib/run.mjs`) and what is left here is an
|
|
18
|
+
// interpreter:
|
|
19
|
+
//
|
|
20
|
+
// interlock run emits a step: the agents to spawn, with their briefings,
|
|
21
|
+
// and the exact argv to call once they return
|
|
22
|
+
// this script spawns what a step names and calls the argv it names
|
|
23
|
+
// agents read files, write code, run commands
|
|
24
|
+
//
|
|
25
|
+
// The loop is four lines. It branches on nothing — not a flag, not a mode, not
|
|
26
|
+
// a count, not a verdict. Every branch the run has (apply-only, no-commit,
|
|
27
|
+
// skip-e2e, isolate-waves, replan allowed, verify skipped, done) is taken inside
|
|
28
|
+
// the CLI, from the run manifest and the wave state. There is nothing here for
|
|
29
|
+
// the ACP driver to duplicate, because there is nothing here to duplicate.
|
|
30
|
+
//
|
|
31
|
+
// What this file still owns is host plumbing, and nothing else:
|
|
32
|
+
//
|
|
33
|
+
// - `parseInvocation`, because argument delivery is host-specific.
|
|
34
|
+
// - The ping that runs a CLI command. The runtime loads no modules and gives
|
|
35
|
+
// the script no filesystem or shell of its own, so every `interlock` call is
|
|
36
|
+
// an agent that runs the command and copies stdout.
|
|
37
|
+
// - Delivering a briefing BY REFERENCE. The script cannot hold several
|
|
38
|
+
// kilobytes of prompt without a ping copying it, and a relay miss on a 4 KB
|
|
39
|
+
// string is silent under-instruction — so the worker is handed a path and a
|
|
40
|
+
// hash and must report the hash back. See BOOTSTRAP.
|
|
41
|
+
// - The runtime's own token counter, which no other process can read.
|
|
42
|
+
// - `RUNAWAY_BACKSTOP`, which is the RUNTIME's agent ceiling and not a policy
|
|
43
|
+
// cap. The policy bound on run steps is `interlock limits`' `maxRunSteps`,
|
|
44
|
+
// enforced by the CLI, which returns a halt step naming it.
|
|
45
|
+
//
|
|
46
|
+
// The strict tail — adversarial review, bounded remediation, the verdict and
|
|
47
|
+
// the handoff artifacts — used to run inline here behind a `host-tail` step,
|
|
48
|
+
// and was the last loop text this file held. `emit-strict-tail-from-cli` made
|
|
49
|
+
// it part of the program the CLI emits, so this script interprets those steps
|
|
50
|
+
// exactly as it interprets a batch: spawn what the step names, write the
|
|
51
|
+
// results, call the argv. There is no seam left, and the no-policy sweep in
|
|
52
|
+
// test/workflows.test.mjs has no allowance.
|
|
53
|
+
//
|
|
54
|
+
// The runtime also accepts no mid-run user input. That is the zero-touch
|
|
55
|
+
// contract, and it is structural rather than aspirational — there is no
|
|
56
|
+
// AskUserQuestion to remove, because there is nobody listening.
|
|
57
|
+
|
|
58
|
+
// --- run configuration -----------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// The Workflow tool delivers `args` as a string, a JSON array, or `{ change }`.
|
|
61
|
+
// Treating an array as "not an object" used to drop the name, so validate ran
|
|
62
|
+
// nameless against every active change and halted. parseInvocation is marked
|
|
63
|
+
// so tests can eval it without the runtime.
|
|
64
|
+
// PARSE_INVOCATION_START
|
|
65
|
+
function parseInvocation(args) {
|
|
66
|
+
let value = args
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
const trimmed = value.trim()
|
|
69
|
+
if (
|
|
70
|
+
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
71
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
72
|
+
) {
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(trimmed)
|
|
75
|
+
} catch {
|
|
76
|
+
value = trimmed
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
value = trimmed
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const tokens = Array.isArray(value)
|
|
83
|
+
? value.map(String)
|
|
84
|
+
: typeof value === 'string'
|
|
85
|
+
? value.split(/\s+/).filter(Boolean)
|
|
86
|
+
: []
|
|
87
|
+
const opts = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}
|
|
88
|
+
const flags = new Set(
|
|
89
|
+
(Array.isArray(opts.flags) ? opts.flags : []).concat(tokens.filter(t => t.startsWith('-'))).map(String)
|
|
90
|
+
)
|
|
91
|
+
const has = name => flags.has(name) || flags.has(`--${name}`) || opts[name] === true
|
|
92
|
+
const named =
|
|
93
|
+
(typeof opts.change === 'string' && opts.change.trim()) ||
|
|
94
|
+
(typeof opts.name === 'string' && opts.name.trim()) ||
|
|
95
|
+
''
|
|
96
|
+
const strict = has('strict')
|
|
97
|
+
// The PLAN shape, which is a different decision from `mode` below (that one is
|
|
98
|
+
// checkpoint/continue). Both flags at once is a contradiction the run reports
|
|
99
|
+
// and stops on, never a last-wins guess: the two produce different bills and
|
|
100
|
+
// different agent counts, and picking one silently would ship the shape the
|
|
101
|
+
// operator did not ask for. The conflict is reported rather than thrown so the
|
|
102
|
+
// parse stays pure and the halt happens where every other halt does.
|
|
103
|
+
const soloFlag = has('solo')
|
|
104
|
+
const wavesFlag = has('waves')
|
|
105
|
+
// The TASK shape, which is a third decision again: whether the change's
|
|
106
|
+
// leading failing-test section runs first instead of deferring. Absent both
|
|
107
|
+
// flags, `tasks.md`'s own section heading decides — so an ordinary change is
|
|
108
|
+
// planned exactly as it was before either flag existed. Contradictory flags
|
|
109
|
+
// are reported and stopped on for the same reason --solo/--waves are: the two
|
|
110
|
+
// produce different wave orders and different verifications.
|
|
111
|
+
const tddFlag = has('tdd')
|
|
112
|
+
const noTddFlag = has('no-tdd')
|
|
113
|
+
return {
|
|
114
|
+
changeArg: named || tokens.find(t => !t.startsWith('-')) || '',
|
|
115
|
+
applyOnly: has('apply-only'),
|
|
116
|
+
noCommit: has('no-commit'),
|
|
117
|
+
skipE2e: has('skip-e2e'),
|
|
118
|
+
skipCoverage: has('skip-coverage'),
|
|
119
|
+
review: strict || has('review'),
|
|
120
|
+
handoff: strict || has('handoff'),
|
|
121
|
+
conformance: strict || has('conformance'),
|
|
122
|
+
strict,
|
|
123
|
+
// Opt-in, default off — unset, a run is byte-for-byte today's behavior: no
|
|
124
|
+
// worktree, no merge-lanes step. See design.md Decision 1 for why this is
|
|
125
|
+
// gated rather than always-on.
|
|
126
|
+
isolateWaves: has('isolate-waves'),
|
|
127
|
+
laneMode: soloFlag && wavesFlag ? null : soloFlag ? 'solo' : wavesFlag ? 'waves' : null,
|
|
128
|
+
laneModeConflict: soloFlag && wavesFlag,
|
|
129
|
+
tddMode: tddFlag && noTddFlag ? null : tddFlag ? 'tdd' : noTddFlag ? 'no-tdd' : null,
|
|
130
|
+
tddModeConflict: tddFlag && noTddFlag,
|
|
131
|
+
maxParallel: Number.isInteger(opts.maxParallel) ? opts.maxParallel : null,
|
|
132
|
+
mode: opts.mode === 'continue' ? 'continue' : 'checkpoint'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// PARSE_INVOCATION_END
|
|
136
|
+
|
|
137
|
+
// --- host plumbing ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// Spawn prefix for this script's OWN control-plane pings. A step names the type
|
|
140
|
+
// and tools of every agent it asks for, so these two literals are all that is
|
|
141
|
+
// left of the four this file used to carry — and they are the ping's, which is
|
|
142
|
+
// plumbing rather than policy. Dual-write `type` (plugin agent) and `tools`
|
|
143
|
+
// (allowlist) so a runtime that ignores one key still shrinks the inherited
|
|
144
|
+
// catalog.
|
|
145
|
+
const PING_AGENT = 'interlock:ping'
|
|
146
|
+
const PING_TOOLS = ['Bash', 'Read', 'Write']
|
|
147
|
+
const pingExtra = { type: PING_AGENT, tools: PING_TOOLS }
|
|
148
|
+
|
|
149
|
+
const WORK = '.claude/ship'
|
|
150
|
+
const RESULTS = `${WORK}/results.json`
|
|
151
|
+
|
|
152
|
+
// The runtime caps a run at 1000 agents. This is that ceiling, named so nobody
|
|
153
|
+
// mistakes it for policy: it is NOT a bound on the loop, and it is deliberately
|
|
154
|
+
// not derived from `maxRunSteps` or from anything else the CLI publishes. It
|
|
155
|
+
// exists only so a loop whose exit condition depends on a relay cannot spin
|
|
156
|
+
// forever if that relay breaks.
|
|
157
|
+
const RUNAWAY_BACKSTOP = 1000
|
|
158
|
+
|
|
159
|
+
// Briefings are delivered BY REFERENCE (design D2). The CLI writes each one to a
|
|
160
|
+
// file whose first line carries its sha256; this script hands the worker the
|
|
161
|
+
// path and requires the hash back. A result whose `briefing` is absent or
|
|
162
|
+
// differs is treated as a null result — the task fails closed with a named
|
|
163
|
+
// reason, exactly as a missing handoff packet does.
|
|
164
|
+
//
|
|
165
|
+
// This text is plumbing, not policy: it names a file and a field and says
|
|
166
|
+
// nothing whatsoever about the work. It is pinned against
|
|
167
|
+
// test/fixtures/prompts/bootstrap.txt so it cannot quietly grow instructions.
|
|
168
|
+
const BOOTSTRAP = (label, path, sha) =>
|
|
169
|
+
`Your briefing for this step is the file ${path}.\n\n` +
|
|
170
|
+
`Read it in full before doing anything else and follow it as the whole of your instructions. ` +
|
|
171
|
+
`Its first line is an HTML comment carrying a sha256; report that hex string as "briefing" in ` +
|
|
172
|
+
`your result. A result without it is discarded and this step is recorded as failed, so read the ` +
|
|
173
|
+
`file rather than guessing at what "${label}" means.\n\n` +
|
|
174
|
+
`Expected sha256: ${sha}`
|
|
175
|
+
|
|
176
|
+
// Everything this host observed that the CLI could not, which is now two
|
|
177
|
+
// things: `banners`, the degradations only this host can raise, folded into the
|
|
178
|
+
// close through --host-banners; and `spend`, the runtime's own token counter,
|
|
179
|
+
// which no other process can read. The review, remediation and handoff figures
|
|
180
|
+
// used to live here too and are the CLI's own now — it adjudicated them.
|
|
181
|
+
const banners = []
|
|
182
|
+
const summary = { spend: [], notes: [] }
|
|
183
|
+
// --- token spend -----------------------------------------------------------
|
|
184
|
+
//
|
|
185
|
+
// `budget` is a workflow-runtime global — `{total, spent(), remaining()}` — and
|
|
186
|
+
// is read through three guards, none of which is paranoia:
|
|
187
|
+
//
|
|
188
|
+
// 1. `typeof budget === 'undefined'`. The ACP host evaluates parts of this
|
|
189
|
+
// file with no such global, and a bare reference would throw a
|
|
190
|
+
// ReferenceError rather than degrade.
|
|
191
|
+
// 2. `budget.spent` is a function. A runtime that stops exposing accounting
|
|
192
|
+
// partway through a run must leave the later waves unmeasured, not fail
|
|
193
|
+
// them.
|
|
194
|
+
// 3. NOT `budget.total`. It is null whenever no token target was given, while
|
|
195
|
+
// `spent()` stays perfectly meaningful — guarding on it would blank the
|
|
196
|
+
// measurement on every ordinary run.
|
|
197
|
+
//
|
|
198
|
+
// Every failure path returns null, never 0 and never a throw: a run must not die
|
|
199
|
+
// over its own bookkeeping, and an unmeasured wave must not read as a free one.
|
|
200
|
+
function spentTokens() {
|
|
201
|
+
try {
|
|
202
|
+
if (typeof budget === 'undefined' || !budget || typeof budget.spent !== 'function') return null
|
|
203
|
+
const n = Number(budget.spent())
|
|
204
|
+
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null
|
|
205
|
+
} catch {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The reading the run opened at, and the reading at the last wave boundary.
|
|
211
|
+
const spendOpenedAt = spentTokens()
|
|
212
|
+
let spendMark = spendOpenedAt
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Close the current wave span and attribute its measured delta to `wave`.
|
|
216
|
+
*
|
|
217
|
+
* Called at the point the script closes a wave — beside the `summary.waves`
|
|
218
|
+
* push, and after the inter-wave verification ping — and nowhere else, so the
|
|
219
|
+
* script's notion of a wave boundary and the recorded attribution come from one
|
|
220
|
+
* place. Two derivations of "where a wave ended" would drift, and spend would
|
|
221
|
+
* land on the wrong wave with nothing to reveal it.
|
|
222
|
+
*
|
|
223
|
+
* Keyed by wave rather than pushed blindly: a wave that closes an
|
|
224
|
+
* implementation span and then a verification span has two measured deltas and
|
|
225
|
+
* one wave. Adding two measured figures is not the same as inventing a split of
|
|
226
|
+
* one, which is why the deltas are summed and never divided.
|
|
227
|
+
*
|
|
228
|
+
* A wave whose span could not be measured records null. It is never assumed to
|
|
229
|
+
* be zero on the grounds that no implementer ran — a verification-only span
|
|
230
|
+
* still spends the orchestrator's turns.
|
|
231
|
+
*/
|
|
232
|
+
function markWaveSpend(wave) {
|
|
233
|
+
const now = spentTokens()
|
|
234
|
+
const delta = now === null || spendMark === null ? null : Math.max(0, now - spendMark)
|
|
235
|
+
if (now !== null) spendMark = now
|
|
236
|
+
const key = wave === undefined || wave === null || !String(wave).trim() ? 'unnumbered' : String(wave)
|
|
237
|
+
const found = summary.spend.find(s => s.wave === key)
|
|
238
|
+
if (!found) summary.spend.push({ wave: key, outputTokens: delta })
|
|
239
|
+
else if (delta !== null) found.outputTokens = found.outputTokens === null ? delta : found.outputTokens + delta
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- the run ----------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const {
|
|
245
|
+
changeArg,
|
|
246
|
+
applyOnly,
|
|
247
|
+
noCommit,
|
|
248
|
+
skipE2e,
|
|
249
|
+
skipCoverage,
|
|
250
|
+
review,
|
|
251
|
+
handoff,
|
|
252
|
+
conformance,
|
|
253
|
+
strict,
|
|
254
|
+
isolateWaves,
|
|
255
|
+
laneMode,
|
|
256
|
+
laneModeConflict,
|
|
257
|
+
tddMode,
|
|
258
|
+
tddModeConflict,
|
|
259
|
+
maxParallel,
|
|
260
|
+
mode
|
|
261
|
+
} = parseInvocation(typeof args === 'undefined' ? undefined : args)
|
|
262
|
+
|
|
263
|
+
let steps = 0
|
|
264
|
+
let resolvedChange = changeArg || '(unresolved)'
|
|
265
|
+
|
|
266
|
+
/** A control-plane ping: mechanical, cheap, and never asked to decide anything. */
|
|
267
|
+
const ping = (name, prompt, schema) => agent(prompt, { label: name, schema, ...pingExtra })
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Spawn one agent a step named.
|
|
271
|
+
*
|
|
272
|
+
* The step named its type, tools, model, effort, isolation and schema, so this
|
|
273
|
+
* passes them through and adds only the two things a by-reference host needs:
|
|
274
|
+
* the bootstrap, and `briefing` as a required field.
|
|
275
|
+
*/
|
|
276
|
+
async function spawnOne(s) {
|
|
277
|
+
const result = await agent(BOOTSTRAP(s.label, s.promptPath, s.promptSha256), {
|
|
278
|
+
label: s.label,
|
|
279
|
+
type: s.type,
|
|
280
|
+
tools: s.tools,
|
|
281
|
+
...(s.model ? { model: s.model } : {}),
|
|
282
|
+
...(s.effort ? { effort: s.effort } : {}),
|
|
283
|
+
...(s.isolation ? { isolation: s.isolation } : {}),
|
|
284
|
+
schema: {
|
|
285
|
+
...s.schema,
|
|
286
|
+
required: [...(s.schema.required || []), 'briefing'],
|
|
287
|
+
properties: { ...(s.schema.properties || {}), briefing: { type: 'string' } }
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
// Fail closed on an unacknowledged briefing. An agent that did not read its
|
|
291
|
+
// instructions did not do this task, whatever it reports about it — and a
|
|
292
|
+
// silently under-instructed worker is exactly the failure prompt integrity
|
|
293
|
+
// exists to catch. So it is a null result with a named reason, which the
|
|
294
|
+
// recorder already treats as a failed task, rather than a warning beside a
|
|
295
|
+
// result that gets counted.
|
|
296
|
+
const ack = result && typeof result.briefing === 'string' ? result.briefing.trim() : ''
|
|
297
|
+
if (ack !== s.promptSha256) {
|
|
298
|
+
banners.push(
|
|
299
|
+
`BRIEFING NOT ACKNOWLEDGED: ${s.label} reported ${ack || '(nothing)'} for a briefing whose ` +
|
|
300
|
+
`sha256 is ${s.promptSha256} — the step is recorded as failed rather than trusted`
|
|
301
|
+
)
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
return result
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Spawn everything a step names, in parallel. */
|
|
308
|
+
async function spawnAll(step) {
|
|
309
|
+
const spawns = Array.isArray(step.spawns) ? step.spawns : []
|
|
310
|
+
if (!spawns.length) return []
|
|
311
|
+
return await pipeline(spawns, s => spawnOne(s))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Run one `interlock` command and return the step it printed.
|
|
316
|
+
*
|
|
317
|
+
* The script has no shell, so this is a ping that writes whatever files the
|
|
318
|
+
* command reads, runs the argv and copies stdout. `cliStdout` is the record:
|
|
319
|
+
* a step is read from the CLI's own bytes, never from a field a model retyped,
|
|
320
|
+
* and a relay that produced neither is a loud stop rather than a guess at what
|
|
321
|
+
* the CLI decided.
|
|
322
|
+
*/
|
|
323
|
+
async function cli(argv, results, extraWrites = []) {
|
|
324
|
+
const label = `cli-${++steps}`
|
|
325
|
+
const writes = [...(results === undefined ? [] : [{ path: RESULTS, value: results }]), ...extraWrites]
|
|
326
|
+
const writeLines = writes
|
|
327
|
+
.map(w => `Write this JSON to ${w.path} exactly as given:\n${JSON.stringify(w.value)}`)
|
|
328
|
+
.join('\n\n')
|
|
329
|
+
// Quoted, because an argv token can hold a whole sentence: a halt reason is
|
|
330
|
+
// one argument and reads as several the moment it is joined with spaces. The
|
|
331
|
+
// relay runs a command line, so the quoting has to happen where the command
|
|
332
|
+
// line is built.
|
|
333
|
+
const quote = a => (/[^\w@%+=:,./-]/.test(String(a)) ? `'${String(a).replace(/'/g, `'\\''`)}'` : String(a))
|
|
334
|
+
const command =
|
|
335
|
+
`interlock ${argv.map(quote).join(' ')}${results === undefined ? '' : ` --results ${RESULTS}`} --json`
|
|
336
|
+
const relayed = await ping(
|
|
337
|
+
label,
|
|
338
|
+
`You are a mechanical relay for one command. Do not interpret it, and do not do the work it ` +
|
|
339
|
+
`describes.\n\n` +
|
|
340
|
+
(writeLines ? `${writeLines}\n\n` : '') +
|
|
341
|
+
`Then run exactly:\n ${command}\n\n` +
|
|
342
|
+
`Copy that command's stdout into this result verbatim as cliStdout, and copy its "action" ` +
|
|
343
|
+
`field into action. Never invent either, never summarize, and never adjust a value: a step ` +
|
|
344
|
+
`edited in transit is indistinguishable afterwards from the step the CLI emitted.\n` +
|
|
345
|
+
`A non-zero exit is still a result — copy whatever it printed.`,
|
|
346
|
+
{
|
|
347
|
+
type: 'object',
|
|
348
|
+
properties: { cliStdout: { type: 'string' }, action: { type: 'string' } }
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
if (relayed && typeof relayed.cliStdout === 'string') {
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(relayed.cliStdout)
|
|
354
|
+
if (parsed && typeof parsed.action === 'string') return parsed
|
|
355
|
+
} catch {
|
|
356
|
+
// stdout was not JSON. Nothing is inferred from that: the caller stops.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return null
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Before anything is spawned: two contradictory shape flags is a question only
|
|
363
|
+
// the operator can answer, and this run has nobody to ask. Halting here costs
|
|
364
|
+
// one invocation; guessing costs a whole run of the wrong shape.
|
|
365
|
+
if (laneModeConflict) {
|
|
366
|
+
return await stop(
|
|
367
|
+
'contradictory plan-shape flags: --solo and --waves were both passed — pass exactly one, or ' +
|
|
368
|
+
'neither to let the classifier recommend inside the published envelope'
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (tddModeConflict) {
|
|
373
|
+
return await stop(
|
|
374
|
+
'contradictory task-shape flags: --tdd and --no-tdd were both passed — pass exactly one, or ' +
|
|
375
|
+
"neither to let tasks.md's own leading-section heading decide"
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// The environment probe. Everything it asks about is a property of THIS HOST —
|
|
380
|
+
// whether haiku is reachable, whether the graph was built, whether a test
|
|
381
|
+
// profile exists, whether model routing is overridden — so it is asked here and
|
|
382
|
+
// carried to the close as a host banner, rather than being something the CLI
|
|
383
|
+
// could have found out for itself.
|
|
384
|
+
const probed = await ping(
|
|
385
|
+
'validate',
|
|
386
|
+
`Report this environment. Change nothing, and do not start any work.\n\n` +
|
|
387
|
+
`Run: test -f .claude/graph/graph.json && echo yes || echo no\n` +
|
|
388
|
+
`Report yes as hasGraph:true, no as hasGraph:false with a one-line graphReason.\n\n` +
|
|
389
|
+
`Run: test -f .claude/testing/profile.json && echo yes || echo no\n` +
|
|
390
|
+
`Report it as hasTestProfile.\n\n` +
|
|
391
|
+
`Run: printenv CLAUDE_CODE_SUBAGENT_MODEL\n` +
|
|
392
|
+
`If it prints a value, report it as subagentModelOverride. If it is unset the command exits ` +
|
|
393
|
+
`non-zero and prints nothing — that is the normal case, so leave the field out rather than ` +
|
|
394
|
+
`reporting an empty string.\n\n` +
|
|
395
|
+
`Then run: printenv CLAUDE_CODE_USE_BEDROCK; printenv AWS_BEDROCK\n` +
|
|
396
|
+
`If subagentModelOverride is set, leave haikuAvailable out — routing is already overridden.\n` +
|
|
397
|
+
`If either Bedrock variable prints a non-empty value other than 0 or false, report ` +
|
|
398
|
+
`haikuAvailable:false. Bedrock accounts often cannot reach haiku and a failed ping halts the loop.\n` +
|
|
399
|
+
`Otherwise report haikuAvailable:true. When unsure, haikuAvailable:false so pings inherit ` +
|
|
400
|
+
`the session model rather than hard-failing.\n\n` +
|
|
401
|
+
`Then create the working directory ${WORK}/.`,
|
|
402
|
+
{
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: {
|
|
405
|
+
hasGraph: { type: 'boolean' },
|
|
406
|
+
graphReason: { type: 'string' },
|
|
407
|
+
hasTestProfile: { type: 'boolean' },
|
|
408
|
+
subagentModelOverride: { type: 'string' },
|
|
409
|
+
haikuAvailable: { type: 'boolean' }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if (probed && probed.hasGraph === false) {
|
|
415
|
+
banners.push(
|
|
416
|
+
`GRAPH UNAVAILABLE: ${probed.graphReason || 'never built'} — implementer and reviewer agents fall back to grep and will be slower`
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
if (probed && probed.hasTestProfile === false) {
|
|
420
|
+
banners.push('NO TEST PROFILE: run /interlock:fix-tests --reconfigure once')
|
|
421
|
+
}
|
|
422
|
+
// CLAUDE_CODE_SUBAGENT_MODEL overrides both the session model and the per-agent
|
|
423
|
+
// model a step asks for, so when it is set the planner's tier ladder — the opus
|
|
424
|
+
// clamp, the haiku pings — is not in effect and the run costs whatever that
|
|
425
|
+
// model costs. Nothing here can prevent that; it is the user's environment. But
|
|
426
|
+
// a summary claiming no degradation while the entire model ladder was bypassed
|
|
427
|
+
// is exactly the silence the banner block exists to remove.
|
|
428
|
+
const subagentModel =
|
|
429
|
+
probed && typeof probed.subagentModelOverride === 'string'
|
|
430
|
+
? probed.subagentModelOverride.trim()
|
|
431
|
+
: ''
|
|
432
|
+
if (subagentModel) {
|
|
433
|
+
banners.push(
|
|
434
|
+
`MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL=${subagentModel} — every agent runs on ` +
|
|
435
|
+
`that model, so the per-tier assignment in the plan is not in effect`
|
|
436
|
+
)
|
|
437
|
+
} else if (probed && probed.haikuAvailable === true) {
|
|
438
|
+
// Mutate rather than rebind — `ping` closes over the object.
|
|
439
|
+
pingExtra.model = 'haiku'
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// --- the loop ---------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
let step = await cli([
|
|
445
|
+
'run',
|
|
446
|
+
'start',
|
|
447
|
+
...(changeArg ? ['--change', changeArg] : []),
|
|
448
|
+
...(applyOnly ? ['--apply-only'] : []),
|
|
449
|
+
...(noCommit ? ['--no-commit'] : []),
|
|
450
|
+
...(skipE2e ? ['--skip-e2e'] : []),
|
|
451
|
+
...(skipCoverage ? ['--skip-coverage'] : []),
|
|
452
|
+
...(isolateWaves ? ['--isolate-waves'] : []),
|
|
453
|
+
...(laneMode ? ['--mode', laneMode] : []),
|
|
454
|
+
...(tddMode === 'tdd' ? ['--tdd'] : tddMode === 'no-tdd' ? ['--no-tdd'] : []),
|
|
455
|
+
...(Number.isInteger(maxParallel) ? ['--max-parallel', String(maxParallel)] : []),
|
|
456
|
+
...(mode === 'continue' ? ['--continue'] : []),
|
|
457
|
+
...(review ? ['--review'] : []),
|
|
458
|
+
...(handoff ? ['--handoff'] : []),
|
|
459
|
+
...(conformance ? ['--conformance'] : []),
|
|
460
|
+
...(strict ? ['--strict'] : []),
|
|
461
|
+
'--host',
|
|
462
|
+
'workflow'
|
|
463
|
+
])
|
|
464
|
+
|
|
465
|
+
while (step && step.then) {
|
|
466
|
+
if (steps > RUNAWAY_BACKSTOP) {
|
|
467
|
+
return await stop(`the interpreter exceeded the runtime's agent ceiling (${RUNAWAY_BACKSTOP})`)
|
|
468
|
+
}
|
|
469
|
+
for (const banner of step.banners || []) banners.push(banner)
|
|
470
|
+
if (typeof step.change === 'string' && step.change) resolvedChange = step.change
|
|
471
|
+
|
|
472
|
+
// A wave's measured span closes where the wave closes: at the step that
|
|
473
|
+
// dispatches or verifies it. One place, so the boundary the script acted on
|
|
474
|
+
// and the boundary the receipt reports cannot drift apart.
|
|
475
|
+
if (step.action === 'run-batch' || step.action === 'test-wave' || step.action === 'verify') {
|
|
476
|
+
markWaveSpend(step.wave)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const results = await spawnAll(step)
|
|
480
|
+
// The close is the one continuation that needs more than the spawn results:
|
|
481
|
+
// it reads what only this host observed. Appending here rather than at the
|
|
482
|
+
// one call site `stop()` uses is what keeps a CLEAN run's banners and token
|
|
483
|
+
// spend from being dropped — they used to reach the close only on a halt,
|
|
484
|
+
// which is the path least likely to have any.
|
|
485
|
+
step = isClose(step.then.argv)
|
|
486
|
+
? await cli([...step.then.argv, ...closeArgs()], results, closeWrites())
|
|
487
|
+
: await cli(step.then.argv, results)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!step) {
|
|
491
|
+
return await stop('the run program returned no step — the CLI relay could not be read')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// The last argv was `run close`, so `step` IS the close: its summary and its
|
|
495
|
+
// banners. Print and stop.
|
|
496
|
+
return finish(step)
|
|
497
|
+
|
|
498
|
+
// --- closing ----------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Stop the run through the CLI's own close, so a halt records a receipt, an
|
|
502
|
+
* outcome and a terminal trajectory event exactly as a completion does. A
|
|
503
|
+
* halted run is the most informative record in the corpus, which is why it is
|
|
504
|
+
* written on the way out rather than skipped as a failure.
|
|
505
|
+
*/
|
|
506
|
+
function isClose(argv) {
|
|
507
|
+
return Array.isArray(argv) && argv[0] === 'run' && argv[1] === 'close'
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function stop(reason) {
|
|
511
|
+
const closed = await cli(['run', 'close', '--halt', reason, ...closeArgs()], undefined, closeWrites())
|
|
512
|
+
return finish(
|
|
513
|
+
closed || {
|
|
514
|
+
action: 'halt',
|
|
515
|
+
summary:
|
|
516
|
+
`SHIP HALTED — ${reason}\n\n` +
|
|
517
|
+
`The close itself could not be reached, so no receipt was written for this run.`
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The flags the close reads for what only this host observed. */
|
|
523
|
+
function closeArgs() {
|
|
524
|
+
return [
|
|
525
|
+
...(banners.length ? ['--host-banners', `${WORK}/host-banners.json`] : []),
|
|
526
|
+
'--host-observed',
|
|
527
|
+
`${WORK}/host-observed.json`,
|
|
528
|
+
// Unconditional: this driver knows nothing about topics, servers or
|
|
529
|
+
// notification text. It requests the push; the CLI decides whether one is
|
|
530
|
+
// configured and what it says (design D1).
|
|
531
|
+
'--notify'
|
|
532
|
+
]
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Those same values, as writes the relay performs before running the close. */
|
|
536
|
+
function closeWrites() {
|
|
537
|
+
const spentAtClose = spentTokens()
|
|
538
|
+
return [
|
|
539
|
+
...(banners.length ? [{ path: `${WORK}/host-banners.json`, value: banners }] : []),
|
|
540
|
+
{
|
|
541
|
+
path: `${WORK}/host-observed.json`,
|
|
542
|
+
value: {
|
|
543
|
+
...summary,
|
|
544
|
+
outputTokens:
|
|
545
|
+
spentAtClose === null || spendOpenedAt === null
|
|
546
|
+
? null
|
|
547
|
+
: Math.max(0, spentAtClose - spendOpenedAt)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
]
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Print the close's summary. */
|
|
554
|
+
function finish(closed) {
|
|
555
|
+
const text =
|
|
556
|
+
typeof closed.summary === 'string' && closed.summary.trim()
|
|
557
|
+
? closed.summary
|
|
558
|
+
: `SHIP ${closed.action === 'halt' ? 'HALTED' : 'ENDED'} — ${resolvedChange}`
|
|
559
|
+
return (
|
|
560
|
+
text +
|
|
561
|
+
// Printed by this host alone: `/goal` is a Claude Code convention and means
|
|
562
|
+
// nothing on another host, so it is not part of the summary the CLI builds.
|
|
563
|
+
'\nGOAL MET: interlock ship returned a terminal summary.'
|
|
564
|
+
)
|
|
565
|
+
}
|