@tpsdev-ai/flair 0.48.0 → 0.50.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 (46) hide show
  1. package/README.md +2 -0
  2. package/dist/bridges/runtime/roundtrip.js +91 -2
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli.js +903 -226
  5. package/dist/component-env.js +52 -4
  6. package/dist/deploy.js +20 -3
  7. package/dist/doctor-client.js +105 -32
  8. package/dist/federation/scheduler.js +24 -3
  9. package/dist/hook-install.js +96 -16
  10. package/dist/install/clients.js +318 -9
  11. package/dist/lib/auth-resolve.js +34 -3
  12. package/dist/lib/mcp-enable.js +134 -26
  13. package/dist/lib/scheduler-platform.js +132 -10
  14. package/dist/lib/scratch-owner.js +49 -0
  15. package/dist/rem/scheduler.js +23 -5
  16. package/dist/resources/AgentSeed.js +2 -0
  17. package/dist/resources/Memory.js +24 -5
  18. package/dist/resources/MemoryBootstrap.js +8 -4
  19. package/dist/resources/MemoryFeed.js +3 -0
  20. package/dist/resources/MemoryMaintenance.js +11 -2
  21. package/dist/resources/bm25-index-service.js +257 -0
  22. package/dist/resources/bm25-index.js +631 -0
  23. package/dist/resources/bm25.js +31 -1
  24. package/dist/resources/embeddings-boot.js +45 -3
  25. package/dist/resources/health.js +52 -7
  26. package/dist/resources/mcp-tools.js +1 -0
  27. package/dist/resources/memory-read-scope.js +2 -0
  28. package/dist/resources/search-readiness.js +100 -0
  29. package/dist/resources/semantic-retrieval-core.js +102 -23
  30. package/dist/resources/sort-comparators.js +45 -0
  31. package/dist/src/lib/scheduler-platform.js +132 -10
  32. package/dist/src/rem/scheduler.js +23 -5
  33. package/dist/version-check.js +59 -13
  34. package/docs/auth.md +5 -0
  35. package/docs/claude-code.md +10 -3
  36. package/docs/deepseek-harness.md +1 -1
  37. package/docs/deployment.md +11 -1
  38. package/docs/hosted-on-fabric.md +2 -0
  39. package/docs/integrations.md +78 -5
  40. package/docs/mcp-clients.md +85 -15
  41. package/docs/notes/mcp-oauth-model2.md +31 -13
  42. package/docs/quickstart-fabric.md +1 -1
  43. package/docs/quickstart.md +9 -9
  44. package/docs/standalone-local.md +3 -0
  45. package/docs/troubleshooting.md +25 -0
  46. package/package.json +4 -3
package/dist/cli.js CHANGED
@@ -20,14 +20,14 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
20
20
  import { probeInstance } from "./probe.js";
21
21
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
22
22
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
23
- import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
23
+ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, wirePi, piFlairSpec, PI_FLAIR_PACKAGE, PI_FLAIR_DEFAULT_URL, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
24
24
  import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
25
25
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
26
26
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
27
- import { readClientMcpBlock, effectiveFlairUrl, 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";
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, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
30
- import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.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
+ 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";
@@ -141,7 +141,8 @@ function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagN
141
141
  const DEFAULT_PORT = 19926;
142
142
  const DEFAULT_OPS_PORT = 19925;
143
143
  const FABRIC_OPS_PORT = 9925;
144
- const DEFAULT_ADMIN_USER = "admin";
144
+ // DEFAULT_ADMIN_USER + resolveAdminUser (flag > FLAIR_ADMIN_USER env > "admin")
145
+ // live in src/lib/auth-resolve.ts — imported above (flair#1345).
145
146
  const STARTUP_TIMEOUT_MS = 60_000;
146
147
  const HEALTH_POLL_INTERVAL_MS = 500;
147
148
  // flair#670 — single-host default for the Harper ops API bind address.
@@ -684,6 +685,48 @@ function resolveSigningIdentityFor(opts, command) {
684
685
  function resolveSigningAgentId(opts, command) {
685
686
  return resolveSigningIdentityFor(opts, command).agentId;
686
687
  }
688
+ // ── Shared credential/identity flag surface (flair#1106) ─────────────────────
689
+ // Sibling commands (memory add, backup, federation sync) used to drift on
690
+ // the same concepts: `--admin-pass-file` existed on backup/sync but was an
691
+ // unknown option on `memory add`, and `memory add --agent` was a commander
692
+ // requiredOption so FLAIR_AGENT_ID could never satisfy it. One helper owns
693
+ // the credential flag names/shapes; identity (`--agent`) stays optional so
694
+ // the env fallback can actually apply. This does not invent a new auth
695
+ // model — it only declares the flags authedRequest already resolves.
696
+ /** Flag strings the sibling commands must share (name + argument shape). */
697
+ export const SHARED_CREDENTIAL_FLAGS = {
698
+ adminPass: "--admin-pass <pass>",
699
+ adminPassFile: "--admin-pass-file <path>",
700
+ adminUser: "--admin-user <name>",
701
+ };
702
+ export const SHARED_IDENTITY_FLAGS = {
703
+ agent: "--agent <id>",
704
+ };
705
+ function addSharedCredentialOptions(cmd) {
706
+ return cmd
707
+ .option(SHARED_CREDENTIAL_FLAGS.adminPass, "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
708
+ .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.")
709
+ .option(SHARED_CREDENTIAL_FLAGS.adminUser, "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)");
710
+ }
711
+ function addSharedIdentityOption(cmd) {
712
+ return cmd.option(SHARED_IDENTITY_FLAGS.agent, "Agent ID (or set FLAIR_AGENT_ID env)");
713
+ }
714
+ /**
715
+ * Resolve `--admin-pass-file` into the same `adminPass` slot the inline flag
716
+ * uses. Shared so sibling commands cannot drift on how the file is read
717
+ * (mode 0600 via readAdminPassFileSecure).
718
+ */
719
+ function applyAdminPassFile(opts) {
720
+ if (!opts.adminPass && opts.adminPassFile) {
721
+ try {
722
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
723
+ }
724
+ catch (err) {
725
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
726
+ process.exit(1);
727
+ }
728
+ }
729
+ }
687
730
  // Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
688
731
  //
689
732
  // Deliberately NOT routed through Harper's per-instance config the way
@@ -1428,7 +1471,13 @@ async function api(method, path, body, options) {
1428
1471
  agentId = decodeURIComponent(match[1]);
1429
1472
  }
1430
1473
  }
1431
- return authedRequest(method, path, body, { baseUrl: base, agentId, keysDir: options?.keysDir });
1474
+ return authedRequest(method, path, body, {
1475
+ baseUrl: base,
1476
+ agentId,
1477
+ keysDir: options?.keysDir,
1478
+ explicitAdminPass: options?.explicitAdminPass,
1479
+ adminUser: options?.adminUser,
1480
+ });
1432
1481
  }
1433
1482
  /**
1434
1483
  * The authedGet `flair upgrade` verification (flair#635/#741) hands to
@@ -1905,6 +1954,27 @@ function readHarperPid(dataDir) {
1905
1954
  return null;
1906
1955
  }
1907
1956
  }
1957
+ /**
1958
+ * flair#1345 — Harper returns the SAME 401 `{"error":"Login failed"}` for a
1959
+ * wrong password and for a nonexistent username, and the CLI's errors used
1960
+ * to hint only at the password. On an instance whose superuser is not named
1961
+ * `admin` (now reachable in practice: the #604/#610 `authorizeLocal: false`
1962
+ * hardening removed the credential-less loopback path, so these calls MUST
1963
+ * send real Basic auth) that sent operators down the wrong trail entirely.
1964
+ * Name both causes, each with the knob that fixes it.
1965
+ */
1966
+ function opsAuth401Hint(adminUser) {
1967
+ if (adminUser === undefined) {
1968
+ // No credentials were sent at all (local caller riding authorizeLocal) —
1969
+ // "wrong password or username" would be asserting a cause that isn't
1970
+ // established. The remedy is to send credentials.
1971
+ return ("\n No admin credentials were sent and the instance rejected the request." +
1972
+ "\n Pass --admin-pass <pass> or --admin-pass-file <path> (and --admin-user <name> if the superuser is not 'admin').");
1973
+ }
1974
+ return (`\n The operations API rejected the admin credentials (tried username '${adminUser}'). Two possible causes:` +
1975
+ "\n - wrong password — check --admin-pass / --admin-pass-file / FLAIR_ADMIN_PASS" +
1976
+ `\n - wrong username — this instance's superuser may not be '${adminUser}'; pass --admin-user <name> or set FLAIR_ADMIN_USER`);
1977
+ }
1908
1978
  /**
1909
1979
  * Seed an agent record via the Harper operations API.
1910
1980
  * Accepts either a port number (localhost) or a full URL string (--target).
@@ -1958,6 +2028,9 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
1958
2028
  const text = await res.text().catch(() => "");
1959
2029
  if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
1960
2030
  return;
2031
+ if (res.status === 401) {
2032
+ throw new Error(`Operations API insert failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
2033
+ }
1961
2034
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
1962
2035
  }
1963
2036
  }
@@ -2009,6 +2082,9 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
2009
2082
  const text = await res.text().catch(() => "");
2010
2083
  if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
2011
2084
  return;
2085
+ if (res.status === 401) {
2086
+ throw new Error(`Federation Instance insert via ops API failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
2087
+ }
2012
2088
  throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
2013
2089
  }
2014
2090
  }
@@ -2586,9 +2662,9 @@ export function upgradeStatusSuffix(name, status) {
2586
2662
  * is `flair doctor --fix`, never `npm install -g`.
2587
2663
  * 3. Wired with a concrete pin — that pin IS the installed version
2588
2664
  * (current when it equals latest, else outdated → re-pin via doctor).
2589
- * 4. Wired but unpinned (a bare npx spec / the SessionStart hook) — `npx -y`
2590
- * re-resolves latest every session, so the effective version IS latest →
2591
- * current.
2665
+ * 4. Wired but unpinned (a bare npx spec / a pre-#1143 SessionStart hook) —
2666
+ * `npx -y` re-resolves latest every session, so the effective version IS
2667
+ * latest → current.
2592
2668
  */
2593
2669
  export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
2594
2670
  // 1. Legacy global install.
@@ -2917,11 +2993,12 @@ program
2917
2993
  .option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
2918
2994
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
2919
2995
  .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
2996
+ .option("--admin-user <name>", "Admin username when authenticating to an already-running instance via --target/--ops-target (env: FLAIR_ADMIN_USER; default: admin — local bootstrap and Fabric provisioning always create 'admin')")
2920
2997
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
2921
2998
  .option("--data-dir <dir>", "Harper data directory")
2922
2999
  .option("--skip-start", "Skip Harper startup (assume already running)")
2923
3000
  .option("--skip-soul", "Skip interactive personality setup")
2924
- .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, antigravity, all, or none")
3001
+ .option("--client <client>", "Client(s) to wire: claude-code, codex, gemini, cursor, antigravity, pi (native extension), all, or none")
2925
3002
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
2926
3003
  .option("--skip-smoke", "Skip the MCP smoke test")
2927
3004
  .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
@@ -3005,7 +3082,10 @@ program
3005
3082
  }
3006
3083
  flairAdminPass = opts.adminPass;
3007
3084
  }
3008
- const adminUser = DEFAULT_ADMIN_USER;
3085
+ // flair#1345: only the already-running-instance leg honors --admin-user /
3086
+ // FLAIR_ADMIN_USER — the provisioning leg just CREATED the superuser as
3087
+ // DEFAULT_ADMIN_USER via provisionFabric, so that name is ground truth.
3088
+ const adminUser = didProvision ? DEFAULT_ADMIN_USER : resolveAdminUser(opts.adminUser);
3009
3089
  const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
3010
3090
  const role = opts.remote ? "hub" : undefined;
3011
3091
  // Generate or reuse keypair (only if --agent-id provided, or --remote needs
@@ -3140,9 +3220,9 @@ program
3140
3220
  const noMcp = opts.mcp === false;
3141
3221
  const selectedClients = [];
3142
3222
  if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
3143
- const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
3223
+ const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity", "pi"];
3144
3224
  if (!valid.includes(clientOpt)) {
3145
- console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, all, none`);
3225
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, pi, all, none`);
3146
3226
  process.exit(1);
3147
3227
  }
3148
3228
  selectedClients.push(clientOpt);
@@ -3773,6 +3853,13 @@ program
3773
3853
  case "antigravity":
3774
3854
  result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
3775
3855
  break;
3856
+ // pi is a NATIVE EXTENSION, not an MCP client (flair#1342):
3857
+ // wirePi edits ~/.pi/agent/settings.json `packages`, and pi
3858
+ // settings carry no env block — no FLAIR_CLIENT to stamp; the
3859
+ // wire message tells the user what to export at pi launch.
3860
+ case "pi":
3861
+ result = wirePi(mcpEnv);
3862
+ break;
3776
3863
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
3777
3864
  }
3778
3865
  wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
@@ -3784,7 +3871,12 @@ program
3784
3871
  // Launch flair-mcp and confirm it answers a JSON-RPC initialize over
3785
3872
  // stdio. Best-effort: failures warn but never fail the command. Skipped
3786
3873
  // with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
3787
- if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
3874
+ // pi doesn't run flair-mcp (native extension, flair#1342), so a pi-only
3875
+ // wiring has nothing this smoke test exercises — spawning it anyway
3876
+ // would render a green "MCP server responded" for a setup that never
3877
+ // starts an MCP server.
3878
+ const wiredAnyMcpClient = wiringResults.some((r) => r.client !== "pi");
3879
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0 && wiredAnyMcpClient) {
3788
3880
  console.log("\n Smoke-testing MCP server...");
3789
3881
  try {
3790
3882
  // Same spec that gets WIRED above — the smoke test must exercise the
@@ -3934,6 +4026,7 @@ agent
3934
4026
  .option("--port <port>", "Harper HTTP port")
3935
4027
  .option("--admin-pass <pass>", "Admin password for registration")
3936
4028
  .option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
4029
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
3937
4030
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
3938
4031
  .option("--ops-port <port>", "Harper operations API port")
3939
4032
  .option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
@@ -3942,7 +4035,7 @@ agent
3942
4035
  const httpPort = resolveHttpPort(opts);
3943
4036
  const opsPort = resolveOpsPort(opts);
3944
4037
  const keysDir = opts.keysDir ?? defaultKeysDir();
3945
- const adminUser = DEFAULT_ADMIN_USER;
4038
+ const adminUser = resolveAdminUser(opts.adminUser);
3946
4039
  const name = opts.name ?? id;
3947
4040
  // Where to seed the Agent record. Default is localhost (opsPort). When
3948
4041
  // --ops-target or --target is given, seed on the remote instead of localhost
@@ -4026,6 +4119,7 @@ agent
4026
4119
  .command("list")
4027
4120
  .description("List all agents")
4028
4121
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
4122
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
4029
4123
  .option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
4030
4124
  .option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
4031
4125
  .option("--port <port>", "Harper HTTP port")
@@ -4043,7 +4137,7 @@ agent
4043
4137
  let agents;
4044
4138
  if (adminPass) {
4045
4139
  const opsPort = resolveOpsPort(opts);
4046
- const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
4140
+ const auth = Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64");
4047
4141
  // List every Agent without null-scanning the primary key. A
4048
4142
  // `starts_with ""` on `id` makes Harper search the index for nulls, which
4049
4143
  // the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
@@ -4163,6 +4257,7 @@ agent
4163
4257
  .option("--port <port>", "Harper HTTP port")
4164
4258
  .option("--ops-port <port>", "Harper operations API port")
4165
4259
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
4260
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
4166
4261
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
4167
4262
  .action(async (id, opts) => {
4168
4263
  const httpPort = resolveHttpPort(opts);
@@ -4174,7 +4269,7 @@ agent
4174
4269
  "to keep secrets out of shell history.");
4175
4270
  }
4176
4271
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
4177
- const adminUser = DEFAULT_ADMIN_USER;
4272
+ const adminUser = resolveAdminUser(opts.adminUser);
4178
4273
  const keysDir = opts.keysDir ?? defaultKeysDir();
4179
4274
  if (!adminPass) {
4180
4275
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for key rotation");
@@ -4247,12 +4342,13 @@ agent
4247
4342
  .option("--port <port>", "Harper HTTP port")
4248
4343
  .option("--ops-port <port>", "Harper operations API port")
4249
4344
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
4345
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
4250
4346
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
4251
4347
  .option("--force", "Skip interactive confirmation (required when stdin is not a TTY)")
4252
4348
  .action(async (id, opts) => {
4253
4349
  const opsPort = resolveOpsPort(opts);
4254
4350
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
4255
- const adminUser = DEFAULT_ADMIN_USER;
4351
+ const adminUser = resolveAdminUser(opts.adminUser);
4256
4352
  const keysDir = opts.keysDir ?? defaultKeysDir();
4257
4353
  if (!adminPass) {
4258
4354
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for agent remove");
@@ -4525,18 +4621,12 @@ keys
4525
4621
  // dry-run delta, symmetric removal) lives in src/hook-install.ts — this
4526
4622
  // section is pure CLI plumbing: option parsing, default resolution, and
4527
4623
  // rendering the pure functions' results.
4528
- function resolveHookAgentId(opts, homeDir) {
4529
- return (opts.agent ||
4530
- opts.agentId ||
4531
- process.env.FLAIR_AGENT_ID ||
4532
- readClientMcpBlock("claude-code", homeDir).agentId ||
4533
- undefined);
4534
- }
4535
- function resolveHookFlairUrl(opts, homeDir) {
4624
+ function resolveHookFlairUrl(opts, homeDir, harness) {
4536
4625
  return (opts.url ||
4537
4626
  process.env.FLAIR_TARGET ||
4538
4627
  process.env.FLAIR_URL ||
4539
- readClientMcpBlock("claude-code", homeDir).flairUrl ||
4628
+ readClientMcpBlock(harness, homeDir).flairUrl ||
4629
+ (harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).flairUrl : undefined) ||
4540
4630
  resolveBaseUrl({}));
4541
4631
  }
4542
4632
  function requireSupportedHarness(raw) {
@@ -4553,19 +4643,19 @@ hook
4553
4643
  .description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
4554
4644
  .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
4555
4645
  .option("--dry-run", "Print the exact JSON delta without writing")
4556
- .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
4646
+ .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for this harness's MCP client)")
4557
4647
  .option("--agent-id <id>", "Alias for --agent")
4558
- .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
4648
+ .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else this harness's MCP wiring, else the local default)")
4559
4649
  .option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
4560
4650
  .action((opts) => {
4561
4651
  const harness = requireSupportedHarness(opts.harness);
4562
4652
  const home = homedir();
4563
- const agentId = resolveHookAgentId(opts, home);
4653
+ const agentId = resolveHookAgentId(opts, home, harness);
4564
4654
  if (!agentId) {
4565
4655
  console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
4566
4656
  process.exit(1);
4567
4657
  }
4568
- const flairUrl = resolveHookFlairUrl(opts, home);
4658
+ const flairUrl = resolveHookFlairUrl(opts, home, harness);
4569
4659
  const dryRun = !!opts.dryRun;
4570
4660
  if (opts.continuity) {
4571
4661
  const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
@@ -4582,6 +4672,11 @@ hook
4582
4672
  const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
4583
4673
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4584
4674
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4675
+ const pinWarning = unpinnedSpecWarning();
4676
+ if (pinWarning && result.ok) {
4677
+ for (const line of pinWarning.split("\n"))
4678
+ console.error(` ⚠ ${line}`);
4679
+ }
4585
4680
  if (result.backupPath) {
4586
4681
  console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4587
4682
  }
@@ -4641,16 +4736,20 @@ hook
4641
4736
  // status in every branch below. "absent" is NOT a failure: installing the
4642
4737
  // pair is the opt-in, so absence renders as "not enabled".
4643
4738
  const renderContinuity = () => {
4739
+ // Continuity is Claude Code only. Do not tip `--continuity --harness
4740
+ // <other>` — that writes Claude tool matchers into the wrong file.
4741
+ if (!harnessSupportsContinuity(harness))
4742
+ return;
4644
4743
  const cont = continuityHookStatus(home, harness);
4645
4744
  if (cont.state === "installed") {
4646
4745
  console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
4647
4746
  }
4648
4747
  else if (cont.state === "absent") {
4649
- console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
4748
+ console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, `(opt-in: ${hookInstallHint(harness, "--continuity")})`)}`);
4650
4749
  }
4651
4750
  else {
4652
4751
  const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
4653
- console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
4752
+ console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, `— re-run: ${hookInstallHint(harness, "--continuity")}`)}`);
4654
4753
  }
4655
4754
  };
4656
4755
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
@@ -4663,21 +4762,28 @@ hook
4663
4762
  }
4664
4763
  if (!status.wired) {
4665
4764
  console.log(` ${render.icons.error} not wired`);
4666
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
4765
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${hookInstallHint(status.harness)}`);
4667
4766
  renderContinuity();
4668
4767
  console.log("");
4669
4768
  process.exit(1);
4670
4769
  }
4671
4770
  console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
4672
- console.log(` ${render.wrap(render.c.dim, "Agent:")} ${status.agentId ?? render.wrap(render.c.dim, "(unknown could not parse command)")}`);
4673
- console.log(` ${render.wrap(render.c.dim, "Flair URL:")} ${status.flairUrl ?? render.wrap(render.c.dim, "(unknown could not parse command)")}`);
4771
+ // flair#1325 skip the URL line only when agentId was recovered
4772
+ // (the installer form that omits FLAIR_URL). A wired correct-shape
4773
+ // command with no env assignments still prints unknown, not a
4774
+ // silent all-clear.
4775
+ for (const line of hookStatusIdentityLines(status)) {
4776
+ const label = line.label === "Agent" ? "Agent: " : "Flair URL:";
4777
+ const value = line.value === HOOK_STATUS_UNPARSED ? render.wrap(render.c.dim, line.value) : line.value;
4778
+ console.log(` ${render.wrap(render.c.dim, label)} ${value}`);
4779
+ }
4674
4780
  // flair#1007 — whether a command that stopped resolving would fail quietly
4675
4781
  // or print an error on every session start.
4676
4782
  if (status.silenced) {
4677
4783
  console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
4678
4784
  }
4679
4785
  else {
4680
- 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`);
4786
+ 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`);
4681
4787
  }
4682
4788
  renderContinuity();
4683
4789
  console.log("");
@@ -5026,6 +5132,7 @@ mcp
5026
5132
  .option("--keys-dir <dir>", "Directory to write the new key pair into (else FLAIR_KEY_DIR, ~/.flair/keys)")
5027
5133
  .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
5028
5134
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
5135
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5029
5136
  .option("--port <port>", "Harper HTTP port")
5030
5137
  .option("--ops-port <port>", "Harper operations API port")
5031
5138
  .option("--json", "Print machine-readable JSON instead of a human summary")
@@ -5061,7 +5168,7 @@ mcp
5061
5168
  manifestPath,
5062
5169
  issuer,
5063
5170
  opsPortOrUrl: opsPort,
5064
- adminUser: DEFAULT_ADMIN_USER,
5171
+ adminUser: resolveAdminUser(opts.adminUser),
5065
5172
  adminPass,
5066
5173
  });
5067
5174
  if (opts.json) {
@@ -5086,6 +5193,7 @@ mcp
5086
5193
  .description("Server-side revoke a granted machine client (deletes its backing Agent record), then clean up locally.")
5087
5194
  .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
5088
5195
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
5196
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5089
5197
  .option("--issuer <url>", "Public origin of the /mcp OAuth surface — used only for the enable-gate probe (defaults to FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
5090
5198
  .option("--ops-port <port>", "Harper operations API port")
5091
5199
  .option("--port <port>", "Harper HTTP port")
@@ -5114,7 +5222,7 @@ mcp
5114
5222
  name,
5115
5223
  manifestPath,
5116
5224
  opsPortOrUrl: opsPort,
5117
- adminUser: DEFAULT_ADMIN_USER,
5225
+ adminUser: resolveAdminUser(opts.adminUser),
5118
5226
  adminPass,
5119
5227
  keepKeys: !!opts.keepKeys,
5120
5228
  });
@@ -5205,6 +5313,7 @@ mcp
5205
5313
  .option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
5206
5314
  .option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
5207
5315
  .option("--admin-pass <pass>", "Admin password for the TARGET instance. Required explicitly for a remote target — FLAIR_ADMIN_PASS and ~/.flair/admin-pass are this machine's local credentials and are never sent to a remote instance")
5316
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5208
5317
  .option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
5209
5318
  .option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
5210
5319
  .option("--json", "Print machine-readable JSON instead of a human summary")
@@ -5264,7 +5373,7 @@ mcp
5264
5373
  idpSubject,
5265
5374
  principal: opts.principal,
5266
5375
  principalKind: opts.principalKind,
5267
- adminUser: DEFAULT_ADMIN_USER,
5376
+ adminUser: resolveAdminUser(opts.adminUser),
5268
5377
  adminPass,
5269
5378
  signingKeyFilePath: opts.signingKeyFile,
5270
5379
  secretsMechanism,
@@ -5313,6 +5422,7 @@ mcp
5313
5422
  .description("Flag off + restart = byte-identical boot (Model-2 contract) — removes the /mcp OAuth surface.")
5314
5423
  .option("--instance <url>", "Remote flair instance to disable against (else FLAIR_URL)")
5315
5424
  .option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
5425
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5316
5426
  .option("--confirm-flag-off", "Confirm FLAIR_MCP_OAUTH is already unset on the target instance's environment (skips the interactive confirm)")
5317
5427
  .option("--json", "Print machine-readable JSON instead of a human summary")
5318
5428
  .action(async (opts) => {
@@ -5328,7 +5438,7 @@ mcp
5328
5438
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required.");
5329
5439
  process.exit(1);
5330
5440
  }
5331
- const result = await disableMcp({ instance, adminUser: DEFAULT_ADMIN_USER, adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
5441
+ const result = await disableMcp({ instance, adminUser: resolveAdminUser(opts.adminUser), adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
5332
5442
  if (opts.json) {
5333
5443
  console.log(render.asJSON(result));
5334
5444
  if (!result.ok)
@@ -5399,12 +5509,13 @@ principal
5399
5509
  .option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
5400
5510
  .option("--port <port>", "Harper HTTP port")
5401
5511
  .option("--admin-pass <pass>", "Admin password for registration")
5512
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5402
5513
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
5403
5514
  .option("--ops-port <port>", "Harper operations API port")
5404
5515
  .action(async (id, opts) => {
5405
5516
  const opsPort = resolveOpsPort(opts);
5406
5517
  const keysDir = opts.keysDir ?? defaultKeysDir();
5407
- const adminUser = DEFAULT_ADMIN_USER;
5518
+ const adminUser = resolveAdminUser(opts.adminUser);
5408
5519
  const kind = opts.kind ?? "agent";
5409
5520
  const name = opts.name ?? id;
5410
5521
  const isAdmin = opts.admin ?? false;
@@ -5491,6 +5602,7 @@ principal
5491
5602
  .description("List all principals")
5492
5603
  .option("--kind <kind>", "Filter by kind: human or agent")
5493
5604
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
5605
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5494
5606
  .option("--port <port>", "Harper HTTP port")
5495
5607
  .option("--ops-port <port>", "Harper operations API port")
5496
5608
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
@@ -5501,7 +5613,7 @@ principal
5501
5613
  console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
5502
5614
  process.exit(1);
5503
5615
  }
5504
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5616
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5505
5617
  const conditions = opts.kind
5506
5618
  ? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
5507
5619
  : [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
@@ -5623,6 +5735,7 @@ principal
5623
5735
  .command("disable <id>")
5624
5736
  .description("Deactivate a principal (revokes access, preserves data)")
5625
5737
  .option("--admin-pass <pass>", "Admin password")
5738
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5626
5739
  .option("--ops-port <port>", "Harper operations API port")
5627
5740
  .action(async (id, opts) => {
5628
5741
  const opsPort = resolveOpsPort(opts);
@@ -5631,7 +5744,7 @@ principal
5631
5744
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
5632
5745
  process.exit(1);
5633
5746
  }
5634
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5747
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5635
5748
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5636
5749
  method: "POST",
5637
5750
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5653,6 +5766,7 @@ principal
5653
5766
  .command("promote <id> <tier>")
5654
5767
  .description("Change a principal's trust tier (endorsed, corroborated, unverified)")
5655
5768
  .option("--admin-pass <pass>", "Admin password")
5769
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5656
5770
  .option("--ops-port <port>", "Harper operations API port")
5657
5771
  .action(async (id, tier, opts) => {
5658
5772
  const validTiers = ["endorsed", "corroborated", "unverified"];
@@ -5666,7 +5780,7 @@ principal
5666
5780
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
5667
5781
  process.exit(1);
5668
5782
  }
5669
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5783
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5670
5784
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5671
5785
  method: "POST",
5672
5786
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5698,6 +5812,7 @@ idp
5698
5812
  .option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
5699
5813
  .option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
5700
5814
  .option("--admin-pass <pass>", "Admin password")
5815
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5701
5816
  .option("--ops-port <port>", "Harper operations API port")
5702
5817
  .action(async (opts) => {
5703
5818
  const opsPort = resolveOpsPort(opts);
@@ -5707,7 +5822,7 @@ idp
5707
5822
  process.exit(1);
5708
5823
  }
5709
5824
  const id = `idp_${randomUUID().slice(0, 8)}`;
5710
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5825
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5711
5826
  const now = new Date().toISOString();
5712
5827
  const record = {
5713
5828
  id,
@@ -5744,6 +5859,7 @@ idp
5744
5859
  .command("list")
5745
5860
  .description("List configured IdPs")
5746
5861
  .option("--admin-pass <pass>", "Admin password")
5862
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5747
5863
  .option("--ops-port <port>", "Harper operations API port")
5748
5864
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
5749
5865
  .action(async (opts) => {
@@ -5753,7 +5869,7 @@ idp
5753
5869
  console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
5754
5870
  process.exit(1);
5755
5871
  }
5756
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5872
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5757
5873
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5758
5874
  method: "POST",
5759
5875
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5798,6 +5914,7 @@ idp
5798
5914
  .command("remove <id>")
5799
5915
  .description("Remove an IdP configuration")
5800
5916
  .option("--admin-pass <pass>", "Admin password")
5917
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5801
5918
  .option("--ops-port <port>", "Harper operations API port")
5802
5919
  .action(async (id, opts) => {
5803
5920
  const opsPort = resolveOpsPort(opts);
@@ -5806,7 +5923,7 @@ idp
5806
5923
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
5807
5924
  process.exit(1);
5808
5925
  }
5809
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5926
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5810
5927
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5811
5928
  method: "POST",
5812
5929
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5865,12 +5982,13 @@ program
5865
5982
  .option("--port <port>", "Harper HTTP port")
5866
5983
  .option("--ops-port <port>", "Harper operations API port")
5867
5984
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
5985
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5868
5986
  .option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
5869
5987
  .action(async (fromAgent, toAgent, opts) => {
5870
5988
  const httpPort = resolveHttpPort(opts);
5871
5989
  const opsPort = resolveOpsPort(opts);
5872
5990
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
5873
- const adminUser = DEFAULT_ADMIN_USER;
5991
+ const adminUser = resolveAdminUser(opts.adminUser);
5874
5992
  const scope = opts.scope ?? "read";
5875
5993
  if (!adminPass) {
5876
5994
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for grant");
@@ -5914,11 +6032,12 @@ program
5914
6032
  .option("--port <port>", "Harper HTTP port")
5915
6033
  .option("--ops-port <port>", "Harper operations API port")
5916
6034
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
6035
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5917
6036
  .action(async (fromAgent, toAgent, opts) => {
5918
6037
  const httpPort = resolveHttpPort(opts);
5919
6038
  const opsPort = resolveOpsPort(opts);
5920
6039
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
5921
- const adminUser = DEFAULT_ADMIN_USER;
6040
+ const adminUser = resolveAdminUser(opts.adminUser);
5922
6041
  if (!adminPass) {
5923
6042
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for revoke");
5924
6043
  process.exit(1);
@@ -5962,7 +6081,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
5962
6081
  // Fallback: check DB for legacy _keySeed
5963
6082
  const opsPort = resolveOpsPort(opts);
5964
6083
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
5965
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6084
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5966
6085
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5967
6086
  method: "POST",
5968
6087
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -6048,6 +6167,95 @@ function driverCheckAppliesTo(opts) {
6048
6167
  const target = resolveTarget(opts);
6049
6168
  return !target || isLocalBase(target.replace(/\/$/, ""));
6050
6169
  }
6170
+ /**
6171
+ * flair#1108: a bare undici/Node "fetch failed" names neither the URL
6172
+ * that was probed nor the knob that would change it. These helpers are
6173
+ * the operator-facing sentence and the setting that produced (or would
6174
+ * change) that URL. Pure so the contract can be unit-tested without
6175
+ * driving process.exit.
6176
+ */
6177
+ export function federationStatusUrlSetting(opts) {
6178
+ if (opts.target)
6179
+ return "--target";
6180
+ if (process.env.FLAIR_TARGET)
6181
+ return "FLAIR_TARGET";
6182
+ if (process.env.FLAIR_URL)
6183
+ return "FLAIR_URL";
6184
+ if (opts.port !== undefined && opts.port !== null && String(opts.port) !== "")
6185
+ return "--port";
6186
+ return "FLAIR_URL or --port";
6187
+ }
6188
+ export function describeFederationStatusFetchFailed(url, setting) {
6189
+ return `fetch failed against ${url} (set ${setting})`;
6190
+ }
6191
+ /** True for a connect-level failure (no HTTP status): Node's undici
6192
+ * `TypeError: fetch failed`, Bun's `Unable to connect…`, or a cause
6193
+ * carrying a connect/DNS errno. Auth and HTTP errors stay out. */
6194
+ export function isFederationStatusConnectFailure(err) {
6195
+ const msg = err instanceof Error ? err.message : String(err);
6196
+ if (/\bfetch failed\b/i.test(msg))
6197
+ return true;
6198
+ if (/unable to connect/i.test(msg))
6199
+ return true;
6200
+ const cause = err instanceof Error ? err.cause : undefined;
6201
+ const code = cause && typeof cause === "object" && cause && "code" in cause
6202
+ ? String(cause.code)
6203
+ : "";
6204
+ return /^(ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH)$/.test(code);
6205
+ }
6206
+ export function rewriteFederationStatusFetchFailed(err, url, setting) {
6207
+ if (!isFederationStatusConnectFailure(err))
6208
+ return err;
6209
+ const next = new Error(describeFederationStatusFetchFailed(url, setting));
6210
+ if (err && typeof err === "object" && "status" in err) {
6211
+ next.status = err.status;
6212
+ }
6213
+ return next;
6214
+ }
6215
+ /**
6216
+ * Auth-shaped vs connect-level for `federation status`. A rewritten
6217
+ * fetch-failed sentence embeds the probed URL; that URL can contain a
6218
+ * whole-token `401` (e.g. `--port 401`). The old `message.includes("401")`
6219
+ * check then printed the credential remedy and hid the URL+setting this
6220
+ * change exists to surface (Bugbot on flair#1108).
6221
+ */
6222
+ export function isFederationStatusAuthFailure(err) {
6223
+ if (!err)
6224
+ return false;
6225
+ if (isFederationStatusConnectFailure(err))
6226
+ return false;
6227
+ if (typeof err === "object" && "status" in err) {
6228
+ const status = err.status;
6229
+ if (status === 401 || status === 403)
6230
+ return true;
6231
+ }
6232
+ const m = err instanceof Error
6233
+ ? err.message
6234
+ : String(typeof err === "object" && err && "message" in err
6235
+ ? err.message ?? err
6236
+ : err);
6237
+ return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
6238
+ }
6239
+ /**
6240
+ * Whether to print the "set one of: FLAIR_AGENT_ID / FLAIR_ADMIN_PASS /
6241
+ * FLAIR_TOKEN" block. Narrower than `isFederationStatusAuthFailure`: a
6242
+ * 403 with credentials already sent (wrong password) is fatal, but the
6243
+ * server's own body is the honest message — the credential-list remedy
6244
+ * is for missing/invalid auth (401), not a rejected password (flair#634).
6245
+ */
6246
+ export function isFederationStatusAuthRemedy(err) {
6247
+ if (!err || isFederationStatusConnectFailure(err))
6248
+ return false;
6249
+ if (typeof err === "object" && "status" in err && err.status === 401) {
6250
+ return true;
6251
+ }
6252
+ const m = err instanceof Error
6253
+ ? err.message
6254
+ : String(typeof err === "object" && err && "message" in err
6255
+ ? err.message ?? err
6256
+ : err);
6257
+ return m.includes("missing_or_invalid_authorization") || /(?:^|\D)401(?:\D|$)/.test(m);
6258
+ }
6051
6259
  federation
6052
6260
  .command("status")
6053
6261
  .description("Show federation status and peer connections")
@@ -6056,8 +6264,11 @@ federation
6056
6264
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
6057
6265
  .option("--json", "Emit JSON {instance, peers, driver} (also: pipe + FLAIR_OUTPUT=json)")
6058
6266
  .action(async (opts) => {
6059
- const target = resolveTarget(opts);
6060
- const baseUrl = target ? target.replace(/\/$/, "") : undefined;
6267
+ // Same URL api() would have derived, including --port (the command
6268
+ // advertised --port but previously dropped it on the floor). Naming
6269
+ // that URL on fetch failure is only honest if it is the URL we probe.
6270
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
6271
+ const urlSetting = federationStatusUrlSetting(opts);
6061
6272
  const mode = render.resolveOutputMode(opts);
6062
6273
  // flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
6063
6274
  // must never take down the whole render — the principle latestPeerContact
@@ -6067,41 +6278,33 @@ federation
6067
6278
  let instance = null;
6068
6279
  let instanceErr = null;
6069
6280
  try {
6070
- instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
6281
+ instance = await api("GET", "/FederationInstance", undefined, { baseUrl });
6071
6282
  }
6072
6283
  catch (err) {
6073
- instanceErr = err;
6284
+ instanceErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
6074
6285
  }
6075
6286
  // peers: null = unverifiable (the read failed), [] = verified empty.
6076
6287
  let peers = null;
6077
6288
  let peersErr = null;
6078
6289
  try {
6079
- const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
6290
+ const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
6080
6291
  peers = r.peers ?? [];
6081
6292
  }
6082
6293
  catch (err) {
6083
- peersErr = err;
6294
+ peersErr = rewriteFederationStatusFetchFailed(err, baseUrl, urlSetting);
6084
6295
  }
6085
6296
  // Auth-shaped failures stay FATAL even when the other read succeeded:
6086
6297
  // both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
6087
6298
  // property of the session's credentials, not of one endpoint — and
6088
6299
  // degrading it to "unverifiable" would swallow the actionable remedy
6089
6300
  // (flair#634's UX, kept). Only non-auth failures degrade independently.
6090
- const authShaped = (err) => {
6091
- if (!err)
6092
- return false;
6093
- if (err.status === 401 || err.status === 403)
6094
- return true;
6095
- const m = String(err.message ?? err);
6096
- return m.includes("missing_or_invalid_authorization") || m.includes("401");
6097
- };
6098
6301
  // Both reads failed → nothing to render at all. Either way keep the
6099
6302
  // classic failure UX (auth remedy when it's an auth problem), exit
6100
6303
  // non-zero.
6101
- if ((instanceErr && peersErr) || authShaped(instanceErr) || authShaped(peersErr)) {
6304
+ if ((instanceErr && peersErr) || isFederationStatusAuthFailure(instanceErr) || isFederationStatusAuthFailure(peersErr)) {
6102
6305
  const primaryErr = instanceErr ?? peersErr;
6103
6306
  const msg = String(primaryErr.message ?? primaryErr);
6104
- if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
6307
+ if (isFederationStatusAuthRemedy(primaryErr)) {
6105
6308
  console.error(`${render.icons.error} federation status requires auth.`);
6106
6309
  console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
6107
6310
  console.error(` ${render.wrap(render.c.cyan, "FLAIR_AGENT_ID=<your-agent-id>")} ${render.wrap(render.c.dim, "(Ed25519 — uses ~/.flair/keys/<id>.key)")}`);
@@ -6459,6 +6662,7 @@ federation
6459
6662
  .description("Pair this spoke with a hub instance")
6460
6663
  .option("--port <port>", "Harper HTTP port")
6461
6664
  .option("--admin-pass <pass>", "Admin password")
6665
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
6462
6666
  .option("--ops-port <port>", "Harper operations API port")
6463
6667
  .option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN) [deprecated: use --token-from]")
6464
6668
  .option("--token-from <file>", "Read bootstrap triple from JSON file (use '-' for stdin)")
@@ -6536,7 +6740,7 @@ federation
6536
6740
  "Without it, 'flair federation sync' will report 'No hub peer configured'.");
6537
6741
  process.exit(1);
6538
6742
  }
6539
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6743
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
6540
6744
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
6541
6745
  const peerRes = await fetch(`${opsEndpoint}/`, {
6542
6746
  method: "POST",
@@ -6573,6 +6777,7 @@ federation
6573
6777
  .description("Generate a one-time pairing token (run on the hub)")
6574
6778
  .option("--port <port>", "Harper HTTP port")
6575
6779
  .option("--admin-pass <pass>", "Admin password")
6780
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
6576
6781
  .option("--ops-port <port>", "Harper operations API port")
6577
6782
  .option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
6578
6783
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
@@ -6587,7 +6792,7 @@ federation
6587
6792
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
6588
6793
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
6589
6794
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
6590
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6795
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
6591
6796
  // 1. Persist the PairingToken record
6592
6797
  const opsRes = await fetch(`${opsEndpoint}/`, {
6593
6798
  method: "POST",
@@ -6692,7 +6897,7 @@ export async function runFederationSyncOnce(opts) {
6692
6897
  const syncStartedAt = new Date().toISOString();
6693
6898
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
6694
6899
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
6695
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6900
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
6696
6901
  const tables = ["Memory", "Soul", "Agent", "Relationship"];
6697
6902
  const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
6698
6903
  const hubUrl = hub.endpoint ?? hub.id;
@@ -6934,28 +7139,16 @@ export async function runFederationSyncOnce(opts) {
6934
7139
  return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
6935
7140
  }
6936
7141
  }
6937
- const federationSync = federation
7142
+ const federationSync = addSharedCredentialOptions(federation
6938
7143
  .command("sync")
6939
7144
  .description("Push local changes to the hub (one-shot). Subcommands manage the scheduled driver.")
6940
7145
  .option("--port <port>", "Harper HTTP port")
6941
- .option("--admin-pass <pass>", "Admin password")
6942
- .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.")
6943
7146
  .option("--ops-port <port>", "Harper operations API port")
6944
7147
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
6945
- .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
6946
- .action(async (opts) => {
7148
+ .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")).action(async (opts) => {
6947
7149
  // --admin-pass-file resolves into the same `adminPass` slot the inline
6948
7150
  // flag uses, so the scheduler never has to embed a secret in a unit file.
6949
- // readAdminPassFileSecure() refuses a file that is not owner-only.
6950
- if (!opts.adminPass && opts.adminPassFile) {
6951
- try {
6952
- opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
6953
- }
6954
- catch (err) {
6955
- console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
6956
- process.exit(1);
6957
- }
6958
- }
7151
+ applyAdminPassFile(opts);
6959
7152
  const r = await runFederationSyncOnce(opts);
6960
7153
  if (r.error) {
6961
7154
  console.error(`Error: ${r.error.message}`);
@@ -7140,6 +7333,7 @@ federation
7140
7333
  .option("--interval <seconds>", "Seconds between syncs", "30")
7141
7334
  .option("--port <port>", "Harper HTTP port")
7142
7335
  .option("--admin-pass <pass>", "Admin password")
7336
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
7143
7337
  .option("--ops-port <port>", "Harper operations API port")
7144
7338
  .option("--target <url>", "Remote Flair URL")
7145
7339
  .option("--ops-target <url>", "Explicit ops API URL")
@@ -8666,7 +8860,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
8666
8860
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
8667
8861
  if (adminPass) {
8668
8862
  res = await fetch(`${baseUrl}/Health`, {
8669
- headers: { Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}` },
8863
+ headers: { Authorization: `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}` },
8670
8864
  signal: AbortSignal.timeout(5000),
8671
8865
  });
8672
8866
  }
@@ -8718,8 +8912,10 @@ const statusCmd = program
8718
8912
  discoveredPort = await discoverLocalFlairPort(baseUrl);
8719
8913
  }
8720
8914
  // Version-behind check (flair#587) — offline-tolerant + cached, so this
8721
- // never adds meaningful latency or fails `status` when the registry is
8722
- // unreachable. Independent of Harper health; runs either way.
8915
+ // never fails `status` when the registry is unreachable, and costs no
8916
+ // network round trip on the common up-to-date path. When a cached answer
8917
+ // would print a nudge it spends one short-timeout refetch so the printed
8918
+ // fact is current (flair#1341). Independent of Harper health; runs either way.
8723
8919
  const versionCheckResult = await checkVersion(__pkgVersion);
8724
8920
  const versionNudge = formatVersionNudge(versionCheckResult);
8725
8921
  if (opts.json) {
@@ -9211,7 +9407,7 @@ statusCmd
9211
9407
  }
9212
9408
  }
9213
9409
  else {
9214
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
9410
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
9215
9411
  const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
9216
9412
  for (const agentId of agentList) {
9217
9413
  try {
@@ -11754,7 +11950,7 @@ program
11754
11950
  // embedding with a freshly-computed one. Without this path, `flair
11755
11951
  // reembed` could not recover from the very condition it exists to fix.
11756
11952
  const opsPort = resolveOpsPort(opts);
11757
- const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
11953
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
11758
11954
  // Harper rejects empty-value conditions ("not indexed for nulls"). Use
11759
11955
  // `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
11760
11956
  // has a createdAt, the index is built, and the comparison is total.
@@ -11852,7 +12048,7 @@ program
11852
12048
  let allMemories = [];
11853
12049
  if (adminPassSingle) {
11854
12050
  const opsPort = resolveOpsPort(opts);
11855
- const opsAuth = `Basic ${Buffer.from(`admin:${adminPassSingle}`).toString("base64")}`;
12051
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPassSingle}`).toString("base64")}`;
11856
12052
  const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
11857
12053
  method: "POST",
11858
12054
  headers: { "Content-Type": "application/json", Authorization: opsAuth },
@@ -11940,7 +12136,10 @@ program
11940
12136
  console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
11941
12137
  process.exit(1);
11942
12138
  }
11943
- const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
12139
+ // Single source of truth (flair#1351): banner prints the URL the test's
12140
+ // own client uses. resolveBaseUrl is the existing CLI resolver; pass the
12141
+ // same value through to api() so the two cannot diverge.
12142
+ const baseUrl = resolveBaseUrl(opts);
11944
12143
  console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
11945
12144
  let passed = 0;
11946
12145
  let failed = 0;
@@ -11975,7 +12174,7 @@ program
11975
12174
  };
11976
12175
  if (agentId)
11977
12176
  body.agentId = agentId;
11978
- await api("PUT", `/Memory/${id}`, body);
12177
+ await api("PUT", `/Memory/${id}`, body, { baseUrl });
11979
12178
  memoryId = id;
11980
12179
  return true;
11981
12180
  });
@@ -11985,7 +12184,7 @@ program
11985
12184
  const body = { q: "flair test", limit: 5 };
11986
12185
  if (agentId)
11987
12186
  body.agentId = agentId;
11988
- const result = await api("POST", "/SemanticSearch", body);
12187
+ const result = await api("POST", "/SemanticSearch", body, { baseUrl });
11989
12188
  return (result?.results?.length ?? 0) > 0;
11990
12189
  });
11991
12190
  // 3. Delete the test memory via DELETE /Memory/<id>
@@ -11995,7 +12194,7 @@ program
11995
12194
  console.log(` (skipped — no id returned from write step)`);
11996
12195
  return true;
11997
12196
  }
11998
- await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
12197
+ await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined, { baseUrl });
11999
12198
  return true;
12000
12199
  });
12001
12200
  const passColor = passed > 0 ? render.c.green : render.c.dim;
@@ -12666,6 +12865,9 @@ program
12666
12865
  catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
12667
12866
  // The component directory for a local install is the flair package itself:
12668
12867
  // `flair start` spawns `harper run .` with cwd = flairPackageDir().
12868
+ // That path is often inside node_modules on an npm install-g; doctor still
12869
+ // READs it for drift detection, but describePublicUrlFinding never names
12870
+ // it as the fix (flair#1313 — wiped on every upgrade).
12669
12871
  const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
12670
12872
  let componentEnvValue = null;
12671
12873
  try {
@@ -12755,7 +12957,7 @@ program
12755
12957
  }
12756
12958
  const auditStatus = auditCredIssue
12757
12959
  ? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
12758
- : await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, DEFAULT_ADMIN_USER, auditAdminPass);
12960
+ : await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, resolveAdminUser(undefined), auditAdminPass);
12759
12961
  switch (auditStatus.state) {
12760
12962
  case "ok":
12761
12963
  // Present-tense claim ONLY (see AuditVerifyResult): the probe
@@ -12834,11 +13036,13 @@ program
12834
13036
  }
12835
13037
  // 7. Client integration (flair#588) — the first 6 checks diagnose the
12836
13038
  // SERVER side. This diagnoses whether Flair is actually wired to a real
12837
- // MCP client (Claude Code, Codex, Gemini, Cursor): the MCP block present
12838
- // + reachable + the configured agent genuinely registered (every detected
12839
- // client), plus CLAUDE.md + the SessionStart hook (Claude Code only,
12840
- // since only Claude Code has those mechanisms). Reuses detectClients()
12841
- // rather than reimplementing client detection.
13039
+ // client: for MCP clients (Claude Code, Codex, Gemini, Cursor,
13040
+ // Antigravity) the MCP block present + reachable + the configured agent
13041
+ // genuinely registered; for pi (a NATIVE EXTENSION host flair#1342) the
13042
+ // pi-flair reference in pi's own settings, including the flair#1346
13043
+ // npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
13044
+ // SessionStart hook (Claude Code + Codex — flair#1148). Reuses
13045
+ // detectClients() rather than reimplementing client detection.
12842
13046
  console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
12843
13047
  // Prompt y/N before a content-editing fix, but only when interactive —
12844
13048
  // in a non-TTY context (CI, scripts) --fix itself is the consent signal,
@@ -12858,6 +13062,7 @@ program
12858
13062
  }
12859
13063
  else {
12860
13064
  let claudeCodeAgentId;
13065
+ let codexAgentId;
12861
13066
  let anyKnownAgentId;
12862
13067
  // `doctor --fix` writes client configs through the same wire functions
12863
13068
  // init does, so it owes the user the same warning when the spec it would
@@ -12870,9 +13075,175 @@ program
12870
13075
  }
12871
13076
  }
12872
13077
  for (const client of detectedClients) {
13078
+ // ── pi (flair#1342): NATIVE EXTENSION, not an MCP client ───────────
13079
+ // There is no mcpServers block to read — pi loads @tpsdev-ai/pi-flair
13080
+ // through its own settings.json (`packages`). Every check below is a
13081
+ // filesystem fact except agent registration, which is only checkable
13082
+ // when this shell exposes the env pi would launch with — and the
13083
+ // output says which of the two it verified.
13084
+ if (client.kind === "native-extension") {
13085
+ let pi = checkPiFlairWiring(homedir(), process.cwd());
13086
+ // --fix for pi needs no agent id (pi settings carry no env block);
13087
+ // a resolvable id only improves the export hint in the message.
13088
+ const wirePiFix = async (prompt) => {
13089
+ if (dryRun) {
13090
+ console.log(` ${render.wrap(render.c.dim, "Would update")} ${pi.settingsPath}`);
13091
+ return;
13092
+ }
13093
+ const proceed = await confirmFix(prompt);
13094
+ if (!proceed) {
13095
+ console.log(` Skipped.`);
13096
+ return;
13097
+ }
13098
+ const hintAgentId = resolveFixAgentId({
13099
+ optsAgent: opts.agent,
13100
+ envAgentId: process.env.FLAIR_AGENT_ID,
13101
+ anyKnownAgentId,
13102
+ keyAgentIds,
13103
+ keysDir: defaultKeysDir(),
13104
+ }) ?? "<your-agent-id>";
13105
+ const wireResult = wirePi({ FLAIR_AGENT_ID: hintAgentId, FLAIR_URL: baseUrl });
13106
+ console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
13107
+ if (wireResult.ok)
13108
+ fixed++;
13109
+ };
13110
+ // (a) The flair#1346 trap FIRST, and by NAME: an npm: spec under
13111
+ // "extensions" is silently ignored by pi — the user believes they
13112
+ // are wired while pi registers zero tools. This is the documented
13113
+ // field failure mode and must never fold into a generic "not
13114
+ // wired": the fix is a MOVE to "packages", not an add.
13115
+ const userTraps = pi.misconfigured.filter((m) => m.path === pi.settingsPath);
13116
+ const projectTraps = pi.misconfigured.filter((m) => m.path !== pi.settingsPath);
13117
+ for (const bad of pi.misconfigured) {
13118
+ console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} is listed under "extensions" as an npm: spec (${bad.entry}) in ${render.wrap(render.c.dim, bad.path)}`);
13119
+ console.log(` pi silently ignores npm: specs under "extensions", so the Flair tools never register (flair#1346). Package sources belong under "packages".`);
13120
+ issues++;
13121
+ }
13122
+ if (userTraps.length > 0) {
13123
+ if (autoFix) {
13124
+ await wirePiFix(` Move the npm: spec to "packages" in ${pi.settingsPath} now? [y/N] `);
13125
+ // Re-derive the wiring from disk so the sections below reason
13126
+ // about the POST-fix state — otherwise a move that just
13127
+ // succeeded would still read as "not wired" and prompt again.
13128
+ pi = checkPiFlairWiring(homedir(), process.cwd());
13129
+ }
13130
+ else {
13131
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(moves it to "packages")`)}`);
13132
+ }
13133
+ }
13134
+ if (projectTraps.length > 0) {
13135
+ // wirePi edits the USER-scope settings only — a project-scope
13136
+ // trap gets the exact manual fix, never a --fix that claims a
13137
+ // file it does not touch.
13138
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} move the entry from "extensions" to "packages" in ${projectTraps[0].path}`);
13139
+ }
13140
+ if (!pi.wired) {
13141
+ console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} not wired in ${render.wrap(render.c.dim, pi.settingsPath)}`);
13142
+ if (autoFix) {
13143
+ await wirePiFix(` Wire pi now (adds ${piFlairSpec()} to "packages" in ${pi.settingsPath})? [y/N] `);
13144
+ }
13145
+ else {
13146
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages")`)} — or: pi install npm:${PI_FLAIR_PACKAGE}`);
13147
+ }
13148
+ issues++;
13149
+ continue;
13150
+ }
13151
+ if (pi.wiredVia === "packages") {
13152
+ console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via "packages" (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
13153
+ if (!pi.pinnedVersion) {
13154
+ console.log(` ${render.icons.info} unpinned — pi re-resolves latest on (re)install; pin with ${piFlairSpec()}`);
13155
+ }
13156
+ }
13157
+ else {
13158
+ // extension-path: the documented pre-0.49 workaround (a local
13159
+ // path to the installed dist/index.js). Works, but the canonical
13160
+ // form is a "packages" entry — and a DANGLING path is a broken
13161
+ // wiring pi skips silently, so check the one thing checkable.
13162
+ if (pi.extensionPathExists) {
13163
+ console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via a file-path "extensions" entry (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
13164
+ console.log(` ${render.wrap(render.c.dim, `pre-0.49 workaround — the canonical form is a "packages" entry: ${piFlairSpec()}`)}`);
13165
+ }
13166
+ else {
13167
+ console.log(` ${render.icons.error} pi: the "extensions" entry ${pi.spec} in ${render.wrap(render.c.dim, pi.wiredIn)} points at a file that does not exist — pi silently skips missing extension paths`);
13168
+ if (autoFix) {
13169
+ await wirePiFix(` Wire pi via "packages" instead (adds ${piFlairSpec()})? [y/N] `);
13170
+ }
13171
+ else {
13172
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages"; remove the dangling entry yourself)`)}`);
13173
+ }
13174
+ issues++;
13175
+ continue;
13176
+ }
13177
+ }
13178
+ // Env sanity (flair#1342 scope 3). pi settings carry no env block:
13179
+ // pi-flair reads FLAIR_* from the environment of whatever shell/IDE
13180
+ // launches pi. Doctor can only see ITS OWN environment — these
13181
+ // lines verify this shell, and say so, rather than pretending to
13182
+ // verify every pi launch. None of them counts as an issue: a clean
13183
+ // pi launched elsewhere can be fine while this shell is bare, and
13184
+ // vice versa.
13185
+ console.log(` ${render.wrap(render.c.dim, "pi-flair reads FLAIR_AGENT_ID / FLAIR_URL / FLAIR_KEY_PATH from the shell that launches pi — doctor sees only its own environment (this shell):")}`);
13186
+ const piEnvAgent = process.env.FLAIR_AGENT_ID;
13187
+ const piEnvUrl = process.env.FLAIR_URL;
13188
+ const piEnvKey = process.env.FLAIR_KEY_PATH;
13189
+ if (piEnvAgent) {
13190
+ console.log(` ${render.icons.ok} FLAIR_AGENT_ID set ('${piEnvAgent}')`);
13191
+ }
13192
+ else {
13193
+ console.log(` ${render.icons.warn} FLAIR_AGENT_ID not set in this shell — pi-flair falls back to the cwd directory name as its agent id (identity varies by project); export FLAIR_AGENT_ID=<id> where pi is launched`);
13194
+ }
13195
+ if (piEnvUrl) {
13196
+ console.log(` ${render.icons.ok} FLAIR_URL set (${piEnvUrl})`);
13197
+ }
13198
+ else {
13199
+ console.log(` ${render.icons.info} FLAIR_URL not set — pi-flair defaults to ${render.wrap(render.c.dim, PI_FLAIR_DEFAULT_URL)}`);
13200
+ }
13201
+ if (piEnvKey) {
13202
+ if (existsSync(piEnvKey)) {
13203
+ console.log(` ${render.icons.ok} FLAIR_KEY_PATH set (${piEnvKey})`);
13204
+ }
13205
+ else {
13206
+ console.log(` ${render.icons.warn} FLAIR_KEY_PATH points at a missing file (${piEnvKey})`);
13207
+ }
13208
+ }
13209
+ else {
13210
+ console.log(` ${render.icons.info} FLAIR_KEY_PATH not set — auto-resolved from ~/.flair/keys`);
13211
+ }
13212
+ // Agent registration — checkable only when this shell exposes an
13213
+ // agent id at all; otherwise say what was NOT verified instead of
13214
+ // skipping silently.
13215
+ if (piEnvAgent) {
13216
+ const piUrl = piEnvUrl || PI_FLAIR_DEFAULT_URL;
13217
+ const piReachable = await probeFlairReachable(piUrl);
13218
+ if (!piReachable) {
13219
+ console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, piUrl)} not reachable — cannot verify agent registration`);
13220
+ }
13221
+ else {
13222
+ const piReg = await checkAgentRegistered(piUrl, piEnvAgent, defaultKeysDir());
13223
+ if (piReg.state === "registered") {
13224
+ console.log(` ${render.icons.ok} agent '${piEnvAgent}' registered`);
13225
+ }
13226
+ else if (piReg.state === "not-registered") {
13227
+ console.log(` ${render.icons.error} agent '${piEnvAgent}' is NOT registered on this Flair instance`);
13228
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${piEnvAgent}`);
13229
+ issues++;
13230
+ }
13231
+ else {
13232
+ const piFinding = describeAgentGateFinding(piEnvAgent, piReg.state, piReg.detail, { instanceReachable: piReachable });
13233
+ console.log(` ${render.icons.warn} ${piFinding?.message ?? `could not verify agent registration (${piReg.detail})`}`);
13234
+ }
13235
+ }
13236
+ }
13237
+ else {
13238
+ console.log(` ${render.wrap(render.c.dim, "agent registration not verified — no FLAIR_AGENT_ID visible to doctor")}`);
13239
+ }
13240
+ continue;
13241
+ }
12873
13242
  const block = readClientMcpBlock(client.id, homedir());
12874
13243
  if (client.id === "claude-code" && block.agentId)
12875
13244
  claudeCodeAgentId = block.agentId;
13245
+ if (client.id === "codex" && block.agentId)
13246
+ codexAgentId = block.agentId;
12876
13247
  if (block.agentId)
12877
13248
  anyKnownAgentId = anyKnownAgentId ?? block.agentId;
12878
13249
  if (!block.present) {
@@ -12919,8 +13290,14 @@ program
12919
13290
  client.id === "antigravity" ? wireAntigravity(wireEnv) :
12920
13291
  wireCursor(wireEnv);
12921
13292
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
12922
- if (wireResult.ok)
13293
+ if (wireResult.ok) {
12923
13294
  fixed++;
13295
+ if (client.id === "claude-code")
13296
+ claudeCodeAgentId = fixAgentId;
13297
+ if (client.id === "codex")
13298
+ codexAgentId = fixAgentId;
13299
+ anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
13300
+ }
12924
13301
  }
12925
13302
  }
12926
13303
  }
@@ -12970,8 +13347,9 @@ program
12970
13347
  console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
12971
13348
  }
12972
13349
  }
12973
- // Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
12974
- // has these mechanisms, so only run them when claude-code was detected.
13350
+ // Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
13351
+ // Codex has a SessionStart hook too (checked below); CLAUDE.md and
13352
+ // continuity stay Claude Code only.
12975
13353
  if (detectedClients.some((c) => c.id === "claude-code")) {
12976
13354
  const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
12977
13355
  if (claudeMd.present) {
@@ -13161,6 +13539,92 @@ program
13161
13539
  issues++;
13162
13540
  }
13163
13541
  }
13542
+ // Codex SessionStart hook (flair#1148) — same flair-session-start
13543
+ // command Claude Code uses, written to ~/.codex/hooks.json. Continuity
13544
+ // and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
13545
+ // is the hook file.
13546
+ if (detectedClients.some((c) => c.id === "codex")) {
13547
+ const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
13548
+ if (hook.present) {
13549
+ if (hook.execution === "broken") {
13550
+ if (hook.silenced) {
13551
+ console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
13552
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
13553
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
13554
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
13555
+ console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
13556
+ }
13557
+ else {
13558
+ 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`);
13559
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
13560
+ 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)")}`);
13561
+ }
13562
+ }
13563
+ else if (hook.execution === "unknown") {
13564
+ 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"})`)}`);
13565
+ }
13566
+ else if (!hook.ours) {
13567
+ 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)")}`);
13568
+ }
13569
+ else {
13570
+ 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")}`);
13571
+ }
13572
+ if (!hook.silenced && hook.ours) {
13573
+ console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
13574
+ if (hook.upgradable) {
13575
+ if (autoFix) {
13576
+ if (dryRun) {
13577
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
13578
+ }
13579
+ else {
13580
+ const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
13581
+ if (!proceed) {
13582
+ console.log(` Skipped.`);
13583
+ }
13584
+ else {
13585
+ const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
13586
+ console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13587
+ if (upgrade.ok && upgrade.changed)
13588
+ fixed++;
13589
+ }
13590
+ }
13591
+ }
13592
+ else {
13593
+ 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)")}`);
13594
+ }
13595
+ }
13596
+ else {
13597
+ 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`);
13598
+ }
13599
+ issues++;
13600
+ }
13601
+ }
13602
+ else {
13603
+ console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
13604
+ if (autoFix) {
13605
+ if (dryRun) {
13606
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
13607
+ }
13608
+ else {
13609
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
13610
+ if (!proceed) {
13611
+ console.log(` Skipped.`);
13612
+ }
13613
+ else {
13614
+ const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
13615
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
13616
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13617
+ if (fixRes.ok)
13618
+ fixed++;
13619
+ }
13620
+ }
13621
+ }
13622
+ else {
13623
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
13624
+ }
13625
+ issues++;
13626
+ }
13627
+ }
13164
13628
  }
13165
13629
  // 7a. Resolve which agent identities the two verified-read sections below
13166
13630
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
@@ -13560,7 +14024,8 @@ program
13560
14024
  // from /HealthDetail at all — it's the one metric in this file that requires
13561
14025
  // live QUERIES, because it's checking whether querying itself still works.
13562
14026
  // For a sample of the querying agent's OWN memories (fetchRecallSpotCheckData
13563
- // below, GET /Memory?agentId=<self>), a CUE is derived from each memory
14027
+ // below, a projected+bounded GET /Memory flair#1360: never the unfiltered
14028
+ // collection with embeddings inline), a CUE is derived from each memory
13564
14029
  // (deriveRecallCue — its `subject` if present, else the leading ~8 words /
13565
14030
  // first sentence of `content`; a PARTIAL cue, never the full content) and
13566
14031
  // searched for through the EXACT SAME authenticated read path `flair memory
@@ -13573,23 +14038,42 @@ program
13573
14038
  // own id appears in its search's top-k; MRR = mean reciprocal rank (0 if
13574
14039
  // not found within k).
13575
14040
  //
13576
- // Framing — this is a HEALTH SPOT-CHECK, not a benchmark and not a trust
13577
- // judgment. Querying by a cue derived FROM the target memory is easier than
13578
- // a real user query, so a high score means "recall is functioning", not
13579
- // "recall is optimal". Its job is to catch recall CRATERING (embeddings
13580
- // down, index busted) the score collapsing toward 0 is the signal, not
13581
- // fine-grained precision grading. NOTE (#1216): this cue-from-the-memory
13582
- // design is self-polluting as a recall-QUALITY metric — relevance is
13583
- // query/corpus overlap by construction, so near-duplicate density reads as a
13584
- // recall collapse (flair#967 / #857 / #996). It is deliberately NOT the
13585
- // recall-quality number; that authority is the deterministic, fixed-label,
13586
- // CI-gated eval at test/bench/recall-eval (recall@k / nDCG@10 / MRR). This
13587
- // probe stays scoped to live-health cratering only. Requires an actual
13588
- // agent identity to
13589
- // query AS (semantic search is agent-scoped) no identity, fewer than the
13590
- // sample-size memories to sample, or a search error all degrade to `null` +
13591
- // a `gaps` entry, same graceful-degradation contract as every metric here
13592
- // NEVER a false 0.0 masquerading as a real (broken) score.
14041
+ // Framing — this is a REPORT-ONLY HEALTH SPOT-CHECK, not a benchmark, not a
14042
+ // trust judgment, and (since flair#967) not an alerting signal either.
14043
+ // Querying by a cue derived FROM the target memory is easier than a real
14044
+ // user query, so a high score means "recall is functioning", not "recall is
14045
+ // optimal". NOTE (#1216): this cue-from-the-memory design is self-polluting
14046
+ // as a recall-QUALITY metric relevance is query/corpus overlap by
14047
+ // construction, so near-duplicate density reads as a recall collapse
14048
+ // (flair#967 / #857 / #996). It is deliberately NOT the recall-quality
14049
+ // number; that authority is the deterministic, fixed-label, CI-gated eval at
14050
+ // test/bench/recall-eval, wired as a gate in
14051
+ // test/integration-heavy/recall-eval-gate.test.ts.
14052
+ //
14053
+ // flair#967 WHY THIS METRIC NO LONGER EMITS AN EVENT. Measured on rockit
14054
+ // production over 32 nightly runs: population σ = 0.291, mean absolute
14055
+ // run-to-run delta = 0.223, against a QUALITY_EVENT_RECALL_DROP_THRESHOLD of
14056
+ // 0.2. The alarm sat at 0.69σ BELOW the metric's own noise floor, so the
14057
+ // median night-to-night wobble already exceeded the delta that declared a
14058
+ // regression. Replaying diffQualitySnapshots over the stored snapshot series
14059
+ // predicts the sweep's 6 findings-mails in 34 runs exactly, 6 for 6, and all
14060
+ // six were oscillation: lifetime precision 0. So the emission is gone. The
14061
+ // score is still computed, still printed, still snapshotted (history and the
14062
+ // cratering signal are both preserved) — it just no longer has the authority
14063
+ // to page anyone, because it never once earned it. That authority stays with
14064
+ // the deterministic CI gate above, which is fixed-label, hermetic and
14065
+ // actually detects ranking regressions. Re-arming this probe is a data
14066
+ // question, not a taste question: it needs a measured precision on the FIXED
14067
+ // cue derivation first, and a threshold DERIVED from that run-to-run variance
14068
+ // (≥2σ on the sample design), not another literal.
14069
+ //
14070
+ // Requires an actual agent identity to query AS (semantic search is
14071
+ // agent-scoped) — no identity, fewer than the sample-size memories to sample,
14072
+ // an UNHEALTHY sample (planRecallSpotCheck below: duplicate or empty cues, so
14073
+ // the window cannot be scored fairly) or a search error all degrade to `null`
14074
+ // + a `gaps` entry, same graceful-degradation contract as every metric here —
14075
+ // NEVER a false 0.0 masquerading as a real (broken) score, and never a number
14076
+ // quietly computed over a window that could not produce one.
13593
14077
  /** First-pass default, same "documented heuristic, not derived from data we
13594
14078
  * don't have" spirit as health.ts's own 10%-hash-fallback threshold below.
13595
14079
  * Tunable later if a real fleet shows this is too loud/quiet. */
@@ -13604,17 +14088,108 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
13604
14088
  * "first-pass default, tunable later" spirit as the thresholds above. */
13605
14089
  export const QUALITY_RECALL_SAMPLE_SIZE = 10;
13606
14090
  export const QUALITY_RECALL_K = 5;
14091
+ /**
14092
+ * Fields the recall spot-check and the quality-snapshot lookup actually
14093
+ * read. Harper REST `select(...)` (same syntax adk-flair-js's listMemories
14094
+ * already uses) projects these server-side so the nightly sweep never
14095
+ * pulls embedding vectors inline — the defect in flair#1360 was an
14096
+ * unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
14097
+ * vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
14098
+ * 10 memories. `type` is intentionally omitted: it is not a declared
14099
+ * Memory column (see schemas/memory.graphql); snapshot exclusion keys
14100
+ * off `subject` (`quality-snapshot/…`).
14101
+ */
14102
+ export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
14103
+ /**
14104
+ * Extra most-recent rows fetched beyond `sampleSize` so
14105
+ * `planRecallSpotCheck` can drop the sweep's own quality-snapshot
14106
+ * bookkeeping and still fill a 10-row window — without scanning the
14107
+ * table. Nightly `--emit` writes one snapshot per run; 16 is a buffer
14108
+ * for a few extra `--emit`s in the same recency window, not a second
14109
+ * full-table read.
14110
+ */
14111
+ export const QUALITY_RECALL_SNAPSHOT_OVERFETCH = 16;
14112
+ /**
14113
+ * Harper REST collection path for the recall spot-check's sample fetch:
14114
+ * agent-scoped, projected (never `embedding`), recency-sorted, bounded.
14115
+ * `limit(start,end)` is Harper's offset window — same as
14116
+ * packages/adk-flair-js/src/memory_service.ts.
14117
+ */
14118
+ export function qualityRecallSamplePath(agentId, sampleSize = QUALITY_RECALL_SAMPLE_SIZE) {
14119
+ const select = QUALITY_MEMORY_LIST_SELECT.join(",");
14120
+ const end = sampleSize + QUALITY_RECALL_SNAPSHOT_OVERFETCH;
14121
+ return `/Memory?agentId=${encodeURIComponent(agentId)}&select(${select})&sort(-createdAt)&limit(0,${end})`;
14122
+ }
14123
+ /**
14124
+ * Harper REST collection path for the previous quality-snapshot lookup:
14125
+ * same projection as the sample fetch (never `embedding`). Subject is
14126
+ * passed as a query equals (indexed) plus a client-side re-filter —
14127
+ * Memory.search() historically did not turn bare query params into
14128
+ * conditions beyond the signed agent scope, so the client-side filter
14129
+ * in fetchPreviousQualitySnapshot stays as defense in depth. No `limit`:
14130
+ * a bounded window could miss yesterday's snapshot after a busy day of
14131
+ * writes, and without a reliable server-side subject pushdown that
14132
+ * would silently look like a first run.
14133
+ */
14134
+ export function qualitySnapshotLookupPath(agentId, subject) {
14135
+ const select = QUALITY_MEMORY_LIST_SELECT.join(",");
14136
+ return `/Memory?agentId=${encodeURIComponent(agentId)}&subject=${encodeURIComponent(subject)}&select(${select})&sort(-createdAt)`;
14137
+ }
14138
+ /** Leading-word cap on the content-derived cue. 25, matching the arm of the
14139
+ * flair#967 A/B that was actually measured (same 10 memories, same instance,
14140
+ * same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
14141
+ * content cue → 1.00 / 0.78). Still a PARTIAL cue by construction — capped,
14142
+ * never the whole memory for anything longer than the cap. */
14143
+ const RECALL_CUE_CONTENT_WORD_LIMIT = 25;
14144
+ /**
14145
+ * Is `subject` DISCRIMINATIVE enough to be handed to semantic search as a
14146
+ * query in its own right? (flair#967.)
14147
+ *
14148
+ * The old bar was `length >= 3`, which is a check on whether the subject
14149
+ * EXISTS, not on whether it is a query. Measured consequence: slug-shaped
14150
+ * subjects — `pr-1359`, `kern-2026-08-23`, the spot-check's own
14151
+ * `quality-snapshot/127.0.0.1:9926` — carry almost no semantic signal, so
14152
+ * searching one is a query for nothing in particular (searching `pr-1359` on
14153
+ * rockit production returned, as top-1, a review note about PR #1275 from five
14154
+ * days earlier). Worse, every memory sharing such a subject issues the
14155
+ * IDENTICAL query and gets the IDENTICAL result list, so siblings must
14156
+ * mutually displace each other and all but one are scored as misses no matter
14157
+ * how healthy retrieval is.
14158
+ *
14159
+ * The rule, stated plainly — a subject is used as the cue only when it is:
14160
+ * 1. at least 3 characters (the original bar, kept), AND
14161
+ * 2. NOT opaque-identifier-shaped: an unspaced token carrying a digit or an
14162
+ * identifier separator (`/ : _ . # @ \`) is a slug, not a phrase.
14163
+ * Whitespace is the primary discriminator — `Harper 5.2 upgrade` is
14164
+ * prose and stays a cue; `kern-2026-08-23` is not. A bare hyphen does
14165
+ * NOT make a slug, so ordinary compounds (`two-gate`) survive, AND
14166
+ * 3. carrying at least one alphabetic run of 3+ characters — a subject with
14167
+ * no word in it (`---`, `42`) is not a query either.
14168
+ *
14169
+ * Fails CLOSED: anything that isn't clearly a phrase falls back to content,
14170
+ * which the A/B measured as the strictly better cue. Pure — no I/O.
14171
+ */
14172
+ export function isDiscriminativeSubject(subject) {
14173
+ const s = (subject ?? "").trim();
14174
+ if (s.length < 3)
14175
+ return false;
14176
+ if (!/\s/.test(s) && /[0-9/:_.#@\\]/.test(s))
14177
+ return false;
14178
+ if (!/[A-Za-z]{3}/.test(s))
14179
+ return false;
14180
+ return true;
14181
+ }
13607
14182
  /**
13608
14183
  * Derive a PARTIAL search cue from a memory — used by the recall spot-check
13609
14184
  * (Slice 1d) to query for a memory without handing back its full content.
13610
- * Prefers `subject` when present and non-trivial (a real word or phrase, not
13611
- * empty/whitespace-only padding); otherwise falls back to the first sentence
13612
- * of `content`, capped to the leading ~8 words so the cue stays a genuine
14185
+ * Prefers `subject` ONLY when it is discriminative (isDiscriminativeSubject
14186
+ * above — flair#967); otherwise falls back to the first sentence of
14187
+ * `content`, capped to the leading ~25 words so the cue stays a genuine
13613
14188
  * partial cue rather than the whole memory. Pure — no I/O.
13614
14189
  */
13615
14190
  export function deriveRecallCue(memory) {
13616
14191
  const subject = (memory.subject ?? "").trim();
13617
- if (subject.length >= 3)
14192
+ if (isDiscriminativeSubject(subject))
13618
14193
  return subject;
13619
14194
  const content = (memory.content ?? "").trim();
13620
14195
  if (!content)
@@ -13622,7 +14197,7 @@ export function deriveRecallCue(memory) {
13622
14197
  const sentenceMatch = content.match(/^[^.!?\n]+[.!?]?/);
13623
14198
  const firstSentence = (sentenceMatch ? sentenceMatch[0] : content).trim();
13624
14199
  const words = firstSentence.split(/\s+/).filter(Boolean);
13625
- const cueWordLimit = 8;
14200
+ const cueWordLimit = RECALL_CUE_CONTENT_WORD_LIMIT;
13626
14201
  return words.length <= cueWordLimit ? firstSentence : words.slice(0, cueWordLimit).join(" ");
13627
14202
  }
13628
14203
  /**
@@ -13659,6 +14234,71 @@ export function computeRecallSpotCheck(sampledIds, perQueryResultIds, k) {
13659
14234
  k,
13660
14235
  };
13661
14236
  }
14237
+ /** Rows the spot-check writes itself, and therefore must never grade itself
14238
+ * on — see RecallSpotCheckPlan['excludedSnapshotRows']. */
14239
+ function isQualitySnapshotRow(m) {
14240
+ return m?.type === "quality-snapshot" || (m?.subject ?? "").startsWith("quality-snapshot/");
14241
+ }
14242
+ /**
14243
+ * Pure planner for the recall spot-check: raw memory rows → the window to
14244
+ * query (id + cue) plus that window's health. Extracted from
14245
+ * fetchRecallSpotCheckData so the sampling, cue-derivation and
14246
+ * fail-closed health rules are testable without any I/O (flair#967).
14247
+ *
14248
+ * Order of operations, and why:
14249
+ * 1. drop the tool's own quality-snapshot rows (never grade your own
14250
+ * bookkeeping);
14251
+ * 2. take the `sampleSize` most-recently-written remaining rows (unchanged —
14252
+ * recency is still the sampling frame; see the issue's direction 3 for the
14253
+ * stratified-sampling follow-up this deliberately does NOT take on);
14254
+ * 3. derive each cue via deriveRecallCue;
14255
+ * 4. judge the window: any duplicate cue, or any empty cue, makes it
14256
+ * UNSCORABLE — reported as unhealthy, never silently scored.
14257
+ */
14258
+ export function planRecallSpotCheck(memories, opts = {}) {
14259
+ const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
14260
+ const rows = Array.isArray(memories) ? memories : [];
14261
+ const scorable = rows.filter((m) => !isQualitySnapshotRow(m ?? {}));
14262
+ const excludedSnapshotRows = rows.length - scorable.length;
14263
+ const sorted = scorable.slice().sort((a, b) => {
14264
+ const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
14265
+ const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
14266
+ return tb - ta;
14267
+ });
14268
+ const sampled = sorted.slice(0, sampleSize).map((m) => ({ id: String(m?.id), cue: deriveRecallCue(m ?? {}) }));
14269
+ const counts = new Map();
14270
+ let emptyCueCount = 0;
14271
+ for (const s of sampled) {
14272
+ if (!s.cue) {
14273
+ emptyCueCount += 1;
14274
+ continue;
14275
+ }
14276
+ counts.set(s.cue, (counts.get(s.cue) ?? 0) + 1);
14277
+ }
14278
+ const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
14279
+ if (duplicateCues.length === 0 && emptyCueCount === 0) {
14280
+ return { sampled, health: { healthy: true }, excludedSnapshotRows };
14281
+ }
14282
+ const parts = [];
14283
+ if (duplicateCues.length > 0) {
14284
+ const shown = duplicateCues.slice(0, 3).map((c) => `"${c.length > 60 ? `${c.slice(0, 57)}...` : c}"`).join(", ");
14285
+ const dupMemberCount = duplicateCues.reduce((n, c) => n + (counts.get(c) ?? 0), 0);
14286
+ parts.push(`${dupMemberCount} of the ${sampled.length} sampled memories derive the same cue as another (${shown}${duplicateCues.length > 3 ? `, +${duplicateCues.length - 3} more` : ""}) — identical cues are one query with one result list, so those memories must displace each other and cannot all be found`);
14287
+ }
14288
+ if (emptyCueCount > 0) {
14289
+ parts.push(`${emptyCueCount} of the ${sampled.length} sampled memories have no derivable cue (no subject and no content)`);
14290
+ }
14291
+ return {
14292
+ sampled,
14293
+ health: {
14294
+ healthy: false,
14295
+ reason: `sample unhealthy — ${parts.join("; ")}. No score recorded for this run (flair#967: fail closed rather than publish an unscorable number).`,
14296
+ duplicateCues,
14297
+ emptyCueCount,
14298
+ },
14299
+ excludedSnapshotRows,
14300
+ };
14301
+ }
13662
14302
  /**
13663
14303
  * Pure computation: /HealthDetail response (+ reachability) → quality report.
13664
14304
  * Never throws — every missing data source degrades to a null section + a
@@ -13856,50 +14496,55 @@ export function computeQualityReport(healthy, healthData, opts = {}) {
13856
14496
  * `agentId`'s own memories and, for each, search for a cue derived from it.
13857
14497
  * Reuses the EXACT read path `flair memory search` / `flair memory list`
13858
14498
  * use — `api()` (→ authedRequest's 5-tier resolver) for both the
13859
- * `GET /Memory?agentId=...` sample fetch and the `POST /SemanticSearch`
13860
- * queriesso this has zero new endpoint and zero new auth mechanism; it
13861
- * is scoped to `agentId`'s own memories exactly as those commands already
13862
- * are. Never throws: every failure mode (no agentId, fewer than
13863
- * `sampleSize` memories, a fetch/search error) returns `{ ok: false,
13864
- * skipReason }` for computeQualityReport to turn into a `gaps` entry.
14499
+ * projected, bounded `GET /Memory?…&select(…)&limit(…)` sample fetch
14500
+ * (flair#1360never the unfiltered collection with embeddings inline)
14501
+ * and the `POST /SemanticSearch` queries so this has zero new endpoint
14502
+ * and zero new auth mechanism; it is scoped to `agentId`'s own memories
14503
+ * exactly as those commands already are. Never throws: every failure mode
14504
+ * (no agentId, fewer than `sampleSize` memories, a fetch/search error)
14505
+ * returns `{ ok: false, skipReason }` for computeQualityReport to turn
14506
+ * into a `gaps` entry.
13865
14507
  */
13866
- async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14508
+ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
13867
14509
  const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
13868
14510
  const k = opts.k ?? QUALITY_RECALL_K;
14511
+ const request = opts.request ?? api;
13869
14512
  if (!agentId) {
13870
14513
  return { ok: false, skipReason: "no agent identity to query as — pass --agent or set FLAIR_AGENT_ID" };
13871
14514
  }
13872
14515
  let all;
13873
14516
  try {
13874
- const q = new URLSearchParams({ agentId }).toString();
13875
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
14517
+ const raw = await request("GET", qualityRecallSamplePath(agentId, sampleSize), undefined, { baseUrl, agentId });
13876
14518
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
13877
14519
  }
13878
14520
  catch (err) {
13879
14521
  return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
13880
14522
  }
13881
- if (all.length < sampleSize) {
14523
+ // Deterministic sample + cue derivation + fail-closed health judgment, all
14524
+ // pure (planRecallSpotCheck above). Snapshot rows are excluded there, so the
14525
+ // "enough memories" check has to run on the PLANNED window, not on the raw
14526
+ // row count — an instance whose recent writes are mostly the sweep's own
14527
+ // bookkeeping should skip with a reason, not score a short window.
14528
+ const plan = planRecallSpotCheck(all, { sampleSize });
14529
+ if (plan.sampled.length < sampleSize) {
14530
+ const excluded = plan.excludedSnapshotRows > 0 ? ` (${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping)` : "";
13882
14531
  return {
13883
14532
  ok: false,
13884
14533
  agentId,
13885
- skipReason: `agent '${agentId}' has ${all.length} memories, fewer than the ${sampleSize} needed to sample`,
14534
+ skipReason: `agent '${agentId}' has ${plan.sampled.length} scorable memories, fewer than the ${sampleSize} needed to sample${excluded}`,
13886
14535
  };
13887
14536
  }
13888
- // Deterministic sample: the sampleSize most-recently-written memories.
13889
- const sorted = all.slice().sort((a, b) => {
13890
- const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
13891
- const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
13892
- return tb - ta;
13893
- });
13894
- const sampled = sorted.slice(0, sampleSize);
14537
+ // flair#967: a window whose cues collide cannot be scored fairly — report
14538
+ // that fact instead of a number, and don't spend the searches either.
14539
+ if (!plan.health.healthy) {
14540
+ return { ok: false, agentId, skipReason: plan.health.reason, sampleHealth: plan.health };
14541
+ }
13895
14542
  const sampledIds = [];
13896
14543
  const perQueryResultIds = [];
13897
14544
  try {
13898
- for (const m of sampled) {
13899
- const id = String(m.id);
13900
- const cue = deriveRecallCue(m);
14545
+ for (const { id, cue } of plan.sampled) {
13901
14546
  const body = { agentId, q: cue, limit: k };
13902
- const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
14547
+ const res = await request("POST", "/SemanticSearch", body, { baseUrl, agentId });
13903
14548
  const results = Array.isArray(res) ? res : (res?.results ?? []);
13904
14549
  sampledIds.push(id);
13905
14550
  perQueryResultIds.push(results.map((r) => String(r.id)));
@@ -13908,7 +14553,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
13908
14553
  catch (err) {
13909
14554
  return { ok: false, agentId, skipReason: `recall spot-check search failed: ${err?.message ?? String(err)}` };
13910
14555
  }
13911
- return { ok: true, agentId, sampledIds, perQueryResultIds, k };
14556
+ return { ok: true, agentId, sampledIds, perQueryResultIds, k, sampleHealth: plan.health };
13912
14557
  }
13913
14558
  // ─── flair quality --emit (Slice 2 of the memory-quality-observability arc:
13914
14559
  // quality OrgEvents) ─────────────────────────────────────────────────────────
@@ -13958,6 +14603,22 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
13958
14603
  export const QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT = 90;
13959
14604
  export const QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT = 5;
13960
14605
  export const QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT = 10;
14606
+ /**
14607
+ * RETAINED AT ITS ORIGINAL VALUE AND DELIBERATELY UNWIRED (flair#967).
14608
+ *
14609
+ * Nothing in diffQualitySnapshots reads this any more — the recall spot-check
14610
+ * is report-only and emits no event at any delta (see the Slice 1d framing in
14611
+ * the module doc for the 32-run σ = 0.291 / precision-0 measurement behind
14612
+ * that). The constant stays, unchanged at 0.2, as the standing evidence that
14613
+ * the fix was "remove alerting authority from a metric that never earned it",
14614
+ * NOT "widen the gate until it stops talking" — a silenced check and a
14615
+ * de-authorised one look identical in a changelog and are opposites in
14616
+ * practice, and 0.2 sitting here at 0.69σ is the arithmetic that makes the
14617
+ * difference legible. If this probe is ever re-armed, the replacement
14618
+ * threshold must be DERIVED from the measured run-to-run variance of the
14619
+ * FIXED cue derivation, not typed in — do not just re-reference this literal.
14620
+ * Asserted unchanged by test/unit/quality-recall-spotcheck-967.test.ts.
14621
+ */
13961
14622
  export const QUALITY_EVENT_RECALL_DROP_THRESHOLD = 0.2;
13962
14623
  export const QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD = 0.5; // >50%
13963
14624
  export const QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD = 5; // AND by >= 5 clusters
@@ -14024,29 +14685,30 @@ export function diffQualitySnapshots(current, previous) {
14024
14685
  });
14025
14686
  }
14026
14687
  }
14027
- // ── recall spot-check: recall@k and MRR, same delta-drop threshold ──
14028
- if (current.recallSpotCheck && previous.recallSpotCheck) {
14029
- const beforeR = previous.recallSpotCheck.recallAtK;
14030
- const afterR = current.recallSpotCheck.recallAtK;
14031
- if (beforeR - afterR > QUALITY_EVENT_RECALL_DROP_THRESHOLD) {
14032
- findings.push({
14033
- kind: "quality.regression",
14034
- scope: "quality",
14035
- summary: `recall spot-check recall@k dropped from ${beforeR} to ${afterR} since last snapshot`,
14036
- detail: { metric: "recallSpotCheck.recallAtK", before: beforeR, after: afterR, threshold: QUALITY_EVENT_RECALL_DROP_THRESHOLD },
14037
- });
14038
- }
14039
- const beforeM = previous.recallSpotCheck.mrr;
14040
- const afterM = current.recallSpotCheck.mrr;
14041
- if (beforeM - afterM > QUALITY_EVENT_RECALL_DROP_THRESHOLD) {
14042
- findings.push({
14043
- kind: "quality.regression",
14044
- scope: "quality",
14045
- summary: `recall spot-check MRR dropped from ${beforeM} to ${afterM} since last snapshot`,
14046
- detail: { metric: "recallSpotCheck.mrr", before: beforeM, after: afterM, threshold: QUALITY_EVENT_RECALL_DROP_THRESHOLD },
14047
- });
14048
- }
14049
- }
14688
+ // ── recall spot-check: REPORT-ONLY, no branch here on purpose (flair#967) ──
14689
+ //
14690
+ // This metric used to emit two quality.regression events (recall@k and MRR,
14691
+ // both at QUALITY_EVENT_RECALL_DROP_THRESHOLD). It no longer emits anything,
14692
+ // at any delta. Measured, on rockit production:
14693
+ //
14694
+ // 32 nightly runs · population σ 0.291 · mean |run-to-run delta| 0.223
14695
+ // threshold 0.2 → 0.69σ, i.e. BELOW the metric's own noise floor
14696
+ // 6 findings-mails in 34 runs, replay-predicted 6/6 from these branches,
14697
+ // all 6 oscillation → lifetime precision 0
14698
+ //
14699
+ // Removing an emission is not the same move as raising a threshold, and the
14700
+ // distinction is the whole point: raising 0.2 would leave a check that still
14701
+ // claims to detect recall regressions while detecting none, whereas this
14702
+ // hands that job to the instrument that can actually do it — the
14703
+ // deterministic, fixed-label, CI-gated eval in
14704
+ // test/integration-heavy/recall-eval-gate.test.ts (test/bench/recall-eval),
14705
+ // whose floors sit ≥2 whole queries below the measured value against a
14706
+ // 0.000 noise band. QUALITY_EVENT_RECALL_DROP_THRESHOLD is left at 0.2,
14707
+ // unwired, so that stays checkable rather than asserted.
14708
+ //
14709
+ // current.recallSpotCheck / previous.recallSpotCheck are still SNAPSHOTTED
14710
+ // (buildQualitySnapshot above) — the history that made this diagnosis
14711
+ // possible keeps accumulating, and `flair quality` still prints the number.
14050
14712
  // ── quiet agents: per-agent, NEWLY quiet only (was false last snapshot,
14051
14713
  // true now) — never re-fires for an agent that was already quiet last
14052
14714
  // snapshot, and never fires for an agent absent from the previous snapshot
@@ -14100,21 +14762,23 @@ export function qualitySnapshotSubject(baseUrl) {
14100
14762
  return `quality-snapshot/${host}`;
14101
14763
  }
14102
14764
  /** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
14103
- * via the exact same read path fetchRecallSpotCheckData uses (`api("GET",
14104
- * "/Memory?agentId=...")`, self-scoped by the signed request's own agent
14105
- * identity — no new endpoint). Filters client-side by subject (the server's
14106
- * `GET /Memory?...` doesn't translate query params into search conditions
14107
- * beyond the signed agentId scope see resources/Memory.ts's search()),
14108
- * same client-side-filter pattern `memory list --hash-fallback` already
14109
- * uses. Returns null on: no prior snapshot, a fetch error, or a snapshot row
14110
- * whose content isn't parseable/versioned JSON (never throws a corrupt or
14111
- * foreign row degrades to "no snapshot", same as a genuine first run, rather
14112
- * than crashing `--emit`). */
14113
- async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject) {
14765
+ * via the same signed `GET /Memory` read path fetchRecallSpotCheckData
14766
+ * uses (self-scoped by the signed request's own agent identity — no new
14767
+ * endpoint). Projects the same fields (never embeddings — flair#1360) and
14768
+ * asks for `subject` as a query equals; still filters client-side by
14769
+ * subject because Memory.search() historically did not turn bare query
14770
+ * params into search conditions beyond the signed agentId scope (see
14771
+ * resources/Memory.ts's search()), same client-side-filter pattern
14772
+ * `memory list --hash-fallback` already uses. Returns null on: no prior
14773
+ * snapshot, a fetch error, or a snapshot row whose content isn't
14774
+ * parseable/versioned JSON (never throws — a corrupt or foreign row
14775
+ * degrades to "no snapshot", same as a genuine first run, rather than
14776
+ * crashing `--emit`). */
14777
+ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, opts = {}) {
14778
+ const request = opts.request ?? api;
14114
14779
  let all;
14115
14780
  try {
14116
- const q = new URLSearchParams({ agentId }).toString();
14117
- const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
14781
+ const raw = await request("GET", qualitySnapshotLookupPath(agentId, subject), undefined, { baseUrl, agentId });
14118
14782
  all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
14119
14783
  }
14120
14784
  catch {
@@ -14226,6 +14890,12 @@ program
14226
14890
  const mode = render.resolveOutputMode(opts);
14227
14891
  if (mode === "json") {
14228
14892
  const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
14893
+ // flair#967: when a window was assembled, say whether it was scorable —
14894
+ // structurally, not only as prose inside a `gaps` reason. An unhealthy
14895
+ // sample is a FACT ABOUT THE RUN that a consumer must be able to read
14896
+ // without string-matching.
14897
+ if (recallSpotCheckData.sampleHealth)
14898
+ out.recallSampleHealth = recallSpotCheckData.sampleHealth;
14229
14899
  if (emitResult) {
14230
14900
  out.emit = { firstRun: emitResult.firstRun, snapshotId: emitResult.snapshotId, errors: emitResult.errors };
14231
14901
  out.emittedEvents = emitResult.emittedEvents.map((e) => ({
@@ -14339,15 +15009,15 @@ program
14339
15009
  console.log(render.kv("Clusters", `${render.wrap(render.c.bold, String(dc.clusterCount))} ${render.wrap(render.c.dim, `(${dc.totalMemoriesInClusters} memories, largest cluster ${dc.largestClusterSize})`)}`));
14340
15010
  console.log(` ${render.wrap(render.c.dim, "an ops signal — near-duplicate memories piling up, not a trust judgment")}`);
14341
15011
  }
14342
- // Recall spot-check (flair-quality Slice 1d) — a health SPOT-CHECK, not
14343
- // a benchmark or trust judgment: catches recall cratering (embeddings/
14344
- // index down), not a quality grade. See QualityReport['recallSpotCheck']
14345
- // doc for the full framing.
15012
+ // Recall spot-check (flair-quality Slice 1d) — a REPORT-ONLY health
15013
+ // spot-check: not a benchmark, not a trust judgment, and since flair#967
15014
+ // not an alerting signal either. See QualityReport['recallSpotCheck'] doc
15015
+ // and the Slice 1d module doc for the full framing.
14346
15016
  if (report.recallSpotCheck) {
14347
15017
  const rc = report.recallSpotCheck;
14348
- console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"}, health signal — not a benchmark)`)}`);
15018
+ console.log(`\n${render.wrap(render.c.bold, "Recall spot-check")} ${render.wrap(render.c.dim, `(agent ${rc.agentId ?? "—"}, report-only — not a benchmark, not an alert)`)}`);
14349
15019
  console.log(render.kv(`recall@${rc.k}`, `${render.wrap(render.c.bold, rc.recallAtK.toFixed(2))} ${render.wrap(render.c.dim, `(MRR ${rc.mrr.toFixed(2)}, ${rc.sampleSize} sampled)`)}`));
14350
- console.log(` ${render.wrap(render.c.dim, "catches recall cratering (embeddings/index down) a high score means recall is functioning, not that it's optimal")}`);
15020
+ console.log(` ${render.wrap(render.c.dim, "observability only — recall REGRESSIONS are detected by the deterministic CI gate (test/bench/recall-eval), not by this number")}`);
14351
15021
  }
14352
15022
  // Gaps
14353
15023
  if (report.gaps.length > 0) {
@@ -14576,9 +15246,8 @@ function parseEntitiesOptionOrExit(csv) {
14576
15246
  }
14577
15247
  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`)";
14578
15248
  const memory = program.command("memory").description("Manage agent memories");
14579
- memory.command("add [content]")
14580
- .description("Write a new memory row for an agent (content via positional arg or --content)")
14581
- .requiredOption("--agent <id>")
15249
+ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]")
15250
+ .description("Write a new memory row for an agent (content via positional arg or --content)")))
14582
15251
  .option("--content <text>", "memory content (alias for positional arg)")
14583
15252
  .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>")
14584
15253
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
@@ -14592,10 +15261,15 @@ memory.command("add [content]")
14592
15261
  console.error("error: content required (positional arg or --content)");
14593
15262
  process.exit(1);
14594
15263
  }
14595
- const agentId = resolveSigningAgentId(opts, "memory add") ?? opts.agent;
14596
- const memId = `${opts.agent}-${Date.now()}`;
15264
+ applyAdminPassFile(opts);
15265
+ const agentId = resolveSigningAgentId(opts, "memory add");
15266
+ if (!agentId) {
15267
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15268
+ process.exit(2);
15269
+ }
15270
+ const memId = `${agentId}-${Date.now()}`;
14597
15271
  const body = {
14598
- id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
15272
+ id: memId, agentId, content, durability: opts.durability || "standard",
14599
15273
  tags: opts.tags ? String(opts.tags).split(",").map((x) => x.trim()).filter(Boolean) : undefined,
14600
15274
  type: "memory", createdAt: new Date().toISOString(),
14601
15275
  };
@@ -14628,7 +15302,11 @@ memory.command("add [content]")
14628
15302
  if (entities.length > 0)
14629
15303
  body.entities = entities;
14630
15304
  }
14631
- const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
15305
+ const out = await api("PUT", `/Memory/${memId}`, body, {
15306
+ agentId,
15307
+ explicitAdminPass: opts.adminPass,
15308
+ adminUser: opts.adminUser,
15309
+ });
14632
15310
  console.log(JSON.stringify(out, null, 2));
14633
15311
  });
14634
15312
  // ─── flair memory write-task-summary ────────────────────────────────────────
@@ -14906,7 +15584,7 @@ memory.command("hygiene")
14906
15584
  const enabled = new Set((opts.pattern ?? "compact-id,test-content,tiny").split(",").map((s) => s.trim()).filter(Boolean));
14907
15585
  const tinyThreshold = Math.max(0, Number(opts.tinyThreshold) || 25);
14908
15586
  const apply = !!opts.apply;
14909
- const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
15587
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
14910
15588
  async function ops(body) {
14911
15589
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
14912
15590
  method: "POST",
@@ -15874,7 +16552,14 @@ bridge
15874
16552
  process.exit(2);
15875
16553
  }
15876
16554
  try {
15877
- const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
16555
+ const result = await runRoundTrip({
16556
+ descriptor: loaded.descriptor,
16557
+ cwd,
16558
+ fixturePath: opts.fixture,
16559
+ // Keep the intermediate export so a failure can print a live path.
16560
+ // The next harness start sweeps leftovers older than a minute (flair#1032).
16561
+ retainTmpDir: true,
16562
+ });
15878
16563
  if (opts.json) {
15879
16564
  console.log(JSON.stringify(result, null, 2));
15880
16565
  process.exit(result.passed ? 0 : 1);
@@ -16081,31 +16766,20 @@ function printTrustError(detail) {
16081
16766
  }
16082
16767
  }
16083
16768
  // ─── flair backup ────────────────────────────────────────────────────────────
16084
- program
16769
+ addSharedCredentialOptions(program
16085
16770
  .command("backup")
16086
16771
  .description("Export agents, memories, and souls to a JSON archive")
16087
16772
  .option("--output <path>", "Output file path (default: ~/.flair/backups/flair-backup-<timestamp>.json)")
16088
16773
  .option("--agents <ids>", "Comma-separated agent IDs to include (default: all)")
16089
16774
  .option("--port <port>", "Harper HTTP port")
16090
- .option("--url <url>", "Flair base URL (overrides --port)")
16091
- .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
16092
- .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.")
16093
- .action(async (opts) => {
16775
+ .option("--url <url>", "Flair base URL (overrides --port)")).action(async (opts) => {
16094
16776
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
16095
- let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16096
- if (!adminPass && opts.adminPassFile) {
16097
- // readAdminPassFileSecure refuses world/group readable files (mode 0600
16098
- // recommended). Common gotcha: files generated via
16099
- // `openssl rand -base64 24 > admin-pass` end in a newline; helper trims it.
16100
- try {
16101
- adminPass = readAdminPassFileSecure(opts.adminPassFile);
16102
- }
16103
- catch (err) {
16104
- console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
16105
- process.exit(1);
16106
- }
16107
- }
16108
- const adminUser = DEFAULT_ADMIN_USER;
16777
+ applyAdminPassFile(opts);
16778
+ // Env is a second-class fallback after the explicit flags (same order
16779
+ // backup used before the shared helper). FLAIR_ADMIN_PASS is still
16780
+ // accepted so existing scripts keep working.
16781
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16782
+ const adminUser = resolveAdminUser(opts.adminUser);
16109
16783
  if (!adminPass) {
16110
16784
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
16111
16785
  process.exit(1);
@@ -16194,11 +16868,12 @@ program
16194
16868
  .option("--port <port>", "Harper HTTP port")
16195
16869
  .option("--url <url>", "Flair base URL (overrides --port)")
16196
16870
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
16871
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16197
16872
  .option("--dry-run", "Show what would be imported without making changes")
16198
16873
  .action(async (backupPath, opts) => {
16199
16874
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
16200
16875
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16201
- const adminUser = DEFAULT_ADMIN_USER;
16876
+ const adminUser = resolveAdminUser(opts.adminUser);
16202
16877
  const dryRun = Boolean(opts.dryRun);
16203
16878
  const mode = opts.replace ? "replace" : "merge";
16204
16879
  if (!adminPass) {
@@ -16311,6 +16986,7 @@ program
16311
16986
  .option("--port <port>", "Harper HTTP port")
16312
16987
  .option("--url <url>", "Flair base URL (overrides --port)")
16313
16988
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
16989
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16314
16990
  .option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
16315
16991
  .action(async (agentId, opts) => {
16316
16992
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
@@ -16319,7 +16995,7 @@ program
16319
16995
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
16320
16996
  process.exit(1);
16321
16997
  }
16322
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
16998
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
16323
16999
  async function adminGet(path) {
16324
17000
  const res = await fetch(`${baseUrl}${path}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(10_000) });
16325
17001
  if (!res.ok)
@@ -16402,6 +17078,7 @@ program
16402
17078
  .option("--url <url>", "Flair base URL (overrides --port)")
16403
17079
  .option("--ops-target <url>", "Explicit ops API URL for the Agent seed (env: FLAIR_OPS_TARGET; bypasses port derivation). Use when --url is remote and the ops port isn't HTTP-1.")
16404
17080
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
17081
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16405
17082
  .option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
16406
17083
  .action(async (importPath, opts) => {
16407
17084
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
@@ -16462,12 +17139,12 @@ program
16462
17139
  : nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed.subarray(0, 32))).publicKey;
16463
17140
  const pubKeyB64url = b64url(pubKey);
16464
17141
  // Register agent via ops API (remote when --url/--ops-target points off-box)
16465
- await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, DEFAULT_ADMIN_USER, adminPass);
17142
+ await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, resolveAdminUser(opts.adminUser), adminPass);
16466
17143
  console.log(typeof seedOpsTarget === "string"
16467
17144
  ? ` Agent registered (ops: ${seedOpsTarget})`
16468
17145
  : ` Agent registered`);
16469
17146
  // Restore memories
16470
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
17147
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
16471
17148
  let memCount = 0;
16472
17149
  for (const mem of data.memories ?? []) {
16473
17150
  try {
@@ -16748,7 +17425,7 @@ program
16748
17425
  createdAt: new Date().toISOString(),
16749
17426
  };
16750
17427
  const memoryPath = `/Memory/${memoryId}`;
16751
- const auth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
17428
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
16752
17429
  const res = await fetch(`${httpUrl}${memoryPath}`, {
16753
17430
  method: "PUT",
16754
17431
  headers: {
@@ -17222,7 +17899,7 @@ if (import.meta.main) {
17222
17899
  // ─── Exported for testing ─────────────────────────────────────────────────────
17223
17900
  export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
17224
17901
  // Harper's own config — the per-instance port record (flair#914)
17225
- harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, FABRIC_OPS_PORT, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
17902
+ harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, FABRIC_OPS_PORT, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure, DEFAULT_ADMIN_USER, resolveAdminUser,
17226
17903
  // launchd label (flair#693)
17227
17904
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
17228
17905
  // launchd management observation (flair#1022)