@tpsdev-ai/flair 0.53.0 → 0.54.1

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