@event4u/agent-config 9.7.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +5 -5
  4. package/dist/agent-src/commands/commit/in-chunks.md +8 -0
  5. package/dist/agent-src/commands/commit.md +17 -0
  6. package/dist/agent-src/commands/pr/create.md +4 -0
  7. package/dist/agent-src/contexts/override-system.md +75 -5
  8. package/dist/agent-src/rules/context-hygiene.md +2 -0
  9. package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
  10. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  11. package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
  12. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -0
  14. package/dist/agent-src/rules/language-and-tone.md +2 -0
  15. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  16. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  17. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  18. package/dist/agent-src/rules/no-roadmap-references.md +3 -0
  19. package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
  20. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  21. package/dist/agent-src/rules/output-discipline.md +2 -0
  22. package/dist/agent-src/rules/persona-governance.md +2 -0
  23. package/dist/agent-src/rules/preservation-guard.md +3 -0
  24. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  25. package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
  26. package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
  27. package/dist/agent-src/rules/skill-quality.md +2 -0
  28. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  29. package/dist/agent-src/rules/source-of-truth.md +2 -0
  30. package/dist/agent-src/rules/telegraph-speak.md +2 -0
  31. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  32. package/dist/agent-src/rules/tool-safety.md +1 -0
  33. package/dist/agent-src/rules/verify-before-complete.md +2 -0
  34. package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
  35. package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
  36. package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
  37. package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
  38. package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
  39. package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
  40. package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
  41. package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
  42. package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
  43. package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
  44. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
  45. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
  46. package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
  47. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
  48. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
  49. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
  50. package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
  52. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  53. package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
  54. package/dist/cli/configRoot.js +112 -0
  55. package/dist/cli/configRoot.js.map +1 -0
  56. package/dist/cli/configRoot.test.js +97 -0
  57. package/dist/cli/configRoot.test.js.map +1 -0
  58. package/dist/cli/help.js +4 -1
  59. package/dist/cli/help.js.map +1 -1
  60. package/dist/cli/main.js +19 -2
  61. package/dist/cli/main.js.map +1 -1
  62. package/dist/cli/registry.js +1 -0
  63. package/dist/cli/registry.js.map +1 -1
  64. package/dist/discovery/deprecation-report.md +1 -1
  65. package/dist/discovery/discovery-manifest.json +161 -44
  66. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  67. package/dist/discovery/discovery-manifest.summary.md +4 -4
  68. package/dist/discovery/orphan-report.md +1 -1
  69. package/dist/discovery/packs.json +13 -8
  70. package/dist/discovery/trust-report.md +11 -11
  71. package/dist/discovery/workspaces.json +11 -6
  72. package/dist/install/install.mjs +16 -0
  73. package/dist/mcp/registry-manifest.json +3 -3
  74. package/dist/router.json +1 -1
  75. package/dist/server/app.js +10 -0
  76. package/dist/server/app.js.map +1 -1
  77. package/dist/server/routes/ping.js +18 -0
  78. package/dist/server/routes/ping.js.map +1 -1
  79. package/dist/server/schemas/settings.js +8 -0
  80. package/dist/server/schemas/settings.js.map +1 -1
  81. package/dist/server/serverInfo.js +6 -2
  82. package/dist/server/serverInfo.js.map +1 -1
  83. package/dist/server/token.js +11 -4
  84. package/dist/server/token.js.map +1 -1
  85. package/dist/server/writeRoot.js +15 -1
  86. package/dist/server/writeRoot.js.map +1 -1
  87. package/dist/shared/capabilities.js +33 -0
  88. package/dist/shared/capabilities.js.map +1 -0
  89. package/dist/shared/capabilities.test.js +39 -0
  90. package/dist/shared/capabilities.test.js.map +1 -0
  91. package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
  92. package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
  93. package/dist/ui/index.html +14 -5
  94. package/docs/CLAIMS.md +62 -37
  95. package/docs/architecture.md +2 -2
  96. package/docs/benchmark.md +137 -0
  97. package/docs/catalog.md +8 -3
  98. package/docs/comparison.yaml +44 -2
  99. package/docs/contracts/local-server-ports.md +53 -0
  100. package/docs/contracts/no-runtime-boundary.md +1 -1
  101. package/docs/contracts/subagent-boundary.md +15 -0
  102. package/docs/customization.md +14 -3
  103. package/docs/decisions/ADR-016-installer-architecture.md +2 -0
  104. package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
  105. package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
  106. package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
  107. package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
  108. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
  109. package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
  110. package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
  111. package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
  112. package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
  113. package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
  114. package/docs/decisions/INDEX.md +21 -0
  115. package/docs/decisions/engine-reclassification-2026-07.md +104 -0
  116. package/docs/distribution/awesome-list-submission.md +1 -1
  117. package/docs/distribution/registries.md +36 -27
  118. package/docs/enforcement-by-host.md +7 -0
  119. package/docs/featured-skills.md +2 -2
  120. package/docs/getting-started-by-role.md +1 -1
  121. package/docs/getting-started.md +1 -1
  122. package/docs/governance-advantage.md +1 -1
  123. package/docs/guides/gated-platform-reads.md +221 -0
  124. package/docs/positioning-evidence.md +1 -1
  125. package/docs/proof.md +92 -16
  126. package/docs/threat-model.md +1 -0
  127. package/package.json +5 -2
  128. package/src/config/agent-settings.template.yml +43 -0
  129. package/src/config/reach-channels.yml +217 -0
  130. package/src/config/reach-prescriptions-intake.yml +200 -0
  131. package/src/scripts/_dispatch.bash +15 -0
  132. package/src/scripts/_lib/exec_evidence.ts +251 -0
  133. package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
  134. package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
  135. package/src/scripts/_lib/kernel_rules.ts +46 -0
  136. package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
  137. package/src/scripts/_lib/risk_paths.ts +160 -0
  138. package/src/scripts/_lib/secret_detector.test.ts +103 -0
  139. package/src/scripts/_lib/secret_detector.ts +319 -0
  140. package/src/scripts/_lib/tool_probe.ts +359 -0
  141. package/src/scripts/adr/regenerate_index.ts +81 -6
  142. package/src/scripts/audit_adr_coverage.ts +33 -0
  143. package/src/scripts/build_proof.ts +162 -0
  144. package/src/scripts/check_adr_frontmatter.ts +195 -0
  145. package/src/scripts/check_artefact_count_messaging.ts +4 -0
  146. package/src/scripts/check_backstop_debt.ts +200 -0
  147. package/src/scripts/check_claims.ts +188 -8
  148. package/src/scripts/check_comparison.ts +12 -0
  149. package/src/scripts/check_enforcement_coverage.ts +592 -0
  150. package/src/scripts/check_kernel_rule_bundle.ts +16 -12
  151. package/src/scripts/check_reach_channels.ts +407 -0
  152. package/src/scripts/check_reach_staleness.ts +364 -0
  153. package/src/scripts/check_secret_leak.test.ts +47 -0
  154. package/src/scripts/check_secret_leak.ts +290 -0
  155. package/src/scripts/code_graph/build.ts +0 -0
  156. package/src/scripts/code_graph/cli.ts +207 -0
  157. package/src/scripts/code_graph/detect.ts +134 -0
  158. package/src/scripts/code_graph/extract.ts +386 -0
  159. package/src/scripts/code_graph/loader.ts +123 -0
  160. package/src/scripts/code_graph/query.ts +176 -0
  161. package/src/scripts/code_graph/sanitize.ts +30 -0
  162. package/src/scripts/code_graph/types.ts +83 -0
  163. package/src/scripts/code_graph/validate.ts +63 -0
  164. package/src/scripts/external_sources_denylist.json +3 -1
  165. package/src/scripts/generate_subagent_floor.ts +194 -0
  166. package/src/scripts/hook_manifest.yaml +13 -3
  167. package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
  168. package/src/scripts/iron_law_sha.ts +3 -12
  169. package/src/scripts/lint_override_kernel_guard.ts +219 -0
  170. package/src/scripts/measure_rule_budget.ts +3 -12
  171. package/src/scripts/postinstall_gui.ts +129 -0
  172. package/src/scripts/reach_doctor.ts +1651 -0
  173. package/src/scripts/release.ts +24 -3
  174. package/src/scripts/risk_escalation_shadow.ts +160 -0
  175. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  176. package/src/scripts/schemas/reach-channels.schema.json +134 -0
  177. package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
  178. package/src/scripts/schemas/rule.schema.json +10 -0
  179. package/src/scripts/skill_linter.ts +55 -0
  180. package/src/scripts/validate_reach_prescriptions.ts +751 -0
  181. package/src/server/schemas/settings.ts +16 -0
  182. package/src/shared/capabilities.test.ts +43 -0
  183. package/src/shared/capabilities.ts +75 -0
  184. package/src/templates/minimal/agents-overrides-readme.md +20 -0
  185. package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
@@ -0,0 +1,1651 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * reach:doctor — read-only health report over the reach channel registry
4
+ * (road-to-internet-reach Phase 2, steps 4-7; `--deep` from Phase 7 step 4).
5
+ *
6
+ * WHAT THIS ANSWERS. Two operator questions, nothing more: *is the upstream
7
+ * tool I already chose to install healthy, and is its install command
8
+ * pinned?* — and, for a backend that declares one, *is it actually able to do
9
+ * the retrieval, or merely installed?* Per channel it reports the health of the
10
+ * ordered backend candidates, which one is currently active, and — when the
11
+ * first candidate is `missing` or `broken` — the exact pinned command **a
12
+ * human** runs for the current platform, echoed verbatim from
13
+ * `src/config/reach-channels.yml`.
14
+ *
15
+ * THE SECOND QUESTION EXISTS BECAUSE A PASSING PROBE CAN LIE. `yt-dlp
16
+ * --version` exits 0 as soon as the binary is on PATH, while YouTube extraction
17
+ * additionally needs an external JavaScript runtime that the tool must be
18
+ * configured to use. A channel reported `ok` on that evidence alone is a false
19
+ * green. The readiness layer (§ READINESS LAYER) closes it locally and
20
+ * read-only, and its verdict is surfaced as the distinct `not-ready` channel
21
+ * status so `missing` (nothing installed) can never be confused with
22
+ * `not-ready` (installed, cannot retrieve).
23
+ *
24
+ * WHAT THIS IS NOT. Not a router, not a recommendation, not an agent-facing
25
+ * surface. The Phase 0 pre-registered benchmark returned `band: stop` (native
26
+ * arm 12/12, reach arm 0 outright wins), so no router skill ships and no
27
+ * channel in the registry is routed, preferred, or suggested to an agent.
28
+ * A channel appearing here means "a recipe an operator wrote needs this
29
+ * backend", never "the agent should reach for this".
30
+ *
31
+ * READ-ONLY BY CONSTRUCTION.
32
+ * - Zero writes. No file is created, truncated, appended to, or chmod'ed;
33
+ * `--deep` prints the `last_verified` lines an operator MAY commit and
34
+ * writes none of them. Asserted mechanically by
35
+ * `tests/scripts/witness/reach_doctor_readonly.test.ts`.
36
+ * - Zero installs. Install prescriptions are strings this script selects
37
+ * and echoes; it never executes one and never invents one.
38
+ * - Zero network in the default (shallow) mode. The only child processes
39
+ * are the registry's side-effect-free probes, run through `probeTool()`
40
+ * (which hardens the child env per ADR-123). `--deep` is the single
41
+ * opt-in exception and refuses to run in CI.
42
+ *
43
+ * SCHEMA-ENFORCED INPUT — the registry is NOT trusted input. `collect()`
44
+ * validates the file (including `--registry <path>`) against
45
+ * `reach-channels.schema.json` plus the cross-field `probe_cmd === id` rule,
46
+ * and refuses the whole run with exit 2 on any error-severity finding. This is
47
+ * a security boundary, not tidiness: `probe_cmd` + `probe_args` become a child
48
+ * process, so an unvalidated registry was an arbitrary-execution primitive
49
+ * (`probe_cmd: sh`, `probe_args: ['-c', '<payload>']` ran and reported `ok`).
50
+ *
51
+ * PER-CHANNEL ISOLATION. A malformed channel becomes that channel's `error`
52
+ * row; every other channel is still reported. One bad entry can never take
53
+ * the report down — the same guarantee `probeTools()` gives per backend. Since
54
+ * the schema gate above refuses malformed files outright, this layer is now
55
+ * defense in depth: it is exercised directly through `buildChannelRow()` /
56
+ * `errorRow()`, which are exported for exactly that reason.
57
+ *
58
+ * Payload / flag / exit-code conventions follow `hooks_doctor.ts`:
59
+ * `schema_version` on a JSON-serialisable payload, `--format json|table`
60
+ * (table default), `--strict` for CI, exit 2 for an unusable input. The
61
+ * JSON payload validates against
62
+ * `src/scripts/schemas/reach-doctor-payload.schema.json`.
63
+ *
64
+ * Exit codes:
65
+ * 0 — report produced (and, under `--strict`, every admitted channel is ok).
66
+ * 1 — `--strict` and at least one admitted channel is not `ok`.
67
+ * 2 — usage error, unusable OR schema-invalid registry, or `--deep`
68
+ * requested inside CI.
69
+ *
70
+ * Invocation (from the project root):
71
+ * ./agent-config reach:doctor [--format json|table] [--strict]
72
+ * [--channel <id>] [--registry <path>] [--deep]
73
+ */
74
+
75
+ import { spawnSync } from 'node:child_process';
76
+ import * as fs from 'node:fs';
77
+ import * as os from 'node:os';
78
+ import * as path from 'node:path';
79
+ import process from 'node:process';
80
+ import { fileURLToPath, pathToFileURL } from 'node:url';
81
+
82
+ import { hardenedSpawnEnv } from './_lib/spawn_env.js';
83
+ import { probeTool } from './_lib/tool_probe.js';
84
+ import type { FixPrescription, ToolProbeStatus } from './_lib/tool_probe.js';
85
+ import {
86
+ REGISTRY_PATH,
87
+ RegistryLoadError,
88
+ SCHEMA_MESSAGE_MAX_CHARS,
89
+ excerpt_for_finding,
90
+ load_registry,
91
+ sanitizeParseError,
92
+ validate_file,
93
+ } from './check_reach_channels.js';
94
+
95
+ const _HERE = fileURLToPath(import.meta.url);
96
+ /** Repo root — two dirs up from src/scripts, mirroring the sibling scripts. */
97
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
98
+
99
+ /** Payload format version. Bumped only on a breaking payload shape change. */
100
+ export const SCHEMA_VERSION = 1;
101
+
102
+ /**
103
+ * `removed` extends the probe taxonomy: past `removal_after`, never probed.
104
+ * `not-ready` extends it the other way: a candidate IS installed and answering
105
+ * its probe, but a declared readiness requirement is not confirmed satisfied —
106
+ * the "installed, unverified" ceiling (see § READINESS LAYER). It exists so an
107
+ * operator can tell `missing` (nothing to run) from `not-ready` (the binary
108
+ * runs, the retrieval still will not work).
109
+ */
110
+ export type ChannelStatus = ToolProbeStatus | 'removed' | 'not-ready';
111
+
112
+ /** Thrown for the exit-2 class: bad flags, unknown channel, `--deep` in CI. */
113
+ export class ReachDoctorUsageError extends Error {}
114
+
115
+ /**
116
+ * Deep probes — the ONLY network in this script, and only under `--deep`.
117
+ *
118
+ * Declared here rather than in the registry on purpose: the registry's schema
119
+ * describes *install pinning and local probes*, and a network endpoint is not
120
+ * derivable from it. A backend with no entry below reports `not-declared`
121
+ * instead of getting an endpoint invented for it — `yt-dlp` is exactly that
122
+ * case (any real video URL would be both an invention and a terms-of-service
123
+ * surface). Every declared probe is read-only: a rate-limit read, or a HEAD
124
+ * against `example.com`, the domain IANA reserves for documentation use.
125
+ */
126
+ interface DeepProbeSpec {
127
+ /** Args appended to the backend's own `probe_cmd`. */
128
+ readonly args: readonly string[];
129
+ /** The endpoint actually contacted, surfaced in the report. */
130
+ readonly endpoint: string;
131
+ }
132
+
133
+ const DEEP_PROBES: Readonly<Record<string, DeepProbeSpec>> = {
134
+ // GitHub's own rate-limit read — the canonical "is my token live" call.
135
+ gh: { args: ['api', 'rate_limit'], endpoint: 'https://api.github.com/rate_limit' },
136
+ curl: {
137
+ args: [
138
+ '--silent',
139
+ '--show-error',
140
+ '--max-time',
141
+ '10',
142
+ '--head',
143
+ '--output',
144
+ os.devNull,
145
+ 'https://example.com',
146
+ ],
147
+ endpoint: 'https://example.com',
148
+ },
149
+ node: {
150
+ args: [
151
+ '-e',
152
+ "fetch('https://example.com', { method: 'HEAD' }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
153
+ ],
154
+ endpoint: 'https://example.com',
155
+ },
156
+ };
157
+
158
+ /** Per-backend deadline for a deep probe — network, so looser than a probe. */
159
+ const DEEP_TIMEOUT_MS = 15_000;
160
+
161
+ export interface DeepRow {
162
+ status: 'ok' | 'failed' | 'skipped' | 'not-declared';
163
+ endpoint: string | null;
164
+ exit_code: number | null;
165
+ detail: string;
166
+ }
167
+
168
+ export interface BackendRow {
169
+ id: string;
170
+ probe_cmd: string;
171
+ status: ToolProbeStatus;
172
+ path: string | null;
173
+ exit_code: number | null;
174
+ diagnostic: string;
175
+ /** Pinned prescription for the CURRENT platform; `missing`/`broken` only. */
176
+ fix: string | null;
177
+ deep: DeepRow | null;
178
+ /**
179
+ * Config-semantic readiness verdict, present ONLY for a backend that
180
+ * declares a readiness requirement (see `READINESS_REQUIREMENTS`). ABSENT —
181
+ * not `null` — for every other backend, so "this backend has no such
182
+ * requirement" is expressed by the field not existing rather than by a
183
+ * value a reader has to interpret.
184
+ */
185
+ readiness?: ReadinessRow;
186
+ }
187
+
188
+ /**
189
+ * Credential-file permission metadata. Deliberately carries the path and the
190
+ * mode ONLY — never the contents, and never a hash or fingerprint of the
191
+ * secret (a fingerprint is a confirmation oracle, so it is not a safe
192
+ * "redacted" form).
193
+ */
194
+ export interface CredentialRow {
195
+ path: string;
196
+ present: boolean;
197
+ /** Octal permission bits, e.g. `0600`. `null` when not checked/absent. */
198
+ mode: string | null;
199
+ group_or_world_readable: boolean;
200
+ /** POSIX modes are not meaningful on win32; the check is skipped there. */
201
+ checked: boolean;
202
+ }
203
+
204
+ export interface ChannelRow {
205
+ id: string;
206
+ status: ChannelStatus;
207
+ /** First backend that probes `ok`; `null` when none does. */
208
+ active_backend: string | null;
209
+ tier: string;
210
+ lifecycle: string;
211
+ last_verified: string;
212
+ /** Pinned fix for the first candidate when it is `missing`/`broken`. */
213
+ fix: string | null;
214
+ warnings: string[];
215
+ credential: CredentialRow | null;
216
+ backends: BackendRow[];
217
+ }
218
+
219
+ export interface ReachDoctorPayload {
220
+ schema_version: number;
221
+ /** `process.platform` — the key the install prescription was chosen by. */
222
+ platform: string;
223
+ /** Registry actually read, repo-relative when it lives inside the repo. */
224
+ registry: string;
225
+ /** True only when `--deep` ran (network was contacted). */
226
+ deep: boolean;
227
+ channels: ChannelRow[];
228
+ /** `--deep` only: `last_verified` lines the operator MAY commit. */
229
+ last_verified_suggestions: string[];
230
+ }
231
+
232
+ export interface CollectOptions {
233
+ registryPath?: string;
234
+ /** Single-channel filter (`--channel`). */
235
+ channel?: string;
236
+ deep?: boolean;
237
+ /** Injectable clock for the `removal_after` comparison. */
238
+ now?: Date;
239
+ /**
240
+ * Injectable readiness observer (defaults to the live one). TEST-FACING,
241
+ * exactly like `now`: no CLI flag reaches it.
242
+ */
243
+ readiness?: ReadinessProbeFn;
244
+ }
245
+
246
+ function toRecord(value: unknown): Record<string, unknown> | null {
247
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
248
+ return null;
249
+ }
250
+ return value as Record<string, unknown>;
251
+ }
252
+
253
+ function readString(record: Record<string, unknown>, key: string): string | null {
254
+ const value = record[key];
255
+ return typeof value === 'string' && value !== '' ? value : null;
256
+ }
257
+
258
+ /** `YYYY-MM-DD` in local time — the form `last_verified` / `removal_after` use. */
259
+ function isoDate(now: Date): string {
260
+ const year = now.getFullYear();
261
+ const month = String(now.getMonth() + 1).padStart(2, '0');
262
+ const day = String(now.getDate()).padStart(2, '0');
263
+ return `${year}-${month}-${day}`;
264
+ }
265
+
266
+ function expandHome(target: string): string {
267
+ if (target === '~') return os.homedir();
268
+ if (target.startsWith('~/')) return path.join(os.homedir(), target.slice(2));
269
+ return path.resolve(target);
270
+ }
271
+
272
+ /**
273
+ * Roots a declared `credential_path` may point into: the operator's home (where
274
+ * `~/.config`, `~/.netrc` and token files live), this repo, and the system temp
275
+ * dir (the fixture case — a temp path reveals nothing its author does not already
276
+ * control).
277
+ */
278
+ function credentialRoots(): string[] {
279
+ const roots = [os.homedir(), ROOT, os.tmpdir()];
280
+ // BOTH the symlinked and the resolved form of every root, because macOS hands
281
+ // out `/var/folders/…` from `os.tmpdir()` while its realpath is
282
+ // `/private/var/folders/…`. Comparing only one form refused the legitimate
283
+ // fixture case — caught by running the check, not by reading it.
284
+ for (const root of [...roots]) {
285
+ try {
286
+ roots.push(fs.realpathSync(root));
287
+ } catch {
288
+ // A root that cannot be resolved simply contributes its literal form.
289
+ }
290
+ }
291
+ return [...new Set(roots.map((root) => path.resolve(root)))];
292
+ }
293
+
294
+ /**
295
+ * PATH CONFINEMENT — the registry is untrusted input, so `credential_path` is an
296
+ * arbitrary-path primitive until it is bounded.
297
+ *
298
+ * Found by an adversarial council pass on this file, and reproduced before being
299
+ * believed: a `--registry` file declaring
300
+ * `credential_path: ../../../../../../../etc/passwd` made the doctor `stat` that
301
+ * path and report, at exit 0 inside a normal-looking health row,
302
+ * `{"path":"/etc/passwd","present":true,"mode":"0644","group_or_world_readable":true,"checked":true}`.
303
+ * That is an existence-and-permission oracle over the whole filesystem wearing the
304
+ * costume of a credential warning. The schema cannot express the bound (it only
305
+ * pins the value to a single line), so the bound lives here.
306
+ *
307
+ * Honest severity: whoever runs this CLI already has a shell and could `stat` the
308
+ * file themselves, so locally it buys an attacker nothing. It matters because this
309
+ * is a *diagnostic others may invoke* — a CI job, an agent surface with a path
310
+ * parameter — and a tool whose whole contract is "read-only and boring" should not
311
+ * ship a primitive that reports arbitrary path metadata.
312
+ *
313
+ * Refusal is reported, never silent: `checked: false` plus a warning naming it, so
314
+ * an operator with a legitimately unusual credential location sees why nothing was
315
+ * checked rather than seeing a pass.
316
+ */
317
+ export function confineCredentialPath(resolved: string): boolean {
318
+ const roots = credentialRoots();
319
+ const candidates = [path.resolve(resolved)];
320
+ try {
321
+ // Realpath the target too, so a symlink cannot point out of the roots and
322
+ // so the macOS `/var` → `/private/var` case matches from either side.
323
+ candidates.push(fs.realpathSync(candidates[0] as string));
324
+ } catch {
325
+ // Not resolvable (usually: does not exist) — the literal form decides.
326
+ }
327
+ return candidates.every((candidate) =>
328
+ roots.some((root) => candidate === root || candidate.startsWith(`${root}${path.sep}`)),
329
+ );
330
+ }
331
+
332
+ /**
333
+ * Group- or world-readability check on a declared credential file.
334
+ *
335
+ * No channel in the shipped registry declares `credential_path` — the
336
+ * login-tier channels that would have are cancelled with Phase 5 — so on the
337
+ * real registry this check is inert. It ships anyway (and is covered by
338
+ * fixture tests) because the failure it catches is silent: a `0644` token file
339
+ * is readable by every process on the box, and nothing else in the pipeline
340
+ * looks. Adding the field to the shipped registry requires a
341
+ * `reach-channels.schema.json` addition first; the schema is `additionalProperties: false`.
342
+ */
343
+ function inspectCredential(declared: string): CredentialRow {
344
+ const resolved = expandHome(declared);
345
+
346
+ // Confinement runs BEFORE any syscall, so an out-of-bounds path is never
347
+ // stat'ed and not even its existence leaks.
348
+ if (!confineCredentialPath(resolved)) {
349
+ return {
350
+ path: resolved,
351
+ present: false,
352
+ mode: null,
353
+ group_or_world_readable: false,
354
+ checked: false,
355
+ };
356
+ }
357
+ // WIN32: NO ALTERNATIVE CONTROL, AND THE GAP IS REPORTED RATHER THAN HIDDEN.
358
+ // Windows does not express file access as a `stat.mode` bitmask at all — it
359
+ // uses ACLs, so there is no POSIX permission triple here to read and no
360
+ // narrower check available. Reading the real ACL would need either a native
361
+ // binding or spawning `icacls`, and this doctor refuses to add a child
362
+ // process for a diagnostic (its read-only contract is asserted by the
363
+ // witness test). So the honest answer is `checked: false`: `present` is
364
+ // still reported, `mode` is `null`, and `group_or_world_readable` is `false`
365
+ // as "not determined" — never as "determined safe". `checked` is a REQUIRED
366
+ // field of the payload schema and both output formats surface it (the table
367
+ // prints "not checked on this platform"), so an operator sees an unevaluated
368
+ // check instead of a passing one.
369
+ if (process.platform === 'win32') {
370
+ return {
371
+ path: resolved,
372
+ present: fs.existsSync(resolved),
373
+ mode: null,
374
+ group_or_world_readable: false,
375
+ checked: false,
376
+ };
377
+ }
378
+ let stat: fs.Stats;
379
+ try {
380
+ stat = fs.statSync(resolved);
381
+ } catch {
382
+ return {
383
+ path: resolved,
384
+ present: false,
385
+ mode: null,
386
+ group_or_world_readable: false,
387
+ checked: true,
388
+ };
389
+ }
390
+ // A directory is not a credential file, and its 0755 is normal rather than a
391
+ // finding — reporting one as group-readable would manufacture a warning an
392
+ // operator cannot act on (council finding: the mode was read without checking
393
+ // the file type). Same for a socket / device / fifo: `checked: false` says the
394
+ // question was not answered, which is the truth.
395
+ if (!stat.isFile()) {
396
+ return {
397
+ path: resolved,
398
+ present: true,
399
+ mode: null,
400
+ group_or_world_readable: false,
401
+ checked: false,
402
+ };
403
+ }
404
+ const bits = stat.mode & 0o777;
405
+ // Group-read (0o040) or other-read (0o004) — write bits are a superset
406
+ // concern the mode string surfaces without needing its own field.
407
+ const exposed = (bits & 0o044) !== 0;
408
+ return {
409
+ path: resolved,
410
+ present: true,
411
+ mode: `0${bits.toString(8).padStart(3, '0')}`,
412
+ group_or_world_readable: exposed,
413
+ checked: true,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Run the declared deep probe for one backend.
419
+ *
420
+ * Reached from `buildChannelRow`, so the same boundary note applies: the schema
421
+ * gate is `collect()`, which validates the registry before `--deep` can turn any
422
+ * `probe_cmd` into a child process. This export is TEST-FACING; a direct caller
423
+ * is responsible for validating its input first.
424
+ */
425
+ export function runDeepProbe(probeCmd: string, localStatus: ToolProbeStatus): DeepRow {
426
+ // `Object.hasOwn`, never a bare index. THE GUARD IS WHAT MAKES A
427
+ // PROTOTYPE-CHAIN KEY IMPOSSIBLE HERE — reviewers have flagged this area
428
+ // twice, so the mechanism is recorded rather than left to be re-derived:
429
+ // `DEEP_PROBES[probeCmd]` walks the prototype chain, so `probe_cmd:
430
+ // constructor` resolved to `Object` and `probe_cmd: __proto__` to
431
+ // `Object.prototype`. Either one skipped the `not-declared` branch and
432
+ // emitted a `deep` block with `endpoint: undefined` — a payload that fails
433
+ // its own schema. `Object.hasOwn` consults only own properties, so every
434
+ // inherited key now takes the `not-declared` path by construction, and no
435
+ // endpoint is ever invented for a backend that declared none.
436
+ const spec = Object.hasOwn(DEEP_PROBES, probeCmd) ? DEEP_PROBES[probeCmd] : undefined;
437
+ if (spec === undefined) {
438
+ return {
439
+ status: 'not-declared',
440
+ endpoint: null,
441
+ exit_code: null,
442
+ detail: `no deep probe is declared for '${probeCmd}' — an endpoint is never invented for a backend`,
443
+ };
444
+ }
445
+ if (localStatus !== 'ok') {
446
+ return {
447
+ status: 'skipped',
448
+ endpoint: spec.endpoint,
449
+ exit_code: null,
450
+ detail: `local probe is ${localStatus} — a real request would only re-report that`,
451
+ };
452
+ }
453
+ const result = spawnSync(probeCmd, [...spec.args], {
454
+ env: hardenedSpawnEnv(),
455
+ timeout: DEEP_TIMEOUT_MS,
456
+ stdio: ['ignore', 'ignore', 'ignore'],
457
+ windowsHide: true,
458
+ });
459
+ if (result.error !== undefined) {
460
+ return {
461
+ status: 'failed',
462
+ endpoint: spec.endpoint,
463
+ exit_code: null,
464
+ detail: `deep request failed to run: ${result.error.message}`,
465
+ };
466
+ }
467
+ const code = result.status ?? null;
468
+ if (code === 0) {
469
+ return {
470
+ status: 'ok',
471
+ endpoint: spec.endpoint,
472
+ exit_code: 0,
473
+ detail: `one real read-only request to ${spec.endpoint} succeeded`,
474
+ };
475
+ }
476
+ return {
477
+ status: 'failed',
478
+ endpoint: spec.endpoint,
479
+ exit_code: code,
480
+ detail: `request to ${spec.endpoint} exited ${code === null ? 'without a status' : String(code)}`,
481
+ };
482
+ }
483
+
484
+ /* ====================================================================== *
485
+ * READINESS LAYER — a passing `--version` is not a working backend
486
+ * (road-to-gated-reach Phase 2, steps 2/3/6)
487
+ * ====================================================================== */
488
+
489
+ /**
490
+ * THE INVARIANT THIS WHOLE LAYER DEPENDS ON (Phase 2 step 6 — recorded here
491
+ * because this is the file that would break it).
492
+ *
493
+ * 1. **Probes stay `--version`-only.** Every argument list this script hands
494
+ * to a child process is either the registry's schema-constrained,
495
+ * flag-shaped `probe_args`, or a literal `['--version']` written here. No
496
+ * registry value ever selects a *subcommand*.
497
+ * 2. **The doctor NEVER runs an upstream tool's own status subcommand when
498
+ * that subcommand has side effects.** `gh auth status` refreshes and can
499
+ * rewrite the stored credential; `yt-dlp -U` / `--update-to` replaces the
500
+ * binary; a `login`/`doctor`/`serve` subcommand can start a daemon or
501
+ * mint a token. Any of those would make this *diagnostic* mutating — and
502
+ * the read-only claim is asserted mechanically by
503
+ * `tests/scripts/witness/reach_doctor_readonly.test.ts`, which fails on the
504
+ * mere presence of a filesystem write primitive in this file.
505
+ * 3. **Therefore an "installed, unverified" ceiling is preferred to a false
506
+ * green.** Where readiness cannot be settled without a side-effectful call
507
+ * or a network round-trip, the honest answer is `unknown` plus the
508
+ * `not-ready` channel status — never `ok`. The one exception is `--deep`,
509
+ * which is opt-in, CI-refusing, and limited to the endpoints declared in
510
+ * `DEEP_PROBES`.
511
+ *
512
+ * The readiness check below stays inside that invariant by construction: it
513
+ * reads a LOCAL config file and consults PATH. It contacts no network, and the
514
+ * only new child process it introduces is one more `--version`.
515
+ */
516
+
517
+ /**
518
+ * DECISION (Phase 2 step 3): readiness requirements live HERE, in doctor-side
519
+ * logic keyed on the backend id — NOT as an optional per-backend field in
520
+ * `reach-channels.schema.json`.
521
+ *
522
+ * Why this shape:
523
+ *
524
+ * - **It keeps the schema honest for the backends that have no such
525
+ * requirement.** With a schema field, the four other channels' backends
526
+ * would each have to *declare an absence* (omit an optional key that a
527
+ * reader must then interpret); with a code table they declare nothing at
528
+ * all, and the payload simply carries no `readiness` object for them.
529
+ * - **The requirement is a property of the upstream tool, not of the
530
+ * operator's registry entry.** "yt-dlp needs an external JS runtime" is a
531
+ * fact about yt-dlp; a registry that could omit it would hand the operator
532
+ * back the exact false green this layer exists to close.
533
+ * - **The registry is untrusted input** (see `collect()`), and a schema field
534
+ * of kind "check this config file" would widen that input's influence into
535
+ * *which local path the doctor reads*. Keyed here, the resolved path
536
+ * depends only on the operator's own environment.
537
+ * - **Precedent in this same file:** `DEEP_PROBES` is declared code-side for
538
+ * the same reason — the registry's schema describes install pinning and
539
+ * local probes, and neither a network endpoint nor a config-semantic rule
540
+ * is derivable from it. A backend absent from the table gets `undefined`,
541
+ * never an invented requirement.
542
+ *
543
+ * The rejected trade-off, stated: a schema field would make the requirement
544
+ * visible in the registry an operator reads, and would let a *new* backend with
545
+ * the same need be added by editing config instead of code. That is a real
546
+ * cost, and it is paid here — adding a second js-runtime-needing backend means
547
+ * a code edit plus a test, not a YAML line. It was judged the cheaper cost,
548
+ * because the alternative's failure mode (a registry silently declaring no
549
+ * requirement, or declaring one for a backend where it is meaningless) is a
550
+ * wrong *health verdict*, while this one is only extra work for a maintainer.
551
+ * A short form of this reasoning also lives in the `$comment` of
552
+ * `src/scripts/schemas/reach-channels.schema.json`, where a reader looking for
553
+ * the absent field will actually be standing.
554
+ */
555
+ export type ReadinessKind = 'js-runtime';
556
+
557
+ const READINESS_REQUIREMENTS: Readonly<Record<string, ReadinessKind>> = {
558
+ // `yt-dlp --version` exits 0 as soon as the binary exists, but YouTube
559
+ // extraction additionally needs an external JavaScript runtime. Only Deno
560
+ // is enabled by default; with Node the user config must opt in.
561
+ 'yt-dlp': 'js-runtime',
562
+ };
563
+
564
+ /** The literal flag whose PRESENCE is the signal — any runtime value counts. */
565
+ export const JS_RUNTIME_FLAG = '--js-runtimes';
566
+
567
+ /** Runtime the flag names when the doctor prescribes it. */
568
+ const JS_RUNTIME_PRESCRIBED = 'node';
569
+
570
+ /**
571
+ * External JavaScript runtimes, in the order checked. `deno` first because
572
+ * yt-dlp enables it with no config at all, so finding it settles the question.
573
+ */
574
+ const JS_RUNTIME_CANDIDATES: readonly string[] = ['deno', 'node'];
575
+
576
+ /**
577
+ * VERSION GATE — `2025.11.12`, the yt-dlp release that introduced
578
+ * `--js-runtimes`. Older builds reject the option outright, so prescribing it
579
+ * there would tell an operator to write a config the tool then errors on: a
580
+ * fix command that breaks a working install is worse than no fix command.
581
+ * Below the gate the remedy is therefore "upgrade first", never the flag.
582
+ *
583
+ * Provenance: taken from the roadmap step that specified this gate, NOT
584
+ * re-derived here — this command reaches no network and must not pretend to
585
+ * have checked a release feed. An unparseable version is treated as
586
+ * *unconfirmed* rather than assumed new enough (see `assessJsRuntimeReadiness`),
587
+ * so a wrong gate value can only make the doctor more conservative.
588
+ */
589
+ const JS_RUNTIME_MIN_VERSION: readonly [number, number, number] = [2025, 11, 12];
590
+
591
+ /** Bounded read: a user config is a handful of lines; 64 KiB is already absurd. */
592
+ export const READINESS_CONFIG_MAX_BYTES = 64 * 1024;
593
+
594
+ /** Deadline for the extra `--version` capture — same order as a probe. */
595
+ const VERSION_TIMEOUT_MS = 5_000;
596
+
597
+ /** `ready` = confirmed usable · `not-ready` = confirmed unusable · `unknown` = not settled. */
598
+ export type ReadinessStatus = 'ready' | 'not-ready' | 'unknown';
599
+
600
+ export interface ReadinessRow {
601
+ kind: ReadinessKind;
602
+ status: ReadinessStatus;
603
+ /** Config path resolved the way the upstream tool itself resolves it. */
604
+ config_path: string;
605
+ /** True only when that path is a real, readable regular file. */
606
+ config_present: boolean;
607
+ /** True when the literal flag was found in it (any runtime value). */
608
+ config_flag: boolean;
609
+ /** Runtimes found on PATH, in `JS_RUNTIME_CANDIDATES` order. */
610
+ runtimes: string[];
611
+ /** Parsed backend version (`YYYY.M.D`), or null when it could not be parsed. */
612
+ version: string | null;
613
+ /** Idempotent, OS-specific remedy the OPERATOR runs. Never executed here. */
614
+ fix: string | null;
615
+ detail: string;
616
+ }
617
+
618
+ /**
619
+ * The three facts about the live machine that a fixture cannot supply. Split
620
+ * out so the whole verdict — path resolution, the bounded config read, the
621
+ * version gate — is exercisable against real files with only these injected.
622
+ */
623
+ export interface JsRuntimeObservation {
624
+ /** The backend binary's own probe verdict; readiness is decidable only at `ok`. */
625
+ backend_status: ToolProbeStatus;
626
+ /**
627
+ * First line of the backend's `--version` output, or null when none was
628
+ * read. NEVER reaches the payload: it is parsed against a strict numeric
629
+ * shape and only the parsed form is reported, so raw child output cannot
630
+ * travel into a report (the same discipline `probeTool` keeps by ignoring
631
+ * stdio entirely).
632
+ */
633
+ version_raw: string | null;
634
+ /** JS runtimes found on PATH. */
635
+ runtimes: readonly string[];
636
+ }
637
+
638
+ /**
639
+ * Resolve the yt-dlp USER config path the way yt-dlp itself resolves it:
640
+ * `$XDG_CONFIG_HOME/yt-dlp/config` when `XDG_CONFIG_HOME` is set and non-empty,
641
+ * otherwise `~/.config/yt-dlp/config`.
642
+ *
643
+ * THIS IS THE FAILURE WORTH PREVENTING. If the doctor read `~/.config/...`
644
+ * while the tool read `$XDG_CONFIG_HOME/...`, the doctor and the operator would
645
+ * agree with each other — "the flag is not there", "I added it, still broken" —
646
+ * while the real tool was reading a different file the whole time. A diagnostic
647
+ * that resolves a path differently from the tool it diagnoses is worse than no
648
+ * diagnostic, because it is confidently wrong.
649
+ *
650
+ * KNOWN GAP, not silently absorbed: on Windows yt-dlp ALSO honours
651
+ * `%APPDATA%\yt-dlp\config`. This resolver implements the XDG pair only, so on
652
+ * a win32 host with no `XDG_CONFIG_HOME` it inspects `~/.config/yt-dlp/config`
653
+ * — a location yt-dlp does read, but not the only one. The emitted fix targets
654
+ * the same path this check read, so the two never disagree with each other;
655
+ * they can, on win32, both be looking somewhere the operator's real config is
656
+ * not. Stated rather than papered over.
657
+ */
658
+ export function resolveYtDlpConfigPath(env: NodeJS.ProcessEnv = process.env): string {
659
+ const xdg = env['XDG_CONFIG_HOME'];
660
+ const base =
661
+ typeof xdg === 'string' && xdg.trim() !== ''
662
+ ? expandHome(xdg)
663
+ : path.join(os.homedir(), '.config');
664
+ return path.join(base, 'yt-dlp', 'config');
665
+ }
666
+
667
+ export interface ConfigFlagRead {
668
+ present: boolean;
669
+ flag: boolean;
670
+ /** Non-null when the path exists but was deliberately NOT read. */
671
+ refused: string | null;
672
+ }
673
+
674
+ /**
675
+ * Bounded, symlink-refusing read for the literal flag.
676
+ *
677
+ * - `lstatSync`, never `statSync`: a symlink is REFUSED rather than followed.
678
+ * The doctor is asked to inspect a config file, and following a link would
679
+ * let something other than that file answer for it (the same reasoning as
680
+ * `confineCredentialPath` above, applied to a read instead of a stat).
681
+ * Non-regular files (directory, fifo, socket, device) are refused for the
682
+ * same reason — reading one can block forever.
683
+ * - The read is CAPPED at `READINESS_CONFIG_MAX_BYTES` via
684
+ * `openSync(path, 'r')` + `readSync`, not `readFileSync`: a config path that
685
+ * is really a 5 GB file must not become an OOM in a health report.
686
+ * - Only the flag's PRESENCE is extracted. The file's contents never reach the
687
+ * payload, so an operator's config (which can legitimately carry cookies
688
+ * paths, proxies, credentials) cannot leak through a diagnostic.
689
+ */
690
+ export function readConfigFlag(
691
+ configPath: string,
692
+ maxBytes: number = READINESS_CONFIG_MAX_BYTES,
693
+ ): ConfigFlagRead {
694
+ let stat: fs.Stats;
695
+ try {
696
+ stat = fs.lstatSync(configPath);
697
+ } catch {
698
+ return { present: false, flag: false, refused: null };
699
+ }
700
+ if (stat.isSymbolicLink()) {
701
+ return {
702
+ present: false,
703
+ flag: false,
704
+ refused: `${configPath} is a symlink — refused rather than followed, so nothing but that file itself can answer for it`,
705
+ };
706
+ }
707
+ if (!stat.isFile()) {
708
+ return {
709
+ present: false,
710
+ flag: false,
711
+ refused: `${configPath} is not a regular file — not read`,
712
+ };
713
+ }
714
+ let descriptor: number | null = null;
715
+ try {
716
+ descriptor = fs.openSync(configPath, 'r');
717
+ const buffer = Buffer.alloc(Math.min(maxBytes, Math.max(stat.size, 1)));
718
+ const read = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
719
+ const text = buffer.subarray(0, read).toString('utf-8');
720
+ return { present: true, flag: text.includes(JS_RUNTIME_FLAG), refused: null };
721
+ } catch {
722
+ return {
723
+ present: true,
724
+ flag: false,
725
+ refused: `${configPath} exists but could not be read`,
726
+ };
727
+ } finally {
728
+ if (descriptor !== null) {
729
+ try {
730
+ fs.closeSync(descriptor);
731
+ } catch {
732
+ // A descriptor that cannot be closed changes no verdict.
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Parse the `YYYY.M.D` release shape yt-dlp reports, tolerating the nightly
740
+ * fourth component (`2025.11.12.232946`). ANYTHING else returns null, which the
741
+ * caller reads as "cannot confirm" — never as "new enough". The nightly suffix
742
+ * is dropped from the reported version on purpose: the gate compares releases.
743
+ */
744
+ const YT_DLP_VERSION_RE = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:\.\d+)?$/;
745
+
746
+ export function parseYtDlpVersion(raw: string | null): readonly [number, number, number] | null {
747
+ if (raw === null) return null;
748
+ const match = YT_DLP_VERSION_RE.exec(raw.trim());
749
+ if (match === null) return null;
750
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
751
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
752
+ return null;
753
+ }
754
+ return [year, month, day];
755
+ }
756
+
757
+ function compareVersions(
758
+ left: readonly [number, number, number],
759
+ right: readonly [number, number, number],
760
+ ): number {
761
+ for (let index = 0; index < 3; index += 1) {
762
+ const delta = (left[index] as number) - (right[index] as number);
763
+ if (delta !== 0) return delta;
764
+ }
765
+ return 0;
766
+ }
767
+
768
+ function shellQuote(value: string): string {
769
+ return `'${value.replace(/'/g, "'\\''")}'`;
770
+ }
771
+
772
+ function powershellQuote(value: string): string {
773
+ return `'${value.replace(/'/g, "''")}'`;
774
+ }
775
+
776
+ /**
777
+ * The IDEMPOTENT, OS-specific remedy. The doctor prints it; it never runs it
778
+ * (no install, no write — `non-destructive-by-default` plus the read-only
779
+ * witness).
780
+ *
781
+ * Idempotence is the whole design constraint: an operator may paste this twice,
782
+ * or into a provisioning script that runs on every boot, so it must create the
783
+ * directory if needed and append the flag ONLY when the line is not already
784
+ * there. On POSIX the `grep … || printf …` pair is wrapped in `{ …; }` because
785
+ * `A && B || C` would run C when A fails — the grouping is what keeps a failed
786
+ * `mkdir` from silently becoming an append attempt. The appended text is
787
+ * byte-identical to what `grep -qxF` matches, so the second run is a no-op.
788
+ *
789
+ * The path is the SAME one `readConfigFlag` inspected, so the check and the
790
+ * remedy can never point at different files.
791
+ */
792
+ export function jsRuntimeFixCommand(
793
+ configPath: string,
794
+ platform: string = process.platform,
795
+ ): string {
796
+ const line = `${JS_RUNTIME_FLAG} ${JS_RUNTIME_PRESCRIBED}`;
797
+ const directory = path.dirname(configPath);
798
+ if (platform === 'win32') {
799
+ return (
800
+ `New-Item -ItemType Directory -Force -Path ${powershellQuote(directory)} | Out-Null; ` +
801
+ `if (-not (Test-Path -LiteralPath ${powershellQuote(configPath)}) -or ` +
802
+ `-not (Select-String -LiteralPath ${powershellQuote(configPath)} -SimpleMatch ` +
803
+ `-Pattern ${powershellQuote(JS_RUNTIME_FLAG)} -Quiet)) ` +
804
+ `{ Add-Content -LiteralPath ${powershellQuote(configPath)} -Value ${powershellQuote(line)} }`
805
+ );
806
+ }
807
+ return (
808
+ `mkdir -p ${shellQuote(directory)} && ` +
809
+ `{ grep -qxF -- ${shellQuote(line)} ${shellQuote(configPath)} 2>/dev/null || ` +
810
+ `printf '%s\\n' ${shellQuote(line)} >> ${shellQuote(configPath)}; }`
811
+ );
812
+ }
813
+
814
+ /**
815
+ * Settle the js-runtime readiness question from one observation plus the real,
816
+ * locally-resolved config file. Pure with respect to the network; the only I/O
817
+ * is the bounded config read.
818
+ *
819
+ * Order of the branches is the contract:
820
+ *
821
+ * 1. backend not `ok` → `unknown`. Nothing about a runtime can make an
822
+ * uninstalled extractor work, so no readiness verdict is claimed; the
823
+ * channel's own `missing` / `broken` status and its pinned install
824
+ * prescription are the actionable output. The runtime and config
825
+ * observations are still reported — they were really made, and they tell
826
+ * the operator what will ALSO be needed after the install.
827
+ * 2. Deno on PATH → `ready`. yt-dlp enables Deno by default, so no config
828
+ * flag is required and the version gate is irrelevant.
829
+ * 3. No JS runtime at all → `not-ready`. No config edit fixes that, so no fix
830
+ * command is emitted for it — installing a runtime is the operator's
831
+ * decision and is not a prescription this registry pins.
832
+ * 4. Node-only: the version gate runs BEFORE the flag is considered, so a
833
+ * build that cannot support the flag is never told to add it, and an
834
+ * unparseable version can never read as `ready`.
835
+ */
836
+ export function assessJsRuntimeReadiness(
837
+ observation: JsRuntimeObservation,
838
+ env: NodeJS.ProcessEnv = process.env,
839
+ ): ReadinessRow {
840
+ const configPath = resolveYtDlpConfigPath(env);
841
+ const read = readConfigFlag(configPath);
842
+ const runtimes = [...observation.runtimes];
843
+ const parsed = parseYtDlpVersion(observation.version_raw);
844
+ const version = parsed === null ? null : parsed.join('.');
845
+ const suffix = read.refused === null ? '' : ` (${read.refused})`;
846
+
847
+ const base = {
848
+ kind: 'js-runtime' as const,
849
+ config_path: configPath,
850
+ config_present: read.present,
851
+ config_flag: read.flag,
852
+ runtimes,
853
+ version,
854
+ };
855
+
856
+ if (observation.backend_status !== 'ok') {
857
+ return {
858
+ ...base,
859
+ status: 'unknown',
860
+ version: null,
861
+ fix: null,
862
+ detail:
863
+ `the backend itself is ${observation.backend_status}, so extraction readiness is not evaluated yet — ` +
864
+ `install it first (the channel's pinned prescription above), then re-run; ` +
865
+ `the runtime and config facts below were still observed and will still apply${suffix}`,
866
+ };
867
+ }
868
+
869
+ if (runtimes.includes('deno')) {
870
+ return {
871
+ ...base,
872
+ status: 'ready',
873
+ fix: null,
874
+ detail: `deno is on PATH and yt-dlp enables it by default — no ${JS_RUNTIME_FLAG} entry is required${suffix}`,
875
+ };
876
+ }
877
+
878
+ if (runtimes.length === 0) {
879
+ return {
880
+ ...base,
881
+ status: 'not-ready',
882
+ fix: null,
883
+ detail:
884
+ `no external JavaScript runtime is on PATH (checked: ${JS_RUNTIME_CANDIDATES.join(', ')}) — ` +
885
+ `YouTube extraction needs one; installing Deno needs no yt-dlp config change${suffix}`,
886
+ };
887
+ }
888
+
889
+ if (parsed === null) {
890
+ return {
891
+ ...base,
892
+ status: 'unknown',
893
+ fix: null,
894
+ detail:
895
+ `cannot confirm this yt-dlp version supports ${JS_RUNTIME_FLAG}: --version reported no ` +
896
+ `YYYY.M.D release, so the ${JS_RUNTIME_MIN_VERSION.join('.')} gate cannot be applied — ` +
897
+ `upgrade first (the channel's pinned prescription above), then re-run${suffix}`,
898
+ };
899
+ }
900
+
901
+ if (compareVersions(parsed, JS_RUNTIME_MIN_VERSION) < 0) {
902
+ return {
903
+ ...base,
904
+ status: 'not-ready',
905
+ fix: null,
906
+ detail:
907
+ `yt-dlp ${version} is older than ${JS_RUNTIME_MIN_VERSION.join('.')}, the release that added ` +
908
+ `${JS_RUNTIME_FLAG} — this build would reject the flag, so upgrade first ` +
909
+ `(the channel's pinned prescription above) rather than editing the config${suffix}`,
910
+ };
911
+ }
912
+
913
+ if (read.flag) {
914
+ return {
915
+ ...base,
916
+ status: 'ready',
917
+ fix: null,
918
+ detail: `node is on PATH and ${configPath} carries ${JS_RUNTIME_FLAG}${suffix}`,
919
+ };
920
+ }
921
+
922
+ return {
923
+ ...base,
924
+ status: 'not-ready',
925
+ fix: jsRuntimeFixCommand(configPath),
926
+ detail:
927
+ `node is the only JavaScript runtime on PATH and ${configPath} ` +
928
+ `${read.present ? `does not carry ${JS_RUNTIME_FLAG}` : `does not exist, so nothing carries ${JS_RUNTIME_FLAG}`} — ` +
929
+ `yt-dlp will not use node until it does${suffix}`,
930
+ };
931
+ }
932
+
933
+ /**
934
+ * Probe the JS runtimes through the EXISTING probe machinery rather than a new
935
+ * spawn path: `probeTool` already hardens the child env, bounds the deadline,
936
+ * ignores stdio and never throws, so presence-on-PATH needs no second
937
+ * implementation.
938
+ */
939
+ export function probeJsRuntimes(): string[] {
940
+ return JS_RUNTIME_CANDIDATES.filter(
941
+ (bin) =>
942
+ probeTool({ name: `js-runtime/${bin}`, bin, probe_args: ['--version'] }).status ===
943
+ 'ok',
944
+ );
945
+ }
946
+
947
+ /**
948
+ * Capture a backend's `--version` line.
949
+ *
950
+ * This is the ONE place readiness cannot reuse `probeTool`: that function
951
+ * deliberately ignores child stdio (its verdict is the exit status), and the
952
+ * version gate needs the string. So it is a separate `spawnSync` — routed
953
+ * through `hardenedSpawnEnv()` like every other spawn in this file (ADR-123),
954
+ * with the same bounded deadline, `stdio` piped for stdout ONLY, and a capped
955
+ * buffer. The argument list is the literal `['--version']` written here, never a
956
+ * registry value, so this cannot become a channel for a chosen subcommand.
957
+ */
958
+ export function probeBackendVersion(probeCmd: string): string | null {
959
+ const result = spawnSync(probeCmd, ['--version'], {
960
+ env: hardenedSpawnEnv(),
961
+ timeout: VERSION_TIMEOUT_MS,
962
+ stdio: ['ignore', 'pipe', 'ignore'],
963
+ encoding: 'utf-8',
964
+ maxBuffer: 64 * 1024,
965
+ windowsHide: true,
966
+ });
967
+ if (result.error !== undefined || result.status !== 0) return null;
968
+ const first = (result.stdout ?? '').split('\n')[0]?.trim() ?? '';
969
+ return first === '' ? null : first;
970
+ }
971
+
972
+ /**
973
+ * Readiness verdict for one backend, or `null` when that backend declares no
974
+ * requirement. `Object.hasOwn`, never a bare index — same prototype-chain
975
+ * reasoning as `runDeepProbe`: `backendId: 'constructor'` must take the
976
+ * no-requirement path rather than resolving to an inherited value.
977
+ */
978
+ export function liveReadinessRow(
979
+ backendId: string,
980
+ probedStatus: ToolProbeStatus,
981
+ ): ReadinessRow | null {
982
+ const kind = Object.hasOwn(READINESS_REQUIREMENTS, backendId)
983
+ ? READINESS_REQUIREMENTS[backendId]
984
+ : undefined;
985
+ if (kind === undefined) return null;
986
+ return assessJsRuntimeReadiness({
987
+ backend_status: probedStatus,
988
+ // Only worth a spawn when the binary answered its own probe.
989
+ version_raw: probedStatus === 'ok' ? probeBackendVersion(backendId) : null,
990
+ runtimes: probeJsRuntimes(),
991
+ });
992
+ }
993
+
994
+ /**
995
+ * Injectable readiness observer. The default is `liveReadinessRow`; the option
996
+ * exists for the same reason `now` does — a fixture cannot install yt-dlp, and
997
+ * a machine without it could otherwise only ever exercise the `unknown` branch.
998
+ * TEST-FACING: nothing on the CLI surface can set it.
999
+ */
1000
+ export type ReadinessProbeFn = (
1001
+ backendId: string,
1002
+ probedStatus: ToolProbeStatus,
1003
+ ) => ReadinessRow | null;
1004
+
1005
+ /**
1006
+ * Build one channel row. Throws on a malformed channel — the caller turns
1007
+ * that into this channel's `error` row so the rest of the report survives.
1008
+ *
1009
+ * THE SCHEMA GATE IS `collect()`, NOT THIS FUNCTION. `collect()` runs
1010
+ * `validate_file` (schema + the cross-field `probe_cmd === id` rule) and refuses
1011
+ * the whole run before `load_registry`, this function, or any probe is reached.
1012
+ * This export exists for the tests that exercise the per-channel isolation layer
1013
+ * directly — no registry can get past that gate to reach it otherwise — so it is
1014
+ * TEST-FACING, not a public API. A direct caller is responsible for validating
1015
+ * its own input first; the entry gate and the `probe_cmd` check below are
1016
+ * defense in depth for exactly that case, not a substitute for the schema.
1017
+ */
1018
+ export function buildChannelRow(
1019
+ raw: unknown,
1020
+ options: { deep: boolean; now: Date; readiness?: ReadinessProbeFn },
1021
+ ): ChannelRow {
1022
+ const readinessProbe = options.readiness ?? liveReadinessRow;
1023
+ // Entry gate — defense in depth behind `collect()`'s schema validation. Each
1024
+ // shape a validated registry cannot produce is refused with a named reason
1025
+ // rather than read speculatively: a row built from an entry whose `id` or
1026
+ // `backends` had to be guessed would attribute probe results to a channel
1027
+ // nobody declared, which is worse than no row at all.
1028
+ const record = toRecord(raw);
1029
+ if (record === null) {
1030
+ throw new Error('channel entry is not a mapping');
1031
+ }
1032
+ const id = readString(record, 'id');
1033
+ if (id === null) {
1034
+ throw new Error('channel entry carries no id — a row can only be attributed by id');
1035
+ }
1036
+ const backendsRaw = record['backends'];
1037
+ if (!Array.isArray(backendsRaw)) {
1038
+ throw new Error(`channel '${id}': backends must be a non-empty list, got a non-list`);
1039
+ }
1040
+ if (backendsRaw.length === 0) {
1041
+ throw new Error(`channel '${id}': backends must be a non-empty list, got an empty one`);
1042
+ }
1043
+
1044
+ const tier = readString(record, 'tier') ?? '(unset)';
1045
+ const lifecycle = readString(record, 'lifecycle') ?? '(unset)';
1046
+ const lastVerified = readString(record, 'last_verified') ?? '(unset)';
1047
+ const removalAfter = readString(record, 'removal_after');
1048
+ const replacement = readString(record, 'replacement');
1049
+ const credentialPath = readString(record, 'credential_path');
1050
+
1051
+ const warnings: string[] = [];
1052
+ const credential = credentialPath === null ? null : inspectCredential(credentialPath);
1053
+ if (credential !== null && credential.group_or_world_readable) {
1054
+ warnings.push(
1055
+ `credential file ${credential.path} is mode ${credential.mode ?? '?'} ` +
1056
+ `(group- or world-readable) — run: chmod 600 ${credential.path}`,
1057
+ );
1058
+ }
1059
+ // A refusal is louder than a silent skip: an operator whose credential really
1060
+ // does live outside the permitted roots must see WHY nothing was checked,
1061
+ // instead of reading an unevaluated check as a passing one.
1062
+ if (
1063
+ credential !== null &&
1064
+ !credential.checked &&
1065
+ process.platform !== 'win32' &&
1066
+ !confineCredentialPath(credential.path)
1067
+ ) {
1068
+ warnings.push(
1069
+ `credential_path ${credential.path} is outside the permitted roots ` +
1070
+ `(your home directory, this repo, or the system temp dir) — not inspected. ` +
1071
+ `Move the credential, or drop the field.`,
1072
+ );
1073
+ }
1074
+
1075
+ // Past `removal_after` the channel is not probed at all: the migration
1076
+ // window closed, so its backends' health is no longer a question.
1077
+ //
1078
+ // This comparison IS string-wise, and that is sound rather than a shortcut:
1079
+ // `reach-channels.schema.json` pins `removal_after` (line 57) to
1080
+ // `^[0-9]{4}-[0-9]{2}-[0-9]{2}$` and `collect()` refuses the registry before
1081
+ // this line on any schema error, so both operands are fixed-width
1082
+ // zero-padded ISO dates — a form whose lexicographic order IS its
1083
+ // chronological order. `isoDate()` emits the same shape. (The staleness lint
1084
+ // takes the other route for the same field, parsing to epoch numbers; see
1085
+ // the note at `check_reach_staleness.ts` § (c).)
1086
+ const today = isoDate(options.now);
1087
+ const removed = removalAfter !== null && today > removalAfter;
1088
+ if (removed) {
1089
+ warnings.push(
1090
+ `removed: past removal_after ${removalAfter} — probe skipped` +
1091
+ (replacement === null
1092
+ ? ' (no replacement declared)'
1093
+ : `; superseded by channel '${replacement}'`),
1094
+ );
1095
+ return {
1096
+ id,
1097
+ status: 'removed',
1098
+ active_backend: null,
1099
+ tier,
1100
+ lifecycle,
1101
+ last_verified: lastVerified,
1102
+ fix: null,
1103
+ warnings,
1104
+ credential,
1105
+ backends: [],
1106
+ };
1107
+ }
1108
+ if (lifecycle === 'deprecated') {
1109
+ warnings.push(
1110
+ 'deprecated: still probed for one migration window' +
1111
+ (replacement === null
1112
+ ? ' (no replacement declared)'
1113
+ : `; superseded by channel '${replacement}'`) +
1114
+ (removalAfter === null ? '' : `, removal_after ${removalAfter}`),
1115
+ );
1116
+ }
1117
+
1118
+ const backends: BackendRow[] = [];
1119
+ for (const backendRaw of backendsRaw) {
1120
+ const backend = toRecord(backendRaw);
1121
+ if (backend === null) {
1122
+ throw new Error(`channel '${id}': backend entry is not a mapping`);
1123
+ }
1124
+ const backendId = readString(backend, 'id');
1125
+ const probeCmd = readString(backend, 'probe_cmd');
1126
+ if (backendId === null || probeCmd === null) {
1127
+ throw new Error(`channel '${id}': backend entry needs id + probe_cmd`);
1128
+ }
1129
+ // Defense in depth behind the schema gate in `collect()`: even if a
1130
+ // registry reached here unvalidated, a row labelled `backendId` may
1131
+ // never carry the health of a different executable. Refusing is the
1132
+ // only safe answer — relabelling would hide which binary ran.
1133
+ if (backendId !== probeCmd) {
1134
+ throw new Error(
1135
+ `channel '${id}': backend '${backendId}' declares probe_cmd '${probeCmd}' — ` +
1136
+ `a backend may only probe its own id`,
1137
+ );
1138
+ }
1139
+ const probeArgsRaw = backend['probe_args'];
1140
+ const probeArgs = Array.isArray(probeArgsRaw)
1141
+ ? probeArgsRaw.filter((arg): arg is string => typeof arg === 'string')
1142
+ : [];
1143
+ // The `install` map IS the prescription: `probeTool` selects the
1144
+ // current platform's string (falling back to `default`) and echoes it.
1145
+ // Nothing here composes or edits an install command.
1146
+ const install = toRecord(backend['install']);
1147
+ const fixPrescription: FixPrescription | undefined =
1148
+ install === null ? undefined : (install as Record<string, string>);
1149
+
1150
+ const probed = probeTool({
1151
+ name: `${id}/${backendId}`,
1152
+ bin: probeCmd,
1153
+ probe_args: probeArgs,
1154
+ ...(fixPrescription === undefined ? {} : { fix: fixPrescription }),
1155
+ });
1156
+
1157
+ const readiness = readinessProbe(backendId, probed.status);
1158
+
1159
+ backends.push({
1160
+ id: backendId,
1161
+ probe_cmd: probeCmd,
1162
+ status: probed.status,
1163
+ path: probed.path,
1164
+ exit_code: probed.exit_code,
1165
+ diagnostic: probed.diagnostic,
1166
+ fix: probed.fix,
1167
+ deep: options.deep ? runDeepProbe(probeCmd, probed.status) : null,
1168
+ // Omitted entirely (not null) when the backend declares no
1169
+ // requirement — the four other channels gain no field at all.
1170
+ ...(readiness === null ? {} : { readiness }),
1171
+ });
1172
+ }
1173
+
1174
+ // Backend order is the switch: the first USABLE candidate wins. A backend
1175
+ // whose probe is `ok` but whose declared readiness requirement is not
1176
+ // confirmed satisfied is NOT usable — that is the whole point of the
1177
+ // readiness layer, and treating it as active would reintroduce the false
1178
+ // green (`yt-dlp --version` exits 0, the transcript pull still fails).
1179
+ const usable = (backend: BackendRow): boolean =>
1180
+ backend.status === 'ok' &&
1181
+ (backend.readiness === undefined || backend.readiness.status === 'ready');
1182
+ const active = backends.find(usable) ?? null;
1183
+ // With no healthy candidate the channel takes the FIRST candidate's
1184
+ // verdict — that is the one whose fix the operator is expected to run.
1185
+ const first = backends[0] as BackendRow;
1186
+ // `not-ready` = "installed and answering, but not confirmed able to satisfy
1187
+ // this channel". It covers BOTH readiness verdicts that are not `ready`:
1188
+ // `not-ready` (confirmed unusable) and `unknown` (not settled). One enum
1189
+ // value, because the distinction between them is already carried precisely
1190
+ // by `backends[].readiness.status` — and because reporting `ok` for an
1191
+ // unsettled readiness is exactly the false green this layer closes (see the
1192
+ // invariant block: an "installed, unverified" ceiling over a false green).
1193
+ const unready = backends.some(
1194
+ (backend) =>
1195
+ backend.status === 'ok' &&
1196
+ backend.readiness !== undefined &&
1197
+ backend.readiness.status !== 'ready',
1198
+ );
1199
+ const status: ChannelStatus =
1200
+ active !== null ? 'ok' : unready ? 'not-ready' : first.status;
1201
+ // Unchanged meaning: `fix` is the pinned INSTALL prescription. A readiness
1202
+ // remedy is a different kind of instruction (it edits an operator's config
1203
+ // rather than installing a pin) and lives in `backends[].readiness.fix`, so
1204
+ // one field never carries two meanings.
1205
+ const fix =
1206
+ active === null && (first.status === 'missing' || first.status === 'broken')
1207
+ ? first.fix
1208
+ : null;
1209
+
1210
+ return {
1211
+ id,
1212
+ status,
1213
+ active_backend: active === null ? null : active.id,
1214
+ tier,
1215
+ lifecycle,
1216
+ last_verified: lastVerified,
1217
+ fix,
1218
+ warnings,
1219
+ credential,
1220
+ backends,
1221
+ };
1222
+ }
1223
+
1224
+ export function errorRow(id: string, err: unknown): ChannelRow {
1225
+ // `sanitizeParseError`, not `String(err)`: this was the one error→output path
1226
+ // in the reach scripts that did not go through it, and a YAMLParseError
1227
+ // stringifies with the offending source line plus a caret — no error message
1228
+ // reaching this payload may carry a line of the registry it came from.
1229
+ const message = sanitizeParseError(err);
1230
+ return {
1231
+ id,
1232
+ status: 'error',
1233
+ active_backend: null,
1234
+ tier: '(unknown)',
1235
+ lifecycle: '(unknown)',
1236
+ last_verified: '(unknown)',
1237
+ fix: null,
1238
+ warnings: [`channel could not be read: ${message}`],
1239
+ credential: null,
1240
+ backends: [],
1241
+ };
1242
+ }
1243
+
1244
+ /** Build the report payload. Throws only for the exit-2 class. */
1245
+ export function collect(options: CollectOptions = {}): ReachDoctorPayload {
1246
+ const registryPath = path.resolve(options.registryPath ?? REGISTRY_PATH);
1247
+ const deep = options.deep ?? false;
1248
+ const now = options.now ?? new Date();
1249
+
1250
+ // The CI refusal lives HERE, not only in the CLI: `collect` is exported and
1251
+ // takes `deep`, so an importer or a test calling `collect({ deep: true })`
1252
+ // would otherwise perform real network requests inside CI. `main()` keeps
1253
+ // its own check purely to phrase the operator-facing message.
1254
+ if (deep) {
1255
+ const ciMarker = detectCi();
1256
+ if (ciMarker !== null) {
1257
+ throw new ReachDoctorUsageError(
1258
+ `--deep performs real network requests and never runs in CI (${ciMarker} is set)`,
1259
+ );
1260
+ }
1261
+ }
1262
+
1263
+ // Enforce the registry schema BEFORE anything is spawned. Without this the
1264
+ // `--registry <path>` flag was an arbitrary-execution primitive: an
1265
+ // operator-supplied file declaring `probe_cmd: sh` + `probe_args: ['-c',
1266
+ // '<payload>']` was loaded straight into `probeTool()` and executed, and
1267
+ // the report printed `✅ ok`. Any error-severity finding — including the
1268
+ // cross-field `probe_cmd-binding` rule — refuses the whole run (exit 2);
1269
+ // a report is not worth producing from a file we cannot vouch for.
1270
+ const findings = validate_file(registryPath).filter(
1271
+ (finding) => finding.severity === 'error',
1272
+ );
1273
+ if (findings.length > 0) {
1274
+ // Redacted like every other echo of registry content. This was the FOURTH
1275
+ // site of the same class and the last one found: the schema validator
1276
+ // quotes the offending value in its message, so composing this refusal
1277
+ // detail raw meant `--registry <any file>` printed that file's matching
1278
+ // values straight back — the exact oracle the other three sites had
1279
+ // already closed. Found by re-running the leak probe against EVERY entry
1280
+ // point rather than assuming that fixing three had fixed the class.
1281
+ const detail = findings
1282
+ .slice(0, 10)
1283
+ .map(
1284
+ (finding) =>
1285
+ `${finding.path}: ${finding.rule}: ` +
1286
+ `${excerpt_for_finding(finding.message, SCHEMA_MESSAGE_MAX_CHARS)}`,
1287
+ )
1288
+ .join('; ');
1289
+ const more = findings.length > 10 ? ` (+${findings.length - 10} more)` : '';
1290
+ throw new RegistryLoadError(
1291
+ `registry fails its schema, refusing to probe it: ${registryPath}: ` +
1292
+ `${findings.length} violation(s): ${detail}${more}`,
1293
+ );
1294
+ }
1295
+
1296
+ const data = toRecord(load_registry(registryPath));
1297
+ if (data === null) {
1298
+ throw new RegistryLoadError(`registry is not a mapping: ${registryPath}`);
1299
+ }
1300
+ const channelsRaw = data['channels'];
1301
+ if (!Array.isArray(channelsRaw)) {
1302
+ throw new RegistryLoadError(`registry declares no channels list: ${registryPath}`);
1303
+ }
1304
+
1305
+ let selected = channelsRaw;
1306
+ if (options.channel !== undefined) {
1307
+ const wanted = options.channel;
1308
+ selected = channelsRaw.filter((entry) => {
1309
+ const record = toRecord(entry);
1310
+ return record !== null && record['id'] === wanted;
1311
+ });
1312
+ if (selected.length === 0) {
1313
+ const known = channelsRaw
1314
+ .map((entry) => toRecord(entry)?.['id'])
1315
+ .filter((value): value is string => typeof value === 'string');
1316
+ throw new ReachDoctorUsageError(
1317
+ `unknown channel '${wanted}' — registry declares: ${known.join(', ')}`,
1318
+ );
1319
+ }
1320
+ }
1321
+
1322
+ const channels: ChannelRow[] = [];
1323
+ for (const [index, raw] of selected.entries()) {
1324
+ // Attribution before parsing: an unreadable entry still gets a row.
1325
+ const declaredId = toRecord(raw)?.['id'];
1326
+ const label = typeof declaredId === 'string' && declaredId !== ''
1327
+ ? declaredId
1328
+ : `(channel #${index})`;
1329
+ try {
1330
+ channels.push(
1331
+ buildChannelRow(raw, {
1332
+ deep,
1333
+ now,
1334
+ ...(options.readiness === undefined ? {} : { readiness: options.readiness }),
1335
+ }),
1336
+ );
1337
+ } catch (err) {
1338
+ channels.push(errorRow(label, err));
1339
+ }
1340
+ }
1341
+
1342
+ const suggestions: string[] = [];
1343
+ if (deep) {
1344
+ const today = isoDate(now);
1345
+ for (const channel of channels) {
1346
+ const verified = channel.backends.filter((b) => b.deep?.status === 'ok');
1347
+ if (channel.status === 'ok' && verified.length > 0) {
1348
+ suggestions.push(
1349
+ `channels[${channel.id}].last_verified: "${today}" ` +
1350
+ `(deep-verified: ${verified.map((b) => b.id).join(', ')})`,
1351
+ );
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ const rel = path.relative(ROOT, registryPath);
1357
+ return {
1358
+ schema_version: SCHEMA_VERSION,
1359
+ platform: process.platform,
1360
+ registry: rel !== '' && !rel.startsWith('..') ? rel : registryPath,
1361
+ deep,
1362
+ channels,
1363
+ last_verified_suggestions: suggestions,
1364
+ };
1365
+ }
1366
+
1367
+ const STATUS_MARKER: Readonly<Record<ChannelStatus, string>> = {
1368
+ ok: '✅ ',
1369
+ missing: '❌ ',
1370
+ broken: '❌ ',
1371
+ timeout: '⚠️ ',
1372
+ error: '⚠️ ',
1373
+ removed: '· ',
1374
+ // Distinct from `missing`'s ❌ on purpose: the binary IS installed, so the
1375
+ // operator's next action is a config/upgrade step, not an install.
1376
+ 'not-ready': '⚠️ ',
1377
+ };
1378
+
1379
+ export function renderTable(payload: ReachDoctorPayload): string {
1380
+ const lines: string[] = [];
1381
+ lines.push('Reach channels — read-only health report');
1382
+ lines.push('-'.repeat(72));
1383
+ lines.push(`registry: ${payload.registry}`);
1384
+ lines.push(`platform: ${payload.platform}`);
1385
+ lines.push(
1386
+ `mode: ${payload.deep ? 'deep (one real network request per declared backend)' : 'shallow (local probes only, no network)'}`,
1387
+ );
1388
+ lines.push('');
1389
+ lines.push('Not a routing table: no channel below is routed, preferred, or');
1390
+ lines.push('suggested to an agent (Phase 0 benchmark band: stop).');
1391
+ lines.push('');
1392
+ for (const channel of payload.channels) {
1393
+ lines.push(
1394
+ `${STATUS_MARKER[channel.status]}${channel.id.padEnd(14)} ${channel.status.padEnd(8)} ` +
1395
+ `backend ${(channel.active_backend ?? '—').padEnd(8)} ` +
1396
+ `tier ${channel.tier.padEnd(11)} ${channel.lifecycle.padEnd(12)} ` +
1397
+ `verified ${channel.last_verified}`,
1398
+ );
1399
+ for (const backend of channel.backends) {
1400
+ const mark = backend.status === 'ok' ? '✅' : '❌';
1401
+ lines.push(` ${mark} ${backend.id.padEnd(10)} ${backend.diagnostic}`);
1402
+ if (backend.deep !== null) {
1403
+ lines.push(` deep: ${backend.deep.status} — ${backend.deep.detail}`);
1404
+ }
1405
+ // Rendered for BOTH the ready and the not-ready case: an operator
1406
+ // reading "installed" needs to see that the second question was
1407
+ // asked at all, otherwise a silent pass is indistinguishable from
1408
+ // a check that never ran.
1409
+ if (backend.readiness !== undefined) {
1410
+ const readiness = backend.readiness;
1411
+ lines.push(
1412
+ ` readiness (${readiness.kind}): ${readiness.status} — ${readiness.detail}`,
1413
+ );
1414
+ lines.push(
1415
+ ` readiness config: ${readiness.config_path} ` +
1416
+ `(${readiness.config_present ? 'present' : 'not a readable file'}, ` +
1417
+ `${JS_RUNTIME_FLAG}: ${readiness.config_flag ? 'yes' : 'no'}) · ` +
1418
+ `runtimes on PATH: ${readiness.runtimes.length === 0 ? 'none' : readiness.runtimes.join(', ')} · ` +
1419
+ // `null` covers both "never probed" and "unparseable",
1420
+ // so the label claims neither — `detail` says which.
1421
+ `version: ${readiness.version ?? 'not confirmed'}`,
1422
+ );
1423
+ if (readiness.fix !== null) {
1424
+ lines.push(` readiness fix (${payload.platform}): ${readiness.fix}`);
1425
+ }
1426
+ }
1427
+ }
1428
+ if (channel.fix !== null) {
1429
+ lines.push(` fix (${payload.platform}): ${channel.fix}`);
1430
+ }
1431
+ if (channel.credential !== null) {
1432
+ const cred = channel.credential;
1433
+ const state = !cred.checked
1434
+ ? 'not checked on this platform'
1435
+ : !cred.present
1436
+ ? 'declared but not present'
1437
+ : `mode ${cred.mode ?? '?'}`;
1438
+ lines.push(` credential: ${cred.path} (${state})`);
1439
+ }
1440
+ for (const warning of channel.warnings) {
1441
+ lines.push(` ⚠️ ${warning}`);
1442
+ }
1443
+ }
1444
+ if (payload.last_verified_suggestions.length > 0) {
1445
+ lines.push('');
1446
+ lines.push('last_verified lines you MAY commit (nothing was written):');
1447
+ lines.push('-'.repeat(72));
1448
+ for (const suggestion of payload.last_verified_suggestions) {
1449
+ lines.push(` ${suggestion}`);
1450
+ }
1451
+ }
1452
+ return lines.join('\n');
1453
+ }
1454
+
1455
+ /**
1456
+ * `--strict` fails on any **admitted** channel that is not `ok`. A `removed`
1457
+ * channel is not admitted — its migration window closed, so it is excluded by
1458
+ * design rather than treated as a failure.
1459
+ *
1460
+ * A credential-permission warning does NOT change the exit code: the flag's
1461
+ * contract is "non-zero when a channel is not ok", and widening it silently
1462
+ * would make `--strict` mean something the roadmap never specified. The
1463
+ * warning is surfaced in both output formats instead.
1464
+ */
1465
+ export function finalExitCode(payload: ReachDoctorPayload, strict: boolean): number {
1466
+ if (!strict) return 0;
1467
+ const failing = payload.channels.filter(
1468
+ (channel) => channel.status !== 'ok' && channel.status !== 'removed',
1469
+ );
1470
+ return failing.length > 0 ? 1 : 0;
1471
+ }
1472
+
1473
+ const USAGE = `reach:doctor — read-only health report over the reach channel registry
1474
+
1475
+ Answers two operator questions per channel: is the upstream backend I already
1476
+ chose to install healthy and pinned — and, where the backend declares a
1477
+ readiness requirement, is it actually able to retrieve, or merely installed?
1478
+ A channel reported not-ready is installed and answering its probe while a
1479
+ config-semantic requirement is unsatisfied or unconfirmed; the remedy is
1480
+ printed, never run. It is NOT a router and NOT an agent-facing
1481
+ recommendation — no channel it prints is routed, preferred, or suggested to
1482
+ an agent.
1483
+
1484
+ Usage:
1485
+ agent-config reach:doctor [options]
1486
+
1487
+ Options:
1488
+ --format json|table Output shape (default: table). JSON validates against
1489
+ src/scripts/schemas/reach-doctor-payload.schema.json.
1490
+ --strict Exit 1 when any admitted channel is not ok (for CI).
1491
+ --channel <id> Report a single channel.
1492
+ --registry <path> Read an alternate registry file. It is validated
1493
+ against reach-channels.schema.json first (plus
1494
+ probe_cmd === id); any violation refuses the run with
1495
+ exit 2 rather than probing an unvouched-for file.
1496
+ --deep ⚠️ NETWORK. Opt-in only. Sends ONE real, read-only
1497
+ request per backend that declares a deep probe (a
1498
+ rate-limit read, or a HEAD to example.com) to confirm
1499
+ the backend really reaches its endpoint. Refuses to
1500
+ run in CI. Writes NOTHING: it prints the
1501
+ last_verified lines you may choose to commit.
1502
+ WHAT YOU ARE CONSENTING TO: a backend's own rate-limit
1503
+ read authenticates with whatever credential that tool
1504
+ already holds, so running --deep presents your existing
1505
+ token to that third party and appears in their logs.
1506
+ Nothing new is stored and no credential is read by this
1507
+ command — but the request is made on your behalf, which
1508
+ is why it is opt-in and never automatic.
1509
+ --help Show this help.
1510
+
1511
+ Without --deep the command touches no network at all: the only child
1512
+ processes are the registry's side-effect-free local probes. It never installs
1513
+ anything and never writes a file, in either mode.
1514
+
1515
+ Exit codes: 0 ok · 1 --strict violation · 2 usage / unusable or schema-invalid
1516
+ registry / --deep inside CI.`;
1517
+
1518
+ interface ParsedArgs {
1519
+ format: 'table' | 'json';
1520
+ strict: boolean;
1521
+ deep: boolean;
1522
+ help: boolean;
1523
+ channel: string | undefined;
1524
+ registry: string | undefined;
1525
+ }
1526
+
1527
+ export function parseArgs(argv: readonly string[]): ParsedArgs {
1528
+ const parsed: ParsedArgs = {
1529
+ format: 'table',
1530
+ strict: false,
1531
+ deep: false,
1532
+ help: false,
1533
+ channel: undefined,
1534
+ registry: undefined,
1535
+ };
1536
+ for (let index = 0; index < argv.length; index += 1) {
1537
+ const arg = argv[index];
1538
+ if (arg === '--format') {
1539
+ const value = argv[index + 1];
1540
+ if (value !== 'json' && value !== 'table') {
1541
+ throw new ReachDoctorUsageError('--format expects json|table');
1542
+ }
1543
+ parsed.format = value;
1544
+ index += 1;
1545
+ } else if (arg === '--channel') {
1546
+ const value = argv[index + 1];
1547
+ if (value === undefined || value.startsWith('-')) {
1548
+ throw new ReachDoctorUsageError('--channel expects a channel id');
1549
+ }
1550
+ parsed.channel = value;
1551
+ index += 1;
1552
+ } else if (arg === '--registry') {
1553
+ const value = argv[index + 1];
1554
+ if (value === undefined || value.startsWith('-')) {
1555
+ throw new ReachDoctorUsageError('--registry expects a path');
1556
+ }
1557
+ parsed.registry = value;
1558
+ index += 1;
1559
+ } else if (arg === '--strict') {
1560
+ parsed.strict = true;
1561
+ } else if (arg === '--deep') {
1562
+ parsed.deep = true;
1563
+ } else if (arg === '--help' || arg === '-h') {
1564
+ parsed.help = true;
1565
+ } else {
1566
+ throw new ReachDoctorUsageError(`unknown argument '${String(arg)}'`);
1567
+ }
1568
+ }
1569
+ return parsed;
1570
+ }
1571
+
1572
+ /** CI markers that make `--deep` refuse: network in CI is never wanted here. */
1573
+ export function detectCi(env: NodeJS.ProcessEnv = process.env): string | null {
1574
+ for (const key of ['CI', 'GITHUB_ACTIONS', 'AGENT_CONFIG_CI']) {
1575
+ const value = env[key];
1576
+ if (value !== undefined && value !== '' && value !== '0' && value !== 'false') {
1577
+ return key;
1578
+ }
1579
+ }
1580
+ return null;
1581
+ }
1582
+
1583
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
1584
+ let args: ParsedArgs;
1585
+ try {
1586
+ args = parseArgs(argv);
1587
+ } catch (err) {
1588
+ process.stderr.write(`reach_doctor: ${(err as Error).message}\n\n${USAGE}\n`);
1589
+ return 2;
1590
+ }
1591
+ if (args.help) {
1592
+ process.stdout.write(`${USAGE}\n`);
1593
+ return 0;
1594
+ }
1595
+ if (args.deep) {
1596
+ const ciMarker = detectCi();
1597
+ if (ciMarker !== null) {
1598
+ process.stderr.write(
1599
+ `reach_doctor: --deep performs real network requests and never runs in CI ` +
1600
+ `(${ciMarker} is set). Run it locally, as an operator.\n`,
1601
+ );
1602
+ return 2;
1603
+ }
1604
+ }
1605
+
1606
+ let payload: ReachDoctorPayload;
1607
+ try {
1608
+ payload = collect({
1609
+ deep: args.deep,
1610
+ ...(args.channel === undefined ? {} : { channel: args.channel }),
1611
+ ...(args.registry === undefined ? {} : { registryPath: args.registry }),
1612
+ });
1613
+ } catch (err) {
1614
+ if (err instanceof RegistryLoadError || err instanceof ReachDoctorUsageError) {
1615
+ process.stderr.write(`reach_doctor: ${err.message}\n`);
1616
+ return 2;
1617
+ }
1618
+ throw err;
1619
+ }
1620
+
1621
+ if (args.format === 'json') {
1622
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1623
+ } else {
1624
+ process.stdout.write(`${renderTable(payload)}\n`);
1625
+ }
1626
+ return finalExitCode(payload, args.strict);
1627
+ }
1628
+
1629
+ function _isCliEntry(): boolean {
1630
+ if (!process.argv[1]) {
1631
+ return false;
1632
+ }
1633
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
1634
+ if (import.meta.url === argvUrl) {
1635
+ return true;
1636
+ }
1637
+ // A symlinked invocation makes the raw URLs differ (import.meta.url is the
1638
+ // resolved real path while argv[1] keeps the symlink path) — compare
1639
+ // realpaths so the CLI still fires when run through a projection symlink.
1640
+ try {
1641
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
1642
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
1643
+ return here === argv;
1644
+ } catch {
1645
+ return false;
1646
+ }
1647
+ }
1648
+
1649
+ if (_isCliEntry()) {
1650
+ process.exit(main());
1651
+ }