@cspeach/cli 1.1.30 → 1.1.32

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 (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -9,6 +9,7 @@
9
9
  * status [folder] anyone: the register as text
10
10
  * outbox what this laptop has recorded, and where
11
11
  * rule … lead: settle a disagreement
12
+ * rules … the decision rules: anyone views, the lead changes (team-rules.ts)
12
13
  * leave stop recording
13
14
  *
14
15
  * Every path written here is one a person typed. Nothing is written to a
@@ -18,18 +19,24 @@
18
19
  import fs from 'node:fs';
19
20
  import os from 'node:os';
20
21
  import path from 'node:path';
21
- import { activeOf, levelOf, normalizeRuleId, objectKey, provenOf, } from '@cspeach/register-core';
22
+ import { isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, proposeLanes, scopeFromWords, targetsOf, } from '@cspeach/register-core';
22
23
  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';
24
- import { MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
25
- import { measureFromFile, shown, upToFive } from '../register/atc-file.js';
26
- import { writeRecord } from '../register/records.js';
24
+ 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';
25
+ import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
26
+ import { measureFromFile, shown, shownUpTo, upToFive } from '../register/atc-file.js';
27
+ import { writeLeadRecord, writeRecord } from '../register/records.js';
27
28
  import { findingsByObject } from '../register/findings.js';
28
29
  import { registerFor } from '../register/picture.js';
29
30
  import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
30
31
  import { writeXlsxFile } from '../register/xlsx.js';
31
32
  import { writeWhole } from '../register/write-whole.js';
32
- import { statusHtmlFor } from '../register/status-html.js';
33
+ import { EXPORTS_DIR, exportsOn, setExports, writeExportFiles } from '../register/export-files.js';
34
+ import { previousAsOfIn, reportViewFor, statusHtmlFor } from '../register/status-html.js';
35
+ import { firstBlockLines, awaitingWord, lastCheckWord } from '../register/status-file/report-model.js';
36
+ import { lookupVariant, variantLookupLine } from '../register/variant.js';
37
+ import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
38
+ import { rulesCommand } from './team-rules.js';
39
+ import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
33
40
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
34
41
  const TYPES = ['clean-core', 'upgrade', 'build'];
35
42
  /**
@@ -43,7 +50,7 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
43
50
  * compares this with the lead's name, a ruling only counts when it comes from
44
51
  * the lead, and every record says who wrote it.
45
52
  */
46
- const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
53
+ export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
47
54
  /** After a record this laptop wrote: its working list, made again. One line, or none. */
48
55
  function refreshSheet(deps) {
49
56
  const joined = activeProject();
@@ -74,7 +81,7 @@ function looksLikeAMachine(name, deps) {
74
81
  * and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
75
82
  * has always taken. The verbs that pass nothing read exactly as before.
76
83
  */
77
- function flags(args, flagOnly = []) {
84
+ export function flags(args, flagOnly = []) {
78
85
  const positional = [];
79
86
  const opts = {};
80
87
  for (let i = 0; i < args.length; i++) {
@@ -97,7 +104,7 @@ function flags(args, flagOnly = []) {
97
104
  }
98
105
  return { positional, opts };
99
106
  }
100
- const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
107
+ export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
101
108
  /** "1 object", "3 objects" — every count this command prints reads as a sentence. */
102
109
  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
103
110
  /** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
@@ -110,19 +117,28 @@ export async function runTeamCommand(args, deps) {
110
117
  try {
111
118
  switch (sub) {
112
119
  case 'init': return await init(rest, deps);
120
+ case 'start': return await start(rest, deps);
113
121
  case 'join': return join(rest, deps);
114
- case 'lane': return lane(rest, deps);
122
+ case 'lane': return await lane(rest, deps);
115
123
  case 'refresh': return await refresh(rest, deps);
116
124
  case 'status': return status(rest, deps);
117
125
  case 'outbox': return outbox(rest, deps);
118
126
  case 'rule': return rule(rest, deps);
127
+ case 'rules': return rulesCommand(rest, deps);
119
128
  case 'leave': return leave(deps);
120
129
  case 'decide': return decide(rest, deps);
130
+ case 'target': return target(rest, deps);
121
131
  case 'fixed': return await fixed(rest, deps);
122
132
  case 'sheet': return sheet(deps);
123
133
  case 'report': return report(rest, deps);
134
+ case 'export': return exportVerb(rest, deps);
135
+ case 'discover': return await teamDiscover(rest, deps);
136
+ case 'focus': return focusCommand(rest, deps);
137
+ case 'priority': return priorityCommand(rest, deps);
138
+ case 'plan': return await planCommand(rest, deps);
139
+ case 'rebuild': return await rebuildVerb(rest, deps);
124
140
  default:
125
- deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report (cspeach --help for details)');
141
+ deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
126
142
  return sub ? 1 : 0;
127
143
  }
128
144
  }
@@ -131,16 +147,22 @@ export async function runTeamCommand(args, deps) {
131
147
  deps.print(`✗ ${e.message}`);
132
148
  return 1;
133
149
  }
134
- throw e;
150
+ // Anything else (no SAP system configured, the connection refused, …) is one
151
+ // plain line too, never a stack trace (rehearsal 2026-10-07). CSPEACH_DEBUG=1
152
+ // still rethrows, so the trace is there when someone needs it.
153
+ if (process.env['CSPEACH_DEBUG'] === '1')
154
+ throw e;
155
+ deps.print(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
156
+ return 1;
135
157
  }
136
158
  }
137
- function requireLead(project, deps) {
159
+ export function requireLead(project, deps) {
138
160
  const me = whoAmI(deps);
139
161
  if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
140
162
  throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
141
163
  }
142
164
  }
143
- function joinedOrThrow() {
165
+ export function joinedOrThrow() {
144
166
  const joined = activeProject();
145
167
  if (!joined)
146
168
  throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
@@ -148,11 +170,11 @@ function joinedOrThrow() {
148
170
  }
149
171
  // ── init ────────────────────────────────────────────────────────────────────
150
172
  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]';
173
+ + '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
152
174
  /** 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'];
175
+ const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
154
176
  /** The two that take no value, so neither can eat the folder name. */
155
- const INIT_FLAGS = ['include-local', 'no-measure'];
177
+ const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
156
178
  /** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
157
179
  const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
158
180
  /**
@@ -178,6 +200,181 @@ function unknownInitOption(name) {
178
200
  return said;
179
201
  return `${said} Did you mean --${guess}?`;
180
202
  }
203
+ const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
204
+ + '[--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
205
+ const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers'];
206
+ /** The check a project measures with when nobody names one. */
207
+ export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
208
+ /** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
209
+ export const NO_DISCOVERY_LINE = 'Nothing is known about the system yet. Run: cspeach team discover, then try again.';
210
+ /** Step 2 (owner: "we need to give some regex. This is not good."): nobody types a pattern. */
211
+ const NOT_A_PATTERN = 'Name each package in full, for example ZFI_AR — not a pattern.';
212
+ const FOR_NOT_A_PATTERN = 'Say the area or the packages by name, for example --for "FI and SD" — not a pattern.';
213
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
214
+ /** Longest name a person gives with --as or --developers (F13: like a lead name). */
215
+ export const MAX_PERSON_NAME = 60;
216
+ const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
217
+ const firstWordOf = (name) => (name.trim().split(/\s+/)[0] ?? '').toLowerCase();
218
+ /**
219
+ * A person's name typed on the command line (`team join --as`, `--developers`,
220
+ * `lane move --to`): plain text, no control characters, at most 60 characters.
221
+ * `what` starts the message, so the line says which option was wrong.
222
+ */
223
+ export function personName(raw, what, example = 'James Miller') {
224
+ const name = raw === undefined || raw === 'true' ? '' : raw.trim();
225
+ if (!name)
226
+ throw new RegisterError(`${what} needs a name, for example: ${what} "${example}"`);
227
+ if (CONTROL.test(name) || name.length > MAX_PERSON_NAME) {
228
+ throw new RegisterError(`${what} must be a plain name of at most ${MAX_PERSON_NAME} characters, without control characters.`);
229
+ }
230
+ return name;
231
+ }
232
+ /** `--developers "James Miller, Mike Chen, me"`: the names as given, checked, each once. `me` stays `me` here. */
233
+ function developersOf(raw) {
234
+ if (raw === undefined)
235
+ return [];
236
+ const seen = new Set();
237
+ const out = [];
238
+ for (const d of list(raw === 'true' ? '' : raw)) {
239
+ if (isPatternWord(d))
240
+ throw new RegisterError('Name each developer in full, for example "James Miller" — not a pattern.');
241
+ const name = personName(d, '--developers');
242
+ if (seen.has(name.toLowerCase()))
243
+ continue;
244
+ seen.add(name.toLowerCase());
245
+ out.push(name);
246
+ }
247
+ if (!out.length)
248
+ throw new RegisterError('--developers needs at least one name, for example: --developers "James Miller, me"');
249
+ return out;
250
+ }
251
+ /**
252
+ * 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
253
+ * command for what init + join + lane add said in three: the folder you are
254
+ * in, a name from the packages, you as the lead, the default check, your
255
+ * results straight into the folder, and every object yours. The long verbs
256
+ * stay for big team setups.
257
+ *
258
+ * Step 2 (spec §3.6, I16) — set up by name: `--for "FI and SD"` reads the
259
+ * packages from the folder's discovery (`scopeFromWords`, names only), and
260
+ * `--developers "James Miller, Mike Chen, me"` proposes one lane each,
261
+ * balanced by work (`proposeLanes`), written as package NAMES. Nothing is
262
+ * measured on that path (`--no-measure`): the lanes are balanced by objects
263
+ * and the table says so. A typed pattern is refused in one line.
264
+ */
265
+ async function start(args, deps) {
266
+ const { positional, opts } = flags(args, ['no-measure']);
267
+ for (const name of Object.keys(opts)) {
268
+ if (!START_OPTIONS.includes(name))
269
+ throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
270
+ }
271
+ const typed = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
272
+ if (typed.some(isPatternWord))
273
+ throw new RegisterError(NOT_A_PATTERN);
274
+ if (opts.for === 'true')
275
+ throw new RegisterError(START_USAGE);
276
+ const forWords = (opts.for ?? '').trim();
277
+ if (forWords && forWords.split(/[\s,;&]+/).some(isPatternWord))
278
+ throw new RegisterError(FOR_NOT_A_PATTERN);
279
+ if (forWords && typed.length)
280
+ throw new RegisterError(`Name the packages or say --for, not both.\n${START_USAGE}`);
281
+ const developers = developersOf(opts.developers);
282
+ if (!forWords && !typed.length)
283
+ throw new RegisterError(START_USAGE);
284
+ const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
285
+ if (fs.existsSync(path.join(dir, TEAM_FILE)))
286
+ throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
287
+ let packages = [...new Set(typed)];
288
+ let unmatched = [];
289
+ if (forWords) {
290
+ const found = readEstate(dir);
291
+ if (!found) {
292
+ deps.print(NO_DISCOVERY_LINE);
293
+ return 1;
294
+ }
295
+ const scope = scopeFromWords(found.estate, forWords);
296
+ unmatched = scope.unmatched.map((w) => shown(w));
297
+ if (!scope.packages.length) {
298
+ throw new RegisterError(`Nothing in the inventory matched: ${unmatched.join(', ') || shown(forWords)}. Name an area such as FI, or a package in full.`);
299
+ }
300
+ packages = scope.packages;
301
+ }
302
+ const lead = deps.identity().name;
303
+ const name = opts.name && opts.name !== 'true' ? opts.name : forWords ? shownUpTo(forWords, 80) : packages.join(', ');
304
+ const variant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'] : DEFAULT_ATC_VARIANT;
305
+ // I16: a setup by name measures nothing yet; the first check is the lead's next step.
306
+ const noMeasure = !!opts['no-measure'] || !!forWords || developers.length > 0;
307
+ // The three verbs run as they always do; their detail is kept back and the
308
+ // few lines that matter are said once, below.
309
+ const said = [];
310
+ const quiet = { ...deps, print: (l) => said.push(l) };
311
+ const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
312
+ ...(noMeasure ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
313
+ const code = await init(initArgs, quiet);
314
+ if (code !== 0) {
315
+ for (const l of said)
316
+ deps.print(l);
317
+ return code;
318
+ }
319
+ join([dir, '--outbox', dir], quiet);
320
+ let lanesWritten = null;
321
+ let note = '';
322
+ let laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
323
+ if (developers.length) {
324
+ // `me` is the seat, named only now: after the join, it is the name this folder records under.
325
+ const me = whoAmI(deps);
326
+ const owners = [];
327
+ for (const d of developers.map((x) => (x.toLowerCase() === 'me' ? me : x)))
328
+ if (!owners.some((o) => sameName(o, d)))
329
+ owners.push(d);
330
+ const project = readProjectFile(dir);
331
+ const proposal = proposeLanes(registerFor(dir).register, owners);
332
+ note = proposal.note ?? 'balanced by the work: objects with findings count three times, then findings, then objects';
333
+ lanesWritten = proposal.lanes.map((l) => ({ id: l.id, owner: l.owner, packages: l.packages, objects: l.objects }));
334
+ writeLanes(dir, project.projectId, lanesWritten.filter((l) => l.packages.length).map((l) => ({ id: l.id, owner: l.owner, packages: l.packages, namePatterns: [] })));
335
+ laneId = lanesWritten.find((l) => sameName(l.owner, me) && l.packages.length)?.id ?? '';
336
+ }
337
+ else {
338
+ await lane(['add', laneId, '--owner', lead, '--packages', packages.join(',')], quiet);
339
+ }
340
+ const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
341
+ const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
342
+ const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
343
+ deps.print(`✓ Started "${name}" in this folder. Lead: ${lead}.`);
344
+ if (forWords)
345
+ deps.print(` Packages: ${packages.join(', ')}.`);
346
+ if (unmatched.length)
347
+ deps.print(` Nothing in the inventory matched: ${unmatched.join(', ')}.`);
348
+ // §3.1: what the variant lookup found, said on every path (init's own line, kept back above).
349
+ for (const l of said.filter((x) => /^ {2}(! )?CSPeach could not (find|confirm) the variant /.test(x)))
350
+ deps.print(l);
351
+ deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
352
+ if (measured)
353
+ deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
354
+ // I15: above 500 objects the first check is per package, also when this start measured nothing on purpose.
355
+ else if (said.includes(FIRST_CHECK_PER_PACKAGE_LINE) || objects > FIRST_CHECK_WHOLE_MAX)
356
+ deps.print(FIRST_CHECK_PER_PACKAGE_LINE);
357
+ else if (lanesWritten)
358
+ deps.print(' No check run yet: cspeach team refresh --all measures it.');
359
+ else if (noMeasure)
360
+ deps.print(' No check run yet: cspeach team refresh measures it.');
361
+ if (lanesWritten) {
362
+ deps.print(` Lanes, ${note}.`);
363
+ for (const l of lanesWritten) {
364
+ deps.print(l.packages.length
365
+ ? ` ${l.owner} (${l.id}): ${l.packages.join(', ')} · ${plural(l.objects, 'object', 'objects')}`
366
+ : ` ${l.owner}: no package yet.`);
367
+ }
368
+ deps.print(' Move a package: cspeach team lane move --packages <names> --to <name>');
369
+ deps.print(' Your results go straight into this folder.');
370
+ }
371
+ else {
372
+ deps.print(` You own all ${plural(objects, 'object', 'objects')}. Your results go straight into this folder.`);
373
+ }
374
+ if (laneId)
375
+ deps.print(` Next, in CSPeach: /abap-clean-core triage lane ${laneId}`);
376
+ return 0;
377
+ }
181
378
  async function init(args, deps) {
182
379
  const { positional, opts } = flags(args, INIT_FLAGS);
183
380
  // `--no-measur` must not read as "measure anyway": a person who meant to skip
@@ -210,7 +407,24 @@ async function init(args, deps) {
210
407
  throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
211
408
  if (!deps.connect)
212
409
  throw new RegisterError('init needs a SAP connection to pull the object list.');
410
+ if (opts['from-discovery'] && opts['no-from-discovery'])
411
+ throw new RegisterError(`Say --from-discovery or --no-from-discovery, not both.
412
+ ${INIT_USAGE}`);
413
+ // The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
414
+ const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
213
415
  const { sql, system, atc } = await deps.connect(opts['sap-alias']);
416
+ // D5 / I8: the named variant, looked up on the system before it is saved.
417
+ // A global variant found needs no words; anything else is said, and the
418
+ // confirmed name is still saved (the first check says if SAP refuses it).
419
+ if (atcVariant) {
420
+ const look = await lookupVariant(sql, atcVariant);
421
+ if (look === 'unreadable' || !look.found || look.privateTo) {
422
+ const sid = shown(system?.sid ?? 'the system');
423
+ deps.print(look !== 'unreadable' && !look.found
424
+ ? ` ! CSPeach could not find the variant ${atcVariant} on ${sid}. It is saved as named; the first check shows whether SAP knows it.`
425
+ : ` ${look === 'unreadable' ? '' : '! '}${variantLookupLine(atcVariant, sid, look)}`);
426
+ }
427
+ }
214
428
  deps.print('Reading the package list from SAP…');
215
429
  const all = await listCustomPackages(sql);
216
430
  const patterns = list(opts.packages);
@@ -225,6 +439,9 @@ async function init(args, deps) {
225
439
  if (!chosen.length) {
226
440
  throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
227
441
  }
442
+ if (discovery)
443
+ for (const l of estatePackageLines(chosen, discovery.estate))
444
+ deps.print(l);
228
445
  deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
229
446
  const pull = await pullBaseline(sql, { packages: chosen });
230
447
  if (!pull.objects.length)
@@ -241,12 +458,17 @@ async function init(args, deps) {
241
458
  createdAt: now.toISOString(),
242
459
  };
243
460
  fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
244
- fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
245
- fs.writeFileSync(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
461
+ writeWhole(path.join(dir, BASELINE_FILE), baselineText);
462
+ writeWhole(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
246
463
  writeLanes(dir, projectId, []);
464
+ const evidenceLines = discovery || opts['from-discovery']
465
+ ? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
466
+ : [];
247
467
  deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
248
468
  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
469
  deps.print(` Written to ${dir}`);
470
+ for (const l of evidenceLines)
471
+ deps.print(l);
250
472
  // F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
251
473
  // user. That name goes into the team project file, into the writer id and
252
474
  // into every record from then on. Said once, never asked.
@@ -267,11 +489,75 @@ async function init(args, deps) {
267
489
  deps.print(l);
268
490
  return joinedRc;
269
491
  }
270
- await firstMeasurement(dir, project, pull.objects, opts, atc, deps);
492
+ // Step 2 (I15): above 500 objects one run over the whole baseline would be cut
493
+ // by its 30-minute budget. No cut first check: the per-package one is named instead.
494
+ if (pull.objects.length > FIRST_CHECK_WHOLE_MAX && !opts['no-measure'])
495
+ deps.print(FIRST_CHECK_PER_PACKAGE_LINE);
496
+ else
497
+ await firstMeasurement(dir, project, pull.objects, opts, atc, deps, sql);
271
498
  for (const l of joinLines)
272
499
  deps.print(l);
273
500
  return 0;
274
501
  }
502
+ /** Above this many objects, `init` does not run the first check over the whole baseline (I15). */
503
+ export const FIRST_CHECK_WHOLE_MAX = 500;
504
+ export const FIRST_CHECK_PER_PACKAGE_LINE = ' First check per package: cspeach team refresh --all --per-package (it resumes if stopped).';
505
+ /**
506
+ * Per chosen package, what the estate knows beside the live count: its
507
+ * description, application area and last change. Packages the estate does
508
+ * not hold get the count alone.
509
+ */
510
+ function estatePackageLines(chosen, estate) {
511
+ const known = new Map();
512
+ const pkgs = estate?.packages;
513
+ if (Array.isArray(pkgs))
514
+ for (const p of pkgs)
515
+ if (p && typeof p.name === 'string')
516
+ known.set(p.name, p);
517
+ return chosen.map((c) => {
518
+ const p = known.get(c.package);
519
+ const text = (v) => (typeof v === 'string' ? v.trim().slice(0, 300) : '');
520
+ const parts = [plural(c.objects, 'object', 'objects')];
521
+ if (p) {
522
+ if (text(p.text))
523
+ parts.push(text(p.text));
524
+ if (text(p.area))
525
+ parts.push(`area ${text(p.area)}`);
526
+ const at = text(p.lastChanged?.at);
527
+ if (at)
528
+ parts.push(`last changed ${at}${text(p.lastChanged?.by) ? ` by ${text(p.lastChanged.by)}` : ''}`);
529
+ }
530
+ return ` ${c.package}: ${parts.join(' · ')}`;
531
+ });
532
+ }
533
+ /**
534
+ * baseline/evidence-0.json (spec 2026-10-06 §7): the inventory's evidence for
535
+ * the team project's own objects, from the newest object list. Rows for other
536
+ * objects are never written; they are counted. Never fails `init`: the team
537
+ * project is complete without it.
538
+ */
539
+ function firstEvidence(dir, projectId, baseline, discovery, by, now, deps) {
540
+ if (!discovery)
541
+ return [' No discovery in this folder, so no evidence was written. Run: cspeach team discover --out <folder>'];
542
+ const ev = evidenceRows(discovery.objects, new Set(baseline.map((o) => objectKey(o.type, o.name))));
543
+ if (!ev)
544
+ return [' The object list of the inventory could not be read, so no evidence was written.'];
545
+ try {
546
+ const file = `objects-${discovery.stamp}.json`;
547
+ let sum = '';
548
+ try {
549
+ sum = sha256(fs.readFileSync(path.join(dir, 'discovery', file), 'utf8'));
550
+ }
551
+ catch { /* the checksum is a nicety */ }
552
+ writeLeadRecord(path.join(dir, 'baseline'), projectId, 'evidence', { source: { file, sha256: sum } }, ev.rows, by, { now, fileName: 'evidence-0.json', ...(deps.rand ? { rand: deps.rand } : {}) });
553
+ }
554
+ catch (e) {
555
+ // Node's own message carries the full path, user name and all; the code says enough.
556
+ const code = e?.code;
557
+ return [` ! The inventory's evidence could not be written${code ? ` (${code})` : ''}. The team project itself is complete.`];
558
+ }
559
+ return [` ${EVIDENCE_LINE(ev.rows.length)}`, ...(ev.outside ? [` ${OUTSIDE_LINE(ev.outside)}`] : [])];
560
+ }
275
561
  /** Errors that are about this laptop's disk or the shared folder, never about SAP. */
276
562
  const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
277
563
  /**
@@ -285,7 +571,7 @@ const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'E
285
571
  * an estate the run reports nothing back for at all — no object listed — leaves
286
572
  * nothing to write, and then that is said out loud and `init` still succeeds.
287
573
  */
288
- async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
574
+ async function firstMeasurement(dir, project, baseline, opts, atc, deps, sql) {
289
575
  const LATER = 'Measure when you can: cspeach team refresh --all';
290
576
  if (opts['no-measure']) {
291
577
  deps.print(' Not measured (--no-measure). When you are ready: cspeach team refresh --all');
@@ -318,6 +604,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
318
604
  joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
319
605
  dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
320
606
  confirmAbsent: true,
607
+ ...(sql ? { sql } : {}),
321
608
  ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
322
609
  signal: stop.signal,
323
610
  onProgress: progressLine(deps),
@@ -361,11 +648,15 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
361
648
  function join(args, deps, asName) {
362
649
  const { positional, opts } = flags(args);
363
650
  if (!positional[0])
364
- throw new RegisterError('Usage: cspeach team join <team project folder> [--outbox <folder>]');
651
+ throw new RegisterError('Usage: cspeach team join <team project folder> [--outbox <folder>] [--as "<your name>"]');
652
+ // F13: `--as "James Miller"` joins under the lane owner's name, kept in
653
+ // JoinedProject.name, so the writer id and every record are that person.
365
654
  const projectDir = path.resolve(positional[0]);
366
655
  const project = readProjectFile(projectDir);
367
656
  const objects = readBaseline(projectDir).length; // also verifies the checksum
368
657
  const state = loadState();
658
+ if (opts.as !== undefined && asName === undefined)
659
+ asName = joinAsName(opts.as, projectDir, project, state.projects[project.projectId]?.name, deps);
369
660
  const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
370
661
  // A name given once is kept: joining again must not turn the lead back into
371
662
  // whatever the machine calls them.
@@ -376,11 +667,96 @@ function join(args, deps, asName) {
376
667
  state.active = project.projectId;
377
668
  saveState(state);
378
669
  deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
379
- deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
380
- deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
381
- deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
670
+ // Outbox is the team project folder itself: records land where everyone reads them.
671
+ if (path.relative(projectDir, outboxDir) === '') {
672
+ deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
673
+ }
674
+ else {
675
+ deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
676
+ deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
677
+ deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
678
+ }
679
+ const owns = ownsNothingLine(projectDir, project, saved ?? deps.identity().name);
680
+ if (owns)
681
+ deps.print(` ${owns}`);
382
682
  return 0;
383
683
  }
684
+ /**
685
+ * A developer's first day (spec §3.6, F13, cut 5): someone who owns no lane in
686
+ * a folder that has lanes is told so — never left on "Done" with nothing to
687
+ * do. When one lane owner shares their first name, the line says how to join
688
+ * under that name; two such owners are never guessed between. The lead, and a
689
+ * folder with no lanes yet, get nothing. Null when there is nothing to say.
690
+ */
691
+ export function ownsNothingLine(projectDir, project, me) {
692
+ const lanes = readLanes(projectDir);
693
+ if (!lanes.length || sameName(me, project.lead.name))
694
+ return null;
695
+ const m = ownerMatch(lanes, me);
696
+ if (m.kind === 'exact')
697
+ return null;
698
+ if (m.kind === 'one')
699
+ return `Are you ${m.owner}? Join with: cspeach team join . --as "${m.owner}"`;
700
+ if (m.kind === 'several')
701
+ return severalLine(m);
702
+ return `You own nothing here yet. Ask ${shown(project.lead.name)} to move packages to you.`;
703
+ }
704
+ /**
705
+ * The lane owner a seat may be (architecture §D, F13, cut 5) — the ONE place
706
+ * this is decided, for `team join` and the journey's JOIN card. Owners compare
707
+ * case-insensitively ("James Miller" and "james miller" are one person, the
708
+ * first spelling kept); the first word alone makes a candidate. Names come
709
+ * out of a synced file, so the owner returned is cleaned for the screen.
710
+ */
711
+ export function ownerMatch(lanes, me) {
712
+ if (lanes.some((l) => sameName(l.owner, me)))
713
+ return { kind: 'exact' };
714
+ const first = firstWordOf(me);
715
+ const people = new Map();
716
+ for (const l of lanes) {
717
+ if (!first || firstWordOf(l.owner) !== first)
718
+ continue;
719
+ const key = l.owner.trim().toLowerCase();
720
+ const seen = people.get(key);
721
+ if (seen)
722
+ seen.laneIds.push(l.id);
723
+ else
724
+ people.set(key, { owner: shown(l.owner).trim(), laneIds: [l.id] });
725
+ }
726
+ const found = [...people.values()];
727
+ if (found.length > 1)
728
+ return { kind: 'several', called: found[0].owner.split(/\s+/)[0], count: found.length };
729
+ return found[0] ? { kind: 'one', ...found[0] } : { kind: 'none' };
730
+ }
731
+ /** Cut 5: never a guess between two people. */
732
+ export const severalLine = (m) => `${m.count === 2 ? 'Two' : m.count} people here are called ${m.called}. Join with: cspeach team join . --as "<full name>"`;
733
+ /**
734
+ * `--as` (F13; review finding 2): only the name of someone who owns a lane
735
+ * here, in that lane's spelling. The lead's name only from the lead's own
736
+ * seat — otherwise one option would hand any joiner the lead's powers.
737
+ */
738
+ function joinAsName(raw, projectDir, project, savedName, deps) {
739
+ const name = personName(raw, '--as');
740
+ const lead = project.lead.name;
741
+ if (sameName(name, lead)) {
742
+ if (![savedName, deps.identity().name].some((n) => !!n && sameName(n, lead))) {
743
+ throw new RegisterError(`Only the lead's own seat joins as ${shown(lead)}.`);
744
+ }
745
+ return lead;
746
+ }
747
+ const lanes = readLanes(projectDir);
748
+ const owner = lanes.find((l) => sameName(l.owner, name));
749
+ if (!owner) {
750
+ const owners = [];
751
+ for (const l of lanes)
752
+ if (!owners.some((o) => sameName(o, l.owner)))
753
+ owners.push(shown(l.owner).trim());
754
+ throw new RegisterError(owners.length
755
+ ? `--as takes the name of someone who owns a lane here: ${owners.join(', ')}.`
756
+ : '--as takes the name of someone who owns a lane here; nobody does yet.');
757
+ }
758
+ return owner.owner.trim();
759
+ }
384
760
  function leave(deps) {
385
761
  const state = loadState();
386
762
  if (!state.active) {
@@ -394,8 +770,10 @@ function leave(deps) {
394
770
  return 0;
395
771
  }
396
772
  // ── lane ────────────────────────────────────────────────────────────────────
397
- function lane(args, deps) {
773
+ async function lane(args, deps) {
398
774
  const [verb, ...rest] = args;
775
+ if (verb === 'propose')
776
+ return laneProposal(rest, deps);
399
777
  const { positional, opts } = flags(rest);
400
778
  const joined = joinedOrThrow();
401
779
  const project = readProjectFile(joined.projectDir);
@@ -408,16 +786,24 @@ function lane(args, deps) {
408
786
  return 0;
409
787
  }
410
788
  requireLead(project, deps);
789
+ if (verb === 'move')
790
+ return laneMove(positional, opts, joined, project, lanes, deps);
411
791
  const id = positional[0];
412
792
  if (!id)
413
- throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
793
+ 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 "…"');
794
+ const reason = reasonFrom(opts, false);
414
795
  if (verb === 'remove') {
415
796
  writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
416
797
  deps.print(`✓ Lane ${id} removed.`);
798
+ const was = lanes.find((l) => l.id === id);
799
+ // With a reason, the change is also kept in the plan history; without one,
800
+ // `lane remove` says and writes exactly what it always did.
801
+ if (was && reason)
802
+ deps.print(laneChangeRecord(deps, 'remove', was, undefined, id, reason));
417
803
  return 0;
418
804
  }
419
805
  if (verb !== 'add')
420
- throw new RegisterError('lane: use add, remove or list.');
806
+ throw new RegisterError('lane: use add, remove, move, list or propose.');
421
807
  const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
422
808
  if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
423
809
  throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
@@ -435,14 +821,79 @@ function lane(args, deps) {
435
821
  deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
436
822
  if (reg.register.totals.unowned)
437
823
  deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
824
+ if (reason)
825
+ deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
826
+ return 0;
827
+ }
828
+ const MOVE_USAGE = 'Usage: cspeach team lane move --packages <names> --to <developer> [--reason "<why>"]';
829
+ /** The first pattern a lane holds (a package with `*`, or any name pattern); undefined for a lane of names. */
830
+ const patternOf = (l) => l.packages.find((p) => p.includes('*')) ?? l.namePatterns[0];
831
+ /**
832
+ * `team lane move --packages ZSD_ORDERS --to mike` (spec §3.6, F14): package
833
+ * NAMES move from whichever lane holds them to the developer's lane — a new
834
+ * lane of names when that developer has none yet. Lanes of names only: a lane
835
+ * that holds a pattern cannot give up one package, so the move is refused in
836
+ * one line and nothing is written. A lane left empty is removed.
837
+ */
838
+ function laneMove(positional, opts, joined, project, lanes, deps) {
839
+ const names = [...new Set(list(opts.packages === 'true' ? '' : opts.packages).map((p) => p.toUpperCase()))];
840
+ if (positional.length || !names.length || !opts.to || opts.to === 'true')
841
+ throw new RegisterError(MOVE_USAGE);
842
+ if (names.some(isPatternWord))
843
+ throw new RegisterError('Name each package in full, for example ZSD_ORDERS — not a pattern.');
844
+ const known = new Set(readBaseline(joined.projectDir).map((o) => o.package.toUpperCase()));
845
+ for (const n of names)
846
+ if (!known.has(n))
847
+ throw new RegisterError(`${shown(n)} is not a package of this team project.`);
848
+ const to = personName(opts.to, '--to');
849
+ const target = lanes.find((l) => l.id.toLowerCase() === to.toLowerCase()) ?? lanes.find((l) => sameName(l.owner, to));
850
+ const holders = lanes.filter((l) => l !== target && l.packages.some((p) => names.some((n) => matches(p, n))));
851
+ for (const l of [...holders, ...(target ? [target] : [])]) {
852
+ const p = patternOf(l);
853
+ if (p) {
854
+ throw new RegisterError(`Lane ${l.id} uses the pattern ${p}; CSPeach cannot move one package out of a pattern. `
855
+ + `Rewrite the lane with names: cspeach team lane add ${l.id} --owner … --packages <names>.`);
856
+ }
857
+ }
858
+ // Review finding 7: a move that changes nothing says so and writes nothing.
859
+ if (target && !holders.length && names.every((n) => target.packages.some((p) => p.toUpperCase() === n))) {
860
+ deps.print(`Nothing to move: ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} already in ${shown(target.owner)}'s lane.`);
861
+ return 0;
862
+ }
863
+ const reason = reasonFrom(opts, false);
864
+ const into = target
865
+ ? { ...target, packages: [...target.packages, ...names.filter((n) => !target.packages.some((p) => p.toUpperCase() === n))] }
866
+ : { id: laneIdFor(to, new Set(lanes.map((l) => l.id))), owner: to, packages: names, namePatterns: [] };
867
+ const removed = [];
868
+ const next = [];
869
+ for (const l of lanes) {
870
+ if (l === target) {
871
+ next.push(into);
872
+ continue;
873
+ }
874
+ if (!holders.includes(l)) {
875
+ next.push(l);
876
+ continue;
877
+ }
878
+ const left = l.packages.filter((p) => !names.includes(p.toUpperCase()));
879
+ if (left.length || l.namePatterns.length)
880
+ next.push({ ...l, packages: left });
881
+ else
882
+ removed.push(l.id);
883
+ }
884
+ if (!target)
885
+ next.push(into);
886
+ writeLanes(joined.projectDir, project.projectId, next);
887
+ const mine = registerFor(joined.projectDir, joined.outboxDir).register.lanes.find((l) => l.id === into.id);
888
+ deps.print(`✓ ${names.join(', ')} moved to ${shown(into.owner)} (${into.id}): `
889
+ + `${plural(into.packages.length, 'package', 'packages')}, ${plural(mine?.objects ?? 0, 'object', 'objects')}.`);
890
+ for (const id of removed)
891
+ deps.print(` Lane ${id} held nothing else and was removed.`);
892
+ if (reason)
893
+ deps.print(laneChangeRecord(deps, 'add', target, into, into.id, reason));
438
894
  return 0;
439
895
  }
440
896
  // ── status ──────────────────────────────────────────────────────────────────
441
- const STATE_WORDS = [
442
- ['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
443
- ['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
444
- ['regressed', 'regressed'], ['retired', 'retired'],
445
- ];
446
897
  function status(args, deps) {
447
898
  const { positional } = flags(args);
448
899
  const joined = activeProject();
@@ -453,36 +904,39 @@ function status(args, deps) {
453
904
  const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
454
905
  const t = r.totals;
455
906
  deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
456
- // A clean sheet out of a file is somebody's export, not this system speaking.
457
- // The words "verified" and "clean" do not change; the line says how many of
458
- // them were measured one step removed, so nobody reads them as live proof.
459
- const fromFile = t.fromFile.verified + t.fromFile.clean;
460
- deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
461
- + `${t.byState.verified} verified, ${t.byState.clean} clean`
462
- + `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
463
- // The newest run that counted — by its time, not by the order the files were
464
- // read in — so "from a file" is said of the run the date belongs to.
465
- const lastRun = [...r.runs].reverse().find((run) => run.at === r.lastMeasuredAt);
466
- deps.print(r.lastMeasuredAt
467
- ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
468
- : 'Not measured yet: "verified" and "clean" need an ATC run.');
907
+ // Step 2 (the one report): the first screen is the status page's first
908
+ // block, from the same model — coverage first, then the numbers. The
909
+ // previous page in reports/ says where "changed since" starts.
910
+ const asOf = (deps.now?.() ?? new Date()).toISOString();
911
+ const previousAsOf = previousAsOfIn(path.join(projectDir, 'reports', reportFileNames(project.name).html));
912
+ const view = reportViewFor({ project, register: r, asOf, previousAsOf });
913
+ for (const l of firstBlockLines(view))
914
+ deps.print(l);
469
915
  deps.print('');
470
- for (const [state, word] of STATE_WORDS)
471
- if (t.byState[state])
472
- deps.print(` ${String(t.byState[state]).padStart(6)} ${word}`);
916
+ deps.print(nowLine(r));
917
+ for (const l of targetLines(project))
918
+ deps.print(l);
919
+ // Spec §3.6: a joiner who owns nothing reads that first, never a finished picture.
920
+ if (!positional[0] && joined) {
921
+ const owns = ownsNothingLine(projectDir, project, whoAmI(deps));
922
+ if (owns)
923
+ deps.print(owns);
924
+ }
473
925
  deps.print('');
474
- if (r.lanes.length) {
475
- // The same columns, in the same order, as the team project page's lane table,
476
- // counted with the same three functions. One lane must not read "50% done"
477
- // in a browser and "3 done" here.
478
- deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
479
- for (const l of r.lanes) {
480
- 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)} `
482
- + `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
926
+ if (view.lanes.length) {
927
+ // Every lane together, in the order the lanes were set up — never ranked.
928
+ deps.print(`${'Developer'.padEnd(20)} Objects Decided Fixes proven Awaiting check Kept Rebuilds Fix failed Last check`);
929
+ for (const row of view.lanes) {
930
+ deps.print(`${row.developer.padEnd(20)} ${String(row.objects).padStart(7)} ${String(row.decided).padStart(7)} `
931
+ + `${String(row.fixesProven).padStart(12)} ${awaitingWord(row).padEnd(18)} ${String(row.kept).padStart(4)} `
932
+ + `${String(row.rebuilds).padStart(8)} ${String(row.fixFailed).padStart(10)} ${lastCheckWord(row)}`);
933
+ }
934
+ // What each lane does next, in the priority order every surface sorts by. No %.
935
+ for (const row of view.lanes) {
936
+ if (row.laneId === null)
937
+ continue;
938
+ deps.print(` ${row.developer}: Next: ${row.next.length ? row.next.join(', ') : 'nothing open'}`);
483
939
  }
484
- // Said once per lane it is true of. A lane set up before this rule held
485
- // both a package list and a name list, and took the whole package.
486
940
  for (const id of narrowed)
487
941
  deps.print(`Lane ${id} now means: in these packages AND matching these names.`);
488
942
  deps.print('');
@@ -491,6 +945,10 @@ function status(args, deps) {
491
945
  deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
492
946
  if (t.overlapping)
493
947
  deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
948
+ if (t.retireCandidates)
949
+ deps.print(`${plural(t.retireCandidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
950
+ if (t.outOfScope)
951
+ deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
494
952
  if (t.conflicts) {
495
953
  deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
496
954
  + ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
@@ -546,6 +1004,7 @@ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
546
1004
  */
547
1005
  export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
548
1006
  + '[--variant <NAME>] [--force-variant] [--chunk <n>] [--max-minutes <n>] [--no-one-by-one] [--sap-alias <name>]\n'
1007
+ + ' or: cspeach team refresh (--all | --packages "ZFI_*,ZSD_*") --per-package [--again] [--variant <NAME>]\n'
549
1008
  + ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
550
1009
  + '[--variant <NAME>] [--absent-means-clean]';
551
1010
  /** Options that take a value, with the example each refusal shows. */
@@ -558,7 +1017,7 @@ const VALUE_OPTIONS = {
558
1017
  'sap-alias': '--sap-alias S4H',
559
1018
  'from-file': '--from-file atc-export.csv',
560
1019
  };
561
- const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean'];
1020
+ const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean', 'per-package', 'again'];
562
1021
  const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
563
1022
  /** Options that describe a check RUN. A file was made by a run that is already over. */
564
1023
  const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
@@ -648,6 +1107,11 @@ export function parseRefreshArgs(args) {
648
1107
  }
649
1108
  const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
650
1109
  const fromFile = opts['from-file'];
1110
+ if (opts['per-package'] !== undefined && (fromFile !== undefined || opts.lane !== undefined)) {
1111
+ return no('--per-package goes with --all or --packages: it measures one package at a time.');
1112
+ }
1113
+ if (opts.again !== undefined && opts['per-package'] === undefined)
1114
+ return no('--again only goes with --per-package.');
651
1115
  if (fromFile === undefined) {
652
1116
  if (given.length !== 1)
653
1117
  return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
@@ -691,6 +1155,8 @@ export function parseRefreshArgs(args) {
691
1155
  oneByOne: opts['no-one-by-one'] === undefined,
692
1156
  absentMeansClean: opts['absent-means-clean'] !== undefined,
693
1157
  ...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
1158
+ ...(opts['per-package'] !== undefined ? { perPackage: true } : {}),
1159
+ ...(opts.again !== undefined ? { again: true } : {}),
694
1160
  };
695
1161
  if (opts.variant !== undefined) {
696
1162
  const variant = opts.variant.trim().toUpperCase();
@@ -863,6 +1329,38 @@ function notMeasuredLines(res, deps, words = {}) {
863
1329
  + someNames(res.unsupportedNames));
864
1330
  }
865
1331
  }
1332
+ /**
1333
+ * What reading API names found and cost, said in the summary line: "Read n
1334
+ * API names", and — when finding documents were read — how many and how long
1335
+ * (each is one call to SAP). Findings left unnamed are counted with the reason:
1336
+ * they carry no `uses`, so they stay open for B.
1337
+ */
1338
+ export function apiNamesWords(a) {
1339
+ if (!a || !a.findings)
1340
+ return { suffix: '', warning: null };
1341
+ const docs = a.documents
1342
+ ? ` (${plural(a.documents, 'finding document', 'finding documents')} from SAP, ${(a.ms / 1000).toFixed(1)} s)`
1343
+ : '';
1344
+ const suffix = ` Read ${plural(a.named, 'API name', 'API names')}${docs}.`;
1345
+ if (!a.unnamed)
1346
+ return { suffix, warning: null };
1347
+ const one = a.unnamed === 1;
1348
+ // Review M3: every reason, in words. With one finding the count is left off.
1349
+ const reasons = [
1350
+ [a.namedNone, 'SAP named none'],
1351
+ [a.unreadable, a.unreadable === 1 && one ? 'its document could not be read' : 'whose document could not be read'],
1352
+ [a.overCap, `past the limit of ${MAX_API_DOCUMENTS} documents per run`],
1353
+ [a.stopped, 'not read because the run was stopped'],
1354
+ [a.notLookedUp, 'not looked up'],
1355
+ ];
1356
+ const why = reasons.filter(([n]) => n > 0).map(([n, w]) => (one ? w : `${n} ${w}`)).join(', ');
1357
+ return {
1358
+ suffix,
1359
+ // "API-usage findings": the released-API check reports deprecated APIs too, not only unreleased ones.
1360
+ warning: ` ! ${plural(a.unnamed, 'API-usage finding', 'API-usage findings')} ${one ? 'has' : 'have'} no API name, `
1361
+ + `so ${one ? 'it stays' : 'they stay'} open for B${why ? `: ${why}` : ''}.`,
1362
+ };
1363
+ }
866
1364
  export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
867
1365
  if (!res.file) {
868
1366
  deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
@@ -872,7 +1370,10 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
872
1370
  notMeasuredLines(res, deps, words);
873
1371
  return;
874
1372
  }
875
- deps.print(words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`);
1373
+ const api = apiNamesWords(res.apiNames);
1374
+ deps.print((words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`) + api.suffix);
1375
+ if (api.warning)
1376
+ deps.print(api.warning);
876
1377
  secondPassLines(res, deps);
877
1378
  // F3: SAP reports some findings twice — same place, same message, two finding
878
1379
  // ids. One problem in the code is one finding, so it is counted once; a
@@ -930,7 +1431,7 @@ function stillStanding(register, absentNames) {
930
1431
  * `once`, on purpose: the SECOND press is Node's own, so a request that has
931
1432
  * hung can always be quit.
932
1433
  */
933
- function onInterrupt(deps, stop) {
1434
+ export function onInterrupt(deps, stop) {
934
1435
  if (deps.onInterrupt)
935
1436
  return deps.onInterrupt(stop);
936
1437
  const handler = () => stop();
@@ -1070,6 +1571,16 @@ async function refresh(args, deps) {
1070
1571
  deps.print(CANNOT_MEASURE);
1071
1572
  return 2;
1072
1573
  }
1574
+ // 1.1.31: no scope said → your own lane, when you own exactly one.
1575
+ if (!args.some((a) => /^--(all|lane|packages|from-file)(=|$)/.test(a))) {
1576
+ const joinedNow = activeProject();
1577
+ if (joinedNow) {
1578
+ const me = whoAmI(deps).trim().toLowerCase();
1579
+ const mine = readLanes(joinedNow.projectDir).filter((l) => l.owner.trim().toLowerCase() === me);
1580
+ if (mine.length === 1)
1581
+ args = [...args, '--lane', mine[0].id];
1582
+ }
1583
+ }
1073
1584
  const parsed = parseRefreshArgs(args);
1074
1585
  if (!parsed.ok)
1075
1586
  throw new RegisterError(parsed.message);
@@ -1094,8 +1605,9 @@ async function refresh(args, deps) {
1094
1605
  if (!inScope.length)
1095
1606
  throw new RegisterError(`Nothing in this team project matches ${scopeLabel(scope)}.`);
1096
1607
  let atc;
1608
+ let sql;
1097
1609
  try {
1098
- ({ atc } = await deps.connect(opts.sapAlias));
1610
+ ({ atc, sql } = await deps.connect(opts.sapAlias));
1099
1611
  }
1100
1612
  catch (e) {
1101
1613
  // One line, the reason, no stack: "no system configured", a dead VPN and a
@@ -1107,6 +1619,8 @@ async function refresh(args, deps) {
1107
1619
  deps.print(CANNOT_MEASURE);
1108
1620
  return 2;
1109
1621
  }
1622
+ if (opts.perPackage)
1623
+ return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql }, deps);
1110
1624
  deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
1111
1625
  const stop = new AbortController();
1112
1626
  const off = onInterrupt(deps, () => {
@@ -1130,6 +1644,7 @@ async function refresh(args, deps) {
1130
1644
  ...(foreign || persist ? { forceVariant: true } : {}),
1131
1645
  signal: stop.signal,
1132
1646
  confirmAbsent: true,
1647
+ sql,
1133
1648
  onProgress: progressLine(deps),
1134
1649
  });
1135
1650
  }
@@ -1182,6 +1697,124 @@ async function refresh(args, deps) {
1182
1697
  }
1183
1698
  return 0;
1184
1699
  }
1700
+ /**
1701
+ * D6: the estate measured one package at a time, one measurement record each,
1702
+ * through the same `measure()` as every run. A package that already has a
1703
+ * counted record of this variant for exactly its own scope is skipped unless
1704
+ * `--again`, so a long estate run can be stopped and taken up again. Ctrl-C
1705
+ * stops between packages; every record already written stays.
1706
+ */
1707
+ async function refreshPerPackage(input, deps) {
1708
+ const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql } = input;
1709
+ const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1710
+ const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
1711
+ // A package is done only when a counted run from the system (never a result file),
1712
+ // with this variant and this package as its scope, says it reached every object:
1713
+ // nothing failed, nothing left not run. A run cut short by Ctrl-C, the deadline or
1714
+ // a lost connection wrote a partial record, and that package is measured again.
1715
+ const done = new Set();
1716
+ if (!opts.again) {
1717
+ for (const run of registerFor(joined.projectDir, joined.outboxDir).register.runs) {
1718
+ if ((run.atcVariant ?? '').trim().toUpperCase() !== variant || !run.scope || run.basis !== 'system')
1719
+ continue;
1720
+ const c = run.coverage;
1721
+ // notRun must be said and be 0: a record that does not say is not proof the run got everywhere.
1722
+ // Step 2 (I15): nor is a second pass that left silent objects out or never reached some.
1723
+ const pass = run.secondPass;
1724
+ if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
1725
+ && c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked
1726
+ && !(pass && pass.skipped > 0) && (pass?.notChecked ?? 0) === 0)
1727
+ done.add(run.scope);
1728
+ }
1729
+ }
1730
+ const todo = packages.filter((p) => !done.has(labelOf(p)));
1731
+ const skipped = packages.length - todo.length;
1732
+ if (skipped) {
1733
+ deps.print(`Skipped ${plural(skipped, 'package', 'packages')} already measured (use --again to measure ${skipped === 1 ? 'it' : 'them'} again).`);
1734
+ }
1735
+ if (!todo.length)
1736
+ return 0;
1737
+ deps.print(`Measuring ${plural(todo.length, 'package', 'packages')} one at a time with ${variant}. Ctrl-C stops between packages and keeps what is written.`);
1738
+ // Ctrl-C is checked between packages, never handed into a package's run: a package
1739
+ // is measured whole or not at all. (A deadline or a lost connection can still cut a
1740
+ // package short; its record then shows it, and the next run measures it again.)
1741
+ const stop = new AbortController();
1742
+ const off = onInterrupt(deps, () => {
1743
+ if (stop.signal.aborted)
1744
+ return;
1745
+ deps.print(' Stopping after this package. What has already been measured will still be written.');
1746
+ deps.print(' Press Ctrl-C again to quit now.');
1747
+ stop.abort();
1748
+ });
1749
+ let measuredPackages = 0;
1750
+ let written = 0;
1751
+ let variantSaved = false;
1752
+ let lostConnection = false;
1753
+ try {
1754
+ for (const pkg of todo) {
1755
+ if (stop.signal.aborted)
1756
+ break;
1757
+ const objects = inScope.filter((o) => o.package === pkg).length;
1758
+ deps.print(`${pkg}: ${plural(objects, 'object', 'objects')}…`);
1759
+ const res = await measure({
1760
+ joined, project, baseline, lanes, scope: { kind: 'packages', patterns: [pkg] }, variant, atc, by: { name: whoAmI(deps) },
1761
+ ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
1762
+ ...(opts.chunkSize ? { chunkSize: opts.chunkSize } : {}),
1763
+ ...(opts.maxTotalMs ? { maxTotalMs: opts.maxTotalMs } : {}),
1764
+ ...(opts.oneByOne ? {} : { oneByOne: false }),
1765
+ ...(foreign || persist ? { forceVariant: true } : {}),
1766
+ confirmAbsent: true,
1767
+ ...(sql ? { sql } : {}),
1768
+ onProgress: progressLine(deps),
1769
+ });
1770
+ measuredPackages += 1;
1771
+ if (res.file) {
1772
+ written += 1;
1773
+ const api = apiNamesWords(res.apiNames);
1774
+ deps.print(` ✓ ${pkg}: ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.${api.suffix} Written: ${path.basename(res.file)}`);
1775
+ if (api.warning)
1776
+ deps.print(api.warning);
1777
+ }
1778
+ else {
1779
+ deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
1780
+ }
1781
+ secondPassLines(res, deps);
1782
+ notMeasuredLines(res, deps);
1783
+ if (res.file && persist && !variantSaved) {
1784
+ try {
1785
+ writeProjectVariant(joined.projectDir, variant);
1786
+ variantSaved = true;
1787
+ deps.print(`✓ This team project now measures with ${variant}.`);
1788
+ }
1789
+ catch (e) {
1790
+ deps.print(` ! The measurement is written, but ${TEAM_FILE} could not be updated, so this team project still has no check variant. `
1791
+ + `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
1792
+ }
1793
+ }
1794
+ if (res.stoppedBy === 'connection') {
1795
+ lostConnection = true;
1796
+ break;
1797
+ }
1798
+ }
1799
+ }
1800
+ finally {
1801
+ off();
1802
+ }
1803
+ if (foreign) {
1804
+ deps.print(` ! Made with ${variant}, not the team project's ${(project.atcVariant ?? '').trim().toUpperCase()}. `
1805
+ + 'The records are kept, but they are not counted in the picture.');
1806
+ }
1807
+ if (written)
1808
+ refreshSheet(deps);
1809
+ if (stop.signal.aborted || lostConnection) {
1810
+ deps.print(`✗ ${lostConnection ? 'Lost the connection to SAP' : 'Stopped'} after ${measuredPackages} of ${todo.length} packages. What was measured is written. `
1811
+ + 'Run the same command again to measure the rest.');
1812
+ return 3;
1813
+ }
1814
+ deps.print(`✓ ${plural(written, 'package', 'packages')} measured, one record each.`);
1815
+ deps.print(' Next: cspeach team status');
1816
+ return written ? 0 : 1;
1817
+ }
1185
1818
  // ── outbox / rule ───────────────────────────────────────────────────────────
1186
1819
  function outbox(_args, deps) {
1187
1820
  const joined = joinedOrThrow();
@@ -1200,15 +1833,25 @@ function outbox(_args, deps) {
1200
1833
  deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
1201
1834
  return 0;
1202
1835
  }
1836
+ 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
1837
  function rule(args, deps) {
1204
1838
  const { positional, opts } = flags(args);
1205
1839
  const [type, name, classification] = positional;
1206
1840
  if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
1207
- throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
1841
+ throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"] (a retire needs the reason)`);
1208
1842
  }
1843
+ // Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
1844
+ if (opts.reason === 'true')
1845
+ throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
1846
+ const reason = (opts.reason ?? '').trim();
1847
+ if (reason.length > MAX_REASON)
1848
+ throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
1849
+ // The lead's retire is what makes an object retired (D2), so it carries what was checked.
1850
+ if (classification === 'retire' && !reason)
1851
+ throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
1209
1852
  const joined = joinedOrThrow();
1210
1853
  requireLead(readProjectFile(joined.projectDir), deps);
1211
- const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(opts.reason ? { reason: opts.reason } : {}) }], { name: whoAmI(deps) }, {
1854
+ const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(reason ? { reason } : {}) }], { name: whoAmI(deps) }, {
1212
1855
  dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
1213
1856
  ...(deps.rand ? { rand: deps.rand } : {}),
1214
1857
  });
@@ -1218,8 +1861,11 @@ function rule(args, deps) {
1218
1861
  return 0;
1219
1862
  }
1220
1863
  // ── decide ──────────────────────────────────────────────────────────────────
1221
- const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"]';
1222
- const DECIDE_OPTIONS = ['level', 'target', 'reason'];
1864
+ const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"] [--kind app|class|api] [--app <APP>]';
1865
+ const DECIDE_OPTIONS = ['level', 'target', 'reason', 'kind', 'app'];
1866
+ /** Step 2 (D3): what a rebuild becomes, said with `--kind` beside `rebuild` only. */
1867
+ const REBUILD_KINDS = ['app', 'class', 'api'];
1868
+ const REBUILD_KIND_WORDS = { app: 'as an app', class: 'as a clean class', api: 'as an API service' };
1223
1869
  /** The five words a person types, onto the four classifications the files hold (spec §4.2). */
1224
1870
  const DECIDE_WORDS = {
1225
1871
  keep: { classification: 'keep' },
@@ -1260,10 +1906,26 @@ export function parseDecideArgs(args) {
1260
1906
  return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
1261
1907
  if (said.exception && !reason)
1262
1908
  return { ok: false, message: 'An accepted exception needs a reason: --reason "<why it stays>"' };
1909
+ if (said.classification === 'retire' && !reason) {
1910
+ 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"' };
1911
+ }
1912
+ const rebuild = said.classification === 'redesign';
1913
+ if ((opts.kind !== undefined || opts.app !== undefined) && !rebuild)
1914
+ return { ok: false, message: '--kind and --app only go with rebuild.' };
1915
+ const kind = opts.kind === undefined ? undefined : opts.kind.trim().toLowerCase();
1916
+ if (kind !== undefined && !REBUILD_KINDS.includes(kind))
1917
+ return { ok: false, message: 'A rebuild kind is app, class or api.' };
1918
+ const app = opts.app === undefined ? undefined : opts.app.trim().toUpperCase();
1919
+ if (app !== undefined && !isAppLabel(app))
1920
+ return { ok: false, message: '--app is a name of letters, digits and underscores, for example AR_COCKPIT (not CON, NUL, AUX, PRN, COM1-9 or LPT1-9).' };
1263
1921
  return {
1264
1922
  ok: true,
1265
1923
  value: {
1266
1924
  type: type.toUpperCase(), name: name.toUpperCase(),
1925
+ // F3 / M19: a person NAMED the level this decision's target is read from —
1926
+ // `--target` on a fix or rebuild, `--level` on a keep or an exception.
1927
+ // Only then does `decide` stamp `targetBy`; the default A is no target.
1928
+ targetNamed: moves ? !!target.value : said.classification === 'keep' && !!level.value,
1267
1929
  row: {
1268
1930
  classification: said.classification,
1269
1931
  ...(said.exception ? { exception: true } : {}),
@@ -1271,17 +1933,24 @@ export function parseDecideArgs(args) {
1271
1933
  // Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
1272
1934
  ...(moves ? { targetLevel: target.value ?? 'A' } : {}),
1273
1935
  ...(reason ? { reason } : {}),
1936
+ ...(kind ? { rebuildKind: kind } : {}),
1937
+ ...(app ? { app } : {}),
1274
1938
  },
1275
1939
  },
1276
1940
  };
1277
1941
  }
1278
1942
  const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
1279
- function decisionWords(row) {
1943
+ function decisionWords(row, targetNamed) {
1280
1944
  const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
1281
1945
  if (row.level)
1282
1946
  parts.push(`level ${row.level}`);
1947
+ // Step 2 (F3): the default A is recorded but is no target, so it is not said as one.
1283
1948
  if (row.targetLevel)
1284
- parts.push(`target ${row.targetLevel}`);
1949
+ parts.push(targetNamed ? `target ${row.targetLevel}` : 'no target set');
1950
+ if (row.rebuildKind)
1951
+ parts.push(REBUILD_KIND_WORDS[row.rebuildKind]);
1952
+ if (row.app)
1953
+ parts.push(`app ${row.app}`);
1285
1954
  return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
1286
1955
  }
1287
1956
  function decide(args, deps) {
@@ -1289,16 +1958,144 @@ function decide(args, deps) {
1289
1958
  if (!parsed.ok)
1290
1959
  throw new RegisterError(parsed.message);
1291
1960
  const joined = joinedOrThrow();
1292
- const { type, name, row } = parsed.value;
1293
- const res = writeRecord(joined, 'decision', [{ key: objectKey(type, name), ...row }], { name: whoAmI(deps) }, {
1294
- ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
1961
+ const { type, name, row, targetNamed } = parsed.value;
1962
+ const now = (deps.now ?? (() => new Date()))();
1963
+ const by = whoAmI(deps);
1964
+ const res = writeRecord(joined, 'decision', [{
1965
+ key: objectKey(type, name), ...row,
1966
+ ...(targetNamed ? { targetBy: { name: by, at: now.toISOString() } } : {}),
1967
+ }], { name: by }, {
1968
+ now, ...(deps.rand ? { rand: deps.rand } : {}),
1295
1969
  });
1296
1970
  if (!res.file)
1297
1971
  throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
1298
- deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
1972
+ deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row, targetNamed)}.`);
1973
+ if (row.classification === 'retire')
1974
+ deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
1975
+ refreshSheet(deps);
1976
+ return 0;
1977
+ }
1978
+ // ── rebuild ─────────────────────────────────────────────────────────────────
1979
+ /**
1980
+ * `cspeach team rebuild built <TYPE> <NAME> --app <APP>` (F9): a rebuild run by
1981
+ * /abap-modernize reaches "built, not yet checked" by hand. `proven` is not
1982
+ * written tonight: it needs a code-run ATC over the new objects.
1983
+ */
1984
+ async function rebuildVerb(args, deps) {
1985
+ const { REBUILD_USAGE, recordRebuildBuilt } = await import('../journey/rebuild.js');
1986
+ const { positional, opts } = flags(args);
1987
+ for (const name of Object.keys(opts))
1988
+ if (name !== 'app')
1989
+ throw new RegisterError(`--${name} is not an option of team rebuild.\n${REBUILD_USAGE}`);
1990
+ const [word, type, name] = positional;
1991
+ if (word?.toLowerCase() !== 'built' || !type || !name || positional.length !== 3 || !opts.app || opts.app === 'true')
1992
+ throw new RegisterError(REBUILD_USAGE);
1993
+ joinedOrThrow();
1994
+ let line;
1995
+ try {
1996
+ line = recordRebuildBuilt({ type, name, app: opts.app }, whoAmI(deps), (deps.now ?? (() => new Date()))());
1997
+ }
1998
+ catch (e) {
1999
+ throw new RegisterError(e instanceof Error ? e.message : String(e));
2000
+ }
2001
+ deps.print(line);
1299
2002
  refreshSheet(deps);
1300
2003
  return 0;
1301
2004
  }
2005
+ // ── target ──────────────────────────────────────────────────────────────────
2006
+ const TARGET_USAGE = 'Usage: cspeach team target <A|B|C|D> --reason "<why>" [--packages ZFI_AR,ZFI_GL] [--variant <NAME>] | cspeach team target clear [--packages …]';
2007
+ const TARGET_OPTIONS = ['reason', 'packages', 'variant'];
2008
+ const TARGET_PACKAGE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30}$/;
2009
+ const samePackages = (a, b) => (!a?.length && !b?.length) || (!!a && !!b && a.length === b.length && [...a].sort().join(',') === [...b].sort().join(','));
2010
+ /** The packages an entry is for, read loosely from the file (upper case), or undefined for the whole project. */
2011
+ function entryPackages(t) {
2012
+ const p = t?.packages;
2013
+ return Array.isArray(p) ? p.map((x) => String(x).trim().toUpperCase()) : undefined;
2014
+ }
2015
+ const forWhom = (packages) => (packages?.length ? packages.join(', ') : 'the whole team project');
2016
+ /**
2017
+ * The project's target (spec §3.2, review I9): `target <L> --reason` sets the
2018
+ * entry without packages; `--packages` (exact names, never a pattern) sets the
2019
+ * entry for exactly that set; `clear` removes the matching entry. The whole
2020
+ * project file is written, then renamed. A decision whose target a person set
2021
+ * still comes first (register-core `targetOf`). `--variant` is stored only
2022
+ * (F15): checks go on with the project's variant.
2023
+ */
2024
+ function target(args, deps) {
2025
+ const { positional, opts } = flags(args);
2026
+ for (const name of Object.keys(opts)) {
2027
+ if (!TARGET_OPTIONS.includes(name))
2028
+ throw new RegisterError(`--${name} is not an option of team target.\n${TARGET_USAGE}`);
2029
+ }
2030
+ const [word] = positional;
2031
+ const clearing = word?.toLowerCase() === 'clear';
2032
+ const level = clearing ? undefined : levelOf((word ?? '').trim().toUpperCase());
2033
+ if ((!clearing && !level) || positional.length !== 1)
2034
+ throw new RegisterError(TARGET_USAGE);
2035
+ const joined = joinedOrThrow();
2036
+ const project = readProjectFile(joined.projectDir);
2037
+ requireLead(project, deps);
2038
+ let packages;
2039
+ if (opts.packages !== undefined) {
2040
+ const named = list(opts.packages === 'true' ? '' : opts.packages).map((p) => p.toUpperCase());
2041
+ if (!named.length)
2042
+ throw new RegisterError(`--packages needs package names.\n${TARGET_USAGE}`);
2043
+ if (named.some((p) => /[*?%]/.test(p)))
2044
+ throw new RegisterError('Name each package in full, for example ZFI_AR — not a pattern.');
2045
+ const known = new Set(readBaseline(joined.projectDir).map((o) => o.package.toUpperCase()));
2046
+ const unknown = named.find((p) => !TARGET_PACKAGE.test(p) || !known.has(p));
2047
+ if (unknown)
2048
+ throw new RegisterError(`${shown(unknown)} is not a package of this team project.`);
2049
+ packages = [...new Set(named)].sort();
2050
+ }
2051
+ const existing = Array.isArray(project.target) ? [...project.target] : project.target ? [project.target] : [];
2052
+ const at = existing.findIndex((t) => samePackages(entryPackages(t), packages));
2053
+ let next = existing;
2054
+ const out = [];
2055
+ if (clearing) {
2056
+ if (at < 0) {
2057
+ deps.print(`Nothing to clear: no target is set for ${forWhom(packages)}.`);
2058
+ return 0;
2059
+ }
2060
+ next = existing.filter((_, i) => i !== at);
2061
+ out.push(`✓ Target cleared for ${forWhom(packages)}.${next.length ? '' : ' No target set — counted as the check reports.'}`);
2062
+ }
2063
+ else {
2064
+ if (opts.reason === undefined || opts.reason === 'true')
2065
+ throw new RegisterError('A target needs a reason: --reason "why this level is enough"');
2066
+ const reason = Array.from(shownUpTo(opts.reason, Number.POSITIVE_INFINITY)).slice(0, MAX_REASON).join('').trim();
2067
+ if (!reason)
2068
+ throw new RegisterError('A target needs a reason: --reason "why this level is enough"');
2069
+ let variant;
2070
+ if (opts.variant !== undefined) {
2071
+ variant = opts.variant === 'true' ? '' : opts.variant.trim().toUpperCase();
2072
+ if (!VARIANT_NAME.test(variant))
2073
+ throw new RegisterError(`"${shown(opts.variant === 'true' ? '' : opts.variant.trim())}" is not an ATC check variant name.`);
2074
+ }
2075
+ const entry = {
2076
+ level, by: whoAmI(deps), at: (deps.now ?? (() => new Date()))().toISOString(), reason,
2077
+ ...(packages ? { packages } : {}), ...(variant ? { variant } : {}),
2078
+ };
2079
+ next = at < 0 ? [...existing, entry] : existing.map((t, i) => (i === at ? entry : t));
2080
+ out.push(`✓ Target: level ${level} for ${forWhom(packages)}. Reason recorded.`);
2081
+ if (variant) {
2082
+ out.push(` Target ${level} — variant ${variant} (stored, not yet used for measuring). `
2083
+ + `Checks still use ${(project.atcVariant ?? DEFAULT_ATC_VARIANT).trim().toUpperCase()}.`);
2084
+ }
2085
+ }
2086
+ // The whole file, then renamed (review I9): one entry is written as an object, more as a list.
2087
+ const { target: _old, ...rest } = readProjectFile(joined.projectDir);
2088
+ const file = { ...rest, ...(next.length ? { target: next.length === 1 ? next[0] : next } : {}) };
2089
+ writeWhole(path.join(joined.projectDir, TEAM_FILE), `${JSON.stringify(file, null, 2)}\n`);
2090
+ for (const l of out)
2091
+ deps.print(l);
2092
+ return 0;
2093
+ }
2094
+ /** `team status`: one line per target in the project file, only when there is one (D1). */
2095
+ function targetLines(project) {
2096
+ return targetsOf(project).map((t) => `Target ${t.level}${t.variant ? ` — variant ${t.variant} (stored, not yet used for measuring)` : ''}`
2097
+ + `, for ${forWhom(t.packages)}. Set by ${shown(t.by)}.`);
2098
+ }
1302
2099
  // ── fixed ───────────────────────────────────────────────────────────────────
1303
2100
  const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
1304
2101
  const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
@@ -1377,28 +2174,26 @@ function sheet(deps) {
1377
2174
  return res.file ? 0 : 1;
1378
2175
  }
1379
2176
  // ── report ──────────────────────────────────────────────────────────────────
1380
- const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
2177
+ export const heldReportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`;
1381
2178
  /**
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/`.
2179
+ * The combined workbook and the status file, made from the team project folder
2180
+ * (the same files the team page reads) and written whole, then renamed, into
2181
+ * `outDir`. Used by `report` and by `plan import`, which makes them again so the
2182
+ * lead sees the new export stamp.
2183
+ *
2184
+ * A report open in Excel is the likeliest failure here: Windows refuses the
2185
+ * rename. Each file is tried on its own, so one held file never costs the
2186
+ * other, and the caller is told by file name which one was not updated.
2187
+ * writeWhole has already removed its .tmp when it throws.
1385
2188
  */
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);
2189
+ export function writeReports(projectDir, project, outDir, deps) {
2190
+ const picture = registerFor(projectDir);
1394
2191
  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');
2192
+ const folderName = path.basename(projectDir);
1397
2193
  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.
2194
+ // The discovery file as the folder holds it: the status file says "could not be read"
2195
+ // for one register-core cannot read, the way the page does.
2196
+ const discovery = readEstate(projectDir);
1402
2197
  const held = [];
1403
2198
  const attempt = (name, write) => {
1404
2199
  try {
@@ -1412,18 +2207,48 @@ function report(args, deps) {
1412
2207
  return false;
1413
2208
  }
1414
2209
  };
2210
+ // The page this one replaces says when the last report was made, so the
2211
+ // "changed since" block starts there. Read BEFORE it is overwritten.
2212
+ const previousAsOf = previousAsOfIn(path.join(outDir, names.html));
1415
2213
  const saved = [
1416
- attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName))) ? names.xlsx : null,
2214
+ attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName, project))) ? names.xlsx : null,
1417
2215
  attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
1418
2216
  project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
1419
- checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
2217
+ checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName, previousAsOf,
2218
+ ...(discovery ? { estate: discovery.estate } : {}),
1420
2219
  }))) ? names.html : null,
1421
2220
  ].filter((n) => n !== null);
2221
+ return { saved, held };
2222
+ }
2223
+ const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
2224
+ /**
2225
+ * Lead only (spec §4.5). Reads the team project folder — the same files the team
2226
+ * page reads, so its numbers are the page's — and writes two files whole, then
2227
+ * renamed, into `reports/` or the folder named with --out. Never `writers/`.
2228
+ */
2229
+ function report(args, deps) {
2230
+ const { positional, opts } = flags(args);
2231
+ if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
2232
+ throw new RegisterError(REPORT_USAGE);
2233
+ const joined = joinedOrThrow();
2234
+ const project = readProjectFile(joined.projectDir);
2235
+ requireLead(project, deps);
2236
+ const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
2237
+ const { saved, held } = writeReports(joined.projectDir, project, outDir, deps);
1422
2238
  const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
1423
2239
  if (saved.length)
1424
2240
  deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
1425
2241
  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`);
2242
+ deps.print(heldReportLine(name));
2243
+ // D2: the CSV export rides along only when the project file switched it on.
2244
+ if (exportsOn(project)) {
2245
+ const out = writeExports(joined.projectDir, project, path.join(outDir, EXPORTS_DIR), deps);
2246
+ if (out.saved.length)
2247
+ deps.print('✓ Saved the CSV export in the exports folder beside the report.');
2248
+ for (const name of out.held)
2249
+ deps.print(heldExportLine(name));
2250
+ held.push(...out.held);
2251
+ }
1427
2252
  // Records on this laptop that have not reached the folder are not in the report.
1428
2253
  const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
1429
2254
  const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
@@ -1436,3 +2261,54 @@ function report(args, deps) {
1436
2261
  }
1437
2262
  return held.length ? 1 : 0;
1438
2263
  }
2264
+ // ── export ──────────────────────────────────────────────────────────────────
2265
+ const EXPORT_USAGE = 'Usage: cspeach team export [--out <folder>] [--always | --never]';
2266
+ const heldExportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team export`;
2267
+ /** The CSV export of the team project folder, as the team page reads it, into `dir`. */
2268
+ export function writeExports(projectDir, project, dir, deps) {
2269
+ const { register } = registerFor(projectDir);
2270
+ return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
2271
+ }
2272
+ /**
2273
+ * `team export [--out <folder>] [--always]` (spec §3.4, D2): the CSV files for
2274
+ * the customer's own tools, on demand. Into `reports/exports` of the team
2275
+ * project — the lead's folder, like the report — or the folder `--out` names,
2276
+ * which anyone may. `--always` (lead only) switches on `"exports": { "csv": true }`
2277
+ * in cspeach-team.json, so every report writes them too; `--never` (lead only)
2278
+ * switches it off and writes no files.
2279
+ */
2280
+ function exportVerb(args, deps) {
2281
+ const { positional, opts } = flags(args, ['always', 'never']);
2282
+ const always = opts.always === 'true';
2283
+ const never = opts.never === 'true';
2284
+ if (positional.length || Object.keys(opts).some((k) => k !== 'out' && k !== 'always' && k !== 'never') || opts.out === 'true'
2285
+ || (always && never) || (never && opts.out !== undefined)) {
2286
+ throw new RegisterError(EXPORT_USAGE);
2287
+ }
2288
+ const joined = joinedOrThrow();
2289
+ const project = readProjectFile(joined.projectDir);
2290
+ if (always || never || !opts.out)
2291
+ requireLead(project, deps);
2292
+ if (never) {
2293
+ setExports(joined.projectDir, false);
2294
+ deps.print('✓ Reports no longer write the CSV export ("exports": { "csv": false } in cspeach-team.json). cspeach team export still writes them when you ask.');
2295
+ return 0;
2296
+ }
2297
+ if (always && !exportsOn(project))
2298
+ setExports(joined.projectDir, true);
2299
+ const dir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports', EXPORTS_DIR);
2300
+ const { saved, held } = writeExports(joined.projectDir, project, dir, deps);
2301
+ const where = opts.out ? 'the folder you named' : 'the exports folder of the reports folder';
2302
+ if (saved.length === 6)
2303
+ deps.print(`✓ Saved the CSV export (4 CSV files, manifest.json and SCHEMA.md) in ${where}.`);
2304
+ else if (saved.length)
2305
+ deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(', ')} in ${where}.`);
2306
+ for (const name of held)
2307
+ deps.print(heldExportLine(name));
2308
+ if (always) {
2309
+ deps.print(exportsOn(project)
2310
+ ? ' The CSV export was already written with every report.'
2311
+ : '✓ From now on every report writes the CSV export too ("exports": { "csv": true } in cspeach-team.json).');
2312
+ }
2313
+ return held.length ? 1 : 0;
2314
+ }