@tpsdev-ai/flair 0.26.0 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli.js +434 -50
- package/dist/doctor-client.js +47 -0
- package/dist/resources/migrations/embedding-stamp.js +83 -2
- package/dist/resources/migrations/runner.js +44 -0
- package/docs/assets/flair-cross-orchestrator.cast +33 -0
- package/docs/assets/flair-cross-orchestrator.gif +0 -0
- package/docs/assets/flair-demo.cast +18 -0
- package/docs/assets/flair-demo.gif +0 -0
- package/docs/auth.md +178 -0
- package/docs/bridges.md +291 -0
- package/docs/claude-code.md +202 -0
- package/docs/deployment.md +204 -0
- package/docs/entity-vocabulary.md +109 -0
- package/docs/federation.md +179 -0
- package/docs/integrations.md +197 -0
- package/docs/mcp-clients.md +240 -0
- package/docs/n8n-management.md +142 -0
- package/docs/n8n.md +122 -0
- package/docs/notes/adk-spike-findings-2026-05-05.md +331 -0
- package/docs/notes/mcp-agent-auth-consumer.md +229 -0
- package/docs/notes/mcp-oauth-model2.md +132 -0
- package/docs/notes/rem-ux.md +39 -0
- package/docs/openclaw.md +131 -0
- package/docs/quickstart.md +153 -0
- package/docs/releasing.md +173 -0
- package/docs/rem.md +60 -0
- package/docs/rerank-provisioning.md +67 -0
- package/docs/secrets-and-keys.md +173 -0
- package/docs/spoke-bringup.md +303 -0
- package/docs/supply-chain-policy.md +156 -0
- package/docs/system-requirements.md +42 -0
- package/docs/the-team.md +129 -0
- package/docs/troubleshooting.md +187 -0
- package/docs/upgrade.md +416 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -242,6 +242,8 @@ flair init --no-mcp # instance + agent only, skip MCP wiring
|
|
|
242
242
|
flair init --skip-smoke # skip the MCP smoke test
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
+
> Running in a non-interactive shell (CI, Docker, an unattended setup script)? Bare `flair init` with no `--agent` bootstraps the instance only and silently skips agent registration, MCP client wiring, and the smoke test. Pass the flags explicitly: `flair init --agent <id> --client all`.
|
|
246
|
+
|
|
245
247
|
Lifecycle management:
|
|
246
248
|
|
|
247
249
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
|
20
20
|
import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
21
21
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
22
22
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
23
|
-
import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
23
|
+
import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
24
24
|
import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
25
25
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
26
26
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
@@ -2725,6 +2725,17 @@ program
|
|
|
2725
2725
|
console.log(` └─────────────────────────────────────────────────┘`);
|
|
2726
2726
|
}
|
|
2727
2727
|
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
2728
|
+
// flair#802a: a non-interactive shell (CI, Docker, an unattended setup
|
|
2729
|
+
// script) that omits --agent lands here with NO indication that agent
|
|
2730
|
+
// registration, MCP client wiring, and the smoke test were all skipped
|
|
2731
|
+
// — the run exits 0 and looks complete. In a real TTY the missing
|
|
2732
|
+
// --agent is usually obvious from the command the user just typed;
|
|
2733
|
+
// non-interactively it's easy to never notice until something that
|
|
2734
|
+
// needed the agent (recall, an MCP client) mysteriously doesn't work.
|
|
2735
|
+
if (!process.stdin.isTTY) {
|
|
2736
|
+
console.log(`\n ℹ Non-interactive shell: skipped agent registration, MCP client wiring, and the smoke test.`);
|
|
2737
|
+
console.log(` Complete setup with: flair init --agent <id> --client all`);
|
|
2738
|
+
}
|
|
2728
2739
|
}
|
|
2729
2740
|
// All init work is genuinely done at this point: Harper is installed +
|
|
2730
2741
|
// running (detached, unref'd — survives this process exiting), the agent is
|
|
@@ -8656,11 +8667,22 @@ async function stopFlairProcess(port) {
|
|
|
8656
8667
|
console.log("Stopping...");
|
|
8657
8668
|
try {
|
|
8658
8669
|
const { execSync } = await import("node:child_process");
|
|
8659
|
-
|
|
8670
|
+
// -sTCP:LISTEN: match the LISTENING server only — a bare `lsof -ti :port`
|
|
8671
|
+
// also matches CLIENT sockets referencing the port, including THIS CLI's
|
|
8672
|
+
// own keep-alive connections left by the credential pre-flight's
|
|
8673
|
+
// probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
|
|
8674
|
+
// path SIGTERM'd its own process mid-restart — "Stopping..." then death
|
|
8675
|
+
// (exit 143) before "Starting..." ever ran, leaving the server down
|
|
8676
|
+
// (flair#800, deterministic on the Linux/non-launchd default path).
|
|
8677
|
+
const lsof = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: "utf-8" }).trim();
|
|
8660
8678
|
if (lsof) {
|
|
8661
8679
|
for (const pid of lsof.split("\n")) {
|
|
8680
|
+
const target = Number(pid.trim());
|
|
8681
|
+
// Belt-and-suspenders: never SIGTERM ourselves, whatever lsof says.
|
|
8682
|
+
if (!Number.isFinite(target) || target === process.pid)
|
|
8683
|
+
continue;
|
|
8662
8684
|
try {
|
|
8663
|
-
process.kill(
|
|
8685
|
+
process.kill(target, "SIGTERM");
|
|
8664
8686
|
}
|
|
8665
8687
|
catch { }
|
|
8666
8688
|
}
|
|
@@ -9821,9 +9843,18 @@ program
|
|
|
9821
9843
|
console.log(` Skipped.`);
|
|
9822
9844
|
}
|
|
9823
9845
|
else {
|
|
9824
|
-
|
|
9846
|
+
// flair#802b: fall back to the sole locally-keyed agent when
|
|
9847
|
+
// nothing else identifies one — the only case doctor can
|
|
9848
|
+
// infer without being told (see inferSoleAgentId's doc
|
|
9849
|
+
// comment in doctor-client.ts for why 0/2+ keys don't guess).
|
|
9850
|
+
const fixAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId || inferSoleAgentId(keyAgentIds);
|
|
9825
9851
|
if (!fixAgentId) {
|
|
9826
|
-
|
|
9852
|
+
if (keyAgentIds.length > 1) {
|
|
9853
|
+
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: multiple agents found (${[...keyAgentIds].sort().join(", ")}) — pass --agent <id> to choose which one`);
|
|
9854
|
+
}
|
|
9855
|
+
else {
|
|
9856
|
+
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent registered — run \`flair agent add <id>\` first, then re-run \`flair doctor --fix\``);
|
|
9857
|
+
}
|
|
9827
9858
|
}
|
|
9828
9859
|
else {
|
|
9829
9860
|
const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: resolveWireFlairUrl(block.flairUrl, baseUrl) };
|
|
@@ -9839,7 +9870,13 @@ program
|
|
|
9839
9870
|
}
|
|
9840
9871
|
}
|
|
9841
9872
|
else {
|
|
9842
|
-
|
|
9873
|
+
// flair#802b: only splice in a concrete --agent if the id isn't
|
|
9874
|
+
// already resolvable some other way — an explicit --agent /
|
|
9875
|
+
// FLAIR_AGENT_ID / an already-wired client's agent id means bare
|
|
9876
|
+
// `--fix` already works, so don't clutter the suggestion.
|
|
9877
|
+
const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
|
|
9878
|
+
const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
|
|
9879
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
|
|
9843
9880
|
}
|
|
9844
9881
|
issues++;
|
|
9845
9882
|
continue;
|
|
@@ -10569,6 +10606,258 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
10569
10606
|
}
|
|
10570
10607
|
return { ok: true, agentId, sampledIds, perQueryResultIds, k };
|
|
10571
10608
|
}
|
|
10609
|
+
// ─── flair quality --emit (Slice 2 of the memory-quality-observability arc:
|
|
10610
|
+
// quality OrgEvents) ─────────────────────────────────────────────────────────
|
|
10611
|
+
//
|
|
10612
|
+
// Design (K&S-approved in the arc round, honored exactly here):
|
|
10613
|
+
//
|
|
10614
|
+
// - NO schema change, NO new table/resource. Events ride the existing
|
|
10615
|
+
// OrgEvent surface (schemas/event.graphql: kind/scope/summary/detail/
|
|
10616
|
+
// targetIds/refId — see `flair orgevent` above) via the exact same
|
|
10617
|
+
// PUT /OrgEvent/{id} write shape, extracted into `publishOrgEvent()` below
|
|
10618
|
+
// so both commands share one call site rather than two hand-rolled fetches.
|
|
10619
|
+
// - The threshold/diff DECISION is CLI-side (diffQualitySnapshots, pure,
|
|
10620
|
+
// fixture-tested below); emission is a thin write via that existing
|
|
10621
|
+
// surface. Kind is one of two: `quality.threshold_crossed` (an absolute
|
|
10622
|
+
// line was crossed since the last snapshot) or `quality.regression` (a
|
|
10623
|
+
// metric moved backward by more than its delta threshold since the last
|
|
10624
|
+
// snapshot) — see QualityEventFinding.
|
|
10625
|
+
// - Snapshots are stored AS Flair memories (durability persistent, subject
|
|
10626
|
+
// `quality-snapshot/<host>` — qualitySnapshotSubject() below) — free
|
|
10627
|
+
// history + search, dogfoods the product, no new storage surface. Content
|
|
10628
|
+
// is the COMPACT numeric core only (QualitySnapshotCore), never the full
|
|
10629
|
+
// human report — see buildQualitySnapshot().
|
|
10630
|
+
// - Sherlock's constraint: events carry BEHAVIORAL FACTS only, never trust
|
|
10631
|
+
// judgments — "embedding coverage dropped to 85% (threshold 90%)", never
|
|
10632
|
+
// "agent X is low quality". Every summary string below is written to that
|
|
10633
|
+
// discipline; keep it that way in any future edit here.
|
|
10634
|
+
// - Edge-triggered, not level-triggered: every threshold/regression check
|
|
10635
|
+
// below fires only on the TRANSITION since the immediately-previous
|
|
10636
|
+
// snapshot (e.g. quietAgents requires "was NOT quiet last snapshot, IS
|
|
10637
|
+
// quiet now" — the spec's own "NEWLY quiet" wording), not on every run
|
|
10638
|
+
// while a condition merely persists. Without this, a metric that stays
|
|
10639
|
+
// below threshold across many `--emit` runs would re-emit an event every
|
|
10640
|
+
// single run — pure noise. First run (no previous snapshot) therefore
|
|
10641
|
+
// always emits nothing (diffQualitySnapshots(current, null) === []) — there
|
|
10642
|
+
// is no prior state to diff against, so nothing can have "crossed" or
|
|
10643
|
+
// "regressed" yet; that run only establishes the baseline.
|
|
10644
|
+
// - Missing data (a null/gap section on either side of the diff) never
|
|
10645
|
+
// produces an event — absence of data is a gap, not a regression. Encoded
|
|
10646
|
+
// by requiring BOTH current and previous to carry a given metric before
|
|
10647
|
+
// diffing it at all.
|
|
10648
|
+
/** First-pass defaults for the Slice 2 diff/thresholds — same "documented
|
|
10649
|
+
* heuristic, tunable later against a real fleet" spirit as
|
|
10650
|
+
* QUALITY_QUIET_THRESHOLD_DAYS / QUALITY_HASH_FALLBACK_DEGRADED_PCT above.
|
|
10651
|
+
* Deliberately NOT exposed as CLI flags (per the arc design: the diff
|
|
10652
|
+
* decision stays CLI-side and legible in one place, not ad-hoc per
|
|
10653
|
+
* invocation) — change these constants and their fixture tests together. */
|
|
10654
|
+
export const QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT = 90;
|
|
10655
|
+
export const QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT = 5;
|
|
10656
|
+
export const QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT = 10;
|
|
10657
|
+
export const QUALITY_EVENT_RECALL_DROP_THRESHOLD = 0.2;
|
|
10658
|
+
export const QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD = 0.5; // >50%
|
|
10659
|
+
export const QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD = 5; // AND by >= 5 clusters
|
|
10660
|
+
/** Pure: full QualityReport → compact snapshot core. Any section that's
|
|
10661
|
+
* `null` in the report (a gap) stays `null` in the snapshot — the diff step
|
|
10662
|
+
* treats that as "no event", never a false 0. */
|
|
10663
|
+
export function buildQualitySnapshot(report, computedAt) {
|
|
10664
|
+
return {
|
|
10665
|
+
schemaVersion: 1,
|
|
10666
|
+
computedAt: computedAt ?? new Date().toISOString(),
|
|
10667
|
+
agentFilter: report.agentFilter,
|
|
10668
|
+
embeddingCoverage: report.embeddingCoverage ? { coveragePct: report.embeddingCoverage.coveragePct } : null,
|
|
10669
|
+
staleness: report.staleness ? { stalePct: report.staleness.stalePct } : null,
|
|
10670
|
+
recallSpotCheck: report.recallSpotCheck
|
|
10671
|
+
? { recallAtK: report.recallSpotCheck.recallAtK, mrr: report.recallSpotCheck.mrr }
|
|
10672
|
+
: null,
|
|
10673
|
+
quietAgents: report.quietAgents
|
|
10674
|
+
? { perAgent: report.quietAgents.perAgent.map((a) => ({ id: a.id, quiet: a.quiet, daysSinceLastWrite: a.daysSinceLastWrite })) }
|
|
10675
|
+
: null,
|
|
10676
|
+
dedupClusters: report.dedupClusters ? { clusterCount: report.dedupClusters.clusterCount } : null,
|
|
10677
|
+
};
|
|
10678
|
+
}
|
|
10679
|
+
/**
|
|
10680
|
+
* Pure diff: current snapshot + previous snapshot (or null on a first run)
|
|
10681
|
+
* → the list of OrgEvent findings to emit. Never throws. See the module doc
|
|
10682
|
+
* above for the edge-triggered / missing-data-means-no-event rules; fixture
|
|
10683
|
+
* tests live in test/unit/quality-report.test.ts.
|
|
10684
|
+
*/
|
|
10685
|
+
export function diffQualitySnapshots(current, previous) {
|
|
10686
|
+
const findings = [];
|
|
10687
|
+
if (!previous)
|
|
10688
|
+
return findings; // first run — nothing to diff against yet
|
|
10689
|
+
// ── embedding coverage: absolute floor (edge-triggered) + delta drop ──
|
|
10690
|
+
if (current.embeddingCoverage && previous.embeddingCoverage) {
|
|
10691
|
+
const before = previous.embeddingCoverage.coveragePct;
|
|
10692
|
+
const after = current.embeddingCoverage.coveragePct;
|
|
10693
|
+
if (after < QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT && before >= QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT) {
|
|
10694
|
+
findings.push({
|
|
10695
|
+
kind: "quality.threshold_crossed",
|
|
10696
|
+
scope: "quality",
|
|
10697
|
+
summary: `embedding coverage dropped to ${after}% (threshold ${QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT}%)`,
|
|
10698
|
+
detail: { metric: "embeddingCoverage.coveragePct", before, after, threshold: QUALITY_EVENT_COVERAGE_ABS_THRESHOLD_PCT },
|
|
10699
|
+
});
|
|
10700
|
+
}
|
|
10701
|
+
if (before - after > QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT) {
|
|
10702
|
+
findings.push({
|
|
10703
|
+
kind: "quality.regression",
|
|
10704
|
+
scope: "quality",
|
|
10705
|
+
summary: `embedding coverage dropped ${before - after} points since last snapshot (${before}% → ${after}%)`,
|
|
10706
|
+
detail: { metric: "embeddingCoverage.coveragePct", before, after, threshold: QUALITY_EVENT_COVERAGE_DROP_THRESHOLD_PCT },
|
|
10707
|
+
});
|
|
10708
|
+
}
|
|
10709
|
+
}
|
|
10710
|
+
// ── staleness: absolute ceiling only (edge-triggered) ──
|
|
10711
|
+
if (current.staleness && previous.staleness) {
|
|
10712
|
+
const before = previous.staleness.stalePct;
|
|
10713
|
+
const after = current.staleness.stalePct;
|
|
10714
|
+
if (after > QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT && before <= QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT) {
|
|
10715
|
+
findings.push({
|
|
10716
|
+
kind: "quality.threshold_crossed",
|
|
10717
|
+
scope: "quality",
|
|
10718
|
+
summary: `staleness rose to ${after}% past validTo (threshold ${QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT}%)`,
|
|
10719
|
+
detail: { metric: "staleness.stalePct", before, after, threshold: QUALITY_EVENT_STALENESS_ABS_THRESHOLD_PCT },
|
|
10720
|
+
});
|
|
10721
|
+
}
|
|
10722
|
+
}
|
|
10723
|
+
// ── recall spot-check: recall@k and MRR, same delta-drop threshold ──
|
|
10724
|
+
if (current.recallSpotCheck && previous.recallSpotCheck) {
|
|
10725
|
+
const beforeR = previous.recallSpotCheck.recallAtK;
|
|
10726
|
+
const afterR = current.recallSpotCheck.recallAtK;
|
|
10727
|
+
if (beforeR - afterR > QUALITY_EVENT_RECALL_DROP_THRESHOLD) {
|
|
10728
|
+
findings.push({
|
|
10729
|
+
kind: "quality.regression",
|
|
10730
|
+
scope: "quality",
|
|
10731
|
+
summary: `recall spot-check recall@k dropped from ${beforeR} to ${afterR} since last snapshot`,
|
|
10732
|
+
detail: { metric: "recallSpotCheck.recallAtK", before: beforeR, after: afterR, threshold: QUALITY_EVENT_RECALL_DROP_THRESHOLD },
|
|
10733
|
+
});
|
|
10734
|
+
}
|
|
10735
|
+
const beforeM = previous.recallSpotCheck.mrr;
|
|
10736
|
+
const afterM = current.recallSpotCheck.mrr;
|
|
10737
|
+
if (beforeM - afterM > QUALITY_EVENT_RECALL_DROP_THRESHOLD) {
|
|
10738
|
+
findings.push({
|
|
10739
|
+
kind: "quality.regression",
|
|
10740
|
+
scope: "quality",
|
|
10741
|
+
summary: `recall spot-check MRR dropped from ${beforeM} to ${afterM} since last snapshot`,
|
|
10742
|
+
detail: { metric: "recallSpotCheck.mrr", before: beforeM, after: afterM, threshold: QUALITY_EVENT_RECALL_DROP_THRESHOLD },
|
|
10743
|
+
});
|
|
10744
|
+
}
|
|
10745
|
+
}
|
|
10746
|
+
// ── quiet agents: per-agent, NEWLY quiet only (was false last snapshot,
|
|
10747
|
+
// true now) — never re-fires for an agent that was already quiet last
|
|
10748
|
+
// snapshot, and never fires for an agent absent from the previous snapshot
|
|
10749
|
+
// (a brand-new agent can't have "regressed" from a state we never saw). ──
|
|
10750
|
+
if (current.quietAgents && previous.quietAgents) {
|
|
10751
|
+
const prevQuietById = new Map(previous.quietAgents.perAgent.map((a) => [a.id, a.quiet]));
|
|
10752
|
+
for (const a of current.quietAgents.perAgent) {
|
|
10753
|
+
if (a.quiet && prevQuietById.get(a.id) === false) {
|
|
10754
|
+
const days = a.daysSinceLastWrite;
|
|
10755
|
+
findings.push({
|
|
10756
|
+
kind: "quality.threshold_crossed",
|
|
10757
|
+
scope: "quality",
|
|
10758
|
+
summary: days == null ? `agent ${a.id} quiet — no recorded write` : `agent ${a.id} quiet for ${days}d (threshold ${QUALITY_QUIET_THRESHOLD_DAYS}d)`,
|
|
10759
|
+
detail: { metric: "quietAgents", before: false, after: true, threshold: QUALITY_QUIET_THRESHOLD_DAYS },
|
|
10760
|
+
targetIds: [a.id],
|
|
10761
|
+
});
|
|
10762
|
+
}
|
|
10763
|
+
}
|
|
10764
|
+
}
|
|
10765
|
+
// ── dedup clusters: BOTH >50% relative growth AND >=5 absolute growth ──
|
|
10766
|
+
if (current.dedupClusters && previous.dedupClusters) {
|
|
10767
|
+
const before = previous.dedupClusters.clusterCount;
|
|
10768
|
+
const after = current.dedupClusters.clusterCount;
|
|
10769
|
+
const growth = after - before;
|
|
10770
|
+
const growthPct = before > 0 ? growth / before : (after > 0 ? Infinity : 0);
|
|
10771
|
+
if (growthPct > QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD && growth >= QUALITY_EVENT_DEDUP_GROWTH_ABS_THRESHOLD) {
|
|
10772
|
+
findings.push({
|
|
10773
|
+
kind: "quality.regression",
|
|
10774
|
+
scope: "quality",
|
|
10775
|
+
summary: `dedup cluster count grew from ${before} to ${after} since last snapshot`,
|
|
10776
|
+
detail: { metric: "dedupClusters.clusterCount", before, after, threshold: QUALITY_EVENT_DEDUP_GROWTH_PCT_THRESHOLD },
|
|
10777
|
+
});
|
|
10778
|
+
}
|
|
10779
|
+
}
|
|
10780
|
+
return findings;
|
|
10781
|
+
}
|
|
10782
|
+
/** Subject convention for quality snapshots stored as Flair memories: one
|
|
10783
|
+
* lineage per (agent, Flair instance) pair — an agent that runs
|
|
10784
|
+
* `quality --emit` against more than one target gets independent diff
|
|
10785
|
+
* baselines per target, keyed on HOST (not the full URL, so a bare port
|
|
10786
|
+
* change on the same box doesn't fork the lineage; a different host/instance
|
|
10787
|
+
* correctly starts its own). */
|
|
10788
|
+
export function qualitySnapshotSubject(baseUrl) {
|
|
10789
|
+
let host;
|
|
10790
|
+
try {
|
|
10791
|
+
host = new URL(baseUrl).host;
|
|
10792
|
+
}
|
|
10793
|
+
catch {
|
|
10794
|
+
host = baseUrl.replace(/^[a-zA-Z]+:\/\//, "").replace(/\/.*$/, "");
|
|
10795
|
+
}
|
|
10796
|
+
return `quality-snapshot/${host}`;
|
|
10797
|
+
}
|
|
10798
|
+
/** Fetch the most recent prior quality snapshot for `agentId` at `baseUrl`,
|
|
10799
|
+
* via the exact same read path fetchRecallSpotCheckData uses (`api("GET",
|
|
10800
|
+
* "/Memory?agentId=...")`, self-scoped by the signed request's own agent
|
|
10801
|
+
* identity — no new endpoint). Filters client-side by subject (the server's
|
|
10802
|
+
* `GET /Memory?...` doesn't translate query params into search conditions
|
|
10803
|
+
* beyond the signed agentId scope — see resources/Memory.ts's search()),
|
|
10804
|
+
* same client-side-filter pattern `memory list --hash-fallback` already
|
|
10805
|
+
* uses. Returns null on: no prior snapshot, a fetch error, or a snapshot row
|
|
10806
|
+
* whose content isn't parseable/versioned JSON (never throws — a corrupt or
|
|
10807
|
+
* foreign row degrades to "no snapshot", same as a genuine first run, rather
|
|
10808
|
+
* than crashing `--emit`). */
|
|
10809
|
+
async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject) {
|
|
10810
|
+
let all;
|
|
10811
|
+
try {
|
|
10812
|
+
const q = new URLSearchParams({ agentId }).toString();
|
|
10813
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl });
|
|
10814
|
+
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
10815
|
+
}
|
|
10816
|
+
catch {
|
|
10817
|
+
return null;
|
|
10818
|
+
}
|
|
10819
|
+
const matches = all.filter((m) => m?.subject === subject);
|
|
10820
|
+
if (matches.length === 0)
|
|
10821
|
+
return null;
|
|
10822
|
+
matches.sort((a, b) => {
|
|
10823
|
+
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
10824
|
+
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
10825
|
+
return tb - ta;
|
|
10826
|
+
});
|
|
10827
|
+
try {
|
|
10828
|
+
const parsed = JSON.parse(matches[0].content);
|
|
10829
|
+
if (parsed && typeof parsed === "object" && parsed.schemaVersion === 1)
|
|
10830
|
+
return parsed;
|
|
10831
|
+
return null;
|
|
10832
|
+
}
|
|
10833
|
+
catch {
|
|
10834
|
+
return null;
|
|
10835
|
+
}
|
|
10836
|
+
}
|
|
10837
|
+
/** Store `snapshot` as a new persistent-durability memory (subject
|
|
10838
|
+
* `quality-snapshot/<host>`) — the diff baseline the NEXT `--emit` run reads
|
|
10839
|
+
* back via fetchPreviousQualitySnapshot. Content is the compact JSON core
|
|
10840
|
+
* only (buildQualitySnapshot's output), never the full human report. Same
|
|
10841
|
+
* `PUT /Memory/{id}` write shape `memory write-task-summary` already uses.
|
|
10842
|
+
* Throws on a write failure — the CLI action below is responsible for
|
|
10843
|
+
* surfacing that as a clear error, same as every other write path here. */
|
|
10844
|
+
async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
|
|
10845
|
+
const memId = `${agentId}-quality-snapshot-${Date.now()}`;
|
|
10846
|
+
const body = {
|
|
10847
|
+
id: memId,
|
|
10848
|
+
agentId,
|
|
10849
|
+
content: JSON.stringify(snapshot),
|
|
10850
|
+
durability: "persistent",
|
|
10851
|
+
tags: ["quality-snapshot"],
|
|
10852
|
+
subject,
|
|
10853
|
+
type: "quality-snapshot",
|
|
10854
|
+
createdAt: new Date().toISOString(),
|
|
10855
|
+
};
|
|
10856
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl });
|
|
10857
|
+
if (out?.error)
|
|
10858
|
+
throw new Error(String(out.error));
|
|
10859
|
+
return memId;
|
|
10860
|
+
}
|
|
10572
10861
|
// ─── flair quality ────────────────────────────────────────────────────────────
|
|
10573
10862
|
program
|
|
10574
10863
|
.command("quality")
|
|
@@ -10578,9 +10867,14 @@ program
|
|
|
10578
10867
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
10579
10868
|
.option("--json", "Output as JSON")
|
|
10580
10869
|
.option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
|
|
10870
|
+
.option("--emit", "Slice 2: snapshot this report, diff it against the previous quality-snapshot memory, and emit OrgEvents (quality.threshold_crossed / quality.regression) for any crossings/regressions found. Requires an agent identity (--agent or FLAIR_AGENT_ID) — the opt-in write boundary; without this flag `flair quality` remains fully read-only")
|
|
10581
10871
|
.action(async (opts) => {
|
|
10582
10872
|
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
|
|
10583
10873
|
const agentId = opts.agent || process.env.FLAIR_AGENT_ID || null;
|
|
10874
|
+
if (opts.emit && !agentId) {
|
|
10875
|
+
console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
|
|
10876
|
+
process.exit(1);
|
|
10877
|
+
}
|
|
10584
10878
|
// Recall spot-check needs live queries (not just /HealthDetail), so only
|
|
10585
10879
|
// attempt it when the instance is actually reachable — no point probing
|
|
10586
10880
|
// memory reads against a server fetchHealthDetail already found down.
|
|
@@ -10588,9 +10882,57 @@ program
|
|
|
10588
10882
|
? await fetchRecallSpotCheckData(agentId, baseUrl)
|
|
10589
10883
|
: { ok: false, skipReason: "instance unreachable" };
|
|
10590
10884
|
const report = computeQualityReport(healthy, healthData, { agentId, recallSpotCheckData });
|
|
10885
|
+
// ── Slice 2: --emit is the opt-in write boundary. Everything above this
|
|
10886
|
+
// point is unchanged from pre-Slice-2 behavior; everything in this block
|
|
10887
|
+
// only runs when the flag is passed AND the instance is reachable (an
|
|
10888
|
+
// unreachable instance has nothing to diff against and no live write
|
|
10889
|
+
// target — it falls through to the existing "unreachable" exit(1) below,
|
|
10890
|
+
// same as always). ──
|
|
10891
|
+
let emitResult = null;
|
|
10892
|
+
if (opts.emit && healthy && agentId) {
|
|
10893
|
+
const subject = qualitySnapshotSubject(baseUrl);
|
|
10894
|
+
const previous = await fetchPreviousQualitySnapshot(agentId, baseUrl, subject);
|
|
10895
|
+
const current = buildQualitySnapshot(report);
|
|
10896
|
+
const findings = diffQualitySnapshots(current, previous); // [] on a first run (previous === null)
|
|
10897
|
+
emitResult = { firstRun: previous === null, emittedEvents: [], snapshotId: null, errors: [] };
|
|
10898
|
+
for (const finding of findings) {
|
|
10899
|
+
const published = await publishOrgEvent({
|
|
10900
|
+
agentId,
|
|
10901
|
+
baseUrl,
|
|
10902
|
+
kind: finding.kind,
|
|
10903
|
+
scope: finding.scope,
|
|
10904
|
+
summary: finding.summary,
|
|
10905
|
+
detail: JSON.stringify(finding.detail),
|
|
10906
|
+
targetIds: finding.targetIds,
|
|
10907
|
+
});
|
|
10908
|
+
if (published.ok) {
|
|
10909
|
+
emitResult.emittedEvents.push({ ...finding, orgEventId: published.id });
|
|
10910
|
+
}
|
|
10911
|
+
else {
|
|
10912
|
+
emitResult.errors.push(`${finding.kind} (${finding.detail.metric}): ${published.error}`);
|
|
10913
|
+
}
|
|
10914
|
+
}
|
|
10915
|
+
try {
|
|
10916
|
+
emitResult.snapshotId = await storeQualitySnapshot(agentId, baseUrl, subject, current);
|
|
10917
|
+
}
|
|
10918
|
+
catch (err) {
|
|
10919
|
+
emitResult.errors.push(`snapshot store failed: ${err?.message ?? String(err)}`);
|
|
10920
|
+
}
|
|
10921
|
+
}
|
|
10591
10922
|
const mode = render.resolveOutputMode(opts);
|
|
10592
10923
|
if (mode === "json") {
|
|
10593
10924
|
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...report };
|
|
10925
|
+
if (emitResult) {
|
|
10926
|
+
out.emit = { firstRun: emitResult.firstRun, snapshotId: emitResult.snapshotId, errors: emitResult.errors };
|
|
10927
|
+
out.emittedEvents = emitResult.emittedEvents.map((e) => ({
|
|
10928
|
+
kind: e.kind,
|
|
10929
|
+
scope: e.scope,
|
|
10930
|
+
summary: e.summary,
|
|
10931
|
+
detail: e.detail,
|
|
10932
|
+
targetIds: e.targetIds,
|
|
10933
|
+
orgEventId: e.orgEventId,
|
|
10934
|
+
}));
|
|
10935
|
+
}
|
|
10594
10936
|
console.log(render.asJSON(out));
|
|
10595
10937
|
if (!healthy)
|
|
10596
10938
|
process.exit(1);
|
|
@@ -10710,6 +11052,29 @@ program
|
|
|
10710
11052
|
console.log(` ${render.icons.info} ${g.metric}: ${g.reason}`);
|
|
10711
11053
|
}
|
|
10712
11054
|
}
|
|
11055
|
+
// Events (flair-quality Slice 2 — only present when --emit was passed)
|
|
11056
|
+
if (emitResult) {
|
|
11057
|
+
console.log(`\n${render.wrap(render.c.bold, "Events")} ${render.wrap(render.c.dim, "(--emit: snapshot + diff against the previous quality-snapshot memory)")}`);
|
|
11058
|
+
if (emitResult.firstRun) {
|
|
11059
|
+
console.log(` ${render.icons.info} first run — no prior snapshot to diff against; stored a baseline, emitted nothing`);
|
|
11060
|
+
}
|
|
11061
|
+
else if (emitResult.emittedEvents.length === 0) {
|
|
11062
|
+
console.log(` ${render.icons.ok} no threshold crossings or regressions since the last snapshot`);
|
|
11063
|
+
}
|
|
11064
|
+
else {
|
|
11065
|
+
console.log(` ${render.wrap(render.c.bold, String(emitResult.emittedEvents.length))} event(s) emitted:`);
|
|
11066
|
+
for (const e of emitResult.emittedEvents) {
|
|
11067
|
+
const icon = e.kind === "quality.regression" ? render.icons.warn : render.icons.info;
|
|
11068
|
+
console.log(` ${icon} [${e.kind}] ${e.summary}`);
|
|
11069
|
+
}
|
|
11070
|
+
}
|
|
11071
|
+
if (emitResult.snapshotId) {
|
|
11072
|
+
console.log(` ${render.wrap(render.c.dim, `snapshot stored: ${emitResult.snapshotId}`)}`);
|
|
11073
|
+
}
|
|
11074
|
+
for (const err of emitResult.errors) {
|
|
11075
|
+
console.log(` ${render.icons.error} ${err}`);
|
|
11076
|
+
}
|
|
11077
|
+
}
|
|
10713
11078
|
console.log("");
|
|
10714
11079
|
});
|
|
10715
11080
|
// ─── flair session snapshot ──────────────────────────────────────────────────
|
|
@@ -13157,6 +13522,58 @@ workspace
|
|
|
13157
13522
|
// Memory.write(): `${agentId}-${randomUUID()}`).
|
|
13158
13523
|
const MAX_ORGEVENT_SUMMARY_LENGTH = 500;
|
|
13159
13524
|
const MAX_ORGEVENT_DETAIL_LENGTH = 8000;
|
|
13525
|
+
/**
|
|
13526
|
+
* The ONE write shape behind `flair orgevent` — signed PUT /OrgEvent/{id} —
|
|
13527
|
+
* extracted so `flair quality --emit` (flair-quality Slice 2) reuses the
|
|
13528
|
+
* exact same call, rather than hand-rolling a second one. Everything below
|
|
13529
|
+
* mirrors what the `orgevent` command's action used to do inline: id
|
|
13530
|
+
* convention (`${agentId}-${randomUUID()}`, matching flair-client's
|
|
13531
|
+
* Memory.write()), the same Ed25519 signing (buildEd25519Auth), the same
|
|
13532
|
+
* length guards (MAX_ORGEVENT_SUMMARY_LENGTH/MAX_ORGEVENT_DETAIL_LENGTH) so
|
|
13533
|
+
* every caller — CLI flag or programmatic — gets them, and the same
|
|
13534
|
+
* authorId self-declaration OrgEvent.put() verifies server-side against the
|
|
13535
|
+
* signature (see the module doc above `orgevent`). Never throws — every
|
|
13536
|
+
* failure mode (missing key, oversized field, non-2xx response) returns
|
|
13537
|
+
* `{ ok: false, error }` for the caller to surface however fits its own UX.
|
|
13538
|
+
*/
|
|
13539
|
+
async function publishOrgEvent(params) {
|
|
13540
|
+
if (params.summary.length > MAX_ORGEVENT_SUMMARY_LENGTH) {
|
|
13541
|
+
return { ok: false, error: `summary exceeds ${MAX_ORGEVENT_SUMMARY_LENGTH} character limit (got ${params.summary.length})` };
|
|
13542
|
+
}
|
|
13543
|
+
if (params.detail && params.detail.length > MAX_ORGEVENT_DETAIL_LENGTH) {
|
|
13544
|
+
return { ok: false, error: `detail exceeds ${MAX_ORGEVENT_DETAIL_LENGTH} character limit (got ${params.detail.length})` };
|
|
13545
|
+
}
|
|
13546
|
+
const keyPath = resolveKeyPath(params.agentId);
|
|
13547
|
+
if (!keyPath) {
|
|
13548
|
+
return { ok: false, error: `private key not found for agent '${params.agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.` };
|
|
13549
|
+
}
|
|
13550
|
+
const id = `${params.agentId}-${randomUUID()}`;
|
|
13551
|
+
const auth = buildEd25519Auth(params.agentId, "PUT", `/OrgEvent/${id}`, keyPath);
|
|
13552
|
+
const body = {
|
|
13553
|
+
id,
|
|
13554
|
+
authorId: params.agentId,
|
|
13555
|
+
kind: params.kind,
|
|
13556
|
+
summary: params.summary,
|
|
13557
|
+
createdAt: new Date().toISOString(),
|
|
13558
|
+
};
|
|
13559
|
+
if (params.detail)
|
|
13560
|
+
body.detail = params.detail;
|
|
13561
|
+
if (params.scope)
|
|
13562
|
+
body.scope = params.scope;
|
|
13563
|
+
if (params.targetIds && params.targetIds.length > 0)
|
|
13564
|
+
body.targetIds = params.targetIds;
|
|
13565
|
+
const res = await fetch(`${params.baseUrl}/OrgEvent/${id}`, {
|
|
13566
|
+
method: "PUT",
|
|
13567
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
13568
|
+
body: JSON.stringify(body),
|
|
13569
|
+
});
|
|
13570
|
+
if (!res.ok) {
|
|
13571
|
+
const text = await res.text().catch(() => "");
|
|
13572
|
+
return { ok: false, error: `PUT /OrgEvent/${id} failed (${res.status}): ${text}` };
|
|
13573
|
+
}
|
|
13574
|
+
const data = await res.json().catch(() => null);
|
|
13575
|
+
return { ok: true, id: data?.id ?? id };
|
|
13576
|
+
}
|
|
13160
13577
|
program
|
|
13161
13578
|
.command("orgevent")
|
|
13162
13579
|
.description("Publish an org-wide coordination event attributed to your agent (PUT /OrgEvent/{id})")
|
|
@@ -13174,59 +13591,26 @@ program
|
|
|
13174
13591
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
13175
13592
|
process.exit(1);
|
|
13176
13593
|
}
|
|
13177
|
-
if (opts.summary && String(opts.summary).length > MAX_ORGEVENT_SUMMARY_LENGTH) {
|
|
13178
|
-
console.error(`Error: --summary exceeds ${MAX_ORGEVENT_SUMMARY_LENGTH} character limit (got ${String(opts.summary).length}).`);
|
|
13179
|
-
process.exit(1);
|
|
13180
|
-
}
|
|
13181
|
-
if (opts.detail && String(opts.detail).length > MAX_ORGEVENT_DETAIL_LENGTH) {
|
|
13182
|
-
console.error(`Error: --detail exceeds ${MAX_ORGEVENT_DETAIL_LENGTH} character limit (got ${String(opts.detail).length}).`);
|
|
13183
|
-
process.exit(1);
|
|
13184
|
-
}
|
|
13185
|
-
const keyPath = resolveKeyPath(agentId);
|
|
13186
|
-
if (!keyPath) {
|
|
13187
|
-
console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
|
|
13188
|
-
process.exit(1);
|
|
13189
|
-
}
|
|
13190
13594
|
// orgevent reuses --target for recipients, so the remote-URL override is
|
|
13191
13595
|
// --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
|
|
13192
13596
|
const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
|
|
13193
|
-
|
|
13194
|
-
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
const id = `${agentId}-${randomUUID()}`;
|
|
13198
|
-
const auth = buildEd25519Auth(agentId, "PUT", `/OrgEvent/${id}`, keyPath);
|
|
13199
|
-
// authorId IS included in the body now — OrgEvent.put() (unlike post())
|
|
13200
|
-
// does not auto-attribute from the signature, it 403s any mismatch. This
|
|
13201
|
-
// is a self-declaration the server verifies against the signature, not a
|
|
13202
|
-
// forgeable claim.
|
|
13203
|
-
const body = {
|
|
13204
|
-
id,
|
|
13205
|
-
authorId: agentId,
|
|
13597
|
+
const targetIds = Array.isArray(opts.target) && opts.target.length > 0 ? opts.target : undefined;
|
|
13598
|
+
const result = await publishOrgEvent({
|
|
13599
|
+
agentId,
|
|
13600
|
+
baseUrl,
|
|
13206
13601
|
kind: opts.kind,
|
|
13207
13602
|
summary: opts.summary,
|
|
13208
|
-
|
|
13209
|
-
|
|
13210
|
-
|
|
13211
|
-
body.detail = opts.detail;
|
|
13212
|
-
if (opts.scope)
|
|
13213
|
-
body.scope = opts.scope;
|
|
13214
|
-
if (Array.isArray(opts.target) && opts.target.length > 0)
|
|
13215
|
-
body.targetIds = opts.target;
|
|
13216
|
-
const res = await fetch(`${baseUrl}/OrgEvent/${id}`, {
|
|
13217
|
-
method: "PUT",
|
|
13218
|
-
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
13219
|
-
body: JSON.stringify(body),
|
|
13603
|
+
detail: opts.detail,
|
|
13604
|
+
scope: opts.scope,
|
|
13605
|
+
targetIds,
|
|
13220
13606
|
});
|
|
13221
|
-
if (!
|
|
13222
|
-
|
|
13223
|
-
console.error(`Error: PUT /OrgEvent/${id} failed (${res.status}): ${text}`);
|
|
13607
|
+
if (!result.ok) {
|
|
13608
|
+
console.error(`Error: ${result.error}`);
|
|
13224
13609
|
process.exit(1);
|
|
13225
13610
|
}
|
|
13226
|
-
const
|
|
13227
|
-
const targets = Array.isArray(opts.target) && opts.target.length > 0 ? ` → ${opts.target.join(", ")}` : "";
|
|
13611
|
+
const targets = targetIds ? ` → ${targetIds.join(", ")}` : "";
|
|
13228
13612
|
console.log(`✓ OrgEvent published as '${agentId}': kind=${opts.kind}${targets}`);
|
|
13229
|
-
console.log(` id: ${
|
|
13613
|
+
console.log(` id: ${result.id}`);
|
|
13230
13614
|
});
|
|
13231
13615
|
// ─── flair attention ─────────────────────────────────────────────────────────
|
|
13232
13616
|
//
|
package/dist/doctor-client.js
CHANGED
|
@@ -373,6 +373,53 @@ export function planAgentIterations(keyAgentIds, agentFlag) {
|
|
|
373
373
|
return [agentFlag];
|
|
374
374
|
return [...keyAgentIds].sort();
|
|
375
375
|
}
|
|
376
|
+
// ── `doctor --fix` agent-id inference (flair#802b) ─────────────────────────
|
|
377
|
+
//
|
|
378
|
+
// `doctor` suggested `flair doctor --fix` to auto-wire an unconfigured MCP
|
|
379
|
+
// client, but running that exact command failed — "no agent id known — pass
|
|
380
|
+
// --agent <id>" — whenever the client had never been wired before (so there
|
|
381
|
+
// was no existing block to read an agent id from) and neither --agent nor
|
|
382
|
+
// FLAIR_AGENT_ID was set. The suggested fix didn't work as suggested. Two
|
|
383
|
+
// pure decisions fix that without adding any new network/crypto surface:
|
|
384
|
+
//
|
|
385
|
+
// 1. inferSoleAgentId — when exactly one locally-keyed agent exists (the
|
|
386
|
+
// same keyAgentIds pool planAgentIterations already draws from, i.e.
|
|
387
|
+
// doctor's own "Keys found" enumeration), --fix can use it without
|
|
388
|
+
// being told: there's no other candidate it could mean. Two or more
|
|
389
|
+
// keys, or zero, are genuinely ambiguous/unanswerable and still require
|
|
390
|
+
// an explicit --agent (or registering one first) — this never guesses
|
|
391
|
+
// in either of those cases.
|
|
392
|
+
// 2. fixCommandAgentHint — the *printed suggestion* (before --fix ever
|
|
393
|
+
// runs) splices in a concrete `--agent <id>` so the command a user
|
|
394
|
+
// copy-pastes actually works, using the first (sorted) known key id as
|
|
395
|
+
// the example. Only relevant when the id isn't already resolvable some
|
|
396
|
+
// other way (explicit --agent, FLAIR_AGENT_ID, or an id read off an
|
|
397
|
+
// already-wired client) — the caller checks that before calling this.
|
|
398
|
+
/**
|
|
399
|
+
* The one case `doctor --fix` can safely infer an agent id without being
|
|
400
|
+
* told: exactly one locally-keyed agent. Zero keys (nothing to infer) or two
|
|
401
|
+
* or more (genuinely ambiguous — which one?) both return undefined; the
|
|
402
|
+
* caller must fall back to an explicit error telling the user what to do
|
|
403
|
+
* (register one, or pass --agent).
|
|
404
|
+
*/
|
|
405
|
+
export function inferSoleAgentId(keyAgentIds) {
|
|
406
|
+
return keyAgentIds.length === 1 ? keyAgentIds[0] : undefined;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Build the ` --agent <id>` fragment to splice into a suggested
|
|
410
|
+
* `flair doctor --fix ...` command so the printed suggestion is actually
|
|
411
|
+
* copy-pasteable rather than guaranteed to fail the moment nothing else
|
|
412
|
+
* (explicit --agent, FLAIR_AGENT_ID, an already-wired client's agent id) can
|
|
413
|
+
* supply one. Uses the first (sorted) known local key id as a concrete
|
|
414
|
+
* example. Returns "" when no agent id is known at all — the caller should
|
|
415
|
+
* tell the user to register one first rather than print a `--fix` suggestion
|
|
416
|
+
* that has nothing to work with either way.
|
|
417
|
+
*/
|
|
418
|
+
export function fixCommandAgentHint(keyAgentIds) {
|
|
419
|
+
if (keyAgentIds.length === 0)
|
|
420
|
+
return "";
|
|
421
|
+
return ` --agent ${[...keyAgentIds].sort()[0]}`;
|
|
422
|
+
}
|
|
376
423
|
/**
|
|
377
424
|
* Render decision for one agent's registration-gate outcome, ahead of a
|
|
378
425
|
* verified-read section (Fleet presence / Migrations). Returns null when the
|