@leftoff/cli 0.9.18-beta.4 → 0.9.18-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/bin/leftoff.js +601 -557
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +163 -7
- package/mod/hooks/register.js +144 -6
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +96 -0
- package/ui/app.js +154 -2
- package/ui/ask.js +4 -2
- package/ui/decisions.js +133 -0
- package/ui/hook-fix.js +109 -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,
|
|
@@ -27,9 +27,12 @@ const POST_TIMEOUT_MS = 800
|
|
|
27
27
|
const END_TIMEOUT_MS = 1000
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
|
|
30
|
+
const SKIP_GRACE_MS = 4000 // 2026-11-03 how long a mod event waits for its classic twin before that classic event counts as passed over
|
|
31
|
+
const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can be slow)
|
|
32
|
+
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
30
33
|
|
|
31
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
32
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.6' // keep in step with .claude-plugin/plugin.json
|
|
33
36
|
|
|
34
37
|
const st = {
|
|
35
38
|
internal: false,
|
|
@@ -72,6 +75,13 @@ const st = {
|
|
|
72
75
|
paneNeeds: 0, // sessions that need you (the pane's top line)
|
|
73
76
|
turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
|
|
74
77
|
stopAt: 0, // and when a Stop last came (classic.Stop)
|
|
78
|
+
// 2026-11-03 Claude Code 2.1.31x passes every classic.* event of a plugin you
|
|
79
|
+
// installed over when its built-in cc-plugin-sec-default is seated first (a
|
|
80
|
+
// Team or Enterprise plan, or managed settings): see SKIP_GRACE_MS below.
|
|
81
|
+
classicSeen: false, // a classic.* hook of this mod ran in this process
|
|
82
|
+
hooksSkipped: null, // the classic events we saw passed over, as last told to the daemon
|
|
83
|
+
permAt: 0, // when classic.PermissionRequest last ran
|
|
84
|
+
watchAsks: {}, // tool_use_id -> { tool, at, reported, done }: permission prompts tool.check saw
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
// Millisecond precision: the daemon dedupes events per (sessionId, type, at).
|
|
@@ -233,7 +243,7 @@ async function readRepo($, cwd) {
|
|
|
233
243
|
|
|
234
244
|
async function heartbeat($) {
|
|
235
245
|
if (st.internal || !st.sessionId) return
|
|
236
|
-
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
|
|
246
|
+
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn), ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}) })
|
|
237
247
|
if (res && !st.modHealthSent) await reportModHealth($)
|
|
238
248
|
if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
|
|
239
249
|
else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came (it never goes: tabMarkerKept), or a rename that had to wait
|
|
@@ -256,6 +266,89 @@ async function stopFallback($, completedAt, reason) {
|
|
|
256
266
|
await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
|
|
257
267
|
}
|
|
258
268
|
|
|
269
|
+
// ---------------------------------------------------------------- passed-over classic hooks (2026-11-03)
|
|
270
|
+
//
|
|
271
|
+
// Claude Code 2.1.31x seats its built-in "cc-plugin-sec-default" outermost on
|
|
272
|
+
// a Team or Enterprise plan or a machine with managed settings, and its hooks
|
|
273
|
+
// module answers every classic.* event with next.to(e, "append"): the event
|
|
274
|
+
// jumps past the user tier, where a plugin you installed (this one) sits, so
|
|
275
|
+
// none of our classic.* hooks run (Claude Code then says "leftoff: its
|
|
276
|
+
// classic.Stop hook was skipped. "cc-plugin-sec-default" runs first and sent
|
|
277
|
+
// the event on past it."). Our other events are untouched: session.start,
|
|
278
|
+
// prompt.submit, turn.start, turn.complete, tool.check and tool.call.
|
|
279
|
+
// So: each of those notes the classic event that should come with it; when it
|
|
280
|
+
// doesn't come within the grace and no classic hook has run in this process at
|
|
281
|
+
// all, the classic event counts as passed over and the daemon hears which.
|
|
282
|
+
function sawClassic($, name) {
|
|
283
|
+
if (name === 'PermissionRequest') st.permAt = Date.now()
|
|
284
|
+
if (st.classicSeen) return
|
|
285
|
+
st.classicSeen = true
|
|
286
|
+
if (st.hooksSkipped && st.hooksSkipped.length) { // a slow SessionStart we counted out too early, a setting changed: they run
|
|
287
|
+
st.hooksSkipped = []
|
|
288
|
+
sendOrSpool($, 'heartbeat', { hooksSkipped: [], fromMod: true }).catch(() => {})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function expectClassic($, name, graceMs) {
|
|
293
|
+
if (st.classicSeen || st.internal) return
|
|
294
|
+
$.clock.after(graceMs || SKIP_GRACE_MS, () => { noteSkipped($, name).catch(() => {}) })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function noteSkipped($, name) {
|
|
298
|
+
if (st.classicSeen || st.internal || !st.sessionId) return
|
|
299
|
+
const cur = st.hooksSkipped || []
|
|
300
|
+
if (cur.includes(name)) return
|
|
301
|
+
st.hooksSkipped = cur.concat(name)
|
|
302
|
+
await sendOrSpool($, 'heartbeat', { hooksSkipped: st.hooksSkipped, fromMod: true })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A permission prompt, seen from tool.check: next(e) is the whole check
|
|
306
|
+
// (rules, modes, other plugins' hooks, Claude Code's own), and { decision: "ask" }
|
|
307
|
+
// with approver "person" is the terminal's permission dialog (a host such as
|
|
308
|
+
// headless answers "host"; no approver means it is never asked). We only
|
|
309
|
+
// look: the answer goes back as it came. Reported as Claude Code's own
|
|
310
|
+
// permission_prompt notification would be, unless classic.PermissionRequest
|
|
311
|
+
// ran for it (then permissionHook has put it on the board, answerable there).
|
|
312
|
+
function watchAsk($, e) {
|
|
313
|
+
if (st.internal || st.headless || !st.sessionId) return
|
|
314
|
+
const at = Date.now()
|
|
315
|
+
const id = String(e.tool_use_id || (String(e.tool || '') + '@' + at))
|
|
316
|
+
st.watchAsks[id] = { tool: e.tool, at, reported: false, done: false }
|
|
317
|
+
expectClassic($, 'PermissionRequest')
|
|
318
|
+
$.clock.after(PERM_GRACE_MS, () => { reportAsk($, id, e).catch(() => {}) })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function reportAsk($, id, e) {
|
|
322
|
+
const w = st.watchAsks[id]
|
|
323
|
+
if (!w || w.done) return
|
|
324
|
+
if (st.permAt >= w.at) { delete st.watchAsks[id]; return } // its PermissionRequest hook ran: the board has it
|
|
325
|
+
w.reported = true
|
|
326
|
+
await sendOrSpool($, 'notification', {
|
|
327
|
+
notificationType: 'permission_prompt', message: clip(askMessage(e.tool), 200), tool: e.tool || null, fromMod: true,
|
|
328
|
+
...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function askMessage(tool) {
|
|
333
|
+
if (tool === 'AskUserQuestion') return 'Claude has a question for you'
|
|
334
|
+
return 'Claude needs your permission to use ' + (tool || 'a tool')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The prompt went away: the tool runs (tool.call) or the turn is over. Says so
|
|
338
|
+
// only for prompts the mod reported (an activity the board reads as a tool
|
|
339
|
+
// that ran, which ends "Needs you" for a permission prompt).
|
|
340
|
+
async function settleAsks($, toolUseId, tool) {
|
|
341
|
+
let reported = false
|
|
342
|
+
for (const id of Object.keys(st.watchAsks)) {
|
|
343
|
+
const w = st.watchAsks[id]
|
|
344
|
+
if (toolUseId !== undefined && (toolUseId ? id !== String(toolUseId) : w.tool !== tool)) continue
|
|
345
|
+
w.done = true
|
|
346
|
+
if (w.reported) reported = true
|
|
347
|
+
delete st.watchAsks[id]
|
|
348
|
+
}
|
|
349
|
+
if (reported) await sendOrSpool($, 'activity', { kind: 'tool', phase: 'post', tool: tool || null, fromMod: true })
|
|
350
|
+
}
|
|
351
|
+
|
|
259
352
|
// The commands, in the order they're tried. Claude Code 2.1.295's built-ins
|
|
260
353
|
// own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
|
|
261
354
|
// ours share a /cp prefix; each command lists fallbacks in case a later
|
|
@@ -384,6 +477,20 @@ async function unlockOwnParkLocks($, cwd) {
|
|
|
384
477
|
} catch {}
|
|
385
478
|
}
|
|
386
479
|
|
|
480
|
+
// ------------------------------------------------------------ decisions (2026-11-03)
|
|
481
|
+
|
|
482
|
+
// The daemon's short read of this repo's accepted decisions (id + one line each, capped), or null:
|
|
483
|
+
// nothing for Leftoff's own runs or a board-run headless session, when the daemon doesn't answer quickly,
|
|
484
|
+
// or when the repo has none.
|
|
485
|
+
const DECISIONS_TIMEOUT_MS = 800
|
|
486
|
+
async function decisionContext($, e) {
|
|
487
|
+
if (st.internal || await isHeadless($)) return null
|
|
488
|
+
const q = '?session=' + encodeURIComponent(e.session_id || st.sessionId || '') + (e.cwd ? '&cwd=' + encodeURIComponent(e.cwd) : '')
|
|
489
|
+
const r = await api($, 'GET', '/v1/decisions/context' + q, undefined, DECISIONS_TIMEOUT_MS)
|
|
490
|
+
const t = r && r.ok && r.json && typeof r.json.context === 'string' ? r.json.context.trim() : ''
|
|
491
|
+
return t ? t.slice(0, 2000) : null
|
|
492
|
+
}
|
|
493
|
+
|
|
387
494
|
// ------------------------------------------------------------ lists (v0.5.1)
|
|
388
495
|
|
|
389
496
|
// This session's saved lists, when they changed since Claude last heard
|
|
@@ -748,6 +855,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
748
855
|
doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
|
|
749
856
|
userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
|
|
750
857
|
parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
|
|
858
|
+
decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
|
|
751
859
|
decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
|
|
752
860
|
didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
|
|
753
861
|
}
|
|
@@ -1042,6 +1150,7 @@ export function register(on, options) {
|
|
|
1042
1150
|
st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
|
|
1043
1151
|
st.ready = true
|
|
1044
1152
|
startReplyLoop($)
|
|
1153
|
+
expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
|
|
1045
1154
|
} catch {}
|
|
1046
1155
|
// Commands last, each on its own: a taken name throws, so a
|
|
1047
1156
|
// clash costs only that command, and registerCommands reports it.
|
|
@@ -1050,6 +1159,7 @@ export function register(on, options) {
|
|
|
1050
1159
|
})
|
|
1051
1160
|
|
|
1052
1161
|
on('classic.SessionStart', async ($, e, next) => {
|
|
1162
|
+
sawClassic($, 'SessionStart')
|
|
1053
1163
|
if (!(await internalOnce($)) && !st.internal && e.session_id) {
|
|
1054
1164
|
let parentSessionId
|
|
1055
1165
|
if (st.sessionId && e.session_id !== st.sessionId) {
|
|
@@ -1077,11 +1187,20 @@ export function register(on, options) {
|
|
|
1077
1187
|
const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
|
|
1078
1188
|
await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
|
|
1079
1189
|
}
|
|
1190
|
+
// 2026-11-03 decision memory: the repo's accepted decisions go to Claude as this event's context.
|
|
1191
|
+
// Added to what the hooks below (yours included) answer, never in place of it.
|
|
1192
|
+
const ctx = await decisionContext($, e)
|
|
1193
|
+
if (ctx) {
|
|
1194
|
+
const res = await next(e)
|
|
1195
|
+
const r = res && typeof res === 'object' ? res : {}
|
|
1196
|
+
return { ...r, additionalContext: [...(Array.isArray(r.additionalContext) ? r.additionalContext : []), ctx] }
|
|
1197
|
+
}
|
|
1080
1198
|
}
|
|
1081
1199
|
return next(e)
|
|
1082
1200
|
})
|
|
1083
1201
|
|
|
1084
1202
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
1203
|
+
sawClassic($, 'UserPromptSubmit')
|
|
1085
1204
|
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
|
|
1086
1205
|
return next(e)
|
|
1087
1206
|
})
|
|
@@ -1091,6 +1210,7 @@ export function register(on, options) {
|
|
|
1091
1210
|
// context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
|
|
1092
1211
|
// Context goes on the event before next(); on the result it would be too late.
|
|
1093
1212
|
on('prompt.submit', async ($, e, next) => {
|
|
1213
|
+
expectClassic($, 'UserPromptSubmit')
|
|
1094
1214
|
const ctx = await listContextFor($)
|
|
1095
1215
|
return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
|
|
1096
1216
|
})
|
|
@@ -1104,6 +1224,7 @@ export function register(on, options) {
|
|
|
1104
1224
|
on('turn.complete', async ($, e, next) => {
|
|
1105
1225
|
// classic.Stop reports a normal end with its details; Stop doesn't fire for
|
|
1106
1226
|
// an interrupted or failed turn, so those are reported here.
|
|
1227
|
+
await settleAsks($, undefined).catch(() => {}) // 2026-11-03 a permission prompt the mod reported is over with the turn
|
|
1107
1228
|
if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
|
|
1108
1229
|
// 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
|
|
1109
1230
|
// (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
|
|
@@ -1111,11 +1232,12 @@ export function register(on, options) {
|
|
|
1111
1232
|
// for good. If no Stop came for this turn shortly after it completed, the
|
|
1112
1233
|
// end is reported from here; a Stop that comes later is the same turn
|
|
1113
1234
|
// (the daemon doesn't count it twice).
|
|
1114
|
-
else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }) }
|
|
1235
|
+
else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
|
|
1115
1236
|
return next(e)
|
|
1116
1237
|
})
|
|
1117
1238
|
|
|
1118
1239
|
on('classic.Stop', async ($, e, next) => {
|
|
1240
|
+
sawClassic($, 'Stop')
|
|
1119
1241
|
st.stopAt = Date.now()
|
|
1120
1242
|
// v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
|
|
1121
1243
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
@@ -1140,20 +1262,36 @@ export function register(on, options) {
|
|
|
1140
1262
|
})
|
|
1141
1263
|
|
|
1142
1264
|
on('classic.Notification', async ($, e, next) => {
|
|
1265
|
+
sawClassic($, 'Notification')
|
|
1143
1266
|
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
|
|
1144
1267
|
return next(e)
|
|
1145
1268
|
})
|
|
1146
1269
|
|
|
1147
|
-
on('classic.PermissionRequest', permissionHook)
|
|
1270
|
+
on('classic.PermissionRequest', async ($, e, next) => { sawClassic($, 'PermissionRequest'); return permissionHook($, e, next) })
|
|
1271
|
+
|
|
1272
|
+
// 2026-11-03 a permission prompt, seen whether or not classic.PermissionRequest
|
|
1273
|
+
// reaches us (see watchAsk): we only look, and hand the answer back as it came.
|
|
1274
|
+
on('tool.check', async ($, e, next) => {
|
|
1275
|
+
const r = await next(e)
|
|
1276
|
+
try { if (r && r.decision === 'ask' && e && e.approver === 'person') watchAsk($, e) } catch {}
|
|
1277
|
+
return r
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
// The tool runs: a prompt the mod reported for it was answered in the terminal.
|
|
1281
|
+
on('tool.call', async ($, e, next) => {
|
|
1282
|
+
if (e && Object.keys(st.watchAsks).length) await settleAsks($, e.tool_use_id || null, e.tool).catch(() => {})
|
|
1283
|
+
return next(e)
|
|
1284
|
+
})
|
|
1148
1285
|
|
|
1149
1286
|
on('classic.PostToolUse', async ($, e, next) => {
|
|
1287
|
+
sawClassic($, 'PostToolUse')
|
|
1150
1288
|
await toolRan($, e)
|
|
1151
1289
|
const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
|
|
1152
1290
|
if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
|
|
1153
1291
|
return next(e)
|
|
1154
1292
|
})
|
|
1155
1293
|
|
|
1156
|
-
on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
1294
|
+
on('classic.PostToolUseFailure', async ($, e, next) => { sawClassic($, 'PostToolUseFailure'); await toolRan($, e); return next(e) })
|
|
1157
1295
|
|
|
1158
1296
|
on('session.end', async ($, e, next) => {
|
|
1159
1297
|
// About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
|
|
@@ -11,10 +11,12 @@ How to walk through a change:
|
|
|
11
11
|
|
|
12
12
|
1. Order the steps the way the change makes sense to read: the core idea first, then what follows from it. Not file order.
|
|
13
13
|
2. Each step gets a short title and one or two sentences of why: the request it answers, the bug it fixes, or the trade-off you chose and the alternative you didn't take.
|
|
14
|
-
3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along, imports and formatting are mechanical and need no story.
|
|
14
|
+
3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along (updated to match the change), imports and formatting are mechanical and need no story.
|
|
15
|
+
- A test that doesn't really test anything is never mechanical and never safe to skip. That is a test that asserts nothing meaningful or asserts a tautology, mocks the very thing under test, snapshots its own output, had an assertion loosened or deleted to make it pass, or was skipped or disabled. Put it in a step and give it a look callout at its line saying why.
|
|
15
16
|
4. Point out what deserves a second look, at the exact line: something risky or subtle (look), a guess the code makes (assumption), or behaviour no test covers (untested). Be honest about the parts you were unsure of.
|
|
16
|
-
5.
|
|
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",
|