@cspeach/cli 1.1.31 → 1.1.32

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 (63) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/help.js +16 -1
  4. package/dist/commands/plan-chain.js +3 -0
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +4 -0
  7. package/dist/commands/team-rules.js +187 -0
  8. package/dist/commands/team.js +632 -90
  9. package/dist/journey/chain.js +265 -15
  10. package/dist/journey/clean-core.js +25 -3
  11. package/dist/journey/flow.js +51 -1
  12. package/dist/journey/rebuild.js +464 -0
  13. package/dist/journey/run.js +213 -20
  14. package/dist/journey/session-deps.js +5 -1
  15. package/dist/lane/app-chain.js +12 -2
  16. package/dist/one-shot.js +24 -17
  17. package/dist/projects/clean-core-confirm.js +38 -5
  18. package/dist/projects/save-command.js +20 -3
  19. package/dist/register/baseline.js +39 -2
  20. package/dist/register/decision-rules.js +35 -0
  21. package/dist/register/discover.js +49 -19
  22. package/dist/register/export-files.js +58 -0
  23. package/dist/register/measure.js +191 -9
  24. package/dist/register/plan-import.js +6 -3
  25. package/dist/register/records.js +15 -3
  26. package/dist/register/rule-proposal.js +4 -0
  27. package/dist/register/rules-edit.js +257 -0
  28. package/dist/register/status-file/register-status-file.js +106 -108
  29. package/dist/register/status-file/report-model.js +569 -0
  30. package/dist/register/status-file/team-css.js +1 -1
  31. package/dist/register/status-file-sync.js +1 -0
  32. package/dist/register/status-html.js +25 -6
  33. package/dist/register/store.js +4 -3
  34. package/dist/register/variant.js +65 -0
  35. package/dist/register/workbooks.js +22 -56
  36. package/dist/register/write-whole.js +30 -2
  37. package/dist/renderer/tool-labels.js +2 -1
  38. package/dist/repl/builtin-commands.js +4 -0
  39. package/dist/repl/journey-entry.js +4 -2
  40. package/dist/repl/plan-hop.js +2 -9
  41. package/dist/repl/rules-menu.js +177 -0
  42. package/dist/repl/slash-completer.js +1 -0
  43. package/dist/repl/slash-picker.js +5 -4
  44. package/dist/repl.js +145 -86
  45. package/dist/router/classifier.js +8 -4
  46. package/dist/router/router-core.js +43 -7
  47. package/dist/router/routing-decision.js +1 -1
  48. package/dist/runs/bundled-runs.js +100 -0
  49. package/dist/runs/front-door.js +19 -0
  50. package/dist/runs/manifest-runs.js +50 -0
  51. package/dist/runs/run-command.js +24 -0
  52. package/dist/runs/runs.js +27 -0
  53. package/dist/runs/shadow.js +100 -0
  54. package/dist/runs/types.js +1 -0
  55. package/dist/session/queued-step.js +45 -0
  56. package/dist/skills/bundled-skills.js +53 -53
  57. package/dist/tools/dispatch-skill.js +8 -4
  58. package/dist/tools/lane/app-lane-resolve.js +1 -1
  59. package/dist/tools/project/rules_propose.js +59 -0
  60. package/dist/tools/project/team_findings.js +13 -2
  61. package/dist/upgrade-check.js +49 -4
  62. package/package.json +120 -120
  63. package/.cspeach/project.json +0 -4
@@ -0,0 +1,569 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/report-model.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 one report: what a manager, a lead and a developer read about one team
7
+ * project, all lanes together, worked out once.
8
+ *
9
+ * "We need a simple report where we see different lanes together" (owner,
10
+ * 2026-10-08). The saved status page, the workbook and `cspeach team status`
11
+ * all print from `reportView`, so a number on paper, in Excel and in the
12
+ * terminal cannot differ.
13
+ *
14
+ * Pure: a Register in, plain data out. No DOM and no clock — `asOf` is handed
15
+ * in. The honesty rules this file keeps (each pinned by a test):
16
+ * - the coverage line comes first: no percentage, no claim before it;
17
+ * - "fix proven" counts only objects the check moved to verified — an object
18
+ * that was clean from its first check never had a fix to prove;
19
+ * - kept by decision is its own count: never "not started", never "below target";
20
+ * - no target anywhere → one line, "no target set — counted as the check reports",
21
+ * and no judgement words;
22
+ * - developers are listed in lane order (lanes.json), never by a count.
23
+ */
24
+ import { PLAN_COLUMNS, inLane, isOutsideUsageWindowLine, planRows, provenForTarget, provenForTargetWords, targetsOf, usageCellOf, utcWords } from '@cspeach/register-core';
25
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
26
+ const two = (v) => String(v).padStart(2, '0');
27
+ const DAY_MS = 86_400_000;
28
+ /** `8 Oct 2026 02:39 UTC`. */
29
+ export function whenWords(iso) {
30
+ const t = iso ? new Date(iso) : null;
31
+ if (!t || Number.isNaN(t.getTime()))
32
+ return '';
33
+ return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]} ${t.getUTCFullYear()} ${two(t.getUTCHours())}:${two(t.getUTCMinutes())} UTC`;
34
+ }
35
+ /** `8 Oct`. */
36
+ export function dayWords(iso) {
37
+ const t = iso ? new Date(iso) : null;
38
+ if (!t || Number.isNaN(t.getTime()))
39
+ return '';
40
+ return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]}`;
41
+ }
42
+ const count = (v) => Number(v ?? 0).toLocaleString('en-US');
43
+ const plural = (v, one, many) => `${count(v)} ${v === 1 ? one : many}`;
44
+ const ms = (iso) => { const t = Date.parse(iso ?? ''); return Number.isFinite(t) ? t : NaN; };
45
+ /** The words for no target, everywhere (spec D1). */
46
+ export const NO_TARGET = 'no target set — counted as the check reports';
47
+ /** The same words standing alone as a sentence: on the page, the workbook and `team status`. */
48
+ export const NO_TARGET_LINE = 'No target set — counted as the check reports.';
49
+ /** The first report a folder gets with change tracking (review F10). Never "no changes". */
50
+ export const FIRST_REPORT = 'First report with change tracking — changes appear from the next report.';
51
+ /** Where the columns the workbook no longer carries went (review finding 3). */
52
+ export const EXPORT_LINE = 'Check runs, results per object and every event are in the CSV export (cspeach team export). The system inventory is discovery/estate.xlsx.';
53
+ /** Said once wherever a target is read against the findings. */
54
+ export const READING_LINE = "Reading the findings against a level is CSPeach's reading of SAP's release states.";
55
+ export const FIX_RULE = 'A fix counts when a check proves it.';
56
+ export const LEVEL_KEY = {
57
+ A: 'uses only released APIs',
58
+ B: 'calls public classic APIs',
59
+ C: 'uses SAP-internal objects',
60
+ D: 'modifies SAP standard',
61
+ none: 'no person has judged its level yet',
62
+ nothing: 'checked clean, so nothing to decide',
63
+ };
64
+ /** The as-of stamp a status page carries, or null for none (a 1.1.31 page, or none at all). */
65
+ export function asOfFromStatusHtml(text) {
66
+ // `[m]`: the source scan allows a meta tag only in the status file itself.
67
+ const m = /<[m]eta name="cspeach-as-of" content="([^"]{10,40})">/.exec(String(text ?? ''));
68
+ return m && Number.isFinite(Date.parse(m[1])) ? m[1] : null;
69
+ }
70
+ const isActive = (o) => o.inScope !== false && o.state !== 'retired';
71
+ const classOf = (o) => o.decision?.classification;
72
+ const isKept = (o) => classOf(o) === 'keep';
73
+ const isRebuild = (o) => classOf(o) === 'redesign';
74
+ const ownerOf = (register, o) => register.lanes.find((l) => l.id === o.lane)?.owner ?? null;
75
+ const REBUILD_STATUSES = new Set(['handed-off', 'started', 'built', 'proven']);
76
+ /** The rebuild row on an old object, read tolerantly: a later build defines it (T5). */
77
+ const rebuildOf = (o) => {
78
+ const r = o.evidence?.rebuild;
79
+ return r && typeof r === 'object' && REBUILD_STATUSES.has(r.status) ? r : null;
80
+ };
81
+ /** The variant the numbers come from: the project's, else the newest counted run's. */
82
+ function variantOf(project, register) {
83
+ const own = typeof project?.atcVariant === 'string' ? project.atcVariant.trim() : '';
84
+ if (own)
85
+ return own;
86
+ const run = [...(register.runs ?? [])].reverse().find((r) => r.atcVariant);
87
+ return run?.atcVariant ?? "the team project's check";
88
+ }
89
+ function coverageOf(project, register) {
90
+ const active = register.objects.filter(isActive);
91
+ const of = active.length;
92
+ const checked = active.filter((o) => (o.lastMeasuredAt ?? null) !== null).length;
93
+ const notChecked = of - checked;
94
+ const variant = variantOf(project, register);
95
+ const at = register.lastMeasuredAt ?? null;
96
+ const lastRun = [...(register.runs ?? [])].reverse().find((r) => r.at === at);
97
+ const fromFile = lastRun?.basis === 'file';
98
+ const notRun = lastRun?.coverage?.notRun ?? 0;
99
+ const cut = notChecked > 0 && notRun > 0 ? ` (the last check run stopped before reaching ${count(Math.min(notRun, notChecked))} of them)` : '';
100
+ const line = at
101
+ ? `Checked ${count(checked)} of ${plural(of, 'object', 'objects')} with ${variant}, latest check on ${whenWords(at)}${fromFile ? ', from a file' : ''} · ${count(notChecked)} not checked${cut}.`
102
+ : `Checked 0 of ${plural(of, 'object', 'objects')} with ${variant} · ${count(of)} not checked.`;
103
+ // What a reader two forwards away cannot look up in the folder: where the
104
+ // results came from, and what was left out.
105
+ const notes = [];
106
+ const runs = register.runs ?? [];
107
+ const fileRuns = runs.filter((r) => r.basis === 'file').length;
108
+ if (fileRuns)
109
+ notes.push(`${plural(fileRuns, 'check run was', 'check runs were')} read from a file, not run on the system.`);
110
+ if (runs.some((r) => r.absentMeansClean))
111
+ notes.push('The person who imported a file told CSPeach that objects the file does not mention are clean.');
112
+ const other = register.skipped?.measurementsOtherVariant ?? 0;
113
+ if (other)
114
+ notes.push(`${plural(other, 'check run made with another check variant was', 'check runs made with another check variant were')} set aside.`);
115
+ return { checked, of, notChecked, variant, at, fromFile, line, notes };
116
+ }
117
+ /**
118
+ * Findings in the check at each check run: each object counts its latest
119
+ * counted check at or before that run. The last six points.
120
+ */
121
+ export function findingsTrend(register) {
122
+ const runs = register.runs ?? [];
123
+ const times = [...new Set(runs.map((r) => r.at))].filter((t) => Number.isFinite(ms(t))).sort((a, b) => ms(a) - ms(b));
124
+ const objects = register.objects.filter(isActive);
125
+ return times.slice(-6).map((t) => {
126
+ let sum = 0;
127
+ for (const o of objects) {
128
+ let open = null;
129
+ for (const m of o.measurements ?? []) {
130
+ if (!m.counted)
131
+ continue;
132
+ const at = runs[m.run]?.at;
133
+ if (at && ms(at) <= ms(t))
134
+ open = m.open;
135
+ }
136
+ sum += open ?? 0;
137
+ }
138
+ return { at: t, findings: sum };
139
+ });
140
+ }
141
+ /** Does any target exist that a person set? (controller ruling for T3/T4). */
142
+ export function hasTarget(project, register) {
143
+ if (targetsOf(project).length)
144
+ return true;
145
+ return register.objects.some((o) => (classOf(o) === 'fix' || classOf(o) === 'redesign') && o.decision?.targetBy && o.decision?.targetLevel);
146
+ }
147
+ function targetOf(project, register) {
148
+ if (!hasTarget(project, register))
149
+ return { set: false, line: NO_TARGET, provenLines: [] };
150
+ const withTarget = register.objects.filter((o) => isActive(o) && o.proof?.target?.level);
151
+ const judged = withTarget.filter((o) => !isKept(o));
152
+ const meets = judged.filter((o) => o.proof.proven).length;
153
+ const keptBelow = withTarget.filter((o) => isKept(o) && !o.proof.proven).length;
154
+ // Final review I1: a fix the check did not pass in full, with nothing open for its target, says so by name.
155
+ const provenLines = register.objects.filter((o) => isActive(o) && provenForTarget(o))
156
+ .sort((a, b) => a.name.localeCompare(b.name, 'en')).map((o) => `${o.name}: ${provenForTargetWords(o)}.`);
157
+ const whole = targetsOf(project).filter((t) => !t.packages);
158
+ const level = whole.length === 1 ? ` (${whole[0].level})` : '';
159
+ return {
160
+ set: true, meets, of: judged.length, keptBelow,
161
+ line: `${count(meets)} of ${plural(judged.length, 'object meets', 'objects meet')} your target${level}.`,
162
+ keptLine: keptBelow ? `${plural(keptBelow, 'object is', 'objects are')} kept below your target, by decision.` : '',
163
+ provenLines,
164
+ };
165
+ }
166
+ function levelsOf(register) {
167
+ const now = register.totals.byLevel.now;
168
+ const rows = ['A', 'B', 'C', 'D'].map((l) => ({ level: l, word: l, n: now[l] ?? 0, key: LEVEL_KEY[l] }));
169
+ rows.push({ level: 'none', word: 'Not assessed', n: now.none ?? 0, key: LEVEL_KEY.none });
170
+ const nothing = register.totals.byLevel.nothingToDecide ?? 0;
171
+ if (nothing)
172
+ rows.push({ level: 'nothing', word: 'Clean, nothing to decide', n: nothing, key: LEVEL_KEY.nothing });
173
+ return rows;
174
+ }
175
+ /** Everything that waits for a person, in a fixed order of kinds; oldest first inside a kind. */
176
+ export function decisionsNeeded(project, register) {
177
+ const lead = project?.lead?.name ?? 'the lead';
178
+ const who = (o) => ownerOf(register, o) ?? lead;
179
+ const items = [];
180
+ const add = (kind, o, text, decides, since) => items.push({ kind, key: o.key, name: o.name, text, decides, since: since ?? null });
181
+ for (const o of register.objects) {
182
+ if (o.decision?.conflict) {
183
+ const at = (o.decisionHistory ?? []).reduce((a, d) => (d.at > a ? d.at : a), '');
184
+ add('conflict', o, `Settle the different decisions on ${o.name}.`, lead, at || null);
185
+ }
186
+ }
187
+ for (const o of register.objects)
188
+ if (o.retireCandidate === true)
189
+ add('retire', o, `Retire ${o.name}?`, lead, o.decision?.at);
190
+ for (const o of register.objects) {
191
+ if (isKept(o) && o.decision?.exception === true && !o.decision?.reason)
192
+ add('reason', o, `Give the reason ${o.name} is kept.`, lead, o.decision?.at);
193
+ }
194
+ for (const o of register.objects)
195
+ if (o.state === 'did-not-hold' && !provenForTarget(o))
196
+ add('failed', o, `${o.name}: the fix failed the check. Fix it again?`, who(o), o.lastMeasuredAt);
197
+ for (const o of register.objects)
198
+ if (o.state === 'regressed' && !provenForTarget(o))
199
+ add('regressed', o, `${o.name} has more findings than before. Look again?`, who(o), o.lastMeasuredAt);
200
+ // Not here (ruling on review finding 5): objects nobody has decided yet. That
201
+ // is the developer's own triage, counted on its own in the headline.
202
+ const order = ['conflict', 'retire', 'reason', 'failed', 'regressed'];
203
+ // One line per object: the first kind in this order wins.
204
+ const seen = new Set();
205
+ const once = items.filter((x) => order.indexOf(x.kind) >= 0).sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
206
+ .filter((x) => (seen.has(x.key) ? false : (seen.add(x.key), true)));
207
+ return once.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind)
208
+ || (ms(a.since) || 0) - (ms(b.since) || 0) || a.name.localeCompare(b.name, 'en'));
209
+ }
210
+ /** Counted measurements of one object, oldest first, with their run time. */
211
+ const countedOf = (register, o) => (o.measurements ?? [])
212
+ .filter((m) => m.counted && register.runs?.[m.run]?.at)
213
+ .map((m) => ({ at: register.runs[m.run].at, open: m.open }))
214
+ .sort((a, b) => ms(a.at) - ms(b.at));
215
+ const lastClaimAt = (o) => (o.fixes ?? []).filter((f) => f.status === 'fixed').map((f) => f.at).sort((a, b) => ms(a) - ms(b)).pop() ?? null;
216
+ /** When a verified object became verified: the latest check with nothing open that followed one with findings. */
217
+ export function provenAt(register, o) {
218
+ let at = null;
219
+ let before = null;
220
+ for (const m of countedOf(register, o)) {
221
+ if (before !== null && before > 0 && m.open === 0)
222
+ at = m.at;
223
+ before = m.open;
224
+ }
225
+ return at;
226
+ }
227
+ /** When a fix did not hold: the first check after the newest claim that still had findings. */
228
+ export function failedAt(register, o) {
229
+ const claim = ms(lastClaimAt(o));
230
+ const m = countedOf(register, o).find((x) => (!Number.isFinite(claim) || ms(x.at) > claim) && x.open > 0);
231
+ return m?.at ?? null;
232
+ }
233
+ /** Events recorded inside (from, to]: newest first. Each names one object or one plan change. */
234
+ export function eventsBetween(register, from, to) {
235
+ const lo = ms(from);
236
+ const hi = ms(to);
237
+ const inside = (iso) => { const t = ms(iso); return Number.isFinite(t) && (!Number.isFinite(lo) || t > lo) && (!Number.isFinite(hi) || t <= hi); };
238
+ const out = { proven: [], provenForTarget: [], failed: [], reported: [], decided: [], plan: [] };
239
+ for (const o of register.objects) {
240
+ // Keyed on the TRANSITION, never on lastMeasuredAt: a later run that only
241
+ // confirms the same state must not announce it again (review finding 2).
242
+ if (o.state === 'verified' && inside(provenAt(register, o)))
243
+ out.proven.push(o.name);
244
+ else if (provenForTarget(o)) {
245
+ if (inside(failedAt(register, o)))
246
+ out.provenForTarget.push(o.name);
247
+ }
248
+ else if (o.state === 'did-not-hold' && inside(failedAt(register, o)))
249
+ out.failed.push(o.name);
250
+ // "Waiting for a check" only while it still is: a fix proven or failed since is said once, above (finding 1).
251
+ else if ((o.state === 'claimed' || o.state === 'working') && (o.fixes ?? []).some((f) => f.status === 'fixed' && inside(f.at)))
252
+ out.reported.push(o.name);
253
+ if ((o.decisionHistory ?? []).some((d) => inside(d.at)))
254
+ out.decided.push(o.name);
255
+ }
256
+ for (const c of register.plan?.history ?? [])
257
+ if (inside(c.at))
258
+ out.plan.push(c.what);
259
+ return out;
260
+ }
261
+ function changedOf(register, previousAsOf, asOf) {
262
+ if (!previousAsOf)
263
+ return { first: true, since: null, lines: [], more: 0, moreObjects: 0, line: FIRST_REPORT };
264
+ const e = eventsBetween(register, previousAsOf, asOf);
265
+ // The third value says where the names a line leaves out can be found: the
266
+ // History sheet holds check runs, fixes, decisions and plan changes; which
267
+ // objects a check proved or failed is on the Objects sheet.
268
+ const groups = [
269
+ [e.proven, (n) => `${plural(n, 'fix', 'fixes')} proven by the check`, 'objects'],
270
+ [e.provenForTarget, (n) => `${plural(n, 'fix', 'fixes')} proven for your target`, 'objects'],
271
+ [e.failed, (n) => `${plural(n, 'fix', 'fixes')} failed the check`, 'objects'],
272
+ [e.reported, (n) => `${plural(n, 'fix', 'fixes')} reported, waiting for a check`, 'history'],
273
+ [e.decided, (n) => `${plural(n, 'object', 'objects')} with a decision recorded`, 'history'],
274
+ [e.plan, (n) => `${plural(n, 'plan change', 'plan changes')}`, 'history'],
275
+ ].filter(([names]) => names.length);
276
+ let more = 0;
277
+ let moreObjects = 0;
278
+ const lines = groups.map(([names, words, where]) => {
279
+ const shown = names.slice(0, 3);
280
+ const left = names.length - shown.length;
281
+ if (where === 'history')
282
+ more += left;
283
+ else
284
+ moreObjects += left;
285
+ return `${words(names.length)}: ${shown.join(', ')}${left ? ` and ${count(left)} more` : ''}.`;
286
+ });
287
+ return {
288
+ first: false, since: previousAsOf, lines, more, moreObjects,
289
+ line: lines.length ? '' : `Nothing was recorded since the last report on ${dayWords(previousAsOf)}.`,
290
+ };
291
+ }
292
+ /** The rebuild group (review F17). Absent `evidence.rebuild` → planned from the decision only (I13). */
293
+ export function rebuildGroup(register, asOf) {
294
+ const g = { planned: 0, handedOff: 0, inProgress: 0, built: 0, proven: 0, retired: 0, stale: [] };
295
+ const now = ms(asOf);
296
+ for (const o of register.objects) {
297
+ const r = rebuildOf(o);
298
+ if (o.state === 'retired' && r) {
299
+ g.retired += 1;
300
+ continue;
301
+ }
302
+ if (!r) {
303
+ if (isRebuild(o) && o.state !== 'retired')
304
+ g.planned += 1;
305
+ continue;
306
+ }
307
+ if (r.status === 'handed-off') {
308
+ g.handedOff += 1;
309
+ if (Number.isFinite(now) && Number.isFinite(ms(r.at)) && now - ms(r.at) > 14 * DAY_MS)
310
+ g.stale.push({ name: o.name, at: r.at });
311
+ }
312
+ else if (r.status === 'started')
313
+ g.inProgress += 1;
314
+ else if (r.status === 'built')
315
+ g.built += 1;
316
+ else if (r.status === 'proven')
317
+ g.proven += 1;
318
+ }
319
+ g.total = g.planned + g.handedOff + g.inProgress + g.built + g.proven + g.retired;
320
+ g.words = [
321
+ ['planned', g.planned], ['handed off', g.handedOff], ['in progress', g.inProgress],
322
+ ['built, not yet checked', g.built], ['built and proven', g.proven], ['old retired', g.retired],
323
+ ].map(([w, n]) => `${count(n)} ${w}`);
324
+ g.staleLines = g.stale.map((s) => `${s.name}: handed off on ${dayWords(s.at)}, not started.`);
325
+ return g;
326
+ }
327
+ /** One row per lane, in lanes.json order — never sorted by a count (review F11). */
328
+ export function laneRows(register, asOf) {
329
+ const now = ms(asOf);
330
+ const rowOf = (owner, laneId, objects) => {
331
+ const claimed = objects.filter((o) => o.state === 'claimed');
332
+ const claimTimes = claimed.map((o) => Math.max(...(o.fixes ?? []).filter((f) => f.status === 'fixed').map((f) => ms(f.at)).filter(Number.isFinite), -Infinity))
333
+ .filter(Number.isFinite);
334
+ const oldest = claimTimes.length && Number.isFinite(now) ? Math.max(0, Math.floor((now - Math.min(...claimTimes)) / DAY_MS)) : null;
335
+ const packages = [...new Set(objects.map((o) => o.package))].sort((a, b) => a.localeCompare(b, 'en'));
336
+ const last = objects.map((o) => o.lastMeasuredAt).filter(Boolean).sort().pop() ?? null;
337
+ return {
338
+ laneId, developer: owner, packages,
339
+ packagesWord: packages.length > 3 ? `${packages.slice(0, 3).join(', ')} and ${packages.length - 3} more` : packages.join(', '),
340
+ objects: objects.length,
341
+ decided: objects.filter((o) => o.decision).length,
342
+ fixesProven: objects.filter((o) => o.state === 'verified').length,
343
+ awaiting: claimed.length, oldestDays: oldest,
344
+ kept: objects.filter(isKept).length,
345
+ rebuilds: objects.filter((o) => isRebuild(o) || rebuildOf(o)).length,
346
+ fixFailed: objects.filter((o) => o.state === 'did-not-hold' && !provenForTarget(o)).length,
347
+ lastCheck: last,
348
+ next: [],
349
+ };
350
+ };
351
+ // A person with two lanes is two rows; the lane id tells them apart.
352
+ const twice = new Set(register.lanes.map((l) => l.owner).filter((o, i, all) => all.indexOf(o) !== i));
353
+ const rows = register.lanes.map((l) => ({
354
+ ...rowOf(twice.has(l.owner) ? `${l.owner} (${l.id})` : l.owner, l.id, register.objects.filter((o) => inLane(o, l.id))),
355
+ // The lane's next objects, in the priority order every surface sorts by (register-core's `next`).
356
+ next: (l.next ?? []).slice(0, 3).map((k) => k.slice(k.indexOf('|') + 1)),
357
+ }));
358
+ // Active objects only: a retired or out-of-scope object needs no owner.
359
+ const unowned = register.objects.filter((o) => o.lane === null && isActive(o));
360
+ if (unowned.length)
361
+ rows.push(rowOf('No owner yet', null, unowned));
362
+ return rows;
363
+ }
364
+ export const awaitingWord = (r) => (r.awaiting ? `${count(r.awaiting)}${r.oldestDays !== null ? ` (oldest ${plural(r.oldestDays, 'day', 'days')})` : ''}` : '0');
365
+ export const lastCheckWord = (r) => (r.lastCheck ? dayWords(r.lastCheck) : 'not yet');
366
+ function keptList(register) {
367
+ return register.objects.filter((o) => isKept(o) && o.state !== 'retired').map((o) => ({
368
+ name: o.name, type: o.type,
369
+ line: `${o.name} kept${o.decision.level ? ` at ${o.decision.level}` : ''}${o.decision.exception ? ' as an accepted exception' : ''}, ${o.decision.reason ? 'reason recorded' : 'no reason recorded'}.`,
370
+ why: o.decision.reason ?? '',
371
+ by: `Decided by ${o.decision.by}${o.decision.at ? ` on ${dayWords(o.decision.at)}` : ''}.`,
372
+ })).sort((a, b) => a.name.localeCompare(b.name, 'en'));
373
+ }
374
+ /** Why "Objects" can be more than the coverage line's "of m": retired and out-of-scope objects are not checked. */
375
+ function objectsSub(t) {
376
+ const retired = t.byState?.retired ?? 0;
377
+ const parts = [retired ? `${count(retired)} retired` : '', t.outOfScope ? `${count(t.outOfScope)} taken out of scope` : ''].filter(Boolean);
378
+ return parts.length ? `${parts.join(', ')}; not checked` : 'in the team project';
379
+ }
380
+ /** "with its reason" only when every kept object has one. */
381
+ function keptSub(register) {
382
+ const kept = register.objects.filter((o) => isKept(o) && o.state !== 'retired');
383
+ const missing = kept.filter((o) => !o.decision.reason).length;
384
+ return missing ? `by decision; ${plural(missing, 'has', 'have')} no reason recorded` : 'by decision, with its reason';
385
+ }
386
+ /**
387
+ * The whole report, worked out once.
388
+ * @param {{ project: object, register: object, asOf: string, previousAsOf?: string|null }} input
389
+ */
390
+ export function reportView({ project, register, asOf, previousAsOf = null }) {
391
+ const t = register.totals;
392
+ const coverage = coverageOf(project, register);
393
+ const needed = decisionsNeeded(project, register);
394
+ const kept = (t.byDisposition?.keep ?? 0) + (t.byDisposition?.exception ?? 0);
395
+ const fixesProven = t.byState.verified ?? 0;
396
+ const packages = new Set(register.objects.map((o) => o.package)).size;
397
+ const estate = register.estate;
398
+ const sid = estate?.system?.sid;
399
+ const scope = `${plural(t.objects, 'object', 'objects')} in ${plural(packages, 'package', 'packages')}`
400
+ + `${estate ? `, of ${count(estate.totals.codeObjects)} code objects${sid ? ` on ${sid}` : ' on the system'}` : ''}.`;
401
+ const rebuilds = rebuildGroup(register, asOf);
402
+ const fromFile = t.fromFile?.verified ?? 0;
403
+ const numbers = [
404
+ { id: 'objects', n: t.objects, label: 'Objects', sub: objectsSub(t) },
405
+ { id: 'decided', n: t.decided, label: 'Decided', sub: 'a person recorded what to do' },
406
+ { id: 'fixesProven', n: fixesProven, label: 'Fixes proven', sub: fromFile ? `${count(fromFile)} of them measured from a file` : 'a check passed after the fix' },
407
+ { id: 'awaiting', n: t.byState.claimed ?? 0, label: 'Fixed, awaiting check', sub: 'reported fixed, not checked since' },
408
+ { id: 'kept', n: kept, label: 'Kept as is', sub: keptSub(register) },
409
+ ...(rebuilds.total > 0 ? [{ id: 'rebuilds', n: rebuilds.total, label: 'Rebuilds', sub: 'decided to be built again' }] : []),
410
+ ];
411
+ // Objects with findings nobody has decided yet: the team's own triage, not a decision for someone else.
412
+ const notDecided = register.objects.filter((o) => isActive(o) && !o.decision && (o.openFindings ?? 0) > 0).length;
413
+ const headline = `${plural(fixesProven, 'fix', 'fixes')} proven by your check · ${plural(needed.length, 'decision', 'decisions')} waiting · ${count(kept)} kept by decision`
414
+ + `${notDecided ? ` · ${plural(notDecided, 'object', 'objects')} not decided yet` : ''}.`;
415
+ return {
416
+ asOf, coverage, headline, scope, numbers, fromFile,
417
+ trend: findingsTrend(register),
418
+ levels: levelsOf(register),
419
+ target: targetOf(project, register),
420
+ decisions: { total: needed.length, shown: needed.slice(0, 5), more: Math.max(0, needed.length - 5), all: needed },
421
+ changed: changedOf(register, previousAsOf, asOf),
422
+ lanes: laneRows(register, asOf),
423
+ kept: keptList(register),
424
+ rebuilds,
425
+ };
426
+ }
427
+ /** "Findings in your check at each check run: 62 → 47." Empty below two runs. */
428
+ export const trendLine = (trend) => (trend.length < 2 ? ''
429
+ : `Findings in your check at each check run: ${trend.map((p) => count(p.findings)).join(' → ')} (each object counts its latest check).`);
430
+ const decisionLine = (d) => `${d.text} Decides: ${d.decides}.${d.since ? ` Since ${dayWords(d.since)}.` : ''}`;
431
+ export { decisionLine };
432
+ /**
433
+ * The page's first block as terminal lines: what `cspeach team status` prints first.
434
+ */
435
+ export function firstBlockLines(view) {
436
+ const lines = [view.coverage.line];
437
+ const trend = trendLine(view.trend);
438
+ if (trend)
439
+ lines.push(trend);
440
+ for (const note of view.coverage.notes)
441
+ lines.push(note);
442
+ lines.push(view.headline, view.scope, '');
443
+ for (const x of view.numbers)
444
+ lines.push(` ${String(count(x.n)).padStart(6)} ${x.label}`);
445
+ if (view.fromFile)
446
+ lines.push(` ${count(view.fromFile)} of the fixes proven ${view.fromFile === 1 ? 'was' : 'were'} measured from a file.`);
447
+ lines.push('');
448
+ lines.push(`Levels now: ${view.levels.map((l) => `${l.word} ${count(l.n)}`).join(' · ')}.`);
449
+ lines.push(view.target.set ? view.target.line : NO_TARGET_LINE);
450
+ if (view.target.keptLine)
451
+ lines.push(view.target.keptLine);
452
+ for (const l of view.target.provenLines ?? [])
453
+ lines.push(l);
454
+ lines.push('');
455
+ if (view.decisions.total) {
456
+ lines.push(`Decisions needed: ${count(view.decisions.total)}`);
457
+ for (const d of view.decisions.shown)
458
+ lines.push(` ${decisionLine(d)}`);
459
+ if (view.decisions.more)
460
+ lines.push(` and ${count(view.decisions.more)} more in the workbook.`);
461
+ }
462
+ else
463
+ lines.push('Decisions needed: none.');
464
+ const c = view.changed;
465
+ lines.push('');
466
+ if (c.first)
467
+ lines.push('Changed since the last report:', ` ${c.line}`);
468
+ else {
469
+ lines.push(`Changed since the last report, ${dayWords(c.since)}:`);
470
+ if (c.line)
471
+ lines.push(` ${c.line}`);
472
+ for (const l of c.lines)
473
+ lines.push(` ${l}`);
474
+ if (c.more)
475
+ lines.push(' The ones left out are in History.');
476
+ }
477
+ return lines;
478
+ }
479
+ /* ── The workbook's four sheets: rows only, the CLI writes the file ──────── */
480
+ const usageWord = (u) => { const c = usageCellOf(u); return c === 'unknown' && !isOutsideUsageWindowLine(u.why) ? 'not recorded' : c; };
481
+ const callersWord = (o) => (o.evidence?.callers?.read ? o.evidence.callers.objects : 'not read');
482
+ /** Summary: the page's numbers, the coverage row first. */
483
+ export function summarySheetRows(view, { madeFor = '' } = {}) {
484
+ const rows = [
485
+ [view.coverage.line, ''],
486
+ ...view.coverage.notes.map((note) => [note, '']),
487
+ ['As of', utcWords(view.asOf)],
488
+ [view.headline, ''],
489
+ [view.scope, ''],
490
+ [],
491
+ ...view.numbers.map((x) => [x.label, x.n]),
492
+ [],
493
+ ...view.levels.map((l) => [['A', 'B', 'C', 'D'].includes(l.level) ? `Level ${l.word}: ${l.key}` : l.word, l.n]),
494
+ [view.target.set ? view.target.line : NO_TARGET_LINE, ''],
495
+ ...(view.target.keptLine ? [[view.target.keptLine, '']] : []),
496
+ ...(view.target.provenLines ?? []).map((l) => [l, '']),
497
+ ...(view.target.set ? [[READING_LINE, '']] : []),
498
+ ['Decisions needed', view.decisions.total],
499
+ ...(view.rebuilds.total ? [[`Rebuilds: ${view.rebuilds.words.join(' · ')}`, view.rebuilds.total]] : []),
500
+ [],
501
+ ['By lane / developer (lanes in their set order)', ''],
502
+ ['Developer', 'Lane packages', 'Objects', 'Decided', 'Fixes proven', 'Awaiting check', 'Kept', 'Rebuilds', 'Fix failed', 'Last check'],
503
+ ...view.lanes.map((r) => [r.developer, r.packages.join(', '), r.objects, r.decided, r.fixesProven, awaitingWord(r), r.kept, r.rebuilds, r.fixFailed, lastCheckWord(r)]),
504
+ [],
505
+ [madeFor, ''],
506
+ [FIX_RULE, ''],
507
+ ['Edit only the five columns before the dash on the Objects sheet, then: cspeach team plan import <file>', ''],
508
+ ['Changes made by someone else after this export are shown and skipped, never overwritten.', ''],
509
+ [EXPORT_LINE, ''],
510
+ ];
511
+ return rows;
512
+ }
513
+ export const OBJECTS_EXTRA = ['Level now', 'Reason', 'Last check', 'Checked with'];
514
+ /** The plan's "Level" is the level a person decided; beside "Level now" it says so. */
515
+ export const OBJECTS_COLUMNS = [...PLAN_COLUMNS.map((c) => (c === 'Level' ? 'Level decided' : c)), ...OBJECTS_EXTRA];
516
+ /** Objects: the Plan sheet's columns (so plan import reads it), then four read-only facts. */
517
+ export function objectsSheetRows(register, variant) {
518
+ const byKey = new Map(register.objects.map((o) => [`${o.type}|${o.name}`.toUpperCase(), o]));
519
+ return planRows(register).map((row) => {
520
+ const o = byKey.get(`${row[1]}|${row[0]}`.toUpperCase());
521
+ return [...row, o?.levelNow ?? '', o?.decision?.reason ?? '', o?.lastMeasuredAt ? utcWords(o.lastMeasuredAt) : 'not checked', o?.lastMeasuredAt ? variant : ''];
522
+ });
523
+ }
524
+ export const BUSINESS_COLUMNS = ['Object', 'Type', 'Package', 'Owner', 'Proposal', 'Reason', 'Decided by', 'Last changed', 'Callers', 'Usage', 'Confirm'];
525
+ /** For the business: retire, rebuild and kept rows, with their evidence, and an empty Confirm column. */
526
+ export function businessSheetRows(register) {
527
+ const proposal = (o) => {
528
+ if (o.retireCandidate)
529
+ return 'retire (candidate)';
530
+ const c = classOf(o);
531
+ if (c === 'retire')
532
+ return 'retire';
533
+ if (c === 'redesign')
534
+ return 'rebuild beside the core';
535
+ if (c === 'keep')
536
+ return o.decision.exception ? 'kept, accepted exception' : 'kept as is';
537
+ return null;
538
+ };
539
+ return register.objects.filter((o) => proposal(o) && !(o.state === 'retired' && !o.retireCandidate))
540
+ .sort((a, b) => a.package.localeCompare(b.package, 'en') || a.name.localeCompare(b.name, 'en'))
541
+ .map((o) => [
542
+ o.name, o.type, o.package, ownerOf(register, o) ?? 'nobody yet', proposal(o), o.decision?.reason ?? '',
543
+ o.decision ? `${o.decision.by}${o.decision.at ? `, ${utcWords(o.decision.at)}` : ''}` : '',
544
+ o.evidence?.changed?.at ? o.evidence.changed.at.slice(0, 10) : 'not read', callersWord(o), usageWord(o.evidence?.usage ?? { state: 'unknown' }), '',
545
+ ]);
546
+ }
547
+ export const HISTORY_COLUMNS = ['When', 'Who', 'What', 'Object', 'Why'];
548
+ const DECISION_WORD = { keep: 'keep', fix: 'fix', retire: 'retire', redesign: 'rebuild beside the core' };
549
+ const FIX_WORD = { fixed: 'fix reported', skipped: 'fix skipped', failed: 'fix failed', pending: 'fix pending' };
550
+ const HISTORY_CAP = 5000;
551
+ /** History: one event log from the rows the folder already holds (plan changes, decisions, fixes). Newest first. */
552
+ export function historySheetRows(register) {
553
+ const rows = [];
554
+ for (const r of register.runs ?? []) {
555
+ rows.push([r.at, r.by, `check run: ${plural(r.findings ?? 0, 'finding', 'findings')}, ${r.atcVariant ?? 'variant not recorded'}${r.basis === 'file' ? ', from a file' : ''}`,
556
+ plural(r.objects ?? 0, 'object', 'objects'), r.scope ?? '']);
557
+ }
558
+ for (const c of register.plan?.history ?? []) {
559
+ rows.push([c.at, c.by, `plan: ${c.what}`, (c.keys ?? []).map((k) => k.slice(k.indexOf('|') + 1)).join(', ') + (c.more ? ` and ${c.more} more` : ''), c.why ?? '']);
560
+ }
561
+ for (const o of register.objects) {
562
+ for (const d of o.decisionHistory ?? []) {
563
+ rows.push([d.at, d.by, `${d.ruling ? 'ruling' : 'decision'}: ${DECISION_WORD[d.classification] ?? d.classification}${d.superseded ? ' (replaced later)' : ''}`, o.name, d.reason ?? '']);
564
+ }
565
+ for (const f of o.fixes ?? [])
566
+ rows.push([f.at, f.by, `${FIX_WORD[f.status] ?? `fix ${f.status}`}: ${f.rule}`, o.name, '']);
567
+ }
568
+ return rows.sort((a, b) => (ms(b[0]) || 0) - (ms(a[0]) || 0)).slice(0, HISTORY_CAP).map((r) => [utcWords(r[0]), ...r.slice(1)]);
569
+ }