@cspeach/cli 0.6.0

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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Readline ↔ inquirer hand-off guard.
3
+ *
4
+ * Why this exists: repl.tsx runs a long-lived `readline` interface for the
5
+ * main CSPeach prompt. Tools (`ask_question`, coaching picker, approval
6
+ * prompt) use `@inquirer/prompts`, which grabs raw stdin for the duration
7
+ * of the sub-prompt and then releases it. When inquirer releases, the
8
+ * underlying readline sometimes receives a spurious `close` — we've
9
+ * observed the CLI drop back to the shell mid-session with no Ctrl-D and
10
+ * no `/exit`. The safe pattern proven for the coaching picker is:
11
+ *
12
+ * 1. set a suppress-close flag so the readline 'close' handler is a no-op
13
+ * 2. pause() the readline so it doesn't fight inquirer for stdin
14
+ * 3. run the inquirer prompt
15
+ * 4. resume() the readline
16
+ * 5. clear the flag on process.nextTick (readline emits 'close' synchronously
17
+ * after release, so we must wait a tick before re-arming)
18
+ *
19
+ * This module exposes that pattern as a single `withInquirer(fn)` helper
20
+ * so tool handlers don't each have to know about the internal readline or
21
+ * the suppress flag. repl.tsx calls `registerReadline(rl, setSuppress)`
22
+ * once at startup; tools call `withInquirer(() => input({...}))`.
23
+ *
24
+ * When CSPeach runs outside a registered REPL (one-shot mode, tests,
25
+ * Ink-path entry before registration), `withInquirer` just runs the
26
+ * function — no pause, no suppress — which is safe because there is no
27
+ * main readline to disturb.
28
+ */
29
+ import { clearActiveSpinner } from '../renderer/tool-widget.js';
30
+ let state = null;
31
+ /**
32
+ * Called by repl.tsx once the main readline is created. The `setSuppress`
33
+ * callback must flip the same boolean the readline 'close' handler
34
+ * inspects — otherwise the guard has no effect.
35
+ */
36
+ export function registerReadline(rl, setSuppress) {
37
+ state = { rl, setSuppress };
38
+ }
39
+ /** Clear the guard — call when the REPL is shutting down. */
40
+ export function unregisterReadline() {
41
+ state = null;
42
+ }
43
+ /**
44
+ * Run `fn` with the main readline paused and its 'close' exit suppressed.
45
+ * Safe to call when no readline is registered (no-op guard).
46
+ *
47
+ * Critical fix 2026-05-01: after inquirer cleanly relinquishes stdin, the
48
+ * underlying `process.stdin` ends up "unreffed" — paused with no flowing
49
+ * listener that holds an event-loop ref. The next `rl.question()` call
50
+ * prints its prompt and then the Node event loop runs out of refs and
51
+ * exits SILENTLY (no 'close' event, no question rejection — process just
52
+ * exits). This is what made CSPeach drop back to the shell after every
53
+ * skill-picker / approval / ask_question prompt.
54
+ *
55
+ * Calling `process.stdin.resume()` in the finally re-attaches stdin to
56
+ * the flowing event-loop ref so the next `rl.question` actually waits.
57
+ * The ANSI cursor toggle calls inquirer makes during cleanup are what
58
+ * leave stdin paused; resume() reverses that.
59
+ */
60
+ export async function withInquirer(fn) {
61
+ if (!state)
62
+ return fn();
63
+ const { rl, setSuppress } = state;
64
+ // 2026-05-01: silence any active tool-dispatch spinner so it doesn't fight
65
+ // inquirer for stdout/cursor. The spinner uses \r + erase-line every 80ms,
66
+ // which collides with inquirer's prompt rendering — visible as ghost-text
67
+ // and a cursor that hides under the rotating frame while the user types.
68
+ clearActiveSpinner();
69
+ // 2026-05-08: snapshot stdin 'data' / 'keypress' listeners BEFORE the
70
+ // inquirer prompt runs. Some inquirer widgets (notably `search`, used
71
+ // by /files step 1) strip ALL listeners on cleanup — including
72
+ // readline's own input handler. Without re-attach, the next
73
+ // rl.question() shows its prompt with terminal echo on but ignores
74
+ // Enter (no input handler is wired). Surfaced after Diff 8 stopped
75
+ // hiding this with an immediate "Goodbye" exit.
76
+ const beforeData = process.stdin.listeners('data').slice();
77
+ const beforeKeypress = process.stdin.listeners('keypress').slice();
78
+ setSuppress(true);
79
+ rl.pause();
80
+ // 2026-05-08: explicitly engage raw mode BEFORE the inquirer prompt
81
+ // renders. Inquirer's select() / search() / input() do this internally
82
+ // too, but on Windows + post-readline-pause there's a timing window
83
+ // where the first keystroke arrives in cooked mode and is lost — the
84
+ // user sees the prompt with `❯ option` already highlighted, presses an
85
+ // arrow, nothing happens, presses Enter, and only THEN do the arrows
86
+ // start working (the first Enter flushes the cooked-mode buffer and
87
+ // hits inquirer's now-armed keypress listener). Live feedback: "for
88
+ // some reason on this question I have to enter 'enter' first to start
89
+ // using my arrows". Arming raw mode pre-render closes the window.
90
+ //
91
+ // We don't restore in `finally`: inquirer takes its own snapshot of
92
+ // isRaw at its start (which is now true after our setRawMode call)
93
+ // and restores TO that value on exit, leaving raw mode on as it
94
+ // intends — matching what every working "consecutive inquirer prompt"
95
+ // path already does (e.g. /files step 1 → step 2). rl.resume() in
96
+ // finally will re-engage readline keypress mode on its own.
97
+ if (process.stdin.isTTY && !process.stdin.isRaw) {
98
+ process.stdin.setRawMode(true);
99
+ }
100
+ try {
101
+ return await fn();
102
+ }
103
+ finally {
104
+ // Re-attach data / keypress listeners that inquirer removed but
105
+ // didn't replace. The has() check avoids double-binding any
106
+ // listener inquirer DID restore (different instance is OK; the
107
+ // same function reference is the only thing we re-add).
108
+ const afterData = new Set(process.stdin.listeners('data'));
109
+ for (const lf of beforeData) {
110
+ if (!afterData.has(lf)) {
111
+ process.stdin.on('data', lf);
112
+ }
113
+ }
114
+ const afterKeypress = new Set(process.stdin.listeners('keypress'));
115
+ for (const lf of beforeKeypress) {
116
+ if (!afterKeypress.has(lf)) {
117
+ process.stdin.on('keypress', lf);
118
+ }
119
+ }
120
+ // Order matters here:
121
+ // 1. resume stdin so the event loop stays referenced (otherwise the
122
+ // next rl.question prints its prompt and the process exits).
123
+ // 2. resume the readline so it accepts new input.
124
+ // 3. defer the suppress-clear so the spurious 'close' that inquirer
125
+ // synchronously emits on release is still ignored.
126
+ process.stdin.resume();
127
+ rl.resume();
128
+ process.nextTick(() => setSuppress(false));
129
+ }
130
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared @inquirer/prompts theme. Centralises CSPeach's color discipline
3
+ * so any prompt — select, input, confirm, password, checkbox — looks the
4
+ * same and stays in line with the surrounding terminal aesthetic.
5
+ *
6
+ * Why this exists: by default @inquirer/prompts renders the chosen
7
+ * answer in cyan. CC-style discipline says color should encode meaning
8
+ * (success / error / brand) — a cyan answer line on every prompt
9
+ * trains the user to treat color as decoration. Override the answer
10
+ * style to default white so the only colored thing on a finished
11
+ * prompt is the leading green ✔ check.
12
+ *
13
+ * 2026-05-01 (CC-parity color audit): introduced as part of the cuts
14
+ * after screenshot review showed 9 distinct hues on a single screen.
15
+ * Goal is to land at ~5: default, dim, brand peach, green outcome,
16
+ * red error.
17
+ */
18
+ import chalk from 'chalk';
19
+ /** The shared theme. Pass as `theme` to any @inquirer/prompts call. */
20
+ export const inquirerTheme = {
21
+ style: {
22
+ /**
23
+ * The selected answer printed back after Enter — by default cyan,
24
+ * here default-color. The leading ✔ (provided by inquirer) stays
25
+ * green to encode "done", which is enough signal on its own.
26
+ */
27
+ answer: (s) => chalk.white(s),
28
+ /**
29
+ * Help / hint text shown next to the prompt question. Default
30
+ * inquirer hint is dim — keep it dim, just be explicit so future
31
+ * inquirer version bumps don't regress us.
32
+ */
33
+ help: (s) => chalk.dim(s),
34
+ /**
35
+ * Highlighted (cursor-on) choice in select / checkbox. Default is
36
+ * cyan. Use a subtle inverse instead — the cursor `❯` glyph is
37
+ * the primary signal anyway.
38
+ */
39
+ highlight: (s) => chalk.bold(s),
40
+ },
41
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Rule 8 — no batch without plan. Per-LLM-turn write-op counter.
3
+ *
4
+ * One CSPeach LLM turn = one user prompt processed end-to-end through the
5
+ * agent loop, ending when the assistant's stop_reason is end_turn or
6
+ * max_tokens. Many Anthropic API calls and tool dispatches happen within
7
+ * a single turn — the boundary is the user prompt, NOT the API call.
8
+ *
9
+ * Reset at turn start in agent/loop.ts (top of runTurn, before the API loop).
10
+ * Gate fires from agent/tool-dispatch.ts (where LLM-issued tool calls land).
11
+ *
12
+ * The 1st mutating call of a turn proceeds WITHOUT the Rule-8 prompt — only
13
+ * increments the counter. The 2nd (and any subsequent) mutating call fires
14
+ * the RULE_8_BATCH_PLAN confirmation prompt. Independent of /safety-mode —
15
+ * batch detection is unconditional per Plan 3 §6.6.
16
+ *
17
+ * Module-scope singleton per CLI process. Tests must call resetRule8State()
18
+ * in beforeEach to avoid leakage across Vitest test files.
19
+ */
20
+ let writeOpsThisTurn = [];
21
+ let planGateApprovedThisTurn = false;
22
+ /**
23
+ * Record a successful mutating tool dispatch. Called from tool-dispatch
24
+ * AFTER tool.handler returns without is_error.
25
+ */
26
+ export function recordWriteOp(tool, args) {
27
+ writeOpsThisTurn.push({ tool, args, ts: new Date().toISOString() });
28
+ }
29
+ /**
30
+ * Returns true on the 2nd (and later) mutating call of the turn — the gate
31
+ * fires here. The 1st mutating call proceeds without the prompt; only its
32
+ * recordWriteOp pushes the seed entry.
33
+ *
34
+ * 2026-05-01: when the user already approved a batch via the plan-gate
35
+ * (request_approval → "Apply all" or per-change "Apply" for all changes),
36
+ * Rule 8 is redundant — they've seen the full plan and explicitly approved
37
+ * every operation in it. Suppress in that case. Rule 8 remains active for
38
+ * batches the model issues OUTSIDE the approval flow (rare but possible —
39
+ * e.g. recovery paths, ad-hoc tool calls), so the safety net stays.
40
+ */
41
+ export function shouldGateRule8() {
42
+ if (planGateApprovedThisTurn)
43
+ return false;
44
+ return writeOpsThisTurn.length >= 1;
45
+ }
46
+ /**
47
+ * Tell the Rule 8 gate that the plan-gate has already covered this turn's
48
+ * batch. Called from request_approval when the user picks "Apply all" or
49
+ * approves every change individually. Reset by resetRule8State at the
50
+ * start of the next turn.
51
+ */
52
+ export function markPlanGateApproved() {
53
+ planGateApprovedThisTurn = true;
54
+ }
55
+ /**
56
+ * Reset state at the start of every LLM turn. Called from runTurn before
57
+ * the agent loop begins.
58
+ */
59
+ export function resetRule8State() {
60
+ writeOpsThisTurn = [];
61
+ planGateApprovedThisTurn = false;
62
+ }
63
+ /**
64
+ * Read-only view of the writes that have already happened this turn. Used
65
+ * by renderPlanSummary and by tests.
66
+ */
67
+ export function getWriteOpsThisTurn() {
68
+ return writeOpsThisTurn;
69
+ }
70
+ /**
71
+ * Render a short human-readable plan summary used in the SafetyAction
72
+ * what_will_happen field for RULE_8_BATCH_PLAN. Lists prior writes + the
73
+ * proposed-next write.
74
+ */
75
+ export function renderPlanSummary(proposedNext) {
76
+ const lines = [];
77
+ lines.push(`Batch in progress (${writeOpsThisTurn.length + 1} write ops this turn):`);
78
+ writeOpsThisTurn.forEach((op, i) => {
79
+ lines.push(` ${i + 1}. ${op.tool} ${summarizeArgs(op.args)}`);
80
+ });
81
+ lines.push(` ${writeOpsThisTurn.length + 1}. ${proposedNext.tool} ${summarizeArgs(proposedNext.args)} ← about to dispatch`);
82
+ return lines.join('\n');
83
+ }
84
+ function summarizeArgs(args) {
85
+ // Best-effort one-line summary. Pulls the most useful identifier fields:
86
+ // type+name, class_name+method_name, transport.
87
+ const a = args;
88
+ const parts = [];
89
+ if (typeof a['type'] === 'string' && typeof a['name'] === 'string') {
90
+ parts.push(`${a['type']} ${a['name']}`);
91
+ }
92
+ if (typeof a['class_name'] === 'string' && typeof a['method_name'] === 'string') {
93
+ parts.push(`${a['class_name']}->${a['method_name']}`);
94
+ }
95
+ if (typeof a['transport'] === 'string' && a['transport']) {
96
+ parts.push(`(transport: ${a['transport']})`);
97
+ }
98
+ return parts.length === 0 ? '(no key args)' : parts.join(' ');
99
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Safety-confirm-as-mode: structured confirmation cards for Forge Rule
3
+ * triggers (Rules 7 / 7a / 8 / 9 / 10). Promotes the safety prompts from
4
+ * ad-hoc to a first-class mode. When this is the active confirmation
5
+ * surface, every rule-triggering operation gets a card showing which rule
6
+ * applies, what's about to happen, and what rollback is available.
7
+ *
8
+ * Pure orchestration over `@inquirer/prompts.confirm` — no I/O of its own
9
+ * beyond the prompt invocation.
10
+ */
11
+ import chalk from 'chalk';
12
+ import { select } from '@inquirer/prompts';
13
+ import { withInquirer } from './inquirer-guard.js';
14
+ import { inquirerTheme } from './inquirer-theme.js';
15
+ const RULE_LABEL = {
16
+ RULE_7_SNAPSHOT_BEFORE_WRITE: 'RULE 7 — snapshot before write',
17
+ RULE_7A_USE_UPDATE_METHOD: 'RULE 7a — use sap_update_method (not sap_set_source)',
18
+ RULE_8_BATCH_PLAN: 'RULE 8 — no batch without plan',
19
+ RULE_9_TRANSPORT_RELEASE: 'RULE 9 — transport release (irreversible)',
20
+ RULE_9_TRANSPORT_ISOLATION: 'RULE 9 — transport isolation',
21
+ RULE_10_VERIFY_AFTER_WRITE: 'RULE 10 — verify after write',
22
+ };
23
+ export async function presentSafetyConfirmation(action) {
24
+ const card = renderCard(action);
25
+ // 2026-05-01: switched from confirm() to select() to fix the "press Enter
26
+ // first, then 'y'" race observed in dispatched-tool rule-8 prompts. confirm
27
+ // has a brief window after rendering its multi-line message before raw
28
+ // mode engages, during which the first keystroke can land in cooked-mode
29
+ // readline instead of inquirer (visible as a stray 'y' echoed below the
30
+ // prompt). select needs arrow keys, so it engages raw mode aggressively
31
+ // and consistently — and it matches the Apply/Review/Cancel pattern the
32
+ // approval gauntlet already uses, so the UX stays uniform across all gates.
33
+ const first = await withInquirer(() => select({
34
+ message: card,
35
+ default: 'apply',
36
+ theme: inquirerTheme,
37
+ choices: [
38
+ { value: 'apply', name: 'Apply' },
39
+ { value: 'cancel', name: 'Cancel' },
40
+ ],
41
+ }));
42
+ if (first === 'cancel') {
43
+ return { confirmed: false, reason: 'user declined first prompt' };
44
+ }
45
+ // RULE 9 transport-release is irreversible — require a second explicit ack.
46
+ if (action.rule === 'RULE_9_TRANSPORT_RELEASE') {
47
+ const second = await withInquirer(() => select({
48
+ message: `IRREVERSIBLE — releasing ${action.transport} cannot be undone from this side. ` +
49
+ `Are you absolutely sure?`,
50
+ default: 'cancel',
51
+ theme: inquirerTheme,
52
+ choices: [
53
+ { value: 'cancel', name: 'Cancel' },
54
+ { value: 'release', name: 'Release transport' },
55
+ ],
56
+ }));
57
+ if (second === 'cancel') {
58
+ return { confirmed: false, reason: 'user declined irreversibility ack' };
59
+ }
60
+ }
61
+ return { confirmed: true };
62
+ }
63
+ /**
64
+ * Minimal indented summary block — replaces the chunky ASCII-art box.
65
+ * Renders as:
66
+ *
67
+ * ⚠ RULE 8 — no batch without plan
68
+ * op: sap_set_source
69
+ * object: PROG ZTEST_FIXED_2
70
+ * transport: (none)
71
+ * batch: 2 objects this turn
72
+ * what: Batch in progress (...)
73
+ * rollback: available (snapshot)
74
+ *
75
+ * The `⚠` glyph is yellow (alert) — distinct from peach (brand) and red
76
+ * (error) so the user immediately recognises a Forge Rule prompt. Body
77
+ * lines are indented under the header for visual grouping without a box.
78
+ */
79
+ function renderCard(action) {
80
+ const lines = [];
81
+ const transport = action.transport && action.transport.length > 0 ? action.transport : '(none)';
82
+ const rollback = action.rollback_available ? 'available (snapshot)' : chalk.red('NOT available');
83
+ lines.push('');
84
+ lines.push(`${chalk.yellow('⚠')} ${chalk.yellow.bold(RULE_LABEL[action.rule])}`);
85
+ lines.push(` ${chalk.dim('op: ')} ${action.op}`);
86
+ lines.push(` ${chalk.dim('object: ')} ${action.object.type} ${action.object.name}`);
87
+ lines.push(` ${chalk.dim('transport:')} ${transport}`);
88
+ if (action.rule === 'RULE_8_BATCH_PLAN' && typeof action.batch_count === 'number') {
89
+ lines.push(` ${chalk.dim('batch: ')} ${action.batch_count} objects this turn`);
90
+ }
91
+ // what_will_happen may include embedded newlines (e.g. Rule 8 plan summary).
92
+ // Indent the continuation lines so they align under the header field.
93
+ const whatLines = action.what_will_happen.split('\n');
94
+ if (whatLines.length === 1) {
95
+ lines.push(` ${chalk.dim('what: ')} ${whatLines[0]}`);
96
+ }
97
+ else {
98
+ lines.push(` ${chalk.dim('what: ')} ${whatLines[0]}`);
99
+ for (let i = 1; i < whatLines.length; i++) {
100
+ lines.push(` ${whatLines[i]}`);
101
+ }
102
+ }
103
+ lines.push(` ${chalk.dim('rollback: ')} ${rollback}`);
104
+ lines.push('');
105
+ return lines.join('\n');
106
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Module-scope state for the safety-confirm-as-mode toggle. The REPL turns
3
+ * this on via `--safety-confirm` flag at start, or `/safety-mode on|off` at
4
+ * runtime. Tool dispatchers that hit a Forge Rule (snapshot, write, batch,
5
+ * transport release, activation) read isSafetyConfirmEnabled() and choose
6
+ * presentSafetyConfirmation() vs the legacy ad-hoc inquirer prompt.
7
+ *
8
+ * Module-scope state instead of a context-passed flag because the toggle is
9
+ * orthogonal to call sites — every dispatch site needs to read it without
10
+ * changing every signature in the call chain. Keep the state here, keep the
11
+ * accessors small, and keep the test surface tiny.
12
+ */
13
+ let enabled = false;
14
+ export function isSafetyConfirmEnabled() {
15
+ return enabled;
16
+ }
17
+ export function setSafetyConfirmEnabled(value) {
18
+ enabled = Boolean(value);
19
+ }
20
+ /**
21
+ * Handles the `/safety-mode on` / `/safety-mode off` slash command. Returns
22
+ * a human-readable status string for the REPL to print. Unknown args fall
23
+ * through to a status-only response without changing state.
24
+ */
25
+ export function handleSafetyModeCommand(args) {
26
+ const arg = args.trim().toLowerCase();
27
+ if (arg === 'on') {
28
+ setSafetyConfirmEnabled(true);
29
+ return 'Safety-confirm mode: ON. Forge Rule 7/7a/8/9/10 ops now show structured confirmation cards.';
30
+ }
31
+ if (arg === 'off') {
32
+ setSafetyConfirmEnabled(false);
33
+ return 'Safety-confirm mode: OFF. Falling back to ad-hoc prompts.';
34
+ }
35
+ return `Safety-confirm mode is currently ${enabled ? 'ON' : 'OFF'}. Use \`/safety-mode on\` or \`/safety-mode off\`.`;
36
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Slash-command Tab completion for the REPL readline.
3
+ *
4
+ * UX contract:
5
+ * - User types `/` then Tab → list of every available command (skills +
6
+ * built-ins) printed below the prompt; line stays untouched.
7
+ * - User types `/abap-r` then Tab → if 1 match, line auto-completes.
8
+ * If multiple, list filtered matches; line stays.
9
+ * - User types non-slash text + Tab → no completion (no spurious matches).
10
+ *
11
+ * Backed by readline's native `completer` option, so it inherits the
12
+ * prompt's history-aware redraw and works without a custom render loop.
13
+ */
14
+ import { SKILL_CATALOG } from '../skill-catalog.js';
15
+ /**
16
+ * Built-in slash commands that are NOT skill routes (these are handled
17
+ * directly by the REPL's command dispatcher, not by the agent loop).
18
+ * Keep this list aligned with the `if (trimmed === '/...')` branches in
19
+ * repl.tsx so Tab doesn't suggest commands that don't exist.
20
+ */
21
+ const BUILTIN_COMMANDS = [
22
+ '/cancel',
23
+ '/exit',
24
+ '/quit',
25
+ '/help',
26
+ '/skills',
27
+ '/new',
28
+ '/reset',
29
+ '/ui',
30
+ '/reroute',
31
+ '/transport',
32
+ ];
33
+ /**
34
+ * Build the full slash-command list once at module load. Skill commands
35
+ * come from SKILL_CATALOG so adding a skill there auto-extends Tab.
36
+ */
37
+ function buildAllCommands() {
38
+ const skillCmds = SKILL_CATALOG.map((s) => `/${s.name}`);
39
+ return [...BUILTIN_COMMANDS, ...skillCmds].sort();
40
+ }
41
+ const ALL_COMMANDS = buildAllCommands();
42
+ /**
43
+ * readline `completer` callback. Returns `[hits, prefix]` per Node's
44
+ * contract:
45
+ * - hits: matching completion strings (full lines, not deltas)
46
+ * - prefix: the substring of `line` that the hits should replace
47
+ *
48
+ * Behavior:
49
+ * - line empty → []
50
+ * - line doesn't start with / → [] (no-op for natural prose)
51
+ * - line === '/' → all commands
52
+ * - line === '/abap-' → all skills under that prefix
53
+ */
54
+ export function slashCompleter(line) {
55
+ if (!line.startsWith('/'))
56
+ return [[], line];
57
+ const hits = ALL_COMMANDS.filter((c) => c.startsWith(line));
58
+ return [hits, line];
59
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Slash-command live picker. Opens an @inquirer/prompts `search` prompt
3
+ * pre-filtered to whatever the user has typed so far after the slash, lets
4
+ * them refine the filter character-by-character, navigate with arrow
5
+ * keys, and confirm with Enter. Replaces the Tab-cycle autocomplete.
6
+ *
7
+ * UX contract:
8
+ * - User types `/` then Enter → picker shows ALL commands
9
+ * - User types `/abap-r` then Enter → picker pre-filtered to those
10
+ * commands matching `abap-r`. User can refine further or pick.
11
+ * - User types `/abap-refactor` and presses Enter → exact match, no
12
+ * picker opens (the existing routing handles the slash directly).
13
+ * - User cancels (Esc / Ctrl-C inside picker) → returns null; caller
14
+ * treats the original line as cancelled.
15
+ *
16
+ * Layout-friendly: renders below the prompt via inquirer's normal
17
+ * full-screen takeover, then erases itself on Enter or Esc — same
18
+ * mechanism as every other inquirer prompt in the REPL, so it
19
+ * coexists with the readline / withInquirer guard.
20
+ */
21
+ import { search } from '@inquirer/prompts';
22
+ import { withInquirer } from './inquirer-guard.js';
23
+ import { inquirerTheme } from './inquirer-theme.js';
24
+ import { SKILL_CATALOG } from '../skill-catalog.js';
25
+ const BUILTIN_ENTRIES = [
26
+ { name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
27
+ { name: '/exit', description: 'Exit CSPeach' },
28
+ { name: '/quit', description: 'Exit CSPeach (alias)' },
29
+ { name: '/help', description: 'Show all skills and commands' },
30
+ { name: '/skills', description: 'Show all skills (alias)' },
31
+ { name: '/new', description: 'Reset routing — next prompt is classified afresh' },
32
+ { name: '/reset', description: 'Reset routing (alias)' },
33
+ { name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
34
+ { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
35
+ { name: '/transport', description: 'View or set the active transport for this session' },
36
+ ];
37
+ function buildCatalogue() {
38
+ const skillEntries = SKILL_CATALOG.map((s) => ({
39
+ name: `/${s.name}`,
40
+ description: s.description,
41
+ }));
42
+ return [...BUILTIN_ENTRIES, ...skillEntries].sort((a, b) => a.name.localeCompare(b.name));
43
+ }
44
+ const ALL_ENTRIES = buildCatalogue();
45
+ /**
46
+ * Open the slash picker. Returns the picked command WITHOUT a leading
47
+ * slash (caller is expected to construct the full prompt) or null if
48
+ * the user cancelled.
49
+ *
50
+ * @param prefix characters typed AFTER the slash (e.g. 'abap-r' for
51
+ * input '/abap-r'). Used to pre-filter the catalogue
52
+ * so the user doesn't need to retype what they had.
53
+ */
54
+ export async function openSlashPicker(prefix) {
55
+ // Pre-filter the catalogue to entries whose command name (without slash)
56
+ // contains the typed prefix as a substring — substring rather than
57
+ // startsWith because users often remember a fragment in the middle of
58
+ // a name (e.g. 'fix' should bring up /abap-atc-fix and /abap-upgrade-fix).
59
+ const lowerPrefix = prefix.trim().toLowerCase();
60
+ const seeded = lowerPrefix.length === 0
61
+ ? ALL_ENTRIES
62
+ : ALL_ENTRIES.filter((e) => e.name.toLowerCase().includes(lowerPrefix));
63
+ // If the prefix narrows down to exactly one command, just return it
64
+ // without opening a picker — saves the user a keystroke.
65
+ if (seeded.length === 1) {
66
+ return seeded[0].name.slice(1); // strip leading slash
67
+ }
68
+ // If the prefix narrows to zero commands, fall back to ALL entries so
69
+ // the user has something to pick from rather than an empty list.
70
+ const choicesForPicker = seeded.length === 0 ? ALL_ENTRIES : seeded;
71
+ // 2026-05-01: explicit cancel sentinel. @inquirer/prompts.search does
72
+ // NOT bind Esc to cancellation — only Ctrl+C does, and many users
73
+ // won't think to use it (and on some terminals Ctrl+C kills the
74
+ // whole process). Make cancel a first-class choice the user can
75
+ // arrow up to and press Enter on.
76
+ const CANCEL_VALUE = '__cspeach_cancel__';
77
+ try {
78
+ const picked = await withInquirer(() => search({
79
+ message: 'Pick a command',
80
+ theme: inquirerTheme,
81
+ source: async (input) => {
82
+ const filter = (input ?? '').toLowerCase();
83
+ const filtered = filter.length === 0
84
+ ? choicesForPicker
85
+ : choicesForPicker.filter((e) => e.name.toLowerCase().includes(filter));
86
+ // Cancel sentinel is always available regardless of filter,
87
+ // so a typo can't trap the user inside the picker.
88
+ const cancelChoice = {
89
+ name: '← Cancel — keep my typed text and let me edit it',
90
+ value: CANCEL_VALUE,
91
+ short: 'cancel',
92
+ };
93
+ const choices = filtered.map((e) => ({
94
+ name: `${e.name.padEnd(28)} ${e.description}`,
95
+ value: e.name,
96
+ short: e.name,
97
+ }));
98
+ return [cancelChoice, ...choices];
99
+ },
100
+ }));
101
+ if (picked === CANCEL_VALUE)
102
+ return null;
103
+ return typeof picked === 'string' ? picked.slice(1) : null; // strip leading slash
104
+ }
105
+ catch {
106
+ // Ctrl+C inside the picker — treat as cancellation.
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Quick check: does the typed line match a real, registered slash
112
+ * command exactly? Used by the REPL to decide between "pass through to
113
+ * existing routing" and "open picker for ambiguous input".
114
+ *
115
+ * The match is on the command name only; arguments after the command
116
+ * (e.g. `/transport S4HK903361`) are ignored — `/transport` itself is
117
+ * registered, so the line should pass through.
118
+ */
119
+ export function isExactSlashMatch(trimmed) {
120
+ if (!trimmed.startsWith('/'))
121
+ return false;
122
+ const head = trimmed.split(/\s+/, 1)[0].toLowerCase();
123
+ return ALL_ENTRIES.some((e) => e.name === head);
124
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * PreviewHook factories for sap_update_method diff-confirmation gate.
3
+ *
4
+ * Three factories cover the three execution contexts:
5
+ * - replPreviewHook — interactive REPL: renders diff + asks confirm
6
+ * - noopPreviewHook — auto-confirm (reserved for trusted paths)
7
+ * - alwaysDeclinePreviewHook — refuse every confirm (one-shot / non-interactive)
8
+ *
9
+ * B.0a plumbing: factories are wired into ToolContext.previewHook at all 5
10
+ * construction sites. B.1 will consume ctx.previewHook inside sap_update_method.
11
+ */
12
+ import { confirm } from '@inquirer/prompts';
13
+ import { renderDiff } from './diff-display.js';
14
+ import { withInquirer } from './inquirer-guard.js';
15
+ /**
16
+ * B.1 real interactive hook — composes renderDiff + @inquirer/prompts.confirm.
17
+ * Renders a unified diff inside the REPL and asks the user "Apply change?".
18
+ */
19
+ export const replPreviewHook = async ({ className, methodName, currentBody, proposedBody, }) => {
20
+ const diff = renderDiff(currentBody, proposedBody, {
21
+ label: `${className}->${methodName}`,
22
+ syntaxHighlight: true,
23
+ });
24
+ process.stdout.write(`\n${diff}\n`);
25
+ const confirmed = await withInquirer(() => confirm({
26
+ message: `Apply change to ${className}->${methodName}?`,
27
+ default: false,
28
+ }));
29
+ return { confirmed };
30
+ };
31
+ /**
32
+ * No-op hook — confirms automatically without prompting. Use for paths where we
33
+ * trust the LLM (classifier; doctor probe-write where the developer initiated
34
+ * the run). NOT used in current dispatch sites — exported for future flexibility.
35
+ */
36
+ export const noopPreviewHook = async () => ({ confirmed: true });
37
+ /**
38
+ * Always-decline hook — refuses every confirmation. Use for non-interactive
39
+ * paths where there is no human to confirm (one-shot, scripts). Writes that
40
+ * need diff confirmation fail safely with `cancelled_by_user` instead of
41
+ * silently going through.
42
+ */
43
+ export const alwaysDeclinePreviewHook = async () => ({
44
+ confirmed: false,
45
+ });