@tpsdev-ai/flair 0.45.0 → 0.47.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 +35 -2
- package/dist/build-info.json +6 -0
- package/dist/cli.js +847 -168
- package/dist/doctor-client.js +358 -11
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +71 -21
- package/dist/lib/scheduler-platform.js +363 -1
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +126 -20
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +363 -1
- package/dist/src/rem/scheduler.js +126 -20
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +52 -3
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/cli.js
CHANGED
|
@@ -6,10 +6,11 @@ import * as render from "./render.js";
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
|
|
10
11
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
11
12
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
12
|
-
import { keystore } from "./keystore.js";
|
|
13
|
+
import { keystore, keyPath as keystoreKeyPath } from "./keystore.js";
|
|
13
14
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl, resolveDeployPublicUrl } from "./deploy.js";
|
|
14
15
|
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
|
|
15
16
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
@@ -23,11 +24,13 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
|
|
|
23
24
|
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
24
25
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
25
26
|
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 {
|
|
27
|
+
import { readClientMcpBlock, effectiveFlairUrl, 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";
|
|
28
|
+
import { checkGlobalBinOnPath, cliBootPathWarning, resolveNpmGlobalPrefix, } from "./install/global-bin-path.js";
|
|
29
|
+
import { installHook, uninstallHook, hookStatus, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
28
30
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
29
31
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
30
32
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
33
|
+
import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
|
|
31
34
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
32
35
|
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
33
36
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
@@ -1604,6 +1607,164 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1604
1607
|
}
|
|
1605
1608
|
}
|
|
1606
1609
|
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Positive control for the Harper audit log (flair#970): verify that audit
|
|
1612
|
+
* ACTUALLY records by writing and then reading the audit trail back — never by
|
|
1613
|
+
* trusting the `audit: true` flag `describe_table` reports.
|
|
1614
|
+
*
|
|
1615
|
+
* Why a positive control: records applied via cluster base-copy/resync are
|
|
1616
|
+
* committed with audit explicitly disabled (harper Table.ts isCopyApply, filed
|
|
1617
|
+
* upstream as harper#2212), so a node can report audit enabled, answer
|
|
1618
|
+
* read_audit_log with HTTP 200, and still hold zero entries. Before this
|
|
1619
|
+
* check, nothing in flair declared, read, or verified audit — the canonical
|
|
1620
|
+
* check that cannot fire.
|
|
1621
|
+
*
|
|
1622
|
+
* Probe (modeled on verifySemanticSearch above, same skipped/degraded/ok
|
|
1623
|
+
* discipline):
|
|
1624
|
+
* 1. PUT an ephemeral probe row (id `flair-doctor-audit-probe-<uuid>`,
|
|
1625
|
+
* short inert marker content — ephemeral durability so a failed cleanup
|
|
1626
|
+
* self-prunes; the DELETE in `finally` is best-effort).
|
|
1627
|
+
* 2. PATCH it — verified live on harper@5.2.0: PATCH /Memory/<id> returns
|
|
1628
|
+
* 204 and generates an audit entry with operation "patch" (PUT generates
|
|
1629
|
+
* "upsert", DELETE "delete").
|
|
1630
|
+
* 3. `read_audit_log` (search_type hash_value, the probe id) over the ops
|
|
1631
|
+
* API with Basic admin auth — the operations API only exists on its own
|
|
1632
|
+
* port/socket, so the agent's Ed25519 header cannot authenticate it.
|
|
1633
|
+
* 4. Assert BOTH write entries are present. The probe's own DELETE lands
|
|
1634
|
+
* AFTER the read, so it is never required (audit is append-only; the
|
|
1635
|
+
* probe's entries persisting after the row is gone is by design).
|
|
1636
|
+
*
|
|
1637
|
+
* All assertions are BOOLEAN (entry counts only). Audit entries carry full
|
|
1638
|
+
* record images (`records: [value]`), so no entry content is ever copied into
|
|
1639
|
+
* a result detail — the detail strings are fixed text plus counts/statuses.
|
|
1640
|
+
*/
|
|
1641
|
+
export async function verifyAuditLog(baseUrl, agentIdOpt, keysDir, opsUrl, adminUser, adminPass) {
|
|
1642
|
+
// Resolve an agent + key to sign the probe writes with — identical
|
|
1643
|
+
// resolution to verifySemanticSearch so the two probes agree on identity.
|
|
1644
|
+
let agentId = agentIdOpt || process.env.FLAIR_AGENT_ID || undefined;
|
|
1645
|
+
if (!agentId) {
|
|
1646
|
+
try {
|
|
1647
|
+
const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
1648
|
+
const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), keysDir));
|
|
1649
|
+
if (agentKeyFile)
|
|
1650
|
+
agentId = agentKeyFile.replace(/\.key$/, "");
|
|
1651
|
+
}
|
|
1652
|
+
catch { /* keysDir missing */ }
|
|
1653
|
+
}
|
|
1654
|
+
if (!agentId) {
|
|
1655
|
+
return { state: "skipped", reason: "no-agent", detail: "no agent id or key found" };
|
|
1656
|
+
}
|
|
1657
|
+
let keyPath = resolveKeyPath(agentId);
|
|
1658
|
+
if (!keyPath) {
|
|
1659
|
+
const candidate = join(keysDir, `${agentId}.key`);
|
|
1660
|
+
if (existsSync(candidate))
|
|
1661
|
+
keyPath = candidate;
|
|
1662
|
+
}
|
|
1663
|
+
if (!keyPath) {
|
|
1664
|
+
return { state: "skipped", reason: "no-key", detail: `no private key for agent '${agentId}'` };
|
|
1665
|
+
}
|
|
1666
|
+
if (!adminUser || !adminPass) {
|
|
1667
|
+
return {
|
|
1668
|
+
state: "skipped",
|
|
1669
|
+
reason: "no-admin-credentials",
|
|
1670
|
+
detail: "no admin credentials for the ops API (read_audit_log requires them)",
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
const id = `flair-doctor-audit-probe-${randomUUID()}`;
|
|
1674
|
+
const path = `/Memory/${id}`;
|
|
1675
|
+
let stored = false;
|
|
1676
|
+
try {
|
|
1677
|
+
// Write 1: PUT the probe row. Ephemeral durability — TTL is the cleanup
|
|
1678
|
+
// backstop if the finally-DELETE fails.
|
|
1679
|
+
const putRes = await authFetch(baseUrl, agentId, keyPath, "PUT", path, {
|
|
1680
|
+
id,
|
|
1681
|
+
agentId,
|
|
1682
|
+
content: `flair doctor audit probe (inert marker, safe to ignore) [${id}]`,
|
|
1683
|
+
durability: "ephemeral",
|
|
1684
|
+
createdAt: new Date().toISOString(),
|
|
1685
|
+
});
|
|
1686
|
+
if (!putRes.ok && putRes.status !== 204) {
|
|
1687
|
+
return { state: "skipped", reason: "probe-failed", detail: `could not write probe row: HTTP ${putRes.status}` };
|
|
1688
|
+
}
|
|
1689
|
+
stored = true;
|
|
1690
|
+
// Write 2: PATCH — a second, distinct audit-visible write (live-verified
|
|
1691
|
+
// to produce its own entry on harper@5.2.0; see doc comment).
|
|
1692
|
+
const patchRes = await authFetch(baseUrl, agentId, keyPath, "PATCH", path, {
|
|
1693
|
+
content: `flair doctor audit probe (inert marker, second write) [${id}]`,
|
|
1694
|
+
});
|
|
1695
|
+
if (!patchRes.ok && patchRes.status !== 204) {
|
|
1696
|
+
return { state: "skipped", reason: "probe-failed", detail: `could not apply second probe write: HTTP ${patchRes.status}` };
|
|
1697
|
+
}
|
|
1698
|
+
// Read the audit trail back over the ops API.
|
|
1699
|
+
let auditRes;
|
|
1700
|
+
try {
|
|
1701
|
+
auditRes = await fetch(`${opsUrl.replace(/\/+$/, "")}/`, {
|
|
1702
|
+
method: "POST",
|
|
1703
|
+
headers: {
|
|
1704
|
+
"Content-Type": "application/json",
|
|
1705
|
+
Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`,
|
|
1706
|
+
},
|
|
1707
|
+
body: JSON.stringify({
|
|
1708
|
+
operation: "read_audit_log",
|
|
1709
|
+
database: "flair",
|
|
1710
|
+
table: "Memory",
|
|
1711
|
+
search_type: "hash_value",
|
|
1712
|
+
search_values: [id],
|
|
1713
|
+
}),
|
|
1714
|
+
signal: AbortSignal.timeout(10_000),
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1719
|
+
return { state: "skipped", reason: "probe-failed", detail: `ops API unreachable at ${opsUrl} (${message.slice(0, 80)})` };
|
|
1720
|
+
}
|
|
1721
|
+
if (auditRes.status === 400) {
|
|
1722
|
+
// harper rejects read_audit_log with HTTP 400 ("To use this operation
|
|
1723
|
+
// audit log must be enabled in harperdb-config.yaml") when
|
|
1724
|
+
// logging.auditLog is off — live-verified on harper@5.2.0.
|
|
1725
|
+
return { state: "degraded", cause: "disabled", detail: "read_audit_log rejected the probe: audit logging is not enabled on this instance" };
|
|
1726
|
+
}
|
|
1727
|
+
if (!auditRes.ok) {
|
|
1728
|
+
// 401/403/404/5xx tell us nothing about whether audit records — that is
|
|
1729
|
+
// "could not verify", never "verified" and never "broken".
|
|
1730
|
+
return { state: "skipped", reason: "probe-failed", detail: `read_audit_log failed: HTTP ${auditRes.status}` };
|
|
1731
|
+
}
|
|
1732
|
+
const body = (await auditRes.json().catch(() => null));
|
|
1733
|
+
// BOOLEAN classification only: count the probe's write entries. Audit
|
|
1734
|
+
// entries carry full record images — none of that content may reach the
|
|
1735
|
+
// result (and via it, doctor/init output).
|
|
1736
|
+
const entries = body && Array.isArray(body[id]) ? body[id] : [];
|
|
1737
|
+
const writeEntries = entries.filter((e) => e && typeof e === "object" && e.operation !== "delete");
|
|
1738
|
+
if (writeEntries.length >= 2) {
|
|
1739
|
+
return { state: "ok" };
|
|
1740
|
+
}
|
|
1741
|
+
// Enabled-but-empty (or partial) is the critical row: we put data in and
|
|
1742
|
+
// could not see it come back — the pipeline is broken, not "empty". The
|
|
1743
|
+
// pre-fix world silently passed this as ok.
|
|
1744
|
+
return {
|
|
1745
|
+
state: "degraded",
|
|
1746
|
+
cause: "not-recording",
|
|
1747
|
+
detail: `probe made 2 writes; read_audit_log returned ${writeEntries.length} write ${writeEntries.length === 1 ? "entry" : "entries"}`,
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
if (err instanceof KeyLoadError) {
|
|
1752
|
+
return { state: "skipped", reason: "key-load", detail: err.message };
|
|
1753
|
+
}
|
|
1754
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1755
|
+
return { state: "skipped", reason: "probe-failed", detail: `probe error: ${message.slice(0, 100)}` };
|
|
1756
|
+
}
|
|
1757
|
+
finally {
|
|
1758
|
+
// Best-effort cleanup — ephemeral TTL is the backstop if this fails. The
|
|
1759
|
+
// DELETE itself appends one more audit entry AFTER the read, by design.
|
|
1760
|
+
if (stored) {
|
|
1761
|
+
try {
|
|
1762
|
+
await authFetch(baseUrl, agentId, keyPath, "DELETE", path);
|
|
1763
|
+
}
|
|
1764
|
+
catch { /* leave the ephemeral row; it'll age out */ }
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1607
1768
|
// ─── Doctor: client-integration network checks (flair#588) ────────────────────
|
|
1608
1769
|
//
|
|
1609
1770
|
// The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
|
|
@@ -3355,6 +3516,33 @@ program
|
|
|
3355
3516
|
else {
|
|
3356
3517
|
console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
|
|
3357
3518
|
}
|
|
3519
|
+
// Verify the audit log ACTUALLY records (flair#970) — a positive
|
|
3520
|
+
// control, not a flag read: `describe_table` reports `audit: true` on
|
|
3521
|
+
// nodes whose audit trail is empty (base-copy elision, harper#2212).
|
|
3522
|
+
// Same surface as the semantic-search check above.
|
|
3523
|
+
console.log("Verifying audit log...");
|
|
3524
|
+
const auditCheck = await verifyAuditLog(httpUrl, agentId, keysDir, `http://127.0.0.1:${opsPort}`, adminUser, adminPass);
|
|
3525
|
+
if (auditCheck.state === "ok") {
|
|
3526
|
+
// Present tense ONLY: the probe proves current recording, never
|
|
3527
|
+
// historical completeness — see AuditVerifyResult's doc comment.
|
|
3528
|
+
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)")}`);
|
|
3529
|
+
}
|
|
3530
|
+
else if (auditCheck.state === "degraded") {
|
|
3531
|
+
if (auditCheck.cause === "disabled") {
|
|
3532
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log DISABLED")} — ${auditCheck.detail}.`);
|
|
3533
|
+
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.")}`);
|
|
3534
|
+
}
|
|
3535
|
+
else {
|
|
3536
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log NOT RECORDING")} — ${auditCheck.detail}.`);
|
|
3537
|
+
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.")}`);
|
|
3538
|
+
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\".")}`);
|
|
3539
|
+
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.")}`);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
else {
|
|
3543
|
+
// An unrun check must not look like a pass.
|
|
3544
|
+
console.log(`${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditCheck.detail})`);
|
|
3545
|
+
}
|
|
3358
3546
|
// Output — admin password printed once, never written to disk
|
|
3359
3547
|
console.log("\n✅ Flair initialized successfully");
|
|
3360
3548
|
console.log(` Agent ID: ${agentId}`);
|
|
@@ -3824,6 +4012,13 @@ agent
|
|
|
3824
4012
|
: `✅ Agent '${id}' (${name}) registered`);
|
|
3825
4013
|
console.log(` Private key: ${privPath}`);
|
|
3826
4014
|
console.log(` Public key: ${pubKeyB64url}`);
|
|
4015
|
+
// flair#1280 — connector legibility at provisioning time: an OAuth /mcp
|
|
4016
|
+
// connector resolves its own token subject to an Agent via
|
|
4017
|
+
// Credential(kind:idp), NOT via this key, and the two identities are
|
|
4018
|
+
// DISTINCT unless linked. One line here saves the "my connector memory is
|
|
4019
|
+
// empty" discovery later.
|
|
4020
|
+
console.log(` Note: an OAuth /mcp connector maps its own IdP subject to an Agent (distinct from '${id}' by default).\n` +
|
|
4021
|
+
` To point a connector at '${id}': flair mcp enable --principal ${id} --idp-subject <your-idp-login>`);
|
|
3827
4022
|
});
|
|
3828
4023
|
agent
|
|
3829
4024
|
.command("list")
|
|
@@ -4359,6 +4554,7 @@ hook
|
|
|
4359
4554
|
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
|
|
4360
4555
|
.option("--agent-id <id>", "Alias for --agent")
|
|
4361
4556
|
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
|
|
4557
|
+
.option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
|
|
4362
4558
|
.action((opts) => {
|
|
4363
4559
|
const harness = requireSupportedHarness(opts.harness);
|
|
4364
4560
|
const home = homedir();
|
|
@@ -4369,6 +4565,18 @@ hook
|
|
|
4369
4565
|
}
|
|
4370
4566
|
const flairUrl = resolveHookFlairUrl(opts, home);
|
|
4371
4567
|
const dryRun = !!opts.dryRun;
|
|
4568
|
+
if (opts.continuity) {
|
|
4569
|
+
const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4570
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4571
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
4572
|
+
if (result.backupPath) {
|
|
4573
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4574
|
+
}
|
|
4575
|
+
console.log("");
|
|
4576
|
+
if (!result.ok)
|
|
4577
|
+
process.exit(1);
|
|
4578
|
+
return;
|
|
4579
|
+
}
|
|
4372
4580
|
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4373
4581
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4374
4582
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
@@ -4388,10 +4596,23 @@ hook
|
|
|
4388
4596
|
.description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
|
|
4389
4597
|
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
4390
4598
|
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
4599
|
+
.option("--continuity", "Remove the continuity capture hooks instead (PostToolUse + Stop — flair#1257)")
|
|
4391
4600
|
.action((opts) => {
|
|
4392
4601
|
const harness = requireSupportedHarness(opts.harness);
|
|
4393
4602
|
const home = homedir();
|
|
4394
4603
|
const dryRun = !!opts.dryRun;
|
|
4604
|
+
if (opts.continuity) {
|
|
4605
|
+
const result = uninstallContinuityHooks({ homeDir: home, harness, dryRun });
|
|
4606
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4607
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
4608
|
+
if (result.backupPath) {
|
|
4609
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4610
|
+
}
|
|
4611
|
+
console.log("");
|
|
4612
|
+
if (!result.ok)
|
|
4613
|
+
process.exit(1);
|
|
4614
|
+
return;
|
|
4615
|
+
}
|
|
4395
4616
|
const result = uninstallHook({ homeDir: home, harness, dryRun });
|
|
4396
4617
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4397
4618
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
@@ -4414,6 +4635,22 @@ hook
|
|
|
4414
4635
|
const harness = requireSupportedHarness(opts.harness);
|
|
4415
4636
|
const home = homedir();
|
|
4416
4637
|
const status = hookStatus(home, harness);
|
|
4638
|
+
// Continuity pair (flair#1257) — reported alongside the SessionStart
|
|
4639
|
+
// status in every branch below. "absent" is NOT a failure: installing the
|
|
4640
|
+
// pair is the opt-in, so absence renders as "not enabled".
|
|
4641
|
+
const renderContinuity = () => {
|
|
4642
|
+
const cont = continuityHookStatus(home, harness);
|
|
4643
|
+
if (cont.state === "installed") {
|
|
4644
|
+
console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
|
|
4645
|
+
}
|
|
4646
|
+
else if (cont.state === "absent") {
|
|
4647
|
+
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
|
|
4648
|
+
}
|
|
4649
|
+
else {
|
|
4650
|
+
const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
|
|
4651
|
+
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
|
|
4652
|
+
}
|
|
4653
|
+
};
|
|
4417
4654
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
4418
4655
|
console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
|
|
4419
4656
|
console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
|
|
@@ -4425,6 +4662,7 @@ hook
|
|
|
4425
4662
|
if (!status.wired) {
|
|
4426
4663
|
console.log(` ${render.icons.error} not wired`);
|
|
4427
4664
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
|
|
4665
|
+
renderContinuity();
|
|
4428
4666
|
console.log("");
|
|
4429
4667
|
process.exit(1);
|
|
4430
4668
|
}
|
|
@@ -4439,6 +4677,7 @@ hook
|
|
|
4439
4677
|
else {
|
|
4440
4678
|
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
4679
|
}
|
|
4680
|
+
renderContinuity();
|
|
4442
4681
|
console.log("");
|
|
4443
4682
|
});
|
|
4444
4683
|
// ─── flair mcp ───────────────────────────────────────────────────────────────
|
|
@@ -5736,7 +5975,14 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
5736
5975
|
return nacl.sign.keyPair.fromSeed(new Uint8Array(seedFromDb)).secretKey;
|
|
5737
5976
|
}
|
|
5738
5977
|
}
|
|
5739
|
-
|
|
5978
|
+
// flair#1233: the old advice here — "Re-run 'flair federation status' to
|
|
5979
|
+
// regenerate" — was impossible: the server's create branch only fires when
|
|
5980
|
+
// NO Instance row exists, so a re-run can never regenerate a key for an
|
|
5981
|
+
// existing identity. Name the real remedy instead.
|
|
5982
|
+
throw new Error(`No usable private key for instance ${instanceId}. Expected keystore file: ${keystoreKeyPath(instanceId)} ` +
|
|
5983
|
+
`(no legacy _keySeed in the Instance table either). Restore that key file from a backup of ~/.flair/keys ` +
|
|
5984
|
+
`(and FLAIR_KEY_PASSPHRASE, if one was set when it was written), or re-key this instance: delete its ` +
|
|
5985
|
+
`Instance row and re-pair to mint a fresh identity.`);
|
|
5740
5986
|
}
|
|
5741
5987
|
/**
|
|
5742
5988
|
* Sign a request body and return a new body with the signature field added.
|
|
@@ -5811,165 +6057,48 @@ federation
|
|
|
5811
6057
|
const target = resolveTarget(opts);
|
|
5812
6058
|
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
5813
6059
|
const mode = render.resolveOutputMode(opts);
|
|
6060
|
+
// flair#1233: fetch instance and peers INDEPENDENTLY. One read failing
|
|
6061
|
+
// must never take down the whole render — the principle latestPeerContact
|
|
6062
|
+
// already documents for the driver verdict ("must never be the reason
|
|
6063
|
+
// `federation status` fails"), extended to the two primary reads. Print
|
|
6064
|
+
// what's available; mark the rest unverifiable.
|
|
6065
|
+
let instance = null;
|
|
6066
|
+
let instanceErr = null;
|
|
5814
6067
|
try {
|
|
5815
|
-
|
|
5816
|
-
const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
|
|
5817
|
-
// Driver state (flair#922). Computed from the LOCAL service manager, so
|
|
5818
|
-
// it is only meaningful when the CLI is pointed at the local instance.
|
|
5819
|
-
let driver = null;
|
|
5820
|
-
let assessment = null;
|
|
5821
|
-
if (driverCheckAppliesTo(opts)) {
|
|
5822
|
-
try {
|
|
5823
|
-
const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
|
|
5824
|
-
driver = schedulerStatus();
|
|
5825
|
-
let lastSyncAt = null;
|
|
5826
|
-
for (const p of peers ?? []) {
|
|
5827
|
-
if (!p?.lastSyncAt)
|
|
5828
|
-
continue;
|
|
5829
|
-
const t = Date.parse(p.lastSyncAt);
|
|
5830
|
-
if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
|
|
5831
|
-
lastSyncAt = new Date(t).toISOString();
|
|
5832
|
-
}
|
|
5833
|
-
}
|
|
5834
|
-
assessment = assessDriver({
|
|
5835
|
-
installed: driver.installed,
|
|
5836
|
-
active: driver.active,
|
|
5837
|
-
intervalSeconds: driver.intervalSeconds,
|
|
5838
|
-
lastSyncAt,
|
|
5839
|
-
now: Date.now(),
|
|
5840
|
-
});
|
|
5841
|
-
}
|
|
5842
|
-
catch {
|
|
5843
|
-
// An unsupported platform (neither darwin nor linux) or an
|
|
5844
|
-
// unreadable unit must not take down `federation status` — the peer
|
|
5845
|
-
// table is still the primary output.
|
|
5846
|
-
driver = null;
|
|
5847
|
-
assessment = null;
|
|
5848
|
-
}
|
|
5849
|
-
}
|
|
5850
|
-
if (mode === "json") {
|
|
5851
|
-
console.log(render.asJSON({ instance, peers, driver, driverAssessment: assessment }));
|
|
5852
|
-
return;
|
|
5853
|
-
}
|
|
5854
|
-
const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
|
|
5855
|
-
console.log(render.wrap(render.c.bold, "Federation"));
|
|
5856
|
-
console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
|
|
5857
|
-
console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
|
|
5858
|
-
console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
|
|
5859
|
-
if (peers.length === 0) {
|
|
5860
|
-
console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
|
|
5861
|
-
return;
|
|
5862
|
-
}
|
|
5863
|
-
// Print the driver line BEFORE the per-peer table: "is anything running
|
|
5864
|
-
// sync at all" is the question that decides how to read everything
|
|
5865
|
-
// below it.
|
|
5866
|
-
if (assessment) {
|
|
5867
|
-
const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
5868
|
-
? render.icons.ok
|
|
5869
|
-
: assessment.verdict === "unknown"
|
|
5870
|
-
? render.icons.info
|
|
5871
|
-
: render.icons.warn;
|
|
5872
|
-
const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
5873
|
-
? render.c.green
|
|
5874
|
-
: assessment.verdict === "unknown"
|
|
5875
|
-
? render.c.dim
|
|
5876
|
-
: render.c.yellow;
|
|
5877
|
-
console.log();
|
|
5878
|
-
console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
|
|
5879
|
-
console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
|
|
5880
|
-
if (assessment.remedy)
|
|
5881
|
-
console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
|
|
5882
|
-
}
|
|
5883
|
-
const now = Date.now();
|
|
5884
|
-
const formatPeerAge = (iso, refNow, staleAfterMs) => {
|
|
5885
|
-
if (!iso)
|
|
5886
|
-
return render.wrap(render.c.red, "never");
|
|
5887
|
-
const t = Date.parse(iso);
|
|
5888
|
-
if (!Number.isFinite(t))
|
|
5889
|
-
return render.wrap(render.c.red, "never");
|
|
5890
|
-
const ageMs = refNow - t;
|
|
5891
|
-
const ageStr = ageMs < 60_000 ? "<1m ago"
|
|
5892
|
-
: ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
|
|
5893
|
-
: ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
|
|
5894
|
-
: `${Math.floor(ageMs / 86_400_000)}d ago`;
|
|
5895
|
-
const stale = ageMs > staleAfterMs;
|
|
5896
|
-
return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
|
|
5897
|
-
};
|
|
5898
|
-
console.log();
|
|
5899
|
-
const cols = [
|
|
5900
|
-
{ label: "peer", key: "id" },
|
|
5901
|
-
{ label: "role", key: "role", format: (v) => String(v ?? "—") },
|
|
5902
|
-
{
|
|
5903
|
-
label: "status",
|
|
5904
|
-
key: "status",
|
|
5905
|
-
format: (v) => {
|
|
5906
|
-
const s = String(v ?? "—");
|
|
5907
|
-
const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
|
|
5908
|
-
return render.wrap(color, s);
|
|
5909
|
-
},
|
|
5910
|
-
},
|
|
5911
|
-
{
|
|
5912
|
-
// Liveness: "did we hear from this peer recently?" Updates on every
|
|
5913
|
-
// contact, even when 100% of records were skipped. See flair#444.
|
|
5914
|
-
label: "last_sync",
|
|
5915
|
-
key: "lastSyncAt",
|
|
5916
|
-
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
5917
|
-
},
|
|
5918
|
-
{
|
|
5919
|
-
// Progress: "did data actually flow in?" Updates only when merged>0.
|
|
5920
|
-
// Diverging from last_sync means contact-yes but data-no — investigate.
|
|
5921
|
-
label: "last_merge",
|
|
5922
|
-
key: "lastMergeAt",
|
|
5923
|
-
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
5924
|
-
},
|
|
5925
|
-
{
|
|
5926
|
-
label: "relay",
|
|
5927
|
-
key: "relayOnly",
|
|
5928
|
-
format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
|
|
5929
|
-
},
|
|
5930
|
-
];
|
|
5931
|
-
console.log(render.table(cols, peers));
|
|
5932
|
-
// Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
|
|
5933
|
-
// A peer that "syncs" every 5min but hasn't merged a record in 24h is
|
|
5934
|
-
// exactly the failure mode we want surfaced.
|
|
5935
|
-
const haveStale = peers.some((p) => {
|
|
5936
|
-
const cursor = p.lastMergeAt ?? p.lastSyncAt;
|
|
5937
|
-
if (!cursor)
|
|
5938
|
-
return true;
|
|
5939
|
-
const t = Date.parse(cursor);
|
|
5940
|
-
return !Number.isFinite(t) || (now - t) > 86_400_000;
|
|
5941
|
-
});
|
|
5942
|
-
if (haveStale) {
|
|
5943
|
-
console.log();
|
|
5944
|
-
// The staleness warning used to fire identically whether sync was
|
|
5945
|
-
// running and the peer was unreachable, or nothing had run sync since
|
|
5946
|
-
// the day the spoke was paired (flair#922). Those need opposite
|
|
5947
|
-
// actions, so the remedy is now chosen by the driver verdict instead
|
|
5948
|
-
// of always pointing at SyncLog.
|
|
5949
|
-
const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
|
|
5950
|
-
const remedy = noDriver
|
|
5951
|
-
? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
|
|
5952
|
-
: "Check skippedReasons in SyncLog or run 'flair federation sync'.";
|
|
5953
|
-
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, remedy)}`);
|
|
5954
|
-
}
|
|
5955
|
-
const haveContactButNoMerge = peers.some((p) => {
|
|
5956
|
-
if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
|
|
5957
|
-
return false;
|
|
5958
|
-
if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
|
|
5959
|
-
return false; // only recent contact
|
|
5960
|
-
// Contact within the last hour, but no merge ever (or stale by >1h)
|
|
5961
|
-
if (!p.lastMergeAt)
|
|
5962
|
-
return true;
|
|
5963
|
-
const tm = Date.parse(p.lastMergeAt);
|
|
5964
|
-
return !Number.isFinite(tm) || (now - tm) > 3_600_000;
|
|
5965
|
-
});
|
|
5966
|
-
if (haveContactButNoMerge && !haveStale) {
|
|
5967
|
-
console.log();
|
|
5968
|
-
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peer contact is fresh but no records merged in the last hour.")} ${render.wrap(render.c.dim, "Possible silent-skip scenario — check SyncLog.skippedReasons.")}`);
|
|
5969
|
-
}
|
|
6068
|
+
instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
5970
6069
|
}
|
|
5971
6070
|
catch (err) {
|
|
5972
|
-
|
|
6071
|
+
instanceErr = err;
|
|
6072
|
+
}
|
|
6073
|
+
// peers: null = unverifiable (the read failed), [] = verified empty.
|
|
6074
|
+
let peers = null;
|
|
6075
|
+
let peersErr = null;
|
|
6076
|
+
try {
|
|
6077
|
+
const r = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
|
|
6078
|
+
peers = r.peers ?? [];
|
|
6079
|
+
}
|
|
6080
|
+
catch (err) {
|
|
6081
|
+
peersErr = err;
|
|
6082
|
+
}
|
|
6083
|
+
// Auth-shaped failures stay FATAL even when the other read succeeded:
|
|
6084
|
+
// both endpoints sit behind the same allowAdmin gate, so a 401/403 is a
|
|
6085
|
+
// property of the session's credentials, not of one endpoint — and
|
|
6086
|
+
// degrading it to "unverifiable" would swallow the actionable remedy
|
|
6087
|
+
// (flair#634's UX, kept). Only non-auth failures degrade independently.
|
|
6088
|
+
const authShaped = (err) => {
|
|
6089
|
+
if (!err)
|
|
6090
|
+
return false;
|
|
6091
|
+
if (err.status === 401 || err.status === 403)
|
|
6092
|
+
return true;
|
|
6093
|
+
const m = String(err.message ?? err);
|
|
6094
|
+
return m.includes("missing_or_invalid_authorization") || m.includes("401");
|
|
6095
|
+
};
|
|
6096
|
+
// Both reads failed → nothing to render at all. Either way keep the
|
|
6097
|
+
// classic failure UX (auth remedy when it's an auth problem), exit
|
|
6098
|
+
// non-zero.
|
|
6099
|
+
if ((instanceErr && peersErr) || authShaped(instanceErr) || authShaped(peersErr)) {
|
|
6100
|
+
const primaryErr = instanceErr ?? peersErr;
|
|
6101
|
+
const msg = String(primaryErr.message ?? primaryErr);
|
|
5973
6102
|
if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
|
|
5974
6103
|
console.error(`${render.icons.error} federation status requires auth.`);
|
|
5975
6104
|
console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
|
|
@@ -5981,6 +6110,203 @@ federation
|
|
|
5981
6110
|
console.error(`${render.icons.error} ${msg}`);
|
|
5982
6111
|
process.exit(1);
|
|
5983
6112
|
}
|
|
6113
|
+
// Driver state (flair#922). Computed from the LOCAL service manager, so
|
|
6114
|
+
// it is only meaningful when the CLI is pointed at the local instance —
|
|
6115
|
+
// and only when the peer read succeeded: the verdict is derived from
|
|
6116
|
+
// peer contact, so with peers unverifiable it would be a confident claim
|
|
6117
|
+
// built on no data.
|
|
6118
|
+
let driver = null;
|
|
6119
|
+
let assessment = null;
|
|
6120
|
+
if (peers !== null && driverCheckAppliesTo(opts)) {
|
|
6121
|
+
try {
|
|
6122
|
+
const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
|
|
6123
|
+
driver = schedulerStatus();
|
|
6124
|
+
let lastSyncAt = null;
|
|
6125
|
+
for (const p of peers) {
|
|
6126
|
+
if (!p?.lastSyncAt)
|
|
6127
|
+
continue;
|
|
6128
|
+
const t = Date.parse(p.lastSyncAt);
|
|
6129
|
+
if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
|
|
6130
|
+
lastSyncAt = new Date(t).toISOString();
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
6133
|
+
assessment = assessDriver({
|
|
6134
|
+
installed: driver.installed,
|
|
6135
|
+
active: driver.active,
|
|
6136
|
+
intervalSeconds: driver.intervalSeconds,
|
|
6137
|
+
lastSyncAt,
|
|
6138
|
+
now: Date.now(),
|
|
6139
|
+
});
|
|
6140
|
+
}
|
|
6141
|
+
catch {
|
|
6142
|
+
// An unsupported platform (neither darwin nor linux) or an
|
|
6143
|
+
// unreadable unit must not take down `federation status` — the peer
|
|
6144
|
+
// table is still the primary output.
|
|
6145
|
+
driver = null;
|
|
6146
|
+
assessment = null;
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
if (mode === "json") {
|
|
6150
|
+
console.log(render.asJSON({
|
|
6151
|
+
instance,
|
|
6152
|
+
peers,
|
|
6153
|
+
driver,
|
|
6154
|
+
driverAssessment: assessment,
|
|
6155
|
+
// flair#1233: name what could not be read, so a partial result is
|
|
6156
|
+
// distinguishable from "verified absent" (instance/peers stay null
|
|
6157
|
+
// when their read failed).
|
|
6158
|
+
...(instanceErr || peersErr
|
|
6159
|
+
? {
|
|
6160
|
+
unverifiable: {
|
|
6161
|
+
...(instanceErr ? { instance: String(instanceErr.message ?? instanceErr) } : {}),
|
|
6162
|
+
...(peersErr ? { peers: String(peersErr.message ?? peersErr) } : {}),
|
|
6163
|
+
},
|
|
6164
|
+
}
|
|
6165
|
+
: {}),
|
|
6166
|
+
}));
|
|
6167
|
+
return;
|
|
6168
|
+
}
|
|
6169
|
+
console.log(render.wrap(render.c.bold, "Federation"));
|
|
6170
|
+
if (instance) {
|
|
6171
|
+
const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
|
|
6172
|
+
console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
|
|
6173
|
+
console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
|
|
6174
|
+
console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
|
|
6175
|
+
// flair#1233 degraded marker — actor + state + remedy. The server sets
|
|
6176
|
+
// signingKeyAvailable (runtime-only) on GET /FederationInstance; only
|
|
6177
|
+
// an explicit false fires this. Older servers omit the field, which
|
|
6178
|
+
// proves nothing either way, so no marker.
|
|
6179
|
+
if (instance.signingKeyAvailable === false) {
|
|
6180
|
+
console.log();
|
|
6181
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Signing key unavailable — the server could not store or read this instance's private key.")}`);
|
|
6182
|
+
console.log(` ${render.wrap(render.c.dim, "Reads (this status) still work; pair/sync signing on that instance will fail until the keystore is fixed.")}`);
|
|
6183
|
+
console.log(` ${render.wrap(render.c.cyan, "Fix on the server: make $HOME/.flair/keys (under the Harper process's HOME) a directory writable by the Harper process, mode 0700. If the key was never stored, re-key: delete the Instance row and re-pair.")}`);
|
|
6184
|
+
}
|
|
6185
|
+
}
|
|
6186
|
+
else {
|
|
6187
|
+
// Instance read failed but peers succeeded — render what we have.
|
|
6188
|
+
console.log(render.kv("Instance", `${render.icons.warn} unverifiable — ${render.wrap(render.c.dim, String(instanceErr?.message ?? instanceErr))}`));
|
|
6189
|
+
}
|
|
6190
|
+
if (peers === null) {
|
|
6191
|
+
// Peer read failed but the instance rendered — say so explicitly
|
|
6192
|
+
// instead of aborting: "unverifiable" is a different claim from "no
|
|
6193
|
+
// peers", and conflating them is how hub state became unobservable.
|
|
6194
|
+
console.log();
|
|
6195
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peers unverifiable — the peer read failed. This says nothing about whether peers exist or sync runs.")}`);
|
|
6196
|
+
console.log(` ${render.wrap(render.c.dim, String(peersErr?.message ?? peersErr))}`);
|
|
6197
|
+
return;
|
|
6198
|
+
}
|
|
6199
|
+
if (peers.length === 0) {
|
|
6200
|
+
console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
|
|
6201
|
+
return;
|
|
6202
|
+
}
|
|
6203
|
+
// Print the driver line BEFORE the per-peer table: "is anything running
|
|
6204
|
+
// sync at all" is the question that decides how to read everything
|
|
6205
|
+
// below it.
|
|
6206
|
+
if (assessment) {
|
|
6207
|
+
const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
6208
|
+
? render.icons.ok
|
|
6209
|
+
: assessment.verdict === "unknown"
|
|
6210
|
+
? render.icons.info
|
|
6211
|
+
: render.icons.warn;
|
|
6212
|
+
const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
|
|
6213
|
+
? render.c.green
|
|
6214
|
+
: assessment.verdict === "unknown"
|
|
6215
|
+
? render.c.dim
|
|
6216
|
+
: render.c.yellow;
|
|
6217
|
+
console.log();
|
|
6218
|
+
console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
|
|
6219
|
+
console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
|
|
6220
|
+
if (assessment.remedy)
|
|
6221
|
+
console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
|
|
6222
|
+
}
|
|
6223
|
+
const now = Date.now();
|
|
6224
|
+
const formatPeerAge = (iso, refNow, staleAfterMs) => {
|
|
6225
|
+
if (!iso)
|
|
6226
|
+
return render.wrap(render.c.red, "never");
|
|
6227
|
+
const t = Date.parse(iso);
|
|
6228
|
+
if (!Number.isFinite(t))
|
|
6229
|
+
return render.wrap(render.c.red, "never");
|
|
6230
|
+
const ageMs = refNow - t;
|
|
6231
|
+
const ageStr = ageMs < 60_000 ? "<1m ago"
|
|
6232
|
+
: ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
|
|
6233
|
+
: ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
|
|
6234
|
+
: `${Math.floor(ageMs / 86_400_000)}d ago`;
|
|
6235
|
+
const stale = ageMs > staleAfterMs;
|
|
6236
|
+
return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
|
|
6237
|
+
};
|
|
6238
|
+
console.log();
|
|
6239
|
+
const cols = [
|
|
6240
|
+
{ label: "peer", key: "id" },
|
|
6241
|
+
{ label: "role", key: "role", format: (v) => String(v ?? "—") },
|
|
6242
|
+
{
|
|
6243
|
+
label: "status",
|
|
6244
|
+
key: "status",
|
|
6245
|
+
format: (v) => {
|
|
6246
|
+
const s = String(v ?? "—");
|
|
6247
|
+
const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
|
|
6248
|
+
return render.wrap(color, s);
|
|
6249
|
+
},
|
|
6250
|
+
},
|
|
6251
|
+
{
|
|
6252
|
+
// Liveness: "did we hear from this peer recently?" Updates on every
|
|
6253
|
+
// contact, even when 100% of records were skipped. See flair#444.
|
|
6254
|
+
label: "last_sync",
|
|
6255
|
+
key: "lastSyncAt",
|
|
6256
|
+
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
6257
|
+
},
|
|
6258
|
+
{
|
|
6259
|
+
// Progress: "did data actually flow in?" Updates only when merged>0.
|
|
6260
|
+
// Diverging from last_sync means contact-yes but data-no — investigate.
|
|
6261
|
+
label: "last_merge",
|
|
6262
|
+
key: "lastMergeAt",
|
|
6263
|
+
format: (v) => formatPeerAge(v, now, 86_400_000),
|
|
6264
|
+
},
|
|
6265
|
+
{
|
|
6266
|
+
label: "relay",
|
|
6267
|
+
key: "relayOnly",
|
|
6268
|
+
format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
|
|
6269
|
+
},
|
|
6270
|
+
];
|
|
6271
|
+
console.log(render.table(cols, peers));
|
|
6272
|
+
// Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
|
|
6273
|
+
// A peer that "syncs" every 5min but hasn't merged a record in 24h is
|
|
6274
|
+
// exactly the failure mode we want surfaced.
|
|
6275
|
+
const haveStale = peers.some((p) => {
|
|
6276
|
+
const cursor = p.lastMergeAt ?? p.lastSyncAt;
|
|
6277
|
+
if (!cursor)
|
|
6278
|
+
return true;
|
|
6279
|
+
const t = Date.parse(cursor);
|
|
6280
|
+
return !Number.isFinite(t) || (now - t) > 86_400_000;
|
|
6281
|
+
});
|
|
6282
|
+
if (haveStale) {
|
|
6283
|
+
console.log();
|
|
6284
|
+
// The staleness warning used to fire identically whether sync was
|
|
6285
|
+
// running and the peer was unreachable, or nothing had run sync since
|
|
6286
|
+
// the day the spoke was paired (flair#922). Those need opposite
|
|
6287
|
+
// actions, so the remedy is now chosen by the driver verdict instead
|
|
6288
|
+
// of always pointing at SyncLog.
|
|
6289
|
+
const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
|
|
6290
|
+
const remedy = noDriver
|
|
6291
|
+
? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
|
|
6292
|
+
: "Check skippedReasons in SyncLog or run 'flair federation sync'.";
|
|
6293
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "One or more peers haven't merged a record in >24h.")} ${render.wrap(render.c.dim, remedy)}`);
|
|
6294
|
+
}
|
|
6295
|
+
const haveContactButNoMerge = peers.some((p) => {
|
|
6296
|
+
if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
|
|
6297
|
+
return false;
|
|
6298
|
+
if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
|
|
6299
|
+
return false; // only recent contact
|
|
6300
|
+
// Contact within the last hour, but no merge ever (or stale by >1h)
|
|
6301
|
+
if (!p.lastMergeAt)
|
|
6302
|
+
return true;
|
|
6303
|
+
const tm = Date.parse(p.lastMergeAt);
|
|
6304
|
+
return !Number.isFinite(tm) || (now - tm) > 3_600_000;
|
|
6305
|
+
});
|
|
6306
|
+
if (haveContactButNoMerge && !haveStale) {
|
|
6307
|
+
console.log();
|
|
6308
|
+
console.log(`${render.icons.warn} ${render.wrap(render.c.yellow, "Peer contact is fresh but no records merged in the last hour.")} ${render.wrap(render.c.dim, "Possible silent-skip scenario — check SyncLog.skippedReasons.")}`);
|
|
6309
|
+
}
|
|
5984
6310
|
});
|
|
5985
6311
|
// `flair federation reachability` — probe local instance + all paired peers.
|
|
5986
6312
|
// Productizes flair#695: a single command that tells
|
|
@@ -6433,6 +6759,11 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6433
6759
|
return await syncRes.json();
|
|
6434
6760
|
}
|
|
6435
6761
|
let totalBatches = 0;
|
|
6762
|
+
// Memory rows that passed the since-cursor filter but were excluded as
|
|
6763
|
+
// private. Used only so the quiet path can distinguish "nothing since
|
|
6764
|
+
// the cursor" from "found rows, all withheld" (flair#1232). Does not
|
|
6765
|
+
// change what gets pushed — private still never leaves the instance.
|
|
6766
|
+
let privateHeldBack = 0;
|
|
6436
6767
|
for (const table of tables) {
|
|
6437
6768
|
let rows = [];
|
|
6438
6769
|
for (const query of [
|
|
@@ -6466,9 +6797,11 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6466
6797
|
// filter only applies there; on the other 3 tables `row.visibility`
|
|
6467
6798
|
// is always undefined, which isFederationPrivateVisibility() treats
|
|
6468
6799
|
// as non-private (included) — a no-op for them.
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6800
|
+
const sinceCursor = batch.filter((r) => r.updatedAt !== null || r.createdAt > since);
|
|
6801
|
+
const federable = sinceCursor.filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility));
|
|
6802
|
+
if (table === "Memory")
|
|
6803
|
+
privateHeldBack += sinceCursor.length - federable.length;
|
|
6804
|
+
rows = rows.concat(federable);
|
|
6472
6805
|
}
|
|
6473
6806
|
if (rows.length === 0)
|
|
6474
6807
|
continue;
|
|
@@ -6583,7 +6916,13 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6583
6916
|
catch (pingErr) {
|
|
6584
6917
|
console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
|
|
6585
6918
|
}
|
|
6586
|
-
|
|
6919
|
+
// flair#1232: "No changes" is true only when nothing was found since
|
|
6920
|
+
// the cursor. If rows were found and every one was withheld as private,
|
|
6921
|
+
// say so — count and reason only, never content. A zero withheld count
|
|
6922
|
+
// must not invent a private-withheld story.
|
|
6923
|
+
console.log(privateHeldBack > 0
|
|
6924
|
+
? `No federable changes since last sync (${privateHeldBack} row${privateHeldBack === 1 ? "" : "s"} held back: private visibility).`
|
|
6925
|
+
: "No changes since last sync.");
|
|
6587
6926
|
return { pushed: 0, skipped: 0 };
|
|
6588
6927
|
}
|
|
6589
6928
|
console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
|
|
@@ -7577,6 +7916,43 @@ export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
|
|
|
7577
7916
|
const scopeTag = [...adkTags][0];
|
|
7578
7917
|
return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
|
|
7579
7918
|
}
|
|
7919
|
+
// ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
|
|
7920
|
+
// Continuity-journal scope tag prefix. Canonical string duplicated in
|
|
7921
|
+
// resources/memory-reflect-lib.ts / resources/auto-promote-lib.ts and
|
|
7922
|
+
// packages/flair-mcp/src/continuity.ts — this file sits on the CLI side of the
|
|
7923
|
+
// npm-packaging boundary (see this file's header) and cannot import them; kept
|
|
7924
|
+
// in sync by the shared canonical string, same discipline as
|
|
7925
|
+
// MACHINE_REVIEWER_* below.
|
|
7926
|
+
export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
|
|
7927
|
+
/**
|
|
7928
|
+
* Decide a promoted Memory row's visibility for the HUMAN `rem promote` path
|
|
7929
|
+
* (flair#1257 slice 3). Mirror of resources/auto-promote-lib.ts
|
|
7930
|
+
* decidePromotedVisibility (the server-side auto-promote half) — Sherlock's
|
|
7931
|
+
* default-private-unless ruling covers BOTH promotion paths: the sources of a
|
|
7932
|
+
* continuity candidate are the most sensitive tier (ephemeral+private journal
|
|
7933
|
+
* rows), so leaving visibility unset here would let Memory's durability-keyed
|
|
7934
|
+
* default widen it to shared ("persistent" defaults shared) — a silent
|
|
7935
|
+
* visibility escalation. "shared" only when the candidate is continuity-scoped
|
|
7936
|
+
* AND carries the distiller's affirmative ruling WITH its recorded
|
|
7937
|
+
* team-relevance justification; every other case — including every
|
|
7938
|
+
* uncertainty — is "private".
|
|
7939
|
+
*
|
|
7940
|
+
* Returns undefined for NON-continuity candidates: their visibility behavior
|
|
7941
|
+
* (durability-keyed default) is byte-for-byte the pre-slice-3 contract and is
|
|
7942
|
+
* deliberately not changed here.
|
|
7943
|
+
*/
|
|
7944
|
+
export function derivePromotedVisibility(candidate) {
|
|
7945
|
+
const scopeTag = candidate.scopeTag;
|
|
7946
|
+
const isContinuity = typeof scopeTag === "string" &&
|
|
7947
|
+
scopeTag.length > CONTINUITY_SCOPE_TAG_PREFIX.length &&
|
|
7948
|
+
scopeTag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
|
|
7949
|
+
if (!isContinuity)
|
|
7950
|
+
return undefined;
|
|
7951
|
+
if (candidate.visibilityRuling !== "shared")
|
|
7952
|
+
return "private";
|
|
7953
|
+
const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
|
|
7954
|
+
return rationale.length > 0 ? "shared" : "private";
|
|
7955
|
+
}
|
|
7580
7956
|
// ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
|
|
7581
7957
|
// A promotion records a reviewerId that feeds audit/attribution
|
|
7582
7958
|
// (schemas/memory.graphql:209). An automated (machine-driven) promotion path
|
|
@@ -7693,11 +8069,19 @@ rem
|
|
|
7693
8069
|
// Write the resulting Soul or Memory entry
|
|
7694
8070
|
if (opts.to === "memory") {
|
|
7695
8071
|
const memId = `${candidate.agentId}-promoted-${Date.now()}`;
|
|
8072
|
+
// flair#1257 slice 3: for a CONTINUITY-scoped candidate, visibility is
|
|
8073
|
+
// decided default-private-unless (derivePromotedVisibility) — the
|
|
8074
|
+
// sources are ephemeral+private journal rows, and an unset visibility
|
|
8075
|
+
// would silently widen to shared via the persistent durability
|
|
8076
|
+
// default. Non-continuity candidates return undefined here and keep
|
|
8077
|
+
// the pre-slice-3 durability-keyed default, unchanged.
|
|
8078
|
+
const promotedVisibility = derivePromotedVisibility(candidate);
|
|
7696
8079
|
const memWrite = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
|
|
7697
8080
|
id: memId,
|
|
7698
8081
|
agentId: candidate.agentId,
|
|
7699
8082
|
content: candidate.claim,
|
|
7700
8083
|
durability: "persistent",
|
|
8084
|
+
...(promotedVisibility ? { visibility: promotedVisibility } : {}),
|
|
7701
8085
|
tags: promotedTags,
|
|
7702
8086
|
derivedFrom: candidate.sourceMemoryIds ?? [],
|
|
7703
8087
|
promotionStatus: "approved",
|
|
@@ -11933,6 +12317,33 @@ program
|
|
|
11933
12317
|
`Commands run through the CLI; the instance serves the data.`)}`);
|
|
11934
12318
|
}
|
|
11935
12319
|
}
|
|
12320
|
+
// 0.5 npm global bin dir on PATH (flair#1134) — a user-prefix
|
|
12321
|
+
// `npm i -g` succeeds and then `flair` is command-not-found because
|
|
12322
|
+
// <prefix>/bin never made it into PATH. postinstall warns at install
|
|
12323
|
+
// time, but lifecycle scripts are suppressed on several real paths
|
|
12324
|
+
// (--ignore-scripts, bun without trustedDependencies, tar-swap
|
|
12325
|
+
// deploys), so doctor re-runs the same check — cheap, local, and
|
|
12326
|
+
// independent of Harper being up. When npm itself is absent or slow
|
|
12327
|
+
// the check SKIPS silently: flair may be installed by other means,
|
|
12328
|
+
// and "npm missing" has no actionable fix this check could print.
|
|
12329
|
+
const npmGlobalPrefix = await resolveNpmGlobalPrefix();
|
|
12330
|
+
if (npmGlobalPrefix) {
|
|
12331
|
+
const binCheck = checkGlobalBinOnPath({
|
|
12332
|
+
prefix: npmGlobalPrefix,
|
|
12333
|
+
pathEnv: process.env.PATH,
|
|
12334
|
+
shell: process.env.SHELL,
|
|
12335
|
+
});
|
|
12336
|
+
if ("message" in binCheck) {
|
|
12337
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `npm global bin dir ${binCheck.binDir} is NOT on PATH — global npm installs (flair included) won't be found by name`)}`);
|
|
12338
|
+
for (const line of binCheck.message.split("\n")) {
|
|
12339
|
+
console.log(` ${render.wrap(render.c.dim, line)}`);
|
|
12340
|
+
}
|
|
12341
|
+
issues++;
|
|
12342
|
+
}
|
|
12343
|
+
else {
|
|
12344
|
+
console.log(` ${render.icons.ok} npm global bin dir ${render.wrap(render.c.dim, binCheck.binDir)} is on PATH`);
|
|
12345
|
+
}
|
|
12346
|
+
}
|
|
11936
12347
|
// Helper: try to reach Harper on a given port.
|
|
11937
12348
|
// Must return true ONLY when Harper's /Health endpoint returns 200 OK.
|
|
11938
12349
|
// A generic HTTP status > 0 (flair#862) would accept 404 from a Node
|
|
@@ -12303,6 +12714,60 @@ program
|
|
|
12303
12714
|
}
|
|
12304
12715
|
}
|
|
12305
12716
|
}
|
|
12717
|
+
// 4b. Audit-log positive control (flair#970) — REAL write→read_audit_log
|
|
12718
|
+
// round-trip, only if Harper is responding. `describe_table` reporting
|
|
12719
|
+
// `audit: true` proves nothing: a node that joined or resynced via
|
|
12720
|
+
// cluster base copy holds zero audit history while reporting audit
|
|
12721
|
+
// enabled and answering read_audit_log with clean empty (harper#2212).
|
|
12722
|
+
// So doctor writes probe rows and asserts their audit entries come back —
|
|
12723
|
+
// never trusts the flag. Same ok/degraded/skipped discipline as the
|
|
12724
|
+
// embeddings check above: skipped is rendered UNVERIFIED, never as a pass.
|
|
12725
|
+
if (harperResponding) {
|
|
12726
|
+
// read_audit_log only exists on the ops API (its own port), which the
|
|
12727
|
+
// agent's Ed25519 header cannot authenticate — resolve the local admin
|
|
12728
|
+
// credential (env or ~/.flair/admin-pass; never prompts). A file with
|
|
12729
|
+
// unsafe permissions throws — that is "could not probe", not "broken".
|
|
12730
|
+
let auditAdminPass;
|
|
12731
|
+
let auditCredIssue = null;
|
|
12732
|
+
try {
|
|
12733
|
+
auditAdminPass = resolveLocalAdminPass(undefined);
|
|
12734
|
+
}
|
|
12735
|
+
catch (err) {
|
|
12736
|
+
auditCredIssue = err instanceof Error ? err.message : String(err);
|
|
12737
|
+
}
|
|
12738
|
+
const auditStatus = auditCredIssue
|
|
12739
|
+
? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
|
|
12740
|
+
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, DEFAULT_ADMIN_USER, auditAdminPass);
|
|
12741
|
+
switch (auditStatus.state) {
|
|
12742
|
+
case "ok":
|
|
12743
|
+
// Present-tense claim ONLY (see AuditVerifyResult): the probe
|
|
12744
|
+
// proves the log records writes NOW — never that history is
|
|
12745
|
+
// complete. Overclaiming here would rebuild the false trust
|
|
12746
|
+
// anchor this check exists to kill, one layer up.
|
|
12747
|
+
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)")}`);
|
|
12748
|
+
break;
|
|
12749
|
+
case "degraded":
|
|
12750
|
+
if (auditStatus.cause === "disabled") {
|
|
12751
|
+
console.log(` ${render.icons.error} Audit log DISABLED ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
12752
|
+
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.")}`);
|
|
12753
|
+
}
|
|
12754
|
+
else {
|
|
12755
|
+
console.log(` ${render.icons.error} Audit log NOT RECORDING ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
12756
|
+
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.")}`);
|
|
12757
|
+
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\".")}`);
|
|
12758
|
+
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.")}`);
|
|
12759
|
+
}
|
|
12760
|
+
issues++;
|
|
12761
|
+
break;
|
|
12762
|
+
case "skipped":
|
|
12763
|
+
// An unrun check must not look like a pass — UNVERIFIED, visually
|
|
12764
|
+
// distinct from ok, but not a hard issue (mirrors the embeddings
|
|
12765
|
+
// skip: the operator may simply have no agent or no local admin
|
|
12766
|
+
// credential on this box).
|
|
12767
|
+
console.log(` ${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditStatus.detail})`);
|
|
12768
|
+
break;
|
|
12769
|
+
}
|
|
12770
|
+
}
|
|
12306
12771
|
// 5. Stale PID file (skip if already reported in port check)
|
|
12307
12772
|
const dataDir = defaultDataDir();
|
|
12308
12773
|
const pidFile = join(dataDir, "hdb.pid");
|
|
@@ -12455,13 +12920,22 @@ program
|
|
|
12455
12920
|
continue;
|
|
12456
12921
|
}
|
|
12457
12922
|
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
12458
|
-
|
|
12923
|
+
// flair#1287: a block with FLAIR_AGENT_ID but no FLAIR_URL is a
|
|
12924
|
+
// WORKING setup — flair-client falls back to its built-in default —
|
|
12925
|
+
// and must never be reported as unconfigured. Say which URL applies
|
|
12926
|
+
// and keep verifying against it, exactly as for an explicit one.
|
|
12927
|
+
const eff = effectiveFlairUrl(block);
|
|
12928
|
+
const urlLabel = eff.defaulted ? `${eff.url} (client default)` : eff.url;
|
|
12929
|
+
if (eff.defaulted) {
|
|
12930
|
+
console.log(` ${render.icons.info} FLAIR_URL not set — flair-mcp defaults to ${render.wrap(render.c.dim, eff.url)}`);
|
|
12931
|
+
}
|
|
12932
|
+
const reachable = await probeFlairReachable(eff.url);
|
|
12459
12933
|
if (!reachable) {
|
|
12460
|
-
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim,
|
|
12934
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} not reachable — cannot verify agent registration`);
|
|
12461
12935
|
continue;
|
|
12462
12936
|
}
|
|
12463
|
-
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim,
|
|
12464
|
-
const reg = await checkAgentRegistered(
|
|
12937
|
+
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} reachable`);
|
|
12938
|
+
const reg = await checkAgentRegistered(eff.url, block.agentId, defaultKeysDir());
|
|
12465
12939
|
if (reg.state === "registered") {
|
|
12466
12940
|
console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
|
|
12467
12941
|
}
|
|
@@ -12616,6 +13090,73 @@ program
|
|
|
12616
13090
|
}
|
|
12617
13091
|
issues++;
|
|
12618
13092
|
}
|
|
13093
|
+
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
13094
|
+
// the SessionStart check above: installed / absent / stale-form).
|
|
13095
|
+
// Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
|
|
13096
|
+
// opt-in — so "absent" renders as informational "not enabled": NEVER
|
|
13097
|
+
// a pass (an unrun check must not look green) and never counted as an
|
|
13098
|
+
// issue. A partial/stale install IS an issue and is --fix-able;
|
|
13099
|
+
// --fix also offers first-time enablement (the y/N prompt is the
|
|
13100
|
+
// consent; non-TTY --fix is itself the consent signal, matching every
|
|
13101
|
+
// other doctor fix).
|
|
13102
|
+
const continuity = checkContinuityCaptureHooks(homedir());
|
|
13103
|
+
if (continuity.state === "installed") {
|
|
13104
|
+
console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
|
|
13105
|
+
}
|
|
13106
|
+
else if (continuity.state === "absent") {
|
|
13107
|
+
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)")}`);
|
|
13108
|
+
if (autoFix) {
|
|
13109
|
+
if (dryRun) {
|
|
13110
|
+
console.log(` ${render.wrap(render.c.dim, "Would wire the continuity capture hooks (PostToolUse + Stop) in")} ${continuity.path}`);
|
|
13111
|
+
}
|
|
13112
|
+
else {
|
|
13113
|
+
const proceed = await confirmFix(` Enable continuity capture (PostToolUse + Stop hooks in ${continuity.path})? [y/N] `);
|
|
13114
|
+
if (!proceed) {
|
|
13115
|
+
console.log(` Skipped.`);
|
|
13116
|
+
}
|
|
13117
|
+
else {
|
|
13118
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
13119
|
+
const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId);
|
|
13120
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13121
|
+
if (fixRes.ok && fixRes.changed)
|
|
13122
|
+
fixed++;
|
|
13123
|
+
}
|
|
13124
|
+
}
|
|
13125
|
+
}
|
|
13126
|
+
}
|
|
13127
|
+
else {
|
|
13128
|
+
const continuityDetail = continuity.state === "partial"
|
|
13129
|
+
? (!continuity.postToolUse.present ? "the PostToolUse entry is missing" : "the Stop entry is missing")
|
|
13130
|
+
: "an entry is not the current form (unsilenced, hand-altered, or a drifted PostToolUse matcher)";
|
|
13131
|
+
console.log(` ${render.icons.warn} Continuity capture hooks: ${continuity.state} — ${continuityDetail}`);
|
|
13132
|
+
if (autoFix) {
|
|
13133
|
+
if (dryRun) {
|
|
13134
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the continuity capture hooks in")} ${continuity.path}`);
|
|
13135
|
+
}
|
|
13136
|
+
else {
|
|
13137
|
+
const proceed = await confirmFix(` Rewrite the continuity capture hooks in ${continuity.path} to the current form? [y/N] `);
|
|
13138
|
+
if (!proceed) {
|
|
13139
|
+
console.log(` Skipped.`);
|
|
13140
|
+
}
|
|
13141
|
+
else {
|
|
13142
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
13143
|
+
// Preserve the FLAIR_URL an existing entry already carries —
|
|
13144
|
+
// a repair must never silently re-point the hooks at a
|
|
13145
|
+
// different instance.
|
|
13146
|
+
const existingCommand = continuity.postToolUse.command || continuity.stop.command || "";
|
|
13147
|
+
const existingUrl = existingCommand.match(/FLAIR_URL=(\S+)/)?.[1];
|
|
13148
|
+
const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId, existingUrl);
|
|
13149
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13150
|
+
if (fixRes.ok && fixRes.changed)
|
|
13151
|
+
fixed++;
|
|
13152
|
+
}
|
|
13153
|
+
}
|
|
13154
|
+
}
|
|
13155
|
+
else {
|
|
13156
|
+
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)")}`);
|
|
13157
|
+
}
|
|
13158
|
+
issues++;
|
|
13159
|
+
}
|
|
12619
13160
|
}
|
|
12620
13161
|
}
|
|
12621
13162
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
@@ -12861,6 +13402,80 @@ program
|
|
|
12861
13402
|
}
|
|
12862
13403
|
}
|
|
12863
13404
|
}
|
|
13405
|
+
// 10. Scheduled drivers (flair#1278) — launchd/systemd liveness for the
|
|
13406
|
+
// background schedulers (federation sync, REM nightly), read from the
|
|
13407
|
+
// LOCAL service manager (no Harper dependency, so no harperResponding
|
|
13408
|
+
// gate). Neither #1231 fleet incident (launchd spawn error 209 from a
|
|
13409
|
+
// missing log dir, exit 126 from a stripped exec bit) was visible in
|
|
13410
|
+
// doctor: driver health only surfaced in `flair federation sync status`
|
|
13411
|
+
// / `flair rem nightly status` — commands an operator has to think to
|
|
13412
|
+
// run, while doctor is the tool they actually run when something feels
|
|
13413
|
+
// off. Reuses each scheduler's own status read (installed + genuinely
|
|
13414
|
+
// loaded, flair#850) plus the #1282 last-exit plumbing
|
|
13415
|
+
// (queryLastExitStatus); the verdict is describeScheduledDriverFinding
|
|
13416
|
+
// (src/lib/scheduler-platform.ts) — pure decision logic, unit-tested
|
|
13417
|
+
// without spawning launchctl/systemctl. Not-enabled renders as
|
|
13418
|
+
// informational: an unenabled scheduler is a choice — never the pass
|
|
13419
|
+
// marker, never the fail marker, never an issue.
|
|
13420
|
+
console.log(`\n ${render.wrap(render.c.bold, "Scheduled drivers")}`);
|
|
13421
|
+
try {
|
|
13422
|
+
const { queryLastExitStatus, describeScheduledDriverFinding } = await import("./lib/scheduler-platform.js");
|
|
13423
|
+
const fedSched = await import("./federation/scheduler.js");
|
|
13424
|
+
const remSched = await import("./rem/scheduler.js");
|
|
13425
|
+
const guiDomain = `gui/${process.getuid?.() ?? ""}`;
|
|
13426
|
+
const drivers = [
|
|
13427
|
+
{
|
|
13428
|
+
status: fedSched.schedulerStatus(),
|
|
13429
|
+
label: "Federation sync driver",
|
|
13430
|
+
enableCommand: "flair federation sync enable",
|
|
13431
|
+
statusCommand: "flair federation sync status",
|
|
13432
|
+
darwinTarget: `${guiDomain}/${fedSched.LAUNCHD_LABEL}`,
|
|
13433
|
+
linuxServiceUnit: fedSched.SYSTEMD_SERVICE_UNIT,
|
|
13434
|
+
stderrLogPath: join(homedir(), ".flair", "logs", "federation-sync.stderr.log"),
|
|
13435
|
+
},
|
|
13436
|
+
{
|
|
13437
|
+
status: remSched.schedulerStatus(),
|
|
13438
|
+
label: "REM nightly driver",
|
|
13439
|
+
enableCommand: "flair rem nightly enable",
|
|
13440
|
+
statusCommand: "flair rem nightly status",
|
|
13441
|
+
darwinTarget: `${guiDomain}/${remSched.LAUNCHD_LABEL}`,
|
|
13442
|
+
linuxServiceUnit: remSched.SYSTEMD_SERVICE_UNIT,
|
|
13443
|
+
stderrLogPath: join(homedir(), ".flair", "logs", "rem-nightly.stderr.log"),
|
|
13444
|
+
},
|
|
13445
|
+
];
|
|
13446
|
+
for (const d of drivers) {
|
|
13447
|
+
// Read the last run only when the service manager actually has the
|
|
13448
|
+
// job — "not installed" and "not loaded" carry their own findings,
|
|
13449
|
+
// and layering a last-exit read on top would blur which actor failed.
|
|
13450
|
+
const lastExit = d.status.installed && d.status.active === true
|
|
13451
|
+
? queryLastExitStatus({ plat: d.status.platform, darwinTarget: d.darwinTarget, linuxServiceUnit: d.linuxServiceUnit })
|
|
13452
|
+
: null;
|
|
13453
|
+
const finding = describeScheduledDriverFinding({
|
|
13454
|
+
label: d.label,
|
|
13455
|
+
enableCommand: d.enableCommand,
|
|
13456
|
+
statusCommand: d.statusCommand,
|
|
13457
|
+
installed: d.status.installed,
|
|
13458
|
+
active: d.status.active,
|
|
13459
|
+
lastExit,
|
|
13460
|
+
stderrLogPath: d.stderrLogPath,
|
|
13461
|
+
});
|
|
13462
|
+
console.log(` ${render.icons[finding.icon]} ${finding.message}`);
|
|
13463
|
+
finding.detail.forEach((line, i) => {
|
|
13464
|
+
// Embed-verify degraded style: the actor+state line loud (red),
|
|
13465
|
+
// the remedy dim.
|
|
13466
|
+
const color = finding.state === "degraded" && i === 0 ? render.c.red : render.c.dim;
|
|
13467
|
+
console.log(` ${render.wrap(color, line)}`);
|
|
13468
|
+
});
|
|
13469
|
+
if (finding.isIssue)
|
|
13470
|
+
issues++;
|
|
13471
|
+
}
|
|
13472
|
+
}
|
|
13473
|
+
catch (err) {
|
|
13474
|
+
// An unsupported platform (neither darwin nor linux) or a broken unit
|
|
13475
|
+
// read must not take down doctor — report the section as unchecked
|
|
13476
|
+
// (UNVERIFIED, not a pass), same as the other probes' skip discipline.
|
|
13477
|
+
console.log(` ${render.icons.warn} Scheduled drivers: could not check ${render.wrap(render.c.dim, `(${err?.message ?? err})`)}`);
|
|
13478
|
+
}
|
|
12864
13479
|
// Summary — see summarizeDoctorRun above (flair#721): distinguishes
|
|
12865
13480
|
// issues --fix actually resolved this run from ones still outstanding.
|
|
12866
13481
|
console.log("");
|
|
@@ -13934,6 +14549,29 @@ sessionSnapshot
|
|
|
13934
14549
|
console.error(` extracted to: ${targetDir}`);
|
|
13935
14550
|
});
|
|
13936
14551
|
// ─── Memory and Soul commands ────────────────────────────────────────────────
|
|
14552
|
+
// ─── --entities <csv> (flair#1288) ──────────────────────────────────────────
|
|
14553
|
+
//
|
|
14554
|
+
// Shared parse+validate for the `--entities <csv>` option on `memory add`,
|
|
14555
|
+
// `workspace set`, and `orgevent`. Comma-delimited to match the existing CLI
|
|
14556
|
+
// list-option convention (`--tags <csv>`, `--derived-from <csv>`); safe
|
|
14557
|
+
// because no entity grammar admits a comma. Invalid input exits 1 with the
|
|
14558
|
+
// canonical message — it names the offending values, the `type:value`
|
|
14559
|
+
// format, and the closed type set (errors must enable a response; same hint
|
|
14560
|
+
// the attention path's server-side invalid_entity 400 carries). The server
|
|
14561
|
+
// still re-validates on every write path (resources/entity-vocab.ts via
|
|
14562
|
+
// Memory/WorkspaceState/OrgEvent) — this client-side gate exists so a typo
|
|
14563
|
+
// is caught before any signing/network work, with a message a raw 400 body
|
|
14564
|
+
// never matched.
|
|
14565
|
+
function parseEntitiesOptionOrExit(csv) {
|
|
14566
|
+
const { entities, invalid } = parseEntitiesCsv(csv);
|
|
14567
|
+
if (invalid.length > 0) {
|
|
14568
|
+
console.error(`error: invalid --entities value${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`);
|
|
14569
|
+
console.error(` ${entityFormatHint()}`);
|
|
14570
|
+
process.exit(1);
|
|
14571
|
+
}
|
|
14572
|
+
return entities;
|
|
14573
|
+
}
|
|
14574
|
+
const ENTITIES_OPTION_DESCRIPTION = "Comma-separated entity vocabulary strings this record touches (type:value from the closed type set, e.g. repo:tpsdev-ai/flair — see docs/entity-vocabulary.md; feeds `flair attention`)";
|
|
13937
14575
|
const memory = program.command("memory").description("Manage agent memories");
|
|
13938
14576
|
memory.command("add [content]")
|
|
13939
14577
|
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
@@ -13944,6 +14582,7 @@ memory.command("add [content]")
|
|
|
13944
14582
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
13945
14583
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
13946
14584
|
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to any other agent) or 'shared' (visible to owner + every other agent on this instance — open within the org, not gated by a MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509)")
|
|
14585
|
+
.option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
|
|
13947
14586
|
.action(async (contentArg, opts) => {
|
|
13948
14587
|
const content = contentArg ?? opts.content;
|
|
13949
14588
|
if (!content) {
|
|
@@ -13979,6 +14618,13 @@ memory.command("add [content]")
|
|
|
13979
14618
|
if (opts.derivedFrom) {
|
|
13980
14619
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
13981
14620
|
}
|
|
14621
|
+
// flair#1288: validated client-side; exits 1 with the canonical
|
|
14622
|
+
// format-and-type-set message on any malformed value.
|
|
14623
|
+
if (opts.entities) {
|
|
14624
|
+
const entities = parseEntitiesOptionOrExit(String(opts.entities));
|
|
14625
|
+
if (entities.length > 0)
|
|
14626
|
+
body.entities = entities;
|
|
14627
|
+
}
|
|
13982
14628
|
const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
|
|
13983
14629
|
console.log(JSON.stringify(out, null, 2));
|
|
13984
14630
|
});
|
|
@@ -16238,6 +16884,7 @@ workspace
|
|
|
16238
16884
|
.option("--task <id>", "Task/issue id this workspace is attached to")
|
|
16239
16885
|
.option("--phase <phase>", "Current phase (e.g. design, implement, review)")
|
|
16240
16886
|
.option("--summary <text>", "Short summary of current workspace state")
|
|
16887
|
+
.option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
|
|
16241
16888
|
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
16242
16889
|
.option("--port <port>", "Harper HTTP port")
|
|
16243
16890
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
@@ -16254,6 +16901,9 @@ workspace
|
|
|
16254
16901
|
process.exit(1);
|
|
16255
16902
|
}
|
|
16256
16903
|
}
|
|
16904
|
+
// flair#1288: validate --entities before any key/network work; exits 1
|
|
16905
|
+
// with the canonical format-and-type-set message on any malformed value.
|
|
16906
|
+
const entities = opts.entities ? parseEntitiesOptionOrExit(String(opts.entities)) : undefined;
|
|
16257
16907
|
const keyPath = resolveKeyPath(agentId);
|
|
16258
16908
|
if (!keyPath) {
|
|
16259
16909
|
console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
|
|
@@ -16286,6 +16936,8 @@ workspace
|
|
|
16286
16936
|
body.phase = opts.phase;
|
|
16287
16937
|
if (opts.summary)
|
|
16288
16938
|
body.summary = opts.summary;
|
|
16939
|
+
if (entities && entities.length > 0)
|
|
16940
|
+
body.entities = entities;
|
|
16289
16941
|
const res = await fetch(`${baseUrl}/WorkspaceState/${id}`, {
|
|
16290
16942
|
method: "PUT",
|
|
16291
16943
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -16360,6 +17012,8 @@ async function publishOrgEvent(params) {
|
|
|
16360
17012
|
body.scope = params.scope;
|
|
16361
17013
|
if (params.targetIds && params.targetIds.length > 0)
|
|
16362
17014
|
body.targetIds = params.targetIds;
|
|
17015
|
+
if (params.entities && params.entities.length > 0)
|
|
17016
|
+
body.entities = params.entities;
|
|
16363
17017
|
const res = await fetch(`${params.baseUrl}/OrgEvent/${id}`, {
|
|
16364
17018
|
method: "PUT",
|
|
16365
17019
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
@@ -16380,6 +17034,7 @@ program
|
|
|
16380
17034
|
.option("--detail <text>", "Longer detail payload")
|
|
16381
17035
|
.option("--scope <scope>", "Scope of the event (e.g. an agent id, repo, or 'org')")
|
|
16382
17036
|
.option("--target <agentId>", "Recipient agent id (repeatable)", (val, acc) => { acc.push(val); return acc; }, [])
|
|
17037
|
+
.option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
|
|
16383
17038
|
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
16384
17039
|
.option("--port <port>", "Harper HTTP port")
|
|
16385
17040
|
.option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
@@ -16393,6 +17048,9 @@ program
|
|
|
16393
17048
|
// --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
|
|
16394
17049
|
const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
|
|
16395
17050
|
const targetIds = Array.isArray(opts.target) && opts.target.length > 0 ? opts.target : undefined;
|
|
17051
|
+
// flair#1288: validate --entities before any key/network work; exits 1
|
|
17052
|
+
// with the canonical format-and-type-set message on any malformed value.
|
|
17053
|
+
const entities = opts.entities ? parseEntitiesOptionOrExit(String(opts.entities)) : undefined;
|
|
16396
17054
|
const result = await publishOrgEvent({
|
|
16397
17055
|
agentId,
|
|
16398
17056
|
baseUrl,
|
|
@@ -16401,6 +17059,7 @@ program
|
|
|
16401
17059
|
detail: opts.detail,
|
|
16402
17060
|
scope: opts.scope,
|
|
16403
17061
|
targetIds,
|
|
17062
|
+
entities,
|
|
16404
17063
|
});
|
|
16405
17064
|
if (!result.ok) {
|
|
16406
17065
|
console.error(`Error: ${result.error}`);
|
|
@@ -16521,6 +17180,26 @@ program
|
|
|
16521
17180
|
// its Node-version check passes. The shim imports this module, so import.meta.main
|
|
16522
17181
|
// is false there — without this explicit entry point the CLI would load but never run.
|
|
16523
17182
|
async function runCli() {
|
|
17183
|
+
// flair#1134 — npm ≥12 blocks install scripts by default, so the
|
|
17184
|
+
// postinstall PATH warning cannot fire there; the first thing of ours
|
|
17185
|
+
// that executes is this CLI, reached via npx / absolute path / a PATH
|
|
17186
|
+
// fixed-for-one-shell. Spawn-free (prefix derived from this file's own
|
|
17187
|
+
// location), validated (the derived bin dir must really hold flair),
|
|
17188
|
+
// TTY-gated (no per-run noise for automation), and skipped for `doctor`,
|
|
17189
|
+
// which prints the full finding itself. Must never break the CLI.
|
|
17190
|
+
if (process.argv[2] !== "doctor") {
|
|
17191
|
+
try {
|
|
17192
|
+
const banner = cliBootPathWarning({
|
|
17193
|
+
packageDir: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
|
17194
|
+
pathEnv: process.env.PATH,
|
|
17195
|
+
shell: process.env.SHELL,
|
|
17196
|
+
stderrIsTTY: process.stderr.isTTY === true,
|
|
17197
|
+
});
|
|
17198
|
+
if (banner)
|
|
17199
|
+
console.error(banner);
|
|
17200
|
+
}
|
|
17201
|
+
catch { /* a diagnostic must never take down the CLI */ }
|
|
17202
|
+
}
|
|
16524
17203
|
// A bare `flair` (no command) is a help request, not a usage error — show
|
|
16525
17204
|
// help and exit 0, rather than commander's default (help + exit 1). Flags
|
|
16526
17205
|
// like -h/--help/-v have argv beyond the binary and fall through to
|