@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.
@@ -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. `/interlock:ship`
14
- // still decides whether to skip the classifier from the plan fingerprint, not
15
- // from this file, so a stale or hand-edited card cannot make a later run
16
- // reuse a plan it should have rebuilt. The card says that about itself, in
17
- // the file, because a markdown "resume" artifact that nothing consumes is
18
- // exactly the thing a reader would otherwise assume is wired in.
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
- // The lane that stopped early, plus anything the recorder found no result
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
- return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
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
- return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
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 ---------------------------------------------------------------