@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
@@ -0,0 +1,177 @@
1
+ import { DEFAULTS_PINNED_LINE, refuseUnreadable, requireRulesLead, rulesNow, rulesRefusal, saveRuleEdit, stampFor } from '../commands/team-rules.js';
2
+ import { activeProject, RegisterError } from '../register/store.js';
3
+ import { addFromTemplate, addProposed, removeRule, resetRules, ruleWords, rulesTableLines, setOutcome, setThreshold, toggleRule, RULE_TEMPLATES, } from '../register/rules-edit.js';
4
+ import { clearRuleProposal, ruleProposalFor } from '../register/rule-proposal.js';
5
+ export const NOT_JOINED_LINE = 'This laptop has not joined a team project. Run: cspeach team join <folder>';
6
+ const NOTHING_SAVED = 'Nothing was saved.';
7
+ /** A menu that never ends would hold the session; a lead does a handful of changes at a sitting. */
8
+ const MAX_ROUNDS = 100;
9
+ const DISPOSITION_CHOICES = [
10
+ { value: 'keep', label: 'Keep' }, { value: 'fix', label: 'Fix' }, { value: 'rebuild', label: 'Rebuild' }, { value: 'retire-candidate', label: 'Retire candidate' },
11
+ ];
12
+ let cardSeq = 0;
13
+ const card = (title, choices, aliases = {}, facts) => ({
14
+ id: `rules-${(cardSeq += 1)}`, tag: 'QUESTION', title, choices, initial: 0, aliases, ...(facts ? { facts } : {}),
15
+ });
16
+ const pick = async (o, req) => {
17
+ const res = await o.confirm(req);
18
+ return res.cancelled ? null : res.value;
19
+ };
20
+ /** The change for one rule, from its own menu; null = back, or nothing to save. */
21
+ async function ruleChange(o, rule, rules) {
22
+ const stamp = stampFor(o.deps);
23
+ const what = await pick(o, card(`Rule ${rule.id}: ${ruleWords(rule)}`, [
24
+ { value: 'toggle', label: rule.on ? 'Turn off' : 'Turn on' },
25
+ { value: 'threshold', label: 'Change threshold' },
26
+ { value: 'outcome', label: 'Change outcome' },
27
+ { value: 'remove', label: 'Remove' },
28
+ { value: 'back', label: 'Back' },
29
+ ], { '1': 'toggle', '2': 'threshold', '3': 'outcome', '4': 'remove', b: 'back' }, rule.note ? [`Why: ${rule.note}`] : undefined));
30
+ switch (what) {
31
+ case 'toggle': return toggleRule(rules, rule.id, !rule.on, stamp);
32
+ case 'remove': return removeRule(rules, rule.id);
33
+ case 'threshold': {
34
+ // A rule that only caps the count is changed at its cap; any other at its floor.
35
+ const atMost = rule.when.maxFindings !== undefined && rule.when.minFindings === undefined;
36
+ const counts = rule.when.maxFindings !== undefined || rule.when.minFindings !== undefined;
37
+ const answer = (await o.ask(atMost ? 'At most how many findings?'
38
+ : counts ? 'More than how many findings?' : 'Add a condition: more than how many findings?')).trim();
39
+ if (!/^\d+$/.test(answer)) {
40
+ if (answer)
41
+ o.print('✗ The number of findings needs a whole number of 0 or more, for example 15.');
42
+ return null;
43
+ }
44
+ const n = Number(answer);
45
+ return setThreshold(rules, rule.id, atMost ? { maxFindings: n } : { minFindings: n + 1 }, stamp);
46
+ }
47
+ case 'outcome': {
48
+ const choices = [...DISPOSITION_CHOICES.filter((d) => d.value !== rule.then.disposition), { value: 'cancel', label: 'Cancel' }];
49
+ const to = await pick(o, card(`Rule ${rule.id}: what should it propose?`, choices));
50
+ return !to || to === 'cancel' ? null : setOutcome(rules, rule.id, to, stamp);
51
+ }
52
+ default: return null;
53
+ }
54
+ }
55
+ async function addChange(o, rules) {
56
+ const names = Object.keys(RULE_TEMPLATES);
57
+ const choices = [
58
+ ...names.map((n) => ({ value: n, label: `${n}: ${ruleWords({ id: '', on: true, ...RULE_TEMPLATES[n] })}` })),
59
+ { value: 'cancel', label: 'Cancel' },
60
+ ];
61
+ const name = await pick(o, card('Add which rule? It goes last: rules are tried in order.', choices));
62
+ return !name || name === 'cancel' ? null : addFromTemplate(rules, name, stampFor(o.deps));
63
+ }
64
+ export async function runRulesMenu(o) {
65
+ const joined = activeProject();
66
+ if (!joined) {
67
+ o.print(NOT_JOINED_LINE);
68
+ return;
69
+ }
70
+ for (let round = 0; round < MAX_ROUNDS; round++) {
71
+ const now = rulesNow(joined.projectDir);
72
+ for (const line of rulesTableLines(now.rules, now.from, now.warning))
73
+ o.print(line);
74
+ try {
75
+ requireRulesLead(now.project, o.deps);
76
+ }
77
+ catch (e) {
78
+ if (!(e instanceof RegisterError))
79
+ throw e;
80
+ o.print(rulesRefusal(now.project.lead.name));
81
+ return;
82
+ }
83
+ // A rule proposed from words (rules_propose) waits here until the person saves it.
84
+ const proposed = ruleProposalFor(joined.projectId);
85
+ const choices = [
86
+ ...now.rules.map((r) => ({ value: `rule:${r.id}`, label: `${r.id} · ${ruleWords(r)}${r.on ? '' : ' (off)'}` })),
87
+ ...(proposed ? [{ value: 'proposed', label: `Add the proposed rule: ${proposed.words}` }] : []),
88
+ { value: 'add', label: 'Add a rule' }, { value: 'reset', label: 'Restore defaults' }, { value: 'done', label: 'Done' },
89
+ ];
90
+ const aliases = Object.fromEntries(choices.map((c, i) => [String(i + 1), c.value]));
91
+ aliases.d = 'done';
92
+ const choice = await pick(o, card('Decision rules: pick a rule to change it, or', choices, aliases));
93
+ if (!choice || choice === 'done')
94
+ return;
95
+ let edit = null;
96
+ try {
97
+ if (choice === 'add')
98
+ edit = await addChange(o, now.rules);
99
+ else if (choice === 'proposed' && proposed)
100
+ edit = addProposed(now.rules, proposed.proposal, stampFor(o.deps));
101
+ else if (choice === 'reset') {
102
+ if (now.from === 'default' && !now.warning) {
103
+ o.print('The rules are the defaults already.');
104
+ continue;
105
+ }
106
+ edit = resetRules(now.rules);
107
+ }
108
+ else {
109
+ const rule = now.rules.find((r) => `rule:${r.id}` === choice);
110
+ if (rule)
111
+ edit = await ruleChange(o, rule, now.rules);
112
+ }
113
+ if (edit)
114
+ refuseUnreadable(now.warning, edit.change);
115
+ }
116
+ catch (e) {
117
+ if (!(e instanceof RegisterError))
118
+ throw e;
119
+ o.print(`✗ ${e.message}`);
120
+ continue;
121
+ }
122
+ if (!edit)
123
+ continue;
124
+ // The first save of a project on the defaults writes every rule into its file: said on the card.
125
+ const pins = now.from === 'default' && edit.rules !== null;
126
+ const save = await pick(o, card(edit.card, [{ value: 'save', label: 'Save' }, { value: 'cancel', label: 'Cancel' }], { s: 'save', c: 'cancel' }, pins ? [DEFAULTS_PINNED_LINE] : undefined));
127
+ if (save !== 'save') {
128
+ o.print(NOTHING_SAVED);
129
+ continue;
130
+ }
131
+ try {
132
+ for (const line of saveRuleEdit(joined.projectDir, edit, o.deps))
133
+ o.print(line);
134
+ if (choice === 'proposed')
135
+ clearRuleProposal();
136
+ }
137
+ catch (e) {
138
+ if (!(e instanceof RegisterError))
139
+ throw e;
140
+ o.print(`✗ ${e.message}`);
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * The REPL's `/rules`: both submit paths (Ink and classic) call this. The card
146
+ * is the Ink card slot when it has a listener, else a typed line (classic); the
147
+ * number question the same. Headless shows the table and no card.
148
+ */
149
+ export async function runRulesSlash(trimmed, a) {
150
+ if (trimmed !== '/rules') {
151
+ a.emit('/rules takes no arguments — it opens the decision rules of the team project this laptop has joined');
152
+ return;
153
+ }
154
+ const { sessionTeamDeps } = await import('../journey/session-deps.js');
155
+ const { journeyAsk, journeyCardConfirm } = await import('../journey/chain.js');
156
+ const { deps } = a.headless ? await sessionTeamDeps({ headless: true }) : await sessionTeamDeps({ headless: false, signal: a.signal });
157
+ const ask = journeyAsk();
158
+ try {
159
+ await runRulesMenu({
160
+ deps: { ...deps, print: a.emit },
161
+ confirm: a.headless ? async () => ({ value: null, cancelled: true }) : journeyCardConfirm(a.emit),
162
+ // Esc on the question puts it away: nothing changes, the menu goes on.
163
+ ask: async (q) => { try {
164
+ return await ask(q);
165
+ }
166
+ catch {
167
+ return '';
168
+ } },
169
+ print: a.emit,
170
+ });
171
+ }
172
+ catch (e) {
173
+ if (process.env['CSPEACH_DEBUG'] === '1')
174
+ throw e;
175
+ a.emit(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
176
+ }
177
+ }
@@ -36,6 +36,7 @@ const BUILTIN_COMMANDS = [
36
36
  '/ui',
37
37
  '/reroute',
38
38
  '/transport',
39
+ '/rules',
39
40
  ];
40
41
  /**
41
42
  * Build the full slash-command list once at module load. Skill commands
@@ -25,11 +25,12 @@ import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
25
25
  // 2026-06-11 — builtin list extracted to builtin-commands.ts (pure data)
26
26
  // so the router layer can check builtin heads without importing inquirer.
27
27
  import { BUILTIN_ENTRIES } from './builtin-commands.js';
28
+ import { partOfRun } from '../runs/runs.js';
28
29
  function buildCatalogue() {
29
- const skillEntries = SKILL_CATALOG.map((s) => ({
30
- name: `/${s.name}`,
31
- description: s.description,
32
- }));
30
+ const skillEntries = SKILL_CATALOG.map((s) => {
31
+ const part = partOfRun(s.name);
32
+ return { name: `/${s.name}`, description: part ? `${s.description} · part of ${part}` : s.description };
33
+ });
33
34
  return [...BUILTIN_ENTRIES, ...skillEntries].sort((a, b) => a.name.localeCompare(b.name));
34
35
  }
35
36
  const ALL_ENTRIES = buildCatalogue();
package/dist/repl.js CHANGED
@@ -21,7 +21,8 @@ import { createOfflineAdtClient } from './sap/offline-adt-client.js';
21
21
  import { STANDALONE_ALIAS } from './tools/index.js';
22
22
  import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-standalone.js';
23
23
  import { clearAppFlow } from './lane/app-flow.js';
24
- import { clearJourneyFlow, isJourneyHop, journeyHopLine } from './journey/flow.js';
24
+ import { clearJourneyFlow } from './journey/flow.js';
25
+ import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
25
26
  import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
26
27
  import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
27
28
  import { getStore } from './auth/api-key.js';
@@ -84,6 +85,8 @@ import { maybeOfferAbapforgeMigration } from './projects/migration.js';
84
85
  import { shouldOfferAbapforgeMigration } from './projects/workspace-retire.js';
85
86
  import { slashCompleter } from './repl/slash-completer.js';
86
87
  import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
88
+ import { detectRunStart } from './runs/front-door.js';
89
+ import { shadowStart, guardRunLabel } from './runs/shadow.js';
87
90
  import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
88
91
  import { atCompleter } from './repl/at-completer.js';
89
92
  import { appendHistoryEntry, readHistoryEntries } from './repl/history.js';
@@ -172,6 +175,7 @@ import './tools/project/cca_merge.js';
172
175
  import './tools/project/upgrade_merge_progress.js';
173
176
  import './tools/project/team_findings.js';
174
177
  import './tools/project/team_estate.js';
178
+ import './tools/project/rules_propose.js';
175
179
  import './tools/subagent/_background-shared.js';
176
180
  import './tools/subagent/background_run.js';
177
181
  import './tools/subagent/monitor_emit.js';
@@ -893,11 +897,11 @@ export async function runRepl(opts) {
893
897
  // scope so both the Ink and readline branches share the same instance.
894
898
  // Cleared at the start of every consume cycle so a stale value never
895
899
  // re-fires.
896
- let pendingDispatchValue = null;
897
- const pendingDispatchAccessor = {
898
- get: () => pendingDispatchValue,
899
- set: (cmd) => { pendingDispatchValue = cmd; },
900
- };
900
+ // Audit M-2 — the slot also carries WHO queued the command: a CSPeach source
901
+ // (journey, rebuild hand-off, app chain, plan chain) sets its origin, and both
902
+ // branches render it through queuedStepView — never as "You › <the command>".
903
+ // Consumers take() the command and its origin together.
904
+ const pendingDispatchAccessor = createDispatchSlot();
901
905
  // Task 13 fix round — the plan chain queues through planHops.queue, which
902
906
  // marks the command as an in-run hop (one progress line, no guarded
903
907
  // notice). Anything else in the slot (the /abap-app start, a skill switch)
@@ -995,7 +999,7 @@ export async function runRepl(opts) {
995
999
  /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
996
1000
  // Task 13 fix round — `planHop` travels WITH the command into handleSubmit,
997
1001
  // so a refused submit can never leave a stale hop mark behind.
998
- const autoDispatch = (cmd, planHop = false) => {
1002
+ const autoDispatch = (cmd, planHop = false, queued = null) => {
999
1003
  // Round 4 item 9b — the exit is already under way: start nothing new.
1000
1004
  // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
1001
1005
  const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
@@ -1007,11 +1011,11 @@ export async function runRepl(opts) {
1007
1011
  return;
1008
1012
  }
1009
1013
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
1010
- // Journey hops (Task 3) — the journey already said its step line.
1011
- const notice = isJourneyHop(session, cmd) ? null : autoRoutedNotice(cmd);
1014
+ // Audit M-2 — no "Auto-routed:" for any command CSPeach queued itself.
1015
+ const notice = queued ? null : autoRoutedNotice(cmd);
1012
1016
  if (notice)
1013
1017
  chunkEmitter.emit('chunk', formatInfo(notice));
1014
- setImmediate(() => { void handleSubmit(cmd, { planHop }); });
1018
+ setImmediate(() => { void handleSubmit(cmd, { planHop, queued }); });
1015
1019
  };
1016
1020
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
1017
1021
  // owns the single-flight reserve()/release() lifecycle. All re-entrant
@@ -1037,10 +1041,14 @@ export async function runRepl(opts) {
1037
1041
  // new turn's content touched with no separator and looked like
1038
1042
  // garbled overlap).
1039
1043
  if (trimmed !== '/exit' && trimmed !== '/quit') {
1040
- // Rehearsal fix 1 — a journey hop was queued by CSPeach, not typed: one
1041
- // plain journey line, never "You › <the command>".
1042
- const hopLine = journeyHopLine(session, trimmed);
1043
- chunkEmitter.emit('chunk', hopLine ? `\n${hopLine}\n` : formatPromptEcho(trimmed, process.stdout.columns || 80));
1044
+ // Rehearsal fix 1 + audit M-2 — a command CSPeach queued (journey,
1045
+ // rebuild hand-off, app chain, plan chain) is its own step on screen:
1046
+ // its one line, never "You › <the command>".
1047
+ const view = queuedStepView(submitOpts?.queued);
1048
+ if (view.echo)
1049
+ chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
1050
+ else if (view.line)
1051
+ chunkEmitter.emit('chunk', `\n${view.line}\n`);
1044
1052
  }
1045
1053
  if (trimmed === '/exit' || trimmed === '/quit') {
1046
1054
  done = true;
@@ -1275,6 +1283,19 @@ export async function runRepl(opts) {
1275
1283
  rewindEmitter.emit('open');
1276
1284
  return;
1277
1285
  }
1286
+ // /rules — the team project's decision rules (step 2, Task 10, D7): table, menu, card before every save.
1287
+ if (trimmed === '/rules' || trimmed.startsWith('/rules ')) {
1288
+ const { runRulesSlash } = await import('./repl/rules-menu.js');
1289
+ const rulesAbort = new AbortController();
1290
+ currentTurnAbort = rulesAbort;
1291
+ try {
1292
+ await runRulesSlash(trimmed, { emit: (line) => chunkEmitter.emit('chunk', line + '\n'), headless: isHeadless(), signal: rulesAbort.signal });
1293
+ }
1294
+ finally {
1295
+ currentTurnAbort = null;
1296
+ }
1297
+ return;
1298
+ }
1278
1299
  // /safety-mode on|off — structured Forge Rule confirmation cards.
1279
1300
  // UX-5 #1 (2026-06-12): handleSafetyModeCommand existed (unit-tested)
1280
1301
  // but no REPL path ever routed to it — the toggle was unreachable at
@@ -1300,6 +1321,25 @@ export async function runRepl(opts) {
1300
1321
  effectiveInput = expansion.text;
1301
1322
  }
1302
1323
  }
1324
+ // Guided runs — the one front door for Ink, classic and one-shot:
1325
+ // the /run verb (rewritten to its alias), then today's trigger. The
1326
+ // trigger still decides (spec §4 step 2); the dispatch below is unchanged.
1327
+ const door = await detectRunStart(effectiveInput, {
1328
+ cwd: process.cwd(), session, resume: true,
1329
+ // Shadow (spec §4 step 2): the trigger decided; in managed mode ask our
1330
+ // router the same text, with the shadow-only text, and count whether it
1331
+ // agrees. Not awaited; counts only; nothing on screen.
1332
+ onFreeTextStart: (text) => void shadowStart(text, 'run-clean-core', {
1333
+ classify: (p, c) => classifyPrompt(p, c, { shadowRuns: true }),
1334
+ context: { sap_system: selectedAlias, last_skill: session.last_skill ?? null, last_object: session.last_object ?? null },
1335
+ llmMode: cfg.llm?.mode,
1336
+ }),
1337
+ });
1338
+ if (door.kind === 'line') {
1339
+ chunkEmitter.emit('chunk', door.line + '\n');
1340
+ return;
1341
+ }
1342
+ effectiveInput = door.input;
1303
1343
  const parsed = parseShortcut(effectiveInput);
1304
1344
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1305
1345
  // catalog: print the notice ("did you mean" / builtin-with-args) and
@@ -1310,39 +1350,33 @@ export async function runRepl(opts) {
1310
1350
  return;
1311
1351
  }
1312
1352
  // Guided clean-core journey (Task 3) — "start clean core on ZFI_AR",
1313
- // "/abap-clean-core start ZFI_AR", "carry on" in a project folder.
1314
- // Detected here, before the classifier; any other line is untouched.
1315
- {
1316
- const { detectJourney } = await import('./journey/trigger.js');
1317
- const journey = detectJourney(effectiveInput, { cwd: process.cwd(), session });
1318
- if (journey) {
1319
- const { runJourneyEntry } = await import('./repl/journey-entry.js');
1320
- const journeyAbort = new AbortController();
1321
- currentTurnAbort = journeyAbort;
1322
- interruptCount = 0;
1323
- if (pendingCancel.applyTo(journeyAbort))
1324
- interruptCount = 1;
1325
- turnStatusEmitter.start('clean core');
1326
- try {
1327
- await runJourneyEntry(journey, {
1328
- session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
1329
- emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1330
- queueNext: (cmd) => pendingDispatchAccessor.set(cmd),
1331
- signal: journeyAbort.signal,
1332
- });
1333
- }
1334
- finally {
1335
- turnStatusEmitter.stop();
1336
- currentTurnAbort = null;
1337
- postTurn = true;
1338
- }
1339
- const queued = pendingDispatchAccessor.get();
1340
- if (queued) {
1341
- pendingDispatchAccessor.set(null);
1342
- autoDispatch(queued);
1343
- }
1344
- return;
1353
+ // "/abap-clean-core start ZFI_AR", "/run clean-core ZFI_AR", "carry on"
1354
+ // in a project folder. Detected above, before the classifier.
1355
+ if (door.kind === 'run') {
1356
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
1357
+ const journeyAbort = new AbortController();
1358
+ currentTurnAbort = journeyAbort;
1359
+ interruptCount = 0;
1360
+ if (pendingCancel.applyTo(journeyAbort))
1361
+ interruptCount = 1;
1362
+ turnStatusEmitter.start('clean core');
1363
+ try {
1364
+ await runJourneyEntry(door.trigger, {
1365
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
1366
+ emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1367
+ queueNext: (cmd, origin) => pendingDispatchAccessor.set(cmd, origin),
1368
+ signal: journeyAbort.signal,
1369
+ });
1370
+ }
1371
+ finally {
1372
+ turnStatusEmitter.stop();
1373
+ currentTurnAbort = null;
1374
+ postTurn = true;
1345
1375
  }
1376
+ const queued = pendingDispatchAccessor.take();
1377
+ if (queued)
1378
+ autoDispatch(queued.cmd, false, queued.origin);
1379
+ return;
1346
1380
  }
1347
1381
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1348
1382
  // `parsed.prompt` rather than `trimmed` means:
@@ -1439,6 +1473,8 @@ export async function runRepl(opts) {
1439
1473
  }
1440
1474
  return;
1441
1475
  }
1476
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
1477
+ c = guardRunLabel(c);
1442
1478
  if (c.screenshot) {
1443
1479
  chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
1444
1480
  }
@@ -1668,10 +1704,9 @@ export async function runRepl(opts) {
1668
1704
  const { clearPlanGrant } = await import('./approvals/plan-grant.js');
1669
1705
  clearPlanGrant();
1670
1706
  }
1671
- if (reauditDispatched) {
1672
- pendingDispatchAccessor.set(null);
1673
- autoDispatch(reauditDispatched, planHops.take(reauditDispatched));
1674
- }
1707
+ const reaudit = pendingDispatchAccessor.take();
1708
+ if (reaudit)
1709
+ autoDispatch(reaudit.cmd, planHops.take(reaudit.cmd), reaudit.origin);
1675
1710
  return;
1676
1711
  }
1677
1712
  // 2026-10-04 (continuous run) — a phase boundary is save + COMPACT:
@@ -2161,9 +2196,8 @@ export async function runRepl(opts) {
2161
2196
  // next-phase offer above queued the plan-resume it built itself.
2162
2197
  // Consume the slot and re-enter handleSubmit with that command —
2163
2198
  // no user keypress. The user's consent was the picker pick / y answer.
2164
- const dispatched = pendingDispatchAccessor.get();
2199
+ const dispatched = pendingDispatchAccessor.take();
2165
2200
  if (dispatched) {
2166
- pendingDispatchAccessor.set(null);
2167
2201
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2168
2202
  // called in the plan-resume prepare branch of handleSubmit) is the
2169
2203
  // single owner of the phase-boundary compaction — it runs for typed AND dispatched resumes,
@@ -2173,7 +2207,7 @@ export async function runRepl(opts) {
2173
2207
  // resolves cleanly before the next one starts. Recursion via
2174
2208
  // setImmediate keeps the call stack flat across long chains.
2175
2209
  // (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
2176
- autoDispatch(dispatched, planHops.take(dispatched));
2210
+ autoDispatch(dispatched.cmd, planHops.take(dispatched.cmd), dispatched.origin);
2177
2211
  }
2178
2212
  };
2179
2213
  // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
@@ -2819,23 +2853,26 @@ export async function runRepl(opts) {
2819
2853
  // consent was the picker pick, no Enter required. Cleared
2820
2854
  // immediately so a stale value never re-fires.
2821
2855
  classicPlanHop = false; // a typed line is never an in-run hop
2822
- const dispatchedCmd = pendingDispatchAccessor.get();
2823
- if (dispatchedCmd) {
2824
- pendingDispatchAccessor.set(null);
2856
+ const classicQueued = pendingDispatchAccessor.take();
2857
+ if (classicQueued) {
2858
+ const dispatchedCmd = classicQueued.cmd;
2825
2859
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2826
2860
  // called in the plan-resume prepare branch below) is the single owner
2827
2861
  // of the phase-boundary compaction — it runs for typed AND dispatched resumes, and
2828
2862
  // only AFTER preparePlanResume succeeds, so a failed prepare never
2829
2863
  // destroys the session context.
2830
2864
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
2865
+ // Audit M-2 — the Ink mirror: a command CSPeach queued is its own step
2866
+ // (its line, or nothing when its source already said it), never "Auto-routed:".
2831
2867
  {
2832
- const n = isJourneyHop(session, dispatchedCmd) ? null : autoRoutedNotice(dispatchedCmd);
2833
- if (n)
2834
- console.log(formatInfo(n).trimEnd());
2835
- // Rehearsal fix 1 — the journey hop says what CSPeach is doing (the Ink mirror).
2836
- const hop = journeyHopLine(session, dispatchedCmd);
2837
- if (hop)
2838
- console.log(hop);
2868
+ const view = queuedStepView(classicQueued.origin);
2869
+ if (view.echo) {
2870
+ const n = autoRoutedNotice(dispatchedCmd);
2871
+ if (n)
2872
+ console.log(formatInfo(n).trimEnd());
2873
+ }
2874
+ else if (view.line)
2875
+ console.log(view.line);
2839
2876
  classicPlanHop = planHops.take(dispatchedCmd);
2840
2877
  }
2841
2878
  line = dispatchedCmd;
@@ -3142,6 +3179,12 @@ export async function runRepl(opts) {
3142
3179
  console.log(chalk.cyan(msg));
3143
3180
  continue;
3144
3181
  }
3182
+ // /rules — the team project's decision rules (step 2, Task 10, D7); cards are typed lines here.
3183
+ if (trimmed === '/rules' || trimmed.startsWith('/rules ')) {
3184
+ const { runRulesSlash } = await import('./repl/rules-menu.js');
3185
+ await runRulesSlash(trimmed, { emit: (line) => console.log(line), headless: isHeadless(), signal: new AbortController().signal });
3186
+ continue;
3187
+ }
3145
3188
  // Task 3 (ux-wave3) — classic-mode /rewind: numbered inquirer list + confirm
3146
3189
  // + gated restore. Reuses the identical candidate collector and executor as
3147
3190
  // the Ink panel (executeRestore); only the surface differs.
@@ -3262,7 +3305,8 @@ export async function runRepl(opts) {
3262
3305
  // Pressing Enter twice used to clear, but users expected Enter
3263
3306
  // to be a no-op when there's nothing to submit, so the
3264
3307
  // double-Enter cancel was confusing.
3265
- if (resolvedTrimmed.startsWith('/')) {
3308
+ // A bare /run falls through to its usage line below, like /help (M-1).
3309
+ if (resolvedTrimmed.startsWith('/') && !/^\/run$/i.test(resolvedTrimmed)) {
3266
3310
  const splitIdx = resolvedTrimmed.search(/\s/);
3267
3311
  const bodyAfter = splitIdx === -1 ? '' : resolvedTrimmed.slice(splitIdx).trim();
3268
3312
  if (bodyAfter.length === 0) {
@@ -3305,6 +3349,23 @@ export async function runRepl(opts) {
3305
3349
  resolvedTrimmed = expansion.text;
3306
3350
  }
3307
3351
  }
3352
+ // Guided runs — the same front door as the Ink path: the /run verb
3353
+ // (rewritten to its alias), then today's trigger. The trigger still
3354
+ // decides (spec §4 step 2); the dispatch below is unchanged.
3355
+ const door = await detectRunStart(resolvedTrimmed, {
3356
+ cwd: process.cwd(), session, resume: true,
3357
+ // Shadow (spec §4 step 2): same as the Ink path. Not awaited; counts only.
3358
+ onFreeTextStart: (text) => void shadowStart(text, 'run-clean-core', {
3359
+ classify: (p, c) => classifyPrompt(p, c, { shadowRuns: true }),
3360
+ context: { sap_system: selectedAlias, last_skill: session.last_skill ?? null, last_object: session.last_object ?? null },
3361
+ llmMode: cfg.llm?.mode,
3362
+ }),
3363
+ });
3364
+ if (door.kind === 'line') {
3365
+ console.log(door.line);
3366
+ continue;
3367
+ }
3368
+ resolvedTrimmed = door.input;
3308
3369
  const parsed = parseShortcut(resolvedTrimmed);
3309
3370
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
3310
3371
  // catalog: print the notice ("did you mean" / builtin-with-args) and
@@ -3317,29 +3378,25 @@ export async function runRepl(opts) {
3317
3378
  // Guided clean-core journey (Task 3) — classic mirror of the Ink entry.
3318
3379
  // Cards become one typed line here (no card listener); a queued step is
3319
3380
  // picked up by the loop-top dispatch consumer.
3320
- {
3321
- const { detectJourney } = await import('./journey/trigger.js');
3322
- const journey = detectJourney(resolvedTrimmed, { cwd: process.cwd(), session });
3323
- if (journey) {
3324
- const { runJourneyEntry } = await import('./repl/journey-entry.js');
3325
- const journeyAbort = new AbortController();
3326
- const onJourneySigint = () => { journeyAbort.abort(); };
3327
- turnInProgress = true;
3328
- rl.on('SIGINT', onJourneySigint);
3329
- try {
3330
- await runJourneyEntry(journey, {
3331
- session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
3332
- emit: (line) => console.log(line),
3333
- queueNext: (cmd) => pendingDispatchAccessor.set(cmd),
3334
- signal: journeyAbort.signal,
3335
- });
3336
- }
3337
- finally {
3338
- rl.removeListener('SIGINT', onJourneySigint);
3339
- turnInProgress = false;
3340
- }
3341
- continue;
3381
+ if (door.kind === 'run') {
3382
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
3383
+ const journeyAbort = new AbortController();
3384
+ const onJourneySigint = () => { journeyAbort.abort(); };
3385
+ turnInProgress = true;
3386
+ rl.on('SIGINT', onJourneySigint);
3387
+ try {
3388
+ await runJourneyEntry(door.trigger, {
3389
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
3390
+ emit: (line) => console.log(line),
3391
+ queueNext: (cmd, origin) => pendingDispatchAccessor.set(cmd, origin),
3392
+ signal: journeyAbort.signal,
3393
+ });
3342
3394
  }
3395
+ finally {
3396
+ rl.removeListener('SIGINT', onJourneySigint);
3397
+ turnInProgress = false;
3398
+ }
3399
+ continue;
3343
3400
  }
3344
3401
  // Record the prose body (post-shortcut stripping) for /reroute. Using
3345
3402
  // `parsed.prompt` rather than `trimmed` means:
@@ -3400,6 +3457,8 @@ export async function runRepl(opts) {
3400
3457
  throw err;
3401
3458
  c = fallback;
3402
3459
  }
3460
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
3461
+ c = guardRunLabel(c);
3403
3462
  if (c.screenshot)
3404
3463
  console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
3405
3464
  // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
@@ -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
- body: JSON.stringify(images.length > 0 ? { prompt, context, images } : { prompt, context }),
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
- const images = await promptImages(prompt, opts.cwd ?? process.cwd());
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) {