@cspeach/cli 0.6.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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Layer 2 — skill-level checkpoints.
3
+ *
4
+ * Background. Layer 1 (commit be1c6d8) makes sure that when a turn aborts
5
+ * mid-stream, every prior assistant message + tool result + accumulated
6
+ * partial text lands on disk in `session.messages`. The model can resume
7
+ * with full conversation context.
8
+ *
9
+ * What Layer 1 does NOT cover: skill-internal state files. /abap-cca, for
10
+ * example, maintains `.abapforge/cca/projects/<id>/project.json` with an
11
+ * inventory of objects, classifications, and per-package state. The skill
12
+ * writes this file at PHASE BOUNDARIES (end of DISCOVER, end of INVENTORY,
13
+ * etc.). A crash mid-DISCOVER means the partial inventory in project.json
14
+ * is stale — the skill has to redo every probe on resume.
15
+ *
16
+ * Layer 2 fixes that. After every tool call returns, the agent loop calls
17
+ * `applyToolResultCheckpoint(...)` which dispatches to a registered handler
18
+ * for the active skill. The handler reads the prior on-disk state, applies
19
+ * one tool result, and writes back atomically. Resume picks up at the next
20
+ * probe with the partial inventory intact, no SAP work redone.
21
+ *
22
+ * v0.6 ships the infrastructure with a default JSONL handler that captures
23
+ * a per-session audit log of every tool call. Skill-specific handlers
24
+ * (e.g. for /abap-cca's project.json) can be added in follow-up commits
25
+ * without touching the loop.
26
+ */
27
+ import { promises as fs } from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import * as os from 'node:os';
30
+ const HANDLERS = new Map();
31
+ /**
32
+ * Register a skill-specific handler. Idempotent — re-registering replaces
33
+ * the prior handler. Skills should register at module-load time.
34
+ */
35
+ export function registerCheckpointHandler(handler) {
36
+ HANDLERS.set(handler.skill, handler);
37
+ }
38
+ /** Test helper — clears the registry. Not exported in production paths. */
39
+ export function _resetHandlersForTesting() {
40
+ HANDLERS.clear();
41
+ }
42
+ export function checkpointsRoot() {
43
+ return path.join(os.homedir(), '.cspeach', 'checkpoints');
44
+ }
45
+ /**
46
+ * Default handler — appends one JSONL row per tool call to
47
+ * ~/.cspeach/checkpoints/<sessionId>/tool-calls.jsonl.
48
+ *
49
+ * This runs for EVERY skill regardless of registered handlers. Provides a
50
+ * uniform audit trail without skills having to register anything. Safe for
51
+ * concurrent appends (atomic O_APPEND on POSIX, single-threaded on Node so
52
+ * mid-write interleaving doesn't happen).
53
+ */
54
+ async function appendDefaultJsonl(input) {
55
+ try {
56
+ const dir = path.join(checkpointsRoot(), input.sessionId);
57
+ await fs.mkdir(dir, { recursive: true });
58
+ const filePath = path.join(dir, 'tool-calls.jsonl');
59
+ const row = {
60
+ at: input.completedAt,
61
+ tool: input.toolName,
62
+ args: truncateArgs(input.args),
63
+ ok: !input.isError,
64
+ duration_ms: input.durationMs,
65
+ // Cap result payload at 4KB to bound JSONL growth on long turns.
66
+ result: truncateResult(input.result),
67
+ };
68
+ await fs.appendFile(filePath, JSON.stringify(row) + '\n', 'utf-8');
69
+ }
70
+ catch {
71
+ // best-effort — never throw out
72
+ }
73
+ }
74
+ function truncateArgs(args) {
75
+ try {
76
+ const json = JSON.stringify(args);
77
+ if (json.length <= 1024)
78
+ return args;
79
+ return JSON.parse(json.slice(0, 1024) + '..."}');
80
+ }
81
+ catch {
82
+ return '<unstringifiable>';
83
+ }
84
+ }
85
+ function truncateResult(result) {
86
+ if (typeof result === 'string') {
87
+ return result.length <= 4096 ? result : result.slice(0, 4096) + '...';
88
+ }
89
+ try {
90
+ const json = JSON.stringify(result);
91
+ if (json.length <= 4096)
92
+ return result;
93
+ return json.slice(0, 4096) + '...';
94
+ }
95
+ catch {
96
+ return '<unstringifiable>';
97
+ }
98
+ }
99
+ /**
100
+ * Main entry point — call from the agent loop after every tool dispatch.
101
+ * Always runs the default JSONL handler. Also dispatches to a skill-specific
102
+ * handler if one is registered. Both are best-effort; failures are swallowed.
103
+ */
104
+ export async function applyToolResultCheckpoint(input) {
105
+ // Default audit JSONL — runs for every skill.
106
+ await appendDefaultJsonl(input);
107
+ // Skill-specific handler (optional).
108
+ if (input.skill) {
109
+ const handler = HANDLERS.get(input.skill);
110
+ if (handler) {
111
+ try {
112
+ await handler.apply(input);
113
+ }
114
+ catch {
115
+ // best-effort
116
+ }
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,47 @@
1
+ import { getTool } from '../tools/index.js';
2
+ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
3
+ import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
4
+ export async function dispatchTool(name, args, ctx) {
5
+ const tool = getTool(name);
6
+ if (!tool) {
7
+ return { content: JSON.stringify({ error: `unknown_tool: ${name}` }), is_error: true };
8
+ }
9
+ // Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
10
+ // explicit confirmation. Fires regardless of /safety-mode toggle (batch
11
+ // detection is unconditional per Plan 3 §6.6).
12
+ if (tool.isMutating && shouldGateRule8()) {
13
+ const r = await presentSafetyConfirmation({
14
+ rule: 'RULE_8_BATCH_PLAN',
15
+ op: tool.name,
16
+ object: {
17
+ type: typeof args?.type === 'string' ? args.type : 'BATCH',
18
+ name: typeof args?.name === 'string' ? args.name : '(multiple)',
19
+ },
20
+ what_will_happen: renderPlanSummary({ tool: tool.name, args }),
21
+ rollback_available: true, // snapshots taken per-write
22
+ transport: typeof args?.transport === 'string' ? args.transport : '',
23
+ batch_count: getWriteOpsThisTurn().length + 1,
24
+ });
25
+ if (!r.confirmed) {
26
+ return {
27
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
28
+ is_error: true,
29
+ };
30
+ }
31
+ }
32
+ try {
33
+ const result = await tool.handler(args, ctx);
34
+ // (regression-hunt trace removed 2026-05-01 after root cause found —
35
+ // see memory/project_phase3_lite_regression.md.)
36
+ // Always record AFTER successful mutating dispatch — applies to 1st and
37
+ // 2nd+ writes alike. The 1st write seeds the counter; without this the
38
+ // gate would never fire on the 2nd write.
39
+ if (tool.isMutating && !(result.is_error ?? false)) {
40
+ recordWriteOp(tool.name, args);
41
+ }
42
+ return { content: result.content, is_error: result.is_error ?? false };
43
+ }
44
+ catch (err) {
45
+ return { content: JSON.stringify({ error: String(err) }), is_error: true };
46
+ }
47
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Collect ALL assistant text emitted during a single turn.
3
+ *
4
+ * Why this exists: the end-of-turn save hook (loop.ts, maybeOfferSave call
5
+ * site) needs to scan the turn's assistant output for a skill manifest
6
+ * block (e.g. `<!-- csforge:cca-manifest ... -->` for /abap-cca). Before
7
+ * 2026-05-12 the call site only looked at the FINAL assistant message of
8
+ * the turn — but skills like /abap-cca emit the manifest in the report
9
+ * message, then call `ask_question` for a closing "what's next?" picker,
10
+ * then emit a short wrap-up message. The wrap-up message ended the turn
11
+ * but did not contain the manifest, so `extractCcaAssessment` threw
12
+ * "No manifest" and the save hook silently bailed (save-command.ts:114).
13
+ *
14
+ * Fix: walk session.messages from the per-turn start marker and join the
15
+ * text from every assistant message in between. The save hook now sees
16
+ * the full turn-worth of assistant output regardless of which inner
17
+ * message contained the manifest.
18
+ */
19
+ /**
20
+ * Concatenate text from every assistant message in `messages` starting at
21
+ * `turnStartCount`. Non-text content blocks (tool_use, tool_result) are
22
+ * skipped. Non-string `.text` values are coerced to empty strings rather
23
+ * than thrown — defensive against malformed content arrays.
24
+ *
25
+ * Returns '' (not null) when nothing matches so the callsite stays simple.
26
+ *
27
+ * Messages are joined with `\n\n` so the model's pre-tool message and
28
+ * post-tool message stay separable in the joined output. The save hook's
29
+ * extractor uses a regex that doesn't care about inter-message whitespace.
30
+ */
31
+ export function collectTurnAssistantText(messages, turnStartCount) {
32
+ if (turnStartCount < 0 || turnStartCount >= messages.length)
33
+ return '';
34
+ const parts = [];
35
+ for (let i = turnStartCount; i < messages.length; i++) {
36
+ const m = messages[i];
37
+ if (m.role !== 'assistant')
38
+ continue;
39
+ if (!Array.isArray(m.content))
40
+ continue;
41
+ for (const block of m.content) {
42
+ if (block?.type !== 'text')
43
+ continue;
44
+ if (typeof block.text === 'string')
45
+ parts.push(block.text);
46
+ }
47
+ }
48
+ return parts.join('\n\n');
49
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Graceful turn-error UX.
3
+ *
4
+ * Replaces the v0.5 catch path that printed only `[error] <msg>` and lost
5
+ * everything. The new behaviour:
6
+ *
7
+ * - Writes the full error (message + stack) to ~/.cspeach/logs/<sessionId>-<ts>.log
8
+ * - Prints a clean 4-6 line block to stderr with: cause, session id,
9
+ * stream path, log path, resume hint.
10
+ * - Never throws — best-effort. The CLI returns to the prompt cleanly so
11
+ * the user can either resume or type a new prompt.
12
+ *
13
+ * The previous loop persistence step (loop.ts try/catch) has already
14
+ * pushed any partial assistant content onto session.messages and saved
15
+ * the session before the throw bubbled here. Resume picks up that state.
16
+ */
17
+ import chalk from 'chalk';
18
+ import { promises as fs } from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import { TurnStreamWriter } from './turn-stream.js';
22
+ export function logsRoot() {
23
+ return path.join(os.homedir(), '.cspeach', 'logs');
24
+ }
25
+ /**
26
+ * Pretty-print `err.message` for human display. Translates known
27
+ * provider-stream signatures into actionable phrases. Anything unknown
28
+ * falls through verbatim.
29
+ */
30
+ export function describeError(err) {
31
+ if (err == null)
32
+ return 'unknown error';
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ if (!msg)
35
+ return 'unknown error';
36
+ // Undici / Node fetch closes a stream with bare "terminated".
37
+ if (msg === 'terminated' || msg.includes('terminated')) {
38
+ return 'provider stream closed mid-response';
39
+ }
40
+ if (msg.includes('ECONNRESET'))
41
+ return 'network connection reset';
42
+ if (msg.includes('ETIMEDOUT'))
43
+ return 'network timeout';
44
+ if (msg.toLowerCase().includes('aborterror'))
45
+ return 'request aborted';
46
+ return msg.slice(0, 200);
47
+ }
48
+ /**
49
+ * Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
50
+ * Best-effort — failure here is silent, the user-facing block still renders.
51
+ * Returns the absolute path on success, null on failure.
52
+ */
53
+ async function writeErrorLog(sessionId, err) {
54
+ try {
55
+ const dir = logsRoot();
56
+ await fs.mkdir(dir, { recursive: true });
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
58
+ const filePath = path.join(dir, `${sessionId}-${stamp}.log`);
59
+ const lines = [
60
+ `# CSPeach turn error log`,
61
+ `session: ${sessionId}`,
62
+ `at: ${new Date().toISOString()}`,
63
+ ``,
64
+ `## error`,
65
+ `${err instanceof Error ? err.name + ': ' + err.message : String(err)}`,
66
+ ``,
67
+ `## stack`,
68
+ `${err instanceof Error && err.stack ? err.stack : '(no stack)'}`,
69
+ ``,
70
+ ];
71
+ if (err && typeof err === 'object') {
72
+ const e = err;
73
+ const status = e.status ?? e.response?.status;
74
+ if (status)
75
+ lines.push(`status: ${status}`);
76
+ const code = e.code;
77
+ if (code)
78
+ lines.push(`code: ${code}`);
79
+ const cause = e.cause;
80
+ if (cause) {
81
+ lines.push(``, `## cause`, String(cause));
82
+ if (cause instanceof Error && cause.stack) {
83
+ lines.push(``, `## cause stack`, cause.stack);
84
+ }
85
+ }
86
+ }
87
+ await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf-8');
88
+ return filePath;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /**
95
+ * Render the user-facing turn-error block. Writes a log file (best-effort)
96
+ * and prints a 4-6 line diagnostic that names the cause + the recovery path.
97
+ */
98
+ export async function renderTurnError(input) {
99
+ const print = input.print ?? ((line) => process.stderr.write(line + '\n'));
100
+ print('');
101
+ if (input.userInterrupted) {
102
+ // User pressed Ctrl+C / Esc — frame as a deliberate cancellation,
103
+ // not a crash. Skip the log file (no diagnostic value).
104
+ print(chalk.yellow(`✋ Turn cancelled.`));
105
+ print(chalk.gray(` Session: ${input.session.id}`));
106
+ print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
107
+ print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
108
+ print('');
109
+ return;
110
+ }
111
+ const reason = describeError(input.err);
112
+ const logPath = await writeErrorLog(input.session.id, input.err);
113
+ print(chalk.red(`⚠ Turn interrupted: ${reason}`));
114
+ print(chalk.gray(` Session: ${input.session.id}`));
115
+ if (input.streamFilePath) {
116
+ print(chalk.gray(` Stream: ${input.streamFilePath}`));
117
+ }
118
+ if (logPath) {
119
+ print(chalk.gray(` Log: ${logPath}`));
120
+ }
121
+ print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
122
+ print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
123
+ print('');
124
+ }
125
+ /** Re-export for callers that want to compute a stream path independently. */
126
+ export { TurnStreamWriter };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Per-turn stream-to-disk mirror.
3
+ *
4
+ * The renderer (Ink + ANSI pipeline + widget extraction) is the SOLE display
5
+ * channel for assistant text in v0.5. If the terminal pipeline dies — Ink
6
+ * frame corruption, an exception inside the widget renderer, the host
7
+ * terminal closing — the user sees nothing and the in-memory text was, until
8
+ * v0.6, the only copy. (See loop.ts try/catch for the persistence side of
9
+ * the same problem.)
10
+ *
11
+ * This writer mirrors EVERY text delta arriving from the provider stream to
12
+ * a per-turn markdown file under ~/.cspeach/streams/<sessionId>/<ts>.md.
13
+ * Best-effort: file-IO failures NEVER throw out — a stream-to-disk hiccup
14
+ * must not crash a turn that is otherwise working.
15
+ *
16
+ * Use case: when a turn aborts mid-stream, the user runs `cat
17
+ * ~/.cspeach/streams/<sessionId>/<ts>.md` and recovers the partial output.
18
+ * The graceful error UX (T19) prints this path on every failed turn.
19
+ */
20
+ import { promises as fs } from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import * as os from 'node:os';
23
+ export function streamsRoot() {
24
+ // Mirror the existing ~/.cspeach/sessions/ convention. The sessions/
25
+ // directory holds the structured JSON; streams/ holds the human-readable
26
+ // text mirror.
27
+ return path.join(os.homedir(), '.cspeach', 'streams');
28
+ }
29
+ export class TurnStreamWriter {
30
+ filePath;
31
+ headerWritten = false;
32
+ constructor(sessionId, startedAt = new Date()) {
33
+ // ISO timestamp with filename-safe punctuation.
34
+ const stamp = startedAt.toISOString().replace(/[:.]/g, '-');
35
+ this.filePath = path.join(streamsRoot(), sessionId, `${stamp}.md`);
36
+ }
37
+ /** The on-disk file path (absolute). Useful for the resume/error UX. */
38
+ path() {
39
+ return this.filePath;
40
+ }
41
+ /**
42
+ * Append assistant text to the stream file. Best-effort — silently swallows
43
+ * file-IO errors so a write hiccup never kills a working turn. Lazily writes
44
+ * a small header on the first append so an unused turn produces no file.
45
+ */
46
+ async append(text) {
47
+ if (!text || text.length === 0)
48
+ return;
49
+ try {
50
+ if (!this.headerWritten) {
51
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
52
+ const header = `# CSPeach turn stream\n_started ${new Date().toISOString()}_\n\n`;
53
+ await fs.writeFile(this.filePath, header, 'utf-8');
54
+ this.headerWritten = true;
55
+ }
56
+ await fs.appendFile(this.filePath, text, 'utf-8');
57
+ }
58
+ catch {
59
+ // best-effort: never throw out
60
+ }
61
+ }
62
+ /**
63
+ * Close the stream with an optional footer (token totals, duration, end
64
+ * status). Idempotent — safe to call multiple times.
65
+ */
66
+ async close(footer) {
67
+ if (!this.headerWritten)
68
+ return;
69
+ try {
70
+ const block = footer
71
+ ? `\n\n---\n${footer}\n`
72
+ : `\n\n---\n_closed ${new Date().toISOString()}_\n`;
73
+ await fs.appendFile(this.filePath, block, 'utf-8');
74
+ }
75
+ catch {
76
+ // best-effort
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Layer 3 — long-turn watchdog.
3
+ *
4
+ * Background. The lost-77-minute /abap-cca on ZDPR_PAYMENTS run did 30+ tool
5
+ * calls and 200K output tokens in a single turn. Even with Layer 1 + 2, a
6
+ * turn that long is a smell: it's hard to follow, it consumes token budget
7
+ * unevenly, and it sits inside one Anthropic streaming session that's more
8
+ * likely to be cut by a network blip the longer it runs.
9
+ *
10
+ * The watchdog injects a synthetic advisory user message between tool rounds
11
+ * when the running turn approaches a budget. The model sees:
12
+ *
13
+ * "[CSPeach watchdog] You've been running this turn for 18m / 92K output
14
+ * tokens. Consider checkpointing what you have, summarizing progress so
15
+ * far, and prompting the user to continue. Don't keep going as if nothing
16
+ * changed."
17
+ *
18
+ * Soft signal — the model decides whether to wrap up or push through. The
19
+ * advisory is emitted exactly ONCE per turn so we don't spam.
20
+ *
21
+ * Defaults:
22
+ * wall-clock: 20 minutes per turn (overridable via CSPEACH_TURN_WALL_BUDGET_MS)
23
+ * output tokens: 100,000 per turn (overridable via CSPEACH_TURN_TOKEN_BUDGET)
24
+ *
25
+ * Set CSPEACH_TURN_WATCHDOG=off to disable entirely.
26
+ */
27
+ export const DEFAULT_WALL_BUDGET_MS = 20 * 60 * 1000;
28
+ export const DEFAULT_TOKEN_BUDGET = 100_000;
29
+ export function loadWatchdogConfig(env = process.env) {
30
+ const enabled = (env.CSPEACH_TURN_WATCHDOG ?? 'on').toLowerCase() !== 'off';
31
+ const wallBudgetMs = parsePositiveInt(env.CSPEACH_TURN_WALL_BUDGET_MS, DEFAULT_WALL_BUDGET_MS);
32
+ const tokenBudget = parsePositiveInt(env.CSPEACH_TURN_TOKEN_BUDGET, DEFAULT_TOKEN_BUDGET);
33
+ return { wallBudgetMs, tokenBudget, enabled };
34
+ }
35
+ function parsePositiveInt(raw, fallback) {
36
+ if (!raw)
37
+ return fallback;
38
+ const n = Number.parseInt(raw, 10);
39
+ return Number.isFinite(n) && n > 0 ? n : fallback;
40
+ }
41
+ export function evaluateWatchdog(input, now = Date.now()) {
42
+ if (!input.config.enabled || input.alreadyWarned) {
43
+ return { shouldWarn: false, reason: '', message: '' };
44
+ }
45
+ const elapsedMs = Math.max(0, now - input.startedAt);
46
+ const wallExceeded = elapsedMs >= input.config.wallBudgetMs;
47
+ const tokensExceeded = input.outputTokensThisTurn >= input.config.tokenBudget;
48
+ if (!wallExceeded && !tokensExceeded) {
49
+ return { shouldWarn: false, reason: '', message: '' };
50
+ }
51
+ const wallMin = Math.floor(elapsedMs / 60_000);
52
+ const wallLimit = Math.floor(input.config.wallBudgetMs / 60_000);
53
+ const reasons = [];
54
+ if (wallExceeded)
55
+ reasons.push(`${wallMin}m elapsed (limit ${wallLimit}m)`);
56
+ if (tokensExceeded) {
57
+ reasons.push(`${kFormat(input.outputTokensThisTurn)} output tokens (limit ${kFormat(input.config.tokenBudget)})`);
58
+ }
59
+ const reason = reasons.join(', ');
60
+ const message = `[CSPeach watchdog] This turn has crossed a budget: ${reason}. ` +
61
+ `Consider checkpointing what you have, summarising progress so far, ` +
62
+ `and prompting the user to continue with a fresh turn. Don't keep ` +
63
+ `going as if nothing changed — long single-turn runs are more likely ` +
64
+ `to be cut by a network blip the longer they get.`;
65
+ return { shouldWarn: true, reason, message };
66
+ }
67
+ function kFormat(n) {
68
+ if (n < 1000)
69
+ return String(n);
70
+ return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`;
71
+ }
@@ -0,0 +1,40 @@
1
+ import clipboardy from 'clipboardy';
2
+ import readline from 'node:readline/promises';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ export async function promptAdvisory(args) {
5
+ output.write(args.displayText + '\n');
6
+ const rl = readline.createInterface({ input, output });
7
+ try {
8
+ while (true) {
9
+ const answer = (await rl.question('[y]applied [s]kip [n]abort [c]opy-to-clipboard: '))
10
+ .trim()
11
+ .toLowerCase();
12
+ switch (answer) {
13
+ case 'y':
14
+ case 'yes':
15
+ case 'applied':
16
+ return 'applied';
17
+ case 's':
18
+ case 'skip':
19
+ case 'skipped':
20
+ return 'skipped';
21
+ case 'n':
22
+ case 'no':
23
+ case 'abort':
24
+ case 'aborted':
25
+ return 'aborted';
26
+ case 'c':
27
+ case 'copy':
28
+ await clipboardy.write(args.clipboardText);
29
+ output.write('✓ Copied to clipboard.\n');
30
+ continue;
31
+ default:
32
+ output.write('Unrecognised input. Try y, s, n, or c.\n');
33
+ continue;
34
+ }
35
+ }
36
+ }
37
+ finally {
38
+ rl.close();
39
+ }
40
+ }
@@ -0,0 +1,38 @@
1
+ import chalk from 'chalk';
2
+ export function renderAdvisoryProposal(args) {
3
+ const displayParts = [];
4
+ const clipboardParts = [];
5
+ displayParts.push(chalk.yellow.bold('╔══ ADVISORY MODE — proposed change (no SAP write will occur) ══╗'));
6
+ displayParts.push(chalk.bold(`Summary: ${args.summary}`));
7
+ displayParts.push(`Risk: ${args.risk}`);
8
+ if (args.transport)
9
+ displayParts.push(`Target transport: ${args.transport}`);
10
+ displayParts.push('');
11
+ clipboardParts.push(`# ${args.summary}`);
12
+ if (args.transport)
13
+ clipboardParts.push(`# Target transport: ${args.transport}`);
14
+ clipboardParts.push('');
15
+ args.changes.forEach((change, idx) => {
16
+ const header = `Change ${idx + 1}: ${change.op.toUpperCase()} ${change.type} ${change.object}`;
17
+ displayParts.push(chalk.cyan(header));
18
+ clipboardParts.push(`## ${header}`);
19
+ if (change.diff) {
20
+ displayParts.push(chalk.gray('─'.repeat(64)));
21
+ displayParts.push(change.diff);
22
+ displayParts.push(chalk.gray('─'.repeat(64)));
23
+ clipboardParts.push('');
24
+ clipboardParts.push(change.diff);
25
+ clipboardParts.push('');
26
+ }
27
+ });
28
+ displayParts.push('');
29
+ displayParts.push(chalk.yellow('Next step: open this object in ADT or SE80, apply the change, save and activate.'));
30
+ displayParts.push(chalk.gray('Press c to copy to clipboard, y when applied, s to skip, n to abort.'));
31
+ displayParts.push(chalk.yellow.bold('╚══════════════════════════════════════════════════════════════════╝'));
32
+ clipboardParts.push('');
33
+ clipboardParts.push('# This is an advisory proposal. Apply manually in ADT/SE80.');
34
+ return {
35
+ displayText: displayParts.join('\n'),
36
+ clipboardText: clipboardParts.join('\n'),
37
+ };
38
+ }
@@ -0,0 +1,100 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, saveConfig } from '../config/loader.js';
3
+ const BOX_WIDTH = 62;
4
+ const TOP = '┌' + '─'.repeat(BOX_WIDTH) + '┐';
5
+ const BOTTOM = '└' + '─'.repeat(BOX_WIDTH) + '┘';
6
+ const pad = (s) => '│ ' + s.padEnd(BOX_WIDTH - 2) + '│';
7
+ const blank = '│' + ' '.repeat(BOX_WIDTH) + '│';
8
+ /**
9
+ * Capture a single keypress for the y/n/d answer. Raw-mode readline so the
10
+ * hand-drawn box stays the only visible affordance.
11
+ *
12
+ * Why readline, not inquirer `select`: inquirer would render its own full-screen
13
+ * picker below our hand-drawn box, giving the user TWO UIs for the same
14
+ * question. The spec §3.5 Option A says "the box is the UI" — one affordance,
15
+ * one keypress.
16
+ */
17
+ async function promptNagChoice() {
18
+ return new Promise((resolve) => {
19
+ if (process.stdin.isTTY)
20
+ process.stdin.setRawMode(true);
21
+ process.stdin.resume();
22
+ process.stdin.setEncoding('utf8');
23
+ const onData = (key) => {
24
+ const k = key.toLowerCase();
25
+ // Ctrl-C → treat as 'n' and let the outer flow continue.
26
+ if (k === '\u0003') {
27
+ cleanup();
28
+ resolve('n');
29
+ return;
30
+ }
31
+ if (k === 'y' || k === 'n' || k === 'd') {
32
+ cleanup();
33
+ process.stdout.write(`\n${k}\n`);
34
+ resolve(k);
35
+ }
36
+ };
37
+ const cleanup = () => {
38
+ process.stdin.removeListener('data', onData);
39
+ if (process.stdin.isTTY)
40
+ process.stdin.setRawMode(false);
41
+ process.stdin.pause();
42
+ };
43
+ process.stdin.on('data', onData);
44
+ });
45
+ }
46
+ /**
47
+ * Called just before `renderPerChangeApproval` when the current change
48
+ * is low-risk on a `never` system and the nag has not been dismissed.
49
+ *
50
+ * Mutates config on 'y' (sets auto_approve = 'low') or 'd' (sets per-sap
51
+ * nag_dismissed = true — see M5 for the scoping rationale). Returns
52
+ * immediately (no side effect) on 'n'.
53
+ */
54
+ export async function maybeShowAutoApproveNag(ctx) {
55
+ const cfg = await loadConfig();
56
+ const sapCfg = cfg.sap[ctx.sapAlias];
57
+ // Preconditions: low risk, never policy, per-sap nag not dismissed.
58
+ if (ctx.effectiveRisk !== 'low')
59
+ return;
60
+ if (!sapCfg || sapCfg.auto_approve !== 'never')
61
+ return;
62
+ if (sapCfg.nag_dismissed ?? false)
63
+ return;
64
+ // Draw the box. This is the ONLY visible UI — keypress answers it directly.
65
+ console.log('');
66
+ console.log(TOP);
67
+ console.log(blank);
68
+ console.log(pad(chalk.yellow('This change was classified as LOW risk.')));
69
+ console.log(blank);
70
+ console.log(pad(`Your current policy on ${ctx.sapAlias} prompts on everything.`));
71
+ console.log(pad('Want to auto-approve low-risk changes on this system?'));
72
+ console.log(blank);
73
+ console.log(pad(chalk.green('[y] yes') + ' (safer for DEV systems only)'));
74
+ console.log(pad(chalk.dim('[n] no ') + ' (keep prompting every time)'));
75
+ console.log(pad(chalk.dim('[d] don\'t ask me this again (on ' + ctx.sapAlias + ')')));
76
+ console.log(blank);
77
+ console.log(BOTTOM);
78
+ process.stdout.write(chalk.dim('Press y / n / d: '));
79
+ const choice = await promptNagChoice();
80
+ if (choice === 'y') {
81
+ const fresh = await loadConfig(); // re-read to avoid stale write
82
+ if (fresh.sap[ctx.sapAlias]) {
83
+ fresh.sap[ctx.sapAlias].auto_approve = 'low';
84
+ await saveConfig(fresh);
85
+ console.log(chalk.green(`Auto-approve set to "low" for ${ctx.sapAlias}.`));
86
+ console.log(chalk.gray('Change with: cspeach config set sap.' + ctx.sapAlias + '.auto_approve never\n'));
87
+ }
88
+ }
89
+ else if (choice === 'd') {
90
+ const fresh = await loadConfig();
91
+ // Per-sap scoping (M5): dismissing on DEV must not suppress the more
92
+ // important PROD prompt. Each sap system has its own `nag_dismissed` flag.
93
+ if (fresh.sap[ctx.sapAlias]) {
94
+ fresh.sap[ctx.sapAlias].nag_dismissed = true;
95
+ await saveConfig(fresh);
96
+ console.log(chalk.gray(`Noted. This prompt will not appear again on ${ctx.sapAlias}.\n`));
97
+ }
98
+ }
99
+ // 'n' → no config change, fall through to per-change approval normally.
100
+ }