@tpsdev-ai/flair 0.53.0 → 0.54.2

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 (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -0,0 +1,1665 @@
1
+ import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, describePublicUrlFinding, readEnvValue } from "../component-env.js";
2
+ import { checkClaudeMdBootstrap, checkContinuityCaptureHooks, describeAgentGateFinding, effectiveFlairUrl, embeddingsSkipRemedy, fixClaudeMdBootstrap, fixCommandAgentHint, fixContinuityCaptureHooks, fixSessionStartHook, inspectSessionStartHook, partitionKeyIds, planAgentIterations, readClientMcpBlock, resolveFixAgentId, resolveWireFlairUrl, upgradeSessionStartHookCommand } from "../doctor-client.js";
3
+ import { markStale, sortOldestVersionFirst } from "../fleet-presence.js";
4
+ import { hookSettingsPath, repinSessionStartHook, resolveHookAgentId } from "../hook-install.js";
5
+ import { detectClients, wireAntigravity, wireClaudeCode, wireCodex, wireCursor, wireGemini } from "../install/clients.js";
6
+ import { checkGlobalBinOnPath, resolveNpmGlobalPrefix } from "../install/global-bin-path.js";
7
+ import { buildEd25519Auth, defaultKeysDir, resolveAdminUser, resolveKeyPath, resolveLocalAdminPass } from "../lib/auth-resolve.js";
8
+ import { flairConfigYamlCandidates, readPortFromYamlFile, resolveFlairConfigYaml } from "../lib/doctor-config-path.js";
9
+ import { collectFederationEnv, describeFederationDriverFinding, federationPeersConfigured, loadYamlDoc } from "../lib/doctor-federation-driver.js";
10
+ import { DOCTOR_CHECK_IDS, catalogIssueDelta, renderCatalogDoctorLines, runDoctorChecks } from "../lib/doctor-run.js";
11
+ import { opsApiBindFinding } from "../lib/ops-api-bind.js";
12
+ import { flairCliVersion, unpinnedSpecWarning } from "../lib/mcp-spec.js";
13
+ import { staleSessionStartHookPins } from "../lib/owned-pins.js";
14
+ import * as render from "../render.js";
15
+ import { checkVersion, formatVersionNudge, probeInstanceVersion, FLAIR_PKG_NAME } from "../version-check.js";
16
+ import { resolveRegistryNotice } from "../lib/npm-registry.js";
17
+ import { existsSync, readFileSync, statSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+ let cli;
21
+ /** Bind the cli-locals this module depends on. */
22
+ export function bindCli(fns) {
23
+ cli = fns;
24
+ }
25
+ function api(...args) {
26
+ return cli.api(...args);
27
+ }
28
+ function checkAgentRegistered(...args) {
29
+ return cli.checkAgentRegistered(...args);
30
+ }
31
+ function classifyOpsSocketPosture(...args) {
32
+ return cli.classifyOpsSocketPosture(...args);
33
+ }
34
+ function configPath(...args) {
35
+ return cli.configPath(...args);
36
+ }
37
+ function defaultDataDir(...args) {
38
+ return cli.defaultDataDir(...args);
39
+ }
40
+ function flairPackageDir(...args) {
41
+ return cli.flairPackageDir(...args);
42
+ }
43
+ function listeningPidsOnPort(...args) {
44
+ return cli.listeningPidsOnPort(...args);
45
+ }
46
+ function persistDefaultInstallCoordinates(...args) {
47
+ return cli.persistDefaultInstallCoordinates(...args);
48
+ }
49
+ function planLaunchdRepairFor(...args) {
50
+ return cli.planLaunchdRepairFor(...args);
51
+ }
52
+ function probeFlairReachable(...args) {
53
+ return cli.probeFlairReachable(...args);
54
+ }
55
+ function readHarperConfig(...args) {
56
+ return cli.readHarperConfig(...args);
57
+ }
58
+ function readPortFromConfig(...args) {
59
+ return cli.readPortFromConfig(...args);
60
+ }
61
+ function relativeTime(...args) {
62
+ return cli.relativeTime(...args);
63
+ }
64
+ function repairLaunchdManagement(...args) {
65
+ return cli.repairLaunchdManagement(...args);
66
+ }
67
+ function resolveHttpPort(...args) {
68
+ return cli.resolveHttpPort(...args);
69
+ }
70
+ function resolveOpsPort(...args) {
71
+ return cli.resolveOpsPort(...args);
72
+ }
73
+ function verifyAuditLog(...args) {
74
+ return cli.verifyAuditLog(...args);
75
+ }
76
+ function verifySemanticSearch(...args) {
77
+ return cli.verifySemanticSearch(...args);
78
+ }
79
+ export function summarizeDoctorRun(found, fixed, autoFix) {
80
+ const plural = (n) => `issue${n === 1 ? "" : "s"}`;
81
+ if (found === 0) {
82
+ return { line: ` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`, exitCode: 0 };
83
+ }
84
+ if (!autoFix) {
85
+ return {
86
+ line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`,
87
+ exitCode: 1,
88
+ };
89
+ }
90
+ if (fixed >= found) {
91
+ return {
92
+ line: ` ${render.icons.ok} ${render.wrap(render.c.green, `${found} ${plural(found)} found, ${fixed} fixed ✓`)}`,
93
+ exitCode: 0,
94
+ };
95
+ }
96
+ const remaining = found - fixed;
97
+ return {
98
+ line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found, ${fixed} fixed, ${remaining} remaining`)}`,
99
+ exitCode: 1,
100
+ };
101
+ }
102
+ // ─── flair doctor ─────────────────────────────────────────────────────────────
103
+ export function register(program) {
104
+ const __pkgVersion = cli.__pkgVersion;
105
+ // ─── flair doctor — pure summary/exit helper ─────────────────────────────────
106
+ // Extracted for testability (flair#721), same pattern as formatCandidateLine /
107
+ // describeReflectError in src/commands/rem.ts: the action callback spawns process.exit and a
108
+ // long sequence of console.log side effects, which makes it high-effort/
109
+ // low-value to drive directly — this is the actual decision logic. Before
110
+ // #721, doctor tracked only a single `issues` counter: every detected
111
+ // problem incremented it, and the final summary/exit-code read that counter
112
+ // alone, with no separate record of which of those issues `--fix` actually
113
+ // resolved during the same run. So a `--fix` run that interactively fixed
114
+ // every issue it found still printed "N issues found — see fixes above" and
115
+ // exited 1 — indistinguishable from a run that fixed nothing. This helper
116
+ // takes the accumulated found/fixed counts plus whether `--fix` was passed
117
+ // at all, and decides the summary line + exit code:
118
+ // - 0 found → "No issues found", exit 0 (unchanged)
119
+ // - found, no --fix → "N issues found — see fixes above", exit 1 (unchanged)
120
+ // - found, --fix, all fixed → "N issues found, N fixed ✓", exit 0
121
+ // - found, --fix, some remaining → "N issues found, M fixed, K remaining", exit 1
122
+ program
123
+ .command("doctor")
124
+ .description("Diagnose common Flair problems and suggest fixes")
125
+ .option("--port <port>", "Harper HTTP port")
126
+ .option("--agent <id>", "Agent ID to use for the semantic-search round-trip (or FLAIR_AGENT_ID env)")
127
+ .option("--fix", "Automatically fix issues where possible")
128
+ .option("--dry-run", "Show what --fix would do without making changes")
129
+ .action(async (opts) => {
130
+ const port = resolveHttpPort(opts);
131
+ const autoFix = opts.fix ?? false;
132
+ const dryRun = opts.dryRun ?? false;
133
+ if (dryRun && !autoFix) {
134
+ console.log(" ℹ️ --dry-run only has effect with --fix\n");
135
+ }
136
+ let effectivePort = port;
137
+ let baseUrl = `http://127.0.0.1:${port}`;
138
+ let issues = 0;
139
+ let fixed = 0; // issues that --fix successfully resolved during this run (flair#721)
140
+ let harperResponding = false;
141
+ let keyAgentIds = []; // populated by step 2 (Keys directory) below; feeds the flair#722 per-agent iteration
142
+ let nodeKeyIds = []; // node-scoped federation keys; feeds the #1514 driver gate
143
+ console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
144
+ // 0. Version check (flair#587) — offline-tolerant + cached, independent
145
+ // of Harper being up. A gap of ≥2 minor versions (or any major) is
146
+ // treated as loud/red — heuristic for "likely missed a security fix"
147
+ // since we don't have advisory data, only the version gap. A red gap
148
+ // counts as an issue (exit 1); a quieter yellow gap (one minor, or
149
+ // patch-only) is printed but doesn't fail doctor.
150
+ // ── flair#1072: the currency claim must be about the INSTANCE ─────────────
151
+ //
152
+ // This check used to run `checkVersion(__pkgVersion)` — the version of the
153
+ // CLI you happen to have installed — and print "flair <x> is current". When
154
+ // FLAIR_URL or --url points at a deployed instance, every other line doctor
155
+ // prints is genuinely remote, so that sentence reads as a statement about
156
+ // the thing you are talking to. It was a statement about your laptop.
157
+ //
158
+ // Reported against an instance five minors behind, where doctor said
159
+ // "current". Telling you that is doctor's entire job.
160
+ //
161
+ // UNKNOWN MUST NOT FALL BACK TO THE LOCAL NUMBER. An older instance may not
162
+ // expose its version at all, and the tempting fix is to use the one already
163
+ // in hand — which is precisely how this bug reads today. If the instance
164
+ // version cannot be determined, say so and count it as an issue rather than
165
+ // answering from the wrong machine.
166
+ const instanceVersion = await probeInstanceVersion(baseUrl);
167
+ const versionSubject = instanceVersion ?? null;
168
+ if (versionSubject === null) {
169
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `could not determine the version running at ${baseUrl} — not reporting currency. ` +
170
+ `(The local CLI is ${__pkgVersion}; that is NOT the instance.)`)}`);
171
+ issues++;
172
+ }
173
+ else {
174
+ const versionCheckResult = await checkVersion(versionSubject);
175
+ const versionNudge = formatVersionNudge(versionCheckResult);
176
+ if (versionNudge) {
177
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
178
+ const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
179
+ console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
180
+ if (versionNudge.severity === "red")
181
+ issues++;
182
+ }
183
+ else if (versionCheckResult.latest) {
184
+ console.log(` ${render.icons.ok} instance at ${baseUrl} runs flair ${versionSubject} — current`);
185
+ }
186
+ if (versionSubject !== __pkgVersion) {
187
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `local CLI is ${__pkgVersion}, instance is ${versionSubject} — they differ. ` +
188
+ `Commands run through the CLI; the instance serves the data.`)}`);
189
+ }
190
+ }
191
+ // flair#1692: name the registry (and where it came from) on every doctor
192
+ // check, so a redirected mirror is visible to the operator.
193
+ const registryNotice = await resolveRegistryNotice(FLAIR_PKG_NAME);
194
+ if (registryNotice.line) {
195
+ console.log(` ${render.icons.info} ${render.wrap(render.c.dim, registryNotice.line)}`);
196
+ }
197
+ if (registryNotice.error) {
198
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, registryNotice.error)}`);
199
+ issues++;
200
+ }
201
+ // 0.5 npm global bin dir on PATH (flair#1134) — a user-prefix
202
+ // `npm i -g` succeeds and then `flair` is command-not-found because
203
+ // <prefix>/bin never made it into PATH. postinstall warns at install
204
+ // time, but lifecycle scripts are suppressed on several real paths
205
+ // (--ignore-scripts, bun without trustedDependencies, tar-swap
206
+ // deploys), so doctor re-runs the same check — cheap, local, and
207
+ // independent of Harper being up. When npm itself is absent or slow
208
+ // the check SKIPS silently: flair may be installed by other means,
209
+ // and "npm missing" has no actionable fix this check could print.
210
+ const npmGlobalPrefix = await resolveNpmGlobalPrefix();
211
+ if (npmGlobalPrefix) {
212
+ const binCheck = checkGlobalBinOnPath({
213
+ prefix: npmGlobalPrefix,
214
+ pathEnv: process.env.PATH,
215
+ shell: process.env.SHELL,
216
+ });
217
+ if ("message" in binCheck) {
218
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `npm global bin dir ${binCheck.binDir} is NOT on PATH — global npm installs (flair included) won't be found by name`)}`);
219
+ for (const line of binCheck.message.split("\n")) {
220
+ console.log(` ${render.wrap(render.c.dim, line)}`);
221
+ }
222
+ issues++;
223
+ }
224
+ else {
225
+ console.log(` ${render.icons.ok} npm global bin dir ${render.wrap(render.c.dim, binCheck.binDir)} is on PATH`);
226
+ }
227
+ }
228
+ // Helper: try to reach Harper on a given port.
229
+ // Must return true ONLY when Harper's /Health endpoint returns 200 OK.
230
+ // A generic HTTP status > 0 (flair#862) would accept 404 from a Node
231
+ // inspector on 9229 or any other service — "present but wrong" beats
232
+ // "absent but correct".
233
+ async function probePort(p) {
234
+ try {
235
+ const res = await fetch(`http://127.0.0.1:${p}/Health`, { signal: AbortSignal.timeout(3000) });
236
+ return res.ok; // 200-299 only — /Health returns { ok: true } on 200
237
+ }
238
+ catch {
239
+ return false;
240
+ }
241
+ }
242
+ // Helper: discover what port a Harper PID is listening on.
243
+ // Scans ALL listening ports for this PID and returns the first one that
244
+ // responds to /Health with 200 OK. This avoids picking a debug port (9229)
245
+ // or any non-Flair listener that happens to share the process (flair#862).
246
+ async function discoverPortFromPid(pid) {
247
+ // Defense-in-depth: caller already validates, but re-check here
248
+ if (!/^\d+$/.test(pid))
249
+ return null;
250
+ try {
251
+ const { execSync } = await import("node:child_process");
252
+ const out = execSync(`lsof -aPi -p ${pid} -sTCP:LISTEN -Fn 2>/dev/null || true`, { encoding: "utf-8" });
253
+ // Extract all ports from lsof -Fn output (lines like "n127.0.0.1:PORT")
254
+ const ports = [...out.matchAll(/n(?:\S+):(\d+)/g)].map(m => Number(m[1]));
255
+ if (ports.length === 0)
256
+ return null;
257
+ // Try each port until one responds to /Health with 200 OK
258
+ for (const port of ports) {
259
+ if (await probePort(port))
260
+ return port;
261
+ }
262
+ return null; // No port responded to /Health
263
+ }
264
+ catch { /* ignore */ }
265
+ return null;
266
+ }
267
+ // 1. Port check — is something listening?
268
+ // First, check PID file so we can cross-reference
269
+ const dataDir0 = defaultDataDir();
270
+ const pidFile0 = join(dataDir0, "hdb.pid");
271
+ let pidAlive = false;
272
+ let pidValue = "";
273
+ if (existsSync(pidFile0)) {
274
+ const rawPid = (await import("node:fs")).readFileSync(pidFile0, "utf-8").trim();
275
+ // Strict integer validation — PID must be purely numeric to prevent injection
276
+ if (/^\d+$/.test(rawPid)) {
277
+ pidValue = rawPid;
278
+ try {
279
+ process.kill(Number(pidValue), 0);
280
+ pidAlive = true;
281
+ }
282
+ catch { /* dead */ }
283
+ }
284
+ else {
285
+ console.log(` ${render.icons.warn} PID file contains non-numeric value: ${render.wrap(render.c.dim, pidFile0)} — skipping`);
286
+ }
287
+ }
288
+ if (await probePort(port)) {
289
+ console.log(` ${render.icons.ok} Harper responding on port ${render.wrap(render.c.bold, String(port))}`);
290
+ harperResponding = true;
291
+ }
292
+ else {
293
+ // Port didn't respond — but if PID is alive, try to find the real port
294
+ let discoveredPort = null;
295
+ if (pidAlive) {
296
+ discoveredPort = await discoverPortFromPid(pidValue);
297
+ if (discoveredPort && discoveredPort !== port && await probePort(discoveredPort)) {
298
+ console.log(` ${render.icons.warn} Harper not on expected port ${port}, but responding on port ${render.wrap(render.c.bold, String(discoveredPort))} ${render.wrap(render.c.dim, `(PID ${pidValue})`)}`);
299
+ console.log(` ${render.wrap(render.c.dim, `Your config says port ${port} but Harper is actually running on ${discoveredPort}`)}`);
300
+ if (autoFix) {
301
+ if (dryRun) {
302
+ console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
303
+ }
304
+ else {
305
+ // dataDir0 is defaultDataDir() — `flair doctor` has no
306
+ // --data-dir, so the default install is what it means, and
307
+ // saying so keeps that true when it grows one (flair#914).
308
+ persistDefaultInstallCoordinates(dataDir0, discoveredPort);
309
+ console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
310
+ fixed++;
311
+ }
312
+ }
313
+ else {
314
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(updates config to match running port)")}`);
315
+ }
316
+ effectivePort = discoveredPort;
317
+ baseUrl = `http://127.0.0.1:${discoveredPort}`;
318
+ harperResponding = true;
319
+ issues++;
320
+ }
321
+ else {
322
+ console.log(` ${render.icons.error} Harper process alive (PID ${pidValue}) but not responding on any detected port`);
323
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
324
+ issues++;
325
+ }
326
+ }
327
+ else {
328
+ // No live PID — Harper genuinely isn't running
329
+ // Check if something else grabbed the port
330
+ try {
331
+ const { execSync } = await import("node:child_process");
332
+ // Listening sockets only, never our own PID — doctor has already
333
+ // probed this port over HTTP, so a bare lsof reports doctor's own
334
+ // process as the squatter and tells the operator to kill it
335
+ // (flair#905; see parseListeningPids).
336
+ const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
337
+ if (pids.length > 0) {
338
+ const lsof = pids.join(" ");
339
+ console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
340
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
341
+ }
342
+ else {
343
+ console.log(` ${render.icons.error} Harper is not running`);
344
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
345
+ }
346
+ }
347
+ catch {
348
+ console.log(` ${render.icons.error} Harper is not running`);
349
+ if (autoFix) {
350
+ if (dryRun) {
351
+ console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
352
+ }
353
+ else {
354
+ console.log(` ${render.wrap(render.c.dim, "Attempting restart...")}`);
355
+ try {
356
+ const { execSync } = await import("node:child_process");
357
+ execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
358
+ console.log(` ${render.icons.ok} Restart attempted`);
359
+ fixed++;
360
+ }
361
+ catch {
362
+ console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
363
+ }
364
+ }
365
+ }
366
+ else {
367
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
368
+ }
369
+ }
370
+ issues++;
371
+ }
372
+ }
373
+ // 1a. CLI ↔ running-server version handshake (flair#695 §B) — the
374
+ // version TRIPLE: this CLI's own version (__pkgVersion, checked against
375
+ // npm-latest in step 0 above), and the RUNNING server's reported
376
+ // version (GET /Health — public, no auth needed). A mismatch means the
377
+ // installed package was upgraded but the daemon hasn't restarted onto
378
+ // it yet — exactly the bare-npm trap the global preAction hook (above,
379
+ // every other command) nudges about on stderr; doctor prints the full
380
+ // picture here instead of a one-liner and `--fix` offers the restart.
381
+ let runningVersion = null;
382
+ if (harperResponding) {
383
+ try {
384
+ const healthRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(3000) });
385
+ if (healthRes.ok) {
386
+ const body = (await healthRes.json());
387
+ runningVersion = typeof body?.version === "string" ? body.version : null;
388
+ }
389
+ }
390
+ catch { /* leave runningVersion null — reported below as "unknown" */ }
391
+ if (runningVersion && runningVersion !== __pkgVersion) {
392
+ console.log(` ${render.icons.error} Version mismatch: CLI/installed ${render.wrap(render.c.bold, __pkgVersion)} but server is running ${render.wrap(render.c.bold, runningVersion)}`);
393
+ if (autoFix) {
394
+ if (dryRun) {
395
+ console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
396
+ }
397
+ else {
398
+ try {
399
+ const { execSync } = await import("node:child_process");
400
+ execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${effectivePort}`, { stdio: "inherit" });
401
+ console.log(` ${render.icons.ok} Restarted onto ${__pkgVersion}`);
402
+ fixed++;
403
+ }
404
+ catch {
405
+ console.log(` ${render.icons.error} Restart failed — try: flair restart`);
406
+ }
407
+ }
408
+ }
409
+ else {
410
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
411
+ }
412
+ issues++;
413
+ }
414
+ else if (runningVersion) {
415
+ console.log(` ${render.icons.ok} Server running version matches CLI (${runningVersion})`);
416
+ }
417
+ else {
418
+ console.log(` ${render.icons.warn} Could not determine the running server's version`);
419
+ }
420
+ }
421
+ // 2. Keys directory
422
+ const keysDir = defaultKeysDir();
423
+ if (existsSync(keysDir)) {
424
+ const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
425
+ // ~/.flair/keys is shared by agent Ed25519 signing keys and node-scoped
426
+ // federation keys (flair#1193). Only agent keys are signing identities;
427
+ // node keys are AES-GCM keystore blobs that must never be parsed as, or
428
+ // inferred as, an agent. Partition them out here so every downstream
429
+ // consumer of keyAgentIds (registration checks, --fix inference,
430
+ // fixCommandAgentHint) is node-free by construction.
431
+ const partitioned = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
432
+ keyAgentIds = partitioned.agentKeyIds;
433
+ nodeKeyIds = partitioned.nodeKeyIds;
434
+ if (keyAgentIds.length > 0) {
435
+ console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyAgentIds.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
436
+ if (partitioned.nodeKeyIds.length > 0) {
437
+ console.log(` ${render.icons.info} ${render.wrap(render.c.dim, `${partitioned.nodeKeyIds.length} node-scoped federation key(s) present — not agent signing keys; skipping`)}`);
438
+ }
439
+ }
440
+ else if (partitioned.nodeKeyIds.length > 0) {
441
+ // Node keys but no agent key: functionally there is no agent identity
442
+ // here. Report it plainly (not the old DECODER false alarm) and point
443
+ // at the real remedy. Kept a warn — not an issues++ — so a genuine
444
+ // federation-only host doesn't newly fail doctor's exit code.
445
+ console.log(` ${render.icons.warn} No agent signing key found — only ${render.wrap(render.c.bold, String(nodeKeyIds.length))} node-scoped federation key(s) in ${render.wrap(render.c.dim, keysDir)}`);
446
+ console.log(` ${render.wrap(render.c.dim, "These are Fabric node keys, not agent identities. Fix:")} flair init --agent-id <your-agent>`);
447
+ }
448
+ else {
449
+ console.log(` ${render.icons.error} Keys directory exists but no .key files found`);
450
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
451
+ issues++;
452
+ }
453
+ }
454
+ else {
455
+ console.log(` ${render.icons.error} Keys directory missing: ${render.wrap(render.c.dim, keysDir)}`);
456
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
457
+ issues++;
458
+ }
459
+ // 3. Config file (flair#1514) — same resolution Harper uses: cwd, then
460
+ // the component/package dir, then ~/.flair. Looking only at
461
+ // ~/.flair/config.yaml printed "using defaults" on wrapper-launched
462
+ // component dirs whose real config is ~/agents/flair/config.yaml.
463
+ const configLookup = {
464
+ cwd: process.cwd(),
465
+ homeDir: homedir(),
466
+ componentDir: flairPackageDir(),
467
+ };
468
+ const cfgPath = resolveFlairConfigYaml(configLookup);
469
+ if (cfgPath) {
470
+ const savedPort = readPortFromYamlFile(cfgPath) ?? readPortFromConfig();
471
+ console.log(` ${render.icons.ok} Config: ${render.wrap(render.c.dim, cfgPath)} ${render.wrap(render.c.dim, `(port: ${savedPort ?? "default"})`)}`);
472
+ }
473
+ else {
474
+ const tried = flairConfigYamlCandidates(configLookup);
475
+ console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, tried[0] ?? configPath())} — using defaults`);
476
+ if (tried.length > 1) {
477
+ console.log(` ${render.wrap(render.c.dim, `also tried: ${tried.slice(1).join(", ")}`)}`);
478
+ }
479
+ }
480
+ // 3b. Ops API bind (flair#670) — report-only finding, never auto-fixed.
481
+ // Rebinding the ops API requires a Harper restart to take effect, so
482
+ // `doctor --fix` deliberately does not touch it here; the fix is
483
+ // `flair init` (re-run, then `flair restart` to apply it) or a manual
484
+ // harper-config.yaml edit + restart. flair#827: re-running `flair init`
485
+ // used to regenerate ~/.flair/admin-pass unconditionally, desyncing it
486
+ // from Harper's already-persisted credential and breaking admin auth on
487
+ // the very re-run this remedy prescribed — resolveInitAdminPasswordSource
488
+ // (see its doc comment) now reuses the existing password instead, so this
489
+ // remedy is safe to follow on a working install.
490
+ try {
491
+ const finding = opsApiBindFinding(readHarperConfig(defaultDataDir()));
492
+ if (finding?.allInterfaces) {
493
+ console.log(` ${render.icons.error} ${finding.message}`);
494
+ console.log(` ${render.wrap(render.c.dim, finding.remedy)}`);
495
+ issues++;
496
+ }
497
+ }
498
+ catch { /* best-effort — don't fail doctor over a malformed harper-config.yaml */ }
499
+ // 3c. Ops-socket permission posture (flair#763) — report-only, never
500
+ // auto-fixed. Re-tightening a live socket needs a restart, so the remedy is
501
+ // `flair init`/restart (which re-applies the posture), not a `doctor --fix`.
502
+ // Only assessed when the socket exists (Harper has booted at least once).
503
+ try {
504
+ const socketPath = join(defaultDataDir(), "operations-server");
505
+ if (existsSync(socketPath)) {
506
+ const dirMode = statSync(dirname(socketPath)).mode;
507
+ const socketMode = statSync(socketPath).mode;
508
+ const groupOptIn = !!(process.env.FLAIR_SOCKET_GROUP && process.env.FLAIR_SOCKET_GROUP.trim().length > 0);
509
+ const verdict = classifyOpsSocketPosture(dirMode, socketMode, groupOptIn);
510
+ if (verdict.flagged) {
511
+ console.log(` ${render.icons.error} Ops socket permissions: ${verdict.reason}`);
512
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init ${render.wrap(render.c.dim, "(re-applies the 0700 dir / 0600 socket posture on next start; set FLAIR_SOCKET_GROUP for deliberate multi-user access)")}`);
513
+ issues++;
514
+ }
515
+ }
516
+ }
517
+ catch { /* best-effort — a stat failure shouldn't fail doctor */ }
518
+ // 3d. The URL this instance tells the world to use (flair#1005, flair#1000).
519
+ //
520
+ // Asks the instance for its OWN discovery document rather than inferring
521
+ // anything from config: /OAuthMetadata's `issuer` is the exact field that was
522
+ // wrong in flair#1000, and it is the only thing that proves what a client
523
+ // will actually be handed. describePublicUrlFinding (src/component-env.ts) is
524
+ // pure decision logic, unit-tested, and documents in its own header why the
525
+ // detectable condition is DRIFT rather than "unset on a public instance" —
526
+ // doctor reaches this instance over loopback and cannot observe whether it is
527
+ // also reachable at a public address.
528
+ if (harperResponding) {
529
+ let advertisedIssuer = null;
530
+ try {
531
+ const res = await fetch(`${baseUrl}/OAuthMetadata`, { signal: AbortSignal.timeout(5000) });
532
+ if (res.ok) {
533
+ const doc = (await res.json());
534
+ if (typeof doc?.issuer === "string" && doc.issuer !== "")
535
+ advertisedIssuer = doc.issuer;
536
+ }
537
+ }
538
+ catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
539
+ // The component directory for a local install is the flair package itself:
540
+ // `flair start` spawns `harper run .` with cwd = flairPackageDir().
541
+ // That path is often inside node_modules on an npm install-g; doctor still
542
+ // READs it for drift detection, but describePublicUrlFinding never names
543
+ // it as the fix (flair#1313 — wiped on every upgrade).
544
+ const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
545
+ let componentEnvValue = null;
546
+ try {
547
+ if (existsSync(componentEnvPath)) {
548
+ componentEnvValue = readEnvValue(readFileSync(componentEnvPath, "utf-8"), PUBLIC_URL_KEY);
549
+ }
550
+ }
551
+ catch { /* unreadable → treat as absent */ }
552
+ const finding = describePublicUrlFinding({
553
+ advertisedIssuer,
554
+ componentEnvValue,
555
+ processEnvValue: process.env.FLAIR_PUBLIC_URL ?? null,
556
+ componentEnvPath,
557
+ });
558
+ if (finding) {
559
+ const icon = finding.icon === "ok" ? render.icons.ok
560
+ : finding.icon === "warn" ? render.icons.warn
561
+ : render.icons.error;
562
+ console.log(` ${icon} ${finding.message}`);
563
+ if (finding.fixHint)
564
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
565
+ if (finding.isIssue)
566
+ issues++;
567
+ }
568
+ }
569
+ // 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
570
+ //
571
+ // The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
572
+ // not loaded: SemanticSearch falls back to keyword-only scan, and an
573
+ // unauthenticated probe 401s → "cannot verify" → no issue counted. A clean-VM
574
+ // dogfood found semantic search DEAD out of the box (sudo/root-owned install
575
+ // can't write the models symlink → EACCES) while `flair doctor` reported
576
+ // "no issues found". This now stores a memory with a distinctive phrase and
577
+ // searches for a PARAPHRASE (no shared keywords). If the top result isn't
578
+ // recovered by MEANING, recall-by-meaning is broken and doctor FAILS LOUDLY.
579
+ if (harperResponding) {
580
+ const semanticStatus = await verifySemanticSearch(baseUrl, opts.agent, defaultKeysDir());
581
+ switch (semanticStatus.state) {
582
+ case "ok":
583
+ console.log(` ${render.icons.ok} Embeddings: semantic search operational ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${semanticStatus.score.toFixed(2)})`)}`);
584
+ break;
585
+ case "degraded":
586
+ // LOUD failure — never report all-clear when recall-by-meaning is dead.
587
+ console.log(` ${render.icons.error} Semantic search DEGRADED ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
588
+ console.log(` ${render.wrap(render.c.red, "Embeddings are not loaded; recall-by-meaning will NOT work.")}`);
589
+ console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
590
+ console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
591
+ issues++;
592
+ break;
593
+ case "failed":
594
+ // flair#1501: a rejected signature is LOUD. It is either a real auth
595
+ // defect (the key is unregistered or stale) or a doctor defect, and
596
+ // both need a person — never soften it to "not verified". The detail
597
+ // names the identity and key path the probe signed with.
598
+ console.log(` ${render.icons.error} Embeddings: probe rejected ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
599
+ console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
600
+ issues++;
601
+ break;
602
+ case "skipped": {
603
+ // Could not run the round-trip. Don't claim all-clear — surface that
604
+ // the check was skipped, but don't count it as a hard issue since
605
+ // the user may simply not have an agent yet.
606
+ //
607
+ // flair#1023: the remedy is chosen from the classified reason
608
+ // (embeddingsSkipRemedy, src/doctor-client.ts) instead of being
609
+ // printed unconditionally. A key that will not decode gets no
610
+ // "pass --agent" advice, because following it changes nothing.
611
+ console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
612
+ const remedy = embeddingsSkipRemedy(semanticStatus.reason);
613
+ if (remedy)
614
+ console.log(` ${render.wrap(render.c.dim, remedy)}`);
615
+ break;
616
+ }
617
+ }
618
+ }
619
+ // 4b. Audit-log positive control (flair#970) — REAL write→read_audit_log
620
+ // round-trip, only if Harper is responding. `describe_table` reporting
621
+ // `audit: true` proves nothing: a node that joined or resynced via
622
+ // cluster base copy holds zero audit history while reporting audit
623
+ // enabled and answering read_audit_log with clean empty (harper#2212).
624
+ // So doctor writes probe rows and asserts their audit entries come back —
625
+ // never trusts the flag. Same ok/degraded/skipped discipline as the
626
+ // embeddings check above: skipped is rendered UNVERIFIED, never as a pass.
627
+ if (harperResponding) {
628
+ // read_audit_log only exists on the ops API (its own port), which the
629
+ // agent's Ed25519 header cannot authenticate — resolve the local admin
630
+ // credential (env or ~/.flair/admin-pass; never prompts). A file with
631
+ // unsafe permissions throws — that is "could not probe", not "broken".
632
+ let auditAdminPass;
633
+ let auditCredIssue = null;
634
+ try {
635
+ auditAdminPass = resolveLocalAdminPass(undefined);
636
+ }
637
+ catch (err) {
638
+ auditCredIssue = err instanceof Error ? err.message : String(err);
639
+ }
640
+ const auditStatus = auditCredIssue
641
+ ? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
642
+ : await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, resolveAdminUser(undefined), auditAdminPass);
643
+ switch (auditStatus.state) {
644
+ case "ok":
645
+ // Present-tense claim ONLY (see AuditVerifyResult): the probe
646
+ // proves the log records writes NOW — never that history is
647
+ // complete. Overclaiming here would rebuild the false trust
648
+ // anchor this check exists to kill, one layer up.
649
+ console.log(` ${render.icons.ok} Audit log: recording (verified now) ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
650
+ break;
651
+ case "degraded":
652
+ if (auditStatus.cause === "disabled") {
653
+ console.log(` ${render.icons.error} Audit log DISABLED ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
654
+ console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
655
+ }
656
+ else {
657
+ console.log(` ${render.icons.error} Audit log NOT RECORDING ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
658
+ console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
659
+ console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
660
+ console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
661
+ }
662
+ issues++;
663
+ break;
664
+ case "failed":
665
+ // Same loud discipline as the embeddings probe above (flair#1501).
666
+ console.log(` ${render.icons.error} Audit log: probe rejected ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
667
+ console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
668
+ issues++;
669
+ break;
670
+ case "skipped":
671
+ // An unrun check must not look like a pass — UNVERIFIED, visually
672
+ // distinct from ok, but not a hard issue (mirrors the embeddings
673
+ // skip: the operator may simply have no agent or no local admin
674
+ // credential on this box).
675
+ console.log(` ${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditStatus.detail})`);
676
+ break;
677
+ }
678
+ }
679
+ // 5. Stale PID file (skip if already reported in port check)
680
+ const dataDir = defaultDataDir();
681
+ const pidFile = join(dataDir, "hdb.pid");
682
+ if (existsSync(pidFile)) {
683
+ const pidContent = (await import("node:fs")).readFileSync(pidFile, "utf-8").trim();
684
+ try {
685
+ process.kill(Number(pidContent), 0);
686
+ if (harperResponding) {
687
+ console.log(` ${render.icons.ok} PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is alive)`)}`);
688
+ }
689
+ // If not responding, we already reported the issue in step 1
690
+ }
691
+ catch {
692
+ console.log(` ${render.icons.error} Stale PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is dead)`)}`);
693
+ if (autoFix) {
694
+ if (dryRun) {
695
+ console.log(` ${render.wrap(render.c.dim, "Would remove:")} ${pidFile}`);
696
+ }
697
+ else {
698
+ (await import("node:fs")).unlinkSync(pidFile);
699
+ console.log(` ${render.icons.ok} Removed stale PID file`);
700
+ fixed++;
701
+ }
702
+ }
703
+ else {
704
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} rm ${pidFile} && flair restart`);
705
+ }
706
+ issues++;
707
+ }
708
+ }
709
+ // 6. Data directory
710
+ if (existsSync(dataDir)) {
711
+ console.log(` ${render.icons.ok} Data directory: ${render.wrap(render.c.dim, dataDir)}`);
712
+ }
713
+ else {
714
+ // Check ~/harper/ (common alternative)
715
+ const altDir = join(homedir(), "harper");
716
+ if (existsSync(altDir)) {
717
+ console.log(` ${render.icons.warn} Data at ${render.wrap(render.c.dim, "~/harper/")} (not ${render.wrap(render.c.dim, "~/.flair/data")}) — old install location`);
718
+ }
719
+ else {
720
+ console.log(` ${render.icons.error} No data directory found`);
721
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
722
+ issues++;
723
+ }
724
+ }
725
+ // 7. Client integration (flair#588) — the first 6 checks diagnose the
726
+ // SERVER side. This diagnoses whether Flair is actually wired to a real
727
+ // client: for MCP clients (Claude Code, Codex, Gemini, Cursor,
728
+ // Antigravity) the MCP block present + reachable + the configured agent
729
+ // genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
730
+ // pi-flair reference in pi's own settings, including the flair#1346
731
+ // npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
732
+ // SessionStart hook (Claude Code + Codex — flair#1148). Reuses
733
+ // detectClients() rather than reimplementing client detection.
734
+ console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
735
+ // Prompt y/N before a content-editing fix, but only when interactive —
736
+ // in a non-TTY context (CI, scripts) --fix itself is the consent signal,
737
+ // matching how doctor's other --fix branches already behave unprompted.
738
+ // Mirrors the confirm pattern at `flair fabric upgrade` (~line 6258).
739
+ async function confirmFix(question) {
740
+ if (!process.stdin.isTTY)
741
+ return true;
742
+ const { createInterface } = await import("node:readline");
743
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
744
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
745
+ return /^y(es)?$/i.test(answer.trim());
746
+ }
747
+ const detectedClients = detectClients().filter((c) => c.detected);
748
+ // flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
749
+ // hook, verified-read plan, keys classification, launchd) is the same
750
+ // catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
751
+ // both. Extra doctor UX (pi, --fix, execution probe, continuity,
752
+ // agent registration) stays below and does not redefine those checks.
753
+ //
754
+ // flair#1573 slice b — launchd management is diagnosed + repaired by its
755
+ // own section below (planLaunchdRepairFor / repairLaunchdManagement), not
756
+ // by the install-health catalog. The catalog's launchd check stays for
757
+ // `upgrade` (flair#1022), but doctor would otherwise double-count the same
758
+ // drift (catalog "detached" fail + repair "regenerate"/"adopt"/"refuse").
759
+ const doctorCatalogIds = DOCTOR_CHECK_IDS.filter((id) => id !== "launchd-management");
760
+ const doctorCtx = {
761
+ homeDir: homedir(),
762
+ cwd: process.cwd(),
763
+ detectedClientIds: detectedClients.map((c) => c.id),
764
+ keysDir,
765
+ keyAgentIds,
766
+ agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
767
+ };
768
+ const catalogBefore = runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds });
769
+ if (detectedClients.length === 0) {
770
+ console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
771
+ }
772
+ else {
773
+ let claudeCodeAgentId;
774
+ let codexAgentId;
775
+ let anyKnownAgentId;
776
+ // `doctor --fix` writes client configs through the same wire functions
777
+ // init does, so it owes the user the same warning when the spec it would
778
+ // write cannot be pinned (flair#907).
779
+ if (autoFix) {
780
+ const pinWarning = unpinnedSpecWarning();
781
+ if (pinWarning) {
782
+ for (const line of pinWarning.split("\n"))
783
+ console.log(` ${render.icons.warn} ${line}`);
784
+ }
785
+ }
786
+ for (const client of detectedClients) {
787
+ // flair#989 — pi is a dead namespace: the pi (kind:
788
+ // "native-extension") check is removed from doctor entirely. pi was
789
+ // the last non-MCP client here, and a detected-but-unwired pi was
790
+ // counted as an install failure for a namespace nobody opts into any
791
+ // more. Doctor now diagnoses only MCP clients the user wired (below).
792
+ if (client.kind !== "mcp")
793
+ continue;
794
+ const block = readClientMcpBlock(client.id, homedir());
795
+ if (client.id === "claude-code" && block.agentId)
796
+ claudeCodeAgentId = block.agentId;
797
+ if (client.id === "codex" && block.agentId)
798
+ codexAgentId = block.agentId;
799
+ if (block.agentId)
800
+ anyKnownAgentId = anyKnownAgentId ?? block.agentId;
801
+ if (!block.present) {
802
+ // flair#989: this client is DETECTED (binary/config on the box) but
803
+ // was never wired to Flair — the user did not opt into it. That is
804
+ // not an install FAILURE, so it renders as info, never a ✗, and is
805
+ // not counted (the catalog's opt-in mcp-block check owns the count).
806
+ // `--fix` still offers to wire it, on the user's y/N consent.
807
+ console.log(` ${render.icons.info} ${client.label}: detected but not wired to Flair — optional (no Flair MCP server in ${render.wrap(render.c.dim, block.configPath)})`);
808
+ if (autoFix) {
809
+ if (dryRun) {
810
+ console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
811
+ }
812
+ else {
813
+ const proceed = await confirmFix(` Wire ${client.label} now? [y/N] `);
814
+ if (!proceed) {
815
+ console.log(` Skipped.`);
816
+ }
817
+ else {
818
+ // flair#802b: fall back to the sole locally-keyed agent when
819
+ // nothing else identifies one — the only case doctor can
820
+ // infer without being told (see inferSoleAgentId's doc
821
+ // comment in doctor-client.ts for why 0/2+ keys don't guess).
822
+ // flair#1193: resolveFixAgentId additionally refuses a
823
+ // node-scoped federation id from ANY source (inference, env,
824
+ // or a wired block a prior buggy run may have poisoned) — a
825
+ // node id can't sign, so wiring it would authenticate the
826
+ // connector as a phantom unregistered node.
827
+ const fixAgentId = resolveFixAgentId({
828
+ optsAgent: opts.agent,
829
+ envAgentId: process.env.FLAIR_AGENT_ID,
830
+ anyKnownAgentId,
831
+ keyAgentIds,
832
+ keysDir: defaultKeysDir(),
833
+ });
834
+ if (!fixAgentId) {
835
+ if (keyAgentIds.length > 1) {
836
+ console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: multiple agents found (${[...keyAgentIds].sort().join(", ")}) — pass --agent <id> to choose which one`);
837
+ }
838
+ else {
839
+ console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent identity found in keys/ — run \`flair init --agent <name>\` or \`flair agent add <name>\` before wiring a connector`);
840
+ }
841
+ }
842
+ else {
843
+ const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: resolveWireFlairUrl(block.flairUrl, baseUrl) };
844
+ const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
845
+ client.id === "codex" ? wireCodex(wireEnv) :
846
+ client.id === "gemini" ? wireGemini(wireEnv) :
847
+ client.id === "antigravity" ? wireAntigravity(wireEnv) :
848
+ wireCursor(wireEnv);
849
+ console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
850
+ if (wireResult.ok) {
851
+ if (client.id === "claude-code")
852
+ claudeCodeAgentId = fixAgentId;
853
+ if (client.id === "codex")
854
+ codexAgentId = fixAgentId;
855
+ anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
856
+ }
857
+ }
858
+ }
859
+ }
860
+ }
861
+ else {
862
+ // flair#802b: only splice in a concrete --agent if the id isn't
863
+ // already resolvable some other way — an explicit --agent /
864
+ // FLAIR_AGENT_ID / an already-wired client's agent id means bare
865
+ // `--fix` already works, so don't clutter the suggestion.
866
+ const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
867
+ const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
868
+ console.log(` ${render.wrap(render.c.dim, "To wire it (optional):")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label})`)}`);
869
+ }
870
+ continue;
871
+ }
872
+ console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
873
+ // flair#1287: a block with FLAIR_AGENT_ID but no FLAIR_URL is a
874
+ // WORKING setup — flair-client falls back to its built-in default —
875
+ // and must never be reported as unconfigured. Say which URL applies
876
+ // and keep verifying against it, exactly as for an explicit one.
877
+ const eff = effectiveFlairUrl(block);
878
+ const urlLabel = eff.defaulted ? `${eff.url} (client default)` : eff.url;
879
+ if (eff.defaulted) {
880
+ console.log(` ${render.icons.info} FLAIR_URL not set — flair-mcp defaults to ${render.wrap(render.c.dim, eff.url)}`);
881
+ }
882
+ const reachable = await probeFlairReachable(eff.url);
883
+ if (!reachable) {
884
+ console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} not reachable — cannot verify agent registration`);
885
+ continue;
886
+ }
887
+ console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} reachable`);
888
+ const reg = await checkAgentRegistered(eff.url, block.agentId, defaultKeysDir());
889
+ if (reg.state === "registered") {
890
+ console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
891
+ }
892
+ else if (reg.state === "not-registered") {
893
+ console.log(` ${render.icons.error} agent '${block.agentId}' is NOT registered on this Flair instance`);
894
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${block.agentId}`);
895
+ issues++;
896
+ }
897
+ else {
898
+ // flair#1023: `reachable` was just established two lines above, so
899
+ // reuse the same self-inconsistency guard the agent gates use
900
+ // rather than echoing a detail that may claim the opposite.
901
+ const finding = describeAgentGateFinding(block.agentId, reg.state, reg.detail, { instanceReachable: reachable });
902
+ console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
903
+ }
904
+ }
905
+ // flair#989: the harness-specific checks below (CLAUDE.md, SessionStart
906
+ // hook, continuity, Codex hook) run only for a harness the user actually
907
+ // WIRED — its MCP block is present. A harness merely DETECTED on the box
908
+ // but never opted into owes none of these; flagging them was the false-
909
+ // positive this fix removes. Read the block fresh so a `--fix` that just
910
+ // wired the client during the loop above is reflected here.
911
+ const claudeCodeDetected = detectedClients.some((c) => c.id === "claude-code");
912
+ const claudeCodeConfigured = claudeCodeDetected && readClientMcpBlock("claude-code", homedir()).present;
913
+ const codexConfigured = detectedClients.some((c) => c.id === "codex") && readClientMcpBlock("codex", homedir()).present;
914
+ // Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
915
+ // Codex has a SessionStart hook too (checked below); CLAUDE.md and
916
+ // continuity stay Claude Code only.
917
+ //
918
+ // flair#989: CLAUDE.md and the SessionStart hook are wiring-dependent —
919
+ // they apply, and can only fail, once Claude Code is WIRED — so they are
920
+ // gated on `claudeCodeConfigured`. Continuity (below) is a separate
921
+ // opt-in that renders "not enabled" as info and never a failure, so it
922
+ // stays gated on mere detection (flair#1324/#1257).
923
+ if (claudeCodeConfigured) {
924
+ const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
925
+ if (claudeMd.present) {
926
+ console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
927
+ }
928
+ else {
929
+ console.log(` ${render.icons.error} CLAUDE.md: bootstrap instruction not found (checked ${render.wrap(render.c.dim, join(process.cwd(), "CLAUDE.md"))} and ${render.wrap(render.c.dim, join(homedir(), ".claude", "CLAUDE.md"))})`);
930
+ if (autoFix) {
931
+ if (dryRun) {
932
+ console.log(` ${render.wrap(render.c.dim, "Would append bootstrap instruction to")} ${join(process.cwd(), "CLAUDE.md")}`);
933
+ }
934
+ else {
935
+ const proceed = await confirmFix(` Add the Flair bootstrap line to ./CLAUDE.md? [y/N] `);
936
+ if (!proceed) {
937
+ console.log(` Skipped.`);
938
+ }
939
+ else {
940
+ const fixRes = fixClaudeMdBootstrap(process.cwd());
941
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
942
+ }
943
+ }
944
+ }
945
+ else {
946
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
947
+ }
948
+ }
949
+ // flair#1007: presence was never the problem — the failing entry was
950
+ // perfectly well-formed. inspectSessionStartHook() additionally RUNS
951
+ // the registered command (bounded, side-effect-free via
952
+ // FLAIR_HOOK_PROBE) so doctor can tell "wired" from "wired and still
953
+ // works", and reports the shell-level silencing separately so an
954
+ // already-installed loud hook can be upgraded rather than only
955
+ // diagnosed.
956
+ const hook = inspectSessionStartHook(homedir());
957
+ if (hook.present) {
958
+ // flair#1485: pin ≠ installed CLI version is a failure, never a
959
+ // ✓ "still runs". Check freshness first so a stale pin cannot
960
+ // hide behind the execution probe. Catalog owns the issue count.
961
+ const claudeStale = staleSessionStartHookPins(homedir()).find((r) => r.target.id === "claude-code");
962
+ if (claudeStale) {
963
+ console.log(` ${render.icons.error} SessionStart hook: pinned to flair-mcp@${claudeStale.pin} (installed CLI is ${flairCliVersion()}) — the hook still launches the OLD adapter on every session`);
964
+ if (autoFix) {
965
+ if (dryRun) {
966
+ console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
967
+ }
968
+ else {
969
+ const repin = repinSessionStartHook(homedir(), "claude-code");
970
+ console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
971
+ }
972
+ }
973
+ else {
974
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install ${render.wrap(render.c.dim, "(re-pins the hook to the installed CLI version)")}`);
975
+ }
976
+ }
977
+ else if (hook.execution === "broken") {
978
+ // Two very different states that share one probe outcome:
979
+ //
980
+ // 1. Silenced (current) command that didn't run — the npx cache
981
+ // is cold, the machine is offline, or the adapter hasn't been
982
+ // fetched yet. On a fresh install this is NORMAL: the hook is
983
+ // wired but no Claude Code session has exercised it yet.
984
+ // Report as informational, not a warning, and never suggest
985
+ // reinstall — the setup is correct, the environment just
986
+ // hasn't warmed yet.
987
+ //
988
+ // 2. Unsilenced (legacy) command that didn't run — the hook has
989
+ // been in place long enough that a cold cache is not the
990
+ // explanation. This IS a genuine failure: warn and name the
991
+ // actual state with a fitting remedy.
992
+ if (hook.silenced) {
993
+ console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
994
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
995
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
996
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Claude Code session will warm the npx cache.")}`);
997
+ }
998
+ else {
999
+ console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
1000
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
1001
+ console.log(` ${render.wrap(render.c.dim, "The hook command could not be executed. Check that npx can resolve")}`);
1002
+ console.log(` ${render.wrap(render.c.dim, "@tpsdev-ai/flair-mcp — a cold npx cache or network issue")}`);
1003
+ console.log(` ${render.wrap(render.c.dim, "issue can prevent the adapter from running on its first invocation.")}`);
1004
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
1005
+ }
1006
+ }
1007
+ else if (hook.execution === "unknown") {
1008
+ console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
1009
+ }
1010
+ else if (!hook.ours) {
1011
+ console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
1012
+ }
1013
+ else {
1014
+ console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
1015
+ }
1016
+ // Independent of whether it runs today: would it stay quiet if it
1017
+ // stopped? Only offered as a repair when the command is the exact
1018
+ // string Flair itself wrote — a hand-edited or pinned hook is the
1019
+ // user's, and doctor reports on it rather than rewriting it.
1020
+ if (!hook.silenced && hook.ours) {
1021
+ console.log(` ${render.icons.warn} SessionStart hook: a failure would print an error on every session (this command predates the silent-failure fix)`);
1022
+ if (hook.upgradable) {
1023
+ if (autoFix) {
1024
+ if (dryRun) {
1025
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
1026
+ }
1027
+ else {
1028
+ const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
1029
+ if (!proceed) {
1030
+ console.log(` Skipped.`);
1031
+ }
1032
+ else {
1033
+ const upgrade = upgradeSessionStartHookCommand(homedir());
1034
+ console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
1035
+ }
1036
+ }
1037
+ }
1038
+ else {
1039
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
1040
+ }
1041
+ }
1042
+ else {
1043
+ console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install`);
1044
+ }
1045
+ }
1046
+ }
1047
+ else {
1048
+ console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
1049
+ if (autoFix) {
1050
+ if (dryRun) {
1051
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
1052
+ }
1053
+ else {
1054
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
1055
+ if (!proceed) {
1056
+ console.log(` Skipped.`);
1057
+ }
1058
+ else {
1059
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
1060
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId);
1061
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
1062
+ }
1063
+ }
1064
+ }
1065
+ else {
1066
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
1067
+ }
1068
+ }
1069
+ } // end CLAUDE.md + SessionStart hook (claudeCodeConfigured)
1070
+ // Continuity capture is a standalone Claude Code opt-in — shown whenever
1071
+ // Claude Code is DETECTED, independent of MCP wiring (flair#1324/#1257).
1072
+ if (claudeCodeDetected) {
1073
+ // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
1074
+ // the SessionStart check above: installed / absent / stale-form).
1075
+ // Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
1076
+ // opt-in — so "absent" renders as informational "not enabled": NEVER
1077
+ // a pass (an unrun check must not look green), never counted as an
1078
+ // issue, and NEVER wired by --fix (flair#1324: doctor's fixable set
1079
+ // is broken state; initiating an opt-in the user hasn't made is not a
1080
+ // fix — a y/N prompt auto-answers yes in every non-TTY run, so it was
1081
+ // no consent gate at all; enablement is `flair hook install
1082
+ // --continuity` only). A partial or stale pair IS evidence of a prior
1083
+ // opt-in, so repairing it to the complete current form remains a
1084
+ // legitimate --fix.
1085
+ const continuity = checkContinuityCaptureHooks(homedir());
1086
+ if (continuity.state === "installed") {
1087
+ console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
1088
+ }
1089
+ else if (continuity.state === "absent") {
1090
+ console.log(` ${render.icons.info} Continuity capture hooks: not enabled ${render.wrap(render.c.dim, "(opt-in — auto-journal working state into the ephemeral memory tier; enable: flair hook install --continuity)")}`);
1091
+ }
1092
+ else {
1093
+ const continuityDetail = continuity.state === "partial"
1094
+ ? (!continuity.postToolUse.present ? "the PostToolUse entry is missing" : "the Stop entry is missing")
1095
+ : "an entry is not the current form (unsilenced, hand-altered, or a drifted PostToolUse matcher)";
1096
+ console.log(` ${render.icons.warn} Continuity capture hooks: ${continuity.state} — ${continuityDetail}`);
1097
+ if (autoFix) {
1098
+ if (dryRun) {
1099
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the continuity capture hooks in")} ${continuity.path}`);
1100
+ }
1101
+ else {
1102
+ const proceed = await confirmFix(` Rewrite the continuity capture hooks in ${continuity.path} to the current form? [y/N] `);
1103
+ if (!proceed) {
1104
+ console.log(` Skipped.`);
1105
+ }
1106
+ else {
1107
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
1108
+ // Preserve the FLAIR_URL an existing entry already carries —
1109
+ // a repair must never silently re-point the hooks at a
1110
+ // different instance.
1111
+ const existingCommand = continuity.postToolUse.command || continuity.stop.command || "";
1112
+ const existingUrl = existingCommand.match(/FLAIR_URL=(\S+)/)?.[1];
1113
+ const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId, existingUrl);
1114
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
1115
+ if (fixRes.ok && fixRes.changed)
1116
+ fixed++;
1117
+ }
1118
+ }
1119
+ }
1120
+ else {
1121
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites both entries to the current form — same agent, same instance)")}`);
1122
+ }
1123
+ issues++;
1124
+ }
1125
+ }
1126
+ // Codex SessionStart hook (flair#1148) — same flair-session-start
1127
+ // command Claude Code uses, written to ~/.codex/hooks.json. Continuity
1128
+ // and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
1129
+ // is the hook file.
1130
+ if (codexConfigured) {
1131
+ const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
1132
+ if (hook.present) {
1133
+ const codexStale = staleSessionStartHookPins(homedir()).find((r) => r.target.id === "codex");
1134
+ if (codexStale) {
1135
+ console.log(` ${render.icons.error} SessionStart hook (codex): pinned to flair-mcp@${codexStale.pin} (installed CLI is ${flairCliVersion()}) — the hook still launches the OLD adapter on every session`);
1136
+ if (autoFix) {
1137
+ if (dryRun) {
1138
+ console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
1139
+ }
1140
+ else {
1141
+ const repin = repinSessionStartHook(homedir(), "codex");
1142
+ console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
1143
+ }
1144
+ }
1145
+ else {
1146
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(re-pins the hook to the installed CLI version)")}`);
1147
+ }
1148
+ }
1149
+ else if (hook.execution === "broken") {
1150
+ if (hook.silenced) {
1151
+ console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
1152
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
1153
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
1154
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
1155
+ console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
1156
+ }
1157
+ else {
1158
+ console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
1159
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
1160
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
1161
+ }
1162
+ }
1163
+ else if (hook.execution === "unknown") {
1164
+ console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
1165
+ }
1166
+ else if (!hook.ours) {
1167
+ console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
1168
+ }
1169
+ else {
1170
+ console.log(` ${render.icons.ok} SessionStart hook (codex): flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
1171
+ }
1172
+ if (!hook.silenced && hook.ours) {
1173
+ console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
1174
+ if (hook.upgradable) {
1175
+ if (autoFix) {
1176
+ if (dryRun) {
1177
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
1178
+ }
1179
+ else {
1180
+ const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
1181
+ if (!proceed) {
1182
+ console.log(` Skipped.`);
1183
+ }
1184
+ else {
1185
+ const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
1186
+ console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
1187
+ }
1188
+ }
1189
+ }
1190
+ else {
1191
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
1192
+ }
1193
+ }
1194
+ else {
1195
+ console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install --harness codex`);
1196
+ }
1197
+ }
1198
+ }
1199
+ else {
1200
+ console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
1201
+ if (autoFix) {
1202
+ if (dryRun) {
1203
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
1204
+ }
1205
+ else {
1206
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
1207
+ if (!proceed) {
1208
+ console.log(` Skipped.`);
1209
+ }
1210
+ else {
1211
+ const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
1212
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
1213
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
1214
+ }
1215
+ }
1216
+ }
1217
+ else {
1218
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
1219
+ }
1220
+ }
1221
+ }
1222
+ }
1223
+ // Catalog is the install-health verdict — count fail/unrun here, not
1224
+ // via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
1225
+ // --fix that cleared a catalog member shows up in the found→fixed delta.
1226
+ const catalogAfter = autoFix ? runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds }) : catalogBefore;
1227
+ const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
1228
+ issues += catalogDelta.found;
1229
+ if (autoFix)
1230
+ fixed += catalogDelta.fixed;
1231
+ console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
1232
+ for (const row of renderCatalogDoctorLines(catalogAfter)) {
1233
+ console.log(` ${render.icons[row.icon]} ${row.line}`);
1234
+ }
1235
+ // 7b. Launchd management repair (flair#1573 slice b) — `doctor --fix`
1236
+ // repairs a MISSING, CORRUPT, or DETACHED launchd plist. This is a
1237
+ // distinct concern from the install-health catalog above (which
1238
+ // `upgrade` also asserts), so it owns its own reporting + counting
1239
+ // rather than double-counting the catalog's launchd check. The
1240
+ // DECISION is pure (planLaunchdRepairFor -> planLaunchdRepair); the
1241
+ // EXECUTION (adopt: clean-stop -> regenerate pass-file plist -> load ->
1242
+ // verify) is repairLaunchdManagement, which is the only place that
1243
+ // touches the real filesystem and launchctl.
1244
+ console.log(`\n ${render.wrap(render.c.bold, "Launchd management")}`);
1245
+ if (autoFix && !dryRun) {
1246
+ // Execute the repair directly; it re-derives the plan internally and
1247
+ // verifies via assessLaunchdManagement (fail-loud, never a silent pass).
1248
+ const repairResult = await repairLaunchdManagement(defaultDataDir(), effectivePort);
1249
+ switch (repairResult.kind) {
1250
+ case "no-op":
1251
+ console.log(` ${render.icons.ok} ${repairResult.detail}`);
1252
+ break;
1253
+ case "refused":
1254
+ issues++;
1255
+ console.log(` ${render.icons.error} ${repairResult.detail}`);
1256
+ break;
1257
+ case "repaired":
1258
+ fixed++;
1259
+ console.log(` ${render.icons.ok} ${repairResult.detail}`);
1260
+ break;
1261
+ case "failed":
1262
+ issues++;
1263
+ console.log(` ${render.icons.error} ${repairResult.detail}`);
1264
+ if (repairResult.remedy)
1265
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${repairResult.remedy.join(" && ")}`);
1266
+ break;
1267
+ }
1268
+ }
1269
+ else {
1270
+ // Report only (no --fix, or --fix --dry-run): compute the plan, touch
1271
+ // nothing. A regenerate plan is drift; a refuse plan is a named refusal.
1272
+ const repairPlan = planLaunchdRepairFor(defaultDataDir(), effectivePort);
1273
+ switch (repairPlan.plan.kind) {
1274
+ case "no-op":
1275
+ console.log(` ${render.icons.ok} ${repairPlan.plan.detail}`);
1276
+ break;
1277
+ case "refuse":
1278
+ issues++;
1279
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
1280
+ break;
1281
+ case "regenerate":
1282
+ issues++;
1283
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
1284
+ if (dryRun) {
1285
+ console.log(` ${render.wrap(render.c.dim, "Would regenerate")} the launchd plist (pass-file mode) and load it`);
1286
+ }
1287
+ else {
1288
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(regenerates the plist in pass-file mode, loads it, and verifies)")}`);
1289
+ }
1290
+ break;
1291
+ case "adopt":
1292
+ issues++;
1293
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
1294
+ if (dryRun) {
1295
+ console.log(` ${render.wrap(render.c.dim, "Would adopt")} the direct-spawned instance into launchd (clean-stop, regenerate, load — bounces the live instance)`);
1296
+ }
1297
+ else {
1298
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(clean-stops the direct process, regenerates the plist, loads it, and verifies — bounces the live instance)")}`);
1299
+ }
1300
+ break;
1301
+ }
1302
+ }
1303
+ // 7a. Resolve which agent identities the two verified-read sections below
1304
+ // (Fleet presence, Migrations) iterate (flair#722). Previously both
1305
+ // sections required --agent explicitly; doctor already enumerates every
1306
+ // key in ~/.flair/keys (step 2 above), so by default it now runs the
1307
+ // signed read AS EACH of those agents instead of hiding behind a flag —
1308
+ // a real dogfood run found the #720 halted-migration warning visible via
1309
+ // `flair status --agent local` but invisible in the default `doctor` run
1310
+ // the same user ran minutes later. --agent <id> narrows this to exactly
1311
+ // that one identity (planAgentIterations — same pre-#722 semantics: a
1312
+ // single signed identity, just no longer widened to "every key").
1313
+ //
1314
+ // The registration gate (checkAgentRegistered — same signed GET
1315
+ // /Agent/:id used by the Client integration section above) is resolved
1316
+ // ONCE here per agent and shared by both sections, so a bad/unregistered
1317
+ // key doesn't cost two network round-trips, and its "found" count isn't
1318
+ // double-counted by each section re-discovering the same finding
1319
+ // (flair#721 found/fixed/remaining summary — these are found-only, no
1320
+ // --fix action exists for a bad local key). A gate failure for one agent
1321
+ // never aborts the others — that's the failure isolation flair#722 asks
1322
+ // for; describeAgentGateFinding (src/doctor-client.ts) is pure decision
1323
+ // logic so it's unit-tested without a real Harper.
1324
+ const verifiedReadAgentIds = harperResponding
1325
+ ? planAgentIterations(keyAgentIds, opts.agent || process.env.FLAIR_AGENT_ID)
1326
+ : [];
1327
+ const agentGates = [];
1328
+ for (const id of verifiedReadAgentIds) {
1329
+ const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
1330
+ agentGates.push({ id, state: reg.state, detail: reg.detail });
1331
+ // harperResponding is necessarily true here (verifiedReadAgentIds is
1332
+ // empty otherwise), so an "unreachable" verdict from this loop is
1333
+ // always a self-contradiction — flair#1023. Hand the guard the fact.
1334
+ const finding = describeAgentGateFinding(id, reg.state, reg.detail, { instanceReachable: harperResponding });
1335
+ if (finding?.isIssue)
1336
+ issues++;
1337
+ }
1338
+ // Shared renderer for one agent's registration-gate outcome — prints the
1339
+ // "Agent: <id>" subsection header, and if the gate isn't clean, the
1340
+ // finding (never re-counted here; already counted once above) and
1341
+ // returns false so the caller skips its own verified fetch for this
1342
+ // agent and moves on to the next (failure isolation).
1343
+ function renderAgentGateHeader(gate) {
1344
+ console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
1345
+ const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail, { instanceReachable: harperResponding });
1346
+ if (!finding)
1347
+ return true;
1348
+ const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
1349
+ console.log(` ${icon} ${finding.message}`);
1350
+ if (finding.fixHint)
1351
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
1352
+ return false;
1353
+ }
1354
+ // 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
1355
+ //
1356
+ // "Instance" here means each AGENT's heartbeat row — Presence is keyed by
1357
+ // agentId (schemas/schema.graphql), not by Flair server — so several rows
1358
+ // can (and typically will) share one flairVersion/harperVersion whenever
1359
+ // several agents heartbeat through the same Flair. That's still the
1360
+ // useful fleet signal: an outlier version on one row means THAT agent's
1361
+ // serving instance is behind the rest.
1362
+ //
1363
+ // SCOPE, verified against runFederationSyncOnce's own table list in
1364
+ // src/commands/federation.ts (`const tables = ["Memory", "Soul", "Agent",
1365
+ // "Relationship"]`): Presence is NOT one of the tables federation sync
1366
+ // replicates. So this section reports only what THIS instance's own
1367
+ // Presence table has recorded — every agent whose FLAIR_URL points
1368
+ // directly at the Flair `doctor` is talking to. On a hub+spokes
1369
+ // deployment where each spoke runs its own separate Flair database, a
1370
+ // spoke's locally-recorded heartbeats are invisible from the hub's
1371
+ // `doctor` unless those agents also heartbeat straight to the hub. Not
1372
+ // fixed here — flair#639's fix list is version-stamping + a doctor
1373
+ // listing, not widening federation sync scope.
1374
+ //
1375
+ // flair#722: iterated per agent (agentGates above) instead of a single
1376
+ // --agent-gated read. flairVersion/harperVersion are gated to verified
1377
+ // readers on the server (resources/Presence.ts, same boundary as
1378
+ // currentTask), so each agent subsection signs its own GET — a working
1379
+ // key reveals versions for that subsection; roster IDENTITY is public
1380
+ // either way. Zero local keys (and no --agent) falls back to exactly the
1381
+ // pre-#722 single unauthenticated read (hidden versions, "Pass --agent"
1382
+ // hint) — there's no agent to sign as, but remote agents may still have
1383
+ // heartbeated onto this instance and identities are worth showing.
1384
+ async function fetchAndRenderFleetPresence(headers, canSign, indent) {
1385
+ try {
1386
+ const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
1387
+ if (!presRes.ok) {
1388
+ console.log(`${indent}${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
1389
+ return;
1390
+ }
1391
+ const roster = (await presRes.json());
1392
+ if (!Array.isArray(roster) || roster.length === 0) {
1393
+ console.log(`${indent}${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
1394
+ return;
1395
+ }
1396
+ const rows = sortOldestVersionFirst(markStale(roster));
1397
+ for (const row of rows) {
1398
+ const lastSeen = typeof row.lastHeartbeatAt === "number"
1399
+ ? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
1400
+ : "—";
1401
+ const versionLabel = !canSign
1402
+ ? render.wrap(render.c.dim, "hidden")
1403
+ : row.flairVersion
1404
+ ? `v${row.flairVersion}`
1405
+ : render.wrap(render.c.dim, "no version reported");
1406
+ const staleNote = row.stale && row.newestVersion
1407
+ ? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
1408
+ : "";
1409
+ const icon = row.stale ? render.icons.warn : render.icons.ok;
1410
+ const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
1411
+ // Natural-presence: same staleness principle as the version
1412
+ // column — a live activity is shown as current, a decayed one as
1413
+ // "last-known". `activityFresh === false` (server verdict) plus a
1414
+ // known lastActivity → "(was: X)"; a fresh, non-idle activity →
1415
+ // "(X)". Skip entirely when there's nothing informative to say
1416
+ // (no signal, or idle) so the line stays quiet for the common case.
1417
+ const lastActivity = row.lastActivity ?? row.activity;
1418
+ const activityNote = row.activityFresh === false
1419
+ ? (lastActivity && lastActivity !== "idle"
1420
+ ? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
1421
+ : "")
1422
+ : (row.activity && row.activity !== "idle"
1423
+ ? " " + render.wrap(render.c.dim, `(${row.activity})`)
1424
+ : "");
1425
+ console.log(`${indent}${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
1426
+ }
1427
+ if (!canSign) {
1428
+ console.log(`${indent} ${render.wrap(render.c.dim, "Pass --agent <id> (with a matching key in ~/.flair/keys) to reveal versions — flairVersion/harperVersion require a verified signature, same as currentTask.")}`);
1429
+ }
1430
+ console.log(`${indent} ${render.wrap(render.c.dim, "Staleness above is fleet-relative (newest version seen among these instances) — comparing against the latest PUBLISHED flair is the version check at the top of this report, not this section.")}`);
1431
+ }
1432
+ catch (err) {
1433
+ console.log(`${indent}${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
1434
+ }
1435
+ }
1436
+ if (harperResponding) {
1437
+ console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
1438
+ if (agentGates.length === 0) {
1439
+ await fetchAndRenderFleetPresence({}, false, " ");
1440
+ }
1441
+ else {
1442
+ for (const gate of agentGates) {
1443
+ const registered = renderAgentGateHeader(gate);
1444
+ if (!registered)
1445
+ continue;
1446
+ const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
1447
+ const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/Presence", keyPath) };
1448
+ await fetchAndRenderFleetPresence(headers, true, " ");
1449
+ }
1450
+ }
1451
+ }
1452
+ // 9. Migration state (flair#695) — pending/in-progress/blocked + last
1453
+ // ledger-derived outcome per registered migration, read off the same
1454
+ // authenticated /HealthDetail the "Fleet presence" section above
1455
+ // already fetches. `--fix` here means the SAME restart offered in step
1456
+ // 1a above (a halted migration retries automatically on the next boot —
1457
+ // there's no separate "run the migration now" fix; the fix for
1458
+ // "blocked" is whatever the halt reason names, e.g. freeing disk).
1459
+ //
1460
+ // flair#722: iterated per agent (agentGates above), same as Fleet
1461
+ // presence — each subsection's finding is found-only (no per-agent
1462
+ // --fix here beyond the existing restart-on-halt story). Gate FINDINGS
1463
+ // are rendered in full under Fleet presence only (the first
1464
+ // verified-read section); re-printing the identical per-agent finding
1465
+ // here doubled the noise on real multi-key machines (a 27-key dogfood
1466
+ // box printed 15 not-registered findings twice each), so this section
1467
+ // iterates only the gate-passed agents and rolls the rest into one
1468
+ // aggregate skip line. The issue COUNT is unaffected either way — gate
1469
+ // findings are counted exactly once, at gate-resolution time (step 7a).
1470
+ async function fetchAndRenderMigrations(headers, indent) {
1471
+ try {
1472
+ const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers, signal: AbortSignal.timeout(5000) });
1473
+ if (!migRes.ok) {
1474
+ console.log(`${indent}${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
1475
+ return;
1476
+ }
1477
+ const detail = (await migRes.json());
1478
+ const migBlock = detail?.migrations;
1479
+ if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
1480
+ console.log(`${indent}${render.icons.info} No migrations registered on this instance`);
1481
+ return;
1482
+ }
1483
+ if (migBlock.cyclePhase === "pre-hash") {
1484
+ console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
1485
+ }
1486
+ // flair#812: the boot trigger sets `scheduled` synchronously at
1487
+ // module load, so `idle` means resources/migration-boot.js never
1488
+ // loaded in the serving process — NO migration will ever run on
1489
+ // this instance, which is precisely the failure that went unnoticed
1490
+ // because a skipped cycle looked identical to a clean one.
1491
+ if (migBlock.cyclePhase === "idle") {
1492
+ console.log(`${indent}${render.icons.error} Migration boot cycle never fired on this instance — no migration will run until this is resolved. Check the instance log for [flair-migrations] and confirm the running build ships dist/resources/migration-boot.js.`);
1493
+ issues++;
1494
+ }
1495
+ // A cycle that reached a terminal phase carrying an error explains
1496
+ // itself here rather than only in the process log — the reason
1497
+ // string names the paths tried and the remedy.
1498
+ if (migBlock.lastCycleError) {
1499
+ console.log(`${indent}${render.icons.error} Last migration cycle did not complete: ${migBlock.lastCycleError}`);
1500
+ issues++;
1501
+ }
1502
+ for (const m of migBlock.migrations) {
1503
+ if (m.state === "completed") {
1504
+ // flair#812: a `reason` on a COMPLETED migration means the
1505
+ // runner short-circuited it from the (hand-editable) state file
1506
+ // rather than verifying the corpus this boot. Print it, so an
1507
+ // unverified claim is never rendered as a verified one.
1508
+ const note = m.reason ? ` ${render.wrap(render.c.dim, `(${m.reason})`)}` : "";
1509
+ console.log(`${indent}${render.icons.ok} ${m.id}: completed${note}`);
1510
+ }
1511
+ else if (m.state === "halted" || m.state === "failed") {
1512
+ console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
1513
+ issues++;
1514
+ }
1515
+ else if (m.state === "running") {
1516
+ console.log(`${indent}${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
1517
+ }
1518
+ else {
1519
+ console.log(`${indent}${render.icons.info} ${m.id}: ${m.state}`);
1520
+ }
1521
+ }
1522
+ }
1523
+ catch (err) {
1524
+ console.log(`${indent}${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
1525
+ }
1526
+ }
1527
+ if (harperResponding) {
1528
+ console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
1529
+ if (agentGates.length === 0) {
1530
+ console.log(` ${render.icons.info} Pass --agent <id> (with a matching key in ~/.flair/keys) to see migration state — requires a verified read, same as Fleet presence above.`);
1531
+ }
1532
+ else {
1533
+ const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail, { instanceReachable: harperResponding }) === null);
1534
+ for (const gate of passedGates) {
1535
+ renderAgentGateHeader(gate);
1536
+ const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
1537
+ const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/HealthDetail", keyPath) };
1538
+ await fetchAndRenderMigrations(headers, " ");
1539
+ }
1540
+ const skipped = agentGates.length - passedGates.length;
1541
+ if (skipped > 0) {
1542
+ console.log(` ${render.icons.info} ${skipped} agent(s) skipped — registration-gate findings reported under Fleet presence above`);
1543
+ }
1544
+ }
1545
+ }
1546
+ // 10. Scheduled drivers (flair#1278) — launchd/systemd liveness for the
1547
+ // background schedulers (federation sync, REM nightly), read from the
1548
+ // LOCAL service manager (no Harper dependency, so no harperResponding
1549
+ // gate). Neither #1231 fleet incident (launchd spawn error 209 from a
1550
+ // missing log dir, exit 126 from a stripped exec bit) was visible in
1551
+ // doctor: driver health only surfaced in `flair federation sync status`
1552
+ // / `flair rem nightly status` — commands an operator has to think to
1553
+ // run, while doctor is the tool they actually run when something feels
1554
+ // off. Reuses each scheduler's own status read (installed + genuinely
1555
+ // loaded, flair#850) plus the #1282 last-exit plumbing
1556
+ // (queryLastExitStatus); the verdict is describeScheduledDriverFinding
1557
+ // (src/lib/scheduler-platform.ts) — pure decision logic, unit-tested
1558
+ // without spawning launchctl/systemctl. Not-enabled renders as
1559
+ // informational: an unenabled scheduler is a choice — never the pass
1560
+ // marker, never the fail marker, never an issue.
1561
+ //
1562
+ // flair#1514: the federation driver is additionally gated on peers
1563
+ // being configured. Zero peers → N/A (never ✗). Peers configured +
1564
+ // driver missing/broken still ✗. Config.yaml is the component-dir
1565
+ // file resolved above, not only ~/.flair/config.yaml.
1566
+ console.log(`\n ${render.wrap(render.c.bold, "Scheduled drivers")}`);
1567
+ try {
1568
+ const { queryLastExitStatus, describeScheduledDriverFinding } = await import("../lib/scheduler-platform.js");
1569
+ const fedSched = await import("../federation/scheduler.js");
1570
+ const remSched = await import("../rem/scheduler.js");
1571
+ const guiDomain = `gui/${process.getuid?.() ?? ""}`;
1572
+ let livePeerCount = null;
1573
+ if (harperResponding) {
1574
+ try {
1575
+ const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
1576
+ const peers = Array.isArray(r?.peers) ? r.peers : [];
1577
+ livePeerCount = peers.filter((p) => p?.status !== "revoked").length;
1578
+ }
1579
+ catch {
1580
+ livePeerCount = null;
1581
+ }
1582
+ }
1583
+ const configDoc = cfgPath ? loadYamlDoc(cfgPath) : null;
1584
+ const fedEnv = collectFederationEnv({
1585
+ processEnv: process.env,
1586
+ envFilePaths: [
1587
+ join(process.cwd(), COMPONENT_ENV_FILENAME),
1588
+ join(flairPackageDir(), COMPONENT_ENV_FILENAME),
1589
+ ...(cfgPath ? [join(dirname(cfgPath), COMPONENT_ENV_FILENAME)] : []),
1590
+ ],
1591
+ });
1592
+ const peersConfigured = federationPeersConfigured({
1593
+ livePeerCount,
1594
+ configDoc,
1595
+ env: fedEnv,
1596
+ nodeKeyIds,
1597
+ });
1598
+ const drivers = [
1599
+ {
1600
+ kind: "federation",
1601
+ status: fedSched.schedulerStatus(),
1602
+ label: "Federation sync driver",
1603
+ enableCommand: "flair federation sync enable",
1604
+ statusCommand: "flair federation sync status",
1605
+ darwinTarget: `${guiDomain}/${fedSched.LAUNCHD_LABEL}`,
1606
+ linuxServiceUnit: fedSched.SYSTEMD_SERVICE_UNIT,
1607
+ stderrLogPath: join(homedir(), ".flair", "logs", "federation-sync.stderr.log"),
1608
+ },
1609
+ {
1610
+ kind: "rem",
1611
+ status: remSched.schedulerStatus(),
1612
+ label: "REM nightly driver",
1613
+ enableCommand: "flair rem nightly enable",
1614
+ statusCommand: "flair rem nightly status",
1615
+ darwinTarget: `${guiDomain}/${remSched.LAUNCHD_LABEL}`,
1616
+ linuxServiceUnit: remSched.SYSTEMD_SERVICE_UNIT,
1617
+ stderrLogPath: join(homedir(), ".flair", "logs", "rem-nightly.stderr.log"),
1618
+ },
1619
+ ];
1620
+ for (const d of drivers) {
1621
+ // Read the last run only when the service manager actually has the
1622
+ // job — "not installed" and "not loaded" carry their own findings,
1623
+ // and layering a last-exit read on top would blur which actor failed.
1624
+ const lastExit = d.status.installed && d.status.active === true
1625
+ ? queryLastExitStatus({ plat: d.status.platform, darwinTarget: d.darwinTarget, linuxServiceUnit: d.linuxServiceUnit })
1626
+ : null;
1627
+ const facts = {
1628
+ label: d.label,
1629
+ enableCommand: d.enableCommand,
1630
+ statusCommand: d.statusCommand,
1631
+ installed: d.status.installed,
1632
+ active: d.status.active,
1633
+ lastExit,
1634
+ stderrLogPath: d.stderrLogPath,
1635
+ };
1636
+ const finding = d.kind === "federation"
1637
+ ? describeFederationDriverFinding({ peersConfigured, driver: facts })
1638
+ : describeScheduledDriverFinding(facts);
1639
+ console.log(` ${render.icons[finding.icon]} ${finding.message}`);
1640
+ finding.detail.forEach((line, i) => {
1641
+ // Embed-verify degraded style: the actor+state line loud (red),
1642
+ // the remedy dim.
1643
+ const color = finding.state === "degraded" && i === 0 ? render.c.red : render.c.dim;
1644
+ console.log(` ${render.wrap(color, line)}`);
1645
+ });
1646
+ if (finding.isIssue)
1647
+ issues++;
1648
+ }
1649
+ }
1650
+ catch (err) {
1651
+ // An unsupported platform (neither darwin nor linux) or a broken unit
1652
+ // read must not take down doctor — report the section as unchecked
1653
+ // (UNVERIFIED, not a pass), same as the other probes' skip discipline.
1654
+ console.log(` ${render.icons.warn} Scheduled drivers: could not check ${render.wrap(render.c.dim, `(${err?.message ?? err})`)}`);
1655
+ }
1656
+ // Summary — see summarizeDoctorRun above (flair#721): distinguishes
1657
+ // issues --fix actually resolved this run from ones still outstanding.
1658
+ console.log("");
1659
+ const summary = summarizeDoctorRun(issues, fixed, autoFix);
1660
+ console.log(summary.line);
1661
+ console.log("");
1662
+ if (summary.exitCode !== 0)
1663
+ process.exit(summary.exitCode);
1664
+ });
1665
+ }