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
@@ -0,0 +1,68 @@
1
+ import { applyCleanup } from "./cleanup.js";
2
+ import { applyHard, printable } from "./hard-cleanup.js";
3
+ /**
4
+ * Maps the answers to pass-2 actions; the hard answer counts only with --hard.
5
+ * @param offersMoves The cleanup plan holds an archive item, so the abort line names moves too.
6
+ */
7
+ export function cleanupDecision(answers, withHard, offersMoves = false) {
8
+ const moveHard = withHard && answers.hard;
9
+ const nothing = !answers.main && !answers.backups && !moveHard;
10
+ let abort;
11
+ if (nothing)
12
+ abort = withHard || offersMoves ? "Aborted; nothing was removed or moved." : "Aborted; nothing was removed.";
13
+ return { abort, remove: (i) => (i.group === "backups" ? answers.backups : answers.main), moveHard };
14
+ }
15
+ /** The first cleanup confirmation; a zero remove or archive count is left out. */
16
+ function mainPrompt(removeCount, archiveCount) {
17
+ if (archiveCount === 0)
18
+ return `\nRemove the ${removeCount} item(s) above (backups excluded)?`;
19
+ if (removeCount === 0)
20
+ return `\nArchive the ${archiveCount} item(s) above (backups excluded)?`;
21
+ return `\nRemove ${removeCount} and archive ${archiveCount} item(s) above (backups excluded)?`;
22
+ }
23
+ /** The third cleanup confirmation, for the hard items. */
24
+ export function hardPrompt(count, archiveRoot) {
25
+ return `Move the ${count} item(s) to ${printable(archiveRoot)}?`;
26
+ }
27
+ /** Asks the three confirmations, then applies what was confirmed; undefined when all were no. */
28
+ export async function runCleanupPass2(input) {
29
+ const { plan, hard, yes, prompt, log } = input;
30
+ const removable = plan.items.filter((i) => !i.refused);
31
+ const main = removable.filter((i) => i.group !== "backups");
32
+ const backups = removable.filter((i) => i.group === "backups");
33
+ const movable = hard?.items.filter((i) => !i.refused) ?? [];
34
+ const archive = main.filter((i) => i.archiveDest !== undefined).length;
35
+ let confirmMain = yes;
36
+ let confirmBackups = yes;
37
+ let confirmHard = yes;
38
+ if (!yes) {
39
+ if (main.length > 0)
40
+ confirmMain = await prompt.confirm(mainPrompt(main.length - archive, archive));
41
+ if (backups.length > 0) {
42
+ const total = backups.reduce((n, b) => n + (b.bytes ?? 0), 0);
43
+ confirmBackups = await prompt.confirm(`Also delete the ${backups.length} memory backup dir(s) (${total >= 1048576 ? `${(total / 1048576).toFixed(1)} MB` : `${total} B`})? They cannot be recovered.`);
44
+ }
45
+ if (hard && movable.length > 0)
46
+ confirmHard = await prompt.confirm(hardPrompt(movable.length, hard.archiveRoot));
47
+ }
48
+ const decision = cleanupDecision({ main: confirmMain, backups: confirmBackups, hard: confirmHard }, hard !== undefined, archive > 0);
49
+ if (decision.abort) {
50
+ log(decision.abort);
51
+ return undefined;
52
+ }
53
+ const done = applyCleanup(plan, decision.remove);
54
+ for (const r of done.removed)
55
+ log(` removed: ${printable(r)}`);
56
+ for (const m of done.moved)
57
+ log(` moved: ${printable(m)}`);
58
+ for (const k of done.skipped)
59
+ log(` kept: ${printable(k)}`);
60
+ for (const w of done.warnings)
61
+ log(` WARN ${printable(w)}`);
62
+ const archived = hard && decision.moveHard ? applyHard(hard) : undefined;
63
+ for (const m of archived?.moved ?? [])
64
+ log(` moved: ${printable(m)}`);
65
+ for (const w of archived?.warnings ?? [])
66
+ log(` WARN ${printable(w)}`);
67
+ return { done, archived };
68
+ }
@@ -1,6 +1,5 @@
1
1
  import type { Scope } from "@freeharness/core";
2
2
  import { UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
3
- import { type InstallManifest } from "../infra/manifest.js";
4
3
  import { type MemoryDiagnosis } from "../adapters/memory/doctor.js";
5
4
  /** The vendored `openspec` bin replaces it. */
6
5
  export declare const LEGACY_OPENSPEC_PACKAGE = "@fission-ai/openspec";
@@ -8,25 +7,11 @@ export declare const LEGACY_OPENSPEC_PACKAGE = "@fission-ai/openspec";
8
7
  export declare const CLI_PACKAGE = "freeharness";
9
8
  /** Backups confirm separately. */
10
9
  export type CleanupGroup = "memory" | "backups" | "legacy" | "duplicates" | "npm" | "retired";
11
- /** Retired by a rename or a fold, bare names under `~/.claude`. */
12
- export declare const RETIRED: {
13
- readonly skills: readonly ["local-code-intel"];
14
- readonly agents: readonly ["mle-reviewer", "mle-evaluator"];
15
- };
16
- /** One retired name found on disk, with its manifest-recorded flag. */
17
- export interface RetiredFinding {
18
- kind: "skill" | "agent";
19
- name: string;
20
- /** Skill dir or agent file. */
21
- path: string;
22
- /** Manifest lists `path` (agent) or a file under it (skill dir). */
23
- recorded: boolean;
24
- }
25
- /** Whose entries a `hooks` item prunes. */
26
- export type HookOwner = "freeharness" | typeof UPSTREAM_PLUGIN_NAME;
10
+ /** Whose entries a `hooks` item prunes; `retired` is only the entries running a retired hook script. */
11
+ export type HookOwner = "freeharness" | typeof UPSTREAM_PLUGIN_NAME | "retired";
27
12
  /** One leftover the cleanup knows how to remove. */
28
13
  export interface CleanupItem {
29
- kind: "dir" | "file" | "hooks" | "npm-global" | "settings-key";
14
+ kind: "dir" | "file" | "hooks" | "npm-global" | "json-key";
30
15
  /** Filesystem path, or the package name for `npm-global`. */
31
16
  target: string;
32
17
  reason: string;
@@ -39,8 +24,14 @@ export interface CleanupItem {
39
24
  hookOwner?: HookOwner;
40
25
  /** `npm-global` items: try `npm unlink -g` first, then `npm uninstall -g`. */
41
26
  unlink?: boolean;
42
- /** `settings-key` items: the `enabledPlugins` key removed from `target`. */
43
- settingsKey?: string;
27
+ /** `json-key` items: the key removed from the JSON object in `target`, outermost first. */
28
+ keyPath?: string[];
29
+ /** Plugin-cache and IDE-rule items: the archive path the item moves to instead of being removed; `json-key` items: where the pre-edit copy goes. */
30
+ archiveDest?: string;
31
+ /** Marks an IDE rule file, whose archive check differs from a plugin-cache item's. */
32
+ ideRule?: boolean;
33
+ /** Upstream launcher items, each kind with its own pass-2 check. */
34
+ launcher?: "mcp" | "marketplace";
44
35
  }
45
36
  /** What a cleanup pass would (or did) do. */
46
37
  export interface CleanupPlan {
@@ -48,10 +39,14 @@ export interface CleanupPlan {
48
39
  items: CleanupItem[];
49
40
  /** Informational lines: seen, but out of the fixed list. */
50
41
  notes: string[];
42
+ /** User scope: the dated archive every `archiveDest` lies in. */
43
+ archiveRoot?: string;
51
44
  }
52
45
  /** Outcome of `applyCleanup`. */
53
46
  export interface CleanupResult {
54
47
  removed: string[];
48
+ /** One `source -> destination` line per plugin-cache item moved into the archive. */
49
+ moved: string[];
55
50
  /** Refused, or not confirmed. */
56
51
  skipped: string[];
57
52
  /** Non-fatal failures (an npm uninstall that exited non-zero). */
@@ -63,6 +58,8 @@ export interface CleanupDeps {
63
58
  memory?: MemoryDiagnosis;
64
59
  /** Names the agents/skills a project may duplicate. */
65
60
  canonicalDir?: string;
61
+ /** The run's dated archive, shared with the hard plan (default: today's under the home dir). */
62
+ archiveRoot?: string;
66
63
  }
67
64
  /** Sums the size (bytes) of every file under a directory. */
68
65
  export declare function dirBytes(dir: string): number;
@@ -72,12 +69,13 @@ export declare function humanBytes(n: number): string;
72
69
  export declare function globalNpmPackages(): Record<string, string> | null;
73
70
  /** The `<npm prefix -g>/bin/freeharness` shadowing ours on PATH (an old `npm link -g`); undefined when absent, identical to ours, or npm is unavailable. */
74
71
  export declare function globalNpmFreeharnessBin(): string | undefined;
75
- /** Claude-mem data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), oldest first. */
76
- export declare function memoryBackupDirs(): string[];
72
+ /**
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.
75
+ */
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. */
78
78
  export declare function pruneManifest(scope: Scope, projectRoot: string): number;
79
- /** Retired skill/agent names still present under a `.claude` dir: `update`'s additive merge never deletes them; an absent path is skipped. */
80
- export declare function retiredFindings(claudeDir: string, manifest: InstallManifest | undefined): RetiredFinding[];
81
79
  /** Builds the cleanup plan for a scope without touching anything; `items` is empty when there is nothing to do. */
82
80
  export declare function planCleanup(scope: Scope, projectRoot: string, deps?: CleanupDeps): Promise<CleanupPlan>;
83
81
  /** Removes the confirmed items of a plan (`confirmed` defaults to all); refused items are always skipped. */
@@ -2,30 +2,48 @@
2
2
  // discovered by heuristics. User scope: parked upstream claude-mem + data backups, upstream
3
3
  // claude-mem hooks in settings.json (once the plugin is registered), legacy /openspec commands,
4
4
  // the global @fission-ai/openspec package, a global npm `freeharness` link, retired names
5
- // (RETIRED) still on disk after a rename or a fold when the manifest records them. Project
5
+ // (RETIRED in install-facts.ts) still on disk after a rename or a fold when the manifest records them,
6
+ // retired hook scripts and the settings entries that run them. Project
6
7
  // scope: legacy /openspec commands, project copies of canonical agents/skills, duplicate
7
8
  // FreeHarness hook entries. Never listed: the schema override, non-canonical skills/agents,
8
9
  // `openspec-*` skills, `.claude/commands/opsx/` (the user's or ours).
9
10
  //
10
11
  // Two-pass: `planCleanup` computes the list untouched; `applyCleanup` removes the confirmed items.
12
+ // Items under the Claude Code plugin cache are never deleted: they move into the dated archive
13
+ // through the hard-cleanup mover.
11
14
  import fs from "node:fs";
12
15
  import os from "node:os";
13
16
  import path from "node:path";
14
17
  import { spawnSync } from "node:child_process";
15
- import { PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
18
+ import { PLUGIN_KEY, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
16
19
  import { manifestPath, readManifest } from "../infra/manifest.js";
17
- import { configDir, dataDir, diagnoseMemory } from "../adapters/memory/doctor.js";
18
- import { canonicalNames, memoryPluginInstalled } from "./doctor.js";
20
+ import { configDir, diagnoseMemory } from "../adapters/memory/doctor.js";
21
+ import { FREE_DIR_NAME, LEGACY_DIR_NAME, resolveMemoryPaths } from "../adapters/memory/paths.js";
22
+ import { canonicalNames, memoryPluginInstalled, RETIRED, retiredFindings, runsRetiredHook } from "./install-facts.js";
19
23
  import { isClaudeMemHook, isOurHook, pruneHooksMatching } from "./uninstall.js";
24
+ import { archiveMove, archiveRootFor, isOrHolds, moveWarning, pluginCacheArchive, pluginCacheOverlap, pluginCacheRefusal } from "./hard-cleanup.js";
25
+ import { removeJsonKey } from "./json-key-edit.js";
26
+ import { configCopyArchive, launcherStillMatches, launcherText, marketplaceRefusal, planUpstreamLaunchers } from "./upstream-launchers.js";
20
27
  /** The vendored `openspec` bin replaces it. */
21
28
  export const LEGACY_OPENSPEC_PACKAGE = "@fission-ai/openspec";
22
29
  /** A global link of it shadows the ~/.local/bin symlink. */
23
30
  export const CLI_PACKAGE = "freeharness";
24
- /** Retired by a rename or a fold, bare names under `~/.claude`. */
25
- export const RETIRED = {
26
- skills: ["local-code-intel"],
27
- agents: ["mle-reviewer", "mle-evaluator"],
28
- };
31
+ /** An entry mixing a retired script with another command is not one we emitted, so it stays. */
32
+ function isRetiredHook(e) {
33
+ const commands = (e.hooks ?? []).map((h) => h.command ?? "");
34
+ return commands.length > 0 && commands.every(runsRetiredHook);
35
+ }
36
+ function hookPredicate(owner) {
37
+ if (owner === UPSTREAM_PLUGIN_NAME)
38
+ return isClaudeMemHook;
39
+ return owner === "retired" ? isRetiredHook : isOurHook;
40
+ }
41
+ /** Upstream IDE rule files under the home dir, relative; fixed list. */
42
+ const IDE_RULE_FILES = [
43
+ path.join(".agent", "rules", `${UPSTREAM_PLUGIN_NAME}-context.md`),
44
+ path.join(".agents", "rules", `${UPSTREAM_PLUGIN_NAME}-context.md`),
45
+ path.join(".agent", "rules", "free-mem-context.md"),
46
+ ];
29
47
  /** Sums the size (bytes) of every file under a directory. */
30
48
  export function dirBytes(dir) {
31
49
  let total = 0;
@@ -93,18 +111,26 @@ export function globalNpmFreeharnessBin() {
93
111
  }
94
112
  return bin;
95
113
  }
96
- /** Claude-mem data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), oldest first. */
97
- export function memoryBackupDirs() {
98
- const data = dataDir();
99
- const parent = path.dirname(data);
100
- const prefix = `${path.basename(data)}-backup-`;
101
- if (!fs.existsSync(parent))
102
- return [];
103
- return fs
104
- .readdirSync(parent, { withFileTypes: true })
105
- .filter((e) => e.isDirectory() && e.name.startsWith(prefix))
106
- .map((e) => path.join(parent, e.name))
107
- .sort();
114
+ /**
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.
117
+ */
118
+ export function memoryBackupDirs(env = process.env, home = os.homedir()) {
119
+ const live = resolveMemoryPaths(env, home).dataDir;
120
+ const dirs = [live, path.join(home, FREE_DIR_NAME), path.join(home, LEGACY_DIR_NAME)];
121
+ const found = new Set();
122
+ for (const data of dirs) {
123
+ const parent = path.dirname(data);
124
+ const prefix = `${path.basename(data)}-backup-`;
125
+ if (!fs.existsSync(parent))
126
+ continue;
127
+ for (const e of fs.readdirSync(parent, { withFileTypes: true })) {
128
+ const candidate = path.join(parent, e.name);
129
+ if (e.isDirectory() && e.name.startsWith(prefix) && !isOrHolds(candidate, live))
130
+ found.add(candidate);
131
+ }
132
+ }
133
+ return [...found].sort();
108
134
  }
109
135
  /** Plugin keys registered in Claude Code's installed_plugins.json (e.g. "name@marketplace"); empty when absent or unreadable. */
110
136
  function installedPluginKeys(cfg) {
@@ -128,29 +154,16 @@ export function pruneManifest(scope, projectRoot) {
128
154
  fs.writeFileSync(file, JSON.stringify({ ...m, files: kept, updatedAt: new Date().toISOString() }, null, 2) + "\n");
129
155
  return dropped;
130
156
  }
131
- /** Retired skill/agent names still present under a `.claude` dir: `update`'s additive merge never deletes them; an absent path is skipped. */
132
- export function retiredFindings(claudeDir, manifest) {
133
- const groups = [
134
- { kind: "skill", dir: "skills", names: RETIRED.skills },
135
- { kind: "agent", dir: "agents", names: RETIRED.agents },
136
- ];
137
- const found = [];
138
- for (const { kind, dir, names } of groups) {
139
- for (const name of names) {
140
- const fileName = kind === "agent" ? `${name}.md` : name;
141
- const p = path.join(claudeDir, dir, fileName);
142
- if (!fs.existsSync(p))
143
- continue;
144
- const under = `${p}${path.sep}`;
145
- const recorded = manifest?.files.some((f) => f.path === p || f.path.startsWith(under)) ?? false;
146
- found.push({ kind, name, path: p, recorded });
147
- }
148
- }
149
- return found;
157
+ /** Routes a plugin-cache item into the dated archive; an earlier refusal wins over the containment one. */
158
+ function toArchive(item, cfg, archiveRoot) {
159
+ const archive = pluginCacheArchive(item.target, cfg, archiveRoot);
160
+ return { ...item, archiveDest: archive.destination, refused: item.refused ?? archive.refused };
150
161
  }
162
+ const UPSTREAM_ENABLED_REFUSAL = `the ${UPSTREAM_PLUGIN_KEY} key is still enabled — disable it first: \`claude plugin disable ${UPSTREAM_PLUGIN_KEY}\``;
151
163
  /** Computes the user-scope cleanup list; nothing is touched. */
152
164
  async function planUser(deps) {
153
- const plan = { scope: "user", items: [], notes: [] };
165
+ const archiveRoot = deps.archiveRoot ?? archiveRootFor(os.homedir(), new Date());
166
+ const plan = { scope: "user", items: [], notes: [], archiveRoot };
154
167
  const cfg = configDir();
155
168
  // `memory activate` parks foreign versions under the upstream root (doctor.ts's
156
169
  // disabledRoot): an upstream install or a pre-rename FreeHarness build, never ours.
@@ -162,7 +175,7 @@ async function planUser(deps) {
162
175
  const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
163
176
  item.refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
164
177
  }
165
- plan.items.push(item);
178
+ plan.items.push(toArchive(item, cfg, archiveRoot));
166
179
  }
167
180
  // Superseded FreeHarness builds: our own cache versions (.install-version present) that are
168
181
  // not the registered installPath, left behind by a version bump. A legacy-root build that
@@ -171,7 +184,7 @@ async function planUser(deps) {
171
184
  const d = deps.memory ?? (await diagnoseMemory());
172
185
  if (d.activeInstallPath) {
173
186
  for (const c of d.cache.filter((c) => c.ours && !c.legacy && path.resolve(c.dir) !== path.resolve(d.activeInstallPath))) {
174
- plan.items.push({ kind: "dir", target: c.dir, reason: `superseded FreeHarness memory build ${c.version} (registered: ${d.activeVersion ?? "?"})`, group: "memory", bytes: dirBytes(c.dir) });
187
+ plan.items.push(toArchive({ kind: "dir", target: c.dir, reason: `superseded FreeHarness memory build ${c.version} (registered: ${d.activeVersion ?? "?"})`, group: "memory", bytes: dirBytes(c.dir) }, cfg, archiveRoot));
175
188
  }
176
189
  }
177
190
  }
@@ -184,7 +197,21 @@ async function planUser(deps) {
184
197
  if (fs.existsSync(userSettings) && memoryPluginInstalled(cfg) && pruneHooksMatching(userSettings, isClaudeMemHook, true) > 0) {
185
198
  plan.items.push({ kind: "hooks", target: userSettings, reason: `upstream claude-mem hook entries (the ${PLUGIN_KEY} plugin ships its own hooks.json; these run every hook twice)`, group: "memory", hookOwner: UPSTREAM_PLUGIN_NAME });
186
199
  }
187
- // Leftovers of a `registerHooks` migration: the pre-rename cache dir (gated on its own
200
+ // Rule stubs the upstream Gemini/Antigravity installer wrote into IDE rule dirs; no engine
201
+ // updates them. Fixed list, no glob; a directory at the path is not ours to move.
202
+ for (const rel of IDE_RULE_FILES) {
203
+ const file = path.join(os.homedir(), rel);
204
+ try {
205
+ const st = fs.lstatSync(file);
206
+ if (!st.isFile() && !st.isSymbolicLink())
207
+ continue;
208
+ }
209
+ catch {
210
+ continue;
211
+ }
212
+ plan.items.push({ kind: "file", target: file, reason: "upstream IDE rule file (no engine updates it)", group: "memory", ideRule: true, archiveDest: path.join(archiveRoot, "ide-rules", rel) });
213
+ }
214
+ // Leftovers of the old key: the pre-rename cache dir (gated on its own
188
215
  // existence, not the key's) and the disabled key (listed only while present). Both stay
189
216
  // refused while the old key is enabled, or (same VERDICT check as above) while the build is
190
217
  // not verified as sole active plugin — installed_plugins.json alone can register the
@@ -202,18 +229,23 @@ async function planUser(deps) {
202
229
  }
203
230
  let refused;
204
231
  if (oldKeyValue === true) {
205
- refused = `the ${UPSTREAM_PLUGIN_KEY} key is still enabled — migrate first: \`freeharness init --scope user --only memory\``;
232
+ refused = UPSTREAM_ENABLED_REFUSAL;
206
233
  }
207
234
  else if (d.verdict !== "ours") {
208
235
  const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
209
236
  refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
210
237
  }
211
238
  if (fs.existsSync(oldCacheDir)) {
212
- plan.items.push({ kind: "dir", target: oldCacheDir, reason: `previous registration, migrated to ${PLUGIN_NAME}`, group: "memory", bytes: dirBytes(oldCacheDir), refused });
239
+ plan.items.push(toArchive({ kind: "dir", target: oldCacheDir, reason: "previous registration, left in the plugin cache", group: "memory", bytes: dirBytes(oldCacheDir), refused }, cfg, archiveRoot));
213
240
  }
214
241
  if (oldKeyValue !== undefined) {
215
- plan.items.push({ kind: "settings-key", target: userSettings, reason: `disabled ${UPSTREAM_PLUGIN_KEY} key left in enabledPlugins after migrating to ${PLUGIN_KEY}`, group: "memory", settingsKey: UPSTREAM_PLUGIN_KEY, refused });
242
+ const copy = configCopyArchive(userSettings, os.homedir(), archiveRoot);
243
+ plan.items.push({ kind: "json-key", target: userSettings, reason: `disabled ${UPSTREAM_PLUGIN_KEY} key left in enabledPlugins`, group: "memory", keyPath: ["enabledPlugins", UPSTREAM_PLUGIN_KEY], archiveDest: copy.destination, refused: refused ?? copy.refused });
216
244
  }
245
+ // An enabled upstream plugin still installs from its marketplace; other clients' launchers are not that plugin.
246
+ const launchers = planUpstreamLaunchers({ home: os.homedir(), cfg, archiveRoot, marketplaceRefusal: oldKeyValue === true ? UPSTREAM_ENABLED_REFUSAL : undefined });
247
+ plan.items.push(...launchers.items);
248
+ plan.notes.push(...launchers.notes);
217
249
  }
218
250
  const legacy = path.join(cfg, "commands", "openspec");
219
251
  if (fs.existsSync(legacy)) {
@@ -250,20 +282,24 @@ async function planUser(deps) {
250
282
  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 });
251
283
  }
252
284
  }
253
- // Retired names: listed only when the manifest records the path, so a same-named
254
- // user-owned skill/agent stays untouched (doctor reports that case as INFO, never here).
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).
287
+ // A hook script lives in FreeHarness's own hooks dir, so its presence is enough.
255
288
  const manifest = readManifest(manifestPath("user", ""));
256
289
  for (const f of retiredFindings(path.join(os.homedir(), ".claude"), manifest)) {
257
- if (!f.recorded)
290
+ if (!f.recorded && f.kind !== "hook")
258
291
  continue;
259
292
  plan.items.push({
260
293
  kind: f.kind === "skill" ? "dir" : "file",
261
294
  target: f.path,
262
- reason: `retired ${f.kind} (renamed or folded elsewhere; kept on disk by update's additive manifest merge)`,
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)`,
263
296
  group: "retired",
264
297
  bytes: f.kind === "skill" ? dirBytes(f.path) : undefined,
265
298
  });
266
299
  }
300
+ if (fs.existsSync(userSettings) && pruneHooksMatching(userSettings, isRetiredHook, true) > 0) {
301
+ plan.items.push({ kind: "hooks", target: userSettings, reason: `settings hook entries running a retired hook script (${RETIRED.hooks.join(", ")})`, group: "retired", hookOwner: "retired" });
302
+ }
267
303
  plan.notes.push(`kept: ${path.join(os.homedir(), ".local", "share", "openspec", "schemas", "spec-driven")} (the FreeHarness schema override)`);
268
304
  return plan;
269
305
  }
@@ -319,9 +355,61 @@ function planProject(projectRoot, deps) {
319
355
  export async function planCleanup(scope, projectRoot, deps = {}) {
320
356
  return scope === "user" ? planUser(deps) : planProject(projectRoot, deps);
321
357
  }
358
+ function inInbox(dest, inbox) {
359
+ const rel = path.relative(inbox, dest);
360
+ return !(rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
361
+ }
362
+ /**
363
+ * The destination stays in the `ide-rules` inbox so no plan item lands over other archive content;
364
+ * a source that became a directory since pass 1 is no longer the stub it listed, and moving it would take what the user put there.
365
+ */
366
+ function ideRuleRefusal(source, dest, archiveRoot) {
367
+ if (archiveRoot === undefined)
368
+ return "no archive root in the plan";
369
+ const inbox = path.join(archiveRoot, "ide-rules");
370
+ if (!inInbox(dest, inbox))
371
+ return `destination ${dest} is outside ${inbox}`;
372
+ try {
373
+ if (fs.lstatSync(source).isDirectory())
374
+ return "is a directory now";
375
+ }
376
+ catch {
377
+ // gone: the move reports it
378
+ }
379
+ return undefined;
380
+ }
381
+ /** A pre-edit copy outside the `configs` inbox could land over other archive content, so pass 2 refuses it. */
382
+ function jsonKeyRefusal(item, archiveRoot) {
383
+ if (archiveRoot === undefined)
384
+ return "no archive root in the plan";
385
+ if (!item.keyPath?.length || item.archiveDest === undefined)
386
+ return "no key path or copy destination in the plan";
387
+ const inbox = path.join(archiveRoot, "configs");
388
+ return inInbox(item.archiveDest, inbox) ? undefined : `destination ${item.archiveDest} is outside ${inbox}`;
389
+ }
390
+ /** Moving rather than deleting keeps a wrong match recoverable; each kind has its own pass-2 check, as what makes its move unsafe differs. */
391
+ function archiveItem(item, dest, cfg, archiveRoot, out) {
392
+ const refused = item.ideRule
393
+ ? ideRuleRefusal(item.target, dest, archiveRoot)
394
+ : item.launcher === "marketplace"
395
+ ? marketplaceRefusal(item.target, dest, archiveRoot)
396
+ : pluginCacheRefusal(item.target, dest, cfg, archiveRoot);
397
+ if (refused) {
398
+ out.skipped.push(item.target);
399
+ out.warnings.push(`${item.target}: refused (${refused})`);
400
+ return;
401
+ }
402
+ try {
403
+ out.moved.push(`${item.target} -> ${archiveMove(item.target, dest)}`);
404
+ }
405
+ catch (e) {
406
+ out.warnings.push(moveWarning(item.target, e));
407
+ }
408
+ }
322
409
  /** Removes the confirmed items of a plan (`confirmed` defaults to all); refused items are always skipped. */
323
410
  export function applyCleanup(plan, confirmed = () => true) {
324
- const out = { removed: [], skipped: [], warnings: [] };
411
+ const out = { removed: [], moved: [], skipped: [], warnings: [] };
412
+ const cfg = configDir();
325
413
  for (const item of plan.items) {
326
414
  if (item.refused || !confirmed(item)) {
327
415
  out.skipped.push(item.target);
@@ -329,14 +417,26 @@ export function applyCleanup(plan, confirmed = () => true) {
329
417
  }
330
418
  switch (item.kind) {
331
419
  case "dir":
332
- case "file":
420
+ case "file": {
421
+ if (item.archiveDest !== undefined) {
422
+ archiveItem(item, item.archiveDest, cfg, plan.archiveRoot, out);
423
+ break;
424
+ }
425
+ // Nothing in, at or above the plugin cache is ever deleted; a link to it is unlinked only.
426
+ const cached = pluginCacheOverlap(item.target, cfg);
427
+ if (cached) {
428
+ out.skipped.push(item.target);
429
+ out.warnings.push(`${item.target}: refused (${cached})`);
430
+ break;
431
+ }
333
432
  fs.rmSync(item.target, { recursive: true, force: true });
334
433
  out.removed.push(item.target);
335
434
  break;
435
+ }
336
436
  case "hooks": {
337
- const claudeMem = item.hookOwner === UPSTREAM_PLUGIN_NAME;
338
- pruneHooksMatching(item.target, claudeMem ? isClaudeMemHook : isOurHook, false);
339
- out.removed.push(`${item.target} (${claudeMem ? UPSTREAM_PLUGIN_NAME : "FreeHarness"} hook entries)`);
437
+ pruneHooksMatching(item.target, hookPredicate(item.hookOwner), false);
438
+ const whose = item.hookOwner === UPSTREAM_PLUGIN_NAME ? `upstream ${UPSTREAM_PLUGIN_NAME}` : item.hookOwner === "retired" ? "retired FreeHarness" : "FreeHarness";
439
+ out.removed.push(`${item.target} (${whose} hook entries)`);
340
440
  break;
341
441
  }
342
442
  case "npm-global": {
@@ -358,12 +458,16 @@ export function applyCleanup(plan, confirmed = () => true) {
358
458
  out.warnings.push(`npm ${verb} -g ${item.target} failed (exit ${r.status ?? "?"}): ${(r.stderr || r.stdout || "").trim().split("\n")[0]}`);
359
459
  break;
360
460
  }
361
- case "settings-key": {
362
- const settings = JSON.parse(fs.readFileSync(item.target, "utf8"));
363
- if (item.settingsKey && settings.enabledPlugins)
364
- delete settings.enabledPlugins[item.settingsKey];
365
- fs.writeFileSync(item.target, JSON.stringify(settings, null, 2) + "\n", "utf8");
366
- out.removed.push(`${item.target} (removed ${item.settingsKey} from enabledPlugins)`);
461
+ case "json-key": {
462
+ const refused = jsonKeyRefusal(item, plan.archiveRoot);
463
+ const r = refused ? { ok: false, reason: refused } : removeJsonKey(item.target, item.keyPath ?? [], item.archiveDest ?? "", { stillMatches: launcherStillMatches(item) });
464
+ if (r.ok) {
465
+ out.removed.push(`${item.target} (removed ${keyLabel(item)}; copy at ${r.copy})`);
466
+ }
467
+ else {
468
+ out.skipped.push(item.target);
469
+ out.warnings.push(`${item.target}: refused (${r.reason})`);
470
+ }
367
471
  break;
368
472
  }
369
473
  }
@@ -373,10 +477,20 @@ export function applyCleanup(plan, confirmed = () => true) {
373
477
  /** Renders a plan item for the preview / doctor output, one line, without the OK/WARN prefix. */
374
478
  export function describeItem(item) {
375
479
  const size = item.bytes !== undefined ? ` (${humanBytes(item.bytes)})` : "";
376
- const what = item.kind === "npm-global"
377
- ? `npm ${item.unlink ? "unlink" : "uninstall"} -g ${item.target}`
378
- : item.kind === "hooks" || item.kind === "settings-key"
379
- ? `edit: ${item.target}`
380
- : `rm: ${item.target}`;
381
- return `${what}${size} — ${item.reason}${item.refused ? ` — REFUSED: ${item.refused}` : ""}`;
480
+ return `${itemAction(item)}${size} — ${item.reason}${item.refused ? ` — REFUSED: ${item.refused}` : ""}`;
481
+ }
482
+ /** Doctor prints these lines unescaped, and a launcher's key is a server name another program wrote. */
483
+ function keyLabel(item) {
484
+ return item.keyPath?.map(launcherText).join(".") ?? "?";
485
+ }
486
+ function itemAction(item) {
487
+ if (item.kind === "npm-global")
488
+ return `npm ${item.unlink ? "unlink" : "uninstall"} -g ${item.target}`;
489
+ if (item.kind === "json-key")
490
+ return `edit: ${item.target} (remove ${keyLabel(item)}; original to ${item.archiveDest ?? "?"})`;
491
+ if (item.kind === "hooks")
492
+ return `edit: ${item.target}`;
493
+ if (item.archiveDest !== undefined)
494
+ return `mv: ${item.target} -> ${item.archiveDest}`;
495
+ return `rm: ${item.target}`;
382
496
  }