@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.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- 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
|
+
});
|