@bridge_gpt/mcp-server 0.2.52 → 0.2.54

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 (52) hide show
  1. package/README.md +121 -15
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +7 -5
  7. package/build/conductor/bridge-api-client.js +97 -8
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/doctor.js +428 -5
  10. package/build/conductor/epic-runtime.js +133 -97
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/readiness-cli.js +152 -0
  13. package/build/conductor/readiness-sections.js +666 -0
  14. package/build/conductor/readiness.js +795 -0
  15. package/build/conductor/run-branch.js +137 -0
  16. package/build/conductor/test-run-branch-vectors.js +165 -0
  17. package/build/conductor/tools.js +56 -3
  18. package/build/conductor-bin.js +21 -17
  19. package/build/doctor.js +68 -1
  20. package/build/drive-epic.js +287 -51
  21. package/build/executor/claim-scope.js +104 -0
  22. package/build/executor/cli.js +14 -25
  23. package/build/executor/env-file-guard.js +82 -3
  24. package/build/executor/job-runner.js +60 -0
  25. package/build/index.js +4496 -4697
  26. package/build/install-doctor.js +154 -2
  27. package/build/local-artifact-storage.js +130 -0
  28. package/build/pipelines.generated.js +17 -10
  29. package/build/plane/alembic-head.js +40 -11
  30. package/build/plane/build-freshness.js +22 -11
  31. package/build/plane/cli.js +285 -36
  32. package/build/plane/manifest.js +209 -1
  33. package/build/plane/member-roster.js +70 -0
  34. package/build/plane/preflight.js +363 -48
  35. package/build/plane/shutdown.js +14 -1
  36. package/build/plane/status.js +35 -1
  37. package/build/plane/supervisor.js +546 -164
  38. package/build/plane/types.js +61 -2
  39. package/build/polling-policy.js +72 -0
  40. package/build/readiness-check.js +412 -0
  41. package/build/readme.generated.js +1 -1
  42. package/build/review-generation.js +219 -0
  43. package/build/run-unit-tests-launcher.js +5 -0
  44. package/build/setup-epic.js +514 -23
  45. package/build/ticket-key-utils.js +4 -3
  46. package/build/ticket-review-artifact-gate.js +461 -0
  47. package/build/upgrade-cli.js +5 -26
  48. package/build/version.generated.js +3 -3
  49. package/docs/install/mcp-tool-integrations.md +23 -1
  50. package/package.json +2 -2
  51. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
  52. package/pipelines/review-ticket.json +17 -4
@@ -43,8 +43,17 @@
43
43
  * but it makes the failure legible ("schema version is not supported" instead
44
44
  * of what reads like a corrupt file), and it pairs with the member-scoped
45
45
  * rejection in `manifest.ts` so the next member added never reproduces it.
46
+ *
47
+ * Bumped to 3 by BAPI-1102, which added the two-phase `lifecycle` field and the
48
+ * `laneScope` a phase-two request carries. Same reasoning, now for the RECORD
49
+ * shape rather than the member set: `parsePlaneManifest` rejects an unsupported
50
+ * top-level key outright, so a v3 manifest read by a v2 binary would be rejected
51
+ * as "unsupported field 'lifecycle'" — a message about a field, for what is
52
+ * really a version gap. Reading v1 and v2 stays supported and a manifest without
53
+ * `lifecycle` is interpreted as a fully-up single-phase plane, which is exactly
54
+ * what an older build wrote.
46
55
  */
47
- export const PLANE_MANIFEST_SCHEMA_VERSION = 2;
56
+ export const PLANE_MANIFEST_SCHEMA_VERSION = 3;
48
57
  /**
49
58
  * Every schema version this build can READ.
50
59
  *
@@ -54,7 +63,7 @@ export const PLANE_MANIFEST_SCHEMA_VERSION = 2;
54
63
  * legible, inflicted from the other side. Accepting 1 costs nothing: the two
55
64
  * versions differ only in whether `observer` may appear among the members.
56
65
  */
57
- export const PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS = [1, 2];
66
+ export const PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3];
58
67
  /** Runtime artifact directory, relative to the repository root. */
59
68
  export const PLANE_RUNTIME_DIR = ".bridge/plane";
60
69
  /** Manifest filename inside {@link PLANE_RUNTIME_DIR}. */
@@ -143,6 +152,42 @@ export const PLANE_OBSERVER_MODE_VALUE = "true";
143
152
  export const PLANE_OBSERVER_CHANNEL_TYPE_ENV = "CONDUCTOR_DEADMAN_CHANNEL_TYPE";
144
153
  /** Env var naming the env var that holds the observer's destination URL. */
145
154
  export const PLANE_OBSERVER_DESTINATION_ENV = "CONDUCTOR_DEADMAN_DESTINATION_REF";
155
+ /** Build a {@link PlaneCheckReport} for a check that ran and passed. */
156
+ export function planeCheckPassed(check, label, detail) {
157
+ return {
158
+ diagnostic: null,
159
+ outcomes: [{ check, id: check, label, status: "pass", ...(detail ? { detail } : {}) }],
160
+ };
161
+ }
162
+ /**
163
+ * Build a {@link PlaneCheckReport} from a diagnostic plus its structured fix.
164
+ *
165
+ * The severity is the single source of the canonical status — `blocking` is
166
+ * `fail`, `warning` is `warn` — so the two can never disagree about the same
167
+ * finding.
168
+ */
169
+ export function planeCheckFinding(diagnostic, label, detail) {
170
+ return {
171
+ diagnostic,
172
+ outcomes: [
173
+ {
174
+ check: diagnostic.check,
175
+ id: diagnostic.check,
176
+ label,
177
+ status: diagnostic.severity === "blocking" ? "fail" : "warn",
178
+ detail,
179
+ ...(diagnostic.remediation ? { remediation: diagnostic.remediation } : {}),
180
+ },
181
+ ],
182
+ };
183
+ }
184
+ /** Build a {@link PlaneCheckReport} for a check that was never reached. */
185
+ export function planeCheckSkipped(check, label, detail) {
186
+ return {
187
+ diagnostic: null,
188
+ outcomes: [{ check, id: check, label, status: "skip", detail }],
189
+ };
190
+ }
146
191
  /**
147
192
  * Every value {@link PlaneHeartbeatComponent} may take, for runtime guards.
148
193
  *
@@ -164,3 +209,17 @@ export const PLANE_HEARTBEAT_HEALTH_STATES = [
164
209
  "never_seen",
165
210
  "unknown",
166
211
  ];
212
+ /**
213
+ * Every lifecycle phase, in occurrence order, with the one terminal failure
214
+ * phase last. Order carries no meaning to the transition guard — which is
215
+ * expected/next, never "forward only" — so `lanes-failed` sitting after `ready`
216
+ * says it is not part of the progression, not that it follows readiness.
217
+ */
218
+ export const PLANE_LIFECYCLE_PHASES = [
219
+ "control-plane-starting",
220
+ "control-plane-ready",
221
+ "lanes-starting",
222
+ "lanes-ready",
223
+ "ready",
224
+ "lanes-failed",
225
+ ];
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The ONE polling policy shared by every wait in this process (BAPI-1104, R77).
3
+ *
4
+ * `pollForResult` in `index.ts` has carried these numbers since BAPI-659, and the
5
+ * bounded-wait primitive added by this ticket needs the same tolerance for the
6
+ * same reasons. Two independent copies would drift the moment either is tuned,
7
+ * and the drift would be invisible: one wait would give up on a transient outage
8
+ * the other rode out, and nothing would report the disagreement. So the values
9
+ * live here once and both callers import them.
10
+ *
11
+ * Nothing in this module holds mutable state. A policy that could be reconfigured
12
+ * at runtime is a policy two callers can observe differently, which is precisely
13
+ * the property this extraction exists to remove.
14
+ */
15
+ /**
16
+ * Bound on CONSECUTIVE transport (fetch/header) failures before a poll loop
17
+ * gives up recoverably. Independent of the deadline and of the polling
18
+ * intervals — a single resolved response resets the counter to zero.
19
+ *
20
+ * Moved here verbatim from `index.ts` (BAPI-659). The value is unchanged.
21
+ */
22
+ export const MAX_CONSECUTIVE_POLL_FAILURES = 3;
23
+ /**
24
+ * The default first-poll delay used when a caller supplies no schedule.
25
+ *
26
+ * `pollForResult` waits before its FIRST request, not after it — an artifact is
27
+ * never ready the instant a generation is submitted, and an immediate probe only
28
+ * spends a request to learn that.
29
+ */
30
+ export const DEFAULT_INITIAL_POLL_DELAY_MS = 15_000;
31
+ /**
32
+ * The steady-state delay `pollForResult` backs off to once a poll has been
33
+ * running for longer than {@link POLL_BACKOFF_AFTER_MS}.
34
+ */
35
+ export const DEFAULT_STEADY_POLL_DELAY_MS = 30_000;
36
+ /**
37
+ * Elapsed time after which the default (schedule-free) cadence backs off from
38
+ * {@link DEFAULT_INITIAL_POLL_DELAY_MS} to {@link DEFAULT_STEADY_POLL_DELAY_MS}.
39
+ */
40
+ export const POLL_BACKOFF_AFTER_MS = 60_000;
41
+ /**
42
+ * The delay before the next poll under the DEFAULT (schedule-free) cadence.
43
+ *
44
+ * This is the exact rule `pollForResult` applied inline before this module
45
+ * existed: 15s until a minute has elapsed, 30s thereafter. Expressed as a pure
46
+ * function of elapsed time so the bounded-wait primitive can apply the identical
47
+ * curve without re-deriving it, and so a test can assert the curve directly.
48
+ */
49
+ export function defaultPollDelayMs(elapsedMs) {
50
+ return elapsedMs > POLL_BACKOFF_AFTER_MS
51
+ ? DEFAULT_STEADY_POLL_DELAY_MS
52
+ : DEFAULT_INITIAL_POLL_DELAY_MS;
53
+ }
54
+ /**
55
+ * Ceiling on a SINGLE probe request, before it is treated as a transport failure.
56
+ *
57
+ * Deliberately far shorter than the 240-second bounded window (see
58
+ * `bounded-wait.ts`): a probe that hangs must not eat the whole window, because a
59
+ * window that produced no observation at all is indistinguishable to the caller
60
+ * from one that observed "still pending" — and the second is a fact while the
61
+ * first is a stall. Each call additionally clamps this to the time actually
62
+ * remaining, so the ceiling can never overshoot the deadline.
63
+ */
64
+ export const MAX_PROBE_TIMEOUT_MS = 30_000;
65
+ /**
66
+ * Bounded jitter applied on top of the backoff delay, in milliseconds.
67
+ *
68
+ * Exists so several waiting workers do not synchronize onto the same probe
69
+ * instants after a shared upstream blip. Small relative to the delays above: the
70
+ * point is to break lockstep, not to reshape the cadence.
71
+ */
72
+ export const MAX_POLL_JITTER_MS = 1_000;
@@ -0,0 +1,412 @@
1
+ /**
2
+ * The canonical shared readiness-check contract (BAPI-1055, AC-6).
3
+ *
4
+ * Conductor prerequisite probing is spread across five loci — `bridge doctor`
5
+ * ({@link file:./install-doctor.ts}), `conductor doctor`
6
+ * ({@link file:./conductor/doctor.ts}), `plane` preflight
7
+ * ({@link file:./plane/preflight.ts}), the server `conductor-readiness`
8
+ * collector, and the unified install doctor
9
+ * ({@link file:./conductor/install-doctor.ts}) that already composes four of
10
+ * them. Each speaks its own status vocabulary. This module is the ONE record
11
+ * shape they are all projected into, so a consolidated report can say
12
+ * pass/fail and name exactly one remediation per failure without any locus
13
+ * having to abandon its own verdicts.
14
+ *
15
+ * DEPENDENCY-NEUTRAL LEAF. It imports nothing: no fs, no network, no CLI, no
16
+ * sibling diagnostic. That is what makes it safe to import from every surface,
17
+ * including ones pinned to issue zero probes.
18
+ *
19
+ * ## Exhaustive source-status mapping (the accepted four-state vocabulary)
20
+ *
21
+ * The shared vocabulary has FOUR states, not three, because four is the
22
+ * minimum that lets every source verdict round-trip without a lossy merge.
23
+ * Collapsing `degraded` onto `fail` would make a fallback npm channel read
24
+ * identically to an unusable ledger, which would contradict the rule that each
25
+ * section's own advisory semantics stay unchanged.
26
+ *
27
+ * | Source | Source verdict | Canonical |
28
+ * | ----------------------------------- | ----------------------------- | --------- |
29
+ * | `install-doctor` `InstallCheckStatus` | `PASS` | `pass` |
30
+ * | `install-doctor` `InstallCheckStatus` | `WARN` | `warn` |
31
+ * | `install-doctor` `InstallCheckStatus` | `SKIP` | `skip` |
32
+ * | `install-doctor` `InstallCheckStatus` | `INFO` | see below |
33
+ * | `conductor/install-doctor` section | `ok` | `pass` |
34
+ * | `conductor/install-doctor` section | `degraded` | `warn` |
35
+ * | `conductor/install-doctor` section | `fatal` | `fail` |
36
+ * | `conductor/doctor` inspection | `degraded: true` | `warn` |
37
+ * | `plane` diagnostic | `severity: "warning"` | `warn` |
38
+ * | `plane` diagnostic | `severity: "blocking"` | `fail` |
39
+ * | server readiness | unreadable / `null` / missing | `fail` |
40
+ *
41
+ * `INFO` never becomes a canonical status. It is informational by definition,
42
+ * so its content is preserved in {@link ReadinessCheck.detail} while the check
43
+ * takes `pass`, `warn`, or `skip` according to that check's own established
44
+ * semantics — chosen per check id by the adapter, never guessed here.
45
+ *
46
+ * Three states are deliberately NOT interchangeable:
47
+ *
48
+ * - `fail` means a prerequisite is not satisfied, INCLUDING the case where its
49
+ * state could not be established. An unreadable liveness read, a failed
50
+ * permission probe, `executor.ready === null`, and a field a newer server
51
+ * would have sent are all `fail` with a remediation for restoring the probe.
52
+ * Uncertainty must never appear healthy.
53
+ * - `warn` means the prerequisite is degraded but usable, or a launch-permitting
54
+ * plane warning. It is not a soft failure and is never derived from silence.
55
+ * - `skip` means the check genuinely did not apply or a dependency prevented it
56
+ * from running. It is never used to hide an unknown.
57
+ *
58
+ * ## The failure/remediation invariant
59
+ *
60
+ * AC-6 requires exactly ONE named remediation on every failure, so the union
61
+ * below makes that a property of the type: the `fail` variant REQUIRES one
62
+ * non-empty scalar remediation and the `pass` variant PROHIBITS one. `warn` and
63
+ * `skip` may each carry at most one, because an actionable warning and the
64
+ * actionable deny-probe skip both have a named next step, while a
65
+ * dependency-driven skip has none.
66
+ *
67
+ * ## Secret discipline
68
+ *
69
+ * Every `detail` and `remediation` is fixed prose plus non-secret facts: a
70
+ * label, a closed enum, a count, a boolean, a timestamp, a relative identifier,
71
+ * or an approved base/setup URL. The helpers here cap length, strip control
72
+ * characters, and refuse obvious absolute paths — but they are a last line of
73
+ * defence, NOT permission to pass a raw exception, subprocess output, a request
74
+ * header, a response body, or a credential into the constructor. Projecting
75
+ * source data into safe prose is the adapter's job; this module only refuses
76
+ * the most obvious leaks.
77
+ */
78
+ /** Every valid status, for exhaustive validation and test enumeration. */
79
+ export const READINESS_CHECK_STATUSES = [
80
+ "pass",
81
+ "warn",
82
+ "fail",
83
+ "skip",
84
+ ];
85
+ /** Every valid source, for exhaustive validation and test enumeration. */
86
+ export const READINESS_CHECK_SOURCES = [
87
+ "install",
88
+ "conductor",
89
+ "plane",
90
+ "server",
91
+ ];
92
+ /** Raised by {@link createReadinessCheck} for input that cannot be projected. */
93
+ export class ReadinessCheckValidationError extends Error {
94
+ constructor(reason) {
95
+ // The reason names the RULE that was broken, never the offending value: the
96
+ // value is exactly the untrusted material this module exists to keep out of
97
+ // output, and an error message is an output.
98
+ super(`invalid readiness check: ${reason}`);
99
+ this.name = "ReadinessCheckValidationError";
100
+ }
101
+ }
102
+ /** Length cap for a projected detail line. */
103
+ export const READINESS_DETAIL_MAX_LENGTH = 400;
104
+ /** Length cap for a projected remediation. */
105
+ export const READINESS_REMEDIATION_MAX_LENGTH = 400;
106
+ /**
107
+ * Stable id grammar: `<source>.<segment>[.<segment>...]`.
108
+ *
109
+ * Lowercase, digits, and hyphens only. Whitespace, uppercase, quotes, slashes,
110
+ * and colons are all rejected, which is what makes an interpolated value — a
111
+ * path, a repo name, an error string — structurally unable to become an id.
112
+ */
113
+ const ID_SEGMENT = "[a-z0-9]+(?:-[a-z0-9]+)*";
114
+ const READINESS_ID_PATTERN = new RegExp(`^${ID_SEGMENT}(?:\\.${ID_SEGMENT})+$`);
115
+ /**
116
+ * Filesystem roots an absolute POSIX path leak actually starts with.
117
+ *
118
+ * An allowlist of DANGER rather than a general "starts with a slash" rule,
119
+ * because the strings this contract legitimately carries are full of leading
120
+ * slashes that are not paths at all: the slash commands remediations name
121
+ * (`/install-bridge`), and the endpoint identifiers details name (`/jira/ping`).
122
+ * Rejecting those would push adapters toward vaguer prose without removing any
123
+ * real leak — the leak is always a home, temp, or system root.
124
+ */
125
+ const POSIX_PATH_ROOTS = [
126
+ "Users",
127
+ "home",
128
+ "root",
129
+ "var",
130
+ "tmp",
131
+ "private",
132
+ "opt",
133
+ "usr",
134
+ "etc",
135
+ "srv",
136
+ "mnt",
137
+ "media",
138
+ "Applications",
139
+ "Library",
140
+ "System",
141
+ "Volumes",
142
+ "node_modules",
143
+ ];
144
+ /**
145
+ * Absolute POSIX and Windows paths, and `file://` URLs.
146
+ *
147
+ * Deliberately narrow: it catches the obvious leak (`/Users/...`, `/private/tmp/...`,
148
+ * `C:\\...`) without rejecting the relative, repository-conventional paths
149
+ * remediations legitimately name (`.github/workflows/claude-review.yml`,
150
+ * `docs/claude/epic-conductor-v2-operator-runbook.md`) or the approved setup
151
+ * URLs (`https://host/setup`) — note the Windows drive alternative requires a
152
+ * word boundary before the letter, so the `s:/` inside `https://` is not a hit.
153
+ */
154
+ const ABSOLUTE_PATH_PATTERN = new RegExp("(^|[\\s\"'`(<])(?:" +
155
+ `\\/(?:${POSIX_PATH_ROOTS.join("|")})\\/` +
156
+ "|[A-Za-z]:[\\\\/]" +
157
+ "|file:\\/\\/)");
158
+ /** True when `value` looks like it embeds an absolute filesystem path. */
159
+ export function containsAbsolutePath(value) {
160
+ return ABSOLUTE_PATH_PATTERN.test(value);
161
+ }
162
+ /**
163
+ * Request headers and serialized response bodies.
164
+ *
165
+ * Both have recognizable shapes, which is what makes them worth refusing here:
166
+ * unlike a credential value — a random-looking string no validator can tell
167
+ * from a repository name — an `Authorization:` header or a JSON object body
168
+ * announces itself. Neither belongs in a report field under any circumstances,
169
+ * so this is a structural refusal rather than a judgement call.
170
+ *
171
+ * The credential value itself is NOT covered, and cannot be: nothing in this
172
+ * module ever holds one. That guarantee lives at the collectors that do, which
173
+ * is why they author prose instead of copying resolver output.
174
+ */
175
+ const TRANSPORT_ARTIFACT_PATTERN = /(^|[\s"'`(<])(?:authorization\s*:|x-api-key\s*:|bearer\s+\S|\{\s*"[A-Za-z_$][\w$]*"\s*:)/i;
176
+ /** True when `value` looks like a request header or a serialized response body. */
177
+ export function containsTransportArtifact(value) {
178
+ return TRANSPORT_ARTIFACT_PATTERN.test(value);
179
+ }
180
+ /**
181
+ * Normalize already-projected prose for a bounded, secret-free field.
182
+ *
183
+ * Strips control characters (including the newlines that would let one field
184
+ * forge another report line), collapses runs of whitespace, trims, and caps the
185
+ * length. Throws when the result is empty or still looks like an absolute path.
186
+ */
187
+ function normalizeProse(value, field, maxLength) {
188
+ if (typeof value !== "string") {
189
+ // Arrays are called out by name because "one remediation" is the invariant
190
+ // an array silently violates: a caller handing over two fixes is not a
191
+ // formatting problem, it is a contract violation.
192
+ throw new ReadinessCheckValidationError(Array.isArray(value) ? `${field} must be a single string, not an array` : `${field} must be a string`);
193
+ }
194
+ // eslint-disable-next-line no-control-regex -- stripping control characters is the point
195
+ const stripped = value.replace(/[\u0000-\u001f\u007f]+/g, " ");
196
+ const collapsed = stripped.replace(/\s{2,}/g, " ").trim();
197
+ if (collapsed.length === 0) {
198
+ throw new ReadinessCheckValidationError(`${field} must not be blank`);
199
+ }
200
+ if (containsAbsolutePath(collapsed)) {
201
+ throw new ReadinessCheckValidationError(`${field} must not contain an absolute path`);
202
+ }
203
+ if (containsTransportArtifact(collapsed)) {
204
+ throw new ReadinessCheckValidationError(`${field} must not contain a request header or a response body`);
205
+ }
206
+ return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 1).trimEnd()}…` : collapsed;
207
+ }
208
+ /** Normalize a detail line. Exported so adapters project through one helper. */
209
+ export function normalizeReadinessDetail(value) {
210
+ return normalizeProse(value, "detail", READINESS_DETAIL_MAX_LENGTH);
211
+ }
212
+ /** Normalize a remediation. Exported so adapters project through one helper. */
213
+ export function normalizeReadinessRemediation(value) {
214
+ return normalizeProse(value, "remediation", READINESS_REMEDIATION_MAX_LENGTH);
215
+ }
216
+ /**
217
+ * Build one validated {@link ReadinessCheck}.
218
+ *
219
+ * The ONLY sanctioned way to construct a canonical check. It enforces the
220
+ * failure/remediation invariant, the id namespace, the closed vocabularies, and
221
+ * the bounded secret-free field rules — so a report cannot acquire a failure
222
+ * with no fix, a fix attached to a pass, or an id derived from untrusted input.
223
+ *
224
+ * Throws {@link ReadinessCheckValidationError}; callers assembling a report from
225
+ * external material should route through {@link validateReadinessChecks}, which
226
+ * converts a throw into a stable failed check rather than losing the locus.
227
+ */
228
+ export function createReadinessCheck(input) {
229
+ if (input === null || typeof input !== "object") {
230
+ throw new ReadinessCheckValidationError("check must be an object");
231
+ }
232
+ if (!READINESS_CHECK_SOURCES.includes(input.source)) {
233
+ throw new ReadinessCheckValidationError("source is not a supported readiness source");
234
+ }
235
+ if (!READINESS_CHECK_STATUSES.includes(input.status)) {
236
+ throw new ReadinessCheckValidationError("status is not a supported readiness status");
237
+ }
238
+ if (typeof input.id !== "string" || !READINESS_ID_PATTERN.test(input.id)) {
239
+ throw new ReadinessCheckValidationError("id is not a stable dotted lowercase identifier");
240
+ }
241
+ if (!input.id.startsWith(`${input.source}.`)) {
242
+ throw new ReadinessCheckValidationError("id namespace does not match its source");
243
+ }
244
+ const label = normalizeProse(input.label, "label", READINESS_DETAIL_MAX_LENGTH);
245
+ const detail = input.detail === undefined ? undefined : normalizeReadinessDetail(input.detail);
246
+ if (input.status === "pass") {
247
+ if (input.remediation !== undefined) {
248
+ // A pass with a fix attached is how a report starts telling operators to
249
+ // act on things that are already fine, which is how they learn to ignore it.
250
+ throw new ReadinessCheckValidationError("a passing check must not carry a remediation");
251
+ }
252
+ return { id: input.id, source: input.source, label, status: "pass", ...(detail ? { detail } : {}) };
253
+ }
254
+ if (input.status === "fail") {
255
+ if (input.remediation === undefined) {
256
+ throw new ReadinessCheckValidationError("a failing check must carry exactly one remediation");
257
+ }
258
+ return {
259
+ id: input.id,
260
+ source: input.source,
261
+ label,
262
+ status: "fail",
263
+ ...(detail ? { detail } : {}),
264
+ remediation: normalizeReadinessRemediation(input.remediation),
265
+ };
266
+ }
267
+ const remediation = input.remediation === undefined ? undefined : normalizeReadinessRemediation(input.remediation);
268
+ return {
269
+ id: input.id,
270
+ source: input.source,
271
+ label,
272
+ status: input.status,
273
+ ...(detail ? { detail } : {}),
274
+ ...(remediation ? { remediation } : {}),
275
+ };
276
+ }
277
+ /** Fixed prose for a check that had to be replaced because it was malformed. */
278
+ export const READINESS_MALFORMED_CHECK_DETAIL = "this prerequisite reported a result that failed the readiness contract, so its real state is unknown";
279
+ /** Fixed remediation for a replaced malformed check. */
280
+ export const READINESS_MALFORMED_CHECK_REMEDIATION = "re-run `conductor readiness`; if it persists, run `conductor doctor` and `install conductor` to re-collect this locus.";
281
+ /** Fixed label for a replaced malformed check. */
282
+ export const READINESS_MALFORMED_CHECK_LABEL = "Unreadable prerequisite";
283
+ /**
284
+ * Validate a whole array of checks, replacing every malformed entry with a
285
+ * stable, source-scoped failure rather than dropping it.
286
+ *
287
+ * Dropping is the dangerous direction: a locus that vanished reads as "nothing
288
+ * to report", while a failed check with fixed prose reads as "this could not be
289
+ * established" — which is the truth. The replacement satisfies the
290
+ * one-remediation invariant like any other failure.
291
+ *
292
+ * `fallbackSource` is used when the malformed entry does not even carry a usable
293
+ * source, so the substitute still lands in a namespace a reader can act on.
294
+ */
295
+ export function validateReadinessChecks(checks, fallbackSource = "conductor") {
296
+ const validated = [];
297
+ let malformedIndex = 0;
298
+ for (const candidate of checks) {
299
+ try {
300
+ validated.push(createReadinessCheck(candidate));
301
+ }
302
+ catch {
303
+ const raw = (candidate ?? {});
304
+ const source = READINESS_CHECK_SOURCES.includes(raw.source)
305
+ ? raw.source
306
+ : fallbackSource;
307
+ // The malformed entry's OWN id is never reused: it is the untrusted value
308
+ // that failed validation, so the substitute gets a generated stable id in
309
+ // the right namespace instead.
310
+ malformedIndex += 1;
311
+ validated.push(createReadinessCheck({
312
+ id: `${source}.unreadable-check-${malformedIndex}`,
313
+ source,
314
+ label: READINESS_MALFORMED_CHECK_LABEL,
315
+ status: "fail",
316
+ detail: READINESS_MALFORMED_CHECK_DETAIL,
317
+ remediation: READINESS_MALFORMED_CHECK_REMEDIATION,
318
+ }));
319
+ }
320
+ }
321
+ return validated;
322
+ }
323
+ /** Count checks by canonical status. Pure; used by report summaries and tests. */
324
+ export function summarizeReadinessChecks(checks) {
325
+ const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
326
+ for (const check of checks)
327
+ counts[check.status] += 1;
328
+ return counts;
329
+ }
330
+ /** Fixed stand-in for a source detail that failed the secret-safety contract. */
331
+ export const READINESS_UNSAFE_DETAIL_REPLACEMENT = "detail withheld — the source reported it in a form this report may not carry";
332
+ /** Fixed stand-in for a source remediation that failed the secret-safety contract. */
333
+ export const READINESS_UNSAFE_REMEDIATION_REPLACEMENT = "re-run `conductor readiness`; this prerequisite's own fix text could not be carried into this report safely.";
334
+ /**
335
+ * Build a check, degrading REJECTED prose rather than losing the finding — and
336
+ * never throwing.
337
+ *
338
+ * Legacy sections and diagnostics author their prose for a terminal, and some of
339
+ * them legitimately name an absolute path there — the executor service-unit
340
+ * collector reports the LaunchAgents directory it searched, for instance. That
341
+ * is fine in the report it was written for and NOT fine in this one, which is a
342
+ * wider surface.
343
+ *
344
+ * The two obvious responses are both wrong. Passing it through would put a home
345
+ * directory into the consolidated report; dropping the whole check on a
346
+ * validation failure would silently remove a real prerequisite because its prose
347
+ * was unsafe. So the offending FIELD is replaced and the id, label, and status —
348
+ * the parts an operator acts on — survive.
349
+ *
350
+ * The ladder degrades one field at a time, least-destructive first, so a check
351
+ * loses no more of itself than the failure requires. The last rung is a stable
352
+ * failure in the same namespace: this function is called from inside
353
+ * {@link file:./conductor/readiness.ts}'s checks-array literal, which is built
354
+ * BEFORE {@link validateReadinessChecks} runs, so a throw here would escape the
355
+ * aggregator's "never throws" contract rather than being absorbed by it. An
356
+ * earlier revision of this function replaced only `detail` and justified the gap
357
+ * by pointing at that later validation pass — which does not run early enough to
358
+ * catch it.
359
+ */
360
+ export function createReadinessCheckSafely(input) {
361
+ // A pass may not carry a remediation at all, so for a pass the field is
362
+ // DROPPED rather than replaced — substituting prose there would invent an
363
+ // action for a prerequisite that is already satisfied.
364
+ const safeRemediation = () => input?.status === "pass"
365
+ ? { remediation: undefined }
366
+ : { remediation: READINESS_UNSAFE_REMEDIATION_REPLACEMENT };
367
+ // 1. As authored.
368
+ try {
369
+ return createReadinessCheck(input);
370
+ }
371
+ catch {
372
+ /* fall through */
373
+ }
374
+ // 2. The detail alone — the field untrusted source prose usually arrives in.
375
+ try {
376
+ return createReadinessCheck({ ...input, detail: READINESS_UNSAFE_DETAIL_REPLACEMENT });
377
+ }
378
+ catch {
379
+ /* fall through */
380
+ }
381
+ // 3. The remediation alone, keeping a detail that was never the problem.
382
+ try {
383
+ return createReadinessCheck({ ...input, ...safeRemediation() });
384
+ }
385
+ catch {
386
+ /* fall through */
387
+ }
388
+ // 4. Both.
389
+ try {
390
+ return createReadinessCheck({
391
+ ...input,
392
+ detail: READINESS_UNSAFE_DETAIL_REPLACEMENT,
393
+ ...safeRemediation(),
394
+ });
395
+ }
396
+ catch {
397
+ /* fall through */
398
+ }
399
+ // 5. Structurally invalid — a bad id, source, or status, which is a caller bug
400
+ // rather than untrusted prose. Reported as an unreadable prerequisite in the
401
+ // caller's namespace when it named a usable one, exactly as
402
+ // `validateReadinessChecks` would have. Never a throw, and never a pass.
403
+ const source = READINESS_CHECK_SOURCES.includes(input?.source) ? input.source : "conductor";
404
+ return createReadinessCheck({
405
+ id: `${source}.unreadable-check`,
406
+ source,
407
+ label: READINESS_MALFORMED_CHECK_LABEL,
408
+ status: "fail",
409
+ detail: READINESS_MALFORMED_CHECK_DETAIL,
410
+ remediation: READINESS_MALFORMED_CHECK_REMEDIATION,
411
+ });
412
+ }