@cspeach/cli 1.1.31 → 1.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/help.js +16 -1
  4. package/dist/commands/plan-chain.js +3 -0
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +4 -0
  7. package/dist/commands/team-rules.js +187 -0
  8. package/dist/commands/team.js +632 -90
  9. package/dist/journey/chain.js +265 -15
  10. package/dist/journey/clean-core.js +25 -3
  11. package/dist/journey/flow.js +51 -1
  12. package/dist/journey/rebuild.js +464 -0
  13. package/dist/journey/run.js +213 -20
  14. package/dist/journey/session-deps.js +5 -1
  15. package/dist/lane/app-chain.js +12 -2
  16. package/dist/one-shot.js +24 -17
  17. package/dist/projects/clean-core-confirm.js +38 -5
  18. package/dist/projects/save-command.js +20 -3
  19. package/dist/register/baseline.js +39 -2
  20. package/dist/register/decision-rules.js +35 -0
  21. package/dist/register/discover.js +49 -19
  22. package/dist/register/export-files.js +58 -0
  23. package/dist/register/measure.js +191 -9
  24. package/dist/register/plan-import.js +6 -3
  25. package/dist/register/records.js +15 -3
  26. package/dist/register/rule-proposal.js +4 -0
  27. package/dist/register/rules-edit.js +257 -0
  28. package/dist/register/status-file/register-status-file.js +106 -108
  29. package/dist/register/status-file/report-model.js +569 -0
  30. package/dist/register/status-file/team-css.js +1 -1
  31. package/dist/register/status-file-sync.js +1 -0
  32. package/dist/register/status-html.js +25 -6
  33. package/dist/register/store.js +4 -3
  34. package/dist/register/variant.js +65 -0
  35. package/dist/register/workbooks.js +22 -56
  36. package/dist/register/write-whole.js +30 -2
  37. package/dist/renderer/tool-labels.js +2 -1
  38. package/dist/repl/builtin-commands.js +4 -0
  39. package/dist/repl/journey-entry.js +4 -2
  40. package/dist/repl/plan-hop.js +2 -9
  41. package/dist/repl/rules-menu.js +177 -0
  42. package/dist/repl/slash-completer.js +1 -0
  43. package/dist/repl/slash-picker.js +5 -4
  44. package/dist/repl.js +145 -86
  45. package/dist/router/classifier.js +8 -4
  46. package/dist/router/router-core.js +43 -7
  47. package/dist/router/routing-decision.js +1 -1
  48. package/dist/runs/bundled-runs.js +100 -0
  49. package/dist/runs/front-door.js +19 -0
  50. package/dist/runs/manifest-runs.js +50 -0
  51. package/dist/runs/run-command.js +24 -0
  52. package/dist/runs/runs.js +27 -0
  53. package/dist/runs/shadow.js +100 -0
  54. package/dist/runs/types.js +1 -0
  55. package/dist/session/queued-step.js +45 -0
  56. package/dist/skills/bundled-skills.js +53 -53
  57. package/dist/tools/dispatch-skill.js +8 -4
  58. package/dist/tools/lane/app-lane-resolve.js +1 -1
  59. package/dist/tools/project/rules_propose.js +59 -0
  60. package/dist/tools/project/team_findings.js +13 -2
  61. package/dist/upgrade-check.js +49 -4
  62. package/package.json +120 -120
  63. package/.cspeach/project.json +0 -4
@@ -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
- export const ROUTER_REVISION = 2;
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
- export const ROUTER_SYSTEM = `You classify user prompts into ABAP-development skills. Given a prompt and context, return JSON: {"skill": "<name>", "confidence": 0-1, "alternates": ["<name>"], "reasoning": "..."}.
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
- ${SKILL_LIST.map((n) => `- ${n} — ${SKILL_DESCRIPTIONS[n]}`).join('\n')}
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 { schema_version: ROUTER_SCHEMA_VERSION, revision: r.revision, system: r.system, screenshot_note: r.screenshot_note };
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 ? routerPrompt.system : routerPrompt.system + routerPrompt.screenshot_note,
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
+ }