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.
- package/README.md +8 -7
- package/canonical/HARVEST.yaml +25 -14
- package/canonical/PROVENANCE.yaml +1 -1
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +7 -8
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +217 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +9 -2
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
- package/dist/adapters/harness/claude-code.d.ts +19 -3
- package/dist/adapters/harness/claude-code.js +204 -39
- package/dist/adapters/harness/opencode.js +16 -2
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +174 -29
- package/dist/adapters/memory/doctor.js +758 -134
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +22 -0
- package/dist/adapters/memory/paths.js +85 -0
- package/dist/adapters/memory/vendored.d.ts +56 -29
- package/dist/adapters/memory/vendored.js +191 -154
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +182 -68
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +193 -44
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +28 -0
- package/dist/cli/install-facts.js +66 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +247 -70
- package/dist/infra/fsutil.d.ts +31 -3
- package/dist/infra/fsutil.js +69 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +1 -1
- package/provenance/memory.PROVENANCE.yaml +458 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// Hard cleanup moves stale artifacts into `<home>/Archive/freeharness-<date>/`, never deleting one.
|
|
2
|
+
// A project checkout is untrusted: no locator descends through a symlinked root, and a guard, run at
|
|
3
|
+
// plan and again at apply, confines each source to its locator root and refuses protected paths.
|
|
4
|
+
// The only removals: a source after its verified cross-device copy, and partial output of this call.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { PLUGIN_KEY, UPSTREAM_PLUGIN_KEY } from "../adapters/memory/vendored.js";
|
|
9
|
+
import { configDir, dataDir } from "../adapters/memory/doctor.js";
|
|
10
|
+
import { FREE_DIR_NAME, LEGACY_DIR_NAME, resolveMemoryPaths } from "../adapters/memory/paths.js";
|
|
11
|
+
import { removeJsonKey } from "./json-key-edit.js";
|
|
12
|
+
/** Memory logs younger than this many days (by the date in the name) stay. */
|
|
13
|
+
export const LOG_RETENTION_DAYS = 14;
|
|
14
|
+
/** Build and OS junk moved out of a FreeHarness checkout's canonical/. */
|
|
15
|
+
const CANONICAL_JUNK = new Set(["node_modules", "__pycache__", ".venv", ".DS_Store"]);
|
|
16
|
+
const LOG_NAME = /^claude-mem-(\d{4})-(\d{2})-(\d{2})\.log$/;
|
|
17
|
+
/** `<file>.<stamp>.bak`, the stamp being `toISOString()` (always with milliseconds) with `:` and `.` replaced by `-`. */
|
|
18
|
+
const BACKUP_NAME = /^(.+)\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.bak$/;
|
|
19
|
+
const REINSTALL = "freeharness init --scope user --only memory";
|
|
20
|
+
/** The default filesystems of these platforms fold case, so path comparisons must too. */
|
|
21
|
+
const FOLDS_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
22
|
+
const realFs = {
|
|
23
|
+
rename: (from, to) => fs.renameSync(from, to),
|
|
24
|
+
copy: (from, to) => {
|
|
25
|
+
const st = fs.lstatSync(from);
|
|
26
|
+
if (st.isFile()) {
|
|
27
|
+
// Onto the empty reservation `to`, so the name is never free between check and copy.
|
|
28
|
+
fs.copyFileSync(from, to);
|
|
29
|
+
fs.chmodSync(to, st.mode & 0o7777);
|
|
30
|
+
fs.utimesSync(to, st.atime, st.mtime);
|
|
31
|
+
}
|
|
32
|
+
else if (st.isSymbolicLink()) {
|
|
33
|
+
fs.unlinkSync(to);
|
|
34
|
+
fs.symlinkSync(fs.readlinkSync(from), to);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, errorOnExist: true, force: false });
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** Node reports an existing copy target as `EEXIST` from fs calls and `ERR_FS_CP_EEXIST` from `cpSync`. */
|
|
42
|
+
function targetExists(e) {
|
|
43
|
+
const code = errCode(e);
|
|
44
|
+
return code === "EEXIST" || code === "ERR_FS_CP_EEXIST";
|
|
45
|
+
}
|
|
46
|
+
/** Escapes C0/C1, format (bidi, zero-width) and line-separator controls, so a file name cannot drive or reorder the terminal. */
|
|
47
|
+
export function printable(s) {
|
|
48
|
+
return s.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, (c) => {
|
|
49
|
+
const cp = c.codePointAt(0) ?? 0;
|
|
50
|
+
return cp <= 0xff ? `\\x${cp.toString(16).padStart(2, "0")}` : `\\u{${cp.toString(16)}}`;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function lexists(p) {
|
|
54
|
+
try {
|
|
55
|
+
fs.lstatSync(p);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function isDir(p) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.statSync(p).isDirectory();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function localDate(d) {
|
|
71
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
72
|
+
}
|
|
73
|
+
function errCode(e) {
|
|
74
|
+
return e?.code;
|
|
75
|
+
}
|
|
76
|
+
/** `CODE: message` of an error; Node fs messages already open with their code. */
|
|
77
|
+
function errText(e) {
|
|
78
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
79
|
+
const code = errCode(e);
|
|
80
|
+
return !code || message.startsWith(code) ? message : `${code}: ${message}`;
|
|
81
|
+
}
|
|
82
|
+
/** Entry names of a dir, sorted; empty when it is absent, and noted when it is unreadable. */
|
|
83
|
+
function names(dir, notes) {
|
|
84
|
+
try {
|
|
85
|
+
return fs.readdirSync(dir).sort();
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (errCode(e) !== "ENOENT")
|
|
89
|
+
notes.push(`${dir} is unreadable (${errCode(e) ?? errText(e)}): not scanned`);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** True, with a note, when a locator root is a symlink. */
|
|
94
|
+
function linkedRoot(dir, notes) {
|
|
95
|
+
try {
|
|
96
|
+
if (!fs.lstatSync(dir).isSymbolicLink())
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
notes.push(`skipped: ${dir} is a symlink; hard cleanup never follows one`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
/** Entry count and file bytes under a path, without following symlinks. */
|
|
106
|
+
function treeStats(p) {
|
|
107
|
+
try {
|
|
108
|
+
const st = fs.lstatSync(p);
|
|
109
|
+
if (!st.isDirectory())
|
|
110
|
+
return { entries: 1, bytes: st.isFile() ? st.size : 0 };
|
|
111
|
+
return fs.readdirSync(p).reduce((sum, n) => {
|
|
112
|
+
const child = treeStats(path.join(p, n));
|
|
113
|
+
return { entries: sum.entries + child.entries, bytes: sum.bytes + child.bytes };
|
|
114
|
+
}, { entries: 1, bytes: 0 });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { entries: 0, bytes: 0 };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function humanSize(n) {
|
|
121
|
+
if (n >= 1048576)
|
|
122
|
+
return `${(n / 1048576).toFixed(1)} MB`;
|
|
123
|
+
if (n >= 1024)
|
|
124
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
125
|
+
return `${n} B`;
|
|
126
|
+
}
|
|
127
|
+
/** First free name of `dest`, `dest.2`, ...; locators never share a destination. */
|
|
128
|
+
export function freeDestination(dest) {
|
|
129
|
+
let candidate = dest;
|
|
130
|
+
for (let n = 2; lexists(candidate); n++)
|
|
131
|
+
candidate = `${dest}.${n}`;
|
|
132
|
+
return candidate;
|
|
133
|
+
}
|
|
134
|
+
function key(p) {
|
|
135
|
+
return FOLDS_CASE ? p.toLowerCase() : p;
|
|
136
|
+
}
|
|
137
|
+
/** Native realpath of the longest existing prefix, plus the missing rest. */
|
|
138
|
+
function realOr(p) {
|
|
139
|
+
const abs = path.resolve(p);
|
|
140
|
+
try {
|
|
141
|
+
return fs.realpathSync.native(abs);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
const parent = path.dirname(abs);
|
|
145
|
+
return parent === abs ? abs : path.join(realOr(parent), path.basename(abs));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** The path with its parent dir resolved; the last segment is not followed, as a link moves as itself. */
|
|
149
|
+
function resolveParent(p) {
|
|
150
|
+
const abs = path.resolve(p);
|
|
151
|
+
return path.join(realOr(path.dirname(abs)), path.basename(abs));
|
|
152
|
+
}
|
|
153
|
+
function same(a, b) {
|
|
154
|
+
return key(a) === key(b);
|
|
155
|
+
}
|
|
156
|
+
function within(child, parent) {
|
|
157
|
+
const rel = path.relative(key(parent), key(child));
|
|
158
|
+
// A child named `..x` is inside; only `..` itself or a `../` prefix leaves the parent.
|
|
159
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
160
|
+
}
|
|
161
|
+
/** Segments of `p` below `parent`, in `p`'s own case. */
|
|
162
|
+
function below(p, parent) {
|
|
163
|
+
return p.split(path.sep).slice(parent.split(path.sep).length);
|
|
164
|
+
}
|
|
165
|
+
/** Why a path must be neither removed nor moved as itself: it is, lies in, or holds the plugin cache. The last segment is not followed. */
|
|
166
|
+
export function pluginCacheOverlap(target, cfg) {
|
|
167
|
+
const p = resolveParent(target);
|
|
168
|
+
const cache = realOr(path.join(cfg, "plugins", "cache"));
|
|
169
|
+
if (same(p, cache) || within(p, cache))
|
|
170
|
+
return `resolves under the plugin cache (${cache})`;
|
|
171
|
+
if (within(cache, p))
|
|
172
|
+
return `is an ancestor of the plugin cache (${cache})`;
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
/** Same resolution as the data-dir guard below. */
|
|
176
|
+
export function isOrHolds(candidate, dir) {
|
|
177
|
+
const p = resolveParent(candidate);
|
|
178
|
+
const d = realOr(dir);
|
|
179
|
+
return same(p, d) || within(d, p);
|
|
180
|
+
}
|
|
181
|
+
/** Why a source must stay, or undefined when it may move. */
|
|
182
|
+
function protectedReason(source, ctx, projectRoot) {
|
|
183
|
+
const p = resolveParent(source);
|
|
184
|
+
if (path.isAbsolute(ctx.configDir)) {
|
|
185
|
+
const cached = pluginCacheOverlap(source, ctx.configDir);
|
|
186
|
+
if (cached)
|
|
187
|
+
return cached;
|
|
188
|
+
}
|
|
189
|
+
const inUseAndBothDefaults = [ctx.dataDir, path.join(ctx.home, FREE_DIR_NAME), path.join(ctx.home, LEGACY_DIR_NAME)];
|
|
190
|
+
for (const dir of inUseAndBothDefaults.filter((d) => path.isAbsolute(d))) {
|
|
191
|
+
const data = realOr(dir);
|
|
192
|
+
if (same(p, data))
|
|
193
|
+
return "is the memory data dir";
|
|
194
|
+
if (within(data, p))
|
|
195
|
+
return `is an ancestor of the memory data dir (${data})`;
|
|
196
|
+
if (within(p, data)) {
|
|
197
|
+
const rel = below(p, data);
|
|
198
|
+
const allowed = (rel.length === 2 && key(rel[0]) === "logs" && LOG_NAME.test(rel[1])) || (rel.length === 1 && BACKUP_NAME.exec(rel[0])?.[1] === "settings.json");
|
|
199
|
+
if (!allowed)
|
|
200
|
+
return `resolves into the memory data dir (${data}) outside logs/ and the settings backups`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (projectRoot) {
|
|
204
|
+
const changes = path.join(projectRoot, "openspec", "changes");
|
|
205
|
+
const sdd = realOr(path.join(projectRoot, ".freeharness", "sdd"));
|
|
206
|
+
if (within(p, sdd)) {
|
|
207
|
+
const change = below(p, sdd)[0];
|
|
208
|
+
if (lexists(path.join(changes, change)))
|
|
209
|
+
return `SDD workspace of the active change ${change}`;
|
|
210
|
+
}
|
|
211
|
+
for (const change of names(sdd, [])) {
|
|
212
|
+
if (lexists(path.join(changes, change)) && within(path.join(sdd, change), p))
|
|
213
|
+
return `is an ancestor of the SDD workspace of the active change ${change}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
/** Why a source escapes its locator root (a symlink on the way, or a realpath outside), or undefined. */
|
|
219
|
+
function escapeReason(source, base) {
|
|
220
|
+
if (!base || !within(source, base))
|
|
221
|
+
return `is outside its locator root${base ? ` ${base}` : ""}`;
|
|
222
|
+
let dir = base;
|
|
223
|
+
for (const segment of path.relative(base, source).split(path.sep).slice(0, -1)) {
|
|
224
|
+
dir = path.join(dir, segment);
|
|
225
|
+
if (lexists(dir) && fs.lstatSync(dir).isSymbolicLink())
|
|
226
|
+
return `is reached through the symlink ${dir}`;
|
|
227
|
+
}
|
|
228
|
+
const parent = realOr(path.dirname(source));
|
|
229
|
+
const root = realOr(base);
|
|
230
|
+
return same(parent, root) || within(parent, root) ? undefined : `resolves outside ${root}`;
|
|
231
|
+
}
|
|
232
|
+
/** The project root, or the deepest user dir holding the source. */
|
|
233
|
+
function baseOf(g, source) {
|
|
234
|
+
if (g.scope === "project")
|
|
235
|
+
return g.projectRoot;
|
|
236
|
+
const dirs = [g.ctx.dataDir, g.ctx.configDir].filter((d) => path.isAbsolute(d) && within(source, d));
|
|
237
|
+
return dirs.sort((a, b) => b.length - a.length)[0] ?? "";
|
|
238
|
+
}
|
|
239
|
+
function guard(g, source) {
|
|
240
|
+
return protectedReason(source, g.ctx, g.scope === "project" ? g.projectRoot : "") ?? escapeReason(source, baseOf(g, source));
|
|
241
|
+
}
|
|
242
|
+
/** Stamped backups of `file` in `dir` except the newest, as `[name, ...]`. */
|
|
243
|
+
function staleBackups(dir, file, notes) {
|
|
244
|
+
const own = names(dir, notes).filter((n) => BACKUP_NAME.exec(n)?.[1] === file);
|
|
245
|
+
// One prefix and one stamp width (BACKUP_NAME): lexical order is chronological.
|
|
246
|
+
return own.slice(0, -1);
|
|
247
|
+
}
|
|
248
|
+
function planUser(ctx, archiveRoot, items, notes, add) {
|
|
249
|
+
const logs = path.join(ctx.dataDir, "logs");
|
|
250
|
+
const cutoff = new Date(ctx.now.getFullYear(), ctx.now.getMonth(), ctx.now.getDate() - LOG_RETENTION_DAYS);
|
|
251
|
+
if (!linkedRoot(logs, notes)) {
|
|
252
|
+
for (const n of names(logs, notes)) {
|
|
253
|
+
const m = LOG_NAME.exec(n);
|
|
254
|
+
if (!m)
|
|
255
|
+
continue;
|
|
256
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
257
|
+
const day = new Date(y, mo - 1, d);
|
|
258
|
+
// Date rolls an out-of-range month or day over; such a name carries no real date.
|
|
259
|
+
if (day.getFullYear() !== y || day.getMonth() !== mo - 1 || day.getDate() !== d)
|
|
260
|
+
continue;
|
|
261
|
+
if (day < cutoff)
|
|
262
|
+
add(path.join(logs, n), path.join(archiveRoot, "memory", "logs", n), `memory log older than ${LOG_RETENTION_DAYS} days`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const n of staleBackups(ctx.dataDir, "settings.json", notes)) {
|
|
266
|
+
add(path.join(ctx.dataDir, n), path.join(archiveRoot, "memory", "settings-bak", n), "memory settings backup (a newer one is kept)");
|
|
267
|
+
}
|
|
268
|
+
for (const n of staleBackups(ctx.configDir, "settings.json", notes)) {
|
|
269
|
+
add(path.join(ctx.configDir, n), path.join(archiveRoot, "claude-code", n), "Claude Code settings backup (a newer one is kept)");
|
|
270
|
+
}
|
|
271
|
+
const plugins = path.join(ctx.configDir, "plugins");
|
|
272
|
+
if (linkedRoot(plugins, notes))
|
|
273
|
+
return;
|
|
274
|
+
for (const file of ["installed_plugins.json", "known_marketplaces.json"]) {
|
|
275
|
+
for (const n of staleBackups(plugins, file, notes))
|
|
276
|
+
add(path.join(plugins, n), path.join(archiveRoot, "claude-code", n), `${file} backup (a newer one is kept)`);
|
|
277
|
+
}
|
|
278
|
+
const registry = path.join(plugins, "installed_plugins.json");
|
|
279
|
+
if (!fs.existsSync(registry))
|
|
280
|
+
return;
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
const raw = JSON.parse(fs.readFileSync(registry, "utf8"));
|
|
284
|
+
entries = raw.plugins && typeof raw.plugins === "object" ? raw.plugins : {};
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
notes.push(`${registry} is unreadable or not JSON: registry entries not checked`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const stamp = ctx.now.toISOString().replace(/[:.]/g, "-");
|
|
291
|
+
for (const pluginKey of [UPSTREAM_PLUGIN_KEY, PLUGIN_KEY]) {
|
|
292
|
+
if (!deadEntry(entries[pluginKey]))
|
|
293
|
+
continue;
|
|
294
|
+
items.push({
|
|
295
|
+
kind: "registry-entry",
|
|
296
|
+
source: registry,
|
|
297
|
+
destination: path.join(archiveRoot, "claude-code", `installed_plugins.json.${stamp}.pre-cleanup`),
|
|
298
|
+
reason: `${pluginKey} registry entry whose every install path is missing`,
|
|
299
|
+
key: pluginKey,
|
|
300
|
+
// Our own entry means a broken install, fixed by reinstalling rather than by forgetting it.
|
|
301
|
+
refused: pluginKey === PLUGIN_KEY ? `reinstall with \`${REINSTALL}\`` : undefined,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** True when a registry value lists at least one install path and every one is absolute and missing. */
|
|
306
|
+
function deadEntry(value) {
|
|
307
|
+
if (!Array.isArray(value))
|
|
308
|
+
return false;
|
|
309
|
+
const paths = value.map((v) => (v && typeof v === "object" ? v.installPath : undefined)).filter((p) => typeof p === "string");
|
|
310
|
+
// A relative path would resolve against this process's cwd.
|
|
311
|
+
return paths.length > 0 && paths.every((p) => path.isAbsolute(p) && !fs.existsSync(p));
|
|
312
|
+
}
|
|
313
|
+
function planProject(projectRoot, archiveRoot, notes, add) {
|
|
314
|
+
const base = path.basename(path.resolve(projectRoot));
|
|
315
|
+
const changes = path.join(projectRoot, "openspec", "changes");
|
|
316
|
+
const fh = path.join(projectRoot, ".freeharness");
|
|
317
|
+
if (!linkedRoot(fh, notes)) {
|
|
318
|
+
const sdd = path.join(fh, "sdd");
|
|
319
|
+
if (!linkedRoot(sdd, notes)) {
|
|
320
|
+
const archived = names(path.join(changes, "archive"), notes);
|
|
321
|
+
for (const change of names(sdd, notes)) {
|
|
322
|
+
const dir = path.join(sdd, change);
|
|
323
|
+
if (!isDir(dir) || lexists(path.join(changes, change)))
|
|
324
|
+
continue;
|
|
325
|
+
if (archived.some((a) => a === change || (/^\d{4}-\d{2}-\d{2}-/.test(a) && a.slice(11) === change))) {
|
|
326
|
+
add(dir, path.join(archiveRoot, base, "sdd", change), `SDD workspace of the archived change ${change}`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
notes.push(`kept: ${dir} (no active or archived change ${change}; it may belong to another branch)`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const parked = path.join(fh, "parked");
|
|
334
|
+
if (!linkedRoot(parked, notes) && lexists(parked))
|
|
335
|
+
add(parked, path.join(archiveRoot, base, "parked"), "parked patches");
|
|
336
|
+
}
|
|
337
|
+
const canonical = path.join(projectRoot, "canonical");
|
|
338
|
+
if (linkedRoot(canonical, notes) || !fs.existsSync(path.join(canonical, "PROVENANCE.yaml")))
|
|
339
|
+
return;
|
|
340
|
+
const walk = (rel) => {
|
|
341
|
+
let entries;
|
|
342
|
+
try {
|
|
343
|
+
entries = fs.readdirSync(path.join(canonical, rel), { withFileTypes: true });
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
if (errCode(e) !== "ENOENT")
|
|
347
|
+
notes.push(`${path.join(canonical, rel)} is unreadable (${errCode(e) ?? errText(e)}): not scanned`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
351
|
+
const child = path.join(rel, e.name);
|
|
352
|
+
if (CANONICAL_JUNK.has(e.name))
|
|
353
|
+
add(path.join(canonical, child), path.join(archiveRoot, base, "canonical-junk", child), `build or OS junk under canonical/`);
|
|
354
|
+
else if (e.isSymbolicLink()) {
|
|
355
|
+
if (isDir(path.join(canonical, child)))
|
|
356
|
+
linkedRoot(path.join(canonical, child), notes);
|
|
357
|
+
}
|
|
358
|
+
else if (e.isDirectory())
|
|
359
|
+
walk(child);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
walk("");
|
|
363
|
+
}
|
|
364
|
+
/** The dated archive every cleanup move lands in: `<home>/Archive/freeharness-<local date>`. */
|
|
365
|
+
export function archiveRootFor(home, now) {
|
|
366
|
+
return path.join(home, "Archive", `freeharness-${localDate(now)}`);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Why a plugin-cache item must stay: its realpath is not strictly inside the plugin cache.
|
|
370
|
+
* The hard list's guard refuses every cache path by design, so cache items get this check instead.
|
|
371
|
+
*/
|
|
372
|
+
export function pluginCacheEscape(source, cfg) {
|
|
373
|
+
const cache = realOr(path.join(cfg, "plugins", "cache"));
|
|
374
|
+
return within(realOr(source), cache) ? undefined : `resolves outside the plugin cache (${cache})`;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Archive destination of a plugin-cache path, `<archiveRoot>/plugins-cache/<path below the cache>`; reads only.
|
|
378
|
+
* A source not spelled below the cache would map outside `plugins-cache`, so it is refused and gets
|
|
379
|
+
* the `plugins-cache` dir itself, which the apply-time check never accepts.
|
|
380
|
+
*/
|
|
381
|
+
export function pluginCacheArchive(source, cfg, archiveRoot) {
|
|
382
|
+
const cache = path.join(cfg, "plugins", "cache");
|
|
383
|
+
const rel = path.relative(cache, source);
|
|
384
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
385
|
+
return { destination: path.join(archiveRoot, "plugins-cache"), refused: `is not below the plugin cache (${cache})` };
|
|
386
|
+
}
|
|
387
|
+
return { destination: freeDestination(path.join(archiveRoot, "plugins-cache", rel)), refused: pluginCacheEscape(source, cfg) };
|
|
388
|
+
}
|
|
389
|
+
/** Why a planned plugin-cache move must not run now: no archive root, a destination outside `<archiveRoot>/plugins-cache`, or a source escaping the cache. */
|
|
390
|
+
export function pluginCacheRefusal(source, destination, cfg, archiveRoot) {
|
|
391
|
+
if (archiveRoot === undefined)
|
|
392
|
+
return "no archive root in the plan";
|
|
393
|
+
const inbox = path.join(archiveRoot, "plugins-cache");
|
|
394
|
+
if (!within(destination, inbox))
|
|
395
|
+
return `destination ${destination} is outside ${inbox}`;
|
|
396
|
+
return pluginCacheEscape(source, cfg);
|
|
397
|
+
}
|
|
398
|
+
/** Builds the hard-item plan for a scope; reads only. */
|
|
399
|
+
export function planHard(scope, projectRoot, ctx = {}) {
|
|
400
|
+
const home = ctx.home ?? os.homedir();
|
|
401
|
+
// An injected home without its own dirs must never fall through to the real config or data dir.
|
|
402
|
+
const full = {
|
|
403
|
+
home,
|
|
404
|
+
configDir: ctx.configDir ?? (ctx.home ? path.join(home, ".claude") : configDir()),
|
|
405
|
+
dataDir: ctx.dataDir ?? (ctx.home ? resolveMemoryPaths({}, home).dataDir : dataDir()),
|
|
406
|
+
now: ctx.now ?? new Date(),
|
|
407
|
+
};
|
|
408
|
+
const archiveRoot = archiveRootFor(home, full.now);
|
|
409
|
+
const items = [];
|
|
410
|
+
const notes = [];
|
|
411
|
+
const g = { scope, ctx: full, projectRoot };
|
|
412
|
+
const add = (source, dest, reason) => {
|
|
413
|
+
items.push({ kind: "move", source, destination: freeDestination(dest), reason, bytes: treeStats(source).bytes, refused: guard(g, source) });
|
|
414
|
+
};
|
|
415
|
+
// A relative dir would resolve against this process's cwd, not where its owner looks.
|
|
416
|
+
const relative = [["memory data dir", full.dataDir], ["config dir", full.configDir]].filter(([, d]) => !path.isAbsolute(d));
|
|
417
|
+
for (const [label, d] of relative)
|
|
418
|
+
notes.push(`${label} ${JSON.stringify(d)} is not absolute: ${scope === "user" ? "no user item planned" : "not checked as a protected path"}`);
|
|
419
|
+
if (scope === "project")
|
|
420
|
+
planProject(projectRoot, archiveRoot, notes, add);
|
|
421
|
+
else if (relative.length === 0)
|
|
422
|
+
planUser(full, archiveRoot, items, notes, add);
|
|
423
|
+
return { scope, archiveRoot, items, notes, ctx: full, projectRoot };
|
|
424
|
+
}
|
|
425
|
+
/** Takes the first free name of `dest`, `dest.2`, ... by creating it empty. */
|
|
426
|
+
function reserve(dest, dir) {
|
|
427
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
428
|
+
for (let n = 1;; n++) {
|
|
429
|
+
const candidate = n === 1 ? dest : `${dest}.${n}`;
|
|
430
|
+
try {
|
|
431
|
+
if (dir)
|
|
432
|
+
fs.mkdirSync(candidate);
|
|
433
|
+
else
|
|
434
|
+
fs.closeSync(fs.openSync(candidate, "wx"));
|
|
435
|
+
return candidate;
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
if (errCode(e) !== "EEXIST")
|
|
439
|
+
throw e;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** Drops a reservation that is still the empty dir or file `reserve` made. */
|
|
444
|
+
function release(dest, dir) {
|
|
445
|
+
try {
|
|
446
|
+
if (dir)
|
|
447
|
+
fs.rmdirSync(dest);
|
|
448
|
+
else if (fs.lstatSync(dest).isFile() && fs.lstatSync(dest).size === 0)
|
|
449
|
+
fs.unlinkSync(dest);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Not empty, or gone: not ours to remove.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Moves one path to a reserved archive name; a failure before the source removal keeps the source
|
|
457
|
+
* whole. Across devices the source goes only after the copy matches it in entries and bytes; if that
|
|
458
|
+
* removal fails, part of the source may be gone, and the full copy is at the path the error names.
|
|
459
|
+
*/
|
|
460
|
+
function moveOne(source, planned, ops) {
|
|
461
|
+
const dir = fs.lstatSync(source).isDirectory();
|
|
462
|
+
const dest = reserve(planned, dir);
|
|
463
|
+
try {
|
|
464
|
+
ops.rename(source, dest);
|
|
465
|
+
return dest;
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
if (errCode(e) !== "EXDEV") {
|
|
469
|
+
release(dest, dir);
|
|
470
|
+
throw e;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
ops.copy(source, dest);
|
|
475
|
+
const from = treeStats(source);
|
|
476
|
+
const to = treeStats(dest);
|
|
477
|
+
if (from.entries !== to.entries || from.bytes !== to.bytes)
|
|
478
|
+
throw Object.assign(new Error(`copy of ${source} is incomplete`), { code: "ESIZE" });
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
// An existing target means another writer's content: it stays.
|
|
482
|
+
if (!targetExists(e)) {
|
|
483
|
+
try {
|
|
484
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
catch (rmErr) {
|
|
487
|
+
throw Object.assign(new Error(`${errText(e)}; partial copy left at ${dest} (${errText(rmErr)})`), { code: errCode(e) });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
throw e;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
fs.rmSync(source, { recursive: true });
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
throw new Error(`copied to ${dest}, but removing the source failed (${errText(e)})`);
|
|
497
|
+
}
|
|
498
|
+
return dest;
|
|
499
|
+
}
|
|
500
|
+
/** Moves one path to a reserved name at or after `destination` with the hard mover's guarantees; returns where it landed. */
|
|
501
|
+
export function archiveMove(source, destination, fsOps = {}) {
|
|
502
|
+
return moveOne(source, destination, { ...realFs, ...fsOps });
|
|
503
|
+
}
|
|
504
|
+
/** The WARN line of a failed move: a source gone since planning reads as such, anything else keeps its error code. */
|
|
505
|
+
export function moveWarning(source, e) {
|
|
506
|
+
return errCode(e) === "ENOENT" && !lexists(source) ? `${source}: gone, nothing moved` : `${source}: ${errText(e)}`;
|
|
507
|
+
}
|
|
508
|
+
/** Removes a dead registry key through the shared config edit; an entry that came back to life stays. */
|
|
509
|
+
function dropRegistryKey(item, ops) {
|
|
510
|
+
const r = removeJsonKey(item.source, ["plugins", item.key], item.destination, { stillMatches: deadEntry, rename: (from, to) => ops.rename(from, to) });
|
|
511
|
+
if (!r.ok)
|
|
512
|
+
throw new Error(`${item.key} not removed (${r.reason})`);
|
|
513
|
+
return r.copy;
|
|
514
|
+
}
|
|
515
|
+
/** Moves the confirmed items of a plan (`confirmed` defaults to all); refused items are always skipped. */
|
|
516
|
+
export function applyHard(plan, confirmed = () => true, fsOps = {}) {
|
|
517
|
+
const ops = { ...realFs, ...fsOps };
|
|
518
|
+
const out = { moved: [], skipped: [], warnings: [] };
|
|
519
|
+
for (const item of plan.items) {
|
|
520
|
+
if (item.refused || !confirmed(item)) {
|
|
521
|
+
out.skipped.push(item.source);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const refused = within(item.destination, plan.archiveRoot) ? guard(plan, item.source) : `destination ${item.destination} is outside the archive root ${plan.archiveRoot}`;
|
|
525
|
+
if (refused) {
|
|
526
|
+
out.skipped.push(item.source);
|
|
527
|
+
out.warnings.push(`${item.source}: refused (${refused})`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
if (item.kind === "registry-entry") {
|
|
532
|
+
const copy = dropRegistryKey(item, ops);
|
|
533
|
+
out.moved.push(`${item.source} (removed ${item.key}; original copied to ${copy})`);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
out.moved.push(`${item.source} -> ${moveOne(item.source, item.destination, ops)}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
out.warnings.push(moveWarning(item.source, e));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
/** Renders a hard item for the preview, one line, without the OK/WARN prefix; the caller escapes it. */
|
|
546
|
+
export function describeHard(item) {
|
|
547
|
+
if (item.refused)
|
|
548
|
+
return `keep: ${item.source}${item.kind === "registry-entry" ? ` (${item.key})` : ""} — ${item.reason} — ${item.refused}`;
|
|
549
|
+
if (item.kind === "registry-entry")
|
|
550
|
+
return `edit: ${item.source} (remove ${item.key}; original to ${item.destination}) — ${item.reason}`;
|
|
551
|
+
return `move: ${item.source} -> ${item.destination} (${humanSize(item.bytes)}) — ${item.reason}`;
|
|
552
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InstallManifest } from "../infra/manifest.js";
|
|
2
|
+
/** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`). */
|
|
3
|
+
export declare const RETIRED: {
|
|
4
|
+
readonly skills: readonly ["local-code-intel"];
|
|
5
|
+
readonly agents: readonly ["mle-reviewer", "mle-evaluator", "implementer-medium", "implementer-high"];
|
|
6
|
+
readonly hooks: readonly ["freeharness-auto-memory.mjs"];
|
|
7
|
+
};
|
|
8
|
+
export type RetiredKind = "skill" | "agent" | "hook";
|
|
9
|
+
export interface RetiredFinding {
|
|
10
|
+
kind: RetiredKind;
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
/** Manifest lists `path` (agent, hook) or a file under it (skill dir). */
|
|
14
|
+
recorded: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Whether a settings hook command runs a retired hook script; the entries `cleanup` prunes beside the script. */
|
|
17
|
+
export declare function runsRetiredHook(command: string): boolean;
|
|
18
|
+
/** Retired names still present under a `.claude` dir: `update`'s additive merge never deletes them; an absent path is skipped. */
|
|
19
|
+
export declare function retiredFindings(claudeDir: string, manifest: InstallManifest | undefined): RetiredFinding[];
|
|
20
|
+
/** Canonical agent file names and skill dir names: what a project could duplicate, and what doctor tells apart from the user's own content. */
|
|
21
|
+
export declare function canonicalNames(canonicalDir: string): {
|
|
22
|
+
agents: string[];
|
|
23
|
+
skills: string[];
|
|
24
|
+
};
|
|
25
|
+
/** Whether free-mem is enabled: only the settings key set to true counts, since an installed but disabled plugin keeps no memory. */
|
|
26
|
+
export declare function freeMemEnabled(claudeDir: string): boolean;
|
|
27
|
+
/** Whether free-mem is enabled or registered in installed_plugins.json; the hook-conflict and cleanup checks count a registered plugin too. */
|
|
28
|
+
export declare function memoryPluginInstalled(claudeDir: string): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PLUGIN_KEY } from "../adapters/memory/vendored.js";
|
|
4
|
+
/** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`). */
|
|
5
|
+
export const RETIRED = {
|
|
6
|
+
skills: ["local-code-intel"],
|
|
7
|
+
agents: ["mle-reviewer", "mle-evaluator", "implementer-medium", "implementer-high"],
|
|
8
|
+
hooks: ["freeharness-auto-memory.mjs"],
|
|
9
|
+
};
|
|
10
|
+
/** Whether a settings hook command runs a retired hook script; the entries `cleanup` prunes beside the script. */
|
|
11
|
+
export function runsRetiredHook(command) {
|
|
12
|
+
return RETIRED.hooks.some((name) => command.includes(name));
|
|
13
|
+
}
|
|
14
|
+
/** Retired names still present under a `.claude` dir: `update`'s additive merge never deletes them; an absent path is skipped. */
|
|
15
|
+
export function retiredFindings(claudeDir, manifest) {
|
|
16
|
+
const groups = [
|
|
17
|
+
{ kind: "skill", dir: "skills", names: RETIRED.skills },
|
|
18
|
+
{ kind: "agent", dir: "agents", names: RETIRED.agents },
|
|
19
|
+
{ kind: "hook", dir: path.join("freeharness", "hooks"), names: RETIRED.hooks },
|
|
20
|
+
];
|
|
21
|
+
const found = [];
|
|
22
|
+
for (const { kind, dir, names } of groups) {
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const fileName = kind === "agent" ? `${name}.md` : name;
|
|
25
|
+
const p = path.join(claudeDir, dir, fileName);
|
|
26
|
+
if (!fs.existsSync(p))
|
|
27
|
+
continue;
|
|
28
|
+
const under = `${p}${path.sep}`;
|
|
29
|
+
const recorded = manifest?.files.some((f) => f.path === p || f.path.startsWith(under)) ?? false;
|
|
30
|
+
found.push({ kind, name, path: p, recorded });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
/** Canonical agent file names and skill dir names: what a project could duplicate, and what doctor tells apart from the user's own content. */
|
|
36
|
+
export function canonicalNames(canonicalDir) {
|
|
37
|
+
const list = (dir, dirsOnly) => {
|
|
38
|
+
if (!fs.existsSync(dir))
|
|
39
|
+
return [];
|
|
40
|
+
return fs
|
|
41
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
42
|
+
.filter((e) => (dirsOnly ? e.isDirectory() : e.isFile() && e.name.endsWith(".md") && e.name !== "README.md"))
|
|
43
|
+
.map((e) => e.name);
|
|
44
|
+
};
|
|
45
|
+
return { agents: list(path.join(canonicalDir, "agents"), false), skills: list(path.join(canonicalDir, "skills"), true) };
|
|
46
|
+
}
|
|
47
|
+
function readJsonObject(f) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(f, "utf8"));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Whether free-mem is enabled: only the settings key set to true counts, since an installed but disabled plugin keeps no memory. */
|
|
56
|
+
export function freeMemEnabled(claudeDir) {
|
|
57
|
+
const settings = readJsonObject(path.join(claudeDir, "settings.json"));
|
|
58
|
+
return settings?.enabledPlugins?.[PLUGIN_KEY] === true;
|
|
59
|
+
}
|
|
60
|
+
/** Whether free-mem is enabled or registered in installed_plugins.json; the hook-conflict and cleanup checks count a registered plugin too. */
|
|
61
|
+
export function memoryPluginInstalled(claudeDir) {
|
|
62
|
+
if (freeMemEnabled(claudeDir))
|
|
63
|
+
return true;
|
|
64
|
+
const ip = readJsonObject(path.join(claudeDir, "plugins", "installed_plugins.json"));
|
|
65
|
+
return Boolean(ip.plugins && PLUGIN_KEY in ip.plugins);
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A refusal carries a short token (`key-gone`, `copy-failed: EACCES`) the caller prints as is. */
|
|
2
|
+
export type JsonKeyResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
copy: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
export interface JsonKeyOptions {
|
|
10
|
+
/** Pass 2 re-checks the value, so an entry another program rewrote since pass 1 stays. */
|
|
11
|
+
stillMatches?: (value: unknown) => boolean;
|
|
12
|
+
rename?: (from: string, to: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Removes `keyPath` from the JSON object in `file` after a verified copy of the unedited bytes
|
|
16
|
+
* lands at the first free name of `copyDest`. Every refusal leaves `file` byte-equal.
|
|
17
|
+
*/
|
|
18
|
+
export declare function removeJsonKey(file: string, keyPath: readonly string[], copyDest: string, options?: JsonKeyOptions): JsonKeyResult;
|