@bridge_gpt/mcp-server 0.1.17 → 0.2.1

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 (45) hide show
  1. package/README.md +334 -196
  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 +25 -17
  10. package/build/agent-launchers/cursor.js +65 -0
  11. package/build/agent-launchers/index.js +23 -8
  12. package/build/agent-registry.js +68 -0
  13. package/build/agents.generated.js +1 -1
  14. package/build/brainstorm-files.js +89 -0
  15. package/build/bridge-config.js +404 -0
  16. package/build/chain-orchestrator.js +247 -33
  17. package/build/command-catalog.js +376 -0
  18. package/build/commands.generated.js +10 -7
  19. package/build/credential-materialization.js +128 -0
  20. package/build/credential-store.js +232 -0
  21. package/build/decision-page-schema.js +39 -6
  22. package/build/decision-page-template.js +54 -18
  23. package/build/doctor.js +18 -2
  24. package/build/git-ignore-utils.js +63 -0
  25. package/build/index.js +1707 -557
  26. package/build/mcp-invoke.js +417 -0
  27. package/build/mcp-provisioning.js +342 -0
  28. package/build/mcp-registration-doctor.js +96 -0
  29. package/build/pipeline-orchestrator.js +9 -1
  30. package/build/pipelines.generated.js +5 -3
  31. package/build/schedule-run.js +440 -92
  32. package/build/schedule-store.js +41 -1
  33. package/build/scheduled-prompt.js +109 -0
  34. package/build/scheduler-backends/at-fallback.js +5 -10
  35. package/build/scheduler-backends/escaping.js +40 -10
  36. package/build/scheduler-backends/launchd.js +23 -14
  37. package/build/scheduler-backends/systemd-user.js +32 -19
  38. package/build/scheduler-backends/task-scheduler.js +8 -13
  39. package/build/start-tickets-prereqs.js +90 -1
  40. package/build/start-tickets.js +563 -42
  41. package/build/third-party-mcp-targets.js +75 -0
  42. package/build/version.generated.js +1 -1
  43. package/package.json +4 -3
  44. package/pipelines/full-automation.json +3 -1
  45. package/smoke-test/SMOKE-TEST.md +62 -17
@@ -11,6 +11,10 @@
11
11
  */
12
12
  import { promises as fs } from "node:fs";
13
13
  import { pathApiForPlatform } from "./scheduler-backends/types.js";
14
+ /** Upper bound on retained run-history events per schedule (oldest dropped). */
15
+ export const MAX_RUN_HISTORY_EVENTS = 50;
16
+ /** Monotonic suffix source for atomic temp files (avoids same-tick collisions). */
17
+ let atomicWriteCounter = 0;
14
18
  /**
15
19
  * Build the schedule root from the injected home directory and platform path
16
20
  * API: `~/.bridge-gpt/schedules` on POSIX, `%USERPROFILE%\.bridge-gpt\schedules`
@@ -47,10 +51,46 @@ export async function ensureScheduleDirectories(homeDir, platform) {
47
51
  /**
48
52
  * Persist one pretty-printed JSON file per schedule id with a trailing newline.
49
53
  * Called only AFTER the scheduler backend successfully creates the unit/job.
54
+ *
55
+ * The write is atomic (BAPI-351): the content is written to a temp file in the
56
+ * same directory and renamed into place, so a concurrent `_execute` run-history
57
+ * append never observes a partially-written metadata file, and a crash mid-write
58
+ * leaves the prior valid file intact rather than a truncated one.
50
59
  */
51
60
  export async function writeScheduleMetadata(metadata, homeDir, platform) {
52
61
  const { metadataPath } = getSchedulePaths(metadata.id, homeDir, platform);
53
- await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf-8");
62
+ const content = `${JSON.stringify(metadata, null, 2)}\n`;
63
+ const tmpPath = `${metadataPath}.tmp-${process.pid}-${atomicWriteCounter++}`;
64
+ try {
65
+ await fs.writeFile(tmpPath, content, "utf-8");
66
+ await fs.rename(tmpPath, metadataPath);
67
+ }
68
+ catch (error) {
69
+ // Best-effort cleanup of the temp file; never leave it orphaned, and never
70
+ // partially overwrite the real metadata file.
71
+ await fs.unlink(tmpPath).catch(() => undefined);
72
+ throw error;
73
+ }
74
+ }
75
+ /**
76
+ * Append a bounded run-history event to a schedule's metadata and rewrite it
77
+ * atomically (BAPI-351). Tolerant of legacy records that have no `run_history`
78
+ * (initializes it). Returns the updated metadata, or `null` if the schedule
79
+ * metadata no longer exists.
80
+ */
81
+ export async function appendScheduleRunEvent(id, event, homeDir, platform) {
82
+ const metadata = await readScheduleMetadata(id, homeDir, platform);
83
+ if (!metadata)
84
+ return null;
85
+ const history = Array.isArray(metadata.run_history) ? [...metadata.run_history] : [];
86
+ history.push(event);
87
+ // Keep the newest events when bounding (drop from the front).
88
+ metadata.run_history =
89
+ history.length > MAX_RUN_HISTORY_EVENTS
90
+ ? history.slice(history.length - MAX_RUN_HISTORY_EVENTS)
91
+ : history;
92
+ await writeScheduleMetadata(metadata, homeDir, platform);
93
+ return metadata;
54
94
  }
55
95
  /** Read and parse a single schedule metadata JSON file; `null` when missing. */
56
96
  export async function readScheduleMetadata(id, homeDir, platform) {
@@ -0,0 +1,109 @@
1
+ import { schemaSupportsAutoFlag } from "./command-catalog.js";
2
+ /** The fixed late-fire threshold, in seconds, shared by every scheduled run. */
3
+ export const LATE_FIRE_THRESHOLD_SECONDS = 60;
4
+ /**
5
+ * Quote a single argument token for safe inclusion in the rendered prompt while
6
+ * preserving its boundary. Simple tokens (alphanumerics plus a small set of
7
+ * path/flag-safe punctuation) are left bare; anything containing whitespace or
8
+ * prompt/markup-sensitive characters is wrapped in double quotes with embedded
9
+ * double quotes escaped. This is prompt-token quoting, NOT shell quoting — no
10
+ * shell ever sees these strings.
11
+ */
12
+ export function quotePromptToken(token) {
13
+ if (token === "")
14
+ return '""';
15
+ // Safe: letters, digits, and characters that never need quoting in a prompt
16
+ // (path separators, ISO-timestamp punctuation, flag dashes, etc.).
17
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token))
18
+ return token;
19
+ return `"${token.replace(/"/g, '\\"')}"`;
20
+ }
21
+ /**
22
+ * Compute the augmented argv tokens the scheduled run delegates to the command:
23
+ * the normalized `input.args`, plus (only where the command can parse them)
24
+ * `--scheduled-at <ISO>` and `--auto`.
25
+ *
26
+ * `--scheduled-at` is appended ONLY for the legacy `full-automation` command,
27
+ * whose Stage 0 parses it and whose drift stage consumes it. Every other command
28
+ * rejects unrecognized flags and halts (e.g. `start-tickets` stops on any
29
+ * unsupported flag), and the shared late-fire gate already embeds the scheduled
30
+ * time — so injecting `--scheduled-at` into their argv would break an otherwise
31
+ * `schedulable: true` command for no benefit.
32
+ *
33
+ * `--auto` is appended when auto-approve is set AND the command supports it (its
34
+ * schema declares a boolean `--auto` flag, or it is `full-automation`). It is
35
+ * never duplicated if already present in `input.args`.
36
+ *
37
+ * This is the SINGLE source of the delegated argv: both the rendered target
38
+ * command line and the `$ARGUMENTS` body substitution derive from it, so the body
39
+ * parse and the command line never disagree (e.g. `review-ticket`, which reads
40
+ * `--auto` out of `$ARGUMENTS`, sees the same `--auto` the command line shows).
41
+ */
42
+ export function buildAugmentedArgs(input) {
43
+ const args = [...input.args];
44
+ if (input.commandName === "full-automation") {
45
+ args.push("--scheduled-at", input.scheduledAt);
46
+ }
47
+ const supportsAuto = schemaSupportsAutoFlag(input.schema) || input.commandName === "full-automation";
48
+ const alreadyHasAuto = input.args.includes("--auto");
49
+ if (input.autoApprove && supportsAuto && !alreadyHasAuto) {
50
+ args.push("--auto");
51
+ }
52
+ return args;
53
+ }
54
+ /**
55
+ * Render the delegated target command line as
56
+ * `/<commandName> <augmented args...>` (see {@link buildAugmentedArgs}).
57
+ */
58
+ export function buildTargetCommandLine(input) {
59
+ const parts = [`/${input.commandName}`];
60
+ for (const arg of buildAugmentedArgs(input))
61
+ parts.push(quotePromptToken(arg));
62
+ return parts.join(" ");
63
+ }
64
+ /** Render only the fixed late-fire gate text (exposed for focused testing). */
65
+ export function renderLateFireGate(scheduleId, scheduledAt, autoApprove) {
66
+ const lateAction = autoApprove
67
+ ? "explain that the run is firing late and then PROCEED with the command below (this schedule was created with auto-approve)."
68
+ : "explain that the run is firing late and then HALT WITHOUT EXECUTING the command below (this schedule was created without auto-approve, and a headless run must not proceed unconfirmed).";
69
+ return [
70
+ "## Scheduled-run drift gate",
71
+ "",
72
+ `This is an automated, headless scheduled run (schedule id: ${scheduleId}).`,
73
+ `It was scheduled to fire at ${scheduledAt}.`,
74
+ "",
75
+ `Before doing anything else, check how late this run is firing. If it is more than ${LATE_FIRE_THRESHOLD_SECONDS} seconds later than the scheduled time above, ${lateAction}`,
76
+ `If it is within ${LATE_FIRE_THRESHOLD_SECONDS} seconds of the scheduled time, proceed silently.`,
77
+ "",
78
+ "Then run the following command exactly as written:",
79
+ ].join("\n");
80
+ }
81
+ /**
82
+ * Replace `$ARGUMENTS` in a command body with the safely-quoted, normalized
83
+ * argument string. Every occurrence is replaced (commands often repeat the
84
+ * placeholder in a title and a body line).
85
+ */
86
+ function replaceArgumentsPlaceholder(body, argString) {
87
+ return body.split("$ARGUMENTS").join(argString);
88
+ }
89
+ /**
90
+ * Render the full scheduled-run prompt: gate + delegated command line + command
91
+ * body (with `$ARGUMENTS` substituted). Deterministic for identical inputs.
92
+ */
93
+ export function renderScheduledPrompt(input) {
94
+ const gate = renderLateFireGate(input.scheduleId, input.scheduledAt, input.autoApprove);
95
+ const targetInput = {
96
+ commandName: input.commandName,
97
+ args: input.args,
98
+ scheduledAt: input.scheduledAt,
99
+ autoApprove: input.autoApprove,
100
+ schema: input.schema,
101
+ };
102
+ const targetCommandLine = buildTargetCommandLine(targetInput);
103
+ // `$ARGUMENTS` must match what the command line shows — including the appended
104
+ // `--scheduled-at` / `--auto` — so a body that re-parses `$ARGUMENTS` (e.g.
105
+ // review-ticket reading `--auto`) agrees with the rendered command line.
106
+ const argString = buildAugmentedArgs(targetInput).map(quotePromptToken).join(" ");
107
+ const body = replaceArgumentsPlaceholder(input.commandBody, argString);
108
+ return [gate, "", targetCommandLine, "", body].join("\n");
109
+ }
@@ -1,4 +1,4 @@
1
- import { formatEnvExportsForGeneratedUnit, posixShellQuote } from "./escaping.js";
1
+ import { bakedEnvFromCreateInput, formatEnvExportsForGeneratedUnit, posixShellQuote, } from "./escaping.js";
2
2
  /** Virtual artifact path for the script piped to `at` (never written to disk). */
3
3
  export const AT_STDIN_ARTIFACT_PATH = "<at-stdin>";
4
4
  /** Convert an ISO timestamp to the `YYYYMMDDHHMM` form required by `at -t`. */
@@ -10,15 +10,10 @@ export function formatAtTimestamp(runAtIso) {
10
10
  }
11
11
  /** Render the script piped into `at`. */
12
12
  export function renderAtScript(input) {
13
- const exports = formatEnvExportsForGeneratedUnit({
14
- envPath: input.envPath,
15
- nodePath: input.nodePath,
16
- npxPath: input.npxPath,
17
- claudePath: input.claudePath,
18
- ideaFile: input.ideaFile,
19
- repoPath: input.repoPath,
20
- });
21
- const command = [input.invocation.exe, ...input.invocation.args]
13
+ const exports = formatEnvExportsForGeneratedUnit(bakedEnvFromCreateInput(input));
14
+ // The script runs the Node execution shim (trigger invocation); the shim spawns
15
+ // the stored agent invocation and records run-history events.
16
+ const command = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
22
17
  .map((part) => posixShellQuote(part))
23
18
  .join(" ");
24
19
  return [
@@ -8,14 +8,23 @@
8
8
  * truncate a generated file mid-write and silently drop a security-relevant
9
9
  * suffix — via {@link assertNoNul}.
10
10
  */
11
- /** All baked PATH-trap environment variable names, in a stable order. */
11
+ /**
12
+ * All baked PATH-trap / schedule-context environment variable names, in a stable
13
+ * order. Generalized in BAPI-351: the full-automation-specific `BRIDGE_GPT_CLAUDE`
14
+ * became the agent-neutral `BRIDGE_GPT_AGENT_PATH`, and schedule-context vars
15
+ * (`BRIDGE_GPT_SCHEDULE_ID`, `BRIDGE_GPT_COMMAND`, `BRIDGE_GPT_COMMAND_ARGS_JSON`)
16
+ * were added. `BRIDGE_GPT_IDEA_FILE` is now legacy-only (baked only when present).
17
+ */
12
18
  export const BAKED_ENV_VAR_NAMES = [
13
19
  "PATH",
14
20
  "BRIDGE_GPT_NODE",
15
21
  "BRIDGE_GPT_NPX",
16
- "BRIDGE_GPT_CLAUDE",
17
- "BRIDGE_GPT_IDEA_FILE",
22
+ "BRIDGE_GPT_AGENT",
23
+ "BRIDGE_GPT_AGENT_PATH",
18
24
  "BRIDGE_GPT_REPO_PATH",
25
+ "BRIDGE_GPT_SCHEDULE_ID",
26
+ "BRIDGE_GPT_COMMAND",
27
+ "BRIDGE_GPT_COMMAND_ARGS_JSON",
19
28
  ];
20
29
  /**
21
30
  * Reject any generated value containing a NUL byte. NUL can prematurely
@@ -81,26 +90,47 @@ export function systemdQuote(value) {
81
90
  return `"${escaped}"`;
82
91
  }
83
92
  /**
84
- * Build the ordered baked PATH-trap env entries from explicit, schedule-time
85
- * values — never `process.env`. Each value is NUL-checked. This is the single
86
- * source every backend uses so PATH, BRIDGE_GPT_NODE, BRIDGE_GPT_NPX,
87
- * BRIDGE_GPT_CLAUDE, BRIDGE_GPT_IDEA_FILE, and BRIDGE_GPT_REPO_PATH are baked
88
- * consistently across launchd / Task Scheduler / systemd / at.
93
+ * Build the ordered baked PATH-trap + schedule-context env entries from explicit,
94
+ * schedule-time values — never `process.env`. Each value is NUL-checked. This is
95
+ * the single source every backend uses so the baked environment is consistent
96
+ * across launchd / Task Scheduler / systemd / at. `BRIDGE_GPT_IDEA_FILE` is
97
+ * appended only for the legacy full-automation path (when `ideaFile` is set).
89
98
  */
90
99
  export function bakedEnvEntries(env) {
91
100
  const entries = [
92
101
  ["PATH", env.envPath],
93
102
  ["BRIDGE_GPT_NODE", env.nodePath],
94
103
  ["BRIDGE_GPT_NPX", env.npxPath],
95
- ["BRIDGE_GPT_CLAUDE", env.claudePath],
96
- ["BRIDGE_GPT_IDEA_FILE", env.ideaFile],
104
+ ["BRIDGE_GPT_AGENT", env.agent],
105
+ ["BRIDGE_GPT_AGENT_PATH", env.agentPath],
97
106
  ["BRIDGE_GPT_REPO_PATH", env.repoPath],
107
+ ["BRIDGE_GPT_SCHEDULE_ID", env.scheduleId],
108
+ ["BRIDGE_GPT_COMMAND", env.command],
109
+ ["BRIDGE_GPT_COMMAND_ARGS_JSON", env.commandArgsJson],
98
110
  ];
111
+ if (env.ideaFile !== undefined) {
112
+ entries.push(["BRIDGE_GPT_IDEA_FILE", env.ideaFile]);
113
+ }
99
114
  for (const [key, value] of entries) {
100
115
  assertNoNul(value, key);
101
116
  }
102
117
  return entries;
103
118
  }
119
+ /** Build a {@link BakedEnv} from a generalized scheduler create input. */
120
+ export function bakedEnvFromCreateInput(input) {
121
+ return {
122
+ envPath: input.envPath,
123
+ nodePath: input.nodePath,
124
+ npxPath: input.npxPath,
125
+ agent: input.agent,
126
+ agentPath: input.agentPath,
127
+ repoPath: input.repoPath,
128
+ scheduleId: input.id,
129
+ command: input.command,
130
+ commandArgsJson: JSON.stringify(input.args),
131
+ ideaFile: input.legacyIdeaFile,
132
+ };
133
+ }
104
134
  /**
105
135
  * Render the baked env as POSIX `export KEY='value'` lines (used by the `at`
106
136
  * heredoc script). Values come only from {@link bakedEnvEntries}; no value is
@@ -16,10 +16,24 @@
16
16
  */
17
17
  import { promises as fs } from "node:fs";
18
18
  import { pathApiForPlatform } from "./types.js";
19
- import { bakedEnvEntries, xmlEscape } from "./escaping.js";
20
- /** launchd label for a schedule id. */
19
+ import { bakedEnvFromCreateInput, bakedEnvEntries, xmlEscape } from "./escaping.js";
20
+ /** launchd label for a NEW schedule id (neutral, generic schedule-run naming). */
21
21
  export function launchdLabelForId(id) {
22
- return `com.bridge-gpt.full-automation.${id}`;
22
+ return `com.bridge-gpt.schedule-run.${id}`;
23
+ }
24
+ /**
25
+ * Resolve the launchd label for an existing schedule, preferring the label baked
26
+ * into the recorded plist path so legacy `com.bridge-gpt.full-automation.<id>`
27
+ * schedules remain list/cancel compatible after the neutral-naming change.
28
+ */
29
+ export function launchdLabelForMetadata(metadata) {
30
+ const unitPath = metadata.unit_path;
31
+ if (unitPath && unitPath.endsWith(".plist")) {
32
+ const base = unitPath.split(/[\\/]/).pop();
33
+ if (base)
34
+ return base.slice(0, -".plist".length);
35
+ }
36
+ return launchdLabelForId(metadata.id);
23
37
  }
24
38
  /** `~/Library/LaunchAgents/<label>.plist` for a schedule id. */
25
39
  export function launchdPlistPathForId(id, homeDir) {
@@ -49,15 +63,10 @@ function resolveUid(deps) {
49
63
  export function renderLaunchdPlist(input) {
50
64
  const label = launchdLabelForId(input.id);
51
65
  const cal = startCalendarIntervalFromIso(input.runAtIso);
52
- const env = bakedEnvEntries({
53
- envPath: input.envPath,
54
- nodePath: input.nodePath,
55
- npxPath: input.npxPath,
56
- claudePath: input.claudePath,
57
- ideaFile: input.ideaFile,
58
- repoPath: input.repoPath,
59
- });
60
- const programArgs = [input.invocation.exe, ...input.invocation.args]
66
+ const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
67
+ // The OS unit runs the Node execution shim (trigger invocation), which then
68
+ // spawns the stored agent invocation and records run-history events.
69
+ const programArgs = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
61
70
  .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
62
71
  .join("\n");
63
72
  const envEntries = env
@@ -168,7 +177,7 @@ export function createLaunchdBackend() {
168
177
  entries.push({ metadata, status: "stale", detail: "plist missing" });
169
178
  continue;
170
179
  }
171
- const label = launchdLabelForId(metadata.id);
180
+ const label = launchdLabelForMetadata(metadata);
172
181
  const printed = await input.deps.runCommand("launchctl", ["print", `gui/${uid}/${label}`]);
173
182
  entries.push({
174
183
  metadata,
@@ -180,7 +189,7 @@ export function createLaunchdBackend() {
180
189
  },
181
190
  async cancel(input) {
182
191
  const uid = resolveUid(input.deps);
183
- const label = launchdLabelForId(input.metadata.id);
192
+ const label = launchdLabelForMetadata(input.metadata);
184
193
  const plistPath = input.metadata.unit_path ?? launchdPlistPathForId(input.metadata.id, input.deps.homeDir);
185
194
  let plistExisted = true;
186
195
  try {
@@ -14,12 +14,12 @@
14
14
  */
15
15
  import { promises as fs } from "node:fs";
16
16
  import { pathApiForPlatform } from "./types.js";
17
- import { bakedEnvEntries, systemdQuote } from "./escaping.js";
18
- /** Return the unit names for a schedule id. */
17
+ import { bakedEnvFromCreateInput, bakedEnvEntries, systemdQuote } from "./escaping.js";
18
+ /** Return the unit names for a NEW schedule id (neutral schedule-run naming). */
19
19
  export function systemdUnitNamesForId(id) {
20
20
  return {
21
- service: `bridge-gpt-full-automation-${id}.service`,
22
- timer: `bridge-gpt-full-automation-${id}.timer`,
21
+ service: `bridge-gpt-schedule-run-${id}.service`,
22
+ timer: `bridge-gpt-schedule-run-${id}.timer`,
23
23
  };
24
24
  }
25
25
  /** Return the unit file paths for a schedule id. */
@@ -32,25 +32,40 @@ export function systemdUnitPathsForId(id, homeDir) {
32
32
  timer: pathApi.join(dir, names.timer),
33
33
  };
34
34
  }
35
+ /**
36
+ * Resolve unit names + paths for an EXISTING schedule, preferring the names baked
37
+ * into the recorded unit paths so legacy `bridge-gpt-full-automation-<id>` units
38
+ * remain list/cancel compatible after the neutral-naming change.
39
+ */
40
+ export function systemdUnitsForMetadata(metadata, homeDir) {
41
+ const timerPath = metadata.unit_path && metadata.unit_path.endsWith(".timer") ? metadata.unit_path : undefined;
42
+ const servicePath = (metadata.unit_paths ?? []).find((p) => p.endsWith(".service"));
43
+ if (timerPath && servicePath) {
44
+ const basename = (p) => p.split(/[\\/]/).pop();
45
+ return {
46
+ names: { service: basename(servicePath), timer: basename(timerPath) },
47
+ paths: { service: servicePath, timer: timerPath },
48
+ };
49
+ }
50
+ return {
51
+ names: systemdUnitNamesForId(metadata.id),
52
+ paths: systemdUnitPathsForId(metadata.id, homeDir),
53
+ };
54
+ }
35
55
  /** Render the systemd service unit. */
36
56
  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
- });
57
+ const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
45
58
  const envLines = env
46
59
  .map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`)
47
60
  .join("\n");
48
- const execStart = [input.invocation.exe, ...input.invocation.args]
61
+ // ExecStart runs the Node execution shim (trigger invocation); the shim spawns
62
+ // the stored agent invocation and records run-history events.
63
+ const execStart = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
49
64
  .map((part) => systemdQuote(part))
50
65
  .join(" ");
51
66
  return [
52
67
  "[Unit]",
53
- `Description=Bridge GPT full-automation one-shot run (${input.id})`,
68
+ `Description=Bridge GPT schedule-run one-shot (${input.id}: ${input.command})`,
54
69
  "",
55
70
  "[Service]",
56
71
  "Type=oneshot",
@@ -82,7 +97,7 @@ export function renderSystemdTimer(input) {
82
97
  const names = systemdUnitNamesForId(input.id);
83
98
  return [
84
99
  "[Unit]",
85
- `Description=Bridge GPT full-automation one-shot timer (${input.id})`,
100
+ `Description=Bridge GPT schedule-run one-shot timer (${input.id}: ${input.command})`,
86
101
  "",
87
102
  "[Timer]",
88
103
  `OnCalendar=${formatSystemdOnCalendar(input.runAtIso)}`,
@@ -181,8 +196,7 @@ export function createSystemdUserBackend() {
181
196
  async list(input) {
182
197
  const entries = [];
183
198
  for (const metadata of input.recorded) {
184
- const paths = systemdUnitPathsForId(metadata.id, input.deps.homeDir);
185
- const names = systemdUnitNamesForId(metadata.id);
199
+ const { paths, names } = systemdUnitsForMetadata(metadata, input.deps.homeDir);
186
200
  let timerExists = true;
187
201
  try {
188
202
  await fs.access(paths.timer);
@@ -209,8 +223,7 @@ export function createSystemdUserBackend() {
209
223
  return entries;
210
224
  },
211
225
  async cancel(input) {
212
- const paths = systemdUnitPathsForId(input.metadata.id, input.deps.homeDir);
213
- const names = systemdUnitNamesForId(input.metadata.id);
226
+ const { paths, names } = systemdUnitsForMetadata(input.metadata, input.deps.homeDir);
214
227
  const disable = await input.deps.runCommand("systemctl", [
215
228
  "--user",
216
229
  "disable",
@@ -18,10 +18,10 @@
18
18
  */
19
19
  import { promises as fs } from "node:fs";
20
20
  import { pathApiForPlatform } from "./types.js";
21
- import { bakedEnvEntries, escapeWindowsCmdSetValue, windowsCmdQuote, xmlEscape, } from "./escaping.js";
22
- /** Task Scheduler task name for a schedule id. */
21
+ import { bakedEnvFromCreateInput, bakedEnvEntries, escapeWindowsCmdSetValue, windowsCmdQuote, xmlEscape, } from "./escaping.js";
22
+ /** Task Scheduler task name for a NEW schedule id (neutral schedule-run naming). */
23
23
  export function taskNameForId(id) {
24
- return `BridgeGPT-FullAutomation-${id}`;
24
+ return `BridgeGPT-ScheduleRun-${id}`;
25
25
  }
26
26
  /** `%USERPROFILE%\.bridge-gpt\schedules\<id>.cmd` wrapper path for a schedule id. */
27
27
  export function cmdWrapperPathForId(id, homeDir) {
@@ -46,14 +46,7 @@ export function formatTaskSchedulerBoundary(runAtIso) {
46
46
  }
47
47
  /** Render the `.cmd` wrapper that the Task Scheduler task invokes. */
48
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
- });
49
+ const env = bakedEnvEntries(bakedEnvFromCreateInput(input));
57
50
  const lines = ["@echo off"];
58
51
  for (const [key, value] of env) {
59
52
  if (key === "PATH") {
@@ -65,7 +58,9 @@ export function renderWindowsCmdWrapper(input) {
65
58
  }
66
59
  }
67
60
  lines.push(`cd /D ${windowsCmdQuote(input.repoPath)}`);
68
- const command = [input.invocation.exe, ...input.invocation.args]
61
+ // The wrapper runs the Node execution shim (trigger invocation), not the agent
62
+ // binary directly; the shim spawns the stored agent invocation.
63
+ const command = [input.triggerInvocation.exe, ...input.triggerInvocation.args]
69
64
  .map((part) => windowsCmdQuote(part))
70
65
  .join(" ");
71
66
  // Append stdout/stderr to the local schedule logs.
@@ -86,7 +81,7 @@ export function renderTaskSchedulerXml(input) {
86
81
  '<?xml version="1.0" encoding="UTF-16"?>',
87
82
  '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
88
83
  " <RegistrationInfo>",
89
- ` <Description>${xmlEscape(`Bridge GPT full-automation one-shot run (${input.id})`)}</Description>`,
84
+ ` <Description>${xmlEscape(`Bridge GPT schedule-run one-shot (${input.id}: ${input.command})`)}</Description>`,
90
85
  " </RegistrationInfo>",
91
86
  " <Triggers>",
92
87
  " <TimeTrigger>",
@@ -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