@cspeach/cli 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -1,7 +1,117 @@
1
1
  import { parsePlanContent, PLAN_PHASE_STATUSES } from './plan-schema.js';
2
- const MANIFEST_RE = /<!--\s*csforge:plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
3
- export function extractPlan(markdown) {
4
- // Last block wins — a continue-in-session turn emits one block per phase.
2
+ import { computeNextPhase, isPhaseSatisfied } from './plan-run.js';
3
+ // E2 dual-read: accept BOTH the legacy `csforge:` prefix (old artifacts on
4
+ // customer disks carry it forever) and the current `cspeach:` prefix. Legacy
5
+ // acceptance is permanent.
6
+ const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
7
+ // 2026-06-06 live-smoke lesson #4 (Postel): normalise model-emitted
8
+ // `work` blocks instead of rejecting them. Models naturally write
9
+ // `"transport": null` for "none" (schema fields are optional, NOT
10
+ // nullable) and a structured OBJECT for notes (a design phase's
11
+ // decision register) — both reasonable; both killed a fully correct
12
+ // phase write-back. null -> absent; object notes -> JSON string.
13
+ function normalizeWorkFields(p) {
14
+ const w = p?.work;
15
+ if (!w || typeof w !== 'object')
16
+ return;
17
+ for (const k of ['generated', 'transport', 'snapshot', 'notes', 'binding']) {
18
+ if (w[k] === null)
19
+ delete w[k];
20
+ }
21
+ if (w.notes !== undefined && typeof w.notes !== 'string') {
22
+ w.notes = JSON.stringify(w.notes);
23
+ }
24
+ }
25
+ /**
26
+ * Expand a compact `changed` map against the prior envelope content into a
27
+ * full PlanContent candidate (still Zod-validated by the caller). The
28
+ * dangerous failure here is silently dropping or duplicating phase content
29
+ * during the merge — hence: every changed key must name an existing phase,
30
+ * an inner `id` may not contradict its key, and the phase list is ALWAYS
31
+ * prior.phases mapped 1:1 (no additions, no removals, no reordering).
32
+ */
33
+ function expandCompactContent(changedRaw, prior, rawStatuses) {
34
+ if (!prior) {
35
+ throw new Error('compact plan-manifest ("changed" without "content") is only valid on a resume turn — ' +
36
+ 'no prior plan content is available to merge into; seed sessions must emit the full shape');
37
+ }
38
+ if (typeof changedRaw !== 'object' || changedRaw === null || Array.isArray(changedRaw)) {
39
+ throw new Error('plan-manifest "changed" must be an object map keyed by phase id');
40
+ }
41
+ const changed = changedRaw;
42
+ const knownIds = new Set(prior.phases.map((p) => p.id));
43
+ for (const [id, overlay] of Object.entries(changed)) {
44
+ if (!knownIds.has(id)) {
45
+ throw new Error(`plan-manifest changed["${id}"] references an unknown phase id; ` +
46
+ `known phases: ${[...knownIds].join(', ')}. ` +
47
+ 'To add or remove phases, emit the full shape with "content" instead.');
48
+ }
49
+ if (typeof overlay !== 'object' || overlay === null || Array.isArray(overlay)) {
50
+ throw new Error(`plan-manifest changed["${id}"] must be an object carrying the phase's updated fields`);
51
+ }
52
+ const innerId = overlay.id;
53
+ if (innerId !== undefined && innerId !== id) {
54
+ throw new Error(`plan-manifest changed["${id}"] carries a mismatched "id": ${String(innerId)}`);
55
+ }
56
+ // Lesson-#4 parity for the overlay itself: models write `"work": null`
57
+ // for "nothing to record". null/undefined work means "keep prior work" —
58
+ // drop the key so the spread below doesn't clobber the prior phase's work.
59
+ if (overlay.work == null) {
60
+ delete overlay.work;
61
+ }
62
+ // Task 6 review I-1 (audit forgery seam): audit verdicts are HARNESS-
63
+ // authored only (plan-resume.ts / plan-chain.ts). A model-emitted overlay
64
+ // carrying an `audit` key could wash a persisted 'failed' verdict to
65
+ // 'passed' — strip it so the prior phase's audit always survives the
66
+ // spread below.
67
+ delete overlay.audit;
68
+ // Track 1 (phase-loss seam): `ui` (flavor/floorplan/appId) is plan-create-
69
+ // only — set once at plan creation, NEVER edited on resume (abap-plan
70
+ // SKILL.md hard rule). A resume overlay must never edit or brick it: a
71
+ // malformed model-written `ui` would fail the STRICT planPhaseUiSchema and
72
+ // reject the whole merged candidate (losing the phase), and a valid one
73
+ // would silently rewrite the flavor mid-plan. Strip it so the prior
74
+ // phase's `ui` always survives the spread below.
75
+ delete overlay.ui;
76
+ normalizeWorkFields(overlay);
77
+ }
78
+ // 1:1 map over the PRIOR phase list — changed entries overlay their phase,
79
+ // everything else passes through verbatim from the envelope.
80
+ const phases = prior.phases.map((p) => changed[p.id] !== undefined ? { ...p, ...changed[p.id], id: p.id } : p);
81
+ // Recompute summary CLI-side from the merged statuses (the compact shape
82
+ // does not carry one — fewer tokens, and it cannot drift from statuses).
83
+ // Invalid/missing status values are skipped here; the shared statuses
84
+ // validation loop in extractPlan throws the precise error afterwards.
85
+ const statuses = {};
86
+ for (const p of phases) {
87
+ const s = rawStatuses[p.id];
88
+ if (typeof s === 'string' && PLAN_PHASE_STATUSES.includes(s)) {
89
+ statuses[p.id] = s;
90
+ }
91
+ }
92
+ const next = computeNextPhase(phases, statuses);
93
+ const summary = {
94
+ total: phases.length,
95
+ // Waived phases (C1) count as validated here — summary.validated means
96
+ // "done for DAG purposes", same as the tracker header tally. Audit-aware
97
+ // (fix-wave): a validated-but-audit-failed phase is NOT done — same
98
+ // predicate call as `next` above and savePlanPatch's recompute, so the
99
+ // two summary fields can never disagree about the same phase.
100
+ validated: phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit)).length,
101
+ blocked: phases.filter((p) => statuses[p.id] === 'blocked').length,
102
+ next: next?.id ?? null,
103
+ };
104
+ return { ...prior, phases, summary };
105
+ }
106
+ /**
107
+ * Parse the LAST plan-manifest block out of the skill's markdown output.
108
+ * `priorContent` is the validated content of the envelope being resumed —
109
+ * required to expand the compact shape; on the full shape it is the source
110
+ * of truth for per-phase `audit` fields (Task 6 review I-1 — audits are
111
+ * harness-authored, never model-authored). Seed sessions pass nothing.
112
+ */
113
+ export function extractPlan(markdown, priorContent) {
114
+ // Last block wins — defensive against a turn emitting more than one block.
5
115
  const re = new RegExp(MANIFEST_RE.source, MANIFEST_RE.flags);
6
116
  let m;
7
117
  let last = null;
@@ -24,46 +134,83 @@ export function extractPlan(markdown) {
24
134
  if (typeof obj.title !== 'string' || obj.title.trim().length === 0) {
25
135
  throw new Error('plan-manifest is missing a non-empty "title"');
26
136
  }
27
- // 2026-06-06 live-smoke lesson #2: the model cannot know the consumed
28
- // spec-gap's real path/sha — that provenance lives CLI-side in the
29
- // envelope's promotedFrom (threaded by the --from save flow). Models
30
- // emit placeholders like {"path":"@equip","sha256":""}, and a junk
31
- // provenance pointer must not cost the whole plan: strip it.
32
- const rawContent = obj.content;
33
- if (rawContent && typeof rawContent === 'object' && rawContent.from?.specGap) {
34
- const sha = String(rawContent.from.specGap.sha256 ?? '');
35
- if (!/^[0-9a-f]{64}$/.test(sha))
36
- delete rawContent.from.specGap;
37
- }
38
- // 2026-06-06 live-smoke lesson #4 (Postel): normalise model-emitted
39
- // `work` blocks instead of rejecting them. Models naturally write
40
- // `"transport": null` for "none" (schema fields are optional, NOT
41
- // nullable) and a structured OBJECT for notes (a design phase's
42
- // decision register) — both reasonable; both killed a fully correct
43
- // phase write-back. null -> absent; object notes -> JSON string.
44
- const rawPhases = rawContent?.phases;
45
- if (Array.isArray(rawPhases)) {
46
- for (const p of rawPhases) {
47
- const w = p?.work;
48
- if (!w || typeof w !== 'object')
49
- continue;
50
- for (const k of ['generated', 'transport', 'snapshot', 'notes']) {
51
- if (w[k] === null)
52
- delete w[k];
53
- }
54
- if (w.notes !== undefined && typeof w.notes !== 'string') {
55
- w.notes = JSON.stringify(w.notes);
137
+ if (typeof obj.statuses !== 'object' || obj.statuses === null || Array.isArray(obj.statuses)) {
138
+ throw new Error('plan-manifest is missing the "statuses" object');
139
+ }
140
+ const rawStatuses = obj.statuses;
141
+ // Dual-shape manifests are the one silent-data-loss path: a model that
142
+ // copies stale `content` out of <plan_state> AND emits correct `changed`
143
+ // updates would persist the stale plan with no signal. Refuse loudly.
144
+ if (obj.content !== undefined && obj.changed !== undefined) {
145
+ throw new Error('plan-manifest must contain either "content" (full shape) or "changed" (compact shape), ' +
146
+ 'not both — re-emit using exactly one');
147
+ }
148
+ let contentInput;
149
+ if (obj.content !== undefined) {
150
+ // FULL shape — Mode 1 create, plus the backward-compatible / structural-
151
+ // change path on resume.
152
+ // 2026-06-06 live-smoke lesson #2: the model cannot know the consumed
153
+ // spec-gap's real path/sha — that provenance lives CLI-side in the
154
+ // envelope's promotedFrom (threaded by the --from save flow). Models
155
+ // emit placeholders like {"path":"@equip","sha256":""}, and a junk
156
+ // provenance pointer must not cost the whole plan: strip it.
157
+ const rawContent = obj.content;
158
+ if (rawContent && typeof rawContent === 'object' && rawContent.from?.specGap) {
159
+ const sha = String(rawContent.from.specGap.sha256 ?? '');
160
+ if (!/^[0-9a-f]{64}$/.test(sha))
161
+ delete rawContent.from.specGap;
162
+ }
163
+ const rawPhases = rawContent?.phases;
164
+ if (Array.isArray(rawPhases)) {
165
+ // Task 6 review I-1 (audit forgery seam, full-shape variant): the full
166
+ // shape replaces content wholesale, so a model omitting (or inventing)
167
+ // `audit` fields would erase or forge persisted verdicts — an absent
168
+ // audit is fail-OPEN (isAuditResolved(undefined) === true). Re-impose
169
+ // the prior envelope's audit onto every phase by id and strip any
170
+ // model-authored audit for phases the prior never audited (seed
171
+ // sessions have no prior → all audits stripped; audits are harness-
172
+ // authored only). The just-executed phase needs no exception here:
173
+ // finishPlanResume overwrites its audit with 'pending' on success, and
174
+ // carrying the prior verdict on non-success matches the compact merge.
175
+ const priorAudits = new Map((priorContent?.phases ?? []).map((p) => [p.id, p.audit]));
176
+ for (const p of rawPhases) {
177
+ normalizeWorkFields(p);
178
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
179
+ const rec = p;
180
+ const prior = typeof rec.id === 'string' ? priorAudits.get(rec.id) : undefined;
181
+ if (prior !== undefined)
182
+ rec.audit = prior;
183
+ else
184
+ delete rec.audit;
185
+ }
56
186
  }
57
187
  }
188
+ // Fix-wave — content.lastStop is HARNESS-authored (chain-stop records),
189
+ // same rule as the per-phase audits above: the full shape replaces
190
+ // content wholesale, so a model re-emission would silently drop the
191
+ // prior stop record (or forge one). Re-impose the prior envelope's
192
+ // lastStop; strip any model-authored one when the prior never had it
193
+ // (seed sessions have no prior → always stripped).
194
+ if (rawContent && typeof rawContent === 'object') {
195
+ const rc = rawContent;
196
+ if (priorContent?.lastStop !== undefined)
197
+ rc.lastStop = priorContent.lastStop;
198
+ else
199
+ delete rc.lastStop;
200
+ }
201
+ contentInput = obj.content;
202
+ }
203
+ else if (obj.changed !== undefined) {
204
+ // COMPACT shape (A2) — resume turns only; merged into the prior content.
205
+ contentInput = expandCompactContent(obj.changed, priorContent, rawStatuses);
58
206
  }
59
- const pc = parsePlanContent(obj.content);
207
+ else {
208
+ throw new Error('plan-manifest must carry either "content" (full shape) or "changed" (compact resume shape)');
209
+ }
210
+ const pc = parsePlanContent(contentInput);
60
211
  if (!pc.ok) {
61
212
  throw new Error(`plan-manifest content failed validation:\n ${pc.errors.join('\n ')}`);
62
213
  }
63
- if (typeof obj.statuses !== 'object' || obj.statuses === null || Array.isArray(obj.statuses)) {
64
- throw new Error('plan-manifest is missing the "statuses" object');
65
- }
66
- const rawStatuses = obj.statuses;
67
214
  const statuses = {};
68
215
  for (const p of pc.content.phases) {
69
216
  const s = rawStatuses[p.id];
@@ -1,15 +1,24 @@
1
1
  const TITLE_RE = /^## Spec Gap Analysis — (.+)$/m;
2
2
  const DOMAIN_RE = /^\*\*Inferred Domain:\*\*\s*(.+?)(?:\s*\[INFERRED.*?\])?$/m;
3
3
  const INPUT_TYPE_RE = /^\*\*Input Type:\*\*\s*(.+)$/m;
4
+ const SOURCE_DOC_RE = /^\*\*Source Document:\*\*\s*(.+)$/m;
4
5
  const SECTION_HEADING_RE = /^### (.+)$/gm;
5
6
  // Real-world model output shows two formatting variants:
6
7
  // ' 1. [BLOCKER] Text' — leading indent (4 spaces or tab), plain severity
7
8
  // '1. **[BLOCKER]** Text' — no indent, severity wrapped in markdown bold
8
9
  // Tolerate both: optional leading whitespace + optional `**` around the severity bracket.
9
10
  const GAP_LINE_RE = /^\s*(\d+)\.\s*(?:\*\*)?\[(BLOCKER|IMPORTANT|NICE TO KNOW)\](?:\*\*)?\s*(.+)$/m;
11
+ // An "Options:" / "Options (multi):" line under a gap — candidate answers the
12
+ // user can pick instead of typing. Tolerant of leading whitespace + bold.
13
+ const OPTIONS_LINE_RE = /^\s*(?:\*\*)?Options(\s*\(multi\))?(?:\*\*)?\s*:\s*(.+)$/i;
10
14
  const AUDIENCE_BY_SECTION_NAME = {
11
15
  'missing business questions': 'business',
12
16
  'missing technical questions': 'technical',
17
+ // Standard-vs-Custom / fit-gap: the BA knows the standard tcode/app exists,
18
+ // the architect confirms the technical fit — so it's a mixed audience.
19
+ 'fit-gap': 'mixed',
20
+ 'standard-vs-custom': 'mixed',
21
+ 'standard functionality': 'mixed',
13
22
  'data assumptions to confirm': 'data',
14
23
  'authorization questions': 'auth',
15
24
  'authorisation questions': 'auth',
@@ -28,6 +37,9 @@ function audienceFor(sectionName) {
28
37
  const KNOWN_GAP_SECTIONS = [
29
38
  'Missing Business Questions',
30
39
  'Missing Technical Questions',
40
+ 'Fit-Gap', // 'Standard-vs-Custom (Fit-Gap)' — matched by substring
41
+ 'Standard-vs-Custom',
42
+ 'Standard Functionality',
31
43
  'Data Assumptions to Confirm',
32
44
  'Authorization Questions',
33
45
  'Authorisation Questions', // British spelling — both OK
@@ -47,6 +59,7 @@ export function extractSpecGap(markdown) {
47
59
  const title = titleMatch[1].trim();
48
60
  const inputType = (INPUT_TYPE_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
49
61
  const inferredDomain = (DOMAIN_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
62
+ const sourceDocument = SOURCE_DOC_RE.exec(markdown)?.[1].trim() || undefined;
50
63
  const sectionStarts = [];
51
64
  let m;
52
65
  const reCopy = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
@@ -84,18 +97,32 @@ export function extractSpecGap(markdown) {
84
97
  }
85
98
  return {
86
99
  title,
87
- content: { inputType, inferredDomain, sections },
100
+ content: { inputType, inferredDomain, ...(sourceDocument ? { sourceDocument } : {}), sections },
88
101
  items,
89
102
  };
90
103
  }
91
104
  function parseGapsFromSection(sectionBody, nextId) {
92
105
  const out = [];
93
- const re = new RegExp(GAP_LINE_RE.source, 'gm');
94
- let m;
95
- while ((m = re.exec(sectionBody)) !== null) {
96
- const severity = m[2];
97
- const text = m[3].trim();
98
- out.push({ id: nextId(), severity, text });
106
+ const gapRe = new RegExp(GAP_LINE_RE.source); // per-line, no /m — exec on each line
107
+ let current = null;
108
+ for (const raw of sectionBody.split('\n')) {
109
+ const line = raw.replace(/\r$/, '');
110
+ const g = gapRe.exec(line);
111
+ if (g) {
112
+ current = { id: nextId(), severity: g[2], text: g[3].trim() };
113
+ out.push(current);
114
+ continue;
115
+ }
116
+ // An Options: line attaches its choices to the gap immediately above it.
117
+ const o = OPTIONS_LINE_RE.exec(line);
118
+ if (o && current) {
119
+ const opts = o[2].split('|').map((s) => s.trim()).filter((s) => s.length > 0);
120
+ if (opts.length > 0) {
121
+ current.options = opts;
122
+ if (o[1])
123
+ current.optionsMultiSelect = true;
124
+ }
125
+ }
99
126
  }
100
127
  return out;
101
128
  }
@@ -10,7 +10,7 @@
10
10
  * <!-- csforge:test-coverage-manifest
11
11
  * artefact: test-coverage
12
12
  * title: ZDPR_PAYMENTS — test coverage
13
- * detail_path: .abapforge/tests/zdpr-payments/project.json
13
+ * detail_path: .cspeach/tests/zdpr-payments/project.json
14
14
  * baseline_path: <project-relative path>
15
15
  * baseline_sha256: <hex>
16
16
  * transport: <id or ->
@@ -25,7 +25,9 @@
25
25
  * item-002 | ZPAYMENTS_OLD | PROG | ZPAYMENTS_OLD_TEST | 4 | generated
26
26
  * -->
27
27
  */
28
- const MANIFEST_RE = /<!--\s*csforge:test-coverage-manifest\s*\n([\s\S]*?)\n\s*-->/;
28
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
29
+ // prefixes (legacy acceptance is permanent — old saved artifacts carry it).
30
+ const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):test-coverage-manifest\s*\n([\s\S]*?)\n\s*-->/;
29
31
  function parseManifestBlock(markdown) {
30
32
  const m = MANIFEST_RE.exec(markdown);
31
33
  if (!m) {
@@ -9,7 +9,7 @@
9
9
  * <!-- csforge:upgrade-manifest
10
10
  * artefact: upgrade-scan
11
11
  * title: ZFI* — S/4HANA 2023 readiness
12
- * detail_path: .abapforge/upgrades/zfi-readiness-2023-baseline.json
12
+ * detail_path: .cspeach/upgrades/zfi-readiness-2023-baseline.json
13
13
  * atc_variant: S4HANA_READINESS_2023
14
14
  * scope: Z* in package ZFI*
15
15
  * total: 2847
@@ -17,12 +17,16 @@
17
17
  * manual_review: 136
18
18
  * suppressed: 26
19
19
  * findings:
20
- * finding-001 | ZFI_BP_SYNC | PROG | S4HANA_BUSINESS_PARTNER | E
21
- * finding-002 | ZCL_FI_DOC | CLAS | PERF_DB_SEARCH | W
20
+ * finding-001 | ZFI_BP_SYNC | PROG | S4HANA_BUSINESS_PARTNER | E | 88 | Replace BAPI_BUPA_CREATE with I_BusinessPartner CDS
21
+ * finding-002 | ZCL_FI_DOC | CLAS | PERF_DB_SEARCH | W | - | Add ORDER BY PRIMARY KEY to the SELECT
22
22
  * -->
23
23
  *
24
24
  * Key-value pairs are `key: value` per line. Tabular sections use a
25
25
  * trailing colon header (`findings:`) followed by `|`-separated rows.
26
+ * Findings rows are 7-column: id | object | type | rule | severity | line |
27
+ * message. `line` is an integer or `-` (object-level, not line-specific);
28
+ * `message` is a one-line description incl. the fix. Legacy 5-column rows
29
+ * (no line/message) still parse — the extra fields are optional.
26
30
  *
27
31
  * Three artefact types share the same parser and dispatch by `artefact:`:
28
32
  * - upgrade-scan → findings table
@@ -32,7 +36,9 @@
32
36
  * Parsing is permissive — missing optional fields produce defaults; a
33
37
  * missing manifest block throws so callers know to surface the error.
34
38
  */
35
- const MANIFEST_RE = /<!--\s*csforge:upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/;
39
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
40
+ // prefixes (legacy acceptance is permanent — old saved artifacts carry it).
41
+ const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/;
36
42
  function parseManifestBlock(markdown) {
37
43
  const m = MANIFEST_RE.exec(markdown);
38
44
  if (!m) {
@@ -82,6 +88,13 @@ function intOr(kv, key, fallback) {
82
88
  const n = Number.parseInt(v, 10);
83
89
  return Number.isFinite(n) ? n : fallback;
84
90
  }
91
+ function optionalInt(kv, key) {
92
+ const v = kv[key];
93
+ if (v === undefined)
94
+ return undefined;
95
+ const n = Number.parseInt(v, 10);
96
+ return Number.isFinite(n) ? n : undefined;
97
+ }
85
98
  function buildItems(count, defaultStatus) {
86
99
  const out = [];
87
100
  for (let i = 0; i < count; i++) {
@@ -107,24 +120,66 @@ export function extractUpgradeScan(markdown) {
107
120
  if (!kv.detail_path) {
108
121
  throw new Error("upgrade-scan manifest missing required 'detail_path'");
109
122
  }
110
- const findings = (tables.findings ?? []).map((row, i) => ({
111
- id: row[0] ?? `finding-${String(i + 1).padStart(3, '0')}`,
112
- objectName: row[1] ?? '',
113
- objectType: row[2] ?? '',
114
- rule: row[3] ?? '',
115
- severity: row[4] ?? '',
116
- }));
123
+ const findings = (tables.findings ?? []).map((row, i) => {
124
+ const finding = {
125
+ id: row[0] ?? `finding-${String(i + 1).padStart(3, '0')}`,
126
+ objectName: row[1] ?? '',
127
+ objectType: row[2] ?? '',
128
+ rule: row[3] ?? '',
129
+ severity: row[4] ?? '',
130
+ };
131
+ // line: cell 6 (row[5]); include only if a finite integer (skip '-', '', non-numeric).
132
+ if (row[5] !== undefined) {
133
+ const n = Number.parseInt(row[5], 10);
134
+ if (Number.isFinite(n))
135
+ finding.line = n;
136
+ }
137
+ // message: cells 7+ rejoined with ' | ' so a '|' inside the message survives.
138
+ if (row.length > 6) {
139
+ const message = row.slice(6).join(' | ').trim();
140
+ if (message.length > 0)
141
+ finding.message = message;
142
+ }
143
+ return finding;
144
+ });
145
+ const summary = {
146
+ total: intOr(kv, 'total', findings.length),
147
+ autoFixable: intOr(kv, 'auto_fixable', 0),
148
+ manualReview: intOr(kv, 'manual_review', 0),
149
+ suppressed: intOr(kv, 'suppressed', 0),
150
+ };
151
+ // ── D7 manifest integrity (2026-06-12) ──────────────────────────────────
152
+ // The scan manifest is chain input for /abap-upgrade-fix slicing and the
153
+ // merge's pending-fill. A `total` that disagrees with the rows means
154
+ // either the model truncated the table without marking it (D7) or
155
+ // miscounted (D6) — both corrupt downstream reconciliation, so fail the
156
+ // extract loudly (extract-plan loud-failure style).
157
+ const truncated = kv.findings_truncated === 'true';
158
+ const shown = optionalInt(kv, 'findings_shown');
159
+ if (truncated) {
160
+ if (shown !== undefined && shown !== findings.length) {
161
+ throw new Error(`upgrade-scan manifest contradiction: findings_shown is ${shown} but the table has ${findings.length} rows.`);
162
+ }
163
+ if (summary.total < findings.length) {
164
+ throw new Error(`upgrade-scan manifest contradiction: total (${summary.total}) is smaller than the ${findings.length} rows shown.`);
165
+ }
166
+ }
167
+ else if (summary.total !== findings.length) {
168
+ throw new Error(`upgrade-scan manifest contradiction: total is ${summary.total} but the table has ${findings.length} finding rows. `
169
+ + `Counts must equal rows — if the table was capped, mark it with findings_truncated: true + findings_shown.`);
170
+ }
117
171
  const content = {
118
172
  detailPath: kv.detail_path,
119
173
  atcVariant: kv.atc_variant ?? '',
120
174
  scope: kv.scope ?? '',
121
- summary: {
122
- total: intOr(kv, 'total', findings.length),
123
- autoFixable: intOr(kv, 'auto_fixable', 0),
124
- manualReview: intOr(kv, 'manual_review', 0),
125
- suppressed: intOr(kv, 'suppressed', 0),
126
- },
175
+ summary,
127
176
  findings,
177
+ ...(truncated
178
+ ? {
179
+ findingsTruncated: true,
180
+ ...(shown !== undefined ? { findingsShown: shown } : {}),
181
+ }
182
+ : {}),
128
183
  };
129
184
  // One InteractionItem per finding, default status 'open'.
130
185
  const items = findings.map((f) => ({
@@ -154,6 +209,49 @@ export function extractUpgradeProgress(markdown) {
154
209
  finding: row[2] ?? '',
155
210
  status: row[3] ?? 'pending',
156
211
  }));
212
+ const summary = {
213
+ fixed: intOr(kv, 'fixed', 0),
214
+ skipped: intOr(kv, 'skipped', 0),
215
+ failed: intOr(kv, 'failed', 0),
216
+ pending: intOr(kv, 'pending', 0),
217
+ };
218
+ // ── D6/D7 manifest integrity (2026-06-12) ───────────────────────────────
219
+ // Same backstop as extract-cca / the scan branch above: the progress
220
+ // manifest is chain input for /abap-upgrade-merge — summary counts that
221
+ // contradict the fixes rows corrupt the consolidated team numbers, so a
222
+ // contradiction fails the extract LOUDLY. Unknown statuses tally as
223
+ // 'pending', matching the InteractionItem coercion below.
224
+ const STATUSES = ['fixed', 'skipped', 'failed', 'pending'];
225
+ const rowTally = {
226
+ fixed: 0, skipped: 0, failed: 0, pending: 0,
227
+ };
228
+ for (const f of fixes) {
229
+ const s = STATUSES.includes(f.status)
230
+ ? f.status
231
+ : 'pending';
232
+ rowTally[s]++;
233
+ }
234
+ const fixesTruncated = kv.fixes_truncated === 'true';
235
+ const fixesShown = optionalInt(kv, 'fixes_shown');
236
+ if (fixesTruncated) {
237
+ if (fixesShown !== undefined && fixesShown !== fixes.length) {
238
+ throw new Error(`upgrade-progress manifest contradiction: fixes_shown is ${fixesShown} but the table has ${fixes.length} rows.`);
239
+ }
240
+ for (const s of STATUSES) {
241
+ if (rowTally[s] > summary[s]) {
242
+ throw new Error(`upgrade-progress manifest contradiction: ${rowTally[s]} '${s}' rows in the table but the summary says ${s}: ${summary[s]}. `
243
+ + `Counts must cover every row even when the table is truncated.`);
244
+ }
245
+ }
246
+ }
247
+ else {
248
+ for (const s of STATUSES) {
249
+ if (rowTally[s] !== summary[s]) {
250
+ throw new Error(`upgrade-progress manifest contradiction: summary says ${s}: ${summary[s]} but the table has ${rowTally[s]} '${s}' rows. `
251
+ + `Counts must equal rows — if the table was capped, mark it with fixes_truncated: true + fixes_shown.`);
252
+ }
253
+ }
254
+ }
157
255
  const content = {
158
256
  detailPath: kv.detail_path,
159
257
  baselineRef: {
@@ -161,12 +259,7 @@ export function extractUpgradeProgress(markdown) {
161
259
  sha256: kv.baseline_sha256 ?? '',
162
260
  },
163
261
  transport: kv.transport && kv.transport !== '-' ? kv.transport : null,
164
- summary: {
165
- fixed: intOr(kv, 'fixed', 0),
166
- skipped: intOr(kv, 'skipped', 0),
167
- failed: intOr(kv, 'failed', 0),
168
- pending: intOr(kv, 'pending', 0),
169
- },
262
+ summary,
170
263
  fixes,
171
264
  };
172
265
  const items = fixes.map((f) => {