@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
@@ -1,3 +1,5 @@
1
+ import { basename } from 'node:path';
2
+ import { isAttachableFilename } from '../projects/workspace.js';
1
3
  /**
2
4
  * Parse `--from @<path>` from a slash-command argument string. The leading `@`
3
5
  * is the file-attach convention used elsewhere in the CLI; supports
@@ -22,3 +24,24 @@ export function parseFromFlag(args) {
22
24
  const rest = (before + after).replace(/^\s+|\s+$/g, '');
23
25
  return { fromPath, rest };
24
26
  }
27
+ /**
28
+ * Forgiveness for the common `--from @<document>` slip.
29
+ *
30
+ * `--from` chains a saved `.cspeach.json` envelope into a downstream skill, but
31
+ * users naturally type it to point at a Word/PDF/text spec ("use this file") —
32
+ * which used to error with a cryptic "unknown target skill" and cancel the
33
+ * whole turn. A document is NEVER a valid `--from` source, so when the token is
34
+ * an attachable doc/text file we silently rewrite `--from @<file>` to a plain
35
+ * `@<file>` attachment (handled by expandTextFileAttachments) and let the turn
36
+ * proceed.
37
+ *
38
+ * Returns the rewritten message + filename, or null when `--from` should run
39
+ * normally (no flag, or the token is a bare envelope fragment / *.cspeach.json).
40
+ */
41
+ export function coerceDocFromFlagToAttachment(message) {
42
+ const { fromPath } = parseFromFlag(message);
43
+ if (!fromPath || !isAttachableFilename(fromPath))
44
+ return null;
45
+ const rewritten = message.replace(/--from\s+(@(?:"[^"]+"|\S+))/, '$1');
46
+ return { rewritten, filename: basename(fromPath) };
47
+ }
@@ -52,23 +52,47 @@ export async function loadSafelist() {
52
52
  return new Set(DEFAULT_SAFELIST);
53
53
  }
54
54
  }
55
+ /**
56
+ * Build the ordered list of filename extensions to probe for a given
57
+ * platform. On Windows, PATHEXT extensions (.EXE, .CMD, .BAT, ...) come
58
+ * BEFORE the bare name — npm drops an extensionless Unix shim next to
59
+ * `pnpm.cmd` (e.g. ...\npm\pnpm) that is NOT spawnable with shell:false,
60
+ * so preferring the real .cmd/.exe makes the existence check report a
61
+ * launchable binary. On Unix, only the bare name (shebang scripts and
62
+ * ELF binaries spawn directly).
63
+ *
64
+ * Exported for tests so the ordering invariant can be asserted without
65
+ * touching the filesystem.
66
+ */
67
+ export function executableExtensions(platform, pathextRaw) {
68
+ if (platform === 'win32') {
69
+ const pathext = (pathextRaw ?? '.COM;.EXE;.BAT;.CMD')
70
+ .toLowerCase()
71
+ .split(';')
72
+ .filter((e) => e.length > 0);
73
+ // PATHEXT extensions FIRST, bare name LAST. The bare name still gets
74
+ // probed (commands that ship only an extensionless binary remain
75
+ // findable) but loses to a co-located .exe/.cmd.
76
+ return [...pathext, ''];
77
+ }
78
+ return [''];
79
+ }
55
80
  /**
56
81
  * Resolve a bare command name to an absolute executable path by walking
57
- * PATH. Tries the bare name + PATHEXT extensions on Windows. Returns
58
- * null if nothing matches anywhere on PATH. Identical to Chunk 3B's
59
- * inlined function — extracted verbatim.
82
+ * PATH. Tries PATHEXT extensions before the bare name on Windows. Returns
83
+ * null if nothing matches anywhere on PATH.
84
+ *
85
+ * This is used ONLY as a pre-flight "is it installed?" existence check so
86
+ * the tool can return a friendly "not installed" error. The ACTUAL spawn
87
+ * goes through cross-spawn with the bare command name — cross-spawn does
88
+ * its own PATH + PATHEXT resolution and invokes .cmd via cmd.exe with
89
+ * correct per-arg escaping (which Node's spawn(shell:false) refuses to do
90
+ * since CVE-2024-27980). `platform`/`pathext` overridable for tests.
60
91
  */
61
- export async function resolveExecutable(name) {
92
+ export async function resolveExecutable(name, platform = process.platform, pathext = process.env.PATHEXT) {
62
93
  const PATH = process.env.PATH ?? process.env.Path ?? '';
63
94
  const dirs = PATH.split(path.delimiter).filter((d) => d.length > 0);
64
- let extensions;
65
- if (process.platform === 'win32') {
66
- const pathext = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').toLowerCase().split(';');
67
- extensions = ['', ...pathext];
68
- }
69
- else {
70
- extensions = [''];
71
- }
95
+ const extensions = executableExtensions(platform, pathext);
72
96
  for (const dir of dirs) {
73
97
  for (const ext of extensions) {
74
98
  const candidate = path.join(dir, name + ext);
@@ -9,11 +9,125 @@
9
9
  */
10
10
  import * as path from 'node:path';
11
11
  import { promises as fsPromises } from 'node:fs';
12
- /** Phase 3 sensitive in-root paths — refused by all filesystem tools. */
13
- export const BLOCKED_PREFIXES = ['.cspeach', '.env', '.git', '.cspeach-design'];
14
12
  /**
15
- * True if `realAbsPath` falls under one of BLOCKED_PREFIXES relative to root.
16
- * Caller should refuse the operation when `blocked` is true.
13
+ * Phase 3 sensitive in-root directory prefixes — refused by all filesystem
14
+ * tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
15
+ *
16
+ * NOTE: the `.env` family is handled separately (by BASENAME, see
17
+ * BLOCKED_BASENAME_RE) so that `.env.local`, `.env.production`, etc. — the
18
+ * files that actually hold secrets in JS projects — are caught too. It is
19
+ * NOT in this list.
20
+ */
21
+ export const BLOCKED_PREFIXES = ['.cspeach', '.git', '.cspeach-design'];
22
+ /**
23
+ * Skill working subdirectories under `.cspeach/` that are CARVED OUT of the
24
+ * denylist so the upgrade/cca/modernize/test skills can read AND write their
25
+ * own detail files (e.g. `.cspeach/upgrades/ZTEST_..._.json`). The namespace
26
+ * cleanup that moved working files from `.abapforge/<domain>/` to
27
+ * `.cspeach/<domain>/` would otherwise be defeated by the `.cspeach` prefix
28
+ * block. See the carve-out in isDenylistedPath for the exact predicate.
29
+ */
30
+ export const CSPEACH_WORK_SUBDIRS = ['cca', 'upgrades', 'modernize', 'tests'];
31
+ /**
32
+ * Phase 3 sensitive BASENAMES — refused regardless of directory depth.
33
+ *
34
+ * Matches `.env` exactly or `.env.<suffix>` (e.g. `.env.local`,
35
+ * `.env.production`, `.env.development`, `.env.test`). The `(\.|$)` boundary
36
+ * means `.environment` / `.envrc` are NOT caught — only the real dotenv
37
+ * family. Case-insensitive (NTFS/APFS resolve `.ENV` to the same inode).
38
+ */
39
+ export const BLOCKED_BASENAME_RE = /^\.env(\.|$)/i;
40
+ /**
41
+ * Human-readable summary of the denylist for error messages.
42
+ * Note: `.cspeach/{cca,upgrades,modernize,tests}/` working files are permitted
43
+ * (skill detail files) — only the rest of `.cspeach/` is blocked.
44
+ */
45
+ export const DENYLIST_DESCRIPTION = [...BLOCKED_PREFIXES, '.env*'].join(', ');
46
+ /**
47
+ * Executable / script file extensions the filesystem write tools refuse to
48
+ * create. Defense-in-depth: shell_exec / background_run only run a safelisted
49
+ * set of commands, but cross-spawn resolves bare names cwd-first on Windows.
50
+ * If the model could write `<safelisted>.cmd` (e.g. git.cmd) into the project
51
+ * tree it could shadow a real PATH binary. Spawning the absolute PATH-resolved
52
+ * path already neutralizes that at the spawn site; this denylist independently
53
+ * stops the executable shim from ever being planted in the first place — see
54
+ * blockedExecutableExtension, which normalizes Windows filename quirks (trailing
55
+ * dots/spaces, alternate data streams) so a name like `git.cmd ` or `git.cmd::$DATA`
56
+ * cannot slip a real `git.cmd` onto disk past the extension check.
57
+ *
58
+ * Legitimate project scaffolding (Fiori writes .js/.json/.xml/.properties/
59
+ * .yaml/.ts) never needs these, so the block does not impede normal work.
60
+ *
61
+ * Case-insensitive — NTFS/APFS resolve `.CMD` to the same file as `.cmd`.
62
+ */
63
+ export const BLOCKED_EXECUTABLE_EXTS = new Set([
64
+ '.cmd', '.bat', '.com', '.exe', '.ps1', '.sh', '.msi', '.scr',
65
+ ]);
66
+ /** Human-readable summary of the executable-extension block for error messages. */
67
+ export const EXECUTABLE_EXT_DESCRIPTION = [...BLOCKED_EXECUTABLE_EXTS].join(', ');
68
+ /** Sentinel ext returned when the basename carries a `:` (ADS / drive-relative). */
69
+ const BLOCKED_STREAM_SENTINEL = ':stream';
70
+ /**
71
+ * Normalize a Windows basename the way the filesystem does on access:
72
+ * - strip ALL trailing dots and spaces (`git.cmd `, `git.cmd.`, `git.cmd...`
73
+ * all open the file `git.cmd`).
74
+ * Returns the normalized basename. A name that is entirely dots/spaces
75
+ * collapses to '' (which extname() then reports as no extension — safe).
76
+ */
77
+ function normalizeWindowsBasename(base) {
78
+ return base.replace(/[. ]+$/, '');
79
+ }
80
+ /**
81
+ * Returns the offending extension (lowercased, with leading dot) if `targetPath`
82
+ * names a file the write tools must refuse, else null. Used by file_write /
83
+ * file_edit to refuse planting an executable shim.
84
+ *
85
+ * Accepts a full path or a bare basename (both call sites pass a resolved
86
+ * absolute path; the directory part is irrelevant — only the basename decides).
87
+ *
88
+ * Two pre-extname normalizations close Windows filename-normalization bypasses
89
+ * that would otherwise land an executable-named file on disk while slipping
90
+ * past a naive `path.extname` check:
91
+ *
92
+ * 1. Trailing dots/spaces — Windows strips these on access, so `git.cmd `
93
+ * and `git.cmd.` / `git.cmd...` all resolve to `git.cmd`. We strip them
94
+ * from the basename before taking the extension.
95
+ *
96
+ * 2. A `:` anywhere in the basename — alternate data stream (`git.cmd:foo`,
97
+ * `git.cmd::$DATA`) or drive-relative path. Either way the on-disk file
98
+ * is executable-named; reject outright. (A drive letter's `:` lives in
99
+ * the DIRECTORY part — e.g. basename of `C:\proj\foo.js` is `foo.js` —
100
+ * so legit absolute paths never false-positive here.)
101
+ */
102
+ export function blockedExecutableExtension(targetPath) {
103
+ const rawBase = path.basename(targetPath);
104
+ // (2) Alternate data stream / drive-relative — the basename must never carry ':'.
105
+ if (rawBase.includes(':'))
106
+ return BLOCKED_STREAM_SENTINEL;
107
+ // (1) Strip trailing dots/spaces the way Windows does on access, THEN take ext.
108
+ const base = normalizeWindowsBasename(rawBase);
109
+ const ext = path.extname(base).toLowerCase();
110
+ return BLOCKED_EXECUTABLE_EXTS.has(ext) ? ext : null;
111
+ }
112
+ /**
113
+ * True if `realAbsPath` is denylisted relative to root — either it falls under
114
+ * one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
115
+ * `.env` family. Caller should refuse the operation when `blocked` is true.
116
+ *
117
+ * CARVE-OUT — the four skill working subdirectories under `.cspeach/`
118
+ * (CSPEACH_WORK_SUBDIRS = cca/upgrades/modernize/tests) are ALLOWED so skills
119
+ * can read+write their own detail files. A path is carved out (NOT blocked)
120
+ * iff ALL hold:
121
+ * 1. its root-relative path (lowercased, '/'-separated) starts with
122
+ * `.cspeach/<sub>/` for some <sub> in CSPEACH_WORK_SUBDIRS — i.e. it is
123
+ * STRICTLY INSIDE one of those subdirs (the subdir entry itself is not
124
+ * carved out), AND
125
+ * 2. its basename does NOT end with `.cspeach.json` (those envelopes are
126
+ * CLI-owned — direct writes stay blocked), AND
127
+ * 3. its basename is NOT in the `.env` secret family (BLOCKED_BASENAME_RE).
128
+ * The carve-out short-circuits ONLY the `.cspeach` prefix. `.git`,
129
+ * `.cspeach-design`, the `.cspeach` root, non-work subdirs, and `.env*`
130
+ * everywhere else remain blocked.
17
131
  */
18
132
  export function isDenylistedPath(realAbsPath, root) {
19
133
  const relFromRoot = path.relative(path.resolve(root), realAbsPath).replace(/\\/g, '/');
@@ -24,12 +138,33 @@ export function isDenylistedPath(realAbsPath, root) {
24
138
  // `relFromRoot` keeps its ORIGINAL case so callers can log what the model
25
139
  // actually requested.
26
140
  const relLower = relFromRoot.toLowerCase();
141
+ const base = path.basename(relFromRoot);
142
+ const baseLower = base.toLowerCase();
143
+ // ── CARVE-OUT: .cspeach/<work-subdir>/… — short-circuits ONLY .cspeach. ──
144
+ // Must be strictly INSIDE a work subdir, not a CLI-owned envelope, and not
145
+ // an .env secret. Placed before the prefix loop so it can return early, but
146
+ // gated so it can never bypass .git / .cspeach-design / the .env block.
147
+ for (const sub of CSPEACH_WORK_SUBDIRS) {
148
+ if (relLower.startsWith('.cspeach/' + sub + '/')) {
149
+ if (!baseLower.endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base)) {
150
+ return { blocked: false, relFromRoot };
151
+ }
152
+ break; // inside a work subdir but failed an exclusion → fall through to block
153
+ }
154
+ }
155
+ // Directory-prefix denylist (.cspeach, .git, .cspeach-design).
27
156
  for (const prefix of BLOCKED_PREFIXES) {
28
157
  const prefixLower = prefix.toLowerCase();
29
158
  if (relLower === prefixLower || relLower.startsWith(prefixLower + '/')) {
30
159
  return { blocked: true, relFromRoot };
31
160
  }
32
161
  }
162
+ // Basename denylist — the .env family at ANY depth (.env, .env.local, …).
163
+ // Using the basename (not a prefix) is what catches `.env.production` while
164
+ // still excluding `.environment-notes.md` and `.envrc`.
165
+ if (BLOCKED_BASENAME_RE.test(base)) {
166
+ return { blocked: true, relFromRoot };
167
+ }
33
168
  return { blocked: false, relFromRoot };
34
169
  }
35
170
  export class PathOutsideRootError extends Error {
@@ -15,7 +15,32 @@
15
15
  export const TOOL_FLAG_PREFIX = 'CSPEACH_TOOL_';
16
16
  /** One-shot per process — set of flag-names we've already warned about. */
17
17
  const warnedFlags = new Set();
18
+ /**
19
+ * In-memory force-enable set, mirroring renderer/tty.ts's headless-override
20
+ * pattern: consulted BEFORE env detection. A startup hook (REPL + one-shot)
21
+ * calls `enableToolFlags([...])` when the persisted `local_files` config key
22
+ * is true, so the flag-gated filesystem tools become visible to listTools()
23
+ * without the user exporting CSPEACH_TOOL_* env vars.
24
+ *
25
+ * Stored by lowercase tool name (the same name listTools()/registerTool use),
26
+ * so `enableToolFlags(['file_read'])` and `isToolFlagOn('FILE_READ')` agree.
27
+ */
28
+ const overrideEnabled = new Set();
29
+ /** Force-enable the named tools' flags for this process (override beats env). */
30
+ export function enableToolFlags(toolNames) {
31
+ for (const name of toolNames)
32
+ overrideEnabled.add(name.toLowerCase());
33
+ }
34
+ /** Clear all in-memory flag overrides. Test-resettable; safe at runtime too. */
35
+ export function clearToolFlagOverrides() {
36
+ overrideEnabled.clear();
37
+ }
18
38
  export function isToolFlagOn(toolName) {
39
+ // Override wins: a startup-applied force-enable makes the tool visible
40
+ // regardless of env. The env path below still runs for un-overridden tools,
41
+ // preserving the CSPEACH_TOOL_<NAME>=on dogfood mechanism + misconfig warn.
42
+ if (overrideEnabled.has(toolName.toLowerCase()))
43
+ return true;
19
44
  const flag = `${TOOL_FLAG_PREFIX}${toolName.toUpperCase()}`;
20
45
  const raw = process.env[flag];
21
46
  if (raw === undefined)
@@ -1,12 +1,19 @@
1
+ import chalk from 'chalk';
1
2
  import { registerTool } from './index.js';
3
+ import { isHeadless } from '../renderer/tty.js';
2
4
  import { effectiveRisk } from '../approvals/risk-floor.js';
3
- import { mintApprovalId } from '../approvals/jwt.js';
5
+ import { mintChangeApproval } from '../approvals/jwt.js';
6
+ import { canonicalApprovalObject } from '../approvals/canonical.js';
4
7
  import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
5
8
  import { loadConfig } from '../config/loader.js';
6
9
  import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
7
10
  import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
8
11
  import { promptAdvisory } from '../approvals/advisory-prompt.js';
9
12
  import { markPlanGateApproved } from '../repl/rule8-detector.js';
13
+ import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
14
+ import { getCurrentTransport } from '../repl/current-transport.js';
15
+ import { displayTransport } from '../approvals/op-labels.js';
16
+ import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
10
17
  /**
11
18
  * Advisory-only replacement for the per-change approval gauntlet.
12
19
  *
@@ -20,6 +27,22 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
20
27
  */
21
28
  export async function handleAdvisoryApproval(args, _ctx) {
22
29
  const render = renderAdvisoryProposal(args);
30
+ // B5 (2026-06-11) — headless: the advisory prompt blocks on stdin that
31
+ // will never answer. Render the proposal (it IS the run's deliverable —
32
+ // the developer reads the captured output and applies manually), skip
33
+ // the prompt, and report a headless-specific directive. NEVER 'applied':
34
+ // nobody confirmed anything.
35
+ if (isHeadless()) {
36
+ console.log(render.displayText);
37
+ console.error(chalk.dim('headless: advisory proposal recorded in output — no prompt, developer applies manually'));
38
+ return {
39
+ content: JSON.stringify({
40
+ advisory_outcome: 'skipped',
41
+ headless: true,
42
+ ai_directive: 'Advisory mode (headless run): the proposal has been rendered into the run output for the developer to review and apply manually later. Nobody confirmed it. Do not attempt any write operations. Summarise what you proposed and continue.',
43
+ }),
44
+ };
45
+ }
23
46
  const outcome = await promptAdvisory({
24
47
  displayText: render.displayText,
25
48
  clipboardText: render.clipboardText,
@@ -46,6 +69,42 @@ export async function handleAdvisoryApproval(args, _ctx) {
46
69
  }),
47
70
  };
48
71
  }
72
+ /**
73
+ * §6 escalation exception — the infra base-object TYPE CODES a no-write/design
74
+ * phase (writes:false) may self-create under abap-plan rule 4a. These are the
75
+ * ADT type codes the model puts in `request_approval` `changes[].type`:
76
+ * - DEVC — package (sap_create_object type DEVC)
77
+ * - CTS — transport (sap_transport_create surfaces as {op:'create',type:'CTS'})
78
+ *
79
+ * §6's third member — number range — is intentionally ABSENT: the
80
+ * sap_number_range_intervals tool is a STUB (registered as "do not call", no
81
+ * ADT write), so a number-range op never reaches request_approval. Adding a
82
+ * marker for it would be dead code. Add its type code here if/when that tool
83
+ * ships and starts flowing through the approval gate.
84
+ *
85
+ * DUPLICATION NOTE (deliberate, for a follow-up): the AUDIT side enforces the
86
+ * SAME set in plan-audit.ts (PLAN_AUDIT_PROMPT_CONTRACT §6) — but as PROSE in
87
+ * an LLM prompt ("DEVC (package), transport, or number range"), not code. There
88
+ * is no clean seam to share a predicate because one enforcement layer is a
89
+ * natural-language contract and the other is TypeScript. The two must be kept
90
+ * in sync by hand; this comment and the audit prose both name the same set.
91
+ */
92
+ const INFRA_BASE_OBJECT_TYPES = new Set(['DEVC', 'CTS']);
93
+ /**
94
+ * TRUE iff EVERY change is a pure infra base-object CREATE — the sanctioned 4a
95
+ * self-create escalation (§6/§7.2). Tightness is the safety property: a single
96
+ * non-infra change (a CLAS/DDLS/… deliverable), any non-create op, or an empty
97
+ * set makes this FALSE, so the writes:false backstop denies it (the correct
98
+ * out-of-lane B3 case). Type is normalized (trim + upper) to match how the
99
+ * model may spell it; op is restricted to 'create' because 4a sanctions
100
+ * self-CREATE only (a transport release / package delete is not an escalation).
101
+ */
102
+ function isPureInfraEscalation(changes) {
103
+ return (!!changes &&
104
+ changes.length > 0 &&
105
+ changes.every((c) => c.op === 'create' &&
106
+ INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase())));
107
+ }
49
108
  registerTool({
50
109
  name: 'request_approval',
51
110
  description: 'Request user approval for one or more SAP mutations. Returns approval_ids (one per change) to be passed as approval_id on matching mutating tool calls.',
@@ -64,7 +123,8 @@ registerTool({
64
123
  op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release'] },
65
124
  object: { type: 'string' },
66
125
  type: { type: 'string' },
67
- diff: { type: 'string', description: 'Unified diff for modify/create ops (optional)' },
126
+ diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
127
+ package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
68
128
  },
69
129
  required: ['op', 'object', 'type'],
70
130
  },
@@ -73,11 +133,52 @@ registerTool({
73
133
  required: ['summary', 'risk', 'changes'],
74
134
  },
75
135
  handler: async (args, ctx) => {
136
+ // Task 10 (agentic-flow, 2026-07-03) — deviation backstop, BEFORE the
137
+ // approval flow renders anything (including the advisory prompt, which
138
+ // would hang an unattended chain just the same). In guarded mode the
139
+ // write-phase stops are computed from the plan's DECLARED writes field;
140
+ // a phase that declared writes:false and requests a write approval
141
+ // anyway is a plan deviation: DENY without prompting, raise the flag
142
+ // the post-turn chain (plan-chain.ts) turns into blocked + STOPPED.
143
+ // Same error mechanism as the headless fail-fast below: a structured
144
+ // error RESULT so the model wraps up gracefully instead of writing.
145
+ // Guarded-gated: step mode / outside phases are byte-identical.
146
+ //
147
+ // §6/§7.2 escalation exception (audit redesign, Task 4): a PURE infra
148
+ // base-object create (DEVC / transport) under writes:false is the
149
+ // sanctioned rule-4a self-create. The very act of routing through
150
+ // request_approval IS the user-consent gate, so it must NOT be denied here
151
+ // — fall through to the normal approval flow (which prompts the user, whose
152
+ // approval is the 4a consent). Guarded-mode clients could otherwise never
153
+ // run 4a: this backstop blocks it BEFORE the audit even runs. TIGHT: the
154
+ // exception fires only when EVERY change is infra; a deliverable
155
+ // (CLAS/DDLS/…) or a mixed set still denies (correct out-of-lane B3).
156
+ if (isGuardedRunActive() &&
157
+ getCurrentPhaseWrites() === false &&
158
+ !isPureInfraEscalation(args.changes)) {
159
+ const detail = PLAN_DEVIATION_DETAIL;
160
+ notePlanDeviation(detail);
161
+ console.error(chalk.red('plan deviation: this phase declared writes:false but requested a write approval — ' +
162
+ 'the write is denied and the guarded chain will stop.'));
163
+ return {
164
+ content: JSON.stringify({
165
+ error: 'plan_deviation',
166
+ detail,
167
+ }),
168
+ is_error: true,
169
+ };
170
+ }
76
171
  let cfg = await loadConfig();
172
+ // Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
173
+ // config overlaid by the session override, clamped to the active alias's
174
+ // role ceiling (prd pins advisory-only; qas caps at approval-gated). With
175
+ // no role configured and no session override this is exactly
176
+ // cfg.write_mode — byte-identical behavior.
177
+ const role = cfg.sap[ctx.sapAlias]?.role;
77
178
  // Advisory-only short-circuit — never mint approval_ids.
78
179
  // The developer applies the change manually in ADT; the AI gets an
79
180
  // ai_directive telling it not to attempt any SAP write tools.
80
- if (cfg.write_mode === 'advisory-only') {
181
+ if (getEffectiveWriteMode(cfg.write_mode, role) === 'advisory-only') {
81
182
  return handleAdvisoryApproval(args, { sapAlias: ctx.sapAlias });
82
183
  }
83
184
  let sapCfg = cfg.sap[ctx.sapAlias];
@@ -88,24 +189,51 @@ registerTool({
88
189
  const changes = args.changes;
89
190
  const declared = args.risk;
90
191
  const eff = effectiveRisk(declared, changes, riskCtx);
91
- // Progressive-disclosure: offer to upgrade never→low on first low-risk encounter.
92
- await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
93
- // Refresh cfg so this approval benefits from a just-enabled auto_approve.
94
- cfg = await loadConfig();
95
- sapCfg = cfg.sap[ctx.sapAlias];
96
- riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
192
+ // Transport shown on the approval surfaces — resolved PER OP to mirror
193
+ // what the write tools will actually do (I2, 2026-07-05 review):
194
+ // create-family tools fall back to the session transport, so the box may
195
+ // show it; modify/delete tools do NOT (resolveWriteTransport uses args
196
+ // only + owning-TR override), so those boxes show only an explicit
197
+ // request transport; activate/release take no transport at all. See
198
+ // displayTransport in approvals/op-labels.ts. The plan gate is a batch
199
+ // summary: session fallback applies only when the batch contains a
200
+ // create (the only op it is true for).
201
+ const sessionTransport = getCurrentTransport();
202
+ const planGateTransport = args.transport
203
+ ?? (changes.some((c) => c.op === 'create') ? sessionTransport ?? undefined : undefined);
204
+ // Task 6 (agentic-flow, 2026-07-03) — guarded plan chains disable
205
+ // auto-approve entirely: the user's auto_approve consent was given for
206
+ // hand-driven turns, not for phases the harness auto-dispatched. When the
207
+ // flag is active, skip the nag (it only advertises auto-approve) and fall
208
+ // through to the interactive prompts below. Flag off ⇒ this whole block
209
+ // is a no-op and behaviour stays byte-identical (skill-mode compat).
210
+ const guardedRun = isGuardedRunActive();
211
+ // Task 3 (ux-wave1) — qas systems never auto-approve: the role's promise
212
+ // is "no unattended writes", and a pre-configured auto_approve would mint
213
+ // silently. Treat auto_approve as 'never' on a qas alias — composes with
214
+ // the guarded-run skip above, same skip points, ladder order unchanged.
215
+ // (prd needs nothing here: the advisory clamp already prevents minting.)
216
+ const qasNoAutoApprove = role === 'qas';
217
+ // Progressive-disclosure: offer to upgrade never→low on first low-risk
218
+ // encounter. B5: skipped in headless — the nag's raw-mode keypress wait
219
+ // would hang on piped/closed stdin just like any other prompt. Skipped on
220
+ // qas too: the nag only advertises auto-approve, which qas disables.
221
+ if (!isHeadless() && !guardedRun && !qasNoAutoApprove) {
222
+ await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
223
+ // Refresh cfg so this approval benefits from a just-enabled auto_approve.
224
+ cfg = await loadConfig();
225
+ sapCfg = cfg.sap[ctx.sapAlias];
226
+ riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
227
+ }
97
228
  // Auto-approve caps — never auto-approve at high, cap change count per risk level.
98
- const autoApproveAllowed = ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
229
+ const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
99
230
  (sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
100
231
  if (autoApproveAllowed) {
101
232
  const ids = [];
102
233
  for (const c of changes) {
103
- ids.push(await mintApprovalId({
104
- object: c.object,
105
- type: c.type,
106
- op: c.op,
107
- session_id: ctx.session.id,
108
- }));
234
+ // Task A3: mint via the canonical path — the JWT stores the same
235
+ // object string the write tools' validators will compare against.
236
+ ids.push(await mintChangeApproval(c, ctx.session.id));
109
237
  }
110
238
  // Tell Rule 8 the user has already covered this batch — they
111
239
  // configured auto-approve, so no per-dispatch re-prompts.
@@ -120,6 +248,29 @@ registerTool({
120
248
  }),
121
249
  };
122
250
  }
251
+ // B5 (2026-06-11) — headless fail-fast. Everything below this point
252
+ // prompts on stdin (plan gate, per-change approvals) and would hang a
253
+ // headless run forever. The headless policy NEVER auto-approves writes:
254
+ // the only non-interactive approval path is the user's own pre-configured
255
+ // auto_approve (handled above — it asks nothing). Return an error RESULT
256
+ // so the model can wrap up gracefully instead of the process hanging.
257
+ if (isHeadless()) {
258
+ console.error(chalk.red('headless: approval requested but headless runs cannot approve writes — ' +
259
+ 'use advisory mode (write_mode = "advisory-only") or run interactively.'));
260
+ return {
261
+ content: JSON.stringify({
262
+ approved: false,
263
+ headless: true,
264
+ reason: 'headless_no_approval',
265
+ message: 'Headless run: writes cannot be approved without a user at the terminal, and headless runs never auto-approve. ' +
266
+ 'No approval IDs were minted — do not attempt any write operations. ' +
267
+ 'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
268
+ effective_risk: eff.level,
269
+ raised_by: eff.raisedBy,
270
+ }),
271
+ is_error: true,
272
+ };
273
+ }
123
274
  // Plan-gate: for multi-change requests, ask once whether to apply all,
124
275
  // review each, or cancel. Apply-all is the default and the happy path —
125
276
  // it skips the redundant per-change prompts that produced the "3 prompts
@@ -127,7 +278,7 @@ registerTool({
127
278
  // gate. Cancel rejects the whole plan.
128
279
  let skipPerChangeGates = false;
129
280
  if (changes.length > 1) {
130
- const mode = await renderPlanGate(args.summary, changes, args.transport, eff);
281
+ const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff);
131
282
  if (mode === 'cancel') {
132
283
  return {
133
284
  content: JSON.stringify({
@@ -159,13 +310,16 @@ registerTool({
159
310
  const rejections = [];
160
311
  const autoApproveActivateFor = new Set();
161
312
  const autoSkipActivateFor = new Set();
313
+ // Sibling matching keys are canonicalized (Task A3) so a decorated modify
314
+ // row ("ZBP_FOO (testclasses include)") still pairs with its bare-named
315
+ // activate sibling ("ZBP_FOO").
162
316
  const writeKeys = new Set();
163
317
  for (const c of changes) {
164
318
  if (c.op === 'modify' || c.op === 'create')
165
- writeKeys.add(`${c.type}:${c.object}`);
319
+ writeKeys.add(`${c.type}:${canonicalApprovalObject(c.object)}`);
166
320
  }
167
321
  for (const c of changes) {
168
- const objKey = `${c.type}:${c.object}`;
322
+ const objKey = `${c.type}:${canonicalApprovalObject(c.object)}`;
169
323
  let outcome;
170
324
  if (skipPerChangeGates) {
171
325
  outcome = { approved: true };
@@ -177,17 +331,14 @@ registerTool({
177
331
  outcome = { approved: false, reason: 'sibling_write_skipped' };
178
332
  }
179
333
  else {
180
- outcome = await renderPerChangeApprovalV3(c, eff, args.transport);
334
+ outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport));
181
335
  }
182
336
  if (outcome.approved) {
183
337
  if (c.op === 'modify' || c.op === 'create')
184
338
  autoApproveActivateFor.add(objKey);
185
- approvalIds.push(await mintApprovalId({
186
- object: c.object,
187
- type: c.type,
188
- op: c.op,
189
- session_id: ctx.session.id,
190
- }));
339
+ // Task A3: mint via the canonical path (same code path the write
340
+ // tools' validators compare against).
341
+ approvalIds.push(await mintChangeApproval(c, ctx.session.id));
191
342
  }
192
343
  else {
193
344
  if (outcome.reason === '__cancel_turn__') {