@cspeach/cli 1.1.30 → 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 (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -18,18 +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, levelOf, normalizeRuleId, 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, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.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
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
27
  import { findingsByObject } from '../register/findings.js';
28
28
  import { registerFor } from '../register/picture.js';
29
29
  import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
30
30
  import { writeXlsxFile } from '../register/xlsx.js';
31
31
  import { writeWhole } from '../register/write-whole.js';
32
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';
33
35
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
34
36
  const TYPES = ['clean-core', 'upgrade', 'build'];
35
37
  /**
@@ -43,7 +45,7 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
43
45
  * compares this with the lead's name, a ruling only counts when it comes from
44
46
  * the lead, and every record says who wrote it.
45
47
  */
46
- const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
48
+ export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
47
49
  /** After a record this laptop wrote: its working list, made again. One line, or none. */
48
50
  function refreshSheet(deps) {
49
51
  const joined = activeProject();
@@ -74,7 +76,7 @@ function looksLikeAMachine(name, deps) {
74
76
  * and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
75
77
  * has always taken. The verbs that pass nothing read exactly as before.
76
78
  */
77
- function flags(args, flagOnly = []) {
79
+ export function flags(args, flagOnly = []) {
78
80
  const positional = [];
79
81
  const opts = {};
80
82
  for (let i = 0; i < args.length; i++) {
@@ -97,7 +99,7 @@ function flags(args, flagOnly = []) {
97
99
  }
98
100
  return { positional, opts };
99
101
  }
100
- const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
102
+ export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
101
103
  /** "1 object", "3 objects" — every count this command prints reads as a sentence. */
102
104
  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
103
105
  /** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
@@ -110,8 +112,9 @@ export async function runTeamCommand(args, deps) {
110
112
  try {
111
113
  switch (sub) {
112
114
  case 'init': return await init(rest, deps);
115
+ case 'start': return await start(rest, deps);
113
116
  case 'join': return join(rest, deps);
114
- case 'lane': return lane(rest, deps);
117
+ case 'lane': return await lane(rest, deps);
115
118
  case 'refresh': return await refresh(rest, deps);
116
119
  case 'status': return status(rest, deps);
117
120
  case 'outbox': return outbox(rest, deps);
@@ -121,8 +124,12 @@ export async function runTeamCommand(args, deps) {
121
124
  case 'fixed': return await fixed(rest, deps);
122
125
  case 'sheet': return sheet(deps);
123
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);
124
131
  default:
125
- deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report (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)');
126
133
  return sub ? 1 : 0;
127
134
  }
128
135
  }
@@ -131,16 +138,22 @@ export async function runTeamCommand(args, deps) {
131
138
  deps.print(`✗ ${e.message}`);
132
139
  return 1;
133
140
  }
134
- 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;
135
148
  }
136
149
  }
137
- function requireLead(project, deps) {
150
+ export function requireLead(project, deps) {
138
151
  const me = whoAmI(deps);
139
152
  if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
140
153
  throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
141
154
  }
142
155
  }
143
- function joinedOrThrow() {
156
+ export function joinedOrThrow() {
144
157
  const joined = activeProject();
145
158
  if (!joined)
146
159
  throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
@@ -148,11 +161,11 @@ function joinedOrThrow() {
148
161
  }
149
162
  // ── init ────────────────────────────────────────────────────────────────────
150
163
  const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<your name>"] [--type clean-core|upgrade|build] '
151
- + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure]';
164
+ + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
152
165
  /** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
153
- 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'];
154
167
  /** The two that take no value, so neither can eat the folder name. */
155
- const INIT_FLAGS = ['include-local', 'no-measure'];
168
+ const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
156
169
  /** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
157
170
  const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
158
171
  /**
@@ -178,6 +191,60 @@ function unknownInitOption(name) {
178
191
  return said;
179
192
  return `${said} Did you mean --${guess}?`;
180
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
+ }
181
248
  async function init(args, deps) {
182
249
  const { positional, opts } = flags(args, INIT_FLAGS);
183
250
  // `--no-measur` must not read as "measure anyway": a person who meant to skip
@@ -210,6 +277,11 @@ async function init(args, deps) {
210
277
  throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
211
278
  if (!deps.connect)
212
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);
213
285
  const { sql, system, atc } = await deps.connect(opts['sap-alias']);
214
286
  deps.print('Reading the package list from SAP…');
215
287
  const all = await listCustomPackages(sql);
@@ -225,6 +297,9 @@ async function init(args, deps) {
225
297
  if (!chosen.length) {
226
298
  throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
227
299
  }
300
+ if (discovery)
301
+ for (const l of estatePackageLines(chosen, discovery.estate))
302
+ deps.print(l);
228
303
  deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
229
304
  const pull = await pullBaseline(sql, { packages: chosen });
230
305
  if (!pull.objects.length)
@@ -244,9 +319,14 @@ async function init(args, deps) {
244
319
  fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
245
320
  fs.writeFileSync(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
246
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
+ : [];
247
325
  deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
248
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.`);
249
327
  deps.print(` Written to ${dir}`);
328
+ for (const l of evidenceLines)
329
+ deps.print(l);
250
330
  // F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
251
331
  // user. That name goes into the team project file, into the writer id and
252
332
  // into every record from then on. Said once, never asked.
@@ -272,6 +352,62 @@ async function init(args, deps) {
272
352
  deps.print(l);
273
353
  return 0;
274
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
+ }
275
411
  /** Errors that are about this laptop's disk or the shared folder, never about SAP. */
276
412
  const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
277
413
  /**
@@ -376,6 +512,11 @@ function join(args, deps, asName) {
376
512
  state.active = project.projectId;
377
513
  saveState(state);
378
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
+ }
379
520
  deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
380
521
  deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
381
522
  deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
@@ -394,8 +535,10 @@ function leave(deps) {
394
535
  return 0;
395
536
  }
396
537
  // ── lane ────────────────────────────────────────────────────────────────────
397
- function lane(args, deps) {
538
+ async function lane(args, deps) {
398
539
  const [verb, ...rest] = args;
540
+ if (verb === 'propose')
541
+ return laneProposal(rest, deps);
399
542
  const { positional, opts } = flags(rest);
400
543
  const joined = joinedOrThrow();
401
544
  const project = readProjectFile(joined.projectDir);
@@ -410,14 +553,20 @@ function lane(args, deps) {
410
553
  requireLead(project, deps);
411
554
  const id = positional[0];
412
555
  if (!id)
413
- 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);
414
558
  if (verb === 'remove') {
415
559
  writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
416
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));
417
566
  return 0;
418
567
  }
419
568
  if (verb !== 'add')
420
- throw new RegisterError('lane: use add, remove or list.');
569
+ throw new RegisterError('lane: use add, remove, list or propose.');
421
570
  const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
422
571
  if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
423
572
  throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
@@ -435,6 +584,8 @@ function lane(args, deps) {
435
584
  deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
436
585
  if (reg.register.totals.unowned)
437
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));
438
589
  return 0;
439
590
  }
440
591
  // ── status ──────────────────────────────────────────────────────────────────
@@ -453,11 +604,21 @@ function status(args, deps) {
453
604
  const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
454
605
  const t = r.totals;
455
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
+ }
456
615
  // A clean sheet out of a file is somebody's export, not this system speaking.
457
616
  // The words "verified" and "clean" do not change; the line says how many of
458
617
  // them were measured one step removed, so nobody reads them as live proof.
459
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.
460
620
  deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
621
+ + `${t.cleanNothingToDecide ? `${t.cleanNothingToDecide} clean, nothing to decide. ` : ''}`
461
622
  + `${t.byState.verified} verified, ${t.byState.clean} clean`
462
623
  + `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
463
624
  // The newest run that counted — by its time, not by the order the files were
@@ -466,6 +627,7 @@ function status(args, deps) {
466
627
  deps.print(r.lastMeasuredAt
467
628
  ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
468
629
  : 'Not measured yet: "verified" and "clean" need an ATC run.');
630
+ deps.print(nowLine(r));
469
631
  deps.print('');
470
632
  for (const [state, word] of STATE_WORDS)
471
633
  if (t.byState[state])
@@ -478,9 +640,15 @@ function status(args, deps) {
478
640
  deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
479
641
  for (const l of r.lanes) {
480
642
  deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} `
481
- + `${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)} `
482
644
  + `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
483
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
+ }
484
652
  // Said once per lane it is true of. A lane set up before this rule held
485
653
  // both a package list and a name list, and took the whole package.
486
654
  for (const id of narrowed)
@@ -491,6 +659,10 @@ function status(args, deps) {
491
659
  deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
492
660
  if (t.overlapping)
493
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.`);
494
666
  if (t.conflicts) {
495
667
  deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
496
668
  + ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
@@ -546,6 +718,7 @@ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
546
718
  */
547
719
  export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
548
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'
549
722
  + ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
550
723
  + '[--variant <NAME>] [--absent-means-clean]';
551
724
  /** Options that take a value, with the example each refusal shows. */
@@ -558,7 +731,7 @@ const VALUE_OPTIONS = {
558
731
  'sap-alias': '--sap-alias S4H',
559
732
  'from-file': '--from-file atc-export.csv',
560
733
  };
561
- 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'];
562
735
  const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
563
736
  /** Options that describe a check RUN. A file was made by a run that is already over. */
564
737
  const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
@@ -648,6 +821,11 @@ export function parseRefreshArgs(args) {
648
821
  }
649
822
  const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
650
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.');
651
829
  if (fromFile === undefined) {
652
830
  if (given.length !== 1)
653
831
  return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
@@ -691,6 +869,8 @@ export function parseRefreshArgs(args) {
691
869
  oneByOne: opts['no-one-by-one'] === undefined,
692
870
  absentMeansClean: opts['absent-means-clean'] !== undefined,
693
871
  ...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
872
+ ...(opts['per-package'] !== undefined ? { perPackage: true } : {}),
873
+ ...(opts.again !== undefined ? { again: true } : {}),
694
874
  };
695
875
  if (opts.variant !== undefined) {
696
876
  const variant = opts.variant.trim().toUpperCase();
@@ -930,7 +1110,7 @@ function stillStanding(register, absentNames) {
930
1110
  * `once`, on purpose: the SECOND press is Node's own, so a request that has
931
1111
  * hung can always be quit.
932
1112
  */
933
- function onInterrupt(deps, stop) {
1113
+ export function onInterrupt(deps, stop) {
934
1114
  if (deps.onInterrupt)
935
1115
  return deps.onInterrupt(stop);
936
1116
  const handler = () => stop();
@@ -1070,6 +1250,16 @@ async function refresh(args, deps) {
1070
1250
  deps.print(CANNOT_MEASURE);
1071
1251
  return 2;
1072
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
+ }
1073
1263
  const parsed = parseRefreshArgs(args);
1074
1264
  if (!parsed.ok)
1075
1265
  throw new RegisterError(parsed.message);
@@ -1107,6 +1297,8 @@ async function refresh(args, deps) {
1107
1297
  deps.print(CANNOT_MEASURE);
1108
1298
  return 2;
1109
1299
  }
1300
+ if (opts.perPackage)
1301
+ return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts }, deps);
1110
1302
  deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
1111
1303
  const stop = new AbortController();
1112
1304
  const off = onInterrupt(deps, () => {
@@ -1182,6 +1374,117 @@ async function refresh(args, deps) {
1182
1374
  }
1183
1375
  return 0;
1184
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
+ }
1185
1488
  // ── outbox / rule ───────────────────────────────────────────────────────────
1186
1489
  function outbox(_args, deps) {
1187
1490
  const joined = joinedOrThrow();
@@ -1200,15 +1503,25 @@ function outbox(_args, deps) {
1200
1503
  deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
1201
1504
  return 0;
1202
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"';
1203
1507
  function rule(args, deps) {
1204
1508
  const { positional, opts } = flags(args);
1205
1509
  const [type, name, classification] = positional;
1206
1510
  if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
1207
- throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
1511
+ throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"] (a retire needs the reason)`);
1208
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);
1209
1522
  const joined = joinedOrThrow();
1210
1523
  requireLead(readProjectFile(joined.projectDir), deps);
1211
- 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) }, {
1212
1525
  dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
1213
1526
  ...(deps.rand ? { rand: deps.rand } : {}),
1214
1527
  });
@@ -1260,6 +1573,9 @@ export function parseDecideArgs(args) {
1260
1573
  return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
1261
1574
  if (said.exception && !reason)
1262
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
+ }
1263
1579
  return {
1264
1580
  ok: true,
1265
1581
  value: {
@@ -1296,6 +1612,8 @@ function decide(args, deps) {
1296
1612
  if (!res.file)
1297
1613
  throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
1298
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.');
1299
1617
  refreshSheet(deps);
1300
1618
  return 0;
1301
1619
  }
@@ -1377,28 +1695,26 @@ function sheet(deps) {
1377
1695
  return res.file ? 0 : 1;
1378
1696
  }
1379
1697
  // ── report ──────────────────────────────────────────────────────────────────
1380
- const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
1698
+ export const heldReportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`;
1381
1699
  /**
1382
- * Lead only (spec §4.5). Reads the team project folder — the same files the team
1383
- * page reads, so its numbers are the page's — and writes two files whole, then
1384
- * renamed, into `reports/` or the folder named with --out. Never `writers/`.
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.
1385
1709
  */
1386
- function report(args, deps) {
1387
- const { positional, opts } = flags(args);
1388
- if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
1389
- throw new RegisterError(REPORT_USAGE);
1390
- const joined = joinedOrThrow();
1391
- const project = readProjectFile(joined.projectDir);
1392
- requireLead(project, deps);
1393
- const picture = registerFor(joined.projectDir);
1710
+ export function writeReports(projectDir, project, outDir, deps) {
1711
+ const picture = registerFor(projectDir);
1394
1712
  const asOf = (deps.now?.() ?? new Date()).toISOString();
1395
- const folderName = path.basename(joined.projectDir);
1396
- const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
1713
+ const folderName = path.basename(projectDir);
1397
1714
  const names = reportFileNames(project.name);
1398
- // A report open in Excel is the likeliest failure here: Windows refuses the
1399
- // rename. Each file is tried on its own, so one held file never costs the
1400
- // other, and the lead is told by file name which one was not updated.
1401
- // writeWhole has already removed its .tmp when it throws.
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);
1402
1718
  const held = [];
1403
1719
  const attempt = (name, write) => {
1404
1720
  try {
@@ -1417,13 +1733,31 @@ function report(args, deps) {
1417
1733
  attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
1418
1734
  project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
1419
1735
  checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
1736
+ ...(discovery ? { estate: discovery.estate } : {}),
1420
1737
  }))) ? names.html : null,
1421
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);
1422
1756
  const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
1423
1757
  if (saved.length)
1424
1758
  deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
1425
1759
  for (const name of held)
1426
- deps.print(` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`);
1760
+ deps.print(heldReportLine(name));
1427
1761
  // Records on this laptop that have not reached the folder are not in the report.
1428
1762
  const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
1429
1763
  const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');