@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
@@ -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
+ }
@@ -9,11 +9,16 @@
9
9
  * Ink mode → chunkEmitter.emit('chunk', formatted) — Body's
10
10
  * <Static> commits the line to scrollback
11
11
  *
12
- * The sapStateStore push that used to drive a Header / Sidebar is
13
- * gone (no consumer). All status visibility now lives in the printed
14
- * status line that accompanies each turn — matches CC's pattern.
12
+ * The sapStateStore push that used to drive a Header / Sidebar was
13
+ * removed when the Header died (no consumer). UX Wave 1 Task 9 partially
14
+ * revives it: the Task 4 <StatusLine /> reads dollarSpendToday from the
15
+ * store, and this helper is the one chokepoint that already computes the
16
+ * session cost total after EVERY turn (both classic and Ink paths call
17
+ * it), so the spend wire lives here. The printed status line remains the
18
+ * primary per-turn status surface.
15
19
  */
16
20
  import { computeCost } from '../cost/pricing.js';
21
+ import { globalStore } from '../ui/sap-state-store.js';
17
22
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
18
23
  import { shouldUseInk } from '../renderer/tty.js';
19
24
  export function snapshotTurnStart(session) {
@@ -26,7 +31,7 @@ export function snapshotTurnStart(session) {
26
31
  };
27
32
  }
28
33
  export function pushPostTurnStatus(params) {
29
- const { session, selectedAlias, snapshot, chunkEmitter } = params;
34
+ const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
30
35
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
31
36
  const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
32
37
  const lifetimeTokensBefore = snapshot.input + snapshot.output;
@@ -37,13 +42,21 @@ export function pushPostTurnStatus(params) {
37
42
  cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,
38
43
  cacheCreate: (session.usage.cache_creation_input_tokens ?? 0) - snapshot.cacheCreate,
39
44
  };
40
- const turnCost = computeCost(session.model, turnCostTokens);
45
+ const turnCost = computeCost(turnModel ?? session.model, turnCostTokens);
41
46
  const sessionCost = computeCost(session.model, {
42
47
  input: session.usage.input_tokens,
43
48
  output: session.usage.output_tokens,
44
49
  cacheRead: session.usage.cache_read_input_tokens,
45
50
  cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
46
51
  });
52
+ // Task 9 (ux-wave1) — statusline spend wire. Honest semantics note: the
53
+ // store field is named `dollarSpendToday` (legacy sidebar naming, kept to
54
+ // avoid rename churn) but the value written is SESSION-scoped — the same
55
+ // sessionCost total shown in the printed status footer, computed from
56
+ // lifetime session.usage at session.model rates (mixed-model turns are an
57
+ // approximation, same caveat as the footer's session line). There is no
58
+ // day-spanning ledger; the statusline shows session spend.
59
+ globalStore.update({ dollarSpendToday: sessionCost });
47
60
  const statusPayload = {
48
61
  sessionId: session.id,
49
62
  sapAlias: selectedAlias,
@@ -60,7 +73,14 @@ export function pushPostTurnStatus(params) {
60
73
  // Push through chunkEmitter so the line lands in Body's <Static>
61
74
  // scrollback alongside the conversation, instead of corrupting
62
75
  // the live Ink frame.
63
- chunkEmitter.emit('chunk', formatStatusFooter(statusPayload));
76
+ //
77
+ // Wave1 fast-follow — variant:'turn' slims the Ink receipt to
78
+ // turn-only facts (skill │ turns │ tok │ $ │ duration): the
79
+ // persistent <StatusLine /> already shows the alias and the session
80
+ // $ total, so the full receipt double-reported both after every
81
+ // turn. Classic mode (and the no-emitter fallback) keeps the full
82
+ // receipt — there is no statusline there.
83
+ chunkEmitter.emit('chunk', formatStatusFooter({ ...statusPayload, variant: 'turn' }));
64
84
  }
65
85
  else {
66
86
  printStatusFooter(statusPayload);
@@ -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
  }
@@ -5,13 +5,21 @@
5
5
  * surface, every rule-triggering operation gets a card showing which rule
6
6
  * applies, what's about to happen, and what rollback is available.
7
7
  *
8
- * Pure orchestration over `@inquirer/prompts.confirm` — no I/O of its own
9
- * beyond the prompt invocation.
8
+ * Three presentation surfaces, decided per call (headless first):
9
+ * - headless → fail-fast decline, no prompt (B5)
10
+ * - Ink → askQuestionEmitter choice modal (UX-5 review I1) —
11
+ * inquirer must NEVER run under Ink: it fights the
12
+ * React frame for stdin and paints over it
13
+ * - classic → inquirer select via withInquirer (unchanged)
10
14
  */
11
15
  import chalk from 'chalk';
12
16
  import { select } from '@inquirer/prompts';
13
17
  import { withInquirer } from './inquirer-guard.js';
14
18
  import { inquirerTheme } from './inquirer-theme.js';
19
+ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
20
+ import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
21
+ import { clearActiveSpinner } from '../renderer/tool-widget.js';
22
+ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
15
23
  const RULE_LABEL = {
16
24
  RULE_7_SNAPSHOT_BEFORE_WRITE: 'RULE 7 — snapshot before write',
17
25
  RULE_7A_USE_UPDATE_METHOD: 'RULE 7a — use sap_update_method (not sap_set_source)',
@@ -21,7 +29,100 @@ const RULE_LABEL = {
21
29
  RULE_10_VERIFY_AFTER_WRITE: 'RULE 10 — verify after write',
22
30
  };
23
31
  export async function presentSafetyConfirmation(action) {
32
+ // B5 (2026-06-11) — headless fail-fast. This card is a stdin modal (the
33
+ // Rule 8 batch gate reaches it on the 2nd+ mutating call per turn even in
34
+ // one-shot, e.g. two local file writes); a headless run would hang on it
35
+ // forever. Headless runs NEVER auto-confirm safety gates — decline with a
36
+ // clear reason so the model can wrap up gracefully.
37
+ if (isHeadless()) {
38
+ console.error(chalk.red(`headless: ${RULE_LABEL[action.rule]} requires interactive confirmation — ` +
39
+ 'declining (headless runs never auto-confirm safety gates; run interactively).'));
40
+ return {
41
+ confirmed: false,
42
+ reason: 'headless run cannot confirm safety gates — run interactively',
43
+ headless: true,
44
+ };
45
+ }
24
46
  const card = renderCard(action);
47
+ // UX-5 review I1 (2026-06-12) — Ink-native branch. /safety-mode on made
48
+ // this card reachable under Ink, where the raw inquirer select() below
49
+ // toggled raw mode out from under the React frame and painted over it.
50
+ // Mirror of renderPlanGate (approvals/render.ts): route through the
51
+ // askQuestionEmitter choice modal — one input system, no stdin handoff.
52
+ // The modal renders `context` itself in dimColor, so strip the chalk ANSI
53
+ // codes (they'd show as raw escape text). Same choice semantics as the
54
+ // classic path below: Apply/Cancel, plus the Rule 9 irreversibility ack.
55
+ // Esc / no listener resolve as cancelled → decline (safe default).
56
+ if (shouldUseInk()) {
57
+ // CRITICAL race fix (2026-06-13) — this gate fires from INSIDE
58
+ // dispatchTool, on the 2nd+ mutating op of a turn. For local-FS ops
59
+ // (file_write / shell_exec, the fiori-build path) the agent loop
60
+ // classifies the tool as NON-interactive: it starts the in-place tool
61
+ // spinner, a per-second labelled heartbeat, AND keeps the turn-status
62
+ // row ticking — none of which it pauses (only ask_question /
63
+ // request_approval get paused at the loop level). So when this modal
64
+ // opens, three render sources are still repainting the Ink frame
65
+ // underneath it. That produced the observed live bug: the RULE 8 card
66
+ // rendered DOUBLED, Enter was not consumed by the modal, the keystroke
67
+ // LEAKED to the outer prompt (history-replay of the slash command), and
68
+ // the turn wedged for ~10 minutes while the tool spinner kept ticking.
69
+ //
70
+ // The gate must claim exclusive input focus itself (it can't rely on the
71
+ // loop, which doesn't know a non-interactive tool is about to block on
72
+ // the user). Mirror what the loop does for interactive tools:
73
+ // - clearActiveSpinner(): stop the live in-place tool spinner so only
74
+ // ONE surface (this modal) renders.
75
+ // - turnStatusEmitter.pause(): freeze the ticking status row so it
76
+ // stops repainting the dynamic frame and overdrawing the modal.
77
+ // resume() in the finally restores normal rendering the moment the gate
78
+ // resolves. (The per-second heartbeat is loop-local and is stopped on
79
+ // the loop side by ordering the gate ahead of heartbeat start — see
80
+ // tool-dispatch.ts / loop.ts.)
81
+ clearActiveSpinner();
82
+ const statusWasActive = turnStatusEmitter.snapshot().active;
83
+ if (statusWasActive)
84
+ turnStatusEmitter.pause();
85
+ try {
86
+ const first = await askQuestionEmitter.request({
87
+ id: 'safety-confirm',
88
+ question: `⚠ ${RULE_LABEL[action.rule]}`,
89
+ context: stripAnsi(card).trim(),
90
+ kind: 'choice',
91
+ choices: [
92
+ { value: 'apply', label: 'Apply' },
93
+ { value: 'cancel', label: 'Cancel' },
94
+ ],
95
+ });
96
+ if (first.cancelled || first.answer !== 'apply') {
97
+ return { confirmed: false, reason: 'user declined first prompt' };
98
+ }
99
+ // RULE 9 transport-release is irreversible — require a second explicit ack.
100
+ if (action.rule === 'RULE_9_TRANSPORT_RELEASE') {
101
+ const second = await askQuestionEmitter.request({
102
+ id: 'safety-confirm-irreversible',
103
+ question: `IRREVERSIBLE — releasing ${action.transport} cannot be undone from this side. ` +
104
+ `Are you absolutely sure?`,
105
+ kind: 'choice',
106
+ // Cancel first → the modal cursor starts there (mirrors the classic
107
+ // path's `default: 'cancel'`).
108
+ choices: [
109
+ { value: 'cancel', label: 'Cancel' },
110
+ { value: 'release', label: 'Release transport' },
111
+ ],
112
+ });
113
+ if (second.cancelled || second.answer !== 'release') {
114
+ return { confirmed: false, reason: 'user declined irreversibility ack' };
115
+ }
116
+ }
117
+ return { confirmed: true };
118
+ }
119
+ finally {
120
+ // Restore normal rendering. resume() is a no-op if the turn already
121
+ // ended (e.g. the user Ctrl+C'd mid-modal) or was never paused.
122
+ if (statusWasActive)
123
+ turnStatusEmitter.resume();
124
+ }
125
+ }
25
126
  // 2026-05-01: switched from confirm() to select() to fix the "press Enter
26
127
  // first, then 'y'" race observed in dispatched-tool rule-8 prompts. confirm
27
128
  // has a brief window after rendering its multi-line message before raw
@@ -60,6 +161,14 @@ export async function presentSafetyConfirmation(action) {
60
161
  }
61
162
  return { confirmed: true };
62
163
  }
164
+ /**
165
+ * Strip chalk ANSI escape codes. The Ink ask-question modal renders the
166
+ * card via its own <Text dimColor> — embedded escapes would display as
167
+ * literal `[33m` noise there. (Same approach as renderPlanGate.)
168
+ */
169
+ function stripAnsi(s) {
170
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
171
+ }
63
172
  /**
64
173
  * Minimal indented summary block — replaces the chunky ASCII-art box.
65
174
  * Renders as:
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Module-scope state for the safety-confirm-as-mode toggle. The REPL turns
3
- * this on via `--safety-confirm` flag at start, or `/safety-mode on|off` at
4
- * runtime. Tool dispatchers that hit a Forge Rule (snapshot, write, batch,
2
+ * Module-scope state for the safety-confirm-as-mode toggle. Off by default;
3
+ * the user turns it on with the `/safety-mode on` slash command at runtime
4
+ * (M3, 2026-06-12: there is no startup flag — an earlier version of this
5
+ * header claimed a `--safety-confirm` flag that was never implemented).
6
+ * Tool dispatchers that hit a Forge Rule (snapshot, write, batch,
5
7
  * transport release, activation) read isSafetyConfirmEnabled() and choose
6
8
  * presentSafetyConfirmation() vs the legacy ad-hoc inquirer prompt.
7
9
  *
@@ -34,3 +36,17 @@ export function handleSafetyModeCommand(args) {
34
36
  }
35
37
  return `Safety-confirm mode is currently ${enabled ? 'ON' : 'OFF'}. Use \`/safety-mode on\` or \`/safety-mode off\`.`;
36
38
  }
39
+ /**
40
+ * REPL slash-dispatch entry. Returns the status string when `trimmed` is a
41
+ * `/safety-mode` command (bare or with args), or null when the line is not
42
+ * a /safety-mode command at all. Both REPL paths (Ink handleSubmit and the
43
+ * classic readline loop) route through this single matcher so the command
44
+ * behaves identically everywhere — mirrors how /transport dispatches via
45
+ * handleTransportCommand.
46
+ */
47
+ export function matchSafetyModeCommand(trimmed) {
48
+ if (trimmed !== '/safety-mode' && !trimmed.startsWith('/safety-mode '))
49
+ return null;
50
+ const args = trimmed === '/safety-mode' ? '' : trimmed.slice('/safety-mode '.length);
51
+ return handleSafetyModeCommand(args);
52
+ }
@@ -22,12 +22,17 @@ const BUILTIN_COMMANDS = [
22
22
  '/cancel',
23
23
  '/compact',
24
24
  '/cost',
25
+ '/context',
26
+ '/export',
25
27
  '/exit',
26
28
  '/quit',
27
29
  '/help',
28
30
  '/skills',
31
+ '/mode',
29
32
  '/new',
33
+ '/recap',
30
34
  '/reset',
35
+ '/rewind',
31
36
  '/ui',
32
37
  '/reroute',
33
38
  '/transport',
@@ -21,21 +21,10 @@
21
21
  import { search } from '@inquirer/prompts';
22
22
  import { withInquirer } from './inquirer-guard.js';
23
23
  import { inquirerTheme } from './inquirer-theme.js';
24
- import { SKILL_CATALOG } from '../skill-catalog.js';
25
- const BUILTIN_ENTRIES = [
26
- { name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
27
- { name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
28
- { name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
29
- { name: '/exit', description: 'Exit CSPeach' },
30
- { name: '/quit', description: 'Exit CSPeach (alias)' },
31
- { name: '/help', description: 'Show all skills and commands' },
32
- { name: '/skills', description: 'Show all skills (alias)' },
33
- { name: '/new', description: 'Reset routing — next prompt is classified afresh' },
34
- { name: '/reset', description: 'Reset routing (alias)' },
35
- { name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
36
- { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
37
- { name: '/transport', description: 'View or set the active transport for this session' },
38
- ];
24
+ import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
25
+ // 2026-06-11 — builtin list extracted to builtin-commands.ts (pure data)
26
+ // so the router layer can check builtin heads without importing inquirer.
27
+ import { BUILTIN_ENTRIES } from './builtin-commands.js';
39
28
  function buildCatalogue() {
40
29
  const skillEntries = SKILL_CATALOG.map((s) => ({
41
30
  name: `/${s.name}`,
@@ -133,5 +122,11 @@ export function isExactSlashMatch(trimmed) {
133
122
  if (!trimmed.startsWith('/'))
134
123
  return false;
135
124
  const head = trimmed.split(/\s+/, 1)[0].toLowerCase();
125
+ // Retired-but-redirected names (SKILL_ALIASES) count as exact so the
126
+ // line passes through to parseShortcut, which resolves the redirect and
127
+ // emits the "renamed" notice — instead of dumping the user into the
128
+ // picker. Aliases are deliberately NOT in the picker/completer lists.
129
+ if (head.startsWith('/') && head.slice(1) in SKILL_ALIASES)
130
+ return true;
136
131
  return ALL_ENTRIES.some((e) => e.name === head);
137
132
  }