@tpsdev-ai/flair 0.21.0 → 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 (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1755 -213
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +456 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +73 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
package/dist/cli.js CHANGED
@@ -3,17 +3,22 @@ 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
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";
16
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";
17
22
  import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.js";
18
23
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
19
24
  // src/ into resources/, which don't survive npm packaging (see also
@@ -121,39 +126,50 @@ const DEFAULT_ADMIN_USER = "admin";
121
126
  const STARTUP_TIMEOUT_MS = 60_000;
122
127
  const HEALTH_POLL_INTERVAL_MS = 500;
123
128
  /**
124
- * 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.
125
131
  *
126
- * Admin pass files (default ~/.flair/admin-pass) are short-lived secrets generated
127
- * by `openssl rand` — mode 0600 keeps them out of reach of other local users + most
128
- * backup tooling. A 0644 file silently leaks the secret to anyone with read access
129
- * 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.).
130
137
  *
131
- * Throws with an actionable error if the file isn't owner-only, otherwise returns
132
- * the trimmed file content (admin-pass files generated by `openssl rand -base64`
133
- * 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.
134
143
  */
135
- export function readAdminPassFileSecure(path) {
144
+ function readSecretFileSecure(path, flagName) {
136
145
  if (!existsSync(path)) {
137
- throw new Error(`--admin-pass-file path does not exist: ${path}`);
146
+ throw new Error(`${flagName} path does not exist: ${path}`);
138
147
  }
139
148
  const st = statSync(path);
140
149
  if (st.mode & 0o077) {
141
150
  const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
142
- 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. ` +
143
152
  `Run \`chmod 600 ${path}\` to restrict to owner-only.`);
144
153
  }
145
154
  const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
146
155
  if (!content) {
147
- 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}`);
148
157
  }
149
158
  return content;
150
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
+ }
151
164
  function defaultAdminPassPath() {
152
165
  return join(homedir(), ".flair", "admin-pass");
153
166
  }
154
167
  /**
155
168
  * Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
156
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).
157
173
  *
158
174
  * Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
159
175
  * env → the secure `~/.flair/admin-pass` file `flair init` already writes with
@@ -373,24 +389,38 @@ async function api(method, path, body, options) {
373
389
  const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
374
390
  const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
375
391
  const isLocal = isLocalBase(base);
376
- // 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):
377
396
  // 1. FLAIR_TOKEN env → Bearer token (backward compat)
378
- // 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth (remote targets only).
379
- // 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.
380
400
  // 3. FLAIR_AGENT_ID env + key file → Ed25519 signature (standard)
381
- // 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)
382
408
  //
383
409
  // NOTE: this function is for the Harper HTTP/REST API only. The Harper
384
410
  // operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
385
- // does NOT honor authorizeLocal it always requires Basic admin auth, and
386
- // those helpers send it unconditionally. authorizeLocal=true affects this
387
- // 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.
388
418
  let authHeader;
389
419
  const token = process.env.FLAIR_TOKEN;
390
420
  if (token) {
391
421
  authHeader = `Bearer ${token}`;
392
422
  }
393
- 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) {
394
424
  // Admin Basic auth — used by federation, backup, and other admin CLI commands
395
425
  const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
396
426
  authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
@@ -418,6 +448,25 @@ async function api(method, path, body, options) {
418
448
  }
419
449
  }
420
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
+ }
421
470
  }
422
471
  const res = await fetch(`${base}${path}`, {
423
472
  method,
@@ -434,8 +483,19 @@ async function api(method, path, body, options) {
434
483
  return { ok: true };
435
484
  }
436
485
  const text = await res.text();
437
- 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
+ }
438
497
  throw new Error(text || `HTTP ${res.status}`);
498
+ }
439
499
  if (!text)
440
500
  return { ok: true };
441
501
  return JSON.parse(text);
@@ -742,10 +802,13 @@ function readHarperPid(dataDir) {
742
802
  * Seed an agent record via the Harper operations API.
743
803
  * Accepts either a port number (localhost) or a full URL string (--target).
744
804
  *
745
- * `adminPass` is typed as optional but in practice the Harper operations API
746
- * always requires Basic admin auth every existing call site passes one.
747
- * The optional signature leaves headroom for a future Harper that honors
748
- * `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.
749
812
  */
750
813
  export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) {
751
814
  const url = typeof opsPortOrUrl === "number"
@@ -802,10 +865,12 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
802
865
  // admin:admin-pass), not the REST API (which needs server-side HDB_ADMIN_PASSWORD
803
866
  // — unavailable on Fabric). Same pattern as seedAgentViaOpsApi above.
804
867
  //
805
- // `adminPass` is optional in the signature for symmetry with seedAgentViaOpsApi
806
- // and to keep the door open for a future Harper that honors authorizeLocal on
807
- // its ops endpoint. Today the Harper operations API always requires Basic admin
808
- // 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.
809
874
  export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId, publicKey, role, adminUser, adminPass) {
810
875
  const url = typeof opsPortOrUrl === "number"
811
876
  ? `http://127.0.0.1:${opsPortOrUrl}/`
@@ -1091,9 +1156,6 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
1091
1156
  // WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
1092
1157
  // shorthand the flair_pair_initiator spec used, which was harmless only
1093
1158
  // because every grant there is false).
1094
- // 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
1095
- // it needs insert/update — currently read-only here; confirm the writer's
1096
- // identity (system vs agent) and widen only if it's the agent.
1097
1159
  // Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
1098
1160
  // grant (empty = no attribute-level restriction, so the table-level CRUD applies);
1099
1161
  // omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
@@ -1119,14 +1181,20 @@ const FLAIR_AGENT_PERMISSION = {
1119
1181
  Integration: grant(true, true, true, true),
1120
1182
  Credential: grant(true, true, true, true),
1121
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),
1122
1194
  // Agent: read for discovery, update own card; creation/removal is admin.
1123
1195
  Agent: grant(true, false, true, false),
1124
1196
  // Read-only reference data.
1125
1197
  Instance: grant(true, false, false, false),
1126
- // Observatory read-models — public reads; writes are system-driven (gate 3).
1127
- ObsOffice: grant(true, false, false, false),
1128
- ObsAgentSnapshot: grant(true, false, false, false),
1129
- ObsEventFeed: grant(true, false, false, false),
1130
1198
  // Federation / OAuth / IdP / internal — system + admin only; agents get none.
1131
1199
  Peer: grant(false, false, false, false),
1132
1200
  PairingToken: grant(false, false, false, false),
@@ -1320,6 +1388,50 @@ export function shouldPrintUpgradeLine(status, showAll) {
1320
1388
  return false;
1321
1389
  return true;
1322
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
+ }
1323
1435
  /**
1324
1436
  * Order a soul key→count map for display: highest count first, ties broken
1325
1437
  * alphabetically for stable output. Soul entries are keyed identity facts
@@ -1501,6 +1613,48 @@ const __pkgVersion = (() => {
1501
1613
  })();
1502
1614
  const program = new Command();
1503
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
+ });
1504
1658
  // ─── flair init ──────────────────────────────────────────────────────────────
1505
1659
  program
1506
1660
  .command("init")
@@ -1794,6 +1948,12 @@ program
1794
1948
  if (major < 18)
1795
1949
  throw new Error(`Node.js >= 18 required (found ${process.version})`);
1796
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");
1797
1957
  if (!opts.skipStart) {
1798
1958
  // Check if already running
1799
1959
  try {
@@ -1816,17 +1976,22 @@ program
1816
1976
  // database before the env is initialized).
1817
1977
  const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
1818
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.
1819
1983
  const harperSetConfig = JSON.stringify({
1820
1984
  rootPath: dataDir,
1821
1985
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1822
1986
  operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
1823
1987
  mqtt: { network: { port: null }, webSocket: false },
1824
1988
  localStudio: { enabled: false },
1825
- authentication: { authorizeLocal: true, enableSessions: true },
1989
+ authentication: { authorizeLocal: false, enableSessions: true },
1826
1990
  });
1827
1991
  const env = {
1828
1992
  ...process.env,
1829
1993
  ROOTPATH: dataDir,
1994
+ FLAIR_MODELS_DIR: modelsDir,
1830
1995
  HARPER_SET_CONFIG: harperSetConfig,
1831
1996
  DEFAULTS_MODE: "dev",
1832
1997
  HDB_ADMIN_USERNAME: adminUser,
@@ -1837,6 +2002,14 @@ program
1837
2002
  OPERATIONSAPI_NETWORK_PORT: String(opsPort),
1838
2003
  LOCAL_STUDIO: "false",
1839
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.
1840
2013
  if (alreadyInstalled) {
1841
2014
  console.log("Existing Harper installation found — skipping install.");
1842
2015
  console.log("If something is wrong, run: flair doctor");
@@ -1901,14 +2074,23 @@ program
1901
2074
  mkdirSync(plistDir, { recursive: true });
1902
2075
  const plistPath = join(plistDir, `${label}.plist`);
1903
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.
1904
2079
  const setConfig = JSON.stringify({
1905
2080
  rootPath: dataDir,
1906
2081
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1907
2082
  operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
1908
2083
  mqtt: { network: { port: null }, webSocket: false },
1909
2084
  localStudio: { enabled: false },
1910
- authentication: { authorizeLocal: true, enableSessions: true },
2085
+ authentication: { authorizeLocal: false, enableSessions: true },
1911
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;");
1912
2094
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
1913
2095
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1914
2096
  <plist version="1.0">
@@ -1925,7 +2107,8 @@ program
1925
2107
  <key>EnvironmentVariables</key>
1926
2108
  <dict>
1927
2109
  <key>ROOTPATH</key><string>${dataDir}</string>
1928
- <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>
1929
2112
  <key>DEFAULTS_MODE</key><string>dev</string>
1930
2113
  <key>HDB_ADMIN_USERNAME</key><string>${adminUser}</string>
1931
2114
  <key>HDB_ADMIN_PASSWORD</key><string>${adminPass}</string>
@@ -2675,6 +2858,108 @@ agent
2675
2858
  }
2676
2859
  console.log(`\n✅ Agent '${id}' removed successfully`);
2677
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
+ });
2678
2963
  // ─── flair principal ─────────────────────────────────────────────────────────
2679
2964
  // 1.0 identity management. The Principal model extends Agent — this is the
2680
2965
  // preferred CLI surface for managing identities going forward.
@@ -3389,7 +3674,7 @@ federation
3389
3674
  }
3390
3675
  });
3391
3676
  // `flair federation reachability` — probe local instance + all paired peers.
3392
- // Productizes ~/ops/scripts/flair-boot-probe.sh: a single command that tells
3677
+ // Productizes flair#695: a single command that tells
3393
3678
  // you whether memories CAN flow across the federation right now. Read-only;
3394
3679
  // no mutations, no side effects beyond a single tagged status read per peer.
3395
3680
  federation
@@ -4061,7 +4346,7 @@ federation
4061
4346
  await runFederationWatch(opts);
4062
4347
  });
4063
4348
  // `flair federation prune` — remove stale spoke peers (never the hub).
4064
- // Productizes ~/ops/scripts/cleanup-stale-fed-peers.sh into a real CLI
4349
+ // Productizes flair#695 into a real CLI
4065
4350
  // subcommand with safety: dry-run is the default, --apply required to delete.
4066
4351
  function parseDuration(spec) {
4067
4352
  // Accept forms like "30d", "12h", "90m". Returns milliseconds.
@@ -4171,7 +4456,7 @@ federation
4171
4456
  });
4172
4457
  // `flair federation verify` — end-to-end roundtrip: write a tagged memory
4173
4458
  // locally, wait for federation push, probe peers for the tag. Productizes
4174
- // ~/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.
4175
4460
  federation
4176
4461
  .command("verify")
4177
4462
  .description("End-to-end check: write a tagged memory locally and verify it shows up on each peer")
@@ -4405,13 +4690,61 @@ rem
4405
4690
  process.exit(1);
4406
4691
  }
4407
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.
4408
4740
  rem
4409
4741
  .command("rapid")
4410
- .description("REM — reflection/learning: generate a structured LLM reflection prompt")
4742
+ .description("REM — reflection/learning: distill recent memories into staged candidates")
4411
4743
  .option("--port <port>", "Harper HTTP port")
4412
4744
  .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
4413
4745
  .option("--focus <type>", "lessons_learned | patterns | decisions | errors", "lessons_learned")
4414
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)")
4415
4748
  .action(async (opts) => {
4416
4749
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
4417
4750
  if (!agentId) {
@@ -4420,30 +4753,62 @@ rem
4420
4753
  }
4421
4754
  console.log(`\n-- rem rapid --`);
4422
4755
  console.log(`Agent: ${agentId} Focus: ${opts.focus}`);
4423
- try {
4424
- const body = {
4425
- agentId,
4426
- focus: opts.focus,
4427
- };
4428
- if (opts.since)
4429
- body.since = opts.since;
4430
- const result = await api("POST", "/ReflectMemories", body);
4431
- if (result.error) {
4432
- 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}`);
4433
4782
  process.exit(1);
4434
4783
  }
4435
- console.log(`\nSource memories: ${result.count ?? 0}`);
4436
- if (result.suggestedTags?.length) {
4437
- 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));
4438
4796
  }
4439
- console.log("\n--- Reflection Prompt ---");
4440
- console.log(result.prompt ?? "(no prompt returned)");
4441
- console.log("--- End Prompt ---\n");
4442
- console.log("Feed the prompt above to your LLM, then write insights back with:");
4443
- 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>`);
4444
4798
  }
4445
4799
  catch (err) {
4446
- 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
+ }
4447
4812
  process.exit(1);
4448
4813
  }
4449
4814
  });
@@ -4942,6 +5307,11 @@ remNightly
4942
5307
  console.log(`Archived: ${row.archived ?? "—"}`);
4943
5308
  console.log(`Expired: ${row.expired ?? "—"}`);
4944
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
+ }
4945
5315
  console.log(`Duration: ${row.durationMs}ms`);
4946
5316
  if (row.errors.length > 0) {
4947
5317
  console.log(`Errors:`);
@@ -6029,32 +6399,68 @@ statusCmd
6029
6399
  console.log("\n✅ no warnings");
6030
6400
  }
6031
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
+ }
6032
6428
  // ─── flair upgrade --target <fabric> ────────────────────────────────────────
6033
6429
  //
6034
6430
  // One-command upgrade of a Flair instance DEPLOYED to a Harper Fabric cluster.
6035
6431
  // Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
6036
- // 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
6037
6434
  // version-resolution + @harperfast/harper pin + reuse of deploy() lives in
6038
6435
  // src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
6039
6436
  async function runFabricUpgrade(opts) {
6040
6437
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
6041
6438
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
6042
6439
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
6043
- const fabricUser = opts.fabricUser ?? process.env.FABRIC_USER;
6044
- 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
+ }
6045
6450
  const check = opts.check ?? false;
6046
6451
  // Creds are not required for --check (read-only registry + best-effort GET),
6047
6452
  // but ARE required to actually deploy.
6048
6453
  if (!check && !(fabricUser && fabricPassword)) {
6049
6454
  console.error(red("flair upgrade --target: credentials required to deploy"));
6050
- 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");
6051
6457
  console.error(" or use --check to preview the plan without credentials");
6052
6458
  process.exit(1);
6053
6459
  }
6054
- // Warn on password-via-flag (leaks to shell history). Env is preferred.
6055
- if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
6056
- console.error(dim("warning: --fabric-password leaks to shell history. Prefer FABRIC_PASSWORD env."));
6057
- }
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));
6058
6464
  const upgradeOpts = {
6059
6465
  target: opts.target,
6060
6466
  project: opts.project,
@@ -6092,6 +6498,29 @@ async function runFabricUpgrade(opts) {
6092
6498
  return;
6093
6499
  }
6094
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
+ }
6095
6524
  }
6096
6525
  catch (err) {
6097
6526
  console.error(red(`\n✗ fabric upgrade failed: ${err.message}`));
@@ -6102,32 +6531,378 @@ async function runFabricUpgrade(opts) {
6102
6531
  process.exit(1);
6103
6532
  }
6104
6533
  }
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.ts — just
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
+ }
6676
+ }
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
+ });
6105
6875
  // ─── flair upgrade ────────────────────────────────────────────────────────────
6106
6876
  program
6107
6877
  .command("upgrade")
6108
6878
  .description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
6109
6879
  .option("--check", "Only check for updates / show the plan, don't install or deploy")
6110
- .option("--restart", "Restart Flair after upgrade")
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)")
6111
6884
  .option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
6112
6885
  // ── Fabric upgrade (--target) ────────────────────────────────────────────
6113
6886
  // When --target is passed, upgrade the Flair component DEPLOYED to that
6114
6887
  // Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
6115
6888
  // under the hood with the @harperfast/harper pin baked in (flair#513).
6116
6889
  .option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
6117
- .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER) for --target")
6118
- .option("--fabric-password <pass>", "Fabric admin password (env: FABRIC_PASSWORD) for --target")
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")
6119
6893
  .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
6120
6894
  .option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
6121
6895
  .option("--project <name>", "Fabric component name for --target", "flair")
6122
6896
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
6123
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)")
6124
6899
  .action(async (opts) => {
6125
6900
  // ── Fabric-upgrade branch ───────────────────────────────────────────────
6126
6901
  if (opts.target) {
6127
6902
  await runFabricUpgrade(opts);
6128
6903
  return;
6129
6904
  }
6130
- const { execSync, execFileSync } = await import("node:child_process");
6905
+ const { execFileSync } = await import("node:child_process");
6131
6906
  const checkOnly = opts.check ?? false;
6132
6907
  const showAll = opts.all ?? false;
6133
6908
  console.log("Checking for updates...\n");
@@ -6236,22 +7011,121 @@ program
6236
7011
  }
6237
7012
  return;
6238
7013
  }
6239
- // Perform upgrade. `latest` comes from the npm registry's HTTP
6240
- // response, so CodeQL (correctly) treats it as untrusted input.
6241
- // Use execFileSync with argv the spec `<name>@<version>` becomes a
6242
- // single argument to the upgrade command, no shell to inject into.
6243
- console.log(`\nUpgrading ${totalUpgrades} package${totalUpgrades > 1 ? "s" : ""}...\n`);
6244
- for (const { pkg, latest } of npmUpgrades) {
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;
6245
7074
  try {
6246
- console.log(` Installing ${pkg}@${latest}...`);
6247
- execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
6248
- console.log(` ✅ ${pkg}@${latest} installed`);
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
+ }
6249
7085
  }
6250
7086
  catch (err) {
6251
- console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
6252
- }
6253
- }
6254
- for (const { pkg, latest } of openclawUpgrades) {
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
+ }
7106
+ // Perform upgrade. `latest` comes from the npm registry's HTTP
7107
+ // response, so CodeQL (correctly) treats it as untrusted input.
7108
+ // Use execFileSync with argv — the spec `<name>@<version>` becomes a
7109
+ // single argument to the upgrade command, no shell to inject into.
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;
7116
+ for (const { pkg, latest } of npmUpgrades) {
7117
+ try {
7118
+ console.log(` Installing ${pkg}@${latest}...`);
7119
+ execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
7120
+ console.log(` ✅ ${pkg}@${latest} installed`);
7121
+ }
7122
+ catch (err) {
7123
+ console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
7124
+ if (pkg === "@tpsdev-ai/flair")
7125
+ flairInstallFailed = true;
7126
+ }
7127
+ }
7128
+ for (const { pkg, latest } of openclawUpgrades) {
6255
7129
  // OpenClaw plugins upgrade via `openclaw plugins install --force --pin`.
6256
7130
  // Requires openclaw on PATH; if not, surface the manual recipe instead
6257
7131
  // of a confusing failure.
@@ -6271,31 +7145,107 @@ program
6271
7145
  console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
6272
7146
  }
6273
7147
  }
6274
- if (opts.restart) {
6275
- console.log("\nRestarting Flair...");
6276
- try {
6277
- const port = resolveHttpPort({});
6278
- const label = "ai.tpsdev.flair";
6279
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
6280
- if (process.platform === "darwin" && existsSync(plistPath)) {
6281
- try {
6282
- execSync(`launchctl stop ${label}`, { stdio: "pipe" });
6283
- }
6284
- catch { }
6285
- await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
6286
- console.log("✅ Flair restarted with new version");
6287
- }
6288
- else {
6289
- console.log("Run: flair restart");
6290
- }
6291
- }
6292
- catch {
6293
- console.log("Run: flair restart");
6294
- }
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).`);
6295
7243
  }
6296
7244
  else {
6297
- 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.");
6298
7247
  }
7248
+ process.exit(1);
6299
7249
  });
6300
7250
  // ─── flair stop ───────────────────────────────────────────────────────────────
6301
7251
  program
@@ -6390,9 +7340,12 @@ program
6390
7340
  }
6391
7341
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
6392
7342
  const opsPort = resolveOpsPort(opts);
7343
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
6393
7344
  const env = {
6394
7345
  ...process.env,
6395
7346
  ROOTPATH: dataDir,
7347
+ // See the matching comment at the install-time spawn site above.
7348
+ FLAIR_MODELS_DIR: modelsDir,
6396
7349
  DEFAULTS_MODE: "dev",
6397
7350
  HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
6398
7351
  HTTP_PORT: String(port),
@@ -6404,6 +7357,8 @@ program
6404
7357
  if (adminPass) {
6405
7358
  env.HDB_ADMIN_PASSWORD = adminPass;
6406
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).
6407
7362
  const proc = spawn(process.execPath, [bin, "run", "."], {
6408
7363
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
6409
7364
  });
@@ -6418,14 +7373,20 @@ program
6418
7373
  }
6419
7374
  });
6420
7375
  // ─── flair restart ────────────────────────────────────────────────────────────
6421
- program
6422
- .command("restart")
6423
- .description("Restart the Flair (Harper) instance")
6424
- .option("--port <port>", "Harper HTTP port")
6425
- .action(async (opts) => {
6426
- const port = resolveHttpPort(opts);
6427
- const platform = process.platform;
6428
- 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") {
6429
7390
  const label = "ai.tpsdev.flair";
6430
7391
  const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
6431
7392
  if (existsSync(plistPath)) {
@@ -6436,9 +7397,10 @@ program
6436
7397
  execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
6437
7398
  }
6438
7399
  catch { }
6439
- // Capture the current PID *before* stopping so we can verify exit. Without
6440
- // this, waitForHealth can race against the still-shutting-down old process
6441
- // 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.
6442
7404
  const oldPid = readHarperPid(defaultDataDir());
6443
7405
  try {
6444
7406
  execSync(`launchctl stop ${label}`, { stdio: "pipe" });
@@ -6446,69 +7408,122 @@ program
6446
7408
  catch { }
6447
7409
  if (oldPid)
6448
7410
  await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
6449
- await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
6450
- console.log("✅ Flair restarted");
6451
7411
  return;
6452
7412
  }
6453
7413
  catch (err) {
6454
- 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}`);
6455
7415
  }
6456
7416
  }
6457
7417
  }
6458
- {
6459
- // Port-based restart (Linux, or macOS fallback)
6460
- console.log("Stopping...");
6461
- try {
6462
- const { execSync } = await import("node:child_process");
6463
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
6464
- if (lsof) {
6465
- for (const pid of lsof.split("\n")) {
6466
- try {
6467
- process.kill(Number(pid.trim()), "SIGTERM");
6468
- }
6469
- 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");
6470
7427
  }
6471
- // Wait briefly for shutdown
6472
- await new Promise(r => setTimeout(r, 2000));
7428
+ catch { }
6473
7429
  }
7430
+ // Wait briefly for shutdown
7431
+ await new Promise(r => setTimeout(r, 2000));
6474
7432
  }
6475
- catch { /* not running */ }
6476
- console.log("Starting...");
6477
- const bin = harperBin();
6478
- if (!bin) {
6479
- console.error("❌ Harper binary not found. Run 'flair init' first.");
6480
- process.exit(1);
6481
- }
6482
- const dataDir = defaultDataDir();
6483
- // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
6484
- // Without this, `flair init --admin-pass X` (which only exports HDB_*
6485
- // to the initial Harper spawn) followed by `flair restart` would silently
6486
- // drop admin credentials any subsequent auth'd call returns 401.
6487
- const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
6488
- const env = {
6489
- ...process.env,
6490
- ROOTPATH: dataDir,
6491
- DEFAULTS_MODE: "dev",
6492
- HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
6493
- HTTP_PORT: String(port),
6494
- LOCAL_STUDIO: "false",
6495
- };
6496
- if (adminPass) {
6497
- env.HDB_ADMIN_PASSWORD = adminPass;
6498
- }
6499
- const proc = spawn(process.execPath, [bin, "run", "."], {
6500
- cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
6501
- });
6502
- proc.unref();
6503
- try {
6504
- await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
6505
- console.log("✅ Flair restarted");
6506
- }
6507
- catch {
6508
- console.error("❌ Flair failed to restart within timeout");
6509
- 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
+ }
6510
7462
  }
6511
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
+ }
6512
7527
  });
6513
7528
  // ─── flair uninstall ──────────────────────────────────────────────────────────
6514
7529
  program
@@ -6603,7 +7618,28 @@ program
6603
7618
  const dryRun = opts.dryRun ?? false;
6604
7619
  const batchSize = Number(opts.batchSize);
6605
7620
  const delayMs = Number(opts.delayMs);
6606
- 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;
6607
7643
  if (agentId) {
6608
7644
  console.log(`Re-embedding memories for agent: ${agentId}`);
6609
7645
  }
@@ -6896,8 +7932,9 @@ program
6896
7932
  .description("Deploy Flair as a component to a remote Harper Fabric cluster")
6897
7933
  .option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
6898
7934
  .option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
6899
- .option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
6900
- .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)")
6901
7938
  .option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
6902
7939
  .option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
6903
7940
  .option("--project <name>", "Component name in Fabric", "flair")
@@ -6913,16 +7950,27 @@ program
6913
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], [])
6914
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")
6915
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)")
6916
7954
  .action(async (opts) => {
6917
7955
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
6918
7956
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
6919
7957
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
6920
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
+ }
6921
7969
  const deployOpts = {
6922
7970
  fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
6923
7971
  fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
6924
- fabricUser: opts.fabricUser ?? process.env.FABRIC_USER,
6925
- fabricPassword: opts.fabricPassword ?? process.env.FABRIC_PASSWORD,
7972
+ fabricUser,
7973
+ fabricPassword,
6926
7974
  fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
6927
7975
  target: opts.target,
6928
7976
  project: opts.project,
@@ -6947,11 +7995,10 @@ program
6947
7995
  console.error(` - ${e}`);
6948
7996
  process.exit(1);
6949
7997
  }
6950
- // Warn on password-via-flag (leaks to shell history). Env is preferred.
6951
- if (opts.fabricPassword && !process.env.FABRIC_PASSWORD) {
6952
- console.error(dim("warning: --fabric-password leaks to shell history. " +
6953
- "Prefer FABRIC_PASSWORD env."));
6954
- }
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));
6955
8002
  const url = buildDeployUrl(deployOpts);
6956
8003
  console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
6957
8004
  if (deployOpts.dryRun)
@@ -6971,6 +8018,35 @@ program
6971
8018
  }
6972
8019
  console.log(`\n URL: ${result.url}`);
6973
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
+ }
6974
8050
  console.log(`\nNext steps:`);
6975
8051
  console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
6976
8052
  console.log(dim(` 2. Seed your first agent:`));
@@ -6994,6 +8070,67 @@ program
6994
8070
  process.exit(1);
6995
8071
  }
6996
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
+ });
6997
8134
  // ─── flair doctor ─────────────────────────────────────────────────────────────
6998
8135
  program
6999
8136
  .command("doctor")
@@ -7153,6 +8290,53 @@ program
7153
8290
  issues++;
7154
8291
  }
7155
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
+ }
7156
8340
  // 2. Keys directory
7157
8341
  const keysDir = defaultKeysDir();
7158
8342
  if (existsSync(keysDir)) {
@@ -7402,6 +8586,146 @@ program
7402
8586
  }
7403
8587
  }
7404
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
+ }
7405
8729
  // Summary
7406
8730
  console.log("");
7407
8731
  if (issues === 0) {
@@ -7567,7 +8891,9 @@ sessionSnapshot
7567
8891
  });
7568
8892
  // ─── Memory and Soul commands ────────────────────────────────────────────────
7569
8893
  const memory = program.command("memory").description("Manage agent memories");
7570
- 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>")
7571
8897
  .option("--content <text>", "memory content (alias for positional arg)")
7572
8898
  .option("--durability <d>", "standard").option("--tags <csv>")
7573
8899
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
@@ -7676,6 +9002,7 @@ memory.command("write-task-summary")
7676
9002
  console.log(memId);
7677
9003
  });
7678
9004
  memory.command("search [query]")
9005
+ .description("Semantic search over an agent's memories (query via positional arg or --q)")
7679
9006
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7680
9007
  .option("--q <query>", "search query (alias for positional arg)")
7681
9008
  .option("--limit <n>", "Max results", "5")
@@ -7702,6 +9029,7 @@ memory.command("search [query]")
7702
9029
  console.log(JSON.stringify(res, null, 2));
7703
9030
  });
7704
9031
  memory.command("list")
9032
+ .description("List an agent's memories (optionally filtered by --tag or embedding-backfill triage)")
7705
9033
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
7706
9034
  .option("--tag <tag>")
7707
9035
  .option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
@@ -7983,7 +9311,7 @@ program
7983
9311
  .option("--since <iso-or-relative>", "Only memories created after this point (ISO 8601 or '7d'/'24h'/'30m')")
7984
9312
  .option("--as-of <iso>", "Temporal validity: only memories valid at this point (ISO 8601)")
7985
9313
  .option("--include-superseded", "Include memories that have been superseded")
7986
- .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")
7987
9315
  .option("--min-score <n>", "Drop results below this score (0..1)", "0")
7988
9316
  // Client-side filters (applied after server response)
7989
9317
  .option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
@@ -8009,7 +9337,7 @@ program
8009
9337
  agentId,
8010
9338
  q: query,
8011
9339
  limit: Number.parseInt(opts.limit, 10) || 5,
8012
- scoring: opts.scoring === "raw" ? "raw" : "composite",
9340
+ scoring: opts.scoring === "composite" ? "composite" : "raw",
8013
9341
  };
8014
9342
  if (opts.tag)
8015
9343
  payload.tag = opts.tag;
@@ -8210,8 +9538,73 @@ program
8210
9538
  process.exit(1);
8211
9539
  }
8212
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
+ });
8213
9605
  const soul = program.command("soul").description("Manage agent soul entries");
8214
9606
  soul.command("set")
9607
+ .description("Set (upsert) a soul entry for an agent by key")
8215
9608
  .requiredOption("--agent <id>")
8216
9609
  .requiredOption("--key <key>")
8217
9610
  .requiredOption("--value <value>")
@@ -8243,6 +9636,7 @@ soul.command("set")
8243
9636
  console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
8244
9637
  });
8245
9638
  soul.command("get")
9639
+ .description("Fetch a single soul entry by id (agent:key)")
8246
9640
  .argument("<id>")
8247
9641
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
8248
9642
  .action(async (id, opts) => {
@@ -8274,6 +9668,7 @@ soul.command("get")
8274
9668
  }
8275
9669
  });
8276
9670
  soul.command("list")
9671
+ .description("List all soul entries for an agent")
8277
9672
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
8278
9673
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
8279
9674
  .action(async (opts) => {
@@ -9620,7 +11015,7 @@ program
9620
11015
  }
9621
11016
  });
9622
11017
  // ─── flair presence ─────────────────────────────────────────────────────────
9623
- const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
11018
+ const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "debugging", "idle"];
9624
11019
  const MAX_TASK_LENGTH = 120;
9625
11020
  const presence = program.command("presence").description("Manage agent presence (The Office Space)");
9626
11021
  presence
@@ -9687,15 +11082,26 @@ presence
9687
11082
  // ─── flair workspace ─────────────────────────────────────────────────────────
9688
11083
  //
9689
11084
  // Coordination write surface (Kris #510). `workspace set` writes the
9690
- // agent's OWN WorkspaceState via a signed POST /WorkspaceState. Identity comes
9691
- // from the Ed25519 signature the body carries NO agentId, so an agent can only
9692
- // write as itself (the WorkspaceState.post() handler overwrites agentId from the
9693
- // signature regardless of body content). Mirrors `presence set`'s signed-POST shape.
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.
9694
11100
  const MAX_WORKSPACE_FIELD_LENGTH = 2000;
9695
11101
  const workspace = program.command("workspace").description("Manage agent workspace state (The Office Space)");
9696
11102
  workspace
9697
11103
  .command("set")
9698
- .description("Set your agent's current workspace state (POST /WorkspaceState)")
11104
+ .description("Set your agent's current workspace state (PUT /WorkspaceState/{id})")
9699
11105
  .requiredOption("--ref <ref>", "Workspace ref (branch, worktree, or task ref)")
9700
11106
  .option("--label <text>", "Human-readable label for this workspace")
9701
11107
  .option("--provider <name>", "Provider/runtime (e.g. claude-code, openclaw)", "cli")
@@ -9724,15 +11130,23 @@ workspace
9724
11130
  process.exit(1);
9725
11131
  }
9726
11132
  const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
9727
- const auth = buildEd25519Auth(agentId, "POST", "/WorkspaceState", keyPath);
9728
- // NOTE: agentId is intentionally NOT included in the body the handler
9729
- // 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.
9730
11142
  const now = new Date().toISOString();
9731
11143
  const body = {
9732
- id: `${agentId}:${opts.ref}`,
11144
+ id,
11145
+ agentId,
9733
11146
  ref: opts.ref,
9734
11147
  provider: opts.provider ?? "cli",
9735
11148
  timestamp: now,
11149
+ createdAt: now,
9736
11150
  };
9737
11151
  if (opts.label)
9738
11152
  body.label = opts.label;
@@ -9742,14 +11156,14 @@ workspace
9742
11156
  body.phase = opts.phase;
9743
11157
  if (opts.summary)
9744
11158
  body.summary = opts.summary;
9745
- const res = await fetch(`${baseUrl}/WorkspaceState`, {
9746
- method: "POST",
11159
+ const res = await fetch(`${baseUrl}/WorkspaceState/${id}`, {
11160
+ method: "PUT",
9747
11161
  headers: { "Content-Type": "application/json", Authorization: auth },
9748
11162
  body: JSON.stringify(body),
9749
11163
  });
9750
11164
  if (!res.ok) {
9751
11165
  const text = await res.text().catch(() => "");
9752
- console.error(`Error: POST /WorkspaceState failed (${res.status}): ${text}`);
11166
+ console.error(`Error: PUT /WorkspaceState/${id} failed (${res.status}): ${text}`);
9753
11167
  process.exit(1);
9754
11168
  }
9755
11169
  console.log(`✓ Workspace state updated for '${agentId}': ref=${opts.ref}${opts.phase ? `, phase=${opts.phase}` : ""}`);
@@ -9757,15 +11171,28 @@ workspace
9757
11171
  // ─── flair orgevent ──────────────────────────────────────────────────────────
9758
11172
  //
9759
11173
  // Coordination write surface (Kris #510). `orgevent` publishes an
9760
- // OrgEvent ATTRIBUTED to the authenticated agent via a signed POST /OrgEvent.
9761
- // The event's authorId comes from the Ed25519 signature — the body carries NO
9762
- // authorId, so an agent CANNOT forge another agent's events (the OrgEvent.post()
9763
- // handler overwrites authorId from the signature). Mirrors `presence set`'s shape.
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()}`).
9764
11191
  const MAX_ORGEVENT_SUMMARY_LENGTH = 500;
9765
11192
  const MAX_ORGEVENT_DETAIL_LENGTH = 8000;
9766
11193
  program
9767
11194
  .command("orgevent")
9768
- .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})")
9769
11196
  .requiredOption("--kind <kind>", "Event kind (e.g. coord.claim, coord.release, status)")
9770
11197
  .requiredOption("--summary <text>", "Short summary of the event")
9771
11198
  .option("--detail <text>", "Longer detail payload")
@@ -9796,12 +11223,22 @@ program
9796
11223
  // orgevent reuses --target for recipients, so the remote-URL override is
9797
11224
  // --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
9798
11225
  const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
9799
- const auth = buildEd25519Auth(agentId, "POST", "/OrgEvent", keyPath);
9800
- // NOTE: authorId is intentionally NOT included in the body — the handler
9801
- // 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.
9802
11236
  const body = {
11237
+ id,
11238
+ authorId: agentId,
9803
11239
  kind: opts.kind,
9804
11240
  summary: opts.summary,
11241
+ createdAt: new Date().toISOString(),
9805
11242
  };
9806
11243
  if (opts.detail)
9807
11244
  body.detail = opts.detail;
@@ -9809,21 +11246,126 @@ program
9809
11246
  body.scope = opts.scope;
9810
11247
  if (Array.isArray(opts.target) && opts.target.length > 0)
9811
11248
  body.targetIds = opts.target;
9812
- const res = await fetch(`${baseUrl}/OrgEvent`, {
9813
- method: "POST",
11249
+ const res = await fetch(`${baseUrl}/OrgEvent/${id}`, {
11250
+ method: "PUT",
9814
11251
  headers: { "Content-Type": "application/json", Authorization: auth },
9815
11252
  body: JSON.stringify(body),
9816
11253
  });
9817
11254
  if (!res.ok) {
9818
11255
  const text = await res.text().catch(() => "");
9819
- console.error(`Error: POST /OrgEvent failed (${res.status}): ${text}`);
11256
+ console.error(`Error: PUT /OrgEvent/${id} failed (${res.status}): ${text}`);
9820
11257
  process.exit(1);
9821
11258
  }
9822
11259
  const data = await res.json().catch(() => null);
9823
11260
  const targets = Array.isArray(opts.target) && opts.target.length > 0 ? ` → ${opts.target.join(", ")}` : "";
9824
11261
  console.log(`✓ OrgEvent published as '${agentId}': kind=${opts.kind}${targets}`);
9825
- if (data?.id)
9826
- 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
+ }
9827
11369
  });
9828
11370
  // Parse argv and run the CLI. Exported so the CommonJS preflight shim
9829
11371
  // (cli-shim.cts → dist/cli-shim.cjs, the real bin entry) can invoke it after