@tpsdev-ai/flair 0.46.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/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, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
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";
27
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
@@ -4009,6 +4012,13 @@ agent
4009
4012
  : `✅ Agent '${id}' (${name}) registered`);
4010
4013
  console.log(` Private key: ${privPath}`);
4011
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>`);
4012
4022
  });
4013
4023
  agent
4014
4024
  .command("list")
@@ -5965,7 +5975,14 @@ async function loadInstanceSecretKey(instanceId, opts) {
5965
5975
  return nacl.sign.keyPair.fromSeed(new Uint8Array(seedFromDb)).secretKey;
5966
5976
  }
5967
5977
  }
5968
- throw new Error(`No private key found for instance ${instanceId}. Re-run 'flair federation status' to regenerate.`);
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.`);
5969
5986
  }
5970
5987
  /**
5971
5988
  * Sign a request body and return a new body with the signature field added.
@@ -6040,165 +6057,48 @@ federation
6040
6057
  const target = resolveTarget(opts);
6041
6058
  const baseUrl = target ? target.replace(/\/$/, "") : undefined;
6042
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;
6043
6067
  try {
6044
- const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
6045
- const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
6046
- // Driver state (flair#922). Computed from the LOCAL service manager, so
6047
- // it is only meaningful when the CLI is pointed at the local instance.
6048
- let driver = null;
6049
- let assessment = null;
6050
- if (driverCheckAppliesTo(opts)) {
6051
- try {
6052
- const { schedulerStatus, assessDriver } = await import("./federation/scheduler.js");
6053
- driver = schedulerStatus();
6054
- let lastSyncAt = null;
6055
- for (const p of peers ?? []) {
6056
- if (!p?.lastSyncAt)
6057
- continue;
6058
- const t = Date.parse(p.lastSyncAt);
6059
- if (Number.isFinite(t) && (lastSyncAt === null || t > Date.parse(lastSyncAt))) {
6060
- lastSyncAt = new Date(t).toISOString();
6061
- }
6062
- }
6063
- assessment = assessDriver({
6064
- installed: driver.installed,
6065
- active: driver.active,
6066
- intervalSeconds: driver.intervalSeconds,
6067
- lastSyncAt,
6068
- now: Date.now(),
6069
- });
6070
- }
6071
- catch {
6072
- // An unsupported platform (neither darwin nor linux) or an
6073
- // unreadable unit must not take down `federation status` — the peer
6074
- // table is still the primary output.
6075
- driver = null;
6076
- assessment = null;
6077
- }
6078
- }
6079
- if (mode === "json") {
6080
- console.log(render.asJSON({ instance, peers, driver, driverAssessment: assessment }));
6081
- return;
6082
- }
6083
- const statusColor = instance.status === "active" ? render.c.green : render.c.yellow;
6084
- console.log(render.wrap(render.c.bold, "Federation"));
6085
- console.log(render.kv("Instance", `${instance.id} ${render.wrap(render.c.dim, `(${instance.role})`)}`));
6086
- console.log(render.kv("Public key", render.wrap(render.c.dim, instance.publicKey)));
6087
- console.log(render.kv("Status", render.wrap(statusColor, instance.status)));
6088
- if (peers.length === 0) {
6089
- console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, "No peers configured. Use 'flair federation pair' to connect to a hub.")}`);
6090
- return;
6091
- }
6092
- // Print the driver line BEFORE the per-peer table: "is anything running
6093
- // sync at all" is the question that decides how to read everything
6094
- // below it.
6095
- if (assessment) {
6096
- const icon = assessment.verdict === "driving" || assessment.verdict === "external-driver"
6097
- ? render.icons.ok
6098
- : assessment.verdict === "unknown"
6099
- ? render.icons.info
6100
- : render.icons.warn;
6101
- const color = assessment.verdict === "driving" || assessment.verdict === "external-driver"
6102
- ? render.c.green
6103
- : assessment.verdict === "unknown"
6104
- ? render.c.dim
6105
- : render.c.yellow;
6106
- console.log();
6107
- console.log(`${icon} ${render.wrap(color, assessment.headline)}`);
6108
- console.log(` ${render.wrap(render.c.dim, assessment.detail)}`);
6109
- if (assessment.remedy)
6110
- console.log(` ${render.wrap(render.c.cyan, assessment.remedy)}`);
6111
- }
6112
- const now = Date.now();
6113
- const formatPeerAge = (iso, refNow, staleAfterMs) => {
6114
- if (!iso)
6115
- return render.wrap(render.c.red, "never");
6116
- const t = Date.parse(iso);
6117
- if (!Number.isFinite(t))
6118
- return render.wrap(render.c.red, "never");
6119
- const ageMs = refNow - t;
6120
- const ageStr = ageMs < 60_000 ? "<1m ago"
6121
- : ageMs < 3_600_000 ? `${Math.floor(ageMs / 60_000)}m ago`
6122
- : ageMs < 86_400_000 ? `${Math.floor(ageMs / 3_600_000)}h ago`
6123
- : `${Math.floor(ageMs / 86_400_000)}d ago`;
6124
- const stale = ageMs > staleAfterMs;
6125
- return render.wrap(stale ? render.c.yellow : render.c.dim, ageStr);
6126
- };
6127
- console.log();
6128
- const cols = [
6129
- { label: "peer", key: "id" },
6130
- { label: "role", key: "role", format: (v) => String(v ?? "—") },
6131
- {
6132
- label: "status",
6133
- key: "status",
6134
- format: (v) => {
6135
- const s = String(v ?? "—");
6136
- const color = s === "paired" || s === "connected" || s === "active" ? render.c.green : s === "revoked" ? render.c.red : render.c.yellow;
6137
- return render.wrap(color, s);
6138
- },
6139
- },
6140
- {
6141
- // Liveness: "did we hear from this peer recently?" Updates on every
6142
- // contact, even when 100% of records were skipped. See flair#444.
6143
- label: "last_sync",
6144
- key: "lastSyncAt",
6145
- format: (v) => formatPeerAge(v, now, 86_400_000),
6146
- },
6147
- {
6148
- // Progress: "did data actually flow in?" Updates only when merged>0.
6149
- // Diverging from last_sync means contact-yes but data-no — investigate.
6150
- label: "last_merge",
6151
- key: "lastMergeAt",
6152
- format: (v) => formatPeerAge(v, now, 86_400_000),
6153
- },
6154
- {
6155
- label: "relay",
6156
- key: "relayOnly",
6157
- format: (v) => (v ? render.wrap(render.c.cyan, "yes") : render.wrap(render.c.dim, "no")),
6158
- },
6159
- ];
6160
- console.log(render.table(cols, peers));
6161
- // Stale warning is gated on lastMergeAt (real progress), not lastSyncAt.
6162
- // A peer that "syncs" every 5min but hasn't merged a record in 24h is
6163
- // exactly the failure mode we want surfaced.
6164
- const haveStale = peers.some((p) => {
6165
- const cursor = p.lastMergeAt ?? p.lastSyncAt;
6166
- if (!cursor)
6167
- return true;
6168
- const t = Date.parse(cursor);
6169
- return !Number.isFinite(t) || (now - t) > 86_400_000;
6170
- });
6171
- if (haveStale) {
6172
- console.log();
6173
- // The staleness warning used to fire identically whether sync was
6174
- // running and the peer was unreachable, or nothing had run sync since
6175
- // the day the spoke was paired (flair#922). Those need opposite
6176
- // actions, so the remedy is now chosen by the driver verdict instead
6177
- // of always pointing at SyncLog.
6178
- const noDriver = assessment?.verdict === "no-driver" || assessment?.verdict === "driver-inactive";
6179
- const remedy = noDriver
6180
- ? "Nothing is driving sync — see the driver line above. Run 'flair federation sync enable'."
6181
- : "Check skippedReasons in SyncLog or run 'flair federation sync'.";
6182
- 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)}`);
6183
- }
6184
- const haveContactButNoMerge = peers.some((p) => {
6185
- if (!p.lastSyncAt || !Number.isFinite(Date.parse(p.lastSyncAt)))
6186
- return false;
6187
- if ((now - Date.parse(p.lastSyncAt)) > 3_600_000)
6188
- return false; // only recent contact
6189
- // Contact within the last hour, but no merge ever (or stale by >1h)
6190
- if (!p.lastMergeAt)
6191
- return true;
6192
- const tm = Date.parse(p.lastMergeAt);
6193
- return !Number.isFinite(tm) || (now - tm) > 3_600_000;
6194
- });
6195
- if (haveContactButNoMerge && !haveStale) {
6196
- console.log();
6197
- 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.")}`);
6198
- }
6068
+ instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
6069
+ }
6070
+ catch (err) {
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 ?? [];
6199
6079
  }
6200
6080
  catch (err) {
6201
- const msg = String(err.message ?? 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);
6202
6102
  if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
6203
6103
  console.error(`${render.icons.error} federation status requires auth.`);
6204
6104
  console.error(` ${render.wrap(render.c.dim, "Set one of:")}`);
@@ -6210,6 +6110,203 @@ federation
6210
6110
  console.error(`${render.icons.error} ${msg}`);
6211
6111
  process.exit(1);
6212
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
+ }
6213
6310
  });
6214
6311
  // `flair federation reachability` — probe local instance + all paired peers.
6215
6312
  // Productizes flair#695: a single command that tells
@@ -7819,6 +7916,43 @@ export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
7819
7916
  const scopeTag = [...adkTags][0];
7820
7917
  return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
7821
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
+ }
7822
7956
  // ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
7823
7957
  // A promotion records a reviewerId that feeds audit/attribution
7824
7958
  // (schemas/memory.graphql:209). An automated (machine-driven) promotion path
@@ -7935,11 +8069,19 @@ rem
7935
8069
  // Write the resulting Soul or Memory entry
7936
8070
  if (opts.to === "memory") {
7937
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);
7938
8079
  const memWrite = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, {
7939
8080
  id: memId,
7940
8081
  agentId: candidate.agentId,
7941
8082
  content: candidate.claim,
7942
8083
  durability: "persistent",
8084
+ ...(promotedVisibility ? { visibility: promotedVisibility } : {}),
7943
8085
  tags: promotedTags,
7944
8086
  derivedFrom: candidate.sourceMemoryIds ?? [],
7945
8087
  promotionStatus: "approved",
@@ -12175,6 +12317,33 @@ program
12175
12317
  `Commands run through the CLI; the instance serves the data.`)}`);
12176
12318
  }
12177
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
+ }
12178
12347
  // Helper: try to reach Harper on a given port.
12179
12348
  // Must return true ONLY when Harper's /Health endpoint returns 200 OK.
12180
12349
  // A generic HTTP status > 0 (flair#862) would accept 404 from a Node
@@ -12751,13 +12920,22 @@ program
12751
12920
  continue;
12752
12921
  }
12753
12922
  console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
12754
- const reachable = await probeFlairReachable(block.flairUrl);
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);
12755
12933
  if (!reachable) {
12756
- console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} not reachable — cannot verify agent registration`);
12934
+ console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} not reachable — cannot verify agent registration`);
12757
12935
  continue;
12758
12936
  }
12759
- console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} reachable`);
12760
- const reg = await checkAgentRegistered(block.flairUrl, block.agentId, defaultKeysDir());
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());
12761
12939
  if (reg.state === "registered") {
12762
12940
  console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
12763
12941
  }
@@ -13224,6 +13402,80 @@ program
13224
13402
  }
13225
13403
  }
13226
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
+ }
13227
13479
  // Summary — see summarizeDoctorRun above (flair#721): distinguishes
13228
13480
  // issues --fix actually resolved this run from ones still outstanding.
13229
13481
  console.log("");
@@ -14297,6 +14549,29 @@ sessionSnapshot
14297
14549
  console.error(` extracted to: ${targetDir}`);
14298
14550
  });
14299
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`)";
14300
14575
  const memory = program.command("memory").description("Manage agent memories");
14301
14576
  memory.command("add [content]")
14302
14577
  .description("Write a new memory row for an agent (content via positional arg or --content)")
@@ -14307,6 +14582,7 @@ memory.command("add [content]")
14307
14582
  .option("--subject <text>", "one-line title / entity this memory is about")
14308
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)")
14309
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)
14310
14586
  .action(async (contentArg, opts) => {
14311
14587
  const content = contentArg ?? opts.content;
14312
14588
  if (!content) {
@@ -14342,6 +14618,13 @@ memory.command("add [content]")
14342
14618
  if (opts.derivedFrom) {
14343
14619
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
14344
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
+ }
14345
14628
  const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
14346
14629
  console.log(JSON.stringify(out, null, 2));
14347
14630
  });
@@ -16601,6 +16884,7 @@ workspace
16601
16884
  .option("--task <id>", "Task/issue id this workspace is attached to")
16602
16885
  .option("--phase <phase>", "Current phase (e.g. design, implement, review)")
16603
16886
  .option("--summary <text>", "Short summary of current workspace state")
16887
+ .option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
16604
16888
  .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
16605
16889
  .option("--port <port>", "Harper HTTP port")
16606
16890
  .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
@@ -16617,6 +16901,9 @@ workspace
16617
16901
  process.exit(1);
16618
16902
  }
16619
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;
16620
16907
  const keyPath = resolveKeyPath(agentId);
16621
16908
  if (!keyPath) {
16622
16909
  console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
@@ -16649,6 +16936,8 @@ workspace
16649
16936
  body.phase = opts.phase;
16650
16937
  if (opts.summary)
16651
16938
  body.summary = opts.summary;
16939
+ if (entities && entities.length > 0)
16940
+ body.entities = entities;
16652
16941
  const res = await fetch(`${baseUrl}/WorkspaceState/${id}`, {
16653
16942
  method: "PUT",
16654
16943
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -16723,6 +17012,8 @@ async function publishOrgEvent(params) {
16723
17012
  body.scope = params.scope;
16724
17013
  if (params.targetIds && params.targetIds.length > 0)
16725
17014
  body.targetIds = params.targetIds;
17015
+ if (params.entities && params.entities.length > 0)
17016
+ body.entities = params.entities;
16726
17017
  const res = await fetch(`${params.baseUrl}/OrgEvent/${id}`, {
16727
17018
  method: "PUT",
16728
17019
  headers: { "Content-Type": "application/json", Authorization: auth },
@@ -16743,6 +17034,7 @@ program
16743
17034
  .option("--detail <text>", "Longer detail payload")
16744
17035
  .option("--scope <scope>", "Scope of the event (e.g. an agent id, repo, or 'org')")
16745
17036
  .option("--target <agentId>", "Recipient agent id (repeatable)", (val, acc) => { acc.push(val); return acc; }, [])
17037
+ .option("--entities <csv>", ENTITIES_OPTION_DESCRIPTION)
16746
17038
  .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
16747
17039
  .option("--port <port>", "Harper HTTP port")
16748
17040
  .option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
@@ -16756,6 +17048,9 @@ program
16756
17048
  // --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
16757
17049
  const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
16758
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;
16759
17054
  const result = await publishOrgEvent({
16760
17055
  agentId,
16761
17056
  baseUrl,
@@ -16764,6 +17059,7 @@ program
16764
17059
  detail: opts.detail,
16765
17060
  scope: opts.scope,
16766
17061
  targetIds,
17062
+ entities,
16767
17063
  });
16768
17064
  if (!result.ok) {
16769
17065
  console.error(`Error: ${result.error}`);
@@ -16884,6 +17180,26 @@ program
16884
17180
  // its Node-version check passes. The shim imports this module, so import.meta.main
16885
17181
  // is false there — without this explicit entry point the CLI would load but never run.
16886
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
+ }
16887
17203
  // A bare `flair` (no command) is a help request, not a usage error — show
16888
17204
  // help and exit 0, rather than commander's default (help + exit 1). Flags
16889
17205
  // like -h/--help/-v have argv beyond the binary and fall through to