@cspeach/cli 1.1.48 → 1.1.49

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 (101) hide show
  1. package/dist/agent/level.js +1 -1
  2. package/dist/agent/loop.js +7 -6
  3. package/dist/agent/sap-connection-adapter.js +2 -0
  4. package/dist/agent/system-prompt.js +2 -2
  5. package/dist/agent/tool-dispatch.js +13 -1
  6. package/dist/approvals/op-labels.js +49 -10
  7. package/dist/approvals/render.js +14 -23
  8. package/dist/approvals/risk-floor.js +196 -19
  9. package/dist/approvals/visible-changes.js +35 -0
  10. package/dist/commands/help.js +1 -1
  11. package/dist/commands/project.js +301 -0
  12. package/dist/commands/team-plan.js +16 -6
  13. package/dist/commands/team.js +295 -68
  14. package/dist/doctor/checks/zcspeach.js +96 -0
  15. package/dist/journey/chain.js +489 -61
  16. package/dist/journey/clean-core.js +61 -36
  17. package/dist/journey/flow.js +75 -2
  18. package/dist/journey/people.js +37 -0
  19. package/dist/journey/rebuild.js +7 -6
  20. package/dist/journey/run.js +129 -42
  21. package/dist/journey/trigger.js +16 -10
  22. package/dist/one-shot.js +1 -1
  23. package/dist/project-context/render.js +7 -0
  24. package/dist/projects/clean-core-confirm.js +166 -44
  25. package/dist/projects/email-template.js +56 -0
  26. package/dist/projects/extract-cca.js +8 -7
  27. package/dist/projects/index.js +0 -1
  28. package/dist/projects/merge-cca.js +0 -19
  29. package/dist/projects/merge-upgrade.js +2 -2
  30. package/dist/projects/project-folder.js +2 -1
  31. package/dist/projects/save-command.js +51 -14
  32. package/dist/register/decision-rules.js +2 -1
  33. package/dist/register/export-files.js +18 -0
  34. package/dist/register/picture.js +3 -3
  35. package/dist/register/project-transport.js +125 -0
  36. package/dist/register/records.js +25 -13
  37. package/dist/register/rules-edit.js +5 -0
  38. package/dist/register/status-file/progress-chart.js +17 -6
  39. package/dist/register/status-file/register-map.js +8 -5
  40. package/dist/register/status-file/register-model.js +55 -36
  41. package/dist/register/status-file/register-parts.js +97 -82
  42. package/dist/register/status-file/register-status-file.js +16 -7
  43. package/dist/register/status-file/report-model.js +293 -205
  44. package/dist/register/status-file/team-css.js +1 -1
  45. package/dist/register/status-html.js +11 -4
  46. package/dist/register/store.js +2 -2
  47. package/dist/register/workbooks.js +24 -13
  48. package/dist/renderer/footer-line.js +10 -2
  49. package/dist/renderer/tool-labels.js +2 -2
  50. package/dist/repl/builtin-commands.js +1 -1
  51. package/dist/repl/continue-skill.js +5 -0
  52. package/dist/repl/level-command.js +6 -15
  53. package/dist/repl.js +21 -8
  54. package/dist/router/router-core.js +5 -3
  55. package/dist/skill-catalog.js +4 -7
  56. package/dist/tools/_team-work-path.js +53 -0
  57. package/dist/tools/approval.js +97 -19
  58. package/dist/tools/filesystem/file-edit.js +8 -1
  59. package/dist/tools/filesystem/file-write.js +8 -1
  60. package/dist/tools/local-files.js +31 -0
  61. package/dist/tools/project/_merge-shared.js +2 -2
  62. package/dist/tools/project/_team-tool-shared.js +5 -0
  63. package/dist/tools/project/cca_merge.js +1 -11
  64. package/dist/tools/project/rules_propose.js +2 -1
  65. package/dist/tools/project/team_findings.js +81 -28
  66. package/dist/tools/project/team_lane_move.js +127 -0
  67. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  68. package/dist/tools/subagent/schedule_create.js +131 -0
  69. package/dist/tools/text-elements/tools.js +7 -1
  70. package/dist/tools/tool-loading.js +1 -1
  71. package/dist/tools/update-method-intercept.js +18 -1
  72. package/dist/tools/write-pipeline.js +19 -5
  73. package/dist/ui/approval-modal.js +6 -3
  74. package/dist/ui/error-emitter.js +21 -0
  75. package/dist/ui/footer-line.js +19 -1
  76. package/dist/ui/header.js +41 -0
  77. package/dist/ui/ink-classifier-route.js +19 -0
  78. package/dist/ui/rich-error-box.js +9 -0
  79. package/dist/ui/sidebar.js +12 -0
  80. package/dist/ui/status-emitter.js +19 -0
  81. package/dist/ui/status-line.js +62 -0
  82. package/dist/ui/status-row.js +38 -0
  83. package/dist/ui/todo-panel.js +85 -0
  84. package/dist/ui/widgets/ask-form.js +250 -0
  85. package/dist/ui/widgets/ask-question-modal.js +178 -0
  86. package/fiori/zlas-inspection-lr/package.json +26 -0
  87. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  88. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  89. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  90. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  91. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  92. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  93. package/live-check-2026-10-17-1343.txt +41 -0
  94. package/package.json +121 -121
  95. package/test-harness/fe-draft/app.ts +0 -188
  96. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  97. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  98. package/test-harness/fe-draft/routes.ts +0 -224
  99. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  100. package/test-harness/fe-draft/server.ts +0 -225
  101. package/test-harness/fe-draft/variants.ts +0 -196
@@ -1,14 +1,14 @@
1
1
  import path from 'node:path';
2
2
  import { buildRegister, narrowedLanes } from '@cspeach/register-core';
3
3
  import { collectRecords, readBaseline, readEstate, readLanes, readProjectFile } from './store.js';
4
- export function registerFor(projectDir, outboxDir) {
5
- const project = readProjectFile(projectDir);
4
+ /** `project`: the folder's project file when the caller has already read it (one read, one answer). */
5
+ export function registerFor(projectDir, outboxDir, project = readProjectFile(projectDir)) {
6
6
  const collected = collectRecords(projectDir);
7
7
  // This laptop's own outbox counts too, so a developer sees their work before
8
8
  // handing it in. It is also the one way this picture can legitimately differ
9
9
  // from the team project page's, so `status` says out loud when it counted them.
10
10
  const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
11
- const baseline = readBaseline(projectDir);
11
+ const baseline = readBaseline(projectDir, project);
12
12
  const lanes = readLanes(projectDir);
13
13
  // The newest discovery, when the folder holds one: the estate around the team project.
14
14
  const estate = readEstate(projectDir)?.estate;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Task 8 (B11) — the project transport: the request a team or solo project
3
+ * records its fixes in. Asked once at the start (the journey's transport
4
+ * card, `team start --transport`) or set later by the lead (`team transport`),
5
+ * and stored in the project file as `transport: { request, by, at }`.
6
+ *
7
+ * `projectTransport(cwd)` is what the approval card and the modify/delete
8
+ * write tools read: the request of the project joined in THIS folder, or
9
+ * null. A folder without a project, a copy of one, a project file halfway
10
+ * through a sync, a value that is not a request number — all null, never a
11
+ * throw: there the write and the card behave exactly as before.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { RegisterError, TEAM_FILE, activeProject, readProjectFile } from './store.js';
16
+ import { writeWhole } from './write-whole.js';
17
+ /** A transport request number as SAP writes it: three letters or digits, K, six digits. */
18
+ export const TRANSPORT_REQUEST = /^[A-Z0-9]{3}K\d{6}$/;
19
+ /**
20
+ * The project transport in words, said in one place (Task 3): the journey's
21
+ * lines (indented, `journey/run.ts`) and `team status` (after the target
22
+ * lines) print these.
23
+ */
24
+ export const TRANSPORT_NONE_WORDS = 'Transport: none yet (set it with: cspeach team transport <REQ>).';
25
+ /** The journey's line for the request: "Transport: S4HK900123." */
26
+ export const transportWords = (request) => `Transport: ${request}.`;
27
+ /** `team status`: the request, and that it is the project's: "Transport: S4HK900123 (project)." */
28
+ export const projectTransportWords = (request) => `Transport: ${request} (project).`;
29
+ /** The plain line for a number that is not a request number (shown as typed, cut short). */
30
+ export function notARequestLine(typed) {
31
+ const t = Array.from(typed.trim()).slice(0, 60).join('');
32
+ return `"${t}" is not a transport request number.`;
33
+ }
34
+ /**
35
+ * The same folder, said two ways (as commands/team-discover.ts samePath): on
36
+ * Windows case does not matter, and a trailing separator never does. Kept
37
+ * here so the register layer does not reach into the commands.
38
+ */
39
+ function samePath(a, b) {
40
+ const norm = (p) => {
41
+ const r = path.resolve(p).replace(/[\\/]+$/, '');
42
+ return process.platform === 'win32' ? r.toLowerCase() : r;
43
+ };
44
+ return norm(a) === norm(b);
45
+ }
46
+ /** The request of the project joined in this folder, or null. Never throws. */
47
+ export function projectTransport(cwd) {
48
+ try {
49
+ if (!cwd || !fs.existsSync(path.join(cwd, TEAM_FILE)))
50
+ return null;
51
+ const project = readProjectFile(cwd);
52
+ const joined = activeProject();
53
+ if (!joined || joined.projectId !== project.projectId || !samePath(joined.projectDir, cwd))
54
+ return null;
55
+ const t = project.transport;
56
+ const request = t && typeof t === 'object' ? t.request : undefined;
57
+ if (typeof request !== 'string')
58
+ return null;
59
+ const r = request.trim().toUpperCase();
60
+ return TRANSPORT_REQUEST.test(r) ? r : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * The project request a write falls back to (one order for every write tool:
68
+ * owning lock ?? explicit ?? this ?? session). A plan-grant id stays held to
69
+ * its own transport claim (args or session), so it never takes it.
70
+ */
71
+ export function projectTransportForWrite(ctx, payload) {
72
+ return payload?.grant ? undefined : projectTransport(ctx.cwd) ?? undefined;
73
+ }
74
+ /** The project request as a card and the footer say it: "S4HK903760 (project)". */
75
+ export const projectRequestLabel = (request) => `${request} (project)`;
76
+ /** The plan card's transport when its writes land in more than one request (each in the request its choice names). */
77
+ export const SEVERAL_REQUESTS_LABEL = 'several requests (each change goes to the request that holds it)';
78
+ /**
79
+ * Task 6 (project view, design §2 "Transport") — the ONE choice of the
80
+ * request a write goes to, for the approval card and for every write tool:
81
+ * - modify / delete: owning lock ?? explicit ?? project ?? session;
82
+ * - create: explicit ?? project ?? session (an owning lock is not a create's);
83
+ * - activate / release / run: none (those take no transport).
84
+ * Each caller hands in the facts it uses today: a 'resolve' write
85
+ * (set_source, update_method, delete) passes no session request; a
86
+ * 'fallback-resolve' write (class include, messages) and text-elements pass
87
+ * it. The card for a modify/delete passes none. So in a project folder the
88
+ * card and every write agree (the project request comes before the session);
89
+ * outside one, a fallback-resolve or text-elements modify can write under the
90
+ * session request while its card names none — today's behaviour, kept (byte
91
+ * rule). Outside a project folder `project` is null and the order is today's.
92
+ */
93
+ export function transportForWrite(i) {
94
+ // As the card and the writes always read them: a request named (explicit) or
95
+ // held for the session counts when it is there at all (??); an owning lock
96
+ // and the project request count only when they name one.
97
+ const pick = (from, request) => {
98
+ const there = from === 'owning' || from === 'project' ? !!request : request !== undefined && request !== null;
99
+ if (!there)
100
+ return null;
101
+ const r = request;
102
+ return { request: r, from, label: from === 'owning' ? `${r} (object already locked here)` : from === 'project' ? projectRequestLabel(r) : r };
103
+ };
104
+ const none = { request: undefined, from: 'none', label: undefined };
105
+ switch (i.op) {
106
+ case 'create':
107
+ return pick('explicit', i.explicit) ?? pick('project', i.project) ?? pick('session', i.session) ?? none;
108
+ case 'modify':
109
+ case 'delete':
110
+ return pick('owning', i.owning) ?? pick('explicit', i.explicit) ?? pick('project', i.project) ?? pick('session', i.session) ?? none;
111
+ default:
112
+ return none;
113
+ }
114
+ }
115
+ /**
116
+ * Set (or, with null, clear) the project transport. The whole project file is
117
+ * written, then renamed — a sync tool must never pick up half a project file.
118
+ */
119
+ export function writeProjectTransport(projectDir, entry) {
120
+ if (entry && !TRANSPORT_REQUEST.test(entry.request))
121
+ throw new RegisterError(notARequestLine(entry.request));
122
+ const { transport: _old, ...rest } = readProjectFile(projectDir);
123
+ const next = entry ? { ...rest, transport: entry } : rest;
124
+ writeWhole(path.join(projectDir, TEAM_FILE), `${JSON.stringify(next, null, 2)}\n`);
125
+ }
@@ -113,7 +113,9 @@ export function decisionRowsFromCca(content, projectRoot) {
113
113
  rows.set(key, {
114
114
  key, classification: cls,
115
115
  ...(['high', 'medium', 'low'].includes(confidence) ? { confidence: confidence } : {}),
116
- ...(typeof o?.classification?.evidence === 'string' ? { reason: o.classification.evidence.slice(0, 300) } : {}),
116
+ // The assessment's words, never a person's (project view §3.2): no one said Yes to them.
117
+ ...(typeof o?.classification?.evidence === 'string' && o.classification.evidence.trim()
118
+ ? { reason: o.classification.evidence.slice(0, 300), reasonFrom: 'proposal' } : {}),
117
119
  });
118
120
  }
119
121
  }
@@ -162,20 +164,25 @@ export function fixRowsFromUpgradeProgress(content, baseline) {
162
164
  }
163
165
  return { rows, ambiguous };
164
166
  }
167
+ /** A "Rule <id>: " a reason starts with — written by the model (or an older record), so code takes it off. */
168
+ const RULE_PREFIX = /^\s*rule\s+[A-Za-z0-9_-]{1,20}\s*:\s*/i;
169
+ /** A reason without a leading "Rule <id>:". Rule ids belong to the rules, never inside a recorded reason (project view Task 4). */
170
+ export const withoutRuleId = (reason) => reason.replace(RULE_PREFIX, '');
165
171
  /**
166
172
  * Decision rows from a saved clean-core triage — CONFIRMED rows only (one Yes
167
173
  * 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.
174
+ * classifications (spec §4.2). Project view §3.2: a row names a target only
175
+ * when a person picked it (no default A), and its reason says whose words it
176
+ * is — the person's when the confirm code marked them so (`reasonBy`), else
177
+ * the proposal's.
169
178
  */
170
179
  export function decisionRowsFromCleanCore(content,
171
180
  /**
172
- * Who said Yes, and when. With it, a confirmed row whose proposal NAMED the
173
- * level its target is read from — a fix/rebuild target, a keep/exception
174
- * level — carries `targetBy` (F3, M19): the person's Yes set that target.
175
- * A fix with no target in the proposal still records the legacy A, and that
176
- * is no target. Absent: rows exactly as before.
181
+ * Who said Yes, and when. With it, a confirmed row with a level carries
182
+ * `levelBy` (G8): the card showed that level and the person's Yes confirmed
183
+ * it. Absent: rows exactly as before.
177
184
  */
178
- by) {
185
+ by, opts = {}) {
179
186
  const rows = [];
180
187
  for (const r of content.adjudications ?? []) {
181
188
  if (r?.confirmed !== true)
@@ -188,14 +195,18 @@ by) {
188
195
  : r.disposition === 'retire' ? { classification: 'retire' } : null;
189
196
  if (!base)
190
197
  continue;
191
- const reason = typeof r.reason === 'string' ? r.reason.trim().slice(0, 300) : '';
198
+ const key = objectKey(r.objectType, r.objectName);
199
+ const reason = typeof r.reason === 'string' ? withoutRuleId(r.reason).trim().slice(0, 300) : '';
192
200
  const named = moves ? !!r.targetLevel : base.classification === 'keep' && !!r.level;
201
+ const picked = by !== undefined && named && opts.personPicked?.has(key) === true;
193
202
  rows.push({
194
- key: objectKey(r.objectType, r.objectName), ...base,
203
+ key, ...base,
195
204
  ...(r.level ? { level: r.level } : {}),
196
- ...(moves ? { targetLevel: r.targetLevel ?? 'A' } : {}),
197
- ...(reason ? { reason } : {}),
198
- ...(by && named ? { targetBy: { name: by.name, at: by.at } } : {}),
205
+ ...(by && r.level ? { levelBy: { name: by.name, at: by.at } } : {}),
206
+ // A target only beside the person who picked it: the proposal's is no target.
207
+ ...(moves && picked && r.targetLevel ? { targetLevel: r.targetLevel } : {}),
208
+ ...(reason ? { reason, reasonFrom: r.reasonBy === 'person' ? 'person' : 'proposal' } : {}),
209
+ ...(picked ? { targetBy: { name: by.name, at: by.at } } : {}),
199
210
  });
200
211
  }
201
212
  return rows;
@@ -225,6 +236,7 @@ opts = {}) {
225
236
  }
226
237
  if (env.artefactType === 'clean-core-decision') {
227
238
  // Every row here had a person's Yes (confirmCleanCoreRows / the journey's row card).
239
+ // No `personPicked`: a target the model proposed is no target (decision 2).
228
240
  const at = (opts.now ?? new Date()).toISOString();
229
241
  const rows = decisionRowsFromCleanCore(env.content, { name: by.name, at });
230
242
  return writeRecord(joined, 'decision', rows, by, { source, ...opts, now: opts.now ?? new Date(at) });
@@ -204,6 +204,11 @@ export const RULE_TEMPLATES = {
204
204
  'classic-program': {
205
205
  when: { types: ['PROG'], findingRules: ['SEARCH_PROBLEMATIC_STATEMENTS_FOR_ALV_CALLS'], targetAtMost: 'A' }, then: { disposition: 'rebuild' }, note: 'a classic list program cannot reach A in place',
206
206
  },
207
+ // Default rule R6 (project view design §7.2): the same words, for a project whose own rules lack it.
208
+ 'classic-program-fix': {
209
+ when: { types: ['PROG'], minFindings: 1 }, then: { disposition: 'fix' },
210
+ note: 'a classic program is fixed in place; a rebuild is decided with a target of A (cspeach team target) or by the lead',
211
+ },
207
212
  };
208
213
  /** The next free "R<n>" after the highest one in use. */
209
214
  function nextId(rules) {
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { esc } from './register-map.js';
24
24
  import { cell, dayWords, n, ownerLabel } from './register-parts.js';
25
+ import { viewOf } from './register-model.js';
25
26
  export const NO_RUN_YET = 'No check run yet, so there is no line to draw.';
26
27
  /** Back to front, so the line that matters most — proven — is drawn over the others. */
27
28
  const SERIES = [
@@ -95,16 +96,26 @@ export function progressChartSvg(points, { width = 640, height = 220, capped } =
95
96
  const barRow = (label, pct, proven, active, extra = '') => `<li><span class="pct-label">${label}</span> `
96
97
  + `<span class="pct-bar"><span class="pct-fill${extra} ${pctClass(pct)}"></span></span>`
97
98
  + `<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));
99
+ /**
100
+ * How far each lane and each focus is now, as bars with the numbers beside them.
101
+ * A lane's bar is the view's lane done (its own objects, the proven and clean
102
+ * parts over its active ones), the same Done % the developers table prints.
103
+ */
104
+ function doneBarsHtml(register, view) {
105
+ const done = new Map(view.lanes.map((s) => [s.scope.id, s.done]));
106
+ const lanes = (register.lanes ?? []).map((l) => {
107
+ const d = done.get(l.id) ?? { pct: 0, proven: 0, active: 0 };
108
+ return barRow(cell(ownerLabel(l)), d.pct, d.proven, d.active);
109
+ });
101
110
  const focuses = (register.plan?.focuses ?? []).map((f) => barRow(`Focus: ${cell(f.label)}`, f.donePct, f.proven, f.active, ' f-focus'));
102
111
  if (!lanes.length && !focuses.length)
103
112
  return '';
104
113
  return '<h3>Done now, per developer and focus</h3>'
105
114
  + `<ul class="pct-bars" data-role="done-bars">${[...lanes, ...focuses].join('')}</ul>`;
106
115
  }
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);
116
+ /** The Progress over time section's body, as the page and the status file both draw it. `view`: the project view (built once by the page). */
117
+ export function progressSectionHtml(register, view) {
118
+ // No lanes: no lane bar, so no view is needed (an empty folder has no objects to read one from).
119
+ const lanes = register.lanes?.length ? viewOf(register, view) : { lanes: [] };
120
+ return progressChartSvg(register.progress, { capped: register.progressCapped }) + doneBarsHtml(register, lanes);
110
121
  }
@@ -161,11 +161,14 @@ export function blockSvg(block, { cell = 10, gap = 2, role = 'group', colourBy =
161
161
  // leave retired objects out, and a cell painted C that no count includes
162
162
  // would show a manager two numbers for one folder.
163
163
  //
164
- // A clean (or verified) object nobody decided has no level and nothing to
165
- // decide: it keeps its state colour too, and its title says so — never
166
- // "level not decided" (register-core leaves it out of the level counts).
167
- const nothing = byLevel && o.inScope !== false && !o.decision && !LEVEL_SET.has(o.levelNow) && (o.state === 'clean' || o.state === 'verified');
168
- const asLevel = byLevel && o.state !== 'retired' && !nothing;
164
+ // A clean (or verified) object nobody decided has nothing to decide: it
165
+ // keeps its state colour too, and its title says so — never "level A" or
166
+ // "level not decided". Its level now may read A from the latest check, but
167
+ // the level counts put it in "clean, nothing to decide", and the map must
168
+ // agree with them. An out-of-scope object keeps its state colour as well:
169
+ // the level counts cover active objects only.
170
+ const nothing = byLevel && o.inScope !== false && !o.decision && (o.state === 'clean' || o.state === 'verified');
171
+ const asLevel = byLevel && o.inScope !== false && o.state !== 'retired' && !nothing;
169
172
  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
173
  + `<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
174
  }).join('');
@@ -8,11 +8,14 @@
8
8
  * Pure: a Register in, plain data out. No DOM and no clock — `now` is handed
9
9
  * in — so the page, the status file and the tests all get the same answer.
10
10
  *
11
- * "Proven" means the system said so: verified (had findings, a later
12
- * measurement has none) or clean (measured, never had any). A developer's
13
- * "fixed" is a claim and is never counted here.
11
+ * "Proven" means the system said so: the project view's proven part (a fix the
12
+ * check proved, also for its own target) or clean (measured, never had any). A
13
+ * developer's "fixed" is a claim and is never counted here. Every count of
14
+ * objects reads the project view (register-core `projectView`), which the
15
+ * caller builds once and hands in, so the page, the status file and the
16
+ * terminal share one definition of each word.
14
17
  */
15
- import { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf } from '@cspeach/register-core';
18
+ import { activeOf, laneActiveOf, laneProvenOf, projectView, provenOf, settledOf } from '@cspeach/register-core';
16
19
  /**
17
20
  * The three counts every surface divides by are defined once, in register-core,
18
21
  * where the terminal reads them too. They are passed on from here so that
@@ -20,6 +23,13 @@ import { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf } from '@cspe
20
23
  * status file — takes its numbers from the model, like everything else it draws.
21
24
  */
22
25
  export { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf };
26
+ /**
27
+ * The project view these words read. The page and the status file build it once
28
+ * (`collectView`) and hand it in; a caller holding only a register (a test, an
29
+ * older caller) gets one built from the register alone: the same parts, with no
30
+ * project file to name the lead or a project target's setter.
31
+ */
32
+ export const viewOf = (register, view) => view ?? projectView(register, { kind: 'team-project', formatVersion: 1, projectId: register.projectId, name: '', type: 'clean-core', lead: { name: 'the lead' } });
23
33
  /** Worst first: the order cells are banded in a block and listed in a legend. */
24
34
  export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
25
35
  export const STATE_LABEL = {
@@ -119,46 +129,45 @@ function upTo(parts, { tail, cap = LIST_CAP, sep = ', ', more } = {}) {
119
129
  * normal pairing. Counting the ways would tell the manager four things need
120
130
  * attention when three objects do, so this counts objects.
121
131
  */
122
- function attentionKeys(register) {
123
- const keys = new Set();
124
- for (const o of register.objects) {
125
- if (o.state === 'regressed' || o.state === 'did-not-hold' || o.decision?.conflict)
126
- keys.add(o.key);
127
- }
128
- return keys;
132
+ /** Kinds of the view's needs that call for attention on the map: the system disagrees, or the writers do. */
133
+ const ATTENTION = new Set(['conflict', 'failed', 'regressed']);
134
+ function attentionKeys(view) {
135
+ return new Set(view.all.needs.filter((x) => ATTENTION.has(x.kind)).map((x) => x.key));
129
136
  }
130
- export function summarize(register, { unreadable = 0 } = {}) {
131
- const { totals } = register;
132
- const proven = provenOf(totals.byState);
137
+ /** The view's proven part and clean part: what a check run says, over active objects. */
138
+ const provenCount = (view) => view.all.parts.proven + view.all.parts.clean;
139
+ export function summarize(register, { unreadable = 0, view } = {}) {
140
+ view = viewOf(register, view);
141
+ const all = view.all;
142
+ const proven = provenCount(view);
133
143
  // Floor, never round: 7,997 of 8,000 is not 100% proven, and a manager who
134
144
  // reads 100% stops looking. Only every last object earns the round number.
135
- const provenPct = totals.active ? Math.floor((proven / totals.active) * 100) : 0;
136
- const needAttention = attentionKeys(register).size;
145
+ const provenPct = all.active ? Math.floor((proven / all.active) * 100) : 0;
146
+ const needAttention = attentionKeys(view).size;
137
147
  const lastMeasured = register.lastMeasuredAt ? `last measured ${formatWhen(register.lastMeasuredAt)}` : 'never measured';
138
148
  // A project where every object was retired has nothing left to prove, and
139
149
  // "0% proven" would read as a team that proved nothing. An empty project
140
150
  // keeps the percentage: its map already says there is nothing in scope yet.
141
- const provenPhrase = totals.objects > 0 && totals.active === 0 ? 'nothing left to prove' : `${provenPct}% proven`;
151
+ // "of active": the percentage divides by the active objects, never by all of them (Task 3).
152
+ const provenPhrase = all.total > 0 && all.active === 0 ? 'nothing left to prove' : `${provenPct}% of active proven`;
142
153
  // A proof read out of a file is still a proof, and it is not the same thing
143
154
  // as a run of the system. The one line a manager reads says how much of what
144
155
  // it just called proven came that way, right beside the word.
145
- const fromFile = (totals.fromFile?.verified ?? 0) + (totals.fromFile?.clean ?? 0);
156
+ const fromFile = all.objects.filter((v) => (v.part === 'proven' || v.part === 'clean') && v.proof.basis === 'file').length;
146
157
  const fromFilePhrase = fromFile > 0 ? ` · ${count(fromFile)} of them ${FROM_FILE}` : '';
147
- // A folder from an older build carries no `undecided`; work it out the same
148
- // way register-core does, so the line never prints a blank.
149
- const decided = totals.decided ?? 0;
158
+ const decided = all.objects.filter((v) => v.decision.what !== 'none').length;
150
159
  // 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);
160
+ // to decide. It is counted on its own; it is never "undecided".
161
+ const nothingToDecide = all.levels.nothingToDecide;
162
+ const undecided = Math.max(0, all.total - decided - nothingToDecide);
154
163
  const decidedPhrase = nothingToDecide > 0
155
164
  ? `${count(decided)} decided${undecided > 0 ? `, ${count(undecided)} undecided` : ''} · ${count(nothingToDecide)} ${NOTHING_TO_DECIDE}`
156
165
  : `${count(decided)} decided, ${count(undecided)} undecided`;
157
166
  return {
158
- objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
167
+ objects: all.total, active: all.active, proven, provenPct, decided, undecided, needAttention,
159
168
  lastMeasured, unreadable, fromFile,
160
169
  ...(nothingToDecide > 0 ? { nothingToDecide } : {}),
161
- line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${decidedPhrase} · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
170
+ line: `${plural(all.total, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${decidedPhrase} · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
162
171
  };
163
172
  }
164
173
  const DAY_MS = 86_400_000;
@@ -167,19 +176,26 @@ const DAY_MS = 86_400_000;
167
176
  * one mark: where a cut list of OBJECTS sends the reader. On the page that is
168
177
  * the map, where a cell opens. In the status file the map is a picture, so it
169
178
  * is the object list the file carries instead.
179
+ *
180
+ * What waits for a person about one object — a regression, a failed fix, a
181
+ * conflict, an exception with no reason, a retire candidate — is the view's
182
+ * needs list, the same list the status page prints (one line per object, a fix
183
+ * proven for its own target is not a failed fix).
170
184
  */
171
- export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface = 'page' } = {}) {
185
+ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface = 'page', view } = {}) {
186
+ view = viewOf(register, view);
172
187
  const items = [];
173
- const inState = (state) => register.objects.filter((o) => o.state === state);
188
+ const byKey = new Map(register.objects.map((o) => [o.key, o]));
189
+ const needing = (kind) => view.all.needs.filter((x) => x.kind === kind).map((x) => byKey.get(x.key)).filter(Boolean);
174
190
  // The four lists below name objects, and an object is a cell. Everything
175
191
  // else here names something the map does not draw.
176
192
  const onMap = surface === 'file' ? seeObjectList : seeMap;
177
- const regressed = inState('regressed');
193
+ const regressed = needing('regressed');
178
194
  if (regressed.length) {
179
195
  items.push({ kind: 'regressed', title: `${plural(regressed.length, 'object has', 'objects have')} more findings than before`,
180
196
  detail: upTo(regressed.map((o) => o.name), { tail: onMap }), keys: regressed.map((o) => o.key) });
181
197
  }
182
- const didNotHold = inState('did-not-hold');
198
+ const didNotHold = needing('failed');
183
199
  if (didNotHold.length) {
184
200
  // Objects, not fixes. One object can carry three failed fixes, and "3 fixes
185
201
  // did not hold" sends a lead looking for three things to put right.
@@ -194,7 +210,7 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
194
210
  }), { tail: onMap }),
195
211
  keys: didNotHold.map((o) => o.key) });
196
212
  }
197
- const conflicts = register.objects.filter((o) => o.decision?.conflict);
213
+ const conflicts = needing('conflict');
198
214
  if (conflicts.length) {
199
215
  items.push({ kind: 'conflict', title: `${plural(conflicts.length, 'decision needs', 'decisions need')} the lead to rule`,
200
216
  detail: upTo(conflicts.map((o) => `${o.name} — ${Object.entries(o.decision.conflict).map(([w, c]) => `${w}: ${c}`).join(', ')}`), { tail: onMap, sep: '; ' }),
@@ -203,7 +219,7 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
203
219
  // An accepted exception is work nobody has to do — on a reason somebody gave.
204
220
  // One with no reason (an older record, a hand-written file) is a decision the
205
221
  // lead cannot defend yet.
206
- const noReason = register.objects.filter((o) => o.decision?.classification === 'keep' && o.decision?.exception === true && !o.decision?.reason);
222
+ const noReason = needing('reason');
207
223
  if (noReason.length) {
208
224
  items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
209
225
  detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
@@ -211,7 +227,9 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
211
227
  // A developer's "retire" is a candidate until the lead confirms it with a
212
228
  // reason (spec 2026-10-06 D2). Until then the object stays active, and the
213
229
  // lead is the one person who can move it.
214
- const candidates = register.objects.filter((o) => o.retireCandidate === true);
230
+ // From the decision, as the tiles and team status count them: a candidate that waits for a conflict first is still one.
231
+ const candidates = view.all.objects.filter((v) => v.decision.what === 'retire-candidate' && v.part !== 'retired' && v.part !== 'out-of-scope')
232
+ .map((v) => byKey.get(v.key)).filter(Boolean);
215
233
  if (candidates.length) {
216
234
  items.push({ kind: 'retire-candidate', title: `${plural(candidates.length, 'retire candidate waits', 'retire candidates wait')} for your confirmation`,
217
235
  detail: upTo(candidates.map((o) => o.name), { tail: onMap }), keys: candidates.map((o) => o.key) });
@@ -223,7 +241,7 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
223
241
  items.push({ kind: 'out-of-scope-no-reason', title: `${plural(outNoReason.length, 'object was', 'objects were')} taken out of scope with no reason`,
224
242
  detail: upTo(outNoReason.map((o) => o.name), { tail: onMap }), keys: outNoReason.map((o) => o.key) });
225
243
  }
226
- const unowned = register.objects.filter((o) => o.lane === null);
244
+ const unowned = view.unowned.objects;
227
245
  if (unowned.length) {
228
246
  const packages = [...new Set(unowned.map((o) => o.package))].sort();
229
247
  items.push({ kind: 'unowned', title: `${plural(unowned.length, 'object has', 'objects have')} no owner`,
@@ -311,9 +329,10 @@ export function packageBlocks(register) {
311
329
  /**
312
330
  * Active objects nobody has measured. Their colour rests on what people said,
313
331
  * and the page says so out loud: "not measured" is a state of knowledge, not a
314
- * blank. A retired object is not counted — there is nothing left to prove.
332
+ * blank. A retired or out-of-scope object is not counted — there is nothing
333
+ * left to prove. The view's coverage, the same number the status page prints.
315
334
  */
316
- export const notMeasuredCount = (register) => register.objects.filter((o) => o.state !== 'retired' && (o.lastMeasuredAt ?? null) === null).length;
335
+ export const notMeasuredCount = (register, view) => viewOf(register, view).all.coverage.notChecked;
317
336
  /**
318
337
  * One object's measurements, newest first: when, by whom, how many open, and
319
338
  * whether the numbers came from a system check or from a file somebody read in.