@cspeach/cli 1.1.31 → 1.1.33

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 (66) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/cost.js +6 -2
  4. package/dist/commands/help.js +18 -2
  5. package/dist/commands/plan-chain.js +3 -0
  6. package/dist/commands/plan-gate.js +10 -1
  7. package/dist/commands/plan-resume.js +4 -0
  8. package/dist/commands/team-rules.js +187 -0
  9. package/dist/commands/team.js +632 -90
  10. package/dist/commands/usage.js +106 -0
  11. package/dist/cost/month-usage.js +45 -0
  12. package/dist/journey/chain.js +265 -15
  13. package/dist/journey/clean-core.js +25 -3
  14. package/dist/journey/flow.js +51 -1
  15. package/dist/journey/rebuild.js +464 -0
  16. package/dist/journey/run.js +213 -20
  17. package/dist/journey/session-deps.js +5 -1
  18. package/dist/lane/app-chain.js +12 -2
  19. package/dist/one-shot.js +24 -17
  20. package/dist/projects/clean-core-confirm.js +38 -5
  21. package/dist/projects/save-command.js +20 -3
  22. package/dist/register/baseline.js +39 -2
  23. package/dist/register/decision-rules.js +35 -0
  24. package/dist/register/discover.js +49 -19
  25. package/dist/register/export-files.js +58 -0
  26. package/dist/register/measure.js +191 -9
  27. package/dist/register/plan-import.js +6 -3
  28. package/dist/register/records.js +15 -3
  29. package/dist/register/rule-proposal.js +4 -0
  30. package/dist/register/rules-edit.js +257 -0
  31. package/dist/register/status-file/register-status-file.js +106 -108
  32. package/dist/register/status-file/report-model.js +569 -0
  33. package/dist/register/status-file/team-css.js +1 -1
  34. package/dist/register/status-file-sync.js +1 -0
  35. package/dist/register/status-html.js +25 -6
  36. package/dist/register/store.js +4 -3
  37. package/dist/register/variant.js +65 -0
  38. package/dist/register/workbooks.js +22 -56
  39. package/dist/register/write-whole.js +30 -2
  40. package/dist/renderer/tool-labels.js +2 -1
  41. package/dist/repl/builtin-commands.js +7 -0
  42. package/dist/repl/journey-entry.js +4 -2
  43. package/dist/repl/plan-hop.js +2 -9
  44. package/dist/repl/rules-menu.js +177 -0
  45. package/dist/repl/slash-completer.js +2 -0
  46. package/dist/repl/slash-picker.js +5 -4
  47. package/dist/repl.js +157 -86
  48. package/dist/router/classifier.js +8 -4
  49. package/dist/router/router-core.js +43 -7
  50. package/dist/router/routing-decision.js +1 -1
  51. package/dist/runs/bundled-runs.js +100 -0
  52. package/dist/runs/front-door.js +19 -0
  53. package/dist/runs/manifest-runs.js +50 -0
  54. package/dist/runs/run-command.js +24 -0
  55. package/dist/runs/runs.js +27 -0
  56. package/dist/runs/shadow.js +100 -0
  57. package/dist/runs/types.js +1 -0
  58. package/dist/session/queued-step.js +45 -0
  59. package/dist/skills/bundled-skills.js +53 -53
  60. package/dist/tools/dispatch-skill.js +8 -4
  61. package/dist/tools/lane/app-lane-resolve.js +1 -1
  62. package/dist/tools/project/rules_propose.js +59 -0
  63. package/dist/tools/project/team_findings.js +13 -2
  64. package/dist/upgrade-check.js +49 -4
  65. package/package.json +120 -120
  66. package/.cspeach/project.json +0 -4
@@ -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,19 +19,23 @@
18
19
  import fs from 'node:fs';
19
20
  import os from 'node:os';
20
21
  import path from 'node:path';
21
- import { laneActiveOf, laneProvenOf, levelOf, normalizeRuleId, objectKey, } 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
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';
24
- import { MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
25
- import { measureFromFile, shown, upToFive } from '../register/atc-file.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';
26
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';
33
37
  import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
38
+ import { rulesCommand } from './team-rules.js';
34
39
  import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
35
40
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
36
41
  const TYPES = ['clean-core', 'upgrade', 'build'];
@@ -119,17 +124,21 @@ export async function runTeamCommand(args, deps) {
119
124
  case 'status': return status(rest, deps);
120
125
  case 'outbox': return outbox(rest, deps);
121
126
  case 'rule': return rule(rest, deps);
127
+ case 'rules': return rulesCommand(rest, deps);
122
128
  case 'leave': return leave(deps);
123
129
  case 'decide': return decide(rest, deps);
130
+ case 'target': return target(rest, deps);
124
131
  case 'fixed': return await fixed(rest, deps);
125
132
  case 'sheet': return sheet(deps);
126
133
  case 'report': return report(rest, deps);
134
+ case 'export': return exportVerb(rest, deps);
127
135
  case 'discover': return await teamDiscover(rest, deps);
128
136
  case 'focus': return focusCommand(rest, deps);
129
137
  case 'priority': return priorityCommand(rest, deps);
130
138
  case 'plan': return await planCommand(rest, deps);
139
+ case 'rebuild': return await rebuildVerb(rest, deps);
131
140
  default:
132
- deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report|discover|focus|priority|plan|start (cspeach --help for details)');
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)');
133
142
  return sub ? 1 : 0;
134
143
  }
135
144
  }
@@ -191,16 +200,67 @@ function unknownInitOption(name) {
191
200
  return said;
192
201
  return `${said} Did you mean --${guess}?`;
193
202
  }
194
- const START_USAGE = 'Usage: cspeach team start <packages> [--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
195
- const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias'];
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'];
196
206
  /** The check a project measures with when nobody names one. */
197
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
+ }
198
251
  /**
199
252
  * 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
200
253
  * command for what init + join + lane add said in three: the folder you are
201
254
  * in, a name from the packages, you as the lead, the default check, your
202
255
  * results straight into the folder, and every object yours. The long verbs
203
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.
204
264
  */
205
265
  async function start(args, deps) {
206
266
  const { positional, opts } = flags(args, ['no-measure']);
@@ -208,21 +268,48 @@ async function start(args, deps) {
208
268
  if (!START_OPTIONS.includes(name))
209
269
  throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
210
270
  }
211
- const packages = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
212
- if (!packages.length)
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)
213
283
  throw new RegisterError(START_USAGE);
214
284
  const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
215
285
  if (fs.existsSync(path.join(dir, TEAM_FILE)))
216
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
+ }
217
302
  const lead = deps.identity().name;
218
- const name = opts.name && opts.name !== 'true' ? opts.name : packages.join(', ');
303
+ const name = opts.name && opts.name !== 'true' ? opts.name : forWords ? shownUpTo(forWords, 80) : packages.join(', ');
219
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;
220
307
  // The three verbs run as they always do; their detail is kept back and the
221
308
  // few lines that matter are said once, below.
222
309
  const said = [];
223
310
  const quiet = { ...deps, print: (l) => said.push(l) };
224
311
  const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
225
- ...(opts['no-measure'] ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
312
+ ...(noMeasure ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
226
313
  const code = await init(initArgs, quiet);
227
314
  if (code !== 0) {
228
315
  for (const l of said)
@@ -230,19 +317,62 @@ async function start(args, deps) {
230
317
  return code;
231
318
  }
232
319
  join([dir, '--outbox', dir], quiet);
233
- const laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
234
- await lane(['add', laneId, '--owner', lead, '--packages', packages.join(',')], quiet);
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
+ }
235
340
  const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
236
341
  const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
237
342
  const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
238
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);
239
351
  deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
240
352
  if (measured)
241
353
  deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
242
- else if (opts['no-measure'])
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)
243
360
  deps.print(' No check run yet: cspeach team refresh measures it.');
244
- deps.print(` You own all ${plural(objects, 'object', 'objects')}. Your results go straight into this folder.`);
245
- deps.print(` Next, in CSPeach: /abap-clean-core triage lane ${laneId}`);
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}`);
246
376
  return 0;
247
377
  }
248
378
  async function init(args, deps) {
@@ -283,6 +413,18 @@ ${INIT_USAGE}`);
283
413
  // The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
284
414
  const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
285
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
+ }
286
428
  deps.print('Reading the package list from SAP…');
287
429
  const all = await listCustomPackages(sql);
288
430
  const patterns = list(opts.packages);
@@ -316,8 +458,8 @@ ${INIT_USAGE}`);
316
458
  createdAt: now.toISOString(),
317
459
  };
318
460
  fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
319
- fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
320
- 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`);
321
463
  writeLanes(dir, projectId, []);
322
464
  const evidenceLines = discovery || opts['from-discovery']
323
465
  ? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
@@ -347,11 +489,19 @@ ${INIT_USAGE}`);
347
489
  deps.print(l);
348
490
  return joinedRc;
349
491
  }
350
- 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);
351
498
  for (const l of joinLines)
352
499
  deps.print(l);
353
500
  return 0;
354
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).';
355
505
  /**
356
506
  * Per chosen package, what the estate knows beside the live count: its
357
507
  * description, application area and last change. Packages the estate does
@@ -421,7 +571,7 @@ const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'E
421
571
  * an estate the run reports nothing back for at all — no object listed — leaves
422
572
  * nothing to write, and then that is said out loud and `init` still succeeds.
423
573
  */
424
- async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
574
+ async function firstMeasurement(dir, project, baseline, opts, atc, deps, sql) {
425
575
  const LATER = 'Measure when you can: cspeach team refresh --all';
426
576
  if (opts['no-measure']) {
427
577
  deps.print(' Not measured (--no-measure). When you are ready: cspeach team refresh --all');
@@ -454,6 +604,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
454
604
  joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
455
605
  dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
456
606
  confirmAbsent: true,
607
+ ...(sql ? { sql } : {}),
457
608
  ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
458
609
  signal: stop.signal,
459
610
  onProgress: progressLine(deps),
@@ -497,11 +648,15 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
497
648
  function join(args, deps, asName) {
498
649
  const { positional, opts } = flags(args);
499
650
  if (!positional[0])
500
- 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.
501
654
  const projectDir = path.resolve(positional[0]);
502
655
  const project = readProjectFile(projectDir);
503
656
  const objects = readBaseline(projectDir).length; // also verifies the checksum
504
657
  const state = loadState();
658
+ if (opts.as !== undefined && asName === undefined)
659
+ asName = joinAsName(opts.as, projectDir, project, state.projects[project.projectId]?.name, deps);
505
660
  const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
506
661
  // A name given once is kept: joining again must not turn the lead back into
507
662
  // whatever the machine calls them.
@@ -515,13 +670,93 @@ function join(args, deps, asName) {
515
670
  // Outbox is the team project folder itself: records land where everyone reads them.
516
671
  if (path.relative(projectDir, outboxDir) === '') {
517
672
  deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
518
- return 0;
519
673
  }
520
- deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
521
- deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
522
- deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
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}`);
523
682
  return 0;
524
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
+ }
525
760
  function leave(deps) {
526
761
  const state = loadState();
527
762
  if (!state.active) {
@@ -551,6 +786,8 @@ async function lane(args, deps) {
551
786
  return 0;
552
787
  }
553
788
  requireLead(project, deps);
789
+ if (verb === 'move')
790
+ return laneMove(positional, opts, joined, project, lanes, deps);
554
791
  const id = positional[0];
555
792
  if (!id)
556
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 "…"');
@@ -566,7 +803,7 @@ async function lane(args, deps) {
566
803
  return 0;
567
804
  }
568
805
  if (verb !== 'add')
569
- throw new RegisterError('lane: use add, remove, list or propose.');
806
+ throw new RegisterError('lane: use add, remove, move, list or propose.');
570
807
  const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
571
808
  if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
572
809
  throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
@@ -588,12 +825,75 @@ async function lane(args, deps) {
588
825
  deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
589
826
  return 0;
590
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));
894
+ return 0;
895
+ }
591
896
  // ── status ──────────────────────────────────────────────────────────────────
592
- const STATE_WORDS = [
593
- ['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
594
- ['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
595
- ['regressed', 'regressed'], ['retired', 'retired'],
596
- ];
597
897
  function status(args, deps) {
598
898
  const { positional } = flags(args);
599
899
  const joined = activeProject();
@@ -604,53 +904,39 @@ function status(args, deps) {
604
904
  const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
605
905
  const t = r.totals;
606
906
  deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
607
- // The estate around the team project, when the folder holds a discovery (spec 2026-10-06 §9).
608
- if (r.estate) {
609
- const e = r.estate.totals;
610
- const n = (v) => v.toLocaleString('en-US');
611
- deps.print(`Estate: ${n(e.packages)} ${e.packages === 1 ? 'package' : 'packages'}, `
612
- + `${n(e.codeObjects)} programs and other code objects, ${n(e.nonCodeObjects)} non-code objects, `
613
- + `${n(e.unknowns)} ${e.unknowns === 1 ? 'unknown' : 'unknowns'}`);
614
- }
615
- // A clean sheet out of a file is somebody's export, not this system speaking.
616
- // The words "verified" and "clean" do not change; the line says how many of
617
- // them were measured one step removed, so nobody reads them as live proof.
618
- const fromFile = t.fromFile.verified + t.fromFile.clean;
619
- // Recording fix 4: a clean object nobody decided has nothing to decide — said on its own.
620
- deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
621
- + `${t.cleanNothingToDecide ? `${t.cleanNothingToDecide} clean, nothing to decide. ` : ''}`
622
- + `${t.byState.verified} verified, ${t.byState.clean} clean`
623
- + `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
624
- // The newest run that counted — by its time, not by the order the files were
625
- // read in — so "from a file" is said of the run the date belongs to.
626
- const lastRun = [...r.runs].reverse().find((run) => run.at === r.lastMeasuredAt);
627
- deps.print(r.lastMeasuredAt
628
- ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
629
- : 'Not measured yet: "verified" and "clean" need an ATC run.');
630
- deps.print(nowLine(r));
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);
631
915
  deps.print('');
632
- for (const [state, word] of STATE_WORDS)
633
- if (t.byState[state])
634
- 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
+ }
635
925
  deps.print('');
636
- if (r.lanes.length) {
637
- // The same columns, in the same order, as the team project page's lane table,
638
- // counted with the same three functions. One lane must not read "50% done"
639
- // in a browser and "3 done" here.
640
- deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
641
- for (const l of r.lanes) {
642
- deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} `
643
- + `${String(laneActiveOf(l)).padStart(6)} ${String(laneProvenOf(l)).padStart(6)} `
644
- + `${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)}`);
645
933
  }
646
- // Done and what comes next, by the same priority every surface sorts by.
647
- // Names only: a lane id stands only beside its owner's name.
648
- for (const l of r.lanes) {
649
- const next = l.next.slice(0, 3).map((k) => k.slice(k.indexOf('|') + 1));
650
- deps.print(` ${l.owner} (${l.id}): Done ${l.donePct}%. Next: ${next.length ? next.join(', ') : 'nothing left to prove'}`);
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'}`);
651
939
  }
652
- // Said once per lane it is true of. A lane set up before this rule held
653
- // both a package list and a name list, and took the whole package.
654
940
  for (const id of narrowed)
655
941
  deps.print(`Lane ${id} now means: in these packages AND matching these names.`);
656
942
  deps.print('');
@@ -1043,6 +1329,38 @@ function notMeasuredLines(res, deps, words = {}) {
1043
1329
  + someNames(res.unsupportedNames));
1044
1330
  }
1045
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
+ }
1046
1364
  export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
1047
1365
  if (!res.file) {
1048
1366
  deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
@@ -1052,7 +1370,10 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
1052
1370
  notMeasuredLines(res, deps, words);
1053
1371
  return;
1054
1372
  }
1055
- 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);
1056
1377
  secondPassLines(res, deps);
1057
1378
  // F3: SAP reports some findings twice — same place, same message, two finding
1058
1379
  // ids. One problem in the code is one finding, so it is counted once; a
@@ -1284,8 +1605,9 @@ async function refresh(args, deps) {
1284
1605
  if (!inScope.length)
1285
1606
  throw new RegisterError(`Nothing in this team project matches ${scopeLabel(scope)}.`);
1286
1607
  let atc;
1608
+ let sql;
1287
1609
  try {
1288
- ({ atc } = await deps.connect(opts.sapAlias));
1610
+ ({ atc, sql } = await deps.connect(opts.sapAlias));
1289
1611
  }
1290
1612
  catch (e) {
1291
1613
  // One line, the reason, no stack: "no system configured", a dead VPN and a
@@ -1298,7 +1620,7 @@ async function refresh(args, deps) {
1298
1620
  return 2;
1299
1621
  }
1300
1622
  if (opts.perPackage)
1301
- return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts }, deps);
1623
+ return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql }, deps);
1302
1624
  deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
1303
1625
  const stop = new AbortController();
1304
1626
  const off = onInterrupt(deps, () => {
@@ -1322,6 +1644,7 @@ async function refresh(args, deps) {
1322
1644
  ...(foreign || persist ? { forceVariant: true } : {}),
1323
1645
  signal: stop.signal,
1324
1646
  confirmAbsent: true,
1647
+ sql,
1325
1648
  onProgress: progressLine(deps),
1326
1649
  });
1327
1650
  }
@@ -1382,7 +1705,7 @@ async function refresh(args, deps) {
1382
1705
  * stops between packages; every record already written stays.
1383
1706
  */
1384
1707
  async function refreshPerPackage(input, deps) {
1385
- const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts } = input;
1708
+ const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql } = input;
1386
1709
  const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1387
1710
  const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
1388
1711
  // A package is done only when a counted run from the system (never a result file),
@@ -1396,8 +1719,11 @@ async function refreshPerPackage(input, deps) {
1396
1719
  continue;
1397
1720
  const c = run.coverage;
1398
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;
1399
1724
  if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
1400
- && c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked)
1725
+ && c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked
1726
+ && !(pass && pass.skipped > 0) && (pass?.notChecked ?? 0) === 0)
1401
1727
  done.add(run.scope);
1402
1728
  }
1403
1729
  }
@@ -1438,12 +1764,16 @@ async function refreshPerPackage(input, deps) {
1438
1764
  ...(opts.oneByOne ? {} : { oneByOne: false }),
1439
1765
  ...(foreign || persist ? { forceVariant: true } : {}),
1440
1766
  confirmAbsent: true,
1767
+ ...(sql ? { sql } : {}),
1441
1768
  onProgress: progressLine(deps),
1442
1769
  });
1443
1770
  measuredPackages += 1;
1444
1771
  if (res.file) {
1445
1772
  written += 1;
1446
- deps.print(` ✓ ${pkg}: ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings. Written: ${path.basename(res.file)}`);
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);
1447
1777
  }
1448
1778
  else {
1449
1779
  deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
@@ -1531,8 +1861,11 @@ function rule(args, deps) {
1531
1861
  return 0;
1532
1862
  }
1533
1863
  // ── decide ──────────────────────────────────────────────────────────────────
1534
- const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"]';
1535
- const DECIDE_OPTIONS = ['level', 'target', 'reason'];
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' };
1536
1869
  /** The five words a person types, onto the four classifications the files hold (spec §4.2). */
1537
1870
  const DECIDE_WORDS = {
1538
1871
  keep: { classification: 'keep' },
@@ -1576,10 +1909,23 @@ export function parseDecideArgs(args) {
1576
1909
  if (said.classification === 'retire' && !reason) {
1577
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"' };
1578
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).' };
1579
1921
  return {
1580
1922
  ok: true,
1581
1923
  value: {
1582
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,
1583
1929
  row: {
1584
1930
  classification: said.classification,
1585
1931
  ...(said.exception ? { exception: true } : {}),
@@ -1587,17 +1933,24 @@ export function parseDecideArgs(args) {
1587
1933
  // Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
1588
1934
  ...(moves ? { targetLevel: target.value ?? 'A' } : {}),
1589
1935
  ...(reason ? { reason } : {}),
1936
+ ...(kind ? { rebuildKind: kind } : {}),
1937
+ ...(app ? { app } : {}),
1590
1938
  },
1591
1939
  },
1592
1940
  };
1593
1941
  }
1594
1942
  const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
1595
- function decisionWords(row) {
1943
+ function decisionWords(row, targetNamed) {
1596
1944
  const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
1597
1945
  if (row.level)
1598
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.
1599
1948
  if (row.targetLevel)
1600
- 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}`);
1601
1954
  return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
1602
1955
  }
1603
1956
  function decide(args, deps) {
@@ -1605,18 +1958,144 @@ function decide(args, deps) {
1605
1958
  if (!parsed.ok)
1606
1959
  throw new RegisterError(parsed.message);
1607
1960
  const joined = joinedOrThrow();
1608
- const { type, name, row } = parsed.value;
1609
- const res = writeRecord(joined, 'decision', [{ key: objectKey(type, name), ...row }], { name: whoAmI(deps) }, {
1610
- ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
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 } : {}),
1611
1969
  });
1612
1970
  if (!res.file)
1613
1971
  throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
1614
- deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
1972
+ deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row, targetNamed)}.`);
1615
1973
  if (row.classification === 'retire')
1616
1974
  deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
1617
1975
  refreshSheet(deps);
1618
1976
  return 0;
1619
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);
2002
+ refreshSheet(deps);
2003
+ return 0;
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
+ }
1620
2099
  // ── fixed ───────────────────────────────────────────────────────────────────
1621
2100
  const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
1622
2101
  const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
@@ -1728,11 +2207,14 @@ export function writeReports(projectDir, project, outDir, deps) {
1728
2207
  return false;
1729
2208
  }
1730
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));
1731
2213
  const saved = [
1732
- 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,
1733
2215
  attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
1734
2216
  project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
1735
- checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
2217
+ checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName, previousAsOf,
1736
2218
  ...(discovery ? { estate: discovery.estate } : {}),
1737
2219
  }))) ? names.html : null,
1738
2220
  ].filter((n) => n !== null);
@@ -1758,6 +2240,15 @@ function report(args, deps) {
1758
2240
  deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
1759
2241
  for (const name of held)
1760
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
+ }
1761
2252
  // Records on this laptop that have not reached the folder are not in the report.
1762
2253
  const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
1763
2254
  const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
@@ -1770,3 +2261,54 @@ function report(args, deps) {
1770
2261
  }
1771
2262
  return held.length ? 1 : 0;
1772
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
+ }