@tpsdev-ai/flair 0.45.0 → 0.46.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.
@@ -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
@@ -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
  }
@@ -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
+ }