@tpsdev-ai/flair 0.22.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8131,6 +8131,46 @@ see src/fleet-verify.ts's file header for the full caveat.`)
8131
8131
  }
8132
8132
  process.exit(result.exitCode);
8133
8133
  });
8134
+ // ─── flair doctor — pure summary/exit helper ─────────────────────────────────
8135
+ // Extracted for testability (flair#721), same pattern as formatCandidateLine /
8136
+ // describeReflectError above: the action callback spawns process.exit and a
8137
+ // long sequence of console.log side effects, which makes it high-effort/
8138
+ // low-value to drive directly — this is the actual decision logic. Before
8139
+ // #721, doctor tracked only a single `issues` counter: every detected
8140
+ // problem incremented it, and the final summary/exit-code read that counter
8141
+ // alone, with no separate record of which of those issues `--fix` actually
8142
+ // resolved during the same run. So a `--fix` run that interactively fixed
8143
+ // every issue it found still printed "N issues found — see fixes above" and
8144
+ // exited 1 — indistinguishable from a run that fixed nothing. This helper
8145
+ // takes the accumulated found/fixed counts plus whether `--fix` was passed
8146
+ // at all, and decides the summary line + exit code:
8147
+ // - 0 found → "No issues found", exit 0 (unchanged)
8148
+ // - found, no --fix → "N issues found — see fixes above", exit 1 (unchanged)
8149
+ // - found, --fix, all fixed → "N issues found, N fixed ✓", exit 0
8150
+ // - found, --fix, some remaining → "N issues found, M fixed, K remaining", exit 1
8151
+ export function summarizeDoctorRun(found, fixed, autoFix) {
8152
+ const plural = (n) => `issue${n === 1 ? "" : "s"}`;
8153
+ if (found === 0) {
8154
+ return { line: ` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`, exitCode: 0 };
8155
+ }
8156
+ if (!autoFix) {
8157
+ return {
8158
+ line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`,
8159
+ exitCode: 1,
8160
+ };
8161
+ }
8162
+ if (fixed >= found) {
8163
+ return {
8164
+ line: ` ${render.icons.ok} ${render.wrap(render.c.green, `${found} ${plural(found)} found, ${fixed} fixed ✓`)}`,
8165
+ exitCode: 0,
8166
+ };
8167
+ }
8168
+ const remaining = found - fixed;
8169
+ return {
8170
+ line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found, ${fixed} fixed, ${remaining} remaining`)}`,
8171
+ exitCode: 1,
8172
+ };
8173
+ }
8134
8174
  // ─── flair doctor ─────────────────────────────────────────────────────────────
8135
8175
  program
8136
8176
  .command("doctor")
@@ -8149,6 +8189,7 @@ program
8149
8189
  let effectivePort = port;
8150
8190
  let baseUrl = `http://127.0.0.1:${port}`;
8151
8191
  let issues = 0;
8192
+ let fixed = 0; // issues that --fix successfully resolved during this run (flair#721)
8152
8193
  let harperResponding = false;
8153
8194
  console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
8154
8195
  // 0. Version check (flair#587) — offline-tolerant + cached, independent
@@ -8234,6 +8275,7 @@ program
8234
8275
  else {
8235
8276
  writeConfig(discoveredPort);
8236
8277
  console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
8278
+ fixed++;
8237
8279
  }
8238
8280
  }
8239
8281
  else {
@@ -8277,6 +8319,7 @@ program
8277
8319
  const { execSync } = await import("node:child_process");
8278
8320
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
8279
8321
  console.log(` ${render.icons.ok} Restart attempted`);
8322
+ fixed++;
8280
8323
  }
8281
8324
  catch {
8282
8325
  console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
@@ -8319,6 +8362,7 @@ program
8319
8362
  const { execSync } = await import("node:child_process");
8320
8363
  execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${effectivePort}`, { stdio: "inherit" });
8321
8364
  console.log(` ${render.icons.ok} Restarted onto ${__pkgVersion}`);
8365
+ fixed++;
8322
8366
  }
8323
8367
  catch {
8324
8368
  console.log(` ${render.icons.error} Restart failed — try: flair restart`);
@@ -8418,6 +8462,7 @@ program
8418
8462
  else {
8419
8463
  (await import("node:fs")).unlinkSync(pidFile);
8420
8464
  console.log(` ${render.icons.ok} Removed stale PID file`);
8465
+ fixed++;
8421
8466
  }
8422
8467
  }
8423
8468
  else {
@@ -8498,6 +8543,8 @@ program
8498
8543
  client.id === "gemini" ? wireGemini(wireEnv) :
8499
8544
  wireCursor(wireEnv);
8500
8545
  console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
8546
+ if (wireResult.ok)
8547
+ fixed++;
8501
8548
  }
8502
8549
  }
8503
8550
  }
@@ -8549,6 +8596,8 @@ program
8549
8596
  else {
8550
8597
  const fixRes = fixClaudeMdBootstrap(process.cwd());
8551
8598
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
8599
+ if (fixRes.ok)
8600
+ fixed++;
8552
8601
  }
8553
8602
  }
8554
8603
  }
@@ -8576,6 +8625,8 @@ program
8576
8625
  const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
8577
8626
  const fixRes = fixSessionStartHook(homedir(), fixAgentId);
8578
8627
  console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
8628
+ if (fixRes.ok)
8629
+ fixed++;
8579
8630
  }
8580
8631
  }
8581
8632
  }
@@ -8726,17 +8777,14 @@ program
8726
8777
  console.log(` ${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
8727
8778
  }
8728
8779
  }
8729
- // Summary
8780
+ // Summary — see summarizeDoctorRun above (flair#721): distinguishes
8781
+ // issues --fix actually resolved this run from ones still outstanding.
8730
8782
  console.log("");
8731
- if (issues === 0) {
8732
- console.log(` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`);
8733
- }
8734
- else {
8735
- console.log(` ${render.icons.error} ${render.wrap(render.c.red, `${issues} issue${issues > 1 ? "s" : ""} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`);
8736
- }
8783
+ const summary = summarizeDoctorRun(issues, fixed, autoFix);
8784
+ console.log(summary.line);
8737
8785
  console.log("");
8738
- if (issues > 0)
8739
- process.exit(1);
8786
+ if (summary.exitCode !== 0)
8787
+ process.exit(summary.exitCode);
8740
8788
  });
8741
8789
  // ─── flair session snapshot ──────────────────────────────────────────────────
8742
8790
  // Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
@@ -70,7 +70,6 @@ function resolveDeps(deps) {
70
70
  ledgerDeps: deps.ledgerDeps ?? {},
71
71
  batchDelayMs: deps.batchDelayMs ?? resolveTestBatchDelayMs() ?? 100,
72
72
  initiator: deps.initiator ?? "auto",
73
- headroomFloor: deps.headroomFloor,
74
73
  };
75
74
  }
76
75
  // ─── space/snapshot sizing heuristics (overridable via RunnerDeps in future if ever needed) ──
@@ -256,11 +255,11 @@ async function runOneMigration(migration, envelope, deps, lock) {
256
255
  }
257
256
  const estSnapshot = estimateSnapshotBytes(posture.snapshotScope, initialRemaining);
258
257
  const estWorkingSet = estimateWorkingSetBytes(migration.riskClass, initialRemaining);
259
- let space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet, headroomFloor: deps.headroomFloor }, deps.spaceProbe);
258
+ let space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
260
259
  if (!space.ok) {
261
260
  // ── Ladder step 2: prune snapshots, then retry ──
262
261
  pruneMigrationSnapshots(deps.snapshotRoot);
263
- space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet, headroomFloor: deps.headroomFloor }, deps.spaceProbe);
262
+ space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
264
263
  }
265
264
  if (!space.ok) {
266
265
  // ── Ladder step 5: no safe path → halt-don't-brick ──
@@ -13,9 +13,60 @@
13
13
  * `estimateWorkingSetBytes` below is the caller's job (the runner computes
14
14
  * it per risk class); this module just evaluates the estimate against real
15
15
  * (or test-overridden) disk free/total space.
16
+ *
17
+ * flair#720 rewrote the rule this module enforces. The original shape
18
+ * (`projectedUsedFraction <= 0.9`, measured against TOTAL disk size) was
19
+ * designed for a flair-dedicated volume, but on a general-purpose machine
20
+ * — a personal Mac especially, where APFS purgeable space makes
21
+ * `statfs.bavail` understate real availability — the system volume
22
+ * routinely sits above 90% used already, so the fraction test failed
23
+ * before a single byte was written, regardless of how trivially the
24
+ * migration's own footprint fit (flair#720: 220 KB needed, 18.6 GB free,
25
+ * halted anyway). The fix keeps invariant III's "never fill past a
26
+ * cushion" intent but measures the migration's OWN impact plus an
27
+ * absolute reserve, instead of the disk's pre-existing fullness:
28
+ * `ok = neededBytes <= freeBytes AND (freeBytes - neededBytes) >= reserve`.
29
+ * See `RESERVE_MIN_BYTES` / `RESERVE_MAX_BYTES` / `RESERVE_FRACTION` below
30
+ * for how `reserve` is derived.
16
31
  */
17
32
  import { statfsSync } from "node:fs";
18
- export const DEFAULT_HEADROOM_FLOOR = 0.9; // never project past 90% used
33
+ /**
34
+ * Reserve floor: on any disk, no migration may leave less than this much
35
+ * free afterward, even on a tiny volume where 5% of total would be less.
36
+ * 256 MiB is comfortably more than any single migration's own working set
37
+ * in this codebase (the largest estimate is embedding/HNSW rebuild
38
+ * overhead, a few KB per row) — it exists to leave room for Harper's own
39
+ * ordinary operation (WAL, temp files, log growth), not the migration
40
+ * itself.
41
+ */
42
+ export const RESERVE_MIN_BYTES = 256 * 1024 * 1024; // 256 MiB
43
+ /**
44
+ * Reserve cap: on a large disk, 5% of total would be an unreasonably large
45
+ * cushion to demand (e.g. 50 GB on a 1 TB drive) for what is, worst case,
46
+ * a few GB of migration working set. 2 GiB caps the reserve so the rule
47
+ * stays proportionate to what migrations actually need, not the disk's
48
+ * raw size.
49
+ */
50
+ export const RESERVE_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
51
+ /**
52
+ * Between the floor and the cap, the reserve scales with disk size: 5% of
53
+ * total. This is the "proportionate cushion" middle ground — big enough to
54
+ * matter on a mid-size disk, small enough not to dominate on either
55
+ * extreme (RESERVE_MIN_BYTES / RESERVE_MAX_BYTES clamp the two ends).
56
+ */
57
+ export const RESERVE_FRACTION = 0.05;
58
+ /**
59
+ * Production escape hatch for constrained deployments (flair#720) — e.g. a
60
+ * small dedicated volume where even the 256 MiB floor is more cushion than
61
+ * the operator can spare. When set to a finite, non-negative number, this
62
+ * overrides the computed reserve entirely (including the floor/cap clamp
63
+ * — set it to 0 to disable the reserve check outright, keeping only the
64
+ * `neededBytes <= freeBytes` fit test). Invalid values (non-numeric,
65
+ * negative, NaN, Infinity) are ignored and the computed reserve is used
66
+ * instead — mirrors TEST_FREE_BYTES_ENV's validation below. Unset (the
67
+ * default) in every normal deployment.
68
+ */
69
+ export const RESERVE_BYTES_ENV = "FLAIR_MIGRATION_RESERVE_BYTES";
19
70
  /**
20
71
  * Test-only escape hatch: when set, overrides the real statfs-reported free
21
72
  * byte count. Used by the halt-on-blocked-space integration test (real
@@ -45,29 +96,72 @@ export const defaultSpaceProbe = {
45
96
  getTotalBytes: realGetTotalBytes,
46
97
  };
47
98
  /**
48
- * Evaluates the pre-flight space check. Fails (ok:false) if the needed
49
- * bytes don't fit in free space OR if consuming them would project disk
50
- * usage past the headroom floor — matching invariant III's "never fill past
51
- * ~90%" rule exactly (a technically-fitting migration that would still push
52
- * the disk to 95% full is refused, same as one that doesn't fit at all).
99
+ * Resolves the absolute reserve a migration must leave free afterward:
100
+ * `RESERVE_BYTES_ENV` if set to a valid (finite, >= 0) value, else
101
+ * `clamp(RESERVE_FRACTION * totalBytes, RESERVE_MIN_BYTES, RESERVE_MAX_BYTES)`.
102
+ * Exported so tests can exercise the clamp/override logic directly without
103
+ * going through a full `checkSpace` call.
104
+ */
105
+ export function resolveReserveBytes(totalBytes, env = process.env) {
106
+ const override = env[RESERVE_BYTES_ENV];
107
+ if (override !== undefined) {
108
+ const n = Number(override);
109
+ if (Number.isFinite(n) && n >= 0)
110
+ return n;
111
+ }
112
+ const fractional = totalBytes * RESERVE_FRACTION;
113
+ return Math.min(RESERVE_MAX_BYTES, Math.max(RESERVE_MIN_BYTES, fractional));
114
+ }
115
+ /**
116
+ * Bytes → human readable (binary-based thresholds, decimal labels — same
117
+ * convention as src/render.ts's `humanBytes`, which this module can't
118
+ * import: resources/**\/*.ts and src/**\/*.ts are separate TypeScript
119
+ * project boundaries (tsconfig.json vs tsconfig.cli.json/tsconfig.check.src.json)
120
+ * — resources/ is Harper-loaded component code and never imports from the
121
+ * CLI's src/ tree. Small enough to duplicate locally rather than restructure
122
+ * the module boundary for one formatter.
123
+ */
124
+ export function humanBytes(n) {
125
+ if (!Number.isFinite(n) || n < 0)
126
+ return "—";
127
+ if (n < 1024)
128
+ return `${n} B`;
129
+ if (n < 1024 * 1024)
130
+ return `${(n / 1024).toFixed(1)} KB`;
131
+ if (n < 1024 * 1024 * 1024)
132
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
133
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
134
+ }
135
+ /**
136
+ * Evaluates the pre-flight space check (flair#720's rule): fails (ok:false)
137
+ * if the needed bytes don't fit in free space outright, OR if spending them
138
+ * would leave less than `reserveBytes` free afterward. Unlike the rule this
139
+ * replaced, fullness that already exists on the disk before the migration
140
+ * runs is irrelevant — only the migration's OWN impact on free space is
141
+ * judged, against an absolute (not fraction-of-total) cushion.
53
142
  */
54
143
  export function checkSpace(input, probe = defaultSpaceProbe) {
55
- const floor = input.headroomFloor ?? DEFAULT_HEADROOM_FLOOR;
56
144
  const freeBytes = probe.getFreeBytes(input.dataDir);
57
145
  const totalBytes = probe.getTotalBytes(input.dataDir);
58
146
  const neededBytes = input.estimatedSnapshotBytes + input.estimatedWorkingSetBytes;
59
- const usedBytesNow = Math.max(0, totalBytes - freeBytes);
60
- const projectedUsedBytes = usedBytesNow + neededBytes;
61
- const projectedUsedFraction = totalBytes > 0 ? projectedUsedBytes / totalBytes : 1;
147
+ const reserveBytes = resolveReserveBytes(totalBytes);
62
148
  const fits = neededBytes <= freeBytes;
63
- const withinFloor = projectedUsedFraction <= floor;
64
- const ok = fits && withinFloor;
149
+ const remainingAfter = freeBytes - neededBytes;
150
+ const withinReserve = fits && remainingAfter >= reserveBytes;
151
+ const ok = fits && withinReserve;
65
152
  let reason;
66
- if (!ok) {
153
+ if (!fits) {
154
+ reason =
155
+ `need ${humanBytes(neededBytes)} free (snapshot + migration working set), have ${humanBytes(freeBytes)} — ` +
156
+ `short by ${humanBytes(neededBytes - freeBytes)}. Set ${RESERVE_BYTES_ENV} to lower the required post-migration ` +
157
+ `reserve on constrained deployments (this won't help here — the migration doesn't fit even before any reserve)`;
158
+ }
159
+ else if (!withinReserve) {
67
160
  reason =
68
- `need ${neededBytes} bytes free (snapshot + migration working set), have ${freeBytes}; ` +
69
- `proceeding would exceed the ${Math.round(floor * 100)}% disk headroom floor — ` +
70
- `prune old snapshots (flair keeps last-3/30-day) or set FLAIR_SNAPSHOT_DIR to a volume with more room`;
161
+ `need ${humanBytes(neededBytes)} free (snapshot + migration working set); have ${humanBytes(freeBytes)}, ` +
162
+ `which covers it but would leave only ${humanBytes(remainingAfter)} free afterwardshort of the ` +
163
+ `${humanBytes(reserveBytes)} minimum reserve required post-migration. Set ${RESERVE_BYTES_ENV} to a lower ` +
164
+ `byte count to shrink the required reserve on constrained deployments`;
71
165
  }
72
- return { ok, freeBytes, totalBytes, neededBytes, projectedUsedFraction, reason };
166
+ return { ok, freeBytes, totalBytes, neededBytes, reserveBytes, reason };
73
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",