@renzrollon/interlock 1.2.0 → 1.3.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/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +16 -1
- package/CHANGELOG.md +68 -13
- package/bin/interlock +154 -18
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +209 -34
- package/lib/doctor.mjs +23 -18
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/evals-triage.mjs +1 -1
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +3 -2
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +6 -11
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/.claude-plugin/marketplace.json +0 -19
package/lib/resume-card.mjs
CHANGED
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Three rules it exists under, and all three are load-bearing:
|
|
12
12
|
//
|
|
13
|
-
// 1. **It is a record, not a trigger.** Nothing reads it back.
|
|
14
|
-
// still decides whether to skip the classifier from the
|
|
15
|
-
// from this file, so a stale or hand-edited card
|
|
16
|
-
// reuse a plan it should have rebuilt. The card says
|
|
17
|
-
// the file, because a markdown "resume" artifact that
|
|
18
|
-
// exactly the thing a reader would otherwise assume is
|
|
13
|
+
// 1. **It is a record, not a trigger.** Nothing reads it back to decide.
|
|
14
|
+
// `/interlock:ship` still decides whether to skip the classifier from the
|
|
15
|
+
// plan fingerprint, not from this file, so a stale or hand-edited card
|
|
16
|
+
// cannot make a later run reuse a plan it should have rebuilt. The card says
|
|
17
|
+
// that about itself, in the file, because a markdown "resume" artifact that
|
|
18
|
+
// nothing consumes is exactly the thing a reader would otherwise assume is
|
|
19
|
+
// wired in. Two readers show it and nothing more
|
|
20
|
+
// (show-preflight-and-interrupted-runs-at-session-start design D4): the
|
|
21
|
+
// SessionStart preflight lists the cards of open changes from the stamp on
|
|
22
|
+
// each card's first line (`listResumeCards`, below), and the hooks module's
|
|
23
|
+
// `/interlock-handoff` renders one verbatim on request. Neither marks,
|
|
24
|
+
// moves or deletes a card.
|
|
19
25
|
// 2. **It never throws.** A read-only checkout, a full disk, an unwritable
|
|
20
26
|
// `.claude/` — each degrades to a no-op that *reports* itself through the
|
|
21
27
|
// return value, and the close turns that into a degradation banner. The run
|
|
@@ -29,8 +35,8 @@
|
|
|
29
35
|
// Only on a halt. A clean run's summary already ends in `ARCHIVE PENDING` and
|
|
30
36
|
// there is nothing to resume.
|
|
31
37
|
|
|
32
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
33
|
-
import { join } from 'node:path'
|
|
38
|
+
import { closeSync, existsSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
39
|
+
import { join, resolve } from 'node:path'
|
|
34
40
|
import { LIMITS } from './limits.mjs'
|
|
35
41
|
|
|
36
42
|
/** Where the card lands, relative to the repo root. Already gitignored. */
|
|
@@ -161,6 +167,7 @@ export function formatResumeCard({
|
|
|
161
167
|
const elsewhere = typeof stateHome === 'string' && stateHome.length > 0 && !samePath(stateHome, cwd)
|
|
162
168
|
const lines = []
|
|
163
169
|
|
|
170
|
+
// The stamp `parseResumeCardStamp` reads back: change it and the parser together.
|
|
164
171
|
lines.push(`<!-- ${RESUME_CARD_SCHEMA} change=${name} run=${hasRun ? runId : NO_RUN_ID} -->`)
|
|
165
172
|
lines.push(`# Ship halted — ${name}`)
|
|
166
173
|
lines.push('')
|
|
@@ -375,6 +382,117 @@ export function formatResumeCard({
|
|
|
375
382
|
return lines.join('\n')
|
|
376
383
|
}
|
|
377
384
|
|
|
385
|
+
// The writer's first line, read back. Anchored on the schema, so a card of
|
|
386
|
+
// another shape is no card; a change or run carrying a separator is refused.
|
|
387
|
+
const SCHEMA_PATTERN = RESUME_CARD_SCHEMA.replace(/[.\/]/g, '\\$&')
|
|
388
|
+
const STAMP = new RegExp(`^<!-- ${SCHEMA_PATTERN} change=([^\\s/\\\\]+) run=([^\\s/\\\\]+) -->$`)
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The change and run id a card's first line names, or `null` when the line is
|
|
392
|
+
* not this schema's stamp. The inverse of the stamp `formatResumeCard` writes.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} line
|
|
395
|
+
* @returns {{change: string, runId: string} | null}
|
|
396
|
+
*/
|
|
397
|
+
export function parseResumeCardStamp(line) {
|
|
398
|
+
if (typeof line !== 'string') return null
|
|
399
|
+
const m = STAMP.exec(line.replace(/\r$/, ''))
|
|
400
|
+
if (!m || m[1] === '.' || m[1] === '..') return null
|
|
401
|
+
return { change: m[1], runId: m[2] }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** How much of a card the listing reads: the stamp line, never the body. */
|
|
405
|
+
const STAMP_BYTES = 512
|
|
406
|
+
|
|
407
|
+
function firstLine(path) {
|
|
408
|
+
const fd = openSync(path, 'r')
|
|
409
|
+
try {
|
|
410
|
+
const buffer = Buffer.alloc(STAMP_BYTES)
|
|
411
|
+
const n = readSync(fd, buffer, 0, STAMP_BYTES, 0)
|
|
412
|
+
return buffer.toString('utf8', 0, n).split('\n')[0]
|
|
413
|
+
} finally {
|
|
414
|
+
closeSync(fd)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const messageOf = err => (err && err.message) || String(err)
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The halt resume cards under each root's handoff directory, as the
|
|
422
|
+
* SessionStart preflight lists them (design D4). A card whose change still has
|
|
423
|
+
* a directory under `changesDir` is listed with its absolute path, change, run
|
|
424
|
+
* id and modification time; the cards of other changes are counted under
|
|
425
|
+
* `archived`; a `ship-*.md` without a readable stamp is named under
|
|
426
|
+
* `unreadable`. Every handoff directory opened or found absent is recorded
|
|
427
|
+
* under `lookedIn`, and two roots naming one real directory are read once.
|
|
428
|
+
*
|
|
429
|
+
* Reads the first line of each card and nothing past it; marks, moves and
|
|
430
|
+
* deletes nothing. Never throws.
|
|
431
|
+
*
|
|
432
|
+
* @param {{roots?: string[], changesDir?: string}} [input]
|
|
433
|
+
* @returns {{listed: Array<{path: string, change: string, runId: string, writtenAt: string|null}>, archived: number, unreadable: Array<{path: string, reason: string}>, lookedIn: string[]}}
|
|
434
|
+
*/
|
|
435
|
+
export function listResumeCards({ roots = [], changesDir } = {}) {
|
|
436
|
+
const out = { listed: [], archived: 0, unreadable: [], lookedIn: [] }
|
|
437
|
+
try {
|
|
438
|
+
const seen = new Set()
|
|
439
|
+
for (const root of Array.isArray(roots) ? roots : []) {
|
|
440
|
+
if (typeof root !== 'string' || !root) continue
|
|
441
|
+
const dir = resolve(root, HANDOFF_DIR)
|
|
442
|
+
let real = dir
|
|
443
|
+
try {
|
|
444
|
+
real = realpathSync(dir)
|
|
445
|
+
} catch {
|
|
446
|
+
// An absent directory is still one directory, named as given.
|
|
447
|
+
}
|
|
448
|
+
if (seen.has(real)) continue
|
|
449
|
+
seen.add(real)
|
|
450
|
+
out.lookedIn.push(dir)
|
|
451
|
+
let names
|
|
452
|
+
try {
|
|
453
|
+
names = readdirSync(dir)
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (!(err && err.code === 'ENOENT')) out.unreadable.push({ path: dir, reason: messageOf(err) })
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
for (const name of names.filter(n => n.startsWith('ship-') && n.endsWith('.md')).sort()) {
|
|
459
|
+
const path = join(dir, name)
|
|
460
|
+
let stamp
|
|
461
|
+
try {
|
|
462
|
+
stamp = parseResumeCardStamp(firstLine(path))
|
|
463
|
+
} catch (err) {
|
|
464
|
+
out.unreadable.push({ path, reason: messageOf(err) })
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
if (!stamp) {
|
|
468
|
+
out.unreadable.push({ path, reason: `the first line is not an ${RESUME_CARD_SCHEMA} stamp` })
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
let open = false
|
|
472
|
+
try {
|
|
473
|
+
open = typeof changesDir === 'string' && statSync(join(changesDir, stamp.change)).isDirectory()
|
|
474
|
+
} catch {
|
|
475
|
+
open = false
|
|
476
|
+
}
|
|
477
|
+
if (!open) {
|
|
478
|
+
out.archived++
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
let writtenAt = null
|
|
482
|
+
try {
|
|
483
|
+
writtenAt = statSync(path).mtime.toISOString()
|
|
484
|
+
} catch {
|
|
485
|
+
writtenAt = null
|
|
486
|
+
}
|
|
487
|
+
out.listed.push({ path, change: stamp.change, runId: stamp.runId, writtenAt })
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
out.unreadable.push({ path: String(roots), reason: messageOf(err) })
|
|
492
|
+
}
|
|
493
|
+
return out
|
|
494
|
+
}
|
|
495
|
+
|
|
378
496
|
/**
|
|
379
497
|
* Write the card for a halted run.
|
|
380
498
|
*
|
package/lib/run.mjs
CHANGED
|
@@ -64,6 +64,7 @@ import { classifySurface } from './surface.mjs'
|
|
|
64
64
|
import { buildChecklist } from './conformance.mjs'
|
|
65
65
|
import { record as recordAutonomy } from './autonomy.mjs'
|
|
66
66
|
import { writeResumeCard } from './resume-card.mjs'
|
|
67
|
+
import { summarizePlan } from './plan-summary.mjs'
|
|
67
68
|
import {
|
|
68
69
|
applyReplan,
|
|
69
70
|
batchOutcomes,
|
|
@@ -439,7 +440,17 @@ export const RELAY_STEP_FIELDS = Object.freeze([
|
|
|
439
440
|
'reason',
|
|
440
441
|
// The control-plane ping model `run start` decided (design D3). The driver
|
|
441
442
|
// sets its relay pings' model from this field and from nothing else.
|
|
442
|
-
'pingModel'
|
|
443
|
+
'pingModel',
|
|
444
|
+
// For a second reader, the ship meter's wave board, and read by neither
|
|
445
|
+
// driver (draw-the-wave-board-in-the-meter-pane design D1): the step's wave
|
|
446
|
+
// and batch positions, a verify step's skip flag, the ids a recorded batch
|
|
447
|
+
// names, and the plan summary the adoption and replan steps carry.
|
|
448
|
+
'waveIndex',
|
|
449
|
+
'batchIndex',
|
|
450
|
+
'batchCount',
|
|
451
|
+
'skipped',
|
|
452
|
+
'recorded',
|
|
453
|
+
'plan'
|
|
443
454
|
])
|
|
444
455
|
|
|
445
456
|
/**
|
|
@@ -2582,6 +2593,12 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
2582
2593
|
if (slotsBanner && first && typeof first === 'object') {
|
|
2583
2594
|
first.banners = [...(Array.isArray(first.banners) ? first.banners : []), slotsBanner]
|
|
2584
2595
|
}
|
|
2596
|
+
// The waves that will run, once, for the meter's board (design D3): the
|
|
2597
|
+
// state's batches, which a `maxParallel` override re-chunked, with the
|
|
2598
|
+
// planner's own deferral records.
|
|
2599
|
+
if (first && typeof first === 'object' && first.action !== 'halt') {
|
|
2600
|
+
first.plan = summarizePlan(state, { deferred: plan.deferred })
|
|
2601
|
+
}
|
|
2585
2602
|
return first
|
|
2586
2603
|
}
|
|
2587
2604
|
|
|
@@ -3033,6 +3050,13 @@ export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
|
3033
3050
|
if (verdict.claimDerived) banners.push(claimDerivedBanner())
|
|
3034
3051
|
if (verdict.overrides.length) banners.push(overrideBanner(verdict.overrides))
|
|
3035
3052
|
|
|
3053
|
+
// The lane that stopped early, plus anything the recorder found no result for
|
|
3054
|
+
// at all. Neither is ticked and neither is a failure. One expression for the
|
|
3055
|
+
// tally and for the step's `recorded`, so the two cannot disagree (design D2).
|
|
3056
|
+
const notAttemptedOf = counts => [
|
|
3057
|
+
...unattempted,
|
|
3058
|
+
...counts.recordedNotAttempted.filter(id => !unattempted.includes(id))
|
|
3059
|
+
]
|
|
3036
3060
|
verdict.waves.forEach(counts => {
|
|
3037
3061
|
manifest.waves.push({
|
|
3038
3062
|
wave: pending.wave,
|
|
@@ -3041,12 +3065,7 @@ export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
|
3041
3065
|
ok: counts.ok,
|
|
3042
3066
|
failed: counts.failed,
|
|
3043
3067
|
failedIds: counts.failedIds,
|
|
3044
|
-
|
|
3045
|
-
// for at all. Neither is ticked and neither is a failure.
|
|
3046
|
-
notAttempted: [
|
|
3047
|
-
...unattempted,
|
|
3048
|
-
...counts.recordedNotAttempted.filter(id => !unattempted.includes(id))
|
|
3049
|
-
]
|
|
3068
|
+
notAttempted: notAttemptedOf(counts)
|
|
3050
3069
|
})
|
|
3051
3070
|
})
|
|
3052
3071
|
|
|
@@ -3068,7 +3087,17 @@ export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
|
3068
3087
|
}
|
|
3069
3088
|
|
|
3070
3089
|
manifest.banners.push(...banners)
|
|
3071
|
-
|
|
3090
|
+
const recordedStep = decorate(ctx, nextRaw, manifest, { banners })
|
|
3091
|
+
// What this batch recorded, for the meter's board (design D2): set here, on
|
|
3092
|
+
// the step record-batch returns, so no other subcommand can emit it.
|
|
3093
|
+
if (recordedStep && typeof recordedStep === 'object') {
|
|
3094
|
+
recordedStep.recorded = {
|
|
3095
|
+
ok: verdict.tickIds,
|
|
3096
|
+
failed: verdict.waves.flatMap(counts => counts.failedIds),
|
|
3097
|
+
notAttempted: [...new Set(verdict.waves.flatMap(notAttemptedOf))]
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
return finishStep(ctx, manifest, recordedStep)
|
|
3072
3101
|
}
|
|
3073
3102
|
|
|
3074
3103
|
// --- run judge --------------------------------------------------------------
|
|
@@ -3479,7 +3508,12 @@ export function runReplan(ctx, { results, hostRecords }) {
|
|
|
3479
3508
|
ctx.warn('an implementer agent-spawn was not recorded on replan')
|
|
3480
3509
|
return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
|
|
3481
3510
|
}
|
|
3482
|
-
|
|
3511
|
+
const revisedStep = decorate(ctx, nextRaw, manifest)
|
|
3512
|
+
// The revised waves replace the adopted ones on the meter's board (design D3).
|
|
3513
|
+
if (revisedStep && typeof revisedStep === 'object' && revisedStep.action !== 'halt') {
|
|
3514
|
+
revisedStep.plan = summarizePlan(after)
|
|
3515
|
+
}
|
|
3516
|
+
return finishStep(ctx, manifest, revisedStep)
|
|
3483
3517
|
}
|
|
3484
3518
|
|
|
3485
3519
|
// --- run next ---------------------------------------------------------------
|