@bridge_gpt/mcp-server 0.2.52 → 0.2.53
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.
- package/README.md +42 -12
- package/build/commands.generated.js +6 -4
- package/build/conductor/bridge-api-client.js +61 -0
- package/build/conductor/cli.js +23 -0
- package/build/conductor/doctor.js +428 -5
- package/build/conductor/install-doctor.js +65 -656
- package/build/conductor/readiness-cli.js +152 -0
- package/build/conductor/readiness-sections.js +666 -0
- package/build/conductor/readiness.js +710 -0
- package/build/conductor/tools.js +56 -3
- package/build/conductor-bin.js +21 -17
- package/build/index.js +4651 -4580
- package/build/install-doctor.js +154 -2
- package/build/pipelines.generated.js +3 -3
- package/build/plane/alembic-head.js +40 -11
- package/build/plane/build-freshness.js +22 -11
- package/build/plane/preflight.js +363 -48
- package/build/plane/types.js +36 -0
- package/build/readiness-check.js +412 -0
- package/build/readme.generated.js +1 -1
- package/build/version.generated.js +3 -3
- package/package.json +2 -2
- package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared conductor readiness SECTION layer (BAPI-1055).
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `conductor/install-doctor.ts`, where these builders
|
|
5
|
+
* had already composed four of the five prerequisite loci. Nothing about a
|
|
6
|
+
* section changed in the move: same ids, same labels, same statuses, same
|
|
7
|
+
* detail text, same installer remediations. What changed is who may consume
|
|
8
|
+
* them — the install-time doctor and the advisory `conductor readiness` gate now
|
|
9
|
+
* read ONE adapter set instead of interpreting the same probes twice.
|
|
10
|
+
*
|
|
11
|
+
* ## Why extraction rather than a second aggregator
|
|
12
|
+
*
|
|
13
|
+
* The obvious alternative was to build the consolidated gate beside the install
|
|
14
|
+
* doctor. That would have left two independent gates over the same probes, which
|
|
15
|
+
* is precisely the duplication AC-6 exists to remove — and they would drift, as
|
|
16
|
+
* two implementations of one classification always do. Evolving the install
|
|
17
|
+
* doctor in place was the other alternative, and it would have mixed advisory
|
|
18
|
+
* reporting into an install-time, fatal-capable contract.
|
|
19
|
+
*
|
|
20
|
+
* ## The fatal boundary stays with the installer
|
|
21
|
+
*
|
|
22
|
+
* `ConductorInstallSectionStatus` keeps its `fatal` state, because the installer
|
|
23
|
+
* genuinely refuses to write on one (`conductorInstallDoctorHasFatal` gates the
|
|
24
|
+
* pre-write abort in `install-bridge-conductor.ts`). That refusal is unchanged.
|
|
25
|
+
* The advisory gate maps `fatal` onto the canonical `fail` status and never
|
|
26
|
+
* inherits the exit-code consequence: an advisory command that could exit
|
|
27
|
+
* non-zero would not be advisory.
|
|
28
|
+
*
|
|
29
|
+
* ## Purity
|
|
30
|
+
*
|
|
31
|
+
* Every builder here is PURE — it reads an already-collected report and returns
|
|
32
|
+
* sections. No probe, no network call, no filesystem write, no process spawn.
|
|
33
|
+
* The one exception is {@link inspectWorkflowPresence}, which awaits the
|
|
34
|
+
* READ-ONLY reader seams its caller injects and never opens a path of its own.
|
|
35
|
+
*/
|
|
36
|
+
import { MANAGED_HOOK_NAMES } from "./git-hooks.js";
|
|
37
|
+
import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
|
|
38
|
+
import { CLAUDE_REVIEW_WORKFLOW_DRIFT_REMEDIATION, summarizeClaudeReviewWorkflowDrift, } from "../claude-review-workflow-drift.js";
|
|
39
|
+
import { describeNativeLedgerAvailability } from "./doctor.js";
|
|
40
|
+
/** Operator runbook pointer surfaced beside the executor gap. */
|
|
41
|
+
export const CONDUCTOR_OPERATOR_RUNBOOK_POINTER = "docs/claude/epic-conductor-v2-operator-runbook.md";
|
|
42
|
+
/**
|
|
43
|
+
* Capability-matrix guidance for the executor leg.
|
|
44
|
+
*
|
|
45
|
+
* Amended by BAPI-779 (R-1): `install conductor` can now write the unit AND
|
|
46
|
+
* start it, each behind its own consent. What did NOT change is the readiness
|
|
47
|
+
* rule — `executor_ready` still comes only from a live server observation, so
|
|
48
|
+
* neither a written unit nor a successful local start may produce that claim.
|
|
49
|
+
*/
|
|
50
|
+
export const EXECUTOR_PROVISIONING_GUIDANCE = "`install conductor` writes the unit and starts it, each behind its own consent; " +
|
|
51
|
+
"readiness still comes only from a live server observation";
|
|
52
|
+
/**
|
|
53
|
+
* The `BRIDGE_MCP_PROFILE` token that makes the 8 conductor MCP tools visible.
|
|
54
|
+
*
|
|
55
|
+
* The tools are profile-gated, never core, and this is the only token that
|
|
56
|
+
* un-gates them — so the doctor names it explicitly rather than describing the
|
|
57
|
+
* gap abstractly (BAPI-775).
|
|
58
|
+
*/
|
|
59
|
+
export const CONDUCTOR_PROFILE_TOKEN = "conductor";
|
|
60
|
+
/** Remediation for a host MCP config that carries no Bridge entry at all. */
|
|
61
|
+
export const CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION = "no supported host MCP config carries a Bridge entry — run " +
|
|
62
|
+
`\`npx ${MCP_PACKAGE_NAME} install\` to register one, then re-run \`install conductor\`.`;
|
|
63
|
+
/** Remediation for an entry that exists but lacks the conductor token. */
|
|
64
|
+
export const CONDUCTOR_PROFILE_TOKEN_REMEDIATION = "re-run `install conductor` and accept the tool-visibility consent; the conductor " +
|
|
65
|
+
"tools become visible after the MCP client is restarted.";
|
|
66
|
+
/** Remediation surfaced whenever the managed conductor hooks are not usable. */
|
|
67
|
+
export const CONDUCTOR_HOOK_TARGET_REMEDIATION = "re-run `install conductor` and accept the local-observability consent, or run " +
|
|
68
|
+
"`conductor install-git-hooks` directly.";
|
|
69
|
+
/** Remediation surfaced when the ledger's native binding does not load. */
|
|
70
|
+
export const CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION = "`better-sqlite3` is an optionalDependency npm silently skips on a build failure — " +
|
|
71
|
+
"reinstall it for this Node runtime to restore local conductor observability.";
|
|
72
|
+
/** Remediation surfaced whenever no healthy generated service unit is detected. */
|
|
73
|
+
export const EXECUTOR_INSTALL_SERVICE_REMEDIATION = "generate a persistent unit with `install conductor --executor-id <id>` or " +
|
|
74
|
+
"`executor install-service` (or run the executor by hand) — " +
|
|
75
|
+
`see ${CONDUCTOR_OPERATOR_RUNBOOK_POINTER}.`;
|
|
76
|
+
/** Remediation for a unit that exists but whose service is not running. */
|
|
77
|
+
export const EXECUTOR_SERVICE_START_REMEDIATION = "start the service (re-run `install conductor` and accept the start consent, or run the " +
|
|
78
|
+
`printed launchctl/systemctl command by hand) — see ${CONDUCTOR_OPERATOR_RUNBOOK_POINTER}.`;
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Section builders
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
export function supervisorSection(readiness) {
|
|
83
|
+
const s = readiness.supervisor;
|
|
84
|
+
const sections = [];
|
|
85
|
+
if (!s.setup_present || !s.config_present) {
|
|
86
|
+
const missing = [
|
|
87
|
+
!s.setup_present ? "setup" : null,
|
|
88
|
+
!s.config_present ? "config" : null,
|
|
89
|
+
].filter(Boolean).join(" and ");
|
|
90
|
+
sections.push({
|
|
91
|
+
id: "supervisor-configuration",
|
|
92
|
+
label: "Supervisor configuration",
|
|
93
|
+
status: "degraded",
|
|
94
|
+
detail: `project-default supervisor ${missing} absent`,
|
|
95
|
+
remediation: "bootstrap writes the safe project-default posture.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
sections.push({
|
|
100
|
+
id: "supervisor-configuration",
|
|
101
|
+
label: "Supervisor configuration",
|
|
102
|
+
status: "ok",
|
|
103
|
+
detail: `setup=${s.setup_source}, config=${s.config_source}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Deliberately its OWN section, not folded into the one above: "configured but
|
|
107
|
+
// unsafe" and "not configured" drive different operator actions, and an empty
|
|
108
|
+
// required-check list makes the CI gate pass unconditionally rather than fail.
|
|
109
|
+
sections.push(s.required_checks_empty
|
|
110
|
+
? {
|
|
111
|
+
id: "supervisor-required-checks",
|
|
112
|
+
label: "Supervisor required CI checks",
|
|
113
|
+
status: "degraded",
|
|
114
|
+
detail: "required-check list is EMPTY — the done gate would pass unconditionally",
|
|
115
|
+
remediation: "bootstrap writes a non-empty required-check list into done_gate_config.",
|
|
116
|
+
}
|
|
117
|
+
: {
|
|
118
|
+
id: "supervisor-required-checks",
|
|
119
|
+
label: "Supervisor required CI checks",
|
|
120
|
+
status: "ok",
|
|
121
|
+
detail: `${s.required_checks_count} required check(s) configured`,
|
|
122
|
+
});
|
|
123
|
+
// BAPI-694 (R5) — its OWN section, and WARN-ONLY.
|
|
124
|
+
//
|
|
125
|
+
// `degraded`, never `fatal`: a divergence between the code_review gate's
|
|
126
|
+
// signal and the merge path's may be exactly what the operator intended, and
|
|
127
|
+
// blocking an install over a legitimate posture would be worse than the
|
|
128
|
+
// silence this replaces. It also changes no gate: both settings are reported,
|
|
129
|
+
// neither is coerced into the other's vocabulary.
|
|
130
|
+
//
|
|
131
|
+
// Absent (`null`) means the server predates BAPI-694 and cannot report
|
|
132
|
+
// alignment, so no section is emitted at all — an advisory addition must not
|
|
133
|
+
// manufacture a finding out of a server that never answered.
|
|
134
|
+
const alignment = s.review_policy_alignment;
|
|
135
|
+
if (alignment !== null) {
|
|
136
|
+
if (alignment.status === "divergent" || alignment.status === "invalid") {
|
|
137
|
+
sections.push({
|
|
138
|
+
id: "supervisor-review-alignment",
|
|
139
|
+
label: "Supervisor review-policy alignment",
|
|
140
|
+
status: "degraded",
|
|
141
|
+
detail: alignment.explanation,
|
|
142
|
+
remediation: alignment.status === "invalid"
|
|
143
|
+
? "fix the done gate's review_state condition — review configuration " +
|
|
144
|
+
"exists but cannot be read, so the merge path refuses rather than merges."
|
|
145
|
+
: "set the repository review-policy default and the done gate's " +
|
|
146
|
+
"review_state condition to the same signal, or confirm the difference " +
|
|
147
|
+
`is intended — see ${CONDUCTOR_OPERATOR_RUNBOOK_POINTER}.`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// `aligned` and `not_configured` are both non-warning states, and are
|
|
152
|
+
// reported separately: "they agree" and "neither is set" are different
|
|
153
|
+
// facts, and collapsing them would imply a stored default that is absent.
|
|
154
|
+
sections.push({
|
|
155
|
+
id: "supervisor-review-alignment",
|
|
156
|
+
label: "Supervisor review-policy alignment",
|
|
157
|
+
status: "ok",
|
|
158
|
+
detail: alignment.explanation,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return sections;
|
|
163
|
+
}
|
|
164
|
+
export function githubCredentialsSection(readiness) {
|
|
165
|
+
const g = readiness.github;
|
|
166
|
+
if (g.credentials_complete) {
|
|
167
|
+
return {
|
|
168
|
+
id: "github-credentials",
|
|
169
|
+
label: "GitHub App credentials",
|
|
170
|
+
status: "ok",
|
|
171
|
+
detail: "owner, repository id, and installation id all resolved",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const missing = [
|
|
175
|
+
!g.owner_resolved ? "owner" : null,
|
|
176
|
+
!g.repo_id_resolved ? "repository id" : null,
|
|
177
|
+
!g.installation_id_resolved ? "installation id" : null,
|
|
178
|
+
].filter(Boolean).join(", ");
|
|
179
|
+
return {
|
|
180
|
+
id: "github-credentials",
|
|
181
|
+
label: "GitHub App credentials",
|
|
182
|
+
status: "degraded",
|
|
183
|
+
detail: g.credentials_readable
|
|
184
|
+
? `incomplete: ${missing} unresolved`
|
|
185
|
+
: "no GitHub credential row for this repository",
|
|
186
|
+
remediation: "connect GitHub from the Bridge setup UI (`install-bridge connect-github`).",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function githubActionsSection(readiness) {
|
|
190
|
+
const g = readiness.github;
|
|
191
|
+
if (g.actions_write) {
|
|
192
|
+
return {
|
|
193
|
+
id: "github-actions-permission",
|
|
194
|
+
label: "GitHub App actions permission",
|
|
195
|
+
status: "ok",
|
|
196
|
+
detail: "actions: write",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (!g.actions_probe_succeeded) {
|
|
200
|
+
// "Could not be checked" is NOT the same finding as "confirmedly missing" —
|
|
201
|
+
// the first needs a retry, the second needs a permission grant.
|
|
202
|
+
return {
|
|
203
|
+
id: "github-actions-permission",
|
|
204
|
+
label: "GitHub App actions permission",
|
|
205
|
+
status: "degraded",
|
|
206
|
+
detail: "permission could not be checked",
|
|
207
|
+
remediation: "re-run once credentials resolve; the workflow rerun lane fails open on 403 until this reads `write`.",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
id: "github-actions-permission",
|
|
212
|
+
label: "GitHub App actions permission",
|
|
213
|
+
status: "degraded",
|
|
214
|
+
detail: `actions: ${g.actions_permission_level}`,
|
|
215
|
+
remediation: "grant the GitHub App `actions: write`; the conductor's workflow rerun lane fails open on 403 without it.",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export function reconcilerSection(readiness) {
|
|
219
|
+
const r = readiness.reconciler;
|
|
220
|
+
if (!r.liveness_readable) {
|
|
221
|
+
// BAPI-1055: unreadable is NOT the same finding as stale, and it had no
|
|
222
|
+
// remediation at all — so a report could name a gap with no next step.
|
|
223
|
+
return {
|
|
224
|
+
id: "reconciler-liveness",
|
|
225
|
+
label: "Reconciler tick liveness",
|
|
226
|
+
status: "degraded",
|
|
227
|
+
detail: "liveness could not be read",
|
|
228
|
+
remediation: "retry once the Bridge API is reachable; reconciler liveness is unknown here, " +
|
|
229
|
+
"which is not the same as confirmed-stale.",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (!r.stale) {
|
|
233
|
+
return {
|
|
234
|
+
id: "reconciler-liveness",
|
|
235
|
+
label: "Reconciler tick liveness",
|
|
236
|
+
status: "ok",
|
|
237
|
+
detail: `last tick ${r.last_tick_age_seconds}s ago across ${r.active_run_count} active run(s)`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
id: "reconciler-liveness",
|
|
242
|
+
label: "Reconciler tick liveness",
|
|
243
|
+
status: "degraded",
|
|
244
|
+
detail: r.last_tick_at === null
|
|
245
|
+
? "no reconciler tick recorded for this repository"
|
|
246
|
+
: `last tick ${r.last_tick_age_seconds}s ago (threshold ${readiness.thresholds.reconciler_stale_after_seconds}s)`,
|
|
247
|
+
remediation: "start the reconciler (`conductor epic-tick` schedule) for this repository.",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export function workflowSection(presence, reviewPolicySource, driftDetail) {
|
|
251
|
+
if (presence === "present") {
|
|
252
|
+
return {
|
|
253
|
+
id: "claude-review-workflow",
|
|
254
|
+
label: "claude-review workflow",
|
|
255
|
+
status: "ok",
|
|
256
|
+
// BAPI-941: when the lineage comparison ran but could not be completed,
|
|
257
|
+
// that uncertainty rides on the SAME informational detail line rather than
|
|
258
|
+
// becoming a status of its own. The section stays `ok`: the workflow is
|
|
259
|
+
// installed and readable, which is all this section has ever asserted.
|
|
260
|
+
detail: driftDetail
|
|
261
|
+
? `.github/workflows/claude-review.yml present — ${driftDetail}`
|
|
262
|
+
: ".github/workflows/claude-review.yml present",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// BAPI-941: present but stale. Reported through the SAME section, status
|
|
266
|
+
// vocabulary, and detail/remediation shape as every other degraded state — no
|
|
267
|
+
// new health component, no bespoke styling, and no color dependence: the
|
|
268
|
+
// detail's own wording carries the warning. The file IS installed, so this is
|
|
269
|
+
// never `absent`, and the policy-applicability branch below does not apply
|
|
270
|
+
// (a stale workflow only matters to a policy that consumes its verdict, and
|
|
271
|
+
// the caller only classifies drift when it does).
|
|
272
|
+
if (presence === "drifted") {
|
|
273
|
+
return {
|
|
274
|
+
id: "claude-review-workflow",
|
|
275
|
+
label: "claude-review workflow",
|
|
276
|
+
status: "degraded",
|
|
277
|
+
detail: driftDetail ??
|
|
278
|
+
".github/workflows/claude-review.yml differs from the repository default branch",
|
|
279
|
+
remediation: CLAUDE_REVIEW_WORKFLOW_DRIFT_REMEDIATION,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Applicability is policy-dependent: a run whose review signal is GitHub's own
|
|
283
|
+
// review decision does not need the Claude workflow at all, so its absence is
|
|
284
|
+
// not a gap. Reporting it as degraded there would train operators to ignore
|
|
285
|
+
// this section.
|
|
286
|
+
if (reviewPolicySource === "native_review_decision" || reviewPolicySource === "none") {
|
|
287
|
+
return {
|
|
288
|
+
id: "claude-review-workflow",
|
|
289
|
+
label: "claude-review workflow",
|
|
290
|
+
status: "ok",
|
|
291
|
+
detail: `not applicable for review policy '${reviewPolicySource}'`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
id: "claude-review-workflow",
|
|
296
|
+
label: "claude-review workflow",
|
|
297
|
+
status: "degraded",
|
|
298
|
+
detail: presence === "unreadable"
|
|
299
|
+
? ".github/workflows/claude-review.yml could not be read"
|
|
300
|
+
: ".github/workflows/claude-review.yml absent",
|
|
301
|
+
remediation: "the selected review policy consumes the sticky verdict this workflow emits; install the template.",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Real detection of BAPI-688 generated service units, composed from
|
|
306
|
+
* `collectExecutorServiceDiagnostics`. Never `fatal` (a missing unit is a
|
|
307
|
+
* repairable gap, and blocking every install on it would be wrong) and never
|
|
308
|
+
* omitted (the flow must not read as fully provisioned by silence).
|
|
309
|
+
*
|
|
310
|
+
* This section reports UNIT PRESENCE and nothing else (BAPI-779). Whether the
|
|
311
|
+
* service manager is actually running the unit is
|
|
312
|
+
* {@link executorServiceStateSection}, and whether the SERVER has observed an
|
|
313
|
+
* executor is {@link executorObservationSection}. Three separate facts, three
|
|
314
|
+
* separate sections — a healthy unit here proves only that a file exists.
|
|
315
|
+
*/
|
|
316
|
+
export function executorProvisioningSection(diagnostics) {
|
|
317
|
+
const base = { id: "executor-provisioning", label: "Executor provisioning" };
|
|
318
|
+
if (diagnostics === null) {
|
|
319
|
+
return {
|
|
320
|
+
...base,
|
|
321
|
+
status: "degraded",
|
|
322
|
+
detail: "executor service-unit diagnostics could not be collected",
|
|
323
|
+
remediation: EXECUTOR_INSTALL_SERVICE_REMEDIATION,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (diagnostics.status === "skipped") {
|
|
327
|
+
// The collector's `reason` is already sanitized and secret-free (fixed
|
|
328
|
+
// convention paths only): "no units found", unsupported platform, or the
|
|
329
|
+
// Windows manual-Task-Scheduler case.
|
|
330
|
+
return {
|
|
331
|
+
...base,
|
|
332
|
+
status: "degraded",
|
|
333
|
+
detail: diagnostics.reason,
|
|
334
|
+
remediation: EXECUTOR_INSTALL_SERVICE_REMEDIATION,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const unhealthy = diagnostics.units.filter((unit) => unit.warnings.length > 0 || unit.credentials.some((cred) => !cred.resolved));
|
|
338
|
+
const ids = diagnostics.units.map((unit) => unit.executorId).join(", ");
|
|
339
|
+
if (unhealthy.length === 0 && diagnostics.warnings.length === 0) {
|
|
340
|
+
return {
|
|
341
|
+
...base,
|
|
342
|
+
status: "ok",
|
|
343
|
+
detail: `${diagnostics.units.length} generated service unit(s) found (${ids}); ` +
|
|
344
|
+
"unit presence only — see the service-state and observation sections",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
...base,
|
|
349
|
+
status: "degraded",
|
|
350
|
+
detail: `${diagnostics.units.length} generated service unit(s) found (${ids}), but ` +
|
|
351
|
+
`${unhealthy.length} unit(s) carry warnings or unresolved credentials — see the ` +
|
|
352
|
+
"embedded executor-provisioning report",
|
|
353
|
+
remediation: EXECUTOR_INSTALL_SERVICE_REMEDIATION,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Local service-manager state for the units that were discovered (BAPI-779).
|
|
358
|
+
*
|
|
359
|
+
* Never `fatal`: this is a diagnostic, and an unreadable service manager is a
|
|
360
|
+
* gap in the *report*, not a broken install. `unknown` is deliberately not
|
|
361
|
+
* collapsed into `not-found` — "the probe could not answer" and "the service
|
|
362
|
+
* manager has no such unit" lead to different operator actions.
|
|
363
|
+
*/
|
|
364
|
+
export function executorServiceStateSection(observations, unitsDiscovered, inspectorAvailable) {
|
|
365
|
+
const base = { id: "executor-service-state", label: "Executor service state" };
|
|
366
|
+
if (!unitsDiscovered) {
|
|
367
|
+
return {
|
|
368
|
+
...base,
|
|
369
|
+
status: "degraded",
|
|
370
|
+
detail: "no generated unit to inspect, so no service state was collected",
|
|
371
|
+
remediation: EXECUTOR_INSTALL_SERVICE_REMEDIATION,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (!inspectorAvailable || observations.length === 0) {
|
|
375
|
+
return {
|
|
376
|
+
...base,
|
|
377
|
+
status: "degraded",
|
|
378
|
+
detail: "local service state could not be collected on this host",
|
|
379
|
+
remediation: EXECUTOR_SERVICE_START_REMEDIATION,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// Deterministic ordering by executor id so two runs of the same host render
|
|
383
|
+
// identically regardless of enumeration order.
|
|
384
|
+
const rendered = [...observations]
|
|
385
|
+
.sort((a, b) => (a.executorId < b.executorId ? -1 : a.executorId > b.executorId ? 1 : 0))
|
|
386
|
+
.map((o) => `${o.executorId}=${o.state}`)
|
|
387
|
+
.join(", ");
|
|
388
|
+
const allActive = observations.every((o) => o.state === "active");
|
|
389
|
+
return allActive
|
|
390
|
+
? { ...base, status: "ok", detail: `service state: ${rendered}` }
|
|
391
|
+
: {
|
|
392
|
+
...base,
|
|
393
|
+
status: "degraded",
|
|
394
|
+
detail: `service state: ${rendered}`,
|
|
395
|
+
remediation: EXECUTOR_SERVICE_START_REMEDIATION,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The AUTHORITATIVE executor fact: what the server has observed (BAPI-779).
|
|
400
|
+
*
|
|
401
|
+
* This is the only section permitted to say `executor_ready`, and only for
|
|
402
|
+
* `readiness.executor.ready === true`. The three not-ready shapes are kept
|
|
403
|
+
* distinct because they mean different things: `null` is "never observed" (the
|
|
404
|
+
* executor has not claimed yet), `false` is "observed but stale" (it claimed
|
|
405
|
+
* once and went quiet), and an unreadable readiness payload is "unknown" (the
|
|
406
|
+
* doctor cannot say). Collapsing them would let a stale executor read like a
|
|
407
|
+
* fresh install, or vice versa.
|
|
408
|
+
*/
|
|
409
|
+
export function executorObservationSection(readiness) {
|
|
410
|
+
const base = { id: "executor-observation", label: "Executor server observation" };
|
|
411
|
+
if (readiness === null || !readiness.executor.liveness_readable) {
|
|
412
|
+
return {
|
|
413
|
+
...base,
|
|
414
|
+
status: "degraded",
|
|
415
|
+
detail: "server executor observation is unknown (readiness could not be read)",
|
|
416
|
+
remediation: "retry once the Bridge API is reachable; no readiness claim is made from local state.",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const e = readiness.executor;
|
|
420
|
+
const seen = e.last_seen_at === null
|
|
421
|
+
? "never seen"
|
|
422
|
+
: `last seen ${e.last_seen_at} (${e.last_seen_age_seconds}s ago)`;
|
|
423
|
+
if (e.ready === true) {
|
|
424
|
+
return { ...base, status: "ok", detail: `executor_ready — ${seen}` };
|
|
425
|
+
}
|
|
426
|
+
if (e.ready === false) {
|
|
427
|
+
return {
|
|
428
|
+
...base,
|
|
429
|
+
status: "degraded",
|
|
430
|
+
detail: `observed but stale/not ready — ${seen}`,
|
|
431
|
+
remediation: EXECUTOR_SERVICE_START_REMEDIATION,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
...base,
|
|
436
|
+
status: "degraded",
|
|
437
|
+
detail: `never observed by the server — ${seen}`,
|
|
438
|
+
remediation: EXECUTOR_SERVICE_START_REMEDIATION,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Local conductor capability sections (BAPI-775)
|
|
443
|
+
//
|
|
444
|
+
// Three INDEPENDENT read-only checks — host profile token, managed hook targets,
|
|
445
|
+
// native ledger loadability. All three are non-fatal by construction: a missing
|
|
446
|
+
// local conductor capability is a repairable gap, and letting one flip
|
|
447
|
+
// `conductorInstallDoctorExitCode` to 1 would make every ordinary install fail
|
|
448
|
+
// on a capability it never asked for.
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
/**
|
|
451
|
+
* Whether this host looks like a conductor context.
|
|
452
|
+
*
|
|
453
|
+
* Composed from the two facts already collected: the resolved MCP-profile
|
|
454
|
+
* inspection (`BAPI_CONDUCTOR_ENABLED` or a registered epic-tick schedule) and
|
|
455
|
+
* the read-only host token inspection. Outside such a context a missing local
|
|
456
|
+
* capability is ADVISORY — reporting it as a gap would train operators of
|
|
457
|
+
* ordinary repositories to ignore this whole part of the report.
|
|
458
|
+
*/
|
|
459
|
+
export function detectConductorContext(legacyConductor, profile) {
|
|
460
|
+
// Read defensively: an INJECTED report sentinel (and the installer's own
|
|
461
|
+
// degraded-doctor fallback) may populate only the fields a test cares about,
|
|
462
|
+
// and a doctor that throws on a partially-built report would abort the run it
|
|
463
|
+
// exists to diagnose. Same posture as the defensive legacy formatters below.
|
|
464
|
+
return (legacyConductor?.mcp_profile?.conductor_context_detected === true ||
|
|
465
|
+
profile?.tokenPresent === true);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Host MCP profile-token presence.
|
|
469
|
+
*
|
|
470
|
+
* Reports CONFIG IDENTIFIERS AND BOOLEANS ONLY — never an entry, an `env`
|
|
471
|
+
* object, a command line, an API key, or an unrelated profile token — and names
|
|
472
|
+
* only the three configs the merge path can write, so nothing here can imply
|
|
473
|
+
* that Codex or Copilot configuration was inspected or changed.
|
|
474
|
+
*/
|
|
475
|
+
export function conductorProfileTokenSection(profile, conductorContext) {
|
|
476
|
+
const base = { id: "conductor-profile-token", label: "MCP conductor profile token" };
|
|
477
|
+
if (profile === null) {
|
|
478
|
+
return {
|
|
479
|
+
...base,
|
|
480
|
+
status: "degraded",
|
|
481
|
+
detail: "the host MCP profile token could not be inspected",
|
|
482
|
+
remediation: CONDUCTOR_PROFILE_TOKEN_REMEDIATION,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const carrying = profile.configs
|
|
486
|
+
.filter((c) => c.state === "token-present")
|
|
487
|
+
.map((c) => c.path);
|
|
488
|
+
const scope = "inspected .mcp.json, .vscode/mcp.json, .cursor/mcp.json only " +
|
|
489
|
+
"(Codex and Copilot host configuration is not inspected)";
|
|
490
|
+
if (carrying.length > 0) {
|
|
491
|
+
return {
|
|
492
|
+
...base,
|
|
493
|
+
status: "ok",
|
|
494
|
+
detail: `\`${profile.token}\` present in ${carrying.join(", ")}; ${scope}`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// "No entry anywhere" and "an entry without the token" are different findings
|
|
498
|
+
// with different next steps, so they never collapse into one remediation.
|
|
499
|
+
const detail = profile.bridgeEntryPresent
|
|
500
|
+
? `no supported host config carries the \`${profile.token}\` token; ${scope}`
|
|
501
|
+
: `no supported host config carries a Bridge entry; ${scope}`;
|
|
502
|
+
const remediation = profile.bridgeEntryPresent
|
|
503
|
+
? CONDUCTOR_PROFILE_TOKEN_REMEDIATION
|
|
504
|
+
: CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION;
|
|
505
|
+
return conductorContext
|
|
506
|
+
? { ...base, status: "degraded", detail, remediation }
|
|
507
|
+
: { ...base, status: "ok", detail: `advisory — ${detail}`, remediation };
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Managed conductor hook targets, DERIVED from `legacyConductor.git_hooks`.
|
|
511
|
+
*
|
|
512
|
+
* Derived, never re-probed: a second parse of the same hook files is a second
|
|
513
|
+
* chance to disagree with the report embedded right below this section. Healthy
|
|
514
|
+
* requires each managed hook to exist, carry the managed block, and point at an
|
|
515
|
+
* embedded binary that stats as a regular file — a hook whose target vanished is
|
|
516
|
+
* a dead hook, and reporting it as installed is the exact failure BAPI-772 made
|
|
517
|
+
* structurally impossible at INSTALL time but which can still arise later.
|
|
518
|
+
*
|
|
519
|
+
* Renders hook NAME and boolean state only. The embedded absolute binary path is
|
|
520
|
+
* read for the file check and never copied into this section.
|
|
521
|
+
*/
|
|
522
|
+
export function conductorHookTargetsSection(legacyConductor, conductorContext) {
|
|
523
|
+
const base = { id: "conductor-hook-targets", label: "Conductor hook targets" };
|
|
524
|
+
// Nullish, not `=== null`: an injected builder may resolve `undefined`, and a
|
|
525
|
+
// doctor that throws on it would abort the run it exists to diagnose.
|
|
526
|
+
if (legacyConductor === null || legacyConductor === undefined) {
|
|
527
|
+
return {
|
|
528
|
+
...base,
|
|
529
|
+
status: "degraded",
|
|
530
|
+
detail: "hook targets could not be derived (the conductor doctor was not collected)",
|
|
531
|
+
remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
// Defensive for the same reason as {@link detectConductorContext}: an injected
|
|
535
|
+
// sentinel need not carry every legacy field.
|
|
536
|
+
const hooks = legacyConductor.git_hooks;
|
|
537
|
+
if (hooks === undefined) {
|
|
538
|
+
return {
|
|
539
|
+
...base,
|
|
540
|
+
status: "degraded",
|
|
541
|
+
detail: "hook targets could not be derived (the conductor doctor reported no hook state)",
|
|
542
|
+
remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
if (!hooks.is_worktree) {
|
|
546
|
+
return {
|
|
547
|
+
...base,
|
|
548
|
+
status: "ok",
|
|
549
|
+
detail: "advisory — not a git worktree, so no managed conductor hooks apply",
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const usable = (name) => {
|
|
553
|
+
const hook = (hooks.hooks ?? []).find((h) => h.name === name);
|
|
554
|
+
return (hook !== undefined &&
|
|
555
|
+
hook.exists &&
|
|
556
|
+
hook.managed_block_present &&
|
|
557
|
+
hook.embedded_bin_is_file);
|
|
558
|
+
};
|
|
559
|
+
const rendered = MANAGED_HOOK_NAMES.map((name) => `${name}=${usable(name)}`).join(", ");
|
|
560
|
+
const allUsable = MANAGED_HOOK_NAMES.every((name) => usable(name));
|
|
561
|
+
if (allUsable) {
|
|
562
|
+
return { ...base, status: "ok", detail: `managed hooks usable: ${rendered}` };
|
|
563
|
+
}
|
|
564
|
+
const detail = `managed hooks usable: ${rendered}`;
|
|
565
|
+
return conductorContext
|
|
566
|
+
? { ...base, status: "degraded", detail, remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION }
|
|
567
|
+
: {
|
|
568
|
+
...base,
|
|
569
|
+
status: "ok",
|
|
570
|
+
detail: `advisory — ${detail}`,
|
|
571
|
+
remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Native ledger loadability, DERIVED from `legacyConductor.native_ledger`.
|
|
576
|
+
*
|
|
577
|
+
* No parallel `better-sqlite3` check: the mapping from an inspection to
|
|
578
|
+
* available/degraded lives in one place ({@link describeNativeLedgerAvailability}),
|
|
579
|
+
* so this section and the conductor doctor can never disagree about whether the
|
|
580
|
+
* binding loads. A degraded result carries the module name, the ABI, and the
|
|
581
|
+
* sanitized failure kind, because the common cause is npm silently skipping an
|
|
582
|
+
* optionalDependency build and that is undiagnosable without the ABI.
|
|
583
|
+
*/
|
|
584
|
+
export function conductorLedgerLoadabilitySection(legacyConductor) {
|
|
585
|
+
const base = {
|
|
586
|
+
id: "conductor-ledger-loadability",
|
|
587
|
+
label: "Conductor ledger loadability",
|
|
588
|
+
};
|
|
589
|
+
if (legacyConductor === null || legacyConductor === undefined) {
|
|
590
|
+
return {
|
|
591
|
+
...base,
|
|
592
|
+
status: "degraded",
|
|
593
|
+
detail: "ledger loadability could not be derived (the conductor doctor was not collected)",
|
|
594
|
+
remediation: CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const inspection = legacyConductor.native_ledger;
|
|
598
|
+
if (inspection === undefined) {
|
|
599
|
+
return {
|
|
600
|
+
...base,
|
|
601
|
+
status: "degraded",
|
|
602
|
+
detail: "ledger loadability could not be derived (the conductor doctor reported no native-ledger state)",
|
|
603
|
+
remediation: CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
const availability = describeNativeLedgerAvailability(inspection);
|
|
607
|
+
if (availability.status === "available") {
|
|
608
|
+
return { ...base, status: "ok", detail: "ledger available — the native binding loads" };
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
...base,
|
|
612
|
+
status: "degraded",
|
|
613
|
+
detail: `ledger degraded — ${availability.module} did not load ` +
|
|
614
|
+
`(NODE_MODULE_VERSION ${availability.nodeModulesAbi}, failure: ${availability.failureKind})`,
|
|
615
|
+
remediation: CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Runner
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
/**
|
|
622
|
+
* BAPI-941: the workflow's local state AND, when the seam is supplied and the
|
|
623
|
+
* file is genuinely present, its lineage relative to the default branch. The
|
|
624
|
+
* drift detail is bounded text from the shared classifier — never git stderr and
|
|
625
|
+
* never file content.
|
|
626
|
+
*/
|
|
627
|
+
export async function inspectWorkflowPresence(readWorkflowFile, classifyWorkflowDrift) {
|
|
628
|
+
const local = await inspectWorkflowLocalPresence(readWorkflowFile);
|
|
629
|
+
if (local !== "present" || !classifyWorkflowDrift)
|
|
630
|
+
return { presence: local };
|
|
631
|
+
try {
|
|
632
|
+
const classification = await classifyWorkflowDrift();
|
|
633
|
+
if (classification.state === "drifted") {
|
|
634
|
+
return {
|
|
635
|
+
presence: "drifted",
|
|
636
|
+
driftDetail: summarizeClaudeReviewWorkflowDrift(classification),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (classification.state === "unverified") {
|
|
640
|
+
// Inconclusive, so the presence state stays `present` and non-failing. The
|
|
641
|
+
// uncertainty is still reported, through the existing detail line rather
|
|
642
|
+
// than by inventing a failure the probe did not establish.
|
|
643
|
+
return {
|
|
644
|
+
presence: "present",
|
|
645
|
+
driftDetail: summarizeClaudeReviewWorkflowDrift(classification),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return { presence: "present" };
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
// A thrown probe is an unavailable comparison, not a fault in the workflow.
|
|
652
|
+
return { presence: "present" };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
export async function inspectWorkflowLocalPresence(readWorkflowFile) {
|
|
656
|
+
try {
|
|
657
|
+
await readWorkflowFile();
|
|
658
|
+
return "present";
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
// ENOENT is the ordinary "absent" case; anything else is "unreadable". The
|
|
662
|
+
// error's message is never surfaced — it can echo an absolute path.
|
|
663
|
+
const code = err?.code;
|
|
664
|
+
return code === "ENOENT" ? "absent" : "unreadable";
|
|
665
|
+
}
|
|
666
|
+
}
|