@cspeach/cli 0.9.0 → 1.0.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 (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  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/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. 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,6 +1,9 @@
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';
@@ -20,6 +23,22 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
20
23
  */
21
24
  export async function handleAdvisoryApproval(args, _ctx) {
22
25
  const render = renderAdvisoryProposal(args);
26
+ // B5 (2026-06-11) — headless: the advisory prompt blocks on stdin that
27
+ // will never answer. Render the proposal (it IS the run's deliverable —
28
+ // the developer reads the captured output and applies manually), skip
29
+ // the prompt, and report a headless-specific directive. NEVER 'applied':
30
+ // nobody confirmed anything.
31
+ if (isHeadless()) {
32
+ console.log(render.displayText);
33
+ console.error(chalk.dim('headless: advisory proposal recorded in output — no prompt, developer applies manually'));
34
+ return {
35
+ content: JSON.stringify({
36
+ advisory_outcome: 'skipped',
37
+ headless: true,
38
+ 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.',
39
+ }),
40
+ };
41
+ }
23
42
  const outcome = await promptAdvisory({
24
43
  displayText: render.displayText,
25
44
  clipboardText: render.clipboardText,
@@ -88,24 +107,25 @@ registerTool({
88
107
  const changes = args.changes;
89
108
  const declared = args.risk;
90
109
  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';
110
+ // Progressive-disclosure: offer to upgrade never→low on first low-risk
111
+ // encounter. B5: skipped in headless — the nag's raw-mode keypress wait
112
+ // would hang on piped/closed stdin just like any other prompt.
113
+ if (!isHeadless()) {
114
+ await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
115
+ // Refresh cfg so this approval benefits from a just-enabled auto_approve.
116
+ cfg = await loadConfig();
117
+ sapCfg = cfg.sap[ctx.sapAlias];
118
+ riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
119
+ }
97
120
  // Auto-approve caps — never auto-approve at high, cap change count per risk level.
98
121
  const autoApproveAllowed = ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
99
122
  (sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
100
123
  if (autoApproveAllowed) {
101
124
  const ids = [];
102
125
  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
- }));
126
+ // Task A3: mint via the canonical path — the JWT stores the same
127
+ // object string the write tools' validators will compare against.
128
+ ids.push(await mintChangeApproval(c, ctx.session.id));
109
129
  }
110
130
  // Tell Rule 8 the user has already covered this batch — they
111
131
  // configured auto-approve, so no per-dispatch re-prompts.
@@ -120,6 +140,29 @@ registerTool({
120
140
  }),
121
141
  };
122
142
  }
143
+ // B5 (2026-06-11) — headless fail-fast. Everything below this point
144
+ // prompts on stdin (plan gate, per-change approvals) and would hang a
145
+ // headless run forever. The headless policy NEVER auto-approves writes:
146
+ // the only non-interactive approval path is the user's own pre-configured
147
+ // auto_approve (handled above — it asks nothing). Return an error RESULT
148
+ // so the model can wrap up gracefully instead of the process hanging.
149
+ if (isHeadless()) {
150
+ console.error(chalk.red('headless: approval requested but headless runs cannot approve writes — ' +
151
+ 'use advisory mode (write_mode = "advisory-only") or run interactively.'));
152
+ return {
153
+ content: JSON.stringify({
154
+ approved: false,
155
+ headless: true,
156
+ reason: 'headless_no_approval',
157
+ message: 'Headless run: writes cannot be approved without a user at the terminal, and headless runs never auto-approve. ' +
158
+ 'No approval IDs were minted — do not attempt any write operations. ' +
159
+ 'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
160
+ effective_risk: eff.level,
161
+ raised_by: eff.raisedBy,
162
+ }),
163
+ is_error: true,
164
+ };
165
+ }
123
166
  // Plan-gate: for multi-change requests, ask once whether to apply all,
124
167
  // review each, or cancel. Apply-all is the default and the happy path —
125
168
  // it skips the redundant per-change prompts that produced the "3 prompts
@@ -159,13 +202,16 @@ registerTool({
159
202
  const rejections = [];
160
203
  const autoApproveActivateFor = new Set();
161
204
  const autoSkipActivateFor = new Set();
205
+ // Sibling matching keys are canonicalized (Task A3) so a decorated modify
206
+ // row ("ZBP_FOO (testclasses include)") still pairs with its bare-named
207
+ // activate sibling ("ZBP_FOO").
162
208
  const writeKeys = new Set();
163
209
  for (const c of changes) {
164
210
  if (c.op === 'modify' || c.op === 'create')
165
- writeKeys.add(`${c.type}:${c.object}`);
211
+ writeKeys.add(`${c.type}:${canonicalApprovalObject(c.object)}`);
166
212
  }
167
213
  for (const c of changes) {
168
- const objKey = `${c.type}:${c.object}`;
214
+ const objKey = `${c.type}:${canonicalApprovalObject(c.object)}`;
169
215
  let outcome;
170
216
  if (skipPerChangeGates) {
171
217
  outcome = { approved: true };
@@ -182,12 +228,9 @@ registerTool({
182
228
  if (outcome.approved) {
183
229
  if (c.op === 'modify' || c.op === 'create')
184
230
  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
- }));
231
+ // Task A3: mint via the canonical path (same code path the write
232
+ // tools' validators compare against).
233
+ approvalIds.push(await mintChangeApproval(c, ctx.session.id));
191
234
  }
192
235
  else {
193
236
  if (outcome.reason === '__cancel_turn__') {
@@ -25,8 +25,55 @@ import chalk from 'chalk';
25
25
  import { registerTool } from './index.js';
26
26
  import { input, select, checkbox } from '@inquirer/prompts';
27
27
  import { withInquirer } from '../repl/inquirer-guard.js';
28
- import { shouldUseInk } from '../renderer/tty.js';
28
+ import { shouldUseInk, isHeadless } from '../renderer/tty.js';
29
29
  import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
30
+ /**
31
+ * Label heuristic for rule 2 of pickHeadlessChoices: a string counts as
32
+ * "recommended" only when it contains the word AND that word is not part of
33
+ * a negation — skills write both "RAP (recommended)" and "SEGW (not
34
+ * recommended)" and the latter must NEVER win the auto-pick.
35
+ */
36
+ function labelSaysRecommended(s) {
37
+ return /\brecommended\b/i.test(s) && !/\b(?:not|non|never)[\s-]+recommended\b/i.test(s);
38
+ }
39
+ /**
40
+ * B5 (2026-06-11) — headless auto-pick rule for choice/multi questions.
41
+ *
42
+ * Rule order (the fired rule is reported so logs and the tool result are
43
+ * honest about WHY an option was picked):
44
+ * 1. `recommended: true` on one or more choices → those choices
45
+ * (rule: 'recommended').
46
+ * 2. No explicit flag, but a label/value contains the word "recommended"
47
+ * (skills frequently write "RAP (recommended)") → those choices
48
+ * (rule: 'recommended-label'). Negated forms ("not recommended",
49
+ * "non-recommended", "never recommended") do NOT match.
50
+ * 3. Nothing marked → the FIRST option (rule: 'first-option').
51
+ *
52
+ * For kind=choice only the first match is taken; for kind=multi the whole
53
+ * recommended set is taken (or the first option when nothing is marked).
54
+ *
55
+ * Contract: `choices` must be non-empty — the handler guards with
56
+ * `choices.length > 0` before calling (an empty array means there is nothing
57
+ * to pick and the headless path returns the free-text error instead). An
58
+ * empty array here is a programming error, so this throws rather than
59
+ * returning `[undefined]`.
60
+ *
61
+ * Exported for unit testing.
62
+ */
63
+ export function pickHeadlessChoices(kind, choices) {
64
+ if (choices.length === 0) {
65
+ throw new Error('pickHeadlessChoices requires at least one choice — callers must guard (see ask_question handler)');
66
+ }
67
+ const flagged = choices.filter((c) => c.recommended === true);
68
+ if (flagged.length > 0) {
69
+ return { picked: kind === 'multi' ? flagged : [flagged[0]], rule: 'recommended' };
70
+ }
71
+ const labelled = choices.filter((c) => labelSaysRecommended(c.label) || labelSaysRecommended(c.value));
72
+ if (labelled.length > 0) {
73
+ return { picked: kind === 'multi' ? labelled : [labelled[0]], rule: 'recommended-label' };
74
+ }
75
+ return { picked: [choices[0]], rule: 'first-option' };
76
+ }
30
77
  registerTool({
31
78
  name: 'ask_question',
32
79
  description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction.",
@@ -59,6 +106,10 @@ registerTool({
59
106
  properties: {
60
107
  value: { type: 'string', description: 'Machine value returned if the user picks this.' },
61
108
  label: { type: 'string', description: 'Human-readable label shown in the picker.' },
109
+ recommended: {
110
+ type: 'boolean',
111
+ description: 'Mark the option you would recommend. In headless (non-interactive) runs this option is auto-picked; in interactive runs it is informational only.',
112
+ },
62
113
  },
63
114
  required: ['value', 'label'],
64
115
  },
@@ -71,9 +122,50 @@ registerTool({
71
122
  const question = String(args.question);
72
123
  const context = args.context ? String(args.context) : undefined;
73
124
  const kind = args.kind;
74
- const choices = Array.isArray(args.choices)
75
- ? args.choices
76
- : [];
125
+ const choices = Array.isArray(args.choices) ? args.choices : [];
126
+ // B5 (2026-06-11) — headless answer policy (defects D1/D1b). In a
127
+ // headless run (one-shot / piped stdin) NO prompt may ever block on
128
+ // stdin: battery S2 Pass A stalled forever on exactly this path.
129
+ // - choice/multi WITH options: auto-pick (recommended > recommended-
130
+ // label > first option), log to stderr, and tell the model via
131
+ // `auto_answered: true` that the user did NOT really answer.
132
+ // - free text (or choice/multi without options): no auto-answer is
133
+ // possible — return an error RESULT to the model (not a hard exit)
134
+ // so it can finish gracefully with documented assumptions, the way
135
+ // S2 Pass B completed when answers were supplied up front.
136
+ if (isHeadless()) {
137
+ if ((kind === 'choice' || kind === 'multi') && choices.length > 0) {
138
+ const { picked, rule } = pickHeadlessChoices(kind, choices);
139
+ const answer = picked.map((c) => c.value).join(',');
140
+ console.error(chalk.dim(`headless: auto-answered '${question}' → '${answer}' (${rule})`));
141
+ return {
142
+ content: JSON.stringify({
143
+ id,
144
+ kind,
145
+ answer,
146
+ headless: true,
147
+ auto_answered: true,
148
+ auto_answer_rule: rule,
149
+ note: 'Headless run: the user did not actually answer — this option was auto-selected ' +
150
+ `(${rule}). Proceed with it and clearly note the assumption in your final output.`,
151
+ }),
152
+ };
153
+ }
154
+ console.error(chalk.yellow(`headless: cannot answer free-text question '${question}' — ` +
155
+ 'provide this detail in the prompt or run interactively.'));
156
+ return {
157
+ content: JSON.stringify({
158
+ id,
159
+ kind,
160
+ headless: true,
161
+ error: 'headless_unanswerable',
162
+ message: `Headless run: no user is available to answer this free-text question: "${question}". ` +
163
+ 'Either proceed with a sensible assumption and CLEARLY document it in your final output, ' +
164
+ 'or finish by telling the user to include this detail in the prompt or run interactively.',
165
+ }),
166
+ is_error: true,
167
+ };
168
+ }
77
169
  // 2026-05-01 redesign: when the LLM provides choices, use a real
78
170
  // arrow-key picker (select / checkbox) instead of free-text input.
79
171
  // The previous "always free-text, smart-resolve digits" pattern produced
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `system_capability` — the data-driven capability gate behind Pillar A.
3
+ *
4
+ * Given a capability id (cds.viewEntity | rap.managed | abap.filterExpr |
5
+ * abap.inlineData) or a raw ABAP feature name, returns a yes/no/with-fallback
6
+ * verdict for the CONNECTED SAP release, read out of the committed ABAP
7
+ * feature-matrix snapshot. Skills call this instead of hardcoding version
8
+ * prose like "FILTER needs 7.50".
9
+ *
10
+ * Read-only: it consults system-info (which caches/queries CVERS once) and a
11
+ * static snapshot — it never writes to SAP. Flag-gated with the other
12
+ * local-build tools (rides the `local_build` switch via LOCAL_BUILD_TOOLS).
13
+ *
14
+ * See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md.
15
+ */
16
+ import { registerTool } from '../index.js';
17
+ import { getSapSystemInfo } from '../../sap/system-info.js';
18
+ import { loadCapabilityMatrix } from '../../sap/capability-matrix.js';
19
+ import { resolveCapability, CAPABILITY_REGISTRY, } from '../../sap/capability.js';
20
+ function isRegistryId(s) {
21
+ return Object.prototype.hasOwnProperty.call(CAPABILITY_REGISTRY, s);
22
+ }
23
+ async function systemCapabilityHandler(args, ctx) {
24
+ const capability = args?.capability;
25
+ if (typeof capability !== 'string' || capability.trim() === '') {
26
+ return { content: 'error: capability (non-empty string) is required', is_error: true };
27
+ }
28
+ const info = await getSapSystemInfo(ctx.adt, ctx.sapAlias);
29
+ if (!info) {
30
+ // Release undetectable. Build a minimal verdict-like JSON: with-fallback
31
+ // when the caller asked for a registry id that HAS a fallback, else no.
32
+ const fallback = isRegistryId(capability)
33
+ ? CAPABILITY_REGISTRY[capability].fallback || undefined
34
+ : undefined;
35
+ const feature = isRegistryId(capability) ? CAPABILITY_REGISTRY[capability].feature : capability;
36
+ const verdict = {
37
+ capability,
38
+ feature,
39
+ status: fallback ? 'with-fallback' : 'no',
40
+ reason: 'SAP release could not be detected — verify the system manually before relying on this feature',
41
+ release: 'unknown',
42
+ column: null,
43
+ source: 'matrix-snapshot',
44
+ ...(fallback ? { fallback } : {}),
45
+ };
46
+ return { content: JSON.stringify(verdict) };
47
+ }
48
+ const verdict = resolveCapability(info, loadCapabilityMatrix(), capability);
49
+ return { content: JSON.stringify(verdict) };
50
+ }
51
+ registerTool({
52
+ name: 'system_capability',
53
+ description: 'Returns yes / no / with-fallback for whether the CONNECTED SAP release supports a given ABAP '
54
+ + 'capability, read out of the committed ABAP feature-matrix snapshot — so skills stop hardcoding '
55
+ + 'version prose like "FILTER needs 7.50". Pass a capability gate id (cds.viewEntity | rap.managed | '
56
+ + 'abap.filterExpr | abap.inlineData) OR a raw ABAP feature name from the matrix. The verdict carries '
57
+ + 'the feature consulted, the matrix column used, the reason, and (only for with-fallback) a concrete '
58
+ + 'fallback instruction. Read-only — never writes to SAP.',
59
+ isMutating: false,
60
+ category: 'sap',
61
+ flagGated: true,
62
+ input_schema: {
63
+ type: 'object',
64
+ properties: {
65
+ capability: {
66
+ type: 'string',
67
+ description: 'A capability gate id (cds.viewEntity | rap.managed | abap.filterExpr | abap.inlineData) '
68
+ + 'OR a raw ABAP feature name from the matrix.',
69
+ },
70
+ },
71
+ required: ['capability'],
72
+ },
73
+ handler: systemCapabilityHandler,
74
+ });