@cspeach/cli 0.9.0 → 1.0.0
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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +209 -20
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +148 -21
- package/dist/config/loader.js +95 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +130 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +159 -25
- package/dist/projects/plan-schema.js +63 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +247 -20
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +301 -35
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +48 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +6 -2
- package/dist/ui/body.js +13 -0
- package/dist/ui/footer.js +20 -6
- package/dist/ui/line-resolution.js +17 -6
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/widgets/ask-question-modal.js +4 -1
- package/package.json +19 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notice rendering — the 'warn' / 'info' side-channel (battery B2 fix).
|
|
3
|
+
*
|
|
4
|
+
* Tool handlers emit user-facing notices on the SAME EventEmitter that
|
|
5
|
+
* carries 'chunk' stream data:
|
|
6
|
+
*
|
|
7
|
+
* ctx.chunkEmitter?.emit('warn', msg) — user-actionable notices
|
|
8
|
+
* (e.g. "ignoring transport=X,
|
|
9
|
+
* object locked in Y")
|
|
10
|
+
* ctx.chunkEmitter?.emit('info', msg) — dim, non-fatal diagnostics
|
|
11
|
+
* (e.g. "snapshot ✓", lookup
|
|
12
|
+
* fallback traces)
|
|
13
|
+
*
|
|
14
|
+
* Before this module existed those events were NO-OPS in the live REPL:
|
|
15
|
+
* the Ink Body only consumed 'chunk', and the classic/one-shot paths never
|
|
16
|
+
* put a chunkEmitter on the ToolContext at all. resolveWriteTransport's
|
|
17
|
+
* carefully-worded Rule 9 notices (D15/D20) were invisible.
|
|
18
|
+
*
|
|
19
|
+
* Two consumers, one formatter:
|
|
20
|
+
* - Ink: ui/body.tsx subscribes 'warn'/'info' and pushes
|
|
21
|
+
* formatNoticeLine(...) through the SAME chunk batcher, so
|
|
22
|
+
* ordering relative to surrounding chunks is preserved.
|
|
23
|
+
* - classic / one-shot: createClassicOutputEmitter() returns an emitter
|
|
24
|
+
* whose listeners write straight to stdout. repl.tsx
|
|
25
|
+
* (classic branch) and one-shot.ts put it on ctx.chunkEmitter.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT wired for subagents (tools/subagent/agent_run.ts):
|
|
28
|
+
* their ctx.chunkEmitter stays undefined so child-turn notices remain
|
|
29
|
+
* muted — exactly the pre-existing contract pinned in agent_run.test.ts.
|
|
30
|
+
*/
|
|
31
|
+
import chalk from 'chalk';
|
|
32
|
+
import { EventEmitter } from 'node:events';
|
|
33
|
+
/**
|
|
34
|
+
* Format one notice as a single terminal line (trailing newline included).
|
|
35
|
+
* Multi-line messages are collapsed to one line — notices are glanceable
|
|
36
|
+
* margin notes, not documents; anything long belongs in the tool result.
|
|
37
|
+
*
|
|
38
|
+
* warn → yellow " ⚠ message"
|
|
39
|
+
* info → dim " · message"
|
|
40
|
+
*/
|
|
41
|
+
export function formatNoticeLine(level, message) {
|
|
42
|
+
const oneLine = message.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
43
|
+
if (level === 'warn')
|
|
44
|
+
return chalk.yellow(` ⚠ ${oneLine}`) + '\n';
|
|
45
|
+
return chalk.dim(` · ${oneLine}`) + '\n';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the output emitter for the NON-Ink paths (classic REPL, one-shot).
|
|
49
|
+
*
|
|
50
|
+
* Listeners:
|
|
51
|
+
* 'chunk' → written verbatim (already-formatted stream text — e.g. the
|
|
52
|
+
* verify-chain line emitted by write tools)
|
|
53
|
+
* 'warn' → formatNoticeLine('warn', …)
|
|
54
|
+
* 'info' → formatNoticeLine('info', …)
|
|
55
|
+
*
|
|
56
|
+
* The `write` sink defaults to process.stdout — injectable for tests.
|
|
57
|
+
*/
|
|
58
|
+
export function createClassicOutputEmitter(write = (s) => process.stdout.write(s)) {
|
|
59
|
+
const emitter = new EventEmitter();
|
|
60
|
+
emitter.on('chunk', (s) => write(s));
|
|
61
|
+
emitter.on('warn', (msg) => write(formatNoticeLine('warn', msg)));
|
|
62
|
+
emitter.on('info', (msg) => write(formatNoticeLine('info', msg)));
|
|
63
|
+
return emitter;
|
|
64
|
+
}
|
|
@@ -92,6 +92,10 @@ const MESSAGES = {
|
|
|
92
92
|
'Reserving a transport request…',
|
|
93
93
|
'Asking SAP for a fresh TR…',
|
|
94
94
|
],
|
|
95
|
+
sap_transport_for_object: [
|
|
96
|
+
'Checking who owns {object_name}…',
|
|
97
|
+
'Looking for an owning transport…',
|
|
98
|
+
],
|
|
95
99
|
sap_transport_list: [
|
|
96
100
|
'Listing open transports…',
|
|
97
101
|
'Finding modifiable TRs…',
|
|
@@ -156,6 +160,10 @@ const MESSAGES = {
|
|
|
156
160
|
'Publishing service binding…',
|
|
157
161
|
'Asking SAP to expose the OData endpoint…',
|
|
158
162
|
],
|
|
163
|
+
sap_service_binding_unpublish: [
|
|
164
|
+
'Unpublishing service binding…',
|
|
165
|
+
'Asking SAP to remove the OData endpoint…',
|
|
166
|
+
],
|
|
159
167
|
};
|
|
160
168
|
const FALLBACK_MESSAGES = [
|
|
161
169
|
'Working on it…',
|
|
@@ -61,7 +61,7 @@ export function renderToolCallTop(params) {
|
|
|
61
61
|
else
|
|
62
62
|
process.stdout.write(s);
|
|
63
63
|
};
|
|
64
|
-
const summary =
|
|
64
|
+
const summary = summariseToolArgs(name, args);
|
|
65
65
|
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
66
66
|
const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
|
|
67
67
|
write('\n' + line + '\n');
|
|
@@ -136,7 +136,7 @@ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
|
|
|
136
136
|
return { stop: stopFn };
|
|
137
137
|
}
|
|
138
138
|
export function renderToolCallBottom(params) {
|
|
139
|
-
const { durationMs, isError, resultSummary, chunkEmitter } = params;
|
|
139
|
+
const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
|
|
140
140
|
const write = (s) => {
|
|
141
141
|
if (chunkEmitter)
|
|
142
142
|
chunkEmitter.emit('chunk', s);
|
|
@@ -144,15 +144,26 @@ export function renderToolCallBottom(params) {
|
|
|
144
144
|
process.stdout.write(s);
|
|
145
145
|
};
|
|
146
146
|
const icon = isError ? chalk.red('✗') : chalk.green('✓');
|
|
147
|
+
// Label: plain (not bold — the top line keeps visual primacy) name +
|
|
148
|
+
// dim arg summary, mirroring the top-line shape so orphaned rows still
|
|
149
|
+
// read as "which tool, on what object".
|
|
150
|
+
let label = '';
|
|
151
|
+
if (name) {
|
|
152
|
+
const summary = summariseToolArgs(name, args ?? {});
|
|
153
|
+
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
154
|
+
label = ` ${name}${wrapped}`;
|
|
155
|
+
}
|
|
147
156
|
const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
|
|
148
157
|
const detail = detailRaw
|
|
149
158
|
? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
|
|
150
159
|
: '';
|
|
151
160
|
const timing = chalk.dim(formatDuration(durationMs));
|
|
152
161
|
const sep = chalk.dim('·');
|
|
153
|
-
let line = ` ${peach('⎿')} ${icon}`;
|
|
162
|
+
let line = ` ${peach('⎿')} ${icon}${label}`;
|
|
154
163
|
if (detail)
|
|
155
164
|
line += ` ${detail} ${sep} ${timing}`;
|
|
165
|
+
else if (label)
|
|
166
|
+
line += ` ${sep} ${timing}`;
|
|
156
167
|
else
|
|
157
168
|
line += ` ${timing}`;
|
|
158
169
|
write(line + '\n');
|
|
@@ -162,8 +173,11 @@ export function renderToolCallBottom(params) {
|
|
|
162
173
|
* arguments. Per-tool aware: pulls LLM-supplied summary first, then the
|
|
163
174
|
* common (name, type, package) triple, then specific keys for known tools.
|
|
164
175
|
* Falls back to a truncated JSON dump.
|
|
176
|
+
*
|
|
177
|
+
* Exported (D29) so the bottom result row can self-identify with the same
|
|
178
|
+
* summary the top line used.
|
|
165
179
|
*/
|
|
166
|
-
function
|
|
180
|
+
export function summariseToolArgs(toolName, args) {
|
|
167
181
|
if (!args || Object.keys(args).length === 0)
|
|
168
182
|
return '';
|
|
169
183
|
const a = args;
|
package/dist/renderer/tty.js
CHANGED
|
@@ -25,6 +25,36 @@ export function setRenderingOverride(mode) {
|
|
|
25
25
|
export function getRenderingOverride() {
|
|
26
26
|
return renderingOverride;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* B5 (2026-06-11) — headless answer policy signal.
|
|
30
|
+
*
|
|
31
|
+
* "Headless" means NO human can answer a prompt: stdin will never deliver
|
|
32
|
+
* an answer, so any inquirer/readline wait blocks forever (battery S2 Pass A
|
|
33
|
+
* stalled on exactly this — defect D1). Consumers (ask_question, the save
|
|
34
|
+
* hook, request_approval) branch to auto-answer / fail-fast policies instead
|
|
35
|
+
* of prompting.
|
|
36
|
+
*
|
|
37
|
+
* Decision order:
|
|
38
|
+
* 1. Explicit override — runOneShot() sets `true` at startup because
|
|
39
|
+
* one-shot is a single non-interactive turn by design (CI parity:
|
|
40
|
+
* the same invocation must behave identically piped or from a
|
|
41
|
+
* terminal). Tests set/clear it directly.
|
|
42
|
+
* 2. Fallback — stdin is not a TTY. Covers piped/redirected invocations
|
|
43
|
+
* that never went through runOneShot. stdin (not stdout) is the right
|
|
44
|
+
* floor: prompts BLOCK on stdin; piping only stdout still leaves a
|
|
45
|
+
* human able to answer.
|
|
46
|
+
*/
|
|
47
|
+
let headlessOverride = null;
|
|
48
|
+
/** Set (or clear with null) the explicit headless override. */
|
|
49
|
+
export function setHeadlessOverride(v) {
|
|
50
|
+
headlessOverride = v;
|
|
51
|
+
}
|
|
52
|
+
/** True when no human can answer prompts — see setHeadlessOverride. */
|
|
53
|
+
export function isHeadless() {
|
|
54
|
+
if (headlessOverride !== null)
|
|
55
|
+
return headlessOverride;
|
|
56
|
+
return process.stdin.isTTY !== true;
|
|
57
|
+
}
|
|
28
58
|
/**
|
|
29
59
|
* True when the Ink UI frame should be mounted.
|
|
30
60
|
*
|
|
@@ -32,8 +62,17 @@ export function getRenderingOverride() {
|
|
|
32
62
|
* 1. If override === 'classic', never use Ink (explicit opt-out)
|
|
33
63
|
* 2. If override === 'ink', use Ink when a TTY is available; otherwise
|
|
34
64
|
* fall back — Ink cannot render on a pipe regardless of user preference
|
|
35
|
-
* 3. If override === 'auto' (default), use Ink when interactive TTY
|
|
36
|
-
* not one-shot + CI=unset
|
|
65
|
+
* 3. If override === 'auto' (default), use Ink when interactive TTY on
|
|
66
|
+
* BOTH stdout and stdin + not one-shot + CI=unset
|
|
67
|
+
*
|
|
68
|
+
* UX-5 review C1 (2026-06-12): the auto branch must also require
|
|
69
|
+
* `process.stdin.isTTY` — Ink mounts raw-mode key handling on stdin, and a
|
|
70
|
+
* piped stdin (`"/cost`n/exit" | cspeach`) cannot enter raw mode. Worse,
|
|
71
|
+
* mounting Ink there bypassed the classic loop's early-line buffer
|
|
72
|
+
* (UX-5 #5), so every piped line was dropped. Piped-stdin invocations now
|
|
73
|
+
* take the classic path even when stdout is a terminal. The explicit
|
|
74
|
+
* `ui.rendering = 'ink'` override (branch 2) deliberately keeps its
|
|
75
|
+
* stdout-only floor — that's the user's explicit choice.
|
|
37
76
|
*
|
|
38
77
|
* v0.3+: pipeline.ts and approvals/render.ts branch on this.
|
|
39
78
|
*/
|
|
@@ -42,8 +81,8 @@ export function shouldUseInk() {
|
|
|
42
81
|
return false;
|
|
43
82
|
if (renderingOverride === 'ink')
|
|
44
83
|
return isTTY();
|
|
45
|
-
// auto
|
|
46
|
-
return isTTY() && !isOneShot() && !process.env['CI'];
|
|
84
|
+
// auto — stdin must be a TTY too (raw-mode capable, no piped lines to drop)
|
|
85
|
+
return isTTY() && process.stdin.isTTY === true && !isOneShot() && !process.env['CI'];
|
|
47
86
|
}
|
|
48
87
|
/**
|
|
49
88
|
* Stub for v0.3 Windows ConPTY / CI TTY hinting.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify-chain line — one compact row per completed SAP write summarizing
|
|
3
|
+
* the Forge-Rule pipeline the tool actually ran (tour slide-7 alignment):
|
|
4
|
+
*
|
|
5
|
+
* ① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activate pending
|
|
6
|
+
*
|
|
7
|
+
* HONESTY CONTRACT: every segment is derived from the tool's real result —
|
|
8
|
+
* never synthesized. States render truthfully:
|
|
9
|
+
* - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
|
|
10
|
+
* nothing to save; NEVER used for other read failures)
|
|
11
|
+
* - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
|
|
12
|
+
* the pre-write read failed for a non-404 reason; the object may exist
|
|
13
|
+
* but there is no recovery copy. Hard-gate tools block instead of
|
|
14
|
+
* emitting this; it only appears on soft (best-effort) gates.
|
|
15
|
+
* - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
|
|
16
|
+
* where the gate is soft, e.g. class includes)
|
|
17
|
+
* - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
|
|
18
|
+
* activation is blocked, so we don't show a fake pending step
|
|
19
|
+
* - syntax 'not_checked' → "③ syntax – (checked at activation)" —
|
|
20
|
+
* sap_set_class_include has no standalone syntax gate
|
|
21
|
+
* - ④ is always 'pending', never '✓': activation is a SEPARATE tool call
|
|
22
|
+
* (sap_activate); these write tools never activate.
|
|
23
|
+
*
|
|
24
|
+
* Consumed by the write tools in tools/sap-write.ts, emitted on the
|
|
25
|
+
* 'chunk' channel so it lands between the ⏺ dispatch line and the ⎿
|
|
26
|
+
* result row in both renderers.
|
|
27
|
+
*/
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
const pass = (s) => `${s} ${chalk.green('✓')}`;
|
|
30
|
+
const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
|
|
31
|
+
const skip = (s, why) => chalk.dim(`${s} – (${why})`);
|
|
32
|
+
const ERROR_DETAIL_MAX = 60;
|
|
33
|
+
const SNAPSHOT_DETAIL_MAX = 40;
|
|
34
|
+
const truncate = (s, max) => {
|
|
35
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
36
|
+
return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Build the chain line (no trailing newline). Returns the full line
|
|
40
|
+
* including the two-space indent that aligns with the ⎿ result row.
|
|
41
|
+
*/
|
|
42
|
+
export function buildWriteChainLine(input) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
// ① snapshot
|
|
45
|
+
if (input.snapshot === 'taken')
|
|
46
|
+
parts.push(pass('① snapshot'));
|
|
47
|
+
else if (input.snapshot === 'new_object')
|
|
48
|
+
parts.push(skip('① snapshot', 'new object'));
|
|
49
|
+
else if (input.snapshot === 'source_read_failed') {
|
|
50
|
+
// Honest: the read failed — we do NOT know the object is new, and there
|
|
51
|
+
// is no recovery copy. Never render this as "(new object)".
|
|
52
|
+
const why = input.snapshotDetail
|
|
53
|
+
? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
|
|
54
|
+
: 'no source read';
|
|
55
|
+
parts.push(skip('① snapshot', why));
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
parts.push(skip('① snapshot', 'skipped'));
|
|
59
|
+
// ② written
|
|
60
|
+
parts.push(input.written ? pass('② written') : fail('② written'));
|
|
61
|
+
// ③ syntax (+ ④ activate, which only exists when activation is possible)
|
|
62
|
+
if (input.syntax === 'not_checked') {
|
|
63
|
+
parts.push(skip('③ syntax', 'checked at activation'));
|
|
64
|
+
parts.push(chalk.dim('④ activate pending'));
|
|
65
|
+
}
|
|
66
|
+
else if (input.syntax.ok) {
|
|
67
|
+
parts.push(pass('③ syntax'));
|
|
68
|
+
parts.push(chalk.dim('④ activate pending'));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
|
|
72
|
+
const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
|
|
73
|
+
parts.push(fail('③ syntax', detail));
|
|
74
|
+
// No ④ — activation is blocked; rendering a pending step would lie.
|
|
75
|
+
}
|
|
76
|
+
return ' ' + parts.join(' ');
|
|
77
|
+
}
|
package/dist/repl/at-picker.js
CHANGED
|
@@ -17,24 +17,75 @@
|
|
|
17
17
|
* without screen takeover, Enter when they want a full search-as-you-type
|
|
18
18
|
* picker. Same backing data, different UI.
|
|
19
19
|
*/
|
|
20
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
20
22
|
import { search } from '@inquirer/prompts';
|
|
21
23
|
import { withInquirer } from './inquirer-guard.js';
|
|
22
24
|
import { inquirerTheme } from './inquirer-theme.js';
|
|
23
|
-
import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
|
|
25
|
+
import { ensureWorkspaceSync, listProjectFilesSync, isAttachableFilename } from '../projects/workspace.js';
|
|
24
26
|
const CANCEL_VALUE = '__cspeach_cancel__';
|
|
25
27
|
/**
|
|
26
28
|
* 2026-06-06 (Ink picker parity): workspace listing + substring filter,
|
|
27
29
|
* exported so the Ink FilePalette shares the same data + seeding
|
|
28
30
|
* semantics as this classic inquirer picker.
|
|
31
|
+
*
|
|
32
|
+
* Bug #4 (2026-06-15): the listing is the UNION of
|
|
33
|
+
* - all workspace files (.cspeach.json envelopes + .txt/.md/.docx/.pdf), AND
|
|
34
|
+
* - top-level .txt/.md/.docx/.pdf files in the current working directory.
|
|
35
|
+
* A spec file dropped next to the project (the common case) lives in the
|
|
36
|
+
* cwd, never in .cspeach/, so without the cwd half the picker was empty.
|
|
37
|
+
*
|
|
38
|
+
* Deduped by filename — when a name exists in both, the workspace entry
|
|
39
|
+
* wins. Non-recursive over the cwd; the .cspeach subfolder and any
|
|
40
|
+
* dotfiles/dirs are skipped. SYNCHRONOUS by contract (the Ink palette and
|
|
41
|
+
* pendingAtTokenPrefix call it sync). `cwd` / `workspace` are injectable
|
|
42
|
+
* for tests; the zero-arg call resolves both from the environment.
|
|
29
43
|
*/
|
|
30
|
-
export function listWorkspaceFileChoices() {
|
|
44
|
+
export function listWorkspaceFileChoices(opts) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
// Workspace half (takes precedence on name collisions).
|
|
48
|
+
try {
|
|
49
|
+
const ws = opts?.workspace ?? ensureWorkspaceSync();
|
|
50
|
+
for (const f of listProjectFilesSync(ws)) {
|
|
51
|
+
if (seen.has(f.filename))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(f.filename);
|
|
54
|
+
out.push({ filename: f.filename, mtimeMs: f.mtimeMs });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// fall through — still return whatever the cwd half yields
|
|
59
|
+
}
|
|
60
|
+
// cwd half — top-level .txt/.md/.docx/.pdf only. Never throws.
|
|
61
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
31
62
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
63
|
+
for (const name of readdirSync(cwd)) {
|
|
64
|
+
if (name.startsWith('.'))
|
|
65
|
+
continue; // dotfiles/dirs (incl. .cspeach)
|
|
66
|
+
if (!isAttachableFilename(name))
|
|
67
|
+
continue;
|
|
68
|
+
if (seen.has(name))
|
|
69
|
+
continue; // workspace already has it
|
|
70
|
+
const full = join(cwd, name);
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = statSync(full);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile())
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(name);
|
|
81
|
+
out.push({ filename: name, mtimeMs: stat.mtimeMs });
|
|
82
|
+
}
|
|
34
83
|
}
|
|
35
84
|
catch {
|
|
36
|
-
return
|
|
85
|
+
// unreadable cwd — return just the workspace list
|
|
37
86
|
}
|
|
87
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
88
|
+
return out;
|
|
38
89
|
}
|
|
39
90
|
/** Substring filter; empty prefix returns everything. */
|
|
40
91
|
export function filterFileChoices(prefix, all) {
|
|
@@ -126,8 +177,10 @@ export function pendingAtTokenPrefix(trimmed, knownFilenames) {
|
|
|
126
177
|
}
|
|
127
178
|
else {
|
|
128
179
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
// Bug #4: check the MERGED listing (workspace + cwd text files),
|
|
181
|
+
// not workspace-only, so an exact `@spec.txt` for a cwd file
|
|
182
|
+
// submits directly instead of re-opening the picker.
|
|
183
|
+
const files = listWorkspaceFileChoices();
|
|
131
184
|
const exact = files.some((f) => f.filename === prefix);
|
|
132
185
|
if (exact)
|
|
133
186
|
return null;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in (non-skill) slash commands — the single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from slash-picker.ts (2026-06-11) so the router layer
|
|
5
|
+
* (src/router/classifier.ts) can distinguish "user typed a builtin with
|
|
6
|
+
* arguments" (e.g. `/help me`) from "user typed an unknown skill"
|
|
7
|
+
* WITHOUT importing the inquirer-backed picker module. Pure data — no
|
|
8
|
+
* runtime dependencies.
|
|
9
|
+
*/
|
|
10
|
+
export const BUILTIN_ENTRIES = [
|
|
11
|
+
{ name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
|
|
12
|
+
{ name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
|
|
13
|
+
{ name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
|
|
14
|
+
{ name: '/exit', description: 'Exit CSPeach' },
|
|
15
|
+
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
16
|
+
{ name: '/help', description: 'Show all skills and commands' },
|
|
17
|
+
{ name: '/skills', description: 'Show all skills (alias)' },
|
|
18
|
+
{ name: '/new', description: 'Reset routing — next prompt is classified afresh' },
|
|
19
|
+
{ name: '/reset', description: 'Reset routing (alias)' },
|
|
20
|
+
{ name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
|
|
21
|
+
{ name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
|
|
22
|
+
{ name: '/transport', description: 'View or set the active transport for this session' },
|
|
23
|
+
{ name: '/safety-mode', description: 'Toggle structured Forge Rule confirmation cards (on / off / status)' },
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
|
|
27
|
+
* `/files` is a real REPL builtin (repl.tsx handles it in both paths) but
|
|
28
|
+
* is deliberately absent from the picker catalogue, so it's added here.
|
|
29
|
+
*/
|
|
30
|
+
const BUILTIN_HEADS = new Set([
|
|
31
|
+
...BUILTIN_ENTRIES.map((e) => e.name),
|
|
32
|
+
'/files',
|
|
33
|
+
]);
|
|
34
|
+
/** True when `head` (e.g. '/help', as typed, any case) is a builtin command. */
|
|
35
|
+
export function isBuiltinCommandHead(head) {
|
|
36
|
+
return BUILTIN_HEADS.has(head.toLowerCase());
|
|
37
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Early-line buffer for piped-stdin REPL runs — UX-5 #5 (2026-06-12).
|
|
3
|
+
*
|
|
4
|
+
* The bug (found 2026-06-11): lines piped into the classic REPL
|
|
5
|
+
* (`"/cost`n/exit" | cspeach`) arrive on the readline BEFORE the loop's
|
|
6
|
+
* first rl.question() is pending. Node's readline only routes a line to a
|
|
7
|
+
* question callback when one is registered; otherwise it emits the 'line'
|
|
8
|
+
* EVENT — which nobody consumed, so every early line was silently dropped
|
|
9
|
+
* (even /cost never dispatched). stdin EOF then closed the readline and
|
|
10
|
+
* the old 'close' handler hard-exited. This blocked CI-driven multi-phase
|
|
11
|
+
* plan runs entirely.
|
|
12
|
+
*
|
|
13
|
+
* Fix shape:
|
|
14
|
+
* - attach a 'line' listener at readline creation that QUEUES lines
|
|
15
|
+
* arriving while no question is pending (readline emits 'line' only
|
|
16
|
+
* in that case — a pending question consumes the line directly);
|
|
17
|
+
* - the REPL loop drains this queue before calling rl.question;
|
|
18
|
+
* - on stdin EOF ('close') the buffer flips `closed` and the loop keeps
|
|
19
|
+
* processing queued lines before exiting (the repl.tsx close handler
|
|
20
|
+
* no longer hard-exits in piped mode);
|
|
21
|
+
* - the FIRST piped line gets a UTF-8 BOM strip — PowerShell pipes
|
|
22
|
+
* inject one, which used to corrupt the first command head.
|
|
23
|
+
*
|
|
24
|
+
* Only attached when stdin is NOT a TTY (see repl.tsx): in interactive
|
|
25
|
+
* mode, type-ahead during a turn should keep its existing
|
|
26
|
+
* behaviour rather than silently becoming queued commands.
|
|
27
|
+
*/
|
|
28
|
+
/** Strip a single leading UTF-8 BOM (PowerShell pipes inject one). */
|
|
29
|
+
export function stripBom(s) {
|
|
30
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Attach the queueing listeners to `rl` and return the buffer handle.
|
|
34
|
+
* Call once, immediately after readline creation, BEFORE any awaits —
|
|
35
|
+
* piped data starts flowing on the next tick.
|
|
36
|
+
*/
|
|
37
|
+
export function attachEarlyLineBuffer(rl) {
|
|
38
|
+
const queue = [];
|
|
39
|
+
let firstLine = true;
|
|
40
|
+
let isClosed = false;
|
|
41
|
+
rl.on('line', (l) => {
|
|
42
|
+
let line = String(l);
|
|
43
|
+
if (firstLine) {
|
|
44
|
+
line = stripBom(line);
|
|
45
|
+
firstLine = false;
|
|
46
|
+
}
|
|
47
|
+
queue.push(line);
|
|
48
|
+
});
|
|
49
|
+
rl.on('close', () => {
|
|
50
|
+
isClosed = true;
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
size: () => queue.length,
|
|
54
|
+
shift: () => queue.shift(),
|
|
55
|
+
closed: () => isClosed,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Inert buffer for interactive (TTY) runs — keeps the repl.tsx loop free
|
|
60
|
+
* of `isTTY` branches at every drain site.
|
|
61
|
+
*/
|
|
62
|
+
export function noopEarlyLineBuffer() {
|
|
63
|
+
return {
|
|
64
|
+
size: () => 0,
|
|
65
|
+
shift: () => undefined,
|
|
66
|
+
closed: () => false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -28,6 +28,50 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { clearActiveSpinner } from '../renderer/tool-widget.js';
|
|
30
30
|
let state = null;
|
|
31
|
+
/**
|
|
32
|
+
* UX-5 #4 (2026-06-12) — sequence-scoped (debounced) release.
|
|
33
|
+
*
|
|
34
|
+
* Failure mode this fixes: between BACK-TO-BACK inquirer prompts fired by
|
|
35
|
+
* the SAME handler (safety-confirm's two-step select, ask_question's
|
|
36
|
+
* select → custom-input follow-up, /files step 1 → step 2), the old
|
|
37
|
+
* per-prompt release resumed the outer readline the instant prompt N
|
|
38
|
+
* finished — readline repainted its prompt with a stale history/buffer
|
|
39
|
+
* entry for a frame — and then prompt N+1 paused it again. The user saw
|
|
40
|
+
* the old line flash between the two pickers.
|
|
41
|
+
*
|
|
42
|
+
* Scope note (M1, 2026-06-12): consecutive ask_question TOOL calls are
|
|
43
|
+
* separated by a full LLM round-trip (seconds apart) — the 50ms debounce
|
|
44
|
+
* neither covers nor needs to cover those; readline legitimately resumes
|
|
45
|
+
* between turns. The debounce only spans same-handler prompt sequences.
|
|
46
|
+
*
|
|
47
|
+
* Fix shape: the release (rl.resume + suppress-clear) is DEFERRED by
|
|
48
|
+
* RELEASE_DEBOUNCE_MS. If another withInquirer starts inside that window
|
|
49
|
+
* (an ask SEQUENCE), the pending release is cancelled and the outer
|
|
50
|
+
* prompt stays parked across the whole sequence — no repaint between
|
|
51
|
+
* prompts. Only the final prompt's release actually fires.
|
|
52
|
+
*
|
|
53
|
+
* Note: this is a CLASSIC-path concern only. Under Ink, ask_question /
|
|
54
|
+
* approval render through the Ink-native emitter modals (see
|
|
55
|
+
* tools/ask-question.ts shouldUseInk branch) and registerReadline is
|
|
56
|
+
* never called, so withInquirer is a pass-through there.
|
|
57
|
+
*
|
|
58
|
+
* The suppress flag intentionally stays TRUE during the debounce window —
|
|
59
|
+
* any spurious 'close' inquirer emits on release is still swallowed,
|
|
60
|
+
* which is strictly safer than the old nextTick clearing.
|
|
61
|
+
*/
|
|
62
|
+
// 50ms (M4 rationale): covers same-tick and next-macrotask prompt chaining
|
|
63
|
+
// with ~3 frames of margin (one terminal frame ≈ 16ms) while staying far
|
|
64
|
+
// below human-perceptible delay before the outer prompt repaints.
|
|
65
|
+
export const RELEASE_DEBOUNCE_MS = 50;
|
|
66
|
+
let pendingRelease = null;
|
|
67
|
+
/** Re-entrancy depth — release is scheduled only when the OUTERMOST call exits. */
|
|
68
|
+
let activeDepth = 0;
|
|
69
|
+
function cancelPendingRelease() {
|
|
70
|
+
if (pendingRelease) {
|
|
71
|
+
clearTimeout(pendingRelease);
|
|
72
|
+
pendingRelease = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
31
75
|
/**
|
|
32
76
|
* Called by repl.tsx once the main readline is created. The `setSuppress`
|
|
33
77
|
* callback must flip the same boolean the readline 'close' handler
|
|
@@ -38,6 +82,8 @@ export function registerReadline(rl, setSuppress) {
|
|
|
38
82
|
}
|
|
39
83
|
/** Clear the guard — call when the REPL is shutting down. */
|
|
40
84
|
export function unregisterReadline() {
|
|
85
|
+
cancelPendingRelease();
|
|
86
|
+
activeDepth = 0;
|
|
41
87
|
state = null;
|
|
42
88
|
}
|
|
43
89
|
/**
|
|
@@ -66,6 +112,12 @@ export async function withInquirer(fn) {
|
|
|
66
112
|
// which collides with inquirer's prompt rendering — visible as ghost-text
|
|
67
113
|
// and a cursor that hides under the rotating frame while the user types.
|
|
68
114
|
clearActiveSpinner();
|
|
115
|
+
// UX-5 #4 — continuing an ask SEQUENCE: a release from the previous
|
|
116
|
+
// prompt is still pending, so cancel it. The readline stays parked
|
|
117
|
+
// (paused + close-suppressed) across the gap — no stale-line repaint
|
|
118
|
+
// between consecutive prompts.
|
|
119
|
+
cancelPendingRelease();
|
|
120
|
+
activeDepth++;
|
|
69
121
|
// 2026-05-08: snapshot stdin 'data' / 'keypress' listeners BEFORE the
|
|
70
122
|
// inquirer prompt runs. Some inquirer widgets (notably `search`, used
|
|
71
123
|
// by /files step 1) strip ALL listeners on cleanup — including
|
|
@@ -120,11 +172,24 @@ export async function withInquirer(fn) {
|
|
|
120
172
|
// Order matters here:
|
|
121
173
|
// 1. resume stdin so the event loop stays referenced (otherwise the
|
|
122
174
|
// next rl.question prints its prompt and the process exits).
|
|
123
|
-
// 2.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
175
|
+
// 2. SCHEDULE the readline resume + suppress-clear instead of doing
|
|
176
|
+
// them inline (UX-5 #4). If another inquirer prompt opens within
|
|
177
|
+
// RELEASE_DEBOUNCE_MS — an ask sequence — the entry above cancels
|
|
178
|
+
// this timer and the outer readline never repaints between
|
|
179
|
+
// prompts. The timer is deliberately NOT unref'd: it must hold
|
|
180
|
+
// the event loop open across the gap.
|
|
181
|
+
// 3. inside the deferred release, still defer the suppress-clear by
|
|
182
|
+
// one tick so the spurious 'close' that inquirer synchronously
|
|
183
|
+
// emits on release is ignored.
|
|
126
184
|
process.stdin.resume();
|
|
127
|
-
|
|
128
|
-
|
|
185
|
+
activeDepth = Math.max(0, activeDepth - 1);
|
|
186
|
+
if (activeDepth === 0) {
|
|
187
|
+
cancelPendingRelease();
|
|
188
|
+
pendingRelease = setTimeout(() => {
|
|
189
|
+
pendingRelease = null;
|
|
190
|
+
rl.resume();
|
|
191
|
+
process.nextTick(() => setSuppress(false));
|
|
192
|
+
}, RELEASE_DEBOUNCE_MS);
|
|
193
|
+
}
|
|
129
194
|
}
|
|
130
195
|
}
|