@cspeach/cli 0.9.0 → 1.1.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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  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/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -59,18 +59,6 @@ export function disableBracketedPaste(stream = process.stdout) {
59
59
  if (stream.isTTY)
60
60
  stream.write(BRACKETED_PASTE_DISABLE);
61
61
  }
62
- /**
63
- * Transform stream that collapses the content between `\x1b[200~` and
64
- * `\x1b[201~` into a single line (newlines replaced with spaces). The
65
- * markers themselves are stripped. All other bytes pass through
66
- * unchanged, including keystrokes, arrow-key escape sequences, and
67
- * terminal responses — these are not affected because they never contain
68
- * the paste markers.
69
- *
70
- * The implementation uses Buffer operations (not string concat) to avoid
71
- * splitting multi-byte UTF-8 sequences at chunk boundaries when a paste
72
- * spans multiple `data` events.
73
- */
74
62
  export class BracketedPasteDecoder extends Transform {
75
63
  /**
76
64
  * Readline with `terminal: true` inspects `input.isTTY` and calls
@@ -81,9 +69,17 @@ export class BracketedPasteDecoder extends Transform {
81
69
  *
82
70
  * We present as a TTY and forward the raw-mode toggle and the size
83
71
  * getters to the real `process.stdin` / `process.stdout`.
72
+ *
73
+ * (Typed `boolean`, not the literal `true`, so InkStdinPasteGuard can
74
+ * mirror the real process.stdin.isTTY instead.)
84
75
  */
85
76
  // eslint-disable-next-line @typescript-eslint/naming-convention
86
77
  isTTY = true;
78
+ emitVerbatim;
79
+ constructor(options = {}) {
80
+ super();
81
+ this.emitVerbatim = options.emitVerbatim ?? false;
82
+ }
87
83
  setRawMode(mode) {
88
84
  if (typeof process.stdin.setRawMode === 'function')
89
85
  process.stdin.setRawMode(mode);
@@ -166,9 +162,9 @@ export class BracketedPasteDecoder extends Transform {
166
162
  this.carryTimer = null;
167
163
  }
168
164
  // End of stream. If we were still inside a paste (unterminated),
169
- // flush the buffered content as a single line so it's not lost.
165
+ // flush the buffered content so it's not lost.
170
166
  if (this.inPaste && this.pasteBuf.length > 0) {
171
- this.push(this.collapseNewlines(this.pasteBuf));
167
+ this.push(this.emitVerbatim ? this.pasteBuf : this.collapseNewlines(this.pasteBuf));
172
168
  this.pasteBuf = Buffer.alloc(0);
173
169
  this.inPaste = false;
174
170
  }
@@ -192,11 +188,24 @@ export class BracketedPasteDecoder extends Transform {
192
188
  return;
193
189
  }
194
190
  this.pasteBuf = Buffer.concat([this.pasteBuf, data.subarray(i, endIdx)]);
195
- this.push(this.collapseNewlines(this.pasteBuf));
196
- // Append a newline so readline commits the pasted line as a
197
- // single input — otherwise the user would still have to press
198
- // Enter after the paste.
199
- this.push(Buffer.from('\n'));
191
+ if (this.emitVerbatim) {
192
+ // Ink mode: ONE chunk, newlines intact, markers stripped, no
193
+ // synthetic Enter. Downstream (Ink useInput → text-input paste
194
+ // branch) buffers the complete lines and keeps the fragment —
195
+ // nothing submits until the user presses Enter. Crucially a
196
+ // `\r` that arrived as its own stdin chunk INSIDE the span was
197
+ // accumulated here instead of reaching Ink's parseKeypress,
198
+ // which would have flagged it key.return and submitted.
199
+ if (this.pasteBuf.length > 0)
200
+ this.push(this.pasteBuf);
201
+ }
202
+ else {
203
+ this.push(this.collapseNewlines(this.pasteBuf));
204
+ // Append a newline so readline commits the pasted line as a
205
+ // single input — otherwise the user would still have to press
206
+ // Enter after the paste.
207
+ this.push(Buffer.from('\n'));
208
+ }
200
209
  this.pasteBuf = Buffer.alloc(0);
201
210
  this.inPaste = false;
202
211
  i = endIdx + END_MARKER.length;
@@ -0,0 +1,42 @@
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: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
15
+ { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
16
+ { name: '/exit', description: 'Exit CSPeach' },
17
+ { name: '/quit', description: 'Exit CSPeach (alias)' },
18
+ { name: '/help', description: 'Show all skills and commands' },
19
+ { name: '/skills', description: 'Show all skills (alias)' },
20
+ { name: '/mode', description: 'Cycle or set the session write mode (advisory / gated / auto) — config unchanged' },
21
+ { name: '/new', description: 'Reset routing — next prompt is classified afresh' },
22
+ { name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
23
+ { name: '/reset', description: 'Reset routing (alias)' },
24
+ { name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
25
+ { name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
26
+ { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
27
+ { name: '/transport', description: 'View or set the active transport for this session' },
28
+ { name: '/safety-mode', description: 'Toggle structured Forge Rule confirmation cards (on / off / status)' },
29
+ ];
30
+ /**
31
+ * Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
32
+ * `/files` is a real REPL builtin (repl.tsx handles it in both paths) but
33
+ * is deliberately absent from the picker catalogue, so it's added here.
34
+ */
35
+ const BUILTIN_HEADS = new Set([
36
+ ...BUILTIN_ENTRIES.map((e) => e.name),
37
+ '/files',
38
+ ]);
39
+ /** True when `head` (e.g. '/help', as typed, any case) is a builtin command. */
40
+ export function isBuiltinCommandHead(head) {
41
+ return BUILTIN_HEADS.has(head.toLowerCase());
42
+ }
@@ -14,16 +14,26 @@
14
14
  *
15
15
  * Module-scope singleton per CLI process. Tests must call clearCurrentTransport()
16
16
  * in beforeEach to avoid leakage across Vitest test files.
17
+ *
18
+ * Task 9 (ux-wave1) — this setter is ALSO the single statusline wire for the
19
+ * `tr <TRKORR>` segment: every path that changes the session transport
20
+ * (sap_transport create success, owning-transport adoption in the create
21
+ * guard, /transport slash command) funnels through set/clear here, so the
22
+ * globalStore push lives HERE rather than scattered across tool handlers.
23
+ * Store writes are harmless in classic mode (nothing subscribes there).
17
24
  */
25
+ import { globalStore } from '../ui/sap-state-store.js';
18
26
  let currentTransport = null;
19
27
  export function setCurrentTransport(trkorr) {
20
28
  currentTransport = trkorr;
29
+ globalStore.update({ transport: trkorr });
21
30
  }
22
31
  export function getCurrentTransport() {
23
32
  return currentTransport;
24
33
  }
25
34
  export function clearCurrentTransport() {
26
35
  currentTransport = null;
36
+ globalStore.update({ transport: null });
27
37
  }
28
38
  /**
29
39
  * Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
@@ -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
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Persistent REPL history — the ONE writer shared by every append site:
3
+ * - classic readline path (repl.tsx)
4
+ * - Ink handleSubmit (repl.tsx)
5
+ * - Ink Footer (ui/footer.tsx)
6
+ *
7
+ * File shape: `~/.cspeach/history`, one entry per line, chronological
8
+ * (oldest at top, newest at bottom), appended on every submit; capped at
9
+ * READ time (HISTORY_SIZE in repl.tsx), not write time.
10
+ *
11
+ * Task 8 (ux-wave1) — consecutive-duplicate dedup lives HERE so all
12
+ * append sites share a single guard (CC parity: bash
13
+ * HISTCONTROL=ignoredups). `x, y, x` stores three entries — only
14
+ * back-to-back repeats collapse, never across gaps. Comparison is on the
15
+ * flattened + trimmed entry, against the file tail — so it also holds
16
+ * across session restarts, AND it collapses the Ink path's historical
17
+ * double append (Footer wrote the entry, then repl.tsx's handleSubmit
18
+ * wrote the same submission again — unflattened).
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ // Resolved lazily (not at module load) so it honours the HOME/USERPROFILE
24
+ // active at call time — lets tests point it at a temp dir.
25
+ export function historyFile() {
26
+ return path.join(os.homedir(), '.cspeach', 'history');
27
+ }
28
+ /** All persisted entries, oldest → newest, trimmed, blank lines dropped. */
29
+ export function readHistoryEntries() {
30
+ try {
31
+ return fs
32
+ .readFileSync(historyFile(), 'utf-8')
33
+ .split('\n')
34
+ .map((l) => l.trim())
35
+ .filter((l) => l.length > 0);
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ /**
42
+ * Flatten a submission into a single history-file entry: `\r\n` becomes
43
+ * ONE space, then every remaining C0 control + DEL becomes a space.
44
+ *
45
+ * Wave1 fast-follow (tab-flatten): newlines were already flattened, but
46
+ * tabs (e.g. from a pasted tab-indented ABAP snippet) survived into the
47
+ * history file. A tab-bearing entry later rendered in the Ctrl+R fuzzy
48
+ * list dodges Ink's truncate-end — widestLine/string-width measure `\t`
49
+ * as 0 cells while the terminal renders up to 8 — re-opening the exact
50
+ * wrapped-row erase-accounting leak Bug 1 fixed (see text-input.tsx
51
+ * maxDisplayWidth doc). Flattening at WRITE time keeps new entries clean;
52
+ * the fuzzy-row render flattens too, covering pre-fix history files.
53
+ */
54
+ export function flattenHistoryEntry(line) {
55
+ // eslint-disable-next-line no-control-regex
56
+ return line.replace(/\r\n/g, ' ').replace(/[\x00-\x1f\x7f]/g, ' ').trim();
57
+ }
58
+ /**
59
+ * Append one entry to the history file.
60
+ * - Submissions are flattened (newlines, tabs, and all other C0
61
+ * controls → ' ') — the file is one-entry-per-line (Task 7 contract)
62
+ * and entries must never carry width-invisible controls (tab-flatten
63
+ * fast-follow; see flattenHistoryEntry).
64
+ * - Skips when the flattened + trimmed entry equals the file's LAST
65
+ * entry (consecutive dedup — trimmed comparison).
66
+ * - Never throws: history persistence failure must not crash the REPL.
67
+ *
68
+ * @returns true when the entry was written, false when skipped.
69
+ */
70
+ export function appendHistoryEntry(line) {
71
+ const entry = flattenHistoryEntry(line);
72
+ if (!entry)
73
+ return false;
74
+ try {
75
+ const entries = readHistoryEntries();
76
+ if (entries.length > 0 && entries[entries.length - 1] === entry)
77
+ return false;
78
+ const file = historyFile();
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ fs.appendFileSync(file, entry + '\n', 'utf-8');
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * InkStdinPasteGuard — bracketed-paste interception for the INK path
3
+ * (wave1 live-fix, Bug 2: multiline paste submitted line-by-line on real
4
+ * Windows Terminal).
5
+ *
6
+ * What the live test proved (and Task 7's assumption it disproved):
7
+ * Windows Terminal does NOT deliver a paste as one big chunk with
8
+ * embedded newlines — pasted newlines arrive as `\r`, and a `\r` can
9
+ * land as its OWN stdin chunk. Ink's parseKeypress flags a chunk that is
10
+ * exactly '\r' as key.return (ink/build/parse-keypress.js:145-148), so
11
+ * every pasted line submitted immediately — the garbled multi-dispatch
12
+ * the user saw.
13
+ *
14
+ * Why the interception must happen BEFORE Ink parses:
15
+ * - With DEC 2004 enabled the paste arrives as `\x1b[200~ … \x1b[201~`,
16
+ * but Ink's parser mangles the markers: parse-keypress' fnKeyRe
17
+ * matches `\x1b[200~` with code '[200~' → name undefined, and
18
+ * use-input.js:73-75 strips the leading ESC from the sequence, so the
19
+ * marker would land in the input as literal garbage text ("[200~…").
20
+ * There is no reliable way to reassemble a marker split across chunks
21
+ * from inside useInput.
22
+ * - The reliable seam is the stdin stream itself: Ink accepts a custom
23
+ * `stdin` in its render options and touches exactly this surface on
24
+ * it (ink/build/components/App.js:104-139): `isTTY`, `setEncoding`,
25
+ * `ref`/`unref`, `setRawMode`, `addListener('readable')` + `read()`.
26
+ * A Transform (Duplex) provides the readable side natively; the four
27
+ * TTY-ish members are shimmed here, forwarding to process.stdin.
28
+ *
29
+ * Wiring (repl.tsx Ink branch): enableBracketedPaste() →
30
+ * `process.stdin.pipe(guard)` → `render(…, { stdin: guard })`. The base
31
+ * class (BracketedPasteDecoder with emitVerbatim) strips the markers,
32
+ * accumulates the whole span — including any `\r`-only chunks inside it —
33
+ * and re-emits the paste as ONE chunk with newlines preserved. Ink then
34
+ * hands that single chunk unsplit to useInput, where text-input's Task-7
35
+ * paste branch buffers complete lines via onPasteLines and keeps the
36
+ * tail fragment. NOTHING can submit mid-paste because no `\r`-only chunk
37
+ * ever reaches parseKeypress between the markers.
38
+ *
39
+ * Terminals without bracketed-paste support simply never send markers:
40
+ * the guard is pass-through and behaviour is exactly the pre-fix Task 7
41
+ * path (no regression, same as the classic-mode caveat).
42
+ */
43
+ import { BracketedPasteDecoder } from './bracketed-paste.js';
44
+ export class InkStdinPasteGuard extends BracketedPasteDecoder {
45
+ /**
46
+ * Mirror the REAL stdin's TTY-ness (the base class hardcodes true for
47
+ * readline's sake). Ink gates isRawModeSupported on this — lying about
48
+ * a non-TTY stdin would make Ink call setRawMode on a pipe.
49
+ */
50
+ // eslint-disable-next-line @typescript-eslint/naming-convention
51
+ isTTY = process.stdin.isTTY ?? false;
52
+ constructor() {
53
+ super({ emitVerbatim: true });
54
+ }
55
+ /** Ink calls stdin.ref()/unref() around raw-mode enable/disable. */
56
+ ref() {
57
+ process.stdin.ref?.();
58
+ return this;
59
+ }
60
+ unref() {
61
+ process.stdin.unref?.();
62
+ return this;
63
+ }
64
+ }
@@ -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,16 @@
1
+ /** Ordered from most to least restricted. */
2
+ export const WRITE_MODE_ORDER = ['advisory-only', 'approval-gated', 'auto'];
3
+ const ROLE_CEILING = {
4
+ dev: 'auto',
5
+ qas: 'approval-gated',
6
+ prd: 'advisory-only',
7
+ };
8
+ /** The most permissive write mode a system role allows. */
9
+ export function modeCeiling(role) {
10
+ return role === undefined ? 'auto' : ROLE_CEILING[role];
11
+ }
12
+ /** Clamp a mode to the role's ceiling. */
13
+ export function clampToCeiling(mode, role) {
14
+ const ceiling = modeCeiling(role);
15
+ return WRITE_MODE_ORDER.indexOf(mode) <= WRITE_MODE_ORDER.indexOf(ceiling) ? mode : ceiling;
16
+ }
@@ -0,0 +1,104 @@
1
+ import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
2
+ /**
3
+ * Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
4
+ *
5
+ * The config's `write_mode` is the durable setting; this module layers a
6
+ * SESSION override on top (Shift+Tab-style cycling, lost on exit) and clamps
7
+ * the result to the active system role's ceiling (mode-ceiling.ts):
8
+ *
9
+ * effective = clamp(sessionOverride ?? configMode, roleCeiling)
10
+ *
11
+ * Cycling walks WRITE_MODE_ORDER restricted to the modes the role allows:
12
+ * dev / unset → advisory → gated → auto → advisory …
13
+ * qas → advisory → gated → advisory … (auto not in the cycle — a
14
+ * gated→advisory step is a
15
+ * WRAP, not a cap)
16
+ * prd → pinned at advisory-only; a cycle attempt reports
17
+ * `capped: true` so the caller can render the refusal line.
18
+ *
19
+ * Pure module — no I/O, no config access; callers pass config mode + role in.
20
+ * Module-level state is test-resettable via `setSessionWriteMode(null)`.
21
+ */
22
+ let sessionWriteMode = null;
23
+ /** Session override; null = follow config. Module-level, test-resettable. */
24
+ export function setSessionWriteMode(mode) {
25
+ sessionWriteMode = mode;
26
+ }
27
+ export function getSessionWriteMode() {
28
+ return sessionWriteMode;
29
+ }
30
+ /** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
31
+ export function getEffectiveWriteMode(cfgMode, role) {
32
+ return clampToCeiling(sessionWriteMode ?? cfgMode, role);
33
+ }
34
+ /**
35
+ * Cycle to the next mode the role allows (wraps). Sets the session override
36
+ * so the new mode takes effect immediately. Returns the new mode AND whether
37
+ * the requested step was capped (for the refusal line).
38
+ */
39
+ export function cycleWriteMode(cfgMode, role) {
40
+ const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
41
+ // Modes at or below the ceiling, in cycling order (most→least restricted).
42
+ const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
43
+ if (allowed.length === 1) {
44
+ // prd: pinned — there is nowhere to step. Report capped for the refusal line.
45
+ setSessionWriteMode(allowed[0]);
46
+ return { mode: allowed[0], capped: true };
47
+ }
48
+ const current = getEffectiveWriteMode(cfgMode, role);
49
+ const next = allowed[(allowed.indexOf(current) + 1) % allowed.length];
50
+ setSessionWriteMode(next);
51
+ return { mode: next, capped: false };
52
+ }
53
+ /** Short /mode arguments → WriteMode. */
54
+ const MODE_ARGS = {
55
+ advisory: 'advisory-only',
56
+ gated: 'approval-gated',
57
+ auto: 'auto',
58
+ };
59
+ function announceLine(prev, next) {
60
+ return `mode: ${prev} → ${next} (session only — config unchanged)`;
61
+ }
62
+ function refusalLine(clamped, role, alias) {
63
+ return `mode capped at ${clamped} — sap.${alias ?? '?'}.role = ${role} (policy)`;
64
+ }
65
+ /**
66
+ * /mode command core (UX Wave 1, Task 3). Pure apart from the session
67
+ * override it shares with cycleWriteMode — the REPL builtin and the
68
+ * Shift+Tab / Alt+M keybindings are thin shells over this.
69
+ *
70
+ * - bare / empty arg → cycle (wraps within the role's allowed modes)
71
+ * - 'advisory'|'gated'|'auto' → jump directly (still clamped to the ceiling;
72
+ * a jump above it returns the refusal line and
73
+ * the clamped mode)
74
+ * - anything else → usage line, mode untouched
75
+ *
76
+ * Returns the line to print (caller dims it) and the resulting effective
77
+ * mode (caller pushes it into the SapStateStore so the statusline is live).
78
+ */
79
+ export function runModeCommand(arg, cfgMode, role, alias) {
80
+ const prev = getEffectiveWriteMode(cfgMode, role);
81
+ const normalized = arg?.trim().toLowerCase() ?? '';
82
+ if (normalized === '') {
83
+ const { mode, capped } = cycleWriteMode(cfgMode, role);
84
+ // capped is only ever true with a prd role, so `role!` is safe here.
85
+ if (capped)
86
+ return { line: refusalLine(mode, role, alias), mode };
87
+ return { line: announceLine(prev, mode), mode };
88
+ }
89
+ const target = MODE_ARGS[normalized];
90
+ if (target === undefined) {
91
+ return {
92
+ line: `usage: /mode [advisory|gated|auto] — bare /mode cycles (current: ${prev})`,
93
+ mode: prev,
94
+ };
95
+ }
96
+ const clamped = clampToCeiling(target, role);
97
+ setSessionWriteMode(clamped);
98
+ if (clamped !== target) {
99
+ // Jump above the ceiling — clamp landed us lower than requested. `role!`
100
+ // is safe: without a role the ceiling is 'auto' and nothing clamps.
101
+ return { line: refusalLine(clamped, role, alias), mode: clamped };
102
+ }
103
+ return { line: announceLine(prev, clamped), mode: clamped };
104
+ }