@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,237 @@
1
+ /**
2
+ * Linux systemd-user backend (BAPI-327, primary Linux scheduler).
3
+ *
4
+ * Generates a `.service` + `.timer` pair under `~/.config/systemd/user/` and
5
+ * installs them with `systemctl --user`. The timer fires once via
6
+ * `OnCalendar=<run_at_iso>`.
7
+ *
8
+ * Drift policy: `Persistent=false` gives a real OS-level no-catch-up guarantee —
9
+ * a run missed during downtime is dropped, not fired late on next boot/login.
10
+ * (launchd and `at` cannot guarantee this; the cross-backend staleness guard is
11
+ * the baked `--scheduled-at <T>` value checked by `/full-automation` in Phase C.)
12
+ * Working directory is set by the service's `WorkingDirectory=` directive, never
13
+ * via a shell `cd` in ExecStart and never a Claude `--cwd` flag.
14
+ */
15
+ import { promises as fs } from "node:fs";
16
+ import { pathApiForPlatform } from "./types.js";
17
+ import { bakedEnvEntries, systemdQuote } from "./escaping.js";
18
+ /** Return the unit names for a schedule id. */
19
+ export function systemdUnitNamesForId(id) {
20
+ return {
21
+ service: `bridge-gpt-full-automation-${id}.service`,
22
+ timer: `bridge-gpt-full-automation-${id}.timer`,
23
+ };
24
+ }
25
+ /** Return the unit file paths for a schedule id. */
26
+ export function systemdUnitPathsForId(id, homeDir) {
27
+ const pathApi = pathApiForPlatform("linux");
28
+ const dir = pathApi.join(homeDir, ".config", "systemd", "user");
29
+ const names = systemdUnitNamesForId(id);
30
+ return {
31
+ service: pathApi.join(dir, names.service),
32
+ timer: pathApi.join(dir, names.timer),
33
+ };
34
+ }
35
+ /** Render the systemd service unit. */
36
+ export function renderSystemdService(input) {
37
+ const env = bakedEnvEntries({
38
+ envPath: input.envPath,
39
+ nodePath: input.nodePath,
40
+ npxPath: input.npxPath,
41
+ claudePath: input.claudePath,
42
+ ideaFile: input.ideaFile,
43
+ repoPath: input.repoPath,
44
+ });
45
+ const envLines = env
46
+ .map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`)
47
+ .join("\n");
48
+ const execStart = [input.invocation.exe, ...input.invocation.args]
49
+ .map((part) => systemdQuote(part))
50
+ .join(" ");
51
+ return [
52
+ "[Unit]",
53
+ `Description=Bridge GPT full-automation one-shot run (${input.id})`,
54
+ "",
55
+ "[Service]",
56
+ "Type=oneshot",
57
+ envLines,
58
+ `WorkingDirectory=${systemdQuote(input.repoPath)}`,
59
+ `ExecStart=${execStart}`,
60
+ `StandardOutput=append:${input.paths.stdoutPath}`,
61
+ `StandardError=append:${input.paths.stderrPath}`,
62
+ "",
63
+ ].join("\n");
64
+ }
65
+ /**
66
+ * Render an `OnCalendar` value systemd actually accepts. systemd's calendar
67
+ * grammar is `YYYY-MM-DD HH:MM:SS` with an optional timezone token — it does NOT
68
+ * accept the ISO `T` separator, fractional seconds, or a bare `Z`, so emitting
69
+ * `run_at_iso` verbatim makes `systemd-analyze calendar` reject the unit and the
70
+ * timer silently never fires. Render from the UTC fields (host-timezone
71
+ * independent) and append the `UTC` token.
72
+ */
73
+ export function formatSystemdOnCalendar(runAtIso) {
74
+ const d = new Date(runAtIso);
75
+ const pad = (n) => String(n).padStart(2, "0");
76
+ const date = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
77
+ const time = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
78
+ return `${date} ${time} UTC`;
79
+ }
80
+ /** Render the systemd timer unit. */
81
+ export function renderSystemdTimer(input) {
82
+ const names = systemdUnitNamesForId(input.id);
83
+ return [
84
+ "[Unit]",
85
+ `Description=Bridge GPT full-automation one-shot timer (${input.id})`,
86
+ "",
87
+ "[Timer]",
88
+ `OnCalendar=${formatSystemdOnCalendar(input.runAtIso)}`,
89
+ // Drift policy: a missed run must NOT fire late on next boot.
90
+ "Persistent=false",
91
+ `Unit=${names.service}`,
92
+ "",
93
+ "[Install]",
94
+ "WantedBy=timers.target",
95
+ "",
96
+ ].join("\n");
97
+ }
98
+ /** Whether `systemctl --user` is usable (used by isAvailable). */
99
+ async function systemctlUserUsable(deps) {
100
+ const probe = await deps.runCommand("systemctl", ["--user", "--version"]);
101
+ return probe.exitCode === 0;
102
+ }
103
+ /** Create the Linux systemd-user backend. */
104
+ export function createSystemdUserBackend() {
105
+ return {
106
+ name: "systemd-user",
107
+ async isAvailable(deps) {
108
+ if (deps.platform !== "linux")
109
+ return false;
110
+ return systemctlUserUsable(deps);
111
+ },
112
+ async create(input) {
113
+ const paths = systemdUnitPathsForId(input.id, input.deps.homeDir);
114
+ const names = systemdUnitNamesForId(input.id);
115
+ const serviceContent = renderSystemdService(input);
116
+ const timerContent = renderSystemdTimer(input);
117
+ const artifacts = [
118
+ { path: paths.service, content: serviceContent, kind: "systemd-service" },
119
+ { path: paths.timer, content: timerContent, kind: "systemd-timer" },
120
+ ];
121
+ const unitPaths = [paths.service, paths.timer];
122
+ if (input.dryRun) {
123
+ return {
124
+ ok: true,
125
+ backend: "systemd-user",
126
+ unitPath: paths.timer,
127
+ unitPaths,
128
+ backendJobId: names.timer,
129
+ artifacts,
130
+ };
131
+ }
132
+ const pathApi = pathApiForPlatform("linux");
133
+ await fs.mkdir(pathApi.dirname(paths.service), { recursive: true });
134
+ await fs.writeFile(paths.service, serviceContent, "utf-8");
135
+ await fs.writeFile(paths.timer, timerContent, "utf-8");
136
+ // Remove the orphaned unit files if a systemctl step fails.
137
+ const cleanupUnitFiles = async () => {
138
+ await fs.unlink(paths.timer).catch(() => undefined);
139
+ await fs.unlink(paths.service).catch(() => undefined);
140
+ };
141
+ const reload = await input.deps.runCommand("systemctl", ["--user", "daemon-reload"]);
142
+ if (reload.exitCode !== 0) {
143
+ await cleanupUnitFiles();
144
+ return {
145
+ ok: false,
146
+ backend: "systemd-user",
147
+ unitPath: paths.timer,
148
+ unitPaths,
149
+ backendJobId: names.timer,
150
+ artifacts,
151
+ error: `systemctl --user daemon-reload failed: ${(reload.stderr || reload.stdout).trim()}`,
152
+ };
153
+ }
154
+ const enable = await input.deps.runCommand("systemctl", [
155
+ "--user",
156
+ "enable",
157
+ "--now",
158
+ names.timer,
159
+ ]);
160
+ if (enable.exitCode !== 0) {
161
+ await cleanupUnitFiles();
162
+ return {
163
+ ok: false,
164
+ backend: "systemd-user",
165
+ unitPath: paths.timer,
166
+ unitPaths,
167
+ backendJobId: names.timer,
168
+ artifacts,
169
+ error: `systemctl --user enable --now failed: ${(enable.stderr || enable.stdout).trim()}`,
170
+ };
171
+ }
172
+ return {
173
+ ok: true,
174
+ backend: "systemd-user",
175
+ unitPath: paths.timer,
176
+ unitPaths,
177
+ backendJobId: names.timer,
178
+ artifacts,
179
+ };
180
+ },
181
+ async list(input) {
182
+ const entries = [];
183
+ for (const metadata of input.recorded) {
184
+ const paths = systemdUnitPathsForId(metadata.id, input.deps.homeDir);
185
+ const names = systemdUnitNamesForId(metadata.id);
186
+ let timerExists = true;
187
+ try {
188
+ await fs.access(paths.timer);
189
+ }
190
+ catch {
191
+ timerExists = false;
192
+ }
193
+ if (!timerExists) {
194
+ entries.push({ metadata, status: "stale", detail: "timer unit file missing" });
195
+ continue;
196
+ }
197
+ const status = await input.deps.runCommand("systemctl", ["--user", "status", names.timer]);
198
+ if (status.exitCode === 0) {
199
+ entries.push({ metadata, status: "active", detail: "timer active" });
200
+ }
201
+ else if (status.exitCode === 3) {
202
+ // exit 3 = unit known but inactive; still registered, treat as stale.
203
+ entries.push({ metadata, status: "stale", detail: "timer inactive" });
204
+ }
205
+ else {
206
+ entries.push({ metadata, status: "backend-unavailable", detail: "systemctl unavailable" });
207
+ }
208
+ }
209
+ return entries;
210
+ },
211
+ async cancel(input) {
212
+ const paths = systemdUnitPathsForId(input.metadata.id, input.deps.homeDir);
213
+ const names = systemdUnitNamesForId(input.metadata.id);
214
+ const disable = await input.deps.runCommand("systemctl", [
215
+ "--user",
216
+ "disable",
217
+ "--now",
218
+ names.timer,
219
+ ]);
220
+ let removedAny = false;
221
+ for (const unitPath of [paths.timer, paths.service]) {
222
+ try {
223
+ await fs.unlink(unitPath);
224
+ removedAny = true;
225
+ }
226
+ catch (error) {
227
+ if (error.code !== "ENOENT") {
228
+ return { ok: false, nativeRemoved: false, stale: false, error: String(error) };
229
+ }
230
+ }
231
+ }
232
+ await input.deps.runCommand("systemctl", ["--user", "daemon-reload"]);
233
+ const nativeRemoved = disable.exitCode === 0 || removedAny;
234
+ return { ok: true, nativeRemoved, stale: disable.exitCode !== 0 };
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Windows Task Scheduler backend (BAPI-327).
3
+ *
4
+ * Generates a `.cmd` wrapper (baked PATH + env, `cd /D <repo>`, absolute Claude
5
+ * invocation, stdout/stderr redirection) plus a Task Scheduler `.xml` definition,
6
+ * and registers a one-shot task via `schtasks /Create /XML`.
7
+ *
8
+ * Why XML instead of `/SC ONCE /SD /ST`: the CLI `/SD` date is parsed in the
9
+ * host's short-date locale, so a hard-coded `MM/DD/YYYY` fails on UK/DE/JP/etc.
10
+ * machines. The XML `<StartBoundary>` is ISO-8601 and locale-independent.
11
+ *
12
+ * Drift policy: the generated XML omits the missed-start catch-up element (its
13
+ * schema default is already off), giving a real OS-level no-catch-up guarantee —
14
+ * a missed start never runs late. (launchd and `at` cannot guarantee this; the
15
+ * cross-backend staleness guard is the baked `--scheduled-at <T>` value checked by
16
+ * `/full-automation` in Phase C.) Working directory is handled only by `cd /D`
17
+ * inside the wrapper.
18
+ */
19
+ import { promises as fs } from "node:fs";
20
+ import { pathApiForPlatform } from "./types.js";
21
+ import { bakedEnvEntries, escapeWindowsCmdSetValue, windowsCmdQuote, xmlEscape, } from "./escaping.js";
22
+ /** Task Scheduler task name for a schedule id. */
23
+ export function taskNameForId(id) {
24
+ return `BridgeGPT-FullAutomation-${id}`;
25
+ }
26
+ /** `%USERPROFILE%\.bridge-gpt\schedules\<id>.cmd` wrapper path for a schedule id. */
27
+ export function cmdWrapperPathForId(id, homeDir) {
28
+ const pathApi = pathApiForPlatform("win32");
29
+ return pathApi.join(homeDir, ".bridge-gpt", "schedules", `${id}.cmd`);
30
+ }
31
+ /** `%USERPROFILE%\.bridge-gpt\schedules\<id>.xml` task definition path. */
32
+ export function taskXmlPathForId(id, homeDir) {
33
+ const pathApi = pathApiForPlatform("win32");
34
+ return pathApi.join(homeDir, ".bridge-gpt", "schedules", `${id}.xml`);
35
+ }
36
+ /**
37
+ * Local ISO-8601 `<StartBoundary>` value (`YYYY-MM-DDTHH:MM:SS`, no `Z`). Task
38
+ * Scheduler interprets a bare boundary as local time, preserving the prior
39
+ * local-time scheduling semantics while staying locale-independent.
40
+ */
41
+ export function formatTaskSchedulerBoundary(runAtIso) {
42
+ const d = new Date(runAtIso);
43
+ const pad = (n) => String(n).padStart(2, "0");
44
+ return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
45
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`);
46
+ }
47
+ /** Render the `.cmd` wrapper that the Task Scheduler task invokes. */
48
+ export function renderWindowsCmdWrapper(input) {
49
+ const env = bakedEnvEntries({
50
+ envPath: input.envPath,
51
+ nodePath: input.nodePath,
52
+ npxPath: input.npxPath,
53
+ claudePath: input.claudePath,
54
+ ideaFile: input.ideaFile,
55
+ repoPath: input.repoPath,
56
+ });
57
+ const lines = ["@echo off"];
58
+ for (const [key, value] of env) {
59
+ if (key === "PATH") {
60
+ // Prepend the baked PATH while preserving the runtime PATH behind it.
61
+ lines.push(`set "PATH=${escapeWindowsCmdSetValue(value)};%PATH%"`);
62
+ }
63
+ else {
64
+ lines.push(`set "${key}=${escapeWindowsCmdSetValue(value)}"`);
65
+ }
66
+ }
67
+ lines.push(`cd /D ${windowsCmdQuote(input.repoPath)}`);
68
+ const command = [input.invocation.exe, ...input.invocation.args]
69
+ .map((part) => windowsCmdQuote(part))
70
+ .join(" ");
71
+ // Append stdout/stderr to the local schedule logs.
72
+ lines.push(`${command} 1>>${windowsCmdQuote(input.paths.stdoutPath)} 2>>${windowsCmdQuote(input.paths.stderrPath)}`);
73
+ lines.push("");
74
+ return lines.join("\r\n");
75
+ }
76
+ /**
77
+ * Render a minimal one-shot Task Scheduler XML document. The single `TimeTrigger`
78
+ * fires once at the ISO-8601 `<StartBoundary>`; the action runs the generated
79
+ * `.cmd` wrapper. The `<Settings>` block intentionally does not enable the
80
+ * missed-start catch-up option, so a missed start never runs late.
81
+ */
82
+ export function renderTaskSchedulerXml(input) {
83
+ const wrapperPath = cmdWrapperPathForId(input.id, input.deps.homeDir);
84
+ const boundary = formatTaskSchedulerBoundary(input.runAtIso);
85
+ return [
86
+ '<?xml version="1.0" encoding="UTF-16"?>',
87
+ '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
88
+ " <RegistrationInfo>",
89
+ ` <Description>${xmlEscape(`Bridge GPT full-automation one-shot run (${input.id})`)}</Description>`,
90
+ " </RegistrationInfo>",
91
+ " <Triggers>",
92
+ " <TimeTrigger>",
93
+ ` <StartBoundary>${xmlEscape(boundary)}</StartBoundary>`,
94
+ " <Enabled>true</Enabled>",
95
+ " </TimeTrigger>",
96
+ " </Triggers>",
97
+ " <Principals>",
98
+ ' <Principal id="Author">',
99
+ " <LogonType>InteractiveToken</LogonType>",
100
+ " </Principal>",
101
+ " </Principals>",
102
+ " <Settings>",
103
+ " <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
104
+ " <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>",
105
+ " <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
106
+ " <Enabled>true</Enabled>",
107
+ " </Settings>",
108
+ ' <Actions Context="Author">',
109
+ " <Exec>",
110
+ ` <Command>${xmlEscape(wrapperPath)}</Command>`,
111
+ " </Exec>",
112
+ " </Actions>",
113
+ "</Task>",
114
+ "",
115
+ ].join("\r\n");
116
+ }
117
+ /** Whether `schtasks` is resolvable (used by isAvailable). */
118
+ async function schtasksResolvable(deps) {
119
+ const probe = await deps.runCommand("where.exe", ["schtasks"]);
120
+ return probe.exitCode === 0;
121
+ }
122
+ /** Create the Windows Task Scheduler backend. */
123
+ export function createTaskSchedulerBackend() {
124
+ return {
125
+ name: "task-scheduler",
126
+ async isAvailable(deps) {
127
+ if (deps.platform !== "win32")
128
+ return false;
129
+ return schtasksResolvable(deps);
130
+ },
131
+ async create(input) {
132
+ const wrapperPath = cmdWrapperPathForId(input.id, input.deps.homeDir);
133
+ const xmlPath = taskXmlPathForId(input.id, input.deps.homeDir);
134
+ const wrapperContent = renderWindowsCmdWrapper(input);
135
+ const xmlContent = renderTaskSchedulerXml(input);
136
+ const artifacts = [
137
+ { path: wrapperPath, content: wrapperContent, kind: "windows-cmd-wrapper" },
138
+ { path: xmlPath, content: xmlContent, kind: "task-scheduler-xml" },
139
+ ];
140
+ const taskName = taskNameForId(input.id);
141
+ const unitPaths = [wrapperPath, xmlPath];
142
+ if (input.dryRun) {
143
+ return {
144
+ ok: true,
145
+ backend: "task-scheduler",
146
+ unitPath: wrapperPath,
147
+ unitPaths,
148
+ backendJobId: taskName,
149
+ artifacts,
150
+ };
151
+ }
152
+ await fs.writeFile(wrapperPath, wrapperContent, "utf-8");
153
+ // schtasks /XML requires the definition file to be UTF-16 with a BOM.
154
+ await fs.writeFile(xmlPath, `${xmlContent}`, "utf16le");
155
+ const result = await input.deps.runCommand("schtasks", [
156
+ "/Create",
157
+ "/TN",
158
+ taskName,
159
+ "/XML",
160
+ xmlPath,
161
+ "/F",
162
+ ]);
163
+ if (result.exitCode !== 0) {
164
+ // Don't leave the orphaned wrapper + XML behind when registration fails.
165
+ await fs.unlink(wrapperPath).catch(() => undefined);
166
+ await fs.unlink(xmlPath).catch(() => undefined);
167
+ return {
168
+ ok: false,
169
+ backend: "task-scheduler",
170
+ unitPath: wrapperPath,
171
+ unitPaths,
172
+ backendJobId: taskName,
173
+ artifacts,
174
+ error: `schtasks /Create failed: ${(result.stderr || result.stdout).trim()}`,
175
+ };
176
+ }
177
+ return {
178
+ ok: true,
179
+ backend: "task-scheduler",
180
+ unitPath: wrapperPath,
181
+ unitPaths,
182
+ backendJobId: taskName,
183
+ artifacts,
184
+ };
185
+ },
186
+ async list(input) {
187
+ const entries = [];
188
+ for (const metadata of input.recorded) {
189
+ const taskName = metadata.backend_job_id ?? taskNameForId(metadata.id);
190
+ const query = await input.deps.runCommand("schtasks", ["/Query", "/TN", taskName]);
191
+ entries.push({
192
+ metadata,
193
+ status: query.exitCode === 0 ? "active" : "stale",
194
+ detail: query.exitCode === 0 ? "task registered" : "task not found",
195
+ });
196
+ }
197
+ return entries;
198
+ },
199
+ async cancel(input) {
200
+ const taskName = input.metadata.backend_job_id ?? taskNameForId(input.metadata.id);
201
+ const result = await input.deps.runCommand("schtasks", ["/Delete", "/TN", taskName, "/F"]);
202
+ // Remove the generated wrapper + XML definition; both are ENOENT-tolerant.
203
+ const wrapperPath = input.metadata.unit_path ?? cmdWrapperPathForId(input.metadata.id, input.deps.homeDir);
204
+ const xmlPath = taskXmlPathForId(input.metadata.id, input.deps.homeDir);
205
+ for (const artifactPath of [wrapperPath, xmlPath]) {
206
+ try {
207
+ await fs.unlink(artifactPath);
208
+ }
209
+ catch (error) {
210
+ if (error.code !== "ENOENT") {
211
+ return { ok: false, nativeRemoved: result.exitCode === 0, stale: false, error: String(error) };
212
+ }
213
+ }
214
+ }
215
+ const nativeRemoved = result.exitCode === 0;
216
+ return { ok: true, nativeRemoved, stale: !nativeRemoved };
217
+ },
218
+ };
219
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared contract for the cross-platform `schedule-run` scheduler backends
3
+ * (BAPI-327 / Phase B of the Full Automation v1 epic, BAPI-325).
4
+ *
5
+ * Every OS-native backend (launchd, Task Scheduler, systemd-user, at-fallback)
6
+ * implements the {@link SchedulerBackend} interface against an injected
7
+ * {@link SchedulerBackendDeps} so the create/list/cancel logic is fully unit
8
+ * testable without touching real schedulers, the filesystem, or the network.
9
+ *
10
+ * The design mirrors `start-tickets.ts`: a list-based {@link RunCommand} boundary
11
+ * (`execFile`, never `shell: true`), `platform`/`env`/`cwd`/`homeDir`/`execPath`
12
+ * captured as data, and an optional injectable clock for deterministic tests.
13
+ */
14
+ import path from "node:path";
15
+ /**
16
+ * Return the platform-appropriate Node path API: `path.win32` on Windows so
17
+ * generated unit paths use backslashes, `path.posix` everywhere else. Using a
18
+ * data-driven platform (not the ambient `process.platform`) keeps generation
19
+ * deterministic and unit-testable for every OS from any host.
20
+ */
21
+ export function pathApiForPlatform(platform) {
22
+ return platform === "win32" ? path.win32 : path.posix;
23
+ }
@@ -13,6 +13,9 @@
13
13
  * the runtime graph stays acyclic — `start-tickets.ts` imports values FROM here,
14
14
  * never the reverse.
15
15
  */
16
+ import { resolveBapiCredentials } from "./credential-store.js";
17
+ import { readBridgeConfig } from "./bridge-config.js";
18
+ import { probeWorktreeMcpRegistration } from "./mcp-registration-doctor.js";
16
19
  // ---------------------------------------------------------------------------
17
20
  // Constants (moved here from start-tickets.ts so both consumers share them)
18
21
  // ---------------------------------------------------------------------------
@@ -250,6 +253,87 @@ function agentDescriptor(agent) {
250
253
  function uvDescriptor() {
251
254
  return commandDescriptor("uv", "uv", UV_INSTALL_HINTS);
252
255
  }
256
+ /** Secret-free remediation hint shared by the credential-resolution descriptor. */
257
+ const CREDENTIAL_RESOLUTION_HINT = 'Set BAPI_API_KEY in the environment, or add it under "bapi:<repo_name>" in ' +
258
+ "~/.config/bridge/credentials.json.";
259
+ const CREDENTIAL_RESOLUTION_INSTALL_HINTS = {
260
+ darwin: CREDENTIAL_RESOLUTION_HINT,
261
+ linux: CREDENTIAL_RESOLUTION_HINT,
262
+ win32: CREDENTIAL_RESOLUTION_HINT,
263
+ };
264
+ /**
265
+ * Doctor-only, strictly read-only probe: can the Bridge API credentials be
266
+ * resolved for the current repo? Resolves repo identity from `BAPI_REPO_NAME`
267
+ * first, then `.bridge/config`, then asks the credential resolver. Reports only
268
+ * the SOURCE CLASS (env vs. store) — NEVER the resolved key value.
269
+ */
270
+ export function credentialResolutionDescriptor() {
271
+ return {
272
+ id: "bapi-credentials",
273
+ label: "Bridge API credential resolution",
274
+ installHint: CREDENTIAL_RESOLUTION_INSTALL_HINTS,
275
+ probe: async (deps) => {
276
+ const { readFile, stat, homedir } = deps;
277
+ if (!readFile || !stat || !homedir) {
278
+ return { found: false, detail: "credential probe unavailable (no read-only filesystem access)" };
279
+ }
280
+ let repoName = (deps.env.BAPI_REPO_NAME ?? "").trim();
281
+ if (repoName.length === 0) {
282
+ const read = await readBridgeConfig(deps.cwd, { readFile });
283
+ if (read.ok) {
284
+ repoName = read.manifest.repoName;
285
+ }
286
+ else {
287
+ return {
288
+ found: false,
289
+ detail: "cannot determine repo identity (set BAPI_REPO_NAME or add a valid .bridge/config). " +
290
+ CREDENTIAL_RESOLUTION_HINT,
291
+ };
292
+ }
293
+ }
294
+ const result = await resolveBapiCredentials(repoName, {
295
+ env: deps.env,
296
+ homedir,
297
+ platform: deps.platform,
298
+ readFile,
299
+ stat,
300
+ });
301
+ if (result.ok) {
302
+ const via = result.credentials.source === "env" ? "env" : "store";
303
+ return { found: true, detail: `credentials resolvable via ${via}` };
304
+ }
305
+ return { found: false, detail: CREDENTIAL_RESOLUTION_HINT };
306
+ },
307
+ };
308
+ }
309
+ /** Secret-free remediation hint shared by the worktree-MCP descriptor. */
310
+ const WORKTREE_MCP_HINT = "Re-run start-tickets to provision the worktree MCP registration " +
311
+ "(.mcp.json / .cursor/mcp.json pointing at the mcp-invoke shim).";
312
+ const WORKTREE_MCP_INSTALL_HINTS = {
313
+ darwin: WORKTREE_MCP_HINT,
314
+ linux: WORKTREE_MCP_HINT,
315
+ win32: WORKTREE_MCP_HINT,
316
+ };
317
+ /**
318
+ * Doctor-only, strictly read-only probe: does the current worktree's generated
319
+ * MCP registration point at the Bridge API `mcp-invoke` shim? Inspects
320
+ * `.mcp.json` / `.cursor/mcp.json` under `deps.cwd` without spawning anything.
321
+ */
322
+ export function worktreeMcpReachabilityDescriptor() {
323
+ return {
324
+ id: "worktree-mcp-registration",
325
+ label: "Worktree MCP registration reachability",
326
+ installHint: WORKTREE_MCP_INSTALL_HINTS,
327
+ probe: async (deps) => {
328
+ const { readFile } = deps;
329
+ if (!readFile) {
330
+ return { found: false, detail: "registration probe unavailable (no read-only filesystem access)" };
331
+ }
332
+ const result = await probeWorktreeMcpRegistration(deps.cwd, { readFile });
333
+ return { found: result.found, detail: result.detail };
334
+ },
335
+ };
336
+ }
253
337
  /**
254
338
  * The exact existing live-preflight requirement set per platform, plus the git
255
339
  * work-tree custom check appended after the command checks. This is the ONLY
@@ -286,7 +370,12 @@ export function getPreflightPrereqDescriptors(platform, env) {
286
370
  * additive (BAPI-302/303 regression-safe).
287
371
  */
288
372
  export function getDoctorOnlyPrereqDescriptors(_platform, _env, agent) {
289
- return [uvDescriptor(), agentDescriptor(agent)];
373
+ return [
374
+ uvDescriptor(),
375
+ agentDescriptor(agent),
376
+ credentialResolutionDescriptor(),
377
+ worktreeMcpReachabilityDescriptor(),
378
+ ];
290
379
  }
291
380
  /**
292
381
  * The doctor's full descriptor set: the preflight descriptors (verbatim) plus