@tpsdev-ai/flair 0.44.8 → 0.44.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -19,11 +19,11 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
19
19
  import { probeInstance } from "./probe.js";
20
20
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
21
21
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
22
- import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
23
- import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
22
+ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
23
+ import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
24
24
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
25
25
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
26
- import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
26
+ import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
27
27
  import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
28
28
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
29
29
  import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
@@ -2394,14 +2394,58 @@ export function upgradeStatusSuffix(name, status) {
2394
2394
  if (status === "current")
2395
2395
  return " (current)";
2396
2396
  if (status === "missing") {
2397
- return name === "@tpsdev-ai/flair-mcp"
2397
+ return name === FLAIR_MCP_PACKAGE
2398
2398
  ? " (zero-install via npx — run: flair doctor --fix)"
2399
2399
  : " (run: npm install -g)";
2400
2400
  }
2401
2401
  if (status === "optional")
2402
2402
  return " (install via: openclaw plugins install @tpsdev-ai/openclaw-flair)";
2403
+ // flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
2404
+ // the post-upgrade pin refresh), never `npm install -g` — a global bin does
2405
+ // nothing for an `npx -y -p @tpsdev-ai/flair-mcp` invocation (flair#1208).
2406
+ if (status === "outdated" && name === FLAIR_MCP_PACKAGE) {
2407
+ return " (npx-wired — run: flair doctor --fix to re-pin)";
2408
+ }
2403
2409
  return "";
2404
2410
  }
2411
+ /**
2412
+ * Resolve the `flair upgrade` finding for flair-mcp from its ACTUAL wiring,
2413
+ * not a global-install probe (flair#1208).
2414
+ *
2415
+ * flair-mcp is zero-install via npx (#1168): a correctly-wired machine never
2416
+ * installs it globally, so the global bin/lib probe returning null is the
2417
+ * NORMAL state, not "missing". Its real installed version is the pin its wiring
2418
+ * carries (a client MCP config's args, refreshed by `flair doctor --fix`).
2419
+ *
2420
+ * Resolution order:
2421
+ * 1. Legacy global install — the bin/lib probe found a version. Honor it.
2422
+ * 2. Not wired anywhere — genuinely missing; the remedy (upgradeStatusSuffix)
2423
+ * is `flair doctor --fix`, never `npm install -g`.
2424
+ * 3. Wired with a concrete pin — that pin IS the installed version
2425
+ * (current when it equals latest, else outdated → re-pin via doctor).
2426
+ * 4. Wired but unpinned (a bare npx spec / the SessionStart hook) — `npx -y`
2427
+ * re-resolves latest every session, so the effective version IS latest →
2428
+ * current.
2429
+ */
2430
+ export function resolveFlairMcpFinding(globalProbe, latest, wiring) {
2431
+ // 1. Legacy global install.
2432
+ if (globalProbe !== null) {
2433
+ return { installed: globalProbe, status: globalProbe === latest ? "current" : "outdated" };
2434
+ }
2435
+ // 2. Not wired anywhere.
2436
+ if (!wiring.wired) {
2437
+ return { installed: null, status: "missing" };
2438
+ }
2439
+ // 3. Wired with a pin.
2440
+ if (wiring.pinnedVersion) {
2441
+ return {
2442
+ installed: wiring.pinnedVersion,
2443
+ status: wiring.pinnedVersion === latest ? "current" : "outdated",
2444
+ };
2445
+ }
2446
+ // 4. Wired but unpinned — npx resolves latest on every session.
2447
+ return { installed: latest, status: "current" };
2448
+ }
2405
2449
  /**
2406
2450
  * Pure flag resolution for `flair upgrade`'s restart/verify defaults
2407
2451
  * (flair#635 decision: restart is now the default; `--no-restart` opts
@@ -2714,7 +2758,7 @@ program
2714
2758
  .option("--data-dir <dir>", "Harper data directory")
2715
2759
  .option("--skip-start", "Skip Harper startup (assume already running)")
2716
2760
  .option("--skip-soul", "Skip interactive personality setup")
2717
- .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
2761
+ .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, antigravity, all, or none")
2718
2762
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
2719
2763
  .option("--skip-smoke", "Skip the MCP smoke test")
2720
2764
  .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
@@ -2933,9 +2977,9 @@ program
2933
2977
  const noMcp = opts.mcp === false;
2934
2978
  const selectedClients = [];
2935
2979
  if (clientOpt && clientOpt !== "all" && clientOpt !== "none" && !noMcp) {
2936
- const valid = ["claude-code", "codex", "gemini", "cursor"];
2980
+ const valid = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
2937
2981
  if (!valid.includes(clientOpt)) {
2938
- console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
2982
+ console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, antigravity, all, none`);
2939
2983
  process.exit(1);
2940
2984
  }
2941
2985
  selectedClients.push(clientOpt);
@@ -3536,6 +3580,9 @@ program
3536
3580
  case "cursor":
3537
3581
  result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
3538
3582
  break;
3583
+ case "antigravity":
3584
+ result = wireAntigravity({ ...mcpEnv, FLAIR_CLIENT: "antigravity" });
3585
+ break;
3539
3586
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
3540
3587
  }
3541
3588
  wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
@@ -7408,6 +7455,133 @@ export function decideCandidateAction(candidate, action) {
7408
7455
  }
7409
7456
  return { ok: true };
7410
7457
  }
7458
+ // ─── ADK tag-lineage on promote (#1205 slice 1205a — Sherlock security req) ───
7459
+ // ADK session records are written by adk-flair (memory_service.py) under a
7460
+ // SHARED-namespace agentId, with per-user separation carried ENTIRELY by a
7461
+ // compound scope tag `adk:<app>:<user>`. That tag is the access-control
7462
+ // boundary. A candidate distilled from those records therefore MUST carry the
7463
+ // scope tag when promoted, or the promoted claim lands in the shared agentId
7464
+ // memory retrievable by every other user of the app — a cross-user leak.
7465
+ //
7466
+ // `rem promote` historically hard-coded `["nightly-rem-promoted", from:<id>]`
7467
+ // and DROPPED the source tag. We now propagate the source scope tag for
7468
+ // ADK-sourced candidates, and FAIL CLOSED (refuse) when a candidate is
7469
+ // ADK-sourced but its scope tag can't be uniquely+completely determined.
7470
+ //
7471
+ // SCOPING (deliberate, per spec): fail-closed applies ONLY to ADK-sourced
7472
+ // candidates. Non-ADK candidates carry no `adk:` tag and promote byte-for-byte
7473
+ // as before — a transient/deleted source on a non-ADK candidate must NOT block
7474
+ // its promotion.
7475
+ //
7476
+ // SEAM (foundation only; the distillation engine is slice #1205b): ADK-sourcing
7477
+ // is detected here by re-reading the candidate's source memories and inspecting
7478
+ // their tags. That leaves ONE residual fail-open: an ADK-sourced candidate all
7479
+ // of whose source memories are unreadable (deleted/transient) yields no `adk:`
7480
+ // evidence and is treated as non-ADK. Closing that corner without regressing
7481
+ // non-ADK promotion requires the ENGINE to stamp the authoritative scope tag
7482
+ // onto the MemoryCandidate row at distillation time (it distills per single
7483
+ // scope:tagged tag, so it knows it authoritatively). `derivePromotedTags` is
7484
+ // written so that override can be threaded in later without touching callers.
7485
+ export const ADK_SCOPE_TAG_PREFIX = "adk:";
7486
+ /**
7487
+ * Decide the tag set for a promoted Memory given the candidate id and the
7488
+ * result of fetching each of its source memories. Pure — no I/O; the action
7489
+ * callback does the fetching and threads the results here so this is unit-
7490
+ * testable and the fail-closed logic is exercised directly.
7491
+ *
7492
+ * `stampedScopeTag` (#1205b-1 — the engine slice the #1205a SEAM note below
7493
+ * anticipated): the authoritative scope:"tagged" tag the distillation engine
7494
+ * stamped onto the MemoryCandidate row (resources/MemoryReflect.ts →
7495
+ * buildStagedCandidateRow). When present it is AUTHORITATIVE and short-circuits
7496
+ * the source re-read entirely — the engine distilled under exactly this one
7497
+ * tag, so it knows the per-user scope tag independent of whether the source
7498
+ * memories are still readable. This closes the residual fail-open the SEAM
7499
+ * note describes: a candidate all of whose sources are unreadable yields no
7500
+ * `adk:` evidence and would otherwise be mis-classified NON-ADK and promoted
7501
+ * tagless into the shared agentId namespace (a cross-user leak). Threading it
7502
+ * in as an optional trailing arg keeps every pre-#1205b caller (and every
7503
+ * candidate that never carried a stamp) on the unchanged source-re-read path.
7504
+ *
7505
+ * With NO stamp (undefined/empty) the source-re-read classification runs
7506
+ * exactly as in #1205a:
7507
+ * - No `adk:` scope tag across readable sources → NON-ADK candidate; return
7508
+ * the provenance tags only (unchanged behavior).
7509
+ * - Exactly one `adk:` scope tag AND every source readable → ADK-sourced;
7510
+ * return [scopeTag, ...provenance].
7511
+ * - `adk:` evidence present but the scope tag is ambiguous (>1 distinct tag)
7512
+ * OR incomplete (some source unreadable) → REFUSE (fail-closed): a
7513
+ * tagless/mis-tagged claim in a shared ADK namespace is a cross-user leak,
7514
+ * not a benign miss.
7515
+ */
7516
+ export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
7517
+ const provenance = ["nightly-rem-promoted", `from:${candidateId}`];
7518
+ // #1205b-1: a stamped scope tag is AUTHORITATIVE — consume it directly, never
7519
+ // re-read sources. This is the seam closure: correctness no longer depends on
7520
+ // source readability. `adkSourced` (which gates the Soul-promotion refusal in
7521
+ // the promote action) tracks whether the stamped tag is an ADK scope tag.
7522
+ if (typeof stampedScopeTag === "string" && stampedScopeTag.length > 0) {
7523
+ return {
7524
+ ok: true,
7525
+ tags: [stampedScopeTag, ...provenance],
7526
+ adkSourced: stampedScopeTag.startsWith(ADK_SCOPE_TAG_PREFIX),
7527
+ };
7528
+ }
7529
+ const adkTags = new Set();
7530
+ let anySourceUnreadable = false;
7531
+ for (const s of sources) {
7532
+ if (!s.ok) {
7533
+ anySourceUnreadable = true;
7534
+ continue;
7535
+ }
7536
+ for (const t of s.tags) {
7537
+ if (typeof t === "string" && t.startsWith(ADK_SCOPE_TAG_PREFIX))
7538
+ adkTags.add(t);
7539
+ }
7540
+ }
7541
+ // No positive ADK evidence → non-ADK. An unreadable source with zero ADK
7542
+ // evidence does NOT fail closed here (that would regress non-ADK promotion);
7543
+ // see the SEAM note above.
7544
+ if (adkTags.size === 0) {
7545
+ return { ok: true, tags: provenance, adkSourced: false };
7546
+ }
7547
+ if (adkTags.size > 1) {
7548
+ return {
7549
+ ok: false,
7550
+ reason: `ADK-sourced candidate spans multiple scope tags (${[...adkTags].sort().join(", ")}); refusing to promote — a merged cross-user claim would leak across users`,
7551
+ };
7552
+ }
7553
+ if (anySourceUnreadable) {
7554
+ return {
7555
+ ok: false,
7556
+ reason: `ADK-sourced candidate has unreadable source memories; the per-user scope tag cannot be confirmed — refusing to promote (fail-closed)`,
7557
+ };
7558
+ }
7559
+ const scopeTag = [...adkTags][0];
7560
+ return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
7561
+ }
7562
+ // ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
7563
+ // A promotion records a reviewerId that feeds audit/attribution
7564
+ // (schemas/memory.graphql:209). An automated (machine-driven) promotion path
7565
+ // must record a reviewerId that can NEVER be mistaken for a human/agent
7566
+ // reviewer, so attribution isn't laundered. Reserve the `machine:` namespace
7567
+ // for that, and forbid the human `--reviewer` path from claiming it.
7568
+ export const MACHINE_REVIEWER_PREFIX = "machine:";
7569
+ /** Canonical machine reviewerId for the ADK auto-promote consumer (#1205b). */
7570
+ export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
7571
+ /** True iff `id` is in the reserved machine-reviewer namespace — i.e. it
7572
+ * denotes an automated path, not a human or agent reviewer. */
7573
+ export function isMachineReviewerId(id) {
7574
+ return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
7575
+ }
7576
+ /** The human `flair rem promote` path must not record a reviewerId in the
7577
+ * reserved machine namespace — that would launder automated attribution onto
7578
+ * a human-operated promotion. Returns an error string, or null if allowed. */
7579
+ export function validateHumanReviewerId(reviewerId) {
7580
+ if (isMachineReviewerId(reviewerId)) {
7581
+ return `--reviewer '${reviewerId}' uses the reserved '${MACHINE_REVIEWER_PREFIX}' namespace (reserved for automated promotion); use a human/agent reviewer id`;
7582
+ }
7583
+ return null;
7584
+ }
7411
7585
  // ─── flair rem promote ───────────────────────────────────────────────────────
7412
7586
  // Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
7413
7587
  // or persistent Memory. Both --rationale and --to are required (spec § 5: no
@@ -7435,6 +7609,13 @@ rem
7435
7609
  process.exit(1);
7436
7610
  }
7437
7611
  const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
7612
+ // The human promote path must not record a reserved machine reviewerId
7613
+ // (Sherlock #4): that would launder automated attribution.
7614
+ const reviewerErr = validateHumanReviewerId(reviewerId);
7615
+ if (reviewerErr) {
7616
+ console.error(`Error: ${reviewerErr}`);
7617
+ process.exit(1);
7618
+ }
7438
7619
  try {
7439
7620
  // Fetch the candidate
7440
7621
  const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
@@ -7445,6 +7626,51 @@ rem
7445
7626
  console.error(`Error: candidate ${candidateId} ${msg}`);
7446
7627
  process.exit(1);
7447
7628
  }
7629
+ // ADK tag-lineage: derive the promoted-claim tag set.
7630
+ //
7631
+ // #1205b-1: if the engine stamped an authoritative `scopeTag` on the
7632
+ // candidate (scope:"tagged" distillation), consume it DIRECTLY and skip
7633
+ // the source re-read — correctness no longer depends on the source
7634
+ // memories still being readable (the #1205a seam closure). We only fall
7635
+ // back to re-reading sources when there is NO stamp (a pre-#1205b
7636
+ // candidate, or a non-tagged distillation).
7637
+ const stampedScopeTag = typeof candidate.scopeTag === "string" && candidate.scopeTag.length > 0 ? candidate.scopeTag : undefined;
7638
+ const sourceFetches = [];
7639
+ if (!stampedScopeTag) {
7640
+ // No authoritative stamp — re-read sources to classify. Fail-closed for
7641
+ // ADK-sourced candidates whose per-user scope tag can't be confirmed;
7642
+ // unchanged for non-ADK candidates. See derivePromotedTags for rules.
7643
+ const sourceIds = Array.isArray(candidate.sourceMemoryIds) ? candidate.sourceMemoryIds : [];
7644
+ for (const sid of sourceIds) {
7645
+ try {
7646
+ const mem = await api("GET", `/Memory/${encodeURIComponent(String(sid))}`);
7647
+ if (mem && !mem.error) {
7648
+ sourceFetches.push({ ok: true, tags: Array.isArray(mem.tags) ? mem.tags : [] });
7649
+ }
7650
+ else {
7651
+ sourceFetches.push({ ok: false });
7652
+ }
7653
+ }
7654
+ catch {
7655
+ sourceFetches.push({ ok: false });
7656
+ }
7657
+ }
7658
+ }
7659
+ const tagDecision = derivePromotedTags(candidateId, sourceFetches, stampedScopeTag);
7660
+ if (!tagDecision.ok) {
7661
+ console.error(`Error: candidate ${candidateId} — ${tagDecision.reason}`);
7662
+ process.exit(1);
7663
+ }
7664
+ // Soul entries are agentId-scoped and cannot carry a per-user scope tag,
7665
+ // so an ADK-sourced candidate promoted to Soul is a cross-user leak by
7666
+ // construction — fail closed. (Server-side trust-tier enforcement that
7667
+ // hard-locks the target is the engine slice #1205b; this is the CLI-side
7668
+ // foundation.)
7669
+ if (opts.to === "soul" && tagDecision.adkSourced) {
7670
+ console.error(`Error: candidate ${candidateId} is ADK-sourced (scope tag ${tagDecision.tags[0]}); Soul is agentId-scoped and cannot carry a per-user scope tag — refusing to promote to Soul (would leak across users). Promote ADK-sourced candidates to memory.`);
7671
+ process.exit(1);
7672
+ }
7673
+ const promotedTags = tagDecision.tags;
7448
7674
  const decidedAt = new Date().toISOString();
7449
7675
  // Write the resulting Soul or Memory entry
7450
7676
  if (opts.to === "memory") {
@@ -7454,7 +7680,7 @@ rem
7454
7680
  agentId: candidate.agentId,
7455
7681
  content: candidate.claim,
7456
7682
  durability: "persistent",
7457
- tags: ["nightly-rem-promoted", `from:${candidateId}`],
7683
+ tags: promotedTags,
7458
7684
  derivedFrom: candidate.sourceMemoryIds ?? [],
7459
7685
  promotionStatus: "approved",
7460
7686
  promotedAt: decidedAt,
@@ -9517,18 +9743,30 @@ program
9517
9743
  }
9518
9744
  catch { /* best-effort */ }
9519
9745
  }
9520
- const installed = probe();
9746
+ const globalProbe = probe();
9747
+ let installed;
9521
9748
  let status;
9522
- if (installed === null) {
9523
- // openclaw-plugin packages are optionalif openclaw isn't
9524
- // installed, don't surface a misleading "install with npm" advice.
9525
- status = kind === "openclaw-plugin" ? "optional" : "missing";
9526
- }
9527
- else if (installed === latest) {
9528
- status = "current";
9749
+ if (name === FLAIR_MCP_PACKAGE) {
9750
+ // flair-mcp is zero-install via npx (#1168) a null global probe is
9751
+ // the NORMAL state, not "missing". Resolve it from its actual wiring
9752
+ // (the pin in a client MCP config / the SessionStart hook) so the
9753
+ // listing is truthful and the remedy actually works (flair#1208).
9754
+ const home = process.env.HOME ?? homedir();
9755
+ ({ installed, status } = resolveFlairMcpFinding(globalProbe, latest, detectWiredFlairMcp(home)));
9529
9756
  }
9530
9757
  else {
9531
- status = "outdated";
9758
+ installed = globalProbe;
9759
+ if (installed === null) {
9760
+ // openclaw-plugin packages are optional — if openclaw isn't
9761
+ // installed, don't surface a misleading "install with npm" advice.
9762
+ status = kind === "openclaw-plugin" ? "optional" : "missing";
9763
+ }
9764
+ else if (installed === latest) {
9765
+ status = "current";
9766
+ }
9767
+ else {
9768
+ status = "outdated";
9769
+ }
9532
9770
  }
9533
9771
  findings.push({ name, installed, latest, status, kind });
9534
9772
  // Suppress the line for openclaw plugins that are optional-because-
@@ -9553,12 +9791,19 @@ program
9553
9791
  console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
9554
9792
  const outdated = findings.filter((f) => f.status === "outdated");
9555
9793
  const missing = findings.filter((f) => f.status === "missing");
9794
+ // flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
9795
+ // the post-upgrade pin refresh below), NEVER `npm install -g` — a global
9796
+ // bin does nothing for an `npx -y -p @tpsdev-ai/flair-mcp` invocation
9797
+ // (#1168/#1208). So a stale-pinned flair-mcp drives a remedy line, not the
9798
+ // npm-install + restart transaction. It is kept out of npmUpgrades here and
9799
+ // surfaced separately below.
9800
+ const flairMcpOutdated = outdated.find((f) => f.name === FLAIR_MCP_PACKAGE) ?? null;
9556
9801
  // openclaw plugins upgrade through `openclaw plugins install`, not `npm
9557
9802
  // install -g` (npm-installed wouldn't connect to OpenClaw's gateway slot).
9558
9803
  // Split outdated into npm-upgradeable vs openclaw-plugin so we can use
9559
9804
  // the right command for each.
9560
9805
  const npmUpgrades = outdated
9561
- .filter((f) => f.kind !== "openclaw-plugin")
9806
+ .filter((f) => f.kind !== "openclaw-plugin" && f.name !== FLAIR_MCP_PACKAGE)
9562
9807
  .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
9563
9808
  const openclawUpgrades = outdated
9564
9809
  .filter((f) => f.kind === "openclaw-plugin")
@@ -9568,15 +9813,26 @@ program
9568
9813
  console.log("\n✅ Everything is up to date.");
9569
9814
  return;
9570
9815
  }
9571
- if (missing.length > 0 && outdated.length === 0) {
9572
- const npmMissing = missing.filter((f) => f.name !== "@tpsdev-ai/flair-mcp");
9573
- const mcpMissing = missing.filter((f) => f.name === "@tpsdev-ai/flair-mcp");
9574
- console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
9575
- if (npmMissing.length > 0) {
9576
- console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
9816
+ // Nothing to install via npm/openclaw. What is left is advisory: packages
9817
+ // not detected (missing) and/or a flair-mcp whose wired pin is behind latest
9818
+ // both fixed by re-wiring (`flair doctor --fix`), never by the
9819
+ // npm-install + restart transaction below (#1168/#1208). Print the remedies
9820
+ // and stop.
9821
+ if (totalUpgrades === 0) {
9822
+ if (missing.length > 0) {
9823
+ const npmMissing = missing.filter((f) => f.name !== FLAIR_MCP_PACKAGE);
9824
+ const mcpMissing = missing.some((f) => f.name === FLAIR_MCP_PACKAGE);
9825
+ console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
9826
+ if (npmMissing.length > 0) {
9827
+ console.log(` Install missing: npm install -g ${npmMissing.map((f) => f.name).join(" ")}`);
9828
+ }
9829
+ if (mcpMissing) {
9830
+ console.log(` flair-mcp is zero-install via npx — run: flair doctor --fix to wire the hook`);
9831
+ }
9577
9832
  }
9578
- if (mcpMissing.length > 0) {
9579
- console.log(` flair-mcp is zero-install via npx run: flair doctor --fix to re-wire the hook`);
9833
+ if (flairMcpOutdated) {
9834
+ console.log(`\n⬆️ flair-mcp is wired via npx (pinned ${flairMcpOutdated.installed} latest ${flairMcpOutdated.latest}).`);
9835
+ console.log(` Re-pin it: flair doctor --fix`);
9580
9836
  }
9581
9837
  return;
9582
9838
  }
@@ -12154,7 +12410,8 @@ program
12154
12410
  const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
12155
12411
  client.id === "codex" ? wireCodex(wireEnv) :
12156
12412
  client.id === "gemini" ? wireGemini(wireEnv) :
12157
- wireCursor(wireEnv);
12413
+ client.id === "antigravity" ? wireAntigravity(wireEnv) :
12414
+ wireCursor(wireEnv);
12158
12415
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
12159
12416
  if (wireResult.ok)
12160
12417
  fixed++;
@@ -12680,7 +12937,14 @@ program
12680
12937
  // a real user query, so a high score means "recall is functioning", not
12681
12938
  // "recall is optimal". Its job is to catch recall CRATERING (embeddings
12682
12939
  // down, index busted) — the score collapsing toward 0 is the signal, not
12683
- // fine-grained precision grading. Requires an actual agent identity to
12940
+ // fine-grained precision grading. NOTE (#1216): this cue-from-the-memory
12941
+ // design is self-polluting as a recall-QUALITY metric — relevance is
12942
+ // query/corpus overlap by construction, so near-duplicate density reads as a
12943
+ // recall collapse (flair#967 / #857 / #996). It is deliberately NOT the
12944
+ // recall-quality number; that authority is the deterministic, fixed-label,
12945
+ // CI-gated eval at test/bench/recall-eval (recall@k / nDCG@10 / MRR). This
12946
+ // probe stays scoped to live-health cratering only. Requires an actual
12947
+ // agent identity to
12684
12948
  // query AS (semantic search is agent-scoped) — no identity, fewer than the
12685
12949
  // sample-size memories to sample, or a search error all degrade to `null` +
12686
12950
  // a `gaps` entry, same graceful-degradation contract as every metric here —
@@ -22,7 +22,8 @@
22
22
  import { spawnSync } from "node:child_process";
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
- import { clientConfigPath } from "./install/clients.js";
25
+ import { ALL_CLIENTS, clientConfigPath } from "./install/clients.js";
26
+ import { FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
26
27
  // The exact substring `flair init` writes into CLAUDE.md (src/cli.ts, the
27
28
  // `init` action) and that the doctor check + fix both key off of.
28
29
  export const CLAUDE_MD_BOOTSTRAP_MARKER = "mcp__flair__bootstrap";
@@ -365,6 +366,59 @@ export function checkSessionStartHook(homeDir) {
365
366
  return { present: false, path };
366
367
  }
367
368
  }
369
+ // ── flair-mcp presence by WIRING, not global install (flair#1208) ───────────
370
+ //
371
+ // flair-mcp is zero-install via npx by design (#1168): a correctly-wired
372
+ // machine invokes it as `npx -y -p @tpsdev-ai/flair-mcp` and NEVER installs it
373
+ // globally, so `flair upgrade`'s global bin/lib probe finds nothing and
374
+ // mis-reports it "not detected." Its real "installed version" is the pin its
375
+ // wiring carries — the mcpServerSpec() written into a client's MCP config
376
+ // (pinned since #1135). Detect it there instead.
377
+ /**
378
+ * Extract a pinned `@tpsdev-ai/flair-mcp` version from any wiring string — a
379
+ * client MCP `args` array, a Codex TOML args line, or a SessionStart hook
380
+ * command. Returns the version when the spec is written
381
+ * `@tpsdev-ai/flair-mcp@<ver>`; null for a bare/unpinned spec.
382
+ *
383
+ * The SessionStart hook is deliberately unpinned (`npx -y -p
384
+ * @tpsdev-ai/flair-mcp`, buildSessionStartHookCommand above), so a hook
385
+ * establishes that flair-mcp is wired but never carries a version — the pin
386
+ * comes from the client MCP config.
387
+ */
388
+ export function extractFlairMcpPin(text) {
389
+ if (typeof text !== "string")
390
+ return null;
391
+ // `@tpsdev-ai/flair-mcp@<version>`; the version token runs until the first
392
+ // character that can't appear in a spec embedded in JSON args / TOML.
393
+ const m = text.match(/@tpsdev-ai\/flair-mcp@([0-9A-Za-z][^\s"'\],]*)/);
394
+ return m ? m[1] : null;
395
+ }
396
+ export function detectWiredFlairMcp(homeDir) {
397
+ let wired = false;
398
+ let pinnedVersion = null;
399
+ // The package name only ever appears in a Flair MCP wiring block, so its
400
+ // presence in a config's text is a reliable "flair-mcp is wired here" signal.
401
+ const note = (text) => {
402
+ if (!text || !text.includes(FLAIR_MCP_PACKAGE))
403
+ return;
404
+ wired = true;
405
+ if (!pinnedVersion) {
406
+ const pin = extractFlairMcpPin(text);
407
+ if (pin)
408
+ pinnedVersion = pin;
409
+ }
410
+ };
411
+ // 1. The SessionStart hook (claude-code). Establishes wiring; unpinned by design.
412
+ const hook = checkSessionStartHook(homeDir);
413
+ if (hook.present && isFlairHookCommand(hook.command ?? ""))
414
+ note(hook.command);
415
+ // 2. Every known client's MCP config — a wired flair block carries the spec.
416
+ for (const client of ALL_CLIENTS) {
417
+ const configPath = withHome(homeDir, () => clientConfigPath(client.id));
418
+ note(readTextFile(configPath));
419
+ }
420
+ return { wired, pinnedVersion };
421
+ }
368
422
  /**
369
423
  * Merge-safe insert of a Flair SessionStart hook group into
370
424
  * ~/.claude/settings.json — creates the file/array if absent, preserves any
@@ -204,9 +204,16 @@ function replaceCodexFlairBlock(raw, env) {
204
204
  * Creates the file (and parent dir) if absent; preserves existing servers and
205
205
  * any other top-level keys. Returns ok:true only when the file was written.
206
206
  */
207
- function wireJsonMcp(configPath, label, env) {
207
+ function wireJsonMcp(configPath, label, env,
208
+ // The parenthetical appended to a successful wire/refresh message. Defaults to
209
+ // the confident "restart <label> to pick it up". A client whose end-to-end
210
+ // pickup Flair has NOT verified (Antigravity — flair#1209) passes an honest
211
+ // note instead, so the message claims only what it did (wrote the config), not
212
+ // that the client will read it.
213
+ pickupNote) {
208
214
  const home = resolveHome();
209
215
  const display = configPath.startsWith(home) ? "~" + configPath.slice(home.length) : configPath;
216
+ const note = pickupNote ?? `restart ${label} to pick it up`;
210
217
  try {
211
218
  let config = {};
212
219
  if (existsSync(configPath)) {
@@ -229,7 +236,7 @@ function wireJsonMcp(configPath, label, env) {
229
236
  mkdirSync(dirname(configPath), { recursive: true });
230
237
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
231
238
  const action = urlAgentMatch ? "refreshed pin in" : "wired";
232
- return { ok: true, message: `${label}: ${action} ${display} (restart ${label} to pick it up)` };
239
+ return { ok: true, message: `${label}: ${action} ${display} (${note})` };
233
240
  }
234
241
  catch (err) {
235
242
  const reason = err instanceof Error ? err.message : String(err);
@@ -256,6 +263,23 @@ function geminiConfigPath() {
256
263
  function codexConfigPath() {
257
264
  return join(resolveHome(), ".codex", "config.toml");
258
265
  }
266
+ /**
267
+ * Antigravity CLI (`agy`) + Antigravity 2.0 IDE + SDK: they share ONE central
268
+ * MCP config at ~/.gemini/config/mcp_config.json on every OS (flair#1209).
269
+ *
270
+ * This is a SIBLING of, and distinct from, Gemini CLI's ~/.gemini/settings.json
271
+ * (geminiConfigPath above) — both tools live under ~/.gemini but read different
272
+ * files, so wiring one never touches the other. Same standard `mcpServers`
273
+ * stdio schema (command/args/env) the JSON clients above use.
274
+ *
275
+ * Path per Antigravity's own docs (antigravity.google/docs/mcp) and a Google
276
+ * Developer Advocate write-up (atamel.dev "Where does Antigravity look for MCP
277
+ * Servers?"). NOTE: the end-to-end wiring has NOT been verified against a real
278
+ * `agy` install — see the PR body.
279
+ */
280
+ function antigravityConfigPath() {
281
+ return join(resolveHome(), ".gemini", "config", "mcp_config.json");
282
+ }
259
283
  /**
260
284
  * Single dispatcher for "where does this client's MCP config live" — used by
261
285
  * `flair doctor`'s client-integration checks (flair#588) to read the config
@@ -272,6 +296,8 @@ export function clientConfigPath(id) {
272
296
  return geminiConfigPath();
273
297
  case "cursor":
274
298
  return cursorConfigPath();
299
+ case "antigravity":
300
+ return antigravityConfigPath();
275
301
  }
276
302
  }
277
303
  // ---- Internal wiring functions --------------------------------------------------
@@ -330,6 +356,18 @@ function _wireGemini(env) {
330
356
  function _wireCursor(env) {
331
357
  return wireJsonMcp(cursorConfigPath(), "Cursor", env);
332
358
  }
359
+ // Antigravity uses the same standard JSON `mcpServers` stdio schema as Gemini/
360
+ // Cursor (command/args/env), so wireJsonMcp merges into it byte-identically —
361
+ // only the config PATH differs (flair#1209).
362
+ //
363
+ // The success message deliberately does NOT claim "restart Antigravity to pick
364
+ // it up": Flair writes the config to the documented path, but has not verified
365
+ // end-to-end that a live `agy` reads it. So the message claims only the write,
366
+ // and asks the user to confirm pickup (flair#1209 review — honesty on an
367
+ // unverified integration).
368
+ function _wireAntigravity(env) {
369
+ return wireJsonMcp(antigravityConfigPath(), "Antigravity", env, "wiring unverified against a real agy — restart Antigravity and confirm the flair tools appear");
370
+ }
333
371
  // ---- Exported detection & wiring array ------------------------------------------
334
372
  export const ALL_CLIENTS = [
335
373
  {
@@ -356,6 +394,13 @@ export const ALL_CLIENTS = [
356
394
  bin: "cursor",
357
395
  wire: _wireCursor,
358
396
  },
397
+ {
398
+ id: "antigravity",
399
+ label: "Antigravity",
400
+ // Google's Antigravity CLI — the executable is `agy` (flair#1209).
401
+ bin: "agy",
402
+ wire: _wireAntigravity,
403
+ },
359
404
  ];
360
405
  /**
361
406
  * The summary `flair init` prints LAST.
@@ -432,3 +477,6 @@ export function wireGemini(env) {
432
477
  export function wireCursor(env) {
433
478
  return _wireCursor(env);
434
479
  }
480
+ export function wireAntigravity(env) {
481
+ return _wireAntigravity(env);
482
+ }