@bridge_gpt/mcp-server 0.1.16 → 0.2.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 (50) hide show
  1. package/README.md +333 -162
  2. package/build/agent-capabilities/cli.js +152 -0
  3. package/build/agent-capabilities/default-deps.js +45 -0
  4. package/build/agent-capabilities/probe-context.js +111 -0
  5. package/build/agent-capabilities/probes.js +278 -0
  6. package/build/agent-capabilities/reporter.js +50 -0
  7. package/build/agent-capabilities/runner.js +56 -0
  8. package/build/agent-capabilities/types.js +10 -0
  9. package/build/agent-launchers/claude.js +85 -0
  10. package/build/agent-launchers/index.js +17 -0
  11. package/build/agent-launchers/types.js +1 -0
  12. package/build/agents.generated.js +1 -1
  13. package/build/brainstorm-files.js +89 -0
  14. package/build/bridge-config.js +404 -0
  15. package/build/chain-orchestrator.js +1364 -0
  16. package/build/chain-utils.js +68 -0
  17. package/build/commands.generated.js +5 -3
  18. package/build/credential-materialization.js +128 -0
  19. package/build/credential-store.js +232 -0
  20. package/build/decision-page-schema.js +39 -6
  21. package/build/decision-page-template.js +54 -18
  22. package/build/doctor.js +18 -2
  23. package/build/fetch-stub.js +139 -0
  24. package/build/git-ignore-utils.js +63 -0
  25. package/build/index.js +1623 -546
  26. package/build/mcp-invoke.js +417 -0
  27. package/build/mcp-provisioning.js +249 -0
  28. package/build/mcp-registration-doctor.js +96 -0
  29. package/build/pipeline-orchestrator.js +66 -1
  30. package/build/pipeline-utils.js +33 -0
  31. package/build/pipelines.generated.js +165 -5
  32. package/build/schedule-run.js +951 -0
  33. package/build/schedule-store.js +132 -0
  34. package/build/scheduler-backends/at-fallback.js +144 -0
  35. package/build/scheduler-backends/escaping.js +113 -0
  36. package/build/scheduler-backends/index.js +72 -0
  37. package/build/scheduler-backends/launchd.js +216 -0
  38. package/build/scheduler-backends/systemd-user.js +237 -0
  39. package/build/scheduler-backends/task-scheduler.js +219 -0
  40. package/build/scheduler-backends/types.js +23 -0
  41. package/build/start-tickets-prereqs.js +90 -1
  42. package/build/start-tickets.js +222 -70
  43. package/build/third-party-mcp-targets.js +75 -0
  44. package/build/version.generated.js +1 -1
  45. package/package.json +8 -8
  46. package/pipelines/full-automation.json +49 -0
  47. package/pipelines/idea-to-ticket.json +71 -0
  48. package/pipelines/implement-ticket.json +28 -2
  49. package/smoke-test/SMOKE-TEST.md +511 -0
  50. package/smoke-test/smoke-test-mcp.md +23 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Resolver for Tier-1 bridge-api credentials.
3
+ *
4
+ * Resolution order is env-first, then a user-scoped credentials file:
5
+ * 1. `BAPI_API_KEY` in the process environment (overrides everything).
6
+ * 2. `$XDG_CONFIG_HOME/bridge/credentials.json` (or `~/.config/bridge/credentials.json`).
7
+ * 3. `~/.bridge/credentials.json` (only when the primary path is absent).
8
+ *
9
+ * The file is keyed by a logical target `bapi:<repoName>`. This module NEVER
10
+ * creates or initializes credential files, and NEVER places secret values in
11
+ * thrown errors, returned error strings, or stderr warnings.
12
+ */
13
+ import path from "path";
14
+ // ---------------------------------------------------------------------------
15
+ // Path resolution
16
+ // ---------------------------------------------------------------------------
17
+ /** Primary store path: honors a non-empty `XDG_CONFIG_HOME`, else `~/.config`. */
18
+ export function getPrimaryCredentialStorePath(deps) {
19
+ const xdg = deps.env.XDG_CONFIG_HOME;
20
+ if (xdg && xdg.trim().length > 0) {
21
+ return path.join(xdg, "bridge", "credentials.json");
22
+ }
23
+ return path.join(deps.homedir(), ".config", "bridge", "credentials.json");
24
+ }
25
+ /** Fallback store path: `~/.bridge/credentials.json`. */
26
+ export function getFallbackCredentialStorePath(deps) {
27
+ return path.join(deps.homedir(), ".bridge", "credentials.json");
28
+ }
29
+ /**
30
+ * Choose which store path to read. The primary path is preferred and used
31
+ * whenever it exists. The fallback is only consulted when the primary is
32
+ * *absent* (`ENOENT`); a primary that exists but is unreadable does NOT fall
33
+ * back — the caller surfaces the read error instead.
34
+ */
35
+ export async function resolveCredentialStorePath(deps) {
36
+ const primaryPath = getPrimaryCredentialStorePath(deps);
37
+ const fallbackPath = getFallbackCredentialStorePath(deps);
38
+ try {
39
+ await deps.stat(primaryPath);
40
+ return { found: true, path: primaryPath, isPrimary: true };
41
+ }
42
+ catch (err) {
43
+ const code = err && typeof err === "object" ? err.code : undefined;
44
+ if (code !== "ENOENT") {
45
+ // The primary exists but is not statable cleanly — do NOT fall back; let
46
+ // the subsequent read surface the real error against the primary path.
47
+ return { found: true, path: primaryPath, isPrimary: true };
48
+ }
49
+ }
50
+ try {
51
+ await deps.stat(fallbackPath);
52
+ return { found: true, path: fallbackPath, isPrimary: false };
53
+ }
54
+ catch {
55
+ return { found: false, primaryPath, fallbackPath };
56
+ }
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Permission warning
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * On POSIX platforms, warn (but continue) when the credentials file is
63
+ * group/world accessible. Skipped entirely on Windows. The warning mentions
64
+ * `0600` but never any secret value.
65
+ */
66
+ export function warnIfInsecureCredentialFileMode(statResult, platform, filePath, stderr) {
67
+ if (platform === "win32")
68
+ return;
69
+ if ((statResult.mode & 0o077) !== 0) {
70
+ const write = stderr ?? ((m) => process.stderr.write(`${m}\n`));
71
+ write(`Warning: credentials file ${filePath} is group/world-accessible; ` +
72
+ `it should be mode 0600 (run: chmod 600 ${filePath}).`);
73
+ }
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // JSON parsing / validation
77
+ // ---------------------------------------------------------------------------
78
+ /**
79
+ * Parse and structurally validate the credentials JSON. The root must be an
80
+ * object whose values are objects of secret-name -> string. Secret *values* are
81
+ * never echoed into error messages (only logical keys and secret names appear).
82
+ */
83
+ export function parseCredentialStoreJson(text) {
84
+ let data;
85
+ try {
86
+ data = JSON.parse(text);
87
+ }
88
+ catch {
89
+ return { ok: false, error: "credentials file is not valid JSON" };
90
+ }
91
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
92
+ return { ok: false, error: "credentials file must be a JSON object" };
93
+ }
94
+ for (const [key, value] of Object.entries(data)) {
95
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
96
+ return {
97
+ ok: false,
98
+ error: `credentials entry "${key}" must be an object of secret names to strings`,
99
+ };
100
+ }
101
+ for (const [secretName, secretValue] of Object.entries(value)) {
102
+ if (typeof secretValue !== "string") {
103
+ return {
104
+ ok: false,
105
+ error: `credentials entry "${key}" has a non-string value for "${secretName}"`,
106
+ };
107
+ }
108
+ }
109
+ }
110
+ return { ok: true, value: data };
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Resolution
114
+ // ---------------------------------------------------------------------------
115
+ /** Collect trimmed, non-empty env values for the requested keys. */
116
+ function collectEnvValues(env, requiredKeys) {
117
+ const values = {};
118
+ const missing = [];
119
+ for (const key of requiredKeys) {
120
+ const raw = (env[key] ?? "").trim();
121
+ if (raw.length > 0) {
122
+ values[key] = raw;
123
+ }
124
+ else {
125
+ missing.push(key);
126
+ }
127
+ }
128
+ return { values, missing };
129
+ }
130
+ /**
131
+ * Resolve a logical credential bundle env-first, then from the user-scoped
132
+ * credentials file. `bundleKey` is the logical store key (e.g. `bapi:<repo>` or
133
+ * `sfcc:<sandbox-id>`); `requiredKeys` is the exact set of secret names that
134
+ * must all resolve.
135
+ *
136
+ * Behavior:
137
+ * - Environment is consulted first. If EVERY required key is present in the
138
+ * environment (trimmed, non-empty), the store is NEVER read and `source` is
139
+ * `"env"`.
140
+ * - Only when one or more keys are missing from the environment is the store
141
+ * read (exactly once). Env values override store values key-by-key.
142
+ * - The result fails unless every required key resolves.
143
+ *
144
+ * Error messages reference only the logical bundle key, the missing secret
145
+ * names, and the expected store path — never a secret value. Never creates
146
+ * files.
147
+ */
148
+ export async function resolveCredentialBundle(bundleKey, requiredKeys, deps) {
149
+ const primaryPath = getPrimaryCredentialStorePath(deps);
150
+ const fromEnv = collectEnvValues(deps.env, requiredKeys);
151
+ if (fromEnv.missing.length === 0) {
152
+ return { ok: true, values: fromEnv.values, source: "env" };
153
+ }
154
+ const resolution = await resolveCredentialStorePath(deps);
155
+ if (!resolution.found) {
156
+ return {
157
+ ok: false,
158
+ kind: "not-found",
159
+ error: `No credentials found for "${bundleKey}". Set ${fromEnv.missing.join(", ")} in the ` +
160
+ `environment, or add them under "${bundleKey}" in ${primaryPath}.`,
161
+ };
162
+ }
163
+ // Permission warning is best-effort and must not block resolution.
164
+ try {
165
+ const statResult = await deps.stat(resolution.path);
166
+ warnIfInsecureCredentialFileMode(statResult, deps.platform, resolution.path, deps.stderr);
167
+ }
168
+ catch {
169
+ /* stat failure here is non-fatal; the read below surfaces real errors */
170
+ }
171
+ let raw;
172
+ try {
173
+ raw = await deps.readFile(resolution.path);
174
+ }
175
+ catch {
176
+ return {
177
+ ok: false,
178
+ kind: "read-error",
179
+ error: `Unable to read credentials file at ${resolution.path}.`,
180
+ };
181
+ }
182
+ const parsed = parseCredentialStoreJson(raw);
183
+ if (!parsed.ok) {
184
+ return {
185
+ ok: false,
186
+ kind: "parse-error",
187
+ error: `Invalid credentials file at ${resolution.path}: ${parsed.error}.`,
188
+ };
189
+ }
190
+ const entry = parsed.value[bundleKey] ?? {};
191
+ const values = { ...fromEnv.values };
192
+ const stillMissing = [];
193
+ for (const key of fromEnv.missing) {
194
+ // Trim store values exactly like the env path (collectEnvValues), so a
195
+ // credential pasted into credentials.json with a trailing newline/space is
196
+ // never sent verbatim in an auth header. Assign the TRIMMED value.
197
+ const storeValue = typeof entry[key] === "string" ? entry[key].trim() : "";
198
+ if (storeValue.length > 0) {
199
+ values[key] = storeValue;
200
+ }
201
+ else {
202
+ stillMissing.push(key);
203
+ }
204
+ }
205
+ if (stillMissing.length > 0) {
206
+ return {
207
+ ok: false,
208
+ kind: "missing-key",
209
+ error: `No usable value(s) for ${stillMissing.join(", ")} in "${bundleKey}". Add them under ` +
210
+ `"${bundleKey}" in ${primaryPath}, or set them in the environment.`,
211
+ };
212
+ }
213
+ return { ok: true, values, source: "file" };
214
+ }
215
+ /**
216
+ * Resolve the `BAPI_API_KEY` for `bapi:<repoName>`. Delegates to the generic
217
+ * {@link resolveCredentialBundle} with bundle key `bapi:${repoName}` and the
218
+ * single required key `BAPI_API_KEY`, preserving the historical public return
219
+ * shape (`{ apiKey, source }`) relied on by `mcp-invoke` and `index.ts`. Env
220
+ * wins (trimmed, non-empty); otherwise the credentials file is read. Never
221
+ * leaks secret values; never creates files.
222
+ */
223
+ export async function resolveBapiCredentials(repoName, deps) {
224
+ const result = await resolveCredentialBundle(`bapi:${repoName}`, ["BAPI_API_KEY"], deps);
225
+ if (!result.ok) {
226
+ return { ok: false, kind: result.kind, error: result.error };
227
+ }
228
+ return {
229
+ ok: true,
230
+ credentials: { apiKey: result.values.BAPI_API_KEY, source: result.source },
231
+ };
232
+ }
@@ -19,8 +19,8 @@ export const ActionableItemSchema = z
19
19
  question: z.string().min(1),
20
20
  original_question: z
21
21
  .string()
22
- .min(1)
23
- .describe("The clarifying question or critique point as originally raised; soft cap ~30 words."),
22
+ .optional()
23
+ .describe("Optional display-only field: the clarifying question or critique point as originally raised; soft cap ~30 words. Omit it (or pass an empty string) for non-review callers — the renderer omits the section when it is absent or blank."),
24
24
  why_it_matters: z
25
25
  .string()
26
26
  .min(1)
@@ -31,12 +31,12 @@ export const ActionableItemSchema = z
31
31
  .describe("Why the recommended branch is the best choice; soft cap ~60 words."),
32
32
  codebase_evidence: z
33
33
  .string()
34
- .min(1)
35
- .describe("Combined Assessment paragraph and Codebase Evidence bullet list. Rendered as escaped plain text inside a closed-by-default <details> block."),
34
+ .optional()
35
+ .describe("Optional display-only field: combined Assessment paragraph and Codebase Evidence bullet list. Rendered as escaped plain text inside a closed-by-default <details> block, which is omitted when this field is absent or blank."),
36
36
  source: z
37
37
  .string()
38
- .min(1)
39
- .describe("Source reference from the combined review-and-resolution doc, e.g. 'Clarifying Q3 (prior round, weak concurrence)'"),
38
+ .optional()
39
+ .describe("Optional source reference from the combined review-and-resolution doc, e.g. 'Clarifying Q3 (prior round, weak concurrence)'. When absent the rendered card emits data-source=\"\"."),
40
40
  recommendation_index: z
41
41
  .number()
42
42
  .int()
@@ -69,12 +69,45 @@ export const ActionableItemSchema = z
69
69
  });
70
70
  }
71
71
  });
72
+ // Optional presentation labels that override the page's review-flavored copy.
73
+ // Every field is optional and may be an explicitly empty string (no .min(1)) so
74
+ // a caller can intentionally render an empty heading/title segment. The renderer
75
+ // merges these with raw default constants using nullish coalescing, so an
76
+ // explicit "" is preserved rather than falling back to a default.
77
+ export const DecisionPageLabelsSchema = z.object({
78
+ title: z
79
+ .string()
80
+ .optional()
81
+ .describe('Overrides the page <title>/<h1> lead text (default "Review Decisions").'),
82
+ intro: z
83
+ .string()
84
+ .optional()
85
+ .describe("Overrides the actionable-page intro copy shown when there are decisions."),
86
+ section_heading: z
87
+ .string()
88
+ .optional()
89
+ .describe('Overrides the decision cards <h2> (default "Review Decisions").'),
90
+ improvements_heading: z
91
+ .string()
92
+ .optional()
93
+ .describe('Overrides the confirmed-improvements <h2> (default "Confirmed Improvements").'),
94
+ });
72
95
  // Raw input shape for the `generate_decision_page` tool registration.
73
96
  // MCP's registerTool expects a shape object (which the SDK wraps in z.object),
74
97
  // not a pre-built z.object. Exporting the shape lets index.ts and the schema
75
98
  // share a single source of truth for the input contract.
76
99
  export const DecisionPageInputShape = {
77
100
  ticket_key: z.string().describe("Jira ticket key, e.g. BAPI-123"),
101
+ output_subdir: z
102
+ .string()
103
+ .optional()
104
+ .default("review")
105
+ .describe('Optional docs-relative subdirectory to write the page under (default "review"). Validated strictly: no absolute paths, backslashes, ".." segments, null bytes, or encoded path tokens.'),
106
+ output_filename: z
107
+ .string()
108
+ .optional()
109
+ .describe('Optional output filename (default "${ticket_key}-decisions.html"). Must end with .html and contain no path separators; the .html suffix is required and never auto-appended.'),
110
+ labels: DecisionPageLabelsSchema.optional().describe("Optional presentation-label overrides (title, intro, section_heading, improvements_heading). Presentation-only; does not change data-testid hooks or the submitted JSON shape."),
78
111
  actionable_items: z
79
112
  .array(ActionableItemSchema)
80
113
  .optional()
@@ -31,6 +31,30 @@ const COPY_SUCCESS_LABEL = "Copied!";
31
31
  const COPY_FALLBACK_PROMPT_LABEL = "Auto-copy unavailable. Press Ctrl+C / Cmd+C to copy.";
32
32
  const PAGE_INTRO_ASK_COPY = "Have a question about an item? Choose 'Ask about this' for that card; we'll discuss before the changes are made.";
33
33
  const PAGE_INTRO_NO_DECISIONS = "All suggestions were confirmed as improvements. No decisions are needed from you.";
34
+ // Raw (unescaped) default label constants. They reproduce the legacy review copy
35
+ // exactly so omitting `labels` yields byte-identical output. Escaping happens at
36
+ // each render site, so these must stay raw to avoid double-escaping.
37
+ const DEFAULT_LABEL_TITLE = "Review Decisions";
38
+ const DEFAULT_LABEL_INTRO = PAGE_INTRO_ASK_COPY;
39
+ const DEFAULT_LABEL_SECTION_HEADING = "Review Decisions";
40
+ const DEFAULT_LABEL_IMPROVEMENTS_HEADING = "Confirmed Improvements";
41
+ /**
42
+ * Merge caller-supplied labels with the raw default constants using nullish
43
+ * coalescing (`??`), NOT logical OR — an explicit empty-string label is an
44
+ * intentional override and must be preserved rather than replaced by a default.
45
+ */
46
+ function resolveDecisionPageLabels(labels) {
47
+ return {
48
+ title: labels?.title ?? DEFAULT_LABEL_TITLE,
49
+ intro: labels?.intro ?? DEFAULT_LABEL_INTRO,
50
+ section_heading: labels?.section_heading ?? DEFAULT_LABEL_SECTION_HEADING,
51
+ improvements_heading: labels?.improvements_heading ?? DEFAULT_LABEL_IMPROVEMENTS_HEADING,
52
+ };
53
+ }
54
+ /** True only when a string is present and not whitespace-only. */
55
+ function hasDisplayText(value) {
56
+ return typeof value === "string" && value.trim().length > 0;
57
+ }
34
58
  // ---------------------------------------------------------------------------
35
59
  // Template
36
60
  // ---------------------------------------------------------------------------
@@ -38,17 +62,21 @@ const DEFAULT_ASSETS = { faviconBase64: "", logoBase64: "", fontsRelPath: "" };
38
62
  export function generateDecisionPageHtml(data, assets = DEFAULT_ASSETS) {
39
63
  const { ticket_key, actionable_items, clear_improvements } = data;
40
64
  const hasDecisions = actionable_items.length > 0;
65
+ const effectiveLabels = resolveDecisionPageLabels(data.labels);
41
66
  const faviconLink = assets.faviconBase64
42
67
  ? `<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,${assets.faviconBase64}">`
43
68
  : "";
44
69
  const fontFaces = assets.fontsRelPath ? renderFontFaces(assets.fontsRelPath) : "";
45
- const pageIntro = hasDecisions ? PAGE_INTRO_ASK_COPY : PAGE_INTRO_NO_DECISIONS;
70
+ // The has-decisions intro is an overridable label; the no-decisions copy is a
71
+ // fixed constant because the MCP handler returns before rendering in that path
72
+ // (this branch is only reachable from direct renderer callers/tests).
73
+ const pageIntro = hasDecisions ? effectiveLabels.intro : PAGE_INTRO_NO_DECISIONS;
46
74
  return `<!DOCTYPE html>
47
75
  <html lang="en">
48
76
  <head>
49
77
  <meta charset="UTF-8">
50
78
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
51
- <title>Review Decisions: ${escapeHtml(ticket_key)}</title>
79
+ <title>${escapeHtml(effectiveLabels.title)}: ${escapeHtml(ticket_key)}</title>
52
80
  ${faviconLink}
53
81
  <style>
54
82
  ${fontFaces}
@@ -407,12 +435,12 @@ ${fontFaces}
407
435
  ${renderHeader(assets)}
408
436
  <main class="main-content">
409
437
  <div class="container">
410
- <h1>Review Decisions: ${escapeHtml(ticket_key)}</h1>
438
+ <h1>${escapeHtml(effectiveLabels.title)}: ${escapeHtml(ticket_key)}</h1>
411
439
  <p class="page-intro">${escapeHtml(pageIntro)}</p>
412
440
 
413
- ${hasDecisions ? renderForm(data) : renderNoDecisions()}
441
+ ${hasDecisions ? renderForm(data, effectiveLabels) : renderNoDecisions()}
414
442
 
415
- ${renderImprovements(clear_improvements)}
443
+ ${renderImprovements(clear_improvements, effectiveLabels)}
416
444
 
417
445
  </div>
418
446
  </main>
@@ -466,13 +494,13 @@ function renderNoDecisions() {
466
494
  <p>No decisions needed. All suggestions were confirmed as improvements.</p>
467
495
  </div>`;
468
496
  }
469
- function renderForm(data) {
497
+ function renderForm(data, labels) {
470
498
  const { actionable_items } = data;
471
499
  let html = ` <div id="form-container">
472
500
  <form id="decision-form">`;
473
501
  if (actionable_items.length > 0) {
474
502
  html += `
475
- <h2>Review Decisions</h2>`;
503
+ <h2>${escapeHtml(labels.section_heading)}</h2>`;
476
504
  for (const item of actionable_items) {
477
505
  html += renderDecisionItem(item);
478
506
  }
@@ -547,13 +575,25 @@ function renderDecisionItem(item) {
547
575
  </div>`;
548
576
  // Encode option labels for client-side JS to resolve chosen_label
549
577
  const labelsJson = escapeHtml(JSON.stringify(item.options));
550
- return `
551
- <section class="card" data-item-id="${id}" data-item-type="decision" data-source="${escapeHtml(item.source)}" data-labels="${labelsJson}" data-testid="decision-card" aria-labelledby="${titleId}">
552
- <h3 class="card-title" id="${titleId}" data-testid="decision-card-title">${escapeHtml(item.question)}</h3>
578
+ // Optional review-flavored display sections: omit entirely when the field is
579
+ // missing, empty, or whitespace-only so non-review callers can skip them.
580
+ const originalQuestionSection = hasDisplayText(item.original_question)
581
+ ? `
553
582
  <section class="card-section" data-testid="original-question-section" aria-labelledby="${originalQuestionLabelId}">
554
583
  <div class="card-section-label" id="${originalQuestionLabelId}">Original question</div>
555
584
  <div class="card-section-body" data-testid="original-question-body">${escapeHtml(item.original_question)}</div>
556
- </section>
585
+ </section>`
586
+ : "";
587
+ const codebaseEvidenceSection = hasDisplayText(item.codebase_evidence)
588
+ ? `
589
+ <details class="codebase-evidence" data-testid="codebase-evidence">
590
+ <summary>Codebase evidence</summary>
591
+ <div class="codebase-evidence-body" data-testid="codebase-evidence-body">${escapeHtml(item.codebase_evidence)}</div>
592
+ </details>`
593
+ : "";
594
+ return `
595
+ <section class="card" data-item-id="${id}" data-item-type="decision" data-source="${escapeHtml(item.source ?? "")}" data-labels="${labelsJson}" data-testid="decision-card" aria-labelledby="${titleId}">
596
+ <h3 class="card-title" id="${titleId}" data-testid="decision-card-title">${escapeHtml(item.question)}</h3>${originalQuestionSection}
557
597
  <section class="card-section" data-testid="why-it-matters-section" aria-labelledby="${whyItMattersLabelId}">
558
598
  <div class="card-section-label" id="${whyItMattersLabelId}">Why it matters</div>
559
599
  <div class="card-section-body" data-testid="why-it-matters-body">${escapeHtml(item.why_it_matters)}</div>
@@ -568,14 +608,10 @@ function renderDecisionItem(item) {
568
608
  <div class="comment-area">
569
609
  <label for="${textareaId}">Comment</label>
570
610
  <textarea id="${textareaId}" name="${textareaId}" placeholder="Required for None of these selections..." data-testid="decision-comment"></textarea>
571
- </div>
572
- <details class="codebase-evidence" data-testid="codebase-evidence">
573
- <summary>Codebase evidence</summary>
574
- <div class="codebase-evidence-body" data-testid="codebase-evidence-body">${escapeHtml(item.codebase_evidence)}</div>
575
- </details>
611
+ </div>${codebaseEvidenceSection}
576
612
  </section>`;
577
613
  }
578
- function renderImprovements(improvements) {
614
+ function renderImprovements(improvements, labels) {
579
615
  if (improvements.length === 0)
580
616
  return "";
581
617
  let items = "";
@@ -593,7 +629,7 @@ function renderImprovements(improvements) {
593
629
  </li>`;
594
630
  }
595
631
  return ` <section data-testid="confirmed-improvements">
596
- <h2>Confirmed Improvements</h2>
632
+ <h2>${escapeHtml(labels.improvements_heading)}</h2>
597
633
  <p>These improvements have been confirmed and will be applied. No action needed from you.</p>
598
634
  <ul class="improvements-list">${items}
599
635
  </ul>
package/build/doctor.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * spawning, no MCP server startup. It only ever runs read-only PATH probes
15
15
  * (`which`/`where`, `bash --version`, `git rev-parse`) through the injected deps.
16
16
  */
17
+ import { readFile, stat } from "fs/promises";
18
+ import os from "os";
17
19
  import { createDefaultStartTicketsDeps } from "./start-tickets.js";
18
20
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
19
21
  import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
@@ -32,7 +34,8 @@ export function getDoctorUsage() {
32
34
  " -h, --help Show this help",
33
35
  "",
34
36
  "Checks (for the current OS): the start-tickets preflight prerequisites plus",
35
- "uv plus the selected agent's command.",
37
+ "uv, the selected agent's command, Bridge API credential resolution, and",
38
+ "worktree MCP registration reachability.",
36
39
  "",
37
40
  "Exit code: 0 when all required prerequisites are present, non-zero otherwise.",
38
41
  ].join("\n");
@@ -100,9 +103,22 @@ export async function collectDoctorResults(deps, agentName) {
100
103
  if (!descriptorsResult.ok) {
101
104
  return { ok: false, unsupported: true, error: descriptorsResult.error };
102
105
  }
106
+ // Augment with read-only filesystem deps so the doctor-only credential and
107
+ // worktree-MCP-registration probes can run. These are intentionally NOT part
108
+ // of StartTicketsDeps (live preflight never reads files); doctor injects them
109
+ // here, and they remain strictly read-only (readFile/stat/homedir only). If a
110
+ // caller (e.g. a unit test) already supplied them on `deps`, honor those so
111
+ // the probes are deterministic.
112
+ const injected = deps;
113
+ const probeDeps = {
114
+ ...deps,
115
+ readFile: injected.readFile ?? ((p) => readFile(p, "utf-8")),
116
+ stat: injected.stat ?? ((p) => stat(p)),
117
+ homedir: injected.homedir ?? os.homedir,
118
+ };
103
119
  const results = [];
104
120
  for (const descriptor of descriptorsResult.descriptors) {
105
- results.push(await probePrerequisite(deps, descriptor));
121
+ results.push(await probePrerequisite(probeDeps, descriptor));
106
122
  }
107
123
  return { ok: true, results };
108
124
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Hand-rolled global ``fetch`` stub for the pipeline-orchestrator tests.
3
+ *
4
+ * Replaces the undici MockAgent/setGlobalDispatcher mocking that broke when
5
+ * Dependabot bumped undici past Node's bundled copy. Instead of intercepting
6
+ * Node's internal dispatcher, we swap ``globalThis.fetch`` itself: the stub
7
+ * matches registered routes by method + path (string-exact or RegExp) +
8
+ * optional query subset, records every call for assertions, and throws loudly
9
+ * on any unmatched request so nothing escapes to the real network (the
10
+ * ``agent.disableNetConnect()`` equivalent). It depends only on Node's global
11
+ * ``Response``/``Headers``/``URL`` — no package required, and no coupling to
12
+ * Node's bundled undici version.
13
+ *
14
+ * Two registration styles mirror undici's ``.intercept().reply(...)``:
15
+ * - ``replyOnce(spec, status, body, responseOptions?)`` — a one-shot route
16
+ * consumed FIFO; matches a single request, like an undici interceptor. Used
17
+ * by the persistence and execution tests (including tests that register two
18
+ * responses for the same method+path to be returned in order).
19
+ * - ``route(spec, handler)`` — a persistent route whose handler runs on every
20
+ * matching request. The handler receives the captured call and returns
21
+ * ``{ statusCode, data, responseOptions }`` — the same shape undici's
22
+ * ``reply((opts) => ...)`` callback returned — so stateful fakes port over
23
+ * almost verbatim.
24
+ */
25
+ export class FetchStub {
26
+ original;
27
+ routes = [];
28
+ /** Every request seen since the last ``reset()``, in order. */
29
+ calls = [];
30
+ /** Replace ``globalThis.fetch`` with the stub. */
31
+ install() {
32
+ this.original = globalThis.fetch;
33
+ globalThis.fetch = ((input, init) => this.handle(input, init));
34
+ }
35
+ /** Restore the original ``globalThis.fetch``. */
36
+ restore() {
37
+ if (this.original)
38
+ globalThis.fetch = this.original;
39
+ this.original = undefined;
40
+ }
41
+ /** Clear all registered routes and recorded calls (per-test reset). */
42
+ reset() {
43
+ this.routes = [];
44
+ this.calls = [];
45
+ }
46
+ /** Register a persistent route that matches every qualifying request. */
47
+ route(spec, handler) {
48
+ this.routes.push({ spec, handler, once: false, consumed: false });
49
+ return this;
50
+ }
51
+ /**
52
+ * Register a one-shot route consumed FIFO — mirrors a single undici
53
+ * ``.intercept(spec).reply(status, body, responseOptions)``. Object bodies
54
+ * are JSON-serialized (and default to ``content-type: application/json``
55
+ * unless the caller set one); string bodies pass through untouched.
56
+ */
57
+ replyOnce(spec, status, body, responseOptions) {
58
+ const isObjectBody = body !== undefined && body !== null && typeof body === "object";
59
+ const data = body === undefined || body === null
60
+ ? ""
61
+ : typeof body === "string"
62
+ ? body
63
+ : JSON.stringify(body);
64
+ const headers = { ...(responseOptions?.headers ?? {}) };
65
+ if (isObjectBody &&
66
+ !Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
67
+ headers["content-type"] = "application/json";
68
+ }
69
+ this.routes.push({
70
+ spec,
71
+ handler: () => ({ statusCode: status, data, responseOptions: { headers } }),
72
+ once: true,
73
+ consumed: false,
74
+ });
75
+ return this;
76
+ }
77
+ handle(input, init) {
78
+ const opts = (init ?? {});
79
+ const rawUrl = typeof input === "string"
80
+ ? input
81
+ : input instanceof URL
82
+ ? input.toString()
83
+ : String(input?.url ?? input);
84
+ const url = new URL(rawUrl);
85
+ const method = (opts.method ?? "GET").toUpperCase();
86
+ const headers = new Headers(opts.headers ?? {});
87
+ const body = opts.body === undefined || opts.body === null
88
+ ? undefined
89
+ : String(opts.body);
90
+ const call = {
91
+ url,
92
+ path: url.pathname + url.search,
93
+ method,
94
+ headers,
95
+ body,
96
+ json: () => JSON.parse(body ?? ""),
97
+ };
98
+ this.calls.push(call);
99
+ for (const r of this.routes) {
100
+ if (r.once && r.consumed)
101
+ continue;
102
+ if (!FetchStub.matches(r.spec, call))
103
+ continue;
104
+ if (r.once)
105
+ r.consumed = true;
106
+ const result = r.handler(call);
107
+ return Promise.resolve(new Response(result.data ?? "", {
108
+ status: result.statusCode,
109
+ headers: result.responseOptions?.headers ?? {},
110
+ }));
111
+ }
112
+ // No match: emulate disableNetConnect() — fail loudly, never hit the net.
113
+ const registered = this.routes
114
+ .map((r) => `${(r.spec.method ?? "GET").toUpperCase()} ${String(r.spec.path ?? "*")}`)
115
+ .join(", ");
116
+ throw new Error(`FetchStub: no route matched ${method} ${call.path} ` +
117
+ `(net connect is disabled). Registered routes: [${registered}]`);
118
+ }
119
+ static matches(spec, call) {
120
+ if ((spec.method ?? "GET").toUpperCase() !== call.method)
121
+ return false;
122
+ if (spec.path !== undefined) {
123
+ if (typeof spec.path === "string") {
124
+ if (spec.path !== call.url.pathname)
125
+ return false;
126
+ }
127
+ else if (!spec.path.test(call.url.pathname)) {
128
+ return false;
129
+ }
130
+ }
131
+ if (spec.query) {
132
+ for (const [k, v] of Object.entries(spec.query)) {
133
+ if (call.url.searchParams.get(k) !== v)
134
+ return false;
135
+ }
136
+ }
137
+ return true;
138
+ }
139
+ }