@cspeach/cli 1.1.29 → 1.1.31

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 (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -18,12 +18,20 @@
18
18
  import fs from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
- import { activeOf, buildRegister, narrowedLanes, objectKey, provenOf, } from '@cspeach/register-core';
21
+ import { laneActiveOf, laneProvenOf, levelOf, normalizeRuleId, objectKey, } from '@cspeach/register-core';
22
22
  import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
23
- import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, collectRecords, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
24
- import { measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
23
+ import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
24
+ import { MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
25
25
  import { measureFromFile, shown, upToFive } from '../register/atc-file.js';
26
- import { writeRecord } from '../register/records.js';
26
+ import { writeLeadRecord, writeRecord } from '../register/records.js';
27
+ import { findingsByObject } from '../register/findings.js';
28
+ import { registerFor } from '../register/picture.js';
29
+ import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
30
+ import { writeXlsxFile } from '../register/xlsx.js';
31
+ import { writeWhole } from '../register/write-whole.js';
32
+ import { statusHtmlFor } from '../register/status-html.js';
33
+ import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
34
+ import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
27
35
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
28
36
  const TYPES = ['clean-core', 'upgrade', 'build'];
29
37
  /**
@@ -37,7 +45,16 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
37
45
  * compares this with the lead's name, a ruling only counts when it comes from
38
46
  * the lead, and every record says who wrote it.
39
47
  */
40
- const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
48
+ export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
49
+ /** After a record this laptop wrote: its working list, made again. One line, or none. */
50
+ function refreshSheet(deps) {
51
+ const joined = activeProject();
52
+ if (!joined)
53
+ return;
54
+ const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
55
+ if (res.line)
56
+ deps.print(res.line);
57
+ }
41
58
  /**
42
59
  * Names that are a machine's, not a person's. A team project whose lead is
43
60
  * "Admin" says nothing about who leads it, and the writer id, the rulings and
@@ -59,7 +76,7 @@ function looksLikeAMachine(name, deps) {
59
76
  * and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
60
77
  * has always taken. The verbs that pass nothing read exactly as before.
61
78
  */
62
- function flags(args, flagOnly = []) {
79
+ export function flags(args, flagOnly = []) {
63
80
  const positional = [];
64
81
  const opts = {};
65
82
  for (let i = 0; i < args.length; i++) {
@@ -82,7 +99,7 @@ function flags(args, flagOnly = []) {
82
99
  }
83
100
  return { positional, opts };
84
101
  }
85
- const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
102
+ export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
86
103
  /** "1 object", "3 objects" — every count this command prints reads as a sentence. */
87
104
  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
88
105
  /** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
@@ -95,15 +112,24 @@ export async function runTeamCommand(args, deps) {
95
112
  try {
96
113
  switch (sub) {
97
114
  case 'init': return await init(rest, deps);
115
+ case 'start': return await start(rest, deps);
98
116
  case 'join': return join(rest, deps);
99
- case 'lane': return lane(rest, deps);
117
+ case 'lane': return await lane(rest, deps);
100
118
  case 'refresh': return await refresh(rest, deps);
101
119
  case 'status': return status(rest, deps);
102
120
  case 'outbox': return outbox(rest, deps);
103
121
  case 'rule': return rule(rest, deps);
104
122
  case 'leave': return leave(deps);
123
+ case 'decide': return decide(rest, deps);
124
+ case 'fixed': return await fixed(rest, deps);
125
+ case 'sheet': return sheet(deps);
126
+ case 'report': return report(rest, deps);
127
+ case 'discover': return await teamDiscover(rest, deps);
128
+ case 'focus': return focusCommand(rest, deps);
129
+ case 'priority': return priorityCommand(rest, deps);
130
+ case 'plan': return await planCommand(rest, deps);
105
131
  default:
106
- deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave (cspeach --help for details)');
132
+ deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report|discover|focus|priority|plan|start (cspeach --help for details)');
107
133
  return sub ? 1 : 0;
108
134
  }
109
135
  }
@@ -112,16 +138,22 @@ export async function runTeamCommand(args, deps) {
112
138
  deps.print(`✗ ${e.message}`);
113
139
  return 1;
114
140
  }
115
- throw e;
141
+ // Anything else (no SAP system configured, the connection refused, …) is one
142
+ // plain line too, never a stack trace (rehearsal 2026-10-07). CSPEACH_DEBUG=1
143
+ // still rethrows, so the trace is there when someone needs it.
144
+ if (process.env['CSPEACH_DEBUG'] === '1')
145
+ throw e;
146
+ deps.print(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
147
+ return 1;
116
148
  }
117
149
  }
118
- function requireLead(project, deps) {
150
+ export function requireLead(project, deps) {
119
151
  const me = whoAmI(deps);
120
152
  if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
121
153
  throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
122
154
  }
123
155
  }
124
- function joinedOrThrow() {
156
+ export function joinedOrThrow() {
125
157
  const joined = activeProject();
126
158
  if (!joined)
127
159
  throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
@@ -129,11 +161,11 @@ function joinedOrThrow() {
129
161
  }
130
162
  // ── init ────────────────────────────────────────────────────────────────────
131
163
  const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<your name>"] [--type clean-core|upgrade|build] '
132
- + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure]';
164
+ + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
133
165
  /** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
134
- const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure'];
166
+ const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
135
167
  /** The two that take no value, so neither can eat the folder name. */
136
- const INIT_FLAGS = ['include-local', 'no-measure'];
168
+ const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
137
169
  /** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
138
170
  const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
139
171
  /**
@@ -159,6 +191,60 @@ function unknownInitOption(name) {
159
191
  return said;
160
192
  return `${said} Did you mean --${guess}?`;
161
193
  }
194
+ const START_USAGE = 'Usage: cspeach team start <packages> [--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
195
+ const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias'];
196
+ /** The check a project measures with when nobody names one. */
197
+ export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
198
+ /**
199
+ * 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
200
+ * command for what init + join + lane add said in three: the folder you are
201
+ * in, a name from the packages, you as the lead, the default check, your
202
+ * results straight into the folder, and every object yours. The long verbs
203
+ * stay for big team setups.
204
+ */
205
+ async function start(args, deps) {
206
+ const { positional, opts } = flags(args, ['no-measure']);
207
+ for (const name of Object.keys(opts)) {
208
+ if (!START_OPTIONS.includes(name))
209
+ throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
210
+ }
211
+ const packages = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
212
+ if (!packages.length)
213
+ throw new RegisterError(START_USAGE);
214
+ const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
215
+ if (fs.existsSync(path.join(dir, TEAM_FILE)))
216
+ throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
217
+ const lead = deps.identity().name;
218
+ const name = opts.name && opts.name !== 'true' ? opts.name : packages.join(', ');
219
+ const variant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'] : DEFAULT_ATC_VARIANT;
220
+ // The three verbs run as they always do; their detail is kept back and the
221
+ // few lines that matter are said once, below.
222
+ const said = [];
223
+ const quiet = { ...deps, print: (l) => said.push(l) };
224
+ const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
225
+ ...(opts['no-measure'] ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
226
+ const code = await init(initArgs, quiet);
227
+ if (code !== 0) {
228
+ for (const l of said)
229
+ deps.print(l);
230
+ return code;
231
+ }
232
+ join([dir, '--outbox', dir], quiet);
233
+ const laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
234
+ await lane(['add', laneId, '--owner', lead, '--packages', packages.join(',')], quiet);
235
+ const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
236
+ const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
237
+ const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
238
+ deps.print(`✓ Started "${name}" in this folder. Lead: ${lead}.`);
239
+ deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
240
+ if (measured)
241
+ deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
242
+ else if (opts['no-measure'])
243
+ deps.print(' No check run yet: cspeach team refresh measures it.');
244
+ deps.print(` You own all ${plural(objects, 'object', 'objects')}. Your results go straight into this folder.`);
245
+ deps.print(` Next, in CSPeach: /abap-clean-core triage lane ${laneId}`);
246
+ return 0;
247
+ }
162
248
  async function init(args, deps) {
163
249
  const { positional, opts } = flags(args, INIT_FLAGS);
164
250
  // `--no-measur` must not read as "measure anyway": a person who meant to skip
@@ -191,6 +277,11 @@ async function init(args, deps) {
191
277
  throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
192
278
  if (!deps.connect)
193
279
  throw new RegisterError('init needs a SAP connection to pull the object list.');
280
+ if (opts['from-discovery'] && opts['no-from-discovery'])
281
+ throw new RegisterError(`Say --from-discovery or --no-from-discovery, not both.
282
+ ${INIT_USAGE}`);
283
+ // The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
284
+ const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
194
285
  const { sql, system, atc } = await deps.connect(opts['sap-alias']);
195
286
  deps.print('Reading the package list from SAP…');
196
287
  const all = await listCustomPackages(sql);
@@ -206,6 +297,9 @@ async function init(args, deps) {
206
297
  if (!chosen.length) {
207
298
  throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
208
299
  }
300
+ if (discovery)
301
+ for (const l of estatePackageLines(chosen, discovery.estate))
302
+ deps.print(l);
209
303
  deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
210
304
  const pull = await pullBaseline(sql, { packages: chosen });
211
305
  if (!pull.objects.length)
@@ -225,9 +319,14 @@ async function init(args, deps) {
225
319
  fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
226
320
  fs.writeFileSync(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
227
321
  writeLanes(dir, projectId, []);
322
+ const evidenceLines = discovery || opts['from-discovery']
323
+ ? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
324
+ : [];
228
325
  deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
229
326
  deps.print(` Set aside, not in scope: ${project.setAside.local} customer-named local ($TMP) entries of any type, ${pull.setAside.generated} generated, ${pull.setAside.otherTypes} non-code entries.`);
230
327
  deps.print(` Written to ${dir}`);
328
+ for (const l of evidenceLines)
329
+ deps.print(l);
231
330
  // F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
232
331
  // user. That name goes into the team project file, into the writer id and
233
332
  // into every record from then on. Said once, never asked.
@@ -253,6 +352,62 @@ async function init(args, deps) {
253
352
  deps.print(l);
254
353
  return 0;
255
354
  }
355
+ /**
356
+ * Per chosen package, what the estate knows beside the live count: its
357
+ * description, application area and last change. Packages the estate does
358
+ * not hold get the count alone.
359
+ */
360
+ function estatePackageLines(chosen, estate) {
361
+ const known = new Map();
362
+ const pkgs = estate?.packages;
363
+ if (Array.isArray(pkgs))
364
+ for (const p of pkgs)
365
+ if (p && typeof p.name === 'string')
366
+ known.set(p.name, p);
367
+ return chosen.map((c) => {
368
+ const p = known.get(c.package);
369
+ const text = (v) => (typeof v === 'string' ? v.trim().slice(0, 300) : '');
370
+ const parts = [plural(c.objects, 'object', 'objects')];
371
+ if (p) {
372
+ if (text(p.text))
373
+ parts.push(text(p.text));
374
+ if (text(p.area))
375
+ parts.push(`area ${text(p.area)}`);
376
+ const at = text(p.lastChanged?.at);
377
+ if (at)
378
+ parts.push(`last changed ${at}${text(p.lastChanged?.by) ? ` by ${text(p.lastChanged.by)}` : ''}`);
379
+ }
380
+ return ` ${c.package}: ${parts.join(' · ')}`;
381
+ });
382
+ }
383
+ /**
384
+ * baseline/evidence-0.json (spec 2026-10-06 §7): the inventory's evidence for
385
+ * the team project's own objects, from the newest object list. Rows for other
386
+ * objects are never written; they are counted. Never fails `init`: the team
387
+ * project is complete without it.
388
+ */
389
+ function firstEvidence(dir, projectId, baseline, discovery, by, now, deps) {
390
+ if (!discovery)
391
+ return [' No discovery in this folder, so no evidence was written. Run: cspeach team discover --out <folder>'];
392
+ const ev = evidenceRows(discovery.objects, new Set(baseline.map((o) => objectKey(o.type, o.name))));
393
+ if (!ev)
394
+ return [' The object list of the inventory could not be read, so no evidence was written.'];
395
+ try {
396
+ const file = `objects-${discovery.stamp}.json`;
397
+ let sum = '';
398
+ try {
399
+ sum = sha256(fs.readFileSync(path.join(dir, 'discovery', file), 'utf8'));
400
+ }
401
+ catch { /* the checksum is a nicety */ }
402
+ writeLeadRecord(path.join(dir, 'baseline'), projectId, 'evidence', { source: { file, sha256: sum } }, ev.rows, by, { now, fileName: 'evidence-0.json', ...(deps.rand ? { rand: deps.rand } : {}) });
403
+ }
404
+ catch (e) {
405
+ // Node's own message carries the full path, user name and all; the code says enough.
406
+ const code = e?.code;
407
+ return [` ! The inventory's evidence could not be written${code ? ` (${code})` : ''}. The team project itself is complete.`];
408
+ }
409
+ return [` ${EVIDENCE_LINE(ev.rows.length)}`, ...(ev.outside ? [` ${OUTSIDE_LINE(ev.outside)}`] : [])];
410
+ }
256
411
  /** Errors that are about this laptop's disk or the shared folder, never about SAP. */
257
412
  const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
258
413
  /**
@@ -298,6 +453,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
298
453
  const res = await measure({
299
454
  joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
300
455
  dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
456
+ confirmAbsent: true,
301
457
  ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
302
458
  signal: stop.signal,
303
459
  onProgress: progressLine(deps),
@@ -312,7 +468,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
312
468
  // line: this one record belongs to the team project, not to this laptop.
313
469
  if (res.file)
314
470
  deps.print(' This first measurement is part of the team project\'s baseline, in the shared folder.');
315
- const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
471
+ const stoppedBy = res.stoppedBy ?? secondPassStop(res) ?? (stop.signal.aborted ? 'aborted' : undefined);
316
472
  if (stoppedBy) {
317
473
  const why = STOPPED_WORDS[stoppedBy];
318
474
  deps.print(res.file
@@ -356,6 +512,11 @@ function join(args, deps, asName) {
356
512
  state.active = project.projectId;
357
513
  saveState(state);
358
514
  deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
515
+ // Outbox is the team project folder itself: records land where everyone reads them.
516
+ if (path.relative(projectDir, outboxDir) === '') {
517
+ deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
518
+ return 0;
519
+ }
359
520
  deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
360
521
  deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
361
522
  deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
@@ -374,8 +535,10 @@ function leave(deps) {
374
535
  return 0;
375
536
  }
376
537
  // ── lane ────────────────────────────────────────────────────────────────────
377
- function lane(args, deps) {
538
+ async function lane(args, deps) {
378
539
  const [verb, ...rest] = args;
540
+ if (verb === 'propose')
541
+ return laneProposal(rest, deps);
379
542
  const { positional, opts } = flags(rest);
380
543
  const joined = joinedOrThrow();
381
544
  const project = readProjectFile(joined.projectDir);
@@ -390,14 +553,20 @@ function lane(args, deps) {
390
553
  requireLead(project, deps);
391
554
  const id = positional[0];
392
555
  if (!id)
393
- throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
556
+ throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] [--reason "<why>"] | lane remove <id> [--reason "<why>"] | lane propose --developers "…"');
557
+ const reason = reasonFrom(opts, false);
394
558
  if (verb === 'remove') {
395
559
  writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
396
560
  deps.print(`✓ Lane ${id} removed.`);
561
+ const was = lanes.find((l) => l.id === id);
562
+ // With a reason, the change is also kept in the plan history; without one,
563
+ // `lane remove` says and writes exactly what it always did.
564
+ if (was && reason)
565
+ deps.print(laneChangeRecord(deps, 'remove', was, undefined, id, reason));
397
566
  return 0;
398
567
  }
399
568
  if (verb !== 'add')
400
- throw new RegisterError('lane: use add, remove or list.');
569
+ throw new RegisterError('lane: use add, remove, list or propose.');
401
570
  const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
402
571
  if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
403
572
  throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
@@ -415,27 +584,11 @@ function lane(args, deps) {
415
584
  deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
416
585
  if (reg.register.totals.unowned)
417
586
  deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
587
+ if (reason)
588
+ deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
418
589
  return 0;
419
590
  }
420
591
  // ── status ──────────────────────────────────────────────────────────────────
421
- function registerFor(projectDir, outboxDir) {
422
- const project = readProjectFile(projectDir);
423
- const collected = collectRecords(projectDir);
424
- // This laptop's own outbox counts too, so a developer sees their work before
425
- // handing it in. It is also the one way this picture can legitimately differ
426
- // from the team project page's, so `status` says out loud when it counted them.
427
- const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
428
- const baseline = readBaseline(projectDir);
429
- const lanes = readLanes(projectDir);
430
- const register = buildRegister({ project, baseline, lanes, records: [...collected.records, ...own.records] });
431
- return {
432
- register, files: collected.files + own.files,
433
- unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
434
- // F2: a lanes.json written before names began to narrow packages holds
435
- // fewer objects now. The lead is told, not left to work it out from a count.
436
- narrowed: narrowedLanes(lanes, baseline),
437
- };
438
- }
439
592
  const STATE_WORDS = [
440
593
  ['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
441
594
  ['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
@@ -451,11 +604,21 @@ function status(args, deps) {
451
604
  const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
452
605
  const t = r.totals;
453
606
  deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
607
+ // The estate around the team project, when the folder holds a discovery (spec 2026-10-06 §9).
608
+ if (r.estate) {
609
+ const e = r.estate.totals;
610
+ const n = (v) => v.toLocaleString('en-US');
611
+ deps.print(`Estate: ${n(e.packages)} ${e.packages === 1 ? 'package' : 'packages'}, `
612
+ + `${n(e.codeObjects)} programs and other code objects, ${n(e.nonCodeObjects)} non-code objects, `
613
+ + `${n(e.unknowns)} ${e.unknowns === 1 ? 'unknown' : 'unknowns'}`);
614
+ }
454
615
  // A clean sheet out of a file is somebody's export, not this system speaking.
455
616
  // The words "verified" and "clean" do not change; the line says how many of
456
617
  // them were measured one step removed, so nobody reads them as live proof.
457
618
  const fromFile = t.fromFile.verified + t.fromFile.clean;
619
+ // Recording fix 4: a clean object nobody decided has nothing to decide — said on its own.
458
620
  deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
621
+ + `${t.cleanNothingToDecide ? `${t.cleanNothingToDecide} clean, nothing to decide. ` : ''}`
459
622
  + `${t.byState.verified} verified, ${t.byState.clean} clean`
460
623
  + `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
461
624
  // The newest run that counted — by its time, not by the order the files were
@@ -464,6 +627,7 @@ function status(args, deps) {
464
627
  deps.print(r.lastMeasuredAt
465
628
  ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
466
629
  : 'Not measured yet: "verified" and "clean" need an ATC run.');
630
+ deps.print(nowLine(r));
467
631
  deps.print('');
468
632
  for (const [state, word] of STATE_WORDS)
469
633
  if (t.byState[state])
@@ -476,9 +640,15 @@ function status(args, deps) {
476
640
  deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
477
641
  for (const l of r.lanes) {
478
642
  deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} `
479
- + `${String(activeOf(l.objects, l.byState)).padStart(6)} ${String(provenOf(l.byState)).padStart(6)} `
643
+ + `${String(laneActiveOf(l)).padStart(6)} ${String(laneProvenOf(l)).padStart(6)} `
480
644
  + `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
481
645
  }
646
+ // Done and what comes next, by the same priority every surface sorts by.
647
+ // Names only: a lane id stands only beside its owner's name.
648
+ for (const l of r.lanes) {
649
+ const next = l.next.slice(0, 3).map((k) => k.slice(k.indexOf('|') + 1));
650
+ deps.print(` ${l.owner} (${l.id}): Done ${l.donePct}%. Next: ${next.length ? next.join(', ') : 'nothing left to prove'}`);
651
+ }
482
652
  // Said once per lane it is true of. A lane set up before this rule held
483
653
  // both a package list and a name list, and took the whole package.
484
654
  for (const id of narrowed)
@@ -489,6 +659,10 @@ function status(args, deps) {
489
659
  deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
490
660
  if (t.overlapping)
491
661
  deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
662
+ if (t.retireCandidates)
663
+ deps.print(`${plural(t.retireCandidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
664
+ if (t.outOfScope)
665
+ deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
492
666
  if (t.conflicts) {
493
667
  deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
494
668
  + ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
@@ -544,6 +718,7 @@ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
544
718
  */
545
719
  export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
546
720
  + '[--variant <NAME>] [--force-variant] [--chunk <n>] [--max-minutes <n>] [--no-one-by-one] [--sap-alias <name>]\n'
721
+ + ' or: cspeach team refresh (--all | --packages "ZFI_*,ZSD_*") --per-package [--again] [--variant <NAME>]\n'
547
722
  + ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
548
723
  + '[--variant <NAME>] [--absent-means-clean]';
549
724
  /** Options that take a value, with the example each refusal shows. */
@@ -556,7 +731,7 @@ const VALUE_OPTIONS = {
556
731
  'sap-alias': '--sap-alias S4H',
557
732
  'from-file': '--from-file atc-export.csv',
558
733
  };
559
- const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean'];
734
+ const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean', 'per-package', 'again'];
560
735
  const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
561
736
  /** Options that describe a check RUN. A file was made by a run that is already over. */
562
737
  const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
@@ -646,6 +821,11 @@ export function parseRefreshArgs(args) {
646
821
  }
647
822
  const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
648
823
  const fromFile = opts['from-file'];
824
+ if (opts['per-package'] !== undefined && (fromFile !== undefined || opts.lane !== undefined)) {
825
+ return no('--per-package goes with --all or --packages: it measures one package at a time.');
826
+ }
827
+ if (opts.again !== undefined && opts['per-package'] === undefined)
828
+ return no('--again only goes with --per-package.');
649
829
  if (fromFile === undefined) {
650
830
  if (given.length !== 1)
651
831
  return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
@@ -689,6 +869,8 @@ export function parseRefreshArgs(args) {
689
869
  oneByOne: opts['no-one-by-one'] === undefined,
690
870
  absentMeansClean: opts['absent-means-clean'] !== undefined,
691
871
  ...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
872
+ ...(opts['per-package'] !== undefined ? { perPackage: true } : {}),
873
+ ...(opts.again !== undefined ? { again: true } : {}),
692
874
  };
693
875
  if (opts.variant !== undefined) {
694
876
  const variant = opts.variant.trim().toUpperCase();
@@ -805,9 +987,49 @@ export const absentLine = (count, names) => [
805
987
  + `${count === 1 ? 'It is' : 'They are'} left as not measured.`,
806
988
  ...(names.length ? [` ${someNames(names)}`] : []),
807
989
  ];
990
+ /**
991
+ * R-1, said plainly: how many silent objects were checked again on their own,
992
+ * and — for the ones that were not — the true reason. An object the second pass
993
+ * skipped or never finished gets THIS reason, never the variant's.
994
+ */
995
+ function secondPassLines(res, deps) {
996
+ const pass = res.secondPass;
997
+ if (!pass)
998
+ return;
999
+ if (pass.asked) {
1000
+ deps.print(` ${plural(pass.asked, 'object the run did not list was', 'objects the run did not list were')} checked again on `
1001
+ + `${pass.asked === 1 ? 'its' : 'their'} own: ${pass.listed} listed.`);
1002
+ }
1003
+ const n = pass.notChecked ?? 0;
1004
+ const own = n === 1 ? 'on its own and stays' : 'on their own and stay';
1005
+ if (pass.stopped) {
1006
+ const why = STOPPED_WORDS[pass.stopped];
1007
+ deps.print(n
1008
+ ? ` ! Checking those objects one by one ${why} before it finished: ${plural(n, 'object was', 'objects were')} not checked again ${own} not measured.`
1009
+ : ` ! Checking those objects one by one ${why} before it finished.`);
1010
+ }
1011
+ else if (n) {
1012
+ deps.print(` ! ${plural(n, 'object was', 'objects were')} not checked again: ${n === 1 ? 'its own check' : 'their own checks'} did not finish. `
1013
+ + `${n === 1 ? 'It stays' : 'They stay'} not measured.`);
1014
+ }
1015
+ if (pass.skipped && pass.skippedBy === 'time') {
1016
+ deps.print(` ! ${plural(pass.skipped, 'object the run did not list was', 'objects the run did not list were')} not checked again on `
1017
+ + `${pass.skipped === 1 ? 'its' : 'their'} own: the time budget ran out first. ${pass.skipped === 1 ? 'It stays' : 'They stay'} not measured.`);
1018
+ }
1019
+ else if (pass.skipped) {
1020
+ deps.print(` ! ${plural(pass.skipped, 'more object was', 'more objects were')} not checked again `
1021
+ + `(one measurement checks up to ${MAX_SECOND_PASS} on their own). Narrow the run with --lane or --packages to check the rest.`);
1022
+ }
1023
+ if ((n || pass.skipped) && res.notCheckedNames?.length)
1024
+ deps.print(` ${someNames(res.notCheckedNames)}`);
1025
+ }
808
1026
  function notMeasuredLines(res, deps, words = {}) {
809
- if (res.absent) {
810
- for (const line of words.absentLines?.(res.absent) ?? absentLine(res.absent, res.absentNames))
1027
+ // Objects the second pass skipped or never finished were said above, with
1028
+ // their own reason. Only the rest are the variant's silence.
1029
+ const unchecked = new Set(res.notCheckedNames ?? []);
1030
+ const silent = Math.max(0, res.absent - (res.secondPass?.skipped ?? 0) - (res.secondPass?.notChecked ?? 0));
1031
+ if (silent) {
1032
+ for (const line of words.absentLines?.(silent) ?? absentLine(silent, res.absentNames.filter((k) => !unchecked.has(k))))
811
1033
  deps.print(line);
812
1034
  }
813
1035
  for (const c of res.refusedChunks) {
@@ -826,10 +1048,12 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
826
1048
  deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
827
1049
  for (const note of words.notes ?? [])
828
1050
  deps.print(note);
1051
+ secondPassLines(res, deps);
829
1052
  notMeasuredLines(res, deps, words);
830
1053
  return;
831
1054
  }
832
1055
  deps.print(words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`);
1056
+ secondPassLines(res, deps);
833
1057
  // F3: SAP reports some findings twice — same place, same message, two finding
834
1058
  // ids. One problem in the code is one finding, so it is counted once; a
835
1059
  // person comparing this with their own ATC screen has to be told why the two
@@ -850,6 +1074,11 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
850
1074
  deps.print(` Written to ${res.file}`);
851
1075
  deps.print(' Next: cspeach team status');
852
1076
  }
1077
+ /**
1078
+ * A second pass that was cut short, or never had time to start, leaves objects
1079
+ * not checked again: the measurement is partial, and the exit code says so.
1080
+ */
1081
+ const secondPassStop = (res) => res.secondPass?.stopped ?? (res.secondPass?.skippedBy === 'time' && res.secondPass.skipped ? 'deadline' : undefined);
853
1082
  /** Why a run ended before it reached the end of the set, in plain words. */
854
1083
  const STOPPED_WORDS = {
855
1084
  deadline: 'ran out of time',
@@ -881,7 +1110,7 @@ function stillStanding(register, absentNames) {
881
1110
  * `once`, on purpose: the SECOND press is Node's own, so a request that has
882
1111
  * hung can always be quit.
883
1112
  */
884
- function onInterrupt(deps, stop) {
1113
+ export function onInterrupt(deps, stop) {
885
1114
  if (deps.onInterrupt)
886
1115
  return deps.onInterrupt(stop);
887
1116
  const handler = () => stop();
@@ -998,6 +1227,7 @@ function refreshFromFile(opts, deps) {
998
1227
  });
999
1228
  if (!res.file)
1000
1229
  return 1;
1230
+ refreshSheet(deps);
1001
1231
  // Live evidence outranks file evidence, per object. An import that changes no
1002
1232
  // state is legal and is kept — but nobody should have to work that out from
1003
1233
  // the picture afterwards, and no closing line may read as a fresh measurement.
@@ -1020,6 +1250,16 @@ async function refresh(args, deps) {
1020
1250
  deps.print(CANNOT_MEASURE);
1021
1251
  return 2;
1022
1252
  }
1253
+ // 1.1.31: no scope said → your own lane, when you own exactly one.
1254
+ if (!args.some((a) => /^--(all|lane|packages|from-file)(=|$)/.test(a))) {
1255
+ const joinedNow = activeProject();
1256
+ if (joinedNow) {
1257
+ const me = whoAmI(deps).trim().toLowerCase();
1258
+ const mine = readLanes(joinedNow.projectDir).filter((l) => l.owner.trim().toLowerCase() === me);
1259
+ if (mine.length === 1)
1260
+ args = [...args, '--lane', mine[0].id];
1261
+ }
1262
+ }
1023
1263
  const parsed = parseRefreshArgs(args);
1024
1264
  if (!parsed.ok)
1025
1265
  throw new RegisterError(parsed.message);
@@ -1057,6 +1297,8 @@ async function refresh(args, deps) {
1057
1297
  deps.print(CANNOT_MEASURE);
1058
1298
  return 2;
1059
1299
  }
1300
+ if (opts.perPackage)
1301
+ return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts }, deps);
1060
1302
  deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
1061
1303
  const stop = new AbortController();
1062
1304
  const off = onInterrupt(deps, () => {
@@ -1079,6 +1321,7 @@ async function refresh(args, deps) {
1079
1321
  // the lead's first run, whose name is not on disk yet.
1080
1322
  ...(foreign || persist ? { forceVariant: true } : {}),
1081
1323
  signal: stop.signal,
1324
+ confirmAbsent: true,
1082
1325
  onProgress: progressLine(deps),
1083
1326
  });
1084
1327
  }
@@ -1086,6 +1329,8 @@ async function refresh(args, deps) {
1086
1329
  off();
1087
1330
  }
1088
1331
  reportMeasurement(res, variant, deps, foreign ? (project.atcVariant ?? '').trim().toUpperCase() : undefined);
1332
+ if (res.file)
1333
+ refreshSheet(deps);
1089
1334
  // The picture, once, for the two things said after the report. Built lazily:
1090
1335
  // a refresh that measured nothing and reported nothing does not need it.
1091
1336
  let merged = null;
@@ -1111,7 +1356,7 @@ async function refresh(args, deps) {
1111
1356
  + `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
1112
1357
  }
1113
1358
  }
1114
- const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
1359
+ const stoppedBy = res.stoppedBy ?? secondPassStop(res) ?? (stop.signal.aborted ? 'aborted' : undefined);
1115
1360
  if (stoppedBy) {
1116
1361
  const why = STOPPED_WORDS[stoppedBy];
1117
1362
  deps.print(res.file
@@ -1129,6 +1374,117 @@ async function refresh(args, deps) {
1129
1374
  }
1130
1375
  return 0;
1131
1376
  }
1377
+ /**
1378
+ * D6: the estate measured one package at a time, one measurement record each,
1379
+ * through the same `measure()` as every run. A package that already has a
1380
+ * counted record of this variant for exactly its own scope is skipped unless
1381
+ * `--again`, so a long estate run can be stopped and taken up again. Ctrl-C
1382
+ * stops between packages; every record already written stays.
1383
+ */
1384
+ async function refreshPerPackage(input, deps) {
1385
+ const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts } = input;
1386
+ const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1387
+ const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
1388
+ // A package is done only when a counted run from the system (never a result file),
1389
+ // with this variant and this package as its scope, says it reached every object:
1390
+ // nothing failed, nothing left not run. A run cut short by Ctrl-C, the deadline or
1391
+ // a lost connection wrote a partial record, and that package is measured again.
1392
+ const done = new Set();
1393
+ if (!opts.again) {
1394
+ for (const run of registerFor(joined.projectDir, joined.outboxDir).register.runs) {
1395
+ if ((run.atcVariant ?? '').trim().toUpperCase() !== variant || !run.scope || run.basis !== 'system')
1396
+ continue;
1397
+ const c = run.coverage;
1398
+ // notRun must be said and be 0: a record that does not say is not proof the run got everywhere.
1399
+ if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
1400
+ && c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked)
1401
+ done.add(run.scope);
1402
+ }
1403
+ }
1404
+ const todo = packages.filter((p) => !done.has(labelOf(p)));
1405
+ const skipped = packages.length - todo.length;
1406
+ if (skipped) {
1407
+ deps.print(`Skipped ${plural(skipped, 'package', 'packages')} already measured (use --again to measure ${skipped === 1 ? 'it' : 'them'} again).`);
1408
+ }
1409
+ if (!todo.length)
1410
+ return 0;
1411
+ deps.print(`Measuring ${plural(todo.length, 'package', 'packages')} one at a time with ${variant}. Ctrl-C stops between packages and keeps what is written.`);
1412
+ // Ctrl-C is checked between packages, never handed into a package's run: a package
1413
+ // is measured whole or not at all. (A deadline or a lost connection can still cut a
1414
+ // package short; its record then shows it, and the next run measures it again.)
1415
+ const stop = new AbortController();
1416
+ const off = onInterrupt(deps, () => {
1417
+ if (stop.signal.aborted)
1418
+ return;
1419
+ deps.print(' Stopping after this package. What has already been measured will still be written.');
1420
+ deps.print(' Press Ctrl-C again to quit now.');
1421
+ stop.abort();
1422
+ });
1423
+ let measuredPackages = 0;
1424
+ let written = 0;
1425
+ let variantSaved = false;
1426
+ let lostConnection = false;
1427
+ try {
1428
+ for (const pkg of todo) {
1429
+ if (stop.signal.aborted)
1430
+ break;
1431
+ const objects = inScope.filter((o) => o.package === pkg).length;
1432
+ deps.print(`${pkg}: ${plural(objects, 'object', 'objects')}…`);
1433
+ const res = await measure({
1434
+ joined, project, baseline, lanes, scope: { kind: 'packages', patterns: [pkg] }, variant, atc, by: { name: whoAmI(deps) },
1435
+ ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
1436
+ ...(opts.chunkSize ? { chunkSize: opts.chunkSize } : {}),
1437
+ ...(opts.maxTotalMs ? { maxTotalMs: opts.maxTotalMs } : {}),
1438
+ ...(opts.oneByOne ? {} : { oneByOne: false }),
1439
+ ...(foreign || persist ? { forceVariant: true } : {}),
1440
+ confirmAbsent: true,
1441
+ onProgress: progressLine(deps),
1442
+ });
1443
+ measuredPackages += 1;
1444
+ if (res.file) {
1445
+ written += 1;
1446
+ deps.print(` ✓ ${pkg}: ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings. Written: ${path.basename(res.file)}`);
1447
+ }
1448
+ else {
1449
+ deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
1450
+ }
1451
+ secondPassLines(res, deps);
1452
+ notMeasuredLines(res, deps);
1453
+ if (res.file && persist && !variantSaved) {
1454
+ try {
1455
+ writeProjectVariant(joined.projectDir, variant);
1456
+ variantSaved = true;
1457
+ deps.print(`✓ This team project now measures with ${variant}.`);
1458
+ }
1459
+ catch (e) {
1460
+ deps.print(` ! The measurement is written, but ${TEAM_FILE} could not be updated, so this team project still has no check variant. `
1461
+ + `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
1462
+ }
1463
+ }
1464
+ if (res.stoppedBy === 'connection') {
1465
+ lostConnection = true;
1466
+ break;
1467
+ }
1468
+ }
1469
+ }
1470
+ finally {
1471
+ off();
1472
+ }
1473
+ if (foreign) {
1474
+ deps.print(` ! Made with ${variant}, not the team project's ${(project.atcVariant ?? '').trim().toUpperCase()}. `
1475
+ + 'The records are kept, but they are not counted in the picture.');
1476
+ }
1477
+ if (written)
1478
+ refreshSheet(deps);
1479
+ if (stop.signal.aborted || lostConnection) {
1480
+ deps.print(`✗ ${lostConnection ? 'Lost the connection to SAP' : 'Stopped'} after ${measuredPackages} of ${todo.length} packages. What was measured is written. `
1481
+ + 'Run the same command again to measure the rest.');
1482
+ return 3;
1483
+ }
1484
+ deps.print(`✓ ${plural(written, 'package', 'packages')} measured, one record each.`);
1485
+ deps.print(' Next: cspeach team status');
1486
+ return written ? 0 : 1;
1487
+ }
1132
1488
  // ── outbox / rule ───────────────────────────────────────────────────────────
1133
1489
  function outbox(_args, deps) {
1134
1490
  const joined = joinedOrThrow();
@@ -1147,15 +1503,25 @@ function outbox(_args, deps) {
1147
1503
  deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
1148
1504
  return 0;
1149
1505
  }
1506
+ const RETIRE_RULING_NEEDS_REASON = 'A retire is confirmed with a reason: --reason "no callers (index read OK); usage unknown on this system; owner confirms"';
1150
1507
  function rule(args, deps) {
1151
1508
  const { positional, opts } = flags(args);
1152
1509
  const [type, name, classification] = positional;
1153
1510
  if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
1154
- throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
1155
- }
1511
+ throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"] (a retire needs the reason)`);
1512
+ }
1513
+ // Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
1514
+ if (opts.reason === 'true')
1515
+ throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
1516
+ const reason = (opts.reason ?? '').trim();
1517
+ if (reason.length > MAX_REASON)
1518
+ throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
1519
+ // The lead's retire is what makes an object retired (D2), so it carries what was checked.
1520
+ if (classification === 'retire' && !reason)
1521
+ throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
1156
1522
  const joined = joinedOrThrow();
1157
1523
  requireLead(readProjectFile(joined.projectDir), deps);
1158
- const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(opts.reason ? { reason: opts.reason } : {}) }], { name: whoAmI(deps) }, {
1524
+ const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(reason ? { reason } : {}) }], { name: whoAmI(deps) }, {
1159
1525
  dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
1160
1526
  ...(deps.rand ? { rand: deps.rand } : {}),
1161
1527
  });
@@ -1164,3 +1530,243 @@ function rule(args, deps) {
1164
1530
  deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
1165
1531
  return 0;
1166
1532
  }
1533
+ // ── decide ──────────────────────────────────────────────────────────────────
1534
+ const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"]';
1535
+ const DECIDE_OPTIONS = ['level', 'target', 'reason'];
1536
+ /** The five words a person types, onto the four classifications the files hold (spec §4.2). */
1537
+ const DECIDE_WORDS = {
1538
+ keep: { classification: 'keep' },
1539
+ exception: { classification: 'keep', exception: true },
1540
+ fix: { classification: 'fix' },
1541
+ rebuild: { classification: 'redesign' },
1542
+ redesign: { classification: 'redesign' },
1543
+ retire: { classification: 'retire' },
1544
+ };
1545
+ const MAX_REASON = 300;
1546
+ export function parseDecideArgs(args) {
1547
+ const { positional, opts } = flags(args);
1548
+ for (const name of Object.keys(opts)) {
1549
+ if (!DECIDE_OPTIONS.includes(name))
1550
+ return { ok: false, message: `--${name} is not an option of team decide.\n${DECIDE_USAGE}` };
1551
+ }
1552
+ const [type, name, word] = positional;
1553
+ const said = word ? DECIDE_WORDS[word.toLowerCase()] : undefined;
1554
+ if (!type || !name || !said || positional.length > 3)
1555
+ return { ok: false, message: DECIDE_USAGE };
1556
+ const letter = (flag) => {
1557
+ if (opts[flag] === undefined)
1558
+ return { ok: true };
1559
+ const value = levelOf(opts[flag].trim().toUpperCase());
1560
+ return value ? { ok: true, value } : { ok: false };
1561
+ };
1562
+ const level = letter('level');
1563
+ const target = letter('target');
1564
+ if (!level.ok || !target.ok)
1565
+ return { ok: false, message: 'A level is one of A, B, C or D. A uses only released APIs; D modifies SAP standard.' };
1566
+ const moves = said.classification === 'fix' || said.classification === 'redesign';
1567
+ if (target.value && !moves)
1568
+ return { ok: false, message: 'A target level only goes with fix or rebuild.' };
1569
+ if (opts.reason === 'true')
1570
+ return { ok: false, message: '--reason needs words, for example: --reason "reads BSID directly"' };
1571
+ const reason = (opts.reason ?? '').trim();
1572
+ if (reason.length > MAX_REASON)
1573
+ return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
1574
+ if (said.exception && !reason)
1575
+ return { ok: false, message: 'An accepted exception needs a reason: --reason "<why it stays>"' };
1576
+ if (said.classification === 'retire' && !reason) {
1577
+ return { ok: false, message: 'A retire needs a reason: say what was checked, for example: --reason "no callers (index read OK); usage unknown on this system"' };
1578
+ }
1579
+ return {
1580
+ ok: true,
1581
+ value: {
1582
+ type: type.toUpperCase(), name: name.toUpperCase(),
1583
+ row: {
1584
+ classification: said.classification,
1585
+ ...(said.exception ? { exception: true } : {}),
1586
+ ...(level.value ? { level: level.value } : {}),
1587
+ // Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
1588
+ ...(moves ? { targetLevel: target.value ?? 'A' } : {}),
1589
+ ...(reason ? { reason } : {}),
1590
+ },
1591
+ },
1592
+ };
1593
+ }
1594
+ const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
1595
+ function decisionWords(row) {
1596
+ const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
1597
+ if (row.level)
1598
+ parts.push(`level ${row.level}`);
1599
+ if (row.targetLevel)
1600
+ parts.push(`target ${row.targetLevel}`);
1601
+ return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
1602
+ }
1603
+ function decide(args, deps) {
1604
+ const parsed = parseDecideArgs(args);
1605
+ if (!parsed.ok)
1606
+ throw new RegisterError(parsed.message);
1607
+ const joined = joinedOrThrow();
1608
+ const { type, name, row } = parsed.value;
1609
+ const res = writeRecord(joined, 'decision', [{ key: objectKey(type, name), ...row }], { name: whoAmI(deps) }, {
1610
+ ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
1611
+ });
1612
+ if (!res.file)
1613
+ throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
1614
+ deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
1615
+ if (row.classification === 'retire')
1616
+ deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
1617
+ refreshSheet(deps);
1618
+ return 0;
1619
+ }
1620
+ // ── fixed ───────────────────────────────────────────────────────────────────
1621
+ const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
1622
+ const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
1623
+ /**
1624
+ * "I fixed this" — a claim, matched to a finding by (object, rule). It moves no
1625
+ * colour to proven: only the next measurement does (spec §4.3).
1626
+ */
1627
+ async function fixed(args, deps) {
1628
+ const { positional, opts } = flags(args);
1629
+ for (const name of Object.keys(opts)) {
1630
+ if (name !== 'rule' && name !== 'transport')
1631
+ throw new RegisterError(`--${name} is not an option of team fixed.\n${FIXED_USAGE}`);
1632
+ }
1633
+ const [rawType, rawName] = positional;
1634
+ if (!rawType || !rawName || positional.length > 2)
1635
+ throw new RegisterError(FIXED_USAGE);
1636
+ const type = rawType.toUpperCase();
1637
+ const name = rawName.toUpperCase();
1638
+ const joined = joinedOrThrow();
1639
+ const key = objectKey(type, name);
1640
+ if (!readBaseline(joined.projectDir).some((o) => objectKey(o.type, o.name) === key)) {
1641
+ // `shown()`: the person's own arguments, still printed through CSPeach.
1642
+ throw new RegisterError(`${shown(type)} ${shown(name)} is not in this team project's object list.`);
1643
+ }
1644
+ let transport;
1645
+ if (opts.transport !== undefined) {
1646
+ transport = opts.transport === 'true' ? '' : opts.transport.trim().toUpperCase();
1647
+ if (!/^[A-Z0-9]{1,20}$/.test(transport))
1648
+ throw new RegisterError('--transport needs the request number, for example: --transport S4HK903500');
1649
+ }
1650
+ let rules;
1651
+ if (opts.rule !== undefined) {
1652
+ const rule = opts.rule === 'true' ? '' : normalizeRuleId(opts.rule);
1653
+ if (!rule)
1654
+ throw new RegisterError('--rule needs the rule id, for example: --rule "API:BSID"');
1655
+ rules = [rule];
1656
+ }
1657
+ else {
1658
+ const f = findingsByObject(joined.projectDir, joined.outboxDir).get(key);
1659
+ if (!f?.latestAt)
1660
+ throw new RegisterError('Nothing measured yet to claim against; name the rule with --rule.');
1661
+ if (!f.latest.length) {
1662
+ deps.print(`Nothing is open for ${type} ${name} at its last measurement (${utcLine(f.latestAt)}). Nothing was recorded.`);
1663
+ return 0;
1664
+ }
1665
+ deps.print(`Open for ${type} ${name} at its last measurement, ${utcLine(f.latestAt)}:`);
1666
+ for (const r of f.latest.slice(0, 10))
1667
+ deps.print(` ${r.count}× ${r.rule}${r.message ? ` — ${r.message}` : ''}`);
1668
+ if (f.latest.length > 10)
1669
+ deps.print(` and ${f.latest.length - 10} more`);
1670
+ if (!deps.ask)
1671
+ throw new RegisterError('Marking findings fixed needs a yes. In a script, name each rule with --rule.');
1672
+ const total = f.latest.reduce((n, r) => n + r.count, 0);
1673
+ const answer = (await deps.ask(`Mark these ${plural(total, 'finding', 'findings')} as fixed? [y/n]`)).trim().toLowerCase();
1674
+ if (answer !== 'y' && answer !== 'yes') {
1675
+ deps.print('Nothing was recorded.');
1676
+ return 1;
1677
+ }
1678
+ rules = f.latest.map((r) => r.rule);
1679
+ }
1680
+ writeRecord(joined, 'fix', rules.map((rule) => ({ key, rule, status: 'fixed', ...(transport ? { transport } : {}) })), { name: whoAmI(deps) }, { ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}) });
1681
+ deps.print(`✓ Claimed: ${type} ${name}, ${plural(rules.length, 'rule', 'rules')} marked fixed. A claim is not proof — the next cspeach team refresh checks it.`);
1682
+ refreshSheet(deps);
1683
+ return 0;
1684
+ }
1685
+ // ── sheet ───────────────────────────────────────────────────────────────────
1686
+ function sheet(deps) {
1687
+ const joined = joinedOrThrow();
1688
+ const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
1689
+ if (!res.ownsLane) {
1690
+ deps.print('You own no lane in this team project, so there is no working list to write. The lead adds lanes with: cspeach team lane add');
1691
+ return 0;
1692
+ }
1693
+ if (res.line)
1694
+ deps.print(res.line.trimStart());
1695
+ return res.file ? 0 : 1;
1696
+ }
1697
+ // ── report ──────────────────────────────────────────────────────────────────
1698
+ export const heldReportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`;
1699
+ /**
1700
+ * The combined workbook and the status file, made from the team project folder
1701
+ * (the same files the team page reads) and written whole, then renamed, into
1702
+ * `outDir`. Used by `report` and by `plan import`, which makes them again so the
1703
+ * lead sees the new export stamp.
1704
+ *
1705
+ * A report open in Excel is the likeliest failure here: Windows refuses the
1706
+ * rename. Each file is tried on its own, so one held file never costs the
1707
+ * other, and the caller is told by file name which one was not updated.
1708
+ * writeWhole has already removed its .tmp when it throws.
1709
+ */
1710
+ export function writeReports(projectDir, project, outDir, deps) {
1711
+ const picture = registerFor(projectDir);
1712
+ const asOf = (deps.now?.() ?? new Date()).toISOString();
1713
+ const folderName = path.basename(projectDir);
1714
+ const names = reportFileNames(project.name);
1715
+ // The discovery file as the folder holds it: the status file says "could not be read"
1716
+ // for one register-core cannot read, the way the page does.
1717
+ const discovery = readEstate(projectDir);
1718
+ const held = [];
1719
+ const attempt = (name, write) => {
1720
+ try {
1721
+ write();
1722
+ return true;
1723
+ }
1724
+ catch (e) {
1725
+ if (!HELD.has(e?.code ?? ''))
1726
+ throw e;
1727
+ held.push(name);
1728
+ return false;
1729
+ }
1730
+ };
1731
+ const saved = [
1732
+ attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName))) ? names.xlsx : null,
1733
+ attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
1734
+ project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
1735
+ checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
1736
+ ...(discovery ? { estate: discovery.estate } : {}),
1737
+ }))) ? names.html : null,
1738
+ ].filter((n) => n !== null);
1739
+ return { saved, held };
1740
+ }
1741
+ const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
1742
+ /**
1743
+ * Lead only (spec §4.5). Reads the team project folder — the same files the team
1744
+ * page reads, so its numbers are the page's — and writes two files whole, then
1745
+ * renamed, into `reports/` or the folder named with --out. Never `writers/`.
1746
+ */
1747
+ function report(args, deps) {
1748
+ const { positional, opts } = flags(args);
1749
+ if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
1750
+ throw new RegisterError(REPORT_USAGE);
1751
+ const joined = joinedOrThrow();
1752
+ const project = readProjectFile(joined.projectDir);
1753
+ requireLead(project, deps);
1754
+ const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
1755
+ const { saved, held } = writeReports(joined.projectDir, project, outDir, deps);
1756
+ const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
1757
+ if (saved.length)
1758
+ deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
1759
+ for (const name of held)
1760
+ deps.print(heldReportLine(name));
1761
+ // Records on this laptop that have not reached the folder are not in the report.
1762
+ const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
1763
+ const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
1764
+ if (path.resolve(ownDir) !== path.resolve(inFolder) && fs.existsSync(ownDir)) {
1765
+ const missing = fs.readdirSync(ownDir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(inFolder, f))).length;
1766
+ if (missing) {
1767
+ deps.print(` ${plural(missing, 'record file on this laptop is', 'record files on this laptop are')} not in the team project folder yet, `
1768
+ + `so ${missing === 1 ? 'it is' : 'they are'} not in this report.`);
1769
+ }
1770
+ }
1771
+ return held.length ? 1 : 0;
1772
+ }