@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,195 @@
1
+ // cspeach-cli/src/projects/handover-md.ts
2
+ //
3
+ // Task 8 (agentic-flow, 2026-07-03) — markdown handover projection.
4
+ //
5
+ // Design rule (docs/strategy/2026-07-02-agentic-flow-design.md, Decision 5):
6
+ // the .cspeach.json envelope is the SINGLE machine source of truth; this
7
+ // markdown file is a WRITE-ONLY, deterministically regenerable projection.
8
+ // Nothing ever reads it back as state — enforced by design: no function in
9
+ // this module accepts markdown. One `<planBase>-handover.md` per plan family
10
+ // (the version suffix is stripped), overwritten on every envelope save.
11
+ //
12
+ // renderPlanHandover is PURE and DETERMINISTIC: no Date.now(), no randomness —
13
+ // every value derives from envelope fields, so the same envelope always
14
+ // renders byte-identical output (regenerability is the whole contract).
15
+ import { writeFileSync } from 'node:fs';
16
+ import { basename, dirname, join } from 'node:path';
17
+ import chalk from 'chalk';
18
+ import { statusesFromItems, computeNextPhase, isPhaseSatisfied, planCompletionLines, } from './plan-run.js';
19
+ import { projectFilename } from './filename.js';
20
+ import { readProjectFile } from './status.js';
21
+ /**
22
+ * Plain-text mirror of the tracker's auditGlyph wording (plan-run.ts) — same
23
+ * words, no chalk. Empty-audit phases render NO segment, matching the
24
+ * tracker's legacy-envelope behaviour. The `default` arm is deliberate
25
+ * forward-compat: an unknown future PlanAuditState reads "audit not run"
26
+ * instead of crashing the projection.
27
+ */
28
+ function auditWords(audit) {
29
+ if (!audit)
30
+ return null;
31
+ switch (audit.state) {
32
+ case 'passed': return '✔ audited';
33
+ case 'warn': {
34
+ // Non-blocking tier — passed WITH notes. Plain-text mirror of the
35
+ // tracker's warn glyph; count comes from findings.
36
+ const n = audit.findings?.length ?? 0;
37
+ return `⚠ passed with ${n} note${n === 1 ? '' : 's'}`;
38
+ }
39
+ case 'waived': return `✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
40
+ case 'failed': return '✖ audit failed';
41
+ case 'not_audited_hand_edited': return '✋ hand-edited';
42
+ case 'pending':
43
+ case 'infra_failed':
44
+ default:
45
+ return '⚠ audit not run';
46
+ }
47
+ }
48
+ /**
49
+ * Minimal YAML scalar emitter (no library — the frontmatter fields are all
50
+ * scalars / string arrays). Simple token values stay bare; anything else is
51
+ * emitted as a JSON string, which is valid YAML 1.2 double-quoted syntax.
52
+ */
53
+ function yamlScalar(v) {
54
+ // Bare (plain) scalar only for the safe token alphabet: starts with a
55
+ // non-indicator char (`@` is a YAML reserved indicator and `-` opens a
56
+ // block-sequence entry — both are quoted when leading, but stay fine
57
+ // mid-string), no `: ` (mapping ambiguity), no ` #` (comment), no
58
+ // trailing space. Everything else goes JSON-quoted.
59
+ const plainSafe = /^[A-Za-z0-9/_.][A-Za-z0-9@/_.\- ]*$/.test(v)
60
+ && !v.includes(': ') && !v.includes(' #') && !v.endsWith(' ');
61
+ return plainSafe ? v : JSON.stringify(v);
62
+ }
63
+ /**
64
+ * Audit off-switch (2026-07-06, owner) — derive `audits: on | off | mixed` for
65
+ * the frontmatter PURELY from phase audit-field presence, no new state:
66
+ * among the SATISFIED phases (the ones that actually ran to completion), an ON
67
+ * run leaves every one carrying an audit field (passed/waived), an OFF run
68
+ * leaves none. Mixed = both present (a plan run partly on, partly off).
69
+ * Nothing satisfied yet ⇒ 'on' (the config default; nothing has run off).
70
+ * Caveat: a pre-auditor legacy plan (satisfied phases, no audit fields) derives
71
+ * 'off' — technically "audits never existed" rather than "turned off", but the
72
+ * field-presence signal is honest and needs no schema addition.
73
+ */
74
+ function deriveAudits(phases, statuses) {
75
+ const satisfied = phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit));
76
+ if (satisfied.length === 0)
77
+ return 'on';
78
+ const withAudit = satisfied.filter((p) => p.audit !== undefined).length;
79
+ if (withAudit === 0)
80
+ return 'off';
81
+ if (withAudit === satisfied.length)
82
+ return 'on';
83
+ return 'mixed';
84
+ }
85
+ function progressLine(p, status) {
86
+ const segments = [
87
+ p.id,
88
+ p.title?.trim() || null,
89
+ status,
90
+ auditWords(p.audit),
91
+ p.work?.transport ? `transport ${p.work.transport}` : null,
92
+ ].filter((s) => s !== null && s.length > 0);
93
+ return `- ${segments.join(' — ')}`;
94
+ }
95
+ /**
96
+ * Render the handover markdown for a plan envelope. Pure + deterministic —
97
+ * see the module header. Throws on non-plan envelopes (structural misuse);
98
+ * the write helper below catches everything.
99
+ */
100
+ export function renderPlanHandover(envelope) {
101
+ if (envelope.artefactType !== 'plan') {
102
+ throw new Error(`renderPlanHandover expects a plan envelope, got ${envelope.artefactType}`);
103
+ }
104
+ const content = envelope.content;
105
+ const statuses = statusesFromItems(envelope.interaction.items, content.phases);
106
+ // Derived status — same predicates as the DAG (never reimplemented here):
107
+ // all satisfied → complete; any blocked → blocked; else in-progress.
108
+ const allSatisfied = content.phases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit));
109
+ const anyBlocked = content.phases.some((p) => statuses[p.id] === 'blocked');
110
+ const status = allSatisfied ? 'complete' : anyBlocked ? 'blocked' : 'in-progress';
111
+ const next = computeNextPhase(content.phases, statuses);
112
+ // 'done' only when genuinely complete; a deadlocked plan (no eligible phase,
113
+ // not all satisfied) says 'none' — 'done' would misreport a blocked plan.
114
+ const phaseField = next ? next.id : allSatisfied ? 'done' : 'none';
115
+ // The resume command uses the canonical filename reconstructed from envelope
116
+ // fields (deterministic). A rare on-disk `-N` collision sibling still
117
+ // resolves — version-less/newest-version resolution in plan-resume.ts
118
+ // matches the whole family.
119
+ const nextAction = allSatisfied
120
+ ? planCompletionLines(content.phases).join(' ')
121
+ : `/abap-plan --resume @${projectFilename({
122
+ title: envelope.title, artefactType: 'plan', id: envelope.id, version: envelope.version,
123
+ })}`;
124
+ const artifacts = content.phases.flatMap((p) => p.work?.generated ?? []);
125
+ const audits = deriveAudits(content.phases, statuses);
126
+ const lines = [
127
+ '---',
128
+ `status: ${status}`,
129
+ `audits: ${audits}`,
130
+ `phase: ${yamlScalar(phaseField)}`,
131
+ `next-action: ${yamlScalar(nextAction)}`,
132
+ `artifacts: [${artifacts.map(yamlScalar).join(', ')}]`,
133
+ `envelope-version: ${envelope.version}`,
134
+ '---',
135
+ `# ${envelope.title} — plan handover`,
136
+ '',
137
+ '## Progress',
138
+ '',
139
+ ...content.phases.map((p) => progressLine(p, statuses[p.id] ?? 'todo')),
140
+ '',
141
+ '## Last stop',
142
+ '',
143
+ ];
144
+ const stop = content.lastStop;
145
+ lines.push(stop
146
+ ? `${stop.phaseId} — ${stop.reason}${stop.detail ? ` — ${stop.detail}` : ''} (${stop.at})`
147
+ : '(none)');
148
+ lines.push('', '## Decisions & notes', '');
149
+ const noted = content.phases.filter((p) => (p.work?.notes ?? '').trim().length > 0);
150
+ if (noted.length === 0) {
151
+ lines.push('(none)');
152
+ }
153
+ else {
154
+ for (const p of noted) {
155
+ lines.push(`### ${p.id}`, '', p.work.notes.trim(), '');
156
+ }
157
+ lines.pop(); // drop the trailing blank inside the section
158
+ }
159
+ return lines.join('\n') + '\n';
160
+ }
161
+ /**
162
+ * The projection's path for an envelope path: version suffix (`-vN` and any
163
+ * `-vN-M` collision sub-suffix from saveProject) stripped, so ONE handover
164
+ * file per plan family is overwritten across versions —
165
+ * `hr-extract-plan-ab12-v3.cspeach.json` → `hr-extract-plan-ab12-handover.md`.
166
+ */
167
+ export function handoverPathFor(envelopePath) {
168
+ const name = basename(envelopePath);
169
+ const base = name.replace(/(?:-v\d+(?:-\d+)?)?\.cspeach\.json$/, '');
170
+ return join(dirname(envelopePath), `${base}-handover.md`);
171
+ }
172
+ /**
173
+ * Regenerate `<planBase>-handover.md` beside a just-saved plan envelope.
174
+ * Reads the ENVELOPE back from `savedPath` (reading the envelope is fine —
175
+ * it is the source of truth; the .md is never read). Best-effort by design:
176
+ * any failure logs one dim line and returns null — projection loss must
177
+ * never break the run. Returns the written path on success; null for
178
+ * non-plan envelopes and on any failure.
179
+ */
180
+ export function writePlanHandover(savedPath, log) {
181
+ const logFn = log ?? ((...lines) => { for (const l of lines)
182
+ console.log(l); });
183
+ try {
184
+ const envelope = readProjectFile(savedPath);
185
+ if (envelope.artefactType !== 'plan')
186
+ return null;
187
+ const target = handoverPathFor(savedPath);
188
+ writeFileSync(target, renderPlanHandover(envelope), 'utf8');
189
+ return target;
190
+ }
191
+ catch (e) {
192
+ logFn(chalk.dim(`[plan] handover markdown not regenerated (${e instanceof Error ? e.message : String(e)}) — the envelope remains the source of truth.`));
193
+ return null;
194
+ }
195
+ }
@@ -1,10 +1,13 @@
1
1
  export { planContentSchema, parsePlanContent, PLAN_LAYERS, PLAN_DELEGATE_SKILLS, PLAN_PHASE_STATUSES } from './plan-schema.js';
2
2
  export { extractPlan } from './extract-plan.js';
3
- export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision } from './plan-run.js';
3
+ export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, isPhaseSatisfied, isAuditResolved } from './plan-run.js';
4
4
  export { validateEnvelope } from './validate.js';
5
5
  export { canonicalSha256 } from './canonicalize.js';
6
6
  export { titleSlug, shortId, projectFilename } from './filename.js';
7
- export { runSaveCommand } from './save-command.js';
7
+ export { runSaveCommand, detectArtifactSkill, peekPlanManifestTitle, resolvePriorPlanEnvelope } from './save-command.js';
8
+ export { offerAnswerBlockers, openBlockers, applyBlockerAnswers } from './answer-blockers.js';
9
+ export { mergeCcaAssessments, renderCcaMergeManifest, parseScopePatterns, scopePatternsIntersect, CCA_MANIFEST_ROW_CAP } from './merge-cca.js';
10
+ export { mergeUpgradeProgress, renderUpgradeMergeManifest } from './merge-upgrade.js';
8
11
  export { extractDesign } from './extract-design.js';
9
12
  export { extractEstimate } from './extract-estimate.js';
10
13
  export { buildPromotedFromSnapshot, validatePromotionSource } from './promote.js';
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Deterministic merge of N parallel /abap-cca assessment slices into ONE
3
+ * consolidated cca-assessment (the /abap-cca-merge skill's engine).
4
+ *
5
+ * WHY CODE, NOT PROSE: battery scenario S4 (docs/research/battery/
6
+ * s4-cca-scorecard.md, defects D6/D7) proved the model cannot keep
7
+ * manifest counts consistent with manifest rows when reconciling by
8
+ * hand — `retire: 0` next to 7 retire rows, 27/104 rows silently
9
+ * dropped. This module does the arithmetic; the model presents the
10
+ * result and adds judgment commentary, nothing else.
11
+ *
12
+ * Rules implemented (mirrors .claude/skills/abap-cca-merge/SKILL.md):
13
+ * - identity: same projectId across inputs, or opts.allowCrossProject
14
+ * - atcVariant must match across inputs (hard error — classifications
15
+ * produced under different ATC variants are not comparable)
16
+ * - scope-overlap detection: intersecting scope patterns AND duplicate
17
+ * object names across inputs both produce warnings
18
+ * - per-object union keyed by objectName|objectType (item ids are
19
+ * PER-INPUT local — item-001 exists in every slice — so ids cannot
20
+ * be the union key; merged rows are re-numbered)
21
+ * - conflict rule: most-restrictive wins (retire > redesign > fix >
22
+ * keep), recorded BOTH as a warning AND on the merged row
23
+ * (`conflict: { values: {label: cls}, rule: 'most-restrictive' }`)
24
+ * - counts RECOMPUTED from merged rows — never trusted from inputs
25
+ * - truncation-aware: an input marked classificationsTruncated yields
26
+ * a warning that the merge covers manifest rows only and the detail
27
+ * file must be consulted for full coverage
28
+ */
29
+ import { OUTPUT_ROOT } from './output-paths.js';
30
+ /** Higher = more restrictive. Conflict resolution picks the max. */
31
+ const RESTRICTIVENESS = {
32
+ retire: 3,
33
+ redesign: 2,
34
+ fix: 1,
35
+ keep: 0,
36
+ };
37
+ /**
38
+ * Manifest row-priority sort (matches abap-cca SKILL.md: "redesign first,
39
+ * then fix, then retire, then keep, ordered by confidence DESC within
40
+ * each group"). This is WORK priority, not restrictiveness.
41
+ */
42
+ const ROW_PRIORITY = {
43
+ redesign: 0,
44
+ fix: 1,
45
+ retire: 2,
46
+ keep: 3,
47
+ };
48
+ const CONFIDENCE_RANK = {
49
+ high: 0,
50
+ medium: 1,
51
+ low: 2,
52
+ };
53
+ /* ── scope expression helpers ───────────────────────────────────────────── */
54
+ /**
55
+ * Split a scope expression like "ZFI* + ZSD*" or "ZFI*, ZSD*" into
56
+ * individual patterns. Exported for tests.
57
+ */
58
+ export function parseScopePatterns(scope) {
59
+ return scope
60
+ .split(/[+,]/)
61
+ .map((s) => s.trim())
62
+ .filter((s) => s.length > 0);
63
+ }
64
+ /**
65
+ * Conservative intersection check for the prefix-glob patterns the
66
+ * --scope flag accepts (`ZFI*`, exact names). Two prefix globs intersect
67
+ * when one stem is a prefix of the other (`ZF*` ⊇ `ZFI*`). Patterns we
68
+ * cannot reason about (internal `*`) only match when identical —
69
+ * deliberately under-reports rather than spamming false overlaps.
70
+ * Exported for tests.
71
+ */
72
+ export function scopePatternsIntersect(a, b) {
73
+ const A = a.trim().toUpperCase();
74
+ const B = b.trim().toUpperCase();
75
+ if (A === B)
76
+ return true;
77
+ const aStar = A.endsWith('*') && !A.slice(0, -1).includes('*');
78
+ const bStar = B.endsWith('*') && !B.slice(0, -1).includes('*');
79
+ const aStem = aStar ? A.slice(0, -1) : A;
80
+ const bStem = bStar ? B.slice(0, -1) : B;
81
+ if (A.includes('*') && !aStar)
82
+ return false; // unparseable — identical-only
83
+ if (B.includes('*') && !bStar)
84
+ return false;
85
+ if (aStar && bStar)
86
+ return aStem.startsWith(bStem) || bStem.startsWith(aStem);
87
+ if (aStar)
88
+ return B.startsWith(aStem);
89
+ if (bStar)
90
+ return A.startsWith(bStem);
91
+ return false; // two distinct exact names
92
+ }
93
+ /* ── merge ──────────────────────────────────────────────────────────────── */
94
+ function todayUtc() {
95
+ return new Date().toISOString().slice(0, 10);
96
+ }
97
+ export function mergeCcaAssessments(inputs, opts = {}) {
98
+ if (inputs.length < 2) {
99
+ throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
100
+ }
101
+ const warnings = [];
102
+ const first = inputs[0];
103
+ // ── identity validation ──
104
+ for (const inp of inputs.slice(1)) {
105
+ if (inp.content.projectId !== first.content.projectId && !opts.allowCrossProject) {
106
+ throw new Error(`projectId mismatch: '${inp.label}' is project '${inp.content.projectId}' but '${first.label}' is '${first.content.projectId}'. `
107
+ + `Merging different projects silently corrupts the estate view — pass allowCrossProject only for disjoint-scope slices of the same system.`);
108
+ }
109
+ }
110
+ for (const inp of inputs.slice(1)) {
111
+ if (inp.content.atcVariant !== first.content.atcVariant) {
112
+ throw new Error(`atcVariant mismatch: '${inp.label}' was classified under '${inp.content.atcVariant}' but '${first.label}' under '${first.content.atcVariant}'. `
113
+ + `Classifications from different ATC variants are not comparable — re-run one slice with the matching variant.`);
114
+ }
115
+ }
116
+ // ── scope-overlap detection (warning, not blocker) ──
117
+ for (let i = 0; i < inputs.length; i++) {
118
+ for (let j = i + 1; j < inputs.length; j++) {
119
+ const a = inputs[i];
120
+ const b = inputs[j];
121
+ for (const pa of parseScopePatterns(a.content.scope)) {
122
+ for (const pb of parseScopePatterns(b.content.scope)) {
123
+ if (scopePatternsIntersect(pa, pb)) {
124
+ warnings.push(`SCOPE OVERLAP: '${a.label}' scope '${pa}' intersects '${b.label}' scope '${pb}' — slices were not disjoint; duplicate objects are reconciled most-restrictive.`);
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ // ── truncation awareness ──
131
+ for (const inp of inputs) {
132
+ if (inp.content.classificationsTruncated) {
133
+ const shown = inp.content.classificationsShown ?? inp.content.classifications.length;
134
+ const total = inp.content.classificationsTotal ?? '?';
135
+ warnings.push(`TRUNCATED INPUT: '${inp.label}' manifest carries only ${shown} of ${total} classifications — `
136
+ + `this merge covers the manifest rows only. For full coverage merge from the detail file at ${inp.content.detailPath}.`);
137
+ }
138
+ }
139
+ // ── per-object union (key: objectName|objectType) ──
140
+ const byKey = new Map();
141
+ const keyOrder = [];
142
+ for (const inp of inputs) {
143
+ for (const row of inp.content.classifications) {
144
+ const key = `${row.objectName.toUpperCase()}|${row.objectType.toUpperCase()}`;
145
+ if (!byKey.has(key)) {
146
+ byKey.set(key, []);
147
+ keyOrder.push(key);
148
+ }
149
+ byKey.get(key).push({
150
+ label: inp.label,
151
+ classification: row.classification,
152
+ confidence: row.confidence,
153
+ objectName: row.objectName,
154
+ objectType: row.objectType,
155
+ });
156
+ }
157
+ }
158
+ const mergedRows = [];
159
+ for (const key of keyOrder) {
160
+ const sources = byKey.get(key);
161
+ const base = sources[0];
162
+ const distinct = new Set(sources.map((s) => s.classification));
163
+ if (sources.length > 1 && distinct.size === 1) {
164
+ warnings.push(`DUPLICATE OBJECT: ${base.objectName} (${base.objectType}) appears in ${sources.length} inputs `
165
+ + `(${sources.map((s) => s.label).join(', ')}) with the same classification '${base.classification}' — overlapping slices, merged into one row.`);
166
+ }
167
+ if (distinct.size > 1) {
168
+ // conflict — most-restrictive wins
169
+ const winner = sources.reduce((best, s) => RESTRICTIVENESS[s.classification] > RESTRICTIVENESS[best.classification] ? s : best);
170
+ const values = {};
171
+ for (const s of sources)
172
+ values[s.label] = s.classification;
173
+ warnings.push(`CLASSIFICATION CONFLICT: ${base.objectName} (${base.objectType}) — `
174
+ + sources.map((s) => `${s.label}: ${s.classification} [${s.confidence}]`).join('; ')
175
+ + ` → resolved: ${winner.classification} (most-restrictive rule). Review with the consultants who classified before remediation planning.`);
176
+ mergedRows.push({
177
+ id: '', // re-numbered after sort
178
+ objectName: base.objectName,
179
+ objectType: base.objectType,
180
+ classification: winner.classification,
181
+ confidence: winner.confidence,
182
+ conflict: { values, rule: 'most-restrictive' },
183
+ });
184
+ }
185
+ else {
186
+ mergedRows.push({
187
+ id: '',
188
+ objectName: base.objectName,
189
+ objectType: base.objectType,
190
+ classification: base.classification,
191
+ confidence: base.confidence,
192
+ });
193
+ }
194
+ }
195
+ // ── manifest row-priority sort + re-number ──
196
+ mergedRows.sort((a, b) => {
197
+ const p = ROW_PRIORITY[a.classification] - ROW_PRIORITY[b.classification];
198
+ if (p !== 0)
199
+ return p;
200
+ const c = CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence];
201
+ if (c !== 0)
202
+ return c;
203
+ return a.objectName.localeCompare(b.objectName);
204
+ });
205
+ mergedRows.forEach((row, i) => {
206
+ row.id = `item-${String(i + 1).padStart(3, '0')}`;
207
+ });
208
+ // ── counts RECOMPUTED from rows — never trusted from inputs ──
209
+ const tally = { keep: 0, fix: 0, retire: 0, redesign: 0 };
210
+ for (const row of mergedRows)
211
+ tally[row.classification]++;
212
+ // uncategorized objects have no classification rows, so they cannot be
213
+ // recomputed — summed across inputs (slices are disjoint by contract;
214
+ // overlap is already warned about above).
215
+ const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
216
+ const date = opts.mergedDate ?? todayUtc();
217
+ const mergedProjectId = `${first.content.projectId}_merged_${date}`;
218
+ const scopes = [];
219
+ for (const inp of inputs) {
220
+ const s = inp.content.scope.trim();
221
+ if (s.length > 0 && !scopes.includes(s))
222
+ scopes.push(s);
223
+ }
224
+ const merged = {
225
+ detailPath: `${OUTPUT_ROOT}/cca/projects/${mergedProjectId}/project.json`,
226
+ projectId: mergedProjectId,
227
+ scope: scopes.join(', '),
228
+ atcVariant: first.content.atcVariant,
229
+ summary: {
230
+ totalObjects: mergedRows.length + uncategorized,
231
+ keep: tally.keep,
232
+ fix: tally.fix,
233
+ retire: tally.retire,
234
+ redesign: tally.redesign,
235
+ uncategorized,
236
+ },
237
+ classifications: mergedRows,
238
+ };
239
+ return { merged, warnings };
240
+ }
241
+ /* ── manifest rendering ─────────────────────────────────────────────────── */
242
+ /** Manifest table cap — full rows live in the merged detail data. */
243
+ export const CCA_MANIFEST_ROW_CAP = 50;
244
+ function renderConflictCell(conflict) {
245
+ const pairs = Object.entries(conflict.values)
246
+ .map(([label, cls]) => `${label.replace(/[|;:]/g, '_')}:${cls}`)
247
+ .join(';');
248
+ return `conflict=${pairs}`;
249
+ }
250
+ /**
251
+ * Render the EXACT `<!-- csforge:cca-manifest ... -->` block for a merged
252
+ * assessment. The model must paste this VERBATIM — every number is
253
+ * computed here, and extract-cca.ts re-validates counts-vs-rows on save,
254
+ * so a hand-edited block fails loudly instead of saving silently wrong.
255
+ *
256
+ * `omitDetailPath` is for the detail-write-failed path in cca_merge:
257
+ * a manifest must never point at a file that does not exist, so the
258
+ * line is dropped (the extractor then refuses the save loudly).
259
+ */
260
+ export function renderCcaMergeManifest(merged, title, opts = {}) {
261
+ const rows = merged.classifications.slice(0, CCA_MANIFEST_ROW_CAP);
262
+ const truncated = merged.classifications.length > CCA_MANIFEST_ROW_CAP;
263
+ const lines = [
264
+ '<!-- csforge:cca-manifest',
265
+ 'artefact: cca-assessment',
266
+ `title: ${title}`,
267
+ ...(opts.omitDetailPath ? [] : [`detail_path: ${merged.detailPath}`]),
268
+ `project_id: ${merged.projectId}`,
269
+ `scope: ${merged.scope}`,
270
+ `atc_variant: ${merged.atcVariant}`,
271
+ `total_objects: ${merged.summary.totalObjects}`,
272
+ `keep: ${merged.summary.keep}`,
273
+ `fix: ${merged.summary.fix}`,
274
+ `retire: ${merged.summary.retire}`,
275
+ `redesign: ${merged.summary.redesign}`,
276
+ `uncategorized: ${merged.summary.uncategorized}`,
277
+ ];
278
+ if (truncated) {
279
+ lines.push('classifications_truncated: true');
280
+ lines.push(`classifications_shown: ${rows.length}`);
281
+ lines.push(`classifications_total: ${merged.classifications.length}`);
282
+ }
283
+ lines.push('classifications:');
284
+ for (const row of rows) {
285
+ const cells = [row.id, row.objectName, row.objectType, row.classification, row.confidence];
286
+ if (row.conflict)
287
+ cells.push(renderConflictCell(row.conflict));
288
+ lines.push(` ${cells.join(' | ')}`);
289
+ }
290
+ lines.push('-->');
291
+ return lines.join('\n');
292
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Deterministic merge of N parallel /abap-upgrade-fix progress slices
3
+ * into ONE consolidated upgrade-progress (the /abap-upgrade-merge
4
+ * skill's engine).
5
+ *
6
+ * WHY CODE, NOT PROSE: see merge-cca.ts header — battery S4 (defects
7
+ * D6/D7) showed the model cannot keep counts and rows consistent when
8
+ * reconciling envelopes by hand. This module owns the arithmetic.
9
+ *
10
+ * Rules implemented (mirrors .claude/skills/abap-upgrade-merge/SKILL.md):
11
+ * - baseline_sha256 must match across all inputs — HARD ERROR
12
+ * otherwise (progress against different baselines is meaningless)
13
+ * - per-finding status reconciliation, precedence:
14
+ * failed > fixed > skipped > pending
15
+ * - double-fix detection: same finding 'fixed' in 2+ inputs → warning
16
+ * - findings in the baseline untouched by every slice → pending rows
17
+ * - counts RECOMPUTED from merged rows — never trusted from inputs
18
+ */
19
+ import { OUTPUT_ROOT } from './output-paths.js';
20
+ /** Higher = wins the reconciliation. */
21
+ const STATUS_PRECEDENCE = {
22
+ failed: 3,
23
+ fixed: 2,
24
+ skipped: 1,
25
+ pending: 0,
26
+ };
27
+ const VALID_STATUSES = ['fixed', 'skipped', 'failed', 'pending'];
28
+ function todayUtc() {
29
+ return new Date().toISOString().slice(0, 10);
30
+ }
31
+ function asStatus(raw, label, findingId, warnings) {
32
+ if (VALID_STATUSES.includes(raw))
33
+ return raw;
34
+ warnings.push(`UNKNOWN STATUS: '${label}' reports status '${raw}' for ${findingId} — treated as 'pending'.`);
35
+ return 'pending';
36
+ }
37
+ export function mergeUpgradeProgress(inputs, baseline) {
38
+ if (inputs.length < 2) {
39
+ throw new Error(`upgrade merge requires at least 2 inputs (got ${inputs.length}) — a single progress file needs no merge`);
40
+ }
41
+ const warnings = [];
42
+ // ── baseline integrity — HARD ERROR on mismatch ──
43
+ for (const inp of inputs) {
44
+ if (inp.content.baselineRef.sha256 !== baseline.sha256) {
45
+ throw new Error(`baseline mismatch: '${inp.label}' was fixed against baseline sha256 ${inp.content.baselineRef.sha256 || '(empty)'} `
46
+ + `but the merge baseline is ${baseline.sha256}. Progress files from different baselines cannot be merged — `
47
+ + `re-run /abap-upgrade-fix against the current baseline or merge only matching slices.`);
48
+ }
49
+ }
50
+ // All-empty hashes pass the equality check above vacuously — that is
51
+ // NOT verified integrity, so say so out loud.
52
+ if (baseline.sha256.trim().length === 0) {
53
+ warnings.push('NO BASELINE HASH: no input carries a baseline_sha256 — baseline integrity is unverified. '
54
+ + 'The slices may have been fixed against different scans; the sha256 discipline could not be applied.');
55
+ }
56
+ const byId = new Map();
57
+ const idOrder = [];
58
+ for (const inp of inputs) {
59
+ for (const fix of inp.content.fixes) {
60
+ if (!byId.has(fix.id)) {
61
+ byId.set(fix.id, []);
62
+ idOrder.push(fix.id);
63
+ }
64
+ byId.get(fix.id).push({
65
+ label: inp.label,
66
+ status: asStatus(fix.status, inp.label, fix.id, warnings),
67
+ objectName: fix.objectName,
68
+ finding: fix.finding,
69
+ });
70
+ }
71
+ }
72
+ const mergedFixes = [];
73
+ for (const id of idOrder) {
74
+ const sources = byId.get(id);
75
+ const base = sources[0];
76
+ // data-integrity check: same finding id should reference the same object
77
+ const names = new Set(sources.map((s) => s.objectName.toUpperCase()));
78
+ if (names.size > 1) {
79
+ warnings.push(`ID COLLISION: ${id} maps to different objects across inputs (${sources.map((s) => `${s.label}: ${s.objectName}`).join('; ')}) `
80
+ + `— check that all slices came from the same scan. Keeping '${base.objectName}'.`);
81
+ }
82
+ // double-fix detection (warning, not blocker)
83
+ const fixers = sources.filter((s) => s.status === 'fixed');
84
+ if (fixers.length > 1) {
85
+ warnings.push(`DOUBLE-FIX: ${id} — ${base.objectName} reported 'fixed' by ${fixers.length} inputs `
86
+ + `(${fixers.map((s) => s.label).join(', ')}). Two devs likely claimed the same object — `
87
+ + `/abap-upgrade-verify will confirm which fix is active.`);
88
+ }
89
+ const winner = sources.reduce((best, s) => STATUS_PRECEDENCE[s.status] > STATUS_PRECEDENCE[best.status] ? s : best);
90
+ mergedFixes.push({
91
+ id,
92
+ objectName: base.objectName,
93
+ finding: base.finding,
94
+ status: winner.status,
95
+ });
96
+ }
97
+ // ── pending-fill: baseline findings no slice touched ──
98
+ if (baseline.findings) {
99
+ for (const f of baseline.findings) {
100
+ if (!byId.has(f.id)) {
101
+ mergedFixes.push({
102
+ id: f.id,
103
+ objectName: f.objectName,
104
+ finding: f.finding,
105
+ status: 'pending',
106
+ });
107
+ }
108
+ }
109
+ }
110
+ // stable order by finding id (scan order: finding-001, finding-002, ...)
111
+ mergedFixes.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
112
+ // ── counts RECOMPUTED from rows ──
113
+ const tally = { fixed: 0, skipped: 0, failed: 0, pending: 0 };
114
+ for (const fix of mergedFixes)
115
+ tally[fix.status]++;
116
+ // ── transport: keep only when unanimous ──
117
+ const transports = [...new Set(inputs.map((i) => i.content.transport).filter((t) => t !== null))];
118
+ let transport = null;
119
+ if (transports.length === 1) {
120
+ transport = transports[0];
121
+ }
122
+ else if (transports.length > 1) {
123
+ warnings.push(`MULTIPLE TRANSPORTS: slices used different transports (${transports.join(', ')}) — merged progress carries no single transport; release each separately.`);
124
+ }
125
+ const baselinePath = baseline.path ?? inputs[0].content.baselineRef.path;
126
+ const date = baseline.mergedDate ?? todayUtc();
127
+ const stem = (baselinePath.split(/[\\\/]/).pop() ?? 'baseline').replace(/\.json$/i, '');
128
+ const merged = {
129
+ detailPath: `${OUTPUT_ROOT}/upgrades/${stem}_progress_merged_${date}.json`,
130
+ baselineRef: { path: baselinePath, sha256: baseline.sha256 },
131
+ transport,
132
+ summary: {
133
+ fixed: tally.fixed,
134
+ skipped: tally.skipped,
135
+ failed: tally.failed,
136
+ pending: tally.pending,
137
+ },
138
+ fixes: mergedFixes,
139
+ };
140
+ return { merged, warnings };
141
+ }
142
+ /* ── manifest rendering ─────────────────────────────────────────────────── */
143
+ /**
144
+ * Render the EXACT `<!-- csforge:upgrade-manifest ... -->` block for a
145
+ * merged progress. The model must paste this VERBATIM — every number is
146
+ * computed here from the merged rows.
147
+ *
148
+ * `omitDetailPath` is for the detail-write-failed path in
149
+ * upgrade_merge_progress: a manifest must never point at a file that
150
+ * does not exist, so the line is dropped (the extractor then refuses
151
+ * the save loudly).
152
+ */
153
+ export function renderUpgradeMergeManifest(merged, title, opts = {}) {
154
+ const lines = [
155
+ '<!-- csforge:upgrade-manifest',
156
+ 'artefact: upgrade-progress',
157
+ `title: ${title}`,
158
+ ...(opts.omitDetailPath ? [] : [`detail_path: ${merged.detailPath}`]),
159
+ `baseline_path: ${merged.baselineRef.path}`,
160
+ `baseline_sha256: ${merged.baselineRef.sha256}`,
161
+ `transport: ${merged.transport ?? '-'}`,
162
+ `fixed: ${merged.summary.fixed}`,
163
+ `skipped: ${merged.summary.skipped}`,
164
+ `failed: ${merged.summary.failed}`,
165
+ `pending: ${merged.summary.pending}`,
166
+ 'fixes:',
167
+ ];
168
+ for (const fix of merged.fixes) {
169
+ lines.push(` ${[fix.id, fix.objectName, fix.finding, fix.status].join(' | ')}`);
170
+ }
171
+ lines.push('-->');
172
+ return lines.join('\n');
173
+ }