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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/ask.js CHANGED
@@ -19,7 +19,7 @@ import { glyphState, glyphHtml } from './sglyph.js' // 0.9.14: the status glyph,
19
19
  export const projectHref = (token) => '#/project/' + encodeURIComponent(String(token || ''))
20
20
  export const threadHref = (id, machine = null) => '#/ask/' + encodeURIComponent(String(id || '')) + (machine ? '?m=' + encodeURIComponent(machine) : '')
21
21
 
22
- const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2})\]/g
22
+ const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2}|dec:D-[0-9a-f]{4,12})\]/g
23
23
 
24
24
  // Where a citation chip goes: a session, a checkpoint on its session page,
25
25
  // a digest day; a commit has no page here (its sha is copied).
@@ -28,15 +28,17 @@ export function chipHref(c) {
28
28
  if (c.kind === 'session' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId)
29
29
  if (c.kind === 'checkpoint' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId) + (c.cpId ? '?cp=' + encodeURIComponent(c.cpId) : '')
30
30
  if (c.kind === 'digest' && c.date) return '#/digest/' + encodeURIComponent(c.date)
31
+ if (c.kind === 'decision' && c.repo && c.id) return '#/decisions/' + String(c.repo).replace(/[^\w:./@-]/g, encodeURIComponent) + '?d=' + encodeURIComponent(c.id) // 2026-11-03
31
32
  return null
32
33
  }
33
34
  export function chipText(c) {
34
35
  if (!c) return ''
35
36
  if (c.kind === 'commit') return (c.short || String(c.sha || '').slice(0, 7)) + (c.label ? ' ' + c.label : '')
36
37
  if (c.kind === 'digest') return 'Digest ' + (c.date || '')
38
+ if (c.kind === 'decision') return (c.id || 'Decision') + (c.label ? ' ' + c.label : '')
37
39
  return c.label || (c.kind === 'checkpoint' ? 'Checkpoint' : 'Session')
38
40
  }
39
- const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper' }
41
+ const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper', decision: 'route' }
40
42
 
41
43
  // One chip. Its words are filled in as text (data-qt).
42
44
  export function chipHtml(c, key, { esc, icon = () => '', n = null } = {}) {
@@ -0,0 +1,133 @@
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
+ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null } = {}) {
16
+ const list = Array.isArray(decided) ? decided : []
17
+ if (!list.length) return ''
18
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
19
+ const rows = list.map((d) => {
20
+ const hit = log && log[norm(d.text)]
21
+ const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '">' + STATUS_SHAPE[hit.status] + ' ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
22
+ return '<li class="dec-row">' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(d.text) + '</span>' + st +
23
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
24
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
25
+ '<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div></li>'
26
+ }).join('')
27
+ const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
28
+ return '<ul class="dec-list">' + rows + '</ul>' + link
29
+ }
30
+
31
+ // ------------------------------------------------------------ the decision log (timeline)
32
+
33
+ // A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
34
+ export function decisionScope(arg) {
35
+ const a = String(arg || '')
36
+ if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
37
+ return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
38
+ }
39
+ export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
40
+
41
+ // What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
42
+ // (superseded and rejected too). Proposed ones are always above, to review.
43
+ export function timelineRows(decisions, { all = false } = {}) {
44
+ const list = Array.isArray(decisions) ? decisions : []
45
+ return {
46
+ review: list.filter((d) => d.status === 'proposed'),
47
+ rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
48
+ hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
49
+ }
50
+ }
51
+
52
+ function sessionBit(d, esc) {
53
+ const s = d.session
54
+ if (!s || !s.id) return ''
55
+ const title = s.title || 'its session'
56
+ return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
57
+ (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>' : '')
58
+ }
59
+ function chainBit(d, byId, esc) {
60
+ const out = []
61
+ 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>') }
62
+ 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>')
63
+ return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
64
+ }
65
+ function statusBit(d, esc) {
66
+ 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>'
67
+ }
68
+ function editForm(d, esc) {
69
+ return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
70
+ '<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
71
+ '<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
72
+ '<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>'
73
+ }
74
+
75
+ // One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
76
+ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false } = {}) {
77
+ const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
78
+ 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>'
79
+ if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
80
+ 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>' +
81
+ (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) + '">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) + '">Not related</button></span>' : '') + '</div>' : '' }).join('')
82
+ 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) + '">' + label + '</button>'
83
+ const acts = !may ? '' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button></div>'
84
+ : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : '<button type="button" class="linkish" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button>') + '</div>'
85
+ return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
86
+ '<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>' +
87
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
88
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
89
+ chainBit(d, byId, esc) + sug +
90
+ '<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>'
91
+ }
92
+
93
+ // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
94
+ export function storageChoiceHtml(project, { esc = escD, may = true } = {}) {
95
+ if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
96
+ const on = !!project.inRepo
97
+ 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) + '"' + (may ? '' : ' disabled') + '>' + label + '</button>'
98
+ return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
99
+ '<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>' +
100
+ '<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.'
101
+ : '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>'
102
+ }
103
+
104
+ // The whole page. v: GET /v1/decisions. ui: { all, editing, may }
105
+ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
106
+ const list = (v && v.decisions) || []
107
+ const byId = new Map(list.map((d) => [d.id, d]))
108
+ const multi = ((v && v.repos) || []).length > 1
109
+ const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
110
+ const sc = (v && v.scope) || {}
111
+ const name = sc.kind === 'project' ? sc.name : sc.label
112
+ const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi }
113
+ let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
114
+ h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
115
+ (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
116
+ if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false && ui.trusted !== false }) + '</section>'
117
+ 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>'
118
+ 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>' +
119
+ '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button></div>'
120
+ h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
121
+ (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>'
122
+ return '<div class="proj dec-page">' + h + '</div>'
123
+ }
124
+
125
+ // The project page's short Decisions section: counts, the storage choice, the way in.
126
+ export function projectDecisionsHtml(v, { esc = escD, may = true, scopeArg = '' } = {}) {
127
+ if (!v) return ''
128
+ const list = v.decisions || []
129
+ const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
130
+ return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
131
+ '<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>' +
132
+ storageChoiceHtml(v.project, { esc, may }) + '</section>'
133
+ }
package/ui/semdiff.js CHANGED
@@ -216,7 +216,20 @@ 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
+ return '<div class="wt-decs" role="group" aria-label="Decisions behind this step">' + ids.map((id) => {
227
+ const d = by.get(id)
228
+ return '<button type="button" class="dec-chip wt-dec' + (gone.has(id) ? ' wt-dec-gone' : '') + '" data-action="wt-dec" data-id="' + esc(id) + '"' + (data && data.decisionRepo ? ' data-repo="' + esc(data.decisionRepo) + '"' : '') +
229
+ ' data-tip="' + esc((gone.has(id) ? 'Changed since this walkthrough · ' : '') + (d ? d.text : 'Decision ' + id)) + '"><span class="wt-dec-id">' + esc(id) + '</span>' + (d ? '<span class="wt-dec-t">' + esc(d.text) + '</span>' : '') + (gone.has(id) ? '<span class="wt-dec-w"> · changed since</span>' : '') + '</button>'
230
+ }).join('') + '</div>'
231
+ }
232
+ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true, data = null }) {
220
233
  const core = st.tag === 'Core'
221
234
  // 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
222
235
  const rp = st.repo ? [st.repo] : []
@@ -246,7 +259,7 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
246
259
  : '<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
260
  // callouts whose line isn't in a drawn snippet (folded code) still read here
248
261
  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>'
262
+ 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
263
  }
251
264
 
252
265
  function groupHtml(g, { esc, icon, open, data = null }) {
@@ -389,6 +402,8 @@ function modeSegHtml(show, split) {
389
402
  // "3 files changed since this walkthrough · Update"
390
403
  export function staleWords(st) {
391
404
  if (!st) return ''
405
+ // 2026-11-03 a decision it cites has changed (superseded, rejected or edited) since it was written
406
+ 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
407
  if (st.count == null) return 'The change has moved on since this walkthrough'
393
408
  if (!st.count) return 'The change has moved on since this walkthrough'
394
409
  return st.count + (st.count === 1 ? ' file changed' : ' files changed') + ' since this walkthrough'
@@ -477,7 +492,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
477
492
  const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
478
493
  const bd = data.stats.breakdown || {}
479
494
  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 })
495
+ 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
496
  const repos = walkRepos(data)
482
497
  // 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
483
498
  if (repos.length) {
@@ -565,7 +580,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
565
580
  slides.forEach((s, i) => {
566
581
  if (s.kind === 'step') {
567
582
  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) + '"')
583
+ 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
584
  } else if (s.kind === 'skip') {
570
585
  const groups = data.groups || []
571
586
  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 +823,7 @@ export function toMarkdown(data, { title = '' } = {}) {
808
823
  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
824
  L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
810
825
  if (st.why) L.push(st.why)
826
+ if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); L.push(''); L.push('Decisions: ' + st.decisions.map((id) => '`' + id + '`' + (by.get(id) ? ' ' + by.get(id).text : '')).join('; ')) } // 2026-11-03
811
827
  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
828
  for (const sn of st.snippets || []) {
813
829
  const fence = /```/.test(sn.patch) ? '~~~~' : '```'
@@ -820,6 +836,7 @@ export function toMarkdown(data, { title = '' } = {}) {
820
836
  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
837
  }
822
838
  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) + '._') }
839
+ 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
840
  return L.join('\n') + '\n'
824
841
  }
825
842
 
@@ -865,6 +882,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
865
882
  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
883
  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
884
  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>'
885
+ 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
886
  b += '<section class="sl" data-i="0">' + o + nav(0) + '</section>'
869
887
  slides.forEach((s, i) => {
870
888
  if (!i) return
@@ -873,6 +891,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
873
891
  const st = s.st
874
892
  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
893
  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>' : '')
894
+ if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); x += '<p class="f decs">Decisions: ' + st.decisions.map((id) => '<code>' + escH(id) + '</code>' + (by.get(id) ? ' ' + escH(by.get(id).text) : '')).join('; ') + '</p>' } // 2026-11-03
876
895
  const drawn = new Set()
877
896
  for (const sn of st.snippets || []) {
878
897
  const cs = (st.callouts || []).filter((c) => c.hunk === sn.hunk)