@renzrollon/interlock 1.1.1 → 1.2.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 +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +135 -1
- package/bin/interlock +409 -49
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
|
@@ -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
|
-
//
|
|
16
|
-
//
|
|
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
|
|
79
|
-
//
|
|
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
|
|
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
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
546
|
-
* tier-1 or tier-2 tasks
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"workflows",
|
|
19
19
|
"skills",
|
|
20
20
|
"hooks",
|
|
21
|
+
"types",
|
|
21
22
|
"agents",
|
|
22
23
|
"shared",
|
|
23
24
|
".claude-plugin",
|
package/shared/TEST-PROFILE.md
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
is
|
|
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
|
|
@@ -34,7 +34,7 @@ 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
|
|
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
|
|
package/types/index.d.ts
ADDED
|
@@ -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
|
+
}
|