@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 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
- const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
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(Number(pid.trim()), "SIGTERM");
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
- const fixAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
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
- console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
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
- console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
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
- // id generation mirrors flair-client's Memory.write() convention
13194
- // (`${agentId}-${randomUUID()}`) unique per publish, unlike OrgEvent's
13195
- // own (HTTP-unreachable) post() default of `${authorId}-${isoTimestamp}`,
13196
- // which can collide within the same millisecond.
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
- createdAt: new Date().toISOString(),
13209
- };
13210
- if (opts.detail)
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 (!res.ok) {
13222
- const text = await res.text().catch(() => "");
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 data = await res.json().catch(() => null);
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: ${data?.id ?? id}`);
13613
+ console.log(` id: ${result.id}`);
13230
13614
  });
13231
13615
  // ─── flair attention ─────────────────────────────────────────────────────────
13232
13616
  //
@@ -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