@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.
- package/CHANGELOG.md +5 -1
- package/bin/leftoff.js +563 -535
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +163 -7
- package/mod/hooks/register.js +25 -2
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +59 -0
- package/ui/app.js +136 -1
- package/ui/ask.js +4 -2
- package/ui/decisions.js +133 -0
- package/ui/semdiff.js +23 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
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}
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|