@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,64 @@
1
+ /**
2
+ * Notice rendering — the 'warn' / 'info' side-channel (battery B2 fix).
3
+ *
4
+ * Tool handlers emit user-facing notices on the SAME EventEmitter that
5
+ * carries 'chunk' stream data:
6
+ *
7
+ * ctx.chunkEmitter?.emit('warn', msg) — user-actionable notices
8
+ * (e.g. "ignoring transport=X,
9
+ * object locked in Y")
10
+ * ctx.chunkEmitter?.emit('info', msg) — dim, non-fatal diagnostics
11
+ * (e.g. "snapshot ✓", lookup
12
+ * fallback traces)
13
+ *
14
+ * Before this module existed those events were NO-OPS in the live REPL:
15
+ * the Ink Body only consumed 'chunk', and the classic/one-shot paths never
16
+ * put a chunkEmitter on the ToolContext at all. resolveWriteTransport's
17
+ * carefully-worded Rule 9 notices (D15/D20) were invisible.
18
+ *
19
+ * Two consumers, one formatter:
20
+ * - Ink: ui/body.tsx subscribes 'warn'/'info' and pushes
21
+ * formatNoticeLine(...) through the SAME chunk batcher, so
22
+ * ordering relative to surrounding chunks is preserved.
23
+ * - classic / one-shot: createClassicOutputEmitter() returns an emitter
24
+ * whose listeners write straight to stdout. repl.tsx
25
+ * (classic branch) and one-shot.ts put it on ctx.chunkEmitter.
26
+ *
27
+ * Deliberately NOT wired for subagents (tools/subagent/agent_run.ts):
28
+ * their ctx.chunkEmitter stays undefined so child-turn notices remain
29
+ * muted — exactly the pre-existing contract pinned in agent_run.test.ts.
30
+ */
31
+ import chalk from 'chalk';
32
+ import { EventEmitter } from 'node:events';
33
+ /**
34
+ * Format one notice as a single terminal line (trailing newline included).
35
+ * Multi-line messages are collapsed to one line — notices are glanceable
36
+ * margin notes, not documents; anything long belongs in the tool result.
37
+ *
38
+ * warn → yellow " ⚠ message"
39
+ * info → dim " · message"
40
+ */
41
+ export function formatNoticeLine(level, message) {
42
+ const oneLine = message.replace(/\s*\r?\n\s*/g, ' ').trim();
43
+ if (level === 'warn')
44
+ return chalk.yellow(` ⚠ ${oneLine}`) + '\n';
45
+ return chalk.dim(` · ${oneLine}`) + '\n';
46
+ }
47
+ /**
48
+ * Build the output emitter for the NON-Ink paths (classic REPL, one-shot).
49
+ *
50
+ * Listeners:
51
+ * 'chunk' → written verbatim (already-formatted stream text — e.g. the
52
+ * verify-chain line emitted by write tools)
53
+ * 'warn' → formatNoticeLine('warn', …)
54
+ * 'info' → formatNoticeLine('info', …)
55
+ *
56
+ * The `write` sink defaults to process.stdout — injectable for tests.
57
+ */
58
+ export function createClassicOutputEmitter(write = (s) => process.stdout.write(s)) {
59
+ const emitter = new EventEmitter();
60
+ emitter.on('chunk', (s) => write(s));
61
+ emitter.on('warn', (msg) => write(formatNoticeLine('warn', msg)));
62
+ emitter.on('info', (msg) => write(formatNoticeLine('info', msg)));
63
+ return emitter;
64
+ }
@@ -92,6 +92,10 @@ const MESSAGES = {
92
92
  'Reserving a transport request…',
93
93
  'Asking SAP for a fresh TR…',
94
94
  ],
95
+ sap_transport_for_object: [
96
+ 'Checking who owns {object_name}…',
97
+ 'Looking for an owning transport…',
98
+ ],
95
99
  sap_transport_list: [
96
100
  'Listing open transports…',
97
101
  'Finding modifiable TRs…',
@@ -156,6 +160,10 @@ const MESSAGES = {
156
160
  'Publishing service binding…',
157
161
  'Asking SAP to expose the OData endpoint…',
158
162
  ],
163
+ sap_service_binding_unpublish: [
164
+ 'Unpublishing service binding…',
165
+ 'Asking SAP to remove the OData endpoint…',
166
+ ],
159
167
  };
160
168
  const FALLBACK_MESSAGES = [
161
169
  'Working on it…',
@@ -40,7 +40,7 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
40
40
  * direct stdout (which would corrupt the live Ink frame).
41
41
  */
42
42
  export function formatStatusFooter(params) {
43
- const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens } = params;
43
+ const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
44
44
  const shortSession = sessionId.slice(0, 8);
45
45
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
46
46
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
@@ -49,17 +49,27 @@ export function formatStatusFooter(params) {
49
49
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
50
50
  const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
51
51
  const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
52
- const parts = [
53
- chalk.dim(`session ${shortSession}`),
54
- chalk.dim(sapAlias),
55
- chalk.dim(skillDisplay),
56
- chalk.dim(turns),
57
- chalk.dim(tokStr),
58
- ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
59
- ...(cachedStr ? [chalk.dim(cachedStr)] : []),
60
- ...(costSessStr ? [chalk.dim(costSessStr)] : []),
61
- ...(elapsed ? [chalk.dim(elapsed)] : []),
62
- ];
52
+ // Ink turn-receipt (see the variant doc on StatusFooterParams):
53
+ // turn-only facts — everything session-scoped lives in <StatusLine />.
54
+ const parts = variant === 'turn'
55
+ ? [
56
+ chalk.dim(skillDisplay),
57
+ chalk.dim(turns),
58
+ chalk.dim(tokStr),
59
+ ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
60
+ ...(elapsed ? [chalk.dim(elapsed)] : []),
61
+ ]
62
+ : [
63
+ chalk.dim(`session ${shortSession}`),
64
+ chalk.dim(sapAlias),
65
+ chalk.dim(skillDisplay),
66
+ chalk.dim(turns),
67
+ chalk.dim(tokStr),
68
+ ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
69
+ ...(cachedStr ? [chalk.dim(cachedStr)] : []),
70
+ ...(costSessStr ? [chalk.dim(costSessStr)] : []),
71
+ ...(elapsed ? [chalk.dim(elapsed)] : []),
72
+ ];
63
73
  const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
64
74
  if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
65
75
  lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
@@ -48,24 +48,67 @@ const GENERIC_SUFFIXES = [
48
48
  { at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
49
49
  { at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check the VPN)' },
50
50
  ];
51
+ /**
52
+ * 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
53
+ *
54
+ * The audit is a PROXY model call (api.cspeach.dev over the internet), NOT a
55
+ * SAP tool call, so the generic schedule's "check the VPN" 5m copy is a WRONG
56
+ * diagnosis (the VPN is only for SAP). It is also now bounded: runPhaseAudit
57
+ * enforces a 120s hard deadline PER attempt × 3 attempts, so the whole audit
58
+ * can never exceed ~6m before it returns infra_failed — no infinite hang. This
59
+ * schedule reflects that reality honestly: past 2m the audit is necessarily in
60
+ * a retry (a single attempt hard-stops at 120s), and there is nothing to check
61
+ * — it either recovers on the next attempt or returns infra_failed. The
62
+ * per-attempt transition ("[audit] attempt N of 3 (previous timed out)") is
63
+ * printed separately by plan-chain via runPhaseAudit's onRetry hook.
64
+ */
65
+ export const AUDIT_HEARTBEAT_THRESHOLDS = [
66
+ { at: 3_000, label: '[audit] …running' },
67
+ { at: 10_000, label: '[audit] …running (10s)' },
68
+ { at: 30_000, label: '[audit] …running (30s)' },
69
+ { at: 60_000, label: '[audit] …running (60s — still running, large evidence can take a while)' },
70
+ { at: 120_000, label: '[audit] …running (2m — still running; large evidence can take a while · Ctrl+C aborts the turn)' },
71
+ { at: 240_000, label: '[audit] …running (4m — a prior attempt timed out; retrying · Ctrl+C aborts the turn)' },
72
+ { at: 300_000, label: '[audit] …running (5m — nearly at the retry ceiling; will stop and re-audit on resume if it can\'t finish)' },
73
+ ];
51
74
  export function startThinkingHeartbeat(opts) {
52
75
  const startedAt = Date.now();
53
76
  let nextIdx = 0;
54
77
  let stopped = false;
55
- const THRESHOLDS_MS = opts?.label
56
- ? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
57
- : LLM_THRESHOLDS;
78
+ // Precedence: an explicit `thresholds` schedule (audit) wins over the
79
+ // `label` generic-suffix path, which wins over the default LLM schedule.
80
+ const THRESHOLDS_MS = opts?.thresholds
81
+ ? opts.thresholds
82
+ : opts?.label
83
+ ? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
84
+ : LLM_THRESHOLDS;
85
+ /**
86
+ * Receives the styled label WITHOUT a newline. Fable review (2026-07-05,
87
+ * audit-reverify item 1): the newline used to be baked INSIDE the chalk
88
+ * argument — with color enabled (FORCE_COLOR / real terminals) chalk
89
+ * appends its closing style codes AFTER the embedded `\n`, so the sink
90
+ * branch's trailing-`\n` strip was a no-op and every tick rendered an
91
+ * extra blank line. The label is now styled alone and each branch owns
92
+ * its own termination: sink = none (the caller terminates), chunkEmitter /
93
+ * stdout = a plain `\n` appended after the styled text (byte-identical to
94
+ * before under no-color; under color the closing style code now correctly
95
+ * precedes the newline).
96
+ */
58
97
  const emitLine = (line) => {
59
- if (opts?.chunkEmitter) {
98
+ if (opts?.emit) {
99
+ // Custom sink (e.g. plan-chain's args.log) owns line termination.
100
+ opts.emit(line);
101
+ }
102
+ else if (opts?.chunkEmitter) {
60
103
  // Phase A #4 — route through Ink's chunk pipeline. The Body
61
104
  // component's reducer treats this like any other model chunk and
62
105
  // appends it as a node, so the heartbeat lives inside the React
63
106
  // render tree and survives subsequent renders.
64
- opts.chunkEmitter.emit('chunk', line);
107
+ opts.chunkEmitter.emit('chunk', line + '\n');
65
108
  }
66
109
  else {
67
110
  // Classic mode — direct stdout, plain newline-terminated line.
68
- process.stdout.write(line);
111
+ process.stdout.write(line + '\n');
69
112
  }
70
113
  };
71
114
  const tick = () => {
@@ -74,8 +117,21 @@ export function startThinkingHeartbeat(opts) {
74
117
  const elapsed = Date.now() - startedAt;
75
118
  while (nextIdx < THRESHOLDS_MS.length && THRESHOLDS_MS[nextIdx].at <= elapsed) {
76
119
  const t = THRESHOLDS_MS[nextIdx];
77
- // Indent by 2 spaces to align with tool-call rows.
78
- emitLine(chalk.dim(` ${t.label}\n`));
120
+ // audit-lean FIX #2 — a dynamic suffix (e.g. the live token count),
121
+ // evaluated NOW so a stall shows a different number from progress.
122
+ // Defensive: observability must never break the liveness tick.
123
+ let suffix = '';
124
+ if (opts?.suffix) {
125
+ try {
126
+ suffix = opts.suffix();
127
+ }
128
+ catch {
129
+ suffix = '';
130
+ }
131
+ }
132
+ // Indent by 2 spaces to align with tool-call rows. Style ONLY the
133
+ // label (+ suffix) — never a newline inside the chalk call (see emitLine).
134
+ emitLine(chalk.dim(` ${t.label}${suffix}`));
79
135
  nextIdx++;
80
136
  }
81
137
  };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * todo_set transcript block — UX Wave 2 follow-up (CC-style checklist).
3
+ *
4
+ * Owner feedback (2026-07): "it's not like we have in Claude Code — only
5
+ * shows the phase it's working on with an arrow." The turn-status strip
6
+ * (single ▶ row) and the Ctrl+T panel are both by-design; what was missing
7
+ * was Claude Code's behaviour of dropping the FULL checklist into the
8
+ * scrollback transcript on every todo_set, so the user sees the whole plan
9
+ * advance in place rather than a raw `⏺ todo_set({...})` JSON line.
10
+ *
11
+ * This module is the pure formatter for that block. todo.ts emits the
12
+ * returned string as a `chunk` on ctx.chunkEmitter after a successful set
13
+ * (routes to Ink Static scrollback AND the classic 'chunk'→stdout path);
14
+ * the raw ⏺/⎿ todo_set rows are widget-suppressed (tool-widget.ts) so the
15
+ * block is the sole visible representation — exactly mirroring ask_question.
16
+ *
17
+ * House style follows todo-panel.tsx glyphs precisely so the transcript
18
+ * block and the Ctrl+T panel read as the same list:
19
+ * ▣ completed (dim)
20
+ * ▶ in_progress (peach, bold)
21
+ * □ pending (default)
22
+ * Every row is truncate-end (Wave-1 rule — no soft-wrap), preceded by a
23
+ * slim `Tasks (done/total done)` header.
24
+ */
25
+ import chalk from 'chalk';
26
+ import { PEACH } from './banners.js';
27
+ /** Max visible width of a row's TEXT (after the 2-char glyph+space prefix).
28
+ * 72-col total matches the Ctrl+T panel width; 70 leaves room for `▶ `. */
29
+ export const TODO_BLOCK_TEXT_MAX = 70;
30
+ function truncateEnd(s, max) {
31
+ return s.length > max ? s.substring(0, max - 1) + '…' : s;
32
+ }
33
+ /**
34
+ * Render the checklist as a scrollback block (leading + trailing newline for
35
+ * separation from surrounding transcript content). Returns ANSI-coloured
36
+ * text ready to emit as a `chunk`.
37
+ */
38
+ export function formatTodoChecklistBlock(todos) {
39
+ const done = todos.filter((t) => t.status === 'completed').length;
40
+ const total = todos.length;
41
+ const header = chalk.dim(`Tasks (${done}/${total} done)`);
42
+ const rows = todos.map((t) => {
43
+ const text = truncateEnd(t.text, TODO_BLOCK_TEXT_MAX);
44
+ if (t.status === 'completed')
45
+ return chalk.dim(`▣ ${text}`);
46
+ if (t.status === 'in_progress')
47
+ return chalk.hex(PEACH).bold(`▶ ${text}`);
48
+ return `□ ${text}`;
49
+ });
50
+ return '\n' + [header, ...rows].join('\n') + '\n';
51
+ }
@@ -21,6 +21,31 @@ import { PEACH } from './banners.js';
21
21
  * - No cyan, no rainbow, no boxes.
22
22
  */
23
23
  const peach = (s) => chalk.hex(PEACH)(s);
24
+ /**
25
+ * Widget-representation de-clutter (2026-07): tools whose OWN rendering IS
26
+ * the visible representation, making the generic ⏺ dispatch line (with its
27
+ * raw JSON args) and the SUCCESS ⎿ result line pure scrollback noise. For
28
+ * these the ⏺ top line and the success ⎿ row are suppressed; ERROR ⎿ rows
29
+ * still render (the ✗ row is the only trace of a failed call). The agent
30
+ * loop also skips the tool spinner for these (it would orphan without its
31
+ * ⏺ line).
32
+ *
33
+ * Members:
34
+ * - ask_question: the Ink form / modal (or the classic console.log
35
+ * question text) is the representation. Classic mode is unaffected in
36
+ * the ways that matter — the v1 path prints its question via
37
+ * console.log and the v2 classic path prints its `form i/n · header`
38
+ * blocks, neither of which goes through these renderers.
39
+ * - todo_set: the handler emits a CC-style checklist block into the
40
+ * transcript (renderer/todo-block.ts) on every successful set — that
41
+ * block is the representation, so the raw `⏺ todo_set({"todos":[…])`
42
+ * line + ⎿ JSON result are suppressed. Owner feedback drove this: the
43
+ * transcript should show the full checklist, not a JSON tool line.
44
+ */
45
+ const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
46
+ export function isWidgetSuppressedTool(name) {
47
+ return WIDGET_SUPPRESSED_TOOLS.has(name);
48
+ }
24
49
  const ARG_SUMMARY_MAX = 64;
25
50
  // 2026-05-15 (bug 1): widened from 96 → 240 so a parsed ADT exception
26
51
  // message ("CTS_WBO_API/047: Request S4HK903388 is not a local request"
@@ -55,13 +80,17 @@ export function clearActiveSpinner() {
55
80
  }
56
81
  export function renderToolCallTop(params) {
57
82
  const { name, args, chunkEmitter } = params;
83
+ // Ask-form de-clutter: the form/modal IS the visible representation —
84
+ // no ⏺ dispatch line (with its raw JSON args) belongs in scrollback.
85
+ if (isWidgetSuppressedTool(name))
86
+ return;
58
87
  const write = (s) => {
59
88
  if (chunkEmitter)
60
89
  chunkEmitter.emit('chunk', s);
61
90
  else
62
91
  process.stdout.write(s);
63
92
  };
64
- const summary = argSummary(name, args);
93
+ const summary = summariseToolArgs(name, args);
65
94
  const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
66
95
  const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
67
96
  write('\n' + line + '\n');
@@ -136,7 +165,15 @@ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
136
165
  return { stop: stopFn };
137
166
  }
138
167
  export function renderToolCallBottom(params) {
139
- const { durationMs, isError, resultSummary, chunkEmitter } = params;
168
+ const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
169
+ // Ask-form de-clutter: paired with the top-line suppression — on SUCCESS
170
+ // the answer already came back through the form; a ⎿ row would be noise.
171
+ // ERRORS still render (review fix): an ask_form_invalid /
172
+ // ask_question_invalid round never mounts a form, so without the ✗ row
173
+ // the turn would stall silently with nothing in scrollback. D29 made the
174
+ // bottom row self-identifying (name + args), so no orphan risk.
175
+ if (name && isWidgetSuppressedTool(name) && !isError)
176
+ return;
140
177
  const write = (s) => {
141
178
  if (chunkEmitter)
142
179
  chunkEmitter.emit('chunk', s);
@@ -144,15 +181,26 @@ export function renderToolCallBottom(params) {
144
181
  process.stdout.write(s);
145
182
  };
146
183
  const icon = isError ? chalk.red('✗') : chalk.green('✓');
184
+ // Label: plain (not bold — the top line keeps visual primacy) name +
185
+ // dim arg summary, mirroring the top-line shape so orphaned rows still
186
+ // read as "which tool, on what object".
187
+ let label = '';
188
+ if (name) {
189
+ const summary = summariseToolArgs(name, args ?? {});
190
+ const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
191
+ label = ` ${name}${wrapped}`;
192
+ }
147
193
  const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
148
194
  const detail = detailRaw
149
195
  ? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
150
196
  : '';
151
197
  const timing = chalk.dim(formatDuration(durationMs));
152
198
  const sep = chalk.dim('·');
153
- let line = ` ${peach('⎿')} ${icon}`;
199
+ let line = ` ${peach('⎿')} ${icon}${label}`;
154
200
  if (detail)
155
201
  line += ` ${detail} ${sep} ${timing}`;
202
+ else if (label)
203
+ line += ` ${sep} ${timing}`;
156
204
  else
157
205
  line += ` ${timing}`;
158
206
  write(line + '\n');
@@ -162,8 +210,11 @@ export function renderToolCallBottom(params) {
162
210
  * arguments. Per-tool aware: pulls LLM-supplied summary first, then the
163
211
  * common (name, type, package) triple, then specific keys for known tools.
164
212
  * Falls back to a truncated JSON dump.
213
+ *
214
+ * Exported (D29) so the bottom result row can self-identify with the same
215
+ * summary the top line used.
165
216
  */
166
- function argSummary(toolName, args) {
217
+ export function summariseToolArgs(toolName, args) {
167
218
  if (!args || Object.keys(args).length === 0)
168
219
  return '';
169
220
  const a = args;
@@ -25,6 +25,36 @@ export function setRenderingOverride(mode) {
25
25
  export function getRenderingOverride() {
26
26
  return renderingOverride;
27
27
  }
28
+ /**
29
+ * B5 (2026-06-11) — headless answer policy signal.
30
+ *
31
+ * "Headless" means NO human can answer a prompt: stdin will never deliver
32
+ * an answer, so any inquirer/readline wait blocks forever (battery S2 Pass A
33
+ * stalled on exactly this — defect D1). Consumers (ask_question, the save
34
+ * hook, request_approval) branch to auto-answer / fail-fast policies instead
35
+ * of prompting.
36
+ *
37
+ * Decision order:
38
+ * 1. Explicit override — runOneShot() sets `true` at startup because
39
+ * one-shot is a single non-interactive turn by design (CI parity:
40
+ * the same invocation must behave identically piped or from a
41
+ * terminal). Tests set/clear it directly.
42
+ * 2. Fallback — stdin is not a TTY. Covers piped/redirected invocations
43
+ * that never went through runOneShot. stdin (not stdout) is the right
44
+ * floor: prompts BLOCK on stdin; piping only stdout still leaves a
45
+ * human able to answer.
46
+ */
47
+ let headlessOverride = null;
48
+ /** Set (or clear with null) the explicit headless override. */
49
+ export function setHeadlessOverride(v) {
50
+ headlessOverride = v;
51
+ }
52
+ /** True when no human can answer prompts — see setHeadlessOverride. */
53
+ export function isHeadless() {
54
+ if (headlessOverride !== null)
55
+ return headlessOverride;
56
+ return process.stdin.isTTY !== true;
57
+ }
28
58
  /**
29
59
  * True when the Ink UI frame should be mounted.
30
60
  *
@@ -32,8 +62,17 @@ export function getRenderingOverride() {
32
62
  * 1. If override === 'classic', never use Ink (explicit opt-out)
33
63
  * 2. If override === 'ink', use Ink when a TTY is available; otherwise
34
64
  * fall back — Ink cannot render on a pipe regardless of user preference
35
- * 3. If override === 'auto' (default), use Ink when interactive TTY +
36
- * not one-shot + CI=unset
65
+ * 3. If override === 'auto' (default), use Ink when interactive TTY on
66
+ * BOTH stdout and stdin + not one-shot + CI=unset
67
+ *
68
+ * UX-5 review C1 (2026-06-12): the auto branch must also require
69
+ * `process.stdin.isTTY` — Ink mounts raw-mode key handling on stdin, and a
70
+ * piped stdin (`"/cost`n/exit" | cspeach`) cannot enter raw mode. Worse,
71
+ * mounting Ink there bypassed the classic loop's early-line buffer
72
+ * (UX-5 #5), so every piped line was dropped. Piped-stdin invocations now
73
+ * take the classic path even when stdout is a terminal. The explicit
74
+ * `ui.rendering = 'ink'` override (branch 2) deliberately keeps its
75
+ * stdout-only floor — that's the user's explicit choice.
37
76
  *
38
77
  * v0.3+: pipeline.ts and approvals/render.ts branch on this.
39
78
  */
@@ -42,8 +81,8 @@ export function shouldUseInk() {
42
81
  return false;
43
82
  if (renderingOverride === 'ink')
44
83
  return isTTY();
45
- // auto
46
- return isTTY() && !isOneShot() && !process.env['CI'];
84
+ // auto — stdin must be a TTY too (raw-mode capable, no piped lines to drop)
85
+ return isTTY() && process.stdin.isTTY === true && !isOneShot() && !process.env['CI'];
47
86
  }
48
87
  /**
49
88
  * Stub for v0.3 Windows ConPTY / CI TTY hinting.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Verify-chain line — one compact row per completed SAP write summarizing
3
+ * the Forge-Rule pipeline the tool actually ran (tour slide-7 alignment):
4
+ *
5
+ * ① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activate pending
6
+ *
7
+ * HONESTY CONTRACT: every segment is derived from the tool's real result —
8
+ * never synthesized. States render truthfully:
9
+ * - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
10
+ * nothing to save; NEVER used for other read failures)
11
+ * - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
12
+ * the pre-write read failed for a non-404 reason; the object may exist
13
+ * but there is no recovery copy. Hard-gate tools block instead of
14
+ * emitting this; it only appears on soft (best-effort) gates.
15
+ * - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
16
+ * where the gate is soft, e.g. class includes)
17
+ * - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
18
+ * activation is blocked, so we don't show a fake pending step
19
+ * - syntax 'not_checked' → "③ syntax – (checked at activation)" —
20
+ * sap_set_class_include has no standalone syntax gate
21
+ * - ④ is always 'pending', never '✓': activation is a SEPARATE tool call
22
+ * (sap_activate); these write tools never activate.
23
+ *
24
+ * Consumed by the write tools in tools/sap-write.ts, emitted on the
25
+ * 'chunk' channel so it lands between the ⏺ dispatch line and the ⎿
26
+ * result row in both renderers.
27
+ */
28
+ import chalk from 'chalk';
29
+ const pass = (s) => `${s} ${chalk.green('✓')}`;
30
+ const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
31
+ const skip = (s, why) => chalk.dim(`${s} – (${why})`);
32
+ const ERROR_DETAIL_MAX = 60;
33
+ const SNAPSHOT_DETAIL_MAX = 40;
34
+ const truncate = (s, max) => {
35
+ const flat = s.replace(/\s+/g, ' ').trim();
36
+ return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
37
+ };
38
+ /**
39
+ * Build the chain line (no trailing newline). Returns the full line
40
+ * including the two-space indent that aligns with the ⎿ result row.
41
+ */
42
+ export function buildWriteChainLine(input) {
43
+ const parts = [];
44
+ // ① snapshot
45
+ if (input.snapshot === 'taken')
46
+ parts.push(pass('① snapshot'));
47
+ else if (input.snapshot === 'new_object')
48
+ parts.push(skip('① snapshot', 'new object'));
49
+ else if (input.snapshot === 'source_read_failed') {
50
+ // Honest: the read failed — we do NOT know the object is new, and there
51
+ // is no recovery copy. Never render this as "(new object)".
52
+ const why = input.snapshotDetail
53
+ ? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
54
+ : 'no source read';
55
+ parts.push(skip('① snapshot', why));
56
+ }
57
+ else
58
+ parts.push(skip('① snapshot', 'skipped'));
59
+ // ② written
60
+ parts.push(input.written ? pass('② written') : fail('② written'));
61
+ // ③ syntax (+ ④ activate, which only exists when activation is possible)
62
+ if (input.syntax === 'not_checked') {
63
+ parts.push(skip('③ syntax', 'checked at activation'));
64
+ parts.push(chalk.dim('④ activate pending'));
65
+ }
66
+ else if (input.syntax.ok) {
67
+ parts.push(pass('③ syntax'));
68
+ parts.push(chalk.dim('④ activate pending'));
69
+ }
70
+ else {
71
+ const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
72
+ const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
73
+ parts.push(fail('③ syntax', detail));
74
+ // No ④ — activation is blocked; rendering a pending step would lie.
75
+ }
76
+ return ' ' + parts.join(' ');
77
+ }
@@ -17,24 +17,75 @@
17
17
  * without screen takeover, Enter when they want a full search-as-you-type
18
18
  * picker. Same backing data, different UI.
19
19
  */
20
+ import { readdirSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
20
22
  import { search } from '@inquirer/prompts';
21
23
  import { withInquirer } from './inquirer-guard.js';
22
24
  import { inquirerTheme } from './inquirer-theme.js';
23
- import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
25
+ import { ensureWorkspaceSync, listProjectFilesSync, isAttachableFilename } from '../projects/workspace.js';
24
26
  const CANCEL_VALUE = '__cspeach_cancel__';
25
27
  /**
26
28
  * 2026-06-06 (Ink picker parity): workspace listing + substring filter,
27
29
  * exported so the Ink FilePalette shares the same data + seeding
28
30
  * semantics as this classic inquirer picker.
31
+ *
32
+ * Bug #4 (2026-06-15): the listing is the UNION of
33
+ * - all workspace files (.cspeach.json envelopes + .txt/.md/.docx/.pdf), AND
34
+ * - top-level .txt/.md/.docx/.pdf files in the current working directory.
35
+ * A spec file dropped next to the project (the common case) lives in the
36
+ * cwd, never in .cspeach/, so without the cwd half the picker was empty.
37
+ *
38
+ * Deduped by filename — when a name exists in both, the workspace entry
39
+ * wins. Non-recursive over the cwd; the .cspeach subfolder and any
40
+ * dotfiles/dirs are skipped. SYNCHRONOUS by contract (the Ink palette and
41
+ * pendingAtTokenPrefix call it sync). `cwd` / `workspace` are injectable
42
+ * for tests; the zero-arg call resolves both from the environment.
29
43
  */
30
- export function listWorkspaceFileChoices() {
44
+ export function listWorkspaceFileChoices(opts) {
45
+ const out = [];
46
+ const seen = new Set();
47
+ // Workspace half (takes precedence on name collisions).
48
+ try {
49
+ const ws = opts?.workspace ?? ensureWorkspaceSync();
50
+ for (const f of listProjectFilesSync(ws)) {
51
+ if (seen.has(f.filename))
52
+ continue;
53
+ seen.add(f.filename);
54
+ out.push({ filename: f.filename, mtimeMs: f.mtimeMs });
55
+ }
56
+ }
57
+ catch {
58
+ // fall through — still return whatever the cwd half yields
59
+ }
60
+ // cwd half — top-level .txt/.md/.docx/.pdf only. Never throws.
61
+ const cwd = opts?.cwd ?? process.cwd();
31
62
  try {
32
- const ws = ensureWorkspaceSync();
33
- return listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
63
+ for (const name of readdirSync(cwd)) {
64
+ if (name.startsWith('.'))
65
+ continue; // dotfiles/dirs (incl. .cspeach)
66
+ if (!isAttachableFilename(name))
67
+ continue;
68
+ if (seen.has(name))
69
+ continue; // workspace already has it
70
+ const full = join(cwd, name);
71
+ let stat;
72
+ try {
73
+ stat = statSync(full);
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ if (!stat.isFile())
79
+ continue;
80
+ seen.add(name);
81
+ out.push({ filename: name, mtimeMs: stat.mtimeMs });
82
+ }
34
83
  }
35
84
  catch {
36
- return [];
85
+ // unreadable cwd — return just the workspace list
37
86
  }
87
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
88
+ return out;
38
89
  }
39
90
  /** Substring filter; empty prefix returns everything. */
40
91
  export function filterFileChoices(prefix, all) {
@@ -126,8 +177,10 @@ export function pendingAtTokenPrefix(trimmed, knownFilenames) {
126
177
  }
127
178
  else {
128
179
  try {
129
- const ws = ensureWorkspaceSync();
130
- const files = listProjectFilesSync(ws);
180
+ // Bug #4: check the MERGED listing (workspace + cwd text files),
181
+ // not workspace-only, so an exact `@spec.txt` for a cwd file
182
+ // submits directly instead of re-opening the picker.
183
+ const files = listWorkspaceFileChoices();
131
184
  const exact = files.some((f) => f.filename === prefix);
132
185
  if (exact)
133
186
  return null;