@tpsdev-ai/flair 0.51.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +13 -8
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -839,6 +839,41 @@ export function detectWiredFlairMcp(homeDir) {
839
839
  }
840
840
  return { wired, pinnedVersion };
841
841
  }
842
+ /** The `@tpsdev-ai/flair-mcp` version pinned in a client's MCP config, or null
843
+ * when the client is unwired or wired unpinned. */
844
+ export function readClientMcpPin(clientId, homeDir) {
845
+ const configPath = withHome(homeDir, () => clientConfigPath(clientId));
846
+ return extractFlairMcpPin(readTextFile(configPath) ?? "");
847
+ }
848
+ /**
849
+ * Compare a harness's SessionStart hook pin against its MCP client pin.
850
+ *
851
+ * `flair init`/`flair upgrade`/`flair hook install` keep both on the same
852
+ * @version. A skew means an upgrade moved the client block forward but left
853
+ * the hook behind (flair#1516) — so every session silently launches the OLD
854
+ * adapter while the client block advertises the new one. `flair doctor` used
855
+ * to report such a hook as wired "and still runs" without ever comparing the
856
+ * two pins.
857
+ *
858
+ * Only a concrete-vs-concrete difference is a skew: an unpinned hook (pre-
859
+ * #1143) or a missing pin on either side is "nothing to compare", never a
860
+ * false skew.
861
+ */
862
+ export function checkSessionStartHookPinSkew(homeDir, harness) {
863
+ const hookPath = harness === "codex"
864
+ ? join(homeDir, ".codex", "hooks.json")
865
+ : join(homeDir, ".claude", "settings.json");
866
+ const hook = checkSessionStartHook(homeDir, hookPath);
867
+ const hookWired = hook.present && isFlairHookCommand(hook.command ?? "");
868
+ const hookPin = hookWired ? extractFlairMcpPin(hook.command ?? "") : null;
869
+ const clientPin = readClientMcpPin(harness, homeDir);
870
+ return {
871
+ hookWired,
872
+ hookPin,
873
+ clientPin,
874
+ skewed: !!hookPin && !!clientPin && hookPin !== clientPin,
875
+ };
876
+ }
842
877
  /**
843
878
  * Merge-safe insert of a Flair SessionStart hook group into the harness
844
879
  * settings file (default ~/.claude/settings.json) — creates the file/array
@@ -55,6 +55,7 @@
55
55
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
56
56
  import { dirname, join } from "node:path";
57
57
  import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, buildContinuityCaptureHookCommand, checkContinuityCaptureHooks, computeContinuityHookInstall, computeContinuityHookRemoval, hookCommandIsSilenced, isHookCommandValueSafe, isSessionStartHookInvocation, readClientMcpBlock, } from "./doctor-client.js";
58
+ import { mcpServerSpec } from "./lib/mcp-spec.js";
58
59
  // ── harness registry ────────────────────────────────────────────────────────
59
60
  /** SessionStart hook harnesses. A second harness is an additive registry
60
61
  * entry, not a rewrite (Kern's #719 verdict: "a switch statement... is
@@ -352,6 +353,79 @@ export function installHook(opts) {
352
353
  backupPath, delta,
353
354
  };
354
355
  }
356
+ /**
357
+ * Re-pin an ALREADY-WIRED Flair SessionStart hook to the current
358
+ * mcpServerSpec(), preserving the agent id and Flair URL the entry already
359
+ * carries.
360
+ *
361
+ * This is `flair upgrade`'s hook counterpart to refreshing the MCP client
362
+ * pins (flair#1516). `flair upgrade` re-pins every wired client's MCP block
363
+ * to the new @version but used to leave the SessionStart hook command on the
364
+ * OLD one — so a user who upgraded by the documented path kept launching the
365
+ * previous adapter on every session, silently, while `flair doctor` reported
366
+ * the hook "still runs" without noticing the skew.
367
+ *
368
+ * NEVER adds a hook — a home with no Flair hook is a clean `skip`, not an
369
+ * `add`: wiring a hook is `flair init` / `flair hook install`, an opt-in the
370
+ * upgrade path must not make on the user's behalf. Only rewrites the exact
371
+ * canonical `npx -y -p …` invocation Flair itself writes (pinned or the
372
+ * pre-#1143 unpinned form); a hand-edited command is the user's and is left
373
+ * untouched. Fail-closed on a malformed settings file and backs up before any
374
+ * real write — the same Sherlock conditions installHook implements. Idempotent:
375
+ * a second call is a `noop`.
376
+ */
377
+ export function repinSessionStartHook(homeDir, harness) {
378
+ const path = hookSettingsPath(homeDir, harness);
379
+ const skip = (ok, message) => ({ ok, path, harness, action: "skip", message, backupPath: null });
380
+ const read = readSettingsFile(path);
381
+ if (read.parseError) {
382
+ return skip(false, `${read.parseError} — refusing to re-pin a file we can't safely parse; left untouched`);
383
+ }
384
+ const config = read.parsed ?? {};
385
+ const existing = findHookEntry(config);
386
+ if (!existing) {
387
+ return skip(true, `no Flair SessionStart hook in ${path} — nothing to re-pin`);
388
+ }
389
+ const current = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex]?.command ?? "";
390
+ // Only re-pin the canonical invocation Flair writes. A legacy (pre-#1143,
391
+ // no `-p`) or hand-edited command is NOT version-bumped here — `flair
392
+ // doctor`/`flair hook install` own the legacy → current rewrite, with their
393
+ // own consent.
394
+ if (!isSessionStartHookInvocation(current)) {
395
+ return skip(true, `SessionStart hook in ${path} is not the canonical form Flair writes — left untouched`);
396
+ }
397
+ const env = parseHookCommandEnv(current);
398
+ if (!env.agentId) {
399
+ return skip(true, `could not read the agent id from the SessionStart hook in ${path} — left untouched`);
400
+ }
401
+ let next;
402
+ try {
403
+ next = buildSessionStartHookCommand(env.agentId, env.flairUrl);
404
+ }
405
+ catch (err) {
406
+ const reason = err instanceof Error ? err.message : String(err);
407
+ return skip(false, `could not rebuild the SessionStart hook command for ${path}: ${reason}`);
408
+ }
409
+ if (next === current) {
410
+ return { ok: true, path, harness, action: "noop", message: `SessionStart hook in ${path} already pinned to ${mcpServerSpec()}`, backupPath: null };
411
+ }
412
+ let backupPath = null;
413
+ try {
414
+ backupPath = takeBackup(path);
415
+ }
416
+ catch (err) {
417
+ const reason = err instanceof Error ? err.message : String(err);
418
+ return skip(false, `could not back up ${path} before re-pinning it: ${reason} — refusing to touch it`);
419
+ }
420
+ const newConfig = deepClone(config);
421
+ newConfig.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex] = { type: "command", command: next };
422
+ writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
423
+ return {
424
+ ok: true, path, harness, action: "update",
425
+ message: `re-pinned the SessionStart hook in ${path} to ${mcpServerSpec()}`,
426
+ backupPath,
427
+ };
428
+ }
355
429
  /** Symmetric removal — deletes ONLY our hook entry (found the same way
356
430
  * install finds it: SESSION_START_HOOK_MARKER substring match), never
357
431
  * touches unrelated hooks/keys. A no-op (ok:true, action "noop") when
@@ -145,6 +145,20 @@ export function prefixFromPackageDir(packageDir, platform = process.platform) {
145
145
  const kept = segments.slice(0, Math.max(1, segments.length - ups));
146
146
  return kept.join(win32 ? "\\" : "/") || (win32 ? packageDir : "/");
147
147
  }
148
+ /**
149
+ * Inverse of {@link prefixFromPackageDir}: where a global
150
+ * `@tpsdev-ai/flair` install lives under `prefix`.
151
+ *
152
+ * String-based (not path.join) so the win32 shape stays faithful even in
153
+ * tests running on posix hosts.
154
+ */
155
+ export function npmGlobalFlairPackageDir(prefix, platform = process.platform) {
156
+ const win32 = platform === "win32";
157
+ const clean = stripTrailingSeps(prefix.trim(), win32);
158
+ return win32
159
+ ? [clean, "node_modules", "@tpsdev-ai", "flair"].join("\\")
160
+ : [clean, "lib", "node_modules", "@tpsdev-ai", "flair"].join("/");
161
+ }
148
162
  function defaultBinDirHasFlair(binDir, platform) {
149
163
  const names = platform === "win32" ? ["flair.cmd", "flair"] : ["flair"];
150
164
  return names.some((n) => existsSync(join(binDir, n)));
@@ -445,6 +445,21 @@ export async function authedRequest(method, path, body, opts) {
445
445
  console.error(`Warning: Ed25519 auth failed for agent '${opts.agentId}': ${message}`);
446
446
  }
447
447
  }
448
+ // Tier 1.5: flag-pinned agent (flair#1500) — an --agent flag identity signs
449
+ // as THAT agent BEFORE env admin. A flag-pinned agent with no key on disk is
450
+ // a hard error (never a warning, never a silent substitution to env admin or
451
+ // the local admin-pass file): the operator named a specific identity and the
452
+ // CLI must not quietly sign as someone else.
453
+ if (!authHeader && opts.agentIdSource === "flag" && opts.agentId) {
454
+ const keyPath = resolveKeyPath(opts.agentId);
455
+ if (!keyPath) {
456
+ throw new Error(`--agent '${opts.agentId}' has no signing key. Expected a key at ` +
457
+ `${join(defaultKeysDir(), `${opts.agentId}.key`)} (or set FLAIR_KEY_DIR ` +
458
+ `to a directory containing it). Refusing to fall back to FLAIR_ADMIN_PASS ` +
459
+ `or the local admin-pass file.`);
460
+ }
461
+ authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
462
+ }
448
463
  // Tier 2: env — FLAIR_TOKEN (Bearer), else FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD (Basic).
449
464
  if (!authHeader) {
450
465
  if (process.env.FLAIR_TOKEN) {
@@ -53,19 +53,24 @@ function runMcpBlock(ctx) {
53
53
  if (mcp.length === 0) {
54
54
  return result(id, label, "skip", { detail: "no MCP client detected" });
55
55
  }
56
- const missing = [];
57
- for (const clientId of mcp) {
58
- const block = readClientMcpBlock(clientId, ctx.homeDir);
59
- if (!block.present)
60
- missing.push(`${clientId} (${block.configPath})`);
61
- }
62
- if (missing.length > 0) {
63
- return result(id, label, "fail", {
64
- detail: `no Flair MCP server configured: ${missing.join(", ")}`,
65
- remedy: "flair doctor --fix",
56
+ // flair#989: DETECTION is not OPT-IN. A client whose binary or config merely
57
+ // exists on the box — e.g. Codex is installed but the user ran `flair init
58
+ // --client claude-code` is not an install FAILURE for lacking a Flair
59
+ // block; it was never opted into. The MCP block IS the opt-in signal, so the
60
+ // install-health subject is the WIRED clients only. An un-wired but detected
61
+ // client is surfaced as info by `flair doctor`, never counted as a failure
62
+ // here (which used to inflate the ✗ count with clients the user never chose).
63
+ const wired = mcp.filter((clientId) => readClientMcpBlock(clientId, ctx.homeDir).present);
64
+ if (wired.length === 0) {
65
+ // Detected clients exist, but Flair is wired to none of them. Not a
66
+ // per-client failure (nothing was opted in) — a skip that names the
67
+ // detected clients, so a zero-wiring run neither invents a failure the
68
+ // user didn't cause nor masquerades as a verified-healthy wiring.
69
+ return result(id, label, "skip", {
70
+ detail: `no Flair MCP server wired into any detected client (${mcp.join(", ")}) — wire one with: flair init --client <id>`,
66
71
  });
67
72
  }
68
- return result(id, label, "pass", { detail: `configured for ${mcp.join(", ")}` });
73
+ return result(id, label, "pass", { detail: `configured for ${wired.join(", ")}` });
69
74
  }
70
75
  function runFlairUrl(ctx) {
71
76
  const id = "flair-url";
@@ -91,8 +96,11 @@ function runFlairUrl(ctx) {
91
96
  function runClaudeMd(ctx) {
92
97
  const id = "claude-md";
93
98
  const label = "CLAUDE.md bootstrap";
94
- if (!ctx.detectedClientIds.includes("claude-code")) {
95
- return result(id, label, "skip", { detail: "Claude Code not detected" });
99
+ // flair#989: only relevant once Claude Code is actually WIRED. A Claude Code
100
+ // binary merely present on the box (never `flair init`-ed) does not owe a
101
+ // CLAUDE.md bootstrap line — same opt-in rule as runMcpBlock/runSessionStartHook.
102
+ if (!ctx.detectedClientIds.includes("claude-code") || !readClientMcpBlock("claude-code", ctx.homeDir).present) {
103
+ return result(id, label, "skip", { detail: "Claude Code not wired" });
96
104
  }
97
105
  const check = checkClaudeMdBootstrap(ctx.cwd, ctx.homeDir);
98
106
  if (!check.present) {
@@ -106,9 +114,14 @@ function runClaudeMd(ctx) {
106
114
  function runSessionStartHook(ctx) {
107
115
  const id = "session-start-hook";
108
116
  const label = "SessionStart hook";
109
- const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h));
117
+ // flair#989: only a harness the user actually WIRED (its MCP block is
118
+ // present) owes a SessionStart hook. A merely-detected harness — Codex on
119
+ // PATH that was never `flair init`-ed — is not an install failure for
120
+ // lacking a hook it was never asked to have. This mirrors runMcpBlock's
121
+ // opt-in rule so a detected-but-unwired client can't fail either check.
122
+ const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h) && readClientMcpBlock(h, ctx.homeDir).present);
110
123
  if (harnesses.length === 0) {
111
- return result(id, label, "skip", { detail: "no hook-capable client detected" });
124
+ return result(id, label, "skip", { detail: "no wired hook-capable client detected" });
112
125
  }
113
126
  const missing = [];
114
127
  const loud = [];
@@ -0,0 +1,257 @@
1
+ /**
2
+ * upgrade-exec-path.ts — flair#1109 (b)
3
+ *
4
+ * `flair upgrade` reports and upgrades the npm-global `@tpsdev-ai/flair`
5
+ * package. A host can also be serving from a different exec path — a plain
6
+ * extracted tree (npm pack + tar under systemd), a checkout, a second prefix.
7
+ * When those paths differ, the npm-global listing is not "the" install: it
8
+ * may be a stale relic, and upgrading it will not touch the tree serving
9
+ * traffic.
10
+ *
11
+ * This module is detection and wording only. It does not add an in-place
12
+ * tarball-swap upgrade lane (that is #1109 (a), kept elsewhere).
13
+ *
14
+ * Everything that classifies or formats is pure and dependency-injected
15
+ * except the default /proc and lsof readers used when the CLI asks about a
16
+ * live pid.
17
+ */
18
+ import { readFileSync, realpathSync, statSync } from "node:fs";
19
+ import { dirname, resolve } from "node:path";
20
+ import { execFileSync } from "node:child_process";
21
+ import { npmGlobalFlairPackageDir } from "../install/global-bin-path.js";
22
+ /** The CLI / server package whose install path we are comparing. */
23
+ export const FLAIR_PACKAGE = "@tpsdev-ai/flair";
24
+ const PROCESS_PROBE_TIMEOUT_MS = 2000;
25
+ /**
26
+ * Walk up from `startPath` looking for `@tpsdev-ai/flair`'s own package.json.
27
+ *
28
+ * Named search, not a hop count: a harper bin lives several levels under
29
+ * the package root, a CLI script lives at `dist/cli.js`, and a serving
30
+ * cwd may already BE the package root. Checking `name` means an intermediate
31
+ * `package.json` (harper, a workspace) cannot be mistaken for ours.
32
+ */
33
+ export function findFlairPackageDir(startPath) {
34
+ let dir;
35
+ try {
36
+ const st = statSync(startPath);
37
+ dir = st.isDirectory() ? startPath : dirname(startPath);
38
+ }
39
+ catch {
40
+ dir = startPath;
41
+ }
42
+ for (let i = 0; i < 8; i++) {
43
+ const loc = readFlairPackageAt(dir);
44
+ if (loc)
45
+ return loc;
46
+ const parent = dirname(dir);
47
+ if (parent === dir)
48
+ break;
49
+ dir = parent;
50
+ }
51
+ return null;
52
+ }
53
+ /** Read `@tpsdev-ai/flair` at exactly `dir`, or null if it is not that package. */
54
+ export function readFlairPackageAt(dir) {
55
+ try {
56
+ const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
57
+ if (pkg?.name !== FLAIR_PACKAGE)
58
+ return null;
59
+ return {
60
+ dir: canonicalPath(dir),
61
+ version: typeof pkg.version === "string" && pkg.version ? pkg.version : null,
62
+ };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** Canonical path for equality: realpath when it exists, else lexical resolve. */
69
+ export function canonicalPath(p) {
70
+ const resolved = resolve(p);
71
+ try {
72
+ return realpathSync(resolved);
73
+ }
74
+ catch {
75
+ return resolved;
76
+ }
77
+ }
78
+ export function sameInstallPath(a, b) {
79
+ return canonicalPath(a) === canonicalPath(b);
80
+ }
81
+ /**
82
+ * Path-shaped tokens from a process command line (null- or space-separated).
83
+ * Flags and bare words (`run`, `.`) are dropped — they are not exec paths.
84
+ */
85
+ export function extractPathHints(cmdline) {
86
+ return cmdline
87
+ .split(/\0|\s+/)
88
+ .map((t) => t.trim())
89
+ .filter((t) => t.length > 0)
90
+ .filter((t) => {
91
+ if (t.startsWith("-"))
92
+ return false;
93
+ if (t.startsWith("/") || t.startsWith("\\"))
94
+ return true;
95
+ if (/^[A-Za-z]:[\\/]/.test(t))
96
+ return true;
97
+ if (t.includes("node_modules") || t.includes("/") || t.includes("\\"))
98
+ return true;
99
+ return false;
100
+ });
101
+ }
102
+ export function defaultReadProcessCwd(pid) {
103
+ if (!Number.isInteger(pid) || pid <= 0)
104
+ return null;
105
+ try {
106
+ return realpathSync(`/proc/${pid}/cwd`);
107
+ }
108
+ catch {
109
+ try {
110
+ const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
111
+ encoding: "utf-8",
112
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
113
+ stdio: ["ignore", "pipe", "ignore"],
114
+ });
115
+ const m = String(out).match(/^n(.+)$/m);
116
+ return m ? m[1] : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ }
123
+ export function defaultReadProcessCmdline(pid) {
124
+ if (!Number.isInteger(pid) || pid <= 0)
125
+ return null;
126
+ try {
127
+ return readFileSync(`/proc/${pid}/cmdline`, "utf-8");
128
+ }
129
+ catch {
130
+ try {
131
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
132
+ encoding: "utf-8",
133
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
134
+ stdio: ["ignore", "pipe", "ignore"],
135
+ });
136
+ const trimmed = String(out).trim();
137
+ return trimmed === "" ? null : trimmed;
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Locate the `@tpsdev-ai/flair` tree a live pid is executing from.
146
+ *
147
+ * Prefers cwd (Harper is spawned with `cwd: <package dir>`), then path
148
+ * tokens on the command line (the harper bin lives under that tree).
149
+ * Returns null when neither hint resolves to our package — never guesses.
150
+ */
151
+ export function resolveServingFlairPackage(pid, hooks = {}) {
152
+ const readCwd = hooks.readCwd ?? defaultReadProcessCwd;
153
+ const readCmdline = hooks.readCmdline ?? defaultReadProcessCmdline;
154
+ const hints = [];
155
+ try {
156
+ const cwd = readCwd(pid);
157
+ if (cwd)
158
+ hints.push(cwd);
159
+ }
160
+ catch { /* injected readers must not fail the check */ }
161
+ try {
162
+ const cmdline = readCmdline(pid);
163
+ if (cmdline)
164
+ hints.push(...extractPathHints(cmdline));
165
+ }
166
+ catch { /* same */ }
167
+ for (const hint of hints) {
168
+ const found = findFlairPackageDir(hint);
169
+ if (found)
170
+ return found;
171
+ }
172
+ return null;
173
+ }
174
+ export function resolveNpmGlobalFlairPackage(prefix, platform = process.platform) {
175
+ if (!prefix || prefix.trim() === "")
176
+ return null;
177
+ return readFlairPackageAt(npmGlobalFlairPackageDir(prefix, platform));
178
+ }
179
+ /**
180
+ * `prefixKnown` is true only when `npm prefix -g` actually returned a
181
+ * prefix. A failed/absent probe is not "the global package is missing" —
182
+ * that is `unknown` (no warning). A known prefix with no `@tpsdev-ai/flair`
183
+ * under it is a real mismatch.
184
+ */
185
+ export function classifyExecPathVsNpmGlobal(input) {
186
+ const running = input.serving ?? input.cli;
187
+ if (!running)
188
+ return { kind: "unknown" };
189
+ if (!input.prefixKnown)
190
+ return { kind: "unknown" };
191
+ const source = input.serving ? "serving-instance" : "this-cli";
192
+ if (input.global && sameInstallPath(running.dir, input.global.dir)) {
193
+ return { kind: "match", runningPath: running.dir, globalPath: input.global.dir, source };
194
+ }
195
+ return {
196
+ kind: "mismatch",
197
+ runningPath: running.dir,
198
+ runningVersion: running.version,
199
+ globalPath: input.global?.dir ?? null,
200
+ globalVersion: input.global?.version ?? null,
201
+ source,
202
+ };
203
+ }
204
+ function versionLabel(version) {
205
+ return version ? ` (${version})` : "";
206
+ }
207
+ /**
208
+ * Operator-facing warning. Names both paths (and versions when readable)
209
+ * and says what `flair upgrade` will and will not touch. Does not propose
210
+ * an in-place tarball swap — that lane is out of scope for (b).
211
+ */
212
+ export function formatExecPathMismatchWarning(check) {
213
+ if (check.kind !== "mismatch")
214
+ return null;
215
+ const subject = check.source === "serving-instance"
216
+ ? "The running instance's exec path is not the npm-global install."
217
+ : "This CLI's exec path is not the npm-global install.";
218
+ const runningLabel = check.source === "serving-instance" ? "Running" : "This CLI";
219
+ const lines = [
220
+ `⚠️ ${subject}`,
221
+ ` ${runningLabel}: ${check.runningPath}${versionLabel(check.runningVersion)}`,
222
+ ];
223
+ if (check.globalPath) {
224
+ lines.push(` npm-global: ${check.globalPath}${versionLabel(check.globalVersion)}`);
225
+ }
226
+ else {
227
+ lines.push(" npm-global: not installed (no @tpsdev-ai/flair under the npm global prefix)");
228
+ }
229
+ lines.push(" `flair upgrade` only upgrades the npm-global packages. The tree serving traffic is unchanged.");
230
+ return lines.join("\n");
231
+ }
232
+ /**
233
+ * The one function `flair upgrade` calls. Best-effort: a missing pid, a
234
+ * missing prefix, or an unreadable /proc entry degrades to "unknown"
235
+ * (no warning) rather than failing the command.
236
+ */
237
+ export function collectUpgradeExecPathWarning(input) {
238
+ try {
239
+ const prefixKnown = typeof input.npmGlobalPrefix === "string" && input.npmGlobalPrefix.trim() !== "";
240
+ const serving = input.servingPid != null
241
+ ? resolveServingFlairPackage(input.servingPid, input.hooks)
242
+ : null;
243
+ const cli = findFlairPackageDir(input.cliPackageDir);
244
+ const global = prefixKnown
245
+ ? resolveNpmGlobalFlairPackage(input.npmGlobalPrefix, input.platform ?? process.platform)
246
+ : null;
247
+ return formatExecPathMismatchWarning(classifyExecPathVsNpmGlobal({
248
+ serving,
249
+ cli,
250
+ global,
251
+ prefixKnown,
252
+ }));
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }