@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,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
+ }