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,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export declare const DISABLE_COMMAND = "claude plugin disable claude-mem@thedotmack";
|
|
3
|
+
export declare const MARKER_NAME = ".migrated-from.json";
|
|
4
|
+
export declare const PARTIAL_PATTERN: RegExp;
|
|
5
|
+
export interface MigrationLink {
|
|
6
|
+
/** Path relative to the legacy directory. */
|
|
7
|
+
path: string;
|
|
8
|
+
target: string;
|
|
9
|
+
/** True when the target resolves outside the legacy tree, which aborts pass 2. */
|
|
10
|
+
outside: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface MigrationPlan {
|
|
13
|
+
home: string;
|
|
14
|
+
legacyDir: string;
|
|
15
|
+
freeDir: string;
|
|
16
|
+
bytes: number;
|
|
17
|
+
/** Bytes available on the HOME file system; null when `statfs` is unavailable. */
|
|
18
|
+
freeBytes: number | null;
|
|
19
|
+
links: MigrationLink[];
|
|
20
|
+
/** Leftover `.free-mem.partial-<stamp>` siblings, removed by the next pass 2. */
|
|
21
|
+
partials: string[];
|
|
22
|
+
unreadable: string[];
|
|
23
|
+
/** The WARN that refuses the copy while the upstream plugin is enabled; null otherwise. */
|
|
24
|
+
refusal: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Claude Code's transcript directory for the legacy observer cwd and the one for the free-mem
|
|
27
|
+
* observer cwd; set only when the legacy one exists, so a session open across the copy keeps it.
|
|
28
|
+
*/
|
|
29
|
+
observerMove?: {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface StopResult {
|
|
35
|
+
/** True when no worker answers and no live pid file remains. */
|
|
36
|
+
stopped: boolean;
|
|
37
|
+
wasRunning: boolean;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface SpawnLock {
|
|
41
|
+
refresh(): void;
|
|
42
|
+
/** True while the lock file still names this process. */
|
|
43
|
+
held(): boolean;
|
|
44
|
+
release(): void;
|
|
45
|
+
}
|
|
46
|
+
export interface MigrationPorts {
|
|
47
|
+
stopWorker(): Promise<StopResult>;
|
|
48
|
+
now(): number;
|
|
49
|
+
/** Null when the copied database passes `PRAGMA quick_check`, else the reason. */
|
|
50
|
+
integrityCheck(dbFile: string): Promise<string | null> | string | null;
|
|
51
|
+
version: string;
|
|
52
|
+
fs?: typeof fs;
|
|
53
|
+
acquireLock?(lockPath: string): SpawnLock | string;
|
|
54
|
+
/** The engine breaks a lock older than 90 s, so it is touched well inside that bound. */
|
|
55
|
+
lockRefreshMs?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface MigrationResult {
|
|
58
|
+
status: "copied" | "aborted" | "refused";
|
|
59
|
+
reason?: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
durationMs: number;
|
|
62
|
+
skipped: string[];
|
|
63
|
+
warnings: string[];
|
|
64
|
+
/** True when the stop call stopped a running worker, which the post-install restart owes back. */
|
|
65
|
+
restartOwed: boolean;
|
|
66
|
+
/** The outcome line for the observer transcript directory, when the plan has one. */
|
|
67
|
+
observer?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Plans the copy of `<home>/.claude-mem` into `<home>/.free-mem`, reading only.
|
|
71
|
+
* @param home The HOME that holds both directories.
|
|
72
|
+
* @param env Environment for the data-dir variables and `CLAUDE_CONFIG_DIR`.
|
|
73
|
+
* @returns The plan, or null when nothing is copied: no legacy directory, `~/.free-mem` exists,
|
|
74
|
+
* or a data-dir variable or the legacy settings redirect names a custom directory.
|
|
75
|
+
*/
|
|
76
|
+
export declare function planMemoryMigration(home: string, env: Record<string, string | undefined>): MigrationPlan | null;
|
|
77
|
+
/** True when `pidFile` exists and names a live process, or cannot be read as a pid record. */
|
|
78
|
+
export declare function pidFileLive(pidFile: string, f?: typeof fs): boolean;
|
|
79
|
+
/** Takes the engine spawn lock with the engine's own `wx` create, so no launcher spawns a worker. */
|
|
80
|
+
export declare function fileSpawnLock(lockPath: string, f: typeof fs, now: () => number): SpawnLock | string;
|
|
81
|
+
/** Runs `PRAGMA quick_check` on a database through `bun -e`; Bun is already an install prerequisite. */
|
|
82
|
+
export declare function bunIntegrityCheck(bunPath: string | null): (dbFile: string) => Promise<string | null>;
|
|
83
|
+
/**
|
|
84
|
+
* Applies a plan: stop the worker, hold the spawn lock, copy into a sibling, rename the database
|
|
85
|
+
* trio, rewrite settings, check the copy, recheck the source, write the marker, rename the sibling.
|
|
86
|
+
* Every failure removes the sibling, so the resolver keeps serving the legacy directory.
|
|
87
|
+
* @param plan A plan from {@link planMemoryMigration}.
|
|
88
|
+
* @param ports The stop call, clock, integrity check, version and, for tests, file system and lock.
|
|
89
|
+
* @returns What happened; never throws.
|
|
90
|
+
*/
|
|
91
|
+
export declare function applyMemoryMigration(plan: MigrationPlan, ports: MigrationPorts): Promise<MigrationResult>;
|
|
92
|
+
/** The pass-1 lines for a plan: the copy with its size and free space, links, leftovers, refusals. */
|
|
93
|
+
export declare function describePlan(plan: MigrationPlan): string[];
|
|
94
|
+
/** The pass-2 lines for an applied plan. */
|
|
95
|
+
export declare function describeResult(plan: MigrationPlan, r: MigrationResult): string[];
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// Copies a legacy ~/.claude-mem forward into ~/.free-mem once, through a temporary sibling whose
|
|
2
|
+
// rename is the only commit point; the legacy tree is only read, apart from the engine spawn lock.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { DB_FILENAME, FREE_DIR_NAME, LEGACY_DB_FILENAME, LEGACY_DIR_NAME, resolveMemoryPaths } from "./paths.js";
|
|
7
|
+
const UPSTREAM_KEY = "claude-mem@thedotmack";
|
|
8
|
+
export const DISABLE_COMMAND = `claude plugin disable ${UPSTREAM_KEY}`;
|
|
9
|
+
export const MARKER_NAME = ".migrated-from.json";
|
|
10
|
+
export const PARTIAL_PATTERN = /^\.free-mem\.partial-[0-9TZ]+$/;
|
|
11
|
+
const RUNTIME_FILES = new Set(["worker.pid", ".server-beta.pid", ".server-beta.port", ".server-beta.runtime.json", ".worker-start-attempted", ".worker-clean-shutdown", "spawn.lock"]);
|
|
12
|
+
const LOCK_REFRESH_MS = 30_000;
|
|
13
|
+
const OLD_PREFIX = "CLAUDE_MEM_";
|
|
14
|
+
const NEW_PREFIX = "FREE_MEM_";
|
|
15
|
+
class Abort extends Error {
|
|
16
|
+
}
|
|
17
|
+
function inside(p, root) {
|
|
18
|
+
return p === root || p.startsWith(root + path.sep);
|
|
19
|
+
}
|
|
20
|
+
function isSkipped(name) {
|
|
21
|
+
return name === "logs" || RUNTIME_FILES.has(name) || name.endsWith(".lock");
|
|
22
|
+
}
|
|
23
|
+
function readJson(file) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(file, "utf8").replace(/^/, ""));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function upstreamEnabled(configDir) {
|
|
32
|
+
const settingsFile = path.join(configDir, "settings.json");
|
|
33
|
+
const enabled = readJson(settingsFile).enabledPlugins?.[UPSTREAM_KEY];
|
|
34
|
+
if (enabled === false)
|
|
35
|
+
return null;
|
|
36
|
+
if (enabled !== true && !readJson(path.join(configDir, "plugins", "installed_plugins.json")).plugins?.[UPSTREAM_KEY])
|
|
37
|
+
return null;
|
|
38
|
+
return settingsFile;
|
|
39
|
+
}
|
|
40
|
+
function scanTree(legacyDir) {
|
|
41
|
+
const out = { bytes: 0, links: [], unreadable: [] };
|
|
42
|
+
const walk = (rel) => {
|
|
43
|
+
let names;
|
|
44
|
+
try {
|
|
45
|
+
names = fs.readdirSync(path.join(legacyDir, rel));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
out.unreadable.push(rel || ".");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const name of names.sort()) {
|
|
52
|
+
if (!rel && isSkipped(name))
|
|
53
|
+
continue;
|
|
54
|
+
const r = path.join(rel, name);
|
|
55
|
+
const abs = path.join(legacyDir, r);
|
|
56
|
+
try {
|
|
57
|
+
const st = fs.lstatSync(abs);
|
|
58
|
+
if (st.isSymbolicLink()) {
|
|
59
|
+
const target = fs.readlinkSync(abs);
|
|
60
|
+
out.links.push({ path: r, target, outside: !inside(path.resolve(path.dirname(abs), target), legacyDir) });
|
|
61
|
+
}
|
|
62
|
+
else if (st.isDirectory())
|
|
63
|
+
walk(r);
|
|
64
|
+
else
|
|
65
|
+
out.bytes += st.size;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
out.unreadable.push(r);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk("");
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function freeSpace(dir) {
|
|
76
|
+
const statfs = fs.statfsSync;
|
|
77
|
+
try {
|
|
78
|
+
return statfs ? Number(statfs(dir).bavail) * Number(statfs(dir).bsize) : null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Plans the copy of `<home>/.claude-mem` into `<home>/.free-mem`, reading only.
|
|
86
|
+
* @param home The HOME that holds both directories.
|
|
87
|
+
* @param env Environment for the data-dir variables and `CLAUDE_CONFIG_DIR`.
|
|
88
|
+
* @returns The plan, or null when nothing is copied: no legacy directory, `~/.free-mem` exists,
|
|
89
|
+
* or a data-dir variable or the legacy settings redirect names a custom directory.
|
|
90
|
+
*/
|
|
91
|
+
export function planMemoryMigration(home, env) {
|
|
92
|
+
const resolved = resolveMemoryPaths(env, home);
|
|
93
|
+
if (resolved.source !== "fallback")
|
|
94
|
+
return null;
|
|
95
|
+
const legacyDir = resolved.dataDir;
|
|
96
|
+
const scan = scanTree(legacyDir);
|
|
97
|
+
const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
98
|
+
const settingsFile = upstreamEnabled(configDir);
|
|
99
|
+
const refusal = settingsFile
|
|
100
|
+
? `${UPSTREAM_KEY} is enabled, and its hooks would start an upstream worker on ${legacyDir} and the same port; the memory data copy is skipped and the rest of the install proceeds. Disable it with \`${DISABLE_COMMAND}\` (or set enabledPlugins["${UPSTREAM_KEY}"] to false in ${settingsFile}), then re-run.`
|
|
101
|
+
: null;
|
|
102
|
+
let partials = [];
|
|
103
|
+
try {
|
|
104
|
+
partials = fs.readdirSync(home).filter((n) => PARTIAL_PATTERN.test(n)).sort().map((n) => path.join(home, n));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* an unreadable HOME has no leftovers to list */
|
|
108
|
+
}
|
|
109
|
+
const freeDir = path.join(home, FREE_DIR_NAME);
|
|
110
|
+
const plan = { home, legacyDir, freeDir, bytes: scan.bytes, freeBytes: freeSpace(home), links: scan.links, partials, unreadable: scan.unreadable, refusal };
|
|
111
|
+
const projects = path.join(configDir, "projects");
|
|
112
|
+
const from = path.join(projects, projectSlug(path.join(legacyDir, OBSERVER_DIR)));
|
|
113
|
+
try {
|
|
114
|
+
fs.lstatSync(from);
|
|
115
|
+
plan.observerMove = { from, to: path.join(projects, projectSlug(path.join(freeDir, OBSERVER_DIR))) };
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* no legacy transcripts, nothing to move */
|
|
119
|
+
}
|
|
120
|
+
return plan;
|
|
121
|
+
}
|
|
122
|
+
const OBSERVER_DIR = "observer-sessions";
|
|
123
|
+
/** Claude Code names a projects directory after its cwd with every non-alphanumeric as `-`. */
|
|
124
|
+
function projectSlug(cwd) {
|
|
125
|
+
return cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
126
|
+
}
|
|
127
|
+
/** Renames the legacy transcript directory only when it is a real directory and the target is absent. */
|
|
128
|
+
function moveObserver(f, move) {
|
|
129
|
+
const keep = (why) => `observer transcripts left in place: ${why}; ${move.from} and ${move.to} both kept, nothing deleted`;
|
|
130
|
+
try {
|
|
131
|
+
if (!f.lstatSync(move.from).isDirectory())
|
|
132
|
+
return keep(`${move.from} is not a real directory`);
|
|
133
|
+
if (present(f, move.to))
|
|
134
|
+
return keep(`${move.to} already exists`);
|
|
135
|
+
f.renameSync(move.from, move.to);
|
|
136
|
+
return `Moved the observer transcripts from ${move.from} to ${move.to}.`;
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
return keep(e.message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** lstat, so a dangling link at the target also counts as present and is never replaced. */
|
|
143
|
+
function present(f, p) {
|
|
144
|
+
try {
|
|
145
|
+
f.lstatSync(p);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** True when `pidFile` exists and names a live process, or cannot be read as a pid record. */
|
|
153
|
+
export function pidFileLive(pidFile, f = fs) {
|
|
154
|
+
let raw;
|
|
155
|
+
try {
|
|
156
|
+
raw = f.readFileSync(pidFile, "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const pid = Number(readJsonText(raw).pid);
|
|
162
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
163
|
+
return true;
|
|
164
|
+
try {
|
|
165
|
+
process.kill(pid, 0);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return e.code === "EPERM";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function readJsonText(raw) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(raw) ?? {};
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Takes the engine spawn lock with the engine's own `wx` create, so no launcher spawns a worker. */
|
|
181
|
+
export function fileSpawnLock(lockPath, f, now) {
|
|
182
|
+
try {
|
|
183
|
+
f.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, startedAt: new Date(now()).toISOString() }), { flag: "wx" });
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
const code = e.code;
|
|
187
|
+
return code === "EEXIST" ? `${lockPath} is held by another launcher; re-run once it is released` : `cannot take ${lockPath}: ${e.message}`;
|
|
188
|
+
}
|
|
189
|
+
const held = () => {
|
|
190
|
+
try {
|
|
191
|
+
return readJsonText(f.readFileSync(lockPath, "utf8")).pid === process.pid;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
refresh() {
|
|
199
|
+
const t = new Date(now());
|
|
200
|
+
f.utimesSync(lockPath, t, t);
|
|
201
|
+
},
|
|
202
|
+
held,
|
|
203
|
+
release() {
|
|
204
|
+
try {
|
|
205
|
+
if (held())
|
|
206
|
+
f.unlinkSync(lockPath);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* already gone */
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** Touches the lock on a timer, which fires only because every long step is asynchronous. */
|
|
215
|
+
function keepFresh(lock, ms) {
|
|
216
|
+
let lost = null;
|
|
217
|
+
const timer = setInterval(() => {
|
|
218
|
+
try {
|
|
219
|
+
lock.refresh();
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
lost ??= e.message;
|
|
223
|
+
}
|
|
224
|
+
}, ms);
|
|
225
|
+
return { stop: () => clearInterval(timer), lost: () => lost };
|
|
226
|
+
}
|
|
227
|
+
const QUICK_CHECK = `const { Database } = require("bun:sqlite");
|
|
228
|
+
const db = new Database(process.env.FH_MIGRATE_DB);
|
|
229
|
+
console.log(JSON.stringify(db.query("PRAGMA quick_check").all()));
|
|
230
|
+
db.close();`;
|
|
231
|
+
/** Runs `PRAGMA quick_check` on a database through `bun -e`; Bun is already an install prerequisite. */
|
|
232
|
+
export function bunIntegrityCheck(bunPath) {
|
|
233
|
+
return (dbFile) => new Promise((resolve) => {
|
|
234
|
+
if (!bunPath)
|
|
235
|
+
return resolve(`Bun is not available to run PRAGMA quick_check on ${dbFile}`);
|
|
236
|
+
const opts = { env: { ...process.env, FH_MIGRATE_DB: dbFile }, encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 };
|
|
237
|
+
execFile(bunPath, ["-e", QUICK_CHECK], opts, (err, stdout, stderr) => {
|
|
238
|
+
const rows = readJsonText(stdout.trim().split("\n").pop() ?? "");
|
|
239
|
+
if (Array.isArray(rows) && rows.length === 1 && Object.values(rows[0])[0] === "ok")
|
|
240
|
+
return resolve(null);
|
|
241
|
+
resolve(`PRAGMA quick_check on ${dbFile} returned ${(stdout || stderr || err?.message || "no output").trim()}`);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function trioState(f, dir) {
|
|
246
|
+
const state = {};
|
|
247
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
248
|
+
const name = LEGACY_DB_FILENAME + suffix;
|
|
249
|
+
try {
|
|
250
|
+
const st = f.lstatSync(path.join(dir, name));
|
|
251
|
+
state[name] = `${st.size}:${st.mtimeMs}`;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
state[name] = "absent";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return state;
|
|
258
|
+
}
|
|
259
|
+
function rebaseString(value, legacyDir, freeDir) {
|
|
260
|
+
for (const [from, to] of [[legacyDir, freeDir], [`~/${LEGACY_DIR_NAME}`, `~/${FREE_DIR_NAME}`]]) {
|
|
261
|
+
if (value === from || value.startsWith(from + "/"))
|
|
262
|
+
return to + value.slice(from.length);
|
|
263
|
+
}
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
function rebaseValues(value, legacyDir, freeDir) {
|
|
267
|
+
if (typeof value === "string")
|
|
268
|
+
return rebaseString(value, legacyDir, freeDir);
|
|
269
|
+
if (Array.isArray(value))
|
|
270
|
+
return value.map((v) => rebaseValues(v, legacyDir, freeDir));
|
|
271
|
+
if (value && typeof value === "object")
|
|
272
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, rebaseValues(v, legacyDir, freeDir)]));
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
function renameKeys(settings) {
|
|
276
|
+
const out = {};
|
|
277
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
278
|
+
const renamed = key.startsWith(OLD_PREFIX) ? NEW_PREFIX + key.slice(OLD_PREFIX.length) : key;
|
|
279
|
+
if (renamed !== key && Object.hasOwn(settings, renamed))
|
|
280
|
+
continue;
|
|
281
|
+
out[renamed] = value;
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
class Copier {
|
|
286
|
+
f;
|
|
287
|
+
plan;
|
|
288
|
+
skipped = [];
|
|
289
|
+
warnings = [];
|
|
290
|
+
constructor(f, plan) {
|
|
291
|
+
this.f = f;
|
|
292
|
+
this.plan = plan;
|
|
293
|
+
}
|
|
294
|
+
async copyDir(rel, dest) {
|
|
295
|
+
const src = path.join(this.plan.legacyDir, rel);
|
|
296
|
+
const mode = this.f.lstatSync(src).mode & 0o7777;
|
|
297
|
+
this.f.mkdirSync(dest, { mode: 0o700 });
|
|
298
|
+
for (const name of this.f.readdirSync(src).sort()) {
|
|
299
|
+
const r = path.join(rel, name);
|
|
300
|
+
if (!rel && isSkipped(name)) {
|
|
301
|
+
// A spawn.lock here is the one this copy holds: an existing one refuses the lock.
|
|
302
|
+
if (name !== "spawn.lock")
|
|
303
|
+
this.skipped.push(r);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
await this.copyEntry(r, path.join(dest, name));
|
|
307
|
+
}
|
|
308
|
+
this.f.chmodSync(dest, mode);
|
|
309
|
+
}
|
|
310
|
+
async copyEntry(rel, dest) {
|
|
311
|
+
const src = path.join(this.plan.legacyDir, rel);
|
|
312
|
+
const st = this.f.lstatSync(src);
|
|
313
|
+
if (st.isSymbolicLink()) {
|
|
314
|
+
const target = this.f.readlinkSync(src);
|
|
315
|
+
if (!inside(path.resolve(path.dirname(src), target), this.plan.legacyDir))
|
|
316
|
+
throw new Abort(`symbolic link ${rel} -> ${target} leaves ${this.plan.legacyDir}`);
|
|
317
|
+
this.f.symlinkSync(path.isAbsolute(target) ? rebaseString(target, this.plan.legacyDir, this.plan.freeDir) : target, dest);
|
|
318
|
+
}
|
|
319
|
+
else if (st.isDirectory())
|
|
320
|
+
await this.copyDir(rel, dest);
|
|
321
|
+
else if (st.isFile()) {
|
|
322
|
+
await this.f.promises.copyFile(src, dest);
|
|
323
|
+
this.f.chmodSync(dest, st.mode & 0o7777);
|
|
324
|
+
}
|
|
325
|
+
else
|
|
326
|
+
this.skipped.push(rel);
|
|
327
|
+
}
|
|
328
|
+
renameTrio(dir) {
|
|
329
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
330
|
+
const from = path.join(dir, LEGACY_DB_FILENAME + suffix);
|
|
331
|
+
const to = path.join(dir, DB_FILENAME + suffix);
|
|
332
|
+
if (this.f.existsSync(from) && !this.f.existsSync(to))
|
|
333
|
+
this.f.renameSync(from, to);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
rewriteSettings(dir) {
|
|
337
|
+
const file = path.join(dir, "settings.json");
|
|
338
|
+
let st;
|
|
339
|
+
try {
|
|
340
|
+
st = this.f.lstatSync(file);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!st.isFile())
|
|
346
|
+
return;
|
|
347
|
+
let raw;
|
|
348
|
+
try {
|
|
349
|
+
raw = JSON.parse(this.f.readFileSync(file, "utf8").replace(/^/, ""));
|
|
350
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
351
|
+
throw new Error("not an object");
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
this.warnings.push(`${path.join(this.plan.legacyDir, "settings.json")} is not JSON; copied unchanged, so readers fall back to ${OLD_PREFIX} keys and defaults`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const env = raw.env;
|
|
358
|
+
const renamed = env && typeof env === "object" && !Array.isArray(env) ? { ...raw, env: renameKeys(env) } : renameKeys(raw);
|
|
359
|
+
const mode = st.mode & 0o7777;
|
|
360
|
+
this.f.chmodSync(file, mode | 0o200);
|
|
361
|
+
this.f.writeFileSync(file, JSON.stringify(rebaseValues(renamed, this.plan.legacyDir, this.plan.freeDir), null, 2) + "\n");
|
|
362
|
+
this.f.chmodSync(file, mode);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function stamp(ms) {
|
|
366
|
+
return new Date(ms).toISOString().replace(/[-:.]/g, "");
|
|
367
|
+
}
|
|
368
|
+
function preflight(plan) {
|
|
369
|
+
const outside = plan.links.find((l) => l.outside);
|
|
370
|
+
if (outside)
|
|
371
|
+
return `symbolic link ${outside.path} -> ${outside.target} leaves ${plan.legacyDir}`;
|
|
372
|
+
if (plan.unreadable.length > 0)
|
|
373
|
+
return `cannot read ${plan.unreadable.map((r) => path.join(plan.legacyDir, r)).join(", ")}`;
|
|
374
|
+
if (plan.freeBytes !== null && plan.freeBytes < plan.bytes)
|
|
375
|
+
return `the copy needs ${plan.bytes} bytes and ${plan.freeBytes} are free`;
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Applies a plan: stop the worker, hold the spawn lock, copy into a sibling, rename the database
|
|
380
|
+
* trio, rewrite settings, check the copy, recheck the source, write the marker, rename the sibling.
|
|
381
|
+
* Every failure removes the sibling, so the resolver keeps serving the legacy directory.
|
|
382
|
+
* @param plan A plan from {@link planMemoryMigration}.
|
|
383
|
+
* @param ports The stop call, clock, integrity check, version and, for tests, file system and lock.
|
|
384
|
+
* @returns What happened; never throws.
|
|
385
|
+
*/
|
|
386
|
+
export async function applyMemoryMigration(plan, ports) {
|
|
387
|
+
const f = ports.fs ?? fs;
|
|
388
|
+
const started = ports.now();
|
|
389
|
+
const result = { status: "aborted", bytes: plan.bytes, durationMs: 0, skipped: [], warnings: [], restartOwed: false };
|
|
390
|
+
const done = (status, reason) => ({ ...result, status, reason, durationMs: Math.max(0, ports.now() - started) });
|
|
391
|
+
if (plan.refusal)
|
|
392
|
+
return done("refused", plan.refusal);
|
|
393
|
+
const early = preflight(plan) ?? (f.existsSync(plan.freeDir) ? `${plan.freeDir} already exists` : null);
|
|
394
|
+
if (early)
|
|
395
|
+
return done("aborted", early);
|
|
396
|
+
const stop = await ports.stopWorker();
|
|
397
|
+
result.restartOwed = stop.wasRunning && stop.stopped;
|
|
398
|
+
if (!stop.stopped)
|
|
399
|
+
return done("aborted", `the worker did not stop: ${stop.reason ?? "unknown"}`);
|
|
400
|
+
const lockPath = path.join(plan.legacyDir, "spawn.lock");
|
|
401
|
+
const lock = ports.acquireLock ? ports.acquireLock(lockPath) : fileSpawnLock(lockPath, f, ports.now);
|
|
402
|
+
if (typeof lock === "string")
|
|
403
|
+
return done("aborted", lock);
|
|
404
|
+
const fresh = keepFresh(lock, ports.lockRefreshMs ?? LOCK_REFRESH_MS);
|
|
405
|
+
let partial = null;
|
|
406
|
+
try {
|
|
407
|
+
for (const leftover of plan.partials)
|
|
408
|
+
if (PARTIAL_PATTERN.test(path.basename(leftover)) && path.dirname(leftover) === plan.home)
|
|
409
|
+
await f.promises.rm(leftover, { recursive: true, force: true });
|
|
410
|
+
const before = trioState(f, plan.legacyDir);
|
|
411
|
+
partial = path.join(plan.home, `${FREE_DIR_NAME}.partial-${stamp(ports.now())}`);
|
|
412
|
+
const copier = new Copier(f, plan);
|
|
413
|
+
await copier.copyDir("", partial);
|
|
414
|
+
copier.renameTrio(partial);
|
|
415
|
+
copier.rewriteSettings(partial);
|
|
416
|
+
result.skipped = copier.skipped.sort();
|
|
417
|
+
result.warnings = copier.warnings;
|
|
418
|
+
const db = path.join(partial, DB_FILENAME);
|
|
419
|
+
const bad = f.existsSync(db) ? await ports.integrityCheck(db) : null;
|
|
420
|
+
if (bad)
|
|
421
|
+
throw new Abort(bad);
|
|
422
|
+
// The recheck sits right before the marker, so a write during the integrity check still aborts.
|
|
423
|
+
const after = trioState(f, plan.legacyDir);
|
|
424
|
+
const changed = Object.keys(before).find((k) => before[k] !== after[k]);
|
|
425
|
+
if (changed)
|
|
426
|
+
throw new Abort(`${path.join(plan.legacyDir, changed)} changed during the copy, so a worker wrote the legacy database`);
|
|
427
|
+
if (pidFileLive(path.join(plan.legacyDir, "worker.pid"), f))
|
|
428
|
+
throw new Abort(`${path.join(plan.legacyDir, "worker.pid")} appeared during the copy, so a worker started on the legacy directory`);
|
|
429
|
+
const lost = fresh.lost();
|
|
430
|
+
if (lost || !lock.held())
|
|
431
|
+
throw new Abort(`${lockPath} was not held for the whole copy${lost ? ` (refresh failed: ${lost})` : ""}, so a launcher may have started a worker`);
|
|
432
|
+
const at = new Date(ports.now()).toISOString();
|
|
433
|
+
f.writeFileSync(path.join(partial, MARKER_NAME), JSON.stringify({ source: plan.legacyDir, at, freeharness: ports.version, skipped: result.skipped }, null, 2) + "\n");
|
|
434
|
+
if (f.existsSync(plan.freeDir))
|
|
435
|
+
throw new Abort(`${plan.freeDir} appeared during the copy`);
|
|
436
|
+
f.renameSync(partial, plan.freeDir);
|
|
437
|
+
partial = null;
|
|
438
|
+
// Only after the commit: before it the engine still runs in the legacy observer cwd.
|
|
439
|
+
if (plan.observerMove)
|
|
440
|
+
result.observer = moveObserver(f, plan.observerMove);
|
|
441
|
+
return done("copied");
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
return done("aborted", e instanceof Error ? e.message : String(e));
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
// A sibling that cannot be removed matches PARTIAL_PATTERN, so the next pass 2 removes it.
|
|
448
|
+
if (partial)
|
|
449
|
+
await f.promises.rm(partial, { recursive: true, force: true }).catch(() => undefined);
|
|
450
|
+
fresh.stop();
|
|
451
|
+
lock.release();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function formatBytes(n) {
|
|
455
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
456
|
+
let i = 0;
|
|
457
|
+
let v = n;
|
|
458
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
459
|
+
v /= 1024;
|
|
460
|
+
i++;
|
|
461
|
+
}
|
|
462
|
+
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
|
463
|
+
}
|
|
464
|
+
/** The pass-1 lines for a plan: the copy with its size and free space, links, leftovers, refusals. */
|
|
465
|
+
export function describePlan(plan) {
|
|
466
|
+
if (plan.refusal)
|
|
467
|
+
return [`WARN ${plan.refusal}`];
|
|
468
|
+
const space = plan.freeBytes === null ? "unknown" : formatBytes(plan.freeBytes);
|
|
469
|
+
const lines = [
|
|
470
|
+
`Would copy the memory data from ${plan.legacyDir} to ${plan.freeDir} (${formatBytes(plan.bytes)}; free space ${space}) in pass 2: stop the worker, hold ${path.join(plan.legacyDir, "spawn.lock")}, copy without following links into a temporary sibling, rename ${LEGACY_DB_FILENAME}* to ${DB_FILENAME}*, rewrite ${OLD_PREFIX} settings keys and legacy path values, check the copy, write ${MARKER_NAME}; ${plan.legacyDir} is only read.`,
|
|
471
|
+
];
|
|
472
|
+
for (const l of plan.links)
|
|
473
|
+
lines.push(l.outside ? `WARN symbolic link ${l.path} -> ${l.target} leaves ${plan.legacyDir}; pass 2 aborts the copy` : `symbolic link ${l.path} -> ${l.target} is copied as a link`);
|
|
474
|
+
if (plan.observerMove)
|
|
475
|
+
lines.push(`Would move the observer transcripts from ${plan.observerMove.from} to ${plan.observerMove.to} after the copy, only when the source is a real directory and the target is absent; otherwise both are kept with a note.`);
|
|
476
|
+
for (const p of plan.partials)
|
|
477
|
+
lines.push(`leftover partial copy ${p}; pass 2 removes it before copying`);
|
|
478
|
+
const blocked = preflight(plan);
|
|
479
|
+
if (blocked && !plan.links.some((l) => l.outside))
|
|
480
|
+
lines.push(`WARN pass 2 aborts the copy: ${blocked}`);
|
|
481
|
+
return lines;
|
|
482
|
+
}
|
|
483
|
+
/** The pass-2 lines for an applied plan. */
|
|
484
|
+
export function describeResult(plan, r) {
|
|
485
|
+
if (r.status === "refused")
|
|
486
|
+
return [`WARN ${r.reason}`];
|
|
487
|
+
if (r.status === "aborted")
|
|
488
|
+
return [`WARN memory data copy aborted: ${r.reason}; no ${plan.freeDir} was created, so the worker keeps serving ${plan.legacyDir}`];
|
|
489
|
+
const lines = [`Copied the memory data from ${plan.legacyDir} to ${plan.freeDir} (${formatBytes(r.bytes)} in ${(r.durationMs / 1000).toFixed(1)} s); ${plan.legacyDir} is unchanged.`];
|
|
490
|
+
for (const w of r.warnings)
|
|
491
|
+
lines.push(`WARN ${w}`);
|
|
492
|
+
if (r.observer)
|
|
493
|
+
lines.push(r.observer);
|
|
494
|
+
if (r.restartOwed)
|
|
495
|
+
lines.push(`The stopped worker restarts on ${plan.freeDir} when \`freeharness update\` finishes staging the new plugin bundle; after \`freeharness init\`, the next Claude Code SessionStart starts it.`);
|
|
496
|
+
return lines;
|
|
497
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type MemoryPathSource = "FREE_MEM_DATA_DIR" | "CLAUDE_MEM_DATA_DIR" | "settings" | "default" | "fallback";
|
|
2
|
+
export interface MemoryPaths {
|
|
3
|
+
dataDir: string;
|
|
4
|
+
/** Absolute path of the database the engine opens in `dataDir`. */
|
|
5
|
+
dbFile: string;
|
|
6
|
+
/** True when `dataDir` is `<home>/.claude-mem`. */
|
|
7
|
+
legacy: boolean;
|
|
8
|
+
source: MemoryPathSource;
|
|
9
|
+
}
|
|
10
|
+
export declare const FREE_DIR_NAME = ".free-mem";
|
|
11
|
+
export declare const LEGACY_DIR_NAME = ".claude-mem";
|
|
12
|
+
export declare const DB_FILENAME = "free-mem.db";
|
|
13
|
+
export declare const LEGACY_DB_FILENAME = "claude-mem.db";
|
|
14
|
+
/** A settings file's keys as the engine reads them (BOM dropped, `env` first); null when unreadable. */
|
|
15
|
+
export declare function readMemorySettings(settingsPath: string): Record<string, unknown> | null;
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the memory data directory and database from `env` and `home` alone, never from
|
|
18
|
+
* process globals, so callers and tests can pass fake values.
|
|
19
|
+
* @param env Environment to read `FREE_MEM_DATA_DIR` and `CLAUDE_MEM_DATA_DIR` from.
|
|
20
|
+
* @param home Home directory that holds `.free-mem` and `.claude-mem` and expands `~`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveMemoryPaths(env: Record<string, string | undefined>, home: string): MemoryPaths;
|