@leftoff/cli 0.9.18-beta.5 → 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.5",
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,
@@ -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.5' // 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
36
36
 
37
37
  const st = {
38
38
  internal: false,
@@ -477,6 +477,20 @@ async function unlockOwnParkLocks($, cwd) {
477
477
  } catch {}
478
478
  }
479
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
+
480
494
  // ------------------------------------------------------------ lists (v0.5.1)
481
495
 
482
496
  // This session's saved lists, when they changed since Claude last heard
@@ -841,6 +855,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
841
855
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
842
856
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
843
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
844
859
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
845
860
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
846
861
  }
@@ -1172,6 +1187,14 @@ export function register(on, options) {
1172
1187
  const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
1173
1188
  await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
1174
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
+ }
1175
1198
  }
1176
1199
  return next(e)
1177
1200
  })
@@ -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.5",
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",
package/ui/app.css CHANGED
@@ -5433,3 +5433,62 @@ code .file-ref-off { font-size: inherit; }
5433
5433
  .hf-acts { justify-content: stretch; }
5434
5434
  .hf-acts .btn { flex: 1 1 auto; }
5435
5435
  }
5436
+
5437
+ /* 2026-11-03 decision memory */
5438
+ .dec-list { list-style: none; margin: 0; padding: 0 0 6px; display: grid; gap: 10px; }
5439
+ .dec-row { font-size: 13.5px; line-height: 1.45; color: var(--text); }
5440
+ .dec-id { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); }
5441
+ .dec-why, .dec-alt, .dec-src { font-size: 12.5px; color: var(--text2); margin-top: 2px; }
5442
+ .dec-src { color: var(--muted, var(--text2)); }
5443
+ .dec-st { margin-left: 6px; font-size: 12px; color: var(--text2); white-space: nowrap; }
5444
+ .dec-open { display: inline-flex; align-items: center; min-height: 44px; font-size: 13px; }
5445
+
5446
+ .dec-page .dec-head h1 { margin: 4px 0 2px; }
5447
+ .dec-tl { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 0; }
5448
+ .dec-item { position: relative; padding: 12px 0 12px 18px; border-left: 2px solid var(--line); margin-left: 6px; }
5449
+ .dec-item::before { content: ''; position: absolute; left: -6px; top: 17px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 2px solid var(--text2); box-sizing: border-box; }
5450
+ .dec-item.dec-accepted::before { background: var(--text2); }
5451
+ .dec-item.dec-superseded, .dec-item.dec-rejected { opacity: .78; }
5452
+ .dec-item.dec-superseded::before { border-color: var(--line-strong, var(--line)); }
5453
+ .dec-item.dec-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; }
5454
+ .dec-h { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12.5px; color: var(--text2); }
5455
+ .dec-date { font-variant-numeric: tabular-nums; }
5456
+ .dec-repo { padding: 1px 6px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; }
5457
+ .dec-item .dec-st { margin-left: 0; }
5458
+ .dec-item .dec-text { font-size: 15px; line-height: 1.45; margin-top: 4px; color: var(--text); overflow-wrap: anywhere; }
5459
+ .dec-meta { font-size: 12.5px; color: var(--text2); margin-top: 4px; }
5460
+ .dec-chains { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; font-size: 12.5px; color: var(--text2); }
5461
+ .dec-chip { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; min-height: 24px; cursor: pointer; position: relative; }
5462
+ .dec-chip::after { content: ''; position: absolute; inset: -10px -4px; }
5463
+ .dec-chip:hover { border-color: var(--text2); }
5464
+ .dec-sug { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; padding: 8px 10px; border: 1px dashed var(--line); border-radius: 8px; font-size: 13px; }
5465
+ .dec-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
5466
+ .dec-acts .btn, .dec-acts .linkish { min-height: 44px; }
5467
+ .dec-acts-quiet { margin-top: 0; }
5468
+ .dec-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 4px; }
5469
+ .dec-ctl .sask-opt, .dec-ctl .btn, .dec-store-opts .sask-opt { min-height: 44px; }
5470
+ .dec-store-h { font-weight: 600; margin-bottom: 6px; }
5471
+ .dec-store-opts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
5472
+ .dec-store p { margin: 8px 0 0; max-width: 62ch; }
5473
+ .dec-edit { display: grid; gap: 8px; margin-top: 8px; max-width: 640px; }
5474
+ .dec-edit label { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
5475
+ .dec-in { font: inherit; font-size: 15px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); min-height: 44px; box-sizing: border-box; width: 100%; }
5476
+ @media (pointer: coarse) { .dec-in { font-size: 16px; } }
5477
+ .dec-in:focus-visible { outline: 2px solid var(--accent, var(--text2)); outline-offset: 1px; }
5478
+ .dec-flash { background: color-mix(in srgb, var(--text2) 10%, transparent); transition: background .6s; }
5479
+ @media (prefers-reduced-motion: reduce) { .dec-flash { transition: none; } }
5480
+ .dec-proj p { margin: 4px 0 10px; }
5481
+
5482
+ .wt-decs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
5483
+ .wt-dec { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 4px 8px; font-weight: 400; }
5484
+ @media (pointer: coarse) { .wt-dec { min-height: 44px; } }
5485
+ .wt-dec .wt-dec-id { font-weight: 600; }
5486
+ .wt-dec .wt-dec-t { font: 12.5px/1.3 var(--sans, system-ui, sans-serif); color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42ch; }
5487
+ .wt-dec-gone { border-style: dashed; }
5488
+ .wt-dec-w { font: 12px/1 var(--sans, system-ui, sans-serif); color: var(--text2); }
5489
+ .dec-chainview { margin-top: 4px; }
5490
+
5491
+ .dv-isdec .dv-dl { display: none !important; }
5492
+ .dec-ctl .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: transparent; color: var(--text2); cursor: pointer; }
5493
+ .dec-ctl .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); }
5494
+ @media (min-width: 700px) { .dv-isdec .dv-sheet { max-width: 760px; height: auto; max-height: 80vh; margin-left: auto; margin-right: auto; } }
package/ui/app.js CHANGED
@@ -74,6 +74,7 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
74
74
  import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
75
75
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
76
76
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
77
+ import { decidedListHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
77
78
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
78
79
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
79
80
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
@@ -2940,6 +2941,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2940
2941
  // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2941
2942
  side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2942
2943
  side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
2944
+ if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) noteSessionDecisions(s)
2945
+ if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) side += foldHtml({ key: 'cp-decided', title: 'Decided', count: cps[0].decided.length, open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(cps[0].decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null }) }, { esc }) // 2026-11-03 decision memory
2943
2946
  if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
2944
2947
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2945
2948
  if (doubted.length) side += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
@@ -4443,6 +4446,131 @@ function cpHeadLine(cp) {
4443
4446
  return '<div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
4444
4447
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4445
4448
  }
4449
+ // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4450
+ const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {} }
4451
+ // The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
4452
+ function noteSessionDecisions(s) {
4453
+ const repo = s && s.decisionRepo
4454
+ if (!repo || decisionsUi.loadedRepo[repo]) return
4455
+ decisionsUi.loadedRepo[repo] = true
4456
+ api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4457
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
4458
+ const by = {}
4459
+ for (const d of v.decisions || []) if (d.sessionId === s.id) { by[norm(d.text)] = d; if (d.original) by[norm(d.original.text)] = d }
4460
+ decisionsUi.bySession[s.id] = by
4461
+ const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4462
+ const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4463
+ if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo })
4464
+ }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4465
+ }
4466
+ // ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
4467
+ async function viewDecisions(arg) {
4468
+ const raw = decodeURIComponent(arg || '')
4469
+ if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
4470
+ decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
4471
+ const want = state.route && state.route.params && state.route.params.get('d')
4472
+ if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
4473
+ paintDecisions()
4474
+ if (want) { const el = document.getElementById('dec-' + want); if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600) } }
4475
+ }
4476
+ function paintDecisions() {
4477
+ if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
4478
+ $app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed) }, { esc })
4479
+ if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
4480
+ }
4481
+ async function decisionAction(repo, ref, action, body = {}) {
4482
+ try {
4483
+ await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
4484
+ decisionsUi.loadedRepo = {}
4485
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4486
+ else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
4487
+ } catch (e) { toast(e.message || String(e)) }
4488
+ }
4489
+ document.addEventListener('click', async (ev) => {
4490
+ const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
4491
+ if (!btn || btn.tagName === 'FORM') return
4492
+ const a = btn.dataset.action
4493
+ if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
4494
+ if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
4495
+ if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; return paintDecisions() }
4496
+ if (a === 'dec-cancel') { decisionsUi.editing = null; return paintDecisions() }
4497
+ if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
4498
+ if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4499
+ if (a === 'dec-show') {
4500
+ const el = document.getElementById('dec-' + btn.dataset.id)
4501
+ if (el) { el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600); return }
4502
+ if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
4503
+ return
4504
+ }
4505
+ if (a === 'dec-store') {
4506
+ const on = btn.dataset.on === 'true'
4507
+ try {
4508
+ await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
4509
+ toast(on ? 'Accepted decisions now go into each repo’s docs/DECISIONS.md' : 'The decision log stays in Leftoff; repo files are left as they are')
4510
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4511
+ else loadProjectDecisions(qaUi.token, true)
4512
+ } catch (e) { toast(e.message || String(e)) }
4513
+ }
4514
+ })
4515
+ // A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
4516
+ document.addEventListener('click', (ev) => {
4517
+ const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
4518
+ if (!b) return
4519
+ ev.preventDefault()
4520
+ const s = wtSession(wtUi.sid)
4521
+ openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b)
4522
+ })
4523
+ async function openDecisionViewer(repo, id, from) {
4524
+ if (!repo || !id) return
4525
+ let v
4526
+ try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
4527
+ const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
4528
+ const d = byId.get(id)
4529
+ const el = docViewerEl()
4530
+ el.querySelector('.dv-title').textContent = 'Decision ' + id
4531
+ el.querySelector('.dv-dl').hidden = true
4532
+ el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
4533
+ el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
4534
+ // the chain: what it replaced (oldest first), the decision, what replaced it
4535
+ const chain = []
4536
+ if (d) {
4537
+ const back = []; let cur = d, guard = 0
4538
+ while (cur && cur.supersedes && cur.supersedes.length && guard++ < 20) { const o = byId.get(cur.supersedes[0]); if (!o || back.includes(o)) break; back.unshift(o); cur = o }
4539
+ chain.push(...back, d)
4540
+ cur = d; guard = 0
4541
+ while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
4542
+ }
4543
+ el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map((x) => decisionItemHtml(x, { esc, byId, may: false })).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') +
4544
+ '<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
4545
+ el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
4546
+ refUi.from = from || null
4547
+ el.hidden = false
4548
+ document.documentElement.classList.add('dv-open')
4549
+ const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
4550
+ }
4551
+ document.addEventListener('submit', (ev) => {
4552
+ const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
4553
+ if (!f) return
4554
+ ev.preventDefault()
4555
+ const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
4556
+ decisionsUi.editing = null
4557
+ decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4558
+ })
4559
+ // The project page's Decisions section
4560
+ async function loadProjectDecisions(token, force = false) {
4561
+ if (!token) return
4562
+ try {
4563
+ const v = await api('GET', '/v1/decisions?project=' + encodeURIComponent(token), undefined, { timeoutMs: 8000 })
4564
+ decisionsUi.project = { token, v }
4565
+ paintProjectDecisions()
4566
+ } catch { decisionsUi.project = null }
4567
+ }
4568
+ function paintProjectDecisions() {
4569
+ const box = document.getElementById('projDecisionsBox')
4570
+ const pd = decisionsUi.project
4571
+ if (!box || !pd || pd.token !== qaUi.token) return
4572
+ box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
4573
+ }
4446
4574
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4447
4575
  // your note and the files behind More (open or closed as you last left it on this device).
4448
4576
  function cpLatestHtml(cp, wtSid, paths) {
@@ -6646,6 +6774,7 @@ async function render() {
6646
6774
  else if (r.name === 'search') await viewSearch(r.q || '')
6647
6775
  else if (r.name === 'new') await viewNewSession()
6648
6776
  else if (r.name === 'project' && r.arg) await viewProject(r.arg)
6777
+ else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
6649
6778
  else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
6650
6779
  else if (r.name === 'setup') await viewSetup()
6651
6780
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
@@ -6680,6 +6809,7 @@ async function viewProject(token) {
6680
6809
  const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
6681
6810
  qaUi.page = page
6682
6811
  paintProject()
6812
+ if (page && page.scope && page.scope.kind === 'project') loadProjectDecisions(token)
6683
6813
  if (fresh) window.scrollTo(0, 0)
6684
6814
  const r = state.route || {}
6685
6815
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -6699,6 +6829,11 @@ function paintProject() {
6699
6829
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
6700
6830
  $app.innerHTML = out.html
6701
6831
  fillAsk($app, out.keys)
6832
+ if (p.scope && p.scope.kind === 'project') { // 2026-11-03 decision memory: the project's log and where it's kept
6833
+ const at = document.getElementById('projAsks')
6834
+ if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
6835
+ paintProjectDecisions()
6836
+ }
6702
6837
  const ta = document.getElementById('askIn')
6703
6838
  if (ta) { ta.value = state.drafts['ask:' + ((p.scope || {}).key || '')] || ''; fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
6704
6839
  }
@@ -9538,7 +9673,7 @@ function docViewerEl() {
9538
9673
  document.body.appendChild(el)
9539
9674
  return el
9540
9675
  }
9541
- function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9676
+ function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9542
9677
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9543
9678
  async function openDoc(sessionId, { path, plan } = {}) {
9544
9679
  if (!sessionId || (!path && !plan)) return