@cspeach/cli 1.1.29 → 1.1.30

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 (38) hide show
  1. package/dist/agent/loop.js +6 -3
  2. package/dist/auth/auth-file.js +2 -2
  3. package/dist/cli.js +4 -0
  4. package/dist/commands/login.js +1 -1
  5. package/dist/commands/logout.js +1 -1
  6. package/dist/commands/team.js +298 -26
  7. package/dist/commands/whoami.js +1 -1
  8. package/dist/config/paths.js +15 -1
  9. package/dist/one-shot.js +1 -0
  10. package/dist/projects/clean-core-confirm.js +36 -0
  11. package/dist/projects/extract-clean-core.js +79 -0
  12. package/dist/projects/save-command.js +38 -9
  13. package/dist/projects/validate.js +2 -1
  14. package/dist/projects/workspace.js +2 -1
  15. package/dist/register/atc-file.js +9 -1
  16. package/dist/register/findings.js +79 -0
  17. package/dist/register/measure.js +60 -1
  18. package/dist/register/offer.js +33 -0
  19. package/dist/register/picture.js +21 -0
  20. package/dist/register/records.js +32 -0
  21. package/dist/register/status-file/register-map.js +213 -0
  22. package/dist/register/status-file/register-model.js +307 -0
  23. package/dist/register/status-file/register-parts.js +509 -0
  24. package/dist/register/status-file/register-status-file.js +171 -0
  25. package/dist/register/status-file/team-css.js +5 -0
  26. package/dist/register/status-file-sync.js +19 -0
  27. package/dist/register/status-html.js +17 -0
  28. package/dist/register/workbooks.js +104 -0
  29. package/dist/register/write-whole.js +22 -0
  30. package/dist/register/xlsx.js +117 -0
  31. package/dist/renderer/tool-labels.js +2 -1
  32. package/dist/repl.js +1 -0
  33. package/dist/skills/bundled-skills.js +45 -45
  34. package/dist/tools/_filesystem-shared.js +16 -9
  35. package/dist/tools/project/team_findings.js +72 -0
  36. package/dist/tools/sap-write.js +8 -0
  37. package/dist/tools/tool-loading.js +1 -1
  38. package/package.json +2 -2
@@ -7,6 +7,7 @@ import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from
7
7
  import { extractCcaAssessment } from './extract-cca.js';
8
8
  import { extractModernizeResult } from './extract-modernize.js';
9
9
  import { extractTestCoverage } from './extract-test-coverage.js';
10
+ import { extractCleanCoreDecision } from './extract-clean-core.js';
10
11
  import { extractPlan, laneConflictsWithPrior, LANE_NEW_FAMILY } from './extract-plan.js';
11
12
  import { buildEnvelope } from './build.js';
12
13
  import { buildPlanRevision } from './plan-run.js';
@@ -103,6 +104,14 @@ const SKILL_REGISTRY = {
103
104
  itemNoun: 'phases',
104
105
  manifestMarker: 'plan-manifest',
105
106
  },
107
+ 'abap-clean-core': {
108
+ // 2026-10-06 (clean-core mission control): a triage table. The rows become
109
+ // team project decisions only after one Yes per row (clean-core-confirm.ts).
110
+ artefactType: 'clean-core-decision',
111
+ extract: (md) => extractCleanCoreDecision(md),
112
+ itemNoun: 'decisions',
113
+ manifestMarker: 'clean-core-manifest',
114
+ },
106
115
  };
107
116
  /**
108
117
  * C1 (2026-06-11, defect D22) — map an inline artifact-manifest block in the
@@ -128,6 +137,8 @@ export function detectArtifactSkill(markdown) {
128
137
  return 'abap-modernize';
129
138
  if (/<!--\s*(?:csforge|cspeach):test-coverage-manifest\b/.test(markdown))
130
139
  return 'abap-test';
140
+ if (/<!--\s*(?:csforge|cspeach):clean-core-manifest\b/.test(markdown))
141
+ return 'abap-clean-core';
131
142
  const up = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
132
143
  if (up) {
133
144
  const art = /(?:^|\n)\s*artefact:\s*(\S+)/.exec(up[1] ?? '');
@@ -360,22 +371,33 @@ export async function runSaveCommand(args) {
360
371
  }
361
372
  // C1 review fix — name the revision target BEFORE the confirm so the user
362
373
  // knows what a 'y' will do. Log line only, deliberately NOT a second
363
- // question: the B5 headless contract (below) allows at most one prompt.
374
+ // question: the B5 headless contract (below) allows one `prompt` question.
364
375
  if (planExtract && prior) {
365
376
  const priorFilename = prior.path.split(/[\\\/]/).pop();
366
377
  args.log(`[save] this will save as v${prior.envelope.version + 1} of existing plan '${prior.envelope.title}' (${priorFilename})`);
367
378
  }
368
- // CONTRACT (B5): runSaveCommand may ask the user at most ONE question —
369
- // this save confirm. The headless path in loop.ts (maybeOfferSave) installs
370
- // a prompt that blanket-answers 'y' to WHATEVER it is asked; any second
371
- // question added here would silently receive 'y' too. If you need another
372
- // prompt, give maybeOfferSave's headless prompt a question-aware answer
373
- // first.
379
+ // CONTRACT (B5): runSaveCommand asks 1+N cards — this save confirm, then
380
+ // (clean-core decisions only) one card per row. Only the save confirm may
381
+ // fall back to `prompt`: the headless path in loop.ts (maybeOfferSave)
382
+ // installs a prompt that blanket-answers 'y' to WHATEVER it is asked, so
383
+ // the row cards go through `confirm` only (never `prompt`), and a headless
384
+ // run confirms no row. Any further question routed through `prompt` would
385
+ // silently receive 'y' too: give maybeOfferSave's headless prompt a
386
+ // question-aware answer first.
374
387
  const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
375
388
  if (!isYes(answer)) {
376
389
  args.onNotSaved?.('declined');
377
390
  return null;
378
391
  }
392
+ // 2026-10-06 — a clean-core triage becomes team project decisions only row by
393
+ // row, each on its own Yes. Without a joined team project nothing is asked.
394
+ if (spec.artefactType === 'clean-core-decision') {
395
+ const { confirmCleanCoreRows } = await import('./clean-core-confirm.js');
396
+ const { activeProject } = await import('../register/store.js');
397
+ extract = await confirmCleanCoreRows(extract, {
398
+ ...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined: activeProject() !== null,
399
+ });
400
+ }
379
401
  let env;
380
402
  if (planExtract && prior) {
381
403
  // C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
@@ -438,9 +460,16 @@ export async function runSaveCommand(args) {
438
460
  const joined = activeProject();
439
461
  if (joined) {
440
462
  const { recordFromEnvelope } = await import('../register/records.js');
441
- const res = recordFromEnvelope(env, path, joined, { name: args.author.name }, args.cwd);
463
+ const res = recordFromEnvelope(env, path, joined, { name: joined.name ?? args.author.name }, args.cwd);
442
464
  if (res?.file)
443
- args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${res.file}`);
465
+ args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${basename(res.file)}`);
466
+ if (res?.file) {
467
+ const { regenerateWorkingList } = await import('../register/workbooks.js');
468
+ // The name the team project knows this laptop by (init --lead), as the team verbs use.
469
+ const sheet = regenerateWorkingList(joined, joined.name ?? args.author.name);
470
+ if (sheet.line)
471
+ args.log(sheet.line.trimStart());
472
+ }
444
473
  if (res?.refused.length) {
445
474
  args.log(`Not recorded (${res.refused.length} not in the team project's object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
446
475
  }
@@ -5,7 +5,7 @@ const KNOWN_ARTEFACT_TYPES = [
5
5
  'upgrade-scan', 'upgrade-progress', 'upgrade-report',
6
6
  'cca-assessment',
7
7
  'modernize-result', 'test-coverage',
8
- 'plan',
8
+ 'plan', 'clean-core-decision',
9
9
  ];
10
10
  const STATUS_BY_TYPE = {
11
11
  'spec-gap': ['open', 'answered', 'deferred', 'n-a'],
@@ -20,6 +20,7 @@ const STATUS_BY_TYPE = {
20
20
  'modernize-result': ['applied', 'skipped', 'failed', 'pending'],
21
21
  'test-coverage': ['generated', 'skipped', 'failed', 'pending'],
22
22
  'plan': ['todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked'],
23
+ 'clean-core-decision': ['proposed', 'confirmed', 'declined'],
23
24
  };
24
25
  const REQUIRED_TOP_LEVEL = [
25
26
  'schemaVersion', 'artefactType', 'id', 'title',
@@ -27,6 +27,7 @@ import { dirname, join, isAbsolute } from 'node:path';
27
27
  import os from 'node:os';
28
28
  import toml from '@iarna/toml';
29
29
  import { loadConfig } from '../config/loader.js';
30
+ import { configFile } from '../config/paths.js';
30
31
  /**
31
32
  * Walk up the directory tree from `startDir` looking for a `.git` entry
32
33
  * (file or directory — submodules use a file). Returns the directory
@@ -152,7 +153,7 @@ export async function ensureWorkspace() {
152
153
  export function ensureWorkspaceSync() {
153
154
  let target = defaultWorkspaceFor(process.cwd());
154
155
  try {
155
- const cfgPath = join(os.homedir(), '.cspeach', 'config.toml');
156
+ const cfgPath = configFile();
156
157
  if (existsSync(cfgPath)) {
157
158
  const raw = readFileSync(cfgPath, 'utf-8');
158
159
  const parsed = toml.parse(raw);
@@ -254,6 +254,14 @@ const quoteNeverClosed = (line) => `A quotation mark in line ${line} is never cl
254
254
  * binary on one line — none of which may reach a person through CSPeach.
255
255
  */
256
256
  export function shown(text) {
257
+ return shownUpTo(text, 60);
258
+ }
259
+ /**
260
+ * The same cleaning as `shown()`, with a longer cap: for text a person wrote on
261
+ * purpose (a decision's reason), which is read whole, not glanced at in a list.
262
+ * Kept to `max` characters, then "…".
263
+ */
264
+ export function shownUpTo(text, max) {
257
265
  const flat = String(text)
258
266
  // An OSC sequence runs to BEL or ESC-backslash and can retitle the window.
259
267
  .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)?/g, ' ')
@@ -268,7 +276,7 @@ export function shown(text) {
268
276
  .replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ')
269
277
  .replace(/\s+/g, ' ')
270
278
  .trim();
271
- return flat.length > 60 ? `${flat.slice(0, 60)}…` : flat;
279
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
272
280
  }
273
281
  /** Five names and the count of the rest: a list nobody can read is a list nobody acts on. */
274
282
  export const upToFive = (items) => (items.length > 5 ? `${items.slice(0, 5).join(', ')} and ${items.length - 5} more` : items.join(', '));
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Rule by rule, what the runs found in each object — the detail the register
3
+ * itself keeps only as counts. Read from the same measurement records, with the
4
+ * register's own two rules: only the team project's check variant counts, and
5
+ * live evidence outranks file evidence, per object.
6
+ *
7
+ * Used by `cspeach team fixed` (which rules are open now) and by the read-only
8
+ * team_findings tool (what a triage proposes from). No source code is in here:
9
+ * a record carries a rule id and a message title, nothing more.
10
+ */
11
+ import { normalizeKey, normalizeRuleId, UNNAMED_FINDING } from '@cspeach/register-core';
12
+ import { shown } from './atc-file.js';
13
+ import { collectRecords, readProjectFile } from './store.js';
14
+ /** Re-exported for the read-only team_findings tool: the one cleaning, a longer cap. */
15
+ export { shownUpTo } from './atc-file.js';
16
+ const variantKey = (v) => (typeof v === 'string' ? v.trim().toUpperCase() : '');
17
+ const basisOf = (v) => {
18
+ if (v === undefined || v === null)
19
+ return 'system';
20
+ const w = String(v).trim().toLowerCase();
21
+ return w === '' || w === 'system' ? 'system' : 'file';
22
+ };
23
+ export function findingsByObject(projectDir, outboxDir) {
24
+ const project = readProjectFile(projectDir);
25
+ const ours = variantKey(project.atcVariant);
26
+ const records = [
27
+ ...collectRecords(projectDir).records,
28
+ ...(outboxDir && outboxDir !== projectDir ? collectRecords(outboxDir).records : []),
29
+ ];
30
+ const runs = records
31
+ .filter((r) => r?.kind === 'record' && r.recordType === 'measurement' && r.projectId === project.projectId
32
+ && typeof r.recordId === 'string' && Number.isFinite(Date.parse(r.at)) && Array.isArray(r.rows))
33
+ .filter((r) => { const theirs = variantKey(r.atcVariant); return !(theirs && ours && theirs !== ours); })
34
+ .map((r) => ({ ...r, at: new Date(r.at).toISOString() }))
35
+ .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.recordId < b.recordId ? -1 : a.recordId > b.recordId ? 1 : 0));
36
+ const seenIds = new Set();
37
+ const byKey = new Map();
38
+ for (const r of runs) {
39
+ if (seenIds.has(r.recordId))
40
+ continue; // a sync tool's "(1)" copy
41
+ seenIds.add(r.recordId);
42
+ for (const row of r.rows) {
43
+ if (typeof row?.key !== 'string' || !Array.isArray(row.findings))
44
+ continue;
45
+ const key = normalizeKey(row.key);
46
+ const list = byKey.get(key) ?? [];
47
+ let seen = list.find((s) => s.recordId === r.recordId);
48
+ if (!seen) {
49
+ seen = { at: r.at, recordId: r.recordId, basis: basisOf(r.basis), rules: new Map() };
50
+ list.push(seen);
51
+ }
52
+ for (const f of row.findings) {
53
+ const named = !!f && typeof f === 'object';
54
+ const rule = (named ? normalizeRuleId(f.rule) : typeof f === 'string' ? normalizeRuleId(f) : '') || UNNAMED_FINDING;
55
+ // `shown()`: the title was written on another laptop or came out of a
56
+ // handed-over file, and it goes to a terminal and to the model's tools.
57
+ const message = named && typeof f.message === 'string' ? shown(f.message) : '';
58
+ const had = seen.rules.get(rule);
59
+ if (had) {
60
+ had.count += 1;
61
+ if (!had.message && message)
62
+ had.message = message;
63
+ }
64
+ else
65
+ seen.rules.set(rule, { rule, message, count: 1 });
66
+ }
67
+ byKey.set(key, list);
68
+ }
69
+ }
70
+ const out = new Map();
71
+ for (const [key, list] of byKey) {
72
+ const driving = list.some((s) => s.basis === 'system') ? list.filter((s) => s.basis === 'system') : list;
73
+ const first = driving[0] ?? null;
74
+ const last = driving[driving.length - 1] ?? null;
75
+ const rules = (s) => (s ? [...s.rules.values()].sort((a, b) => (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0)) : []);
76
+ out.set(key, { key, baselineAt: first?.at ?? null, baseline: rules(first), latestAt: last?.at ?? null, latest: rules(last) });
77
+ }
78
+ return out;
79
+ }
@@ -39,6 +39,8 @@ import { writeRecord } from './records.js';
39
39
  * row they get says so.
40
40
  */
41
41
  export const ABSENT_MEANS_CLEAN = false;
42
+ /** R-1: how many silent objects one measurement checks again, one run each. */
43
+ export const MAX_SECOND_PASS = 200;
42
44
  export function scopeLabel(scope) {
43
45
  if (scope.kind === 'lane')
44
46
  return `lane:${scope.id.trim()}`;
@@ -257,6 +259,7 @@ function readOutcome(answer) {
257
259
  return o;
258
260
  }
259
261
  export async function measure(input) {
262
+ const started = Date.now();
260
263
  const label = scopeLabel(input.scope);
261
264
  // One object, one row, however often the baseline names it: a case-twin would
262
265
  // otherwise be asked for twice and counted twice.
@@ -326,6 +329,60 @@ export async function measure(input) {
326
329
  if (!(said === 'absent' && evidenceOf.get(key) === 'listed'))
327
330
  evidenceOf.set(key, said);
328
331
  }
332
+ // ── R-1: the objects the run did not list, each checked again on its own ──
333
+ let secondPass;
334
+ let notChecked = new Set();
335
+ let ownFindings = [];
336
+ if (input.confirmAbsent && !absentMeansClean && !outcome.stopped && !input.signal?.aborted) {
337
+ const silent = inScope.filter((o) => buckets.get(objectKey(o.type, o.name)) === 'absent');
338
+ if (silent.length) {
339
+ const toRun = silent.slice(0, Math.max(0, input.secondPassCap ?? MAX_SECOND_PASS));
340
+ const skipped = silent.slice(toRun.length).map((o) => objectKey(o.type, o.name));
341
+ const left = input.maxTotalMs ? input.maxTotalMs - (Date.now() - started) : undefined;
342
+ if (toRun.length && (left === undefined || left > 0)) {
343
+ const again = readOutcome(await input.atc.runSet(toRun.map((o) => ({ type: o.type, name: o.name })), variant, {
344
+ chunkSize: 1, maxSingleRuns: 0,
345
+ ...(input.signal ? { signal: input.signal } : {}),
346
+ ...(left !== undefined ? { maxTotalMs: left } : {}),
347
+ ...(input.onProgress ? { onProgress: input.onProgress } : {}),
348
+ }));
349
+ const asked = new Set(toRun.map((o) => objectKey(o.type, o.name)));
350
+ // Only a run that LISTED the object counts. A refused or cut-short own run
351
+ // leaves it exactly where the first run left it: absent, no row.
352
+ const seen = new Set(again.measured.filter((m) => m?.evidence === 'listed').map(refKey).filter((k) => asked.has(k)));
353
+ for (const key of seen) {
354
+ claims.get(key)?.delete('absent');
355
+ claim(key, 'measured');
356
+ buckets.set(key, 'measured');
357
+ evidenceOf.set(key, 'listed');
358
+ }
359
+ ownFindings = again.findings.filter((f) => typeof f?.key === 'string' && seen.has(normalizeKey(f.key)));
360
+ // An own run that FINISHED and still did not list the object is the
361
+ // variant's silence, said twice. Anything else — refused, never reached —
362
+ // was not checked again, and nothing may be said about why.
363
+ const didNotFinish = new Set([
364
+ ...again.failed.map(refKey),
365
+ ...again.chunks.filter((c) => c?.status === 'refused' && !c.superseded).flatMap((c) => list(c.asked).map(refKey)),
366
+ ]);
367
+ const silentAgain = new Set(again.measured.filter((m) => m?.evidence !== 'listed').map(refKey)
368
+ .filter((k) => asked.has(k) && !seen.has(k) && !didNotFinish.has(k)));
369
+ const unreached = [...asked].filter((k) => !seen.has(k) && !silentAgain.has(k));
370
+ const stopped = STOPPED_BY.has(again.stopped) ? again.stopped
371
+ : input.signal?.aborted ? 'aborted' : undefined;
372
+ notChecked = new Set([...unreached, ...skipped]);
373
+ secondPass = {
374
+ asked: toRun.length, listed: seen.size, skipped: skipped.length,
375
+ ...(skipped.length ? { skippedBy: 'cap' } : {}),
376
+ ...(unreached.length ? { notChecked: unreached.length } : {}),
377
+ ...(stopped ? { stopped } : {}),
378
+ };
379
+ }
380
+ else {
381
+ notChecked = new Set(silent.map((o) => objectKey(o.type, o.name)));
382
+ secondPass = { asked: 0, listed: 0, skipped: silent.length, skippedBy: toRun.length ? 'time' : 'cap' };
383
+ }
384
+ }
385
+ }
329
386
  // Row order follows the scope, so two runs of the same lane read the same way.
330
387
  const rowRefs = inScope.filter((o) => buckets.get(objectKey(o.type, o.name)) === 'measured')
331
388
  .map((o) => ({ type: o.type, name: o.name, ...(evidenceOf.get(objectKey(o.type, o.name)) ? { evidence: evidenceOf.get(objectKey(o.type, o.name)) } : {}) }));
@@ -347,10 +404,11 @@ export async function measure(input) {
347
404
  ...coverage, unsupportedNames, absentNames, scope: label, refusedChunks,
348
405
  ...(duplicates ? { duplicateFindingsMerged: duplicates } : {}),
349
406
  ...(stoppedBy ? { stoppedBy } : {}),
407
+ ...(secondPass ? { secondPass, notCheckedNames: keysInScope.filter((k) => notChecked.has(k)).slice(0, MAX_ABSENT_NAMES) } : {}),
350
408
  };
351
409
  if (!rowRefs.length)
352
410
  return { ...counts, file: null, withFindings: 0, refusedRows };
353
- const rows = findingsToRows(rowRefs, outcome.findings, input.project.type);
411
+ const rows = findingsToRows(rowRefs, [...outcome.findings, ...ownFindings], input.project.type);
354
412
  const res = writeRecord(input.joined, 'measurement', rows, input.by, {
355
413
  ...(input.now ? { now: input.now } : {}), ...(input.dir ? { dir: input.dir } : {}),
356
414
  ...(input.fileName ? { fileName: input.fileName } : {}), ...(input.rand ? { rand: input.rand } : {}),
@@ -361,6 +419,7 @@ export async function measure(input) {
361
419
  // below are recomputed after the refusal and are the ones a caller prints.
362
420
  extra: {
363
421
  atcVariant: variant, scope: label, basis: 'system', coverage,
422
+ ...(secondPass ? { secondPass } : {}),
364
423
  ...(duplicates ? { duplicateFindingsMerged: duplicates } : {}),
365
424
  },
366
425
  });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The one line CSPeach prints after it activated, and verified, an object of
3
+ * the team project this laptop has joined (spec §4.3). It records nothing:
4
+ * "CSPeach activated it" and "the developer says it is fixed" are two different
5
+ * sentences, and only the second is a claim.
6
+ */
7
+ import { objectKey } from '@cspeach/register-core';
8
+ import { activeProject, readBaseline } from './store.js';
9
+ export function teamFixOffers(objects) {
10
+ try {
11
+ const joined = activeProject();
12
+ if (!joined)
13
+ return [];
14
+ const baseline = readBaseline(joined.projectDir);
15
+ const byKey = new Map(baseline.map((o) => [objectKey(o.type, o.name), o]));
16
+ const byName = new Map();
17
+ for (const o of baseline)
18
+ byName.set(o.name.toUpperCase(), [...(byName.get(o.name.toUpperCase()) ?? []), o]);
19
+ const lines = [];
20
+ for (const o of objects) {
21
+ const type = String(o?.type ?? '').split('/')[0].trim().toUpperCase();
22
+ const name = String(o?.name ?? '').trim().toUpperCase();
23
+ const same = byName.get(name) ?? [];
24
+ const hit = byKey.get(objectKey(type, name)) ?? (same.length === 1 ? same[0] : undefined);
25
+ if (hit)
26
+ lines.push(`This object is in your team project. Mark it fixed? cspeach team fixed ${hit.type.toUpperCase()} ${hit.name.toUpperCase()}`);
27
+ }
28
+ return [...new Set(lines)];
29
+ }
30
+ catch {
31
+ return []; // a folder halfway through a sync must never touch an activation
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ import path from 'node:path';
2
+ import { buildRegister, narrowedLanes } from '@cspeach/register-core';
3
+ import { collectRecords, readBaseline, readLanes, readProjectFile } from './store.js';
4
+ export function registerFor(projectDir, outboxDir) {
5
+ const project = readProjectFile(projectDir);
6
+ const collected = collectRecords(projectDir);
7
+ // This laptop's own outbox counts too, so a developer sees their work before
8
+ // handing it in. It is also the one way this picture can legitimately differ
9
+ // from the team project page's, so `status` says out loud when it counted them.
10
+ const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
11
+ const baseline = readBaseline(projectDir);
12
+ const lanes = readLanes(projectDir);
13
+ const register = buildRegister({ project, baseline, lanes, records: [...collected.records, ...own.records] });
14
+ return {
15
+ register, files: collected.files + own.files,
16
+ unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
17
+ // F2: a lanes.json written before names began to narrow packages holds
18
+ // fewer objects now. The lead is told, not left to work it out from a count.
19
+ narrowed: narrowedLanes(lanes, baseline),
20
+ };
21
+ }
@@ -137,6 +137,34 @@ export function fixRowsFromUpgradeProgress(content, baseline) {
137
137
  }
138
138
  return { rows, ambiguous };
139
139
  }
140
+ /**
141
+ * Decision rows from a saved clean-core triage — CONFIRMED rows only (one Yes
142
+ * per row, clean-core-confirm.ts). The five words map onto the four
143
+ * classifications (spec §4.2); a fix or a rebuild with no target reaches A.
144
+ */
145
+ export function decisionRowsFromCleanCore(content) {
146
+ const rows = [];
147
+ for (const r of content.adjudications ?? []) {
148
+ if (r?.confirmed !== true)
149
+ continue;
150
+ const moves = r.disposition === 'fix' || r.disposition === 'rebuild';
151
+ const base = r.disposition === 'keep' ? { classification: 'keep' }
152
+ : r.disposition === 'exception' ? { classification: 'keep', exception: true }
153
+ : r.disposition === 'fix' ? { classification: 'fix' }
154
+ : r.disposition === 'rebuild' ? { classification: 'redesign' }
155
+ : r.disposition === 'retire' ? { classification: 'retire' } : null;
156
+ if (!base)
157
+ continue;
158
+ const reason = typeof r.reason === 'string' ? r.reason.trim().slice(0, 300) : '';
159
+ rows.push({
160
+ key: objectKey(r.objectType, r.objectName), ...base,
161
+ ...(r.level ? { level: r.level } : {}),
162
+ ...(moves ? { targetLevel: r.targetLevel ?? 'A' } : {}),
163
+ ...(reason ? { reason } : {}),
164
+ });
165
+ }
166
+ return rows;
167
+ }
140
168
  /**
141
169
  * Called after every successful envelope save. Does nothing unless this laptop
142
170
  * has joined a project; never throws (a record is a by-product, a save is not).
@@ -160,5 +188,9 @@ opts = {}) {
160
188
  const res = writeRecord(joined, 'fix', rows, by, { source, ...opts });
161
189
  return { ...res, refused: [...res.refused, ...ambiguous.map((n) => `${n} (name matches more than one object type)`)] };
162
190
  }
191
+ if (env.artefactType === 'clean-core-decision') {
192
+ const rows = decisionRowsFromCleanCore(env.content);
193
+ return writeRecord(joined, 'decision', rows, by, { source, ...opts });
194
+ }
163
195
  return null;
164
196
  }
@@ -0,0 +1,213 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/register-map.js,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ /**
6
+ * The map: one block per package, one cell per object, colour = state.
7
+ *
8
+ * Strings, not DOM, for two reasons: the status file is a document with no
9
+ * script in it and needs the same picture as text; and 20,000 cells arrive in
10
+ * one innerHTML assignment rather than 20,000 createElement calls.
11
+ *
12
+ * Colour is a CSS class (`s-verified`), never a style attribute: the page's
13
+ * Content-Security-Policy forbids inline styles, and the status file and the
14
+ * page then share one stylesheet.
15
+ */
16
+ import { STATE_ORDER, STATE_LABEL, FROM_FILE, NOT_MENTIONED, NOT_REPORTED } from './register-model.js';
17
+ const ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
18
+ /**
19
+ * The one escaping function for the whole page. Object, package, lane and
20
+ * person names all come from a shared folder and are untrusted text.
21
+ * The two quote characters are written as `\x22` and `\x27`. They are the same
22
+ * characters, and a quote inside a regex literal blinds the word scanner that
23
+ * reads these files (`team-words.test.js`): it knows strings and comments but
24
+ * not regexes, so one `"` here flips text and code around for the rest of the
25
+ * file and the guard goes quiet. Leave them escaped.
26
+ *
27
+ * @param {unknown} value
28
+ * @returns {string}
29
+ */
30
+ export function esc(value) {
31
+ return value == null ? '' : String(value).replace(/[&<>\x22\x27]/g, (c) => ENTITIES[c]);
32
+ }
33
+ /**
34
+ * The class for a state. The states are a closed set: an unknown or hostile
35
+ * state becomes `s-unknown`, never a raw value, because `class` is
36
+ * space-separated and escaping does not neutralise a space — a state of
37
+ * "clean hidden" would otherwise add the class `hidden` and hide the cell.
38
+ * @param {unknown} state
39
+ * @returns {string}
40
+ */
41
+ export function stateClass(state) {
42
+ return `s-${Object.hasOwn(STATE_LABEL, state) ? state : 'unknown'}`;
43
+ }
44
+ /**
45
+ * The words for a state, from the same closed set: an unknown state — or an
46
+ * inherited property name like "constructor" — reads "Unknown".
47
+ * @param {unknown} state
48
+ * @returns {string}
49
+ */
50
+ export function stateLabel(state) {
51
+ return Object.hasOwn(STATE_LABEL, state) ? STATE_LABEL[state] : 'Unknown';
52
+ }
53
+ /**
54
+ * The newest measurement this object's colour was actually read from.
55
+ *
56
+ * `counted` is register-core's own answer to "did this run move the state", so
57
+ * the view never works that rule out a second time. Walking backwards stops at
58
+ * the first counted one, which on a real object is the last entry.
59
+ */
60
+ function drivingNewest(o) {
61
+ const all = o?.measurements;
62
+ if (!Array.isArray(all))
63
+ return null;
64
+ for (let i = all.length - 1; i >= 0; i -= 1)
65
+ if (all[i]?.counted)
66
+ return all[i];
67
+ return null;
68
+ }
69
+ /**
70
+ * The words for an object's colour, with what that colour rests on.
71
+ *
72
+ * Three strengths, and only the two PROVEN states can carry the weaker two —
73
+ * "Did not hold, measured from a file" would be labelling a problem with how it
74
+ * was found. A colour read from a system run says nothing extra; one read from
75
+ * a file says so; and one that rests on SILENCE — nothing mentioned the object
76
+ * and it was counted clean anyway — says that instead, because nothing measured
77
+ * it at all.
78
+ *
79
+ * Whose silence it was follows the BASIS the state was read from: a file that
80
+ * did not mention it, or a check run that did not report it. Saying "in a file"
81
+ * of a live run would send a manager looking for an export that does not exist.
82
+ *
83
+ * It lives here, beside `stateLabel`, because the map's cells need it too: the
84
+ * `<title>` is the only thing a hover, a screen reader on the roving cell, and
85
+ * the status file's picture ever say about an object, and a file-backed "Clean"
86
+ * there would be the one place the label went missing.
87
+ *
88
+ * @param {{ state: string, measuredBasis?: string|null, measurements?: object[] }} o
89
+ * @returns {string}
90
+ */
91
+ export function stateWord(o) {
92
+ const label = stateLabel(o?.state);
93
+ if (o?.state !== 'verified' && o?.state !== 'clean')
94
+ return label;
95
+ const fromFile = o.measuredBasis === 'file';
96
+ if (drivingNewest(o)?.evidence === 'absent')
97
+ return `${label} — ${fromFile ? NOT_MENTIONED : NOT_REPORTED}`;
98
+ return fromFile ? `${label} — ${FROM_FILE}` : label;
99
+ }
100
+ /**
101
+ * Wider than tall (reads like a block of text), and bounded so one huge
102
+ * package cannot run off the page. Nonsense in (NaN, negative) gives the
103
+ * smallest map rather than a NaN-sized picture.
104
+ * @param {number} count
105
+ * @returns {number}
106
+ */
107
+ export function mapColumns(count) {
108
+ return Math.max(8, Math.min(60, Math.ceil(Math.sqrt(Math.max(0, count || 0)) * 1.6)));
109
+ }
110
+ /** The four clean-core levels. Anything else (absent, or a word this build does not know) is "not decided". */
111
+ const LEVEL_SET = new Set(['A', 'B', 'C', 'D']);
112
+ /** The five level buckets, in order, and their words: one table for the map, the legend and the bars. */
113
+ export const LEVEL_BUCKETS = ['A', 'B', 'C', 'D', 'none'];
114
+ export const LEVEL_WORD = { A: 'A', B: 'B', C: 'C', D: 'D', none: 'not decided' };
115
+ /**
116
+ * The class for a level, from a closed set like the states: a hostile
117
+ * `levelNow` can never write a second class into the cell.
118
+ * @param {unknown} l
119
+ * @returns {string}
120
+ */
121
+ export const levelClass = (l) => `l-${LEVEL_SET.has(l) ? l : 'none'}`;
122
+ /** The only two roles a block may carry, so `role` can never be written into. */
123
+ const BLOCK_ROLES = new Set(['group', 'img']);
124
+ /**
125
+ * One package block as a single `<svg>` string.
126
+ * @param {{ package: string, objects: Array<{ key: string, type: string, name: string, state: string }> }} block
127
+ * `colourBy: 'level'` colours each cell by the level it stands at now
128
+ * (`levelNow`) instead of its state, and the title names that level. The state
129
+ * stays in the title either way.
130
+ * @param {{ cell?: number, gap?: number, role?: 'group'|'img', colourBy?: 'state'|'level' }} [opts]
131
+ * @returns {string}
132
+ */
133
+ export function blockSvg(block, { cell = 10, gap = 2, role = 'group', colourBy = 'state' } = {}) {
134
+ // `group` by default: an image's children are presentational, and the page
135
+ // makes each cell something a reader can land on and open. `img` would hide
136
+ // every one of them from a screen reader.
137
+ //
138
+ // The status file asks for `img`, and is right to: it carries no script, so
139
+ // nothing there can open a cell, and a screen reader would otherwise be
140
+ // handed tens of thousands of shapes that do nothing. The set is closed, like
141
+ // the states — `role` is an attribute, and a caller must not be able to write
142
+ // a second one after it.
143
+ const safeRole = BLOCK_ROLES.has(role) ? role : 'group';
144
+ const n = block.objects.length;
145
+ const cols = mapColumns(n);
146
+ const rows = Math.max(1, Math.ceil(n / cols));
147
+ const step = cell + gap;
148
+ const width = cols * step - gap;
149
+ const height = rows * step - gap;
150
+ // `data-key` is how the page finds the object behind a cell that was clicked.
151
+ // A picture has no clicks, so in `img` mode it is 30 dead bytes per cell — on
152
+ // a 20,000-object project, most of a megabyte in a file somebody has to mail.
153
+ // The per-cell `<title>` stays either way: hovering a cell to read the name is
154
+ // worth its bytes in a document nobody can search any other way.
155
+ const keyed = safeRole !== 'img';
156
+ const byLevel = colourBy === 'level';
157
+ const rects = block.objects.map((o, i) => {
158
+ const x = (i % cols) * step;
159
+ const y = Math.floor(i / cols) * step;
160
+ // A retired object keeps its state colour in level mode: the level counts
161
+ // leave retired objects out, and a cell painted C that no count includes
162
+ // would show a manager two numbers for one folder.
163
+ const asLevel = byLevel && o.state !== 'retired';
164
+ 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">`
165
+ + `<title>${esc(o.name)} · ${esc(o.type)}${asLevel ? ` · level ${esc(LEVEL_WORD[LEVEL_SET.has(o.levelNow) ? o.levelNow : 'none'])}` : ''} · ${esc(stateWord(o))}</title></rect>`;
166
+ }).join('');
167
+ const label = `${block.package}: ${n.toLocaleString('en-US')} ${n === 1 ? 'object' : 'objects'}`;
168
+ return `<svg class="block-map" role="${safeRole}" aria-label="${esc(label)}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects}</svg>`;
169
+ }
170
+ /**
171
+ * The key to the colours: only the states that occur, worst first, with counts.
172
+ *
173
+ * `unknown` is the tenth colour, and it is not a state. A cell drawn in it
174
+ * holds something this build has no word for, and it is meant to look wrong —
175
+ * so the legend names it and counts it rather than leaving a mark on the map
176
+ * that nothing in the key accounts for.
177
+ *
178
+ * @param {Record<string, number>} byState
179
+ * @param {{ unknown?: number }} [opts]
180
+ * @returns {string}
181
+ */
182
+ export function legendHtml(byState, { unknown = 0 } = {}) {
183
+ const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc(count.toLocaleString('en-US'))}</b></li>`;
184
+ const items = STATE_ORDER.filter((s) => (byState[s] ?? 0) > 0)
185
+ .map((s) => entry(stateClass(s), stateLabel(s), byState[s]));
186
+ if (unknown > 0)
187
+ items.push(entry(stateClass('unknown'), stateLabel('unknown'), unknown));
188
+ return `<ul class="legend">${items.join('')}</ul>`;
189
+ }
190
+ /**
191
+ * The key to the level colours: always all five, D included, with counts —
192
+ * or, for a folder from an older build with no level counts, one line saying so.
193
+ *
194
+ * The counts are register-core's own (`totals.byLevel.now`), never recounted
195
+ * here, so the legend and the "Levels now" bar are one number. Retired objects
196
+ * are outside those counts and drawn in their state colour, so they get their
197
+ * own entry when there are any.
198
+ *
199
+ * @param {Record<string, number>} counts `totals.byLevel.now`
200
+ * @param {{ retired?: number }} [opts]
201
+ * @returns {string}
202
+ */
203
+ export function levelLegendHtml(counts, { retired = 0 } = {}) {
204
+ const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc((count ?? 0).toLocaleString('en-US'))}</b></li>`;
205
+ // A folder from an older build has no level counts: say so, as the level bars
206
+ // do, rather than print five zeros that read as "measured, nothing there".
207
+ const items = counts
208
+ ? LEVEL_BUCKETS.map((l) => entry(`l-${l}`, LEVEL_WORD[l], counts[l]))
209
+ : ['<li>No levels recorded yet.</li>'];
210
+ if (retired > 0)
211
+ items.push(entry(stateClass('retired'), 'retired', retired));
212
+ return `<ul class="legend">${items.join('')}</ul>`;
213
+ }