@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.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- 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 {
|
|
72
|
-
import {
|
|
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
|
-
|
|
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]
|
|
866
|
-
* @param {
|
|
867
|
-
*
|
|
868
|
-
* record is at or after it
|
|
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(
|
|
884
|
-
const outcomes = digestOutcomes(
|
|
885
|
-
const metrics = digestMetrics(
|
|
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(
|
|
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'}` +
|
package/lib/resume-card.mjs
CHANGED
|
@@ -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(
|
|
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(
|