@cspeach/cli 1.1.30 → 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 (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -48,7 +48,7 @@ export function listWorkspaceFileChoices(opts) {
48
48
  const seen = new Set();
49
49
  // Workspace half (takes precedence on name collisions).
50
50
  try {
51
- const ws = opts?.workspace ?? ensureWorkspaceSync();
51
+ const ws = opts?.workspace ?? ensureWorkspaceSync(opts?.cwd);
52
52
  for (const f of listProjectFilesSync(ws)) {
53
53
  if (seen.has(f.filename))
54
54
  continue;
@@ -0,0 +1,25 @@
1
+ import { enterJourney } from '../journey/chain.js';
2
+ import { openStatusPage } from '../util/open-in-browser.js';
3
+ export async function runJourneyEntry(trigger, a) {
4
+ const hooks = {
5
+ session: a.session,
6
+ cwd: a.cwd,
7
+ headless: a.headless,
8
+ emit: a.emit,
9
+ ...(a.queueNext && !a.headless ? { queueNext: a.queueNext } : {}),
10
+ ...(!a.standalone && a.adt ? { adt: a.adt } : {}),
11
+ sapAlias: a.sapAlias,
12
+ ...(a.standalone ? { noSap: true } : {}),
13
+ signal: a.signal,
14
+ // Task 6 — the status page opens in the browser after Yes on its card.
15
+ openFile: (file) => openStatusPage(file),
16
+ };
17
+ try {
18
+ await enterJourney(trigger, hooks);
19
+ }
20
+ catch (err) {
21
+ if (process.env['CSPEACH_DEBUG'] === '1')
22
+ throw err;
23
+ a.emit('The clean-core steps stopped here. Say carry on to continue.');
24
+ }
25
+ }
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';
@@ -170,12 +171,14 @@ import './tools/project/convention_get.js';
170
171
  import './tools/project/cca_merge.js';
171
172
  import './tools/project/upgrade_merge_progress.js';
172
173
  import './tools/project/team_findings.js';
174
+ import './tools/project/team_estate.js';
173
175
  import './tools/subagent/_background-shared.js';
174
176
  import './tools/subagent/background_run.js';
175
177
  import './tools/subagent/monitor_emit.js';
176
178
  import './tools/subagent/schedule_draft_create.js';
177
179
  import './tools/subagent/agent_run.js';
178
180
  import './tools/subagent/read_agent.js';
181
+ import { folderTag, currentFolderKey } from './session/project-match.js';
179
182
  const HISTORY_SIZE = 500;
180
183
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
181
184
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -390,22 +393,52 @@ export async function runRepl(opts) {
390
393
  const earlyBrand = getLook() === 'panels'
391
394
  ? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
392
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 */ }
393
403
  // v0.6 — surface interrupted sessions at startup so the developer doesn't
394
404
  // have to remember the session id from the crash UX. Like `git stash list`
395
405
  // for resilience: if you have any unfinished work, you see it immediately.
396
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`.
397
409
  if (!opts?.resumedSession) {
398
410
  try {
399
- const { countInterruptedSessions } = await import('./session/store.js');
400
- 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);
401
414
  if (n > 0) {
402
415
  if (getLook() === 'panels')
403
416
  startupNotices.push(panelsInterruptedNote(n) + '\n');
404
417
  else
405
- 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));
406
426
  }
407
427
  }
408
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 */ }
409
442
  }
410
443
  // Phase 2.1 of Track B — when CSPEACH_PROJECT_CONTEXT=on the dogfooder
411
444
  // is on the experimental enrichment path. Surface that visibly so they
@@ -726,7 +759,7 @@ export async function runRepl(opts) {
726
759
  // through the same role resolution new sessions use (after the awaited
727
760
  // served config), marks it 'predicted', saves, then stays pinned. Every
728
761
  // resume entry (picker, --resume <id>) lands here via session/resume.ts.
729
- 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()));
730
763
  // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
731
764
  let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
732
765
  // Round 7 item 8c — the footer shows a resumed session's spend at once.
@@ -974,7 +1007,8 @@ export async function runRepl(opts) {
974
1007
  return;
975
1008
  }
976
1009
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
977
- 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);
978
1012
  if (notice)
979
1013
  chunkEmitter.emit('chunk', formatInfo(notice));
980
1014
  setImmediate(() => { void handleSubmit(cmd, { planHop }); });
@@ -1003,7 +1037,10 @@ export async function runRepl(opts) {
1003
1037
  // new turn's content touched with no separator and looked like
1004
1038
  // garbled overlap).
1005
1039
  if (trimmed !== '/exit' && trimmed !== '/quit') {
1006
- 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));
1007
1044
  }
1008
1045
  if (trimmed === '/exit' || trimmed === '/quit') {
1009
1046
  done = true;
@@ -1213,6 +1250,7 @@ export async function runRepl(opts) {
1213
1250
  session.todos = [];
1214
1251
  todoEmitter.emit('update', []);
1215
1252
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1253
+ clearJourneyFlow(session); // Task 3 — and a clean-core journey
1216
1254
  chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
1217
1255
  return;
1218
1256
  }
@@ -1271,6 +1309,41 @@ export async function runRepl(opts) {
1271
1309
  if (rejectUnknownSkill(parsed, (m) => chunkEmitter.emit('chunk', chalk.yellow(`\n${m}\n`)))) {
1272
1310
  return;
1273
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
+ }
1274
1347
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1275
1348
  // `parsed.prompt` rather than `trimmed` means:
1276
1349
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -2355,6 +2428,17 @@ export async function runRepl(opts) {
2355
2428
  // sits in this folder. Best-effort: never blocks the prompt.
2356
2429
  if (!isHeadless()) {
2357
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 */ }
2358
2442
  try {
2359
2443
  const { offerContinueAtStartup, inkStartupChooser } = await import('./commands/plan-startup.js');
2360
2444
  const { resolveWorkspacePath } = await import('./projects/workspace.js');
@@ -2363,6 +2447,7 @@ export async function runRepl(opts) {
2363
2447
  return;
2364
2448
  await offerContinueAtStartup({
2365
2449
  workspaceDir: ws,
2450
+ cwd: process.cwd(),
2366
2451
  sessionId: session.id,
2367
2452
  sapAlias: session.sap_system ?? undefined,
2368
2453
  choose: inkStartupChooser(),
@@ -2663,40 +2748,40 @@ export async function runRepl(opts) {
2663
2748
  registerReadline(rl, (v) => {
2664
2749
  suppressRlClose = v;
2665
2750
  }, { input: pasteInput });
2666
- // 2026-05-08: one-time workspace-default migration. The default workspace
2667
- // changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
2668
- // Existing users with files in ~/CSPeach/ get asked once how to handle
2669
- // them. After that the flag is set and this becomes a no-op.
2670
- // Wrapped in withInquirer because it opens a select() sub-prompt.
2671
- await maybePromptWorkspaceMigration({
2672
- prompt: async ({ fileCount, oldWorkspace, newWorkspace }) => withInquirer(() => select({
2673
- message: `Found ${fileCount} file(s) in ${oldWorkspace}. CSPeach now defaults to project-rooted workspaces — what should we do?`,
2674
- default: 'move',
2675
- choices: [
2676
- { value: 'move', name: `Move all to ${newWorkspace} (recommended for this project)` },
2677
- { value: 'keep_global', name: `Keep using ${oldWorkspace} as global workspace (pins config workspace_folder)` },
2678
- { value: 'leave', name: `Leave ${oldWorkspace} alone — start fresh in ${newWorkspace}` },
2679
- ],
2680
- })),
2681
- log: (line) => console.log(chalk.dim(line)),
2682
- });
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
+ }
2683
2764
  // 2026-06-15: one-time output-namespace migration. The customer-output
2684
2765
  // root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
2685
2766
  // Workspaces that still have a top-level `.abapforge/` get offered a
2686
2767
  // one-time merge-move into `.cspeach/`. The dual-read resolver keeps old
2687
2768
  // projects working if the user declines; "never" suppresses future offers.
2688
- await maybeOfferAbapforgeMigration(findProjectRoot(process.cwd()) ?? process.cwd(), {
2689
- prompt: async ({ legacyDir, newDir }) => withInquirer(() => select({
2690
- message: `Found legacy CSPeach outputs in ${legacyDir}. The output folder is now '.cspeach/'. Move them?`,
2691
- default: 'move',
2692
- choices: [
2693
- { value: 'move', name: `Move everything into ${newDir} (recommended)` },
2694
- { value: 'skip', name: `Not now — ask me again next time` },
2695
- { value: 'never', name: `Leave it and never ask again` },
2696
- ],
2697
- })),
2698
- log: (line) => console.log(chalk.dim(line)),
2699
- });
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
+ }
2700
2785
  // 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
2701
2786
  // choices, Enter = the first). Continue queues the resume for the loop.
2702
2787
  if (!isHeadless()) {
@@ -2708,6 +2793,7 @@ export async function runRepl(opts) {
2708
2793
  if (ws) {
2709
2794
  await offerContinueAtStartup({
2710
2795
  workspaceDir: ws,
2796
+ cwd: process.cwd(),
2711
2797
  sessionId: session.id,
2712
2798
  sapAlias: session.sap_system ?? undefined,
2713
2799
  choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
@@ -2743,9 +2829,13 @@ export async function runRepl(opts) {
2743
2829
  // destroys the session context.
2744
2830
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
2745
2831
  {
2746
- const n = autoRoutedNotice(dispatchedCmd);
2832
+ const n = isJourneyHop(session, dispatchedCmd) ? null : autoRoutedNotice(dispatchedCmd);
2747
2833
  if (n)
2748
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);
2749
2839
  classicPlanHop = planHops.take(dispatchedCmd);
2750
2840
  }
2751
2841
  line = dispatchedCmd;
@@ -3041,6 +3131,7 @@ export async function runRepl(opts) {
3041
3131
  session.awaitingSkillAnswer = false;
3042
3132
  session.todos = []; // P2 — /new clears the plan (spec §4)
3043
3133
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3134
+ clearJourneyFlow(session); // Task 3 — and a clean-core journey
3044
3135
  console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
3045
3136
  continue;
3046
3137
  }
@@ -3223,6 +3314,33 @@ export async function runRepl(opts) {
3223
3314
  if (rejectUnknownSkill(parsed, (m) => console.log(chalk.yellow(`\n${m}\n`)))) {
3224
3315
  continue;
3225
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
+ }
3226
3344
  // Record the prose body (post-shortcut stripping) for /reroute. Using
3227
3345
  // `parsed.prompt` rather than `trimmed` means:
3228
3346
  // - plain prose: `parsed.prompt === trimmed`, stored as-is