@tpsdev-ai/flair 0.51.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1037 -566
  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/launchd-repair.js +198 -0
  10. package/dist/lib/stabilize-mqtt-network.js +123 -0
  11. package/dist/lib/upgrade-exec-path.js +257 -0
  12. package/dist/lib/upgrade-plain-tree.js +558 -0
  13. package/dist/rem/promote-policy.js +204 -0
  14. package/dist/rem/restore.js +55 -15
  15. package/dist/rem/runner.js +203 -20
  16. package/dist/resources/AdminMemory.js +2 -1
  17. package/dist/resources/AgentSeed.js +26 -10
  18. package/dist/resources/Asset.js +203 -0
  19. package/dist/resources/AutoPromoteCandidates.js +2 -4
  20. package/dist/resources/Credential.js +14 -0
  21. package/dist/resources/Federation.js +80 -0
  22. package/dist/resources/Integration.js +12 -0
  23. package/dist/resources/Memory.js +158 -60
  24. package/dist/resources/MemoryBootstrap.js +63 -20
  25. package/dist/resources/MemoryCandidate.js +12 -0
  26. package/dist/resources/MemoryConsolidate.js +2 -1
  27. package/dist/resources/MemoryDedupStats.js +17 -2
  28. package/dist/resources/MemoryFeed.js +30 -0
  29. package/dist/resources/MemoryGrant.js +14 -0
  30. package/dist/resources/MemoryReflect.js +75 -17
  31. package/dist/resources/Message.js +190 -0
  32. package/dist/resources/OrgEvent.js +12 -0
  33. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  34. package/dist/resources/RecordUsage.js +1 -1
  35. package/dist/resources/Relationship.js +12 -0
  36. package/dist/resources/SemanticSearch.js +45 -13
  37. package/dist/resources/Soul.js +54 -18
  38. package/dist/resources/WorkspaceState.js +12 -0
  39. package/dist/resources/auth-middleware.js +17 -44
  40. package/dist/resources/authority-field-guard.js +37 -0
  41. package/dist/resources/bm25-index-service.js +1 -1
  42. package/dist/resources/bm25-index.js +50 -11
  43. package/dist/resources/embedding-space-guard.js +238 -0
  44. package/dist/resources/embeddings-provider.js +32 -5
  45. package/dist/resources/federation-classify.js +23 -1
  46. package/dist/resources/health.js +11 -2
  47. package/dist/resources/hit-tracking.js +244 -0
  48. package/dist/resources/mcp-tools.js +272 -7
  49. package/dist/resources/memory-reflect-lib.js +111 -0
  50. package/dist/resources/migrations/embedding-stamp.js +22 -4
  51. package/dist/resources/owner-field-guard.js +62 -0
  52. package/dist/resources/promotion-stamp.js +29 -0
  53. package/dist/resources/record-owner-guard.js +71 -5
  54. package/dist/resources/record-types.js +30 -7
  55. package/dist/resources/relay-lib.js +205 -0
  56. package/dist/resources/relay-ops.js +294 -0
  57. package/dist/resources/skill-write.js +120 -0
  58. package/dist/resources/soul-adk-guard.js +68 -0
  59. package/dist/resources/soul-write-policy.js +63 -0
  60. package/dist/resources/table-helpers.js +2 -0
  61. package/dist/resources/usage-recording.js +3 -3
  62. package/dist/src/rem/promote-policy.js +204 -0
  63. package/docs/api-reference.md +374 -0
  64. package/docs/auth.md +52 -0
  65. package/docs/federation.md +4 -0
  66. package/docs/integrations.md +6 -6
  67. package/docs/mcp-clients.md +16 -1
  68. package/docs/releasing.md +11 -8
  69. package/docs/rem.md +20 -2
  70. package/docs/upgrade.md +47 -2
  71. package/package.json +6 -5
  72. package/schemas/memory.graphql +51 -2
  73. package/schemas/message.graphql +74 -0
  74. package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { load as parseYaml } from "js-yaml";
5
5
  import * as render from "./render.js";
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync, openSync, closeSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, constants as fsConstants, } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
- import { join, resolve, sep, dirname } from "node:path";
8
+ import { join, resolve, sep, dirname, basename } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
11
11
  import { createHash, randomUUID, randomBytes } from "node:crypto";
@@ -20,20 +20,22 @@ 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";
33
33
  import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
34
34
  import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
35
35
  import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
36
- import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
36
+ import { classifyPlist, planLaunchdRepair, mapRepairThrow, decideAdoptStop, } from "./lib/launchd-repair.js";
37
+ import { stabilizeMqttNetworkKeyOrder } from "./lib/stabilize-mqtt-network.js";
38
+ import { applyUpgradeHookConsent, catalogIssueDelta, DOCTOR_CHECK_IDS, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
37
39
  import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartTimeToEpochMs, parsePsLstart, parseSidecarJson, } from "./lib/daemon-liveness.js";
38
40
  // Value-only static import so `--interval`'s advertised default cannot drift
39
41
  // from the one the scheduler actually validates against. The module itself is
@@ -41,6 +43,8 @@ import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartT
41
43
  // functions — this pulls in nothing but node builtins.
42
44
  import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
43
45
  import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
46
+ import { collectUpgradeExecPathWarning, findFlairPackageDir, resolveNpmGlobalFlairPackage, resolveServingFlairPackage, } from "./lib/upgrade-exec-path.js";
47
+ import { applyPlainTreeUpgrade, decidePlainTreeRollback, discardPlainTreePrevious, findSystemdUnitsForTree, formatPlainTreeBanner, formatPlainTreePlan, formatPlainTreeScopeFooter, planPlainTreeUpgrade, resolvePlainTreeListingTarget, resolvePlainTreeTarget, restartSystemdUnits, restorePlainTreePrevious, } from "./lib/upgrade-plain-tree.js";
44
48
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
45
49
  // src/ into resources/, which don't survive npm packaging (see also
46
50
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -75,6 +79,16 @@ function signBody(body, secretKey) {
75
79
  // it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
76
80
  // no longer informational-only. Credential.principalId is an unrelated
77
81
  // owner field — do not grep that path when changing this one.
82
+ //
83
+ // S2 COMMENT-PIN (Kern P2-5, flair#1521): Message is principal-owning by `from`
84
+ // (PRINCIPAL_OWNER_FIELD in federation-classify.ts) but its rows carry NO
85
+ // `provenance` stamp, so principalIdFromRow returns undefined for them → a v:2
86
+ // Message push would omit `principalId` → the receiver's v≥2
87
+ // checkPrincipalEntitlement skips EVERY Message as `principal_mismatch` (a
88
+ // 100%-skip sync, not a migration). S1 does not push Message (receive-only —
89
+ // the spoke push list below is a separate hardcoded set), so this is inert
90
+ // today; the S2 pusher MUST stamp `principalId = row.from` for Message here
91
+ // (a per-table owner-aware derivation, not `provenance.verified.agentId`).
78
92
  function principalIdFromRow(row) {
79
93
  if (typeof row?.provenance !== "string" || row.provenance.length === 0)
80
94
  return undefined;
@@ -209,21 +223,45 @@ function launchdPlistPath(label, launchAgentsDir = defaultLaunchAgentsDir()) {
209
223
  */
210
224
  export function buildLaunchdPlist(opts) {
211
225
  const e = escapeXml;
212
- return `<?xml version="1.0" encoding="UTF-8"?>
213
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
214
- <plist version="1.0">
215
- <dict>
216
- <key>Label</key><string>${e(opts.label)}</string>
217
- <key>ProgramArguments</key>
218
- <array>
226
+ const passFile = opts.passFile;
227
+ // ProgramArguments: inline mode execs node directly; pass-file mode execs
228
+ // the launcher, which reads the secret from a 0600 file and then execs node
229
+ // itself (so launchd still tracks Harper as the job).
230
+ const programArguments = passFile
231
+ ? `<array>
232
+ <string>${e(passFile.launcher)}</string>
233
+ <string>${e(passFile.adminPassFile)}</string>
234
+ <string>${e(opts.execPath)}</string>
235
+ <string>${e(opts.harperBinPath)}</string>
236
+ </array>`
237
+ : `<array>
219
238
  <string>${e(opts.execPath)}</string>
220
239
  <string>${e(opts.harperBinPath)}</string>
221
240
  <string>run</string>
222
241
  <string>.</string>
223
- </array>
224
- <key>WorkingDirectory</key><string>${e(opts.workingDirectory)}</string>
225
- <key>EnvironmentVariables</key>
226
- <dict>
242
+ </array>`;
243
+ // EnvironmentVariables: pass-file mode drops HDB_ADMIN_PASSWORD (the secret
244
+ // is read from the file by the launcher) and adds HOME + PATH, which the
245
+ // launcher needs under launchd's minimal env to start Harper non-interactively.
246
+ const environmentVariables = passFile
247
+ ? `<dict>
248
+ <key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
249
+ <key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
250
+ <key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
251
+ <key>DEFAULTS_MODE</key><string>dev</string>
252
+ <key>HDB_ADMIN_USERNAME</key><string>${e(opts.adminUser)}</string>
253
+ <key>THREADS_COUNT</key><string>1</string>
254
+ <key>NODE_HOSTNAME</key><string>localhost</string>
255
+ <key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
256
+ <key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
257
+ <key>LOCAL_STUDIO</key><string>false</string>
258
+ <key>MQTT_NETWORK_PORT</key><string>null</string>
259
+ <key>MQTT_NETWORK_SECUREPORT</key><string>null</string>
260
+ <key>MQTT_WEBSOCKET</key><string>false</string>
261
+ <key>HOME</key><string>${e(passFile.home)}</string>
262
+ <key>PATH</key><string>${e(passFile.path)}</string>
263
+ </dict>`
264
+ : `<dict>
227
265
  <key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
228
266
  <key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
229
267
  <key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
@@ -235,7 +273,20 @@ export function buildLaunchdPlist(opts) {
235
273
  <key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
236
274
  <key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
237
275
  <key>LOCAL_STUDIO</key><string>false</string>
238
- </dict>
276
+ <key>MQTT_NETWORK_PORT</key><string>null</string>
277
+ <key>MQTT_NETWORK_SECUREPORT</key><string>null</string>
278
+ <key>MQTT_WEBSOCKET</key><string>false</string>
279
+ </dict>`;
280
+ return `<?xml version="1.0" encoding="UTF-8"?>
281
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
282
+ <plist version="1.0">
283
+ <dict>
284
+ <key>Label</key><string>${e(opts.label)}</string>
285
+ <key>ProgramArguments</key>
286
+ ${programArguments}
287
+ <key>WorkingDirectory</key><string>${e(opts.workingDirectory)}</string>
288
+ <key>EnvironmentVariables</key>
289
+ ${environmentVariables}
239
290
  <key>RunAtLoad</key><true/>
240
291
  <key>KeepAlive</key><true/>
241
292
  <key>StandardOutPath</key><string>${e(join(opts.dataDir, "log", "launchd-stdout.log"))}</string>
@@ -243,6 +294,46 @@ export function buildLaunchdPlist(opts) {
243
294
  </dict>
244
295
  </plist>`;
245
296
  }
297
+ /**
298
+ * Absolute path to the product-owned launchd launcher (flair#1573 slice a).
299
+ * Shipped in the package under templates/launchd/; the plist's
300
+ * ProgramArguments point at it in pass-file mode. `packageRoot` is injectable
301
+ * so tests can point this at a fixture tree instead of the real package dir.
302
+ */
303
+ export function launchdLauncherPath(packageRoot = flairPackageDir()) {
304
+ return join(packageRoot, "templates", "launchd", "start-flair-with-admin-pass.sh");
305
+ }
306
+ /**
307
+ * Write `content` to `path` atomically: write to a temp file in the SAME
308
+ * directory, then rename over the target. Same-fs rename is atomic on POSIX,
309
+ * so a reader never observes a half-written file. `mode` is applied to the
310
+ * temp file from creation — pass 0o600 when `content` holds a secret, so the
311
+ * secret is never briefly world-readable on disk (flair#1573 slice a).
312
+ */
313
+ export function writeFileAtomic(path, content, mode) {
314
+ const dir = dirname(path);
315
+ mkdirSync(dir, { recursive: true });
316
+ const tmpPath = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
317
+ try {
318
+ writeFileSync(tmpPath, content, { mode });
319
+ renameSync(tmpPath, path);
320
+ }
321
+ catch (err) {
322
+ try {
323
+ unlinkSync(tmpPath);
324
+ }
325
+ catch { /* best effort */ }
326
+ throw err;
327
+ }
328
+ }
329
+ /**
330
+ * Write the admin password to `path` with mode 0600 from creation (flair#1573
331
+ * slice a). The secret is written to a temp file in the same dir (0600) and
332
+ * renamed into place, so it is never briefly world-readable.
333
+ */
334
+ export function writeAdminPassFile(path, content) {
335
+ writeFileAtomic(path, content, 0o600);
336
+ }
246
337
  /**
247
338
  * Which launchd label an existing installation for `dataDir` is actually
248
339
  * registered under right now. Prefers the new instance-scoped label if its
@@ -687,10 +778,14 @@ function resolveSigningIdentityFor(opts, command) {
687
778
  emitSigningIdentityDebug(resolved, command);
688
779
  return resolved;
689
780
  }
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.
781
+ // Same seam, returning the full ResolvedSigningIdentity (agentId + source) so
782
+ // callers can thread the source down to api()/authedRequest. The source is what
783
+ // lets authedRequest distinguish a flag-pinned agent (--agent X, flair#1500)
784
+ // from an env-pinned one (FLAIR_AGENT_ID) — the flag must sign as itself BEFORE
785
+ // env admin, while the env keeps its legacy Basic behavior. Still emits the
786
+ // debug line via resolveSigningIdentityFor.
692
787
  function resolveSigningAgentId(opts, command) {
693
- return resolveSigningIdentityFor(opts, command).agentId;
788
+ return resolveSigningIdentityFor(opts, command);
694
789
  }
695
790
  // ── Shared credential/identity flag surface (flair#1106) ─────────────────────
696
791
  // Sibling commands (memory add, backup, federation sync) used to drift on
@@ -838,6 +933,26 @@ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
838
933
  network: { port: opsNetworkPortValue(opsBindHost, opsPort), cors: true, domainSocket: opsSocket },
839
934
  };
840
935
  }
936
+ /**
937
+ * Harper config that fully disables the MQTT broker (flair#1586).
938
+ *
939
+ * Flair does not use MQTT. Harper's mqtt component (server/mqtt.ts
940
+ * `handleApplication`) binds a TCP listener on `mqtt.network.port` (1883) and
941
+ * a TLS listener on `mqtt.network.securePort` (8883) whenever EITHER is truthy
942
+ * (`if (port || securePort)`), plus a WebSocket upgrade path when
943
+ * `mqtt.webSocket` is true. Nulling only `network.port` leaves
944
+ * `network.securePort` at its 8883 default, so the TLS listener still binds.
945
+ * Fully disabling MQTT requires nulling BOTH ports and turning off the
946
+ * WebSocket path.
947
+ *
948
+ * Note: config-root.schema.json still documents a flat `mqtt.port` /
949
+ * `mqtt.securePort`, but the runtime reads the nested `mqtt.network.*` form
950
+ * (see static/defaultConfig.yaml) — the flat keys are stale.
951
+ */
952
+ const MQTT_DISABLED_CONFIG = {
953
+ network: { port: null, securePort: null },
954
+ webSocket: false,
955
+ };
841
956
  /**
842
957
  * Build the flair-owned environment overrides for a DIRECT (non-launchd)
843
958
  * Harper spawn (flair#863) — shared by `flair start`'s fallback path and
@@ -852,6 +967,15 @@ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
852
967
  * the ops API to all interfaces on every restart/upgrade, and persisted it.
853
968
  * One builder means the next spawn site cannot reintroduce that gap.
854
969
  *
970
+ * MQTT (flair#1586): the direct-spawn path re-asserts the mqtt disable via the
971
+ * individual MQTT_* env vars (the same channel as OPERATIONSAPI_NETWORK_PORT /
972
+ * HTTP_PORT) rather than HARPER_SET_CONFIG, so it cannot reintroduce the
973
+ * SET_CONFIG drift/restore gap. "null" casts to a null port (Harper's
974
+ * castConfigValue), which passes config validation (portConstraints
975
+ * `.empty(null)`) and is falsy, so Harper's mqtt component (server/mqtt.ts
976
+ * `if (port || securePort)`) binds neither the TCP (1883) nor TLS (8883)
977
+ * listener, and MQTT_WEBSOCKET=false turns off the WebSocket upgrade path.
978
+ *
855
979
  * Deliberately omits HDB_ADMIN_PASSWORD when no password is in hand: an empty
856
980
  * string would strip Harper's auth on an existing install.
857
981
  */
@@ -865,6 +989,14 @@ export function buildDirectSpawnEnv(opts) {
865
989
  HDB_ADMIN_USERNAME: opts.adminUser,
866
990
  HTTP_PORT: String(opts.httpPort),
867
991
  OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opts.opsBindHost, opts.opsPort),
992
+ // flair#1586: fully disable the MQTT broker (Flair does not use it). "null"
993
+ // casts to a null port (Harper's castConfigValue), which passes config
994
+ // validation (portConstraints `.empty(null)`) and is falsy, so Harper binds
995
+ // neither the TCP (1883) nor TLS (8883) listener; MQTT_WEBSOCKET=false turns
996
+ // off the WebSocket upgrade path.
997
+ MQTT_NETWORK_PORT: "null",
998
+ MQTT_NETWORK_SECUREPORT: "null",
999
+ MQTT_WEBSOCKET: "false",
868
1000
  LOCAL_STUDIO: "false",
869
1001
  // flair#905 / lrf5: Harper's forceDowngradePrompt reads CONFIRM_DOWNGRADE
870
1002
  // from the environment (via the `prompt` npm package's assignCmdEnvVariables
@@ -1484,6 +1616,7 @@ async function api(method, path, body, options) {
1484
1616
  keysDir: options?.keysDir,
1485
1617
  explicitAdminPass: options?.explicitAdminPass,
1486
1618
  adminUser: options?.adminUser,
1619
+ agentIdSource: options?.agentIdSource,
1487
1620
  });
1488
1621
  }
1489
1622
  /**
@@ -2413,10 +2546,30 @@ const FLAIR_AGENT_PERMISSION = {
2413
2546
  Memory: grant(true, true, true, true),
2414
2547
  MemoryCandidate: grant(true, true, true, true),
2415
2548
  MemoryGrant: grant(true, true, true, true),
2549
+ // Asset (images-in-Flair slice 1). Harper authorizes BEFORE Asset.post
2550
+ // runs, so a de-elevated flair_agent needs the table grant or signed
2551
+ // POST /Asset 403s as AccessViolation (Kern P0). CRUD envelope;
2552
+ // owner-only + write-time size/MIME gates live in resources/Asset.ts.
2553
+ Asset: grant(true, true, true, true),
2416
2554
  Soul: grant(true, true, true, false),
2417
2555
  OrgEvent: grant(true, true, true, true),
2418
2556
  WorkspaceState: grant(true, true, true, true),
2419
2557
  Relationship: grant(true, true, true, true),
2558
+ // Flair Relay S1 (flair#1521). Harper authorizes BEFORE the resource
2559
+ // methods run, so a de-elevated flair_agent needs the table grant OR it
2560
+ // 403s on POST /Message before relaySend is reached (Kern P0-2). read =
2561
+ // the party-scoped collection (Message.search); insert = send via post().
2562
+ // update = FALSE (least privilege, Kern P0 blocker): the ack's write goes
2563
+ // through the IN-PROCESS static accessor (relayConsume → deps.messages.put),
2564
+ // which bypasses role gates entirely — the same raw-put seam Federation.ts
2565
+ // relies on — so update:true is NOT needed for any legitimate path. Leaving
2566
+ // it granted let PATCH /Message/<id> reach Table's update verb, whose
2567
+ // authorize step consults update:true and PASSES for any de-elevated agent,
2568
+ // bypassing Message.put()'s FORBIDDEN guard AND relayConsume's recipient-only
2569
+ // check (Message has no patch() at the platform level → TableResource.patch
2570
+ // runs update()+save() directly). delete = false: direct deletes are
2571
+ // admin/internal only. Message.patch() also guards the verb in-resource.
2572
+ Message: grant(true, true, false, false),
2420
2573
  Integration: grant(true, true, true, true),
2421
2574
  Credential: grant(true, true, true, true),
2422
2575
  Presence: grant(true, true, true, false),
@@ -2430,6 +2583,9 @@ const FLAIR_AGENT_PERMISSION = {
2430
2583
  // (create → count → delete → count again → repeat), defeating the cap
2431
2584
  // entirely. See resources/MemoryUsage.ts's module doc.
2432
2585
  MemoryUsage: grant(true, true, false, false),
2586
+ // MemoryHitStat (flair#1528): internal search-hit ledger. No agent REST
2587
+ // surface (@table without @export). Counts overlay onto Memory reads.
2588
+ MemoryHitStat: grant(false, false, false, false),
2433
2589
  // Agent: read for discovery, update own card; creation/removal is admin.
2434
2590
  Agent: grant(true, false, true, false),
2435
2591
  // Read-only reference data.
@@ -3389,7 +3545,7 @@ program
3389
3545
  rootPath: dataDir,
3390
3546
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
3391
3547
  operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
3392
- mqtt: { network: { port: null }, webSocket: false },
3548
+ mqtt: MQTT_DISABLED_CONFIG,
3393
3549
  localStudio: { enabled: false },
3394
3550
  authentication: { authorizeLocal: false, enableSessions: true },
3395
3551
  });
@@ -3411,6 +3567,12 @@ program
3411
3567
  // all interfaces. See opsNetworkPortValue's doc comment.
3412
3568
  OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opsBindHost, opsPort),
3413
3569
  LOCAL_STUDIO: "false",
3570
+ // flair#1586: same MQTT_* re-assert as buildDirectSpawnEnv / the
3571
+ // launchd plist, so init cannot restore Harper's 1883/8883 defaults
3572
+ // on a later boot that omits HARPER_SET_CONFIG.
3573
+ MQTT_NETWORK_PORT: "null",
3574
+ MQTT_NETWORK_SECUREPORT: "null",
3575
+ MQTT_WEBSOCKET: "false",
3414
3576
  };
3415
3577
  // models (flair#504 Phase 1): the embedding backend registers itself
3416
3578
  // in-process at boot (resources/embeddings-boot.ts, loaded by
@@ -3514,7 +3676,7 @@ program
3514
3676
  rootPath: dataDir,
3515
3677
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
3516
3678
  operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
3517
- mqtt: { network: { port: null }, webSocket: false },
3679
+ mqtt: MQTT_DISABLED_CONFIG,
3518
3680
  localStudio: { enabled: false },
3519
3681
  authentication: { authorizeLocal: false, enableSessions: true },
3520
3682
  });
@@ -3672,7 +3834,7 @@ program
3672
3834
  console.log("");
3673
3835
  for (const [key, value] of soulEntries) {
3674
3836
  try {
3675
- await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
3837
+ await api("PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() }, { baseUrl: httpUrl, explicitAdminPass: adminPass, adminUser });
3676
3838
  console.log(` ✓ soul:${key} set`);
3677
3839
  }
3678
3840
  catch (err) {
@@ -4474,8 +4636,9 @@ agent
4474
4636
  /** Best-effort seed-validity check for a `.key` file: does it parse via any
4475
4637
  * of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
4476
4638
  * — 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. */
4639
+ * only to decide "unidentified" vs. "worth a registration check", not to
4640
+ * actually sign anything. An unparseable file is not junk: the keys dir is
4641
+ * also where FileKeyStore writes AES-256-GCM blobs (flair#1026). */
4479
4642
  function isValidPrivateKeySeedFile(keyPath) {
4480
4643
  try {
4481
4644
  loadEd25519PrivateKeyFromFile(keyPath);
@@ -4490,10 +4653,11 @@ function isValidPrivateKeySeedFile(keyPath) {
4490
4653
  * never writes or moves anything; see applyKeyPrune below for the actual
4491
4654
  * move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
4492
4655
  * 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).
4656
+ * `.key` files with an unparseable seed are "unidentified" without a network
4657
+ * call either — reported, never pruned (flair#1026). Only a `.key` file that
4658
+ * DOES parse triggers a signed `GET /Agent/:id` against `baseUrl`
4659
+ * (checkAgentRegistered above, the exact same check doctor's registration
4660
+ * gate uses).
4497
4661
  *
4498
4662
  * If that check EVER reports "unreachable" — the instance couldn't be
4499
4663
  * confirmed up for that key — the WHOLE run aborts immediately
@@ -4544,7 +4708,7 @@ export async function classifyKeysDir(keysDir, baseUrl) {
4544
4708
  // flair#1023 added "key-unreadable". It cannot occur here — this key's
4545
4709
  // seed already parsed via isValidPrivateKeySeedFile above — but is
4546
4710
  // handled explicitly rather than folded into the else: a key that will
4547
- // not load means exactly what prune already calls "invalid".
4711
+ // not load is "unidentified", not prunable "invalid" (flair#1026).
4548
4712
  const decision = reg.state === "key-unreadable"
4549
4713
  ? classifyKeyFile(c.agentId, false, null, baseUrl)
4550
4714
  : classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
@@ -4604,10 +4768,11 @@ keys
4604
4768
  }
4605
4769
  const stale = result.entries.filter((e) => e.class === "stale");
4606
4770
  const invalid = result.entries.filter((e) => e.class === "invalid");
4771
+ const unidentified = result.entries.filter((e) => e.class === "unidentified");
4607
4772
  const kept = result.entries.filter((e) => e.class === "keep");
4608
4773
  const ignored = result.entries.filter((e) => e.class === "ignored");
4609
4774
  const prunable = [...stale, ...invalid];
4610
- if (stale.length + invalid.length + kept.length === 0) {
4775
+ if (stale.length + invalid.length + unidentified.length + kept.length === 0) {
4611
4776
  console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
4612
4777
  console.log("");
4613
4778
  return;
@@ -4616,12 +4781,15 @@ keys
4616
4781
  const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
4617
4782
  console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
4618
4783
  }
4784
+ for (const e of unidentified) {
4785
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.bold, e.name)} — unidentified: ${e.reason}`);
4786
+ }
4619
4787
  for (const e of kept) {
4620
4788
  console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
4621
4789
  }
4622
4790
  if (!apply) {
4623
4791
  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`)}`);
4792
+ 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
4793
  if (prunable.length > 0) {
4626
4794
  console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
4627
4795
  }
@@ -4633,7 +4801,7 @@ keys
4633
4801
  for (const m of moved) {
4634
4802
  console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
4635
4803
  }
4636
- console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
4804
+ 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
4805
  });
4638
4806
  // ─── flair hook ──────────────────────────────────────────────────────────────
4639
4807
  // Ambient memory via harness SessionStart hooks (flair#745, design record
@@ -7937,6 +8105,9 @@ rem
7937
8105
  .command("candidates")
7938
8106
  .description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
7939
8107
  .option("--port <port>", "Harper HTTP port")
8108
+ .option("--ops-port <port>", "Harper operations API port")
8109
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
8110
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
7940
8111
  .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
7941
8112
  .option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
7942
8113
  .option("--json", "Output as JSON for scripting")
@@ -7952,16 +8123,35 @@ rem
7952
8123
  console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
7953
8124
  process.exit(1);
7954
8125
  }
8126
+ const opsPort = resolveOpsPort(opts);
8127
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8128
+ if (!adminPass) {
8129
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
8130
+ process.exit(1);
8131
+ }
8132
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
7955
8133
  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"],
8134
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
8135
+ method: "POST",
8136
+ headers: { "Content-Type": "application/json", Authorization: auth },
8137
+ body: JSON.stringify({
8138
+ operation: "search_by_conditions",
8139
+ schema: "flair",
8140
+ table: "MemoryCandidate",
8141
+ operator: "and",
8142
+ conditions: [
8143
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
8144
+ { search_attribute: "status", search_type: "equals", search_value: status },
8145
+ ],
8146
+ get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
8147
+ }),
7963
8148
  });
7964
- const candidates = Array.isArray(result) ? result : (result?.results ?? []);
8149
+ if (!res.ok) {
8150
+ const text = await res.text().catch(() => "");
8151
+ console.error(`${render.icons.error} ${res.status} ${text}`);
8152
+ process.exit(1);
8153
+ }
8154
+ const candidates = await res.json();
7965
8155
  const mode = render.resolveOutputMode(opts);
7966
8156
  if (mode === "json") {
7967
8157
  console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
@@ -8011,209 +8201,8 @@ rem
8011
8201
  // Pure validators extracted for testability. The action callbacks below thread
8012
8202
  // these through process.exit on failure; the helpers themselves are
8013
8203
  // 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
- }
8204
+ import { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, validateHumanReviewerId } from "./rem/promote-policy.js";
8205
+ 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
8206
  // ─── flair rem promote ───────────────────────────────────────────────────────
8218
8207
  // Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
8219
8208
  // or persistent Memory. Both --rationale and --to are required (spec § 5: no
@@ -8249,6 +8238,17 @@ rem
8249
8238
  process.exit(1);
8250
8239
  }
8251
8240
  try {
8241
+ if (opts.to === "memory") {
8242
+ const promoted = await api("POST", "/PromoteMemoryCandidate", {
8243
+ candidateId, rationale: opts.rationale,
8244
+ ...(opts.reviewer ? { reviewerId: opts.reviewer } : {}),
8245
+ });
8246
+ if (promoted?.error)
8247
+ throw new Error(promoted.error);
8248
+ console.log(`✅ Wrote Memory ${promoted.memoryId} (durability=persistent)`);
8249
+ console.log(`✅ Candidate ${candidateId} marked promoted → memory, reviewer=${promoted.reviewerId}`);
8250
+ return;
8251
+ }
8252
8252
  // Fetch the candidate
8253
8253
  const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
8254
8254
  const candidateData = (candidate && !candidate.error) ? candidate : null;
@@ -8295,63 +8295,31 @@ rem
8295
8295
  }
8296
8296
  // Soul entries are agentId-scoped and cannot carry a per-user scope tag,
8297
8297
  // 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.)
8298
+ // construction — fail closed here and again on Soul.post/put so a
8299
+ // scripted PUT /Soul cannot bypass the CLI.
8301
8300
  if (opts.to === "soul" && tagDecision.adkSourced) {
8302
8301
  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
8302
  process.exit(1);
8304
8303
  }
8305
8304
  const promotedTags = tagDecision.tags;
8306
8305
  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})`);
8306
+ // Memory promotion is handled by the server workflow above.
8307
+ const soulId = `${candidate.agentId}-${opts.key}`;
8308
+ const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
8309
+ id: soulId,
8310
+ agentId: candidate.agentId,
8311
+ key: opts.key,
8312
+ value: candidate.claim,
8313
+ priority: "standard",
8314
+ durability: "persistent",
8315
+ createdAt: decidedAt,
8316
+ updatedAt: decidedAt,
8317
+ });
8318
+ if (soulWrite?.error) {
8319
+ console.error(`Error writing Soul: ${soulWrite.error}`);
8320
+ process.exit(1);
8354
8321
  }
8322
+ console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
8355
8323
  // Update the candidate row
8356
8324
  const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
8357
8325
  ...candidate,
@@ -8524,6 +8492,9 @@ remNightly
8524
8492
  .command("run-once")
8525
8493
  .description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
8526
8494
  .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
8495
+ .option("--ops-port <port>", "Harper operations API port")
8496
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
8497
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
8527
8498
  .option("--dry-run", "Log the row but skip the snapshot write")
8528
8499
  .action(async (opts) => {
8529
8500
  const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
@@ -8532,12 +8503,47 @@ remNightly
8532
8503
  process.exit(1);
8533
8504
  }
8534
8505
  const { runNightlyCycle } = await import("./rem/runner.js");
8506
+ // The runner is agent-authed and cannot reach the ops port itself. When
8507
+ // admin credentials are available, inject an ops-API `search_by_conditions`
8508
+ // helper so the pending-candidate count can be sampled; otherwise the count
8509
+ // is best-effort 0 (the cycle still runs).
8510
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
8511
+ const opsSearch = adminPass
8512
+ ? async (table, conditions, getAttributes) => {
8513
+ const opsPort = resolveOpsPort(opts);
8514
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
8515
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
8516
+ method: "POST",
8517
+ headers: { "Content-Type": "application/json", Authorization: auth },
8518
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions, get_attributes: getAttributes }),
8519
+ });
8520
+ if (!res.ok)
8521
+ throw new Error(`ops API failed (${res.status})`);
8522
+ const raw = await res.json();
8523
+ return Array.isArray(raw) ? raw : (raw?.results ?? []);
8524
+ }
8525
+ : undefined;
8535
8526
  try {
8527
+ const healthBase = (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`).replace(/\/+$/, "");
8536
8528
  const result = await runNightlyCycle({
8537
8529
  agentId,
8538
8530
  flairVersion: __pkgVersion,
8539
8531
  apiCall: api,
8532
+ opsSearch,
8540
8533
  dryRun: !!opts.dryRun,
8534
+ healthProbe: async (timeoutMs) => {
8535
+ const t = Date.now();
8536
+ try {
8537
+ const res = await fetch(`${healthBase}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
8538
+ if (!res.ok) {
8539
+ return { ok: false, elapsedMs: Date.now() - t, error: `GET /Health returned HTTP ${res.status}` };
8540
+ }
8541
+ return { ok: true, elapsedMs: Date.now() - t };
8542
+ }
8543
+ catch (err) {
8544
+ return { ok: false, elapsedMs: Date.now() - t, error: err?.message ?? String(err) };
8545
+ }
8546
+ },
8541
8547
  });
8542
8548
  const row = result.logRow;
8543
8549
  console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
@@ -8558,6 +8564,12 @@ remNightly
8558
8564
  if (row.candidates) {
8559
8565
  console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
8560
8566
  }
8567
+ if (row.distill) {
8568
+ const remaining = Math.max(0, row.distill.unreflected - row.distill.gathered);
8569
+ console.log(`Distilled: ${row.distill.gathered} memor${row.distill.gathered === 1 ? "y" : "ies"} (cap ${row.distill.maxMemories}; ${remaining} unreflected remaining)`);
8570
+ if (row.distill.aborted)
8571
+ console.log(`Aborted: yes — in-flight distillation stopped (flair rem pause)`);
8572
+ }
8561
8573
  // row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
8562
8574
  // this cycle — i.e. a non-dry-run cycle for an ADK agentId.
8563
8575
  if (row.autoPromoted) {
@@ -8570,6 +8582,10 @@ remNightly
8570
8582
  console.log(`Dedup: ${row.dedup.clusterCount} cluster${row.dedup.clusterCount === 1 ? "" : "s"} (${row.dedup.totalMemoriesInClusters} memories, largest ${row.dedup.largestClusterSize})`);
8571
8583
  }
8572
8584
  console.log(`Duration: ${row.durationMs}ms`);
8585
+ if (result.status === "refused") {
8586
+ console.log(`\nNote: REM refused to start because /Health could not be served.`);
8587
+ console.log(`Restore /Health before retrying, or \`flair rem pause\` to stop the scheduler.`);
8588
+ }
8573
8589
  if (row.errors.length > 0) {
8574
8590
  console.log(`Errors:`);
8575
8591
  for (const e of row.errors)
@@ -8635,8 +8651,7 @@ remSnapshot
8635
8651
  //
8636
8652
  // The <date> argument is an ISO-timestamp prefix or date-only prefix; the
8637
8653
  // command picks the latest snapshot matching that prefix.
8638
- rem
8639
- .command("restore <date>")
8654
+ addSharedCredentialOptions(rem.command("restore <date>"))
8640
8655
  .description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
8641
8656
  .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
8642
8657
  .option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
@@ -8674,12 +8689,24 @@ rem
8674
8689
  // --apply path: live replay via src/rem/restore.ts
8675
8690
  if (opts.apply) {
8676
8691
  const { applySnapshot } = await import("./rem/restore.js");
8692
+ applyAdminPassFile(opts);
8693
+ const restoreBase = process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`;
8694
+ const adminPass = opts.dryRun ? undefined : resolveLocalAdminPass(opts.adminPass, !isLocalBase(restoreBase));
8695
+ if (!opts.dryRun && !adminPass) {
8696
+ console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for rem restore --apply " +
8697
+ "(Soul rewrite is operator-only; an agent key is refused).");
8698
+ process.exit(1);
8699
+ }
8700
+ const soulApiCall = adminPass
8701
+ ? (method, path, body) => api(method, path, body, { explicitAdminPass: adminPass, adminUser: opts.adminUser, agentId: null })
8702
+ : undefined;
8677
8703
  try {
8678
8704
  const result = await applySnapshot({
8679
8705
  agentId,
8680
8706
  snapshotPath: match.path,
8681
8707
  flairVersion: __pkgVersion,
8682
8708
  apiCall: api,
8709
+ soulApiCall,
8683
8710
  dryRun: !!opts.dryRun,
8684
8711
  });
8685
8712
  const verb = opts.dryRun ? "(dry-run) would" : "";
@@ -8690,7 +8717,7 @@ rem
8690
8717
  console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
8691
8718
  console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
8692
8719
  }
8693
- console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
8720
+ console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls, ${result.deleted.candidates} candidates`);
8694
8721
  console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
8695
8722
  if (result.errors.length > 0) {
8696
8723
  console.log(` Errors:`);
@@ -8737,16 +8764,32 @@ rem
8737
8764
  // Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
8738
8765
  // nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
8739
8766
  // honored — lets ops pause fleet-wide without writing a file.
8767
+ // #1515: the same sentinel aborts an in-flight /ReflectMemories gather on
8768
+ // the Harper host (checked between yield points) so an operator can stop a
8769
+ // runaway run without restarting Harper.
8740
8770
  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(() => {
8771
+ function writeRemPauseSentinel() {
8745
8772
  const dir = dirname(REM_PAUSE_FLAG);
8746
8773
  if (!existsSync(dir))
8747
8774
  mkdirSync(dir, { recursive: true, mode: 0o700 });
8748
8775
  writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
8776
+ }
8777
+ rem
8778
+ .command("pause")
8779
+ .description("Pause nightly REM runs and abort an in-flight distillation gather")
8780
+ .action(() => {
8781
+ writeRemPauseSentinel();
8749
8782
  console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
8783
+ console.log(` In-flight distillation will abort at the next yield.`);
8784
+ console.log(` Resume with: flair rem resume`);
8785
+ });
8786
+ rem
8787
+ .command("abort")
8788
+ .description("Abort an in-flight REM distillation (same sentinel as pause)")
8789
+ .action(() => {
8790
+ writeRemPauseSentinel();
8791
+ console.log(`✅ REM abort requested (sentinel: ${REM_PAUSE_FLAG})`);
8792
+ console.log(` In-flight distillation will stop at the next yield; the scheduler stays paused.`);
8750
8793
  console.log(` Resume with: flair rem resume`);
8751
8794
  });
8752
8795
  rem
@@ -8890,7 +8933,7 @@ export async function discoverLocalFlairPort(originalUrl) {
8890
8933
  // pass it in, so this verified read signs as the SAME agent the rest of the
8891
8934
  // command does. Undefined = resolve locally via the legacy flag>env pair (all
8892
8935
  // other callers, unchanged).
8893
- async function fetchHealthDetail(opts, signingAgentIdOverride) {
8936
+ async function fetchHealthDetail(opts, signingAgentIdOverride, signingAgentSource) {
8894
8937
  const port = resolveHttpPort(opts);
8895
8938
  // --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
8896
8939
  const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
@@ -8926,6 +8969,7 @@ async function fetchHealthDetail(opts, signingAgentIdOverride) {
8926
8969
  agentId: signingAgentIdOverride !== undefined
8927
8970
  ? (signingAgentIdOverride ?? undefined)
8928
8971
  : (opts.agent || process.env.FLAIR_AGENT_ID),
8972
+ agentIdSource: signingAgentSource,
8929
8973
  });
8930
8974
  }
8931
8975
  catch {
@@ -8944,8 +8988,8 @@ const statusCmd = program
8944
8988
  .option("--json", "Output as JSON")
8945
8989
  .option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
8946
8990
  .action(async (opts) => {
8947
- const statusAgentId = resolveSigningAgentId(opts, "status");
8948
- const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId);
8991
+ const { agentId: statusAgentId, source: statusSource } = resolveSigningAgentId(opts, "status");
8992
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId, statusSource);
8949
8993
  // When unreachable on a localhost URL, probe candidate ports to detect
8950
8994
  // config-vs-daemon port drift. Surface the actually-listening
8951
8995
  // port with a fix recipe — better UX than just "unreachable."
@@ -10292,6 +10336,7 @@ program
10292
10336
  .command("upgrade")
10293
10337
  .description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
10294
10338
  .option("--check", "Only check for updates / show the plan, don't install or deploy")
10339
+ .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
10340
  .option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
10296
10341
  .option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
10297
10342
  .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 +10358,7 @@ program
10313
10358
  // A colliding name is normally recoverable via optsWithGlobals(); this one is
10314
10359
  // not, because commander's version listener exits the process. The name had
10315
10360
  // 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)")
10361
+ .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
10362
  .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
10318
10363
  .option("--project <name>", "Fabric component name for --target", "flair")
10319
10364
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
@@ -10338,6 +10383,48 @@ program
10338
10383
  const checkOnly = opts.check ?? false;
10339
10384
  const showAll = opts.all ?? false;
10340
10385
  console.log("Checking for updates...\n");
10386
+ // flair#1109 (a): if the serving tree (or --tree) is a packed extract,
10387
+ // take the in-place tarball lane instead of upgrading a leftover
10388
+ // npm-global relic. (b) still probes — and still prints — when we are
10389
+ // not taking that lane (git checkout, unknown path). Detection is
10390
+ // best-effort and never fails the command except an explicit --tree
10391
+ // that does not name a packed install (refuse, don't silently fall through).
10392
+ const upgradeServingPid = resolveInstanceServingPid(defaultDataDir(), resolveHttpPort({}));
10393
+ const upgradeNpmPrefix = await resolveNpmGlobalPrefix();
10394
+ let treeDecision = { kind: "skip" };
10395
+ try {
10396
+ treeDecision = resolvePlainTreeTarget({
10397
+ treeFlag: typeof opts.tree === "string" && opts.tree.trim() !== "" ? opts.tree.trim() : null,
10398
+ serving: upgradeServingPid != null ? resolveServingFlairPackage(upgradeServingPid) : null,
10399
+ cli: findFlairPackageDir(flairPackageDir()),
10400
+ global: resolveNpmGlobalFlairPackage(upgradeNpmPrefix, process.platform),
10401
+ });
10402
+ }
10403
+ catch { /* treat as skip — never fail the probe */ }
10404
+ if (treeDecision.kind === "refuse") {
10405
+ console.error(`❌ ${treeDecision.message}`);
10406
+ process.exit(1);
10407
+ }
10408
+ const treeLane = treeDecision.kind === "use" ? treeDecision.inspection : null;
10409
+ // flair#1109 (b): print the mismatch warning only when this run will
10410
+ // still treat npm-global as the install. Collect always, so the (b)
10411
+ // wiring test keeps seeing the call.
10412
+ try {
10413
+ const execPathWarning = collectUpgradeExecPathWarning({
10414
+ servingPid: upgradeServingPid,
10415
+ cliPackageDir: flairPackageDir(),
10416
+ npmGlobalPrefix: upgradeNpmPrefix,
10417
+ });
10418
+ if (execPathWarning && !treeLane) {
10419
+ console.log(execPathWarning);
10420
+ console.log("");
10421
+ }
10422
+ }
10423
+ catch { /* never fail upgrade over a path probe */ }
10424
+ if (treeLane) {
10425
+ console.log(formatPlainTreeBanner(treeLane));
10426
+ console.log("");
10427
+ }
10341
10428
  const packages = [
10342
10429
  {
10343
10430
  name: "@tpsdev-ai/flair",
@@ -10380,11 +10467,33 @@ program
10380
10467
  if (transitive && !showAll)
10381
10468
  continue;
10382
10469
  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";
10470
+ let registryLatest = null;
10471
+ try {
10472
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
10473
+ if (res.ok) {
10474
+ const data = await res.json();
10475
+ registryLatest = typeof data.version === "string" && data.version ? data.version : null;
10476
+ }
10477
+ }
10478
+ catch { /* /latest timed out or failed — pin path must still work */ }
10479
+ let latest;
10480
+ if (treeLane && name === FLAIR_PKG_NAME) {
10481
+ // Consult registry latest, then apply --flair-version as the swap
10482
+ // target. A pin still applies when /latest is unavailable; without
10483
+ // that, a requested tarball swap reports up to date and does nothing.
10484
+ const listing = resolvePlainTreeListingTarget({
10485
+ registryLatest,
10486
+ pin: typeof opts.flairVersion === "string" ? opts.flairVersion : null,
10487
+ });
10488
+ if (!listing)
10489
+ continue;
10490
+ latest = listing.version;
10491
+ }
10492
+ else {
10493
+ if (!registryLatest)
10494
+ continue;
10495
+ latest = registryLatest;
10496
+ }
10388
10497
  if (name === FLAIR_PKG_NAME && latest !== "unknown") {
10389
10498
  try {
10390
10499
  primeVersionCheckCache(latest);
@@ -10394,7 +10503,18 @@ program
10394
10503
  const globalProbe = probe();
10395
10504
  let installed;
10396
10505
  let status;
10397
- if (name === FLAIR_MCP_PACKAGE) {
10506
+ if (treeLane && name === FLAIR_PKG_NAME) {
10507
+ // The serving/CLI packed tree is the install. A PATH or
10508
+ // require.resolve probe would report the npm-global relic.
10509
+ installed = treeLane.version;
10510
+ if (installed === null)
10511
+ status = "missing";
10512
+ else if (installed === latest)
10513
+ status = "current";
10514
+ else
10515
+ status = "outdated";
10516
+ }
10517
+ else if (name === FLAIR_MCP_PACKAGE) {
10398
10518
  // flair-mcp is zero-install via npx (#1168) — a null global probe is
10399
10519
  // the NORMAL state, not "missing". Resolve it from its actual wiring
10400
10520
  // (the pin in a client MCP config / the SessionStart hook) so the
@@ -10436,7 +10556,12 @@ program
10436
10556
  }
10437
10557
  // Scope footer: make explicit what `flair upgrade` does and
10438
10558
  // 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).");
10559
+ if (treeLane) {
10560
+ console.log(`\n${formatPlainTreeScopeFooter(treeLane)}`);
10561
+ }
10562
+ else {
10563
+ 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).");
10564
+ }
10440
10565
  const outdated = findings.filter((f) => f.status === "outdated");
10441
10566
  const missing = findings.filter((f) => f.status === "missing");
10442
10567
  // flair-mcp is refreshed by re-pinning its wiring (`flair doctor --fix` /
@@ -10457,6 +10582,20 @@ program
10457
10582
  .filter((f) => f.kind === "openclaw-plugin")
10458
10583
  .map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
10459
10584
  const totalUpgrades = npmUpgrades.length + openclawUpgrades.length;
10585
+ let treePlan = null;
10586
+ if (treeLane) {
10587
+ const flairFindingForPlan = findings.find((f) => f.name === FLAIR_PKG_NAME);
10588
+ treePlan = planPlainTreeUpgrade({
10589
+ treeDir: treeLane.dir,
10590
+ fromVersion: treeLane.version,
10591
+ toVersion: flairFindingForPlan?.latest ?? treeLane.version ?? "unknown",
10592
+ systemdUnits: findSystemdUnitsForTree(treeLane.dir),
10593
+ });
10594
+ if (flairFindingForPlan?.status === "outdated") {
10595
+ console.log("");
10596
+ console.log(formatPlainTreePlan(treePlan));
10597
+ }
10598
+ }
10460
10599
  if (outdated.length === 0 && missing.length === 0) {
10461
10600
  console.log("\n✅ Everything is up to date.");
10462
10601
  return;
@@ -10515,6 +10654,19 @@ program
10515
10654
  const result = client.wire(env);
10516
10655
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
10517
10656
  }
10657
+ // flair#1516: the SessionStart hook command carries the SAME
10658
+ // @tpsdev-ai/flair-mcp@<version> pin as the client MCP block, but only
10659
+ // the client block was refreshed above — so an upgraded user kept
10660
+ // launching the PREVIOUS adapter on every session, silently, while
10661
+ // `flair doctor` reported the hook "still runs". Re-pin every ALREADY-
10662
+ // wired hook to the current spec too (never adds one — that stays an
10663
+ // opt-in). Best-effort, same as the client refresh.
10664
+ for (const harness of SUPPORTED_HARNESSES) {
10665
+ const repin = repinSessionStartHook(homedir(), harness);
10666
+ if (repin.action === "update") {
10667
+ console.log(` ${repin.ok ? "✓" : "•"} ${repin.message}`);
10668
+ }
10669
+ }
10518
10670
  }
10519
10671
  // Nothing to install via npm/openclaw. What is left is advisory (packages
10520
10672
  // not detected) and/or a flair-mcp whose wired pin is behind latest. The
@@ -10548,7 +10700,10 @@ program
10548
10700
  return;
10549
10701
  }
10550
10702
  if (checkOnly) {
10551
- console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade`);
10703
+ const treeHint = typeof opts.tree === "string" && opts.tree.trim() !== ""
10704
+ ? ` --tree ${opts.tree.trim()}`
10705
+ : treeLane ? ` --tree ${treeLane.dir}` : "";
10706
+ console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade${treeHint}`);
10552
10707
  if (missing.length > 0) {
10553
10708
  console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
10554
10709
  }
@@ -10662,7 +10817,7 @@ program
10662
10817
  let currentEngineVersion = null;
10663
10818
  let targetEngineVersion = null;
10664
10819
  if (flairIsUpgrading && hasDataDir) {
10665
- currentEngineVersion = readInstalledHarperVersion(flairPackageDir());
10820
+ currentEngineVersion = readInstalledHarperVersion(treeLane?.dir ?? flairPackageDir());
10666
10821
  const targetFlairVersion = flairFinding?.latest;
10667
10822
  if (targetFlairVersion && currentEngineVersion) {
10668
10823
  targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
@@ -10723,6 +10878,12 @@ program
10723
10878
  let flairInstallFailed = false;
10724
10879
  for (const { pkg, latest } of npmUpgrades) {
10725
10880
  try {
10881
+ if (treePlan && pkg === FLAIR_PKG_NAME) {
10882
+ console.log(` Fetching ${pkg}@${latest} (npm pack) and swapping ${treePlan.treeDir}...`);
10883
+ await applyPlainTreeUpgrade(treePlan);
10884
+ console.log(` ✅ ${pkg}@${latest} installed (plain-tree swap; previous tree at ${treePlan.previousDir})`);
10885
+ continue;
10886
+ }
10726
10887
  console.log(` Installing ${pkg}@${latest}...`);
10727
10888
  execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
10728
10889
  console.log(` ✅ ${pkg}@${latest} installed`);
@@ -10785,6 +10946,9 @@ program
10785
10946
  }
10786
10947
  if (!shouldRestart) {
10787
10948
  console.log("\nRun: flair restart to use the new version");
10949
+ if (treePlan) {
10950
+ console.log(`Previous tree kept at ${treePlan.previousDir} until you restart and verify.`);
10951
+ }
10788
10952
  return;
10789
10953
  }
10790
10954
  console.log("\nRestarting Flair...");
@@ -10805,12 +10969,31 @@ program
10805
10969
  const rollbackTo = async (toVersion, reason) => {
10806
10970
  console.log(`\nRolling back @tpsdev-ai/flair to ${toVersion}...`);
10807
10971
  try {
10808
- execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
10972
+ if (treePlan) {
10973
+ const rollbackDecision = decidePlainTreeRollback(existsSync(treePlan.previousDir));
10974
+ if (rollbackDecision.kind === "restore") {
10975
+ if (!restorePlainTreePrevious(treePlan)) {
10976
+ throw new Error(`no previous tree at ${treePlan.previousDir} to restore`);
10977
+ }
10978
+ console.log(` ✅ restored previous tree from ${treePlan.previousDir}`);
10979
+ }
10980
+ else {
10981
+ console.log(` (${rollbackDecision.reason})`);
10982
+ }
10983
+ }
10984
+ else {
10985
+ execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${toVersion}`], { stdio: "pipe" });
10986
+ }
10809
10987
  }
10810
10988
  catch (err) {
10811
10989
  console.error(`❌ rollback install failed: ${err.message}`);
10812
10990
  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`);
10991
+ const prevExists = !!(treePlan && existsSync(treePlan.previousDir));
10992
+ console.error(treePlan
10993
+ ? (prevExists
10994
+ ? ` Recover by hand: restore ${treePlan.previousDir} to ${treePlan.treeDir} && flair start`
10995
+ : ` The live tree at ${treePlan.treeDir} was not swapped; there is no .upgrade-prev to restore. Start it with: flair start`)
10996
+ : ` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
10814
10997
  process.exit(1);
10815
10998
  }
10816
10999
  // flair#1053: when the engine (Harper) version changed, the pre-upgrade
@@ -10859,9 +11042,16 @@ program
10859
11042
  }
10860
11043
  // Same post-swap rule as the upgrade restart above: the rolled-back
10861
11044
  // version's own CLI is the thing that knows how to start it.
10862
- const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
11045
+ const rolledBackRoot = treePlan?.treeDir ?? flairPackageDir();
11046
+ const rolledBackCli = resolveInstalledFlairCli(rolledBackRoot, toVersion);
10863
11047
  try {
10864
- await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
11048
+ if (treePlan && treePlan.systemdUnits.length > 0) {
11049
+ console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
11050
+ restartSystemdUnits(treePlan.systemdUnits);
11051
+ }
11052
+ else {
11053
+ await restartAfterUpgrade(port, upgradeDataDir, rolledBackCli.ok ? rolledBackCli : null);
11054
+ }
10865
11055
  }
10866
11056
  catch (err) {
10867
11057
  console.error(`❌ rollback restart failed: ${err.message}`);
@@ -10914,9 +11104,10 @@ program
10914
11104
  // from disk AFTER the swap. `null` (flair itself wasn't swapped, or the new
10915
11105
  // tree can't be verified) falls back to an in-process restart, announced.
10916
11106
  const flairWasSwapped = flairIsUpgrading && !flairInstallFailed;
11107
+ const swappedPackageRoot = treePlan?.treeDir ?? flairPackageDir();
10917
11108
  let newCli = null;
10918
11109
  if (flairWasSwapped) {
10919
- const resolved = resolveInstalledFlairCli(flairPackageDir(), expectedFlairVersion);
11110
+ const resolved = resolveInstalledFlairCli(swappedPackageRoot, expectedFlairVersion);
10920
11111
  if (resolved.ok === false) {
10921
11112
  console.error(`warning: could not verify the newly installed CLI (${resolved.reason}) — restarting with this process's own code instead.`);
10922
11113
  }
@@ -10926,7 +11117,15 @@ program
10926
11117
  }
10927
11118
  let restartWasDelegated = false;
10928
11119
  try {
10929
- restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
11120
+ if (treePlan && treePlan.systemdUnits.length > 0) {
11121
+ console.log(` (restarting systemd unit: ${treePlan.systemdUnits.map((u) => u.name).join(", ")})`);
11122
+ restartSystemdUnits(treePlan.systemdUnits);
11123
+ restartWasDelegated = true;
11124
+ console.log("✅ Flair restarted (systemd unit)");
11125
+ }
11126
+ else {
11127
+ restartWasDelegated = await restartAfterUpgrade(port, upgradeDataDir, newCli);
11128
+ }
10930
11129
  }
10931
11130
  catch (err) {
10932
11131
  console.error(`❌ restart failed: ${err.message}`);
@@ -10963,6 +11162,9 @@ program
10963
11162
  const detached = isDetached(management);
10964
11163
  if (!shouldVerify) {
10965
11164
  console.log(" (--no-verify: skipping post-restart verification)");
11165
+ if (treePlan) {
11166
+ console.log(` Previous tree kept at ${treePlan.previousDir} (rollback source; not discarded without verify).`);
11167
+ }
10966
11168
  if (detached) {
10967
11169
  for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
10968
11170
  console.error(line);
@@ -10996,6 +11198,8 @@ program
10996
11198
  toVersion: expectedFlairVersion,
10997
11199
  });
10998
11200
  printVerifiedSummary(renderVerifiedSummary(verify.version, run));
11201
+ if (treePlan)
11202
+ discardPlainTreePrevious(treePlan.previousDir);
10999
11203
  return;
11000
11204
  }
11001
11205
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -11026,6 +11230,8 @@ program
11026
11230
  console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
11027
11231
  console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
11028
11232
  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.");
11233
+ if (treePlan)
11234
+ discardPlainTreePrevious(treePlan.previousDir);
11029
11235
  return;
11030
11236
  }
11031
11237
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -11703,6 +11909,253 @@ function observeLaunchdManagement(dataDir, port) {
11703
11909
  list: realLaunchctlLister,
11704
11910
  });
11705
11911
  }
11912
+ /**
11913
+ * Build the launchd plist for a `doctor --fix` repair (flair#1573 slice b).
11914
+ *
11915
+ * Deliberately DIVERGES from the `flair init` plist in one way that matters:
11916
+ * it always uses the pass-file (secret-free) mode, so the regenerated plist
11917
+ * never embeds HDB_ADMIN_PASSWORD inline — the exact regression this issue
11918
+ * exists to prevent. The ports and ROOTPATH come from the instance's own
11919
+ * harper-config.yaml (config authority, flair#914), never ~/.flair/config.yaml
11920
+ * or defaults, so the repair cannot re-bootstrap Harper against a different
11921
+ * directory or port.
11922
+ *
11923
+ * `config` is the parsed harper-config.yaml, already gated readable by the
11924
+ * caller. Throws when the Harper binary cannot be resolved — a plist pointing
11925
+ * at a missing binary is the stale-plist failure this repair must not write.
11926
+ */
11927
+ function buildRepairPlist(dataDir, config) {
11928
+ // The DEFAULT_PORT / httpPort-1 fallbacks below are practically unreachable:
11929
+ // Harper writes harper-config.yaml ports on every boot (flair#914), so a
11930
+ // readable config always carries them. They exist only as a last resort.
11931
+ const httpPort = harperPortValue(config?.http?.port) ?? DEFAULT_PORT;
11932
+ const opsPortRaw = config?.operationsApi?.network?.port;
11933
+ const opsPort = harperPortValue(opsPortRaw) ?? (httpPort - 1);
11934
+ const opsBind = detectOpsApiAllInterfacesBind(opsPortRaw);
11935
+ const opsBindHost = opsBind.boundHost ?? "127.0.0.1";
11936
+ const opsSocket = join(dataDir, "operations-server");
11937
+ // Preserve the config's exact ops-port form (host-qualified or bare) so the
11938
+ // regenerated plist neither re-narrows nor re-widens the bind — the
11939
+ // no-re-bootstrap guarantee is mechanical, not best-effort.
11940
+ const opsNetworkPort = typeof opsPortRaw === "string" && opsPortRaw.trim() !== ""
11941
+ ? opsPortRaw.trim()
11942
+ : opsNetworkPortValue(opsBindHost, opsPort);
11943
+ const setConfig = JSON.stringify({
11944
+ rootPath: dataDir,
11945
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
11946
+ operationsApi: { network: { port: opsNetworkPort, cors: true, domainSocket: opsSocket } },
11947
+ mqtt: MQTT_DISABLED_CONFIG,
11948
+ localStudio: { enabled: false },
11949
+ authentication: { authorizeLocal: false, enableSessions: true },
11950
+ });
11951
+ const harperBinPath = harperBin();
11952
+ if (!harperBinPath)
11953
+ throw new Error(harperBinNotFoundMessage(harperSearchRoots()));
11954
+ const label = launchdLabel(dataDir);
11955
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
11956
+ return buildLaunchdPlist({
11957
+ label,
11958
+ execPath: process.execPath,
11959
+ harperBinPath,
11960
+ workingDirectory: flairPackageDir(),
11961
+ dataDir,
11962
+ modelsDir,
11963
+ setConfig,
11964
+ adminUser: DEFAULT_ADMIN_USER,
11965
+ adminPass: "", // ignored in pass-file mode
11966
+ httpPort,
11967
+ opsNetworkPort,
11968
+ passFile: {
11969
+ launcher: launchdLauncherPath(),
11970
+ adminPassFile: defaultAdminPassPath(),
11971
+ home: homedir(),
11972
+ path: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
11973
+ },
11974
+ });
11975
+ }
11976
+ /**
11977
+ * Compute the launchd repair plan for `dataDir` (flair#1573 slice b) WITHOUT
11978
+ * executing it — the detect + classify + decide half. The doctor command uses
11979
+ * this for dry-run / non-`--fix` reporting; `repairLaunchdManagement` (below)
11980
+ * reuses it and then executes a `regenerate` or `adopt` plan.
11981
+ */
11982
+ function planLaunchdRepairFor(dataDir, port) {
11983
+ // Config authority gate (flair#914): the whole fix is gated on the
11984
+ // instance's own harper-config.yaml being readable.
11985
+ const config = harperConfigPath(dataDir) ? readHarperConfig(dataDir) : null;
11986
+ const configReadable = config !== null;
11987
+ // Observe the current state.
11988
+ const observation = observeLaunchdManagement(dataDir, port);
11989
+ // Classify the plist disposition (ownership guard's first question).
11990
+ const { plistPath, isLegacy } = resolveLaunchdLabel(dataDir);
11991
+ const disposition = classifyPlist(plistPath, dataDir, {
11992
+ exists: existsSync,
11993
+ read: (p) => { try {
11994
+ return readFileSync(p, "utf-8");
11995
+ }
11996
+ catch {
11997
+ return null;
11998
+ } },
11999
+ readRootPath: readPlistRootPath,
12000
+ });
12001
+ // Is a direct (non-launchd) process serving this instance right now?
12002
+ const instancePid = resolveInstanceServingPid(dataDir, port);
12003
+ const directProcessRunning = instancePid !== null && observation.state !== "managed";
12004
+ const plan = planLaunchdRepair({ observation, disposition, plistPath, directProcessRunning, configReadable });
12005
+ return { plan, plistPath, isLegacy, config };
12006
+ }
12007
+ /**
12008
+ * Repair launchd management for `dataDir` (flair#1573 slice b) — the
12009
+ * `doctor --fix` launchd repair for a MISSING, CORRUPT, or DETACHED plist.
12010
+ *
12011
+ * detect -> (adopt: clean-stop the direct process) -> regenerate (pass-file
12012
+ * mode) -> load -> verify. The DECISION (state matrix + ownership guard +
12013
+ * config authority) lives in planLaunchdRepair (src/lib/launchd-repair.ts);
12014
+ * this is the EXECUTION, and it is the only place that touches the real
12015
+ * filesystem and launchctl.
12016
+ *
12017
+ * Reuses the existing primitives rather than re-inventing them:
12018
+ * - observeLaunchdManagement / assessLaunchdManagement is the fail-loud
12019
+ * verifier (launchctl PID AND that PID is the serving process).
12020
+ * - ensureLaunchdServiceLoaded is the unload -> load -> start.
12021
+ * - the five-state liveness machine (gatherDaemonEvidence +
12022
+ * classifyDaemonState, flair#1454) attributes and clean-stops the direct
12023
+ * process on the adopt path — never a foreign/recycled pid, never kill -9.
12024
+ *
12025
+ * The adopt path (flair#1573 slice b2) BOUNCES the live instance: it
12026
+ * clean-stops the direct-spawned process (SIGTERM + wait for exit), confirms
12027
+ * the port is free, then regenerates + loads. A foreign/unattributable direct
12028
+ * process is refused by the liveness machine (DISAGREEMENT/UNKNOWN), never
12029
+ * signalled.
12030
+ *
12031
+ * Never reports success on a direct-start fallback: the final verify is
12032
+ * assessLaunchdManagement, and anything short of `managed` is a `failed` result
12033
+ * with the detached detail + remedy, never a silent pass. The whole executor
12034
+ * arm is wrapped in try/catch (Kern's b1 defect): a throw becomes a named
12035
+ * `failed` result (or an engine-backwards `refused`), never a crash mid-report.
12036
+ */
12037
+ async function repairLaunchdManagement(dataDir, port) {
12038
+ const { plan, plistPath, isLegacy, config } = planLaunchdRepairFor(dataDir, port);
12039
+ switch (plan.kind) {
12040
+ case "no-op":
12041
+ return { kind: "no-op", reason: plan.reason, detail: plan.detail };
12042
+ case "refuse":
12043
+ return { kind: "refused", reason: plan.reason, detail: plan.detail, plistPath: plan.plistPath };
12044
+ case "adopt":
12045
+ case "regenerate": {
12046
+ try {
12047
+ // Guard FIRST (flair#1093): the repair is a boot path, and an older
12048
+ // engine opening a newer store fails at the storage layer minutes
12049
+ // later — same refusal as startFlairProcess. On the adopt arm this
12050
+ // must run BEFORE the stop: it is a pure read whose inputs don't
12051
+ // change during the repair, so guard-first refuses WITHOUT bouncing
12052
+ // the live instance (guard-after-stop would SIGTERM the instance and
12053
+ // then refuse, leaving it down with nothing to restart it).
12054
+ guardEngineNotBackwards(dataDir);
12055
+ // Adopt (flair#1573 slice b2): clean-stop the direct process first, so
12056
+ // the regenerate + load below does not collide on the port.
12057
+ if (plan.kind === "adopt") {
12058
+ const stop = await stopDirectProcessForAdopt(port, dataDir);
12059
+ if (stop)
12060
+ return stop; // a named failed result
12061
+ }
12062
+ const { execSync } = await import("node:child_process");
12063
+ // Regenerate the plist (pass-file mode) and write it atomically.
12064
+ // No secret is embedded, so 0644 is correct here.
12065
+ const plist = buildRepairPlist(dataDir, config);
12066
+ const newPlistPath = launchdPlistPath(launchdLabel(dataDir));
12067
+ writeFileAtomic(newPlistPath, plist, 0o644);
12068
+ // flair#1586 / #1581: a SET_CONFIG-less detach (MQTT_* via
12069
+ // buildDirectSpawnEnv) can persist mqtt.network as mtls, port,
12070
+ // securePort when Harper stored no originals for already-null ports.
12071
+ // Adopt SET_CONFIG updates those keys in place and would otherwise
12072
+ // leave harper-config.yaml not byte-identical to the first-repair
12073
+ // file (port, securePort, mtls). Reorder only those scalar lines
12074
+ // before launchd loads so the next persist matches the settled file.
12075
+ const cfgPath = harperConfigPath(dataDir);
12076
+ if (cfgPath) {
12077
+ const raw = readFileSync(cfgPath, "utf-8");
12078
+ const { text, changed } = stabilizeMqttNetworkKeyOrder(raw);
12079
+ if (changed)
12080
+ writeFileAtomic(cfgPath, text, 0o644);
12081
+ }
12082
+ // If the resolved plist was a pre-flair#693 legacy label, unload and
12083
+ // remove it so it is not orphaned beside the regenerated one.
12084
+ if (isLegacy && plistPath !== newPlistPath) {
12085
+ try {
12086
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
12087
+ }
12088
+ catch { /* best effort */ }
12089
+ try {
12090
+ unlinkSync(plistPath);
12091
+ }
12092
+ catch { /* best effort */ }
12093
+ }
12094
+ // Load (unload -> load -> start).
12095
+ ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
12096
+ // Verify (fail-loud).
12097
+ const after = observeLaunchdManagement(dataDir, port);
12098
+ if (after.state !== "managed") {
12099
+ return { kind: "failed", detail: after.detail, remedy: after.remedy };
12100
+ }
12101
+ const detail = plan.kind === "adopt"
12102
+ ? `adopted the direct-spawned instance into launchd (bounced the live instance): ${after.detail}`
12103
+ : after.detail;
12104
+ return { kind: "repaired", detail };
12105
+ }
12106
+ catch (err) {
12107
+ return mapRepairThrow(err);
12108
+ }
12109
+ }
12110
+ }
12111
+ }
12112
+ /**
12113
+ * Clean-stop the direct (non-launchd) process serving `dataDir`+`port` for the
12114
+ * adopt path (flair#1573 slice b2). Returns a `failed` result when the process
12115
+ * cannot be attributed (never stop a foreign process) or the port is still
12116
+ * occupied after the stop; returns null when the port is free and the caller
12117
+ * should proceed to regenerate + load.
12118
+ *
12119
+ * Reuses the five-state liveness machine (flair#1454): identity is verified
12120
+ * (pidfile + sidecar + start time) before any signal, so a DISAGREEMENT/UNKNOWN
12121
+ * verdict refuses rather than gambling on a recycled pid. The stop is SIGTERM +
12122
+ * wait for exit — never kill -9.
12123
+ */
12124
+ async function stopDirectProcessForAdopt(port, dataDir) {
12125
+ const evidence = await gatherDaemonEvidence(port, dataDir);
12126
+ const state = classifyDaemonState(evidence, { port, dataDir });
12127
+ // SIGTERM + wait for exit for a verified live pid (RUNNING or WEDGED — a
12128
+ // wedged daemon is recovery, not a recycled-pid gamble).
12129
+ if (state.state === "RUNNING" || state.state === "WEDGED") {
12130
+ try {
12131
+ process.kill(state.pid, "SIGTERM");
12132
+ }
12133
+ catch { /* already gone */ }
12134
+ try {
12135
+ await waitForProcessExit(state.pid, STARTUP_TIMEOUT_MS);
12136
+ }
12137
+ catch { /* best-effort — the port check below surfaces the real problem */ }
12138
+ }
12139
+ const postStopHealth = await probeHealth(port);
12140
+ const decision = decideAdoptStop(state, postStopHealth);
12141
+ if (decision !== "proceed")
12142
+ return decision;
12143
+ // Belt-and-suspenders: lsof confirms no TCP listener remains before the
12144
+ // caller loads the plist. probeHealth "refused" (ECONNREFUSED) already means
12145
+ // nothing is listening, but a port that is BOUND yet refuses connections
12146
+ // (backlog-full, or a non-HTTP listener) would still EADDRINUSE on load —
12147
+ // this catches that rare case the HTTP probe cannot see.
12148
+ const { execSync } = await import("node:child_process");
12149
+ const listeners = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
12150
+ if (listeners.length > 0) {
12151
+ return {
12152
+ kind: "failed",
12153
+ detail: `port still occupied after stopping the direct process (listener pid ${listeners.join(", ")})`,
12154
+ remedy: ["flair stop", "flair doctor --fix"],
12155
+ };
12156
+ }
12157
+ return null;
12158
+ }
11706
12159
  /**
11707
12160
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
11708
12161
  * plist is present (falling back on failure), otherwise a manual SIGTERM by
@@ -12298,8 +12751,21 @@ program
12298
12751
  // is actually stamping new writes with.
12299
12752
  const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
12300
12753
  const EMBEDDING_VARIANT = "searchprefix";
12754
+ // embedding-space-guard slice 1: getModelId() now stamps the ENGINE-QUALIFIED
12755
+ // id `<engine>:<base>[+searchprefix]`. Duplicated as a literal here (separate
12756
+ // build target — see above). A row is CURRENT-SPACE iff its stamp is the
12757
+ // qualified id OR its one-time bare-name equivalent (today's corpus, stamped
12758
+ // before the qualifier). Treat BOTH as current so `--stale-only` never
12759
+ // re-embeds an already-correct bare-stamped row — that would loop forever
12760
+ // (Memory.put re-stamps it QUALIFIED, still "!= bare" under a single-value
12761
+ // check). Keep in lockstep with resources/embeddings-provider.ts's
12762
+ // getModelId()/EMBEDDING_ENGINE and resources/embedding-space-guard.ts's
12763
+ // normalizeStamp().
12764
+ const EMBEDDING_ENGINE = "gguf";
12301
12765
  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;
12766
+ const bareCurrentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
12767
+ const currentModel = `${EMBEDDING_ENGINE}:${bareCurrentModel}`;
12768
+ const isCurrentSpace = (stamp) => stamp === currentModel || stamp === bareCurrentModel;
12303
12769
  if (agentId) {
12304
12770
  console.log(`Re-embedding memories for agent: ${agentId}`);
12305
12771
  }
@@ -12358,7 +12824,7 @@ program
12358
12824
  for (const m of allMemories) {
12359
12825
  if (!m.content)
12360
12826
  continue;
12361
- if (staleOnly && m.embeddingModel === currentModel)
12827
+ if (staleOnly && isCurrentSpace(m.embeddingModel))
12362
12828
  continue;
12363
12829
  const agent = m.agentId || "unknown";
12364
12830
  if (!byAgent.has(agent))
@@ -12463,7 +12929,7 @@ program
12463
12929
  if (!m.content)
12464
12930
  return false;
12465
12931
  if (staleOnly)
12466
- return !m.embeddingModel || m.embeddingModel !== currentModel;
12932
+ return !m.embeddingModel || !isCurrentSpace(m.embeddingModel);
12467
12933
  return true;
12468
12934
  });
12469
12935
  const total = candidates.length;
@@ -13441,16 +13907,22 @@ program
13441
13907
  // catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
13442
13908
  // both. Extra doctor UX (pi, --fix, execution probe, continuity,
13443
13909
  // agent registration) stays below and does not redefine those checks.
13910
+ //
13911
+ // flair#1573 slice b — launchd management is diagnosed + repaired by its
13912
+ // own section below (planLaunchdRepairFor / repairLaunchdManagement), not
13913
+ // by the install-health catalog. The catalog's launchd check stays for
13914
+ // `upgrade` (flair#1022), but doctor would otherwise double-count the same
13915
+ // drift (catalog "detached" fail + repair "regenerate"/"adopt"/"refuse").
13916
+ const doctorCatalogIds = DOCTOR_CHECK_IDS.filter((id) => id !== "launchd-management");
13444
13917
  const doctorCtx = {
13445
13918
  homeDir: homedir(),
13446
13919
  cwd: process.cwd(),
13447
13920
  detectedClientIds: detectedClients.map((c) => c.id),
13448
- launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
13449
13921
  keysDir,
13450
13922
  keyAgentIds,
13451
13923
  agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
13452
13924
  };
13453
- const catalogBefore = runDoctorChecks(doctorCtx);
13925
+ const catalogBefore = runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds });
13454
13926
  if (detectedClients.length === 0) {
13455
13927
  console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
13456
13928
  }
@@ -13469,170 +13941,13 @@ program
13469
13941
  }
13470
13942
  }
13471
13943
  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
- }
13944
+ // flair#989 pi is a dead namespace: the pi (kind:
13945
+ // "native-extension") check is removed from doctor entirely. pi was
13946
+ // the last non-MCP client here, and a detected-but-unwired pi was
13947
+ // counted as an install failure for a namespace nobody opts into any
13948
+ // more. Doctor now diagnoses only MCP clients the user wired (below).
13949
+ if (client.kind !== "mcp")
13634
13950
  continue;
13635
- }
13636
13951
  const block = readClientMcpBlock(client.id, homedir());
13637
13952
  if (client.id === "claude-code" && block.agentId)
13638
13953
  claudeCodeAgentId = block.agentId;
@@ -13641,7 +13956,12 @@ program
13641
13956
  if (block.agentId)
13642
13957
  anyKnownAgentId = anyKnownAgentId ?? block.agentId;
13643
13958
  if (!block.present) {
13644
- console.log(` ${render.icons.error} ${client.label}: no Flair MCP server configured in ${render.wrap(render.c.dim, block.configPath)}`);
13959
+ // flair#989: this client is DETECTED (binary/config on the box) but
13960
+ // was never wired to Flair — the user did not opt into it. That is
13961
+ // not an install FAILURE, so it renders as info, never a ✗, and is
13962
+ // not counted (the catalog's opt-in mcp-block check owns the count).
13963
+ // `--fix` still offers to wire it, on the user's y/N consent.
13964
+ 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
13965
  if (autoFix) {
13646
13966
  if (dryRun) {
13647
13967
  console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
@@ -13702,7 +14022,7 @@ program
13702
14022
  // `--fix` already works, so don't clutter the suggestion.
13703
14023
  const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
13704
14024
  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)`)}`);
14025
+ console.log(` ${render.wrap(render.c.dim, "To wire it (optional):")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label})`)}`);
13706
14026
  }
13707
14027
  continue;
13708
14028
  }
@@ -13739,10 +14059,25 @@ program
13739
14059
  console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
13740
14060
  }
13741
14061
  }
14062
+ // flair#989: the harness-specific checks below (CLAUDE.md, SessionStart
14063
+ // hook, continuity, Codex hook) run only for a harness the user actually
14064
+ // WIRED — its MCP block is present. A harness merely DETECTED on the box
14065
+ // but never opted into owes none of these; flagging them was the false-
14066
+ // positive this fix removes. Read the block fresh so a `--fix` that just
14067
+ // wired the client during the loop above is reflected here.
14068
+ const claudeCodeDetected = detectedClients.some((c) => c.id === "claude-code");
14069
+ const claudeCodeConfigured = claudeCodeDetected && readClientMcpBlock("claude-code", homedir()).present;
14070
+ const codexConfigured = detectedClients.some((c) => c.id === "codex") && readClientMcpBlock("codex", homedir()).present;
13742
14071
  // Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
13743
14072
  // Codex has a SessionStart hook too (checked below); CLAUDE.md and
13744
14073
  // continuity stay Claude Code only.
13745
- if (detectedClients.some((c) => c.id === "claude-code")) {
14074
+ //
14075
+ // flair#989: CLAUDE.md and the SessionStart hook are wiring-dependent —
14076
+ // they apply, and can only fail, once Claude Code is WIRED — so they are
14077
+ // gated on `claudeCodeConfigured`. Continuity (below) is a separate
14078
+ // opt-in that renders "not enabled" as info and never a failure, so it
14079
+ // stays gated on mere detection (flair#1324/#1257).
14080
+ if (claudeCodeConfigured) {
13746
14081
  const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
13747
14082
  if (claudeMd.present) {
13748
14083
  console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
@@ -13816,6 +14151,30 @@ program
13816
14151
  else {
13817
14152
  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
14153
  }
14154
+ // flair#1516: a hook can be wired AND still run yet be pinned to a
14155
+ // DIFFERENT @tpsdev-ai/flair-mcp version than the Claude Code MCP
14156
+ // client — an upgrade refreshed the client block but (pre-#1516)
14157
+ // left the hook behind, so every session silently launched the OLD
14158
+ // adapter. "and still runs" never caught this; compare the two pins.
14159
+ const claudeHookSkew = checkSessionStartHookPinSkew(homedir(), "claude-code");
14160
+ if (claudeHookSkew.skewed) {
14161
+ 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`);
14162
+ if (autoFix) {
14163
+ if (dryRun) {
14164
+ console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
14165
+ }
14166
+ else {
14167
+ const repin = repinSessionStartHook(homedir(), "claude-code");
14168
+ console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
14169
+ if (repin.ok && repin.action === "update")
14170
+ fixed++;
14171
+ }
14172
+ }
14173
+ else {
14174
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install ${render.wrap(render.c.dim, "(re-pins the hook to the current version)")}`);
14175
+ }
14176
+ issues++;
14177
+ }
13819
14178
  // Independent of whether it runs today: would it stay quiet if it
13820
14179
  // stopped? Only offered as a repair when the command is the exact
13821
14180
  // string Flair itself wrote — a hand-edited or pinned hook is the
@@ -13869,6 +14228,10 @@ program
13869
14228
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
13870
14229
  }
13871
14230
  }
14231
+ } // end CLAUDE.md + SessionStart hook (claudeCodeConfigured)
14232
+ // Continuity capture is a standalone Claude Code opt-in — shown whenever
14233
+ // Claude Code is DETECTED, independent of MCP wiring (flair#1324/#1257).
14234
+ if (claudeCodeDetected) {
13872
14235
  // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
13873
14236
  // the SessionStart check above: installed / absent / stale-form).
13874
14237
  // Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
@@ -13926,7 +14289,7 @@ program
13926
14289
  // command Claude Code uses, written to ~/.codex/hooks.json. Continuity
13927
14290
  // and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
13928
14291
  // is the hook file.
13929
- if (detectedClients.some((c) => c.id === "codex")) {
14292
+ if (codexConfigured) {
13930
14293
  const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
13931
14294
  if (hook.present) {
13932
14295
  if (hook.execution === "broken") {
@@ -13952,6 +14315,28 @@ program
13952
14315
  else {
13953
14316
  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
14317
  }
14318
+ // flair#1516: same version-skew check as Claude Code — a Codex hook
14319
+ // left behind by an upgrade keeps launching the OLD adapter while
14320
+ // the Codex MCP block advertises the new pin.
14321
+ const codexHookSkew = checkSessionStartHookPinSkew(homedir(), "codex");
14322
+ if (codexHookSkew.skewed) {
14323
+ 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`);
14324
+ if (autoFix) {
14325
+ if (dryRun) {
14326
+ console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
14327
+ }
14328
+ else {
14329
+ const repin = repinSessionStartHook(homedir(), "codex");
14330
+ console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
14331
+ if (repin.ok && repin.action === "update")
14332
+ fixed++;
14333
+ }
14334
+ }
14335
+ else {
14336
+ 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)")}`);
14337
+ }
14338
+ issues++;
14339
+ }
13955
14340
  if (!hook.silenced && hook.ours) {
13956
14341
  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
14342
  if (hook.upgradable) {
@@ -14006,7 +14391,7 @@ program
14006
14391
  // Catalog is the install-health verdict — count fail/unrun here, not
14007
14392
  // via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
14008
14393
  // --fix that cleared a catalog member shows up in the found→fixed delta.
14009
- const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
14394
+ const catalogAfter = autoFix ? runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds }) : catalogBefore;
14010
14395
  const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
14011
14396
  issues += catalogDelta.found;
14012
14397
  if (autoFix)
@@ -14015,6 +14400,74 @@ program
14015
14400
  for (const row of renderCatalogDoctorLines(catalogAfter)) {
14016
14401
  console.log(` ${render.icons[row.icon]} ${row.line}`);
14017
14402
  }
14403
+ // 7b. Launchd management repair (flair#1573 slice b) — `doctor --fix`
14404
+ // repairs a MISSING, CORRUPT, or DETACHED launchd plist. This is a
14405
+ // distinct concern from the install-health catalog above (which
14406
+ // `upgrade` also asserts), so it owns its own reporting + counting
14407
+ // rather than double-counting the catalog's launchd check. The
14408
+ // DECISION is pure (planLaunchdRepairFor -> planLaunchdRepair); the
14409
+ // EXECUTION (adopt: clean-stop -> regenerate pass-file plist -> load ->
14410
+ // verify) is repairLaunchdManagement, which is the only place that
14411
+ // touches the real filesystem and launchctl.
14412
+ console.log(`\n ${render.wrap(render.c.bold, "Launchd management")}`);
14413
+ if (autoFix && !dryRun) {
14414
+ // Execute the repair directly; it re-derives the plan internally and
14415
+ // verifies via assessLaunchdManagement (fail-loud, never a silent pass).
14416
+ const repairResult = await repairLaunchdManagement(defaultDataDir(), effectivePort);
14417
+ switch (repairResult.kind) {
14418
+ case "no-op":
14419
+ console.log(` ${render.icons.ok} ${repairResult.detail}`);
14420
+ break;
14421
+ case "refused":
14422
+ issues++;
14423
+ console.log(` ${render.icons.error} ${repairResult.detail}`);
14424
+ break;
14425
+ case "repaired":
14426
+ fixed++;
14427
+ console.log(` ${render.icons.ok} ${repairResult.detail}`);
14428
+ break;
14429
+ case "failed":
14430
+ issues++;
14431
+ console.log(` ${render.icons.error} ${repairResult.detail}`);
14432
+ if (repairResult.remedy)
14433
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${repairResult.remedy.join(" && ")}`);
14434
+ break;
14435
+ }
14436
+ }
14437
+ else {
14438
+ // Report only (no --fix, or --fix --dry-run): compute the plan, touch
14439
+ // nothing. A regenerate plan is drift; a refuse plan is a named refusal.
14440
+ const repairPlan = planLaunchdRepairFor(defaultDataDir(), effectivePort);
14441
+ switch (repairPlan.plan.kind) {
14442
+ case "no-op":
14443
+ console.log(` ${render.icons.ok} ${repairPlan.plan.detail}`);
14444
+ break;
14445
+ case "refuse":
14446
+ issues++;
14447
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
14448
+ break;
14449
+ case "regenerate":
14450
+ issues++;
14451
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
14452
+ if (dryRun) {
14453
+ console.log(` ${render.wrap(render.c.dim, "Would regenerate")} the launchd plist (pass-file mode) and load it`);
14454
+ }
14455
+ else {
14456
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(regenerates the plist in pass-file mode, loads it, and verifies)")}`);
14457
+ }
14458
+ break;
14459
+ case "adopt":
14460
+ issues++;
14461
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
14462
+ if (dryRun) {
14463
+ console.log(` ${render.wrap(render.c.dim, "Would adopt")} the direct-spawned instance into launchd (clean-stop, regenerate, load — bounces the live instance)`);
14464
+ }
14465
+ else {
14466
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(clean-stops the direct process, regenerates the plist, loads it, and verifies — bounces the live instance)")}`);
14467
+ }
14468
+ break;
14469
+ }
14470
+ }
14018
14471
  // 7a. Resolve which agent identities the two verified-read sections below
14019
14472
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
14020
14473
  // sections required --agent explicitly; doctor already enumerates every
@@ -14484,11 +14937,13 @@ export const QUALITY_RECALL_K = 5;
14484
14937
  * pulls embedding vectors inline — the defect in flair#1360 was an
14485
14938
  * unfiltered `GET /Memory?agentId=…` that returned every row's 768-d
14486
14939
  * 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/…`).
14940
+ * 10 memories. `archived` is projected so the planner can drop basemented
14941
+ * rows before sampling (flair#857 SemanticSearch excludes them, so an
14942
+ * archived row in the sample is a guaranteed miss). `type` is intentionally
14943
+ * omitted: it is not a declared Memory column (see schemas/memory.graphql);
14944
+ * snapshot exclusion keys off `subject` (`quality-snapshot/…`).
14490
14945
  */
14491
- export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt"];
14946
+ export const QUALITY_MEMORY_LIST_SELECT = ["id", "subject", "content", "createdAt", "archived"];
14492
14947
  /**
14493
14948
  * Extra most-recent rows fetched beyond `sampleSize` so
14494
14949
  * `planRecallSpotCheck` can drop the sweep's own quality-snapshot
@@ -14635,20 +15090,26 @@ function isQualitySnapshotRow(m) {
14635
15090
  * fail-closed health rules are testable without any I/O (flair#967).
14636
15091
  *
14637
15092
  * Order of operations, and why:
14638
- * 1. drop the tool's own quality-snapshot rows (never grade your own
15093
+ * 1. drop archived rows (SemanticSearch excludes them flair#857 so a
15094
+ * basemented row in the sample is a guaranteed miss, not a recall signal);
15095
+ * 2. drop the tool's own quality-snapshot rows (never grade your own
14639
15096
  * bookkeeping);
14640
- * 2. take the `sampleSize` most-recently-written remaining rows (unchanged —
15097
+ * 3. take the `sampleSize` most-recently-written remaining rows (unchanged —
14641
15098
  * recency is still the sampling frame; see the issue's direction 3 for the
14642
15099
  * 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
15100
+ * 4. derive each cue via deriveRecallCue;
15101
+ * 5. judge the window: any duplicate cue, or any empty cue, makes it
14645
15102
  * UNSCORABLE — reported as unhealthy, never silently scored.
14646
15103
  */
14647
15104
  export function planRecallSpotCheck(memories, opts = {}) {
14648
15105
  const sampleSize = opts.sampleSize ?? QUALITY_RECALL_SAMPLE_SIZE;
14649
15106
  const rows = Array.isArray(memories) ? memories : [];
14650
- const scorable = rows.filter((m) => !isQualitySnapshotRow(m ?? {}));
14651
- const excludedSnapshotRows = rows.length - scorable.length;
15107
+ // `archived !== true` matches SemanticSearch / AdminMemory: unset and
15108
+ // false stay in the live pool; only an explicit basement is dropped.
15109
+ const live = rows.filter((m) => m?.archived !== true);
15110
+ const scorable = live.filter((m) => !isQualitySnapshotRow(m ?? {}));
15111
+ const excludedArchivedRows = rows.length - live.length;
15112
+ const excludedSnapshotRows = live.length - scorable.length;
14652
15113
  const sorted = scorable.slice().sort((a, b) => {
14653
15114
  const ta = a?.createdAt ? new Date(a.createdAt).getTime() : 0;
14654
15115
  const tb = b?.createdAt ? new Date(b.createdAt).getTime() : 0;
@@ -14666,7 +15127,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
14666
15127
  }
14667
15128
  const duplicateCues = [...counts.entries()].filter(([, n]) => n > 1).map(([cue]) => cue);
14668
15129
  if (duplicateCues.length === 0 && emptyCueCount === 0) {
14669
- return { sampled, health: { healthy: true }, excludedSnapshotRows };
15130
+ return { sampled, health: { healthy: true }, excludedSnapshotRows, excludedArchivedRows };
14670
15131
  }
14671
15132
  const parts = [];
14672
15133
  if (duplicateCues.length > 0) {
@@ -14686,6 +15147,7 @@ export function planRecallSpotCheck(memories, opts = {}) {
14686
15147
  emptyCueCount,
14687
15148
  },
14688
15149
  excludedSnapshotRows,
15150
+ excludedArchivedRows,
14689
15151
  };
14690
15152
  }
14691
15153
  /**
@@ -14910,13 +15372,21 @@ export async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
14910
15372
  return { ok: false, agentId, skipReason: `could not fetch memories to sample: ${err?.message ?? String(err)}` };
14911
15373
  }
14912
15374
  // 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.
15375
+ // pure (planRecallSpotCheck above). Archived rows (flair#857) and snapshot
15376
+ // rows are excluded there, so the "enough memories" check has to run on the
15377
+ // PLANNED window, not on the raw row count — an instance whose recent writes
15378
+ // are mostly basemented or the sweep's own bookkeeping should skip with a
15379
+ // reason, not score a short window.
14917
15380
  const plan = planRecallSpotCheck(all, { sampleSize });
14918
15381
  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)` : "";
15382
+ const exclusionParts = [];
15383
+ if (plan.excludedArchivedRows > 0) {
15384
+ exclusionParts.push(`${plan.excludedArchivedRows} archived row(s) excluded — SemanticSearch cannot return basemented memories; restore with \`flair memory restore <id>\` if they should be live`);
15385
+ }
15386
+ if (plan.excludedSnapshotRows > 0) {
15387
+ exclusionParts.push(`${plan.excludedSnapshotRows} quality-snapshot row(s) excluded — the spot-check never grades its own bookkeeping`);
15388
+ }
15389
+ const excluded = exclusionParts.length > 0 ? ` (${exclusionParts.join("; ")})` : "";
14920
15390
  return {
14921
15391
  ok: false,
14922
15392
  agentId,
@@ -15198,7 +15668,7 @@ export async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject, op
15198
15668
  * `PUT /Memory/{id}` write shape `memory write-task-summary` already uses.
15199
15669
  * Throws on a write failure — the CLI action below is responsible for
15200
15670
  * surfacing that as a clear error, same as every other write path here. */
15201
- async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
15671
+ async function storeQualitySnapshot(agentId, agentIdSource, baseUrl, subject, snapshot) {
15202
15672
  const memId = `${agentId}-quality-snapshot-${Date.now()}`;
15203
15673
  const body = {
15204
15674
  id: memId,
@@ -15210,7 +15680,7 @@ async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
15210
15680
  type: "quality-snapshot",
15211
15681
  createdAt: new Date().toISOString(),
15212
15682
  };
15213
- const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId });
15683
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId, agentIdSource });
15214
15684
  if (out?.error)
15215
15685
  throw new Error(String(out.error));
15216
15686
  return memId;
@@ -15226,8 +15696,8 @@ program
15226
15696
  .option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
15227
15697
  .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
15698
  .action(async (opts) => {
15229
- const agentId = resolveSigningAgentId(opts, "quality");
15230
- const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId);
15699
+ const { agentId, source } = resolveSigningAgentId(opts, "quality");
15700
+ const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId, source);
15231
15701
  if (opts.emit && !agentId) {
15232
15702
  console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
15233
15703
  process.exit(1);
@@ -15270,7 +15740,7 @@ program
15270
15740
  }
15271
15741
  }
15272
15742
  try {
15273
- emitResult.snapshotId = await storeQualitySnapshot(agentId, baseUrl, subject, current);
15743
+ emitResult.snapshotId = await storeQualitySnapshot(agentId, source, baseUrl, subject, current);
15274
15744
  }
15275
15745
  catch (err) {
15276
15746
  emitResult.errors.push(`snapshot store failed: ${err?.message ?? String(err)}`);
@@ -15651,7 +16121,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
15651
16121
  process.exit(1);
15652
16122
  }
15653
16123
  applyAdminPassFile(opts);
15654
- const agentId = resolveSigningAgentId(opts, "memory add");
16124
+ const { agentId, source } = resolveSigningAgentId(opts, "memory add");
15655
16125
  if (!agentId) {
15656
16126
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15657
16127
  process.exit(2);
@@ -15693,6 +16163,7 @@ addSharedCredentialOptions(addSharedIdentityOption(memory.command("add [content]
15693
16163
  }
15694
16164
  const out = await api("PUT", `/Memory/${memId}`, body, {
15695
16165
  agentId,
16166
+ agentIdSource: source,
15696
16167
  explicitAdminPass: opts.adminPass,
15697
16168
  adminUser: opts.adminUser,
15698
16169
  });
@@ -15750,7 +16221,7 @@ memory.command("write-task-summary")
15750
16221
  lines.push(opts.summary);
15751
16222
  }
15752
16223
  const content = lines.join("\n");
15753
- const agentId = resolveSigningAgentId(opts, "memory write-task-summary") ?? opts.agent;
16224
+ const { agentId, source } = resolveSigningAgentId(opts, "memory write-task-summary");
15754
16225
  const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
15755
16226
  const body = {
15756
16227
  id: memId,
@@ -15767,7 +16238,7 @@ memory.command("write-task-summary")
15767
16238
  if (opts.derivedFrom) {
15768
16239
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
15769
16240
  }
15770
- const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId });
16241
+ const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId, agentIdSource: source });
15771
16242
  if (out?.error) {
15772
16243
  console.error(`Error writing task summary: ${out.error}`);
15773
16244
  process.exit(1);
@@ -15779,6 +16250,7 @@ memory.command("write-task-summary")
15779
16250
  memory.command("search [query]")
15780
16251
  .description("Semantic search over an agent's memories (query via positional arg or --q)")
15781
16252
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
16253
+ .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
16254
  .option("--q <query>", "search query (alias for positional arg)")
15783
16255
  .option("--limit <n>", "Max results", "5")
15784
16256
  .option("--tag <tag>")
@@ -15787,7 +16259,7 @@ memory.command("search [query]")
15787
16259
  .option("--url <url>", "Flair base URL (overrides --port)")
15788
16260
  .option("--port <port>", "Harper HTTP port")
15789
16261
  .action(async (queryArg, opts) => {
15790
- const agentId = resolveSigningAgentId(opts, "memory search");
16262
+ const { agentId, source } = resolveSigningAgentId(opts, "memory search");
15791
16263
  if (!agentId) {
15792
16264
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15793
16265
  process.exit(2);
@@ -15803,7 +16275,7 @@ memory.command("search [query]")
15803
16275
  if (opts.includeArchived)
15804
16276
  body.includeArchived = true;
15805
16277
  const baseUrl = resolveBaseUrl(opts);
15806
- const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
16278
+ const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId, agentIdSource: source, explicitAdminPass: opts.adminPass });
15807
16279
  console.log(JSON.stringify(res, null, 2));
15808
16280
  });
15809
16281
  // ─── flair memory basement / restore ────────────────────────────────────────
@@ -15820,13 +16292,13 @@ memory.command("basement <id>")
15820
16292
  .option("--url <url>", "Flair base URL (overrides --port)")
15821
16293
  .option("--port <port>", "Harper HTTP port")
15822
16294
  .action(async (id, opts) => {
15823
- const agentId = resolveSigningAgentId(opts, "memory basement");
16295
+ const { agentId, source } = resolveSigningAgentId(opts, "memory basement");
15824
16296
  if (!agentId) {
15825
16297
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15826
16298
  process.exit(2);
15827
16299
  }
15828
16300
  const baseUrl = resolveBaseUrl(opts);
15829
- const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
16301
+ const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId, agentIdSource: source });
15830
16302
  console.log(JSON.stringify(res, null, 2));
15831
16303
  });
15832
16304
  memory.command("restore <id>")
@@ -15836,13 +16308,13 @@ memory.command("restore <id>")
15836
16308
  .option("--url <url>", "Flair base URL (overrides --port)")
15837
16309
  .option("--port <port>", "Harper HTTP port")
15838
16310
  .action(async (id, opts) => {
15839
- const agentId = resolveSigningAgentId(opts, "memory restore");
16311
+ const { agentId, source } = resolveSigningAgentId(opts, "memory restore");
15840
16312
  if (!agentId) {
15841
16313
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15842
16314
  process.exit(2);
15843
16315
  }
15844
16316
  const baseUrl = resolveBaseUrl(opts);
15845
- const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
16317
+ const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId, agentIdSource: source });
15846
16318
  console.log(JSON.stringify(res, null, 2));
15847
16319
  });
15848
16320
  memory.command("list")
@@ -15853,13 +16325,13 @@ memory.command("list")
15853
16325
  .option("--limit <n>", "Max rows when using --hash-fallback", "50")
15854
16326
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
15855
16327
  .action(async (opts) => {
15856
- const agentId = resolveSigningAgentId(opts, "memory list");
16328
+ const { agentId, source } = resolveSigningAgentId(opts, "memory list");
15857
16329
  if (!agentId) {
15858
16330
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
15859
16331
  process.exit(2);
15860
16332
  }
15861
16333
  const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
15862
- const raw = await api("GET", `/Memory?${q}`, undefined, { agentId });
16334
+ const raw = await api("GET", `/Memory?${q}`, undefined, { agentId, agentIdSource: source });
15863
16335
  const mode = render.resolveOutputMode(opts);
15864
16336
  // hashFallback flag changes the lens: instead of all memories, show
15865
16337
  // only those that need re-embedding. Keep that surface separate.
@@ -16191,7 +16663,7 @@ program
16191
16663
  .option("--json", "Output raw JSON array")
16192
16664
  .action(async (query, opts) => {
16193
16665
  try {
16194
- const agentId = resolveSigningAgentId(opts, "search");
16666
+ const { agentId } = resolveSigningAgentId(opts, "search");
16195
16667
  if (!agentId) {
16196
16668
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
16197
16669
  process.exit(2);
@@ -16351,7 +16823,7 @@ program
16351
16823
  .option("--key <path>", "Ed25519 private key path")
16352
16824
  .option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
16353
16825
  .action(async (opts) => {
16354
- const agentId = resolveSigningAgentId(opts, "bootstrap");
16826
+ const { agentId, source } = resolveSigningAgentId(opts, "bootstrap");
16355
16827
  if (!agentId) {
16356
16828
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
16357
16829
  process.exit(2);
@@ -16366,7 +16838,7 @@ program
16366
16838
  // finds a usable key. Previously this only ever tried Ed25519 (no
16367
16839
  // admin fallback at all) and sent NO Authorization header when no key
16368
16840
  // 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 }));
16841
+ const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, agentIdSource: source, explicitKeyPath: opts.key }));
16370
16842
  if (mode === "json") {
16371
16843
  // Agent-first: emit the full server response, augmented with the cap
16372
16844
  // that was requested. Includes context, sections, tokenEstimate, etc.
@@ -16446,7 +16918,7 @@ relationship.command("add")
16446
16918
  .option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
16447
16919
  .option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
16448
16920
  .action(async (opts) => {
16449
- const agentId = resolveSigningAgentId(opts, "relationship add") ?? opts.agent;
16921
+ const { agentId, source } = resolveSigningAgentId(opts, "relationship add");
16450
16922
  const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
16451
16923
  const body = {
16452
16924
  id,
@@ -16463,11 +16935,11 @@ relationship.command("add")
16463
16935
  body.validTo = opts.validTo;
16464
16936
  if (opts.source)
16465
16937
  body.source = opts.source;
16466
- const out = await api("PUT", `/Relationship/${id}`, body, { agentId });
16938
+ const out = await api("PUT", `/Relationship/${id}`, body, { agentId, agentIdSource: source });
16467
16939
  console.log(JSON.stringify(out, null, 2));
16468
16940
  });
16469
16941
  const soul = program.command("soul").description("Manage agent soul entries");
16470
- soul.command("set")
16942
+ addSharedCredentialOptions(soul.command("set"))
16471
16943
  .description("Set (upsert) a soul entry for an agent by key")
16472
16944
  .requiredOption("--agent <id>")
16473
16945
  .requiredOption("--key <key>")
@@ -16475,6 +16947,7 @@ soul.command("set")
16475
16947
  .option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
16476
16948
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
16477
16949
  .action(async (opts) => {
16950
+ applyAdminPassFile(opts);
16478
16951
  // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
16479
16952
  // The Soul table resource has no POST handler, so a collection POST /Soul
16480
16953
  // 405s; the record must be written by its primary key. (#498)
@@ -16484,7 +16957,7 @@ soul.command("set")
16484
16957
  // precedence — but before this, api() re-derived the signer as
16485
16958
  // FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
16486
16959
  // 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;
16960
+ const { agentId, source } = resolveSigningAgentId(opts, "soul set");
16488
16961
  const id = `${opts.agent}:${opts.key}`;
16489
16962
  const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
16490
16963
  id,
@@ -16493,7 +16966,7 @@ soul.command("set")
16493
16966
  value: opts.value,
16494
16967
  durability: opts.durability,
16495
16968
  createdAt: new Date().toISOString(),
16496
- }, { agentId });
16969
+ }, { agentId, agentIdSource: source, explicitAdminPass: opts.adminPass, adminUser: opts.adminUser });
16497
16970
  const mode = render.resolveOutputMode(opts);
16498
16971
  if (mode === "json") {
16499
16972
  console.log(render.asJSON(out));
@@ -16515,8 +16988,8 @@ soul.command("get")
16515
16988
  // flair#1183: /Soul reads are verified (any registered agent). Resolve the
16516
16989
  // signer through the canonical seam so soul get honors the SAME precedence
16517
16990
  // 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 });
16991
+ const { agentId, source } = resolveSigningAgentId(opts, "soul get");
16992
+ const out = await api("GET", `/Soul/${id}`, undefined, { agentId, agentIdSource: source });
16520
16993
  const mode = render.resolveOutputMode(opts);
16521
16994
  if (mode === "json") {
16522
16995
  console.log(render.asJSON(out));
@@ -16548,12 +17021,12 @@ soul.command("list")
16548
17021
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
16549
17022
  .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
16550
17023
  .action(async (opts) => {
16551
- const agentId = resolveSigningAgentId(opts, "soul list");
17024
+ const { agentId, source } = resolveSigningAgentId(opts, "soul list");
16552
17025
  if (!agentId) {
16553
17026
  console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
16554
17027
  process.exit(2);
16555
17028
  }
16556
- const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId });
17029
+ const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId, agentIdSource: source });
16557
17030
  const mode = render.resolveOutputMode(opts);
16558
17031
  if (mode === "json") {
16559
17032
  console.log(render.asJSON(out));
@@ -17379,19 +17852,7 @@ program
17379
17852
  console.warn(` warn: agent ${agent.id}: ${err.message}`);
17380
17853
  }
17381
17854
  }
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
17855
+ // Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
17395
17856
  console.log("Restoring souls...");
17396
17857
  let soulCount = 0;
17397
17858
  for (const soul of souls) {
@@ -17403,6 +17864,17 @@ program
17403
17864
  console.warn(` warn: soul ${soul.id}: ${err.message}`);
17404
17865
  }
17405
17866
  }
17867
+ console.log("Restoring memories...");
17868
+ let memoryCount = 0;
17869
+ for (const memory of memories) {
17870
+ try {
17871
+ await adminPut(`/Memory/${memory.id}`, memory);
17872
+ memoryCount++;
17873
+ }
17874
+ catch (err) {
17875
+ console.warn(` warn: memory ${memory.id}: ${err.message}`);
17876
+ }
17877
+ }
17406
17878
  console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Restore complete")}`);
17407
17879
  console.log(render.kv("Agents restored", `${render.wrap(render.c.bold, String(agentCount))}${render.wrap(render.c.dim, `/${agents.length}`)}`));
17408
17880
  console.log(render.kv("Memories restored", `${render.wrap(render.c.bold, String(memoryCount))}${render.wrap(render.c.dim, `/${memories.length}`)}`));
@@ -17574,30 +18046,29 @@ program
17574
18046
  console.log(typeof seedOpsTarget === "string"
17575
18047
  ? ` Agent registered (ops: ${seedOpsTarget})`
17576
18048
  : ` Agent registered`);
17577
- // Restore memories
18049
+ // Restore souls before memories: refuseLearnedSoulWrite matches Memory text.
17578
18050
  const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
17579
- let memCount = 0;
17580
- for (const mem of data.memories ?? []) {
18051
+ let soulCount = 0;
18052
+ for (const soul of data.souls ?? []) {
17581
18053
  try {
17582
- await fetch(`${baseUrl}/Memory/${mem.id}`, {
18054
+ await fetch(`${baseUrl}/Soul/${encodeURIComponent(soul.id)}`, {
17583
18055
  method: "PUT",
17584
18056
  headers: { "Content-Type": "application/json", Authorization: auth },
17585
- body: JSON.stringify(mem),
18057
+ body: JSON.stringify(soul),
17586
18058
  });
17587
- memCount++;
18059
+ soulCount++;
17588
18060
  }
17589
18061
  catch { /* skip failures */ }
17590
18062
  }
17591
- // Restore souls
17592
- let soulCount = 0;
17593
- for (const soul of data.souls ?? []) {
18063
+ let memCount = 0;
18064
+ for (const mem of data.memories ?? []) {
17594
18065
  try {
17595
- await fetch(`${baseUrl}/Soul/${encodeURIComponent(soul.id)}`, {
18066
+ await fetch(`${baseUrl}/Memory/${mem.id}`, {
17596
18067
  method: "PUT",
17597
18068
  headers: { "Content-Type": "application/json", Authorization: auth },
17598
- body: JSON.stringify(soul),
18069
+ body: JSON.stringify(mem),
17599
18070
  });
17600
- soulCount++;
18071
+ memCount++;
17601
18072
  }
17602
18073
  catch { /* skip failures */ }
17603
18074
  }
@@ -17914,7 +18385,7 @@ presence
17914
18385
  .option("--port <port>", "Harper HTTP port")
17915
18386
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
17916
18387
  .action(async (opts) => {
17917
- const agentId = resolveSigningAgentId(opts, "presence set");
18388
+ const { agentId } = resolveSigningAgentId(opts, "presence set");
17918
18389
  if (!agentId) {
17919
18390
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
17920
18391
  process.exit(1);
@@ -18000,7 +18471,7 @@ workspace
18000
18471
  .option("--port <port>", "Harper HTTP port")
18001
18472
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
18002
18473
  .action(async (opts) => {
18003
- const agentId = resolveSigningAgentId(opts, "workspace set");
18474
+ const { agentId } = resolveSigningAgentId(opts, "workspace set");
18004
18475
  if (!agentId) {
18005
18476
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
18006
18477
  process.exit(1);
@@ -18150,7 +18621,7 @@ program
18150
18621
  .option("--port <port>", "Harper HTTP port")
18151
18622
  .option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
18152
18623
  .action(async (opts) => {
18153
- const agentId = resolveSigningAgentId(opts, "orgevent");
18624
+ const { agentId } = resolveSigningAgentId(opts, "orgevent");
18154
18625
  if (!agentId) {
18155
18626
  console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
18156
18627
  process.exit(1);
@@ -18220,7 +18691,7 @@ program
18220
18691
  .option("--json", "Output raw JSON")
18221
18692
  .action(async (entity, opts) => {
18222
18693
  try {
18223
- const agentId = resolveSigningAgentId(opts, "attention");
18694
+ const { agentId } = resolveSigningAgentId(opts, "attention");
18224
18695
  if (!agentId) {
18225
18696
  console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
18226
18697
  process.exit(2);