@renzrollon/interlock 1.0.0 → 1.1.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/lib/run.mjs CHANGED
@@ -52,6 +52,7 @@ import { planRemediation } from './remediate.mjs'
52
52
  import { classifySurface } from './surface.mjs'
53
53
  import { buildChecklist } from './conformance.mjs'
54
54
  import { record as recordAutonomy } from './autonomy.mjs'
55
+ import { writeResumeCard } from './resume-card.mjs'
55
56
  import {
56
57
  applyReplan,
57
58
  batchOutcomes,
@@ -1449,7 +1450,8 @@ function logSpawns(ctx, manifest, step) {
1449
1450
  // No run identity yet — the classify step runs before there is a wave state,
1450
1451
  // so nothing is owed. The same rule `logVerifyJudgement` follows: a call with
1451
1452
  // no runId is not part of a logged run, and logging is not what should fail it.
1452
- if (!manifest.runId) return step
1453
+ if (!manifest.runId) return null
1454
+ let failure = null
1453
1455
  for (const s of step.spawns || []) {
1454
1456
  if (s.kind === 'implementer') continue // logged by wave-state, at the lane
1455
1457
  const written = appendRunLogEvent(ctx.root, {
@@ -1460,13 +1462,18 @@ function logSpawns(ctx, manifest, step) {
1460
1462
  model: s.model || undefined,
1461
1463
  kind: s.kind
1462
1464
  })
1463
- if (!written.written) ctx.warn(`agent-spawn not recorded: ${written.reason}`)
1465
+ if (!written.written) {
1466
+ ctx.warn(`agent-spawn not recorded: ${written.reason}`)
1467
+ // The first failure is the one that opened the gap; the rest of the loop
1468
+ // still runs so the warnings a reader gets are unchanged.
1469
+ if (!failure) failure = `agent-spawn ${s.label}: ${written.reason}`
1470
+ }
1464
1471
  }
1465
- return step
1472
+ return failure
1466
1473
  }
1467
1474
 
1468
1475
  function logCliExit(ctx, manifest, command, exitCode) {
1469
- if (!manifest.runId) return
1476
+ if (!manifest.runId) return null
1470
1477
  const written = appendRunLogEvent(ctx.root, {
1471
1478
  runId: manifest.runId,
1472
1479
  change: manifest.change,
@@ -1474,9 +1481,46 @@ function logCliExit(ctx, manifest, command, exitCode) {
1474
1481
  command,
1475
1482
  exitCode
1476
1483
  })
1477
- if (!written.written) ctx.warn(`cli-exit not recorded: ${written.reason}`)
1484
+ if (written.written) return null
1485
+ ctx.warn(`cli-exit not recorded: ${written.reason}`)
1486
+ return `cli-exit ${command}: ${written.reason}`
1487
+ }
1488
+
1489
+ /**
1490
+ * The halt a failed run-trajectory append owes the run.
1491
+ *
1492
+ * `.claude/ship/runs` is `fatal: true` in `lib/doctor.mjs`'s STATE_DIRS, and
1493
+ * `wave-state` / `verify judge` in `bin/interlock` already `process.exit(1)`
1494
+ * when their append does not land. The live `interlock run` path used to warn
1495
+ * and walk on — into the task tick and the commit — so a run nobody could
1496
+ * reconstruct still ticked boxes and shipped. A warning is not a gate.
1497
+ *
1498
+ * Caller-side by construction: fatality is a policy of the SITE, not of the
1499
+ * shared writer, which is why `appendRunLogEvent` still just reports. The
1500
+ * outcome corpus and review metrics keep their deliberately different
1501
+ * semantics — a failed write there is reported and never touches the exit code.
1502
+ *
1503
+ * `site` names where the gap is, so the halt reason is enough to find it.
1504
+ */
1505
+ function trajectoryHaltStep(site, reason, extra = {}) {
1506
+ return haltStep(`trajectory append failed: ${site}: ${reason}`, extra)
1507
+ }
1508
+
1509
+ /**
1510
+ * The reason to halt with when `logWaveMutation` failed.
1511
+ *
1512
+ * It returns the filesystem's own reason where it has one. The fallback covers
1513
+ * an injected or older dep that reports only the boolean — better a named site
1514
+ * with a vague cause than a halt that says nothing at all.
1515
+ */
1516
+ function waveAppendReason(outcome) {
1517
+ const reason = outcome && typeof outcome.reason === 'string' ? outcome.reason.trim() : ''
1518
+ return reason || 'the wave-action or its paired cli-exit did not land'
1478
1519
  }
1479
1520
 
1521
+ /** Same, for `logAgentSpawns` — it returns a boolean and reports its reason to stderr. */
1522
+ const LANE_SPAWN_APPEND_FAILED = 'an implementer lane spawn did not land'
1523
+
1480
1524
  /** The shared tree's HEAD. Never throws: an unreadable base is null, never a guess. */
1481
1525
  function headCommit(ctx) {
1482
1526
  return ctx.deps.headCommit(ctx.root)
@@ -1771,15 +1815,29 @@ function adoptPlan(ctx, manifest, plan) {
1771
1815
  // anything, and `run-log check` does not require it.
1772
1816
  sessionId: hostSessionId(manifest, ctx.env) || undefined
1773
1817
  })
1774
- if (!started.written) ctx.warn(`run-start not recorded: ${started.reason}`)
1818
+ if (!started.written) {
1819
+ ctx.warn(`run-start not recorded: ${started.reason}`)
1820
+ return trajectoryHaltStep('run-start', started.reason)
1821
+ }
1775
1822
 
1776
- const { step, ok } = ctx.deps.logWaveMutation(root, {
1823
+ const created = ctx.deps.logWaveMutation(root, {
1777
1824
  source: 'create',
1778
1825
  change: manifest.change,
1779
1826
  state
1780
1827
  })
1781
- if (!ok) ctx.warn('the wave-state trajectory append failed on run start')
1782
- ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' })
1828
+ const { step, ok } = created
1829
+ if (!ok) {
1830
+ ctx.warn('the wave-state trajectory append failed on run start')
1831
+ return trajectoryHaltStep('wave-state create', waveAppendReason(created))
1832
+ }
1833
+ // The lane spawns, which `logSpawns` deliberately skips (they are logged at
1834
+ // the lane, by wave-state). Its verdict is read for the same reason every
1835
+ // other append's is: `wave-state next` exits 1 on this exact boolean, and the
1836
+ // live path owes the trajectory the same fatality.
1837
+ if (ctx.deps.logAgentSpawns(root, { state, change: manifest.change, step, source: 'next' }) === false) {
1838
+ ctx.warn('an implementer agent-spawn was not recorded on run start')
1839
+ return trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED)
1840
+ }
1783
1841
  return decorate(ctx, step, manifest)
1784
1842
  }
1785
1843
 
@@ -2103,18 +2161,43 @@ export function runRecordBatch(ctx, { results }) {
2103
2161
  changedPaths: ctx.deps.observedChangedPaths(root)
2104
2162
  })
2105
2163
  writeState(root, after)
2106
- const { step: nextRaw, ok: logOk } = ctx.deps.logWaveMutation(root, {
2164
+ const recorded = ctx.deps.logWaveMutation(root, {
2107
2165
  source: 'record-batch',
2108
2166
  change: manifest.change,
2109
2167
  state: after
2110
2168
  })
2111
- if (!logOk) ctx.warn('the wave-state trajectory append failed on record-batch')
2112
- ctx.deps.logAgentSpawns(root, {
2169
+ const { step: nextRaw, ok: logOk } = recorded
2170
+ if (!logOk) {
2171
+ // Before the tick and before the commit. The batch result is already on
2172
+ // disk (`writeState` above) and there is no undo API for it, so the halt
2173
+ // names the gap instead of pretending the state never advanced — close's
2174
+ // reconstructability check reads the same trajectory and agrees.
2175
+ ctx.warn('the wave-state trajectory append failed on record-batch')
2176
+ manifest.banners.push(...banners)
2177
+ return finishStep(
2178
+ ctx,
2179
+ manifest,
2180
+ trajectoryHaltStep('wave-state record-batch', waveAppendReason(recorded), { banners })
2181
+ )
2182
+ }
2183
+ // Before the tick and before the commit, exactly like the wave-action above:
2184
+ // a batch whose implementer spawns went unrecorded is a batch nobody can
2185
+ // attribute, and `emitRecordedState` already exits 1 on this same boolean.
2186
+ const laneSpawnsLogged = ctx.deps.logAgentSpawns(root, {
2113
2187
  state: after,
2114
2188
  change: manifest.change,
2115
2189
  step: nextRaw,
2116
2190
  source: 'record-batch'
2117
2191
  })
2192
+ if (laneSpawnsLogged === false) {
2193
+ ctx.warn('an implementer agent-spawn was not recorded on record-batch')
2194
+ manifest.banners.push(...banners)
2195
+ return finishStep(
2196
+ ctx,
2197
+ manifest,
2198
+ trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED, { banners })
2199
+ )
2200
+ }
2118
2201
 
2119
2202
  // One authority, read once, for the tick and for the tallies alike.
2120
2203
  const verdict = adjudicateBatches([reported], recordedOutcomes(batchOutcomes(state, after)))
@@ -2227,7 +2310,26 @@ export function runJudge(ctx, { context, results }) {
2227
2310
  }
2228
2311
  verdict = judgeVerification(plan, reportedSteps, { context })
2229
2312
  for (const banner of verdict.banners || []) banners.push(banner)
2230
- logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
2313
+ const exitFailure = logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
2314
+ if (exitFailure) {
2315
+ // The judgement happened; the record of it did not. Halting here is what
2316
+ // `verify judge` in bin/interlock already does on the same failure —
2317
+ // before the branch into record-verify, the handoff or the commit.
2318
+ //
2319
+ // A verdict that was ALREADY halting keeps its own reason, the same rule
2320
+ // `finishStep` applies below: this run stopped because verification
2321
+ // failed, and that reason is what reaches the summary, the push and the
2322
+ // resume card. The lost record is a banner beside it, never instead of it.
2323
+ banners.push(`TRAJECTORY APPEND FAILED: ${exitFailure}`)
2324
+ manifest.banners.push(...banners)
2325
+ return finishStep(
2326
+ ctx,
2327
+ manifest,
2328
+ verdict.halt
2329
+ ? haltStep(verdict.reason || 'verification halted the run', { banners })
2330
+ : trajectoryHaltStep('cli-exit', exitFailure.replace(/^cli-exit /, ''), { banners })
2331
+ )
2332
+ }
2231
2333
  } else {
2232
2334
  banners.push(`VERIFICATION SKIPPED: reason=${reason}`)
2233
2335
  }
@@ -2243,13 +2345,21 @@ export function runJudge(ctx, { context, results }) {
2243
2345
  }
2244
2346
  const after = recordVerifyResult(state, record)
2245
2347
  writeState(root, after)
2246
- const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
2348
+ const verified = ctx.deps.logWaveMutation(root, {
2247
2349
  source: 'record-verify',
2248
2350
  change: manifest.change,
2249
2351
  state: after
2250
2352
  })
2251
- if (!ok) ctx.warn('the wave-state trajectory append failed on record-verify')
2353
+ const { step: nextRaw, ok } = verified
2252
2354
  manifest.banners.push(...banners)
2355
+ if (!ok) {
2356
+ ctx.warn('the wave-state trajectory append failed on record-verify')
2357
+ return finishStep(
2358
+ ctx,
2359
+ manifest,
2360
+ trajectoryHaltStep('wave-state record-verify', waveAppendReason(verified), { banners })
2361
+ )
2362
+ }
2253
2363
  return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
2254
2364
  }
2255
2365
 
@@ -2481,12 +2591,16 @@ export function runReplan(ctx, { results }) {
2481
2591
  return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
2482
2592
  }
2483
2593
  writeState(root, after)
2484
- const { step: nextRaw, ok } = ctx.deps.logWaveMutation(root, {
2594
+ const replanned = ctx.deps.logWaveMutation(root, {
2485
2595
  source: 'replan',
2486
2596
  change: manifest.change,
2487
2597
  state: after
2488
2598
  })
2489
- if (!ok) ctx.warn('the wave-state trajectory append failed on replan')
2599
+ const { step: nextRaw, ok } = replanned
2600
+ if (!ok) {
2601
+ ctx.warn('the wave-state trajectory append failed on replan')
2602
+ return finishStep(ctx, manifest, trajectoryHaltStep('wave-state replan', waveAppendReason(replanned)))
2603
+ }
2490
2604
  return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
2491
2605
  }
2492
2606
 
@@ -2520,8 +2634,18 @@ function noManifest() {
2520
2634
  * process — each `run` subcommand is its own.
2521
2635
  */
2522
2636
  function finishStep(ctx, manifest, step) {
2523
- logSpawns(ctx, manifest, step)
2524
- return recordThen(ctx.root, manifest, step)
2637
+ const failure = logSpawns(ctx, manifest, step)
2638
+ // An agent-spawn nobody recorded is a gap in the trajectory, and the step it
2639
+ // belongs to must not run. A step that was ALREADY halting keeps its own
2640
+ // reason: replacing it would trade why the run stopped for the bookkeeping
2641
+ // failure that followed, and it halts either way.
2642
+ const final =
2643
+ failure && step.action !== 'halt'
2644
+ ? trajectoryHaltStep('agent-spawn', failure.replace(/^agent-spawn /, ''), {
2645
+ ...(Array.isArray(step.banners) ? { banners: step.banners } : {})
2646
+ })
2647
+ : step
2648
+ return recordThen(ctx.root, manifest, final)
2525
2649
  }
2526
2650
 
2527
2651
  // --- run close (design D10) -------------------------------------------------
@@ -2758,13 +2882,23 @@ export async function runClose(
2758
2882
  : undefined
2759
2883
  })
2760
2884
 
2885
+ // The trajectory writes this close owes. A failure here is the same fatal
2886
+ // class as every other `.claude/ship/runs` append — the close has no tick or
2887
+ // commit left to stop, so the halt it can still perform is a spoken
2888
+ // degradation and a non-zero exit, exactly what an unreconstructable run gets
2889
+ // below. Silence would leave a run that reads complete and cannot be replayed.
2890
+ let trajectoryComplete = true
2761
2891
  if (manifest.runId) {
2762
2892
  const written = appendRunLogEvent(root, {
2763
2893
  ...receipt,
2764
2894
  runId: manifest.runId,
2765
2895
  change: manifest.change
2766
2896
  })
2767
- if (!written.written) ctx.warn(`run-receipt not recorded: ${written.reason}`)
2897
+ if (!written.written) {
2898
+ ctx.warn(`run-receipt not recorded: ${written.reason}`)
2899
+ trajectoryComplete = false
2900
+ degradations.push(`TRAJECTORY APPEND FAILED: run-receipt: ${written.reason}`)
2901
+ }
2768
2902
 
2769
2903
  const terminal = appendRunLogEvent(root, {
2770
2904
  runId: manifest.runId,
@@ -2772,7 +2906,12 @@ export async function runClose(
2772
2906
  type: haltReason ? 'run-halt' : 'run-complete',
2773
2907
  reason: haltReason || undefined
2774
2908
  })
2775
- if (!terminal.written) ctx.warn(`${haltReason ? 'run-halt' : 'run-complete'} not recorded: ${terminal.reason}`)
2909
+ if (!terminal.written) {
2910
+ const type = haltReason ? 'run-halt' : 'run-complete'
2911
+ ctx.warn(`${type} not recorded: ${terminal.reason}`)
2912
+ trajectoryComplete = false
2913
+ degradations.push(`TRAJECTORY APPEND FAILED: ${type}: ${terminal.reason}`)
2914
+ }
2776
2915
  }
2777
2916
 
2778
2917
  // The outcome corpus. Its own write failure is reported and never touches the
@@ -2821,6 +2960,36 @@ export async function runClose(
2821
2960
  }
2822
2961
  }
2823
2962
 
2963
+ // The halt resume card. Only on a halt: a clean close has nothing to resume,
2964
+ // and its summary already ends in the archive reminder.
2965
+ //
2966
+ // Written last, so the card carries every banner this close raised — the
2967
+ // reconstructability failure and the autonomy one included. Its own failure is
2968
+ // a banner and never an exit code: it is a pointer to records that were
2969
+ // already written, so losing it costs a reader convenience, not evidence. That
2970
+ // is the outcome-corpus class, not the trajectory's.
2971
+ //
2972
+ // Nothing reads it back. The next ship still decides whether to skip the
2973
+ // classifier from the plan fingerprint, so a card that was never written, or
2974
+ // was hand-edited, cannot change what a later run does.
2975
+ let resumeCard = null
2976
+ if (haltReason) {
2977
+ const written = writeResumeCard(root, {
2978
+ change: manifest.change,
2979
+ runId: manifest.runId,
2980
+ halted: haltReason,
2981
+ leftoverTaskIds,
2982
+ plan: manifest.plan,
2983
+ planStored: Boolean(planFingerprint) && existsSync(join(root, PLAN_FILE_PATH)),
2984
+ waves: manifest.waves || [],
2985
+ degradations,
2986
+ projectSlug: slug,
2987
+ cwd: absRoot
2988
+ })
2989
+ if (written.written) resumeCard = written.path
2990
+ else degradations.push(`RESUME CARD NOT WRITTEN: ${written.reason}`)
2991
+ }
2992
+
2824
2993
  manifest.lastThen = null
2825
2994
  writeManifest(root, manifest)
2826
2995
 
@@ -2829,7 +2998,7 @@ export async function runClose(
2829
2998
  action: haltReason ? 'halt' : 'complete',
2830
2999
  then: null,
2831
3000
  spawns: [],
2832
- exitCode: haltReason || !reconstructable ? 1 : 0,
3001
+ exitCode: haltReason || !reconstructable || !trajectoryComplete ? 1 : 0,
2833
3002
  banners: degradations,
2834
3003
  summary: formatRunSummary({
2835
3004
  change: manifest.change,
@@ -2841,7 +3010,8 @@ export async function runClose(
2841
3010
  root: absRoot,
2842
3011
  projectSlug: slug,
2843
3012
  push,
2844
- unarchived
3013
+ unarchived,
3014
+ resumeCard
2845
3015
  })
2846
3016
  }
2847
3017
  }
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=18"
8
8
  },
9
9
  "bin": {
10
- "interlock": "./bin/interlock",
11
- "interlock-graph": "./bin/interlock-graph",
12
- "interlock-run": "./bin/interlock-run",
13
- "interlock-ship-acp": "./bin/interlock-ship-acp"
10
+ "interlock": "bin/interlock",
11
+ "interlock-graph": "bin/interlock-graph",
12
+ "interlock-run": "bin/interlock-run",
13
+ "interlock-ship-acp": "bin/interlock-ship-acp"
14
14
  },
15
15
  "files": [
16
16
  "bin",
@@ -110,10 +110,17 @@ one you need. When Rule 0 applies, graph query/consumers counts as “locate.”
110
110
 
111
111
  At session start, current-task context comes from a few cheap, authoritative
112
112
  sources — `git log --oneline`, `git status --short`, `openspec status --json`,
113
- an open-task grep over `openspec/changes/*/tasks.md`, and the latest handoff.
113
+ an open-task grep over `openspec/changes/*/tasks.md`, and the latest explore
114
+ brief (`.claude/handoff/explore-*.md`).
114
115
  Front-load these in one pass. Read them,
115
116
  then act. Do not re-traverse the tree to reconstruct what they already tell you.
116
117
 
118
+ The glob is load-bearing, not tidiness. `.claude/handoff/` also holds
119
+ `ship-*.md` halt resume cards, which are records of runs that already stopped —
120
+ nothing reads them, and finding one is not permission to start anything. Reading
121
+ "the latest handoff" unqualified after a halt would hand you one as if it were
122
+ session context.
123
+
117
124
  **Explore brief as Rule-3 state:** when `/interlock:spec` runs after explore
118
125
  (especially after `/clear` or prompt-cache expiry), read the latest matching
119
126
  `.claude/handoff/explore-*.md` first (see `EXPLORE-BRIEF.md`).