@tpsdev-ai/flair 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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, } 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.
@@ -1905,6 +1906,27 @@ function readHarperPid(dataDir) {
1905
1906
  return null;
1906
1907
  }
1907
1908
  }
1909
+ /**
1910
+ * flair#1345 — Harper returns the SAME 401 `{"error":"Login failed"}` for a
1911
+ * wrong password and for a nonexistent username, and the CLI's errors used
1912
+ * to hint only at the password. On an instance whose superuser is not named
1913
+ * `admin` (now reachable in practice: the #604/#610 `authorizeLocal: false`
1914
+ * hardening removed the credential-less loopback path, so these calls MUST
1915
+ * send real Basic auth) that sent operators down the wrong trail entirely.
1916
+ * Name both causes, each with the knob that fixes it.
1917
+ */
1918
+ function opsAuth401Hint(adminUser) {
1919
+ if (adminUser === undefined) {
1920
+ // No credentials were sent at all (local caller riding authorizeLocal) —
1921
+ // "wrong password or username" would be asserting a cause that isn't
1922
+ // established. The remedy is to send credentials.
1923
+ return ("\n No admin credentials were sent and the instance rejected the request." +
1924
+ "\n Pass --admin-pass <pass> or --admin-pass-file <path> (and --admin-user <name> if the superuser is not 'admin').");
1925
+ }
1926
+ return (`\n The operations API rejected the admin credentials (tried username '${adminUser}'). Two possible causes:` +
1927
+ "\n - wrong password — check --admin-pass / --admin-pass-file / FLAIR_ADMIN_PASS" +
1928
+ `\n - wrong username — this instance's superuser may not be '${adminUser}'; pass --admin-user <name> or set FLAIR_ADMIN_USER`);
1929
+ }
1908
1930
  /**
1909
1931
  * Seed an agent record via the Harper operations API.
1910
1932
  * Accepts either a port number (localhost) or a full URL string (--target).
@@ -1958,6 +1980,9 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
1958
1980
  const text = await res.text().catch(() => "");
1959
1981
  if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
1960
1982
  return;
1983
+ if (res.status === 401) {
1984
+ throw new Error(`Operations API insert failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
1985
+ }
1961
1986
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
1962
1987
  }
1963
1988
  }
@@ -2009,6 +2034,9 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
2009
2034
  const text = await res.text().catch(() => "");
2010
2035
  if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
2011
2036
  return;
2037
+ if (res.status === 401) {
2038
+ throw new Error(`Federation Instance insert via ops API failed (401): ${text}${opsAuth401Hint(auth === undefined ? undefined : adminUser)}`);
2039
+ }
2012
2040
  throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
2013
2041
  }
2014
2042
  }
@@ -2917,11 +2945,12 @@ program
2917
2945
  .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
2946
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
2919
2947
  .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
2948
+ .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
2949
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
2921
2950
  .option("--data-dir <dir>", "Harper data directory")
2922
2951
  .option("--skip-start", "Skip Harper startup (assume already running)")
2923
2952
  .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")
2953
+ .option("--client <client>", "Client(s) to wire: claude-code, codex, gemini, cursor, antigravity, pi (native extension), all, or none")
2925
2954
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
2926
2955
  .option("--skip-smoke", "Skip the MCP smoke test")
2927
2956
  .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
@@ -3005,7 +3034,10 @@ program
3005
3034
  }
3006
3035
  flairAdminPass = opts.adminPass;
3007
3036
  }
3008
- const adminUser = DEFAULT_ADMIN_USER;
3037
+ // flair#1345: only the already-running-instance leg honors --admin-user /
3038
+ // FLAIR_ADMIN_USER — the provisioning leg just CREATED the superuser as
3039
+ // DEFAULT_ADMIN_USER via provisionFabric, so that name is ground truth.
3040
+ const adminUser = didProvision ? DEFAULT_ADMIN_USER : resolveAdminUser(opts.adminUser);
3009
3041
  const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
3010
3042
  const role = opts.remote ? "hub" : undefined;
3011
3043
  // Generate or reuse keypair (only if --agent-id provided, or --remote needs
@@ -3140,9 +3172,9 @@ program
3140
3172
  const noMcp = opts.mcp === false;
3141
3173
  const selectedClients = [];
3142
3174
  if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
3143
- const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
3175
+ const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity", "pi"];
3144
3176
  if (!valid.includes(clientOpt)) {
3145
- console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, all, none`);
3177
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, pi, all, none`);
3146
3178
  process.exit(1);
3147
3179
  }
3148
3180
  selectedClients.push(clientOpt);
@@ -3773,6 +3805,13 @@ program
3773
3805
  case "antigravity":
3774
3806
  result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
3775
3807
  break;
3808
+ // pi is a NATIVE EXTENSION, not an MCP client (flair#1342):
3809
+ // wirePi edits ~/.pi/agent/settings.json `packages`, and pi
3810
+ // settings carry no env block — no FLAIR_CLIENT to stamp; the
3811
+ // wire message tells the user what to export at pi launch.
3812
+ case "pi":
3813
+ result = wirePi(mcpEnv);
3814
+ break;
3776
3815
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
3777
3816
  }
3778
3817
  wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
@@ -3784,7 +3823,12 @@ program
3784
3823
  // Launch flair-mcp and confirm it answers a JSON-RPC initialize over
3785
3824
  // stdio. Best-effort: failures warn but never fail the command. Skipped
3786
3825
  // with --skip-smoke, --no-mcp, --client none, or when nothing was wired.
3787
- if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
3826
+ // pi doesn't run flair-mcp (native extension, flair#1342), so a pi-only
3827
+ // wiring has nothing this smoke test exercises — spawning it anyway
3828
+ // would render a green "MCP server responded" for a setup that never
3829
+ // starts an MCP server.
3830
+ const wiredAnyMcpClient = wiringResults.some((r) => r.client !== "pi");
3831
+ if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0 && wiredAnyMcpClient) {
3788
3832
  console.log("\n Smoke-testing MCP server...");
3789
3833
  try {
3790
3834
  // Same spec that gets WIRED above — the smoke test must exercise the
@@ -3934,6 +3978,7 @@ agent
3934
3978
  .option("--port <port>", "Harper HTTP port")
3935
3979
  .option("--admin-pass <pass>", "Admin password for registration")
3936
3980
  .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).")
3981
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
3937
3982
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
3938
3983
  .option("--ops-port <port>", "Harper operations API port")
3939
3984
  .option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
@@ -3942,7 +3987,7 @@ agent
3942
3987
  const httpPort = resolveHttpPort(opts);
3943
3988
  const opsPort = resolveOpsPort(opts);
3944
3989
  const keysDir = opts.keysDir ?? defaultKeysDir();
3945
- const adminUser = DEFAULT_ADMIN_USER;
3990
+ const adminUser = resolveAdminUser(opts.adminUser);
3946
3991
  const name = opts.name ?? id;
3947
3992
  // Where to seed the Agent record. Default is localhost (opsPort). When
3948
3993
  // --ops-target or --target is given, seed on the remote instead of localhost
@@ -4026,6 +4071,7 @@ agent
4026
4071
  .command("list")
4027
4072
  .description("List all agents")
4028
4073
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
4074
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
4029
4075
  .option("--agent <id>", "Agent ID to authenticate as via Ed25519 (or FLAIR_AGENT_ID env) when no admin pass")
4030
4076
  .option("--keys-dir <dir>", "Directory holding the agent's Ed25519 key")
4031
4077
  .option("--port <port>", "Harper HTTP port")
@@ -4043,7 +4089,7 @@ agent
4043
4089
  let agents;
4044
4090
  if (adminPass) {
4045
4091
  const opsPort = resolveOpsPort(opts);
4046
- const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
4092
+ const auth = Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64");
4047
4093
  // List every Agent without null-scanning the primary key. A
4048
4094
  // `starts_with ""` on `id` makes Harper search the index for nulls, which
4049
4095
  // the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
@@ -4163,6 +4209,7 @@ agent
4163
4209
  .option("--port <port>", "Harper HTTP port")
4164
4210
  .option("--ops-port <port>", "Harper operations API port")
4165
4211
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
4212
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
4166
4213
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
4167
4214
  .action(async (id, opts) => {
4168
4215
  const httpPort = resolveHttpPort(opts);
@@ -4174,7 +4221,7 @@ agent
4174
4221
  "to keep secrets out of shell history.");
4175
4222
  }
4176
4223
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
4177
- const adminUser = DEFAULT_ADMIN_USER;
4224
+ const adminUser = resolveAdminUser(opts.adminUser);
4178
4225
  const keysDir = opts.keysDir ?? defaultKeysDir();
4179
4226
  if (!adminPass) {
4180
4227
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for key rotation");
@@ -4247,12 +4294,13 @@ agent
4247
4294
  .option("--port <port>", "Harper HTTP port")
4248
4295
  .option("--ops-port <port>", "Harper operations API port")
4249
4296
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
4297
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
4250
4298
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
4251
4299
  .option("--force", "Skip interactive confirmation (required when stdin is not a TTY)")
4252
4300
  .action(async (id, opts) => {
4253
4301
  const opsPort = resolveOpsPort(opts);
4254
4302
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
4255
- const adminUser = DEFAULT_ADMIN_USER;
4303
+ const adminUser = resolveAdminUser(opts.adminUser);
4256
4304
  const keysDir = opts.keysDir ?? defaultKeysDir();
4257
4305
  if (!adminPass) {
4258
4306
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for agent remove");
@@ -4669,8 +4717,15 @@ hook
4669
4717
  process.exit(1);
4670
4718
  }
4671
4719
  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)")}`);
4720
+ // flair#1325 skip the URL line only when agentId was recovered
4721
+ // (the installer form that omits FLAIR_URL). A wired correct-shape
4722
+ // command with no env assignments still prints unknown, not a
4723
+ // silent all-clear.
4724
+ for (const line of hookStatusIdentityLines(status)) {
4725
+ const label = line.label === "Agent" ? "Agent: " : "Flair URL:";
4726
+ const value = line.value === HOOK_STATUS_UNPARSED ? render.wrap(render.c.dim, line.value) : line.value;
4727
+ console.log(` ${render.wrap(render.c.dim, label)} ${value}`);
4728
+ }
4674
4729
  // flair#1007 — whether a command that stopped resolving would fail quietly
4675
4730
  // or print an error on every session start.
4676
4731
  if (status.silenced) {
@@ -5026,6 +5081,7 @@ mcp
5026
5081
  .option("--keys-dir <dir>", "Directory to write the new key pair into (else FLAIR_KEY_DIR, ~/.flair/keys)")
5027
5082
  .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
5028
5083
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
5084
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5029
5085
  .option("--port <port>", "Harper HTTP port")
5030
5086
  .option("--ops-port <port>", "Harper operations API port")
5031
5087
  .option("--json", "Print machine-readable JSON instead of a human summary")
@@ -5061,7 +5117,7 @@ mcp
5061
5117
  manifestPath,
5062
5118
  issuer,
5063
5119
  opsPortOrUrl: opsPort,
5064
- adminUser: DEFAULT_ADMIN_USER,
5120
+ adminUser: resolveAdminUser(opts.adminUser),
5065
5121
  adminPass,
5066
5122
  });
5067
5123
  if (opts.json) {
@@ -5086,6 +5142,7 @@ mcp
5086
5142
  .description("Server-side revoke a granted machine client (deletes its backing Agent record), then clean up locally.")
5087
5143
  .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
5088
5144
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
5145
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5089
5146
  .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
5147
  .option("--ops-port <port>", "Harper operations API port")
5091
5148
  .option("--port <port>", "Harper HTTP port")
@@ -5114,7 +5171,7 @@ mcp
5114
5171
  name,
5115
5172
  manifestPath,
5116
5173
  opsPortOrUrl: opsPort,
5117
- adminUser: DEFAULT_ADMIN_USER,
5174
+ adminUser: resolveAdminUser(opts.adminUser),
5118
5175
  adminPass,
5119
5176
  keepKeys: !!opts.keepKeys,
5120
5177
  });
@@ -5205,6 +5262,7 @@ mcp
5205
5262
  .option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
5206
5263
  .option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
5207
5264
  .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")
5265
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5208
5266
  .option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
5209
5267
  .option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
5210
5268
  .option("--json", "Print machine-readable JSON instead of a human summary")
@@ -5264,7 +5322,7 @@ mcp
5264
5322
  idpSubject,
5265
5323
  principal: opts.principal,
5266
5324
  principalKind: opts.principalKind,
5267
- adminUser: DEFAULT_ADMIN_USER,
5325
+ adminUser: resolveAdminUser(opts.adminUser),
5268
5326
  adminPass,
5269
5327
  signingKeyFilePath: opts.signingKeyFile,
5270
5328
  secretsMechanism,
@@ -5313,6 +5371,7 @@ mcp
5313
5371
  .description("Flag off + restart = byte-identical boot (Model-2 contract) — removes the /mcp OAuth surface.")
5314
5372
  .option("--instance <url>", "Remote flair instance to disable against (else FLAIR_URL)")
5315
5373
  .option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
5374
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5316
5375
  .option("--confirm-flag-off", "Confirm FLAIR_MCP_OAUTH is already unset on the target instance's environment (skips the interactive confirm)")
5317
5376
  .option("--json", "Print machine-readable JSON instead of a human summary")
5318
5377
  .action(async (opts) => {
@@ -5328,7 +5387,7 @@ mcp
5328
5387
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required.");
5329
5388
  process.exit(1);
5330
5389
  }
5331
- const result = await disableMcp({ instance, adminUser: DEFAULT_ADMIN_USER, adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
5390
+ const result = await disableMcp({ instance, adminUser: resolveAdminUser(opts.adminUser), adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
5332
5391
  if (opts.json) {
5333
5392
  console.log(render.asJSON(result));
5334
5393
  if (!result.ok)
@@ -5399,12 +5458,13 @@ principal
5399
5458
  .option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
5400
5459
  .option("--port <port>", "Harper HTTP port")
5401
5460
  .option("--admin-pass <pass>", "Admin password for registration")
5461
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5402
5462
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
5403
5463
  .option("--ops-port <port>", "Harper operations API port")
5404
5464
  .action(async (id, opts) => {
5405
5465
  const opsPort = resolveOpsPort(opts);
5406
5466
  const keysDir = opts.keysDir ?? defaultKeysDir();
5407
- const adminUser = DEFAULT_ADMIN_USER;
5467
+ const adminUser = resolveAdminUser(opts.adminUser);
5408
5468
  const kind = opts.kind ?? "agent";
5409
5469
  const name = opts.name ?? id;
5410
5470
  const isAdmin = opts.admin ?? false;
@@ -5491,6 +5551,7 @@ principal
5491
5551
  .description("List all principals")
5492
5552
  .option("--kind <kind>", "Filter by kind: human or agent")
5493
5553
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
5554
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5494
5555
  .option("--port <port>", "Harper HTTP port")
5495
5556
  .option("--ops-port <port>", "Harper operations API port")
5496
5557
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
@@ -5501,7 +5562,7 @@ principal
5501
5562
  console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
5502
5563
  process.exit(1);
5503
5564
  }
5504
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5565
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5505
5566
  const conditions = opts.kind
5506
5567
  ? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
5507
5568
  : [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
@@ -5623,6 +5684,7 @@ principal
5623
5684
  .command("disable <id>")
5624
5685
  .description("Deactivate a principal (revokes access, preserves data)")
5625
5686
  .option("--admin-pass <pass>", "Admin password")
5687
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5626
5688
  .option("--ops-port <port>", "Harper operations API port")
5627
5689
  .action(async (id, opts) => {
5628
5690
  const opsPort = resolveOpsPort(opts);
@@ -5631,7 +5693,7 @@ principal
5631
5693
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
5632
5694
  process.exit(1);
5633
5695
  }
5634
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5696
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5635
5697
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5636
5698
  method: "POST",
5637
5699
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5653,6 +5715,7 @@ principal
5653
5715
  .command("promote <id> <tier>")
5654
5716
  .description("Change a principal's trust tier (endorsed, corroborated, unverified)")
5655
5717
  .option("--admin-pass <pass>", "Admin password")
5718
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5656
5719
  .option("--ops-port <port>", "Harper operations API port")
5657
5720
  .action(async (id, tier, opts) => {
5658
5721
  const validTiers = ["endorsed", "corroborated", "unverified"];
@@ -5666,7 +5729,7 @@ principal
5666
5729
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
5667
5730
  process.exit(1);
5668
5731
  }
5669
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5732
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5670
5733
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5671
5734
  method: "POST",
5672
5735
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5698,6 +5761,7 @@ idp
5698
5761
  .option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
5699
5762
  .option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
5700
5763
  .option("--admin-pass <pass>", "Admin password")
5764
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5701
5765
  .option("--ops-port <port>", "Harper operations API port")
5702
5766
  .action(async (opts) => {
5703
5767
  const opsPort = resolveOpsPort(opts);
@@ -5707,7 +5771,7 @@ idp
5707
5771
  process.exit(1);
5708
5772
  }
5709
5773
  const id = `idp_${randomUUID().slice(0, 8)}`;
5710
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5774
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5711
5775
  const now = new Date().toISOString();
5712
5776
  const record = {
5713
5777
  id,
@@ -5744,6 +5808,7 @@ idp
5744
5808
  .command("list")
5745
5809
  .description("List configured IdPs")
5746
5810
  .option("--admin-pass <pass>", "Admin password")
5811
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5747
5812
  .option("--ops-port <port>", "Harper operations API port")
5748
5813
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
5749
5814
  .action(async (opts) => {
@@ -5753,7 +5818,7 @@ idp
5753
5818
  console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
5754
5819
  process.exit(1);
5755
5820
  }
5756
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5821
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5757
5822
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5758
5823
  method: "POST",
5759
5824
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5798,6 +5863,7 @@ idp
5798
5863
  .command("remove <id>")
5799
5864
  .description("Remove an IdP configuration")
5800
5865
  .option("--admin-pass <pass>", "Admin password")
5866
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5801
5867
  .option("--ops-port <port>", "Harper operations API port")
5802
5868
  .action(async (id, opts) => {
5803
5869
  const opsPort = resolveOpsPort(opts);
@@ -5806,7 +5872,7 @@ idp
5806
5872
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
5807
5873
  process.exit(1);
5808
5874
  }
5809
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5875
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5810
5876
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5811
5877
  method: "POST",
5812
5878
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -5865,12 +5931,13 @@ program
5865
5931
  .option("--port <port>", "Harper HTTP port")
5866
5932
  .option("--ops-port <port>", "Harper operations API port")
5867
5933
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
5934
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5868
5935
  .option("--keys-dir <dir>", "Directory for Ed25519 keys (for from-agent Ed25519 auth)")
5869
5936
  .action(async (fromAgent, toAgent, opts) => {
5870
5937
  const httpPort = resolveHttpPort(opts);
5871
5938
  const opsPort = resolveOpsPort(opts);
5872
5939
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
5873
- const adminUser = DEFAULT_ADMIN_USER;
5940
+ const adminUser = resolveAdminUser(opts.adminUser);
5874
5941
  const scope = opts.scope ?? "read";
5875
5942
  if (!adminPass) {
5876
5943
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for grant");
@@ -5914,11 +5981,12 @@ program
5914
5981
  .option("--port <port>", "Harper HTTP port")
5915
5982
  .option("--ops-port <port>", "Harper operations API port")
5916
5983
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
5984
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
5917
5985
  .action(async (fromAgent, toAgent, opts) => {
5918
5986
  const httpPort = resolveHttpPort(opts);
5919
5987
  const opsPort = resolveOpsPort(opts);
5920
5988
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
5921
- const adminUser = DEFAULT_ADMIN_USER;
5989
+ const adminUser = resolveAdminUser(opts.adminUser);
5922
5990
  if (!adminPass) {
5923
5991
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for revoke");
5924
5992
  process.exit(1);
@@ -5962,7 +6030,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
5962
6030
  // Fallback: check DB for legacy _keySeed
5963
6031
  const opsPort = resolveOpsPort(opts);
5964
6032
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
5965
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6033
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
5966
6034
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
5967
6035
  method: "POST",
5968
6036
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -6459,6 +6527,7 @@ federation
6459
6527
  .description("Pair this spoke with a hub instance")
6460
6528
  .option("--port <port>", "Harper HTTP port")
6461
6529
  .option("--admin-pass <pass>", "Admin password")
6530
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
6462
6531
  .option("--ops-port <port>", "Harper operations API port")
6463
6532
  .option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN) [deprecated: use --token-from]")
6464
6533
  .option("--token-from <file>", "Read bootstrap triple from JSON file (use '-' for stdin)")
@@ -6536,7 +6605,7 @@ federation
6536
6605
  "Without it, 'flair federation sync' will report 'No hub peer configured'.");
6537
6606
  process.exit(1);
6538
6607
  }
6539
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6608
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
6540
6609
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
6541
6610
  const peerRes = await fetch(`${opsEndpoint}/`, {
6542
6611
  method: "POST",
@@ -6573,6 +6642,7 @@ federation
6573
6642
  .description("Generate a one-time pairing token (run on the hub)")
6574
6643
  .option("--port <port>", "Harper HTTP port")
6575
6644
  .option("--admin-pass <pass>", "Admin password")
6645
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
6576
6646
  .option("--ops-port <port>", "Harper operations API port")
6577
6647
  .option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
6578
6648
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
@@ -6587,7 +6657,7 @@ federation
6587
6657
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
6588
6658
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
6589
6659
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
6590
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6660
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
6591
6661
  // 1. Persist the PairingToken record
6592
6662
  const opsRes = await fetch(`${opsEndpoint}/`, {
6593
6663
  method: "POST",
@@ -6692,7 +6762,7 @@ export async function runFederationSyncOnce(opts) {
6692
6762
  const syncStartedAt = new Date().toISOString();
6693
6763
  const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
6694
6764
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
6695
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
6765
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
6696
6766
  const tables = ["Memory", "Soul", "Agent", "Relationship"];
6697
6767
  const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
6698
6768
  const hubUrl = hub.endpoint ?? hub.id;
@@ -6940,6 +7010,7 @@ const federationSync = federation
6940
7010
  .option("--port <port>", "Harper HTTP port")
6941
7011
  .option("--admin-pass <pass>", "Admin password")
6942
7012
  .option("--admin-pass-file <path>", "Read the admin password from a file (e.g. ~/.flair/admin-pass). Preferred for launchd/cron — keeps the secret out of ps and shell history.")
7013
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
6943
7014
  .option("--ops-port <port>", "Harper operations API port")
6944
7015
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
6945
7016
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -7140,6 +7211,7 @@ federation
7140
7211
  .option("--interval <seconds>", "Seconds between syncs", "30")
7141
7212
  .option("--port <port>", "Harper HTTP port")
7142
7213
  .option("--admin-pass <pass>", "Admin password")
7214
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
7143
7215
  .option("--ops-port <port>", "Harper operations API port")
7144
7216
  .option("--target <url>", "Remote Flair URL")
7145
7217
  .option("--ops-target <url>", "Explicit ops API URL")
@@ -8666,7 +8738,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
8666
8738
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
8667
8739
  if (adminPass) {
8668
8740
  res = await fetch(`${baseUrl}/Health`, {
8669
- headers: { Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}` },
8741
+ headers: { Authorization: `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}` },
8670
8742
  signal: AbortSignal.timeout(5000),
8671
8743
  });
8672
8744
  }
@@ -8718,8 +8790,10 @@ const statusCmd = program
8718
8790
  discoveredPort = await discoverLocalFlairPort(baseUrl);
8719
8791
  }
8720
8792
  // 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.
8793
+ // never fails `status` when the registry is unreachable, and costs no
8794
+ // network round trip on the common up-to-date path. When a cached answer
8795
+ // would print a nudge it spends one short-timeout refetch so the printed
8796
+ // fact is current (flair#1341). Independent of Harper health; runs either way.
8723
8797
  const versionCheckResult = await checkVersion(__pkgVersion);
8724
8798
  const versionNudge = formatVersionNudge(versionCheckResult);
8725
8799
  if (opts.json) {
@@ -9211,7 +9285,7 @@ statusCmd
9211
9285
  }
9212
9286
  }
9213
9287
  else {
9214
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
9288
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
9215
9289
  const maxTokens = Number.parseInt(String(opts.maxTokens ?? "4000"), 10);
9216
9290
  for (const agentId of agentList) {
9217
9291
  try {
@@ -11754,7 +11828,7 @@ program
11754
11828
  // embedding with a freshly-computed one. Without this path, `flair
11755
11829
  // reembed` could not recover from the very condition it exists to fix.
11756
11830
  const opsPort = resolveOpsPort(opts);
11757
- const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
11831
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
11758
11832
  // Harper rejects empty-value conditions ("not indexed for nulls"). Use
11759
11833
  // `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
11760
11834
  // has a createdAt, the index is built, and the comparison is total.
@@ -11852,7 +11926,7 @@ program
11852
11926
  let allMemories = [];
11853
11927
  if (adminPassSingle) {
11854
11928
  const opsPort = resolveOpsPort(opts);
11855
- const opsAuth = `Basic ${Buffer.from(`admin:${adminPassSingle}`).toString("base64")}`;
11929
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPassSingle}`).toString("base64")}`;
11856
11930
  const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
11857
11931
  method: "POST",
11858
11932
  headers: { "Content-Type": "application/json", Authorization: opsAuth },
@@ -11940,7 +12014,10 @@ program
11940
12014
  console.error(`${render.icons.error} ${render.wrap(render.c.red, "set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS")}`);
11941
12015
  process.exit(1);
11942
12016
  }
11943
- const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
12017
+ // Single source of truth (flair#1351): banner prints the URL the test's
12018
+ // own client uses. resolveBaseUrl is the existing CLI resolver; pass the
12019
+ // same value through to api() so the two cannot diverge.
12020
+ const baseUrl = resolveBaseUrl(opts);
11944
12021
  console.log(`\n${render.wrap(render.c.bold, "Flair test")} ${render.wrap(render.c.dim, `(url: ${baseUrl})`)}\n`);
11945
12022
  let passed = 0;
11946
12023
  let failed = 0;
@@ -11975,7 +12052,7 @@ program
11975
12052
  };
11976
12053
  if (agentId)
11977
12054
  body.agentId = agentId;
11978
- await api("PUT", `/Memory/${id}`, body);
12055
+ await api("PUT", `/Memory/${id}`, body, { baseUrl });
11979
12056
  memoryId = id;
11980
12057
  return true;
11981
12058
  });
@@ -11985,7 +12062,7 @@ program
11985
12062
  const body = { q: "flair test", limit: 5 };
11986
12063
  if (agentId)
11987
12064
  body.agentId = agentId;
11988
- const result = await api("POST", "/SemanticSearch", body);
12065
+ const result = await api("POST", "/SemanticSearch", body, { baseUrl });
11989
12066
  return (result?.results?.length ?? 0) > 0;
11990
12067
  });
11991
12068
  // 3. Delete the test memory via DELETE /Memory/<id>
@@ -11995,7 +12072,7 @@ program
11995
12072
  console.log(` (skipped — no id returned from write step)`);
11996
12073
  return true;
11997
12074
  }
11998
- await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
12075
+ await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined, { baseUrl });
11999
12076
  return true;
12000
12077
  });
12001
12078
  const passColor = passed > 0 ? render.c.green : render.c.dim;
@@ -12666,6 +12743,9 @@ program
12666
12743
  catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
12667
12744
  // The component directory for a local install is the flair package itself:
12668
12745
  // `flair start` spawns `harper run .` with cwd = flairPackageDir().
12746
+ // That path is often inside node_modules on an npm install-g; doctor still
12747
+ // READs it for drift detection, but describePublicUrlFinding never names
12748
+ // it as the fix (flair#1313 — wiped on every upgrade).
12669
12749
  const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
12670
12750
  let componentEnvValue = null;
12671
12751
  try {
@@ -12755,7 +12835,7 @@ program
12755
12835
  }
12756
12836
  const auditStatus = auditCredIssue
12757
12837
  ? { 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);
12838
+ : await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, resolveAdminUser(undefined), auditAdminPass);
12759
12839
  switch (auditStatus.state) {
12760
12840
  case "ok":
12761
12841
  // Present-tense claim ONLY (see AuditVerifyResult): the probe
@@ -12834,11 +12914,13 @@ program
12834
12914
  }
12835
12915
  // 7. Client integration (flair#588) — the first 6 checks diagnose the
12836
12916
  // 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.
12917
+ // client: for MCP clients (Claude Code, Codex, Gemini, Cursor,
12918
+ // Antigravity) the MCP block present + reachable + the configured agent
12919
+ // genuinely registered; for pi (a NATIVE EXTENSION host flair#1342) the
12920
+ // pi-flair reference in pi's own settings, including the flair#1346
12921
+ // npm:-under-"extensions" trap; plus CLAUDE.md + the SessionStart hook
12922
+ // (Claude Code only, since only Claude Code has those mechanisms). Reuses
12923
+ // detectClients() rather than reimplementing client detection.
12842
12924
  console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
12843
12925
  // Prompt y/N before a content-editing fix, but only when interactive —
12844
12926
  // in a non-TTY context (CI, scripts) --fix itself is the consent signal,
@@ -12870,6 +12952,170 @@ program
12870
12952
  }
12871
12953
  }
12872
12954
  for (const client of detectedClients) {
12955
+ // ── pi (flair#1342): NATIVE EXTENSION, not an MCP client ───────────
12956
+ // There is no mcpServers block to read — pi loads @tpsdev-ai/pi-flair
12957
+ // through its own settings.json (`packages`). Every check below is a
12958
+ // filesystem fact except agent registration, which is only checkable
12959
+ // when this shell exposes the env pi would launch with — and the
12960
+ // output says which of the two it verified.
12961
+ if (client.kind === "native-extension") {
12962
+ let pi = checkPiFlairWiring(homedir(), process.cwd());
12963
+ // --fix for pi needs no agent id (pi settings carry no env block);
12964
+ // a resolvable id only improves the export hint in the message.
12965
+ const wirePiFix = async (prompt) => {
12966
+ if (dryRun) {
12967
+ console.log(` ${render.wrap(render.c.dim, "Would update")} ${pi.settingsPath}`);
12968
+ return;
12969
+ }
12970
+ const proceed = await confirmFix(prompt);
12971
+ if (!proceed) {
12972
+ console.log(` Skipped.`);
12973
+ return;
12974
+ }
12975
+ const hintAgentId = resolveFixAgentId({
12976
+ optsAgent: opts.agent,
12977
+ envAgentId: process.env.FLAIR_AGENT_ID,
12978
+ anyKnownAgentId,
12979
+ keyAgentIds,
12980
+ keysDir: defaultKeysDir(),
12981
+ }) ?? "<your-agent-id>";
12982
+ const wireResult = wirePi({ FLAIR_AGENT_ID: hintAgentId, FLAIR_URL: baseUrl });
12983
+ console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
12984
+ if (wireResult.ok)
12985
+ fixed++;
12986
+ };
12987
+ // (a) The flair#1346 trap FIRST, and by NAME: an npm: spec under
12988
+ // "extensions" is silently ignored by pi — the user believes they
12989
+ // are wired while pi registers zero tools. This is the documented
12990
+ // field failure mode and must never fold into a generic "not
12991
+ // wired": the fix is a MOVE to "packages", not an add.
12992
+ const userTraps = pi.misconfigured.filter((m) => m.path === pi.settingsPath);
12993
+ const projectTraps = pi.misconfigured.filter((m) => m.path !== pi.settingsPath);
12994
+ for (const bad of pi.misconfigured) {
12995
+ 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)}`);
12996
+ console.log(` pi silently ignores npm: specs under "extensions", so the Flair tools never register (flair#1346). Package sources belong under "packages".`);
12997
+ issues++;
12998
+ }
12999
+ if (userTraps.length > 0) {
13000
+ if (autoFix) {
13001
+ await wirePiFix(` Move the npm: spec to "packages" in ${pi.settingsPath} now? [y/N] `);
13002
+ // Re-derive the wiring from disk so the sections below reason
13003
+ // about the POST-fix state — otherwise a move that just
13004
+ // succeeded would still read as "not wired" and prompt again.
13005
+ pi = checkPiFlairWiring(homedir(), process.cwd());
13006
+ }
13007
+ else {
13008
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(moves it to "packages")`)}`);
13009
+ }
13010
+ }
13011
+ if (projectTraps.length > 0) {
13012
+ // wirePi edits the USER-scope settings only — a project-scope
13013
+ // trap gets the exact manual fix, never a --fix that claims a
13014
+ // file it does not touch.
13015
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} move the entry from "extensions" to "packages" in ${projectTraps[0].path}`);
13016
+ }
13017
+ if (!pi.wired) {
13018
+ console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} not wired in ${render.wrap(render.c.dim, pi.settingsPath)}`);
13019
+ if (autoFix) {
13020
+ await wirePiFix(` Wire pi now (adds ${piFlairSpec()} to "packages" in ${pi.settingsPath})? [y/N] `);
13021
+ }
13022
+ else {
13023
+ 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}`);
13024
+ }
13025
+ issues++;
13026
+ continue;
13027
+ }
13028
+ if (pi.wiredVia === "packages") {
13029
+ console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via "packages" (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
13030
+ if (!pi.pinnedVersion) {
13031
+ console.log(` ${render.icons.info} unpinned — pi re-resolves latest on (re)install; pin with ${piFlairSpec()}`);
13032
+ }
13033
+ }
13034
+ else {
13035
+ // extension-path: the documented pre-0.49 workaround (a local
13036
+ // path to the installed dist/index.js). Works, but the canonical
13037
+ // form is a "packages" entry — and a DANGLING path is a broken
13038
+ // wiring pi skips silently, so check the one thing checkable.
13039
+ if (pi.extensionPathExists) {
13040
+ 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)}`);
13041
+ console.log(` ${render.wrap(render.c.dim, `pre-0.49 workaround — the canonical form is a "packages" entry: ${piFlairSpec()}`)}`);
13042
+ }
13043
+ else {
13044
+ 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`);
13045
+ if (autoFix) {
13046
+ await wirePiFix(` Wire pi via "packages" instead (adds ${piFlairSpec()})? [y/N] `);
13047
+ }
13048
+ else {
13049
+ 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)`)}`);
13050
+ }
13051
+ issues++;
13052
+ continue;
13053
+ }
13054
+ }
13055
+ // Env sanity (flair#1342 scope 3). pi settings carry no env block:
13056
+ // pi-flair reads FLAIR_* from the environment of whatever shell/IDE
13057
+ // launches pi. Doctor can only see ITS OWN environment — these
13058
+ // lines verify this shell, and say so, rather than pretending to
13059
+ // verify every pi launch. None of them counts as an issue: a clean
13060
+ // pi launched elsewhere can be fine while this shell is bare, and
13061
+ // vice versa.
13062
+ 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):")}`);
13063
+ const piEnvAgent = process.env.FLAIR_AGENT_ID;
13064
+ const piEnvUrl = process.env.FLAIR_URL;
13065
+ const piEnvKey = process.env.FLAIR_KEY_PATH;
13066
+ if (piEnvAgent) {
13067
+ console.log(` ${render.icons.ok} FLAIR_AGENT_ID set ('${piEnvAgent}')`);
13068
+ }
13069
+ else {
13070
+ 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`);
13071
+ }
13072
+ if (piEnvUrl) {
13073
+ console.log(` ${render.icons.ok} FLAIR_URL set (${piEnvUrl})`);
13074
+ }
13075
+ else {
13076
+ console.log(` ${render.icons.info} FLAIR_URL not set — pi-flair defaults to ${render.wrap(render.c.dim, PI_FLAIR_DEFAULT_URL)}`);
13077
+ }
13078
+ if (piEnvKey) {
13079
+ if (existsSync(piEnvKey)) {
13080
+ console.log(` ${render.icons.ok} FLAIR_KEY_PATH set (${piEnvKey})`);
13081
+ }
13082
+ else {
13083
+ console.log(` ${render.icons.warn} FLAIR_KEY_PATH points at a missing file (${piEnvKey})`);
13084
+ }
13085
+ }
13086
+ else {
13087
+ console.log(` ${render.icons.info} FLAIR_KEY_PATH not set — auto-resolved from ~/.flair/keys`);
13088
+ }
13089
+ // Agent registration — checkable only when this shell exposes an
13090
+ // agent id at all; otherwise say what was NOT verified instead of
13091
+ // skipping silently.
13092
+ if (piEnvAgent) {
13093
+ const piUrl = piEnvUrl || PI_FLAIR_DEFAULT_URL;
13094
+ const piReachable = await probeFlairReachable(piUrl);
13095
+ if (!piReachable) {
13096
+ console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, piUrl)} not reachable — cannot verify agent registration`);
13097
+ }
13098
+ else {
13099
+ const piReg = await checkAgentRegistered(piUrl, piEnvAgent, defaultKeysDir());
13100
+ if (piReg.state === "registered") {
13101
+ console.log(` ${render.icons.ok} agent '${piEnvAgent}' registered`);
13102
+ }
13103
+ else if (piReg.state === "not-registered") {
13104
+ console.log(` ${render.icons.error} agent '${piEnvAgent}' is NOT registered on this Flair instance`);
13105
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${piEnvAgent}`);
13106
+ issues++;
13107
+ }
13108
+ else {
13109
+ const piFinding = describeAgentGateFinding(piEnvAgent, piReg.state, piReg.detail, { instanceReachable: piReachable });
13110
+ console.log(` ${render.icons.warn} ${piFinding?.message ?? `could not verify agent registration (${piReg.detail})`}`);
13111
+ }
13112
+ }
13113
+ }
13114
+ else {
13115
+ console.log(` ${render.wrap(render.c.dim, "agent registration not verified — no FLAIR_AGENT_ID visible to doctor")}`);
13116
+ }
13117
+ continue;
13118
+ }
12873
13119
  const block = readClientMcpBlock(client.id, homedir());
12874
13120
  if (client.id === "claude-code" && block.agentId)
12875
13121
  claudeCodeAgentId = block.agentId;
@@ -13573,23 +13819,42 @@ program
13573
13819
  // own id appears in its search's top-k; MRR = mean reciprocal rank (0 if
13574
13820
  // not found within k).
13575
13821
  //
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.
13822
+ // Framing — this is a REPORT-ONLY HEALTH SPOT-CHECK, not a benchmark, not a
13823
+ // trust judgment, and (since flair#967) not an alerting signal either.
13824
+ // Querying by a cue derived FROM the target memory is easier than a real
13825
+ // user query, so a high score means "recall is functioning", not "recall is
13826
+ // optimal". NOTE (#1216): this cue-from-the-memory design is self-polluting
13827
+ // as a recall-QUALITY metric relevance is query/corpus overlap by
13828
+ // construction, so near-duplicate density reads as a recall collapse
13829
+ // (flair#967 / #857 / #996). It is deliberately NOT the recall-quality
13830
+ // number; that authority is the deterministic, fixed-label, CI-gated eval at
13831
+ // test/bench/recall-eval, wired as a gate in
13832
+ // test/integration-heavy/recall-eval-gate.test.ts.
13833
+ //
13834
+ // flair#967 WHY THIS METRIC NO LONGER EMITS AN EVENT. Measured on rockit
13835
+ // production over 32 nightly runs: population σ = 0.291, mean absolute
13836
+ // run-to-run delta = 0.223, against a QUALITY_EVENT_RECALL_DROP_THRESHOLD of
13837
+ // 0.2. The alarm sat at 0.69σ BELOW the metric's own noise floor, so the
13838
+ // median night-to-night wobble already exceeded the delta that declared a
13839
+ // regression. Replaying diffQualitySnapshots over the stored snapshot series
13840
+ // predicts the sweep's 6 findings-mails in 34 runs exactly, 6 for 6, and all
13841
+ // six were oscillation: lifetime precision 0. So the emission is gone. The
13842
+ // score is still computed, still printed, still snapshotted (history and the
13843
+ // cratering signal are both preserved) — it just no longer has the authority
13844
+ // to page anyone, because it never once earned it. That authority stays with
13845
+ // the deterministic CI gate above, which is fixed-label, hermetic and
13846
+ // actually detects ranking regressions. Re-arming this probe is a data
13847
+ // question, not a taste question: it needs a measured precision on the FIXED
13848
+ // cue derivation first, and a threshold DERIVED from that run-to-run variance
13849
+ // (≥2σ on the sample design), not another literal.
13850
+ //
13851
+ // Requires an actual agent identity to query AS (semantic search is
13852
+ // agent-scoped) — no identity, fewer than the sample-size memories to sample,
13853
+ // an UNHEALTHY sample (planRecallSpotCheck below: duplicate or empty cues, so
13854
+ // the window cannot be scored fairly) or a search error all degrade to `null`
13855
+ // + a `gaps` entry, same graceful-degradation contract as every metric here —
13856
+ // NEVER a false 0.0 masquerading as a real (broken) score, and never a number
13857
+ // quietly computed over a window that could not produce one.
13593
13858
  /** First-pass default, same "documented heuristic, not derived from data we
13594
13859
  * don't have" spirit as health.ts's own 10%-hash-fallback threshold below.
13595
13860
  * Tunable later if a real fleet shows this is too loud/quiet. */
@@ -13604,17 +13869,61 @@ export const QUALITY_HASH_FALLBACK_DEGRADED_PCT = 10;
13604
13869
  * "first-pass default, tunable later" spirit as the thresholds above. */
13605
13870
  export const QUALITY_RECALL_SAMPLE_SIZE = 10;
13606
13871
  export const QUALITY_RECALL_K = 5;
13872
+ /** Leading-word cap on the content-derived cue. 25, matching the arm of the
13873
+ * flair#967 A/B that was actually measured (same 10 memories, same instance,
13874
+ * same minute: subject cue → recall@5 0.60 / MRR 0.16; first-25-words-of-
13875
+ * content cue → 1.00 / 0.78). Still a PARTIAL cue by construction — capped,
13876
+ * never the whole memory for anything longer than the cap. */
13877
+ const RECALL_CUE_CONTENT_WORD_LIMIT = 25;
13878
+ /**
13879
+ * Is `subject` DISCRIMINATIVE enough to be handed to semantic search as a
13880
+ * query in its own right? (flair#967.)
13881
+ *
13882
+ * The old bar was `length >= 3`, which is a check on whether the subject
13883
+ * EXISTS, not on whether it is a query. Measured consequence: slug-shaped
13884
+ * subjects — `pr-1359`, `kern-2026-08-23`, the spot-check's own
13885
+ * `quality-snapshot/127.0.0.1:9926` — carry almost no semantic signal, so
13886
+ * searching one is a query for nothing in particular (searching `pr-1359` on
13887
+ * rockit production returned, as top-1, a review note about PR #1275 from five
13888
+ * days earlier). Worse, every memory sharing such a subject issues the
13889
+ * IDENTICAL query and gets the IDENTICAL result list, so siblings must
13890
+ * mutually displace each other and all but one are scored as misses no matter
13891
+ * how healthy retrieval is.
13892
+ *
13893
+ * The rule, stated plainly — a subject is used as the cue only when it is:
13894
+ * 1. at least 3 characters (the original bar, kept), AND
13895
+ * 2. NOT opaque-identifier-shaped: an unspaced token carrying a digit or an
13896
+ * identifier separator (`/ : _ . # @ \`) is a slug, not a phrase.
13897
+ * Whitespace is the primary discriminator — `Harper 5.2 upgrade` is
13898
+ * prose and stays a cue; `kern-2026-08-23` is not. A bare hyphen does
13899
+ * NOT make a slug, so ordinary compounds (`two-gate`) survive, AND
13900
+ * 3. carrying at least one alphabetic run of 3+ characters — a subject with
13901
+ * no word in it (`---`, `42`) is not a query either.
13902
+ *
13903
+ * Fails CLOSED: anything that isn't clearly a phrase falls back to content,
13904
+ * which the A/B measured as the strictly better cue. Pure — no I/O.
13905
+ */
13906
+ export function isDiscriminativeSubject(subject) {
13907
+ const s = (subject ?? "").trim();
13908
+ if (s.length < 3)
13909
+ return false;
13910
+ if (!/\s/.test(s) && /[0-9/:_.#@\\]/.test(s))
13911
+ return false;
13912
+ if (!/[A-Za-z]{3}/.test(s))
13913
+ return false;
13914
+ return true;
13915
+ }
13607
13916
  /**
13608
13917
  * Derive a PARTIAL search cue from a memory — used by the recall spot-check
13609
13918
  * (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
13919
+ * Prefers `subject` ONLY when it is discriminative (isDiscriminativeSubject
13920
+ * above — flair#967); otherwise falls back to the first sentence of
13921
+ * `content`, capped to the leading ~25 words so the cue stays a genuine
13613
13922
  * partial cue rather than the whole memory. Pure — no I/O.
13614
13923
  */
13615
13924
  export function deriveRecallCue(memory) {
13616
13925
  const subject = (memory.subject ?? "").trim();
13617
- if (subject.length >= 3)
13926
+ if (isDiscriminativeSubject(subject))
13618
13927
  return subject;
13619
13928
  const content = (memory.content ?? "").trim();
13620
13929
  if (!content)
@@ -13622,7 +13931,7 @@ export function deriveRecallCue(memory) {
13622
13931
  const sentenceMatch = content.match(/^[^.!?\n]+[.!?]?/);
13623
13932
  const firstSentence = (sentenceMatch ? sentenceMatch[0] : content).trim();
13624
13933
  const words = firstSentence.split(/\s+/).filter(Boolean);
13625
- const cueWordLimit = 8;
13934
+ const cueWordLimit = RECALL_CUE_CONTENT_WORD_LIMIT;
13626
13935
  return words.length <= cueWordLimit ? firstSentence : words.slice(0, cueWordLimit).join(" ");
13627
13936
  }
13628
13937
  /**
@@ -13659,6 +13968,71 @@ export function computeRecallSpotCheck(sampledIds, perQueryResultIds, k) {
13659
13968
  k,
13660
13969
  };
13661
13970
  }
13971
+ /** Rows the spot-check writes itself, and therefore must never grade itself
13972
+ * on — see RecallSpotCheckPlan['excludedSnapshotRows']. */
13973
+ function isQualitySnapshotRow(m) {
13974
+ return m?.type === "quality-snapshot" || (m?.subject ?? "").startsWith("quality-snapshot/");
13975
+ }
13976
+ /**
13977
+ * Pure planner for the recall spot-check: raw memory rows → the window to
13978
+ * query (id + cue) plus that window's health. Extracted from
13979
+ * fetchRecallSpotCheckData so the sampling, cue-derivation and
13980
+ * fail-closed health rules are testable without any I/O (flair#967).
13981
+ *
13982
+ * Order of operations, and why:
13983
+ * 1. drop the tool's own quality-snapshot rows (never grade your own
13984
+ * bookkeeping);
13985
+ * 2. take the `sampleSize` most-recently-written remaining rows (unchanged —
13986
+ * recency is still the sampling frame; see the issue's direction 3 for the
13987
+ * stratified-sampling follow-up this deliberately does NOT take on);
13988
+ * 3. derive each cue via deriveRecallCue;
13989
+ * 4. judge the window: any duplicate cue, or any empty cue, makes it
13990
+ * UNSCORABLE — reported as unhealthy, never silently scored.
13991
+ */
13992
+ export function planRecallSpotCheck(memories, opts = {}) {
13993
+ const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
13994
+ const rows = Array.isArray(memories) ? memories : [];
13995
+ const scorable = rows.filter((m) => !isQualitySnapshotRow(m ?? {}));
13996
+ const excludedSnapshotRows = rows.length - scorable.length;
13997
+ const sorted = scorable.slice().sort((a, b) => {
13998
+ const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
13999
+ const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
14000
+ return tb - ta;
14001
+ });
14002
+ const sampled = sorted.slice(0, sampleSize).map((m) => ({ id: String(m?.id), cue: deriveRecallCue(m ?? {}) }));
14003
+ const counts = new Map();
14004
+ let emptyCueCount = 0;
14005
+ for (const s of sampled) {
14006
+ if (!s.cue) {
14007
+ emptyCueCount += 1;
14008
+ continue;
14009
+ }
14010
+ counts.set(s.cue, (counts.get(s.cue) ?? 0) + 1);
14011
+ }
14012
+ const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
14013
+ if (duplicateCues.length === 0 && emptyCueCount === 0) {
14014
+ return { sampled, health: { healthy: true }, excludedSnapshotRows };
14015
+ }
14016
+ const parts = [];
14017
+ if (duplicateCues.length > 0) {
14018
+ const shown = duplicateCues.slice(0, 3).map((c) => `"${c.length > 60 ? `${c.slice(0, 57)}...` : c}"`).join(", ");
14019
+ const dupMemberCount = duplicateCues.reduce((n, c) => n + (counts.get(c) ?? 0), 0);
14020
+ 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`);
14021
+ }
14022
+ if (emptyCueCount > 0) {
14023
+ parts.push(`${emptyCueCount} of the ${sampled.length} sampled memories have no derivable cue (no subject and no content)`);
14024
+ }
14025
+ return {
14026
+ sampled,
14027
+ health: {
14028
+ healthy: false,
14029
+ reason: `sample unhealthy — ${parts.join("; ")}. No score recorded for this run (flair#967: fail closed rather than publish an unscorable number).`,
14030
+ duplicateCues,
14031
+ emptyCueCount,
14032
+ },
14033
+ excludedSnapshotRows,
14034
+ };
14035
+ }
13662
14036
  /**
13663
14037
  * Pure computation: /HealthDetail response (+ reachability) → quality report.
13664
14038
  * Never throws — every missing data source degrades to a null section + a
@@ -13878,26 +14252,29 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
13878
14252
  catch (err) {
13879
14253
  return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
13880
14254
  }
13881
- if (all.length < sampleSize) {
14255
+ // Deterministic sample + cue derivation + fail-closed health judgment, all
14256
+ // pure (planRecallSpotCheck above). Snapshot rows are excluded there, so the
14257
+ // "enough memories" check has to run on the PLANNED window, not on the raw
14258
+ // row count — an instance whose recent writes are mostly the sweep's own
14259
+ // bookkeeping should skip with a reason, not score a short window.
14260
+ const plan = planRecallSpotCheck(all, { sampleSize });
14261
+ if (plan.sampled.length < sampleSize) {
14262
+ const excluded = plan.excludedSnapshotRows > 0 ? ` (${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping)` : "";
13882
14263
  return {
13883
14264
  ok: false,
13884
14265
  agentId,
13885
- skipReason: `agent '${agentId}' has ${all.length} memories, fewer than the ${sampleSize} needed to sample`,
14266
+ skipReason: `agent '${agentId}' has ${plan.sampled.length} scorable memories, fewer than the ${sampleSize} needed to sample${excluded}`,
13886
14267
  };
13887
14268
  }
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);
14269
+ // flair#967: a window whose cues collide cannot be scored fairly — report
14270
+ // that fact instead of a number, and don't spend the searches either.
14271
+ if (!plan.health.healthy) {
14272
+ return { ok: false, agentId, skipReason: plan.health.reason, sampleHealth: plan.health };
14273
+ }
13895
14274
  const sampledIds = [];
13896
14275
  const perQueryResultIds = [];
13897
14276
  try {
13898
- for (const m of sampled) {
13899
- const id = String(m.id);
13900
- const cue = deriveRecallCue(m);
14277
+ for (const { id, cue } of plan.sampled) {
13901
14278
  const body = { agentId, q: cue, limit: k };
13902
14279
  const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
13903
14280
  const results = Array.isArray(res) ? res : (res?.results ?? []);
@@ -13908,7 +14285,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
13908
14285
  catch (err) {
13909
14286
  return { ok: false, agentId, skipReason: `recall spot-check search failed: ${err?.message ?? String(err)}` };
13910
14287
  }
13911
- return { ok: true, agentId, sampledIds, perQueryResultIds, k };
14288
+ return { ok: true, agentId, sampledIds, perQueryResultIds, k, sampleHealth: plan.health };
13912
14289
  }
13913
14290
  // ─── flair quality --emit (Slice 2 of the memory-quality-observability arc:
13914
14291
  // quality OrgEvents) ─────────────────────────────────────────────────────────
@@ -13958,6 +14335,22 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
13958
14335
  export const QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT = 90;
13959
14336
  export const QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT = 5;
13960
14337
  export const QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT = 10;
14338
+ /**
14339
+ * RETAINED AT ITS ORIGINAL VALUE AND DELIBERATELY UNWIRED (flair#967).
14340
+ *
14341
+ * Nothing in diffQualitySnapshots reads this any more — the recall spot-check
14342
+ * is report-only and emits no event at any delta (see the Slice 1d framing in
14343
+ * the module doc for the 32-run σ = 0.291 / precision-0 measurement behind
14344
+ * that). The constant stays, unchanged at 0.2, as the standing evidence that
14345
+ * the fix was "remove alerting authority from a metric that never earned it",
14346
+ * NOT "widen the gate until it stops talking" — a silenced check and a
14347
+ * de-authorised one look identical in a changelog and are opposites in
14348
+ * practice, and 0.2 sitting here at 0.69σ is the arithmetic that makes the
14349
+ * difference legible. If this probe is ever re-armed, the replacement
14350
+ * threshold must be DERIVED from the measured run-to-run variance of the
14351
+ * FIXED cue derivation, not typed in — do not just re-reference this literal.
14352
+ * Asserted unchanged by test/unit/quality-recall-spotcheck-967.test.ts.
14353
+ */
13961
14354
  export const QUALITY_EVENT_RECALL_DROP_THRESHOLD = 0.2;
13962
14355
  export const QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD = 0.5; // >50%
13963
14356
  export const QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD = 5; // AND by >= 5 clusters
@@ -14024,29 +14417,30 @@ export function diffQualitySnapshots(current, previous) {
14024
14417
  });
14025
14418
  }
14026
14419
  }
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
- }
14420
+ // ── recall spot-check: REPORT-ONLY, no branch here on purpose (flair#967) ──
14421
+ //
14422
+ // This metric used to emit two quality.regression events (recall@k and MRR,
14423
+ // both at QUALITY_EVENT_RECALL_DROP_THRESHOLD). It no longer emits anything,
14424
+ // at any delta. Measured, on rockit production:
14425
+ //
14426
+ // 32 nightly runs · population σ 0.291 · mean |run-to-run delta| 0.223
14427
+ // threshold 0.2 → 0.69σ, i.e. BELOW the metric's own noise floor
14428
+ // 6 findings-mails in 34 runs, replay-predicted 6/6 from these branches,
14429
+ // all 6 oscillation → lifetime precision 0
14430
+ //
14431
+ // Removing an emission is not the same move as raising a threshold, and the
14432
+ // distinction is the whole point: raising 0.2 would leave a check that still
14433
+ // claims to detect recall regressions while detecting none, whereas this
14434
+ // hands that job to the instrument that can actually do it — the
14435
+ // deterministic, fixed-label, CI-gated eval in
14436
+ // test/integration-heavy/recall-eval-gate.test.ts (test/bench/recall-eval),
14437
+ // whose floors sit ≥2 whole queries below the measured value against a
14438
+ // 0.000 noise band. QUALITY_EVENT_RECALL_DROP_THRESHOLD is left at 0.2,
14439
+ // unwired, so that stays checkable rather than asserted.
14440
+ //
14441
+ // current.recallSpotCheck / previous.recallSpotCheck are still SNAPSHOTTED
14442
+ // (buildQualitySnapshot above) — the history that made this diagnosis
14443
+ // possible keeps accumulating, and `flair quality` still prints the number.
14050
14444
  // ── quiet agents: per-agent, NEWLY quiet only (was false last snapshot,
14051
14445
  // true now) — never re-fires for an agent that was already quiet last
14052
14446
  // snapshot, and never fires for an agent absent from the previous snapshot
@@ -14226,6 +14620,12 @@ program
14226
14620
  const mode = render.resolveOutputMode(opts);
14227
14621
  if (mode === "json") {
14228
14622
  const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
14623
+ // flair#967: when a window was assembled, say whether it was scorable —
14624
+ // structurally, not only as prose inside a `gaps` reason. An unhealthy
14625
+ // sample is a FACT ABOUT THE RUN that a consumer must be able to read
14626
+ // without string-matching.
14627
+ if (recallSpotCheckData.sampleHealth)
14628
+ out.recallSampleHealth = recallSpotCheckData.sampleHealth;
14229
14629
  if (emitResult) {
14230
14630
  out.emit = { firstRun: emitResult.firstRun, snapshotId: emitResult.snapshotId, errors: emitResult.errors };
14231
14631
  out.emittedEvents = emitResult.emittedEvents.map((e) => ({
@@ -14339,15 +14739,15 @@ program
14339
14739
  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
14740
  console.log(` ${render.wrap(render.c.dim, "an ops signal — near-duplicate memories piling up, not a trust judgment")}`);
14341
14741
  }
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.
14742
+ // Recall spot-check (flair-quality Slice 1d) — a REPORT-ONLY health
14743
+ // spot-check: not a benchmark, not a trust judgment, and since flair#967
14744
+ // not an alerting signal either. See QualityReport['recallSpotCheck'] doc
14745
+ // and the Slice 1d module doc for the full framing.
14346
14746
  if (report.recallSpotCheck) {
14347
14747
  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)`)}`);
14748
+ 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
14749
  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")}`);
14750
+ 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
14751
  }
14352
14752
  // Gaps
14353
14753
  if (report.gaps.length > 0) {
@@ -14906,7 +15306,7 @@ memory.command("hygiene")
14906
15306
  const enabled = new Set((opts.pattern ?? "compact-id,test-content,tiny").split(",").map((s) => s.trim()).filter(Boolean));
14907
15307
  const tinyThreshold = Math.max(0, Number(opts.tinyThreshold) || 25);
14908
15308
  const apply = !!opts.apply;
14909
- const opsAuth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
15309
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
14910
15310
  async function ops(body) {
14911
15311
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
14912
15312
  method: "POST",
@@ -16090,6 +16490,7 @@ program
16090
16490
  .option("--url <url>", "Flair base URL (overrides --port)")
16091
16491
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env, or use --admin-pass-file)")
16092
16492
  .option("--admin-pass-file <path>", "Read admin password from a file (e.g., ~/.flair/admin-pass). Preferred over --admin-pass for launchd/cron — keeps the secret out of ps and shell history.")
16493
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16093
16494
  .action(async (opts) => {
16094
16495
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
16095
16496
  let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
@@ -16105,7 +16506,7 @@ program
16105
16506
  process.exit(1);
16106
16507
  }
16107
16508
  }
16108
- const adminUser = DEFAULT_ADMIN_USER;
16509
+ const adminUser = resolveAdminUser(opts.adminUser);
16109
16510
  if (!adminPass) {
16110
16511
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
16111
16512
  process.exit(1);
@@ -16194,11 +16595,12 @@ program
16194
16595
  .option("--port <port>", "Harper HTTP port")
16195
16596
  .option("--url <url>", "Flair base URL (overrides --port)")
16196
16597
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
16598
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16197
16599
  .option("--dry-run", "Show what would be imported without making changes")
16198
16600
  .action(async (backupPath, opts) => {
16199
16601
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
16200
16602
  const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
16201
- const adminUser = DEFAULT_ADMIN_USER;
16603
+ const adminUser = resolveAdminUser(opts.adminUser);
16202
16604
  const dryRun = Boolean(opts.dryRun);
16203
16605
  const mode = opts.replace ? "replace" : "merge";
16204
16606
  if (!adminPass) {
@@ -16311,6 +16713,7 @@ program
16311
16713
  .option("--port <port>", "Harper HTTP port")
16312
16714
  .option("--url <url>", "Flair base URL (overrides --port)")
16313
16715
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
16716
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16314
16717
  .option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
16315
16718
  .action(async (agentId, opts) => {
16316
16719
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
@@ -16319,7 +16722,7 @@ program
16319
16722
  console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
16320
16723
  process.exit(1);
16321
16724
  }
16322
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
16725
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
16323
16726
  async function adminGet(path) {
16324
16727
  const res = await fetch(`${baseUrl}${path}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(10_000) });
16325
16728
  if (!res.ok)
@@ -16402,6 +16805,7 @@ program
16402
16805
  .option("--url <url>", "Flair base URL (overrides --port)")
16403
16806
  .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
16807
  .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS env)")
16808
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
16405
16809
  .option("--keys-dir <dir>", "Keys directory", defaultKeysDir())
16406
16810
  .action(async (importPath, opts) => {
16407
16811
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
@@ -16462,12 +16866,12 @@ program
16462
16866
  : nacl.sign.keyPair.fromSeed(new Uint8Array(decodedSeed.subarray(0, 32))).publicKey;
16463
16867
  const pubKeyB64url = b64url(pubKey);
16464
16868
  // Register agent via ops API (remote when --url/--ops-target points off-box)
16465
- await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, DEFAULT_ADMIN_USER, adminPass);
16869
+ await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, resolveAdminUser(opts.adminUser), adminPass);
16466
16870
  console.log(typeof seedOpsTarget === "string"
16467
16871
  ? ` Agent registered (ops: ${seedOpsTarget})`
16468
16872
  : ` Agent registered`);
16469
16873
  // Restore memories
16470
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
16874
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
16471
16875
  let memCount = 0;
16472
16876
  for (const mem of data.memories ?? []) {
16473
16877
  try {
@@ -16748,7 +17152,7 @@ program
16748
17152
  createdAt: new Date().toISOString(),
16749
17153
  };
16750
17154
  const memoryPath = `/Memory/${memoryId}`;
16751
- const auth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
17155
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
16752
17156
  const res = await fetch(`${httpUrl}${memoryPath}`, {
16753
17157
  method: "PUT",
16754
17158
  headers: {
@@ -17222,7 +17626,7 @@ if (import.meta.main) {
17222
17626
  // ─── Exported for testing ─────────────────────────────────────────────────────
17223
17627
  export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
17224
17628
  // 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,
17629
+ 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
17630
  // launchd label (flair#693)
17227
17631
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
17228
17632
  // launchd management observation (flair#1022)