@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
@@ -1,5 +1,22 @@
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';
1
9
  /** Statuses a dead session may have left behind — resumable, picked before fresh `todo`s. */
2
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
+ }
3
20
  /**
4
21
  * Phase status lives in interaction.items (store convention). Build the
5
22
  * id→status map; a phase with no matching item defaults to 'todo' rather
@@ -23,11 +40,11 @@ export function statusesFromItems(items, phases) {
23
40
  export function computeNextPhase(phases, statuses) {
24
41
  for (const p of phases) {
25
42
  const s = statuses[p.id] ?? 'todo';
26
- if (s === 'validated' || s === 'blocked')
43
+ if (isPhaseSatisfied(s) || s === 'blocked')
27
44
  continue;
28
45
  if (s !== 'todo' && !IN_PROGRESS.includes(s))
29
46
  continue;
30
- if (p.entryCriteria.every((dep) => statuses[dep] === 'validated'))
47
+ if (p.entryCriteria.every((dep) => isPhaseSatisfied(statuses[dep])))
31
48
  return p;
32
49
  }
33
50
  return null;
@@ -42,41 +59,158 @@ export function computeNextPhase(phases, statuses) {
42
59
  * ▶ c1.orchestration abap-generate ← THIS SESSION
43
60
  * ○ c1.integration abap-generate (needs c1.orchestration)
44
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
+ */
45
115
  export function renderPlanTracker(a) {
116
+ const PEACH = '#F5A623';
46
117
  const lines = [];
47
118
  const total = a.content.phases.length;
48
- const validated = a.content.phases.filter((p) => a.statuses[p.id] === 'validated').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;
49
122
  if (a.includeHeader !== false) {
50
- lines.push(`─ Plan: ${a.title} (v${a.version}) ── ${validated}/${total} validated ─`);
123
+ lines.push(chalk.dim(`─ ${a.title} (v${a.version}) · ${validated}/${total} done ─`));
124
+ lines.push('');
51
125
  }
52
- const idWidth = Math.max(...a.content.phases.map((p) => p.id.length));
53
- const skillWidth = Math.max(...a.content.phases.map((p) => p.delegateTo.length));
54
126
  for (const p of a.content.phases) {
55
127
  const s = a.statuses[p.id] ?? 'todo';
56
128
  const isCurrent = a.currentId != null && p.id === a.currentId;
57
- const symbol = isCurrent ? '▶'
58
- : s === 'validated' ? '✔'
59
- : s === 'blocked' ? '✖'
60
- : IN_PROGRESS.includes(s) ? '◌'
61
- : '○';
62
- lines.push(` ${symbol} ${p.id.padEnd(idWidth)} ${p.delegateTo.padEnd(skillWidth)} ${annotationFor(p, s, isCurrent, a.statuses)}`.trimEnd());
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
+ }
63
153
  }
64
154
  return lines.join('\n');
65
155
  }
66
- function annotationFor(p, s, isCurrent, statuses) {
67
- if (isCurrent)
68
- return '← THIS SESSION';
69
- if (s === 'validated') {
70
- const objs = p.work?.generated?.join(', ') ?? '';
71
- const tr = p.work?.transport ? ` (${p.work.transport})` : '';
72
- return objs || tr ? `→ ${objs}${tr}`.trimEnd() : '';
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.');
73
211
  }
74
- if (s === 'blocked')
75
- return 'BLOCKED';
76
- if (IN_PROGRESS.includes(s))
77
- return `in progress (${s})`;
78
- const unmet = p.entryCriteria.filter((dep) => statuses[dep] !== 'validated');
79
- return unmet.length > 0 ? `(needs ${unmet.join(', ')})` : '';
212
+ lines.push('Consider /abap-preflight on the produced transport(s) before release.');
213
+ return lines;
80
214
  }
81
215
  /**
82
216
  * Build the version-N+1 envelope from the original plan envelope and the
@@ -28,11 +28,17 @@ export const PLAN_LAYERS = [
28
28
  */
29
29
  export const PLAN_DELEGATE_SKILLS = [
30
30
  'abap-design', 'abap-data-model', 'abap-rap', 'abap-eml',
31
- 'abap-generate', 'abap-segw', 'abap-test',
31
+ 'abap-generate', 'abap-segw', 'abap-test', 'abap-fiori-build',
32
+ 'abap-extend-model',
32
33
  ];
33
- /** Keep in sync with ItemStatusByType['plan'] in types.ts. */
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
+ */
34
40
  export const PLAN_PHASE_STATUSES = [
35
- 'todo', 'designing', 'building', 'verifying', 'validated', 'blocked',
41
+ 'todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked',
36
42
  ];
37
43
  const SHA256_RE = /^[0-9a-f]{64}$/;
38
44
  const planPhaseManifestSchema = z.object({
@@ -48,9 +54,14 @@ const planPhaseWorkSchema = z.object({
48
54
  transport: z.string().optional(),
49
55
  snapshot: z.string().optional(),
50
56
  notes: z.string().optional(),
57
+ // C2 (D24): exact SRVB name for the plan-complete /abap-fiori-build chain.
58
+ binding: z.string().optional(),
51
59
  }).passthrough();
52
60
  const planPhaseSchema = z.object({
53
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(),
54
65
  component: z.string().min(1),
55
66
  layer: z.enum(PLAN_LAYERS),
56
67
  entryCriteria: z.array(z.string()),
@@ -60,6 +71,41 @@ const planPhaseSchema = z.object({
60
71
  approval: z.boolean().optional(),
61
72
  work: planPhaseWorkSchema.optional(),
62
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
+ });
63
109
  // Input type is `unknown` (third param): the `.default({})` on `from` means
64
110
  // the schema ACCEPTS input without the key while still OUTPUTTING PlanContent.
65
111
  export const planContentSchema = z
@@ -68,6 +114,8 @@ export const planContentSchema = z
68
114
  goal: z.string().min(1),
69
115
  source: z.string(),
70
116
  target: z.string(),
117
+ // Absent mode parses as undefined (treated as 'create') — back-compat.
118
+ mode: z.enum(['create', 'revision']).optional(),
71
119
  }),
72
120
  // Tolerant by lesson (2026-06-06 live smoke): the model omitted the whole
73
121
  // `from` key for a goal-only plan and the strict schema killed the save.
@@ -82,6 +130,9 @@ export const planContentSchema = z
82
130
  .optional(),
83
131
  })
84
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(),
85
136
  phases: z.array(planPhaseSchema).min(1),
86
137
  summary: z.object({
87
138
  total: z.number().int().nonnegative(),
@@ -91,6 +142,15 @@ export const planContentSchema = z
91
142
  }),
92
143
  })
93
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
+ }
94
154
  // Phase ids must be unique, and entryCriteria may only reference phases
95
155
  // EARLIER in the array. Bottom-up ordering by construction — this is what
96
156
  // guarantees resume is deterministic and the dependency graph acyclic
@@ -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 —
@@ -92,6 +92,29 @@ export async function runPromoteCommand(args) {
92
92
  }
93
93
  }
94
94
  const promotedFrom = await buildPromotedFromSnapshot(env);
95
- 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
+ }
96
119
  return { promotedFrom, extendedSkillInput };
97
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.