@cspeach/cli 0.6.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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Shared parser for the `<!-- csforge:upgrade-manifest ... -->` block
3
+ * that all three /abap-upgrade-{scan,fix,verify} skills emit at the
4
+ * end of their markdown output. The block is hidden in human renderers
5
+ * (HTML comment) and structured enough for a deterministic extractor.
6
+ *
7
+ * Format:
8
+ *
9
+ * <!-- csforge:upgrade-manifest
10
+ * artefact: upgrade-scan
11
+ * title: ZFI* — S/4HANA 2023 readiness
12
+ * detail_path: .abapforge/upgrades/zfi-readiness-2023-baseline.json
13
+ * atc_variant: S4HANA_READINESS_2023
14
+ * scope: Z* in package ZFI*
15
+ * total: 2847
16
+ * auto_fixable: 344
17
+ * manual_review: 136
18
+ * suppressed: 26
19
+ * findings:
20
+ * finding-001 | ZFI_BP_SYNC | PROG | S4HANA_BUSINESS_PARTNER | E
21
+ * finding-002 | ZCL_FI_DOC | CLAS | PERF_DB_SEARCH | W
22
+ * -->
23
+ *
24
+ * Key-value pairs are `key: value` per line. Tabular sections use a
25
+ * trailing colon header (`findings:`) followed by `|`-separated rows.
26
+ *
27
+ * Three artefact types share the same parser and dispatch by `artefact:`:
28
+ * - upgrade-scan → findings table
29
+ * - upgrade-progress → fixes table + baseline_path / baseline_sha256
30
+ * - upgrade-report → regressions table + baseline + progress refs
31
+ *
32
+ * Parsing is permissive — missing optional fields produce defaults; a
33
+ * missing manifest block throws so callers know to surface the error.
34
+ */
35
+ const MANIFEST_RE = /<!--\s*csforge:upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/;
36
+ function parseManifestBlock(markdown) {
37
+ const m = MANIFEST_RE.exec(markdown);
38
+ if (!m) {
39
+ throw new Error('No <!-- csforge:upgrade-manifest --> block found in skill output');
40
+ }
41
+ const body = m[1] ?? '';
42
+ const kv = {};
43
+ const tables = {};
44
+ let currentTable = null;
45
+ for (const rawLine of body.split('\n')) {
46
+ const line = rawLine.replace(/\r$/, '');
47
+ if (line.trim().length === 0) {
48
+ currentTable = null;
49
+ continue;
50
+ }
51
+ if (currentTable !== null && /^\s+/.test(line)) {
52
+ // Indented row under a table header.
53
+ const cells = line.trim().split('|').map((c) => c.trim());
54
+ if (cells.length > 0) {
55
+ if (!tables[currentTable])
56
+ tables[currentTable] = [];
57
+ tables[currentTable].push(cells);
58
+ }
59
+ continue;
60
+ }
61
+ const colon = line.indexOf(':');
62
+ if (colon === -1)
63
+ continue;
64
+ const key = line.slice(0, colon).trim();
65
+ const value = line.slice(colon + 1).trim();
66
+ if (value.length === 0) {
67
+ // Header with no value → starts a table.
68
+ currentTable = key;
69
+ tables[key] = [];
70
+ }
71
+ else {
72
+ kv[key] = value;
73
+ currentTable = null;
74
+ }
75
+ }
76
+ return { kv, tables };
77
+ }
78
+ function intOr(kv, key, fallback) {
79
+ const v = kv[key];
80
+ if (v === undefined)
81
+ return fallback;
82
+ const n = Number.parseInt(v, 10);
83
+ return Number.isFinite(n) ? n : fallback;
84
+ }
85
+ function buildItems(count, defaultStatus) {
86
+ const out = [];
87
+ for (let i = 0; i < count; i++) {
88
+ out.push({
89
+ id: paddedId(i + 1),
90
+ status: defaultStatus,
91
+ answer: null,
92
+ answeredBy: null,
93
+ answeredAt: null,
94
+ comments: [],
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+ function paddedId(n) {
100
+ return `item-${String(n).padStart(3, '0')}`;
101
+ }
102
+ export function extractUpgradeScan(markdown) {
103
+ const { kv, tables } = parseManifestBlock(markdown);
104
+ if (kv.artefact !== 'upgrade-scan') {
105
+ throw new Error(`Manifest artefact mismatch: expected 'upgrade-scan', got '${kv.artefact}'`);
106
+ }
107
+ if (!kv.detail_path) {
108
+ throw new Error("upgrade-scan manifest missing required 'detail_path'");
109
+ }
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
+ }));
117
+ const content = {
118
+ detailPath: kv.detail_path,
119
+ atcVariant: kv.atc_variant ?? '',
120
+ 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
+ },
127
+ findings,
128
+ };
129
+ // One InteractionItem per finding, default status 'open'.
130
+ const items = findings.map((f) => ({
131
+ id: f.id,
132
+ status: 'open',
133
+ answer: null,
134
+ answeredBy: null,
135
+ answeredAt: null,
136
+ comments: [],
137
+ }));
138
+ return { title: kv.title ?? 'Upgrade Scan', content, items };
139
+ }
140
+ export function extractUpgradeProgress(markdown) {
141
+ const { kv, tables } = parseManifestBlock(markdown);
142
+ if (kv.artefact !== 'upgrade-progress') {
143
+ throw new Error(`Manifest artefact mismatch: expected 'upgrade-progress', got '${kv.artefact}'`);
144
+ }
145
+ if (!kv.detail_path) {
146
+ throw new Error("upgrade-progress manifest missing required 'detail_path'");
147
+ }
148
+ if (!kv.baseline_path) {
149
+ throw new Error("upgrade-progress manifest missing required 'baseline_path'");
150
+ }
151
+ const fixes = (tables.fixes ?? []).map((row, i) => ({
152
+ id: row[0] ?? paddedId(i + 1),
153
+ objectName: row[1] ?? '',
154
+ finding: row[2] ?? '',
155
+ status: row[3] ?? 'pending',
156
+ }));
157
+ const content = {
158
+ detailPath: kv.detail_path,
159
+ baselineRef: {
160
+ path: kv.baseline_path,
161
+ sha256: kv.baseline_sha256 ?? '',
162
+ },
163
+ 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
+ },
170
+ fixes,
171
+ };
172
+ const items = fixes.map((f) => {
173
+ const allowed = ['fixed', 'skipped', 'failed', 'pending'];
174
+ const status = (allowed.includes(f.status)
175
+ ? f.status
176
+ : 'pending');
177
+ return {
178
+ id: f.id,
179
+ status,
180
+ answer: null,
181
+ answeredBy: null,
182
+ answeredAt: null,
183
+ comments: [],
184
+ };
185
+ });
186
+ return { title: kv.title ?? 'Upgrade Progress', content, items };
187
+ }
188
+ export function extractUpgradeReport(markdown) {
189
+ const { kv, tables } = parseManifestBlock(markdown);
190
+ if (kv.artefact !== 'upgrade-report') {
191
+ throw new Error(`Manifest artefact mismatch: expected 'upgrade-report', got '${kv.artefact}'`);
192
+ }
193
+ if (!kv.detail_path) {
194
+ throw new Error("upgrade-report manifest missing required 'detail_path'");
195
+ }
196
+ if (!kv.baseline_path) {
197
+ throw new Error("upgrade-report manifest missing required 'baseline_path'");
198
+ }
199
+ if (!kv.progress_path) {
200
+ throw new Error("upgrade-report manifest missing required 'progress_path'");
201
+ }
202
+ const regressions = (tables.regressions ?? []).map((row, i) => ({
203
+ id: row[0] ?? `reg-${String(i + 1).padStart(3, '0')}`,
204
+ objectName: row[1] ?? '',
205
+ rule: row[2] ?? '',
206
+ reason: row[3] ?? '',
207
+ }));
208
+ const content = {
209
+ detailPath: kv.detail_path,
210
+ baselineRef: { path: kv.baseline_path, sha256: kv.baseline_sha256 ?? '' },
211
+ progressRef: { path: kv.progress_path, sha256: kv.progress_sha256 ?? '' },
212
+ summary: {
213
+ closed: intOr(kv, 'closed', 0),
214
+ regressions: intOr(kv, 'regressions', regressions.length),
215
+ pending: intOr(kv, 'pending', 0),
216
+ },
217
+ regressions,
218
+ };
219
+ // For terminal artefact, items track regression status — default
220
+ // 'regression' for entries that came in via the regressions table.
221
+ const items = regressions.map((r) => ({
222
+ id: r.id,
223
+ status: 'regression',
224
+ answer: null,
225
+ answeredBy: null,
226
+ answeredAt: null,
227
+ comments: [],
228
+ }));
229
+ return { title: kv.title ?? 'Upgrade Report', content, items };
230
+ }
@@ -0,0 +1,18 @@
1
+ const SLUG_MAX = 60;
2
+ export function titleSlug(title) {
3
+ return title
4
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip combining diacritics
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9\s-]/g, '') // strip punctuation
7
+ .trim()
8
+ .replace(/\s+/g, '-')
9
+ .replace(/-+/g, '-')
10
+ .slice(0, SLUG_MAX)
11
+ .replace(/-+$/, ''); // trim trailing hyphen if slice cut mid-word
12
+ }
13
+ export function shortId(uuid) {
14
+ return uuid.slice(0, 4);
15
+ }
16
+ export function projectFilename(args) {
17
+ return `${titleSlug(args.title)}-${args.artefactType}-${shortId(args.id)}-v${args.version}.cspeach.json`;
18
+ }
@@ -0,0 +1,8 @@
1
+ export { validateEnvelope } from './validate.js';
2
+ export { canonicalSha256 } from './canonicalize.js';
3
+ export { titleSlug, shortId, projectFilename } from './filename.js';
4
+ export { runSaveCommand } from './save-command.js';
5
+ export { extractDesign } from './extract-design.js';
6
+ export { extractEstimate } from './extract-estimate.js';
7
+ export { buildPromotedFromSnapshot, validatePromotionSource } from './promote.js';
8
+ export { runPromoteCommand } from './promote-command.js';
@@ -0,0 +1,111 @@
1
+ /**
2
+ * One-time workspace-default migration prompt.
3
+ *
4
+ * Why this exists: until 2026-05-07 the default workspace was `~/CSPeach/`
5
+ * — a single global folder per user. As of 2026-05-08 the default is
6
+ * project-rooted (`<git-root>/.cspeach/`), so a consultant working on
7
+ * customer A and customer B no longer has both customers' artefacts
8
+ * piling into one folder.
9
+ *
10
+ * For users with files already in `~/CSPeach/`, the switch needs to ask
11
+ * once: keep the old global, move into the new project, or leave both
12
+ * alone. The answer is recorded in `config.migration_prompted`; the
13
+ * prompt fires exactly once.
14
+ *
15
+ * Three branches:
16
+ * - "move": renameSync every artefact from ~/CSPeach/ into the
17
+ * resolved project workspace. Old folder cleaned.
18
+ * - "keep_global": set workspace_folder = '~/CSPeach', preserving the
19
+ * pre-2026-05-08 behaviour for users who liked it.
20
+ * - "leave": do nothing — old folder stays as-is, new artefacts
21
+ * go to the project workspace. User can manually
22
+ * copy whatever they want later.
23
+ *
24
+ * The prompt is interactive (via the supplied async prompt callback);
25
+ * one-shot mode skips it entirely (caller passes a no-op).
26
+ */
27
+ import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import os from 'node:os';
30
+ import { loadConfig, saveConfig } from '../config/loader.js';
31
+ import { defaultWorkspaceFor } from './workspace.js';
32
+ /**
33
+ * Run the migration prompt if and only if it hasn't run before AND
34
+ * there's actually something to migrate. No-op otherwise. Always sets
35
+ * `migration_prompted: true` after running so the next invocation
36
+ * skips this whole module.
37
+ */
38
+ export async function maybePromptWorkspaceMigration(deps) {
39
+ const cfg = await loadConfig();
40
+ if (cfg.migration_prompted === true)
41
+ return;
42
+ // If user already pinned workspace_folder, they've already chosen —
43
+ // mark prompted, skip the dialog. The pin survives.
44
+ if (cfg.workspace_folder !== undefined && cfg.workspace_folder.trim().length > 0) {
45
+ await saveConfig({ ...cfg, migration_prompted: true });
46
+ return;
47
+ }
48
+ const oldWorkspace = join(os.homedir(), 'CSPeach');
49
+ if (!existsSync(oldWorkspace)) {
50
+ await saveConfig({ ...cfg, migration_prompted: true });
51
+ return;
52
+ }
53
+ let oldFiles;
54
+ try {
55
+ oldFiles = readdirSync(oldWorkspace).filter((f) => f.endsWith('.cspeach.json') || f.endsWith('.txt') || f.endsWith('.md'));
56
+ }
57
+ catch {
58
+ await saveConfig({ ...cfg, migration_prompted: true });
59
+ return;
60
+ }
61
+ if (oldFiles.length === 0) {
62
+ await saveConfig({ ...cfg, migration_prompted: true });
63
+ return;
64
+ }
65
+ const newWorkspace = defaultWorkspaceFor(process.cwd());
66
+ let choice;
67
+ try {
68
+ choice = await deps.prompt({
69
+ fileCount: oldFiles.length,
70
+ oldWorkspace,
71
+ newWorkspace,
72
+ });
73
+ }
74
+ catch {
75
+ // User Ctrl+C'd the prompt — leave migration_prompted unset so the
76
+ // dialog re-fires next session. Better than silently committing to
77
+ // a default they never saw.
78
+ return;
79
+ }
80
+ if (choice === 'move') {
81
+ try {
82
+ mkdirSync(newWorkspace, { recursive: true });
83
+ for (const f of oldFiles) {
84
+ const src = join(oldWorkspace, f);
85
+ const dst = join(newWorkspace, f);
86
+ if (existsSync(dst)) {
87
+ deps.log(`(skipping ${f} — already exists in new workspace)`);
88
+ continue;
89
+ }
90
+ renameSync(src, dst);
91
+ }
92
+ deps.log(`Moved ${oldFiles.length} file(s) → ${newWorkspace}`);
93
+ }
94
+ catch (err) {
95
+ deps.log(`Migration failed: ${err.message}. Old folder left untouched.`);
96
+ }
97
+ }
98
+ else if (choice === 'keep_global') {
99
+ await saveConfig({
100
+ ...cfg,
101
+ workspace_folder: oldWorkspace,
102
+ migration_prompted: true,
103
+ });
104
+ deps.log(`Workspace pinned to ${oldWorkspace}.`);
105
+ return; // saveConfig already done
106
+ }
107
+ else {
108
+ deps.log(`Leaving ${oldWorkspace} alone. New artefacts go to ${newWorkspace}.`);
109
+ }
110
+ await saveConfig({ ...cfg, migration_prompted: true });
111
+ }
@@ -0,0 +1,96 @@
1
+ import { readProjectFile } from './status.js';
2
+ import { buildPromotedFromSnapshot, validatePromotionSource } from './promote.js';
3
+ import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
4
+ /**
5
+ * Per-skill predecessor map. v0.7 admits multi-source via array values —
6
+ * e.g. /abap-upgrade-fix accepts BOTH upgrade-scan (the existing chain
7
+ * from /abap-upgrade-scan) AND cca-assessment (the v0.7 chain from
8
+ * /abap-cca, with an adapter that reads CCA's detail file). Order in
9
+ * the array doesn't matter; validatePromotionSource just checks
10
+ * membership.
11
+ */
12
+ const PREDECESSOR_BY_TARGET = {
13
+ 'abap-design': 'spec-gap',
14
+ 'abap-estimate': 'design',
15
+ 'abap-generate': 'design',
16
+ 'abap-rap': 'design',
17
+ 'abap-upgrade-fix': ['upgrade-scan', 'cca-assessment'], // v0.7: multi-source
18
+ 'abap-upgrade-verify': 'upgrade-progress',
19
+ 'abap-modernize': 'cca-assessment', // v0.7: new chain
20
+ 'abap-test': 'cca-assessment', // v0.7: new chain
21
+ };
22
+ function affirmative(reply) {
23
+ const v = reply.trim().toLowerCase();
24
+ return v === 'y' || v === 'yes';
25
+ }
26
+ function buildExtendedInput(p) {
27
+ const lines = [
28
+ `Promoted from ${p.artefactType} (v${p.version}) — "${p.title}".`,
29
+ `Source items below; use the answers/decisions as primary context.`,
30
+ '',
31
+ ];
32
+ for (const it of p.snapshot.items) {
33
+ const sevPart = it.severity ? `[${it.severity}] ` : '';
34
+ const trail = it.answer ? it.answer : (it.status === 'open' ? '<open>' : it.status);
35
+ lines.push(`${sevPart}${it.id}: ${it.text} → ${trail}`);
36
+ }
37
+ return lines.join('\n');
38
+ }
39
+ export async function runPromoteCommand(args) {
40
+ const expectedPredecessor = PREDECESSOR_BY_TARGET[args.targetSkill];
41
+ if (!expectedPredecessor) {
42
+ args.log(`--from: unknown target skill: ${args.targetSkill}`);
43
+ return null;
44
+ }
45
+ // Resolve the @<token> against the workspace folder. Absolute paths are
46
+ // returned as-is; bare names are looked up in `~/CSPeach/` (or the
47
+ // configured workspace_folder). Ambiguous bare names abort with the
48
+ // candidate list so the user can disambiguate, rather than silently
49
+ // picking the wrong file.
50
+ const resolved = await resolveAtTokenAsync(args.sourcePath, process.cwd());
51
+ let resolvedPath;
52
+ if (resolved.kind === 'path') {
53
+ resolvedPath = resolved.path;
54
+ }
55
+ else if (resolved.kind === 'notFound') {
56
+ args.log(`--from: no file matching '${resolved.token}' in workspace ${resolved.workspace}`);
57
+ args.log(`Run /files to see available .cspeach.json files.`);
58
+ return null;
59
+ }
60
+ else {
61
+ args.log(`--from: '${args.sourcePath}' is ambiguous — multiple matches:`);
62
+ args.log(formatProjectFileList(resolved.matches, ''));
63
+ args.log(`Use a more specific @<fragment> to disambiguate.`);
64
+ return null;
65
+ }
66
+ let env;
67
+ try {
68
+ env = readProjectFile(resolvedPath);
69
+ }
70
+ catch (e) {
71
+ args.log(`--from source error: ${String(e instanceof Error ? e.message : e)}`);
72
+ return null;
73
+ }
74
+ const validation = validatePromotionSource(env, expectedPredecessor);
75
+ if (!validation.ok) {
76
+ args.log(`--from: ${validation.error ?? 'source validation failed.'}`);
77
+ return null;
78
+ }
79
+ for (const w of validation.warnings) {
80
+ if (w.kind === 'ZERO_ANSWERED') {
81
+ const reply = await args.prompt(`Source has no answered items (${w.count} still open). Continue anyway? [y/N]: `);
82
+ if (!affirmative(reply))
83
+ return null;
84
+ }
85
+ else if (w.kind === 'PARTIAL_OPEN') {
86
+ const sample = w.openIds.slice(0, 5).join(', ');
87
+ const more = w.openIds.length > 5 ? `, ... ${w.openIds.length - 5} more` : '';
88
+ const reply = await args.prompt(`${w.openIds.length} item(s) still open (${sample}${more}). Promote anyway? [y/N]: `);
89
+ if (!affirmative(reply))
90
+ return null;
91
+ }
92
+ }
93
+ const promotedFrom = await buildPromotedFromSnapshot(env);
94
+ const extendedSkillInput = buildExtendedInput(promotedFrom);
95
+ return { promotedFrom, extendedSkillInput };
96
+ }
@@ -0,0 +1,107 @@
1
+ import { canonicalSha256 } from './canonicalize.js';
2
+ function itemTextFor(env, itemId) {
3
+ if (env.artefactType === 'spec-gap') {
4
+ for (const s of env.content.sections) {
5
+ for (const g of s.gaps) {
6
+ if (g.id === itemId)
7
+ return { text: g.text, severity: g.severity };
8
+ }
9
+ }
10
+ return { text: '<text not found>' };
11
+ }
12
+ if (env.artefactType === 'design') {
13
+ const o = env.content.objects.find((x) => x.id === itemId);
14
+ return o ? { text: `${o.name} (${o.type})` } : { text: '<text not found>' };
15
+ }
16
+ if (env.artefactType === 'estimate') {
17
+ const c = env.content.components.find((x) => x.id === itemId);
18
+ return c ? { text: `${c.name} — ${c.hoursOptimistic}/${c.hoursRealistic}/${c.hoursPessimistic} h` } : { text: '<text not found>' };
19
+ }
20
+ if (env.artefactType === 'upgrade-scan') {
21
+ const f = env.content.findings.find((x) => x.id === itemId);
22
+ return f ? { text: `${f.objectName} (${f.objectType}) — ${f.rule} [${f.severity}]` } : { text: '<text not found>' };
23
+ }
24
+ if (env.artefactType === 'upgrade-progress') {
25
+ const fx = env.content.fixes.find((x) => x.id === itemId);
26
+ return fx ? { text: `${fx.objectName} — ${fx.finding} → ${fx.status}` } : { text: '<text not found>' };
27
+ }
28
+ if (env.artefactType === 'upgrade-report') {
29
+ const r = env.content.regressions.find((x) => x.id === itemId);
30
+ return r ? { text: `${r.objectName} — ${r.rule}: ${r.reason}` } : { text: '<text not found>' };
31
+ }
32
+ if (env.artefactType === 'cca-assessment') {
33
+ const c = env.content.classifications.find((x) => x.id === itemId);
34
+ return c ? { text: `${c.objectName} (${c.objectType}) — ${c.classification} [${c.confidence}]` } : { text: '<text not found>' };
35
+ }
36
+ if (env.artefactType === 'modernize-result') {
37
+ const m = env.content.modernizations.find((x) => x.id === itemId);
38
+ return m ? { text: `${m.objectName} (${m.objectType}) — ${m.fromArchitecture} → ${m.toArchitecture} [${m.status}]` } : { text: '<text not found>' };
39
+ }
40
+ if (env.artefactType === 'test-coverage') {
41
+ const t = env.content.tests.find((x) => x.id === itemId);
42
+ return t ? { text: `${t.targetObjectName} (${t.targetObjectType}) — ${t.testClassName} (${t.methodCount} methods) [${t.status}]` } : { text: '<text not found>' };
43
+ }
44
+ return { text: '<unsupported artefactType>' };
45
+ }
46
+ export async function buildPromotedFromSnapshot(source) {
47
+ const sha = await canonicalSha256(source);
48
+ const items = source.interaction.items.map((it) => {
49
+ const tx = itemTextFor(source, it.id);
50
+ const out = {
51
+ id: it.id,
52
+ text: tx.text,
53
+ status: it.status,
54
+ answer: it.answer,
55
+ };
56
+ if (tx.severity)
57
+ out.severity = tx.severity;
58
+ return out;
59
+ });
60
+ return {
61
+ id: source.id,
62
+ title: source.title,
63
+ artefactType: source.artefactType,
64
+ version: source.version,
65
+ sha256: sha,
66
+ snapshottedAt: new Date().toISOString(),
67
+ snapshot: { items },
68
+ };
69
+ }
70
+ export function validatePromotionSource(source, expectedPredecessor) {
71
+ // v0.7 — accept either a single predecessor or an array (multi-source).
72
+ // /abap-upgrade-fix accepts both upgrade-scan AND cca-assessment, etc.
73
+ const allowed = Array.isArray(expectedPredecessor)
74
+ ? expectedPredecessor
75
+ : [expectedPredecessor];
76
+ if (!allowed.includes(source.artefactType)) {
77
+ // Preserve the natural "a <type> source" phrasing for single-predecessor
78
+ // case (matches existing test expectations + reads better in CLI output);
79
+ // multi-source case explicitly lists the alternatives.
80
+ const expectedDesc = allowed.length === 1
81
+ ? `a ${allowed[0]}`
82
+ : `one of [${allowed.join(', ')}]`;
83
+ return {
84
+ ok: false,
85
+ warnings: [],
86
+ error: `--from expects ${expectedDesc} source; got ${source.artefactType}.`,
87
+ };
88
+ }
89
+ const warnings = [];
90
+ const items = source.interaction.items;
91
+ const openIds = items.filter((i) => i.status === 'open').map((i) => i.id);
92
+ const answeredCount = items.filter((i) => i.status !== 'open' && (source.artefactType === 'spec-gap'
93
+ ? i.status === 'answered'
94
+ : true)).length;
95
+ if (source.artefactType === 'spec-gap') {
96
+ if (answeredCount === 0) {
97
+ warnings.push({ kind: 'ZERO_ANSWERED', count: items.length });
98
+ }
99
+ else if (openIds.length > 0) {
100
+ warnings.push({ kind: 'PARTIAL_OPEN', openIds });
101
+ }
102
+ }
103
+ // For design / estimate, status is always terminal (no "open" enum), so no
104
+ // partial-open warning applies. They are always either decided or not-yet-decided
105
+ // via redesign/defer/disputed; that nuance is a v0.6+ check.
106
+ return { ok: true, warnings };
107
+ }