@cspeach/cli 1.1.30 → 1.1.31

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.
Files changed (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -0,0 +1,168 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/estate-parts.js,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ /**
6
+ * The estate: what custom code the system holds, before anyone splits it up.
7
+ *
8
+ * Drawn from register-core's `EstateSummary` (the newest discovery file, read
9
+ * the strict way) and nothing else, so the page, the status file and the
10
+ * workbook count the same packages. Two places wear it: the Estate section of
11
+ * a team project page, and the whole page of a folder the lead has run
12
+ * discovery into but not yet made a team project from.
13
+ *
14
+ * The same rules as every other part of this page: every value out of the
15
+ * folder goes through `cell()` (or `esc()` for our own words), colour is a
16
+ * class and never a style attribute, and a count is a count — "last change" is
17
+ * when the code was changed and is never called use.
18
+ */
19
+ import { esc } from './register-map.js';
20
+ import { cell, n } from './register-parts.js';
21
+ import { formatWhen } from './register-model.js';
22
+ import { findingsByPackage, plainDate, shortUsageWindow, usageWindowOf } from '@cspeach/register-core';
23
+ export const NO_ESTATE = 'No estate inventory yet. The lead runs cspeach team discover.';
24
+ /** A discovery file is there but is not one this build can read (another kind or version). */
25
+ export const ESTATE_UNREADABLE = 'The estate inventory in this folder could not be read.';
26
+ /** As many package rows as a reader scans before the table stops helping. */
27
+ const PACKAGE_ROWS = 100;
28
+ const numberTile = (label, value, note) => `<li class="tile"><span class="tile-n">${n(value)}</span> <span class="tile-label">${esc(label)}</span> <span class="tile-note">${esc(note)}</span></li>`;
29
+ /**
30
+ * Whether runtime usage is recorded, in the tile's own short words. The source
31
+ * is not named on screen; the workbook's Usage sources sheet says which one.
32
+ * Only an ACTIVE source can say usage is recorded — empty, off and not read
33
+ * all mean nothing on this system records which programs run.
34
+ *
35
+ * A window under 90 days, or one that ended more than 90 days before the
36
+ * report, says so: it is too short (or too old) to say what is unused, and the
37
+ * tile must not read as if it could (rehearsal fix 2).
38
+ *
39
+ * @param {string} [reportAt] when the report was made; the inventory's own time when not given
40
+ */
41
+ function usageWords(estate, reportAt) {
42
+ const active = (estate.usageSources ?? []).find((s) => s.state === 'active');
43
+ if (!active)
44
+ return 'none recorded on this system';
45
+ if (!active.period)
46
+ return 'recorded, period not stated';
47
+ const window = usageWindowOf(estate.usageSources);
48
+ const weak = window ? shortUsageWindow(window, reportAt || estate.takenAt || '') : null;
49
+ if (weak) {
50
+ const span = `${plainDate(window.from)} – ${plainDate(window.to)}`;
51
+ return weak.short
52
+ ? `only ${weak.days} days (${span}) — too short to say what is unused`
53
+ : `${weak.days} days (${span}), ended over 90 days ago — too old to say what is unused`;
54
+ }
55
+ return `recorded from ${active.period.from} to ${active.period.to}`;
56
+ }
57
+ function tilesHtml(estate, reportAt) {
58
+ const t = estate.totals ?? {};
59
+ return '<ul class="tiles estate-tiles" data-role="estate-tiles">'
60
+ + numberTile('Packages', t.packages ?? 0, 'with custom code')
61
+ + numberTile('Code objects', t.codeObjects ?? 0, 'programs, classes, function groups')
62
+ + numberTile('Non-code objects', t.nonCodeObjects ?? 0, 'tables, data elements and the rest')
63
+ + numberTile('Outside packages', t.local ?? 0, 'local objects, never transported')
64
+ + numberTile('Modifications of SAP objects', t.modifications ?? 0, t.noteCorrections ? `not counting ${n(t.noteCorrections)} note corrections` : 'changes to SAP\x27s own code')
65
+ + `<li class="tile tile-words"><span class="tile-text">${esc(usageWords(estate, reportAt))}</span> <span class="tile-label">Usage data</span> <span class="tile-note">which programs run</span></li>`
66
+ + '</ul>';
67
+ }
68
+ function unknownsHtml(estate) {
69
+ const list = estate.unknowns ?? [];
70
+ if (!list.length)
71
+ return '<div class="estate-unknowns" data-role="estate-unknowns"><h3>Unknowns</h3><p class="quiet-note">Nothing is unknown: every step of the inventory read.</p></div>';
72
+ return '<div class="estate-unknowns" data-role="estate-unknowns"><h3>Unknowns</h3><ul>'
73
+ + list.map((u) => `<li><b>${cell(u.what)}</b> — ${u.why ? cell(u.why) : '<span class="quiet-note">no reason recorded</span>'}</li>`).join('')
74
+ + '</ul></div>';
75
+ }
76
+ /**
77
+ * @param {Record<string, number>} [findings] open findings per package, over
78
+ * the objects a check has measured (register-core `findingsByPackage`); a
79
+ * package with none measured has no key and says so.
80
+ */
81
+ function packagesHtml(estate, { inScope, findings }) {
82
+ const all = estate.packages ?? [];
83
+ const shown = all.slice(0, PACKAGE_ROWS);
84
+ const heads = ['Package', 'Area', 'Objects', ...(findings ? ['Open findings'] : []), 'Last change', ...(inScope ? ['In scope'] : [])]
85
+ .map((h) => `<th scope="col">${h}</th>`).join('');
86
+ const row = (p) => `<tr><td><span class="ident">${cell(p.name)}</span>${p.text ? ` <span class="quiet-note">${cell(p.text)}</span>` : ''}</td>`
87
+ + `<td>${cell(p.area)}</td><td>${n(p.objects)}</td>`
88
+ + (findings ? `<td>${Object.hasOwn(findings, p.name) ? n(findings[p.name]) : '<span class="quiet-note">not measured</span>'}</td>` : '')
89
+ + `<td>${p.lastChanged ? cell(p.lastChanged.at) : '<span class="quiet-note">not recorded</span>'}</td>`
90
+ + (inScope ? `<td>${p.inScope ? 'in scope' : '<span class="quiet-note">not in scope</span>'}</td>` : '')
91
+ + '</tr>';
92
+ const more = all.length - shown.length;
93
+ return `<div class="scroller"><table data-role="estate-packages" aria-label="Packages with custom code"><thead><tr>${heads}</tr></thead>`
94
+ + `<tbody>${shown.map(row).join('')}</tbody></table></div>`
95
+ + (more > 0 ? `<p class="quiet-note" data-role="estate-more">and ${n(more)} more</p>` : '');
96
+ }
97
+ /**
98
+ * Inside a team project the whole system is one line, not a table of every
99
+ * package on it (rehearsal fix 3): the project is what the page is about, and
100
+ * the inventory workbook holds the rest.
101
+ */
102
+ function wholeSystemHtml(estate) {
103
+ const t = estate.totals ?? {};
104
+ return `<p class="estate-whole" data-role="estate-whole">Whole system: ${n(t.packages ?? 0)} package${t.packages === 1 ? '' : 's'}, `
105
+ + `${n(t.codeObjects ?? 0)} code object${t.codeObjects === 1 ? '' : 's'} — see the inventory (estate.xlsx in the discovery folder).</p>`;
106
+ }
107
+ /**
108
+ * The estate section's body.
109
+ *
110
+ * @param {object|null|undefined} estate register-core `EstateSummary`
111
+ * @param {{ inScope?: boolean, findings?: Record<string, number>, unreadable?: boolean, now?: string }} [opts]
112
+ * `inScope` — drawn inside a team project: the whole system is one line and
113
+ * the In scope line follows; the package table is the estate-only view's.
114
+ * `unreadable` — a discovery file was found but could not be read, so the
115
+ * empty sentence must not say there is none.
116
+ * `now` — when the report was made, for the usage tile.
117
+ */
118
+ export function estateHtml(estate, { inScope = false, findings, unreadable = false, now } = {}) {
119
+ if (!estate) {
120
+ return unreadable
121
+ ? `<p class="empty-note" data-role="no-estate">${ESTATE_UNREADABLE}</p>`
122
+ : `<p class="empty-note" data-role="no-estate">${NO_ESTATE}</p>`;
123
+ }
124
+ const scope = inScope && estate.inScope
125
+ ? `<p class="estate-scope" data-role="estate-in-scope">In scope: ${n(estate.inScope.packages)} package${estate.inScope.packages === 1 ? '' : 's'}, ${n(estate.inScope.objects)} object${estate.inScope.objects === 1 ? '' : 's'}</p>`
126
+ : '';
127
+ if (inScope)
128
+ return `${tilesHtml(estate, now)}${wholeSystemHtml(estate)}${scope}${unknownsHtml(estate)}`;
129
+ return `${tilesHtml(estate, now)}${scope}${unknownsHtml(estate)}${packagesHtml(estate, { inScope: false, findings })}`;
130
+ }
131
+ /**
132
+ * The Estate section's body inside a team project, as the page and the status
133
+ * file both draw it: open findings per package come from register-core, so the
134
+ * two surfaces never count them two ways.
135
+ * @param {object} register
136
+ * @param {{ fileGiven?: boolean, now?: string }} [opts] a discovery file was in the folder; when the report was made
137
+ */
138
+ export function estateSectionHtml(register, { fileGiven = false, now } = {}) {
139
+ return estateHtml(register.estate, {
140
+ inScope: true, findings: findingsByPackage(register), unreadable: fileGiven && !register.estate, now,
141
+ });
142
+ }
143
+ /**
144
+ * The whole page for a folder that holds a discovery and no team project yet.
145
+ * It says what it is, who took it and when, and the one step that comes next.
146
+ */
147
+ export function estateOnlyPageHtml(estate, folderName, now) {
148
+ const taken = estate?.takenAt ? `Taken ${esc(formatWhen(estate.takenAt))}${estate.by ? ` by ${cell(estate.by)}` : ''}.` : '';
149
+ return `
150
+ <header class="masthead">
151
+ <p class="provenance">Estate inventory, read from the folder ${cell(folderName)}.</p>
152
+ <h1>The custom code on this system</h1>
153
+ <p class="summary">${taken} No team project yet. The lead makes one from this inventory with cspeach team init --from-discovery.</p>
154
+ <p class="notice" data-role="reload-note" hidden></p>
155
+ <div class="actions">
156
+ <button type="button" data-action="reload" class="primary">Read the folder again</button>
157
+ <button type="button" data-action="print" class="quiet">Print or save as PDF</button>
158
+ <button type="button" data-action="change-folder" class="quiet">Open another folder</button>
159
+ </div>
160
+ </header>
161
+ <section data-role="estate" class="estate" aria-labelledby="team-estate-heading">
162
+ <h2 id="team-estate-heading">Estate</h2>
163
+ ${estateHtml(estate, { unreadable: !estate, now })}
164
+ </section>
165
+ <footer>
166
+ <p data-role="trust">This page reads the files in your folder and draws them here, in your browser. Your browser blocks it from sending anything to any other site. The inventory holds package names, object counts, SAP user ids and dates. No source code. No passwords.</p>
167
+ </footer>`;
168
+ }
@@ -0,0 +1,68 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/plan-parts.js,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ /**
6
+ * Plan and changes (spec 2026-10-06 §9 item 8): how the working lists are
7
+ * ordered, and who changed that, when and why.
8
+ *
9
+ * Three things, read top to bottom: the priority weights in one line with where
10
+ * they came from; the focus stack in order, each with how much of it is done;
11
+ * and the change history, newest first, as register-core hands it. Shared by
12
+ * the page and the status file, so a manager reading either sees one plan.
13
+ *
14
+ * Every value out of the folder goes through `cell()`; our own words through
15
+ * `esc()`. No style attribute anywhere.
16
+ */
17
+ import { esc } from './register-map.js';
18
+ import { formatWhen } from './register-model.js';
19
+ import { cell, dayWords, n, NO_FOCUS } from './register-parts.js';
20
+ import { DEFAULT_WEIGHTS } from '@cspeach/register-core';
21
+ export const NOTHING_REPLANNED = 'Nothing has been re-planned. Lists follow the suggested order.';
22
+ /** How many changes are listed before the rest is counted. */
23
+ export const HISTORY_ROWS = 50;
24
+ const PARTS = ['effort', 'risk', 'shared', 'usage', 'disposition'];
25
+ const whole = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
26
+ function weightsHtml(plan) {
27
+ const w = plan?.weights ?? DEFAULT_WEIGHTS;
28
+ const source = plan?.weightsFrom === 'set'
29
+ ? (() => {
30
+ const day = dayWords(plan.weightsAt);
31
+ return `Set${plan.weightsBy ? ` by ${cell(plan.weightsBy)}` : ''}${day ? ` on ${esc(day)}` : ''}.`;
32
+ })()
33
+ : 'The default weights.';
34
+ return `<p class="plan-weights" data-role="weights">Priority weights: ${PARTS.map((p) => `${p} ${n(whole(w[p]))}`).join(', ')}. ${source}</p>`;
35
+ }
36
+ function focusStackHtml(focuses) {
37
+ if (!focuses.length)
38
+ return `<p class="quiet-note" data-role="no-focus">${esc(NO_FOCUS)}</p>`;
39
+ const item = (f) => {
40
+ const day = dayWords(f.at);
41
+ return `<li>${cell(f.label)} — ${n(whole(f.donePct))}% done, ${n(whole(f.proven))} of ${n(whole(f.active))} proven. `
42
+ + `Set by ${cell(f.by)}${day ? ` on ${esc(day)}` : ''}${f.reason ? ` because ${cell(f.reason)}` : ''}.</li>`;
43
+ };
44
+ return `<h3>The focus, in order</h3><ol class="focus-stack" data-role="focus-stack">${focuses.map(item).join('')}</ol>`;
45
+ }
46
+ function historyHtml(history) {
47
+ const shown = history.slice(0, HISTORY_ROWS);
48
+ const row = (c) => `<tr><td>${esc(formatWhen(c.at))}</td><td>${cell(c.by)}</td><td class="what">${cell(c.what)}</td>`
49
+ + `<td class="what">${c.why ? cell(c.why) : '<span class="quiet-note">no reason recorded</span>'}</td></tr>`;
50
+ const more = history.length - shown.length;
51
+ return '<h3>Changes, newest first</h3>'
52
+ + '<div class="scroller"><table data-role="plan-history" aria-label="Plan changes, newest first"><thead><tr>'
53
+ + '<th scope="col">When</th><th scope="col">Who</th><th scope="col">What</th><th scope="col">Why</th></tr></thead>'
54
+ + `<tbody>${shown.map(row).join('')}</tbody></table></div>`
55
+ + (more > 0 ? `<p class="quiet-note" data-role="plan-more">and ${n(more)} more</p>` : '');
56
+ }
57
+ /**
58
+ * The Plan and changes section's body.
59
+ * @param {object} register register-core `Register` (an older one with no `plan` reads as not re-planned)
60
+ * @returns {string} escaped markup
61
+ */
62
+ export function planSectionHtml(register) {
63
+ const plan = register?.plan;
64
+ const history = Array.isArray(plan?.history) ? plan.history : [];
65
+ if (!history.length)
66
+ return `${weightsHtml(plan)}<p class="empty-note" data-role="plan-empty">${NOTHING_REPLANNED}</p>`;
67
+ return weightsHtml(plan) + focusStackHtml(Array.isArray(plan.focuses) ? plan.focuses : []) + historyHtml(history);
68
+ }
@@ -0,0 +1,110 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/progress-chart.js,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ /**
6
+ * Progress over time (spec 2026-10-06 §9 item 5).
7
+ *
8
+ * One small line chart a manager reads in one look: how many objects are
9
+ * decided, claimed, proven, and at level A, one point per check run. Drawn from
10
+ * register-core's `progress` series and nothing else, so the page, the status
11
+ * file and the workbook's Progress sheet all show the same counts.
12
+ *
13
+ * It lives under the page's strict policy and inside a mailed document, so it
14
+ * is plain inline SVG: no reference to anything outside itself, no style
15
+ * attribute (every colour and dash is a class in team.css), and every string
16
+ * escaped. Colour never stands alone: each line has its own dash, and the
17
+ * legend says the numbers in words.
18
+ *
19
+ * Under the chart, the done-% bars per lane and per focus — the same numbers
20
+ * the lane table and the focus line print. Bar widths are classes in 5 % steps
21
+ * (`w-0` … `w-100`), because a width written into the markup would be a style.
22
+ */
23
+ import { esc } from './register-map.js';
24
+ import { cell, dayWords, n, ownerLabel } from './register-parts.js';
25
+ export const NO_RUN_YET = 'No check run yet, so there is no line to draw.';
26
+ /** Back to front, so the line that matters most — proven — is drawn over the others. */
27
+ const SERIES = [
28
+ { id: 'decided', label: 'Decided', of: (p) => p.decided },
29
+ { id: 'claimed', label: 'Claimed', of: (p) => p.claimed },
30
+ { id: 'proven', label: 'Proven', of: (p) => p.proven },
31
+ { id: 'a', label: 'At level A', of: (p) => p.aNow },
32
+ ];
33
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
34
+ const fix = (v) => Number(v.toFixed(1));
35
+ /** The width class for a percentage: nearest 5 % step, kept within 0 and 100. */
36
+ export function pctClass(pct) {
37
+ const v = Number(pct);
38
+ if (!Number.isFinite(v) || v <= 0)
39
+ return 'w-0';
40
+ return `w-${Math.min(100, Math.round(v / 5) * 5)}`;
41
+ }
42
+ function legendHtml(points) {
43
+ const first = points[0];
44
+ const last = points[points.length - 1];
45
+ const was = (s) => (points.length > 1 ? ` (${n(num(s.of(first)))} at the first run shown)` : '');
46
+ const item = (s) => {
47
+ const value = s.id === 'a' ? `${n(num(last.aNow))}, ${n(num(last.aShare))}% of active` : n(num(s.of(last)));
48
+ return `<li><span class="key key-${s.id}"></span>${esc(s.label)}: ${value}${was(s)}</li>`;
49
+ };
50
+ return `<ul class="chart-legend" data-role="progress-legend">${SERIES.map(item).join('')}</ul>`;
51
+ }
52
+ /**
53
+ * The chart as one figure: the SVG, the legend in words, what a point is, and —
54
+ * when register-core cut the series — how many runs are not shown.
55
+ *
56
+ * @param {object[]} points register-core `ProgressPoint[]`, oldest first
57
+ * @param {{ width?: number, height?: number, capped?: { shown: number, total: number } }} [opts]
58
+ * @returns {string} escaped markup
59
+ */
60
+ export function progressChartSvg(points, { width = 640, height = 220, capped } = {}) {
61
+ if (!Array.isArray(points) || !points.length)
62
+ return `<p class="empty-note" data-role="no-progress">${NO_RUN_YET}</p>`;
63
+ const left = 44;
64
+ const right = 12;
65
+ const top = 12;
66
+ const bottom = 28;
67
+ const plotW = width - left - right;
68
+ const plotH = height - top - bottom;
69
+ const max = Math.max(1, ...points.map((p) => Math.max(num(p.active), ...SERIES.map((s) => num(s.of(p))))));
70
+ const x = (i) => fix(points.length === 1 ? left + plotW / 2 : left + (plotW * i) / (points.length - 1));
71
+ const y = (v) => fix(top + plotH - (plotH * num(v)) / max);
72
+ const ticks = [...new Set([0, max >= 4 ? Math.round(max / 2) : null, max].filter((t) => t !== null))];
73
+ const grid = ticks.map((t) => `<line class="grid" x1="${left}" x2="${width - right}" y1="${y(t)}" y2="${y(t)}"></line>`
74
+ + `<text class="axis" x="${left - 6}" y="${fix(y(t) + 4)}" text-anchor="end">${esc(n(t))}</text>`).join('');
75
+ const firstDay = dayWords(points[0].at);
76
+ const lastDay = dayWords(points[points.length - 1].at);
77
+ const base = height - 8;
78
+ const xLabels = points.length === 1
79
+ ? `<text class="axis" x="${x(0)}" y="${base}" text-anchor="middle">${esc(firstDay)}</text>`
80
+ : `<text class="axis" x="${left}" y="${base}" text-anchor="start">${esc(firstDay)}</text>`
81
+ + (lastDay && lastDay !== firstDay ? `<text class="axis" x="${width - right}" y="${base}" text-anchor="end">${esc(lastDay)}</text>` : '');
82
+ const lines = SERIES.map((s) => `<polyline class="pl pl-${s.id}" points="${points.map((p, i) => `${x(i)},${y(s.of(p))}`).join(' ')}"></polyline>`).join('');
83
+ // One run is a point, not a line: a polyline with one vertex draws nothing.
84
+ const dots = points.length === 1 ? SERIES.map((s) => `<circle class="pd pd-${s.id}" cx="${x(0)}" cy="${y(s.of(points[0]))}" r="3.5"></circle>`).join('') : '';
85
+ const last = points[points.length - 1];
86
+ const label = `Progress over time, ${points.length} point${points.length === 1 ? '' : 's'}: `
87
+ + SERIES.map((s) => `${s.label} ${num(s.of(last))}`).join(', ');
88
+ return '<figure class="progress-chart" data-role="progress-chart">'
89
+ + `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(label)}">${grid}${lines}${dots}${xLabels}</svg>`
90
+ + `<figcaption>${legendHtml(points)}`
91
+ + '<p class="legend-line">Each point is a check run, oldest on the left. The A line counts the objects at level A. Lanes, scope and the focus are counted as they are now.</p>'
92
+ + (capped ? `<p class="legend-line" data-role="progress-capped">Showing the last ${n(num(capped.shown))} of ${n(num(capped.total))} check runs.</p>` : '')
93
+ + '</figcaption></figure>';
94
+ }
95
+ const barRow = (label, pct, proven, active, extra = '') => `<li><span class="pct-label">${label}</span> `
96
+ + `<span class="pct-bar"><span class="pct-fill${extra} ${pctClass(pct)}"></span></span>`
97
+ + `<span class="pct-n">${n(num(pct))}% · ${n(num(proven))} of ${n(num(active))} proven</span></li>`;
98
+ /** How far each lane and each focus is now, as bars with the numbers beside them. */
99
+ function doneBarsHtml(register) {
100
+ const lanes = (register.lanes ?? []).map((l) => barRow(cell(ownerLabel(l)), l.donePct, l.proven, l.active));
101
+ const focuses = (register.plan?.focuses ?? []).map((f) => barRow(`Focus: ${cell(f.label)}`, f.donePct, f.proven, f.active, ' f-focus'));
102
+ if (!lanes.length && !focuses.length)
103
+ return '';
104
+ return '<h3>Done now, per developer and focus</h3>'
105
+ + `<ul class="pct-bars" data-role="done-bars">${[...lanes, ...focuses].join('')}</ul>`;
106
+ }
107
+ /** The Progress over time section's body, as the page and the status file both draw it. */
108
+ export function progressSectionHtml(register) {
109
+ return progressChartSvg(register.progress, { capped: register.progressCapped }) + doneBarsHtml(register);
110
+ }
@@ -160,9 +160,14 @@ export function blockSvg(block, { cell = 10, gap = 2, role = 'group', colourBy =
160
160
  // A retired object keeps its state colour in level mode: the level counts
161
161
  // leave retired objects out, and a cell painted C that no count includes
162
162
  // would show a manager two numbers for one folder.
163
- const asLevel = byLevel && o.state !== 'retired';
163
+ //
164
+ // A clean (or verified) object nobody decided has no level and nothing to
165
+ // decide: it keeps its state colour too, and its title says so — never
166
+ // "level not decided" (register-core leaves it out of the level counts).
167
+ const nothing = byLevel && o.inScope !== false && !o.decision && !LEVEL_SET.has(o.levelNow) && (o.state === 'clean' || o.state === 'verified');
168
+ const asLevel = byLevel && o.state !== 'retired' && !nothing;
164
169
  return `<rect class="cell ${asLevel ? levelClass(o.levelNow) : stateClass(o.state)}"${keyed ? ` data-key="${esc(o.key)}"` : ''} x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2">`
165
- + `<title>${esc(o.name)} · ${esc(o.type)}${asLevel ? ` · level ${esc(LEVEL_WORD[LEVEL_SET.has(o.levelNow) ? o.levelNow : 'none'])}` : ''} · ${esc(stateWord(o))}</title></rect>`;
170
+ + `<title>${esc(o.name)} · ${esc(o.type)}${asLevel ? ` · level ${esc(LEVEL_WORD[LEVEL_SET.has(o.levelNow) ? o.levelNow : 'none'])}` : nothing ? ' · clean, nothing to decide' : ''} · ${esc(stateWord(o))}</title></rect>`;
166
171
  }).join('');
167
172
  const label = `${block.package}: ${n.toLocaleString('en-US')} ${n === 1 ? 'object' : 'objects'}`;
168
173
  return `<svg class="block-map" role="${safeRole}" aria-label="${esc(label)}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects}</svg>`;
@@ -200,13 +205,16 @@ export function legendHtml(byState, { unknown = 0 } = {}) {
200
205
  * @param {{ retired?: number }} [opts]
201
206
  * @returns {string}
202
207
  */
203
- export function levelLegendHtml(counts, { retired = 0 } = {}) {
208
+ export function levelLegendHtml(counts, { retired = 0, nothingToDecide = 0 } = {}) {
204
209
  const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc((count ?? 0).toLocaleString('en-US'))}</b></li>`;
205
210
  // A folder from an older build has no level counts: say so, as the level bars
206
211
  // do, rather than print five zeros that read as "measured, nothing there".
207
212
  const items = counts
208
213
  ? LEVEL_BUCKETS.map((l) => entry(`l-${l}`, LEVEL_WORD[l], counts[l]))
209
214
  : ['<li>No levels recorded yet.</li>'];
215
+ // Drawn in the clean colour, the colour their cells keep (blockSvg).
216
+ if (counts && nothingToDecide > 0)
217
+ items.push(entry(stateClass('clean'), 'clean, nothing to decide', nothingToDecide));
210
218
  if (retired > 0)
211
219
  items.push(entry(stateClass('retired'), 'retired', retired));
212
220
  return `<ul class="legend">${items.join('')}</ul>`;
@@ -12,14 +12,14 @@
12
12
  * measurement has none) or clean (measured, never had any). A developer's
13
13
  * "fixed" is a claim and is never counted here.
14
14
  */
15
- import { activeOf, provenOf, settledOf } from '@cspeach/register-core';
15
+ import { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf } from '@cspeach/register-core';
16
16
  /**
17
17
  * The three counts every surface divides by are defined once, in register-core,
18
18
  * where the terminal reads them too. They are passed on from here so that
19
19
  * register-parts.js — which draws the lane table for both the page and the
20
20
  * status file — takes its numbers from the model, like everything else it draws.
21
21
  */
22
- export { activeOf, provenOf, settledOf };
22
+ export { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf };
23
23
  /** Worst first: the order cells are banded in a block and listed in a legend. */
24
24
  export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
25
25
  export const STATE_LABEL = {
@@ -79,6 +79,8 @@ const andList = (names) => (names.length < 3 ? names.join(' and ') : `${names.sl
79
79
  /** As many names as a person reads before they stop reading names. */
80
80
  const LIST_CAP = 12;
81
81
  const count = (value) => value.toLocaleString('en-US');
82
+ /** A clean (or verified) object nobody decided: counted on its own, never as undecided. */
83
+ export const NOTHING_TO_DECIDE = 'clean, nothing to decide';
82
84
  /**
83
85
  * How a cut list ends.
84
86
  *
@@ -145,11 +147,18 @@ export function summarize(register, { unreadable = 0 } = {}) {
145
147
  // A folder from an older build carries no `undecided`; work it out the same
146
148
  // way register-core does, so the line never prints a blank.
147
149
  const decided = totals.decided ?? 0;
148
- const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided);
150
+ // Recording fix 4 (2026-10-07): a clean object nobody decided has nothing
151
+ // to decide. register-core counts it on its own; it is never "undecided".
152
+ const nothingToDecide = Number.isFinite(totals.cleanNothingToDecide) ? totals.cleanNothingToDecide : 0;
153
+ const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided - nothingToDecide);
154
+ const decidedPhrase = nothingToDecide > 0
155
+ ? `${count(decided)} decided${undecided > 0 ? `, ${count(undecided)} undecided` : ''} · ${count(nothingToDecide)} ${NOTHING_TO_DECIDE}`
156
+ : `${count(decided)} decided, ${count(undecided)} undecided`;
149
157
  return {
150
158
  objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
151
159
  lastMeasured, unreadable, fromFile,
152
- line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${count(decided)} decided, ${count(undecided)} undecided · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
160
+ ...(nothingToDecide > 0 ? { nothingToDecide } : {}),
161
+ line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${decidedPhrase} · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
153
162
  };
154
163
  }
155
164
  const DAY_MS = 86_400_000;
@@ -199,6 +208,21 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
199
208
  items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
200
209
  detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
201
210
  }
211
+ // A developer's "retire" is a candidate until the lead confirms it with a
212
+ // reason (spec 2026-10-06 D2). Until then the object stays active, and the
213
+ // lead is the one person who can move it.
214
+ const candidates = register.objects.filter((o) => o.retireCandidate === true);
215
+ if (candidates.length) {
216
+ items.push({ kind: 'retire-candidate', title: `${plural(candidates.length, 'retire candidate waits', 'retire candidates wait')} for your confirmation`,
217
+ detail: upTo(candidates.map((o) => o.name), { tail: onMap }), keys: candidates.map((o) => o.key) });
218
+ }
219
+ // Out of scope is a decision about work nobody will do, so it needs a reason
220
+ // the lead can defend. A record from an older build may carry none.
221
+ const outNoReason = register.objects.filter((o) => o.inScope === false && !o.scopeReason);
222
+ if (outNoReason.length) {
223
+ items.push({ kind: 'out-of-scope-no-reason', title: `${plural(outNoReason.length, 'object was', 'objects were')} taken out of scope with no reason`,
224
+ detail: upTo(outNoReason.map((o) => o.name), { tail: onMap }), keys: outNoReason.map((o) => o.key) });
225
+ }
202
226
  const unowned = register.objects.filter((o) => o.lane === null);
203
227
  if (unowned.length) {
204
228
  const packages = [...new Set(unowned.map((o) => o.package))].sort();
@@ -213,6 +237,16 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
213
237
  items.push({ kind: 'overlap', title: `${plural(overlap.length, 'object is', 'objects are')} claimed by more than one lane`,
214
238
  detail: upTo([...new Set(overlap.map((o) => o.laneOverlap.join(' + ')))].sort(), { tail: justHowMany, sep: '; ' }), keys: overlap.map((o) => o.key) });
215
239
  }
240
+ // Rows of the last Excel import that clashed with a change recorded after the
241
+ // workbook was made were skipped, not applied. Only the newest import counts:
242
+ // an older one's skips were either entered again or are no longer wanted.
243
+ const lastImport = (register.plan?.history ?? []).find((c) => c.action === 'excel');
244
+ const clashes = lastImport?.clashesSkipped ?? 0;
245
+ if (Number.isInteger(clashes) && clashes > 0) {
246
+ items.push({ kind: 'excel-clashes', title: `${plural(clashes, 'Excel plan row was', 'Excel plan rows were')} skipped at the last import`,
247
+ detail: 'They clashed with changes recorded after the workbook was made. Make a fresh workbook with cspeach team report and enter them again.',
248
+ keys: [] });
249
+ }
216
250
  // A lane that has finished its work stops writing records, and that is the
217
251
  // point — it is not quiet, it is done. Only a lane with work still open can
218
252
  // be waiting for someone.
@@ -220,8 +254,8 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
220
254
  for (const lane of register.lanes) {
221
255
  if (!lane.objects || !lane.lastRecordAt || !Number.isFinite(nowMs))
222
256
  continue;
223
- if (lane.objects - settledOf(lane.byState) <= 0)
224
- continue;
257
+ if (laneActiveOf(lane) - laneProvenOf(lane) <= 0)
258
+ continue; // the lane's own Done % denominator
225
259
  const days = Math.floor((nowMs - Date.parse(lane.lastRecordAt)) / DAY_MS);
226
260
  if (days >= quietDays) {
227
261
  items.push({ kind: 'quiet-lane', title: `${lane.owner} (${lane.id}): no records for ${plural(days, 'day', 'days')}`,
@@ -21,7 +21,7 @@
21
21
  * style attribute.
22
22
  */
23
23
  import { esc, blockSvg, legendHtml, levelLegendHtml, stateWord, LEVEL_BUCKETS as LEVELS, LEVEL_WORD as LEVEL_NAME } from './register-map.js';
24
- import { activeOf, formatWhen, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, } from './register-model.js';
24
+ import { formatWhen, laneActiveOf, laneProvenOf, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, NOTHING_TO_DECIDE, } from './register-model.js';
25
25
  import { DISPOSITION_ORDER, DISPOSITION_WORD, developerTable, dispositionOf, overlapNote, } from '@cspeach/register-core';
26
26
  // The map owns it, because the map's cells need it too; it is offered here as
27
27
  // well, where every other shared mark lives.
@@ -119,6 +119,8 @@ export function lanesTableHtml(lanes, objects) {
119
119
  for (const o of objects ?? []) {
120
120
  if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
121
121
  continue;
122
+ if (o.inScope === false)
123
+ continue; // the same objects the lane's Proven counts
122
124
  // Every lane that claims the object, exactly as the lane's own counts are
123
125
  // taken: an object two lanes both claim is proven for both of them, so
124
126
  // "(n from a file)" can never be larger than the Proven it sits beside.
@@ -129,10 +131,10 @@ export function lanesTableHtml(lanes, objects) {
129
131
  .map((h) => `<th scope="col">${h}</th>`).join('');
130
132
  const provenCell = (l) => {
131
133
  const said = fromFile.get(l.id) ?? 0;
132
- return `${n(provenOf(l.byState))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
134
+ return `${n(laneProvenOf(l))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
133
135
  };
134
136
  const row = (l) => `<tr><td>${esc(ownerLabel(l))}</td><td>${n(l.objects)}</td>`
135
- + `<td>${n(activeOf(l.objects, l.byState))}</td><td>${provenCell(l)}</td>`
137
+ + `<td>${n(laneActiveOf(l))}</td><td>${provenCell(l)}</td>`
136
138
  + `<td>${n((l.byState?.regressed ?? 0) + (l.byState?.['did-not-hold'] ?? 0))}</td>`
137
139
  + `<td>${esc(formatWhen(l.lastRecordAt))}</td></tr>`;
138
140
  return `<div class="scroller"><table><thead><tr>${heads}</tr></thead>`
@@ -165,7 +167,7 @@ export function mapBodyHtml(register, blocks, { role, colourBy } = {}) {
165
167
  return '<p>No objects in scope yet.</p>';
166
168
  // The status file never passes `colourBy`: it prints the state map only.
167
169
  const legend = colourBy === 'level'
168
- ? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0 })
170
+ ? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0, nothingToDecide: register.totals.byLevel?.nothingToDecide ?? 0 })
169
171
  : legendHtml(register.totals.byState, { unknown: unknownStateCount(register) });
170
172
  return `${legend}
171
173
  <div class="blocks">${blocks.map((b) => blockFigureHtml(b, { role, colourBy })).join('')}</div>`;
@@ -401,9 +403,10 @@ const CLASSIFICATION_WORD = { keep: 'keep', fix: 'fix', redesign: 'redesign', re
401
403
  * it (the caller escapes it), never as "not decided": somebody DID decide, and
402
404
  * calling it undecided would put the object in a bucket nobody chose.
403
405
  */
404
- export function decisionWord(decision, projectType) {
406
+ export function decisionWord(decision, projectType, state) {
407
+ // Recording fix 4: a clean (or verified) object nobody decided has nothing to decide.
405
408
  if (!decision)
406
- return 'not decided';
409
+ return state === 'clean' || state === 'verified' ? 'clean — nothing to decide' : 'not decided';
407
410
  if (projectType === 'clean-core') {
408
411
  const d = dispositionOf(decision);
409
412
  if (d)
@@ -428,6 +431,9 @@ export function whereItStandsHtml(register) {
428
431
  + tile('Proven', provenOf(s), fromFile ? `${n(fromFile)} of them ${FROM_FILE}` : 'a check run says so')
429
432
  + tile('Did not hold', didNotHold, 'claimed, and the check still finds it', didNotHold ? 'bad' : '')
430
433
  + tile('Retired', s.retired ?? 0, 'nothing left to do')
434
+ // Only when there are some: a developer said retire and the lead has not
435
+ // confirmed it yet, so these objects still count as active work.
436
+ + ((t.retireCandidates ?? 0) > 0 ? tile('Retire candidates', t.retireCandidates, 'wait for the lead to confirm', 'retire-candidates') : '')
431
437
  + '</ul>'
432
438
  + (!(t.decided > 0) ? '<p class="empty-note" data-role="nothing-decided">Nothing decided yet. Developers record decisions with /abap-clean-core or cspeach team decide.</p>' : '')
433
439
  + levelBarsHtml(register);
@@ -437,7 +443,7 @@ export function whereItStandsHtml(register) {
437
443
  * never stands alone. Segments are paths, not rects: a `rect` on these surfaces
438
444
  * is a map cell, and the budget and the keyboard both count cells by that tag.
439
445
  */
440
- function levelBar(title, counts) {
446
+ function levelBar(title, counts, nothingToDecide = 0) {
441
447
  const total = LEVELS.reduce((sum, l) => sum + (counts?.[l] ?? 0), 0);
442
448
  let x = 0;
443
449
  const rects = total
@@ -451,7 +457,9 @@ function levelBar(title, counts) {
451
457
  return r;
452
458
  }).join('')
453
459
  : '<path class="lvl l-none" d="M0 0h100v10h-100z"></path>';
454
- const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ');
460
+ // A clean object nobody decided is not "not decided": it is said on its own, after the buckets.
461
+ const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ')
462
+ + (nothingToDecide > 0 ? ` · ${NOTHING_TO_DECIDE} ${n(nothingToDecide)}` : '');
455
463
  return `<figure class="levelbar"><figcaption>${esc(title)}</figcaption>`
456
464
  + `<svg viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${esc(`${title}: ${words}`)}">${rects}</svg>`
457
465
  + `<p class="levelwords">${esc(words)}</p></figure>`;
@@ -460,7 +468,7 @@ function levelBar(title, counts) {
460
468
  export function levelBarsHtml(register) {
461
469
  const b = register.totals?.byLevel;
462
470
  const none = !b || ['A', 'B', 'C', 'D'].every((l) => !(b.decided?.[l]) && !(b.now?.[l]));
463
- return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided)}${levelBar('Levels now', b?.now)}`
471
+ return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided, b?.nothingToDecide ?? 0)}${levelBar('Levels now', b?.now, b?.nothingToDecide ?? 0)}`
464
472
  + `<p class="legend-line">${none ? 'No levels recorded yet. ' : ''}A level moves only when a check proves the fix.</p></div>`;
465
473
  }
466
474
  /**
@@ -500,10 +508,48 @@ export function developersTableHtml(register) {
500
508
  const said = col === provenAt ? fromFile.get(lane.id) ?? 0 : 0;
501
509
  return `<td>${n(v)}${said ? ` (${n(said)} ${FROM_FILE})` : ''}</td>`;
502
510
  };
503
- const head = t.header.map((h) => `<th scope="col">${esc(h)}</th>`).join('');
511
+ // Spec 2026-10-06 §9 item 7: how far each lane is, what it does next (the
512
+ // first three by effective priority) and how much recorded work sits outside
513
+ // the focus — a plain count, recorded and never blocked.
514
+ const nameOf = new Map(register.objects.map((o) => [o.key, o.name]));
515
+ const plan = (lane) => {
516
+ // A key with no object behind it names nothing a reader can find, so it is
517
+ // left out rather than printed as the raw key.
518
+ const keys = (lane.next ?? []).slice(0, 3);
519
+ const next = keys.map((k) => nameOf.get(k)).filter((name) => name);
520
+ return `<td>${n(lane.donePct ?? 0)}%</td>`
521
+ + `<td class="next">${keys.length ? next.map((name) => `<span class="ident">${cell(name)}</span>`).join(', ') : '<span class="quiet-note">nothing open</span>'}</td>`
522
+ + `<td>${n(lane.outsideFocus ?? 0)}</td>`;
523
+ };
524
+ const head = [...t.header, 'Done %', 'Next', 'Outside the focus'].map((h) => `<th scope="col">${esc(h)}</th>`).join('');
504
525
  const body = t.rows.map((r, i) => `<tr><td>${cell(r[0])}</td>${r.slice(1, -1).map((v, j) => num(v, j + 1, register.lanes[i])).join('')}`
505
- + `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td></tr>`).join('');
526
+ + `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td>${plan(register.lanes[i])}</tr>`).join('');
506
527
  const note = overlapNote(register);
507
528
  return `<div class="scroller"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
508
529
  + (note ? `<p class="quiet-note">${esc(note)}</p>` : '');
509
530
  }
531
+ /* ── The focus, under the headline (spec 2026-10-06 §9 item 1) ──────────── */
532
+ const MONTH_WORDS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
533
+ /** A day as a person says it: 24 Sep 2026. UTC, like every time on this page. */
534
+ export const dayWords = (iso) => {
535
+ const t = iso ? new Date(iso) : null;
536
+ return !t || Number.isNaN(t.getTime()) ? '' : `${t.getUTCDate()} ${MONTH_WORDS[t.getUTCMonth()]} ${t.getUTCFullYear()}`;
537
+ };
538
+ export const NO_FOCUS = 'No focus set. Working lists follow the suggested order.';
539
+ /**
540
+ * The second line of the headline: the focus on top of the stack — what, who
541
+ * set it and when, why, and how much of it is done — or the sentence that says
542
+ * there is none. Escaped markup.
543
+ */
544
+ export function focusLineHtml(register) {
545
+ const f = register?.plan?.focuses?.[0];
546
+ if (!f)
547
+ return esc(NO_FOCUS);
548
+ const day = dayWords(f.at);
549
+ return [
550
+ `Now: ${cell(f.label)}`,
551
+ `set by ${cell(f.by)}${day ? ` ${esc(day)}` : ''}`,
552
+ ...(f.reason ? [`because ${cell(f.reason)}`] : []),
553
+ `${n(f.donePct ?? 0)}% done`,
554
+ ].join(' · ');
555
+ }