@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
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import { classificationFromCcaDetail, fromCcaDetailKey, normalizeKey, normalizeRuleId, objectKey, } from '@cspeach/register-core';
14
14
  import { defaultRand, readBaseline } from './store.js';
15
15
  import { safeDetailRel, resolveDetailFileSync } from '../projects/detail-path.js';
16
+ import { writeWhole } from './write-whole.js';
16
17
  const FIX_STATUSES = new Set(['fixed', 'skipped', 'failed', 'pending']);
17
18
  export function writeRecord(joined, recordType, rows, by, opts = {}) {
18
19
  const known = new Set(readBaseline(joined.projectDir).map((o) => objectKey(o.type, o.name)));
@@ -63,6 +64,30 @@ export function writeRecord(joined, recordType, rows, by, opts = {}) {
63
64
  }
64
65
  return { file, written: kept.length, refused };
65
66
  }
67
+ /** `20261007T080000Z`: the ISO time without dashes, colons or milliseconds, as every record file name carries it. */
68
+ export const fileStamp = (now) => now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
69
+ /**
70
+ * A record the LEAD writes into one of the lead's own folders (`plan/`,
71
+ * `baseline/`), whose rows may be empty — a plan header says everything in
72
+ * its header fields. The caller has already decided which rows belong; this
73
+ * only writes, whole then renamed. Never under `writers/`.
74
+ */
75
+ export function writeLeadRecord(dir, projectId, recordType, header, rows, by, opts = {}) {
76
+ const now = opts.now ?? new Date();
77
+ const recordId = (opts.rand ?? defaultRand)(6);
78
+ const name = opts.fileName ? path.basename(opts.fileName) : `${fileStamp(now)}-${recordType}-${recordId.slice(0, 4)}.json`;
79
+ const file = path.join(dir, name);
80
+ const own = {
81
+ kind: 'record', formatVersion: 1, recordId, projectId, recordType,
82
+ at: now.toISOString(), by: { name: by.name, writer: by.writer },
83
+ };
84
+ // A header field never stands in for one of the record's own fields.
85
+ const extra = Object.fromEntries(Object.entries(header).filter(([k]) => !(k in own) && k !== 'rows'));
86
+ const record = { ...own, ...extra, rows: [...rows] };
87
+ writeWhole(file, `${JSON.stringify(record, null, 2)}
88
+ `);
89
+ return { file, recordId };
90
+ }
66
91
  /**
67
92
  * Decisions from a saved CCA assessment. The full list lives in the detail
68
93
  * file's `objects.json` (the envelope table stops at 50 rows), so that is read
@@ -137,6 +162,34 @@ export function fixRowsFromUpgradeProgress(content, baseline) {
137
162
  }
138
163
  return { rows, ambiguous };
139
164
  }
165
+ /**
166
+ * Decision rows from a saved clean-core triage — CONFIRMED rows only (one Yes
167
+ * per row, clean-core-confirm.ts). The five words map onto the four
168
+ * classifications (spec §4.2); a fix or a rebuild with no target reaches A.
169
+ */
170
+ export function decisionRowsFromCleanCore(content) {
171
+ const rows = [];
172
+ for (const r of content.adjudications ?? []) {
173
+ if (r?.confirmed !== true)
174
+ continue;
175
+ const moves = r.disposition === 'fix' || r.disposition === 'rebuild';
176
+ const base = r.disposition === 'keep' ? { classification: 'keep' }
177
+ : r.disposition === 'exception' ? { classification: 'keep', exception: true }
178
+ : r.disposition === 'fix' ? { classification: 'fix' }
179
+ : r.disposition === 'rebuild' ? { classification: 'redesign' }
180
+ : r.disposition === 'retire' ? { classification: 'retire' } : null;
181
+ if (!base)
182
+ continue;
183
+ const reason = typeof r.reason === 'string' ? r.reason.trim().slice(0, 300) : '';
184
+ rows.push({
185
+ key: objectKey(r.objectType, r.objectName), ...base,
186
+ ...(r.level ? { level: r.level } : {}),
187
+ ...(moves ? { targetLevel: r.targetLevel ?? 'A' } : {}),
188
+ ...(reason ? { reason } : {}),
189
+ });
190
+ }
191
+ return rows;
192
+ }
140
193
  /**
141
194
  * Called after every successful envelope save. Does nothing unless this laptop
142
195
  * has joined a project; never throws (a record is a by-product, a save is not).
@@ -160,5 +213,9 @@ opts = {}) {
160
213
  const res = writeRecord(joined, 'fix', rows, by, { source, ...opts });
161
214
  return { ...res, refused: [...res.refused, ...ambiguous.map((n) => `${n} (name matches more than one object type)`)] };
162
215
  }
216
+ if (env.artefactType === 'clean-core-decision') {
217
+ const rows = decisionRowsFromCleanCore(env.content);
218
+ return writeRecord(joined, 'decision', rows, by, { source, ...opts });
219
+ }
163
220
  return null;
164
221
  }
@@ -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
+ }
@@ -0,0 +1,221 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/register-map.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 map: one block per package, one cell per object, colour = state.
7
+ *
8
+ * Strings, not DOM, for two reasons: the status file is a document with no
9
+ * script in it and needs the same picture as text; and 20,000 cells arrive in
10
+ * one innerHTML assignment rather than 20,000 createElement calls.
11
+ *
12
+ * Colour is a CSS class (`s-verified`), never a style attribute: the page's
13
+ * Content-Security-Policy forbids inline styles, and the status file and the
14
+ * page then share one stylesheet.
15
+ */
16
+ import { STATE_ORDER, STATE_LABEL, FROM_FILE, NOT_MENTIONED, NOT_REPORTED } from './register-model.js';
17
+ const ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
18
+ /**
19
+ * The one escaping function for the whole page. Object, package, lane and
20
+ * person names all come from a shared folder and are untrusted text.
21
+ * The two quote characters are written as `\x22` and `\x27`. They are the same
22
+ * characters, and a quote inside a regex literal blinds the word scanner that
23
+ * reads these files (`team-words.test.js`): it knows strings and comments but
24
+ * not regexes, so one `"` here flips text and code around for the rest of the
25
+ * file and the guard goes quiet. Leave them escaped.
26
+ *
27
+ * @param {unknown} value
28
+ * @returns {string}
29
+ */
30
+ export function esc(value) {
31
+ return value == null ? '' : String(value).replace(/[&<>\x22\x27]/g, (c) => ENTITIES[c]);
32
+ }
33
+ /**
34
+ * The class for a state. The states are a closed set: an unknown or hostile
35
+ * state becomes `s-unknown`, never a raw value, because `class` is
36
+ * space-separated and escaping does not neutralise a space — a state of
37
+ * "clean hidden" would otherwise add the class `hidden` and hide the cell.
38
+ * @param {unknown} state
39
+ * @returns {string}
40
+ */
41
+ export function stateClass(state) {
42
+ return `s-${Object.hasOwn(STATE_LABEL, state) ? state : 'unknown'}`;
43
+ }
44
+ /**
45
+ * The words for a state, from the same closed set: an unknown state — or an
46
+ * inherited property name like "constructor" — reads "Unknown".
47
+ * @param {unknown} state
48
+ * @returns {string}
49
+ */
50
+ export function stateLabel(state) {
51
+ return Object.hasOwn(STATE_LABEL, state) ? STATE_LABEL[state] : 'Unknown';
52
+ }
53
+ /**
54
+ * The newest measurement this object's colour was actually read from.
55
+ *
56
+ * `counted` is register-core's own answer to "did this run move the state", so
57
+ * the view never works that rule out a second time. Walking backwards stops at
58
+ * the first counted one, which on a real object is the last entry.
59
+ */
60
+ function drivingNewest(o) {
61
+ const all = o?.measurements;
62
+ if (!Array.isArray(all))
63
+ return null;
64
+ for (let i = all.length - 1; i >= 0; i -= 1)
65
+ if (all[i]?.counted)
66
+ return all[i];
67
+ return null;
68
+ }
69
+ /**
70
+ * The words for an object's colour, with what that colour rests on.
71
+ *
72
+ * Three strengths, and only the two PROVEN states can carry the weaker two —
73
+ * "Did not hold, measured from a file" would be labelling a problem with how it
74
+ * was found. A colour read from a system run says nothing extra; one read from
75
+ * a file says so; and one that rests on SILENCE — nothing mentioned the object
76
+ * and it was counted clean anyway — says that instead, because nothing measured
77
+ * it at all.
78
+ *
79
+ * Whose silence it was follows the BASIS the state was read from: a file that
80
+ * did not mention it, or a check run that did not report it. Saying "in a file"
81
+ * of a live run would send a manager looking for an export that does not exist.
82
+ *
83
+ * It lives here, beside `stateLabel`, because the map's cells need it too: the
84
+ * `<title>` is the only thing a hover, a screen reader on the roving cell, and
85
+ * the status file's picture ever say about an object, and a file-backed "Clean"
86
+ * there would be the one place the label went missing.
87
+ *
88
+ * @param {{ state: string, measuredBasis?: string|null, measurements?: object[] }} o
89
+ * @returns {string}
90
+ */
91
+ export function stateWord(o) {
92
+ const label = stateLabel(o?.state);
93
+ if (o?.state !== 'verified' && o?.state !== 'clean')
94
+ return label;
95
+ const fromFile = o.measuredBasis === 'file';
96
+ if (drivingNewest(o)?.evidence === 'absent')
97
+ return `${label} — ${fromFile ? NOT_MENTIONED : NOT_REPORTED}`;
98
+ return fromFile ? `${label} — ${FROM_FILE}` : label;
99
+ }
100
+ /**
101
+ * Wider than tall (reads like a block of text), and bounded so one huge
102
+ * package cannot run off the page. Nonsense in (NaN, negative) gives the
103
+ * smallest map rather than a NaN-sized picture.
104
+ * @param {number} count
105
+ * @returns {number}
106
+ */
107
+ export function mapColumns(count) {
108
+ return Math.max(8, Math.min(60, Math.ceil(Math.sqrt(Math.max(0, count || 0)) * 1.6)));
109
+ }
110
+ /** The four clean-core levels. Anything else (absent, or a word this build does not know) is "not decided". */
111
+ const LEVEL_SET = new Set(['A', 'B', 'C', 'D']);
112
+ /** The five level buckets, in order, and their words: one table for the map, the legend and the bars. */
113
+ export const LEVEL_BUCKETS = ['A', 'B', 'C', 'D', 'none'];
114
+ export const LEVEL_WORD = { A: 'A', B: 'B', C: 'C', D: 'D', none: 'not decided' };
115
+ /**
116
+ * The class for a level, from a closed set like the states: a hostile
117
+ * `levelNow` can never write a second class into the cell.
118
+ * @param {unknown} l
119
+ * @returns {string}
120
+ */
121
+ export const levelClass = (l) => `l-${LEVEL_SET.has(l) ? l : 'none'}`;
122
+ /** The only two roles a block may carry, so `role` can never be written into. */
123
+ const BLOCK_ROLES = new Set(['group', 'img']);
124
+ /**
125
+ * One package block as a single `<svg>` string.
126
+ * @param {{ package: string, objects: Array<{ key: string, type: string, name: string, state: string }> }} block
127
+ * `colourBy: 'level'` colours each cell by the level it stands at now
128
+ * (`levelNow`) instead of its state, and the title names that level. The state
129
+ * stays in the title either way.
130
+ * @param {{ cell?: number, gap?: number, role?: 'group'|'img', colourBy?: 'state'|'level' }} [opts]
131
+ * @returns {string}
132
+ */
133
+ export function blockSvg(block, { cell = 10, gap = 2, role = 'group', colourBy = 'state' } = {}) {
134
+ // `group` by default: an image's children are presentational, and the page
135
+ // makes each cell something a reader can land on and open. `img` would hide
136
+ // every one of them from a screen reader.
137
+ //
138
+ // The status file asks for `img`, and is right to: it carries no script, so
139
+ // nothing there can open a cell, and a screen reader would otherwise be
140
+ // handed tens of thousands of shapes that do nothing. The set is closed, like
141
+ // the states — `role` is an attribute, and a caller must not be able to write
142
+ // a second one after it.
143
+ const safeRole = BLOCK_ROLES.has(role) ? role : 'group';
144
+ const n = block.objects.length;
145
+ const cols = mapColumns(n);
146
+ const rows = Math.max(1, Math.ceil(n / cols));
147
+ const step = cell + gap;
148
+ const width = cols * step - gap;
149
+ const height = rows * step - gap;
150
+ // `data-key` is how the page finds the object behind a cell that was clicked.
151
+ // A picture has no clicks, so in `img` mode it is 30 dead bytes per cell — on
152
+ // a 20,000-object project, most of a megabyte in a file somebody has to mail.
153
+ // The per-cell `<title>` stays either way: hovering a cell to read the name is
154
+ // worth its bytes in a document nobody can search any other way.
155
+ const keyed = safeRole !== 'img';
156
+ const byLevel = colourBy === 'level';
157
+ const rects = block.objects.map((o, i) => {
158
+ const x = (i % cols) * step;
159
+ const y = Math.floor(i / cols) * step;
160
+ // A retired object keeps its state colour in level mode: the level counts
161
+ // leave retired objects out, and a cell painted C that no count includes
162
+ // would show a manager two numbers for one folder.
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;
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">`
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>`;
171
+ }).join('');
172
+ const label = `${block.package}: ${n.toLocaleString('en-US')} ${n === 1 ? 'object' : 'objects'}`;
173
+ return `<svg class="block-map" role="${safeRole}" aria-label="${esc(label)}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects}</svg>`;
174
+ }
175
+ /**
176
+ * The key to the colours: only the states that occur, worst first, with counts.
177
+ *
178
+ * `unknown` is the tenth colour, and it is not a state. A cell drawn in it
179
+ * holds something this build has no word for, and it is meant to look wrong —
180
+ * so the legend names it and counts it rather than leaving a mark on the map
181
+ * that nothing in the key accounts for.
182
+ *
183
+ * @param {Record<string, number>} byState
184
+ * @param {{ unknown?: number }} [opts]
185
+ * @returns {string}
186
+ */
187
+ export function legendHtml(byState, { unknown = 0 } = {}) {
188
+ const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc(count.toLocaleString('en-US'))}</b></li>`;
189
+ const items = STATE_ORDER.filter((s) => (byState[s] ?? 0) > 0)
190
+ .map((s) => entry(stateClass(s), stateLabel(s), byState[s]));
191
+ if (unknown > 0)
192
+ items.push(entry(stateClass('unknown'), stateLabel('unknown'), unknown));
193
+ return `<ul class="legend">${items.join('')}</ul>`;
194
+ }
195
+ /**
196
+ * The key to the level colours: always all five, D included, with counts —
197
+ * or, for a folder from an older build with no level counts, one line saying so.
198
+ *
199
+ * The counts are register-core's own (`totals.byLevel.now`), never recounted
200
+ * here, so the legend and the "Levels now" bar are one number. Retired objects
201
+ * are outside those counts and drawn in their state colour, so they get their
202
+ * own entry when there are any.
203
+ *
204
+ * @param {Record<string, number>} counts `totals.byLevel.now`
205
+ * @param {{ retired?: number }} [opts]
206
+ * @returns {string}
207
+ */
208
+ export function levelLegendHtml(counts, { retired = 0, nothingToDecide = 0 } = {}) {
209
+ const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc((count ?? 0).toLocaleString('en-US'))}</b></li>`;
210
+ // A folder from an older build has no level counts: say so, as the level bars
211
+ // do, rather than print five zeros that read as "measured, nothing there".
212
+ const items = counts
213
+ ? LEVEL_BUCKETS.map((l) => entry(`l-${l}`, LEVEL_WORD[l], counts[l]))
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));
218
+ if (retired > 0)
219
+ items.push(entry(stateClass('retired'), 'retired', retired));
220
+ return `<ul class="legend">${items.join('')}</ul>`;
221
+ }