@tpsdev-ai/flair 0.33.0 → 0.35.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.
@@ -7,15 +7,19 @@
7
7
  // but no CLAUDE.md line; or no SessionStart hook) that silently no-op, with
8
8
  // no way to tell "is Flair working for my agent?" short of an incident.
9
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
10
+ // This module is filesystem logic (no network, no crypto) so it's fast and
11
+ // fully unit-testable in isolation — mirrors test/unit/client-wiring.test.ts's
12
12
  // technique of overriding process.env.HOME to a temp dir. The two
13
13
  // network-dependent checks (reachability + agent registration) live in
14
- // src/cli.ts alongside authFetch/resolveKeyPath, which they reuse.
14
+ // src/cli.ts alongside authFetch/resolveKeyPath, which they reuse. The one
15
+ // exception to "filesystem only" is probeSessionStartHookCommand (flair#1007),
16
+ // which spawns a bounded subprocess — it takes an injectable runner so every
17
+ // caller in the test suite stays hermetic.
15
18
  //
16
19
  // Every read here is try/catch-wrapped: a missing or malformed config file is
17
20
  // "not present", never a thrown error — doctor must never crash or hang on a
18
21
  // broken client config.
22
+ import { spawnSync } from "node:child_process";
19
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
24
  import { dirname, join } from "node:path";
21
25
  import { clientConfigPath } from "./install/clients.js";
@@ -26,6 +30,129 @@ const CLAUDE_MD_BOOTSTRAP_LINE = "At the start of every session, run mcp__flair_
26
30
  // The exact substring identifying a Flair SessionStart hook command (see
27
31
  // docs/mcp-clients.md "Auto-recall on session start").
28
32
  export const SESSION_START_HOOK_MARKER = "flair-session-start";
33
+ // ── the canonical SessionStart hook command (flair#1007) ───────────────────
34
+ //
35
+ // ONE builder, three call sites: `flair doctor --fix` (fixSessionStartHook),
36
+ // `flair init`'s copy-paste hint (sessionStartHookHint), and `flair hook
37
+ // install` (src/hook-install.ts's buildHookCommand, which delegates here).
38
+ // Before #1007 each of those carried its own literal copy of the string, so a
39
+ // change to the invocation's failure behaviour had to be made in three places
40
+ // and could be tested in none of them.
41
+ //
42
+ // WHY THE INVOCATION IS WRAPPED
43
+ // -----------------------------
44
+ // The hook runs `npx -y @tpsdev-ai/flair-mcp flair-session-start`: it resolves
45
+ // a package binary through whatever Node runtime the user's shell happens to
46
+ // expose. Under a Node version manager, globally installed packages are
47
+ // per-runtime-version, so a routine and entirely unrelated runtime upgrade
48
+ // orphans that binary. The command then stops resolving and the harness
49
+ // reports a hook error on EVERY session, indefinitely, in wording that names
50
+ // neither Flair nor a remedy. It also outlives Flair itself: uninstalling the
51
+ // CLI does not remove the hook, so the error survives the tool it exists to
52
+ // serve (flair#1007).
53
+ //
54
+ // packages/flair-mcp/src/session-start-hook.ts already guarantees
55
+ // no-op-on-any-failure — but that guarantee lives INSIDE the binary which, in
56
+ // this failure, never runs. The guard is behind the door it is meant to guard.
57
+ // The only layer that still exists when the binary does not is the command
58
+ // string itself, so that is where the silence has to be enforced.
59
+ //
60
+ // WHY `sh -c '...'` RATHER THAN A BARE FRAGMENT
61
+ // ---------------------------------------------
62
+ // Claude Code runs a `type: "command"` hook as `/bin/sh -c "<command>"` — it
63
+ // spawns with `shell: true` and never consults $SHELL (verified against the
64
+ // 2.1.220 bundle; the settings schema's claim that `"shell": "bash"` uses your
65
+ // $SHELL is not what the code does on POSIX). So a bare POSIX fragment would
66
+ // in fact be enough for the one harness we support today.
67
+ //
68
+ // It is still wrapped, because this string is not private to that harness:
69
+ // SUPPORTED_HARNESSES (src/hook-install.ts) is a registry meant to grow, and
70
+ // docs/mcp-clients.md publishes this exact command for hand-wiring. Measured
71
+ // across sh, bash, zsh, dash, ksh, fish and tcsh:
72
+ // - the wrapped form is uniform — stdout passed through byte-for-byte on
73
+ // success; empty stdout, empty stderr and exit 0 on every failure;
74
+ // - a bare `out=$(...) && ... || true` fragment is a SYNTAX ERROR in fish and
75
+ // csh/tcsh — silent on some shells, LOUDER than before on others, which is
76
+ // not a fix;
77
+ // - the unwrapped pre-#1007 command is already broken outright in csh/tcsh
78
+ // ("FLAIR_AGENT_ID=me: Command not found."), which the wrapper fixes.
79
+ // The cost is one extra process at session start; the benefit is that the
80
+ // silence is a property of the string rather than of who runs it.
81
+ //
82
+ // WHY STDOUT IS CAPTURED, NOT JUST STDERR DISCARDED
83
+ // -------------------------------------------------
84
+ // Exit code alone is not the whole surface. In the same bundle, a SessionStart
85
+ // hook's stdout is consumed two ways EVEN AT EXIT 0:
86
+ // - stdout that does not start with `{` is injected into the model's context
87
+ // verbatim, as a `<hook> hook success: <stdout>` meta message;
88
+ // - stdout that starts with `{` but fails to parse/validate is reported as a
89
+ // hook error — at exit 0, presented as exit 1.
90
+ // So a failing resolver that happens to print on stdout would either become
91
+ // silently injected context or produce the exact error class this issue is
92
+ // about, no matter what the exit code says. Capturing stdout and emitting it
93
+ // only on success closes both; discarding stderr alone would close neither.
94
+ /**
95
+ * Values interpolated into the hook command are constrained by a strict
96
+ * allow-list rather than escaped. The command is a single-quoted `sh -c`
97
+ * argument and correct single-quote escaping is NOT uniform across the shells
98
+ * above (fish and csh disagree with POSIX), so "cannot contain a quote" is
99
+ * enforced by shape instead of handled by quoting. This is also strictly
100
+ * safer than the pre-#1007 command, where an agent id containing a space or a
101
+ * `;` was a command injection into the user's settings file.
102
+ */
103
+ const HOOK_VALUE_SAFE_RE = /^[A-Za-z0-9._:/-]+$/;
104
+ export function isHookCommandValueSafe(value) {
105
+ return typeof value === "string" && HOOK_VALUE_SAFE_RE.test(value);
106
+ }
107
+ /**
108
+ * Build the exact `command` string to register as a SessionStart hook.
109
+ * Throws (rather than emitting a quoted approximation) when a value cannot be
110
+ * represented safely — see HOOK_VALUE_SAFE_RE.
111
+ */
112
+ export function buildSessionStartHookCommand(agentId, flairUrl) {
113
+ if (!isHookCommandValueSafe(agentId)) {
114
+ throw new Error(`agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
115
+ }
116
+ if (flairUrl != null && flairUrl !== "" && !isHookCommandValueSafe(flairUrl)) {
117
+ throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
118
+ }
119
+ const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
120
+ const invocation = `${env} npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
121
+ return `sh -c 'out=$(${invocation} 2>/dev/null) && printf %s "$out" || true'`;
122
+ }
123
+ /**
124
+ * Does this command absorb a failure instead of surfacing it? Checked as two
125
+ * independent PROPERTIES (stderr discarded, non-zero exit absorbed) rather
126
+ * than by string equality with what we currently emit, so a hand-rolled
127
+ * command that genuinely achieves both is not nagged about.
128
+ */
129
+ export function hookCommandIsSilenced(command) {
130
+ if (typeof command !== "string")
131
+ return false;
132
+ const discardsStderr = command.includes("2>/dev/null") || command.includes("2>&-");
133
+ const absorbsFailure = /\|\|\s*(?:true|:)(?:\s|'|$)/.test(command) || /;\s*(?:true|:)\s*'?\s*$/.test(command);
134
+ return discardsStderr && absorbsFailure;
135
+ }
136
+ /**
137
+ * The EXACT unwrapped shape Flair wrote before #1007. Recognising it precisely
138
+ * (not "anything containing the marker") is what lets `flair doctor --fix`
139
+ * upgrade a hook we know we authored while never rewriting one a user placed
140
+ * or edited themselves.
141
+ */
142
+ const LEGACY_SESSION_START_HOOK_RE = /^FLAIR_AGENT_ID=([^\s'"]+)(?: FLAIR_URL=([^\s'"]+))? npx -y @tpsdev-ai\/flair-mcp flair-session-start$/;
143
+ export function parseLegacySessionStartHookCommand(command) {
144
+ if (typeof command !== "string")
145
+ return null;
146
+ const m = command.trim().match(LEGACY_SESSION_START_HOOK_RE);
147
+ if (!m)
148
+ return null;
149
+ return { agentId: m[1], flairUrl: m[2] };
150
+ }
151
+ /** Does this command invoke the Flair adapter at all (pinned or not)? Only
152
+ * such a command is ever probed or rewritten. */
153
+ export function isFlairHookCommand(command) {
154
+ return typeof command === "string" && command.includes("@tpsdev-ai/flair-mcp") && command.includes(SESSION_START_HOOK_MARKER);
155
+ }
29
156
  // ── shared helpers ──────────────────────────────────────────────────────────
30
157
  /**
31
158
  * Run `fn` with process.env.HOME temporarily pointed at `homeDir`, then
@@ -228,7 +355,7 @@ export function checkSessionStartHook(homeDir) {
228
355
  continue;
229
356
  for (const hook of hooks) {
230
357
  if (typeof hook?.command === "string" && hook.command.includes(SESSION_START_HOOK_MARKER)) {
231
- return { present: true, path };
358
+ return { present: true, path, command: hook.command };
232
359
  }
233
360
  }
234
361
  }
@@ -255,6 +382,13 @@ export function fixSessionStartHook(homeDir, agentId) {
255
382
  message: "no agent id known — pass --agent <id> (or set FLAIR_AGENT_ID) so doctor knows which agent to wire the hook to",
256
383
  };
257
384
  }
385
+ if (!isHookCommandValueSafe(agentId)) {
386
+ return {
387
+ ok: false,
388
+ path,
389
+ message: `agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`,
390
+ };
391
+ }
258
392
  try {
259
393
  let config = {};
260
394
  const raw = readTextFile(path);
@@ -271,7 +405,7 @@ export function fixSessionStartHook(homeDir, agentId) {
271
405
  hooks: [
272
406
  {
273
407
  type: "command",
274
- command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
408
+ command: buildSessionStartHookCommand(agentId),
275
409
  },
276
410
  ],
277
411
  });
@@ -284,6 +418,172 @@ export function fixSessionStartHook(homeDir, agentId) {
284
418
  return { ok: false, path, message: `could not write ${path}: ${reason}` };
285
419
  }
286
420
  }
421
+ /** Default probe budget. Generous: a cold `npx` may have to reach the registry
422
+ * before it can answer, and a slow answer must never be reported as a broken
423
+ * hook — that is what the "unknown" verdict is for. */
424
+ export const HOOK_PROBE_TIMEOUT_MS = 20_000;
425
+ /** Package-manager chatter that is never the reason a hook failed. The harness
426
+ * itself reports the FIRST stderr line, which on a modern npm is one of these
427
+ * — so doctor deliberately does better than repeating the symptom back. */
428
+ const NOISE_LINE_RE = /^(?:npm|yarn|pnpm|bun)\s+(?:notice|warn|info|http|verb)\b/i;
429
+ /** The most informative single line of a failed probe's output. */
430
+ export function evidenceLine(s, max = 200) {
431
+ const lines = (s || "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
432
+ const line = lines.find((l) => !NOISE_LINE_RE.test(l)) ?? lines[0] ?? "";
433
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
434
+ }
435
+ const defaultProbeRunner = (command, timeoutMs) => {
436
+ // `/bin/sh -c` is not a guess: it is exactly how Claude Code runs a
437
+ // `type: "command"` hook (spawn with `shell: true`, $SHELL never consulted).
438
+ // Probing through any other shell would answer a question the user never
439
+ // asked.
440
+ const res = spawnSync("/bin/sh", ["-c", command], {
441
+ input: "",
442
+ encoding: "utf-8",
443
+ timeout: timeoutMs,
444
+ env: {
445
+ ...process.env,
446
+ // Tell a #1007-or-later adapter to answer without side effects.
447
+ FLAIR_HOOK_PROBE: "1",
448
+ // Bound an OLDER adapter, which will do a real bootstrap + presence
449
+ // heartbeat because it has never heard of FLAIR_HOOK_PROBE.
450
+ FLAIR_HOOK_TIMEOUT_MS: "1500",
451
+ FLAIR_PRESENCE_TIMEOUT_MS: "500",
452
+ },
453
+ });
454
+ const timedOut = res.signal === "SIGTERM" && res.status === null;
455
+ return {
456
+ exitCode: res.status,
457
+ stdout: res.stdout ?? "",
458
+ stderr: res.stderr ?? "",
459
+ timedOut,
460
+ spawnError: res.error && !timedOut ? res.error.message : null,
461
+ };
462
+ };
463
+ /**
464
+ * Run a registered hook command once, bounded, with probe-mode env set.
465
+ * Only ever called for commands isFlairHookCommand() recognises — doctor does
466
+ * not execute a stranger's hook to find out what it does.
467
+ */
468
+ export function probeSessionStartHookCommand(command, opts = {}) {
469
+ const timeoutMs = opts.timeoutMs ?? HOOK_PROBE_TIMEOUT_MS;
470
+ const runner = opts.runner ?? defaultProbeRunner;
471
+ try {
472
+ return runner(command, timeoutMs);
473
+ }
474
+ catch (err) {
475
+ const reason = err instanceof Error ? err.message : String(err);
476
+ return { exitCode: null, stdout: "", stderr: "", timedOut: false, spawnError: reason };
477
+ }
478
+ }
479
+ /** Pure: probe outcome -> verdict. See the section doc for why "exit 0, no
480
+ * output" is a definite failure rather than an ambiguous one. */
481
+ export function classifyHookProbe(outcome) {
482
+ if (outcome.timedOut) {
483
+ return { execution: "unknown", detail: "the command did not answer in time" };
484
+ }
485
+ if (outcome.spawnError) {
486
+ return { execution: "unknown", detail: `could not run the command (${outcome.spawnError})` };
487
+ }
488
+ if (outcome.exitCode !== 0) {
489
+ const evidence = evidenceLine(outcome.stderr) || evidenceLine(outcome.stdout);
490
+ return {
491
+ execution: "broken",
492
+ detail: evidence ? `exited ${outcome.exitCode}: ${evidence}` : `exited ${outcome.exitCode}`,
493
+ };
494
+ }
495
+ if (!outcome.stdout.trim()) {
496
+ return {
497
+ execution: "broken",
498
+ detail: "the command produced no output — the flair-session-start binary never ran",
499
+ };
500
+ }
501
+ return { execution: "runs" };
502
+ }
503
+ /**
504
+ * Full report for the registered hook: presence (as before), plus whether its
505
+ * command still executes and whether it would fail quietly if it stopped.
506
+ * `probe` is injectable and defaults to the real bounded spawn; pass a stub
507
+ * (or null via `{ probe: false }`) to keep a caller hermetic.
508
+ */
509
+ export function inspectSessionStartHook(homeDir, opts = {}) {
510
+ const found = checkSessionStartHook(homeDir);
511
+ if (!found.present || !found.command) {
512
+ return { path: found.path, present: false, ours: false, silenced: false, upgradable: false, execution: null };
513
+ }
514
+ const command = found.command;
515
+ const ours = isFlairHookCommand(command);
516
+ const silenced = hookCommandIsSilenced(command);
517
+ const upgradable = parseLegacySessionStartHookCommand(command) !== null;
518
+ if (!ours || opts.probe === false) {
519
+ return { path: found.path, present: true, command, ours, silenced, upgradable, execution: null };
520
+ }
521
+ const outcome = probeSessionStartHookCommand(command, {
522
+ timeoutMs: opts.timeoutMs,
523
+ runner: opts.probe || undefined,
524
+ });
525
+ const verdict = classifyHookProbe(outcome);
526
+ return { path: found.path, present: true, command, ours, silenced, upgradable, execution: verdict.execution, detail: verdict.detail };
527
+ }
528
+ /**
529
+ * Rewrite an existing Flair-authored hook command to the current canonical
530
+ * form, in place, preserving the agent id and URL the entry already carries —
531
+ * so this never needs --agent and never re-points a hook at a different agent.
532
+ *
533
+ * Deliberately narrow: it refuses unless the registered command is the EXACT
534
+ * unwrapped string Flair used to write. A hook the user hand-wrote or pinned
535
+ * is theirs; doctor reports on it and leaves it alone. And it never REMOVES a
536
+ * hook — an unresolvable command is an environment that changed, not a
537
+ * decision to un-wire ambient memory, and `flair hook uninstall` is the
538
+ * command for that when the user does decide.
539
+ */
540
+ export function upgradeSessionStartHookCommand(homeDir) {
541
+ const path = join(homeDir, ".claude", "settings.json");
542
+ try {
543
+ const raw = readTextFile(path);
544
+ if (!raw || !raw.trim())
545
+ return { ok: false, path, changed: false, message: `no ${path} to update` };
546
+ const config = JSON.parse(raw);
547
+ const groups = config?.hooks?.SessionStart;
548
+ if (!Array.isArray(groups))
549
+ return { ok: false, path, changed: false, message: `no SessionStart hooks in ${path}` };
550
+ for (const group of groups) {
551
+ const hooks = group?.hooks;
552
+ if (!Array.isArray(hooks))
553
+ continue;
554
+ for (const hook of hooks) {
555
+ if (typeof hook?.command !== "string" || !hook.command.includes(SESSION_START_HOOK_MARKER))
556
+ continue;
557
+ // Already absorbing its own failures — nothing to repair, whether we
558
+ // wrote it or the user did. Checked BEFORE the legacy match so a second
559
+ // run is a clean no-op rather than "that isn't the command we wrote".
560
+ if (hookCommandIsSilenced(hook.command)) {
561
+ return { ok: true, path, changed: false, message: `SessionStart hook in ${path} is already current` };
562
+ }
563
+ const legacy = parseLegacySessionStartHookCommand(hook.command);
564
+ if (!legacy) {
565
+ return {
566
+ ok: false,
567
+ path,
568
+ changed: false,
569
+ message: `the SessionStart hook in ${path} is not the command Flair wrote — leaving it untouched`,
570
+ };
571
+ }
572
+ const next = buildSessionStartHookCommand(legacy.agentId, legacy.flairUrl);
573
+ if (next === hook.command)
574
+ return { ok: true, path, changed: false, message: `SessionStart hook in ${path} is already current` };
575
+ hook.command = next;
576
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
577
+ return { ok: true, path, changed: true, message: `rewrote the SessionStart hook in ${path} so a failure to resolve stays silent` };
578
+ }
579
+ }
580
+ return { ok: false, path, changed: false, message: `no Flair SessionStart hook found in ${path}` };
581
+ }
582
+ catch (err) {
583
+ const reason = err instanceof Error ? err.message : String(err);
584
+ return { ok: false, path, changed: false, message: `could not update ${path}: ${reason}` };
585
+ }
586
+ }
287
587
  function indentLines(s) {
288
588
  return s
289
589
  .split("\n")
@@ -315,7 +615,9 @@ function sessionStartHookHint(agentId, path) {
315
615
  hooks: [
316
616
  {
317
617
  type: "command",
318
- command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
618
+ command: isHookCommandValueSafe(agentId)
619
+ ? buildSessionStartHookCommand(agentId)
620
+ : buildSessionStartHookCommand("me"),
319
621
  },
320
622
  ],
321
623
  },
@@ -420,6 +722,27 @@ export function fixCommandAgentHint(keyAgentIds) {
420
722
  return "";
421
723
  return ` --agent ${[...keyAgentIds].sort()[0]}`;
422
724
  }
725
+ /**
726
+ * The remedy line to print under doctor's "Embeddings: not verified" warning,
727
+ * or null when no remedy applies (flair#1023 requirement 4).
728
+ *
729
+ * doctor used to print "Pass --agent <id> ..." for EVERY skip reason. That
730
+ * sentence only fixes the two cases where no agent identity was resolved. It
731
+ * cannot fix a key that will not decode — following it produces the identical
732
+ * error — and it cannot fix an HTTP failure from the probe itself. Printing a
733
+ * remedy that provably will not change the outcome spends the operator's time
734
+ * and is worse than printing nothing, so those cases now print nothing.
735
+ */
736
+ export function embeddingsSkipRemedy(reason) {
737
+ switch (reason) {
738
+ case "no-agent":
739
+ case "no-key":
740
+ return "Pass --agent <id> (or set FLAIR_AGENT_ID) so doctor can run a real semantic round-trip.";
741
+ case "key-load":
742
+ case "probe-failed":
743
+ return null;
744
+ }
745
+ }
423
746
  /**
424
747
  * Render decision for one agent's registration-gate outcome, ahead of a
425
748
  * verified-read section (Fleet presence / Migrations). Returns null when the
@@ -428,10 +751,37 @@ export function fixCommandAgentHint(keyAgentIds) {
428
751
  * agent's subsection; the caller must still move on to the next agent
429
752
  * (failure isolation, flair#722) rather than aborting the whole section.
430
753
  */
431
- export function describeAgentGateFinding(agentId, state, detail) {
754
+ export function describeAgentGateFinding(agentId, state, detail, reachability) {
755
+ // Self-inconsistency guard. "unreachable" is a legitimate state — a bare
756
+ // 500, a timeout — but it must never be ASSERTED after this run has already
757
+ // watched the instance answer. When it is, report the honest thing: the
758
+ // check failed, and we know it was not connectivity.
759
+ if (state === "unreachable" && reachability?.instanceReachable === true) {
760
+ // Strip the caller's own "instance unreachable:" prefix before quoting the
761
+ // detail — that prefix IS the claim being disclaimed, and leaving it in
762
+ // would reassert it in the same sentence that denies it.
763
+ const raw = detail?.replace(/^instance unreachable:\s*/i, "").trim();
764
+ return {
765
+ icon: "warn",
766
+ message: `could not verify agent '${agentId}' registration — the instance responded to this run, ` +
767
+ `so this is not a connectivity problem${raw ? ` (${raw})` : ""}`,
768
+ isIssue: false,
769
+ };
770
+ }
432
771
  switch (state) {
433
772
  case "registered":
434
773
  return null;
774
+ case "key-unreadable":
775
+ return {
776
+ icon: "warn",
777
+ // Names the file and the operation. No cause is invented and no fix
778
+ // hint is offered: the failure classes that land here (a corrupt key,
779
+ // a file that is not an agent key at all) have different remedies and
780
+ // we cannot tell them apart from the bytes. A remedy we cannot stand
781
+ // behind is the flair#1023 defect, not a fix for it.
782
+ message: `could not verify agent '${agentId}' registration — ${detail ?? "its signing key could not be loaded"}`,
783
+ isIssue: false,
784
+ };
435
785
  case "no-key":
436
786
  return {
437
787
  icon: "warn",
@@ -517,7 +867,19 @@ export function resolveCollisionSafeName(existingNames, filename) {
517
867
  */
518
868
  export function classifyKeyFile(agentId, seedValid, registration, baseUrl) {
519
869
  if (!seedValid) {
520
- return { class: "invalid", reason: "not a parseable Ed25519 private key seed" };
870
+ // NOT "invalid", and therefore NOT prunable. "I could not parse this" and
871
+ // "this is a stale agent key" are different findings, and only the second
872
+ // is safe to act on. `~/.flair/keys/<id>.key` is a namespace shared by two
873
+ // writers: plaintext Ed25519 seeds, and AES-256-GCM keystore blobs written
874
+ // by FileKeyStore (flair#1026). A keystore blob is unparseable AS A SEED
875
+ // while being a LIVE federation key — classifying it "invalid" moved a key
876
+ // that was in use. An unidentified file is reported for a human and left
877
+ // exactly where it is.
878
+ return {
879
+ class: "unidentified",
880
+ reason: "not a parseable Ed25519 private key seed — may be a keystore blob or another format; " +
881
+ "left in place, inspect it before removing anything (flair#1026)",
882
+ };
521
883
  }
522
884
  if (registration?.state === "registered") {
523
885
  return { class: "keep", reason: `agent '${agentId}' is registered on ${baseUrl} — never pruned` };