@renzrollon/interlock 1.1.0 → 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 +142 -0
- package/bin/interlock +413 -65
- 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 +153 -45
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +83 -13
- 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 +1193 -85
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +285 -83
- 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
|
/**
|