@cspeach/cli 1.1.29 → 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 (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -0,0 +1,555 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/register-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 marks the page and the status file both draw.
7
+ *
8
+ * Two surfaces show one folder: the page a manager opens, and the file a lead
9
+ * mails to people who will never open a folder in a browser. When the same row
10
+ * is written out twice it drifts, and these two had already drifted — one lane
11
+ * table carried a `scope` the other did not, and neither showed the number it
12
+ * was dividing by. So every mark that belongs to both lives here, once.
13
+ *
14
+ * Pure strings in, one string out. No DOM, no clock, no listeners: the status
15
+ * file is a document on somebody's desktop with no script in it, and it wears
16
+ * these same strings. That is also why this file knows the model and the map
17
+ * and nothing else — the page component belongs to the browser.
18
+ *
19
+ * Two rules hold everywhere below, as they do on the page: every value that
20
+ * came out of the folder goes through `esc()`, and colour is a class, never a
21
+ * style attribute.
22
+ */
23
+ import { esc, blockSvg, legendHtml, levelLegendHtml, stateWord, LEVEL_BUCKETS as LEVELS, LEVEL_WORD as LEVEL_NAME } from './register-map.js';
24
+ import { formatWhen, laneActiveOf, laneProvenOf, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, NOTHING_TO_DECIDE, } from './register-model.js';
25
+ import { DISPOSITION_ORDER, DISPOSITION_WORD, developerTable, dispositionOf, overlapNote, } from '@cspeach/register-core';
26
+ // The map owns it, because the map's cells need it too; it is offered here as
27
+ // well, where every other shared mark lives.
28
+ export { stateWord };
29
+ /** Digits grouped the way a reader expects: 1,500, not 1500. */
30
+ export const n = (value) => Number(value ?? 0).toLocaleString('en-US');
31
+ /**
32
+ * As much of a piece of folder text as a table cell can hold.
33
+ *
34
+ * Everything below prints text a whole team writes into a shared folder — a
35
+ * check variant, a scope, a person's name, the name of a file somebody
36
+ * imported. register-core already keeps 300 characters of a variant and a
37
+ * scope; 300 characters in a column would still push every other column off a
38
+ * phone. Cut the RAW text, then escape it: cutting after escaping can slice an
39
+ * entity in half and leave `&am` on the page.
40
+ */
41
+ const CELL_TEXT = 120;
42
+ /**
43
+ * Characters that are not text: zero-width joiners and spaces, the bidirectional
44
+ * embedding and OVERRIDE controls, the invisible-maths set, and a byte-order
45
+ * mark. `esc()` cannot help here — they are not markup, they are instructions to
46
+ * the text engine. One U+202E in a person's name reverses every character after
47
+ * it, so a line that reads "Clean — measured from a file" on one row can be made
48
+ * to read backwards on the next, and nothing on screen says why.
49
+ *
50
+ * Written as `\u` escapes, never as the characters themselves: a guard against
51
+ * invisible text cannot be made of invisible text. A reviewer must be able to
52
+ * see what this class covers, and a merge tool must not be able to lose one of
53
+ * them silently. Same rule as `cspeach-cli/src/register/atc-file.ts`.
54
+ */
55
+ const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g;
56
+ const short = (value) => {
57
+ const text = String(value ?? '').replace(INVISIBLE, '');
58
+ return text.length > CELL_TEXT ? `${text.slice(0, CELL_TEXT)}…` : text;
59
+ };
60
+ /** One value out of the folder, stripped of what is not text, cut to size, and escaped. */
61
+ export const cell = (value) => esc(short(value));
62
+ /** A count of objects a person can read: a whole number, none of it negative. */
63
+ const whole = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0;
64
+ /** The three types, in the words a person says them in. */
65
+ const TYPE_WORDS = { 'clean-core': 'clean core', upgrade: 'upgrade', build: 'build' };
66
+ /**
67
+ * Whose team project this is, in one line.
68
+ *
69
+ * No article. "A build team project" reads as a *build team*, and "An upgrade
70
+ * team project" is no better — so the type goes in brackets instead, in plain
71
+ * words. A type this build has never heard of is printed exactly as the folder
72
+ * wrote it, escaped, rather than bent into a phrase we would be guessing at.
73
+ *
74
+ * One sentence for both surfaces, so the page and the file cannot introduce
75
+ * the same folder two different ways.
76
+ *
77
+ * @param {{ type?: string, lead: { name: string } }} project
78
+ * @param {string} [folderName] omitted → the sentence stops after the lead.
79
+ * The status file omits it: its stamp names the folder one line below, and a
80
+ * document that says where it came from twice reads like two documents.
81
+ * @returns {string}
82
+ */
83
+ export function provenanceLine(project, folderName) {
84
+ const type = project.type ? (TYPE_WORDS[project.type] ?? esc(project.type)) : '';
85
+ return `Team project${type ? ` (${type})` : ''}, led by ${esc(project.lead.name)}`
86
+ + `${folderName ? `, read from the folder ${esc(folderName)}` : ''}.`;
87
+ }
88
+ /**
89
+ * Who owns something. Raw text — every caller puts it through `esc()`, because
90
+ * the owner and the lane id both came out of a shared folder.
91
+ *
92
+ * An absent owner says itself in words: an empty cell reads as a value somebody
93
+ * lost rather than as one nobody has set yet.
94
+ */
95
+ export const ownerLabel = (lane) => (lane ? `${lane.owner} (${lane.id})` : 'nobody yet');
96
+ /**
97
+ * Who owns what.
98
+ *
99
+ * Active sits beside Objects because Proven is counted against active objects,
100
+ * never against all of them: a retired object is not an unproven one. Without
101
+ * that column "Proven 2, Objects 4" reads as half done when it is two of three,
102
+ * and the lead and the manager argue about a number neither of them chose. The
103
+ * three counts come from register-core, which is also what the terminal uses.
104
+ *
105
+ * A lane summary counts states and nothing else, so how much of a lane's proven
106
+ * work came out of a file has to be counted here, off the objects. `objects` is
107
+ * optional: a caller that has none gets exactly the table it got before.
108
+ *
109
+ * @param {object[]} lanes `register.lanes`
110
+ * @param {object[]} [objects] `register.objects`
111
+ * @returns {string}
112
+ */
113
+ export function lanesTableHtml(lanes, objects) {
114
+ if (!lanes?.length)
115
+ return '<p>The lead has not set up lanes yet.</p>';
116
+ // One pass over the objects, not one per lane: a real estate has 20,000 of
117
+ // them and ten lanes, and this table is drawn on every render.
118
+ const fromFile = new Map();
119
+ for (const o of objects ?? []) {
120
+ if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
121
+ continue;
122
+ if (o.inScope === false)
123
+ continue; // the same objects the lane's Proven counts
124
+ // Every lane that claims the object, exactly as the lane's own counts are
125
+ // taken: an object two lanes both claim is proven for both of them, so
126
+ // "(n from a file)" can never be larger than the Proven it sits beside.
127
+ for (const id of o.laneOverlap?.length ? o.laneOverlap : [o.lane])
128
+ fromFile.set(id, (fromFile.get(id) ?? 0) + 1);
129
+ }
130
+ const heads = ['Owner', 'Objects', 'Active', 'Proven', 'Open problems', 'Last record']
131
+ .map((h) => `<th scope="col">${h}</th>`).join('');
132
+ const provenCell = (l) => {
133
+ const said = fromFile.get(l.id) ?? 0;
134
+ return `${n(laneProvenOf(l))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
135
+ };
136
+ const row = (l) => `<tr><td>${esc(ownerLabel(l))}</td><td>${n(l.objects)}</td>`
137
+ + `<td>${n(laneActiveOf(l))}</td><td>${provenCell(l)}</td>`
138
+ + `<td>${n((l.byState?.regressed ?? 0) + (l.byState?.['did-not-hold'] ?? 0))}</td>`
139
+ + `<td>${esc(formatWhen(l.lastRecordAt))}</td></tr>`;
140
+ return `<div class="scroller"><table><thead><tr>${heads}</tr></thead>`
141
+ + `<tbody>${lanes.map(row).join('')}</tbody></table></div>`;
142
+ }
143
+ /**
144
+ * One thing waiting for a person. The kind becomes a class so the bar beside it
145
+ * can carry that state's own colour, and nothing else about the kind is used.
146
+ */
147
+ export const needItemHtml = (item) => `<li class="need k-${esc(item.kind)}"><b>${esc(item.title)}</b><span>${esc(item.detail)}</span></li>`;
148
+ /**
149
+ * One package as a block of cells.
150
+ *
151
+ * `role: 'img'` is for the status file: it carries no script, so nothing there
152
+ * can open a cell, and a screen reader handed tens of thousands of shapes that
153
+ * do nothing is worse off than one handed a labelled picture.
154
+ */
155
+ export const blockFigureHtml = (block, { role, colourBy } = {}) => `<figure data-role="block"><h3>${esc(block.package)}</h3>`
156
+ + `${blockSvg(block, { ...(role ? { role } : {}), ...(colourBy ? { colourBy } : {}) })}`
157
+ + `<figcaption>${n(block.objects.length)} object${block.objects.length === 1 ? '' : 's'}</figcaption></figure>`;
158
+ /**
159
+ * The map itself: the key to the colours, then one block per package.
160
+ *
161
+ * A project with nothing in it yet gets a sentence instead. An empty legend
162
+ * above an empty row of blocks looks like a page that failed to load, and a
163
+ * lead who has just run `project init` would reasonably think it had.
164
+ */
165
+ export function mapBodyHtml(register, blocks, { role, colourBy } = {}) {
166
+ if (!register.totals.objects)
167
+ return '<p>No objects in scope yet.</p>';
168
+ // The status file never passes `colourBy`: it prints the state map only.
169
+ const legend = colourBy === 'level'
170
+ ? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0, nothingToDecide: register.totals.byLevel?.nothingToDecide ?? 0 })
171
+ : legendHtml(register.totals.byState, { unknown: unknownStateCount(register) });
172
+ return `${legend}
173
+ <div class="blocks">${blocks.map((b) => blockFigureHtml(b, { role, colourBy })).join('')}</div>`;
174
+ }
175
+ /**
176
+ * What was read and what was left out.
177
+ *
178
+ * A number a manager cannot see is a number they cannot trust, so nothing here
179
+ * is rounded away or hidden. The file says it too, and it matters most there:
180
+ * whoever it was mailed to cannot look in the folder, and an ignored ruling is
181
+ * a decision somebody made that this picture does not show.
182
+ *
183
+ * `tense: 'past'` is for that file. It states a moment that has gone, so it
184
+ * says what WAS read rather than what is.
185
+ *
186
+ * @param {{ files?: number, unreadable?: number, checksum?: string, register?: object }} data
187
+ * @param {{ tense?: 'present'|'past' }} [opts]
188
+ * @returns {string}
189
+ */
190
+ export function readNote({ files = 0, unreadable = 0, checksum, register }, { tense = 'present' } = {}) {
191
+ const past = tense === 'past';
192
+ const s = register?.skipped ?? {};
193
+ const parts = [`${n(files)} files ${past ? 'were read' : 'read'}`];
194
+ if (unreadable)
195
+ parts.push(`${n(unreadable)} could not be read${past ? '' : ' yet'}`);
196
+ if (s.duplicateRecords)
197
+ parts.push(`${n(s.duplicateRecords)} duplicate${s.duplicateRecords === 1 ? '' : 's'} ignored`);
198
+ if (s.otherProject)
199
+ parts.push(`${n(s.otherProject)} from another team project ignored`);
200
+ if (s.invalidRecords) {
201
+ parts.push(s.invalidRecords === 1
202
+ ? `1 file ${past ? 'was' : 'is'} not a status file`
203
+ : `${n(s.invalidRecords)} files ${past ? 'were' : 'are'} not status files`);
204
+ }
205
+ if (s.rulingsNotFromLead)
206
+ parts.push(`${n(s.rulingsNotFromLead)} ruling${s.rulingsNotFromLead === 1 ? '' : 's'} not from the lead ignored`);
207
+ if (s.measurementsOtherVariant) {
208
+ parts.push(`${n(s.measurementsOtherVariant)} measurement${s.measurementsOtherVariant === 1 ? '' : 's'} made with another check variant ignored`);
209
+ }
210
+ if (checksum === 'not-checked')
211
+ parts.push('object list checksum not checked');
212
+ return parts.join(' · ');
213
+ }
214
+ /* ── What was measured, and what was not ─────────────────────────────────── */
215
+ /**
216
+ * The one sentence that keeps a file export from reading as a live result.
217
+ *
218
+ * Live evidence outranks file evidence, per object: once the system has
219
+ * measured an object, that object's colour is read from system runs alone. The
220
+ * file run is still in its history — hiding it would leave a manager looking
221
+ * for a run they were told about — and this is what it says.
222
+ */
223
+ const NOT_USED = 'Measured from a file — not used for the state.';
224
+ /**
225
+ * Nothing looked at this object. The file stayed silent about it and the person
226
+ * who imported the file said to read silence as clean — so the row says clean
227
+ * on somebody's judgement, and the page says whose judgement it was.
228
+ */
229
+ const SILENCE = 'Counted as clean because the file did not mention it.';
230
+ /**
231
+ * The same silence from a live run: the check finished and never reported the
232
+ * object. There is no file to have mentioned anything, and saying there was
233
+ * would send a reader looking for an export nobody made.
234
+ */
235
+ const SILENCE_RUN = 'Counted as clean because the check run did not report it.';
236
+ const IMPORTER_SAID = 'The person who imported this file told CSPeach that objects the file does not mention are clean.';
237
+ /**
238
+ * How a run was made, in words. A file is never called plainly "measured".
239
+ *
240
+ * A file import carries a check variant only when the person who imported it
241
+ * vouched for one, and an empty half of this phrase reads as a value somebody
242
+ * lost rather than as one the file never had. A live run always has a variant.
243
+ */
244
+ const howMade = (fromFile, variant) => `${fromFile ? FROM_FILE : 'system check'}`
245
+ + `${variant ? `, ${variant}` : (fromFile ? ', check variant not stated' : '')}`;
246
+ /**
247
+ * The same, as markup: a file wears a tag, so the eye finds it down a column.
248
+ * The whole phrase goes inside the tag, variant and all — a pill followed by a
249
+ * loose comma reads as a comma nobody meant to type.
250
+ */
251
+ const howMadeHtml = (fromFile, variant) => (fromFile
252
+ ? `<span class="tag from-file">${esc(howMade(true, short(variant)))}</span>`
253
+ : esc(howMade(false, short(variant))));
254
+ /**
255
+ * The id register-core invents when a finding arrives with no readable name of
256
+ * its own. It is a placeholder, and on screen a placeholder in a Rule column
257
+ * reads as a rule somebody could go and look up. Say what it is instead.
258
+ *
259
+ * The id is written out rather than imported: this module draws marks and knows
260
+ * the model, and one string is a smaller dependency than a package.
261
+ */
262
+ const UNNAMED = 'UNNAMED_FINDING';
263
+ export const ruleWord = (rule) => (rule === UNNAMED ? 'a finding with no readable name' : short(rule));
264
+ /**
265
+ * One cell of the status file's object list.
266
+ *
267
+ * The label follows `measuredBasis` — the basis the FIGURE came from — and
268
+ * never the newest run of any basis. A system run followed by a file export
269
+ * leaves `openFindings` a live number, and "1 open, from a file" under it would
270
+ * be the page telling a manager to trust the system less than it should.
271
+ * `runs` is kept in the signature for callers; nothing here needs it.
272
+ */
273
+ export function measuredWord(o, runs) {
274
+ if ((o.lastMeasuredAt ?? null) === null)
275
+ return 'not measured';
276
+ return `${n(o.openFindings)} open${o.measuredBasis === 'file' ? ', from a file' : ''}`;
277
+ }
278
+ /** The line under the summary. Empty when every active object has been measured. */
279
+ export function notMeasuredLine(register) {
280
+ const count = notMeasuredCount(register);
281
+ if (!count)
282
+ return '';
283
+ return `${n(count)} of ${n(register.totals.active)} active object${register.totals.active === 1 ? '' : 's'} `
284
+ + `${count === 1 ? 'has' : 'have'} never been measured. ${count === 1 ? 'Its' : 'Their'} colour rests on what people said.`;
285
+ }
286
+ /** The evidence panel's measurement block: the two numbers, then every run that covered this object. */
287
+ export function measurementHistoryHtml(o, runs) {
288
+ const { rows, more } = measurementHistory(o, runs);
289
+ if (!rows.length) {
290
+ return '<p class="unmeasured" data-role="not-measured"><b>Not measured.</b> Nobody has run a check on this object yet, so its colour rests on what people said.</p>';
291
+ }
292
+ // `counted` comes from the register, which is where that rule lives. Working
293
+ // it out again here would be a second copy of it, free to drift.
294
+ const note = (r) => [
295
+ // Whose silence it was follows the run this row came from, never the shape
296
+ // of the row: a live run that reported nothing is not a file that said
297
+ // nothing, and only one of the two has an export to go and look at.
298
+ r.evidence === 'absent' ? (r.fromFile ? SILENCE : SILENCE_RUN) : '',
299
+ r.counted ? '' : NOT_USED,
300
+ ].filter(Boolean).map((text) => ` <span class="quiet-note">${text}</span>`).join('');
301
+ const row = (r) => `<tr><td>${esc(formatWhen(r.at))}</td><td>${cell(r.by)}</td><td>${n(r.open)}</td>`
302
+ + `<td>${howMadeHtml(r.fromFile, r.variant)}${note(r)}</td></tr>`;
303
+ return `<p><b>Measured:</b> ${n(o.openFindings)} open now, ${n(o.baselineFindings)} at the start. Last measured ${esc(formatWhen(o.lastMeasuredAt))}.</p>`
304
+ + `<div class="scroller" data-role="history"><table aria-label="Every measurement of ${cell(o.name)}">`
305
+ + '<thead><tr><th scope="col">When</th><th scope="col">By</th><th scope="col">Open</th><th scope="col">How</th></tr></thead>'
306
+ + `<tbody>${rows.map(row).join('')}</tbody></table></div>`
307
+ + (more ? `<p class="quiet-note">and ${n(more)} earlier ${more === 1 ? 'measurement' : 'measurements'}</p>` : '');
308
+ }
309
+ /**
310
+ * What a run was asked for and did not measure, and — only when it adds up —
311
+ * what became of those objects.
312
+ *
313
+ * "Not measured" is one number with four meanings, and a lead chasing the gap
314
+ * needs to know whether to look at the export, at the run, at an error or at
315
+ * the object's own type. But a breakdown that does not account for the whole
316
+ * gap is worse than none: a reader adds the parts, gets a different number, and
317
+ * stops believing the table. So the parts are printed only when they sum to
318
+ * exactly `asked − measured` and every one of them is a whole number — a folder
319
+ * can say `absent: 999`, and then the gap stands on its own.
320
+ *
321
+ * The gap itself is printed only when both numbers are whole: "3.5 not
322
+ * measured" is not a count of objects.
323
+ */
324
+ function gapOf(c) {
325
+ if (!c || !whole(c.asked) || !whole(c.measured) || c.asked <= c.measured)
326
+ return { missed: '', why: '' };
327
+ const gap = c.asked - c.measured;
328
+ const named = [
329
+ ['absent', 'the run never mentioned'],
330
+ ['notRun', 'the run never reached'],
331
+ ['failed', 'failed'],
332
+ ['unsupported', 'of a type this check cannot reach'],
333
+ ].filter(([key]) => c[key] !== undefined && c[key] !== null);
334
+ const adds = named.every(([key]) => whole(c[key])) && named.reduce((t, [key]) => t + c[key], 0) === gap;
335
+ return {
336
+ missed: `, ${n(gap)} not measured`,
337
+ why: adds ? named.filter(([key]) => c[key] > 0).map(([key, word]) => `${n(c[key])} ${word}`).join(' · ') : '',
338
+ };
339
+ }
340
+ /**
341
+ * Every measurement that counted, newest first. The page and the status file
342
+ * both print it.
343
+ *
344
+ * The findings column counts OBJECTS WITH FINDINGS, never findings added up
345
+ * across objects. A run that checks one object at a time gives every finding it
346
+ * produced to that object, so a finding in a shared include is counted once
347
+ * under each program that uses it — per object the numbers are right, added up
348
+ * they are a total that grows and shrinks with the chunking and that nobody on
349
+ * the team could defend in a meeting.
350
+ *
351
+ * An estate with nothing recorded yet is a normal first day, not a team that has
352
+ * done nothing: the sentence says what to run and what an object that no check
353
+ * run reports will look like.
354
+ */
355
+ export function runsTableHtml(runs) {
356
+ if (!runs?.length) {
357
+ return '<p>No measurement is recorded yet. A lead or developer connected to SAP can run cspeach team refresh. '
358
+ + 'Objects stay "not measured" until a check run reports them.</p>';
359
+ }
360
+ const row = (r) => {
361
+ const objects = `${n(r.objects)} object${r.objects === 1 ? '' : 's'}`;
362
+ const { missed, why } = gapOf(r.coverage);
363
+ // The NAME of the file, which register-core keeps; never a path, which it
364
+ // does not. One writer's Downloads folder is not the team's business.
365
+ const from = r.sourceFile ? ` — ${cell(r.sourceFile)}` : '';
366
+ // The sentence belongs to the RUN that carries the choice, not to the
367
+ // table: "this file", printed once under a list of three runs, names none
368
+ // of them. A run with a note keeps no rule under it, so the note and the
369
+ // row it belongs to read as one thing.
370
+ const said = r.absentMeansClean ? `<tr><td colspan="6" class="run-note"><span class="quiet-note">${IMPORTER_SAID}</span></td></tr>` : '';
371
+ return `<tr${said ? ' class="has-note"' : ''}><td>${esc(formatWhen(r.at))}</td><td>${cell(r.by)}</td><td>${cell(r.scope ?? '')}</td>`
372
+ + `<td>${objects}${missed}${why ? `<br><span class="quiet-note">${why}</span>` : ''}</td><td>${n(r.withFindings)}</td>`
373
+ + `<td>${howMadeHtml(r.basis === 'file', r.atcVariant)}${from}</td></tr>${said}`;
374
+ };
375
+ return '<div class="scroller"><table aria-label="Measurements"><thead><tr><th scope="col">When</th><th scope="col">By</th><th scope="col">Scope</th>'
376
+ + '<th scope="col">Covered</th><th scope="col">Objects with findings</th><th scope="col">How</th></tr></thead>'
377
+ + `<tbody>${[...runs].reverse().map(row).join('')}</tbody></table></div>`;
378
+ }
379
+ /**
380
+ * The runs nobody folded in, because they were taken with another check
381
+ * variant. The footer already counts them; a count alone cannot be looked into,
382
+ * so this says which run, when and by whom — as a list, not a table, because it
383
+ * is an aside beside the table above and should not read as more measurement.
384
+ */
385
+ export function ignoredRunsHtml(ignoredRuns) {
386
+ if (!ignoredRuns?.length)
387
+ return '';
388
+ const many = ignoredRuns.length !== 1;
389
+ const item = (r) => `<li>${esc(formatWhen(r.at))} — ${cell(r.by)}, ${cell(r.atcVariant)}`
390
+ + `${r.basis === 'file' ? ` <span class="tag from-file">${FROM_FILE}</span>` : ''}`
391
+ + `${r.sourceFile ? ` — ${cell(r.sourceFile)}` : ''}</li>`;
392
+ return `<div class="aside" data-role="ignored-runs"><p>Set aside: ${n(ignoredRuns.length)} run${many ? 's were' : ' was'} `
393
+ + `taken with another check variant, so ${many ? 'they cannot' : 'it cannot'} be compared with this team project.</p>`
394
+ + `<ul>${ignoredRuns.map(item).join('')}</ul></div>`;
395
+ }
396
+ /* ── Clean core: where it stands, levels, dispositions, per developer ───── */
397
+ const CLASSIFICATION_WORD = { keep: 'keep', fix: 'fix', redesign: 'redesign', retire: 'retire' };
398
+ /**
399
+ * The word for a decision. A clean-core team project speaks in the five
400
+ * dispositions (spec §4.2); any other type keeps the classification.
401
+ *
402
+ * A classification this build has no word for is printed as the folder wrote
403
+ * it (the caller escapes it), never as "not decided": somebody DID decide, and
404
+ * calling it undecided would put the object in a bucket nobody chose.
405
+ */
406
+ export function decisionWord(decision, projectType, state) {
407
+ // Recording fix 4: a clean (or verified) object nobody decided has nothing to decide.
408
+ if (!decision)
409
+ return state === 'clean' || state === 'verified' ? 'clean — nothing to decide' : 'not decided';
410
+ if (projectType === 'clean-core') {
411
+ const d = dispositionOf(decision);
412
+ if (d)
413
+ return DISPOSITION_WORD[d];
414
+ }
415
+ return CLASSIFICATION_WORD[decision.classification] ?? short(decision.classification);
416
+ }
417
+ const tile = (label, value, note, cls = '') => `<li class="tile${cls ? ` ${cls}` : ''}"><span class="tile-n">${n(value)}</span> <span class="tile-label">${esc(label)}</span> <span class="tile-note">${esc(note)}</span></li>`;
418
+ /**
419
+ * Five tiles, a number and two lines each, then the two level bars. Every
420
+ * number comes from register-core's totals — the same ones the terminal and
421
+ * the workbook print — so nothing here is counted twice.
422
+ */
423
+ export function whereItStandsHtml(register) {
424
+ const t = register.totals ?? {};
425
+ const s = t.byState ?? {};
426
+ const fromFile = (t.fromFile?.verified ?? 0) + (t.fromFile?.clean ?? 0);
427
+ const didNotHold = s['did-not-hold'] ?? 0;
428
+ return '<ul class="tiles" data-role="tiles">'
429
+ + tile('Decided', t.decided ?? 0, `of ${n(t.objects)} objects`)
430
+ + tile('Claimed', s.claimed ?? 0, 'a developer says fixed')
431
+ + tile('Proven', provenOf(s), fromFile ? `${n(fromFile)} of them ${FROM_FILE}` : 'a check run says so')
432
+ + tile('Did not hold', didNotHold, 'claimed, and the check still finds it', didNotHold ? 'bad' : '')
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') : '')
437
+ + '</ul>'
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>' : '')
439
+ + levelBarsHtml(register);
440
+ }
441
+ /**
442
+ * One bar: the five buckets side by side, then the same thing in words — colour
443
+ * never stands alone. Segments are paths, not rects: a `rect` on these surfaces
444
+ * is a map cell, and the budget and the keyboard both count cells by that tag.
445
+ */
446
+ function levelBar(title, counts, nothingToDecide = 0) {
447
+ const total = LEVELS.reduce((sum, l) => sum + (counts?.[l] ?? 0), 0);
448
+ let x = 0;
449
+ const rects = total
450
+ ? LEVELS.map((l) => {
451
+ const c = counts?.[l] ?? 0;
452
+ if (!c)
453
+ return '';
454
+ const w = (c / total) * 100;
455
+ const r = `<path class="lvl l-${l}" d="M${x.toFixed(3)} 0h${w.toFixed(3)}v10h-${w.toFixed(3)}z"><title>${esc(`${LEVEL_NAME[l]}: ${c}`)}</title></path>`;
456
+ x += w;
457
+ return r;
458
+ }).join('')
459
+ : '<path class="lvl l-none" d="M0 0h100v10h-100z"></path>';
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)}` : '');
463
+ return `<figure class="levelbar"><figcaption>${esc(title)}</figcaption>`
464
+ + `<svg viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${esc(`${title}: ${words}`)}">${rects}</svg>`
465
+ + `<p class="levelwords">${esc(words)}</p></figure>`;
466
+ }
467
+ /** Levels at decision and now, in words as well as colour. A folder with no levels says so. */
468
+ export function levelBarsHtml(register) {
469
+ const b = register.totals?.byLevel;
470
+ const none = !b || ['A', 'B', 'C', 'D'].every((l) => !(b.decided?.[l]) && !(b.now?.[l]));
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)}`
472
+ + `<p class="legend-line">${none ? 'No levels recorded yet. ' : ''}A level moves only when a check proves the fix.</p></div>`;
473
+ }
474
+ /**
475
+ * One line of chips; the accepted exceptions open a list of object and reason.
476
+ * An exception is work nobody has to do, so the reason is the whole of it — and
477
+ * where there is none, the list says so rather than leaving a blank.
478
+ */
479
+ export function dispositionsHtml(register) {
480
+ const d = register.totals?.byDisposition ?? {};
481
+ const chips = DISPOSITION_ORDER.map((k) => `<li class="chip d-${k}">${esc(DISPOSITION_WORD[k])} <b>${n(d[k] ?? 0)}</b></li>`).join('');
482
+ const ex = register.objects.filter((o) => dispositionOf(o.decision) === 'exception');
483
+ const list = !ex.length ? '' : `<details class="exceptions" data-role="exceptions"><summary>${ex.length === 1 ? 'The accepted exception and why' : `The ${n(ex.length)} accepted exceptions and why`}</summary><ul>`
484
+ + ex.map((o) => `<li><span class="ident">${cell(o.name)}</span> — ${o.decision.reason ? cell(o.decision.reason) : '<span class="quiet-note">no reason recorded</span>'}</li>`).join('')
485
+ + '</ul></details>';
486
+ return `<ul class="chips" data-role="dispositions">${chips}</ul>${list}`;
487
+ }
488
+ /**
489
+ * Who owns what, per developer — register-core's sheet model for the numbers
490
+ * (the workbook prints the same row), the page's own words for the date.
491
+ */
492
+ export function developersTableHtml(register) {
493
+ if (!register.lanes?.length)
494
+ return '<p>The lead has not set up lanes yet.</p>';
495
+ const t = developerTable(register);
496
+ // How much of a lane's Proven came out of a file, said beside the number —
497
+ // as the lane table always has. One pass over the objects, counted for every
498
+ // lane that claims one, exactly as register-core counts the lane's Proven.
499
+ const fromFile = new Map();
500
+ for (const o of register.objects) {
501
+ if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
502
+ continue;
503
+ for (const id of o.laneOverlap?.length ? o.laneOverlap : [o.lane])
504
+ fromFile.set(id, (fromFile.get(id) ?? 0) + 1);
505
+ }
506
+ const provenAt = t.header.indexOf('Proven');
507
+ const num = (v, col, lane) => {
508
+ const said = col === provenAt ? fromFile.get(lane.id) ?? 0 : 0;
509
+ return `<td>${n(v)}${said ? ` (${n(said)} ${FROM_FILE})` : ''}</td>`;
510
+ };
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('');
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('')}`
526
+ + `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td>${plan(register.lanes[i])}</tr>`).join('');
527
+ const note = overlapNote(register);
528
+ return `<div class="scroller"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
529
+ + (note ? `<p class="quiet-note">${esc(note)}</p>` : '');
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
+ }