@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/lib/report.mjs CHANGED
@@ -66,15 +66,31 @@
66
66
  // would depress the share for a planner's silence, and read as an implementer
67
67
  // going off-plan.
68
68
  //
69
+ // THE CORPORA ARE READ FROM THE STATE HOME, NOT FROM THE ROOT (design D17). A
70
+ // linked worktree's corpora live in its main checkout (`lib/state-home.mjs`), so
71
+ // the trajectories, the outcome corpus, the review metrics and the interrupted
72
+ // notes are all read from the home the CLI resolved — or that this module
73
+ // resolves itself when none was passed — and the report names that home and the
74
+ // surface of the root it ran in. The outcome-eval history is the exception, and
75
+ // on purpose: it is a committed file under `evals/history/`, part of the working
76
+ // tree rather than a corpus a run appends to, so it is read from the root.
77
+ //
78
+ // Trajectories recorded in OTHER linked worktrees are counted, never read: a
79
+ // stat-only listing of each worktree git names, so a run recorded before the
80
+ // state home existed is spoken about without being folded into any figure.
81
+ //
69
82
  // Exposed to skills as `interlock report [--json] [--since <iso>] [--change <name>]`.
70
83
 
71
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
72
- import { join } from 'node:path'
84
+ import { execFileSync } from 'node:child_process'
85
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
86
+ import { join, resolve } from 'node:path'
73
87
  import { LEARNING_DIR, OUTCOMES_FILE, readOutcomes } from './outcomes.mjs'
74
88
  import { METRICS_DIR, REVIEW_METRICS_SCHEMA } from './metrics.mjs'
75
- import { RUN_LOG_DIR, readRunLog, runLogDir } from './run-log.mjs'
89
+ import { RUN_LOG_DIR, RUN_SURFACES, readRunLog, runLogDir } from './run-log.mjs'
76
90
  import { EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE, readShipOutcomes } from './eval-history.mjs'
77
91
  import { REPORT_CAPS } from './limits.mjs'
92
+ import { readInterruptedNotes } from './interrupted.mjs'
93
+ import { SURFACES, resolveStateHome } from './state-home.mjs'
78
94
 
79
95
  /** Bump when an indicator's definition changes, so two reports stay comparable. */
80
96
  export const REPORT_SCHEMA = 'interlock.report/1'
@@ -252,6 +268,10 @@ function digestTrajectories(root, { since, change, maxRuns }) {
252
268
  change: runChange,
253
269
  startedAt: firstTs || null,
254
270
  hasRunStart: Boolean(start),
271
+ // Recorded on `run-start` since design D14. Anything outside the
272
+ // vocabulary — an older trajectory, or no run-start at all — is `null`,
273
+ // and is counted unrecorded rather than guessed.
274
+ surface: start && RUN_SURFACES.includes(start.surface) ? start.surface : null,
255
275
  hasTerminal: records.some(r => r.type === 'run-complete' || r.type === 'run-halt'),
256
276
  hasReceipt: Boolean(receipt),
257
277
  // Receipt-derived, tri-state throughout: a receipt written by a run that
@@ -429,11 +449,208 @@ function digestOutcomes(root, { since, change }) {
429
449
  return { ...read, records: kept, filteredOut }
430
450
  }
431
451
 
452
+ // ---------------------------------------------------------------------------
453
+ // The state home, and what it cannot see
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /** How long `git worktree list` may take before the count is reported as not made. */
457
+ const WORKTREE_LIST_TIMEOUT_MS = 10_000
458
+
459
+ /** How a home was arrived at — `lib/state-home.mjs`'s vocabulary. */
460
+ const RESOLVED_FROM = Object.freeze(['explicit', 'git', 'lane', 'fallback'])
461
+
462
+ /**
463
+ * The surface partition's buckets, keyed as `coverage.surfaces` keys them and
464
+ * labelled as every rendering prints them, in one order. Exported so the HTML
465
+ * surface reads the labels from here: two renderings naming one bucket two ways
466
+ * is how a reader comes to believe they are two facts.
467
+ */
468
+ export const SURFACE_BUCKETS = Object.freeze([
469
+ Object.freeze({ key: 'main', label: 'main' }),
470
+ Object.freeze({ key: 'linkedWorktree', label: 'linked-worktree' }),
471
+ Object.freeze({ key: 'laneWorktree', label: 'lane-worktree' }),
472
+ Object.freeze({ key: 'unknown', label: 'unknown' }),
473
+ // A run whose `run-start` recorded no surface: written before the field
474
+ // existed, or with no `run-start` at all.
475
+ Object.freeze({ key: 'unrecorded', label: 'unrecorded' })
476
+ ])
477
+
478
+ function defaultExec(file, args, { cwd } = {}) {
479
+ return execFileSync(file, args, {
480
+ cwd,
481
+ encoding: 'utf8',
482
+ stdio: ['ignore', 'pipe', 'ignore'],
483
+ timeout: WORKTREE_LIST_TIMEOUT_MS
484
+ })
485
+ }
486
+
487
+ function nonEmptyString(value) {
488
+ return typeof value === 'string' && value.trim() !== ''
489
+ }
490
+
491
+ function realOrSelf(path) {
492
+ try {
493
+ return realpathSync(path)
494
+ } catch {
495
+ return resolve(path)
496
+ }
497
+ }
498
+
499
+ /**
500
+ * The home every corpus is read from, and what the root is.
501
+ *
502
+ * A resolution object the CLI computed is used as given — the CLI resolves once
503
+ * so every reader in one invocation agrees. A bare string is taken as an
504
+ * explicit home and resolved here, so a caller that passed the flag's value
505
+ * instead of a resolution still gets that home rather than silently losing it.
506
+ * Absent, the home is resolved here from `opts.env` (else the process
507
+ * environment) and git. `resolveStateHome` never throws; neither does this.
508
+ */
509
+ function stateHomeFor(root, opts) {
510
+ const given = opts.stateHome
511
+ const exec = typeof opts.exec === 'function' ? opts.exec : undefined
512
+ if (isObject(given) && nonEmptyString(given.home)) {
513
+ const surface = SURFACES.includes(given.surface) ? given.surface : 'unknown'
514
+ let reason = nonEmptyString(given.reason) ? given.reason : null
515
+ if (surface !== given.surface) {
516
+ reason =
517
+ `the surface passed with the state home (${JSON.stringify(given.surface ?? null)}) is not one of ` +
518
+ `${SURFACES.join(', ')}` + (reason ? `; ${reason}` : '')
519
+ }
520
+ return {
521
+ home: given.home,
522
+ surface,
523
+ reason,
524
+ resolvedFrom: RESOLVED_FROM.includes(given.resolvedFrom) ? given.resolvedFrom : 'explicit'
525
+ }
526
+ }
527
+ try {
528
+ if (nonEmptyString(given)) return resolveStateHome(root, { explicit: given, env: opts.env, exec })
529
+ return resolveStateHome(root, { env: opts.env, exec })
530
+ } catch (err) {
531
+ // Unreachable by the resolver's own contract; held here because this
532
+ // module's is the same.
533
+ const abs = resolve(nonEmptyString(root) ? root : '.')
534
+ return { home: abs, surface: 'unknown', reason: `the state home could not be resolved: ${messageOf(err)}`, resolvedFrom: 'fallback' }
535
+ }
536
+ }
537
+
538
+ function describeWorktreeListFailure(err, home) {
539
+ if (err && err.code === 'ENOENT') return `git could not be run in ${home}: not found on PATH (ENOENT)`
540
+ if (err && (err.code === 'ETIMEDOUT' || err.signal === 'SIGTERM')) {
541
+ return `git worktree list timed out after ${WORKTREE_LIST_TIMEOUT_MS / 1000}s in ${home}`
542
+ }
543
+ const status = err && typeof err.status === 'number' ? err.status : null
544
+ if (status === 128) {
545
+ return `git worktree list failed in ${home} (exit 128): not a git repository, or one git refuses to read`
546
+ }
547
+ if (status !== null) return `git worktree list failed in ${home} (exit ${status})`
548
+ return `git worktree list failed in ${home}: ${messageOf(err)}`
549
+ }
550
+
551
+ /**
552
+ * Trajectories recorded in the home repository's OTHER worktrees, which this
553
+ * report does not read: the spoken form of every run recorded before the state
554
+ * home existed in a worktree that still exists.
555
+ *
556
+ * COUNTED, NEVER OPENED. Each worktree git lists, other than the home itself,
557
+ * has its run-log directory listed by name and entry type — `readdir` opens the
558
+ * directory, not a file in it — so no indicator can change on their account and
559
+ * an unreadable file is counted like any other. Any git failure, or a home that
560
+ * is not a repository, is a reason with null counts, never a zero: a scan that
561
+ * was not made is not a scan that found nothing.
562
+ */
563
+ function unreadInLinkedWorktrees(home, exec) {
564
+ const notCounted = reason => ({ worktrees: null, runs: null, reason, unscannable: [] })
565
+ if (!existsSync(home)) return notCounted(`the state home ${home} does not exist, so it has no worktree list`)
566
+
567
+ let out
568
+ try {
569
+ out = exec('git', ['worktree', 'list', '--porcelain'], { cwd: home })
570
+ } catch (err) {
571
+ return notCounted(describeWorktreeListFailure(err, home))
572
+ }
573
+
574
+ const listed = String(out ?? '')
575
+ .split(/\r?\n/)
576
+ .filter(line => line.startsWith('worktree '))
577
+ .map(line => line.slice('worktree '.length))
578
+ .filter(Boolean)
579
+ if (!listed.length) return notCounted(`git worktree list in ${home} named no worktree`)
580
+
581
+ const self = realOrSelf(home)
582
+ let worktrees = 0
583
+ let runs = 0
584
+ const unscannable = []
585
+ for (const entry of listed) {
586
+ const path = resolve(home, entry)
587
+ if (realOrSelf(path) === self) continue
588
+ const dir = runLogDir(path)
589
+ let count
590
+ try {
591
+ count = readdirSync(dir, { withFileTypes: true }).filter(d => d.name.endsWith('.jsonl') && !d.isDirectory()).length
592
+ } catch (err) {
593
+ // No run-log directory — or a worktree git still lists after its
594
+ // directory was deleted — strands nothing.
595
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) continue
596
+ unscannable.push({ worktree: path, reason: messageOf(err) })
597
+ continue
598
+ }
599
+ if (count) {
600
+ worktrees++
601
+ runs += count
602
+ }
603
+ }
604
+ return { worktrees, runs, reason: null, unscannable }
605
+ }
606
+
607
+ /** One count per scanned run, from the surface its `run-start` recorded. */
608
+ function surfacesOf(runs) {
609
+ const counts = Object.fromEntries(SURFACE_BUCKETS.map(b => [b.key, 0]))
610
+ const keyOf = Object.fromEntries(SURFACE_BUCKETS.filter(b => b.key !== 'unrecorded').map(b => [b.label, b.key]))
611
+ for (const run of runs) counts[(run.surface && keyOf[run.surface]) || 'unrecorded']++
612
+ return counts
613
+ }
614
+
432
615
  // ---------------------------------------------------------------------------
433
616
  // Indicators
434
617
  // ---------------------------------------------------------------------------
435
618
 
436
- function coverageOf(trajectories, outcomes, metrics, evalHistory) {
619
+ /**
620
+ * Runs without a terminal event, split by whether an interrupted-run note names
621
+ * them (design D7). A note is what a session that ended mid-run leaves instead
622
+ * of a close, so a run it names is INTERRUPTED; one nobody explains is
623
+ * UNEXPLAINED — a different fact, and the one a reader should go and look at.
624
+ *
625
+ * A note is joined by run id to the scanned runs only. One that names no run in
626
+ * the listing at all is counted apart rather than joined to nothing; one whose
627
+ * run is on disk but outside the filter is outside the view, as its run is.
628
+ */
629
+ function interruptedCoverage(root, trajectories, filter) {
630
+ const read = readInterruptedNotes(root)
631
+ const scanned = new Set(trajectories.runs.map(r => r.runId))
632
+ const listed = new Set(trajectories.listing.runIds || [])
633
+ const named = new Set()
634
+ let withoutTrajectory = 0
635
+ for (const note of read.notes) {
636
+ if (scanned.has(note.runId)) named.add(note.runId)
637
+ else if (!listed.has(note.runId)) {
638
+ // No trajectory to apply the filter to, so the note's own fields stand in.
639
+ if (filter.change && note.change !== filter.change) continue
640
+ if (filter.since && !(typeof note.at === 'string' && note.at >= filter.since)) continue
641
+ withoutTrajectory++
642
+ }
643
+ }
644
+ const open = trajectories.runs.filter(r => !r.hasTerminal)
645
+ const interrupted = open.filter(r => named.has(r.runId)).length
646
+ return {
647
+ withoutTerminal: { total: open.length, interrupted, unexplained: open.length - interrupted },
648
+ interruptedNotesWithoutTrajectory: withoutTrajectory,
649
+ interruptedNotesUnreadable: read.unreadable
650
+ }
651
+ }
652
+
653
+ function coverageOf(trajectories, outcomes, metrics, evalHistory, interrupted, unread) {
437
654
  const byMode = {}
438
655
  for (const record of outcomes.records) {
439
656
  const mode = typeof record.mode === 'string' ? record.mode : 'unknown'
@@ -448,11 +665,16 @@ function coverageOf(trajectories, outcomes, metrics, evalHistory) {
448
665
  filteredOut: trajectories.filteredOut,
449
666
  withRunStart: trajectories.runs.filter(r => r.hasRunStart).length,
450
667
  withTerminal: trajectories.runs.filter(r => r.hasTerminal).length,
668
+ ...interrupted,
451
669
  withReceipt: trajectories.runs.filter(r => r.hasReceipt).length,
452
670
  unreadable: trajectories.unreadable,
453
671
  skippedLines: trajectories.skippedLines,
454
672
  reason: trajectories.listing.reason
455
673
  },
674
+ // Over the scanned runs, like every trajectory figure above.
675
+ surfaces: surfacesOf(trajectories.runs),
676
+ // Counted, never read — see `unreadInLinkedWorktrees`.
677
+ unreadInLinkedWorktrees: unread,
456
678
  outcomes: {
457
679
  path: outcomes.path,
458
680
  exists: outcomes.exists,
@@ -862,10 +1084,21 @@ function shipOutcomeEvalOf(history) {
862
1084
  * Compute every indicator the corpora support. Never throws, writes nothing,
863
1085
  * and produces no verdict.
864
1086
  *
865
- * @param {string} [root] repository root
866
- * @param {{since?: string, change?: string, maxRuns?: number}} [opts]
867
- * `since` is an ISO 8601 instant; a run is in the window when its earliest
868
- * record is at or after it. `maxRuns` overrides the published scan cap.
1087
+ * @param {string} [root] the working tree the report runs in
1088
+ * @param {object} [opts]
1089
+ * @param {string} [opts.since] an ISO 8601 instant; a run is in the window when
1090
+ * its earliest record is at or after it
1091
+ * @param {string} [opts.change] narrow to one change
1092
+ * @param {number} [opts.maxRuns] overrides the published scan cap
1093
+ * @param {{home: string, surface: string, reason: string|null, resolvedFrom: string}|string} [opts.stateHome]
1094
+ * the resolution the CLI computed (`resolveStateHome`); every corpus but the
1095
+ * committed eval history is read from its `home`. A string is an explicit
1096
+ * home, resolved here. Absent, the home is resolved here.
1097
+ * @param {Record<string, string|undefined>} [opts.env] where `INTERLOCK_STATE_HOME`
1098
+ * is read when the home is resolved here; defaults to the process environment
1099
+ * @param {(file: string, args: string[], opts: {cwd: string}) => string} [opts.exec]
1100
+ * runs git (the resolver's calls and the worktree list) and returns stdout;
1101
+ * throws on failure. Defaults to a bounded `execFileSync`.
869
1102
  * @returns {object} report
870
1103
  */
871
1104
  export function buildReport(root = '.', opts = {}) {
@@ -878,19 +1111,32 @@ export function buildReport(root = '.', opts = {}) {
878
1111
  : REPORT_CAPS.maxRunsScanned
879
1112
 
880
1113
  const filter = { since, change, maxRuns }
1114
+ const stateHome = stateHomeFor(root, opts)
1115
+ const home = stateHome.home
1116
+ const exec = typeof opts.exec === 'function' ? opts.exec : defaultExec
881
1117
 
882
1118
  try {
883
- const trajectories = digestTrajectories(root, filter)
884
- const outcomes = digestOutcomes(root, filter)
885
- const metrics = digestMetrics(root, filter)
1119
+ const trajectories = digestTrajectories(home, filter)
1120
+ const outcomes = digestOutcomes(home, filter)
1121
+ const metrics = digestMetrics(home, filter)
1122
+ // The committed eval history is part of the working tree, not a corpus a
1123
+ // run appends to: it is read from the root. See the header.
886
1124
  const evalHistory = digestEvalHistory(root, filter)
887
1125
  const runs = trajectories.runs
888
1126
 
889
1127
  return {
890
1128
  schema: REPORT_SCHEMA,
891
1129
  root,
1130
+ stateHome,
892
1131
  filter,
893
- coverage: coverageOf(trajectories, outcomes, metrics, evalHistory),
1132
+ coverage: coverageOf(
1133
+ trajectories,
1134
+ outcomes,
1135
+ metrics,
1136
+ evalHistory,
1137
+ interruptedCoverage(home, trajectories, filter),
1138
+ unreadInLinkedWorktrees(home, exec)
1139
+ ),
894
1140
  indicators: {
895
1141
  firstPassShip: firstPassShipOf(runs),
896
1142
  rework: reworkOf(runs),
@@ -911,6 +1157,7 @@ export function buildReport(root = '.', opts = {}) {
911
1157
  return {
912
1158
  schema: REPORT_SCHEMA,
913
1159
  root,
1160
+ stateHome,
914
1161
  filter,
915
1162
  coverage: null,
916
1163
  indicators: null,
@@ -937,10 +1184,31 @@ function plain(ind, unit = '') {
937
1184
  (ind.notObserved ? `, ${ind.notObserved} not observed` : '')
938
1185
  }
939
1186
 
1187
+ /**
1188
+ * Which home the corpora were read from and what the root is, and — when the
1189
+ * home could not be resolved — that it could not, and why. Spoken, never
1190
+ * implied: a report read from the wrong directory looks exactly like a report
1191
+ * of a quiet project.
1192
+ */
1193
+ function stateHomeLines(h) {
1194
+ if (!h) return []
1195
+ const out = [
1196
+ ` state home: ${h.home} (${h.surface})` +
1197
+ (h.resolvedFrom === 'explicit' ? ' — set by --state-home or INTERLOCK_STATE_HOME' : '')
1198
+ ]
1199
+ if (h.resolvedFrom === 'fallback') {
1200
+ out.push(` STATE HOME UNRESOLVED: ${h.reason || 'no reason was given'} — every corpus was read from the root`)
1201
+ } else if (h.reason) {
1202
+ out.push(` surface unknown: ${h.reason}`)
1203
+ }
1204
+ return out
1205
+ }
1206
+
940
1207
  /** Human-readable report. Coverage first, per design.md D3. */
941
1208
  export function formatReport(report) {
942
1209
  const lines = []
943
1210
  lines.push('INTERLOCK REPORT — indicators over the recorded corpora. Gates nothing (§4.15a).')
1211
+ lines.push(...stateHomeLines(report.stateHome))
944
1212
 
945
1213
  if (report.filter && (report.filter.since || report.filter.change)) {
946
1214
  const bits = []
@@ -964,11 +1232,44 @@ export function formatReport(report) {
964
1232
  (t.filteredOut ? ` (${t.filteredOut} outside the filter)` : '')
965
1233
  )
966
1234
  lines.push(` with run-start ${t.withRunStart}`)
1235
+ const surfaces = report.coverage.surfaces
1236
+ if (surfaces) {
1237
+ lines.push(
1238
+ ` surfaces ${SURFACE_BUCKETS.map(b => `${b.label} ${surfaces[b.key] ?? 0}`).join(', ')}` +
1239
+ ` — over ${t.scanned} scanned, as each run-start recorded it`
1240
+ )
1241
+ }
967
1242
  lines.push(` with terminal ${t.withTerminal} (run-complete or run-halt)`)
1243
+ if (t.withoutTerminal) {
1244
+ const w = t.withoutTerminal
1245
+ lines.push(
1246
+ ` without terminal ${w.total} — interrupted ${w.interrupted} (a SessionEnd note names the run), ` +
1247
+ `unexplained ${w.unexplained}`
1248
+ )
1249
+ }
1250
+ if (t.interruptedNotesWithoutTrajectory) {
1251
+ const n = t.interruptedNotesWithoutTrajectory
1252
+ lines.push(` ${n} interrupted-run note${n === 1 ? '' : 's'} name${n === 1 ? 's' : ''} no scanned trajectory`)
1253
+ }
1254
+ for (const u of t.interruptedNotesUnreadable || []) {
1255
+ lines.push(` interrupted-run note unreadable ${u.file}: ${u.reason}`)
1256
+ }
968
1257
  lines.push(` with receipt ${t.withReceipt} — every receipt-derived indicator below rests on this`)
969
1258
  if (t.skippedLines) lines.push(` torn lines ${t.skippedLines} skipped`)
970
1259
  for (const u of t.unreadable) lines.push(` unreadable ${u.runId}: ${u.reason}`)
971
1260
  if (t.reason) lines.push(` directory ${t.reason}`)
1261
+ const u = report.coverage.unreadInLinkedWorktrees
1262
+ if (u) {
1263
+ if (u.runs === null || u.worktrees === null) {
1264
+ lines.push(` trajectories recorded in other linked worktrees could not be counted: ${u.reason || 'no reason was given'}`)
1265
+ } else {
1266
+ lines.push(
1267
+ ` trajectories recorded in other linked worktrees and not read: ${u.runs} across ${u.worktrees} ` +
1268
+ `worktree${u.worktrees === 1 ? '' : 's'} — counted, never opened, in no figure below`
1269
+ )
1270
+ }
1271
+ for (const w of u.unscannable || []) lines.push(` worktree not counted ${w.worktree}: ${w.reason}`)
1272
+ }
972
1273
 
973
1274
  lines.push(
974
1275
  ` outcome corpus ${o.exists ? `${o.records} record(s)` : 'absent'}` +
@@ -86,6 +86,28 @@ function rows(list, { whole }) {
86
86
  }
87
87
  }
88
88
 
89
+ // A label or an outcome printed inside a backticked span on one list row: a
90
+ // backtick or a newline from a plan or a host would break the row apart.
91
+ function inline(value, max) {
92
+ return clamp(typeof value === 'string' ? value.replace(/[`\r\n]+/g, ' ') : '', max)
93
+ }
94
+
95
+ // A session id is pasted into a shell command, so it is printed only when it is
96
+ // a plain identifier: no whitespace, no shell syntax, and no leading `-` that
97
+ // `claude` would read as a flag. The host's ids are UUIDs.
98
+ function isResumableId(value) {
99
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
100
+ }
101
+
102
+ // Whether the state home names the directory the close ran in. Text only —
103
+ // trailing separators ignored, nothing read from disk — so a home reached
104
+ // through a symlink prints a line naming a real path rather than losing one.
105
+ function samePath(a, b) {
106
+ if (typeof a !== 'string' || typeof b !== 'string') return false
107
+ const trim = p => (p.length > 1 ? p.replace(/[\\/]+$/, '') || p.slice(0, 1) : p)
108
+ return trim(a) === trim(b)
109
+ }
110
+
89
111
  /**
90
112
  * Where this change's card lives, relative to the repo root.
91
113
  *
@@ -109,7 +131,14 @@ export function resumeCardPath({ change, runId } = {}) {
109
131
  * happens at the next `run start`, against artifacts that may change in
110
132
  * between, and a card that guessed would be wrong exactly when it mattered.
111
133
  *
134
+ * `cwd` is the directory the close ran in and `stateHome` the directory its
135
+ * corpora went to (design D14); the two differ for a run in a linked worktree,
136
+ * where this card is written under the main checkout. `laneSessions` is every
137
+ * lane for which the host recorded a session id, with that lane's outcome.
138
+ *
112
139
  * @param {object} input
140
+ * @param {Array<{label?: string, sessionId?: string, outcome?: string}>} [input.laneSessions]
141
+ * @param {string|null} [input.stateHome]
113
142
  * @returns {string} the markdown body
114
143
  */
115
144
  export function formatResumeCard({
@@ -120,13 +149,16 @@ export function formatResumeCard({
120
149
  plan = null,
121
150
  planStored = false,
122
151
  waves = [],
152
+ laneSessions = [],
123
153
  degradations = [],
124
154
  projectSlug = null,
125
- cwd = null
155
+ cwd = null,
156
+ stateHome = null
126
157
  } = {}) {
127
158
  const name = typeof change === 'string' && change.trim() ? change.trim() : 'unnamed'
128
159
  const reason = clamp(halted, MAX_REASON_CHARS) || 'the run halted without recording a reason'
129
160
  const hasRun = typeof runId === 'string' && runId.length > 0
161
+ const elsewhere = typeof stateHome === 'string' && stateHome.length > 0 && !samePath(stateHome, cwd)
130
162
  const lines = []
131
163
 
132
164
  lines.push(`<!-- ${RESUME_CARD_SCHEMA} change=${name} run=${hasRun ? runId : NO_RUN_ID} -->`)
@@ -151,8 +183,20 @@ export function formatResumeCard({
151
183
  )
152
184
  if (projectSlug) lines.push(`- project: \`${projectSlug}\``)
153
185
  if (cwd) lines.push(`- cwd: \`${cwd}\``)
186
+ // Only when it differs, so a main-checkout card is byte-identical to one
187
+ // written before the state home existed. When it does differ, the corpora are
188
+ // not in the tree the run worked on, and a reader in either tree needs to be
189
+ // told which relative paths mean which.
190
+ if (elsewhere) {
191
+ lines.push(
192
+ `- state home: \`${stateHome}\` — the trajectory, the outcome line and this card were written ` +
193
+ 'here; the other relative paths in this card are relative to the cwd above'
194
+ )
195
+ }
154
196
  if (hasRun) {
155
- lines.push(`- trajectory: \`.claude/ship/runs/${runId}.jsonl\``)
197
+ lines.push(
198
+ `- trajectory: \`${elsewhere ? join(stateHome, '.claude', 'ship', 'runs', `${runId}.jsonl`) : `.claude/ship/runs/${runId}.jsonl`}\``
199
+ )
156
200
  lines.push('')
157
201
  lines.push('Read the whole walk that led here rather than re-deriving it:')
158
202
  lines.push('')
@@ -249,6 +293,50 @@ export function formatResumeCard({
249
293
  }
250
294
  lines.push('')
251
295
 
296
+ lines.push('## Lanes with a host session')
297
+ lines.push('')
298
+ // Only a lane whose host recorded a session id is listed, and only an id that
299
+ // is safe to paste gets a command: a command a reader cannot run is worse
300
+ // than its absence. An id that was recorded but is not a plain identifier is
301
+ // said, not dropped. The Workflow host records no lane session, so a run
302
+ // there always lands on the absence sentence.
303
+ const recorded = (Array.isArray(laneSessions) ? laneSessions : []).filter(
304
+ lane => lane && typeof lane === 'object' && typeof lane.sessionId === 'string' && lane.sessionId.length > 0
305
+ )
306
+ const laneRows = rows(
307
+ recorded.map(lane => {
308
+ const label = inline(lane.label, 120) || 'unlabelled lane'
309
+ const outcome = inline(lane.outcome, 200) || 'outcome not recorded'
310
+ return isResumableId(lane.sessionId)
311
+ ? `- \`${label}\` (${outcome}): \`claude --resume ${lane.sessionId} --fork-session\``
312
+ : `- \`${label}\` (${outcome}): the recorded session id is not a safe identifier, so no resume command is printed for it`
313
+ }),
314
+ {
315
+ whole: hasRun
316
+ ? `\`interlock run-log query --run ${runId} --type agent-result\``
317
+ : 'the run manifest, `.claude/ship/run.json`'
318
+ }
319
+ )
320
+ if (!laneRows.shown.length) {
321
+ lines.push('No lane recorded a host session, so there is no session to resume.')
322
+ } else {
323
+ for (const row of laneRows.shown) lines.push(row)
324
+ if (laneRows.note) {
325
+ lines.push('')
326
+ lines.push(laneRows.note)
327
+ }
328
+ if (recorded.some(lane => isResumableId(lane.sessionId))) {
329
+ lines.push('')
330
+ lines.push(
331
+ 'Each command works only if the session still exists: the host deletes transcripts after ' +
332
+ 'its retention period, and a session deleted in the desktop app is gone with its ' +
333
+ 'transcript. `--fork-session` resumes into a new session, so the recorded one is left as ' +
334
+ 'it was.'
335
+ )
336
+ }
337
+ }
338
+ lines.push('')
339
+
252
340
  lines.push('## Degraded before the halt')
253
341
  lines.push('')
254
342
  const bannerRows = rows(