@cspeach/cli 0.9.0 → 1.1.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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numbered-menu expansion — UX-5 #2 (2026-06-12).
|
|
3
|
+
*
|
|
4
|
+
* Skills frequently end a turn with a plain-text numbered menu:
|
|
5
|
+
*
|
|
6
|
+
* 1. Explain it
|
|
7
|
+
* 2. Show structure
|
|
8
|
+
* 3. Run ATC
|
|
9
|
+
*
|
|
10
|
+
* Users then type `2` at the prompt — and before this module nothing
|
|
11
|
+
* useful happened: the bare digit was either routed to the previous skill
|
|
12
|
+
* as a meaningless answer or burned a classifier round-trip. The
|
|
13
|
+
* pragmatic fix: when the user submits a SHORT pure-number input AND the
|
|
14
|
+
* previous assistant message contains a matching numbered list item,
|
|
15
|
+
* expand the input to that item's text before the turn starts (with a
|
|
16
|
+
* dim echo so the user sees what they picked). Anything else passes
|
|
17
|
+
* through unchanged.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately conservative — expansion fires ONLY when:
|
|
20
|
+
* - the input matches ^[0-9]{1,2}$ (1–2 digit pure number, no spaces)
|
|
21
|
+
* - the MENU-SHAPED TAIL of the last assistant text — its final
|
|
22
|
+
* ~MENU_TAIL_LINES non-empty lines — contains a numbered list with
|
|
23
|
+
* 2+ items ("N. text" or "N · text" line patterns). A real menu sits
|
|
24
|
+
* at the END of the message; numbered plans/steps mid-message
|
|
25
|
+
* (followed by more prose) must NOT hijack a legitimate numeric
|
|
26
|
+
* answer (UX-5 review I2, 2026-06-12).
|
|
27
|
+
* - the typed number is one of the list's item numbers
|
|
28
|
+
*
|
|
29
|
+
* Everything else (no list, out-of-range number, prose input) returns
|
|
30
|
+
* null and the caller treats the input as typed.
|
|
31
|
+
*/
|
|
32
|
+
/** Pure 1–2 digit number, nothing else. */
|
|
33
|
+
const PURE_NUMBER_RE = /^[0-9]{1,2}$/;
|
|
34
|
+
/**
|
|
35
|
+
* A numbered list item at the start of a line: `12. text` or `12 · text`.
|
|
36
|
+
* Bullet variants with `)` etc. are deliberately NOT matched — staying
|
|
37
|
+
* conservative beats over-firing on prose like "step 3) optional".
|
|
38
|
+
*/
|
|
39
|
+
const ITEM_LINE_RE = /^\s*([0-9]{1,2})(?:\.|\s·)\s+(\S.*)$/;
|
|
40
|
+
/**
|
|
41
|
+
* Extract numbered list items from assistant text. Returns one entry per
|
|
42
|
+
* distinct item number — when the same number appears twice (e.g. two
|
|
43
|
+
* separate lists in one message), the LAST occurrence wins, since the
|
|
44
|
+
* most recent list is the one the user is looking at.
|
|
45
|
+
*/
|
|
46
|
+
export function extractNumberedItems(text) {
|
|
47
|
+
const byNumber = new Map();
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
const m = ITEM_LINE_RE.exec(line);
|
|
50
|
+
if (!m)
|
|
51
|
+
continue;
|
|
52
|
+
const n = Number.parseInt(m[1], 10);
|
|
53
|
+
byNumber.set(n, m[2].trim());
|
|
54
|
+
}
|
|
55
|
+
return [...byNumber.entries()].map(([number, text]) => ({ number, text }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How many trailing non-empty lines count as the "menu-shaped tail" of an
|
|
59
|
+
* assistant message. 10 fits the largest real skill menus seen in the wild
|
|
60
|
+
* (8 items + a header + a trailing "pick one" line) while excluding
|
|
61
|
+
* numbered plan/step lists that sit mid-message above closing prose.
|
|
62
|
+
*/
|
|
63
|
+
export const MENU_TAIL_LINES = 10;
|
|
64
|
+
/**
|
|
65
|
+
* The final `maxLines` NON-EMPTY lines of `text`, original order, joined
|
|
66
|
+
* with newlines. Blank lines are skipped (menus are often separated from
|
|
67
|
+
* the item lines by blank lines — those shouldn't eat the budget).
|
|
68
|
+
*/
|
|
69
|
+
export function menuTail(text, maxLines = MENU_TAIL_LINES) {
|
|
70
|
+
const lines = text.split('\n');
|
|
71
|
+
const tail = [];
|
|
72
|
+
for (let i = lines.length - 1; i >= 0 && tail.length < maxLines; i--) {
|
|
73
|
+
const line = lines[i];
|
|
74
|
+
if (line.trim().length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
tail.unshift(line);
|
|
77
|
+
}
|
|
78
|
+
return tail.join('\n');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* If `input` is a short pure number selecting an item from a numbered
|
|
82
|
+
* list in the menu-shaped TAIL of `lastAssistantText`, return the
|
|
83
|
+
* expansion; otherwise null (pass the input through unchanged).
|
|
84
|
+
*
|
|
85
|
+
* I2 (2026-06-12): only the final MENU_TAIL_LINES non-empty lines are
|
|
86
|
+
* scanned — a numbered plan mid-message (with prose after it) no longer
|
|
87
|
+
* hijacks a numeric answer the user meant literally.
|
|
88
|
+
*/
|
|
89
|
+
export function maybeExpandNumberSelection(input, lastAssistantText) {
|
|
90
|
+
const trimmed = input.trim();
|
|
91
|
+
if (!PURE_NUMBER_RE.test(trimmed))
|
|
92
|
+
return null;
|
|
93
|
+
if (!lastAssistantText)
|
|
94
|
+
return null;
|
|
95
|
+
const items = extractNumberedItems(menuTail(lastAssistantText));
|
|
96
|
+
if (items.length < 2)
|
|
97
|
+
return null; // not a menu — a lone "1." is prose
|
|
98
|
+
const n = Number.parseInt(trimmed, 10);
|
|
99
|
+
const hit = items.find((i) => i.number === n);
|
|
100
|
+
if (!hit)
|
|
101
|
+
return null; // out of range — pass through
|
|
102
|
+
return {
|
|
103
|
+
text: hit.text,
|
|
104
|
+
echo: `→ ${hit.number}. ${hit.text}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Text of the most recent assistant message in `messages`. Joins the
|
|
109
|
+
* text blocks of that single message (tool_use blocks skipped); handles
|
|
110
|
+
* the defensive string-content case too. Returns null when no assistant
|
|
111
|
+
* message exists.
|
|
112
|
+
*/
|
|
113
|
+
export function lastAssistantText(messages) {
|
|
114
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
115
|
+
const m = messages[i];
|
|
116
|
+
if (m.role !== 'assistant')
|
|
117
|
+
continue;
|
|
118
|
+
if (typeof m.content === 'string')
|
|
119
|
+
return m.content;
|
|
120
|
+
if (Array.isArray(m.content)) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
for (const block of m.content) {
|
|
123
|
+
if (block?.type === 'text' && typeof block.text === 'string')
|
|
124
|
+
parts.push(block.text);
|
|
125
|
+
}
|
|
126
|
+
return parts.join('\n\n');
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
* Ink mode → chunkEmitter.emit('chunk', formatted) — Body's
|
|
10
10
|
* <Static> commits the line to scrollback
|
|
11
11
|
*
|
|
12
|
-
* The sapStateStore push that used to drive a Header / Sidebar
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The sapStateStore push that used to drive a Header / Sidebar was
|
|
13
|
+
* removed when the Header died (no consumer). UX Wave 1 Task 9 partially
|
|
14
|
+
* revives it: the Task 4 <StatusLine /> reads dollarSpendToday from the
|
|
15
|
+
* store, and this helper is the one chokepoint that already computes the
|
|
16
|
+
* session cost total after EVERY turn (both classic and Ink paths call
|
|
17
|
+
* it), so the spend wire lives here. The printed status line remains the
|
|
18
|
+
* primary per-turn status surface.
|
|
15
19
|
*/
|
|
16
20
|
import { computeCost } from '../cost/pricing.js';
|
|
21
|
+
import { globalStore } from '../ui/sap-state-store.js';
|
|
17
22
|
import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
|
|
18
23
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
19
24
|
export function snapshotTurnStart(session) {
|
|
@@ -26,7 +31,7 @@ export function snapshotTurnStart(session) {
|
|
|
26
31
|
};
|
|
27
32
|
}
|
|
28
33
|
export function pushPostTurnStatus(params) {
|
|
29
|
-
const { session, selectedAlias, snapshot, chunkEmitter } = params;
|
|
34
|
+
const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
|
|
30
35
|
const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
31
36
|
const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
|
|
32
37
|
const lifetimeTokensBefore = snapshot.input + snapshot.output;
|
|
@@ -37,13 +42,21 @@ export function pushPostTurnStatus(params) {
|
|
|
37
42
|
cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,
|
|
38
43
|
cacheCreate: (session.usage.cache_creation_input_tokens ?? 0) - snapshot.cacheCreate,
|
|
39
44
|
};
|
|
40
|
-
const turnCost = computeCost(session.model, turnCostTokens);
|
|
45
|
+
const turnCost = computeCost(turnModel ?? session.model, turnCostTokens);
|
|
41
46
|
const sessionCost = computeCost(session.model, {
|
|
42
47
|
input: session.usage.input_tokens,
|
|
43
48
|
output: session.usage.output_tokens,
|
|
44
49
|
cacheRead: session.usage.cache_read_input_tokens,
|
|
45
50
|
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
46
51
|
});
|
|
52
|
+
// Task 9 (ux-wave1) — statusline spend wire. Honest semantics note: the
|
|
53
|
+
// store field is named `dollarSpendToday` (legacy sidebar naming, kept to
|
|
54
|
+
// avoid rename churn) but the value written is SESSION-scoped — the same
|
|
55
|
+
// sessionCost total shown in the printed status footer, computed from
|
|
56
|
+
// lifetime session.usage at session.model rates (mixed-model turns are an
|
|
57
|
+
// approximation, same caveat as the footer's session line). There is no
|
|
58
|
+
// day-spanning ledger; the statusline shows session spend.
|
|
59
|
+
globalStore.update({ dollarSpendToday: sessionCost });
|
|
47
60
|
const statusPayload = {
|
|
48
61
|
sessionId: session.id,
|
|
49
62
|
sapAlias: selectedAlias,
|
|
@@ -60,7 +73,14 @@ export function pushPostTurnStatus(params) {
|
|
|
60
73
|
// Push through chunkEmitter so the line lands in Body's <Static>
|
|
61
74
|
// scrollback alongside the conversation, instead of corrupting
|
|
62
75
|
// the live Ink frame.
|
|
63
|
-
|
|
76
|
+
//
|
|
77
|
+
// Wave1 fast-follow — variant:'turn' slims the Ink receipt to
|
|
78
|
+
// turn-only facts (skill │ turns │ tok │ $ │ duration): the
|
|
79
|
+
// persistent <StatusLine /> already shows the alias and the session
|
|
80
|
+
// $ total, so the full receipt double-reported both after every
|
|
81
|
+
// turn. Classic mode (and the no-emitter fallback) keeps the full
|
|
82
|
+
// receipt — there is no statusline there.
|
|
83
|
+
chunkEmitter.emit('chunk', formatStatusFooter({ ...statusPayload, variant: 'turn' }));
|
|
64
84
|
}
|
|
65
85
|
else {
|
|
66
86
|
printStatusFooter(statusPayload);
|
|
@@ -82,10 +82,25 @@ export function renderPlanSummary(proposedNext) {
|
|
|
82
82
|
return lines.join('\n');
|
|
83
83
|
}
|
|
84
84
|
function summarizeArgs(args) {
|
|
85
|
-
// Best-effort one-line summary. Pulls the most useful identifier fields
|
|
86
|
-
//
|
|
85
|
+
// Best-effort one-line summary. Pulls the most useful identifier fields so a
|
|
86
|
+
// customer reviewing the batch gate sees WHAT each op targets (Forge Rule 8
|
|
87
|
+
// is unauditable theatre otherwise):
|
|
88
|
+
// - file_write / file_edit → the path
|
|
89
|
+
// - shell_exec → command + args (e.g. `npm run build`)
|
|
90
|
+
// - SAP object writes → type+name, class_name->method_name, transport
|
|
87
91
|
const a = args;
|
|
88
92
|
const parts = [];
|
|
93
|
+
// Local-FS writes (the fiori-build path: 13× file_write + 2× shell_exec) —
|
|
94
|
+
// the path / command is the only meaningful descriptor; surface it FIRST.
|
|
95
|
+
if (typeof a['path'] === 'string' && a['path']) {
|
|
96
|
+
parts.push(a['path']);
|
|
97
|
+
}
|
|
98
|
+
if (typeof a['command'] === 'string' && a['command']) {
|
|
99
|
+
const cmdArgs = Array.isArray(a['args'])
|
|
100
|
+
? a['args'].filter((x) => typeof x === 'string').join(' ')
|
|
101
|
+
: '';
|
|
102
|
+
parts.push(cmdArgs ? `${a['command']} ${cmdArgs}` : a['command']);
|
|
103
|
+
}
|
|
89
104
|
if (typeof a['type'] === 'string' && typeof a['name'] === 'string') {
|
|
90
105
|
parts.push(`${a['type']} ${a['name']}`);
|
|
91
106
|
}
|
|
@@ -5,13 +5,21 @@
|
|
|
5
5
|
* surface, every rule-triggering operation gets a card showing which rule
|
|
6
6
|
* applies, what's about to happen, and what rollback is available.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Three presentation surfaces, decided per call (headless first):
|
|
9
|
+
* - headless → fail-fast decline, no prompt (B5)
|
|
10
|
+
* - Ink → askQuestionEmitter choice modal (UX-5 review I1) —
|
|
11
|
+
* inquirer must NEVER run under Ink: it fights the
|
|
12
|
+
* React frame for stdin and paints over it
|
|
13
|
+
* - classic → inquirer select via withInquirer (unchanged)
|
|
10
14
|
*/
|
|
11
15
|
import chalk from 'chalk';
|
|
12
16
|
import { select } from '@inquirer/prompts';
|
|
13
17
|
import { withInquirer } from './inquirer-guard.js';
|
|
14
18
|
import { inquirerTheme } from './inquirer-theme.js';
|
|
19
|
+
import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
20
|
+
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
21
|
+
import { clearActiveSpinner } from '../renderer/tool-widget.js';
|
|
22
|
+
import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
15
23
|
const RULE_LABEL = {
|
|
16
24
|
RULE_7_SNAPSHOT_BEFORE_WRITE: 'RULE 7 — snapshot before write',
|
|
17
25
|
RULE_7A_USE_UPDATE_METHOD: 'RULE 7a — use sap_update_method (not sap_set_source)',
|
|
@@ -21,7 +29,100 @@ const RULE_LABEL = {
|
|
|
21
29
|
RULE_10_VERIFY_AFTER_WRITE: 'RULE 10 — verify after write',
|
|
22
30
|
};
|
|
23
31
|
export async function presentSafetyConfirmation(action) {
|
|
32
|
+
// B5 (2026-06-11) — headless fail-fast. This card is a stdin modal (the
|
|
33
|
+
// Rule 8 batch gate reaches it on the 2nd+ mutating call per turn even in
|
|
34
|
+
// one-shot, e.g. two local file writes); a headless run would hang on it
|
|
35
|
+
// forever. Headless runs NEVER auto-confirm safety gates — decline with a
|
|
36
|
+
// clear reason so the model can wrap up gracefully.
|
|
37
|
+
if (isHeadless()) {
|
|
38
|
+
console.error(chalk.red(`headless: ${RULE_LABEL[action.rule]} requires interactive confirmation — ` +
|
|
39
|
+
'declining (headless runs never auto-confirm safety gates; run interactively).'));
|
|
40
|
+
return {
|
|
41
|
+
confirmed: false,
|
|
42
|
+
reason: 'headless run cannot confirm safety gates — run interactively',
|
|
43
|
+
headless: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
24
46
|
const card = renderCard(action);
|
|
47
|
+
// UX-5 review I1 (2026-06-12) — Ink-native branch. /safety-mode on made
|
|
48
|
+
// this card reachable under Ink, where the raw inquirer select() below
|
|
49
|
+
// toggled raw mode out from under the React frame and painted over it.
|
|
50
|
+
// Mirror of renderPlanGate (approvals/render.ts): route through the
|
|
51
|
+
// askQuestionEmitter choice modal — one input system, no stdin handoff.
|
|
52
|
+
// The modal renders `context` itself in dimColor, so strip the chalk ANSI
|
|
53
|
+
// codes (they'd show as raw escape text). Same choice semantics as the
|
|
54
|
+
// classic path below: Apply/Cancel, plus the Rule 9 irreversibility ack.
|
|
55
|
+
// Esc / no listener resolve as cancelled → decline (safe default).
|
|
56
|
+
if (shouldUseInk()) {
|
|
57
|
+
// CRITICAL race fix (2026-06-13) — this gate fires from INSIDE
|
|
58
|
+
// dispatchTool, on the 2nd+ mutating op of a turn. For local-FS ops
|
|
59
|
+
// (file_write / shell_exec, the fiori-build path) the agent loop
|
|
60
|
+
// classifies the tool as NON-interactive: it starts the in-place tool
|
|
61
|
+
// spinner, a per-second labelled heartbeat, AND keeps the turn-status
|
|
62
|
+
// row ticking — none of which it pauses (only ask_question /
|
|
63
|
+
// request_approval get paused at the loop level). So when this modal
|
|
64
|
+
// opens, three render sources are still repainting the Ink frame
|
|
65
|
+
// underneath it. That produced the observed live bug: the RULE 8 card
|
|
66
|
+
// rendered DOUBLED, Enter was not consumed by the modal, the keystroke
|
|
67
|
+
// LEAKED to the outer prompt (history-replay of the slash command), and
|
|
68
|
+
// the turn wedged for ~10 minutes while the tool spinner kept ticking.
|
|
69
|
+
//
|
|
70
|
+
// The gate must claim exclusive input focus itself (it can't rely on the
|
|
71
|
+
// loop, which doesn't know a non-interactive tool is about to block on
|
|
72
|
+
// the user). Mirror what the loop does for interactive tools:
|
|
73
|
+
// - clearActiveSpinner(): stop the live in-place tool spinner so only
|
|
74
|
+
// ONE surface (this modal) renders.
|
|
75
|
+
// - turnStatusEmitter.pause(): freeze the ticking status row so it
|
|
76
|
+
// stops repainting the dynamic frame and overdrawing the modal.
|
|
77
|
+
// resume() in the finally restores normal rendering the moment the gate
|
|
78
|
+
// resolves. (The per-second heartbeat is loop-local and is stopped on
|
|
79
|
+
// the loop side by ordering the gate ahead of heartbeat start — see
|
|
80
|
+
// tool-dispatch.ts / loop.ts.)
|
|
81
|
+
clearActiveSpinner();
|
|
82
|
+
const statusWasActive = turnStatusEmitter.snapshot().active;
|
|
83
|
+
if (statusWasActive)
|
|
84
|
+
turnStatusEmitter.pause();
|
|
85
|
+
try {
|
|
86
|
+
const first = await askQuestionEmitter.request({
|
|
87
|
+
id: 'safety-confirm',
|
|
88
|
+
question: `⚠ ${RULE_LABEL[action.rule]}`,
|
|
89
|
+
context: stripAnsi(card).trim(),
|
|
90
|
+
kind: 'choice',
|
|
91
|
+
choices: [
|
|
92
|
+
{ value: 'apply', label: 'Apply' },
|
|
93
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
if (first.cancelled || first.answer !== 'apply') {
|
|
97
|
+
return { confirmed: false, reason: 'user declined first prompt' };
|
|
98
|
+
}
|
|
99
|
+
// RULE 9 transport-release is irreversible — require a second explicit ack.
|
|
100
|
+
if (action.rule === 'RULE_9_TRANSPORT_RELEASE') {
|
|
101
|
+
const second = await askQuestionEmitter.request({
|
|
102
|
+
id: 'safety-confirm-irreversible',
|
|
103
|
+
question: `IRREVERSIBLE — releasing ${action.transport} cannot be undone from this side. ` +
|
|
104
|
+
`Are you absolutely sure?`,
|
|
105
|
+
kind: 'choice',
|
|
106
|
+
// Cancel first → the modal cursor starts there (mirrors the classic
|
|
107
|
+
// path's `default: 'cancel'`).
|
|
108
|
+
choices: [
|
|
109
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
110
|
+
{ value: 'release', label: 'Release transport' },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
if (second.cancelled || second.answer !== 'release') {
|
|
114
|
+
return { confirmed: false, reason: 'user declined irreversibility ack' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { confirmed: true };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// Restore normal rendering. resume() is a no-op if the turn already
|
|
121
|
+
// ended (e.g. the user Ctrl+C'd mid-modal) or was never paused.
|
|
122
|
+
if (statusWasActive)
|
|
123
|
+
turnStatusEmitter.resume();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
25
126
|
// 2026-05-01: switched from confirm() to select() to fix the "press Enter
|
|
26
127
|
// first, then 'y'" race observed in dispatched-tool rule-8 prompts. confirm
|
|
27
128
|
// has a brief window after rendering its multi-line message before raw
|
|
@@ -60,6 +161,14 @@ export async function presentSafetyConfirmation(action) {
|
|
|
60
161
|
}
|
|
61
162
|
return { confirmed: true };
|
|
62
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Strip chalk ANSI escape codes. The Ink ask-question modal renders the
|
|
166
|
+
* card via its own <Text dimColor> — embedded escapes would display as
|
|
167
|
+
* literal `[33m` noise there. (Same approach as renderPlanGate.)
|
|
168
|
+
*/
|
|
169
|
+
function stripAnsi(s) {
|
|
170
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
171
|
+
}
|
|
63
172
|
/**
|
|
64
173
|
* Minimal indented summary block — replaces the chunky ASCII-art box.
|
|
65
174
|
* Renders as:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Module-scope state for the safety-confirm-as-mode toggle.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Module-scope state for the safety-confirm-as-mode toggle. Off by default;
|
|
3
|
+
* the user turns it on with the `/safety-mode on` slash command at runtime
|
|
4
|
+
* (M3, 2026-06-12: there is no startup flag — an earlier version of this
|
|
5
|
+
* header claimed a `--safety-confirm` flag that was never implemented).
|
|
6
|
+
* Tool dispatchers that hit a Forge Rule (snapshot, write, batch,
|
|
5
7
|
* transport release, activation) read isSafetyConfirmEnabled() and choose
|
|
6
8
|
* presentSafetyConfirmation() vs the legacy ad-hoc inquirer prompt.
|
|
7
9
|
*
|
|
@@ -34,3 +36,17 @@ export function handleSafetyModeCommand(args) {
|
|
|
34
36
|
}
|
|
35
37
|
return `Safety-confirm mode is currently ${enabled ? 'ON' : 'OFF'}. Use \`/safety-mode on\` or \`/safety-mode off\`.`;
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* REPL slash-dispatch entry. Returns the status string when `trimmed` is a
|
|
41
|
+
* `/safety-mode` command (bare or with args), or null when the line is not
|
|
42
|
+
* a /safety-mode command at all. Both REPL paths (Ink handleSubmit and the
|
|
43
|
+
* classic readline loop) route through this single matcher so the command
|
|
44
|
+
* behaves identically everywhere — mirrors how /transport dispatches via
|
|
45
|
+
* handleTransportCommand.
|
|
46
|
+
*/
|
|
47
|
+
export function matchSafetyModeCommand(trimmed) {
|
|
48
|
+
if (trimmed !== '/safety-mode' && !trimmed.startsWith('/safety-mode '))
|
|
49
|
+
return null;
|
|
50
|
+
const args = trimmed === '/safety-mode' ? '' : trimmed.slice('/safety-mode '.length);
|
|
51
|
+
return handleSafetyModeCommand(args);
|
|
52
|
+
}
|
|
@@ -22,12 +22,17 @@ const BUILTIN_COMMANDS = [
|
|
|
22
22
|
'/cancel',
|
|
23
23
|
'/compact',
|
|
24
24
|
'/cost',
|
|
25
|
+
'/context',
|
|
26
|
+
'/export',
|
|
25
27
|
'/exit',
|
|
26
28
|
'/quit',
|
|
27
29
|
'/help',
|
|
28
30
|
'/skills',
|
|
31
|
+
'/mode',
|
|
29
32
|
'/new',
|
|
33
|
+
'/recap',
|
|
30
34
|
'/reset',
|
|
35
|
+
'/rewind',
|
|
31
36
|
'/ui',
|
|
32
37
|
'/reroute',
|
|
33
38
|
'/transport',
|
|
@@ -21,21 +21,10 @@
|
|
|
21
21
|
import { search } from '@inquirer/prompts';
|
|
22
22
|
import { withInquirer } from './inquirer-guard.js';
|
|
23
23
|
import { inquirerTheme } from './inquirer-theme.js';
|
|
24
|
-
import { SKILL_CATALOG } from '../skill-catalog.js';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{ name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
|
|
29
|
-
{ name: '/exit', description: 'Exit CSPeach' },
|
|
30
|
-
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
31
|
-
{ name: '/help', description: 'Show all skills and commands' },
|
|
32
|
-
{ name: '/skills', description: 'Show all skills (alias)' },
|
|
33
|
-
{ name: '/new', description: 'Reset routing — next prompt is classified afresh' },
|
|
34
|
-
{ name: '/reset', description: 'Reset routing (alias)' },
|
|
35
|
-
{ name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
|
|
36
|
-
{ name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
|
|
37
|
-
{ name: '/transport', description: 'View or set the active transport for this session' },
|
|
38
|
-
];
|
|
24
|
+
import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
|
|
25
|
+
// 2026-06-11 — builtin list extracted to builtin-commands.ts (pure data)
|
|
26
|
+
// so the router layer can check builtin heads without importing inquirer.
|
|
27
|
+
import { BUILTIN_ENTRIES } from './builtin-commands.js';
|
|
39
28
|
function buildCatalogue() {
|
|
40
29
|
const skillEntries = SKILL_CATALOG.map((s) => ({
|
|
41
30
|
name: `/${s.name}`,
|
|
@@ -133,5 +122,11 @@ export function isExactSlashMatch(trimmed) {
|
|
|
133
122
|
if (!trimmed.startsWith('/'))
|
|
134
123
|
return false;
|
|
135
124
|
const head = trimmed.split(/\s+/, 1)[0].toLowerCase();
|
|
125
|
+
// Retired-but-redirected names (SKILL_ALIASES) count as exact so the
|
|
126
|
+
// line passes through to parseShortcut, which resolves the redirect and
|
|
127
|
+
// emits the "renamed" notice — instead of dumping the user into the
|
|
128
|
+
// picker. Aliases are deliberately NOT in the picker/completer lists.
|
|
129
|
+
if (head.startsWith('/') && head.slice(1) in SKILL_ALIASES)
|
|
130
|
+
return true;
|
|
136
131
|
return ALL_ENTRIES.some((e) => e.name === head);
|
|
137
132
|
}
|