@cspeach/cli 0.8.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -17,11 +17,83 @@
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__';
27
+ /**
28
+ * 2026-06-06 (Ink picker parity): workspace listing + substring filter,
29
+ * exported so the Ink FilePalette shares the same data + seeding
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.
43
+ */
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();
62
+ try {
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
+ }
83
+ }
84
+ catch {
85
+ // unreadable cwd — return just the workspace list
86
+ }
87
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
88
+ return out;
89
+ }
90
+ /** Substring filter; empty prefix returns everything. */
91
+ export function filterFileChoices(prefix, all) {
92
+ const lower = prefix.trim().toLowerCase();
93
+ return lower.length === 0
94
+ ? [...all]
95
+ : all.filter((f) => f.filename.toLowerCase().includes(lower));
96
+ }
25
97
  /**
26
98
  * Open the at-picker pre-filtered to `prefix`. Returns the picked
27
99
  * filename (no leading @), or null on cancel / empty-workspace.
@@ -31,21 +103,11 @@ const CANCEL_VALUE = '__cspeach_cancel__';
31
103
  * (bare `@`) seeds the picker with all workspace files.
32
104
  */
33
105
  export async function openAtPicker(prefix) {
34
- let allFiles;
35
- try {
36
- const ws = ensureWorkspaceSync();
37
- allFiles = listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
38
- }
39
- catch {
40
- allFiles = [];
41
- }
106
+ const allFiles = listWorkspaceFileChoices();
42
107
  if (allFiles.length === 0) {
43
108
  return null;
44
109
  }
45
- const lowerPrefix = prefix.trim().toLowerCase();
46
- const seeded = lowerPrefix.length === 0
47
- ? allFiles
48
- : allFiles.filter((f) => f.filename.toLowerCase().includes(lowerPrefix));
110
+ const seeded = filterFileChoices(prefix, allFiles);
49
111
  // Single unique match — short-circuit, save the user a keystroke. Same
50
112
  // shortcut openSlashPicker uses.
51
113
  if (seeded.length === 1) {
@@ -95,7 +157,7 @@ export async function openAtPicker(prefix) {
95
157
  * null when the line doesn't end with an @-token or the token already
96
158
  * resolves exactly.
97
159
  */
98
- export function pendingAtTokenPrefix(trimmed) {
160
+ export function pendingAtTokenPrefix(trimmed, knownFilenames) {
99
161
  // Match the rightmost @<word> at the end of the line. \S+ means
100
162
  // non-whitespace only — `@filename with space` is not auto-detected
101
163
  // (user can quote with `@"name with space"` for those, handled by
@@ -106,16 +168,26 @@ export function pendingAtTokenPrefix(trimmed) {
106
168
  const prefix = m[1] ?? '';
107
169
  // If the prefix already exactly matches a workspace filename, the
108
170
  // existing @-resolution will pick it up — don't open the picker.
171
+ // `knownFilenames` lets callers (Ink pipeline, tests) inject the
172
+ // listing instead of hitting the filesystem here.
109
173
  if (prefix.length > 0) {
110
- try {
111
- const ws = ensureWorkspaceSync();
112
- const files = listProjectFilesSync(ws);
113
- const exact = files.some((f) => f.filename === prefix);
114
- if (exact)
174
+ if (knownFilenames) {
175
+ if (knownFilenames.includes(prefix))
115
176
  return null;
116
177
  }
117
- catch {
118
- // Fall through — open picker as a safe default.
178
+ else {
179
+ try {
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();
184
+ const exact = files.some((f) => f.filename === prefix);
185
+ if (exact)
186
+ return null;
187
+ }
188
+ catch {
189
+ // Fall through — open picker as a safe default.
190
+ }
119
191
  }
120
192
  }
121
193
  return prefix;
@@ -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
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Numbered-menu expansion — UX-5 #2 (2026-06-12).
3
+ *
4
+ * Skills frequently end a turn with a plain-text numbered menu:
5
+ *
6
+ * 1. Explain it
7
+ * 2. Show structure
8
+ * 3. Run ATC
9
+ *
10
+ * Users then type `2` at the prompt — and before this module nothing
11
+ * useful happened: the bare digit was either routed to the previous skill
12
+ * as a meaningless answer or burned a classifier round-trip. The
13
+ * pragmatic fix: when the user submits a SHORT pure-number input AND the
14
+ * previous assistant message contains a matching numbered list item,
15
+ * expand the input to that item's text before the turn starts (with a
16
+ * dim echo so the user sees what they picked). Anything else passes
17
+ * through unchanged.
18
+ *
19
+ * Deliberately conservative — expansion fires ONLY when:
20
+ * - the input matches ^[0-9]{1,2}$ (1–2 digit pure number, no spaces)
21
+ * - the MENU-SHAPED TAIL of the last assistant text — its final
22
+ * ~MENU_TAIL_LINES non-empty lines — contains a numbered list with
23
+ * 2+ items ("N. text" or "N · text" line patterns). A real menu sits
24
+ * at the END of the message; numbered plans/steps mid-message
25
+ * (followed by more prose) must NOT hijack a legitimate numeric
26
+ * answer (UX-5 review I2, 2026-06-12).
27
+ * - the typed number is one of the list's item numbers
28
+ *
29
+ * Everything else (no list, out-of-range number, prose input) returns
30
+ * null and the caller treats the input as typed.
31
+ */
32
+ /** Pure 1–2 digit number, nothing else. */
33
+ const PURE_NUMBER_RE = /^[0-9]{1,2}$/;
34
+ /**
35
+ * A numbered list item at the start of a line: `12. text` or `12 · text`.
36
+ * Bullet variants with `)` etc. are deliberately NOT matched — staying
37
+ * conservative beats over-firing on prose like "step 3) optional".
38
+ */
39
+ const ITEM_LINE_RE = /^\s*([0-9]{1,2})(?:\.|\s·)\s+(\S.*)$/;
40
+ /**
41
+ * Extract numbered list items from assistant text. Returns one entry per
42
+ * distinct item number — when the same number appears twice (e.g. two
43
+ * separate lists in one message), the LAST occurrence wins, since the
44
+ * most recent list is the one the user is looking at.
45
+ */
46
+ export function extractNumberedItems(text) {
47
+ const byNumber = new Map();
48
+ for (const line of text.split('\n')) {
49
+ const m = ITEM_LINE_RE.exec(line);
50
+ if (!m)
51
+ continue;
52
+ const n = Number.parseInt(m[1], 10);
53
+ byNumber.set(n, m[2].trim());
54
+ }
55
+ return [...byNumber.entries()].map(([number, text]) => ({ number, text }));
56
+ }
57
+ /**
58
+ * How many trailing non-empty lines count as the "menu-shaped tail" of an
59
+ * assistant message. 10 fits the largest real skill menus seen in the wild
60
+ * (8 items + a header + a trailing "pick one" line) while excluding
61
+ * numbered plan/step lists that sit mid-message above closing prose.
62
+ */
63
+ export const MENU_TAIL_LINES = 10;
64
+ /**
65
+ * The final `maxLines` NON-EMPTY lines of `text`, original order, joined
66
+ * with newlines. Blank lines are skipped (menus are often separated from
67
+ * the item lines by blank lines — those shouldn't eat the budget).
68
+ */
69
+ export function menuTail(text, maxLines = MENU_TAIL_LINES) {
70
+ const lines = text.split('\n');
71
+ const tail = [];
72
+ for (let i = lines.length - 1; i >= 0 && tail.length < maxLines; i--) {
73
+ const line = lines[i];
74
+ if (line.trim().length === 0)
75
+ continue;
76
+ tail.unshift(line);
77
+ }
78
+ return tail.join('\n');
79
+ }
80
+ /**
81
+ * If `input` is a short pure number selecting an item from a numbered
82
+ * list in the menu-shaped TAIL of `lastAssistantText`, return the
83
+ * expansion; otherwise null (pass the input through unchanged).
84
+ *
85
+ * I2 (2026-06-12): only the final MENU_TAIL_LINES non-empty lines are
86
+ * scanned — a numbered plan mid-message (with prose after it) no longer
87
+ * hijacks a numeric answer the user meant literally.
88
+ */
89
+ export function maybeExpandNumberSelection(input, lastAssistantText) {
90
+ const trimmed = input.trim();
91
+ if (!PURE_NUMBER_RE.test(trimmed))
92
+ return null;
93
+ if (!lastAssistantText)
94
+ return null;
95
+ const items = extractNumberedItems(menuTail(lastAssistantText));
96
+ if (items.length < 2)
97
+ return null; // not a menu — a lone "1." is prose
98
+ const n = Number.parseInt(trimmed, 10);
99
+ const hit = items.find((i) => i.number === n);
100
+ if (!hit)
101
+ return null; // out of range — pass through
102
+ return {
103
+ text: hit.text,
104
+ echo: `→ ${hit.number}. ${hit.text}`,
105
+ };
106
+ }
107
+ /**
108
+ * Text of the most recent assistant message in `messages`. Joins the
109
+ * text blocks of that single message (tool_use blocks skipped); handles
110
+ * the defensive string-content case too. Returns null when no assistant
111
+ * message exists.
112
+ */
113
+ export function lastAssistantText(messages) {
114
+ for (let i = messages.length - 1; i >= 0; i--) {
115
+ const m = messages[i];
116
+ if (m.role !== 'assistant')
117
+ continue;
118
+ if (typeof m.content === 'string')
119
+ return m.content;
120
+ if (Array.isArray(m.content)) {
121
+ const parts = [];
122
+ for (const block of m.content) {
123
+ if (block?.type === 'text' && typeof block.text === 'string')
124
+ parts.push(block.text);
125
+ }
126
+ return parts.join('\n\n');
127
+ }
128
+ return null;
129
+ }
130
+ return null;
131
+ }
@@ -26,7 +26,7 @@ export function snapshotTurnStart(session) {
26
26
  };
27
27
  }
28
28
  export function pushPostTurnStatus(params) {
29
- const { session, selectedAlias, snapshot, chunkEmitter } = params;
29
+ const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
30
30
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
31
31
  const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
32
32
  const lifetimeTokensBefore = snapshot.input + snapshot.output;
@@ -37,7 +37,7 @@ export function pushPostTurnStatus(params) {
37
37
  cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,
38
38
  cacheCreate: (session.usage.cache_creation_input_tokens ?? 0) - snapshot.cacheCreate,
39
39
  };
40
- const turnCost = computeCost(session.model, turnCostTokens);
40
+ const turnCost = computeCost(turnModel ?? session.model, turnCostTokens);
41
41
  const sessionCost = computeCost(session.model, {
42
42
  input: session.usage.input_tokens,
43
43
  output: session.usage.output_tokens,
@@ -82,10 +82,25 @@ export function renderPlanSummary(proposedNext) {
82
82
  return lines.join('\n');
83
83
  }
84
84
  function summarizeArgs(args) {
85
- // Best-effort one-line summary. Pulls the most useful identifier fields:
86
- // type+name, class_name+method_name, transport.
85
+ // Best-effort one-line summary. Pulls the most useful identifier fields so a
86
+ // customer reviewing the batch gate sees WHAT each op targets (Forge Rule 8
87
+ // is unauditable theatre otherwise):
88
+ // - file_write / file_edit → the path
89
+ // - shell_exec → command + args (e.g. `npm run build`)
90
+ // - SAP object writes → type+name, class_name->method_name, transport
87
91
  const a = args;
88
92
  const parts = [];
93
+ // Local-FS writes (the fiori-build path: 13× file_write + 2× shell_exec) —
94
+ // the path / command is the only meaningful descriptor; surface it FIRST.
95
+ if (typeof a['path'] === 'string' && a['path']) {
96
+ parts.push(a['path']);
97
+ }
98
+ if (typeof a['command'] === 'string' && a['command']) {
99
+ const cmdArgs = Array.isArray(a['args'])
100
+ ? a['args'].filter((x) => typeof x === 'string').join(' ')
101
+ : '';
102
+ parts.push(cmdArgs ? `${a['command']} ${cmdArgs}` : a['command']);
103
+ }
89
104
  if (typeof a['type'] === 'string' && typeof a['name'] === 'string') {
90
105
  parts.push(`${a['type']} ${a['name']}`);
91
106
  }