@renzrollon/interlock 1.4.0 → 1.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "interlock",
3
3
  "displayName": "Interlock",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
6
6
  "author": {
7
7
  "name": "Carl Rollon",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project are documented here.
5
5
  The format follows Keep a Changelog 1.1.0, and
6
6
  this project adheres to Semantic Versioning 2.0.0.
7
7
 
8
+ ## 1.4.1 — 2026-10-09
9
+
10
+ ### Changed
11
+
12
+ - **The ship meter pane reads as a timeline.** In 1.4.0 the `agents` section was a flat list in first-seen order: relay pings, the planner and lane workers were interleaved, nothing said when anything happened, and lanes that ran in parallel looked like lanes that ran in turn.
13
+ - **The `timeline` section replaces it.** There is one line per CLI step as it crossed, such as `run-batch · wave 2 · batch 1/2 · 3 in parallel` or `verify · wave 2 · skipped: <reason>`. The relay agent that carried the step is folded into that line: `cli <id>`, its served model, its request count and its duration.
14
+ - **Each step's agents** are indented beneath it in the order they started, with name, id, model, turn word and a dim requests-and-tokens line. A spawn no agent has picked up yet reads `waiting`. A relay whose line printed no step, such as `interlock limits`, stands at its own time, and agents nothing names are listed last under `unmatched`.
15
+ - **Every timed line has a local `HH:MM:SS` gutter**, blank where the clock gave no reading, never a guessed time.
16
+ - **The header** reads `started <date> <time> · last activity <time>`, replacing `last activity <ISO>`.
17
+ - **Durations** read `41.2s`, `2m 14s` or `1h 03m`, replacing `134.2s`.
18
+ - **Wave cards group their lanes by batch**, under a line with the batch's dispatch time and `N in parallel` or `1 lane`. The lanes are indented beneath it and no longer repeat the batch cell. A lane's note adds its duration once its turn ended.
19
+ - **Each section's body is indented** under its heading.
20
+ - **Relay token figures:** a relay's token breakdown is no longer drawn. The trajectory's `agent-result` line still carries it.
21
+ - **New modules:** the words and the order come from two new Node-free modules, `lib/meter-time.mjs` and `lib/meter-timeline.mjs`, pinned by `test/spine/meter-time.test.mjs` and `test/spine/meter-timeline.test.mjs`.
22
+ - **Layout probe:** a probe on 2.1.295 showed the gutter keeps a hanging indent when a line wraps, inside a card too. The pane is as tall as the engine seats it, and a long run scrolls.
23
+ - **Unchanged:** `interlock waves --format board` prints exactly what it printed before.
24
+
25
+ ### Fixed
26
+
27
+ - **A run resumed across the 1.4.0 upgrade does not re-run its lanes.** The 1.4.0 entry said it would, because the runtime caches an agent by its label. The runtime keys an agent on its prompt and its options together, the label among them, but a lane's label is the title its step carried, and on a resume the completed relays hand back the steps they first printed. So every lane the run had already dispatched is spawned under its old title and returns its saved result. `docs/04` now says the replay cache keys on the prompt and the options, not on the prompt text alone, and says why an upgrade does not rename the lanes a resume replays.
28
+ - **`docs/07` no longer says the board names a lane exactly as its spawn.** Since 1.4.0 the spawn is shown as `task 1.1 · …`, while the board, the briefing file, the worktree and the trajectory line name the lane by its label, `1.1`.
29
+
8
30
  ## 1.4.0 — 2026-10-09
9
31
 
10
32
  ### Changed
package/hooks/mod.mjs CHANGED
@@ -79,6 +79,17 @@
79
79
  // is a theme key from `lib/meter-palette.mjs` and names a meaning, never a
80
80
  // threshold: the engine's own figures carry none.
81
81
  //
82
+ // THE STEP TIMELINE. The agents list's place is a timeline: one line per step
83
+ // as it crossed, the agent whose Bash call printed it folded into that line,
84
+ // the agents a step spawned one indent beneath it in the order they started,
85
+ // relays no step names at their own time, and the agents nothing names last.
86
+ // Every timed line carries the local `HH:MM:SS` of a reading the module took
87
+ // itself (the launch's, a step's stamp, an agent's first request), blank
88
+ // where the clock gave none; durations are humanised. The order and the
89
+ // step's words are `lib/meter-timeline.mjs`'s, the time words
90
+ // `lib/meter-time.mjs`'s. Within a wave card the lanes sit beneath their
91
+ // batch's sub-header, timed by when that batch was first dispatched.
92
+ //
82
93
  // THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
83
94
  // design D5-D8). Outside a run the module draws two things, both from the
84
95
  // report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
@@ -113,6 +124,8 @@ import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch,
113
124
  import { LIMITS } from '../lib/limits.mjs'
114
125
  import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
115
126
  import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
127
+ import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
128
+ import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
116
129
  import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
117
130
  import {
118
131
  adoptPreflight,
@@ -155,8 +168,6 @@ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
155
168
  const NO_LANES_LINE = 'no lanes dispatched yet'
156
169
  const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
157
170
  const BOARD_FAILED_LINE = 'wave board could not be drawn'
158
- // The actions that dispatch a batch of lanes, and so place the board's cursor on one.
159
- const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
160
171
  // The session start's two panes, each opened by the command of its name (design D6).
161
172
  const PREFLIGHT_PANE = 'interlock-preflight'
162
173
  const HANDOFF_PANE = 'interlock-handoff'
@@ -186,6 +197,12 @@ const PATH_BEFORE = /[\s\/'"=]/
186
197
  const PATH_AFTER = /[\w.+-]/
187
198
  /** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
188
199
  const NAME_PROPS = { bold: true, color: PALETTE.identity }
200
+ const DIM = { dimColor: true }
201
+ // The timeline's gutter: `HH:MM:SS` and two spaces, and how far a section body
202
+ // and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
203
+ const GUTTER_COLUMNS = 10
204
+ const INDENT = 2
205
+ const NO_STEP_LINE = 'no step has crossed yet'
189
206
  /** The props the run header and each section heading are drawn with. */
190
207
  const HEADING_PROPS = { bold: true, color: PALETTE.accent }
191
208
  const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
@@ -230,6 +247,13 @@ function freshRun() {
230
247
  // activity (null until the clock answers), the word it makes, and the one
231
248
  // interval that recomputes it, known by its handle and its generation.
232
249
  lastActivityAt: null,
250
+ // The reading the last stamp took (null when the clock failed), and the
251
+ // launch's own: the timeline's times, never guessed.
252
+ stampedAt: null,
253
+ launchedAt: null,
254
+ // Every step applied, in crossing order, and the time each batch was first dispatched, by `<waveIndex>:<batchIndex>`.
255
+ steps: [],
256
+ batchAt: new Map(),
233
257
  quietWord: null,
234
258
  tick: null,
235
259
  generation: 0,
@@ -308,7 +332,8 @@ function position() {
308
332
  if (run.change) parts.push(run.change)
309
333
  if (run.action) parts.push(run.action)
310
334
  if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
311
- if (isInt(run.batchIndex) && isInt(run.batchCount)) parts.push(`batch ${run.batchIndex + 1}/${run.batchCount}`)
335
+ const batch = batchText(run.batchIndex, run.batchCount)
336
+ if (batch) parts.push(batch)
312
337
  return parts.join(' · ')
313
338
  }
314
339
 
@@ -376,6 +401,7 @@ async function stamp($) {
376
401
  const held = run
377
402
  const now = await clockNow($)
378
403
  if (held !== run) return false
404
+ run.stampedAt = now
379
405
  if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
380
406
  return setWord(null)
381
407
  }
@@ -813,13 +839,24 @@ function agentOf(id) {
813
839
  // The two words after `interlock` in the first driver line it ran while no row named it.
814
840
  relay: null,
815
841
  // Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
816
- endToasted: false
842
+ endToasted: false,
843
+ // The reading at its first request, taken once (null when the clock failed then), and at its turn end.
844
+ stepped: false,
845
+ firstAt: null,
846
+ endAt: null
817
847
  }
818
848
  run.agents.set(id, a)
819
849
  }
820
850
  return a
821
851
  }
822
852
 
853
+ /** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
854
+ function firstRequest(a) {
855
+ if (a.stepped) return
856
+ a.stepped = true
857
+ a.firstAt = run.stampedAt
858
+ }
859
+
823
860
  /** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
824
861
  function addUsage(a, usage) {
825
862
  a.requests++
@@ -905,7 +942,34 @@ function takeBoard($, record) {
905
942
  if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
906
943
  }
907
944
 
908
- async function applyRecord($, record) {
945
+ /**
946
+ * One step as the timeline draws it, its fields the record's own: the time the
947
+ * stamp read, its position, a skip or halt reason, the size of a plan summary
948
+ * it carried, the agent whose Bash call printed it, and the spawns it named.
949
+ */
950
+ function noteStep(record, relayId, spawns) {
951
+ const step = {
952
+ n: run.steps.length,
953
+ at: run.stampedAt,
954
+ action: record.action,
955
+ wave: record.wave,
956
+ waveIndex: record.waveIndex,
957
+ batchIndex: record.batchIndex,
958
+ batchCount: record.batchCount,
959
+ skipped: record.skipped,
960
+ reason: record.reason,
961
+ planWaves: isPlanSummary(record.plan) ? planWaveCount(record.plan) : null,
962
+ relayId: str(relayId),
963
+ spawns
964
+ }
965
+ run.steps.push(step)
966
+ if (BATCH_ACTIONS.has(record.action) && isInt(record.waveIndex) && isInt(record.batchIndex)) {
967
+ const at = `${record.waveIndex}:${record.batchIndex}`
968
+ if (!run.batchAt.has(at)) run.batchAt.set(at, step.at)
969
+ }
970
+ }
971
+
972
+ async function applyRecord($, record, relayId) {
909
973
  const held = run
910
974
  await stamp($)
911
975
  if (held !== run) return
@@ -915,8 +979,10 @@ async function applyRecord($, record) {
915
979
  if (record.wave !== undefined) run.wave = record.wave
916
980
  run.batchIndex = record.batchIndex
917
981
  run.batchCount = record.batchCount
982
+ const spawns = []
918
983
  for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
919
984
  if (!s || typeof s !== 'object' || !str(s.label)) continue
985
+ spawns.push({ label: s.label, title: str(s.title) || s.label, sha: str(s.promptSha256) })
920
986
  run.rows.set(s.label, {
921
987
  label: s.label,
922
988
  title: str(s.title) || s.label,
@@ -930,6 +996,7 @@ async function applyRecord($, record) {
930
996
  batch: BATCH_ACTIONS.has(record.action)
931
997
  })
932
998
  }
999
+ noteStep(record, relayId, spawns)
933
1000
  takeBoard($, record)
934
1001
  for (const banner of Array.isArray(record.banners) ? record.banners : []) {
935
1002
  if (typeof banner !== 'string' || !banner) continue
@@ -1106,39 +1173,118 @@ function sessionLines(usage, failure) {
1106
1173
  }
1107
1174
 
1108
1175
  /**
1109
- * `last activity <ISO>` or `last activity unknown`, then the quiet word while a
1110
- * live run is quiet, as the line and the word apart. The word is recomputed
1111
- * from the clock at each draw, so a pane opened after the host stopped the
1112
- * interval still shows it (design D2, D5).
1176
+ * `started <local date and time> · last activity <local time>` (the start left
1177
+ * out when the launch read no time, `last activity unknown` when no stamp did),
1178
+ * then the quiet word while a live run is quiet, as the line and the word
1179
+ * apart. The word is recomputed from the clock at each draw, so a pane opened
1180
+ * after the host stopped the interval still shows it (design D2, D5).
1113
1181
  */
1114
1182
  async function lastActivityText($) {
1115
- if (run.lastActivityAt === null) return { line: 'last activity unknown', word: null }
1116
- const at = new Date(run.lastActivityAt).toISOString()
1183
+ const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
1184
+ if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
1117
1185
  const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
1118
- return { line: `last activity ${at}`, word }
1186
+ return { line: `${started}last activity ${clockText(run.lastActivityAt)}`, word }
1119
1187
  }
1120
1188
 
1189
+ /** An agent id as a line shows it: its first eight characters. */
1190
+ const id8 = id => String(id).slice(0, 8)
1191
+
1192
+ /** `1 request`, `N requests`. */
1193
+ const requestsText = a => (a.requests === 1 ? '1 request' : `${a.requests} requests`)
1194
+
1195
+ /** An agent's turn: `running`, else the host's word and, once reported, how long the turn took. */
1196
+ const turnText = a =>
1197
+ a.reason ? `${a.reason}${a.durationMs !== null ? ` ${durationText(a.durationMs)}` : ''}` : 'running'
1198
+
1199
+ /** Whether the host ended the agent's turn without an answer. */
1200
+ const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
1201
+
1121
1202
  /**
1122
- * An agent's first line: its name, its id and models dim, then its turn word.
1123
- * A joined agent is named in the identity colour, a relay dim, an unmatched
1124
- * agent dim and italic. The turn word is the host's, coloured by what it means;
1125
- * a relay's running or answered turn stays dim, and only its abnormal end is coloured.
1203
+ * An agent's first line, one wrapping text: its name, its id and models dim,
1204
+ * then its turn word. A joined agent is named in the identity colour, a relay
1205
+ * dim, an unmatched agent dim and italic. The turn word is the host's, coloured
1206
+ * by what it means; a relay's running or answered turn stays dim, and only its
1207
+ * abnormal end is coloured.
1126
1208
  */
1127
- function agentLine(Text, a, done) {
1128
- const row = rowForAgent(a)
1129
- const name = agentName(a)
1130
- const nameProps = row ? NAME_PROPS : a.relay ? { dimColor: true } : { dimColor: true, italic: true }
1131
- const quiet = !row && a.relay && (!a.reason || a.reason === 'answer')
1209
+ function agentLine(Text, a, name, kind) {
1210
+ const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
1211
+ const quiet = kind === 'relay' && !abnormal(a)
1132
1212
  return h(
1133
1213
  Text,
1134
- null,
1135
- h(Text, nameProps, name || UNMATCHED),
1136
- h(Text, { dimColor: true }, ` · ${a.id} · ${a.models.join(', ') || 'model not reported'}`),
1214
+ { wrap: 'wrap' },
1215
+ h(Text, nameProps, name),
1216
+ h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
1137
1217
  ' · ',
1138
- h(Text, quiet ? { dimColor: true } : turnProps(a.reason), done)
1218
+ h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
1139
1219
  )
1140
1220
  }
1141
1221
 
1222
+ /**
1223
+ * One timed line (layout probe P3): the local time in a fixed dim gutter,
1224
+ * blank when no time was read, then the content in a column that wraps beside
1225
+ * it, `depth` steps in. Each content line is one Text with its coloured words
1226
+ * nested inside it, never a row of sibling Texts.
1227
+ */
1228
+ function gutterRow(Box, Text, key, at, depth, ...content) {
1229
+ return h(
1230
+ Box,
1231
+ { key, flexDirection: 'row' },
1232
+ h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
1233
+ h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
1234
+ )
1235
+ }
1236
+
1237
+ /**
1238
+ * A step's line: its own words, then the agent whose Bash call printed it,
1239
+ * dim, with its served models, its requests and, once its turn ended, how long
1240
+ * it took; the relay's turn word only when the host ended it without an answer.
1241
+ */
1242
+ function stepLine(Text, step) {
1243
+ const spans = [stepText(step)]
1244
+ if (step.relayId) {
1245
+ const a = run.agents.get(step.relayId)
1246
+ const figures = a ? ` · ${a.models.join(', ') || 'model not reported'} · ${requestsText(a)}` : ''
1247
+ spans.push(h(Text, DIM, ` · cli ${id8(step.relayId)}${figures}`))
1248
+ if (a && abnormal(a)) spans.push(' · ', h(Text, turnProps(a.reason), turnText(a)))
1249
+ else if (a && a.durationMs !== null) spans.push(h(Text, DIM, ` · ${durationText(a.durationMs)}`))
1250
+ }
1251
+ return h(Text, { wrap: 'wrap' }, ...spans)
1252
+ }
1253
+
1254
+ /**
1255
+ * The timeline section: `lib/meter-timeline.mjs`'s entries, each a gutter
1256
+ * line. An agent a step spawned and an agent no step names keep the requests
1257
+ * and tokens line beneath their own; a relay's figures ride its step's line or
1258
+ * its own one line, and its token breakdown is the trajectory's.
1259
+ */
1260
+ function drawTimeline($, e) {
1261
+ const { Box, Text } = $.ui.resolve(e)
1262
+ const entries = timeline({
1263
+ steps: run.steps,
1264
+ agents: [...run.agents.values()],
1265
+ launch: { at: run.launchedAt, name: run.workflowName }
1266
+ })
1267
+ const figures = a => h(Text, { dimColor: true, wrap: 'wrap' }, `${requestsText(a)} · ${tokensText(a)}`)
1268
+ const out = []
1269
+ for (const entry of entries) {
1270
+ const row = (...content) => gutterRow(Box, Text, entry.key, entry.at, entry.depth, ...content)
1271
+ if (entry.kind === 'launch') out.push(row(h(Text, { wrap: 'wrap' }, `launch · ${entry.name || 'interlock:ship'}`)))
1272
+ else if (entry.kind === 'step') out.push(row(stepLine(Text, entry.step)))
1273
+ else if (entry.kind === 'spawn' && entry.agent) {
1274
+ out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
1275
+ } else if (entry.kind === 'spawn') {
1276
+ out.push(row(h(Text, { wrap: 'wrap' }, h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting'))))
1277
+ } else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
1278
+ else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key }, h(Text, DIM, 'unmatched')))
1279
+ else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
1280
+ }
1281
+ if (entries.some(entry => entry.kind === 'unmatched')) {
1282
+ out.push(h(Box, { key: 'agents-unmatched', paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
1283
+ }
1284
+ if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step' }, h(Text, DIM, NO_STEP_LINE)))
1285
+ return out
1286
+ }
1287
+
1142
1288
  /** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
1143
1289
  function flatRow($, e, r) {
1144
1290
  const { Box, Text } = $.ui.resolve(e)
@@ -1180,7 +1326,7 @@ function laneNotes() {
1180
1326
  for (const r of run.rows.values()) {
1181
1327
  const a = agentForSha(r.sha)
1182
1328
  const served = a && a.models.length ? a.models.join(', ') : '?'
1183
- notes[r.label] = `served ${served}${a ? ` · ${a.reason || 'running'}` : ''}`
1329
+ notes[r.label] = `served ${served}${a ? ` · ${turnText(a)}` : ''}`
1184
1330
  }
1185
1331
  return notes
1186
1332
  }
@@ -1193,11 +1339,13 @@ function boardKey(key) {
1193
1339
  return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
1194
1340
  }
1195
1341
 
1196
- /** The width a card and the renderer share: the body, capped at the published default. */
1342
+ /**
1343
+ * The width a card and the renderer share: the section body (the pane's body
1344
+ * less the body's indent beneath its heading), capped at the published default.
1345
+ */
1197
1346
  function boardColumns(e) {
1198
- const body = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0
1199
- ? e.props.bodyColumns
1200
- : LIMITS.waveBoardDefaultColumns
1347
+ const pane = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0 ? e.props.bodyColumns : null
1348
+ const body = pane === null ? LIMITS.waveBoardDefaultColumns : Math.max(1, pane - INDENT)
1201
1349
  return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
1202
1350
  }
1203
1351
 
@@ -1226,14 +1374,14 @@ function idSpans(Text, parts) {
1226
1374
  }
1227
1375
 
1228
1376
  /**
1229
- * A lane's batch, label, model, tier and effort cells, space-joined as the
1230
- * board prints them, the label a span in the identity colour.
1377
+ * A lane's label, model, tier and effort cells, space-joined as the board
1378
+ * prints them, the label a span in the identity colour. The batch cell is the
1379
+ * sub-header the lane sits under, so the lane does not repeat it.
1231
1380
  */
1232
1381
  function headSpans(Text, parts) {
1233
- const before = [parts.batch].filter(Boolean).join(' ')
1234
1382
  const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
1235
- if (!parts.label) return [[before, after].filter(Boolean).join(' ')]
1236
- return [before ? `${before} ` : '', h(Text, NAME_PROPS, parts.label), after ? ` ${after}` : ''].filter(span => span !== '')
1383
+ if (!parts.label) return [after]
1384
+ return [h(Text, NAME_PROPS, parts.label), ...(after ? [` ${after}`] : [])]
1237
1385
  }
1238
1386
 
1239
1387
  /** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
@@ -1242,10 +1390,11 @@ const isCurrentLane = parts =>
1242
1390
  (parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
1243
1391
 
1244
1392
  /**
1245
- * A lane in its card: the fixed cells as one Text, the gist wrapping beneath,
1246
- * then the host's note, dim. The coloured words are spans nested in that one
1247
- * Text, because a row of separate elements shrinks each into its own column
1248
- * and breaks words apart once the line is wider than the card.
1393
+ * A lane in its card, one step in beneath its batch's sub-header with a blank
1394
+ * gutter: the fixed cells as one Text, the gist wrapping beneath, then the
1395
+ * host's note, dim. The coloured words are spans nested in that one Text,
1396
+ * because a row of separate elements shrinks each into its own column and
1397
+ * breaks words apart once the line is wider than the card.
1249
1398
  */
1250
1399
  function drawLane($, e, row) {
1251
1400
  const { Box, Text } = $.ui.resolve(e)
@@ -1261,9 +1410,35 @@ function drawLane($, e, row) {
1261
1410
  const kids = [
1262
1411
  h(Text, { wrap: 'wrap' }, ...line),
1263
1412
  parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
1264
- parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
1413
+ parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true, wrap: 'wrap' }, parts.note)) : null
1265
1414
  ].filter(Boolean)
1266
- return h(Box, { key, flexDirection: 'column' }, ...kids)
1415
+ return gutterRow(Box, Text, key, null, 1, ...kids)
1416
+ }
1417
+
1418
+ /**
1419
+ * A card's lanes, grouped by the batch the board printed for each: before each
1420
+ * group a gutter line with the time that batch was first dispatched and how
1421
+ * many lanes it runs side by side. It carries no state word: the cursor card's
1422
+ * border already marks where the run is.
1423
+ */
1424
+ function drawBatches($, e, index, lanes) {
1425
+ const { Box, Text } = $.ui.resolve(e)
1426
+ const groups = new Map()
1427
+ for (const row of lanes) {
1428
+ const batch = row.parts && typeof row.parts.batch === 'string' ? row.parts.batch : null
1429
+ if (!groups.has(batch)) groups.set(batch, [])
1430
+ groups.get(batch).push(row)
1431
+ }
1432
+ const out = []
1433
+ for (const [batch, rows] of groups) {
1434
+ const k = batch && /^b\d+$/.test(batch) ? batch.slice(1) : null
1435
+ if (k !== null) {
1436
+ const at = run.batchAt.has(`${index}:${k}`) ? run.batchAt.get(`${index}:${k}`) : null
1437
+ out.push(gutterRow(Box, Text, `wave-batch-${index}-${k}`, at, 0, h(Text, { wrap: 'wrap' }, `${batch} · ${laneCountText(rows.length)}`)))
1438
+ }
1439
+ out.push(...rows.map(row => drawLane($, e, row)))
1440
+ }
1441
+ return out
1267
1442
  }
1268
1443
 
1269
1444
  function drawBoardCards($, e, board, width) {
@@ -1272,10 +1447,12 @@ function drawBoardCards($, e, board, width) {
1272
1447
  const out = []
1273
1448
  let index = null
1274
1449
  let kids = null
1450
+ let lanes = null
1275
1451
  // Whether the card being filled holds a lane at the cursor: its border is then the current colour.
1276
1452
  let current = false
1277
1453
  const flush = () => {
1278
1454
  if (index === null) return
1455
+ kids.push(...drawBatches($, e, index, lanes))
1279
1456
  out.push(
1280
1457
  h(
1281
1458
  Box,
@@ -1294,6 +1471,7 @@ function drawBoardCards($, e, board, width) {
1294
1471
  )
1295
1472
  index = null
1296
1473
  kids = null
1474
+ lanes = null
1297
1475
  current = false
1298
1476
  }
1299
1477
  for (const row of board) {
@@ -1302,14 +1480,14 @@ function drawBoardCards($, e, board, width) {
1302
1480
  flush()
1303
1481
  index = row.key.slice('wave:'.length)
1304
1482
  kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
1483
+ lanes = []
1305
1484
  continue
1306
1485
  }
1307
1486
  if (row.key.startsWith('lane:')) {
1308
- const lane = drawLane($, e, row)
1309
- if (kids) {
1310
- kids.push(lane)
1487
+ if (lanes) {
1488
+ lanes.push(row)
1311
1489
  if (isCurrentLane(row.parts)) current = true
1312
- } else out.push(lane)
1490
+ } else out.push(drawLane($, e, row))
1313
1491
  continue
1314
1492
  }
1315
1493
  flush()
@@ -1358,6 +1536,8 @@ async function drawPane($, e) {
1358
1536
  const { Box, Text } = $.ui.resolve(e)
1359
1537
  const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
1360
1538
  const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
1539
+ // Each section's body sits one indent in beneath its heading.
1540
+ const body = (key, ...kids) => h(Box, { key, flexDirection: 'column', paddingLeft: INDENT }, ...kids)
1361
1541
  // The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
1362
1542
  const guardLine = async () => {
1363
1543
  const guard = await launchGuardText($)
@@ -1382,23 +1562,10 @@ async function drawPane($, e) {
1382
1562
  out.push(h(Text, { dimColor: true }, RECORD_LINE))
1383
1563
 
1384
1564
  out.push(heading('waves'))
1385
- out.push(...drawWaves($, e))
1565
+ out.push(body('waves-body', ...drawWaves($, e)))
1386
1566
 
1387
- out.push(heading('agents'))
1388
- if (!run.agents.size) out.push(h(Text, { dimColor: true }, 'no agent has reported a request yet'))
1389
- for (const a of run.agents.values()) {
1390
- const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
1391
- const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
1392
- out.push(
1393
- h(
1394
- Box,
1395
- { key: `agent-${a.id}`, flexDirection: 'column' },
1396
- h(Box, null, agentLine(Text, a, done)),
1397
- h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
1398
- )
1399
- )
1400
- }
1401
- if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
1567
+ out.push(heading('timeline'))
1568
+ out.push(body('timeline-body', ...drawTimeline($, e)))
1402
1569
 
1403
1570
  // One plain read feeds the session lines and the plan windows; a breakdown
1404
1571
  // is never asked for, because only the plain call is free (design D4).
@@ -1412,36 +1579,35 @@ async function drawPane($, e) {
1412
1579
  }
1413
1580
  const [contextLine, costLine] = sessionLines(usage, failure)
1414
1581
  out.push(heading('session'))
1415
- out.push(keyed('session-context', contextLine))
1416
- out.push(keyed('session-cost', costLine))
1582
+ out.push(body('session-body', keyed('session-context', contextLine), keyed('session-cost', costLine)))
1417
1583
 
1418
1584
  out.push(heading('plan windows'))
1419
1585
  const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
1420
- if (failure !== null) out.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1421
- else if (!windows.length) out.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1586
+ const planLines = []
1587
+ if (failure !== null) planLines.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
1588
+ else if (!windows.length) planLines.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
1422
1589
  for (const w of windows) {
1423
1590
  if (!w || typeof w !== 'object') continue
1424
1591
  const kind = String(w.kind)
1425
- out.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1592
+ planLines.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
1426
1593
  }
1594
+ out.push(body('plan-windows-body', ...planLines))
1427
1595
 
1428
1596
  out.push(heading('banners'))
1429
1597
  out.push(
1430
- h(
1431
- Box,
1432
- { key: 'banners', flexDirection: 'column' },
1598
+ body(
1599
+ 'banners',
1433
1600
  ...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
1434
1601
  )
1435
1602
  )
1436
1603
 
1437
1604
  out.push(heading('refusals'))
1438
1605
  const denials = guardDenialLine(run.guardDenials)
1439
- if (denials) out.push(keyed('guard-denials', denials, { color: PALETTE.alarm }))
1440
- out.push(await guardLine())
1606
+ out.push(body('refusals-body', ...(denials ? [keyed('guard-denials', denials, { color: PALETTE.alarm })] : []), await guardLine()))
1441
1607
 
1442
1608
  if (run.summary) {
1443
1609
  out.push(heading('close summary'))
1444
- out.push(h(Box, { key: 'summary', flexDirection: 'column' }, h(Text, null, run.summary)))
1610
+ out.push(body('summary', h(Text, null, run.summary)))
1445
1611
  }
1446
1612
  return h(Box, { flexDirection: 'column' }, ...out)
1447
1613
  }
@@ -1456,7 +1622,7 @@ export function register(on) {
1456
1622
  try {
1457
1623
  await $.command.register({
1458
1624
  name: PANE,
1459
- description: 'Open the Interlock ship meter: the live run, its waves, agents, plan windows and banners',
1625
+ description: 'Open the Interlock ship meter: the live run, its waves, step timeline, plan windows and banners',
1460
1626
  immediate: true
1461
1627
  })
1462
1628
  } catch (err) {
@@ -1573,6 +1739,7 @@ export function register(on) {
1573
1739
  run.workflowName = str(r.result.workflowName)
1574
1740
  run.transcriptDir = str(r.result.transcriptDir)
1575
1741
  run.lastActivityAt = now
1742
+ run.launchedAt = now
1576
1743
  $.ui.invalidate('ui.render')
1577
1744
  const placed = await openPane($)
1578
1745
  if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
@@ -1608,7 +1775,7 @@ export function register(on) {
1608
1775
  try {
1609
1776
  const { record, problem } = readRecord(r.text)
1610
1777
  if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
1611
- else if (held === run) await applyRecord($, record)
1778
+ else if (held === run) await applyRecord($, record, str(e.agentId))
1612
1779
  else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
1613
1780
  } catch (err) {
1614
1781
  $.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
@@ -1642,7 +1809,9 @@ export function register(on) {
1642
1809
  on('turn.step', async function* ($, e, next) {
1643
1810
  if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
1644
1811
  // A run agent's request starting, and its answer arriving, are each activity (design D1).
1812
+ const held = run
1645
1813
  if (await stamp($)) redrawWord($)
1814
+ if (held === run) firstRequest(agentOf(e.agentId))
1646
1815
  const result = yield* next(e)
1647
1816
  if (await stamp($)) redrawWord($)
1648
1817
  try {
@@ -1670,7 +1839,9 @@ export function register(on) {
1670
1839
  $.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
1671
1840
  }
1672
1841
  }
1842
+ const held = run
1673
1843
  if (await stamp($)) redrawWord($)
1844
+ if (held === run) a.endAt = run.stampedAt
1674
1845
  $.ui.invalidate('ui.render')
1675
1846
  }
1676
1847
  return next(e)
@@ -0,0 +1,43 @@
1
+ // The ship meter's time words: the clock reading a line was stamped with, and
2
+ // how long a turn took, as a person reads them.
3
+ //
4
+ // Each reading is the engine's own (`$.clock.now()` in the hooks module), so a
5
+ // time is either a real reading or absent: absent draws as nothing, never as a
6
+ // guessed time. The wall-clock words are the local time of the machine the
7
+ // engine runs on, from `Date`'s own local fields. The duration bands are unit
8
+ // conversions (seconds, minutes, hours), not thresholds: no band is a verdict
9
+ // on the figure it formats.
10
+ //
11
+ // Pure and Node-free: the hooks module runs in the engine with no Node, and
12
+ // imports this file.
13
+
14
+ const isTime = ms => typeof ms === 'number' && Number.isFinite(ms)
15
+ const pad = n => String(n).padStart(2, '0')
16
+
17
+ /** The local wall-clock time of a reading, `HH:MM:SS`; `''` for no reading. */
18
+ export function clockText(ms) {
19
+ if (!isTime(ms)) return ''
20
+ const d = new Date(ms)
21
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
22
+ }
23
+
24
+ /** The local date and time of a reading, `YYYY-MM-DD HH:MM:SS`; `''` for no reading. */
25
+ export function stampText(ms) {
26
+ if (!isTime(ms)) return ''
27
+ const d = new Date(ms)
28
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clockText(ms)}`
29
+ }
30
+
31
+ /**
32
+ * A duration in milliseconds as a person reads it: under a minute, seconds to
33
+ * one decimal, cut down (`41.2s`, `0.8s`, never `60.0s`); under an hour, whole
34
+ * minutes and seconds (`2m 14s`); else hours and minutes (`1h 03m`). `''` for
35
+ * no figure, a negative one or one that is not a finite number.
36
+ */
37
+ export function durationText(ms) {
38
+ if (!isTime(ms) || ms < 0) return ''
39
+ if (ms < 60_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`
40
+ const seconds = Math.floor(ms / 1000)
41
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`
42
+ return `${Math.floor(seconds / 3600)}h ${pad(Math.floor((seconds % 3600) / 60))}m`
43
+ }
@@ -0,0 +1,161 @@
1
+ // The ship meter's step timeline: what each step the CLI printed says, and the
2
+ // order the pane lays the steps, the agents they spawned and the agents no
3
+ // step names in.
4
+ //
5
+ // A step's words are its own fields, verbatim: the action, its position, how
6
+ // many lanes a batch dispatched, a skipped verification's reason, a halt's
7
+ // reason and the plan size a step relayed. No word here is a verdict, a cause
8
+ // or a state the step did not carry.
9
+ //
10
+ // The order is pure data over plain inputs, so it is pinned under Node
11
+ // (`test/spine/meter-timeline.test.mjs`) and the hooks module only draws it:
12
+ //
13
+ // - The top level is the launch, every step and every relay agent no step
14
+ // names, laid by time. An entry with a time sorts by it, ties keeping their
15
+ // input order (the launch, then the steps as they crossed, then the relays as
16
+ // first seen); an entry with no time follows every timed one, in input order.
17
+ // - Beneath a step, at depth 1, the agents it spawned: a spawn is joined to an
18
+ // agent by its briefing hash, one agent per spawn in step order (an agent
19
+ // beyond the spawns that carry its hash stays with the last of them). The
20
+ // joined spawns are laid by their agent's first request time, by the same
21
+ // rule; the spawns no agent has joined yet follow, `waiting`, in the step's
22
+ // spawn order. A step's relay is drawn on the step's own line and nowhere
23
+ // else.
24
+ // - Last, the agents nothing names, under one head, in first-seen order.
25
+ //
26
+ // Pure and Node-free: the hooks module runs in the engine with no Node, and
27
+ // imports this file.
28
+
29
+ /** The actions that dispatch a batch of lanes. */
30
+ export const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
31
+
32
+ const isInt = v => Number.isInteger(v)
33
+ const isTime = v => typeof v === 'number' && Number.isFinite(v)
34
+ const str = v => (typeof v === 'string' && v ? v : null)
35
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
36
+
37
+ /** `batch <j>/<k>`, one-based, as the status line says it; `null` when the step carries no position. */
38
+ export function batchText(batchIndex, batchCount) {
39
+ return isInt(batchIndex) && isInt(batchCount) ? `batch ${batchIndex + 1}/${batchCount}` : null
40
+ }
41
+
42
+ /** How many lanes run side by side: `N in parallel`, `1 lane`, or `null` for none. */
43
+ export function laneCountText(n) {
44
+ if (!isInt(n) || n < 1) return null
45
+ return n === 1 ? '1 lane' : `${n} in parallel`
46
+ }
47
+
48
+ /** The waves a plan summary holds, its test wave counted; `null` for anything that is not a summary. */
49
+ export function planWaveCount(plan) {
50
+ if (plan === null || typeof plan !== 'object' || !Array.isArray(plan.waves)) return null
51
+ return plan.waves.length + (plan.testWave ? 1 : 0)
52
+ }
53
+
54
+ /** A step's words, from its own fields only. */
55
+ export function stepText(step) {
56
+ const s = step && typeof step === 'object' ? step : {}
57
+ const parts = [String(s.action)]
58
+ if (s.wave !== undefined && s.wave !== null) parts.push(`wave ${s.wave}`)
59
+ const batch = batchText(s.batchIndex, s.batchCount)
60
+ if (batch) parts.push(batch)
61
+ if (BATCH_ACTIONS.has(s.action)) {
62
+ const lanes = laneCountText(Array.isArray(s.spawns) ? s.spawns.length : 0)
63
+ if (lanes) parts.push(lanes)
64
+ }
65
+ if (s.action === 'verify' && s.skipped === true) parts.push(str(s.reason) ? `skipped: ${s.reason}` : 'skipped')
66
+ if (s.action === 'halt' && str(s.reason)) parts.push(s.reason)
67
+ if (isInt(s.planWaves)) parts.push(`plan: ${plural(s.planWaves, 'wave')}`)
68
+ return parts.join(' · ')
69
+ }
70
+
71
+ /** The entries with a time laid by it, ties in input order; then the entries with none, in input order. */
72
+ function byTime(entries) {
73
+ const timed = entries.map((entry, i) => ({ entry, i })).filter(({ entry }) => isTime(entry.at))
74
+ timed.sort((a, b) => a.entry.at - b.entry.at || a.i - b.i)
75
+ return [...timed.map(({ entry }) => entry), ...entries.filter(entry => !isTime(entry.at))]
76
+ }
77
+
78
+ /**
79
+ * The timeline as ordered display entries, each `{ key, kind, at, depth, ... }`:
80
+ * `launch` (`name`), `step` (`step`), `spawn` (`step`, `spawn`, `agent` or
81
+ * `null` while it waits), `relay` (`agent`, a relay agent no step names),
82
+ * `unmatched-head`, and `unmatched` (`agent`). An agent's entry is keyed
83
+ * `agent-<id>`, a waiting spawn's `spawn-<n>-<label>`, a step's `step-<n>`.
84
+ *
85
+ * @param {{
86
+ * steps?: {n: number, at: number|null, action: string, wave?: unknown, waveIndex?: unknown, batchIndex?: unknown,
87
+ * batchCount?: unknown, skipped?: unknown, reason?: unknown, planWaves?: number|null, relayId?: string|null,
88
+ * spawns: {label: string, title: string, sha: string|null}[]}[],
89
+ * agents?: {id: string, sha?: string|null, relay?: string|null, firstAt?: number|null}[],
90
+ * launch?: {at: number|null, name?: string|null}|null
91
+ * }} input
92
+ * @returns {object[]}
93
+ */
94
+ export function timeline({ steps = [], agents = [], launch = null } = {}) {
95
+ const relays = new Set(steps.map(step => str(step.relayId)).filter(Boolean))
96
+
97
+ // Each spawn carrying a hash, in step order, and the agents carrying it, first seen first.
98
+ const slots = new Map()
99
+ const spawnsOf = step => (Array.isArray(step.spawns) ? step.spawns : [])
100
+ steps.forEach((step, i) =>
101
+ spawnsOf(step).forEach((spawn, j) => {
102
+ const sha = str(spawn.sha)
103
+ if (!sha) return
104
+ if (!slots.has(sha)) slots.set(sha, [])
105
+ slots.get(sha).push({ pos: `${i}:${j}`, agents: [] })
106
+ })
107
+ )
108
+ const placed = new Set()
109
+ const counts = new Map()
110
+ for (const agent of agents) {
111
+ const sha = str(agent.sha)
112
+ if (!sha || relays.has(agent.id) || !slots.has(sha)) continue
113
+ const held = slots.get(sha)
114
+ const at = counts.get(sha) || 0
115
+ held[Math.min(at, held.length - 1)].agents.push(agent)
116
+ counts.set(sha, at + 1)
117
+ placed.add(agent.id)
118
+ }
119
+ const joinedBy = new Map()
120
+ for (const held of slots.values()) for (const slot of held) joinedBy.set(slot.pos, slot.agents)
121
+
122
+ const top = []
123
+ if (launch && typeof launch === 'object') {
124
+ top.push({ key: 'launch', kind: 'launch', at: isTime(launch.at) ? launch.at : null, depth: 0, name: str(launch.name) })
125
+ }
126
+ for (const step of steps) top.push({ key: `step-${step.n}`, kind: 'step', at: isTime(step.at) ? step.at : null, depth: 0, step })
127
+ const orphans = agents.filter(agent => str(agent.relay) && !relays.has(agent.id) && !placed.has(agent.id))
128
+ for (const agent of orphans) {
129
+ top.push({ key: `agent-${agent.id}`, kind: 'relay', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 0, agent })
130
+ placed.add(agent.id)
131
+ }
132
+
133
+ const out = []
134
+ for (const entry of byTime(top)) {
135
+ out.push(entry)
136
+ if (entry.kind !== 'step') continue
137
+ const i = steps.indexOf(entry.step)
138
+ const joined = []
139
+ const waiting = []
140
+ for (const [j, spawn] of spawnsOf(entry.step).entries()) {
141
+ const held = joinedBy.get(`${i}:${j}`) || []
142
+ if (!held.length) {
143
+ waiting.push({ key: `spawn-${entry.step.n}-${spawn.label}`, kind: 'spawn', at: null, depth: 1, step: entry.step, spawn, agent: null })
144
+ }
145
+ for (const agent of held) {
146
+ const at = isTime(agent.firstAt) ? agent.firstAt : null
147
+ joined.push({ key: `agent-${agent.id}`, kind: 'spawn', at, depth: 1, step: entry.step, spawn, agent })
148
+ }
149
+ }
150
+ out.push(...byTime(joined), ...waiting)
151
+ }
152
+
153
+ const unmatched = agents.filter(agent => !relays.has(agent.id) && !placed.has(agent.id))
154
+ if (unmatched.length) {
155
+ out.push({ key: 'agents-unmatched-head', kind: 'unmatched-head', at: null, depth: 0 })
156
+ for (const agent of unmatched) {
157
+ out.push({ key: `agent-${agent.id}`, kind: 'unmatched', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 1, agent })
158
+ }
159
+ }
160
+ return out
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
5
5
  "type": "module",
6
6
  "engines": {