@bridge_gpt/mcp-server 0.2.24 → 0.2.26

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 (36) hide show
  1. package/README.md +98 -28
  2. package/build/agents.generated.js +1 -1
  3. package/build/bridge-api-urls.js +31 -0
  4. package/build/commands.generated.js +5 -5
  5. package/build/conductor/epic-reconcile.js +7 -1
  6. package/build/conductor/epic-runtime.js +5 -0
  7. package/build/conductor-bundle-artifacts.js +802 -0
  8. package/build/conductor-bundle-cli.js +256 -0
  9. package/build/connect-github-api.js +365 -0
  10. package/build/connect-github.js +415 -0
  11. package/build/decision-page-schema.js +34 -5
  12. package/build/decision-page-template.js +117 -35
  13. package/build/docs.generated.js +2 -1
  14. package/build/doctor.js +148 -1
  15. package/build/env-flags.js +31 -0
  16. package/build/index.js +3467 -498
  17. package/build/init.js +7 -3
  18. package/build/install-bridge.js +624 -38
  19. package/build/install-doctor.js +64 -0
  20. package/build/mcp-host-config.js +521 -0
  21. package/build/mcp-host-targets.js +194 -0
  22. package/build/mcp-install-state.js +175 -0
  23. package/build/pipelines.generated.js +127 -132
  24. package/build/readme.generated.js +1 -1
  25. package/build/start-tickets.js +166 -18
  26. package/build/tool-surface-gating.js +396 -0
  27. package/build/version.generated.js +1 -1
  28. package/docs/install/github-app.md +80 -17
  29. package/docs/install/mcp-tool-integrations.md +2 -2
  30. package/package.json +5 -5
  31. package/pipelines/learn-repository.json +111 -119
  32. package/public/css/main.min.css +258 -65
  33. package/public/css/main.min.css.map +1 -1
  34. package/public/js/main.min.js +188 -92
  35. package/public/js/main.min.js.map +1 -1
  36. package/smoke-test/SMOKE-TEST.md +4 -4
@@ -0,0 +1,802 @@
1
+ /**
2
+ * BAPI-634: deterministic helpers for the `emit-conductor-bundle` standalone
3
+ * instruction — the post-ticket-creation, pre-approval step that harmonizes an
4
+ * epic's sibling tickets and emits the artifacts the conductor needs.
5
+ *
6
+ * The orchestration (reading siblings, reconciling contradictions, rendering
7
+ * descriptions) lives in `instructions/emit-conductor-bundle.md`. Only the parts
8
+ * that must NOT depend on agent judgment live here:
9
+ *
10
+ * - entry validation of identities, the node→ticket mapping, and paths;
11
+ * - touched-file normalization;
12
+ * - placeholder→real-key sidecar finalization;
13
+ * - the Python-compatible no-op comparison and the 16,000-char bound;
14
+ * - allowlisted manifest/report serialization and atomic JSON writes.
15
+ *
16
+ * Every export is pure or takes an injected filesystem seam — no network, no
17
+ * subprocess, no MCP server construction.
18
+ */
19
+ import path from "node:path";
20
+ /** Jira key grammar (mirrors the `/implement-ticket` command's pattern). */
21
+ const JIRA_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
22
+ /**
23
+ * Sanitized epic-slug grammar. Deliberately identical to the `ticket_key`
24
+ * grammar enforced by `generate_decision_page` (`decision-page-schema.ts`),
25
+ * because `emit-conductor-bundle.md` passes `{epic_slug}` as that tool's
26
+ * `ticket_key`. A slug this rejects could never render its decision page.
27
+ */
28
+ const EPIC_SLUG_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
29
+ /** Placeholder node identity written by `decompose-epic.md` before tickets exist. */
30
+ const PLACEHOLDER_RE = /TBD-\d+/;
31
+ /**
32
+ * The conductor's hard spec bound. `_resolve_spec_review_ticket_spec()` truncates
33
+ * silently at this many characters (`api/library/epic_conductor/reconciler.py`),
34
+ * so a description over it loses content with no error. We fail loud instead.
35
+ */
36
+ export const TICKET_SPEC_MAX_CHARS = 16000;
37
+ /** Schema version stamped into every emitted manifest. */
38
+ export const SIBLING_TICKET_MANIFEST_SCHEMA_VERSION = 1;
39
+ /** Schema version stamped into every emitted harmonization report. */
40
+ export const HARMONIZATION_REPORT_SCHEMA_VERSION = 1;
41
+ /** The seven reconciliation classes from the ticket's taxonomy. */
42
+ export const HARMONIZATION_FINDING_CLASSES = [
43
+ "interface_drift",
44
+ "ownership_collision",
45
+ "orphan_consumer",
46
+ "order_violation",
47
+ "scope_overlap",
48
+ "vocabulary_drift",
49
+ "nfr_conflict",
50
+ ];
51
+ const fail = (error) => ({ ok: false, error });
52
+ const succeed = (value) => ({ ok: true, value });
53
+ // ===========================================================================
54
+ // Touched-file normalization
55
+ // ===========================================================================
56
+ /**
57
+ * Reject, don't drop. `normalizeDeclaredTouchedFiles` in `conductor/file-scope-guard.ts`
58
+ * silently drops unusable entries because a warn-only guard must never fail; here
59
+ * the opposite is required. An entry we cannot normalize means ownership is
60
+ * *unknown*, and the ticket is explicit that unknown must never be encoded as an
61
+ * empty array — an empty `touched_files` silently disables overlap protection.
62
+ *
63
+ * Concrete files only. `apply_file_overlap_serialization`
64
+ * (`api/library/db/epic_runs.py`) intersects normalized path *strings* with
65
+ * `set(a) & set(b)`, so a directory or glob entry never matches a real path — it
66
+ * would look like a declaration while protecting nothing.
67
+ */
68
+ export function normalizeTouchedFiles(input) {
69
+ if (!Array.isArray(input)) {
70
+ return fail("touched_files must be an array of repository-relative paths.");
71
+ }
72
+ const out = new Set();
73
+ for (const item of input) {
74
+ const normalized = normalizeTouchedFileEntry(item);
75
+ if (!normalized.ok)
76
+ return normalized;
77
+ out.add(normalized.value);
78
+ }
79
+ return succeed(Array.from(out).sort());
80
+ }
81
+ /** Normalize one entry, or explain precisely why it is unusable. */
82
+ function normalizeTouchedFileEntry(item) {
83
+ if (typeof item !== "string") {
84
+ return fail(`touched_files entries must be strings, got ${JSON.stringify(item)}.`);
85
+ }
86
+ const trimmed = item.trim();
87
+ if (trimmed.length === 0) {
88
+ return fail("touched_files entries must not be blank.");
89
+ }
90
+ // Prose is the common LLM failure here ("the routes module"). A real path has
91
+ // no whitespace; catching it explicitly beats emitting a path that can never
92
+ // intersect anything.
93
+ if (/\s/.test(trimmed)) {
94
+ return fail(`touched_files entry ${JSON.stringify(item)} contains whitespace — ` +
95
+ "declare a concrete repository-relative file path, not prose.");
96
+ }
97
+ // Check absolute forms BEFORE separator normalization so both styles are caught.
98
+ if (trimmed.startsWith("/") ||
99
+ trimmed.startsWith("\\") ||
100
+ /^[A-Za-z]:[\\/]/.test(trimmed)) {
101
+ return fail(`touched_files entry ${JSON.stringify(item)} must be repository-relative, not absolute.`);
102
+ }
103
+ if (trimmed.startsWith("./")) {
104
+ return fail(`touched_files entry ${JSON.stringify(item)} must not start with './' — ` +
105
+ "the backend does not normalize it away before comparing paths.");
106
+ }
107
+ const posix = trimmed.replace(/\\/g, "/");
108
+ const segments = posix.split("/");
109
+ if (segments.some((s) => s === "..")) {
110
+ return fail(`touched_files entry ${JSON.stringify(item)} must not contain '..' traversal.`);
111
+ }
112
+ if (posix.endsWith("/")) {
113
+ return fail(`touched_files entry ${JSON.stringify(item)} looks like a directory. ` +
114
+ "The backend intersects path strings exactly, so a directory protects nothing — " +
115
+ "declare each concrete file.");
116
+ }
117
+ if (posix.includes("*") || posix.includes("?")) {
118
+ return fail(`touched_files entry ${JSON.stringify(item)} looks like a glob. ` +
119
+ "The backend intersects path strings exactly, so a glob never matches — " +
120
+ "declare each concrete file.");
121
+ }
122
+ // Worker worktrees are cut per dispatch; a path through one is never a
123
+ // repo-relative declaration and would never intersect a sibling's.
124
+ if (/(^|\/)\.worktrees?(\/|$)/.test(posix) || posix.startsWith("tmp/")) {
125
+ return fail(`touched_files entry ${JSON.stringify(item)} points into a temporary worktree.`);
126
+ }
127
+ const cleaned = segments.filter((s) => s !== "" && s !== ".").join("/");
128
+ if (cleaned.length === 0) {
129
+ return fail(`touched_files entry ${JSON.stringify(item)} is not a usable path.`);
130
+ }
131
+ return succeed(cleaned);
132
+ }
133
+ /**
134
+ * Validate identities, mapping cardinality, manifest agreement, and path
135
+ * containment, returning canonical resolved paths. Fails before any mapped file
136
+ * is read and before any mutation.
137
+ *
138
+ * Node identity is taken ONLY from `plan_node_id` matching a sidecar node. It is
139
+ * never inferred from mapping order, ticket titles, descriptions, or a Jira
140
+ * search — inferring it risks attaching one ticket's dependencies to another.
141
+ */
142
+ export async function validateConductorBundleInputs(args, fs) {
143
+ const { epic_key, epic_slug, docs_dir, mappings } = args;
144
+ if (typeof epic_key !== "string" || !JIRA_KEY_RE.test(epic_key)) {
145
+ return fail(`epic_key must match ${JIRA_KEY_RE.source}, got ${JSON.stringify(epic_key)}.`);
146
+ }
147
+ if (typeof epic_slug !== "string" || !EPIC_SLUG_RE.test(epic_slug)) {
148
+ return fail(`epic_slug must match ${EPIC_SLUG_RE.source}, got ${JSON.stringify(epic_slug)}.`);
149
+ }
150
+ if (!Array.isArray(mappings) || mappings.length === 0) {
151
+ return fail("mappings must be a non-empty array of node→ticket entries.");
152
+ }
153
+ const seenNodes = new Set();
154
+ const seenKeys = new Set();
155
+ for (const entry of mappings) {
156
+ if (!entry || typeof entry !== "object") {
157
+ return fail("Every mapping entry must be an object.");
158
+ }
159
+ const { plan_node_id, ticket_key } = entry;
160
+ if (typeof plan_node_id !== "string" || plan_node_id.trim().length === 0) {
161
+ return fail("Every mapping entry needs a non-empty plan_node_id.");
162
+ }
163
+ if (typeof ticket_key !== "string" || !JIRA_KEY_RE.test(ticket_key)) {
164
+ return fail(`Mapping for ${plan_node_id} has an invalid ticket_key ${JSON.stringify(ticket_key)}.`);
165
+ }
166
+ if (ticket_key === epic_key) {
167
+ return fail(`Mapping for ${plan_node_id} uses the epic key ${epic_key} as a child ticket.`);
168
+ }
169
+ if (seenNodes.has(plan_node_id)) {
170
+ return fail(`Duplicate plan_node_id ${plan_node_id} in mappings.`);
171
+ }
172
+ if (seenKeys.has(ticket_key)) {
173
+ return fail(`Duplicate ticket_key ${ticket_key} in mappings.`);
174
+ }
175
+ seenNodes.add(plan_node_id);
176
+ seenKeys.add(ticket_key);
177
+ }
178
+ const sidecarNodes = readSidecarNodeKeys(args.sidecar);
179
+ if (!sidecarNodes.ok)
180
+ return fail(sidecarNodes.error);
181
+ const planVersion = readSidecarPlanVersion(args.sidecar);
182
+ if (!planVersion.ok)
183
+ return fail(planVersion.error);
184
+ const coverage = checkMappingCoversSidecar(mappings, sidecarNodes.value);
185
+ if (!coverage.ok)
186
+ return fail(coverage.error);
187
+ if (args.existing_manifest !== undefined && args.existing_manifest !== null) {
188
+ const agreement = checkManifestAgreement(args.existing_manifest, {
189
+ epic_key,
190
+ epic_slug,
191
+ plan_version: planVersion.value,
192
+ decomposition_fingerprint: args.decomposition_fingerprint,
193
+ mappings,
194
+ });
195
+ if (!agreement.ok)
196
+ return fail(agreement.error);
197
+ }
198
+ const epicDir = path.resolve(docs_dir, "epic-plans", epic_slug);
199
+ const resolvedEpicDir = await canonicalize(epicDir, fs);
200
+ if (!resolvedEpicDir.ok)
201
+ return fail(resolvedEpicDir.error);
202
+ const resolvedMappings = [];
203
+ for (const entry of mappings) {
204
+ const exploration = await resolveInsideEpicDir(entry.exploration_path, resolvedEpicDir.value, fs, `${entry.plan_node_id} exploration_path`);
205
+ if (!exploration.ok)
206
+ return fail(exploration.error);
207
+ const draft = await resolveInsideEpicDir(entry.draft_path, resolvedEpicDir.value, fs, `${entry.plan_node_id} draft_path`);
208
+ if (!draft.ok)
209
+ return fail(draft.error);
210
+ resolvedMappings.push({
211
+ ...entry,
212
+ resolved_exploration_path: exploration.value,
213
+ resolved_draft_path: draft.value,
214
+ });
215
+ }
216
+ return succeed({
217
+ epic_key,
218
+ epic_slug,
219
+ epic_dir: resolvedEpicDir.value,
220
+ goals_path: path.join(resolvedEpicDir.value, "goals-and-nfrs.md"),
221
+ epic_plan_path: path.join(resolvedEpicDir.value, "epic-plan.md"),
222
+ sidecar_path: path.join(resolvedEpicDir.value, "epic-plan.dag.json"),
223
+ manifest_path: path.join(resolvedEpicDir.value, "sibling-ticket-manifest.json"),
224
+ report_path: path.join(resolvedEpicDir.value, "harmonization-report.json"),
225
+ plan_version: planVersion.value,
226
+ mappings: resolvedMappings,
227
+ });
228
+ }
229
+ /** Read the sidecar's node identities in order. */
230
+ function readSidecarNodeKeys(sidecar) {
231
+ if (!sidecar || typeof sidecar !== "object") {
232
+ return fail("epic-plan.dag.json must parse to an object.");
233
+ }
234
+ const nodes = sidecar.nodes;
235
+ if (!Array.isArray(nodes) || nodes.length === 0) {
236
+ return fail("epic-plan.dag.json must have a non-empty nodes array.");
237
+ }
238
+ const keys = [];
239
+ for (const node of nodes) {
240
+ if (!node || typeof node !== "object") {
241
+ return fail("Every epic-plan.dag.json node must be an object.");
242
+ }
243
+ const key = node.ticket_key;
244
+ if (typeof key !== "string" || key.trim().length === 0) {
245
+ return fail("Every epic-plan.dag.json node needs a non-empty ticket_key.");
246
+ }
247
+ keys.push(key.trim());
248
+ }
249
+ const unique = new Set(keys);
250
+ if (unique.size !== keys.length) {
251
+ return fail("epic-plan.dag.json has duplicate node ticket_key values.");
252
+ }
253
+ return succeed(keys);
254
+ }
255
+ /** Read and range-check `plan_version`. */
256
+ function readSidecarPlanVersion(sidecar) {
257
+ const version = sidecar.plan_version;
258
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
259
+ return fail(`epic-plan.dag.json plan_version must be an integer >= 1, got ${JSON.stringify(version)}.`);
260
+ }
261
+ return succeed(version);
262
+ }
263
+ /**
264
+ * Require a complete one-to-one mapping. Each supplied `plan_node_id` must match
265
+ * exactly one sidecar node — either a placeholder on the initial run, or its
266
+ * already-mapped `ticket_key` on an idempotent rerun.
267
+ */
268
+ function checkMappingCoversSidecar(mappings, nodeKeys) {
269
+ const nodeSet = new Set(nodeKeys);
270
+ for (const entry of mappings) {
271
+ const matchesPlaceholder = nodeSet.has(entry.plan_node_id);
272
+ const matchesFinalized = nodeSet.has(entry.ticket_key);
273
+ if (!matchesPlaceholder && !matchesFinalized) {
274
+ return fail(`Mapping plan_node_id ${entry.plan_node_id} matches no node in epic-plan.dag.json.`);
275
+ }
276
+ }
277
+ const covered = new Set();
278
+ for (const entry of mappings) {
279
+ if (nodeSet.has(entry.plan_node_id))
280
+ covered.add(entry.plan_node_id);
281
+ else
282
+ covered.add(entry.ticket_key);
283
+ }
284
+ const uncovered = nodeKeys.filter((k) => !covered.has(k));
285
+ if (uncovered.length > 0) {
286
+ return fail(`epic-plan.dag.json node(s) ${uncovered.join(", ")} have no mapping entry. ` +
287
+ "Every node must be mapped before any mutation.");
288
+ }
289
+ return succeed(true);
290
+ }
291
+ /**
292
+ * A rerun must agree with the manifest on every identity field. Neither source
293
+ * silently wins — disagreement halts, because the manifest may belong to another
294
+ * epic or another decomposition.
295
+ */
296
+ function checkManifestAgreement(manifest, expected) {
297
+ if (!manifest || typeof manifest !== "object") {
298
+ return fail("sibling-ticket-manifest.json must parse to an object.");
299
+ }
300
+ const m = manifest;
301
+ if (m.epic_key !== expected.epic_key) {
302
+ return fail(`Manifest epic_key ${JSON.stringify(m.epic_key)} disagrees with the supplied ` +
303
+ `${expected.epic_key}. Refusing to reuse another epic's mapping.`);
304
+ }
305
+ if (m.epic_slug !== expected.epic_slug) {
306
+ return fail(`Manifest epic_slug ${JSON.stringify(m.epic_slug)} disagrees with the supplied ` +
307
+ `${expected.epic_slug}.`);
308
+ }
309
+ if (m.plan_version !== expected.plan_version) {
310
+ return fail(`Manifest plan_version ${JSON.stringify(m.plan_version)} disagrees with the ` +
311
+ `sidecar's ${expected.plan_version}.`);
312
+ }
313
+ if (m.decomposition_fingerprint !== expected.decomposition_fingerprint) {
314
+ return fail("Manifest decomposition_fingerprint disagrees with this decomposition. " +
315
+ "Refusing to reuse another decomposition's mapping.");
316
+ }
317
+ if (!Array.isArray(m.mappings)) {
318
+ return fail("Manifest mappings must be an array.");
319
+ }
320
+ const recorded = new Map();
321
+ for (const entry of m.mappings) {
322
+ if (!entry || typeof entry !== "object") {
323
+ return fail("Every manifest mapping entry must be an object.");
324
+ }
325
+ recorded.set(entry.plan_node_id, entry.ticket_key);
326
+ }
327
+ for (const entry of expected.mappings) {
328
+ const known = recorded.get(entry.plan_node_id);
329
+ if (known !== undefined && known !== entry.ticket_key) {
330
+ return fail(`Manifest maps ${entry.plan_node_id} to ${known}, but this invocation maps it ` +
331
+ `to ${entry.ticket_key}. Halting rather than preferring either source.`);
332
+ }
333
+ }
334
+ return succeed(true);
335
+ }
336
+ /** Canonicalize a path, surfacing a missing target as a validation failure. */
337
+ async function canonicalize(target, fs) {
338
+ try {
339
+ return succeed(await fs.realpath(target));
340
+ }
341
+ catch (err) {
342
+ return fail(`Cannot resolve ${target}: ${err instanceof Error ? err.message : String(err)}`);
343
+ }
344
+ }
345
+ /**
346
+ * Resolve a mapped path and prove it stays inside the epic directory. Rejects
347
+ * absolute paths and traversal syntactically, then re-checks containment against
348
+ * the CANONICAL path so a symlink cannot escape.
349
+ */
350
+ async function resolveInsideEpicDir(candidate, epicDir, fs, label) {
351
+ if (typeof candidate !== "string" || candidate.trim().length === 0) {
352
+ return fail(`${label} must be a non-empty path.`);
353
+ }
354
+ const raw = candidate.trim();
355
+ if (path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)) {
356
+ return fail(`${label} must be relative to the epic directory, not absolute.`);
357
+ }
358
+ if (raw.replace(/\\/g, "/").split("/").some((s) => s === "..")) {
359
+ return fail(`${label} must not contain '..' traversal.`);
360
+ }
361
+ const joined = path.resolve(epicDir, raw);
362
+ const canonical = await canonicalize(joined, fs);
363
+ if (!canonical.ok) {
364
+ return fail(`${label} is missing or unreadable: ${canonical.error}`);
365
+ }
366
+ if (!isInside(canonical.value, epicDir)) {
367
+ return fail(`${label} resolves outside the epic directory (symlink escape): ${canonical.value}`);
368
+ }
369
+ return succeed(canonical.value);
370
+ }
371
+ /** True when `target` is `dir` itself or lies beneath it. */
372
+ function isInside(target, dir) {
373
+ if (target === dir)
374
+ return true;
375
+ return target.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
376
+ }
377
+ /**
378
+ * Apply one validated node→key map across `nodes[].ticket_key`,
379
+ * `nodes[].depends_on`, and `edges[].from`/`to`, attach per-node `touched_files`,
380
+ * and reject anything that would make the plan un-runnable. Mutates nothing else
381
+ * and never touches the input object.
382
+ *
383
+ * `plan_version` is preserved, never incremented: these changes finalize version
384
+ * 1 *before* it is stored.
385
+ */
386
+ export function finalizeEpicPlanSidecar(args) {
387
+ const { sidecar, node_key_map, touched_files_by_key } = args;
388
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
389
+ return fail("epic-plan.dag.json must parse to an object.");
390
+ }
391
+ if (args.plan_version_already_stored) {
392
+ return fail("This plan_version is already stored. Plan blobs are immutable and " +
393
+ "post-approval description rewrites are forbidden, so finalizing it now " +
394
+ "would invalidate the approved hash. This needs an explicit re-plan.");
395
+ }
396
+ const plan = structuredClone(sidecar);
397
+ const versionCheck = readSidecarPlanVersion(plan);
398
+ if (!versionCheck.ok)
399
+ return fail(versionCheck.error);
400
+ const nodes = plan.nodes;
401
+ if (!Array.isArray(nodes) || nodes.length === 0) {
402
+ return fail("epic-plan.dag.json must have a non-empty nodes array.");
403
+ }
404
+ const edges = plan.edges;
405
+ if (!Array.isArray(edges)) {
406
+ return fail("epic-plan.dag.json edges must be an array (use [] for none).");
407
+ }
408
+ // `base_lineage` affects the plan hash but has zero consumers in either
409
+ // conductor. Halt rather than silently deleting it — a sidecar carrying it was
410
+ // built by something we do not understand, and dropping it would hide that.
411
+ for (const node of nodes) {
412
+ if ("base_lineage" in node) {
413
+ return fail(`Node ${String(node.ticket_key)} declares base_lineage. It has no consumer ` +
414
+ "and changes the plan hash for no behavioral gain; refusing to emit or " +
415
+ "silently remove it.");
416
+ }
417
+ }
418
+ const resolve = (value) => node_key_map[value] ?? value;
419
+ for (const node of nodes) {
420
+ const originalKey = String(node.ticket_key);
421
+ const realKey = resolve(originalKey);
422
+ node.ticket_key = realKey;
423
+ const dependsOn = node.depends_on;
424
+ if (dependsOn !== undefined && !Array.isArray(dependsOn)) {
425
+ return fail(`Node ${realKey} depends_on must be an array.`);
426
+ }
427
+ node.depends_on = (Array.isArray(dependsOn) ? dependsOn : []).map((d) => typeof d === "string" ? resolve(d) : d);
428
+ const touched = touched_files_by_key[realKey];
429
+ if (touched === undefined) {
430
+ return fail(`Node ${realKey} has no touched_files entry. Ownership uncertainty must be ` +
431
+ "escalated, never encoded as an empty array — an empty array silently " +
432
+ "disables file-overlap protection.");
433
+ }
434
+ const normalized = normalizeTouchedFiles(touched);
435
+ if (!normalized.ok)
436
+ return fail(`Node ${realKey}: ${normalized.error}`);
437
+ node.touched_files = normalized.value;
438
+ }
439
+ for (const edge of edges) {
440
+ if (!edge || typeof edge !== "object") {
441
+ return fail("Every plan edge must be an object.");
442
+ }
443
+ if (typeof edge.from === "string")
444
+ edge.from = resolve(edge.from);
445
+ if (typeof edge.to === "string")
446
+ edge.to = resolve(edge.to);
447
+ }
448
+ const residual = findResidualPlaceholder(plan);
449
+ if (residual) {
450
+ return fail(`Residual placeholder ${residual} survives finalization. Every TBD- reference ` +
451
+ "must resolve to a real key before the plan is stored.");
452
+ }
453
+ return validateFinalizedGraph(plan, nodes, edges);
454
+ }
455
+ /** Find any surviving `TBD-N` anywhere in the finalized plan. */
456
+ function findResidualPlaceholder(plan) {
457
+ const match = PLACEHOLDER_RE.exec(JSON.stringify(plan));
458
+ return match ? match[0] : null;
459
+ }
460
+ /**
461
+ * Prove the finalized graph is runnable: unique keys, known references, no
462
+ * self-dependency, no cycle, and ordinary `edges` that encode the same graph as
463
+ * the canonical `depends_on`.
464
+ */
465
+ function validateFinalizedGraph(plan, nodes, edges) {
466
+ const keys = nodes.map((n) => String(n.ticket_key));
467
+ const keySet = new Set(keys);
468
+ if (keySet.size !== keys.length) {
469
+ return fail("Finalized plan has duplicate ticket_key values.");
470
+ }
471
+ // `depends_on` is canonical; an edge's `from` IS the predecessor, matching
472
+ // `validateEpicPlanSidecar` in setup-epic.ts and the server's builder.
473
+ const canonicalEdges = new Set();
474
+ for (const node of nodes) {
475
+ const key = String(node.ticket_key);
476
+ for (const dep of node.depends_on) {
477
+ if (typeof dep !== "string" || !keySet.has(dep)) {
478
+ return fail(`Node ${key} depends_on unknown ticket ${JSON.stringify(dep)}.`);
479
+ }
480
+ if (dep === key) {
481
+ return fail(`Node ${key} depends on itself.`);
482
+ }
483
+ canonicalEdges.add(`${dep} ${key}`);
484
+ }
485
+ }
486
+ for (const edge of edges) {
487
+ const from = edge.from;
488
+ const to = edge.to;
489
+ if (typeof from !== "string" || !keySet.has(from)) {
490
+ return fail(`Edge from ${JSON.stringify(from)} references an unknown ticket.`);
491
+ }
492
+ if (typeof to !== "string" || !keySet.has(to)) {
493
+ return fail(`Edge to ${JSON.stringify(to)} references an unknown ticket.`);
494
+ }
495
+ // A typed overlap edge is metadata, not a semantic dependency — it carries a
496
+ // `kind` and is preserved verbatim rather than checked against depends_on.
497
+ if (typeof edge.kind === "string" && edge.kind.length > 0)
498
+ continue;
499
+ if (!canonicalEdges.has(`${from} ${to}`)) {
500
+ return fail(`Edge ${from} -> ${to} contradicts the canonical depends_on graph. ` +
501
+ "depends_on is authoritative; ordinary edges must encode the same graph.");
502
+ }
503
+ }
504
+ const cycle = findCycle(keys, canonicalEdges);
505
+ if (cycle) {
506
+ return fail(`Finalized plan has a cycle: ${cycle}.`);
507
+ }
508
+ return succeed(plan);
509
+ }
510
+ /** Iterative DFS cycle detector over `predecessor\0successor` edge keys. */
511
+ function findCycle(keys, edgeKeys) {
512
+ const adjacency = new Map();
513
+ for (const key of keys)
514
+ adjacency.set(key, []);
515
+ for (const edgeKey of edgeKeys) {
516
+ const [from, to] = edgeKey.split(" ");
517
+ adjacency.get(from).push(to);
518
+ }
519
+ const WHITE = 0;
520
+ const GREY = 1;
521
+ const BLACK = 2;
522
+ const color = new Map(keys.map((k) => [k, WHITE]));
523
+ const stack = [];
524
+ const visit = (start) => {
525
+ const frames = [
526
+ { node: start, index: 0 },
527
+ ];
528
+ color.set(start, GREY);
529
+ stack.push(start);
530
+ while (frames.length > 0) {
531
+ const frame = frames[frames.length - 1];
532
+ const neighbors = adjacency.get(frame.node) ?? [];
533
+ if (frame.index >= neighbors.length) {
534
+ color.set(frame.node, BLACK);
535
+ stack.pop();
536
+ frames.pop();
537
+ continue;
538
+ }
539
+ const next = neighbors[frame.index++];
540
+ const state = color.get(next);
541
+ if (state === GREY) {
542
+ const from = stack.indexOf(next);
543
+ return [...stack.slice(from), next].join(" -> ");
544
+ }
545
+ if (state === WHITE) {
546
+ color.set(next, GREY);
547
+ stack.push(next);
548
+ frames.push({ node: next, index: 0 });
549
+ }
550
+ }
551
+ return null;
552
+ };
553
+ for (const key of keys) {
554
+ if (color.get(key) !== WHITE)
555
+ continue;
556
+ const cycle = visit(key);
557
+ if (cycle)
558
+ return cycle;
559
+ }
560
+ return null;
561
+ }
562
+ // ===========================================================================
563
+ // Description comparison and bounds
564
+ // ===========================================================================
565
+ /**
566
+ * Python's `str.split()` whitespace set (`Py_UNICODE_ISSPACE`). Spelled out
567
+ * explicitly because JS `\s` is NOT the same set: `\s` omits `\x1c`-`\x1f` and
568
+ * `\x85` while including ``. A Jira description pasted from a rich-text
569
+ * editor routinely carries NBSP (`\xa0`), so an approximate class here would
570
+ * produce a different normalization than the server and rewrite tickets that
571
+ * did not change.
572
+ */
573
+ const PY_WHITESPACE = /[\t\n\v\f\r \x1c-\x1f\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/g;
574
+ /**
575
+ * Mirror `normalize_ticket_spec()` (`api/library/epic_conductor/spec_hash.py`)
576
+ * so a cosmetic diff never triggers a Jira rewrite.
577
+ *
578
+ * Because whitespace collapses, a pure reflow/indent edit is deliberately NOT a
579
+ * material change — the server treats it the same way, and diverging here would
580
+ * false-freeze tickets via `spec_stale`.
581
+ */
582
+ export function normalizeTicketSpecForComparison(spec) {
583
+ let text;
584
+ if (spec === null || spec === undefined) {
585
+ text = "";
586
+ }
587
+ else if (typeof spec === "string") {
588
+ text = spec;
589
+ }
590
+ else {
591
+ try {
592
+ text = canonicalJsonStringify(spec);
593
+ }
594
+ catch {
595
+ text = String(spec);
596
+ }
597
+ }
598
+ return text.replace(PY_WHITESPACE, " ").trim();
599
+ }
600
+ /**
601
+ * `json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)`.
602
+ * JSON.stringify does not sort keys, so sort recursively.
603
+ *
604
+ * Caveat: Python renders a float `1.0` as `1.0` where JS renders `1`. Ticket
605
+ * descriptions are strings, so this path is defensive only; a numeric spec would
606
+ * need explicit float handling to stay hash-compatible.
607
+ */
608
+ function canonicalJsonStringify(value) {
609
+ const sortDeep = (input) => {
610
+ if (Array.isArray(input))
611
+ return input.map(sortDeep);
612
+ if (input && typeof input === "object") {
613
+ const sorted = {};
614
+ for (const key of Object.keys(input).sort()) {
615
+ sorted[key] = sortDeep(input[key]);
616
+ }
617
+ return sorted;
618
+ }
619
+ return input;
620
+ };
621
+ return JSON.stringify(sortDeep(value));
622
+ }
623
+ /** Count Unicode code points — Python's `len()`, not JS's UTF-16 `.length`. */
624
+ export function countSpecCharacters(text) {
625
+ let count = 0;
626
+ for (const _ of text)
627
+ count++;
628
+ return count;
629
+ }
630
+ /**
631
+ * Reject a description the conductor would silently truncate, or one still
632
+ * carrying an unresolved placeholder.
633
+ *
634
+ * The bound counts code points because the server's limit is a Python `len()`.
635
+ * Using JS `.length` would count an astral character (e.g. an emoji) twice and
636
+ * reject a description that actually fits.
637
+ */
638
+ export function validateRenderedTicketDescription(description) {
639
+ if (typeof description !== "string") {
640
+ return fail("A rendered ticket description must be a string.");
641
+ }
642
+ const placeholder = PLACEHOLDER_RE.exec(description);
643
+ if (placeholder) {
644
+ return fail(`Rendered description still references ${placeholder[0]}. Resolve every ` +
645
+ "placeholder to a real key before emitting — a worker sees only this text.");
646
+ }
647
+ const length = countSpecCharacters(description);
648
+ if (length > TICKET_SPEC_MAX_CHARS) {
649
+ return fail(`Rendered description is ${length} characters, over the conductor's ` +
650
+ `${TICKET_SPEC_MAX_CHARS}-character bound. The reconciler truncates silently ` +
651
+ "at this bound, so emitting it would drop required content. Compact the " +
652
+ "description; if the required content still will not fit, escalate this as " +
653
+ "a decomposition/scope finding.");
654
+ }
655
+ return succeed(description);
656
+ }
657
+ // ===========================================================================
658
+ // Sanitization and serialization
659
+ // ===========================================================================
660
+ /** Values that look like a credential regardless of their key. */
661
+ const SECRET_VALUE_PATTERNS = [
662
+ /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi,
663
+ /\bghp_[A-Za-z0-9]{20,}/g,
664
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
665
+ /\bsk-[A-Za-z0-9]{20,}/g,
666
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
667
+ /\bAKIA[0-9A-Z]{16}\b/g,
668
+ ];
669
+ /** Redaction marker used in place of removed secret-like content. */
670
+ export const REDACTED = "[REDACTED]";
671
+ /** Replace credential-shaped substrings in free text. */
672
+ function sanitizeText(input) {
673
+ let out = input;
674
+ for (const pattern of SECRET_VALUE_PATTERNS) {
675
+ out = out.replace(pattern, REDACTED);
676
+ }
677
+ return out;
678
+ }
679
+ /** Truncate to a bound, marking the cut so a reader knows content was dropped. */
680
+ function bound(input, max) {
681
+ const sanitized = sanitizeText(input);
682
+ if (countSpecCharacters(sanitized) <= max)
683
+ return sanitized;
684
+ return Array.from(sanitized).slice(0, max).join("") + "…[truncated]";
685
+ }
686
+ /** Field bounds for report findings — keeps one run's report reviewable. */
687
+ const FINDING_SUMMARY_MAX = 600;
688
+ const FINDING_RESOLUTION_MAX = 600;
689
+ const FINDING_SEAM_MAX = 200;
690
+ const FINDING_EVIDENCE_MAX = 200;
691
+ /**
692
+ * Serialize a manifest through a strict allowlist. Anything not named here — a
693
+ * description, an exploration body, a credential, a raw Jira response — cannot
694
+ * survive into the recovery artifact.
695
+ */
696
+ export function serializeSiblingTicketManifest(candidate) {
697
+ return {
698
+ schema_version: SIBLING_TICKET_MANIFEST_SCHEMA_VERSION,
699
+ epic_key: candidate.epic_key,
700
+ epic_slug: candidate.epic_slug,
701
+ plan_version: candidate.plan_version,
702
+ decomposition_fingerprint: candidate.decomposition_fingerprint,
703
+ finalized_fingerprint: candidate.finalized_fingerprint ?? null,
704
+ run_phase: candidate.run_phase,
705
+ mappings: (candidate.mappings ?? []).map((m) => ({
706
+ plan_node_id: m.plan_node_id,
707
+ ticket_key: m.ticket_key,
708
+ exploration_path: m.exploration_path,
709
+ draft_path: m.draft_path,
710
+ })),
711
+ decisions: (candidate.decisions ?? []).map((d) => ({
712
+ finding_id: d.finding_id,
713
+ chosen_option: bound(d.chosen_option, FINDING_SUMMARY_MAX),
714
+ rationale: bound(d.rationale, FINDING_RESOLUTION_MAX),
715
+ })),
716
+ completed_mutations: (candidate.completed_mutations ?? []).map((c) => ({
717
+ kind: c.kind,
718
+ ticket_key: c.ticket_key,
719
+ detail: bound(c.detail, FINDING_SUMMARY_MAX),
720
+ })),
721
+ };
722
+ }
723
+ /**
724
+ * Serialize a report through a strict allowlist with bounded fields and
725
+ * secret-value scrubbing. A finding quotes conflicting evidence, so this is the
726
+ * one artifact most likely to carry ticket prose — bound every field and never
727
+ * admit a full description or raw response.
728
+ */
729
+ export function serializeHarmonizationReport(candidate) {
730
+ return {
731
+ schema_version: HARMONIZATION_REPORT_SCHEMA_VERSION,
732
+ harmonization_run_id: candidate.harmonization_run_id,
733
+ epic_key: candidate.epic_key,
734
+ epic_slug: candidate.epic_slug,
735
+ plan_version: candidate.plan_version,
736
+ findings: (candidate.findings ?? []).map((f) => ({
737
+ id: f.id,
738
+ finding_class: f.finding_class,
739
+ affected_ticket_keys: [...(f.affected_ticket_keys ?? [])],
740
+ affected_seam: bound(f.affected_seam ?? "", FINDING_SEAM_MAX),
741
+ affected_files: [...(f.affected_files ?? [])],
742
+ conflict_summary: bound(f.conflict_summary ?? "", FINDING_SUMMARY_MAX),
743
+ evidence_refs: (f.evidence_refs ?? []).map((e) => bound(e, FINDING_EVIDENCE_MAX)),
744
+ resolution_status: f.resolution_status,
745
+ resolution: bound(f.resolution ?? "", FINDING_RESOLUTION_MAX),
746
+ })),
747
+ };
748
+ }
749
+ /**
750
+ * Build a stable finding id from normalized class, affected nodes, and seam or
751
+ * file identity, so the same contradiction keeps its id across runs and a
752
+ * resumed decision still applies.
753
+ */
754
+ export function buildFindingId(findingClass, affectedTicketKeys, seamIdentity) {
755
+ const keys = [...affectedTicketKeys].sort().join("+");
756
+ const seam = seamIdentity
757
+ .trim()
758
+ .toLowerCase()
759
+ .replace(/[^a-z0-9]+/g, "-")
760
+ .replace(/^-+|-+$/g, "");
761
+ return `${findingClass}:${keys}:${seam}`;
762
+ }
763
+ // ===========================================================================
764
+ // Atomic writes
765
+ // ===========================================================================
766
+ /**
767
+ * Write JSON by temp-file-then-rename so an interrupted run cannot leave a
768
+ * partial manifest, report, or sidecar. The temp file goes in the DESTINATION
769
+ * directory because rename is only atomic within a filesystem.
770
+ *
771
+ * On failure the previous destination is left untouched and the temp file is
772
+ * cleaned up; the original error is surfaced rather than the cleanup's.
773
+ */
774
+ export async function writeJsonAtomically(destination, value, fs) {
775
+ const dir = path.dirname(destination);
776
+ const tempPath = path.join(dir, `.${path.basename(destination)}.tmp`);
777
+ const serialized = JSON.stringify(value, null, 2) + "\n";
778
+ try {
779
+ await fs.writeFile(tempPath, serialized);
780
+ }
781
+ catch (err) {
782
+ await cleanupQuietly(tempPath, fs);
783
+ return fail(`Could not write ${tempPath}: ${err instanceof Error ? err.message : String(err)}`);
784
+ }
785
+ try {
786
+ await fs.rename(tempPath, destination);
787
+ }
788
+ catch (err) {
789
+ await cleanupQuietly(tempPath, fs);
790
+ return fail(`Could not replace ${destination}: ${err instanceof Error ? err.message : String(err)}`);
791
+ }
792
+ return succeed(destination);
793
+ }
794
+ /** Best-effort temp cleanup — never masks the original failure. */
795
+ async function cleanupQuietly(target, fs) {
796
+ try {
797
+ await fs.unlink(target);
798
+ }
799
+ catch {
800
+ // The temp file may never have been created; nothing to report.
801
+ }
802
+ }