@cspeach/cli 1.1.29 → 1.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -0,0 +1,435 @@
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, usageLineWithWindow } from '@cspeach/register-core';
16
+ import { runTeamCommand, whoAmI, writeReports } from '../commands/team.js';
17
+ import { shown, shownUpTo } from '../register/atc-file.js';
18
+ import { activeProject, readEstate, readLanes, readProjectFile } from '../register/store.js';
19
+ import { claimedSinceCheck, fixFailedCheck, myObjects, readJourneyFacts, stepOf, toDecide, toFix, toProve, toRedesign, unreadableReason, STEPS, } from './clean-core.js';
20
+ /* ── words ──────────────────────────────────────────────────────────────── */
21
+ export const UNREADABLE_LINES = {
22
+ '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.',
23
+ 'project-file': 'This folder\'s project file can\'t be read.',
24
+ other: 'This folder can\'t be read right now.',
25
+ };
26
+ const step = (stage, title) => `Step ${stepOf(stage)} of ${STEPS} — ${title}`;
27
+ const plural = (count, one, many) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
28
+ const minutes = (objects) => {
29
+ // About 25 objects a minute (live measurement 2026-10-07), never under one minute.
30
+ const m = Math.max(1, Math.ceil(objects / 25));
31
+ return `About ${plural(m, 'minute', 'minutes')}.`;
32
+ };
33
+ /** Words a solo person never reads, and anything that looks like a path or a command to type. */
34
+ const NOT_SOLO = /\b(team|lanes?|outbox(es)?|join(s|ed|ing)?|writers?|variant|projectId|claim(s|ed|ing)?)\b/i;
35
+ // Unix paths bare, quoted or bracketed ('/Users/x/y.json'), and home paths (~/x).
36
+ const LOOKS_LIKE_PATH = /[A-Za-z]:\\|\\\\|(^|[\s"'`(])\/[^\s"'`)]*\/|~\/|cspeach\s/i;
37
+ export const NO_SAP_LINE = 'No SAP system is connected in this session.';
38
+ const NO_ATC_LINE = "SAP's code checks can't be run from this session.";
39
+ const ALREADY_LINE = 'This folder already holds a project. Say carry on to work in it.';
40
+ /**
41
+ * The failures CSPeach knows, each in its own plain words. "Cannot measure"
42
+ * comes first: a session without SAP is said before any step runs, so here it
43
+ * means the connection offers no ATC.
44
+ */
45
+ const KNOWN = [
46
+ [/cannot measure/i, NO_ATC_LINE],
47
+ [/No SAP system is configured|needs a SAP connection/i, NO_SAP_LINE],
48
+ [/already holds a team project/i, ALREADY_LINE],
49
+ ];
50
+ /**
51
+ * What to say when a team body failed: a known failure in its own words;
52
+ * else its first error, when that reads as a plain sentence; else `fallback`.
53
+ * Never a path, never a word a solo person does not use.
54
+ */
55
+ export function failureLine(said, fallback) {
56
+ for (const line of said)
57
+ for (const [re, words] of KNOWN)
58
+ if (re.test(line))
59
+ return words;
60
+ const first = said.find((l) => l.startsWith('✗'))?.replace(/^✗\s*/, '').trim();
61
+ if (!first || NOT_SOLO.test(first) || LOOKS_LIKE_PATH.test(first))
62
+ return fallback;
63
+ return shown(first);
64
+ }
65
+ const failure = failureLine;
66
+ /**
67
+ * A decision record that could not be written, inside a journey: "Not
68
+ * recorded: <plain reason>." The reason as it was said when it reads as a
69
+ * plain sentence; never a path, a team word or a command.
70
+ */
71
+ export function notRecordedLine(error) {
72
+ const said = error instanceof Error ? error.message : String(error);
73
+ const plain = failureLine([`✗ ${said}`], 'the record could not be written to this folder').replace(/[\s.…]+$/, '');
74
+ return `Not recorded: ${plain}.`;
75
+ }
76
+ /** An object name to show: as it is when it is an ABAP name, cleaned otherwise. */
77
+ export const objectShown = (name) => (OBJECT_NAME.test(name.toUpperCase()) ? name.toUpperCase() : shown(name));
78
+ /** An object marked fix whose name CSPeach never puts in a command: one plain line, and it is passed over. */
79
+ export const passedOverLine = (name) => `${objectShown(name)}: the name is not an ABAP name, so CSPeach passed it over.`;
80
+ /* ── commands the journey queues ────────────────────────────────────────── */
81
+ const OBJECT_TYPE = /^[A-Z]{4}$/;
82
+ /** An ABAP object name: letters, digits, underscores, an optional /NAMESPACE/ in front. */
83
+ const OBJECT_NAME = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
84
+ const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
85
+ export const MAX_REASON = 300;
86
+ const chars = (...codes) => String.fromCharCode(...codes);
87
+ /** Straight, back and curly quotes. */
88
+ const QUOTES = new RegExp(`["'${chars(0x60, 0x2018, 0x2019, 0x201c, 0x201d)}]`, 'g');
89
+ /** Bidi isolates and the Arabic letter mark (shown() already drops the other bidi and zero-width characters). */
90
+ const ISOLATES = new RegExp(`[${chars(0x61c, 0x2066, 0x2067, 0x2068, 0x2069)}]`, 'g');
91
+ /** Dashes and the minus: the command holds exactly one " — ", the one before the reason. */
92
+ const DASHES = new RegExp(`[${chars(0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015, 0x2212)}]`, 'g');
93
+ /** A word that would be read as an image to attach (the turn attaches typed image paths). */
94
+ const IMAGE_WORD = /\.(png|jpe?g|gif|webp)\b/gi;
95
+ /**
96
+ * A recorded reason as the person wrote it, for a card: escape sequences,
97
+ * control, zero-width and bidi characters gone, at most `max` characters.
98
+ * Not the command text — that is cleanReason().
99
+ */
100
+ export function shownReason(reason, max) {
101
+ return shownUpTo(String(reason ?? '').replace(ISOLATES, ''), max);
102
+ }
103
+ /**
104
+ * A reason as one plain line for the queued command: escape sequences,
105
+ * control, zero-width and bidi characters gone (shown()); no quotes; no
106
+ * dashes but a plain hyphen; and nothing the turn would act on — no `@`
107
+ * (a file to attach), no `--` (a flag such as --from), no `[ ]` (an image
108
+ * chip), no image file name. At most 300 characters.
109
+ */
110
+ export function cleanReason(reason) {
111
+ return shownUpTo(String(reason ?? '').replace(ISOLATES, ''), Number.MAX_SAFE_INTEGER)
112
+ .replace(QUOTES, '')
113
+ .replace(DASHES, '-')
114
+ .replace(/-{2,}/g, '-')
115
+ .replace(/@/g, ' ')
116
+ .replace(/\[/g, '(')
117
+ .replace(/\]/g, ')')
118
+ .replace(IMAGE_WORD, ' $1')
119
+ .replace(/\s+/g, ' ')
120
+ .trim()
121
+ .slice(0, MAX_REASON)
122
+ .trim();
123
+ }
124
+ /** `/abap-clean-core triage lane <id>`, or null when the id is not one CSPeach passes on. */
125
+ export function triageCommand(laneId) {
126
+ return LANE_ID.test(laneId) ? `/abap-clean-core triage lane ${laneId}` : null;
127
+ }
128
+ /** `/abap-refactor <TYPE> <NAME> — <reason>`, names upper-cased; null when a name is not an ABAP name. */
129
+ export function refactorCommand(o) {
130
+ const type = o.type.trim().toUpperCase();
131
+ const name = o.name.trim().toUpperCase();
132
+ if (!OBJECT_TYPE.test(type) || !OBJECT_NAME.test(name))
133
+ return null;
134
+ const reason = cleanReason(o.reason);
135
+ return `/abap-refactor ${type} ${name}${reason ? ` — ${reason}` : ''}`;
136
+ }
137
+ /* ── the runner ─────────────────────────────────────────────────────────── */
138
+ const result = (stage, lines, next, skip) => ({ stage, lines, next, ...(skip ? { skip } : {}) });
139
+ /** A package name typed in full: no pattern, nothing that is not a package name. */
140
+ const PACKAGE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30}$/;
141
+ const IS_PATTERN = /[*?%]/;
142
+ /**
143
+ * Run one stage. Never throws: anything unexpected comes back as one plain
144
+ * line and a stop. Headless runs nothing and queues nothing; it says the next step.
145
+ */
146
+ export async function runJourneyStage(stage, ctx) {
147
+ try {
148
+ if (ctx.headless)
149
+ return nextStepOnly(stage, ctx);
150
+ // Said before any card: a step that needs SAP, in a session without one.
151
+ if (ctx.team.deps.noSap && (stage === 'INVENTORY' || stage === 'START' || stage === 'PROVE'))
152
+ return result(stage, [NO_SAP_LINE], { kind: 'stop' });
153
+ switch (stage) {
154
+ case 'UNREADABLE': return result(stage, [UNREADABLE_LINES[unreadableReason(facts(ctx)) ?? 'other']], { kind: 'stop' });
155
+ case 'INVENTORY': return await inventory(ctx);
156
+ case 'START': return await start(ctx);
157
+ case 'JOIN': return await joinStage(ctx);
158
+ case 'DECIDE': return decideStage(ctx);
159
+ case 'FIX': return fixStage(ctx);
160
+ case 'PROVE': return await prove(ctx);
161
+ case 'REPORT': return await report(ctx);
162
+ case 'DONE': return done(ctx);
163
+ }
164
+ }
165
+ catch (e) {
166
+ if (process.env['CSPEACH_DEBUG'] === '1')
167
+ throw e;
168
+ const said = [`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`];
169
+ return result(stage, [failure(said, 'This step could not finish.')], { kind: 'stop' });
170
+ }
171
+ }
172
+ const me = (ctx) => whoAmI(ctx.team.deps);
173
+ const facts = (ctx) => readJourneyFacts(ctx.cwd, me(ctx), ctx.skips);
174
+ /** Run a team body with its output kept back; `said` holds only this run's lines. */
175
+ async function quiet(ctx, args) {
176
+ const { said } = ctx.team;
177
+ said.length = 0;
178
+ const code = await runTeamCommand(args, ctx.team.deps);
179
+ return { code, said: [...said] };
180
+ }
181
+ /** Headless / one-shot: the next step in words, nothing run, nothing queued. */
182
+ function nextStepOnly(stage, ctx) {
183
+ const stop = (line) => result(stage, [line], { kind: 'stop' });
184
+ switch (stage) {
185
+ case 'UNREADABLE': return stop(UNREADABLE_LINES[unreadableReason(facts(ctx)) ?? 'other']);
186
+ case 'INVENTORY': return stop(`Next: ${step(stage, 'look at what custom code is on the system.')}`);
187
+ case 'START': {
188
+ const named = (ctx.packages ?? []).map((p) => p.trim().toUpperCase()).filter((p) => PACKAGE.test(p));
189
+ return stop(`Next: ${step(stage, `start your project${named.length ? ` on ${named.join(', ')}` : ''} in this folder.`)}`);
190
+ }
191
+ case 'JOIN': return stop(`Next: ${step(stage, 'pick up the project in this folder.')}`);
192
+ case 'DECIDE':
193
+ case 'FIX': {
194
+ const r = stage === 'DECIDE' ? decideStage(ctx) : fixStage(ctx);
195
+ // Names passed over are still said; the step line gives way to the command.
196
+ return r.next.kind === 'queue'
197
+ ? result(stage, [...r.lines.filter((l) => !l.startsWith('Step ') && !l.startsWith(' ')), `Next: ${r.next.command}`], { kind: 'stop' }, r.skip)
198
+ : r;
199
+ }
200
+ case 'PROVE': return stop(`Next: ${step(stage, 'let SAP check your fixes.')}`);
201
+ case 'REPORT': return stop(`Next: ${step(stage, 'make the status page.')}`);
202
+ case 'DONE': return done(ctx);
203
+ }
204
+ }
205
+ /* INVENTORY — what is out there. */
206
+ async function inventory(ctx) {
207
+ const { code, said } = await quiet(ctx, ['discover', '--out', ctx.cwd]);
208
+ const found = code === 0 ? readEstate(ctx.cwd) : null;
209
+ const s = found ? estateSummaryOf(found.estate) : null;
210
+ if (!s)
211
+ return result('INVENTORY', [failure(said, 'The system could not be read.')], { kind: 'stop' });
212
+ const t = s.totals;
213
+ const words = [
214
+ `${plural(t.packages, 'package', 'packages')} with ${plural(t.codeObjects, 'code object', 'code objects')}.`,
215
+ ...(t.modifications ? [`${plural(t.modifications, 'change', 'changes')} to SAP's own objects.`] : []),
216
+ usageLineWithWindow(s),
217
+ ];
218
+ return result('INVENTORY', [`${step('INVENTORY', 'what is out there.')} ${words.join(' ')}`], { kind: 'continue' });
219
+ }
220
+ /* START — the project on the named packages, in this folder. */
221
+ async function start(ctx) {
222
+ const stop = (line) => result('START', [line], { kind: 'stop' });
223
+ const asked = (ctx.packages ?? []).map((p) => p.trim().toUpperCase()).filter(Boolean);
224
+ if (!asked.length)
225
+ return stop('Which package first? Say: start clean core on ZFI_AR.');
226
+ if (asked.some((p) => IS_PATTERN.test(p)))
227
+ return stop('Name each package in full, for example ZFI_AR — not a pattern.');
228
+ if (asked.some((p) => !PACKAGE.test(p)))
229
+ return stop('Name each package in full, for example ZFI_AR.');
230
+ const packages = [...new Set(asked)];
231
+ // Checked against the system's own package list when the folder holds one.
232
+ const found = readEstate(ctx.cwd);
233
+ const known = found ? estateSummaryOf(found.estate)?.packages.map((p) => p.name.toUpperCase()) : undefined;
234
+ const unknown = known ? packages.filter((p) => !known.includes(p)) : [];
235
+ if (unknown.length)
236
+ return stop(`CSPeach did not find ${unknown.length === 1 ? 'the package' : 'the packages'} ${unknown.join(', ')} on the system.`);
237
+ const { code, said } = await quiet(ctx, ['start', ...packages, '--folder', ctx.cwd]);
238
+ if (code !== 0)
239
+ return stop(failure(said, 'The project could not be started.'));
240
+ let name = packages.join(', ');
241
+ try {
242
+ name = shown(readProjectFile(ctx.cwd).name);
243
+ }
244
+ catch { /* the name it was started with */ }
245
+ const objects = said.map((l) => /^\s*([\d,]+) objects?, matched against/.exec(l)?.[1]).find(Boolean);
246
+ const check = said.map((l) => /First check \([^)]*\): ([\d,]+) of ([\d,]+) objects? with findings/.exec(l)).find(Boolean);
247
+ const lines = [`✓ Your project on ${name} started here.`];
248
+ if (objects)
249
+ lines.push(` ${objects} ${objects === '1' ? 'object' : 'objects'}, matched against the system's count.`);
250
+ lines.push(check ? ` First check: ${check[1]} of ${check[2]} objects with findings.` : ' No check yet.');
251
+ return result('START', lines, { kind: 'continue' });
252
+ }
253
+ /* JOIN — a project in this folder this laptop does not record into. */
254
+ async function joinStage(ctx) {
255
+ const f = facts(ctx);
256
+ const folder = f.folder;
257
+ // Out of a synced file someone else may have written: cleaned before it is shown.
258
+ const lead = 'lead' in folder ? shown(folder.lead) : '';
259
+ const name = 'name' in folder ? shown(folder.name) : '';
260
+ if (folder.kind === 'unreadable')
261
+ return result('JOIN', [UNREADABLE_LINES[folder.reason]], { kind: 'stop' });
262
+ if (folder.kind !== 'mine-not-joined' && folder.kind !== 'someone-else')
263
+ return result('JOIN', [], { kind: 'continue' });
264
+ if (folder.kind === 'someone-else') {
265
+ const res = await ctx.team.confirm({
266
+ id: 'journey-join',
267
+ tag: 'QUESTION',
268
+ title: `This folder holds ${lead}'s project ${name}. Work in it?`,
269
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
270
+ initial: 0,
271
+ aliases: { y: 'yes', n: 'no' },
272
+ });
273
+ if (res.cancelled || res.value !== 'yes')
274
+ return result('JOIN', ['Nothing changed in this folder.'], { kind: 'stop' });
275
+ }
276
+ // Results go straight into the folder.
277
+ const { code, said } = await quiet(ctx, ['join', ctx.cwd, '--outbox', ctx.cwd]);
278
+ if (code !== 0)
279
+ return result('JOIN', [failure(said, 'The project in this folder could not be opened.')], { kind: 'stop' });
280
+ const line = folder.kind === 'mine-not-joined'
281
+ ? `Picking up your project on ${name}.`
282
+ : `✓ Working in ${lead}'s project ${name}.`;
283
+ return result('JOIN', [line], { kind: 'continue' });
284
+ }
285
+ /* DECIDE — the triage of my objects, a model step. */
286
+ function decideStage(ctx) {
287
+ const f = facts(ctx);
288
+ const left = f.register ? toDecide(f.register, f.me, f.skip) : [];
289
+ const lane = left[0]?.lane;
290
+ const command = lane ? triageCommand(lane) : null;
291
+ if (!command)
292
+ return result('DECIDE', ['Nothing to decide that CSPeach can hand on.'], { kind: 'stop' });
293
+ return result('DECIDE', [`${step('DECIDE', 'decide what happens to each object.')} ${plural(left.length, 'object', 'objects')} to go.`], { kind: 'queue', command });
294
+ }
295
+ /* FIX — the next object to fix, a model step. */
296
+ function fixStage(ctx) {
297
+ const f = facts(ctx);
298
+ const left = f.register ? toFix(f.register, f.me, f.skip) : [];
299
+ // A name that is not an ABAP name is passed over with one line; the next one is queued.
300
+ const passed = [];
301
+ for (const o of left) {
302
+ // The reason comes from the recorded decision only, never from the model's words.
303
+ const command = refactorCommand({ type: o.type, name: o.name, ...(o.decision?.reason ? { reason: o.decision.reason } : {}) });
304
+ if (!command) {
305
+ passed.push(o);
306
+ continue;
307
+ }
308
+ const lines = [...passed.map((x) => passedOverLine(x.name)), `${step('FIX', `fix ${o.name.toUpperCase()}.`)}`];
309
+ if (fixFailedCheck(o))
310
+ lines.push(' Its last fix failed the check.');
311
+ return result('FIX', lines, { kind: 'queue', command, key: o.key }, passed.length ? { fix: new Set(passed.map((x) => x.key)) } : undefined);
312
+ }
313
+ return result('FIX', passed.map((x) => passedOverLine(x.name)), { kind: 'continue' }, passed.length ? { fix: new Set(passed.map((x) => x.key)) } : undefined);
314
+ }
315
+ /** What a check of my claimed fixes measures: my one area, or the packages of the claimed objects. */
316
+ function proveScope(f, waiting) {
317
+ const r = f.register;
318
+ const joined = activeProject();
319
+ const lanes = joined ? readLanes(joined.projectDir).filter((l) => l.owner.trim().toLowerCase() === f.me.trim().toLowerCase()) : [];
320
+ if (lanes.length === 1)
321
+ return { args: ['refresh'], objects: myObjects(r, f.me).length };
322
+ const packages = [...new Set(waiting.map((o) => o.package))];
323
+ return {
324
+ args: ['refresh', '--packages', packages.join(',')],
325
+ objects: r.objects.filter((o) => o.inScope && packages.includes(o.package)).length,
326
+ };
327
+ }
328
+ /* PROVE — SAP checks the claimed fixes. */
329
+ async function prove(ctx) {
330
+ const f = facts(ctx);
331
+ if (!f.register)
332
+ return result('PROVE', [UNREADABLE_LINES[unreadableReason(f) ?? 'other']], { kind: 'stop' });
333
+ const waiting = toProve(f.register, f.me);
334
+ if (!waiting.length)
335
+ return result('PROVE', [], { kind: 'continue' });
336
+ const scope = proveScope(f, waiting);
337
+ const res = await ctx.team.confirm({
338
+ id: 'journey-prove',
339
+ tag: 'QUESTION',
340
+ title: `Let SAP check your ${plural(scope.objects, 'object', 'objects')}? ${minutes(scope.objects)}`,
341
+ facts: ['Reads only. Nothing in SAP is changed.'],
342
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'later', label: 'Later' }],
343
+ initial: 0,
344
+ aliases: { y: 'yes', l: 'later' },
345
+ });
346
+ if (res.value === 'later' && !res.cancelled)
347
+ return result('PROVE', ['Left for later.'], { kind: 'continue' }, { prove: true });
348
+ if (res.cancelled || res.value !== 'yes')
349
+ return result('PROVE', ['Not checked.'], { kind: 'stop' });
350
+ const { said } = await quiet(ctx, scope.args);
351
+ const measured = said.map((l) => /^✓ ([\d,]+) objects? measured/.exec(l)?.[1]).find(Boolean);
352
+ const after = readJourneyFacts(ctx.cwd, f.me, ctx.skips).register;
353
+ const byKey = new Map((after?.objects ?? []).map((o) => [o.key, o]));
354
+ const lines = [];
355
+ for (const was of waiting) {
356
+ const now = byKey.get(was.key);
357
+ const name = objectShown(was.name);
358
+ if (!now || !now.lastMeasuredAt || now.lastMeasuredAt === was.lastMeasuredAt)
359
+ lines.push(`${name}: not checked yet.`);
360
+ else if (now.state === 'verified' || now.state === 'clean')
361
+ lines.push(`${name}: fix proven.`);
362
+ else if (fixFailedCheck(now) && !claimedSinceCheck(now))
363
+ lines.push(`${name}: fix failed the check.`);
364
+ else
365
+ lines.push(`${name}: not checked yet.`);
366
+ }
367
+ if (!measured && lines.every((l) => l.endsWith('not checked yet.'))) {
368
+ return result('PROVE', [failure(said, 'SAP could not check them right now.')], { kind: 'stop' });
369
+ }
370
+ return result('PROVE', [`✓ ${measured ?? waiting.length} checked.`, ...lines], { kind: 'continue' });
371
+ }
372
+ /* REPORT — the status page and the workbook, in reports/. */
373
+ async function report(ctx) {
374
+ const joined = activeProject();
375
+ if (!joined)
376
+ return result('REPORT', [UNREADABLE_LINES.other], { kind: 'stop' });
377
+ const project = readProjectFile(joined.projectDir);
378
+ const mine = project.lead.name.trim().toLowerCase() === me(ctx).trim().toLowerCase();
379
+ // The status page of a shared folder is its lead's to make.
380
+ if (!mine)
381
+ return result('REPORT', [`The status page is made by ${shown(project.lead.name)}.`], { kind: 'continue' }, { report: true });
382
+ const { saved, held } = writeReports(joined.projectDir, project, path.join(joined.projectDir, 'reports'), ctx.team.deps);
383
+ const lines = [];
384
+ if (saved.length)
385
+ lines.push(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in the reports folder.`);
386
+ for (const name of held)
387
+ lines.push(` "${name}" is open in another program, so it was not updated. Close it and say carry on.`);
388
+ const page = saved.find((n) => n.toLowerCase().endsWith('.html'));
389
+ if (page) {
390
+ const res = await ctx.team.confirm({
391
+ id: 'journey-open-report',
392
+ tag: 'QUESTION',
393
+ title: 'Open the status page in your browser?',
394
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
395
+ initial: 0,
396
+ aliases: { y: 'yes', n: 'no' },
397
+ });
398
+ if (res.value === 'yes' && !res.cancelled) {
399
+ try {
400
+ const said = await ctx.openFile?.(path.join(joined.projectDir, 'reports', page));
401
+ if (Array.isArray(said))
402
+ lines.push(...said);
403
+ }
404
+ catch {
405
+ lines.push(' Could not open the browser — open the page from the reports folder.');
406
+ }
407
+ }
408
+ }
409
+ return result('REPORT', lines, { kind: 'continue' }, { report: true });
410
+ }
411
+ /** The closing count of objects kept: "1 kept as is", "2 kept as they are". */
412
+ export const keptPart = (n) => `${n} ${n === 1 ? 'kept as is' : 'kept as they are'}`;
413
+ /* DONE — a short summary. */
414
+ function done(ctx) {
415
+ const f = facts(ctx);
416
+ const lines = ['Done for now.'];
417
+ if (f.register) {
418
+ const mine = myObjects(f.register, f.me);
419
+ const count = (pred) => mine.filter(pred).length;
420
+ const parts = [
421
+ [count((o) => o.state === 'clean'), 'clean'],
422
+ [count((o) => o.state === 'verified'), 'fix proven'],
423
+ ].filter(([n]) => n > 0).map(([n, w]) => `${n} ${w}`);
424
+ const kept = count((o) => o.decision?.classification === 'keep' && o.state !== 'clean');
425
+ if (kept)
426
+ parts.push(keptPart(kept));
427
+ if (mine.length)
428
+ lines.push(` ${plural(mine.length, 'object', 'objects')}${parts.length ? `: ${parts.join(', ')}` : ''}.`);
429
+ const rebuild = toRedesign(f.register, f.me).length;
430
+ if (rebuild)
431
+ lines.push(` ${plural(rebuild, 'object', 'objects')} planned for a rebuild — handled separately.`);
432
+ }
433
+ lines.push('Next time, open CSPeach in this folder and say carry on.');
434
+ return result('DONE', lines, { kind: 'done' });
435
+ }
@@ -0,0 +1,77 @@
1
+ import { activeProject } from '../register/store.js';
2
+ const CANCELLED = { value: null, cancelled: true };
3
+ /** The confirm card the session shows; headless never shows one. */
4
+ export function journeyConfirm(headless, confirm) {
5
+ if (headless)
6
+ return async () => CANCELLED;
7
+ return confirm ?? (async (req) => {
8
+ const { askConfirm } = await import('../ui/confirm-request.js');
9
+ return askConfirm(req);
10
+ });
11
+ }
12
+ /**
13
+ * A team body's [y/n] question as a Yes/No card: 'y', 'n', or '' when the card
14
+ * was put away or nobody is there. A question that wants words (a variant
15
+ * name) gets '' without a card: a card cannot answer it, and '' assumes nothing.
16
+ */
17
+ function askWith(confirm) {
18
+ let n = 0;
19
+ return async (question) => {
20
+ const yn = /\s*\[y\/n\]\s*$/i;
21
+ if (!yn.test(question))
22
+ return '';
23
+ const res = await confirm({
24
+ id: `journey-ask-${(n += 1)}`,
25
+ tag: 'QUESTION',
26
+ title: question.replace(yn, ''),
27
+ choices: [{ value: 'y', label: 'Yes' }, { value: 'n', label: 'No' }],
28
+ initial: 0,
29
+ aliases: { y: 'y', n: 'n' },
30
+ });
31
+ return res.cancelled || res.value === null ? '' : res.value;
32
+ };
33
+ }
34
+ /** Stop on the turn's abort signal; an abort that already happened stops at once. */
35
+ function interruptOn(signal) {
36
+ return (stop) => {
37
+ if (signal.aborted) {
38
+ stop();
39
+ return () => { };
40
+ }
41
+ const handler = () => stop();
42
+ signal.addEventListener('abort', handler, { once: true });
43
+ return () => signal.removeEventListener('abort', handler);
44
+ };
45
+ }
46
+ export async function sessionTeamDeps(o) {
47
+ if (!o.headless && !o.signal)
48
+ throw new Error('A live journey step needs the turn abort signal, so Ctrl-C can stop a check.');
49
+ const said = [];
50
+ const confirm = journeyConfirm(o.headless, o.confirm);
51
+ let identity = o.identity;
52
+ if (!identity) {
53
+ const { getAuthorIdentity } = await import('../agent/loop.js');
54
+ // Read at each call, so it follows a join made during the journey: once this
55
+ // laptop records into a project, the name given for that project is the seat name.
56
+ identity = () => ({ name: activeProject()?.name ?? getAuthorIdentity().name });
57
+ }
58
+ const adt = o.adt;
59
+ const noSap = !!o.noSap || !adt;
60
+ const deps = {
61
+ print: (line) => said.push(line),
62
+ identity,
63
+ noSap,
64
+ // The session is connected to one system; that is the one used.
65
+ connect: noSap ? undefined : async () => ({
66
+ sql: (q, max) => adt.sqlQuery(q, max),
67
+ ...(o.sapAlias && o.sapAlias !== 'none' ? { system: { sid: o.sapAlias } } : {}),
68
+ atc: { runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts) },
69
+ }),
70
+ ask: askWith(confirm),
71
+ ...(o.signal ? { onInterrupt: interruptOn(o.signal) } : {}),
72
+ ...(o.now ? { now: o.now } : {}),
73
+ ...(o.rand ? { rand: o.rand } : {}),
74
+ ...(o.machine ? { machine: o.machine } : {}),
75
+ };
76
+ return { deps, said, confirm };
77
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Guided clean-core journey, Task 3 — the sentences that start or resume a
3
+ * journey, detected in the CLI before the classifier (no proxy change).
4
+ *
5
+ * The rule, one sentence at a time:
6
+ * - never a question: no sentence that ends in "?" or opens with a question
7
+ * word (what, how, is, can, explain, tell, …);
8
+ * - it opens with a start/check verb on clean core ("start clean core on …",
9
+ * "let's begin a clean-core run on …"), or — with no verb on clean core
10
+ * itself — it is ONLY names: "clean core for ZFI_AR and ZFI_GL",
11
+ * "check ZFI_AR for clean core (and keep track of it)", a polite ending
12
+ * allowed. "clean core for ZFI_AR is overdue" is a statement, not a start;
13
+ * - it names at least one package, by code: ZFI_AR, zfi_ar, /NS/NAME.
14
+ * A word like "your" is not a package: a name needs an underscore, a
15
+ * digit, a namespace, or capitals as typed.
16
+ * Or the explicit form "/abap-clean-core start <PKG…>".
17
+ * A pattern (*, %, ?) in such a sentence is refused in one line; no journey.
18
+ *
19
+ * Resume, in a folder that holds a project with no plan step pending: any of
20
+ * "carry on" / "continue" / "continue clean core" picks up a journey this
21
+ * session paused; "continue clean core" also resumes after a clean turn with
22
+ * no answer pending; bare "carry on" / "continue" only as the session's first
23
+ * words. Otherwise "continue" keeps its old meaning.
24
+ */
25
+ import { folderKind } from './clean-core.js';
26
+ import { isJourneyPaused, isJourneyPausedJustNow, userTurnCount } from './flow.js';
27
+ /** The runner's own words for a pattern (run.ts START). */
28
+ export const PATTERN_LINE = 'Name each package in full, for example ZFI_AR — not a pattern.';
29
+ const CC = String.raw `clean[\s-]?core`;
30
+ const CLEAN_CORE = new RegExp(CC, 'i');
31
+ /** Words in front of the verb that do not change what is asked. */
32
+ const FILLER = new RegExp(String.raw `^(?:(?:let'?s|let us|please|now|ok(?:ay)?|so|then|and|right|go ahead and|time to|(?:i|we)(?:'d| would)? (?:like|want) to|(?:i|we) need to)[,]?\s+)+`, 'i');
33
+ const QUESTION = /^(?:what|why|how|is|are|was|were|can|could|would|should|does|did|will|explain|tell|describe|which|when|where|who|whether|show)\b/i;
34
+ /**
35
+ * Package names joined by "and", "," or "&" — nothing else. A name may hold
36
+ * *, % or ? here only so a pattern is caught and refused in its own words.
37
+ */
38
+ const NAME = String.raw `(?:\/[A-Za-z0-9_]{1,10}\/[A-Za-z0-9_*%?]{1,30}|[ZzYy][A-Za-z0-9_*%?]{0,29})`;
39
+ const NAMES = String.raw `${NAME}(?:(?:\s*,\s*|\s*&\s*|\s+and\s+|\s+)${NAME})*`;
40
+ /** What may follow the names: a polite ending, never more sentence. */
41
+ const TAIL = String.raw `(?:[\s,]+(?:please|now|today|for me|thanks|thank you))*`;
42
+ /** After "check <names> for clean core": "and keep track of it" and the like. */
43
+ const TRACK = String.raw `(?:\s+and\s+(?:keep\s+(?:a\s+)?track|track)(?:\s+of)?(?:\s+(?:it|them))?)?`;
44
+ /** A verb on clean core opens the sentence: the names can follow in any words. */
45
+ const VERB_OPENINGS = [
46
+ new RegExp(String.raw `^(?:start|begin|kick off|launch|set up|run)\s+(?:(?:a|the|our|my|up|with|on)\s+){0,2}${CC}\b`, 'i'),
47
+ new RegExp(String.raw `^check\s+(?:the\s+)?${CC}\b`, 'i'),
48
+ ];
49
+ /**
50
+ * No verb on clean core itself: the sentence must hold only names (and a
51
+ * polite ending) — a statement ("clean core for ZFI_AR is overdue") never
52
+ * starts a paid step. Group 1 is the names slot; every word in it must look
53
+ * like a package as typed ("yes", "yet", "you" do not).
54
+ */
55
+ const NAMES_OPENINGS = [
56
+ new RegExp(String.raw `^check\s+(${NAMES})\s+for\s+${CC}${TRACK}${TAIL}$`, 'i'),
57
+ new RegExp(String.raw `^${CC}\s+(?:for|on|with|of)\s+(${NAMES})${TAIL}$`, 'i'),
58
+ ];
59
+ /** The package test, as typed: /NS/…, an underscore or a digit, or capitals. */
60
+ const looksLikePackage = (typed) => typed.startsWith('/') || /[_\d]/.test(typed) || typed === typed.toUpperCase();
61
+ /** True when the sentence opens a journey: a verb on clean core, or only package names. */
62
+ function opensJourney(s) {
63
+ if (VERB_OPENINGS.some((re) => re.test(s)))
64
+ return true;
65
+ for (const re of NAMES_OPENINGS) {
66
+ const slot = re.exec(s)?.[1];
67
+ if (slot === undefined)
68
+ continue;
69
+ const words = slot.split(/\s*[,&]\s*|\s+/).filter((w) => w && w.toLowerCase() !== 'and');
70
+ if (words.length && words.every(looksLikePackage))
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ const EXPLICIT = /^\/(?:abap-)?clean-core\s+start(?:\s+([\s\S]*))?$/i;
76
+ const PATTERN = /[*%]|[A-Za-z0-9_]\?[A-Za-z0-9_]/;
77
+ /** A package as typed: /NS/NAME, or Z… / Y… (letters, digits, underscores). */
78
+ const PACKAGE_TOKEN = /(?<![A-Za-z0-9_/])(\/[A-Za-z0-9_]{1,10}\/[A-Za-z0-9_]{1,30}|[ZzYy][A-Za-z0-9_]{1,29})(?![A-Za-z0-9_/])/g;
79
+ const unique = (names) => [...new Set(names)];
80
+ /** The sentences of a message: split after . ! ? and at line breaks. */
81
+ function sentences(text) {
82
+ return text.replace(/[‘’]/g, "'").split(/(?<=[.!?])\s+|\r?\n+/).map((s) => s.trim()).filter(Boolean);
83
+ }
84
+ /** Package-looking names in a sentence, upper-cased, each once. */
85
+ export function packagesIn(sentence) {
86
+ const out = [];
87
+ for (const m of sentence.matchAll(PACKAGE_TOKEN)) {
88
+ const typed = m[1];
89
+ if (looksLikePackage(typed))
90
+ out.push(typed.toUpperCase());
91
+ }
92
+ return unique(out);
93
+ }
94
+ /** A start sentence (or the explicit form), a refused pattern, or null. Pure. */
95
+ export function parseJourneyStart(input) {
96
+ const text = input.trim();
97
+ const explicit = EXPLICIT.exec(text);
98
+ if (explicit) {
99
+ const rest = (explicit[1] ?? '').trim();
100
+ if (PATTERN.test(rest))
101
+ return { kind: 'refuse', line: PATTERN_LINE };
102
+ // As typed, upper-cased: the runner checks each one against the system.
103
+ const named = rest.split(/[\s,;]+/).filter((w) => w && !/^(and|or)$/i.test(w)).map((w) => w.toUpperCase());
104
+ return { kind: 'start', packages: unique(named) };
105
+ }
106
+ if (text.startsWith('/'))
107
+ return null;
108
+ for (const raw of sentences(text)) {
109
+ if (/\?\s*$/.test(raw) || QUESTION.test(raw))
110
+ continue;
111
+ const s = raw.replace(FILLER, '').replace(/[.!]+$/, '').trim();
112
+ if (QUESTION.test(s) || !opensJourney(s))
113
+ continue;
114
+ if (PATTERN.test(s))
115
+ return { kind: 'refuse', line: PATTERN_LINE };
116
+ const packages = packagesIn(s);
117
+ if (packages.length)
118
+ return { kind: 'start', packages };
119
+ }
120
+ return null;
121
+ }
122
+ /** "carry on", "continue", "continue (with) clean core". */
123
+ export function isResumePhrase(input) {
124
+ const t = input.trim().toLowerCase().replace(/[.!]+$/, '').replace(/\s+/g, ' ');
125
+ return new RegExp(String.raw `^(?:carry on|continue)(?: (?:with )?(?:the )?${CC})?$`).test(t);
126
+ }
127
+ /**
128
+ * The journey this input asks for, or null (the input goes on as before).
129
+ * Resume needs a project in `cwd` and no plan step in flight. Then:
130
+ * - right after a pause line, with no turn of the person's since, any of the
131
+ * words resumes — even after an interrupted step (that is what paused it);
132
+ * - otherwise nothing interrupted, no answer pending, and: a journey this
133
+ * session paused, or the words name clean core, or it is the session's
134
+ * first turn (text and image turns count; tool results do not).
135
+ */
136
+ export function detectJourney(input, ctx) {
137
+ const start = parseJourneyStart(input);
138
+ if (start)
139
+ return start;
140
+ if (!isResumePhrase(input))
141
+ return null;
142
+ const s = ctx.session;
143
+ // Only whether a project file is there; the stage machine reads the rest.
144
+ if (s.plan_in_flight || folderKind(ctx.cwd, '').kind === 'none')
145
+ return null;
146
+ // Right after the pause line ("Say carry on"), with no turn of the person's
147
+ // since, the pause wins over an interrupted or waiting turn.
148
+ if (isJourneyPausedJustNow(ctx.session))
149
+ return { kind: 'resume' };
150
+ if (s.turnInterrupted || s.awaitingSkillAnswer)
151
+ return null;
152
+ return isJourneyPaused(ctx.session) || CLEAN_CORE.test(input) || userTurnCount(ctx.session) === 0 ? { kind: 'resume' } : null;
153
+ }