@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,186 @@
1
+ // Decision memory (2026-11-03): pure builders for the board. A checkpoint's
2
+ // Decided list (the session panel), a repo's or a project's decision log (the
3
+ // timeline), and the decision chips a walkthrough step cites. app.js wires
4
+ // them; unit-tested in test/unit/decisions-ui-1103.test.mjs.
5
+
6
+ const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
7
+ const day = (iso) => String(iso || '').slice(0, 10)
8
+ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', correction: 'Your correction', agent: 'Agent chose' }
9
+ // Status as a shape and a word, never color alone.
10
+ export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
11
+ export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
12
+
13
+ // A checkpoint's decisions, under the newest checkpoint in the session panel.
14
+ // log: the session's decisions from its repo's log, when loaded ({ <text norm>: { id, status } }).
15
+ // 1k: a proposed one is reviewed right here (Accept / Reject / Edit, the log page's API and gate). trusted: false on a
16
+ // device that may look but not change the log (the buttons show disabled, with the reason). editing: the id being edited.
17
+ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null, trusted = true, editing = null, may = true } = {}) {
18
+ const list = Array.isArray(decided) ? decided : []
19
+ if (!list.length) return ''
20
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
21
+ const rows = list.map((d) => {
22
+ const hit = log && log[norm(d.text)]
23
+ const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[hit.status] + '</span> ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
24
+ if (hit && editing === hit.id) return '<li class="dec-row" id="decp-' + esc(hit.id) + '"><span class="dec-id">' + esc(hit.id) + '</span>' + editForm({ ...hit, repo: hit.repo || repoId }, esc) + '</li>'
25
+ const rp = (hit && (hit.repo || repoId)) || ''
26
+ const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
27
+ const acts = hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
28
+ '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>' : ''
29
+ return '<li class="dec-row"' + (hit ? ' id="decp-' + esc(hit.id) + '"' : '') + '>' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(hit && hit.edited ? hit.text : d.text) + '</span>' + st +
30
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
31
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
32
+ '<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div>' + acts + '</li>'
33
+ }).join('')
34
+ const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
35
+ return '<ul class="dec-list">' + rows + '</ul>' + link
36
+ }
37
+
38
+ // 1b: one quiet line in the session panel: the session started with its repo's decisions (shape plus
39
+ // words), or wasn't given them because Claude Code skipped Leftoff's SessionStart (the why in the tip).
40
+ export function decisionsGivenHtml(s, { esc = escD, when = (iso) => String(iso || '').slice(0, 16).replace('T', ' ') } = {}) {
41
+ if (!s) return ''
42
+ const g = s.decisionsGiven
43
+ if (g && g.n > 0) {
44
+ const words = g.via === 'command' ? 'Given ' + g.n + ' decision' + (g.n === 1 ? '' : 's') + ' with /cp-decisions' : 'Started with ' + g.n + ' decision' + (g.n === 1 ? '' : 's')
45
+ const tip = (g.via === 'command' ? 'You ran /cp-decisions' : 'Leftoff added the repo’s accepted decisions when the session started') + (g.at ? ' · ' + when(g.at) : '') + '. Only accepted decisions are given.'
46
+ return '<p class="dec-given" data-tip="' + esc(tip) + '"><span class="dec-given-i" aria-hidden="true">●</span> ' + esc(words) + '</p>'
47
+ }
48
+ const m = s.decisionsMissed
49
+ if (m && m.n > 0) return '<p class="dec-given dec-missed" data-tip="' + esc(m.tip || '') + '"><span class="dec-given-i" aria-hidden="true">◌</span> ' + esc(m.label || ('Not given its repo’s ' + m.n + ' decisions')) + ' <span class="dec-given-how">· type <code>/cp-decisions</code></span></p>'
50
+ return ''
51
+ }
52
+
53
+ // ------------------------------------------------------------ the decision log (timeline)
54
+
55
+ // A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
56
+ export function decisionScope(arg) {
57
+ const a = String(arg || '')
58
+ if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
59
+ return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
60
+ }
61
+ export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
62
+
63
+ // What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
64
+ // (superseded and rejected too). Proposed ones are always above, to review.
65
+ export function timelineRows(decisions, { all = false } = {}) {
66
+ const list = Array.isArray(decisions) ? decisions : []
67
+ return {
68
+ review: list.filter((d) => d.status === 'proposed'),
69
+ rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
70
+ hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
71
+ }
72
+ }
73
+
74
+ function sessionBit(d, esc) {
75
+ // testplan Part 3: proposed from a failed check of a test plan: the plan is where it came from
76
+ const tp = /^testplan:(tp_[0-9a-f]{16})#(c\d+)$/.exec(String(d.checkpointId || ''))
77
+ if (tp) return '<a class="linkish dec-sess" href="#/testplan/' + esc(tp[1]) + '">From a test plan (check ' + esc(tp[2].slice(1)) + ')</a>'
78
+ const s = d.session
79
+ if (!s || !s.id) return ''
80
+ const title = s.title || 'its session'
81
+ return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
82
+ (s.pr && s.pr.url && /^https:\/\//.test(s.pr.url) ? ' · <a class="linkish dec-pr" href="' + esc(s.pr.url) + '" target="_blank" rel="noopener">PR' + (s.pr.number ? ' #' + esc(s.pr.number) : '') + '</a>' : '')
83
+ }
84
+ function chainBit(d, byId, esc) {
85
+ const out = []
86
+ for (const id of d.supersedes || []) { const o = byId.get(id); out.push('<span class="dec-chain">Replaces <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>' + (o ? ' (' + esc(day(o.createdAt)) + ')' : '') + '</span>') }
87
+ if (d.supersededBy) out.push('<span class="dec-chain">Superseded by <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(d.supersededBy) + '">' + esc(d.supersededBy) + '</button> on ' + esc(day(d.supersededAt)) + '</span>')
88
+ return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
89
+ }
90
+ function statusBit(d, esc) {
91
+ return '<span class="dec-st dec-st-' + esc(d.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>'
92
+ }
93
+ function editForm(d, esc) {
94
+ return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
95
+ '<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
96
+ '<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
97
+ '<div class="dec-acts"><button type="submit" class="btn primary">Save</button><button type="button" class="btn" data-action="dec-cancel" data-id="' + esc(d.id) + '">Cancel</button></div></form>'
98
+ }
99
+
100
+ // 1h: POST /v1/decisions/action is trustedOnly: on a paired device without Trust my paired devices the
101
+ // buttons show, disabled, with this tip, rather than failing on a click.
102
+ export const DEC_TRUST_TIP = 'Changing the decision log from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
103
+ const offAttrs = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(DEC_TRUST_TIP) + '"' : '')
104
+
105
+ // One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
106
+ // trusted (1h): false on a device that may look but not change the log. edit: false hides Edit (the viewer).
107
+ // replaces (1g): an id this one may be confirmed to replace (a walkthrough step departs from it).
108
+ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false, trusted = true, edit = true, replaces = null } = {}) {
109
+ const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
110
+ const head = '<div class="dec-h"><span class="dec-date">' + esc(day(d.createdAt)) + '</span><span class="dec-id">' + esc(d.id) + '</span>' + repo + statusBit(d, esc) + '</div>'
111
+ if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
112
+ const sug = (d.suggests || []).map((id) => { const o = byId.get(id); return o ? '<div class="dec-sug"><span>Changes <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>: ' + esc(o.text) + '</span>' +
113
+ (may ? '<span class="dec-acts"><button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(id) + '</button><button type="button" class="btn" data-action="dec-act" data-act="dismiss" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Not related</button></span>' : '') + '</div>' : '' }).join('')
114
+ const btn = (act, label, cls = '') => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
115
+ const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
116
+ const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
117
+ const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
118
+ : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
119
+ return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
120
+ '<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
121
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
122
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
123
+ chainBit(d, byId, esc) + sug +
124
+ '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
125
+ }
126
+
127
+ // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
128
+ export function storageChoiceHtml(project, { esc = escD, may = true, trusted = true } = {}) {
129
+ if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
130
+ const on = !!project.inRepo
131
+ const opt = (v, label) => '<button type="button" class="sask-opt" data-action="dec-store" data-project="' + esc(project.id) + '" data-on="' + v + '" aria-pressed="' + (on === v) + '"' + (trusted === false ? offAttrs(false, esc) : may ? '' : ' disabled') + '>' + label + '</button>'
132
+ return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
133
+ '<div class="sask-scope dec-store-opts" role="group" aria-label="Keep decision log in this repo">' + opt(false, 'Leftoff only') + opt(true, 'Also in each repo') + '</div>' +
134
+ '<p class="faint">' + (on ? 'Accepted decisions are written to ' + esc(project.file || 'docs/DECISIONS.md') + ' in each repo of ' + esc(project.name) + ', between Leftoff’s markers, on a Mac with a checkout. Never staged or committed: you commit it or not.'
135
+ : 'The log stays in Leftoff’s folder, shared by your Macs. Turn this on to also write accepted decisions into each repo’s ' + esc(project.file || 'docs/DECISIONS.md') + '.') + '</p></div>'
136
+ }
137
+
138
+ // The whole page. v: GET /v1/decisions. ui: { all, editing, may }
139
+ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
140
+ const list = (v && v.decisions) || []
141
+ const byId = new Map(list.map((d) => [d.id, d]))
142
+ const multi = ((v && v.repos) || []).length > 1
143
+ const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
144
+ const sc = (v && v.scope) || {}
145
+ const name = sc.kind === 'project' ? sc.name : sc.label
146
+ const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi, trusted: ui.trusted !== false }
147
+ let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
148
+ h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
149
+ (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
150
+ if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
151
+ if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
152
+ if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
153
+ const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
154
+ '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
155
+ h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
156
+ (rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : '<p class="faint">' + (review.length ? 'Nothing accepted yet.' : 'No decisions yet. They’re proposed here when a checkpoint sees one: a question you answered, a plan you approved, a correction.') + '</p>') + '</section>'
157
+ return '<div class="proj dec-page">' + h + '</div>'
158
+ }
159
+
160
+ // testplan Part 3: the way in to a test plan of the scope's merged PRs (a project, or a repo's log)
161
+ export function testplanLinkHtml(sc, { esc = escD, cls = 'btn' } = {}) {
162
+ if (!sc) return ''
163
+ const token = sc.kind === 'project' ? sc.id : sc.label || sc.id
164
+ if (!token) return ''
165
+ return '<a class="' + cls + ' tp-open" href="#/testplan/new?scope=' + esc(encodeURIComponent(token)) + '" data-tip="A short manual test plan for the PRs merged lately">Test plan</a>'
166
+ }
167
+
168
+ // 1k: a quiet count of decisions waiting to be reviewed: a shape plus a number (and the words for a screen reader and the tip), never a colored badge alone.
169
+ export function reviewCountHtml(n, { esc = escD, href = null, cls = '' } = {}) {
170
+ if (!(n > 0)) return ''
171
+ const words = n + ' decision' + (n === 1 ? '' : 's') + ' to review'
172
+ const inner = '<span aria-hidden="true">◌</span> ' + n
173
+ return href ? '<a class="dec-rc ' + cls + '" href="' + esc(href) + '" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '<span class="dec-rc-w"> to review</span></a>'
174
+ : '<span class="dec-rc ' + cls + '" role="img" data-tip="' + esc(words) + '" aria-label="' + esc(words) + '">' + inner + '</span>'
175
+ }
176
+
177
+ // The project page's short Decisions section: counts, the storage choice, the way in.
178
+ export function projectDecisionsHtml(v, { esc = escD, may = true, trusted = true, scopeArg = '' } = {}) {
179
+ if (!v) return ''
180
+ const list = v.decisions || []
181
+ const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
182
+ return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
183
+ '<p>' + (cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
184
+ '<div class="dec-acts">' + testplanLinkHtml(v.scope, { esc }) + '</div>' +
185
+ storageChoiceHtml(v.project, { esc, may, trusted }) + (trusted === false ? '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' : '') + '</section>'
186
+ }
package/ui/prs.js CHANGED
@@ -182,8 +182,11 @@ function sessionLinks(r, opts) {
182
182
 
183
183
  // One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
184
184
  // (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
185
- export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
185
+ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null, picks = null } = {}) {
186
186
  const p = r.pr || {}
187
+ // testplan Part 3: a merged PR can be picked for a test plan (a box plus the word, the state in aria-pressed)
188
+ const picked = !!(picks && picks.has(p.url))
189
+ const pick = picks && p.state === 'merged' ? '<button type="button" class="pr-pick" data-action="prs-pick" data-url="' + esc(p.url) + '" aria-pressed="' + picked + '" data-tip="' + (picked ? 'Picked for a test plan' : 'Pick for a test plan') + '"><span aria-hidden="true">' + (picked ? '☑' : '☐') + '</span><span class="pr-pick-w">' + (picked ? 'Picked' : 'Pick') + '</span></button>' : ''
187
190
  const url = safeUrl(p.url)
188
191
  // 0.8.9: what the checks and review chips don't already say (a conflict, your review)
189
192
  const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
@@ -195,7 +198,7 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
195
198
  : [p.repo, p.branch, when].filter(Boolean)
196
199
  const why = whyText(r)
197
200
  const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
198
- return '<article class="prrow' + (open ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
201
+ return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
199
202
  '<div class="pr-line1">' + line1 + '</div>' +
200
203
  '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
201
204
  '<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
@@ -411,12 +414,19 @@ export const isOpenPr = (r) => !['merged', 'closed'].includes(String((r && r.pr
411
414
  function doneGroupHtml(g, { open, rowOpts }) {
412
415
  return '<details class="prgroup done b-' + g.key + '" data-group="' + g.key + '"' + (open ? ' open' : '') + '><summary><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span><span class="chev" aria-hidden="true">›</span></h2></summary>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
413
416
  }
414
- export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [] } = {}) {
417
+ // testplan Part 3: what's picked, and the way to a test plan of exactly those
418
+ export function prPicksBarHtml(picks) {
419
+ const n = picks ? picks.size : 0
420
+ if (!n) return ''
421
+ const href = '#/testplan/new?prs=' + [...picks].map(encodeURIComponent).join(',')
422
+ return '<div class="prs-picks" role="status"><span><span aria-hidden="true">☑</span> ' + n + ' merged PR' + (n === 1 ? '' : 's') + ' picked</span><a class="btn primary" href="' + esc(href) + '">Test plan for these</a><button type="button" class="linkish" data-action="prs-pick-clear">Clear</button></div>'
423
+ }
424
+ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [], picks = null } = {}) {
415
425
  const f = { repos: [], state: null, q: '', project: null, ...filter }
416
426
  if (f.project && !(projects || []).some((p) => p.id === f.project)) f.project = null
417
427
  const all = prRows(d)
418
428
  const me = d.me || null
419
- let h = prsCutHtml(d) // 0.9.12: first, so a phone sees it
429
+ let h = prsCutHtml(d) + prPicksBarHtml(picks) // 0.9.12: first, so a phone sees it
420
430
  if (all.length) {
421
431
  // 0.9.0: projects first (when you have any), then the repos in the picked one
422
432
  if ((projects || []).length) {
@@ -449,7 +459,7 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
449
459
  const rows = filterPrRows(all, f, { me, projects })
450
460
  const { groups, quiet } = groupPrRows(rows, { now, me })
451
461
  const showMachine = !!d.multiMachine
452
- const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
462
+ const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null, picks: view === 'mine' ? picks : null })
453
463
  if (!all.length && d.tooBig) {
454
464
  h += '<div class="empty">Too many PRs to load at once. <button type="button" class="linkish" data-action="prs-refresh">Try again</button></div>'
455
465
  } else if (!all.length) {
package/ui/semdiff.js CHANGED
@@ -216,7 +216,27 @@ export function alsoIn(st, g, shared) {
216
216
  }
217
217
  export const CHANGED_TIP = 'Its files changed since this walkthrough was written'
218
218
 
219
- function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true }) {
219
+ // 2026-11-03 decision memory: the accepted decisions a step cites, as small chips that open the decision with its chain
220
+ export function decisionsOf(data) { return new Map(((data && data.decisions) || []).map((d) => [d.id, d])) }
221
+ export function stepDecisionsHtml(st, data, { esc }) {
222
+ const ids = Array.isArray(st.decisions) ? st.decisions : []
223
+ if (!ids.length) return ''
224
+ const by = decisionsOf(data)
225
+ const gone = new Set((data && data.stale && data.stale.decisions) || [])
226
+ const departs = new Set(Array.isArray(st.departs) ? st.departs : [])
227
+ return '<div class="wt-decs" role="group" aria-label="Decisions behind this step">' + ids.map((id) => {
228
+ const d = by.get(id)
229
+ const dep = departs.has(id), prop = !!(d && d.status === 'proposed')
230
+ // 1g/1f: shape plus word, never color alone: ≠ Departs, ◌ Proposed
231
+ const marks = (dep ? '<span class="wt-dec-rel wt-dec-dep"><span aria-hidden="true">≠</span> Departs</span>' : '') + (prop ? '<span class="wt-dec-rel wt-dec-prop"><span aria-hidden="true">◌</span> Proposed</span>' : '')
232
+ const tip = (gone.has(id) ? 'Changed since this walkthrough · ' : '') + (dep ? 'This step departs from it · ' : '') + (prop ? 'Proposed by this session, not accepted yet · ' : '') + (d ? d.text : 'Decision ' + id)
233
+ return '<button type="button" class="dec-chip wt-dec' + (gone.has(id) ? ' wt-dec-gone' : '') + (dep ? ' wt-dec-departs' : '') + (prop ? ' wt-dec-proposed' : '') + '" data-action="wt-dec" data-id="' + esc(id) + '"' + (dep ? ' data-departs="1"' : '') + (data && data.decisionRepo ? ' data-repo="' + esc(data.decisionRepo) + '"' : '') +
234
+ ' data-tip="' + esc(tip) + '"><span class="wt-dec-id">' + esc(id) + '</span>' + marks + (d ? '<span class="wt-dec-t">' + esc(d.text) + '</span>' : '') + (gone.has(id) ? '<span class="wt-dec-w"> · changed since</span>' : '') + '</button>'
235
+ }).join('') + '</div>'
236
+ }
237
+ // how the export names a cited decision: "departs from" / "(proposed)"
238
+ function decWord(st, id, d) { return (Array.isArray(st.departs) && st.departs.includes(id) ? 'departs from ' : '') + (d && d.status === 'proposed' ? '(proposed) ' : '') }
239
+ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true, data = null }) {
220
240
  const core = st.tag === 'Core'
221
241
  // 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
222
242
  const rp = st.repo ? [st.repo] : []
@@ -246,7 +266,7 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
246
266
  : '<button type="button" class="wt-showc" data-action="wt-code" data-step="' + esc(st.id) + '" aria-expanded="false">' + icon('chevron-right') + 'Show code <span class="faint">· ' + plural(lines, 'line') + '</span></button>'
247
267
  // callouts whose line isn't in a drawn snippet (folded code) still read here
248
268
  const loose = !show && st.callouts.length ? '<div class="wt-cos">' + st.callouts.map((c) => calloutHtml(c, { esc, icon })).join('') + '</div>' : ''
249
- return '<article class="wt-st ' + (core ? 'wt-core' : 'wt-sup') + (done ? ' done' : '') + (cur ? ' cur' : '') + '" id="wt-' + esc(st.id) + '" data-step="' + esc(st.id) + '" tabindex="-1" aria-label="' + esc('Step ' + st.n + ': ' + mdPlain(st.title)) + '">' + head + why + loose + code + '</article>'
269
+ return '<article class="wt-st ' + (core ? 'wt-core' : 'wt-sup') + (done ? ' done' : '') + (cur ? ' cur' : '') + '" id="wt-' + esc(st.id) + '" data-step="' + esc(st.id) + '" tabindex="-1" aria-label="' + esc('Step ' + st.n + ': ' + mdPlain(st.title)) + '">' + head + why + stepDecisionsHtml(st, data, { esc }) + loose + code + '</article>'
250
270
  }
251
271
 
252
272
  function groupHtml(g, { esc, icon, open, data = null }) {
@@ -389,6 +409,8 @@ function modeSegHtml(show, split) {
389
409
  // "3 files changed since this walkthrough · Update"
390
410
  export function staleWords(st) {
391
411
  if (!st) return ''
412
+ // 2026-11-03 a decision it cites has changed (superseded, rejected or edited) since it was written
413
+ if (Array.isArray(st.decisions) && st.decisions.length && !st.count) return (st.decisions.length === 1 ? 'Decision ' + st.decisions[0] + ' has' : 'Decisions ' + st.decisions.join(', ') + ' have') + ' changed since this walkthrough'
392
414
  if (st.count == null) return 'The change has moved on since this walkthrough'
393
415
  if (!st.count) return 'The change has moved on since this walkthrough'
394
416
  return st.count + (st.count === 1 ? ' file changed' : ' files changed') + ' since this walkthrough'
@@ -477,7 +499,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
477
499
  const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
478
500
  const bd = data.stats.breakdown || {}
479
501
  const shared = sharedSnips(data), skip = skipFiles(data), stat = statusMap(data)
480
- const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip, stat, shared })
502
+ const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip, stat, shared, data })
481
503
  const repos = walkRepos(data)
482
504
  // 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
483
505
  if (repos.length) {
@@ -565,7 +587,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
565
587
  slides.forEach((s, i) => {
566
588
  if (s.kind === 'step') {
567
589
  const x = s.step
568
- h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen */ }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
590
+ h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen */, data }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
569
591
  } else if (s.kind === 'skip') {
570
592
  const groups = data.groups || []
571
593
  const n = groups.reduce((a, g) => a + g.files.length + (g.kind === 'repeated' ? 1 : 0), 0), l = groups.reduce((a, g) => a + g.lines, 0)
@@ -808,6 +830,7 @@ export function toMarkdown(data, { title = '' } = {}) {
808
830
  if (repos.length && st.repo && st.repo !== lastRepo) { lastRepo = st.repo; const r = repos.find((x) => x.label === st.repo); L.push(''); L.push('### ' + st.repo + (r ? ' (' + repoHeadWords(r, data.steps || []) + ')' : '')) }
809
831
  L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
810
832
  if (st.why) L.push(st.why)
833
+ if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); L.push(''); L.push('Decisions: ' + st.decisions.map((id) => decWord(st, id, by.get(id)) + '`' + id + '`' + (by.get(id) ? ' ' + by.get(id).text : '')).join('; ')) } // 2026-11-03
811
834
  for (const c of st.callouts || []) { L.push(''); L.push('> **' + (CALLOUT[c.kind] || CALLOUT.look).word + '** (`' + pathLabel(c.file, data) + ':' + c.line + '`): ' + c.text) }
812
835
  for (const sn of st.snippets || []) {
813
836
  const fence = /```/.test(sn.patch) ? '~~~~' : '```'
@@ -820,6 +843,7 @@ export function toMarkdown(data, { title = '' } = {}) {
820
843
  for (const g of groups) L.push('- ' + g.title + (g.note ? ' (' + g.note + ')' : '') + ': ' + plural(g.files.length + (g.kind === 'repeated' ? 1 : 0), 'file') + ', ' + plural(g.lines, 'line'))
821
844
  }
822
845
  if (data.coverage) { L.push(''); L.push('_Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '._') }
846
+ if (Array.isArray(data.decisions) && data.decisions.length) { L.push(''); L.push('### Decisions cited'); L.push(''); for (const d of data.decisions) L.push('- `' + d.id + '` ' + d.text + (d.why ? ' (' + d.why + ')' : '')) }
823
847
  return L.join('\n') + '\n'
824
848
  }
825
849
 
@@ -838,7 +862,7 @@ export function staticDiffHtml(patch, callouts = []) {
838
862
  }
839
863
  return h + '</div>'
840
864
  }
841
- const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}main{max-width:900px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 6px}.f{color:#59636e;font-size:13px}.sum{font-size:15px}.tag{display:inline-flex;align-items:center;height:20px;padding:0 8px;border:1px solid #d1d9e0;border-radius:999px;font-size:12px;line-height:1;margin-left:8px;vertical-align:2px}.tag.core{font-weight:700;border-width:2px}.fl{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#59636e;margin:10px 0 4px}.d{border:1px solid #d1d9e0;border-radius:6px;overflow:hidden;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.r{display:grid;grid-template-columns:44px 44px 18px 1fr}.g{color:#59636e;text-align:right;padding:0 6px;user-select:none}.m{text-align:center}.c{white-space:pre-wrap;word-break:break-word;padding-right:8px}.r.h{background:#ddf4ff;color:#59636e}.r.add{background:#e6ffec}.r.add .g{background:#ccffd8}.r.del{background:#ffebe9}.r.del .g{background:#ffd7d5}.r.mark .g:first-child{box-shadow:inset 3px 0 0 #1f2328}.co{margin:4px 8px 6px 106px;padding:6px 10px;border:1px solid #1f2328;border-radius:6px;font:13px/1.45 -apple-system,system-ui,sans-serif;white-space:normal}.co.assumption{border-style:dotted}.co.untested{border-style:dashed}.co b{margin-right:6px}.loose .co{margin-left:0}details{margin:8px 0}summary{cursor:pointer}ul{padding-left:20px}code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:5px;padding:0 4px;overflow-wrap:break-word;word-break:normal;-webkit-box-decoration-break:clone;box-decoration-break:clone}h2 code{font-size:.84em}.why{margin:8px 0 12px;overflow-wrap:break-word}.why p{margin:0 0 8px}.why :is(ul,ol){margin:0 0 8px;padding-left:20px}.why li{margin:2px 0}.why h2,.why h3,.why h4,.why h5,.why h6{font-size:1em;margin:10px 0 4px}.why blockquote{margin:0 0 8px;padding-left:10px;border-left:3px solid #d1d9e0}.why pre{margin:0 0 8px;overflow-x:auto;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:6px;padding:8px 10px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}.why pre code{background:none;border:0;padding:0;font-size:inherit;overflow-wrap:normal;word-break:normal}.co code{font-size:.84em}a{color:#0969da;overflow-wrap:anywhere}.u{overflow-wrap:anywhere}.tk-c{color:#59636e}.tk-s{color:#0a3069}.tk-k{color:#cf222e}.tk-n{color:#0550ae}.tk-add{color:#116329}.tk-del{color:#82071e}@media print{.why pre{white-space:pre-wrap}code{background:none}}@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}code,.why pre{background:#161b22;border-color:#3d444d}.why blockquote{border-color:#3d444d}a{color:#4493f8}.tk-c{color:#9198a1}.tk-s{color:#a5d6ff}.tk-k{color:#ff7b72}.tk-n{color:#79c0ff}.tk-add{color:#7ee787}.tk-del{color:#ffa198}.f,.g,.fl{color:#9198a1}.d,.tag{border-color:#3d444d}.r.h{background:#121d2f}.r.add{background:#12261e}.r.add .g{background:#1b4721}.r.del{background:#25171c}.r.del .g{background:#542426}.co,.r.mark .g:first-child{border-color:#e6edf3;box-shadow:inset 3px 0 0 #e6edf3}}'
865
+ export const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}main{max-width:900px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 6px}.f{color:#59636e;font-size:13px}.sum{font-size:15px}.tag{display:inline-flex;align-items:center;height:20px;padding:0 8px;border:1px solid #d1d9e0;border-radius:999px;font-size:12px;line-height:1;margin-left:8px;vertical-align:2px}.tag.core{font-weight:700;border-width:2px}.fl{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#59636e;margin:10px 0 4px}.d{border:1px solid #d1d9e0;border-radius:6px;overflow:hidden;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.r{display:grid;grid-template-columns:44px 44px 18px 1fr}.g{color:#59636e;text-align:right;padding:0 6px;user-select:none}.m{text-align:center}.c{white-space:pre-wrap;word-break:break-word;padding-right:8px}.r.h{background:#ddf4ff;color:#59636e}.r.add{background:#e6ffec}.r.add .g{background:#ccffd8}.r.del{background:#ffebe9}.r.del .g{background:#ffd7d5}.r.mark .g:first-child{box-shadow:inset 3px 0 0 #1f2328}.co{margin:4px 8px 6px 106px;padding:6px 10px;border:1px solid #1f2328;border-radius:6px;font:13px/1.45 -apple-system,system-ui,sans-serif;white-space:normal}.co.assumption{border-style:dotted}.co.untested{border-style:dashed}.co b{margin-right:6px}.loose .co{margin-left:0}details{margin:8px 0}summary{cursor:pointer}ul{padding-left:20px}code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:5px;padding:0 4px;overflow-wrap:break-word;word-break:normal;-webkit-box-decoration-break:clone;box-decoration-break:clone}h2 code{font-size:.84em}.why{margin:8px 0 12px;overflow-wrap:break-word}.why p{margin:0 0 8px}.why :is(ul,ol){margin:0 0 8px;padding-left:20px}.why li{margin:2px 0}.why h2,.why h3,.why h4,.why h5,.why h6{font-size:1em;margin:10px 0 4px}.why blockquote{margin:0 0 8px;padding-left:10px;border-left:3px solid #d1d9e0}.why pre{margin:0 0 8px;overflow-x:auto;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:6px;padding:8px 10px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}.why pre code{background:none;border:0;padding:0;font-size:inherit;overflow-wrap:normal;word-break:normal}.co code{font-size:.84em}a{color:#0969da;overflow-wrap:anywhere}.u{overflow-wrap:anywhere}.tk-c{color:#59636e}.tk-s{color:#0a3069}.tk-k{color:#cf222e}.tk-n{color:#0550ae}.tk-add{color:#116329}.tk-del{color:#82071e}@media print{.why pre{white-space:pre-wrap}code{background:none}}@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}code,.why pre{background:#161b22;border-color:#3d444d}.why blockquote{border-color:#3d444d}a{color:#4493f8}.tk-c{color:#9198a1}.tk-s{color:#a5d6ff}.tk-k{color:#ff7b72}.tk-n{color:#79c0ff}.tk-add{color:#7ee787}.tk-del{color:#ffa198}.f,.g,.fl{color:#9198a1}.d,.tag{border-color:#3d444d}.r.h{background:#121d2f}.r.add{background:#12261e}.r.add .g{background:#1b4721}.r.del{background:#25171c}.r.del .g{background:#542426}.co,.r.mark .g:first-child{border-color:#e6edf3;box-shadow:inset 3px 0 0 #e6edf3}}'
842
866
  // ONE self-contained file: inline CSS, the snippets baked in, no scripts, no links out.
843
867
  // 0.9.18 a deck, like the board's: an overview, then one slide per step, then Safe to
844
868
  // skip. Without a script: one radio per slide (hidden, still focusable, so ← / → move
@@ -865,6 +889,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
865
889
  const agHead = (s) => { if (!repos.length || !s.st || !s.st.repo || s.st.repo === agRepo) return ''; agRepo = s.st.repo; const r = repos.find((x) => x.label === s.st.repo); return '<li class="rh"><h3>' + escH(s.st.repo) + (r ? ' <span class="f">· ' + repoHeadWords(r, steps) + '</span>' : '') + '</h3></li>' }
866
890
  o += '<h2>Reading order</h2><ol class="ag">' + slides.map((s, i) => i === 0 ? '' : agHead(s) + '<li><label for="s' + i + '">' + (s.st ? s.st.n + '. ' : '') + walkInline(s.t) + (s.st ? ' <span class="tag ' + (s.st.tag === 'Core' ? 'core' : '') + '">' + escH(s.st.tag) + '</span>' : '') + '</label></li>').join('') + '</ol>'
867
891
  if (data.coverage) o += '<p class="f">Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '.</p>'
892
+ if (Array.isArray(data.decisions) && data.decisions.length) o += '<h2>Decisions cited</h2><ul>' + data.decisions.map((d) => '<li><code>' + escH(d.id) + '</code> ' + escH(d.text) + (d.why ? ' <span class="f">(' + escH(d.why) + ')</span>' : '') + '</li>').join('') + '</ul>' // 2026-11-03
868
893
  b += '<section class="sl" data-i="0">' + o + nav(0) + '</section>'
869
894
  slides.forEach((s, i) => {
870
895
  if (!i) return
@@ -873,6 +898,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
873
898
  const st = s.st
874
899
  if (repos.length && st.repo) { const r = repos.find((y) => y.label === st.repo); x += '<h3 class="rp">' + escH(st.repo) + (r ? ' <span class="f">· ' + repoHeadWords(r, steps) + '</span>' : '') + '</h3>' } // 2026-10-29 the step's repo
875
900
  x += '<h2>' + st.n + '. ' + walkInline(st.title) + '<span class="tag ' + (st.tag === 'Core' ? 'core' : '') + '">' + escH(st.tag) + '</span></h2>' + (st.why ? '<div class="why">' + walkBlock(st.why, { exported: true }) + '</div>' : '')
901
+ if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); x += '<p class="f decs">Decisions: ' + st.decisions.map((id) => escH(decWord(st, id, by.get(id))) + '<code>' + escH(id) + '</code>' + (by.get(id) ? ' ' + escH(by.get(id).text) : '')).join('; ') + '</p>' } // 2026-11-03
876
902
  const drawn = new Set()
877
903
  for (const sn of st.snippets || []) {
878
904
  const cs = (st.callouts || []).filter((c) => c.hunk === sn.hunk)
@@ -29,17 +29,33 @@ export function sessionCatchupHtml(ui, { esc, icon = () => '', sid = '' } = {})
29
29
  : '<ul class="scu-body scu-list">' + body.map((_, i) => '<li data-scu="b-' + i + '"></li>').join('') + '</ul>'
30
30
  return '<div class="scu" role="region" aria-label="Catch me up">' + head('<span class="scu-when" data-scu="when"></span>') +
31
31
  (goal ? '<p class="scu-goal" data-scu="goal"></p>' : '') +
32
- text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') +
32
+ text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') + recapDecisionsHtml(ui.data, { esc }) +
33
33
  '<div class="msg-foot"><p class="scu-by" data-scu="by"></p>' + copyButtonHtml(recapSource(ui.data), { deferred: true }) + '</div></div>'
34
34
  }
35
35
 
36
+ // testplan Part 2: what the session decided since you looked, each with its word in the log (shape plus word).
37
+ const DEC_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻', unlogged: '·' }
38
+ const DEC_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded', unlogged: 'Not in the log' }
39
+ export function recapDecisionsHtml(d, { esc }) {
40
+ const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
41
+ if (!list.length) return ''
42
+ const sinceWord = d && d.recap && d.recap.since ? 'Decided since you looked' : 'Decided in this session'
43
+ return '<div class="scu-decs"><h3 class="scu-dec-h">' + esc(sinceWord) + '</h3><ul class="scu-dec-l">' + list.map((x) =>
44
+ '<li><span class="dec-st dec-st-' + esc(x.status) + '"><span aria-hidden="true">' + (DEC_SHAPE[x.status] || '·') + '</span> ' + esc(DEC_WORD[x.status] || x.status) + '</span> ' +
45
+ (x.id ? '<span class="dec-id">' + esc(x.id) + '</span> ' : '') + '<span class="dec-text">' + esc(x.text) + '</span>' + (x.supersededBy ? ' <span class="faint">→ ' + esc(x.supersededBy) + '</span>' : '') + '</li>').join('') + '</ul></div>'
46
+ }
47
+ export function recapDecisionsText(d) {
48
+ const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
49
+ return list.length ? 'Decided:\n' + list.map((x) => '- ' + (x.id ? x.id + ' ' : '') + x.text + ' (' + (DEC_WORD[x.status] || x.status).toLowerCase() + ')').join('\n') : ''
50
+ }
51
+
36
52
  // 0.9.11: what Copy takes from a recap, as markdown: the goal, the lines as a
37
53
  // list (or a sentence or two, as shown), and the Next line.
38
54
  export function recapSource(d) {
39
55
  const { body, next } = recapParts(d)
40
56
  const goal = recapGoal(d)
41
57
  const text = body.length <= 2 ? body.join(' ') : body.map((b) => '- ' + b).join('\n')
42
- return [goal, text, next].filter((x) => x && String(x).trim()).join('\n\n')
58
+ return [goal, text, next, recapDecisionsText(d)].filter((x) => x && String(x).trim()).join('\n\n')
43
59
  }
44
60
 
45
61
  // The recap's lines: what happened, and the one "Next: …" / "Up to you…" /
@@ -56,7 +56,7 @@ export function formatCheckpointText(cp, { indent = '' } = {}) {
56
56
  line(title + ':')
57
57
  items.forEach((x, i) => line((title === 'Next steps' ? ' ' + (i + 1) + '. ' : ' - ') + fmt(x)))
58
58
  }
59
- list('Key decisions', cp.decisions)
59
+ list('Approach notes', cp.decisions)
60
60
  list('Open questions', cp.questions)
61
61
  list('Files touched', cp.files, (f) => (f.path || '') + (f.path && f.why ? ' — ' : '') + (f.why || ''))
62
62
  list('What didn’t work', cp.didntWork)