@cspeach/cli 1.1.31 → 1.1.33
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/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/cost.js +6 -2
- package/dist/commands/help.js +18 -2
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +4 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +632 -90
- package/dist/commands/usage.js +106 -0
- package/dist/cost/month-usage.js +45 -0
- package/dist/journey/chain.js +265 -15
- package/dist/journey/clean-core.js +25 -3
- package/dist/journey/flow.js +51 -1
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +213 -20
- package/dist/journey/session-deps.js +5 -1
- package/dist/lane/app-chain.js +12 -2
- package/dist/one-shot.js +24 -17
- package/dist/projects/clean-core-confirm.js +38 -5
- package/dist/projects/save-command.js +20 -3
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +49 -19
- package/dist/register/export-files.js +58 -0
- package/dist/register/measure.js +191 -9
- package/dist/register/plan-import.js +6 -3
- package/dist/register/records.js +15 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/register-status-file.js +106 -108
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +1 -0
- package/dist/register/status-html.js +25 -6
- package/dist/register/store.js +4 -3
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +22 -56
- package/dist/register/write-whole.js +30 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/builtin-commands.js +7 -0
- package/dist/repl/journey-entry.js +4 -2
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +157 -86
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_findings.js +13 -2
- package/dist/upgrade-check.js +49 -4
- package/package.json +120 -120
- package/.cspeach/project.json +0 -4
|
@@ -94,7 +94,7 @@ function transportKindOf(err) {
|
|
|
94
94
|
return 'timeout';
|
|
95
95
|
return 'network';
|
|
96
96
|
}
|
|
97
|
-
async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal) {
|
|
97
|
+
async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal, shadowRuns = false) {
|
|
98
98
|
// Review I2 — a cancel that landed before this call (e.g. while prompt
|
|
99
99
|
// images were read) never fires the 'abort' event: honour it here.
|
|
100
100
|
if (signal?.aborted)
|
|
@@ -111,7 +111,10 @@ async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal)
|
|
|
111
111
|
'Authorization': `Bearer ${key}`,
|
|
112
112
|
'Content-Type': 'application/json',
|
|
113
113
|
},
|
|
114
|
-
|
|
114
|
+
// Guided runs (spec §4 step 2): a shadow call asks the proxy for the
|
|
115
|
+
// shadow-only router text; a live call's body is unchanged.
|
|
116
|
+
body: JSON.stringify(shadowRuns ? { prompt, context, shadow: 'runs' }
|
|
117
|
+
: images.length > 0 ? { prompt, context, images } : { prompt, context }),
|
|
115
118
|
signal: ctrl.signal,
|
|
116
119
|
});
|
|
117
120
|
}
|
|
@@ -188,7 +191,8 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
188
191
|
throw new Error('no_api_key');
|
|
189
192
|
if (opts.signal?.aborted)
|
|
190
193
|
throw routingCancelled();
|
|
191
|
-
|
|
194
|
+
// A shadow call never sends images (spec §4 step 2): text only.
|
|
195
|
+
const images = opts.shadowRuns ? [] : await promptImages(prompt, opts.cwd ?? process.cwd());
|
|
192
196
|
if (opts.signal?.aborted)
|
|
193
197
|
throw routingCancelled();
|
|
194
198
|
let r;
|
|
@@ -201,7 +205,7 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
201
205
|
// they bubble through as before.
|
|
202
206
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
203
207
|
try {
|
|
204
|
-
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal);
|
|
208
|
+
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal, opts.shadowRuns === true);
|
|
205
209
|
break;
|
|
206
210
|
}
|
|
207
211
|
catch (err) {
|
|
@@ -36,7 +36,8 @@ export const ROUTER_SCHEMA_VERSION = 1;
|
|
|
36
36
|
* its own bundled copy — so a stale published manifest never rolls a newer
|
|
37
37
|
* CLI back to older routing. router-revision-pin.test.ts fails until bumped.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
// 3 (2026-10-09): runs_system (shadow-only text) added; live text unchanged.
|
|
40
|
+
export const ROUTER_REVISION = 3;
|
|
40
41
|
// 2026-06-12: keep in lockstep with .claude/skills (35 abap skills).
|
|
41
42
|
// abap-dump replaced abap-dump-analyze + abap-dump-triage (portfolio merge);
|
|
42
43
|
// added previously-missing abap-plan, abap-fiori-build, abap-cca-merge, abap-upgrade-merge.
|
|
@@ -90,15 +91,35 @@ export const SKILL_DESCRIPTIONS = {
|
|
|
90
91
|
'abap-upgrade-verify': 'Verify upgrade remediation — re-run ATC, compare against baseline, flag regressions.',
|
|
91
92
|
};
|
|
92
93
|
export const SKILL_LIST = Object.keys(SKILL_DESCRIPTIONS);
|
|
94
|
+
// Guided runs (2026-10-09, spec 2026-10-08 §3.3; review C-1/I-1): a run is a
|
|
95
|
+
// declared, routable entry (.claude/runs/<name>/RUN.md) the CLI runs with code.
|
|
96
|
+
// In SHADOW (CLI 1.1.32) the live text below stays byte-identical for every
|
|
97
|
+
// installed CLI — a label the proxy cannot gate by CLI version would make old
|
|
98
|
+
// CLIs dispatch it as a skill. The run label, the abap-clean-core line and the
|
|
99
|
+
// run note live ONLY in ROUTER_SYSTEM_RUNS, which the shadow call alone asks
|
|
100
|
+
// for (`shadow: 'runs'`). They go live with the flip. run-app is declared but
|
|
101
|
+
// not routed until it has fixtures (§3.5).
|
|
102
|
+
export const CLEAN_CORE_SKILL_LINE = 'Decide and defend the clean-core-correct approach for ONE requirement, proposed approach or object/API — the recommended path, its level (A/B/C/D) and citable evidence. Read-only; a question or a decision, never a multi-step run.';
|
|
103
|
+
export const RUN_DESCRIPTIONS = {
|
|
104
|
+
'run-clean-core': 'Start, or carry on with, the guided clean-core run on named packages (ZFI_AR, ZFI_GL): inventory, start the team project, decide per object, fix, prove with the check, report. Not a question about clean core.',
|
|
105
|
+
};
|
|
106
|
+
export const RUN_LIST = Object.keys(RUN_DESCRIPTIONS);
|
|
107
|
+
export const RUN_NOTE = '- Starting or continuing a multi-step clean-core run on named packages → run-clean-core; a question about clean core → abap-clean-core; inventorying the estate → abap-cca.';
|
|
108
|
+
/** The skill lines; with `runs`, abap-clean-core joins in alphabetical place. */
|
|
109
|
+
function skillLines(runs) {
|
|
110
|
+
const names = runs ? [...SKILL_LIST, 'abap-clean-core'].sort() : SKILL_LIST;
|
|
111
|
+
return names.map((n) => `- ${n} — ${n === 'abap-clean-core' ? CLEAN_CORE_SKILL_LINE : SKILL_DESCRIPTIONS[n]}`).join('\n');
|
|
112
|
+
}
|
|
93
113
|
// Screenshot-aware routing (2026-09-23) — appended ONLY when images are sent,
|
|
94
114
|
// so text-only routing keeps the exact pre-feature instructions.
|
|
95
115
|
export const SCREENSHOT_NOTE = `
|
|
96
116
|
|
|
97
117
|
A screenshot is attached. Read it (error text, transaction code such as ST22/ATC/SE80, object names) and classify on the prompt AND the screenshot together — a vague prompt ("can you see this?") with an ST22 dump is abap-dump. Also add "screenshot": a one-line reading of it (e.g. "ST22 dump: CX_SY_ZERODIVIDE in ZCL_X, method CALC").`;
|
|
98
|
-
|
|
118
|
+
function systemText(runs) {
|
|
119
|
+
return `You classify user prompts into ABAP-development skills. Given a prompt and context, return JSON: {"skill": "<name>", "confidence": 0-1, "alternates": ["<name>"], "reasoning": "..."}.
|
|
99
120
|
|
|
100
121
|
Skills (name — what it is for):
|
|
101
|
-
${
|
|
122
|
+
${skillLines(runs)}${runs ? `\n\nGuided runs (name — what it is for; answer with the run name as "skill"):\n${RUN_LIST.map((n) => `- ${n} — ${RUN_DESCRIPTIONS[n]}`).join('\n')}` : ''}
|
|
102
123
|
|
|
103
124
|
Routing notes:
|
|
104
125
|
- Editing, changing, or adding a feature to an EXISTING custom (Z*/Y*) object → abap-refactor (in-place change) or abap-generate (new objects). abap-enhance is ONLY for SAP-delivered STANDARD objects — never for custom code.
|
|
@@ -107,14 +128,19 @@ Routing notes:
|
|
|
107
128
|
- Building an app FROM a spec or requirements document (Word / PDF / Excel, 'build the app in this spec') → abap-app. A direct request without a spec document keeps its own skill.
|
|
108
129
|
- Quick cold-read / orientation of an object → abap-explain (technical audience).
|
|
109
130
|
- Performance issues / N+1 / SELECT * → abap-review (performance lens). Clean-core / ABAP Cloud readiness of one object → abap-review (cloud lens); estate-wide clean-core → abap-cca.
|
|
110
|
-
- Developer documentation of an object → abap-handover (object scope)
|
|
131
|
+
- Developer documentation of an object → abap-handover (object scope).${runs ? `\n${RUN_NOTE}` : ''}
|
|
111
132
|
|
|
112
133
|
Pick the single best skill. Confidence is 0.75+ if the intent clearly matches one skill, 0.6-0.75 if two skills are plausible, <0.6 if none are clearly right.`;
|
|
134
|
+
}
|
|
135
|
+
export const ROUTER_SYSTEM = systemText(false);
|
|
136
|
+
/** The shadow-only text: live + abap-clean-core + the run label + the run note. */
|
|
137
|
+
export const ROUTER_SYSTEM_RUNS = systemText(true);
|
|
113
138
|
export const ROUTER_PROMPT = {
|
|
114
139
|
schema_version: ROUTER_SCHEMA_VERSION,
|
|
115
140
|
revision: ROUTER_REVISION,
|
|
116
141
|
system: ROUTER_SYSTEM,
|
|
117
142
|
screenshot_note: SCREENSHOT_NOTE,
|
|
143
|
+
runs_system: ROUTER_SYSTEM_RUNS,
|
|
118
144
|
};
|
|
119
145
|
/**
|
|
120
146
|
* A router entry read from outside (the signed manifest), or null when it is
|
|
@@ -134,7 +160,13 @@ export function asRouterPrompt(raw) {
|
|
|
134
160
|
return null;
|
|
135
161
|
if (typeof r.screenshot_note !== 'string')
|
|
136
162
|
return null;
|
|
137
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
schema_version: ROUTER_SCHEMA_VERSION,
|
|
165
|
+
revision: r.revision,
|
|
166
|
+
system: r.system,
|
|
167
|
+
screenshot_note: r.screenshot_note,
|
|
168
|
+
...(typeof r.runs_system === 'string' && r.runs_system.trim() !== '' ? { runs_system: r.runs_system } : {}),
|
|
169
|
+
};
|
|
138
170
|
}
|
|
139
171
|
const tok = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
140
172
|
// Live fix 2026-10-02: some prompts made the router answer in prose instead of
|
|
@@ -210,8 +242,12 @@ export function routerUserMessage(prompt, context) {
|
|
|
210
242
|
return `Prompt: ${prompt}\n\nContext: sap_system=${context.sap_system ?? 'unknown'}, last_skill=${context.last_skill ?? 'none'}`;
|
|
211
243
|
}
|
|
212
244
|
/** The router request body (Messages API), exactly as the proxy has sent it since 2026-10-02. */
|
|
213
|
-
export function buildRouterRequest(model, prompt, context, images = [], routerPrompt = ROUTER_PROMPT
|
|
245
|
+
export function buildRouterRequest(model, prompt, context, images = [], routerPrompt = ROUTER_PROMPT,
|
|
246
|
+
// Guided runs (2026-10-09): `runs` sends the shadow-only text; without it
|
|
247
|
+
// the request is byte-identical to the live one.
|
|
248
|
+
opts = {}) {
|
|
214
249
|
const userMsg = routerUserMessage(prompt, context);
|
|
250
|
+
const base = opts.runs ? (routerPrompt.runs_system ?? ROUTER_SYSTEM_RUNS) : routerPrompt.system;
|
|
215
251
|
return {
|
|
216
252
|
model,
|
|
217
253
|
// Root cause found live 2026-10-02 (v54): the router model (claude-sonnet-5)
|
|
@@ -220,7 +256,7 @@ export function buildRouterRequest(model, prompt, context, images = [], routerPr
|
|
|
220
256
|
// a quick pick: thinking off, and headroom for the JSON answer.
|
|
221
257
|
max_tokens: 600,
|
|
222
258
|
thinking: { type: 'disabled' },
|
|
223
|
-
system: images.length === 0 ?
|
|
259
|
+
system: images.length === 0 ? base : base + routerPrompt.screenshot_note,
|
|
224
260
|
// 2026-10-02: JSON via structured outputs (see the note above).
|
|
225
261
|
output_config: { format: { type: 'json_schema', schema: resultSchema(images.length > 0) } },
|
|
226
262
|
// Screenshot-aware routing (2026-09-23): images ride ahead of the text in
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// (Layer B spec), extend the silent-arm with a margin check: if
|
|
7
7
|
// top-2.confidence is within 0.15 of top-1, demote to 'coach'. One extra
|
|
8
8
|
// `if` — no API change to this file's exports.
|
|
9
|
-
const HIGH_THRESHOLD = 0.85;
|
|
9
|
+
export const HIGH_THRESHOLD = 0.85;
|
|
10
10
|
const COACH_THRESHOLD = 0.60;
|
|
11
11
|
export function decideRouting(result) {
|
|
12
12
|
if (result.confidence >= HIGH_THRESHOLD) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const BUNDLED_RUNS = [
|
|
2
|
+
{
|
|
3
|
+
"name": "run-app",
|
|
4
|
+
"kind": "guided-run",
|
|
5
|
+
"title": "Guided app build from a spec",
|
|
6
|
+
"version": "1.0",
|
|
7
|
+
"min_cli_version": "1.1.32",
|
|
8
|
+
"description": "Build an app from a spec file (Word, PDF, Excel or text): find the gaps, pick the app lane (backend and UI) on a card, then plan and build.",
|
|
9
|
+
"entry": "/run app <@spec>",
|
|
10
|
+
"alias": "/abap-app <@spec>",
|
|
11
|
+
"resume_words": [],
|
|
12
|
+
"steps": [
|
|
13
|
+
{
|
|
14
|
+
"id": "GAPS",
|
|
15
|
+
"kind": "skill",
|
|
16
|
+
"skill": "abap-spec-gap",
|
|
17
|
+
"min": "1.0"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "LANE",
|
|
21
|
+
"kind": "skill",
|
|
22
|
+
"skill": "abap-app",
|
|
23
|
+
"mode": "Stage 2 — what to do",
|
|
24
|
+
"min": "1.0"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "PLAN",
|
|
28
|
+
"kind": "skill",
|
|
29
|
+
"skill": "abap-plan",
|
|
30
|
+
"min": "1.2"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"cards": [
|
|
34
|
+
"Build this now?"
|
|
35
|
+
],
|
|
36
|
+
"engine": "lane/app-chain.ts",
|
|
37
|
+
"paragraph": "Builds an app from a spec file. CSPeach finds the gaps in the spec, picks the app lane (the backend and the UI) and shows it on a card, then plans the build and runs it. The lane is picked by CSPeach, never by the model. State lives in the saved spec-gap and plan files in this folder.",
|
|
38
|
+
"source_sha256": "db993138068ee8e2dd675ed2af500576c4ecf9d7e898575eddcdd6e08761a1bb"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "run-clean-core",
|
|
42
|
+
"kind": "guided-run",
|
|
43
|
+
"title": "Guided clean-core run",
|
|
44
|
+
"version": "1.0",
|
|
45
|
+
"min_cli_version": "1.1.32",
|
|
46
|
+
"description": "Start, or carry on with, the guided clean-core run on named packages (ZFI_AR, ZFI_GL): inventory, start the team project, decide per object, fix, prove with the check, report. Not a question about clean core.",
|
|
47
|
+
"entry": "/run clean-core <PKG…>",
|
|
48
|
+
"alias": "/abap-clean-core start <PKG…>",
|
|
49
|
+
"resume_words": [
|
|
50
|
+
"carry on",
|
|
51
|
+
"continue",
|
|
52
|
+
"continue clean core",
|
|
53
|
+
"carry on with clean core"
|
|
54
|
+
],
|
|
55
|
+
"steps": [
|
|
56
|
+
{
|
|
57
|
+
"id": "INVENTORY",
|
|
58
|
+
"kind": "code",
|
|
59
|
+
"does": "team discover"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "START",
|
|
63
|
+
"kind": "code",
|
|
64
|
+
"does": "team init or join, then the first check per package"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "DECIDE",
|
|
68
|
+
"kind": "skill",
|
|
69
|
+
"skill": "abap-clean-core",
|
|
70
|
+
"mode": "Triage mode",
|
|
71
|
+
"min": "1.0"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "FIX",
|
|
75
|
+
"kind": "skill",
|
|
76
|
+
"skill": "abap-refactor",
|
|
77
|
+
"min": "1.1"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "PROVE",
|
|
81
|
+
"kind": "code",
|
|
82
|
+
"does": "team refresh with the project's check variant"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "REPORT",
|
|
86
|
+
"kind": "code",
|
|
87
|
+
"does": "team report"
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"cards": [
|
|
91
|
+
"Look at the system?",
|
|
92
|
+
"Start the project here?",
|
|
93
|
+
"Run the first check per package now?",
|
|
94
|
+
"Fix X now?"
|
|
95
|
+
],
|
|
96
|
+
"engine": "journey/chain.ts",
|
|
97
|
+
"paragraph": "Starts a clean-core run on the packages you name. CSPeach looks at the system, starts a team project in this folder, proposes a level and a target per object, fixes what you approve, proves each fix with the check, and writes the report. It never writes to SAP without a card. Its state lives in the project files in this folder and in this session; say carry on to pick up after a pause.",
|
|
98
|
+
"source_sha256": "9fd8ec6b3eac4fa24e664c36641e779ae08d1194a1eb8aeb1b55bf5eda4dcbde"
|
|
99
|
+
}
|
|
100
|
+
];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resolveRunCommand } from './run-command.js';
|
|
2
|
+
export async function detectRunStart(input, ctx) {
|
|
3
|
+
const cmd = resolveRunCommand(input);
|
|
4
|
+
if (cmd.kind === 'line')
|
|
5
|
+
return cmd;
|
|
6
|
+
const text = cmd.kind === 'rewrite' ? cmd.command : input;
|
|
7
|
+
const { detectJourney, parseJourneyStart } = await import('../journey/trigger.js');
|
|
8
|
+
const trigger = ctx.resume ? detectJourney(text, { cwd: ctx.cwd, session: ctx.session }) : parseJourneyStart(text);
|
|
9
|
+
if (!trigger) {
|
|
10
|
+
// I-5: a /run with nothing after the name, whose alias is not a trigger,
|
|
11
|
+
// would be an empty skill turn — say what the run needs instead.
|
|
12
|
+
if (cmd.kind === 'rewrite' && cmd.tail === '')
|
|
13
|
+
return { kind: 'line', line: `Add what the run needs: ${cmd.entry}` };
|
|
14
|
+
return { kind: 'input', input: text };
|
|
15
|
+
}
|
|
16
|
+
if ((trigger.kind === 'start' || trigger.kind === 'refuse') && !text.trimStart().startsWith('/'))
|
|
17
|
+
ctx.onFreeTextStart?.(text);
|
|
18
|
+
return { kind: 'run', trigger, input: text };
|
|
19
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const VERSION = /^\d+(\.\d+){0,2}$/;
|
|
2
|
+
const NAME = /^run-[a-z0-9-]+$/;
|
|
3
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
4
|
+
export function asRunEntries(raw) {
|
|
5
|
+
if (!Array.isArray(raw))
|
|
6
|
+
return [];
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const e of raw) {
|
|
9
|
+
if (!e || typeof e !== 'object' || Array.isArray(e))
|
|
10
|
+
continue;
|
|
11
|
+
const r = e;
|
|
12
|
+
const name = str(r.name);
|
|
13
|
+
const min = str(r.min_cli_version);
|
|
14
|
+
if (!NAME.test(name) || !VERSION.test(min))
|
|
15
|
+
continue;
|
|
16
|
+
const steps = Array.isArray(r.steps)
|
|
17
|
+
? r.steps.flatMap((s) => {
|
|
18
|
+
if (!s || typeof s !== 'object')
|
|
19
|
+
return [];
|
|
20
|
+
const o = s;
|
|
21
|
+
if (o.kind !== 'code' && o.kind !== 'skill')
|
|
22
|
+
return [];
|
|
23
|
+
return [{ id: str(o.id), kind: o.kind, ...(typeof o.skill === 'string' ? { skill: o.skill } : {}), ...(typeof o.min === 'string' ? { min: o.min } : {}) }];
|
|
24
|
+
})
|
|
25
|
+
: [];
|
|
26
|
+
out.push({ name, version: str(r.version), min_cli_version: min, title: str(r.title), entry: str(r.entry), alias: str(r.alias), steps, sha256: str(r.sha256) });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function compare(a, b) {
|
|
31
|
+
const pa = a.split('.').map(Number);
|
|
32
|
+
const pb = b.split('.').map(Number);
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
35
|
+
if (d !== 0)
|
|
36
|
+
return d;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
/** The run whose min_cli_version is above `current` — the highest one; null when every run is satisfied. */
|
|
41
|
+
export function runsFloor(entries, current) {
|
|
42
|
+
let best = null;
|
|
43
|
+
for (const r of entries) {
|
|
44
|
+
if (compare(current, r.min_cli_version) >= 0)
|
|
45
|
+
continue;
|
|
46
|
+
if (!best || compare(r.min_cli_version, best.min) > 0)
|
|
47
|
+
best = { name: r.name, min: r.min_cli_version };
|
|
48
|
+
}
|
|
49
|
+
return best;
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The /run verb (spec 2026-10-08 §6.1): `/run <name> <tail>` is rewritten to
|
|
2
|
+
// the run's declared alias (RUN.md `alias`, its <…> placeholder = the tail)
|
|
3
|
+
// and goes down today's path unchanged — /run clean-core ZFI_AR becomes
|
|
4
|
+
// /abap-clean-core start ZFI_AR. No new engine entry, no new routing.
|
|
5
|
+
import { BUNDLED_RUNS, runByShortName } from './runs.js';
|
|
6
|
+
const RUN_LINE = /^\/run(?:\s+([\s\S]*))?$/i;
|
|
7
|
+
const SHORT = /^[a-z0-9-]{1,30}$/i;
|
|
8
|
+
export const RUNS_USAGE_LINE = () => `Guided runs: ${BUNDLED_RUNS.map((r) => r.entry).join(' · ')}. Type /help for what each one does.`;
|
|
9
|
+
export function resolveRunCommand(input) {
|
|
10
|
+
const m = RUN_LINE.exec(input.trim());
|
|
11
|
+
if (!m)
|
|
12
|
+
return { kind: 'none' };
|
|
13
|
+
const rest = (m[1] ?? '').trim();
|
|
14
|
+
if (!rest)
|
|
15
|
+
return { kind: 'line', line: RUNS_USAGE_LINE() };
|
|
16
|
+
const short = rest.split(/\s+/, 1)[0] ?? '';
|
|
17
|
+
const run = SHORT.test(short) ? runByShortName(short) : undefined;
|
|
18
|
+
if (!run)
|
|
19
|
+
return { kind: 'line', line: `No guided run called ${SHORT.test(short) ? short : 'that'}. ${RUNS_USAGE_LINE()}` };
|
|
20
|
+
const prefix = run.alias.slice(0, run.alias.indexOf('<')).trimEnd();
|
|
21
|
+
// The tail as typed (line breaks kept): for /run app it is the skill turn's body.
|
|
22
|
+
const tail = rest.slice(short.length).trim();
|
|
23
|
+
return { kind: 'rewrite', command: `${prefix} ${tail}`.trim(), run: run.name, entry: run.entry, tail };
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Guided runs — typed access to the bundled declarations (spec 2026-10-08 §3).
|
|
2
|
+
// A declaration names, describes and versions; the engine (code) runs.
|
|
3
|
+
import { BUNDLED_RUNS } from './bundled-runs.js';
|
|
4
|
+
export { BUNDLED_RUNS };
|
|
5
|
+
/** The router labels of the declared runs ("run-" prefix). */
|
|
6
|
+
export const RUN_LABELS = new Set(BUNDLED_RUNS.map((r) => r.name));
|
|
7
|
+
export const isRunLabel = (s) => RUN_LABELS.has(s);
|
|
8
|
+
export function runByName(name) {
|
|
9
|
+
return BUNDLED_RUNS.find((r) => r.name === name);
|
|
10
|
+
}
|
|
11
|
+
/** The word typed after /run ('clean-core'), any case. */
|
|
12
|
+
export function runByShortName(short) {
|
|
13
|
+
return runByName(`run-${short.toLowerCase()}`);
|
|
14
|
+
}
|
|
15
|
+
/** 'run-clean-core (step DECIDE)' for a skill a run uses; undefined otherwise. */
|
|
16
|
+
export function partOfRun(skill) {
|
|
17
|
+
for (const r of BUNDLED_RUNS) {
|
|
18
|
+
const step = r.steps.find((s) => s.kind === 'skill' && s.skill === skill);
|
|
19
|
+
if (step)
|
|
20
|
+
return `${r.name} (step ${step.id})`;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
/** The first skill step's skill — what a run label falls back to while routing is in shadow. */
|
|
25
|
+
export function firstSkillOf(run) {
|
|
26
|
+
return run.steps.find((s) => s.kind === 'skill')?.skill;
|
|
27
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Guided runs, shadow routing (spec 2026-10-08 §4 step 2). The proxy has a
|
|
2
|
+
// shadow-only router text that knows the run label (Task 8); trigger.ts still
|
|
3
|
+
// decides. This module counts whether the two agree, so the flip (§4 step 3)
|
|
4
|
+
// can be gated on evidence:
|
|
5
|
+
// both the trigger started a run AND the router said the run label at ≥ 0.85 (would route silently)
|
|
6
|
+
// trigger-only the trigger started a run; the router gave a real answer that was anything else ← the gate: must stay 0
|
|
7
|
+
// unknown no real answer (offline, unparseable, error, or not managed mode)
|
|
8
|
+
// unexpected a LIVE answer carried a run label (cannot happen with the live text; guarded anyway)
|
|
9
|
+
// The shadow sends the start sentence to our router for classification, the
|
|
10
|
+
// same as any typed message today (managed mode only). Counts only are kept:
|
|
11
|
+
// never the prompt, a package, a session. File: ~/.cspeach/shadow-runs.json.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
15
|
+
import { isKnownSkill } from '../router/classifier.js';
|
|
16
|
+
import { HIGH_THRESHOLD } from '../router/routing-decision.js';
|
|
17
|
+
import { firstSkillOf, isRunLabel, runByName } from './runs.js';
|
|
18
|
+
const FIELD = {
|
|
19
|
+
both: 'both', 'trigger-only': 'triggerOnly', unknown: 'unknown', unexpected: 'unexpected',
|
|
20
|
+
};
|
|
21
|
+
export function shadowFile() {
|
|
22
|
+
return path.join(cspeachRoot(), 'shadow-runs.json');
|
|
23
|
+
}
|
|
24
|
+
export function readShadowCounts(file = shadowFile()) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
28
|
+
return {};
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [run, v] of Object.entries(raw)) {
|
|
31
|
+
if (!isRunLabel(run) || !v || typeof v !== 'object')
|
|
32
|
+
continue;
|
|
33
|
+
const c = v;
|
|
34
|
+
const n = (k) => (typeof c[k] === 'number' && Number.isFinite(c[k]) ? c[k] : 0);
|
|
35
|
+
out[run] = { since: typeof c.since === 'string' ? c.since : new Date().toISOString(), both: n('both'), triggerOnly: n('triggerOnly'), unknown: n('unknown'), unexpected: n('unexpected') };
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Add one to a count. Best effort and silent: a broken file is started over; an unwritable one is ignored. */
|
|
44
|
+
export function recordShadow(run, kind, file = shadowFile()) {
|
|
45
|
+
try {
|
|
46
|
+
const all = readShadowCounts(file);
|
|
47
|
+
const c = all[run] ?? { since: new Date().toISOString(), both: 0, triggerOnly: 0, unknown: 0, unexpected: 0 };
|
|
48
|
+
c[FIELD[kind]] += 1;
|
|
49
|
+
all[run] = c;
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
51
|
+
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Counting never gets in the way of the run, and never writes to the screen.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The kind for a start the trigger accepted, given what the router said (null = no answer). Review I-2. */
|
|
58
|
+
export function shadowKindFor(result, run) {
|
|
59
|
+
if (!result || result.offline || result.reasoning === 'router_unparseable')
|
|
60
|
+
return 'unknown';
|
|
61
|
+
return result.skill === run && result.confidence >= HIGH_THRESHOLD ? 'both' : 'trigger-only';
|
|
62
|
+
}
|
|
63
|
+
/** Shadow-classify a free-text start the trigger accepted. Resolves always; the REPL does not await it. */
|
|
64
|
+
export async function shadowStart(text, run, deps) {
|
|
65
|
+
const record = deps.record ?? recordShadow;
|
|
66
|
+
if (deps.llmMode !== 'managed') {
|
|
67
|
+
record(run, 'unknown');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let result = null;
|
|
71
|
+
try {
|
|
72
|
+
result = await deps.classify(text, deps.context);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
result = null;
|
|
76
|
+
}
|
|
77
|
+
record(run, shadowKindFor(result, run));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Defensive guard on a LIVE answer: the live router text has no run label
|
|
81
|
+
* (Task 8), so this never fires in this release. If it ever does: count it,
|
|
82
|
+
* hand the dispatcher a real skill (first known alternate, else the run's
|
|
83
|
+
* first skill step), drop run labels from alternates, and keep the confidence
|
|
84
|
+
* under the silent threshold so the person sees the picker, never a silent
|
|
85
|
+
* dispatch of a skill the router did not score (review I-1).
|
|
86
|
+
*/
|
|
87
|
+
export function guardRunLabel(result, record = recordShadow) {
|
|
88
|
+
const alternates = result.alternates.filter((a) => !isRunLabel(a));
|
|
89
|
+
if (!isRunLabel(result.skill))
|
|
90
|
+
return alternates.length === result.alternates.length ? result : { ...result, alternates };
|
|
91
|
+
record(result.skill, 'unexpected');
|
|
92
|
+
const run = runByName(result.skill);
|
|
93
|
+
const fallback = alternates.find(isKnownSkill) ?? (run ? firstSkillOf(run) : undefined) ?? 'abap-explain';
|
|
94
|
+
return {
|
|
95
|
+
...result,
|
|
96
|
+
skill: fallback,
|
|
97
|
+
confidence: Math.min(result.confidence, HIGH_THRESHOLD - 0.01),
|
|
98
|
+
alternates: alternates.filter((a) => a !== fallback && isKnownSkill(a)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/session/queued-step.ts
|
|
2
|
+
//
|
|
3
|
+
// Audit M-2 (2026-10-08) — who queued the command in the pending-dispatch slot.
|
|
4
|
+
//
|
|
5
|
+
// The slot holds the next command the REPL runs with no keypress. CSPeach
|
|
6
|
+
// itself fills it (the clean-core journey's DECIDE/FIX step, the rebuild
|
|
7
|
+
// hand-off, the app chain, the plan chain), and so does dispatch_skill after
|
|
8
|
+
// the person picked "Switch to /abap-X". A command CSPeach built is never shown
|
|
9
|
+
// as if the person typed it ("You › …"): its source tags it with an origin when
|
|
10
|
+
// it queues it, and both REPLs (Ink and classic) render it through
|
|
11
|
+
// queuedStepView — one rendering for every origin. A dispatch_skill switch is
|
|
12
|
+
// tagged `switch` (must-fix follow-up b): the person agreed to the switch, but
|
|
13
|
+
// the exact command was written by the model, so it shows as "Auto-routed: …",
|
|
14
|
+
// never as typed. An untagged command keeps the old echo.
|
|
15
|
+
import { shownUpTo } from '../register/atc-file.js';
|
|
16
|
+
export function createDispatchSlot() {
|
|
17
|
+
let cmd = null;
|
|
18
|
+
let origin = null;
|
|
19
|
+
return {
|
|
20
|
+
get: () => cmd,
|
|
21
|
+
set(command, o) {
|
|
22
|
+
cmd = command;
|
|
23
|
+
origin = command === null ? null : o ?? null;
|
|
24
|
+
},
|
|
25
|
+
take() {
|
|
26
|
+
if (cmd === null)
|
|
27
|
+
return null;
|
|
28
|
+
const q = { cmd, origin };
|
|
29
|
+
cmd = null;
|
|
30
|
+
origin = null;
|
|
31
|
+
return q;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* How a dispatched command shows on screen. A CSPeach step: its own line (or
|
|
37
|
+
* nothing when the source already said it), never the "You ›" echo. No origin:
|
|
38
|
+
* the echo, as for anything the person chose.
|
|
39
|
+
*/
|
|
40
|
+
export function queuedStepView(origin) {
|
|
41
|
+
if (!origin)
|
|
42
|
+
return { echo: true };
|
|
43
|
+
const line = origin.line ? shownUpTo(origin.line, 160).trim() : '';
|
|
44
|
+
return { echo: false, line: line || null };
|
|
45
|
+
}
|