@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,124 @@
|
|
|
1
|
+
import { extractSpecGap } from './extract-spec-gap.js';
|
|
2
|
+
import { extractDesign } from './extract-design.js';
|
|
3
|
+
import { extractEstimate } from './extract-estimate.js';
|
|
4
|
+
import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from './extract-upgrade.js';
|
|
5
|
+
import { extractCcaAssessment } from './extract-cca.js';
|
|
6
|
+
import { extractModernizeResult } from './extract-modernize.js';
|
|
7
|
+
import { extractTestCoverage } from './extract-test-coverage.js';
|
|
8
|
+
import { buildEnvelope } from './build.js';
|
|
9
|
+
import { saveProject } from './save.js';
|
|
10
|
+
import { renderEmailTemplate } from './email-template.js';
|
|
11
|
+
import { ensureWorkspace } from './workspace.js';
|
|
12
|
+
const SKILL_REGISTRY = {
|
|
13
|
+
'abap-spec-gap': {
|
|
14
|
+
artefactType: 'spec-gap',
|
|
15
|
+
extract: (md) => extractSpecGap(md),
|
|
16
|
+
itemNoun: 'questions',
|
|
17
|
+
},
|
|
18
|
+
'abap-design': {
|
|
19
|
+
artefactType: 'design',
|
|
20
|
+
extract: (md) => extractDesign(md),
|
|
21
|
+
itemNoun: 'objects',
|
|
22
|
+
},
|
|
23
|
+
'abap-estimate': {
|
|
24
|
+
artefactType: 'estimate',
|
|
25
|
+
extract: (md) => extractEstimate(md),
|
|
26
|
+
itemNoun: 'components',
|
|
27
|
+
},
|
|
28
|
+
'abap-upgrade-scan': {
|
|
29
|
+
artefactType: 'upgrade-scan',
|
|
30
|
+
extract: (md) => extractUpgradeScan(md),
|
|
31
|
+
itemNoun: 'findings',
|
|
32
|
+
},
|
|
33
|
+
'abap-upgrade-fix': {
|
|
34
|
+
artefactType: 'upgrade-progress',
|
|
35
|
+
extract: (md) => extractUpgradeProgress(md),
|
|
36
|
+
itemNoun: 'fixes',
|
|
37
|
+
},
|
|
38
|
+
'abap-upgrade-verify': {
|
|
39
|
+
artefactType: 'upgrade-report',
|
|
40
|
+
extract: (md) => extractUpgradeReport(md),
|
|
41
|
+
itemNoun: 'regressions',
|
|
42
|
+
},
|
|
43
|
+
'abap-upgrade-merge': {
|
|
44
|
+
// Merge produces a NEW upgrade-progress (consolidated) — same shape
|
|
45
|
+
// as a single dev's fix output, just reconciled across N slices.
|
|
46
|
+
artefactType: 'upgrade-progress',
|
|
47
|
+
extract: (md) => extractUpgradeProgress(md),
|
|
48
|
+
itemNoun: 'fixes',
|
|
49
|
+
},
|
|
50
|
+
'abap-cca': {
|
|
51
|
+
artefactType: 'cca-assessment',
|
|
52
|
+
extract: (md) => extractCcaAssessment(md),
|
|
53
|
+
itemNoun: 'classifications',
|
|
54
|
+
},
|
|
55
|
+
'abap-cca-merge': {
|
|
56
|
+
// Merge produces a NEW cca-assessment (consolidated) — same shape as
|
|
57
|
+
// a single consultant's slice, just reconciled across N parallel reviews.
|
|
58
|
+
artefactType: 'cca-assessment',
|
|
59
|
+
extract: (md) => extractCcaAssessment(md),
|
|
60
|
+
itemNoun: 'classifications',
|
|
61
|
+
},
|
|
62
|
+
'abap-modernize': {
|
|
63
|
+
artefactType: 'modernize-result',
|
|
64
|
+
extract: (md) => extractModernizeResult(md),
|
|
65
|
+
itemNoun: 'modernizations',
|
|
66
|
+
},
|
|
67
|
+
'abap-test': {
|
|
68
|
+
artefactType: 'test-coverage',
|
|
69
|
+
extract: (md) => extractTestCoverage(md),
|
|
70
|
+
itemNoun: 'tests',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export async function runSaveCommand(args) {
|
|
74
|
+
const spec = SKILL_REGISTRY[args.skillName];
|
|
75
|
+
if (!spec)
|
|
76
|
+
return null;
|
|
77
|
+
let extract;
|
|
78
|
+
try {
|
|
79
|
+
extract = spec.extract(args.skillOutput);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const answer = (await args.prompt('Save as project file? [y/N]: ')).trim().toLowerCase();
|
|
85
|
+
if (answer !== 'y' && answer !== 'yes')
|
|
86
|
+
return null;
|
|
87
|
+
const env = buildEnvelope({
|
|
88
|
+
artefactType: spec.artefactType,
|
|
89
|
+
extract,
|
|
90
|
+
author: args.author,
|
|
91
|
+
source: {
|
|
92
|
+
skill: args.skillName,
|
|
93
|
+
skillVersion: args.skillVersion,
|
|
94
|
+
input: args.skillInput,
|
|
95
|
+
tokensUsed: args.tokensUsed,
|
|
96
|
+
model: args.model,
|
|
97
|
+
},
|
|
98
|
+
promotedFrom: args.promotedFrom ?? null,
|
|
99
|
+
});
|
|
100
|
+
// Save into the workspace folder (~/CSPeach by default), not cwd. Falls
|
|
101
|
+
// back to args.cwd if the workspace can't be created (e.g. permission
|
|
102
|
+
// error on the home directory) — saving SOMEWHERE is better than losing
|
|
103
|
+
// the artefact.
|
|
104
|
+
let outDir;
|
|
105
|
+
try {
|
|
106
|
+
outDir = await ensureWorkspace();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
outDir = args.cwd;
|
|
110
|
+
}
|
|
111
|
+
const path = await saveProject(env, { cwd: outDir });
|
|
112
|
+
args.log(`Saved: ${path}`);
|
|
113
|
+
args.log(`Share at: https://cspeach.dev/edit`);
|
|
114
|
+
args.log('');
|
|
115
|
+
args.log(renderEmailTemplate({
|
|
116
|
+
title: env.title,
|
|
117
|
+
artefactType: spec.artefactType,
|
|
118
|
+
filename: path.split(/[\\\/]/).pop(),
|
|
119
|
+
itemCount: env.interaction.items.length,
|
|
120
|
+
itemNoun: spec.itemNoun,
|
|
121
|
+
author: args.author.name,
|
|
122
|
+
}));
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { projectFilename } from './filename.js';
|
|
4
|
+
export async function saveProject(env, opts) {
|
|
5
|
+
const baseName = projectFilename({
|
|
6
|
+
title: env.title,
|
|
7
|
+
artefactType: env.artefactType,
|
|
8
|
+
id: env.id,
|
|
9
|
+
version: env.version,
|
|
10
|
+
});
|
|
11
|
+
let target = join(opts.cwd, baseName);
|
|
12
|
+
if (existsSync(target)) {
|
|
13
|
+
const stem = baseName.replace(/\.cspeach\.json$/, '');
|
|
14
|
+
let n = 2;
|
|
15
|
+
while (existsSync(join(opts.cwd, `${stem}-${n}.cspeach.json`)))
|
|
16
|
+
n++;
|
|
17
|
+
target = join(opts.cwd, `${stem}-${n}.cspeach.json`);
|
|
18
|
+
}
|
|
19
|
+
writeFileSync(target, JSON.stringify(env, null, 2), 'utf8');
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { validateEnvelope } from './validate.js';
|
|
3
|
+
import { canonicalSha256 } from './canonicalize.js';
|
|
4
|
+
export function readProjectFile(path) {
|
|
5
|
+
const raw = readFileSync(path, 'utf8');
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
throw new Error(`Not valid JSON. Did the file get truncated in email? (${path})`);
|
|
12
|
+
}
|
|
13
|
+
const result = validateEnvelope(parsed);
|
|
14
|
+
if (!result.ok) {
|
|
15
|
+
throw new Error(`Invalid project file at ${path}: ${result.error.message}`);
|
|
16
|
+
}
|
|
17
|
+
return result.envelope;
|
|
18
|
+
}
|
|
19
|
+
const STATUS_BY_TYPE = {
|
|
20
|
+
'spec-gap': ['answered', 'open', 'deferred', 'n-a'],
|
|
21
|
+
'design': ['keep', 'redesign', 'defer'],
|
|
22
|
+
'estimate': ['confirmed', 'disputed', 'deferred'],
|
|
23
|
+
'cca': ['keep', 'fix', 'retire', 'redesign'],
|
|
24
|
+
'incident': ['pending', 'done'],
|
|
25
|
+
};
|
|
26
|
+
const PRIMARY_BY_TYPE = {
|
|
27
|
+
'spec-gap': 'answered',
|
|
28
|
+
'design': 'keep',
|
|
29
|
+
'estimate': 'confirmed',
|
|
30
|
+
'cca': 'keep',
|
|
31
|
+
'incident': 'done',
|
|
32
|
+
};
|
|
33
|
+
const PROMOTE_HINT_BY_TYPE = {
|
|
34
|
+
'spec-gap': '/abap-design --from',
|
|
35
|
+
'design': '/abap-estimate --from',
|
|
36
|
+
'estimate': null,
|
|
37
|
+
'cca': null,
|
|
38
|
+
'incident': null,
|
|
39
|
+
};
|
|
40
|
+
export function renderStatus(env) {
|
|
41
|
+
const items = env.interaction.items;
|
|
42
|
+
const total = items.length;
|
|
43
|
+
const enums = STATUS_BY_TYPE[env.artefactType] ?? [];
|
|
44
|
+
const primary = PRIMARY_BY_TYPE[env.artefactType];
|
|
45
|
+
const counts = {};
|
|
46
|
+
for (const s of enums)
|
|
47
|
+
counts[s] = 0;
|
|
48
|
+
for (const it of items) {
|
|
49
|
+
if (counts[it.status] !== undefined)
|
|
50
|
+
counts[it.status]++;
|
|
51
|
+
}
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(`${env.title} — ${env.artefactType} (v${env.version})`);
|
|
54
|
+
const primaryCount = primary ? counts[primary] ?? 0 : 0;
|
|
55
|
+
const summaryParts = [`${primaryCount} / ${total} ${primary ?? 'items'}`];
|
|
56
|
+
for (const s of enums) {
|
|
57
|
+
if (s === primary)
|
|
58
|
+
continue;
|
|
59
|
+
const c = counts[s] ?? 0;
|
|
60
|
+
if (c > 0)
|
|
61
|
+
summaryParts.push(`${c} ${displayStatus(s)}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push(` ${summaryParts.join(' · ')}`);
|
|
64
|
+
lines.push(` Last edited by ${env.lastEditedBy.name} (${env.lastEditedBy.role}) — ${formatDate(env.lastEditedAt)}`);
|
|
65
|
+
lines.push('');
|
|
66
|
+
// Open-list section: only meaningful for artefactTypes that have an "open"
|
|
67
|
+
// status (spec-gap). Design/estimate use kept/confirmed as "decided" — there
|
|
68
|
+
// is no "still pending" state; every item has a terminal status from the start.
|
|
69
|
+
if (env.artefactType === 'spec-gap' && counts.open > 0) {
|
|
70
|
+
lines.push(`Open (${counts.open}):`);
|
|
71
|
+
const stillOpen = items.filter((i) => i.status === 'open');
|
|
72
|
+
const gapMap = new Map();
|
|
73
|
+
const content = env.content;
|
|
74
|
+
for (const s of content.sections)
|
|
75
|
+
for (const g of s.gaps)
|
|
76
|
+
gapMap.set(g.id, { severity: g.severity, text: g.text });
|
|
77
|
+
for (const it of stillOpen.slice(0, 10)) {
|
|
78
|
+
const g = gapMap.get(it.id);
|
|
79
|
+
if (g)
|
|
80
|
+
lines.push(` [${g.severity}] ${it.id}: ${truncate(g.text, 80)}`);
|
|
81
|
+
else
|
|
82
|
+
lines.push(` ${it.id}: <text not found in content>`);
|
|
83
|
+
}
|
|
84
|
+
if (stillOpen.length > 10)
|
|
85
|
+
lines.push(` ... ${stillOpen.length - 10} more`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
// Promote hint fires whenever a "next phase" exists for this artefactType.
|
|
89
|
+
// Unlike the v1 implementation we no longer gate on `primaryCount > 0` —
|
|
90
|
+
// a design with zero `keep` items (all `redesign` / `defer`) is still a
|
|
91
|
+
// legitimate input for `--from`, and the user can decide whether to act on
|
|
92
|
+
// the hint. The hint is informational, not a guard.
|
|
93
|
+
const hint = PROMOTE_HINT_BY_TYPE[env.artefactType];
|
|
94
|
+
if (hint) {
|
|
95
|
+
lines.push(`Ready to promote? Run:`);
|
|
96
|
+
lines.push(` ${hint} @<this-file>`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
function displayStatus(s) {
|
|
101
|
+
if (s === 'n-a')
|
|
102
|
+
return 'n/a';
|
|
103
|
+
return s;
|
|
104
|
+
}
|
|
105
|
+
function formatDate(iso) {
|
|
106
|
+
return iso.slice(0, 16).replace('T', ' ');
|
|
107
|
+
}
|
|
108
|
+
function truncate(s, n) {
|
|
109
|
+
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Spec §9: two files are treated as having the same source iff they share the
|
|
113
|
+
* same `id` AND their `history` arrays agree up to the most recent entry that
|
|
114
|
+
* pre-dates the divergence. We implement this by checking that all envelopes
|
|
115
|
+
* share the same id, then verifying their history arrays share a common prefix
|
|
116
|
+
* of length >= 1 (the create entry). Same source + same version + different
|
|
117
|
+
* canonical SHAs → parallel edit warning.
|
|
118
|
+
*/
|
|
119
|
+
function shareCommonAncestor(envs) {
|
|
120
|
+
const firstId = envs[0].id;
|
|
121
|
+
if (!envs.every((e) => e.id === firstId))
|
|
122
|
+
return false;
|
|
123
|
+
// Find the longest common prefix of history arrays.
|
|
124
|
+
const minLen = Math.min(...envs.map((e) => e.history.length));
|
|
125
|
+
let prefix = 0;
|
|
126
|
+
outer: for (let i = 0; i < minLen; i++) {
|
|
127
|
+
const ref = JSON.stringify(envs[0].history[i]);
|
|
128
|
+
for (let j = 1; j < envs.length; j++) {
|
|
129
|
+
if (JSON.stringify(envs[j].history[i]) !== ref)
|
|
130
|
+
break outer;
|
|
131
|
+
}
|
|
132
|
+
prefix++;
|
|
133
|
+
}
|
|
134
|
+
// At least the 'created' entry must be shared for them to be the same source.
|
|
135
|
+
return prefix >= 1;
|
|
136
|
+
}
|
|
137
|
+
export async function renderMultiFileStatus(envs) {
|
|
138
|
+
if (envs.length === 1)
|
|
139
|
+
return renderStatus(envs[0]);
|
|
140
|
+
const firstVer = envs[0].version;
|
|
141
|
+
const allSameVersion = envs.every((e) => e.version === firstVer);
|
|
142
|
+
const sameSource = shareCommonAncestor(envs);
|
|
143
|
+
if (sameSource && allSameVersion) {
|
|
144
|
+
const shas = await Promise.all(envs.map(canonicalSha256));
|
|
145
|
+
const distinct = new Set(shas);
|
|
146
|
+
if (distinct.size > 1) {
|
|
147
|
+
const lines = [];
|
|
148
|
+
lines.push(`⚠ Two v${firstVer} files received from same source, with conflicting changes.`);
|
|
149
|
+
for (let i = 0; i < envs.length; i++) {
|
|
150
|
+
const e = envs[i];
|
|
151
|
+
const items = e.interaction.items;
|
|
152
|
+
const counts = { answered: 0, deferred: 0, comments: 0 };
|
|
153
|
+
for (const it of items) {
|
|
154
|
+
if (it.status === 'answered')
|
|
155
|
+
counts.answered++;
|
|
156
|
+
if (it.status === 'deferred')
|
|
157
|
+
counts.deferred++;
|
|
158
|
+
counts.comments += it.comments.length;
|
|
159
|
+
}
|
|
160
|
+
lines.push(` v${e.version} from ${e.lastEditedBy.name}: ${counts.answered} answered, ${counts.deferred} deferred, ${counts.comments} comments (sha ${shas[i].slice(0, 6)}...)`);
|
|
161
|
+
}
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push('This is a parallel edit. Reconcile manually — open each in the editor,');
|
|
164
|
+
lines.push('copy answers across, save as v' + (firstVer + 1) + ' with merged content.');
|
|
165
|
+
return lines.join('\n');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Otherwise just print each individually
|
|
169
|
+
return envs.map(renderStatus).join('\n\n');
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const SUPPORTED_SCHEMA_MAJOR = 1;
|
|
2
|
+
const SUPPORTED_SCHEMA_MINOR = 0;
|
|
3
|
+
const KNOWN_ARTEFACT_TYPES = [
|
|
4
|
+
'spec-gap', 'design', 'estimate', 'cca', 'incident',
|
|
5
|
+
'upgrade-scan', 'upgrade-progress', 'upgrade-report',
|
|
6
|
+
'cca-assessment',
|
|
7
|
+
'modernize-result', 'test-coverage',
|
|
8
|
+
];
|
|
9
|
+
const STATUS_BY_TYPE = {
|
|
10
|
+
'spec-gap': ['open', 'answered', 'deferred', 'n-a'],
|
|
11
|
+
'design': ['keep', 'redesign', 'defer'],
|
|
12
|
+
'estimate': ['confirmed', 'disputed', 'deferred'],
|
|
13
|
+
'cca': ['keep', 'fix', 'retire', 'redesign'],
|
|
14
|
+
'incident': ['pending', 'done'],
|
|
15
|
+
'upgrade-scan': ['open', 'auto-fix', 'manual', 'suppress'],
|
|
16
|
+
'upgrade-progress': ['fixed', 'skipped', 'failed', 'pending'],
|
|
17
|
+
'upgrade-report': ['closed', 'regression', 'pending'],
|
|
18
|
+
'cca-assessment': ['open', 'reviewed', 'classified', 'archived'],
|
|
19
|
+
'modernize-result': ['applied', 'skipped', 'failed', 'pending'],
|
|
20
|
+
'test-coverage': ['generated', 'skipped', 'failed', 'pending'],
|
|
21
|
+
};
|
|
22
|
+
const REQUIRED_TOP_LEVEL = [
|
|
23
|
+
'schemaVersion', 'artefactType', 'id', 'title',
|
|
24
|
+
'createdAt', 'createdBy', 'lastEditedAt', 'lastEditedBy',
|
|
25
|
+
'version', 'source', 'promotedFrom', 'content', 'interaction', 'history',
|
|
26
|
+
];
|
|
27
|
+
const REQUIRED_IDENTITY = ['name', 'role'];
|
|
28
|
+
const REQUIRED_ITEM = ['id', 'status', 'answer', 'answeredBy', 'answeredAt', 'comments'];
|
|
29
|
+
const REQUIRED_HISTORY_ENTRY = ['at', 'by', 'action'];
|
|
30
|
+
/** Spec §9: missing means absent OR null OR (for strings) empty string. */
|
|
31
|
+
function isMissing(v) {
|
|
32
|
+
if (v === undefined || v === null)
|
|
33
|
+
return true;
|
|
34
|
+
if (typeof v === 'string' && v.length === 0)
|
|
35
|
+
return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function fail(code, field, message, warnings) {
|
|
39
|
+
return { ok: false, error: { code, field, message }, warnings };
|
|
40
|
+
}
|
|
41
|
+
/** Returns null on parse failure; otherwise [major, minor] tuple. */
|
|
42
|
+
function parseSchemaVersion(v) {
|
|
43
|
+
if (typeof v !== 'string')
|
|
44
|
+
return null;
|
|
45
|
+
const m = /^(\d+)\.(\d+)(?:\.\d+)?$/.exec(v);
|
|
46
|
+
if (!m)
|
|
47
|
+
return null;
|
|
48
|
+
return [Number(m[1]), Number(m[2])];
|
|
49
|
+
}
|
|
50
|
+
export function validateEnvelope(input) {
|
|
51
|
+
const warnings = [];
|
|
52
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
53
|
+
return fail('NOT_OBJECT', '<root>', 'Expected a JSON object', warnings);
|
|
54
|
+
}
|
|
55
|
+
const env = input;
|
|
56
|
+
for (const f of REQUIRED_TOP_LEVEL) {
|
|
57
|
+
// promotedFrom is allowed to be null; handle it explicitly
|
|
58
|
+
if (f === 'promotedFrom') {
|
|
59
|
+
if (!(f in env)) {
|
|
60
|
+
return fail('MISSING_FIELD', f, `Missing required field: ${f}`, warnings);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!(f in env) || isMissing(env[f])) {
|
|
65
|
+
return fail('MISSING_FIELD', f, `Missing required field: ${f}`, warnings);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const sv = parseSchemaVersion(env.schemaVersion);
|
|
69
|
+
if (sv === null) {
|
|
70
|
+
return fail('INVALID_VALUE', 'schemaVersion', `Malformed schemaVersion: ${String(env.schemaVersion)} (expected "<int>.<int>")`, warnings);
|
|
71
|
+
}
|
|
72
|
+
const [major, minor] = sv;
|
|
73
|
+
if (major > SUPPORTED_SCHEMA_MAJOR ||
|
|
74
|
+
(major === SUPPORTED_SCHEMA_MAJOR && minor > SUPPORTED_SCHEMA_MINOR)) {
|
|
75
|
+
warnings.push('SCHEMA_TOO_NEW');
|
|
76
|
+
}
|
|
77
|
+
else if (major !== SUPPORTED_SCHEMA_MAJOR || minor !== SUPPORTED_SCHEMA_MINOR) {
|
|
78
|
+
return fail('INVALID_VALUE', 'schemaVersion', `Unsupported schemaVersion: ${String(env.schemaVersion)}`, warnings);
|
|
79
|
+
}
|
|
80
|
+
if (typeof env.artefactType !== 'string' || !KNOWN_ARTEFACT_TYPES.includes(env.artefactType)) {
|
|
81
|
+
return fail('INVALID_VALUE', 'artefactType', `Unknown artefactType: ${String(env.artefactType)}`, warnings);
|
|
82
|
+
}
|
|
83
|
+
if (typeof env.version !== 'number' || !Number.isInteger(env.version) || env.version < 1) {
|
|
84
|
+
return fail('INVALID_VALUE', 'version', `version must be a positive integer`, warnings);
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(env.history) || env.history.length !== env.version) {
|
|
87
|
+
return fail('VERSION_MISMATCH', 'version', `version (${env.version}) must equal history.length (${Array.isArray(env.history) ? env.history.length : 'n/a'})`, warnings);
|
|
88
|
+
}
|
|
89
|
+
for (const idField of ['createdBy', 'lastEditedBy']) {
|
|
90
|
+
const id = env[idField];
|
|
91
|
+
if (typeof id !== 'object' || id === null) {
|
|
92
|
+
return fail('MISSING_FIELD', idField, `${idField} must be an object`, warnings);
|
|
93
|
+
}
|
|
94
|
+
for (const f of REQUIRED_IDENTITY) {
|
|
95
|
+
const idObj = id;
|
|
96
|
+
if (!(f in idObj) || isMissing(idObj[f])) {
|
|
97
|
+
return fail('MISSING_FIELD', `${idField}.${f}`, `Missing ${idField}.${f}`, warnings);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (typeof env.interaction !== 'object' || env.interaction === null
|
|
102
|
+
|| !Array.isArray(env.interaction.items)) {
|
|
103
|
+
return fail('MISSING_FIELD', 'interaction.items', 'interaction.items must be an array', warnings);
|
|
104
|
+
}
|
|
105
|
+
const items = env.interaction.items;
|
|
106
|
+
const validStatuses = STATUS_BY_TYPE[env.artefactType];
|
|
107
|
+
const seenIds = new Set();
|
|
108
|
+
for (let i = 0; i < items.length; i++) {
|
|
109
|
+
const raw = items[i];
|
|
110
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
111
|
+
return fail('INVALID_VALUE', `interaction.items[${i}]`, `interaction.items[${i}] must be an object`, warnings);
|
|
112
|
+
}
|
|
113
|
+
const item = raw;
|
|
114
|
+
for (const f of REQUIRED_ITEM) {
|
|
115
|
+
if (!(f in item)) {
|
|
116
|
+
return fail('MISSING_FIELD', `interaction.items[${i}].${f}`, `Missing items[${i}].${f}`, warnings);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (typeof item.id !== 'string' || item.id.length === 0) {
|
|
120
|
+
return fail('INVALID_VALUE', `interaction.items[${i}].id`, `Item id must be a non-empty string`, warnings);
|
|
121
|
+
}
|
|
122
|
+
if (seenIds.has(item.id)) {
|
|
123
|
+
return fail('INVALID_VALUE', `interaction.items[${i}].id`, `Duplicate item id: ${item.id}`, warnings);
|
|
124
|
+
}
|
|
125
|
+
seenIds.add(item.id);
|
|
126
|
+
if (!validStatuses.includes(item.status)) {
|
|
127
|
+
return fail('INVALID_VALUE', `interaction.items[${i}].status`, `Status '${String(item.status)}' not valid for ${env.artefactType}; allowed: ${validStatuses.join(', ')}`, warnings);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (let i = 0; i < env.history.length; i++) {
|
|
131
|
+
const raw = env.history[i];
|
|
132
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
133
|
+
return fail('INVALID_HISTORY', `history[${i}]`, `history[${i}] must be an object`, warnings);
|
|
134
|
+
}
|
|
135
|
+
const h = raw;
|
|
136
|
+
for (const f of REQUIRED_HISTORY_ENTRY) {
|
|
137
|
+
if (!(f in h)) {
|
|
138
|
+
return fail('INVALID_HISTORY', `history[${i}].${f}`, `Missing history[${i}].${f}`, warnings);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (h.action !== 'created' && h.action !== 'edited') {
|
|
142
|
+
return fail('INVALID_HISTORY', `history[${i}].action`, `action must be 'created' or 'edited', got ${String(h.action)}`, warnings);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, envelope: env, warnings };
|
|
146
|
+
}
|