@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,978 @@
1
+ /**
2
+ * Guided clean-core journey, Task 3 — the harness-owned chain (the /abap-app
3
+ * shape: lane/app-chain.ts).
4
+ *
5
+ * enterJourney a start sentence / "carry on" (REPL, before the classifier)
6
+ * drive derive the stage from the folder, ask the step card,
7
+ * run the code stage, derive again … until a model step
8
+ * (queued through the pending-dispatch slot), a stop, or done
9
+ * afterJourneyTurn the queued model turn ended (runTurn's post-turn hook):
10
+ * derive again and go on
11
+ * gateJourneyForTurn any other turn ends the chain with one line
12
+ *
13
+ * Every queued command is built by code (run.ts triageCommand / refactorCommand).
14
+ * Headless runs nothing and queues nothing: runJourneyStage says the next step.
15
+ */
16
+ import { estateSummaryOf } from '@cspeach/register-core';
17
+ import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, runTeamCommand, whoAmI } from '../commands/team.js';
18
+ import { activeProject, readEstate, readProjectFile, VARIANT_NAME } from '../register/store.js';
19
+ import { findingsByObject } from '../register/findings.js';
20
+ import { shown, shownUpTo } from '../register/atc-file.js';
21
+ import { cspeachWritesThisSession } from '../register/offer.js';
22
+ import { cardSlotEmitter } from '../ui/card-slot.js';
23
+ import { claimedSinceCheck, decidePage, fixFailedCheck, folderKind, provenForTarget, readJourneyFacts, stageOf, stepOf, STEPS, toDecide, toFix, toRedesign } from './clean-core.js';
24
+ import { handOffRebuild, rebuildCard, rebuildGroups } from './rebuild.js';
25
+ import { addJourneyLeftOut, addJourneySkips, clearJourneyFlow, forgetNotNow, getJourneyFlow, journeyHopLine, journeySkips, journeyStepKey, noteJourneyStop, pauseJourneyFlow, setJourneyAt, setJourneyPage, setJourneyQueued, startJourneyFlow, } from './flow.js';
26
+ import { recordFullNotice } from '../renderer/notice-log.js';
27
+ import { firstCheckPerPackage, needsFirstCheck, notRecordedLine, passedOverLine, refactorCommand, runJourneyStage, shownReason } from './run.js';
28
+ import { cleanKeepReason, FACT_ROWS, KEEP_QUESTION } from '../projects/clean-core-confirm.js';
29
+ import { sessionTeamDeps } from './session-deps.js';
30
+ import { lookupVariant, variantLookupLine, variantWhat, VARIANT_TITLE } from '../register/variant.js';
31
+ /* ── words ──────────────────────────────────────────────────────────────── */
32
+ export const PAUSED_LINE = (step) => step ? `Paused at step ${step}. Say carry on to continue.` : 'Paused. Say carry on to continue.';
33
+ export const LOOK_TITLE = 'Look at the system?';
34
+ export const START_TITLE = 'Start the project here?';
35
+ export const NOTHING_READ_LINE = 'Nothing was read from the system.';
36
+ export const NOT_STARTED_LINE = 'No project was started.';
37
+ /** I15: the start above 500 objects measured nothing; this card offers the first check per package. */
38
+ export const FIRST_CHECK_TITLE = 'Run the first check per package now?';
39
+ /**
40
+ * Mode 1 (architecture §D, spec §3.6): No at "Start the project here?" leaves the
41
+ * analysis only — what the inventory showed, with nothing kept against it.
42
+ */
43
+ export const POINT_IN_TIME_LINE = 'What you saw is a point-in-time look, not tracked: no count matched against SAP, no history, no proof after a later change.';
44
+ /** A discovery this many days old is used as it is; an older one asks again. */
45
+ export const FRESH_DAYS = 7;
46
+ const plural = (count, one, many) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
47
+ /** About 25 objects a minute (run.ts, live measurement 2026-10-07). */
48
+ const aboutMinutes = (objects) => {
49
+ const m = Math.max(1, Math.ceil(objects / 25));
50
+ return m === 1 ? 'about a minute' : `about ${m} minutes`;
51
+ };
52
+ const footnote = (stage) => `Step ${stepOf(stage)} of 6`;
53
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
54
+ /** "09:12 on 7 Oct", local time. */
55
+ export function shownTime(iso) {
56
+ const d = new Date(iso);
57
+ if (!Number.isFinite(d.getTime()))
58
+ return 'an earlier time';
59
+ const hh = String(d.getHours()).padStart(2, '0');
60
+ const mm = String(d.getMinutes()).padStart(2, '0');
61
+ return `${hh}:${mm} on ${d.getDate()} ${MONTHS[d.getMonth()]}`;
62
+ }
63
+ const CANCELLED = { value: null, cancelled: true };
64
+ /**
65
+ * The classic REPL has no card listener: each journey card becomes one typed
66
+ * line ("Fix X now? Yes (y) / Pick another (p) / Later (l)"), its facts printed
67
+ * above it. Empty or unknown → put away.
68
+ */
69
+ export function lineConfirm(prompt, emit) {
70
+ return async (req) => {
71
+ for (const f of req.facts ?? [])
72
+ emit(` ${f}`);
73
+ const keys = Object.entries(req.aliases ?? {});
74
+ const choices = req.choices.map((c) => {
75
+ const k = keys.find(([, v]) => v === c.value)?.[0];
76
+ return k ? `${c.label} (${k})` : c.label;
77
+ }).join(' / ');
78
+ const raw = (await prompt(`${req.title} ${choices}`)).trim().toLowerCase();
79
+ if (!raw)
80
+ return CANCELLED;
81
+ const alias = req.aliases?.[raw];
82
+ if (alias)
83
+ return { value: alias };
84
+ const hit = req.choices.find((c) => c.label.toLowerCase() === raw || c.value === raw);
85
+ return hit ? { value: hit.value } : CANCELLED;
86
+ };
87
+ }
88
+ /**
89
+ * The journey's card: the Ink card slot when it has a listener, else a typed
90
+ * line (classic REPL). Also the row cards of the triage save (Task 4).
91
+ */
92
+ export function journeyCardConfirm(emit) {
93
+ return async (req) => {
94
+ if (cardSlotEmitter.listenerCount('confirm') > 0) {
95
+ const { askConfirm } = await import('../ui/confirm-request.js');
96
+ return askConfirm(req);
97
+ }
98
+ const { inkAwarePrompt } = await import('../agent/loop.js');
99
+ return lineConfirm(inkAwarePrompt, emit)(req);
100
+ };
101
+ }
102
+ /**
103
+ * The journey's free-text question ("Why keep it?"): the Ink prompt when it
104
+ * has a listener, else a typed line (classic). Esc on the Ink prompt puts the
105
+ * question away, as Esc on a card does — it throws, so the rows stop and the
106
+ * journey pauses, instead of the question coming back.
107
+ */
108
+ export function journeyAsk() {
109
+ return async (question) => {
110
+ const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
111
+ if (askQuestionEmitter.listenerCount('request') > 0) {
112
+ const res = await askQuestionEmitter.request({ id: 'journey-ask', question, kind: 'text', choices: [] });
113
+ if (res.cancelled || res.answer === null)
114
+ throw new Error('put away');
115
+ return res.answer;
116
+ }
117
+ const { inkAwarePrompt } = await import('../agent/loop.js');
118
+ return inkAwarePrompt(question);
119
+ };
120
+ }
121
+ const defaultConfirm = (h) => journeyCardConfirm(h.emit);
122
+ async function teamFor(h) {
123
+ const base = {
124
+ ...(h.adt ? { adt: h.adt } : {}),
125
+ ...(h.sapAlias ? { sapAlias: h.sapAlias } : {}),
126
+ ...(h.noSap ? { noSap: true } : {}),
127
+ ...(h.identity ? { identity: h.identity } : {}),
128
+ ...(h.now ? { now: h.now } : {}),
129
+ ...(h.rand ? { rand: h.rand } : {}),
130
+ ...(h.machine ? { machine: h.machine } : {}),
131
+ };
132
+ return h.headless
133
+ ? sessionTeamDeps({ ...base, headless: true })
134
+ : sessionTeamDeps({ ...base, headless: false, signal: h.signal ?? new AbortController().signal, confirm: h.confirm ?? defaultConfirm(h) });
135
+ }
136
+ /**
137
+ * The line for a chain that cannot go on at `stage`: before a project exists
138
+ * there is nothing to carry on, so steps 1 and 2 say what did not happen.
139
+ */
140
+ function stopAt(h, stage) {
141
+ if (stage === 'INVENTORY')
142
+ end(h, NOTHING_READ_LINE);
143
+ else if (stage === 'START')
144
+ end(h, NOT_STARTED_LINE);
145
+ else
146
+ pause(h, stepOf(stage));
147
+ }
148
+ /** The session's tool calls (the refactor's writes are read from them); none when the session keeps none. */
149
+ function toolCallsOf(session) {
150
+ const calls = session.toolCalls;
151
+ return Array.isArray(calls) ? calls : [];
152
+ }
153
+ /**
154
+ * Queue the journey's next model step (already set with setJourneyQueued):
155
+ * tagged as CSPeach's, with the one line that stands for it on screen (audit M-2).
156
+ */
157
+ function queueStep(h, command) {
158
+ const line = journeyHopLine(h.session, command);
159
+ h.queueNext(command, { by: 'journey', ...(line ? { line } : {}) });
160
+ }
161
+ /** End the chain: optional last line, the guarded run off. */
162
+ function end(h, line) {
163
+ clearJourneyFlow(h.session);
164
+ if (line)
165
+ h.emit(line);
166
+ }
167
+ /**
168
+ * Audit M-1 — an unexpected error stopped the chain (the entry, or the post-turn
169
+ * hook). The line promises "carry on", so the run is PAUSED, the same pause a
170
+ * stray turn makes: bare "carry on" resumes from the folder-derived stage.
171
+ * With no project file in the folder "carry on" cannot pick anything up
172
+ * (detectJourney needs one), so the run ends and the line says to start again.
173
+ */
174
+ export const STOPPED_LINE = 'The clean-core steps stopped here. Say carry on to continue.';
175
+ export const STOPPED_BEFORE_START_LINE = 'The clean-core steps stopped before a project was started. Say the same words again to start over.';
176
+ /**
177
+ * The same step stopped again (must-fix follow-up a). The run ENDS rather than
178
+ * pauses, so a bare "carry on" no longer picks it up and nothing can loop on
179
+ * its own; the words that name clean core still start it again, by choice.
180
+ */
181
+ export const STOPPED_AGAIN_LINE = (count, detail) => `This step failed ${count === 2 ? 'twice' : `${count} times`}. Details: ${shortDetail(detail)}. Say carry on with clean core to try again, or ask for help.`;
182
+ /** The first line of an error, at most 140 characters, without a trailing full stop. */
183
+ function shortDetail(text) {
184
+ const first = (String(text ?? '').split(/\r?\n/)[0] ?? '').trim().replace(/[.\s]+$/, '') || 'no detail';
185
+ return first.length > 140 ? `${first.slice(0, 140).trimEnd()}…` : first;
186
+ }
187
+ /**
188
+ * The error goes to the session's notice log with the step it stopped at, so
189
+ * support can see it. A second stop in a row at the same step says so, with
190
+ * the detail, and ends the run (`STOPPED_AGAIN_LINE`).
191
+ */
192
+ export function journeyStoppedOnError(session, cwd, err) {
193
+ // The step is the stage the chain last reached: a stop before the folder could
194
+ // be read again (the entry after "carry on") is still a stop at that step.
195
+ const step = journeyStepKey(session) ?? getJourneyFlow(session)?.queued?.stage ?? 'START';
196
+ const detail = err instanceof Error ? err.message : err === undefined ? '' : String(err);
197
+ recordFullNotice('journey', `clean-core steps stopped at ${step}: ${detail || 'no detail'}`);
198
+ let resumable = false;
199
+ try {
200
+ resumable = folderKind(cwd, '').kind !== 'none';
201
+ }
202
+ catch {
203
+ resumable = false;
204
+ }
205
+ if (!resumable) {
206
+ clearJourneyFlow(session);
207
+ return STOPPED_BEFORE_START_LINE;
208
+ }
209
+ const count = noteJourneyStop(session, step);
210
+ if (count > 1) {
211
+ clearJourneyFlow(session);
212
+ return STOPPED_AGAIN_LINE(count, detail);
213
+ }
214
+ pauseJourneyFlow(session);
215
+ return STOPPED_LINE;
216
+ }
217
+ /** Pause the chain: the pause line, and "carry on" (even bare) picks it up. */
218
+ function pause(h, step) {
219
+ pauseJourneyFlow(h.session);
220
+ h.emit(PAUSED_LINE(step));
221
+ }
222
+ /* ── entry ──────────────────────────────────────────────────────────────── */
223
+ /** A start sentence, a refused pattern, or "carry on" — from the REPL, before the classifier. */
224
+ export async function enterJourney(trigger, h) {
225
+ if (trigger.kind === 'refuse') {
226
+ h.emit(trigger.line);
227
+ return;
228
+ }
229
+ startJourneyFlow(h.session, trigger.kind === 'start' ? trigger.packages : []);
230
+ forgetNotNow(h.session);
231
+ try {
232
+ await drive(h);
233
+ }
234
+ catch (e) {
235
+ end(h);
236
+ throw e;
237
+ }
238
+ }
239
+ /* ── the step cards ─────────────────────────────────────────────────────── */
240
+ async function yesNo(confirm, req) {
241
+ const res = await confirm({
242
+ ...req, tag: 'QUESTION', choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }], initial: 0, aliases: { y: 'yes', n: 'no' },
243
+ });
244
+ return !res.cancelled && res.value === 'yes';
245
+ }
246
+ /** The folder's inventory: when it was taken, on which system; null when there is none. */
247
+ function inventoryOf(cwd) {
248
+ const found = readEstate(cwd);
249
+ const s = found ? estateSummaryOf(found.estate) : null;
250
+ if (!s)
251
+ return null;
252
+ return {
253
+ takenAt: s.takenAt,
254
+ ...(s.system?.sid ? { sid: s.system.sid } : {}),
255
+ packages: new Map(s.packages.map((p) => [p.name.toUpperCase(), p.codeObjects])),
256
+ };
257
+ }
258
+ const PACKAGE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30}$/;
259
+ /** The named packages and their code objects, when all are names the system has; else null (the runner says why). */
260
+ function startScope(cwd, packages) {
261
+ const names = [...new Set(packages.map((p) => p.trim().toUpperCase()).filter(Boolean))];
262
+ if (!names.length || names.some((p) => !PACKAGE.test(p)))
263
+ return null;
264
+ const inv = inventoryOf(cwd);
265
+ if (!inv || names.some((p) => !inv.packages.has(p)))
266
+ return null;
267
+ return { names, objects: names.reduce((n, p) => n + (inv.packages.get(p) ?? 0), 0) };
268
+ }
269
+ /** The card shows this much of a recorded reason, wrapped over up to FACT_ROWS rows. */
270
+ export const CARD_REASON = 300;
271
+ const LEVEL = /^[A-D]$/;
272
+ /**
273
+ * Every fix or rebuild is recorded with target A when none was given
274
+ * (register/records.ts, `cspeach team decide`); register-core keeps what it is
275
+ * handed, so a recorded A cannot be told from a default. Only another level
276
+ * was set on purpose, and only that one is said.
277
+ */
278
+ const DEFAULT_TARGET = 'A';
279
+ /**
280
+ * The facts of the fix card, from the register only (no SAP call): the open
281
+ * findings, a failed check, why (the decision's reason as written, cleaned
282
+ * for the screen, at most 300 characters, wrapped on the card), the target level when one was set
283
+ * on purpose, the approval promise, and where this object stands in the list.
284
+ */
285
+ export function fixFacts(o, at, of, open = []) {
286
+ const reason = shownReason(o.decision?.reason, CARD_REASON);
287
+ const set = o.decision?.targetLevel;
288
+ // Step 2: a target a person or the project set (proof.target) is said, A included.
289
+ // Without one, the legacy reading stands: only a recorded level other than A.
290
+ const named = o.proof?.target?.level;
291
+ const target = named && LEVEL.test(named) ? named : set && LEVEL.test(set) && set !== DEFAULT_TARGET ? set : undefined;
292
+ const now = o.decision?.level && LEVEL.test(o.decision.level) ? o.decision.level : undefined;
293
+ return [
294
+ o.openFindings === null ? 'Not checked yet.' : `${plural(o.openFindings, 'open finding', 'open findings')}.`,
295
+ ...(fixFailedCheck(o) ? ['Its last fix failed the check.', ...open] : []),
296
+ ...(reason ? [`Why: ${reason}`] : []),
297
+ ...(target ? [`Target: level ${target}${now && now !== target ? `, now ${now}` : ''}.`] : []),
298
+ 'CSPeach asks before it writes to SAP.',
299
+ `${at} of ${plural(of, 'object marked fix', 'objects marked fix')}.`,
300
+ ];
301
+ }
302
+ /** "Still open:" shows at most this many lines on a card. */
303
+ const STILL_OPEN_ROWS = 3;
304
+ /** A fact wraps onto at most this many rows on a card that also lists what is still open. */
305
+ export const FACT_ROWS_BESIDE_OPEN = 3;
306
+ /**
307
+ * What the newest measurement still finds in an object, in plain words, at
308
+ * most three lines: "Still open: Usage of not released application API
309
+ * (line 13)". Read from the folder's measurement records (findings.ts, the
310
+ * project's variant only); none when they cannot be read. The titles were
311
+ * written on another laptop, so they are cleaned for the screen.
312
+ */
313
+ export function stillOpenLines(key) {
314
+ let latest = [];
315
+ try {
316
+ const joined = activeProject();
317
+ if (!joined)
318
+ return [];
319
+ latest = findingsByObject(joined.projectDir, joined.outboxDir, { lines: true }).get(key)?.latest ?? [];
320
+ }
321
+ catch {
322
+ return [];
323
+ }
324
+ const said = (r) => {
325
+ const what = shownUpTo(r.message || r.rule, 120).replace(/[\s.]+$/, '');
326
+ const where = r.count > 1 ? ` (${r.count} places${r.line ? `, first at line ${r.line}` : ''})` : r.line ? ` (line ${r.line})` : '';
327
+ return `Still open: ${what}${where}`;
328
+ };
329
+ if (latest.length <= STILL_OPEN_ROWS)
330
+ return latest.map(said);
331
+ // Findings, not rules: the same count the card's title gives.
332
+ const more = latest.slice(STILL_OPEN_ROWS - 1).reduce((sum, r) => sum + r.count, 0);
333
+ return [...latest.slice(0, STILL_OPEN_ROWS - 1).map(said), `And ${plural(more, 'other finding', 'other findings')}.`];
334
+ }
335
+ /**
336
+ * Step 4: "Fix <NAME> now?" [Yes] [Pick another] [Later], smallest first
337
+ * (toFix's order); a fix that failed the check asks "<NAME>'s fix failed the
338
+ * check — try again?". Pick another goes round the list; Later puts the
339
+ * object off for the session. Yes hands the runner a skip list that leaves
340
+ * only the chosen object first. Objects whose names are not ABAP names never
341
+ * reach this card: the chain passes them over first.
342
+ */
343
+ export async function fixCard(confirm, objects, skips, openOf = () => []) {
344
+ const list = objects.filter((o) => refactorCommand({ type: o.type, name: o.name }) !== null);
345
+ if (!list.length)
346
+ return { kind: 'none' };
347
+ const many = list.length > 1;
348
+ for (let i = 0; i < list.length * 20; i++) {
349
+ const o = list[i % list.length];
350
+ const name = o.name.toUpperCase();
351
+ const open = fixFailedCheck(o) ? openOf(o) : [];
352
+ const res = await confirm({
353
+ id: 'journey-fix',
354
+ tag: 'QUESTION',
355
+ title: fixFailedCheck(o) ? `${name}'s fix failed the check — try again?` : `Fix ${name} now?`,
356
+ facts: fixFacts(o, (i % list.length) + 1, list.length, open),
357
+ choices: [{ value: 'yes', label: 'Yes' }, ...(many ? [{ value: 'another', label: 'Pick another' }] : []), { value: 'later', label: 'Later' }],
358
+ initial: 0,
359
+ aliases: { y: 'yes', ...(many ? { p: 'another' } : {}), l: 'later' },
360
+ footnote: footnote('FIX'),
361
+ // With what is still open on the card, the reason gets three rows, so the card stays short.
362
+ wrapFacts: open.length ? FACT_ROWS_BESIDE_OPEN : FACT_ROWS,
363
+ });
364
+ if (res.cancelled || res.value === null)
365
+ return { kind: 'cancel' };
366
+ if (res.value === 'another')
367
+ continue;
368
+ if (res.value === 'later')
369
+ return { kind: 'later', object: o };
370
+ const others = objects.filter((x) => x.key !== o.key).map((x) => x.key);
371
+ return { kind: 'yes', object: o, skips: { ...skips, fix: new Set([...(skips.fix ?? []), ...others]) } };
372
+ }
373
+ return { kind: 'cancel' };
374
+ }
375
+ /** The object's target for the fix turn's prompt, when a person or the project set one. */
376
+ function targetFor(o) {
377
+ const level = o?.proof?.target?.level;
378
+ return level && LEVEL.test(level) ? { target: level } : {};
379
+ }
380
+ export const VARIANT_QUESTION = 'Which check variant? Type its name.';
381
+ /** Said before "No project was started." when the variant card or its question was tried too often. */
382
+ export const VARIANT_TRIES_LINE = 'Too many tries to choose a check variant.';
383
+ /**
384
+ * Step 2, after "Start the project here?" (spec §3.1, F7, F16, I17): the check
385
+ * the project measures with, shown and looked up on the system. The journey
386
+ * always proposes ABAP_CLOUD_READINESS (no sentence parser, F7). [Use another]
387
+ * asks for a name (journeyAsk: free text), refuses one that is not a variant
388
+ * name, looks it up and shows the card again. Returns the confirmed name, or
389
+ * null when the card or the question was put away.
390
+ */
391
+ async function variantCard(h, team, confirm) {
392
+ const inv = inventoryOf(h.cwd);
393
+ const sid = shown(inv?.sid ?? (h.sapAlias && h.sapAlias !== 'none' ? h.sapAlias : 'the system'));
394
+ const ask = h.ask ?? journeyAsk();
395
+ const look = async (name) => {
396
+ if (team.deps.noSap || !team.deps.connect)
397
+ return 'unreadable';
398
+ try {
399
+ return await lookupVariant((await team.deps.connect()).sql, name);
400
+ }
401
+ catch {
402
+ return 'unreadable';
403
+ }
404
+ };
405
+ let name = DEFAULT_ATC_VARIANT;
406
+ for (let tries = 0; tries < 5; tries++) {
407
+ const res = await confirm({
408
+ id: 'journey-variant',
409
+ tag: 'QUESTION',
410
+ title: VARIANT_TITLE(name),
411
+ facts: [variantWhat(name), variantLookupLine(name, sid, await look(name))],
412
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'another', label: 'Use another' }],
413
+ initial: 0,
414
+ aliases: { y: 'yes', u: 'another' },
415
+ footnote: footnote('START'),
416
+ });
417
+ if (res.cancelled || res.value === null)
418
+ return null;
419
+ if (res.value === 'yes')
420
+ return name;
421
+ let typed = '';
422
+ for (let k = 0; k < 3 && !typed; k++) {
423
+ let said;
424
+ try {
425
+ said = await ask(VARIANT_QUESTION);
426
+ }
427
+ catch {
428
+ return null;
429
+ }
430
+ const candidate = said.trim().toUpperCase();
431
+ if (VARIANT_NAME.test(candidate))
432
+ typed = candidate;
433
+ else
434
+ h.emit(`"${shownUpTo(said.trim(), 60)}" is not an ATC check variant name.`);
435
+ }
436
+ if (!typed) {
437
+ h.emit(VARIANT_TRIES_LINE);
438
+ return null;
439
+ }
440
+ name = typed;
441
+ }
442
+ h.emit(VARIANT_TRIES_LINE);
443
+ return null;
444
+ }
445
+ /**
446
+ * I15: "Run the first check per package now? It resumes if stopped." Yes runs
447
+ * `team refresh --all --per-package`; Later (or the card put away) ends the
448
+ * chain with the command as a line. Returns true when the chain goes on.
449
+ */
450
+ async function firstCheckCard(h, team, skips, packages) {
451
+ const res = await team.confirm({
452
+ id: 'journey-first-check',
453
+ tag: 'QUESTION',
454
+ title: FIRST_CHECK_TITLE,
455
+ facts: ['It resumes if stopped.'],
456
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'later', label: 'Later' }],
457
+ initial: 0,
458
+ aliases: { y: 'yes', l: 'later' },
459
+ footnote: footnote('START'),
460
+ });
461
+ if (res.cancelled || res.value === null || res.value === 'later') {
462
+ end(h, FIRST_CHECK_PER_PACKAGE_LINE);
463
+ return false;
464
+ }
465
+ const check = await firstCheckPerPackage({ cwd: h.cwd, team, headless: h.headless, skips, packages: [...packages] });
466
+ for (const line of check.lines)
467
+ h.emit(line);
468
+ if (check.next.kind !== 'continue') {
469
+ end(h);
470
+ return false;
471
+ }
472
+ return true;
473
+ }
474
+ /* ── the chain ──────────────────────────────────────────────────────────── */
475
+ /** Where the chain is: the stage, and a print of what the session put off (no progress → stop). */
476
+ const fingerprint = (stage, s) => `${stage}|${s.decide?.size ?? 0}|${s.fix?.size ?? 0}|${s.prove ? 1 : 0}|${s.report ? 1 : 0}|${s.rebuild?.size ?? 0}`;
477
+ /** The newest check's rule ids per object (the kind proposal's ALV fact, F6); none when they cannot be read. */
478
+ function rulesReader() {
479
+ let byKey = null;
480
+ try {
481
+ const joined = activeProject();
482
+ if (joined)
483
+ byKey = findingsByObject(joined.projectDir, joined.outboxDir);
484
+ }
485
+ catch {
486
+ byKey = null;
487
+ }
488
+ return (key) => byKey?.get(key)?.latest.map((r) => r.rule) ?? [];
489
+ }
490
+ async function drive(h) {
491
+ const flow = getJourneyFlow(h.session);
492
+ if (!flow)
493
+ return;
494
+ const team = await teamFor(h);
495
+ const confirm = team.confirm;
496
+ const now = () => (h.now ? h.now() : new Date());
497
+ let ranInventory = false;
498
+ let saidInventory = false;
499
+ let previous = '';
500
+ for (let round = 0; round < 24; round++) {
501
+ const me = whoAmI(team.deps);
502
+ const skips = journeySkips(h.session);
503
+ const facts = readJourneyFacts(h.cwd, me, skips);
504
+ const stage = stageOf(facts);
505
+ setJourneyAt(h.session, stage);
506
+ // Ctrl-C: what was measured is kept; "carry on" picks up from the folder.
507
+ if (h.signal?.aborted) {
508
+ stopAt(h, stage);
509
+ return;
510
+ }
511
+ const print = fingerprint(stage, skips);
512
+ // A code stage that changed nothing: never go round again.
513
+ if (print === previous) {
514
+ stopAt(h, stage);
515
+ return;
516
+ }
517
+ previous = print;
518
+ let runAs = stage;
519
+ let runSkips = skips;
520
+ let startVariant;
521
+ let bigStart = false;
522
+ if (!h.headless) {
523
+ // Step 1 — "Look at the system?": no inventory yet, or one older than a week.
524
+ const inv = stage === 'START' && !ranInventory ? inventoryOf(h.cwd) : null;
525
+ const stale = !!inv && now().getTime() - Date.parse(inv.takenAt) > FRESH_DAYS * 86_400_000;
526
+ if (stage === 'INVENTORY' || stale) {
527
+ const yes = await yesNo(confirm, {
528
+ id: 'journey-look',
529
+ title: LOOK_TITLE,
530
+ facts: [
531
+ ...(stale ? [`The last inventory is from ${shownTime(inv.takenAt)}.`] : []),
532
+ 'Reads packages, objects, usage sources and the caller index.',
533
+ 'About 2 minutes.',
534
+ 'Writes nothing to SAP.',
535
+ 'Saves the inventory in this folder.',
536
+ ],
537
+ footnote: footnote('INVENTORY'),
538
+ });
539
+ if (!yes && !stale) {
540
+ end(h, NOTHING_READ_LINE);
541
+ return;
542
+ }
543
+ if (yes)
544
+ runAs = 'INVENTORY';
545
+ else {
546
+ h.emit(`Using the inventory from ${shownTime(inv.takenAt)}.`);
547
+ saidInventory = true;
548
+ }
549
+ }
550
+ else if (inv && !saidInventory) {
551
+ h.emit(`I took an inventory of ${inv.sid ?? h.sapAlias ?? 'the system'} at ${shownTime(inv.takenAt)}.`);
552
+ saidInventory = true;
553
+ }
554
+ // Step 2 — "Start the project here?", when the named packages are ones the system has.
555
+ if (runAs === 'START') {
556
+ const scope = startScope(h.cwd, flow.packages);
557
+ if (scope) {
558
+ const n = scope.objects;
559
+ bigStart = n > FIRST_CHECK_WHOLE_MAX;
560
+ const yes = await yesNo(confirm, {
561
+ id: 'journey-start',
562
+ title: START_TITLE,
563
+ facts: [
564
+ `${plural(n, 'object', 'objects')} in ${scope.names.join(', ')} in the inventory.`,
565
+ "The start checks this against SAP's own count.",
566
+ bigStart ? 'First check: one package at a time, after the start.' : `First check: ${aboutMinutes(n)}.`,
567
+ 'Saved in this folder.',
568
+ // Recording fix 5: the name every record is written under, seen before anything is written.
569
+ `You: ${shown(me)}.`,
570
+ `You own all ${n.toLocaleString('en-US')}.`,
571
+ 'Writes nothing to SAP.',
572
+ ],
573
+ footnote: footnote('START'),
574
+ });
575
+ if (!yes) {
576
+ end(h, NOT_STARTED_LINE);
577
+ h.emit(POINT_IN_TIME_LINE);
578
+ return;
579
+ }
580
+ // F16 / D5: the check is chosen visibly — never silently the default.
581
+ const chosen = await variantCard(h, team, confirm);
582
+ if (!chosen) {
583
+ end(h, NOT_STARTED_LINE);
584
+ return;
585
+ }
586
+ startVariant = chosen;
587
+ }
588
+ }
589
+ // T7 review 1: nothing measured yet, and the scope is too big for one check — never
590
+ // a status page or "Done for now." over nothing: the per-package first check is offered again.
591
+ // DECIDE too: 600 objects to decide with no check behind any of them is no step to take first.
592
+ if ((runAs === 'DECIDE' || runAs === 'REPORT' || runAs === 'DONE') && needsFirstCheck(facts)) {
593
+ if (!(await firstCheckCard(h, team, runSkips, flow.packages)))
594
+ return;
595
+ previous = '';
596
+ continue;
597
+ }
598
+ // Step 4 — "Fix <NAME> now?".
599
+ if (runAs === 'FIX' && facts.register) {
600
+ // A name that is not an ABAP name never reaches a card or a command: one line, passed over.
601
+ const bad = toFix(facts.register, me, skips).filter((o) => refactorCommand({ type: o.type, name: o.name }) === null);
602
+ if (bad.length) {
603
+ for (const o of bad)
604
+ h.emit(passedOverLine(o.name));
605
+ addJourneySkips(h.session, { fix: new Set(bad.map((o) => o.key)) });
606
+ continue;
607
+ }
608
+ const pick = await fixCard(confirm, toFix(facts.register, me, skips), skips, (o) => stillOpenLines(o.key));
609
+ if (pick.kind === 'cancel') {
610
+ pause(h, stepOf('FIX'));
611
+ return;
612
+ }
613
+ if (pick.kind === 'later') {
614
+ addJourneySkips(h.session, { fix: new Set([pick.object.key]) });
615
+ h.emit(`${pick.object.name.toUpperCase()}: left for later.`);
616
+ continue;
617
+ }
618
+ if (pick.kind === 'yes')
619
+ runSkips = pick.skips;
620
+ }
621
+ // Step 2, Task 5 (D3, F1) — at the end, the rebuild hand-off: one card
622
+ // per object or app label. Yes ends the journey, then queues the rebuild.
623
+ if (runAs === 'DONE' && facts.register) {
624
+ const groups = rebuildGroups(toRedesign(facts.register, me), skips.rebuild, rulesReader());
625
+ if (groups.passed.length) {
626
+ // A rebuild CSPeach cannot hand off is said once, with why, and not asked again.
627
+ for (const p of groups.passed)
628
+ h.emit(p.line);
629
+ addJourneySkips(h.session, { rebuild: new Set(groups.passed.map((p) => p.key)) });
630
+ }
631
+ const group = groups[0];
632
+ if (group) {
633
+ const pick = await rebuildCard(confirm, group);
634
+ if (pick.kind === 'cancel') {
635
+ pause(h);
636
+ return;
637
+ }
638
+ if (pick.kind === 'later') {
639
+ addJourneySkips(h.session, { rebuild: new Set(group.objects.map((o) => o.key ?? `${o.type}|${o.name}`)) });
640
+ h.emit(`${group.objects.length === 1 ? group.objects[0].name : group.app}: left for later.`);
641
+ continue;
642
+ }
643
+ const sid = inventoryOf(h.cwd)?.sid;
644
+ handOffRebuild({
645
+ session: h.session, cwd: h.cwd, emit: h.emit, by: me, now,
646
+ ...(h.queueNext ? { queueNext: h.queueNext } : {}), ...(sid ? { sid } : {}), callsAt: toolCallsOf(h.session).length,
647
+ }, group, pick.rebuild);
648
+ return;
649
+ }
650
+ }
651
+ // Step 3, from the second page on — "Next 40? [Yes] [Later]".
652
+ if (runAs === 'DECIDE' && facts.register && (getJourneyFlow(h.session)?.pages ?? 0) > 0) {
653
+ const left = toDecide(facts.register, me, skips);
654
+ const next = decidePage(facts.register, me, skips).length;
655
+ const res = await confirm({
656
+ id: 'journey-next-page',
657
+ tag: 'QUESTION',
658
+ title: `Next ${next.toLocaleString('en-US')}?`,
659
+ facts: [`${plural(left.length, 'object', 'objects')} left to decide.`],
660
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'later', label: 'Later' }],
661
+ initial: 0,
662
+ aliases: { y: 'yes', l: 'later' },
663
+ footnote: footnote('DECIDE'),
664
+ });
665
+ if (res.cancelled || res.value === null) {
666
+ pause(h, stepOf('DECIDE'));
667
+ return;
668
+ }
669
+ if (res.value === 'later') {
670
+ // Left out, not skipped: "carry on" brings them back to deciding.
671
+ addJourneyLeftOut(h.session, left.map((o) => o.key));
672
+ h.emit('Left for later.');
673
+ continue;
674
+ }
675
+ }
676
+ }
677
+ const r = await runJourneyStage(runAs, {
678
+ cwd: h.cwd, team, headless: h.headless, skips: runSkips, packages: flow.packages,
679
+ ...(h.openFile ? { openFile: h.openFile } : {}),
680
+ ...(startVariant ? { variant: startVariant } : {}),
681
+ });
682
+ if (runAs === 'INVENTORY')
683
+ ranInventory = true;
684
+ for (const line of r.lines)
685
+ h.emit(line);
686
+ addJourneySkips(h.session, r.skip);
687
+ // I15: a start above 500 objects measured nothing. Asked once: the first check, per package.
688
+ // `firstCheckPerPackage`: init decided from the live pull, which can be over 500 when the inventory was not (T7 review 2).
689
+ if (runAs === 'START' && (bigStart || r.firstCheckPerPackage) && r.next.kind === 'continue') {
690
+ if (!(await firstCheckCard(h, team, runSkips, flow.packages)))
691
+ return;
692
+ previous = '';
693
+ continue;
694
+ }
695
+ if (r.next.kind === 'continue') {
696
+ if (runAs !== stage)
697
+ previous = ''; // a fresh inventory is progress
698
+ continue;
699
+ }
700
+ if (r.next.kind === 'queue') {
701
+ const command = r.next.command;
702
+ if (!h.queueNext) {
703
+ end(h, `Next: ${command}`);
704
+ return;
705
+ }
706
+ const skill = /^\/(\S+)/.exec(command)[1];
707
+ if (runAs === 'DECIDE') {
708
+ setJourneyQueued(h.session, { command, skill, stage: 'DECIDE', left: facts.register ? toDecide(facts.register, me, skips).length : 0 });
709
+ // The page the triage is shown (team_findings reads it): at most 40, in the queued area.
710
+ setJourneyPage(h.session, facts.register ? decidePage(facts.register, me, skips).map((o) => o.key) : []);
711
+ }
712
+ else {
713
+ const key = r.next.key;
714
+ setJourneyQueued(h.session, { command, skill, stage: 'FIX', ...(key ? { key } : {}), callsAt: toolCallsOf(h.session).length, variant: projectVariant(h.cwd), ...targetFor(facts.register?.objects.find((o) => o.key === key)) });
715
+ }
716
+ queueStep(h, command);
717
+ return;
718
+ }
719
+ end(h); // stop or done: the runner said its last words
720
+ return;
721
+ }
722
+ pause(h);
723
+ }
724
+ /**
725
+ * runTurn's post-turn hook. A turn of the queued step's skill that moved
726
+ * nothing and ends on a question waits for the reply; one that moved nothing
727
+ * otherwise pauses the chain; anything that moved goes on.
728
+ */
729
+ export async function afterJourneyTurn(h, t) {
730
+ const q = getJourneyFlow(h.session)?.queued;
731
+ if (!q || t.skill !== q.skill || t.awaitingAnswer)
732
+ return;
733
+ if (h.headless) {
734
+ end(h);
735
+ return;
736
+ }
737
+ const team = await teamFor(h);
738
+ const me = whoAmI(team.deps);
739
+ const skips = journeySkips(h.session);
740
+ const facts = readJourneyFacts(h.cwd, me, skips);
741
+ const stage = stageOf(facts);
742
+ let moved = stage !== q.stage;
743
+ if (!moved && facts.register && q.stage === 'DECIDE')
744
+ moved = toDecide(facts.register, me, skips).length < (q.left ?? Number.MAX_SAFE_INTEGER);
745
+ if (!moved && facts.register && q.stage === 'FIX')
746
+ moved = !toFix(facts.register, me).some((o) => o.key === q.key);
747
+ if (!moved) {
748
+ if (/\?\s*$/.test(t.assistantText.trim()))
749
+ return;
750
+ if (q.stage !== 'FIX' || !q.key) {
751
+ pause(h, stepOf(q.stage));
752
+ return;
753
+ }
754
+ // Recording fix 3b: a retry of a fix that failed the check ended with
755
+ // nothing recorded. "Left for later" alone hid what was still open: one
756
+ // card says it and asks what to do.
757
+ const o = facts.register?.objects.find((x) => x.key === q.key);
758
+ // Proof first: a fix with nothing open for its own target is not offered again.
759
+ if (o && fixFailedCheck(o) && !claimedSinceCheck(o) && !provenForTarget(o)) {
760
+ setJourneyQueued(h.session, undefined);
761
+ const goOn = await retryCard(h, team, o);
762
+ if (!goOn)
763
+ return;
764
+ try {
765
+ await drive(h);
766
+ }
767
+ catch (e) {
768
+ end(h);
769
+ throw e;
770
+ }
771
+ return;
772
+ }
773
+ // The refactor ended with no fix recorded (the approval card declined, or
774
+ // nothing written): never the same object again this session — one line,
775
+ // then the next fix or the next step.
776
+ const name = q.key.slice(q.key.indexOf('|') + 1).toUpperCase();
777
+ const written = cspeachWritesThisSession(toolCallsOf(h.session).slice(q.callsAt ?? 0)).has(name);
778
+ addJourneySkips(h.session, { fix: new Set([q.key]) });
779
+ h.emit(written ? `${name}: no fix was recorded — left for later.` : `${name} was not changed — left for later.`);
780
+ }
781
+ setJourneyQueued(h.session, undefined);
782
+ try {
783
+ await drive(h);
784
+ }
785
+ catch (e) {
786
+ end(h);
787
+ throw e;
788
+ }
789
+ }
790
+ /**
791
+ * After a retry that recorded nothing: "<NAME> still has <n> open findings."
792
+ * [Try another fix] queues the refactor of the same object again; [Keep as is]
793
+ * asks "Why keep it?" and records an accepted exception at its level with that
794
+ * reason — the record the row card's Keep as is writes (team decide <T> <N>
795
+ * exception --level <L> --reason "…"); [Later] puts it off for this session.
796
+ * Returns true when the chain goes on from the folder (drive), false when it
797
+ * stopped or a command was queued.
798
+ */
799
+ async function retryCard(h, team, o) {
800
+ const name = o.name.toUpperCase();
801
+ const type = o.type.toUpperCase();
802
+ const level = o.decision?.level && LEVEL.test(o.decision.level) ? o.decision.level : undefined;
803
+ const n = o.openFindings ?? 0;
804
+ const ask = h.ask ?? journeyAsk();
805
+ for (let tries = 0; tries < 5; tries++) {
806
+ const res = await team.confirm({
807
+ id: 'journey-retry',
808
+ tag: 'QUESTION',
809
+ title: `${name} still has ${plural(n, 'open finding', 'open findings')}.`,
810
+ facts: [...stillOpenLines(o.key), 'CSPeach asks before it writes to SAP.'],
811
+ choices: [
812
+ { value: 'again', label: 'Try another fix' },
813
+ ...(level ? [{ value: 'keep', label: 'Keep as is' }] : []),
814
+ { value: 'later', label: 'Later' },
815
+ ],
816
+ initial: 0,
817
+ aliases: { t: 'again', ...(level ? { k: 'keep' } : {}), l: 'later' },
818
+ footnote: footnote('FIX'),
819
+ wrapFacts: FACT_ROWS_BESIDE_OPEN,
820
+ });
821
+ if (res.cancelled || res.value === null) {
822
+ pause(h, stepOf('FIX'));
823
+ return false;
824
+ }
825
+ if (res.value === 'later') {
826
+ addJourneySkips(h.session, { fix: new Set([o.key]) });
827
+ h.emit(`${name}: left for later.`);
828
+ return true;
829
+ }
830
+ if (res.value === 'again') {
831
+ const command = refactorCommand({ type: o.type, name: o.name, ...(o.decision?.reason ? { reason: o.decision.reason } : {}) });
832
+ if (!command) {
833
+ addJourneySkips(h.session, { fix: new Set([o.key]) });
834
+ h.emit(passedOverLine(o.name));
835
+ return true;
836
+ }
837
+ if (!h.queueNext) {
838
+ end(h, `Next: ${command}`);
839
+ return false;
840
+ }
841
+ h.emit(`Step ${stepOf('FIX')} of ${STEPS} — fix ${name} again.`);
842
+ setJourneyQueued(h.session, { command, skill: 'abap-refactor', stage: 'FIX', key: o.key, callsAt: toolCallsOf(h.session).length, variant: projectVariant(h.cwd), ...targetFor(o) });
843
+ queueStep(h, command);
844
+ return false;
845
+ }
846
+ if (res.value === 'keep' && level) {
847
+ let said;
848
+ try {
849
+ said = await ask(KEEP_QUESTION);
850
+ }
851
+ catch {
852
+ pause(h, stepOf('FIX'));
853
+ return false;
854
+ }
855
+ const reason = cleanKeepReason(said);
856
+ if (!reason) {
857
+ h.emit(`A keep needs a reason. Nothing was recorded for ${type} ${name} yet.`);
858
+ continue;
859
+ }
860
+ team.said.length = 0;
861
+ const code = await runTeamCommand(['decide', type, name, 'exception', '--level', level, '--reason', reason], team.deps).catch(() => 1);
862
+ if (code !== 0) {
863
+ const why = team.said.find((l) => l.startsWith('✗'))?.replace(/^✗\s*/, '') ?? '';
864
+ h.emit(notRecordedLine(new Error(why || 'the record could not be written to this folder')));
865
+ addJourneySkips(h.session, { fix: new Set([o.key]) });
866
+ return true;
867
+ }
868
+ h.emit(`✓ Recorded: ${shown(type)} ${shown(name)} — kept at ${level}, reason recorded.`);
869
+ return true;
870
+ }
871
+ }
872
+ addJourneySkips(h.session, { fix: new Set([o.key]) });
873
+ h.emit(`${name}: left for later.`);
874
+ return true;
875
+ }
876
+ /**
877
+ * The queued step's turn was interrupted (Ctrl-C, a dropped stream): the
878
+ * journey pauses, so "carry on" picks it up on the first try. Returns the
879
+ * line to say, or undefined when no journey step was running.
880
+ */
881
+ export function pauseJourneyForInterruptedTurn(session) {
882
+ const q = getJourneyFlow(session)?.queued;
883
+ if (!q)
884
+ return undefined;
885
+ pauseJourneyFlow(session);
886
+ return PAUSED_LINE(stepOf(q.stage));
887
+ }
888
+ /* ── the queued triage turn's prompt ────────────────────────────────────── */
889
+ /**
890
+ * Final fix wave, ruling 3 — the "no commands" note in the turn's own prompt
891
+ * (the skill treats tool strings as data, so the team_findings note alone is
892
+ * not enough; it stays as belt-and-braces).
893
+ */
894
+ export const JOURNEY_TURN_BLOCK = [
895
+ '<journey>',
896
+ "This triage is one step of CSPeach's guided clean-core run.",
897
+ 'Do not suggest any cspeach command, and do not use the words team or lane.',
898
+ 'After your table, CSPeach asks the person about each row, row by row.',
899
+ 'End with the table and the manifest. No closing instructions.',
900
+ '</journey>',
901
+ ].join('\n');
902
+ /**
903
+ * The check variant the journey proves a fix with (step 5): the project's own,
904
+ * else the one a project is started with. Never anything that is not a
905
+ * variant name — it goes into the model's prompt.
906
+ */
907
+ export function projectVariant(cwd) {
908
+ let v = '';
909
+ try {
910
+ v = (readProjectFile(cwd).atcVariant ?? '').trim().toUpperCase();
911
+ }
912
+ catch {
913
+ v = '';
914
+ }
915
+ return VARIANT_NAME.test(v) ? v : DEFAULT_ATC_VARIANT;
916
+ }
917
+ /**
918
+ * Recording fix 3 (2026-10-07) — the queued fix turn checked with ATC's
919
+ * DEFAULT variant and said done, while the journey's proof uses the project's
920
+ * variant and found the fix had failed. The turn's own prompt now names that
921
+ * variant and when the fix is done.
922
+ */
923
+ export function journeyFixBlock(variant, target) {
924
+ const v = VARIANT_NAME.test(variant) ? variant : DEFAULT_ATC_VARIANT;
925
+ // Step 2: the object's target, said only when one is set (a letter, never other words).
926
+ const t = target && LEVEL.test(target) ? target : undefined;
927
+ return [
928
+ '<journey>',
929
+ "This fix is one step of CSPeach's guided clean-core run.",
930
+ `The project's check variant is ${v}.`,
931
+ ...(t ? [`This object's target is clean-core level ${t}. CSPeach proves the fix against that target with this check.`] : []),
932
+ `After you activate the object, run ATC on it with the variant ${v}, not the default one.`,
933
+ // Final review M4: with a target other than A, findings that only matter for A may stay.
934
+ t && t !== 'A'
935
+ ? `The fix is done when ${v} shows no findings that stand in the way of level ${t}; findings that only matter for A may stay.`
936
+ : `The fix is done only when ${v} shows no findings for the object.`,
937
+ 'If findings remain, fix them and check again. For example: do not type parameters with fields of views SAP has not released; use released data elements and classes.',
938
+ 'Do at most 2 more rounds. Then say plainly what is still open.',
939
+ 'Do not suggest any cspeach command, and do not use the words team or lane.',
940
+ '</journey>',
941
+ ].join('\n');
942
+ }
943
+ /**
944
+ * The block for the queued DECIDE triage turn, and for the queued FIX
945
+ * refactor turn; '' on every other turn (the prompt stays as it was).
946
+ */
947
+ export function journeyTurnBlock(session, skill) {
948
+ const q = getJourneyFlow(session)?.queued;
949
+ if (q?.stage === 'DECIDE' && skill === 'abap-clean-core')
950
+ return JOURNEY_TURN_BLOCK;
951
+ if (q?.stage === 'FIX' && skill === 'abap-refactor')
952
+ return journeyFixBlock(q.variant ?? DEFAULT_ATC_VARIANT, q.target);
953
+ return '';
954
+ }
955
+ /* ── the gate ───────────────────────────────────────────────────────────── */
956
+ const norm = (s) => s.trim().replace(/\s+/g, ' ');
957
+ /**
958
+ * At the top of every top-level turn. The journey survives only its own step:
959
+ * the exact command it queued, or a plain reply that stays in that step's
960
+ * skill. Anything else — another slash command, a reply routed elsewhere —
961
+ * ends the chain: `line` is the one thing to say (interactive only).
962
+ */
963
+ export function gateJourneyForTurn(session, t) {
964
+ const flow = getJourneyFlow(session);
965
+ if (!flow)
966
+ return { ended: false };
967
+ const q = flow.queued;
968
+ if (q) {
969
+ if (t.explicitSlash && norm(`/${t.skill} ${t.userMessage}`) === norm(q.command))
970
+ return { ended: false };
971
+ if (!t.explicitSlash && t.skill === q.skill && t.previousSkill === q.skill)
972
+ return { ended: false };
973
+ }
974
+ // The stray message is pushed after this gate: count it as seen, so "carry
975
+ // on" right after its answer resumes, as the pause line says.
976
+ pauseJourneyFlow(session, 1);
977
+ return { ended: true, line: PAUSED_LINE(q ? stepOf(q.stage) : undefined) };
978
+ }