@cspeach/cli 1.1.30 → 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 (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -12,14 +12,14 @@
12
12
  * measurement has none) or clean (measured, never had any). A developer's
13
13
  * "fixed" is a claim and is never counted here.
14
14
  */
15
- import { activeOf, provenOf, settledOf } from '@cspeach/register-core';
15
+ import { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf } from '@cspeach/register-core';
16
16
  /**
17
17
  * The three counts every surface divides by are defined once, in register-core,
18
18
  * where the terminal reads them too. They are passed on from here so that
19
19
  * register-parts.js — which draws the lane table for both the page and the
20
20
  * status file — takes its numbers from the model, like everything else it draws.
21
21
  */
22
- export { activeOf, provenOf, settledOf };
22
+ export { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf };
23
23
  /** Worst first: the order cells are banded in a block and listed in a legend. */
24
24
  export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
25
25
  export const STATE_LABEL = {
@@ -79,6 +79,8 @@ const andList = (names) => (names.length < 3 ? names.join(' and ') : `${names.sl
79
79
  /** As many names as a person reads before they stop reading names. */
80
80
  const LIST_CAP = 12;
81
81
  const count = (value) => value.toLocaleString('en-US');
82
+ /** A clean (or verified) object nobody decided: counted on its own, never as undecided. */
83
+ export const NOTHING_TO_DECIDE = 'clean, nothing to decide';
82
84
  /**
83
85
  * How a cut list ends.
84
86
  *
@@ -145,11 +147,18 @@ export function summarize(register, { unreadable = 0 } = {}) {
145
147
  // A folder from an older build carries no `undecided`; work it out the same
146
148
  // way register-core does, so the line never prints a blank.
147
149
  const decided = totals.decided ?? 0;
148
- const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided);
150
+ // Recording fix 4 (2026-10-07): a clean object nobody decided has nothing
151
+ // to decide. register-core counts it on its own; it is never "undecided".
152
+ const nothingToDecide = Number.isFinite(totals.cleanNothingToDecide) ? totals.cleanNothingToDecide : 0;
153
+ const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided - nothingToDecide);
154
+ const decidedPhrase = nothingToDecide > 0
155
+ ? `${count(decided)} decided${undecided > 0 ? `, ${count(undecided)} undecided` : ''} · ${count(nothingToDecide)} ${NOTHING_TO_DECIDE}`
156
+ : `${count(decided)} decided, ${count(undecided)} undecided`;
149
157
  return {
150
158
  objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
151
159
  lastMeasured, unreadable, fromFile,
152
- line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${count(decided)} decided, ${count(undecided)} undecided · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
160
+ ...(nothingToDecide > 0 ? { nothingToDecide } : {}),
161
+ line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${decidedPhrase} · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
153
162
  };
154
163
  }
155
164
  const DAY_MS = 86_400_000;
@@ -199,6 +208,21 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
199
208
  items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
200
209
  detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
201
210
  }
211
+ // A developer's "retire" is a candidate until the lead confirms it with a
212
+ // reason (spec 2026-10-06 D2). Until then the object stays active, and the
213
+ // lead is the one person who can move it.
214
+ const candidates = register.objects.filter((o) => o.retireCandidate === true);
215
+ if (candidates.length) {
216
+ items.push({ kind: 'retire-candidate', title: `${plural(candidates.length, 'retire candidate waits', 'retire candidates wait')} for your confirmation`,
217
+ detail: upTo(candidates.map((o) => o.name), { tail: onMap }), keys: candidates.map((o) => o.key) });
218
+ }
219
+ // Out of scope is a decision about work nobody will do, so it needs a reason
220
+ // the lead can defend. A record from an older build may carry none.
221
+ const outNoReason = register.objects.filter((o) => o.inScope === false && !o.scopeReason);
222
+ if (outNoReason.length) {
223
+ items.push({ kind: 'out-of-scope-no-reason', title: `${plural(outNoReason.length, 'object was', 'objects were')} taken out of scope with no reason`,
224
+ detail: upTo(outNoReason.map((o) => o.name), { tail: onMap }), keys: outNoReason.map((o) => o.key) });
225
+ }
202
226
  const unowned = register.objects.filter((o) => o.lane === null);
203
227
  if (unowned.length) {
204
228
  const packages = [...new Set(unowned.map((o) => o.package))].sort();
@@ -213,6 +237,16 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
213
237
  items.push({ kind: 'overlap', title: `${plural(overlap.length, 'object is', 'objects are')} claimed by more than one lane`,
214
238
  detail: upTo([...new Set(overlap.map((o) => o.laneOverlap.join(' + ')))].sort(), { tail: justHowMany, sep: '; ' }), keys: overlap.map((o) => o.key) });
215
239
  }
240
+ // Rows of the last Excel import that clashed with a change recorded after the
241
+ // workbook was made were skipped, not applied. Only the newest import counts:
242
+ // an older one's skips were either entered again or are no longer wanted.
243
+ const lastImport = (register.plan?.history ?? []).find((c) => c.action === 'excel');
244
+ const clashes = lastImport?.clashesSkipped ?? 0;
245
+ if (Number.isInteger(clashes) && clashes > 0) {
246
+ items.push({ kind: 'excel-clashes', title: `${plural(clashes, 'Excel plan row was', 'Excel plan rows were')} skipped at the last import`,
247
+ detail: 'They clashed with changes recorded after the workbook was made. Make a fresh workbook with cspeach team report and enter them again.',
248
+ keys: [] });
249
+ }
216
250
  // A lane that has finished its work stops writing records, and that is the
217
251
  // point — it is not quiet, it is done. Only a lane with work still open can
218
252
  // be waiting for someone.
@@ -220,8 +254,8 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
220
254
  for (const lane of register.lanes) {
221
255
  if (!lane.objects || !lane.lastRecordAt || !Number.isFinite(nowMs))
222
256
  continue;
223
- if (lane.objects - settledOf(lane.byState) <= 0)
224
- continue;
257
+ if (laneActiveOf(lane) - laneProvenOf(lane) <= 0)
258
+ continue; // the lane's own Done % denominator
225
259
  const days = Math.floor((nowMs - Date.parse(lane.lastRecordAt)) / DAY_MS);
226
260
  if (days >= quietDays) {
227
261
  items.push({ kind: 'quiet-lane', title: `${lane.owner} (${lane.id}): no records for ${plural(days, 'day', 'days')}`,
@@ -21,7 +21,7 @@
21
21
  * style attribute.
22
22
  */
23
23
  import { esc, blockSvg, legendHtml, levelLegendHtml, stateWord, LEVEL_BUCKETS as LEVELS, LEVEL_WORD as LEVEL_NAME } from './register-map.js';
24
- import { activeOf, formatWhen, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, } from './register-model.js';
24
+ import { formatWhen, laneActiveOf, laneProvenOf, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, NOTHING_TO_DECIDE, } from './register-model.js';
25
25
  import { DISPOSITION_ORDER, DISPOSITION_WORD, developerTable, dispositionOf, overlapNote, } from '@cspeach/register-core';
26
26
  // The map owns it, because the map's cells need it too; it is offered here as
27
27
  // well, where every other shared mark lives.
@@ -119,6 +119,8 @@ export function lanesTableHtml(lanes, objects) {
119
119
  for (const o of objects ?? []) {
120
120
  if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
121
121
  continue;
122
+ if (o.inScope === false)
123
+ continue; // the same objects the lane's Proven counts
122
124
  // Every lane that claims the object, exactly as the lane's own counts are
123
125
  // taken: an object two lanes both claim is proven for both of them, so
124
126
  // "(n from a file)" can never be larger than the Proven it sits beside.
@@ -129,10 +131,10 @@ export function lanesTableHtml(lanes, objects) {
129
131
  .map((h) => `<th scope="col">${h}</th>`).join('');
130
132
  const provenCell = (l) => {
131
133
  const said = fromFile.get(l.id) ?? 0;
132
- return `${n(provenOf(l.byState))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
134
+ return `${n(laneProvenOf(l))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
133
135
  };
134
136
  const row = (l) => `<tr><td>${esc(ownerLabel(l))}</td><td>${n(l.objects)}</td>`
135
- + `<td>${n(activeOf(l.objects, l.byState))}</td><td>${provenCell(l)}</td>`
137
+ + `<td>${n(laneActiveOf(l))}</td><td>${provenCell(l)}</td>`
136
138
  + `<td>${n((l.byState?.regressed ?? 0) + (l.byState?.['did-not-hold'] ?? 0))}</td>`
137
139
  + `<td>${esc(formatWhen(l.lastRecordAt))}</td></tr>`;
138
140
  return `<div class="scroller"><table><thead><tr>${heads}</tr></thead>`
@@ -165,7 +167,7 @@ export function mapBodyHtml(register, blocks, { role, colourBy } = {}) {
165
167
  return '<p>No objects in scope yet.</p>';
166
168
  // The status file never passes `colourBy`: it prints the state map only.
167
169
  const legend = colourBy === 'level'
168
- ? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0 })
170
+ ? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0, nothingToDecide: register.totals.byLevel?.nothingToDecide ?? 0 })
169
171
  : legendHtml(register.totals.byState, { unknown: unknownStateCount(register) });
170
172
  return `${legend}
171
173
  <div class="blocks">${blocks.map((b) => blockFigureHtml(b, { role, colourBy })).join('')}</div>`;
@@ -401,9 +403,10 @@ const CLASSIFICATION_WORD = { keep: 'keep', fix: 'fix', redesign: 'redesign', re
401
403
  * it (the caller escapes it), never as "not decided": somebody DID decide, and
402
404
  * calling it undecided would put the object in a bucket nobody chose.
403
405
  */
404
- export function decisionWord(decision, projectType) {
406
+ export function decisionWord(decision, projectType, state) {
407
+ // Recording fix 4: a clean (or verified) object nobody decided has nothing to decide.
405
408
  if (!decision)
406
- return 'not decided';
409
+ return state === 'clean' || state === 'verified' ? 'clean — nothing to decide' : 'not decided';
407
410
  if (projectType === 'clean-core') {
408
411
  const d = dispositionOf(decision);
409
412
  if (d)
@@ -428,6 +431,9 @@ export function whereItStandsHtml(register) {
428
431
  + tile('Proven', provenOf(s), fromFile ? `${n(fromFile)} of them ${FROM_FILE}` : 'a check run says so')
429
432
  + tile('Did not hold', didNotHold, 'claimed, and the check still finds it', didNotHold ? 'bad' : '')
430
433
  + tile('Retired', s.retired ?? 0, 'nothing left to do')
434
+ // Only when there are some: a developer said retire and the lead has not
435
+ // confirmed it yet, so these objects still count as active work.
436
+ + ((t.retireCandidates ?? 0) > 0 ? tile('Retire candidates', t.retireCandidates, 'wait for the lead to confirm', 'retire-candidates') : '')
431
437
  + '</ul>'
432
438
  + (!(t.decided > 0) ? '<p class="empty-note" data-role="nothing-decided">Nothing decided yet. Developers record decisions with /abap-clean-core or cspeach team decide.</p>' : '')
433
439
  + levelBarsHtml(register);
@@ -437,7 +443,7 @@ export function whereItStandsHtml(register) {
437
443
  * never stands alone. Segments are paths, not rects: a `rect` on these surfaces
438
444
  * is a map cell, and the budget and the keyboard both count cells by that tag.
439
445
  */
440
- function levelBar(title, counts) {
446
+ function levelBar(title, counts, nothingToDecide = 0) {
441
447
  const total = LEVELS.reduce((sum, l) => sum + (counts?.[l] ?? 0), 0);
442
448
  let x = 0;
443
449
  const rects = total
@@ -451,7 +457,9 @@ function levelBar(title, counts) {
451
457
  return r;
452
458
  }).join('')
453
459
  : '<path class="lvl l-none" d="M0 0h100v10h-100z"></path>';
454
- const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ');
460
+ // A clean object nobody decided is not "not decided": it is said on its own, after the buckets.
461
+ const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ')
462
+ + (nothingToDecide > 0 ? ` · ${NOTHING_TO_DECIDE} ${n(nothingToDecide)}` : '');
455
463
  return `<figure class="levelbar"><figcaption>${esc(title)}</figcaption>`
456
464
  + `<svg viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${esc(`${title}: ${words}`)}">${rects}</svg>`
457
465
  + `<p class="levelwords">${esc(words)}</p></figure>`;
@@ -460,7 +468,7 @@ function levelBar(title, counts) {
460
468
  export function levelBarsHtml(register) {
461
469
  const b = register.totals?.byLevel;
462
470
  const none = !b || ['A', 'B', 'C', 'D'].every((l) => !(b.decided?.[l]) && !(b.now?.[l]));
463
- return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided)}${levelBar('Levels now', b?.now)}`
471
+ return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided, b?.nothingToDecide ?? 0)}${levelBar('Levels now', b?.now, b?.nothingToDecide ?? 0)}`
464
472
  + `<p class="legend-line">${none ? 'No levels recorded yet. ' : ''}A level moves only when a check proves the fix.</p></div>`;
465
473
  }
466
474
  /**
@@ -500,10 +508,48 @@ export function developersTableHtml(register) {
500
508
  const said = col === provenAt ? fromFile.get(lane.id) ?? 0 : 0;
501
509
  return `<td>${n(v)}${said ? ` (${n(said)} ${FROM_FILE})` : ''}</td>`;
502
510
  };
503
- const head = t.header.map((h) => `<th scope="col">${esc(h)}</th>`).join('');
511
+ // Spec 2026-10-06 §9 item 7: how far each lane is, what it does next (the
512
+ // first three by effective priority) and how much recorded work sits outside
513
+ // the focus — a plain count, recorded and never blocked.
514
+ const nameOf = new Map(register.objects.map((o) => [o.key, o.name]));
515
+ const plan = (lane) => {
516
+ // A key with no object behind it names nothing a reader can find, so it is
517
+ // left out rather than printed as the raw key.
518
+ const keys = (lane.next ?? []).slice(0, 3);
519
+ const next = keys.map((k) => nameOf.get(k)).filter((name) => name);
520
+ return `<td>${n(lane.donePct ?? 0)}%</td>`
521
+ + `<td class="next">${keys.length ? next.map((name) => `<span class="ident">${cell(name)}</span>`).join(', ') : '<span class="quiet-note">nothing open</span>'}</td>`
522
+ + `<td>${n(lane.outsideFocus ?? 0)}</td>`;
523
+ };
524
+ const head = [...t.header, 'Done %', 'Next', 'Outside the focus'].map((h) => `<th scope="col">${esc(h)}</th>`).join('');
504
525
  const body = t.rows.map((r, i) => `<tr><td>${cell(r[0])}</td>${r.slice(1, -1).map((v, j) => num(v, j + 1, register.lanes[i])).join('')}`
505
- + `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td></tr>`).join('');
526
+ + `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td>${plan(register.lanes[i])}</tr>`).join('');
506
527
  const note = overlapNote(register);
507
528
  return `<div class="scroller"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
508
529
  + (note ? `<p class="quiet-note">${esc(note)}</p>` : '');
509
530
  }
531
+ /* ── The focus, under the headline (spec 2026-10-06 §9 item 1) ──────────── */
532
+ const MONTH_WORDS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
533
+ /** A day as a person says it: 24 Sep 2026. UTC, like every time on this page. */
534
+ export const dayWords = (iso) => {
535
+ const t = iso ? new Date(iso) : null;
536
+ return !t || Number.isNaN(t.getTime()) ? '' : `${t.getUTCDate()} ${MONTH_WORDS[t.getUTCMonth()]} ${t.getUTCFullYear()}`;
537
+ };
538
+ export const NO_FOCUS = 'No focus set. Working lists follow the suggested order.';
539
+ /**
540
+ * The second line of the headline: the focus on top of the stack — what, who
541
+ * set it and when, why, and how much of it is done — or the sentence that says
542
+ * there is none. Escaped markup.
543
+ */
544
+ export function focusLineHtml(register) {
545
+ const f = register?.plan?.focuses?.[0];
546
+ if (!f)
547
+ return esc(NO_FOCUS);
548
+ const day = dayWords(f.at);
549
+ return [
550
+ `Now: ${cell(f.label)}`,
551
+ `set by ${cell(f.by)}${day ? ` ${esc(day)}` : ''}`,
552
+ ...(f.reason ? [`because ${cell(f.reason)}`] : []),
553
+ `${n(f.donePct ?? 0)}% done`,
554
+ ].join(' · ');
555
+ }
@@ -34,12 +34,9 @@
34
34
  */
35
35
  import { esc } from './register-map.js';
36
36
  import { formatWhen } from './register-model.js';
37
- // Every mark this file shares with the page. One copy, in a module that knows
38
- // nothing about the browser: two pictures of one folder that disagree would be
39
- // worse than no picture at all, and these two had already begun to.
40
- import { decisionWord, developersTableHtml, dispositionsHtml, ignoredRunsHtml, mapBodyHtml, measuredWord, n, needItemHtml, notMeasuredLine, ownerLabel, provenanceLine, readNote, runsTableHtml, stateWord, whereItStandsHtml, } from './register-parts.js';
37
+ import { n, provenanceLine, readNote } from './register-parts.js';
38
+ import { FIX_RULE, LEVEL_KEY, NO_TARGET_LINE, READING_LINE, awaitingWord, dayWords, lastCheckWord, reportView, trendLine, } from './report-model.js';
41
39
  const FILE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
42
- const objects = (count) => `${n(count)} object${count === 1 ? '' : 's'}`;
43
40
  const files = (count) => `${n(count)} file${count === 1 ? '' : 's'}`;
44
41
  /**
45
42
  * What the file is called once it lands in somebody's downloads. A manager with
@@ -54,118 +51,137 @@ export function statusFileName(project, asOf) {
54
51
  const slug = String(project?.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'team-project';
55
52
  return `${slug}-status-${String(asOf).slice(0, 10)}.html`;
56
53
  }
57
- /**
58
- * One package, folded shut. The count is in the fold's own line so the size of
59
- * a package is readable without opening it — a real project puts thousands of
60
- * rows behind one of these.
61
- */
62
- function packageFold(block, lanes, runs, projectType) {
63
- // An empty cell reads as a value somebody lost rather than as one nobody has
64
- // set yet, so both absences say themselves in words — in the same lower case
65
- // as the values they stand in for ("fix", "retire", a person's name).
66
- //
67
- // The last column is the whole of step 2 in one cell: what the system found,
68
- // or that nobody has looked. A reader working down this list must not have to
69
- // take a colour on trust, and "not measured" is an answer, not a blank.
70
- const row = (o) => `<tr><td class="ident">${esc(o.name)}</td><td>${esc(o.type)}</td><td>${esc(stateWord(o))}</td>`
71
- + `<td>${esc(decisionWord(o.decision, projectType))}</td>`
72
- + `<td>${esc(ownerLabel(lanes.find((l) => l.id === o.lane)))}</td>`
73
- + `<td>${esc(measuredWord(o, runs))}</td></tr>`;
74
- // Every fold holds a table with the same six headings, so each one says
75
- // which package it belongs to — otherwise a screen reader announces the
76
- // twelfth "Object, Type, State, Decision, Owner, Measured" with nothing to
77
- // tell it from the eleventh.
78
- return `<details data-role="package"><summary>${esc(block.package)} — ${objects(block.objects.length)}</summary>`
79
- + `<div class="scroller"><table aria-label="${esc(block.package)}"><thead><tr><th scope="col">Object</th><th scope="col">Type</th>`
80
- + '<th scope="col">State</th><th scope="col">Decision</th><th scope="col">Owner</th><th scope="col">Measured</th></tr></thead>'
81
- + `<tbody>${block.objects.map(row).join('')}</tbody></table></div></details>`;
82
- }
83
54
  /** Nothing in a saved file can reload, so the page's advice is not repeated here. */
84
- const UNREADABLE_DETAIL = 'Some results may be missing. The next status file will include them once the files have finished syncing.';
55
+ const LANE_CAP = 8;
56
+ const KEPT_CAP = 10;
57
+ const numbersHtml = (view) => `<ul class="rp-numbers" data-role="numbers">${view.numbers.map((x) => `<li data-num="${esc(x.id)}">`
58
+ + `<span class="rp-n">${n(x.n)}</span><span class="rp-label">${esc(x.label)}</span><span class="rp-sub">${esc(x.sub)}</span></li>`).join('')}</ul>`;
59
+ function levelsHtml(view) {
60
+ const rows = view.levels.map((l) => `<li><span class="rp-lv">${esc(l.word)}</span><span class="rp-lv-n">${n(l.n)}</span>`
61
+ + `<span class="rp-lv-key">${esc(l.key)}</span></li>`).join('');
62
+ const t = view.target;
63
+ const target = t.set
64
+ ? `<p class="rp-target" data-role="target">${esc(t.line)}${t.keptLine ? ` ${esc(t.keptLine)}` : ''}</p>`
65
+ + `${(t.provenLines ?? []).map((l) => `<p class="rp-target" data-role="proven-for-target">${esc(l)}</p>`).join('')}<p class="rp-note">${esc(READING_LINE)}</p>`
66
+ : `<p class="rp-target" data-role="target">${esc(NO_TARGET_LINE)}</p>`;
67
+ return `<section class="rp-levels" data-role="levels"><h2>Objects by clean-core level, now</h2><ul class="rp-lvs">${rows}</ul>${target}</section>`;
68
+ }
69
+ function decisionsHtml(view) {
70
+ const d = view.decisions;
71
+ const list = d.total
72
+ ? `<ol>${d.shown.map((x) => `<li><p>${esc(x.text)}</p><p class="rp-who">Decides: ${esc(x.decides)}.${x.since ? ` Since ${esc(dayWords(x.since))}.` : ''}</p></li>`).join('')}</ol>`
73
+ + (d.more ? `<p class="rp-tail">And ${n(d.more)} more in the workbook.</p>` : '')
74
+ : '<p>Nothing is waiting for a decision.</p>';
75
+ return `<section class="rp-decide" data-role="decisions"><h2>Decisions needed: ${d.total ? n(d.total) : 'none'}</h2>${list}</section>`;
76
+ }
77
+ function changedHtml(view) {
78
+ const c = view.changed;
79
+ if (c.first)
80
+ return `<section class="rp-changed" data-role="changed"><h2>Changed since the last report</h2><p>${esc(c.line)}</p></section>`;
81
+ return `<section class="rp-changed" data-role="changed"><h2>Changed since the last report, ${esc(dayWords(c.since))}</h2>`
82
+ + (c.line ? `<p>${esc(c.line)}</p>` : '')
83
+ + (c.lines.length ? `<ul>${c.lines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '')
84
+ + (c.more ? '<p class="rp-tail">The ones left out are in History.</p>' : '')
85
+ + (c.moreObjects ? `<p class="rp-tail">The rest of the objects a check proved or failed are on the Objects sheet.</p>` : '')
86
+ + '</section>';
87
+ }
88
+ const LANE_HEAD = ['Lane packages', 'Objects', 'Decided', 'Fixes proven', 'Awaiting check', 'Kept', 'Rebuilds', 'Fix failed', 'Last check'];
89
+ function lanesHtml(view) {
90
+ const rows = view.lanes.slice(0, LANE_CAP).map((r) => `<tr><th scope="row">${esc(r.developer)}</th>`
91
+ + `<td data-l="Lane packages" class="rp-pk">${esc(r.packagesWord)}</td><td data-l="Objects">${n(r.objects)}</td><td data-l="Decided">${n(r.decided)}</td>`
92
+ + `<td data-l="Fixes proven">${n(r.fixesProven)}</td><td data-l="Awaiting check">${esc(awaitingWord(r))}</td><td data-l="Kept">${n(r.kept)}</td>`
93
+ + `<td data-l="Rebuilds">${n(r.rebuilds)}</td><td data-l="Fix failed">${n(r.fixFailed)}</td><td data-l="Last check">${esc(lastCheckWord(r))}</td></tr>`).join('');
94
+ const more = view.lanes.length > LANE_CAP ? `<p class="rp-tail">And ${n(view.lanes.length - LANE_CAP)} more on the workbook's Summary sheet.</p>` : '';
95
+ return '<section class="rp-detail" data-role="lanes"><h2>By lane / developer</h2>'
96
+ + `<div class="scroller"><table class="rp-devs" aria-label="By lane / developer"><thead><tr><th scope="col">Developer</th>${LANE_HEAD.map((h) => `<th scope="col">${h}</th>`).join('')}</tr></thead>`
97
+ + `<tbody>${rows}</tbody></table></div>${more}`
98
+ + "<p class=\"rp-tail\">Lanes are listed in the order they were set up, never ranked. On the workbook's Objects sheet, filter Lane to see one developer's list.</p></section>";
99
+ }
100
+ function keptHtml(view) {
101
+ const k = view.kept;
102
+ const body = k.length
103
+ ? `<ul class="rp-kept-list">${k.slice(0, KEPT_CAP).map((x) => `<li><p>${esc(x.line)}</p>${x.why ? `<p class="rp-why">${esc(x.why)}</p>` : ''}<p class="rp-why">${esc(x.by)}</p></li>`).join('')}</ul>`
104
+ + (k.length > KEPT_CAP ? `<p class="rp-tail">And ${n(k.length - KEPT_CAP)} more on the workbook's For the business sheet.</p>` : '')
105
+ : '<p>No object is kept by decision.</p>';
106
+ return `<section class="rp-kept" data-role="kept"><h2>Kept as is, by decision (${n(k.length)})</h2>${body}</section>`;
107
+ }
108
+ function rebuildsHtml(view) {
109
+ const g = view.rebuilds;
110
+ if (!g.total)
111
+ return '';
112
+ return `<section class="rp-rebuilds" data-role="rebuilds"><h2>Rebuilds (${n(g.total)})</h2><p>${esc(g.words.join(' · '))}.</p>`
113
+ + (g.staleLines.length ? `<ul>${g.staleLines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '')
114
+ + '</section>';
115
+ }
85
116
  /**
86
- * The whole document.
117
+ * The whole document: the one report.
87
118
  *
88
- * Read in the order a manager reads it: whose team project, when this was true, the
89
- * one line, the picture, what is waiting for a person, who owns what — and only
90
- * then, folded away, every object one by one.
119
+ * Page 1, in order: the coverage line (with the check and its date) · the
120
+ * headline and scope · five numbers (six with rebuilds) · levels now ·
121
+ * decisions needed · changed since the last report. Page 2: by lane /
122
+ * developer, the kept-by-decision list, the rebuild group.
91
123
  *
92
- * `css` is the only value written in unescaped, and the only one that never came
93
- * out of the folder: it is our own stylesheet, imported at build time. Every
94
- * name, package, decision and person below goes through `esc()`.
124
+ * `previousAsOf` is the as-of stamp of the status page this one replaces (read
125
+ * from its `cspeach-as-of` meta tag before it is overwritten); null for the
126
+ * first report. `summary`, `needs` and `blocks` are still accepted from the
127
+ * page's `collectView` and not drawn: the one report works its numbers out
128
+ * from the same folder, once, in report-model.js.
129
+ *
130
+ * `estate` is the discovery file as the folder read it: given but not read into
131
+ * the picture, the scope sentence says it could not be read, never "none".
95
132
  *
96
- * `files`, `checksum` and `register.skipped` are what the read note is worked
97
- * out from. The file prints it for the same reason the page does, and with more
98
- * reason: whoever this was mailed to cannot look in the folder to find out that
99
- * a ruling was ignored, and an ignored ruling is a decision somebody made that
100
- * this picture does not show.
133
+ * `css` is the only value written in unescaped, and the only one that never came
134
+ * out of the folder. Every name, package, decision and person goes through `esc()`.
101
135
  *
102
- * @param {{ project: object, register: object, summary: object, needs: object[],
103
- * blocks: object[], css: string, asOf: string, folderName: string,
104
- * files?: number, checksum?: string, unreadable?: number }} view
136
+ * @param {{ project: object, register: object, css: string, asOf: string, folderName: string,
137
+ * files?: number, checksum?: string, unreadable?: number, needs?: object[], previousAsOf?: string|null,
138
+ * estate?: unknown }} view
105
139
  * @returns {string}
106
140
  */
107
- export function buildStatusHtml({ project, register, summary, needs, blocks, css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, }) {
108
- // A file with no time on it, or with the wrong one, is worse than no file:
109
- // somebody will act on it. Refuse rather than print "never" or quietly stamp
110
- // the moment Save was pressed.
141
+ export function buildStatusHtml({ project, register, needs = [], css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, previousAsOf = null, estate = undefined, }) {
142
+ // A file with no time on it is worse than no file: somebody will act on it.
111
143
  if (!asOf)
112
144
  throw new Error('buildStatusHtml needs asOf — the time the folder was read. The file states it as the truth of the picture.');
113
- // The file says how many files were missing. A caller that reports the need
114
- // but not the count would have it say "0 files could not be read", which is
115
- // the opposite of what the need means.
145
+ // "0 files could not be read" would be the opposite of what the need means.
116
146
  if (needs?.some((i) => i.kind === 'unreadable') && !(Number.isFinite(unreadable) && unreadable > 0)) {
117
147
  throw new Error('buildStatusHtml needs unreadable — a need says files could not be read, and the file states how many.');
118
148
  }
119
- // `css` is ours, but this function is exported and pure, so a caller could one
120
- // day hand it something with a closing style tag in it, which would end the
121
- // stylesheet early and put the rest of it into the document as text — or as
122
- // markup. Refuse; do not rewrite it behind the caller's back.
149
+ // A closing style tag would end the stylesheet early. Refuse; never rewrite it.
123
150
  if (/<\/style/i.test(css))
124
151
  throw new Error('buildStatusHtml refuses css that closes the style element.');
125
- // "Reload in a minute" is true on the page and false in a file that cannot
126
- // reload. The page keeps the model's wording; the file says what a reader
127
- // holding a snapshot can actually expect. This is the ONE mark where the two
128
- // surfaces are allowed to differ.
129
- const forFile = (i) => (i.kind !== 'unreadable' ? i
130
- : { ...i, title: `${files(unreadable)} could not be read when this was made`, detail: UNREADABLE_DETAIL });
152
+ const view = reportView({ project, register, asOf, previousAsOf });
153
+ const trend = trendLine(view.trend);
154
+ const levelKey = `Levels: ${['A', 'B', 'C', 'D'].map((l) => `${l} ${LEVEL_KEY[l]}`).join('; ')}. ${FIX_RULE}`;
131
155
  return `<!doctype html>
132
156
  <html lang="en"><head><meta charset="utf-8">
133
157
  <meta http-equiv="Content-Security-Policy" content="${FILE_CSP}">
134
158
  <meta name="viewport" content="width=device-width, initial-scale=1">
159
+ <meta name="cspeach-as-of" content="${esc(asOf)}">
135
160
  <title>${esc(project.name)} — status as of ${esc(String(asOf).slice(0, 10))}</title>
136
161
  <style>${css}</style></head>
137
- <body><main id="app">
162
+ <body><main id="app" class="rp">
138
163
  <header class="masthead">
139
164
  <p class="provenance">${provenanceLine(project)}</p>
140
165
  <h1>${esc(project.name)}</h1>
141
166
  <p class="stamp" data-role="stamp">Status as of ${esc(formatWhen(asOf))}. This file does not update. Made by CSPeach from the folder ${esc(folderName)}.</p>
142
167
  ${unreadable > 0 ? `<p class="notice" data-role="incomplete">${files(unreadable)} could not be read, so this picture may be incomplete.</p>` : ''}
143
- <p class="summary" data-role="summary">${esc(summary.line)}</p>
144
- ${notMeasuredLine(register) ? `<p class="unmeasured" data-role="not-measured">${esc(notMeasuredLine(register))}</p>` : ''}
145
168
  </header>
146
- <section class="stands" data-role="stands"><h2>Where it stands</h2>
147
- ${whereItStandsHtml(register)}
148
- </section>
149
- <section class="dispositions"><h2>Decisions</h2>
150
- ${dispositionsHtml(register)}
151
- </section>
152
- <section class="needs" data-role="needs"><h2>Needs you</h2>
153
- ${needs.length ? `<ul>${needs.map(forFile).map(needItemHtml).join('')}</ul>` : '<p>Nothing is waiting for anyone.</p>'}
154
- </section>
155
- <section class="lanes" data-role="lanes"><h2>Who owns what</h2>
156
- ${developersTableHtml(register)}
157
- </section>
158
- <section class="map"><h2>Every object</h2>
159
- ${mapBodyHtml(register, blocks, { role: 'img' })}
160
- </section>
161
- <section class="runs" data-role="runs"><h2>Measurements</h2>
162
- ${runsTableHtml(register.runs)}
163
- ${ignoredRunsHtml(register.ignoredRuns)}
164
- </section>
165
- <section class="objects"><h2>The object list</h2>
166
- ${blocks.map((b) => packageFold(b, register.lanes, register.runs, project.type)).join('')}
167
- </section>
168
- <footer><p data-role="read-note">${esc(readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' }))}</p>
169
+ <div class="rp-first">
170
+ <div class="rp-coverage"><p class="rp-coverage-line" data-role="coverage">${esc(view.coverage.line)}</p>${trend ? `<p class="rp-trend" data-role="trend">${esc(trend)}</p>` : ''}${view.coverage.notes.map((note) => `<p class="rp-trend" data-role="coverage-note">${esc(note)}</p>`).join('')}</div>
171
+ <p class="rp-headline" data-role="headline">${esc(view.headline)}</p>
172
+ <p class="rp-scope" data-role="scope">${esc(view.scope)}${estate !== undefined && !register.estate ? ' The estate inventory in this folder could not be read.' : ''}</p>
173
+ ${numbersHtml(view)}
174
+ <div class="rp-band">
175
+ ${levelsHtml(view)}
176
+ ${decisionsHtml(view)}
177
+ </div>
178
+ ${changedHtml(view)}
179
+ </div>
180
+ ${lanesHtml(view)}
181
+ ${keptHtml(view)}
182
+ ${rebuildsHtml(view)}
183
+ <footer><p class="rp-key">${esc(levelKey)}</p>
184
+ <p data-role="read-note">${esc(`${readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' })}`.replace(/([^.])$/, '$1.'))}</p>
169
185
  <p data-role="trust">This file holds object names, packages, team member names, decisions, fix status and check results. No source code. No passwords. It contains no script and no links, and it cannot load anything from the internet.</p></footer>
170
186
  </main></body></html>`;
171
187
  }