@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.
- package/CHANGELOG.md +12 -1
- package/bin/leftoff.js +599 -537
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +46 -2
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +132 -0
- package/ui/app.js +296 -13
- package/ui/ask.js +5 -3
- package/ui/decisions.js +186 -0
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +31 -5
- package/ui/session-catchup.js +18 -2
- package/ui/summary-examples.js +1 -1
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +40 -0
|
@@ -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.
|
|
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
|
-
'
|
|
1395
|
-
'- <
|
|
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
|
-
|
|
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: '
|
|
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}
|
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.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.
|
|
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.
|
|
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; }
|