@cspeach/cli 1.1.31 → 1.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/cost.js +6 -2
- package/dist/commands/help.js +18 -2
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +4 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +632 -90
- package/dist/commands/usage.js +106 -0
- package/dist/cost/month-usage.js +45 -0
- package/dist/journey/chain.js +265 -15
- package/dist/journey/clean-core.js +25 -3
- package/dist/journey/flow.js +51 -1
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +213 -20
- package/dist/journey/session-deps.js +5 -1
- package/dist/lane/app-chain.js +12 -2
- package/dist/one-shot.js +24 -17
- package/dist/projects/clean-core-confirm.js +38 -5
- package/dist/projects/save-command.js +20 -3
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +49 -19
- package/dist/register/export-files.js +58 -0
- package/dist/register/measure.js +191 -9
- package/dist/register/plan-import.js +6 -3
- package/dist/register/records.js +15 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/register-status-file.js +106 -108
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +1 -0
- package/dist/register/status-html.js +25 -6
- package/dist/register/store.js +4 -3
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +22 -56
- package/dist/register/write-whole.js +30 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/builtin-commands.js +7 -0
- package/dist/repl/journey-entry.js +4 -2
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +157 -86
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_findings.js +13 -2
- package/dist/upgrade-check.js +49 -4
- package/package.json +120 -120
- package/.cspeach/project.json +0 -4
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
//
|
|
1011
|
-
const notice =
|
|
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
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1043
|
-
|
|
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"
|
|
1314
|
-
// Detected
|
|
1315
|
-
{
|
|
1316
|
-
const {
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
autoDispatch(
|
|
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.
|
|
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
|
|
2823
|
-
if (
|
|
2824
|
-
|
|
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
|
|
2833
|
-
if (
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
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
|
-
|
|
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 {
|
|
3322
|
-
const
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
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
|