@leftoff/cli 0.9.18-beta.4 → 0.9.18-beta.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.4",
3
+ "version": "0.9.18-beta.6",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
package/mod/hooks/core.js CHANGED
@@ -506,6 +506,10 @@ export function renderCheckpointMd(cp) {
506
506
  // Informative / long checkpoints (v0.4.6): sections below the three lines.
507
507
  // Older readers only pick up the **Label:** lines, so they still work.
508
508
  const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
509
+ // 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
510
+ // sections, so an older reader (no section open yet) skips the heading and its lines.
511
+ const decided = cleanDecided(cp.decided)
512
+ if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
509
513
  for (const k of SECTION_ORDER) {
510
514
  const v = cp[k]
511
515
  if (k === 'recap') { if (v) lines.push('', '### ' + SECTION_TITLES[k], oneLine(v).replace(/^\*+/, '')); continue }
@@ -1389,6 +1393,12 @@ const CHECKPOINT_BASE = [
1389
1393
  'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
1390
1394
  'Describe only what the transcript shows; the lines above it (repo, branch, title, what was asked, previous checkpoint) are background and may be out of date.',
1391
1395
  ]
1396
+ // 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
1397
+ const DECIDED_SECTION = [
1398
+ 'Only if the transcript shows a decision (a choice between real alternatives that the developer made or approved, a plan they approved, a correction they gave such as "no, do X instead", or a trade-off the agent chose and said so), add after those lines a "Decided:" heading and one "- " line per decision, at most 4:',
1399
+ '- <what was decided, in the words the transcript uses> · why: <the reason given, one line> · over: <alternative>; <alternative> · from: <asked | plan | correction | agent>',
1400
+ 'A task done, a file edited or a routine step is not a decision. Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
1401
+ ]
1392
1402
  const INFORMATIVE_SECTIONS = [
1393
1403
  'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
1394
1404
  'Decisions:',
@@ -1409,9 +1419,9 @@ const LONG_SECTIONS = [
1409
1419
  '3. <the step after that>',
1410
1420
  ]
1411
1421
  export const CHECKPOINT_SYSTEMS = {
1412
- short: [...CHECKPOINT_BASE, 'Write only those five lines.', 'Optionally a 6th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 90 words total.'].join('\n'),
1413
- informative: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 280 words total.'].join('\n'),
1414
- long: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 630 words total.'].join('\n'),
1422
+ short: [...CHECKPOINT_BASE, ...DECIDED_SECTION, 'Write only those five lines (and Decided, when there is one).', 'Optionally a 6th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 90 words total.'].join('\n'),
1423
+ informative: [...CHECKPOINT_BASE, ...DECIDED_SECTION, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 280 words total.'].join('\n'),
1424
+ long: [...CHECKPOINT_BASE, ...DECIDED_SECTION, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 630 words total.'].join('\n'),
1415
1425
  }
1416
1426
  // The short prompt, as before v0.4.6 (kept under its old name for anything importing it).
1417
1427
  export const CHECKPOINT_SYSTEM = CHECKPOINT_SYSTEMS.short
@@ -1463,12 +1473,153 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
1463
1473
  head.push('Previous checkpoint: ' + prev, 'Files touched since then: ' + (files.length ? files.join(', ') : 'none'))
1464
1474
  if (Array.isArray(uncommitted)) head.push('Uncommitted files now: ' + (uncommitted.length ? uncommitted.slice(0, 12).join(', ') + (uncommitted.length > 12 ? ' and ' + (uncommitted.length - 12) + ' more' : '') : 'none (everything is committed)'))
1465
1475
  if (userNote) head.push('Note from the developer: ' + userNote)
1476
+ // 2026-11-03: already read from its questions and plans, no model needed; redacted like the prompt, since they're saved
1477
+ const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
1478
+ const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
1479
+ if (found.length) head.push('Decisions already recorded from the session\'s questions and plans (don\'t repeat them): ' + found.map((d) => '“' + clip(d.text, 120) + '”').join('; '))
1466
1480
  if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
1467
1481
  if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
1468
1482
  const slice = transcriptSlice(messages, { maxInputChars, cwd })
1469
1483
  const patterns = (cfg.redact && cfg.redact.patterns) || []
1470
1484
  const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
1471
- return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens }
1485
+ return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
1486
+ }
1487
+
1488
+ // ---------------------------------------------------------------- decisions (2026-11-03)
1489
+ //
1490
+ // A decision is a choice between real alternatives that someone made or
1491
+ // approved in a session: a question you answered from its options, a plan you
1492
+ // approved, a correction you gave, or a trade-off the agent chose and said so.
1493
+ // Not a task done or a file edited. A checkpoint with none is normal.
1494
+ // In a checkpoint each is one line under "### Decided":
1495
+ // - <what was decided> · why: <one line> · over: <alt>; <alt> · from: asked
1496
+ // Both "·" and "|" separate the labelled parts (a model may write either).
1497
+ export const DECIDED_TITLE = 'Decided'
1498
+ export const DECIDED_MAX = 8
1499
+ export const DECISION_SOURCES = ['asked', 'plan', 'correction', 'agent']
1500
+ const DEC_PART = /\s+[·|]\s+(why|over|instead of|alternatives|from|source)\s*:\s*/i
1501
+
1502
+ export function parseDecidedLine(line) {
1503
+ let t = String(line == null ? '' : line).replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').replace(/\*\*/g, '').trim()
1504
+ if (!t || NONE_RE.test(t)) return null
1505
+ const d = { text: '', why: '', alternatives: [], source: 'agent' }
1506
+ const m0 = t.match(/^\[(asked|plan|correction|agent)\]\s*/i) // "[asked] …" also reads
1507
+ if (m0) { d.source = m0[1].toLowerCase(); t = t.slice(m0[0].length) }
1508
+ const parts = t.split(DEC_PART)
1509
+ d.text = parts[0].trim()
1510
+ for (let i = 1; i + 1 < parts.length; i += 2) {
1511
+ const k = parts[i].toLowerCase(), v = parts[i + 1].trim()
1512
+ if (k === 'why') d.why = v
1513
+ else if (k === 'from' || k === 'source') { const src = v.toLowerCase().replace(/[^a-z]/g, ''); if (DECISION_SOURCES.includes(src)) d.source = src }
1514
+ else d.alternatives = v.split(/\s*;\s*/).map((x) => x.trim()).filter((x) => x && !NONE_RE.test(x))
1515
+ }
1516
+ return cleanDecided([d])[0] || null
1517
+ }
1518
+
1519
+ export function renderDecidedLine(d) {
1520
+ const safe = (x) => oneLine(x).replace(/\s+[·|]\s+/g, ' - ')
1521
+ return '- ' + safe(d.text) + (d.why ? ' · why: ' + safe(d.why) : '') + (d.alternatives && d.alternatives.length ? ' · over: ' + d.alternatives.map((a) => safe(a).replace(/;/g, ',')).join('; ') : '') + ' · from: ' + (d.source || 'agent')
1522
+ }
1523
+
1524
+ // Any list -> [{ text, why, alternatives, source }], one line each, deduplicated by text.
1525
+ export function cleanDecided(list) {
1526
+ const out = [], seen = new Set()
1527
+ for (const x of Array.isArray(list) ? list : []) {
1528
+ if (!x || typeof x !== 'object') continue
1529
+ const text = clip(oneLine(x.text).replace(/^["“]|["”]$/g, ''), 240)
1530
+ if (!text || NONE_RE.test(text)) continue
1531
+ const k = decisionNorm(text)
1532
+ if (seen.has(k)) continue
1533
+ seen.add(k)
1534
+ const alternatives = (Array.isArray(x.alternatives) ? x.alternatives : []).map((a) => clip(oneLine(a), 120)).filter(Boolean).slice(0, 6)
1535
+ out.push({ text, why: clip(oneLine(x.why), 240), alternatives, source: DECISION_SOURCES.includes(x.source) ? x.source : 'agent' })
1536
+ if (out.length >= DECIDED_MAX) break
1537
+ }
1538
+ return out
1539
+ }
1540
+ export const decisionNorm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
1541
+
1542
+ // A decision's key in a repo's log: the same decision from the same session is
1543
+ // the same entry wherever it was read (the mod or the daemon). 12 hex characters.
1544
+ export function decisionKey(sessionId, text) {
1545
+ const str = String(sessionId || '') + '\n' + decisionNorm(text)
1546
+ let h1 = 0x811c9dc5, h2 = 0x01000193 ^ 0x5bd1e995
1547
+ for (let i = 0; i < str.length; i++) {
1548
+ const c = str.charCodeAt(i)
1549
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0
1550
+ h2 = Math.imul(h2 ^ c, 0x5bd1e995) >>> 0
1551
+ h2 = (h2 ^ (h2 >>> 15)) >>> 0
1552
+ }
1553
+ return (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')).slice(0, 12)
1554
+ }
1555
+
1556
+ // "User has answered your questions: "Q"="A", "Q2"="B". You can now continue…" -> { Q: A }
1557
+ function questionAnswers(text, questions) {
1558
+ const out = {}
1559
+ const t = String(text || '')
1560
+ for (let i = 0; i < questions.length; i++) {
1561
+ const q = questions[i]
1562
+ const at = t.indexOf('"' + q + '"="')
1563
+ if (at < 0) continue
1564
+ const from = at + q.length + 4
1565
+ let end = -1
1566
+ for (let j = i + 1; j < questions.length && end < 0; j++) { const n = t.indexOf('", "' + questions[j] + '"="', from); if (n >= 0) end = n }
1567
+ if (end < 0) { const m = t.slice(from).search(/"(?:\.\s|\.?$|,\s*"[^"]*"=")/); end = m >= 0 ? from + m : t.length }
1568
+ out[q] = t.slice(from, end).trim()
1569
+ }
1570
+ return out
1571
+ }
1572
+
1573
+ const PLAN_OK = /(?:approved your plan|user has approved|plan (?:was )?approved)/i
1574
+ function planHeadline(plan) {
1575
+ const lines = String(plan || '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean)
1576
+ const h = lines.find((l) => /^#{1,3}\s+\S/.test(l)) || lines[0] || ''
1577
+ return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
1578
+ }
1579
+
1580
+ // The decisions a transcript states outright, with no model: a question you
1581
+ // answered from its options (AskUserQuestion, the board's Needs you), its other
1582
+ // options as the alternatives; a plan you approved (ExitPlanMode). Messages are
1583
+ // src/transcript.js's shape, which is also $.session.messages()'s.
1584
+ export function decisionsFromMessages(messages) {
1585
+ const out = []
1586
+ for (const m of messages || []) {
1587
+ if (!m || m.role !== 'assistant') continue
1588
+ for (const u of m.toolUses || []) {
1589
+ const name = u && (u.tool || u.name)
1590
+ if (!u || u.isError || !u.input) continue
1591
+ if (name === 'AskUserQuestion' && Array.isArray(u.input.questions) && u.text) {
1592
+ const qs = u.input.questions.filter((q) => q && q.question && Array.isArray(q.options) && q.options.length >= 2)
1593
+ const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
1594
+ for (const q of qs) {
1595
+ const a = ans[q.question]
1596
+ if (!a) continue
1597
+ const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
1598
+ const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
1599
+ const chosen = picked.length ? picked.join(', ') : a
1600
+ const opt = q.options.find((o) => o && o.label === picked[0])
1601
+ out.push({
1602
+ text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
1603
+ why: opt && opt.description ? opt.description : '',
1604
+ alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
1605
+ source: 'asked',
1606
+ })
1607
+ }
1608
+ }
1609
+ if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
1610
+ const head = planHeadline(u.input.plan)
1611
+ if (head) out.push({ text: clip('Plan approved: ' + head, 240), why: '', alternatives: [], source: 'plan' })
1612
+ }
1613
+ }
1614
+ }
1615
+ return cleanDecided(out)
1616
+ }
1617
+
1618
+ // The checkpoint's decisions: what the transcript states outright first, then the model's.
1619
+ // earlier: decisions already in this session's previous checkpoints (by text), left out.
1620
+ export function mergeDecided(found, fromModel, { earlier = [] } = {}) {
1621
+ const seen = new Set((earlier || []).map((d) => decisionNorm(d && d.text)))
1622
+ return cleanDecided([...(found || []), ...(fromModel || [])]).filter((d) => !seen.has(decisionNorm(d.text)))
1472
1623
  }
1473
1624
 
1474
1625
  // The extra sections of an informative / long checkpoint, by the heading a
@@ -1480,11 +1631,12 @@ const SECTION_KEYS = {
1480
1631
  'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
1481
1632
  'recap': 'recap',
1482
1633
  'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
1634
+ 'decided': 'decided', // 2026-11-03 decision memory
1483
1635
  }
1484
1636
  export const SECTION_TITLES = { decisions: 'Key decisions', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1485
1637
  const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
1486
1638
  const NONE_RE = /^(none|n\/a|nothing|-|—|none yet|no(ne)? (open )?questions?)\.?$/i
1487
- const LIMITS = { decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1639
+ const LIMITS = { decided: DECIDED_MAX, decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1488
1640
 
1489
1641
  // "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
1490
1642
  export function parseFileNote(text) {
@@ -1495,7 +1647,7 @@ export function parseFileNote(text) {
1495
1647
  return p ? { path: p[1], why: '' } : { path: '', why: t }
1496
1648
  }
1497
1649
 
1498
- function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1650
+ function emptyExtras() { return { decided: [], decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1499
1651
  export function hasExtras(cp) {
1500
1652
  return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
1501
1653
  }
@@ -1510,6 +1662,7 @@ function parseSections(lines) {
1510
1662
  if (!v || NONE_RE.test(v)) return
1511
1663
  if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
1512
1664
  if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
1665
+ if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
1513
1666
  ex[k].push(v)
1514
1667
  }
1515
1668
  for (const raw of lines) {
@@ -1528,6 +1681,7 @@ function parseSections(lines) {
1528
1681
  if (item) push(cur, item[1])
1529
1682
  else if (cur === 'recap') push(cur, line)
1530
1683
  else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
1684
+ else if (cur === 'decided') continue // a decision is one bullet line; loose prose under the heading is not one
1531
1685
  else push(cur, line)
1532
1686
  }
1533
1687
  for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
@@ -1707,6 +1861,8 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1707
1861
  else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
1708
1862
  else if (lastEdit >= 0 && lastCommit < lastEdit) next = 'Commit, or keep going.'
1709
1863
  const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
1864
+ const found = decisionsFromMessages(ms_) // 2026-11-03: only what the transcript states outright; the heuristic guesses none
1865
+ if (found.length) base.decided = found
1710
1866
  const len = normalizeLength(length)
1711
1867
  if (len === 'short') return base
1712
1868
  const ex = emptyExtras()
@@ -1733,7 +1889,7 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1733
1889
  ].filter(Boolean).join(' ')
1734
1890
  ex.nextSteps = next ? [next] : []
1735
1891
  }
1736
- return { ...base, ...ex }
1892
+ return { ...base, ...ex, decided: base.decided || [] }
1737
1893
  }
1738
1894
 
1739
1895
  // Fallback title: {repo}@{branch} · {first 6 words of the first prompt}
@@ -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,
@@ -27,9 +27,12 @@ const POST_TIMEOUT_MS = 800
27
27
  const END_TIMEOUT_MS = 1000
28
28
  const MODEL_TIMEOUT_MS = 20000
29
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
30
33
 
31
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
32
- const MOD_VERSION = '0.9.18-beta.4' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.6' // keep in step with .claude-plugin/plugin.json
33
36
 
34
37
  const st = {
35
38
  internal: false,
@@ -72,6 +75,13 @@ const st = {
72
75
  paneNeeds: 0, // sessions that need you (the pane's top line)
73
76
  turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
74
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
75
85
  }
76
86
 
77
87
  // Millisecond precision: the daemon dedupes events per (sessionId, type, at).
@@ -233,7 +243,7 @@ async function readRepo($, cwd) {
233
243
 
234
244
  async function heartbeat($) {
235
245
  if (st.internal || !st.sessionId) return
236
- 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 } : {}) })
237
247
  if (res && !st.modHealthSent) await reportModHealth($)
238
248
  if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
239
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
@@ -256,6 +266,89 @@ async function stopFallback($, completedAt, reason) {
256
266
  await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
257
267
  }
258
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
+
259
352
  // The commands, in the order they're tried. Claude Code 2.1.295's built-ins
260
353
  // own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
261
354
  // ours share a /cp prefix; each command lists fallbacks in case a later
@@ -384,6 +477,20 @@ async function unlockOwnParkLocks($, cwd) {
384
477
  } catch {}
385
478
  }
386
479
 
480
+ // ------------------------------------------------------------ decisions (2026-11-03)
481
+
482
+ // The daemon's short read of this repo's accepted decisions (id + one line each, capped), or null:
483
+ // nothing for Leftoff's own runs or a board-run headless session, when the daemon doesn't answer quickly,
484
+ // or when the repo has none.
485
+ const DECISIONS_TIMEOUT_MS = 800
486
+ async function decisionContext($, e) {
487
+ if (st.internal || await isHeadless($)) return null
488
+ const q = '?session=' + encodeURIComponent(e.session_id || st.sessionId || '') + (e.cwd ? '&cwd=' + encodeURIComponent(e.cwd) : '')
489
+ const r = await api($, 'GET', '/v1/decisions/context' + q, undefined, DECISIONS_TIMEOUT_MS)
490
+ const t = r && r.ok && r.json && typeof r.json.context === 'string' ? r.json.context.trim() : ''
491
+ return t ? t.slice(0, 2000) : null
492
+ }
493
+
387
494
  // ------------------------------------------------------------ lists (v0.5.1)
388
495
 
389
496
  // This session's saved lists, when they changed since Claude last heard
@@ -748,6 +855,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
748
855
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
749
856
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
750
857
  parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
858
+ decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
751
859
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
752
860
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
753
861
  }
@@ -1042,6 +1150,7 @@ export function register(on, options) {
1042
1150
  st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
1043
1151
  st.ready = true
1044
1152
  startReplyLoop($)
1153
+ expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
1045
1154
  } catch {}
1046
1155
  // Commands last, each on its own: a taken name throws, so a
1047
1156
  // clash costs only that command, and registerCommands reports it.
@@ -1050,6 +1159,7 @@ export function register(on, options) {
1050
1159
  })
1051
1160
 
1052
1161
  on('classic.SessionStart', async ($, e, next) => {
1162
+ sawClassic($, 'SessionStart')
1053
1163
  if (!(await internalOnce($)) && !st.internal && e.session_id) {
1054
1164
  let parentSessionId
1055
1165
  if (st.sessionId && e.session_id !== st.sessionId) {
@@ -1077,11 +1187,20 @@ export function register(on, options) {
1077
1187
  const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
1078
1188
  await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
1079
1189
  }
1190
+ // 2026-11-03 decision memory: the repo's accepted decisions go to Claude as this event's context.
1191
+ // Added to what the hooks below (yours included) answer, never in place of it.
1192
+ const ctx = await decisionContext($, e)
1193
+ if (ctx) {
1194
+ const res = await next(e)
1195
+ const r = res && typeof res === 'object' ? res : {}
1196
+ return { ...r, additionalContext: [...(Array.isArray(r.additionalContext) ? r.additionalContext : []), ctx] }
1197
+ }
1080
1198
  }
1081
1199
  return next(e)
1082
1200
  })
1083
1201
 
1084
1202
  on('classic.UserPromptSubmit', async ($, e, next) => {
1203
+ sawClassic($, 'UserPromptSubmit')
1085
1204
  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
1086
1205
  return next(e)
1087
1206
  })
@@ -1091,6 +1210,7 @@ export function register(on, options) {
1091
1210
  // context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
1092
1211
  // Context goes on the event before next(); on the result it would be too late.
1093
1212
  on('prompt.submit', async ($, e, next) => {
1213
+ expectClassic($, 'UserPromptSubmit')
1094
1214
  const ctx = await listContextFor($)
1095
1215
  return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
1096
1216
  })
@@ -1104,6 +1224,7 @@ export function register(on, options) {
1104
1224
  on('turn.complete', async ($, e, next) => {
1105
1225
  // classic.Stop reports a normal end with its details; Stop doesn't fire for
1106
1226
  // an interrupted or failed turn, so those are reported here.
1227
+ await settleAsks($, undefined).catch(() => {}) // 2026-11-03 a permission prompt the mod reported is over with the turn
1107
1228
  if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
1108
1229
  // 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
1109
1230
  // (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
@@ -1111,11 +1232,12 @@ export function register(on, options) {
1111
1232
  // for good. If no Stop came for this turn shortly after it completed, the
1112
1233
  // end is reported from here; a Stop that comes later is the same turn
1113
1234
  // (the daemon doesn't count it twice).
1114
- else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }) }
1235
+ else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
1115
1236
  return next(e)
1116
1237
  })
1117
1238
 
1118
1239
  on('classic.Stop', async ($, e, next) => {
1240
+ sawClassic($, 'Stop')
1119
1241
  st.stopAt = Date.now()
1120
1242
  // v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
1121
1243
  // board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
@@ -1140,20 +1262,36 @@ export function register(on, options) {
1140
1262
  })
1141
1263
 
1142
1264
  on('classic.Notification', async ($, e, next) => {
1265
+ sawClassic($, 'Notification')
1143
1266
  await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
1144
1267
  return next(e)
1145
1268
  })
1146
1269
 
1147
- on('classic.PermissionRequest', permissionHook)
1270
+ on('classic.PermissionRequest', async ($, e, next) => { sawClassic($, 'PermissionRequest'); return permissionHook($, e, next) })
1271
+
1272
+ // 2026-11-03 a permission prompt, seen whether or not classic.PermissionRequest
1273
+ // reaches us (see watchAsk): we only look, and hand the answer back as it came.
1274
+ on('tool.check', async ($, e, next) => {
1275
+ const r = await next(e)
1276
+ try { if (r && r.decision === 'ask' && e && e.approver === 'person') watchAsk($, e) } catch {}
1277
+ return r
1278
+ })
1279
+
1280
+ // The tool runs: a prompt the mod reported for it was answered in the terminal.
1281
+ on('tool.call', async ($, e, next) => {
1282
+ if (e && Object.keys(st.watchAsks).length) await settleAsks($, e.tool_use_id || null, e.tool).catch(() => {})
1283
+ return next(e)
1284
+ })
1148
1285
 
1149
1286
  on('classic.PostToolUse', async ($, e, next) => {
1287
+ sawClassic($, 'PostToolUse')
1150
1288
  await toolRan($, e)
1151
1289
  const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
1152
1290
  if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
1153
1291
  return next(e)
1154
1292
  })
1155
1293
 
1156
- on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
1294
+ on('classic.PostToolUseFailure', async ($, e, next) => { sawClassic($, 'PostToolUseFailure'); await toolRan($, e); return next(e) })
1157
1295
 
1158
1296
  on('session.end', async ($, e, next) => {
1159
1297
  // About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
@@ -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: …), a step may name the ones it follows or changes ("decisions": ["D-7f3a"]), using only ids from that list. A step that reverses or departs from an accepted 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. 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.18-beta.4",
3
+ "version": "0.9.18-beta.6",
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",