@tpsdev-ai/flair 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/config.yaml +35 -2
  2. package/dist/build-info.json +6 -0
  3. package/dist/cli.js +847 -168
  4. package/dist/doctor-client.js +358 -11
  5. package/dist/federation/scheduler.js +114 -9
  6. package/dist/hook-install.js +150 -1
  7. package/dist/install/global-bin-path.js +234 -0
  8. package/dist/lib/entity-vocab-cli.js +113 -0
  9. package/dist/lib/mcp-enable.js +71 -21
  10. package/dist/lib/scheduler-platform.js +363 -1
  11. package/dist/postinstall.cjs +88 -0
  12. package/dist/rem/runner.js +177 -10
  13. package/dist/rem/scheduler.js +126 -20
  14. package/dist/resources/AttentionQuery.js +5 -3
  15. package/dist/resources/AutoPromoteCandidates.js +18 -12
  16. package/dist/resources/Federation.js +49 -5
  17. package/dist/resources/Memory.js +36 -2
  18. package/dist/resources/MemoryBootstrap.js +118 -7
  19. package/dist/resources/MemoryMaintenance.js +8 -2
  20. package/dist/resources/MemoryReflect.js +70 -5
  21. package/dist/resources/auto-promote-lib.js +46 -0
  22. package/dist/resources/build-info.js +50 -0
  23. package/dist/resources/entity-vocab.js +25 -1
  24. package/dist/resources/health.js +25 -5
  25. package/dist/resources/mcp-oauth-flag.js +20 -0
  26. package/dist/resources/mcp-oauth.js +6 -1
  27. package/dist/resources/mcp-tools.js +53 -3
  28. package/dist/resources/memory-reflect-lib.js +201 -4
  29. package/dist/src/lib/scheduler-platform.js +363 -1
  30. package/dist/src/rem/scheduler.js +126 -20
  31. package/docs/deepseek-harness.md +110 -0
  32. package/docs/entity-vocabulary.md +15 -0
  33. package/docs/integrations.md +1 -0
  34. package/docs/mcp-clients.md +4 -0
  35. package/docs/notes/mcp-oauth-model2.md +52 -3
  36. package/package.json +5 -4
  37. package/schemas/memory.graphql +12 -0
  38. package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
  39. package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
@@ -54,7 +54,7 @@
54
54
  // reuses bootstrap's own maxTokens machinery.
55
55
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
56
56
  import { dirname, join } from "node:path";
57
- import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
57
+ import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, buildContinuityCaptureHookCommand, checkContinuityCaptureHooks, computeContinuityHookInstall, computeContinuityHookRemoval, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
58
58
  // ── harness registry ────────────────────────────────────────────────────────
59
59
  /** v1 supports exactly one harness. The flag/type exist so a second harness
60
60
  * is an additive registry entry, not a rewrite (Kern's #719 verdict: "a
@@ -352,3 +352,152 @@ export function hookStatus(homeDir, harness) {
352
352
  agentId: env.agentId, flairUrl: env.flairUrl, command, parseError: null,
353
353
  };
354
354
  }
355
+ // ── continuity capture hooks (flair#1257 slice 2) ──────────────────────────
356
+ //
357
+ // The PostToolUse + Stop pair that auto-journals working state into the
358
+ // ephemeral Memory tier (see packages/flair-mcp/src/continuity.ts for the
359
+ // capture discipline). INSTALLING THIS PAIR IS THE OPT-IN — there is no env
360
+ // flag — so it gets the same standalone, symmetric, dry-run-able command
361
+ // surface as the SessionStart hook (`flair hook install|uninstall
362
+ // --continuity`, wired in src/cli.ts), sharing this module's Sherlock
363
+ // conditions: fail-closed on malformed settings.json, backup before any real
364
+ // mutation, idempotent merge that never touches unrelated hooks/keys,
365
+ // --dry-run computes the delta without writing (no backup either). The pure
366
+ // mutation cores (computeContinuityHookInstall / computeContinuityHookRemoval)
367
+ // live in src/doctor-client.ts next to the ONE command builder so `flair
368
+ // doctor --fix` and this family cannot drift apart.
369
+ /** Mirror of buildHookCommand for the continuity pair — delegates to the ONE
370
+ * builder in doctor-client.ts. Throws on unrepresentable values;
371
+ * installContinuityHooks() checks first and reports instead. */
372
+ export function buildContinuityHookCommand(agentId, flairUrl) {
373
+ return buildContinuityCaptureHookCommand(agentId, flairUrl);
374
+ }
375
+ /** Install (or repair to current form) the continuity capture pair. */
376
+ export function installContinuityHooks(opts) {
377
+ const { homeDir, harness, agentId, flairUrl } = opts;
378
+ const dryRun = !!opts.dryRun;
379
+ const path = hookSettingsPath(homeDir, harness);
380
+ for (const [label, value] of [["agent id", agentId], ["Flair URL", flairUrl]]) {
381
+ if (!isHookCommandValueSafe(value)) {
382
+ return {
383
+ ok: false, path, harness, dryRun,
384
+ message: `${label} '${value}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -) — refusing to write it`,
385
+ backupPath: null, actions: null,
386
+ };
387
+ }
388
+ }
389
+ if (dryRun) {
390
+ const read = readSettingsFile(path);
391
+ if (read.parseError) {
392
+ return {
393
+ ok: false, path, harness, dryRun,
394
+ message: `${read.parseError} — dry run: nothing would be written until this is fixed`,
395
+ backupPath: null, actions: null,
396
+ };
397
+ }
398
+ const { changed, actions } = computeContinuityHookInstall(read.parsed ?? {}, agentId, flairUrl);
399
+ const message = changed
400
+ ? `would wire the continuity capture hooks (PostToolUse: ${actions.PostToolUse}, Stop: ${actions.Stop}) in ${path} (dry run — nothing written)`
401
+ : `continuity capture hooks already current in ${path} — no changes`;
402
+ return { ok: true, path, harness, dryRun, message, backupPath: null, actions };
403
+ }
404
+ let backupPath = null;
405
+ if (existsSync(path)) {
406
+ try {
407
+ backupPath = takeBackup(path);
408
+ }
409
+ catch (err) {
410
+ const reason = err instanceof Error ? err.message : String(err);
411
+ return {
412
+ ok: false, path, harness, dryRun,
413
+ message: `could not back up ${path} before mutating it: ${reason} — refusing to touch it`,
414
+ backupPath: null, actions: null,
415
+ };
416
+ }
417
+ }
418
+ const read = readSettingsFile(path);
419
+ if (read.parseError) {
420
+ return {
421
+ ok: false, path, harness, dryRun,
422
+ message: `${read.parseError} — refusing to modify a file we can't safely parse. Original left untouched at ${path}` +
423
+ (backupPath ? `; backup copy at ${backupPath}.` : "."),
424
+ backupPath, actions: null,
425
+ };
426
+ }
427
+ const { changed, actions, newConfig } = computeContinuityHookInstall(read.parsed ?? {}, agentId, flairUrl);
428
+ if (!changed) {
429
+ return { ok: true, path, harness, dryRun, message: `continuity capture hooks already current in ${path}`, backupPath, actions };
430
+ }
431
+ mkdirSync(dirname(path), { recursive: true });
432
+ writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
433
+ return {
434
+ ok: true, path, harness, dryRun,
435
+ message: `wired the continuity capture hooks (PostToolUse: ${actions.PostToolUse}, Stop: ${actions.Stop}) in ${path}`,
436
+ backupPath, actions,
437
+ };
438
+ }
439
+ /** Symmetric removal of the continuity pair — only ours, everything else in
440
+ * the file left untouched. A no-op when nothing is installed. */
441
+ export function uninstallContinuityHooks(opts) {
442
+ const { homeDir, harness } = opts;
443
+ const dryRun = !!opts.dryRun;
444
+ const path = hookSettingsPath(homeDir, harness);
445
+ if (dryRun) {
446
+ const read = readSettingsFile(path);
447
+ if (read.parseError) {
448
+ return {
449
+ ok: false, path, harness, dryRun,
450
+ message: `${read.parseError} — dry run: nothing would be removed until this is fixed`,
451
+ backupPath: null, actions: null,
452
+ };
453
+ }
454
+ const { changed, actions } = computeContinuityHookRemoval(read.parsed ?? {});
455
+ const message = changed
456
+ ? `would remove the continuity capture hooks (PostToolUse: ${actions.PostToolUse}, Stop: ${actions.Stop}) from ${path} (dry run — nothing written)`
457
+ : `no continuity capture hooks found in ${path} — nothing to remove`;
458
+ return { ok: true, path, harness, dryRun, message, backupPath: null, actions };
459
+ }
460
+ let backupPath = null;
461
+ if (existsSync(path)) {
462
+ try {
463
+ backupPath = takeBackup(path);
464
+ }
465
+ catch (err) {
466
+ const reason = err instanceof Error ? err.message : String(err);
467
+ return {
468
+ ok: false, path, harness, dryRun,
469
+ message: `could not back up ${path} before mutating it: ${reason} — refusing to touch it`,
470
+ backupPath: null, actions: null,
471
+ };
472
+ }
473
+ }
474
+ const read = readSettingsFile(path);
475
+ if (read.parseError) {
476
+ return {
477
+ ok: false, path, harness, dryRun,
478
+ message: `${read.parseError} — refusing to modify a file we can't safely parse. Original left untouched at ${path}` +
479
+ (backupPath ? `; backup copy at ${backupPath}.` : "."),
480
+ backupPath, actions: null,
481
+ };
482
+ }
483
+ const { changed, actions, newConfig } = computeContinuityHookRemoval(read.parsed ?? {});
484
+ if (!changed) {
485
+ return { ok: true, path, harness, dryRun, message: `no continuity capture hooks found in ${path} — nothing to remove`, backupPath, actions };
486
+ }
487
+ writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
488
+ return {
489
+ ok: true, path, harness, dryRun,
490
+ message: `removed the continuity capture hooks (PostToolUse + Stop) from ${path}`,
491
+ backupPath, actions,
492
+ };
493
+ }
494
+ /** Read-only continuity status for `flair hook status` — the same report
495
+ * doctor's check consumes, resolved through the harness's settings path. */
496
+ export function continuityHookStatus(homeDir, harness) {
497
+ // hookSettingsPath and checkContinuityCaptureHooks both resolve
498
+ // ~/.claude/settings.json from homeDir; asserting through the harness
499
+ // registry keeps a future second harness from silently reading the wrong
500
+ // file.
501
+ void hookSettingsPath(homeDir, harness);
502
+ return checkContinuityCaptureHooks(homeDir);
503
+ }
@@ -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
+ }