@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.
- package/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/help.js +16 -1
- 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/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 +4 -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 +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +145 -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
|
+
}
|
|
@@ -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;
|
|
@@ -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"
|
|
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;
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
autoDispatch(
|
|
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.
|
|
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
|
|
2823
|
-
if (
|
|
2824
|
-
|
|
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
|
|
2833
|
-
if (
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|
|
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
|
-
|
|
114
|
+
// Guided runs (spec §4 step 2): a shadow call asks the proxy for the
|
|
115
|
+
// shadow-only router text; a live call's body is unchanged.
|
|
116
|
+
body: JSON.stringify(shadowRuns ? { prompt, context, shadow: 'runs' }
|
|
117
|
+
: images.length > 0 ? { prompt, context, images } : { prompt, context }),
|
|
115
118
|
signal: ctrl.signal,
|
|
116
119
|
});
|
|
117
120
|
}
|
|
@@ -188,7 +191,8 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
188
191
|
throw new Error('no_api_key');
|
|
189
192
|
if (opts.signal?.aborted)
|
|
190
193
|
throw routingCancelled();
|
|
191
|
-
|
|
194
|
+
// A shadow call never sends images (spec §4 step 2): text only.
|
|
195
|
+
const images = opts.shadowRuns ? [] : await promptImages(prompt, opts.cwd ?? process.cwd());
|
|
192
196
|
if (opts.signal?.aborted)
|
|
193
197
|
throw routingCancelled();
|
|
194
198
|
let r;
|
|
@@ -201,7 +205,7 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
201
205
|
// they bubble through as before.
|
|
202
206
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
203
207
|
try {
|
|
204
|
-
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal);
|
|
208
|
+
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal, opts.shadowRuns === true);
|
|
205
209
|
break;
|
|
206
210
|
}
|
|
207
211
|
catch (err) {
|