@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/README.md +15 -4
- package/dist/build-info.json +3 -3
- package/dist/cli.js +630 -199
- package/dist/doctor-client.js +3 -3
- package/dist/install/clients.js +28 -17
- package/dist/lib/daemon-liveness.js +207 -0
- package/dist/lib/doctor-run.js +481 -0
- package/dist/lib/launchd-management.js +7 -26
- package/dist/lib/upgrade-migrations.js +181 -0
- package/dist/resources/Federation.js +42 -20
- package/dist/resources/MemoryArchive.js +94 -0
- package/dist/resources/MemoryBootstrap.js +3 -1
- package/dist/resources/RecordUsage.js +13 -6
- package/dist/resources/SemanticSearch.js +14 -2
- package/dist/resources/federation-classify.js +90 -0
- package/dist/resources/health.js +9 -10
- package/dist/resources/mcp-tools.js +115 -7
- package/dist/resources/record-types.js +3 -1
- package/dist/resources/search-readiness.js +33 -10
- package/dist/resources/semantic-retrieval-core.js +39 -20
- package/dist/resources/usage-ids.js +63 -0
- package/docs/federation.md +11 -0
- package/docs/supply-chain-policy.md +1 -1
- package/docs/upgrade.md +17 -1
- package/package.json +2 -2
- package/schemas/federation.graphql +1 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor-run.ts — the enumerable install-health runner (flair#1439).
|
|
3
|
+
*
|
|
4
|
+
* `flair doctor` and `flair upgrade` used to keep two definitions of
|
|
5
|
+
* "healthy". Upgrade's success line (`renderVerifiedSummary`) could only
|
|
6
|
+
* qualify a ✅ by the facts in its signature — version plus launchd — so
|
|
7
|
+
* every other doctor check was invisible and rendered green. The previous
|
|
8
|
+
* fix special-cased one known-unhealthy form (launchd detach). That is a
|
|
9
|
+
* blacklist: the next unmeasured fact (a Codex SessionStart hook 0.49.0
|
|
10
|
+
* never wrote) printed the same unqualified success marker.
|
|
11
|
+
*
|
|
12
|
+
* This module is the single entry point that runs the named install-health
|
|
13
|
+
* checks. Adding a check to `DOCTOR_CHECK_IDS` automatically widens what
|
|
14
|
+
* upgrade claims. Success is asserted POSITIVELY: every catalog id must
|
|
15
|
+
* have been executed and none may have failed. An `unrun` member is never
|
|
16
|
+
* treated as a pass — if a reachable state cannot produce "unrun ⇒ no ✅",
|
|
17
|
+
* the enumeration is decorative and we have rebuilt the same guard.
|
|
18
|
+
*
|
|
19
|
+
* Skip (N/A) is not unrun. Linux has no launchd; a machine without Codex
|
|
20
|
+
* does not owe a Codex hook. Those checks execute and return `skip`.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
23
|
+
import { checkClaudeMdBootstrap, checkSessionStartHook, effectiveFlairUrl, hookCommandIsSilenced, isFlairHookCommand, partitionKeyIds, planAgentIterations, readClientMcpBlock, } from "../doctor-client.js";
|
|
24
|
+
import { hookInstallHint, hookSettingsPath, installHook, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
25
|
+
import { isDetached, renderDetachedWarning, } from "./launchd-management.js";
|
|
26
|
+
/**
|
|
27
|
+
* The catalog. This is the contract upgrade asserts against.
|
|
28
|
+
*
|
|
29
|
+
* A new doctor install-health check is added HERE (id + runner). An id
|
|
30
|
+
* without a runner is emitted as `unrun` and blocks the success marker —
|
|
31
|
+
* that is the point, not a fallback.
|
|
32
|
+
*
|
|
33
|
+
* Flint's six named client-integration checks plus launchd (the previous
|
|
34
|
+
* special-case, now a catalog member rather than a side channel).
|
|
35
|
+
*/
|
|
36
|
+
export const DOCTOR_CHECK_IDS = [
|
|
37
|
+
"mcp-block",
|
|
38
|
+
"flair-url",
|
|
39
|
+
"claude-md",
|
|
40
|
+
"session-start-hook",
|
|
41
|
+
"verified-read",
|
|
42
|
+
"keys-prune",
|
|
43
|
+
"launchd-management",
|
|
44
|
+
];
|
|
45
|
+
const MCP_CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
|
|
46
|
+
function result(id, label, status, extra = {}) {
|
|
47
|
+
return { id, label, status, ...extra };
|
|
48
|
+
}
|
|
49
|
+
function runMcpBlock(ctx) {
|
|
50
|
+
const id = "mcp-block";
|
|
51
|
+
const label = "MCP server block";
|
|
52
|
+
const mcp = ctx.detectedClientIds.filter((c) => MCP_CLIENT_IDS.includes(c));
|
|
53
|
+
if (mcp.length === 0) {
|
|
54
|
+
return result(id, label, "skip", { detail: "no MCP client detected" });
|
|
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",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return result(id, label, "pass", { detail: `configured for ${mcp.join(", ")}` });
|
|
69
|
+
}
|
|
70
|
+
function runFlairUrl(ctx) {
|
|
71
|
+
const id = "flair-url";
|
|
72
|
+
const label = "FLAIR_URL";
|
|
73
|
+
const mcp = ctx.detectedClientIds.filter((c) => MCP_CLIENT_IDS.includes(c));
|
|
74
|
+
if (mcp.length === 0) {
|
|
75
|
+
return result(id, label, "skip", { detail: "no MCP client detected" });
|
|
76
|
+
}
|
|
77
|
+
// Presence of a working URL (explicit or client-defaulted) is the check.
|
|
78
|
+
// Unreachable is a doctor warn, not an install-health failure — the
|
|
79
|
+
// instance probe already covered liveness on the upgrade path.
|
|
80
|
+
const present = mcp.filter((clientId) => readClientMcpBlock(clientId, ctx.homeDir).present);
|
|
81
|
+
if (present.length === 0) {
|
|
82
|
+
return result(id, label, "skip", { detail: "no MCP block present to take a URL from" });
|
|
83
|
+
}
|
|
84
|
+
const urls = present.map((clientId) => {
|
|
85
|
+
const block = readClientMcpBlock(clientId, ctx.homeDir);
|
|
86
|
+
const eff = effectiveFlairUrl(block);
|
|
87
|
+
return `${clientId}=${eff.url}${eff.defaulted ? " (client default)" : ""}`;
|
|
88
|
+
});
|
|
89
|
+
return result(id, label, "pass", { detail: urls.join(", ") });
|
|
90
|
+
}
|
|
91
|
+
function runClaudeMd(ctx) {
|
|
92
|
+
const id = "claude-md";
|
|
93
|
+
const label = "CLAUDE.md bootstrap";
|
|
94
|
+
if (!ctx.detectedClientIds.includes("claude-code")) {
|
|
95
|
+
return result(id, label, "skip", { detail: "Claude Code not detected" });
|
|
96
|
+
}
|
|
97
|
+
const check = checkClaudeMdBootstrap(ctx.cwd, ctx.homeDir);
|
|
98
|
+
if (!check.present) {
|
|
99
|
+
return result(id, label, "fail", {
|
|
100
|
+
detail: "bootstrap instruction not found",
|
|
101
|
+
remedy: "flair doctor --fix",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return result(id, label, "pass", { detail: check.path ?? undefined });
|
|
105
|
+
}
|
|
106
|
+
function runSessionStartHook(ctx) {
|
|
107
|
+
const id = "session-start-hook";
|
|
108
|
+
const label = "SessionStart hook";
|
|
109
|
+
const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h));
|
|
110
|
+
if (harnesses.length === 0) {
|
|
111
|
+
return result(id, label, "skip", { detail: "no hook-capable client detected" });
|
|
112
|
+
}
|
|
113
|
+
const missing = [];
|
|
114
|
+
const loud = [];
|
|
115
|
+
for (const harness of harnesses) {
|
|
116
|
+
const path = hookSettingsPath(ctx.homeDir, harness);
|
|
117
|
+
const hook = checkSessionStartHook(ctx.homeDir, path);
|
|
118
|
+
if (!hook.present) {
|
|
119
|
+
missing.push(harness);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (hook.command && isFlairHookCommand(hook.command) && !hookCommandIsSilenced(hook.command)) {
|
|
123
|
+
loud.push(harness);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (missing.length > 0) {
|
|
127
|
+
const harness = missing[0];
|
|
128
|
+
const path = hookSettingsPath(ctx.homeDir, harness);
|
|
129
|
+
const detail = missing.length === 1
|
|
130
|
+
? `SessionStart hook (${harness}): not found in ${path}`
|
|
131
|
+
: `SessionStart hook: not found for ${missing.join(", ")} (${missing.map((h) => hookSettingsPath(ctx.homeDir, h)).join("; ")})`;
|
|
132
|
+
return result(id, label, "fail", {
|
|
133
|
+
detail,
|
|
134
|
+
remedy: missing.map((h) => hookInstallHint(h)).join(" ; "),
|
|
135
|
+
missingHarnesses: missing,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (loud.length > 0) {
|
|
139
|
+
const harness = loud[0];
|
|
140
|
+
return result(id, label, "fail", {
|
|
141
|
+
detail: `SessionStart hook (${harness}): a failure would print an error on every session`,
|
|
142
|
+
remedy: hookInstallHint(harness),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return result(id, label, "pass", {
|
|
146
|
+
detail: `wired for ${harnesses.join(", ")}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function runVerifiedRead(ctx) {
|
|
150
|
+
const id = "verified-read";
|
|
151
|
+
const label = "per-agent verified-read";
|
|
152
|
+
if (ctx.keyAgentIds === undefined) {
|
|
153
|
+
return result(id, label, "skip", { detail: "no keys enumeration in context" });
|
|
154
|
+
}
|
|
155
|
+
const plan = planAgentIterations(ctx.keyAgentIds, ctx.agentFlag);
|
|
156
|
+
if (plan.length === 0) {
|
|
157
|
+
return result(id, label, "skip", { detail: "no agents to iterate" });
|
|
158
|
+
}
|
|
159
|
+
return result(id, label, "pass", { detail: `iterate ${plan.join(", ")}` });
|
|
160
|
+
}
|
|
161
|
+
function runKeysPrune(ctx) {
|
|
162
|
+
const id = "keys-prune";
|
|
163
|
+
const label = "keys prune classification";
|
|
164
|
+
if (!ctx.keysDir) {
|
|
165
|
+
return result(id, label, "skip", { detail: "no keys dir in context" });
|
|
166
|
+
}
|
|
167
|
+
if (!existsSync(ctx.keysDir)) {
|
|
168
|
+
return result(id, label, "skip", { detail: "keys dir absent" });
|
|
169
|
+
}
|
|
170
|
+
const ids = readdirSync(ctx.keysDir)
|
|
171
|
+
.filter((f) => f.endsWith(".key"))
|
|
172
|
+
.map((f) => f.replace(/\.key$/, ""));
|
|
173
|
+
const { agentKeyIds, nodeKeyIds } = partitionKeyIds(ids, ctx.keysDir);
|
|
174
|
+
return result(id, label, "pass", {
|
|
175
|
+
detail: `classified ${agentKeyIds.length} agent / ${nodeKeyIds.length} node key(s)`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function runLaunchdManagement(ctx) {
|
|
179
|
+
const id = "launchd-management";
|
|
180
|
+
const label = "launchd management";
|
|
181
|
+
if (!ctx.launchd) {
|
|
182
|
+
return result(id, label, "skip", { detail: "launchd not observed" });
|
|
183
|
+
}
|
|
184
|
+
const m = ctx.launchd;
|
|
185
|
+
if (isDetached(m)) {
|
|
186
|
+
return result(id, label, "fail", {
|
|
187
|
+
detail: m.detail,
|
|
188
|
+
remedy: m.remedy?.join(" && "),
|
|
189
|
+
launchd: m,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (m.state === "not-applicable" || m.state === "no-service") {
|
|
193
|
+
return result(id, label, "skip", { detail: m.detail, launchd: m });
|
|
194
|
+
}
|
|
195
|
+
return result(id, label, "pass", { detail: m.detail, launchd: m });
|
|
196
|
+
}
|
|
197
|
+
/** Default implementations. An id in DOCTOR_CHECK_IDS missing from this
|
|
198
|
+
* list is emitted as `unrun` — that is a reachable incomplete state. */
|
|
199
|
+
export const DOCTOR_CHECKS = [
|
|
200
|
+
{ id: "mcp-block", label: "MCP server block", run: runMcpBlock },
|
|
201
|
+
{ id: "flair-url", label: "FLAIR_URL", run: runFlairUrl },
|
|
202
|
+
{ id: "claude-md", label: "CLAUDE.md bootstrap", run: runClaudeMd },
|
|
203
|
+
{ id: "session-start-hook", label: "SessionStart hook", run: runSessionStartHook },
|
|
204
|
+
{ id: "verified-read", label: "per-agent verified-read", run: runVerifiedRead },
|
|
205
|
+
{ id: "keys-prune", label: "keys prune classification", run: runKeysPrune },
|
|
206
|
+
{ id: "launchd-management", label: "launchd management", run: runLaunchdManagement },
|
|
207
|
+
];
|
|
208
|
+
/**
|
|
209
|
+
* Run every catalog id. Missing implementations and thrown runners become
|
|
210
|
+
* `unrun`, never `pass`.
|
|
211
|
+
*/
|
|
212
|
+
export function runDoctorChecks(ctx, opts = {}) {
|
|
213
|
+
const catalogIds = opts.catalogIds ?? DOCTOR_CHECK_IDS;
|
|
214
|
+
const defs = new Map((opts.checks ?? DOCTOR_CHECKS).map((c) => [c.id, c]));
|
|
215
|
+
const results = [];
|
|
216
|
+
for (const id of catalogIds) {
|
|
217
|
+
const stub = opts.stubs?.[id];
|
|
218
|
+
const def = defs.get(id);
|
|
219
|
+
if (stub) {
|
|
220
|
+
try {
|
|
221
|
+
results.push(normalizeResult(id, def?.label ?? id, stub(ctx)));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
results.push(unrunResult(id, def?.label ?? id, "check threw before returning"));
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!def) {
|
|
229
|
+
results.push(unrunResult(id, id, "check was not executed — no runner registered"));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
results.push(normalizeResult(id, def.label, def.run(ctx)));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
results.push(unrunResult(id, def.label, "check threw before returning"));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return summarizeDoctorRunResults(results, catalogIds);
|
|
240
|
+
}
|
|
241
|
+
function normalizeResult(id, label, r) {
|
|
242
|
+
return { ...r, id, label: r.label || label };
|
|
243
|
+
}
|
|
244
|
+
function unrunResult(id, label, detail) {
|
|
245
|
+
return { id, label, status: "unrun", detail };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Positive assertion over an already-collected result list. Any catalog id
|
|
249
|
+
* absent from `results` is filled in as `unrun` so a partial list cannot
|
|
250
|
+
* look complete.
|
|
251
|
+
*/
|
|
252
|
+
export function summarizeDoctorRunResults(results, catalogIds = DOCTOR_CHECK_IDS) {
|
|
253
|
+
const byId = new Map(results.map((r) => [r.id, r]));
|
|
254
|
+
const complete = catalogIds.map((id) => byId.get(id) ?? unrunResult(id, id, "check was not executed"));
|
|
255
|
+
const incomplete = complete.some((r) => r.status === "unrun");
|
|
256
|
+
const failed = complete.some((r) => r.status === "fail");
|
|
257
|
+
return {
|
|
258
|
+
results: complete,
|
|
259
|
+
healthy: catalogIds.length > 0 && !incomplete && !failed,
|
|
260
|
+
incomplete,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The final status line of a successful `flair upgrade`.
|
|
265
|
+
*
|
|
266
|
+
* Success is asserted from the enumerated doctor run — never from "facts I
|
|
267
|
+
* gathered, minus known-bad cases." The unqualified `✅ verified: healthy`
|
|
268
|
+
* marker is emitted only when `run.healthy` is true. An unrun member, a
|
|
269
|
+
* failed member, or an empty catalog all withhold it.
|
|
270
|
+
*/
|
|
271
|
+
export function renderVerifiedSummary(version, run, opts) {
|
|
272
|
+
// `authenticated` is a probe fact, not a doctor-check special case. The
|
|
273
|
+
// healthy-unverified upgrade branch could not read /HealthDetail; claiming
|
|
274
|
+
// "authenticated" there contradicts the next line. Default stays true so
|
|
275
|
+
// the ok path is unchanged.
|
|
276
|
+
const authed = opts?.authenticated !== false;
|
|
277
|
+
const facts = `healthy${authed ? ", authenticated" : ""}${version ? `, running ${version}` : ""}`;
|
|
278
|
+
if (run.healthy) {
|
|
279
|
+
return { degraded: false, lines: [`✅ verified: ${facts}`] };
|
|
280
|
+
}
|
|
281
|
+
const lines = [];
|
|
282
|
+
const launchdFail = run.results.find((r) => r.id === "launchd-management" && r.status === "fail");
|
|
283
|
+
if (launchdFail?.launchd && isDetached(launchdFail.launchd)) {
|
|
284
|
+
lines.push(...renderDetachedWarning(launchdFail.launchd, `upgrade landed (${facts}) but the instance is NOT running under launchd.`));
|
|
285
|
+
}
|
|
286
|
+
else if (run.incomplete) {
|
|
287
|
+
const unrun = run.results.filter((r) => r.status === "unrun");
|
|
288
|
+
lines.push(`⚠️ upgrade landed (${facts}) but install checks are incomplete — ${unrun.length} check(s) were not run.`);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
const failed = run.results.filter((r) => r.status === "fail");
|
|
292
|
+
lines.push(`⚠️ upgrade landed (${facts}) but doctor found ${failed.length} issue${failed.length === 1 ? "" : "s"}.`);
|
|
293
|
+
}
|
|
294
|
+
for (const r of run.results) {
|
|
295
|
+
if (r.status === "unrun") {
|
|
296
|
+
lines.push(` UNRUN: ${r.label} — not yet checked (cannot treat as passing)`);
|
|
297
|
+
}
|
|
298
|
+
else if (r.status === "fail" && r.id !== "launchd-management") {
|
|
299
|
+
lines.push(` ✗ ${r.detail ?? r.label}`);
|
|
300
|
+
if (r.remedy)
|
|
301
|
+
lines.push(` Fix: ${r.remedy}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { degraded: true, lines };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Whether upgrade may write a SessionStart hook. Installing something that
|
|
308
|
+
* executes at every session start is consent-bearing — the flag is the
|
|
309
|
+
* documented non-interactive consent; a TTY prompt is the interactive one.
|
|
310
|
+
* Silence (no flag, no TTY) never writes.
|
|
311
|
+
*/
|
|
312
|
+
export function resolveHookInstallConsent(opts) {
|
|
313
|
+
if (opts.installHooksFlag)
|
|
314
|
+
return "install";
|
|
315
|
+
if (opts.interactive && opts.promptAccepted === undefined)
|
|
316
|
+
return "prompt";
|
|
317
|
+
if (opts.interactive && opts.promptAccepted)
|
|
318
|
+
return "install";
|
|
319
|
+
if (opts.interactive)
|
|
320
|
+
return "skip-declined";
|
|
321
|
+
return "skip-noninteractive";
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Interactive upgrade prompt for missing SessionStart hooks.
|
|
325
|
+
*
|
|
326
|
+
* Names EVERY missing harness (and its path) before asking. A yes after
|
|
327
|
+
* this prompt is consent to write each named hook — not an unnamed extra.
|
|
328
|
+
* Codex-only rationale is attached only when Codex is in the set.
|
|
329
|
+
*/
|
|
330
|
+
export function missingHookPromptLines(missing, homeDir) {
|
|
331
|
+
const named = missing.length > 0 ? [...missing] : ["codex"];
|
|
332
|
+
const preamble = [];
|
|
333
|
+
for (const h of named) {
|
|
334
|
+
preamble.push(`SessionStart hook (${h}) is not installed at ${hookSettingsPath(homeDir, h)}.`);
|
|
335
|
+
}
|
|
336
|
+
if (named.includes("codex")) {
|
|
337
|
+
preamble.push("Without the Codex hook, memory never bootstraps (no CLAUDE.md alternative on Codex).");
|
|
338
|
+
}
|
|
339
|
+
if (named.length > 1) {
|
|
340
|
+
preamble.push(`A yes installs the hook for ${named.join(" and ")} — each executes at session start.`);
|
|
341
|
+
}
|
|
342
|
+
const question = named.length === 1
|
|
343
|
+
? " Install the flair-session-start hook now? [y/N] "
|
|
344
|
+
: ` Install the flair-session-start hook for ${named.join(" and ")} now? [y/N] `;
|
|
345
|
+
return { preamble, question };
|
|
346
|
+
}
|
|
347
|
+
/** Plain-language lines when upgrade will not write the hook. */
|
|
348
|
+
export function missingHookWithoutConsentLines(harness, path) {
|
|
349
|
+
return [
|
|
350
|
+
`SessionStart hook (${harness}) is not installed at ${path}.`,
|
|
351
|
+
"This hook runs at every session start — a consent-bearing write, so upgrade will not install it unprompted.",
|
|
352
|
+
`Install with: flair upgrade --install-hooks`,
|
|
353
|
+
` or: ${hookInstallHint(harness)}`,
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
/** True when the SessionStart-hook check failed because a hook file is missing. */
|
|
357
|
+
export function sessionStartHookMissing(run) {
|
|
358
|
+
const hook = run.results.find((r) => r.id === "session-start-hook");
|
|
359
|
+
return hook?.status === "fail" && (hook.missingHarnesses?.length ?? 0) > 0;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Inputs for a consented `installHook` after upgrade. Prefers the missing
|
|
363
|
+
* harness's own MCP block, then env, then the provided fallbacks.
|
|
364
|
+
*/
|
|
365
|
+
export function resolveUpgradeHookInstall(homeDir, harness, fallbacks) {
|
|
366
|
+
const clientId = harness === "codex" ? "codex" : "claude-code";
|
|
367
|
+
const block = readClientMcpBlock(clientId, homeDir);
|
|
368
|
+
const agentId = (typeof process.env.FLAIR_AGENT_ID === "string" && process.env.FLAIR_AGENT_ID) ||
|
|
369
|
+
block.agentId ||
|
|
370
|
+
fallbacks.agentId;
|
|
371
|
+
const flairUrl = (typeof process.env.FLAIR_URL === "string" && process.env.FLAIR_URL) ||
|
|
372
|
+
block.flairUrl ||
|
|
373
|
+
fallbacks.flairUrl ||
|
|
374
|
+
(fallbacks.port ? `http://127.0.0.1:${fallbacks.port}` : "http://127.0.0.1:9926");
|
|
375
|
+
if (!agentId) {
|
|
376
|
+
return { error: "no agent id known — pass --agent or set FLAIR_AGENT_ID so the hook can be wired" };
|
|
377
|
+
}
|
|
378
|
+
return { agentId, flairUrl };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* How many catalog members block doctor / withhold upgrade's ✅.
|
|
382
|
+
* `fail` and `unrun` both count — an unrun member is never a pass.
|
|
383
|
+
*/
|
|
384
|
+
export function catalogBlockingCount(run) {
|
|
385
|
+
return run.results.filter((r) => r.status === "fail" || r.status === "unrun").length;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Found/fixed delta for `flair doctor --fix`. `found` is the pre-fix
|
|
389
|
+
* catalog blocking count; `fixed` is how many of those cleared after.
|
|
390
|
+
*/
|
|
391
|
+
export function catalogIssueDelta(before, after) {
|
|
392
|
+
const found = catalogBlockingCount(before);
|
|
393
|
+
const remaining = catalogBlockingCount(after);
|
|
394
|
+
return { found, fixed: Math.max(0, found - remaining) };
|
|
395
|
+
}
|
|
396
|
+
/** Compact catalog lines for doctor's Install health section. */
|
|
397
|
+
export function renderCatalogDoctorLines(run) {
|
|
398
|
+
return run.results.map((r) => {
|
|
399
|
+
const icon = r.status === "pass" || r.status === "skip" ? "ok" : r.status === "unrun" ? "warn" : "error";
|
|
400
|
+
const word = r.status === "skip" ? "n/a" : r.status;
|
|
401
|
+
const detail = r.detail ? ` — ${r.detail}` : "";
|
|
402
|
+
return { icon, line: `${r.label}: ${word}${detail}` };
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Consent → write composition for missing SessionStart hooks after a
|
|
407
|
+
* catalog run. Silence (no flag, not interactive) never creates a hook
|
|
408
|
+
* file. `--install-hooks` or an accepted prompt writes every named
|
|
409
|
+
* missing harness via `installHook` (or `writeHook`).
|
|
410
|
+
*
|
|
411
|
+
* This is the path `flair upgrade` actually uses. Testing
|
|
412
|
+
* `resolveHookInstallConsent` alone would stay green if the caller
|
|
413
|
+
* always wrote anyway.
|
|
414
|
+
*/
|
|
415
|
+
export function applyUpgradeHookConsent(opts) {
|
|
416
|
+
const homeDir = opts.homeDir;
|
|
417
|
+
const empty = {
|
|
418
|
+
run: opts.run,
|
|
419
|
+
consent: "skip-declined",
|
|
420
|
+
missing: [],
|
|
421
|
+
written: [],
|
|
422
|
+
writes: [],
|
|
423
|
+
messages: [],
|
|
424
|
+
};
|
|
425
|
+
if (!sessionStartHookMissing(opts.run)) {
|
|
426
|
+
return { ...empty, consent: "install" };
|
|
427
|
+
}
|
|
428
|
+
const missing = opts.run.results.find((r) => r.id === "session-start-hook")?.missingHarnesses ?? [];
|
|
429
|
+
const consent = resolveHookInstallConsent({
|
|
430
|
+
installHooksFlag: opts.installHooksFlag,
|
|
431
|
+
interactive: opts.interactive,
|
|
432
|
+
promptAccepted: opts.promptAccepted,
|
|
433
|
+
});
|
|
434
|
+
const prompt = missingHookPromptLines(missing, homeDir);
|
|
435
|
+
if (consent === "prompt") {
|
|
436
|
+
return { ...empty, consent, missing, prompt };
|
|
437
|
+
}
|
|
438
|
+
if (consent === "install") {
|
|
439
|
+
const write = opts.writeHook ?? installHook;
|
|
440
|
+
const writes = [];
|
|
441
|
+
const written = [];
|
|
442
|
+
const messages = [];
|
|
443
|
+
for (const harness of missing) {
|
|
444
|
+
const inputs = resolveUpgradeHookInstall(homeDir, harness, {
|
|
445
|
+
port: opts.port,
|
|
446
|
+
agentId: opts.agentId,
|
|
447
|
+
flairUrl: opts.flairUrl,
|
|
448
|
+
});
|
|
449
|
+
if ("error" in inputs) {
|
|
450
|
+
messages.push(inputs.error);
|
|
451
|
+
writes.push({ harness, ok: false, message: inputs.error });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const installed = write({
|
|
455
|
+
homeDir,
|
|
456
|
+
harness,
|
|
457
|
+
agentId: inputs.agentId,
|
|
458
|
+
flairUrl: inputs.flairUrl,
|
|
459
|
+
});
|
|
460
|
+
writes.push({ harness, ok: installed.ok, message: installed.message });
|
|
461
|
+
messages.push(installed.message);
|
|
462
|
+
if (installed.ok)
|
|
463
|
+
written.push(harness);
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
run: runDoctorChecks(opts.ctx),
|
|
467
|
+
consent,
|
|
468
|
+
missing,
|
|
469
|
+
written,
|
|
470
|
+
writes,
|
|
471
|
+
messages,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const messages = [];
|
|
475
|
+
if (consent === "skip-noninteractive") {
|
|
476
|
+
for (const harness of missing) {
|
|
477
|
+
messages.push(...missingHookWithoutConsentLines(harness, hookSettingsPath(homeDir, harness)));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { ...empty, consent, missing, messages };
|
|
481
|
+
}
|
|
@@ -300,29 +300,10 @@ export function renderDetachedWarning(m, headline) {
|
|
|
300
300
|
lines.push(` Fix: ${m.remedy.join(" && ")}`);
|
|
301
301
|
return lines;
|
|
302
302
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
* fourth, unmeasured fact (the instance had been dropped out of its process
|
|
311
|
-
* manager) was the one that mattered. Deciding it here rather than inline in
|
|
312
|
-
* the command means the decision is testable without performing an upgrade,
|
|
313
|
-
* which on the darwin path nothing else can do: no CI lane runs it.
|
|
314
|
-
*
|
|
315
|
-
* The verified facts are still reported in the degraded case. The upgrade DID
|
|
316
|
-
* land, and hiding that would swap one misleading summary for another; what
|
|
317
|
-
* changes is the marker and the sentence around it.
|
|
318
|
-
*/
|
|
319
|
-
export function renderVerifiedSummary(version, m) {
|
|
320
|
-
const facts = `healthy, authenticated${version ? `, running ${version}` : ""}`;
|
|
321
|
-
if (!isDetached(m)) {
|
|
322
|
-
return { degraded: false, lines: [`✅ verified: ${facts}`] };
|
|
323
|
-
}
|
|
324
|
-
return {
|
|
325
|
-
degraded: true,
|
|
326
|
-
lines: renderDetachedWarning(m, `upgrade landed (${facts}) but the instance is NOT running under launchd.`),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
303
|
+
// renderVerifiedSummary used to live here and qualify ✅ from version +
|
|
304
|
+
// LaunchdManagement alone (flair#1022). That signature was the ceiling on
|
|
305
|
+
// what it could notice — a blacklist of one known-unhealthy form. The
|
|
306
|
+
// enumerated doctor runner in doctor-run.ts is now the source of the
|
|
307
|
+
// success marker (flair#1439). renderDetachedWarning stays: it is the
|
|
308
|
+
// wording for a detached launchd check, used by the runner and by
|
|
309
|
+
// `flair restart`.
|