freeharness 0.4.2 → 0.4.3

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 (54) hide show
  1. package/NOTICE +2 -1
  2. package/README.md +20 -16
  3. package/canonical/HARVEST.yaml +2 -2
  4. package/canonical/PROVENANCE.yaml +2 -2
  5. package/canonical/agents/security-reviewer.md +8 -2
  6. package/canonical/hooks/freeharness-config-guard.mjs +644 -4567
  7. package/canonical/hooks/freeharness-cost-tracker.mjs +14 -27
  8. package/canonical/hooks/freeharness-precompact.mjs +12 -23
  9. package/canonical/hooks/freeharness-sdd-context.mjs +12 -23
  10. package/canonical/hooks/hooks.config-guard.claude-code.json +2 -3
  11. package/canonical/hooks/hooks.memory-guard.claude-code.json +2 -3
  12. package/canonical/skills/code-review/SKILL.md +2 -2
  13. package/canonical/skills/code-review/evals/cases.yaml +1 -1
  14. package/canonical/skills/subagent-driven-development/SKILL.md +4 -4
  15. package/canonical/skills/subagent-driven-development/evals/cases.yaml +149 -20
  16. package/canonical/skills/subagent-driven-development/reference/brief.md +15 -13
  17. package/canonical/skills/subagent-driven-development/reference/handoff.md +29 -15
  18. package/canonical/skills/subagent-driven-development/reference/loop.md +4 -5
  19. package/canonical/skills/subagent-driven-development/reference/workspace.md +2 -2
  20. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +301 -1038
  21. package/dist/adapters/harness/claude-code.d.ts +7 -0
  22. package/dist/adapters/harness/claude-code.js +27 -21
  23. package/dist/adapters/harness/opencode.js +1 -1
  24. package/dist/adapters/harness/plugin.js +9 -9
  25. package/dist/adapters/memory/doctor.d.ts +11 -17
  26. package/dist/adapters/memory/doctor.js +55 -68
  27. package/dist/adapters/memory/paths.d.ts +3 -10
  28. package/dist/adapters/memory/paths.js +16 -54
  29. package/dist/adapters/memory/vendored.d.ts +2 -7
  30. package/dist/adapters/memory/vendored.js +12 -57
  31. package/dist/adapters/memory/worker-control.d.ts +9 -0
  32. package/dist/adapters/memory/worker-control.js +30 -0
  33. package/dist/cli/cleanup.d.ts +1 -1
  34. package/dist/cli/cleanup.js +29 -14
  35. package/dist/cli/cost.js +2 -2
  36. package/dist/cli/doctor.d.ts +9 -9
  37. package/dist/cli/doctor.js +21 -16
  38. package/dist/cli/hard-cleanup.js +1 -1
  39. package/dist/cli/install-facts.d.ts +4 -3
  40. package/dist/cli/install-facts.js +3 -1
  41. package/dist/cli/profile.d.ts +1 -1
  42. package/dist/cli/profile.js +10 -10
  43. package/dist/index.js +7 -5
  44. package/dist/infra/fsutil.d.ts +4 -5
  45. package/dist/infra/fsutil.js +5 -6
  46. package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
  47. package/node_modules/@freeharness/core/dist/registry.js +3 -3
  48. package/node_modules/@freeharness/core/package.json +1 -1
  49. package/node_modules/@freeharness/sdd/package.json +1 -1
  50. package/package.json +3 -3
  51. package/provenance/canonical.PROVENANCE.yaml +2 -2
  52. package/provenance/memory.PROVENANCE.yaml +18 -4
  53. package/canonical/openspec/config.base.yaml +0 -89
  54. package/canonical/rules/safety.md +0 -41
@@ -38,6 +38,8 @@ export declare const QUOTA_TAP_SCRIPT = "freeharness-quota-tap.mjs";
38
38
  export declare const HOOK_SCRIPTS: readonly string[];
39
39
  export declare class ClaudeCodeAdapter implements HarnessPort {
40
40
  readonly target: "claude-code";
41
+ private readonly autoMemoryMerged;
42
+ private turnOffAutoMemoryOnce;
41
43
  apply(cap: Capability, ctx: InstallContext): Promise<AdapterResult>;
42
44
  }
43
45
  /**
@@ -46,6 +48,11 @@ export declare class ClaudeCodeAdapter implements HarnessPort {
46
48
  * test can hold both copies to the same answers.
47
49
  */
48
50
  export declare function freeMemEnabled(claudeDir: string): boolean;
51
+ /**
52
+ * free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
53
+ * keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
54
+ */
55
+ export declare function turnOffAutoMemory(settingsPath: string, dry: boolean, res: AdapterResult): void;
49
56
  /**
50
57
  * Reports and, only once confirmed, strips a trailing `[1m]` suffix from the scope's own
51
58
  * `model` value. The emitted defaults never force or replace a model family: a value with no
@@ -83,6 +83,15 @@ export const HOOK_SCRIPTS = [
83
83
  ];
84
84
  export class ClaudeCodeAdapter {
85
85
  target = "claude-code";
86
+ // The runner keeps one adapter per pass, so a later capability case sees the key this pass
87
+ // already merged and would report it as the owner's value.
88
+ autoMemoryMerged = new Set();
89
+ turnOffAutoMemoryOnce(settingsPath, dry, res) {
90
+ if (this.autoMemoryMerged.has(settingsPath))
91
+ return;
92
+ this.autoMemoryMerged.add(settingsPath);
93
+ turnOffAutoMemory(settingsPath, dry, res);
94
+ }
86
95
  async apply(cap, ctx) {
87
96
  const res = { capabilityId: cap.id, target: this.target, written: [], actions: [], notes: [] };
88
97
  const C = ctx.canonicalDir;
@@ -104,9 +113,6 @@ export class ClaudeCodeAdapter {
104
113
  const hooksDir = user
105
114
  ? path.join(home, ".claude", "freeharness", "hooks")
106
115
  : path.join(ctx.projectRoot, ".freeharness", "hooks");
107
- const safetyFile = user
108
- ? path.join(home, ".claude", "freeharness", "safety.md")
109
- : path.join(ctx.projectRoot, ".freeharness", "safety.md");
110
116
  const ssScript = path.join(hooksDir, "freeharness-session-start");
111
117
  const mgScript = path.join(hooksDir, "freeharness-memory-guard.mjs");
112
118
  // user scope bakes an absolute path; project scope uses ${CLAUDE_PROJECT_DIR}.
@@ -125,7 +131,7 @@ export class ClaudeCodeAdapter {
125
131
  : 'node "${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-cost-tracker.mjs"';
126
132
  const hookCmd = (script, flags) => `${user ? `node "${path.join(hooksDir, script)}"` : `node "\${CLAUDE_PROJECT_DIR}/.freeharness/hooks/${script}"`}${flags ? ` ${flags}` : ""}`;
127
133
  const mergeOwned = (event, entry, ownerKey) => ({ event, r: mergeClaudeHook(settingsPath, event, entry, dry, ownerKey) });
128
- const mainNote = (script, cmd, merged, addedNote, presentNote, failClosed) => {
134
+ const mainNote = (script, cmd, merged, addedNote, presentNote, guard) => {
129
135
  const replaced = merged.flatMap((m) => (m.r.outcome === "replaced" ? [m.r] : []));
130
136
  const hasAdded = merged.some((m) => m.r.outcome === "added");
131
137
  if (replaced.length > 0) {
@@ -134,7 +140,7 @@ export class ClaudeCodeAdapter {
134
140
  ? `Updated ${script} hook (flags changed: ${stale} -> ${cmd}). Update resets FreeHarness hook flags to the shipped values.`
135
141
  : replaced.some((r) => r.onFailureChanged)
136
142
  ? `Updated ${script} hook (onFailure reset to the shipped value).`
137
- : `Updated ${script} hook (collapsed duplicate ${failClosed ? "guard" : "hook"} entries).`;
143
+ : `Updated ${script} hook (collapsed duplicate ${guard ? "guard" : "hook"} entries).`;
138
144
  if (hasAdded)
139
145
  return [updatedNote, addedNote];
140
146
  return [updatedNote];
@@ -143,20 +149,19 @@ export class ClaudeCodeAdapter {
143
149
  return [addedNote];
144
150
  return [presentNote];
145
151
  };
146
- // A shared entry is never rewritten, so a guard copy there cannot gain onFailure block.
147
- const hookNotes = (script, cmd, merged, addedNote, presentNote, failClosed = false) => {
148
- const why = failClosed ? " That copy runs without fail-closed (no onFailure block): if it cannot run, the tool call proceeds unjudged." : "";
149
- const shared = merged.filter((m) => m.r.hasSharedCopy).map((m) => `${script} also runs inside a hook entry shared with other commands in ${m.event}; remove it there by hand.${why}`);
150
- return [...mainNote(script, cmd, merged, addedNote, presentNote, failClosed), ...shared];
152
+ // A shared entry is never rewritten, so the owner removes that copy by hand.
153
+ const hookNotes = (script, cmd, merged, addedNote, presentNote, guard = false) => {
154
+ const shared = merged.filter((m) => m.r.hasSharedCopy).map((m) => `${script} also runs inside a hook entry shared with other commands in ${m.event}; remove it there by hand.`);
155
+ return [...mainNote(script, cmd, merged, addedNote, presentNote, guard), ...shared];
151
156
  };
152
- const emitHook = (script, flags, events, note, failClosed = false) => {
157
+ const emitHook = (script, flags, events, note, guard = false) => {
153
158
  res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
154
159
  const cmd = hookCmd(script, flags);
155
160
  const merged = events.map((e) => {
156
- const hook = { type: "command", command: cmd, ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}), ...(failClosed ? { onFailure: "block" } : {}) };
161
+ const hook = { type: "command", command: cmd, ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}) };
157
162
  return mergeOwned(e.event, { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] }, hookCmd(script, ""));
158
163
  });
159
- res.notes.push(...hookNotes(script, cmd, merged, `Added ${note}.`, `${script} hook already present.`, failClosed));
164
+ res.notes.push(...hookNotes(script, cmd, merged, `Added ${note}.`, `${script} hook already present.`, guard));
160
165
  };
161
166
  switch (cap.id) {
162
167
  case "rules": {
@@ -192,8 +197,8 @@ export class ClaudeCodeAdapter {
192
197
  // Routing table beside the script the hook reads it from.
193
198
  res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
194
199
  res.written.push(writeText(mgScript, readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
195
- // The guards fail closed: a guard that cannot run blocks the call instead of passing it.
196
- const mgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: mgCmd, onFailure: "block" }] };
200
+ // The guards sit under the permission layer, so a guard that cannot run allows the call.
201
+ const mgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: mgCmd }] };
197
202
  const mg = mergeOwned("PreToolUse", mgEntry, mgCmd);
198
203
  res.notes.push(...hookNotes("freeharness-memory-guard.mjs", mgCmd, [mg], "Added memory-guard hook (PreToolUse denies memory .md writes; saves tokens).", "Memory-guard hook already present.", true));
199
204
  emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }], `config-guard hook (PreToolUse denies edits to live configuration by location: settings in a .claude directory, .mcp.json, the user .claude.json and CLAUDE.md, hook directories and the plugin tree, unless an allow marker from the last ${CONFIG_GUARD_TTL_MIN} minutes is present)`, true);
@@ -202,7 +207,7 @@ export class ClaudeCodeAdapter {
202
207
  emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }], "precompact hook (PreCompact writes the checkpoint skeleton + file-op list for SessionStart and asks a running memory worker for a summary; worker down = skipped)");
203
208
  emitHook("freeharness-reload-context.mjs", "", [{ event: "UserPromptSubmit" }], "reload-context hook (UserPromptSubmit answers the exact prompt `reload context` with the newest saved handoff or PreCompact skeleton; other prompts untouched)");
204
209
  if (user && freeMemEnabled(claudeDir))
205
- turnOffAutoMemory(settingsPath, dry, res);
210
+ this.turnOffAutoMemoryOnce(settingsPath, dry, res);
206
211
  break;
207
212
  }
208
213
  case "skills": {
@@ -233,7 +238,6 @@ export class ClaudeCodeAdapter {
233
238
  }));
234
239
  break;
235
240
  case "safety": {
236
- res.written.push(writeText(safetyFile, readText(path.join(C, "rules", "safety.md")), dry));
237
241
  // Advisory PreToolUse hook: warns on vulnerability sinks in Edit/Write content,
238
242
  // never blocks. Same additive merge as the memory guard.
239
243
  res.written.push(writeText(sgScript, readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
@@ -315,7 +319,7 @@ export class ClaudeCodeAdapter {
315
319
  merge(await mem.registerHooks(ctx.scope, ctx));
316
320
  // Registering enables free-mem, so the key follows in the same run; pass 1 previews it.
317
321
  if (user)
318
- turnOffAutoMemory(settingsPath, dry, res);
322
+ this.turnOffAutoMemoryOnce(settingsPath, dry, res);
319
323
  res.notes.push("Worker start + recall are verified on the machine (needs the Claude Agent SDK key / network); not exercised by the installer.");
320
324
  break;
321
325
  }
@@ -327,7 +331,8 @@ export class ClaudeCodeAdapter {
327
331
  continue;
328
332
  res.actions.push({
329
333
  description: `Register MCP server ${s.name}${s.optional ? " (optional)" : ""}`,
330
- command: `claude mcp add ${s.name} -s ${sc} -- ${s.command} ${s.args.join(" ")}`,
334
+ // `claude mcp add` exits 1 for a registered name; the get guard keeps a re-run quiet.
335
+ command: `claude mcp get ${s.name} >/dev/null 2>&1 || claude mcp add ${s.name} -s ${sc} -- ${s.command} ${s.args.join(" ")}`,
331
336
  optional: true, // a failing/missing `claude` must not abort the whole install
332
337
  });
333
338
  }
@@ -384,13 +389,14 @@ export function freeMemEnabled(claudeDir) {
384
389
  * free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
385
390
  * keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
386
391
  */
387
- function turnOffAutoMemory(settingsPath, dry, res) {
392
+ export function turnOffAutoMemory(settingsPath, dry, res) {
388
393
  let merged;
389
394
  try {
390
395
  merged = mergeSettingsKeys(settingsPath, { autoMemoryEnabled: false }, dry);
391
396
  }
392
397
  catch (e) {
393
- res.notes.push(`autoMemoryEnabled not set: ${settingsPath} not parsed (${e instanceof Error ? e.message : String(e)}).`);
398
+ const reason = e instanceof SyntaxError ? "not parsed" : "not updated";
399
+ res.notes.push(`autoMemoryEnabled not set: ${settingsPath} ${reason} (${e instanceof Error ? e.message : String(e)}).`);
394
400
  return;
395
401
  }
396
402
  if (merged.added.length > 0) {
@@ -108,7 +108,7 @@ export class OpenCodeAdapter {
108
108
  break;
109
109
  }
110
110
  case "safety":
111
- res.written.push(writeText(path.join(P, ".freeharness", "safety.md"), readText(path.join(C, "rules", "safety.md")), dry));
111
+ res.notes.push("safety: OpenCode has no hook contract FreeHarness emits to; nothing emitted.");
112
112
  break;
113
113
  case "sdd": {
114
114
  const src = path.join(C, "openspec");
@@ -17,7 +17,7 @@ export const MARKETPLACE_NAME = "freeharness-local";
17
17
  /**
18
18
  * Additively merges a hook entry into the plugin's hooks.json (dedupe by command; the file is
19
19
  * created with the official `description` + `hooks` shape). Entries running only this command
20
- * fold into the first one, rewritten to the current entry, so a re-emit gains the fail-closed
20
+ * fold into the first one, rewritten to the current entry, so a re-emit drops a stale `onFailure`
21
21
  * field and a stale copy never keeps running beside a current one; an entry mixing it with other
22
22
  * commands is left as is.
23
23
  * @param file Absolute path of hooks/hooks.json.
@@ -68,10 +68,10 @@ export class PluginAdapter {
68
68
  const hooksFile = path.join(hooksDir, "hooks.json");
69
69
  const hookCmd = (script, runner = "node", flags = "") => `${runner} "\${CLAUDE_PLUGIN_ROOT}/hooks/${script}"${flags ? ` ${flags}` : ""}`;
70
70
  /** Copies a canonical hook script and registers it under each event (dedupe by command). */
71
- const emitHook = (script, flags, events, failClosed = false) => {
71
+ const emitHook = (script, flags, events) => {
72
72
  res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
73
73
  for (const e of events) {
74
- const hook = { type: "command", command: hookCmd(script, "node", flags), ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}), ...(failClosed ? { onFailure: "block" } : {}) };
74
+ const hook = { type: "command", command: hookCmd(script, "node", flags), ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}) };
75
75
  const entry = { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] };
76
76
  mergePluginHook(hooksFile, e.event, entry, dry);
77
77
  }
@@ -91,10 +91,10 @@ export class PluginAdapter {
91
91
  // it from its own directory regardless of which target installed it.
92
92
  res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
93
93
  res.written.push(writeText(path.join(hooksDir, "freeharness-memory-guard.mjs"), readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
94
- // The guards fail closed: a guard that cannot run blocks the call instead of passing it.
95
- const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs"), onFailure: "block" }] };
94
+ // The guards sit under the permission layer, so a guard that cannot run allows the call.
95
+ const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs") }] };
96
96
  mergePluginHook(hooksFile, "PreToolUse", mg, dry);
97
- emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }], true);
97
+ emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }]);
98
98
  emitHook("freeharness-repeat-tool.mjs", REPEAT_TOOL_FLAGS, [{ event: "PreToolUse" }, { event: "UserPromptSubmit" }]);
99
99
  emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }]);
100
100
  emitHook("freeharness-reload-context.mjs", "", [{ event: "UserPromptSubmit" }]);
@@ -116,7 +116,7 @@ export class PluginAdapter {
116
116
  }));
117
117
  break;
118
118
  case "safety": {
119
- res.notes.push("safety: rules/safety.md is a rules file (not a plugin component); only the advisory security-guidance hook ships in the plugin.");
119
+ res.notes.push("safety: the advisory security-guidance hook ships in the plugin.");
120
120
  res.written.push(writeText(path.join(hooksDir, "freeharness-security-guidance.mjs"), readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
121
121
  const sg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-security-guidance.mjs") }] };
122
122
  mergePluginHook(hooksFile, "PreToolUse", sg, dry);
@@ -256,8 +256,8 @@ its agents, skills, and hooks.
256
256
 
257
257
  ## What is not in the plugin
258
258
 
259
- - Behavior rules (\`CLAUDE.md\`) and \`safety.md\`: not plugin components. Install them per user with
260
- \`freeharness init --scope user --only rules,safety\`.
259
+ - Behavior rules (\`CLAUDE.md\`): not a plugin component. Install them per user with
260
+ \`freeharness init --scope user --only rules\`. The security-guidance hook ships in the plugin.
261
261
  - SDD (OpenSpec \`/opsx\`): per-project init, \`freeharness bootstrap\` inside the project.
262
262
  - Memory (free-mem): its own plugin, \`freeharness init --scope user --only memory\`.
263
263
  - Report-formatting runtime deps: after install run \`npm install\` in
@@ -1,14 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { type StageResult } from "./vendored.js";
3
3
  import { type MemoryPaths } from "./paths.js";
4
- import { type StopResult } from "./migrate.js";
4
+ import { type StopResult } from "./worker-control.js";
5
5
  export interface CacheVersion {
6
6
  version: string;
7
7
  dir: string;
8
8
  ours: boolean;
9
9
  skills: string[];
10
- /** Found under the upstream (thedotmack/claude-mem) cache root: a previous FreeHarness
11
- * build (pre-rename) or a genuine upstream install, never our current native path. */
10
+ /** Found under the upstream (thedotmack/claude-mem) cache root: a pre-rename FreeHarness
11
+ * build or an upstream install, never our native path. */
12
12
  legacy: boolean;
13
13
  }
14
14
  /** What the foreign responder said about itself, already filtered for printing. */
@@ -153,8 +153,7 @@ export declare const OUR_SCHEMA_VERSION = 49;
153
153
  * FreeHarness migration is never reported as a newer upstream engine. A store that records
154
154
  * some FreeHarness versions but not all of them has not finished migrating (a migration
155
155
  * rolled back, or the worker predates the update), so the line warns and names each missing
156
- * version; a store with none has never been opened
157
- * by a FreeHarness worker and keeps the upstream verdict.
156
+ * version; a store with none was never opened by a FreeHarness worker and keeps the upstream verdict.
158
157
  *
159
158
  * Returns:
160
159
  * The line, or null when the db/sqlite3 is unavailable.
@@ -189,8 +188,7 @@ export interface SpendUnavailable {
189
188
  *
190
189
  * Args:
191
190
  * dbPath: Path of claude-mem.db.
192
- * spawn: Injectable spawnSync, so the missing-binary path is deterministic in tests
193
- * regardless of whether sqlite3 happens to be on the test machine's PATH.
191
+ * spawn: Injectable spawnSync, so a test of the missing-binary path does not depend on sqlite3 being on PATH.
194
192
  *
195
193
  * Returns:
196
194
  * One row per (window, model) with a row, or the reason spend could not be read.
@@ -494,21 +492,17 @@ export declare function configDir(): string;
494
492
  /** The engine's data directory and database for this process. */
495
493
  export declare function memoryPaths(): MemoryPaths;
496
494
  export declare function dataDir(): string;
497
- /**
498
- * One WARN per `CLAUDE_MEM_*` variable in `env`, naming its `FREE_MEM_*` replacement, and one
499
- * WARN for all `CLAUDE_MEM_*` keys in the active settings file: a seeded legacy file holds dozens,
500
- * and a line per key would bury the rest of the report.
501
- */
495
+ /** One WARN per ignored `CLAUDE_MEM_*` variable in `env` and key in the settings file, naming its replacement. */
502
496
  export declare function deprecatedNameLines(env: Record<string, string | undefined>, settingsPath: string): string[];
503
- /**
504
- * A WARN when the legacy database (or its WAL) changed after the migration marker's `at`:
505
- * a build still on the old name keeps writing memories the new directory never sees.
506
- */
507
- export declare function legacyWriteLines(home: string): string[];
497
+ /** A WARN while a legacy database sits beside a free-mem store with no observation: the worker creates
498
+ * an empty store at its first start, so existence cannot silence it; an unreadable store stays silent. */
499
+ export declare function legacyHomeLines(env: Record<string, string | undefined>, home: string, spawn?: SpawnSyncFn): string[];
508
500
  /** Engine worker port: env, then settings.json, then 37700 + uid % 100. */
509
501
  export declare function workerPort(dir: string): number;
510
502
  /** One WARN while the upstream plugin is still registered: both workers bind the same port. */
511
503
  export declare function upstreamLines(cfg: string, port: number): string[];
504
+ /** Steps that clear a reachable foreign worker, else null; no-pid fallback as in foreignWorkerLine. */
505
+ export declare function foreignWorkerStep(worker: WorkerProbe): string | null;
512
506
  /**
513
507
  * Diagnose which claude-mem is active.
514
508
  *
@@ -1,7 +1,7 @@
1
1
  // Memory doctor: tell which claude-mem is actually active on this machine (ours vs an
2
2
  // upstream install), back up its data, and switch the machine to the FreeHarness build.
3
3
  //
4
- // Evidence used (no guessing):
4
+ // Evidence used:
5
5
  // - plugin cache versions under <cfg>/plugins/cache/thedotmack/claude-mem/<ver>/ — ours
6
6
  // carries the `.install-version` marker written by VendoredMemory and ships exactly
7
7
  // one skill (mem-search); an upstream install ships many (cloud-sync, babysit, ...).
@@ -9,30 +9,26 @@
9
9
  // - the running worker — /api/health reports its version and, on our build, the product
10
10
  // marker "free-mem"; an unmarked answer is an older build of ours only when the free-mem
11
11
  // PID file names its pid and port. GET / proves nothing: upstream also answers 404 there.
12
- // - the data dir (resolveMemoryPaths in ./paths.ts): the database (a leftover chroma/
13
- // directory is only sized and reported, never touched), plus the
14
- // observer ledgers written by the ported 13.24 engine (observer-health.json,
15
- // quota-cooldown.json) that `memory status` renders as rejections / cooldowns.
12
+ // - the data dir (resolveMemoryPaths in ./paths.ts): the database (a leftover
13
+ // chroma/ is only sized and
14
+ // reported, never touched) and the engine's observer ledgers, which `memory status`
15
+ // renders as rejections / cooldowns.
16
16
  import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { spawn, spawnSync } from "node:child_process";
20
20
  import { MARKETPLACE_OWNER, PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME, claudePluginPaths, isUpstreamRegistered, declaresPluginDeps, installPluginDeps, missingPluginBundles, missingPluginDeps, pluginVersion, probeBun, stagePluginCache, writeInstallMarker, } from "./vendored.js";
21
- import { FREE_DIR_NAME, LEGACY_DB_FILENAME, LEGACY_DIR_NAME, readMemorySettings, resolveMemoryPaths } from "./paths.js";
22
- import { pidFileLive } from "./migrate.js";
21
+ import { LEGACY_DIR_NAME, readMemorySettings, resolveMemoryPaths } from "./paths.js";
22
+ import { pidFileLive } from "./worker-control.js";
23
23
  import { resolveCanonicalDir, sourceRepoRoot } from "../../infra/paths.js";
24
24
  const OLD_PREFIX = "CLAUDE_MEM_";
25
25
  const NEW_PREFIX = "FREE_MEM_";
26
26
  function legacyName(key) {
27
27
  return OLD_PREFIX + key.slice(NEW_PREFIX.length);
28
28
  }
29
- /** A FREE_MEM_ setting, else its CLAUDE_MEM_ twin: the engine reads an unrenamed file the same way. */
30
- function setting(values, key) {
31
- return values[key] ?? values[legacyName(key)];
32
- }
33
- /** Like `setting`, but an empty value counts as unset, as the engine's envGet reads the environment. */
29
+ /** FREE_MEM_ names only, as the engine reads them (CLAUDE_MEM_ twins are ignored); an empty value is unset. */
34
30
  function envSetting(env, key) {
35
- return env[key] || env[legacyName(key)] || undefined;
31
+ return env[key] || undefined;
36
32
  }
37
33
  /** The /api/health product marker of our worker; a test pins it to the engine's and the hooks' copies. */
38
34
  export const FREE_MEM_PRODUCT = "free-mem";
@@ -218,8 +214,7 @@ function dbSchemaVersions(dbPath) {
218
214
  * FreeHarness migration is never reported as a newer upstream engine. A store that records
219
215
  * some FreeHarness versions but not all of them has not finished migrating (a migration
220
216
  * rolled back, or the worker predates the update), so the line warns and names each missing
221
- * version; a store with none has never been opened
222
- * by a FreeHarness worker and keeps the upstream verdict.
217
+ * version; a store with none was never opened by a FreeHarness worker and keeps the upstream verdict.
223
218
  *
224
219
  * Returns:
225
220
  * The line, or null when the db/sqlite3 is unavailable.
@@ -256,8 +251,7 @@ const MISSING_CONSOLIDATION_TABLE = /no such table: (?:main\.)?consolidation_(?:
256
251
  *
257
252
  * Args:
258
253
  * dbPath: Path of claude-mem.db.
259
- * spawn: Injectable spawnSync, so the missing-binary path is deterministic in tests
260
- * regardless of whether sqlite3 happens to be on the test machine's PATH.
254
+ * spawn: Injectable spawnSync, so a test of the missing-binary path does not depend on sqlite3 being on PATH.
261
255
  *
262
256
  * Returns:
263
257
  * One row per (window, model) with a row, or the reason spend could not be read.
@@ -822,9 +816,9 @@ export function effectiveObserverModels(settingsPath) {
822
816
  const defaults = engineDefaultObserverModels();
823
817
  const file = readFlatSettings(settingsPath);
824
818
  const str = (v, fallback) => (typeof v === "string" && v.length > 0 ? v : fallback);
825
- const base = str(setting(file, "FREE_MEM_MODEL"), defaults.base);
826
- const simple = str(setting(file, "FREE_MEM_TIER_SIMPLE_MODEL"), defaults.simple);
827
- const summaryValue = setting(file, "FREE_MEM_TIER_SUMMARY_MODEL");
819
+ const base = str(file.FREE_MEM_MODEL, defaults.base);
820
+ const simple = str(file.FREE_MEM_TIER_SIMPLE_MODEL, defaults.simple);
821
+ const summaryValue = file.FREE_MEM_TIER_SUMMARY_MODEL;
828
822
  const rawSummary = typeof summaryValue === "string" ? summaryValue : defaults.summary;
829
823
  const summary = rawSummary.length > 0 ? rawSummary : base;
830
824
  return { base, simple, summary };
@@ -846,9 +840,9 @@ const ENGINE_LOCAL_MODEL_DEFAULT = "local-model";
846
840
  */
847
841
  export function effectiveObserverProvider(settingsPath) {
848
842
  const values = readFlatSettings(settingsPath);
849
- if (setting(values, "FREE_MEM_PROVIDER") !== "local")
843
+ if (values.FREE_MEM_PROVIDER !== "local")
850
844
  return { provider: "claude", model: null };
851
- const raw = setting(values, "FREE_MEM_LOCAL_MODEL");
845
+ const raw = values.FREE_MEM_LOCAL_MODEL;
852
846
  return { provider: "local", model: typeof raw === "string" && raw.length > 0 ? raw : ENGINE_LOCAL_MODEL_DEFAULT };
853
847
  }
854
848
  /**
@@ -991,7 +985,7 @@ export function previewObserverSettings(settingsPath, opts) {
991
985
  }
992
986
  }
993
987
  const rows = Object.entries(keySet).map(([key, recommended]) => {
994
- const raw = setting(values, key);
988
+ const raw = values[key];
995
989
  const current = raw === undefined ? undefined : typeof raw === "string" ? raw : JSON.stringify(raw);
996
990
  return { key, current, recommended, changed: current !== recommended };
997
991
  });
@@ -1156,12 +1150,12 @@ export async function probeMemoryProvider(baseUrl, model, timeoutMs = 3000) {
1156
1150
  */
1157
1151
  export async function memoryProviderLines(settingsPath, probe = probeMemoryProvider) {
1158
1152
  const values = readFlatSettings(settingsPath);
1159
- if (setting(values, "FREE_MEM_PROVIDER") !== "local")
1153
+ if (values.FREE_MEM_PROVIDER !== "local")
1160
1154
  return [];
1161
1155
  // The worker applies these same defaults for an absent key, so probing "" would report a
1162
1156
  // URL-parse failure for a server the observer is in fact reaching.
1163
1157
  const configured = (key, fallback) => {
1164
- const v = setting(values, key);
1158
+ const v = values[key];
1165
1159
  return typeof v === "string" && v.trim() !== "" ? v : fallback;
1166
1160
  };
1167
1161
  const baseUrl = configured("FREE_MEM_LOCAL_BASE_URL", ENGINE_LOCAL_BASE_URL_DEFAULT);
@@ -1180,52 +1174,33 @@ export function memoryPaths() {
1180
1174
  export function dataDir() {
1181
1175
  return memoryPaths().dataDir;
1182
1176
  }
1183
- /**
1184
- * One WARN per `CLAUDE_MEM_*` variable in `env`, naming its `FREE_MEM_*` replacement, and one
1185
- * WARN for all `CLAUDE_MEM_*` keys in the active settings file: a seeded legacy file holds dozens,
1186
- * and a line per key would bury the rest of the report.
1187
- */
1177
+ /** One WARN per ignored `CLAUDE_MEM_*` variable in `env` and key in the settings file, naming its replacement. */
1188
1178
  export function deprecatedNameLines(env, settingsPath) {
1189
- const lines = Object.keys(env)
1190
- .filter((k) => k.startsWith(OLD_PREFIX) && env[k] !== undefined)
1191
- .sort()
1192
- .map((k) => `WARN deprecated environment variable ${k} is set; use FREE_MEM_${k.slice(OLD_PREFIX.length)}`);
1179
+ const replacement = (k) => NEW_PREFIX + k.slice(OLD_PREFIX.length);
1180
+ const vars = Object.keys(env).filter((k) => k.startsWith(OLD_PREFIX) && env[k] !== undefined).sort();
1193
1181
  const keys = Object.keys(readMemorySettings(settingsPath) ?? {}).filter((k) => k.startsWith(OLD_PREFIX)).sort();
1194
- if (keys.length > 0) {
1195
- lines.push(`WARN deprecated settings keys (${keys.length}) in ${settingsPath}: ${keys.join(", ")}; copying the settings to the free-mem directory renames them to FREE_MEM_*`);
1196
- }
1197
- return lines;
1182
+ return [
1183
+ ...vars.map((k) => `WARN environment variable ${k} is ignored; set ${replacement(k)} instead`),
1184
+ ...keys.map((k) => `WARN settings key ${k} in ${settingsPath} is ignored; rename it to ${replacement(k)}`),
1185
+ ];
1198
1186
  }
1199
- /**
1200
- * A WARN when the legacy database (or its WAL) changed after the migration marker's `at`:
1201
- * a build still on the old name keeps writing memories the new directory never sees.
1202
- */
1203
- export function legacyWriteLines(home) {
1204
- const freeDir = path.join(home, FREE_DIR_NAME);
1205
- let marker;
1187
+ /** A WARN while a legacy database sits beside a free-mem store with no observation: the worker creates
1188
+ * an empty store at its first start, so existence cannot silence it; an unreadable store stays silent. */
1189
+ export function legacyHomeLines(env, home, spawn = spawnSync) {
1190
+ const legacyDir = path.join(home, LEGACY_DIR_NAME);
1191
+ if (env.FREE_MEM_DATA_DIR || fs.statSync(path.join(legacyDir, "claude-mem.db"), { throwIfNoEntry: false })?.isFile() !== true)
1192
+ return [];
1193
+ const { dataDir: freeDir, dbFile } = resolveMemoryPaths(env, home);
1194
+ const warn = [`WARN legacy memory directory ${legacyDir} is not read; free-mem uses ${freeDir}, which holds no observation yet: copy the data by hand to keep the old memories`];
1195
+ if (!fs.existsSync(dbFile))
1196
+ return warn;
1206
1197
  try {
1207
- marker = JSON.parse(fs.readFileSync(path.join(freeDir, ".migrated-from.json"), "utf8"));
1198
+ const r = spawn("sqlite3", ["-readonly", dbFile, "SELECT COUNT(*) FROM observations;"], { encoding: "utf8", timeout: 2000 });
1199
+ return !r.error && r.status === 0 && String(r.stdout).trim() === "0" ? warn : [];
1208
1200
  }
1209
1201
  catch {
1210
1202
  return [];
1211
1203
  }
1212
- const at = typeof marker.at === "string" ? Date.parse(marker.at) : NaN;
1213
- if (Number.isNaN(at))
1214
- return [];
1215
- const source = typeof marker.source === "string" && marker.source ? marker.source : path.join(home, LEGACY_DIR_NAME);
1216
- const db = path.join(source, LEGACY_DB_FILENAME);
1217
- let changed = 0;
1218
- for (const file of [db, `${db}-wal`]) {
1219
- try {
1220
- changed = Math.max(changed, fs.statSync(file).mtimeMs);
1221
- }
1222
- catch {
1223
- /* absent */
1224
- }
1225
- }
1226
- if (changed <= at)
1227
- return [];
1228
- return [`WARN legacy database ${db} changed at ${new Date(changed).toISOString()}, after the migration at ${new Date(at).toISOString()}; a build still on the old name may be writing there, and those memories are not in ${freeDir}`];
1229
1204
  }
1230
1205
  /** Leading decimal integer, as the engine's parseInt reads it, when in 1..65535. */
1231
1206
  function validPort(value) {
@@ -1240,7 +1215,7 @@ export function workerPort(dir) {
1240
1215
  try {
1241
1216
  const raw = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf8"));
1242
1217
  const s = raw.env ?? raw;
1243
- const p = validPort(setting(s, "FREE_MEM_WORKER_PORT"));
1218
+ const p = validPort(s.FREE_MEM_WORKER_PORT);
1244
1219
  if (p !== null)
1245
1220
  return p;
1246
1221
  }
@@ -1323,6 +1298,14 @@ function foreignWorkerLine(port, facts) {
1323
1298
  }
1324
1299
  return `FAIL worker on :${port} is another program (${where}, version ${version}); memory is off — stop the program listening on port ${port} and ${cleanup}`;
1325
1300
  }
1301
+ /** Steps that clear a reachable foreign worker, else null; no-pid fallback as in foreignWorkerLine. */
1302
+ export function foreignWorkerStep(worker) {
1303
+ if (!worker.reachable || worker.identity !== "foreign")
1304
+ return null;
1305
+ const pid = worker.foreign?.pid;
1306
+ const stop = pid !== undefined ? `kill ${pid}` : `stop the program listening on port ${worker.port}`;
1307
+ return `${stop}, then freeharness cleanup --scope user`;
1308
+ }
1326
1309
  async function probeWorker(port, dir) {
1327
1310
  let res;
1328
1311
  try {
@@ -1401,7 +1384,7 @@ function cacheTag(c) {
1401
1384
  return "FreeHarness build";
1402
1385
  }
1403
1386
  const TREE_BYTES_MAX_ENTRIES = 100_000;
1404
- /** Bytes of regular files under `dir`; symlinks are never followed and the walk is capped, so a huge tree yields a lower bound. */
1387
+ /** A size for the report only: symlinks are not followed and the walk is capped, so a huge tree yields a lower bound. */
1405
1388
  function treeBytes(dir) {
1406
1389
  let bytes = 0;
1407
1390
  let seen = 0;
@@ -1505,7 +1488,7 @@ export async function diagnoseMemory() {
1505
1488
  if (provider.provider === "local") {
1506
1489
  lines.push("WARN observer FREE_MEM_PROVIDER=local runs beside the claude observer; run `freeharness memory settings`");
1507
1490
  }
1508
- const skipRaw = setting(readFlatSettings(path.join(data, "settings.json")), "FREE_MEM_SKIP_TOOLS");
1491
+ const skipRaw = readFlatSettings(path.join(data, "settings.json")).FREE_MEM_SKIP_TOOLS;
1509
1492
  if (typeof skipRaw === "string") {
1510
1493
  const skipped = skipRaw.split(",").map((t) => t.trim());
1511
1494
  const missing = LOCAL_PROVIDER_SERVED_TOOLS.filter((t) => !skipped.includes(t));
@@ -1529,7 +1512,7 @@ export async function diagnoseMemory() {
1529
1512
  lines.push(`INFO memory data dir ${data}: chroma/ (${mb} MB) is no longer used by free-mem and is kept; move it aside by hand to reclaim the space`);
1530
1513
  }
1531
1514
  lines.push(...deprecatedNameLines(process.env, path.join(data, "settings.json")));
1532
- lines.push(...legacyWriteLines(os.homedir()));
1515
+ lines.push(...legacyHomeLines(process.env, os.homedir()));
1533
1516
  const schemaLine = schemaVersionLine(dbPath, dbName);
1534
1517
  if (schemaLine !== null)
1535
1518
  lines.push(schemaLine);
@@ -1544,11 +1527,15 @@ export async function diagnoseMemory() {
1544
1527
  else
1545
1528
  verdict = "mixed";
1546
1529
  }
1530
+ // `memory activate` cannot clear a foreign worker beside our build; the upstream verdict keeps the backup-and-activate advice.
1531
+ const foreignStep = verdict === "mixed" ? foreignWorkerStep(worker) : null;
1547
1532
  lines.push(verdict === "ours"
1548
1533
  ? "OK VERDICT: the FreeHarness build is the only active memory plugin"
1549
1534
  : verdict === "none"
1550
1535
  ? "WARN VERDICT: memory plugin not installed"
1551
- : `WARN VERDICT: ${verdict === "upstream" ? "the UPSTREAM install is active" : "MIXED — upstream pieces still active beside ours"} (run: freeharness memory backup && freeharness memory activate)`);
1536
+ : foreignStep !== null
1537
+ ? `WARN VERDICT: MIXED — a foreign worker holds the memory port (see the FAIL line: ${foreignStep})`
1538
+ : `WARN VERDICT: ${verdict === "upstream" ? "the UPSTREAM install is active" : "MIXED — upstream pieces still active beside ours"} (run: freeharness memory backup && freeharness memory activate)`);
1552
1539
  return { configDir: cfg, dataDir: data, dbPath, dbBytes, chromaPresent, cache, activeInstallPath, activeVersion, activeIsOurs, worker, observer, verdict, lines };
1553
1540
  }
1554
1541
  /**
@@ -1,22 +1,15 @@
1
- export type MemoryPathSource = "FREE_MEM_DATA_DIR" | "CLAUDE_MEM_DATA_DIR" | "settings" | "default" | "fallback";
1
+ export type MemoryPathSource = "FREE_MEM_DATA_DIR" | "settings" | "default";
2
2
  export interface MemoryPaths {
3
3
  dataDir: string;
4
4
  /** Absolute path of the database the engine opens in `dataDir`. */
5
5
  dbFile: string;
6
- /** True when `dataDir` is `<home>/.claude-mem`. */
7
- legacy: boolean;
8
6
  source: MemoryPathSource;
9
7
  }
10
8
  export declare const FREE_DIR_NAME = ".free-mem";
9
+ /** The pre-rename data directory: never read, only kept protected and named by doctor. */
11
10
  export declare const LEGACY_DIR_NAME = ".claude-mem";
12
11
  export declare const DB_FILENAME = "free-mem.db";
13
- export declare const LEGACY_DB_FILENAME = "claude-mem.db";
14
12
  /** A settings file's keys as the engine reads them (BOM dropped, `env` first); null when unreadable. */
15
13
  export declare function readMemorySettings(settingsPath: string): Record<string, unknown> | null;
16
- /**
17
- * Resolves the memory data directory and database from `env` and `home` alone, never from
18
- * process globals, so callers and tests can pass fake values.
19
- * @param env Environment to read `FREE_MEM_DATA_DIR` and `CLAUDE_MEM_DATA_DIR` from.
20
- * @param home Home directory that holds `.free-mem` and `.claude-mem` and expands `~`.
21
- */
14
+ /** The data directory and database from `env` and `home` alone, never process globals, so tests pass fakes. */
22
15
  export declare function resolveMemoryPaths(env: Record<string, string | undefined>, home: string): MemoryPaths;