@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
package/workflows/ship.js CHANGED
@@ -148,6 +148,10 @@ const pingExtra = { type: PING_AGENT, tools: PING_TOOLS }
148
148
 
149
149
  const WORK = '.claude/ship'
150
150
  const RESULTS = `${WORK}/results.json`
151
+ // Where the CLI keeps the exact bytes it last printed (LAST_STEP_PATH in
152
+ // lib/run.mjs). Named here only so a halt can point a reader at it; this script
153
+ // cannot read a file.
154
+ const LAST_STEP = `${WORK}/last-step.json`
151
155
 
152
156
  // The runtime caps a run at 1000 agents. This is that ceiling, named so nobody
153
157
  // mistakes it for policy: it is NOT a bound on the loop, and it is deliberately
@@ -262,6 +266,11 @@ const {
262
266
 
263
267
  let steps = 0
264
268
  let resolvedChange = changeArg || '(unresolved)'
269
+ // Why the last relay produced no step, when it did not. A relay's copy that
270
+ // fails to parse used to vanish into a generic halt, and the only way to learn
271
+ // it had been a 23 KB step retyped with two brackets missing was to dig the
272
+ // relay's transcript out by hand.
273
+ let relayFailure = ''
265
274
 
266
275
  /** A control-plane ping: mechanical, cheap, and never asked to decide anything. */
267
276
  const ping = (name, prompt, schema) => agent(prompt, { label: name, schema, ...pingExtra })
@@ -275,7 +284,8 @@ const ping = (name, prompt, schema) => agent(prompt, { label: name, schema, ...p
275
284
  */
276
285
  async function spawnOne(s) {
277
286
  const result = await agent(BOOTSTRAP(s.label, s.promptPath, s.promptSha256), {
278
- label: s.label,
287
+ // Shown as its title; every record below still names it by its label.
288
+ label: s.title || s.label,
279
289
  type: s.type,
280
290
  tools: s.tools,
281
291
  ...(s.model ? { model: s.model } : {}),
@@ -287,13 +297,25 @@ async function spawnOne(s) {
287
297
  properties: { ...(s.schema.properties || {}), briefing: { type: 'string' } }
288
298
  }
289
299
  })
300
+ // No result at all is what `agent()` resolves to when the runtime stopped the
301
+ // agent, the API failed unrecoverably, or a usage limit ended it outside an
302
+ // interactive session. The host was the cause, so it is named as the host —
303
+ // reporting it as an unacknowledged briefing would send the reader to the
304
+ // briefing. Still a null result, which the recorder treats as a failed task.
305
+ if (result === null || result === undefined) {
306
+ banners.push(
307
+ `AGENT RETURNED NO RESULT: ${s.label} — the runtime stopped it, the API failed, or a usage limit ` +
308
+ `ended it; the step is recorded as failed`
309
+ )
310
+ return null
311
+ }
290
312
  // Fail closed on an unacknowledged briefing. An agent that did not read its
291
313
  // instructions did not do this task, whatever it reports about it — and a
292
314
  // silently under-instructed worker is exactly the failure prompt integrity
293
315
  // exists to catch. So it is a null result with a named reason, which the
294
316
  // recorder already treats as a failed task, rather than a warning beside a
295
317
  // result that gets counted.
296
- const ack = result && typeof result.briefing === 'string' ? result.briefing.trim() : ''
318
+ const ack = typeof result.briefing === 'string' ? result.briefing.trim() : ''
297
319
  if (ack !== s.promptSha256) {
298
320
  banners.push(
299
321
  `BRIEFING NOT ACKNOWLEDGED: ${s.label} reported ${ack || '(nothing)'} for a briefing whose ` +
@@ -333,8 +355,10 @@ async function cli(argv, results, extraWrites = []) {
333
355
  const quote = a => (/[^\w@%+=:,./-]/.test(String(a)) ? `'${String(a).replace(/'/g, `'\\''`)}'` : String(a))
334
356
  const command =
335
357
  `interlock ${argv.map(quote).join(' ')}${results === undefined ? '' : ` --results ${RESULTS}`} --json`
358
+ // Shown with the subcommand it relays (`cli-12 · run next`), so a stalled
359
+ // relay can be read off the progress view; `label` stays what errors name.
336
360
  const relayed = await ping(
337
- label,
361
+ `${label} · ${argv.slice(0, 2).join(' ')}`,
338
362
  `You are a mechanical relay for one command. Do not interpret it, and do not do the work it ` +
339
363
  `describes.\n\n` +
340
364
  (writeLines ? `${writeLines}\n\n` : '') +
@@ -348,14 +372,28 @@ async function cli(argv, results, extraWrites = []) {
348
372
  properties: { cliStdout: { type: 'string' }, action: { type: 'string' } }
349
373
  }
350
374
  )
351
- if (relayed && typeof relayed.cliStdout === 'string') {
375
+ let problem
376
+ if (!relayed) {
377
+ problem = 'the relay returned nothing'
378
+ } else if (typeof relayed.cliStdout !== 'string') {
379
+ problem = 'the relay returned no cliStdout'
380
+ } else {
352
381
  try {
353
382
  const parsed = JSON.parse(relayed.cliStdout)
354
383
  if (parsed && typeof parsed.action === 'string') return parsed
355
- } catch {
384
+ problem = `${relayed.cliStdout.length} relayed chars parsed but carry no "action"`
385
+ } catch (err) {
356
386
  // stdout was not JSON. Nothing is inferred from that: the caller stops.
387
+ // What is kept is WHY — V8's message carries the failing position when it
388
+ // has one — so the halt says what broke instead of that something did.
389
+ problem =
390
+ `${relayed.cliStdout.length} relayed chars do not parse as JSON ` +
391
+ `(${(err && err.message) || String(err)})`
357
392
  }
358
393
  }
394
+ relayFailure =
395
+ `${label} (interlock ${argv.slice(0, 2).join(' ')}): ${problem} — ` +
396
+ `the CLI's own bytes are in ${LAST_STEP}`
359
397
  return null
360
398
  }
361
399
 
@@ -376,67 +414,44 @@ if (tddModeConflict) {
376
414
  )
377
415
  }
378
416
 
379
- // The environment probe. Everything it asks about is a property of THIS HOST —
380
- // whether haiku is reachable, whether the graph was built, whether a test
381
- // profile exists, whether model routing is overridden — so it is asked here and
382
- // carried to the close as a host banner, rather than being something the CLI
383
- // could have found out for itself.
417
+ // The environment probe. It asks only what is a property of THIS HOST's shell —
418
+ // whether the effort level is overridden — and creates the working directory.
419
+ // Whether the graph was built and whether a test profile exists are NOT asked:
420
+ // `run start` locates both itself, root first and then the state home, and
421
+ // raises their banners (ship/run-program). Two probes of one file in two places
422
+ // is how a driver and the CLI would disagree about whether it exists. The
423
+ // model-routing variables are not asked either: `run start` reads them from its
424
+ // own environment, together with the host's version, and decides the banners
425
+ // and the ping model itself.
384
426
  const probed = await ping(
385
427
  'validate',
386
428
  `Report this environment. Change nothing, and do not start any work.\n\n` +
387
- `Run: test -f .claude/graph/graph.json && echo yes || echo no\n` +
388
- `Report yes as hasGraph:true, no as hasGraph:false with a one-line graphReason.\n\n` +
389
- `Run: test -f .claude/testing/profile.json && echo yes || echo no\n` +
390
- `Report it as hasTestProfile.\n\n` +
391
- `Run: printenv CLAUDE_CODE_SUBAGENT_MODEL\n` +
392
- `If it prints a value, report it as subagentModelOverride. If it is unset the command exits ` +
429
+ `Run: printenv CLAUDE_CODE_EFFORT_LEVEL\n` +
430
+ `If it prints a value, report it as effortLevelOverride. If it is unset the command exits ` +
393
431
  `non-zero and prints nothing — that is the normal case, so leave the field out rather than ` +
394
432
  `reporting an empty string.\n\n` +
395
- `Then run: printenv CLAUDE_CODE_USE_BEDROCK; printenv AWS_BEDROCK\n` +
396
- `If subagentModelOverride is set, leave haikuAvailable out — routing is already overridden.\n` +
397
- `If either Bedrock variable prints a non-empty value other than 0 or false, report ` +
398
- `haikuAvailable:false. Bedrock accounts often cannot reach haiku and a failed ping halts the loop.\n` +
399
- `Otherwise report haikuAvailable:true. When unsure, haikuAvailable:false so pings inherit ` +
400
- `the session model rather than hard-failing.\n\n` +
401
433
  `Then create the working directory ${WORK}/.`,
402
434
  {
403
435
  type: 'object',
404
436
  properties: {
405
- hasGraph: { type: 'boolean' },
406
- graphReason: { type: 'string' },
407
- hasTestProfile: { type: 'boolean' },
408
- subagentModelOverride: { type: 'string' },
409
- haikuAvailable: { type: 'boolean' }
437
+ effortLevelOverride: { type: 'string' }
410
438
  }
411
439
  }
412
440
  )
413
441
 
414
- if (probed && probed.hasGraph === false) {
415
- banners.push(
416
- `GRAPH UNAVAILABLE: ${probed.graphReason || 'never built'} — implementer and reviewer agents fall back to grep and will be slower`
417
- )
418
- }
419
- if (probed && probed.hasTestProfile === false) {
420
- banners.push('NO TEST PROFILE: run /interlock:fix-tests --reconfigure once')
421
- }
422
- // CLAUDE_CODE_SUBAGENT_MODEL overrides both the session model and the per-agent
423
- // model a step asks for, so when it is set the planner's tier ladder — the opus
424
- // clamp, the haiku pings — is not in effect and the run costs whatever that
425
- // model costs. Nothing here can prevent that; it is the user's environment. But
426
- // a summary claiming no degradation while the entire model ladder was bypassed
427
- // is exactly the silence the banner block exists to remove.
428
- const subagentModel =
429
- probed && typeof probed.subagentModelOverride === 'string'
430
- ? probed.subagentModelOverride.trim()
442
+ // The effort override. CLAUDE_CODE_EFFORT_LEVEL outranks both the session's effort
443
+ // and every per-agent effort a step asks for, so while it is set the plan's
444
+ // per-step effort is not in effect. It is the operator's environment and is
445
+ // never stripped; it is bannered so the summary cannot claim otherwise.
446
+ const effortLevel =
447
+ probed && typeof probed.effortLevelOverride === 'string'
448
+ ? probed.effortLevelOverride.trim()
431
449
  : ''
432
- if (subagentModel) {
450
+ if (effortLevel) {
433
451
  banners.push(
434
- `MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL=${subagentModel} — every agent runs on ` +
435
- `that model, so the per-tier assignment in the plan is not in effect`
452
+ `EFFORT ROUTING OVERRIDDEN: CLAUDE_CODE_EFFORT_LEVEL=${effortLevel} — every agent runs at ` +
453
+ `that effort, so the per-step effort in the plan is not in effect`
436
454
  )
437
- } else if (probed && probed.haikuAvailable === true) {
438
- // Mutate rather than rebind — `ping` closes over the object.
439
- pingExtra.model = 'haiku'
440
455
  }
441
456
 
442
457
  // --- the loop ---------------------------------------------------------------
@@ -462,6 +477,13 @@ let step = await cli([
462
477
  'workflow'
463
478
  ])
464
479
 
480
+ // The control-plane ping model `run start` decided from the host's version and
481
+ // environment. Forwarded, never decided here: when the step names none — an
482
+ // override is in force, or a Bedrock account may not reach haiku — every later
483
+ // relay inherits the session model. Mutate rather than rebind: `ping` closes
484
+ // over the object.
485
+ if (step && typeof step.pingModel === 'string' && step.pingModel) pingExtra.model = step.pingModel
486
+
465
487
  while (step && step.then) {
466
488
  if (steps > RUNAWAY_BACKSTOP) {
467
489
  return await stop(`the interpreter exceeded the runtime's agent ceiling (${RUNAWAY_BACKSTOP})`)
@@ -488,7 +510,9 @@ while (step && step.then) {
488
510
  }
489
511
 
490
512
  if (!step) {
491
- return await stop('the run program returned no step — the CLI relay could not be read')
513
+ if (relayFailure) banners.push(`RELAY UNREADABLE: ${relayFailure}`)
514
+ return await stop('the run program returned no step — the CLI relay could not be read' +
515
+ (relayFailure ? `: ${relayFailure}` : ''))
492
516
  }
493
517
 
494
518
  // The last argv was `run close`, so `step` IS the close: its summary and its
@@ -1,19 +0,0 @@
1
- {
2
- "name": "interlock",
3
- "description": "Spec-driven development orchestration for Claude Code. Layers autonomous multi-agent workflows on top of OpenSpec: repo onboarding, reviewed specs, and start-to-commit shipping.",
4
- "owner": {
5
- "name": "Carl Rollon",
6
- "url": "https://github.com/renzrollon"
7
- },
8
- "plugins": [
9
- {
10
- "name": "interlock",
11
- "source": "./",
12
- "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec.",
13
- "version": "1.1.1",
14
- "author": {
15
- "name": "Carl Rollon"
16
- }
17
- }
18
- ]
19
- }