@tpsdev-ai/flair 0.50.0 → 0.51.1

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.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import nacl from "tweetnacl";
4
4
  import { load as parseYaml } from "js-yaml";
5
5
  import * as render from "./render.js";
6
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
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
8
  import { join, resolve, sep, dirname } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -32,12 +32,15 @@ import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing
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
- import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
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";
37
+ import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartTimeToEpochMs, parsePsLstart, parseSidecarJson, } from "./lib/daemon-liveness.js";
36
38
  // Value-only static import so `--interval`'s advertised default cannot drift
37
39
  // from the one the scheduler actually validates against. The module itself is
38
40
  // still loaded lazily at call time (the `await import()`s below) for the
39
41
  // functions — this pulls in nothing but node builtins.
40
42
  import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
43
+ import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
41
44
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
42
45
  // src/ into resources/, which don't survive npm packaging (see also
43
46
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -60,14 +63,18 @@ function signBody(body, secretKey) {
60
63
  const sig = nacl.sign.detached(message, secretKey);
61
64
  return Buffer.from(sig).toString("base64url");
62
65
  }
63
- // Per-record principalId (federation-edge-hardening slice 3a) INFORMATIONAL
64
- // only; the receiver (resources/Federation.ts) never treats it as verified
65
- // identity or uses it in any auth decision. Sourced from the write-time
66
- // provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
67
- // when present. `provenance` is persisted as a JSON STRING (not an object),
68
- // so it must be parsed a raw `row.provenance?.verified?.agentId` would
69
- // silently always be undefined. Soul/Agent/Relationship rows never carry a
70
- // provenance stamp today, so this is a no-op for them.
66
+ // Per-record principalId (federation-edge-hardening slice 3a / flair#1416).
67
+ // Sourced from the write-time provenance stamp (memory-provenance slice 1,
68
+ // Memory.ts's buildProvenance) when present. `provenance` is persisted as
69
+ // a JSON STRING (not an object), so it must be parsed — a raw
70
+ // `row.provenance?.verified?.agentId` would silently always be undefined.
71
+ // Soul/Agent/Relationship rows never carry a provenance stamp today, so
72
+ // this is a no-op for them (those tables are not principal-owning).
73
+ //
74
+ // As of v:2 this value is IN the signed payload. The receiver validates
75
+ // it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
76
+ // no longer informational-only. Credential.principalId is an unrelated
77
+ // owner field — do not grep that path when changing this one.
71
78
  function principalIdFromRow(row) {
72
79
  if (typeof row?.provenance !== "string" || row.provenance.length === 0)
73
80
  return undefined;
@@ -3002,7 +3009,7 @@ program
3002
3009
  .option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
3003
3010
  .option("--skip-smoke", "Skip the MCP smoke test")
3004
3011
  .option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
3005
- .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
3012
+ .option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
3006
3013
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
3007
3014
  .option("--remote", "When used with --target, init as hub for remote federation")
3008
3015
  .option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
@@ -3463,6 +3470,11 @@ program
3463
3470
  console.log(`Starting Harper on port ${httpPort}...`);
3464
3471
  const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
3465
3472
  proc.unref();
3473
+ // flair#1454: write the identity sidecar immediately after spawn so
3474
+ // `flair stop` and `flair status` can classify this daemon's state
3475
+ // without lsof. Same call as startFlairProcess() uses.
3476
+ if (proc.pid)
3477
+ writeDaemonSidecar(dataDir, proc.pid, httpPort);
3466
3478
  }
3467
3479
  console.log("Waiting for Harper health check...");
3468
3480
  await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
@@ -3864,6 +3876,18 @@ program
3864
3876
  }
3865
3877
  wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
3866
3878
  console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
3879
+ // Codex SessionStart hook (flair#1148 / #1439) — the hook is not
3880
+ // optional on Codex (no CLAUDE.md alternative). Init is the
3881
+ // consent to set up the client, same as the Claude Code hook
3882
+ // applied above. --skip-hook opts out and prints the JSON.
3883
+ if (clientId === "codex" && result.ok) {
3884
+ const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
3885
+ console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
3886
+ if (hookResult.hint) {
3887
+ for (const line of hookResult.hint.split("\n"))
3888
+ console.log(` ${line}`);
3889
+ }
3890
+ }
3867
3891
  }
3868
3892
  }
3869
3893
  }
@@ -7030,22 +7054,40 @@ export async function runFederationSyncOnce(opts) {
7030
7054
  // batch. Closes the hub-relay forgery hole — see
7031
7055
  // resources/Federation.ts FederationSync.post's verification gate.
7032
7056
  //
7033
- // CONTRACT — must match Federation.ts's verification payload
7034
- // byte-for-byte: keys { v, table, id, data, updatedAt,
7035
- // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
7036
- // doesn't matter, but the field SET and values do. `v: 1` versions the
7037
- // canonical form itself: bump it on BOTH sides together if the signed
7038
- // field set ever changes, so an old signature fails closed instead of
7039
- // silently mis-verifying under a new form.
7057
+ // CONTRACT — must match reconstructRecordVerifyBody
7058
+ // (resources/federation-classify.ts) byte-for-byte. canonicalize()
7059
+ // sorts keys, so field ORDER doesn't matter, but the field SET and
7060
+ // values do. `v` versions the canonical form itself: a v:1
7061
+ // signature cannot verify as v:2 (principalId in the field set).
7040
7062
  //
7041
- // Additive/backward-compatible: pre-3a receivers don't read
7042
- // `signature`/`principalId` at all and merge exactly as before.
7043
- const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
7044
- const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
7045
- // Informational only (see principalIdFromRow) never verified by the
7046
- // receiver as proof of authorship. Omitted entirely when the row
7047
- // carries no write-time provenance stamp.
7063
+ // v: 2 puts principalId in the signed payload when the row carries
7064
+ // a provenance stamp, and puts `v` on the wire so Phase 1
7065
+ // receivers (`const v = record.v ?? 1`) don't default these
7066
+ // records back to 1. Soul/Agent/Relationship have no stamp and
7067
+ // omit principalId; Memory without a stamp also omits it (the
7068
+ // receiver then skips Memory as principal_mismatch absent is
7069
+ // not an accept).
7048
7070
  const principalId = principalIdFromRow(row);
7071
+ const signedPayload = {
7072
+ v: 2,
7073
+ table,
7074
+ id: row.id,
7075
+ data: row,
7076
+ updatedAt,
7077
+ originatorInstanceId,
7078
+ };
7079
+ if (principalId)
7080
+ signedPayload.principalId = principalId;
7081
+ const signature = signBody(signedPayload, secretKey);
7082
+ const sr = {
7083
+ v: 2,
7084
+ table,
7085
+ id: row.id,
7086
+ data: row,
7087
+ updatedAt,
7088
+ originatorInstanceId,
7089
+ signature,
7090
+ };
7049
7091
  if (principalId)
7050
7092
  sr.principalId = principalId;
7051
7093
  const srBytes = JSON.stringify(sr).length;
@@ -10276,6 +10318,7 @@ program
10276
10318
  .option("--project <name>", "Fabric component name for --target", "flair")
10277
10319
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
10278
10320
  .option("--yes", "Skip the confirmation prompt for --target")
10321
+ .option("--install-hooks", "Consent to installing missing SessionStart hooks (claude-code / Codex) during upgrade. The hook executes at every session start — upgrade will not write it unprompted. Interactive runs prompt; non-interactive runs state the gap and withhold ✅ unless this flag is passed.")
10279
10322
  .option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
10280
10323
  // ── flair#878 ─────────────────────────────────────────────────────────────
10281
10324
  // These existed on `flair deploy` but stopped at the upgrade boundary, so
@@ -10941,20 +10984,18 @@ program
10941
10984
  });
10942
10985
  const verdict = decideAfterVerify(verify, previousFlairVersion);
10943
10986
  if (verdict.kind === "ok") {
10944
- // flair#1022: the verified facts are unchanged and still stated — the
10945
- // upgrade did land. What changes is the MARKER and the claim around it.
10946
- // A run that ended up outside its process manager has not fully
10947
- // succeeded, so it does not get a ✅, and the line names the property
10948
- // that is wrong rather than only the ones that are right. The choice
10949
- // lives in renderVerifiedSummary so it is testable without performing an
10950
- // upgrade — no CI lane runs this darwin path.
10951
- const summary = renderVerifiedSummary(verify.version, management);
10952
- for (const line of summary.lines) {
10953
- if (summary.degraded)
10954
- console.error(line);
10955
- else
10956
- console.log(line);
10957
- }
10987
+ // flair#1439: the success marker is the doctor runner's verdict, not
10988
+ // a second, narrower notion of "verified". Launchd detach is one
10989
+ // catalog member; the Codex SessionStart hook is another. Adding a
10990
+ // doctor check widens this claim automatically.
10991
+ const run = await doctorRunAfterUpgrade({
10992
+ management,
10993
+ port,
10994
+ installHooksFlag: !!opts.installHooks,
10995
+ fromVersion: previousFlairVersion,
10996
+ toVersion: expectedFlairVersion,
10997
+ });
10998
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run));
10958
10999
  return;
10959
11000
  }
10960
11001
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -10966,21 +11007,25 @@ program
10966
11007
  // "print an honest note but roll back anyway" branch that used to sit below
10967
11008
  // is gone — that credentials case can no longer reach the rollback path.)
10968
11009
  if (verdict.kind === "healthy-unverified") {
10969
- // flair#1022: same rule as the "ok" branch above the is withheld
10970
- // when the run left the instance outside launchd, and the reason is
10971
- // named. This branch already qualifies the version claim; the process
10972
- // manager is a second, independent qualification.
10973
- console.log(detached
10974
- ? `⚠️ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}, but NOT under launchd.`
10975
- : `✅ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}.`);
11010
+ // Same doctor runner as the "ok" branch — an unverified version must
11011
+ // not restore the unqualified while a catalog member is failing.
11012
+ const run = await doctorRunAfterUpgrade({
11013
+ management,
11014
+ port,
11015
+ installHooksFlag: !!opts.installHooks,
11016
+ fromVersion: previousFlairVersion,
11017
+ toVersion: expectedFlairVersion,
11018
+ });
11019
+ const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
11020
+ if (run.healthy) {
11021
+ console.log(`✅ upgrade complete: the instance is up and healthy${versionNote}.`);
11022
+ }
11023
+ else {
11024
+ printVerifiedSummary(renderVerifiedSummary(verify.version, run, { authenticated: false }));
11025
+ }
10976
11026
  console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
10977
11027
  console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
10978
11028
  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.");
10979
- if (detached) {
10980
- for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
10981
- console.error(line);
10982
- }
10983
- }
10984
11029
  return;
10985
11030
  }
10986
11031
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -10991,6 +11036,240 @@ program
10991
11036
  }
10992
11037
  await rollbackTo(verdict.toVersion, verdict.reason);
10993
11038
  });
11039
+ /**
11040
+ * Read a file with O_NOFOLLOW so a symlink planted at the pidfile/sidecar path
11041
+ * cannot redirect the read (flair#1454 decision 6). `readFileSync` has no such
11042
+ * flag, so this opens the fd first and reads from it.
11043
+ */
11044
+ function readFileNoFollow(path) {
11045
+ let fd;
11046
+ try {
11047
+ fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
11048
+ }
11049
+ catch (err) {
11050
+ if (err?.code === "ENOENT")
11051
+ return { kind: "absent" };
11052
+ if (err?.code === "ELOOP")
11053
+ return { kind: "unreadable", reason: `${path} is a symbolic link` };
11054
+ return { kind: "unreadable", reason: `cannot open ${path}: ${err?.code ?? err?.message}` };
11055
+ }
11056
+ try {
11057
+ return { kind: "present", content: readFileSync(fd, "utf-8") };
11058
+ }
11059
+ catch (err) {
11060
+ return { kind: "unreadable", reason: `cannot read ${path}: ${err?.code ?? err?.message}` };
11061
+ }
11062
+ finally {
11063
+ try {
11064
+ closeSync(fd);
11065
+ }
11066
+ catch { /* already closed */ }
11067
+ }
11068
+ }
11069
+ /**
11070
+ * Refuse to trust a data dir that is a symlink or world-writable (flair#1454
11071
+ * decision 6). Returns a reason, or null when the dir is safe (or absent — a
11072
+ * missing dir is "no data", not "unsafe"; the pidfile read reports absent).
11073
+ */
11074
+ function checkDataDirSafe(dataDir) {
11075
+ let lst;
11076
+ try {
11077
+ lst = lstatSync(dataDir);
11078
+ }
11079
+ catch {
11080
+ return null;
11081
+ }
11082
+ if (lst.isSymbolicLink()) {
11083
+ return `data directory ${dataDir} is a symbolic link — refusing to trust its pidfile`;
11084
+ }
11085
+ let st;
11086
+ try {
11087
+ st = statSync(dataDir);
11088
+ }
11089
+ catch {
11090
+ return null;
11091
+ }
11092
+ if (st.mode & 0o002) {
11093
+ return `data directory ${dataDir} is world-writable — refusing to trust its pidfile`;
11094
+ }
11095
+ return null;
11096
+ }
11097
+ /** Read `hdb.pid` (O_NOFOLLOW) into a `PidfileRead`. */
11098
+ function readPidfile(dataDir) {
11099
+ const r = readFileNoFollow(join(dataDir, "hdb.pid"));
11100
+ if (r.kind !== "present")
11101
+ return r;
11102
+ const n = Number(r.content.trim());
11103
+ if (!Number.isInteger(n) || n <= 0) {
11104
+ return { kind: "unreadable", reason: `${join(dataDir, "hdb.pid")} does not contain a valid pid` };
11105
+ }
11106
+ return { kind: "present", pid: n };
11107
+ }
11108
+ /** Read `flair-daemon.json` (O_NOFOLLOW) into a `SidecarRead`. */
11109
+ function readSidecar(dataDir) {
11110
+ const r = readFileNoFollow(join(dataDir, "flair-daemon.json"));
11111
+ if (r.kind !== "present")
11112
+ return r;
11113
+ const parsed = parseSidecarJson(r.content);
11114
+ if (parsed === null) {
11115
+ return { kind: "unreadable", reason: `${join(dataDir, "flair-daemon.json")} is malformed` };
11116
+ }
11117
+ return { kind: "present", ...parsed };
11118
+ }
11119
+ /** `kill(pid, 0)` as a three-way: alive / gone (ESRCH) / eperm (another user's). */
11120
+ function probePidLiveness(pid) {
11121
+ try {
11122
+ process.kill(pid, 0);
11123
+ return { kind: "alive" };
11124
+ }
11125
+ catch (err) {
11126
+ if (err?.code === "ESRCH")
11127
+ return { kind: "gone" };
11128
+ if (err?.code === "EPERM")
11129
+ return { kind: "eperm" };
11130
+ return { kind: "gone" };
11131
+ }
11132
+ }
11133
+ /**
11134
+ * The live process's start time in epoch ms, or null when it cannot be read.
11135
+ * Linux reads `/proc/<pid>/stat` field 22 (starttime in clock ticks) plus
11136
+ * `/proc/uptime`; macOS shells out to `ps -o lstart=`. A null answer degrades
11137
+ * to "identity unverified" — it never decides a verdict toward the destructive
11138
+ * branch (flair#1454 decision 4).
11139
+ */
11140
+ function readProcessStartTimeMs(pid) {
11141
+ if (process.platform === "linux") {
11142
+ try {
11143
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
11144
+ const starttime = parseProcStatStartTime(stat);
11145
+ if (starttime === null)
11146
+ return null;
11147
+ const uptimeRaw = readFileSync("/proc/uptime", "utf-8").trim().split(/\s+/)[0];
11148
+ const uptime = Number(uptimeRaw);
11149
+ if (!Number.isFinite(uptime))
11150
+ return null;
11151
+ return procStartTimeToEpochMs(starttime, uptime, Date.now());
11152
+ }
11153
+ catch {
11154
+ return null;
11155
+ }
11156
+ }
11157
+ if (process.platform === "darwin") {
11158
+ try {
11159
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
11160
+ encoding: "utf-8",
11161
+ env: { ...process.env, LC_ALL: "C" },
11162
+ timeout: 2000,
11163
+ });
11164
+ return parsePsLstart(out);
11165
+ }
11166
+ catch {
11167
+ return null;
11168
+ }
11169
+ }
11170
+ return null;
11171
+ }
11172
+ /** The health probe, three-way: ok / refused (ECONNREFUSED) / unreachable. */
11173
+ async function probeHealth(port) {
11174
+ try {
11175
+ await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
11176
+ return { kind: "ok" };
11177
+ }
11178
+ catch (err) {
11179
+ // Node's undici fetch reports ECONNREFUSED on `err.cause.code`; Bun reports
11180
+ // `ConnectionRefused` on `err.code`. Both mean "nothing is listening".
11181
+ const code = err?.cause?.code ?? err?.code;
11182
+ if (code === "ECONNREFUSED" || code === "ConnectionRefused") {
11183
+ return { kind: "refused" };
11184
+ }
11185
+ return { kind: "unreachable" };
11186
+ }
11187
+ }
11188
+ /** Gather every piece of evidence the classifier needs, in one place. */
11189
+ async function gatherDaemonEvidence(port, dataDir) {
11190
+ const dataDirUnsafe = checkDataDirSafe(dataDir);
11191
+ const pidfile = readPidfile(dataDir);
11192
+ const pidLiveness = pidfile.kind === "present" ? probePidLiveness(pidfile.pid) : null;
11193
+ let sidecar = readSidecar(dataDir);
11194
+ // Probe health first so the self-heal gate below can use it without a
11195
+ // second round-trip. Also consumed at the end for the classifier.
11196
+ const health = await probeHealth(port);
11197
+ // flair#1454 self-heal: a daemon started by a pre-sidecar version of flair
11198
+ // (upgrade-across-#1454) has a live pid in hdb.pid but no flair-daemon.json.
11199
+ // Without this path, classifyDaemonState returns DISAGREEMENT and `flair stop`
11200
+ // refuses — breaking the upgrade flow for every existing user.
11201
+ //
11202
+ // SECURITY: the real guard is /Health, NOT the ±2s start-time check.
11203
+ // The ±2s check is circular in the self-heal path: we write the sidecar
11204
+ // with the live process's OWN start time, then verifyIdentity reads the
11205
+ // same process — it matches by construction for ANY live pid, including a
11206
+ // recycled pid belonging to an unrelated process. Requiring /Health OK is
11207
+ // the correct proof: only the flair daemon responds 200 at
11208
+ // http://127.0.0.1:<port>/Health. An unrelated recycled pid does not.
11209
+ //
11210
+ // Therefore: self-heal is gated on health.kind === "ok". A live pid that
11211
+ // does NOT serve /Health — a recycled pid, a wedged pre-#1454 daemon that
11212
+ // can no longer respond — is left as DISAGREEMENT (refuse to signal).
11213
+ // Never WEDGED, never SIGTERM, on an unverified pid.
11214
+ //
11215
+ // The write uses the same O_NOFOLLOW / 0600 / atomic-rename posture as every
11216
+ // other sidecar write. We skip self-heal when the dataDir is unsafe
11217
+ // (symlink / world-writable) — the check has already happened above.
11218
+ if (sidecar.kind === "absent" &&
11219
+ dataDirUnsafe === null &&
11220
+ pidfile.kind === "present" &&
11221
+ pidLiveness?.kind === "alive" &&
11222
+ health.kind === "ok" // ← the real proof: only flair serves this
11223
+ ) {
11224
+ const pid = pidfile.pid;
11225
+ const startTimeMs = readProcessStartTimeMs(pid);
11226
+ if (startTimeMs !== null) {
11227
+ try {
11228
+ writeDaemonSidecar(dataDir, pid, port, startTimeMs);
11229
+ // Re-read: now that the sidecar exists, classify through the normal path.
11230
+ sidecar = readSidecar(dataDir);
11231
+ }
11232
+ catch {
11233
+ // Self-heal is best-effort. If the write fails (e.g. read-only dataDir),
11234
+ // we proceed with sidecar === absent and fall through to DISAGREEMENT
11235
+ // — the same outcome as before the self-heal path, so no regression.
11236
+ }
11237
+ }
11238
+ }
11239
+ const identity = verifyIdentity({
11240
+ pidfilePid: pidfile.kind === "present" ? pidfile.pid : null,
11241
+ sidecar,
11242
+ readStartTime: readProcessStartTimeMs,
11243
+ });
11244
+ return { dataDirUnsafe, pidfile, pidLiveness, identity, health };
11245
+ }
11246
+ /**
11247
+ * Write the identity sidecar atomically (temp + rename) at spawn time or
11248
+ * during self-heal (flair#1454 decision 3). `pid` is the spawned process's
11249
+ * pid — the same number Harper writes to `hdb.pid`, since Harper runs
11250
+ * in-process. `startTimeMs` defaults to `Date.now()` for a fresh spawn.
11251
+ *
11252
+ * Self-heal callers pass the live process's actual start time (from
11253
+ * readProcessStartTimeMs) so the sidecar records an accurate epoch, not a
11254
+ * wall-clock approximation. Note: in the self-heal path the ±2s start-time
11255
+ * check in verifyIdentity is NOT what prevents recycled-pid adoption —
11256
+ * that guard is the /Health probe that the self-heal caller already required
11257
+ * before reaching this point. The start time is recorded faithfully for
11258
+ * forward compatibility and audit, not as a security gate here.
11259
+ */
11260
+ function writeDaemonSidecar(dataDir, pid, port, startTimeMs = Date.now()) {
11261
+ const sidecar = { pid, startTimeMs, port, flairVersion: __pkgVersion };
11262
+ const tmpPath = join(dataDir, `.flair-daemon.json.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
11263
+ // Write with mode 0600 so the tmp file is never world-readable (flair#1454
11264
+ // decision 6 — same posture as admin-pass and key material).
11265
+ writeFileSync(tmpPath, JSON.stringify(sidecar, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
11266
+ const finalPath = join(dataDir, "flair-daemon.json");
11267
+ renameSync(tmpPath, finalPath);
11268
+ // Re-assert 0600 after the rename: rename preserves the tmp permissions but
11269
+ // a pre-existing file at the destination retains its original mode on some
11270
+ // kernels. An explicit chmod is the only guarantee (flair#1454 decision 6).
11271
+ chmodSync(finalPath, 0o600);
11272
+ }
10994
11273
  // ─── flair stop ───────────────────────────────────────────────────────────────
10995
11274
  program
10996
11275
  .command("stop")
@@ -11016,49 +11295,55 @@ program
11016
11295
  }
11017
11296
  }
11018
11297
  }
11019
- // Fallback: find process by port. Listening sockets only, never our own
11020
- // PID see parseListeningPids (flair#800/flair#905): this used to SIGTERM
11021
- // every process holding ANY socket on the port, so `flair stop` could kill
11022
- // itself (leaving Flair running) or kill an unrelated client of it.
11023
- //
11024
- // Attribution guard (flair#915): the port is not an identity. Refuse to
11025
- // SIGTERM a PID that cannot be attributed to this instance.
11026
- try {
11027
- const { execSync } = await import("node:child_process");
11028
- const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
11029
- if (pids.length > 0) {
11030
- const dataDir = defaultDataDir();
11031
- const harperPid = readHarperPid(dataDir);
11032
- if (harperPid !== null && !pids.includes(harperPid)) {
11033
- console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
11034
- + `do not match this Flair instance (PID ${harperPid}). `
11035
- + `Not stopping — cannot attribute the process to this instance. `
11036
- + `Stop the process manually if it is not Flair.`);
11037
- process.exit(1);
11298
+ // Non-launchd: the five-state liveness machine (flair#1454). The old
11299
+ // decision tree (launchd -> lsof -> "not running") is REPLACED, not
11300
+ // patched: `lsof` absence used to render as a definite "not running", and
11301
+ // the pidfile was only consulted to attribute port-derived PIDs. Now the
11302
+ // pidfile + identity sidecar are the primary evidence, and the health
11303
+ // probe is a cross-check never the verdict.
11304
+ const dataDir = defaultDataDir();
11305
+ const evidence = await gatherDaemonEvidence(port, dataDir);
11306
+ const state = classifyDaemonState(evidence, { port, dataDir });
11307
+ switch (state.state) {
11308
+ case "RUNNING":
11309
+ case "WEDGED": {
11310
+ // Identity is already proven for both of these — killing a wedged
11311
+ // daemon is recovery, not a recycled-PID gamble.
11312
+ const pid = state.pid;
11313
+ const label = state.state === "WEDGED" ? "wedged daemon" : "daemon";
11314
+ try {
11315
+ process.kill(pid, "SIGTERM");
11038
11316
  }
11039
- else if (harperPid === null) {
11040
- console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
11041
- + `but no PID file in data directory — not a running Flair instance. `
11042
- + `Not stopping — cannot attribute the process to this instance. `
11043
- + `Stop the process manually if it is not Flair.`);
11044
- process.exit(1);
11317
+ catch (err) {
11318
+ if (err?.code !== "ESRCH") {
11319
+ console.error(`❌ failed to signal pid ${pid}: ${err?.code ?? err?.message}`);
11320
+ process.exit(1);
11321
+ }
11322
+ }
11323
+ await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
11324
+ const after = await probeHealth(port);
11325
+ if (after.kind === "refused") {
11326
+ console.log(`✅ Flair stopped (${label}, pid ${pid})`);
11045
11327
  }
11046
11328
  else {
11047
- for (const pid of pids) {
11048
- try {
11049
- process.kill(pid, "SIGTERM");
11050
- }
11051
- catch { /* already gone */ }
11052
- }
11053
- console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
11329
+ console.log(`✅ Flair stopped (${label}, pid ${pid}; port ${port} may still be releasing)`);
11054
11330
  }
11331
+ return;
11055
11332
  }
11056
- else {
11333
+ case "NOT_RUNNING":
11057
11334
  console.log("Flair is not running.");
11058
- }
11059
- }
11060
- catch {
11061
- console.log("Flair is not running (nothing found on port " + port + ").");
11335
+ return;
11336
+ case "DISAGREEMENT":
11337
+ console.error(`⚠️ ${state.detail}`);
11338
+ console.error(` Not stopping the evidence conflicts.`);
11339
+ console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
11340
+ console.error(` port: ${port}`);
11341
+ console.error(` To inspect: flair doctor`);
11342
+ process.exit(1);
11343
+ case "UNKNOWN":
11344
+ console.error(`⚠️ ${state.detail}`);
11345
+ console.error(` Not stopping — could not determine whether Flair is running.`);
11346
+ process.exit(1);
11062
11347
  }
11063
11348
  });
11064
11349
  // ─── flair start ──────────────────────────────────────────────────────────────
@@ -11068,16 +11353,35 @@ program
11068
11353
  .option("--port <port>", "Harper HTTP port")
11069
11354
  .action(async (opts) => {
11070
11355
  const port = resolveHttpPort(opts);
11071
- // Check if already running
11072
- try {
11073
- const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
11074
- if (res.status > 0) {
11075
- console.log(`Flair is already running on port ${port}.`);
11076
- return;
11077
- }
11078
- }
11079
- catch { /* not running — good */ }
11080
11356
  const dataDir = defaultDataDir();
11357
+ // Already-running check via the five-state liveness machine (flair#1454).
11358
+ // The old check was a bare `fetch /Health` that treated "got a response"
11359
+ // as "already running" and exited 0 — half of #1454. Now the machine
11360
+ // classifies, and every non-NOT_RUNNING state refuses with a non-zero exit.
11361
+ const evidence = await gatherDaemonEvidence(port, dataDir);
11362
+ const state = classifyDaemonState(evidence, { port, dataDir });
11363
+ switch (state.state) {
11364
+ case "NOT_RUNNING":
11365
+ break; // proceed to boot
11366
+ case "RUNNING":
11367
+ console.error(`Flair is already running on port ${port} (pid ${state.pid}).`);
11368
+ process.exit(1);
11369
+ case "WEDGED":
11370
+ console.error(`⚠️ A wedged Flair daemon (pid ${state.pid}) is holding port ${port}.`);
11371
+ console.error(` Run 'flair stop' first — never start over a live pid.`);
11372
+ process.exit(1);
11373
+ case "DISAGREEMENT":
11374
+ console.error(`⚠️ ${state.detail}`);
11375
+ console.error(` Refusing to start — the evidence conflicts.`);
11376
+ console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
11377
+ console.error(` port: ${port}`);
11378
+ console.error(` To inspect: flair doctor`);
11379
+ process.exit(1);
11380
+ case "UNKNOWN":
11381
+ console.error(`⚠️ ${state.detail}`);
11382
+ console.error(` Refusing to start — could not determine whether Flair is running.`);
11383
+ process.exit(1);
11384
+ }
11081
11385
  if (!existsSync(dataDir)) {
11082
11386
  console.error("❌ No Flair data directory found. Run 'flair init' first.");
11083
11387
  process.exit(1);
@@ -11155,6 +11459,12 @@ program
11155
11459
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
11156
11460
  });
11157
11461
  proc.unref();
11462
+ // Write the identity sidecar immediately after spawn (flair#1454 decision
11463
+ // 3) — BEFORE waitForHealth, so startTimeMs stays within the ±2s tolerance
11464
+ // of the process's real start time. `proc.pid` is the pid Harper writes to
11465
+ // hdb.pid, since Harper runs in-process.
11466
+ if (proc.pid)
11467
+ writeDaemonSidecar(dataDir, proc.pid, port);
11158
11468
  try {
11159
11469
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
11160
11470
  readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
@@ -11217,51 +11527,6 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
11217
11527
  `Re-run with --data-dir ${resolve(declared)} to act on that one, or run ` +
11218
11528
  `'flair init --data-dir ${resolve(dataDir)}' to register a service for this one.`);
11219
11529
  }
11220
- /**
11221
- * Refuse a port-based SIGTERM that cannot be attributed to `dataDir`
11222
- * (flair#902).
11223
- *
11224
- * The port fallback below identifies its target by port number and nothing
11225
- * else, so `--data-dir <scratch>` with a port that scratch instance does not
11226
- * serve signals whichever instance DOES serve it. That is the whole of this
11227
- * bug on Linux, where there is no launchd path at all.
11228
- *
11229
- * Scoped deliberately to a non-default data dir. For the default install the
11230
- * port genuinely is that instance's port by every convention in this CLI
11231
- * (`writeConfig`/`readPortFromConfig`), and the only evidence available here
11232
- * — `<dataDir>/hdb.pid` vs the listening PIDs — is not something we can
11233
- * require without risking a false refusal on a working install whose PID file
11234
- * is missing or whose listener is a worker. So the default path keeps today's
11235
- * behavior exactly, and the residual gap is stated rather than papered over:
11236
- * a default-dir port stop is still unattributed. The new refusal can only
11237
- * fire for a caller that explicitly named another data dir — the case that is
11238
- * wrong today whenever the port does not match.
11239
- */
11240
- function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
11241
- // (flair#915) Apply the attribution check for ALL data directories, not
11242
- // just non-default ones. The default-dir bypass was the residual gap that
11243
- // #910 left behind — it allowed an unattributed SIGTERM on the default
11244
- // install's port. The old concern (false refusal when hdb.pid is missing)
11245
- // is actually the RIGHT behavior: no PID file means we cannot attribute the
11246
- // listener, so we refuse. That is safer than killing the wrong process.
11247
- const expected = readHarperPid(dataDir);
11248
- // No PID file — Harper is not (or was not) running in this directory.
11249
- // The port is stale or held by something else; refuse to SIGTERM it.
11250
- if (expected === null) {
11251
- throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
11252
- + `${resolve(dataDir)}, so that is not a running instance. `
11253
- + `Stopping by port alone would signal a process we cannot attribute. `
11254
- + `If it is not Flair, stop it manually.`);
11255
- }
11256
- // PID file exists — the PID on the port must be Harper.
11257
- if (listeningPids.includes(expected))
11258
- return;
11259
- throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
11260
- + `is not the process listening on ${port}. `
11261
- + `Stopping by port alone would signal a different instance. `
11262
- + `Pass --port with the port ${resolve(dataDir)} actually serves, `
11263
- + `or stop the process manually.`);
11264
- }
11265
11530
  // ─── "is it still under launchd?" (flair#1022) ─────────────────────────────
11266
11531
  //
11267
11532
  // The pure logic lives in src/lib/launchd-management.ts; these two adapters
@@ -11299,6 +11564,117 @@ function resolveInstanceServingPid(dataDir, port) {
11299
11564
  listeningPids,
11300
11565
  });
11301
11566
  }
11567
+ /** Agent signing-key ids under `keysDir` — node-scoped federation keys excluded. */
11568
+ function collectKeyAgentIds(keysDir) {
11569
+ if (!existsSync(keysDir))
11570
+ return [];
11571
+ try {
11572
+ const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
11573
+ const { agentKeyIds } = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
11574
+ return agentKeyIds;
11575
+ }
11576
+ catch {
11577
+ return [];
11578
+ }
11579
+ }
11580
+ async function confirmYes(question) {
11581
+ if (!process.stdin.isTTY)
11582
+ return false;
11583
+ const { createInterface } = await import("node:readline");
11584
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11585
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
11586
+ return /^y(es)?$/i.test(answer.trim());
11587
+ }
11588
+ /**
11589
+ * flair#1439 — run the enumerable doctor catalog after upgrade's instance
11590
+ * probe, and offer a consented SessionStart-hook install when that check
11591
+ * fails. Silent writes are refused: `--install-hooks` or an interactive
11592
+ * yes is the only consent. The consent→write composition lives in
11593
+ * applyUpgradeHookConsent so tests can drive the path that actually
11594
+ * writes (or does not write) the hook file.
11595
+ *
11596
+ * Before the catalog run, `applyUpgradeMigrations` fires any version-keyed
11597
+ * migrations that are pending for the fromVersion→toVersion pair. These do
11598
+ * NOT require `--install-hooks` because the user already consented to the
11599
+ * affected integration when they ran `flair init` — the migration just
11600
+ * applies a new artifact that the old init could not have written.
11601
+ */
11602
+ async function doctorRunAfterUpgrade(args) {
11603
+ const homeDir = homedir();
11604
+ const keysDir = defaultKeysDir();
11605
+ const detectedClientIds = detectClients().filter((c) => c.detected).map((c) => c.id);
11606
+ // ── Version-keyed upgrade migrations (flair#1439) ─────────────────────────
11607
+ // Apply any pending migrations BEFORE the doctor catalog run so that the
11608
+ // catalog sees the post-migration state (e.g. the hook is present, so the
11609
+ // session-start-hook check passes and the upgrade prints ✅ verified: healthy).
11610
+ const migCtx = {
11611
+ homeDir,
11612
+ port: args.port,
11613
+ detectedClientIds,
11614
+ };
11615
+ const migrations = applyUpgradeMigrations(args.fromVersion, args.toVersion, migCtx);
11616
+ for (const { results } of migrations.applied) {
11617
+ for (const r of results) {
11618
+ if (r.wrote) {
11619
+ console.log(` ✓ ${r.message}`);
11620
+ }
11621
+ else if (!r.ok) {
11622
+ console.error(` • ${r.message}`);
11623
+ }
11624
+ // Silently skip no-op (ok, !wrote) — nothing changed, nothing to say.
11625
+ }
11626
+ }
11627
+ if (!migrations.allOk) {
11628
+ // A migration reported a non-fatal issue (each is logged above). Say so
11629
+ // plainly rather than let the upcoming ✅ verified summary imply the upgrade
11630
+ // finished cleanly — the doctor catalog below reflects the real state.
11631
+ console.error(" • one or more upgrade migrations did not complete cleanly — the doctor check below shows the current state.");
11632
+ }
11633
+ const ctx = {
11634
+ homeDir,
11635
+ cwd: process.cwd(),
11636
+ detectedClientIds,
11637
+ launchd: args.management,
11638
+ keysDir,
11639
+ keyAgentIds: collectKeyAgentIds(keysDir),
11640
+ };
11641
+ const run = runDoctorChecks(ctx);
11642
+ const apply = (promptAccepted) => applyUpgradeHookConsent({
11643
+ homeDir,
11644
+ ctx,
11645
+ run,
11646
+ installHooksFlag: args.installHooksFlag,
11647
+ interactive: !!process.stdin.isTTY,
11648
+ promptAccepted,
11649
+ port: args.port,
11650
+ });
11651
+ let outcome = apply();
11652
+ if (outcome.consent === "prompt" && outcome.prompt) {
11653
+ console.log("");
11654
+ for (const line of outcome.prompt.preamble)
11655
+ console.log(` ${line}`);
11656
+ outcome = apply(await confirmYes(outcome.prompt.question));
11657
+ }
11658
+ if (outcome.consent === "install") {
11659
+ for (const w of outcome.writes) {
11660
+ console.log(` ${w.ok ? "✓" : "•"} ${w.message}`);
11661
+ }
11662
+ }
11663
+ if (outcome.consent === "skip-noninteractive") {
11664
+ for (const line of outcome.messages) {
11665
+ console.error(` ${line}`);
11666
+ }
11667
+ }
11668
+ return outcome.run;
11669
+ }
11670
+ function printVerifiedSummary(summary) {
11671
+ for (const line of summary.lines) {
11672
+ if (summary.degraded)
11673
+ console.error(line);
11674
+ else
11675
+ console.log(line);
11676
+ }
11677
+ }
11302
11678
  /**
11303
11679
  * Observe whether `dataDir`'s instance is running under launchd right now.
11304
11680
  *
@@ -11343,13 +11719,13 @@ function observeLaunchdManagement(dataDir, port) {
11343
11719
  * data dir does, via `resolveLaunchdLabel`.
11344
11720
  *
11345
11721
  * Idempotent-ish: stopping an already-stopped instance is a harmless no-op
11346
- * on both paths (launchctl stop on an unloaded/idle service, or an empty
11347
- * `lsof` match).
11722
+ * on both paths (launchctl stop on an unloaded/idle service, or a
11723
+ * NOT_RUNNING classification from the liveness machine).
11348
11724
  *
11349
11725
  * Throws when the resolved target provably belongs to a different instance
11350
- * — see assertLaunchdServiceOwnedBy / assertPortInstanceOwnedBy. Callers
11351
- * already treat a failed stop as fatal, which is the point: refusing beats
11352
- * quiescing the wrong install.
11726
+ * — see assertLaunchdServiceOwnedBy, or a DISAGREEMENT/UNKNOWN verdict from
11727
+ * the liveness machine. Callers already treat a failed stop as fatal, which
11728
+ * is the point: refusing beats quiescing the wrong install.
11353
11729
  */
11354
11730
  async function stopFlairProcess(port, dataDir) {
11355
11731
  if (process.platform === "darwin") {
@@ -11421,43 +11797,42 @@ async function stopFlairProcess(port, dataDir) {
11421
11797
  }
11422
11798
  }
11423
11799
  }
11424
- // Port-based stop (Linux, or macOS fallback when no launchd plist)
11800
+ // Port-based stop (Linux, or macOS fallback when no launchd plist) — the
11801
+ // five-state liveness machine (flair#1454). The old lsof-based tree is
11802
+ // REPLACED, not patched: `lsof` absence used to render as "not running", and
11803
+ // the pidfile was only consulted to attribute port-derived PIDs. Now the
11804
+ // pidfile + identity sidecar are the primary evidence, and the health probe
11805
+ // is a cross-check — never the verdict.
11425
11806
  console.log("Stopping...");
11426
- const { execSync } = await import("node:child_process");
11427
- // -sTCP:LISTEN plus a self-PID guard, both inside listeningPidsOnPort: a bare
11428
- // `lsof -ti :port` also matches CLIENT sockets referencing the port, including
11429
- // THIS CLI's own keep-alive connections left by the credential pre-flight's
11430
- // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
11431
- // path SIGTERM'd its own process mid-restart"Stopping..." then death
11432
- // (exit 143) before "Starting..." ever ran, leaving the server down
11433
- // (flair#800, deterministic on the Linux/non-launchd default path).
11434
- // flair#905 moved both halves into that one helper because the same
11435
- // unfiltered pattern had survived in `flair stop`, `flair uninstall` and
11436
- // `flair doctor` — one guarded resolver is what keeps the next site honest.
11437
- // It returns [] when lsof matches nothing, which is this path's "not running".
11438
- const targets = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
11439
- if (targets.length === 0)
11440
- return;
11441
- // Deliberately outside any catch: a refusal must reach the caller, not be
11442
- // swallowed as "not running" and reported as a successful stop.
11443
- assertPortInstanceOwnedBy(port, dataDir, targets);
11444
- for (const target of targets) {
11445
- try {
11446
- process.kill(target, "SIGTERM");
11447
- }
11448
- catch { }
11449
- }
11450
- // flair#905 / lrf5: wait for every signalled process to actually exit.
11451
- // A blind 2-second sleep is not a guarantee — Harper may be flushing
11452
- // RocksDB WAL/MANIFEST, and the next start will fail with a locked data
11453
- // directory if the old process hasn't released it yet. The launchd path
11454
- // above already does this via waitForProcessExit; the port-based path
11455
- // must match that guarantee.
11456
- for (const target of targets) {
11457
- try {
11458
- await waitForProcessExit(target, STARTUP_TIMEOUT_MS);
11807
+ const evidence = await gatherDaemonEvidence(port, dataDir);
11808
+ const state = classifyDaemonState(evidence, { port, dataDir });
11809
+ switch (state.state) {
11810
+ case "RUNNING":
11811
+ case "WEDGED": {
11812
+ // Identity is already proven for bothkilling a wedged daemon is
11813
+ // recovery, not a recycled-PID gamble.
11814
+ const pid = state.pid;
11815
+ try {
11816
+ process.kill(pid, "SIGTERM");
11817
+ }
11818
+ catch { /* already gone */ }
11819
+ // flair#905 / lrf5: wait for the signalled process to actually exit. A
11820
+ // blind sleep is not a guarantee — Harper may be flushing RocksDB
11821
+ // WAL/MANIFEST, and the next start fails with a locked data directory if
11822
+ // the old process hasn't released it yet.
11823
+ try {
11824
+ await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
11825
+ }
11826
+ catch { /* best-effort — the next start will surface the real problem */ }
11827
+ return;
11459
11828
  }
11460
- catch { /* best-effort — the next start will surface the real problem */ }
11829
+ case "NOT_RUNNING":
11830
+ return; // idempotent no-op
11831
+ case "DISAGREEMENT":
11832
+ case "UNKNOWN":
11833
+ // Deliberately outside any catch: a refusal must reach the caller, not
11834
+ // be swallowed as "not running" and reported as a successful stop.
11835
+ throw new Error(`refusing to stop: ${state.detail}`);
11461
11836
  }
11462
11837
  }
11463
11838
  /**
@@ -11586,6 +11961,10 @@ async function startFlairProcess(port, dataDir) {
11586
11961
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
11587
11962
  });
11588
11963
  proc.unref();
11964
+ // Identity sidecar immediately after spawn (flair#1454 decision 3), before
11965
+ // waitForHealth so startTimeMs stays within the ±2s tolerance.
11966
+ if (proc.pid)
11967
+ writeDaemonSidecar(dataDir, proc.pid, port);
11589
11968
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
11590
11969
  readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
11591
11970
  stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
@@ -13057,6 +13436,21 @@ program
13057
13436
  return /^y(es)?$/i.test(answer.trim());
13058
13437
  }
13059
13438
  const detectedClients = detectClients().filter((c) => c.detected);
13439
+ // flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
13440
+ // hook, verified-read plan, keys classification, launchd) is the same
13441
+ // catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
13442
+ // both. Extra doctor UX (pi, --fix, execution probe, continuity,
13443
+ // agent registration) stays below and does not redefine those checks.
13444
+ const doctorCtx = {
13445
+ homeDir: homedir(),
13446
+ cwd: process.cwd(),
13447
+ detectedClientIds: detectedClients.map((c) => c.id),
13448
+ launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
13449
+ keysDir,
13450
+ keyAgentIds,
13451
+ agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
13452
+ };
13453
+ const catalogBefore = runDoctorChecks(doctorCtx);
13060
13454
  if (detectedClients.length === 0) {
13061
13455
  console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
13062
13456
  }
@@ -13291,7 +13685,6 @@ program
13291
13685
  wireCursor(wireEnv);
13292
13686
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
13293
13687
  if (wireResult.ok) {
13294
- fixed++;
13295
13688
  if (client.id === "claude-code")
13296
13689
  claudeCodeAgentId = fixAgentId;
13297
13690
  if (client.id === "codex")
@@ -13311,7 +13704,6 @@ program
13311
13704
  const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
13312
13705
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
13313
13706
  }
13314
- issues++;
13315
13707
  continue;
13316
13708
  }
13317
13709
  console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
@@ -13369,15 +13761,12 @@ program
13369
13761
  else {
13370
13762
  const fixRes = fixClaudeMdBootstrap(process.cwd());
13371
13763
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13372
- if (fixRes.ok)
13373
- fixed++;
13374
13764
  }
13375
13765
  }
13376
13766
  }
13377
13767
  else {
13378
13768
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
13379
13769
  }
13380
- issues++;
13381
13770
  }
13382
13771
  // flair#1007: presence was never the problem — the failing entry was
13383
13772
  // perfectly well-formed. inspectSessionStartHook() additionally RUNS
@@ -13446,8 +13835,6 @@ program
13446
13835
  else {
13447
13836
  const upgrade = upgradeSessionStartHookCommand(homedir());
13448
13837
  console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13449
- if (upgrade.ok && upgrade.changed)
13450
- fixed++;
13451
13838
  }
13452
13839
  }
13453
13840
  }
@@ -13458,7 +13845,6 @@ program
13458
13845
  else {
13459
13846
  console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install`);
13460
13847
  }
13461
- issues++;
13462
13848
  }
13463
13849
  }
13464
13850
  else {
@@ -13476,15 +13862,12 @@ program
13476
13862
  const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
13477
13863
  const fixRes = fixSessionStartHook(homedir(), fixAgentId);
13478
13864
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13479
- if (fixRes.ok)
13480
- fixed++;
13481
13865
  }
13482
13866
  }
13483
13867
  }
13484
13868
  else {
13485
13869
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
13486
13870
  }
13487
- issues++;
13488
13871
  }
13489
13872
  // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
13490
13873
  // the SessionStart check above: installed / absent / stale-form).
@@ -13584,8 +13967,6 @@ program
13584
13967
  else {
13585
13968
  const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
13586
13969
  console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
13587
- if (upgrade.ok && upgrade.changed)
13588
- fixed++;
13589
13970
  }
13590
13971
  }
13591
13972
  }
@@ -13596,7 +13977,6 @@ program
13596
13977
  else {
13597
13978
  console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install --harness codex`);
13598
13979
  }
13599
- issues++;
13600
13980
  }
13601
13981
  }
13602
13982
  else {
@@ -13614,18 +13994,27 @@ program
13614
13994
  const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
13615
13995
  const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
13616
13996
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
13617
- if (fixRes.ok)
13618
- fixed++;
13619
13997
  }
13620
13998
  }
13621
13999
  }
13622
14000
  else {
13623
14001
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
13624
14002
  }
13625
- issues++;
13626
14003
  }
13627
14004
  }
13628
14005
  }
14006
+ // Catalog is the install-health verdict — count fail/unrun here, not
14007
+ // via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
14008
+ // --fix that cleared a catalog member shows up in the found→fixed delta.
14009
+ const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
14010
+ const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
14011
+ issues += catalogDelta.found;
14012
+ if (autoFix)
14013
+ fixed += catalogDelta.fixed;
14014
+ console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
14015
+ for (const row of renderCatalogDoctorLines(catalogAfter)) {
14016
+ console.log(` ${render.icons[row.icon]} ${row.line}`);
14017
+ }
13629
14018
  // 7a. Resolve which agent identities the two verified-read sections below
13630
14019
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
13631
14020
  // sections required --agent explicitly; doctor already enumerates every
@@ -15393,6 +15782,7 @@ memory.command("search [query]")
15393
15782
  .option("--q <query>", "search query (alias for positional arg)")
15394
15783
  .option("--limit <n>", "Max results", "5")
15395
15784
  .option("--tag <tag>")
15785
+ .option("--include-archived", "Include basemented (archived) memories in results (default: excluded)")
15396
15786
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
15397
15787
  .option("--url <url>", "Flair base URL (overrides --port)")
15398
15788
  .option("--port <port>", "Harper HTTP port")
@@ -15410,10 +15800,51 @@ memory.command("search [query]")
15410
15800
  const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
15411
15801
  if (opts.tag)
15412
15802
  body.tag = opts.tag;
15803
+ if (opts.includeArchived)
15804
+ body.includeArchived = true;
15413
15805
  const baseUrl = resolveBaseUrl(opts);
15414
15806
  const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
15415
15807
  console.log(JSON.stringify(res, null, 2));
15416
15808
  });
15809
+ // ─── flair memory basement / restore ────────────────────────────────────────
15810
+ // flair#1472 Deliverable A — the user-facing archive action. `basement` sends a
15811
+ // memory to the basement (archived=true + stamps archivedAt); `restore`
15812
+ // un-basements it (clears archived/archivedAt/archivedBy). Both are GLOBAL and
15813
+ // deliberate: restore un-retires the memory for EVERY session, not a
15814
+ // session-local view (per-session reuse is drawers, Deliverable B, which does
15815
+ // not exist yet). Scoped to the caller's own memories (own-lane write).
15816
+ memory.command("basement <id>")
15817
+ .description("Send a memory to the basement (archive it). Removes it from bootstrap + default search; still retrievable via `memory search --include-archived`. GLOBAL and deliberate — scoped to your own memories.")
15818
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
15819
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
15820
+ .option("--url <url>", "Flair base URL (overrides --port)")
15821
+ .option("--port <port>", "Harper HTTP port")
15822
+ .action(async (id, opts) => {
15823
+ const agentId = resolveSigningAgentId(opts, "memory basement");
15824
+ if (!agentId) {
15825
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15826
+ process.exit(2);
15827
+ }
15828
+ const baseUrl = resolveBaseUrl(opts);
15829
+ const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
15830
+ console.log(JSON.stringify(res, null, 2));
15831
+ });
15832
+ memory.command("restore <id>")
15833
+ .description("Restore a basemented (archived) memory. Clears archived/archivedAt/archivedBy. GLOBAL and deliberate — this un-retires the memory for EVERY session, not a session-local view (per-session reuse is drawers, which do not exist yet). Scoped to your own memories.")
15834
+ .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
15835
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
15836
+ .option("--url <url>", "Flair base URL (overrides --port)")
15837
+ .option("--port <port>", "Harper HTTP port")
15838
+ .action(async (id, opts) => {
15839
+ const agentId = resolveSigningAgentId(opts, "memory restore");
15840
+ if (!agentId) {
15841
+ console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
15842
+ process.exit(2);
15843
+ }
15844
+ const baseUrl = resolveBaseUrl(opts);
15845
+ const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
15846
+ console.log(JSON.stringify(res, null, 2));
15847
+ });
15417
15848
  memory.command("list")
15418
15849
  .description("List an agent's memories (optionally filtered by --tag or embedding-backfill triage)")
15419
15850
  .option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")