@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.
@@ -454,12 +454,40 @@ function readTextFile(path) {
454
454
  return null;
455
455
  }
456
456
  }
457
+ // ── check 1: MCP server block present + configured ─────────────────────────
458
+ /**
459
+ * flair-client's own DEFAULT_URL (packages/flair-client/src/client.ts:
460
+ * `this.url = config.url ?? readEnvOrUnset("FLAIR_URL") ?? DEFAULT_URL`).
461
+ * Duplicated here as a value rather than imported — flair-client does not
462
+ * export it, and this module stays dependency-light by the same convention as
463
+ * the AgentGateState type duplication below. A unit test
464
+ * (doctor-client-native-shapes.test.ts) asserts this literal matches
465
+ * flair-client's source, so the two cannot drift silently.
466
+ */
467
+ export const FLAIR_CLIENT_DEFAULT_URL = "http://localhost:19926";
468
+ /**
469
+ * The URL the wired flair-mcp process will actually connect to: the block's
470
+ * FLAIR_URL when set, else flair-client's built-in default. `defaulted` tells
471
+ * the caller which of the two it got, so doctor's output can say so
472
+ * (flair#1287 — a defaulted URL is still a probe-able, working URL).
473
+ */
474
+ export function effectiveFlairUrl(block) {
475
+ return block.flairUrl ? { url: block.flairUrl, defaulted: false } : { url: FLAIR_CLIENT_DEFAULT_URL, defaulted: true };
476
+ }
457
477
  /**
458
478
  * Read the Flair MCP server block from `clientId`'s config file. `present`
459
- * is true only when the block exists AND both FLAIR_AGENT_ID and FLAIR_URL
460
- * are set (non-empty) — a half-wired block (e.g. block present, env missing)
461
- * counts as absent for the pass/fail check, but agentId/flairUrl are still
462
- * returned when partially found so callers can use whatever is known.
479
+ * is true when the block exists AND FLAIR_AGENT_ID is set (non-empty).
480
+ *
481
+ * FLAIR_URL is deliberately NOT required (flair#1287): flair-client treats it
482
+ * as optional and falls back to FLAIR_CLIENT_DEFAULT_URL, and the documented
483
+ * `claude mcp add` command (docs/mcp-clients.md) sets only FLAIR_AGENT_ID —
484
+ * so a URL-less block is a WORKING setup that doctor used to false-negative
485
+ * as "no Flair MCP server configured". Doctor's requirement now matches
486
+ * flair-client's actual contract: agent id required (flair-mcp refuses to
487
+ * start without one — "(none — required)" in docs), URL optional
488
+ * (`urlDefaulted` reports the fallback so the output can distinguish it).
489
+ * agentId/flairUrl are still returned when partially found so callers can use
490
+ * whatever is known.
463
491
  */
464
492
  export function readClientMcpBlock(clientId, homeDir) {
465
493
  const configPath = withHome(homeDir, () => clientConfigPath(clientId));
@@ -476,7 +504,11 @@ function readJsonFlairBlock(configPath) {
476
504
  return { present: false, configPath };
477
505
  const agentId = typeof flair.env?.FLAIR_AGENT_ID === "string" && flair.env.FLAIR_AGENT_ID ? flair.env.FLAIR_AGENT_ID : undefined;
478
506
  const flairUrl = typeof flair.env?.FLAIR_URL === "string" && flair.env.FLAIR_URL ? flair.env.FLAIR_URL : undefined;
479
- return { present: !!agentId && !!flairUrl, configPath, agentId, flairUrl };
507
+ // FLAIR_URL optional see readClientMcpBlock's doc (flair#1287). Any
508
+ // extra fields the client's own tooling writes (e.g. `claude mcp add`'s
509
+ // `type: "stdio"`) are irrelevant to presence and deliberately ignored.
510
+ const present = !!agentId;
511
+ return { present, configPath, agentId, flairUrl, urlDefaulted: present && !flairUrl };
480
512
  }
481
513
  catch {
482
514
  // Malformed JSON — treat as "not present", never throw.
@@ -507,7 +539,55 @@ function readCodexFlairBlock(configPath) {
507
539
  if (!raw)
508
540
  return { present: false, configPath };
509
541
  const scanned = scanCodexFlairBlock(raw);
510
- return { present: scanned.present, configPath, agentId: scanned.agentId, flairUrl: scanned.flairUrl };
542
+ return { present: scanned.present, configPath, agentId: scanned.agentId, flairUrl: scanned.flairUrl, urlDefaulted: scanned.urlDefaulted };
543
+ }
544
+ /**
545
+ * The two env keys the codex scanner ever looks for, each with LITERAL
546
+ * regexes for both TOML shapes a real Codex config carries:
547
+ *
548
+ * `line` — the `[mcp_servers.flair.env]` sub-table form (`FLAIR_AGENT_ID
549
+ * = "..."` on its own line): what `codex mcp add` serializes
550
+ * (toml_edit Table via table_from_pairs, openai/codex
551
+ * codex-rs config/edit/document_helpers.rs), what Codex's own
552
+ * config docs show, and what our tomlSnippet() writes;
553
+ * `inline` — the inline table (`env = { "FLAIR_AGENT_ID" = "..." }`, bare
554
+ * or quoted keys): valid Codex TOML that `codex mcp add` itself
555
+ * PRESERVES when merging into a hand-written inline entry
556
+ * (merge_inline_table, same file). The old line-anchored regex
557
+ * silently missed this shape — the flair#1287 defect class (a
558
+ * client-accepted config our detector rejects) in TOML form.
559
+ *
560
+ * Spelled out as regex LITERALS per key rather than built via `new RegExp`
561
+ * with the key interpolated: the key set is closed (these two), and literal
562
+ * patterns keep the scanner off the non-literal-regexp SAST surface entirely
563
+ * — there is nothing dynamic for an injected pattern to ride in on. The
564
+ * `keyof` parameter type makes a third key a compile error here, not a
565
+ * silently unmatched scan.
566
+ */
567
+ const CODEX_ENV_PATTERNS = {
568
+ FLAIR_AGENT_ID: {
569
+ line: /^\s*FLAIR_AGENT_ID\s*=\s*"([^"]*)"/m,
570
+ inline: /"?FLAIR_AGENT_ID"?\s*=\s*"([^"]*)"/,
571
+ },
572
+ FLAIR_URL: {
573
+ line: /^\s*FLAIR_URL\s*=\s*"([^"]*)"/m,
574
+ inline: /"?FLAIR_URL"?\s*=\s*"([^"]*)"/,
575
+ },
576
+ };
577
+ /** Pull one env value out of the `[mcp_servers.flair]` block text — see
578
+ * CODEX_ENV_PATTERNS for the two shapes each key is matched against. */
579
+ function scanCodexEnvValue(block, key) {
580
+ const patterns = CODEX_ENV_PATTERNS[key];
581
+ const lineMatch = block.match(patterns.line);
582
+ if (lineMatch?.[1])
583
+ return lineMatch[1];
584
+ const inlineEnv = block.match(/^\s*env\s*=\s*\{([^}]*)\}/m);
585
+ if (inlineEnv) {
586
+ const inlineMatch = inlineEnv[1].match(patterns.inline);
587
+ if (inlineMatch?.[1])
588
+ return inlineMatch[1];
589
+ }
590
+ return undefined;
511
591
  }
512
592
  function scanCodexFlairBlock(raw) {
513
593
  const startMatch = raw.match(/^\[mcp_servers\.flair\]\s*$/m);
@@ -523,11 +603,12 @@ function scanCodexFlairBlock(raw) {
523
603
  blockLines.push(lines[i]);
524
604
  }
525
605
  const block = blockLines.join("\n");
526
- const agentMatch = block.match(/^\s*FLAIR_AGENT_ID\s*=\s*"([^"]*)"/m);
527
- const urlMatch = block.match(/^\s*FLAIR_URL\s*=\s*"([^"]*)"/m);
528
- const agentId = agentMatch?.[1] || undefined;
529
- const flairUrl = urlMatch?.[1] || undefined;
530
- return { present: !!agentId && !!flairUrl, agentId, flairUrl };
606
+ const agentId = scanCodexEnvValue(block, "FLAIR_AGENT_ID");
607
+ const flairUrl = scanCodexEnvValue(block, "FLAIR_URL");
608
+ // FLAIR_URL optional same contract as readJsonFlairBlock (flair#1287);
609
+ // docs/mcp-clients.md's own Codex snippet sets only FLAIR_AGENT_ID.
610
+ const present = !!agentId;
611
+ return { present, agentId, flairUrl, urlDefaulted: present && !flairUrl };
531
612
  }
532
613
  // ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
533
614
  /**
@@ -0,0 +1,234 @@
1
+ // ─── npm global bin dir vs PATH (flair#1134) ────────────────────────────────
2
+ //
3
+ // `npm install -g @tpsdev-ai/flair` on a user-prefix setup (prefix =
4
+ // ~/.npm-global or similar) succeeds, puts the `flair` bin in
5
+ // `<prefix>/bin`, and then `flair` is "command not found" because that
6
+ // directory was never added to PATH. The install instructions claim
7
+ // one-command readiness, so the failure reads as a broken package, not a
8
+ // broken PATH.
9
+ //
10
+ // This module is the single source of truth for detecting that state and
11
+ // for the message that fixes it. Two consumers:
12
+ // - dist/postinstall.cjs (src/postinstall.cts) — runs at `npm i -g` time,
13
+ // the moment the user hits the lie.
14
+ // - `flair doctor` — cheap, always runs, and covers every path where
15
+ // lifecycle scripts are suppressed (--ignore-scripts, bun without
16
+ // trustedDependencies, the fleet's tar-swap deploys).
17
+ //
18
+ // Contract (errors must enable a response): every warning names the ACTUAL
19
+ // bin directory and prints the exact line to add for the user's shell —
20
+ // never "check your PATH". If we cannot VALIDATE the directory (the flair
21
+ // bin is really there), we say nothing rather than print a wrong fix.
22
+ //
23
+ // Everything here is pure and dependency-injected except
24
+ // resolveNpmGlobalPrefix (spawns `npm prefix -g` for doctor).
25
+ import { join } from "node:path";
26
+ import { existsSync } from "node:fs";
27
+ // ─── path membership ────────────────────────────────────────────────────────
28
+ /** Strip trailing separators ("/", and "\" on win32) without eating a bare root. */
29
+ function stripTrailingSeps(p, win32) {
30
+ const stripped = p.replace(win32 ? /[\\/]+$/ : /\/+$/, "");
31
+ return stripped === "" ? p.slice(0, 1) : stripped;
32
+ }
33
+ function normalizeEntry(entry, win32) {
34
+ let e = stripTrailingSeps(entry.trim(), win32);
35
+ if (win32)
36
+ e = e.replace(/\//g, "\\").toLowerCase();
37
+ return e;
38
+ }
39
+ /**
40
+ * The directory npm links global bins into for a given prefix:
41
+ * `<prefix>/bin` everywhere except win32, where shims land in the prefix
42
+ * itself (npm's own layout, not ours).
43
+ */
44
+ export function npmGlobalBinDir(prefix, platform = process.platform) {
45
+ const win32 = platform === "win32";
46
+ const clean = stripTrailingSeps(prefix.trim(), win32);
47
+ return win32 ? clean : join(clean, "bin");
48
+ }
49
+ /**
50
+ * Is `dir` one of the entries of `pathEnv`? Trailing slashes are ignored on
51
+ * both sides; win32 compares case-insensitively with either separator and
52
+ * splits on ";". Empty entries (historical "cwd" semantics) never match.
53
+ */
54
+ export function isDirOnPath(dir, pathEnv, platform = process.platform) {
55
+ if (!pathEnv)
56
+ return false;
57
+ const win32 = platform === "win32";
58
+ const delim = win32 ? ";" : ":";
59
+ const want = normalizeEntry(dir, win32);
60
+ return pathEnv
61
+ .split(delim)
62
+ .filter((e) => e.trim() !== "")
63
+ .some((e) => normalizeEntry(e, win32) === want);
64
+ }
65
+ /** basename of $SHELL, lowercased — "/usr/local/bin/zsh" → "zsh". */
66
+ function shellFlavor(shell) {
67
+ if (!shell)
68
+ return "";
69
+ return shell.replace(/\\/g, "/").split("/").pop().toLowerCase();
70
+ }
71
+ export function shellPathFix(binDir, shell) {
72
+ const flavor = shellFlavor(shell);
73
+ if (flavor === "fish") {
74
+ // fish_add_path persists via a universal variable — one command does both.
75
+ const line = `fish_add_path ${binDir}`;
76
+ return { exportLine: line, persistCommand: line, rcFile: null };
77
+ }
78
+ const exportLine = `export PATH="${binDir}:$PATH"`;
79
+ const rcFile = flavor === "zsh" ? "~/.zshrc" : flavor === "bash" ? "~/.bashrc" : null;
80
+ return {
81
+ exportLine,
82
+ persistCommand: rcFile === null ? null : `echo '${exportLine}' >> ${rcFile}`,
83
+ rcFile,
84
+ };
85
+ }
86
+ // ─── the message ────────────────────────────────────────────────────────────
87
+ /**
88
+ * The full actionable warning. Names the actual bin dir, gives the exact
89
+ * line for the user's shell, says how to persist it, and how to verify.
90
+ */
91
+ export function formatOffPathMessage(binDir, shell, platform = process.platform) {
92
+ if (platform === "win32") {
93
+ return [
94
+ `flair is installed in ${binDir}, but that directory is not on your PATH,`,
95
+ `so the "flair" command will not be found.`,
96
+ ``,
97
+ `Fix — add it to your user PATH (new terminals pick it up):`,
98
+ ``,
99
+ ` powershell -Command "[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path','User') + ';${binDir}', 'User')"`,
100
+ ``,
101
+ `Then open a new terminal and verify: flair --version`,
102
+ ].join("\n");
103
+ }
104
+ const fix = shellPathFix(binDir, shell);
105
+ const lines = [
106
+ `flair is installed at ${binDir}/flair, but ${binDir} is not on your PATH,`,
107
+ `so the "flair" command will not be found.`,
108
+ ``,
109
+ `Fix — run this in your shell now:`,
110
+ ``,
111
+ ` ${fix.exportLine}`,
112
+ ];
113
+ if (fix.persistCommand) {
114
+ lines.push(``, `and persist it for new shells:`, ``, ` ${fix.persistCommand}`);
115
+ }
116
+ else if (shellFlavor(shell) !== "fish") {
117
+ lines.push(``, `and add that same line to your shell's startup file to persist it.`);
118
+ }
119
+ lines.push(``, `Then verify: flair --version`);
120
+ return lines.join("\n");
121
+ }
122
+ export function checkGlobalBinOnPath(input) {
123
+ const platform = input.platform ?? process.platform;
124
+ const binDir = npmGlobalBinDir(input.prefix, platform);
125
+ if (isDirOnPath(binDir, input.pathEnv, platform))
126
+ return { onPath: true, binDir };
127
+ return {
128
+ onPath: false,
129
+ binDir,
130
+ exportLine: shellPathFix(binDir, input.shell).exportLine,
131
+ message: formatOffPathMessage(binDir, input.shell, platform),
132
+ };
133
+ }
134
+ /**
135
+ * Fallback when npm_config_prefix is absent: derive prefix from where npm put
136
+ * us. String-based (not path.join) so the win32 shape stays faithful even in
137
+ * tests running on posix hosts.
138
+ */
139
+ export function prefixFromPackageDir(packageDir, platform = process.platform) {
140
+ // posix: <prefix>/lib/node_modules/@tpsdev-ai/flair → 4 segments up
141
+ // win32: <prefix>\node_modules\@tpsdev-ai\flair → 3 segments up
142
+ const win32 = platform === "win32";
143
+ const segments = stripTrailingSeps(packageDir, win32).split(win32 ? /[\\/]/ : "/");
144
+ const ups = win32 ? 3 : 4;
145
+ const kept = segments.slice(0, Math.max(1, segments.length - ups));
146
+ return kept.join(win32 ? "\\" : "/") || (win32 ? packageDir : "/");
147
+ }
148
+ function defaultBinDirHasFlair(binDir, platform) {
149
+ const names = platform === "win32" ? ["flair.cmd", "flair"] : ["flair"];
150
+ return names.some((n) => existsSync(join(binDir, n)));
151
+ }
152
+ /**
153
+ * Decide what (if anything) the postinstall hook should print.
154
+ *
155
+ * Returns the warning message, or null when there is nothing to say:
156
+ * - not a global install (npm_config_global !== "true" — local installs and
157
+ * non-npm runners stay silent),
158
+ * - no candidate prefix VALIDATES (the flair bin is not actually in the
159
+ * candidate's bin dir — we never print a fix naming a wrong directory),
160
+ * - or the bin dir is already on PATH.
161
+ */
162
+ export function postinstallWarning(env) {
163
+ if (env.npmConfigGlobal !== "true")
164
+ return null;
165
+ const platform = env.platform ?? process.platform;
166
+ const hasFlair = env.binDirHasFlair ?? ((d) => defaultBinDirHasFlair(d, platform));
167
+ const candidates = [];
168
+ if (env.npmConfigPrefix)
169
+ candidates.push(env.npmConfigPrefix);
170
+ if (env.packageDir)
171
+ candidates.push(prefixFromPackageDir(env.packageDir, platform));
172
+ for (const prefix of candidates) {
173
+ const binDir = npmGlobalBinDir(prefix, platform);
174
+ if (!hasFlair(binDir))
175
+ continue; // unvalidated — never name a wrong dir
176
+ if (isDirOnPath(binDir, env.pathEnv, platform))
177
+ return null;
178
+ return formatOffPathMessage(binDir, env.shell, platform);
179
+ }
180
+ return null;
181
+ }
182
+ /** Compact per-boot variant of the message — this one repeats until fixed. */
183
+ export function formatCompactOffPathBanner(binDir, shell) {
184
+ const fix = shellPathFix(binDir, shell);
185
+ const lines = [
186
+ `flair: ${binDir} (where npm installed flair) is not on your PATH.`,
187
+ ` fix now: ${fix.exportLine}`,
188
+ ];
189
+ if (fix.persistCommand && fix.persistCommand !== fix.exportLine) {
190
+ lines.push(` persist: ${fix.persistCommand}`);
191
+ }
192
+ else if (!fix.persistCommand) {
193
+ lines.push(` persist: add that line to your shell's startup file`);
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+ /**
198
+ * Decide what (if anything) the CLI should print to stderr at boot.
199
+ * Null when: not a TTY, the layout does not validate (dev checkouts, npx
200
+ * cache copies, tar-swap deploys — their derived dir has no flair bin), or
201
+ * the bin dir is on PATH.
202
+ */
203
+ export function cliBootPathWarning(env) {
204
+ if (!env.stderrIsTTY)
205
+ return null;
206
+ const platform = env.platform ?? process.platform;
207
+ const hasFlair = env.binDirHasFlair ?? ((d) => defaultBinDirHasFlair(d, platform));
208
+ const binDir = npmGlobalBinDir(prefixFromPackageDir(env.packageDir, platform), platform);
209
+ if (!hasFlair(binDir))
210
+ return null; // unvalidated — never name a wrong dir
211
+ if (isDirOnPath(binDir, env.pathEnv, platform))
212
+ return null;
213
+ return formatCompactOffPathBanner(binDir, env.shell);
214
+ }
215
+ // ─── doctor plumbing ────────────────────────────────────────────────────────
216
+ /**
217
+ * `npm prefix -g`, best-effort. Returns the trimmed prefix or null when npm
218
+ * is absent / slow / errors — doctor SKIPS the check then (flair may have
219
+ * been installed by other means; a missing npm is not something this check
220
+ * can turn into an actionable finding).
221
+ */
222
+ export async function resolveNpmGlobalPrefix() {
223
+ try {
224
+ const { execFile } = await import("node:child_process");
225
+ const out = await new Promise((resolve, reject) => {
226
+ execFile(process.platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], { timeout: 5000, encoding: "utf-8", shell: process.platform === "win32" }, (err, stdout) => (err ? reject(err) : resolve(String(stdout))));
227
+ });
228
+ const prefix = out.trim();
229
+ return prefix === "" ? null : prefix;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * entity-vocab-cli.ts — CLI-side copy of the attention-plane entity
3
+ * vocabulary validator (resources/entity-vocab.ts is the canonical module).
4
+ *
5
+ * INLINED, not imported: cross-boundary imports from src/ into resources/
6
+ * don't survive npm packaging — tsconfig.cli.json compiles with
7
+ * `rootDir: "src"`, so dist/cli.js has no resources/ module it can resolve
8
+ * at the same relative path. This is the same reason src/cli.ts inlines the
9
+ * federation crypto helpers (see the note beside `sortKeys()` there) and the
10
+ * private-visibility filter. The two files MUST stay in sync:
11
+ * test/unit/cli-entities-option.test.ts imports BOTH and pins ENTITY_TYPES
12
+ * equality, validator parity across a known-answer table, and the
13
+ * entityFormatHint() string — drift fails CI rather than shipping.
14
+ *
15
+ * Used by the `--entities <csv>` option on `flair memory add`,
16
+ * `flair workspace set`, and `flair orgevent` (flair#1288): the CLI validates
17
+ * before any signing/network work so a malformed entity is rejected
18
+ * client-side with an error that names the `type:value` format and
19
+ * enumerates the closed type set. The server independently re-validates on
20
+ * every write path (resources/Memory.ts / WorkspaceState.ts / OrgEvent.ts via
21
+ * invalidEntitiesResponse) — this module is UX, not the security gate.
22
+ */
23
+ /** The closed set of entity types. Mirror of resources/entity-vocab.ts — extend BOTH together. */
24
+ export const ENTITY_TYPES = [
25
+ "repo",
26
+ "issue",
27
+ "customer",
28
+ "subsystem",
29
+ "agent",
30
+ "person",
31
+ ];
32
+ const ENTITY_TYPE_SET = new Set(ENTITY_TYPES);
33
+ /**
34
+ * A "slug" value: lowercase alphanumeric segments joined by single `-` or
35
+ * `_` separators. Used for `customer:`, `subsystem:`, `agent:`, `person:`.
36
+ */
37
+ const SLUG_RE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
38
+ /** A single repo path segment (owner or name): lowercase alphanumeric with `.`, `-`, `_` internal. */
39
+ const REPO_SEGMENT_RE = /^[a-z0-9]+(?:[.\-_][a-z0-9]+)*$/;
40
+ /** `<owner>/<name>` — both segments valid, exactly one `/`. */
41
+ function isValidRepoValue(value) {
42
+ const parts = value.split("/");
43
+ if (parts.length !== 2)
44
+ return false;
45
+ const [owner, name] = parts;
46
+ return REPO_SEGMENT_RE.test(owner) && REPO_SEGMENT_RE.test(name);
47
+ }
48
+ /** `<owner>/<name>#<n>` — a valid repo value, `#`, then a positive integer (no leading zero). */
49
+ function isValidIssueValue(value) {
50
+ const hashIndex = value.indexOf("#");
51
+ if (hashIndex === -1)
52
+ return false;
53
+ const repoPart = value.slice(0, hashIndex);
54
+ const numberPart = value.slice(hashIndex + 1);
55
+ if (!/^[1-9][0-9]*$/.test(numberPart))
56
+ return false;
57
+ return isValidRepoValue(repoPart);
58
+ }
59
+ function isValidSlugValue(value) {
60
+ return SLUG_RE.test(value);
61
+ }
62
+ const VALUE_VALIDATORS = {
63
+ repo: isValidRepoValue,
64
+ issue: isValidIssueValue,
65
+ customer: isValidSlugValue,
66
+ subsystem: isValidSlugValue,
67
+ agent: isValidSlugValue,
68
+ person: isValidSlugValue,
69
+ };
70
+ /** Split an entity string on its first `:` into { type, value }; null if it can't be well-formed. */
71
+ function parseEntity(entity) {
72
+ if (typeof entity !== "string" || entity.length === 0)
73
+ return null;
74
+ const colonIndex = entity.indexOf(":");
75
+ if (colonIndex <= 0)
76
+ return null; // no colon, or colon is the first char (empty type)
77
+ const type = entity.slice(0, colonIndex);
78
+ const value = entity.slice(colonIndex + 1);
79
+ if (value.length === 0)
80
+ return null;
81
+ return { type, value };
82
+ }
83
+ /** Full validation: well-formed `type:value`, type in the closed set, value matches the type's grammar. */
84
+ export function isValidEntity(entity) {
85
+ if (typeof entity !== "string")
86
+ return false;
87
+ const parsed = parseEntity(entity);
88
+ if (!parsed)
89
+ return false;
90
+ if (!ENTITY_TYPE_SET.has(parsed.type))
91
+ return false;
92
+ return VALUE_VALIDATORS[parsed.type](parsed.value);
93
+ }
94
+ /**
95
+ * Canonical "what does well-formed look like" hint (flair#1288): names the
96
+ * `type:value` format AND enumerates the closed type set, so the rejection
97
+ * enables a response. Must produce the EXACT string resources/entity-vocab.ts's
98
+ * entityFormatHint() produces — the sync test compares them verbatim.
99
+ */
100
+ export function entityFormatHint() {
101
+ return `entities are 'type:value' vocabulary strings (e.g. 'repo:owner/name'); valid types: ${ENTITY_TYPES.join(", ")}`;
102
+ }
103
+ /**
104
+ * Parse a `--entities <csv>` option value: comma-split, trim, drop empties —
105
+ * the same list-option convention `--tags <csv>` / `--derived-from <csv>`
106
+ * already use (safe here because no entity grammar admits a comma) — then
107
+ * validate each element against the vocabulary.
108
+ */
109
+ export function parseEntitiesCsv(csv) {
110
+ const entities = String(csv).split(",").map((x) => x.trim()).filter(Boolean);
111
+ const invalid = entities.filter((e) => !isValidEntity(e));
112
+ return { entities, invalid };
113
+ }
@@ -1064,8 +1064,18 @@ export async function enableMcp(params, deps = {}) {
1064
1064
  idpProvider,
1065
1065
  idpSubject: params.idpSubject,
1066
1066
  }, { fetchImpl: deps.fetchImpl, now: deps.now });
1067
- push(true, `principal '${principal}' ${mapping.principalCreated ? "created" : "already existed"}; ` +
1068
- `Credential(kind:idp) ${mapping.credentialReused ? "reused" : "created"} (${mapping.credentialId})`);
1067
+ // flair#1280 provisioning legibility: distinct identities are the
1068
+ // DEFAULT (a connector sub is not your CLI agent unless you link them),
1069
+ // and the one silent failure mode this surface has is discovering that
1070
+ // via an empty bootstrap. So the step that creates the mapping states it
1071
+ // plainly, names the link remedy, and points at the runtime diagnostic.
1072
+ push(true, `connector identity: sub '${params.idpSubject}' (provider '${idpProvider}') resolves to Agent '${principal}' — ` +
1073
+ `every /mcp call reads and writes AS '${principal}'. ` +
1074
+ `principal ${mapping.principalCreated ? "created" : "already existed"}; ` +
1075
+ `Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId}). ` +
1076
+ `If your CLI signs as a DIFFERENT agent id, the connector sees that agent's DISTINCT memory scope (by design) — ` +
1077
+ `re-run with --principal <your-agent-id> to link them. ` +
1078
+ `Diagnostic: the bootstrap tool's agentId/scope fields always say who the server resolved you to.`);
1069
1079
  // ── Gate: confirm the staged secrets are actually live before restarting ─
1070
1080
  let confirmed = Boolean(params.confirmSecretsApplied);
1071
1081
  if (!confirmed && deps.confirmPrompt) {
@@ -234,6 +234,159 @@ export function describeExitCode(code) {
234
234
  return "exit 209 — launchd could not spawn the job (a missing/unwritable log directory produces this)";
235
235
  return `exit ${code}`;
236
236
  }
237
+ /**
238
+ * Reads how the job's most recent run ended, from the only vantage that
239
+ * knows: the service manager itself.
240
+ *
241
+ * darwin: `launchctl print` carries `last exit code = N` once a run has
242
+ * completed (parseLaunchdPrintExit). linux: `systemctl --user show` on the
243
+ * service unit — with one trap encoded here rather than in every caller: a
244
+ * unit that has NEVER completed a run still reports `ExecMainStatus=0,
245
+ * Result=success` (systemd property defaults), so the exit properties are
246
+ * only believed when `ExecMainExitTimestampMonotonic` proves a run actually
247
+ * finished. Without that check, "never ran" renders as "last run succeeded"
248
+ * — the exact skipped-check-looks-like-a-pass shape this feature exists to
249
+ * kill.
250
+ */
251
+ export function queryLastExitStatus(opts) {
252
+ const run = opts.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
253
+ if (opts.plat === "darwin") {
254
+ const target = opts.darwinTarget;
255
+ if (!target)
256
+ throw new Error("queryLastExitStatus: darwinTarget is required on darwin");
257
+ const printCmd = ["launchctl", "print", target];
258
+ const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
259
+ if (spawnedNothing(r)) {
260
+ return { state: "unavailable", exitCode: null, detail: `launchctl could not be run (${printCmd.join(" ")})` };
261
+ }
262
+ if (r.code !== 0) {
263
+ return { state: "unavailable", exitCode: null, detail: `${printCmd.join(" ")} → code ${r.code} (job not loaded — no run record to read)` };
264
+ }
265
+ const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
266
+ if (running) {
267
+ return { state: "running", exitCode: null, detail: `${printCmd.join(" ")} → a run is in flight` };
268
+ }
269
+ if (lastExitCode === null) {
270
+ return { state: "never-ran", exitCode: null, detail: `${printCmd.join(" ")} → no completed run recorded` };
271
+ }
272
+ return { state: "recorded", exitCode: lastExitCode, detail: `${printCmd.join(" ")} → last exit code = ${lastExitCode}` };
273
+ }
274
+ const unit = opts.linuxServiceUnit;
275
+ if (!unit)
276
+ throw new Error("queryLastExitStatus: linuxServiceUnit is required on linux");
277
+ const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result,ExecMainExitTimestampMonotonic"];
278
+ const r = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
279
+ if (spawnedNothing(r)) {
280
+ return { state: "unavailable", exitCode: null, detail: `systemctl could not be run (${showCmd.join(" ")})` };
281
+ }
282
+ if (/failed to connect to bus/i.test(r.stderr)) {
283
+ return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → ${r.stderr.trim()}` };
284
+ }
285
+ if (r.code !== 0) {
286
+ return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → code ${r.code}${r.stderr.trim() ? `: ${r.stderr.trim()}` : ""}` };
287
+ }
288
+ // Believe the exit properties only when a run has actually finished — see
289
+ // the doc comment above for why this must be checked FIRST.
290
+ const ts = /^ExecMainExitTimestampMonotonic=(\d+)\s*$/m.exec(r.stdout);
291
+ if (ts && Number(ts[1]) === 0) {
292
+ return { state: "never-ran", exitCode: null, detail: `${showCmd.join(" ")} → no completed run recorded` };
293
+ }
294
+ const parsed = parseSystemdShowExit(r.stdout);
295
+ if (parsed.execMainStatus === null) {
296
+ return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → no ExecMainStatus in the reply` };
297
+ }
298
+ const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
299
+ return {
300
+ state: "recorded",
301
+ exitCode: parsed.execMainStatus,
302
+ detail: `${showCmd.join(" ")} → ExecMainStatus=${parsed.execMainStatus}${resultTxt}`,
303
+ };
304
+ }
305
+ /**
306
+ * Pure decision logic for `flair doctor`'s "Scheduled drivers" section
307
+ * (flair#1278) — extracted so it is unit-testable without spawning
308
+ * launchctl/systemctl, same idiom as formatEnableReport/assessDriver in the
309
+ * scheduler modules and summarizeDoctorRun in the CLI.
310
+ *
311
+ * The three load-bearing rules:
312
+ * - not-enabled is a CHOICE, not a defect: informational marker, never the
313
+ * pass marker, never the fail marker, never an issue (a skipped check
314
+ * must not look like a pass — flair#970's rule applied to schedulers).
315
+ * - a last-run failure IS a defect, reported loud with actor+state+remedy
316
+ * (embed-verify style): the service manager is firing the job, the runs
317
+ * themselves are dying, so the schedule looks alive while nothing is
318
+ * delivered — the #1231 incident shape.
319
+ * - "could not read" is UNVERIFIED, never a pass and never a hard failure
320
+ * — the same discipline as doctor's audit-log and embeddings probes.
321
+ */
322
+ export function describeScheduledDriverFinding(f) {
323
+ if (!f.installed) {
324
+ return {
325
+ state: "not-enabled",
326
+ icon: "info",
327
+ isIssue: false,
328
+ message: `${f.label}: not enabled`,
329
+ detail: [`Opt-in — enable: ${f.enableCommand}`],
330
+ };
331
+ }
332
+ if (f.active === false) {
333
+ return {
334
+ state: "degraded",
335
+ icon: "error",
336
+ isIssue: true,
337
+ message: `${f.label}: INSTALLED BUT NOT LOADED — nothing will run it`,
338
+ detail: [
339
+ `The unit files are on disk, but the service manager does not have the job loaded, so it never fires.`,
340
+ `Fix: ${f.enableCommand} # then check: ${f.statusCommand}`,
341
+ ],
342
+ };
343
+ }
344
+ if (f.active === null) {
345
+ return {
346
+ state: "unverified",
347
+ icon: "warn",
348
+ isIssue: false,
349
+ message: `${f.label}: UNVERIFIED — installed, but whether it is loaded could not be read`,
350
+ detail: [`Querying the service manager was inconclusive. Check: ${f.statusCommand}`],
351
+ };
352
+ }
353
+ // Loaded from here down.
354
+ const le = f.lastExit;
355
+ if (!le || le.state === "unavailable") {
356
+ return {
357
+ state: "unverified",
358
+ icon: "warn",
359
+ isIssue: false,
360
+ message: `${f.label}: loaded, but its last-run status could not be read`,
361
+ detail: [...(le ? [le.detail] : []), `Check: ${f.statusCommand}`],
362
+ };
363
+ }
364
+ if (le.state === "recorded" && le.exitCode !== 0) {
365
+ return {
366
+ state: "degraded",
367
+ icon: "error",
368
+ isIssue: true,
369
+ message: `${f.label} DEGRADED — loaded, but its last run failed (${describeExitCode(le.exitCode)})`,
370
+ detail: [
371
+ `The service manager has the job loaded and is firing it; the runs themselves are failing, so the schedule looks alive while nothing is delivered.`,
372
+ `Check ${f.stderrLogPath}, then: ${f.statusCommand}`,
373
+ ],
374
+ };
375
+ }
376
+ if (le.state === "running") {
377
+ return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (a run is in flight now)`, detail: [] };
378
+ }
379
+ if (le.state === "never-ran") {
380
+ return {
381
+ state: "healthy",
382
+ icon: "ok",
383
+ isIssue: false,
384
+ message: `${f.label}: loaded (no completed run on record yet)`,
385
+ detail: [`Installed and loaded; the service manager has not recorded a completed run since it last (re)loaded the job.`],
386
+ };
387
+ }
388
+ return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (last run: exit 0)`, detail: [] };
389
+ }
237
390
  function spawnedNothing(r) {
238
391
  return r.code === null && !r.stdout.trim() && !r.stderr.trim();
239
392
  }