@tpsdev-ai/flair 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +29 -4
  2. package/SECURITY.md +28 -18
  3. package/dist/cli.js +482 -32
  4. package/dist/deploy.js +280 -18
  5. package/dist/doctor-client.js +312 -0
  6. package/dist/install/clients.js +18 -0
  7. package/dist/rem/restore.js +1 -1
  8. package/dist/resources/Admin.js +1 -1
  9. package/dist/resources/AdminConnectors.js +1 -1
  10. package/dist/resources/AdminDashboard.js +1 -1
  11. package/dist/resources/AdminIdp.js +1 -1
  12. package/dist/resources/AdminInstance.js +1 -1
  13. package/dist/resources/AdminMemory.js +2 -2
  14. package/dist/resources/AdminPrincipals.js +1 -1
  15. package/dist/resources/Agent.js +14 -0
  16. package/dist/resources/AgentCard.js +1 -1
  17. package/dist/resources/AgentSeed.js +1 -1
  18. package/dist/resources/Federation.js +98 -2
  19. package/dist/resources/IngestEvents.js +1 -1
  20. package/dist/resources/Integration.js +1 -1
  21. package/dist/resources/Memory.js +123 -17
  22. package/dist/resources/MemoryBootstrap.js +46 -36
  23. package/dist/resources/MemoryGrant.js +1 -1
  24. package/dist/resources/OAuth.js +61 -4
  25. package/dist/resources/OrgEventCatchup.js +1 -1
  26. package/dist/resources/Presence.js +55 -3
  27. package/dist/resources/Relationship.js +10 -1
  28. package/dist/resources/SemanticSearch.js +14 -14
  29. package/dist/resources/Soul.js +14 -0
  30. package/dist/resources/WorkspaceLatest.js +1 -1
  31. package/dist/resources/WorkspaceState.js +1 -1
  32. package/dist/resources/agent-auth.js +1 -1
  33. package/dist/resources/agentcard-fields.js +2 -2
  34. package/dist/resources/auth-middleware.js +24 -5
  35. package/dist/resources/bm25-filter.js +1 -1
  36. package/dist/resources/bm25.js +1 -1
  37. package/dist/resources/dedup.js +2 -2
  38. package/dist/resources/ed25519-auth.js +2 -2
  39. package/dist/resources/embeddings-provider.js +1 -1
  40. package/dist/resources/federation-nonce-store.js +195 -0
  41. package/dist/resources/instance-identity.js +53 -0
  42. package/dist/resources/memory-bootstrap-lib.js +1 -1
  43. package/dist/resources/memory-read-scope.js +58 -71
  44. package/dist/resources/memory-visibility.js +37 -0
  45. package/dist/version-check.js +167 -0
  46. package/package.json +2 -2
  47. package/schemas/agent.graphql +7 -0
  48. package/schemas/federation.graphql +12 -0
  49. package/schemas/memory.graphql +16 -0
package/dist/cli.js CHANGED
@@ -12,7 +12,9 @@ import { create as tarCreate, extract as tarExtract, list as tarList } from "tar
12
12
  import { keystore } from "./keystore.js";
13
13
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
14
14
  import { fabricUpgrade } from "./fabric-upgrade.js";
15
- import { detectClients, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
15
+ import { checkVersion, formatVersionNudge } from "./version-check.js";
16
+ import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
17
+ import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.js";
16
18
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
17
19
  // src/ into resources/, which don't survive npm packaging (see also
18
20
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -35,6 +37,39 @@ function signBody(body, secretKey) {
35
37
  const sig = nacl.sign.detached(message, secretKey);
36
38
  return Buffer.from(sig).toString("base64url");
37
39
  }
40
+ // Per-record principalId (federation-edge-hardening slice 3a) — INFORMATIONAL
41
+ // only; the receiver (resources/Federation.ts) never treats it as verified
42
+ // identity or uses it in any auth decision. Sourced from the write-time
43
+ // provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
44
+ // when present. `provenance` is persisted as a JSON STRING (not an object),
45
+ // so it must be parsed — a raw `row.provenance?.verified?.agentId` would
46
+ // silently always be undefined. Soul/Agent/Relationship rows never carry a
47
+ // provenance stamp today, so this is a no-op for them.
48
+ function principalIdFromRow(row) {
49
+ if (typeof row?.provenance !== "string" || row.provenance.length === 0)
50
+ return undefined;
51
+ try {
52
+ return JSON.parse(row.provenance)?.verified?.agentId ?? undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ // Federation push private-visibility filter — inlined for the SAME reason as
59
+ // the crypto helpers above (see comment there; also resources/memory-
60
+ // visibility.ts, the canonical definition; the two must stay in sync).
61
+ //
62
+ // federation-edge-hardening slice 2 (the office-visibility read leak: one rule, one place): the
63
+ // push side of federation sync (runFederationSyncOnce below) must exclude
64
+ // `private` Memory rows from what gets sent to peers, using the EXACT same
65
+ // "not private" semantics as resources/memory-read-scope.ts's resolveReadScope()
66
+ // — a record with NO visibility field (legacy, pre-dates the field) is NOT
67
+ // private and must keep syncing exactly as before. Only `visibility ===
68
+ // "private"` is excluded; null/undefined/"shared"/anything else is included.
69
+ const FEDERATION_PRIVATE_VISIBILITY = "private";
70
+ function isFederationPrivateVisibility(visibility) {
71
+ return visibility === FEDERATION_PRIVATE_VISIBILITY;
72
+ }
38
73
  // ─── Secret detection helpers ────────────────────────
39
74
  /**
40
75
  * Check if a value looks like a real secret/password/token.
@@ -113,6 +148,38 @@ export function readAdminPassFileSecure(path) {
113
148
  }
114
149
  return content;
115
150
  }
151
+ function defaultAdminPassPath() {
152
+ return join(homedir(), ".flair", "admin-pass");
153
+ }
154
+ /**
155
+ * Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
156
+ * `principal add`) without requiring `--admin-pass` on every call (#590).
157
+ *
158
+ * Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
159
+ * env → the secure `~/.flair/admin-pass` file `flair init` already writes with
160
+ * mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
161
+ *
162
+ * When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
163
+ * and file legs are skipped entirely. This is the security-critical guard: a
164
+ * `--target`/`--ops-target` deploy must never silently reuse THIS machine's
165
+ * local admin secret against someone else's Harper instance. Remote callers
166
+ * keep requiring an explicit `--admin-pass`.
167
+ *
168
+ * Throws (via readAdminPassFileSecure) if the file exists but has unsafe
169
+ * permissions, so a misconfigured file surfaces as an actionable chmod error
170
+ * instead of a generic "admin pass required" message.
171
+ */
172
+ function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
173
+ if (explicit)
174
+ return explicit;
175
+ if (isRemoteTarget)
176
+ return undefined;
177
+ if (process.env.FLAIR_ADMIN_PASS)
178
+ return process.env.FLAIR_ADMIN_PASS;
179
+ if (!existsSync(adminPassPath))
180
+ return undefined;
181
+ return readAdminPassFileSecure(adminPassPath);
182
+ }
116
183
  function defaultKeysDir() {
117
184
  return join(homedir(), ".flair", "keys");
118
185
  }
@@ -558,6 +625,90 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
558
625
  }
559
626
  }
560
627
  }
628
+ // ─── Doctor: client-integration network checks (flair#588) ────────────────────
629
+ //
630
+ // The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
631
+ // live in src/doctor-client.ts. These two are network-dependent and live here
632
+ // because they reuse authFetch/resolveKeyPath, which are private to this file.
633
+ /**
634
+ * Quick, offline-tolerant reachability probe for a Flair instance's HTTP
635
+ * endpoint — GETs /Health with a short timeout. Never hangs, never throws:
636
+ * any failure (timeout, DNS, connection refused, bad URL) is "unreachable".
637
+ * Mirrors the doctor action's own probePort helper (3000ms AbortSignal.timeout
638
+ * style), but takes a full URL since client configs point at arbitrary hosts.
639
+ */
640
+ export async function probeFlairReachable(url, timeoutMs = 2000) {
641
+ try {
642
+ const res = await fetch(`${url.replace(/\/+$/, "")}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
643
+ return res.status > 0;
644
+ }
645
+ catch {
646
+ return false;
647
+ }
648
+ }
649
+ /**
650
+ * Is `agentId` actually registered on the Flair instance at `baseUrl`? Signs
651
+ * GET /Agent/:id with the agent's own key (same pattern as the `flair init`
652
+ * verification at line ~2043 and `flair agent rotate` at line ~2596) —
653
+ * reuses authFetch/resolveKeyPath rather than duplicating the signing logic.
654
+ *
655
+ * 200 -> "registered"
656
+ * 401/403 carrying the server's "unknown_agent" signal -> "not-registered"
657
+ * (see below — this is the actual live behavior for a missing agent, NOT
658
+ * 404)
659
+ * any other status, or a network error/timeout -> "unreachable" (could not
660
+ * verify one way or the other — e.g. a bare 401/403/500 doesn't tell us
661
+ * whether the agent exists, so we don't claim NOT registered on those)
662
+ * no local key found for agentId (checked resolveKeyPath, then keysDir) -> "no-key"
663
+ * (can't sign the request at all — distinct from "unreachable" so the
664
+ * caller can print an accurate reason)
665
+ *
666
+ * Why not 404: an unregistered agent never actually reaches the /Agent/:id
667
+ * resource handler (which is where a 404 would come from) — Flair's own
668
+ * signed-auth middleware (resources/auth-middleware.ts) rejects the request
669
+ * first, once it can't find an Agent record matching the signing identity.
670
+ * On current main that's an explicit `401 {"error":"unknown_agent"}` — Live-
671
+ * verified 2026-07-07 against a local Flair instance with a resolvable-but-
672
+ * unregistered signing key: `401 Unauthorized`, body `{"error":"unknown_agent"}`.
673
+ * Some server versions/paths may instead surface Harper's native
674
+ * AccessViolation as a 403 for the same condition, so both codes are checked
675
+ * — but ONLY when the response also carries the unknown-agent marker; a bare
676
+ * 401/403 without it (e.g. a real AccessViolation for an agent that exists
677
+ * but fails a resource-level authorization check) stays "unreachable", since
678
+ * the server can't always distinguish "agent doesn't exist" from "signing key
679
+ * doesn't match a known agent" and we don't want to falsely claim
680
+ * not-registered on that ambiguity. We only make the not-registered call
681
+ * because we ALREADY have a local signing key that resolved for this
682
+ * agentId (checked above) — so this isn't a client-side key problem, and the
683
+ * server naming the agent unknown is a reliable, actionable signal.
684
+ */
685
+ export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
686
+ let keyPath = resolveKeyPath(agentId);
687
+ if (!keyPath) {
688
+ const candidate = join(keysDir, `${agentId}.key`);
689
+ if (existsSync(candidate))
690
+ keyPath = candidate;
691
+ }
692
+ if (!keyPath) {
693
+ return { state: "no-key", detail: `no local key for agent '${agentId}' to sign the check` };
694
+ }
695
+ try {
696
+ const res = await authFetch(baseUrl, agentId, keyPath, "GET", `/Agent/${agentId}`);
697
+ if (res.ok)
698
+ return { state: "registered" };
699
+ if (res.status === 404)
700
+ return { state: "not-registered" };
701
+ const text = await res.text().catch(() => "");
702
+ if ((res.status === 401 || res.status === 403) && /unknown_agent/i.test(text)) {
703
+ return { state: "not-registered", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
704
+ }
705
+ return { state: "unreachable", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
706
+ }
707
+ catch (err) {
708
+ const message = err instanceof Error ? err.message : String(err);
709
+ return { state: "unreachable", detail: `instance unreachable: ${message.slice(0, 100)}` };
710
+ }
711
+ }
561
712
  // Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
562
713
  // Used during restart to confirm the old Harper process actually exited before
563
714
  // we start polling /Health — otherwise the still-shutting-down old process can
@@ -689,7 +840,7 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
689
840
  throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
690
841
  }
691
842
  }
692
- // ─── Provision Flair on Harper Fabric (ops-2kyi) ───────────────────────────
843
+ // ─── Provision Flair on Harper Fabric ──────────────────────────────────────
693
844
  //
694
845
  // Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
695
846
  // tarball with .env baked in, deploys via ops API, waits for restart, and
@@ -782,7 +933,7 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
782
933
  await waitForFlairRestart(target);
783
934
  console.log("Flair is running ✓");
784
935
  // 3. Provision Harper super_user
785
- // Since ops-lzmg is merged, the username doesn't have to be "admin".
936
+ // Since custom-admin-username support is merged, the username doesn't have to be "admin".
786
937
  // We can use the cluster-admin user directly if it's already a super_user.
787
938
  // Check if cluster admin is already a super_user first:
788
939
  let clusterAdminIsSuperUser = false;
@@ -1161,7 +1312,7 @@ export function probeOpenclawPluginVersion(extensionName) {
1161
1312
  /**
1162
1313
  * Whether a package's status line should be printed in the default `flair
1163
1314
  * upgrade` listing. Suppresses optional-because-openclaw-is-absent lines
1164
- * (ops-p42n) — pure noise on machines without openclaw — unless `--all`
1315
+ * — pure noise on machines without openclaw — unless `--all`
1165
1316
  * (showAll) is set. All other statuses always print.
1166
1317
  */
1167
1318
  export function shouldPrintUpgradeLine(status, showAll) {
@@ -1367,6 +1518,8 @@ program
1367
1518
  .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1368
1519
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
1369
1520
  .option("--skip-smoke", "Skip the MCP smoke test")
1521
+ .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
1522
+ .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
1370
1523
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
1371
1524
  .option("--remote", "When used with --target, init as hub for remote federation")
1372
1525
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -1906,8 +2059,6 @@ program
1906
2059
  console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
1907
2060
  console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
1908
2061
  }
1909
- console.log(`\n Claude Code: Add to your CLAUDE.md:`);
1910
- console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
1911
2062
  // ── MCP client wiring ────────────────────────────────────────────────
1912
2063
  // The full one-command front door: detect installed MCP clients and wire
1913
2064
  // each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
@@ -1976,6 +2127,30 @@ program
1976
2127
  console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1977
2128
  wiringResults.push({ client: "claude-code", message: "snippet printed" });
1978
2129
  }
2130
+ // ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
2131
+ // The MCP block alone isn't a working setup — Claude Code also needs
2132
+ // the bootstrap instruction in CLAUDE.md, or it never calls
2133
+ // mcp__flair__bootstrap and memory silently does nothing. Applied
2134
+ // automatically here (same "just do it" shape as the MCP block
2135
+ // above); --skip-claude-md opts out and prints the exact line to
2136
+ // add by hand instead.
2137
+ const claudeMdResult = applyOrReportClaudeMdBootstrap(process.cwd(), homedir(), !!opts.skipClaudeMd);
2138
+ console.log(` ${claudeMdResult.ok ? "✓" : "•"} ${claudeMdResult.message}`);
2139
+ if (claudeMdResult.hint) {
2140
+ for (const line of claudeMdResult.hint.split("\n"))
2141
+ console.log(` ${line}`);
2142
+ }
2143
+ // ── SessionStart hook (flair#597) ─────────────────────────────────
2144
+ // Auto-recall on session start needs this hook wired into
2145
+ // ~/.claude/settings.json — without it, mcp__flair__bootstrap only
2146
+ // ever runs if the agent remembers to call it itself.
2147
+ // --skip-hook opts out and prints the exact JSON to add by hand.
2148
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook);
2149
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
2150
+ if (hookResult.hint) {
2151
+ for (const line of hookResult.hint.split("\n"))
2152
+ console.log(` ${line}`);
2153
+ }
1979
2154
  }
1980
2155
  else {
1981
2156
  let result;
@@ -2116,7 +2291,6 @@ agent
2116
2291
  const httpPort = resolveHttpPort(opts);
2117
2292
  const opsPort = resolveOpsPort(opts);
2118
2293
  const keysDir = opts.keysDir ?? defaultKeysDir();
2119
- const adminPass = opts.adminPass;
2120
2294
  const adminUser = DEFAULT_ADMIN_USER;
2121
2295
  const name = opts.name ?? id;
2122
2296
  // Where to seed the Agent record. Default is localhost (opsPort). When
@@ -2124,8 +2298,27 @@ agent
2124
2298
  // (#514 — agent add could only ever hit localhost ops). Precedence matches
2125
2299
  // `flair import`: explicit --ops-target > derive from --target > localhost.
2126
2300
  const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
2301
+ const isRemoteTarget = typeof seedOpsTarget === "string";
2302
+ // #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
2303
+ // ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
2304
+ // applied for a remote target — see resolveLocalAdminPass.
2305
+ let adminPass;
2306
+ try {
2307
+ adminPass = resolveLocalAdminPass(opts.adminPass, isRemoteTarget);
2308
+ }
2309
+ catch (err) {
2310
+ console.error(`Error: ${err.message}`);
2311
+ process.exit(1);
2312
+ }
2127
2313
  if (!adminPass) {
2128
- console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
2314
+ if (isRemoteTarget) {
2315
+ console.error("Error: --admin-pass is required for agent add when targeting a remote instance " +
2316
+ "(--target/--ops-target) — the local ~/.flair/admin-pass fallback is not used for remote targets.");
2317
+ }
2318
+ else {
2319
+ console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table). " +
2320
+ "Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
2321
+ }
2129
2322
  process.exit(1);
2130
2323
  }
2131
2324
  mkdirSync(keysDir, { recursive: true });
@@ -2501,15 +2694,26 @@ principal
2501
2694
  .action(async (id, opts) => {
2502
2695
  const opsPort = resolveOpsPort(opts);
2503
2696
  const keysDir = opts.keysDir ?? defaultKeysDir();
2504
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
2505
2697
  const adminUser = DEFAULT_ADMIN_USER;
2506
2698
  const kind = opts.kind ?? "agent";
2507
2699
  const name = opts.name ?? id;
2508
2700
  const isAdmin = opts.admin ?? false;
2509
2701
  const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
2510
2702
  const runtime = opts.runtime;
2703
+ // #590 — same local-only fallback as `agent add`: FLAIR_ADMIN_PASS env, then
2704
+ // the secure ~/.flair/admin-pass file (mode 0600). `principal add` has no
2705
+ // --target/--ops-target (always localhost), so the fallback always applies.
2706
+ let adminPass;
2707
+ try {
2708
+ adminPass = resolveLocalAdminPass(opts.adminPass);
2709
+ }
2710
+ catch (err) {
2711
+ console.error(`Error: ${err.message}`);
2712
+ process.exit(1);
2713
+ }
2511
2714
  if (!adminPass) {
2512
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
2715
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required (or ensure ~/.flair/admin-pass exists, " +
2716
+ "created by `flair init`)");
2513
2717
  process.exit(1);
2514
2718
  }
2515
2719
  // Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
@@ -3662,15 +3866,54 @@ export async function runFederationSyncOnce(opts) {
3662
3866
  }
3663
3867
  const batch = await res.json();
3664
3868
  // For null-updatedAt rows, use createdAt as the effective timestamp.
3665
- // Skip rows created before the last sync cursor.
3666
- rows = rows.concat(batch.filter((r) => r.updatedAt !== null || r.createdAt > since));
3869
+ // Skip rows created before the last sync cursor. Only Memory carries
3870
+ // a `visibility` field (Soul/Agent/Relationship don't see
3871
+ // schemas/memory.graphql vs agent.graphql), so the private-exclusion
3872
+ // filter only applies there; on the other 3 tables `row.visibility`
3873
+ // is always undefined, which isFederationPrivateVisibility() treats
3874
+ // as non-private (included) — a no-op for them.
3875
+ rows = rows.concat(batch
3876
+ .filter((r) => r.updatedAt !== null || r.createdAt > since)
3877
+ .filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility)));
3667
3878
  }
3668
3879
  if (rows.length === 0)
3669
3880
  continue;
3881
+ // Records are signed (below) before they're batched, so the secret key
3882
+ // is needed here rather than only inside sendBatch. Still deferred
3883
+ // until we know THIS table has rows to send — preserves the "don't
3884
+ // load the key on a no-op run" property the original lazy load had.
3885
+ if (!secretKey)
3886
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3670
3887
  let batch = [];
3671
3888
  let batchBytes = 0;
3672
3889
  for (const row of rows) {
3673
- const sr = { table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id };
3890
+ const updatedAt = row.updatedAt ?? row.createdAt;
3891
+ const originatorInstanceId = instance.id;
3892
+ // Per-record signature (federation-edge-hardening slice 3a): signed by
3893
+ // THIS instance — the originator — over a versioned canonical form, so
3894
+ // a receiver (including a hub relaying this record onward to other
3895
+ // spokes) can verify authorship independent of who forwarded the
3896
+ // batch. Closes the hub-relay forgery hole — see
3897
+ // resources/Federation.ts FederationSync.post's verification gate.
3898
+ //
3899
+ // CONTRACT — must match Federation.ts's verification payload
3900
+ // byte-for-byte: keys { v, table, id, data, updatedAt,
3901
+ // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
3902
+ // doesn't matter, but the field SET and values do. `v: 1` versions the
3903
+ // canonical form itself: bump it on BOTH sides together if the signed
3904
+ // field set ever changes, so an old signature fails closed instead of
3905
+ // silently mis-verifying under a new form.
3906
+ //
3907
+ // Additive/backward-compatible: pre-3a receivers don't read
3908
+ // `signature`/`principalId` at all and merge exactly as before.
3909
+ const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
3910
+ const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
3911
+ // Informational only (see principalIdFromRow) — never verified by the
3912
+ // receiver as proof of authorship. Omitted entirely when the row
3913
+ // carries no write-time provenance stamp.
3914
+ const principalId = principalIdFromRow(row);
3915
+ if (principalId)
3916
+ sr.principalId = principalId;
3674
3917
  const srBytes = JSON.stringify(sr).length;
3675
3918
  if (batch.length >= BUDGET_RECORDS || (batch.length > 0 && batchBytes + srBytes > BUDGET_BYTES)) {
3676
3919
  const result = await sendBatch(batch);
@@ -3721,7 +3964,7 @@ export async function runFederationSyncOnce(opts) {
3721
3964
  console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3722
3965
  }
3723
3966
  if (totalBatches === 0) {
3724
- // ops-c7t9: no-change syncs must still ping the hub so it updates the
3967
+ // No-change syncs must still ping the hub so it updates the
3725
3968
  // spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
3726
3969
  // look indistinguishable from dead ones on the hub dashboard.
3727
3970
  try {
@@ -4964,7 +5207,7 @@ function oauthDetailLines(o) {
4964
5207
  }
4965
5208
  // Common localhost ports a running Flair daemon might be on. Used by
4966
5209
  // discoverLocalFlairPort when the configured URL is unreachable, to detect
4967
- // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
5210
+ // config-vs-daemon port drift. Order is ad-hoc — first hit wins.
4968
5211
  //
4969
5212
  // 9926: original default (long-running early installs predate the bump)
4970
5213
  // 19926: current default (DEFAULT_PORT)
@@ -5081,16 +5324,23 @@ const statusCmd = program
5081
5324
  .action(async (opts) => {
5082
5325
  const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
5083
5326
  // When unreachable on a localhost URL, probe candidate ports to detect
5084
- // config-vs-daemon port drift (ops-mbdi). Surface the actually-listening
5327
+ // config-vs-daemon port drift. Surface the actually-listening
5085
5328
  // port with a fix recipe — better UX than just "unreachable."
5086
5329
  let discoveredPort = null;
5087
5330
  if (!healthy && isLocalhostUrl(baseUrl)) {
5088
5331
  discoveredPort = await discoverLocalFlairPort(baseUrl);
5089
5332
  }
5333
+ // Version-behind check (flair#587) — offline-tolerant + cached, so this
5334
+ // never adds meaningful latency or fails `status` when the registry is
5335
+ // unreachable. Independent of Harper health; runs either way.
5336
+ const versionCheckResult = await checkVersion(__pkgVersion);
5337
+ const versionNudge = formatVersionNudge(versionCheckResult);
5090
5338
  if (opts.json) {
5091
5339
  const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
5092
5340
  if (discoveredPort != null)
5093
5341
  out.discoveredPort = discoveredPort;
5342
+ if (versionCheckResult.latest)
5343
+ out.latestVersion = versionCheckResult.latest;
5094
5344
  console.log(JSON.stringify(out, null, 2));
5095
5345
  if (!healthy)
5096
5346
  process.exit(1);
@@ -5110,6 +5360,10 @@ const statusCmd = program
5110
5360
  else {
5111
5361
  console.log(`\n Run: flair start or flair doctor`);
5112
5362
  }
5363
+ if (versionNudge) {
5364
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
5365
+ console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
5366
+ }
5113
5367
  process.exit(1);
5114
5368
  }
5115
5369
  const uptimeSec = healthData?.uptimeSeconds;
@@ -5162,6 +5416,10 @@ const statusCmd = program
5162
5416
  const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
5163
5417
  console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
5164
5418
  console.log(render.kv("URL", baseUrl));
5419
+ if (versionNudge) {
5420
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
5421
+ console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
5422
+ }
5165
5423
  if (scopedWarnings.length > 0) {
5166
5424
  console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
5167
5425
  for (const w of scopedWarnings) {
@@ -5884,7 +6142,7 @@ program
5884
6142
  kind: "bin",
5885
6143
  // Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
5886
6144
  // don't support `--version`, so the bin probe returns null even when
5887
- // the package IS globally installed (ops-p42n). Fall back to the lib
6145
+ // the package IS globally installed. Fall back to the lib
5888
6146
  // probe, which require.resolves the package.json from a sibling global
5889
6147
  // install regardless of PATH or --version support. kind stays "bin" so
5890
6148
  // it remains npm-upgradeable (npm install -g), not the openclaw path.
@@ -5927,7 +6185,7 @@ program
5927
6185
  }
5928
6186
  findings.push({ name, installed, latest, status, kind });
5929
6187
  // Suppress the line for openclaw plugins that are optional-because-
5930
- // openclaw-is-absent (ops-p42n): on machines without openclaw the
6188
+ // openclaw-is-absent: on machines without openclaw the
5931
6189
  // "○ … not installed (openclaw not detected) → … (install via …)"
5932
6190
  // line is pure noise. Still print it when openclaw IS installed
5933
6191
  // (current/outdated) or under --all.
@@ -5946,7 +6204,7 @@ program
5946
6204
  }
5947
6205
  catch { /* skip unavailable packages */ }
5948
6206
  }
5949
- // Scope footer (ops-p42n): make explicit what `flair upgrade` does and
6207
+ // Scope footer: make explicit what `flair upgrade` does and
5950
6208
  // doesn't cover, so "were the others checked?" has a one-line answer.
5951
6209
  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).");
5952
6210
  const outdated = findings.filter((f) => f.status === "outdated");
@@ -6648,9 +6906,17 @@ program
6648
6906
  .option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
6649
6907
  .option("--dry-run", "Resolve package, validate args, skip the deploy call")
6650
6908
  .option("--package-root <dir>", "Override package root (mainly for testing)")
6909
+ .option("--deployment-timeout <ms>", "Milliseconds harper waits for cluster-wide peer replication (env: FABRIC_DEPLOYMENT_TIMEOUT; default: 600000 — harper's own 120s default is too short for Fabric)")
6910
+ .option("--install-timeout <ms>", "Milliseconds harper waits for package install (env: FABRIC_INSTALL_TIMEOUT; default: 600000)")
6911
+ .option("--no-verify", "Skip post-deploy served-API verification (default: verify — on by design, so the CLI can't report success on an empty/broken deploy)")
6912
+ .option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
6913
+ .option("--verify-resource <name>", "Resource to verify is serving after deploy (repeatable; default: derived from the deployed package's dist/resources)", (val, prev) => [...prev, val], [])
6914
+ .option("--deploy-retries <n>", "Retry the full harper deploy this many times on a detected flaky peer-replication failure ONLY — a normal deploy failure (auth, bad package, ...) never retries (default: 2; 0 disables)", "2")
6915
+ .option("--ignore-replication-errors", "If peer replication is still failing once retries are exhausted, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
6651
6916
  .action(async (opts) => {
6652
6917
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
6653
6918
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
6919
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
6654
6920
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
6655
6921
  const deployOpts = {
6656
6922
  fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
@@ -6665,6 +6931,14 @@ program
6665
6931
  restart: opts.restart !== false,
6666
6932
  dryRun: opts.dryRun ?? false,
6667
6933
  packageRoot: opts.packageRoot,
6934
+ deploymentTimeoutMs: Number(opts.deploymentTimeout ?? process.env.FABRIC_DEPLOYMENT_TIMEOUT ?? 600_000),
6935
+ installTimeoutMs: Number(opts.installTimeout ?? process.env.FABRIC_INSTALL_TIMEOUT ?? 600_000),
6936
+ verify: opts.verify !== false,
6937
+ verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
6938
+ verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
6939
+ deployRetries: Number(opts.deployRetries ?? 2),
6940
+ ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
6941
+ onProgress: (msg) => console.log(dim(` ${msg}`)),
6668
6942
  };
6669
6943
  const errors = validateDeployOptions(deployOpts);
6670
6944
  if (errors.length) {
@@ -6689,7 +6963,12 @@ program
6689
6963
  console.log(dim(` package root: ${result.packageRoot}`));
6690
6964
  return;
6691
6965
  }
6692
- console.log(`\n${green("✓")} Flair ${result.version} deployed`);
6966
+ if (result.replicationWarning) {
6967
+ console.log(`\n${yellow("⚠")} Flair ${result.version} deployed to the ORIGIN NODE ONLY — peer replication did not complete (see warning above). The peer will catch up via federation sync or a later deploy.`);
6968
+ }
6969
+ else {
6970
+ console.log(`\n${green("✓")} Flair ${result.version} deployed${deployOpts.verify ? " and verified serving" : ""}`);
6971
+ }
6693
6972
  console.log(`\n URL: ${result.url}`);
6694
6973
  console.log(` Project: ${result.project}`);
6695
6974
  console.log(`\nNext steps:`);
@@ -6703,6 +6982,15 @@ program
6703
6982
  if (hint?.includes("401") || hint?.includes("unauthoriz")) {
6704
6983
  console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
6705
6984
  }
6985
+ if (hint?.includes("component is not serving")) {
6986
+ console.error(dim(" hint: harper reported success but the served API disagrees — check the Fabric Studio component logs for the real deploy error, then retry"));
6987
+ }
6988
+ if (hint?.includes("did not settle")) {
6989
+ console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
6990
+ }
6991
+ if (hint?.includes("peer replication failed after")) {
6992
+ console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
6993
+ }
6706
6994
  process.exit(1);
6707
6995
  }
6708
6996
  });
@@ -6726,6 +7014,24 @@ program
6726
7014
  let issues = 0;
6727
7015
  let harperResponding = false;
6728
7016
  console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
7017
+ // 0. Version check (flair#587) — offline-tolerant + cached, independent
7018
+ // of Harper being up. A gap of ≥2 minor versions (or any major) is
7019
+ // treated as loud/red — heuristic for "likely missed a security fix"
7020
+ // since we don't have advisory data, only the version gap. A red gap
7021
+ // counts as an issue (exit 1); a quieter yellow gap (one minor, or
7022
+ // patch-only) is printed but doesn't fail doctor.
7023
+ const versionCheckResult = await checkVersion(__pkgVersion);
7024
+ const versionNudge = formatVersionNudge(versionCheckResult);
7025
+ if (versionNudge) {
7026
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
7027
+ const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
7028
+ console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
7029
+ if (versionNudge.severity === "red")
7030
+ issues++;
7031
+ }
7032
+ else if (versionCheckResult.latest) {
7033
+ console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
7034
+ }
6729
7035
  // Helper: try to reach Harper on a given port
6730
7036
  async function probePort(p) {
6731
7037
  try {
@@ -6952,6 +7258,150 @@ program
6952
7258
  issues++;
6953
7259
  }
6954
7260
  }
7261
+ // 7. Client integration (flair#588) — the first 6 checks diagnose the
7262
+ // SERVER side. This diagnoses whether Flair is actually wired to a real
7263
+ // MCP client (Claude Code, Codex, Gemini, Cursor): the MCP block present
7264
+ // + reachable + the configured agent genuinely registered (every detected
7265
+ // client), plus CLAUDE.md + the SessionStart hook (Claude Code only,
7266
+ // since only Claude Code has those mechanisms). Reuses detectClients()
7267
+ // rather than reimplementing client detection.
7268
+ console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
7269
+ // Prompt y/N before a content-editing fix, but only when interactive —
7270
+ // in a non-TTY context (CI, scripts) --fix itself is the consent signal,
7271
+ // matching how doctor's other --fix branches already behave unprompted.
7272
+ // Mirrors the confirm pattern at `flair fabric upgrade` (~line 6258).
7273
+ async function confirmFix(question) {
7274
+ if (!process.stdin.isTTY)
7275
+ return true;
7276
+ const { createInterface } = await import("node:readline");
7277
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
7278
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
7279
+ return /^y(es)?$/i.test(answer.trim());
7280
+ }
7281
+ const detectedClients = detectClients().filter((c) => c.detected);
7282
+ if (detectedClients.length === 0) {
7283
+ console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
7284
+ }
7285
+ else {
7286
+ let claudeCodeAgentId;
7287
+ let anyKnownAgentId;
7288
+ for (const client of detectedClients) {
7289
+ const block = readClientMcpBlock(client.id, homedir());
7290
+ if (client.id === "claude-code" && block.agentId)
7291
+ claudeCodeAgentId = block.agentId;
7292
+ if (block.agentId)
7293
+ anyKnownAgentId = anyKnownAgentId ?? block.agentId;
7294
+ if (!block.present) {
7295
+ console.log(` ${render.icons.error} ${client.label}: no Flair MCP server configured in ${render.wrap(render.c.dim, block.configPath)}`);
7296
+ if (autoFix) {
7297
+ if (dryRun) {
7298
+ console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
7299
+ }
7300
+ else {
7301
+ const proceed = await confirmFix(` Wire ${client.label} now? [y/N] `);
7302
+ if (!proceed) {
7303
+ console.log(` Skipped.`);
7304
+ }
7305
+ else {
7306
+ const fixAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
7307
+ if (!fixAgentId) {
7308
+ console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
7309
+ }
7310
+ else {
7311
+ const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl || baseUrl };
7312
+ const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
7313
+ client.id === "codex" ? wireCodex(wireEnv) :
7314
+ client.id === "gemini" ? wireGemini(wireEnv) :
7315
+ wireCursor(wireEnv);
7316
+ console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
7317
+ }
7318
+ }
7319
+ }
7320
+ }
7321
+ else {
7322
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
7323
+ }
7324
+ issues++;
7325
+ continue;
7326
+ }
7327
+ console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
7328
+ const reachable = await probeFlairReachable(block.flairUrl);
7329
+ if (!reachable) {
7330
+ console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} not reachable — cannot verify agent registration`);
7331
+ continue;
7332
+ }
7333
+ console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} reachable`);
7334
+ const reg = await checkAgentRegistered(block.flairUrl, block.agentId, defaultKeysDir());
7335
+ if (reg.state === "registered") {
7336
+ console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
7337
+ }
7338
+ else if (reg.state === "not-registered") {
7339
+ console.log(` ${render.icons.error} agent '${block.agentId}' is NOT registered on this Flair instance`);
7340
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${block.agentId}`);
7341
+ issues++;
7342
+ }
7343
+ else {
7344
+ console.log(` ${render.icons.warn} could not verify agent registration ${render.wrap(render.c.dim, `(${reg.detail})`)}`);
7345
+ }
7346
+ }
7347
+ // Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
7348
+ // has these mechanisms, so only run them when claude-code was detected.
7349
+ if (detectedClients.some((c) => c.id === "claude-code")) {
7350
+ const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
7351
+ if (claudeMd.present) {
7352
+ console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
7353
+ }
7354
+ else {
7355
+ console.log(` ${render.icons.error} CLAUDE.md: bootstrap instruction not found (checked ${render.wrap(render.c.dim, join(process.cwd(), "CLAUDE.md"))} and ${render.wrap(render.c.dim, join(homedir(), ".claude", "CLAUDE.md"))})`);
7356
+ if (autoFix) {
7357
+ if (dryRun) {
7358
+ console.log(` ${render.wrap(render.c.dim, "Would append bootstrap instruction to")} ${join(process.cwd(), "CLAUDE.md")}`);
7359
+ }
7360
+ else {
7361
+ const proceed = await confirmFix(` Add the Flair bootstrap line to ./CLAUDE.md? [y/N] `);
7362
+ if (!proceed) {
7363
+ console.log(` Skipped.`);
7364
+ }
7365
+ else {
7366
+ const fixRes = fixClaudeMdBootstrap(process.cwd());
7367
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
7368
+ }
7369
+ }
7370
+ }
7371
+ else {
7372
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
7373
+ }
7374
+ issues++;
7375
+ }
7376
+ const hook = checkSessionStartHook(homedir());
7377
+ if (hook.present) {
7378
+ console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)}`);
7379
+ }
7380
+ else {
7381
+ console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
7382
+ if (autoFix) {
7383
+ if (dryRun) {
7384
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
7385
+ }
7386
+ else {
7387
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
7388
+ if (!proceed) {
7389
+ console.log(` Skipped.`);
7390
+ }
7391
+ else {
7392
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
7393
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId);
7394
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
7395
+ }
7396
+ }
7397
+ }
7398
+ else {
7399
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
7400
+ }
7401
+ issues++;
7402
+ }
7403
+ }
7404
+ }
6955
7405
  // Summary
6956
7406
  console.log("");
6957
7407
  if (issues === 0) {
@@ -6965,7 +7415,7 @@ program
6965
7415
  process.exit(1);
6966
7416
  });
6967
7417
  // ─── flair session snapshot ──────────────────────────────────────────────────
6968
- // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-ojht). Snapshot a
7418
+ // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
6969
7419
  // session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
6970
7420
  //
6971
7421
  // Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
@@ -7120,10 +7570,10 @@ const memory = program.command("memory").description("Manage agent memories");
7120
7570
  memory.command("add [content]").requiredOption("--agent <id>")
7121
7571
  .option("--content <text>", "memory content (alias for positional arg)")
7122
7572
  .option("--durability <d>", "standard").option("--tags <csv>")
7123
- .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
7573
+ .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
7124
7574
  .option("--subject <text>", "one-line title / entity this memory is about")
7125
7575
  .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
7126
- .option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to a grant-holder) or 'shared' (visible to owner + any agent holding a read/search MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509, ops-2dm3)")
7576
+ .option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to any other agent) or 'shared' (visible to owner + every other agent on this instance — open within the org, not gated by a MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509)")
7127
7577
  .action(async (contentArg, opts) => {
7128
7578
  const content = contentArg ?? opts.content;
7129
7579
  if (!content) {
@@ -7149,7 +7599,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
7149
7599
  console.log(JSON.stringify(out, null, 2));
7150
7600
  });
7151
7601
  // ─── flair memory write-task-summary ────────────────────────────────────────
7152
- // Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-3xyd). Standalone
7602
+ // Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B. Standalone
7153
7603
  // helper that any agent harness (or a manual operator) can invoke at task
7154
7604
  // close to capture a structured task summary as a persistent Memory row
7155
7605
  // before resetting the session.
@@ -7166,7 +7616,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
7166
7616
  memory.command("write-task-summary")
7167
7617
  .description("Capture a structured task summary as a persistent Memory row (used by session-reset harness; standalone-callable by operators)")
7168
7618
  .requiredOption("--agent <id>", "Agent the summary belongs to")
7169
- .requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about (e.g. ops-3xyd)")
7619
+ .requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about")
7170
7620
  .requiredOption("--outcome <s>", "Outcome of the task: merged | rejected | abandoned")
7171
7621
  .option("--summary <text>", "Multi-sentence dense compression (populates Memory.summary; will be the agent's read-time view)")
7172
7622
  .option("--files-touched <csv>", "Comma-separated list of files touched during the task (becomes part of content)")
@@ -7362,7 +7812,7 @@ memory.command("list")
7362
7812
  });
7363
7813
  // ─── flair memory hygiene ────────────────────────────────────────────────────
7364
7814
  // Detect + remove junk memory rows that accumulate over time. Surfaced from
7365
- // the 2026-05-07 manual cleanup (ops-ucyy): an instance had 627 records, ~250 of
7815
+ // a 2026-05-07 manual cleanup: an instance had 627 records, ~250 of
7366
7816
  // them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
7367
7817
  // test content ("the quick brown fox..." / "Flair 251 test ..."), and
7368
7818
  // near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
@@ -7377,8 +7827,8 @@ memory.command("list")
7377
7827
  // Default is all three, dry-run. Flip --apply to actually delete. Always
7378
7828
  // requires admin pass to read across agent scopes (uses ops API).
7379
7829
  //
7380
- // Federation note: this only deletes on the local instance. ops-esun
7381
- // (federation distributed-delete via tombstones) is the systemic answer for
7830
+ // Federation note: this only deletes on the local instance. Federation
7831
+ // distributed-delete via tombstones is the systemic answer for
7382
7832
  // fan-out — until that lands, run `flair memory hygiene` on each peer.
7383
7833
  // Exported for unit testing — keeps the predicate logic separable from
7384
7834
  // the CLI plumbing, ops-API fetching, and confirmation flow.
@@ -7498,7 +7948,7 @@ memory.command("hygiene")
7498
7948
  console.log(`\n\n✅ Deleted ${deleted} rows.`);
7499
7949
  console.log("");
7500
7950
  console.log("Note: this is a local-instance delete. Federated peers will keep their copies until");
7501
- console.log("ops-esun (tombstone-based distributed delete) lands. Until then, run `flair memory");
7951
+ console.log("tombstone-based distributed delete lands. Until then, run `flair memory");
7502
7952
  console.log("hygiene --apply` on each peer to fan out.");
7503
7953
  });
7504
7954
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
@@ -9236,7 +9686,7 @@ presence
9236
9686
  });
9237
9687
  // ─── flair workspace ─────────────────────────────────────────────────────────
9238
9688
  //
9239
- // Coordination write surface (ops-wmgx / Kris #510). `workspace set` writes the
9689
+ // Coordination write surface (Kris #510). `workspace set` writes the
9240
9690
  // agent's OWN WorkspaceState via a signed POST /WorkspaceState. Identity comes
9241
9691
  // from the Ed25519 signature — the body carries NO agentId, so an agent can only
9242
9692
  // write as itself (the WorkspaceState.post() handler overwrites agentId from the
@@ -9306,7 +9756,7 @@ workspace
9306
9756
  });
9307
9757
  // ─── flair orgevent ──────────────────────────────────────────────────────────
9308
9758
  //
9309
- // Coordination write surface (ops-wmgx / Kris #510). `orgevent` publishes an
9759
+ // Coordination write surface (Kris #510). `orgevent` publishes an
9310
9760
  // OrgEvent ATTRIBUTED to the authenticated agent via a signed POST /OrgEvent.
9311
9761
  // The event's authorId comes from the Ed25519 signature — the body carries NO
9312
9762
  // authorId, so an agent CANNOT forge another agent's events (the OrgEvent.post()
@@ -9389,4 +9839,4 @@ if (import.meta.main) {
9389
9839
  await runCli();
9390
9840
  }
9391
9841
  // ─── Exported for testing ─────────────────────────────────────────────────────
9392
- export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, 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, };
9842
+ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, 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, };