@leftoff/cli 0.9.18-beta.9 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SECURITY.md CHANGED
@@ -94,7 +94,7 @@ A relayed request is replayed against the Mac's own board from inside the daemon
94
94
 
95
95
  When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
96
96
 
97
- Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
97
+ Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback, or since 0.9.18 another app's Caddy on port 443 that imports Leftoff's site file, which turns away any client not on loopback by its address; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
98
98
 
99
99
  ### Live link (0.9.8 beta)
100
100
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.9",
3
+ "version": "0.9.18",
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
@@ -508,6 +508,10 @@ export function renderCheckpointMd(cp) {
508
508
  const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
509
509
  // 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
510
510
  // sections, so an older reader (no section open yet) skips the heading and its lines.
511
+ // 2026-11-06: what the session's commands show got done (workFacts), first of all, so the
512
+ // digest can say what shipped in past tense; an older reader skips the heading and its lines
513
+ const work = (Array.isArray(cp.work) ? cp.work : []).map(renderWorkLine).filter(Boolean)
514
+ if (work.length) { lines.push('', '### ' + WORK_TITLE); for (const w of work) lines.push('- ' + w) }
511
515
  const decided = cleanDecided(cp.decided)
512
516
  if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
513
517
  for (const k of SECTION_ORDER) {
@@ -566,7 +570,15 @@ export function parseCheckpointMd(text) {
566
570
  if (cp.format === 'markdown') for (const f of ['doing', 'stoppedAt', 'next']) cp[f] = cp[f].replace(/\n{3,}/g, '\n\n').trim()
567
571
  const sec = []
568
572
  for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
573
+ // 2026-11-06: "### Got done" lines are facts in a fixed shape (renderWorkLine); read them here, not as model prose
574
+ const work = []
575
+ let inWork = false
576
+ for (const l of sec) {
577
+ if (/^###\s/.test(l)) { inWork = l.replace(/^###\s+/, '').trim() === WORK_TITLE; continue }
578
+ if (inWork) { const f = parseWorkLine(l); if (f) work.push(f) }
579
+ }
569
580
  Object.assign(cp, parseSections(sec))
581
+ if (work.length) cp.work = work
570
582
  return cp
571
583
  }
572
584
 
@@ -904,10 +916,11 @@ export function computeStatus(s, now, cfg = DEFAULT_CONFIG) {
904
916
  // fire), so a turn that has been silent for twice the dormancy window is
905
917
  // treated as over. The mod sees turn.complete for aborted turns too.
906
918
  // 0.8.0: a Codex rollout shows no end for a turn Codex was killed in, either.
907
- if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter) return 'dormant'
919
+ // 2026-11-06: a subagent working is the turn going on, however quiet the session's own hooks are
920
+ if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter && !(s.subagents && s.subagents.n > 0)) return 'dormant'
908
921
  return 'active'
909
922
  }
910
- if (s.backgroundTasks > 0) return 'background'
923
+ if (s.backgroundTasks > 0 || (s.subagents && s.subagents.n > 0)) return 'background' // 2026-11-06: subagents working (the daemon reads them, src/subagents.js), a turn's end missed or not
911
924
  return idle >= dormantAfter ? 'dormant' : 'waiting'
912
925
  }
913
926
 
@@ -1335,6 +1348,189 @@ export function transcriptSlice(messages, { maxInputChars = 24000, cwd } = {}) {
1335
1348
  return blocks.reverse().join('\n')
1336
1349
  }
1337
1350
 
1351
+ // ---------------------------------------------------------------- work facts (2026-11-06)
1352
+ //
1353
+ // What a session got done, read from the commands it ran and what they printed,
1354
+ // never from a model: commits ("[branch abc1234] subject"), PRs opened (gh pr
1355
+ // create's URL) and merged (gh pr merge, or GitHub's MCP merge tool), branches
1356
+ // pushed, and the last test run with its counts. A checkpoint keeps them under
1357
+ // "### Got done", one line each, so the digest can say "Merged PR #53: …" in
1358
+ // past tense instead of the last status line ("PR 53 was ready to merge").
1359
+ export const WORK_TITLE = 'Got done'
1360
+ export const WORK_MAX = { prs: 6, commits: 10, pushes: 3 }
1361
+
1362
+ const quoted = (s) => s == null ? '' : String(s)
1363
+ function argOf(cmd, names) {
1364
+ for (const n of names) {
1365
+ const re = new RegExp('(?:^|\\s)' + n.replace(/-/g, '\\-') + '(?:=|\\s+)(?:"((?:[^"\\\\]|\\\\.)*)"|\'([^\']*)\'|(\\S+))')
1366
+ const m = String(cmd).match(re)
1367
+ if (m) return quoted(m[1] != null ? m[1].replace(/\\(.)/g, '$1') : m[2] != null ? m[2] : m[3])
1368
+ }
1369
+ return ''
1370
+ }
1371
+ const firstLineOf = (t) => String(t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''
1372
+ const TEST_CMD = /(?:^|[\s;&|(])((?:(?:env\s+(?:-u\s+\S+\s+)*)?(?:timeout\s+(?:-k\s+\S+\s+)?\S+\s+)?)?(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?test(?::[\w:.-]+)?|node\s+(?:--?[\w-]+(?:[= ]\S+)?\s+)*--test\b|npx\s+(?:vitest|jest|mocha)|vitest|jest|pytest|python3?\s+-m\s+pytest|go\s+test|cargo\s+test|mocha|rspec|swift\s+test|make\s+test|deno\s+test))/
1373
+ function testCounts(text) {
1374
+ const t = String(text || '')
1375
+ let m, passed = null, failed = null
1376
+ if ((m = t.match(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/))) return { passed: +m[1], failed: +m[2] }
1377
+ const np = [...t.matchAll(/^\s*(?:#|ℹ)\s*pass\s+(\d+)\s*$/gm)].pop(), nf = [...t.matchAll(/^\s*(?:#|ℹ)\s*fail\s+(\d+)\s*$/gm)].pop()
1378
+ if (np || nf) return { passed: np ? +np[1] : 0, failed: nf ? +nf[1] : 0 }
1379
+ if ((m = t.match(/^\s*Tests:?\s+(.*)$/m)) && /\d+ (?:passed|failed)/.test(m[1])) {
1380
+ const p = m[1].match(/(\d+) passed/), f = m[1].match(/(\d+) failed/)
1381
+ return { passed: p ? +p[1] : 0, failed: f ? +f[1] : 0 }
1382
+ }
1383
+ const lines = t.split('\n').filter((l) => /\d+ (?:passed|failed)\b/.test(l))
1384
+ if (lines.length) {
1385
+ const l = lines[lines.length - 1], p = l.match(/(\d+) passed/), f = l.match(/(\d+) failed/)
1386
+ passed = p ? +p[1] : 0; failed = f ? +f[1] : 0
1387
+ return { passed, failed }
1388
+ }
1389
+ return null
1390
+ }
1391
+ const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
1392
+
1393
+ export function workFacts(messages) {
1394
+ const prs = new Map() // number -> { kind: 'pr', number, title, state, repo }
1395
+ const commits = [], pushes = []
1396
+ let tests = null
1397
+ const results = new Map()
1398
+ for (const m of messages || []) for (const r of m.toolResults || []) if (r && r.tool_use_id) results.set(r.tool_use_id, r)
1399
+ const pr = (number, patch) => {
1400
+ const n = Number(number)
1401
+ if (!n) return
1402
+ const cur = prs.get(n) || { kind: 'pr', number: n, title: '', state: 'opened', repo: null }
1403
+ // merged is final: a later "opened" (a re-run of create) doesn't take it back
1404
+ const state = cur.state === 'merged' ? 'merged' : (patch.state || cur.state)
1405
+ prs.delete(n) // newest last
1406
+ prs.set(n, { ...cur, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v)), state })
1407
+ }
1408
+ for (const m of messages || []) {
1409
+ if (m.role !== 'assistant') continue
1410
+ for (const t of m.toolUses || []) {
1411
+ const name = String(t.tool || t.name || '')
1412
+ const res = (t.tool_use_id && results.get(t.tool_use_id)) || null
1413
+ const text = String(t.text != null ? t.text : (res && res.text) || '')
1414
+ const isError = !!(t.isError || (res && res.isError))
1415
+ const input = t.input || {}
1416
+ // GitHub's MCP tools (mcp__github__create_pull_request / merge_pull_request)
1417
+ if (/(?:^|__)create_pull_request$/.test(name)) {
1418
+ if (isError) continue
1419
+ const u = text.match(PR_URL), n = (u && u[2]) || ((text.match(/"number"\s*:\s*(\d+)/) || [])[1])
1420
+ pr(n, { title: clip(String(input.title || ''), 120), state: 'opened', repo: u ? u[1] : (input.owner && input.repo ? input.owner + '/' + input.repo : null) })
1421
+ continue
1422
+ }
1423
+ if (/(?:^|__)merge_pull_request$/.test(name)) {
1424
+ if (isError || /not mergeable|failed|error/i.test(firstLineOf(text))) continue
1425
+ pr(input.pull_number || input.pullNumber || input.number, { state: 'merged', title: clip(String(input.commit_title || ''), 120).replace(/\s*\(#\d+\)$/, ''), repo: input.owner && input.repo ? input.owner + '/' + input.repo : null })
1426
+ continue
1427
+ }
1428
+ if (name !== 'Bash' || !input.command) continue
1429
+ const cmd = String(input.command)
1430
+ if (/\bgit\b[^\n|;&]*\bcommit\b/.test(cmd) && !/--dry-run/.test(cmd)) {
1431
+ for (const c of text.matchAll(/^\[([^\]\s]+)(?: \([^)]*\))? ([0-9a-f]{7,40})\] (.+)$/gm)) {
1432
+ const subject = clip(c[3].trim(), 120)
1433
+ const i = commits.findIndex((x) => x.subject === subject) // an --amend: the new sha, one line
1434
+ if (i >= 0) commits.splice(i, 1)
1435
+ commits.push({ kind: 'commit', sha: c[2].slice(0, 7), subject, branch: c[1] })
1436
+ }
1437
+ }
1438
+ if (/\bgh\s+pr\s+create\b/.test(cmd) && !isError) {
1439
+ const u = text.match(PR_URL)
1440
+ if (u) pr(u[2], { title: clip(argOf(cmd, ['--title', '-t']), 120), state: 'opened', repo: u[1], branch: argOf(cmd, ['--head', '-H']).replace(/^[\w.-]+:/, '') || pushes[pushes.length - 1] || (commits.length ? commits[commits.length - 1].branch : '') })
1441
+ }
1442
+ if (/\bgh\s+pr\s+merge\b/.test(cmd) && !isError) {
1443
+ const n = (cmd.match(/\bgh\s+pr\s+merge\s+(?:[^\s-]\S*\/pull\/)?#?(\d+)\b/) || [])[1] || (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#(\d+)/i) || [])[1] || ((text.match(PR_URL) || [])[2])
1444
+ const title = (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#\d+ \((.+)\)\s*$/im) || [])[1] || ''
1445
+ if (/automatically merged|auto-?merge|merge queue|will be merged/i.test(text) || /--auto\b/.test(cmd)) pr(n, { state: 'auto', title: clip(title, 120) })
1446
+ else if (!/not mergeable|failed|error:/i.test(text)) pr(n, { state: 'merged', title: clip(title, 120) })
1447
+ }
1448
+ if (/\bgit\s+push\b/.test(cmd) && !isError) {
1449
+ const b = [...text.matchAll(/(?:\[new branch\]|\.\.|\+)\s*\S*\s+(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop() || [...text.matchAll(/\s(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop()
1450
+ if (b) { const i = pushes.indexOf(b); if (i >= 0) pushes.splice(i, 1); pushes.push(b) }
1451
+ }
1452
+ const tc = cmd.match(TEST_CMD)
1453
+ if (tc) {
1454
+ const counts = testCounts(text)
1455
+ if (counts || isError) tests = { kind: 'tests', ok: !isError && (!counts || counts.failed === 0), passed: counts ? counts.passed : null, failed: counts ? counts.failed : null, cmd: clip(tc[1].replace(/^env\s+(?:-u\s+\S+\s+)*/, '').replace(/^timeout\s+(?:-k\s+\S+\s+)?\S+\s+/, '').replace(/\s--?(?:import|require|loader|r)[= ]\S+/g, '').trim(), 60) }
1456
+ }
1457
+ }
1458
+ }
1459
+ const out = []
1460
+ const prList = [...prs.values()].slice(-WORK_MAX.prs)
1461
+ out.push(...prList.filter((p) => p.state === 'merged'), ...prList.filter((p) => p.state !== 'merged'))
1462
+ out.push(...commits.slice(-WORK_MAX.commits))
1463
+ out.push(...pushes.slice(-WORK_MAX.pushes).map((branch) => ({ kind: 'push', branch })))
1464
+ if (tests) out.push(tests)
1465
+ return out
1466
+ }
1467
+
1468
+ const oneL = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim()
1469
+ // One fact, one line; parseWorkLine reads it back.
1470
+ export function renderWorkLine(f) {
1471
+ if (!f) return ''
1472
+ if (f.kind === 'pr') {
1473
+ const head = (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.repo ? ' (' + f.repo + ')' : '') + (f.branch ? ' from ' + f.branch : '')
1474
+ return head + (f.title ? ': ' + oneL(f.title) : '')
1475
+ }
1476
+ if (f.kind === 'commit') return 'Committed ' + f.sha + (f.branch ? ' on ' + f.branch : '') + ': ' + oneL(f.subject)
1477
+ if (f.kind === 'push') return 'Pushed ' + f.branch
1478
+ if (f.kind === 'tests') {
1479
+ const n = f.passed != null ? f.passed + ' passed' + (f.failed ? ', ' + f.failed + ' failed' : '') : ''
1480
+ return 'Tests ' + (f.ok ? 'passed' : 'failed') + (n ? ': ' + n : '') + (f.cmd ? ' (' + oneL(f.cmd).replace(/[()]/g, '') + ')' : '')
1481
+ }
1482
+ return ''
1483
+ }
1484
+ export function parseWorkLine(line) {
1485
+ const t = String(line || '').replace(/^\s*[-*•]\s*/, '').trim()
1486
+ let m
1487
+ if ((m = t.match(/^(Merged|Opened|Set to merge) PR #(\d+)(?: \(([\w.-]+\/[\w.-]+)\))?(?: from (\S+?))?(?::\s*(.*))?$/))) return { kind: 'pr', number: +m[2], state: m[1] === 'Merged' ? 'merged' : m[1] === 'Opened' ? 'opened' : 'auto', repo: m[3] || null, ...(m[4] ? { branch: m[4] } : {}), title: (m[5] || '').trim() }
1488
+ if ((m = t.match(/^Committed ([0-9a-f]{7,40})(?: on (\S+))?:\s*(.*)$/))) return { kind: 'commit', sha: m[1], branch: m[2] || null, subject: m[3].trim() }
1489
+ if ((m = t.match(/^Pushed (\S+)$/))) return { kind: 'push', branch: m[1] }
1490
+ if ((m = t.match(/^Tests (passed|failed)(?::\s*(\d+) passed(?:, (\d+) failed)?)?(?: \((.*)\))?$/))) return { kind: 'tests', ok: m[1] === 'passed', passed: m[2] != null ? +m[2] : null, failed: m[2] != null ? +(m[3] || 0) : null, cmd: m[4] || '' }
1491
+ return null
1492
+ }
1493
+ // "PR 53 is ready to merge" / "Merge PR 53" about a PR that has merged: a status line gone stale.
1494
+ export const STATUSY_PR = /\bPR\s*#?(\d+)\b[^.;]{0,60}?\b(?:ready to merge|ready for review|ready to land|awaiting (?:review|merge|approval)|waiting (?:on|for) (?:review|merge|approval|checks|CI)|open(?:ed)?(?: and)? (?:for|awaiting)|in review|pending (?:review|merge))/i
1495
+ export const STALE_NEXT_PR = /\b(?:merge|land|ship|review|approve|squash)\b[^.;]{0,40}?\bPR\s*#?(\d+)/i
1496
+ // A checkpoint whose Stopped at / Next calls a PR its own facts show merged ready or next to merge:
1497
+ // Stopped at becomes what the facts say, Next is dropped (the digest's next step comes from elsewhere).
1498
+ // -> { stoppedAt, next, corrected: [fields] }; nothing changed when nothing is contradicted.
1499
+ export function reconcileWithWork(cp, work) {
1500
+ const merged = new Map((work || []).filter((f) => f && f.kind === 'pr' && f.state === 'merged').map((f) => [f.number, f]))
1501
+ const out = { stoppedAt: cp.stoppedAt, next: cp.next, corrected: [] }
1502
+ if (!merged.size) return out
1503
+ const one = (t) => String(t || '').replace(/\s+/g, ' ')
1504
+ const m = one(cp.stoppedAt).match(STATUSY_PR)
1505
+ if (m && merged.has(Number(m[1]))) {
1506
+ const pr = merged.get(Number(m[1]))
1507
+ const tests = (work || []).filter((f) => f.kind === 'tests').pop()
1508
+ out.stoppedAt = 'PR #' + pr.number + (pr.title ? ' (' + clip(pr.title, 80) + ')' : '') + ' is merged' + (tests ? '; tests ' + (tests.ok ? 'pass' : 'fail') + (tests.passed != null ? ' (' + (tests.ok ? tests.passed + ' passed' : tests.failed + ' failed') + ')' : '') : '') + '.'
1509
+ out.corrected.push('stoppedAt')
1510
+ }
1511
+ const n = one(cp.next).match(STALE_NEXT_PR)
1512
+ if (n && merged.has(Number(n[1]))) { out.next = ''; out.corrected.push('next') }
1513
+ return out
1514
+ }
1515
+ // A work list from anywhere (the mod's POST, a file) made safe: only what renders and reads back.
1516
+ export function cleanWork(list) {
1517
+ const out = []
1518
+ for (const f of Array.isArray(list) ? list : []) {
1519
+ const w = typeof f === 'string' ? parseWorkLine(f) : parseWorkLine(renderWorkLine(f))
1520
+ if (w) out.push(w)
1521
+ }
1522
+ return out.slice(0, WORK_MAX.prs + WORK_MAX.commits + WORK_MAX.pushes + 1)
1523
+ }
1524
+ // The same fact in two checkpoints is one fact (a commit by its subject, since an amend changes the sha).
1525
+ export function workKey(f) {
1526
+ if (!f) return ''
1527
+ if (f.kind === 'pr') return 'pr:#' + f.number
1528
+ if (f.kind === 'commit') return 'commit:' + String(f.subject || '').toLowerCase()
1529
+ if (f.kind === 'push') return 'push:' + f.branch
1530
+ if (f.kind === 'tests') return 'tests'
1531
+ return ''
1532
+ }
1533
+
1338
1534
  // ---------------------------------------------------------------- checkpoint length (v0.4.6)
1339
1535
  //
1340
1536
  // config summary.length picks how much a checkpoint says. Every length keeps the
@@ -1387,17 +1583,18 @@ const CHECKPOINT_BASE = [
1387
1583
  'You write checkpoints for a developer juggling many coding-agent sessions, read when they come back after half an hour, a day or a weekend. Given the recent transcript of ONE session, start with exactly these five lines:',
1388
1584
  'Goal: <the outcome the developer is after, from what they asked for, in a few plain words; not the latest step. Keep the previous goal unless they changed course or it is done and they moved on>',
1389
1585
  'Progress: <one of Just started, Underway, Mostly done, Done, Stuck> - <what is left between here and the goal, a few words>',
1390
- 'Doing: <what the session was working on, one sentence, concrete nouns: files, features, errors>',
1391
- 'Stopped at: <the exact state when it went quiet: what passed, what failed, what is half-done; say what was actually run or checked, and what is only assumed>',
1586
+ 'Doing: <what the session got done or was working on, one sentence, concrete nouns: files, features, errors; past tense for what is finished, e.g. "Merged PR #53, which keeps the reply box above the keyboard">',
1587
+ 'Stopped at: <the exact state when it went quiet, as outcomes: what passed, what failed, what is half-done, what is merged and what is not; say what was actually run or checked, and what is only assumed>',
1392
1588
  'Next: <the single next action a human or the agent should take>',
1393
1589
  'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
1590
+ 'Say outcomes, not statuses: "Merged PR #53" or "Opened PR #53, not merged yet", never "PR 53 was ready to merge". The "Verified from the commands\' output" line is the record of commits, PRs, pushes and tests: never contradict it, and never claim a merge, commit or passing test it does not show.',
1394
1591
  '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.',
1395
1592
  ]
1396
1593
  // 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
1397
1594
  const DECIDED_SECTION = [
1398
1595
  '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
1596
  '- <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.',
1597
+ 'A task done, a file edited or a routine step is not a decision, and neither is a one-off choice about this task right now (who does a step, whether to commit, push or deploy now). 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
1598
  ]
1402
1599
  const INFORMATIVE_SECTIONS = [
1403
1600
  'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
@@ -1477,12 +1674,15 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
1477
1674
  const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
1478
1675
  const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
1479
1676
  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('; '))
1677
+ // 2026-11-06: what the commands' own output shows got done, so the model says "Merged PR #53" and not "PR 53 was ready to merge"
1678
+ const work = workFacts(messages).map((f) => (f.kind === 'pr' || f.kind === 'commit' ? { ...f, title: f.title && red(f.title), subject: f.subject && red(f.subject) } : f)).map((f) => Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined)))
1679
+ head.push('Verified from the commands\' output (git, gh, the test runner) this session: ' + (work.length ? work.map(renderWorkLine).join('; ') : 'no commit, PR, push or test run'))
1480
1680
  if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
1481
1681
  if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
1482
1682
  const slice = transcriptSlice(messages, { maxInputChars, cwd })
1483
1683
  const patterns = (cfg.redact && cfg.redact.patterns) || []
1484
1684
  const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
1485
- return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
1685
+ return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found, work }
1486
1686
  }
1487
1687
 
1488
1688
  // ---------------------------------------------------------------- decisions (2026-11-03)
@@ -1577,12 +1777,23 @@ function planHeadline(plan) {
1577
1777
  return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
1578
1778
  }
1579
1779
 
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 = []
1780
+ // 2026-11-05: a question about one task right now isn't a decision for the repo: who does it ("You update
1781
+ // it" / "I'll handle it"), or whether to go ahead with a commit, push, deploy and the like. Never recorded.
1782
+ const ACTOR_OPT = /^(?:you|i|i'll|i will|i'd|me|let me|we|we'll|leave it|someone)\b/i
1783
+ const NOW_Q = /\b(?:go ahead|proceed|right now|commit|push|merge|deploy|ship|publish|release|restart|open (?:a|the) pr|create (?:a|the) pr|who (?:should|will|does))\b/i
1784
+ const YESNO_OPT = /^(?:yes|no|y|n|ok|okay|sure|go ahead|do it|proceed|not now|later|skip|wait|stop|hold off|cancel|continue)\b/i
1785
+ export function isOneOffQuestion(q) {
1786
+ const labels = (Array.isArray(q && q.options) ? q.options : []).map((o) => String((o && o.label) || o || '').replace(/\s*\(Recommended\)\s*/i, '').trim()).filter(Boolean)
1787
+ if (labels.length < 2) return false
1788
+ if (labels.every((l) => ACTOR_OPT.test(l))) return true
1789
+ return NOW_Q.test(String((q && q.question) || '')) && labels.every((l) => YESNO_OPT.test(l))
1790
+ }
1791
+
1792
+ // The transcript's own decisions, oldest first, and the answers it later changed:
1793
+ // a question asked again (same header, else the same question) and answered
1794
+ // differently keeps only the last answer; the earlier ones are `reversed` (texts).
1795
+ function readStated(messages) {
1796
+ const out = [], at = new Map(), replaced = new Set()
1586
1797
  for (const m of messages || []) {
1587
1798
  if (!m || m.role !== 'assistant') continue
1588
1799
  for (const u of m.toolUses || []) {
@@ -1593,17 +1804,22 @@ export function decisionsFromMessages(messages) {
1593
1804
  const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
1594
1805
  for (const q of qs) {
1595
1806
  const a = ans[q.question]
1596
- if (!a) continue
1807
+ if (!a || isOneOffQuestion(q)) continue
1597
1808
  const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
1598
1809
  const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
1599
1810
  const chosen = picked.length ? picked.join(', ') : a
1600
1811
  const opt = q.options.find((o) => o && o.label === picked[0])
1601
- out.push({
1812
+ const d = {
1602
1813
  text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
1603
1814
  why: opt && opt.description ? opt.description : '',
1604
1815
  alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
1605
1816
  source: 'asked',
1606
- })
1817
+ }
1818
+ const topic = 'q:' + decisionNorm(q.header || q.question)
1819
+ const was = at.get(topic)
1820
+ if (was != null && decisionNorm(out[was].text) !== decisionNorm(d.text)) { replaced.add(out[was].text); out[was] = null }
1821
+ if (was != null && out[was]) continue // the same answer again
1822
+ at.set(topic, out.length); out.push(d)
1607
1823
  }
1608
1824
  }
1609
1825
  if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
@@ -1612,7 +1828,29 @@ export function decisionsFromMessages(messages) {
1612
1828
  }
1613
1829
  }
1614
1830
  }
1615
- return cleanDecided(out)
1831
+ const found = out.filter(Boolean)
1832
+ const kept = new Set(found.map((d) => decisionNorm(d.text)))
1833
+ return { found, reversed: [...replaced].filter((t) => !kept.has(decisionNorm(t))) }
1834
+ }
1835
+
1836
+ // The decisions a transcript states outright, with no model: a question you
1837
+ // answered from its options (AskUserQuestion, the board's Needs you), its other
1838
+ // options as the alternatives, only its last answer; a plan you approved
1839
+ // (ExitPlanMode). Messages are src/transcript.js's shape, which is also
1840
+ // $.session.messages()'s.
1841
+ export function decisionsFromMessages(messages) {
1842
+ return cleanDecided(readStated(messages).found)
1843
+ }
1844
+
1845
+ // 2026-11-05: what the log settles on its own (src/decisions.js settleStated): the stated decisions,
1846
+ // redacted as checkpoints save them, and the texts of answers changed later in the session.
1847
+ export function statedDecisions(messages, patterns = []) {
1848
+ const red = (t) => redact(String(t || ''), patterns || [])
1849
+ const { found, reversed } = readStated(messages)
1850
+ return {
1851
+ found: cleanDecided(found).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) })),
1852
+ reversed: reversed.map((t) => red(clip(oneLine(t), 240))),
1853
+ }
1616
1854
  }
1617
1855
 
1618
1856
  // The checkpoint's decisions: what the transcript states outright first, then the model's.
@@ -1633,6 +1871,9 @@ const SECTION_KEYS = {
1633
1871
  'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
1634
1872
  'recap': 'recap',
1635
1873
  'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
1874
+ // 2026-11-06: "Got done" is written in code from the commands' output (workFacts); a model
1875
+ // that writes the heading itself is not a source for it, so its lines are dropped
1876
+ 'got done': 'gotDone',
1636
1877
  'decided': 'decided', // 2026-11-03 decision memory
1637
1878
  }
1638
1879
  export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
@@ -1662,6 +1903,7 @@ function parseSections(lines) {
1662
1903
  const push = (k, v) => {
1663
1904
  v = String(v || '').replace(/\*\*/g, '').trim()
1664
1905
  if (!v || NONE_RE.test(v)) return
1906
+ if (k === 'gotDone') return
1665
1907
  if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
1666
1908
  if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
1667
1909
  if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
@@ -1910,7 +2152,7 @@ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own
1910
2152
  export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
1911
2153
 
1912
2154
  export function cleanTitle(text) {
1913
- const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
2155
+ const t = stripChannel(String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '')).split('\n').map((l) => l.trim()).find(Boolean) || '' // 0.9.18: a model that echoes the Live wrapper
1914
2156
  return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1915
2157
  }
1916
2158
 
@@ -19,7 +19,7 @@ import {
19
19
  shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
21
  listHintFrom, backgroundListFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
- boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
22
+ boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText, reconcileWithWork,
23
23
  } from './core.js'
24
24
 
25
25
  const PANE_ID = 'checkpoints'
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.9' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
@@ -866,6 +866,11 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
866
866
  }
867
867
  // 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
868
868
  if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
869
+ // 2026-11-06: a Stopped at / Next that calls a PR this session merged "ready to merge": the facts win
870
+ if (parsed.format !== 'markdown') {
871
+ const fix = reconcileWithWork(parsed, ctx.work)
872
+ if (fix.corrected.length) { parsed = { ...parsed, stoppedAt: fix.stoppedAt, next: fix.next }; generator = { ...(generator || {}), corrected: fix.corrected.join(' ') } }
873
+ }
869
874
  if (opts.next) parsed = { ...parsed, next: opts.next }
870
875
  const cp = {
871
876
  sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
@@ -874,6 +879,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
874
879
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
875
880
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
876
881
  parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
882
+ work: ctx.work || [], // 2026-11-06: what the commands' output shows got done
877
883
  decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
878
884
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
879
885
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
@@ -1107,14 +1113,22 @@ function startReplyLoop($) {
1107
1113
  let backoff = 0
1108
1114
  while (st.replyLoop && st.sessionId) {
1109
1115
  const sid = st.sessionId
1110
- const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
1116
+ const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?cmds=1&ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
1111
1117
  if (!r || !r.ok) {
1112
1118
  // Daemon down: try again on the heartbeat rather than spinning.
1113
1119
  st.replyLoop = false
1114
1120
  return
1115
1121
  }
1116
1122
  const reply = r.json && r.json.reply
1117
- if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
1123
+ // a slash command from the board (the daemon checked it against its list):
1124
+ // Claude Code runs it as if typed, queued behind a running turn (as /rename)
1125
+ if (reply && reply.command && typeof reply.command.name === 'string' && /^[a-z][\w-]{0,30}$/.test(reply.command.name) && sid === st.sessionId) {
1126
+ try {
1127
+ await $.command.run({ command: reply.command.name, args: String(reply.command.args || '').replace(/[\r\n]+/g, ' ').slice(0, 500) })
1128
+ } catch (err) {
1129
+ try { $.ui.toast('Leftoff: couldn’t run the board’s /' + reply.command.name + ': ' + String((err && err.message) || err).slice(0, 120)) } catch {}
1130
+ }
1131
+ } else if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
1118
1132
  try {
1119
1133
  await $.prompt.submit({ text: String(reply.text) })
1120
1134
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.9",
3
+ "version": "0.9.18",
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",