@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.
Files changed (66) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/cost.js +6 -2
  4. package/dist/commands/help.js +18 -2
  5. package/dist/commands/plan-chain.js +3 -0
  6. package/dist/commands/plan-gate.js +10 -1
  7. package/dist/commands/plan-resume.js +4 -0
  8. package/dist/commands/team-rules.js +187 -0
  9. package/dist/commands/team.js +632 -90
  10. package/dist/commands/usage.js +106 -0
  11. package/dist/cost/month-usage.js +45 -0
  12. package/dist/journey/chain.js +265 -15
  13. package/dist/journey/clean-core.js +25 -3
  14. package/dist/journey/flow.js +51 -1
  15. package/dist/journey/rebuild.js +464 -0
  16. package/dist/journey/run.js +213 -20
  17. package/dist/journey/session-deps.js +5 -1
  18. package/dist/lane/app-chain.js +12 -2
  19. package/dist/one-shot.js +24 -17
  20. package/dist/projects/clean-core-confirm.js +38 -5
  21. package/dist/projects/save-command.js +20 -3
  22. package/dist/register/baseline.js +39 -2
  23. package/dist/register/decision-rules.js +35 -0
  24. package/dist/register/discover.js +49 -19
  25. package/dist/register/export-files.js +58 -0
  26. package/dist/register/measure.js +191 -9
  27. package/dist/register/plan-import.js +6 -3
  28. package/dist/register/records.js +15 -3
  29. package/dist/register/rule-proposal.js +4 -0
  30. package/dist/register/rules-edit.js +257 -0
  31. package/dist/register/status-file/register-status-file.js +106 -108
  32. package/dist/register/status-file/report-model.js +569 -0
  33. package/dist/register/status-file/team-css.js +1 -1
  34. package/dist/register/status-file-sync.js +1 -0
  35. package/dist/register/status-html.js +25 -6
  36. package/dist/register/store.js +4 -3
  37. package/dist/register/variant.js +65 -0
  38. package/dist/register/workbooks.js +22 -56
  39. package/dist/register/write-whole.js +30 -2
  40. package/dist/renderer/tool-labels.js +2 -1
  41. package/dist/repl/builtin-commands.js +7 -0
  42. package/dist/repl/journey-entry.js +4 -2
  43. package/dist/repl/plan-hop.js +2 -9
  44. package/dist/repl/rules-menu.js +177 -0
  45. package/dist/repl/slash-completer.js +2 -0
  46. package/dist/repl/slash-picker.js +5 -4
  47. package/dist/repl.js +157 -86
  48. package/dist/router/classifier.js +8 -4
  49. package/dist/router/router-core.js +43 -7
  50. package/dist/router/routing-decision.js +1 -1
  51. package/dist/runs/bundled-runs.js +100 -0
  52. package/dist/runs/front-door.js +19 -0
  53. package/dist/runs/manifest-runs.js +50 -0
  54. package/dist/runs/run-command.js +24 -0
  55. package/dist/runs/runs.js +27 -0
  56. package/dist/runs/shadow.js +100 -0
  57. package/dist/runs/types.js +1 -0
  58. package/dist/session/queued-step.js +45 -0
  59. package/dist/skills/bundled-skills.js +53 -53
  60. package/dist/tools/dispatch-skill.js +8 -4
  61. package/dist/tools/lane/app-lane-resolve.js +1 -1
  62. package/dist/tools/project/rules_propose.js +59 -0
  63. package/dist/tools/project/team_findings.js +13 -2
  64. package/dist/upgrade-check.js +49 -4
  65. package/package.json +120 -120
  66. 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
+ }
@@ -22,6 +22,7 @@ const BUILTIN_COMMANDS = [
22
22
  '/cancel',
23
23
  '/compact',
24
24
  '/cost',
25
+ '/usage',
25
26
  '/context',
26
27
  '/export',
27
28
  '/exit',
@@ -36,6 +37,7 @@ const BUILTIN_COMMANDS = [
36
37
  '/ui',
37
38
  '/reroute',
38
39
  '/transport',
40
+ '/rules',
39
41
  ];
40
42
  /**
41
43
  * 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;
@@ -1060,6 +1068,12 @@ export async function runRepl(opts) {
1060
1068
  printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
1061
1069
  return;
1062
1070
  }
1071
+ // /usage (2026-10-09) — this month in credits, from the proxy's ledger.
1072
+ if (trimmed === '/usage') {
1073
+ const { runUsageCommand } = await import('./commands/usage.js');
1074
+ await runUsageCommand((l) => chunkEmitter.emit('chunk', l + '\n'));
1075
+ return;
1076
+ }
1063
1077
  // Phase 1 cost tracking (2026-05-16) — /cost prints session $ summary
1064
1078
  // from the JSONL log written each turn. Read-only, no network.
1065
1079
  if (trimmed === '/cost') {
@@ -1275,6 +1289,19 @@ export async function runRepl(opts) {
1275
1289
  rewindEmitter.emit('open');
1276
1290
  return;
1277
1291
  }
1292
+ // /rules — the team project's decision rules (step 2, Task 10, D7): table, menu, card before every save.
1293
+ if (trimmed === '/rules' || trimmed.startsWith('/rules ')) {
1294
+ const { runRulesSlash } = await import('./repl/rules-menu.js');
1295
+ const rulesAbort = new AbortController();
1296
+ currentTurnAbort = rulesAbort;
1297
+ try {
1298
+ await runRulesSlash(trimmed, { emit: (line) => chunkEmitter.emit('chunk', line + '\n'), headless: isHeadless(), signal: rulesAbort.signal });
1299
+ }
1300
+ finally {
1301
+ currentTurnAbort = null;
1302
+ }
1303
+ return;
1304
+ }
1278
1305
  // /safety-mode on|off — structured Forge Rule confirmation cards.
1279
1306
  // UX-5 #1 (2026-06-12): handleSafetyModeCommand existed (unit-tested)
1280
1307
  // but no REPL path ever routed to it — the toggle was unreachable at
@@ -1300,6 +1327,25 @@ export async function runRepl(opts) {
1300
1327
  effectiveInput = expansion.text;
1301
1328
  }
1302
1329
  }
1330
+ // Guided runs — the one front door for Ink, classic and one-shot:
1331
+ // the /run verb (rewritten to its alias), then today's trigger. The
1332
+ // trigger still decides (spec §4 step 2); the dispatch below is unchanged.
1333
+ const door = await detectRunStart(effectiveInput, {
1334
+ cwd: process.cwd(), session, resume: true,
1335
+ // Shadow (spec §4 step 2): the trigger decided; in managed mode ask our
1336
+ // router the same text, with the shadow-only text, and count whether it
1337
+ // agrees. Not awaited; counts only; nothing on screen.
1338
+ onFreeTextStart: (text) => void shadowStart(text, 'run-clean-core', {
1339
+ classify: (p, c) => classifyPrompt(p, c, { shadowRuns: true }),
1340
+ context: { sap_system: selectedAlias, last_skill: session.last_skill ?? null, last_object: session.last_object ?? null },
1341
+ llmMode: cfg.llm?.mode,
1342
+ }),
1343
+ });
1344
+ if (door.kind === 'line') {
1345
+ chunkEmitter.emit('chunk', door.line + '\n');
1346
+ return;
1347
+ }
1348
+ effectiveInput = door.input;
1303
1349
  const parsed = parseShortcut(effectiveInput);
1304
1350
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1305
1351
  // catalog: print the notice ("did you mean" / builtin-with-args) and
@@ -1310,39 +1356,33 @@ export async function runRepl(opts) {
1310
1356
  return;
1311
1357
  }
1312
1358
  // 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;
1359
+ // "/abap-clean-core start ZFI_AR", "/run clean-core ZFI_AR", "carry on"
1360
+ // in a project folder. Detected above, before the classifier.
1361
+ if (door.kind === 'run') {
1362
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
1363
+ const journeyAbort = new AbortController();
1364
+ currentTurnAbort = journeyAbort;
1365
+ interruptCount = 0;
1366
+ if (pendingCancel.applyTo(journeyAbort))
1367
+ interruptCount = 1;
1368
+ turnStatusEmitter.start('clean core');
1369
+ try {
1370
+ await runJourneyEntry(door.trigger, {
1371
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
1372
+ emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
1373
+ queueNext: (cmd, origin) => pendingDispatchAccessor.set(cmd, origin),
1374
+ signal: journeyAbort.signal,
1375
+ });
1376
+ }
1377
+ finally {
1378
+ turnStatusEmitter.stop();
1379
+ currentTurnAbort = null;
1380
+ postTurn = true;
1345
1381
  }
1382
+ const queued = pendingDispatchAccessor.take();
1383
+ if (queued)
1384
+ autoDispatch(queued.cmd, false, queued.origin);
1385
+ return;
1346
1386
  }
1347
1387
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1348
1388
  // `parsed.prompt` rather than `trimmed` means:
@@ -1439,6 +1479,8 @@ export async function runRepl(opts) {
1439
1479
  }
1440
1480
  return;
1441
1481
  }
1482
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
1483
+ c = guardRunLabel(c);
1442
1484
  if (c.screenshot) {
1443
1485
  chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
1444
1486
  }
@@ -1668,10 +1710,9 @@ export async function runRepl(opts) {
1668
1710
  const { clearPlanGrant } = await import('./approvals/plan-grant.js');
1669
1711
  clearPlanGrant();
1670
1712
  }
1671
- if (reauditDispatched) {
1672
- pendingDispatchAccessor.set(null);
1673
- autoDispatch(reauditDispatched, planHops.take(reauditDispatched));
1674
- }
1713
+ const reaudit = pendingDispatchAccessor.take();
1714
+ if (reaudit)
1715
+ autoDispatch(reaudit.cmd, planHops.take(reaudit.cmd), reaudit.origin);
1675
1716
  return;
1676
1717
  }
1677
1718
  // 2026-10-04 (continuous run) — a phase boundary is save + COMPACT:
@@ -2161,9 +2202,8 @@ export async function runRepl(opts) {
2161
2202
  // next-phase offer above queued the plan-resume it built itself.
2162
2203
  // Consume the slot and re-enter handleSubmit with that command —
2163
2204
  // no user keypress. The user's consent was the picker pick / y answer.
2164
- const dispatched = pendingDispatchAccessor.get();
2205
+ const dispatched = pendingDispatchAccessor.take();
2165
2206
  if (dispatched) {
2166
- pendingDispatchAccessor.set(null);
2167
2207
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2168
2208
  // called in the plan-resume prepare branch of handleSubmit) is the
2169
2209
  // single owner of the phase-boundary compaction — it runs for typed AND dispatched resumes,
@@ -2173,7 +2213,7 @@ export async function runRepl(opts) {
2173
2213
  // resolves cleanly before the next one starts. Recursion via
2174
2214
  // setImmediate keeps the call stack flat across long chains.
2175
2215
  // (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
2176
- autoDispatch(dispatched, planHops.take(dispatched));
2216
+ autoDispatch(dispatched.cmd, planHops.take(dispatched.cmd), dispatched.origin);
2177
2217
  }
2178
2218
  };
2179
2219
  // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
@@ -2819,23 +2859,26 @@ export async function runRepl(opts) {
2819
2859
  // consent was the picker pick, no Enter required. Cleared
2820
2860
  // immediately so a stale value never re-fires.
2821
2861
  classicPlanHop = false; // a typed line is never an in-run hop
2822
- const dispatchedCmd = pendingDispatchAccessor.get();
2823
- if (dispatchedCmd) {
2824
- pendingDispatchAccessor.set(null);
2862
+ const classicQueued = pendingDispatchAccessor.take();
2863
+ if (classicQueued) {
2864
+ const dispatchedCmd = classicQueued.cmd;
2825
2865
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
2826
2866
  // called in the plan-resume prepare branch below) is the single owner
2827
2867
  // of the phase-boundary compaction — it runs for typed AND dispatched resumes, and
2828
2868
  // only AFTER preparePlanResume succeeds, so a failed prepare never
2829
2869
  // destroys the session context.
2830
2870
  // Task 13 — no echo for a plan hop: the chain already said "Next: <title>".
2871
+ // Audit M-2 — the Ink mirror: a command CSPeach queued is its own step
2872
+ // (its line, or nothing when its source already said it), never "Auto-routed:".
2831
2873
  {
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);
2874
+ const view = queuedStepView(classicQueued.origin);
2875
+ if (view.echo) {
2876
+ const n = autoRoutedNotice(dispatchedCmd);
2877
+ if (n)
2878
+ console.log(formatInfo(n).trimEnd());
2879
+ }
2880
+ else if (view.line)
2881
+ console.log(view.line);
2839
2882
  classicPlanHop = planHops.take(dispatchedCmd);
2840
2883
  }
2841
2884
  line = dispatchedCmd;
@@ -2955,6 +2998,12 @@ export async function runRepl(opts) {
2955
2998
  printHelp();
2956
2999
  continue;
2957
3000
  }
3001
+ // /usage (2026-10-09) — classic mirror of the Ink handler above.
3002
+ if (trimmed === '/usage') {
3003
+ const { runUsageCommand } = await import('./commands/usage.js');
3004
+ await runUsageCommand();
3005
+ continue;
3006
+ }
2958
3007
  // Phase 1 cost tracking (2026-05-16) — classic-mode /cost.
2959
3008
  // Mirrors the Ink-mode handler above. /cost prints session $ summary
2960
3009
  // from the JSONL log written each turn. Read-only, no network.
@@ -3142,6 +3191,12 @@ export async function runRepl(opts) {
3142
3191
  console.log(chalk.cyan(msg));
3143
3192
  continue;
3144
3193
  }
3194
+ // /rules — the team project's decision rules (step 2, Task 10, D7); cards are typed lines here.
3195
+ if (trimmed === '/rules' || trimmed.startsWith('/rules ')) {
3196
+ const { runRulesSlash } = await import('./repl/rules-menu.js');
3197
+ await runRulesSlash(trimmed, { emit: (line) => console.log(line), headless: isHeadless(), signal: new AbortController().signal });
3198
+ continue;
3199
+ }
3145
3200
  // Task 3 (ux-wave3) — classic-mode /rewind: numbered inquirer list + confirm
3146
3201
  // + gated restore. Reuses the identical candidate collector and executor as
3147
3202
  // the Ink panel (executeRestore); only the surface differs.
@@ -3262,7 +3317,8 @@ export async function runRepl(opts) {
3262
3317
  // Pressing Enter twice used to clear, but users expected Enter
3263
3318
  // to be a no-op when there's nothing to submit, so the
3264
3319
  // double-Enter cancel was confusing.
3265
- if (resolvedTrimmed.startsWith('/')) {
3320
+ // A bare /run falls through to its usage line below, like /help (M-1).
3321
+ if (resolvedTrimmed.startsWith('/') && !/^\/run$/i.test(resolvedTrimmed)) {
3266
3322
  const splitIdx = resolvedTrimmed.search(/\s/);
3267
3323
  const bodyAfter = splitIdx === -1 ? '' : resolvedTrimmed.slice(splitIdx).trim();
3268
3324
  if (bodyAfter.length === 0) {
@@ -3305,6 +3361,23 @@ export async function runRepl(opts) {
3305
3361
  resolvedTrimmed = expansion.text;
3306
3362
  }
3307
3363
  }
3364
+ // Guided runs — the same front door as the Ink path: the /run verb
3365
+ // (rewritten to its alias), then today's trigger. The trigger still
3366
+ // decides (spec §4 step 2); the dispatch below is unchanged.
3367
+ const door = await detectRunStart(resolvedTrimmed, {
3368
+ cwd: process.cwd(), session, resume: true,
3369
+ // Shadow (spec §4 step 2): same as the Ink path. Not awaited; counts only.
3370
+ onFreeTextStart: (text) => void shadowStart(text, 'run-clean-core', {
3371
+ classify: (p, c) => classifyPrompt(p, c, { shadowRuns: true }),
3372
+ context: { sap_system: selectedAlias, last_skill: session.last_skill ?? null, last_object: session.last_object ?? null },
3373
+ llmMode: cfg.llm?.mode,
3374
+ }),
3375
+ });
3376
+ if (door.kind === 'line') {
3377
+ console.log(door.line);
3378
+ continue;
3379
+ }
3380
+ resolvedTrimmed = door.input;
3308
3381
  const parsed = parseShortcut(resolvedTrimmed);
3309
3382
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
3310
3383
  // catalog: print the notice ("did you mean" / builtin-with-args) and
@@ -3317,29 +3390,25 @@ export async function runRepl(opts) {
3317
3390
  // Guided clean-core journey (Task 3) — classic mirror of the Ink entry.
3318
3391
  // Cards become one typed line here (no card listener); a queued step is
3319
3392
  // 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;
3393
+ if (door.kind === 'run') {
3394
+ const { runJourneyEntry } = await import('./repl/journey-entry.js');
3395
+ const journeyAbort = new AbortController();
3396
+ const onJourneySigint = () => { journeyAbort.abort(); };
3397
+ turnInProgress = true;
3398
+ rl.on('SIGINT', onJourneySigint);
3399
+ try {
3400
+ await runJourneyEntry(door.trigger, {
3401
+ session, cwd: process.cwd(), adt, sapAlias: selectedAlias, standalone, headless: isHeadless(),
3402
+ emit: (line) => console.log(line),
3403
+ queueNext: (cmd, origin) => pendingDispatchAccessor.set(cmd, origin),
3404
+ signal: journeyAbort.signal,
3405
+ });
3342
3406
  }
3407
+ finally {
3408
+ rl.removeListener('SIGINT', onJourneySigint);
3409
+ turnInProgress = false;
3410
+ }
3411
+ continue;
3343
3412
  }
3344
3413
  // Record the prose body (post-shortcut stripping) for /reroute. Using
3345
3414
  // `parsed.prompt` rather than `trimmed` means:
@@ -3400,6 +3469,8 @@ export async function runRepl(opts) {
3400
3469
  throw err;
3401
3470
  c = fallback;
3402
3471
  }
3472
+ // Defensive (spec §4 step 2): a run label never dispatches in this release.
3473
+ c = guardRunLabel(c);
3403
3474
  if (c.screenshot)
3404
3475
  console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
3405
3476
  // Fix 10 (2026-09-26): routing failed mid-skill → stay in the