@leftoff/cli 0.9.18-beta.5 → 0.9.18-beta.7

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.
@@ -0,0 +1,43 @@
1
+ # Test plans from merged PRs
2
+
3
+ Merging 50 PRs shouldn't need 50 reviews to feel safe. A test plan turns what Leftoff already knows about a batch of merged work into one short manual script: what to click, what you should see, and why it's on the list, ordered by risk and fitted to the time you have.
4
+
5
+ ## Where to start one
6
+
7
+ - A project's page: **Test plan**, next to Decisions and Ask.
8
+ - A repo's or a project's decision log: **Test plan**.
9
+ - The PRs page: pick merged PRs, then **Test plan for these**.
10
+ - The terminal: `leftoff testplan --project acme` (or `--repo web`), with `--since 14d` and `--minutes 15`, `30` or `60`. It prints Markdown.
11
+
12
+ The window is the last 7 days unless you say otherwise. The budget is 30 minutes; 15 and 60 are the other choices, picked for each plan (it isn't a setting).
13
+
14
+ ## What it reads
15
+
16
+ Only Leftoff's own notes, plus what the PRs page already reads with `gh` where it's signed in:
17
+
18
+ - the PRs merged in the window that belong to the project or repo (or the ones you picked), each with its sessions;
19
+ - each PR's sessions' newest checkpoint (Goal, Progress, Decided);
20
+ - the walkthrough kept for its session: steps, Look / Assumption / Untested callouts, weak-test flags, spots, and the decisions a step follows or departs from. A PR with no kept walkthrough is not walked through on its own; it's read from its files, checkpoints and decisions and marked **no walkthrough**. **Walk through the N without one first** runs the usual walkthrough for each, one at a time;
21
+ - the decisions these PRs accepted, superseded or departed from.
22
+
23
+ ## What it makes
24
+
25
+ Up to about 12 checks that fit the budget. Each has a title, where to go (a screen, command or endpoint), at most 5 steps, what you should see, why it's on the list (with chips for the PRs, steps, checkpoints and decisions it comes from), minutes, and its risk as a shape and a word (▲ High, ◆ Medium, ▽ Low). Checks are ordered by risk, then by how many PRs each covers; checks on the same flow sit together.
26
+
27
+ These always become checks, or are listed under **Not covered** with the reason: every Look and Untested callout on a Core step, every weak test (the test isn't trusted, so the behaviour gets a manual check), every decision a PR departs from or that was superseded in the window ("D-b replaced D-a on Nov 2: confirm the new behaviour, and that the old one is gone"), and every migration, config or auth change. A last line says how many of the PRs have at least one check.
28
+
29
+ Every citation is checked against what Leftoff gave the model. A tag it made up is dropped, and a check left with none is dropped too, with a note under **What was checked in the answer**.
30
+
31
+ ## How it runs
32
+
33
+ The same way Ask does: Claude Code headless and read-only (Read, Grep and Glob, nothing that changes a file, the same budget and timeout), never a session on the board. Without Claude Code it uses your summary model with a smaller pack; with neither, it makes the plan from the rules above. Starting one from the board follows Ask's rule for a model run: this Mac's own browser, or a paired device with **Trust my paired devices** on.
34
+
35
+ ## While you test
36
+
37
+ Tick a check **Checked** or **Failed**; "3 of 9 checked" shows at the top. A failed check takes a one-line note, which goes into the export, and offers **Propose a decision**: what you learned goes into the repo's decision log as a proposed correction, linked to the plan, for you to accept or not later. It is never accepted on its own.
38
+
39
+ **Copy as Markdown** gives a checklist with checkboxes; **Download .html** gives one self-contained file in the walkthrough export's look.
40
+
41
+ ## Where it's kept
42
+
43
+ On the Mac that wrote it, in `~/.config/checkpoints/testplans/`, the newest 100 for 90 days, each deletable. Never in the synced folder. The list shows your other Macs' plans too, read over the peer link. Nothing is posted to GitHub or sent to anyone.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.5",
3
+ "version": "0.9.18-beta.7",
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 }
@@ -683,7 +687,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
683
687
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
684
688
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
685
689
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
686
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort']
690
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort', 'decisionsGiven']
687
691
 
688
692
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
689
693
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -1389,10 +1393,16 @@ 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
- 'Decisions:',
1395
- '- <a decision made in the session and the reason, one line each, at most 4>',
1404
+ 'Approach notes:',
1405
+ '- <how the work is being done: the approach taken, a pattern or constraint it follows; one line each, at most 4. Not a choice between alternatives: those go only under Decided>',
1396
1406
  'Open questions:',
1397
1407
  '- <an unresolved question or uncertainty, at most 3>',
1398
1408
  'Files:',
@@ -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,28 +1473,172 @@ 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
1475
1626
  // model (or a checkpoint file) uses for them.
1476
1627
  const SECTION_KEYS = {
1477
- 'decisions': 'decisions', 'key decisions': 'decisions',
1628
+ // 1i (testplan): the section is "Approach notes" now (real choices go only under Decided); a checkpoint
1629
+ // written before, or a model still saying "Decisions:" / "Key decisions", reads into the same field.
1630
+ 'approach notes': 'decisions', 'approach': 'decisions', 'decisions': 'decisions', 'key decisions': 'decisions',
1478
1631
  'open questions': 'questions', 'questions': 'questions',
1479
1632
  'files': 'files', 'files touched': 'files',
1480
1633
  'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
1481
1634
  'recap': 'recap',
1482
1635
  'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
1636
+ 'decided': 'decided', // 2026-11-03 decision memory
1483
1637
  }
1484
- export const SECTION_TITLES = { decisions: 'Key decisions', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1638
+ export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1485
1639
  const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
1486
1640
  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 }
1641
+ const LIMITS = { decided: DECIDED_MAX, decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1488
1642
 
1489
1643
  // "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
1490
1644
  export function parseFileNote(text) {
@@ -1495,7 +1649,7 @@ export function parseFileNote(text) {
1495
1649
  return p ? { path: p[1], why: '' } : { path: '', why: t }
1496
1650
  }
1497
1651
 
1498
- function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1652
+ function emptyExtras() { return { decided: [], decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1499
1653
  export function hasExtras(cp) {
1500
1654
  return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
1501
1655
  }
@@ -1510,6 +1664,7 @@ function parseSections(lines) {
1510
1664
  if (!v || NONE_RE.test(v)) return
1511
1665
  if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
1512
1666
  if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
1667
+ if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
1513
1668
  ex[k].push(v)
1514
1669
  }
1515
1670
  for (const raw of lines) {
@@ -1528,8 +1683,11 @@ function parseSections(lines) {
1528
1683
  if (item) push(cur, item[1])
1529
1684
  else if (cur === 'recap') push(cur, line)
1530
1685
  else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
1686
+ else if (cur === 'decided') continue // a decision is one bullet line; loose prose under the heading is not one
1531
1687
  else push(cur, line)
1532
1688
  }
1689
+ // 1i: a line a model put under both Decided and the approach notes is a decision: it stays under Decided only
1690
+ if (ex.decided.length && ex.decisions.length) { const dn = new Set(ex.decided.map((d) => decisionNorm(d.text))); ex.decisions = ex.decisions.filter((t) => !dn.has(decisionNorm(String(t).split(/\s+·\s+|\s+[-—–]\s+because\b/)[0]))) }
1533
1691
  for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
1534
1692
  ex.recap = clip(ex.recap, 1600)
1535
1693
  return ex
@@ -1707,6 +1865,8 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1707
1865
  else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
1708
1866
  else if (lastEdit >= 0 && lastCommit < lastEdit) next = 'Commit, or keep going.'
1709
1867
  const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
1868
+ const found = decisionsFromMessages(ms_) // 2026-11-03: only what the transcript states outright; the heuristic guesses none
1869
+ if (found.length) base.decided = found
1710
1870
  const len = normalizeLength(length)
1711
1871
  if (len === 'short') return base
1712
1872
  const ex = emptyExtras()
@@ -1733,7 +1893,7 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1733
1893
  ].filter(Boolean).join(' ')
1734
1894
  ex.nextSteps = next ? [next] : []
1735
1895
  }
1736
- return { ...base, ...ex }
1896
+ return { ...base, ...ex, decided: base.decided || [] }
1737
1897
  }
1738
1898
 
1739
1899
  // Fallback title: {repo}@{branch} · {first 6 words of the first prompt}
@@ -14,7 +14,7 @@
14
14
  // each event is registered once per matcher.
15
15
  import {
16
16
  DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
17
- parseCheckpointReply, retryCheckpointPrompt, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
17
+ parseCheckpointReply, retryCheckpointPrompt, mergeDecided, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.5' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.7' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
@@ -365,6 +365,8 @@ const COMMANDS = [
365
365
  { key: 'todo', names: ['cp-todo', 'leftoff-todo'], description: 'Leftoff: save the checklist Claude just wrote as a list you can tick off (board, phone, or here)', argumentHint: '[name] [--pick [N]]', immediate: true },
366
366
  // v0.8.2: checkpoint, mark the session Parked (not Closed when it exits), then close it.
367
367
  { key: 'park', names: ['cp-park', 'leftoff-park'], description: 'Leftoff: checkpoint and park this session to come back to (your note becomes the Next line), then exit', argumentHint: '[note]', immediate: false },
368
+ // 2026-11-03 (1b): the repo's accepted decisions into the conversation on demand (when SessionStart was skipped, or after /clear).
369
+ { key: 'decisions', names: ['cp-decisions', 'leftoff-decisions'], description: 'Leftoff: give Claude this repo\'s accepted decisions (the same list a new session starts with)', immediate: false },
368
370
  { key: 'done', names: ['cp-done', 'leftoff-done'], description: 'Leftoff: tick items on this session\'s list (/cp-done 3, /cp-done 2 4, /cp-done undo 3)', argumentHint: '<n…> [in <list>]', immediate: true },
369
371
  ]
370
372
 
@@ -477,6 +479,37 @@ async function unlockOwnParkLocks($, cwd) {
477
479
  } catch {}
478
480
  }
479
481
 
482
+ // ------------------------------------------------------------ decisions (2026-11-03)
483
+
484
+ // The daemon's short read of this repo's accepted decisions (id + one line each, capped), or null:
485
+ // nothing for Leftoff's own runs or a board-run headless session, when the daemon doesn't answer quickly,
486
+ // or when the repo has none.
487
+ const DECISIONS_TIMEOUT_MS = 800
488
+ async function decisionContext($, e) {
489
+ if (st.internal || await isHeadless($)) return null
490
+ const q = '?session=' + encodeURIComponent(e.session_id || st.sessionId || '') + (e.cwd ? '&cwd=' + encodeURIComponent(e.cwd) : '')
491
+ const r = await api($, 'GET', '/v1/decisions/context' + q, undefined, DECISIONS_TIMEOUT_MS)
492
+ const t = r && r.ok && r.json && typeof r.json.context === 'string' ? r.json.context.trim() : ''
493
+ return t ? t.slice(0, 2000) : null
494
+ }
495
+
496
+ // 1b /cp-decisions: the same text SessionStart adds (src/decisions.js decisionContextText, from the daemon),
497
+ // shown to you and handed to Claude as the command's context. The daemon notes that this session was given them.
498
+ // Runs where the classic hooks are skipped: a command is not a classic event (Claude Code 2.1.318's Security
499
+ // default hooks classic.* but not command.run).
500
+ async function decisionsCommand($) {
501
+ if (st.internal) return { text: 'Leftoff: not in Leftoff\'s own runs.' }
502
+ let cwd = null
503
+ try { cwd = await $.session.cwd() } catch {}
504
+ const sid = st.sessionId || ''
505
+ const r = await api($, 'GET', '/v1/decisions/context?via=command&session=' + encodeURIComponent(sid) + (cwd ? '&cwd=' + encodeURIComponent(cwd) : ''), undefined, 2000)
506
+ if (!r || !r.ok || !r.json) return { text: 'Leftoff: the daemon didn\'t answer, so this repo\'s decisions couldn\'t be read (leftoff status).' }
507
+ const t = typeof r.json.context === 'string' ? r.json.context.trim().slice(0, 2000) : ''
508
+ if (!t) return { text: r.json.repo ? 'Leftoff: this repo has no accepted decisions yet. Review proposed ones on the board.' : 'Leftoff: this folder isn\'t a repo Leftoff keeps a decision log for.' }
509
+ const n = Number(r.json.count) || t.split('\n').filter((l) => l.startsWith('- ')).length
510
+ return { text: 'Leftoff: gave Claude this repo\'s ' + n + ' accepted decision' + (n === 1 ? '' : 's') + ':\n' + t, context: [t] }
511
+ }
512
+
480
513
  // ------------------------------------------------------------ lists (v0.5.1)
481
514
 
482
515
  // This session's saved lists, when they changed since Claude last heard
@@ -841,6 +874,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
841
874
  doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
842
875
  userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
843
876
  parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
877
+ decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
844
878
  decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
845
879
  didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
846
880
  }
@@ -1172,6 +1206,14 @@ export function register(on, options) {
1172
1206
  const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
1173
1207
  await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
1174
1208
  }
1209
+ // 2026-11-03 decision memory: the repo's accepted decisions go to Claude as this event's context.
1210
+ // Added to what the hooks below (yours included) answer, never in place of it.
1211
+ const ctx = await decisionContext($, e)
1212
+ if (ctx) {
1213
+ const res = await next(e)
1214
+ const r = res && typeof res === 'object' ? res : {}
1215
+ return { ...r, additionalContext: [...(Array.isArray(r.additionalContext) ? r.additionalContext : []), ctx] }
1216
+ }
1175
1217
  }
1176
1218
  return next(e)
1177
1219
  })
@@ -1297,6 +1339,8 @@ export function register(on, options) {
1297
1339
  on('command.run', { command: 'leftoff-park' }, async ($, e) => parkCommand($, e.args))
1298
1340
 
1299
1341
  on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
1342
+ on('command.run', { command: 'cp-decisions' }, async ($) => decisionsCommand($))
1343
+ on('command.run', { command: 'leftoff-decisions' }, async ($) => decisionsCommand($))
1300
1344
 
1301
1345
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1302
1346
  if (e.requestId !== PANE_ID) return next(e)
@@ -11,10 +11,12 @@ How to walk through a change:
11
11
 
12
12
  1. Order the steps the way the change makes sense to read: the core idea first, then what follows from it. Not file order.
13
13
  2. Each step gets a short title and one or two sentences of why: the request it answers, the bug it fixes, or the trade-off you chose and the alternative you didn't take.
14
- 3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along, imports and formatting are mechanical and need no story.
14
+ 3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along (updated to match the change), imports and formatting are mechanical and need no story.
15
+ - A test that doesn't really test anything is never mechanical and never safe to skip. That is a test that asserts nothing meaningful or asserts a tautology, mocks the very thing under test, snapshots its own output, had an assertion loosened or deleted to make it pass, or was skipped or disabled. Put it in a step and give it a look callout at its line saying why.
15
16
  4. Point out what deserves a second look, at the exact line: something risky or subtle (look), a guess the code makes (assumption), or behaviour no test covers (untested). Be honest about the parts you were unsure of.
16
- 5. Never paste code. Point at it.
17
+ 5. Cite the decisions behind a step. When Leftoff lists the repo's accepted decisions (D-7f3a: …), and this session's own proposed ones (marked proposed), a step may name the ones it relates to and how: "decisions": [{"id": "D-7f3a", "rel": "follows"}] or [{"id": "D-7f3a", "rel": "departs"}] (a plain "D-7f3a" means follows), using only ids from those lists. A step that reverses or departs from a 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, each with "rel" "follows" or "departs". You may read files with Read, Grep and Glob to check details; change nothing.
19
21
 
20
22
  When the developer asks in the terminal: give the same walkthrough as a short numbered list (title, why, file:line for each step, then what to look at closely).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.5",
3
+ "version": "0.9.18-beta.7",
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,135 @@ 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-rc { display: inline-flex; align-items: center; gap: 2px; font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; white-space: nowrap; }
5445
+ a.dec-rc { min-height: 44px; text-decoration: none; }
5446
+ a.dec-rc:hover .dec-rc-w { text-decoration: underline; }
5447
+ .pchip-rc { margin-left: 6px; }
5448
+ .proj-review-slot:empty { display: none; }
5449
+ .scu-decs { margin-top: 10px; }
5450
+ .scu-dec-h { font-size: 12.5px; font-weight: 600; color: var(--text2); margin: 0 0 4px; }
5451
+ .scu-dec-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 13.5px; }
5452
+ .scu-dec-l .dec-st { margin: 0 4px 0 0; }
5453
+ .dec-given { margin: 6px 0 2px; font-size: 12.5px; color: var(--text2); }
5454
+ .dec-given-i { display: inline-block; width: 1em; text-align: center; }
5455
+ .dec-missed { color: var(--text); }
5456
+ .dec-given-how { color: var(--text2); }
5457
+ .dec-given code { font-size: 12px; }
5458
+ .dec-open { display: inline-flex; align-items: center; min-height: 44px; font-size: 13px; }
5459
+
5460
+ .dec-page .dec-head h1 { margin: 4px 0 2px; }
5461
+ .dec-tl { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 0; }
5462
+ .dec-item { position: relative; padding: 12px 0 12px 18px; border-left: 2px solid var(--line); margin-left: 6px; }
5463
+ .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; }
5464
+ .dec-item.dec-accepted::before { background: var(--text2); }
5465
+ .dec-item.dec-superseded, .dec-item.dec-rejected { opacity: .78; }
5466
+ .dec-item.dec-superseded::before { border-color: var(--line-strong, var(--line)); }
5467
+ .dec-item.dec-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; }
5468
+ .dec-h { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12.5px; color: var(--text2); }
5469
+ .dec-date { font-variant-numeric: tabular-nums; }
5470
+ .dec-repo { padding: 1px 6px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; }
5471
+ .dec-item .dec-st { margin-left: 0; }
5472
+ .dec-item .dec-text { font-size: 15px; line-height: 1.45; margin-top: 4px; color: var(--text); overflow-wrap: anywhere; }
5473
+ .dec-meta { font-size: 12.5px; color: var(--text2); margin-top: 4px; }
5474
+ .dec-chains { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; font-size: 12.5px; color: var(--text2); }
5475
+ .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; }
5476
+ .wt-dec-rel { font: 600 11.5px/1 system-ui, sans-serif; margin-left: 6px; padding: 2px 5px; border: 1px solid var(--line); border-radius: 4px; color: var(--text2); white-space: nowrap; }
5477
+ .wt-dec-dep { border-style: solid; border-color: var(--text2); color: var(--text); }
5478
+ .wt-dec-prop { border-style: dashed; }
5479
+ .dec-chip::after { content: ''; position: absolute; inset: -10px -4px; }
5480
+ .dec-chip:hover { border-color: var(--text2); }
5481
+ .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; }
5482
+ .dec-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
5483
+ .dec-acts .btn, .dec-acts .linkish { min-height: 44px; }
5484
+ .dec-acts-quiet { margin-top: 0; }
5485
+ .dec-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 4px; }
5486
+ .dec-ctl .sask-opt, .dec-ctl .btn, .dec-store-opts .sask-opt { min-height: 44px; }
5487
+ .dec-store-h { font-weight: 600; margin-bottom: 6px; }
5488
+ .dec-store-opts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
5489
+ .dec-store p { margin: 8px 0 0; max-width: 62ch; }
5490
+ .dec-edit { display: grid; gap: 8px; margin-top: 8px; max-width: 640px; }
5491
+ .dec-edit label { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
5492
+ .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%; }
5493
+ @media (pointer: coarse) { .dec-in { font-size: 16px; } }
5494
+ .dec-in:focus-visible { outline: 2px solid var(--accent, var(--text2)); outline-offset: 1px; }
5495
+ .dec-flash { background: color-mix(in srgb, var(--text2) 10%, transparent); transition: background .6s; }
5496
+ @media (prefers-reduced-motion: reduce) { .dec-flash { transition: none; } }
5497
+ .dec-proj p { margin: 4px 0 10px; }
5498
+
5499
+ .wt-decs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
5500
+ .wt-dec { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 4px 8px; font-weight: 400; }
5501
+ @media (pointer: coarse) { .wt-dec { min-height: 44px; } }
5502
+ .wt-dec .wt-dec-id { font-weight: 600; }
5503
+ .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; }
5504
+ .wt-dec-gone { border-style: dashed; }
5505
+ .wt-dec-w { font: 12px/1 var(--sans, system-ui, sans-serif); color: var(--text2); }
5506
+ .dec-chainview { margin-top: 4px; }
5507
+
5508
+ .dv-isdec .dv-dl { display: none !important; }
5509
+ .dec-ctl .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: transparent; color: var(--text2); cursor: pointer; }
5510
+ .dec-ctl .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); }
5511
+ @media (min-width: 700px) { .dv-isdec .dv-sheet { max-width: 760px; height: auto; max-height: 80vh; margin-left: auto; margin-right: auto; } }
5512
+
5513
+ /* testplan Part 3: the test plan page (ui/testplan.js) */
5514
+ .tp-page .tp-head h1 { margin: 4px 0 2px; }
5515
+ .tp-budget { display: inline-flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; }
5516
+ .tp-budget .sask-opt, .tp-go .btn, .tp-exp .btn, .tp-acts .btn, .tp-open { min-height: 44px; }
5517
+ .tp-budget .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 14px; background: transparent; color: var(--text2); cursor: pointer; font: inherit; }
5518
+ .tp-budget .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); font-weight: 600; }
5519
+ .tp-prl, .tp-recent, .tp-ncl { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
5520
+ .tp-prl a, .tp-recent a { overflow-wrap: anywhere; }
5521
+ .tp-wt, .tp-nowt { font-size: 12px; color: var(--text2); white-space: nowrap; }
5522
+ .tp-go { margin: 12px 0; }
5523
+ .tp-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 8px 0 4px; }
5524
+ .tp-prog { font-weight: 600; font-variant-numeric: tabular-nums; }
5525
+ .tp-cov { color: var(--text2); font-size: 13px; }
5526
+ .tp-exp { display: inline-flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
5527
+ .tp-by { font-size: 12.5px; margin: 0 0 8px; }
5528
+ .tp-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; }
5529
+ .tp-check { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: var(--bg); }
5530
+ .tp-check.tp-done { border-style: solid; opacity: .82; }
5531
+ .tp-check.tp-failed { border-width: 2px; border-color: var(--text2); }
5532
+ .tp-ch { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
5533
+ .tp-n { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); }
5534
+ .tp-title { font-size: 16px; margin: 0; flex: 1 1 260px; overflow-wrap: anywhere; }
5535
+ .tp-risk { font-size: 12px; white-space: nowrap; color: var(--text2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; }
5536
+ .tp-risk-high { color: var(--text); border-color: var(--text2); font-weight: 600; }
5537
+ .tp-min { font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; }
5538
+ .tp-where, .tp-expect, .tp-why { margin: 6px 0 0; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
5539
+ .tp-k { display: inline-block; min-width: 96px; font-size: 12px; color: var(--text2); }
5540
+ .tp-steps { margin: 6px 0 0; padding-left: 22px; font-size: 14px; line-height: 1.45; }
5541
+ .tp-cites { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
5542
+ .tp-cite { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 7px; text-decoration: none; position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5543
+ a.tp-cite::after { content: ''; position: absolute; inset: -10px -4px; }
5544
+ .tp-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; align-items: center; }
5545
+ .tp-tick[aria-pressed="true"] { font-weight: 600; border-color: var(--text); }
5546
+ .tp-clear { min-height: 44px; }
5547
+ .tp-note { display: grid; gap: 8px; margin-top: 10px; max-width: 640px; }
5548
+ .tp-note-l { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
5549
+ .tp-propf { margin-top: 10px; }
5550
+ .tp-prop { display: inline-flex; align-items: center; min-height: 44px; }
5551
+ .tp-nc-w { overflow-wrap: anywhere; }
5552
+ .tp-notes { margin: 12px 0; font-size: 13px; color: var(--text2); }
5553
+ .tp-notes summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; }
5554
+ .tp-del .linkish { min-height: 44px; }
5555
+ .tp-wait { display: flex; align-items: center; gap: 10px; padding: 16px 0; color: var(--text2); }
5556
+ .tp-spin { width: 14px; height: 14px; border: 2px solid var(--line); border-top-color: var(--text2); border-radius: 50%; animation: tp-spin 1s linear infinite; }
5557
+ @keyframes tp-spin { to { transform: rotate(360deg); } }
5558
+ @media (prefers-reduced-motion: reduce) { .tp-spin { animation: none; } }
5559
+ @media (max-width: 600px) { .tp-exp { margin-left: 0; } .tp-k { display: block; min-width: 0; } }
5560
+ /* testplan Part 3: picking merged PRs on the PRs page */
5561
+ .prrow.pickable { display: flex; gap: 8px; align-items: flex-start; }
5562
+ .pr-pick { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; border: 0; background: transparent; color: var(--text2); cursor: pointer; font: inherit; font-size: 18px; line-height: 1; padding: 0; }
5563
+ .pr-pick-w { font-size: 11px; margin-top: 2px; }
5564
+ .pr-pick[aria-pressed="true"] { color: var(--text); }
5565
+ .prs-picks { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 8px 12px; margin: 0 0 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
5566
+ .prs-picks .btn, .prs-picks .linkish { min-height: 44px; }
5567
+ .wt-dec .wt-dec-id { white-space: nowrap; flex: none; }