@tpsdev-ai/flair 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
package/dist/deploy.js CHANGED
@@ -8,7 +8,6 @@ import { createRequire } from "node:module";
8
8
  export const REQUIRED_PACKAGE_FILES = [
9
9
  "dist",
10
10
  "schemas",
11
- "ui",
12
11
  "config.yaml",
13
12
  ];
14
13
  // harper's own deploy CLI defaults to a 120s peer-replication timeout that's
@@ -39,9 +38,9 @@ export function validateOptions(opts) {
39
38
  const hasBasic = !!(opts.fabricUser && opts.fabricPassword);
40
39
  const hasBearer = !!opts.fabricToken;
41
40
  if (!hasBasic && !hasBearer) {
42
- errors.push("credentials required: pass --fabric-user + --fabric-password " +
43
- "(or FABRIC_USER / FABRIC_PASSWORD env), or --fabric-token " +
44
- "(FABRIC_TOKEN env)");
41
+ errors.push("credentials required: set FABRIC_USER + FABRIC_PASSWORD env (safest), or pass " +
42
+ "--fabric-user + --fabric-password-file <path>, or --fabric-token (FABRIC_TOKEN env); " +
43
+ "inline --fabric-user/--fabric-password also work but leak to shell history");
45
44
  }
46
45
  return errors;
47
46
  }
@@ -187,7 +186,7 @@ function resolveHarperBin(packageRoot) {
187
186
  export function buildHarperDeployArgs(opts, url, project) {
188
187
  const deploymentTimeoutMs = opts.deploymentTimeoutMs ?? DEFAULT_DEPLOYMENT_TIMEOUT_MS;
189
188
  const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
190
- return [
189
+ const args = [
191
190
  "deploy",
192
191
  `target=${url}`,
193
192
  `project=${project}`,
@@ -196,26 +195,108 @@ export function buildHarperDeployArgs(opts, url, project) {
196
195
  `deployment_timeout=${deploymentTimeoutMs}`,
197
196
  `install_timeout=${installTimeoutMs}`,
198
197
  ];
198
+ // --ignore-replication-errors escape hatch. Only appended when
199
+ // set — omitted entirely otherwise, so this is a no-op for every existing
200
+ // caller/test that doesn't pass it.
201
+ if (opts.ignoreReplicationErrors) {
202
+ args.push("ignore_replication_errors=true");
203
+ }
204
+ return args;
199
205
  }
200
- function spawnHarper(bin, args, cwd, env) {
206
+ // Flaky-peer-replication resilience defaults. See DeployOptions
207
+ // for the full incident writeup this closes.
208
+ export const DEFAULT_DEPLOY_RETRIES = 2;
209
+ export const DEPLOY_RETRY_BACKOFF_MS = [5_000, 10_000];
210
+ // The signature of a Fabric PEER-REPLICATION failure specifically — harper
211
+ // deploys fine to the origin node, but pushing the component to a peer
212
+ // fails, e.g.:
213
+ // "Component 'flair' was deployed on the origin node but failed to
214
+ // replicate to 1 of 1 peer node(s): ... (Error: Connection closed 1006)"
215
+ // This is the CORRECTNESS-CRITICAL part of the fix: matching this pattern
216
+ // (and ONLY this pattern) is what lets us retry a known flake while still
217
+ // failing fast on a real deploy failure (bad package, auth, missing files —
218
+ // none of which mention peer replication and must never be retried).
219
+ // Literal regex, no interpolation.
220
+ export const REPLICATION_FAILURE_RE = /failed to replicate to \d+ (of \d+ )?peer|connection closed\s+1006|ignore_replication_errors/i;
221
+ // Tee-style capture: streams harper's stdout/stderr to the user in real time
222
+ // (unchanged UX — a multi-minute deploy needs live progress, not a black
223
+ // box) while ALSO buffering the combined text so the caller can
224
+ // pattern-match REPLICATION_FAILURE_RE against it after exit. This replaces
225
+ // the previous stdio:"inherit" passthrough, which gave no way to inspect
226
+ // harper's output. stdin stays "inherit" — harper's deploy never reads
227
+ // from it, so there's nothing to tee there.
228
+ function spawnHarperCaptured(bin, args, cwd, env) {
201
229
  return new Promise((resolveP, rejectP) => {
202
230
  const p = spawn(process.execPath, [bin, ...args], {
203
231
  cwd,
204
- stdio: "inherit",
232
+ stdio: ["inherit", "pipe", "pipe"],
205
233
  env,
206
234
  });
207
- p.on("error", rejectP);
208
- p.on("exit", (code) => {
209
- if (code === 0)
210
- resolveP();
211
- else
212
- rejectP(new Error(`harper deploy exited with code ${code}`));
235
+ const chunks = [];
236
+ p.stdout?.on("data", (d) => {
237
+ process.stdout.write(d);
238
+ chunks.push(d.toString("utf8"));
239
+ });
240
+ p.stderr?.on("data", (d) => {
241
+ process.stderr.write(d);
242
+ chunks.push(d.toString("utf8"));
213
243
  });
244
+ p.on("error", rejectP);
245
+ p.on("exit", (code) => resolveP({ code, output: chunks.join("") }));
214
246
  });
215
247
  }
216
248
  function sleep(ms) {
217
249
  return new Promise((r) => setTimeout(r, ms));
218
250
  }
251
+ // Runs `harper deploy`, retrying ONLY on the flaky peer-replication failure
252
+ // signature (REPLICATION_FAILURE_RE) — the real incident this closes: the
253
+ // origin deployed fine, replication to a peer failed with a transient
254
+ // "Connection closed 1006", and a bare manual retry cleared it with no
255
+ // other change. This re-runs the FULL harper deploy (prepare/install/
256
+ // replicate), not just a peer-only re-push — wasteful, but it's exactly
257
+ // what worked by hand, and harper's CLI has no "retry replication only"
258
+ // entry point. Any other failure (bad package, auth, missing files, ...)
259
+ // is never retried — it fails fast, same as before this change.
260
+ async function runHarperDeploy(bin, args, cwd, env, opts) {
261
+ const maxRetries = opts.deployRetries ?? DEFAULT_DEPLOY_RETRIES;
262
+ const backoff = opts.deployRetryBackoffMs ?? DEPLOY_RETRY_BACKOFF_MS;
263
+ const totalAttempts = Math.max(1, maxRetries + 1);
264
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
265
+ const { code, output } = await spawnHarperCaptured(bin, args, cwd, env);
266
+ if (code === 0)
267
+ return { replicationWarning: false };
268
+ const isReplicationFailure = REPLICATION_FAILURE_RE.test(output);
269
+ const isLastAttempt = attempt === totalAttempts;
270
+ if (isReplicationFailure && !isLastAttempt) {
271
+ const waitMs = backoff[Math.min(attempt - 1, backoff.length - 1)];
272
+ // Self-healing must be visible, never silent — console.warn directly
273
+ // (not gated behind onProgress, which some callers like
274
+ // fabric-upgrade.ts don't wire up) so this is loud regardless of caller.
275
+ console.warn(`⚠ flair deploy: replication flake on attempt ${attempt}/${totalAttempts} ` +
276
+ `(harper deploy exited ${code}, peer-replication signature matched) — ` +
277
+ `retrying in ${Math.round(waitMs / 1000)}s...`);
278
+ opts.onProgress?.(`replication flake on attempt ${attempt}/${totalAttempts} — retrying in ${Math.round(waitMs / 1000)}s...`);
279
+ await sleep(waitMs);
280
+ continue;
281
+ }
282
+ if (isReplicationFailure && opts.ignoreReplicationErrors) {
283
+ console.warn(`⚠ flair deploy: peer replication still failing after ${attempt} attempt(s), ` +
284
+ `but --ignore-replication-errors is set — treating this as a WARNED SUCCESS ` +
285
+ `(deployed to the origin node only; the peer will need to catch up via normal ` +
286
+ `federation sync or a later deploy).`);
287
+ opts.onProgress?.(`WARNING: proceeding origin-only — peer replication did not complete after ${attempt} attempt(s)`);
288
+ return { replicationWarning: true };
289
+ }
290
+ if (isReplicationFailure) {
291
+ throw new Error(`harper deploy exited with code ${code}: peer replication failed after ${attempt} attempt(s) ` +
292
+ `(retries exhausted). Pass --ignore-replication-errors to accept an origin-only deploy, ` +
293
+ `or re-run once the peer link recovers.`);
294
+ }
295
+ throw new Error(`harper deploy exited with code ${code}`);
296
+ }
297
+ // Unreachable — the loop always returns or throws — but keeps TS happy.
298
+ throw new Error("harper deploy: exhausted retry loop without resolving");
299
+ }
219
300
  // A single reachability probe against the served (REST) base URL. Harper
220
301
  // restarts the process after every deploy, so the endpoint FLAPS for a bit —
221
302
  // connection refused / reset / DNS blips are all EXPECTED right after
@@ -321,7 +402,7 @@ export async function deploy(opts) {
321
402
  CLI_TARGET_USERNAME: opts.fabricUser,
322
403
  CLI_TARGET_PASSWORD: opts.fabricPassword,
323
404
  };
324
- await spawnHarper(harperBin, args, packageRoot, childEnv);
405
+ const { replicationWarning } = await runHarperDeploy(harperBin, args, packageRoot, childEnv, opts);
325
406
  // harper can print "Successfully deployed" for a component that isn't
326
407
  // actually serving anything (the incident this closes: an empty deploy,
327
408
  // reported success, /Memory 404ing in prod). Verify by curling the served
@@ -337,5 +418,5 @@ export async function deploy(opts) {
337
418
  onProgress: opts.onProgress,
338
419
  });
339
420
  }
340
- return { url, project, version, packageRoot, dryRun: false };
421
+ return { url, project, version, packageRoot, dryRun: false, replicationWarning };
341
422
  }
@@ -0,0 +1,312 @@
1
+ // ─── Doctor: client integration checks (flair#588) ──────────────────────────────
2
+ //
3
+ // `flair doctor` diagnosed the SERVER side only (Harper port, keys, config,
4
+ // embeddings, data dir). It had zero visibility into whether the CLIENT
5
+ // integration — the MCP wiring an agent like Claude Code actually uses — is
6
+ // working. A real incident found users with partial setups (MCP block wired
7
+ // but no CLAUDE.md line; or no SessionStart hook) that silently no-op, with
8
+ // no way to tell "is Flair working for my agent?" short of an incident.
9
+ //
10
+ // This module is pure filesystem logic (no network, no crypto) so it's fast
11
+ // and fully unit-testable in isolation — mirrors test/unit/client-wiring.test.ts's
12
+ // technique of overriding process.env.HOME to a temp dir. The two
13
+ // network-dependent checks (reachability + agent registration) live in
14
+ // src/cli.ts alongside authFetch/resolveKeyPath, which they reuse.
15
+ //
16
+ // Every read here is try/catch-wrapped: a missing or malformed config file is
17
+ // "not present", never a thrown error — doctor must never crash or hang on a
18
+ // broken client config.
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { clientConfigPath } from "./install/clients.js";
22
+ // The exact substring `flair init` writes into CLAUDE.md (src/cli.ts, the
23
+ // `init` action) and that the doctor check + fix both key off of.
24
+ export const CLAUDE_MD_BOOTSTRAP_MARKER = "mcp__flair__bootstrap";
25
+ const CLAUDE_MD_BOOTSTRAP_LINE = "At the start of every session, run mcp__flair__bootstrap before responding.";
26
+ // The exact substring identifying a Flair SessionStart hook command (see
27
+ // docs/mcp-clients.md "Auto-recall on session start").
28
+ export const SESSION_START_HOOK_MARKER = "flair-session-start";
29
+ // ── shared helpers ──────────────────────────────────────────────────────────
30
+ /**
31
+ * Run `fn` with process.env.HOME temporarily pointed at `homeDir`, then
32
+ * restore it. clientConfigPath() (src/install/clients.ts) resolves the home
33
+ * dir via HOME/USERPROFILE at call time (not cached), so this lets us reuse
34
+ * that single source of truth for per-client config paths while keeping
35
+ * doctor-client's own functions parameterized by an explicit homeDir for
36
+ * tests — no test ever touches the real ~/.claude.json etc. The override is
37
+ * synchronous and restored before this function returns, so it's safe even
38
+ * though process.env is process-global.
39
+ */
40
+ function withHome(homeDir, fn) {
41
+ const prev = process.env.HOME;
42
+ process.env.HOME = homeDir;
43
+ try {
44
+ return fn();
45
+ }
46
+ finally {
47
+ if (prev === undefined)
48
+ delete process.env.HOME;
49
+ else
50
+ process.env.HOME = prev;
51
+ }
52
+ }
53
+ function readTextFile(path) {
54
+ try {
55
+ if (!existsSync(path))
56
+ return null;
57
+ return readFileSync(path, "utf-8");
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * Read the Flair MCP server block from `clientId`'s config file. `present`
65
+ * is true only when the block exists AND both FLAIR_AGENT_ID and FLAIR_URL
66
+ * are set (non-empty) — a half-wired block (e.g. block present, env missing)
67
+ * counts as absent for the pass/fail check, but agentId/flairUrl are still
68
+ * returned when partially found so callers can use whatever is known.
69
+ */
70
+ export function readClientMcpBlock(clientId, homeDir) {
71
+ const configPath = withHome(homeDir, () => clientConfigPath(clientId));
72
+ return clientId === "codex" ? readCodexFlairBlock(configPath) : readJsonFlairBlock(configPath);
73
+ }
74
+ function readJsonFlairBlock(configPath) {
75
+ const raw = readTextFile(configPath);
76
+ if (!raw || !raw.trim())
77
+ return { present: false, configPath };
78
+ try {
79
+ const config = JSON.parse(raw);
80
+ const flair = config?.mcpServers?.flair;
81
+ if (!flair || typeof flair !== "object")
82
+ return { present: false, configPath };
83
+ const agentId = typeof flair.env?.FLAIR_AGENT_ID === "string" && flair.env.FLAIR_AGENT_ID ? flair.env.FLAIR_AGENT_ID : undefined;
84
+ const flairUrl = typeof flair.env?.FLAIR_URL === "string" && flair.env.FLAIR_URL ? flair.env.FLAIR_URL : undefined;
85
+ return { present: !!agentId && !!flairUrl, configPath, agentId, flairUrl };
86
+ }
87
+ catch {
88
+ // Malformed JSON — treat as "not present", never throw.
89
+ return { present: false, configPath };
90
+ }
91
+ }
92
+ /**
93
+ * Codex's config is TOML, and this repo carries no TOML parser (see the
94
+ * comment on _wireCodex in src/install/clients.ts) — so this is a lightweight
95
+ * string scan, matching the exact shape _wireCodex/tomlSnippet() produce:
96
+ *
97
+ * [mcp_servers.flair]
98
+ * command = "npx"
99
+ * args = ["-y", "@tpsdev-ai/flair-mcp"]
100
+ *
101
+ * [mcp_servers.flair.env]
102
+ * FLAIR_AGENT_ID = "..."
103
+ * FLAIR_URL = "..."
104
+ *
105
+ * We locate the `[mcp_servers.flair]` header, then collect lines until a
106
+ * header that is NOT part of this table (i.e. doesn't start with
107
+ * "[mcp_servers.flair") — deliberately does NOT stop at the nested
108
+ * `[mcp_servers.flair.env]` sub-table, since that's where the two env keys
109
+ * actually live.
110
+ */
111
+ function readCodexFlairBlock(configPath) {
112
+ const raw = readTextFile(configPath);
113
+ if (!raw)
114
+ return { present: false, configPath };
115
+ const scanned = scanCodexFlairBlock(raw);
116
+ return { present: scanned.present, configPath, agentId: scanned.agentId, flairUrl: scanned.flairUrl };
117
+ }
118
+ function scanCodexFlairBlock(raw) {
119
+ const startMatch = raw.match(/^\[mcp_servers\.flair\]\s*$/m);
120
+ if (!startMatch || startMatch.index === undefined)
121
+ return { present: false };
122
+ const rest = raw.slice(startMatch.index);
123
+ const lines = rest.split("\n");
124
+ const blockLines = [lines[0]];
125
+ for (let i = 1; i < lines.length; i++) {
126
+ const trimmed = lines[i].trim();
127
+ if (trimmed.startsWith("[") && !trimmed.startsWith("[mcp_servers.flair"))
128
+ break;
129
+ blockLines.push(lines[i]);
130
+ }
131
+ const block = blockLines.join("\n");
132
+ const agentMatch = block.match(/^\s*FLAIR_AGENT_ID\s*=\s*"([^"]*)"/m);
133
+ const urlMatch = block.match(/^\s*FLAIR_URL\s*=\s*"([^"]*)"/m);
134
+ const agentId = agentMatch?.[1] || undefined;
135
+ const flairUrl = urlMatch?.[1] || undefined;
136
+ return { present: !!agentId && !!flairUrl, agentId, flairUrl };
137
+ }
138
+ /**
139
+ * Pass when EITHER the project-scoped `${cwd}/CLAUDE.md` or the user-level
140
+ * `~/.claude/CLAUDE.md` contains the bootstrap marker — Claude Code loads
141
+ * both. Checks cwd first (the convention docs/claude-code.md documents and
142
+ * what `flair init` tells users to edit).
143
+ */
144
+ export function checkClaudeMdBootstrap(cwd, homeDir) {
145
+ const cwdPath = join(cwd, "CLAUDE.md");
146
+ const cwdContent = readTextFile(cwdPath);
147
+ if (cwdContent && cwdContent.includes(CLAUDE_MD_BOOTSTRAP_MARKER)) {
148
+ return { present: true, path: cwdPath };
149
+ }
150
+ const homePath = join(homeDir, ".claude", "CLAUDE.md");
151
+ const homeContent = readTextFile(homePath);
152
+ if (homeContent && homeContent.includes(CLAUDE_MD_BOOTSTRAP_MARKER)) {
153
+ return { present: true, path: homePath };
154
+ }
155
+ return { present: false, path: null };
156
+ }
157
+ /**
158
+ * Append the bootstrap instruction to `${cwd}/CLAUDE.md` (creating it if
159
+ * absent). Idempotent — safe to call twice; a second call is a no-op that
160
+ * still reports ok:true.
161
+ */
162
+ export function fixClaudeMdBootstrap(cwd) {
163
+ const path = join(cwd, "CLAUDE.md");
164
+ try {
165
+ const existing = readTextFile(path) ?? "";
166
+ if (existing.includes(CLAUDE_MD_BOOTSTRAP_MARKER)) {
167
+ return { ok: true, path, message: `already present in ${path}` };
168
+ }
169
+ const separator = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
170
+ const block = `${separator}## Flair memory\n\n${CLAUDE_MD_BOOTSTRAP_LINE}\n`;
171
+ writeFileSync(path, existing + block);
172
+ return { ok: true, path, message: `added bootstrap instruction to ${path}` };
173
+ }
174
+ catch (err) {
175
+ const reason = err instanceof Error ? err.message : String(err);
176
+ return { ok: false, path, message: `could not write ${path}: ${reason}` };
177
+ }
178
+ }
179
+ /**
180
+ * Pass when ~/.claude/settings.json exists, parses as JSON, and ANY hook
181
+ * command anywhere under hooks.SessionStart[*].hooks[*].command contains the
182
+ * flair-session-start marker (see docs/mcp-clients.md for the exact shape).
183
+ */
184
+ export function checkSessionStartHook(homeDir) {
185
+ const path = join(homeDir, ".claude", "settings.json");
186
+ const raw = readTextFile(path);
187
+ if (!raw || !raw.trim())
188
+ return { present: false, path };
189
+ try {
190
+ const config = JSON.parse(raw);
191
+ const groups = config?.hooks?.SessionStart;
192
+ if (!Array.isArray(groups))
193
+ return { present: false, path };
194
+ for (const group of groups) {
195
+ const hooks = group?.hooks;
196
+ if (!Array.isArray(hooks))
197
+ continue;
198
+ for (const hook of hooks) {
199
+ if (typeof hook?.command === "string" && hook.command.includes(SESSION_START_HOOK_MARKER)) {
200
+ return { present: true, path };
201
+ }
202
+ }
203
+ }
204
+ return { present: false, path };
205
+ }
206
+ catch {
207
+ return { present: false, path };
208
+ }
209
+ }
210
+ /**
211
+ * Merge-safe insert of a Flair SessionStart hook group into
212
+ * ~/.claude/settings.json — creates the file/array if absent, preserves any
213
+ * other existing hooks/keys (read-parse-merge-write, mirroring wireJsonMcp's
214
+ * merge safety in src/install/clients.ts; never a blind overwrite). Dedupes:
215
+ * a no-op (ok:true) if a matching hook is already present, so it's safe to
216
+ * call twice.
217
+ */
218
+ export function fixSessionStartHook(homeDir, agentId) {
219
+ const path = join(homeDir, ".claude", "settings.json");
220
+ if (!agentId) {
221
+ return {
222
+ ok: false,
223
+ path,
224
+ message: "no agent id known — pass --agent <id> (or set FLAIR_AGENT_ID) so doctor knows which agent to wire the hook to",
225
+ };
226
+ }
227
+ try {
228
+ let config = {};
229
+ const raw = readTextFile(path);
230
+ if (raw && raw.trim())
231
+ config = JSON.parse(raw);
232
+ config.hooks = config.hooks && typeof config.hooks === "object" ? config.hooks : {};
233
+ config.hooks.SessionStart = Array.isArray(config.hooks.SessionStart) ? config.hooks.SessionStart : [];
234
+ const alreadyPresent = config.hooks.SessionStart.some((group) => Array.isArray(group?.hooks) &&
235
+ group.hooks.some((h) => typeof h?.command === "string" && h.command.includes(SESSION_START_HOOK_MARKER)));
236
+ if (alreadyPresent) {
237
+ return { ok: true, path, message: `already present in ${path}` };
238
+ }
239
+ config.hooks.SessionStart.push({
240
+ hooks: [
241
+ {
242
+ type: "command",
243
+ command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
244
+ },
245
+ ],
246
+ });
247
+ mkdirSync(dirname(path), { recursive: true });
248
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
249
+ return { ok: true, path, message: `added SessionStart hook to ${path} (agent '${agentId}')` };
250
+ }
251
+ catch (err) {
252
+ const reason = err instanceof Error ? err.message : String(err);
253
+ return { ok: false, path, message: `could not write ${path}: ${reason}` };
254
+ }
255
+ }
256
+ function indentLines(s) {
257
+ return s
258
+ .split("\n")
259
+ .map((l) => ` ${l}`)
260
+ .join("\n");
261
+ }
262
+ /**
263
+ * Apply-or-report for the CLAUDE.md bootstrap leg. Idempotent: a second call
264
+ * after the line is present (whether from a prior call or already there)
265
+ * reports ok:true, applied:false — safe to call on every `flair init`.
266
+ */
267
+ export function applyOrReportClaudeMdBootstrap(cwd, homeDir, skip) {
268
+ const existing = checkClaudeMdBootstrap(cwd, homeDir);
269
+ if (existing.present) {
270
+ return { applied: false, ok: true, message: `CLAUDE.md already has the bootstrap instruction (${existing.path})` };
271
+ }
272
+ const hint = `Add to your CLAUDE.md:\n${indentLines(CLAUDE_MD_BOOTSTRAP_LINE)}`;
273
+ if (skip) {
274
+ return { applied: false, ok: false, message: "CLAUDE.md bootstrap instruction skipped (--skip-claude-md)", hint };
275
+ }
276
+ const fix = fixClaudeMdBootstrap(cwd);
277
+ return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
278
+ }
279
+ function sessionStartHookHint(agentId, path) {
280
+ const snippet = {
281
+ hooks: {
282
+ SessionStart: [
283
+ {
284
+ hooks: [
285
+ {
286
+ type: "command",
287
+ command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
288
+ },
289
+ ],
290
+ },
291
+ ],
292
+ },
293
+ };
294
+ return `Add this to ${path}:\n${indentLines(JSON.stringify(snippet, null, 2))}`;
295
+ }
296
+ /**
297
+ * Apply-or-report for the SessionStart hook leg. Idempotent: a second call
298
+ * after the hook is present (whether from a prior call or already there)
299
+ * reports ok:true, applied:false — safe to call on every `flair init`.
300
+ */
301
+ export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
302
+ const existing = checkSessionStartHook(homeDir);
303
+ if (existing.present) {
304
+ return { applied: false, ok: true, message: `SessionStart hook already wired in ${existing.path}` };
305
+ }
306
+ const hint = sessionStartHookHint(agentId, existing.path);
307
+ if (skip) {
308
+ return { applied: false, ok: false, message: "SessionStart hook skipped (--skip-hook)", hint };
309
+ }
310
+ const fix = fixSessionStartHook(homeDir, agentId);
311
+ return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
312
+ }
@@ -0,0 +1,98 @@
1
+ // ─── Doctor: fleet presence / instance staleness (flair#639) ────────────────────
2
+ //
3
+ // `flair doctor` diagnosed a single instance only. Auto-presence (flair#608)
4
+ // gives every agent a heartbeat, and each heartbeat now carries the SERVING
5
+ // instance's own flair + harper version (resources/Presence.ts,
6
+ // buildPresenceRecord()) — so the /Presence roster doubles as an org-level
7
+ // fleet-version report, IF something reads it that way. This module is that
8
+ // reading: pure classification over an already-fetched roster, no network,
9
+ // no crypto, so it's fast and fully unit-testable in isolation — same split
10
+ // as doctor-client.ts (pure logic here; the network fetch + Ed25519 signing
11
+ // stay in src/cli.ts alongside authFetch/buildEd25519Auth, which they reuse).
12
+ //
13
+ // Deliberately NOT npm-latest aware: comparing against the newest version
14
+ // PUBLISHED is version-nudge's job (src/version-check.ts, flair#587/#594).
15
+ // This is comparing instances against EACH OTHER — "is anyone behind the
16
+ // rest of the fleet" — which stays meaningful even fully offline, and is the
17
+ // question flair#639 actually asks: an org-level answer without probing
18
+ // every host by hand.
19
+ //
20
+ // Semver comparison reuses fabric-upgrade.ts's parseSemverCore/semverGte
21
+ // (major.minor.patch only, pre-release/build suffixes ignored) — the one
22
+ // semver implementation in this codebase; duplicating it here would risk the
23
+ // two drifting on an edge case.
24
+ import { parseSemverCore, semverGte } from "./fabric-upgrade.js";
25
+ /** True only for a non-empty, parseable semver string — the one predicate every
26
+ * function below needs, so it lives in exactly one place. */
27
+ function hasParsableVersion(v) {
28
+ return typeof v === "string" && parseSemverCore(v) !== null;
29
+ }
30
+ /**
31
+ * Newest flairVersion seen across the roster. Entries with no (or an
32
+ * unparseable) version are excluded from the computation — an unversioned
33
+ * record can't be compared, so it can't set the bar. Returns null when NO
34
+ * entry in the roster reports a parseable version (nothing to compare
35
+ * against — e.g. a fleet that's entirely pre-flair#639, or a roster with a
36
+ * single unversioned instance).
37
+ */
38
+ export function newestVersionSeen(rows) {
39
+ let newest = null;
40
+ for (const row of rows) {
41
+ if (!hasParsableVersion(row.flairVersion))
42
+ continue;
43
+ if (newest === null || semverGte(row.flairVersion, newest))
44
+ newest = row.flairVersion;
45
+ }
46
+ return newest;
47
+ }
48
+ /**
49
+ * Flag each roster row as stale relative to the newest flairVersion seen
50
+ * ACROSS THE WHOLE ROSTER (never against npm-latest — see module doc). A row
51
+ * is stale when:
52
+ * - the roster has SOME newest version to compare against (newest !== null)
53
+ * AND
54
+ * - this row has no parseable version of its own (an older, pre-flair#639
55
+ * instance — the loudest possible skew signal), OR its version is older
56
+ * than the newest seen.
57
+ *
58
+ * A single-instance roster, or a roster where every instance reports the
59
+ * IDENTICAL version, has nothing to compare against → newest equals every
60
+ * row's own version (or is null) → nothing is flagged stale. A roster where
61
+ * NO instance has ever reported a version (newest === null) similarly flags
62
+ * nothing — there's no fleet-relative signal yet, only an org-wide gap this
63
+ * function can't see (that's what the "pass --agent to reveal versions" /
64
+ * "no versions reported yet" doctor messaging is for, not staleness).
65
+ */
66
+ export function markStale(rows) {
67
+ const newest = newestVersionSeen(rows);
68
+ return rows.map((row) => {
69
+ const stale = newest !== null && (!hasParsableVersion(row.flairVersion) || !semverGte(row.flairVersion, newest));
70
+ return { ...row, stale, newestVersion: newest };
71
+ });
72
+ }
73
+ /**
74
+ * Sort oldest-version-first so problems top the listing (flair#639's stated
75
+ * requirement). Rows with no parseable version sort FIRST — an unversioned
76
+ * instance is the biggest unknown in the fleet, not a middling one, and
77
+ * markStale() always flags it stale whenever the roster has any signal at
78
+ * all, so surfacing it first keeps the "problems on top" contract even when
79
+ * mixed with real-but-old versions. Ties (equal versions) preserve roster
80
+ * order (stable sort).
81
+ */
82
+ export function sortOldestVersionFirst(rows) {
83
+ return [...rows].sort((a, b) => {
84
+ const pa = hasParsableVersion(a.flairVersion) ? parseSemverCore(a.flairVersion) : null;
85
+ const pb = hasParsableVersion(b.flairVersion) ? parseSemverCore(b.flairVersion) : null;
86
+ if (!pa && !pb)
87
+ return 0;
88
+ if (!pa)
89
+ return -1;
90
+ if (!pb)
91
+ return 1;
92
+ for (let i = 0; i < 3; i++) {
93
+ if (pa[i] !== pb[i])
94
+ return pa[i] - pb[i];
95
+ }
96
+ return 0;
97
+ });
98
+ }