@cspeach/cli 1.1.30 → 1.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Guided clean-core journey, Task 2 — run one stage
3
+ * (design .superpowers/sdd/guided-journey-design.md §1, §2, §4).
4
+ *
5
+ * The code stages run the `team` command bodies in process, their output kept
6
+ * back, and say a few plain lines of their own. The model stages (DECIDE, FIX)
7
+ * are not run here: the runner builds the slash command from the register and
8
+ * hands it back for the loop to queue. Nothing here touches the REPL or the
9
+ * loop, nothing throws, and every path returns lines plus the next action.
10
+ *
11
+ * Solo words only on screen: never "team", "lane", "outbox" or "join" — except
12
+ * inside a slash command the journey queues or names.
13
+ */
14
+ import path from 'node:path';
15
+ import { estateSummaryOf, provenForTargetWords, usageLineWithWindow } from '@cspeach/register-core';
16
+ import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, ownerMatch, runTeamCommand, severalLine, whoAmI, writeExports, writeReports } from '../commands/team.js';
17
+ import { EXPORTS_DIR, exportsOn, setExportsAlways } from '../register/export-files.js';
18
+ import { shown, shownUpTo } from '../register/atc-file.js';
19
+ import { activeProject, readEstate, readLanes, readProjectFile, VARIANT_NAME } from '../register/store.js';
20
+ import { registerFor } from '../register/picture.js';
21
+ import { claimedSinceCheck, fixFailedCheck, myObjects, provenForTarget, readJourneyFacts, stepOf, toDecide, toFix, toProve, toRedesign, unreadableReason, STEPS, } from './clean-core.js';
22
+ /* ── words ──────────────────────────────────────────────────────────────── */
23
+ export const UNREADABLE_LINES = {
24
+ 'object-list': 'The project\'s object list in this folder can\'t be read right now — it may still be syncing. Try again in a minute.',
25
+ 'project-file': 'This folder\'s project file can\'t be read.',
26
+ other: 'This folder can\'t be read right now.',
27
+ };
28
+ const step = (stage, title) => `Step ${stepOf(stage)} of ${STEPS} — ${title}`;
29
+ const plural = (count, one, many) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
30
+ const minutes = (objects) => {
31
+ // About 25 objects a minute (live measurement 2026-10-07), never under one minute.
32
+ const m = Math.max(1, Math.ceil(objects / 25));
33
+ return `About ${plural(m, 'minute', 'minutes')}.`;
34
+ };
35
+ /** Words a solo person never reads, and anything that looks like a path or a command to type. */
36
+ const NOT_SOLO = /\b(team|lanes?|outbox(es)?|join(s|ed|ing)?|writers?|variant|projectId|claim(s|ed|ing)?)\b/i;
37
+ // Unix paths bare, quoted or bracketed ('/Users/x/y.json'), and home paths (~/x).
38
+ const LOOKS_LIKE_PATH = /[A-Za-z]:\\|\\\\|(^|[\s"'`(])\/[^\s"'`)]*\/|~\/|cspeach\s/i;
39
+ export const NO_SAP_LINE = 'No SAP system is connected in this session.';
40
+ const NO_ATC_LINE = "SAP's code checks can't be run from this session.";
41
+ const ALREADY_LINE = 'This folder already holds a project. Say carry on to work in it.';
42
+ /**
43
+ * The failures CSPeach knows, each in its own plain words. "Cannot measure"
44
+ * comes first: a session without SAP is said before any step runs, so here it
45
+ * means the connection offers no ATC.
46
+ */
47
+ const KNOWN = [
48
+ [/cannot measure/i, NO_ATC_LINE],
49
+ [/No SAP system is configured|needs a SAP connection/i, NO_SAP_LINE],
50
+ [/already holds a team project/i, ALREADY_LINE],
51
+ ];
52
+ /**
53
+ * What to say when a team body failed: a known failure in its own words;
54
+ * else its first error, when that reads as a plain sentence; else `fallback`.
55
+ * Never a path, never a word a solo person does not use.
56
+ */
57
+ export function failureLine(said, fallback) {
58
+ for (const line of said)
59
+ for (const [re, words] of KNOWN)
60
+ if (re.test(line))
61
+ return words;
62
+ const first = said.find((l) => l.startsWith('✗'))?.replace(/^✗\s*/, '').trim();
63
+ if (!first || NOT_SOLO.test(first) || LOOKS_LIKE_PATH.test(first))
64
+ return fallback;
65
+ return shown(first);
66
+ }
67
+ const failure = failureLine;
68
+ /**
69
+ * A decision record that could not be written, inside a journey: "Not
70
+ * recorded: <plain reason>." The reason as it was said when it reads as a
71
+ * plain sentence; never a path, a team word or a command.
72
+ */
73
+ export function notRecordedLine(error) {
74
+ const said = error instanceof Error ? error.message : String(error);
75
+ const plain = failureLine([`✗ ${said}`], 'the record could not be written to this folder').replace(/[\s.…]+$/, '');
76
+ return `Not recorded: ${plain}.`;
77
+ }
78
+ /** An object name to show: as it is when it is an ABAP name, cleaned otherwise. */
79
+ export const objectShown = (name) => (OBJECT_NAME.test(name.toUpperCase()) ? name.toUpperCase() : shown(name));
80
+ /** An object marked fix whose name CSPeach never puts in a command: one plain line, and it is passed over. */
81
+ export const passedOverLine = (name) => `${objectShown(name)}: the name is not an ABAP name, so CSPeach passed it over.`;
82
+ /** F16: the check a project measures with, said as a line. */
83
+ export const checkedWithLine = (variant) => ` Checked with: ${variant}.`;
84
+ /* ── commands the journey queues ────────────────────────────────────────── */
85
+ export const OBJECT_TYPE = /^[A-Z]{4}$/;
86
+ /** An ABAP object name: letters, digits, underscores, an optional /NAMESPACE/ in front. */
87
+ export const OBJECT_NAME = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
88
+ const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
89
+ export const MAX_REASON = 300;
90
+ const chars = (...codes) => String.fromCharCode(...codes);
91
+ /** Straight, back and curly quotes. */
92
+ const QUOTES = new RegExp(`["'${chars(0x60, 0x2018, 0x2019, 0x201c, 0x201d)}]`, 'g');
93
+ /** Bidi isolates and the Arabic letter mark (shown() already drops the other bidi and zero-width characters). */
94
+ const ISOLATES = new RegExp(`[${chars(0x61c, 0x2066, 0x2067, 0x2068, 0x2069)}]`, 'g');
95
+ /** Dashes and the minus: the command holds exactly one " — ", the one before the reason. */
96
+ const DASHES = new RegExp(`[${chars(0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015, 0x2212)}]`, 'g');
97
+ /** A word that would be read as an image to attach (the turn attaches typed image paths). */
98
+ const IMAGE_WORD = /\.(png|jpe?g|gif|webp)\b/gi;
99
+ /**
100
+ * A recorded reason as the person wrote it, for a card: escape sequences,
101
+ * control, zero-width and bidi characters gone, at most `max` characters.
102
+ * Not the command text — that is cleanReason().
103
+ */
104
+ export function shownReason(reason, max) {
105
+ return shownUpTo(String(reason ?? '').replace(ISOLATES, ''), max);
106
+ }
107
+ /**
108
+ * A reason as one plain line for the queued command: escape sequences,
109
+ * control, zero-width and bidi characters gone (shown()); no quotes; no
110
+ * dashes but a plain hyphen; and nothing the turn would act on — no `@`
111
+ * (a file to attach), no `--` (a flag such as --from), no `[ ]` (an image
112
+ * chip), no image file name. At most 300 characters.
113
+ */
114
+ export function cleanReason(reason) {
115
+ return shownUpTo(String(reason ?? '').replace(ISOLATES, ''), Number.MAX_SAFE_INTEGER)
116
+ .replace(QUOTES, '')
117
+ .replace(DASHES, '-')
118
+ .replace(/-{2,}/g, '-')
119
+ .replace(/@/g, ' ')
120
+ .replace(/\[/g, '(')
121
+ .replace(/\]/g, ')')
122
+ .replace(IMAGE_WORD, ' $1')
123
+ .replace(/\s+/g, ' ')
124
+ .trim()
125
+ .slice(0, MAX_REASON)
126
+ .trim();
127
+ }
128
+ /** `/abap-clean-core triage lane <id>`, or null when the id is not one CSPeach passes on. */
129
+ export function triageCommand(laneId) {
130
+ return LANE_ID.test(laneId) ? `/abap-clean-core triage lane ${laneId}` : null;
131
+ }
132
+ /** `/abap-refactor <TYPE> <NAME> — <reason>`, names upper-cased; null when a name is not an ABAP name. */
133
+ export function refactorCommand(o) {
134
+ const type = o.type.trim().toUpperCase();
135
+ const name = o.name.trim().toUpperCase();
136
+ if (!OBJECT_TYPE.test(type) || !OBJECT_NAME.test(name))
137
+ return null;
138
+ const reason = cleanReason(o.reason);
139
+ return `/abap-refactor ${type} ${name}${reason ? ` — ${reason}` : ''}`;
140
+ }
141
+ /* ── the runner ─────────────────────────────────────────────────────────── */
142
+ const result = (stage, lines, next, skip) => ({ stage, lines, next, ...(skip ? { skip } : {}) });
143
+ /** A package name typed in full: no pattern, nothing that is not a package name. */
144
+ const PACKAGE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30}$/;
145
+ const IS_PATTERN = /[*?%]/;
146
+ /**
147
+ * Run one stage. Never throws: anything unexpected comes back as one plain
148
+ * line and a stop. Headless runs nothing and queues nothing; it says the next step.
149
+ */
150
+ export async function runJourneyStage(stage, ctx) {
151
+ try {
152
+ if (ctx.headless)
153
+ return nextStepOnly(stage, ctx);
154
+ // Said before any card: a step that needs SAP, in a session without one.
155
+ if (ctx.team.deps.noSap && (stage === 'INVENTORY' || stage === 'START' || stage === 'PROVE'))
156
+ return result(stage, [NO_SAP_LINE], { kind: 'stop' });
157
+ switch (stage) {
158
+ case 'UNREADABLE': return result(stage, [UNREADABLE_LINES[unreadableReason(facts(ctx)) ?? 'other']], { kind: 'stop' });
159
+ case 'INVENTORY': return await inventory(ctx);
160
+ case 'START': return await start(ctx);
161
+ case 'JOIN': return await joinStage(ctx);
162
+ case 'UNOWNED': return unowned(ctx);
163
+ case 'DECIDE': return decideStage(ctx);
164
+ case 'FIX': return fixStage(ctx);
165
+ case 'PROVE': return await prove(ctx);
166
+ case 'REPORT': return await report(ctx);
167
+ case 'DONE': return done(ctx);
168
+ }
169
+ }
170
+ catch (e) {
171
+ if (process.env['CSPEACH_DEBUG'] === '1')
172
+ throw e;
173
+ const said = [`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`];
174
+ return result(stage, [failure(said, 'This step could not finish.')], { kind: 'stop' });
175
+ }
176
+ }
177
+ const me = (ctx) => whoAmI(ctx.team.deps);
178
+ const facts = (ctx) => readJourneyFacts(ctx.cwd, me(ctx), ctx.skips);
179
+ /** Run a team body with its output kept back; `said` holds only this run's lines. */
180
+ async function quiet(ctx, args) {
181
+ const { said } = ctx.team;
182
+ said.length = 0;
183
+ const code = await runTeamCommand(args, ctx.team.deps);
184
+ return { code, said: [...said] };
185
+ }
186
+ /** Headless / one-shot: the next step in words, nothing run, nothing queued. */
187
+ function nextStepOnly(stage, ctx) {
188
+ const stop = (line) => result(stage, [line], { kind: 'stop' });
189
+ switch (stage) {
190
+ case 'UNREADABLE': return stop(UNREADABLE_LINES[unreadableReason(facts(ctx)) ?? 'other']);
191
+ case 'INVENTORY': return stop(`Next: ${step(stage, 'look at what custom code is on the system.')}`);
192
+ case 'START': {
193
+ const named = (ctx.packages ?? []).map((p) => p.trim().toUpperCase()).filter((p) => PACKAGE.test(p));
194
+ // F16: headless shows no card; the check it would start with is said as a line.
195
+ const objects = inventoryObjects(ctx.cwd, named);
196
+ return result(stage, [
197
+ `Next: ${step(stage, `start your project${named.length ? ` on ${named.join(', ')}` : ''} in this folder.`)}`,
198
+ checkedWithLine(DEFAULT_ATC_VARIANT),
199
+ // I15: above 500 objects the first check is per package, said as a line.
200
+ ...(objects !== null && objects > FIRST_CHECK_WHOLE_MAX ? [FIRST_CHECK_PER_PACKAGE_LINE] : []),
201
+ ], { kind: 'stop' });
202
+ }
203
+ case 'JOIN': return stop(`Next: ${step(stage, 'pick up the project in this folder.')}`);
204
+ case 'UNOWNED': return unowned(ctx);
205
+ case 'DECIDE':
206
+ case 'FIX': {
207
+ if (needsFirstCheck(facts(ctx)))
208
+ return nothingMeasuredResult(stage);
209
+ const r = stage === 'DECIDE' ? decideStage(ctx) : fixStage(ctx);
210
+ // Names passed over are still said; the step line gives way to the command.
211
+ return r.next.kind === 'queue'
212
+ ? result(stage, [...r.lines.filter((l) => !l.startsWith('Step ') && !l.startsWith(' ')), `Next: ${r.next.command}`], { kind: 'stop' }, r.skip)
213
+ : r;
214
+ }
215
+ case 'PROVE': return stop(`Next: ${step(stage, 'let SAP check your fixes.')}`);
216
+ case 'REPORT': return nothingMeasured(facts(ctx)) ? nothingMeasuredResult(stage) : stop(`Next: ${step(stage, 'make the status page.')}`);
217
+ case 'DONE': return done(ctx);
218
+ }
219
+ }
220
+ /* INVENTORY — what is out there. */
221
+ async function inventory(ctx) {
222
+ const { code, said } = await quiet(ctx, ['discover', '--out', ctx.cwd]);
223
+ const found = code === 0 ? readEstate(ctx.cwd) : null;
224
+ const s = found ? estateSummaryOf(found.estate) : null;
225
+ if (!s)
226
+ return result('INVENTORY', [failure(said, 'The system could not be read.')], { kind: 'stop' });
227
+ const t = s.totals;
228
+ const words = [
229
+ `${plural(t.packages, 'package', 'packages')} with ${plural(t.codeObjects, 'code object', 'code objects')}.`,
230
+ ...(t.modifications ? [`${plural(t.modifications, 'change', 'changes')} to SAP's own objects.`] : []),
231
+ usageLineWithWindow(s),
232
+ ];
233
+ return result('INVENTORY', [`${step('INVENTORY', 'what is out there.')} ${words.join(' ')}`], { kind: 'continue' });
234
+ }
235
+ /* START — the project on the named packages, in this folder. */
236
+ async function start(ctx) {
237
+ const stop = (line) => result('START', [line], { kind: 'stop' });
238
+ const asked = (ctx.packages ?? []).map((p) => p.trim().toUpperCase()).filter(Boolean);
239
+ if (!asked.length)
240
+ return stop('Which package first? Say: start clean core on ZFI_AR.');
241
+ if (asked.some((p) => IS_PATTERN.test(p)))
242
+ return stop('Name each package in full, for example ZFI_AR — not a pattern.');
243
+ if (asked.some((p) => !PACKAGE.test(p)))
244
+ return stop('Name each package in full, for example ZFI_AR.');
245
+ const packages = [...new Set(asked)];
246
+ // Checked against the system's own package list when the folder holds one.
247
+ const found = readEstate(ctx.cwd);
248
+ const known = found ? estateSummaryOf(found.estate)?.packages.map((p) => p.name.toUpperCase()) : undefined;
249
+ const unknown = known ? packages.filter((p) => !known.includes(p)) : [];
250
+ if (unknown.length)
251
+ return stop(`CSPeach did not find ${unknown.length === 1 ? 'the package' : 'the packages'} ${unknown.join(', ')} on the system.`);
252
+ const asWith = ctx.variant && VARIANT_NAME.test(ctx.variant) ? ['--atc-variant', ctx.variant] : [];
253
+ // I15: above 500 objects the start measures nothing; the chain offers the per-package first check.
254
+ const big = isBigStart(ctx.cwd, packages);
255
+ const { code, said } = await quiet(ctx, ['start', ...packages, '--folder', ctx.cwd, ...asWith, ...(big ? ['--no-measure'] : [])]);
256
+ if (code !== 0)
257
+ return stop(failure(said, 'The project could not be started.'));
258
+ let name = packages.join(', ');
259
+ let variant = asWith[1] ?? DEFAULT_ATC_VARIANT;
260
+ try {
261
+ const project = readProjectFile(ctx.cwd);
262
+ name = shown(project.name);
263
+ if (project.atcVariant && VARIANT_NAME.test(project.atcVariant))
264
+ variant = project.atcVariant;
265
+ }
266
+ catch { /* the name and the check it was started with */ }
267
+ const objects = said.map((l) => /^\s*([\d,]+) objects?, matched against/.exec(l)?.[1]).find(Boolean);
268
+ const check = said.map((l) => /First check \([^)]*\): ([\d,]+) of ([\d,]+) objects? with findings/.exec(l)).find(Boolean);
269
+ const lines = [`✓ Your project on ${name} started here.`, checkedWithLine(variant)];
270
+ if (objects)
271
+ lines.push(` ${objects} ${objects === '1' ? 'object' : 'objects'}, matched against the system's count.`);
272
+ lines.push(check ? ` First check: ${check[1]} of ${check[2]} objects with findings.` : ' No check yet.');
273
+ // Review T7 finding 2: init decides "big" from the live pull, which may differ from the inventory.
274
+ const perPackage = !check && said.includes(FIRST_CHECK_PER_PACKAGE_LINE);
275
+ return { ...result('START', lines, { kind: 'continue' }), ...(perPackage ? { firstCheckPerPackage: true } : {}) };
276
+ }
277
+ /** Code objects the folder's inventory holds for these packages; null when it does not know them all. */
278
+ function inventoryObjects(cwd, packages) {
279
+ const found = readEstate(cwd);
280
+ const s = found ? estateSummaryOf(found.estate) : null;
281
+ if (!s || !packages.length)
282
+ return null;
283
+ const by = new Map(s.packages.map((p) => [p.name.toUpperCase(), p.codeObjects]));
284
+ if (packages.some((p) => !by.has(p.toUpperCase())))
285
+ return null;
286
+ return packages.reduce((n, p) => n + (by.get(p.toUpperCase()) ?? 0), 0);
287
+ }
288
+ /** A start the 30-minute whole-baseline check would cut (I15): more than 500 objects, known from the inventory. */
289
+ export function isBigStart(cwd, packages) {
290
+ const n = inventoryObjects(cwd, packages.map((p) => p.trim().toUpperCase()).filter(Boolean));
291
+ return n !== null && n > FIRST_CHECK_WHOLE_MAX;
292
+ }
293
+ /**
294
+ * The first check, one package at a time (I15): `team refresh --all
295
+ * --per-package`, which resumes where it stopped. Never throws.
296
+ */
297
+ export async function firstCheckPerPackage(ctx) {
298
+ try {
299
+ const { code, said } = await quiet(ctx, ['refresh', '--all', '--per-package']);
300
+ const done = said.find((l) => /^✓ [\d,]+ packages? measured, one record each\./.test(l));
301
+ if (code === 0 && done)
302
+ return result('START', [` First check: ${done.replace(/^✓ /, '')}`], { kind: 'continue' });
303
+ const stopped = said.find((l) => /^✗ (Stopped|Lost the connection)/.test(l));
304
+ return result('START', [stopped ? ` ${stopped.replace(/^✗ /, '')}` : failure(said, 'The first check could not finish.'), FIRST_CHECK_PER_PACKAGE_LINE], { kind: 'stop' });
305
+ }
306
+ catch (e) {
307
+ if (process.env['CSPEACH_DEBUG'] === '1')
308
+ throw e;
309
+ return result('START', ['The first check could not finish.', FIRST_CHECK_PER_PACKAGE_LINE], { kind: 'stop' });
310
+ }
311
+ }
312
+ /* JOIN — a project in this folder this laptop does not record into. */
313
+ async function joinStage(ctx) {
314
+ const f = facts(ctx);
315
+ const folder = f.folder;
316
+ // Out of a synced file someone else may have written: cleaned before it is shown.
317
+ const lead = 'lead' in folder ? shown(folder.lead) : '';
318
+ const name = 'name' in folder ? shown(folder.name) : '';
319
+ if (folder.kind === 'unreadable')
320
+ return result('JOIN', [UNREADABLE_LINES[folder.reason]], { kind: 'stop' });
321
+ if (folder.kind !== 'mine-not-joined' && folder.kind !== 'someone-else')
322
+ return result('JOIN', [], { kind: 'continue' });
323
+ if (folder.kind === 'someone-else') {
324
+ const res = await ctx.team.confirm({
325
+ id: 'journey-join',
326
+ tag: 'QUESTION',
327
+ title: `This folder holds ${lead}'s project ${name}. Work in it?`,
328
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
329
+ initial: 0,
330
+ aliases: { y: 'yes', n: 'no' },
331
+ });
332
+ if (res.cancelled || res.value !== 'yes')
333
+ return result('JOIN', ['Nothing changed in this folder.'], { kind: 'stop' });
334
+ }
335
+ // Step 2 (spec §3.6, F13, cut 5): a lane owner who shares my first name is
336
+ // asked about once; two of them are never guessed between.
337
+ let asName;
338
+ if (folder.kind === 'someone-else') {
339
+ const match = ownerByFirstName(ctx.cwd, me(ctx));
340
+ if (match.kind === 'several') {
341
+ return result('JOIN', [severalLine(match)], { kind: 'stop' });
342
+ }
343
+ if (match.kind === 'one') {
344
+ const res = await ctx.team.confirm({
345
+ id: 'journey-join-name',
346
+ tag: 'QUESTION',
347
+ title: `Are you ${match.owner}?`,
348
+ facts: [match.lane],
349
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
350
+ initial: 0,
351
+ aliases: { y: 'yes', n: 'no' },
352
+ });
353
+ if (res.cancelled || res.value === null)
354
+ return result('JOIN', ['Nothing changed in this folder.'], { kind: 'stop' });
355
+ if (res.value === 'yes')
356
+ asName = match.owner;
357
+ }
358
+ }
359
+ // Results go straight into the folder.
360
+ const { code, said } = await quiet(ctx, ['join', ctx.cwd, '--outbox', ctx.cwd, ...(asName ? ['--as', asName] : [])]);
361
+ if (code !== 0)
362
+ return result('JOIN', [failure(said, 'The project in this folder could not be opened.')], { kind: 'stop' });
363
+ const line = folder.kind === 'mine-not-joined'
364
+ ? `Picking up your project on ${name}.`
365
+ : `✓ Working in ${lead}'s project ${name}${asName ? ` as ${asName}` : ''}.`;
366
+ return result('JOIN', [line], { kind: 'continue' });
367
+ }
368
+ /**
369
+ * The lane owner I may be, decided by team.ts `ownerMatch` (the one shared
370
+ * rule), with that person's lanes' size for the card.
371
+ */
372
+ function ownerByFirstName(cwd, myName) {
373
+ let lanes = [];
374
+ try {
375
+ lanes = readLanes(cwd);
376
+ }
377
+ catch {
378
+ return { kind: 'none' };
379
+ }
380
+ const m = ownerMatch(lanes, myName);
381
+ if (m.kind === 'several')
382
+ return m;
383
+ if (m.kind !== 'one')
384
+ return { kind: 'none' };
385
+ const ids = new Set(m.laneIds);
386
+ const packages = new Set(lanes.filter((l) => ids.has(l.id)).flatMap((l) => l.packages)).size;
387
+ let objects = null;
388
+ try {
389
+ objects = registerFor(cwd).register.lanes.filter((l) => ids.has(l.id)).reduce((n, l) => n + l.objects, 0);
390
+ }
391
+ catch {
392
+ objects = null;
393
+ }
394
+ const size = `${plural(packages, 'package', 'packages')}${objects === null ? '' : `, ${plural(objects, 'object', 'objects')}`}`;
395
+ return { kind: 'one', owner: m.owner, lane: `Your lane: ${size}.` };
396
+ }
397
+ /* UNOWNED — joined, and nothing here is mine yet (spec §3.6). Never DONE. */
398
+ function unowned(ctx) {
399
+ let lead = '';
400
+ try {
401
+ lead = readProjectFile(ctx.cwd).lead.name;
402
+ }
403
+ catch { /* the line without a name */ }
404
+ return result('UNOWNED', [`You own nothing here yet. Ask ${lead ? shown(lead) : 'the lead'} to move packages to you.`], { kind: 'stop' });
405
+ }
406
+ /* DECIDE — the triage of my objects, a model step. */
407
+ function decideStage(ctx) {
408
+ const f = facts(ctx);
409
+ const left = f.register ? toDecide(f.register, f.me, f.skip) : [];
410
+ const lane = left[0]?.lane;
411
+ const command = lane ? triageCommand(lane) : null;
412
+ if (!command)
413
+ return result('DECIDE', ['Nothing to decide that CSPeach can hand on.'], { kind: 'stop' });
414
+ return result('DECIDE', [`${step('DECIDE', 'decide what happens to each object.')} ${plural(left.length, 'object', 'objects')} to go.`], { kind: 'queue', command });
415
+ }
416
+ /* FIX — the next object to fix, a model step. */
417
+ function fixStage(ctx) {
418
+ const f = facts(ctx);
419
+ const left = f.register ? toFix(f.register, f.me, f.skip) : [];
420
+ // A name that is not an ABAP name is passed over with one line; the next one is queued.
421
+ const passed = [];
422
+ for (const o of left) {
423
+ // The reason comes from the recorded decision only, never from the model's words.
424
+ const command = refactorCommand({ type: o.type, name: o.name, ...(o.decision?.reason ? { reason: o.decision.reason } : {}) });
425
+ if (!command) {
426
+ passed.push(o);
427
+ continue;
428
+ }
429
+ const lines = [...passed.map((x) => passedOverLine(x.name)), `${step('FIX', `fix ${o.name.toUpperCase()}.`)}`];
430
+ if (fixFailedCheck(o))
431
+ lines.push(' Its last fix failed the check.');
432
+ return result('FIX', lines, { kind: 'queue', command, key: o.key }, passed.length ? { fix: new Set(passed.map((x) => x.key)) } : undefined);
433
+ }
434
+ return result('FIX', passed.map((x) => passedOverLine(x.name)), { kind: 'continue' }, passed.length ? { fix: new Set(passed.map((x) => x.key)) } : undefined);
435
+ }
436
+ /** What a check of my claimed fixes measures: my one area, or the packages of the claimed objects. */
437
+ function proveScope(f, waiting) {
438
+ const r = f.register;
439
+ const joined = activeProject();
440
+ const lanes = joined ? readLanes(joined.projectDir).filter((l) => l.owner.trim().toLowerCase() === f.me.trim().toLowerCase()) : [];
441
+ if (lanes.length === 1)
442
+ return { args: ['refresh'], objects: myObjects(r, f.me).length };
443
+ const packages = [...new Set(waiting.map((o) => o.package))];
444
+ return {
445
+ args: ['refresh', '--packages', packages.join(',')],
446
+ objects: r.objects.filter((o) => o.inScope && packages.includes(o.package)).length,
447
+ };
448
+ }
449
+ /**
450
+ * What a check that ran says about one object's fix. A fix the check says did
451
+ * not hold, with nothing open for its own target, is proven FOR THAT TARGET
452
+ * (spec §3.2, proof first) — "would only matter for A" only when every finding
453
+ * left is one that only level A cares about.
454
+ */
455
+ export function proveLine(name, now) {
456
+ if (now.state === 'verified')
457
+ return `${name}: fix proven.`;
458
+ // F12: an object clean at its check never had a fix to prove.
459
+ if (now.state === 'clean')
460
+ return `${name}: no findings.`;
461
+ if (provenForTarget(now))
462
+ return `${name}: ${provenForTargetWords(now)}.`;
463
+ if (fixFailedCheck(now) && !claimedSinceCheck(now))
464
+ return `${name}: fix failed the check.`;
465
+ return `${name}: not checked yet.`;
466
+ }
467
+ /* PROVE — SAP checks the claimed fixes. */
468
+ async function prove(ctx) {
469
+ const f = facts(ctx);
470
+ if (!f.register)
471
+ return result('PROVE', [UNREADABLE_LINES[unreadableReason(f) ?? 'other']], { kind: 'stop' });
472
+ const waiting = toProve(f.register, f.me);
473
+ if (!waiting.length)
474
+ return result('PROVE', [], { kind: 'continue' });
475
+ const scope = proveScope(f, waiting);
476
+ const res = await ctx.team.confirm({
477
+ id: 'journey-prove',
478
+ tag: 'QUESTION',
479
+ title: `Let SAP check your ${plural(scope.objects, 'object', 'objects')}? ${minutes(scope.objects)}`,
480
+ facts: ['Reads only. Nothing in SAP is changed.'],
481
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'later', label: 'Later' }],
482
+ initial: 0,
483
+ aliases: { y: 'yes', l: 'later' },
484
+ });
485
+ if (res.value === 'later' && !res.cancelled)
486
+ return result('PROVE', ['Left for later.'], { kind: 'continue' }, { prove: true });
487
+ if (res.cancelled || res.value !== 'yes')
488
+ return result('PROVE', ['Not checked.'], { kind: 'stop' });
489
+ const { said } = await quiet(ctx, scope.args);
490
+ const measured = said.map((l) => /^✓ ([\d,]+) objects? measured/.exec(l)?.[1]).find(Boolean);
491
+ const after = readJourneyFacts(ctx.cwd, f.me, ctx.skips).register;
492
+ const byKey = new Map((after?.objects ?? []).map((o) => [o.key, o]));
493
+ const lines = [];
494
+ for (const was of waiting) {
495
+ const now = byKey.get(was.key);
496
+ const name = objectShown(was.name);
497
+ if (!now || !now.lastMeasuredAt || now.lastMeasuredAt === was.lastMeasuredAt)
498
+ lines.push(`${name}: not checked yet.`);
499
+ else
500
+ lines.push(proveLine(name, now));
501
+ }
502
+ if (!measured && lines.every((l) => l.endsWith('not checked yet.'))) {
503
+ return result('PROVE', [failure(said, 'SAP could not check them right now.')], { kind: 'stop' });
504
+ }
505
+ return result('PROVE', [`✓ ${measured ?? waiting.length} checked.`, ...lines], { kind: 'continue' });
506
+ }
507
+ /* REPORT — the status page and the workbook, in reports/. */
508
+ async function report(ctx) {
509
+ // A status page with nothing measured on it is no report (review T7 finding 1).
510
+ if (nothingMeasured(facts(ctx)))
511
+ return nothingMeasuredResult('REPORT');
512
+ const joined = activeProject();
513
+ if (!joined)
514
+ return result('REPORT', [UNREADABLE_LINES.other], { kind: 'stop' });
515
+ const project = readProjectFile(joined.projectDir);
516
+ const mine = project.lead.name.trim().toLowerCase() === me(ctx).trim().toLowerCase();
517
+ // The status page of a shared folder is its lead's to make.
518
+ if (!mine)
519
+ return result('REPORT', [`The status page is made by ${shown(project.lead.name)}.`], { kind: 'continue' }, { report: true });
520
+ const { saved, held } = writeReports(joined.projectDir, project, path.join(joined.projectDir, 'reports'), ctx.team.deps);
521
+ const lines = [];
522
+ if (saved.length)
523
+ lines.push(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in the reports folder.`);
524
+ for (const name of held)
525
+ lines.push(` "${name}" is open in another program, so it was not updated. Close it and say carry on.`);
526
+ const page = saved.find((n) => n.toLowerCase().endsWith('.html'));
527
+ if (page) {
528
+ const res = await ctx.team.confirm({
529
+ id: 'journey-open-report',
530
+ tag: 'QUESTION',
531
+ title: 'Open the status page in your browser?',
532
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
533
+ initial: 0,
534
+ aliases: { y: 'yes', n: 'no' },
535
+ });
536
+ if (res.value === 'yes' && !res.cancelled) {
537
+ try {
538
+ const said = await ctx.openFile?.(path.join(joined.projectDir, 'reports', page));
539
+ if (Array.isArray(said))
540
+ lines.push(...said);
541
+ }
542
+ catch {
543
+ lines.push(' Could not open the browser — open the page from the reports folder.');
544
+ }
545
+ }
546
+ }
547
+ // The report is saved whatever happens to the CSV files: a failure here is one line, never the stage.
548
+ try {
549
+ lines.push(...await csvExport(ctx, joined.projectDir, project));
550
+ }
551
+ catch (e) {
552
+ const said = e instanceof Error ? e.message : String(e);
553
+ lines.push(` The CSV files could not be written: ${failureLine([`✗ ${said.split('\n')[0]}`], 'the folder could not be written to').replace(/[\s.…]+$/, '')}.`);
554
+ }
555
+ return result('REPORT', lines, { kind: 'continue' }, { report: true });
556
+ }
557
+ const CSV_SAVED = '✓ Saved the CSV files for your own tools in the exports folder.';
558
+ /**
559
+ * The CSV export at REPORT (spec §3.4, D2): written when the project file's
560
+ * switch is on; otherwise one card. "Every time" turns the switch on (visible
561
+ * in the project file), "Just this once" writes them, "No" or a card put away
562
+ * writes nothing. A headless chain never reaches REPORT (it stops at the
563
+ * next-step line before it), so there is no card and no file there.
564
+ */
565
+ async function csvExport(ctx, projectDir, project) {
566
+ const write = () => {
567
+ const { saved, held } = writeExports(projectDir, project, path.join(projectDir, 'reports', EXPORTS_DIR), ctx.team.deps);
568
+ return [
569
+ ...(saved.length ? [CSV_SAVED] : []),
570
+ ...held.map((name) => ` "${name}" is open in another program, so it was not updated. Close it and say carry on.`),
571
+ ];
572
+ };
573
+ if (exportsOn(project))
574
+ return write();
575
+ const res = await ctx.team.confirm({
576
+ id: 'journey-export',
577
+ tag: 'QUESTION',
578
+ title: 'Also write CSV files for your own tools?',
579
+ choices: [{ value: 'every', label: 'Every time' }, { value: 'once', label: 'Just this once' }, { value: 'no', label: 'No' }],
580
+ initial: 2,
581
+ aliases: { e: 'every', j: 'once', n: 'no' },
582
+ });
583
+ if (res.cancelled || (res.value !== 'every' && res.value !== 'once'))
584
+ return [];
585
+ if (res.value === 'once')
586
+ return write();
587
+ setExportsAlways(projectDir);
588
+ return [...write(), ' They are written with every report from now on.'];
589
+ }
590
+ /** The closing count of objects kept: "1 kept as is", "2 kept as they are". */
591
+ export const keptPart = (n) => `${n} ${n === 1 ? 'kept as is' : 'kept as they are'}`;
592
+ /* DONE — a short summary. */
593
+ /** Said instead of "Done for now." when the project has no counted check at all. */
594
+ export const NOTHING_MEASURED_LINE = 'Nothing is measured yet.';
595
+ /** The folder's project has no counted check run (I15, review T7 finding 1). */
596
+ export function nothingMeasured(f) {
597
+ return !!f.register && f.register.runs.length === 0;
598
+ }
599
+ /** Nothing measured, and too many objects for one check: the per-package first check comes first (I15). */
600
+ export function needsFirstCheck(f) {
601
+ return nothingMeasured(f) && f.register.objects.length > FIRST_CHECK_WHOLE_MAX;
602
+ }
603
+ const nothingMeasuredResult = (stage) => result(stage, [NOTHING_MEASURED_LINE, FIRST_CHECK_PER_PACKAGE_LINE], { kind: 'stop' });
604
+ function done(ctx) {
605
+ const f = facts(ctx);
606
+ // Never "Done for now." with nothing measured.
607
+ if (nothingMeasured(f))
608
+ return nothingMeasuredResult('DONE');
609
+ const lines = ['Done for now.'];
610
+ if (f.register) {
611
+ const mine = myObjects(f.register, f.me);
612
+ const count = (pred) => mine.filter(pred).length;
613
+ const parts = [
614
+ [count((o) => o.state === 'clean'), 'clean'],
615
+ [count((o) => o.state === 'verified'), 'fix proven'],
616
+ ].filter(([n]) => n > 0).map(([n, w]) => `${n} ${w}`);
617
+ const kept = count((o) => o.decision?.classification === 'keep' && o.state !== 'clean');
618
+ if (kept)
619
+ parts.push(keptPart(kept));
620
+ if (mine.length)
621
+ lines.push(` ${plural(mine.length, 'object', 'objects')}${parts.length ? `: ${parts.join(', ')}` : ''}.`);
622
+ const rebuild = toRedesign(f.register, f.me).length;
623
+ if (rebuild)
624
+ lines.push(` ${plural(rebuild, 'object', 'objects')} planned for a rebuild — handled separately.`);
625
+ }
626
+ lines.push('Next time, open CSPeach in this folder and say carry on.');
627
+ return result('DONE', lines, { kind: 'done' });
628
+ }