@tpsdev-ai/flair 0.51.2 → 0.52.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 (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +6 -5
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
package/dist/cli.js CHANGED
@@ -20,13 +20,13 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
20
20
  import { probeInstance } from "./probe.js";
21
21
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
22
22
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
23
- import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, wirePi, piFlairSpec, PI_FLAIR_PACKAGE, PI_FLAIR_DEFAULT_URL, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
23
+ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, wireAntigravity, wirePi, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
24
24
  import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
25
25
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
26
26
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
27
- import { readClientMcpBlock, effectiveFlairUrl, checkPiFlairWiring, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
27
+ import { readClientMcpBlock, effectiveFlairUrl, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, checkSessionStartHookPinSkew, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
28
28
  import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
29
- import { installHook, uninstallHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
29
+ import { installHook, uninstallHook, repinSessionStartHook, hookStatus, hookStatusIdentityLines, HOOK_STATUS_UNPARSED, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, hookSettingsPath, hookInstallHint, harnessSupportsContinuity, resolveHookAgentId, } from "./hook-install.js";
30
30
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, DEFAULT_ADMIN_USER, resolveAdminUser, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
31
31
  import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
32
32
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
@@ -41,6 +41,8 @@ import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartT
41
41
  // functions — this pulls in nothing but node builtins.
42
42
  import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
43
43
  import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
44
+ import { collectUpgradeExecPathWarning, findFlairPackageDir, resolveNpmGlobalFlairPackage, resolveServingFlairPackage, } from "./lib/upgrade-exec-path.js";
45
+ import { applyPlainTreeUpgrade, decidePlainTreeRollback, discardPlainTreePrevious, findSystemdUnitsForTree, formatPlainTreeBanner, formatPlainTreePlan, formatPlainTreeScopeFooter, planPlainTreeUpgrade, resolvePlainTreeListingTarget, resolvePlainTreeTarget, restartSystemdUnits, restorePlainTreePrevious, } from "./lib/upgrade-plain-tree.js";
44
46
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
45
47
  // src/ into resources/, which don't survive npm packaging (see also
46
48
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -75,6 +77,16 @@ function signBody(body, secretKey) {
75
77
  // it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
76
78
  // no longer informational-only. Credential.principalId is an unrelated
77
79
  // owner field — do not grep that path when changing this one.
80
+ //
81
+ // S2 COMMENT-PIN (Kern P2-5, flair#1521): Message is principal-owning by `from`
82
+ // (PRINCIPAL_OWNER_FIELD in federation-classify.ts) but its rows carry NO
83
+ // `provenance` stamp, so principalIdFromRow returns undefined for them → a v:2
84
+ // Message push would omit `principalId` → the receiver's v≥2
85
+ // checkPrincipalEntitlement skips EVERY Message as `principal_mismatch` (a
86
+ // 100%-skip sync, not a migration). S1 does not push Message (receive-only —
87
+ // the spoke push list below is a separate hardcoded set), so this is inert
88
+ // today; the S2 pusher MUST stamp `principalId = row.from` for Message here
89
+ // (a per-table owner-aware derivation, not `provenance.verified.agentId`).
78
90
  function principalIdFromRow(row) {
79
91
  if (typeof row?.provenance !== "string" || row.provenance.length === 0)
80
92
  return undefined;
@@ -687,10 +699,14 @@ function resolveSigningIdentityFor(opts, command) {
687
699
  emitSigningIdentityDebug(resolved, command);
688
700
  return resolved;
689
701
  }
690
- // Same seam, returning just the agentId (or null) for the common call site that
691
- // only needs the id. Still emits the debug line via resolveSigningIdentityFor.
702
+ // Same seam, returning the full ResolvedSigningIdentity (agentId + source) so
703
+ // callers can thread the source down to api()/authedRequest. The source is what
704
+ // lets authedRequest distinguish a flag-pinned agent (--agent X, flair#1500)
705
+ // from an env-pinned one (FLAIR_AGENT_ID) — the flag must sign as itself BEFORE
706
+ // env admin, while the env keeps its legacy Basic behavior. Still emits the
707
+ // debug line via resolveSigningIdentityFor.
692
708
  function resolveSigningAgentId(opts, command) {
693
- return resolveSigningIdentityFor(opts, command).agentId;
709
+ return resolveSigningIdentityFor(opts, command);
694
710
  }
695
711
  // ── Shared credential/identity flag surface (flair#1106) ─────────────────────
696
712
  // Sibling commands (memory add, backup, federation sync) used to drift on
@@ -1484,6 +1500,7 @@ async function api(method, path, body, options) {
1484
1500
  keysDir: options?.keysDir,
1485
1501
  explicitAdminPass: options?.explicitAdminPass,
1486
1502
  adminUser: options?.adminUser,
1503
+ agentIdSource: options?.agentIdSource,
1487
1504
  });
1488
1505
  }
1489
1506
  /**
@@ -2413,10 +2430,30 @@ const FLAIR_AGENT_PERMISSION = {
2413
2430
  Memory: grant(true, true, true, true),
2414
2431
  MemoryCandidate: grant(true, true, true, true),
2415
2432
  MemoryGrant: grant(true, true, true, true),
2433
+ // Asset (images-in-Flair slice 1). Harper authorizes BEFORE Asset.post
2434
+ // runs, so a de-elevated flair_agent needs the table grant or signed
2435
+ // POST /Asset 403s as AccessViolation (Kern P0). CRUD envelope;
2436
+ // owner-only + write-time size/MIME gates live in resources/Asset.ts.
2437
+ Asset: grant(true, true, true, true),
2416
2438
  Soul: grant(true, true, true, false),
2417
2439
  OrgEvent: grant(true, true, true, true),
2418
2440
  WorkspaceState: grant(true, true, true, true),
2419
2441
  Relationship: grant(true, true, true, true),
2442
+ // Flair Relay S1 (flair#1521). Harper authorizes BEFORE the resource
2443
+ // methods run, so a de-elevated flair_agent needs the table grant OR it
2444
+ // 403s on POST /Message before relaySend is reached (Kern P0-2). read =
2445
+ // the party-scoped collection (Message.search); insert = send via post().
2446
+ // update = FALSE (least privilege, Kern P0 blocker): the ack's write goes
2447
+ // through the IN-PROCESS static accessor (relayConsume → deps.messages.put),
2448
+ // which bypasses role gates entirely — the same raw-put seam Federation.ts
2449
+ // relies on — so update:true is NOT needed for any legitimate path. Leaving
2450
+ // it granted let PATCH /Message/<id> reach Table's update verb, whose
2451
+ // authorize step consults update:true and PASSES for any de-elevated agent,
2452
+ // bypassing Message.put()'s FORBIDDEN guard AND relayConsume's recipient-only
2453
+ // check (Message has no patch() at the platform level → TableResource.patch
2454
+ // runs update()+save() directly). delete = false: direct deletes are
2455
+ // admin/internal only. Message.patch() also guards the verb in-resource.
2456
+ Message: grant(true, true, false, false),
2420
2457
  Integration: grant(true, true, true, true),
2421
2458
  Credential: grant(true, true, true, true),
2422
2459
  Presence: grant(true, true, true, false),
@@ -2430,6 +2467,9 @@ const FLAIR_AGENT_PERMISSION = {
2430
2467
  // (create → count → delete → count again → repeat), defeating the cap
2431
2468
  // entirely. See resources/MemoryUsage.ts's module doc.
2432
2469
  MemoryUsage: grant(true, true, false, false),
2470
+ // MemoryHitStat (flair#1528): internal search-hit ledger. No agent REST
2471
+ // surface (@table without @export). Counts overlay onto Memory reads.
2472
+ MemoryHitStat: grant(false, false, false, false),
2433
2473
  // Agent: read for discovery, update own card; creation/removal is admin.
2434
2474
  Agent: grant(true, false, true, false),
2435
2475
  // Read-only reference data.
@@ -3672,7 +3712,7 @@ program
3672
3712
  console.log("");
3673
3713
  for (const [key, value] of soulEntries) {
3674
3714
  try {
3675
- await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
3715
+ await api("PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() }, { baseUrl: httpUrl, explicitAdminPass: adminPass, adminUser });
3676
3716
  console.log(` ✓ soul:${key} set`);
3677
3717
  }
3678
3718
  catch (err) {
@@ -4474,8 +4514,9 @@ agent
4474
4514
  /** Best-effort seed-validity check for a `.key` file: does it parse via any
4475
4515
  * of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
4476
4516
  * — the same loader `flair mcp token` uses) accepts? Never throws — used
4477
- * only to decide "invalid" vs. "worth a registration check", not to
4478
- * actually sign anything. */
4517
+ * only to decide "unidentified" vs. "worth a registration check", not to
4518
+ * actually sign anything. An unparseable file is not junk: the keys dir is
4519
+ * also where FileKeyStore writes AES-256-GCM blobs (flair#1026). */
4479
4520
  function isValidPrivateKeySeedFile(keyPath) {
4480
4521
  try {
4481
4522
  loadEd25519PrivateKeyFromFile(keyPath);
@@ -4490,10 +4531,11 @@ function isValidPrivateKeySeedFile(keyPath) {
4490
4531
  * never writes or moves anything; see applyKeyPrune below for the actual
4491
4532
  * move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
4492
4533
  * and files not ending in `.key` are "ignored" without any network call.
4493
- * `.key` files with an unparseable seed are "invalid" without a network call
4494
- * either — only a `.key` file that DOES parse triggers a signed
4495
- * `GET /Agent/:id` against `baseUrl` (checkAgentRegistered above, the exact
4496
- * same check doctor's registration gate uses).
4534
+ * `.key` files with an unparseable seed are "unidentified" without a network
4535
+ * call either — reported, never pruned (flair#1026). Only a `.key` file that
4536
+ * DOES parse triggers a signed `GET /Agent/:id` against `baseUrl`
4537
+ * (checkAgentRegistered above, the exact same check doctor's registration
4538
+ * gate uses).
4497
4539
  *
4498
4540
  * If that check EVER reports "unreachable" — the instance couldn't be
4499
4541
  * confirmed up for that key — the WHOLE run aborts immediately
@@ -4544,7 +4586,7 @@ export async function classifyKeysDir(keysDir, baseUrl) {
4544
4586
  // flair#1023 added "key-unreadable". It cannot occur here — this key's
4545
4587
  // seed already parsed via isValidPrivateKeySeedFile above — but is
4546
4588
  // handled explicitly rather than folded into the else: a key that will
4547
- // not load means exactly what prune already calls "invalid".
4589
+ // not load is "unidentified", not prunable "invalid" (flair#1026).
4548
4590
  const decision = reg.state === "key-unreadable"
4549
4591
  ? classifyKeyFile(c.agentId, false, null, baseUrl)
4550
4592
  : classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
@@ -4604,10 +4646,11 @@ keys
4604
4646
  }
4605
4647
  const stale = result.entries.filter((e) => e.class === "stale");
4606
4648
  const invalid = result.entries.filter((e) => e.class === "invalid");
4649
+ const unidentified = result.entries.filter((e) => e.class === "unidentified");
4607
4650
  const kept = result.entries.filter((e) => e.class === "keep");
4608
4651
  const ignored = result.entries.filter((e) => e.class === "ignored");
4609
4652
  const prunable = [...stale, ...invalid];
4610
- if (stale.length + invalid.length + kept.length === 0) {
4653
+ if (stale.length + invalid.length + unidentified.length + kept.length === 0) {
4611
4654
  console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
4612
4655
  console.log("");
4613
4656
  return;
@@ -4616,12 +4659,15 @@ keys
4616
4659
  const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
4617
4660
  console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
4618
4661
  }
4662
+ for (const e of unidentified) {
4663
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.bold, e.name)} — unidentified: ${e.reason}`);
4664
+ }
4619
4665
  for (const e of kept) {
4620
4666
  console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
4621
4667
  }
4622
4668
  if (!apply) {
4623
4669
  console.log("");
4624
- console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${ignored.length} ignored`)}`);
4670
+ console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${unidentified.length} unidentified (left in place), ${ignored.length} ignored`)}`);
4625
4671
  if (prunable.length > 0) {
4626
4672
  console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
4627
4673
  }
@@ -4633,7 +4679,7 @@ keys
4633
4679
  for (const m of moved) {
4634
4680
  console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
4635
4681
  }
4636
- console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
4682
+ console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${unidentified.length} unidentified (left in place), ${ignored.length} ignored\n`);
4637
4683
  });
4638
4684
  // ─── flair hook ──────────────────────────────────────────────────────────────
4639
4685
  // Ambient memory via harness SessionStart hooks (flair#745, design record
@@ -7937,6 +7983,9 @@ rem
7937
7983
  .command("candidates")
7938
7984
  .description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
7939
7985
  .option("--port <port>", "Harper HTTP port")
7986
+ .option("--ops-port <port>", "Harper operations API port")
7987
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
7988
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
7940
7989
  .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
7941
7990
  .option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
7942
7991
  .option("--json", "Output as JSON for scripting")
@@ -7952,16 +8001,35 @@ rem
7952
8001
  console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
7953
8002
  process.exit(1);
7954
8003
  }
8004
+ const opsPort = resolveOpsPort(opts);
8005
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8006
+ if (!adminPass) {
8007
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
8008
+ process.exit(1);
8009
+ }
8010
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
7955
8011
  try {
7956
- const result = await api("POST", "/MemoryCandidate/search_by_conditions", {
7957
- operator: "and",
7958
- conditions: [
7959
- { search_attribute: "agentId", search_type: "equals", search_value: agentId },
7960
- { search_attribute: "status", search_type: "equals", search_value: status },
7961
- ],
7962
- get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
8012
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
8013
+ method: "POST",
8014
+ headers: { "Content-Type": "application/json", Authorization: auth },
8015
+ body: JSON.stringify({
8016
+ operation: "search_by_conditions",
8017
+ schema: "flair",
8018
+ table: "MemoryCandidate",
8019
+ operator: "and",
8020
+ conditions: [
8021
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
8022
+ { search_attribute: "status", search_type: "equals", search_value: status },
8023
+ ],
8024
+ get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
8025
+ }),
7963
8026
  });
7964
- const candidates = Array.isArray(result) ? result : (result?.results ?? []);
8027
+ if (!res.ok) {
8028
+ const text = await res.text().catch(() => "");
8029
+ console.error(`${render.icons.error} ${res.status} ${text}`);
8030
+ process.exit(1);
8031
+ }
8032
+ const candidates = await res.json();
7965
8033
  const mode = render.resolveOutputMode(opts);
7966
8034
  if (mode === "json") {
7967
8035
  console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
@@ -8011,209 +8079,8 @@ rem
8011
8079
  // Pure validators extracted for testability. The action callbacks below thread
8012
8080
  // these through process.exit on failure; the helpers themselves are
8013
8081
  // side-effect-free.
8014
- export function validatePromoteOpts(opts) {
8015
- if (!opts.rationale || !opts.rationale.trim()) {
8016
- return "--rationale is required (per spec § 5: no rubber-stamp)";
8017
- }
8018
- if (!opts.to || (opts.to !== "soul" && opts.to !== "memory")) {
8019
- return "--to must be 'soul' or 'memory'";
8020
- }
8021
- if (opts.to === "soul" && (!opts.key || !opts.key.trim())) {
8022
- return "--key is required when --to=soul (gives the Soul entry a meaningful identifier)";
8023
- }
8024
- return null;
8025
- }
8026
- export function validateRejectOpts(opts) {
8027
- if (!opts.reason || !opts.reason.trim()) {
8028
- return "--reason is required";
8029
- }
8030
- return null;
8031
- }
8032
- /**
8033
- * Decide whether a promote/reject action can proceed against a candidate's
8034
- * current state, and what message to surface to the operator. Pure function;
8035
- * action side effects happen in the CLI body after this returns ok.
8036
- */
8037
- export function decideCandidateAction(candidate, action) {
8038
- if (!candidate)
8039
- return { ok: false, severity: "error", message: "candidate not found" };
8040
- const status = candidate.status;
8041
- if (status === "promoted") {
8042
- return action === "promote"
8043
- ? { ok: false, severity: "error", message: `already promoted (target=${candidate.target}, reviewer=${candidate.reviewerId})` }
8044
- : { ok: false, severity: "error", message: `already promoted; cannot reject after promotion` };
8045
- }
8046
- if (status === "rejected") {
8047
- return action === "reject"
8048
- ? { ok: false, severity: "info", message: `already rejected on ${candidate.decidedAt} by ${candidate.reviewerId}` }
8049
- : { ok: false, severity: "error", message: `already rejected; use a fresh candidate or reset status manually` };
8050
- }
8051
- return { ok: true };
8052
- }
8053
- // ─── ADK tag-lineage on promote (#1205 slice 1205a — Sherlock security req) ───
8054
- // ADK session records are written by adk-flair (memory_service.py) under a
8055
- // SHARED-namespace agentId, with per-user separation carried ENTIRELY by a
8056
- // compound scope tag `adk:<app>:<user>`. That tag is the access-control
8057
- // boundary. A candidate distilled from those records therefore MUST carry the
8058
- // scope tag when promoted, or the promoted claim lands in the shared agentId
8059
- // memory retrievable by every other user of the app — a cross-user leak.
8060
- //
8061
- // `rem promote` historically hard-coded `["nightly-rem-promoted", from:<id>]`
8062
- // and DROPPED the source tag. We now propagate the source scope tag for
8063
- // ADK-sourced candidates, and FAIL CLOSED (refuse) when a candidate is
8064
- // ADK-sourced but its scope tag can't be uniquely+completely determined.
8065
- //
8066
- // SCOPING (deliberate, per spec): fail-closed applies ONLY to ADK-sourced
8067
- // candidates. Non-ADK candidates carry no `adk:` tag and promote byte-for-byte
8068
- // as before — a transient/deleted source on a non-ADK candidate must NOT block
8069
- // its promotion.
8070
- //
8071
- // SEAM (foundation only; the distillation engine is slice #1205b): ADK-sourcing
8072
- // is detected here by re-reading the candidate's source memories and inspecting
8073
- // their tags. That leaves ONE residual fail-open: an ADK-sourced candidate all
8074
- // of whose source memories are unreadable (deleted/transient) yields no `adk:`
8075
- // evidence and is treated as non-ADK. Closing that corner without regressing
8076
- // non-ADK promotion requires the ENGINE to stamp the authoritative scope tag
8077
- // onto the MemoryCandidate row at distillation time (it distills per single
8078
- // scope:tagged tag, so it knows it authoritatively). `derivePromotedTags` is
8079
- // written so that override can be threaded in later without touching callers.
8080
- export const ADK_SCOPE_TAG_PREFIX = "adk:";
8081
- /**
8082
- * Decide the tag set for a promoted Memory given the candidate id and the
8083
- * result of fetching each of its source memories. Pure — no I/O; the action
8084
- * callback does the fetching and threads the results here so this is unit-
8085
- * testable and the fail-closed logic is exercised directly.
8086
- *
8087
- * `stampedScopeTag` (#1205b-1 — the engine slice the #1205a SEAM note below
8088
- * anticipated): the authoritative scope:"tagged" tag the distillation engine
8089
- * stamped onto the MemoryCandidate row (resources/MemoryReflect.ts →
8090
- * buildStagedCandidateRow). When present it is AUTHORITATIVE and short-circuits
8091
- * the source re-read entirely — the engine distilled under exactly this one
8092
- * tag, so it knows the per-user scope tag independent of whether the source
8093
- * memories are still readable. This closes the residual fail-open the SEAM
8094
- * note describes: a candidate all of whose sources are unreadable yields no
8095
- * `adk:` evidence and would otherwise be mis-classified NON-ADK and promoted
8096
- * tagless into the shared agentId namespace (a cross-user leak). Threading it
8097
- * in as an optional trailing arg keeps every pre-#1205b caller (and every
8098
- * candidate that never carried a stamp) on the unchanged source-re-read path.
8099
- *
8100
- * With NO stamp (undefined/empty) the source-re-read classification runs
8101
- * exactly as in #1205a:
8102
- * - No `adk:` scope tag across readable sources → NON-ADK candidate; return
8103
- * the provenance tags only (unchanged behavior).
8104
- * - Exactly one `adk:` scope tag AND every source readable → ADK-sourced;
8105
- * return [scopeTag, ...provenance].
8106
- * - `adk:` evidence present but the scope tag is ambiguous (>1 distinct tag)
8107
- * OR incomplete (some source unreadable) → REFUSE (fail-closed): a
8108
- * tagless/mis-tagged claim in a shared ADK namespace is a cross-user leak,
8109
- * not a benign miss.
8110
- */
8111
- export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
8112
- const provenance = ["nightly-rem-promoted", `from:${candidateId}`];
8113
- // #1205b-1: a stamped scope tag is AUTHORITATIVE — consume it directly, never
8114
- // re-read sources. This is the seam closure: correctness no longer depends on
8115
- // source readability. `adkSourced` (which gates the Soul-promotion refusal in
8116
- // the promote action) tracks whether the stamped tag is an ADK scope tag.
8117
- if (typeof stampedScopeTag === "string" && stampedScopeTag.length > 0) {
8118
- return {
8119
- ok: true,
8120
- tags: [stampedScopeTag, ...provenance],
8121
- adkSourced: stampedScopeTag.startsWith(ADK_SCOPE_TAG_PREFIX),
8122
- };
8123
- }
8124
- const adkTags = new Set();
8125
- let anySourceUnreadable = false;
8126
- for (const s of sources) {
8127
- if (!s.ok) {
8128
- anySourceUnreadable = true;
8129
- continue;
8130
- }
8131
- for (const t of s.tags) {
8132
- if (typeof t === "string" && t.startsWith(ADK_SCOPE_TAG_PREFIX))
8133
- adkTags.add(t);
8134
- }
8135
- }
8136
- // No positive ADK evidence → non-ADK. An unreadable source with zero ADK
8137
- // evidence does NOT fail closed here (that would regress non-ADK promotion);
8138
- // see the SEAM note above.
8139
- if (adkTags.size === 0) {
8140
- return { ok: true, tags: provenance, adkSourced: false };
8141
- }
8142
- if (adkTags.size > 1) {
8143
- return {
8144
- ok: false,
8145
- reason: `ADK-sourced candidate spans multiple scope tags (${[...adkTags].sort().join(", ")}); refusing to promote — a merged cross-user claim would leak across users`,
8146
- };
8147
- }
8148
- if (anySourceUnreadable) {
8149
- return {
8150
- ok: false,
8151
- reason: `ADK-sourced candidate has unreadable source memories; the per-user scope tag cannot be confirmed — refusing to promote (fail-closed)`,
8152
- };
8153
- }
8154
- const scopeTag = [...adkTags][0];
8155
- return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
8156
- }
8157
- // ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
8158
- // Continuity-journal scope tag prefix. Canonical string duplicated in
8159
- // resources/memory-reflect-lib.ts / resources/auto-promote-lib.ts and
8160
- // packages/flair-mcp/src/continuity.ts — this file sits on the CLI side of the
8161
- // npm-packaging boundary (see this file's header) and cannot import them; kept
8162
- // in sync by the shared canonical string, same discipline as
8163
- // MACHINE_REVIEWER_* below.
8164
- export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
8165
- /**
8166
- * Decide a promoted Memory row's visibility for the HUMAN `rem promote` path
8167
- * (flair#1257 slice 3). Mirror of resources/auto-promote-lib.ts
8168
- * decidePromotedVisibility (the server-side auto-promote half) — Sherlock's
8169
- * default-private-unless ruling covers BOTH promotion paths: the sources of a
8170
- * continuity candidate are the most sensitive tier (ephemeral+private journal
8171
- * rows), so leaving visibility unset here would let Memory's durability-keyed
8172
- * default widen it to shared ("persistent" defaults shared) — a silent
8173
- * visibility escalation. "shared" only when the candidate is continuity-scoped
8174
- * AND carries the distiller's affirmative ruling WITH its recorded
8175
- * team-relevance justification; every other case — including every
8176
- * uncertainty — is "private".
8177
- *
8178
- * Returns undefined for NON-continuity candidates: their visibility behavior
8179
- * (durability-keyed default) is byte-for-byte the pre-slice-3 contract and is
8180
- * deliberately not changed here.
8181
- */
8182
- export function derivePromotedVisibility(candidate) {
8183
- const scopeTag = candidate.scopeTag;
8184
- const isContinuity = typeof scopeTag === "string" &&
8185
- scopeTag.length > CONTINUITY_SCOPE_TAG_PREFIX.length &&
8186
- scopeTag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
8187
- if (!isContinuity)
8188
- return undefined;
8189
- if (candidate.visibilityRuling !== "shared")
8190
- return "private";
8191
- const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
8192
- return rationale.length > 0 ? "shared" : "private";
8193
- }
8194
- // ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
8195
- // A promotion records a reviewerId that feeds audit/attribution
8196
- // (schemas/memory.graphql:209). An automated (machine-driven) promotion path
8197
- // must record a reviewerId that can NEVER be mistaken for a human/agent
8198
- // reviewer, so attribution isn't laundered. Reserve the `machine:` namespace
8199
- // for that, and forbid the human `--reviewer` path from claiming it.
8200
- export const MACHINE_REVIEWER_PREFIX = "machine:";
8201
- /** Canonical machine reviewerId for the ADK auto-promote consumer (#1205b). */
8202
- export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
8203
- /** True iff `id` is in the reserved machine-reviewer namespace — i.e. it
8204
- * denotes an automated path, not a human or agent reviewer. */
8205
- export function isMachineReviewerId(id) {
8206
- return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
8207
- }
8208
- /** The human `flair rem promote` path must not record a reviewerId in the
8209
- * reserved machine namespace — that would launder automated attribution onto
8210
- * a human-operated promotion. Returns an error string, or null if allowed. */
8211
- export function validateHumanReviewerId(reviewerId) {
8212
- if (isMachineReviewerId(reviewerId)) {
8213
- return `--reviewer '${reviewerId}' uses the reserved '${MACHINE_REVIEWER_PREFIX}' namespace (reserved for automated promotion); use a human/agent reviewer id`;
8214
- }
8215
- return null;
8216
- }
8082
+ import { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, validateHumanReviewerId } from "./rem/promote-policy.js";
8083
+ export { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, derivePromotedVisibility, validateHumanReviewerId, isMachineReviewerId, ADK_SCOPE_TAG_PREFIX, CONTINUITY_SCOPE_TAG_PREFIX, MACHINE_REVIEWER_PREFIX, MACHINE_REVIEWER_ADK_AUTO_PROMOTE } from "./rem/promote-policy.js";
8217
8084
  // ─── flair rem promote ───────────────────────────────────────────────────────
8218
8085
  // Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
8219
8086
  // or persistent Memory. Both --rationale and --to are required (spec § 5: no
@@ -8249,6 +8116,17 @@ rem
8249
8116
  process.exit(1);
8250
8117
  }
8251
8118
  try {
8119
+ if (opts.to === "memory") {
8120
+ const promoted = await api("POST", "/PromoteMemoryCandidate", {
8121
+ candidateId, rationale: opts.rationale,
8122
+ ...(opts.reviewer ? { reviewerId: opts.reviewer } : {}),
8123
+ });
8124
+ if (promoted?.error)
8125
+ throw new Error(promoted.error);
8126
+ console.log(`✅ Wrote Memory ${promoted.memoryId} (durability=persistent)`);
8127
+ console.log(`✅ Candidate ${candidateId} marked promoted → memory, reviewer=${promoted.reviewerId}`);
8128
+ return;
8129
+ }
8252
8130
  // Fetch the candidate
8253
8131
  const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
8254
8132
  const candidateData = (candidate && !candidate.error) ? candidate : null;
@@ -8295,63 +8173,31 @@ rem
8295
8173
  }
8296
8174
  // Soul entries are agentId-scoped and cannot carry a per-user scope tag,
8297
8175
  // so an ADK-sourced candidate promoted to Soul is a cross-user leak by
8298
- // construction — fail closed. (Server-side trust-tier enforcement that
8299
- // hard-locks the target is the engine slice #1205b; this is the CLI-side
8300
- // foundation.)
8176
+ // construction — fail closed here and again on Soul.post/put so a
8177
+ // scripted PUT /Soul cannot bypass the CLI.
8301
8178
  if (opts.to === "soul" && tagDecision.adkSourced) {
8302
8179
  console.error(`Error: candidate ${candidateId} is ADK-sourced (scope tag ${tagDecision.tags[0]}); Soul is agentId-scoped and cannot carry a per-user scope tag — refusing to promote to Soul (would leak across users). Promote ADK-sourced candidates to memory.`);
8303
8180
  process.exit(1);
8304
8181
  }
8305
8182
  const promotedTags = tagDecision.tags;
8306
8183
  const decidedAt = new Date().toISOString();
8307
- // Write the resulting Soul or Memory entry
8308
- if (opts.to === "memory") {
8309
- const memId = `${candidate.agentId}-promoted-${Date.now()}`;
8310
- // flair#1257 slice 3: for a CONTINUITY-scoped candidate, visibility is
8311
- // decided default-private-unless (derivePromotedVisibility) — the
8312
- // sources are ephemeral+private journal rows, and an unset visibility
8313
- // would silently widen to shared via the persistent durability
8314
- // default. Non-continuity candidates return undefined here and keep
8315
- // the pre-slice-3 durability-keyed default, unchanged.
8316
- const promotedVisibility = derivePromotedVisibility(candidate);
8317
- const memWrite = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
8318
- id: memId,
8319
- agentId: candidate.agentId,
8320
- content: candidate.claim,
8321
- durability: "persistent",
8322
- ...(promotedVisibility ? { visibility: promotedVisibility } : {}),
8323
- tags: promotedTags,
8324
- derivedFrom: candidate.sourceMemoryIds ?? [],
8325
- promotionStatus: "approved",
8326
- promotedAt: decidedAt,
8327
- promotedBy: reviewerId,
8328
- createdAt: decidedAt,
8329
- });
8330
- if (memWrite?.error) {
8331
- console.error(`Error writing Memory: ${memWrite.error}`);
8332
- process.exit(1);
8333
- }
8334
- console.log(`✅ Wrote Memory ${memId} (durability=persistent)`);
8335
- }
8336
- else {
8337
- // soul
8338
- const soulId = `${candidate.agentId}-${opts.key}`;
8339
- const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
8340
- id: soulId,
8341
- agentId: candidate.agentId,
8342
- key: opts.key,
8343
- value: candidate.claim,
8344
- priority: "standard",
8345
- durability: "persistent",
8346
- createdAt: decidedAt,
8347
- updatedAt: decidedAt,
8348
- });
8349
- if (soulWrite?.error) {
8350
- console.error(`Error writing Soul: ${soulWrite.error}`);
8351
- process.exit(1);
8352
- }
8353
- console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
8184
+ // Memory promotion is handled by the server workflow above.
8185
+ const soulId = `${candidate.agentId}-${opts.key}`;
8186
+ const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
8187
+ id: soulId,
8188
+ agentId: candidate.agentId,
8189
+ key: opts.key,
8190
+ value: candidate.claim,
8191
+ priority: "standard",
8192
+ durability: "persistent",
8193
+ createdAt: decidedAt,
8194
+ updatedAt: decidedAt,
8195
+ });
8196
+ if (soulWrite?.error) {
8197
+ console.error(`Error writing Soul: ${soulWrite.error}`);
8198
+ process.exit(1);
8354
8199
  }
8200
+ console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
8355
8201
  // Update the candidate row
8356
8202
  const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
8357
8203
  ...candidate,
@@ -8524,6 +8370,9 @@ remNightly
8524
8370
  .command("run-once")
8525
8371
  .description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
8526
8372
  .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
8373
+ .option("--ops-port <port>", "Harper operations API port")
8374
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
8375
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
8527
8376
  .option("--dry-run", "Log the row but skip the snapshot write")
8528
8377
  .action(async (opts) => {
8529
8378
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
@@ -8532,12 +8381,47 @@ remNightly
8532
8381
  process.exit(1);
8533
8382
  }
8534
8383
  const { runNightlyCycle } = await import("./rem/runner.js");
8384
+ // The runner is agent-authed and cannot reach the ops port itself. When
8385
+ // admin credentials are available, inject an ops-API `search_by_conditions`
8386
+ // helper so the pending-candidate count can be sampled; otherwise the count
8387
+ // is best-effort 0 (the cycle still runs).
8388
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8389
+ const opsSearch = adminPass
8390
+ ? async (table, conditions, getAttributes) => {
8391
+ const opsPort = resolveOpsPort(opts);
8392
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
8393
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
8394
+ method: "POST",
8395
+ headers: { "Content-Type": "application/json", Authorization: auth },
8396
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions, get_attributes: getAttributes }),
8397
+ });
8398
+ if (!res.ok)
8399
+ throw new Error(`ops API failed (${res.status})`);
8400
+ const raw = await res.json();
8401
+ return Array.isArray(raw) ? raw : (raw?.results ?? []);
8402
+ }
8403
+ : undefined;
8535
8404
  try {
8405
+ const healthBase = (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`).replace(/\/+$/, "");
8536
8406
  const result = await runNightlyCycle({
8537
8407
  agentId,
8538
8408
  flairVersion: __pkgVersion,
8539
8409
  apiCall: api,
8410
+ opsSearch,
8540
8411
  dryRun: !!opts.dryRun,
8412
+ healthProbe: async (timeoutMs) => {
8413
+ const t = Date.now();
8414
+ try {
8415
+ const res = await fetch(`${healthBase}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
8416
+ if (!res.ok) {
8417
+ return { ok: false, elapsedMs: Date.now() - t, error: `GET /Health returned HTTP ${res.status}` };
8418
+ }
8419
+ return { ok: true, elapsedMs: Date.now() - t };
8420
+ }
8421
+ catch (err) {
8422
+ return { ok: false, elapsedMs: Date.now() - t, error: err?.message ?? String(err) };
8423
+ }
8424
+ },
8541
8425
  });
8542
8426
  const row = result.logRow;
8543
8427
  console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
@@ -8558,6 +8442,12 @@ remNightly
8558
8442
  if (row.candidates) {
8559
8443
  console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
8560
8444
  }
8445
+ if (row.distill) {
8446
+ const remaining = Math.max(0, row.distill.unreflected - row.distill.gathered);
8447
+ console.log(`Distilled: ${row.distill.gathered} memor${row.distill.gathered === 1 ? "y" : "ies"} (cap ${row.distill.maxMemories}; ${remaining} unreflected remaining)`);
8448
+ if (row.distill.aborted)
8449
+ console.log(`Aborted: yes — in-flight distillation stopped (flair rem pause)`);
8450
+ }
8561
8451
  // row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
8562
8452
  // this cycle — i.e. a non-dry-run cycle for an ADK agentId.
8563
8453
  if (row.autoPromoted) {
@@ -8570,6 +8460,10 @@ remNightly
8570
8460
  console.log(`Dedup: ${row.dedup.clusterCount} cluster${row.dedup.clusterCount === 1 ? "" : "s"} (${row.dedup.totalMemoriesInClusters} memories, largest ${row.dedup.largestClusterSize})`);
8571
8461
  }
8572
8462
  console.log(`Duration: ${row.durationMs}ms`);
8463
+ if (result.status === "refused") {
8464
+ console.log(`\nNote: REM refused to start because /Health could not be served.`);
8465
+ console.log(`Restore /Health before retrying, or \`flair rem pause\` to stop the scheduler.`);
8466
+ }
8573
8467
  if (row.errors.length > 0) {
8574
8468
  console.log(`Errors:`);
8575
8469
  for (const e of row.errors)
@@ -8635,8 +8529,7 @@ remSnapshot
8635
8529
  //
8636
8530
  // The <date> argument is an ISO-timestamp prefix or date-only prefix; the
8637
8531
  // command picks the latest snapshot matching that prefix.
8638
- rem
8639
- .command("restore <date>")
8532
+ addSharedCredentialOptions(rem.command("restore <date>"))
8640
8533
  .description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
8641
8534
  .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
8642
8535
  .option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
@@ -8674,12 +8567,24 @@ rem
8674
8567
  // --apply path: live replay via src/rem/restore.ts
8675
8568
  if (opts.apply) {
8676
8569
  const { applySnapshot } = await import("./rem/restore.js");
8570
+ applyAdminPassFile(opts);
8571
+ const restoreBase = process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`;
8572
+ const adminPass = opts.dryRun ? undefined : resolveLocalAdminPass(opts.adminPass, !isLocalBase(restoreBase));
8573
+ if (!opts.dryRun && !adminPass) {
8574
+ console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for rem restore --apply " +
8575
+ "(Soul rewrite is operator-only; an agent key is refused).");
8576
+ process.exit(1);
8577
+ }
8578
+ const soulApiCall = adminPass
8579
+ ? (method, path, body) => api(method, path, body, { explicitAdminPass: adminPass, adminUser: opts.adminUser, agentId: null })
8580
+ : undefined;
8677
8581
  try {
8678
8582
  const result = await applySnapshot({
8679
8583
  agentId,
8680
8584
  snapshotPath: match.path,
8681
8585
  flairVersion: __pkgVersion,
8682
8586
  apiCall: api,
8587
+ soulApiCall,
8683
8588
  dryRun: !!opts.dryRun,
8684
8589
  });
8685
8590
  const verb = opts.dryRun ? "(dry-run) would" : "";
@@ -8690,7 +8595,7 @@ rem
8690
8595
  console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
8691
8596
  console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
8692
8597
  }
8693
- console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
8598
+ console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls, ${result.deleted.candidates} candidates`);
8694
8599
  console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
8695
8600
  if (result.errors.length > 0) {
8696
8601
  console.log(` Errors:`);
@@ -8737,16 +8642,32 @@ rem
8737
8642
  // Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
8738
8643
  // nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
8739
8644
  // honored — lets ops pause fleet-wide without writing a file.
8645
+ // #1515: the same sentinel aborts an in-flight /ReflectMemories gather on
8646
+ // the Harper host (checked between yield points) so an operator can stop a
8647
+ // runaway run without restarting Harper.
8740
8648
  const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
8741
- rem
8742
- .command("pause")
8743
- .description("Pause nightly REM runs — writes ~/.flair/rem.paused sentinel")
8744
- .action(() => {
8649
+ function writeRemPauseSentinel() {
8745
8650
  const dir = dirname(REM_PAUSE_FLAG);
8746
8651
  if (!existsSync(dir))
8747
8652
  mkdirSync(dir, { recursive: true, mode: 0o700 });
8748
8653
  writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
8654
+ }
8655
+ rem
8656
+ .command("pause")
8657
+ .description("Pause nightly REM runs and abort an in-flight distillation gather")
8658
+ .action(() => {
8659
+ writeRemPauseSentinel();
8749
8660
  console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
8661
+ console.log(` In-flight distillation will abort at the next yield.`);
8662
+ console.log(` Resume with: flair rem resume`);
8663
+ });
8664
+ rem
8665
+ .command("abort")
8666
+ .description("Abort an in-flight REM distillation (same sentinel as pause)")
8667
+ .action(() => {
8668
+ writeRemPauseSentinel();
8669
+ console.log(`✅ REM abort requested (sentinel: ${REM_PAUSE_FLAG})`);
8670
+ console.log(` In-flight distillation will stop at the next yield; the scheduler stays paused.`);
8750
8671
  console.log(` Resume with: flair rem resume`);
8751
8672
  });
8752
8673
  rem
@@ -8890,7 +8811,7 @@ export async function discoverLocalFlairPort(originalUrl) {
8890
8811
  // pass it in, so this verified read signs as the SAME agent the rest of the
8891
8812
  // command does. Undefined = resolve locally via the legacy flag>env pair (all
8892
8813
  // other callers, unchanged).
8893
- async function fetchHealthDetail(opts, signingAgentIdOverride) {
8814
+ async function fetchHealthDetail(opts, signingAgentIdOverride, signingAgentSource) {
8894
8815
  const port = resolveHttpPort(opts);
8895
8816
  // --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
8896
8817
  const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
@@ -8926,6 +8847,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
8926
8847
  agentId: signingAgentIdOverride !== undefined
8927
8848
  ? (signingAgentIdOverride ?? undefined)
8928
8849
  : (opts.agent || process.env.FLAIR_AGENT_ID),
8850
+ agentIdSource: signingAgentSource,
8929
8851
  });
8930
8852
  }
8931
8853
  catch {
@@ -8944,8 +8866,8 @@ const statusCmd = program
8944
8866
  .option("--json", "Output as JSON")
8945
8867
  .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
8946
8868
  .action(async (opts) => {
8947
- const statusAgentId = resolveSigningAgentId(opts, "status");
8948
- const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId);
8869
+ const { agentId: statusAgentId, source: statusSource } = resolveSigningAgentId(opts, "status");
8870
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId, statusSource);
8949
8871
  // When unreachable on a localhost URL, probe candidate ports to detect
8950
8872
  // config-vs-daemon port drift. Surface the actually-listening
8951
8873
  // port with a fix recipe — better UX than just "unreachable."
@@ -10292,6 +10214,7 @@ program
10292
10214
  .command("upgrade")
10293
10215
  .description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
10294
10216
  .option("--check", "Only check for updates / show the plan, don't install or deploy")
10217
+ .option("--tree <dir>", "Upgrade this extracted package tree in place (npm pack / plain-tree lane). Default: the serving instance's packed tree when that is not the npm-global install")
10295
10218
  .option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
10296
10219
  .option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
10297
10220
  .option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
@@ -10313,7 +10236,7 @@ program
10313
10236
  // A colliding name is normally recoverable via optsWithGlobals(); this one is
10314
10237
  // not, because commander's version listener exits the process. The name had
10315
10238
  // to change. `--harper-version` below is the symmetry this follows.
10316
- .option("--flair-version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
10239
+ .option("--flair-version <semver>", "Flair version to deploy with --target, or to pin the plain-tree tarball swap (default: latest published @tpsdev-ai/flair)")
10317
10240
  .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
10318
10241
  .option("--project <name>", "Fabric component name for --target", "flair")
10319
10242
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
@@ -10338,6 +10261,48 @@ program
10338
10261
  const checkOnly = opts.check ?? false;
10339
10262
  const showAll = opts.all ?? false;
10340
10263
  console.log("Checking for updates...\n");
10264
+ // flair#1109 (a): if the serving tree (or --tree) is a packed extract,
10265
+ // take the in-place tarball lane instead of upgrading a leftover
10266
+ // npm-global relic. (b) still probes — and still prints — when we are
10267
+ // not taking that lane (git checkout, unknown path). Detection is
10268
+ // best-effort and never fails the command except an explicit --tree
10269
+ // that does not name a packed install (refuse, don't silently fall through).
10270
+ const upgradeServingPid = resolveInstanceServingPid(defaultDataDir(), resolveHttpPort({}));
10271
+ const upgradeNpmPrefix = await resolveNpmGlobalPrefix();
10272
+ let treeDecision = { kind: "skip" };
10273
+ try {
10274
+ treeDecision = resolvePlainTreeTarget({
10275
+ treeFlag: typeof opts.tree === "string" && opts.tree.trim() !== "" ? opts.tree.trim() : null,
10276
+ serving: upgradeServingPid != null ? resolveServingFlairPackage(upgradeServingPid) : null,
10277
+ cli: findFlairPackageDir(flairPackageDir()),
10278
+ global: resolveNpmGlobalFlairPackage(upgradeNpmPrefix, process.platform),
10279
+ });
10280
+ }
10281
+ catch { /* treat as skip — never fail the probe */ }
10282
+ if (treeDecision.kind === "refuse") {
10283
+ console.error(`❌ ${treeDecision.message}`);
10284
+ process.exit(1);
10285
+ }
10286
+ const treeLane = treeDecision.kind === "use" ? treeDecision.inspection : null;
10287
+ // flair#1109 (b): print the mismatch warning only when this run will
10288
+ // still treat npm-global as the install. Collect always, so the (b)
10289
+ // wiring test keeps seeing the call.
10290
+ try {
10291
+ const execPathWarning = collectUpgradeExecPathWarning({
10292
+ servingPid: upgradeServingPid,
10293
+ cliPackageDir: flairPackageDir(),
10294
+ npmGlobalPrefix: upgradeNpmPrefix,
10295
+ });
10296
+ if (execPathWarning && !treeLane) {
10297
+ console.log(execPathWarning);
10298
+ console.log("");
10299
+ }
10300
+ }
10301
+ catch { /* never fail upgrade over a path probe */ }
10302
+ if (treeLane) {
10303
+ console.log(formatPlainTreeBanner(treeLane));
10304
+ console.log("");
10305
+ }
10341
10306
  const packages = [
10342
10307
  {
10343
10308
  name: "@tpsdev-ai/flair",
@@ -10380,11 +10345,33 @@ program
10380
10345
  if (transitive && !showAll)
10381
10346
  continue;
10382
10347
  try {
10383
- const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
10384
- if (!res.ok)
10385
- continue;
10386
- const data = await res.json();
10387
- const latest = data.version ?? "unknown";
10348
+ let registryLatest = null;
10349
+ try {
10350
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
10351
+ if (res.ok) {
10352
+ const data = await res.json();
10353
+ registryLatest = typeof data.version === "string" && data.version ? data.version : null;
10354
+ }
10355
+ }
10356
+ catch { /* /latest timed out or failed — pin path must still work */ }
10357
+ let latest;
10358
+ if (treeLane && name === FLAIR_PKG_NAME) {
10359
+ // Consult registry latest, then apply --flair-version as the swap
10360
+ // target. A pin still applies when /latest is unavailable; without
10361
+ // that, a requested tarball swap reports up to date and does nothing.
10362
+ const listing = resolvePlainTreeListingTarget({
10363
+ registryLatest,
10364
+ pin: typeof opts.flairVersion === "string" ? opts.flairVersion : null,
10365
+ });
10366
+ if (!listing)
10367
+ continue;
10368
+ latest = listing.version;
10369
+ }
10370
+ else {
10371
+ if (!registryLatest)
10372
+ continue;
10373
+ latest = registryLatest;
10374
+ }
10388
10375
  if (name === FLAIR_PKG_NAME && latest !== "unknown") {
10389
10376
  try {
10390
10377
  primeVersionCheckCache(latest);
@@ -10394,7 +10381,18 @@ program
10394
10381
  const globalProbe = probe();
10395
10382
  let installed;
10396
10383
  let status;
10397
- if (name === FLAIR_MCP_PACKAGE) {
10384
+ if (treeLane && name === FLAIR_PKG_NAME) {
10385
+ // The serving/CLI packed tree is the install. A PATH or
10386
+ // require.resolve probe would report the npm-global relic.
10387
+ installed = treeLane.version;
10388
+ if (installed === null)
10389
+ status = "missing";
10390
+ else if (installed === latest)
10391
+ status = "current";
10392
+ else
10393
+ status = "outdated";
10394
+ }
10395
+ else if (name === FLAIR_MCP_PACKAGE) {
10398
10396
  // flair-mcp is zero-install via npx (#1168) — a null global probe is
10399
10397
  // the NORMAL state, not "missing". Resolve it from its actual wiring
10400
10398
  // (the pin in a client MCP config / the SessionStart hook) so the
@@ -10436,7 +10434,12 @@ program
10436
10434
  }
10437
10435
  // Scope footer: make explicit what `flair upgrade` does and
10438
10436
  // doesn't cover, so "were the others checked?" has a one-line answer.
10439
- console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
10437
+ if (treeLane) {
10438
+ console.log(`\n${formatPlainTreeScopeFooter(treeLane)}`);
10439
+ }
10440
+ else {
10441
+ console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
10442
+ }
10440
10443
  const outdated = findings.filter((f) => f.status === "outdated");
10441
10444
  const missing = findings.filter((f) => f.status === "missing");
10442
10445
  // flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
@@ -10457,6 +10460,20 @@ program
10457
10460
  .filter((f) => f.kind === "openclaw-plugin")
10458
10461
  .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
10459
10462
  const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
10463
+ let treePlan = null;
10464
+ if (treeLane) {
10465
+ const flairFindingForPlan = findings.find((f) => f.name === FLAIR_PKG_NAME);
10466
+ treePlan = planPlainTreeUpgrade({
10467
+ treeDir: treeLane.dir,
10468
+ fromVersion: treeLane.version,
10469
+ toVersion: flairFindingForPlan?.latest ?? treeLane.version ?? "unknown",
10470
+ systemdUnits: findSystemdUnitsForTree(treeLane.dir),
10471
+ });
10472
+ if (flairFindingForPlan?.status === "outdated") {
10473
+ console.log("");
10474
+ console.log(formatPlainTreePlan(treePlan));
10475
+ }
10476
+ }
10460
10477
  if (outdated.length === 0 && missing.length === 0) {
10461
10478
  console.log("\n✅ Everything is up to date.");
10462
10479
  return;
@@ -10515,6 +10532,19 @@ program
10515
10532
  const result = client.wire(env);
10516
10533
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
10517
10534
  }
10535
+ // flair#1516: the SessionStart hook command carries the SAME
10536
+ // @tpsdev-ai/flair-mcp@<version> pin as the client MCP block, but only
10537
+ // the client block was refreshed above — so an upgraded user kept
10538
+ // launching the PREVIOUS adapter on every session, silently, while
10539
+ // `flair doctor` reported the hook "still runs". Re-pin every ALREADY-
10540
+ // wired hook to the current spec too (never adds one — that stays an
10541
+ // opt-in). Best-effort, same as the client refresh.
10542
+ for (const harness of SUPPORTED_HARNESSES) {
10543
+ const repin = repinSessionStartHook(homedir(), harness);
10544
+ if (repin.action === "update") {
10545
+ console.log(` ${repin.ok ? "✓" : "•"} ${repin.message}`);
10546
+ }
10547
+ }
10518
10548
  }
10519
10549
  // Nothing to install via npm/openclaw. What is left is advisory (packages
10520
10550
  // not detected) and/or a flair-mcp whose wired pin is behind latest. The
@@ -10548,7 +10578,10 @@ program
10548
10578
  return;
10549
10579
  }
10550
10580
  if (checkOnly) {
10551
- console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade`);
10581
+ const treeHint = typeof opts.tree === "string" && opts.tree.trim() !== ""
10582
+ ? ` --tree ${opts.tree.trim()}`
10583
+ : treeLane ? ` --tree ${treeLane.dir}` : "";
10584
+ console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade${treeHint}`);
10552
10585
  if (missing.length > 0) {
10553
10586
  console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
10554
10587
  }
@@ -10662,7 +10695,7 @@ program
10662
10695
  let currentEngineVersion = null;
10663
10696
  let targetEngineVersion = null;
10664
10697
  if (flairIsUpgrading && hasDataDir) {
10665
- currentEngineVersion = readInstalledHarperVersion(flairPackageDir());
10698
+ currentEngineVersion = readInstalledHarperVersion(treeLane?.dir ?? flairPackageDir());
10666
10699
  const targetFlairVersion = flairFinding?.latest;
10667
10700
  if (targetFlairVersion && currentEngineVersion) {
10668
10701
  targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
@@ -10723,6 +10756,12 @@ program
10723
10756
  let flairInstallFailed = false;
10724
10757
  for (const { pkg, latest } of npmUpgrades) {
10725
10758
  try {
10759
+ if (treePlan && pkg === FLAIR_PKG_NAME) {
10760
+ console.log(` Fetching ${pkg}@${latest} (npm pack) and swapping ${treePlan.treeDir}...`);
10761
+ await applyPlainTreeUpgrade(treePlan);
10762
+ console.log(` ✅ ${pkg}@${latest} installed (plain-tree swap; previous tree at ${treePlan.previousDir})`);
10763
+ continue;
10764
+ }
10726
10765
  console.log(` Installing ${pkg}@${latest}...`);
10727
10766
  execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
10728
10767
  console.log(` ✅ ${pkg}@${latest} installed`);
@@ -10785,6 +10824,9 @@ program
10785
10824
  }
10786
10825
  if (!shouldRestart) {
10787
10826
  console.log("\nRun: flair restart to use the new version");
10827
+ if (treePlan) {
10828
+ console.log(`Previous tree kept at ${treePlan.previousDir} until you restart and verify.`);
10829
+ }
10788
10830
  return;
10789
10831
  }
10790
10832
  console.log("\nRestarting Flair...");
@@ -10805,12 +10847,31 @@ program
10805
10847
  const rollbackTo = async (toVersion, reason) => {
10806
10848
  console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
10807
10849
  try {
10808
- execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
10850
+ if (treePlan) {
10851
+ const rollbackDecision = decidePlainTreeRollback(existsSync(treePlan.previousDir));
10852
+ if (rollbackDecision.kind === "restore") {
10853
+ if (!restorePlainTreePrevious(treePlan)) {
10854
+ throw new Error(`no previous tree at ${treePlan.previousDir} to restore`);
10855
+ }
10856
+ console.log(` ✅ restored previous tree from ${treePlan.previousDir}`);
10857
+ }
10858
+ else {
10859
+ console.log(` (${rollbackDecision.reason})`);
10860
+ }
10861
+ }
10862
+ else {
10863
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
10864
+ }
10809
10865
  }
10810
10866
  catch (err) {
10811
10867
  console.error(`❌ rollback install failed: ${err.message}`);
10812
10868
  console.error(` Flair is currently on the FAILED version (${expectedFlairVersion ?? "unknown"}) and is NOT running.`);
10813
- console.error(` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
10869
+ const prevExists = !!(treePlan && existsSync(treePlan.previousDir));
10870
+ console.error(treePlan
10871
+ ? (prevExists
10872
+ ? ` Recover by hand: restore ${treePlan.previousDir} to ${treePlan.treeDir} && flair start`
10873
+ : ` The live tree at ${treePlan.treeDir} was not swapped; there is no .upgrade-prev to restore. Start it with: flair start`)
10874
+ : ` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
10814
10875
  process.exit(1);
10815
10876
  }
10816
10877
  // flair#1053: when the engine (Harper) version changed, the pre-upgrade
@@ -10859,9 +10920,16 @@ program
10859
10920
  }
10860
10921
  // Same post-swap rule as the upgrade restart above: the rolled-back
10861
10922
  // version's own CLI is the thing that knows how to start it.
10862
- const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
10923
+ const rolledBackRoot = treePlan?.treeDir ?? flairPackageDir();
10924
+ const rolledBackCli = resolveInstalledFlairCli(rolledBackRoot, toVersion);
10863
10925
  try {
10864
- await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
10926
+ if (treePlan && treePlan.systemdUnits.length > 0) {
10927
+ console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
10928
+ restartSystemdUnits(treePlan.systemdUnits);
10929
+ }
10930
+ else {
10931
+ await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
10932
+ }
10865
10933
  }
10866
10934
  catch (err) {
10867
10935
  console.error(`❌ rollback restart failed: ${err.message}`);
@@ -10914,9 +10982,10 @@ program
10914
10982
  // from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
10915
10983
  // tree can't be verified) falls back to an in-process restart, announced.
10916
10984
  const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
10985
+ const swappedPackageRoot = treePlan?.treeDir ?? flairPackageDir();
10917
10986
  let newCli = null;
10918
10987
  if (flairWasSwapped) {
10919
- const resolved = resolveInstalledFlairCli(flairPackageDir(), expectedFlairVersion);
10988
+ const resolved = resolveInstalledFlairCli(swappedPackageRoot, expectedFlairVersion);
10920
10989
  if (resolved.ok === false) {
10921
10990
  console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
10922
10991
  }
@@ -10926,7 +10995,15 @@ program
10926
10995
  }
10927
10996
  let restartWasDelegated = false;
10928
10997
  try {
10929
- restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
10998
+ if (treePlan && treePlan.systemdUnits.length > 0) {
10999
+ console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
11000
+ restartSystemdUnits(treePlan.systemdUnits);
11001
+ restartWasDelegated = true;
11002
+ console.log("✅ Flair restarted (systemd unit)");
11003
+ }
11004
+ else {
11005
+ restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
11006
+ }
10930
11007
  }
10931
11008
  catch (err) {
10932
11009
  console.error(`❌ restart failed: ${err.message}`);
@@ -10963,6 +11040,9 @@ program
10963
11040
  const detached = isDetached(management);
10964
11041
  if (!shouldVerify) {
10965
11042
  console.log(" (--no-verify: skipping post-restart verification)");
11043
+ if (treePlan) {
11044
+ console.log(` Previous tree kept at ${treePlan.previousDir} (rollback source; not discarded without verify).`);
11045
+ }
10966
11046
  if (detached) {
10967
11047
  for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
10968
11048
  console.error(line);
@@ -10996,6 +11076,8 @@ program
10996
11076
  toVersion: expectedFlairVersion,
10997
11077
  });
10998
11078
  printVerifiedSummary(renderVerifiedSummary(verify.version, run));
11079
+ if (treePlan)
11080
+ discardPlainTreePrevious(treePlan.previousDir);
10999
11081
  return;
11000
11082
  }
11001
11083
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -11026,6 +11108,8 @@ program
11026
11108
  console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
11027
11109
  console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
11028
11110
  console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
11111
+ if (treePlan)
11112
+ discardPlainTreePrevious(treePlan.previousDir);
11029
11113
  return;
11030
11114
  }
11031
11115
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -12298,8 +12382,21 @@ program
12298
12382
  // is actually stamping new writes with.
12299
12383
  const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
12300
12384
  const EMBEDDING_VARIANT = "searchprefix";
12385
+ // embedding-space-guard slice 1: getModelId() now stamps the ENGINE-QUALIFIED
12386
+ // id `<engine>:<base>[+searchprefix]`. Duplicated as a literal here (separate
12387
+ // build target — see above). A row is CURRENT-SPACE iff its stamp is the
12388
+ // qualified id OR its one-time bare-name equivalent (today's corpus, stamped
12389
+ // before the qualifier). Treat BOTH as current so `--stale-only` never
12390
+ // re-embeds an already-correct bare-stamped row — that would loop forever
12391
+ // (Memory.put re-stamps it QUALIFIED, still "!= bare" under a single-value
12392
+ // check). Keep in lockstep with resources/embeddings-provider.ts's
12393
+ // getModelId()/EMBEDDING_ENGINE and resources/embedding-space-guard.ts's
12394
+ // normalizeStamp().
12395
+ const EMBEDDING_ENGINE = "gguf";
12301
12396
  const baseModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
12302
- const currentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
12397
+ const bareCurrentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
12398
+ const currentModel = `${EMBEDDING_ENGINE}:${bareCurrentModel}`;
12399
+ const isCurrentSpace = (stamp) => stamp === currentModel || stamp === bareCurrentModel;
12303
12400
  if (agentId) {
12304
12401
  console.log(`Re-embedding memories for agent: ${agentId}`);
12305
12402
  }
@@ -12358,7 +12455,7 @@ program
12358
12455
  for (const m of allMemories) {
12359
12456
  if (!m.content)
12360
12457
  continue;
12361
- if (staleOnly && m.embeddingModel === currentModel)
12458
+ if (staleOnly && isCurrentSpace(m.embeddingModel))
12362
12459
  continue;
12363
12460
  const agent = m.agentId || "unknown";
12364
12461
  if (!byAgent.has(agent))
@@ -12463,7 +12560,7 @@ program
12463
12560
  if (!m.content)
12464
12561
  return false;
12465
12562
  if (staleOnly)
12466
- return !m.embeddingModel || m.embeddingModel !== currentModel;
12563
+ return !m.embeddingModel || !isCurrentSpace(m.embeddingModel);
12467
12564
  return true;
12468
12565
  });
12469
12566
  const total = candidates.length;
@@ -13469,170 +13566,13 @@ program
13469
13566
  }
13470
13567
  }
13471
13568
  for (const client of detectedClients) {
13472
- // ── pi (flair#1342): NATIVE EXTENSION, not an MCP client ───────────
13473
- // There is no mcpServers block to read — pi loads @tpsdev-ai/pi-flair
13474
- // through its own settings.json (`packages`). Every check below is a
13475
- // filesystem fact except agent registration, which is only checkable
13476
- // when this shell exposes the env pi would launch with — and the
13477
- // output says which of the two it verified.
13478
- if (client.kind === "native-extension") {
13479
- let pi = checkPiFlairWiring(homedir(), process.cwd());
13480
- // --fix for pi needs no agent id (pi settings carry no env block);
13481
- // a resolvable id only improves the export hint in the message.
13482
- const wirePiFix = async (prompt) => {
13483
- if (dryRun) {
13484
- console.log(` ${render.wrap(render.c.dim, "Would update")} ${pi.settingsPath}`);
13485
- return;
13486
- }
13487
- const proceed = await confirmFix(prompt);
13488
- if (!proceed) {
13489
- console.log(` Skipped.`);
13490
- return;
13491
- }
13492
- const hintAgentId = resolveFixAgentId({
13493
- optsAgent: opts.agent,
13494
- envAgentId: process.env.FLAIR_AGENT_ID,
13495
- anyKnownAgentId,
13496
- keyAgentIds,
13497
- keysDir: defaultKeysDir(),
13498
- }) ?? "<your-agent-id>";
13499
- const wireResult = wirePi({ FLAIR_AGENT_ID: hintAgentId, FLAIR_URL: baseUrl });
13500
- console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
13501
- if (wireResult.ok)
13502
- fixed++;
13503
- };
13504
- // (a) The flair#1346 trap FIRST, and by NAME: an npm: spec under
13505
- // "extensions" is silently ignored by pi — the user believes they
13506
- // are wired while pi registers zero tools. This is the documented
13507
- // field failure mode and must never fold into a generic "not
13508
- // wired": the fix is a MOVE to "packages", not an add.
13509
- const userTraps = pi.misconfigured.filter((m) => m.path === pi.settingsPath);
13510
- const projectTraps = pi.misconfigured.filter((m) => m.path !== pi.settingsPath);
13511
- for (const bad of pi.misconfigured) {
13512
- console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} is listed under "extensions" as an npm: spec (${bad.entry}) in ${render.wrap(render.c.dim, bad.path)}`);
13513
- console.log(` pi silently ignores npm: specs under "extensions", so the Flair tools never register (flair#1346). Package sources belong under "packages".`);
13514
- issues++;
13515
- }
13516
- if (userTraps.length > 0) {
13517
- if (autoFix) {
13518
- await wirePiFix(` Move the npm: spec to "packages" in ${pi.settingsPath} now? [y/N] `);
13519
- // Re-derive the wiring from disk so the sections below reason
13520
- // about the POST-fix state — otherwise a move that just
13521
- // succeeded would still read as "not wired" and prompt again.
13522
- pi = checkPiFlairWiring(homedir(), process.cwd());
13523
- }
13524
- else {
13525
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(moves it to "packages")`)}`);
13526
- }
13527
- }
13528
- if (projectTraps.length > 0) {
13529
- // wirePi edits the USER-scope settings only — a project-scope
13530
- // trap gets the exact manual fix, never a --fix that claims a
13531
- // file it does not touch.
13532
- console.log(` ${render.wrap(render.c.dim, "Fix:")} move the entry from "extensions" to "packages" in ${projectTraps[0].path}`);
13533
- }
13534
- if (!pi.wired) {
13535
- console.log(` ${render.icons.error} pi: ${PI_FLAIR_PACKAGE} not wired in ${render.wrap(render.c.dim, pi.settingsPath)}`);
13536
- if (autoFix) {
13537
- await wirePiFix(` Wire pi now (adds ${piFlairSpec()} to "packages" in ${pi.settingsPath})? [y/N] `);
13538
- }
13539
- else {
13540
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages")`)} — or: pi install npm:${PI_FLAIR_PACKAGE}`);
13541
- }
13542
- issues++;
13543
- continue;
13544
- }
13545
- if (pi.wiredVia === "packages") {
13546
- console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via "packages" (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
13547
- if (!pi.pinnedVersion) {
13548
- console.log(` ${render.icons.info} unpinned — pi re-resolves latest on (re)install; pin with ${piFlairSpec()}`);
13549
- }
13550
- }
13551
- else {
13552
- // extension-path: the documented pre-0.49 workaround (a local
13553
- // path to the installed dist/index.js). Works, but the canonical
13554
- // form is a "packages" entry — and a DANGLING path is a broken
13555
- // wiring pi skips silently, so check the one thing checkable.
13556
- if (pi.extensionPathExists) {
13557
- console.log(` ${render.icons.ok} pi: ${PI_FLAIR_PACKAGE} wired via a file-path "extensions" entry (${pi.spec}) in ${render.wrap(render.c.dim, pi.wiredIn)}`);
13558
- console.log(` ${render.wrap(render.c.dim, `pre-0.49 workaround — the canonical form is a "packages" entry: ${piFlairSpec()}`)}`);
13559
- }
13560
- else {
13561
- console.log(` ${render.icons.error} pi: the "extensions" entry ${pi.spec} in ${render.wrap(render.c.dim, pi.wiredIn)} points at a file that does not exist — pi silently skips missing extension paths`);
13562
- if (autoFix) {
13563
- await wirePiFix(` Wire pi via "packages" instead (adds ${piFlairSpec()})? [y/N] `);
13564
- }
13565
- else {
13566
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(adds ${piFlairSpec()} to "packages"; remove the dangling entry yourself)`)}`);
13567
- }
13568
- issues++;
13569
- continue;
13570
- }
13571
- }
13572
- // Env sanity (flair#1342 scope 3). pi settings carry no env block:
13573
- // pi-flair reads FLAIR_* from the environment of whatever shell/IDE
13574
- // launches pi. Doctor can only see ITS OWN environment — these
13575
- // lines verify this shell, and say so, rather than pretending to
13576
- // verify every pi launch. None of them counts as an issue: a clean
13577
- // pi launched elsewhere can be fine while this shell is bare, and
13578
- // vice versa.
13579
- console.log(` ${render.wrap(render.c.dim, "pi-flair reads FLAIR_AGENT_ID / FLAIR_URL / FLAIR_KEY_PATH from the shell that launches pi — doctor sees only its own environment (this shell):")}`);
13580
- const piEnvAgent = process.env.FLAIR_AGENT_ID;
13581
- const piEnvUrl = process.env.FLAIR_URL;
13582
- const piEnvKey = process.env.FLAIR_KEY_PATH;
13583
- if (piEnvAgent) {
13584
- console.log(` ${render.icons.ok} FLAIR_AGENT_ID set ('${piEnvAgent}')`);
13585
- }
13586
- else {
13587
- console.log(` ${render.icons.warn} FLAIR_AGENT_ID not set in this shell — pi-flair falls back to the cwd directory name as its agent id (identity varies by project); export FLAIR_AGENT_ID=<id> where pi is launched`);
13588
- }
13589
- if (piEnvUrl) {
13590
- console.log(` ${render.icons.ok} FLAIR_URL set (${piEnvUrl})`);
13591
- }
13592
- else {
13593
- console.log(` ${render.icons.info} FLAIR_URL not set — pi-flair defaults to ${render.wrap(render.c.dim, PI_FLAIR_DEFAULT_URL)}`);
13594
- }
13595
- if (piEnvKey) {
13596
- if (existsSync(piEnvKey)) {
13597
- console.log(` ${render.icons.ok} FLAIR_KEY_PATH set (${piEnvKey})`);
13598
- }
13599
- else {
13600
- console.log(` ${render.icons.warn} FLAIR_KEY_PATH points at a missing file (${piEnvKey})`);
13601
- }
13602
- }
13603
- else {
13604
- console.log(` ${render.icons.info} FLAIR_KEY_PATH not set — auto-resolved from ~/.flair/keys`);
13605
- }
13606
- // Agent registration — checkable only when this shell exposes an
13607
- // agent id at all; otherwise say what was NOT verified instead of
13608
- // skipping silently.
13609
- if (piEnvAgent) {
13610
- const piUrl = piEnvUrl || PI_FLAIR_DEFAULT_URL;
13611
- const piReachable = await probeFlairReachable(piUrl);
13612
- if (!piReachable) {
13613
- console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, piUrl)} not reachable — cannot verify agent registration`);
13614
- }
13615
- else {
13616
- const piReg = await checkAgentRegistered(piUrl, piEnvAgent, defaultKeysDir());
13617
- if (piReg.state === "registered") {
13618
- console.log(` ${render.icons.ok} agent '${piEnvAgent}' registered`);
13619
- }
13620
- else if (piReg.state === "not-registered") {
13621
- console.log(` ${render.icons.error} agent '${piEnvAgent}' is NOT registered on this Flair instance`);
13622
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${piEnvAgent}`);
13623
- issues++;
13624
- }
13625
- else {
13626
- const piFinding = describeAgentGateFinding(piEnvAgent, piReg.state, piReg.detail, { instanceReachable: piReachable });
13627
- console.log(` ${render.icons.warn} ${piFinding?.message ?? `could not verify agent registration (${piReg.detail})`}`);
13628
- }
13629
- }
13630
- }
13631
- else {
13632
- console.log(` ${render.wrap(render.c.dim, "agent registration not verified — no FLAIR_AGENT_ID visible to doctor")}`);
13633
- }
13569
+ // flair#989 pi is a dead namespace: the pi (kind:
13570
+ // "native-extension") check is removed from doctor entirely. pi was
13571
+ // the last non-MCP client here, and a detected-but-unwired pi was
13572
+ // counted as an install failure for a namespace nobody opts into any
13573
+ // more. Doctor now diagnoses only MCP clients the user wired (below).
13574
+ if (client.kind !== "mcp")
13634
13575
  continue;
13635
- }
13636
13576
  const block = readClientMcpBlock(client.id, homedir());
13637
13577
  if (client.id === "claude-code" && block.agentId)
13638
13578
  claudeCodeAgentId = block.agentId;
@@ -13641,7 +13581,12 @@ program
13641
13581
  if (block.agentId)
13642
13582
  anyKnownAgentId = anyKnownAgentId ?? block.agentId;
13643
13583
  if (!block.present) {
13644
- console.log(` ${render.icons.error} ${client.label}: no Flair MCP server configured in ${render.wrap(render.c.dim, block.configPath)}`);
13584
+ // flair#989: this client is DETECTED (binary/config on the box) but
13585
+ // was never wired to Flair — the user did not opt into it. That is
13586
+ // not an install FAILURE, so it renders as info, never a ✗, and is
13587
+ // not counted (the catalog's opt-in mcp-block check owns the count).
13588
+ // `--fix` still offers to wire it, on the user's y/N consent.
13589
+ console.log(` ${render.icons.info} ${client.label}: detected but not wired to Flair — optional (no Flair MCP server in ${render.wrap(render.c.dim, block.configPath)})`);
13645
13590
  if (autoFix) {
13646
13591
  if (dryRun) {
13647
13592
  console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
@@ -13702,7 +13647,7 @@ program
13702
13647
  // `--fix` already works, so don't clutter the suggestion.
13703
13648
  const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
13704
13649
  const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
13705
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
13650
+ console.log(` ${render.wrap(render.c.dim, "To wire it (optional):")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label})`)}`);
13706
13651
  }
13707
13652
  continue;
13708
13653
  }
@@ -13739,10 +13684,25 @@ program
13739
13684
  console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
13740
13685
  }
13741
13686
  }
13687
+ // flair#989: the harness-specific checks below (CLAUDE.md, SessionStart
13688
+ // hook, continuity, Codex hook) run only for a harness the user actually
13689
+ // WIRED — its MCP block is present. A harness merely DETECTED on the box
13690
+ // but never opted into owes none of these; flagging them was the false-
13691
+ // positive this fix removes. Read the block fresh so a `--fix` that just
13692
+ // wired the client during the loop above is reflected here.
13693
+ const claudeCodeDetected = detectedClients.some((c) => c.id === "claude-code");
13694
+ const claudeCodeConfigured = claudeCodeDetected && readClientMcpBlock("claude-code", homedir()).present;
13695
+ const codexConfigured = detectedClients.some((c) => c.id === "codex") && readClientMcpBlock("codex", homedir()).present;
13742
13696
  // Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
13743
13697
  // Codex has a SessionStart hook too (checked below); CLAUDE.md and
13744
13698
  // continuity stay Claude Code only.
13745
- if (detectedClients.some((c) => c.id === "claude-code")) {
13699
+ //
13700
+ // flair#989: CLAUDE.md and the SessionStart hook are wiring-dependent —
13701
+ // they apply, and can only fail, once Claude Code is WIRED — so they are
13702
+ // gated on `claudeCodeConfigured`. Continuity (below) is a separate
13703
+ // opt-in that renders "not enabled" as info and never a failure, so it
13704
+ // stays gated on mere detection (flair#1324/#1257).
13705
+ if (claudeCodeConfigured) {
13746
13706
  const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
13747
13707
  if (claudeMd.present) {
13748
13708
  console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
@@ -13816,6 +13776,30 @@ program
13816
13776
  else {
13817
13777
  console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
13818
13778
  }
13779
+ // flair#1516: a hook can be wired AND still run yet be pinned to a
13780
+ // DIFFERENT @tpsdev-ai/flair-mcp version than the Claude Code MCP
13781
+ // client — an upgrade refreshed the client block but (pre-#1516)
13782
+ // left the hook behind, so every session silently launched the OLD
13783
+ // adapter. "and still runs" never caught this; compare the two pins.
13784
+ const claudeHookSkew = checkSessionStartHookPinSkew(homedir(), "claude-code");
13785
+ if (claudeHookSkew.skewed) {
13786
+ console.log(` ${render.icons.warn} SessionStart hook: pinned to flair-mcp@${claudeHookSkew.hookPin} but the Claude Code MCP client is pinned to @${claudeHookSkew.clientPin} — the hook still launches the OLD adapter on every session`);
13787
+ if (autoFix) {
13788
+ if (dryRun) {
13789
+ console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
13790
+ }
13791
+ else {
13792
+ const repin = repinSessionStartHook(homedir(), "claude-code");
13793
+ console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
13794
+ if (repin.ok && repin.action === "update")
13795
+ fixed++;
13796
+ }
13797
+ }
13798
+ else {
13799
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install ${render.wrap(render.c.dim, "(re-pins the hook to the current version)")}`);
13800
+ }
13801
+ issues++;
13802
+ }
13819
13803
  // Independent of whether it runs today: would it stay quiet if it
13820
13804
  // stopped? Only offered as a repair when the command is the exact
13821
13805
  // string Flair itself wrote — a hand-edited or pinned hook is the
@@ -13869,6 +13853,10 @@ program
13869
13853
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
13870
13854
  }
13871
13855
  }
13856
+ } // end CLAUDE.md + SessionStart hook (claudeCodeConfigured)
13857
+ // Continuity capture is a standalone Claude Code opt-in — shown whenever
13858
+ // Claude Code is DETECTED, independent of MCP wiring (flair#1324/#1257).
13859
+ if (claudeCodeDetected) {
13872
13860
  // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
13873
13861
  // the SessionStart check above: installed / absent / stale-form).
13874
13862
  // Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
@@ -13926,7 +13914,7 @@ program
13926
13914
  // command Claude Code uses, written to ~/.codex/hooks.json. Continuity
13927
13915
  // and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
13928
13916
  // is the hook file.
13929
- if (detectedClients.some((c) => c.id === "codex")) {
13917
+ if (codexConfigured) {
13930
13918
  const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
13931
13919
  if (hook.present) {
13932
13920
  if (hook.execution === "broken") {
@@ -13952,6 +13940,28 @@ program
13952
13940
  else {
13953
13941
  console.log(` ${render.icons.ok} SessionStart hook (codex): flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
13954
13942
  }
13943
+ // flair#1516: same version-skew check as Claude Code — a Codex hook
13944
+ // left behind by an upgrade keeps launching the OLD adapter while
13945
+ // the Codex MCP block advertises the new pin.
13946
+ const codexHookSkew = checkSessionStartHookPinSkew(homedir(), "codex");
13947
+ if (codexHookSkew.skewed) {
13948
+ console.log(` ${render.icons.warn} SessionStart hook (codex): pinned to flair-mcp@${codexHookSkew.hookPin} but the Codex MCP client is pinned to @${codexHookSkew.clientPin} — the hook still launches the OLD adapter on every session`);
13949
+ if (autoFix) {
13950
+ if (dryRun) {
13951
+ console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
13952
+ }
13953
+ else {
13954
+ const repin = repinSessionStartHook(homedir(), "codex");
13955
+ console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
13956
+ if (repin.ok && repin.action === "update")
13957
+ fixed++;
13958
+ }
13959
+ }
13960
+ else {
13961
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(re-pins the hook to the current version)")}`);
13962
+ }
13963
+ issues++;
13964
+ }
13955
13965
  if (!hook.silenced && hook.ours) {
13956
13966
  console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
13957
13967
  if (hook.upgradable) {
@@ -14484,11 +14494,13 @@ export const QUALITY_RECALL_K = 5;
14484
14494
  * pulls embedding vectors inline — the defect in flair#1360 was an
14485
14495
  * unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
14486
14496
  * vector (~66 MB × 2 per `--emit` run on a 3k-row store) just to sample
14487
- * 10 memories. `type` is intentionally omitted: it is not a declared
14488
- * Memory column (see schemas/memory.graphql); snapshot exclusion keys
14489
- * off `subject` (`quality-snapshot/…`).
14497
+ * 10 memories. `archived` is projected so the planner can drop basemented
14498
+ * rows before sampling (flair#857 SemanticSearch excludes them, so an
14499
+ * archived row in the sample is a guaranteed miss). `type` is intentionally
14500
+ * omitted: it is not a declared Memory column (see schemas/memory.graphql);
14501
+ * snapshot exclusion keys off `subject` (`quality-snapshot/…`).
14490
14502
  */
14491
- export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
14503
+ export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt", "archived"];
14492
14504
  /**
14493
14505
  * Extra most-recent rows fetched beyond `sampleSize` so
14494
14506
  * `planRecallSpotCheck` can drop the sweep's own quality-snapshot
@@ -14635,20 +14647,26 @@ function isQualitySnapshotRow(m) {
14635
14647
  * fail-closed health rules are testable without any I/O (flair#967).
14636
14648
  *
14637
14649
  * Order of operations, and why:
14638
- * 1. drop the tool's own quality-snapshot rows (never grade your own
14650
+ * 1. drop archived rows (SemanticSearch excludes them flair#857 so a
14651
+ * basemented row in the sample is a guaranteed miss, not a recall signal);
14652
+ * 2. drop the tool's own quality-snapshot rows (never grade your own
14639
14653
  * bookkeeping);
14640
- * 2. take the `sampleSize` most-recently-written remaining rows (unchanged —
14654
+ * 3. take the `sampleSize` most-recently-written remaining rows (unchanged —
14641
14655
  * recency is still the sampling frame; see the issue's direction 3 for the
14642
14656
  * stratified-sampling follow-up this deliberately does NOT take on);
14643
- * 3. derive each cue via deriveRecallCue;
14644
- * 4. judge the window: any duplicate cue, or any empty cue, makes it
14657
+ * 4. derive each cue via deriveRecallCue;
14658
+ * 5. judge the window: any duplicate cue, or any empty cue, makes it
14645
14659
  * UNSCORABLE — reported as unhealthy, never silently scored.
14646
14660
  */
14647
14661
  export function planRecallSpotCheck(memories, opts = {}) {
14648
14662
  const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
14649
14663
  const rows = Array.isArray(memories) ? memories : [];
14650
- const scorable = rows.filter((m) => !isQualitySnapshotRow(m ?? {}));
14651
- const excludedSnapshotRows = rows.length - scorable.length;
14664
+ // `archived !== true` matches SemanticSearch / AdminMemory: unset and
14665
+ // false stay in the live pool; only an explicit basement is dropped.
14666
+ const live = rows.filter((m) => m?.archived !== true);
14667
+ const scorable = live.filter((m) => !isQualitySnapshotRow(m ?? {}));
14668
+ const excludedArchivedRows = rows.length - live.length;
14669
+ const excludedSnapshotRows = live.length - scorable.length;
14652
14670
  const sorted = scorable.slice().sort((a, b) => {
14653
14671
  const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
14654
14672
  const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
@@ -14666,7 +14684,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
14666
14684
  }
14667
14685
  const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
14668
14686
  if (duplicateCues.length === 0 && emptyCueCount === 0) {
14669
- return { sampled, health: { healthy: true }, excludedSnapshotRows };
14687
+ return { sampled, health: { healthy: true }, excludedSnapshotRows, excludedArchivedRows };
14670
14688
  }
14671
14689
  const parts = [];
14672
14690
  if (duplicateCues.length > 0) {
@@ -14686,6 +14704,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
14686
14704
  emptyCueCount,
14687
14705
  },
14688
14706
  excludedSnapshotRows,
14707
+ excludedArchivedRows,
14689
14708
  };
14690
14709
  }
14691
14710
  /**
@@ -14910,13 +14929,21 @@ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14910
14929
  return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
14911
14930
  }
14912
14931
  // Deterministic sample + cue derivation + fail-closed health judgment, all
14913
- // pure (planRecallSpotCheck above). Snapshot rows are excluded there, so the
14914
- // "enough memories" check has to run on the PLANNED window, not on the raw
14915
- // row count — an instance whose recent writes are mostly the sweep's own
14916
- // bookkeeping should skip with a reason, not score a short window.
14932
+ // pure (planRecallSpotCheck above). Archived rows (flair#857) and snapshot
14933
+ // rows are excluded there, so the "enough memories" check has to run on the
14934
+ // PLANNED window, not on the raw row count — an instance whose recent writes
14935
+ // are mostly basemented or the sweep's own bookkeeping should skip with a
14936
+ // reason, not score a short window.
14917
14937
  const plan = planRecallSpotCheck(all, { sampleSize });
14918
14938
  if (plan.sampled.length < sampleSize) {
14919
- const excluded = plan.excludedSnapshotRows > 0 ? ` (${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping)` : "";
14939
+ const exclusionParts = [];
14940
+ if (plan.excludedArchivedRows > 0) {
14941
+ exclusionParts.push(`${plan.excludedArchivedRows} archived row(s) excluded — SemanticSearch cannot return basemented memories; restore with \`flair memory restore <id>\` if they should be live`);
14942
+ }
14943
+ if (plan.excludedSnapshotRows > 0) {
14944
+ exclusionParts.push(`${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping`);
14945
+ }
14946
+ const excluded = exclusionParts.length > 0 ? ` (${exclusionParts.join("; ")})` : "";
14920
14947
  return {
14921
14948
  ok: false,
14922
14949
  agentId,
@@ -15198,7 +15225,7 @@ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, op
15198
15225
  * `PUT /Memory/{id}` write shape `memory write-task-summary` already uses.
15199
15226
  * Throws on a write failure — the CLI action below is responsible for
15200
15227
  * surfacing that as a clear error, same as every other write path here. */
15201
- async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
15228
+ async function storeQualitySnapshot(agentId, agentIdSource, baseUrl, subject, snapshot) {
15202
15229
  const memId = `${agentId}-quality-snapshot-${Date.now()}`;
15203
15230
  const body = {
15204
15231
  id: memId,
@@ -15210,7 +15237,7 @@ async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
15210
15237
  type: "quality-snapshot",
15211
15238
  createdAt: new Date().toISOString(),
15212
15239
  };
15213
- const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId });
15240
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId, agentIdSource });
15214
15241
  if (out?.error)
15215
15242
  throw new Error(String(out.error));
15216
15243
  return memId;
@@ -15226,8 +15253,8 @@ program
15226
15253
  .option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
15227
15254
  .option("--emit", "Slice 2: snapshot this report, diff it against the previous quality-snapshot memory, and emit OrgEvents (quality.threshold_crossed / quality.regression) for any crossings/regressions found. Requires an agent identity (--agent or FLAIR_AGENT_ID) — the opt-in write boundary; without this flag `flair quality` remains fully read-only")
15228
15255
  .action(async (opts) => {
15229
- const agentId = resolveSigningAgentId(opts, "quality");
15230
- const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId);
15256
+ const { agentId, source } = resolveSigningAgentId(opts, "quality");
15257
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId, source);
15231
15258
  if (opts.emit && !agentId) {
15232
15259
  console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
15233
15260
  process.exit(1);
@@ -15270,7 +15297,7 @@ program
15270
15297
  }
15271
15298
  }
15272
15299
  try {
15273
- emitResult.snapshotId = await storeQualitySnapshot(agentId, baseUrl, subject, current);
15300
+ emitResult.snapshotId = await storeQualitySnapshot(agentId, source, baseUrl, subject, current);
15274
15301
  }
15275
15302
  catch (err) {
15276
15303
  emitResult.errors.push(`snapshot store failed: ${err?.message ?? String(err)}`);
@@ -15651,7 +15678,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
15651
15678
  process.exit(1);
15652
15679
  }
15653
15680
  applyAdminPassFile(opts);
15654
- const agentId = resolveSigningAgentId(opts, "memory add");
15681
+ const { agentId, source } = resolveSigningAgentId(opts, "memory add");
15655
15682
  if (!agentId) {
15656
15683
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15657
15684
  process.exit(2);
@@ -15693,6 +15720,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
15693
15720
  }
15694
15721
  const out = await api("PUT", `/Memory/${memId}`, body, {
15695
15722
  agentId,
15723
+ agentIdSource: source,
15696
15724
  explicitAdminPass: opts.adminPass,
15697
15725
  adminUser: opts.adminUser,
15698
15726
  });
@@ -15750,7 +15778,7 @@ memory.command("write-task-summary")
15750
15778
  lines.push(opts.summary);
15751
15779
  }
15752
15780
  const content = lines.join("\n");
15753
- const agentId = resolveSigningAgentId(opts, "memory write-task-summary") ?? opts.agent;
15781
+ const { agentId, source } = resolveSigningAgentId(opts, "memory write-task-summary");
15754
15782
  const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
15755
15783
  const body = {
15756
15784
  id: memId,
@@ -15767,7 +15795,7 @@ memory.command("write-task-summary")
15767
15795
  if (opts.derivedFrom) {
15768
15796
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
15769
15797
  }
15770
- const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId });
15798
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId, agentIdSource: source });
15771
15799
  if (out?.error) {
15772
15800
  console.error(`Error writing task summary: ${out.error}`);
15773
15801
  process.exit(1);
@@ -15779,6 +15807,7 @@ memory.command("write-task-summary")
15779
15807
  memory.command("search [query]")
15780
15808
  .description("Semantic search over an agent's memories (query via positional arg or --q)")
15781
15809
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
15810
+ .option("--admin-pass <pass>", "Admin password — sign as admin while --agent names whose memories to search (flair#1500: a flag-pinned agent with no key no longer falls back to FLAIR_ADMIN_PASS)")
15782
15811
  .option("--q <query>", "search query (alias for positional arg)")
15783
15812
  .option("--limit <n>", "Max results", "5")
15784
15813
  .option("--tag <tag>")
@@ -15787,7 +15816,7 @@ memory.command("search [query]")
15787
15816
  .option("--url <url>", "Flair base URL (overrides --port)")
15788
15817
  .option("--port <port>", "Harper HTTP port")
15789
15818
  .action(async (queryArg, opts) => {
15790
- const agentId = resolveSigningAgentId(opts, "memory search");
15819
+ const { agentId, source } = resolveSigningAgentId(opts, "memory search");
15791
15820
  if (!agentId) {
15792
15821
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15793
15822
  process.exit(2);
@@ -15803,7 +15832,7 @@ memory.command("search [query]")
15803
15832
  if (opts.includeArchived)
15804
15833
  body.includeArchived = true;
15805
15834
  const baseUrl = resolveBaseUrl(opts);
15806
- const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
15835
+ const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId, agentIdSource: source, explicitAdminPass: opts.adminPass });
15807
15836
  console.log(JSON.stringify(res, null, 2));
15808
15837
  });
15809
15838
  // ─── flair memory basement / restore ────────────────────────────────────────
@@ -15820,13 +15849,13 @@ memory.command("basement <id>")
15820
15849
  .option("--url <url>", "Flair base URL (overrides --port)")
15821
15850
  .option("--port <port>", "Harper HTTP port")
15822
15851
  .action(async (id, opts) => {
15823
- const agentId = resolveSigningAgentId(opts, "memory basement");
15852
+ const { agentId, source } = resolveSigningAgentId(opts, "memory basement");
15824
15853
  if (!agentId) {
15825
15854
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15826
15855
  process.exit(2);
15827
15856
  }
15828
15857
  const baseUrl = resolveBaseUrl(opts);
15829
- const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
15858
+ const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId, agentIdSource: source });
15830
15859
  console.log(JSON.stringify(res, null, 2));
15831
15860
  });
15832
15861
  memory.command("restore <id>")
@@ -15836,13 +15865,13 @@ memory.command("restore <id>")
15836
15865
  .option("--url <url>", "Flair base URL (overrides --port)")
15837
15866
  .option("--port <port>", "Harper HTTP port")
15838
15867
  .action(async (id, opts) => {
15839
- const agentId = resolveSigningAgentId(opts, "memory restore");
15868
+ const { agentId, source } = resolveSigningAgentId(opts, "memory restore");
15840
15869
  if (!agentId) {
15841
15870
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15842
15871
  process.exit(2);
15843
15872
  }
15844
15873
  const baseUrl = resolveBaseUrl(opts);
15845
- const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
15874
+ const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId, agentIdSource: source });
15846
15875
  console.log(JSON.stringify(res, null, 2));
15847
15876
  });
15848
15877
  memory.command("list")
@@ -15853,13 +15882,13 @@ memory.command("list")
15853
15882
  .option("--limit <n>", "Max rows when using --hash-fallback", "50")
15854
15883
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
15855
15884
  .action(async (opts) => {
15856
- const agentId = resolveSigningAgentId(opts, "memory list");
15885
+ const { agentId, source } = resolveSigningAgentId(opts, "memory list");
15857
15886
  if (!agentId) {
15858
15887
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
15859
15888
  process.exit(2);
15860
15889
  }
15861
15890
  const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
15862
- const raw = await api("GET", `/Memory?${q}`, undefined, { agentId });
15891
+ const raw = await api("GET", `/Memory?${q}`, undefined, { agentId, agentIdSource: source });
15863
15892
  const mode = render.resolveOutputMode(opts);
15864
15893
  // hashFallback flag changes the lens: instead of all memories, show
15865
15894
  // only those that need re-embedding. Keep that surface separate.
@@ -16191,7 +16220,7 @@ program
16191
16220
  .option("--json", "Output raw JSON array")
16192
16221
  .action(async (query, opts) => {
16193
16222
  try {
16194
- const agentId = resolveSigningAgentId(opts, "search");
16223
+ const { agentId } = resolveSigningAgentId(opts, "search");
16195
16224
  if (!agentId) {
16196
16225
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
16197
16226
  process.exit(2);
@@ -16351,7 +16380,7 @@ program
16351
16380
  .option("--key <path>", "Ed25519 private key path")
16352
16381
  .option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
16353
16382
  .action(async (opts) => {
16354
- const agentId = resolveSigningAgentId(opts, "bootstrap");
16383
+ const { agentId, source } = resolveSigningAgentId(opts, "bootstrap");
16355
16384
  if (!agentId) {
16356
16385
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
16357
16386
  process.exit(2);
@@ -16366,7 +16395,7 @@ program
16366
16395
  // finds a usable key. Previously this only ever tried Ed25519 (no
16367
16396
  // admin fallback at all) and sent NO Authorization header when no key
16368
16397
  // was found, relying on Harper's local passthrough.
16369
- const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, explicitKeyPath: opts.key }));
16398
+ const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, agentIdSource: source, explicitKeyPath: opts.key }));
16370
16399
  if (mode === "json") {
16371
16400
  // Agent-first: emit the full server response, augmented with the cap
16372
16401
  // that was requested. Includes context, sections, tokenEstimate, etc.
@@ -16446,7 +16475,7 @@ relationship.command("add")
16446
16475
  .option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
16447
16476
  .option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
16448
16477
  .action(async (opts) => {
16449
- const agentId = resolveSigningAgentId(opts, "relationship add") ?? opts.agent;
16478
+ const { agentId, source } = resolveSigningAgentId(opts, "relationship add");
16450
16479
  const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
16451
16480
  const body = {
16452
16481
  id,
@@ -16463,11 +16492,11 @@ relationship.command("add")
16463
16492
  body.validTo = opts.validTo;
16464
16493
  if (opts.source)
16465
16494
  body.source = opts.source;
16466
- const out = await api("PUT", `/Relationship/${id}`, body, { agentId });
16495
+ const out = await api("PUT", `/Relationship/${id}`, body, { agentId, agentIdSource: source });
16467
16496
  console.log(JSON.stringify(out, null, 2));
16468
16497
  });
16469
16498
  const soul = program.command("soul").description("Manage agent soul entries");
16470
- soul.command("set")
16499
+ addSharedCredentialOptions(soul.command("set"))
16471
16500
  .description("Set (upsert) a soul entry for an agent by key")
16472
16501
  .requiredOption("--agent <id>")
16473
16502
  .requiredOption("--key <key>")
@@ -16475,6 +16504,7 @@ soul.command("set")
16475
16504
  .option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
16476
16505
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
16477
16506
  .action(async (opts) => {
16507
+ applyAdminPassFile(opts);
16478
16508
  // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
16479
16509
  // The Soul table resource has no POST handler, so a collection POST /Soul
16480
16510
  // 405s; the record must be written by its primary key. (#498)
@@ -16484,7 +16514,7 @@ soul.command("set")
16484
16514
  // precedence — but before this, api() re-derived the signer as
16485
16515
  // FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
16486
16516
  // wrote a record owned by X while signing as Y (the soul family's stale rung).
16487
- const agentId = resolveSigningAgentId(opts, "soul set") ?? opts.agent;
16517
+ const { agentId, source } = resolveSigningAgentId(opts, "soul set");
16488
16518
  const id = `${opts.agent}:${opts.key}`;
16489
16519
  const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
16490
16520
  id,
@@ -16493,7 +16523,7 @@ soul.command("set")
16493
16523
  value: opts.value,
16494
16524
  durability: opts.durability,
16495
16525
  createdAt: new Date().toISOString(),
16496
- }, { agentId });
16526
+ }, { agentId, agentIdSource: source, explicitAdminPass: opts.adminPass, adminUser: opts.adminUser });
16497
16527
  const mode = render.resolveOutputMode(opts);
16498
16528
  if (mode === "json") {
16499
16529
  console.log(render.asJSON(out));
@@ -16515,8 +16545,8 @@ soul.command("get")
16515
16545
  // flair#1183: /Soul reads are verified (any registered agent). Resolve the
16516
16546
  // signer through the canonical seam so soul get honors the SAME precedence
16517
16547
  // as every other family; a null result lets api() fall to admin-pass/floor.
16518
- const agentId = resolveSigningAgentId(opts, "soul get");
16519
- const out = await api("GET", `/Soul/${id}`, undefined, { agentId });
16548
+ const { agentId, source } = resolveSigningAgentId(opts, "soul get");
16549
+ const out = await api("GET", `/Soul/${id}`, undefined, { agentId, agentIdSource: source });
16520
16550
  const mode = render.resolveOutputMode(opts);
16521
16551
  if (mode === "json") {
16522
16552
  console.log(render.asJSON(out));
@@ -16548,12 +16578,12 @@ soul.command("list")
16548
16578
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
16549
16579
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
16550
16580
  .action(async (opts) => {
16551
- const agentId = resolveSigningAgentId(opts, "soul list");
16581
+ const { agentId, source } = resolveSigningAgentId(opts, "soul list");
16552
16582
  if (!agentId) {
16553
16583
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
16554
16584
  process.exit(2);
16555
16585
  }
16556
- const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId });
16586
+ const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId, agentIdSource: source });
16557
16587
  const mode = render.resolveOutputMode(opts);
16558
16588
  if (mode === "json") {
16559
16589
  console.log(render.asJSON(out));
@@ -17379,19 +17409,7 @@ program
17379
17409
  console.warn(` warn: agent ${agent.id}: ${err.message}`);
17380
17410
  }
17381
17411
  }
17382
- // Restore memories
17383
- console.log("Restoring memories...");
17384
- let memoryCount = 0;
17385
- for (const memory of memories) {
17386
- try {
17387
- await adminPut(`/Memory/${memory.id}`, memory);
17388
- memoryCount++;
17389
- }
17390
- catch (err) {
17391
- console.warn(` warn: memory ${memory.id}: ${err.message}`);
17392
- }
17393
- }
17394
- // Restore souls
17412
+ // Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
17395
17413
  console.log("Restoring souls...");
17396
17414
  let soulCount = 0;
17397
17415
  for (const soul of souls) {
@@ -17403,6 +17421,17 @@ program
17403
17421
  console.warn(` warn: soul ${soul.id}: ${err.message}`);
17404
17422
  }
17405
17423
  }
17424
+ console.log("Restoring memories...");
17425
+ let memoryCount = 0;
17426
+ for (const memory of memories) {
17427
+ try {
17428
+ await adminPut(`/Memory/${memory.id}`, memory);
17429
+ memoryCount++;
17430
+ }
17431
+ catch (err) {
17432
+ console.warn(` warn: memory ${memory.id}: ${err.message}`);
17433
+ }
17434
+ }
17406
17435
  console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
17407
17436
  console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
17408
17437
  console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
@@ -17574,30 +17603,29 @@ program
17574
17603
  console.log(typeof seedOpsTarget === "string"
17575
17604
  ? ` Agent registered (ops: ${seedOpsTarget})`
17576
17605
  : ` Agent registered`);
17577
- // Restore memories
17606
+ // Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
17578
17607
  const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
17579
- let memCount = 0;
17580
- for (const mem of data.memories ?? []) {
17608
+ let soulCount = 0;
17609
+ for (const soul of data.souls ?? []) {
17581
17610
  try {
17582
- await fetch(`${baseUrl}/Memory/${mem.id}`, {
17611
+ await fetch(`${baseUrl}/Soul/${encodeURIComponent(soul.id)}`, {
17583
17612
  method: "PUT",
17584
17613
  headers: { "Content-Type": "application/json", Authorization: auth },
17585
- body: JSON.stringify(mem),
17614
+ body: JSON.stringify(soul),
17586
17615
  });
17587
- memCount++;
17616
+ soulCount++;
17588
17617
  }
17589
17618
  catch { /* skip failures */ }
17590
17619
  }
17591
- // Restore souls
17592
- let soulCount = 0;
17593
- for (const soul of data.souls ?? []) {
17620
+ let memCount = 0;
17621
+ for (const mem of data.memories ?? []) {
17594
17622
  try {
17595
- await fetch(`${baseUrl}/Soul/${encodeURIComponent(soul.id)}`, {
17623
+ await fetch(`${baseUrl}/Memory/${mem.id}`, {
17596
17624
  method: "PUT",
17597
17625
  headers: { "Content-Type": "application/json", Authorization: auth },
17598
- body: JSON.stringify(soul),
17626
+ body: JSON.stringify(mem),
17599
17627
  });
17600
- soulCount++;
17628
+ memCount++;
17601
17629
  }
17602
17630
  catch { /* skip failures */ }
17603
17631
  }
@@ -17914,7 +17942,7 @@ presence
17914
17942
  .option("--port <port>", "Harper HTTP port")
17915
17943
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
17916
17944
  .action(async (opts) => {
17917
- const agentId = resolveSigningAgentId(opts, "presence set");
17945
+ const { agentId } = resolveSigningAgentId(opts, "presence set");
17918
17946
  if (!agentId) {
17919
17947
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
17920
17948
  process.exit(1);
@@ -18000,7 +18028,7 @@ workspace
18000
18028
  .option("--port <port>", "Harper HTTP port")
18001
18029
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
18002
18030
  .action(async (opts) => {
18003
- const agentId = resolveSigningAgentId(opts, "workspace set");
18031
+ const { agentId } = resolveSigningAgentId(opts, "workspace set");
18004
18032
  if (!agentId) {
18005
18033
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
18006
18034
  process.exit(1);
@@ -18150,7 +18178,7 @@ program
18150
18178
  .option("--port <port>", "Harper HTTP port")
18151
18179
  .option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
18152
18180
  .action(async (opts) => {
18153
- const agentId = resolveSigningAgentId(opts, "orgevent");
18181
+ const { agentId } = resolveSigningAgentId(opts, "orgevent");
18154
18182
  if (!agentId) {
18155
18183
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
18156
18184
  process.exit(1);
@@ -18220,7 +18248,7 @@ program
18220
18248
  .option("--json", "Output raw JSON")
18221
18249
  .action(async (entity, opts) => {
18222
18250
  try {
18223
- const agentId = resolveSigningAgentId(opts, "attention");
18251
+ const { agentId } = resolveSigningAgentId(opts, "attention");
18224
18252
  if (!agentId) {
18225
18253
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
18226
18254
  process.exit(2);