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
@@ -1,11 +1,11 @@
1
- // The CLI cannot import the engine: a copy of engine/shared/data-dir.ts and paths.ts resolveDbPath.
1
+ // The CLI cannot import the engine: a copy of engine/shared/data-dir.ts resolveDataDir.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  export const FREE_DIR_NAME = ".free-mem";
5
+ /** The pre-rename data directory: never read, only kept protected and named by doctor. */
5
6
  export const LEGACY_DIR_NAME = ".claude-mem";
6
7
  export const DB_FILENAME = "free-mem.db";
7
- export const LEGACY_DB_FILENAME = "claude-mem.db";
8
- const DATA_DIR_KEYS = ["FREE_MEM_DATA_DIR", "CLAUDE_MEM_DATA_DIR"];
8
+ const DATA_DIR_KEY = "FREE_MEM_DATA_DIR";
9
9
  function expandHome(p, home) {
10
10
  if (p === "~")
11
11
  return home;
@@ -13,14 +13,6 @@ function expandHome(p, home) {
13
13
  return path.join(home, p.slice(2));
14
14
  return p;
15
15
  }
16
- function isDirectory(p) {
17
- try {
18
- return fs.statSync(p).isDirectory();
19
- }
20
- catch {
21
- return false;
22
- }
23
- }
24
16
  /** A settings file's keys as the engine reads them (BOM dropped, `env` first); null when unreadable. */
25
17
  export function readMemorySettings(settingsPath) {
26
18
  try {
@@ -34,52 +26,22 @@ export function readMemorySettings(settingsPath) {
34
26
  return null;
35
27
  }
36
28
  }
37
- // Either default directory is a seeded value, not a redirect; honoring it pins later starts.
29
+ // A seeded or hand-copied default is no redirect; honoring it pins later starts.
38
30
  function settingsDataDir(settingsPath, home, defaultDirs) {
39
- const settings = readMemorySettings(settingsPath);
40
- if (!settings)
31
+ const value = readMemorySettings(settingsPath)?.[DATA_DIR_KEY];
32
+ if (typeof value !== "string" || !value)
41
33
  return undefined;
42
- for (const key of DATA_DIR_KEYS) {
43
- const value = settings[key];
44
- if (typeof value !== "string" || !value)
45
- continue;
46
- const dir = expandHome(value, home);
47
- if (!defaultDirs.includes(path.resolve(dir)))
48
- return dir;
49
- }
50
- return undefined;
51
- }
52
- function dbFileIn(dataDir) {
53
- const current = path.join(dataDir, DB_FILENAME);
54
- const legacy = path.join(dataDir, LEGACY_DB_FILENAME);
55
- return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current;
34
+ const dir = expandHome(value, home);
35
+ return defaultDirs.includes(path.resolve(dir)) ? undefined : dir;
56
36
  }
57
- /**
58
- * Resolves the memory data directory and database from `env` and `home` alone, never from
59
- * process globals, so callers and tests can pass fake values.
60
- * @param env Environment to read `FREE_MEM_DATA_DIR` and `CLAUDE_MEM_DATA_DIR` from.
61
- * @param home Home directory that holds `.free-mem` and `.claude-mem` and expands `~`.
62
- */
37
+ /** The data directory and database from `env` and `home` alone, never process globals, so tests pass fakes. */
63
38
  export function resolveMemoryPaths(env, home) {
64
39
  const freeDir = path.join(home, FREE_DIR_NAME);
65
- const legacyDir = path.join(home, LEGACY_DIR_NAME);
66
- const defaultDirs = [path.resolve(freeDir), path.resolve(legacyDir)];
67
- const result = (dataDir, source) => ({
68
- dataDir,
69
- dbFile: dbFileIn(dataDir),
70
- legacy: path.resolve(dataDir) === defaultDirs[1],
71
- source,
72
- });
73
- for (const key of DATA_DIR_KEYS) {
74
- const value = env[key];
75
- if (value)
76
- return result(expandHome(value, home), key);
77
- }
78
- for (const dir of [freeDir, legacyDir]) {
79
- const redirect = settingsDataDir(path.join(dir, "settings.json"), home, defaultDirs);
80
- if (redirect)
81
- return result(redirect, "settings");
82
- }
83
- // An unmigrated install keeps serving its legacy directory until the copy creates ~/.free-mem.
84
- return isDirectory(freeDir) || !isDirectory(legacyDir) ? result(freeDir, "default") : result(legacyDir, "fallback");
40
+ const defaultDirs = [path.resolve(freeDir), path.resolve(home, LEGACY_DIR_NAME)];
41
+ const result = (dataDir, source) => ({ dataDir, dbFile: path.join(dataDir, DB_FILENAME), source });
42
+ const value = env[DATA_DIR_KEY];
43
+ if (value)
44
+ return result(expandHome(value, home), DATA_DIR_KEY);
45
+ const redirect = settingsDataDir(path.join(freeDir, "settings.json"), home, defaultDirs);
46
+ return redirect ? result(redirect, "settings") : result(freeDir, "default");
85
47
  }
@@ -116,7 +116,7 @@ export declare function isUpstreamRegistered(installed: {
116
116
  }, settings: {
117
117
  enabledPlugins?: Record<string, boolean>;
118
118
  }): boolean;
119
- /** Write the `.install-version` marker that tells our build apart; never writes through a symlink. `uv` stays for readers of the old shape. */
119
+ /** Write the `.install-version` marker that tells our build apart; never writes through a symlink. */
120
120
  export declare function writeInstallMarker(dir: string, version: string, bun: string): string;
121
121
  /**
122
122
  * Version from a plugin dir's manifest ("0.0.0" when absent). The version names a cache
@@ -167,11 +167,6 @@ export declare class VendoredMemory implements MemoryPort {
167
167
  * An upstream registration (UPSTREAM_PLUGIN_KEY) is never written: doctor warns about it.
168
168
  */
169
169
  registerHooks(_scope: Scope, ctx: InstallContext): Promise<AdapterResult>;
170
- /**
171
- * Copy a legacy ~/.claude-mem forward into ~/.free-mem: pass 1 prints the plan, pass 2 applies
172
- * it. A worker it stops is restarted by `update` once the bundle is staged, else at SessionStart.
173
- * The copy is never recorded in `written`, since uninstall removes what the manifest lists.
174
- */
175
- migrateExisting(ctx: InstallContext): Promise<AdapterResult>;
170
+ migrateExisting(_ctx: InstallContext): Promise<AdapterResult>;
176
171
  private run;
177
172
  }
@@ -1,32 +1,22 @@
1
1
  // MemoryPort adapter for the vendored claude-mem engine (FreeHarness V3-P0 memory-runtime).
2
2
  //
3
- // Installs free-mem as a real Claude Code plugin from OUR build, reproducing the
4
- // upstream `npx claude-mem install` mechanics (src/npx-cli/commands/install.ts +
5
- // utils/paths.ts + install/setup-runtime.ts) in TypeScript:
3
+ // Installs free-mem as a real Claude Code plugin from OUR build, reproducing the upstream `npx claude-mem
4
+ // install` mechanics (src/npx-cli/commands/install.ts + utils/paths.ts + install/setup-runtime.ts) in TypeScript:
6
5
  //
7
6
  // ensurePrerequisites Bun probe (build + hook runtime), the only prerequisite.
8
- // install bun run build:plugin -> copyPluginToCache(version)
9
- // -> copyPluginToMarketplace -> plugin deps install
10
- // -> .install-version markers.
11
- // registerHooks known_marketplaces.json + installed_plugins.json +
12
- // settings.enabledPlugins["free-mem@freeharness"]=true,
13
- // all ADDITIVE merges (other entries survive). The upstream
14
- // key (UPSTREAM_PLUGIN_KEY) is never written; doctor warns
15
- // while it is live.
16
- // migrateExisting copies a legacy ~/.claude-mem forward into ~/.free-mem
17
- // (migrate.ts); the legacy directory is only read.
7
+ // install bun run build:plugin -> copyPluginToCache(version) -> copyPluginToMarketplace
8
+ // -> plugin deps install -> .install-version markers.
9
+ // registerHooks known_marketplaces.json + installed_plugins.json + enabledPlugins
10
+ // ["free-mem@freeharness"], all ADDITIVE merges; the upstream key
11
+ // (UPSTREAM_PLUGIN_KEY) is never written; doctor warns while it is live.
18
12
  //
19
- // The FreeHarness build declares its own identity (marketplace freeharness, plugin
20
- // free-mem) so `doctor` and `cleanup` can tell it apart from an upstream or
21
- // pre-rename install. Every write honors ctx.dryRun (writes nothing, reports the plan).
13
+ // The FreeHarness build declares its own identity (marketplace freeharness, plugin free-mem) so `doctor` and
14
+ // `cleanup` can tell it apart from an upstream or pre-rename install. Every write honors ctx.dryRun.
22
15
  import crypto from "node:crypto";
23
16
  import fs from "node:fs";
24
17
  import os from "node:os";
25
18
  import path from "node:path";
26
19
  import { spawnSync } from "node:child_process";
27
- import { cliVersion } from "../../infra/paths.js";
28
- import { applyMemoryMigration, bunIntegrityCheck, describePlan, describeResult, planMemoryMigration } from "./migrate.js";
29
- import { FREE_DIR_NAME, LEGACY_DIR_NAME, resolveMemoryPaths } from "./paths.js";
30
20
  const IS_WINDOWS = process.platform === "win32";
31
21
  /** Name of the digest marker written into the plugin cache directory after a copy. */
32
22
  const BUNDLE_MARKER_NAME = "bundle.sha256";
@@ -284,7 +274,7 @@ function writeJsonAtomic(file, value) {
284
274
  fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
285
275
  fs.renameSync(tmp, file);
286
276
  }
287
- /** Write the `.install-version` marker that tells our build apart; never writes through a symlink. `uv` stays for readers of the old shape. */
277
+ /** Write the `.install-version` marker that tells our build apart; never writes through a symlink. */
288
278
  export function writeInstallMarker(dir, version, bun) {
289
279
  const marker = path.join(dir, ".install-version");
290
280
  fs.mkdirSync(dir, { recursive: true });
@@ -476,43 +466,8 @@ export class VendoredMemory {
476
466
  res.notes.push("Lifecycle hooks and the mem-search MCP come from the plugin's own hooks.json / .mcp.json (no settings.json hook entries are written).");
477
467
  return res;
478
468
  }
479
- /**
480
- * Copy a legacy ~/.claude-mem forward into ~/.free-mem: pass 1 prints the plan, pass 2 applies
481
- * it. A worker it stops is restarted by `update` once the bundle is staged, else at SessionStart.
482
- * The copy is never recorded in `written`, since uninstall removes what the manifest lists.
483
- */
484
- async migrateExisting(ctx) {
485
- const res = this.result();
486
- const home = os.homedir();
487
- const plan = planMemoryMigration(home, process.env);
488
- if (!plan) {
489
- const p = resolveMemoryPaths(process.env, home);
490
- const freeDir = path.join(home, FREE_DIR_NAME);
491
- const db = fs.existsSync(p.dbFile) ? ` (${path.basename(p.dbFile)} present)` : "";
492
- if (p.source !== "default")
493
- res.notes.push(`Existing memory data kept at ${p.dataDir}${db}; the ${PLUGIN_NAME} plugin reads it through ${p.source}, so no copy is planned. Nothing deleted.`);
494
- else if (fs.existsSync(freeDir))
495
- res.notes.push(`Memory data already at ${freeDir}; no copy planned.`);
496
- else
497
- res.notes.push(`No legacy memory data at ${path.join(home, LEGACY_DIR_NAME)}; the worker creates ${freeDir} on first start.`);
498
- return res;
499
- }
500
- if (ctx.dryRun) {
501
- res.notes.push(...describePlan(plan));
502
- return res;
503
- }
504
- const result = await applyMemoryMigration(plan, {
505
- stopWorker: async () => (await import("./doctor.js")).stopWorker("shutdown"),
506
- now: Date.now,
507
- integrityCheck: bunIntegrityCheck(probeBun().path),
508
- version: cliVersion(),
509
- });
510
- const lines = describeResult(plan, result);
511
- res.notes.push(...lines);
512
- // The executor prints only the preview's notes, so the outcome of pass 2 is printed here.
513
- for (const l of lines)
514
- console.log(`memory: ${l}`);
515
- return res;
469
+ async migrateExisting(_ctx) {
470
+ return this.result();
516
471
  }
517
472
  run(cmd, args, cwd, label) {
518
473
  const r = spawnSync(cmd, args, { cwd, stdio: "inherit", windowsHide: true, env: process.env });
@@ -0,0 +1,9 @@
1
+ import fs from "node:fs";
2
+ export interface StopResult {
3
+ /** True when no worker answers and no live pid file remains. */
4
+ stopped: boolean;
5
+ wasRunning: boolean;
6
+ reason?: string;
7
+ }
8
+ /** True when `pidFile` exists and names a live process, or cannot be read as a pid record. */
9
+ export declare function pidFileLive(pidFile: string, f?: typeof fs): boolean;
@@ -0,0 +1,30 @@
1
+ // The worker's pid-file check and the stop outcome shared by the doctor's stop and restart paths.
2
+ import fs from "node:fs";
3
+ function readJsonText(raw) {
4
+ try {
5
+ return JSON.parse(raw) ?? {};
6
+ }
7
+ catch {
8
+ return {};
9
+ }
10
+ }
11
+ /** True when `pidFile` exists and names a live process, or cannot be read as a pid record. */
12
+ export function pidFileLive(pidFile, f = fs) {
13
+ let raw;
14
+ try {
15
+ raw = f.readFileSync(pidFile, "utf8");
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ const pid = Number(readJsonText(raw).pid);
21
+ if (!Number.isInteger(pid) || pid <= 0)
22
+ return true;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch (e) {
28
+ return e.code === "EPERM";
29
+ }
30
+ }
@@ -71,7 +71,7 @@ export declare function globalNpmPackages(): Record<string, string> | null;
71
71
  export declare function globalNpmFreeharnessBin(): string | undefined;
72
72
  /**
73
73
  * Memory data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), sorted.
74
- * Backups of either default directory name count, whichever directory is in use now.
74
+ * Backups of the default directory count even while a redirected directory is in use.
75
75
  */
76
76
  export declare function memoryBackupDirs(env?: Record<string, string | undefined>, home?: string): string[];
77
77
  /** Drops install-manifest entries whose file no longer exists (else every removal is "drift"); 0 without a manifest. */
@@ -17,8 +17,8 @@ import path from "node:path";
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { PLUGIN_KEY, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
19
19
  import { manifestPath, readManifest } from "../infra/manifest.js";
20
- import { configDir, diagnoseMemory } from "../adapters/memory/doctor.js";
21
- import { FREE_DIR_NAME, LEGACY_DIR_NAME, resolveMemoryPaths } from "../adapters/memory/paths.js";
20
+ import { configDir, diagnoseMemory, foreignWorkerStep } from "../adapters/memory/doctor.js";
21
+ import { FREE_DIR_NAME, resolveMemoryPaths } from "../adapters/memory/paths.js";
22
22
  import { canonicalNames, memoryPluginInstalled, RETIRED, retiredFindings, runsRetiredHook } from "./install-facts.js";
23
23
  import { isClaudeMemHook, isOurHook, pruneHooksMatching } from "./uninstall.js";
24
24
  import { archiveMove, archiveRootFor, isOrHolds, moveWarning, pluginCacheArchive, pluginCacheOverlap, pluginCacheRefusal } from "./hard-cleanup.js";
@@ -113,11 +113,11 @@ export function globalNpmFreeharnessBin() {
113
113
  }
114
114
  /**
115
115
  * Memory data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), sorted.
116
- * Backups of either default directory name count, whichever directory is in use now.
116
+ * Backups of the default directory count even while a redirected directory is in use.
117
117
  */
118
118
  export function memoryBackupDirs(env = process.env, home = os.homedir()) {
119
119
  const live = resolveMemoryPaths(env, home).dataDir;
120
- const dirs = [live, path.join(home, FREE_DIR_NAME), path.join(home, LEGACY_DIR_NAME)];
120
+ const dirs = [live, path.join(home, FREE_DIR_NAME)];
121
121
  const found = new Set();
122
122
  for (const data of dirs) {
123
123
  const parent = path.dirname(data);
@@ -160,6 +160,18 @@ function toArchive(item, cfg, archiveRoot) {
160
160
  return { ...item, archiveDest: archive.destination, refused: item.refused ?? archive.refused };
161
161
  }
162
162
  const UPSTREAM_ENABLED_REFUSAL = `the ${UPSTREAM_PLUGIN_KEY} key is still enabled — disable it first: \`claude plugin disable ${UPSTREAM_PLUGIN_KEY}\``;
163
+ /**
164
+ * Refusal while the build is not the sole active plugin. `memory activate` cannot clear a foreign
165
+ * worker beside our build, so that case names the doctor FAIL line's steps, as the mixed VERDICT does.
166
+ */
167
+ function notOursRefusal(d) {
168
+ const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
169
+ const head = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")})`;
170
+ const step = d.verdict === "mixed" ? foreignWorkerStep(d.worker) : null;
171
+ if (step === null)
172
+ return `${head}; run \`freeharness memory activate\` first`;
173
+ return `${head}; stop the foreign worker first (${step})`;
174
+ }
163
175
  /** Computes the user-scope cleanup list; nothing is touched. */
164
176
  async function planUser(deps) {
165
177
  const archiveRoot = deps.archiveRoot ?? archiveRootFor(os.homedir(), new Date());
@@ -171,10 +183,8 @@ async function planUser(deps) {
171
183
  if (fs.existsSync(disabled)) {
172
184
  const d = deps.memory ?? (await diagnoseMemory());
173
185
  const item = { kind: "dir", target: disabled, reason: "upstream claude-mem parked by `memory activate`", group: "memory", bytes: dirBytes(disabled) };
174
- if (d.verdict !== "ours") {
175
- const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
176
- item.refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
177
- }
186
+ if (d.verdict !== "ours")
187
+ item.refused = notOursRefusal(d);
178
188
  plan.items.push(toArchive(item, cfg, archiveRoot));
179
189
  }
180
190
  // Superseded FreeHarness builds: our own cache versions (.install-version present) that are
@@ -232,8 +242,7 @@ async function planUser(deps) {
232
242
  refused = UPSTREAM_ENABLED_REFUSAL;
233
243
  }
234
244
  else if (d.verdict !== "ours") {
235
- const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
236
- refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
245
+ refused = notOursRefusal(d);
237
246
  }
238
247
  if (fs.existsSync(oldCacheDir)) {
239
248
  plan.items.push(toArchive({ kind: "dir", target: oldCacheDir, reason: "previous registration, left in the plugin cache", group: "memory", bytes: dirBytes(oldCacheDir), refused }, cfg, archiveRoot));
@@ -282,17 +291,23 @@ async function planUser(deps) {
282
291
  plan.items.push({ kind: "npm-global", target: CLI_PACKAGE, reason: `npm -g ${CLI_PACKAGE} (old npm link; shadows ~/.local/bin)${linkedBin ? ` at ${linkedBin}` : ""}`, group: "npm", unlink: true });
283
292
  }
284
293
  }
285
- // Retired names: a skill or agent is listed only when the manifest records the path, so a
286
- // same-named user-owned one stays untouched (doctor reports that case as INFO, never here).
294
+ // Retired names: a skill, agent or file is listed only when the manifest records the path,
295
+ // so a same-named user-owned one stays untouched (doctor reports that case as INFO, never here).
287
296
  // A hook script lives in FreeHarness's own hooks dir, so its presence is enough.
288
297
  const manifest = readManifest(manifestPath("user", ""));
298
+ const retiredReason = {
299
+ hook: (name) => `retired hook ${name} (removed from FreeHarness; update never deletes the installed copy)`,
300
+ file: () => "retired rules file (FreeHarness no longer emits it; update never deletes the installed copy)",
301
+ skill: () => "retired skill (renamed or folded elsewhere; kept on disk by update's additive manifest merge)",
302
+ agent: () => "retired agent (renamed or folded elsewhere; kept on disk by update's additive manifest merge)",
303
+ };
289
304
  for (const f of retiredFindings(path.join(os.homedir(), ".claude"), manifest)) {
290
305
  if (!f.recorded && f.kind !== "hook")
291
306
  continue;
292
307
  plan.items.push({
293
308
  kind: f.kind === "skill" ? "dir" : "file",
294
309
  target: f.path,
295
- reason: f.kind === "hook" ? `retired hook ${f.name} (removed from FreeHarness; update never deletes the installed copy)` : `retired ${f.kind} (renamed or folded elsewhere; kept on disk by update's additive manifest merge)`,
310
+ reason: retiredReason[f.kind](f.name),
296
311
  group: "retired",
297
312
  bytes: f.kind === "skill" ? dirBytes(f.path) : undefined,
298
313
  });
@@ -347,7 +362,7 @@ function planProject(projectRoot, deps) {
347
362
  }
348
363
  const oldSafety = path.join(fhDir, "safety.md");
349
364
  if (fs.existsSync(oldSafety))
350
- plan.items.push({ kind: "file", target: oldSafety, reason: "old project-scope safety.md (global since v0.3.0)", group: "duplicates" });
365
+ plan.items.push({ kind: "file", target: oldSafety, reason: "old project-scope safety.md (FreeHarness emits no safety.md)", group: "duplicates" });
351
366
  plan.notes.push(`kept: ${path.join(claude, "commands", "opsx")} and ${path.join(claude, "skills", "openspec-*")} (ours)`);
352
367
  return plan;
353
368
  }
package/dist/cli/cost.js CHANGED
@@ -1948,7 +1948,7 @@ export function formatRoutingReport(result) {
1948
1948
  if (missingTasksFiles.length > 0)
1949
1949
  trailers.push(`tasks.md not found: ${missingTasksFiles.map(printable).join(", ")}`);
1950
1950
  if (skippedFiles.length > 0)
1951
- trailers.push(`skipped files (symlink or not a regular file, ledger over 16 MiB, brief over 1 MiB, brief past the 500-brief cap or with an over-cap header line, unlistable change directory, or unreadable): ${skippedFiles.map(printable).join(", ")}`);
1951
+ trailers.push(`skipped files (symlink or not a regular file, ledger, tasks.md or follow-ups.md over 16 MiB, brief or review package over 1 MiB, brief past the 500-brief cap or with an over-cap header line, unlistable change directory, or unreadable): ${skippedFiles.map(printable).join(", ")}`);
1952
1952
  if (result.calibrationWarning)
1953
1953
  trailers.push(printable(result.calibrationWarning));
1954
1954
  if (rows.length === 0)
@@ -1996,7 +1996,7 @@ function formatRoutingFigures({ reviewRows = [], lead, totalsPerAccepted, follow
1996
1996
  }
1997
1997
  }
1998
1998
  if (lead) {
1999
- lines.push(`lead (main-session tokens of telemetry records tagged with the change; a session is attributed by its change tag only): ${lead.sessions} session${lead.sessions === 1 ? "" : "s"}`);
1999
+ lines.push(`lead (main-session tokens of telemetry records; a session is attributed by its changes array, its tokens apportioned across the changes): ${lead.sessions} session${lead.sessions === 1 ? "" : "s"}`);
2000
2000
  for (const [family, g] of nonZero(lead.tokens))
2001
2001
  lines.push(` ${family}: cache-read ${fmtTokens(g.cache_read)}, fresh ${fmtTokens(g.fresh)}`);
2002
2002
  }
@@ -21,7 +21,7 @@ export declare function depLines(deps: DepStatus[]): string[];
21
21
  /** Hook conflicts in a settings.json: duplicate commands, a foreign memory-injecting SessionStart hook, claude-mem hooks kept while the plugin is installed; empty when settings.json is absent. */
22
22
  export declare function hookConflictLines(settingsPath: string, pluginInstalled: boolean): string[];
23
23
  /**
24
- * Skills and agents under a Claude dir that FreeHarness did not emit (the user's own content); never a global `openspec-*` copy or a manifest-recorded retired name.
24
+ * Skills, agents and retired files under a Claude dir that FreeHarness did not emit (the user's own content); never a global `openspec-*` copy or a manifest-recorded retired name.
25
25
  * @param home Home whose `.claude` holds the user-scope manifest; never derived from `claudeDir`.
26
26
  */
27
27
  export declare function foreignContentLines(claudeDir: string, canonicalDir: string, scope: Scope, projectRoot: string, home?: string): string[];
@@ -71,17 +71,17 @@ export declare function openspecChangeLines(projectRoot: string): string[];
71
71
  */
72
72
  export declare function staleTreeLines(projectRoot: string): string[];
73
73
  /**
74
- * Warns when the guard hooks fail closed and node is not resolvable: with onFailure block a
75
- * missing runtime denies every Edit, Write and Bash call, so the owner must hear it before
76
- * being locked out. PATH is scanned for absolute entries only and no shell runs, so a
77
- * relative entry cannot supply the binary; the settings file is only read. Only the first
78
- * existing binary is probed because it is the one the hook shell runs; a later healthy node
79
- * cannot rescue the hooks. win32 looks for node.exe alone, since spawnSync cannot run a .cmd
80
- * without a shell.
74
+ * Warns when a guard hook entry exists and node is not resolvable: a guard that cannot run
75
+ * allows, so a missing runtime silently leaves every Edit, Write and Bash call unguarded and
76
+ * the owner must hear it. Any guard entry counts, whatever its onFailure field. PATH is
77
+ * scanned for absolute entries only and no shell runs, so a relative entry cannot supply
78
+ * the binary; the settings file is only read. Only the first existing binary is probed because
79
+ * it is the one the hook shell runs; a later healthy node cannot rescue the hooks. win32 looks
80
+ * for node.exe alone, since spawnSync cannot run a .cmd without a shell.
81
81
  * @param settingsPath The user settings.json.
82
82
  * @param env Environment whose PATH is searched.
83
83
  * @param timeoutMs Version probe limit; tests shorten it.
84
- * @returns One OK or WARN line, or none when no blocking guard entry exists.
84
+ * @returns One OK or WARN line, or none when no guard entry exists.
85
85
  */
86
86
  export declare function guardRuntimeLines(settingsPath: string, env?: NodeJS.ProcessEnv, timeoutMs?: number): string[];
87
87
  /**
@@ -98,14 +98,18 @@ export function hookConflictLines(settingsPath, pluginInstalled) {
98
98
  return lines;
99
99
  }
100
100
  /**
101
- * Skills and agents under a Claude dir that FreeHarness did not emit (the user's own content); never a global `openspec-*` copy or a manifest-recorded retired name.
101
+ * Skills, agents and retired files under a Claude dir that FreeHarness did not emit (the user's own content); never a global `openspec-*` copy or a manifest-recorded retired name.
102
102
  * @param home Home whose `.claude` holds the user-scope manifest; never derived from `claudeDir`.
103
103
  */
104
104
  export function foreignContentLines(claudeDir, canonicalDir, scope, projectRoot, home = os.homedir()) {
105
105
  const names = canonicalNames(canonicalDir);
106
106
  const manifest = readManifest(manifestPath(scope, projectRoot, home));
107
- const recordedRetired = new Set(retiredFindings(claudeDir, manifest).filter((f) => f.recorded).map((f) => f.path));
107
+ const retired = retiredFindings(claudeDir, manifest);
108
+ const recordedRetired = new Set(retired.filter((f) => f.recorded).map((f) => f.path));
108
109
  const lines = [];
110
+ for (const f of retired)
111
+ if (f.kind === "file" && !f.recorded)
112
+ lines.push(`INFO file ${f.name} not from FreeHarness (yours; kept)`);
109
113
  const skillsDir = path.join(claudeDir, "skills");
110
114
  if (fs.existsSync(skillsDir)) {
111
115
  for (const e of fs.readdirSync(skillsDir, { withFileTypes: true })) {
@@ -280,20 +284,20 @@ export function staleTreeLines(projectRoot) {
280
284
  return lines;
281
285
  }
282
286
  /**
283
- * Warns when the guard hooks fail closed and node is not resolvable: with onFailure block a
284
- * missing runtime denies every Edit, Write and Bash call, so the owner must hear it before
285
- * being locked out. PATH is scanned for absolute entries only and no shell runs, so a
286
- * relative entry cannot supply the binary; the settings file is only read. Only the first
287
- * existing binary is probed because it is the one the hook shell runs; a later healthy node
288
- * cannot rescue the hooks. win32 looks for node.exe alone, since spawnSync cannot run a .cmd
289
- * without a shell.
287
+ * Warns when a guard hook entry exists and node is not resolvable: a guard that cannot run
288
+ * allows, so a missing runtime silently leaves every Edit, Write and Bash call unguarded and
289
+ * the owner must hear it. Any guard entry counts, whatever its onFailure field. PATH is
290
+ * scanned for absolute entries only and no shell runs, so a relative entry cannot supply
291
+ * the binary; the settings file is only read. Only the first existing binary is probed because
292
+ * it is the one the hook shell runs; a later healthy node cannot rescue the hooks. win32 looks
293
+ * for node.exe alone, since spawnSync cannot run a .cmd without a shell.
290
294
  * @param settingsPath The user settings.json.
291
295
  * @param env Environment whose PATH is searched.
292
296
  * @param timeoutMs Version probe limit; tests shorten it.
293
- * @returns One OK or WARN line, or none when no blocking guard entry exists.
297
+ * @returns One OK or WARN line, or none when no guard entry exists.
294
298
  */
295
299
  export function guardRuntimeLines(settingsPath, env = process.env, timeoutMs = 5000) {
296
- const tail = "the guard hooks fail closed and block every Edit, Write and Bash call";
300
+ const tail = "the guard hooks cannot run and allow every Edit, Write and Bash call";
297
301
  let settings;
298
302
  try {
299
303
  settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
@@ -306,8 +310,8 @@ export function guardRuntimeLines(settingsPath, env = process.env, timeoutMs = 5
306
310
  const asList = (v) => (Array.isArray(v) ? v : []);
307
311
  const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
308
312
  const events = isRecord(settings) && isRecord(settings.hooks) ? Object.values(settings.hooks) : [];
309
- const blocking = events.some((groups) => asList(groups).some((g) => isRecord(g) && asList(g.hooks).some((h) => isRecord(h) && typeof h.command === "string" && h.onFailure === "block" && /freeharness-(config|memory)-guard/.test(h.command))));
310
- if (!blocking)
313
+ const guarded = events.some((groups) => asList(groups).some((g) => isRecord(g) && asList(g.hooks).some((h) => isRecord(h) && typeof h.command === "string" && /freeharness-(config|memory)-guard/.test(h.command))));
314
+ if (!guarded)
311
315
  return [];
312
316
  const names = process.platform === "win32" ? ["node.exe"] : ["node"];
313
317
  for (const dir of (env.PATH ?? "").split(path.delimiter)) {
@@ -511,15 +515,16 @@ export function autoMemoryLine(settingsFile, freeMemEnabled) {
511
515
  : `INFO auto memory on (autoMemoryEnabled ${shown}); ${PLUGIN_KEY} not enabled`;
512
516
  }
513
517
  /**
514
- * With agent teams on, a named Agent dispatch launches as a teammate at the lead's effort, so
515
- * the owner is told why SDD dispatches carry no name. Either the settings `env` or the process
518
+ * With agent teams on, a named Agent dispatch launches as a teammate that inherits the session
519
+ * effort, except a plugin-defined agent spawned by name, which keeps its own; the owner is told
520
+ * why SDD dispatches carry no name. Either the settings `env` or the process
516
521
  * environment turns the flag on.
517
522
  */
518
523
  function withAgentTeamsNotice(line, settings) {
519
524
  const s = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
520
525
  const env = s.env && typeof s.env === "object" && !Array.isArray(s.env) ? s.env : {};
521
526
  const on = env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" || process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1";
522
- return on ? `${line}\nINFO agent teams on (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1): a named Agent dispatch launches as a teammate at the lead's effort; SDD dispatches pass no name` : line;
527
+ return on ? `${line}\nINFO agent teams on (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1): a named Agent dispatch launches as a teammate at the session effort (a plugin-defined agent spawned by name keeps its own); SDD dispatches pass no name` : line;
523
528
  }
524
529
  /** ms; a local server that misses this is down. */
525
530
  export const PROBE_TIMEOUT_MS = 1500;
@@ -13,7 +13,7 @@ import { removeJsonKey } from "./json-key-edit.js";
13
13
  export const LOG_RETENTION_DAYS = 14;
14
14
  /** Build and OS junk moved out of a FreeHarness checkout's canonical/. */
15
15
  const CANONICAL_JUNK = new Set(["node_modules", "__pycache__", ".venv", ".DS_Store"]);
16
- const LOG_NAME = /^claude-mem-(\d{4})-(\d{2})-(\d{2})\.log$/;
16
+ const LOG_NAME = /^free-mem-(\d{4})-(\d{2})-(\d{2})\.log$/;
17
17
  /** `<file>.<stamp>.bak`, the stamp being `toISOString()` (always with milliseconds) with `:` and `.` replaced by `-`. */
18
18
  const BACKUP_NAME = /^(.+)\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.bak$/;
19
19
  const REINSTALL = "freeharness init --scope user --only memory";
@@ -1,16 +1,17 @@
1
1
  import type { InstallManifest } from "../infra/manifest.js";
2
- /** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`). */
2
+ /** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`; files: paths relative to `.claude`). */
3
3
  export declare const RETIRED: {
4
4
  readonly skills: readonly ["local-code-intel"];
5
5
  readonly agents: readonly ["mle-reviewer", "mle-evaluator", "implementer-medium", "implementer-high"];
6
6
  readonly hooks: readonly ["freeharness-auto-memory.mjs"];
7
+ readonly files: readonly ["freeharness/safety.md"];
7
8
  };
8
- export type RetiredKind = "skill" | "agent" | "hook";
9
+ export type RetiredKind = "skill" | "agent" | "hook" | "file";
9
10
  export interface RetiredFinding {
10
11
  kind: RetiredKind;
11
12
  name: string;
12
13
  path: string;
13
- /** Manifest lists `path` (agent, hook) or a file under it (skill dir). */
14
+ /** Manifest lists `path` (agent, hook, file) or a file under it (skill dir). */
14
15
  recorded: boolean;
15
16
  }
16
17
  /** Whether a settings hook command runs a retired hook script; the entries `cleanup` prunes beside the script. */
@@ -1,11 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { PLUGIN_KEY } from "../adapters/memory/vendored.js";
4
- /** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`). */
4
+ /** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`; files: paths relative to `.claude`). */
5
5
  export const RETIRED = {
6
6
  skills: ["local-code-intel"],
7
7
  agents: ["mle-reviewer", "mle-evaluator", "implementer-medium", "implementer-high"],
8
8
  hooks: ["freeharness-auto-memory.mjs"],
9
+ files: ["freeharness/safety.md"],
9
10
  };
10
11
  /** Whether a settings hook command runs a retired hook script; the entries `cleanup` prunes beside the script. */
11
12
  export function runsRetiredHook(command) {
@@ -17,6 +18,7 @@ export function retiredFindings(claudeDir, manifest) {
17
18
  { kind: "skill", dir: "skills", names: RETIRED.skills },
18
19
  { kind: "agent", dir: "agents", names: RETIRED.agents },
19
20
  { kind: "hook", dir: path.join("freeharness", "hooks"), names: RETIRED.hooks },
21
+ { kind: "file", dir: "", names: RETIRED.files },
20
22
  ];
21
23
  const found = [];
22
24
  for (const { kind, dir, names } of groups) {
@@ -144,7 +144,7 @@ export declare function applyProviderProfile(profile: Profile, data: ProviderPro
144
144
  */
145
145
  export declare function mcpCatalogItems(data: McpProfileFile): SelectItem[];
146
146
  /**
147
- * The `claude mcp add` command for a catalog entry (same shape the `mcp` capability emits).
147
+ * The `claude mcp add` command guarded by `claude mcp get` (as the `mcp` capability emits).
148
148
  * @param s Catalog entry.
149
149
  * @param scope user -> `-s user`, project -> `-s local`.
150
150
  * @returns Shell command.
@@ -1,10 +1,8 @@
1
- // Opt-in profiles (v4 P6): `init --profile <id>` applies one data file from
2
- // canonical/profiles/ to an existing target. Three kinds: a Claude Code env block
3
- // (local-ollama), an OpenCode provider block + notes file (local-llamacpp) and an MCP
4
- // catalog with a selection prompt (mcp). Every write is additive (a key the user set is
5
- // kept), previewed first, confirmed second, and recorded in the install manifest so
6
- // `--profile none` removes exactly the keys and files a profile wrote. index.ts composes
7
- // the two passes; nothing here prompts.
1
+ // Opt-in profiles: `init --profile <id>` applies one data file from canonical/profiles/
2
+ // to an existing target (Claude Code env block, OpenCode provider block + notes file, or
3
+ // an MCP catalog). Every write is additive, previewed first, confirmed second, and
4
+ // recorded in the install manifest so `--profile none` removes exactly what a profile
5
+ // wrote. index.ts composes the two passes; nothing here prompts.
8
6
  import fs from "node:fs";
9
7
  import path from "node:path";
10
8
  import { PROFILES, PROFILE_NONE, getProfile, tierEnv } from "@freeharness/core";
@@ -211,7 +209,7 @@ export function mcpCatalogItems(data) {
211
209
  return data.servers.map((s) => ({ id: s.name, label: `~${s.tools_approx} tools — ${s.description} [${s.context_cost}]` }));
212
210
  }
213
211
  /**
214
- * The `claude mcp add` command for a catalog entry (same shape the `mcp` capability emits).
212
+ * The `claude mcp add` command guarded by `claude mcp get` (as the `mcp` capability emits).
215
213
  * @param s Catalog entry.
216
214
  * @param scope user -> `-s user`, project -> `-s local`.
217
215
  * @returns Shell command.
@@ -219,9 +217,11 @@ export function mcpCatalogItems(data) {
219
217
  export function mcpAddCommand(s, scope) {
220
218
  const sc = scope === "user" ? "user" : "local";
221
219
  const envFlags = Object.entries(s.env ?? {}).map(([k, v]) => `-e ${k}=${v}`).join(" ");
220
+ // `claude mcp add` exits 1 for a registered name, so get runs first.
221
+ const guard = `claude mcp get ${s.name} >/dev/null 2>&1 || `;
222
222
  if (s.type === "http" || s.type === "sse")
223
- return `claude mcp add --transport ${s.type} ${s.name} -s ${sc} ${s.url}`;
224
- return `claude mcp add ${s.name} -s ${sc}${envFlags ? ` ${envFlags}` : ""} -- ${s.command} ${(s.args ?? []).join(" ")}`;
223
+ return `${guard}claude mcp add --transport ${s.type} ${s.name} -s ${sc} ${s.url}`;
224
+ return `${guard}claude mcp add ${s.name} -s ${sc}${envFlags ? ` ${envFlags}` : ""} -- ${s.command} ${(s.args ?? []).join(" ")}`;
225
225
  }
226
226
  /**
227
227
  * Builds the MCP profile plan for a selection: one `claude mcp add` action per selected