@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,143 @@
1
+ // cspeach-cli/src/tools/subagent/background_run.ts
2
+ /**
3
+ * background_run — Phase 3 / Chunk 3E.
4
+ *
5
+ * Spawns a safelisted command via the Chunk 3B safelist (reused
6
+ * through _command-shared.ts), registers the child in the in-process
7
+ * _background-shared registry, and returns a bg_id immediately.
8
+ * Child stdout/stderr lines stream into the registry as BgEvents;
9
+ * monitor_emit drains them.
10
+ *
11
+ * Child does NOT outlive the CLI process — the registry's exit
12
+ * handler SIGTERMs every live child on Node exit and SIGKILLs after
13
+ * KILL_GRACE_MS.
14
+ *
15
+ * Flag-gated: invisible to listTools() unless
16
+ * CSPEACH_TOOL_BACKGROUND_RUN=on. isMutating: true — hooks the existing
17
+ * approval gate (same UX as shell_exec).
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { promises as fs } from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { registerTool } from '../index.js';
24
+ import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
25
+ import { loadSafelist, buildSafeEnv, resolveExecutable, validateArgv, rejectPathSeparator, } from '../_command-shared.js';
26
+ import { registerBackground, recordEvent, installExitHandler, } from './_background-shared.js';
27
+ export async function backgroundRunHandler(args, ctx) {
28
+ // Install once-per-process exit handler before we register anything.
29
+ installExitHandler();
30
+ if (!args.command || typeof args.command !== 'string') {
31
+ return { content: 'error: command is required (non-empty string)', is_error: true };
32
+ }
33
+ if (args.cwd !== undefined && typeof args.cwd !== 'string') {
34
+ return { content: 'error: cwd must be a string when provided', is_error: true };
35
+ }
36
+ // Silently drop non-string argv elements. Schema validation upstream should
37
+ // already enforce strings; this is belt-and-braces. A model passing [1, 'x', 2]
38
+ // sees only 'x' reach the child — surprising from the model's perspective but
39
+ // safe (safelist is on `command`, not argv).
40
+ const argv = Array.isArray(args.args) ? args.args.filter((a) => typeof a === 'string') : [];
41
+ const argvErr = validateArgv(argv);
42
+ if (argvErr !== null)
43
+ return { content: `error: ${argvErr}`, is_error: true };
44
+ const sepErr = rejectPathSeparator(args.command);
45
+ if (sepErr !== null)
46
+ return { content: `error: ${sepErr}`, is_error: true };
47
+ const safelist = await loadSafelist();
48
+ if (!safelist.has(args.command)) {
49
+ return {
50
+ content: `error: command "${args.command}" is not in the safelist. allowed: ${[...safelist].sort().join(', ')}. ` +
51
+ 'extend via [shell_exec] allow = [...] in ~/.cspeach/config.toml.',
52
+ is_error: true,
53
+ };
54
+ }
55
+ let cwd;
56
+ try {
57
+ cwd = args.cwd ? resolveSafePath(ctx.cwd, args.cwd) : path.resolve(ctx.cwd);
58
+ }
59
+ catch (err) {
60
+ if (err instanceof PathOutsideRootError)
61
+ return { content: `error: ${err.message}`, is_error: true };
62
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
63
+ }
64
+ try {
65
+ const stat = await fs.stat(cwd);
66
+ if (!stat.isDirectory())
67
+ return { content: `error: cwd "${args.cwd ?? '.'}" is not a directory`, is_error: true };
68
+ }
69
+ catch {
70
+ return { content: `error: cwd "${args.cwd ?? '.'}" does not exist`, is_error: true };
71
+ }
72
+ const resolved = await resolveExecutable(args.command);
73
+ if (resolved === null) {
74
+ return {
75
+ content: `error: command "${args.command}" not installed on this system (not found on PATH).`,
76
+ is_error: true,
77
+ };
78
+ }
79
+ let child;
80
+ try {
81
+ child = spawn(resolved, argv, {
82
+ cwd,
83
+ env: buildSafeEnv(),
84
+ windowsHide: true,
85
+ shell: false,
86
+ detached: false, // explicit assertion — never daemonize
87
+ stdio: ['ignore', 'pipe', 'pipe'],
88
+ });
89
+ }
90
+ catch (err) {
91
+ return { content: `error: spawn failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
92
+ }
93
+ // bg-<uuid> prefix is load-bearing — Task 4's monitor_emit validates
94
+ // against BG_ID_PATTERN /^bg-[0-9a-f-]+$/ to refuse path-traversal-style
95
+ // registry-key injection. Do NOT drop the prefix. See commit 1bfd71d.
96
+ const bg_id = `bg-${randomUUID()}`;
97
+ registerBackground({ bg_id, command: args.command, argv, child });
98
+ child.stdout?.on('data', (chunk) => {
99
+ recordEvent(bg_id, { kind: 'stdout', text: chunk.toString('utf-8'), ts: Date.now() });
100
+ });
101
+ child.stderr?.on('data', (chunk) => {
102
+ recordEvent(bg_id, { kind: 'stderr', text: chunk.toString('utf-8'), ts: Date.now() });
103
+ });
104
+ child.on('error', (err) => {
105
+ recordEvent(bg_id, { kind: 'error', text: err.message, ts: Date.now() });
106
+ });
107
+ child.on('close', (code, signal) => {
108
+ recordEvent(bg_id, {
109
+ kind: 'exit',
110
+ text: `code=${code ?? 'null'}${signal ? ` signal=${signal}` : ''}`,
111
+ ts: Date.now(),
112
+ exitCode: code,
113
+ signal,
114
+ });
115
+ });
116
+ return {
117
+ content: `bg_id: ${bg_id}\n` +
118
+ `command: ${args.command} ${argv.join(' ')}\n` +
119
+ `pid: ${child.pid ?? 'unknown'}\n` +
120
+ `cwd: ${cwd}\n` +
121
+ `note: child is running — call monitor_emit with this bg_id to drain stdout/stderr/exit events. ` +
122
+ `child will be SIGTERMd on CLI exit (then SIGKILLd after grace).`,
123
+ };
124
+ }
125
+ registerTool({
126
+ name: 'background_run',
127
+ description: 'Spawn a safelisted command as a non-blocking background process. Returns a bg_id immediately; ' +
128
+ 'caller polls events via monitor_emit. Same safelist + argv-only + env scrub as shell_exec. ' +
129
+ 'Child does NOT outlive the CLI process — SIGTERM on exit, SIGKILL after grace.',
130
+ isMutating: true,
131
+ category: 'subagent',
132
+ flagGated: true,
133
+ input_schema: {
134
+ type: 'object',
135
+ properties: {
136
+ command: { type: 'string', description: 'Bare command name. Must be in the safelist. No paths.' },
137
+ args: { type: 'array', items: { type: 'string' }, description: 'argv array — no shell parsing.' },
138
+ cwd: { type: 'string', description: 'Optional working directory relative to project root.' },
139
+ },
140
+ required: ['command'],
141
+ },
142
+ handler: (args, ctx) => backgroundRunHandler(args, ctx),
143
+ });
@@ -0,0 +1,65 @@
1
+ // cspeach-cli/src/tools/subagent/monitor_emit.ts
2
+ /**
3
+ * monitor_emit — Phase 3 / Chunk 3E.
4
+ *
5
+ * Drains events from a bg_id's registry buffer. Per-call cursor advance:
6
+ * each call returns up to `max` events from the front of the FIFO and
7
+ * removes them. Subsequent calls return only NEW events.
8
+ *
9
+ * `dropped` is the count of events FIFO-evicted from the buffer since
10
+ * the previous consume call; counter resets on read.
11
+ *
12
+ * Read-only — does not spawn anything. Safe to call repeatedly.
13
+ *
14
+ * Flag-gated: invisible to listTools() unless
15
+ * CSPEACH_TOOL_MONITOR_EMIT=on. isMutating: false.
16
+ */
17
+ import { registerTool } from '../index.js';
18
+ import { consumeEvents } from './_background-shared.js';
19
+ const DEFAULT_MAX = 100;
20
+ const HARD_MAX = 1_000;
21
+ // bg_id = `bg-<uuid>`, where uuid is the 36-char shape (8-4-4-4-12 hex)
22
+ // from crypto.randomUUID. The pattern accepts any UUID-shaped hex (not
23
+ // strictly v4) — that's intentional, since Node's randomUUID() always
24
+ // emits v4 lowercase and the `/i` flag forgives a model fabricating
25
+ // uppercase. Pattern shape is load-bearing — defends against registry-
26
+ // key injection (e.g. '../../etc/passwd'-style strings) by rejecting
27
+ // anything that doesn't match before the registry lookup runs.
28
+ // MUST match background_run.ts's bg_id construction (Task 3, 1bfd71d).
29
+ const BG_ID_PATTERN = /^bg-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ export async function monitorEmitHandler(args, _ctx) {
31
+ if (!args.bg_id || typeof args.bg_id !== 'string') {
32
+ return { content: 'error: bg_id is required (non-empty string)', is_error: true };
33
+ }
34
+ if (!BG_ID_PATTERN.test(args.bg_id)) {
35
+ return { content: `error: invalid bg_id shape '${args.bg_id}' — must match bg-<uuid>`, is_error: true };
36
+ }
37
+ const maxArg = typeof args.max === 'number' && Number.isFinite(args.max) && args.max > 0
38
+ ? Math.floor(args.max)
39
+ : DEFAULT_MAX;
40
+ const max = Math.min(maxArg, HARD_MAX);
41
+ const result = consumeEvents(args.bg_id, max);
42
+ if (result === null) {
43
+ return { content: `error: bg_id '${args.bg_id}' not found in registry`, is_error: true };
44
+ }
45
+ return { content: JSON.stringify(result, null, 2) };
46
+ }
47
+ registerTool({
48
+ name: 'monitor_emit',
49
+ description: 'Drain buffered events (stdout/stderr/exit) from a background_run bg_id. Per-call cursor ' +
50
+ 'advance: each call returns up to `max` events (default 100, hard cap 1000) and removes ' +
51
+ 'them from the buffer. Subsequent calls return only NEW events. Surfaces `dropped` if ' +
52
+ 'FIFO eviction occurred since the previous read.',
53
+ isMutating: false,
54
+ category: 'subagent',
55
+ flagGated: true,
56
+ input_schema: {
57
+ type: 'object',
58
+ properties: {
59
+ bg_id: { type: 'string', description: 'The bg_id returned by background_run.' },
60
+ max: { type: 'number', description: 'Max events to return this call. Default 100, hard cap 1000.' },
61
+ },
62
+ required: ['bg_id'],
63
+ },
64
+ handler: (args, ctx) => monitorEmitHandler(args, ctx),
65
+ });
@@ -0,0 +1,131 @@
1
+ // cspeach-cli/src/tools/subagent/schedule_create.ts
2
+ /**
3
+ * schedule_create — Phase 3 / Chunk 3E.
4
+ *
5
+ * Persists a schedule envelope to ~/.cspeach/schedules/<uuid>.json.
6
+ * Filename is ALWAYS crypto.randomUUID — never derived from user
7
+ * input. Closes the path-injection class of attack on the schedules
8
+ * directory.
9
+ *
10
+ * IMPORTANT: this tool persists schedules. It does NOT execute them.
11
+ * A future chunk (Phase 4 or later) ships the actual runner. The
12
+ * tool description and the response body both surface this caveat
13
+ * so the agent doesn't assume the schedule will fire.
14
+ *
15
+ * Flag-gated: invisible to listTools() unless
16
+ * CSPEACH_TOOL_SCHEDULE_CREATE=on. isMutating: true (writes filesystem).
17
+ */
18
+ import { promises as fs } from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { registerTool } from '../index.js';
23
+ const NOT_EXECUTED_NOTE = 'Persisted only — the schedule runner has not shipped yet. ' +
24
+ 'See Phase 4+ for execution.';
25
+ // Test-overridable. Default to ~/.cspeach/schedules. Set null to reset.
26
+ let schedulesDirOverride = null;
27
+ /** Test-only: redirect the schedules dir so tests don't pollute the real home dir. */
28
+ export function __setSchedulesDirForTests(dir) {
29
+ schedulesDirOverride = dir;
30
+ }
31
+ function getSchedulesDir() {
32
+ return schedulesDirOverride ?? path.join(os.homedir(), '.cspeach', 'schedules');
33
+ }
34
+ // Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z
35
+ const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
36
+ export async function scheduleCreateHandler(args, _ctx) {
37
+ if (args.kind !== 'oneoff' && args.kind !== 'cron') {
38
+ return { content: `error: kind must be 'oneoff' or 'cron' — got '${args.kind}'`, is_error: true };
39
+ }
40
+ if (!args.when || typeof args.when !== 'string' || args.when.trim().length === 0) {
41
+ return { content: 'error: when is required (non-empty string)', is_error: true };
42
+ }
43
+ if (!args.command || typeof args.command !== 'string' || args.command.trim().length === 0) {
44
+ return { content: 'error: command is required (non-empty string)', is_error: true };
45
+ }
46
+ if (args.kind === 'oneoff') {
47
+ if (!ISO_PATTERN.test(args.when)) {
48
+ return { content: `error: when must be an ISO-8601 UTC timestamp (e.g. 2099-06-01T10:00:00Z) — got '${args.when}'`, is_error: true };
49
+ }
50
+ const t = Date.parse(args.when);
51
+ if (Number.isNaN(t)) {
52
+ return { content: `error: when is not a parseable ISO timestamp: '${args.when}'`, is_error: true };
53
+ }
54
+ // Belt-and-braces against silent month/day rollover. Date.parse('2099-02-30T00:00:00Z')
55
+ // returns a valid timestamp that round-trips to March 2 — without this check, the
56
+ // envelope persists '2099-02-30' but the runner fires on March 2 (phantom job).
57
+ const roundTrip = new Date(t).toISOString();
58
+ // Normalise both sides: lowercase 'z', strip optional fractional seconds, compare.
59
+ const norm = (s) => s.replace(/\.\d+Z$/, 'Z').toUpperCase();
60
+ if (norm(roundTrip) !== norm(args.when)) {
61
+ return { content: `error: when is not a valid calendar date: '${args.when}' (rolls to ${roundTrip})`, is_error: true };
62
+ }
63
+ if (t <= Date.now()) {
64
+ return { content: `error: when is in the past — schedule must be in the future`, is_error: true };
65
+ }
66
+ }
67
+ else {
68
+ // cron: just check it has 5 whitespace-separated fields.
69
+ const fields = args.when.trim().split(/\s+/);
70
+ if (fields.length !== 5) {
71
+ return { content: `error: cron expression must have 5 fields (min hour dom mon dow) — got ${fields.length}`, is_error: true };
72
+ }
73
+ }
74
+ const dir = getSchedulesDir();
75
+ await fs.mkdir(dir, { recursive: true });
76
+ // Defence-in-depth: resolve the directory and verify it sits inside
77
+ // the cspeach home (or the test override). Refuses if a symlink
78
+ // points the dir at /etc/, /tmp/host-controlled/, etc.
79
+ const realDir = await fs.realpath(dir);
80
+ // In test mode the override dir IS the root (self-containment is trivial —
81
+ // mkdtemp gives us a unique dir that acts as both schedules-dir and its own
82
+ // root). In prod the schedules dir must sit UNDER ~/.cspeach/, so the root
83
+ // is the parent. Asymmetric on purpose.
84
+ const expectedRoot = schedulesDirOverride !== null
85
+ ? path.resolve(schedulesDirOverride)
86
+ : path.join(os.homedir(), '.cspeach');
87
+ const rel = path.relative(expectedRoot, realDir);
88
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
89
+ return { content: `error: schedules directory '${realDir}' resolves outside expected root`, is_error: true };
90
+ }
91
+ const id = randomUUID();
92
+ const envelope = {
93
+ id,
94
+ kind: args.kind,
95
+ when: args.when,
96
+ command: args.command,
97
+ createdAt: new Date().toISOString(),
98
+ note: NOT_EXECUTED_NOTE,
99
+ };
100
+ const target = path.join(realDir, `${id}.json`);
101
+ await fs.writeFile(target, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
102
+ return {
103
+ content: `schedule persisted (NOT YET EXECUTED — runner ships in a later chunk).\n` +
104
+ `path: ${target}\n` +
105
+ `id: ${id}\n` +
106
+ `kind: ${args.kind}\n` +
107
+ `when: ${args.when}\n` +
108
+ `command: ${args.command}\n` +
109
+ `note: ${NOT_EXECUTED_NOTE}`,
110
+ };
111
+ }
112
+ registerTool({
113
+ name: 'schedule_create',
114
+ description: 'Persist a oneoff or cron schedule envelope to ~/.cspeach/schedules/<uuid>.json. ' +
115
+ 'Filename is a fresh UUID — never derived from input. NOTE: this tool only PERSISTS the ' +
116
+ 'schedule. No runner is shipped yet — the agent should not assume the command will fire ' +
117
+ 'automatically. A future chunk will ship the runner.',
118
+ isMutating: true,
119
+ category: 'subagent',
120
+ flagGated: true,
121
+ input_schema: {
122
+ type: 'object',
123
+ properties: {
124
+ kind: { type: 'string', enum: ['oneoff', 'cron'], description: 'oneoff = single ISO timestamp; cron = 5-field expression' },
125
+ when: { type: 'string', description: 'ISO-8601 UTC timestamp for oneoff, or 5-field cron expression' },
126
+ command: { type: 'string', description: 'The command the runner WOULD execute (free-form string for now)' },
127
+ },
128
+ required: ['kind', 'when', 'command'],
129
+ },
130
+ handler: (args, ctx) => scheduleCreateHandler(args, ctx),
131
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Transport tools — Rule 9 (transport isolation).
3
+ *
4
+ * Confirmed AdtClient signatures (sap-client/src/adt-client.ts):
5
+ * transportCreate(description, targetPackage?) → Promise<TransportResult> line 2508
6
+ * TransportResult { operation, transportNumber?, description?, status?, error? }
7
+ * transportList(user?, status?) → Promise<TransportListResult> line 1048
8
+ * TransportListResult { filterUser, filterStatus, transports: TransportEntry[] }
9
+ * TransportEntry { transportNumber, description?, owner?, status?, type?, target? }
10
+ * transportRelease(transportNumber) → Promise<TransportResult> line 2546
11
+ * Released status string from SAP is 'released' (not a raw 'R' code).
12
+ */
13
+ import { registerTool } from './index.js';
14
+ import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
15
+ import { auditLog } from '@cspeach/sap-client';
16
+ import { ERR } from '../errors/codes.js';
17
+ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
18
+ import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
19
+ import { setCurrentTransport } from '../repl/current-transport.js';
20
+ // ── sap_transport_create ─────────────────────────────────────────────────────
21
+ //
22
+ // Creates a new CTS workbench transport request.
23
+ // Used once per session (Rule 9 — dedicated transport per session).
24
+ registerTool({
25
+ name: 'sap_transport_create',
26
+ description: 'Create a new CTS transport request (Rule 9 — one dedicated transport per session). '
27
+ + 'BEFORE calling this tool, you MUST ask the user for the transport description '
28
+ + 'using `ask_question` — DO NOT generate a marketing-style description yourself. '
29
+ + 'Use the user\'s exact answer as the `description` argument AND in your '
30
+ + '`request_approval` reason. Identical strings prevent the approval mismatch '
31
+ + 'failure mode observed live 2026-05-07. '
32
+ + 'Requires approval_id. Returns the transport number to use in subsequent writes.',
33
+ isMutating: true,
34
+ input_schema: {
35
+ type: 'object',
36
+ properties: {
37
+ description: {
38
+ type: 'string',
39
+ description: 'Transport description AS PROVIDED BY THE USER (ask via ask_question first). '
40
+ + 'Must be ≤60 characters, ASCII only — no em-dashes (—), em-spaces, smart quotes, '
41
+ + 'or non-Latin characters. SAP\'s CTS description field is Latin-1 and rejects/'
42
+ + 'garbles wider Unicode. Good examples: "Sales order overdue report", '
43
+ + '"ZSD_OVERDUE: CDS + OData V4 stack". Bad examples: '
44
+ + '"CSPeach: Overdue SO Monitoring — CDS + OData" (em-dash → HTTP 400), '
45
+ + 'overly long marketing-style summaries.',
46
+ },
47
+ target_package: {
48
+ type: 'string',
49
+ description: 'Target package (DEVCLASS) for the transport — typically the same package the '
50
+ + 'objects will live in. Required by SAP\'s CreateCorrectionRequest endpoint. '
51
+ + 'Empty / missing target_package on this endpoint commonly causes HTTP 400.',
52
+ },
53
+ approval_id: {
54
+ type: 'string',
55
+ description: 'JWT from request_approval. Reason field on request_approval should be '
56
+ + 'IDENTICAL to the description argument here — same exact string — or the '
57
+ + 'approval will fail with object_mismatch.',
58
+ },
59
+ },
60
+ required: ['description', 'approval_id'],
61
+ },
62
+ handler: async (args, ctx) => {
63
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
64
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.description, 'create');
65
+ if (!verify.ok) {
66
+ return {
67
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
68
+ is_error: true,
69
+ };
70
+ }
71
+ const startedAt = Date.now();
72
+ // ── Create transport ─────────────────────────────────────────────────────
73
+ // transportCreate(description, targetPackage?)
74
+ try {
75
+ const result = await ctx.adt.transportCreate(args.description, args.target_package);
76
+ await auditLog('transportCreate', 'CTS', result.transportNumber ?? 'UNKNOWN', 'success', Date.now() - startedAt);
77
+ if (result.status !== 'created' || !result.transportNumber) {
78
+ return {
79
+ content: JSON.stringify({ error: 'transport_create_failed', detail: result.error }),
80
+ is_error: true,
81
+ };
82
+ }
83
+ // Rule 9 — auto-attach the new TR as the session current-transport so that
84
+ // subsequent write calls pick it up via the 3-tier fallback without the LLM
85
+ // needing to pass it explicitly every time.
86
+ setCurrentTransport(result.transportNumber);
87
+ return {
88
+ content: JSON.stringify({
89
+ created: true,
90
+ transport_number: result.transportNumber,
91
+ description: result.description,
92
+ note: 'Use this transport_number in all subsequent sap_set_source / sap_create_object calls this session.',
93
+ }),
94
+ };
95
+ }
96
+ catch (err) {
97
+ await auditLog('transportCreate', 'CTS', 'UNKNOWN', 'error', Date.now() - startedAt);
98
+ return {
99
+ content: JSON.stringify({ error: 'transport_create_failed', detail: String(err) }),
100
+ is_error: true,
101
+ };
102
+ }
103
+ },
104
+ });
105
+ // ── sap_transport_list ───────────────────────────────────────────────────────
106
+ //
107
+ // Lists transport requests, optionally filtered by owner and/or status.
108
+ // Read-only — no approval needed.
109
+ registerTool({
110
+ name: 'sap_transport_list',
111
+ description: 'List SAP CTS transport requests. '
112
+ + 'Optionally filter by owner (user) or status (modifiable | released).',
113
+ isMutating: false,
114
+ input_schema: {
115
+ type: 'object',
116
+ properties: {
117
+ owner: {
118
+ type: 'string',
119
+ description: 'Filter by transport owner (SAP user ID). Omit for all owners.',
120
+ },
121
+ status: {
122
+ type: 'string',
123
+ enum: ['modifiable', 'released'],
124
+ description: 'Filter by status. Defaults to "modifiable".',
125
+ },
126
+ },
127
+ },
128
+ handler: async (args, ctx) => {
129
+ // transportList(user?, status?)
130
+ try {
131
+ const result = await ctx.adt.transportList(args.owner, args.status);
132
+ return {
133
+ content: JSON.stringify({
134
+ filter_user: result.filterUser || null,
135
+ filter_status: result.filterStatus,
136
+ count: result.transports.length,
137
+ transports: result.transports,
138
+ }),
139
+ };
140
+ }
141
+ catch (err) {
142
+ return {
143
+ content: JSON.stringify({ error: 'transport_list_failed', detail: String(err) }),
144
+ is_error: true,
145
+ };
146
+ }
147
+ },
148
+ });
149
+ // ── sap_transport_release ────────────────────────────────────────────────────
150
+ //
151
+ // Releases a transport request.
152
+ // Pre-conditions: all objects must be syntax-clean and ATC-clean (Rule 9).
153
+ // Signature: transportRelease(transportNumber) → { operation, transportNumber, status }
154
+ // status is 'released' on success (not a raw SAP status code).
155
+ registerTool({
156
+ name: 'sap_transport_release',
157
+ description: 'Release a CTS transport request. '
158
+ + 'All objects must be syntax-clean and ATC-clean before releasing (Rule 9). '
159
+ + 'Requires approval_id.',
160
+ isMutating: true,
161
+ input_schema: {
162
+ type: 'object',
163
+ properties: {
164
+ transport: {
165
+ type: 'string',
166
+ description: 'Transport request number to release (e.g. DEVK900123)',
167
+ },
168
+ approval_id: {
169
+ type: 'string',
170
+ description: 'JWT from request_approval',
171
+ },
172
+ },
173
+ required: ['transport', 'approval_id'],
174
+ },
175
+ handler: async (args, ctx) => {
176
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
177
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.transport, 'release');
178
+ if (!verify.ok) {
179
+ return {
180
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
181
+ is_error: true,
182
+ };
183
+ }
184
+ const startedAt = Date.now();
185
+ // ── Gate 2: Safety confirmation (Rule 9 — transport release is irreversible) ──
186
+ if (isSafetyConfirmEnabled()) {
187
+ const r = await presentSafetyConfirmation({
188
+ rule: 'RULE_9_TRANSPORT_RELEASE',
189
+ op: 'transport_release',
190
+ object: { type: 'TRANSPORT', name: args.transport },
191
+ what_will_happen: `Releases ${args.transport}; objects move to QA queue, transport becomes immutable.`,
192
+ rollback_available: false,
193
+ transport: args.transport,
194
+ });
195
+ if (!r.confirmed) {
196
+ return {
197
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
198
+ is_error: true,
199
+ };
200
+ }
201
+ }
202
+ // ── Release transport ────────────────────────────────────────────────────
203
+ // transportRelease(transportNumber)
204
+ try {
205
+ const result = await ctx.adt.transportRelease(args.transport);
206
+ await auditLog('transportRelease', 'CTS', args.transport, result.status === 'released' ? 'success' : 'error', Date.now() - startedAt);
207
+ if (result.error) {
208
+ return {
209
+ content: JSON.stringify({
210
+ error: 'transport_release_failed',
211
+ detail: result.error,
212
+ status: result.status,
213
+ }),
214
+ is_error: true,
215
+ };
216
+ }
217
+ return {
218
+ content: JSON.stringify({
219
+ released: result.status === 'released',
220
+ transport_number: result.transportNumber,
221
+ status: result.status,
222
+ }),
223
+ };
224
+ }
225
+ catch (err) {
226
+ await auditLog('transportRelease', 'CTS', args.transport, 'error', Date.now() - startedAt);
227
+ return {
228
+ content: JSON.stringify({ error: 'transport_release_failed', detail: String(err) }),
229
+ is_error: true,
230
+ };
231
+ }
232
+ },
233
+ });