@cspeach/cli 1.1.47 → 1.1.49

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 (110) hide show
  1. package/dist/agent/level.js +1 -1
  2. package/dist/agent/loop.js +7 -6
  3. package/dist/agent/sap-connection-adapter.js +2 -0
  4. package/dist/agent/system-prompt.js +2 -2
  5. package/dist/agent/tool-dispatch.js +13 -1
  6. package/dist/approvals/op-labels.js +49 -10
  7. package/dist/approvals/render.js +14 -23
  8. package/dist/approvals/risk-floor.js +196 -19
  9. package/dist/approvals/visible-changes.js +35 -0
  10. package/dist/commands/config-set.js +5 -5
  11. package/dist/commands/cost.js +2 -1
  12. package/dist/commands/help.js +1 -1
  13. package/dist/commands/project.js +301 -0
  14. package/dist/commands/team-plan.js +16 -6
  15. package/dist/commands/team.js +295 -68
  16. package/dist/config/loader.js +13 -0
  17. package/dist/cost/cost-log.js +7 -4
  18. package/dist/cost/credits-wire.js +11 -6
  19. package/dist/cost/credits.js +15 -0
  20. package/dist/cost/display-mode.js +16 -4
  21. package/dist/doctor/checks/zcspeach.js +96 -0
  22. package/dist/journey/chain.js +489 -61
  23. package/dist/journey/clean-core.js +61 -36
  24. package/dist/journey/flow.js +75 -2
  25. package/dist/journey/people.js +37 -0
  26. package/dist/journey/rebuild.js +7 -6
  27. package/dist/journey/run.js +129 -42
  28. package/dist/journey/trigger.js +16 -10
  29. package/dist/one-shot.js +1 -1
  30. package/dist/project-context/render.js +7 -0
  31. package/dist/projects/clean-core-confirm.js +166 -44
  32. package/dist/projects/email-template.js +56 -0
  33. package/dist/projects/extract-cca.js +8 -7
  34. package/dist/projects/index.js +0 -1
  35. package/dist/projects/merge-cca.js +0 -19
  36. package/dist/projects/merge-upgrade.js +2 -2
  37. package/dist/projects/project-folder.js +2 -1
  38. package/dist/projects/save-command.js +51 -14
  39. package/dist/register/decision-rules.js +2 -1
  40. package/dist/register/export-files.js +18 -0
  41. package/dist/register/picture.js +3 -3
  42. package/dist/register/project-transport.js +125 -0
  43. package/dist/register/records.js +25 -13
  44. package/dist/register/rules-edit.js +5 -0
  45. package/dist/register/status-file/progress-chart.js +17 -6
  46. package/dist/register/status-file/register-map.js +8 -5
  47. package/dist/register/status-file/register-model.js +55 -36
  48. package/dist/register/status-file/register-parts.js +97 -82
  49. package/dist/register/status-file/register-status-file.js +16 -7
  50. package/dist/register/status-file/report-model.js +293 -205
  51. package/dist/register/status-file/team-css.js +1 -1
  52. package/dist/register/status-html.js +11 -4
  53. package/dist/register/store.js +2 -2
  54. package/dist/register/workbooks.js +24 -13
  55. package/dist/renderer/footer-line.js +10 -2
  56. package/dist/renderer/status-footer.js +2 -1
  57. package/dist/renderer/tool-labels.js +2 -2
  58. package/dist/repl/builtin-commands.js +1 -1
  59. package/dist/repl/continue-skill.js +5 -0
  60. package/dist/repl/level-command.js +6 -15
  61. package/dist/repl.js +21 -8
  62. package/dist/router/router-core.js +5 -3
  63. package/dist/session/audit-export.js +4 -1
  64. package/dist/skill-catalog.js +4 -7
  65. package/dist/tools/_team-work-path.js +53 -0
  66. package/dist/tools/approval.js +97 -19
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-write.js +8 -1
  69. package/dist/tools/local-files.js +31 -0
  70. package/dist/tools/project/_merge-shared.js +2 -2
  71. package/dist/tools/project/_team-tool-shared.js +5 -0
  72. package/dist/tools/project/cca_merge.js +1 -11
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/subagent/schedule_create.js +131 -0
  78. package/dist/tools/text-elements/tools.js +7 -1
  79. package/dist/tools/tool-loading.js +1 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +19 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/error-emitter.js +21 -0
  84. package/dist/ui/footer-line.js +19 -1
  85. package/dist/ui/header.js +41 -0
  86. package/dist/ui/ink-classifier-route.js +19 -0
  87. package/dist/ui/rich-error-box.js +9 -0
  88. package/dist/ui/sidebar.js +12 -0
  89. package/dist/ui/status-emitter.js +19 -0
  90. package/dist/ui/status-line.js +62 -0
  91. package/dist/ui/status-row.js +38 -0
  92. package/dist/ui/todo-panel.js +85 -0
  93. package/dist/ui/widgets/ask-form.js +250 -0
  94. package/dist/ui/widgets/ask-question-modal.js +178 -0
  95. package/fiori/zlas-inspection-lr/package.json +26 -0
  96. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  97. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  98. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  99. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  100. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  101. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  102. package/live-check-2026-10-17-1343.txt +41 -0
  103. package/package.json +121 -121
  104. package/test-harness/fe-draft/app.ts +0 -188
  105. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  106. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  107. package/test-harness/fe-draft/routes.ts +0 -224
  108. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  109. package/test-harness/fe-draft/server.ts +0 -225
  110. package/test-harness/fe-draft/variants.ts +0 -196
@@ -14,12 +14,14 @@
14
14
  *
15
15
  * Every path written here is one a person typed. Nothing is written to a
16
16
  * network location on CSPeach's own initiative, and none of this is reachable
17
- * from a model tool.
17
+ * from a model tool — except the lane move body (moveLanes), which the
18
+ * team_lane_move tool runs only after its own checks and the person's Yes on
19
+ * a card (Task 4, 2026-10-20).
18
20
  */
19
21
  import fs from 'node:fs';
20
22
  import os from 'node:os';
21
23
  import path from 'node:path';
22
- import { isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, proposeLanes, scopeFromWords, targetsOf, } from '@cspeach/register-core';
24
+ import { decidedCount, decisionWhatOf, isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, partCounts, proposeLanes, scopeFromWords, targetsOf, viewWords, } from '@cspeach/register-core';
23
25
  import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
24
26
  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
27
  import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
@@ -30,10 +32,11 @@ import { registerFor } from '../register/picture.js';
30
32
  import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
31
33
  import { writeXlsxFile } from '../register/xlsx.js';
32
34
  import { writeWhole } from '../register/write-whole.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 { EXPORTS_DIR, exportsOff, exportsOn, exportsPresent, setExports, writeExportFiles } from '../register/export-files.js';
36
+ import { previousAsOfIn, projectViewFor, reportViewFor, statusHtmlFor } from '../register/status-html.js';
35
37
  import { firstBlockLines, awaitingWord, lastCheckWord } from '../register/status-file/report-model.js';
36
38
  import { lookupVariant, variantLookupLine } from '../register/variant.js';
39
+ import { TRANSPORT_NONE_WORDS, TRANSPORT_REQUEST, notARequestLine, projectTransportWords, writeProjectTransport } from '../register/project-transport.js';
37
40
  import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
38
41
  import { rulesCommand } from './team-rules.js';
39
42
  import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
@@ -52,11 +55,11 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
52
55
  */
53
56
  export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
54
57
  /** After a record this laptop wrote: its working list, made again. One line, or none. */
55
- function refreshSheet(deps) {
58
+ function refreshSheet(deps, read) {
56
59
  const joined = activeProject();
57
60
  if (!joined)
58
61
  return;
59
- const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
62
+ const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.(), read);
60
63
  if (res.line)
61
64
  deps.print(res.line);
62
65
  }
@@ -107,6 +110,16 @@ export function flags(args, flagOnly = []) {
107
110
  export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
108
111
  /** "1 object", "3 objects" — every count this command prints reads as a sentence. */
109
112
  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
113
+ /**
114
+ * `lane add` for a lane that owns none of what it matches: whose lanes hold
115
+ * those objects, by each owner's name (never he/she), in the order met.
116
+ */
117
+ export function heldElsewhereLine(id, held) {
118
+ const owners = [...new Set(held.map((h) => `${shown(h.owner)}'s`))];
119
+ const named = owners.length === 1 ? owners[0] : `${owners.slice(0, -1).join(', ')} and ${owners.at(-1)}`;
120
+ const n = held.length;
121
+ return `✓ Lane ${id} added: 0 objects of its own — ${plural(n, 'object', 'objects')} it matches already ${n === 1 ? 'belongs' : 'belong'} to ${named} ${owners.length === 1 ? 'lane' : 'lanes'}.`;
122
+ }
110
123
  /** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
111
124
  function matches(pattern, value) {
112
125
  const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
@@ -128,6 +141,7 @@ export async function runTeamCommand(args, deps) {
128
141
  case 'leave': return leave(deps);
129
142
  case 'decide': return decide(rest, deps);
130
143
  case 'target': return target(rest, deps);
144
+ case 'transport': return transportVerb(rest, deps);
131
145
  case 'fixed': return await fixed(rest, deps);
132
146
  case 'sheet': return sheet(deps);
133
147
  case 'report': return report(rest, deps);
@@ -138,7 +152,7 @@ export async function runTeamCommand(args, deps) {
138
152
  case 'plan': return await planCommand(rest, deps);
139
153
  case 'rebuild': return await rebuildVerb(rest, deps);
140
154
  default:
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)');
155
+ deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
142
156
  return sub ? 1 : 0;
143
157
  }
144
158
  }
@@ -156,9 +170,13 @@ export async function runTeamCommand(args, deps) {
156
170
  return 1;
157
171
  }
158
172
  }
173
+ /** True when this seat is the project's lead. The one test: requireLead and team_lane_move both use it. */
174
+ export function isLead(project, deps) {
175
+ return whoAmI(deps).trim().toLowerCase() === project.lead.name.trim().toLowerCase();
176
+ }
159
177
  export function requireLead(project, deps) {
160
178
  const me = whoAmI(deps);
161
- if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
179
+ if (!isLead(project, deps)) {
162
180
  throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
163
181
  }
164
182
  }
@@ -201,8 +219,8 @@ function unknownInitOption(name) {
201
219
  return `${said} Did you mean --${guess}?`;
202
220
  }
203
221
  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'];
222
+ + '[--name "<name>"] [--atc-variant <NAME>] [--transport <REQUEST>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
223
+ const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport'];
206
224
  /** The check a project measures with when nobody names one. */
207
225
  export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
208
226
  /** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
@@ -220,6 +238,8 @@ const firstWordOf = (name) => (name.trim().split(/\s+/)[0] ?? '').toLowerCase();
220
238
  * `lane move --to`): plain text, no control characters, at most 60 characters.
221
239
  * `what` starts the message, so the line says which option was wrong.
222
240
  */
241
+ /** A plain person's name: not empty, no control characters, at most 60 characters. */
242
+ export const isPlainName = (raw) => !!raw.trim() && !CONTROL.test(raw.trim()) && raw.trim().length <= MAX_PERSON_NAME;
223
243
  export function personName(raw, what, example = 'James Miller') {
224
244
  const name = raw === undefined || raw === 'true' ? '' : raw.trim();
225
245
  if (!name)
@@ -281,6 +301,14 @@ async function start(args, deps) {
281
301
  const developers = developersOf(opts.developers);
282
302
  if (!forWords && !typed.length)
283
303
  throw new RegisterError(START_USAGE);
304
+ // Task 8 (B11): the request the fixes are recorded in, checked before anything is written.
305
+ let request;
306
+ if (opts.transport !== undefined) {
307
+ const t = opts.transport === 'true' ? '' : opts.transport.trim().toUpperCase();
308
+ if (!TRANSPORT_REQUEST.test(t))
309
+ throw new RegisterError(notARequestLine(opts.transport === 'true' ? '' : opts.transport));
310
+ request = t;
311
+ }
284
312
  const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
285
313
  if (fs.existsSync(path.join(dir, TEAM_FILE)))
286
314
  throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
@@ -317,6 +345,8 @@ async function start(args, deps) {
317
345
  return code;
318
346
  }
319
347
  join([dir, '--outbox', dir], quiet);
348
+ if (request)
349
+ writeProjectTransport(dir, { request, by: lead, at: (deps.now ?? (() => new Date()))().toISOString() });
320
350
  let lanesWritten = null;
321
351
  let note = '';
322
352
  let laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
@@ -348,6 +378,8 @@ async function start(args, deps) {
348
378
  // §3.1: what the variant lookup found, said on every path (init's own line, kept back above).
349
379
  for (const l of said.filter((x) => /^ {2}(! )?CSPeach could not (find|confirm) the variant /.test(x)))
350
380
  deps.print(l);
381
+ if (request)
382
+ deps.print(` Transport: ${request}.`);
351
383
  deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
352
384
  if (measured)
353
385
  deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
@@ -355,7 +387,7 @@ async function start(args, deps) {
355
387
  else if (said.includes(FIRST_CHECK_PER_PACKAGE_LINE) || objects > FIRST_CHECK_WHOLE_MAX)
356
388
  deps.print(FIRST_CHECK_PER_PACKAGE_LINE);
357
389
  else if (lanesWritten)
358
- deps.print(' No check run yet: cspeach team refresh --all measures it.');
390
+ deps.print(NO_CHECK_ALL_LINE);
359
391
  else if (noMeasure)
360
392
  deps.print(' No check run yet: cspeach team refresh measures it.');
361
393
  if (lanesWritten) {
@@ -502,6 +534,8 @@ ${INIT_USAGE}`);
502
534
  /** Above this many objects, `init` does not run the first check over the whole baseline (I15). */
503
535
  export const FIRST_CHECK_WHOLE_MAX = 500;
504
536
  export const FIRST_CHECK_PER_PACKAGE_LINE = ' First check per package: cspeach team refresh --all --per-package (it resumes if stopped).';
537
+ /** What a start with --developers says: nothing measured yet, for anyone (the journey runs the first check per package then). */
538
+ export const NO_CHECK_ALL_LINE = ' No check run yet: cspeach team refresh --all measures it.';
505
539
  /**
506
540
  * Per chosen package, what the estate knows beside the live count: its
507
541
  * description, application area and last change. Packages the estate does
@@ -810,15 +844,24 @@ async function lane(args, deps) {
810
844
  }
811
845
  writeLanes(joined.projectDir, project.projectId, [...lanes.filter((l) => l.id !== id), next]);
812
846
  const reg = registerFor(joined.projectDir, joined.outboxDir);
813
- const mine = reg.register.lanes.find((l) => l.id === id);
814
- // The count is every object this lane MATCHES, so it can never disagree with
815
- // the overlap line under it (F2).
816
- deps.print(`✓ Lane ${id}: ${next.owner}, ${plural(mine.objects, 'object', 'objects')}.`);
847
+ // Owner decision (final review): the count is the objects this lane OWNS, the
848
+ // view's lane total, as on every other surface. An object another lane owns
849
+ // too is said by the overlap line under it. A lane that owns none of what it
850
+ // matches says whose lanes hold those objects instead, and the overlap line
851
+ // is left out when it would only say the same thing again.
852
+ const view = projectViewFor(reg.register, project);
853
+ const owned = view.lanes.find((l) => l.scope.kind === 'lane' && l.scope.id === id)?.total ?? 0;
854
+ const held = owned === 0 ? view.all.objects.filter((v) => v.alsoMatches.includes(id) && v.owner) : [];
855
+ if (held.length)
856
+ deps.print(heldElsewhereLine(id, held.map((v) => ({ owner: v.owner.name }))));
857
+ else
858
+ deps.print(`✓ Lane ${id}: ${next.owner}, ${plural(owned, 'object', 'objects')}.`);
817
859
  if (next.packages.length && next.namePatterns.length) {
818
860
  deps.print(` Lane ${id} means: in these packages AND matching these names.`);
819
861
  }
820
- if (reg.register.totals.overlapping)
821
- deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
862
+ const overlapping = reg.register.totals.overlapping ?? 0;
863
+ if (overlapping && overlapping !== held.length)
864
+ deps.print(` ! ${plural(overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
822
865
  if (reg.register.totals.unowned)
823
866
  deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
824
867
  if (reason)
@@ -834,18 +877,34 @@ const patternOf = (l) => l.packages.find((p) => p.includes('*')) ?? l.namePatter
834
877
  * lane of names when that developer has none yet. Lanes of names only: a lane
835
878
  * that holds a pattern cannot give up one package, so the move is refused in
836
879
  * one line and nothing is written. A lane left empty is removed.
880
+ *
881
+ * The verb parses the flags; moveLanes is the body, shared with the
882
+ * team_lane_move tool (Task 4, 2026-10-20).
837
883
  */
838
884
  function laneMove(positional, opts, joined, project, lanes, deps) {
839
885
  const names = [...new Set(list(opts.packages === 'true' ? '' : opts.packages).map((p) => p.toUpperCase()))];
840
886
  if (positional.length || !names.length || !opts.to || opts.to === 'true')
841
887
  throw new RegisterError(MOVE_USAGE);
888
+ const reason = reasonFrom(opts, false);
889
+ moveLanes(names, opts.to, reason, joined, project, lanes, deps);
890
+ return 0;
891
+ }
892
+ /**
893
+ * The checks and the new lanes of a move, nothing written: names typed in
894
+ * full, each a package of the baseline, no pattern lane on either side.
895
+ * Throws RegisterError with the one line a person reads.
896
+ */
897
+ export function planLaneMove(names, toRaw, joined, lanes) {
898
+ names = [...new Set(names.map((n) => n.trim().toUpperCase()).filter(Boolean))];
899
+ if (!names.length)
900
+ throw new RegisterError(MOVE_USAGE);
842
901
  if (names.some(isPatternWord))
843
902
  throw new RegisterError('Name each package in full, for example ZSD_ORDERS — not a pattern.');
844
903
  const known = new Set(readBaseline(joined.projectDir).map((o) => o.package.toUpperCase()));
845
904
  for (const n of names)
846
905
  if (!known.has(n))
847
906
  throw new RegisterError(`${shown(n)} is not a package of this team project.`);
848
- const to = personName(opts.to, '--to');
907
+ const to = personName(toRaw, '--to');
849
908
  const target = lanes.find((l) => l.id.toLowerCase() === to.toLowerCase()) ?? lanes.find((l) => sameName(l.owner, to));
850
909
  const holders = lanes.filter((l) => l !== target && l.packages.some((p) => names.some((n) => matches(p, n))));
851
910
  for (const l of [...holders, ...(target ? [target] : [])]) {
@@ -857,10 +916,8 @@ function laneMove(positional, opts, joined, project, lanes, deps) {
857
916
  }
858
917
  // Review finding 7: a move that changes nothing says so and writes nothing.
859
918
  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;
919
+ return { kind: 'nothing', line: `Nothing to move: ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} already in ${shown(target.owner)}'s lane.` };
862
920
  }
863
- const reason = reasonFrom(opts, false);
864
921
  const into = target
865
922
  ? { ...target, packages: [...target.packages, ...names.filter((n) => !target.packages.some((p) => p.toUpperCase() === n))] }
866
923
  : { id: laneIdFor(to, new Set(lanes.map((l) => l.id))), owner: to, packages: names, namePatterns: [] };
@@ -883,15 +940,42 @@ function laneMove(positional, opts, joined, project, lanes, deps) {
883
940
  }
884
941
  if (!target)
885
942
  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')}.`);
943
+ return { kind: 'move', names, target, into, holders, next, removed };
944
+ }
945
+ /**
946
+ * The move itself: lanes.json, then ALWAYS one plan record
947
+ * `lane: { change: 'move' }` (with the reason when one was given), and the
948
+ * lines a person reads through `deps.print`. The caller has checked the lead.
949
+ * A move that changes nothing prints so and writes nothing: `moved` is empty.
950
+ */
951
+ export function moveLanes(names, to, reason, joined, project, lanes, deps) {
952
+ const plan = planLaneMove(names, to, joined, lanes);
953
+ if (plan.kind === 'nothing') {
954
+ deps.print(plan.line);
955
+ return { moved: [], to: personName(to, '--to'), lane: null };
956
+ }
957
+ const { into, removed } = plan;
958
+ writeLanes(joined.projectDir, project.projectId, plan.next);
959
+ // The view after the move: the packages' own objects, and the lane's.
960
+ const view = projectViewFor(registerFor(joined.projectDir, joined.outboxDir).register, project);
961
+ const moved = plan.names.map((name) => {
962
+ const objects = view.all.objects.filter((o) => o.package.toUpperCase() === name.toUpperCase());
963
+ return { package: name, objects: objects.length, parts: partCounts(objects), decided: decidedCount(objects) };
964
+ });
965
+ const laneObjects = view.lanes.find((s) => s.scope.kind === 'lane' && s.scope.id === into.id)?.objects ?? [];
966
+ const movedObjects = moved.reduce((n, m) => n + m.objects, 0);
967
+ // Never a pronoun (nobody's is known): the person's name, else the lane id.
968
+ const whose = into.owner.trim() ? shown(into.owner) : into.id;
969
+ deps.print(`✓ ${plan.names.join(', ')} moved to ${shown(into.owner)} (${into.id}): `
970
+ + `${plural(movedObjects, 'object', 'objects')} in the ${plan.names.length === 1 ? 'package' : 'packages'}; `
971
+ + `${whose}'s lane now holds ${plural(into.packages.length, 'package', 'packages')}, ${plural(laneObjects.length, 'object', 'objects')}.`);
890
972
  for (const id of removed)
891
973
  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;
974
+ deps.print(laneChangeRecord(deps, 'move', plan.target, into, into.id, reason, plan.names, plan.holders.map((l) => ({ id: l.id, owner: l.owner }))));
975
+ return {
976
+ moved, to: into.owner,
977
+ lane: { id: into.id, owner: into.owner, objects: laneObjects.length, parts: partCounts(laneObjects), decided: decidedCount(laneObjects) },
978
+ };
895
979
  }
896
980
  // ── status ──────────────────────────────────────────────────────────────────
897
981
  function status(args, deps) {
@@ -909,13 +993,17 @@ function status(args, deps) {
909
993
  // previous page in reports/ says where "changed since" starts.
910
994
  const asOf = (deps.now?.() ?? new Date()).toISOString();
911
995
  const previousAsOf = previousAsOfIn(path.join(projectDir, 'reports', reportFileNames(project.name).html));
912
- const view = reportViewFor({ project, register: r, asOf, previousAsOf });
996
+ // The project view, built once: the first block, the lane table and the transport line read it.
997
+ const pv = projectViewFor(r, project);
998
+ const view = reportViewFor({ view: pv, register: r, asOf, previousAsOf });
913
999
  for (const l of firstBlockLines(view))
914
1000
  deps.print(l);
915
1001
  deps.print('');
916
1002
  deps.print(nowLine(r));
917
1003
  for (const l of targetLines(project))
918
1004
  deps.print(l);
1005
+ // The request this project's fixes are recorded in, or how to set one.
1006
+ deps.print(pv.project.transport ? projectTransportWords(pv.project.transport.request) : TRANSPORT_NONE_WORDS);
919
1007
  // Spec §3.6: a joiner who owns nothing reads that first, never a finished picture.
920
1008
  if (!positional[0] && joined) {
921
1009
  const owns = ownsNothingLine(projectDir, project, whoAmI(deps));
@@ -924,11 +1012,12 @@ function status(args, deps) {
924
1012
  }
925
1013
  deps.print('');
926
1014
  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`);
1015
+ // Every lane together, in the order the lanes were set up — never ranked. Each object
1016
+ // counts once, in the lane that owns it (the view's lane parts), so the rows add up.
1017
+ deps.print(`${'Developer'.padEnd(20)} Objects Active Retired Decided Fixes proven Awaiting check Kept Rebuilds Fix failed Last check`);
929
1018
  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)} `
1019
+ deps.print(`${row.developer.padEnd(20)} ${String(row.objects).padStart(7)} ${String(row.active).padStart(6)} ${String(row.retired).padStart(7)} `
1020
+ + `${String(row.decided).padStart(7)} ${String(row.fixesProven).padStart(12)} ${awaitingWord(row).padEnd(18)} ${String(row.kept).padStart(4)} `
932
1021
  + `${String(row.rebuilds).padStart(8)} ${String(row.fixFailed).padStart(10)} ${lastCheckWord(row)}`);
933
1022
  }
934
1023
  // What each lane does next, in the priority order every surface sorts by. No %.
@@ -945,8 +1034,10 @@ function status(args, deps) {
945
1034
  deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
946
1035
  if (t.overlapping)
947
1036
  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.`);
1037
+ // From the decision, as the page tile and the business sheet count it (the view's count).
1038
+ const candidates = pv.all.retireCandidates;
1039
+ if (candidates)
1040
+ deps.print(`${plural(candidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
950
1041
  if (t.outOfScope)
951
1042
  deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
952
1043
  if (t.conflicts) {
@@ -1843,7 +1934,18 @@ function rule(args, deps) {
1843
1934
  // Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
1844
1935
  if (opts.reason === 'true')
1845
1936
  throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
1846
- const reason = (opts.reason ?? '').trim();
1937
+ ruleObject({ type, name, classification: classification, reason: opts.reason }, deps);
1938
+ return 0;
1939
+ }
1940
+ /**
1941
+ * The body of `team rule` (Task 6): the lead's ruling, written to `rulings/`.
1942
+ * The journey's retire Yes runs it in process — the same record, the same
1943
+ * checks (a retire needs its reason, only the lead rules). Throws a
1944
+ * RegisterError with the plain reason when it cannot be written.
1945
+ */
1946
+ export function ruleObject(r, deps) {
1947
+ const { type, name, classification } = r;
1948
+ const reason = (r.reason ?? '').trim();
1847
1949
  if (reason.length > MAX_REASON)
1848
1950
  throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
1849
1951
  // The lead's retire is what makes an object retired (D2), so it carries what was checked.
@@ -1851,14 +1953,22 @@ function rule(args, deps) {
1851
1953
  throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
1852
1954
  const joined = joinedOrThrow();
1853
1955
  requireLead(readProjectFile(joined.projectDir), deps);
1854
- const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(reason ? { reason } : {}) }], { name: whoAmI(deps) }, {
1956
+ const res = writeRecord(joined, 'ruling',
1957
+ // The lead typed these words (project view §3.2): a person's, said so on the row.
1958
+ [{ key: objectKey(type, name), classification, ...(reason ? { reason, reasonFrom: 'person' } : {}) }], { name: whoAmI(deps) }, {
1855
1959
  dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
1856
1960
  ...(deps.rand ? { rand: deps.rand } : {}),
1857
1961
  });
1858
1962
  if (!res.file)
1859
1963
  throw new RegisterError(`${objectKey(type, name)} is not in this team project's baseline.`);
1860
- deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
1861
- return 0;
1964
+ // The ruling is what the project now holds: its words read back through the view (design §5 "rule lines").
1965
+ const read = readBack(joined.projectDir, joined.outboxDir);
1966
+ const o = read?.view.object(objectKey(type, name));
1967
+ const shownName = `${type.toUpperCase()} ${name.toUpperCase()}`;
1968
+ deps.print(o && read
1969
+ ? `✓ Ruled: ${shownName} — ${read.view.words.decision(o.decision, whoAmI(deps))}. ${sentence(read.view.words.reason(o.decision.reason))}`
1970
+ : `✓ Ruled: ${shownName} — ${classification}.`);
1971
+ return { key: objectKey(type, name) };
1862
1972
  }
1863
1973
  // ── decide ──────────────────────────────────────────────────────────────────
1864
1974
  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>]';
@@ -1922,36 +2032,80 @@ export function parseDecideArgs(args) {
1922
2032
  ok: true,
1923
2033
  value: {
1924
2034
  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,
2035
+ // F3 / M19, Task 6 round 1: only `--target` (on a fix or rebuild) names a
2036
+ // target, and only then does `decide` stamp `targetBy`. Project view §3.2:
2037
+ // without `--target` no `targetLevel` is written (no default A). A kept
2038
+ // level is a level (`levelBy`), never a target.
2039
+ targetNamed: moves && !!target.value,
1929
2040
  row: {
1930
2041
  classification: said.classification,
1931
2042
  ...(said.exception ? { exception: true } : {}),
1932
2043
  ...(level.value ? { level: level.value } : {}),
1933
- // Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
1934
- ...(moves ? { targetLevel: target.value ?? 'A' } : {}),
1935
- ...(reason ? { reason } : {}),
2044
+ ...(moves && target.value ? { targetLevel: target.value } : {}),
2045
+ // The words after --reason are the person's own.
2046
+ ...(reason ? { reason, reasonFrom: 'person' } : {}),
1936
2047
  ...(kind ? { rebuildKind: kind } : {}),
1937
2048
  ...(app ? { app } : {}),
1938
2049
  },
1939
2050
  },
1940
2051
  };
1941
2052
  }
1942
- const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
1943
- function decisionWords(row, targetNamed) {
1944
- const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
2053
+ /** The register and the view of this folder (with this laptop's outbox), read once; null when it cannot be read. */
2054
+ function readBack(projectDir, outboxDir) {
2055
+ try {
2056
+ const { register } = registerFor(projectDir, outboxDir);
2057
+ return { register, view: projectViewFor(register, readProjectFile(projectDir)) };
2058
+ }
2059
+ catch {
2060
+ return null;
2061
+ }
2062
+ }
2063
+ /** A word table's text as a sentence: one full stop at the end. */
2064
+ const sentence = (text) => `${text}${/[.!?]$/.test(text) ? '' : '.'}`;
2065
+ const samePerson = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
2066
+ /**
2067
+ * The `✓ Recorded:` words for the row the person just wrote, in the view's
2068
+ * word tables (project view Task 4, fix round 1): "fix (decided by you),
2069
+ * level C (decided by you), no target set. Why: …" — their classification
2070
+ * and their reason first, whatever the register settles on.
2071
+ */
2072
+ function writtenWords(row, w, me, at) {
2073
+ const mine = { name: me, at };
2074
+ const what = decisionWhatOf({ classification: row.classification, exception: row.exception === true, ruling: false });
2075
+ const parts = [w.decision({ what, from: 'person', by: mine, reason: null }, me)];
1945
2076
  if (row.level)
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.
1948
- if (row.targetLevel)
1949
- parts.push(targetNamed ? `target ${row.targetLevel}` : 'no target set');
2077
+ parts.push(w.level({ now: null, decided: row.level, by: mine }, me));
2078
+ if (what === 'fix' || what === 'rebuild') {
2079
+ parts.push(w.target(row.targetLevel ? { level: row.targetLevel, from: 'person', by: mine } : { level: null, from: 'none', by: null }, me));
2080
+ }
1950
2081
  if (row.rebuildKind)
1951
2082
  parts.push(REBUILD_KIND_WORDS[row.rebuildKind]);
1952
2083
  if (row.app)
1953
2084
  parts.push(`app ${row.app}`);
1954
- return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
2085
+ return `${parts.join(', ')}. ${sentence(w.reason(row.reason ? { text: row.reason, from: 'person' } : null))}`;
2086
+ }
2087
+ /**
2088
+ * When what the project now shows is not the row just written, one line from
2089
+ * the view: a lead's ruling, a conflict (named by person, never by a writer
2090
+ * id), or another writer's row that stands. Null when they agree.
2091
+ * A conflict the lead wrote names the command that settles it (live re-run
2092
+ * L3): the lead is never told "the lead settles it".
2093
+ */
2094
+ function projectShowsLine(o, me, mine, view) {
2095
+ const d = o.decision;
2096
+ const w = view.words;
2097
+ if (d.from === 'ruling')
2098
+ return `The team project shows: ${w.decision(d, me)}.`;
2099
+ if (d.conflict) {
2100
+ const names = [...new Set((d.standing ?? []).filter((x) => x.what !== mine && !samePerson(x.name, me)).map((x) => x.name))];
2101
+ const whose = names.length ? names.map((n) => `${n}'s`).join(' and ') : "another person's";
2102
+ if (samePerson(view.project.lead, me))
2103
+ return `Your decision differs from ${whose}. As the lead, settle it with: cspeach team rule ${o.type} ${o.name} <decision>`;
2104
+ return `Your decision differs from ${whose}; the lead settles it.`;
2105
+ }
2106
+ if (d.by && !samePerson(d.by.name, me))
2107
+ return `The team project shows: ${w.decision(d, me)}.`;
2108
+ return null;
1955
2109
  }
1956
2110
  function decide(args, deps) {
1957
2111
  const parsed = parseDecideArgs(args);
@@ -1964,15 +2118,25 @@ function decide(args, deps) {
1964
2118
  const res = writeRecord(joined, 'decision', [{
1965
2119
  key: objectKey(type, name), ...row,
1966
2120
  ...(targetNamed ? { targetBy: { name: by, at: now.toISOString() } } : {}),
2121
+ // G8: the level the person typed says who decided it; no --level, nothing stamped.
2122
+ ...(row.level ? { levelBy: { name: by, at: now.toISOString() } } : {}),
1967
2123
  }], { name: by }, {
1968
2124
  now, ...(deps.rand ? { rand: deps.rand } : {}),
1969
2125
  });
1970
2126
  if (!res.file)
1971
2127
  throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
1972
- deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row, targetNamed)}.`);
2128
+ deps.print(`✓ Recorded: ${type} ${name} — ${writtenWords(row, viewWords, by, now.toISOString())}`);
2129
+ // Fix round 1: what the project now shows, read once through the view, when it is not this row.
2130
+ const read = readBack(joined.projectDir, joined.outboxDir);
2131
+ const o = read?.view.object(objectKey(type, name));
2132
+ const mine = decisionWhatOf({ classification: row.classification, exception: row.exception === true, ruling: false });
2133
+ const shows = o && read ? projectShowsLine(o, by, mine, read.view) : null;
2134
+ if (shows)
2135
+ deps.print(shows);
1973
2136
  if (row.classification === 'retire')
1974
2137
  deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
1975
- refreshSheet(deps);
2138
+ // The register just read, handed on: the working list does not read the folder again.
2139
+ refreshSheet(deps, read ?? undefined);
1976
2140
  return 0;
1977
2141
  }
1978
2142
  // ── rebuild ─────────────────────────────────────────────────────────────────
@@ -2096,6 +2260,54 @@ function targetLines(project) {
2096
2260
  return targetsOf(project).map((t) => `Target ${t.level}${t.variant ? ` — variant ${t.variant} (stored, not yet used for measuring)` : ''}`
2097
2261
  + `, for ${forWhom(t.packages)}. Set by ${shown(t.by)}.`);
2098
2262
  }
2263
+ // ── transport ───────────────────────────────────────────────────────────────
2264
+ const TRANSPORT_USAGE = 'Usage: cspeach team transport <REQUEST> [--reason "<why>"] | cspeach team transport clear';
2265
+ /**
2266
+ * Task 8 (B11) — the request this project's fixes are recorded in, set or
2267
+ * cleared by the lead (a project started before this release sets it here).
2268
+ * Stored as `transport: { request, by, at }` (and `reason` when one is said);
2269
+ * the whole project file is written, then renamed. The modify/delete tools
2270
+ * write under it when a change names no transport; a request that already
2271
+ * holds the object still wins (the write is forced there).
2272
+ */
2273
+ function transportVerb(args, deps) {
2274
+ const { positional, opts } = flags(args);
2275
+ for (const name of Object.keys(opts)) {
2276
+ if (name !== 'reason')
2277
+ throw new RegisterError(`--${name} is not an option of team transport.
2278
+ ${TRANSPORT_USAGE}`);
2279
+ }
2280
+ const [word] = positional;
2281
+ if (!word || positional.length !== 1)
2282
+ throw new RegisterError(TRANSPORT_USAGE);
2283
+ const clearing = word.toLowerCase() === 'clear';
2284
+ const joined = joinedOrThrow();
2285
+ const project = readProjectFile(joined.projectDir);
2286
+ requireLead(project, deps);
2287
+ if (clearing) {
2288
+ if (!project.transport) {
2289
+ deps.print('Nothing to clear: no transport is set.');
2290
+ return 0;
2291
+ }
2292
+ writeProjectTransport(joined.projectDir, null);
2293
+ deps.print('✓ Transport cleared. No transport yet.');
2294
+ return 0;
2295
+ }
2296
+ const request = word.trim().toUpperCase();
2297
+ if (!TRANSPORT_REQUEST.test(request))
2298
+ throw new RegisterError(notARequestLine(word));
2299
+ let reason;
2300
+ if (opts.reason !== undefined) {
2301
+ reason = Array.from(shownUpTo(opts.reason === 'true' ? '' : opts.reason, Number.POSITIVE_INFINITY)).slice(0, MAX_REASON).join('').trim();
2302
+ if (!reason)
2303
+ throw new RegisterError('--reason needs words, for example: --reason "the clean-core request for Q4"');
2304
+ }
2305
+ writeProjectTransport(joined.projectDir, {
2306
+ request, by: whoAmI(deps), at: (deps.now ?? (() => new Date()))().toISOString(), ...(reason ? { reason } : {}),
2307
+ });
2308
+ deps.print(`✓ Transport: fixes in this team project are recorded in ${request}.`);
2309
+ return 0;
2310
+ }
2099
2311
  // ── fixed ───────────────────────────────────────────────────────────────────
2100
2312
  const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
2101
2313
  const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
@@ -2186,8 +2398,19 @@ export const heldReportLine = (name) => ` ! "${name}" is open in another progra
2186
2398
  * other, and the caller is told by file name which one was not updated.
2187
2399
  * writeWhole has already removed its .tmp when it throws.
2188
2400
  */
2401
+ /**
2402
+ * Design §5 "Register inputs": a report or export of the joined project reads
2403
+ * this laptop's outbox too, as `team status` does, so it never lags this seat.
2404
+ * Another folder (one named on the command line) is read as it is.
2405
+ */
2406
+ function outboxFor(projectDir) {
2407
+ const joined = activeProject();
2408
+ return joined && path.resolve(joined.projectDir) === path.resolve(projectDir) ? joined.outboxDir : undefined;
2409
+ }
2189
2410
  export function writeReports(projectDir, project, outDir, deps) {
2190
- const picture = registerFor(projectDir);
2411
+ const picture = registerFor(projectDir, outboxFor(projectDir));
2412
+ // The project view, built once for both files.
2413
+ const pv = projectViewFor(picture.register, project);
2191
2414
  const asOf = (deps.now?.() ?? new Date()).toISOString();
2192
2415
  const folderName = path.basename(projectDir);
2193
2416
  const names = reportFileNames(project.name);
@@ -2211,9 +2434,9 @@ export function writeReports(projectDir, project, outDir, deps) {
2211
2434
  // "changed since" block starts there. Read BEFORE it is overwritten.
2212
2435
  const previousAsOf = previousAsOfIn(path.join(outDir, names.html));
2213
2436
  const saved = [
2214
- attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName, project))) ? names.xlsx : null,
2437
+ attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName, project, pv))) ? names.xlsx : null,
2215
2438
  attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
2216
- project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
2439
+ project, register: picture.register, files: picture.files, unreadable: picture.unreadable, projectView: pv,
2217
2440
  checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName, previousAsOf,
2218
2441
  ...(discovery ? { estate: discovery.estate } : {}),
2219
2442
  }))) ? names.html : null,
@@ -2240,23 +2463,27 @@ function report(args, deps) {
2240
2463
  deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
2241
2464
  for (const name of held)
2242
2465
  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.');
2466
+ // D2: the CSV export rides along when the project file switched it on.
2467
+ // G5: an export already beside the report is rewritten too, so it never goes stale.
2468
+ const exportsDir = path.join(outDir, EXPORTS_DIR);
2469
+ const refresh = !exportsOn(project) && !exportsOff(project) && exportsPresent(exportsDir);
2470
+ if (exportsOn(project) || refresh) {
2471
+ const out = writeExports(joined.projectDir, project, exportsDir, deps);
2472
+ if (out.saved.length) {
2473
+ deps.print(refresh ? '✓ Updated the CSV export in the exports folder beside the report.' : '✓ Saved the CSV export in the exports folder beside the report.');
2474
+ }
2248
2475
  for (const name of out.held)
2249
2476
  deps.print(heldExportLine(name));
2250
2477
  held.push(...out.held);
2251
2478
  }
2252
- // Records on this laptop that have not reached the folder are not in the report.
2479
+ // Records on this laptop that have not reached the folder ARE in the report (it reads the
2480
+ // outbox, as team status does), and the team page does not have them yet: said, as status says it.
2253
2481
  const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
2254
2482
  const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
2255
2483
  if (path.resolve(ownDir) !== path.resolve(inFolder) && fs.existsSync(ownDir)) {
2256
2484
  const missing = fs.readdirSync(ownDir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(inFolder, f))).length;
2257
2485
  if (missing) {
2258
- deps.print(` ${plural(missing, 'record file on this laptop is', 'record files on this laptop are')} not in the team project folder yet, `
2259
- + `so ${missing === 1 ? 'it is' : 'they are'} not in this report.`);
2486
+ deps.print(` Includes ${missing} record file${missing === 1 ? '' : 's'} from this laptop that ${missing === 1 ? 'has' : 'have'} not been handed in yet.`);
2260
2487
  }
2261
2488
  }
2262
2489
  return held.length ? 1 : 0;
@@ -2266,7 +2493,7 @@ const EXPORT_USAGE = 'Usage: cspeach team export [--out <folder>] [--always | --
2266
2493
  const heldExportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team export`;
2267
2494
  /** The CSV export of the team project folder, as the team page reads it, into `dir`. */
2268
2495
  export function writeExports(projectDir, project, dir, deps) {
2269
- const { register } = registerFor(projectDir);
2496
+ const { register } = registerFor(projectDir, outboxFor(projectDir));
2270
2497
  return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
2271
2498
  }
2272
2499
  /**