@tpsdev-ai/flair 0.20.1 → 0.22.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 (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
package/dist/cli.js CHANGED
@@ -3,16 +3,23 @@ import { Command } from "commander";
3
3
  import nacl from "tweetnacl";
4
4
  import { load as parseYaml } from "js-yaml";
5
5
  import * as render from "./render.js";
6
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
6
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { join, resolve, sep, dirname } from "node:path";
9
9
  import { spawn } from "node:child_process";
10
- import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
10
+ import { createHash, createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
11
11
  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 { checkServerHandshake, formatHandshakeNudge } from "./version-handshake.js";
17
+ import { probeInstance } from "./probe.js";
18
+ import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
19
+ import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
20
+ import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
21
+ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
22
+ import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.js";
16
23
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
17
24
  // src/ into resources/, which don't survive npm packaging (see also
18
25
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -35,6 +42,39 @@ function signBody(body, secretKey) {
35
42
  const sig = nacl.sign.detached(message, secretKey);
36
43
  return Buffer.from(sig).toString("base64url");
37
44
  }
45
+ // Per-record principalId (federation-edge-hardening slice 3a) — INFORMATIONAL
46
+ // only; the receiver (resources/Federation.ts) never treats it as verified
47
+ // identity or uses it in any auth decision. Sourced from the write-time
48
+ // provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
49
+ // when present. `provenance` is persisted as a JSON STRING (not an object),
50
+ // so it must be parsed — a raw `row.provenance?.verified?.agentId` would
51
+ // silently always be undefined. Soul/Agent/Relationship rows never carry a
52
+ // provenance stamp today, so this is a no-op for them.
53
+ function principalIdFromRow(row) {
54
+ if (typeof row?.provenance !== "string" || row.provenance.length === 0)
55
+ return undefined;
56
+ try {
57
+ return JSON.parse(row.provenance)?.verified?.agentId ?? undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ // Federation push private-visibility filter — inlined for the SAME reason as
64
+ // the crypto helpers above (see comment there; also resources/memory-
65
+ // visibility.ts, the canonical definition; the two must stay in sync).
66
+ //
67
+ // federation-edge-hardening slice 2 (the office-visibility read leak: one rule, one place): the
68
+ // push side of federation sync (runFederationSyncOnce below) must exclude
69
+ // `private` Memory rows from what gets sent to peers, using the EXACT same
70
+ // "not private" semantics as resources/memory-read-scope.ts's resolveReadScope()
71
+ // — a record with NO visibility field (legacy, pre-dates the field) is NOT
72
+ // private and must keep syncing exactly as before. Only `visibility ===
73
+ // "private"` is excluded; null/undefined/"shared"/anything else is included.
74
+ const FEDERATION_PRIVATE_VISIBILITY = "private";
75
+ function isFederationPrivateVisibility(visibility) {
76
+ return visibility === FEDERATION_PRIVATE_VISIBILITY;
77
+ }
38
78
  // ─── Secret detection helpers ────────────────────────
39
79
  /**
40
80
  * Check if a value looks like a real secret/password/token.
@@ -86,33 +126,76 @@ const DEFAULT_ADMIN_USER = "admin";
86
126
  const STARTUP_TIMEOUT_MS = 60_000;
87
127
  const HEALTH_POLL_INTERVAL_MS = 500;
88
128
  /**
89
- * Read an admin password from a file, refusing if the file is world/group readable.
129
+ * Read a secret (admin password, Fabric password, ...) from a file, refusing
130
+ * if the file is world/group readable.
90
131
  *
91
- * Admin pass files (default ~/.flair/admin-pass) are short-lived secrets generated
92
- * by `openssl rand` — mode 0600 keeps them out of reach of other local users + most
93
- * backup tooling. A 0644 file silently leaks the secret to anyone with read access
94
- * to the user's home (multi-user hosts, NFS, time-machine snapshots, etc.).
132
+ * Secret files (default ~/.flair/admin-pass; also used for --fabric-password-file)
133
+ * are short-lived values generated by `openssl rand` — mode 0600 keeps them
134
+ * out of reach of other local users + most backup tooling. A 0644 file
135
+ * silently leaks the secret to anyone with read access to the user's home
136
+ * (multi-user hosts, NFS, time-machine snapshots, etc.).
95
137
  *
96
- * Throws with an actionable error if the file isn't owner-only, otherwise returns
97
- * the trimmed file content (admin-pass files generated by `openssl rand -base64`
98
- * conventionally end in a newline).
138
+ * Throws with an actionable error if the file isn't owner-only, otherwise
139
+ * returns the trimmed file content (values generated by `openssl rand
140
+ * -base64` conventionally end in a newline). `flagName` is only used to
141
+ * personalize the error text (e.g. "--admin-pass-file" vs
142
+ * "--fabric-password-file") — the check itself is identical either way.
99
143
  */
100
- export function readAdminPassFileSecure(path) {
144
+ function readSecretFileSecure(path, flagName) {
101
145
  if (!existsSync(path)) {
102
- throw new Error(`--admin-pass-file path does not exist: ${path}`);
146
+ throw new Error(`${flagName} path does not exist: ${path}`);
103
147
  }
104
148
  const st = statSync(path);
105
149
  if (st.mode & 0o077) {
106
150
  const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
107
- throw new Error(`Refusing to read --admin-pass-file at ${path}: permissions ${modeOctal} are too open. ` +
151
+ throw new Error(`Refusing to read ${flagName} at ${path}: permissions ${modeOctal} are too open. ` +
108
152
  `Run \`chmod 600 ${path}\` to restrict to owner-only.`);
109
153
  }
110
154
  const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
111
155
  if (!content) {
112
- throw new Error(`admin password file is empty or contains only whitespace: ${path}`);
156
+ throw new Error(`${flagName}: file is empty or contains only whitespace: ${path}`);
113
157
  }
114
158
  return content;
115
159
  }
160
+ /** `readSecretFileSecure` specialized for --admin-pass-file (see that function for the shared check). */
161
+ export function readAdminPassFileSecure(path) {
162
+ return readSecretFileSecure(path, "--admin-pass-file");
163
+ }
164
+ function defaultAdminPassPath() {
165
+ return join(homedir(), ".flair", "admin-pass");
166
+ }
167
+ /**
168
+ * Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
169
+ * `principal add`) without requiring `--admin-pass` on every call (#590).
170
+ * Also reused by `api()`'s local-target auth fallback (flair#634) — called
171
+ * there as `resolveLocalAdminPass(undefined, !isLocal)`, so only its file leg
172
+ * ever fires (the env leg is already handled by `api()` itself first).
173
+ *
174
+ * Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
175
+ * env → the secure `~/.flair/admin-pass` file `flair init` already writes with
176
+ * mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
177
+ *
178
+ * When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
179
+ * and file legs are skipped entirely. This is the security-critical guard: a
180
+ * `--target`/`--ops-target` deploy must never silently reuse THIS machine's
181
+ * local admin secret against someone else's Harper instance. Remote callers
182
+ * keep requiring an explicit `--admin-pass`.
183
+ *
184
+ * Throws (via readAdminPassFileSecure) if the file exists but has unsafe
185
+ * permissions, so a misconfigured file surfaces as an actionable chmod error
186
+ * instead of a generic "admin pass required" message.
187
+ */
188
+ function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
189
+ if (explicit)
190
+ return explicit;
191
+ if (isRemoteTarget)
192
+ return undefined;
193
+ if (process.env.FLAIR_ADMIN_PASS)
194
+ return process.env.FLAIR_ADMIN_PASS;
195
+ if (!existsSync(adminPassPath))
196
+ return undefined;
197
+ return readAdminPassFileSecure(adminPassPath);
198
+ }
116
199
  function defaultKeysDir() {
117
200
  return join(homedir(), ".flair", "keys");
118
201
  }
@@ -306,24 +389,38 @@ async function api(method, path, body, options) {
306
389
  const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
307
390
  const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
308
391
  const isLocal = isLocalBase(base);
309
- // Auth resolution order:
392
+ // Auth resolution order (flair#634 — local targets used to send NO auth at
393
+ // all here and ride Harper's authorizeLocal forged super_user; #632 gated
394
+ // FederationInstance/FederationPeers behind allowAdmin, so credential-less
395
+ // local calls to those now 403 instead of silently passing):
310
396
  // 1. FLAIR_TOKEN env → Bearer token (backward compat)
311
- // 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth (remote targets only).
312
- // For local targets with authorizeLocal=true, skip Basic auth and let Harper handle it.
397
+ // 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth. Applies to
398
+ // BOTH local and remote targets an explicit env var always wins, local
399
+ // included, so a caller that sets it never depends on authorizeLocal.
313
400
  // 3. FLAIR_AGENT_ID env + key file → Ed25519 signature (standard)
314
- // 4. No auth (Harper authorizeLocal handles local; remote will 401)
401
+ // 4. LOCAL TARGETS ONLY: the secure ~/.flair/admin-pass file `flair init`
402
+ // writes (#593) → Basic admin auth, via the same resolveLocalAdminPass
403
+ // convenience `agent add`/`principal add` already use (#590). Guarded to
404
+ // isLocal so a --target/FLAIR_URL request aimed elsewhere never rides
405
+ // this machine's local admin secret.
406
+ // 5. No auth (remote will 401/403; local now also gets a real 403 from
407
+ // #632-gated resources instead of the old forged-admin passthrough)
315
408
  //
316
409
  // NOTE: this function is for the Harper HTTP/REST API only. The Harper
317
410
  // operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
318
- // does NOT honor authorizeLocal it always requires Basic admin auth, and
319
- // those helpers send it unconditionally. authorizeLocal=true affects this
320
- // path; it does not affect ops-API calls.
411
+ // ALSO honors authorizeLocal: a header-less loopback request to the ops port
412
+ // is auto-authorized as super_user (verified by live probe — flair#610). Those
413
+ // helpers nonetheless send Basic admin auth UNCONDITIONALLY, so they never
414
+ // depend on that ambient elevation and behave identically against a remote or
415
+ // hardened instance. Hardening the ops-API loopback posture itself (bind scope
416
+ // / disabling authorizeLocal there) is tracked separately in flair#654 and is
417
+ // out of scope for this HTTP/REST auth path.
321
418
  let authHeader;
322
419
  const token = process.env.FLAIR_TOKEN;
323
420
  if (token) {
324
421
  authHeader = `Bearer ${token}`;
325
422
  }
326
- else if (!isLocal && (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD)) {
423
+ else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
327
424
  // Admin Basic auth — used by federation, backup, and other admin CLI commands
328
425
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
329
426
  authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
@@ -351,6 +448,25 @@ async function api(method, path, body, options) {
351
448
  }
352
449
  }
353
450
  }
451
+ // Local-only fallback (flair#634): no explicit env, no usable agent key.
452
+ // FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD are already ruled out by this point
453
+ // (handled above), so resolveLocalAdminPass's env leg is a no-op here and
454
+ // this only ever resolves the ~/.flair/admin-pass file — isRemoteTarget
455
+ // is `!isLocal` so it's skipped entirely for --target/FLAIR_URL requests.
456
+ if (!authHeader) {
457
+ try {
458
+ const filePass = resolveLocalAdminPass(undefined, !isLocal);
459
+ if (filePass) {
460
+ authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
461
+ }
462
+ }
463
+ catch (err) {
464
+ // File exists but has unsafe permissions — warn (never the secret
465
+ // itself) and fall through to no-auth.
466
+ const message = err instanceof Error ? err.message : String(err);
467
+ console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
468
+ }
469
+ }
354
470
  }
355
471
  const res = await fetch(`${base}${path}`, {
356
472
  method,
@@ -367,8 +483,19 @@ async function api(method, path, body, options) {
367
483
  return { ok: true };
368
484
  }
369
485
  const text = await res.text();
370
- if (!res.ok)
486
+ if (!res.ok) {
487
+ // 403 with no credentials sent at all is the flair#634 case: a gated
488
+ // resource (e.g. #632's FederationInstance/FederationPeers) rejected a
489
+ // credential-less call. Name the fix instead of surfacing the raw
490
+ // "forbidden" body — never a stack trace.
491
+ if (res.status === 403 && !authHeader) {
492
+ const hint = isLocal
493
+ ? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass."
494
+ : "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
495
+ throw new Error(`HTTP 403: no credentials sent. ${hint}`);
496
+ }
371
497
  throw new Error(text || `HTTP ${res.status}`);
498
+ }
372
499
  if (!text)
373
500
  return { ok: true };
374
501
  return JSON.parse(text);
@@ -558,6 +685,90 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
558
685
  }
559
686
  }
560
687
  }
688
+ // ─── Doctor: client-integration network checks (flair#588) ────────────────────
689
+ //
690
+ // The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
691
+ // live in src/doctor-client.ts. These two are network-dependent and live here
692
+ // because they reuse authFetch/resolveKeyPath, which are private to this file.
693
+ /**
694
+ * Quick, offline-tolerant reachability probe for a Flair instance's HTTP
695
+ * endpoint — GETs /Health with a short timeout. Never hangs, never throws:
696
+ * any failure (timeout, DNS, connection refused, bad URL) is "unreachable".
697
+ * Mirrors the doctor action's own probePort helper (3000ms AbortSignal.timeout
698
+ * style), but takes a full URL since client configs point at arbitrary hosts.
699
+ */
700
+ export async function probeFlairReachable(url, timeoutMs = 2000) {
701
+ try {
702
+ const res = await fetch(`${url.replace(/\/+$/, "")}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
703
+ return res.status > 0;
704
+ }
705
+ catch {
706
+ return false;
707
+ }
708
+ }
709
+ /**
710
+ * Is `agentId` actually registered on the Flair instance at `baseUrl`? Signs
711
+ * GET /Agent/:id with the agent's own key (same pattern as the `flair init`
712
+ * verification at line ~2043 and `flair agent rotate` at line ~2596) —
713
+ * reuses authFetch/resolveKeyPath rather than duplicating the signing logic.
714
+ *
715
+ * 200 -> "registered"
716
+ * 401/403 carrying the server's "unknown_agent" signal -> "not-registered"
717
+ * (see below — this is the actual live behavior for a missing agent, NOT
718
+ * 404)
719
+ * any other status, or a network error/timeout -> "unreachable" (could not
720
+ * verify one way or the other — e.g. a bare 401/403/500 doesn't tell us
721
+ * whether the agent exists, so we don't claim NOT registered on those)
722
+ * no local key found for agentId (checked resolveKeyPath, then keysDir) -> "no-key"
723
+ * (can't sign the request at all — distinct from "unreachable" so the
724
+ * caller can print an accurate reason)
725
+ *
726
+ * Why not 404: an unregistered agent never actually reaches the /Agent/:id
727
+ * resource handler (which is where a 404 would come from) — Flair's own
728
+ * signed-auth middleware (resources/auth-middleware.ts) rejects the request
729
+ * first, once it can't find an Agent record matching the signing identity.
730
+ * On current main that's an explicit `401 {"error":"unknown_agent"}` — Live-
731
+ * verified 2026-07-07 against a local Flair instance with a resolvable-but-
732
+ * unregistered signing key: `401 Unauthorized`, body `{"error":"unknown_agent"}`.
733
+ * Some server versions/paths may instead surface Harper's native
734
+ * AccessViolation as a 403 for the same condition, so both codes are checked
735
+ * — but ONLY when the response also carries the unknown-agent marker; a bare
736
+ * 401/403 without it (e.g. a real AccessViolation for an agent that exists
737
+ * but fails a resource-level authorization check) stays "unreachable", since
738
+ * the server can't always distinguish "agent doesn't exist" from "signing key
739
+ * doesn't match a known agent" and we don't want to falsely claim
740
+ * not-registered on that ambiguity. We only make the not-registered call
741
+ * because we ALREADY have a local signing key that resolved for this
742
+ * agentId (checked above) — so this isn't a client-side key problem, and the
743
+ * server naming the agent unknown is a reliable, actionable signal.
744
+ */
745
+ export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
746
+ let keyPath = resolveKeyPath(agentId);
747
+ if (!keyPath) {
748
+ const candidate = join(keysDir, `${agentId}.key`);
749
+ if (existsSync(candidate))
750
+ keyPath = candidate;
751
+ }
752
+ if (!keyPath) {
753
+ return { state: "no-key", detail: `no local key for agent '${agentId}' to sign the check` };
754
+ }
755
+ try {
756
+ const res = await authFetch(baseUrl, agentId, keyPath, "GET", `/Agent/${agentId}`);
757
+ if (res.ok)
758
+ return { state: "registered" };
759
+ if (res.status === 404)
760
+ return { state: "not-registered" };
761
+ const text = await res.text().catch(() => "");
762
+ if ((res.status === 401 || res.status === 403) && /unknown_agent/i.test(text)) {
763
+ return { state: "not-registered", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
764
+ }
765
+ return { state: "unreachable", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
766
+ }
767
+ catch (err) {
768
+ const message = err instanceof Error ? err.message : String(err);
769
+ return { state: "unreachable", detail: `instance unreachable: ${message.slice(0, 100)}` };
770
+ }
771
+ }
561
772
  // Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
562
773
  // Used during restart to confirm the old Harper process actually exited before
563
774
  // we start polling /Health — otherwise the still-shutting-down old process can
@@ -591,10 +802,13 @@ function readHarperPid(dataDir) {
591
802
  * Seed an agent record via the Harper operations API.
592
803
  * Accepts either a port number (localhost) or a full URL string (--target).
593
804
  *
594
- * `adminPass` is typed as optional but in practice the Harper operations API
595
- * always requires Basic admin auth every existing call site passes one.
596
- * The optional signature leaves headroom for a future Harper that honors
597
- * `authorizeLocal` on its ops endpoint; until then, callers must pass it.
805
+ * `adminPass` is optional: a local caller may omit it and ride Harper's
806
+ * `authorizeLocal`, which auto-authorizes a header-less loopback request to
807
+ * the ops port as super_user (current behavior, verified by live probe —
808
+ * flair#610). When passed, the helper sends Basic admin auth so it never
809
+ * depends on that ambient elevation and behaves identically against a remote
810
+ * or hardened instance. Hardening the ops-API loopback posture is tracked in
811
+ * flair#654.
598
812
  */
599
813
  export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) {
600
814
  const url = typeof opsPortOrUrl === "number"
@@ -651,10 +865,12 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
651
865
  // admin:admin-pass), not the REST API (which needs server-side HDB_ADMIN_PASSWORD
652
866
  // — unavailable on Fabric). Same pattern as seedAgentViaOpsApi above.
653
867
  //
654
- // `adminPass` is optional in the signature for symmetry with seedAgentViaOpsApi
655
- // and to keep the door open for a future Harper that honors authorizeLocal on
656
- // its ops endpoint. Today the Harper operations API always requires Basic admin
657
- // auth; every current caller passes it.
868
+ // `adminPass` is optional (symmetry with seedAgentViaOpsApi): a local caller may
869
+ // omit it and ride authorizeLocal, which the Harper ops API honors today — a
870
+ // header-less loopback request is auto-authorized as super_user (flair#610).
871
+ // When passed, the helper sends Basic admin auth so it never depends on that
872
+ // ambient elevation and behaves identically against a remote or hardened
873
+ // instance. Hardening that posture is tracked in flair#654.
658
874
  export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId, publicKey, role, adminUser, adminPass) {
659
875
  const url = typeof opsPortOrUrl === "number"
660
876
  ? `http://127.0.0.1:${opsPortOrUrl}/`
@@ -689,7 +905,7 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
689
905
  throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
690
906
  }
691
907
  }
692
- // ─── Provision Flair on Harper Fabric (ops-2kyi) ───────────────────────────
908
+ // ─── Provision Flair on Harper Fabric ──────────────────────────────────────
693
909
  //
694
910
  // Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
695
911
  // tarball with .env baked in, deploys via ops API, waits for restart, and
@@ -782,7 +998,7 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
782
998
  await waitForFlairRestart(target);
783
999
  console.log("Flair is running ✓");
784
1000
  // 3. Provision Harper super_user
785
- // Since ops-lzmg is merged, the username doesn't have to be "admin".
1001
+ // Since custom-admin-username support is merged, the username doesn't have to be "admin".
786
1002
  // We can use the cluster-admin user directly if it's already a super_user.
787
1003
  // Check if cluster admin is already a super_user first:
788
1004
  let clusterAdminIsSuperUser = false;
@@ -940,9 +1156,6 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
940
1156
  // WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
941
1157
  // shorthand the flair_pair_initiator spec used, which was harmless only
942
1158
  // because every grant there is false).
943
- // 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
944
- // it needs insert/update — currently read-only here; confirm the writer's
945
- // identity (system vs agent) and widen only if it's the agent.
946
1159
  // Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
947
1160
  // grant (empty = no attribute-level restriction, so the table-level CRUD applies);
948
1161
  // omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
@@ -968,14 +1181,20 @@ const FLAIR_AGENT_PERMISSION = {
968
1181
  Integration: grant(true, true, true, true),
969
1182
  Credential: grant(true, true, true, true),
970
1183
  Presence: grant(true, true, true, false),
1184
+ // MemoryUsage (flair#683): the usage-feedback dedup ledger. Read (own
1185
+ // contributions, scoped in resources/MemoryUsage.ts) + insert (a fresh
1186
+ // contribution row) only — NO update/delete. This is load-bearing, not
1187
+ // just least-privilege tidiness: the dedup rule ("(agent, memory)
1188
+ // contributes ≤ 1") is enforced by requiring a NEW ledger row before
1189
+ // any usageCount bump; if an agent could delete its own row, it could
1190
+ // re-trigger the /RecordUsage endpoint for the same memory indefinitely
1191
+ // (create → count → delete → count again → repeat), defeating the cap
1192
+ // entirely. See resources/MemoryUsage.ts's module doc.
1193
+ MemoryUsage: grant(true, true, false, false),
971
1194
  // Agent: read for discovery, update own card; creation/removal is admin.
972
1195
  Agent: grant(true, false, true, false),
973
1196
  // Read-only reference data.
974
1197
  Instance: grant(true, false, false, false),
975
- // Observatory read-models — public reads; writes are system-driven (gate 3).
976
- ObsOffice: grant(true, false, false, false),
977
- ObsAgentSnapshot: grant(true, false, false, false),
978
- ObsEventFeed: grant(true, false, false, false),
979
1198
  // Federation / OAuth / IdP / internal — system + admin only; agents get none.
980
1199
  Peer: grant(false, false, false, false),
981
1200
  PairingToken: grant(false, false, false, false),
@@ -1161,7 +1380,7 @@ export function probeOpenclawPluginVersion(extensionName) {
1161
1380
  /**
1162
1381
  * Whether a package's status line should be printed in the default `flair
1163
1382
  * upgrade` listing. Suppresses optional-because-openclaw-is-absent lines
1164
- * (ops-p42n) — pure noise on machines without openclaw — unless `--all`
1383
+ * — pure noise on machines without openclaw — unless `--all`
1165
1384
  * (showAll) is set. All other statuses always print.
1166
1385
  */
1167
1386
  export function shouldPrintUpgradeLine(status, showAll) {
@@ -1169,6 +1388,50 @@ export function shouldPrintUpgradeLine(status, showAll) {
1169
1388
  return false;
1170
1389
  return true;
1171
1390
  }
1391
+ /**
1392
+ * Pure flag resolution for `flair upgrade`'s restart/verify defaults
1393
+ * (flair#635 decision: restart is now the default; `--no-restart` opts
1394
+ * out). `--restart` is a deprecated no-op accepted for backward compat —
1395
+ * `deprecatedRestartFlagUsed` tells the caller to print a one-time notice
1396
+ * without re-deriving the raw Commander value itself.
1397
+ *
1398
+ * Commander quirk this relies on: registering both `--restart` (plain
1399
+ * boolean) and `--no-restart` (negatable) on the same command means
1400
+ * `opts.restart` is `undefined` when neither flag is passed, `true` when
1401
+ * `--restart` is passed, and `false` when `--no-restart` is passed — so
1402
+ * `!== false` is the correct "should restart" default-true test, and
1403
+ * `=== true` isolates "the user explicitly typed the deprecated flag".
1404
+ */
1405
+ export function resolveUpgradeRestartVerify(opts) {
1406
+ return {
1407
+ restart: opts.restart !== false,
1408
+ verify: opts.verify !== false,
1409
+ deprecatedRestartFlagUsed: opts.restart === true,
1410
+ };
1411
+ }
1412
+ /**
1413
+ * Whether `flair deploy` / `flair upgrade --target` should run the
1414
+ * post-deploy fleet convergence sweep (flair#636). Registering
1415
+ * `--no-fleet-verify` via commander leaves `opts.fleetVerify` undefined when
1416
+ * unset, `false` when the flag is passed — mirrors resolveUpgradeRestartVerify's
1417
+ * `!== false` default-true idiom above.
1418
+ */
1419
+ export function shouldRunFleetVerify(opts) {
1420
+ return opts.fleetVerify !== false;
1421
+ }
1422
+ export function decideAfterVerify(result, previousVersion) {
1423
+ if (result.ok)
1424
+ return { kind: "ok" };
1425
+ const reason = result.error ?? "post-restart verification failed";
1426
+ if (!previousVersion)
1427
+ return { kind: "cannot-rollback", reason };
1428
+ return { kind: "rollback", reason, toVersion: previousVersion };
1429
+ }
1430
+ export function decideAfterRollbackVerify(result) {
1431
+ if (result.ok)
1432
+ return { kind: "rolled-back" };
1433
+ return { kind: "rollback-failed", reason: result.error ?? "rollback verification failed" };
1434
+ }
1172
1435
  /**
1173
1436
  * Order a soul key→count map for display: highest count first, ties broken
1174
1437
  * alphabetically for stable output. Soul entries are keyed identity facts
@@ -1350,6 +1613,48 @@ const __pkgVersion = (() => {
1350
1613
  })();
1351
1614
  const program = new Command();
1352
1615
  program.name("flair").version(__pkgVersion, "-v, --version");
1616
+ // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
1617
+ // Every command invocation gets a cheap, cached (~60s), short-timeout check
1618
+ // of the running server's version against this CLI's own — catches the
1619
+ // bare-npm-upgrade trap where `npm i -g @tpsdev-ai/flair@latest` swaps the
1620
+ // CLI binary but the already-running Harper daemon keeps serving the OLD
1621
+ // code until `flair restart`. `doctor` is excluded here — it already prints
1622
+ // a richer version triple (CLI/installed, running, latest-published) plus
1623
+ // migration state, so a global hook nudge on top of that would be
1624
+ // redundant noise on the one command whose whole job is this exact report.
1625
+ program.hook("preAction", async (_thisCommand, actionCommand) => {
1626
+ if (actionCommand.name() === "doctor")
1627
+ return;
1628
+ // Interactive-only: this is a pure stderr UX nudge for a human at a
1629
+ // terminal ("bare-npm users must not get stuck"), not a machine-consumed
1630
+ // signal — it never changes exit codes or stdout. Gating on TTY means a
1631
+ // piped/scripted/CI invocation (and every existing test that spawns the
1632
+ // CLI against a mock server) never pays the extra network round trip,
1633
+ // which matters beyond latency: several unit tests spawn this CLI against
1634
+ // a single-shot mock HTTP server asserting on exactly one received
1635
+ // request (e.g. test/unit/presence-set.test.ts) — an unconditional extra
1636
+ // GET /Health here would silently consume that slot and break them.
1637
+ if (!process.stdout.isTTY)
1638
+ return;
1639
+ try {
1640
+ const opts = (actionCommand.opts?.() ?? {});
1641
+ const serverUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
1642
+ // Cache key component: prefer the server's own ROOTPATH if this shell
1643
+ // happens to have it set (operating a non-default Harper instance
1644
+ // root), else fall back to Flair's own resolved data directory — same
1645
+ // "which local install is this" identity every other doctor/status
1646
+ // check already keys off, so a stale cache from a since-reinstalled
1647
+ // instance sharing the same port never bleeds into a fresh one.
1648
+ const rootPath = process.env.ROOTPATH ?? defaultDataDir();
1649
+ const result = await checkServerHandshake(__pkgVersion, rootPath, serverUrl);
1650
+ const nudge = formatHandshakeNudge(result);
1651
+ if (nudge)
1652
+ console.error(`⚠️ ${nudge}`);
1653
+ }
1654
+ catch {
1655
+ // NEVER block or fail the underlying command over this check.
1656
+ }
1657
+ });
1353
1658
  // ─── flair init ──────────────────────────────────────────────────────────────
1354
1659
  program
1355
1660
  .command("init")
@@ -1367,6 +1672,8 @@ program
1367
1672
  .option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
1368
1673
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
1369
1674
  .option("--skip-smoke", "Skip the MCP smoke test")
1675
+ .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
1676
+ .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
1370
1677
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
1371
1678
  .option("--remote", "When used with --target, init as hub for remote federation")
1372
1679
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -1641,6 +1948,12 @@ program
1641
1948
  if (major < 18)
1642
1949
  throw new Error(`Node.js >= 18 required (found ${process.version})`);
1643
1950
  let alreadyRunning = false;
1951
+ // <ROOTPATH>/models — resources/embeddings-provider.ts's resolveModelsDir()
1952
+ // tier 2 default; an operator override already in the environment wins
1953
+ // (tier 1). Scoped above the alreadyRunning branch below (not just inside
1954
+ // the fresh-start path) since the launchd plist step needs it too, even
1955
+ // when Harper was already running and the fresh-spawn branch was skipped.
1956
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
1644
1957
  if (!opts.skipStart) {
1645
1958
  // Check if already running
1646
1959
  try {
@@ -1663,17 +1976,22 @@ program
1663
1976
  // database before the env is initialized).
1664
1977
  const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
1665
1978
  const opsSocket = join(dataDir, "operations-server");
1979
+ // authorizeLocal: false (flair#654) — a credential-less loopback ops-API
1980
+ // request is no longer auto-authorized as super_user. Every ops-API
1981
+ // seed call below (seedAgentViaOpsApi et al.) already passes a real
1982
+ // adminPass via Basic auth, so this does not change local-init behavior.
1666
1983
  const harperSetConfig = JSON.stringify({
1667
1984
  rootPath: dataDir,
1668
1985
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1669
1986
  operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
1670
1987
  mqtt: { network: { port: null }, webSocket: false },
1671
1988
  localStudio: { enabled: false },
1672
- authentication: { authorizeLocal: true, enableSessions: true },
1989
+ authentication: { authorizeLocal: false, enableSessions: true },
1673
1990
  });
1674
1991
  const env = {
1675
1992
  ...process.env,
1676
1993
  ROOTPATH: dataDir,
1994
+ FLAIR_MODELS_DIR: modelsDir,
1677
1995
  HARPER_SET_CONFIG: harperSetConfig,
1678
1996
  DEFAULTS_MODE: "dev",
1679
1997
  HDB_ADMIN_USERNAME: adminUser,
@@ -1684,6 +2002,14 @@ program
1684
2002
  OPERATIONSAPI_NETWORK_PORT: String(opsPort),
1685
2003
  LOCAL_STUDIO: "false",
1686
2004
  };
2005
+ // models (flair#504 Phase 1): the embedding backend registers itself
2006
+ // in-process at boot (resources/embeddings-boot.ts, loaded by
2007
+ // config.yaml's `jsResource` glob) — NOT via a config env var. See
2008
+ // that file's header for why (flair#694: HARPER_CONFIG persisted a
2009
+ // `models.embedding.default` block into harper-config.yaml that an
2010
+ // older/downgraded build's boot would tear down to an invalid empty
2011
+ // shell). FLAIR_MODELS_DIR above is still the channel that tells the
2012
+ // registration where to find/download the model.
1687
2013
  if (alreadyInstalled) {
1688
2014
  console.log("Existing Harper installation found — skipping install.");
1689
2015
  console.log("If something is wrong, run: flair doctor");
@@ -1748,14 +2074,23 @@ program
1748
2074
  mkdirSync(plistDir, { recursive: true });
1749
2075
  const plistPath = join(plistDir, `${label}.plist`);
1750
2076
  const opsSocket = join(dataDir, "operations-server");
2077
+ // authorizeLocal: false (flair#654) — same posture as the initial spawn
2078
+ // above; the launchd-managed process must not diverge from it.
1751
2079
  const setConfig = JSON.stringify({
1752
2080
  rootPath: dataDir,
1753
2081
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1754
2082
  operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
1755
2083
  mqtt: { network: { port: null }, webSocket: false },
1756
2084
  localStudio: { enabled: false },
1757
- authentication: { authorizeLocal: true, enableSessions: true },
2085
+ authentication: { authorizeLocal: false, enableSessions: true },
1758
2086
  });
2087
+ // models (flair#504 Phase 1): no env var needed here — the
2088
+ // launchd-managed process loads the SAME dist/resources/*.js as any
2089
+ // other spawn, so resources/embeddings-boot.ts self-registers the
2090
+ // backend on every KeepAlive restart in-process. See that file's
2091
+ // header (flair#694) for why this replaced the old HARPER_CONFIG
2092
+ // plist line.
2093
+ const escapeXml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1759
2094
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
1760
2095
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1761
2096
  <plist version="1.0">
@@ -1772,7 +2107,8 @@ program
1772
2107
  <key>EnvironmentVariables</key>
1773
2108
  <dict>
1774
2109
  <key>ROOTPATH</key><string>${dataDir}</string>
1775
- <key>HARPER_SET_CONFIG</key><string>${setConfig.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;")}</string>
2110
+ <key>FLAIR_MODELS_DIR</key><string>${modelsDir}</string>
2111
+ <key>HARPER_SET_CONFIG</key><string>${escapeXml(setConfig)}</string>
1776
2112
  <key>DEFAULTS_MODE</key><string>dev</string>
1777
2113
  <key>HDB_ADMIN_USERNAME</key><string>${adminUser}</string>
1778
2114
  <key>HDB_ADMIN_PASSWORD</key><string>${adminPass}</string>
@@ -1906,8 +2242,6 @@ program
1906
2242
  console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
1907
2243
  console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
1908
2244
  }
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
2245
  // ── MCP client wiring ────────────────────────────────────────────────
1912
2246
  // The full one-command front door: detect installed MCP clients and wire
1913
2247
  // each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
@@ -1976,6 +2310,30 @@ program
1976
2310
  console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
1977
2311
  wiringResults.push({ client: "claude-code", message: "snippet printed" });
1978
2312
  }
2313
+ // ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
2314
+ // The MCP block alone isn't a working setup — Claude Code also needs
2315
+ // the bootstrap instruction in CLAUDE.md, or it never calls
2316
+ // mcp__flair__bootstrap and memory silently does nothing. Applied
2317
+ // automatically here (same "just do it" shape as the MCP block
2318
+ // above); --skip-claude-md opts out and prints the exact line to
2319
+ // add by hand instead.
2320
+ const claudeMdResult = applyOrReportClaudeMdBootstrap(process.cwd(), homedir(), !!opts.skipClaudeMd);
2321
+ console.log(` ${claudeMdResult.ok ? "✓" : "•"} ${claudeMdResult.message}`);
2322
+ if (claudeMdResult.hint) {
2323
+ for (const line of claudeMdResult.hint.split("\n"))
2324
+ console.log(` ${line}`);
2325
+ }
2326
+ // ── SessionStart hook (flair#597) ─────────────────────────────────
2327
+ // Auto-recall on session start needs this hook wired into
2328
+ // ~/.claude/settings.json — without it, mcp__flair__bootstrap only
2329
+ // ever runs if the agent remembers to call it itself.
2330
+ // --skip-hook opts out and prints the exact JSON to add by hand.
2331
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook);
2332
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
2333
+ if (hookResult.hint) {
2334
+ for (const line of hookResult.hint.split("\n"))
2335
+ console.log(` ${line}`);
2336
+ }
1979
2337
  }
1980
2338
  else {
1981
2339
  let result;
@@ -2116,7 +2474,6 @@ agent
2116
2474
  const httpPort = resolveHttpPort(opts);
2117
2475
  const opsPort = resolveOpsPort(opts);
2118
2476
  const keysDir = opts.keysDir ?? defaultKeysDir();
2119
- const adminPass = opts.adminPass;
2120
2477
  const adminUser = DEFAULT_ADMIN_USER;
2121
2478
  const name = opts.name ?? id;
2122
2479
  // Where to seed the Agent record. Default is localhost (opsPort). When
@@ -2124,8 +2481,27 @@ agent
2124
2481
  // (#514 — agent add could only ever hit localhost ops). Precedence matches
2125
2482
  // `flair import`: explicit --ops-target > derive from --target > localhost.
2126
2483
  const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
2484
+ const isRemoteTarget = typeof seedOpsTarget === "string";
2485
+ // #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
2486
+ // ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
2487
+ // applied for a remote target — see resolveLocalAdminPass.
2488
+ let adminPass;
2489
+ try {
2490
+ adminPass = resolveLocalAdminPass(opts.adminPass, isRemoteTarget);
2491
+ }
2492
+ catch (err) {
2493
+ console.error(`Error: ${err.message}`);
2494
+ process.exit(1);
2495
+ }
2127
2496
  if (!adminPass) {
2128
- console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table)");
2497
+ if (isRemoteTarget) {
2498
+ console.error("Error: --admin-pass is required for agent add when targeting a remote instance " +
2499
+ "(--target/--ops-target) — the local ~/.flair/admin-pass fallback is not used for remote targets.");
2500
+ }
2501
+ else {
2502
+ console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table). " +
2503
+ "Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
2504
+ }
2129
2505
  process.exit(1);
2130
2506
  }
2131
2507
  mkdirSync(keysDir, { recursive: true });
@@ -2482,6 +2858,108 @@ agent
2482
2858
  }
2483
2859
  console.log(`\n✅ Agent '${id}' removed successfully`);
2484
2860
  });
2861
+ // ─── flair mcp ───────────────────────────────────────────────────────────────
2862
+ // Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
2863
+ // client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
2864
+ // identity key (no new key material, no browser, no human). The plugin side
2865
+ // (HarperFast/oauth, parent issue #159) shipped the full chain in the
2866
+ // published 2.2.0 release: assertion verification (#160/PR #165), CIMD-first
2867
+ // client resolution (#161/#167), and the client_credentials token-endpoint
2868
+ // grant + issuance rate limiting (#170/#171, closing #162/#163). `flair mcp
2869
+ // token` signs the assertion and, by default, requests a real access token
2870
+ // against the token endpoint; see src/mcp-client-assertion.ts.
2871
+ const mcp = program.command("mcp").description("MCP client-credentials agent-auth (RFC 7523 private_key_jwt)");
2872
+ mcp
2873
+ .command("token")
2874
+ .description("Build + sign an RFC 7523 client_assertion and request an MCP client_credentials " +
2875
+ "access token. Caches the minted token (in-process) and reuses it until " +
2876
+ "near-expiry — use --force-refresh to mint unconditionally.")
2877
+ .requiredOption("--agent-id <id>", "Agent id — becomes the client_id (iss/sub claims)")
2878
+ .option("--client-id <url>", "Client ID Metadata Document URL for this agent (defaults to this instance's " +
2879
+ "MCPClientMetadata URL, derived from FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
2880
+ .option("--token-endpoint <url>", "Token-endpoint URL — becomes the `aud` claim (defaults to this instance's " +
2881
+ "own oauth token endpoint, same env vars)")
2882
+ .option("--resource <url>", "RFC 8707 resource indicator for the token request (defaults to this instance's canonical /mcp URI)")
2883
+ .option("--keys-dir <dir>", "Directory to look for <agentId>.key (else FLAIR_KEY_DIR, ~/.flair/keys, ~/.tps/secrets/flair)")
2884
+ .option("--expires-in <seconds>", `Assertion exp - iat window, seconds (default + hard cap: ${MAX_ASSERTION_LIFETIME_SECONDS})`)
2885
+ .option("--dry-run", "Sign the assertion and print what would be sent, but do not call the token endpoint")
2886
+ .option("--force-refresh", "Mint a fresh token even if a cached, not-near-expiry one exists")
2887
+ .option("--json", "Print machine-readable JSON instead of a human summary")
2888
+ .action(async (opts) => {
2889
+ const agentId = opts.agentId;
2890
+ const keyPath = resolveAgentKeyPath(agentId, opts.keysDir);
2891
+ if (!keyPath) {
2892
+ console.error(`Error: no private key found for agent '${agentId}'. Checked --keys-dir, FLAIR_KEY_DIR, ` +
2893
+ `~/.flair/keys, and ~/.tps/secrets/flair.`);
2894
+ process.exit(1);
2895
+ }
2896
+ const clientId = opts.clientId ?? defaultMcpClientId(agentId);
2897
+ if (!clientId) {
2898
+ console.error("Error: --client-id is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive it from " +
2899
+ "this instance's MCPClientMetadata URL).");
2900
+ process.exit(1);
2901
+ }
2902
+ const tokenEndpoint = opts.tokenEndpoint ?? defaultMcpTokenEndpoint();
2903
+ if (!tokenEndpoint) {
2904
+ console.error("Error: --token-endpoint is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive " +
2905
+ "this instance's own oauth token endpoint).");
2906
+ process.exit(1);
2907
+ }
2908
+ const resource = opts.resource ?? defaultMcpResource();
2909
+ const expiresIn = opts.expiresIn ? Number(opts.expiresIn) : undefined;
2910
+ let privateKey;
2911
+ try {
2912
+ privateKey = loadEd25519PrivateKeyFromFile(keyPath);
2913
+ }
2914
+ catch (err) {
2915
+ console.error(`Error: failed to load private key at ${keyPath}: ${err?.message ?? err}`);
2916
+ process.exit(1);
2917
+ }
2918
+ if (opts.dryRun) {
2919
+ const { assertion, claims } = signClientAssertion({
2920
+ clientId,
2921
+ tokenEndpoint,
2922
+ privateKey,
2923
+ expiresInSeconds: expiresIn,
2924
+ });
2925
+ const form = buildTokenRequestForm({ clientId, assertion, resource });
2926
+ if (opts.json) {
2927
+ console.log(JSON.stringify({ assertion, claims, wouldSendForm: form, tokenEndpoint }, null, 2));
2928
+ return;
2929
+ }
2930
+ console.log(`client_assertion (RFC 7523, EdDSA):\n\n${assertion}\n`);
2931
+ console.log(`claims: iss=sub=${claims.iss} aud=${claims.aud} exp-iat=${claims.exp - claims.iat}s jti=${claims.jti}`);
2932
+ console.log(`\n--dry-run: NOT sent. This assertion is the client_assertion value for:\n` +
2933
+ ` POST ${tokenEndpoint}\n ${JSON.stringify(form, null, 2).split("\n").join("\n ")}`);
2934
+ return;
2935
+ }
2936
+ try {
2937
+ const token = await getMcpAccessToken({
2938
+ clientId,
2939
+ tokenEndpoint,
2940
+ privateKey,
2941
+ resource,
2942
+ expiresInSeconds: expiresIn,
2943
+ forceRefresh: Boolean(opts.forceRefresh),
2944
+ });
2945
+ if (opts.json) {
2946
+ console.log(JSON.stringify(token, null, 2));
2947
+ return;
2948
+ }
2949
+ console.log(`access_token minted (${token.tokenType}, expires_in=${token.expiresIn}s):\n\n${token.accessToken}`);
2950
+ if (token.scope)
2951
+ console.log(`\nscope: ${token.scope}`);
2952
+ }
2953
+ catch (err) {
2954
+ if (err instanceof McpTokenRequestError) {
2955
+ console.error(`Error: token request failed (HTTP ${err.status}${err.error ? ` ${err.error}` : ""}): ${err.message}`);
2956
+ }
2957
+ else {
2958
+ console.error(`Error: token request failed: ${err?.message ?? err}`);
2959
+ }
2960
+ process.exit(1);
2961
+ }
2962
+ });
2485
2963
  // ─── flair principal ─────────────────────────────────────────────────────────
2486
2964
  // 1.0 identity management. The Principal model extends Agent — this is the
2487
2965
  // preferred CLI surface for managing identities going forward.
@@ -2501,15 +2979,26 @@ principal
2501
2979
  .action(async (id, opts) => {
2502
2980
  const opsPort = resolveOpsPort(opts);
2503
2981
  const keysDir = opts.keysDir ?? defaultKeysDir();
2504
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
2505
2982
  const adminUser = DEFAULT_ADMIN_USER;
2506
2983
  const kind = opts.kind ?? "agent";
2507
2984
  const name = opts.name ?? id;
2508
2985
  const isAdmin = opts.admin ?? false;
2509
2986
  const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
2510
2987
  const runtime = opts.runtime;
2988
+ // #590 — same local-only fallback as `agent add`: FLAIR_ADMIN_PASS env, then
2989
+ // the secure ~/.flair/admin-pass file (mode 0600). `principal add` has no
2990
+ // --target/--ops-target (always localhost), so the fallback always applies.
2991
+ let adminPass;
2992
+ try {
2993
+ adminPass = resolveLocalAdminPass(opts.adminPass);
2994
+ }
2995
+ catch (err) {
2996
+ console.error(`Error: ${err.message}`);
2997
+ process.exit(1);
2998
+ }
2511
2999
  if (!adminPass) {
2512
- console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
3000
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required (or ensure ~/.flair/admin-pass exists, " +
3001
+ "created by `flair init`)");
2513
3002
  process.exit(1);
2514
3003
  }
2515
3004
  // Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
@@ -3185,7 +3674,7 @@ federation
3185
3674
  }
3186
3675
  });
3187
3676
  // `flair federation reachability` — probe local instance + all paired peers.
3188
- // Productizes ~/ops/scripts/flair-boot-probe.sh: a single command that tells
3677
+ // Productizes flair#695: a single command that tells
3189
3678
  // you whether memories CAN flow across the federation right now. Read-only;
3190
3679
  // no mutations, no side effects beyond a single tagged status read per peer.
3191
3680
  federation
@@ -3662,15 +4151,54 @@ export async function runFederationSyncOnce(opts) {
3662
4151
  }
3663
4152
  const batch = await res.json();
3664
4153
  // 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));
4154
+ // Skip rows created before the last sync cursor. Only Memory carries
4155
+ // a `visibility` field (Soul/Agent/Relationship don't see
4156
+ // schemas/memory.graphql vs agent.graphql), so the private-exclusion
4157
+ // filter only applies there; on the other 3 tables `row.visibility`
4158
+ // is always undefined, which isFederationPrivateVisibility() treats
4159
+ // as non-private (included) — a no-op for them.
4160
+ rows = rows.concat(batch
4161
+ .filter((r) => r.updatedAt !== null || r.createdAt > since)
4162
+ .filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility)));
3667
4163
  }
3668
4164
  if (rows.length === 0)
3669
4165
  continue;
4166
+ // Records are signed (below) before they're batched, so the secret key
4167
+ // is needed here rather than only inside sendBatch. Still deferred
4168
+ // until we know THIS table has rows to send — preserves the "don't
4169
+ // load the key on a no-op run" property the original lazy load had.
4170
+ if (!secretKey)
4171
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3670
4172
  let batch = [];
3671
4173
  let batchBytes = 0;
3672
4174
  for (const row of rows) {
3673
- const sr = { table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id };
4175
+ const updatedAt = row.updatedAt ?? row.createdAt;
4176
+ const originatorInstanceId = instance.id;
4177
+ // Per-record signature (federation-edge-hardening slice 3a): signed by
4178
+ // THIS instance — the originator — over a versioned canonical form, so
4179
+ // a receiver (including a hub relaying this record onward to other
4180
+ // spokes) can verify authorship independent of who forwarded the
4181
+ // batch. Closes the hub-relay forgery hole — see
4182
+ // resources/Federation.ts FederationSync.post's verification gate.
4183
+ //
4184
+ // CONTRACT — must match Federation.ts's verification payload
4185
+ // byte-for-byte: keys { v, table, id, data, updatedAt,
4186
+ // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
4187
+ // doesn't matter, but the field SET and values do. `v: 1` versions the
4188
+ // canonical form itself: bump it on BOTH sides together if the signed
4189
+ // field set ever changes, so an old signature fails closed instead of
4190
+ // silently mis-verifying under a new form.
4191
+ //
4192
+ // Additive/backward-compatible: pre-3a receivers don't read
4193
+ // `signature`/`principalId` at all and merge exactly as before.
4194
+ const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
4195
+ const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
4196
+ // Informational only (see principalIdFromRow) — never verified by the
4197
+ // receiver as proof of authorship. Omitted entirely when the row
4198
+ // carries no write-time provenance stamp.
4199
+ const principalId = principalIdFromRow(row);
4200
+ if (principalId)
4201
+ sr.principalId = principalId;
3674
4202
  const srBytes = JSON.stringify(sr).length;
3675
4203
  if (batch.length >= BUDGET_RECORDS || (batch.length > 0 && batchBytes + srBytes > BUDGET_BYTES)) {
3676
4204
  const result = await sendBatch(batch);
@@ -3721,7 +4249,7 @@ export async function runFederationSyncOnce(opts) {
3721
4249
  console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3722
4250
  }
3723
4251
  if (totalBatches === 0) {
3724
- // ops-c7t9: no-change syncs must still ping the hub so it updates the
4252
+ // No-change syncs must still ping the hub so it updates the
3725
4253
  // spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
3726
4254
  // look indistinguishable from dead ones on the hub dashboard.
3727
4255
  try {
@@ -3818,7 +4346,7 @@ federation
3818
4346
  await runFederationWatch(opts);
3819
4347
  });
3820
4348
  // `flair federation prune` — remove stale spoke peers (never the hub).
3821
- // Productizes ~/ops/scripts/cleanup-stale-fed-peers.sh into a real CLI
4349
+ // Productizes flair#695 into a real CLI
3822
4350
  // subcommand with safety: dry-run is the default, --apply required to delete.
3823
4351
  function parseDuration(spec) {
3824
4352
  // Accept forms like "30d", "12h", "90m". Returns milliseconds.
@@ -3928,7 +4456,7 @@ federation
3928
4456
  });
3929
4457
  // `flair federation verify` — end-to-end roundtrip: write a tagged memory
3930
4458
  // locally, wait for federation push, probe peers for the tag. Productizes
3931
- // ~/ops/scripts/verify-fed-sync.sh. Cleans up the test memory at the end.
4459
+ // flair#695 Cleans up the test memory at the end.
3932
4460
  federation
3933
4461
  .command("verify")
3934
4462
  .description("End-to-end check: write a tagged memory locally and verify it shows up on each peer")
@@ -4162,13 +4690,61 @@ rem
4162
4690
  process.exit(1);
4163
4691
  }
4164
4692
  });
4693
+ // ─── flair rem rapid — pure helpers ──────────────────────────────────────────
4694
+ // Extracted for testability, same pattern as validatePromoteOpts /
4695
+ // decideCandidateAction above: the action callback below spawns api() +
4696
+ // process.exit, which makes it high-effort/low-value to drive directly;
4697
+ // these two functions are the actual decision logic.
4698
+ /** One staged-candidate summary line: `[id] claim, truncated to ~80 chars`. */
4699
+ export function formatCandidateLine(candidate, maxClaimLen = 80) {
4700
+ const claim = candidate.claim ?? "";
4701
+ const truncated = claim.length > maxClaimLen ? `${claim.slice(0, maxClaimLen)}…` : claim;
4702
+ return ` [${candidate.id ?? "?"}] ${truncated}`;
4703
+ }
4704
+ /**
4705
+ * Classifies a thrown /ReflectMemories execute-mode error for CLI display.
4706
+ * `api()` throws `Error(responseBodyText)` for non-2xx responses (see api()
4707
+ * above) — the two execute-mode failure bodies are:
4708
+ * 503 no-backend: { error: "No generative backend configured..." }
4709
+ * 502 distillation_failed: { error: "distillation_failed", detail: "..." }
4710
+ * Any other shape (network errors, the 400/403 actor-resolution errors
4711
+ * prompt mode shares) falls back to "other" — printed as a plain message,
4712
+ * no docs pointer or retry hint attached since neither applies.
4713
+ */
4714
+ export function describeReflectError(message) {
4715
+ try {
4716
+ const parsed = JSON.parse(message);
4717
+ if (parsed && typeof parsed === "object") {
4718
+ if (parsed.error === "distillation_failed") {
4719
+ return { kind: "distillation-failed", text: String(parsed.detail ?? parsed.error) };
4720
+ }
4721
+ if (typeof parsed.error === "string" && parsed.error.startsWith("No generative backend configured")) {
4722
+ return { kind: "no-backend", text: parsed.error };
4723
+ }
4724
+ if (typeof parsed.error === "string") {
4725
+ return { kind: "other", text: parsed.error };
4726
+ }
4727
+ }
4728
+ }
4729
+ catch {
4730
+ // Not a JSON error body — network error, etc. Pass the raw message through.
4731
+ }
4732
+ return { kind: "other", text: message };
4733
+ }
4734
+ // ─── flair rem rapid ──────────────────────────────────────────────────────────
4735
+ // Executes by default (specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md § 3C,
4736
+ // issue #707): distills server-side via /ReflectMemories execute:true and
4737
+ // stages MemoryCandidate rows, printing a staged-candidate summary. --prompt-only
4738
+ // preserves the pre-#710 handoff behavior byte-for-byte, for the bring-your-
4739
+ // own-model workflow.
4165
4740
  rem
4166
4741
  .command("rapid")
4167
- .description("REM — reflection/learning: generate a structured LLM reflection prompt")
4742
+ .description("REM — reflection/learning: distill recent memories into staged candidates")
4168
4743
  .option("--port <port>", "Harper HTTP port")
4169
4744
  .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
4170
4745
  .option("--focus <type>", "lessons_learned | patterns | decisions | errors", "lessons_learned")
4171
4746
  .option("--since <date>", "ISO timestamp lower bound (default: 24h ago)")
4747
+ .option("--prompt-only", "Return the reflection prompt instead of executing (pre-#710 handoff behavior)")
4172
4748
  .action(async (opts) => {
4173
4749
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
4174
4750
  if (!agentId) {
@@ -4177,30 +4753,62 @@ rem
4177
4753
  }
4178
4754
  console.log(`\n-- rem rapid --`);
4179
4755
  console.log(`Agent: ${agentId} Focus: ${opts.focus}`);
4180
- try {
4181
- const body = {
4182
- agentId,
4183
- focus: opts.focus,
4184
- };
4185
- if (opts.since)
4186
- body.since = opts.since;
4187
- const result = await api("POST", "/ReflectMemories", body);
4188
- if (result.error) {
4189
- console.error(`Reflection error: ${result.error}`);
4756
+ const body = {
4757
+ agentId,
4758
+ focus: opts.focus,
4759
+ };
4760
+ if (opts.since)
4761
+ body.since = opts.since;
4762
+ if (opts.promptOnly) {
4763
+ // --prompt-only: EXACT pre-#710 behavior — prompt-return mode, unchanged.
4764
+ try {
4765
+ const result = await api("POST", "/ReflectMemories", body);
4766
+ if (result.error) {
4767
+ console.error(`Reflection error: ${result.error}`);
4768
+ process.exit(1);
4769
+ }
4770
+ console.log(`\nSource memories: ${result.count ?? 0}`);
4771
+ if (result.suggestedTags?.length) {
4772
+ console.log(`Tags: ${result.suggestedTags.join(", ")}`);
4773
+ }
4774
+ console.log("\n--- Reflection Prompt ---");
4775
+ console.log(result.prompt ?? "(no prompt returned)");
4776
+ console.log("--- End Prompt ---\n");
4777
+ console.log("Feed the prompt above to your LLM, then write insights back with:");
4778
+ console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
4779
+ }
4780
+ catch (err) {
4781
+ console.error(`Error: ${err.message}`);
4190
4782
  process.exit(1);
4191
4783
  }
4192
- console.log(`\nSource memories: ${result.count ?? 0}`);
4193
- if (result.suggestedTags?.length) {
4194
- console.log(`Tags: ${result.suggestedTags.join(", ")}`);
4784
+ return;
4785
+ }
4786
+ // Default: execute mode — distill server-side, stage candidates.
4787
+ try {
4788
+ const result = await api("POST", "/ReflectMemories", { ...body, execute: true });
4789
+ const candidates = Array.isArray(result.candidates) ? result.candidates : [];
4790
+ console.log(`\nModel: ${result.model ?? "?"}`);
4791
+ console.log(`Candidates: ${result.count ?? candidates.length}`);
4792
+ if (candidates.length > 0) {
4793
+ console.log();
4794
+ for (const c of candidates)
4795
+ console.log(formatCandidateLine(c));
4195
4796
  }
4196
- console.log("\n--- Reflection Prompt ---");
4197
- console.log(result.prompt ?? "(no prompt returned)");
4198
- console.log("--- End Prompt ---\n");
4199
- console.log("Feed the prompt above to your LLM, then write insights back with:");
4200
- console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
4797
+ console.log(`\nreview: flair rem candidates / flair rem promote <id>`);
4201
4798
  }
4202
4799
  catch (err) {
4203
- console.error(`Error: ${err.message}`);
4800
+ const desc = describeReflectError(err.message ?? String(err));
4801
+ if (desc.kind === "no-backend") {
4802
+ console.error(`Reflection error: ${desc.text}`);
4803
+ console.error(`See docs/rem.md#configuration for how to point Flair at a models: backend.`);
4804
+ }
4805
+ else if (desc.kind === "distillation-failed") {
4806
+ console.error(`Reflection error: distillation failed — ${desc.text}`);
4807
+ console.error(`Retry, or run with --prompt-only for the manual handoff.`);
4808
+ }
4809
+ else {
4810
+ console.error(`Error: ${desc.text}`);
4811
+ }
4204
4812
  process.exit(1);
4205
4813
  }
4206
4814
  });
@@ -4699,6 +5307,11 @@ remNightly
4699
5307
  console.log(`Archived: ${row.archived ?? "—"}`);
4700
5308
  console.log(`Expired: ${row.expired ?? "—"}`);
4701
5309
  }
5310
+ // row.candidates populates when step 5 (distillation) was attempted
5311
+ // this cycle — see src/rem/runner.ts. Absent when dry-run skipped it.
5312
+ if (row.candidates) {
5313
+ console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
5314
+ }
4702
5315
  console.log(`Duration: ${row.durationMs}ms`);
4703
5316
  if (row.errors.length > 0) {
4704
5317
  console.log(`Errors:`);
@@ -4964,7 +5577,7 @@ function oauthDetailLines(o) {
4964
5577
  }
4965
5578
  // Common localhost ports a running Flair daemon might be on. Used by
4966
5579
  // discoverLocalFlairPort when the configured URL is unreachable, to detect
4967
- // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
5580
+ // config-vs-daemon port drift. Order is ad-hoc — first hit wins.
4968
5581
  //
4969
5582
  // 9926: original default (long-running early installs predate the bump)
4970
5583
  // 19926: current default (DEFAULT_PORT)
@@ -5081,16 +5694,23 @@ const statusCmd = program
5081
5694
  .action(async (opts) => {
5082
5695
  const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
5083
5696
  // When unreachable on a localhost URL, probe candidate ports to detect
5084
- // config-vs-daemon port drift (ops-mbdi). Surface the actually-listening
5697
+ // config-vs-daemon port drift. Surface the actually-listening
5085
5698
  // port with a fix recipe — better UX than just "unreachable."
5086
5699
  let discoveredPort = null;
5087
5700
  if (!healthy && isLocalhostUrl(baseUrl)) {
5088
5701
  discoveredPort = await discoverLocalFlairPort(baseUrl);
5089
5702
  }
5703
+ // Version-behind check (flair#587) — offline-tolerant + cached, so this
5704
+ // never adds meaningful latency or fails `status` when the registry is
5705
+ // unreachable. Independent of Harper health; runs either way.
5706
+ const versionCheckResult = await checkVersion(__pkgVersion);
5707
+ const versionNudge = formatVersionNudge(versionCheckResult);
5090
5708
  if (opts.json) {
5091
5709
  const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
5092
5710
  if (discoveredPort != null)
5093
5711
  out.discoveredPort = discoveredPort;
5712
+ if (versionCheckResult.latest)
5713
+ out.latestVersion = versionCheckResult.latest;
5094
5714
  console.log(JSON.stringify(out, null, 2));
5095
5715
  if (!healthy)
5096
5716
  process.exit(1);
@@ -5110,6 +5730,10 @@ const statusCmd = program
5110
5730
  else {
5111
5731
  console.log(`\n Run: flair start or flair doctor`);
5112
5732
  }
5733
+ if (versionNudge) {
5734
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
5735
+ console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
5736
+ }
5113
5737
  process.exit(1);
5114
5738
  }
5115
5739
  const uptimeSec = healthData?.uptimeSeconds;
@@ -5162,6 +5786,10 @@ const statusCmd = program
5162
5786
  const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
5163
5787
  console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
5164
5788
  console.log(render.kv("URL", baseUrl));
5789
+ if (versionNudge) {
5790
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
5791
+ console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
5792
+ }
5165
5793
  if (scopedWarnings.length > 0) {
5166
5794
  console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
5167
5795
  for (const w of scopedWarnings) {
@@ -5771,32 +6399,68 @@ statusCmd
5771
6399
  console.log("\n✅ no warnings");
5772
6400
  }
5773
6401
  });
6402
+ export function resolveFabricCredentials(opts) {
6403
+ const warnings = [];
6404
+ if (opts.fabricUser && !process.env.FABRIC_USER) {
6405
+ warnings.push("warning: --fabric-user passed inline. Consider FABRIC_USER env — " +
6406
+ "a login name in shell history/ps is recon.");
6407
+ }
6408
+ const fabricUser = opts.fabricUser ?? process.env.FABRIC_USER;
6409
+ let fabricPassword;
6410
+ if (opts.fabricPassword) {
6411
+ if (opts.fabricPasswordFile) {
6412
+ warnings.push("warning: --fabric-password (inline) takes precedence over --fabric-password-file " +
6413
+ "when both are given. Pass --fabric-password-file alone to keep the secret out of shell history.");
6414
+ }
6415
+ if (!process.env.FABRIC_PASSWORD) {
6416
+ warnings.push("warning: --fabric-password leaks to shell history. Prefer FABRIC_PASSWORD env or --fabric-password-file.");
6417
+ }
6418
+ fabricPassword = opts.fabricPassword;
6419
+ }
6420
+ else if (opts.fabricPasswordFile) {
6421
+ fabricPassword = readSecretFileSecure(opts.fabricPasswordFile, "--fabric-password-file");
6422
+ }
6423
+ else {
6424
+ fabricPassword = process.env.FABRIC_PASSWORD;
6425
+ }
6426
+ return { fabricUser, fabricPassword, warnings };
6427
+ }
5774
6428
  // ─── flair upgrade --target <fabric> ────────────────────────────────────────
5775
6429
  //
5776
6430
  // One-command upgrade of a Flair instance DEPLOYED to a Harper Fabric cluster.
5777
6431
  // Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
5778
- // fallbacks, password-via-flag warning) and NEVER prints credentials. The
6432
+ // fallbacks, password-via-flag warning, --fabric-password-file see
6433
+ // resolveFabricCredentials above) and NEVER prints credentials. The
5779
6434
  // version-resolution + @harperfast/harper pin + reuse of deploy() lives in
5780
6435
  // src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
5781
6436
  async function runFabricUpgrade(opts) {
5782
6437
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
5783
6438
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
5784
6439
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
5785
- const fabricUser = opts.fabricUser ?? process.env.FABRIC_USER;
5786
- const fabricPassword = opts.fabricPassword ?? process.env.FABRIC_PASSWORD;
6440
+ let fabricUser;
6441
+ let fabricPassword;
6442
+ let credWarnings = [];
6443
+ try {
6444
+ ({ fabricUser, fabricPassword, warnings: credWarnings } = resolveFabricCredentials(opts));
6445
+ }
6446
+ catch (err) {
6447
+ console.error(red(`Error: ${err.message}`));
6448
+ process.exit(1);
6449
+ }
5787
6450
  const check = opts.check ?? false;
5788
6451
  // Creds are not required for --check (read-only registry + best-effort GET),
5789
6452
  // but ARE required to actually deploy.
5790
6453
  if (!check && !(fabricUser && fabricPassword)) {
5791
6454
  console.error(red("flair upgrade --target: credentials required to deploy"));
5792
- console.error(" pass --fabric-user + --fabric-password (or FABRIC_USER / FABRIC_PASSWORD env)");
6455
+ console.error(" set FABRIC_USER + FABRIC_PASSWORD env (safest), or pass --fabric-user + --fabric-password-file <path>");
6456
+ console.error(" inline --fabric-user/--fabric-password also work but leak to shell history — avoid on shared/multi-user hosts");
5793
6457
  console.error(" or use --check to preview the plan without credentials");
5794
6458
  process.exit(1);
5795
6459
  }
5796
- // Warn on password-via-flag (leaks to shell history). Env is preferred.
5797
- if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
5798
- console.error(dim("warning: --fabric-password leaks to shell history. Prefer FABRIC_PASSWORD env."));
5799
- }
6460
+ // Never log the credential VALUES only the flag names, via the
6461
+ // resolver's own warning strings.
6462
+ for (const w of credWarnings)
6463
+ console.error(dim(w));
5800
6464
  const upgradeOpts = {
5801
6465
  target: opts.target,
5802
6466
  project: opts.project,
@@ -5834,6 +6498,29 @@ async function runFabricUpgrade(opts) {
5834
6498
  return;
5835
6499
  }
5836
6500
  console.log(`\n${green("✓")} Fabric upgrade complete.`);
6501
+ // ── Post-upgrade fleet sweep (flair#636) ────────────────────────────────
6502
+ // "deploy complete" from harper's own CLI means "origin took it" — this
6503
+ // confirms every known federation peer actually converged on the version
6504
+ // we just deployed, instead of trusting a single boolean. Skippable with
6505
+ // --no-fleet-verify. fabricUser/fabricPassword are guaranteed set here —
6506
+ // the !check branch above already required both.
6507
+ if (!shouldRunFleetVerify(opts)) {
6508
+ console.log(dim("(--no-fleet-verify: skipping post-upgrade fleet sweep)"));
6509
+ }
6510
+ else {
6511
+ console.log(`\n${green("→")} Fleet verify`);
6512
+ const sweep = await sweepFleet({
6513
+ target: upgradeOpts.target,
6514
+ fabricUser: fabricUser,
6515
+ fabricPassword: fabricPassword,
6516
+ expectVersion: result.plan.targetVersion,
6517
+ });
6518
+ console.log(renderFleetSweepTable(sweep));
6519
+ if (sweep.exitCode !== FLEET_EXIT_OK) {
6520
+ console.error(red(`\n✗ fleet verify failed (exit ${sweep.exitCode}) — upgrade is NOT fully converged.`));
6521
+ process.exit(sweep.exitCode);
6522
+ }
6523
+ }
5837
6524
  }
5838
6525
  catch (err) {
5839
6526
  console.error(red(`\n✗ fabric upgrade failed: ${err.message}`));
@@ -5844,39 +6531,385 @@ async function runFabricUpgrade(opts) {
5844
6531
  process.exit(1);
5845
6532
  }
5846
6533
  }
5847
- // ─── flair upgrade ────────────────────────────────────────────────────────────
5848
- program
5849
- .command("upgrade")
5850
- .description("Upgrade Flair local packages by default, or a deployed Fabric with --target")
5851
- .option("--check", "Only check for updates / show the plan, don't install or deploy")
5852
- .option("--restart", "Restart Flair after upgrade")
5853
- .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
5854
- // ── Fabric upgrade (--target) ────────────────────────────────────────────
5855
- // When --target is passed, upgrade the Flair component DEPLOYED to that
5856
- // Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
5857
- // under the hood with the @harperfast/harper pin baked in (flair#513).
5858
- .option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
5859
- .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER) for --target")
5860
- .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD) for --target")
5861
- .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
5862
- .option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
5863
- .option("--project <name>", "Fabric component name for --target", "flair")
5864
- .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
5865
- .option("--yes", "Skip the confirmation prompt for --target")
5866
- .action(async (opts) => {
5867
- // ── Fabric-upgrade branch ───────────────────────────────────────────────
5868
- if (opts.target) {
5869
- await runFabricUpgrade(opts);
5870
- return;
6534
+ // ─── Pre-upgrade data snapshot (flair#637) ─────────────────────────────────
6535
+ // `flair upgrade` used to swap @tpsdev-ai/flair's own package with no backup
6536
+ // of ~/.flair/data — if an upgrade broke something past the package level
6537
+ // (schema/data, not just code), there was no tested way back. This is cheap
6538
+ // insurance: a timestamped tar.gz of the whole data directory taken right
6539
+ // before the package swap, with a keep-last-3 retention policy.
6540
+ //
6541
+ // Native-backup alternative considered and rejected: Harper ships a
6542
+ // `get_backup` operation (@harperfast/harper's dataLayer/getBackup.ts,
6543
+ // wired in server/serverHelpers/serverUtilities.ts, documented in
6544
+ // components/mcp/tools/schemas/operationDescriptions.ts) that streams a
6545
+ // live backup over the running HTTP operations API. It's available in this
6546
+ // OSS tier (no license/tier gate found in operation_authorization.tsjust
6547
+ // `requires_su`), but it backs up ONE database/table at a time
6548
+ // (GetBackupObject requires `schema`/`table`, or defaults to a single "data"
6549
+ // database) not the whole `~/.flair/data` tree: no config, no
6550
+ // users/roles, no keys, no other schemas. Using it here would mean
6551
+ // enumerating every schema/table and making N authenticated HTTP calls
6552
+ // against a server this same command is about to take down — for a LESS
6553
+ // complete result than a plain recursive file copy, and one that can't run
6554
+ // at all once the server is stopped (it's an operations-API call, not a
6555
+ // standalone filesystem utility). Rejected in favor of the file-level
6556
+ // snapshot below. See docs/upgrade.md for the restore procedure this
6557
+ // produces.
6558
+ const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
6559
+ const UPGRADE_SNAPSHOT_RETAIN = 3;
6560
+ function upgradeSnapshotFileName() {
6561
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
6562
+ return `flair-data-${ts}.tar.gz`;
6563
+ }
6564
+ /**
6565
+ * Snapshot `dataDir` (normally ~/.flair/data) into a timestamped tar.gz
6566
+ * under ~/.flair/upgrade-snapshots/.
6567
+ *
6568
+ * Consistency: the caller is expected to have stopped Flair first (a
6569
+ * running Harper's data dir can be mid-write, and a plain file copy of a
6570
+ * live database directory isn't guaranteed point-in-time consistent —
6571
+ * Harper 5.x's engine is RocksDB, verified from the .sst/WAL/MANIFEST
6572
+ * layout under database/*, and a torn WAL/SST set won't open) — this
6573
+ * function itself doesn't stop anything, it just archives whatever is on
6574
+ * disk right now.
6575
+ *
6576
+ * Preserves file modes exactly — deliberately NOT using tar's `portable`
6577
+ * option (used elsewhere in this file for the deploy tarball and session
6578
+ * snapshots), which flattens every entry's mode to a umask-based "reasonable
6579
+ * default" and would turn 0600 key/admin-pass files into whatever that
6580
+ * default is. Never follows symlinks out of `dataDir`: node-tar already
6581
+ * archives symlinks as symlinks by default (no `follow` option set here),
6582
+ * and the filter below additionally skips any symlink whose resolved target
6583
+ * falls outside `dataDir`, plus any non-regular file (sockets, FIFOs, device
6584
+ * nodes — e.g. a stale `operations-server` domain socket left behind by a
6585
+ * prior run) that tar can't meaningfully archive anyway.
6586
+ *
6587
+ * Throws on any failure — `flair upgrade` treats a snapshot failure as
6588
+ * abort-the-upgrade by default (safe default; --no-snapshot is the opt-out
6589
+ * for hosts that can't spare the time/disk).
6590
+ *
6591
+ * `snapshotRoot` defaults to UPGRADE_SNAPSHOT_ROOT (~/.flair/upgrade-snapshots)
6592
+ * but is an explicit parameter — not read from homedir() internally — so
6593
+ * unit tests can point it at a throwaway temp dir instead of this machine's
6594
+ * real ~/.flair (test/unit/upgrade-data-snapshot.test.ts).
6595
+ */
6596
+ export async function createDataSnapshot(dataDir, snapshotRoot = UPGRADE_SNAPSHOT_ROOT) {
6597
+ mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
6598
+ const snapshotPath = join(snapshotRoot, upgradeSnapshotFileName());
6599
+ // realpath, not just resolve() — on macOS (and some Linux distros) the
6600
+ // system temp dir itself sits behind a symlink (/tmp -> /private/tmp), so
6601
+ // a plain lexical resolve() of `dataDir` would never equal the realpath()
6602
+ // of a symlink target genuinely INSIDE it, misclassifying every in-bounds
6603
+ // symlink as an escape.
6604
+ const resolvedDataDir = realpathSync(resolve(dataDir));
6605
+ const filter = (entryPath) => {
6606
+ // entryPath is relative to `cwd` (dataDir) per tar's create() contract.
6607
+ const abs = resolve(resolvedDataDir, entryPath);
6608
+ let st;
6609
+ try {
6610
+ st = lstatSync(abs);
6611
+ }
6612
+ catch {
6613
+ return false; // vanished between readdir and stat — skip, don't crash the snapshot
6614
+ }
6615
+ if (st.isSocket() || st.isFIFO() || st.isCharacterDevice() || st.isBlockDevice()) {
6616
+ console.error(` (skipping non-regular file in snapshot: ${entryPath})`);
6617
+ return false;
6618
+ }
6619
+ if (st.isSymbolicLink()) {
6620
+ let real;
6621
+ try {
6622
+ real = realpathSync(abs);
6623
+ }
6624
+ catch {
6625
+ console.error(` (skipping broken symlink in snapshot: ${entryPath})`);
6626
+ return false;
6627
+ }
6628
+ const withinDataDir = real === resolvedDataDir || real.startsWith(resolvedDataDir + sep);
6629
+ if (!withinDataDir) {
6630
+ console.error(` (skipping symlink pointing outside the data dir: ${entryPath})`);
6631
+ return false;
6632
+ }
6633
+ }
6634
+ return true;
6635
+ };
6636
+ // preservePaths: true — WITHOUT it, node-tar strips the leading `/` off
6637
+ // any absolute symlink target it archives (found the hard way: an
6638
+ // in-bounds symlink pointing at an absolute path under `dataDir` came
6639
+ // back on extraction as a nonsense RELATIVE path, silently broken). Every
6640
+ // entry path here is already relative (fileList is `["."]`, cwd is
6641
+ // `dataDir`) — this only affects symlink target text, restoring it
6642
+ // verbatim, which is exactly what a same-host restore into the original
6643
+ // ~/.flair/data path needs.
6644
+ await tarCreate({ gzip: true, cwd: resolvedDataDir, file: snapshotPath, filter, preservePaths: true }, ["."]);
6645
+ // Owner-only — the archive can contain 0600 key/admin-pass material.
6646
+ chmodSync(snapshotPath, 0o600);
6647
+ return { path: snapshotPath, bytes: statSync(snapshotPath).size };
6648
+ }
6649
+ /**
6650
+ * Keep only the newest `retain` upgrade snapshots, deleting older ones.
6651
+ * Best-effort: a pruning failure is logged, not thrown — it must never
6652
+ * un-succeed an upgrade whose snapshot already landed safely on disk.
6653
+ * Returns the paths removed.
6654
+ *
6655
+ * `snapshotRoot` is explicit for the same testability reason as
6656
+ * `createDataSnapshot` above.
6657
+ */
6658
+ export function pruneOldSnapshots(retain = UPGRADE_SNAPSHOT_RETAIN, snapshotRoot = UPGRADE_SNAPSHOT_ROOT) {
6659
+ if (!existsSync(snapshotRoot))
6660
+ return [];
6661
+ const removed = [];
6662
+ try {
6663
+ const files = readdirSync(snapshotRoot)
6664
+ .filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
6665
+ .map((f) => join(snapshotRoot, f))
6666
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
6667
+ for (const stale of files.slice(retain)) {
6668
+ try {
6669
+ rmSync(stale, { force: true });
6670
+ removed.push(stale);
6671
+ }
6672
+ catch (err) {
6673
+ console.error(` (could not prune old snapshot ${stale}: ${err.message})`);
6674
+ }
6675
+ }
5871
6676
  }
5872
- const { execSync, execFileSync } = await import("node:child_process");
5873
- const checkOnly = opts.check ?? false;
5874
- const showAll = opts.all ?? false;
5875
- console.log("Checking for updates...\n");
5876
- const packages = [
5877
- {
5878
- name: "@tpsdev-ai/flair",
5879
- kind: "bin",
6677
+ catch (err) {
6678
+ console.error(` (snapshot retention check failed: ${err.message})`);
6679
+ }
6680
+ return removed;
6681
+ }
6682
+ export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir) {
6683
+ if (!flairIsUpgrading)
6684
+ return "not-upgrading";
6685
+ if (!snapshotRequested)
6686
+ return hasDataDir ? "nudge" : "not-upgrading";
6687
+ return hasDataDir ? "snapshot" : "no-data";
6688
+ }
6689
+ /**
6690
+ * The exact non-blocking recommendation nudge printed when `flair upgrade`
6691
+ * runs without --snapshot (the default) and a data dir exists to snapshot.
6692
+ * Exported as a constant — not inlined in two places — so the CLI output and
6693
+ * its unit test assertion can't drift apart. Modeled on Harper's own
6694
+ * upgrade prompt ("if you have not created a backup of your data, we
6695
+ * recommend you cancel and back up before proceeding") but informational,
6696
+ * never blocking: this must stay safe for non-interactive/scripted upgrades.
6697
+ */
6698
+ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
6699
+ "No pre-upgrade snapshot will be taken.",
6700
+ "To capture one first: `flair snapshot create` (physical) or `flair backup` (logical export), or re-run with --snapshot.",
6701
+ ];
6702
+ // ─── flair snapshot ─────────────────────────────────────────────────────────
6703
+ // Explicit, first-class surface for the physical data-dir snapshot mechanism
6704
+ // above (createDataSnapshot / pruneOldSnapshots / UPGRADE_SNAPSHOT_ROOT).
6705
+ // Added alongside the opt-in rewrite of `flair upgrade`'s snapshot trigger
6706
+ // (2026-07-08) so taking one is a real command, not just a side effect of
6707
+ // upgrading with --snapshot.
6708
+ //
6709
+ // Deliberately NOT named/shaped like `flair backup` / `flair restore`
6710
+ // (further below) — those are a LOGICAL export/import of Agent/Memory/Soul
6711
+ // records as JSON over the HTTP API, portable across hosts and versions.
6712
+ // `flair snapshot` is a PHYSICAL, byte-exact tar.gz of the whole
6713
+ // ~/.flair/data directory (RocksDB files, keys, config, admin-pass — every
6714
+ // byte, same host, same version) taken with Flair stopped for consistency.
6715
+ // Different mechanism, different restore procedure, different failure
6716
+ // modes — hence its own namespace (`snapshot create|list|restore`) instead
6717
+ // of overloading the JSON one. Mirrors the `rem snapshot` / `session
6718
+ // snapshot` subcommand idiom used elsewhere in this file.
6719
+ const snapshotCmd = program
6720
+ .command("snapshot")
6721
+ .description("Physical ~/.flair/data snapshots (byte-exact tar.gz, local-only — see `flair backup`/`flair restore` for the logical JSON export/import)");
6722
+ snapshotCmd
6723
+ .command("create")
6724
+ .description("Take a physical snapshot of the Flair data directory now (briefly stops Flair for a consistent copy — use `flair backup` for a no-downtime logical export)")
6725
+ .option("--data-dir <path>", "Data directory to snapshot (default: ~/.flair/data)")
6726
+ .option("--port <port>", "Harper HTTP port (used to quiesce Flair around the snapshot)")
6727
+ .action(async (opts) => {
6728
+ const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
6729
+ const port = resolveHttpPort(opts);
6730
+ if (!existsSync(dataDir)) {
6731
+ console.error(`Error: data directory does not exist: ${dataDir}`);
6732
+ process.exit(1);
6733
+ }
6734
+ console.log(`Snapshotting ${dataDir}...`);
6735
+ console.log("(Flair will be briefly stopped for a point-in-time-consistent copy, then restarted.)");
6736
+ // Same consistency requirement as the upgrade path's snapshot step: a
6737
+ // live RocksDB directory (WAL/MANIFEST/SST) isn't safe to copy while
6738
+ // Flair is running, so this stops Flair, snapshots, and restarts it —
6739
+ // same stop/start helpers `flair upgrade`'s snapshot step uses, so a
6740
+ // standalone `flair snapshot create` gives the exact same
6741
+ // point-in-time-consistent guarantee, not a weaker one.
6742
+ let stoppedForSnapshot = false;
6743
+ try {
6744
+ await stopFlairProcess(port);
6745
+ stoppedForSnapshot = true;
6746
+ const snapshot = await createDataSnapshot(dataDir);
6747
+ const removed = pruneOldSnapshots();
6748
+ console.log(`✅ Snapshot: ${snapshot.path} (${humanBytes(snapshot.bytes)})`);
6749
+ if (removed.length > 0) {
6750
+ console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
6751
+ }
6752
+ }
6753
+ catch (err) {
6754
+ console.error(`❌ snapshot failed: ${err.message}`);
6755
+ if (stoppedForSnapshot) {
6756
+ try {
6757
+ await startFlairProcess(port);
6758
+ }
6759
+ catch { /* best effort — surface the original snapshot error, not this */ }
6760
+ }
6761
+ process.exit(1);
6762
+ }
6763
+ try {
6764
+ await startFlairProcess(port);
6765
+ }
6766
+ catch (err) {
6767
+ console.error(`❌ the snapshot succeeded but Flair failed to restart: ${err.message}`);
6768
+ console.error(" Check: flair doctor");
6769
+ process.exit(1);
6770
+ }
6771
+ });
6772
+ snapshotCmd
6773
+ .command("list")
6774
+ .description("List physical data snapshots under ~/.flair/upgrade-snapshots/")
6775
+ .option("--json", "Output as JSON")
6776
+ .action((opts) => {
6777
+ if (!existsSync(UPGRADE_SNAPSHOT_ROOT)) {
6778
+ if (opts.json) {
6779
+ console.log("[]");
6780
+ return;
6781
+ }
6782
+ console.log(`(no snapshots — ${UPGRADE_SNAPSHOT_ROOT} does not exist yet)`);
6783
+ console.log("Run `flair snapshot create` to make one, or `flair upgrade --snapshot` to take one automatically before an upgrade.");
6784
+ return;
6785
+ }
6786
+ const rows = readdirSync(UPGRADE_SNAPSHOT_ROOT)
6787
+ .filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
6788
+ .map((f) => {
6789
+ const p = join(UPGRADE_SNAPSHOT_ROOT, f);
6790
+ const s = statSync(p);
6791
+ return { file: f, path: p, size: s.size, mtime: s.mtime.toISOString() };
6792
+ })
6793
+ .sort((a, b) => b.mtime.localeCompare(a.mtime));
6794
+ if (opts.json) {
6795
+ console.log(JSON.stringify(rows, null, 2));
6796
+ return;
6797
+ }
6798
+ if (rows.length === 0) {
6799
+ console.log("(no snapshots)");
6800
+ return;
6801
+ }
6802
+ const fileW = Math.max(20, ...rows.map((r) => r.file.length));
6803
+ console.log(` ${"file".padEnd(fileW)} size age`);
6804
+ for (const r of rows) {
6805
+ console.log(` ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
6806
+ }
6807
+ console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
6808
+ });
6809
+ snapshotCmd
6810
+ .command("restore <path>")
6811
+ .description("Restore a physical snapshot: stops Flair, replaces the data directory, restarts")
6812
+ .option("--data-dir <path>", "Data directory to replace (default: ~/.flair/data)")
6813
+ .option("--port <port>", "Harper HTTP port")
6814
+ .option("--yes", "Skip the confirmation prompt (this destroys the current data directory)")
6815
+ .action(async (snapshotArg, opts) => {
6816
+ const snapshotPath = resolve(snapshotArg);
6817
+ if (!existsSync(snapshotPath)) {
6818
+ console.error(`Error: snapshot does not exist: ${snapshotPath}`);
6819
+ process.exit(1);
6820
+ }
6821
+ const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
6822
+ const port = resolveHttpPort(opts);
6823
+ console.log("This will STOP Flair, DELETE the current data directory, and replace it with:");
6824
+ console.log(` snapshot: ${snapshotPath}`);
6825
+ console.log(` target: ${dataDir}`);
6826
+ if (!opts.yes) {
6827
+ if (!process.stdin.isTTY) {
6828
+ console.error("\nError: refusing to destroy the data directory in a non-interactive shell without --yes.");
6829
+ process.exit(1);
6830
+ }
6831
+ const { createInterface } = await import("node:readline");
6832
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
6833
+ const answer = await new Promise((res) => rl.question(`\nDestroy ${dataDir} and restore from this snapshot? [y/N] `, (a) => { rl.close(); res(a); }));
6834
+ if (!/^y(es)?$/i.test(answer.trim())) {
6835
+ console.log("Aborted.");
6836
+ return;
6837
+ }
6838
+ }
6839
+ try {
6840
+ await stopFlairProcess(port);
6841
+ }
6842
+ catch (err) {
6843
+ console.error(`❌ failed to stop Flair: ${err.message}`);
6844
+ process.exit(1);
6845
+ }
6846
+ try {
6847
+ rmSync(dataDir, { recursive: true, force: true });
6848
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
6849
+ // preservePaths mirrors createDataSnapshot's own preservePaths: true —
6850
+ // restores absolute symlink targets verbatim instead of node-tar's
6851
+ // default of stripping the leading "/" on extraction. No `follow`
6852
+ // option, so symlinks extract as symlinks (never their targets'
6853
+ // contents), and file modes extract exactly as stored — the archive
6854
+ // itself already only contains what createDataSnapshot's filter chose
6855
+ // to include (in-bounds symlinks, regular files/dirs only), so restore
6856
+ // needs no re-filtering of its own.
6857
+ await tarExtract({ file: snapshotPath, cwd: dataDir, preservePaths: true });
6858
+ }
6859
+ catch (err) {
6860
+ console.error(`❌ restore failed: ${err.message}`);
6861
+ console.error(` ${dataDir} may be partially restored or empty — do not start Flair until this is resolved.`);
6862
+ process.exit(1);
6863
+ }
6864
+ try {
6865
+ await startFlairProcess(port);
6866
+ }
6867
+ catch (err) {
6868
+ console.error(`❌ restore succeeded but Flair failed to restart: ${err.message}`);
6869
+ console.error(" Check: flair doctor");
6870
+ process.exit(1);
6871
+ }
6872
+ console.log(`✅ Restored ${dataDir} from ${snapshotPath}`);
6873
+ console.log(" Flair restarted. Verify: flair status && flair doctor");
6874
+ });
6875
+ // ─── flair upgrade ────────────────────────────────────────────────────────────
6876
+ program
6877
+ .command("upgrade")
6878
+ .description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
6879
+ .option("--check", "Only check for updates / show the plan, don't install or deploy")
6880
+ .option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
6881
+ .option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
6882
+ .option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
6883
+ .option("--snapshot", "Take a pre-upgrade ~/.flair/data snapshot before the package swap, keep-last-3 retention (default: off — see `flair snapshot create` to take one by hand, or `flair backup` for a logical export; flair#637)")
6884
+ .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
6885
+ // ── Fabric upgrade (--target) ────────────────────────────────────────────
6886
+ // When --target is passed, upgrade the Flair component DEPLOYED to that
6887
+ // Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
6888
+ // under the hood with the @harperfast/harper pin baked in (flair#513).
6889
+ .option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
6890
+ .option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
6891
+ .option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
6892
+ .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
6893
+ .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
6894
+ .option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
6895
+ .option("--project <name>", "Fabric component name for --target", "flair")
6896
+ .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
6897
+ .option("--yes", "Skip the confirmation prompt for --target")
6898
+ .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
6899
+ .action(async (opts) => {
6900
+ // ── Fabric-upgrade branch ───────────────────────────────────────────────
6901
+ if (opts.target) {
6902
+ await runFabricUpgrade(opts);
6903
+ return;
6904
+ }
6905
+ const { execFileSync } = await import("node:child_process");
6906
+ const checkOnly = opts.check ?? false;
6907
+ const showAll = opts.all ?? false;
6908
+ console.log("Checking for updates...\n");
6909
+ const packages = [
6910
+ {
6911
+ name: "@tpsdev-ai/flair",
6912
+ kind: "bin",
5880
6913
  probe: () => probeBinVersion(execFileSync, "flair"),
5881
6914
  },
5882
6915
  {
@@ -5884,7 +6917,7 @@ program
5884
6917
  kind: "bin",
5885
6918
  // Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
5886
6919
  // 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
6920
+ // the package IS globally installed. Fall back to the lib
5888
6921
  // probe, which require.resolves the package.json from a sibling global
5889
6922
  // install regardless of PATH or --version support. kind stays "bin" so
5890
6923
  // it remains npm-upgradeable (npm install -g), not the openclaw path.
@@ -5927,7 +6960,7 @@ program
5927
6960
  }
5928
6961
  findings.push({ name, installed, latest, status, kind });
5929
6962
  // Suppress the line for openclaw plugins that are optional-because-
5930
- // openclaw-is-absent (ops-p42n): on machines without openclaw the
6963
+ // openclaw-is-absent: on machines without openclaw the
5931
6964
  // "○ … not installed (openclaw not detected) → … (install via …)"
5932
6965
  // line is pure noise. Still print it when openclaw IS installed
5933
6966
  // (current/outdated) or under --all.
@@ -5946,7 +6979,7 @@ program
5946
6979
  }
5947
6980
  catch { /* skip unavailable packages */ }
5948
6981
  }
5949
- // Scope footer (ops-p42n): make explicit what `flair upgrade` does and
6982
+ // Scope footer: make explicit what `flair upgrade` does and
5950
6983
  // doesn't cover, so "were the others checked?" has a one-line answer.
5951
6984
  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
6985
  const outdated = findings.filter((f) => f.status === "outdated");
@@ -5978,11 +7011,108 @@ program
5978
7011
  }
5979
7012
  return;
5980
7013
  }
7014
+ // Hoisted here (was previously computed after install/restart) — the
7015
+ // pre-upgrade snapshot below needs to know the target port AND whether a
7016
+ // restart is coming, before any package is touched. Pure function of
7017
+ // `opts` — safe to call this early.
7018
+ const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
7019
+ const upgradePort = resolveHttpPort({});
7020
+ // ── Pre-upgrade data snapshot (flair#637, opt-in as of the 2026-07-08 rewire) ──
7021
+ // Only an @tpsdev-ai/flair package swap touches the code that reads/
7022
+ // writes ~/.flair/data — an flair-mcp-only or openclaw-plugin-only
7023
+ // upgrade never runs different Harper/Flair code against the data, so
7024
+ // there's nothing at risk and nothing to snapshot.
7025
+ //
7026
+ // Decision (Nathan, 2026-07-08): the physical snapshot used to run
7027
+ // automatically on every local upgrade (opt-out via --no-snapshot). That
7028
+ // defaulted every upgrade into tarring the entire data dir (can be
7029
+ // 800MB+, keep-last-3 retention ~2.5GB) for a failure mode the
7030
+ // tested-downgrade guarantee (docs/upgrade.md, test/compat/downgrade-
7031
+ // boot.test.ts) already covers — and it diverged from Harper's own
7032
+ // upgrade CLI, which recommends a backup before proceeding but never
7033
+ // auto-tars the data directory itself. `--snapshot` is now opt-in, off
7034
+ // by default; opting out gets a non-blocking recommendation nudge
7035
+ // instead of a silent skip. The underlying mechanism (createDataSnapshot
7036
+ // / pruneOldSnapshots, the stop-snapshot-restart quiesce dance, and
7037
+ // abort-the-upgrade-on-snapshot-failure) is unchanged — only the trigger
7038
+ // moved from opt-out to opt-in. `flair snapshot create` (below) exposes
7039
+ // the exact same mechanism as a standalone command for anyone who wants
7040
+ // one without wrapping it around an upgrade.
7041
+ const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
7042
+ const snapshotDataDir = defaultDataDir();
7043
+ const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(snapshotDataDir));
7044
+ let snapshotPath = null;
7045
+ if (snapshotDecision === "nudge") {
7046
+ // Non-blocking nudge only — never prompt/block here, this must stay
7047
+ // safe for non-interactive/scripted upgrades. Modeled on Harper's own
7048
+ // upgrade prompt ("if you have not created a backup ... we recommend
7049
+ // you cancel and back up before proceeding") but informational, not a
7050
+ // gate.
7051
+ console.log("");
7052
+ for (const line of UPGRADE_SNAPSHOT_NUDGE_LINES)
7053
+ console.log(render.wrap(render.c.dim, line));
7054
+ }
7055
+ else if (snapshotDecision === "no-data") {
7056
+ console.log(`\n(no data directory at ${snapshotDataDir} yet — nothing to snapshot)`);
7057
+ }
7058
+ else if (snapshotDecision === "snapshot") {
7059
+ console.log("\nSnapshotting data before upgrade...");
7060
+ // Consistency: a running Harper's data dir can be mid-write, and a
7061
+ // plain file copy of a live database directory isn't guaranteed
7062
+ // point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
7063
+ // can tear under a live copy). Stopping first — then immediately
7064
+ // restarting the OLD version, before any package changes — gives a
7065
+ // quiesced, safe-to-copy directory with only a brief blip, even for
7066
+ // --no-restart (the snapshot's correctness doesn't depend on
7067
+ // whether the caller wants a restart AFTER the upgrade — those are
7068
+ // orthogonal). See docs/upgrade.md for the native-backup alternative
7069
+ // considered and rejected (Harper's `get_backup` op backs up one
7070
+ // table/schema at a time over the running HTTP API — not the whole
7071
+ // data dir — and rejecting it here means this path never depends on
7072
+ // the server being up).
7073
+ let stoppedForSnapshot = false;
7074
+ try {
7075
+ await stopFlairProcess(upgradePort);
7076
+ stoppedForSnapshot = true;
7077
+ const snapshot = await createDataSnapshot(snapshotDataDir);
7078
+ snapshotPath = snapshot.path;
7079
+ const removed = pruneOldSnapshots();
7080
+ console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
7081
+ console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
7082
+ if (removed.length > 0) {
7083
+ console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
7084
+ }
7085
+ }
7086
+ catch (err) {
7087
+ console.error(`❌ snapshot failed: ${err.message}`);
7088
+ console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
7089
+ if (stoppedForSnapshot) {
7090
+ try {
7091
+ await startFlairProcess(upgradePort);
7092
+ }
7093
+ catch { /* best effort — surface the original snapshot error, not this */ }
7094
+ }
7095
+ process.exit(1);
7096
+ }
7097
+ try {
7098
+ await startFlairProcess(upgradePort);
7099
+ }
7100
+ catch (err) {
7101
+ console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
7102
+ console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
7103
+ process.exit(1);
7104
+ }
7105
+ }
5981
7106
  // Perform upgrade. `latest` comes from the npm registry's HTTP
5982
7107
  // response, so CodeQL (correctly) treats it as untrusted input.
5983
7108
  // Use execFileSync with argv — the spec `<name>@<version>` becomes a
5984
7109
  // single argument to the upgrade command, no shell to inject into.
5985
7110
  console.log(`\nUpgrading ${totalUpgrades} package${totalUpgrades > 1 ? "s" : ""}...\n`);
7111
+ // Tracked separately (rather than inferred from findings alone) because the
7112
+ // post-restart verify/rollback step below needs to know whether @tpsdev-ai/flair's
7113
+ // OWN install actually succeeded — if it failed, the running version is still the
7114
+ // OLD one and verification should expect that, not the target we failed to reach.
7115
+ let flairInstallFailed = false;
5986
7116
  for (const { pkg, latest } of npmUpgrades) {
5987
7117
  try {
5988
7118
  console.log(` Installing ${pkg}@${latest}...`);
@@ -5991,6 +7121,8 @@ program
5991
7121
  }
5992
7122
  catch (err) {
5993
7123
  console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
7124
+ if (pkg === "@tpsdev-ai/flair")
7125
+ flairInstallFailed = true;
5994
7126
  }
5995
7127
  }
5996
7128
  for (const { pkg, latest } of openclawUpgrades) {
@@ -6013,31 +7145,107 @@ program
6013
7145
  console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
6014
7146
  }
6015
7147
  }
6016
- if (opts.restart) {
6017
- console.log("\nRestarting Flair...");
6018
- try {
6019
- const port = resolveHttpPort({});
6020
- const label = "ai.tpsdev.flair";
6021
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
6022
- if (process.platform === "darwin" && existsSync(plistPath)) {
6023
- try {
6024
- execSync(`launchctl stop ${label}`, { stdio: "pipe" });
6025
- }
6026
- catch { }
6027
- await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
6028
- console.log("✅ Flair restarted with new version");
6029
- }
6030
- else {
6031
- console.log("Run: flair restart");
6032
- }
6033
- }
6034
- catch {
6035
- console.log("Run: flair restart");
6036
- }
7148
+ // ── Restart + verify + rollback (flair#635) ─────────────────────────────
7149
+ // Decision (2026-07-08): restart is now the default post-upgrade step —
7150
+ // installing new code without restarting leaves the OLD process serving
7151
+ // while the version on disk lies about what's actually running.
7152
+ // --no-restart opts back out for the "stage now, bounce later" case.
7153
+ // --restart is kept as a deprecated no-op for old muscle memory.
7154
+ // Upgrade = install restart → verify → (rollback on failure), one
7155
+ // transaction — never report success on a broken restart.
7156
+ const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
7157
+ const previousFlairVersion = flairFinding?.installed ?? null;
7158
+ const expectedFlairVersion = flairFinding?.status === "outdated" && !flairInstallFailed
7159
+ ? flairFinding.latest
7160
+ : flairFinding?.installed ?? null;
7161
+ // shouldRestart/shouldVerify/deprecatedRestartFlagUsed were hoisted above
7162
+ // the pre-upgrade snapshot block — it needs to know these before any
7163
+ // package is touched.
7164
+ if (deprecatedRestartFlagUsed) {
7165
+ console.error("warning: --restart is deprecated and is now a no-op — flair upgrade restarts by default. Use --no-restart to skip it.");
7166
+ }
7167
+ if (!shouldRestart) {
7168
+ console.log("\nRun: flair restart to use the new version");
7169
+ return;
7170
+ }
7171
+ console.log("\nRestarting Flair...");
7172
+ const port = upgradePort;
7173
+ const baseUrl = `http://127.0.0.1:${port}`;
7174
+ try {
7175
+ await restartFlair(port);
7176
+ }
7177
+ catch (err) {
7178
+ console.error(`❌ restart failed: ${err.message}`);
7179
+ console.error(" Flair may be partially down. Check: flair doctor");
7180
+ process.exit(1);
7181
+ }
7182
+ console.log("✅ Flair restarted");
7183
+ if (!shouldVerify) {
7184
+ console.log(" (--no-verify: skipping post-restart verification)");
7185
+ return;
7186
+ }
7187
+ console.log("\nVerifying...");
7188
+ // The authenticated leg dogfoods api()'s local-credential resolution
7189
+ // (flair#640: env > agent key > ~/.flair/admin-pass file) — probeInstance
7190
+ // itself never resolves credentials, it just calls whatever's handed to it.
7191
+ const verify = await probeInstance(baseUrl, {
7192
+ expectVersion: expectedFlairVersion ?? undefined,
7193
+ timeoutMs: STARTUP_TIMEOUT_MS,
7194
+ authedGet: (path) => api("GET", path, undefined, { baseUrl }),
7195
+ });
7196
+ const verdict = decideAfterVerify(verify, previousFlairVersion);
7197
+ if (verdict.kind === "ok") {
7198
+ console.log(`✅ verified: healthy, authenticated${verify.version ? `, running ${verify.version}` : ""}`);
7199
+ return;
7200
+ }
7201
+ console.error(`❌ post-restart verification failed: ${verdict.reason}`);
7202
+ if (verdict.kind === "cannot-rollback") {
7203
+ console.error(" Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown.");
7204
+ console.error(" Check the instance now: flair doctor");
7205
+ process.exit(1);
7206
+ }
7207
+ console.log(`\nRolling back @tpsdev-ai/flair to ${verdict.toVersion}...`);
7208
+ try {
7209
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${verdict.toVersion}`], { stdio: "pipe" });
7210
+ }
7211
+ catch (err) {
7212
+ console.error(`❌ rollback install failed: ${err.message}`);
7213
+ console.error(` Flair is currently running the FAILED version (${expectedFlairVersion ?? "unknown"}). Manual intervention required.`);
7214
+ process.exit(1);
7215
+ }
7216
+ try {
7217
+ await restartFlair(port);
7218
+ }
7219
+ catch (err) {
7220
+ console.error(`❌ rollback restart failed: ${err.message}`);
7221
+ console.error(" Instance state is UNKNOWN — it may be down entirely. Check: flair doctor");
7222
+ process.exit(1);
7223
+ }
7224
+ const rollbackVerify = await probeInstance(baseUrl, {
7225
+ expectVersion: verdict.toVersion,
7226
+ timeoutMs: STARTUP_TIMEOUT_MS,
7227
+ authedGet: (path) => api("GET", path, undefined, { baseUrl }),
7228
+ });
7229
+ const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
7230
+ if (rollbackVerdict.kind === "rolled-back") {
7231
+ console.error(`❌ upgrade failed verification and was rolled back to @tpsdev-ai/flair@${verdict.toVersion}.`);
7232
+ console.error(` Original failure: ${verdict.reason}`);
7233
+ process.exit(1);
7234
+ }
7235
+ console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
7236
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
7237
+ // This double-failure isn't auto-recoverable yet (flair#637) — but if a
7238
+ // pre-upgrade snapshot landed, point at the CONCRETE path instead of
7239
+ // just the issue number, so recovery doesn't start with a GitHub search.
7240
+ if (snapshotPath) {
7241
+ console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
7242
+ console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
6037
7243
  }
6038
7244
  else {
6039
- console.log("\nRun: flair restart to use the new version");
7245
+ console.error(" No pre-upgrade snapshot was taken for this run (snapshot is opt-in — pass --snapshot next time, or ~/.flair/data didn't exist yet).");
7246
+ console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
6040
7247
  }
7248
+ process.exit(1);
6041
7249
  });
6042
7250
  // ─── flair stop ───────────────────────────────────────────────────────────────
6043
7251
  program
@@ -6132,9 +7340,12 @@ program
6132
7340
  }
6133
7341
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
6134
7342
  const opsPort = resolveOpsPort(opts);
7343
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
6135
7344
  const env = {
6136
7345
  ...process.env,
6137
7346
  ROOTPATH: dataDir,
7347
+ // See the matching comment at the install-time spawn site above.
7348
+ FLAIR_MODELS_DIR: modelsDir,
6138
7349
  DEFAULTS_MODE: "dev",
6139
7350
  HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
6140
7351
  HTTP_PORT: String(port),
@@ -6146,6 +7357,8 @@ program
6146
7357
  if (adminPass) {
6147
7358
  env.HDB_ADMIN_PASSWORD = adminPass;
6148
7359
  }
7360
+ // models (flair#504 Phase 1): no env var needed — resources/embeddings-boot.ts
7361
+ // self-registers the backend in-process on every boot (flair#694).
6149
7362
  const proc = spawn(process.execPath, [bin, "run", "."], {
6150
7363
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
6151
7364
  });
@@ -6160,14 +7373,20 @@ program
6160
7373
  }
6161
7374
  });
6162
7375
  // ─── flair restart ────────────────────────────────────────────────────────────
6163
- program
6164
- .command("restart")
6165
- .description("Restart the Flair (Harper) instance")
6166
- .option("--port <port>", "Harper HTTP port")
6167
- .action(async (opts) => {
6168
- const port = resolveHttpPort(opts);
6169
- const platform = process.platform;
6170
- if (platform === "darwin") {
7376
+ /**
7377
+ * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
7378
+ * plist is present (falling back on failure), otherwise a manual SIGTERM by
7379
+ * port. Split out of the old monolithic `restartFlair` (flair#637) so the
7380
+ * pre-upgrade snapshot can quiesce the data directory between a stop and a
7381
+ * start without duplicating this logic — `restartFlair` is now just
7382
+ * `stopFlairProcess` followed by `startFlairProcess`.
7383
+ *
7384
+ * Idempotent-ish: stopping an already-stopped instance is a harmless no-op
7385
+ * on both paths (launchctl stop on an unloaded/idle service, or an empty
7386
+ * `lsof` match).
7387
+ */
7388
+ async function stopFlairProcess(port) {
7389
+ if (process.platform === "darwin") {
6171
7390
  const label = "ai.tpsdev.flair";
6172
7391
  const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
6173
7392
  if (existsSync(plistPath)) {
@@ -6178,9 +7397,10 @@ program
6178
7397
  execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
6179
7398
  }
6180
7399
  catch { }
6181
- // Capture the current PID *before* stopping so we can verify exit. Without
6182
- // this, waitForHealth can race against the still-shutting-down old process
6183
- // and return success before KeepAlive brings the new one up.
7400
+ // Capture the current PID *before* stopping so callers that
7401
+ // immediately restart can verify exit. Without this, waitForHealth
7402
+ // can race against the still-shutting-down old process and return
7403
+ // success before KeepAlive brings the new one up.
6184
7404
  const oldPid = readHarperPid(defaultDataDir());
6185
7405
  try {
6186
7406
  execSync(`launchctl stop ${label}`, { stdio: "pipe" });
@@ -6188,69 +7408,122 @@ program
6188
7408
  catch { }
6189
7409
  if (oldPid)
6190
7410
  await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
6191
- await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
6192
- console.log("✅ Flair restarted");
6193
7411
  return;
6194
7412
  }
6195
7413
  catch (err) {
6196
- console.error(`launchd restart failed, falling back to port restart: ${err.message}`);
7414
+ console.error(`launchd stop failed, falling back to port-based stop: ${err.message}`);
6197
7415
  }
6198
7416
  }
6199
7417
  }
6200
- {
6201
- // Port-based restart (Linux, or macOS fallback)
6202
- console.log("Stopping...");
6203
- try {
6204
- const { execSync } = await import("node:child_process");
6205
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
6206
- if (lsof) {
6207
- for (const pid of lsof.split("\n")) {
6208
- try {
6209
- process.kill(Number(pid.trim()), "SIGTERM");
6210
- }
6211
- catch { }
7418
+ // Port-based stop (Linux, or macOS fallback when no launchd plist)
7419
+ console.log("Stopping...");
7420
+ try {
7421
+ const { execSync } = await import("node:child_process");
7422
+ const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
7423
+ if (lsof) {
7424
+ for (const pid of lsof.split("\n")) {
7425
+ try {
7426
+ process.kill(Number(pid.trim()), "SIGTERM");
6212
7427
  }
6213
- // Wait briefly for shutdown
6214
- await new Promise(r => setTimeout(r, 2000));
7428
+ catch { }
6215
7429
  }
7430
+ // Wait briefly for shutdown
7431
+ await new Promise(r => setTimeout(r, 2000));
6216
7432
  }
6217
- catch { /* not running */ }
6218
- console.log("Starting...");
6219
- const bin = harperBin();
6220
- if (!bin) {
6221
- console.error("❌ Harper binary not found. Run 'flair init' first.");
6222
- process.exit(1);
6223
- }
6224
- const dataDir = defaultDataDir();
6225
- // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
6226
- // Without this, `flair init --admin-pass X` (which only exports HDB_*
6227
- // to the initial Harper spawn) followed by `flair restart` would silently
6228
- // drop admin credentials any subsequent auth'd call returns 401.
6229
- const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
6230
- const env = {
6231
- ...process.env,
6232
- ROOTPATH: dataDir,
6233
- DEFAULTS_MODE: "dev",
6234
- HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
6235
- HTTP_PORT: String(port),
6236
- LOCAL_STUDIO: "false",
6237
- };
6238
- if (adminPass) {
6239
- env.HDB_ADMIN_PASSWORD = adminPass;
6240
- }
6241
- const proc = spawn(process.execPath, [bin, "run", "."], {
6242
- cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
6243
- });
6244
- proc.unref();
6245
- try {
6246
- await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
6247
- console.log("✅ Flair restarted");
6248
- }
6249
- catch {
6250
- console.error("❌ Flair failed to restart within timeout");
6251
- process.exit(1);
7433
+ }
7434
+ catch { /* not running */ }
7435
+ }
7436
+ /**
7437
+ * Start the local Flair (Harper) process launchd `start` on darwin when a
7438
+ * plist is present (falling back on failure), otherwise a direct spawn.
7439
+ * Counterpart to `stopFlairProcess`; see that function's doc comment.
7440
+ */
7441
+ async function startFlairProcess(port) {
7442
+ if (process.platform === "darwin") {
7443
+ const label = "ai.tpsdev.flair";
7444
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
7445
+ if (existsSync(plistPath)) {
7446
+ try {
7447
+ const { execSync } = await import("node:child_process");
7448
+ try {
7449
+ execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
7450
+ }
7451
+ catch { }
7452
+ try {
7453
+ execSync(`launchctl start ${label}`, { stdio: "pipe" });
7454
+ }
7455
+ catch { }
7456
+ await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
7457
+ return;
7458
+ }
7459
+ catch (err) {
7460
+ console.error(`launchd start failed, falling back to direct start: ${err.message}`);
7461
+ }
6252
7462
  }
6253
7463
  }
7464
+ console.log("Starting...");
7465
+ const bin = harperBin();
7466
+ if (!bin) {
7467
+ throw new Error("Harper binary not found. Run 'flair init' first.");
7468
+ }
7469
+ const dataDir = defaultDataDir();
7470
+ // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
7471
+ // Without this, `flair init --admin-pass X` (which only exports HDB_*
7472
+ // to the initial Harper spawn) followed by `flair restart` would silently
7473
+ // drop admin credentials — any subsequent auth'd call returns 401.
7474
+ const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
7475
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
7476
+ const env = {
7477
+ ...process.env,
7478
+ ROOTPATH: dataDir,
7479
+ // See the matching comment at the install-time spawn site above.
7480
+ FLAIR_MODELS_DIR: modelsDir,
7481
+ DEFAULTS_MODE: "dev",
7482
+ HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
7483
+ HTTP_PORT: String(port),
7484
+ LOCAL_STUDIO: "false",
7485
+ };
7486
+ if (adminPass) {
7487
+ env.HDB_ADMIN_PASSWORD = adminPass;
7488
+ }
7489
+ // models (flair#504 Phase 1): no env var needed — resources/embeddings-boot.ts
7490
+ // self-registers the backend in-process on every boot (flair#694).
7491
+ const proc = spawn(process.execPath, [bin, "run", "."], {
7492
+ cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
7493
+ });
7494
+ proc.unref();
7495
+ await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
7496
+ }
7497
+ /**
7498
+ * The ONE restart mechanism for a local Flair install. Shared by `flair
7499
+ * restart` and `flair upgrade`'s post-install restart step (flair#635) so
7500
+ * the two never drift into two different ways to bounce the same process.
7501
+ * Composed of `stopFlairProcess` + `startFlairProcess` (flair#637) — the
7502
+ * pre-upgrade snapshot step calls those two directly with a snapshot taken
7503
+ * in between, instead of going through this wrapper.
7504
+ *
7505
+ * Throws on failure instead of calling process.exit — callers decide how to
7506
+ * react (`flair restart` exits 1; `flair upgrade` treats a failed restart as
7507
+ * an upgrade failure and may attempt a rollback).
7508
+ */
7509
+ async function restartFlair(port) {
7510
+ await stopFlairProcess(port);
7511
+ await startFlairProcess(port);
7512
+ }
7513
+ program
7514
+ .command("restart")
7515
+ .description("Restart the Flair (Harper) instance")
7516
+ .option("--port <port>", "Harper HTTP port")
7517
+ .action(async (opts) => {
7518
+ const port = resolveHttpPort(opts);
7519
+ try {
7520
+ await restartFlair(port);
7521
+ console.log("✅ Flair restarted");
7522
+ }
7523
+ catch (err) {
7524
+ console.error(`❌ Flair failed to restart: ${err?.message ?? err}`);
7525
+ process.exit(1);
7526
+ }
6254
7527
  });
6255
7528
  // ─── flair uninstall ──────────────────────────────────────────────────────────
6256
7529
  program
@@ -6345,7 +7618,28 @@ program
6345
7618
  const dryRun = opts.dryRun ?? false;
6346
7619
  const batchSize = Number(opts.batchSize);
6347
7620
  const delayMs = Number(opts.delayMs);
6348
- const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
7621
+ // flair#504 Phase 2: MUST match resources/embeddings-provider.ts's
7622
+ // getModelId() — including THE GATE (EMBEDDING_PREFIXES_ENABLED), not
7623
+ // just the suffix. Duplicated as literals, not imported, because
7624
+ // src/cli.ts and resources/**.ts are separate build targets —
7625
+ // tsconfig.cli.json's rootDir is "src" and only includes src/cli.ts +
7626
+ // src/cli-shim.cts, and the published CLI package ships only dist/ built
7627
+ // from that config (package.json's "files"), so resources/ isn't
7628
+ // reachable from (or bundled into) the CLI binary. THE GATE is now ON
7629
+ // (flipped, re-baselined through the ratchet gate — see
7630
+ // embeddings-provider.ts's file header and PR #689 for the park history
7631
+ // this flip revisits), so `currentModel` here is `<base>+searchprefix` —
7632
+ // matching getModelId()'s gate-on return exactly. If
7633
+ // EMBEDDING_PREFIXES_ENABLED or EMBEDDING_VARIANT ever changes in
7634
+ // embeddings-provider.ts, update this block too — a drift here silently
7635
+ // breaks `--stale-only`: it would compare every row's embeddingModel
7636
+ // against the WRONG current-model string, so rows would read as already
7637
+ // "current" (or as needing re-embed) out of sync with what getModelId()
7638
+ // is actually stamping new writes with.
7639
+ const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
7640
+ const EMBEDDING_VARIANT = "searchprefix";
7641
+ const baseModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
7642
+ const currentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
6349
7643
  if (agentId) {
6350
7644
  console.log(`Re-embedding memories for agent: ${agentId}`);
6351
7645
  }
@@ -6638,8 +7932,9 @@ program
6638
7932
  .description("Deploy Flair as a component to a remote Harper Fabric cluster")
6639
7933
  .option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
6640
7934
  .option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
6641
- .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
6642
- .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD)")
7935
+ .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER preferred; inline leaks to shell history)")
7936
+ .option("--fabric-password <pass>", "Fabric admin password (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
7937
+ .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600)")
6643
7938
  .option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
6644
7939
  .option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
6645
7940
  .option("--project <name>", "Component name in Fabric", "flair")
@@ -6653,15 +7948,29 @@ program
6653
7948
  .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)")
6654
7949
  .option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
6655
7950
  .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], [])
7951
+ .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")
7952
+ .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)")
7953
+ .option("--no-fleet-verify", "Skip the automatic post-deploy fleet convergence sweep (default: sweep runs — see flair#636)")
6656
7954
  .action(async (opts) => {
6657
7955
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
6658
7956
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
7957
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
6659
7958
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
7959
+ let fabricUser;
7960
+ let fabricPassword;
7961
+ let credWarnings = [];
7962
+ try {
7963
+ ({ fabricUser, fabricPassword, warnings: credWarnings } = resolveFabricCredentials(opts));
7964
+ }
7965
+ catch (err) {
7966
+ console.error(red(`Error: ${err.message}`));
7967
+ process.exit(1);
7968
+ }
6660
7969
  const deployOpts = {
6661
7970
  fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
6662
7971
  fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
6663
- fabricUser: opts.fabricUser ?? process.env.FABRIC_USER,
6664
- fabricPassword: opts.fabricPassword ?? process.env.FABRIC_PASSWORD,
7972
+ fabricUser,
7973
+ fabricPassword,
6665
7974
  fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
6666
7975
  target: opts.target,
6667
7976
  project: opts.project,
@@ -6675,6 +7984,8 @@ program
6675
7984
  verify: opts.verify !== false,
6676
7985
  verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
6677
7986
  verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
7987
+ deployRetries: Number(opts.deployRetries ?? 2),
7988
+ ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
6678
7989
  onProgress: (msg) => console.log(dim(` ${msg}`)),
6679
7990
  };
6680
7991
  const errors = validateDeployOptions(deployOpts);
@@ -6684,11 +7995,10 @@ program
6684
7995
  console.error(` - ${e}`);
6685
7996
  process.exit(1);
6686
7997
  }
6687
- // Warn on password-via-flag (leaks to shell history). Env is preferred.
6688
- if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
6689
- console.error(dim("warning: --fabric-password leaks to shell history. " +
6690
- "Prefer FABRIC_PASSWORD env."));
6691
- }
7998
+ // Never log the credential VALUES only the flag names, via the
7999
+ // resolver's own warning strings (see resolveFabricCredentials above).
8000
+ for (const w of credWarnings)
8001
+ console.error(dim(w));
6692
8002
  const url = buildDeployUrl(deployOpts);
6693
8003
  console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
6694
8004
  if (deployOpts.dryRun)
@@ -6700,9 +8010,43 @@ program
6700
8010
  console.log(dim(` package root: ${result.packageRoot}`));
6701
8011
  return;
6702
8012
  }
6703
- console.log(`\n${green("✓")} Flair ${result.version} deployed${deployOpts.verify ? " and verified serving" : ""}`);
8013
+ if (result.replicationWarning) {
8014
+ 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.`);
8015
+ }
8016
+ else {
8017
+ console.log(`\n${green("✓")} Flair ${result.version} deployed${deployOpts.verify ? " and verified serving" : ""}`);
8018
+ }
6704
8019
  console.log(`\n URL: ${result.url}`);
6705
8020
  console.log(` Project: ${result.project}`);
8021
+ // ── Post-deploy fleet sweep (flair#636) ─────────────────────────────
8022
+ // Harper's own "Successfully deployed" (and the served-API verify
8023
+ // above) only confirm the ORIGIN. This sweeps the origin + every known
8024
+ // federation peer for actual version/health convergence — the gap that
8025
+ // let the 0.21.0 deploy report success while a peer was still throwing
8026
+ // 1006s. Skippable with --no-fleet-verify. Needs Basic-auth creds
8027
+ // (fabricUser+fabricPassword) — a --fabric-token-only deploy has no way
8028
+ // to authenticate the sweep, so it's skipped with a note instead of a
8029
+ // silent no-op.
8030
+ if (!shouldRunFleetVerify(opts)) {
8031
+ console.log(dim("\n(--no-fleet-verify: skipping post-deploy fleet sweep)"));
8032
+ }
8033
+ else if (!deployOpts.fabricUser || !deployOpts.fabricPassword) {
8034
+ console.log(dim("\n(skipping fleet verify — no --fabric-user/--fabric-password to authenticate the sweep; only --fabric-token was provided)"));
8035
+ }
8036
+ else {
8037
+ console.log(`\n${green("→")} Fleet verify`);
8038
+ const sweep = await sweepFleet({
8039
+ target: result.url,
8040
+ fabricUser: deployOpts.fabricUser,
8041
+ fabricPassword: deployOpts.fabricPassword,
8042
+ expectVersion: result.version,
8043
+ });
8044
+ console.log(renderFleetSweepTable(sweep));
8045
+ if (sweep.exitCode !== FLEET_EXIT_OK) {
8046
+ console.error(red(`\n✗ fleet verify failed (exit ${sweep.exitCode}) — deploy is NOT fully converged.`));
8047
+ process.exit(sweep.exitCode);
8048
+ }
8049
+ }
6706
8050
  console.log(`\nNext steps:`);
6707
8051
  console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
6708
8052
  console.log(dim(` 2. Seed your first agent:`));
@@ -6720,9 +8064,73 @@ program
6720
8064
  if (hint?.includes("did not settle")) {
6721
8065
  console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
6722
8066
  }
8067
+ if (hint?.includes("peer replication failed after")) {
8068
+ console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
8069
+ }
6723
8070
  process.exit(1);
6724
8071
  }
6725
8072
  });
8073
+ // ─── flair fleet ──────────────────────────────────────────────────────────────
8074
+ //
8075
+ // Fabric fleet operations (flair#636). `flair deploy` / `flair upgrade
8076
+ // --target` already run this sweep automatically post-deploy (skippable
8077
+ // with --no-fleet-verify) — this is the standalone entry point for running
8078
+ // it independently, e.g. as a periodic health check or before a rolling
8079
+ // restart step (see the flair#636 decision comment: this sweep is the gate
8080
+ // between peers during a rolling restart, not the restart mechanism itself).
8081
+ const fleet = program.command("fleet").description("Fabric fleet operations (post-deploy convergence verification)");
8082
+ fleet
8083
+ .command("verify")
8084
+ .description("Sweep a Fabric origin + its known federation peers for version/health convergence")
8085
+ .requiredOption("--target <url>", "Fabric URL to verify (the origin node)")
8086
+ .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER — preferred; inline leaks to ps/shell history)")
8087
+ .option("--fabric-password <pass>", "Fabric admin password (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
8088
+ .option("--fabric-password-file <path>", "Read the Fabric admin password from a mode-0600 file (keeps it out of argv and env)")
8089
+ .option("--expect-version <semver>", "Version every node must report (default: the origin's own reported version — a self-consistency check)")
8090
+ .option("--timeout <ms>", "Per-node /Health poll timeout in ms", "60000")
8091
+ .option("--json", "Emit JSON (also: pipe + FLAIR_OUTPUT=json)")
8092
+ .addHelpText("after", `
8093
+ Exit codes:
8094
+ 0 all nodes verified: healthy, authenticated, and version-matched
8095
+ 1 origin failed (unreachable, unauthenticated, or wrong version)
8096
+ 2 origin OK, but a reachable peer is running a DIFFERENT version (skew)
8097
+ 3 origin OK, no skew among reachable peers, but a peer could not be
8098
+ verified at all (unreachable, auth rejected, or no endpoint on file)
8099
+
8100
+ "peer" here means a Flair federation peer (GET /FederationPeers on the
8101
+ origin) — NOT Harper's own cluster-replication nodes, which the OSS
8102
+ @harperfast/harper build this CLI ships does not expose (cluster_status is
8103
+ a harper-pro-only operation). A Fabric replica that was never
8104
+ federation-paired (\`flair federation pair\`) is invisible to this sweep —
8105
+ see src/fleet-verify.ts's file header for the full caveat.`)
8106
+ .action(async (opts) => {
8107
+ // Single source of truth for cred resolution + shell-history warnings,
8108
+ // shared with `flair upgrade --target` and `flair deploy`.
8109
+ const { fabricUser, fabricPassword, warnings } = resolveFabricCredentials(opts);
8110
+ for (const w of warnings)
8111
+ console.error(render.wrap(render.c.dim, w));
8112
+ if (!fabricUser || !fabricPassword) {
8113
+ console.error(render.wrap(render.c.red, "flair fleet verify: credentials required"));
8114
+ console.error(" set FABRIC_USER + FABRIC_PASSWORD env, or --fabric-password-file, or (discouraged) --fabric-user/--fabric-password inline");
8115
+ process.exit(1);
8116
+ }
8117
+ const result = await sweepFleet({
8118
+ target: opts.target,
8119
+ fabricUser,
8120
+ fabricPassword,
8121
+ expectVersion: opts.expectVersion,
8122
+ timeoutMs: Number(opts.timeout ?? 60_000),
8123
+ });
8124
+ const mode = render.resolveOutputMode(opts);
8125
+ if (mode === "json") {
8126
+ console.log(render.asJSON(result));
8127
+ }
8128
+ else {
8129
+ console.log(render.wrap(render.c.bold, `Fleet verify — ${result.target}`));
8130
+ console.log(renderFleetSweepTable(result));
8131
+ }
8132
+ process.exit(result.exitCode);
8133
+ });
6726
8134
  // ─── flair doctor ─────────────────────────────────────────────────────────────
6727
8135
  program
6728
8136
  .command("doctor")
@@ -6743,6 +8151,24 @@ program
6743
8151
  let issues = 0;
6744
8152
  let harperResponding = false;
6745
8153
  console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
8154
+ // 0. Version check (flair#587) — offline-tolerant + cached, independent
8155
+ // of Harper being up. A gap of ≥2 minor versions (or any major) is
8156
+ // treated as loud/red — heuristic for "likely missed a security fix"
8157
+ // since we don't have advisory data, only the version gap. A red gap
8158
+ // counts as an issue (exit 1); a quieter yellow gap (one minor, or
8159
+ // patch-only) is printed but doesn't fail doctor.
8160
+ const versionCheckResult = await checkVersion(__pkgVersion);
8161
+ const versionNudge = formatVersionNudge(versionCheckResult);
8162
+ if (versionNudge) {
8163
+ const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
8164
+ const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
8165
+ console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
8166
+ if (versionNudge.severity === "red")
8167
+ issues++;
8168
+ }
8169
+ else if (versionCheckResult.latest) {
8170
+ console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
8171
+ }
6746
8172
  // Helper: try to reach Harper on a given port
6747
8173
  async function probePort(p) {
6748
8174
  try {
@@ -6864,6 +8290,53 @@ program
6864
8290
  issues++;
6865
8291
  }
6866
8292
  }
8293
+ // 1a. CLI ↔ running-server version handshake (flair#695 §B) — the
8294
+ // version TRIPLE: this CLI's own version (__pkgVersion, checked against
8295
+ // npm-latest in step 0 above), and the RUNNING server's reported
8296
+ // version (GET /Health — public, no auth needed). A mismatch means the
8297
+ // installed package was upgraded but the daemon hasn't restarted onto
8298
+ // it yet — exactly the bare-npm trap the global preAction hook (above,
8299
+ // every other command) nudges about on stderr; doctor prints the full
8300
+ // picture here instead of a one-liner and `--fix` offers the restart.
8301
+ let runningVersion = null;
8302
+ if (harperResponding) {
8303
+ try {
8304
+ const healthRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(3000) });
8305
+ if (healthRes.ok) {
8306
+ const body = (await healthRes.json());
8307
+ runningVersion = typeof body?.version === "string" ? body.version : null;
8308
+ }
8309
+ }
8310
+ catch { /* leave runningVersion null — reported below as "unknown" */ }
8311
+ if (runningVersion && runningVersion !== __pkgVersion) {
8312
+ console.log(` ${render.icons.error} Version mismatch: CLI/installed ${render.wrap(render.c.bold, __pkgVersion)} but server is running ${render.wrap(render.c.bold, runningVersion)}`);
8313
+ if (autoFix) {
8314
+ if (dryRun) {
8315
+ console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
8316
+ }
8317
+ else {
8318
+ try {
8319
+ const { execSync } = await import("node:child_process");
8320
+ execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${effectivePort}`, { stdio: "inherit" });
8321
+ console.log(` ${render.icons.ok} Restarted onto ${__pkgVersion}`);
8322
+ }
8323
+ catch {
8324
+ console.log(` ${render.icons.error} Restart failed — try: flair restart`);
8325
+ }
8326
+ }
8327
+ }
8328
+ else {
8329
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
8330
+ }
8331
+ issues++;
8332
+ }
8333
+ else if (runningVersion) {
8334
+ console.log(` ${render.icons.ok} Server running version matches CLI (${runningVersion})`);
8335
+ }
8336
+ else {
8337
+ console.log(` ${render.icons.warn} Could not determine the running server's version`);
8338
+ }
8339
+ }
6867
8340
  // 2. Keys directory
6868
8341
  const keysDir = defaultKeysDir();
6869
8342
  if (existsSync(keysDir)) {
@@ -6969,6 +8442,290 @@ program
6969
8442
  issues++;
6970
8443
  }
6971
8444
  }
8445
+ // 7. Client integration (flair#588) — the first 6 checks diagnose the
8446
+ // SERVER side. This diagnoses whether Flair is actually wired to a real
8447
+ // MCP client (Claude Code, Codex, Gemini, Cursor): the MCP block present
8448
+ // + reachable + the configured agent genuinely registered (every detected
8449
+ // client), plus CLAUDE.md + the SessionStart hook (Claude Code only,
8450
+ // since only Claude Code has those mechanisms). Reuses detectClients()
8451
+ // rather than reimplementing client detection.
8452
+ console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
8453
+ // Prompt y/N before a content-editing fix, but only when interactive —
8454
+ // in a non-TTY context (CI, scripts) --fix itself is the consent signal,
8455
+ // matching how doctor's other --fix branches already behave unprompted.
8456
+ // Mirrors the confirm pattern at `flair fabric upgrade` (~line 6258).
8457
+ async function confirmFix(question) {
8458
+ if (!process.stdin.isTTY)
8459
+ return true;
8460
+ const { createInterface } = await import("node:readline");
8461
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8462
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
8463
+ return /^y(es)?$/i.test(answer.trim());
8464
+ }
8465
+ const detectedClients = detectClients().filter((c) => c.detected);
8466
+ if (detectedClients.length === 0) {
8467
+ console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
8468
+ }
8469
+ else {
8470
+ let claudeCodeAgentId;
8471
+ let anyKnownAgentId;
8472
+ for (const client of detectedClients) {
8473
+ const block = readClientMcpBlock(client.id, homedir());
8474
+ if (client.id === "claude-code" && block.agentId)
8475
+ claudeCodeAgentId = block.agentId;
8476
+ if (block.agentId)
8477
+ anyKnownAgentId = anyKnownAgentId ?? block.agentId;
8478
+ if (!block.present) {
8479
+ console.log(` ${render.icons.error} ${client.label}: no Flair MCP server configured in ${render.wrap(render.c.dim, block.configPath)}`);
8480
+ if (autoFix) {
8481
+ if (dryRun) {
8482
+ console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
8483
+ }
8484
+ else {
8485
+ const proceed = await confirmFix(` Wire ${client.label} now? [y/N] `);
8486
+ if (!proceed) {
8487
+ console.log(` Skipped.`);
8488
+ }
8489
+ else {
8490
+ const fixAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
8491
+ if (!fixAgentId) {
8492
+ console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
8493
+ }
8494
+ else {
8495
+ const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl || baseUrl };
8496
+ const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
8497
+ client.id === "codex" ? wireCodex(wireEnv) :
8498
+ client.id === "gemini" ? wireGemini(wireEnv) :
8499
+ wireCursor(wireEnv);
8500
+ console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
8501
+ }
8502
+ }
8503
+ }
8504
+ }
8505
+ else {
8506
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
8507
+ }
8508
+ issues++;
8509
+ continue;
8510
+ }
8511
+ console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
8512
+ const reachable = await probeFlairReachable(block.flairUrl);
8513
+ if (!reachable) {
8514
+ console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} not reachable — cannot verify agent registration`);
8515
+ continue;
8516
+ }
8517
+ console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} reachable`);
8518
+ const reg = await checkAgentRegistered(block.flairUrl, block.agentId, defaultKeysDir());
8519
+ if (reg.state === "registered") {
8520
+ console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
8521
+ }
8522
+ else if (reg.state === "not-registered") {
8523
+ console.log(` ${render.icons.error} agent '${block.agentId}' is NOT registered on this Flair instance`);
8524
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${block.agentId}`);
8525
+ issues++;
8526
+ }
8527
+ else {
8528
+ console.log(` ${render.icons.warn} could not verify agent registration ${render.wrap(render.c.dim, `(${reg.detail})`)}`);
8529
+ }
8530
+ }
8531
+ // Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
8532
+ // has these mechanisms, so only run them when claude-code was detected.
8533
+ if (detectedClients.some((c) => c.id === "claude-code")) {
8534
+ const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
8535
+ if (claudeMd.present) {
8536
+ console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
8537
+ }
8538
+ else {
8539
+ 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"))})`);
8540
+ if (autoFix) {
8541
+ if (dryRun) {
8542
+ console.log(` ${render.wrap(render.c.dim, "Would append bootstrap instruction to")} ${join(process.cwd(), "CLAUDE.md")}`);
8543
+ }
8544
+ else {
8545
+ const proceed = await confirmFix(` Add the Flair bootstrap line to ./CLAUDE.md? [y/N] `);
8546
+ if (!proceed) {
8547
+ console.log(` Skipped.`);
8548
+ }
8549
+ else {
8550
+ const fixRes = fixClaudeMdBootstrap(process.cwd());
8551
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
8552
+ }
8553
+ }
8554
+ }
8555
+ else {
8556
+ 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)")}`);
8557
+ }
8558
+ issues++;
8559
+ }
8560
+ const hook = checkSessionStartHook(homedir());
8561
+ if (hook.present) {
8562
+ console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)}`);
8563
+ }
8564
+ else {
8565
+ console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
8566
+ if (autoFix) {
8567
+ if (dryRun) {
8568
+ console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
8569
+ }
8570
+ else {
8571
+ const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
8572
+ if (!proceed) {
8573
+ console.log(` Skipped.`);
8574
+ }
8575
+ else {
8576
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
8577
+ const fixRes = fixSessionStartHook(homedir(), fixAgentId);
8578
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
8579
+ }
8580
+ }
8581
+ }
8582
+ else {
8583
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
8584
+ }
8585
+ issues++;
8586
+ }
8587
+ }
8588
+ }
8589
+ // 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
8590
+ //
8591
+ // "Instance" here means each AGENT's heartbeat row — Presence is keyed by
8592
+ // agentId (schemas/schema.graphql), not by Flair server — so several rows
8593
+ // can (and typically will) share one flairVersion/harperVersion whenever
8594
+ // several agents heartbeat through the same Flair. That's still the
8595
+ // useful fleet signal: an outlier version on one row means THAT agent's
8596
+ // serving instance is behind the rest.
8597
+ //
8598
+ // SCOPE, verified against runFederationSyncOnce's own table list a few
8599
+ // hundred lines up (`const tables = ["Memory", "Soul", "Agent",
8600
+ // "Relationship"]`): Presence is NOT one of the tables federation sync
8601
+ // replicates. So this section reports only what THIS instance's own
8602
+ // Presence table has recorded — every agent whose FLAIR_URL points
8603
+ // directly at the Flair `doctor` is talking to. On a hub+spokes
8604
+ // deployment where each spoke runs its own separate Flair database, a
8605
+ // spoke's locally-recorded heartbeats are invisible from the hub's
8606
+ // `doctor` unless those agents also heartbeat straight to the hub. Not
8607
+ // fixed here — flair#639's fix list is version-stamping + a doctor
8608
+ // listing, not widening federation sync scope.
8609
+ if (harperResponding) {
8610
+ console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
8611
+ try {
8612
+ // flairVersion/harperVersion are gated to verified readers on the
8613
+ // server (resources/Presence.ts, same boundary as currentTask) — sign
8614
+ // the GET when we have an agent + key so the fields aren't silently
8615
+ // nulled out from under us.
8616
+ const fleetAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
8617
+ const fleetKeyPath = fleetAgentId ? join(defaultKeysDir(), `${fleetAgentId}.key`) : undefined;
8618
+ const canSign = !!(fleetAgentId && fleetKeyPath && existsSync(fleetKeyPath));
8619
+ const headers = canSign
8620
+ ? { Authorization: buildEd25519Auth(fleetAgentId, "GET", "/Presence", fleetKeyPath) }
8621
+ : {};
8622
+ const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
8623
+ if (!presRes.ok) {
8624
+ console.log(` ${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
8625
+ }
8626
+ else {
8627
+ const roster = (await presRes.json());
8628
+ if (!Array.isArray(roster) || roster.length === 0) {
8629
+ console.log(` ${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
8630
+ }
8631
+ else {
8632
+ const rows = sortOldestVersionFirst(markStale(roster));
8633
+ for (const row of rows) {
8634
+ const lastSeen = typeof row.lastHeartbeatAt === "number"
8635
+ ? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
8636
+ : "—";
8637
+ const versionLabel = !canSign
8638
+ ? render.wrap(render.c.dim, "hidden")
8639
+ : row.flairVersion
8640
+ ? `v${row.flairVersion}`
8641
+ : render.wrap(render.c.dim, "no version reported");
8642
+ const staleNote = row.stale && row.newestVersion
8643
+ ? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
8644
+ : "";
8645
+ const icon = row.stale ? render.icons.warn : render.icons.ok;
8646
+ const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
8647
+ // Natural-presence: same staleness principle as the version
8648
+ // column — a live activity is shown as current, a decayed one as
8649
+ // "last-known". `activityFresh === false` (server verdict) plus a
8650
+ // known lastActivity → "(was: X)"; a fresh, non-idle activity →
8651
+ // "(X)". Skip entirely when there's nothing informative to say
8652
+ // (no signal, or idle) so the line stays quiet for the common case.
8653
+ const lastActivity = row.lastActivity ?? row.activity;
8654
+ const activityNote = row.activityFresh === false
8655
+ ? (lastActivity && lastActivity !== "idle"
8656
+ ? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
8657
+ : "")
8658
+ : (row.activity && row.activity !== "idle"
8659
+ ? " " + render.wrap(render.c.dim, `(${row.activity})`)
8660
+ : "");
8661
+ console.log(` ${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
8662
+ }
8663
+ if (!canSign) {
8664
+ console.log(` ${render.wrap(render.c.dim, "Pass --agent <id> (with a matching key in ~/.flair/keys) to reveal versions — flairVersion/harperVersion require a verified signature, same as currentTask.")}`);
8665
+ }
8666
+ console.log(` ${render.wrap(render.c.dim, "Staleness above is fleet-relative (newest version seen among these instances) — comparing against the latest PUBLISHED flair is the version check at the top of this report, not this section.")}`);
8667
+ }
8668
+ }
8669
+ }
8670
+ catch (err) {
8671
+ console.log(` ${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
8672
+ }
8673
+ }
8674
+ // 9. Migration state (flair#695) — pending/in-progress/blocked + last
8675
+ // ledger-derived outcome per registered migration, read off the same
8676
+ // authenticated /HealthDetail the "Fleet presence" section above
8677
+ // already fetches. `--fix` here means the SAME restart offered in step
8678
+ // 1a above (a halted migration retries automatically on the next boot —
8679
+ // there's no separate "run the migration now" fix; the fix for
8680
+ // "blocked" is whatever the halt reason names, e.g. freeing disk).
8681
+ if (harperResponding) {
8682
+ console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
8683
+ try {
8684
+ const migAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
8685
+ const migKeyPath = migAgentId ? join(defaultKeysDir(), `${migAgentId}.key`) : undefined;
8686
+ const migCanSign = !!(migAgentId && migKeyPath && existsSync(migKeyPath));
8687
+ if (!migCanSign) {
8688
+ console.log(` ${render.icons.info} Pass --agent <id> (with a matching key in ~/.flair/keys) to see migration state — requires a verified read, same as Fleet presence above.`);
8689
+ }
8690
+ else {
8691
+ const migHeaders = { Authorization: buildEd25519Auth(migAgentId, "GET", "/HealthDetail", migKeyPath) };
8692
+ const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers: migHeaders, signal: AbortSignal.timeout(5000) });
8693
+ if (!migRes.ok) {
8694
+ console.log(` ${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
8695
+ }
8696
+ else {
8697
+ const detail = (await migRes.json());
8698
+ const migBlock = detail?.migrations;
8699
+ if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
8700
+ console.log(` ${render.icons.info} No migrations registered on this instance`);
8701
+ }
8702
+ else {
8703
+ if (migBlock.cyclePhase === "pre-hash") {
8704
+ console.log(` ${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
8705
+ }
8706
+ for (const m of migBlock.migrations) {
8707
+ if (m.state === "completed") {
8708
+ console.log(` ${render.icons.ok} ${m.id}: completed`);
8709
+ }
8710
+ else if (m.state === "halted" || m.state === "failed") {
8711
+ console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
8712
+ issues++;
8713
+ }
8714
+ else if (m.state === "running") {
8715
+ console.log(` ${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
8716
+ }
8717
+ else {
8718
+ console.log(` ${render.icons.info} ${m.id}: ${m.state}`);
8719
+ }
8720
+ }
8721
+ }
8722
+ }
8723
+ }
8724
+ }
8725
+ catch (err) {
8726
+ console.log(` ${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
8727
+ }
8728
+ }
6972
8729
  // Summary
6973
8730
  console.log("");
6974
8731
  if (issues === 0) {
@@ -6982,7 +8739,7 @@ program
6982
8739
  process.exit(1);
6983
8740
  });
6984
8741
  // ─── flair session snapshot ──────────────────────────────────────────────────
6985
- // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-ojht). Snapshot a
8742
+ // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
6986
8743
  // session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
6987
8744
  //
6988
8745
  // Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
@@ -7134,13 +8891,15 @@ sessionSnapshot
7134
8891
  });
7135
8892
  // ─── Memory and Soul commands ────────────────────────────────────────────────
7136
8893
  const memory = program.command("memory").description("Manage agent memories");
7137
- memory.command("add [content]").requiredOption("--agent <id>")
8894
+ memory.command("add [content]")
8895
+ .description("Write a new memory row for an agent (content via positional arg or --content)")
8896
+ .requiredOption("--agent <id>")
7138
8897
  .option("--content <text>", "memory content (alias for positional arg)")
7139
8898
  .option("--durability <d>", "standard").option("--tags <csv>")
7140
- .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
8899
+ .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
7141
8900
  .option("--subject <text>", "one-line title / entity this memory is about")
7142
8901
  .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)")
7143
- .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)")
8902
+ .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)")
7144
8903
  .action(async (contentArg, opts) => {
7145
8904
  const content = contentArg ?? opts.content;
7146
8905
  if (!content) {
@@ -7166,7 +8925,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
7166
8925
  console.log(JSON.stringify(out, null, 2));
7167
8926
  });
7168
8927
  // ─── flair memory write-task-summary ────────────────────────────────────────
7169
- // Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B (ops-9wji-B / ops-3xyd). Standalone
8928
+ // Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B. Standalone
7170
8929
  // helper that any agent harness (or a manual operator) can invoke at task
7171
8930
  // close to capture a structured task summary as a persistent Memory row
7172
8931
  // before resetting the session.
@@ -7183,7 +8942,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
7183
8942
  memory.command("write-task-summary")
7184
8943
  .description("Capture a structured task summary as a persistent Memory row (used by session-reset harness; standalone-callable by operators)")
7185
8944
  .requiredOption("--agent <id>", "Agent the summary belongs to")
7186
- .requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about (e.g. ops-3xyd)")
8945
+ .requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about")
7187
8946
  .requiredOption("--outcome <s>", "Outcome of the task: merged | rejected | abandoned")
7188
8947
  .option("--summary <text>", "Multi-sentence dense compression (populates Memory.summary; will be the agent's read-time view)")
7189
8948
  .option("--files-touched <csv>", "Comma-separated list of files touched during the task (becomes part of content)")
@@ -7243,6 +9002,7 @@ memory.command("write-task-summary")
7243
9002
  console.log(memId);
7244
9003
  });
7245
9004
  memory.command("search [query]")
9005
+ .description("Semantic search over an agent's memories (query via positional arg or --q)")
7246
9006
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7247
9007
  .option("--q <query>", "search query (alias for positional arg)")
7248
9008
  .option("--limit <n>", "Max results", "5")
@@ -7269,6 +9029,7 @@ memory.command("search [query]")
7269
9029
  console.log(JSON.stringify(res, null, 2));
7270
9030
  });
7271
9031
  memory.command("list")
9032
+ .description("List an agent's memories (optionally filtered by --tag or embedding-backfill triage)")
7272
9033
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7273
9034
  .option("--tag <tag>")
7274
9035
  .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
@@ -7379,7 +9140,7 @@ memory.command("list")
7379
9140
  });
7380
9141
  // ─── flair memory hygiene ────────────────────────────────────────────────────
7381
9142
  // Detect + remove junk memory rows that accumulate over time. Surfaced from
7382
- // the 2026-05-07 manual cleanup (ops-ucyy): an instance had 627 records, ~250 of
9143
+ // a 2026-05-07 manual cleanup: an instance had 627 records, ~250 of
7383
9144
  // them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
7384
9145
  // test content ("the quick brown fox..." / "Flair 251 test ..."), and
7385
9146
  // near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
@@ -7394,8 +9155,8 @@ memory.command("list")
7394
9155
  // Default is all three, dry-run. Flip --apply to actually delete. Always
7395
9156
  // requires admin pass to read across agent scopes (uses ops API).
7396
9157
  //
7397
- // Federation note: this only deletes on the local instance. ops-esun
7398
- // (federation distributed-delete via tombstones) is the systemic answer for
9158
+ // Federation note: this only deletes on the local instance. Federation
9159
+ // distributed-delete via tombstones is the systemic answer for
7399
9160
  // fan-out — until that lands, run `flair memory hygiene` on each peer.
7400
9161
  // Exported for unit testing — keeps the predicate logic separable from
7401
9162
  // the CLI plumbing, ops-API fetching, and confirmation flow.
@@ -7515,7 +9276,7 @@ memory.command("hygiene")
7515
9276
  console.log(`\n\n✅ Deleted ${deleted} rows.`);
7516
9277
  console.log("");
7517
9278
  console.log("Note: this is a local-instance delete. Federated peers will keep their copies until");
7518
- console.log("ops-esun (tombstone-based distributed delete) lands. Until then, run `flair memory");
9279
+ console.log("tombstone-based distributed delete lands. Until then, run `flair memory");
7519
9280
  console.log("hygiene --apply` on each peer to fan out.");
7520
9281
  });
7521
9282
  // ─── flair search (top-level shortcut) ───────────────────────────────────────
@@ -7550,7 +9311,7 @@ program
7550
9311
  .option("--since <iso-or-relative>", "Only memories created after this point (ISO 8601 or '7d'/'24h'/'30m')")
7551
9312
  .option("--as-of <iso>", "Temporal validity: only memories valid at this point (ISO 8601)")
7552
9313
  .option("--include-superseded", "Include memories that have been superseded")
7553
- .option("--scoring <mode>", "Scoring mode: composite (default) re-ranks by durability/recency/retrieval; raw uses cosine similarity only", "composite")
9314
+ .option("--scoring <mode>", "Scoring mode: raw (default) uses cosine similarity/BM25 only; composite re-ranks by durability/recency/retrieval (measurably hurts precision as of flair#623 — opt-in only)", "raw")
7554
9315
  .option("--min-score <n>", "Drop results below this score (0..1)", "0")
7555
9316
  // Client-side filters (applied after server response)
7556
9317
  .option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
@@ -7576,7 +9337,7 @@ program
7576
9337
  agentId,
7577
9338
  q: query,
7578
9339
  limit: Number.parseInt(opts.limit, 10) || 5,
7579
- scoring: opts.scoring === "raw" ? "raw" : "composite",
9340
+ scoring: opts.scoring === "composite" ? "composite" : "raw",
7580
9341
  };
7581
9342
  if (opts.tag)
7582
9343
  payload.tag = opts.tag;
@@ -7777,8 +9538,73 @@ program
7777
9538
  process.exit(1);
7778
9539
  }
7779
9540
  });
9541
+ // ─── flair relationship add ──────────────────────────────────────────────────
9542
+ //
9543
+ // Ergonomic agent-directed write surface for the Relationship graph
9544
+ // (relationship-write-path spec): an explicit subject/predicate/object triple
9545
+ // ("record that <subject> <predicate> <object>"), distinct from a free-text
9546
+ // Memory. Mirrors `flair memory add`'s shape (--agent required, signed via
9547
+ // the shared `api()` helper — see api()'s doc above for the Ed25519
9548
+ // resolution order) rather than hand-rolling a signer, per this repo's
9549
+ // existing convention (flair orgevent does hand-roll one because OrgEvent.put()
9550
+ // self-verifies authorId against the signature; Relationship doesn't need that
9551
+ // — the server stamps agentId from the verdict regardless of what's sent).
9552
+ //
9553
+ // PUTs to the CANONICAL id (see canonicalRelationshipId below), not a random
9554
+ // one — re-running this command with the SAME subject/predicate/object
9555
+ // UPSERTS the existing row (confidence/validTo/source refresh) instead of
9556
+ // creating a duplicate. This mirrors flair-client's RelationshipApi.write()
9557
+ // (packages/flair-client/src/client.ts) BYTE FOR BYTE — the CLI can't import
9558
+ // that workspace package into the published @tpsdev-ai/flair bundle (same
9559
+ // reasoning as the existing Memory-id-generation mirroring a few thousand
9560
+ // lines up), so the algorithm is duplicated here rather than shared. A
9561
+ // cross-check test (test/unit/cli-relationship-add.test.ts) pins the two
9562
+ // implementations to identical output so they can't silently drift apart —
9563
+ // a drift here would mean the CLI and the MCP tool/RelationshipApi land the
9564
+ // SAME triple at TWO different ids, defeating the whole dedup guarantee.
9565
+ function canonicalRelationshipId(agentId, subject, predicate, object) {
9566
+ const material = [agentId, subject, predicate, object].join("\u0000").toLowerCase();
9567
+ return createHash("sha256").update(material, "utf8").digest().subarray(0, 16).toString("base64url");
9568
+ }
9569
+ const relationship = program.command("relationship").description("Manage agent relationship triples (knowledge graph)");
9570
+ relationship.command("add")
9571
+ .description("Record that <subject> <predicate> <object> — an explicit entity-to-entity relationship triple. " +
9572
+ "Re-asserting the SAME triple (same subject/predicate/object) UPSERTS the existing row rather than " +
9573
+ "duplicating it. Predicate is free text; recommended vocabulary: manages, works_on, reviews, depends_on, " +
9574
+ "replaces, owns, reports_to, advises. To CONTRADICT a prior relationship: changing the predicate creates " +
9575
+ "a SEPARATE row and does NOT auto-close the old one — re-assert the OLD triple with --valid-to set to now " +
9576
+ "(or delete it) before/after writing the new one.")
9577
+ .requiredOption("--agent <id>")
9578
+ .requiredOption("--subject <text>", "Source entity (e.g. 'nathan')")
9579
+ .requiredOption("--predicate <text>", "Relationship type, free text (e.g. 'manages')")
9580
+ .requiredOption("--object <text>", "Target entity (e.g. 'flair')")
9581
+ .option("--confidence <n>", "0.0-1.0, how certain (default 1.0 = explicitly stated)")
9582
+ .option("--valid-from <iso>", "ISO timestamp this relationship became true (default: now)")
9583
+ .option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
9584
+ .option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
9585
+ .action(async (opts) => {
9586
+ const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
9587
+ const body = {
9588
+ id,
9589
+ agentId: opts.agent,
9590
+ subject: opts.subject,
9591
+ predicate: opts.predicate,
9592
+ object: opts.object,
9593
+ };
9594
+ if (opts.confidence !== undefined)
9595
+ body.confidence = Number(opts.confidence);
9596
+ if (opts.validFrom)
9597
+ body.validFrom = opts.validFrom;
9598
+ if (opts.validTo)
9599
+ body.validTo = opts.validTo;
9600
+ if (opts.source)
9601
+ body.source = opts.source;
9602
+ const out = await api("PUT", `/Relationship/${id}`, body);
9603
+ console.log(JSON.stringify(out, null, 2));
9604
+ });
7780
9605
  const soul = program.command("soul").description("Manage agent soul entries");
7781
9606
  soul.command("set")
9607
+ .description("Set (upsert) a soul entry for an agent by key")
7782
9608
  .requiredOption("--agent <id>")
7783
9609
  .requiredOption("--key <key>")
7784
9610
  .requiredOption("--value <value>")
@@ -7810,6 +9636,7 @@ soul.command("set")
7810
9636
  console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
7811
9637
  });
7812
9638
  soul.command("get")
9639
+ .description("Fetch a single soul entry by id (agent:key)")
7813
9640
  .argument("<id>")
7814
9641
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
7815
9642
  .action(async (id, opts) => {
@@ -7841,6 +9668,7 @@ soul.command("get")
7841
9668
  }
7842
9669
  });
7843
9670
  soul.command("list")
9671
+ .description("List all soul entries for an agent")
7844
9672
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7845
9673
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
7846
9674
  .action(async (opts) => {
@@ -9187,7 +11015,7 @@ program
9187
11015
  }
9188
11016
  });
9189
11017
  // ─── flair presence ─────────────────────────────────────────────────────────
9190
- const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
11018
+ const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "debugging", "idle"];
9191
11019
  const MAX_TASK_LENGTH = 120;
9192
11020
  const presence = program.command("presence").description("Manage agent presence (The Office Space)");
9193
11021
  presence
@@ -9253,16 +11081,27 @@ presence
9253
11081
  });
9254
11082
  // ─── flair workspace ─────────────────────────────────────────────────────────
9255
11083
  //
9256
- // Coordination write surface (ops-wmgx / Kris #510). `workspace set` writes the
9257
- // agent's OWN WorkspaceState via a signed POST /WorkspaceState. Identity comes
9258
- // from the Ed25519 signature the body carries NO agentId, so an agent can only
9259
- // write as itself (the WorkspaceState.post() handler overwrites agentId from the
9260
- // signature regardless of body content). Mirrors `presence set`'s signed-POST shape.
11084
+ // Coordination write surface (Kris #510). `workspace set` writes the
11085
+ // agent's OWN WorkspaceState via a signed PUT /WorkspaceState/{id}. Identity
11086
+ // is asserted by including agentId in the body the server never trusts it
11087
+ // blindly, it 403s any mismatch against the Ed25519 signature's agentId
11088
+ // (WorkspaceState.put(), resources/WorkspaceState.ts), so this is a
11089
+ // self-declaration the server verifies 1:1, not attribution-from-body.
11090
+ //
11091
+ // (flair#679, measured against a real spawned Harper): table-backed resources
11092
+ // only accept writes via PUT /<Table>/<id> — a bare POST /WorkspaceState 405s
11093
+ // ("does not have a post method implemented to handle HTTP method POST"),
11094
+ // same restriction documented in resources/Memory.ts and already fixed for
11095
+ // `soul set` (#498). WorkspaceState.ts DOES define a post() method, but
11096
+ // Harper's REST layer never routes a real HTTP POST to it — post() is only
11097
+ // reachable via in-process resource instantiation, never the wire. put(),
11098
+ // unlike post(), does NOT default createdAt/timestamp/agentId — the CLI
11099
+ // supplies them all explicitly below.
9261
11100
  const MAX_WORKSPACE_FIELD_LENGTH = 2000;
9262
11101
  const workspace = program.command("workspace").description("Manage agent workspace state (The Office Space)");
9263
11102
  workspace
9264
11103
  .command("set")
9265
- .description("Set your agent's current workspace state (POST /WorkspaceState)")
11104
+ .description("Set your agent's current workspace state (PUT /WorkspaceState/{id})")
9266
11105
  .requiredOption("--ref <ref>", "Workspace ref (branch, worktree, or task ref)")
9267
11106
  .option("--label <text>", "Human-readable label for this workspace")
9268
11107
  .option("--provider <name>", "Provider/runtime (e.g. claude-code, openclaw)", "cli")
@@ -9291,15 +11130,23 @@ workspace
9291
11130
  process.exit(1);
9292
11131
  }
9293
11132
  const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
9294
- const auth = buildEd25519Auth(agentId, "POST", "/WorkspaceState", keyPath);
9295
- // NOTE: agentId is intentionally NOT included in the body the handler
9296
- // attributes the record from the Ed25519 signature (no forging).
11133
+ // Deterministic id (agentId:ref) re-running `workspace set` for the same
11134
+ // ref overwrites the same record, which is intentional (one row per
11135
+ // agent+ref, not an append log).
11136
+ const id = `${agentId}:${opts.ref}`;
11137
+ const auth = buildEd25519Auth(agentId, "PUT", `/WorkspaceState/${id}`, keyPath);
11138
+ // agentId IS included in the body now — WorkspaceState.put() (unlike
11139
+ // post()) does not auto-attribute from the signature, it 403s any
11140
+ // mismatch. This is a self-declaration the server verifies against the
11141
+ // signature, not a forgeable claim.
9297
11142
  const now = new Date().toISOString();
9298
11143
  const body = {
9299
- id: `${agentId}:${opts.ref}`,
11144
+ id,
11145
+ agentId,
9300
11146
  ref: opts.ref,
9301
11147
  provider: opts.provider ?? "cli",
9302
11148
  timestamp: now,
11149
+ createdAt: now,
9303
11150
  };
9304
11151
  if (opts.label)
9305
11152
  body.label = opts.label;
@@ -9309,30 +11156,43 @@ workspace
9309
11156
  body.phase = opts.phase;
9310
11157
  if (opts.summary)
9311
11158
  body.summary = opts.summary;
9312
- const res = await fetch(`${baseUrl}/WorkspaceState`, {
9313
- method: "POST",
11159
+ const res = await fetch(`${baseUrl}/WorkspaceState/${id}`, {
11160
+ method: "PUT",
9314
11161
  headers: { "Content-Type": "application/json", Authorization: auth },
9315
11162
  body: JSON.stringify(body),
9316
11163
  });
9317
11164
  if (!res.ok) {
9318
11165
  const text = await res.text().catch(() => "");
9319
- console.error(`Error: POST /WorkspaceState failed (${res.status}): ${text}`);
11166
+ console.error(`Error: PUT /WorkspaceState/${id} failed (${res.status}): ${text}`);
9320
11167
  process.exit(1);
9321
11168
  }
9322
11169
  console.log(`✓ Workspace state updated for '${agentId}': ref=${opts.ref}${opts.phase ? `, phase=${opts.phase}` : ""}`);
9323
11170
  });
9324
11171
  // ─── flair orgevent ──────────────────────────────────────────────────────────
9325
11172
  //
9326
- // Coordination write surface (ops-wmgx / Kris #510). `orgevent` publishes an
9327
- // OrgEvent ATTRIBUTED to the authenticated agent via a signed POST /OrgEvent.
9328
- // The event's authorId comes from the Ed25519 signature — the body carries NO
9329
- // authorId, so an agent CANNOT forge another agent's events (the OrgEvent.post()
9330
- // handler overwrites authorId from the signature). Mirrors `presence set`'s shape.
11173
+ // Coordination write surface (Kris #510). `orgevent` publishes an
11174
+ // OrgEvent ATTRIBUTED to the authenticated agent via a signed PUT
11175
+ // /OrgEvent/{id}. authorId is asserted in the body but self-verified server
11176
+ // side: OrgEvent.put() (resources/OrgEvent.ts) 403s any authorId that doesn't
11177
+ // match the Ed25519 signature's agentId, so an agent still cannot forge
11178
+ // another agent's events — the difference from post() is that put() checks-
11179
+ // and-rejects a mismatch rather than silently overwriting it.
11180
+ //
11181
+ // (flair#679, measured against a real spawned Harper): table-backed resources
11182
+ // only accept writes via PUT /<Table>/<id> — a bare POST /OrgEvent 405s
11183
+ // ("does not have a post method implemented to handle HTTP method POST"),
11184
+ // same restriction documented in resources/Memory.ts and already fixed for
11185
+ // `soul set` (#498). OrgEvent.ts DOES define a post() method that
11186
+ // auto-generates id/createdAt, but Harper's REST layer never routes a real
11187
+ // HTTP POST to it — post() is only reachable via in-process resource
11188
+ // instantiation, never the wire. put() does NOT default id/createdAt, so the
11189
+ // CLI generates and supplies them itself (id convention mirrors flair-client's
11190
+ // Memory.write(): `${agentId}-${randomUUID()}`).
9331
11191
  const MAX_ORGEVENT_SUMMARY_LENGTH = 500;
9332
11192
  const MAX_ORGEVENT_DETAIL_LENGTH = 8000;
9333
11193
  program
9334
11194
  .command("orgevent")
9335
- .description("Publish an org-wide coordination event attributed to your agent (POST /OrgEvent)")
11195
+ .description("Publish an org-wide coordination event attributed to your agent (PUT /OrgEvent/{id})")
9336
11196
  .requiredOption("--kind <kind>", "Event kind (e.g. coord.claim, coord.release, status)")
9337
11197
  .requiredOption("--summary <text>", "Short summary of the event")
9338
11198
  .option("--detail <text>", "Longer detail payload")
@@ -9363,12 +11223,22 @@ program
9363
11223
  // orgevent reuses --target for recipients, so the remote-URL override is
9364
11224
  // --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
9365
11225
  const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
9366
- const auth = buildEd25519Auth(agentId, "POST", "/OrgEvent", keyPath);
9367
- // NOTE: authorId is intentionally NOT included in the body — the handler
9368
- // attributes the event from the Ed25519 signature (no forging).
11226
+ // id generation mirrors flair-client's Memory.write() convention
11227
+ // (`${agentId}-${randomUUID()}`) unique per publish, unlike OrgEvent's
11228
+ // own (HTTP-unreachable) post() default of `${authorId}-${isoTimestamp}`,
11229
+ // which can collide within the same millisecond.
11230
+ const id = `${agentId}-${randomUUID()}`;
11231
+ const auth = buildEd25519Auth(agentId, "PUT", `/OrgEvent/${id}`, keyPath);
11232
+ // authorId IS included in the body now — OrgEvent.put() (unlike post())
11233
+ // does not auto-attribute from the signature, it 403s any mismatch. This
11234
+ // is a self-declaration the server verifies against the signature, not a
11235
+ // forgeable claim.
9369
11236
  const body = {
11237
+ id,
11238
+ authorId: agentId,
9370
11239
  kind: opts.kind,
9371
11240
  summary: opts.summary,
11241
+ createdAt: new Date().toISOString(),
9372
11242
  };
9373
11243
  if (opts.detail)
9374
11244
  body.detail = opts.detail;
@@ -9376,21 +11246,126 @@ program
9376
11246
  body.scope = opts.scope;
9377
11247
  if (Array.isArray(opts.target) && opts.target.length > 0)
9378
11248
  body.targetIds = opts.target;
9379
- const res = await fetch(`${baseUrl}/OrgEvent`, {
9380
- method: "POST",
11249
+ const res = await fetch(`${baseUrl}/OrgEvent/${id}`, {
11250
+ method: "PUT",
9381
11251
  headers: { "Content-Type": "application/json", Authorization: auth },
9382
11252
  body: JSON.stringify(body),
9383
11253
  });
9384
11254
  if (!res.ok) {
9385
11255
  const text = await res.text().catch(() => "");
9386
- console.error(`Error: POST /OrgEvent failed (${res.status}): ${text}`);
11256
+ console.error(`Error: PUT /OrgEvent/${id} failed (${res.status}): ${text}`);
9387
11257
  process.exit(1);
9388
11258
  }
9389
11259
  const data = await res.json().catch(() => null);
9390
11260
  const targets = Array.isArray(opts.target) && opts.target.length > 0 ? ` → ${opts.target.join(", ")}` : "";
9391
11261
  console.log(`✓ OrgEvent published as '${agentId}': kind=${opts.kind}${targets}`);
9392
- if (data?.id)
9393
- console.log(` id: ${data.id}`);
11262
+ console.log(` id: ${data?.id ?? id}`);
11263
+ });
11264
+ // ─── flair attention ─────────────────────────────────────────────────────────
11265
+ //
11266
+ // Entity-scoped attention query (flair#677). "What's touching entity E in the
11267
+ // last N days?" — a unified, grouped-by-source view across Memory,
11268
+ // Relationship, WorkspaceState, Presence, and OrgEvent (POST /AttentionQuery,
11269
+ // resources/AttentionQuery.ts). Read-only; signed the same way `flair search`
11270
+ // signs POST /SemanticSearch. Entity must be a vocabulary string (exact
11271
+ // type:value match — resources/entity-vocab.ts); the server 400s anything
11272
+ // malformed.
11273
+ /** Render one attention-result row for its source group — human-readable mode only. */
11274
+ function describeAttentionRow(source, r) {
11275
+ const dim = (s) => render.wrap(render.c.dim, s);
11276
+ const day = (iso) => (typeof iso === "string" ? iso.slice(0, 10) : "");
11277
+ switch (source) {
11278
+ case "memory":
11279
+ return `${r.content ? String(r.content).replace(/\s+/g, " ").slice(0, 100) : "(no content)"} ${dim(`[${r.agentId} · ${day(r.createdAt)}]`)}`;
11280
+ case "relationship":
11281
+ return `${r.subject} —${r.predicate}→ ${r.object} ${dim(`[${r.agentId} · ${day(r.createdAt)}]`)}`;
11282
+ case "workspaceState":
11283
+ return `${r.summary ?? r.ref} ${dim(`[${r.agentId}${r.phase ? ` · ${r.phase}` : ""} · ${String(r.timestamp ?? "").slice(0, 16).replace("T", " ")}]`)}`;
11284
+ case "presence":
11285
+ return `${r.currentTask} ${dim(`[${r.displayName ?? r.agentId}${r.activity ? ` · ${r.activity}` : ""}]`)}`;
11286
+ case "orgEvent":
11287
+ return `${r.summary} ${dim(`[${r.authorId} · ${r.kind} · ${day(r.createdAt)}]`)}`;
11288
+ default:
11289
+ return JSON.stringify(r);
11290
+ }
11291
+ }
11292
+ program
11293
+ .command("attention <entity>")
11294
+ .description("What's touching entity E in the last N days? Grouped view across memory/relationship/workspace/presence/orgevent (POST /AttentionQuery)")
11295
+ .option("--days <n>", "Window size in days (default 7)")
11296
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
11297
+ .option("--key <path>", "Ed25519 private key path")
11298
+ .option("--port <port>", "Harper HTTP port")
11299
+ .option("--url <url>", "Flair base URL (overrides --port)")
11300
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
11301
+ .option("--json", "Output raw JSON")
11302
+ .action(async (entity, opts) => {
11303
+ try {
11304
+ const agentId = resolveAgentIdOrEnv(opts);
11305
+ if (!agentId) {
11306
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
11307
+ process.exit(2);
11308
+ }
11309
+ const payload = { entity };
11310
+ if (opts.days !== undefined) {
11311
+ const n = Number.parseInt(opts.days, 10);
11312
+ if (!Number.isFinite(n) || n <= 0) {
11313
+ console.error("error: --days must be a positive integer");
11314
+ process.exit(2);
11315
+ }
11316
+ payload.days = n;
11317
+ }
11318
+ const baseUrl = resolveBaseUrl(opts);
11319
+ const headers = { "content-type": "application/json" };
11320
+ const keyPath = opts.key || resolveKeyPath(agentId);
11321
+ if (keyPath) {
11322
+ headers["authorization"] = buildEd25519Auth(agentId, "POST", "/AttentionQuery", keyPath);
11323
+ }
11324
+ const res = await fetch(`${baseUrl}/AttentionQuery`, {
11325
+ method: "POST",
11326
+ headers,
11327
+ body: JSON.stringify(payload),
11328
+ });
11329
+ const text = await res.text();
11330
+ if (!res.ok)
11331
+ throw new Error(text || `HTTP ${res.status}`);
11332
+ const result = text ? JSON.parse(text) : {};
11333
+ const mode = render.resolveOutputMode(opts);
11334
+ if (mode === "json") {
11335
+ console.log(render.asJSON(result));
11336
+ return;
11337
+ }
11338
+ const groups = result.groups ?? {};
11339
+ const counts = result.counts ?? {};
11340
+ console.log(`${render.icons.info} Attention: ${render.wrap(render.c.bold, result.entity ?? entity)} ` +
11341
+ `${render.wrap(render.c.dim, `(last ${result.windowDays ?? payload.days ?? 7}d, since ${result.since ?? "?"})`)}`);
11342
+ console.log(render.wrap(render.c.dim, `total: ${counts.total ?? 0}`));
11343
+ console.log();
11344
+ const sections = [
11345
+ { key: "memory", label: "Memory" },
11346
+ { key: "relationship", label: "Relationship" },
11347
+ { key: "workspaceState", label: "Workspace" },
11348
+ { key: "presence", label: "Presence" },
11349
+ { key: "orgEvent", label: "OrgEvent" },
11350
+ ];
11351
+ for (const { key, label } of sections) {
11352
+ const rows = Array.isArray(groups[key]) ? groups[key] : [];
11353
+ console.log(`${render.wrap(render.c.bold, label)} ${render.wrap(render.c.dim, `(${rows.length})`)}`);
11354
+ if (rows.length === 0) {
11355
+ console.log(` ${render.wrap(render.c.dim, "—")}`);
11356
+ console.log();
11357
+ continue;
11358
+ }
11359
+ for (const r of rows) {
11360
+ console.log(` ${describeAttentionRow(key, r)}`);
11361
+ }
11362
+ console.log();
11363
+ }
11364
+ }
11365
+ catch (err) {
11366
+ console.error(`${render.icons.error} Attention query failed: ${err.message}`);
11367
+ process.exit(1);
11368
+ }
9394
11369
  });
9395
11370
  // Parse argv and run the CLI. Exported so the CommonJS preflight shim
9396
11371
  // (cli-shim.cts → dist/cli-shim.cjs, the real bin entry) can invoke it after
@@ -9406,4 +11381,4 @@ if (import.meta.main) {
9406
11381
  await runCli();
9407
11382
  }
9408
11383
  // ─── Exported for testing ─────────────────────────────────────────────────────
9409
- 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, };
11384
+ 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, };