@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
@@ -154,6 +154,272 @@ export function parseLegacySessionStartHookCommand(command) {
154
154
  export function isFlairHookCommand(command) {
155
155
  return typeof command === "string" && command.includes("@tpsdev-ai/flair-mcp") && command.includes(SESSION_START_HOOK_MARKER);
156
156
  }
157
+ // ── the continuity capture hooks (flair#1257 slice 2) ──────────────────────
158
+ //
159
+ // Continuity's write side is a pair of Claude Code hook entries — PostToolUse
160
+ // (mutating tools only, via the matcher below) and Stop — both running the
161
+ // SAME `flair-continuity-capture` binary shipped by @tpsdev-ai/flair-mcp.
162
+ // Same ONE-builder discipline as the SessionStart command above (#1007):
163
+ // every path that writes these entries (`flair doctor --fix`, `flair hook
164
+ // install --continuity` in src/hook-install.ts) goes through
165
+ // buildContinuityCaptureHookCommand, so the invocation's failure behaviour is
166
+ // defined and tested in one place.
167
+ //
168
+ // Unlike the SessionStart command, this one captures NOTHING to re-emit: a
169
+ // PostToolUse/Stop hook's stdout is harness-interpreted surface and the
170
+ // capture binary never has anything to say to it, so the wrapper discards
171
+ // BOTH streams and absorbs failure (`>/dev/null 2>/dev/null || true`). The
172
+ // same #1007 reasoning applies: if the npx resolution breaks, the silence has
173
+ // to be a property of the command string, because the binary's own fail-open
174
+ // guarantee is behind the door that stopped opening. hookCommandIsSilenced()
175
+ // recognizes this shape unchanged.
176
+ //
177
+ // INSTALLING THESE HOOKS IS THE OPT-IN. There is no env flag: an agent whose
178
+ // settings.json carries the pair journals; one that doesn't, doesn't. Doctor
179
+ // therefore reports "absent" as "not enabled" — informational, never a pass,
180
+ // never a failure (see checkContinuityCaptureHooks / cli.ts's rendering).
181
+ /** The exact substring identifying a Flair continuity-capture hook command. */
182
+ export const CONTINUITY_CAPTURE_HOOK_MARKER = "flair-continuity-capture";
183
+ /**
184
+ * The PostToolUse matcher written alongside our hook entry — the EXACT
185
+ * mutating-tool allowlist the capture binary enforces internally
186
+ * (packages/flair-mcp/src/continuity.ts's MUTATING_TOOLS: Write/Edit/
187
+ * NotebookEdit mutate file/cell state, Bash can mutate anything; read-only
188
+ * tools are world-recoverable and journal nothing). The matcher is an
189
+ * EFFICIENCY (no process spawn for a Read), not the control — the binary's
190
+ * own allowlist is the control and fires regardless of who spawns it.
191
+ */
192
+ export const CONTINUITY_POST_TOOL_USE_MATCHER = "Write|Edit|NotebookEdit|Bash";
193
+ /**
194
+ * Build the exact `command` string registered for BOTH continuity hook events
195
+ * (PostToolUse and Stop run the same binary; the payload's hook_event_name
196
+ * tells it which fired). Same strict value allow-list as the SessionStart
197
+ * builder — throws rather than emitting a quoted approximation.
198
+ */
199
+ export function buildContinuityCaptureHookCommand(agentId, flairUrl) {
200
+ if (!isHookCommandValueSafe(agentId)) {
201
+ throw new Error(`agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
202
+ }
203
+ if (flairUrl != null && flairUrl !== "" && !isHookCommandValueSafe(flairUrl)) {
204
+ throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
205
+ }
206
+ const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
207
+ const invocation = `${env} npx -y -p @tpsdev-ai/flair-mcp ${CONTINUITY_CAPTURE_HOOK_MARKER}`;
208
+ return `sh -c '${invocation} >/dev/null 2>/dev/null || true'`;
209
+ }
210
+ /** Does this command invoke the Flair continuity-capture binary at all? */
211
+ export function isFlairContinuityCommand(command) {
212
+ return (typeof command === "string" &&
213
+ command.includes("@tpsdev-ai/flair-mcp") &&
214
+ command.includes(CONTINUITY_CAPTURE_HOOK_MARKER));
215
+ }
216
+ /** The two hook events continuity registers under. */
217
+ export const CONTINUITY_HOOK_EVENTS = ["PostToolUse", "Stop"];
218
+ function findContinuityEntry(config, event) {
219
+ const groups = config?.hooks?.[event];
220
+ if (!Array.isArray(groups))
221
+ return null;
222
+ for (let gi = 0; gi < groups.length; gi++) {
223
+ const hooks = groups[gi]?.hooks;
224
+ if (!Array.isArray(hooks))
225
+ continue;
226
+ for (let hi = 0; hi < hooks.length; hi++) {
227
+ if (typeof hooks[hi]?.command === "string" && hooks[hi].command.includes(CONTINUITY_CAPTURE_HOOK_MARKER)) {
228
+ return { group: groups[gi], hookIndex: hi, groupIndex: gi };
229
+ }
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+ function continuityEventReport(config, event) {
235
+ const found = findContinuityEntry(config, event);
236
+ if (!found)
237
+ return { present: false, currentForm: false };
238
+ const hook = found.group.hooks[found.hookIndex];
239
+ const command = typeof hook?.command === "string" ? hook.command : "";
240
+ const matcher = typeof found.group?.matcher === "string" ? found.group.matcher : undefined;
241
+ const shapeOk = hook?.type === "command" &&
242
+ command.includes(`npx -y -p @tpsdev-ai/flair-mcp ${CONTINUITY_CAPTURE_HOOK_MARKER}`) &&
243
+ hookCommandIsSilenced(command);
244
+ const matcherOk = event !== "PostToolUse" || matcher === CONTINUITY_POST_TOOL_USE_MATCHER;
245
+ return { present: true, command, matcher, currentForm: shapeOk && matcherOk };
246
+ }
247
+ /**
248
+ * Doctor's check-5 twin of checkSessionStartHook for the continuity pair —
249
+ * pure fs read, no probe. A missing or unparseable settings.json reads as
250
+ * "absent" (not enabled), matching checkSessionStartHook's tolerance.
251
+ */
252
+ export function checkContinuityCaptureHooks(homeDir) {
253
+ const path = join(homeDir, ".claude", "settings.json");
254
+ let config = {};
255
+ const raw = readTextFile(path);
256
+ if (raw && raw.trim()) {
257
+ try {
258
+ config = JSON.parse(raw);
259
+ }
260
+ catch {
261
+ config = {};
262
+ }
263
+ }
264
+ const postToolUse = continuityEventReport(config, "PostToolUse");
265
+ const stop = continuityEventReport(config, "Stop");
266
+ let state;
267
+ if (!postToolUse.present && !stop.present)
268
+ state = "absent";
269
+ else if (!postToolUse.present || !stop.present)
270
+ state = "partial";
271
+ else if (postToolUse.currentForm && stop.currentForm)
272
+ state = "installed";
273
+ else
274
+ state = "stale";
275
+ return { path, postToolUse, stop, state };
276
+ }
277
+ /**
278
+ * Pure merge of the continuity pair into a parsed settings object — the ONE
279
+ * mutation core both write paths (`flair doctor --fix` via
280
+ * fixContinuityCaptureHooks below, `flair hook install --continuity` via
281
+ * src/hook-install.ts) share. Idempotent: re-running with unchanged inputs is
282
+ * a structural no-op. Only OUR entries are ever touched — sibling hooks,
283
+ * groups and keys are preserved byte-identical; a group we don't own keeps
284
+ * its matcher (our binary's internal allowlist still filters — the matcher is
285
+ * an efficiency, not the control).
286
+ */
287
+ export function computeContinuityHookInstall(config, agentId, flairUrl) {
288
+ const command = buildContinuityCaptureHookCommand(agentId, flairUrl);
289
+ const newConfig = JSON.parse(JSON.stringify(config ?? {}));
290
+ const actions = { PostToolUse: "noop", Stop: "noop" };
291
+ let changed = false;
292
+ newConfig.hooks = newConfig.hooks && typeof newConfig.hooks === "object" && !Array.isArray(newConfig.hooks) ? newConfig.hooks : {};
293
+ for (const event of CONTINUITY_HOOK_EVENTS) {
294
+ const existing = findContinuityEntry(newConfig, event);
295
+ if (existing) {
296
+ const hook = existing.group.hooks[existing.hookIndex];
297
+ const soleOwner = existing.group.hooks.length === 1;
298
+ const wantMatcher = event === "PostToolUse" && soleOwner;
299
+ const matcherCurrent = !wantMatcher || existing.group.matcher === CONTINUITY_POST_TOOL_USE_MATCHER;
300
+ if (hook.command === command && hook.type === "command" && matcherCurrent)
301
+ continue;
302
+ existing.group.hooks[existing.hookIndex] = { type: "command", command };
303
+ if (wantMatcher)
304
+ existing.group.matcher = CONTINUITY_POST_TOOL_USE_MATCHER;
305
+ actions[event] = "update";
306
+ changed = true;
307
+ continue;
308
+ }
309
+ newConfig.hooks[event] = Array.isArray(newConfig.hooks[event]) ? newConfig.hooks[event] : [];
310
+ const group = { hooks: [{ type: "command", command }] };
311
+ if (event === "PostToolUse")
312
+ group.matcher = CONTINUITY_POST_TOOL_USE_MATCHER;
313
+ newConfig.hooks[event].push(group);
314
+ actions[event] = "add";
315
+ changed = true;
316
+ }
317
+ return { changed, actions, newConfig };
318
+ }
319
+ /**
320
+ * Pure removal of the continuity pair — deletes ONLY our entries (marker
321
+ * substring match, exactly how install finds them), then prunes any group /
322
+ * event array / `hooks` key left empty by that removal. Never touches
323
+ * anything else.
324
+ */
325
+ export function computeContinuityHookRemoval(config) {
326
+ const newConfig = JSON.parse(JSON.stringify(config ?? {}));
327
+ const actions = { PostToolUse: "noop", Stop: "noop" };
328
+ let changed = false;
329
+ for (const event of CONTINUITY_HOOK_EVENTS) {
330
+ const existing = findContinuityEntry(newConfig, event);
331
+ if (!existing)
332
+ continue;
333
+ existing.group.hooks.splice(existing.hookIndex, 1);
334
+ if (existing.group.hooks.length === 0) {
335
+ newConfig.hooks[event].splice(existing.groupIndex, 1);
336
+ }
337
+ if (newConfig.hooks[event].length === 0) {
338
+ delete newConfig.hooks[event];
339
+ }
340
+ actions[event] = "remove";
341
+ changed = true;
342
+ }
343
+ if (changed && newConfig.hooks && typeof newConfig.hooks === "object" && Object.keys(newConfig.hooks).length === 0) {
344
+ delete newConfig.hooks;
345
+ }
346
+ return { changed, actions, newConfig };
347
+ }
348
+ /**
349
+ * `flair doctor --fix` write path: register (or repair to current form) the
350
+ * continuity pair in ~/.claude/settings.json. Merge-safe read-parse-write,
351
+ * mirroring fixSessionStartHook — creates the file if absent, refuses on a
352
+ * file it cannot parse.
353
+ */
354
+ export function fixContinuityCaptureHooks(homeDir, agentId, flairUrl) {
355
+ const path = join(homeDir, ".claude", "settings.json");
356
+ if (!agentId) {
357
+ return {
358
+ ok: false,
359
+ path,
360
+ changed: false,
361
+ message: "no agent id known — pass --agent <id> (or set FLAIR_AGENT_ID) so doctor knows which agent to wire the continuity hooks to",
362
+ };
363
+ }
364
+ if (!isHookCommandValueSafe(agentId)) {
365
+ return {
366
+ ok: false,
367
+ path,
368
+ changed: false,
369
+ message: `agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`,
370
+ };
371
+ }
372
+ if (flairUrl != null && flairUrl !== "" && !isHookCommandValueSafe(flairUrl)) {
373
+ return {
374
+ ok: false,
375
+ path,
376
+ changed: false,
377
+ message: `Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`,
378
+ };
379
+ }
380
+ try {
381
+ let config = {};
382
+ const raw = readTextFile(path);
383
+ if (raw && raw.trim())
384
+ config = JSON.parse(raw);
385
+ const { changed, newConfig } = computeContinuityHookInstall(config, agentId, flairUrl);
386
+ if (!changed) {
387
+ return { ok: true, path, changed: false, message: `continuity capture hooks already current in ${path}` };
388
+ }
389
+ mkdirSync(dirname(path), { recursive: true });
390
+ writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
391
+ return { ok: true, path, changed: true, message: `wired the continuity capture hooks (PostToolUse + Stop) in ${path} (agent '${agentId}')` };
392
+ }
393
+ catch (err) {
394
+ const reason = err instanceof Error ? err.message : String(err);
395
+ return { ok: false, path, changed: false, message: `could not write ${path}: ${reason}` };
396
+ }
397
+ }
398
+ /**
399
+ * Symmetric removal path (`flair doctor --fix` when disabling / `flair hook
400
+ * uninstall --continuity`). A no-op when nothing is wired — never creates a
401
+ * file that didn't exist, refuses on a file it cannot parse.
402
+ */
403
+ export function removeContinuityCaptureHooks(homeDir) {
404
+ const path = join(homeDir, ".claude", "settings.json");
405
+ const raw = readTextFile(path);
406
+ if (!raw || !raw.trim()) {
407
+ return { ok: true, path, changed: false, message: `no ${path} — continuity capture hooks are not enabled` };
408
+ }
409
+ try {
410
+ const config = JSON.parse(raw);
411
+ const { changed, newConfig } = computeContinuityHookRemoval(config);
412
+ if (!changed) {
413
+ return { ok: true, path, changed: false, message: `no continuity capture hooks found in ${path} — nothing to remove` };
414
+ }
415
+ writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
416
+ return { ok: true, path, changed: true, message: `removed the continuity capture hooks (PostToolUse + Stop) from ${path}` };
417
+ }
418
+ catch (err) {
419
+ const reason = err instanceof Error ? err.message : String(err);
420
+ return { ok: false, path, changed: false, message: `could not update ${path}: ${reason}` };
421
+ }
422
+ }
157
423
  // ── shared helpers ──────────────────────────────────────────────────────────
158
424
  /**
159
425
  * Run `fn` with process.env.HOME temporarily pointed at `homeDir`, then
@@ -188,12 +454,40 @@ function readTextFile(path) {
188
454
  return null;
189
455
  }
190
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
+ }
191
477
  /**
192
478
  * Read the Flair MCP server block from `clientId`'s config file. `present`
193
- * is true only when the block exists AND both FLAIR_AGENT_ID and FLAIR_URL
194
- * are set (non-empty) — a half-wired block (e.g. block present, env missing)
195
- * counts as absent for the pass/fail check, but agentId/flairUrl are still
196
- * 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.
197
491
  */
198
492
  export function readClientMcpBlock(clientId, homeDir) {
199
493
  const configPath = withHome(homeDir, () => clientConfigPath(clientId));
@@ -210,7 +504,11 @@ function readJsonFlairBlock(configPath) {
210
504
  return { present: false, configPath };
211
505
  const agentId = typeof flair.env?.FLAIR_AGENT_ID === "string" && flair.env.FLAIR_AGENT_ID ? flair.env.FLAIR_AGENT_ID : undefined;
212
506
  const flairUrl = typeof flair.env?.FLAIR_URL === "string" && flair.env.FLAIR_URL ? flair.env.FLAIR_URL : undefined;
213
- 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 };
214
512
  }
215
513
  catch {
216
514
  // Malformed JSON — treat as "not present", never throw.
@@ -241,7 +539,55 @@ function readCodexFlairBlock(configPath) {
241
539
  if (!raw)
242
540
  return { present: false, configPath };
243
541
  const scanned = scanCodexFlairBlock(raw);
244
- 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;
245
591
  }
246
592
  function scanCodexFlairBlock(raw) {
247
593
  const startMatch = raw.match(/^\[mcp_servers\.flair\]\s*$/m);
@@ -257,11 +603,12 @@ function scanCodexFlairBlock(raw) {
257
603
  blockLines.push(lines[i]);
258
604
  }
259
605
  const block = blockLines.join("\n");
260
- const agentMatch = block.match(/^\s*FLAIR_AGENT_ID\s*=\s*"([^"]*)"/m);
261
- const urlMatch = block.match(/^\s*FLAIR_URL\s*=\s*"([^"]*)"/m);
262
- const agentId = agentMatch?.[1] || undefined;
263
- const flairUrl = urlMatch?.[1] || undefined;
264
- 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 };
265
612
  }
266
613
  // ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
267
614
  /**
@@ -57,12 +57,12 @@
57
57
  * `flair federation sync --admin-pass-file`, which reads it through
58
58
  * readAdminPassFileSecure() and refuses a file that is not owner-only.
59
59
  */
60
- import { existsSync, chmodSync, rmSync, readFileSync } from "node:fs";
60
+ import { existsSync, chmodSync, rmSync, readFileSync, mkdirSync } from "node:fs";
61
61
  import { resolve, dirname } from "node:path";
62
62
  import { homedir } from "node:os";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { escapeXml } from "../lib/xml-escape.js";
65
- import { detectPlatform as detectPlatformFor, spawnReport, readTemplate, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
65
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
66
66
  export const LAUNCHD_LABEL = "dev.flair.federation.sync";
67
67
  export const SYSTEMD_TIMER_UNIT = "flair-federation-sync.timer";
68
68
  export const SYSTEMD_SERVICE_UNIT = "flair-federation-sync.service";
@@ -132,7 +132,7 @@ export function validateInterval(intervalSeconds) {
132
132
  `For sub-minute latency use \`flair federation watch --interval <s>\` in a foreground session instead.`);
133
133
  }
134
134
  }
135
- function buildSubstitutions(opts, shimPath, flairBin) {
135
+ function buildSubstitutions(opts, shimPath, flairBin, nodeBin) {
136
136
  validateInterval(opts.intervalSeconds);
137
137
  const adminPassFile = opts.adminPassFile ?? "";
138
138
  if (adminPassFile && !existsSync(adminPassFile)) {
@@ -141,6 +141,7 @@ function buildSubstitutions(opts, shimPath, flairBin) {
141
141
  }
142
142
  return {
143
143
  FLAIR_BIN: flairBin,
144
+ NODE_BIN: nodeBin,
144
145
  SHIM_PATH: shimPath,
145
146
  HOME: opts.homeOverride ?? homedir(),
146
147
  INTERVAL_SECONDS: String(opts.intervalSeconds),
@@ -165,9 +166,29 @@ function launchdDomain() {
165
166
  export function enableScheduler(opts) {
166
167
  const plat = detectPlatform(opts.platformOverride);
167
168
  const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
169
+ const nodeBin = resolveNodeBin(opts.nodeBin);
168
170
  const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
169
171
  const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
170
- const subs = buildSubstitutions(opts, shimPath, flairBin);
172
+ const subs = buildSubstitutions(opts, shimPath, flairBin, nodeBin);
173
+ // 0. Create the log directory the unit files point stdout/stderr at.
174
+ // Nothing else ever creates it — launchd kills a job whose StandardOutPath
175
+ // directory is missing (spawn error 209) and systemd fails the unit (#1231).
176
+ //
177
+ // Mode 0700 is load-bearing, NOT cosmetic: this directory also receives
178
+ // REM's nightly log, which carries distillation CANDIDATE CONTENT — actual
179
+ // memory text, not just sync counts and errors. Relaxing it to 0755 (e.g.
180
+ // "for shared debugging") would expose memory content to every local user.
181
+ const logsDir = resolve(subs.HOME, ".flair", "logs");
182
+ try {
183
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
184
+ }
185
+ catch (err) {
186
+ throw new Error(`could not create the scheduler log directory ${logsDir}: ${err?.message ?? err}. ` +
187
+ `The service manager writes the job's stdout/stderr there; without it the first run dies ` +
188
+ `before producing any output. Fix whatever blocks creating that directory, then re-run ` +
189
+ `\`flair federation sync enable\`.`);
190
+ }
191
+ const stderrLogPath = resolve(logsDir, "federation-sync.stderr.log");
171
192
  // 1. Deploy the shim (always — both platforms invoke it).
172
193
  const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-federation-sync.sh.tmpl"), subs);
173
194
  writeFileWithDir(shimPath, shimContents, 0o700);
@@ -179,14 +200,28 @@ export function enableScheduler(opts) {
179
200
  writeFileWithDir(plistPath, plistContents, 0o600);
180
201
  const loadCommand = ["launchctl", "bootstrap", launchdDomain(), plistPath];
181
202
  let loadResult;
203
+ let firstRun;
182
204
  if (!opts.skipLoad) {
183
205
  // Bootout first in case a prior install left the job loaded — this is
184
206
  // what makes re-running enable (e.g. to change --interval) idempotent
185
207
  // rather than a "service already loaded" failure.
186
208
  spawnReport(["launchctl", "bootout", launchdDomain(), plistPath]);
187
209
  loadResult = spawnReport(loadCommand);
210
+ if (loadResult.code === 0) {
211
+ // Ordering gate (#1231): verify the first run ONLY after the load
212
+ // exited 0. A load failure is its own failure mode with its own
213
+ // remedy — kickstarting on top of it would blur which actor failed.
214
+ firstRun = verifyFirstRun({
215
+ plat,
216
+ darwinTarget: `${launchdDomain()}/${LAUNCHD_LABEL}`,
217
+ stderrLogPath,
218
+ });
219
+ }
188
220
  }
189
- return { platform: plat, shimPath, schedulerPath: plistPath, intervalSeconds: opts.intervalSeconds, loadCommand, loadResult };
221
+ return {
222
+ platform: plat, shimPath, schedulerPath: plistPath, intervalSeconds: opts.intervalSeconds,
223
+ loadCommand, loadResult, firstRunVerified: firstRun?.verified === true, firstRun,
224
+ };
190
225
  }
191
226
  // Linux: systemd user units.
192
227
  const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
@@ -197,15 +232,24 @@ export function enableScheduler(opts) {
197
232
  writeFileWithDir(timerPath, timerContents, 0o600);
198
233
  const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
199
234
  let loadResult;
235
+ let firstRun;
200
236
  if (!opts.skipLoad) {
201
237
  spawnReport(["systemctl", "--user", "daemon-reload"]);
202
238
  // Restart so a changed --interval takes effect on re-enable; `enable
203
239
  // --now` alone leaves an already-running timer on its old schedule.
204
240
  loadResult = spawnReport(loadCommand);
205
- if (loadResult.code === 0)
241
+ if (loadResult.code === 0) {
206
242
  spawnReport(["systemctl", "--user", "restart", SYSTEMD_TIMER_UNIT]);
243
+ // Ordering gate (#1231): only after the load exited 0. Starts the
244
+ // SERVICE unit directly (oneshot ⇒ blocks until the run exits) rather
245
+ // than waiting out the timer.
246
+ firstRun = verifyFirstRun({ plat, linuxServiceUnit: SYSTEMD_SERVICE_UNIT, stderrLogPath });
247
+ }
207
248
  }
208
- return { platform: plat, shimPath, schedulerPath: timerPath, intervalSeconds: opts.intervalSeconds, loadCommand, loadResult };
249
+ return {
250
+ platform: plat, shimPath, schedulerPath: timerPath, intervalSeconds: opts.intervalSeconds,
251
+ loadCommand, loadResult, firstRunVerified: firstRun?.verified === true, firstRun,
252
+ };
209
253
  }
210
254
  /** Removes the scheduler entry. Peer records and sync history are untouched. */
211
255
  export function disableScheduler(opts = {}) {
@@ -432,6 +476,14 @@ export function assessDriver(input) {
432
476
  * success-vs-failure decision (flair#850: never print a success headline
433
477
  * before activation is known to have succeeded), extracted from the CLI
434
478
  * action so it is unit-testable without spawning launchctl/systemctl.
479
+ *
480
+ * flair#1231 deepened the #850 rule by one layer: activation exiting 0 proves
481
+ * the service manager ACCEPTED the job, not that the job can run — a stripped
482
+ * exec bit and a missing log directory both passed activation and killed the
483
+ * first real run invisibly. So the ✅ headline is now additionally gated on
484
+ * `firstRunVerified`: success may not be claimed until the thing the operator
485
+ * asked for — a sync run through the service manager — has been observed to
486
+ * happen once.
435
487
  */
436
488
  export function formatEnableReport(r, input) {
437
489
  const activationFailed = !!r.loadResult && r.loadResult.code !== 0;
@@ -459,6 +511,58 @@ export function formatEnableReport(r, input) {
459
511
  lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair federation sync status`);
460
512
  return { lines, ok: false };
461
513
  }
514
+ if (!r.firstRunVerified) {
515
+ const fr = r.firstRun;
516
+ const headline = fr?.outcome === "run-failed"
517
+ ? `⚠️ Federation sync driver installed but the first run FAILED (${describeExitCode(fr.exitCode)})`
518
+ : fr?.outcome === "timeout"
519
+ ? `⚠️ Federation sync driver installed but the first run did not complete within ${Math.round(fr.budgetMs / 1000)}s — cannot confirm it works`
520
+ : fr?.outcome === "manager-unavailable"
521
+ ? `⚠️ Federation sync driver installed but the service manager is unreachable — cannot verify the first run`
522
+ : fr?.outcome === "start-failed"
523
+ ? `⚠️ Federation sync driver installed but the first run could not be started`
524
+ : `⚠️ Federation sync driver installed but the first run was never verified`;
525
+ const lines = [
526
+ headline,
527
+ ` Interval: every ${r.intervalSeconds}s`,
528
+ ` Scheduler: ${r.schedulerPath}`,
529
+ ` Shim: ${r.shimPath}`,
530
+ credLine,
531
+ ];
532
+ if (input.target)
533
+ lines.push(` Target: ${input.target}`);
534
+ if (r.loadResult)
535
+ lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
536
+ if (fr) {
537
+ lines.push(` First run: ${fr.detail}`);
538
+ if (fr.stderrTail) {
539
+ lines.push(` Log tail (${fr.logPath}):`);
540
+ for (const l of fr.stderrTail.split("\n"))
541
+ lines.push(` ${l}`);
542
+ }
543
+ else if (fr.logEmpty) {
544
+ lines.push(` Log file ${fr.logPath} exists but is EMPTY — the run died before writing anything.`);
545
+ }
546
+ else {
547
+ lines.push(` No log file at ${fr.logPath}.`);
548
+ }
549
+ }
550
+ lines.push("");
551
+ if (fr?.outcome === "timeout") {
552
+ lines.push(` The run may legitimately still be going. Check the log above and \`flair federation status\`;`);
553
+ lines.push(` nothing has been CONFIRMED to sync yet.`);
554
+ }
555
+ else if (fr?.outcome === "manager-unavailable") {
556
+ lines.push(` The driver files are installed, but launchctl/systemctl could not be consulted, so whether`);
557
+ lines.push(` sync runs is UNKNOWN. Fix the service manager for this session, then re-run \`flair federation sync enable\`.`);
558
+ }
559
+ else {
560
+ lines.push(` Nothing has synced. Fix the cause above, then re-run \`flair federation sync enable\`.`);
561
+ }
562
+ lines.push("");
563
+ lines.push(` Check anytime with: flair federation sync status`);
564
+ return { lines, ok: false };
565
+ }
462
566
  const lines = [
463
567
  `✅ Federation sync driver enabled (${r.platform})`,
464
568
  ` Interval: every ${r.intervalSeconds}s`,
@@ -470,9 +574,10 @@ export function formatEnableReport(r, input) {
470
574
  lines.push(` Target: ${input.target}`);
471
575
  if (r.loadResult)
472
576
  lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
577
+ lines.push(` First run: completed through the service manager, exit 0`);
473
578
  lines.push("");
474
- lines.push(`The first sync runs immediately. Confirm with \`flair federation status\`,`);
475
- lines.push(`which now reports whether anything is actually driving sync.`);
579
+ lines.push(`Confirm anytime with \`flair federation status\`,`);
580
+ lines.push(`which reports whether anything is actually driving sync.`);
476
581
  lines.push(`Disable with \`flair federation sync disable\`.`);
477
582
  return { lines, ok: true };
478
583
  }