@renzrollon/interlock 1.1.1 → 1.2.1

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.
Files changed (55) hide show
  1. package/.claude-plugin/icon.png +0 -0
  2. package/.claude-plugin/plugin.json +109 -1
  3. package/CHANGELOG.md +150 -11
  4. package/bin/interlock +421 -53
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +124 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +511 -46
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/evals-triage.mjs +1 -1
  17. package/lib/graph/cli.mjs +28 -5
  18. package/lib/graph/retrieve/context.mjs +8 -3
  19. package/lib/host/acp.mjs +77 -14
  20. package/lib/host/claude-cli.mjs +391 -21
  21. package/lib/host/claude-env.mjs +179 -0
  22. package/lib/host/codex.mjs +9 -1
  23. package/lib/host/effort.mjs +163 -0
  24. package/lib/host/model-map.mjs +43 -0
  25. package/lib/host/qwen.mjs +9 -1
  26. package/lib/host/registry.mjs +44 -10
  27. package/lib/host.mjs +46 -5
  28. package/lib/interrupted.mjs +167 -0
  29. package/lib/launch-ledger.mjs +198 -0
  30. package/lib/launch-rule.mjs +174 -0
  31. package/lib/limits.mjs +149 -41
  32. package/lib/meter-quiet.mjs +45 -0
  33. package/lib/notify-defaults.mjs +6 -0
  34. package/lib/notify.mjs +41 -25
  35. package/lib/plan-fingerprint.mjs +80 -11
  36. package/lib/prompts/implementer.mjs +1 -1
  37. package/lib/prompts/verify.mjs +24 -1
  38. package/lib/receipt.mjs +59 -8
  39. package/lib/report-html.mjs +89 -6
  40. package/lib/report.mjs +313 -12
  41. package/lib/resume-card.mjs +90 -2
  42. package/lib/run-log.mjs +192 -5
  43. package/lib/run.mjs +1177 -84
  44. package/lib/state-home.mjs +298 -0
  45. package/lib/verify.mjs +37 -0
  46. package/lib/waves.mjs +53 -16
  47. package/package.json +4 -2
  48. package/shared/TEST-PROFILE.md +10 -5
  49. package/skills/evals/SKILL.md +3 -3
  50. package/skills/fix-tests/SKILL.md +3 -3
  51. package/skills/spec/SKILL.md +1 -6
  52. package/skills/spec/scripts/compact-instructions.mjs +30 -0
  53. package/types/index.d.ts +28 -0
  54. package/workflows/ship.js +75 -51
  55. package/.claude-plugin/marketplace.json +0 -19
@@ -0,0 +1,298 @@
1
+ // The state home — where a run's append-only corpora live.
2
+ //
3
+ // A ship run writes two kinds of file. Its working state (the manifest, the
4
+ // cursor, the briefings, the spill) belongs to the working tree the run operates
5
+ // on. Its corpora (the trajectory, the outcome line, the metrics, the resume
6
+ // card) belong to the project, because `interlock report` reads them across
7
+ // every run the project has ever had.
8
+ //
9
+ // On a main checkout those are the same directory and nothing here matters. On
10
+ // a linked worktree they are not: a Desktop worktree session runs in a checkout
11
+ // that is deleted when the session is archived, so corpora written there are
12
+ // written into a directory with an expiry date, and the report run from the main
13
+ // checkout never sees them. This module names the main checkout instead — read
14
+ // from git's common directory, which is the one fact every linked worktree of a
15
+ // repository shares.
16
+ //
17
+ // Four properties are load-bearing:
18
+ //
19
+ // 1. NEVER THROWS. Every failure — a missing git, a directory that is not a
20
+ // repository, a hang, output it cannot read — collapses to the root itself
21
+ // with a reason, which is exactly today's behaviour plus a sentence. The
22
+ // caller banners `STATE HOME UNRESOLVED: <reason>`; a fallback without a
23
+ // reason would be a silent degradation, so every fallback carries one.
24
+ //
25
+ // 2. WRITES NOTHING. It reads two paths from git and asks one yes/no question.
26
+ // `git rev-parse` touches no index and no lock.
27
+ //
28
+ // 3. LANES ARE NOT SESSIONS. Interlock's own lane worktrees are linked
29
+ // worktrees too, and resolving one to the main checkout would scatter a
30
+ // lane's state across two trees. They are recognised by path segment, with
31
+ // no git call — `laneWorktreePath` in `lib/run.mjs` is the one derivation
32
+ // of that path, and a marker file a lane could lose would misread it as a
33
+ // session the moment it went missing.
34
+ //
35
+ // 4. REAL PATHS. macOS keeps `/tmp` and `/var` behind symlinks and git reports
36
+ // real paths, so the root and every path git prints are compared after
37
+ // `realpath`; a symlinked root and its target resolve alike.
38
+ //
39
+ // An explicit home (`--state-home`, else `INTERLOCK_STATE_HOME`) replaces the
40
+ // home only after the surface is known, because the surface describes the
41
+ // session — where the run actually is — and that stays true whoever chose the
42
+ // home.
43
+ //
44
+ // Shelling out to git from `lib/` follows `lib/drift.mjs` and
45
+ // `lib/run-paths.mjs`: `execFileSync`, stderr discarded, bounded, every failure
46
+ // caught.
47
+
48
+ import { execFileSync } from 'node:child_process'
49
+ import { realpathSync } from 'node:fs'
50
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
51
+
52
+ /** What the working root is. Recorded on the manifest, the `run-start` event and the receipt. */
53
+ export const SURFACES = Object.freeze(['main', 'linked-worktree', 'lane-worktree', 'unknown'])
54
+
55
+ /** The environment variable that pins the state home when no `--state-home` flag is given. */
56
+ export const STATE_HOME_ENV = 'INTERLOCK_STATE_HOME'
57
+
58
+ /**
59
+ * The directory Interlock's lane worktrees live under, as path segments.
60
+ *
61
+ * Restated from `LANE_WORKTREES_DIR` in `lib/run.mjs` rather than imported:
62
+ * `lib/run.mjs` imports this module, and the reverse import would be a cycle.
63
+ * `test/spine/state-home.test.mjs` pins the two together, so the restatement
64
+ * cannot drift without a red test.
65
+ */
66
+ export const LANE_WORKTREE_SEGMENTS = Object.freeze(['.claude', 'ship', 'worktrees'])
67
+
68
+ /** How long one git call may take before the resolver gives up on it. */
69
+ const GIT_TIMEOUT_MS = 10_000
70
+
71
+ /** The option git older than 2.31 does not know, and may echo back rather than reject. */
72
+ const PATH_FORMAT = '--path-format=absolute'
73
+
74
+ function defaultExec(file, args, { cwd } = {}) {
75
+ return execFileSync(file, args, {
76
+ cwd,
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ timeout: GIT_TIMEOUT_MS
80
+ })
81
+ }
82
+
83
+ function nonEmptyString(value) {
84
+ return typeof value === 'string' && value.trim() !== ''
85
+ }
86
+
87
+ function messageOf(err) {
88
+ if (err && typeof err.message === 'string' && err.message) return err.message
89
+ return String(err)
90
+ }
91
+
92
+ function isTimeout(err) {
93
+ return Boolean(err) && (err.code === 'ETIMEDOUT' || err.signal === 'SIGTERM')
94
+ }
95
+
96
+ function isMissingGit(err) {
97
+ return Boolean(err) && err.code === 'ENOENT'
98
+ }
99
+
100
+ /** The first line git wrote to stderr, without its `fatal: ` prefix — present only when an exec captured it. */
101
+ function stderrLine(err) {
102
+ const raw = err && err.stderr
103
+ const text = typeof raw === 'string' ? raw : raw && typeof raw.toString === 'function' ? raw.toString() : ''
104
+ const line = text.split(/\r?\n/).map(l => l.trim()).find(Boolean)
105
+ return line ? line.replace(/^(fatal|error):\s*/i, '') : null
106
+ }
107
+
108
+ /**
109
+ * One sentence for a failed git call. The default exec discards stderr, so the
110
+ * exit status is usually all there is; 128 is git's fatal exit, and the fatal a
111
+ * resolver meets is almost always "not a git repository".
112
+ */
113
+ function describeGitFailure(err, what, root) {
114
+ if (isMissingGit(err)) return `git could not be run in ${root}: not found on PATH (ENOENT)`
115
+ if (isTimeout(err)) return `git timed out after ${GIT_TIMEOUT_MS / 1000}s in ${root}`
116
+ const said = stderrLine(err)
117
+ const status = err && typeof err.status === 'number' ? err.status : null
118
+ if (said) return `${what} failed in ${root}: ${said}`
119
+ if (status === 128) return `${what} failed in ${root} (exit 128): not a git repository, or one git refuses to read`
120
+ if (status !== null) return `${what} failed in ${root} (exit ${status})`
121
+ if (err && err.signal) return `${what} in ${root} was stopped by ${err.signal}`
122
+ return `${what} failed in ${root}: ${messageOf(err)}`
123
+ }
124
+
125
+ function lines(out) {
126
+ return String(out ?? '')
127
+ .split(/\r?\n/)
128
+ .map(l => l.trim())
129
+ .filter(Boolean)
130
+ }
131
+
132
+ /** A path made real when it can be; left as given when it cannot (it may not exist, which is not an error here). */
133
+ function realOrSelf(path, realpath) {
134
+ try {
135
+ const real = realpath(path)
136
+ return nonEmptyString(real) ? real : path
137
+ } catch {
138
+ return path
139
+ }
140
+ }
141
+
142
+ function hasLaneSegments(path) {
143
+ const parts = path.split(/[\\/]+/)
144
+ const [a, b, c] = LANE_WORKTREE_SEGMENTS
145
+ for (let i = 0; i + 2 < parts.length; i++) {
146
+ if (parts[i] === a && parts[i + 1] === b && parts[i + 2] === c) return true
147
+ }
148
+ return false
149
+ }
150
+
151
+ /** True when `path` is `root` or anywhere beneath it. */
152
+ function isWithin(path, root) {
153
+ const rel = relative(root, path)
154
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`))
155
+ }
156
+
157
+ /**
158
+ * The state home a user set explicitly, or `null` when they set none.
159
+ *
160
+ * The `--state-home` flag wins over `INTERLOCK_STATE_HOME`; an empty or
161
+ * whitespace-only value, or a flag given without a value, is no setting at all.
162
+ * A relative value is made absolute against the current directory — the one the
163
+ * user typed it in.
164
+ *
165
+ * @param {{flag?: unknown, env?: Record<string, string|undefined>}} [opts]
166
+ * @returns {string|null} an absolute path, or null
167
+ */
168
+ export function explicitStateHome({ flag, env = process.env } = {}) {
169
+ if (nonEmptyString(flag)) return resolve(process.cwd(), flag)
170
+ const fromEnv = env && env[STATE_HOME_ENV]
171
+ if (nonEmptyString(fromEnv)) return resolve(process.cwd(), fromEnv)
172
+ return null
173
+ }
174
+
175
+ /**
176
+ * Where the corpora of a run working in `root` go, and what `root` is.
177
+ *
178
+ * In order (design D13): the root is made absolute and real; a root under
179
+ * Interlock's lane-worktree directory is its own home with no git call; git's
180
+ * directory and common directory decide main checkout versus linked worktree,
181
+ * with the linked worktree's home accepted only when a working tree sits beside
182
+ * the common directory; any failure is the root itself with a reason; and an
183
+ * explicit home replaces the result last, keeping the surface.
184
+ *
185
+ * Never throws and never writes.
186
+ *
187
+ * @param {string} root the working root the run operates on; defaults to the current directory
188
+ * @param {object} [opts]
189
+ * @param {string|null} [opts.explicit] the `--state-home` value; when absent, `env[STATE_HOME_ENV]` is consulted
190
+ * @param {Record<string, string|undefined>} [opts.env] where `INTERLOCK_STATE_HOME` is read; defaults to `process.env`
191
+ * @param {(file: string, args: string[], opts: {cwd: string}) => string} [opts.exec] runs git and returns stdout; throws on failure
192
+ * @param {(path: string) => string} [opts.realpath] defaults to `fs.realpathSync`
193
+ * @returns {{home: string, surface: 'main'|'linked-worktree'|'lane-worktree'|'unknown', reason: string|null, resolvedFrom: 'explicit'|'git'|'lane'|'fallback'}}
194
+ * `reason` is null unless the surface is `unknown`, and says why when it is —
195
+ * including after an explicit home replaced the fallback, because it still
196
+ * explains the surface.
197
+ */
198
+ export function resolveStateHome(root, { explicit, env = process.env, exec = defaultExec, realpath = realpathSync } = {}) {
199
+ let abs = process.cwd()
200
+ let resolved
201
+ try {
202
+ abs = resolve(nonEmptyString(root) ? root : '.')
203
+ resolved = resolveFromRoot(abs, { exec, realpath })
204
+ } catch (err) {
205
+ // Unreachable by design — every step above catches its own failure — but
206
+ // the never-throws property is the contract, so it is held here as well.
207
+ resolved = fallback(abs, `the state home could not be resolved: ${messageOf(err)}`)
208
+ }
209
+
210
+ let pinned = null
211
+ try {
212
+ pinned = nonEmptyString(explicit) ? resolve(process.cwd(), explicit) : explicitStateHome({ env })
213
+ } catch {
214
+ pinned = null
215
+ }
216
+ if (pinned) return { ...resolved, home: realOrSelf(pinned, realpath), resolvedFrom: 'explicit' }
217
+ return resolved
218
+ }
219
+
220
+ function fallback(home, reason) {
221
+ return { home, surface: 'unknown', reason, resolvedFrom: 'fallback' }
222
+ }
223
+
224
+ function resolveFromRoot(abs, { exec, realpath }) {
225
+ // 1. Absolute, then real. A root that cannot be made real is not one git can
226
+ // be asked about either; say which of the two it is.
227
+ let root
228
+ try {
229
+ root = realpath(abs)
230
+ if (!nonEmptyString(root)) root = abs
231
+ } catch (err) {
232
+ const code = err && err.code
233
+ if (code === 'ENOENT' || code === 'ENOTDIR') return fallback(abs, `the working root ${abs} does not exist`)
234
+ return fallback(abs, `the working root ${abs} could not be resolved: ${code || messageOf(err)}`)
235
+ }
236
+
237
+ // 2. One of Interlock's own lane worktrees: its own home, and no git call.
238
+ if (hasLaneSegments(root) || hasLaneSegments(abs)) {
239
+ return { home: root, surface: 'lane-worktree', reason: null, resolvedFrom: 'lane' }
240
+ }
241
+
242
+ // 3. Ask git for the two directories. A git before 2.31 does not know
243
+ // `--path-format` and either rejects it (retried without) or echoes it back
244
+ // as a line of output (dropped); either way the paths may then be relative,
245
+ // and mean something only once resolved against the root. A missing git or
246
+ // a timeout is not retried — the option cannot be why, and a retry would
247
+ // double a ten-second wait for the same answer.
248
+ let out
249
+ try {
250
+ out = exec('git', ['rev-parse', PATH_FORMAT, '--git-dir', '--git-common-dir'], { cwd: root })
251
+ } catch (first) {
252
+ if (isMissingGit(first) || isTimeout(first)) {
253
+ return fallback(root, describeGitFailure(first, 'git rev-parse', root))
254
+ }
255
+ try {
256
+ out = exec('git', ['rev-parse', '--git-dir', '--git-common-dir'], { cwd: root })
257
+ } catch (retry) {
258
+ return fallback(root, describeGitFailure(retry, 'git rev-parse', root))
259
+ }
260
+ }
261
+
262
+ const printed = lines(out)
263
+ if (printed[0] === PATH_FORMAT) printed.shift()
264
+ if (printed.length < 2) {
265
+ return fallback(
266
+ root,
267
+ `git rev-parse in ${root} printed ${printed.length} line(s) where a git directory and a common directory were expected`
268
+ )
269
+ }
270
+ const gitDir = realOrSelf(resolve(root, printed[0]), realpath)
271
+ const commonDir = realOrSelf(resolve(root, printed[1]), realpath)
272
+
273
+ if (gitDir === commonDir) {
274
+ return { home: root, surface: 'main', reason: null, resolvedFrom: 'git' }
275
+ }
276
+
277
+ // A linked worktree. Its main checkout is the common directory's parent — but
278
+ // only when a working tree actually sits there: a bare repository's common
279
+ // directory has no checkout beside it, and its parent is just a directory.
280
+ const candidate = dirname(commonDir)
281
+ const bare = `the common directory ${commonDir} has no working tree beside it (a bare repository)`
282
+ let inside
283
+ try {
284
+ inside = lines(exec('git', ['-C', candidate, 'rev-parse', '--is-inside-work-tree'], { cwd: root }))[0]
285
+ } catch (err) {
286
+ if (isMissingGit(err) || isTimeout(err)) return fallback(root, describeGitFailure(err, 'git rev-parse', candidate))
287
+ return fallback(root, bare)
288
+ }
289
+ if (inside !== 'true') return fallback(root, bare)
290
+
291
+ // A main checkout inside the working root would make the root its own
292
+ // ancestor's home; whatever produced that answer, it is not one to write into.
293
+ if (isWithin(candidate, root)) {
294
+ return fallback(root, `the main checkout ${candidate} resolved inside the working root ${root}`)
295
+ }
296
+
297
+ return { home: candidate, surface: 'linked-worktree', reason: null, resolvedFrom: 'git' }
298
+ }
package/lib/verify.mjs CHANGED
@@ -449,6 +449,43 @@ export function clusterFailures(failures) {
449
449
  )
450
450
  }
451
451
 
452
+ // --- the budget's clock ----------------------------------------------------
453
+
454
+ /**
455
+ * How long a checkpoint's planned checks actually ran, from the timings
456
+ * `interlock verify exec` recorded around each command.
457
+ *
458
+ * Every exec of a planned kind is summed, not only the last: a check re-run
459
+ * after a repair cost that time too. Timings for a kind the plan did not name
460
+ * are ignored — they are not this checkpoint's checks. A planned kind with no
461
+ * timing at all is reported in `missing`, and the caller decides what to charge
462
+ * instead; this function never estimates one.
463
+ *
464
+ * @param {Array<{kind: string}>} steps the plan's steps
465
+ * @param {Array<{kind: string, durationMs: number}>} timings
466
+ * @returns {{ms: number, missing: string[]}}
467
+ */
468
+ export function foldCheckTimings(steps, timings) {
469
+ const planned = [
470
+ ...new Set(
471
+ (Array.isArray(steps) ? steps : [])
472
+ .map(s => (isObject(s) && typeof s.kind === 'string' ? s.kind : null))
473
+ .filter(Boolean)
474
+ )
475
+ ]
476
+ const usable = (Array.isArray(timings) ? timings : []).filter(
477
+ t => isObject(t) && typeof t.kind === 'string' && Number.isFinite(t.durationMs) && t.durationMs >= 0
478
+ )
479
+ let ms = 0
480
+ const missing = []
481
+ for (const kind of planned) {
482
+ const mine = usable.filter(t => t.kind === kind)
483
+ if (!mine.length) missing.push(kind)
484
+ for (const t of mine) ms += Math.round(t.durationMs)
485
+ }
486
+ return { ms, missing }
487
+ }
488
+
452
489
  // --- judging --------------------------------------------------------------
453
490
 
454
491
  /**
package/lib/waves.mjs CHANGED
@@ -11,9 +11,10 @@
11
11
  // touches several files. The clamp keeps a task's recorded model at haiku
12
12
  // or sonnet below tier 5, and opus only on a true tier-5 task, so the
13
13
  // model cannot escalate itself. That clamped model is what a single-task
14
- // lane dispatches on. A lane of two or more tasks dispatches on opus
15
- // (rule 8) without rewriting any task's recorded model. Every clamp is
16
- // reported.
14
+ // lane dispatches on. A lane of two or more tasks dispatches on opus only
15
+ // when its hardest tier clears `LANE_CAPS.opusMinTier` (or every task
16
+ // already carries opus, as in solo); otherwise it runs on sonnet. Every
17
+ // clamp is reported.
17
18
  //
18
19
  // 2. Fan-out cap. A wave is a set of independent tasks, so the temptation is
19
20
  // to spawn all of them at once. The original implementation did exactly
@@ -75,8 +76,9 @@
75
76
  // becomes one chain lane, packed next-fit under the same per-tier cap. A
76
77
  // batch of two or more lanes is left alone, so work that was going to run
77
78
  // concurrently still does. Fusion never crosses a wave. A chain runs in
78
- // the batch order it was fused from, never re-sorted by task id, and it
79
- // dispatches on opus because it holds more than one task.
79
+ // the batch order it was fused from, never re-sorted by task id. Its model
80
+ // follows `laneModel` — opus only when the hardest tier clears the
81
+ // published floor, otherwise sonnet.
80
82
  //
81
83
  // The second half of this file *executes* that plan — as a state machine, not a
82
84
  // runner. The orchestration is moving into a dynamic-workflow script, which can
@@ -518,18 +520,53 @@ export function laneLabel(lane) {
518
520
  return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
519
521
  }
520
522
 
523
+ /** Separates a spawn's label from the words its title adds. */
524
+ export const TITLE_SEPARATOR = ' · '
525
+
526
+ /** The longest title a lane displays under, ellipsis included. */
527
+ const TITLE_MAX = 48
528
+
521
529
  /**
522
- * The model a lane dispatches on, derived from the lane's shape.
530
+ * The name a lane is SHOWN under — its label, then the first words of its first
531
+ * task's description: `1.1+5 · Add the relaunch guard to…`.
523
532
  *
524
- * A lane of two or more tasks is the work agent the planner chose not to split,
525
- * so it dispatches on opus. A lane of one task dispatches on that task's clamped
526
- * model — haiku or sonnet, or opus for a tier-5 task or a solo promotion. Nothing
527
- * here rewrites `task.model`: a narrowed lane of one returns to the task's model.
533
+ * Display only. Everything that joins on a lane — its briefing file, worktree,
534
+ * trajectory line, record-batch fold — keys on `laneLabel`, because a key built
535
+ * from prose would change whenever a task was reworded. It is still a pure
536
+ * function of the lane, so a replay displays, and cache-hits, the same agent.
537
+ */
538
+ export function laneTitle(lane) {
539
+ const label = laneLabel(lane)
540
+ const first = lane[0] && typeof lane[0].description === 'string' ? lane[0].description : ''
541
+ const words = first.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
542
+ const gist = words.replace(/[\s.,;:—-]+$/, '')
543
+ if (!gist) return label
544
+ const full = `${label}${TITLE_SEPARATOR}${gist}`
545
+ return full.length <= TITLE_MAX ? full : `${full.slice(0, TITLE_MAX - 1).trimEnd()}…`
546
+ }
547
+
548
+ /**
549
+ * The model a lane dispatches on, derived from the lane's shape and hardest tier.
550
+ *
551
+ * A lane of one task dispatches on that task's clamped model — haiku or sonnet,
552
+ * or opus for a tier-5 task or a solo promotion. A lane of two or more tasks is
553
+ * the work agent the planner chose not to split: it dispatches on opus when its
554
+ * hardest tier is at or above `LANE_CAPS.opusMinTier`, or when every task already
555
+ * carries opus (solo promotion of the whole change). Otherwise it runs on
556
+ * sonnet — routine multi-task work no longer pays the flagship model by default.
557
+ * Nothing here rewrites `task.model`: a narrowed lane of one returns to the
558
+ * task's model.
528
559
  */
529
560
  export function laneModel(lane) {
530
- if (lane.length >= 2) return 'opus'
531
- const only = lane[0]
532
- return (only && only.model) || 'sonnet'
561
+ if (!Array.isArray(lane) || lane.length === 0) return 'sonnet'
562
+ if (lane.length === 1) {
563
+ const only = lane[0]
564
+ return (only && only.model) || 'sonnet'
565
+ }
566
+ // Solo promotions rewrite every task to opus after the clamp; an all-opus
567
+ // multi-task lane is that decision (or a pure tier-5 pack), not the floor.
568
+ if (lane.every(t => t && t.model === 'opus')) return 'opus'
569
+ return laneTier(lane) >= LANE_CAPS.opusMinTier ? 'opus' : 'sonnet'
533
570
  }
534
571
 
535
572
  /**
@@ -542,8 +579,8 @@ export function laneModel(lane) {
542
579
  * Returns `null` (inherit the session default — do not force) for tiers 3–4 by
543
580
  * policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
544
581
  * effort report distinguishes the two. Effort is derived from tier alone. The
545
- * model a lane dispatches on follows the lane's shape, so an opus lane of
546
- * tier-1 or tier-2 tasks still runs at `low`.
582
+ * model a lane dispatches on follows shape and the opus floor, so a multi-task
583
+ * lane of tier-1 or tier-2 tasks runs on sonnet at `low`.
547
584
  */
548
585
  export function laneEffort(lane) {
549
586
  const tier = laneTier(lane)
@@ -1412,7 +1449,7 @@ export function planWaves(input, opts = {}) {
1412
1449
  for (const chain of chains) {
1413
1450
  warnings.push(
1414
1451
  `tasks ${chain.ids.join(', ')} were ${chain.fusedBatches} consecutive single-lane batches in ` +
1415
- `${laneWhere(chain.group)}; fused into one chain lane run by one opus agent in that order ` +
1452
+ `${laneWhere(chain.group)}; fused into one chain lane run by one ${laneModel(chain.tasks)} agent in that order ` +
1416
1453
  `(tier-${chain.tier} lane cap ${chain.cap})`
1417
1454
  )
1418
1455
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,9 +18,11 @@
18
18
  "workflows",
19
19
  "skills",
20
20
  "hooks",
21
+ "types",
21
22
  "agents",
22
23
  "shared",
23
- ".claude-plugin",
24
+ ".claude-plugin/plugin.json",
25
+ ".claude-plugin/icon.png",
24
26
  "README.md",
25
27
  "LICENSE",
26
28
  "CHANGELOG.md"
@@ -5,8 +5,9 @@ Durable, machine-readable record of how a project's tests run. Owned by
5
5
  re-sniff `package.json` on every run.
6
6
 
7
7
  **Path:** `.claude/testing/profile.json`
8
- **Human mirror:** idempotent `## Testing` block in `CLAUDE.md` (or `AGENTS.md`
9
- on Cursor-only projects) between HTML comment markers (see below).
8
+ **Human mirror:** idempotent `## Testing` block in the project's instruction
9
+ file — `CLAUDE.md`, `.claude/CLAUDE.md`, or `AGENTS.md` on Cursor-only
10
+ projects — between HTML comment markers (see below).
10
11
 
11
12
  Learned gotchas discovered mid-fix go to
12
13
  `.claude/memory/failure-modes/<slug>.md` (max 3 per session) — **not** into
@@ -153,9 +154,13 @@ reuse as-is.
153
154
 
154
155
  ## Human mirror (`## Testing` block)
155
156
 
156
- Write (or replace) content **between** these markers inside `CLAUDE.md`
157
- (prefer) or `AGENTS.md` (if that is the project's agent doc and `CLAUDE.md`
158
- is absent):
157
+ Write (or replace) content **between** these markers inside the first of
158
+ these that exists: `CLAUDE.md` at the root, then `.claude/CLAUDE.md`, then
159
+ `AGENTS.md` (if that is the project's agent doc). Create a root `CLAUDE.md`
160
+ only when none of the three exists. A repository that is also a Claude Code
161
+ plugin keeps its instructions in `.claude/CLAUDE.md`, because
162
+ `claude plugin validate --strict` rejects a `CLAUDE.md` at the plugin root; a
163
+ mirror that recreated one there would turn that repository's CI red.
159
164
 
160
165
  ```markdown
161
166
  ## Testing
@@ -2,8 +2,8 @@
2
2
  name: evals
3
3
  description: Author an Interlock eval case from an observed model-behaviour failure, and read a completed eval results run. Use when you have a reproduced failure — an archived proposal, a changelog entry, a run artifact, or a transcript — worth regression-testing against a real model, or when an eval run has finished and you need to know whether it is a regression, run-to-run variance, or no signal. Authoring needs cited evidence; the verdict comes from `interlock evals triage`, never from your own reading.
4
4
  license: MIT
5
- compatibility: Requires the early-access eval harness (`claude plugin eval`, enabled with CLAUDE_CODE_WALNUT_SPIRE=1) to RUN a suite. Authoring a case and triaging an existing results file need only Node.js >= 18 and the bundled interlock CLI.
6
- allowed-tools: Bash(interlock *) Bash(claude plugin eval *) Read Grep Glob Write
5
+ compatibility: Requires the early-access harness (claude plugin eval, enabled with CLAUDE_CODE_WALNUT_SPIRE=1) to RUN a suite. Authoring a case and triaging an existing results file need only Node.js >= 18 and the bundled interlock CLI.
6
+ allowed-tools: Bash(interlock *) Bash(claude plugin eval:*) Read Grep Glob Write
7
7
  metadata:
8
8
  type: authoring
9
9
  outputs:
@@ -148,7 +148,7 @@ test until it passes" failure the suite exists to prevent, one layer up.
148
148
 
149
149
  ## 5. When the harness is gated
150
150
 
151
- `claude plugin eval` prints `` `plugin eval` is currently in early access `` and
151
+ The host command claude plugin eval prints that it is currently in early access and
152
152
  does nothing until `CLAUDE_CODE_WALNUT_SPIRE=1` is set in the environment — never
153
153
  in committed `.claude/settings.json`, which would produce a suite that looks
154
154
  configured and does not run.
@@ -4,7 +4,7 @@ description: Discover and persist how a project's tests run, then run the suite
4
4
  license: MIT
5
5
  compatibility: Requires the project's own test tooling. Node.js >= 18 for the bundled interlock CLI.
6
6
  argument-hint: "[--e2e] [--dry-run] [--baseline <ref>] [--from-log <path>] [--reconfigure]"
7
- allowed-tools: Bash Read Write Edit Glob Grep
7
+ allowed-tools: Read Write Edit Glob Grep
8
8
  metadata:
9
9
  type: execution
10
10
  outputs:
@@ -34,13 +34,13 @@ Resolve `typecheck.command` and `lint.command` on the same pass, the way `covera
34
34
 
35
35
  This is the only skill permitted to interview the user about tests. Every later run is zero-question.
36
36
 
37
- Persist the profile and mirror it into the `## Testing` block in `CLAUDE.md` (or `AGENTS.md`), between the managed markers, idempotently.
37
+ Persist the profile and mirror it into the `## Testing` block of the first instruction file that exists — `CLAUDE.md`, then `.claude/CLAUDE.md`, then `AGENTS.md` — between the managed markers, idempotently.
38
38
 
39
39
  ---
40
40
 
41
41
  ## 2. Run and baseline
42
42
 
43
- Run the unit command. Record `total`, `passed`, `failed`, `skipped` — this is the baseline that step 6 asserts against.
43
+ Run the unit command under the permissions already granted to this project. This skill does not pre-approve a shell. Record `total`, `passed`, `failed`, `skipped` — this is the baseline that step 6 asserts against.
44
44
 
45
45
  Split failures into:
46
46
 
@@ -128,12 +128,7 @@ Parse `applyRequires` (artifacts needed before implementation), `artifacts` (eac
128
128
  Then loop, in dependency order, over every artifact that is `ready`:
129
129
 
130
130
  ```bash
131
- openspec instructions <artifact-id> --change "<name>" --json | python3 -c '
132
- import json,sys
133
- d=json.load(sys.stdin)
134
- keep=("instruction","template","resolvedOutputPath","context","rules")
135
- print(json.dumps({k:d[k] for k in keep if d.get(k)}, separators=(",",":")))
136
- '
131
+ openspec instructions <artifact-id> --change "<name>" --json | node "${CLAUDE_PLUGIN_ROOT}/skills/spec/scripts/compact-instructions.mjs"
137
132
  ```
138
133
 
139
134
  Filter each ready artifact independently; do not dump the full instructions response or prefetch every artifact. The compact response carries `instruction` (schema guidance), `template` (the structure to fill), `resolvedOutputPath`, and non-empty `context` / `rules`.
@@ -0,0 +1,30 @@
1
+ // Compact an `openspec instructions --json` payload to the fields an author fills.
2
+ // Shipped in the plugin so the spec skill does not inline another interpreter.
3
+
4
+ import { readFileSync } from 'node:fs'
5
+
6
+ const keep = ['instruction', 'template', 'resolvedOutputPath', 'context', 'rules']
7
+
8
+ let raw
9
+ try {
10
+ raw = readFileSync(0, 'utf8')
11
+ } catch (err) {
12
+ process.stderr.write(`compact-instructions: could not read stdin: ${(err && err.message) || err}\n`)
13
+ process.exit(1)
14
+ }
15
+
16
+ let data
17
+ try {
18
+ data = JSON.parse(raw)
19
+ } catch (err) {
20
+ process.stderr.write(`compact-instructions: stdin is not JSON: ${(err && err.message) || err}\n`)
21
+ process.exit(1)
22
+ }
23
+
24
+ const out = {}
25
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
26
+ for (const key of keep) {
27
+ if (data[key]) out[key] = data[key]
28
+ }
29
+ }
30
+ process.stdout.write(JSON.stringify(out))
@@ -0,0 +1,28 @@
1
+ // Interlock's type contract: the session state its hooks module keeps
2
+ // (guard-ship-relaunch-in-process design D2). `.claude-plugin/plugin.json`
3
+ // names this file as `types`; `claude plugin validate` holds every `$.state`
4
+ // key `hooks/mod.mjs` reads or writes to the keys declared here.
5
+ //
6
+ // One key, the launch guard's record, in the shape `lib/launch-rule.mjs`
7
+ // reads and the settings form's file ledger holds: times are ISO strings.
8
+
9
+ /** One ship launch the runtime accepted, as its result named it. */
10
+ export type Launch = {
11
+ at: string
12
+ runId: string | null
13
+ workflowName: string | null
14
+ scriptPath: string | null
15
+ }
16
+
17
+ /** The session's launches and the time of its last human prompt. */
18
+ export type LaunchRecord = {
19
+ schema: 'interlock.launch-ledger/1'
20
+ launches: Launch[]
21
+ lastHumanPromptAt: string | null
22
+ }
23
+
24
+ declare module 'claude-code' {
25
+ interface PluginState {
26
+ interlock: { ledger: LaunchRecord }
27
+ }
28
+ }