@cspeach/cli 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -9,7 +9,7 @@
9
9
  * <!-- csforge:cca-manifest
10
10
  * artefact: cca-assessment
11
11
  * title: ZFI / ZSD — S/4HANA 2023 estate
12
- * detail_path: .abapforge/cca/projects/zfi-zsd-2026-04/project.json
12
+ * detail_path: .cspeach/cca/projects/zfi-zsd-2026-04/project.json
13
13
  * project_id: zfi-zsd-2026-04
14
14
  * scope: ZFI* + ZSD*
15
15
  * atc_variant: S4HANA_READINESS_2023
@@ -78,6 +78,13 @@ function intOr(kv, key, fallback) {
78
78
  const n = Number.parseInt(v, 10);
79
79
  return Number.isFinite(n) ? n : fallback;
80
80
  }
81
+ function optionalInt(kv, key) {
82
+ const v = kv[key];
83
+ if (v === undefined)
84
+ return undefined;
85
+ const n = Number.parseInt(v, 10);
86
+ return Number.isFinite(n) ? n : undefined;
87
+ }
81
88
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
82
89
  const CONFIDENCES = ['high', 'medium', 'low'];
83
90
  function asClassification(raw) {
@@ -92,6 +99,36 @@ function asConfidence(raw) {
92
99
  ? v
93
100
  : 'medium';
94
101
  }
102
+ /**
103
+ * Parse the optional 6th table cell on a merged row:
104
+ * `conflict=<label>:<cls>;<label>:<cls>`
105
+ * (rendered by merge-cca.ts:renderCcaMergeManifest). Returns undefined
106
+ * for absent / unrecognisable cells; invalid classification values are
107
+ * skipped pair-wise so one typo doesn't drop the whole record.
108
+ */
109
+ function parseConflictCell(raw) {
110
+ if (!raw)
111
+ return undefined;
112
+ const m = /^conflict=(.+)$/.exec(raw.trim());
113
+ if (!m)
114
+ return undefined;
115
+ const values = {};
116
+ for (const pair of m[1].split(';')) {
117
+ const idx = pair.lastIndexOf(':');
118
+ if (idx === -1)
119
+ continue;
120
+ const label = pair.slice(0, idx).trim();
121
+ const cls = pair.slice(idx + 1).trim().toLowerCase();
122
+ if (label.length === 0)
123
+ continue;
124
+ if (!CLASSIFICATIONS.includes(cls))
125
+ continue;
126
+ values[label] = cls;
127
+ }
128
+ if (Object.keys(values).length === 0)
129
+ return undefined;
130
+ return { values, rule: 'most-restrictive' };
131
+ }
95
132
  export function extractCcaAssessment(markdown) {
96
133
  const { kv, tables } = parseManifestBlock(markdown);
97
134
  if (kv.artefact !== 'cca-assessment') {
@@ -103,27 +140,82 @@ export function extractCcaAssessment(markdown) {
103
140
  if (!kv.project_id) {
104
141
  throw new Error("cca-assessment manifest missing required 'project_id'");
105
142
  }
106
- const classifications = (tables.classifications ?? []).map((row, i) => ({
107
- id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
108
- objectName: row[1] ?? '',
109
- objectType: row[2] ?? '',
110
- classification: asClassification(row[3] ?? 'keep'),
111
- confidence: asConfidence(row[4] ?? 'medium'),
112
- }));
143
+ const classifications = (tables.classifications ?? []).map((row, i) => {
144
+ const conflict = parseConflictCell(row[5]);
145
+ return {
146
+ id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
147
+ objectName: row[1] ?? '',
148
+ objectType: row[2] ?? '',
149
+ classification: asClassification(row[3] ?? 'keep'),
150
+ confidence: asConfidence(row[4] ?? 'medium'),
151
+ ...(conflict ? { conflict } : {}),
152
+ };
153
+ });
154
+ const summary = {
155
+ totalObjects: intOr(kv, 'total_objects', classifications.length),
156
+ keep: intOr(kv, 'keep', 0),
157
+ fix: intOr(kv, 'fix', 0),
158
+ retire: intOr(kv, 'retire', 0),
159
+ redesign: intOr(kv, 'redesign', 0),
160
+ uncategorized: intOr(kv, 'uncategorized', 0),
161
+ };
162
+ // ── D6/D7 manifest integrity (2026-06-12) ───────────────────────────────
163
+ // Battery S4 caught manifests whose counts contradicted their own rows
164
+ // (`retire: 0` next to 7 retire rows) and tables silently truncated
165
+ // (27/104 rows, no marker). These manifests are CHAIN INPUT for
166
+ // /abap-cca-merge — a quiet save corrupts the consolidated estate
167
+ // numbers, so a contradiction fails the extract LOUDLY instead (same
168
+ // philosophy as extract-plan's loud-failure path in save-command.ts).
169
+ const truncated = kv.classifications_truncated === 'true';
170
+ const shown = optionalInt(kv, 'classifications_shown');
171
+ const totalCls = optionalInt(kv, 'classifications_total');
172
+ const rowTally = {
173
+ keep: 0, fix: 0, retire: 0, redesign: 0,
174
+ };
175
+ for (const c of classifications)
176
+ rowTally[c.classification]++;
177
+ if (truncated) {
178
+ if (shown !== undefined && shown !== classifications.length) {
179
+ throw new Error(`cca-assessment manifest contradiction: classifications_shown is ${shown} but the table has ${classifications.length} rows.`);
180
+ }
181
+ for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
182
+ if (rowTally[cls] > summary[cls]) {
183
+ throw new Error(`cca-assessment manifest contradiction: ${rowTally[cls]} '${cls}' rows in the table but the summary says ${cls}: ${summary[cls]}. `
184
+ + `Counts must cover every row even when the table is truncated.`);
185
+ }
186
+ }
187
+ if (totalCls !== undefined && totalCls < classifications.length) {
188
+ throw new Error(`cca-assessment manifest contradiction: classifications_total (${totalCls}) is smaller than the ${classifications.length} rows shown.`);
189
+ }
190
+ }
191
+ else {
192
+ for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
193
+ if (rowTally[cls] !== summary[cls]) {
194
+ throw new Error(`cca-assessment manifest contradiction: summary says ${cls}: ${summary[cls]} but the table has ${rowTally[cls]} '${cls}' rows. `
195
+ + `Counts must equal rows — if the table was capped, mark it with classifications_truncated: true + classifications_shown + classifications_total.`);
196
+ }
197
+ }
198
+ }
199
+ // total_objects must equal the class-count sum in BOTH branches — the
200
+ // summary counts cover the full estate even when the table is capped.
201
+ const expectedTotal = summary.keep + summary.fix + summary.retire + summary.redesign + summary.uncategorized;
202
+ if (summary.totalObjects !== expectedTotal) {
203
+ throw new Error(`cca-assessment manifest contradiction: total_objects is ${summary.totalObjects} but keep+fix+retire+redesign+uncategorized = ${expectedTotal}.`);
204
+ }
113
205
  const content = {
114
206
  detailPath: kv.detail_path,
115
207
  projectId: kv.project_id,
116
208
  scope: kv.scope ?? '',
117
209
  atcVariant: kv.atc_variant ?? '',
118
- summary: {
119
- totalObjects: intOr(kv, 'total_objects', classifications.length),
120
- keep: intOr(kv, 'keep', 0),
121
- fix: intOr(kv, 'fix', 0),
122
- retire: intOr(kv, 'retire', 0),
123
- redesign: intOr(kv, 'redesign', 0),
124
- uncategorized: intOr(kv, 'uncategorized', 0),
125
- },
210
+ summary,
126
211
  classifications,
212
+ ...(truncated
213
+ ? {
214
+ classificationsTruncated: true,
215
+ ...(shown !== undefined ? { classificationsShown: shown } : {}),
216
+ ...(totalCls !== undefined ? { classificationsTotal: totalCls } : {}),
217
+ }
218
+ : {}),
127
219
  };
128
220
  // One InteractionItem per classification entry, default status 'open'
129
221
  // (auto-classified by the AI, awaiting human review).
@@ -11,7 +11,7 @@
11
11
  * <!-- csforge:modernize-manifest
12
12
  * artefact: modernize-result
13
13
  * title: ZDPR_PAYMENTS — modernization
14
- * detail_path: .abapforge/modernize/zdpr-payments/project.json
14
+ * detail_path: .cspeach/modernize/zdpr-payments/project.json
15
15
  * baseline_path: <project-relative path>
16
16
  * baseline_sha256: <hex>
17
17
  * transport: <id or ->
@@ -1,7 +1,96 @@
1
1
  import { parsePlanContent, PLAN_PHASE_STATUSES } from './plan-schema.js';
2
+ import { computeNextPhase, isPhaseSatisfied } from './plan-run.js';
2
3
  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.
4
+ // 2026-06-06 live-smoke lesson #4 (Postel): normalise model-emitted
5
+ // `work` blocks instead of rejecting them. Models naturally write
6
+ // `"transport": null` for "none" (schema fields are optional, NOT
7
+ // nullable) and a structured OBJECT for notes (a design phase's
8
+ // decision register) — both reasonable; both killed a fully correct
9
+ // phase write-back. null -> absent; object notes -> JSON string.
10
+ function normalizeWorkFields(p) {
11
+ const w = p?.work;
12
+ if (!w || typeof w !== 'object')
13
+ return;
14
+ for (const k of ['generated', 'transport', 'snapshot', 'notes', 'binding']) {
15
+ if (w[k] === null)
16
+ delete w[k];
17
+ }
18
+ if (w.notes !== undefined && typeof w.notes !== 'string') {
19
+ w.notes = JSON.stringify(w.notes);
20
+ }
21
+ }
22
+ /**
23
+ * Expand a compact `changed` map against the prior envelope content into a
24
+ * full PlanContent candidate (still Zod-validated by the caller). The
25
+ * dangerous failure here is silently dropping or duplicating phase content
26
+ * during the merge — hence: every changed key must name an existing phase,
27
+ * an inner `id` may not contradict its key, and the phase list is ALWAYS
28
+ * prior.phases mapped 1:1 (no additions, no removals, no reordering).
29
+ */
30
+ function expandCompactContent(changedRaw, prior, rawStatuses) {
31
+ if (!prior) {
32
+ throw new Error('compact plan-manifest ("changed" without "content") is only valid on a resume turn — ' +
33
+ 'no prior plan content is available to merge into; seed sessions must emit the full shape');
34
+ }
35
+ if (typeof changedRaw !== 'object' || changedRaw === null || Array.isArray(changedRaw)) {
36
+ throw new Error('plan-manifest "changed" must be an object map keyed by phase id');
37
+ }
38
+ const changed = changedRaw;
39
+ const knownIds = new Set(prior.phases.map((p) => p.id));
40
+ for (const [id, overlay] of Object.entries(changed)) {
41
+ if (!knownIds.has(id)) {
42
+ throw new Error(`plan-manifest changed["${id}"] references an unknown phase id; ` +
43
+ `known phases: ${[...knownIds].join(', ')}. ` +
44
+ 'To add or remove phases, emit the full shape with "content" instead.');
45
+ }
46
+ if (typeof overlay !== 'object' || overlay === null || Array.isArray(overlay)) {
47
+ throw new Error(`plan-manifest changed["${id}"] must be an object carrying the phase's updated fields`);
48
+ }
49
+ const innerId = overlay.id;
50
+ if (innerId !== undefined && innerId !== id) {
51
+ throw new Error(`plan-manifest changed["${id}"] carries a mismatched "id": ${String(innerId)}`);
52
+ }
53
+ // Lesson-#4 parity for the overlay itself: models write `"work": null`
54
+ // for "nothing to record". null/undefined work means "keep prior work" —
55
+ // drop the key so the spread below doesn't clobber the prior phase's work.
56
+ if (overlay.work == null) {
57
+ delete overlay.work;
58
+ }
59
+ normalizeWorkFields(overlay);
60
+ }
61
+ // 1:1 map over the PRIOR phase list — changed entries overlay their phase,
62
+ // everything else passes through verbatim from the envelope.
63
+ const phases = prior.phases.map((p) => changed[p.id] !== undefined ? { ...p, ...changed[p.id], id: p.id } : p);
64
+ // Recompute summary CLI-side from the merged statuses (the compact shape
65
+ // does not carry one — fewer tokens, and it cannot drift from statuses).
66
+ // Invalid/missing status values are skipped here; the shared statuses
67
+ // validation loop in extractPlan throws the precise error afterwards.
68
+ const statuses = {};
69
+ for (const p of phases) {
70
+ const s = rawStatuses[p.id];
71
+ if (typeof s === 'string' && PLAN_PHASE_STATUSES.includes(s)) {
72
+ statuses[p.id] = s;
73
+ }
74
+ }
75
+ const next = computeNextPhase(phases, statuses);
76
+ const summary = {
77
+ total: phases.length,
78
+ // Waived phases (C1) count as validated here — summary.validated means
79
+ // "done for DAG purposes", same as the tracker header tally.
80
+ validated: phases.filter((p) => isPhaseSatisfied(statuses[p.id])).length,
81
+ blocked: phases.filter((p) => statuses[p.id] === 'blocked').length,
82
+ next: next?.id ?? null,
83
+ };
84
+ return { ...prior, phases, summary };
85
+ }
86
+ /**
87
+ * Parse the LAST plan-manifest block out of the skill's markdown output.
88
+ * `priorContent` is the validated content of the envelope being resumed —
89
+ * required to expand the compact shape; ignored for the full shape (seed
90
+ * sessions pass nothing).
91
+ */
92
+ export function extractPlan(markdown, priorContent) {
93
+ // Last block wins — defensive against a turn emitting more than one block.
5
94
  const re = new RegExp(MANIFEST_RE.source, MANIFEST_RE.flags);
6
95
  let m;
7
96
  let last = null;
@@ -24,46 +113,50 @@ export function extractPlan(markdown) {
24
113
  if (typeof obj.title !== 'string' || obj.title.trim().length === 0) {
25
114
  throw new Error('plan-manifest is missing a non-empty "title"');
26
115
  }
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);
56
- }
116
+ if (typeof obj.statuses !== 'object' || obj.statuses === null || Array.isArray(obj.statuses)) {
117
+ throw new Error('plan-manifest is missing the "statuses" object');
118
+ }
119
+ const rawStatuses = obj.statuses;
120
+ // Dual-shape manifests are the one silent-data-loss path: a model that
121
+ // copies stale `content` out of <plan_state> AND emits correct `changed`
122
+ // updates would persist the stale plan with no signal. Refuse loudly.
123
+ if (obj.content !== undefined && obj.changed !== undefined) {
124
+ throw new Error('plan-manifest must contain either "content" (full shape) or "changed" (compact shape), ' +
125
+ 'not both — re-emit using exactly one');
126
+ }
127
+ let contentInput;
128
+ if (obj.content !== undefined) {
129
+ // FULL shape — Mode 1 create, plus the backward-compatible / structural-
130
+ // change path on resume.
131
+ // 2026-06-06 live-smoke lesson #2: the model cannot know the consumed
132
+ // spec-gap's real path/sha — that provenance lives CLI-side in the
133
+ // envelope's promotedFrom (threaded by the --from save flow). Models
134
+ // emit placeholders like {"path":"@equip","sha256":""}, and a junk
135
+ // provenance pointer must not cost the whole plan: strip it.
136
+ const rawContent = obj.content;
137
+ if (rawContent && typeof rawContent === 'object' && rawContent.from?.specGap) {
138
+ const sha = String(rawContent.from.specGap.sha256 ?? '');
139
+ if (!/^[0-9a-f]{64}$/.test(sha))
140
+ delete rawContent.from.specGap;
141
+ }
142
+ const rawPhases = rawContent?.phases;
143
+ if (Array.isArray(rawPhases)) {
144
+ for (const p of rawPhases)
145
+ normalizeWorkFields(p);
57
146
  }
147
+ contentInput = obj.content;
58
148
  }
59
- const pc = parsePlanContent(obj.content);
149
+ else if (obj.changed !== undefined) {
150
+ // COMPACT shape (A2) — resume turns only; merged into the prior content.
151
+ contentInput = expandCompactContent(obj.changed, priorContent, rawStatuses);
152
+ }
153
+ else {
154
+ throw new Error('plan-manifest must carry either "content" (full shape) or "changed" (compact resume shape)');
155
+ }
156
+ const pc = parsePlanContent(contentInput);
60
157
  if (!pc.ok) {
61
158
  throw new Error(`plan-manifest content failed validation:\n ${pc.errors.join('\n ')}`);
62
159
  }
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
160
  const statuses = {};
68
161
  for (const p of pc.content.phases) {
69
162
  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 ->
@@ -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
@@ -82,6 +86,13 @@ function intOr(kv, key, fallback) {
82
86
  const n = Number.parseInt(v, 10);
83
87
  return Number.isFinite(n) ? n : fallback;
84
88
  }
89
+ function optionalInt(kv, key) {
90
+ const v = kv[key];
91
+ if (v === undefined)
92
+ return undefined;
93
+ const n = Number.parseInt(v, 10);
94
+ return Number.isFinite(n) ? n : undefined;
95
+ }
85
96
  function buildItems(count, defaultStatus) {
86
97
  const out = [];
87
98
  for (let i = 0; i < count; i++) {
@@ -107,24 +118,66 @@ export function extractUpgradeScan(markdown) {
107
118
  if (!kv.detail_path) {
108
119
  throw new Error("upgrade-scan manifest missing required 'detail_path'");
109
120
  }
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
- }));
121
+ const findings = (tables.findings ?? []).map((row, i) => {
122
+ const finding = {
123
+ id: row[0] ?? `finding-${String(i + 1).padStart(3, '0')}`,
124
+ objectName: row[1] ?? '',
125
+ objectType: row[2] ?? '',
126
+ rule: row[3] ?? '',
127
+ severity: row[4] ?? '',
128
+ };
129
+ // line: cell 6 (row[5]); include only if a finite integer (skip '-', '', non-numeric).
130
+ if (row[5] !== undefined) {
131
+ const n = Number.parseInt(row[5], 10);
132
+ if (Number.isFinite(n))
133
+ finding.line = n;
134
+ }
135
+ // message: cells 7+ rejoined with ' | ' so a '|' inside the message survives.
136
+ if (row.length > 6) {
137
+ const message = row.slice(6).join(' | ').trim();
138
+ if (message.length > 0)
139
+ finding.message = message;
140
+ }
141
+ return finding;
142
+ });
143
+ const summary = {
144
+ total: intOr(kv, 'total', findings.length),
145
+ autoFixable: intOr(kv, 'auto_fixable', 0),
146
+ manualReview: intOr(kv, 'manual_review', 0),
147
+ suppressed: intOr(kv, 'suppressed', 0),
148
+ };
149
+ // ── D7 manifest integrity (2026-06-12) ──────────────────────────────────
150
+ // The scan manifest is chain input for /abap-upgrade-fix slicing and the
151
+ // merge's pending-fill. A `total` that disagrees with the rows means
152
+ // either the model truncated the table without marking it (D7) or
153
+ // miscounted (D6) — both corrupt downstream reconciliation, so fail the
154
+ // extract loudly (extract-plan loud-failure style).
155
+ const truncated = kv.findings_truncated === 'true';
156
+ const shown = optionalInt(kv, 'findings_shown');
157
+ if (truncated) {
158
+ if (shown !== undefined && shown !== findings.length) {
159
+ throw new Error(`upgrade-scan manifest contradiction: findings_shown is ${shown} but the table has ${findings.length} rows.`);
160
+ }
161
+ if (summary.total < findings.length) {
162
+ throw new Error(`upgrade-scan manifest contradiction: total (${summary.total}) is smaller than the ${findings.length} rows shown.`);
163
+ }
164
+ }
165
+ else if (summary.total !== findings.length) {
166
+ throw new Error(`upgrade-scan manifest contradiction: total is ${summary.total} but the table has ${findings.length} finding rows. `
167
+ + `Counts must equal rows — if the table was capped, mark it with findings_truncated: true + findings_shown.`);
168
+ }
117
169
  const content = {
118
170
  detailPath: kv.detail_path,
119
171
  atcVariant: kv.atc_variant ?? '',
120
172
  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
- },
173
+ summary,
127
174
  findings,
175
+ ...(truncated
176
+ ? {
177
+ findingsTruncated: true,
178
+ ...(shown !== undefined ? { findingsShown: shown } : {}),
179
+ }
180
+ : {}),
128
181
  };
129
182
  // One InteractionItem per finding, default status 'open'.
130
183
  const items = findings.map((f) => ({
@@ -154,6 +207,49 @@ export function extractUpgradeProgress(markdown) {
154
207
  finding: row[2] ?? '',
155
208
  status: row[3] ?? 'pending',
156
209
  }));
210
+ const summary = {
211
+ fixed: intOr(kv, 'fixed', 0),
212
+ skipped: intOr(kv, 'skipped', 0),
213
+ failed: intOr(kv, 'failed', 0),
214
+ pending: intOr(kv, 'pending', 0),
215
+ };
216
+ // ── D6/D7 manifest integrity (2026-06-12) ───────────────────────────────
217
+ // Same backstop as extract-cca / the scan branch above: the progress
218
+ // manifest is chain input for /abap-upgrade-merge — summary counts that
219
+ // contradict the fixes rows corrupt the consolidated team numbers, so a
220
+ // contradiction fails the extract LOUDLY. Unknown statuses tally as
221
+ // 'pending', matching the InteractionItem coercion below.
222
+ const STATUSES = ['fixed', 'skipped', 'failed', 'pending'];
223
+ const rowTally = {
224
+ fixed: 0, skipped: 0, failed: 0, pending: 0,
225
+ };
226
+ for (const f of fixes) {
227
+ const s = STATUSES.includes(f.status)
228
+ ? f.status
229
+ : 'pending';
230
+ rowTally[s]++;
231
+ }
232
+ const fixesTruncated = kv.fixes_truncated === 'true';
233
+ const fixesShown = optionalInt(kv, 'fixes_shown');
234
+ if (fixesTruncated) {
235
+ if (fixesShown !== undefined && fixesShown !== fixes.length) {
236
+ throw new Error(`upgrade-progress manifest contradiction: fixes_shown is ${fixesShown} but the table has ${fixes.length} rows.`);
237
+ }
238
+ for (const s of STATUSES) {
239
+ if (rowTally[s] > summary[s]) {
240
+ throw new Error(`upgrade-progress manifest contradiction: ${rowTally[s]} '${s}' rows in the table but the summary says ${s}: ${summary[s]}. `
241
+ + `Counts must cover every row even when the table is truncated.`);
242
+ }
243
+ }
244
+ }
245
+ else {
246
+ for (const s of STATUSES) {
247
+ if (rowTally[s] !== summary[s]) {
248
+ throw new Error(`upgrade-progress manifest contradiction: summary says ${s}: ${summary[s]} but the table has ${rowTally[s]} '${s}' rows. `
249
+ + `Counts must equal rows — if the table was capped, mark it with fixes_truncated: true + fixes_shown.`);
250
+ }
251
+ }
252
+ }
157
253
  const content = {
158
254
  detailPath: kv.detail_path,
159
255
  baselineRef: {
@@ -161,12 +257,7 @@ export function extractUpgradeProgress(markdown) {
161
257
  sha256: kv.baseline_sha256 ?? '',
162
258
  },
163
259
  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
- },
260
+ summary,
170
261
  fixes,
171
262
  };
172
263
  const items = fixes.map((f) => {
@@ -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 } 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';