@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
package/dist/repl.js CHANGED
@@ -10,7 +10,7 @@ import { select, password as promptPassword } from './repl/themed-prompts.js';
10
10
  import { loadConfig, resolveLocalBuild, resolveStandalone, DEFAULT_CONFIG } from './config/loader.js';
11
11
  import { getLook, setLook, setBackground } from './renderer/look.js';
12
12
  import { applyColorMode } from './renderer/color-mode.js';
13
- import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, panelsStartTail, panelsInterruptedNote, formatPromptEcho } from './renderer/startup-lines.js';
13
+ import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, panelsStartTail, panelsInterruptedNote, lastSessionHereLine, formatPromptEcho } from './renderer/startup-lines.js';
14
14
  import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
15
15
  import { formatPanel } from './renderer/panel-rows.js';
16
16
  import { sanitizeControlChars } from './renderer/sanitize.js';
@@ -21,6 +21,7 @@ import { createOfflineAdtClient } from './sap/offline-adt-client.js';
21
21
  import { STANDALONE_ALIAS } from './tools/index.js';
22
22
  import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-standalone.js';
23
23
  import { clearAppFlow } from './lane/app-flow.js';
24
+ import { clearJourneyFlow, isJourneyHop, journeyHopLine } from './journey/flow.js';
24
25
  import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
25
26
  import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
26
27
  import { getStore } from './auth/api-key.js';
@@ -79,8 +80,8 @@ import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
79
80
  import { pipeRevivedStdin } from './repl/reset-tty-stdin.js';
80
81
  import { logsDir } from './config/paths.js';
81
82
  import { noticeLogPathFor, pruneNoticeLogs, setNoticeLogPath } from './renderer/notice-log.js';
82
- import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
83
- import { findProjectRoot } from './projects/workspace.js';
83
+ import { maybeOfferAbapforgeMigration } from './projects/migration.js';
84
+ import { shouldOfferAbapforgeMigration } from './projects/workspace-retire.js';
84
85
  import { slashCompleter } from './repl/slash-completer.js';
85
86
  import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
86
87
  import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
@@ -169,12 +170,15 @@ import './tools/project/playbook_get.js';
169
170
  import './tools/project/convention_get.js';
170
171
  import './tools/project/cca_merge.js';
171
172
  import './tools/project/upgrade_merge_progress.js';
173
+ import './tools/project/team_findings.js';
174
+ import './tools/project/team_estate.js';
172
175
  import './tools/subagent/_background-shared.js';
173
176
  import './tools/subagent/background_run.js';
174
177
  import './tools/subagent/monitor_emit.js';
175
178
  import './tools/subagent/schedule_draft_create.js';
176
179
  import './tools/subagent/agent_run.js';
177
180
  import './tools/subagent/read_agent.js';
181
+ import { folderTag, currentFolderKey } from './session/project-match.js';
178
182
  const HISTORY_SIZE = 500;
179
183
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
180
184
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -389,22 +393,52 @@ export async function runRepl(opts) {
389
393
  const earlyBrand = getLook() === 'panels'
390
394
  ? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
391
395
  : undefined;
396
+ // 2026-10-08 (Task 12) — once, tag sessions saved before folders existed so
397
+ // the lines below and the pickers see them. Both looks; capped, never throws.
398
+ try {
399
+ const { backfillSessionFolders } = await import('./session/backfill.js');
400
+ await backfillSessionFolders().catch(() => { });
401
+ }
402
+ catch { /* best effort */ }
392
403
  // v0.6 — surface interrupted sessions at startup so the developer doesn't
393
404
  // have to remember the session id from the crash UX. Like `git stash list`
394
405
  // for resilience: if you have any unfinished work, you see it immediately.
395
406
  // Skip when --resume is already in flight (the user is resuming right now).
407
+ // 2026-10-08 (sessions/folders) — both lines are scoped to THIS folder; the
408
+ // dim "Last session here" line points at `cspeach --continue`.
396
409
  if (!opts?.resumedSession) {
397
410
  try {
398
- const { countInterruptedSessions } = await import('./session/store.js');
399
- const n = await countInterruptedSessions();
411
+ const { countInterruptedSessions, latestSessionHere } = await import('./session/store.js');
412
+ const here = currentFolderKey(process.cwd());
413
+ const n = await countInterruptedSessions(here);
400
414
  if (n > 0) {
401
415
  if (getLook() === 'panels')
402
416
  startupNotices.push(panelsInterruptedNote(n) + '\n');
403
417
  else
404
- console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (picks the right one from a list)' }));
418
+ console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (this folder\'s sessions)' }));
419
+ }
420
+ const last = await latestSessionHere(here);
421
+ if (last) {
422
+ if (getLook() === 'panels')
423
+ startupNotices.push(lastSessionHereLine(last) + '\n');
424
+ else
425
+ console.log(lastSessionHereLine(last));
405
426
  }
406
427
  }
407
428
  catch { /* startup banner is best-effort */ }
429
+ // Task 11 — files left in old save locations: point to `cspeach files
430
+ // sort`, at most once per folder per day.
431
+ try {
432
+ const { legacyHintLine, claimLegacyHintToday } = await import('./projects/workspace.js');
433
+ const hint = legacyHintLine(process.cwd());
434
+ if (hint && claimLegacyHintToday(process.cwd())) {
435
+ if (getLook() === 'panels')
436
+ startupNotices.push(formatAttention({ text: hint }));
437
+ else
438
+ console.log(chalk.dim(hint));
439
+ }
440
+ }
441
+ catch { /* the hint is best-effort */ }
408
442
  }
409
443
  // Phase 2.1 of Track B — when CSPEACH_PROJECT_CONTEXT=on the dogfooder
410
444
  // is on the experimental enrichment path. Surface that visibly so they
@@ -725,7 +759,7 @@ export async function runRepl(opts) {
725
759
  // through the same role resolution new sessions use (after the awaited
726
760
  // served config), marks it 'predicted', saves, then stays pinned. Every
727
761
  // resume entry (picker, --resume <id>) lands here via session/resume.ts.
728
- const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
762
+ const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
729
763
  // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
730
764
  let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
731
765
  // Round 7 item 8c — the footer shows a resumed session's spend at once.
@@ -973,7 +1007,8 @@ export async function runRepl(opts) {
973
1007
  return;
974
1008
  }
975
1009
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
976
- const notice = autoRoutedNotice(cmd);
1010
+ // Journey hops (Task 3) — the journey already said its step line.
1011
+ const notice = isJourneyHop(session, cmd) ? null : autoRoutedNotice(cmd);
977
1012
  if (notice)
978
1013
  chunkEmitter.emit('chunk', formatInfo(notice));
979
1014
  setImmediate(() => { void handleSubmit(cmd, { planHop }); });
@@ -1002,7 +1037,10 @@ export async function runRepl(opts) {
1002
1037
  // new turn's content touched with no separator and looked like
1003
1038
  // garbled overlap).
1004
1039
  if (trimmed !== '/exit' && trimmed !== '/quit') {
1005
- chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
1040
+ // Rehearsal fix 1 — a journey hop was queued by CSPeach, not typed: one
1041
+ // plain journey line, never "You › <the command>".
1042
+ const hopLine = journeyHopLine(session, trimmed);
1043
+ chunkEmitter.emit('chunk', hopLine ? `\n${hopLine}\n` : formatPromptEcho(trimmed, process.stdout.columns || 80));
1006
1044
  }
1007
1045
  if (trimmed === '/exit' || trimmed === '/quit') {
1008
1046
  done = true;
@@ -1212,6 +1250,7 @@ export async function runRepl(opts) {
1212
1250
  session.todos = [];
1213
1251
  todoEmitter.emit('update', []);
1214
1252
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1253
+ clearJourneyFlow(session); // Task 3 — and a clean-core journey
1215
1254
  chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
1216
1255
  return;
1217
1256
  }
@@ -1270,6 +1309,41 @@ export async function runRepl(opts) {
1270
1309
  if (rejectUnknownSkill(parsed, (m) => chunkEmitter.emit('chunk', chalk.yellow(`\n${m}\n`)))) {
1271
1310
  return;
1272
1311
  }
1312
+ // Guided clean-core journey (Task 3) — "start clean core on ZFI_AR",
1313
+ // "/abap-clean-core start ZFI_AR", "carry on" in a project folder.
1314
+ // Detected here, before the classifier; any other line is untouched.
1315
+ {
1316
+ const { detectJourney } = await import('./journey/trigger.js');
1317
+ const journey = detectJourney(effectiveInput, { cwd: process.cwd(), session });
1318
+ if (journey) {
1319
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
1320
+ const journeyAbort = new AbortController();
1321
+ currentTurnAbort = journeyAbort;
1322
+ interruptCount = 0;
1323
+ if (pendingCancel.applyTo(journeyAbort))
1324
+ interruptCount = 1;
1325
+ turnStatusEmitter.start('clean core');
1326
+ try {
1327
+ await runJourneyEntry(journey, {
1328
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
1329
+ emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1330
+ queueNext: (cmd) => pendingDispatchAccessor.set(cmd),
1331
+ signal: journeyAbort.signal,
1332
+ });
1333
+ }
1334
+ finally {
1335
+ turnStatusEmitter.stop();
1336
+ currentTurnAbort = null;
1337
+ postTurn = true;
1338
+ }
1339
+ const queued = pendingDispatchAccessor.get();
1340
+ if (queued) {
1341
+ pendingDispatchAccessor.set(null);
1342
+ autoDispatch(queued);
1343
+ }
1344
+ return;
1345
+ }
1346
+ }
1273
1347
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1274
1348
  // `parsed.prompt` rather than `trimmed` means:
1275
1349
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -2354,6 +2428,17 @@ export async function runRepl(opts) {
2354
2428
  // sits in this folder. Best-effort: never blocks the prompt.
2355
2429
  if (!isHeadless()) {
2356
2430
  void (async () => {
2431
+ // 2026-10-08 — one-time offer to retire the old `workspace_folder`
2432
+ // setting, before the plan offer so the two never overlap.
2433
+ try {
2434
+ const { maybeOfferWorkspaceRetire } = await import('./projects/workspace-retire.js');
2435
+ const { inkAwareConfirm } = await import('./agent/loop.js');
2436
+ await maybeOfferWorkspaceRetire({
2437
+ confirm: async (q) => (await inkAwareConfirm(q, { default: 'y' })) === 'y',
2438
+ log: (line) => chunkEmitter.emit('chunk', line + '\n'),
2439
+ });
2440
+ }
2441
+ catch { /* the offer is best-effort */ }
2357
2442
  try {
2358
2443
  const { offerContinueAtStartup, inkStartupChooser } = await import('./commands/plan-startup.js');
2359
2444
  const { resolveWorkspacePath } = await import('./projects/workspace.js');
@@ -2362,6 +2447,7 @@ export async function runRepl(opts) {
2362
2447
  return;
2363
2448
  await offerContinueAtStartup({
2364
2449
  workspaceDir: ws,
2450
+ cwd: process.cwd(),
2365
2451
  sessionId: session.id,
2366
2452
  sapAlias: session.sap_system ?? undefined,
2367
2453
  choose: inkStartupChooser(),
@@ -2662,40 +2748,40 @@ export async function runRepl(opts) {
2662
2748
  registerReadline(rl, (v) => {
2663
2749
  suppressRlClose = v;
2664
2750
  }, { input: pasteInput });
2665
- // 2026-05-08: one-time workspace-default migration. The default workspace
2666
- // changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
2667
- // Existing users with files in ~/CSPeach/ get asked once how to handle
2668
- // them. After that the flag is set and this becomes a no-op.
2669
- // Wrapped in withInquirer because it opens a select() sub-prompt.
2670
- await maybePromptWorkspaceMigration({
2671
- prompt: async ({ fileCount, oldWorkspace, newWorkspace }) => withInquirer(() => select({
2672
- message: `Found ${fileCount} file(s) in ${oldWorkspace}. CSPeach now defaults to project-rooted workspaces — what should we do?`,
2673
- default: 'move',
2674
- choices: [
2675
- { value: 'move', name: `Move all to ${newWorkspace} (recommended for this project)` },
2676
- { value: 'keep_global', name: `Keep using ${oldWorkspace} as global workspace (pins config workspace_folder)` },
2677
- { value: 'leave', name: `Leave ${oldWorkspace} alone — start fresh in ${newWorkspace}` },
2678
- ],
2679
- })),
2680
- log: (line) => console.log(chalk.dim(line)),
2681
- });
2751
+ // 2026-10-08: one-time offer to retire the old `workspace_folder` setting
2752
+ // (files now save in the folder you start in). Best-effort, never blocks.
2753
+ if (!isHeadless()) {
2754
+ try {
2755
+ const { maybeOfferWorkspaceRetire } = await import('./projects/workspace-retire.js');
2756
+ const { inkAwareConfirm } = await import('./agent/loop.js');
2757
+ await maybeOfferWorkspaceRetire({
2758
+ confirm: async (q) => (await inkAwareConfirm(q, { default: 'y' })) === 'y',
2759
+ log: (line) => console.log(chalk.dim(line)),
2760
+ });
2761
+ }
2762
+ catch { /* best-effort */ }
2763
+ }
2682
2764
  // 2026-06-15: one-time output-namespace migration. The customer-output
2683
2765
  // root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
2684
2766
  // Workspaces that still have a top-level `.abapforge/` get offered a
2685
2767
  // one-time merge-move into `.cspeach/`. The dual-read resolver keeps old
2686
2768
  // projects working if the user declines; "never" suppresses future offers.
2687
- await maybeOfferAbapforgeMigration(findProjectRoot(process.cwd()) ?? process.cwd(), {
2688
- prompt: async ({ legacyDir, newDir }) => withInquirer(() => select({
2689
- message: `Found legacy CSPeach outputs in ${legacyDir}. The output folder is now '.cspeach/'. Move them?`,
2690
- default: 'move',
2691
- choices: [
2692
- { value: 'move', name: `Move everything into ${newDir} (recommended)` },
2693
- { value: 'skip', name: `Not now — ask me again next time` },
2694
- { value: 'never', name: `Leave it and never ask again` },
2695
- ],
2696
- })),
2697
- log: (line) => console.log(chalk.dim(line)),
2698
- });
2769
+ // 2026-10-08: solo folders only — a team folder must not merge into the
2770
+ // shared team .cspeach, and a home start must not target the CLI home.
2771
+ if (shouldOfferAbapforgeMigration(process.cwd())) {
2772
+ await maybeOfferAbapforgeMigration(process.cwd(), {
2773
+ prompt: async ({ legacyDir, newDir }) => withInquirer(() => select({
2774
+ message: `Found legacy CSPeach outputs in ${legacyDir}. The output folder is now '.cspeach/'. Move them?`,
2775
+ default: 'move',
2776
+ choices: [
2777
+ { value: 'move', name: `Move everything into ${newDir} (recommended)` },
2778
+ { value: 'skip', name: `Not now — ask me again next time` },
2779
+ { value: 'never', name: `Leave it and never ask again` },
2780
+ ],
2781
+ })),
2782
+ log: (line) => console.log(chalk.dim(line)),
2783
+ });
2784
+ }
2699
2785
  // 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
2700
2786
  // choices, Enter = the first). Continue queues the resume for the loop.
2701
2787
  if (!isHeadless()) {
@@ -2707,6 +2793,7 @@ export async function runRepl(opts) {
2707
2793
  if (ws) {
2708
2794
  await offerContinueAtStartup({
2709
2795
  workspaceDir: ws,
2796
+ cwd: process.cwd(),
2710
2797
  sessionId: session.id,
2711
2798
  sapAlias: session.sap_system ?? undefined,
2712
2799
  choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
@@ -2742,9 +2829,13 @@ export async function runRepl(opts) {
2742
2829
  // destroys the session context.
2743
2830
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
2744
2831
  {
2745
- const n = autoRoutedNotice(dispatchedCmd);
2832
+ const n = isJourneyHop(session, dispatchedCmd) ? null : autoRoutedNotice(dispatchedCmd);
2746
2833
  if (n)
2747
2834
  console.log(formatInfo(n).trimEnd());
2835
+ // Rehearsal fix 1 — the journey hop says what CSPeach is doing (the Ink mirror).
2836
+ const hop = journeyHopLine(session, dispatchedCmd);
2837
+ if (hop)
2838
+ console.log(hop);
2748
2839
  classicPlanHop = planHops.take(dispatchedCmd);
2749
2840
  }
2750
2841
  line = dispatchedCmd;
@@ -3040,6 +3131,7 @@ export async function runRepl(opts) {
3040
3131
  session.awaitingSkillAnswer = false;
3041
3132
  session.todos = []; // P2 — /new clears the plan (spec §4)
3042
3133
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3134
+ clearJourneyFlow(session); // Task 3 — and a clean-core journey
3043
3135
  console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
3044
3136
  continue;
3045
3137
  }
@@ -3222,6 +3314,33 @@ export async function runRepl(opts) {
3222
3314
  if (rejectUnknownSkill(parsed, (m) => console.log(chalk.yellow(`\n${m}\n`)))) {
3223
3315
  continue;
3224
3316
  }
3317
+ // Guided clean-core journey (Task 3) — classic mirror of the Ink entry.
3318
+ // Cards become one typed line here (no card listener); a queued step is
3319
+ // picked up by the loop-top dispatch consumer.
3320
+ {
3321
+ const { detectJourney } = await import('./journey/trigger.js');
3322
+ const journey = detectJourney(resolvedTrimmed, { cwd: process.cwd(), session });
3323
+ if (journey) {
3324
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
3325
+ const journeyAbort = new AbortController();
3326
+ const onJourneySigint = () => { journeyAbort.abort(); };
3327
+ turnInProgress = true;
3328
+ rl.on('SIGINT', onJourneySigint);
3329
+ try {
3330
+ await runJourneyEntry(journey, {
3331
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
3332
+ emit: (line) => console.log(line),
3333
+ queueNext: (cmd) => pendingDispatchAccessor.set(cmd),
3334
+ signal: journeyAbort.signal,
3335
+ });
3336
+ }
3337
+ finally {
3338
+ rl.removeListener('SIGINT', onJourneySigint);
3339
+ turnInProgress = false;
3340
+ }
3341
+ continue;
3342
+ }
3343
+ }
3225
3344
  // Record the prose body (post-shortcut stripping) for /reroute. Using
3226
3345
  // `parsed.prompt` rather than `trimmed` means:
3227
3346
  // - plain prose: `parsed.prompt === trimmed`, stored as-is