@bridge_gpt/mcp-server 0.2.36 → 0.2.38

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 (92) hide show
  1. package/README.md +48 -8
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +70 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-body.js +92 -0
  66. package/build/sfcc/read-projection.js +185 -0
  67. package/build/sfcc/read-result.js +158 -0
  68. package/build/sfcc/reads-custom-object-def.js +57 -34
  69. package/build/sfcc/reads-site-preference.js +86 -33
  70. package/build/sfcc/reads-system-object.js +50 -38
  71. package/build/sfcc/sfcc-result.js +106 -0
  72. package/build/sfcc/tool-wrapper.js +56 -13
  73. package/build/sfcc/write-grants.js +45 -22
  74. package/build/sfcc/write-guard.js +21 -13
  75. package/build/sfcc/write-result.js +71 -15
  76. package/build/sfcc/write-tool-common.js +126 -32
  77. package/build/sfcc/writes-custom-object-def.js +6 -2
  78. package/build/sfcc/writes-system-object.js +11 -50
  79. package/build/start-tickets-prereqs.js +129 -0
  80. package/build/start-tickets.js +17 -13
  81. package/build/ticket-backend-metadata.js +59 -0
  82. package/build/ticket-key-utils.js +92 -0
  83. package/build/tool-error-envelope.js +71 -0
  84. package/build/tool-surface-gating.js +72 -0
  85. package/build/update-status.js +102 -0
  86. package/build/upgrade-advice.js +47 -0
  87. package/build/upgrade-cli.js +417 -101
  88. package/build/version.generated.js +1 -1
  89. package/build/worktree-core.js +73 -0
  90. package/docs/CONDUCTOR.md +23 -8
  91. package/package.json +3 -3
  92. package/pipelines/implement-ticket.json +15 -5
@@ -0,0 +1,272 @@
1
+ /**
2
+ * BAPI-763 — the orchestration seam between a pipeline recipe and the plan ledger.
3
+ *
4
+ * {@link ./plan-execution-ledger.js} holds the domain rules. This module is the thin
5
+ * layer that connects them to a running pipeline: which recipe steps are routed
6
+ * phases, how plan metadata is recovered from the plan-generation step's result, what
7
+ * routing context is injected into a paused agent task, and how a returned phase
8
+ * result is parsed and merged.
9
+ *
10
+ * Two constraints shape it.
11
+ *
12
+ * First, an `agent_task` is not executed by the orchestrator — the recipe pauses, the
13
+ * calling agent does the work, and it resumes with free-form `agent_result` text. So
14
+ * the phase-result envelope has to survive a round trip through prose, which is why
15
+ * it is a fenced JSON block that {@link parsePhaseResultEnvelope} extracts rather
16
+ * than a typed return value.
17
+ *
18
+ * Second, this contract is new and only three instruction files implement it. Every
19
+ * other pipeline (review-ticket, full-automation, plan-epic) still returns ordinary
20
+ * prose. Routing therefore applies only to {@link ROUTED_PHASE_INSTRUCTIONS}; an
21
+ * unrouted step is untouched.
22
+ *
23
+ * Pure: no I/O, no transport. The orchestrator supplies the persisted state.
24
+ */
25
+ import { PlanLedgerError, assertStepClassCoverage, mergePlanStepRecords, resolveOwnedParts, resolveOwnedSteps, sanitizePlanStepRecords, validatePhaseResult, validatePlanMetadata, } from "./plan-execution-ledger.js";
26
+ /** Instruction file -> the phase whose steps it must settle. */
27
+ export const ROUTED_PHASE_INSTRUCTIONS = Object.freeze({
28
+ "execute-plan.md": "produce",
29
+ "execute-plan-verification.md": "pre_pr_verification",
30
+ "verify-plan.md": "post_pr_gap_close",
31
+ });
32
+ export function phaseForInstructionFile(instructionFile) {
33
+ if (!instructionFile)
34
+ return undefined;
35
+ return ROUTED_PHASE_INSTRUCTIONS[instructionFile];
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Recovering plan metadata from the plan-generation step
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Pull the provenance envelope out of whatever `request_plan_generation` returned.
42
+ *
43
+ * The tool result reaches the orchestrator as MCP content, so the metadata may be an
44
+ * object already or JSON embedded in a text block. Returns `undefined` when there is
45
+ * nothing to find; the caller decides whether that is fatal.
46
+ */
47
+ export function extractPlanMetadata(result) {
48
+ const candidate = findPlanMetadataCandidate(result, 0);
49
+ if (candidate === undefined)
50
+ return undefined;
51
+ return validatePlanMetadata(candidate);
52
+ }
53
+ function findPlanMetadataCandidate(value, depth) {
54
+ if (depth > 6 || value === null || value === undefined)
55
+ return undefined;
56
+ if (typeof value === "string") {
57
+ const trimmed = value.trim();
58
+ if (!trimmed.startsWith("{") && !trimmed.includes("plan_metadata"))
59
+ return undefined;
60
+ try {
61
+ return findPlanMetadataCandidate(JSON.parse(trimmed), depth + 1);
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ if (Array.isArray(value)) {
68
+ for (const entry of value) {
69
+ const found = findPlanMetadataCandidate(entry, depth + 1);
70
+ if (found !== undefined)
71
+ return found;
72
+ }
73
+ return undefined;
74
+ }
75
+ if (typeof value === "object") {
76
+ const record = value;
77
+ if (record.plan_metadata !== undefined && record.plan_metadata !== null) {
78
+ return record.plan_metadata;
79
+ }
80
+ // An already-unwrapped envelope.
81
+ if (record.version !== undefined && Array.isArray(record.parts)) {
82
+ return record;
83
+ }
84
+ for (const entry of Object.values(record)) {
85
+ const found = findPlanMetadataCandidate(entry, depth + 1);
86
+ if (found !== undefined)
87
+ return found;
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+ export function buildPhaseRoutingContext(metadata, phase, ledger, checkpoint) {
93
+ return {
94
+ phase,
95
+ ownedSteps: resolveOwnedSteps(metadata, phase),
96
+ ownedRanges: resolveOwnedParts(metadata, phase).map((part) => ({
97
+ partId: part.part_id,
98
+ provenanceClass: part.provenance_class,
99
+ startStep: part.start_step,
100
+ endStep: part.end_step,
101
+ declaredAdvisory: part.declared_advisory,
102
+ })),
103
+ ledger: sanitizePlanStepRecords(ledger),
104
+ ...(checkpoint ? { checkpoint } : {}),
105
+ };
106
+ }
107
+ /**
108
+ * Append the routing context to the instruction text a paused phase receives.
109
+ *
110
+ * Injected through the instruction rather than left to conversation on purpose: the
111
+ * durable record has to survive an approval pause, a compaction, and a resumed
112
+ * session, and the conversational hand-off it replaces survived none of those.
113
+ */
114
+ export function injectPhaseRoutingContext(instruction, context) {
115
+ const block = JSON.stringify(context, null, 2);
116
+ return (`${instruction}\n\n` +
117
+ `---\n\n` +
118
+ `## Routed phase context (BAPI-763 — orchestration-supplied, authoritative)\n\n` +
119
+ `You are executing the \`${context.phase}\` phase. The block below is the plan's ` +
120
+ `provenance routing and the durable ledger as it stands right now. Settle every ` +
121
+ `step in \`ownedSteps\` — each must end as \`executed\`, \`adapted\`, \`escalated\`, ` +
122
+ `or \`unrun-advisory\`. Steps outside \`ownedSteps\` belong to another phase.\n\n` +
123
+ `Entries already present in \`ledger\` are done; do not redo them.\n\n` +
124
+ "```json\n" +
125
+ `${block}\n` +
126
+ "```\n\n" +
127
+ `When the phase finishes, end your result with a fenced \`json\` block tagged ` +
128
+ `\`bapi-phase-result\` in this shape:\n\n` +
129
+ "```bapi-phase-result\n" +
130
+ `{"version":1,"phase":"${context.phase}","records":[{"stepId":<n>,"provenanceClass":"<class>","disposition":"executed","evidence":"<what ran and what was observed>"}]}\n` +
131
+ "```\n");
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Parsing the returned phase result
135
+ // ---------------------------------------------------------------------------
136
+ const PHASE_RESULT_FENCE = /```(?:bapi-phase-result|json)\s*\n([\s\S]*?)\n?```/g;
137
+ /**
138
+ * Extract the phase-result envelope from an agent's free-form result text.
139
+ *
140
+ * Scans every candidate fence and takes the last one that parses into an envelope
141
+ * shape, so an instruction's own example block earlier in the text cannot be mistaken
142
+ * for the real result.
143
+ */
144
+ export function parsePhaseResultEnvelope(agentResult) {
145
+ if (!agentResult)
146
+ return undefined;
147
+ let found;
148
+ PHASE_RESULT_FENCE.lastIndex = 0;
149
+ let match;
150
+ while ((match = PHASE_RESULT_FENCE.exec(agentResult)) !== null) {
151
+ try {
152
+ const parsed = JSON.parse(match[1]);
153
+ if (parsed && Array.isArray(parsed.records) && parsed.phase !== undefined) {
154
+ found = parsed;
155
+ }
156
+ }
157
+ catch {
158
+ // Not an envelope; keep scanning.
159
+ }
160
+ }
161
+ return found;
162
+ }
163
+ const CHECKPOINT_FENCE = /```(?:bapi-checkpoint|json)\s*\n([\s\S]*?)\n?```/g;
164
+ /**
165
+ * Extract the checkpoint result `checkpoint-work.md` returns.
166
+ *
167
+ * Only a checkpoint that reports its work durable on origin is accepted: a block
168
+ * claiming `pushed: false` or `remoteMatchesHead: false` describes a checkpoint that
169
+ * did not achieve the one thing it exists for, so treating it as one would defeat
170
+ * the guarantee. Returns undefined when no usable block is present, which the caller
171
+ * distinguishes from a durable one.
172
+ */
173
+ export function parseCheckpointResult(agentResult) {
174
+ if (!agentResult)
175
+ return undefined;
176
+ let found;
177
+ CHECKPOINT_FENCE.lastIndex = 0;
178
+ let match;
179
+ while ((match = CHECKPOINT_FENCE.exec(agentResult)) !== null) {
180
+ try {
181
+ const parsed = JSON.parse(match[1]);
182
+ if (typeof parsed.branch === "string" &&
183
+ parsed.branch.trim() !== "" &&
184
+ typeof parsed.sha === "string" &&
185
+ parsed.sha.trim() !== "" &&
186
+ parsed.pushed === true &&
187
+ parsed.remoteMatchesHead === true) {
188
+ found = {
189
+ branch: parsed.branch,
190
+ sha: parsed.sha,
191
+ pushed: true,
192
+ remoteMatchesHead: true,
193
+ };
194
+ }
195
+ }
196
+ catch {
197
+ // Not a checkpoint block; keep scanning.
198
+ }
199
+ }
200
+ return found;
201
+ }
202
+ /**
203
+ * Settle a routed phase: parse, validate, and merge its result into the ledger.
204
+ *
205
+ * The two failure modes are treated differently, deliberately.
206
+ *
207
+ * A **malformed or incomplete envelope** is a contract violation by a phase that
208
+ * tried to report — it is flagged with `contractViolation`, and the orchestrator
209
+ * refuses to advance on it. The phase made a claim that does not hold, and advancing
210
+ * would record that claim as the durable truth.
211
+ *
212
+ * A **missing envelope** records an escalation for every owned step instead. This
213
+ * path exists because only three instruction files implement the contract and an
214
+ * agent's formatting can drift; turning that into a hard stop would wedge the
215
+ * pipeline. Crucially it is not silent — each owned step lands in the durable record
216
+ * as `escalated` with the reason, which is what surfaces as unresolved. The failure
217
+ * this ticket exists to fix was work vanishing with no trace, not work being flagged.
218
+ */
219
+ export function settlePhaseResult(agentResult, metadata, phase, existingLedger) {
220
+ const envelope = parsePhaseResultEnvelope(agentResult);
221
+ if (!envelope) {
222
+ const owned = resolveOwnedSteps(metadata, phase);
223
+ if (owned.length === 0) {
224
+ return { ledger: [...existingLedger], contractViolation: false };
225
+ }
226
+ const reason = `phase '${phase}' returned no machine-readable result envelope, so the outcome of ` +
227
+ `its owned steps is unknown and is recorded as escalated rather than assumed complete`;
228
+ const records = owned.map((stepId) => ({
229
+ stepId,
230
+ provenanceClass: classForStep(metadata, stepId),
231
+ phase,
232
+ disposition: "escalated",
233
+ escalationReason: reason,
234
+ }));
235
+ return {
236
+ ledger: mergePlanStepRecords(existingLedger, records),
237
+ diagnostic: reason,
238
+ contractViolation: false,
239
+ };
240
+ }
241
+ try {
242
+ const validated = validatePhaseResult(envelope, metadata, phase);
243
+ return {
244
+ ledger: mergePlanStepRecords(existingLedger, sanitizePlanStepRecords(validated)),
245
+ contractViolation: false,
246
+ };
247
+ }
248
+ catch (err) {
249
+ const message = err instanceof PlanLedgerError ? err.message : String(err);
250
+ return {
251
+ ledger: [...existingLedger],
252
+ diagnostic: `phase '${phase}' returned an invalid result envelope: ${message}`,
253
+ contractViolation: true,
254
+ };
255
+ }
256
+ }
257
+ function classForStep(metadata, stepId) {
258
+ const part = metadata.parts.find((entry) => stepId >= entry.start_step && stepId <= entry.end_step);
259
+ // resolveOwnedSteps only emits step IDs that came from a part, so the fallback is
260
+ // unreachable in practice; it exists so a future caller cannot crash here.
261
+ return part ? part.provenance_class : "implementation";
262
+ }
263
+ /**
264
+ * Validate metadata and prove complete class coverage before any phase runs.
265
+ *
266
+ * Deliberately called before the first routed phase, not lazily at each one: the
267
+ * point is to discover an unowned step class while nothing has been produced yet,
268
+ * rather than after a pull request is already open.
269
+ */
270
+ export function assertPlanIsRoutable(metadata) {
271
+ assertStepClassCoverage(metadata);
272
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Alembic head preflight check (BAPI-756).
3
+ *
4
+ * A database behind the migration head blocks conductor dispatch
5
+ * (`CONTRACT_MIGRATION_BEHIND`), so catching it before anything spawns saves an
6
+ * operator a confusing run that starts cleanly and then refuses every job.
7
+ *
8
+ * This check is deliberately asymmetric: it **blocks only on a confirmed
9
+ * mismatch** — both commands ran, both parsed, and the revision sets differ.
10
+ * Every other outcome (no repository virtualenv, a command failure, output it
11
+ * cannot parse) is a named "could not verify" warning that lets startup
12
+ * proceed. A false refusal here is worse than a skipped check, because the
13
+ * condition it guards is already reported loudly by the server at dispatch time.
14
+ */
15
+ import path from "path";
16
+ /** Remediation embedded in a confirmed-mismatch diagnostic. */
17
+ export const PLANE_ALEMBIC_REMEDIATION = "alembic -c alembic.ini upgrade head";
18
+ /** Fixed prefix of the fail-open warning, so tests and greps can match it. */
19
+ export const PLANE_ALEMBIC_UNVERIFIED_PREFIX = "could not verify database migration head";
20
+ /**
21
+ * Path to the repository virtualenv's Alembic executable.
22
+ *
23
+ * Deliberately NOT a bare `alembic` from `PATH`: the repository venv is the one
24
+ * whose installed migrations and driver match `alembic.ini`, and an unrelated
25
+ * global Alembic would answer a different question.
26
+ */
27
+ export function resolveRepositoryAlembicCommand(repoRoot, platform) {
28
+ return platform === "win32"
29
+ ? path.join(repoRoot, ".venv", "Scripts", "alembic.exe")
30
+ : path.join(repoRoot, ".venv", "bin", "alembic");
31
+ }
32
+ // Log lines Alembic writes alongside its answer. Skipped before token parsing
33
+ // so a logging configuration change cannot be mistaken for a revision id.
34
+ const LOG_LINE_PATTERN = /^(INFO|WARNING|ERROR|DEBUG|CRITICAL)\b|\[alembic/i;
35
+ // This repository's revision ids are slugged (`0004_add_error_count`), not the
36
+ // hex default, so the pattern must accept letters, digits, `_`, `-`, and `.`.
37
+ const REVISION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z_.-]*$/;
38
+ // Tokens Alembic prints that are annotations or sentinels rather than revisions.
39
+ const NON_REVISION_TOKENS = new Set(["head", "heads", "base", "none", "(head)"]);
40
+ /**
41
+ * Parse `alembic heads` / `alembic current` output into a normalized revision
42
+ * set.
43
+ *
44
+ * Returns `null` when no revision token is found — including for empty output,
45
+ * which is what `current` prints for a database stamped at base. The caller
46
+ * turns `null` into the fail-open warning rather than a mismatch, per the
47
+ * ratified "never refuse on an unconfirmed reading" policy.
48
+ */
49
+ export function parseAlembicRevisions(output) {
50
+ const revisions = new Set();
51
+ for (const rawLine of output.split(/\r?\n/)) {
52
+ const line = rawLine.trim();
53
+ if (line.length === 0)
54
+ continue;
55
+ if (LOG_LINE_PATTERN.test(line))
56
+ continue;
57
+ const token = line.split(/\s+/)[0];
58
+ if (!token || NON_REVISION_TOKENS.has(token.toLowerCase()))
59
+ continue;
60
+ if (!REVISION_PATTERN.test(token))
61
+ continue;
62
+ revisions.add(token);
63
+ }
64
+ if (revisions.size === 0)
65
+ return null;
66
+ return [...revisions].sort();
67
+ }
68
+ /**
69
+ * Compare the repository's migration heads with the database's current
70
+ * revisions. Blocks only on a confirmed mismatch; warns on anything unverifiable.
71
+ */
72
+ export async function checkAlembicHead(repoRoot, deps) {
73
+ const alembic = resolveRepositoryAlembicCommand(repoRoot, deps.platform);
74
+ if (!(await deps.fileExists(alembic))) {
75
+ return unverified("the repository virtualenv Alembic executable was not found");
76
+ }
77
+ const heads = await deps.execFile(alembic, ["-c", "alembic.ini", "heads"], { cwd: repoRoot });
78
+ if (!heads.ok) {
79
+ return unverified(`\`alembic heads\` did not complete (${heads.error})`);
80
+ }
81
+ const current = await deps.execFile(alembic, ["-c", "alembic.ini", "current"], { cwd: repoRoot });
82
+ if (!current.ok) {
83
+ return unverified(`\`alembic current\` did not complete (${current.error})`);
84
+ }
85
+ const headRevisions = parseAlembicRevisions(heads.stdout);
86
+ const currentRevisions = parseAlembicRevisions(current.stdout);
87
+ if (headRevisions === null || currentRevisions === null) {
88
+ return unverified("Alembic output did not contain a readable revision");
89
+ }
90
+ const matches = headRevisions.length === currentRevisions.length &&
91
+ headRevisions.every((rev, i) => rev === currentRevisions[i]);
92
+ if (matches)
93
+ return null;
94
+ return {
95
+ check: "alembic-head",
96
+ severity: "blocking",
97
+ message: `the database is not at the migration head — heads [${headRevisions.join(", ")}], ` +
98
+ `current [${currentRevisions.join(", ")}]. A database behind the head blocks ` +
99
+ `conductor dispatch. Run \`${PLANE_ALEMBIC_REMEDIATION}\``,
100
+ };
101
+ }
102
+ function unverified(detail) {
103
+ return {
104
+ check: "alembic-head",
105
+ severity: "warning",
106
+ message: `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${detail}. Startup continues — verify with ` +
107
+ `\`alembic -c alembic.ini heads\` and \`alembic -c alembic.ini current\` if a run ` +
108
+ "later reports CONTRACT_MIGRATION_BEHIND.",
109
+ };
110
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Executor build-freshness preflight check (BAPI-756).
3
+ *
4
+ * A stale `mcp_server/build/` is one of the quietest traps in an attended run:
5
+ * the executor starts, reports healthy, and runs yesterday's code. Comparing
6
+ * the newest `src/**` modification time against the build's own timestamp turns
7
+ * that silent wrong-code condition into a refusal naming `npm run build`.
8
+ *
9
+ * The authoritative build timestamp is `build/index.js`, not the directory.
10
+ * `npm run build` runs tsc, then re-bundles the bin entry points with esbuild,
11
+ * then prepends shebangs — so `build/index.js` is written at the END of the
12
+ * pipeline, while the directory's mtime only tracks direct child creation and
13
+ * can be older than a rebuild that overwrote files in place.
14
+ */
15
+ import path from "path";
16
+ /** Remediation text embedded in every blocking build diagnostic. */
17
+ export const PLANE_BUILD_REMEDIATION = "cd mcp_server && npm run build";
18
+ /**
19
+ * Source subtrees and files excluded from the freshness comparison.
20
+ *
21
+ * `*.generated.ts` files are written by the bundle scripts during the SAME
22
+ * `npm run build` that produces `build/index.js`, so they are always newer than
23
+ * a tsc run that preceded them and would stale every build immediately.
24
+ */
25
+ const EXCLUDED_DIR_NAMES = new Set(["node_modules", "build"]);
26
+ function isRelevantSourceFile(name) {
27
+ if (!name.endsWith(".ts"))
28
+ return false;
29
+ if (name.endsWith(".generated.ts"))
30
+ return false;
31
+ return true;
32
+ }
33
+ /**
34
+ * Newest modification time across the compiled package's TypeScript sources.
35
+ *
36
+ * Returns `null` when the tree contains no relevant source file at all, which
37
+ * the caller treats as "nothing to be stale against" rather than as an error.
38
+ */
39
+ export async function findNewestSourceMtime(srcDir, deps) {
40
+ let newest = null;
41
+ const walk = async (dir) => {
42
+ let entries;
43
+ try {
44
+ entries = await deps.fs.readdir(dir);
45
+ }
46
+ catch (err) {
47
+ return sanitize(err);
48
+ }
49
+ for (const entry of entries) {
50
+ if (entry.isDirectory()) {
51
+ if (EXCLUDED_DIR_NAMES.has(entry.name))
52
+ continue;
53
+ const error = await walk(path.join(dir, entry.name));
54
+ if (error)
55
+ return error;
56
+ continue;
57
+ }
58
+ if (!entry.isFile() || !isRelevantSourceFile(entry.name))
59
+ continue;
60
+ try {
61
+ const stat = await deps.fs.stat(path.join(dir, entry.name));
62
+ if (newest === null || stat.mtimeMs > newest)
63
+ newest = stat.mtimeMs;
64
+ }
65
+ catch (err) {
66
+ return sanitize(err);
67
+ }
68
+ }
69
+ return null;
70
+ };
71
+ const error = await walk(srcDir);
72
+ if (error)
73
+ return { ok: false, error };
74
+ return { ok: true, mtimeMs: newest };
75
+ }
76
+ /**
77
+ * The authoritative build timestamp: the mtime of the compiled entrypoint the
78
+ * executor members are actually launched from.
79
+ */
80
+ export async function findBuildMtime(executorEntrypoint, deps) {
81
+ try {
82
+ const stat = await deps.fs.stat(executorEntrypoint);
83
+ return { ok: true, mtimeMs: stat.mtimeMs };
84
+ }
85
+ catch (err) {
86
+ return { ok: false, error: sanitize(err) };
87
+ }
88
+ }
89
+ /**
90
+ * Verify the executor build exists and is not older than its sources.
91
+ *
92
+ * Returns a blocking diagnostic (never a warning): running an executor from a
93
+ * stale or absent build is exactly the failure this check exists to prevent, so
94
+ * unlike the Alembic check there is no fail-open branch.
95
+ */
96
+ export async function checkPlaneBuildFreshness(repoRoot, deps) {
97
+ const packageRoot = path.join(repoRoot, "mcp_server");
98
+ const buildDir = path.join(packageRoot, "build");
99
+ const executorEntrypoint = path.join(buildDir, "index.js");
100
+ const srcDir = path.join(packageRoot, "src");
101
+ const buildMtime = await findBuildMtime(executorEntrypoint, deps);
102
+ if (!buildMtime.ok) {
103
+ return {
104
+ check: "executor-build",
105
+ severity: "blocking",
106
+ message: `the executor build entrypoint is missing or unreadable at ` +
107
+ `mcp_server/build/index.js — run \`${PLANE_BUILD_REMEDIATION}\``,
108
+ };
109
+ }
110
+ const sourceMtime = await findNewestSourceMtime(srcDir, deps);
111
+ if (!sourceMtime.ok) {
112
+ return {
113
+ check: "executor-build",
114
+ severity: "blocking",
115
+ message: `could not read mcp_server/src to compare against the build ` +
116
+ `(${sourceMtime.error}) — run \`${PLANE_BUILD_REMEDIATION}\``,
117
+ };
118
+ }
119
+ // Equality passes: a build written in the same millisecond as its newest
120
+ // source is current, and treating the boundary as stale would refuse a
121
+ // perfectly good build on a coarse-resolution filesystem.
122
+ if (sourceMtime.mtimeMs !== null && sourceMtime.mtimeMs > buildMtime.mtimeMs) {
123
+ return {
124
+ check: "executor-build",
125
+ severity: "blocking",
126
+ message: "mcp_server/build/ is STALE — a source file under mcp_server/src is newer " +
127
+ `than mcp_server/build/index.js, so executors would run old code. ` +
128
+ `Run \`${PLANE_BUILD_REMEDIATION}\``,
129
+ };
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * Fixed refusal prose for an unresolvable runtime re-exec target (BAPI-768).
135
+ *
136
+ * Exported so the private entrypoint diagnostic and the preflight refusal say
137
+ * the same thing, and so a test can pin the wording rather than a substring.
138
+ */
139
+ export const PLANE_RUNTIME_ENTRYPOINT_REFUSAL = "the currently executing MCP build could not be re-entered — `plane up` re-execs this " +
140
+ "package's own compiled entrypoint to create the detached runtime, and no existing " +
141
+ `entrypoint belonging to it could be found. Run \`${PLANE_BUILD_REMEDIATION}\` and retry ` +
142
+ "from the rebuilt CLI.";
143
+ /**
144
+ * Verify the launcher can identify the compiled entrypoint it is running from.
145
+ *
146
+ * Separate from {@link checkPlaneBuildFreshness} because the two answer
147
+ * different questions about different files. Build freshness asks whether *this
148
+ * repository's* executor build is current; this asks whether the *executing*
149
+ * artifact can re-enter itself. BAPI-768 was a failure of the second while the
150
+ * first was perfectly green, so collapsing them would hide the next one.
151
+ *
152
+ * Blocking, never a warning: a plane whose runtime cannot start is not a
153
+ * degraded plane, it is no plane at all.
154
+ */
155
+ export function checkPlaneRuntimeEntrypoint(resolution) {
156
+ if (resolution.ok)
157
+ return null;
158
+ return {
159
+ check: "runtime-entrypoint",
160
+ severity: "blocking",
161
+ message: PLANE_RUNTIME_ENTRYPOINT_REFUSAL,
162
+ };
163
+ }
164
+ function sanitize(err) {
165
+ const code = err?.code;
166
+ return typeof code === "string" ? code : "unreadable";
167
+ }