@cspeach/cli 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ // cspeach-cli/src/tools/local-build.ts
2
+ /**
3
+ * Startup wiring for the persisted `local_build` config toggle (2026-06-13,
4
+ * renamed + broadened from `local_files`).
5
+ *
6
+ * When the effective toggle is on (see config/loader.ts resolveLocalBuild —
7
+ * `local_build ?? local_files` for backward compat), the otherwise flag-gated
8
+ * LOCAL BUILD tools are force-enabled via the in-memory override in `_flag.ts`,
9
+ * BEFORE the first listTools()/turn. The enable set is now:
10
+ *
11
+ * filesystem: file_read, file_write, file_edit, glob, grep
12
+ * shell: shell_exec
13
+ * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
14
+ * fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
15
+ * fiori_sample_search, fiori_sample_get
16
+ * extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
17
+ * subagent: agent_run (Task 7, 2026-07-03 — customers get subagent
18
+ * dispatch when local_build is on; plan phases force its
19
+ * read-only tool filter regardless of the model's args)
20
+ *
21
+ * This is what makes /abap-fiori-build runnable end to end — it writes local
22
+ * app files (FS tools), runs safelisted build CLIs (shell), AND scaffolds /
23
+ * composes the app via the built-in Fiori engine (fiori_* tools). The fiori_*
24
+ * tools replace the old repo-only `pnpm fiori scaffold` path so shipped-binary
25
+ * customers get the same deterministic engine output without pnpm or the repo.
26
+ *
27
+ * The toggle is PERSISTED (config.toml), not re-consented each session, so
28
+ * `applyLocalBuildConfig` returns an announce line the entry point prints
29
+ * whenever the tools are live — the user must always see that local file access
30
+ * AND command execution are on this session (trust-visibility requirement).
31
+ *
32
+ * The blast radius is honestly named: the announce line surfaces the cwd (where
33
+ * read/write/edit operate) AND the shell safelist (which commands shell_exec may
34
+ * run). The FS tools' own cwd-sandbox (writes confined to the project root;
35
+ * .cspeach/.env/.git denylisted) and shell_exec's argv-only safelist enforcement
36
+ * still apply regardless of this toggle — enabling shell_exec only makes it
37
+ * visible, it does NOT make it unrestricted.
38
+ */
39
+ import { enableToolFlags } from './_flag.js';
40
+ // Source-of-truth for the shell safelist names surfaced in the announce line.
41
+ // Imported (not hand-copied) so the announce can never drift from the actual
42
+ // commands shell_exec permits. If shell_exec's DEFAULT_SAFELIST changes, this
43
+ // announce string follows automatically.
44
+ import { DEFAULT_SAFELIST } from './_command-shared.js';
45
+ /**
46
+ * The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ,
47
+ * shell_exec → CSPEACH_TOOL_SHELL_EXEC). `shell_exec` matches the tool's
48
+ * registered name in tools/shell/shell_exec.ts, so isToolFlagOn('shell_exec')
49
+ * checks the same override key enableToolFlags(['shell_exec']) sets.
50
+ */
51
+ export const LOCAL_BUILD_TOOLS = [
52
+ 'file_read',
53
+ 'read_document',
54
+ 'file_write',
55
+ 'file_edit',
56
+ 'glob',
57
+ 'grep',
58
+ 'shell_exec',
59
+ 'fiori_scaffold',
60
+ 'fiori_apply',
61
+ 'fiori_list',
62
+ 'fiori_deploy_config',
63
+ 'fiori_scaffold_fe',
64
+ 'fiori_fe_extend',
65
+ 'fiori_render_smoke',
66
+ 'fiori_sample_search',
67
+ 'fiori_sample_get',
68
+ 'extend_model_insert',
69
+ 'system_capability',
70
+ 'agent_run',
71
+ ];
72
+ /**
73
+ * Apply the persisted `local_build` toggle. When on, force-enables the FS +
74
+ * shell build tools and returns a one-line announce string (undefined when off,
75
+ * so the caller prints nothing). `cwd` is surfaced so the user sees exactly
76
+ * which directory tree read/write/edit operate under; the shell safelist is
77
+ * surfaced so the user sees which commands shell_exec may run.
78
+ */
79
+ export function applyLocalBuildConfig(localBuild, cwd) {
80
+ if (localBuild !== true)
81
+ return undefined;
82
+ enableToolFlags([...LOCAL_BUILD_TOOLS]);
83
+ const safelist = [...DEFAULT_SAFELIST].join(', ');
84
+ return (`local build tools enabled — file read/write/edit under ${cwd} ` +
85
+ `+ safelisted commands (${safelist}) — cspeach config set local_build off to disable`);
86
+ }
@@ -0,0 +1,31 @@
1
+ // cspeach-cli/src/tools/local-files.ts
2
+ /**
3
+ * Startup wiring for the persisted `local_files` config toggle.
4
+ *
5
+ * When `cfg.local_files === true`, the otherwise flag-gated filesystem tools
6
+ * (file_read/file_write/file_edit/glob/grep) are force-enabled via the
7
+ * in-memory override in `_flag.ts`, BEFORE the first listTools()/turn. This is
8
+ * what makes /abap-fiori-build runnable — it writes local app files.
9
+ *
10
+ * The toggle is PERSISTED (config.toml), not re-consented each session, so
11
+ * `applyLocalFilesConfig` returns an announce line the entry point prints
12
+ * whenever the tools are live — the user must always see that local file
13
+ * access is on this session (trust-visibility requirement). The FS tools'
14
+ * own cwd-sandbox (writes confined to the project root; .cspeach/.env/.git
15
+ * denylisted) still applies regardless of this toggle.
16
+ */
17
+ import { enableToolFlags } from './_flag.js';
18
+ /** The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ). */
19
+ export const LOCAL_FILE_TOOLS = ['file_read', 'file_write', 'file_edit', 'glob', 'grep'];
20
+ /**
21
+ * Apply the persisted `local_files` toggle. When on, force-enables the FS
22
+ * tools and returns a one-line announce string (undefined when off, so the
23
+ * caller prints nothing). `cwd` is surfaced so the user sees exactly which
24
+ * directory tree read/write/edit operate under.
25
+ */
26
+ export function applyLocalFilesConfig(localFiles, cwd) {
27
+ if (localFiles !== true)
28
+ return undefined;
29
+ enableToolFlags([...LOCAL_FILE_TOOLS]);
30
+ return `local file tools enabled (read/write/edit under ${cwd}) — cspeach config set local_files off to disable`;
31
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared input-resolution for the two merge tools (cca_merge,
3
+ * upgrade_merge_progress).
4
+ *
5
+ * Inputs arrive as the same `@<token>` forms the rest of the CLI uses
6
+ * (workspace.ts:resolveAtToken): absolute path, relative path, or a bare
7
+ * workspace-filename fragment. A leading '@' is stripped — skills pass
8
+ * the user's `--inputs @file` tokens through verbatim.
9
+ *
10
+ * Absolute / relative paths are resolved WITHOUT touching the workspace
11
+ * (no ensureWorkspace side effects from a tool call); only bare tokens
12
+ * trigger the workspace lookup.
13
+ */
14
+ import { basename, isAbsolute, join } from 'node:path';
15
+ import { readProjectFile } from '../../projects/status.js';
16
+ import { resolveAtTokenAsync, formatProjectFileList } from '../../projects/workspace.js';
17
+ export function labelFor(path) {
18
+ return basename(path).replace(/\.cspeach\.json$/i, '');
19
+ }
20
+ /**
21
+ * Resolve one `@<token>` to an absolute path. Throws with a
22
+ * user-presentable message on ambiguity / not-found.
23
+ */
24
+ export async function resolveEnvelopeToken(rawToken, cwd) {
25
+ const token = rawToken.startsWith('@') ? rawToken.slice(1) : rawToken;
26
+ if (token.length === 0)
27
+ throw new Error('empty input token');
28
+ if (isAbsolute(token))
29
+ return token;
30
+ if (token.includes('/') || token.includes('\\'))
31
+ return join(cwd, token);
32
+ const resolved = await resolveAtTokenAsync(token, cwd);
33
+ if (resolved.kind === 'path')
34
+ return resolved.path;
35
+ if (resolved.kind === 'ambiguous') {
36
+ throw new Error(`'@${token}' matches multiple workspace files — be more specific:\n`
37
+ + formatProjectFileList(resolved.matches, '(workspace)'));
38
+ }
39
+ throw new Error(`'@${token}' matches no file in the workspace (${resolved.workspace})`);
40
+ }
41
+ /**
42
+ * Resolve + read + type-check one envelope input. readProjectFile
43
+ * already validates the envelope schema and throws readable errors.
44
+ */
45
+ export async function loadEnvelope(rawToken, cwd, expectedType) {
46
+ const path = await resolveEnvelopeToken(rawToken, cwd);
47
+ let envelope;
48
+ try {
49
+ envelope = readProjectFile(path);
50
+ }
51
+ catch (e) {
52
+ throw new Error(`cannot read '${rawToken}': ${e instanceof Error ? e.message : String(e)}`);
53
+ }
54
+ if (envelope.artefactType !== expectedType) {
55
+ throw new Error(`'${rawToken}' is a '${envelope.artefactType}' envelope — this merge needs '${expectedType}' inputs.`);
56
+ }
57
+ return { path, label: labelFor(path), envelope };
58
+ }
59
+ /**
60
+ * The presentation contract returned to the model alongside the merged
61
+ * numbers. Keeping the model away from arithmetic is the whole point
62
+ * (battery S4 defects D6/D7) — it presents, it does not recompute.
63
+ */
64
+ export const MANIFEST_PASTE_INSTRUCTIONS = 'Present merged_summary, per_input and warnings to the user with your own judgment commentary '
65
+ + '(conflict arbitration advice, next steps). Then paste manifest_block VERBATIM — unchanged, '
66
+ + 'byte for byte — as the LAST block of your reply so the CLI save hook can persist the merged '
67
+ + 'envelope. Do NOT recompute, reformat, re-sort, or edit any number or row in the block: the '
68
+ + 'extractor re-validates counts against rows on save and a hand-edited block fails loudly.';
@@ -0,0 +1,164 @@
1
+ /**
2
+ * cca_merge — deterministic reconciliation of N parallel /abap-cca
3
+ * assessment envelopes (the /abap-cca-merge skill's execution seam).
4
+ *
5
+ * DESIGN (2026-06-12, team-chain): the skill used to ask the MODEL to
6
+ * reconcile envelopes by hand; battery S4 (D6/D7) proved models can't
7
+ * keep counts consistent with rows. This tool runs
8
+ * src/projects/merge-cca.ts and returns the merged content, warnings,
9
+ * AND a pre-rendered manifest block the model pastes VERBATIM. The
10
+ * normal save hook then persists the envelope — persistence
11
+ * deliberately stays on the one existing save path (confirm prompt,
12
+ * workspace resolution, validation, version semantics) instead of the
13
+ * tool minting envelopes through a second door. A merge is a NEW
14
+ * artefact superseding its inputs (new identity is correct — there is
15
+ * no prior chain to extend, unlike the plan-revision case in
16
+ * save-command.ts), and the extractor's counts-vs-rows validation acts
17
+ * as a tamper backstop if the model edits the pasted block.
18
+ *
19
+ * NOT flag-gated: the abap-cca-merge skill depends on this tool; gating
20
+ * it off would silently break the multi-consultant chain.
21
+ *
22
+ * Stays isMutating: false despite writing the merged detail file: the
23
+ * write is the merge's own local artifact under .cspeach/ (derived
24
+ * purely from envelopes the user supplied), the same trust level as the
25
+ * save hook persisting the envelope — no SAP or user-file mutation.
26
+ */
27
+ import { mkdirSync, writeFileSync } from 'node:fs';
28
+ import { dirname, isAbsolute, join } from 'node:path';
29
+ import { registerTool } from '../index.js';
30
+ import { mergeCcaAssessments, renderCcaMergeManifest, } from '../../projects/merge-cca.js';
31
+ import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
32
+ export async function ccaMergeHandler(args, ctx) {
33
+ if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
34
+ return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
35
+ }
36
+ const tokens = args.inputs;
37
+ if (tokens.length === 1) {
38
+ return {
39
+ content: 'error: only one input supplied — a single assessment needs no merge. Use the file directly.',
40
+ is_error: true,
41
+ };
42
+ }
43
+ if (tokens.length < 2) {
44
+ return { content: 'error: at least 2 input envelopes are required.', is_error: true };
45
+ }
46
+ if (args.allow_cross_project !== undefined && typeof args.allow_cross_project !== 'boolean') {
47
+ return { content: 'error: allow_cross_project must be a boolean.', is_error: true };
48
+ }
49
+ // load + type-check every input before merging — fail with the precise file
50
+ const inputs = [];
51
+ const perInput = [];
52
+ for (const token of tokens) {
53
+ let resolved;
54
+ try {
55
+ resolved = await loadEnvelope(token, ctx.cwd, 'cca-assessment');
56
+ }
57
+ catch (e) {
58
+ return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
59
+ }
60
+ const content = resolved.envelope.content;
61
+ inputs.push({ label: resolved.label, content });
62
+ perInput.push({
63
+ file: resolved.path,
64
+ label: resolved.label,
65
+ project_id: content.projectId,
66
+ scope: content.scope,
67
+ total_objects: content.summary.totalObjects,
68
+ keep: content.summary.keep,
69
+ fix: content.summary.fix,
70
+ retire: content.summary.retire,
71
+ redesign: content.summary.redesign,
72
+ uncategorized: content.summary.uncategorized,
73
+ truncated: content.classificationsTruncated === true,
74
+ });
75
+ }
76
+ let merged;
77
+ let warnings;
78
+ try {
79
+ ({ merged, warnings } = mergeCcaAssessments(inputs, {
80
+ allowCrossProject: args.allow_cross_project === true,
81
+ }));
82
+ }
83
+ catch (e) {
84
+ return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
85
+ }
86
+ const title = `${inputs[0].content.projectId} — Merged (${inputs.length} slices)`;
87
+ // ── write the merged detail file BEFORE emitting the manifest ──────────
88
+ // The manifest's detail_path must point at a REAL file (the rule this
89
+ // branch added for /abap-cca emitters applies to the merge too). The
90
+ // manifest table caps at 50 rows; this file always carries every merged
91
+ // row. Minimal documented shape (a merge supersedes its inputs, so this
92
+ // is a fresh consolidated detail, not a phase-state project.json):
93
+ // {
94
+ // generatedAt: ISO timestamp of the merge
95
+ // projectId / scope / atcVariant / summary: as in the manifest
96
+ // objects: ALL merged classification rows (uncapped)
97
+ // mergedFrom: the input envelopes [{ file, label }]
98
+ // }
99
+ let detailWritten = true;
100
+ const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
101
+ try {
102
+ mkdirSync(dirname(detailAbs), { recursive: true });
103
+ writeFileSync(detailAbs, JSON.stringify({
104
+ generatedAt: new Date().toISOString(),
105
+ projectId: merged.projectId,
106
+ scope: merged.scope,
107
+ atcVariant: merged.atcVariant,
108
+ summary: merged.summary,
109
+ objects: merged.classifications,
110
+ mergedFrom: perInput.map((p) => ({ file: p.file, label: p.label })),
111
+ }, null, 2), 'utf8');
112
+ }
113
+ catch (e) {
114
+ detailWritten = false;
115
+ warnings.push(`DETAIL WRITE FAILED: could not write the merged detail file '${merged.detailPath}' `
116
+ + `(${e instanceof Error ? e.message : String(e)}). The manifest block omits detail_path — `
117
+ + `a manifest must never point at a file that does not exist — so the save will refuse it. `
118
+ + `Fix the path/permissions and re-run the merge.`);
119
+ }
120
+ const manifestBlock = renderCcaMergeManifest(merged, title, { omitDetailPath: !detailWritten });
121
+ return {
122
+ content: JSON.stringify({
123
+ merged_summary: merged.summary,
124
+ merged_project_id: merged.projectId,
125
+ merged_scope: merged.scope,
126
+ atc_variant: merged.atcVariant,
127
+ ...(detailWritten ? { detail_path: merged.detailPath } : {}),
128
+ per_input: perInput,
129
+ warnings,
130
+ // full merged rows — the manifest block caps at 50; nothing is lost
131
+ // from the transcript even for large estates.
132
+ merged_classifications: merged.classifications,
133
+ manifest_block: manifestBlock,
134
+ instructions: MANIFEST_PASTE_INSTRUCTIONS,
135
+ }, null, 2),
136
+ };
137
+ }
138
+ registerTool({
139
+ name: 'cca_merge',
140
+ description: 'Deterministically merge 2+ parallel /abap-cca assessment envelopes (.cspeach.json) into one consolidated '
141
+ + 'cca-assessment. Validates project identity and ATC variant, detects scope overlaps and classification '
142
+ + 'conflicts (most-restrictive wins: retire > redesign > fix > keep), recomputes all counts from the merged '
143
+ + 'rows, writes the full merged rows to a new detail file under .cspeach/cca/projects/, and returns warnings '
144
+ + 'plus a ready-made csforge:cca-manifest block to paste verbatim. '
145
+ + 'Inputs accept absolute/relative paths or bare workspace @-tokens.',
146
+ isMutating: false,
147
+ category: 'project',
148
+ input_schema: {
149
+ type: 'object',
150
+ properties: {
151
+ inputs: {
152
+ type: 'array',
153
+ items: { type: 'string' },
154
+ description: '2+ cca-assessment envelope references (path or @-token), one per consultant slice.',
155
+ },
156
+ allow_cross_project: {
157
+ type: 'boolean',
158
+ description: 'Accept inputs with different projectId (disjoint --scope slices of the same system). Default false.',
159
+ },
160
+ },
161
+ required: ['inputs'],
162
+ },
163
+ handler: (args, ctx) => ccaMergeHandler(args, ctx),
164
+ });
@@ -4,7 +4,7 @@
4
4
  * Fetches a SKILL.md body via ctx.skillSource (the managed / manifest /
5
5
  * bundled SkillSource abstraction the agent loop already uses). Lets the
6
6
  * agent inspect another skill's playbook for orchestration patterns
7
- * (e.g. /abap-incident calling /abap-dump-analyze inline) without
7
+ * (e.g. /abap-incident calling /abap-dump inline) without
8
8
  * triggering a full skill dispatch.
9
9
  *
10
10
  * Caching: in-process Map-based LRU. Keyed by validated skill name.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * upgrade_merge_progress — deterministic reconciliation of N parallel
3
+ * /abap-upgrade-fix progress envelopes (the /abap-upgrade-merge skill's
4
+ * execution seam). See cca_merge.ts for the design rationale: the tool
5
+ * does the arithmetic (battery S4 D6/D7), the model presents; the
6
+ * pre-rendered manifest block is pasted verbatim and persisted by the
7
+ * normal save hook.
8
+ *
9
+ * Baseline discipline:
10
+ * - every input's baselineRef.sha256 must match the merge baseline —
11
+ * HARD ERROR otherwise.
12
+ * - `baseline` arg (recommended): the originating upgrade-scan
13
+ * envelope. The tool hashes the scan's detail file (that is what
14
+ * baseline_sha256 refers to, per the upgrade-fix manifest contract)
15
+ * and uses the scan's findings to fill never-touched findings as
16
+ * 'pending', so the merged view covers the whole baseline.
17
+ * - without `baseline`, the first input's baselineRef is taken as
18
+ * truth and the others are checked against it; untouched baseline
19
+ * findings cannot be filled in (warned).
20
+ *
21
+ * NOT flag-gated: the abap-upgrade-merge skill depends on this tool.
22
+ *
23
+ * Stays isMutating: false despite writing the merged detail file: the
24
+ * write is the merge's own local artifact under .cspeach/ (derived
25
+ * purely from envelopes the user supplied), the same trust level as the
26
+ * save hook persisting the envelope — no SAP or user-file mutation.
27
+ */
28
+ import { createHash } from 'node:crypto';
29
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
30
+ import { dirname, isAbsolute, join } from 'node:path';
31
+ import { registerTool } from '../index.js';
32
+ import { mergeUpgradeProgress, renderUpgradeMergeManifest, } from '../../projects/merge-upgrade.js';
33
+ import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
34
+ function sha256OfFile(path) {
35
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
36
+ }
37
+ export async function upgradeMergeProgressHandler(args, ctx) {
38
+ if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
39
+ return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
40
+ }
41
+ const tokens = args.inputs;
42
+ if (tokens.length === 1) {
43
+ return {
44
+ content: 'error: only one input supplied — a single progress file needs no merge. Use the file directly.',
45
+ is_error: true,
46
+ };
47
+ }
48
+ if (tokens.length < 2) {
49
+ return { content: 'error: at least 2 input envelopes are required.', is_error: true };
50
+ }
51
+ if (args.baseline !== undefined && typeof args.baseline !== 'string') {
52
+ return { content: 'error: baseline must be a string (upgrade-scan envelope path / @-token).', is_error: true };
53
+ }
54
+ const inputs = [];
55
+ const perInput = [];
56
+ for (const token of tokens) {
57
+ let resolved;
58
+ try {
59
+ resolved = await loadEnvelope(token, ctx.cwd, 'upgrade-progress');
60
+ }
61
+ catch (e) {
62
+ return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
63
+ }
64
+ const content = resolved.envelope.content;
65
+ inputs.push({ label: resolved.label, content });
66
+ perInput.push({
67
+ file: resolved.path,
68
+ label: resolved.label,
69
+ baseline_sha256: content.baselineRef.sha256,
70
+ transport: content.transport,
71
+ fixed: content.summary.fixed,
72
+ skipped: content.summary.skipped,
73
+ failed: content.summary.failed,
74
+ pending: content.summary.pending,
75
+ });
76
+ }
77
+ const extraWarnings = [];
78
+ let baseline;
79
+ let title;
80
+ if (typeof args.baseline === 'string') {
81
+ let scan;
82
+ try {
83
+ scan = await loadEnvelope(args.baseline, ctx.cwd, 'upgrade-scan');
84
+ }
85
+ catch (e) {
86
+ return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
87
+ }
88
+ const scanContent = scan.envelope.content;
89
+ // baseline_sha256 is the sha256 of the baseline DETAIL JSON body (the
90
+ // upgrade-fix manifest contract), not of the envelope.
91
+ const detailAbs = isAbsolute(scanContent.detailPath)
92
+ ? scanContent.detailPath
93
+ : join(ctx.cwd, scanContent.detailPath);
94
+ let sha256;
95
+ try {
96
+ sha256 = sha256OfFile(detailAbs);
97
+ }
98
+ catch (e) {
99
+ return {
100
+ content: `error: cannot hash baseline detail file '${scanContent.detailPath}' (resolved: ${detailAbs}): `
101
+ + `${e instanceof Error ? e.message : String(e)}. The merge needs the scan's detail JSON on this machine.`,
102
+ is_error: true,
103
+ };
104
+ }
105
+ baseline = {
106
+ sha256,
107
+ path: scanContent.detailPath,
108
+ title: scan.envelope.title,
109
+ findings: scanContent.findings.map((f) => ({ id: f.id, objectName: f.objectName, finding: f.rule })),
110
+ };
111
+ title = `${scan.envelope.title} — Merged (${inputs.length} slices)`;
112
+ if (scanContent.findingsTruncated) {
113
+ extraWarnings.push(`TRUNCATED BASELINE: the scan manifest carries only ${scanContent.findingsShown ?? scanContent.findings.length} `
114
+ + `of ${scanContent.summary.total} findings — pending-fill covers the manifest rows only.`);
115
+ }
116
+ }
117
+ else {
118
+ const ref = inputs[0].content.baselineRef;
119
+ baseline = { sha256: ref.sha256, path: ref.path };
120
+ title = `${ref.path.split(/[\\\/]/).pop() ?? 'baseline'} — Merged (${inputs.length} slices)`;
121
+ extraWarnings.push('NO EXPLICIT BASELINE: consistency was checked against the first input\'s baselineRef. '
122
+ + 'Findings untouched by every slice are NOT filled as pending — pass the upgrade-scan envelope '
123
+ + 'as `baseline` for full-baseline coverage.');
124
+ }
125
+ let merged;
126
+ let warnings;
127
+ try {
128
+ ({ merged, warnings } = mergeUpgradeProgress(inputs, baseline));
129
+ }
130
+ catch (e) {
131
+ return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
132
+ }
133
+ warnings = [...extraWarnings, ...warnings];
134
+ // ── write the merged detail file BEFORE emitting the manifest ──────────
135
+ // The manifest's detail_path must point at a REAL file. Minimal
136
+ // documented shape (full merged rows, never capped):
137
+ // {
138
+ // generatedAt: ISO timestamp of the merge
139
+ // baseline / transport / summary: as in the manifest
140
+ // fixes: ALL merged per-finding rows
141
+ // mergedFrom: the input envelopes [{ file, label }]
142
+ // }
143
+ let detailWritten = true;
144
+ const mergedDetailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
145
+ try {
146
+ mkdirSync(dirname(mergedDetailAbs), { recursive: true });
147
+ writeFileSync(mergedDetailAbs, JSON.stringify({
148
+ generatedAt: new Date().toISOString(),
149
+ baseline: { path: merged.baselineRef.path, sha256: merged.baselineRef.sha256 },
150
+ transport: merged.transport,
151
+ summary: merged.summary,
152
+ fixes: merged.fixes,
153
+ mergedFrom: perInput.map((p) => ({ file: p.file, label: p.label })),
154
+ }, null, 2), 'utf8');
155
+ }
156
+ catch (e) {
157
+ detailWritten = false;
158
+ warnings.push(`DETAIL WRITE FAILED: could not write the merged detail file '${merged.detailPath}' `
159
+ + `(${e instanceof Error ? e.message : String(e)}). The manifest block omits detail_path — `
160
+ + `a manifest must never point at a file that does not exist — so the save will refuse it. `
161
+ + `Fix the path/permissions and re-run the merge.`);
162
+ }
163
+ const manifestBlock = renderUpgradeMergeManifest(merged, title, { omitDetailPath: !detailWritten });
164
+ return {
165
+ content: JSON.stringify({
166
+ merged_summary: merged.summary,
167
+ baseline: { path: merged.baselineRef.path, sha256: merged.baselineRef.sha256 },
168
+ transport: merged.transport,
169
+ ...(detailWritten ? { detail_path: merged.detailPath } : {}),
170
+ per_input: perInput,
171
+ warnings,
172
+ merged_fixes: merged.fixes,
173
+ manifest_block: manifestBlock,
174
+ instructions: MANIFEST_PASTE_INSTRUCTIONS,
175
+ }, null, 2),
176
+ };
177
+ }
178
+ registerTool({
179
+ name: 'upgrade_merge_progress',
180
+ description: 'Deterministically merge 2+ parallel /abap-upgrade-fix progress envelopes (.cspeach.json) into one consolidated '
181
+ + 'upgrade-progress. Hard-errors on baseline_sha256 mismatch, reconciles per-finding status '
182
+ + '(failed > fixed > skipped > pending), warns on double-fixes, recomputes all counts from the merged rows, '
183
+ + 'writes the full merged rows to a new detail file under .cspeach/upgrades/, '
184
+ + 'and returns warnings plus a ready-made csforge:upgrade-manifest block to paste verbatim. '
185
+ + 'Pass the originating upgrade-scan envelope as `baseline` (recommended) to fill never-touched findings as pending. '
186
+ + 'Inputs accept absolute/relative paths or bare workspace @-tokens.',
187
+ isMutating: false,
188
+ category: 'project',
189
+ input_schema: {
190
+ type: 'object',
191
+ properties: {
192
+ inputs: {
193
+ type: 'array',
194
+ items: { type: 'string' },
195
+ description: '2+ upgrade-progress envelope references (path or @-token), one per developer slice.',
196
+ },
197
+ baseline: {
198
+ type: 'string',
199
+ description: 'Optional but recommended: the originating upgrade-scan envelope (path or @-token). '
200
+ + 'Used to verify baseline integrity from source and to fill untouched findings as pending.',
201
+ },
202
+ },
203
+ required: ['inputs'],
204
+ },
205
+ handler: (args, ctx) => upgradeMergeProgressHandler(args, ctx),
206
+ });