@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.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- 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
|
+
}
|