@tpsdev-ai/flair 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +15 -4
  2. package/dist/bridges/runtime/roundtrip.js +91 -2
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli.js +565 -171
  5. package/dist/deploy.js +20 -3
  6. package/dist/doctor-client.js +62 -34
  7. package/dist/federation/scheduler.js +24 -3
  8. package/dist/hook-install.js +45 -13
  9. package/dist/install/clients.js +28 -17
  10. package/dist/lib/doctor-run.js +481 -0
  11. package/dist/lib/launchd-management.js +7 -26
  12. package/dist/lib/scheduler-platform.js +132 -10
  13. package/dist/lib/scratch-owner.js +49 -0
  14. package/dist/rem/scheduler.js +23 -5
  15. package/dist/resources/Federation.js +42 -20
  16. package/dist/resources/MemoryBootstrap.js +8 -4
  17. package/dist/resources/RecordUsage.js +13 -6
  18. package/dist/resources/SemanticSearch.js +8 -1
  19. package/dist/resources/federation-classify.js +90 -0
  20. package/dist/resources/health.js +51 -7
  21. package/dist/resources/mcp-tools.js +10 -6
  22. package/dist/resources/search-readiness.js +123 -0
  23. package/dist/resources/semantic-retrieval-core.js +48 -21
  24. package/dist/resources/sort-comparators.js +45 -0
  25. package/dist/resources/usage-ids.js +63 -0
  26. package/dist/src/lib/scheduler-platform.js +132 -10
  27. package/dist/src/rem/scheduler.js +23 -5
  28. package/docs/auth.md +5 -0
  29. package/docs/deepseek-harness.md +1 -1
  30. package/docs/federation.md +11 -0
  31. package/docs/hosted-on-fabric.md +2 -0
  32. package/docs/integrations.md +53 -1
  33. package/docs/mcp-clients.md +67 -15
  34. package/docs/quickstart-fabric.md +1 -1
  35. package/docs/supply-chain-policy.md +1 -1
  36. package/docs/troubleshooting.md +25 -0
  37. package/docs/upgrade.md +17 -1
  38. package/package.json +3 -3
  39. package/schemas/federation.graphql +1 -1
package/dist/cli.js CHANGED
@@ -26,13 +26,14 @@ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion
26
26
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
27
27
  import { readClientMcpBlock, effectiveFlairUrl, checkPiFlairWiring, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
28
28
  import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
29
- import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
29
+ import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
30
30
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, DEFAULT_ADMIN_USER, resolveAdminUser, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
31
31
  import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
32
32
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
33
33
  import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
34
34
  import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
35
- import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
35
+ import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
36
+ import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
36
37
  // Value-only static import so `--interval`'s advertised default cannot drift
37
38
  // from the one the scheduler actually validates against. The module itself is
38
39
  // still loaded lazily at call time (the `await import()`s below) for the
@@ -60,14 +61,18 @@ function signBody(body, secretKey) {
60
61
  const sig = nacl.sign.detached(message, secretKey);
61
62
  return Buffer.from(sig).toString("base64url");
62
63
  }
63
- // Per-record principalId (federation-edge-hardening slice 3a) INFORMATIONAL
64
- // only; the receiver (resources/Federation.ts) never treats it as verified
65
- // identity or uses it in any auth decision. Sourced from the write-time
66
- // provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
67
- // when present. `provenance` is persisted as a JSON STRING (not an object),
68
- // so it must be parsed a raw `row.provenance?.verified?.agentId` would
69
- // silently always be undefined. Soul/Agent/Relationship rows never carry a
70
- // provenance stamp today, so this is a no-op for them.
64
+ // Per-record principalId (federation-edge-hardening slice 3a / flair#1416).
65
+ // Sourced from the write-time provenance stamp (memory-provenance slice 1,
66
+ // Memory.ts's buildProvenance) when present. `provenance` is persisted as
67
+ // a JSON STRING (not an object), so it must be parsed — a raw
68
+ // `row.provenance?.verified?.agentId` would silently always be undefined.
69
+ // Soul/Agent/Relationship rows never carry a provenance stamp today, so
70
+ // this is a no-op for them (those tables are not principal-owning).
71
+ //
72
+ // As of v:2 this value is IN the signed payload. The receiver validates
73
+ // it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
74
+ // no longer informational-only. Credential.principalId is an unrelated
75
+ // owner field — do not grep that path when changing this one.
71
76
  function principalIdFromRow(row) {
72
77
  if (typeof row?.provenance !== "string" || row.provenance.length === 0)
73
78
  return undefined;
@@ -685,6 +690,48 @@ function resolveSigningIdentityFor(opts, command) {
685
690
  function resolveSigningAgentId(opts, command) {
686
691
  return resolveSigningIdentityFor(opts, command).agentId;
687
692
  }
693
+ // ── Shared credential/identity flag surface (flair#1106) ─────────────────────
694
+ // Sibling commands (memory add, backup, federation sync) used to drift on
695
+ // the same concepts: `--admin-pass-file` existed on backup/sync but was an
696
+ // unknown option on `memory add`, and `memory add --agent` was a commander
697
+ // requiredOption so FLAIR_AGENT_ID could never satisfy it. One helper owns
698
+ // the credential flag names/shapes; identity (`--agent`) stays optional so
699
+ // the env fallback can actually apply. This does not invent a new auth
700
+ // model — it only declares the flags authedRequest already resolves.
701
+ /** Flag strings the sibling commands must share (name + argument shape). */
702
+ export const SHARED_CREDENTIAL_FLAGS = {
703
+ adminPass: "--admin-pass <pass>",
704
+ adminPassFile: "--admin-pass-file <path>",
705
+ adminUser: "--admin-user <name>",
706
+ };
707
+ export const SHARED_IDENTITY_FLAGS = {
708
+ agent: "--agent <id>",
709
+ };
710
+ function addSharedCredentialOptions(cmd) {
711
+ return cmd
712
+ .option(SHARED_CREDENTIAL_FLAGS.adminPass, "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
713
+ .option(SHARED_CREDENTIAL_FLAGS.adminPassFile, "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
714
+ .option(SHARED_CREDENTIAL_FLAGS.adminUser, "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)");
715
+ }
716
+ function addSharedIdentityOption(cmd) {
717
+ return cmd.option(SHARED_IDENTITY_FLAGS.agent, "Agent ID (or set FLAIR_AGENT_ID env)");
718
+ }
719
+ /**
720
+ * Resolve `--admin-pass-file` into the same `adminPass` slot the inline flag
721
+ * uses. Shared so sibling commands cannot drift on how the file is read
722
+ * (mode 0600 via readAdminPassFileSecure).
723
+ */
724
+ function applyAdminPassFile(opts) {
725
+ if (!opts.adminPass && opts.adminPassFile) {
726
+ try {
727
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
728
+ }
729
+ catch (err) {
730
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
731
+ process.exit(1);
732
+ }
733
+ }
734
+ }
688
735
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
689
736
  //
690
737
  // Deliberately NOT routed through Harper's per-instance config the way
@@ -1429,7 +1476,13 @@ async function api(method, path, body, options) {
1429
1476
  agentId = decodeURIComponent(match[1]);
1430
1477
  }
1431
1478
  }
1432
- return authedRequest(method, path, body, { baseUrl: base, agentId, keysDir: options?.keysDir });
1479
+ return authedRequest(method, path, body, {
1480
+ baseUrl: base,
1481
+ agentId,
1482
+ keysDir: options?.keysDir,
1483
+ explicitAdminPass: options?.explicitAdminPass,
1484
+ adminUser: options?.adminUser,
1485
+ });
1433
1486
  }
1434
1487
  /**
1435
1488
  * The authedGet `flair upgrade` verification (flair#635/#741) hands to
@@ -2614,9 +2667,9 @@ export function upgradeStatusSuffix(name, status) {
2614
2667
  * is `flair doctor --fix`, never `npm install -g`.
2615
2668
  * 3. Wired with a concrete pin — that pin IS the installed version
2616
2669
  * (current when it equals latest, else outdated → re-pin via doctor).
2617
- * 4. Wired but unpinned (a bare npx spec / the SessionStart hook) — `npx -y`
2618
- * re-resolves latest every session, so the effective version IS latest →
2619
- * current.
2670
+ * 4. Wired but unpinned (a bare npx spec / a pre-#1143 SessionStart hook) —
2671
+ * `npx -y` re-resolves latest every session, so the effective version IS
2672
+ * latest → current.
2620
2673
  */
2621
2674
  export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
2622
2675
  // 1. Legacy global install.
@@ -2954,7 +3007,7 @@ program
2954
3007
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
2955
3008
  .option("--skip-smoke", "Skip the MCP smoke test")
2956
3009
  .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
2957
- .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
3010
+ .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
2958
3011
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
2959
3012
  .option("--remote", "When used with --target, init as hub for remote federation")
2960
3013
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -3816,6 +3869,18 @@ program
3816
3869
  }
3817
3870
  wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
3818
3871
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
3872
+ // Codex SessionStart hook (flair#1148 / #1439) — the hook is not
3873
+ // optional on Codex (no CLAUDE.md alternative). Init is the
3874
+ // consent to set up the client, same as the Claude Code hook
3875
+ // applied above. --skip-hook opts out and prints the JSON.
3876
+ if (clientId === "codex" && result.ok) {
3877
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
3878
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
3879
+ if (hookResult.hint) {
3880
+ for (const line of hookResult.hint.split("\n"))
3881
+ console.log(` ${line}`);
3882
+ }
3883
+ }
3819
3884
  }
3820
3885
  }
3821
3886
  }
@@ -4573,18 +4638,12 @@ keys
4573
4638
  // dry-run delta, symmetric removal) lives in src/hook-install.ts — this
4574
4639
  // section is pure CLI plumbing: option parsing, default resolution, and
4575
4640
  // rendering the pure functions' results.
4576
- function resolveHookAgentId(opts, homeDir) {
4577
- return (opts.agent ||
4578
- opts.agentId ||
4579
- process.env.FLAIR_AGENT_ID ||
4580
- readClientMcpBlock("claude-code", homeDir).agentId ||
4581
- undefined);
4582
- }
4583
- function resolveHookFlairUrl(opts, homeDir) {
4641
+ function resolveHookFlairUrl(opts, homeDir, harness) {
4584
4642
  return (opts.url ||
4585
4643
  process.env.FLAIR_TARGET ||
4586
4644
  process.env.FLAIR_URL ||
4587
- readClientMcpBlock("claude-code", homeDir).flairUrl ||
4645
+ readClientMcpBlock(harness, homeDir).flairUrl ||
4646
+ (harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
4588
4647
  resolveBaseUrl({}));
4589
4648
  }
4590
4649
  function requireSupportedHarness(raw) {
@@ -4601,19 +4660,19 @@ hook
4601
4660
  .description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
4602
4661
  .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
4603
4662
  .option("--dry-run", "Print the exact JSON delta without writing")
4604
- .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
4663
+ .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
4605
4664
  .option("--agent-id <id>", "Alias for --agent")
4606
- .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
4665
+ .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else this harness's MCP wiring, else the local default)")
4607
4666
  .option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
4608
4667
  .action((opts) => {
4609
4668
  const harness = requireSupportedHarness(opts.harness);
4610
4669
  const home = homedir();
4611
- const agentId = resolveHookAgentId(opts, home);
4670
+ const agentId = resolveHookAgentId(opts, home, harness);
4612
4671
  if (!agentId) {
4613
4672
  console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
4614
4673
  process.exit(1);
4615
4674
  }
4616
- const flairUrl = resolveHookFlairUrl(opts, home);
4675
+ const flairUrl = resolveHookFlairUrl(opts, home, harness);
4617
4676
  const dryRun = !!opts.dryRun;
4618
4677
  if (opts.continuity) {
4619
4678
  const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
@@ -4630,6 +4689,11 @@ hook
4630
4689
  const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
4631
4690
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4632
4691
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4692
+ const pinWarning = unpinnedSpecWarning();
4693
+ if (pinWarning && result.ok) {
4694
+ for (const line of pinWarning.split("\n"))
4695
+ console.error(` ⚠ ${line}`);
4696
+ }
4633
4697
  if (result.backupPath) {
4634
4698
  console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4635
4699
  }
@@ -4689,16 +4753,20 @@ hook
4689
4753
  // status in every branch below. "absent" is NOT a failure: installing the
4690
4754
  // pair is the opt-in, so absence renders as "not enabled".
4691
4755
  const renderContinuity = () => {
4756
+ // Continuity is Claude Code only. Do not tip `--continuity --harness
4757
+ // <other>` — that writes Claude tool matchers into the wrong file.
4758
+ if (!harnessSupportsContinuity(harness))
4759
+ return;
4692
4760
  const cont = continuityHookStatus(home, harness);
4693
4761
  if (cont.state === "installed") {
4694
4762
  console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
4695
4763
  }
4696
4764
  else if (cont.state === "absent") {
4697
- console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
4765
+ console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, `(opt-in: ${hookInstallHint(harness, "--continuity")})`)}`);
4698
4766
  }
4699
4767
  else {
4700
4768
  const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
4701
- console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
4769
+ console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
4702
4770
  }
4703
4771
  };
4704
4772
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
@@ -4711,7 +4779,7 @@ hook
4711
4779
  }
4712
4780
  if (!status.wired) {
4713
4781
  console.log(` ${render.icons.error} not wired`);
4714
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
4782
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
4715
4783
  renderContinuity();
4716
4784
  console.log("");
4717
4785
  process.exit(1);
@@ -4732,7 +4800,7 @@ hook
4732
4800
  console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
4733
4801
  }
4734
4802
  else {
4735
- console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
4803
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`${hookInstallHint(status.harness)}\` to adopt the silent form`);
4736
4804
  }
4737
4805
  renderContinuity();
4738
4806
  console.log("");
@@ -6116,6 +6184,95 @@ function driverCheckAppliesTo(opts) {
6116
6184
  const target = resolveTarget(opts);
6117
6185
  return !target || isLocalBase(target.replace(/\/$/, ""));
6118
6186
  }
6187
+ /**
6188
+ * flair#1108: a bare undici/Node "fetch failed" names neither the URL
6189
+ * that was probed nor the knob that would change it. These helpers are
6190
+ * the operator-facing sentence and the setting that produced (or would
6191
+ * change) that URL. Pure so the contract can be unit-tested without
6192
+ * driving process.exit.
6193
+ */
6194
+ export function federationStatusUrlSetting(opts) {
6195
+ if (opts.target)
6196
+ return "--target";
6197
+ if (process.env.FLAIR_TARGET)
6198
+ return "FLAIR_TARGET";
6199
+ if (process.env.FLAIR_URL)
6200
+ return "FLAIR_URL";
6201
+ if (opts.port !== undefined && opts.port !== null && String(opts.port) !== "")
6202
+ return "--port";
6203
+ return "FLAIR_URL or --port";
6204
+ }
6205
+ export function describeFederationStatusFetchFailed(url, setting) {
6206
+ return `fetch failed against ${url} (set ${setting})`;
6207
+ }
6208
+ /** True for a connect-level failure (no HTTP status): Node's undici
6209
+ * `TypeError: fetch failed`, Bun's `Unable to connect…`, or a cause
6210
+ * carrying a connect/DNS errno. Auth and HTTP errors stay out. */
6211
+ export function isFederationStatusConnectFailure(err) {
6212
+ const msg = err instanceof Error ? err.message : String(err);
6213
+ if (/\bfetch failed\b/i.test(msg))
6214
+ return true;
6215
+ if (/unable to connect/i.test(msg))
6216
+ return true;
6217
+ const cause = err instanceof Error ? err.cause : undefined;
6218
+ const code = cause && typeof cause === "object" && cause && "code" in cause
6219
+ ? String(cause.code)
6220
+ : "";
6221
+ return /^(ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH)$/.test(code);
6222
+ }
6223
+ export function rewriteFederationStatusFetchFailed(err, url, setting) {
6224
+ if (!isFederationStatusConnectFailure(err))
6225
+ return err;
6226
+ const next = new Error(describeFederationStatusFetchFailed(url, setting));
6227
+ if (err && typeof err === "object" && "status" in err) {
6228
+ next.status = err.status;
6229
+ }
6230
+ return next;
6231
+ }
6232
+ /**
6233
+ * Auth-shaped vs connect-level for `federation status`. A rewritten
6234
+ * fetch-failed sentence embeds the probed URL; that URL can contain a
6235
+ * whole-token `401` (e.g. `--port 401`). The old `message.includes("401")`
6236
+ * check then printed the credential remedy and hid the URL+setting this
6237
+ * change exists to surface (Bugbot on flair#1108).
6238
+ */
6239
+ export function isFederationStatusAuthFailure(err) {
6240
+ if (!err)
6241
+ return false;
6242
+ if (isFederationStatusConnectFailure(err))
6243
+ return false;
6244
+ if (typeof err === "object" && "status" in err) {
6245
+ const status = err.status;
6246
+ if (status === 401 || status === 403)
6247
+ return true;
6248
+ }
6249
+ const m = err instanceof Error
6250
+ ? err.message
6251
+ : String(typeof err === "object" && err && "message" in err
6252
+ ? err.message ?? err
6253
+ : err);
6254
+ return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
6255
+ }
6256
+ /**
6257
+ * Whether to print the "set one of: FLAIR_AGENT_ID / FLAIR_ADMIN_PASS /
6258
+ * FLAIR_TOKEN" block. Narrower than `isFederationStatusAuthFailure`: a
6259
+ * 403 with credentials already sent (wrong password) is fatal, but the
6260
+ * server's own body is the honest message — the credential-list remedy
6261
+ * is for missing/invalid auth (401), not a rejected password (flair#634).
6262
+ */
6263
+ export function isFederationStatusAuthRemedy(err) {
6264
+ if (!err || isFederationStatusConnectFailure(err))
6265
+ return false;
6266
+ if (typeof err === "object" && "status" in err && err.status === 401) {
6267
+ return true;
6268
+ }
6269
+ const m = err instanceof Error
6270
+ ? err.message
6271
+ : String(typeof err === "object" && err && "message" in err
6272
+ ? err.message ?? err
6273
+ : err);
6274
+ return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
6275
+ }
6119
6276
  federation
6120
6277
  .command("status")
6121
6278
  .description("Show federation status and peer connections")
@@ -6124,8 +6281,11 @@ federation
6124
6281
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
6125
6282
  .option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
6126
6283
  .action(async (opts) => {
6127
- const target = resolveTarget(opts);
6128
- const baseUrl = target ? target.replace(/\/$/, "") : undefined;
6284
+ // Same URL api() would have derived, including --port (the command
6285
+ // advertised --port but previously dropped it on the floor). Naming
6286
+ // that URL on fetch failure is only honest if it is the URL we probe.
6287
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
6288
+ const urlSetting = federationStatusUrlSetting(opts);
6129
6289
  const mode = render.resolveOutputMode(opts);
6130
6290
  // flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
6131
6291
  // must never take down the whole render — the principle latestPeerContact
@@ -6135,41 +6295,33 @@ federation
6135
6295
  let instance = null;
6136
6296
  let instanceErr = null;
6137
6297
  try {
6138
- instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
6298
+ instance = await api("GET", "/FederationInstance", undefined, { baseUrl });
6139
6299
  }
6140
6300
  catch (err) {
6141
- instanceErr = err;
6301
+ instanceErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
6142
6302
  }
6143
6303
  // peers: null = unverifiable (the read failed), [] = verified empty.
6144
6304
  let peers = null;
6145
6305
  let peersErr = null;
6146
6306
  try {
6147
- const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
6307
+ const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
6148
6308
  peers = r.peers ?? [];
6149
6309
  }
6150
6310
  catch (err) {
6151
- peersErr = err;
6311
+ peersErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
6152
6312
  }
6153
6313
  // Auth-shaped failures stay FATAL even when the other read succeeded:
6154
6314
  // both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
6155
6315
  // property of the session's credentials, not of one endpoint — and
6156
6316
  // degrading it to "unverifiable" would swallow the actionable remedy
6157
6317
  // (flair#634's UX, kept). Only non-auth failures degrade independently.
6158
- const authShaped = (err) => {
6159
- if (!err)
6160
- return false;
6161
- if (err.status === 401 || err.status === 403)
6162
- return true;
6163
- const m = String(err.message ?? err);
6164
- return m.includes("missing_or_invalid_authorization") || m.includes("401");
6165
- };
6166
6318
  // Both reads failed → nothing to render at all. Either way keep the
6167
6319
  // classic failure UX (auth remedy when it's an auth problem), exit
6168
6320
  // non-zero.
6169
- if ((instanceErr && peersErr) || authShaped(instanceErr) || authShaped(peersErr)) {
6321
+ if ((instanceErr && peersErr) || isFederationStatusAuthFailure(instanceErr) || isFederationStatusAuthFailure(peersErr)) {
6170
6322
  const primaryErr = instanceErr ?? peersErr;
6171
6323
  const msg = String(primaryErr.message ?? primaryErr);
6172
- if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
6324
+ if (isFederationStatusAuthRemedy(primaryErr)) {
6173
6325
  console.error(`${render.icons.error} federation status requires auth.`);
6174
6326
  console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
6175
6327
  console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
@@ -6895,22 +7047,40 @@ export async function runFederationSyncOnce(opts) {
6895
7047
  // batch. Closes the hub-relay forgery hole — see
6896
7048
  // resources/Federation.ts FederationSync.post's verification gate.
6897
7049
  //
6898
- // CONTRACT — must match Federation.ts's verification payload
6899
- // byte-for-byte: keys { v, table, id, data, updatedAt,
6900
- // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
6901
- // doesn't matter, but the field SET and values do. `v: 1` versions the
6902
- // canonical form itself: bump it on BOTH sides together if the signed
6903
- // field set ever changes, so an old signature fails closed instead of
6904
- // silently mis-verifying under a new form.
7050
+ // CONTRACT — must match reconstructRecordVerifyBody
7051
+ // (resources/federation-classify.ts) byte-for-byte. canonicalize()
7052
+ // sorts keys, so field ORDER doesn't matter, but the field SET and
7053
+ // values do. `v` versions the canonical form itself: a v:1
7054
+ // signature cannot verify as v:2 (principalId in the field set).
6905
7055
  //
6906
- // Additive/backward-compatible: pre-3a receivers don't read
6907
- // `signature`/`principalId` at all and merge exactly as before.
6908
- const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
6909
- const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
6910
- // Informational only (see principalIdFromRow) never verified by the
6911
- // receiver as proof of authorship. Omitted entirely when the row
6912
- // carries no write-time provenance stamp.
7056
+ // v: 2 puts principalId in the signed payload when the row carries
7057
+ // a provenance stamp, and puts `v` on the wire so Phase 1
7058
+ // receivers (`const v = record.v ?? 1`) don't default these
7059
+ // records back to 1. Soul/Agent/Relationship have no stamp and
7060
+ // omit principalId; Memory without a stamp also omits it (the
7061
+ // receiver then skips Memory as principal_mismatch absent is
7062
+ // not an accept).
6913
7063
  const principalId = principalIdFromRow(row);
7064
+ const signedPayload = {
7065
+ v: 2,
7066
+ table,
7067
+ id: row.id,
7068
+ data: row,
7069
+ updatedAt,
7070
+ originatorInstanceId,
7071
+ };
7072
+ if (principalId)
7073
+ signedPayload.principalId = principalId;
7074
+ const signature = signBody(signedPayload, secretKey);
7075
+ const sr = {
7076
+ v: 2,
7077
+ table,
7078
+ id: row.id,
7079
+ data: row,
7080
+ updatedAt,
7081
+ originatorInstanceId,
7082
+ signature,
7083
+ };
6914
7084
  if (principalId)
6915
7085
  sr.principalId = principalId;
6916
7086
  const srBytes = JSON.stringify(sr).length;
@@ -7004,29 +7174,16 @@ export async function runFederationSyncOnce(opts) {
7004
7174
  return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
7005
7175
  }
7006
7176
  }
7007
- const federationSync = federation
7177
+ const federationSync = addSharedCredentialOptions(federation
7008
7178
  .command("sync")
7009
7179
  .description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
7010
7180
  .option("--port <port>", "Harper HTTP port")
7011
- .option("--admin-pass <pass>", "Admin password")
7012
- .option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
7013
- .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
7014
7181
  .option("--ops-port <port>", "Harper operations API port")
7015
7182
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
7016
- .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
7017
- .action(async (opts) => {
7183
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")).action(async (opts) => {
7018
7184
  // --admin-pass-file resolves into the same `adminPass` slot the inline
7019
7185
  // flag uses, so the scheduler never has to embed a secret in a unit file.
7020
- // readAdminPassFileSecure() refuses a file that is not owner-only.
7021
- if (!opts.adminPass && opts.adminPassFile) {
7022
- try {
7023
- opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
7024
- }
7025
- catch (err) {
7026
- console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
7027
- process.exit(1);
7028
- }
7029
- }
7186
+ applyAdminPassFile(opts);
7030
7187
  const r = await runFederationSyncOnce(opts);
7031
7188
  if (r.error) {
7032
7189
  console.error(`Error: ${r.error.message}`);
@@ -10154,6 +10311,7 @@ program
10154
10311
  .option("--project <name>", "Fabric component name for --target", "flair")
10155
10312
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
10156
10313
  .option("--yes", "Skip the confirmation prompt for --target")
10314
+ .option("--install-hooks", "Consent to installing missing SessionStart hooks (claude-code / Codex) during upgrade. The hook executes at every session start — upgrade will not write it unprompted. Interactive runs prompt; non-interactive runs state the gap and withhold ✅ unless this flag is passed.")
10157
10315
  .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
10158
10316
  // ── flair#878 ─────────────────────────────────────────────────────────────
10159
10317
  // These existed on `flair deploy` but stopped at the upgrade boundary, so
@@ -10819,20 +10977,16 @@ program
10819
10977
  });
10820
10978
  const verdict = decideAfterVerify(verify, previousFlairVersion);
10821
10979
  if (verdict.kind === "ok") {
10822
- // flair#1022: the verified facts are unchanged and still stated — the
10823
- // upgrade did land. What changes is the MARKER and the claim around it.
10824
- // A run that ended up outside its process manager has not fully
10825
- // succeeded, so it does not get a ✅, and the line names the property
10826
- // that is wrong rather than only the ones that are right. The choice
10827
- // lives in renderVerifiedSummary so it is testable without performing an
10828
- // upgrade — no CI lane runs this darwin path.
10829
- const summary = renderVerifiedSummary(verify.version, management);
10830
- for (const line of summary.lines) {
10831
- if (summary.degraded)
10832
- console.error(line);
10833
- else
10834
- console.log(line);
10835
- }
10980
+ // flair#1439: the success marker is the doctor runner's verdict, not
10981
+ // a second, narrower notion of "verified". Launchd detach is one
10982
+ // catalog member; the Codex SessionStart hook is another. Adding a
10983
+ // doctor check widens this claim automatically.
10984
+ const run = await doctorRunAfterUpgrade({
10985
+ management,
10986
+ port,
10987
+ installHooksFlag: !!opts.installHooks,
10988
+ });
10989
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run));
10836
10990
  return;
10837
10991
  }
10838
10992
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -10844,21 +10998,23 @@ program
10844
10998
  // "print an honest note but roll back anyway" branch that used to sit below
10845
10999
  // is gone — that credentials case can no longer reach the rollback path.)
10846
11000
  if (verdict.kind === "healthy-unverified") {
10847
- // flair#1022: same rule as the "ok" branch above the is withheld
10848
- // when the run left the instance outside launchd, and the reason is
10849
- // named. This branch already qualifies the version claim; the process
10850
- // manager is a second, independent qualification.
10851
- console.log(detached
10852
- ? `⚠️ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}, but NOT under launchd.`
10853
- : `✅ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}.`);
11001
+ // Same doctor runner as the "ok" branch — an unverified version must
11002
+ // not restore the unqualified while a catalog member is failing.
11003
+ const run = await doctorRunAfterUpgrade({
11004
+ management,
11005
+ port,
11006
+ installHooksFlag: !!opts.installHooks,
11007
+ });
11008
+ const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
11009
+ if (run.healthy) {
11010
+ console.log(`✅ upgrade complete: the instance is up and healthy${versionNote}.`);
11011
+ }
11012
+ else {
11013
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run, { authenticated: false }));
11014
+ }
10854
11015
  console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
10855
11016
  console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
10856
11017
  console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
10857
- if (detached) {
10858
- for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
10859
- console.error(line);
10860
- }
10861
- }
10862
11018
  return;
10863
11019
  }
10864
11020
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -11177,6 +11333,83 @@ function resolveInstanceServingPid(dataDir, port) {
11177
11333
  listeningPids,
11178
11334
  });
11179
11335
  }
11336
+ /** Agent signing-key ids under `keysDir` — node-scoped federation keys excluded. */
11337
+ function collectKeyAgentIds(keysDir) {
11338
+ if (!existsSync(keysDir))
11339
+ return [];
11340
+ try {
11341
+ const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
11342
+ const { agentKeyIds } = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
11343
+ return agentKeyIds;
11344
+ }
11345
+ catch {
11346
+ return [];
11347
+ }
11348
+ }
11349
+ async function confirmYes(question) {
11350
+ if (!process.stdin.isTTY)
11351
+ return false;
11352
+ const { createInterface } = await import("node:readline");
11353
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11354
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
11355
+ return /^y(es)?$/i.test(answer.trim());
11356
+ }
11357
+ /**
11358
+ * flair#1439 — run the enumerable doctor catalog after upgrade's instance
11359
+ * probe, and offer a consented SessionStart-hook install when that check
11360
+ * fails. Silent writes are refused: `--install-hooks` or an interactive
11361
+ * yes is the only consent. The consent→write composition lives in
11362
+ * applyUpgradeHookConsent so tests can drive the path that actually
11363
+ * writes (or does not write) the hook file.
11364
+ */
11365
+ async function doctorRunAfterUpgrade(args) {
11366
+ const homeDir = homedir();
11367
+ const keysDir = defaultKeysDir();
11368
+ const ctx = {
11369
+ homeDir,
11370
+ cwd: process.cwd(),
11371
+ detectedClientIds: detectClients().filter((c) => c.detected).map((c) => c.id),
11372
+ launchd: args.management,
11373
+ keysDir,
11374
+ keyAgentIds: collectKeyAgentIds(keysDir),
11375
+ };
11376
+ const run = runDoctorChecks(ctx);
11377
+ const apply = (promptAccepted) => applyUpgradeHookConsent({
11378
+ homeDir,
11379
+ ctx,
11380
+ run,
11381
+ installHooksFlag: args.installHooksFlag,
11382
+ interactive: !!process.stdin.isTTY,
11383
+ promptAccepted,
11384
+ port: args.port,
11385
+ });
11386
+ let outcome = apply();
11387
+ if (outcome.consent === "prompt" && outcome.prompt) {
11388
+ console.log("");
11389
+ for (const line of outcome.prompt.preamble)
11390
+ console.log(` ${line}`);
11391
+ outcome = apply(await confirmYes(outcome.prompt.question));
11392
+ }
11393
+ if (outcome.consent === "install") {
11394
+ for (const w of outcome.writes) {
11395
+ console.log(` ${w.ok ? "✓" : "•"} ${w.message}`);
11396
+ }
11397
+ }
11398
+ if (outcome.consent === "skip-noninteractive") {
11399
+ for (const line of outcome.messages) {
11400
+ console.error(` ${line}`);
11401
+ }
11402
+ }
11403
+ return outcome.run;
11404
+ }
11405
+ function printVerifiedSummary(summary) {
11406
+ for (const line of summary.lines) {
11407
+ if (summary.degraded)
11408
+ console.error(line);
11409
+ else
11410
+ console.log(line);
11411
+ }
11412
+ }
11180
11413
  /**
11181
11414
  * Observe whether `dataDir`'s instance is running under launchd right now.
11182
11415
  *
@@ -12918,8 +13151,8 @@ program
12918
13151
  // Antigravity) the MCP block present + reachable + the configured agent
12919
13152
  // genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
12920
13153
  // pi-flair reference in pi's own settings, including the flair#1346
12921
- // npm:-under-"extensions" trap; plus CLAUDE.md + the SessionStart hook
12922
- // (Claude Code only, since only Claude Code has those mechanisms). Reuses
13154
+ // npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
13155
+ // SessionStart hook (Claude Code + Codex flair#1148). Reuses
12923
13156
  // detectClients() rather than reimplementing client detection.
12924
13157
  console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
12925
13158
  // Prompt y/N before a content-editing fix, but only when interactive —
@@ -12935,11 +13168,27 @@ program
12935
13168
  return /^y(es)?$/i.test(answer.trim());
12936
13169
  }
12937
13170
  const detectedClients = detectClients().filter((c) => c.detected);
13171
+ // flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
13172
+ // hook, verified-read plan, keys classification, launchd) is the same
13173
+ // catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
13174
+ // both. Extra doctor UX (pi, --fix, execution probe, continuity,
13175
+ // agent registration) stays below and does not redefine those checks.
13176
+ const doctorCtx = {
13177
+ homeDir: homedir(),
13178
+ cwd: process.cwd(),
13179
+ detectedClientIds: detectedClients.map((c) => c.id),
13180
+ launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
13181
+ keysDir,
13182
+ keyAgentIds,
13183
+ agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
13184
+ };
13185
+ const catalogBefore = runDoctorChecks(doctorCtx);
12938
13186
  if (detectedClients.length === 0) {
12939
13187
  console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
12940
13188
  }
12941
13189
  else {
12942
13190
  let claudeCodeAgentId;
13191
+ let codexAgentId;
12943
13192
  let anyKnownAgentId;
12944
13193
  // `doctor --fix` writes client configs through the same wire functions
12945
13194
  // init does, so it owes the user the same warning when the spec it would
@@ -13119,6 +13368,8 @@ program
13119
13368
  const block = readClientMcpBlock(client.id, homedir());
13120
13369
  if (client.id === "claude-code" && block.agentId)
13121
13370
  claudeCodeAgentId = block.agentId;
13371
+ if (client.id === "codex" && block.agentId)
13372
+ codexAgentId = block.agentId;
13122
13373
  if (block.agentId)
13123
13374
  anyKnownAgentId = anyKnownAgentId ?? block.agentId;
13124
13375
  if (!block.present) {
@@ -13165,8 +13416,13 @@ program
13165
13416
  client.id === "antigravity" ? wireAntigravity(wireEnv) :
13166
13417
  wireCursor(wireEnv);
13167
13418
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
13168
- if (wireResult.ok)
13169
- fixed++;
13419
+ if (wireResult.ok) {
13420
+ if (client.id === "claude-code")
13421
+ claudeCodeAgentId = fixAgentId;
13422
+ if (client.id === "codex")
13423
+ codexAgentId = fixAgentId;
13424
+ anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
13425
+ }
13170
13426
  }
13171
13427
  }
13172
13428
  }
@@ -13180,7 +13436,6 @@ program
13180
13436
  const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
13181
13437
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
13182
13438
  }
13183
- issues++;
13184
13439
  continue;
13185
13440
  }
13186
13441
  console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
@@ -13216,8 +13471,9 @@ program
13216
13471
  console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
13217
13472
  }
13218
13473
  }
13219
- // Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
13220
- // has these mechanisms, so only run them when claude-code was detected.
13474
+ // Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
13475
+ // Codex has a SessionStart hook too (checked below); CLAUDE.md and
13476
+ // continuity stay Claude Code only.
13221
13477
  if (detectedClients.some((c) => c.id === "claude-code")) {
13222
13478
  const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
13223
13479
  if (claudeMd.present) {
@@ -13237,15 +13493,12 @@ program
13237
13493
  else {
13238
13494
  const fixRes = fixClaudeMdBootstrap(process.cwd());
13239
13495
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13240
- if (fixRes.ok)
13241
- fixed++;
13242
13496
  }
13243
13497
  }
13244
13498
  }
13245
13499
  else {
13246
13500
  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)")}`);
13247
13501
  }
13248
- issues++;
13249
13502
  }
13250
13503
  // flair#1007: presence was never the problem — the failing entry was
13251
13504
  // perfectly well-formed. inspectSessionStartHook() additionally RUNS
@@ -13314,8 +13567,6 @@ program
13314
13567
  else {
13315
13568
  const upgrade = upgradeSessionStartHookCommand(homedir());
13316
13569
  console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13317
- if (upgrade.ok && upgrade.changed)
13318
- fixed++;
13319
13570
  }
13320
13571
  }
13321
13572
  }
@@ -13326,7 +13577,6 @@ program
13326
13577
  else {
13327
13578
  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`);
13328
13579
  }
13329
- issues++;
13330
13580
  }
13331
13581
  }
13332
13582
  else {
@@ -13344,15 +13594,12 @@ program
13344
13594
  const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
13345
13595
  const fixRes = fixSessionStartHook(homedir(), fixAgentId);
13346
13596
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13347
- if (fixRes.ok)
13348
- fixed++;
13349
13597
  }
13350
13598
  }
13351
13599
  }
13352
13600
  else {
13353
13601
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
13354
13602
  }
13355
- issues++;
13356
13603
  }
13357
13604
  // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
13358
13605
  // the SessionStart check above: installed / absent / stale-form).
@@ -13407,6 +13654,98 @@ program
13407
13654
  issues++;
13408
13655
  }
13409
13656
  }
13657
+ // Codex SessionStart hook (flair#1148) — same flair-session-start
13658
+ // command Claude Code uses, written to ~/.codex/hooks.json. Continuity
13659
+ // and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
13660
+ // is the hook file.
13661
+ if (detectedClients.some((c) => c.id === "codex")) {
13662
+ const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
13663
+ if (hook.present) {
13664
+ if (hook.execution === "broken") {
13665
+ if (hook.silenced) {
13666
+ console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
13667
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
13668
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
13669
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
13670
+ console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
13671
+ }
13672
+ else {
13673
+ 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`);
13674
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
13675
+ 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)")}`);
13676
+ }
13677
+ }
13678
+ else if (hook.execution === "unknown") {
13679
+ 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"})`)}`);
13680
+ }
13681
+ else if (!hook.ours) {
13682
+ 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)")}`);
13683
+ }
13684
+ else {
13685
+ 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")}`);
13686
+ }
13687
+ if (!hook.silenced && hook.ours) {
13688
+ console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
13689
+ if (hook.upgradable) {
13690
+ if (autoFix) {
13691
+ if (dryRun) {
13692
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
13693
+ }
13694
+ else {
13695
+ const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
13696
+ if (!proceed) {
13697
+ console.log(` Skipped.`);
13698
+ }
13699
+ else {
13700
+ const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
13701
+ console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13702
+ }
13703
+ }
13704
+ }
13705
+ else {
13706
+ 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)")}`);
13707
+ }
13708
+ }
13709
+ else {
13710
+ 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`);
13711
+ }
13712
+ }
13713
+ }
13714
+ else {
13715
+ console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
13716
+ if (autoFix) {
13717
+ if (dryRun) {
13718
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
13719
+ }
13720
+ else {
13721
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
13722
+ if (!proceed) {
13723
+ console.log(` Skipped.`);
13724
+ }
13725
+ else {
13726
+ const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
13727
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
13728
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13729
+ }
13730
+ }
13731
+ }
13732
+ else {
13733
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
13734
+ }
13735
+ }
13736
+ }
13737
+ }
13738
+ // Catalog is the install-health verdict — count fail/unrun here, not
13739
+ // via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
13740
+ // --fix that cleared a catalog member shows up in the found→fixed delta.
13741
+ const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
13742
+ const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
13743
+ issues += catalogDelta.found;
13744
+ if (autoFix)
13745
+ fixed += catalogDelta.fixed;
13746
+ console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
13747
+ for (const row of renderCatalogDoctorLines(catalogAfter)) {
13748
+ console.log(` ${render.icons[row.icon]} ${row.line}`);
13410
13749
  }
13411
13750
  // 7a. Resolve which agent identities the two verified-read sections below
13412
13751
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
@@ -13806,7 +14145,8 @@ program
13806
14145
  // from /HealthDetail at all — it's the one metric in this file that requires
13807
14146
  // live QUERIES, because it's checking whether querying itself still works.
13808
14147
  // For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
13809
- // below, GET /Memory?agentId=<self>), a CUE is derived from each memory
14148
+ // below, a projected+bounded GET /Memory flair#1360: never the unfiltered
14149
+ // collection with embeddings inline), a CUE is derived from each memory
13810
14150
  // (deriveRecallCue — its `subject` if present, else the leading ~8 words /
13811
14151
  // first sentence of `content`; a PARTIAL cue, never the full content) and
13812
14152
  // searched for through the EXACT SAME authenticated read path `flair memory
@@ -13869,6 +14209,53 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
13869
14209
  * "first-pass default, tunable later" spirit as the thresholds above. */
13870
14210
  export const QUALITY_RECALL_SAMPLE_SIZE = 10;
13871
14211
  export const QUALITY_RECALL_K = 5;
14212
+ /**
14213
+ * Fields the recall spot-check and the quality-snapshot lookup actually
14214
+ * read. Harper REST `select(...)` (same syntax adk-flair-js's listMemories
14215
+ * already uses) projects these server-side so the nightly sweep never
14216
+ * pulls embedding vectors inline — the defect in flair#1360 was an
14217
+ * unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
14218
+ * vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
14219
+ * 10 memories. `type` is intentionally omitted: it is not a declared
14220
+ * Memory column (see schemas/memory.graphql); snapshot exclusion keys
14221
+ * off `subject` (`quality-snapshot/…`).
14222
+ */
14223
+ export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
14224
+ /**
14225
+ * Extra most-recent rows fetched beyond `sampleSize` so
14226
+ * `planRecallSpotCheck` can drop the sweep's own quality-snapshot
14227
+ * bookkeeping and still fill a 10-row window — without scanning the
14228
+ * table. Nightly `--emit` writes one snapshot per run; 16 is a buffer
14229
+ * for a few extra `--emit`s in the same recency window, not a second
14230
+ * full-table read.
14231
+ */
14232
+ export const QUALITY_RECALL_SNAPSHOT_OVERFETCH = 16;
14233
+ /**
14234
+ * Harper REST collection path for the recall spot-check's sample fetch:
14235
+ * agent-scoped, projected (never `embedding`), recency-sorted, bounded.
14236
+ * `limit(start,end)` is Harper's offset window — same as
14237
+ * packages/adk-flair-js/src/memory_service.ts.
14238
+ */
14239
+ export function qualityRecallSamplePath(agentId, sampleSize = QUALITY_RECALL_SAMPLE_SIZE) {
14240
+ const select = QUALITY_MEMORY_LIST_SELECT.join(",");
14241
+ const end = sampleSize + QUALITY_RECALL_SNAPSHOT_OVERFETCH;
14242
+ return `/Memory?agentId=${encodeURIComponent(agentId)}&select(${select})&sort(-createdAt)&limit(0,${end})`;
14243
+ }
14244
+ /**
14245
+ * Harper REST collection path for the previous quality-snapshot lookup:
14246
+ * same projection as the sample fetch (never `embedding`). Subject is
14247
+ * passed as a query equals (indexed) plus a client-side re-filter —
14248
+ * Memory.search() historically did not turn bare query params into
14249
+ * conditions beyond the signed agent scope, so the client-side filter
14250
+ * in fetchPreviousQualitySnapshot stays as defense in depth. No `limit`:
14251
+ * a bounded window could miss yesterday's snapshot after a busy day of
14252
+ * writes, and without a reliable server-side subject pushdown that
14253
+ * would silently look like a first run.
14254
+ */
14255
+ export function qualitySnapshotLookupPath(agentId, subject) {
14256
+ const select = QUALITY_MEMORY_LIST_SELECT.join(",");
14257
+ return `/Memory?agentId=${encodeURIComponent(agentId)}&subject=${encodeURIComponent(subject)}&select(${select})&sort(-createdAt)`;
14258
+ }
13872
14259
  /** Leading-word cap on the content-derived cue. 25, matching the arm of the
13873
14260
  * flair#967 A/B that was actually measured (same 10 memories, same instance,
13874
14261
  * same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
@@ -14230,23 +14617,25 @@ export function computeQualityReport(healthy, healthData, opts = {}) {
14230
14617
  * `agentId`'s own memories and, for each, search for a cue derived from it.
14231
14618
  * Reuses the EXACT read path `flair memory search` / `flair memory list`
14232
14619
  * use — `api()` (→ authedRequest's 5-tier resolver) for both the
14233
- * `GET /Memory?agentId=...` sample fetch and the `POST /SemanticSearch`
14234
- * queriesso this has zero new endpoint and zero new auth mechanism; it
14235
- * is scoped to `agentId`'s own memories exactly as those commands already
14236
- * are. Never throws: every failure mode (no agentId, fewer than
14237
- * `sampleSize` memories, a fetch/search error) returns `{ ok: false,
14238
- * skipReason }` for computeQualityReport to turn into a `gaps` entry.
14620
+ * projected, bounded `GET /Memory?…&select(…)&limit(…)` sample fetch
14621
+ * (flair#1360never the unfiltered collection with embeddings inline)
14622
+ * and the `POST /SemanticSearch` queries so this has zero new endpoint
14623
+ * and zero new auth mechanism; it is scoped to `agentId`'s own memories
14624
+ * exactly as those commands already are. Never throws: every failure mode
14625
+ * (no agentId, fewer than `sampleSize` memories, a fetch/search error)
14626
+ * returns `{ ok: false, skipReason }` for computeQualityReport to turn
14627
+ * into a `gaps` entry.
14239
14628
  */
14240
- async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14629
+ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14241
14630
  const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
14242
14631
  const k = opts.k ?? QUALITY_RECALL_K;
14632
+ const request = opts.request ?? api;
14243
14633
  if (!agentId) {
14244
14634
  return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
14245
14635
  }
14246
14636
  let all;
14247
14637
  try {
14248
- const q = new URLSearchParams({ agentId }).toString();
14249
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
14638
+ const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
14250
14639
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
14251
14640
  }
14252
14641
  catch (err) {
@@ -14276,7 +14665,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14276
14665
  try {
14277
14666
  for (const { id, cue } of plan.sampled) {
14278
14667
  const body = { agentId, q: cue, limit: k };
14279
- const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
14668
+ const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
14280
14669
  const results = Array.isArray(res) ? res : (res?.results ?? []);
14281
14670
  sampledIds.push(id);
14282
14671
  perQueryResultIds.push(results.map((r) => String(r.id)));
@@ -14494,21 +14883,23 @@ export function qualitySnapshotSubject(baseUrl) {
14494
14883
  return `quality-snapshot/${host}`;
14495
14884
  }
14496
14885
  /** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
14497
- * via the exact same read path fetchRecallSpotCheckData uses (`api("GET",
14498
- * "/Memory?agentId=...")`, self-scoped by the signed request's own agent
14499
- * identity — no new endpoint). Filters client-side by subject (the server's
14500
- * `GET /Memory?...` doesn't translate query params into search conditions
14501
- * beyond the signed agentId scope see resources/Memory.ts's search()),
14502
- * same client-side-filter pattern `memory list --hash-fallback` already
14503
- * uses. Returns null on: no prior snapshot, a fetch error, or a snapshot row
14504
- * whose content isn't parseable/versioned JSON (never throws a corrupt or
14505
- * foreign row degrades to "no snapshot", same as a genuine first run, rather
14506
- * than crashing `--emit`). */
14507
- async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject) {
14886
+ * via the same signed `GET /Memory` read path fetchRecallSpotCheckData
14887
+ * uses (self-scoped by the signed request's own agent identity — no new
14888
+ * endpoint). Projects the same fields (never embeddings — flair#1360) and
14889
+ * asks for `subject` as a query equals; still filters client-side by
14890
+ * subject because Memory.search() historically did not turn bare query
14891
+ * params into search conditions beyond the signed agentId scope (see
14892
+ * resources/Memory.ts's search()), same client-side-filter pattern
14893
+ * `memory list --hash-fallback` already uses. Returns null on: no prior
14894
+ * snapshot, a fetch error, or a snapshot row whose content isn't
14895
+ * parseable/versioned JSON (never throws — a corrupt or foreign row
14896
+ * degrades to "no snapshot", same as a genuine first run, rather than
14897
+ * crashing `--emit`). */
14898
+ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, opts = {}) {
14899
+ const request = opts.request ?? api;
14508
14900
  let all;
14509
14901
  try {
14510
- const q = new URLSearchParams({ agentId }).toString();
14511
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
14902
+ const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
14512
14903
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
14513
14904
  }
14514
14905
  catch {
@@ -14976,9 +15367,8 @@ function parseEntitiesOptionOrExit(csv) {
14976
15367
  }
14977
15368
  const ENTITIES_OPTION_DESCRIPTION = "Comma-separated entity vocabulary strings this record touches (type:value from the closed type set, e.g. repo:tpsdev-ai/flair — see docs/entity-vocabulary.md; feeds `flair attention`)";
14978
15369
  const memory = program.command("memory").description("Manage agent memories");
14979
- memory.command("add [content]")
14980
- .description("Write a new memory row for an agent (content via positional arg or --content)")
14981
- .requiredOption("--agent <id>")
15370
+ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]")
15371
+ .description("Write a new memory row for an agent (content via positional arg or --content)")))
14982
15372
  .option("--content <text>", "memory content (alias for positional arg)")
14983
15373
  .option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
14984
15374
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
@@ -14992,10 +15382,15 @@ memory.command("add [content]")
14992
15382
  console.error("error: content required (positional arg or --content)");
14993
15383
  process.exit(1);
14994
15384
  }
14995
- const agentId = resolveSigningAgentId(opts, "memory add") ?? opts.agent;
14996
- const memId = `${opts.agent}-${Date.now()}`;
15385
+ applyAdminPassFile(opts);
15386
+ const agentId = resolveSigningAgentId(opts, "memory add");
15387
+ if (!agentId) {
15388
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15389
+ process.exit(2);
15390
+ }
15391
+ const memId = `${agentId}-${Date.now()}`;
14997
15392
  const body = {
14998
- id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
15393
+ id: memId, agentId, content, durability: opts.durability || "standard",
14999
15394
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
15000
15395
  type: "memory", createdAt: new Date().toISOString(),
15001
15396
  };
@@ -15028,7 +15423,11 @@ memory.command("add [content]")
15028
15423
  if (entities.length > 0)
15029
15424
  body.entities = entities;
15030
15425
  }
15031
- const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
15426
+ const out = await api("PUT", `/Memory/${memId}`, body, {
15427
+ agentId,
15428
+ explicitAdminPass: opts.adminPass,
15429
+ adminUser: opts.adminUser,
15430
+ });
15032
15431
  console.log(JSON.stringify(out, null, 2));
15033
15432
  });
15034
15433
  // ─── flair memory write-task-summary ────────────────────────────────────────
@@ -16274,7 +16673,14 @@ bridge
16274
16673
  process.exit(2);
16275
16674
  }
16276
16675
  try {
16277
- const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
16676
+ const result = await runRoundTrip({
16677
+ descriptor: loaded.descriptor,
16678
+ cwd,
16679
+ fixturePath: opts.fixture,
16680
+ // Keep the intermediate export so a failure can print a live path.
16681
+ // The next harness start sweeps leftovers older than a minute (flair#1032).
16682
+ retainTmpDir: true,
16683
+ });
16278
16684
  if (opts.json) {
16279
16685
  console.log(JSON.stringify(result, null, 2));
16280
16686
  process.exit(result.passed ? 0 : 1);
@@ -16481,31 +16887,19 @@ function printTrustError(detail) {
16481
16887
  }
16482
16888
  }
16483
16889
  // ─── flair backup ────────────────────────────────────────────────────────────
16484
- program
16890
+ addSharedCredentialOptions(program
16485
16891
  .command("backup")
16486
16892
  .description("Export agents, memories, and souls to a JSON archive")
16487
16893
  .option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
16488
16894
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
16489
16895
  .option("--port <port>", "Harper HTTP port")
16490
- .option("--url <url>", "Flair base URL (overrides --port)")
16491
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
16492
- .option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
16493
- .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16494
- .action(async (opts) => {
16896
+ .option("--url <url>", "Flair base URL (overrides --port)")).action(async (opts) => {
16495
16897
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
16496
- let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16497
- if (!adminPass && opts.adminPassFile) {
16498
- // readAdminPassFileSecure refuses world/group readable files (mode 0600
16499
- // recommended). Common gotcha: files generated via
16500
- // `openssl rand -base64 24 > admin-pass` end in a newline; helper trims it.
16501
- try {
16502
- adminPass = readAdminPassFileSecure(opts.adminPassFile);
16503
- }
16504
- catch (err) {
16505
- console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
16506
- process.exit(1);
16507
- }
16508
- }
16898
+ applyAdminPassFile(opts);
16899
+ // Env is a second-class fallback after the explicit flags (same order
16900
+ // backup used before the shared helper). FLAIR_ADMIN_PASS is still
16901
+ // accepted so existing scripts keep working.
16902
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16509
16903
  const adminUser = resolveAdminUser(opts.adminUser);
16510
16904
  if (!adminPass) {
16511
16905
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");