@tpsdev-ai/flair 0.45.0 → 0.46.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.
package/config.yaml CHANGED
@@ -16,9 +16,42 @@ authentication:
16
16
  clientId: ${OAUTH_GITHUB_CLIENT_ID}
17
17
  clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET}
18
18
  mcp:
19
- enabled: false
19
+ # WHOLE-TOKEN env reference — the same FLAIR_MCP_OAUTH flag flair's
20
+ # in-process /mcp route gates on (resources/mcp-oauth-flag.ts). The on/off
21
+ # choice lives in the instance ENVIRONMENT, not in this packed file, so a
22
+ # re-packed deploy can no longer revert an operator's enablement
23
+ # (flair#1152). Requires @harperfast/oauth >= 2.5.0 (resolved-version
24
+ # assertion + behavioral gate: test/integration/mcp-oauth-boot-safety.test.ts).
25
+ #
26
+ # ASYMMETRY (load-bearing, measured on oauth 2.5.0): the two readers of
27
+ # this flag accept DIFFERENT vocabularies. The component's
28
+ # coerceConfigBoolean accepts ONLY "true"/"false" and DELETES any other
29
+ # string (unresolved placeholder, "1", "yes", garbage) so its disabled
30
+ # default applies. flair's mcpOAuthEnabled() accepts 1/true/yes/on.
31
+ #
32
+ # Failure modes (oauth 2.5.0):
33
+ # unset -> placeholder deleted -> both sides OFF, clean boot
34
+ # true -> BOTH sides ON — the one working enable value
35
+ # (`flair mcp enable` stages exactly this)
36
+ # false -> both sides OFF
37
+ # 1 / yes / on -> flair /mcp handler ON, component AS OFF ->
38
+ # fail-closed broken-on: every /mcp request 401s,
39
+ # no AS is advertised. Use "true" instead.
40
+ # garbage (maybe) -> deleted -> component OFF; flair strict -> OFF.
41
+ # Inert: no /mcp handler, no data path (flair's own
42
+ # discovery documents still serve, by design).
43
+ # On oauth <2.5.0 there is NO normalization: an unresolved placeholder is
44
+ # a truthy string (fail-open) — the version assertion exists for that.
45
+ # If the component's `enabled` vocabulary changes, or it ever drives
46
+ # flair's handler registration directly, re-derive this table first.
47
+ enabled: ${FLAIR_MCP_OAUTH}
20
48
  issuer: ${FLAIR_MCP_ISSUER}
21
- resource: ${FLAIR_MCP_ISSUER}/mcp
49
+ # No `resource:` key ON PURPOSE (flair#1180): when absent, the component
50
+ # derives `<issuer>/mcp` at request time (resolveResource) — identical to
51
+ # flair's in-process derivation. A composite like ${FLAIR_MCP_ISSUER}/mcp
52
+ # NEVER interpolates (env expansion is whole-token-only) and fails every
53
+ # connect with invalid_target. Escape hatch: an operator needing a
54
+ # non-standard resource sets an explicit LITERAL absolute URL here.
22
55
  accessTokenTtl: 900
23
56
  dynamicClientRegistration:
24
57
  enabled: false
package/dist/cli.js CHANGED
@@ -23,8 +23,8 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
23
23
  import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
24
24
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
25
25
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
26
- import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
27
- import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
26
+ import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
27
+ import { installHook, uninstallHook, hookStatus, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
28
28
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
29
29
  import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
30
30
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
@@ -1604,6 +1604,164 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1604
1604
  }
1605
1605
  }
1606
1606
  }
1607
+ /**
1608
+ * Positive control for the Harper audit log (flair#970): verify that audit
1609
+ * ACTUALLY records by writing and then reading the audit trail back — never by
1610
+ * trusting the `audit: true` flag `describe_table` reports.
1611
+ *
1612
+ * Why a positive control: records applied via cluster base-copy/resync are
1613
+ * committed with audit explicitly disabled (harper Table.ts isCopyApply, filed
1614
+ * upstream as harper#2212), so a node can report audit enabled, answer
1615
+ * read_audit_log with HTTP 200, and still hold zero entries. Before this
1616
+ * check, nothing in flair declared, read, or verified audit — the canonical
1617
+ * check that cannot fire.
1618
+ *
1619
+ * Probe (modeled on verifySemanticSearch above, same skipped/degraded/ok
1620
+ * discipline):
1621
+ * 1. PUT an ephemeral probe row (id `flair-doctor-audit-probe-<uuid>`,
1622
+ * short inert marker content — ephemeral durability so a failed cleanup
1623
+ * self-prunes; the DELETE in `finally` is best-effort).
1624
+ * 2. PATCH it — verified live on harper@5.2.0: PATCH /Memory/<id> returns
1625
+ * 204 and generates an audit entry with operation "patch" (PUT generates
1626
+ * "upsert", DELETE "delete").
1627
+ * 3. `read_audit_log` (search_type hash_value, the probe id) over the ops
1628
+ * API with Basic admin auth — the operations API only exists on its own
1629
+ * port/socket, so the agent's Ed25519 header cannot authenticate it.
1630
+ * 4. Assert BOTH write entries are present. The probe's own DELETE lands
1631
+ * AFTER the read, so it is never required (audit is append-only; the
1632
+ * probe's entries persisting after the row is gone is by design).
1633
+ *
1634
+ * All assertions are BOOLEAN (entry counts only). Audit entries carry full
1635
+ * record images (`records: [value]`), so no entry content is ever copied into
1636
+ * a result detail — the detail strings are fixed text plus counts/statuses.
1637
+ */
1638
+ export async function verifyAuditLog(baseUrl, agentIdOpt, keysDir, opsUrl, adminUser, adminPass) {
1639
+ // Resolve an agent + key to sign the probe writes with — identical
1640
+ // resolution to verifySemanticSearch so the two probes agree on identity.
1641
+ let agentId = agentIdOpt || process.env.FLAIR_AGENT_ID || undefined;
1642
+ if (!agentId) {
1643
+ try {
1644
+ const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
1645
+ const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), keysDir));
1646
+ if (agentKeyFile)
1647
+ agentId = agentKeyFile.replace(/\.key$/, "");
1648
+ }
1649
+ catch { /* keysDir missing */ }
1650
+ }
1651
+ if (!agentId) {
1652
+ return { state: "skipped", reason: "no-agent", detail: "no agent id or key found" };
1653
+ }
1654
+ let keyPath = resolveKeyPath(agentId);
1655
+ if (!keyPath) {
1656
+ const candidate = join(keysDir, `${agentId}.key`);
1657
+ if (existsSync(candidate))
1658
+ keyPath = candidate;
1659
+ }
1660
+ if (!keyPath) {
1661
+ return { state: "skipped", reason: "no-key", detail: `no private key for agent '${agentId}'` };
1662
+ }
1663
+ if (!adminUser || !adminPass) {
1664
+ return {
1665
+ state: "skipped",
1666
+ reason: "no-admin-credentials",
1667
+ detail: "no admin credentials for the ops API (read_audit_log requires them)",
1668
+ };
1669
+ }
1670
+ const id = `flair-doctor-audit-probe-${randomUUID()}`;
1671
+ const path = `/Memory/${id}`;
1672
+ let stored = false;
1673
+ try {
1674
+ // Write 1: PUT the probe row. Ephemeral durability — TTL is the cleanup
1675
+ // backstop if the finally-DELETE fails.
1676
+ const putRes = await authFetch(baseUrl, agentId, keyPath, "PUT", path, {
1677
+ id,
1678
+ agentId,
1679
+ content: `flair doctor audit probe (inert marker, safe to ignore) [${id}]`,
1680
+ durability: "ephemeral",
1681
+ createdAt: new Date().toISOString(),
1682
+ });
1683
+ if (!putRes.ok && putRes.status !== 204) {
1684
+ return { state: "skipped", reason: "probe-failed", detail: `could not write probe row: HTTP ${putRes.status}` };
1685
+ }
1686
+ stored = true;
1687
+ // Write 2: PATCH — a second, distinct audit-visible write (live-verified
1688
+ // to produce its own entry on harper@5.2.0; see doc comment).
1689
+ const patchRes = await authFetch(baseUrl, agentId, keyPath, "PATCH", path, {
1690
+ content: `flair doctor audit probe (inert marker, second write) [${id}]`,
1691
+ });
1692
+ if (!patchRes.ok && patchRes.status !== 204) {
1693
+ return { state: "skipped", reason: "probe-failed", detail: `could not apply second probe write: HTTP ${patchRes.status}` };
1694
+ }
1695
+ // Read the audit trail back over the ops API.
1696
+ let auditRes;
1697
+ try {
1698
+ auditRes = await fetch(`${opsUrl.replace(/\/+$/, "")}/`, {
1699
+ method: "POST",
1700
+ headers: {
1701
+ "Content-Type": "application/json",
1702
+ Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`,
1703
+ },
1704
+ body: JSON.stringify({
1705
+ operation: "read_audit_log",
1706
+ database: "flair",
1707
+ table: "Memory",
1708
+ search_type: "hash_value",
1709
+ search_values: [id],
1710
+ }),
1711
+ signal: AbortSignal.timeout(10_000),
1712
+ });
1713
+ }
1714
+ catch (err) {
1715
+ const message = err instanceof Error ? err.message : String(err);
1716
+ return { state: "skipped", reason: "probe-failed", detail: `ops API unreachable at ${opsUrl} (${message.slice(0, 80)})` };
1717
+ }
1718
+ if (auditRes.status === 400) {
1719
+ // harper rejects read_audit_log with HTTP 400 ("To use this operation
1720
+ // audit log must be enabled in harperdb-config.yaml") when
1721
+ // logging.auditLog is off — live-verified on harper@5.2.0.
1722
+ return { state: "degraded", cause: "disabled", detail: "read_audit_log rejected the probe: audit logging is not enabled on this instance" };
1723
+ }
1724
+ if (!auditRes.ok) {
1725
+ // 401/403/404/5xx tell us nothing about whether audit records — that is
1726
+ // "could not verify", never "verified" and never "broken".
1727
+ return { state: "skipped", reason: "probe-failed", detail: `read_audit_log failed: HTTP ${auditRes.status}` };
1728
+ }
1729
+ const body = (await auditRes.json().catch(() => null));
1730
+ // BOOLEAN classification only: count the probe's write entries. Audit
1731
+ // entries carry full record images — none of that content may reach the
1732
+ // result (and via it, doctor/init output).
1733
+ const entries = body && Array.isArray(body[id]) ? body[id] : [];
1734
+ const writeEntries = entries.filter((e) => e && typeof e === "object" && e.operation !== "delete");
1735
+ if (writeEntries.length >= 2) {
1736
+ return { state: "ok" };
1737
+ }
1738
+ // Enabled-but-empty (or partial) is the critical row: we put data in and
1739
+ // could not see it come back — the pipeline is broken, not "empty". The
1740
+ // pre-fix world silently passed this as ok.
1741
+ return {
1742
+ state: "degraded",
1743
+ cause: "not-recording",
1744
+ detail: `probe made 2 writes; read_audit_log returned ${writeEntries.length} write ${writeEntries.length === 1 ? "entry" : "entries"}`,
1745
+ };
1746
+ }
1747
+ catch (err) {
1748
+ if (err instanceof KeyLoadError) {
1749
+ return { state: "skipped", reason: "key-load", detail: err.message };
1750
+ }
1751
+ const message = err instanceof Error ? err.message : String(err);
1752
+ return { state: "skipped", reason: "probe-failed", detail: `probe error: ${message.slice(0, 100)}` };
1753
+ }
1754
+ finally {
1755
+ // Best-effort cleanup — ephemeral TTL is the backstop if this fails. The
1756
+ // DELETE itself appends one more audit entry AFTER the read, by design.
1757
+ if (stored) {
1758
+ try {
1759
+ await authFetch(baseUrl, agentId, keyPath, "DELETE", path);
1760
+ }
1761
+ catch { /* leave the ephemeral row; it'll age out */ }
1762
+ }
1763
+ }
1764
+ }
1607
1765
  // ─── Doctor: client-integration network checks (flair#588) ────────────────────
1608
1766
  //
1609
1767
  // The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
@@ -3355,6 +3513,33 @@ program
3355
3513
  else {
3356
3514
  console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
3357
3515
  }
3516
+ // Verify the audit log ACTUALLY records (flair#970) — a positive
3517
+ // control, not a flag read: `describe_table` reports `audit: true` on
3518
+ // nodes whose audit trail is empty (base-copy elision, harper#2212).
3519
+ // Same surface as the semantic-search check above.
3520
+ console.log("Verifying audit log...");
3521
+ const auditCheck = await verifyAuditLog(httpUrl, agentId, keysDir, `http://127.0.0.1:${opsPort}`, adminUser, adminPass);
3522
+ if (auditCheck.state === "ok") {
3523
+ // Present tense ONLY: the probe proves current recording, never
3524
+ // historical completeness — see AuditVerifyResult's doc comment.
3525
+ console.log(`Audit log: recording (verified now) ✓ ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
3526
+ }
3527
+ else if (auditCheck.state === "degraded") {
3528
+ if (auditCheck.cause === "disabled") {
3529
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log DISABLED")} — ${auditCheck.detail}.`);
3530
+ console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
3531
+ }
3532
+ else {
3533
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log NOT RECORDING")} — ${auditCheck.detail}.`);
3534
+ console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
3535
+ console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
3536
+ console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
3537
+ }
3538
+ }
3539
+ else {
3540
+ // An unrun check must not look like a pass.
3541
+ console.log(`${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditCheck.detail})`);
3542
+ }
3358
3543
  // Output — admin password printed once, never written to disk
3359
3544
  console.log("\n✅ Flair initialized successfully");
3360
3545
  console.log(` Agent ID: ${agentId}`);
@@ -4359,6 +4544,7 @@ hook
4359
4544
  .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
4360
4545
  .option("--agent-id <id>", "Alias for --agent")
4361
4546
  .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
4547
+ .option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
4362
4548
  .action((opts) => {
4363
4549
  const harness = requireSupportedHarness(opts.harness);
4364
4550
  const home = homedir();
@@ -4369,6 +4555,18 @@ hook
4369
4555
  }
4370
4556
  const flairUrl = resolveHookFlairUrl(opts, home);
4371
4557
  const dryRun = !!opts.dryRun;
4558
+ if (opts.continuity) {
4559
+ const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
4560
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4561
+ console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4562
+ if (result.backupPath) {
4563
+ console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4564
+ }
4565
+ console.log("");
4566
+ if (!result.ok)
4567
+ process.exit(1);
4568
+ return;
4569
+ }
4372
4570
  const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
4373
4571
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4374
4572
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
@@ -4388,10 +4586,23 @@ hook
4388
4586
  .description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
4389
4587
  .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
4390
4588
  .option("--dry-run", "Print the exact JSON delta without writing")
4589
+ .option("--continuity", "Remove the continuity capture hooks instead (PostToolUse + Stop — flair#1257)")
4391
4590
  .action((opts) => {
4392
4591
  const harness = requireSupportedHarness(opts.harness);
4393
4592
  const home = homedir();
4394
4593
  const dryRun = !!opts.dryRun;
4594
+ if (opts.continuity) {
4595
+ const result = uninstallContinuityHooks({ homeDir: home, harness, dryRun });
4596
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4597
+ console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4598
+ if (result.backupPath) {
4599
+ console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4600
+ }
4601
+ console.log("");
4602
+ if (!result.ok)
4603
+ process.exit(1);
4604
+ return;
4605
+ }
4395
4606
  const result = uninstallHook({ homeDir: home, harness, dryRun });
4396
4607
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4397
4608
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
@@ -4414,6 +4625,22 @@ hook
4414
4625
  const harness = requireSupportedHarness(opts.harness);
4415
4626
  const home = homedir();
4416
4627
  const status = hookStatus(home, harness);
4628
+ // Continuity pair (flair#1257) — reported alongside the SessionStart
4629
+ // status in every branch below. "absent" is NOT a failure: installing the
4630
+ // pair is the opt-in, so absence renders as "not enabled".
4631
+ const renderContinuity = () => {
4632
+ const cont = continuityHookStatus(home, harness);
4633
+ if (cont.state === "installed") {
4634
+ console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
4635
+ }
4636
+ else if (cont.state === "absent") {
4637
+ console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
4638
+ }
4639
+ else {
4640
+ const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
4641
+ console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
4642
+ }
4643
+ };
4417
4644
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
4418
4645
  console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
4419
4646
  console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
@@ -4425,6 +4652,7 @@ hook
4425
4652
  if (!status.wired) {
4426
4653
  console.log(` ${render.icons.error} not wired`);
4427
4654
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
4655
+ renderContinuity();
4428
4656
  console.log("");
4429
4657
  process.exit(1);
4430
4658
  }
@@ -4439,6 +4667,7 @@ hook
4439
4667
  else {
4440
4668
  console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
4441
4669
  }
4670
+ renderContinuity();
4442
4671
  console.log("");
4443
4672
  });
4444
4673
  // ─── flair mcp ───────────────────────────────────────────────────────────────
@@ -6433,6 +6662,11 @@ export async function runFederationSyncOnce(opts) {
6433
6662
  return await syncRes.json();
6434
6663
  }
6435
6664
  let totalBatches = 0;
6665
+ // Memory rows that passed the since-cursor filter but were excluded as
6666
+ // private. Used only so the quiet path can distinguish "nothing since
6667
+ // the cursor" from "found rows, all withheld" (flair#1232). Does not
6668
+ // change what gets pushed — private still never leaves the instance.
6669
+ let privateHeldBack = 0;
6436
6670
  for (const table of tables) {
6437
6671
  let rows = [];
6438
6672
  for (const query of [
@@ -6466,9 +6700,11 @@ export async function runFederationSyncOnce(opts) {
6466
6700
  // filter only applies there; on the other 3 tables `row.visibility`
6467
6701
  // is always undefined, which isFederationPrivateVisibility() treats
6468
6702
  // as non-private (included) — a no-op for them.
6469
- rows = rows.concat(batch
6470
- .filter((r) => r.updatedAt !== null || r.createdAt > since)
6471
- .filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility)));
6703
+ const sinceCursor = batch.filter((r) => r.updatedAt !== null || r.createdAt > since);
6704
+ const federable = sinceCursor.filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility));
6705
+ if (table === "Memory")
6706
+ privateHeldBack += sinceCursor.length - federable.length;
6707
+ rows = rows.concat(federable);
6472
6708
  }
6473
6709
  if (rows.length === 0)
6474
6710
  continue;
@@ -6583,7 +6819,13 @@ export async function runFederationSyncOnce(opts) {
6583
6819
  catch (pingErr) {
6584
6820
  console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
6585
6821
  }
6586
- console.log("No changes since last sync.");
6822
+ // flair#1232: "No changes" is true only when nothing was found since
6823
+ // the cursor. If rows were found and every one was withheld as private,
6824
+ // say so — count and reason only, never content. A zero withheld count
6825
+ // must not invent a private-withheld story.
6826
+ console.log(privateHeldBack > 0
6827
+ ? `No federable changes since last sync (${privateHeldBack} row${privateHeldBack === 1 ? "" : "s"} held back: private visibility).`
6828
+ : "No changes since last sync.");
6587
6829
  return { pushed: 0, skipped: 0 };
6588
6830
  }
6589
6831
  console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
@@ -12303,6 +12545,60 @@ program
12303
12545
  }
12304
12546
  }
12305
12547
  }
12548
+ // 4b. Audit-log positive control (flair#970) — REAL write→read_audit_log
12549
+ // round-trip, only if Harper is responding. `describe_table` reporting
12550
+ // `audit: true` proves nothing: a node that joined or resynced via
12551
+ // cluster base copy holds zero audit history while reporting audit
12552
+ // enabled and answering read_audit_log with clean empty (harper#2212).
12553
+ // So doctor writes probe rows and asserts their audit entries come back —
12554
+ // never trusts the flag. Same ok/degraded/skipped discipline as the
12555
+ // embeddings check above: skipped is rendered UNVERIFIED, never as a pass.
12556
+ if (harperResponding) {
12557
+ // read_audit_log only exists on the ops API (its own port), which the
12558
+ // agent's Ed25519 header cannot authenticate — resolve the local admin
12559
+ // credential (env or ~/.flair/admin-pass; never prompts). A file with
12560
+ // unsafe permissions throws — that is "could not probe", not "broken".
12561
+ let auditAdminPass;
12562
+ let auditCredIssue = null;
12563
+ try {
12564
+ auditAdminPass = resolveLocalAdminPass(undefined);
12565
+ }
12566
+ catch (err) {
12567
+ auditCredIssue = err instanceof Error ? err.message : String(err);
12568
+ }
12569
+ const auditStatus = auditCredIssue
12570
+ ? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
12571
+ : await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, DEFAULT_ADMIN_USER, auditAdminPass);
12572
+ switch (auditStatus.state) {
12573
+ case "ok":
12574
+ // Present-tense claim ONLY (see AuditVerifyResult): the probe
12575
+ // proves the log records writes NOW — never that history is
12576
+ // complete. Overclaiming here would rebuild the false trust
12577
+ // anchor this check exists to kill, one layer up.
12578
+ console.log(` ${render.icons.ok} Audit log: recording (verified now) ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
12579
+ break;
12580
+ case "degraded":
12581
+ if (auditStatus.cause === "disabled") {
12582
+ console.log(` ${render.icons.error} Audit log DISABLED ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
12583
+ console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
12584
+ }
12585
+ else {
12586
+ console.log(` ${render.icons.error} Audit log NOT RECORDING ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
12587
+ console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
12588
+ console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
12589
+ console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
12590
+ }
12591
+ issues++;
12592
+ break;
12593
+ case "skipped":
12594
+ // An unrun check must not look like a pass — UNVERIFIED, visually
12595
+ // distinct from ok, but not a hard issue (mirrors the embeddings
12596
+ // skip: the operator may simply have no agent or no local admin
12597
+ // credential on this box).
12598
+ console.log(` ${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditStatus.detail})`);
12599
+ break;
12600
+ }
12601
+ }
12306
12602
  // 5. Stale PID file (skip if already reported in port check)
12307
12603
  const dataDir = defaultDataDir();
12308
12604
  const pidFile = join(dataDir, "hdb.pid");
@@ -12616,6 +12912,73 @@ program
12616
12912
  }
12617
12913
  issues++;
12618
12914
  }
12915
+ // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
12916
+ // the SessionStart check above: installed / absent / stale-form).
12917
+ // Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
12918
+ // opt-in — so "absent" renders as informational "not enabled": NEVER
12919
+ // a pass (an unrun check must not look green) and never counted as an
12920
+ // issue. A partial/stale install IS an issue and is --fix-able;
12921
+ // --fix also offers first-time enablement (the y/N prompt is the
12922
+ // consent; non-TTY --fix is itself the consent signal, matching every
12923
+ // other doctor fix).
12924
+ const continuity = checkContinuityCaptureHooks(homedir());
12925
+ if (continuity.state === "installed") {
12926
+ console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
12927
+ }
12928
+ else if (continuity.state === "absent") {
12929
+ console.log(` ${render.icons.info} Continuity capture hooks: not enabled ${render.wrap(render.c.dim, "(opt-in — auto-journal working state into the ephemeral memory tier; enable: flair hook install --continuity)")}`);
12930
+ if (autoFix) {
12931
+ if (dryRun) {
12932
+ console.log(` ${render.wrap(render.c.dim, "Would wire the continuity capture hooks (PostToolUse + Stop) in")} ${continuity.path}`);
12933
+ }
12934
+ else {
12935
+ const proceed = await confirmFix(` Enable continuity capture (PostToolUse + Stop hooks in ${continuity.path})? [y/N] `);
12936
+ if (!proceed) {
12937
+ console.log(` Skipped.`);
12938
+ }
12939
+ else {
12940
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
12941
+ const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId);
12942
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
12943
+ if (fixRes.ok && fixRes.changed)
12944
+ fixed++;
12945
+ }
12946
+ }
12947
+ }
12948
+ }
12949
+ else {
12950
+ const continuityDetail = continuity.state === "partial"
12951
+ ? (!continuity.postToolUse.present ? "the PostToolUse entry is missing" : "the Stop entry is missing")
12952
+ : "an entry is not the current form (unsilenced, hand-altered, or a drifted PostToolUse matcher)";
12953
+ console.log(` ${render.icons.warn} Continuity capture hooks: ${continuity.state} — ${continuityDetail}`);
12954
+ if (autoFix) {
12955
+ if (dryRun) {
12956
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the continuity capture hooks in")} ${continuity.path}`);
12957
+ }
12958
+ else {
12959
+ const proceed = await confirmFix(` Rewrite the continuity capture hooks in ${continuity.path} to the current form? [y/N] `);
12960
+ if (!proceed) {
12961
+ console.log(` Skipped.`);
12962
+ }
12963
+ else {
12964
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
12965
+ // Preserve the FLAIR_URL an existing entry already carries —
12966
+ // a repair must never silently re-point the hooks at a
12967
+ // different instance.
12968
+ const existingCommand = continuity.postToolUse.command || continuity.stop.command || "";
12969
+ const existingUrl = existingCommand.match(/FLAIR_URL=(\S+)/)?.[1];
12970
+ const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId, existingUrl);
12971
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
12972
+ if (fixRes.ok && fixRes.changed)
12973
+ fixed++;
12974
+ }
12975
+ }
12976
+ }
12977
+ else {
12978
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites both entries to the current form — same agent, same instance)")}`);
12979
+ }
12980
+ issues++;
12981
+ }
12619
12982
  }
12620
12983
  }
12621
12984
  // 7a. Resolve which agent identities the two verified-read sections below