@tpsdev-ai/flair 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verif
19
19
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
20
20
  import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
21
21
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
22
- import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.js";
22
+ import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
23
23
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
24
24
  // src/ into resources/, which don't survive npm packaging (see also
25
25
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -382,6 +382,31 @@ function isLocalBase(base) {
382
382
  return !base;
383
383
  }
384
384
  }
385
+ /**
386
+ * Thrown by api() for an HTTP-level failure (as opposed to a network error,
387
+ * which fetch() itself throws before api() ever sees a status code).
388
+ * Data-carrying, not just a message string, so callers downstream can make
389
+ * decisions without re-parsing text (flair#741):
390
+ * - `status`: the HTTP status code, so probeInstance (src/probe.ts) can
391
+ * classify ProbeResult.authFailureKind (401/403 = credential failure,
392
+ * duck-typed off this property — see probe.ts's authedGet doc).
393
+ * - `noCredentials`: true only for the specific bare-403-no-auth-header
394
+ * case (api()'s own hint-carrying branch below) — distinguishes "we had
395
+ * nothing to send" from "we sent something and it was rejected" so
396
+ * verifyAuthedGet (below) knows exactly when its agent-key fallback
397
+ * applies (fix #2) versus when a real credential was tried and failed
398
+ * (where guessing at other keys would be misleading, not helpful).
399
+ */
400
+ class ApiHttpError extends Error {
401
+ status;
402
+ noCredentials;
403
+ constructor(status, message, noCredentials = false) {
404
+ super(message);
405
+ this.name = "ApiHttpError";
406
+ this.status = status;
407
+ this.noCredentials = noCredentials;
408
+ }
409
+ }
385
410
  async function api(method, path, body, options) {
386
411
  // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
387
412
  // When baseUrl is provided (--target), use it directly.
@@ -479,7 +504,7 @@ async function api(method, path, body, options) {
479
504
  // Handle 204 No Content (e.g., PUT upsert returns empty body)
480
505
  if (res.status === 204 || res.headers.get("content-length") === "0") {
481
506
  if (!res.ok)
482
- throw new Error(`HTTP ${res.status}`);
507
+ throw new ApiHttpError(res.status, `HTTP ${res.status}`);
483
508
  return { ok: true };
484
509
  }
485
510
  const text = await res.text();
@@ -492,9 +517,9 @@ async function api(method, path, body, options) {
492
517
  const hint = isLocal
493
518
  ? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass."
494
519
  : "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
495
- throw new Error(`HTTP 403: no credentials sent. ${hint}`);
520
+ throw new ApiHttpError(403, `HTTP 403: no credentials sent. ${hint}`, /* noCredentials */ true);
496
521
  }
497
- throw new Error(text || `HTTP ${res.status}`);
522
+ throw new ApiHttpError(res.status, text || `HTTP ${res.status}`);
498
523
  }
499
524
  if (!text)
500
525
  return { ok: true };
@@ -553,6 +578,90 @@ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
553
578
  body: body !== undefined ? JSON.stringify(body) : undefined,
554
579
  });
555
580
  }
581
+ /**
582
+ * The authedGet `flair upgrade` verification (flair#635/#741) hands to
583
+ * probeInstance(): api()'s existing local-credential chain (FLAIR_TOKEN >
584
+ * FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD > FLAIR_AGENT_ID+key > ~/.flair/admin-
585
+ * pass file), with an Ed25519 agent-key fallback layered on top (flair#741
586
+ * fix #2).
587
+ *
588
+ * The gap this closes: api()'s agent-key leg only fires when an agentId is
589
+ * ALREADY known (FLAIR_AGENT_ID env, or an agentId in the request body/query
590
+ * string) — none of which a bare `flair upgrade` ever sets. So on a machine
591
+ * with an agent key under ~/.flair/keys but no admin password anywhere,
592
+ * api() falls straight through to its no-credentials 403, even though
593
+ * `flair doctor`'s verified-read gate would happily use that same key. This
594
+ * wrapper is what actually reuses that path for verification.
595
+ *
596
+ * Auth requirement of the verification target itself: /HealthDetail
597
+ * (probeInstance's default versionPath) is NOT admin-gated — its
598
+ * `allowRead()` is `allowVerified()` (resources/health.ts, resources/agent-
599
+ * auth.ts), which permits ANY registered agent, not just admins. So a
600
+ * signed request from any registered agent's key is sufficient; there's no
601
+ * need to special-case a different, more-public endpoint.
602
+ *
603
+ * Only engages the fallback when api() reports NOTHING was available to
604
+ * send (`ApiHttpError.noCredentials`) — a wrong/rejected credential (bad
605
+ * admin-pass, unregistered FLAIR_AGENT_ID) is a different, more specific
606
+ * problem than "nothing to try"; guessing at unrelated keys on disk in that
607
+ * case would obscure the real error instead of explaining it.
608
+ *
609
+ * Selection rule when multiple keys exist in `keysDir` (mirrors doctor's
610
+ * planAgentIterations sort — doctor-client.ts): try them in sorted
611
+ * (deterministic) filename order, first one that authenticates wins. A
612
+ * liveness/verified-read check only needs ONE working identity, unlike
613
+ * doctor's per-agent registration report which needs to enumerate all of
614
+ * them.
615
+ */
616
+ export async function verifyAuthedGet(baseUrl, path, keysDir) {
617
+ try {
618
+ return await api("GET", path, undefined, { baseUrl });
619
+ }
620
+ catch (err) {
621
+ if (!(err instanceof ApiHttpError) || !err.noCredentials)
622
+ throw err;
623
+ let keyFiles = [];
624
+ try {
625
+ keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key")).sort();
626
+ }
627
+ catch {
628
+ // No keys dir at all — nothing to fall back to.
629
+ }
630
+ for (const kf of keyFiles) {
631
+ const agentId = kf.replace(/\.key$/, "");
632
+ const keyPath = join(keysDir, kf);
633
+ try {
634
+ const res = await authFetch(baseUrl, agentId, keyPath, "GET", path);
635
+ if (res.ok)
636
+ return await res.json();
637
+ }
638
+ catch {
639
+ // This key didn't work (unregistered, bad signature, network blip
640
+ // on this one attempt) — try the next one.
641
+ }
642
+ }
643
+ // No local key authenticated either — surface api()'s original,
644
+ // actionable "no credentials sent" hint rather than a fallback-specific
645
+ // message; it already names both remedies (FLAIR_ADMIN_PASS / `flair init`).
646
+ throw err;
647
+ }
648
+ }
649
+ /**
650
+ * flair#741 fix #3: does this ProbeResult's failure mean "the server
651
+ * responded but rejected the verifier's credentials" — proof of liveness,
652
+ * NOT a data-integrity risk — as opposed to a genuine down/unreachable/5xx
653
+ * failure where the instance's real state can't be determined? Pure.
654
+ *
655
+ * This is the single predicate behind three call sites in the `upgrade`
656
+ * command: whether the pre-upgrade credential pre-flight aborts (fix #1),
657
+ * and whether the post-restart / post-rollback verification failure
658
+ * messages claim "instance state UNKNOWN — do not assume data integrity"
659
+ * (they must NOT, for this case — that's the flair#741 incident itself) or
660
+ * explain the real, much less scary situation instead.
661
+ */
662
+ export function isCredentialOnlyFailure(result) {
663
+ return result.healthy === true && result.authFailureKind === "credentials";
664
+ }
556
665
  async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
557
666
  const url = `http://127.0.0.1:${httpPort}/Health`;
558
667
  const deadline = Date.now() + timeoutMs;
@@ -2281,7 +2390,12 @@ program
2281
2390
  type: "stdio",
2282
2391
  command: "npx",
2283
2392
  args: ["-y", "@tpsdev-ai/flair-mcp"],
2284
- env: mcpEnv,
2393
+ // flair#718 authorship-provenance: each client's wired env block
2394
+ // gets its OWN FLAIR_CLIENT label (never the shared mcpEnv
2395
+ // object directly — that would stamp the same label into every
2396
+ // client's config) so writes from THIS client's proxy stamp
2397
+ // provenance.claimed.client = "claude-code".
2398
+ env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
2285
2399
  };
2286
2400
  try {
2287
2401
  if (existsSync(claudeJsonPath)) {
@@ -2337,15 +2451,18 @@ program
2337
2451
  }
2338
2452
  else {
2339
2453
  let result;
2454
+ // flair#718 authorship-provenance — see the claude-code branch's
2455
+ // identical comment above: FLAIR_CLIENT is per-client, so it's
2456
+ // added at each call site rather than baked into the shared mcpEnv.
2340
2457
  switch (clientId) {
2341
2458
  case "codex":
2342
- result = wireCodex(mcpEnv);
2459
+ result = wireCodex({ ...mcpEnv, FLAIR_CLIENT: "codex" });
2343
2460
  break;
2344
2461
  case "gemini":
2345
- result = wireGemini(mcpEnv);
2462
+ result = wireGemini({ ...mcpEnv, FLAIR_CLIENT: "gemini" });
2346
2463
  break;
2347
2464
  case "cursor":
2348
- result = wireCursor(mcpEnv);
2465
+ result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
2349
2466
  break;
2350
2467
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
2351
2468
  }
@@ -2858,6 +2975,164 @@ agent
2858
2975
  }
2859
2976
  console.log(`\n✅ Agent '${id}' removed successfully`);
2860
2977
  });
2978
+ /** Best-effort seed-validity check for a `.key` file: does it parse via any
2979
+ * of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
2980
+ * — the same loader `flair mcp token` uses) accepts? Never throws — used
2981
+ * only to decide "invalid" vs. "worth a registration check", not to
2982
+ * actually sign anything. */
2983
+ function isValidPrivateKeySeedFile(keyPath) {
2984
+ try {
2985
+ loadEd25519PrivateKeyFromFile(keyPath);
2986
+ return true;
2987
+ }
2988
+ catch {
2989
+ return false;
2990
+ }
2991
+ }
2992
+ /**
2993
+ * Classify every entry in `keysDir` for `flair keys prune`. Pure READ —
2994
+ * never writes or moves anything; see applyKeyPrune below for the actual
2995
+ * move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
2996
+ * and files not ending in `.key` are "ignored" without any network call.
2997
+ * `.key` files with an unparseable seed are "invalid" without a network call
2998
+ * either — only a `.key` file that DOES parse triggers a signed
2999
+ * `GET /Agent/:id` against `baseUrl` (checkAgentRegistered above, the exact
3000
+ * same check doctor's registration gate uses).
3001
+ *
3002
+ * If that check EVER reports "unreachable" — the instance couldn't be
3003
+ * confirmed up for that key — the WHOLE run aborts immediately
3004
+ * (`aborted: true`, `entries: []`, short-circuiting the loop): never
3005
+ * classify anything, prunable or not, while registration state can't be
3006
+ * verified. A missing keysDir is treated as "nothing to classify" (fresh
3007
+ * install), not an error — matches `flair doctor`'s own "Keys directory
3008
+ * missing" being a separate, non-fatal finding.
3009
+ */
3010
+ export async function classifyKeysDir(keysDir, baseUrl) {
3011
+ if (!existsSync(keysDir))
3012
+ return { aborted: false, entries: [] };
3013
+ const dirents = readdirSync(keysDir, { withFileTypes: true });
3014
+ const entries = [];
3015
+ const candidates = [];
3016
+ for (const d of dirents) {
3017
+ if (d.isDirectory()) {
3018
+ entries.push({
3019
+ name: d.name,
3020
+ class: "ignored",
3021
+ reason: d.name === PRUNED_DIR_NAME ? "prune archive directory" : "directory (not a key file)",
3022
+ });
3023
+ continue;
3024
+ }
3025
+ if (!d.name.endsWith(".key")) {
3026
+ entries.push({ name: d.name, class: "ignored", reason: "not a .key file" });
3027
+ continue;
3028
+ }
3029
+ candidates.push({ name: d.name, agentId: d.name.slice(0, -".key".length) });
3030
+ }
3031
+ for (const c of candidates) {
3032
+ const keyPath = join(keysDir, c.name);
3033
+ if (!isValidPrivateKeySeedFile(keyPath)) {
3034
+ const decision = classifyKeyFile(c.agentId, false, null, baseUrl);
3035
+ entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
3036
+ continue;
3037
+ }
3038
+ const reg = await checkAgentRegistered(baseUrl, c.agentId, keysDir);
3039
+ if (reg.state === "unreachable") {
3040
+ return {
3041
+ aborted: true,
3042
+ abortReason: `could not reach ${baseUrl} to verify agent '${c.agentId}' is registered` +
3043
+ `${reg.detail ? ` (${reg.detail})` : ""} — aborting; nothing was classified or moved. ` +
3044
+ `Pass --instance <url> to target a different instance.`,
3045
+ entries: [],
3046
+ };
3047
+ }
3048
+ const decision = classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
3049
+ entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
3050
+ }
3051
+ return { aborted: false, entries };
3052
+ }
3053
+ /**
3054
+ * Move every "stale" or "invalid" entry from `keysDir` into
3055
+ * `<keysDir>/.pruned/<dateStamp>/`, creating the archive dir as needed —
3056
+ * MOVE, never delete, so a bad classification is always recoverable. Only
3057
+ * ever called with entries classifyKeysDir already decided are prunable; a
3058
+ * "keep" or "ignored" entry passed in here is simply skipped (defense in
3059
+ * depth — a registered agent's key must never move, even if a caller bug
3060
+ * fed it in). Collisions (same filename already archived from an earlier
3061
+ * prune run today) get a numeric suffix (resolveCollisionSafeName) rather
3062
+ * than silently overwriting the earlier archive.
3063
+ */
3064
+ export function applyKeyPrune(keysDir, entries, dateStamp) {
3065
+ const prunable = entries.filter((e) => e.class === "stale" || e.class === "invalid");
3066
+ if (prunable.length === 0)
3067
+ return [];
3068
+ const destDir = join(keysDir, PRUNED_DIR_NAME, dateStamp);
3069
+ mkdirSync(destDir, { recursive: true });
3070
+ const existing = new Set(readdirSync(destDir));
3071
+ const moved = [];
3072
+ for (const e of prunable) {
3073
+ const destName = resolveCollisionSafeName(existing, e.name);
3074
+ existing.add(destName);
3075
+ const from = join(keysDir, e.name);
3076
+ const to = join(destDir, destName);
3077
+ renameSync(from, to);
3078
+ moved.push({ name: e.name, movedTo: to });
3079
+ }
3080
+ return moved;
3081
+ }
3082
+ const keys = program.command("keys").description("Manage Ed25519 key files in the key directory");
3083
+ keys
3084
+ .command("prune")
3085
+ .description("Move stale/unregistered/invalid keys to <keysDir>/.pruned/<date>/ — dry-run by default")
3086
+ .option("--apply", "Actually move prunable keys (default: dry-run, prints what would move and why)")
3087
+ .option("--keys-dir <dir>", "Directory to scan for key files (else FLAIR_KEY_DIR, ~/.flair/keys)")
3088
+ .option("--instance <url>", "Flair instance to check registration against (else FLAIR_TARGET/FLAIR_URL/config)")
3089
+ .option("--port <port>", "Harper HTTP port (used when --instance/FLAIR_URL/FLAIR_TARGET are not set)")
3090
+ .action(async (opts) => {
3091
+ const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
3092
+ const baseUrl = resolveBaseUrl({ target: opts.instance, port: opts.port });
3093
+ const apply = !!opts.apply;
3094
+ console.log(`\n${render.wrap(render.c.bold, "🔑 Flair Keys Prune")}${apply ? "" : render.wrap(render.c.dim, " (dry run)")}\n`);
3095
+ console.log(` Keys directory: ${render.wrap(render.c.dim, keysDir)}`);
3096
+ console.log(` Instance: ${render.wrap(render.c.dim, baseUrl)}\n`);
3097
+ const result = await classifyKeysDir(keysDir, baseUrl);
3098
+ if (result.aborted) {
3099
+ console.error(` ${render.icons.error} ${result.abortReason}`);
3100
+ console.log("");
3101
+ process.exit(1);
3102
+ }
3103
+ const stale = result.entries.filter((e) => e.class === "stale");
3104
+ const invalid = result.entries.filter((e) => e.class === "invalid");
3105
+ const kept = result.entries.filter((e) => e.class === "keep");
3106
+ const ignored = result.entries.filter((e) => e.class === "ignored");
3107
+ const prunable = [...stale, ...invalid];
3108
+ if (stale.length + invalid.length + kept.length === 0) {
3109
+ console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
3110
+ console.log("");
3111
+ return;
3112
+ }
3113
+ for (const e of prunable) {
3114
+ const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
3115
+ console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
3116
+ }
3117
+ for (const e of kept) {
3118
+ console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
3119
+ }
3120
+ if (!apply) {
3121
+ console.log("");
3122
+ console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${ignored.length} ignored`)}`);
3123
+ if (prunable.length > 0) {
3124
+ console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
3125
+ }
3126
+ console.log("");
3127
+ return;
3128
+ }
3129
+ const moved = applyKeyPrune(keysDir, result.entries, pruneDateStamp());
3130
+ console.log("");
3131
+ for (const m of moved) {
3132
+ console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
3133
+ }
3134
+ console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
3135
+ });
2861
3136
  // ─── flair mcp ───────────────────────────────────────────────────────────────
2862
3137
  // Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
2863
3138
  // client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
@@ -7017,6 +7292,66 @@ program
7017
7292
  // `opts` — safe to call this early.
7018
7293
  const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
7019
7294
  const upgradePort = resolveHttpPort({});
7295
+ // Hoisted so the pre-flight check (below) and the post-restart/rollback
7296
+ // verification steps (further down) all target the same URL — upgrade
7297
+ // never restarts Flair onto a different port.
7298
+ const baseUrl = `http://127.0.0.1:${upgradePort}`;
7299
+ // ── Credential pre-flight (flair#741 fix #1) ────────────────────────────
7300
+ // Post-restart verification (below) needs to authenticate against the
7301
+ // running instance. If it can't do that RIGHT NOW, against the CURRENT,
7302
+ // pre-upgrade instance, every upgrade on this machine is structurally
7303
+ // doomed before a single package is touched: post-restart verify fails
7304
+ // for the exact same credential reason, the rollback fires, and the
7305
+ // rollback's own re-verify fails identically — producing "ROLLBACK ALSO
7306
+ // FAILED VERIFICATION / state UNKNOWN" for an instance that was healthy
7307
+ // the entire time. That is exactly the flair#741 incident report (a
7308
+ // real 0.22.0→0.22.1 upgrade, healthy Flair, no ~/.flair/admin-pass, no
7309
+ // FLAIR_ADMIN_PASS). Catch it here, before any mutation, with a message
7310
+ // that says plainly: nothing was touched.
7311
+ //
7312
+ // Runs the SAME verification call (probeInstance + the agent-key-aware
7313
+ // verifyAuthedGet, fix #2) that post-restart verification uses below —
7314
+ // just against the pre-upgrade instance, with no expectVersion (there's
7315
+ // no target version to compare against yet; the question here is purely
7316
+ // "does an authenticated read work at all").
7317
+ //
7318
+ // Gated on --verify (shouldVerify): this check exists ONLY to keep
7319
+ // post-restart verification honest. A user who already opted out of
7320
+ // that verification with --no-verify has no use for a pre-flight that
7321
+ // protects it, and blocking their upgrade on a check they didn't ask
7322
+ // for would be a new, surprising failure mode of its own.
7323
+ //
7324
+ // Deliberately does NOT abort when the pre-flight instance is merely
7325
+ // UNREACHABLE (down/timeout) rather than reachable-but-unauthenticated.
7326
+ // `flair upgrade` may be the user's way of FIXING a down instance (bad
7327
+ // code on disk that a newer version resolves) — today's behavior
7328
+ // (pre-flair#741, no pre-flight at all) already lets that proceed, and
7329
+ // a new hard block here would take away a legitimate recovery path for
7330
+ // a failure mode this issue was never about. Only the specific
7331
+ // "server responded, credentials didn't work" case is structurally
7332
+ // doomed in a way a fresh install/restart can't fix on its own — so
7333
+ // only that case aborts. (If a down instance turns out to ALSO lack
7334
+ // credentials, that surfaces the normal way: post-restart verification
7335
+ // fails and rolls back, same as any other post-restart failure.)
7336
+ if (shouldVerify) {
7337
+ const preflight = await probeInstance(baseUrl, {
7338
+ // A short, bounded budget — this instance is presumed already
7339
+ // running (upgrade's normal case); doctor's probePort convention
7340
+ // (probeFlairReachable's doc comment) uses the same ~3s ballpark
7341
+ // for "is anything there at all" checks.
7342
+ timeoutMs: 3000,
7343
+ pollIntervalMs: 300,
7344
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
7345
+ });
7346
+ if (isCredentialOnlyFailure(preflight)) {
7347
+ console.error(`❌ pre-flight check failed: ${preflight.error}`);
7348
+ console.error(" Nothing has been touched — no packages were installed, no restart happened.");
7349
+ console.error(" The current instance is up and responded; the verifier just has no way to authenticate against it.");
7350
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key — then re-run flair upgrade.");
7351
+ console.error(" (--no-verify skips this check too, but post-restart verification would then fail the exact same way.)");
7352
+ process.exit(1);
7353
+ }
7354
+ }
7020
7355
  // ── Pre-upgrade data snapshot (flair#637, opt-in as of the 2026-07-08 rewire) ──
7021
7356
  // Only an @tpsdev-ai/flair package swap touches the code that reads/
7022
7357
  // writes ~/.flair/data — an flair-mcp-only or openclaw-plugin-only
@@ -7170,7 +7505,7 @@ program
7170
7505
  }
7171
7506
  console.log("\nRestarting Flair...");
7172
7507
  const port = upgradePort;
7173
- const baseUrl = `http://127.0.0.1:${port}`;
7508
+ // baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
7174
7509
  try {
7175
7510
  await restartFlair(port);
7176
7511
  }
@@ -7185,13 +7520,16 @@ program
7185
7520
  return;
7186
7521
  }
7187
7522
  console.log("\nVerifying...");
7188
- // The authenticated leg dogfoods api()'s local-credential resolution
7189
- // (flair#640: env > agent key > ~/.flair/admin-pass file) — probeInstance
7190
- // itself never resolves credentials, it just calls whatever's handed to it.
7523
+ // The authenticated leg reuses verifyAuthedGet (flair#741 fix #2): api()'s
7524
+ // local-credential resolution (flair#640: env > agent key when an agentId
7525
+ // is already known > ~/.flair/admin-pass file), PLUS an Ed25519 agent-key
7526
+ // fallback when none of that resolves anything — see verifyAuthedGet's
7527
+ // doc comment. probeInstance itself never resolves credentials, it just
7528
+ // calls whatever's handed to it.
7191
7529
  const verify = await probeInstance(baseUrl, {
7192
7530
  expectVersion: expectedFlairVersion ?? undefined,
7193
7531
  timeoutMs: STARTUP_TIMEOUT_MS,
7194
- authedGet: (path) => api("GET", path, undefined, { baseUrl }),
7532
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
7195
7533
  });
7196
7534
  const verdict = decideAfterVerify(verify, previousFlairVersion);
7197
7535
  if (verdict.kind === "ok") {
@@ -7199,6 +7537,17 @@ program
7199
7537
  return;
7200
7538
  }
7201
7539
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
7540
+ if (isCredentialOnlyFailure(verify)) {
7541
+ // flair#741 fix #3: a responding server that rejects the verifier's
7542
+ // credentials proves the instance is UP — it is not evidence the
7543
+ // upgrade itself broke anything. Say so explicitly instead of leaving
7544
+ // the reader to infer it from the raw error text. Rollback still
7545
+ // proceeds below (a credential check that worked pre-upgrade — see
7546
+ // the pre-flight above — and stopped working mid-run is an edge case
7547
+ // ambiguous enough that "prefer the known-good version" is still the
7548
+ // safer default), but the reason for it is now honest.
7549
+ console.error(" The instance is up and responded — the verifier could not authenticate. This is not a sign the upgrade broke anything.");
7550
+ }
7202
7551
  if (verdict.kind === "cannot-rollback") {
7203
7552
  console.error(" Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown.");
7204
7553
  console.error(" Check the instance now: flair doctor");
@@ -7224,7 +7573,7 @@ program
7224
7573
  const rollbackVerify = await probeInstance(baseUrl, {
7225
7574
  expectVersion: verdict.toVersion,
7226
7575
  timeoutMs: STARTUP_TIMEOUT_MS,
7227
- authedGet: (path) => api("GET", path, undefined, { baseUrl }),
7576
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
7228
7577
  });
7229
7578
  const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
7230
7579
  if (rollbackVerdict.kind === "rolled-back") {
@@ -7233,7 +7582,22 @@ program
7233
7582
  process.exit(1);
7234
7583
  }
7235
7584
  console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
7236
- console.error(" Instance state is UNKNOWNdo not assume data integrity.");
7585
+ // flair#741 fix #3: this is the exact incident report a 403 from a
7586
+ // responding, healthy server (credentials-only failure) was printed as
7587
+ // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
7588
+ // verify AND the rollback re-verify, because the same missing-auth-
7589
+ // material condition rejects both. Reserve the UNKNOWN/do-not-assume
7590
+ // text for failures where the instance's real state genuinely can't be
7591
+ // determined (connection refused, timeout, 5xx) — a credential-only
7592
+ // failure here means the rollback likely landed fine and the checker
7593
+ // simply can't prove it.
7594
+ if (isCredentialOnlyFailure(rollbackVerify)) {
7595
+ console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
7596
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
7597
+ }
7598
+ else {
7599
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
7600
+ }
7237
7601
  // This double-failure isn't auto-recoverable yet (flair#637) — but if a
7238
7602
  // pre-upgrade snapshot landed, point at the CONCRETE path instead of
7239
7603
  // just the issue number, so recovery doesn't start with a GitHub search.
@@ -8131,6 +8495,46 @@ see src/fleet-verify.ts's file header for the full caveat.`)
8131
8495
  }
8132
8496
  process.exit(result.exitCode);
8133
8497
  });
8498
+ // ─── flair doctor — pure summary/exit helper ─────────────────────────────────
8499
+ // Extracted for testability (flair#721), same pattern as formatCandidateLine /
8500
+ // describeReflectError above: the action callback spawns process.exit and a
8501
+ // long sequence of console.log side effects, which makes it high-effort/
8502
+ // low-value to drive directly — this is the actual decision logic. Before
8503
+ // #721, doctor tracked only a single `issues` counter: every detected
8504
+ // problem incremented it, and the final summary/exit-code read that counter
8505
+ // alone, with no separate record of which of those issues `--fix` actually
8506
+ // resolved during the same run. So a `--fix` run that interactively fixed
8507
+ // every issue it found still printed "N issues found — see fixes above" and
8508
+ // exited 1 — indistinguishable from a run that fixed nothing. This helper
8509
+ // takes the accumulated found/fixed counts plus whether `--fix` was passed
8510
+ // at all, and decides the summary line + exit code:
8511
+ // - 0 found → "No issues found", exit 0 (unchanged)
8512
+ // - found, no --fix → "N issues found — see fixes above", exit 1 (unchanged)
8513
+ // - found, --fix, all fixed → "N issues found, N fixed ✓", exit 0
8514
+ // - found, --fix, some remaining → "N issues found, M fixed, K remaining", exit 1
8515
+ export function summarizeDoctorRun(found, fixed, autoFix) {
8516
+ const plural = (n) => `issue${n === 1 ? "" : "s"}`;
8517
+ if (found === 0) {
8518
+ return { line: ` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`, exitCode: 0 };
8519
+ }
8520
+ if (!autoFix) {
8521
+ return {
8522
+ line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`,
8523
+ exitCode: 1,
8524
+ };
8525
+ }
8526
+ if (fixed >= found) {
8527
+ return {
8528
+ line: ` ${render.icons.ok} ${render.wrap(render.c.green, `${found} ${plural(found)} found, ${fixed} fixed ✓`)}`,
8529
+ exitCode: 0,
8530
+ };
8531
+ }
8532
+ const remaining = found - fixed;
8533
+ return {
8534
+ line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found, ${fixed} fixed, ${remaining} remaining`)}`,
8535
+ exitCode: 1,
8536
+ };
8537
+ }
8134
8538
  // ─── flair doctor ─────────────────────────────────────────────────────────────
8135
8539
  program
8136
8540
  .command("doctor")
@@ -8149,7 +8553,9 @@ program
8149
8553
  let effectivePort = port;
8150
8554
  let baseUrl = `http://127.0.0.1:${port}`;
8151
8555
  let issues = 0;
8556
+ let fixed = 0; // issues that --fix successfully resolved during this run (flair#721)
8152
8557
  let harperResponding = false;
8558
+ let keyAgentIds = []; // populated by step 2 (Keys directory) below; feeds the flair#722 per-agent iteration
8153
8559
  console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
8154
8560
  // 0. Version check (flair#587) — offline-tolerant + cached, independent
8155
8561
  // of Harper being up. A gap of ≥2 minor versions (or any major) is
@@ -8234,6 +8640,7 @@ program
8234
8640
  else {
8235
8641
  writeConfig(discoveredPort);
8236
8642
  console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
8643
+ fixed++;
8237
8644
  }
8238
8645
  }
8239
8646
  else {
@@ -8277,6 +8684,7 @@ program
8277
8684
  const { execSync } = await import("node:child_process");
8278
8685
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
8279
8686
  console.log(` ${render.icons.ok} Restart attempted`);
8687
+ fixed++;
8280
8688
  }
8281
8689
  catch {
8282
8690
  console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
@@ -8319,6 +8727,7 @@ program
8319
8727
  const { execSync } = await import("node:child_process");
8320
8728
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${effectivePort}`, { stdio: "inherit" });
8321
8729
  console.log(` ${render.icons.ok} Restarted onto ${__pkgVersion}`);
8730
+ fixed++;
8322
8731
  }
8323
8732
  catch {
8324
8733
  console.log(` ${render.icons.error} Restart failed — try: flair restart`);
@@ -8342,6 +8751,7 @@ program
8342
8751
  if (existsSync(keysDir)) {
8343
8752
  const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
8344
8753
  if (keyFiles.length > 0) {
8754
+ keyAgentIds = keyFiles.map((f) => f.replace(/\.key$/, ""));
8345
8755
  console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyFiles.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
8346
8756
  }
8347
8757
  else {
@@ -8418,6 +8828,7 @@ program
8418
8828
  else {
8419
8829
  (await import("node:fs")).unlinkSync(pidFile);
8420
8830
  console.log(` ${render.icons.ok} Removed stale PID file`);
8831
+ fixed++;
8421
8832
  }
8422
8833
  }
8423
8834
  else {
@@ -8492,12 +8903,14 @@ program
8492
8903
  console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
8493
8904
  }
8494
8905
  else {
8495
- const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl || baseUrl };
8906
+ const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: resolveWireFlairUrl(block.flairUrl, baseUrl) };
8496
8907
  const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
8497
8908
  client.id === "codex" ? wireCodex(wireEnv) :
8498
8909
  client.id === "gemini" ? wireGemini(wireEnv) :
8499
8910
  wireCursor(wireEnv);
8500
8911
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
8912
+ if (wireResult.ok)
8913
+ fixed++;
8501
8914
  }
8502
8915
  }
8503
8916
  }
@@ -8549,6 +8962,8 @@ program
8549
8962
  else {
8550
8963
  const fixRes = fixClaudeMdBootstrap(process.cwd());
8551
8964
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
8965
+ if (fixRes.ok)
8966
+ fixed++;
8552
8967
  }
8553
8968
  }
8554
8969
  }
@@ -8576,6 +8991,8 @@ program
8576
8991
  const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
8577
8992
  const fixRes = fixSessionStartHook(homedir(), fixAgentId);
8578
8993
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
8994
+ if (fixRes.ok)
8995
+ fixed++;
8579
8996
  }
8580
8997
  }
8581
8998
  }
@@ -8586,6 +9003,54 @@ program
8586
9003
  }
8587
9004
  }
8588
9005
  }
9006
+ // 7a. Resolve which agent identities the two verified-read sections below
9007
+ // (Fleet presence, Migrations) iterate (flair#722). Previously both
9008
+ // sections required --agent explicitly; doctor already enumerates every
9009
+ // key in ~/.flair/keys (step 2 above), so by default it now runs the
9010
+ // signed read AS EACH of those agents instead of hiding behind a flag —
9011
+ // a real dogfood run found the #720 halted-migration warning visible via
9012
+ // `flair status --agent local` but invisible in the default `doctor` run
9013
+ // the same user ran minutes later. --agent <id> narrows this to exactly
9014
+ // that one identity (planAgentIterations — same pre-#722 semantics: a
9015
+ // single signed identity, just no longer widened to "every key").
9016
+ //
9017
+ // The registration gate (checkAgentRegistered — same signed GET
9018
+ // /Agent/:id used by the Client integration section above) is resolved
9019
+ // ONCE here per agent and shared by both sections, so a bad/unregistered
9020
+ // key doesn't cost two network round-trips, and its "found" count isn't
9021
+ // double-counted by each section re-discovering the same finding
9022
+ // (flair#721 found/fixed/remaining summary — these are found-only, no
9023
+ // --fix action exists for a bad local key). A gate failure for one agent
9024
+ // never aborts the others — that's the failure isolation flair#722 asks
9025
+ // for; describeAgentGateFinding (src/doctor-client.ts) is pure decision
9026
+ // logic so it's unit-tested without a real Harper.
9027
+ const verifiedReadAgentIds = harperResponding
9028
+ ? planAgentIterations(keyAgentIds, opts.agent || process.env.FLAIR_AGENT_ID)
9029
+ : [];
9030
+ const agentGates = [];
9031
+ for (const id of verifiedReadAgentIds) {
9032
+ const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
9033
+ agentGates.push({ id, state: reg.state, detail: reg.detail });
9034
+ const finding = describeAgentGateFinding(id, reg.state, reg.detail);
9035
+ if (finding?.isIssue)
9036
+ issues++;
9037
+ }
9038
+ // Shared renderer for one agent's registration-gate outcome — prints the
9039
+ // "Agent: <id>" subsection header, and if the gate isn't clean, the
9040
+ // finding (never re-counted here; already counted once above) and
9041
+ // returns false so the caller skips its own verified fetch for this
9042
+ // agent and moves on to the next (failure isolation).
9043
+ function renderAgentGateHeader(gate) {
9044
+ console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
9045
+ const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail);
9046
+ if (!finding)
9047
+ return true;
9048
+ const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
9049
+ console.log(` ${icon} ${finding.message}`);
9050
+ if (finding.fixHint)
9051
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
9052
+ return false;
9053
+ }
8589
9054
  // 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
8590
9055
  //
8591
9056
  // "Instance" here means each AGENT's heartbeat row — Presence is keyed by
@@ -8606,69 +9071,82 @@ program
8606
9071
  // `doctor` unless those agents also heartbeat straight to the hub. Not
8607
9072
  // fixed here — flair#639's fix list is version-stamping + a doctor
8608
9073
  // listing, not widening federation sync scope.
8609
- if (harperResponding) {
8610
- console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
9074
+ //
9075
+ // flair#722: iterated per agent (agentGates above) instead of a single
9076
+ // --agent-gated read. flairVersion/harperVersion are gated to verified
9077
+ // readers on the server (resources/Presence.ts, same boundary as
9078
+ // currentTask), so each agent subsection signs its own GET — a working
9079
+ // key reveals versions for that subsection; roster IDENTITY is public
9080
+ // either way. Zero local keys (and no --agent) falls back to exactly the
9081
+ // pre-#722 single unauthenticated read (hidden versions, "Pass --agent"
9082
+ // hint) — there's no agent to sign as, but remote agents may still have
9083
+ // heartbeated onto this instance and identities are worth showing.
9084
+ async function fetchAndRenderFleetPresence(headers, canSign, indent) {
8611
9085
  try {
8612
- // flairVersion/harperVersion are gated to verified readers on the
8613
- // server (resources/Presence.ts, same boundary as currentTask) — sign
8614
- // the GET when we have an agent + key so the fields aren't silently
8615
- // nulled out from under us.
8616
- const fleetAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
8617
- const fleetKeyPath = fleetAgentId ? join(defaultKeysDir(), `${fleetAgentId}.key`) : undefined;
8618
- const canSign = !!(fleetAgentId && fleetKeyPath && existsSync(fleetKeyPath));
8619
- const headers = canSign
8620
- ? { Authorization: buildEd25519Auth(fleetAgentId, "GET", "/Presence", fleetKeyPath) }
8621
- : {};
8622
9086
  const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
8623
9087
  if (!presRes.ok) {
8624
- console.log(` ${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
9088
+ console.log(`${indent}${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
9089
+ return;
8625
9090
  }
8626
- else {
8627
- const roster = (await presRes.json());
8628
- if (!Array.isArray(roster) || roster.length === 0) {
8629
- console.log(` ${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
8630
- }
8631
- else {
8632
- const rows = sortOldestVersionFirst(markStale(roster));
8633
- for (const row of rows) {
8634
- const lastSeen = typeof row.lastHeartbeatAt === "number"
8635
- ? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
8636
- : "—";
8637
- const versionLabel = !canSign
8638
- ? render.wrap(render.c.dim, "hidden")
8639
- : row.flairVersion
8640
- ? `v${row.flairVersion}`
8641
- : render.wrap(render.c.dim, "no version reported");
8642
- const staleNote = row.stale && row.newestVersion
8643
- ? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
8644
- : "";
8645
- const icon = row.stale ? render.icons.warn : render.icons.ok;
8646
- const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
8647
- // Natural-presence: same staleness principle as the version
8648
- // column — a live activity is shown as current, a decayed one as
8649
- // "last-known". `activityFresh === false` (server verdict) plus a
8650
- // known lastActivity → "(was: X)"; a fresh, non-idle activity →
8651
- // "(X)". Skip entirely when there's nothing informative to say
8652
- // (no signal, or idle) so the line stays quiet for the common case.
8653
- const lastActivity = row.lastActivity ?? row.activity;
8654
- const activityNote = row.activityFresh === false
8655
- ? (lastActivity && lastActivity !== "idle"
8656
- ? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
8657
- : "")
8658
- : (row.activity && row.activity !== "idle"
8659
- ? " " + render.wrap(render.c.dim, `(${row.activity})`)
8660
- : "");
8661
- console.log(` ${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
8662
- }
8663
- if (!canSign) {
8664
- console.log(` ${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.")}`);
8665
- }
8666
- console.log(` ${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.")}`);
8667
- }
9091
+ const roster = (await presRes.json());
9092
+ if (!Array.isArray(roster) || roster.length === 0) {
9093
+ console.log(`${indent}${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
9094
+ return;
8668
9095
  }
9096
+ const rows = sortOldestVersionFirst(markStale(roster));
9097
+ for (const row of rows) {
9098
+ const lastSeen = typeof row.lastHeartbeatAt === "number"
9099
+ ? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
9100
+ : "—";
9101
+ const versionLabel = !canSign
9102
+ ? render.wrap(render.c.dim, "hidden")
9103
+ : row.flairVersion
9104
+ ? `v${row.flairVersion}`
9105
+ : render.wrap(render.c.dim, "no version reported");
9106
+ const staleNote = row.stale && row.newestVersion
9107
+ ? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
9108
+ : "";
9109
+ const icon = row.stale ? render.icons.warn : render.icons.ok;
9110
+ const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
9111
+ // Natural-presence: same staleness principle as the version
9112
+ // column — a live activity is shown as current, a decayed one as
9113
+ // "last-known". `activityFresh === false` (server verdict) plus a
9114
+ // known lastActivity → "(was: X)"; a fresh, non-idle activity →
9115
+ // "(X)". Skip entirely when there's nothing informative to say
9116
+ // (no signal, or idle) so the line stays quiet for the common case.
9117
+ const lastActivity = row.lastActivity ?? row.activity;
9118
+ const activityNote = row.activityFresh === false
9119
+ ? (lastActivity && lastActivity !== "idle"
9120
+ ? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
9121
+ : "")
9122
+ : (row.activity && row.activity !== "idle"
9123
+ ? " " + render.wrap(render.c.dim, `(${row.activity})`)
9124
+ : "");
9125
+ console.log(`${indent}${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
9126
+ }
9127
+ if (!canSign) {
9128
+ 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.")}`);
9129
+ }
9130
+ 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.")}`);
8669
9131
  }
8670
9132
  catch (err) {
8671
- console.log(` ${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
9133
+ console.log(`${indent}${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
9134
+ }
9135
+ }
9136
+ if (harperResponding) {
9137
+ console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
9138
+ if (agentGates.length === 0) {
9139
+ await fetchAndRenderFleetPresence({}, false, " ");
9140
+ }
9141
+ else {
9142
+ for (const gate of agentGates) {
9143
+ const registered = renderAgentGateHeader(gate);
9144
+ if (!registered)
9145
+ continue;
9146
+ const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
9147
+ const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/Presence", keyPath) };
9148
+ await fetchAndRenderFleetPresence(headers, true, " ");
9149
+ }
8672
9150
  }
8673
9151
  }
8674
9152
  // 9. Migration state (flair#695) — pending/in-progress/blocked + last
@@ -8678,65 +9156,80 @@ program
8678
9156
  // 1a above (a halted migration retries automatically on the next boot —
8679
9157
  // there's no separate "run the migration now" fix; the fix for
8680
9158
  // "blocked" is whatever the halt reason names, e.g. freeing disk).
8681
- if (harperResponding) {
8682
- console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
9159
+ //
9160
+ // flair#722: iterated per agent (agentGates above), same as Fleet
9161
+ // presence — each subsection's finding is found-only (no per-agent
9162
+ // --fix here beyond the existing restart-on-halt story). Gate FINDINGS
9163
+ // are rendered in full under Fleet presence only (the first
9164
+ // verified-read section); re-printing the identical per-agent finding
9165
+ // here doubled the noise on real multi-key machines (a 27-key dogfood
9166
+ // box printed 15 not-registered findings twice each), so this section
9167
+ // iterates only the gate-passed agents and rolls the rest into one
9168
+ // aggregate skip line. The issue COUNT is unaffected either way — gate
9169
+ // findings are counted exactly once, at gate-resolution time (step 7a).
9170
+ async function fetchAndRenderMigrations(headers, indent) {
8683
9171
  try {
8684
- const migAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
8685
- const migKeyPath = migAgentId ? join(defaultKeysDir(), `${migAgentId}.key`) : undefined;
8686
- const migCanSign = !!(migAgentId && migKeyPath && existsSync(migKeyPath));
8687
- if (!migCanSign) {
8688
- 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.`);
9172
+ const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers, signal: AbortSignal.timeout(5000) });
9173
+ if (!migRes.ok) {
9174
+ console.log(`${indent}${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
9175
+ return;
8689
9176
  }
8690
- else {
8691
- const migHeaders = { Authorization: buildEd25519Auth(migAgentId, "GET", "/HealthDetail", migKeyPath) };
8692
- const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers: migHeaders, signal: AbortSignal.timeout(5000) });
8693
- if (!migRes.ok) {
8694
- console.log(` ${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
9177
+ const detail = (await migRes.json());
9178
+ const migBlock = detail?.migrations;
9179
+ if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
9180
+ console.log(`${indent}${render.icons.info} No migrations registered on this instance`);
9181
+ return;
9182
+ }
9183
+ if (migBlock.cyclePhase === "pre-hash") {
9184
+ console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
9185
+ }
9186
+ for (const m of migBlock.migrations) {
9187
+ if (m.state === "completed") {
9188
+ console.log(`${indent}${render.icons.ok} ${m.id}: completed`);
9189
+ }
9190
+ else if (m.state === "halted" || m.state === "failed") {
9191
+ console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
9192
+ issues++;
9193
+ }
9194
+ else if (m.state === "running") {
9195
+ console.log(`${indent}${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
8695
9196
  }
8696
9197
  else {
8697
- const detail = (await migRes.json());
8698
- const migBlock = detail?.migrations;
8699
- if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
8700
- console.log(` ${render.icons.info} No migrations registered on this instance`);
8701
- }
8702
- else {
8703
- if (migBlock.cyclePhase === "pre-hash") {
8704
- console.log(` ${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
8705
- }
8706
- for (const m of migBlock.migrations) {
8707
- if (m.state === "completed") {
8708
- console.log(` ${render.icons.ok} ${m.id}: completed`);
8709
- }
8710
- else if (m.state === "halted" || m.state === "failed") {
8711
- console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
8712
- issues++;
8713
- }
8714
- else if (m.state === "running") {
8715
- console.log(` ${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
8716
- }
8717
- else {
8718
- console.log(` ${render.icons.info} ${m.id}: ${m.state}`);
8719
- }
8720
- }
8721
- }
9198
+ console.log(`${indent}${render.icons.info} ${m.id}: ${m.state}`);
8722
9199
  }
8723
9200
  }
8724
9201
  }
8725
9202
  catch (err) {
8726
- console.log(` ${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
9203
+ console.log(`${indent}${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
8727
9204
  }
8728
9205
  }
8729
- // Summary
8730
- console.log("");
8731
- if (issues === 0) {
8732
- console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`);
8733
- }
8734
- else {
8735
- console.log(` ${render.icons.error} ${render.wrap(render.c.red, `${issues} issue${issues > 1 ? "s" : ""} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`);
9206
+ if (harperResponding) {
9207
+ console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
9208
+ if (agentGates.length === 0) {
9209
+ 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.`);
9210
+ }
9211
+ else {
9212
+ const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail) === null);
9213
+ for (const gate of passedGates) {
9214
+ renderAgentGateHeader(gate);
9215
+ const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
9216
+ const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/HealthDetail", keyPath) };
9217
+ await fetchAndRenderMigrations(headers, " ");
9218
+ }
9219
+ const skipped = agentGates.length - passedGates.length;
9220
+ if (skipped > 0) {
9221
+ console.log(` ${render.icons.info} ${skipped} agent(s) skipped — registration-gate findings reported under Fleet presence above`);
9222
+ }
9223
+ }
8736
9224
  }
9225
+ // Summary — see summarizeDoctorRun above (flair#721): distinguishes
9226
+ // issues --fix actually resolved this run from ones still outstanding.
8737
9227
  console.log("");
8738
- if (issues > 0)
8739
- process.exit(1);
9228
+ const summary = summarizeDoctorRun(issues, fixed, autoFix);
9229
+ console.log(summary.line);
9230
+ console.log("");
9231
+ if (summary.exitCode !== 0)
9232
+ process.exit(summary.exitCode);
8740
9233
  });
8741
9234
  // ─── flair session snapshot ──────────────────────────────────────────────────
8742
9235
  // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a