@tpsdev-ai/flair 0.44.13 → 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.
- package/config.yaml +35 -2
- package/dist/cli.js +410 -21
- package/dist/doctor-client.js +266 -0
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/lib/mcp-enable.js +59 -19
- package/dist/lib/safe-snapshot-extract.js +20 -2
- package/dist/lib/scheduler-platform.js +210 -1
- package/dist/rem/scheduler.js +113 -13
- package/dist/rem/snapshot.js +14 -13
- package/dist/resources/Memory.js +32 -1
- package/dist/resources/MemoryBootstrap.js +88 -51
- package/dist/resources/MemoryFeed.js +56 -1
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/abstention.js +12 -9
- package/dist/resources/auth-middleware.js +11 -3
- package/dist/resources/bm25.js +7 -3
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/memory-visibility.js +48 -0
- package/dist/resources/semantic-retrieval-core.js +99 -19
- package/dist/src/lib/scheduler-platform.js +210 -1
- package/dist/src/rem/scheduler.js +113 -13
- package/docs/notes/mcp-oauth-model2.md +10 -3
- package/docs/quickstart-fabric.md +42 -3
- package/package.json +1 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/doctor-client.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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(`
|
|
475
|
-
lines.push(`which
|
|
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
|
}
|
package/dist/hook-install.js
CHANGED
|
@@ -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
|
+
}
|