@cspeach/cli 1.1.28 → 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 (41) hide show
  1. package/dist/agent/loop.js +6 -3
  2. package/dist/agent/turn-error-ux.js +40 -0
  3. package/dist/auth/auth-file.js +2 -2
  4. package/dist/cli.js +4 -0
  5. package/dist/commands/login.js +1 -1
  6. package/dist/commands/logout.js +1 -1
  7. package/dist/commands/team.js +298 -26
  8. package/dist/commands/whoami.js +1 -1
  9. package/dist/config/paths.js +15 -1
  10. package/dist/one-shot.js +1 -0
  11. package/dist/projects/clean-core-confirm.js +36 -0
  12. package/dist/projects/extract-clean-core.js +79 -0
  13. package/dist/projects/save-command.js +38 -9
  14. package/dist/projects/validate.js +2 -1
  15. package/dist/projects/workspace.js +2 -1
  16. package/dist/register/atc-file.js +9 -1
  17. package/dist/register/findings.js +79 -0
  18. package/dist/register/measure.js +60 -1
  19. package/dist/register/offer.js +33 -0
  20. package/dist/register/picture.js +21 -0
  21. package/dist/register/records.js +32 -0
  22. package/dist/register/status-file/register-map.js +213 -0
  23. package/dist/register/status-file/register-model.js +307 -0
  24. package/dist/register/status-file/register-parts.js +509 -0
  25. package/dist/register/status-file/register-status-file.js +171 -0
  26. package/dist/register/status-file/team-css.js +5 -0
  27. package/dist/register/status-file-sync.js +19 -0
  28. package/dist/register/status-html.js +17 -0
  29. package/dist/register/workbooks.js +104 -0
  30. package/dist/register/write-whole.js +22 -0
  31. package/dist/register/xlsx.js +117 -0
  32. package/dist/renderer/tool-labels.js +2 -1
  33. package/dist/repl.js +1 -0
  34. package/dist/skills/bundled-skills.js +83 -83
  35. package/dist/tools/_filesystem-shared.js +16 -9
  36. package/dist/tools/project/team_findings.js +72 -0
  37. package/dist/tools/sap-write.js +8 -0
  38. package/dist/tools/tool-loading.js +1 -1
  39. package/dist/ui/app.js +4 -0
  40. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  41. package/package.json +2 -2
@@ -0,0 +1,79 @@
1
+ const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):clean-core-manifest\s*\n([\s\S]*?)\n?\s*-->/;
2
+ const DISPOSITIONS = ['keep', 'exception', 'fix', 'rebuild', 'retire'];
3
+ const MODES = ['triage', 'judge', 'adjudicate', 'advise'];
4
+ const MAX_REASON = 300;
5
+ const letter = (raw, id) => {
6
+ const v = raw.trim().toUpperCase();
7
+ if (v === '' || v === '-')
8
+ return null;
9
+ if (v === 'A' || v === 'B' || v === 'C' || v === 'D')
10
+ return v;
11
+ throw new Error(`${id}: a level is one of A, B, C, D or - (got "${raw.trim()}")`);
12
+ };
13
+ export function extractCleanCoreDecision(markdown) {
14
+ const m = MANIFEST_RE.exec(markdown);
15
+ if (!m)
16
+ throw new Error('No <!-- cspeach:clean-core-manifest --> block found in skill output');
17
+ const kv = {};
18
+ const rows = [];
19
+ const seen = new Set();
20
+ let inTable = false;
21
+ let blankInTable = false;
22
+ for (const raw of (m[1] ?? '').split('\n')) {
23
+ const line = raw.replace(/\r$/, '');
24
+ // Inside the table a blank line does not end it: a row after one is refused
25
+ // below, so no row can slip out of the table into the settings.
26
+ if (!line.trim()) {
27
+ if (inTable)
28
+ blankInTable = true;
29
+ continue;
30
+ }
31
+ if (inTable) {
32
+ // `adjudications:` is the last setting; an unindented line here is a broken row.
33
+ if (!/^\s+/.test(line))
34
+ throw new Error(`"${line.trim()}": a row must be indented under adjudications:`);
35
+ const cells = line.trim().split('|').map((c) => c.trim());
36
+ const [id = '', type = '', name = '', level = '', word = '', target = ''] = cells;
37
+ if (blankInTable)
38
+ throw new Error(`${id}: a row comes after a blank line inside adjudications:`);
39
+ // Rows are matched to their cards by id; two rows with one id would share one answer.
40
+ if (seen.has(id))
41
+ throw new Error(`${id}: two rows have this id`);
42
+ seen.add(id);
43
+ const reason = cells.slice(6).join(' | ').trim().slice(0, MAX_REASON);
44
+ const disposition = word.toLowerCase();
45
+ if (!DISPOSITIONS.includes(disposition))
46
+ throw new Error(`${id}: "${word}" is not one of ${DISPOSITIONS.join(', ')}`);
47
+ if (!type || !name)
48
+ throw new Error(`${id}: a row needs an object type and a name`);
49
+ if (disposition === 'exception' && !reason)
50
+ throw new Error(`${id}: an accepted exception needs a reason`);
51
+ const moves = disposition === 'fix' || disposition === 'rebuild';
52
+ rows.push({
53
+ id, objectType: type.toUpperCase(), objectName: name.toUpperCase(), level: letter(level, id), disposition,
54
+ targetLevel: moves ? letter(target, id) : null, reason, confirmed: false,
55
+ });
56
+ continue;
57
+ }
58
+ const colon = line.indexOf(':');
59
+ if (colon === -1)
60
+ continue;
61
+ const key = line.slice(0, colon).trim();
62
+ const value = line.slice(colon + 1).trim();
63
+ if (key === 'adjudications' && !value) {
64
+ inTable = true;
65
+ continue;
66
+ }
67
+ kv[key] = value;
68
+ }
69
+ if (kv.artefact !== 'clean-core-decision')
70
+ throw new Error(`Manifest artefact mismatch: expected 'clean-core-decision', got '${kv.artefact}'`);
71
+ if (!rows.length)
72
+ throw new Error('clean-core-decision manifest has no rows');
73
+ const mode = MODES.includes(kv.input_mode ?? '') ? kv.input_mode : 'triage';
74
+ return {
75
+ title: kv.title || 'Clean-core decisions',
76
+ content: { inputMode: mode, scope: kv.scope ?? '', adjudications: rows },
77
+ items: rows.map((r) => ({ id: r.id, status: 'proposed', answer: null, answeredBy: null, answeredAt: null, comments: [] })),
78
+ };
79
+ }
@@ -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
  }