@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
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,8 @@ 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 } from './journey/flow.js';
25
+ import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
24
26
  import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
25
27
  import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
26
28
  import { getStore } from './auth/api-key.js';
@@ -79,10 +81,12 @@ import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
79
81
  import { pipeRevivedStdin } from './repl/reset-tty-stdin.js';
80
82
  import { logsDir } from './config/paths.js';
81
83
  import { noticeLogPathFor, pruneNoticeLogs, setNoticeLogPath } from './renderer/notice-log.js';
82
- import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
83
- import { findProjectRoot } from './projects/workspace.js';
84
+ import { maybeOfferAbapforgeMigration } from './projects/migration.js';
85
+ import { shouldOfferAbapforgeMigration } from './projects/workspace-retire.js';
84
86
  import { slashCompleter } from './repl/slash-completer.js';
85
87
  import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
88
+ import { detectRunStart } from './runs/front-door.js';
89
+ import { shadowStart, guardRunLabel } from './runs/shadow.js';
86
90
  import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
87
91
  import { atCompleter } from './repl/at-completer.js';
88
92
  import { appendHistoryEntry, readHistoryEntries } from './repl/history.js';
@@ -170,12 +174,15 @@ import './tools/project/convention_get.js';
170
174
  import './tools/project/cca_merge.js';
171
175
  import './tools/project/upgrade_merge_progress.js';
172
176
  import './tools/project/team_findings.js';
177
+ import './tools/project/team_estate.js';
178
+ import './tools/project/rules_propose.js';
173
179
  import './tools/subagent/_background-shared.js';
174
180
  import './tools/subagent/background_run.js';
175
181
  import './tools/subagent/monitor_emit.js';
176
182
  import './tools/subagent/schedule_draft_create.js';
177
183
  import './tools/subagent/agent_run.js';
178
184
  import './tools/subagent/read_agent.js';
185
+ import { folderTag, currentFolderKey } from './session/project-match.js';
179
186
  const HISTORY_SIZE = 500;
180
187
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
181
188
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -390,22 +397,52 @@ export async function runRepl(opts) {
390
397
  const earlyBrand = getLook() === 'panels'
391
398
  ? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
392
399
  : undefined;
400
+ // 2026-10-08 (Task 12) — once, tag sessions saved before folders existed so
401
+ // the lines below and the pickers see them. Both looks; capped, never throws.
402
+ try {
403
+ const { backfillSessionFolders } = await import('./session/backfill.js');
404
+ await backfillSessionFolders().catch(() => { });
405
+ }
406
+ catch { /* best effort */ }
393
407
  // v0.6 — surface interrupted sessions at startup so the developer doesn't
394
408
  // have to remember the session id from the crash UX. Like `git stash list`
395
409
  // for resilience: if you have any unfinished work, you see it immediately.
396
410
  // Skip when --resume is already in flight (the user is resuming right now).
411
+ // 2026-10-08 (sessions/folders) — both lines are scoped to THIS folder; the
412
+ // dim "Last session here" line points at `cspeach --continue`.
397
413
  if (!opts?.resumedSession) {
398
414
  try {
399
- const { countInterruptedSessions } = await import('./session/store.js');
400
- const n = await countInterruptedSessions();
415
+ const { countInterruptedSessions, latestSessionHere } = await import('./session/store.js');
416
+ const here = currentFolderKey(process.cwd());
417
+ const n = await countInterruptedSessions(here);
401
418
  if (n > 0) {
402
419
  if (getLook() === 'panels')
403
420
  startupNotices.push(panelsInterruptedNote(n) + '\n');
404
421
  else
405
- console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (picks the right one from a list)' }));
422
+ console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (this folder\'s sessions)' }));
423
+ }
424
+ const last = await latestSessionHere(here);
425
+ if (last) {
426
+ if (getLook() === 'panels')
427
+ startupNotices.push(lastSessionHereLine(last) + '\n');
428
+ else
429
+ console.log(lastSessionHereLine(last));
406
430
  }
407
431
  }
408
432
  catch { /* startup banner is best-effort */ }
433
+ // Task 11 — files left in old save locations: point to `cspeach files
434
+ // sort`, at most once per folder per day.
435
+ try {
436
+ const { legacyHintLine, claimLegacyHintToday } = await import('./projects/workspace.js');
437
+ const hint = legacyHintLine(process.cwd());
438
+ if (hint && claimLegacyHintToday(process.cwd())) {
439
+ if (getLook() === 'panels')
440
+ startupNotices.push(formatAttention({ text: hint }));
441
+ else
442
+ console.log(chalk.dim(hint));
443
+ }
444
+ }
445
+ catch { /* the hint is best-effort */ }
409
446
  }
410
447
  // Phase 2.1 of Track B — when CSPEACH_PROJECT_CONTEXT=on the dogfooder
411
448
  // is on the experimental enrichment path. Surface that visibly so they
@@ -726,7 +763,7 @@ export async function runRepl(opts) {
726
763
  // through the same role resolution new sessions use (after the awaited
727
764
  // served config), marks it 'predicted', saves, then stays pinned. Every
728
765
  // resume entry (picker, --resume <id>) lands here via session/resume.ts.
729
- const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
766
+ const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
730
767
  // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
731
768
  let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
732
769
  // Round 7 item 8c — the footer shows a resumed session's spend at once.
@@ -860,11 +897,11 @@ export async function runRepl(opts) {
860
897
  // scope so both the Ink and readline branches share the same instance.
861
898
  // Cleared at the start of every consume cycle so a stale value never
862
899
  // re-fires.
863
- let pendingDispatchValue = null;
864
- const pendingDispatchAccessor = {
865
- get: () => pendingDispatchValue,
866
- set: (cmd) => { pendingDispatchValue = cmd; },
867
- };
900
+ // Audit M-2 — the slot also carries WHO queued the command: a CSPeach source
901
+ // (journey, rebuild hand-off, app chain, plan chain) sets its origin, and both
902
+ // branches render it through queuedStepView — never as "You › <the command>".
903
+ // Consumers take() the command and its origin together.
904
+ const pendingDispatchAccessor = createDispatchSlot();
868
905
  // Task 13 fix round — the plan chain queues through planHops.queue, which
869
906
  // marks the command as an in-run hop (one progress line, no guarded
870
907
  // notice). Anything else in the slot (the /abap-app start, a skill switch)
@@ -962,7 +999,7 @@ export async function runRepl(opts) {
962
999
  /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
963
1000
  // Task 13 fix round — `planHop` travels WITH the command into handleSubmit,
964
1001
  // so a refused submit can never leave a stale hop mark behind.
965
- const autoDispatch = (cmd, planHop = false) => {
1002
+ const autoDispatch = (cmd, planHop = false, queued = null) => {
966
1003
  // Round 4 item 9b — the exit is already under way: start nothing new.
967
1004
  // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
968
1005
  const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
@@ -974,10 +1011,11 @@ export async function runRepl(opts) {
974
1011
  return;
975
1012
  }
976
1013
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
977
- const notice = autoRoutedNotice(cmd);
1014
+ // Audit M-2 — no "Auto-routed:" for any command CSPeach queued itself.
1015
+ const notice = queued ? null : autoRoutedNotice(cmd);
978
1016
  if (notice)
979
1017
  chunkEmitter.emit('chunk', formatInfo(notice));
980
- setImmediate(() => { void handleSubmit(cmd, { planHop }); });
1018
+ setImmediate(() => { void handleSubmit(cmd, { planHop, queued }); });
981
1019
  };
982
1020
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
983
1021
  // owns the single-flight reserve()/release() lifecycle. All re-entrant
@@ -1003,7 +1041,14 @@ export async function runRepl(opts) {
1003
1041
  // new turn's content touched with no separator and looked like
1004
1042
  // garbled overlap).
1005
1043
  if (trimmed !== '/exit' && trimmed !== '/quit') {
1006
- chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
1044
+ // Rehearsal fix 1 + audit M-2 — a command CSPeach queued (journey,
1045
+ // rebuild hand-off, app chain, plan chain) is its own step on screen:
1046
+ // its one line, never "You › <the command>".
1047
+ const view = queuedStepView(submitOpts?.queued);
1048
+ if (view.echo)
1049
+ chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
1050
+ else if (view.line)
1051
+ chunkEmitter.emit('chunk', `\n${view.line}\n`);
1007
1052
  }
1008
1053
  if (trimmed === '/exit' || trimmed === '/quit') {
1009
1054
  done = true;
@@ -1213,6 +1258,7 @@ export async function runRepl(opts) {
1213
1258
  session.todos = [];
1214
1259
  todoEmitter.emit('update', []);
1215
1260
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1261
+ clearJourneyFlow(session); // Task 3 — and a clean-core journey
1216
1262
  chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
1217
1263
  return;
1218
1264
  }
@@ -1237,6 +1283,19 @@ export async function runRepl(opts) {
1237
1283
  rewindEmitter.emit('open');
1238
1284
  return;
1239
1285
  }
1286
+ // /rules — the team project's decision rules (step 2, Task 10, D7): table, menu, card before every save.
1287
+ if (trimmed === '/rules' || trimmed.startsWith('/rules ')) {
1288
+ const { runRulesSlash } = await import('./repl/rules-menu.js');
1289
+ const rulesAbort = new AbortController();
1290
+ currentTurnAbort = rulesAbort;
1291
+ try {
1292
+ await runRulesSlash(trimmed, { emit: (line) => chunkEmitter.emit('chunk', line + '\n'), headless: isHeadless(), signal: rulesAbort.signal });
1293
+ }
1294
+ finally {
1295
+ currentTurnAbort = null;
1296
+ }
1297
+ return;
1298
+ }
1240
1299
  // /safety-mode on|off — structured Forge Rule confirmation cards.
1241
1300
  // UX-5 #1 (2026-06-12): handleSafetyModeCommand existed (unit-tested)
1242
1301
  // but no REPL path ever routed to it — the toggle was unreachable at
@@ -1262,6 +1321,25 @@ export async function runRepl(opts) {
1262
1321
  effectiveInput = expansion.text;
1263
1322
  }
1264
1323
  }
1324
+ // Guided runs — the one front door for Ink, classic and one-shot:
1325
+ // the /run verb (rewritten to its alias), then today's trigger. The
1326
+ // trigger still decides (spec §4 step 2); the dispatch below is unchanged.
1327
+ const door = await detectRunStart(effectiveInput, {
1328
+ cwd: process.cwd(), session, resume: true,
1329
+ // Shadow (spec §4 step 2): the trigger decided; in managed mode ask our
1330
+ // router the same text, with the shadow-only text, and count whether it
1331
+ // agrees. Not awaited; counts only; nothing on screen.
1332
+ onFreeTextStart: (text) => void shadowStart(text, 'run-clean-core', {
1333
+ classify: (p, c) => classifyPrompt(p, c, { shadowRuns: true }),
1334
+ context: { sap_system: selectedAlias, last_skill: session.last_skill ?? null, last_object: session.last_object ?? null },
1335
+ llmMode: cfg.llm?.mode,
1336
+ }),
1337
+ });
1338
+ if (door.kind === 'line') {
1339
+ chunkEmitter.emit('chunk', door.line + '\n');
1340
+ return;
1341
+ }
1342
+ effectiveInput = door.input;
1265
1343
  const parsed = parseShortcut(effectiveInput);
1266
1344
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1267
1345
  // catalog: print the notice ("did you mean" / builtin-with-args) and
@@ -1271,6 +1349,35 @@ export async function runRepl(opts) {
1271
1349
  if (rejectUnknownSkill(parsed, (m) => chunkEmitter.emit('chunk', chalk.yellow(`\n${m}\n`)))) {
1272
1350
  return;
1273
1351
  }
1352
+ // Guided clean-core journey (Task 3) — "start clean core on ZFI_AR",
1353
+ // "/abap-clean-core start ZFI_AR", "/run clean-core ZFI_AR", "carry on"
1354
+ // in a project folder. Detected above, before the classifier.
1355
+ if (door.kind === 'run') {
1356
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
1357
+ const journeyAbort = new AbortController();
1358
+ currentTurnAbort = journeyAbort;
1359
+ interruptCount = 0;
1360
+ if (pendingCancel.applyTo(journeyAbort))
1361
+ interruptCount = 1;
1362
+ turnStatusEmitter.start('clean core');
1363
+ try {
1364
+ await runJourneyEntry(door.trigger, {
1365
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
1366
+ emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1367
+ queueNext: (cmd, origin) => pendingDispatchAccessor.set(cmd, origin),
1368
+ signal: journeyAbort.signal,
1369
+ });
1370
+ }
1371
+ finally {
1372
+ turnStatusEmitter.stop();
1373
+ currentTurnAbort = null;
1374
+ postTurn = true;
1375
+ }
1376
+ const queued = pendingDispatchAccessor.take();
1377
+ if (queued)
1378
+ autoDispatch(queued.cmd, false, queued.origin);
1379
+ return;
1380
+ }
1274
1381
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1275
1382
  // `parsed.prompt` rather than `trimmed` means:
1276
1383
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -1366,6 +1473,8 @@ export async function runRepl(opts) {
1366
1473
  }
1367
1474
  return;
1368
1475
  }
1476
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
1477
+ c = guardRunLabel(c);
1369
1478
  if (c.screenshot) {
1370
1479
  chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
1371
1480
  }
@@ -1595,10 +1704,9 @@ export async function runRepl(opts) {
1595
1704
  const { clearPlanGrant } = await import('./approvals/plan-grant.js');
1596
1705
  clearPlanGrant();
1597
1706
  }
1598
- if (reauditDispatched) {
1599
- pendingDispatchAccessor.set(null);
1600
- autoDispatch(reauditDispatched, planHops.take(reauditDispatched));
1601
- }
1707
+ const reaudit = pendingDispatchAccessor.take();
1708
+ if (reaudit)
1709
+ autoDispatch(reaudit.cmd, planHops.take(reaudit.cmd), reaudit.origin);
1602
1710
  return;
1603
1711
  }
1604
1712
  // 2026-10-04 (continuous run) — a phase boundary is save + COMPACT:
@@ -2088,9 +2196,8 @@ export async function runRepl(opts) {
2088
2196
  // next-phase offer above queued the plan-resume it built itself.
2089
2197
  // Consume the slot and re-enter handleSubmit with that command —
2090
2198
  // no user keypress. The user's consent was the picker pick / y answer.
2091
- const dispatched = pendingDispatchAccessor.get();
2199
+ const dispatched = pendingDispatchAccessor.take();
2092
2200
  if (dispatched) {
2093
- pendingDispatchAccessor.set(null);
2094
2201
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2095
2202
  // called in the plan-resume prepare branch of handleSubmit) is the
2096
2203
  // single owner of the phase-boundary compaction — it runs for typed AND dispatched resumes,
@@ -2100,7 +2207,7 @@ export async function runRepl(opts) {
2100
2207
  // resolves cleanly before the next one starts. Recursion via
2101
2208
  // setImmediate keeps the call stack flat across long chains.
2102
2209
  // (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
2103
- autoDispatch(dispatched, planHops.take(dispatched));
2210
+ autoDispatch(dispatched.cmd, planHops.take(dispatched.cmd), dispatched.origin);
2104
2211
  }
2105
2212
  };
2106
2213
  // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
@@ -2355,6 +2462,17 @@ export async function runRepl(opts) {
2355
2462
  // sits in this folder. Best-effort: never blocks the prompt.
2356
2463
  if (!isHeadless()) {
2357
2464
  void (async () => {
2465
+ // 2026-10-08 — one-time offer to retire the old `workspace_folder`
2466
+ // setting, before the plan offer so the two never overlap.
2467
+ try {
2468
+ const { maybeOfferWorkspaceRetire } = await import('./projects/workspace-retire.js');
2469
+ const { inkAwareConfirm } = await import('./agent/loop.js');
2470
+ await maybeOfferWorkspaceRetire({
2471
+ confirm: async (q) => (await inkAwareConfirm(q, { default: 'y' })) === 'y',
2472
+ log: (line) => chunkEmitter.emit('chunk', line + '\n'),
2473
+ });
2474
+ }
2475
+ catch { /* the offer is best-effort */ }
2358
2476
  try {
2359
2477
  const { offerContinueAtStartup, inkStartupChooser } = await import('./commands/plan-startup.js');
2360
2478
  const { resolveWorkspacePath } = await import('./projects/workspace.js');
@@ -2363,6 +2481,7 @@ export async function runRepl(opts) {
2363
2481
  return;
2364
2482
  await offerContinueAtStartup({
2365
2483
  workspaceDir: ws,
2484
+ cwd: process.cwd(),
2366
2485
  sessionId: session.id,
2367
2486
  sapAlias: session.sap_system ?? undefined,
2368
2487
  choose: inkStartupChooser(),
@@ -2663,40 +2782,40 @@ export async function runRepl(opts) {
2663
2782
  registerReadline(rl, (v) => {
2664
2783
  suppressRlClose = v;
2665
2784
  }, { 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
- });
2785
+ // 2026-10-08: one-time offer to retire the old `workspace_folder` setting
2786
+ // (files now save in the folder you start in). Best-effort, never blocks.
2787
+ if (!isHeadless()) {
2788
+ try {
2789
+ const { maybeOfferWorkspaceRetire } = await import('./projects/workspace-retire.js');
2790
+ const { inkAwareConfirm } = await import('./agent/loop.js');
2791
+ await maybeOfferWorkspaceRetire({
2792
+ confirm: async (q) => (await inkAwareConfirm(q, { default: 'y' })) === 'y',
2793
+ log: (line) => console.log(chalk.dim(line)),
2794
+ });
2795
+ }
2796
+ catch { /* best-effort */ }
2797
+ }
2683
2798
  // 2026-06-15: one-time output-namespace migration. The customer-output
2684
2799
  // root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
2685
2800
  // Workspaces that still have a top-level `.abapforge/` get offered a
2686
2801
  // one-time merge-move into `.cspeach/`. The dual-read resolver keeps old
2687
2802
  // 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
- });
2803
+ // 2026-10-08: solo folders only — a team folder must not merge into the
2804
+ // shared team .cspeach, and a home start must not target the CLI home.
2805
+ if (shouldOfferAbapforgeMigration(process.cwd())) {
2806
+ await maybeOfferAbapforgeMigration(process.cwd(), {
2807
+ prompt: async ({ legacyDir, newDir }) => withInquirer(() => select({
2808
+ message: `Found legacy CSPeach outputs in ${legacyDir}. The output folder is now '.cspeach/'. Move them?`,
2809
+ default: 'move',
2810
+ choices: [
2811
+ { value: 'move', name: `Move everything into ${newDir} (recommended)` },
2812
+ { value: 'skip', name: `Not now — ask me again next time` },
2813
+ { value: 'never', name: `Leave it and never ask again` },
2814
+ ],
2815
+ })),
2816
+ log: (line) => console.log(chalk.dim(line)),
2817
+ });
2818
+ }
2700
2819
  // 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
2701
2820
  // choices, Enter = the first). Continue queues the resume for the loop.
2702
2821
  if (!isHeadless()) {
@@ -2708,6 +2827,7 @@ export async function runRepl(opts) {
2708
2827
  if (ws) {
2709
2828
  await offerContinueAtStartup({
2710
2829
  workspaceDir: ws,
2830
+ cwd: process.cwd(),
2711
2831
  sessionId: session.id,
2712
2832
  sapAlias: session.sap_system ?? undefined,
2713
2833
  choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
@@ -2733,19 +2853,26 @@ export async function runRepl(opts) {
2733
2853
  // consent was the picker pick, no Enter required. Cleared
2734
2854
  // immediately so a stale value never re-fires.
2735
2855
  classicPlanHop = false; // a typed line is never an in-run hop
2736
- const dispatchedCmd = pendingDispatchAccessor.get();
2737
- if (dispatchedCmd) {
2738
- pendingDispatchAccessor.set(null);
2856
+ const classicQueued = pendingDispatchAccessor.take();
2857
+ if (classicQueued) {
2858
+ const dispatchedCmd = classicQueued.cmd;
2739
2859
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2740
2860
  // called in the plan-resume prepare branch below) is the single owner
2741
2861
  // of the phase-boundary compaction — it runs for typed AND dispatched resumes, and
2742
2862
  // only AFTER preparePlanResume succeeds, so a failed prepare never
2743
2863
  // destroys the session context.
2744
2864
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
2865
+ // Audit M-2 — the Ink mirror: a command CSPeach queued is its own step
2866
+ // (its line, or nothing when its source already said it), never "Auto-routed:".
2745
2867
  {
2746
- const n = autoRoutedNotice(dispatchedCmd);
2747
- if (n)
2748
- console.log(formatInfo(n).trimEnd());
2868
+ const view = queuedStepView(classicQueued.origin);
2869
+ if (view.echo) {
2870
+ const n = autoRoutedNotice(dispatchedCmd);
2871
+ if (n)
2872
+ console.log(formatInfo(n).trimEnd());
2873
+ }
2874
+ else if (view.line)
2875
+ console.log(view.line);
2749
2876
  classicPlanHop = planHops.take(dispatchedCmd);
2750
2877
  }
2751
2878
  line = dispatchedCmd;
@@ -3041,6 +3168,7 @@ export async function runRepl(opts) {
3041
3168
  session.awaitingSkillAnswer = false;
3042
3169
  session.todos = []; // P2 — /new clears the plan (spec §4)
3043
3170
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3171
+ clearJourneyFlow(session); // Task 3 — and a clean-core journey
3044
3172
  console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
3045
3173
  continue;
3046
3174
  }
@@ -3051,6 +3179,12 @@ export async function runRepl(opts) {
3051
3179
  console.log(chalk.cyan(msg));
3052
3180
  continue;
3053
3181
  }
3182
+ // /rules — the team project's decision rules (step 2, Task 10, D7); cards are typed lines here.
3183
+ if (trimmed === '/rules' || trimmed.startsWith('/rules ')) {
3184
+ const { runRulesSlash } = await import('./repl/rules-menu.js');
3185
+ await runRulesSlash(trimmed, { emit: (line) => console.log(line), headless: isHeadless(), signal: new AbortController().signal });
3186
+ continue;
3187
+ }
3054
3188
  // Task 3 (ux-wave3) — classic-mode /rewind: numbered inquirer list + confirm
3055
3189
  // + gated restore. Reuses the identical candidate collector and executor as
3056
3190
  // the Ink panel (executeRestore); only the surface differs.
@@ -3171,7 +3305,8 @@ export async function runRepl(opts) {
3171
3305
  // Pressing Enter twice used to clear, but users expected Enter
3172
3306
  // to be a no-op when there's nothing to submit, so the
3173
3307
  // double-Enter cancel was confusing.
3174
- if (resolvedTrimmed.startsWith('/')) {
3308
+ // A bare /run falls through to its usage line below, like /help (M-1).
3309
+ if (resolvedTrimmed.startsWith('/') && !/^\/run$/i.test(resolvedTrimmed)) {
3175
3310
  const splitIdx = resolvedTrimmed.search(/\s/);
3176
3311
  const bodyAfter = splitIdx === -1 ? '' : resolvedTrimmed.slice(splitIdx).trim();
3177
3312
  if (bodyAfter.length === 0) {
@@ -3214,6 +3349,23 @@ export async function runRepl(opts) {
3214
3349
  resolvedTrimmed = expansion.text;
3215
3350
  }
3216
3351
  }
3352
+ // Guided runs — the same front door as the Ink path: the /run verb
3353
+ // (rewritten to its alias), then today's trigger. The trigger still
3354
+ // decides (spec §4 step 2); the dispatch below is unchanged.
3355
+ const door = await detectRunStart(resolvedTrimmed, {
3356
+ cwd: process.cwd(), session, resume: true,
3357
+ // Shadow (spec §4 step 2): same as the Ink path. Not awaited; counts only.
3358
+ onFreeTextStart: (text) => void shadowStart(text, 'run-clean-core', {
3359
+ classify: (p, c) => classifyPrompt(p, c, { shadowRuns: true }),
3360
+ context: { sap_system: selectedAlias, last_skill: session.last_skill ?? null, last_object: session.last_object ?? null },
3361
+ llmMode: cfg.llm?.mode,
3362
+ }),
3363
+ });
3364
+ if (door.kind === 'line') {
3365
+ console.log(door.line);
3366
+ continue;
3367
+ }
3368
+ resolvedTrimmed = door.input;
3217
3369
  const parsed = parseShortcut(resolvedTrimmed);
3218
3370
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
3219
3371
  // catalog: print the notice ("did you mean" / builtin-with-args) and
@@ -3223,6 +3375,29 @@ export async function runRepl(opts) {
3223
3375
  if (rejectUnknownSkill(parsed, (m) => console.log(chalk.yellow(`\n${m}\n`)))) {
3224
3376
  continue;
3225
3377
  }
3378
+ // Guided clean-core journey (Task 3) — classic mirror of the Ink entry.
3379
+ // Cards become one typed line here (no card listener); a queued step is
3380
+ // picked up by the loop-top dispatch consumer.
3381
+ if (door.kind === 'run') {
3382
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
3383
+ const journeyAbort = new AbortController();
3384
+ const onJourneySigint = () => { journeyAbort.abort(); };
3385
+ turnInProgress = true;
3386
+ rl.on('SIGINT', onJourneySigint);
3387
+ try {
3388
+ await runJourneyEntry(door.trigger, {
3389
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
3390
+ emit: (line) => console.log(line),
3391
+ queueNext: (cmd, origin) => pendingDispatchAccessor.set(cmd, origin),
3392
+ signal: journeyAbort.signal,
3393
+ });
3394
+ }
3395
+ finally {
3396
+ rl.removeListener('SIGINT', onJourneySigint);
3397
+ turnInProgress = false;
3398
+ }
3399
+ continue;
3400
+ }
3226
3401
  // Record the prose body (post-shortcut stripping) for /reroute. Using
3227
3402
  // `parsed.prompt` rather than `trimmed` means:
3228
3403
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -3282,6 +3457,8 @@ export async function runRepl(opts) {
3282
3457
  throw err;
3283
3458
  c = fallback;
3284
3459
  }
3460
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
3461
+ c = guardRunLabel(c);
3285
3462
  if (c.screenshot)
3286
3463
  console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
3287
3464
  // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
@@ -94,7 +94,7 @@ function transportKindOf(err) {
94
94
  return 'timeout';
95
95
  return 'network';
96
96
  }
97
- async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal) {
97
+ async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal, shadowRuns = false) {
98
98
  // Review I2 — a cancel that landed before this call (e.g. while prompt
99
99
  // images were read) never fires the 'abort' event: honour it here.
100
100
  if (signal?.aborted)
@@ -111,7 +111,10 @@ async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal)
111
111
  'Authorization': `Bearer ${key}`,
112
112
  'Content-Type': 'application/json',
113
113
  },
114
- body: JSON.stringify(images.length > 0 ? { prompt, context, images } : { prompt, context }),
114
+ // Guided runs (spec §4 step 2): a shadow call asks the proxy for the
115
+ // shadow-only router text; a live call's body is unchanged.
116
+ body: JSON.stringify(shadowRuns ? { prompt, context, shadow: 'runs' }
117
+ : images.length > 0 ? { prompt, context, images } : { prompt, context }),
115
118
  signal: ctrl.signal,
116
119
  });
117
120
  }
@@ -188,7 +191,8 @@ export async function classifyPrompt(prompt, context, opts = {}) {
188
191
  throw new Error('no_api_key');
189
192
  if (opts.signal?.aborted)
190
193
  throw routingCancelled();
191
- const images = await promptImages(prompt, opts.cwd ?? process.cwd());
194
+ // A shadow call never sends images (spec §4 step 2): text only.
195
+ const images = opts.shadowRuns ? [] : await promptImages(prompt, opts.cwd ?? process.cwd());
192
196
  if (opts.signal?.aborted)
193
197
  throw routingCancelled();
194
198
  let r;
@@ -201,7 +205,7 @@ export async function classifyPrompt(prompt, context, opts = {}) {
201
205
  // they bubble through as before.
202
206
  for (let attempt = 0; attempt < 2; attempt++) {
203
207
  try {
204
- r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal);
208
+ r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal, opts.shadowRuns === true);
205
209
  break;
206
210
  }
207
211
  catch (err) {