@cspeach/cli 0.8.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,254 @@
1
+ // cspeach-cli/src/projects/plan-run.ts
2
+ //
3
+ // Pure plan-execution helpers for /abap-plan --resume (B3, 2026-06-06).
4
+ // No I/O here — file reads, prompts, and the runTurn call live in
5
+ // commands/plan-resume.ts. Keeping these pure makes the next-phase
6
+ // picker, the tracker renderer, and the revision builder unit-testable
7
+ // without touching disk.
8
+ import chalk from 'chalk';
9
+ /** Statuses a dead session may have left behind — resumable, picked before fresh `todo`s. */
10
+ const IN_PROGRESS = ['designing', 'building', 'verifying'];
11
+ /**
12
+ * C1 (2026-06-11, D30 waiver) — a phase counts as DONE for DAG purposes when
13
+ * it is 'validated' OR 'validated-with-waiver' (exit gate not met, but the
14
+ * user explicitly waived it). Single predicate so eligibility, summary
15
+ * counting, and the "plan complete" checks can never drift apart.
16
+ */
17
+ export function isPhaseSatisfied(s) {
18
+ return s === 'validated' || s === 'validated-with-waiver';
19
+ }
20
+ /**
21
+ * Phase status lives in interaction.items (store convention). Build the
22
+ * id→status map; a phase with no matching item defaults to 'todo' rather
23
+ * than failing — the envelope passed top-level validation, so a missing
24
+ * item is a seeding gap, not corruption.
25
+ */
26
+ export function statusesFromItems(items, phases) {
27
+ const byId = new Map(items.map((it) => [it.id, it.status]));
28
+ const out = {};
29
+ for (const p of phases)
30
+ out[p.id] = byId.get(p.id) ?? 'todo';
31
+ return out;
32
+ }
33
+ /**
34
+ * The next phase a resume session should execute: first phase (in the
35
+ * bottom-up `phases[]` order) that is not yet terminal AND whose
36
+ * entryCriteria are all 'validated'. In-progress statuses (a prior
37
+ * session died mid-phase) qualify the same as 'todo' — the phase is
38
+ * re-entered from the top, gates and grounding included.
39
+ */
40
+ export function computeNextPhase(phases, statuses) {
41
+ for (const p of phases) {
42
+ const s = statuses[p.id] ?? 'todo';
43
+ if (isPhaseSatisfied(s) || s === 'blocked')
44
+ continue;
45
+ if (s !== 'todo' && !IN_PROGRESS.includes(s))
46
+ continue;
47
+ if (p.entryCriteria.every((dep) => isPhaseSatisfied(statuses[dep])))
48
+ return p;
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Plan-progress board printed before and after every resume turn (and by
54
+ * `--status` for plan envelopes). Plain text — flows through chunkEmitter
55
+ * in Ink mode and console.log in classic mode alike.
56
+ *
57
+ * ─ Plan: HR Dayforce Extract (v3) ── 2/7 validated ─
58
+ * ✔ c1.types abap-data-model → ZDOM_DAYF_STATUS (S4HK903412)
59
+ * ▶ c1.orchestration abap-generate ← THIS SESSION
60
+ * ○ c1.integration abap-generate (needs c1.orchestration)
61
+ */
62
+ // Human phase labels — a phase is named for the KIND of work it does, so a
63
+ // non-ABAP reader can follow the plan. The exit-gate / produced objects carry
64
+ // the specifics on the detail line.
65
+ const LAYER_LABELS = {
66
+ 'types': 'Types',
67
+ 'persistence': 'Tables & structures',
68
+ 'data-model': 'CDS views',
69
+ 'behavior': 'Behaviour & validations',
70
+ 'service': 'Service binding',
71
+ 'orchestration': 'Jobs & reports',
72
+ 'integration': 'Integration',
73
+ 'ui': 'UI',
74
+ };
75
+ const DELEGATE_LABELS = {
76
+ 'abap-design': 'Design',
77
+ 'abap-test': 'Tests',
78
+ 'abap-eml': 'Behaviour logic',
79
+ 'abap-segw': 'OData (SEGW)',
80
+ };
81
+ function phaseTitle(p) {
82
+ // C2 (2026-06-11, D29): an explicit seed-time title wins — the seed prose
83
+ // instructs short human names ("Design", "Tables", "Service"). The label
84
+ // maps remain the fallback for envelopes seeded before titles existed.
85
+ const explicit = p.title?.trim();
86
+ if (explicit)
87
+ return explicit;
88
+ return DELEGATE_LABELS[p.delegateTo] ?? LAYER_LABELS[p.layer] ?? tidyId(p.id);
89
+ }
90
+ function tidyId(id) {
91
+ const tail = id.split('.').pop() ?? id;
92
+ return tail.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
93
+ }
94
+ // A short plain-language hint from the exit gate: the first clause, clamped.
95
+ function shortGate(gate, n = 60) {
96
+ const first = String(gate ?? '').split(/[.;]/)[0].trim();
97
+ const s = first.length >= 10 ? first : String(gate ?? '').trim();
98
+ return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
99
+ }
100
+ // The detail line: what a done phase produced, else what the phase is about.
101
+ function phaseDetail(p, s) {
102
+ if (isPhaseSatisfied(s)) {
103
+ const objs = (p.work?.generated ?? []).join(', ');
104
+ return objs || shortGate(p.exitGate);
105
+ }
106
+ return shortGate(p.exitGate);
107
+ }
108
+ /**
109
+ * Plan-progress board — a Claude-Code-style checkbox task list. Each phase is
110
+ * named for the work it does (not its mechanical id), so the reader can follow
111
+ * along; ☒ = done (dim), ☐ = to do, the active phase is bold with a `◀ now`
112
+ * marker, blocked is red. Flows through chunkEmitter (Ink) and console.log
113
+ * (classic) alike — chalk degrades to plain text on a non-colour stream.
114
+ */
115
+ export function renderPlanTracker(a) {
116
+ const PEACH = '#F5A623';
117
+ const lines = [];
118
+ const total = a.content.phases.length;
119
+ // Waived phases count as done in the header tally — the DAG treats them as
120
+ // satisfied; the per-line "✓* (waived)" marker carries the distinction.
121
+ const validated = a.content.phases.filter((p) => isPhaseSatisfied(a.statuses[p.id])).length;
122
+ if (a.includeHeader !== false) {
123
+ lines.push(chalk.dim(`─ ${a.title} (v${a.version}) · ${validated}/${total} done ─`));
124
+ lines.push('');
125
+ }
126
+ for (const p of a.content.phases) {
127
+ const s = a.statuses[p.id] ?? 'todo';
128
+ const isCurrent = a.currentId != null && p.id === a.currentId;
129
+ const title = phaseTitle(p);
130
+ const detail = phaseDetail(p, s);
131
+ const dash = detail ? ` — ${detail}` : '';
132
+ if (s === 'validated') {
133
+ lines.push(chalk.dim(` ☒ ${title}${dash}`));
134
+ }
135
+ else if (s === 'validated-with-waiver') {
136
+ // C1 (D30 waiver) — done-but-waived: the exit gate was never met; the
137
+ // user accepted it anyway. Distinct marker so a tracker reader can tell
138
+ // a verified phase from a waived one at a glance.
139
+ lines.push(chalk.dim(` ☒ ${title}${dash}`) + ` ${chalk.yellow('✓* (waived)')}`);
140
+ }
141
+ else if (s === 'blocked') {
142
+ lines.push(` ${chalk.red('☐')} ${chalk.red(title)} ${chalk.red('— blocked')}${detail ? chalk.dim(` · ${detail}`) : ''}`);
143
+ }
144
+ else if (isCurrent) {
145
+ lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash} ${chalk.hex(PEACH).bold('◀ now')}`);
146
+ }
147
+ else if (IN_PROGRESS.includes(s)) {
148
+ lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash} ${chalk.dim(`(${s})`)}`);
149
+ }
150
+ else {
151
+ lines.push(` ☐ ${title}${chalk.dim(dash)}`);
152
+ }
153
+ }
154
+ return lines.join('\n');
155
+ }
156
+ /* ── plan-complete chaining (C2, 2026-06-11, D24) ───────────────────────── */
157
+ /**
158
+ * SRVB-shaped object name. Two naming families seen live + documented in
159
+ * .claude/rules/rap-patterns.md:
160
+ * - ADT default OData suffix: ZUI_PM_MAINTREQ_O4 / ..._O2
161
+ * - rap-patterns convention: ZSalesOrder_UI_V4 / _UI_V2 / _API_V4 / _API_V2
162
+ */
163
+ const SRVB_NAME_RE = /^[zy]\w*(?:_o[24]|_(?:ui|api)_v[24])$/i;
164
+ /**
165
+ * The exact service-binding name a completed plan produced, for the
166
+ * /abap-fiori-build hand-off. Resolution ladder (newest phase wins at
167
+ * each rung — a structurally-revised plan may carry several):
168
+ * 1. explicit `work.binding` (the seed/resume prose instructs the service
169
+ * phase to record it)
170
+ * 2. an SRVB-shaped name in a service-layer phase's `work.generated`
171
+ * 3. an SRVB-shaped name anywhere in `work.generated` (binding built
172
+ * outside a service-layer phase)
173
+ * Returns null when nothing matches — the caller still mentions
174
+ * /abap-fiori-build generically.
175
+ */
176
+ export function findServiceBinding(phases) {
177
+ for (let i = phases.length - 1; i >= 0; i--) {
178
+ const explicit = phases[i].work?.binding?.trim();
179
+ if (explicit)
180
+ return explicit;
181
+ }
182
+ const scan = (ps) => {
183
+ for (let i = ps.length - 1; i >= 0; i--) {
184
+ const hit = (ps[i].work?.generated ?? []).find((n) => SRVB_NAME_RE.test(n.trim()));
185
+ if (hit)
186
+ return hit.trim();
187
+ }
188
+ return null;
189
+ };
190
+ return scan(phases.filter((p) => p.layer === 'service')) ?? scan(phases);
191
+ }
192
+ /**
193
+ * The lines printed when a plan has no next phase because EVERY phase is
194
+ * satisfied. Single construction site for both call points (preparePlanResume
195
+ * re-opening a finished plan, finishPlanResume completing the last phase) so
196
+ * the D24 fiori chain can never drift between them.
197
+ *
198
+ * A UI-less backend stack (service layer present, no ui phase) chains to
199
+ * /abap-fiori-build — with the EXACT binding name when resolvable. A plan
200
+ * that already built its UI, or never built a service, gets no chain line.
201
+ */
202
+ export function planCompletionLines(phases) {
203
+ const lines = ['Plan complete — every phase validated.'];
204
+ const hasUi = phases.some((p) => p.layer === 'ui' || p.delegateTo === 'abap-fiori-build');
205
+ const hasService = phases.some((p) => p.layer === 'service');
206
+ if (!hasUi && hasService) {
207
+ const binding = findServiceBinding(phases);
208
+ lines.push(binding
209
+ ? `Backend complete. Next: /abap-fiori-build — point it at ${binding}.`
210
+ : 'Backend complete. Next: /abap-fiori-build — point it at the published service binding.');
211
+ }
212
+ lines.push('Consider /abap-preflight on the produced transport(s) before release.');
213
+ return lines;
214
+ }
215
+ /**
216
+ * Build the version-N+1 envelope from the original plan envelope and the
217
+ * manifest block a resume turn emitted.
218
+ *
219
+ * Invariants preserved:
220
+ * - id / createdAt / createdBy / title / source / promotedFrom unchanged
221
+ * (the extract's title is intentionally IGNORED — title drift would
222
+ * break the multi-file same-source detection in status.ts)
223
+ * - history is append-only; version === history.length stays true
224
+ * - item comments / answers added via the editor survive the revision —
225
+ * only the status comes from the new extract
226
+ */
227
+ export function buildPlanRevision(original, extract, author, nowIso) {
228
+ if (original.artefactType !== 'plan') {
229
+ throw new Error(`buildPlanRevision expects a plan envelope, got ${original.artefactType}`);
230
+ }
231
+ const oldItems = new Map(original.interaction.items.map((it) => [it.id, it]));
232
+ const mergedItems = extract.items.map((ni) => {
233
+ const old = oldItems.get(ni.id);
234
+ return old ? { ...old, status: ni.status } : ni;
235
+ });
236
+ const oldStatuses = statusesFromItems(original.interaction.items, original.content.phases);
237
+ const diffs = [];
238
+ for (const p of extract.content.phases) {
239
+ const before = oldStatuses[p.id] ?? 'todo';
240
+ const after = extract.statuses[p.id];
241
+ if (before !== after)
242
+ diffs.push(`${p.id}: ${before} → ${after}`);
243
+ }
244
+ const summary = diffs.length > 0 ? diffs.join('; ') : 'resume run — no phase status change';
245
+ return {
246
+ ...original,
247
+ lastEditedAt: nowIso,
248
+ lastEditedBy: author,
249
+ version: original.version + 1,
250
+ content: extract.content,
251
+ interaction: { items: mergedItems },
252
+ history: [...original.history, { at: nowIso, by: author, action: 'edited', summary }],
253
+ };
254
+ }
@@ -0,0 +1,210 @@
1
+ // cspeach-cli/src/projects/plan-schema.ts
2
+ //
3
+ // Deep validation for the 'plan' artefact content (/abap-plan envelope).
4
+ //
5
+ // Split of responsibilities, matching the rest of the store:
6
+ // - validate.ts — top-level envelope shape, hand-rolled (adds 'plan'
7
+ // to its known types + status enum; content is opaque)
8
+ // - plan-schema.ts — Zod schema for the PlanContent payload, invoked by
9
+ // the --resume path before any phase is executed
10
+ //
11
+ // The TS interfaces live in types.ts with the other content shapes; the
12
+ // `z.ZodType<PlanContent>` annotation below keeps schema and interface in
13
+ // lockstep at compile time.
14
+ import { z } from 'zod';
15
+ /** Keep in sync with PlanLayer in types.ts. */
16
+ export const PLAN_LAYERS = [
17
+ 'types', 'persistence', 'data-model', 'behavior', 'service',
18
+ 'orchestration', 'integration', 'ui',
19
+ ];
20
+ /**
21
+ * Existing skills a phase may delegate to. Keep in sync with
22
+ * PlanDelegateSkill in types.ts. Extending = one entry here + one in the
23
+ * union there. Typical per-layer defaults (guidance lives in the
24
+ * abap-plan SKILL.md, not enforced here):
25
+ * types/persistence → abap-data-model
26
+ * data-model/behavior/service → abap-rap (abap-eml for handler logic)
27
+ * orchestration/integration → abap-generate (abap-segw for ECC OData)
28
+ */
29
+ export const PLAN_DELEGATE_SKILLS = [
30
+ 'abap-design', 'abap-data-model', 'abap-rap', 'abap-eml',
31
+ 'abap-generate', 'abap-segw', 'abap-test', 'abap-fiori-build',
32
+ 'abap-extend-model',
33
+ ];
34
+ /**
35
+ * Keep in sync with ItemStatusByType['plan'] in types.ts.
36
+ * 'validated-with-waiver' (C1, 2026-06-11): the exit gate was NOT met but the
37
+ * user explicitly waived it — DAG eligibility treats it like 'validated'
38
+ * (see isPhaseSatisfied in plan-run.ts).
39
+ */
40
+ export const PLAN_PHASE_STATUSES = [
41
+ 'todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked',
42
+ ];
43
+ const SHA256_RE = /^[0-9a-f]{64}$/;
44
+ const planPhaseManifestSchema = z.object({
45
+ reads: z.array(z.string()),
46
+ rules: z.array(z.string()),
47
+ writesBack: z.array(z.string()),
48
+ });
49
+ // .passthrough(): live smoke showed models enrich `work` with valuable
50
+ // extras (blockedReason, unblockPath, groundingChecks) — strip mode was
51
+ // silently discarding them from the persisted envelope.
52
+ const planPhaseWorkSchema = z.object({
53
+ generated: z.array(z.string()).optional(),
54
+ transport: z.string().optional(),
55
+ snapshot: z.string().optional(),
56
+ notes: z.string().optional(),
57
+ // C2 (D24): exact SRVB name for the plan-complete /abap-fiori-build chain.
58
+ binding: z.string().optional(),
59
+ }).passthrough();
60
+ const planPhaseSchema = z.object({
61
+ id: z.string().min(1),
62
+ // C2 (D29): optional short human name for the tracker board; old envelopes
63
+ // without it still parse and render via the layer/delegate label fallback.
64
+ title: z.string().min(1).optional(),
65
+ component: z.string().min(1),
66
+ layer: z.enum(PLAN_LAYERS),
67
+ entryCriteria: z.array(z.string()),
68
+ delegateTo: z.enum(PLAN_DELEGATE_SKILLS),
69
+ manifest: planPhaseManifestSchema,
70
+ exitGate: z.string().min(1),
71
+ approval: z.boolean().optional(),
72
+ work: planPhaseWorkSchema.optional(),
73
+ });
74
+ /**
75
+ * Validates the discovered live stack for a revision plan (RevisionTarget in
76
+ * types.ts). All layer fields are optional/nullable — a partial stack (e.g.
77
+ * a table view with no MDE) is valid. Only `anchor`, `uiKind`, and `binding`
78
+ * are required shape — layers is always an object but all its members are opt.
79
+ */
80
+ const revisionTargetSchema = z
81
+ .object({
82
+ anchor: z.string().min(1),
83
+ uiKind: z.enum(['fiori-elements', 'freestyle', 'none']),
84
+ binding: z.string().nullable(),
85
+ // C2 (2026-06-26): freestyle app on-disk location (see types.ts). Additive
86
+ // and optional; the superRefine below makes it mandatory ONLY for freestyle.
87
+ app: z.object({ dir: z.string().min(1) }).optional(),
88
+ layers: z.object({
89
+ table: z.string().nullable().optional(),
90
+ interfaceView: z.string().nullable().optional(),
91
+ projectionView: z.string().nullable().optional(),
92
+ bdef: z.string().nullable().optional(),
93
+ behaviorPool: z.string().nullable().optional(),
94
+ mde: z.string().nullable().optional(),
95
+ serviceDef: z.string().nullable().optional(),
96
+ }),
97
+ })
98
+ .superRefine((rev, ctx) => {
99
+ // A freestyle revision must record where the app lives on disk, or the
100
+ // ui-phase delegation to /abap-fiori-build amend has no appDir to amend.
101
+ if (rev.uiKind === 'freestyle' && !rev.app) {
102
+ ctx.addIssue({
103
+ code: z.ZodIssueCode.custom,
104
+ path: ['app'],
105
+ message: 'uiKind "freestyle" requires `revision.app.dir` (the freestyle app location for amend)',
106
+ });
107
+ }
108
+ });
109
+ // Input type is `unknown` (third param): the `.default({})` on `from` means
110
+ // the schema ACCEPTS input without the key while still OUTPUTTING PlanContent.
111
+ export const planContentSchema = z
112
+ .object({
113
+ project: z.object({
114
+ goal: z.string().min(1),
115
+ source: z.string(),
116
+ target: z.string(),
117
+ // Absent mode parses as undefined (treated as 'create') — back-compat.
118
+ mode: z.enum(['create', 'revision']).optional(),
119
+ }),
120
+ // Tolerant by lesson (2026-06-06 live smoke): the model omitted the whole
121
+ // `from` key for a goal-only plan and the strict schema killed the save.
122
+ // Missing `from` normalises to {} — only the inner specGap ref is shaped.
123
+ from: z
124
+ .object({
125
+ specGap: z
126
+ .object({
127
+ path: z.string().min(1),
128
+ sha256: z.string().regex(SHA256_RE, 'must be a lowercase hex sha256'),
129
+ })
130
+ .optional(),
131
+ })
132
+ .default({}),
133
+ // Present when project.mode='revision'. Optional here; the superRefine
134
+ // below enforces the cross-field rule: mode='revision' requires revision.
135
+ revision: revisionTargetSchema.optional(),
136
+ phases: z.array(planPhaseSchema).min(1),
137
+ summary: z.object({
138
+ total: z.number().int().nonnegative(),
139
+ validated: z.number().int().nonnegative(),
140
+ blocked: z.number().int().nonnegative(),
141
+ next: z.string().nullable(),
142
+ }),
143
+ })
144
+ .superRefine((content, ctx) => {
145
+ // mode:'revision' requires a revision target map (the discovered live stack).
146
+ // Absent mode is treated as 'create' — this check is a no-op in that case.
147
+ if (content.project.mode === 'revision' && !content.revision) {
148
+ ctx.addIssue({
149
+ code: z.ZodIssueCode.custom,
150
+ path: ['revision'],
151
+ message: 'mode "revision" requires a `revision` target map (discovered live stack)',
152
+ });
153
+ }
154
+ // Phase ids must be unique, and entryCriteria may only reference phases
155
+ // EARLIER in the array. Bottom-up ordering by construction — this is what
156
+ // guarantees resume is deterministic and the dependency graph acyclic
157
+ // (a forward or self reference can never form, so no cycle can either).
158
+ const seen = new Set();
159
+ content.phases.forEach((p, i) => {
160
+ if (seen.has(p.id)) {
161
+ ctx.addIssue({
162
+ code: z.ZodIssueCode.custom,
163
+ path: ['phases', i, 'id'],
164
+ message: `Duplicate phase id: ${p.id}`,
165
+ });
166
+ }
167
+ p.entryCriteria.forEach((dep, j) => {
168
+ if (!seen.has(dep)) {
169
+ const known = content.phases.some((q) => q.id === dep);
170
+ ctx.addIssue({
171
+ code: z.ZodIssueCode.custom,
172
+ path: ['phases', i, 'entryCriteria', j],
173
+ message: known
174
+ ? `entryCriteria '${dep}' must reference an earlier phase (plans are ordered bottom-up)`
175
+ : `entryCriteria references unknown phase: ${dep}`,
176
+ });
177
+ }
178
+ });
179
+ seen.add(p.id);
180
+ });
181
+ if (content.summary.total !== content.phases.length) {
182
+ ctx.addIssue({
183
+ code: z.ZodIssueCode.custom,
184
+ path: ['summary', 'total'],
185
+ message: `summary.total (${content.summary.total}) must equal phases.length (${content.phases.length})`,
186
+ });
187
+ }
188
+ if (content.summary.next !== null && !seen.has(content.summary.next)) {
189
+ ctx.addIssue({
190
+ code: z.ZodIssueCode.custom,
191
+ path: ['summary', 'next'],
192
+ message: `summary.next references unknown phase: ${content.summary.next}`,
193
+ });
194
+ }
195
+ });
196
+ /**
197
+ * Validate an unknown value as PlanContent. Returns flattened, path-prefixed
198
+ * error strings suitable for printing directly in the REPL — the --resume
199
+ * command bails on the first failed parse rather than executing a phase
200
+ * against a malformed plan.
201
+ */
202
+ export function parsePlanContent(input) {
203
+ const r = planContentSchema.safeParse(input);
204
+ if (r.success)
205
+ return { ok: true, content: r.data };
206
+ return {
207
+ ok: false,
208
+ errors: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
209
+ };
210
+ }
@@ -1,5 +1,5 @@
1
1
  import { readProjectFile } from './status.js';
2
- import { buildPromotedFromSnapshot, validatePromotionSource } from './promote.js';
2
+ import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
3
3
  import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
4
4
  /**
5
5
  * Per-skill predecessor map. v0.7 admits multi-source via array values —
@@ -18,6 +18,7 @@ const PREDECESSOR_BY_TARGET = {
18
18
  'abap-upgrade-verify': 'upgrade-progress',
19
19
  'abap-modernize': 'cca-assessment', // v0.7: new chain
20
20
  'abap-test': 'cca-assessment', // v0.7: new chain
21
+ 'abap-plan': 'spec-gap', // B3: plan consumes answered gaps
21
22
  };
22
23
  function affirmative(reply) {
23
24
  const v = reply.trim().toLowerCase();
@@ -91,6 +92,29 @@ export async function runPromoteCommand(args) {
91
92
  }
92
93
  }
93
94
  const promotedFrom = await buildPromotedFromSnapshot(env);
94
- const extendedSkillInput = buildExtendedInput(promotedFrom);
95
+ let extendedSkillInput = buildExtendedInput(promotedFrom);
96
+ // Always hand the skill the source detail-file path so it reads the baseline
97
+ // directly (the envelope at .cspeach/ root is unreadable; glob skips .cspeach/).
98
+ // This is what makes `--from` actually work for upgrade-fix on any scan,
99
+ // triaged or not — without it the skill globs for the file and fails.
100
+ const detailHint = buildDetailPathHint(env);
101
+ if (detailHint) {
102
+ extendedSkillInput = `${extendedSkillInput}\n\n${detailHint}`;
103
+ }
104
+ // When the source is a triaged upgrade-scan envelope, append a dedicated
105
+ // "Review decisions (honor these)" block joining each finding's status with
106
+ // its objectName/line/rule. This drives /abap-upgrade-fix off the viewer
107
+ // triage instead of re-asking object-by-object. Returns null (and we append
108
+ // nothing) for non-upgrade-scan sources or fully-untriaged scans.
109
+ const reviewBlock = buildReviewDecisionsBlock(env);
110
+ if (reviewBlock) {
111
+ extendedSkillInput = `${extendedSkillInput}\n\n${reviewBlock}`;
112
+ }
113
+ // Carry the originating spec document into the handoff so the downstream
114
+ // skill (e.g. /abap-plan) can cite the running spec the answered gaps trace
115
+ // back to — the Word/PDF doc → spec-gap → plan thread stays visible.
116
+ if (env.artefactType === 'spec-gap' && env.content.sourceDocument) {
117
+ extendedSkillInput = `${extendedSkillInput}\n\nSource spec document: ${env.content.sourceDocument}`;
118
+ }
95
119
  return { promotedFrom, extendedSkillInput };
96
120
  }
@@ -67,6 +67,134 @@ export async function buildPromotedFromSnapshot(source) {
67
67
  snapshot: { items },
68
68
  };
69
69
  }
70
+ /**
71
+ * Build the compact "Review decisions" block for an upgrade-scan source.
72
+ *
73
+ * The viewer lets the user triage each finding to one of
74
+ * `open | auto-fix | manual | suppress` (stored in `interaction.items[].status`).
75
+ * Those decisions must DRIVE /abap-upgrade-fix instead of the skill re-asking
76
+ * object-by-object. The skill cannot file_read the root `.cspeach/<name>.json`
77
+ * envelope (denylisted), so the statuses must reach it via the `--from`
78
+ * prompt injection — this block is what the CLI appends to that injection.
79
+ *
80
+ * Joins each item's status with the matching `content.findings[]` row
81
+ * (objectName / line / rule) and renders one aligned line per finding,
82
+ * e.g.:
83
+ * finding-001 ZFI_DUNNING_SELECT line 52 SELECT_WO_ORDER_BY → auto-fix
84
+ *
85
+ * Returns `null` when the source is not an upgrade-scan, or when every
86
+ * finding is still `open` (untriaged — nothing to honor; the skill falls
87
+ * back to its existing interactive ask for all of them).
88
+ */
89
+ export function buildReviewDecisionsBlock(source) {
90
+ if (source.artefactType === 'upgrade-scan')
91
+ return buildUpgradeScanDecisions(source);
92
+ if (source.artefactType === 'cca-assessment')
93
+ return buildCcaDecisions(source);
94
+ return null;
95
+ }
96
+ /**
97
+ * Always-inject hint for any `--from` source that carries a `content.detailPath`
98
+ * (upgrade-scan/progress/report, cca-assessment, modernize-result, test-coverage).
99
+ *
100
+ * Why this is REQUIRED, not optional: the `.cspeach.json` envelope itself lives at
101
+ * the denylisted `.cspeach/` root and cannot be read by the skill, and `glob` skips
102
+ * `.cspeach/` — so without an explicit path the skill hunts for the baseline and
103
+ * fails (observed live 2026-06-16: `/abap-upgrade-fix --from` globbing endlessly).
104
+ * The detail file lives under `.cspeach/<domain>/`, which the filesystem carve-out
105
+ * makes readable — so we hand the skill that exact path to `file_read` directly.
106
+ */
107
+ export function buildDetailPathHint(source) {
108
+ const detailPath = source.content?.detailPath;
109
+ if (!detailPath)
110
+ return null;
111
+ return [
112
+ 'Source detail file — READ THIS DIRECTLY with file_read at the exact path below.',
113
+ 'Do NOT glob for it, and do NOT try to read the .cspeach.json envelope (that path',
114
+ 'is protected). The detail file holds the full per-finding data (object, line,',
115
+ 'rule, message, successor) and is readable under .cspeach/:',
116
+ ` ${detailPath}`,
117
+ ].join('\n');
118
+ }
119
+ /**
120
+ * Render a fixed-width "Review decisions (honor these)" table from already-built
121
+ * rows. `summary` is the one-line counts header; `countKeys` lists the decision
122
+ * tokens to tally (in display order). Shared by the upgrade-scan and
123
+ * cca-assessment branches so both render identically.
124
+ */
125
+ function renderDecisionRows(rows, countKeys, summaryLabel) {
126
+ const wId = Math.max(...rows.map((r) => r.id.length));
127
+ const wObj = Math.max(...rows.map((r) => r.objectName.length));
128
+ const wLine = Math.max(...rows.map((r) => r.line.length));
129
+ const wRule = Math.max(...rows.map((r) => r.rule.length));
130
+ const counts = {};
131
+ for (const k of countKeys)
132
+ counts[k] = 0;
133
+ for (const r of rows)
134
+ counts[r.status] = (counts[r.status] ?? 0) + 1;
135
+ const summary = countKeys.map((k) => `${counts[k]} ${k}`).join(', ');
136
+ const lines = [
137
+ 'Review decisions from the source envelope (honor these):',
138
+ ` ${summaryLabel}: ${summary}.`,
139
+ '',
140
+ ];
141
+ for (const r of rows) {
142
+ const parts = [
143
+ r.id.padEnd(wId),
144
+ r.objectName.padEnd(wObj),
145
+ r.line.padEnd(wLine),
146
+ r.rule.padEnd(wRule),
147
+ `→ ${r.status}`,
148
+ ];
149
+ // Collapse the run of spaces from an empty (line-less) column so we never
150
+ // emit a stray "line undefined"; padEnd of "" already keeps alignment.
151
+ lines.push(` ${parts.join(' ').replace(/\s+$/, '')}`);
152
+ }
153
+ return lines.join('\n');
154
+ }
155
+ function buildUpgradeScanDecisions(source) {
156
+ const items = source.interaction.items;
157
+ if (items.length === 0)
158
+ return null;
159
+ // Untriaged source (all open) → omit the block; nothing was decided.
160
+ if (items.every((it) => it.status === 'open'))
161
+ return null;
162
+ const findingById = new Map(source.content.findings.map((f) => [f.id, f]));
163
+ const rows = items.map((it) => {
164
+ const f = findingById.get(it.id);
165
+ return {
166
+ id: it.id,
167
+ objectName: f?.objectName ?? '<unknown>',
168
+ line: f?.line !== undefined ? `line ${f.line}` : '',
169
+ rule: f?.rule ?? '',
170
+ status: it.status,
171
+ };
172
+ });
173
+ return renderDecisionRows(rows, ['auto-fix', 'suppress', 'manual', 'open'], 'Applying your review')
174
+ // The upgrade-scan summary historically reads "... K still open"; keep that
175
+ // wording for the open bucket while the shared renderer emits "K open".
176
+ .replace(/(\d+) open\./, '$1 still open.');
177
+ }
178
+ /**
179
+ * cca-assessment: the per-object review decision is the `classification`
180
+ * (keep/fix/retire/redesign) the consultant assigned in the worksheet, carried
181
+ * in content.classifications[]. interaction.items[].status is the triage
182
+ * lifecycle (open/reviewed/classified/archived), not the fix decision — so we
183
+ * surface the classification as the decision token the skill must honor.
184
+ */
185
+ function buildCcaDecisions(source) {
186
+ const classifications = source.content.classifications;
187
+ if (!classifications || classifications.length === 0)
188
+ return null;
189
+ const rows = classifications.map((c) => ({
190
+ id: c.id,
191
+ objectName: c.objectName,
192
+ line: '',
193
+ rule: c.objectType ?? '',
194
+ status: c.classification,
195
+ }));
196
+ return renderDecisionRows(rows, ['fix', 'redesign', 'retire', 'keep'], 'Applying your assessment');
197
+ }
70
198
  export function validatePromotionSource(source, expectedPredecessor) {
71
199
  // v0.7 — accept either a single predecessor or an array (multi-source).
72
200
  // /abap-upgrade-fix accepts both upgrade-scan AND cca-assessment, etc.