@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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +94 -1
  3. package/CHANGELOG.md +142 -0
  4. package/bin/interlock +413 -65
  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 +83 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +488 -28
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/graph/cli.mjs +28 -5
  17. package/lib/graph/retrieve/context.mjs +8 -3
  18. package/lib/host/acp.mjs +77 -14
  19. package/lib/host/claude-cli.mjs +391 -21
  20. package/lib/host/claude-env.mjs +179 -0
  21. package/lib/host/codex.mjs +9 -1
  22. package/lib/host/effort.mjs +163 -0
  23. package/lib/host/model-map.mjs +43 -0
  24. package/lib/host/qwen.mjs +9 -1
  25. package/lib/host/registry.mjs +44 -10
  26. package/lib/host.mjs +46 -5
  27. package/lib/interrupted.mjs +167 -0
  28. package/lib/launch-ledger.mjs +198 -0
  29. package/lib/launch-rule.mjs +174 -0
  30. package/lib/limits.mjs +153 -45
  31. package/lib/meter-quiet.mjs +45 -0
  32. package/lib/plan-fingerprint.mjs +83 -13
  33. package/lib/prompts/implementer.mjs +1 -1
  34. package/lib/prompts/verify.mjs +24 -1
  35. package/lib/receipt.mjs +59 -8
  36. package/lib/report-html.mjs +89 -6
  37. package/lib/report.mjs +313 -12
  38. package/lib/resume-card.mjs +90 -2
  39. package/lib/run-log.mjs +192 -5
  40. package/lib/run.mjs +1193 -85
  41. package/lib/state-home.mjs +298 -0
  42. package/lib/verify.mjs +37 -0
  43. package/lib/waves.mjs +285 -83
  44. package/package.json +2 -1
  45. package/shared/TEST-PROFILE.md +10 -5
  46. package/skills/fix-tests/SKILL.md +1 -1
  47. package/types/index.d.ts +28 -0
  48. 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
  /**