@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
@@ -29,6 +29,27 @@ function retryPreamble(fixAttempt, fixAttemptsRemaining, errors) {
29
29
  )
30
30
  }
31
31
 
32
+ // Shell-quoted, because a planned command is one argument to the wrapper and
33
+ // routinely holds spaces, `&&` or a pipe. The same rule `workflows/ship.js`
34
+ // quotes a relay's argv with.
35
+ const quote = a => (/[^\w@%+=:,./-]/.test(String(a)) ? `'${String(a).replace(/'/g, `'\\''`)}'` : String(a))
36
+
37
+ /**
38
+ * One planned step as the line the agent runs: the command, verbatim, inside
39
+ * `interlock verify exec`, which passes its output and exit code through and
40
+ * records how long it took. The inter-wave budget is charged from that record —
41
+ * the CLI's own clock around the command — rather than from the relay and agent
42
+ * time between the step's dispatch and its judgement.
43
+ *
44
+ * The line is the shell command and nothing else. The briefing says to run each
45
+ * line exactly as written, so a `kind:` label in front of it would be run too —
46
+ * and fail. The kind is already on the line, as `--kind`.
47
+ */
48
+ function execLine(s) {
49
+ const cwd = typeof s.cwd === 'string' && s.cwd.trim() && s.cwd.trim() !== '.' ? ` --cwd ${quote(s.cwd.trim())}` : ''
50
+ return ` interlock verify exec --kind ${quote(s.kind)}${cwd} --command ${quote(s.command)}`
51
+ }
52
+
32
53
  /**
33
54
  * @param {object} input
34
55
  * @param {string} input.change
@@ -51,7 +72,7 @@ export function assembleVerifyPrompt({
51
72
  fixAttemptsRemaining,
52
73
  errors
53
74
  }) {
54
- const planned = (Array.isArray(steps) ? steps : []).map(s => ` ${s.kind}: ${s.command}`).join('\n')
75
+ const planned = (Array.isArray(steps) ? steps : []).map(execLine).join('\n')
55
76
  // A run id the CLI already knows is stated; otherwise the agent is told where
56
77
  // to read it, rather than being left to invent one and spill into nowhere.
57
78
  const spillRunId = runId
@@ -66,6 +87,8 @@ export function assembleVerifyPrompt({
66
87
  `Run these verification steps for change "${change}" and report what happened.\n\n` +
67
88
  retry +
68
89
  planned +
90
+ `\n\nRun each line above exactly as written, as one shell command: \`interlock verify exec\` runs the command unchanged — its ` +
91
+ `output and exit code are the command's own — and records how long the check took.` +
69
92
  // The repair clause is the one sentence a retry must not inherit: "do not
70
93
  // repair on a first failure" is exactly wrong on the second attempt, which
71
94
  // exists to repair.
package/lib/receipt.mjs CHANGED
@@ -103,8 +103,14 @@ export function tailFromManifest(manifest) {
103
103
  * degradations?: string[],
104
104
  * planFingerprint?: string,
105
105
  * leftoverTaskIds?: string[],
106
- * host?: {id?: string, billing?: string, hooks?: boolean, usage?: boolean}
107
- * }} input
106
+ * host?: {id?: string, billing?: string, hooks?: boolean, usage?: boolean,
107
+ * cacheAccounting?: boolean|'hook'},
108
+ * lanesStoppedByHost?: number, schemaResultsMissing?: number,
109
+ * toolsDeniedInLanes?: number, modelSubstitutions?: number,
110
+ * permissionPrompts?: number, autoModeDenials?: number,
111
+ * surface?: string, stateHome?: string
112
+ * }} input the six counts are passed only when the run observed agents at all
113
+ * (a host record or a sidecar); leaving one out records it as unobserved
108
114
  */
109
115
  export function buildReceipt(input) {
110
116
  const source = input || {}
@@ -143,6 +149,12 @@ export function buildReceipt(input) {
143
149
  // outcome corpus's comparisons are only meaningful if the run said so itself.
144
150
  const host = source.host && typeof source.host === 'object' ? source.host : null
145
151
 
152
+ // A count the run measured, zero included, or `undefined`. Never a default:
153
+ // a run that saw no host record and no sidecar observed no agent, and a zero
154
+ // here would file it beside a run that watched every lane and found nothing.
155
+ const measured = value => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
156
+ const named = value => (typeof value === 'string' && value.length > 0 ? value : undefined)
157
+
146
158
  return {
147
159
  type: 'run-receipt',
148
160
  change: source.change,
@@ -155,7 +167,12 @@ export function buildReceipt(input) {
155
167
  // Recorded per run so a reader can PARTITION the corpus rather than
156
168
  // average across it: cache figures exist on some hosts and not others,
157
169
  // and that asymmetry is stated rather than engineered away.
158
- cacheAccounting: host.cacheAccounting
170
+ cacheAccounting: host.cacheAccounting,
171
+ // The effort capability the host declared (flag, negotiated or
172
+ // unsupported), so a reader can tell a run whose effort was applied
173
+ // from one where it could not be. `null` when the manifest predates
174
+ // the capability, never the assumed `flag`.
175
+ effort: host.effort ?? null
159
176
  }
160
177
  : undefined,
161
178
  waves: (Array.isArray(summary.waves) ? summary.waves : []).map(wave => ({
@@ -230,7 +247,21 @@ export function buildReceipt(input) {
230
247
  (paths
231
248
  ? 'the executed plan could not be read back at close'
232
249
  : 'the run did not reach its close, so the executed plan was never read'),
233
- degradations: source.degradations
250
+ degradations: source.degradations,
251
+ // What the run observed about its agents (design D8), copied by name. The
252
+ // close counts them off the host records and the sidecar; this only
253
+ // carries what it was handed, so the banners and the counts cannot come
254
+ // apart through a second derivation here.
255
+ lanesStoppedByHost: measured(source.lanesStoppedByHost),
256
+ schemaResultsMissing: measured(source.schemaResultsMissing),
257
+ toolsDeniedInLanes: measured(source.toolsDeniedInLanes),
258
+ modelSubstitutions: measured(source.modelSubstitutions),
259
+ permissionPrompts: measured(source.permissionPrompts),
260
+ autoModeDenials: measured(source.autoModeDenials),
261
+ // Where the run ran and where its corpora went (design D14), as `run start`
262
+ // recorded them — never re-resolved at close, so one run names one home.
263
+ surface: named(source.surface),
264
+ stateHome: named(source.stateHome)
234
265
  }
235
266
  }
236
267
 
@@ -278,6 +309,17 @@ export function degradationLines({ banners = [], closing = null, hostBanners = [
278
309
  return [...new Set(out)]
279
310
  }
280
311
 
312
+ // Whether two absolute paths name the same directory for the purpose of the
313
+ // `state home:` row. Import-free like the rest of this module, so it compares
314
+ // text: trailing separators are ignored and nothing touches the filesystem. A
315
+ // home recorded through a symlink and a cwd reached through its real path read
316
+ // as different, which prints a row naming a real path — never a lost one.
317
+ function samePath(a, b) {
318
+ if (typeof a !== 'string' || typeof b !== 'string') return false
319
+ const trim = p => (p.length > 1 ? p.replace(/[\\/]+$/, '') || p.slice(0, 1) : p)
320
+ return trim(a) === trim(b)
321
+ }
322
+
281
323
  /**
282
324
  * The summary's first line, exactly as `formatRunSummary` prints it.
283
325
  *
@@ -302,10 +344,10 @@ export function summaryHeadline({ change, halted, leftoverTaskIds } = {}) {
302
344
  * Silence is the failure mode it exists to remove: a summary with no banner
303
345
  * section is indistinguishable from a run that degraded and hid it.
304
346
  *
305
- * `runId`, `root`, `projectSlug`, `push`, `unarchived` and `resumeCard` all
306
- * default so this stays callable exactly as it was before design D7/D8/D9 and
307
- * the halt resume card added them — the receipt module stays import-free either
308
- * way.
347
+ * `runId`, `root`, `projectSlug`, `stateHome`, `push`, `unarchived` and
348
+ * `resumeCard` all default so this stays callable exactly as it was before
349
+ * design D7/D8/D9, the halt resume card and the state home added them — the
350
+ * receipt module stays import-free either way.
309
351
  */
310
352
  export function formatRunSummary({
311
353
  change,
@@ -316,6 +358,7 @@ export function formatRunSummary({
316
358
  runId = undefined,
317
359
  root = undefined,
318
360
  projectSlug = undefined,
361
+ stateHome = undefined,
319
362
  push = null,
320
363
  unarchived = null,
321
364
  resumeCard = null
@@ -390,6 +433,14 @@ export function formatRunSummary({
390
433
  )
391
434
  if (projectSlug !== undefined && projectSlug !== null) lines.push(` project: ${projectSlug}`)
392
435
  if (root !== undefined && root !== null) lines.push(` cwd: ${root}`)
436
+ // Where the run's trajectory, outcome line, metrics and resume card went
437
+ // (design D14), directly under the directory the close ran in, and only when
438
+ // the two differ: a main-checkout run's summary stays byte-identical, and a
439
+ // linked-worktree run tells its reader that its records are not in the tree
440
+ // they are looking at.
441
+ if (typeof stateHome === 'string' && stateHome.length > 0 && !samePath(stateHome, root)) {
442
+ lines.push(` state home: ${stateHome}`)
443
+ }
393
444
 
394
445
  // The push outcome (design D5): a row only when a push was actually
395
446
  // attempted — an unconfigured relay is an optional feature left off, not a
@@ -28,10 +28,11 @@
28
28
  // through the `html` tagged template below, which escapes unless a fragment is
29
29
  // explicitly marked `raw`. There is no second interpolation path.
30
30
 
31
- // The one thing this renderer imports: the wording of an absence it shares with
32
- // the text surface. Two surfaces describing the same absence in different words
33
- // is how a reader comes to believe they are looking at two different facts.
34
- import { NO_EVAL_RESULT } from './report.mjs'
31
+ // The only things this renderer imports: wording it shares with the text
32
+ // surface — an absence, and the surface partition's bucket labels. Two surfaces
33
+ // describing the same fact in different words is how a reader comes to believe
34
+ // they are looking at two different facts.
35
+ import { NO_EVAL_RESULT, SURFACE_BUCKETS } from './report.mjs'
35
36
 
36
37
  const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
37
38
 
@@ -195,10 +196,79 @@ function evalHistoryCorpus(e) {
195
196
  )
196
197
  }
197
198
 
199
+ /**
200
+ * Trajectories in the home repository's other worktrees: counted, never read.
201
+ *
202
+ * A scan that could not be made renders as a word at full weight with its
203
+ * reason, never as a zero — a count nobody took is not a count of nothing.
204
+ */
205
+ function unreadWorktrees(u) {
206
+ if (!u) return ''
207
+ const counted = u.runs !== null && u.runs !== undefined && u.worktrees !== null && u.worktrees !== undefined
208
+ const count = counted
209
+ ? raw(html`<span class="num">${u.runs}</span> file(s) across <span class="num">${u.worktrees}</span> worktree(s), not read`)
210
+ : raw('<span class="word">NOT COUNTED</span>')
211
+
212
+ const notes = []
213
+ if (!counted) notes.push(`could not be counted: ${u.reason || 'no reason was given'}`)
214
+
215
+ return (
216
+ html`<div class="corpus"><div class="corpus-head"><h3>Trajectories in other linked worktrees</h3>
217
+ <div class="corpus-count">${count}</div></div>
218
+ <p class="reason">Counted by file name from the repository's worktree list. None was opened, and no
219
+ figure on this page includes them.</p>` +
220
+ (notes.length ? html`<ul class="named">${raw(notes.map(n => html`<li>${n}</li>`).join(''))}</ul>` : '') +
221
+ (u.unscannable && u.unscannable.length
222
+ ? html`<div class="named-head">Worktrees not counted, named rather than skipped</div><ul class="named">` +
223
+ raw(u.unscannable.map(w => html`<li><span class="file">${w.worktree}</span> — ${w.reason}</li>`).join('')) +
224
+ '</ul>'
225
+ : '') +
226
+ '</div>'
227
+ )
228
+ }
229
+
230
+ /** The partition of the scanned runs by the surface each `run-start` recorded. Every bucket, zeros included. */
231
+ function surfacePartition(surfaces, scanned) {
232
+ if (!surfaces) return ''
233
+ return (
234
+ html`<div class="named-head">Surface each run-start recorded — over ${scanned} scanned</div>` +
235
+ html`<dl class="facets surfaces">${raw(
236
+ SURFACE_BUCKETS.map(b => html`<div><dt>${b.label}</dt><dd class="num">${surfaces[b.key] ?? 0}</dd></div>`).join('')
237
+ )}</dl>`
238
+ )
239
+ }
240
+
241
+ /**
242
+ * Said only when the home is not simply the one git named: an unresolved home,
243
+ * or a surface the resolver could not establish. Never left to the header row.
244
+ */
245
+ function stateHomeNotice(h) {
246
+ if (!h) return ''
247
+ if (h.resolvedFrom === 'fallback') {
248
+ return html`<p class="reason">STATE HOME UNRESOLVED: ${h.reason || 'no reason was given'} — every corpus was read from the root.</p>`
249
+ }
250
+ if (h.reason) return html`<p class="reason">Surface unknown: ${h.reason}</p>`
251
+ return ''
252
+ }
253
+
254
+ const RESOLVED_FROM_WORDS = Object.freeze({
255
+ explicit: 'explicit — --state-home or INTERLOCK_STATE_HOME',
256
+ git: 'git',
257
+ lane: 'lane worktree path',
258
+ fallback: 'unresolved — the root'
259
+ })
260
+
198
261
  function coverageSection(coverage) {
199
262
  const { trajectories: t, outcomes: o, metrics: m } = coverage
200
263
 
201
264
  const trajectoryNotes = []
265
+ if (t.interruptedNotesWithoutTrajectory) {
266
+ const n = t.interruptedNotesWithoutTrajectory
267
+ trajectoryNotes.push(`${n} interrupted-run note${n === 1 ? '' : 's'} name${n === 1 ? 's' : ''} no scanned trajectory`)
268
+ }
269
+ for (const u of t.interruptedNotesUnreadable || []) {
270
+ trajectoryNotes.push(`interrupted-run note unreadable: ${u.file} — ${u.reason}`)
271
+ }
202
272
  if (t.truncated) trajectoryNotes.push(`scan capped — ${t.notScanned} not read`)
203
273
  if (t.filteredOut) trajectoryNotes.push(`${t.filteredOut} outside the filter`)
204
274
  if (t.skippedLines) trajectoryNotes.push(`${t.skippedLines} torn line(s) skipped`)
@@ -215,9 +285,15 @@ function coverageSection(coverage) {
215
285
  ${proportionMark(t.scanned, t.total)}
216
286
  <dl class="facets">
217
287
  <div><dt>with run-start</dt><dd class="num">${t.withRunStart}</dd></div>
218
- <div><dt>with terminal</dt><dd class="num">${t.withTerminal}</dd><dd class="aside">run-complete or run-halt</dd></div>
219
- <div><dt>with receipt</dt><dd class="num">${t.withReceipt}</dd><dd class="aside">every receipt-derived indicator rests on this</dd></div>
288
+ <div><dt>with terminal</dt><dd class="num">${t.withTerminal}</dd><dd class="aside">run-complete or run-halt</dd></div>` +
289
+ (t.withoutTerminal
290
+ ? html`<div><dt>without terminal</dt><dd class="num">${t.withoutTerminal.total}</dd><dd class="aside">of ${t.scanned} scanned</dd></div>
291
+ <div><dt>interrupted</dt><dd class="num">${t.withoutTerminal.interrupted}</dd><dd class="aside">a SessionEnd note names the run</dd></div>
292
+ <div><dt>unexplained</dt><dd class="num">${t.withoutTerminal.unexplained}</dd><dd class="aside">no note names the run</dd></div>`
293
+ : '') +
294
+ html`<div><dt>with receipt</dt><dd class="num">${t.withReceipt}</dd><dd class="aside">every receipt-derived indicator rests on this</dd></div>
220
295
  </dl>` +
296
+ surfacePartition(coverage.surfaces, t.scanned) +
221
297
  (trajectoryNotes.length
222
298
  ? html`<ul class="named">${raw(trajectoryNotes.map(n => html`<li>${n}</li>`).join(''))}</ul>`
223
299
  : '') +
@@ -227,6 +303,8 @@ function coverageSection(coverage) {
227
303
  '</ul>'
228
304
  : '') +
229
305
  '</div>' +
306
+ // --- trajectories the home's repository holds elsewhere, counted not read
307
+ unreadWorktrees(coverage.unreadInLinkedWorktrees) +
230
308
  // --- outcomes
231
309
  html`<div class="corpus"><div class="corpus-head"><h3>Outcome records</h3>
232
310
  <div class="corpus-count">${o.exists ? raw(html`<span class="num">${o.records}</span> record(s)`) : raw('<span class="word">ABSENT</span>')}</div></div>` +
@@ -576,6 +654,7 @@ export function renderReportHtml(report) {
576
654
  const filterBits = []
577
655
  if (filter.since) filterBits.push(`since ${filter.since}`)
578
656
  if (filter.change) filterBits.push(`change ${filter.change}`)
657
+ const home = r.stateHome && typeof r.stateHome === 'object' ? r.stateHome : null
579
658
 
580
659
  const body = r.coverage
581
660
  ? coverageSection(r.coverage) + (r.indicators ? indicatorsSection(r.indicators) : '')
@@ -596,6 +675,9 @@ export function renderReportHtml(report) {
596
675
  <div><div class="kicker">Interlock · instrument readout</div><h1>Interlock Report</h1></div>
597
676
  <div class="meta">
598
677
  <span class="k">ROOT</span><span>${r.root || '.'}</span>
678
+ <span class="k">STATE HOME</span><span>${home && home.home ? home.home : 'unstated'}</span>
679
+ <span class="k">SURFACE</span><span>${home && home.surface ? home.surface : 'unstated'}</span>
680
+ <span class="k">HOME FROM</span><span>${home ? RESOLVED_FROM_WORDS[home.resolvedFrom] || home.resolvedFrom || 'unstated' : 'unstated'}</span>
599
681
  <span class="k">SCHEMA</span><span>${r.schema || 'unknown'}</span>
600
682
  <span class="k">SCAN CAP</span><span>${filter.maxRuns === undefined ? 'unstated' : filter.maxRuns} run(s)</span>
601
683
  <span class="k">FILTER</span><span>${filterBits.length ? filterBits.join(', ') : 'none'}</span>
@@ -613,6 +695,7 @@ export function renderReportHtml(report) {
613
695
  <div><span></span><span>Both are findings, recorded at the same standing as a measured value.</span></div>
614
696
  </div>
615
697
  </div>
698
+ ${raw(stateHomeNotice(home))}
616
699
  ${raw(body)}
617
700
  <div class="foot"><span>${r.note || 'reports only'}</span><span>Exit status is always 0.</span></div>
618
701
  </main></body></html>