@tpsdev-ai/flair 0.28.0 → 0.30.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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/bridges/builtins/index.js +1 -1
  3. package/dist/bridges/discover.js +1 -1
  4. package/dist/bridges/runtime/load-plugin.js +1 -1
  5. package/dist/bridges/scaffold.js +3 -3
  6. package/dist/bridges/types.js +1 -1
  7. package/dist/cli-shim.cjs +1 -1
  8. package/dist/cli.js +357 -130
  9. package/dist/deploy.js +29 -19
  10. package/dist/fabric-upgrade.js +61 -23
  11. package/dist/fleet-verify.js +3 -3
  12. package/dist/lib/mcp-enable.js +2 -2
  13. package/dist/rem/runner.js +2 -2
  14. package/dist/rem/scheduler.js +226 -17
  15. package/dist/resources/A2AAdapter.js +1 -1
  16. package/dist/resources/Admin.js +1 -1
  17. package/dist/resources/AdminConnectors.js +1 -1
  18. package/dist/resources/AdminDashboard.js +1 -1
  19. package/dist/resources/AdminIdp.js +1 -1
  20. package/dist/resources/AdminInstance.js +1 -1
  21. package/dist/resources/AdminMemory.js +1 -1
  22. package/dist/resources/AdminPrincipals.js +1 -1
  23. package/dist/resources/Agent.js +1 -1
  24. package/dist/resources/AgentCard.js +1 -1
  25. package/dist/resources/AgentSeed.js +1 -1
  26. package/dist/resources/AttentionQuery.js +1 -1
  27. package/dist/resources/Credential.js +1 -1
  28. package/dist/resources/Federation.js +1 -1
  29. package/dist/resources/Instance.js +1 -1
  30. package/dist/resources/Integration.js +1 -1
  31. package/dist/resources/MCPClientMetadata.js +1 -1
  32. package/dist/resources/Memory.js +1 -1
  33. package/dist/resources/MemoryBootstrap.js +1 -1
  34. package/dist/resources/MemoryCandidate.js +168 -0
  35. package/dist/resources/MemoryConsolidate.js +1 -1
  36. package/dist/resources/MemoryDedupStats.js +1 -1
  37. package/dist/resources/MemoryFeed.js +1 -1
  38. package/dist/resources/MemoryGrant.js +1 -1
  39. package/dist/resources/MemoryMaintenance.js +1 -1
  40. package/dist/resources/MemoryReflect.js +3 -3
  41. package/dist/resources/MemoryReindex.js +1 -1
  42. package/dist/resources/MemoryUsage.js +1 -1
  43. package/dist/resources/OAuth.js +1 -1
  44. package/dist/resources/OAuthClient.js +1 -1
  45. package/dist/resources/OrgEvent.js +1 -1
  46. package/dist/resources/OrgEventCatchup.js +1 -1
  47. package/dist/resources/OrgEventMaintenance.js +1 -1
  48. package/dist/resources/PairingToken.js +1 -1
  49. package/dist/resources/Peer.js +1 -1
  50. package/dist/resources/Presence.js +3 -3
  51. package/dist/resources/RecordUsage.js +16 -2
  52. package/dist/resources/Relationship.js +1 -1
  53. package/dist/resources/SemanticSearch.js +1 -1
  54. package/dist/resources/SkillScan.js +1 -1
  55. package/dist/resources/Soul.js +1 -1
  56. package/dist/resources/SoulFeed.js +1 -1
  57. package/dist/resources/WorkspaceLatest.js +1 -1
  58. package/dist/resources/WorkspaceState.js +1 -1
  59. package/dist/resources/XAA.js +1 -1
  60. package/dist/resources/a2a-url.js +1 -1
  61. package/dist/resources/agent-auth.js +1 -1
  62. package/dist/resources/agentcard-fields.js +1 -1
  63. package/dist/resources/auth-middleware.js +1 -1
  64. package/dist/resources/embeddings-boot.js +4 -4
  65. package/dist/resources/embeddings-provider.js +13 -43
  66. package/dist/resources/federation-cleanup.js +5 -5
  67. package/dist/resources/federation-nonce-store.js +5 -5
  68. package/dist/resources/health.js +29 -6
  69. package/dist/resources/instance-identity.js +1 -1
  70. package/dist/resources/mcp-client-metadata-fields.js +1 -1
  71. package/dist/resources/mcp-handler.js +1 -1
  72. package/dist/resources/mcp-oauth.js +2 -2
  73. package/dist/resources/mcp-tools.js +9 -1
  74. package/dist/resources/memory-read-scope.js +1 -1
  75. package/dist/resources/memory-reflect-lib.js +4 -4
  76. package/dist/resources/memory-visibility.js +3 -3
  77. package/dist/resources/migration-boot.js +1 -1
  78. package/dist/resources/migrations/embedding-stamp.js +3 -3
  79. package/dist/resources/migrations/graph-heal.js +2 -2
  80. package/dist/resources/migrations/ledger.js +1 -1
  81. package/dist/resources/migrations/registry.js +14 -5
  82. package/dist/resources/migrations/runner.js +64 -11
  83. package/dist/resources/migrations/synthetic-test-migration.js +1 -1
  84. package/dist/resources/migrations/visibility-backfill.js +232 -0
  85. package/dist/resources/models-dir.js +55 -0
  86. package/dist/resources/record-types.js +30 -2
  87. package/dist/resources/rerank-provider.js +24 -4
  88. package/dist/resources/semantic-retrieval-core.js +1 -1
  89. package/dist/resources/usage-recording.js +73 -7
  90. package/dist/resources/version.js +33 -0
  91. package/dist/src/rem/scheduler.js +449 -0
  92. package/docs/assets/flair-cross-orchestrator.cast +1 -1
  93. package/docs/bridges.md +3 -3
  94. package/docs/deployment.md +1 -0
  95. package/docs/mcp-clients.md +3 -1
  96. package/docs/n8n.md +1 -2
  97. package/docs/notes/rem-ux.md +1 -1
  98. package/docs/rem.md +2 -0
  99. package/docs/supply-chain-policy.md +4 -4
  100. package/docs/upgrade.md +2 -2
  101. package/package.json +4 -4
  102. package/schemas/memory.graphql +27 -12
package/dist/deploy.js CHANGED
@@ -156,28 +156,38 @@ export function deriveVerifyResources(packageRoot) {
156
156
  names.sort();
157
157
  return names.length ? names : [FALLBACK_VERIFY_RESOURCE];
158
158
  }
159
+ // flair#870: flair declares the BARE `harper` package name, so that is probed
160
+ // first. `@harperfast/harper` is a permanent lockstep publish of the same
161
+ // source under the legacy scoped name; it stays in the probe list because
162
+ // `packageRoot` can be a staged install of an OLDER published flair (see
163
+ // src/fabric-upgrade.ts) that declared the scoped name.
164
+ const HARPER_PKG_NAMES = ["harper", "@harperfast/harper"];
159
165
  function resolveHarperBin(packageRoot) {
160
- const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
161
- if (existsSync(local))
162
- return local;
163
- try {
164
- const req = createRequire(join(packageRoot, "package.json"));
165
- const mainPath = req.resolve("@harperfast/harper");
166
- let dir = dirname(mainPath);
167
- for (let i = 0; i < 6; i++) {
168
- const candidate = join(dir, "dist/bin/harper.js");
169
- if (existsSync(candidate))
170
- return candidate;
171
- const parent = dirname(dir);
172
- if (parent === dir)
173
- break;
174
- dir = parent;
175
- }
166
+ for (const pkg of HARPER_PKG_NAMES) {
167
+ const local = join(packageRoot, "node_modules", pkg, "dist/bin/harper.js");
168
+ if (existsSync(local))
169
+ return local;
176
170
  }
177
- catch {
178
- /* fall through */
171
+ for (const pkg of HARPER_PKG_NAMES) {
172
+ try {
173
+ const req = createRequire(join(packageRoot, "package.json"));
174
+ const mainPath = req.resolve(pkg);
175
+ let dir = dirname(mainPath);
176
+ for (let i = 0; i < 6; i++) {
177
+ const candidate = join(dir, "dist/bin/harper.js");
178
+ if (existsSync(candidate))
179
+ return candidate;
180
+ const parent = dirname(dir);
181
+ if (parent === dir)
182
+ break;
183
+ dir = parent;
184
+ }
185
+ }
186
+ catch {
187
+ /* try the next package name */
188
+ }
179
189
  }
180
- throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
190
+ throw new Error(`Could not locate Harper CLI binary (tried ${HARPER_PKG_NAMES.join(", ")}). ` +
181
191
  "Flair deploy requires Harper to be installed alongside Flair.");
182
192
  }
183
193
  // Pure arg-array builder — separated from spawnHarper so the timeout
@@ -9,9 +9,9 @@
9
9
  * Upgrading a deployed Fabric Flair used to require, by hand:
10
10
  * 1. mkdir a fresh temp dir
11
11
  * 2. write a package.json that depends on @tpsdev-ai/flair@<version> AND
12
- * carries an `overrides` block pinning @harperfast/harper to a fixed
12
+ * carries an `overrides` block pinning harper to a fixed
13
13
  * version — because the PUBLISHED flair declares an OLD Harper
14
- * (@harperfast/harper@5.0.21 as of flair@0.14.0) whose component
14
+ * (harper@5.0.21 as of flair@0.14.0) whose component
15
15
  * packager (`packageComponent`) emits an EMPTY tarball when the package
16
16
  * root lives under node_modules (the real npm-install scenario).
17
17
  * See flair#513 — fixed in Harper >= 5.1.13.
@@ -31,7 +31,7 @@ import { join } from "node:path";
31
31
  import { tmpdir } from "node:os";
32
32
  import { createRequire } from "node:module";
33
33
  /**
34
- * Minimum @harperfast/harper version whose component packager works when the
34
+ * Minimum harper version whose component packager works when the
35
35
  * package root is under node_modules (flair#513 — the empty-tarball fix landed
36
36
  * in 5.1.13). Anything below this MUST be overridden before a Fabric deploy.
37
37
  */
@@ -43,7 +43,18 @@ export const MIN_HARPER_VERSION = "5.1.13";
43
43
  */
44
44
  export const DEFAULT_HARPER_PIN = "5.1.14";
45
45
  const FLAIR_PKG = "@tpsdev-ai/flair";
46
- const HARPER_PKG = "@harperfast/harper";
46
+ const HARPER_PKG = "harper";
47
+ /**
48
+ * flair#870 renamed flair's Harper dependency from the scoped
49
+ * `@harperfast/harper` to the BARE `harper` name (permanent lockstep publishes
50
+ * of the same source; bare is upstream's primary public name).
51
+ *
52
+ * This module reads and rewrites the dependency graph of a PUBLISHED flair
53
+ * version, which may predate that rename — so every lookup here has to accept
54
+ * either name. The bare name is always preferred when both are present.
55
+ */
56
+ const LEGACY_HARPER_PKG = "@harperfast/harper";
57
+ const HARPER_PKG_NAMES = [HARPER_PKG, LEGACY_HARPER_PKG];
47
58
  /**
48
59
  * Parse "5.1.14" / "5.1.14-rc.1" / "v0.14.0" into [major, minor, patch],
49
60
  * ignoring any pre-release / build suffix for comparison. Returns null when the
@@ -76,7 +87,7 @@ export function semverGte(a, b) {
76
87
  return true; // equal
77
88
  }
78
89
  /**
79
- * Decide what (if any) @harperfast/harper override to bake into the temp
90
+ * Decide what (if any) harper override to bake into the temp
80
91
  * deployable's package.json.
81
92
  *
82
93
  * - If flair already declares Harper >= MIN_HARPER_VERSION → no override
@@ -88,7 +99,7 @@ export function semverGte(a, b) {
88
99
  * passed something too old.
89
100
  *
90
101
  * Pure — no I/O. The LOAD-BEARING bit is `overridden`: when true, the temp
91
- * package.json gets `overrides: { "@harperfast/harper": pin }`.
102
+ * package.json gets `overrides: { "harper": pin }`.
92
103
  */
93
104
  export function resolveHarperPin(declared, preferredPin) {
94
105
  if (declared && semverGte(declared, MIN_HARPER_VERSION)) {
@@ -120,6 +131,13 @@ export function resolveHarperPin(declared, preferredPin) {
120
131
  * @tpsdev-ai/flair@<version> and, when the Harper pin is an override, carries
121
132
  * the `overrides` block that forces npm to install the fix Harper under the
122
133
  * flair package's node_modules.
134
+ *
135
+ * The override is written under BOTH Harper package names (flair#870). Only
136
+ * flair versions OLDER than the rename ever need an override — MIN_HARPER_VERSION
137
+ * is 5.1.13 and every post-rename flair declares ≥ 5.1.22 — and those declare
138
+ * the scoped name, so dropping the legacy key would make the override a silent
139
+ * no-op exactly when it matters. Both are plain VERSION overrides, not `npm:`
140
+ * aliases; an override key that matches nothing in the tree is inert.
123
141
  */
124
142
  export function buildDeployablePackageJson(flairVersion, pin) {
125
143
  const pkg = {
@@ -131,7 +149,7 @@ export function buildDeployablePackageJson(flairVersion, pin) {
131
149
  },
132
150
  };
133
151
  if (pin.overridden) {
134
- pkg.overrides = { [HARPER_PKG]: pin.pin };
152
+ pkg.overrides = Object.fromEntries(HARPER_PKG_NAMES.map((n) => [n, pin.pin]));
135
153
  }
136
154
  return pkg;
137
155
  }
@@ -156,7 +174,14 @@ async function defaultFetchDeclaredHarperVersion(flairVersion) {
156
174
  if (!res.ok)
157
175
  return null;
158
176
  const data = (await res.json());
159
- return data.dependencies?.[HARPER_PKG] ?? null;
177
+ // Either package name (flair#870) — pre-rename flair versions declare the
178
+ // scoped one, and those are precisely the versions that may need an override.
179
+ for (const name of HARPER_PKG_NAMES) {
180
+ const declared = data.dependencies?.[name];
181
+ if (declared)
182
+ return declared;
183
+ }
184
+ return null;
160
185
  }
161
186
  function defaultNpmInstall(dir) {
162
187
  // No package spec on argv — npm reads dependencies + overrides from the
@@ -205,13 +230,22 @@ async function defaultFetchDeployedVersion(opts) {
205
230
  return null;
206
231
  }
207
232
  }
208
- /** Resolve the @harperfast/harper bin that npm installed under the staged flair. */
233
+ /**
234
+ * Resolve the harper bin that npm installed under the staged flair.
235
+ *
236
+ * Probes both Harper package names (flair#870): the staged flair may be a
237
+ * pre-rename published version whose Harper landed under `@harperfast/harper`.
238
+ */
209
239
  export function resolveStagedHarperVersion(stagingDir) {
210
- const harperPkgJson = join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", HARPER_PKG, "package.json");
211
- // npm dedupes: with an override the fixed Harper may hoist to the staging
212
- // root rather than nest under flair. Check both.
213
- const hoisted = join(stagingDir, "node_modules", HARPER_PKG, "package.json");
214
- for (const candidate of [harperPkgJson, hoisted]) {
240
+ const candidates = [];
241
+ for (const name of HARPER_PKG_NAMES) {
242
+ // Nested under the staged flair
243
+ candidates.push(join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", name, "package.json"));
244
+ // …or hoisted: npm dedupes, and with an override the fixed Harper may sit
245
+ // at the staging root rather than nest under flair. Check both.
246
+ candidates.push(join(stagingDir, "node_modules", name, "package.json"));
247
+ }
248
+ for (const candidate of candidates) {
215
249
  if (existsSync(candidate)) {
216
250
  try {
217
251
  const pkg = JSON.parse(readFileSync(candidate, "utf8"));
@@ -224,16 +258,20 @@ export function resolveStagedHarperVersion(stagingDir) {
224
258
  }
225
259
  }
226
260
  // Last resort: resolve through the staged flair's module graph.
227
- try {
228
- const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
229
- const req = createRequire(flairPkg);
230
- const resolved = req.resolve(`${HARPER_PKG}/package.json`);
231
- const pkg = JSON.parse(readFileSync(resolved, "utf8"));
232
- return typeof pkg.version === "string" ? pkg.version : null;
233
- }
234
- catch {
235
- return null;
261
+ const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
262
+ for (const name of HARPER_PKG_NAMES) {
263
+ try {
264
+ const req = createRequire(flairPkg);
265
+ const resolved = req.resolve(`${name}/package.json`);
266
+ const pkg = JSON.parse(readFileSync(resolved, "utf8"));
267
+ if (typeof pkg.version === "string")
268
+ return pkg.version;
269
+ }
270
+ catch {
271
+ /* try the next package name */
272
+ }
236
273
  }
274
+ return null;
237
275
  }
238
276
  /** Locate the staged @tpsdev-ai/flair package root (the deploy() packageRoot). */
239
277
  export function resolveStagedFlairRoot(stagingDir) {
@@ -13,11 +13,11 @@
13
13
  * ── What "peer" means here — read before trusting a green sweep ──────────
14
14
  * Harper Fabric's OWN cluster-replication peers (the physical nodes a
15
15
  * component gets replicated to by `harper deploy`) are NOT enumerable
16
- * through any operation in the OSS @harperfast/harper build this repo
16
+ * through any operation in the OSS harper build this repo
17
17
  * depends on. `cluster_status` — the operation that would answer "what
18
18
  * nodes are in this cluster and are they in sync" — is explicitly called
19
- * out as harper-pro-only in Harper's own source (node_modules/@harperfast/
20
- * harper/components/mcp/tools/schemas/operationDescriptions.ts: "Out-of-core
19
+ * out as harper-pro-only in Harper's own source (node_modules/harper/
20
+ * components/mcp/tools/schemas/operationDescriptions.ts: "Out-of-core
21
21
  * operations (e.g. harper-pro's cluster_status) cannot have entries here").
22
22
  * There is no "list this cluster's replication nodes" call available to this
23
23
  * CLI, on the origin or anywhere else — direct AND via-origin cluster
@@ -70,9 +70,9 @@
70
70
  *
71
71
  * ── Ground truth used to design the remote "existing ops paths" step ────────
72
72
  * Verified against the ACTUALLY INSTALLED packages, not assumed:
73
- * - `@harperfast/harper`'s Operations API has a genuine `set_configuration`
73
+ * - `harper`'s Operations API has a genuine `set_configuration`
74
74
  * operation (writes harperdb-config.yaml for all workers, requires a
75
- * restart to take effect — node_modules/@harperfast/harper/dist/config/
75
+ * restart to take effect — node_modules/harper/dist/config/
76
76
  * configUtils.js's `setConfiguration`) and a genuine `restart` operation
77
77
  * (whole-process restart — see .../components/mcp/tools/schemas/
78
78
  * operationDescriptions.js's operation catalog). Both are called the
@@ -20,8 +20,8 @@
20
20
  * - Steps 1, 2 shipped in slice-1 PR-1 (#414).
21
21
  * - Step 3 (maintenance) shipped in a prior slice-2 PR — fills
22
22
  * `archived`/`expired` in the audit row.
23
- * - Step 4: per specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md § 3B,
24
- * "Deferred from parent § 4 step 4" — trust tiers aren't derivable yet
23
+ * - Step 4: per §3B (issue #707), "Deferred from parent § 4 step 4" —
24
+ * trust tiers aren't derivable yet
25
25
  * (that's the emergent-trust arc). The input filter stays as today (own
26
26
  * agent, non-archived, non-permanent, scope window); the safety net for
27
27
  * un-tiered input is structural — candidates are staged, never
@@ -16,7 +16,7 @@
16
16
  import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync } from "node:fs";
17
17
  import { resolve, dirname } from "node:path";
18
18
  import { homedir, platform } from "node:os";
19
- import { spawnSync } from "node:child_process";
19
+ import { spawn, spawnSync } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
22
22
  export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
@@ -102,10 +102,16 @@ function writeFileWithDir(path, contents, mode = 0o600) {
102
102
  // 30s ceiling on launchctl/systemctl invocations so a hung service manager
103
103
  // can't block the CLI indefinitely. Sherlock #415 follow-up.
104
104
  const SPAWN_TIMEOUT_MS = 30_000;
105
- function spawnReport(cmd) {
105
+ // Status-check spawns (launchctl print / systemctl is-active) return
106
+ // near-instantly when the service manager is reachable, and fail fast (no
107
+ // hang) when it isn't (e.g. "Failed to connect to bus"). A short ceiling
108
+ // keeps `flair rem nightly status` and the Health endpoint responsive even
109
+ // when the query is inconclusive.
110
+ const STATUS_CHECK_TIMEOUT_MS = 5_000;
111
+ function spawnReport(cmd, timeoutMs = SPAWN_TIMEOUT_MS) {
106
112
  const r = spawnSync(cmd[0], cmd.slice(1), {
107
113
  encoding: "buffer",
108
- timeout: SPAWN_TIMEOUT_MS,
114
+ timeout: timeoutMs,
109
115
  });
110
116
  return {
111
117
  code: r.status,
@@ -113,6 +119,195 @@ function spawnReport(cmd) {
113
119
  stderr: r.stderr?.toString("utf-8") ?? "",
114
120
  };
115
121
  }
122
+ /**
123
+ * Command to genuinely query the platform scheduler's active/loaded state
124
+ * for a given install (as opposed to the shape of `loadCommand`, which
125
+ * *installs* it). Shared by the sync and async active-state checks below so
126
+ * the two can't drift.
127
+ */
128
+ function activeCheckCommand(plat) {
129
+ if (plat === "darwin") {
130
+ return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/dev.flair.rem.nightly`];
131
+ }
132
+ return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
133
+ }
134
+ /**
135
+ * Interprets the result of `activeCheckCommand()`. Shared by the sync
136
+ * (CLI) and async (server) callers.
137
+ *
138
+ * - true — the service manager confirms the job is loaded/active.
139
+ * - false — confirmed NOT active. This includes the "no session bus" case
140
+ * (flair#850): when `systemctl --user` can't reach a bus, it fails
141
+ * before printing a status word ("Failed to connect to bus: No medium
142
+ * found") — but nothing CAN be running without a bus, so `false` is the
143
+ * honest answer, not "unknown".
144
+ * - null — genuinely inconclusive (the command itself couldn't run at
145
+ * all — e.g. the binary is missing — with no output to interpret).
146
+ */
147
+ export function interpretActiveResult(plat, code, stdout, stderr) {
148
+ const noOutput = !stdout.trim() && !stderr.trim();
149
+ if (plat === "darwin") {
150
+ if (code === 0)
151
+ return true;
152
+ if (code === null && noOutput)
153
+ return null; // spawn itself failed — inconclusive
154
+ return false; // launchctl ran and reported not-loaded
155
+ }
156
+ const out = stdout.trim();
157
+ if (out === "active" || out === "activating")
158
+ return true;
159
+ if (out === "inactive" || out === "failed" || out === "unknown")
160
+ return false;
161
+ if (code === null && noOutput)
162
+ return null; // spawn itself failed — inconclusive
163
+ return false; // covers the no-bus case: empty stdout, nonzero/failed exit
164
+ }
165
+ /**
166
+ * Synchronous active-state check for CLI use (`flair rem nightly status`).
167
+ * Blocking is fine here — this is a one-shot process and the caller wants
168
+ * the answer before printing anything.
169
+ */
170
+ function queryActiveState(plat) {
171
+ const [cmd, ...args] = activeCheckCommand(plat);
172
+ const r = spawnReport([cmd, ...args], STATUS_CHECK_TIMEOUT_MS);
173
+ return interpretActiveResult(plat, r.code, r.stdout, r.stderr);
174
+ }
175
+ /**
176
+ * Async, non-blocking equivalent for server contexts (the Health endpoint)
177
+ * where a synchronous subprocess would stall the request-handling thread.
178
+ * Same semantics as `queryActiveState()`.
179
+ */
180
+ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEOUT_MS) {
181
+ const [cmd, ...args] = activeCheckCommand(plat);
182
+ return new Promise((resolvePromise) => {
183
+ let settled = false;
184
+ let child;
185
+ const finish = (result) => {
186
+ if (settled)
187
+ return;
188
+ settled = true;
189
+ clearTimeout(timer);
190
+ resolvePromise(result);
191
+ };
192
+ const timer = setTimeout(() => {
193
+ try {
194
+ child?.kill("SIGKILL");
195
+ }
196
+ catch { /* best-effort */ }
197
+ finish(null);
198
+ }, timeoutMs);
199
+ try {
200
+ child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
201
+ }
202
+ catch {
203
+ finish(null);
204
+ return;
205
+ }
206
+ let stdout = "";
207
+ let stderr = "";
208
+ child.stdout?.on("data", (d) => { stdout += d.toString("utf-8"); });
209
+ child.stderr?.on("data", (d) => { stderr += d.toString("utf-8"); });
210
+ child.on("error", () => finish(null));
211
+ child.on("close", (code) => finish(interpretActiveResult(plat, code, stdout, stderr)));
212
+ });
213
+ }
214
+ /**
215
+ * Human remedy text for a failed scheduler-load attempt (flair#850). Covers
216
+ * the one root cause traced so far: a missing systemd user session bus,
217
+ * which blocks `systemctl --user` entirely in ssh-without-lingering,
218
+ * container, and CI contexts. Returns null when the failure doesn't match a
219
+ * known pattern — the caller already prints the raw stderr, so the operator
220
+ * still has something to go on.
221
+ */
222
+ export function describeLoadFailure(plat, loadResult) {
223
+ const stderr = loadResult.stderr || "";
224
+ if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
225
+ return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
226
+ "in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
227
+ "— then re-run `flair rem nightly enable`.");
228
+ }
229
+ return null;
230
+ }
231
+ /**
232
+ * Formats the `flair rem nightly enable` report from an `EnableResult`.
233
+ * Pulled out of the CLI action so the success-vs-failure decision (flair#850:
234
+ * do not print a success headline before activation is known to have
235
+ * succeeded) is unit-testable without spawning a real launchctl/systemctl or
236
+ * parsing CLI argv.
237
+ *
238
+ * `r.loadResult` is only set when the load command actually ran (the CLI
239
+ * never sets `skipLoad`). A missing `loadResult` (test-only path) is treated
240
+ * as success — matches the CLI's real-world behavior, which always runs the
241
+ * load command and therefore always gets a `loadResult`.
242
+ */
243
+ export function formatEnableReport(r, input) {
244
+ const { hour, minute, agentId, flairUrl } = input;
245
+ const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
246
+ const activationFailed = !!r.loadResult && r.loadResult.code !== 0;
247
+ if (activationFailed) {
248
+ const lr = r.loadResult;
249
+ const lines = [
250
+ `⚠️ REM nightly scheduler files written but NOT activated (${r.platform})`,
251
+ ` Schedule: ${scheduleTime} local time — NOT scheduled (see below)`,
252
+ ` Scheduler: ${r.schedulerPath}`,
253
+ ` Shim: ${r.shimPath}`,
254
+ ` Agent: ${agentId}`,
255
+ ` Flair URL: ${flairUrl}`,
256
+ ` Activation: ${r.loadCommand.join(" ")} → code ${lr.code}`,
257
+ ];
258
+ if (lr.stderr)
259
+ lines.push(` stderr: ${lr.stderr.trim()}`);
260
+ const remedy = describeLoadFailure(r.platform, lr);
261
+ lines.push("");
262
+ lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
263
+ lines.push("");
264
+ lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
265
+ return { lines, ok: false };
266
+ }
267
+ const lines = [
268
+ `✅ REM nightly scheduler enabled (${r.platform})`,
269
+ ` Schedule: ${scheduleTime} local time`,
270
+ ` Scheduler: ${r.schedulerPath}`,
271
+ ` Shim: ${r.shimPath}`,
272
+ ` Agent: ${agentId}`,
273
+ ` Flair URL: ${flairUrl}`,
274
+ ];
275
+ if (r.loadResult) {
276
+ lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
277
+ }
278
+ lines.push("");
279
+ lines.push(`Tip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
280
+ lines.push(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
281
+ return { lines, ok: true };
282
+ }
283
+ /**
284
+ * Formats the `flair rem nightly status` report from a `SchedulerStatus`.
285
+ * Extracted for the same testability reason as `formatEnableReport()`
286
+ * (flair#850): status must report genuine active state, not file presence.
287
+ */
288
+ export function formatStatusReport(s) {
289
+ const activeTxt = s.active === true ? "yes" : s.active === false ? "no" : "unknown";
290
+ const lines = [
291
+ `REM nightly scheduler (${s.platform}):`,
292
+ ` Active: ${activeTxt}`,
293
+ ` Installed: ${s.installed ? "yes" : "no"}`,
294
+ ` Scheduler: ${s.schedulerPath}`,
295
+ ` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`,
296
+ ];
297
+ if (!s.installed) {
298
+ lines.push("");
299
+ lines.push(`Enable with: flair rem nightly enable --agent <id> [--at HH:MM]`);
300
+ }
301
+ else if (s.active === false) {
302
+ lines.push("");
303
+ lines.push(`Files are written but nothing is scheduled — the job is not loaded/active.`);
304
+ lines.push(`Re-run: flair rem nightly enable --agent <id> [--at HH:MM]`);
305
+ }
306
+ // Status is informational — it does not itself signal process failure,
307
+ // consistent with the pre-existing "not installed" case never exiting
308
+ // nonzero. `ok` here only reflects whether the headline claims success.
309
+ return { lines, ok: s.active !== false };
310
+ }
116
311
  /**
117
312
  * Installs the platform-native scheduler entry and the shim script.
118
313
  *
@@ -215,26 +410,40 @@ export function disableScheduler(opts = {}) {
215
410
  return { platform: plat, removed, unloadCommand, unloadResult };
216
411
  }
217
412
  /**
218
- * Reports whether the scheduler is installed. Filesystem-only does not
219
- * shell out to launchctl/systemctl to query active state. The Health
220
- * endpoint already does that via existence checks; same approach here.
413
+ * Reports whether the scheduler is installed AND whether it is genuinely
414
+ * active. File presence alone proves the templates were written — it does
415
+ * NOT prove `launchctl`/`systemctl` successfully loaded the job (flair#850).
221
416
  */
222
417
  export function schedulerStatus(opts = {}) {
223
418
  const plat = detectPlatform(opts.platformOverride);
419
+ const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
420
+ let schedulerPath;
421
+ let installed;
224
422
  if (plat === "darwin") {
225
- return {
226
- platform: plat,
227
- installed: existsSync(LAUNCHD_PLIST_PATH),
228
- schedulerPath: LAUNCHD_PLIST_PATH,
229
- shimPath: SHIM_PATH_DEFAULT,
230
- shimExists: existsSync(SHIM_PATH_DEFAULT),
231
- };
423
+ schedulerPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
424
+ installed = existsSync(schedulerPath);
425
+ }
426
+ else {
427
+ schedulerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
428
+ const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
429
+ installed = existsSync(schedulerPath) && existsSync(servicePath);
430
+ }
431
+ let active;
432
+ if (!installed) {
433
+ active = false; // nothing written — definitely nothing active
434
+ }
435
+ else if (opts.skipActiveCheck) {
436
+ active = null; // caller opted out — unknown, not a claim either way
437
+ }
438
+ else {
439
+ active = queryActiveState(plat);
232
440
  }
233
441
  return {
234
442
  platform: plat,
235
- installed: existsSync(SYSTEMD_TIMER_PATH) && existsSync(SYSTEMD_SERVICE_PATH),
236
- schedulerPath: SYSTEMD_TIMER_PATH,
237
- shimPath: SHIM_PATH_DEFAULT,
238
- shimExists: existsSync(SHIM_PATH_DEFAULT),
443
+ installed,
444
+ active,
445
+ schedulerPath,
446
+ shimPath,
447
+ shimExists: existsSync(shimPath),
239
448
  };
240
449
  }
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { access, readFile, readdir } from "node:fs/promises";
3
3
  import { constants } from "node:fs";
4
4
  import { basename, extname, join } from "node:path";
@@ -1,4 +1,4 @@
1
- import { Resource } from "@harperfast/harper";
1
+ import { Resource } from "harper";
2
2
  import { allowAdmin } from "./agent-auth.js";
3
3
  /**
4
4
  * GET /Admin — friendly redirect to /AdminDashboard.
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource } from "@harperfast/harper";
1
+ import { Resource } from "harper";
2
2
  import { layout, htmlResponse } from "./admin-layout.js";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
3
3
  import { localInstanceId } from "./instance-identity.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
3
3
  export class AgentCard extends Resource {
4
4
  // Public discovery metadata (per A2A spec, agent cards are intentionally
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Auth: admin only.
18
18
  */
19
- import { Resource, databases } from "@harperfast/harper";
19
+ import { Resource, databases } from "harper";
20
20
  import { isAdmin, allowAdmin } from "./agent-auth.js";
21
21
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
22
22
  name: displayName,
@@ -75,7 +75,7 @@
75
75
  * validated single-entity + bounded-window condition (WorkspaceState), OR
76
76
  * goes through the source's own content-gated resource (Presence).
77
77
  */
78
- import { Resource, databases } from "@harperfast/harper";
78
+ import { Resource, databases } from "harper";
79
79
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
80
80
  import { isValidEntity } from "./entity-vocab.js";
81
81
  import { resolveReadScope } from "./memory-read-scope.js";
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases, server } from "@harperfast/harper";
1
+ import { Resource, databases, server } from "harper";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
4
  import { allowAdmin } from "./agent-auth.js";
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { allowVerified, allowAdmin } from "./agent-auth.js";
3
3
  /**
4
4
  * Instance is read-only reference data for agents (flair_agent grant: read only).