@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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. 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 = argSummary(name, args);
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 argSummary(toolName, args) {
180
+ export function summariseToolArgs(toolName, args) {
167
181
  if (!args || Object.keys(args).length === 0)
168
182
  return '';
169
183
  const a = args;
@@ -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
+ }
@@ -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 ws = ensureWorkspaceSync();
33
- return listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
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
- const ws = ensureWorkspaceSync();
130
- const files = listProjectFilesSync(ws);
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. resume the readline so it accepts new input.
124
- // 3. defer the suppress-clear so the spurious 'close' that inquirer
125
- // synchronously emits on release is still ignored.
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
- rl.resume();
128
- process.nextTick(() => setSuppress(false));
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
  }