@bridge_gpt/mcp-server 0.2.60 → 0.2.65

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 (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -1
@@ -0,0 +1,920 @@
1
+ /**
2
+ * BAPI-1320: build and structurally check the normalized epic-coherence input
3
+ * document (`epic-coherence-input.json`) that `review-epic-coherence.md` reviews.
4
+ *
5
+ * Why this lives in the packaged MCP server: the procedure ships to consumer
6
+ * repositories, where `mcp_server/src/` does not exist. A repository script
7
+ * would only exist here, so the packaged `epic-coherence-input` subcommand is
8
+ * the reachable surface for authors who draft an epic as local markdown files.
9
+ *
10
+ * Two rules the first real run (BAPI-1316) showed are load-bearing:
11
+ * - epic ACs are matched by their explicit id (`AC-3:`), never by position or
12
+ * keyword;
13
+ * - files a child only reads (`reads` -> `read_files`) are kept apart from the
14
+ * files it edits (`edits` -> `touched_files`).
15
+ *
16
+ * The checker is structural only. It never judges coverage — that stays the
17
+ * reviewer's job. All I/O goes through an injectable seam so tests never touch
18
+ * a real disk.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import path from "node:path";
22
+ import { writeJsonAtomically, } from "./conductor-bundle-artifacts.js";
23
+ // ===========================================================================
24
+ // Constants
25
+ // ===========================================================================
26
+ /**
27
+ * The high-risk edit prefixes from the procedure's "Escalation rule". An
28
+ * unclassified backticked path under one of these is a build error, because
29
+ * whether a child edits or only reads it decides the epic's risk tier.
30
+ */
31
+ export const HIGH_RISK_PATH_PREFIXES = [
32
+ "db/migrations/",
33
+ "db/alembic/",
34
+ "api/library/billing/",
35
+ "api/routes/setup/auth.py",
36
+ "api/library/auth/",
37
+ "mcp_server/src/executor/",
38
+ "mcp_server/src/agent-launchers/",
39
+ "mcp_server/src/claude-settings.ts",
40
+ ".github/workflows/",
41
+ "tests/integration/conductor_harness/",
42
+ "api/library/db/",
43
+ ];
44
+ const EPIC_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/;
45
+ const EPIC_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
46
+ const AC_ID_PATTERN = /^AC-\d+$/;
47
+ const EPIC_AC_BULLET = /^[-*]\s+(?:\*\*)?(AC-\d+):(?:\*\*)?(?:\s+(.*))?$/;
48
+ const TOP_LEVEL_BULLET = /^[-*]\s+(.*)$/;
49
+ const VERIFICATION_LABEL = /(?:\*\*)?Verification:(?:\*\*)?/;
50
+ const BACKTICK_SPAN = /`([^`\n]+)`/g;
51
+ const REPO_PATH_SHAPE = /^[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)*\/?$/;
52
+ // ===========================================================================
53
+ // Canonical encoding and digests
54
+ // ===========================================================================
55
+ /** Raised when a value has no encoding identical to the procedure's Python one. */
56
+ export class CanonicalEncodingError extends Error {
57
+ }
58
+ const PY_ESCAPES = {
59
+ '"': '\\"',
60
+ "\\": "\\\\",
61
+ "\b": "\\b",
62
+ "\f": "\\f",
63
+ "\n": "\\n",
64
+ "\r": "\\r",
65
+ "\t": "\\t",
66
+ };
67
+ /** `json.dumps(s, ensure_ascii=False)` for one string; lone surrogates are rejected. */
68
+ function encodeString(value) {
69
+ let out = '"';
70
+ for (let i = 0; i < value.length; i++) {
71
+ const code = value.charCodeAt(i);
72
+ if (code >= 0xd800 && code <= 0xdbff) {
73
+ const next = value.charCodeAt(i + 1);
74
+ if (!(next >= 0xdc00 && next <= 0xdfff)) {
75
+ throw new CanonicalEncodingError("A string contains a lone surrogate.");
76
+ }
77
+ out += value[i] + value[i + 1];
78
+ i++;
79
+ continue;
80
+ }
81
+ if (code >= 0xdc00 && code <= 0xdfff) {
82
+ throw new CanonicalEncodingError("A string contains a lone surrogate.");
83
+ }
84
+ const ch = value[i];
85
+ const escape = PY_ESCAPES[ch];
86
+ if (escape !== undefined)
87
+ out += escape;
88
+ else if (code < 0x20)
89
+ out += "\\u" + code.toString(16).padStart(4, "0");
90
+ else
91
+ out += ch;
92
+ }
93
+ return out + '"';
94
+ }
95
+ /** Python's `sort_keys` orders by code point, not by UTF-16 code unit. */
96
+ function compareCodePoints(a, b) {
97
+ const left = Array.from(a);
98
+ const right = Array.from(b);
99
+ const length = Math.min(left.length, right.length);
100
+ for (let i = 0; i < length; i++) {
101
+ const diff = left[i].codePointAt(0) - right[i].codePointAt(0);
102
+ if (diff !== 0)
103
+ return diff;
104
+ }
105
+ return left.length - right.length;
106
+ }
107
+ /**
108
+ * The one canonical serializer behind all three digests: sorted keys, compact
109
+ * separators, and raw UTF-8 — identical to
110
+ * `json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)`.
111
+ * Floats, unsafe integers, and anything else Python would encode differently
112
+ * are rejected rather than silently diverging.
113
+ */
114
+ export function canonicalJson(value) {
115
+ if (value === null)
116
+ return "null";
117
+ if (value === true)
118
+ return "true";
119
+ if (value === false)
120
+ return "false";
121
+ if (typeof value === "string")
122
+ return encodeString(value);
123
+ if (typeof value === "number") {
124
+ if (!Number.isSafeInteger(value)) {
125
+ throw new CanonicalEncodingError(`The number ${String(value)} is not a safe integer; floats and unsafe integers have no identical encoding.`);
126
+ }
127
+ return Object.is(value, -0) ? "0" : String(value);
128
+ }
129
+ if (Array.isArray(value)) {
130
+ return "[" + value.map((item) => canonicalJson(item)).join(",") + "]";
131
+ }
132
+ if (typeof value === "object") {
133
+ const record = value;
134
+ const keys = Object.keys(record).sort(compareCodePoints);
135
+ return ("{" +
136
+ keys.map((key) => encodeString(key) + ":" + canonicalJson(record[key])).join(",") +
137
+ "}");
138
+ }
139
+ throw new CanonicalEncodingError(`Unsupported value of type ${typeof value}.`);
140
+ }
141
+ /** SHA-256 hex digest of the canonical encoding. */
142
+ export function canonicalSha256(value) {
143
+ return createHash("sha256").update(Buffer.from(canonicalJson(value), "utf8")).digest("hex");
144
+ }
145
+ /**
146
+ * The procedure's three identities. An absent `child_evidence` or
147
+ * `non_child_owners` array hashes as an empty array.
148
+ */
149
+ export function computeEpicCoherenceDigests(document) {
150
+ return {
151
+ manifest_sha256: canonicalSha256(document.manifest),
152
+ child_evidence_sha256: canonicalSha256(document.child_evidence ?? []),
153
+ non_child_owners_sha256: canonicalSha256(document.non_child_owners ?? []),
154
+ };
155
+ }
156
+ /**
157
+ * Reject number tokens JavaScript would silently reinterpret. `JSON.parse`
158
+ * reads `1.0` and `1e2` as integers, but Python reads them as floats and
159
+ * encodes them differently, so the digests would diverge.
160
+ */
161
+ export function findUnsupportedNumberTokens(raw) {
162
+ const problems = [];
163
+ let inString = false;
164
+ for (let i = 0; i < raw.length; i++) {
165
+ const ch = raw[i];
166
+ if (inString) {
167
+ if (ch === "\\")
168
+ i++;
169
+ else if (ch === '"')
170
+ inString = false;
171
+ continue;
172
+ }
173
+ if (ch === '"') {
174
+ inString = true;
175
+ continue;
176
+ }
177
+ if (ch === "-" || (ch >= "0" && ch <= "9")) {
178
+ let j = i + 1;
179
+ while (j < raw.length && /[0-9.eE+-]/.test(raw[j]))
180
+ j++;
181
+ const token = raw.slice(i, j);
182
+ if (/[.eE]/.test(token)) {
183
+ problems.push(`number ${token} is not an integer`);
184
+ }
185
+ else if (!Number.isSafeInteger(Number(token))) {
186
+ problems.push(`integer ${token} is outside the safe integer range`);
187
+ }
188
+ i = j - 1;
189
+ }
190
+ }
191
+ return problems;
192
+ }
193
+ /** Parse JSON text and reject number tokens with no identical canonical encoding. */
194
+ function parseCanonicalJson(raw, label) {
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(raw);
198
+ }
199
+ catch (err) {
200
+ return {
201
+ ok: false,
202
+ errors: [`${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`],
203
+ warnings: [],
204
+ };
205
+ }
206
+ const numberProblems = findUnsupportedNumberTokens(raw);
207
+ if (numberProblems.length > 0) {
208
+ return {
209
+ ok: false,
210
+ errors: numberProblems.map((problem) => `${label}: ${problem}.`),
211
+ warnings: [],
212
+ };
213
+ }
214
+ return { ok: true, value: parsed, warnings: [] };
215
+ }
216
+ // ===========================================================================
217
+ // Path containment
218
+ // ===========================================================================
219
+ function isInside(target, dir) {
220
+ if (target === dir)
221
+ return true;
222
+ return target.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
223
+ }
224
+ /** A syntactic check: repository-relative, never absolute, never upward. */
225
+ export function repositoryRelativePathProblem(candidate, label) {
226
+ if (typeof candidate !== "string" || candidate.trim().length === 0) {
227
+ return `${label} must be a non-empty path.`;
228
+ }
229
+ if (path.isAbsolute(candidate) || /^[A-Za-z]:[\\/]/.test(candidate) || candidate.startsWith("\\")) {
230
+ return `${label} must be repository-relative, not absolute: ${candidate}`;
231
+ }
232
+ if (candidate.replace(/\\/g, "/").split("/").some((segment) => segment === "..")) {
233
+ return `${label} must not traverse upward: ${candidate}`;
234
+ }
235
+ return null;
236
+ }
237
+ function isMissing(err) {
238
+ const code = err?.code;
239
+ return code === "ENOENT" || code === "ENOTDIR";
240
+ }
241
+ /**
242
+ * Resolve a repository-relative path and prove it stays inside the repository.
243
+ * An existing target is checked by its real path. A missing target is checked
244
+ * through its nearest existing ancestor, so a symlinked ancestor cannot escape.
245
+ */
246
+ async function locateInsideRepository(relative, repoRoot, deps, label) {
247
+ const syntactic = repositoryRelativePathProblem(relative, label);
248
+ if (syntactic)
249
+ return { ok: false, error: syntactic };
250
+ const resolved = path.resolve(deps.cwd, relative);
251
+ let probe = resolved;
252
+ let exists = true;
253
+ for (;;) {
254
+ try {
255
+ const canonical = await deps.fs.realpath(probe);
256
+ if (!isInside(canonical, repoRoot)) {
257
+ return { ok: false, error: `${label} resolves outside the repository: ${relative}` };
258
+ }
259
+ return { ok: true, value: { exists, canonical: exists ? canonical : resolved } };
260
+ }
261
+ catch (err) {
262
+ if (!isMissing(err)) {
263
+ return {
264
+ ok: false,
265
+ error: `${label} cannot be resolved: ${err instanceof Error ? err.message : String(err)}`,
266
+ };
267
+ }
268
+ const parent = path.dirname(probe);
269
+ if (parent === probe) {
270
+ return { ok: false, error: `${label} has no existing ancestor: ${relative}` };
271
+ }
272
+ exists = false;
273
+ probe = parent;
274
+ }
275
+ }
276
+ }
277
+ /** Read a contained, existing file. */
278
+ async function readContainedFile(relative, repoRoot, deps, label) {
279
+ const located = await locateInsideRepository(relative, repoRoot, deps, label);
280
+ if (!located.ok)
281
+ return located;
282
+ if (!located.value.exists)
283
+ return { ok: false, error: `${label} does not exist: ${relative}` };
284
+ try {
285
+ return { ok: true, value: await deps.fs.readFile(located.value.canonical) };
286
+ }
287
+ catch (err) {
288
+ return {
289
+ ok: false,
290
+ error: `${label} is unreadable: ${relative}: ${err instanceof Error ? err.message : String(err)}`,
291
+ };
292
+ }
293
+ }
294
+ async function resolveRepositoryRoot(deps) {
295
+ try {
296
+ return { ok: true, value: await deps.fs.realpath(deps.cwd) };
297
+ }
298
+ catch (err) {
299
+ return {
300
+ ok: false,
301
+ error: `Cannot resolve the repository root ${deps.cwd}: ${err instanceof Error ? err.message : String(err)}`,
302
+ };
303
+ }
304
+ }
305
+ /** Split a draft into its H1, preamble, and H2 sections. Fenced code is not a heading. */
306
+ function parseDraft(text) {
307
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
308
+ let title = null;
309
+ const preamble = [];
310
+ const sections = [];
311
+ let inFence = false;
312
+ for (const line of lines) {
313
+ if (/^\s*(```|~~~)/.test(line))
314
+ inFence = !inFence;
315
+ if (!inFence && title === null && sections.length === 0 && /^# /.test(line)) {
316
+ title = line.slice(2).trim();
317
+ continue;
318
+ }
319
+ if (!inFence && /^## /.test(line)) {
320
+ sections.push({ heading: line.slice(3).trim(), lines: [] });
321
+ continue;
322
+ }
323
+ if (sections.length === 0)
324
+ preamble.push(line);
325
+ else
326
+ sections[sections.length - 1].lines.push(line);
327
+ }
328
+ return {
329
+ title,
330
+ preamble: preamble.join("\n").trim(),
331
+ sections: sections.map((s) => ({ heading: s.heading, body: s.lines.join("\n").trim() })),
332
+ };
333
+ }
334
+ function sameHeading(a, b) {
335
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
336
+ }
337
+ function findSections(draft, heading) {
338
+ return draft.sections.filter((s) => sameHeading(s.heading, heading));
339
+ }
340
+ /** Collapse whitespace runs to single spaces: the only normalization allowed. */
341
+ function normalizeWhitespace(text) {
342
+ return text.replace(/\s+/g, " ").trim();
343
+ }
344
+ /**
345
+ * Split an Acceptance Criteria section into top-level bullets, joining indented
346
+ * continuation lines with single spaces. Prose before the first bullet is an
347
+ * introduction; an unindented non-bullet line after one is ambiguous.
348
+ */
349
+ function parseTopLevelBullets(body, label) {
350
+ const bullets = [];
351
+ const errors = [];
352
+ for (const line of body.split("\n")) {
353
+ if (line.trim().length === 0)
354
+ continue;
355
+ const bullet = TOP_LEVEL_BULLET.exec(line);
356
+ if (bullet) {
357
+ bullets.push([bullet[1]]);
358
+ }
359
+ else if (/^\s/.test(line) && bullets.length > 0) {
360
+ bullets[bullets.length - 1].push(line.trim());
361
+ }
362
+ else if (bullets.length > 0) {
363
+ errors.push(`${label}: an unindented line after a bullet is ambiguous: "${line.trim()}"`);
364
+ }
365
+ }
366
+ return { bullets: bullets.map((parts) => normalizeWhitespace(parts.join(" "))), errors };
367
+ }
368
+ function parseEpicAcceptanceCriteria(body, label) {
369
+ const { bullets, errors } = parseTopLevelBullets(body, label);
370
+ const criteria = [];
371
+ for (const bullet of bullets) {
372
+ const match = EPIC_AC_BULLET.exec(`- ${bullet}`);
373
+ if (!match) {
374
+ errors.push(`${label}: every epic AC bullet must start with an explicit id such as "AC-3:"; ` +
375
+ `ids are never assigned by position or keyword: "${bullet}"`);
376
+ continue;
377
+ }
378
+ const rest = match[2] ?? "";
379
+ const labelMatch = VERIFICATION_LABEL.exec(rest);
380
+ const text = normalizeWhitespace(labelMatch ? rest.slice(0, labelMatch.index) : rest);
381
+ const verification = labelMatch
382
+ ? normalizeWhitespace(rest.slice(labelMatch.index + labelMatch[0].length))
383
+ : "";
384
+ criteria.push({ id: match[1], text, verification });
385
+ }
386
+ if (criteria.length === 0 && errors.length === 0) {
387
+ errors.push(`${label}: the Acceptance Criteria section holds no AC bullets.`);
388
+ }
389
+ return { criteria, errors };
390
+ }
391
+ /** Backticked spans in a draft that look like repository paths. */
392
+ function backtickedRepositoryPaths(text) {
393
+ const found = new Set();
394
+ for (const match of text.matchAll(BACKTICK_SPAN)) {
395
+ const candidate = match[1].trim();
396
+ if (!candidate.includes("/") || candidate.includes("://"))
397
+ continue;
398
+ if (!REPO_PATH_SHAPE.test(candidate))
399
+ continue;
400
+ if (repositoryRelativePathProblem(candidate, "path"))
401
+ continue;
402
+ found.add(candidate);
403
+ }
404
+ return [...found];
405
+ }
406
+ /** A mention is classified when a listed path equals it or one contains the other as a directory. */
407
+ function isClassified(mention, listed) {
408
+ return listed.some((entry) => entry === mention ||
409
+ (mention.endsWith("/") && entry.startsWith(mention)) ||
410
+ (entry.endsWith("/") && mention.startsWith(entry)));
411
+ }
412
+ function isHighRiskPath(candidate) {
413
+ return HIGH_RISK_PATH_PREFIXES.some((prefix) => candidate.startsWith(prefix) || (candidate.endsWith("/") && prefix.startsWith(candidate)));
414
+ }
415
+ // ===========================================================================
416
+ // Structural validation
417
+ // ===========================================================================
418
+ function isRecord(value) {
419
+ return typeof value === "object" && value !== null && !Array.isArray(value);
420
+ }
421
+ function isNonEmptyString(value) {
422
+ return typeof value === "string" && value.trim().length > 0;
423
+ }
424
+ function isStringArray(value) {
425
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
426
+ }
427
+ function duplicates(values) {
428
+ const seen = new Set();
429
+ const dupes = new Set();
430
+ for (const value of values) {
431
+ if (seen.has(value))
432
+ dupes.add(value);
433
+ seen.add(value);
434
+ }
435
+ return [...dupes];
436
+ }
437
+ function validateClaims(claims, acIds, label, errors) {
438
+ if (claims === undefined)
439
+ return;
440
+ if (!Array.isArray(claims)) {
441
+ errors.push(`${label}: owns_verifications must be an array.`);
442
+ return;
443
+ }
444
+ claims.forEach((claim, index) => {
445
+ const where = `${label}: owns_verifications[${index}]`;
446
+ if (!isRecord(claim)) {
447
+ errors.push(`${where} must be an object.`);
448
+ return;
449
+ }
450
+ if (!isNonEmptyString(claim.epic_ac) || !acIds.has(claim.epic_ac)) {
451
+ errors.push(`${where}: epic_ac ${JSON.stringify(claim.epic_ac)} names no real epic AC.`);
452
+ }
453
+ if (!isNonEmptyString(claim.obligation)) {
454
+ errors.push(`${where}: obligation must be a non-empty string.`);
455
+ }
456
+ });
457
+ }
458
+ function validatePathList(value, label, errors) {
459
+ if (value === undefined)
460
+ return [];
461
+ if (!isStringArray(value)) {
462
+ errors.push(`${label} must be an array of strings.`);
463
+ return [];
464
+ }
465
+ for (const entry of value) {
466
+ const problem = repositoryRelativePathProblem(entry, label);
467
+ if (problem)
468
+ errors.push(problem);
469
+ }
470
+ return value;
471
+ }
472
+ /**
473
+ * The procedure's structural "Validation" rules, in memory. It never judges
474
+ * coverage. Returns every problem found, not just the first.
475
+ */
476
+ export function validateEpicCoherenceDocumentShape(document) {
477
+ const errors = [];
478
+ if (!isRecord(document))
479
+ return ["The input document must be a JSON object."];
480
+ const epic = document.epic;
481
+ const acIds = new Set();
482
+ if (!isRecord(epic)) {
483
+ errors.push("epic must be an object.");
484
+ }
485
+ else {
486
+ const hasKey = epic.key !== undefined;
487
+ const hasSlug = epic.slug !== undefined;
488
+ if (!hasKey && !hasSlug)
489
+ errors.push("epic must carry a key or a slug.");
490
+ if (hasKey && (typeof epic.key !== "string" || !EPIC_KEY_PATTERN.test(epic.key))) {
491
+ errors.push(`epic.key ${JSON.stringify(epic.key)} is not a valid ticket key.`);
492
+ }
493
+ if (hasSlug && (typeof epic.slug !== "string" || !EPIC_SLUG_PATTERN.test(epic.slug))) {
494
+ errors.push(`epic.slug ${JSON.stringify(epic.slug)} is not a valid slug.`);
495
+ }
496
+ if (!isNonEmptyString(epic.title))
497
+ errors.push("epic.title must be a non-empty string.");
498
+ if (!Array.isArray(epic.acceptance_criteria) || epic.acceptance_criteria.length === 0) {
499
+ errors.push("epic.acceptance_criteria must be a non-empty array.");
500
+ }
501
+ else {
502
+ const ids = [];
503
+ epic.acceptance_criteria.forEach((ac, index) => {
504
+ if (!isRecord(ac)) {
505
+ errors.push(`epic.acceptance_criteria[${index}] must be an object.`);
506
+ return;
507
+ }
508
+ if (!isNonEmptyString(ac.id) || !AC_ID_PATTERN.test(ac.id)) {
509
+ errors.push(`epic.acceptance_criteria[${index}].id must be an explicit id such as "AC-1".`);
510
+ }
511
+ else {
512
+ ids.push(ac.id);
513
+ }
514
+ if (typeof ac.text !== "string") {
515
+ errors.push(`epic.acceptance_criteria[${index}].text must be a string.`);
516
+ }
517
+ if (typeof ac.verification !== "string") {
518
+ errors.push(`epic.acceptance_criteria[${index}].verification must be a string.`);
519
+ }
520
+ });
521
+ for (const dupe of duplicates(ids))
522
+ errors.push(`Epic AC id ${dupe} is not unique.`);
523
+ ids.forEach((id) => acIds.add(id));
524
+ }
525
+ }
526
+ if (typeof document.design_text !== "string")
527
+ errors.push("design_text must be a string.");
528
+ const childIds = [];
529
+ if (!Array.isArray(document.manifest) || document.manifest.length === 0) {
530
+ errors.push("manifest must be a non-empty array.");
531
+ }
532
+ else {
533
+ for (const entry of document.manifest) {
534
+ if (isRecord(entry) && isNonEmptyString(entry.id))
535
+ childIds.push(entry.id);
536
+ }
537
+ const childIdSet = new Set(childIds);
538
+ document.manifest.forEach((entry, index) => {
539
+ const label = `manifest[${index}]`;
540
+ if (!isRecord(entry)) {
541
+ errors.push(`${label} must be an object.`);
542
+ return;
543
+ }
544
+ for (const field of ["id", "title", "scope", "size_band"]) {
545
+ if (!isNonEmptyString(entry[field]))
546
+ errors.push(`${label}.${field} must be a non-empty string.`);
547
+ }
548
+ for (const field of ["depends_on", "recommended_after"]) {
549
+ const value = entry[field];
550
+ if (!isStringArray(value)) {
551
+ errors.push(`${label}.${field} must be an array of child ids.`);
552
+ }
553
+ else {
554
+ for (const ref of value) {
555
+ if (!childIdSet.has(ref))
556
+ errors.push(`${label}.${field} names unknown child ${ref}.`);
557
+ }
558
+ }
559
+ }
560
+ const edits = validatePathList(entry.touched_files, `${label}.touched_files`, errors);
561
+ const reads = validatePathList(entry.read_files, `${label}.read_files`, errors);
562
+ for (const both of edits.filter((p) => reads.includes(p))) {
563
+ errors.push(`${label}: ${both} is in both touched_files and read_files.`);
564
+ }
565
+ validateClaims(entry.owns_verifications, acIds, label, errors);
566
+ });
567
+ for (const dupe of duplicates(childIds))
568
+ errors.push(`Child id ${dupe} is not unique.`);
569
+ }
570
+ const childIdSet = new Set(childIds);
571
+ if (document.child_evidence !== undefined) {
572
+ if (!Array.isArray(document.child_evidence)) {
573
+ errors.push("child_evidence must be an array.");
574
+ }
575
+ else {
576
+ document.child_evidence.forEach((evidence, index) => {
577
+ const label = `child_evidence[${index}]`;
578
+ if (!isRecord(evidence)) {
579
+ errors.push(`${label} must be an object.`);
580
+ return;
581
+ }
582
+ if (!isNonEmptyString(evidence.id) || !childIdSet.has(evidence.id)) {
583
+ errors.push(`${label}.id ${JSON.stringify(evidence.id)} names no manifest child.`);
584
+ }
585
+ if (evidence.acceptance_criteria !== undefined && !isStringArray(evidence.acceptance_criteria)) {
586
+ errors.push(`${label}.acceptance_criteria must be an array of strings.`);
587
+ }
588
+ if (evidence.body !== undefined && typeof evidence.body !== "string") {
589
+ errors.push(`${label}.body must be a string.`);
590
+ }
591
+ });
592
+ }
593
+ }
594
+ if (document.non_child_owners !== undefined) {
595
+ if (!Array.isArray(document.non_child_owners)) {
596
+ errors.push("non_child_owners must be an array.");
597
+ }
598
+ else {
599
+ const ownerIds = [];
600
+ document.non_child_owners.forEach((owner, index) => {
601
+ const label = `non_child_owners[${index}]`;
602
+ if (!isRecord(owner)) {
603
+ errors.push(`${label} must be an object.`);
604
+ return;
605
+ }
606
+ if (!isNonEmptyString(owner.id)) {
607
+ errors.push(`${label}.id must be a non-empty string.`);
608
+ }
609
+ else {
610
+ ownerIds.push(owner.id);
611
+ if (childIdSet.has(owner.id)) {
612
+ errors.push(`${label}.id ${owner.id} collides with a child id.`);
613
+ }
614
+ }
615
+ if (owner.kind !== "owner_run") {
616
+ errors.push(`${label}.kind must be "owner_run", not ${JSON.stringify(owner.kind)}.`);
617
+ }
618
+ for (const field of ["who", "step_ref", "step_text", "records_result_in"]) {
619
+ if (!isNonEmptyString(owner[field]))
620
+ errors.push(`${label}.${field} must be a non-empty string.`);
621
+ }
622
+ if (!Array.isArray(owner.owns_verifications) || owner.owns_verifications.length === 0) {
623
+ errors.push(`${label}.owns_verifications must be a non-empty array.`);
624
+ }
625
+ else {
626
+ validateClaims(owner.owns_verifications, acIds, label, errors);
627
+ }
628
+ });
629
+ for (const dupe of duplicates(ownerIds))
630
+ errors.push(`Non-child owner id ${dupe} is not unique.`);
631
+ }
632
+ }
633
+ try {
634
+ computeEpicCoherenceDigests(document);
635
+ }
636
+ catch (err) {
637
+ errors.push(`The document has no canonical encoding: ${err instanceof Error ? err.message : String(err)}`);
638
+ }
639
+ return errors;
640
+ }
641
+ /**
642
+ * Named-file checks: every `touched_files` path stays inside the repository (a
643
+ * missing one may be a planned creation); every `read_files` path must exist,
644
+ * stay inside the repository, and be readable.
645
+ */
646
+ async function validateNamedFiles(document, repoRoot, deps) {
647
+ const errors = [];
648
+ for (const entry of document.manifest) {
649
+ for (const edit of entry.touched_files ?? []) {
650
+ const located = await locateInsideRepository(edit, repoRoot, deps, `child ${entry.id} touched_files`);
651
+ if (!located.ok)
652
+ errors.push(located.error);
653
+ }
654
+ for (const read of entry.read_files ?? []) {
655
+ const readable = await readContainedFile(read, repoRoot, deps, `child ${entry.id} read_files`);
656
+ if (!readable.ok)
657
+ errors.push(readable.error);
658
+ }
659
+ }
660
+ return errors;
661
+ }
662
+ // ===========================================================================
663
+ // Check mode
664
+ // ===========================================================================
665
+ /** Structurally validate an in-memory document, including its named files. */
666
+ async function checkDocument(document, repoRoot, deps) {
667
+ const errors = validateEpicCoherenceDocumentShape(document);
668
+ if (errors.length > 0)
669
+ return { ok: false, errors, warnings: [] };
670
+ const typed = document;
671
+ const fileErrors = await validateNamedFiles(typed, repoRoot, deps);
672
+ if (fileErrors.length > 0)
673
+ return { ok: false, errors: fileErrors, warnings: [] };
674
+ return { ok: true, value: { digests: computeEpicCoherenceDigests(typed) }, warnings: [] };
675
+ }
676
+ /** `epic-coherence-input check <path>`: validate an existing document. Writes nothing. */
677
+ export async function checkEpicCoherenceInput(inputPath, deps) {
678
+ const root = await resolveRepositoryRoot(deps);
679
+ if (!root.ok)
680
+ return { ok: false, errors: [root.error], warnings: [] };
681
+ const raw = await readContainedFile(inputPath, root.value, deps, "Input document");
682
+ if (!raw.ok)
683
+ return { ok: false, errors: [raw.error], warnings: [] };
684
+ const parsed = parseCanonicalJson(raw.value, "Input document");
685
+ if (!parsed.ok)
686
+ return parsed;
687
+ return checkDocument(parsed.value, root.value, deps);
688
+ }
689
+ // ===========================================================================
690
+ // Build mode
691
+ // ===========================================================================
692
+ function validateOutlineShape(outline) {
693
+ const errors = [];
694
+ if (!isRecord(outline))
695
+ return ["The outline must be a JSON object."];
696
+ if (!isRecord(outline.epic)) {
697
+ errors.push("outline.epic must be an object.");
698
+ }
699
+ else {
700
+ if (outline.epic.key === undefined && outline.epic.slug === undefined) {
701
+ errors.push("outline.epic must carry a key or a slug.");
702
+ }
703
+ if (!isNonEmptyString(outline.epic.draft))
704
+ errors.push("outline.epic.draft must be a path.");
705
+ }
706
+ if (!Array.isArray(outline.children) || outline.children.length === 0) {
707
+ errors.push("outline.children must be a non-empty array.");
708
+ }
709
+ else {
710
+ outline.children.forEach((child, index) => {
711
+ const label = `outline.children[${index}]`;
712
+ if (!isRecord(child)) {
713
+ errors.push(`${label} must be an object.`);
714
+ return;
715
+ }
716
+ for (const field of ["id", "draft", "size_band"]) {
717
+ if (!isNonEmptyString(child[field]))
718
+ errors.push(`${label}.${field} must be a non-empty string.`);
719
+ }
720
+ for (const field of ["depends_on", "recommended_after", "edits", "reads"]) {
721
+ if (child[field] !== undefined && !isStringArray(child[field])) {
722
+ errors.push(`${label}.${field} must be an array of strings.`);
723
+ }
724
+ }
725
+ if (child.owns_verifications !== undefined && !Array.isArray(child.owns_verifications)) {
726
+ errors.push(`${label}.owns_verifications must be an array.`);
727
+ }
728
+ });
729
+ }
730
+ if (outline.non_child_owners !== undefined && !Array.isArray(outline.non_child_owners)) {
731
+ errors.push("outline.non_child_owners must be an array.");
732
+ }
733
+ return errors;
734
+ }
735
+ function composeDesignText(draft) {
736
+ const parts = [];
737
+ if (draft.preamble)
738
+ parts.push(draft.preamble);
739
+ for (const section of draft.sections) {
740
+ if (sameHeading(section.heading, "Acceptance Criteria"))
741
+ continue;
742
+ if (sameHeading(section.heading, "Materials & Access"))
743
+ continue;
744
+ parts.push(`## ${section.heading}\n\n${section.body}`.trim());
745
+ }
746
+ return parts.join("\n\n");
747
+ }
748
+ /**
749
+ * `epic-coherence-input build --outline <path> --out <path>`. Parses the drafts,
750
+ * builds the normalized document, runs the same structural checker check mode
751
+ * uses, computes the three digests, and writes only when everything passed.
752
+ */
753
+ export async function buildEpicCoherenceInput(options, deps) {
754
+ const errors = [];
755
+ const warnings = [];
756
+ const failed = () => ({
757
+ ok: false,
758
+ errors,
759
+ warnings,
760
+ });
761
+ const root = await resolveRepositoryRoot(deps);
762
+ if (!root.ok)
763
+ return { ok: false, errors: [root.error], warnings };
764
+ const repoRoot = root.value;
765
+ // Contain the output before reading anything, so an escaping --out fails first.
766
+ const out = await locateInsideRepository(options.outPath, repoRoot, deps, "--out");
767
+ if (!out.ok)
768
+ return { ok: false, errors: [out.error], warnings };
769
+ const rawOutline = await readContainedFile(options.outlinePath, repoRoot, deps, "Outline");
770
+ if (!rawOutline.ok)
771
+ return { ok: false, errors: [rawOutline.error], warnings };
772
+ const parsedOutline = parseCanonicalJson(rawOutline.value, "Outline");
773
+ if (!parsedOutline.ok)
774
+ return parsedOutline;
775
+ errors.push(...validateOutlineShape(parsedOutline.value));
776
+ if (errors.length > 0)
777
+ return failed();
778
+ const outline = parsedOutline.value;
779
+ // ---- epic draft --------------------------------------------------------
780
+ const epicRaw = await readContainedFile(outline.epic.draft, repoRoot, deps, "Epic draft");
781
+ if (!epicRaw.ok)
782
+ return { ok: false, errors: [epicRaw.error], warnings };
783
+ const epicDraft = parseDraft(epicRaw.value);
784
+ if (!epicDraft.title)
785
+ errors.push("Epic draft: no H1 title.");
786
+ const epicAcSections = findSections(epicDraft, "Acceptance Criteria");
787
+ let epicCriteria = [];
788
+ if (epicAcSections.length !== 1) {
789
+ errors.push(`Epic draft: expected exactly one "## Acceptance Criteria" section, found ${epicAcSections.length}.`);
790
+ }
791
+ else {
792
+ const parsed = parseEpicAcceptanceCriteria(epicAcSections[0].body, "Epic draft");
793
+ epicCriteria = parsed.criteria;
794
+ errors.push(...parsed.errors);
795
+ }
796
+ // ---- child drafts ------------------------------------------------------
797
+ const manifest = [];
798
+ const childEvidence = [];
799
+ for (const child of outline.children) {
800
+ const label = `Child ${child.id}`;
801
+ const edits = child.edits ?? [];
802
+ const reads = child.reads ?? [];
803
+ for (const both of edits.filter((p) => reads.includes(p))) {
804
+ errors.push(`${label}: ${both} is in both edits and reads.`);
805
+ }
806
+ const draftRaw = await readContainedFile(child.draft, repoRoot, deps, `${label} draft`);
807
+ if (!draftRaw.ok) {
808
+ errors.push(draftRaw.error);
809
+ continue;
810
+ }
811
+ const draft = parseDraft(draftRaw.value);
812
+ if (!draft.title)
813
+ errors.push(`${label} draft: no H1 title.`);
814
+ const summaries = findSections(draft, "Summary");
815
+ if (summaries.length !== 1) {
816
+ errors.push(`${label} draft: expected exactly one "## Summary" section, found ${summaries.length}.`);
817
+ }
818
+ const acSections = findSections(draft, "Acceptance Criteria");
819
+ let childCriteria = [];
820
+ if (acSections.length !== 1) {
821
+ errors.push(`${label} draft: expected exactly one "## Acceptance Criteria" section, found ${acSections.length}.`);
822
+ }
823
+ else {
824
+ const parsed = parseTopLevelBullets(acSections[0].body, `${label} draft`);
825
+ childCriteria = parsed.bullets;
826
+ errors.push(...parsed.errors);
827
+ }
828
+ // Every declared obligation must name a real epic AC and appear verbatim,
829
+ // prefixed by its AC id.
830
+ const epicAcIds = new Set(epicCriteria.map((ac) => ac.id));
831
+ const claims = child.owns_verifications ?? [];
832
+ for (const claim of claims) {
833
+ if (!isRecord(claim) || !isNonEmptyString(claim.epic_ac) || !isNonEmptyString(claim.obligation)) {
834
+ continue; // Reported by the structural checker below.
835
+ }
836
+ if (epicAcSections.length === 1 && !epicAcIds.has(claim.epic_ac)) {
837
+ errors.push(`${label}: owns_verifications epic_ac ${claim.epic_ac} names no real epic AC.`);
838
+ continue;
839
+ }
840
+ const expected = normalizeWhitespace(`${claim.epic_ac}: ${claim.obligation}`);
841
+ if (acSections.length === 1 && !childCriteria.includes(expected)) {
842
+ errors.push(`${label}: the obligation for ${claim.epic_ac} is not found verbatim in the draft's ` +
843
+ `Acceptance Criteria as "${expected}".`);
844
+ }
845
+ }
846
+ // Every backticked repository path must be classified as an edit or a read.
847
+ const listed = [...edits, ...reads];
848
+ for (const mention of backtickedRepositoryPaths(draftRaw.value)) {
849
+ if (isClassified(mention, listed))
850
+ continue;
851
+ if (isHighRiskPath(mention)) {
852
+ errors.push(`${label}: \`${mention}\` is under a high-risk path but is in neither edits nor reads; ` +
853
+ "classify it, because only edits decide the risk tier.");
854
+ }
855
+ else {
856
+ warnings.push(`${label}: \`${mention}\` is named in the draft but is in neither edits nor reads.`);
857
+ }
858
+ }
859
+ manifest.push({
860
+ id: child.id,
861
+ title: draft.title ?? "",
862
+ scope: summaries[0]?.body ?? "",
863
+ size_band: child.size_band,
864
+ depends_on: child.depends_on ?? [],
865
+ recommended_after: child.recommended_after ?? [],
866
+ touched_files: edits,
867
+ read_files: reads,
868
+ owns_verifications: claims,
869
+ });
870
+ childEvidence.push({
871
+ id: child.id,
872
+ acceptance_criteria: childCriteria,
873
+ body: draftRaw.value.replace(/\r\n/g, "\n"),
874
+ });
875
+ }
876
+ if (errors.length > 0)
877
+ return failed();
878
+ const epic = {
879
+ ...(outline.epic.key !== undefined ? { key: outline.epic.key } : {}),
880
+ ...(outline.epic.slug !== undefined ? { slug: outline.epic.slug } : {}),
881
+ title: epicDraft.title ?? "",
882
+ acceptance_criteria: epicCriteria,
883
+ };
884
+ const document = {
885
+ epic,
886
+ design_text: composeDesignText(epicDraft),
887
+ manifest,
888
+ child_evidence: childEvidence,
889
+ ...(outline.non_child_owners !== undefined ? { non_child_owners: outline.non_child_owners } : {}),
890
+ };
891
+ // The same structural checker check mode runs; nothing is written on failure.
892
+ const checked = await checkDocument(document, repoRoot, deps);
893
+ if (!checked.ok)
894
+ return { ok: false, errors: checked.errors, warnings };
895
+ const destination = path.resolve(deps.cwd, options.outPath);
896
+ if (!out.value.exists) {
897
+ try {
898
+ await deps.fs.mkdir(path.dirname(destination));
899
+ }
900
+ catch (err) {
901
+ return {
902
+ ok: false,
903
+ errors: [`Could not create the directory for --out: ${err instanceof Error ? err.message : String(err)}`],
904
+ warnings,
905
+ };
906
+ }
907
+ // Re-contain after creating directories, in case an ancestor changed underneath.
908
+ const recheck = await locateInsideRepository(options.outPath, repoRoot, deps, "--out");
909
+ if (!recheck.ok)
910
+ return { ok: false, errors: [recheck.error], warnings };
911
+ }
912
+ const written = await writeJsonAtomically(destination, document, deps.fs);
913
+ if (!written.ok)
914
+ return { ok: false, errors: [written.error], warnings };
915
+ return {
916
+ ok: true,
917
+ value: { output_path: options.outPath, document, digests: checked.value.digests },
918
+ warnings,
919
+ };
920
+ }