@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
|
@@ -13,23 +13,161 @@
|
|
|
13
13
|
* structurally reliable: the API validates the input schema, no parsing
|
|
14
14
|
* needed, no streaming ambiguity.
|
|
15
15
|
*
|
|
16
|
-
* Renders:
|
|
16
|
+
* Renders (v1 single-question shape):
|
|
17
17
|
* - kind=text: prompt for free-form answer
|
|
18
18
|
* - kind=choice: arrow-key pick-one via inquirer select
|
|
19
19
|
* - kind=multi: space-to-toggle via inquirer checkbox
|
|
20
20
|
*
|
|
21
21
|
* Returns JSON `{ answer, id, kind, cancelled? }` so the LLM knows what
|
|
22
22
|
* it just got back and can cross-reference the question id.
|
|
23
|
+
*
|
|
24
|
+
* UX Wave 2 — v2 batched `questions` shape (up to 4 related questions in
|
|
25
|
+
* ONE call): Ink renders a single <AskForm> (Task 2); classic runs the
|
|
26
|
+
* questions as a sequential inquirer flow (Task 3). Both return
|
|
27
|
+
* `{ formId, answers: [{id, answer, custom?}], cancelled? }`.
|
|
23
28
|
*/
|
|
24
29
|
import chalk from 'chalk';
|
|
25
30
|
import { registerTool } from './index.js';
|
|
26
31
|
import { input, select, checkbox } from '@inquirer/prompts';
|
|
27
32
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
28
|
-
import { shouldUseInk } from '../renderer/tty.js';
|
|
33
|
+
import { shouldUseInk, isHeadless } from '../renderer/tty.js';
|
|
29
34
|
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
35
|
+
/** Chip labels in the v2 form are capped at 12 chars (brief-binding). */
|
|
36
|
+
const HEADER_MAX_CHARS = 12;
|
|
37
|
+
/**
|
|
38
|
+
* UX Wave 2 / Task 1 — validation failure for a v2 `questions` payload.
|
|
39
|
+
* The handler converts this into an error RESULT (`ask_form_invalid`),
|
|
40
|
+
* never a throw across the tool boundary.
|
|
41
|
+
*/
|
|
42
|
+
export class AskFormValidationError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'AskFormValidationError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalizeOptions(raw) {
|
|
49
|
+
if (!Array.isArray(raw))
|
|
50
|
+
return [];
|
|
51
|
+
return raw.map((o) => {
|
|
52
|
+
const opt = { value: String(o.value), label: String(o.label) };
|
|
53
|
+
if (o.description != null)
|
|
54
|
+
opt.description = String(o.description);
|
|
55
|
+
if (o.recommended === true)
|
|
56
|
+
opt.recommended = true;
|
|
57
|
+
return opt;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* UX Wave 2 / Task 1 — normalize EVERY ask_question call (v1 single-question
|
|
62
|
+
* or v2 `questions` batch) into the one internal AskFormRequest shape.
|
|
63
|
+
*
|
|
64
|
+
* v1 mapping: kind=multi → multiSelect; kind=text (or no choices) →
|
|
65
|
+
* freeText with empty options; header defaults to the question id.
|
|
66
|
+
* v2 mapping is 1:1; headers (explicit or defaulted from id) are truncated
|
|
67
|
+
* to 12 chars for the chip row.
|
|
68
|
+
*
|
|
69
|
+
* IMPORTANT: normalization does NOT decide routing. The handler routes
|
|
70
|
+
* v1-shaped ORIGINALS through the untouched v1 code paths (byte-compat by
|
|
71
|
+
* construction); only genuine v2 calls reach the form machinery.
|
|
72
|
+
*
|
|
73
|
+
* Throws AskFormValidationError on an invalid v2 payload (0 or >4
|
|
74
|
+
* questions, entries missing id/question) — the handler converts that to
|
|
75
|
+
* an `ask_form_invalid` error result. Exported for tests.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeAskInput(args) {
|
|
78
|
+
const header = (q) => String(q.header != null && String(q.header).length > 0 ? q.header : q.id)
|
|
79
|
+
.slice(0, HEADER_MAX_CHARS);
|
|
80
|
+
if (Array.isArray(args?.questions)) {
|
|
81
|
+
const raw = args.questions;
|
|
82
|
+
if (raw.length < 1 || raw.length > 4) {
|
|
83
|
+
throw new AskFormValidationError(`\`questions\` must contain 1 to 4 items (got ${raw.length}). ` +
|
|
84
|
+
'Never ask more than 4 at once — split into a follow-up call instead.');
|
|
85
|
+
}
|
|
86
|
+
const questions = raw.map((q, i) => {
|
|
87
|
+
if (q == null || typeof q !== 'object' || q.id == null || q.question == null) {
|
|
88
|
+
throw new AskFormValidationError(`questions[${i}] is invalid — each entry needs at least { id, question }.`);
|
|
89
|
+
}
|
|
90
|
+
const options = normalizeOptions(q.options);
|
|
91
|
+
return {
|
|
92
|
+
id: String(q.id),
|
|
93
|
+
question: String(q.question),
|
|
94
|
+
context: q.context != null ? String(q.context) : undefined,
|
|
95
|
+
header: header({ id: String(q.id), header: q.header }),
|
|
96
|
+
multiSelect: q.multiSelect === true,
|
|
97
|
+
freeText: options.length === 0,
|
|
98
|
+
options,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const formId = args.id != null ? String(args.id) : questions.map((q) => q.id).join('+');
|
|
102
|
+
return { formId, questions };
|
|
103
|
+
}
|
|
104
|
+
// v1 single-question shape → one-question form.
|
|
105
|
+
const id = String(args.id);
|
|
106
|
+
const kind = args.kind;
|
|
107
|
+
const options = kind === 'text' ? [] : normalizeOptions(args.choices);
|
|
108
|
+
return {
|
|
109
|
+
formId: id,
|
|
110
|
+
questions: [{
|
|
111
|
+
id,
|
|
112
|
+
question: String(args.question),
|
|
113
|
+
context: args.context != null ? String(args.context) : undefined,
|
|
114
|
+
header: header({ id, header: undefined }),
|
|
115
|
+
multiSelect: kind === 'multi',
|
|
116
|
+
freeText: kind === 'text' || options.length === 0,
|
|
117
|
+
options,
|
|
118
|
+
}],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Label heuristic for rule 2 of pickHeadlessChoices: a string counts as
|
|
123
|
+
* "recommended" only when it contains the word AND that word is not part of
|
|
124
|
+
* a negation — skills write both "RAP (recommended)" and "SEGW (not
|
|
125
|
+
* recommended)" and the latter must NEVER win the auto-pick.
|
|
126
|
+
*/
|
|
127
|
+
function labelSaysRecommended(s) {
|
|
128
|
+
return /\brecommended\b/i.test(s) && !/\b(?:not|non|never)[\s-]+recommended\b/i.test(s);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* B5 (2026-06-11) — headless auto-pick rule for choice/multi questions.
|
|
132
|
+
*
|
|
133
|
+
* Rule order (the fired rule is reported so logs and the tool result are
|
|
134
|
+
* honest about WHY an option was picked):
|
|
135
|
+
* 1. `recommended: true` on one or more choices → those choices
|
|
136
|
+
* (rule: 'recommended').
|
|
137
|
+
* 2. No explicit flag, but a label/value contains the word "recommended"
|
|
138
|
+
* (skills frequently write "RAP (recommended)") → those choices
|
|
139
|
+
* (rule: 'recommended-label'). Negated forms ("not recommended",
|
|
140
|
+
* "non-recommended", "never recommended") do NOT match.
|
|
141
|
+
* 3. Nothing marked → the FIRST option (rule: 'first-option').
|
|
142
|
+
*
|
|
143
|
+
* For kind=choice only the first match is taken; for kind=multi the whole
|
|
144
|
+
* recommended set is taken (or the first option when nothing is marked).
|
|
145
|
+
*
|
|
146
|
+
* Contract: `choices` must be non-empty — the handler guards with
|
|
147
|
+
* `choices.length > 0` before calling (an empty array means there is nothing
|
|
148
|
+
* to pick and the headless path returns the free-text error instead). An
|
|
149
|
+
* empty array here is a programming error, so this throws rather than
|
|
150
|
+
* returning `[undefined]`.
|
|
151
|
+
*
|
|
152
|
+
* Exported for unit testing.
|
|
153
|
+
*/
|
|
154
|
+
export function pickHeadlessChoices(kind, choices) {
|
|
155
|
+
if (choices.length === 0) {
|
|
156
|
+
throw new Error('pickHeadlessChoices requires at least one choice — callers must guard (see ask_question handler)');
|
|
157
|
+
}
|
|
158
|
+
const flagged = choices.filter((c) => c.recommended === true);
|
|
159
|
+
if (flagged.length > 0) {
|
|
160
|
+
return { picked: kind === 'multi' ? flagged : [flagged[0]], rule: 'recommended' };
|
|
161
|
+
}
|
|
162
|
+
const labelled = choices.filter((c) => labelSaysRecommended(c.label) || labelSaysRecommended(c.value));
|
|
163
|
+
if (labelled.length > 0) {
|
|
164
|
+
return { picked: kind === 'multi' ? labelled : [labelled[0]], rule: 'recommended-label' };
|
|
165
|
+
}
|
|
166
|
+
return { picked: [choices[0]], rule: 'first-option' };
|
|
167
|
+
}
|
|
30
168
|
registerTool({
|
|
31
169
|
name: 'ask_question',
|
|
32
|
-
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction.",
|
|
170
|
+
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction. When you have MULTIPLE related questions, batch up to 4 into ONE call via `questions` — the user answers them in a single form. Never ask more than 4 at once; never split related questions across calls.",
|
|
33
171
|
isMutating: false,
|
|
34
172
|
input_schema: {
|
|
35
173
|
type: 'object',
|
|
@@ -59,21 +197,276 @@ registerTool({
|
|
|
59
197
|
properties: {
|
|
60
198
|
value: { type: 'string', description: 'Machine value returned if the user picks this.' },
|
|
61
199
|
label: { type: 'string', description: 'Human-readable label shown in the picker.' },
|
|
200
|
+
recommended: {
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
description: 'Mark the option you would recommend. In headless (non-interactive) runs this option is auto-picked; in interactive runs it is informational only.',
|
|
203
|
+
},
|
|
62
204
|
},
|
|
63
205
|
required: ['value', 'label'],
|
|
64
206
|
},
|
|
65
207
|
},
|
|
208
|
+
questions: {
|
|
209
|
+
type: 'array',
|
|
210
|
+
minItems: 1,
|
|
211
|
+
maxItems: 4,
|
|
212
|
+
description: 'v2 batched form — ask up to 4 related questions in ONE call; the user answers them all in a single form. When `questions` is provided, the single-question fields (question/kind/choices) are ignored; a top-level `id` (if given) names the form. Omit `options` on a question for a free-text answer.',
|
|
213
|
+
items: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
id: { type: 'string', description: 'Short stable identifier for this question.' },
|
|
217
|
+
question: { type: 'string', description: 'The question text. Keep concise — max ~200 chars.' },
|
|
218
|
+
context: { type: 'string', description: 'Optional one-line explanation of why this matters.' },
|
|
219
|
+
header: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
description: 'Optional chip label for the form header row (max 12 chars). Defaults to the question id.',
|
|
222
|
+
},
|
|
223
|
+
multiSelect: {
|
|
224
|
+
type: 'boolean',
|
|
225
|
+
description: 'true = the user may pick several options; false/omitted = pick exactly one.',
|
|
226
|
+
},
|
|
227
|
+
options: {
|
|
228
|
+
type: 'array',
|
|
229
|
+
description: 'Answer options. Omit entirely for a free-text question.',
|
|
230
|
+
items: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
value: { type: 'string', description: 'Machine value returned if the user picks this.' },
|
|
234
|
+
label: { type: 'string', description: 'Human-readable label shown in the form.' },
|
|
235
|
+
description: { type: 'string', description: 'Optional secondary line, rendered dim under the label.' },
|
|
236
|
+
recommended: {
|
|
237
|
+
type: 'boolean',
|
|
238
|
+
description: 'Mark the option you would recommend. Auto-picked in headless runs.',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
required: ['value', 'label'],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
required: ['id', 'question'],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
66
248
|
},
|
|
67
|
-
|
|
249
|
+
// v2: `questions` replaces the single-question fields, so nothing can be
|
|
250
|
+
// unconditionally required any more. Single-question calls still need
|
|
251
|
+
// id/question/kind (per their descriptions); batched calls need `questions`.
|
|
252
|
+
required: [],
|
|
68
253
|
},
|
|
69
254
|
handler: async (args, _ctx) => {
|
|
255
|
+
// UX Wave 2 / Task 1 — v2 batched form branch. Routing rule (byte-compat
|
|
256
|
+
// by construction): ONLY calls that actually send a `questions` array
|
|
257
|
+
// enter the form machinery; every v1-shaped original falls through to
|
|
258
|
+
// the untouched v1 code paths below — identical prompts, results, and
|
|
259
|
+
// headless behavior, guaranteed structurally rather than by re-testing.
|
|
260
|
+
if (Array.isArray(args.questions)) {
|
|
261
|
+
let form;
|
|
262
|
+
try {
|
|
263
|
+
form = normalizeAskInput(args);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (err instanceof AskFormValidationError) {
|
|
267
|
+
return {
|
|
268
|
+
content: JSON.stringify({ error: 'ask_form_invalid', message: err.message }),
|
|
269
|
+
is_error: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
// Headless v2 — fully implemented NOW (per-question policy, mirroring
|
|
275
|
+
// the v1 B5 rules): optioned questions auto-pick with the fired rule
|
|
276
|
+
// reported; a free-text question inside a batch becomes a per-question
|
|
277
|
+
// `headless_unanswerable` entry but NEVER fails the whole form.
|
|
278
|
+
if (isHeadless()) {
|
|
279
|
+
const answers = form.questions.map((q) => {
|
|
280
|
+
if (!q.freeText && q.options.length > 0) {
|
|
281
|
+
const { picked, rule } = pickHeadlessChoices(q.multiSelect ? 'multi' : 'choice', q.options);
|
|
282
|
+
const answer = picked.map((c) => c.value).join(',');
|
|
283
|
+
console.error(chalk.dim(`headless: auto-answered '${q.question}' → '${answer}' (${rule})`));
|
|
284
|
+
return { id: q.id, answer, auto_answered: true, auto_answer_rule: rule };
|
|
285
|
+
}
|
|
286
|
+
console.error(chalk.yellow(`headless: cannot answer free-text question '${q.question}' — ` +
|
|
287
|
+
'provide this detail in the prompt or run interactively.'));
|
|
288
|
+
return { id: q.id, answer: null, error: 'headless_unanswerable' };
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
content: JSON.stringify({
|
|
292
|
+
formId: form.formId,
|
|
293
|
+
headless: true,
|
|
294
|
+
answers,
|
|
295
|
+
note: 'Headless run: the user did not actually answer this form. Optioned questions ' +
|
|
296
|
+
'were auto-selected (rule reported per answer); free-text questions are ' +
|
|
297
|
+
'unanswerable (answer: null). Proceed with sensible assumptions and clearly ' +
|
|
298
|
+
'note them in your final output.',
|
|
299
|
+
}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// Interactive v2, INK path — wired by Task 2: requestForm emits the
|
|
303
|
+
// discriminated form request, App routes it to <AskForm> (v1 requests
|
|
304
|
+
// keep rendering the untouched AskQuestionModal), and the resolved
|
|
305
|
+
// AskFormResult comes back here. Cancel (Esc / no listener) mirrors
|
|
306
|
+
// the v1 cancel contract: empty answers + cancelled: true.
|
|
307
|
+
if (shouldUseInk()) {
|
|
308
|
+
const result = await askQuestionEmitter.requestForm(form);
|
|
309
|
+
if (result.cancelled) {
|
|
310
|
+
return {
|
|
311
|
+
content: JSON.stringify({ formId: form.formId, answers: [], cancelled: true }),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
content: JSON.stringify({ formId: result.formId, answers: result.answers }),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// Interactive v2, CLASSIC path — Task 3 flag-day (this replaces the
|
|
319
|
+
// Task-1 `ask_form_not_wired` stub; Task 2 wired the Ink side).
|
|
320
|
+
// Sequential inquirer prompts, one per question IN FORM ORDER, each
|
|
321
|
+
// under a dim `form i/n · <header>` progress prefix and built from
|
|
322
|
+
// the v1 building blocks: select + "(type a custom answer)" escape
|
|
323
|
+
// for pick-one, checkbox for multiSelect, input for freeText.
|
|
324
|
+
//
|
|
325
|
+
// Result shapes mirror the Ink AskForm EXACTLY (Task-2 contract):
|
|
326
|
+
// success → { formId, answers: [{ id, answer, custom? }] } — in
|
|
327
|
+
// question order; custom:true ONLY for the escape.
|
|
328
|
+
// Esc anywhere (ExitPromptError) → { formId, answers: [],
|
|
329
|
+
// cancelled: true } — the WHOLE form cancels, partial
|
|
330
|
+
// answers are discarded (non-error content, v1 cancel
|
|
331
|
+
// semantics).
|
|
332
|
+
// Empty submissions re-prompt, mirroring the Ink form ignoring
|
|
333
|
+
// empty submits — a committed form answer is never empty.
|
|
334
|
+
{
|
|
335
|
+
const total = form.questions.length;
|
|
336
|
+
const answers = [];
|
|
337
|
+
try {
|
|
338
|
+
for (let i = 0; i < total; i++) {
|
|
339
|
+
const q = form.questions[i];
|
|
340
|
+
console.log('');
|
|
341
|
+
console.log(chalk.dim(`form ${i + 1}/${total} · ${q.header ?? q.id}`));
|
|
342
|
+
console.log(chalk.bold(q.question));
|
|
343
|
+
if (q.context)
|
|
344
|
+
console.log(chalk.dim(q.context));
|
|
345
|
+
console.log('');
|
|
346
|
+
let entry = null;
|
|
347
|
+
while (entry === null) {
|
|
348
|
+
if (!q.freeText && !q.multiSelect && q.options.length > 0) {
|
|
349
|
+
// Pick-one — same select + custom-escape recipe as v1.
|
|
350
|
+
const CUSTOM = '__cspeach_custom__';
|
|
351
|
+
const picked = await withInquirer(() => select({
|
|
352
|
+
message: 'Pick an answer:',
|
|
353
|
+
choices: [
|
|
354
|
+
...q.options.map((c) => ({ value: c.value, name: c.label })),
|
|
355
|
+
{ value: CUSTOM, name: chalk.dim('(type a custom answer)') },
|
|
356
|
+
],
|
|
357
|
+
}));
|
|
358
|
+
if (picked === CUSTOM) {
|
|
359
|
+
const raw = (await withInquirer(() => input({ message: 'Your answer:' }))).trim();
|
|
360
|
+
if (raw.length === 0)
|
|
361
|
+
continue; // empty custom — re-prompt
|
|
362
|
+
entry = { id: q.id, answer: raw, custom: true };
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
entry = { id: q.id, answer: picked };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (q.multiSelect && q.options.length > 0) {
|
|
369
|
+
const picked = await withInquirer(() => checkbox({
|
|
370
|
+
message: 'Pick one or more (space to toggle, enter to confirm):',
|
|
371
|
+
choices: q.options.map((c) => ({ value: c.value, name: c.label })),
|
|
372
|
+
}));
|
|
373
|
+
if (picked.length === 0)
|
|
374
|
+
continue; // nothing toggled — re-prompt
|
|
375
|
+
entry = { id: q.id, answer: picked.join(',') };
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// freeText (options empty — including multiSelect without
|
|
379
|
+
// options, which normalization degrades to freeText).
|
|
380
|
+
const raw = (await withInquirer(() => input({ message: 'Your answer:' }))).trim();
|
|
381
|
+
if (raw.length === 0)
|
|
382
|
+
continue;
|
|
383
|
+
entry = { id: q.id, answer: raw };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
console.log(chalk.dim(` → ${entry.answer}`));
|
|
387
|
+
answers.push(entry);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const name = err?.name;
|
|
392
|
+
if (name === 'ExitPromptError') {
|
|
393
|
+
return {
|
|
394
|
+
content: JSON.stringify({ formId: form.formId, answers: [], cancelled: true }),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
return { content: JSON.stringify({ formId: form.formId, answers }) };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// UX Wave 2 / Task 1 review fix — malformed v1 shape guard. With
|
|
403
|
+
// `required: []` on the schema (needed so `questions`-only calls pass
|
|
404
|
+
// API validation) a call like `{id:'x'}` with no question/kind/questions
|
|
405
|
+
// would reach the v1 paths: headless would embed the literal question
|
|
406
|
+
// "undefined" in its error, and interactive would render a bold prompt
|
|
407
|
+
// reading `undefined` and BLOCK the loop on user input. Reject it here
|
|
408
|
+
// with a structured error result instead. Unreachable for any
|
|
409
|
+
// previously-valid v1 call (id/question/kind used to be schema-required)
|
|
410
|
+
// ⇒ zero byte-compat exposure.
|
|
411
|
+
if (args.id == null || args.question == null || args.kind == null) {
|
|
412
|
+
return {
|
|
413
|
+
content: JSON.stringify({
|
|
414
|
+
error: 'ask_question_invalid',
|
|
415
|
+
message: 'Invalid ask_question call: the single-question shape requires `id`, `question`, ' +
|
|
416
|
+
'and `kind` ("text" | "choice" | "multi"). To ask several related questions at ' +
|
|
417
|
+
'once, use the `questions` array (1-4 items, each { id, question, ... }) instead.',
|
|
418
|
+
}),
|
|
419
|
+
is_error: true,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
70
422
|
const id = String(args.id);
|
|
71
423
|
const question = String(args.question);
|
|
72
424
|
const context = args.context ? String(args.context) : undefined;
|
|
73
425
|
const kind = args.kind;
|
|
74
|
-
const choices = Array.isArray(args.choices)
|
|
75
|
-
|
|
76
|
-
|
|
426
|
+
const choices = Array.isArray(args.choices) ? args.choices : [];
|
|
427
|
+
// B5 (2026-06-11) — headless answer policy (defects D1/D1b). In a
|
|
428
|
+
// headless run (one-shot / piped stdin) NO prompt may ever block on
|
|
429
|
+
// stdin: battery S2 Pass A stalled forever on exactly this path.
|
|
430
|
+
// - choice/multi WITH options: auto-pick (recommended > recommended-
|
|
431
|
+
// label > first option), log to stderr, and tell the model via
|
|
432
|
+
// `auto_answered: true` that the user did NOT really answer.
|
|
433
|
+
// - free text (or choice/multi without options): no auto-answer is
|
|
434
|
+
// possible — return an error RESULT to the model (not a hard exit)
|
|
435
|
+
// so it can finish gracefully with documented assumptions, the way
|
|
436
|
+
// S2 Pass B completed when answers were supplied up front.
|
|
437
|
+
if (isHeadless()) {
|
|
438
|
+
if ((kind === 'choice' || kind === 'multi') && choices.length > 0) {
|
|
439
|
+
const { picked, rule } = pickHeadlessChoices(kind, choices);
|
|
440
|
+
const answer = picked.map((c) => c.value).join(',');
|
|
441
|
+
console.error(chalk.dim(`headless: auto-answered '${question}' → '${answer}' (${rule})`));
|
|
442
|
+
return {
|
|
443
|
+
content: JSON.stringify({
|
|
444
|
+
id,
|
|
445
|
+
kind,
|
|
446
|
+
answer,
|
|
447
|
+
headless: true,
|
|
448
|
+
auto_answered: true,
|
|
449
|
+
auto_answer_rule: rule,
|
|
450
|
+
note: 'Headless run: the user did not actually answer — this option was auto-selected ' +
|
|
451
|
+
`(${rule}). Proceed with it and clearly note the assumption in your final output.`,
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
console.error(chalk.yellow(`headless: cannot answer free-text question '${question}' — ` +
|
|
456
|
+
'provide this detail in the prompt or run interactively.'));
|
|
457
|
+
return {
|
|
458
|
+
content: JSON.stringify({
|
|
459
|
+
id,
|
|
460
|
+
kind,
|
|
461
|
+
headless: true,
|
|
462
|
+
error: 'headless_unanswerable',
|
|
463
|
+
message: `Headless run: no user is available to answer this free-text question: "${question}". ` +
|
|
464
|
+
'Either proceed with a sensible assumption and CLEARLY document it in your final output, ' +
|
|
465
|
+
'or finish by telling the user to include this detail in the prompt or run interactively.',
|
|
466
|
+
}),
|
|
467
|
+
is_error: true,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
77
470
|
// 2026-05-01 redesign: when the LLM provides choices, use a real
|
|
78
471
|
// arrow-key picker (select / checkbox) instead of free-text input.
|
|
79
472
|
// The previous "always free-text, smart-resolve digits" pattern produced
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `system_capability` — the data-driven capability gate behind Pillar A.
|
|
3
|
+
*
|
|
4
|
+
* Given a capability id (cds.viewEntity | rap.managed | abap.filterExpr |
|
|
5
|
+
* abap.inlineData) or a raw ABAP feature name, returns a yes/no/with-fallback
|
|
6
|
+
* verdict for the CONNECTED SAP release, read out of the committed ABAP
|
|
7
|
+
* feature-matrix snapshot. Skills call this instead of hardcoding version
|
|
8
|
+
* prose like "FILTER needs 7.50".
|
|
9
|
+
*
|
|
10
|
+
* Read-only: it consults system-info (which caches/queries CVERS once) and a
|
|
11
|
+
* static snapshot — it never writes to SAP. Flag-gated with the other
|
|
12
|
+
* local-build tools (rides the `local_build` switch via LOCAL_BUILD_TOOLS).
|
|
13
|
+
*
|
|
14
|
+
* See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md.
|
|
15
|
+
*/
|
|
16
|
+
import { registerTool } from '../index.js';
|
|
17
|
+
import { getSapSystemInfo } from '../../sap/system-info.js';
|
|
18
|
+
import { loadCapabilityMatrix } from '../../sap/capability-matrix.js';
|
|
19
|
+
import { resolveCapability, CAPABILITY_REGISTRY, } from '../../sap/capability.js';
|
|
20
|
+
function isRegistryId(s) {
|
|
21
|
+
return Object.prototype.hasOwnProperty.call(CAPABILITY_REGISTRY, s);
|
|
22
|
+
}
|
|
23
|
+
async function systemCapabilityHandler(args, ctx) {
|
|
24
|
+
const capability = args?.capability;
|
|
25
|
+
if (typeof capability !== 'string' || capability.trim() === '') {
|
|
26
|
+
return { content: 'error: capability (non-empty string) is required', is_error: true };
|
|
27
|
+
}
|
|
28
|
+
const info = await getSapSystemInfo(ctx.adt, ctx.sapAlias);
|
|
29
|
+
if (!info) {
|
|
30
|
+
// Release undetectable. Build a minimal verdict-like JSON: with-fallback
|
|
31
|
+
// when the caller asked for a registry id that HAS a fallback, else no.
|
|
32
|
+
const fallback = isRegistryId(capability)
|
|
33
|
+
? CAPABILITY_REGISTRY[capability].fallback || undefined
|
|
34
|
+
: undefined;
|
|
35
|
+
const feature = isRegistryId(capability) ? CAPABILITY_REGISTRY[capability].feature : capability;
|
|
36
|
+
const verdict = {
|
|
37
|
+
capability,
|
|
38
|
+
feature,
|
|
39
|
+
status: fallback ? 'with-fallback' : 'no',
|
|
40
|
+
reason: 'SAP release could not be detected — verify the system manually before relying on this feature',
|
|
41
|
+
release: 'unknown',
|
|
42
|
+
column: null,
|
|
43
|
+
source: 'matrix-snapshot',
|
|
44
|
+
...(fallback ? { fallback } : {}),
|
|
45
|
+
};
|
|
46
|
+
return { content: JSON.stringify(verdict) };
|
|
47
|
+
}
|
|
48
|
+
const verdict = resolveCapability(info, loadCapabilityMatrix(), capability);
|
|
49
|
+
return { content: JSON.stringify(verdict) };
|
|
50
|
+
}
|
|
51
|
+
registerTool({
|
|
52
|
+
name: 'system_capability',
|
|
53
|
+
description: 'Returns yes / no / with-fallback for whether the CONNECTED SAP release supports a given ABAP '
|
|
54
|
+
+ 'capability, read out of the committed ABAP feature-matrix snapshot — so skills stop hardcoding '
|
|
55
|
+
+ 'version prose like "FILTER needs 7.50". Pass a capability gate id (cds.viewEntity | rap.managed | '
|
|
56
|
+
+ 'abap.filterExpr | abap.inlineData) OR a raw ABAP feature name from the matrix. The verdict carries '
|
|
57
|
+
+ 'the feature consulted, the matrix column used, the reason, and (only for with-fallback) a concrete '
|
|
58
|
+
+ 'fallback instruction. Read-only — never writes to SAP.',
|
|
59
|
+
isMutating: false,
|
|
60
|
+
category: 'sap',
|
|
61
|
+
flagGated: true,
|
|
62
|
+
input_schema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
capability: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'A capability gate id (cds.viewEntity | rap.managed | abap.filterExpr | abap.inlineData) '
|
|
68
|
+
+ 'OR a raw ABAP feature name from the matrix.',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
required: ['capability'],
|
|
72
|
+
},
|
|
73
|
+
handler: systemCapabilityHandler,
|
|
74
|
+
});
|
|
@@ -22,11 +22,20 @@
|
|
|
22
22
|
*
|
|
23
23
|
* The harness clears pendingDispatch at the start of every consume cycle,
|
|
24
24
|
* so a stale dispatch from a prior turn never re-fires.
|
|
25
|
+
*
|
|
26
|
+
* A1 (2026-06-10, defect D23): `/abap-plan --resume …` is REJECTED here.
|
|
27
|
+
* Plan auto-run is harness-owned — commands/plan-resume.ts builds the
|
|
28
|
+
* resume command from the exact path it just saved and queues it itself
|
|
29
|
+
* (offerNextPhaseAutoRun). When the model authored this text it once
|
|
30
|
+
* hallucinated a wrong base token ("@…-plan-c1" → "no file matching")
|
|
31
|
+
* while the harness had just printed the real saved path. The tool stays
|
|
32
|
+
* for every other dispatch (e.g. /abap-generate → /abap-rap switch).
|
|
25
33
|
*/
|
|
26
34
|
import { registerTool } from './index.js';
|
|
35
|
+
import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
27
36
|
registerTool({
|
|
28
37
|
name: 'dispatch_skill',
|
|
29
|
-
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). After this tool returns, end the turn — do not also print a continuation hint.',
|
|
38
|
+
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is harness-owned: the CLI offers and dispatches the next phase itself after the manifest is persisted. After this tool returns, end the turn — do not also print a continuation hint.',
|
|
30
39
|
isMutating: false,
|
|
31
40
|
input_schema: {
|
|
32
41
|
type: 'object',
|
|
@@ -58,6 +67,18 @@ registerTool({
|
|
|
58
67
|
}),
|
|
59
68
|
};
|
|
60
69
|
}
|
|
70
|
+
// A1 (defect D23) — plan auto-run is harness-owned. The CLI builds the
|
|
71
|
+
// resume command from the exact saved envelope path and asks the user
|
|
72
|
+
// itself; a model-authored variant risks a hallucinated file token.
|
|
73
|
+
if (isPlanResumeCommand(command)) {
|
|
74
|
+
return {
|
|
75
|
+
is_error: true,
|
|
76
|
+
content: JSON.stringify({
|
|
77
|
+
error: 'PLAN_RESUME_HARNESS_OWNED',
|
|
78
|
+
reason: 'Do not dispatch /abap-plan --resume. After your plan manifest is persisted, the CLI itself offers the user the next phase and dispatches the resume with the exact saved file in a fresh bounded context. Simply end the turn now.',
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
61
82
|
if (!ctx.pendingDispatch) {
|
|
62
83
|
// Non-REPL path (one-shot, tests). Tell the model the dispatch
|
|
63
84
|
// can't take effect here so it falls back to a continuation hint.
|