@leftoff/cli 0.9.17 → 0.9.18-beta.10

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.
@@ -14,7 +14,7 @@
14
14
  // each event is registered once per matcher.
15
15
  import {
16
16
  DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
17
- parseCheckpointReply, retryCheckpointPrompt, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
17
+ parseCheckpointReply, retryCheckpointPrompt, mergeDecided, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
@@ -26,9 +26,13 @@ const PANE_ID = 'checkpoints'
26
26
  const POST_TIMEOUT_MS = 800
27
27
  const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
+ const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
30
+ const SKIP_GRACE_MS = 4000 // 2026-11-03 how long a mod event waits for its classic twin before that classic event counts as passed over
31
+ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can be slow)
32
+ const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
29
33
 
30
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
31
- const MOD_VERSION = '0.9.17' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.10' // keep in step with .claude-plugin/plugin.json
32
36
 
33
37
  const st = {
34
38
  internal: false,
@@ -69,6 +73,15 @@ const st = {
69
73
  listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
70
74
  listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
71
75
  paneNeeds: 0, // sessions that need you (the pane's top line)
76
+ turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
77
+ stopAt: 0, // and when a Stop last came (classic.Stop)
78
+ // 2026-11-03 Claude Code 2.1.31x passes every classic.* event of a plugin you
79
+ // installed over when its built-in cc-plugin-sec-default is seated first (a
80
+ // Team or Enterprise plan, or managed settings): see SKIP_GRACE_MS below.
81
+ classicSeen: false, // a classic.* hook of this mod ran in this process
82
+ hooksSkipped: null, // the classic events we saw passed over, as last told to the daemon
83
+ permAt: 0, // when classic.PermissionRequest last ran
84
+ watchAsks: {}, // tool_use_id -> { tool, at, reported, done }: permission prompts tool.check saw
72
85
  }
73
86
 
74
87
  // Millisecond precision: the daemon dedupes events per (sessionId, type, at).
@@ -230,7 +243,7 @@ async function readRepo($, cwd) {
230
243
 
231
244
  async function heartbeat($) {
232
245
  if (st.internal || !st.sessionId) return
233
- const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
246
+ const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn), ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}) })
234
247
  if (res && !st.modHealthSent) await reportModHealth($)
235
248
  if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
236
249
  else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came (it never goes: tabMarkerKept), or a rename that had to wait
@@ -243,6 +256,99 @@ async function heartbeat($) {
243
256
  }
244
257
  }
245
258
 
259
+ // 2026-11-03 a turn completed and no Stop came for it within STOP_GRACE_MS:
260
+ // report its end (once; nothing when this process doesn't hold a turn open).
261
+ async function stopFallback($, completedAt, reason) {
262
+ if (st.internal || !st.sessionId) return
263
+ if (st.stopAt && st.stopAt >= (st.turnStartAt || 0)) return // its Stop came
264
+ if (!(st.record && st.record.inTurn)) return
265
+ st.stopAt = Date.now()
266
+ await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
267
+ }
268
+
269
+ // ---------------------------------------------------------------- passed-over classic hooks (2026-11-03)
270
+ //
271
+ // Claude Code 2.1.31x seats its built-in "cc-plugin-sec-default" outermost on
272
+ // a Team or Enterprise plan or a machine with managed settings, and its hooks
273
+ // module answers every classic.* event with next.to(e, "append"): the event
274
+ // jumps past the user tier, where a plugin you installed (this one) sits, so
275
+ // none of our classic.* hooks run (Claude Code then says "leftoff: its
276
+ // classic.Stop hook was skipped. "cc-plugin-sec-default" runs first and sent
277
+ // the event on past it."). Our other events are untouched: session.start,
278
+ // prompt.submit, turn.start, turn.complete, tool.check and tool.call.
279
+ // So: each of those notes the classic event that should come with it; when it
280
+ // doesn't come within the grace and no classic hook has run in this process at
281
+ // all, the classic event counts as passed over and the daemon hears which.
282
+ function sawClassic($, name) {
283
+ if (name === 'PermissionRequest') st.permAt = Date.now()
284
+ if (st.classicSeen) return
285
+ st.classicSeen = true
286
+ if (st.hooksSkipped && st.hooksSkipped.length) { // a slow SessionStart we counted out too early, a setting changed: they run
287
+ st.hooksSkipped = []
288
+ sendOrSpool($, 'heartbeat', { hooksSkipped: [], fromMod: true }).catch(() => {})
289
+ }
290
+ }
291
+
292
+ function expectClassic($, name, graceMs) {
293
+ if (st.classicSeen || st.internal) return
294
+ $.clock.after(graceMs || SKIP_GRACE_MS, () => { noteSkipped($, name).catch(() => {}) })
295
+ }
296
+
297
+ async function noteSkipped($, name) {
298
+ if (st.classicSeen || st.internal || !st.sessionId) return
299
+ const cur = st.hooksSkipped || []
300
+ if (cur.includes(name)) return
301
+ st.hooksSkipped = cur.concat(name)
302
+ await sendOrSpool($, 'heartbeat', { hooksSkipped: st.hooksSkipped, fromMod: true })
303
+ }
304
+
305
+ // A permission prompt, seen from tool.check: next(e) is the whole check
306
+ // (rules, modes, other plugins' hooks, Claude Code's own), and { decision: "ask" }
307
+ // with approver "person" is the terminal's permission dialog (a host such as
308
+ // headless answers "host"; no approver means it is never asked). We only
309
+ // look: the answer goes back as it came. Reported as Claude Code's own
310
+ // permission_prompt notification would be, unless classic.PermissionRequest
311
+ // ran for it (then permissionHook has put it on the board, answerable there).
312
+ function watchAsk($, e) {
313
+ if (st.internal || st.headless || !st.sessionId) return
314
+ const at = Date.now()
315
+ const id = String(e.tool_use_id || (String(e.tool || '') + '@' + at))
316
+ st.watchAsks[id] = { tool: e.tool, at, reported: false, done: false }
317
+ expectClassic($, 'PermissionRequest')
318
+ $.clock.after(PERM_GRACE_MS, () => { reportAsk($, id, e).catch(() => {}) })
319
+ }
320
+
321
+ async function reportAsk($, id, e) {
322
+ const w = st.watchAsks[id]
323
+ if (!w || w.done) return
324
+ if (st.permAt >= w.at) { delete st.watchAsks[id]; return } // its PermissionRequest hook ran: the board has it
325
+ w.reported = true
326
+ await sendOrSpool($, 'notification', {
327
+ notificationType: 'permission_prompt', message: clip(askMessage(e.tool), 200), tool: e.tool || null, fromMod: true,
328
+ ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}),
329
+ })
330
+ }
331
+
332
+ function askMessage(tool) {
333
+ if (tool === 'AskUserQuestion') return 'Claude has a question for you'
334
+ return 'Claude needs your permission to use ' + (tool || 'a tool')
335
+ }
336
+
337
+ // The prompt went away: the tool runs (tool.call) or the turn is over. Says so
338
+ // only for prompts the mod reported (an activity the board reads as a tool
339
+ // that ran, which ends "Needs you" for a permission prompt).
340
+ async function settleAsks($, toolUseId, tool) {
341
+ let reported = false
342
+ for (const id of Object.keys(st.watchAsks)) {
343
+ const w = st.watchAsks[id]
344
+ if (toolUseId !== undefined && (toolUseId ? id !== String(toolUseId) : w.tool !== tool)) continue
345
+ w.done = true
346
+ if (w.reported) reported = true
347
+ delete st.watchAsks[id]
348
+ }
349
+ if (reported) await sendOrSpool($, 'activity', { kind: 'tool', phase: 'post', tool: tool || null, fromMod: true })
350
+ }
351
+
246
352
  // The commands, in the order they're tried. Claude Code 2.1.295's built-ins
247
353
  // own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
248
354
  // ours share a /cp prefix; each command lists fallbacks in case a later
@@ -259,6 +365,8 @@ const COMMANDS = [
259
365
  { key: 'todo', names: ['cp-todo', 'leftoff-todo'], description: 'Leftoff: save the checklist Claude just wrote as a list you can tick off (board, phone, or here)', argumentHint: '[name] [--pick [N]]', immediate: true },
260
366
  // v0.8.2: checkpoint, mark the session Parked (not Closed when it exits), then close it.
261
367
  { key: 'park', names: ['cp-park', 'leftoff-park'], description: 'Leftoff: checkpoint and park this session to come back to (your note becomes the Next line), then exit', argumentHint: '[note]', immediate: false },
368
+ // 2026-11-03 (1b): the repo's accepted decisions into the conversation on demand (when SessionStart was skipped, or after /clear).
369
+ { key: 'decisions', names: ['cp-decisions', 'leftoff-decisions'], description: 'Leftoff: give Claude this repo\'s accepted decisions (the same list a new session starts with)', immediate: false },
262
370
  { key: 'done', names: ['cp-done', 'leftoff-done'], description: 'Leftoff: tick items on this session\'s list (/cp-done 3, /cp-done 2 4, /cp-done undo 3)', argumentHint: '<n…> [in <list>]', immediate: true },
263
371
  ]
264
372
 
@@ -371,6 +479,37 @@ async function unlockOwnParkLocks($, cwd) {
371
479
  } catch {}
372
480
  }
373
481
 
482
+ // ------------------------------------------------------------ decisions (2026-11-03)
483
+
484
+ // The daemon's short read of this repo's accepted decisions (id + one line each, capped), or null:
485
+ // nothing for Leftoff's own runs or a board-run headless session, when the daemon doesn't answer quickly,
486
+ // or when the repo has none.
487
+ const DECISIONS_TIMEOUT_MS = 800
488
+ async function decisionContext($, e) {
489
+ if (st.internal || await isHeadless($)) return null
490
+ const q = '?session=' + encodeURIComponent(e.session_id || st.sessionId || '') + (e.cwd ? '&cwd=' + encodeURIComponent(e.cwd) : '')
491
+ const r = await api($, 'GET', '/v1/decisions/context' + q, undefined, DECISIONS_TIMEOUT_MS)
492
+ const t = r && r.ok && r.json && typeof r.json.context === 'string' ? r.json.context.trim() : ''
493
+ return t ? t.slice(0, 2000) : null
494
+ }
495
+
496
+ // 1b /cp-decisions: the same text SessionStart adds (src/decisions.js decisionContextText, from the daemon),
497
+ // shown to you and handed to Claude as the command's context. The daemon notes that this session was given them.
498
+ // Runs where the classic hooks are skipped: a command is not a classic event (Claude Code's Security default
499
+ // hooks classic.* but not command.run; checked on 2.1.317 and 2.1.318).
500
+ async function decisionsCommand($) {
501
+ if (st.internal) return { text: 'Leftoff: not in Leftoff\'s own runs.' }
502
+ let cwd = null
503
+ try { cwd = await $.session.cwd() } catch {}
504
+ const sid = st.sessionId || ''
505
+ const r = await api($, 'GET', '/v1/decisions/context?via=command&session=' + encodeURIComponent(sid) + (cwd ? '&cwd=' + encodeURIComponent(cwd) : ''), undefined, 2000)
506
+ if (!r || !r.ok || !r.json) return { text: 'Leftoff: the daemon didn\'t answer, so this repo\'s decisions couldn\'t be read (leftoff status).' }
507
+ const t = typeof r.json.context === 'string' ? r.json.context.trim().slice(0, 2000) : ''
508
+ if (!t) return { text: r.json.repo ? 'Leftoff: this repo has no accepted decisions yet. Review proposed ones on the board.' : 'Leftoff: this folder isn\'t a repo Leftoff keeps a decision log for.' }
509
+ const n = Number(r.json.count) || t.split('\n').filter((l) => l.startsWith('- ')).length
510
+ return { text: 'Leftoff: gave Claude this repo\'s ' + n + ' accepted decision' + (n === 1 ? '' : 's') + ':\n' + t, context: [t] }
511
+ }
512
+
374
513
  // ------------------------------------------------------------ lists (v0.5.1)
375
514
 
376
515
  // This session's saved lists, when they changed since Claude last heard
@@ -735,6 +874,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
735
874
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
736
875
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
737
876
  parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
877
+ decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
738
878
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
739
879
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
740
880
  }
@@ -1029,6 +1169,7 @@ export function register(on, options) {
1029
1169
  st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
1030
1170
  st.ready = true
1031
1171
  startReplyLoop($)
1172
+ expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
1032
1173
  } catch {}
1033
1174
  // Commands last, each on its own: a taken name throws, so a
1034
1175
  // clash costs only that command, and registerCommands reports it.
@@ -1037,6 +1178,7 @@ export function register(on, options) {
1037
1178
  })
1038
1179
 
1039
1180
  on('classic.SessionStart', async ($, e, next) => {
1181
+ sawClassic($, 'SessionStart')
1040
1182
  if (!(await internalOnce($)) && !st.internal && e.session_id) {
1041
1183
  let parentSessionId
1042
1184
  if (st.sessionId && e.session_id !== st.sessionId) {
@@ -1064,11 +1206,20 @@ export function register(on, options) {
1064
1206
  const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
1065
1207
  await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
1066
1208
  }
1209
+ // 2026-11-03 decision memory: the repo's accepted decisions go to Claude as this event's context.
1210
+ // Added to what the hooks below (yours included) answer, never in place of it.
1211
+ const ctx = await decisionContext($, e)
1212
+ if (ctx) {
1213
+ const res = await next(e)
1214
+ const r = res && typeof res === 'object' ? res : {}
1215
+ return { ...r, additionalContext: [...(Array.isArray(r.additionalContext) ? r.additionalContext : []), ctx] }
1216
+ }
1067
1217
  }
1068
1218
  return next(e)
1069
1219
  })
1070
1220
 
1071
1221
  on('classic.UserPromptSubmit', async ($, e, next) => {
1222
+ sawClassic($, 'UserPromptSubmit')
1072
1223
  await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
1073
1224
  return next(e)
1074
1225
  })
@@ -1078,11 +1229,13 @@ export function register(on, options) {
1078
1229
  // context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
1079
1230
  // Context goes on the event before next(); on the result it would be too late.
1080
1231
  on('prompt.submit', async ($, e, next) => {
1232
+ expectClassic($, 'UserPromptSubmit')
1081
1233
  const ctx = await listContextFor($)
1082
1234
  return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
1083
1235
  })
1084
1236
 
1085
1237
  on('turn.start', async ($, e, next) => {
1238
+ st.turnStartAt = Date.now()
1086
1239
  await sendOrSpool($, 'turn_started', {})
1087
1240
  return next(e)
1088
1241
  })
@@ -1090,11 +1243,21 @@ export function register(on, options) {
1090
1243
  on('turn.complete', async ($, e, next) => {
1091
1244
  // classic.Stop reports a normal end with its details; Stop doesn't fire for
1092
1245
  // an interrupted or failed turn, so those are reported here.
1246
+ await settleAsks($, undefined).catch(() => {}) // 2026-11-03 a permission prompt the mod reported is over with the turn
1093
1247
  if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
1248
+ // 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
1249
+ // (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
1250
+ // and a tool can end a turn), and then this turn stayed open on the board
1251
+ // for good. If no Stop came for this turn shortly after it completed, the
1252
+ // end is reported from here; a Stop that comes later is the same turn
1253
+ // (the daemon doesn't count it twice).
1254
+ else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
1094
1255
  return next(e)
1095
1256
  })
1096
1257
 
1097
1258
  on('classic.Stop', async ($, e, next) => {
1259
+ sawClassic($, 'Stop')
1260
+ st.stopAt = Date.now()
1098
1261
  // v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
1099
1262
  // board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
1100
1263
  const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
@@ -1118,20 +1281,36 @@ export function register(on, options) {
1118
1281
  })
1119
1282
 
1120
1283
  on('classic.Notification', async ($, e, next) => {
1284
+ sawClassic($, 'Notification')
1121
1285
  await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
1122
1286
  return next(e)
1123
1287
  })
1124
1288
 
1125
- on('classic.PermissionRequest', permissionHook)
1289
+ on('classic.PermissionRequest', async ($, e, next) => { sawClassic($, 'PermissionRequest'); return permissionHook($, e, next) })
1290
+
1291
+ // 2026-11-03 a permission prompt, seen whether or not classic.PermissionRequest
1292
+ // reaches us (see watchAsk): we only look, and hand the answer back as it came.
1293
+ on('tool.check', async ($, e, next) => {
1294
+ const r = await next(e)
1295
+ try { if (r && r.decision === 'ask' && e && e.approver === 'person') watchAsk($, e) } catch {}
1296
+ return r
1297
+ })
1298
+
1299
+ // The tool runs: a prompt the mod reported for it was answered in the terminal.
1300
+ on('tool.call', async ($, e, next) => {
1301
+ if (e && Object.keys(st.watchAsks).length) await settleAsks($, e.tool_use_id || null, e.tool).catch(() => {})
1302
+ return next(e)
1303
+ })
1126
1304
 
1127
1305
  on('classic.PostToolUse', async ($, e, next) => {
1306
+ sawClassic($, 'PostToolUse')
1128
1307
  await toolRan($, e)
1129
1308
  const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
1130
1309
  if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
1131
1310
  return next(e)
1132
1311
  })
1133
1312
 
1134
- on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
1313
+ on('classic.PostToolUseFailure', async ($, e, next) => { sawClassic($, 'PostToolUseFailure'); await toolRan($, e); return next(e) })
1135
1314
 
1136
1315
  on('session.end', async ($, e, next) => {
1137
1316
  // About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
@@ -1160,6 +1339,8 @@ export function register(on, options) {
1160
1339
  on('command.run', { command: 'leftoff-park' }, async ($, e) => parkCommand($, e.args))
1161
1340
 
1162
1341
  on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
1342
+ on('command.run', { command: 'cp-decisions' }, async ($) => decisionsCommand($))
1343
+ on('command.run', { command: 'leftoff-decisions' }, async ($) => decisionsCommand($))
1163
1344
 
1164
1345
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1165
1346
  if (e.requestId !== PANE_ID) return next(e)
@@ -11,10 +11,12 @@ How to walk through a change:
11
11
 
12
12
  1. Order the steps the way the change makes sense to read: the core idea first, then what follows from it. Not file order.
13
13
  2. Each step gets a short title and one or two sentences of why: the request it answers, the bug it fixes, or the trade-off you chose and the alternative you didn't take.
14
- 3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along, imports and formatting are mechanical and need no story.
14
+ 3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along (updated to match the change), imports and formatting are mechanical and need no story.
15
+ - A test that doesn't really test anything is never mechanical and never safe to skip. That is a test that asserts nothing meaningful or asserts a tautology, mocks the very thing under test, snapshots its own output, had an assertion loosened or deleted to make it pass, or was skipped or disabled. Put it in a step and give it a look callout at its line saying why.
15
16
  4. Point out what deserves a second look, at the exact line: something risky or subtle (look), a guess the code makes (assumption), or behaviour no test covers (untested). Be honest about the parts you were unsure of.
16
- 5. Never paste code. Point at it.
17
+ 5. Cite the decisions behind a step. When Leftoff lists the repo's accepted decisions (D-7f3a: …), and this session's own proposed ones (marked proposed), a step may name the ones it relates to and how: "decisions": [{"id": "D-7f3a", "rel": "follows"}] or [{"id": "D-7f3a", "rel": "departs"}] (a plain "D-7f3a" means follows), using only ids from those lists. A step that reverses or departs from a decision gets a look callout at its line naming the decision and how it departs.
18
+ 6. Never paste code. Point at it.
17
19
 
18
- When Leftoff asks (the request lists hunks H1…Hn and a JSON shape): reply with that JSON object only, no prose and no code fences. Every ref is a hunk id from the list ("H3", "H3:new 14-19", "H3:old 7"); never invent ids or line numbers; put every hunk in a step or in mechanical. You may read files with Read, Grep and Glob to check details; change nothing.
20
+ When Leftoff asks (the request lists hunks H1…Hn and a JSON shape): reply with that JSON object only, no prose and no code fences. Every ref is a hunk id from the list ("H3", "H3:new 14-19", "H3:old 7"); never invent ids or line numbers; put every hunk in a step or in mechanical. A step's "decisions" are ids from the decisions listed, never made up, each with "rel" "follows" or "departs". You may read files with Read, Grep and Glob to check details; change nothing.
19
21
 
20
22
  When the developer asks in the terminal: give the same walkthrough as a short numbered list (title, why, file:line for each step, then what to look at closely).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.17",
3
+ "version": "0.9.18-beta.10",
4
4
  "description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
5
5
  "keywords": [
6
6
  "claude",