freeharness 0.4.1 → 0.4.2

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 (194) hide show
  1. package/README.md +8 -7
  2. package/canonical/HARVEST.yaml +25 -14
  3. package/canonical/PROVENANCE.yaml +1 -1
  4. package/canonical/agents/README.md +77 -36
  5. package/canonical/agents/agentic-architect.md +2 -1
  6. package/canonical/agents/database-reviewer.md +6 -8
  7. package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
  8. package/canonical/agents/implementer.md +30 -0
  9. package/canonical/agents/pr-reviewer.md +9 -11
  10. package/canonical/agents/python-reviewer.md +7 -9
  11. package/canonical/agents/report-formatter.md +7 -3
  12. package/canonical/agents/security-reviewer.md +7 -8
  13. package/canonical/agents/test-writer.md +5 -2
  14. package/canonical/hooks/cost-prices.json +11 -9
  15. package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
  16. package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
  17. package/canonical/hooks/freeharness-converge.mjs +78 -23
  18. package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
  19. package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
  20. package/canonical/hooks/freeharness-precompact.mjs +217 -87
  21. package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
  22. package/canonical/hooks/freeharness-reload-context.mjs +197 -0
  23. package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
  24. package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
  25. package/canonical/hooks/freeharness-session-start +297 -59
  26. package/canonical/hooks/hooks.claude-code.json +1 -1
  27. package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
  28. package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
  29. package/canonical/hooks/hooks.converge.claude-code.json +1 -1
  30. package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
  31. package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
  32. package/canonical/openspec/schema.spec-driven.yaml +29 -8
  33. package/canonical/openspec/templates/follow-ups.md +3 -2
  34. package/canonical/openspec/templates/proposal.md +1 -1
  35. package/canonical/openspec/templates/tasks.md +8 -0
  36. package/canonical/rules/CLAUDE.md +5 -4
  37. package/canonical/rules/routing.md +10 -9
  38. package/canonical/skills/agent-evaluation/SKILL.md +17 -6
  39. package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
  40. package/canonical/skills/agentic-architecture/SKILL.md +4 -0
  41. package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
  42. package/canonical/skills/code-review/SKILL.md +9 -2
  43. package/canonical/skills/code-review/evals/cases.yaml +1118 -27
  44. package/canonical/skills/git-workflow/SKILL.md +2 -0
  45. package/canonical/skills/local-code/SKILL.md +2 -2
  46. package/canonical/skills/local-code/evals/cases.yaml +1 -1
  47. package/canonical/skills/report-formatting/SKILL.md +10 -2
  48. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
  49. package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
  50. package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
  51. package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
  52. package/canonical/skills/report-formatting/reference/authoring.md +25 -37
  53. package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
  54. package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
  55. package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
  56. package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
  57. package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
  58. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
  59. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
  60. package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
  61. package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
  62. package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
  63. package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
  64. package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
  65. package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
  66. package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
  67. package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
  68. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
  69. package/dist/adapters/harness/claude-code.d.ts +19 -3
  70. package/dist/adapters/harness/claude-code.js +204 -39
  71. package/dist/adapters/harness/opencode.js +16 -2
  72. package/dist/adapters/harness/plugin.js +28 -17
  73. package/dist/adapters/memory/doctor.d.ts +174 -29
  74. package/dist/adapters/memory/doctor.js +758 -134
  75. package/dist/adapters/memory/migrate.d.ts +95 -0
  76. package/dist/adapters/memory/migrate.js +497 -0
  77. package/dist/adapters/memory/paths.d.ts +22 -0
  78. package/dist/adapters/memory/paths.js +85 -0
  79. package/dist/adapters/memory/vendored.d.ts +56 -29
  80. package/dist/adapters/memory/vendored.js +191 -154
  81. package/dist/adapters/sync/diff.js +4 -1
  82. package/dist/cli/cleanup-prompt.d.ts +35 -0
  83. package/dist/cli/cleanup-prompt.js +68 -0
  84. package/dist/cli/cleanup.d.ts +22 -24
  85. package/dist/cli/cleanup.js +182 -68
  86. package/dist/cli/cost-quota.d.ts +158 -0
  87. package/dist/cli/cost-quota.js +645 -0
  88. package/dist/cli/cost.d.ts +408 -46
  89. package/dist/cli/cost.js +1731 -57
  90. package/dist/cli/doctor.d.ts +52 -13
  91. package/dist/cli/doctor.js +193 -44
  92. package/dist/cli/hard-cleanup.d.ts +93 -0
  93. package/dist/cli/hard-cleanup.js +552 -0
  94. package/dist/cli/install-facts.d.ts +28 -0
  95. package/dist/cli/install-facts.js +66 -0
  96. package/dist/cli/json-key-edit.d.ts +18 -0
  97. package/dist/cli/json-key-edit.js +118 -0
  98. package/dist/cli/memory-import.d.ts +74 -0
  99. package/dist/cli/memory-import.js +469 -0
  100. package/dist/cli/plugin-validate.d.ts +12 -0
  101. package/dist/cli/plugin-validate.js +43 -0
  102. package/dist/cli/uninstall.d.ts +1 -1
  103. package/dist/cli/uninstall.js +2 -2
  104. package/dist/cli/upstream-launchers.d.ts +49 -0
  105. package/dist/cli/upstream-launchers.js +287 -0
  106. package/dist/index.js +247 -70
  107. package/dist/infra/fsutil.d.ts +31 -3
  108. package/dist/infra/fsutil.js +69 -6
  109. package/dist/infra/manifest.d.ts +2 -1
  110. package/dist/infra/manifest.js +3 -2
  111. package/dist/infra/prices.d.ts +18 -0
  112. package/dist/infra/prices.js +64 -0
  113. package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
  114. package/node_modules/@freeharness/core/dist/tiers.js +24 -2
  115. package/node_modules/@freeharness/core/package.json +1 -1
  116. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
  117. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
  118. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
  119. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
  120. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
  121. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
  122. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
  123. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
  124. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
  125. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
  126. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
  127. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
  128. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
  129. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
  130. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
  131. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
  132. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
  133. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
  134. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
  135. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
  136. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
  137. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
  138. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
  139. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
  140. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
  141. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
  142. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
  143. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
  144. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
  145. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
  146. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
  147. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
  148. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
  149. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
  150. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
  151. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
  152. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
  153. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
  154. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
  155. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
  156. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
  157. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
  158. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
  159. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
  160. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
  161. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
  162. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
  163. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
  164. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
  165. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
  166. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
  167. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
  168. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
  169. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
  170. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
  171. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
  172. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
  173. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
  174. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
  175. package/node_modules/@freeharness/sdd/package.json +1 -1
  176. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
  177. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
  178. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
  179. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
  180. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
  181. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
  182. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
  183. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
  184. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
  185. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
  186. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
  187. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
  188. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
  189. package/package.json +6 -4
  190. package/provenance/canonical.PROVENANCE.yaml +1 -1
  191. package/provenance/memory.PROVENANCE.yaml +458 -6
  192. package/provenance/sdd.PROVENANCE.yaml +279 -2
  193. package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
  194. package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
@@ -4,28 +4,29 @@
4
4
  // upstream `npx claude-mem install` mechanics (src/npx-cli/commands/install.ts +
5
5
  // utils/paths.ts + install/setup-runtime.ts) in TypeScript:
6
6
  //
7
- // ensurePrerequisites Bun (build + hook runtime) and uv (Chroma) probes.
7
+ // ensurePrerequisites Bun probe (build + hook runtime), the only prerequisite.
8
8
  // install bun run build:plugin -> copyPluginToCache(version)
9
9
  // -> copyPluginToMarketplace -> plugin deps install
10
10
  // -> .install-version markers.
11
11
  // registerHooks known_marketplaces.json + installed_plugins.json +
12
12
  // settings.enabledPlugins["free-mem@freeharness"]=true,
13
- // all ADDITIVE merges (other entries survive). When an
14
- // upstream registration (UPSTREAM_PLUGIN_KEY) is found, it
15
- // also backs up the three registration files, registers the
16
- // new key and disables (never deletes) the old one.
17
- // migrateExisting an existing ~/.claude-mem data dir is kept; the plugin
18
- // already resolves it by convention (nothing is deleted).
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.
19
18
  //
20
19
  // The FreeHarness build declares its own identity (marketplace freeharness, plugin
21
20
  // free-mem) so `doctor` and `cleanup` can tell it apart from an upstream or
22
- // pre-rename install; the data directory stays ~/.claude-mem by convention on
23
- // both sides. Every write honors ctx.dryRun (writes nothing, reports the plan).
21
+ // pre-rename install. Every write honors ctx.dryRun (writes nothing, reports the plan).
24
22
  import crypto from "node:crypto";
25
23
  import fs from "node:fs";
26
24
  import os from "node:os";
27
25
  import path from "node:path";
28
26
  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";
29
30
  const IS_WINDOWS = process.platform === "win32";
30
31
  /** Name of the digest marker written into the plugin cache directory after a copy. */
31
32
  const BUNDLE_MARKER_NAME = "bundle.sha256";
@@ -45,7 +46,7 @@ const CAPABILITY_ID = "memory";
45
46
  * canonicalDir: The resolved FreeHarness canonical directory (packages/cli/canonical).
46
47
  *
47
48
  * Returns:
48
- * Absolute engine path, or null when the vendored tree is absent (npx bridge).
49
+ * Absolute engine path, or null when the vendored tree is absent (memory is then skipped).
49
50
  */
50
51
  export function vendoredMemoryEngineDir(canonicalDir) {
51
52
  const engineDir = path.join(canonicalDir, "..", "..", "memory", "vendor", UPSTREAM_PLUGIN_NAME, "engine");
@@ -63,7 +64,7 @@ export function vendoredMemoryEngineDir(canonicalDir) {
63
64
  export function memoryBuildActions(engineDir) {
64
65
  return [
65
66
  {
66
- description: "Build the vendored claude-mem plugin bundles (worker, mcp, context, watcher) with Bun",
67
+ description: "Build the free-mem plugin bundles (worker, mcp, context, watcher) from the vendored engine with Bun",
67
68
  command: "bun install && bun run build:plugin",
68
69
  cwd: engineDir,
69
70
  optional: true,
@@ -93,42 +94,127 @@ export function claudePluginPaths(version) {
93
94
  };
94
95
  }
95
96
  /**
96
- * Content digest of a plugin bundle: every file's relative posix path and bytes, in
97
- * path order, so a same-version rebuild whose output is byte-identical digests the
98
- * same as the previous run. Skips `node_modules` (matches the copy filter) and the
99
- * marker file itself, so re-staging never invalidates its own guard.
97
+ * Top-level entries the cache gets after the copy (our markers, the deps lockfile, Claude
98
+ * Code's in-use and orphan markers): never copied and never digested, so staging cannot write
99
+ * through a plugin symlink and an install never reads its own bookkeeping as tampering.
100
+ */
101
+ const UNSTAGED_ENTRIES = new Set([BUNDLE_MARKER_NAME, ".install-version", "bun.lock", "bun.lockb", ".in_use", ".orphaned_at"]);
102
+ const S_IFMT = 0o170000;
103
+ /** Built bundles a plugin dir needs before it may replace a cached build. */
104
+ export const PLUGIN_BUNDLES = ["bun-runner.js", "worker-service.cjs", "mcp-server.cjs"];
105
+ /**
106
+ * Digest of a plugin bundle over every entry's relpath, type, mode and content or link target,
107
+ * in path order. Walks with lstat and never follows a symlink. Skips `node_modules` and the
108
+ * unstaged entries, so the vendored plugin and its staged copy digest alike.
100
109
  *
101
110
  * Args:
102
111
  * dir: Directory to digest recursively (absent dir digests as empty).
103
112
  *
104
113
  * Returns:
105
- * Lowercase hex SHA-256 over `path + NUL + content + NUL` per file.
114
+ * Lowercase hex SHA-256.
106
115
  */
107
116
  export function bundleDigest(dir) {
108
- const files = [];
117
+ const entries = [];
109
118
  const walk = (rel) => {
110
- for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
111
- if (entry.name === "node_modules" || entry.name === BUNDLE_MARKER_NAME)
119
+ for (const name of fs.readdirSync(path.join(dir, rel))) {
120
+ if (name === "node_modules" || (!rel && UNSTAGED_ENTRIES.has(name)))
112
121
  continue;
113
- const childRel = rel ? `${rel}/${entry.name}` : entry.name;
114
- if (entry.isDirectory())
122
+ const childRel = rel ? `${rel}/${name}` : name;
123
+ const abs = path.join(dir, childRel);
124
+ const stat = fs.lstatSync(abs);
125
+ if (!stat.isFile() && !stat.isDirectory() && !stat.isSymbolicLink())
126
+ continue;
127
+ entries.push({ rel: childRel, abs, stat });
128
+ if (stat.isDirectory())
115
129
  walk(childRel);
116
- else if (entry.isFile())
117
- files.push(childRel);
118
130
  }
119
131
  };
120
132
  if (fs.existsSync(dir))
121
133
  walk("");
122
- files.sort();
134
+ entries.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
123
135
  const hash = crypto.createHash("sha256");
124
- for (const rel of files) {
125
- hash.update(rel, "utf-8");
126
- hash.update("\0");
127
- hash.update(fs.readFileSync(path.join(dir, rel)));
136
+ for (const { rel, abs, stat } of entries) {
137
+ const type = stat.isSymbolicLink() ? "l" : stat.isDirectory() ? "d" : "f";
138
+ // Windows has no exec bits and symlink permissions carry no meaning.
139
+ const mode = IS_WINDOWS || type === "l" ? stat.mode & S_IFMT : stat.mode;
140
+ for (const part of [rel, type, mode.toString(8)]) {
141
+ hash.update(part, "utf-8");
142
+ hash.update("\0");
143
+ }
144
+ if (type === "f")
145
+ hash.update(fs.readFileSync(abs));
146
+ else if (type === "l")
147
+ hash.update(fs.readlinkSync(abs), "utf-8");
128
148
  hash.update("\0");
129
149
  }
130
150
  return hash.digest("hex");
131
151
  }
152
+ /**
153
+ * Stage a plugin dir into a cache dir unless the marker and the cache's own digest both equal
154
+ * the plugin digest and the declared deps resolve; `staged` is true for a (would-be) re-stage.
155
+ */
156
+ export function stagePluginCache(pluginDir, cacheDir, opts) {
157
+ const digest = bundleDigest(pluginDir);
158
+ const short = digest.slice(0, 8);
159
+ const markerFile = path.join(cacheDir, BUNDLE_MARKER_NAME);
160
+ const marker = fs.existsSync(markerFile) ? fs.readFileSync(markerFile, "utf-8").trim() : null;
161
+ if (marker === digest && bundleDigest(cacheDir) === marker && missingPluginDeps(cacheDir).length === 0) {
162
+ return { staged: false, digest, notes: [`plugin cache up to date (bundle ${short})`] };
163
+ }
164
+ if (opts.dryRun)
165
+ return { staged: true, digest, notes: [`Would re-stage the plugin cache at ${cacheDir} (bundle ${short}).`] };
166
+ fs.rmSync(cacheDir, { recursive: true, force: true });
167
+ fs.mkdirSync(cacheDir, { recursive: true });
168
+ const root = path.resolve(pluginDir);
169
+ const unstaged = (src) => path.basename(src) === "node_modules" || (path.resolve(path.dirname(src)) === root && UNSTAGED_ENTRIES.has(path.basename(src)));
170
+ fs.cpSync(pluginDir, cacheDir, { recursive: true, force: true, verbatimSymlinks: true, filter: (src) => !unstaged(src) });
171
+ fs.writeFileSync(markerFile, `${digest}\n`, { encoding: "utf-8", flag: "wx" });
172
+ return { staged: true, digest, notes: [`Re-staged the plugin cache at ${cacheDir} (bundle ${short}).`] };
173
+ }
174
+ /** Identity of the cache marker file (absent: null); every re-stage writes a new one. */
175
+ export function stageStamp(cacheDir) {
176
+ const markerFile = path.join(cacheDir, BUNDLE_MARKER_NAME);
177
+ if (!fs.existsSync(markerFile))
178
+ return null;
179
+ const st = fs.lstatSync(markerFile);
180
+ return `${st.ino}:${st.mtimeMs}:${fs.readFileSync(markerFile, "utf-8").trim()}`;
181
+ }
182
+ export function missingPluginBundles(pluginDir) {
183
+ return PLUGIN_BUNDLES.filter((f) => !fs.existsSync(path.join(pluginDir, "scripts", f)));
184
+ }
185
+ /** Whether a plugin dir's package.json declares runtime deps, which only Bun installs. */
186
+ export function declaresPluginDeps(dir) {
187
+ const pkg = readJsonSafe(path.join(dir, "package.json"), {});
188
+ return Object.keys(pkg.dependencies ?? {}).length > 0;
189
+ }
190
+ /**
191
+ * Declared plugin deps that do not resolve from `<dir>/node_modules` (upstream
192
+ * verifyCriticalModules), plus the zod subpaths the worker requires when zod is declared.
193
+ */
194
+ export function missingPluginDeps(dir) {
195
+ const pkg = readJsonSafe(path.join(dir, "package.json"), {});
196
+ const deps = Object.keys(pkg.dependencies ?? {});
197
+ const nm = path.join(dir, "node_modules");
198
+ const missing = deps.filter((dep) => !fs.existsSync(path.join(nm, dep, "package.json")));
199
+ if (deps.includes("zod")) {
200
+ for (const sub of ["v3", "v4", "v4-mini"]) {
201
+ if (!fs.existsSync(path.join(nm, "zod", sub)))
202
+ missing.push(`zod/${sub}`);
203
+ }
204
+ }
205
+ return missing;
206
+ }
207
+ /** Install a staged cache's runtime deps (the hooks run from there); throws when one stays missing. */
208
+ export function installPluginDeps(dir, bunPath) {
209
+ const r = spawnSync(bunPath, ["install", "--ignore-scripts"], { cwd: dir, stdio: "inherit", windowsHide: true, env: process.env });
210
+ if (r.error)
211
+ throw new Error(`bun install (plugin deps) failed: ${r.error.message}`);
212
+ if (r.status !== 0)
213
+ throw new Error(`bun install (plugin deps) failed (exit ${r.status})`);
214
+ const missing = missingPluginDeps(dir);
215
+ if (missing.length > 0)
216
+ throw new Error(`plugin deps missing after install in ${dir}: ${missing.join(", ")}`);
217
+ }
132
218
  function probeTool(command, commonPaths) {
133
219
  const candidates = [command, ...commonPaths];
134
220
  for (const candidate of candidates) {
@@ -137,7 +223,7 @@ function probeTool(command, commonPaths) {
137
223
  try {
138
224
  const r = spawnSync(candidate, ["--version"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
139
225
  if (r.status === 0) {
140
- // "1.3.14" (bun) or "uv 0.9.2 (hash date)" -> first semver-looking token.
226
+ // "1.3.14" or "tool 0.9.2 (hash date)" -> first semver-looking token.
141
227
  const semver = r.stdout.match(/\d+\.\d+(?:\.\d+)?/);
142
228
  return { path: candidate, version: semver ? semver[0] : r.stdout.trim() };
143
229
  }
@@ -161,31 +247,17 @@ export function probeBun() {
161
247
  : [path.join(home, ".bun", "bin", "bun"), "/usr/local/bin/bun", "/opt/homebrew/bin/bun", "/home/linuxbrew/.linuxbrew/bin/bun"];
162
248
  return probeTool("bun", common);
163
249
  }
164
- /**
165
- * Probe uv (Chroma vector store runtime; upstream setup-runtime getUvPath).
166
- *
167
- * Returns:
168
- * Path + version, or nulls when absent.
169
- */
170
- export function probeUv() {
171
- const home = os.homedir();
172
- const common = IS_WINDOWS
173
- ? [path.join(home, ".local", "bin", "uv.exe"), path.join(home, ".cargo", "bin", "uv.exe")]
174
- : [path.join(home, ".local", "bin", "uv"), path.join(home, ".cargo", "bin", "uv"), "/usr/local/bin/uv", "/opt/homebrew/bin/uv"];
175
- return probeTool("uv", common);
176
- }
177
250
  /**
178
251
  * Whether an upstream registration (UPSTREAM_PLUGIN_KEY) is still live: present in
179
- * installed_plugins.json, or enabledPlugins carries it at a value other than `false`. Once a
180
- * migration sets it to `false`, later `registerHooks` runs see it as already migrated and skip
181
- * the backup/disable branch (idempotent on re-run).
252
+ * installed_plugins.json, or enabledPlugins carries it at a value other than `false`. Read-only:
253
+ * doctor warns on it and the data copy refuses while it is live.
182
254
  *
183
255
  * Args:
184
256
  * installed: Parsed installed_plugins.json (or `{}` when absent).
185
257
  * settings: Parsed settings.json (or `{}` when absent).
186
258
  *
187
259
  * Returns:
188
- * True when the upstream key still needs disabling.
260
+ * True when the upstream plugin may still load beside free-mem.
189
261
  */
190
262
  export function isUpstreamRegistered(installed, settings) {
191
263
  if (settings.enabledPlugins?.[UPSTREAM_PLUGIN_KEY] === false)
@@ -212,12 +284,36 @@ function writeJsonAtomic(file, value) {
212
284
  fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
213
285
  fs.renameSync(tmp, file);
214
286
  }
215
- function writeInstallMarker(dir, version, bun, uv) {
287
+ /** Write the `.install-version` marker that tells our build apart; never writes through a symlink. `uv` stays for readers of the old shape. */
288
+ export function writeInstallMarker(dir, version, bun) {
216
289
  const marker = path.join(dir, ".install-version");
217
290
  fs.mkdirSync(dir, { recursive: true });
218
- fs.writeFileSync(marker, JSON.stringify({ version, bun, uv, installedAt: new Date().toISOString() }));
291
+ fs.rmSync(marker, { force: true });
292
+ fs.writeFileSync(marker, JSON.stringify({ version, bun, uv: "unused", installedAt: new Date().toISOString() }), { flag: "wx" });
219
293
  return marker;
220
294
  }
295
+ const PLUGIN_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
296
+ /**
297
+ * Version from a plugin dir's manifest ("0.0.0" when absent). The version names a cache
298
+ * directory that staging removes, so an empty or path-like value must never reach a path.
299
+ *
300
+ * Args:
301
+ * pluginDir: Plugin directory holding `.claude-plugin/plugin.json`.
302
+ *
303
+ * Returns:
304
+ * The semver version.
305
+ *
306
+ * Raises:
307
+ * Error: When the manifest version is not a semver version.
308
+ */
309
+ export function pluginVersion(pluginDir) {
310
+ const manifest = path.join(pluginDir, ".claude-plugin", "plugin.json");
311
+ const version = readJsonSafe(manifest, {}).version ?? "0.0.0";
312
+ if (typeof version !== "string" || !PLUGIN_SEMVER.test(version)) {
313
+ throw new Error(`plugin version ${JSON.stringify(version)} in ${manifest} is not a semver version; refusing to stage the plugin cache`);
314
+ }
315
+ return version;
316
+ }
221
317
  /** MemoryPort implementation backed by the vendored claude-mem plugin build. */
222
318
  export class VendoredMemory {
223
319
  engineDir;
@@ -237,15 +333,13 @@ export class VendoredMemory {
237
333
  }
238
334
  /** Plugin version from the vendored manifest (upstream readPluginVersion). */
239
335
  version() {
240
- const manifest = path.join(this.pluginDir, ".claude-plugin", "plugin.json");
241
- const parsed = readJsonSafe(manifest, {});
242
- return parsed.version ?? "0.0.0";
336
+ return pluginVersion(this.pluginDir);
243
337
  }
244
338
  result() {
245
339
  return { capabilityId: CAPABILITY_ID, target: this.target, written: [], actions: [], notes: [] };
246
340
  }
247
341
  /**
248
- * Probe Bun and uv; instruct when missing. Never installs them (user's machine).
342
+ * Probe Bun; instruct when missing. Never installs it (user's machine).
249
343
  *
250
344
  * Returns:
251
345
  * Notes only. A missing Bun is reported as "MISSING" and blocks install().
@@ -253,11 +347,7 @@ export class VendoredMemory {
253
347
  async ensurePrerequisites(_ctx) {
254
348
  const res = this.result();
255
349
  const bun = probeBun();
256
- const uv = probeUv();
257
350
  res.notes.push(bun.path ? `Bun ${bun.version} found (${bun.path}).` : "MISSING: Bun — install from https://bun.sh (curl -fsSL https://bun.sh/install | bash), then re-run.");
258
- res.notes.push(uv.path
259
- ? `uv ${uv.version} found (${uv.path}).`
260
- : "MISSING: uv — Chroma vector search needs it at runtime (https://docs.astral.sh/uv). Install is not blocked; recall degrades to SQLite-only until uv is present.");
261
351
  return res;
262
352
  }
263
353
  /**
@@ -274,7 +364,6 @@ export class VendoredMemory {
274
364
  res.notes.push("Install skipped: Bun is required to build the plugin and to run its hooks.");
275
365
  return res;
276
366
  }
277
- const uv = probeUv();
278
367
  const scriptsDir = path.join(this.pluginDir, "scripts");
279
368
  const marketplacePlugin = path.join(P.marketplaceDir, "plugin");
280
369
  // 1. Build (bun install once, then build:plugin).
@@ -282,8 +371,7 @@ export class VendoredMemory {
282
371
  res.notes.push(`Would build the plugin bundles: bun run build:plugin (in ${this.engineDir}).`);
283
372
  }
284
373
  else {
285
- const bundles = ["bun-runner.js", "worker-service.cjs", "mcp-server.cjs"];
286
- const reuse = process.env.FREEHARNESS_MEMORY_SKIP_BUILD === "1" && bundles.every((f) => fs.existsSync(path.join(scriptsDir, f)));
374
+ const reuse = process.env.FREEHARNESS_MEMORY_SKIP_BUILD === "1" && missingPluginBundles(this.pluginDir).length === 0;
287
375
  if (reuse) {
288
376
  res.notes.push(`Reused the built plugin bundles in ${scriptsDir} (FREEHARNESS_MEMORY_SKIP_BUILD=1).`);
289
377
  }
@@ -291,34 +379,17 @@ export class VendoredMemory {
291
379
  if (!fs.existsSync(path.join(this.engineDir, "node_modules")))
292
380
  this.run(bun.path, ["install"], this.engineDir, "bun install (engine)");
293
381
  this.run(bun.path, ["run", "build:plugin"], this.engineDir, "bun run build:plugin");
294
- for (const f of bundles) {
295
- if (!fs.existsSync(path.join(scriptsDir, f)))
296
- throw new Error(`build:plugin did not produce ${f}`);
297
- }
382
+ for (const f of missingPluginBundles(this.pluginDir))
383
+ throw new Error(`build:plugin did not produce ${f}`);
298
384
  res.notes.push(`Built plugin bundles v${version} into ${scriptsDir}.`);
299
385
  }
300
386
  }
301
- // 2. copyPluginToCache: rm + cp plugin/ -> cache/<owner>/<name>/<version>/, skipped
302
- // when the cache already holds this exact bundle (bundle.sha256 marker matches) AND
303
- // its runtime deps still resolve; a matching marker over a broken or partial
304
- // node_modules takes the full re-stage path instead of being reported healthy.
305
- const digest = bundleDigest(this.pluginDir);
306
- const markerFile = path.join(P.cacheDir, BUNDLE_MARKER_NAME);
307
- const markerMatches = fs.existsSync(P.cacheDir) && fs.existsSync(markerFile) && fs.readFileSync(markerFile, "utf-8").trim() === digest;
308
- const cacheUpToDate = markerMatches && fs.existsSync(path.join(P.cacheDir, "node_modules")) && this.missingPluginDeps(P.cacheDir).length === 0;
387
+ // 2. copyPluginToCache: rm + cp plugin/ -> cache/<owner>/<name>/<version>/, skipped when
388
+ // the cache already holds this exact bundle; a broken node_modules takes the re-stage path.
389
+ const stage = stagePluginCache(this.pluginDir, P.cacheDir, { dryRun: ctx.dryRun });
390
+ const cacheUpToDate = !stage.staged;
309
391
  res.written.push(P.cacheDir);
310
- if (cacheUpToDate) {
311
- res.notes.push(`plugin cache up to date (bundle ${digest.slice(0, 8)})`);
312
- }
313
- else if (ctx.dryRun) {
314
- res.notes.push(`Would re-stage the plugin cache at ${P.cacheDir} (bundle ${digest.slice(0, 8)}).`);
315
- }
316
- else {
317
- fs.rmSync(P.cacheDir, { recursive: true, force: true });
318
- fs.mkdirSync(P.cacheDir, { recursive: true });
319
- fs.cpSync(this.pluginDir, P.cacheDir, { recursive: true, force: true, filter: (src) => path.basename(src) !== "node_modules" });
320
- fs.writeFileSync(markerFile, `${digest}\n`, "utf-8");
321
- }
392
+ res.notes.push(...stage.notes);
322
393
  // 3. copyPluginToMarketplace: plugin/ + marketplace manifest + package bits.
323
394
  res.written.push(marketplacePlugin);
324
395
  if (!ctx.dryRun) {
@@ -343,8 +414,7 @@ export class VendoredMemory {
343
414
  res.notes.push(`Would install plugin runtime deps in ${P.cacheDir} (bun install --ignore-scripts).`);
344
415
  }
345
416
  else {
346
- this.run(bun.path, ["install", "--ignore-scripts"], P.cacheDir, "bun install (plugin deps)");
347
- this.verifyPluginDeps(P.cacheDir);
417
+ installPluginDeps(P.cacheDir, bun.path);
348
418
  res.notes.push("Plugin runtime deps installed (zod resolvable from plugin/node_modules).");
349
419
  }
350
420
  // 5. Install markers (cache + marketplace + marketplace/plugin, as upstream); the
@@ -353,11 +423,11 @@ export class VendoredMemory {
353
423
  for (const dir of [P.marketplaceDir, marketplacePlugin]) {
354
424
  res.written.push(path.join(dir, ".install-version"));
355
425
  if (!ctx.dryRun)
356
- writeInstallMarker(dir, version, bun.version ?? "unknown", uv.version ?? "unknown");
426
+ writeInstallMarker(dir, version, bun.version ?? "unknown");
357
427
  }
358
428
  res.written.push(path.join(P.cacheDir, ".install-version"));
359
429
  if (!cacheUpToDate && !ctx.dryRun)
360
- writeInstallMarker(P.cacheDir, version, bun.version ?? "unknown", uv.version ?? "unknown");
430
+ writeInstallMarker(P.cacheDir, version, bun.version ?? "unknown");
361
431
  res.notes.push(`Plugin v${version} staged at ${P.cacheDir} (marketplace: ${P.marketplaceDir}).`);
362
432
  return res;
363
433
  }
@@ -365,30 +435,13 @@ export class VendoredMemory {
365
435
  * Register the plugin with Claude Code (upstream registerMarketplace / registerPlugin /
366
436
  * enablePluginInClaudeSettings). All three writes are additive merges: other
367
437
  * marketplaces, plugins, hooks and settings keys are preserved byte-for-byte in shape.
368
- *
369
- * When an upstream registration (UPSTREAM_PLUGIN_KEY) is found (installed_plugins.json
370
- * or a still-true enabledPlugins entry), this also backs up the three files below with a
371
- * shared timestamp before the first write and disables (never deletes) the old key; the
372
- * old cache directory is left in place for `freeharness cleanup` to offer later.
438
+ * An upstream registration (UPSTREAM_PLUGIN_KEY) is never written: doctor warns about it.
373
439
  */
374
440
  async registerHooks(_scope, ctx) {
375
441
  const res = this.result();
376
442
  const version = this.version();
377
443
  const P = claudePluginPaths(version);
378
444
  const now = new Date().toISOString();
379
- const installedBefore = readJsonSafe(P.installedPlugins, {});
380
- const settingsBefore = readJsonSafe(P.settings, {});
381
- const migrating = isUpstreamRegistered(installedBefore, settingsBefore);
382
- if (migrating && !ctx.dryRun) {
383
- const stamp = now.replace(/[:.]/g, "-");
384
- for (const f of [P.installedPlugins, P.knownMarketplaces, P.settings]) {
385
- if (!fs.existsSync(f))
386
- continue;
387
- const backup = `${f}.${stamp}.bak`;
388
- fs.copyFileSync(f, backup);
389
- res.written.push(backup);
390
- }
391
- }
392
445
  // known_marketplaces.json: { "<owner>": { source, installLocation, lastUpdated, autoUpdate } }
393
446
  const known = readJsonSafe(P.knownMarketplaces, {});
394
447
  known[MARKETPLACE_OWNER] = {
@@ -401,8 +454,6 @@ export class VendoredMemory {
401
454
  if (!ctx.dryRun)
402
455
  writeJsonAtomic(P.knownMarketplaces, known);
403
456
  // installed_plugins.json: { version: 2, plugins: { "<name>@<owner>": [ {scope, installPath, version, installedAt, lastUpdated} ] } }
404
- // The upstream entry (if any) is never removed here: invariant 3 forbids deleting a
405
- // foreign key, and the cache directory it points at stays reachable for a rollback.
406
457
  const installed = readJsonSafe(P.installedPlugins, {});
407
458
  if (!installed.version)
408
459
  installed.version = 2;
@@ -412,48 +463,55 @@ export class VendoredMemory {
412
463
  res.written.push(P.installedPlugins);
413
464
  if (!ctx.dryRun)
414
465
  writeJsonAtomic(P.installedPlugins, installed);
415
- // settings.json: enabledPlugins[PLUGIN_KEY] = true (everything else kept); migrating
416
- // also sets enabledPlugins[UPSTREAM_PLUGIN_KEY] = false so Claude Code stops loading it.
466
+ // settings.json: enabledPlugins[PLUGIN_KEY] = true (everything else kept).
417
467
  const settings = readJsonSafe(P.settings, {});
418
468
  if (!settings.enabledPlugins)
419
469
  settings.enabledPlugins = {};
420
470
  const already = settings.enabledPlugins[PLUGIN_KEY] === true;
421
471
  settings.enabledPlugins[PLUGIN_KEY] = true;
422
- if (migrating)
423
- settings.enabledPlugins[UPSTREAM_PLUGIN_KEY] = false;
424
472
  res.written.push(P.settings);
425
473
  if (!ctx.dryRun)
426
474
  writeJsonAtomic(P.settings, settings);
427
- if (migrating) {
428
- res.notes.push(ctx.dryRun
429
- ? `Would migrate from ${UPSTREAM_PLUGIN_KEY}: back up installed_plugins.json, known_marketplaces.json and settings.json before writing, register ${PLUGIN_KEY} v${version}, and set enabledPlugins["${UPSTREAM_PLUGIN_KEY}"] to false. The old cache directory is left in place.`
430
- : `Migrated from ${UPSTREAM_PLUGIN_KEY}: backed up installed_plugins.json, known_marketplaces.json and settings.json before the first write, registered ${PLUGIN_KEY} v${version}, and set enabledPlugins["${UPSTREAM_PLUGIN_KEY}"] to false. The old cache directory is left in place.`);
431
- }
432
- else {
433
- res.notes.push(`${ctx.dryRun ? "Would register" : "Registered"} plugin ${PLUGIN_KEY} v${version}: marketplace + installed_plugins + enabledPlugins (additive)${already ? "; was already enabled" : ""}.`);
434
- }
475
+ res.notes.push(`${ctx.dryRun ? "Would register" : "Registered"} plugin ${PLUGIN_KEY} v${version}: marketplace + installed_plugins + enabledPlugins (additive)${already ? "; was already enabled" : ""}.`);
435
476
  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).");
436
477
  return res;
437
478
  }
438
479
  /**
439
- * Keep an existing ~/.claude-mem data directory from a delegated install. The
440
- * plugin resolves it by convention (CLAUDE_MEM_DATA_DIR or ~/.claude-mem), so no
441
- * write is needed and nothing is deleted.
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.
442
483
  */
443
- async migrateExisting(_ctx) {
484
+ async migrateExisting(ctx) {
444
485
  const res = this.result();
445
- const dataDir = process.env.CLAUDE_MEM_DATA_DIR || path.join(os.homedir(), ".claude-mem");
446
- if (!fs.existsSync(dataDir)) {
447
- res.notes.push(`No existing claude-mem data dir at ${dataDir}; the worker creates it on first start.`);
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));
448
502
  return res;
449
503
  }
450
- const db = path.join(dataDir, "claude-mem.db");
451
- const settings = readJsonSafe(path.join(dataDir, "settings.json"), {});
452
- const redirected = typeof settings.CLAUDE_MEM_DATA_DIR === "string" ? settings.CLAUDE_MEM_DATA_DIR : null;
453
- res.notes.push(`Existing claude-mem data kept at ${dataDir}${fs.existsSync(db) ? " (claude-mem.db present)" : ""}; the vendored plugin reads it by convention${redirected ? ` (settings redirect data to ${redirected})` : ""}. Nothing deleted.`);
454
- const legacyMarker = path.join(dataDir, ".install-version");
455
- if (fs.existsSync(legacyMarker))
456
- res.notes.push("A legacy install marker exists in the data dir; the plugin markers now live in the plugin cache.");
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}`);
457
515
  return res;
458
516
  }
459
517
  run(cmd, args, cwd, label) {
@@ -463,25 +521,4 @@ export class VendoredMemory {
463
521
  if (r.status !== 0)
464
522
  throw new Error(`${label} failed (exit ${r.status})`);
465
523
  }
466
- // upstream verifyCriticalModules: every declared plugin dep must resolve from
467
- // plugin/node_modules, plus the zod subpaths the worker bundle requires.
468
- missingPluginDeps(dir) {
469
- const pkg = readJsonSafe(path.join(dir, "package.json"), {});
470
- const nm = path.join(dir, "node_modules");
471
- const missing = [];
472
- for (const dep of Object.keys(pkg.dependencies ?? {})) {
473
- if (!fs.existsSync(path.join(nm, dep, "package.json")))
474
- missing.push(dep);
475
- }
476
- for (const sub of ["v3", "v4", "v4-mini"]) {
477
- if (!fs.existsSync(path.join(nm, "zod", sub)))
478
- missing.push(`zod/${sub}`);
479
- }
480
- return missing;
481
- }
482
- verifyPluginDeps(dir) {
483
- const missing = this.missingPluginDeps(dir);
484
- if (missing.length > 0)
485
- throw new Error(`plugin deps missing after install in ${dir}: ${missing.join(", ")}`);
486
- }
487
524
  }
@@ -87,7 +87,10 @@ function manifest(target, repo, fork) {
87
87
  label: "plugin/.mcp.json", vendored: path.join(v, "plugin/.mcp.json"), upstream: path.join(fork, "plugin/.mcp.json"), contract: true,
88
88
  divergent: "rewritten to spawn ${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs directly (memory-slim)",
89
89
  },
90
- { label: "mem-search/SKILL.md", vendored: path.join(v, "plugin/skills/mem-search/SKILL.md"), upstream: path.join(fork, "plugin/skills/mem-search/SKILL.md"), contract: true },
90
+ {
91
+ label: "mem-search/SKILL.md", vendored: path.join(v, "plugin/skills/mem-search/SKILL.md"), upstream: path.join(fork, "plugin/skills/mem-search/SKILL.md"), contract: true,
92
+ divergent: "rewritten to name only the tools the slim MCP server registers (radar-w43-fixes)",
93
+ },
91
94
  ];
92
95
  }
93
96
  throw new Error(`Unknown sync target: ${target}. Valid: sdd, memory.`);
@@ -0,0 +1,35 @@
1
+ import type { PromptPort } from "@freeharness/core";
2
+ import { type CleanupItem, type CleanupPlan, type CleanupResult } from "./cleanup.js";
3
+ import { type HardPlan, type HardResult } from "./hard-cleanup.js";
4
+ export interface CleanupAnswers {
5
+ main: boolean;
6
+ backups: boolean;
7
+ hard: boolean;
8
+ }
9
+ export interface CleanupDecision {
10
+ abort?: string;
11
+ remove: (item: CleanupItem) => boolean;
12
+ moveHard: boolean;
13
+ }
14
+ /**
15
+ * Maps the answers to pass-2 actions; the hard answer counts only with --hard.
16
+ * @param offersMoves The cleanup plan holds an archive item, so the abort line names moves too.
17
+ */
18
+ export declare function cleanupDecision(answers: CleanupAnswers, withHard: boolean, offersMoves?: boolean): CleanupDecision;
19
+ /** The third cleanup confirmation, for the hard items. */
20
+ export declare function hardPrompt(count: number, archiveRoot: string): string;
21
+ export interface CleanupPass2 {
22
+ plan: CleanupPlan;
23
+ hard?: HardPlan;
24
+ /** `--yes`: every prompt is answered yes and none is shown. */
25
+ yes: boolean;
26
+ prompt: Pick<PromptPort, "confirm">;
27
+ log: (line: string) => void;
28
+ }
29
+ /** `archived` is absent when the hard plan was not confirmed. */
30
+ export interface CleanupPass2Result {
31
+ done: CleanupResult;
32
+ archived?: HardResult;
33
+ }
34
+ /** Asks the three confirmations, then applies what was confirmed; undefined when all were no. */
35
+ export declare function runCleanupPass2(input: CleanupPass2): Promise<CleanupPass2Result | undefined>;