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,118 @@
|
|
|
1
|
+
// The one path that edits another program's JSON config. The original bytes are archived and
|
|
2
|
+
// verified before the file changes, and the new content replaces it by rename, so every failure
|
|
3
|
+
// leaves the original whole and every success leaves a copy to restore from.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { freeDestination } from "./hard-cleanup.js";
|
|
7
|
+
function isPlainObject(v) {
|
|
8
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
function own(obj, key) {
|
|
11
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
12
|
+
}
|
|
13
|
+
function codeOf(e) {
|
|
14
|
+
return e?.code ?? (e instanceof Error ? e.message : String(e));
|
|
15
|
+
}
|
|
16
|
+
/** Reads the file behind `file`; a symlink is followed so the link itself survives the edit. */
|
|
17
|
+
function readReal(file) {
|
|
18
|
+
try {
|
|
19
|
+
const real = fs.realpathSync.native(file);
|
|
20
|
+
// Checked before the open: opening a FIFO for reading would block.
|
|
21
|
+
if (!fs.statSync(real).isFile())
|
|
22
|
+
return { reason: "not-regular" };
|
|
23
|
+
const fd = fs.openSync(real, "r");
|
|
24
|
+
try {
|
|
25
|
+
const st = fs.fstatSync(fd);
|
|
26
|
+
if (!st.isFile())
|
|
27
|
+
return { reason: "not-regular" };
|
|
28
|
+
return { real, bytes: fs.readFileSync(fd), mode: st.mode & 0o777 };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
fs.closeSync(fd);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return { reason: codeOf(e) === "ENOENT" ? "missing" : `read-failed: ${codeOf(e)}` };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Archives the original bytes under a fresh name and proves the copy, before anything is edited. */
|
|
39
|
+
function archiveCopy(bytes, mode, copyDest) {
|
|
40
|
+
try {
|
|
41
|
+
const copy = freeDestination(copyDest);
|
|
42
|
+
fs.mkdirSync(path.dirname(copy), { recursive: true });
|
|
43
|
+
fs.writeFileSync(copy, bytes, { flag: "wx" });
|
|
44
|
+
fs.chmodSync(copy, mode); // the `mode` option is masked by the umask
|
|
45
|
+
if (!fs.readFileSync(copy).equals(bytes))
|
|
46
|
+
return { reason: "copy-failed: EDIFFERS" };
|
|
47
|
+
return { copy };
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
return { reason: `copy-failed: ${codeOf(e)}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Replaces `real` by rename; `wx` refuses a temp name another writer holds, so only our own temp file is ever removed. */
|
|
54
|
+
function replaceByRename(real, text, mode, rename) {
|
|
55
|
+
const tmp = path.join(path.dirname(real), `.${path.basename(real)}.${process.pid}.tmp`);
|
|
56
|
+
let created = false;
|
|
57
|
+
try {
|
|
58
|
+
const fd = fs.openSync(tmp, "wx", mode);
|
|
59
|
+
created = true;
|
|
60
|
+
try {
|
|
61
|
+
fs.writeFileSync(fd, text);
|
|
62
|
+
fs.fsyncSync(fd);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
fs.closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
fs.chmodSync(tmp, mode);
|
|
68
|
+
rename(tmp, real);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (created) {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(tmp, { force: true });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// The write failure is the one to report.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return `write-failed: ${codeOf(e)}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Removes `keyPath` from the JSON object in `file` after a verified copy of the unedited bytes
|
|
85
|
+
* lands at the first free name of `copyDest`. Every refusal leaves `file` byte-equal.
|
|
86
|
+
*/
|
|
87
|
+
export function removeJsonKey(file, keyPath, copyDest, options = {}) {
|
|
88
|
+
const read = readReal(file);
|
|
89
|
+
if ("reason" in read)
|
|
90
|
+
return { ok: false, reason: read.reason };
|
|
91
|
+
let root;
|
|
92
|
+
try {
|
|
93
|
+
root = JSON.parse(read.bytes.toString("utf8"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { ok: false, reason: "not-json" };
|
|
97
|
+
}
|
|
98
|
+
if (!isPlainObject(root))
|
|
99
|
+
return { ok: false, reason: "not-json" };
|
|
100
|
+
let parent = root;
|
|
101
|
+
for (const segment of keyPath.slice(0, -1)) {
|
|
102
|
+
const next = own(parent, segment) ? parent[segment] : undefined;
|
|
103
|
+
if (!isPlainObject(next))
|
|
104
|
+
return { ok: false, reason: "key-gone" };
|
|
105
|
+
parent = next;
|
|
106
|
+
}
|
|
107
|
+
const last = keyPath[keyPath.length - 1];
|
|
108
|
+
if (last === undefined || !own(parent, last))
|
|
109
|
+
return { ok: false, reason: "key-gone" };
|
|
110
|
+
if (options.stillMatches && !options.stillMatches(parent[last]))
|
|
111
|
+
return { ok: false, reason: "changed" };
|
|
112
|
+
const archived = archiveCopy(read.bytes, read.mode, copyDest);
|
|
113
|
+
if ("reason" in archived)
|
|
114
|
+
return { ok: false, reason: archived.reason };
|
|
115
|
+
delete parent[last];
|
|
116
|
+
const failed = replaceByRename(read.real, JSON.stringify(root, null, 2) + "\n", read.mode, options.rename ?? ((from, to) => fs.renameSync(from, to)));
|
|
117
|
+
return failed ? { ok: false, reason: failed } : { ok: true, copy: archived.copy };
|
|
118
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** The worker's per-file text cap; a bigger file would be refused, so it is never sent. */
|
|
2
|
+
export declare const MAX_FILE_BYTES = 262144;
|
|
3
|
+
export declare const MAX_BATCH_FILES = 200;
|
|
4
|
+
/** Below the worker's 5 MB JSON parser limit, which answers 413 before validation. */
|
|
5
|
+
export declare const MAX_BATCH_BYTES: number;
|
|
6
|
+
export type MemoryKind = "auto-memory" | "agent-memory";
|
|
7
|
+
export interface ImportFile {
|
|
8
|
+
sourcePath: string;
|
|
9
|
+
kind: MemoryKind;
|
|
10
|
+
title: string;
|
|
11
|
+
text: string;
|
|
12
|
+
sha256: string;
|
|
13
|
+
mtimeEpochMs: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ImportBatch {
|
|
16
|
+
project: string;
|
|
17
|
+
files: ImportFile[];
|
|
18
|
+
}
|
|
19
|
+
export interface CollectedFile extends ImportFile {
|
|
20
|
+
project: string;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
export interface SkippedFile {
|
|
24
|
+
sourcePath: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Collection {
|
|
28
|
+
files: CollectedFile[];
|
|
29
|
+
skipped: SkippedFile[];
|
|
30
|
+
}
|
|
31
|
+
export interface CollectOptions {
|
|
32
|
+
home: string;
|
|
33
|
+
configDir: string;
|
|
34
|
+
projects: string[];
|
|
35
|
+
}
|
|
36
|
+
export interface ImportOptions {
|
|
37
|
+
home?: string;
|
|
38
|
+
configDir?: string;
|
|
39
|
+
projects: string[];
|
|
40
|
+
port?: number;
|
|
41
|
+
now: Date;
|
|
42
|
+
dryRun: boolean;
|
|
43
|
+
confirm: () => Promise<boolean>;
|
|
44
|
+
log: (line: string) => void;
|
|
45
|
+
error: (line: string) => void;
|
|
46
|
+
/** Lets a test stand in a cross-device rename. */
|
|
47
|
+
renameFile?: (from: string, to: string) => void;
|
|
48
|
+
}
|
|
49
|
+
export interface ImportOutcome {
|
|
50
|
+
exitCode: number;
|
|
51
|
+
stored: number;
|
|
52
|
+
skipped: number;
|
|
53
|
+
archived: string[];
|
|
54
|
+
failed: SkippedFile[];
|
|
55
|
+
}
|
|
56
|
+
/** Same name the engine derives for a cwd, so imported rows join the project's live ones. */
|
|
57
|
+
export declare function engineProjectName(dir: string): string;
|
|
58
|
+
/** Target project of an auto-memory directory name such as `-Users-u-Proj`. */
|
|
59
|
+
export declare function autoMemoryProject(dirName: string, root?: string): string;
|
|
60
|
+
/** First `# ` heading, else the file name, capped at the worker's title length. */
|
|
61
|
+
export declare function titleOf(text: string, fileName: string): string;
|
|
62
|
+
/** The user scope is walked first, so a project that is the home itself adds no second copy. */
|
|
63
|
+
export declare function collectMemoryFiles(opts: CollectOptions): Collection;
|
|
64
|
+
/** Splits one project's files by count and by serialized size, so no body reaches the parser limit. */
|
|
65
|
+
export declare function batchFiles(project: string, files: CollectedFile[]): ImportBatch[];
|
|
66
|
+
/** HOME-relative inside the home; anything else keeps its absolute path under `abs/`. */
|
|
67
|
+
export declare function archiveTarget(home: string, archiveRoot: string, sourcePath: string): string;
|
|
68
|
+
/** An earlier archived copy is never overwritten. */
|
|
69
|
+
export declare function uniqueTarget(target: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Previews, then posts and archives. File text is untrusted model output: only paths, sizes
|
|
72
|
+
* and counts are ever printed, and nothing moves until the worker has answered for the file.
|
|
73
|
+
*/
|
|
74
|
+
export declare function importMemoryFiles(opts: ImportOptions): Promise<ImportOutcome>;
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// One-time move of Claude Code's model-written memory files into free-mem.
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { configDir as defaultConfigDir, dataDir, workerPort } from "../adapters/memory/doctor.js";
|
|
8
|
+
import { archiveRootFor, printable } from "./hard-cleanup.js";
|
|
9
|
+
/** The worker's per-file text cap; a bigger file would be refused, so it is never sent. */
|
|
10
|
+
export const MAX_FILE_BYTES = 262_144;
|
|
11
|
+
export const MAX_BATCH_FILES = 200;
|
|
12
|
+
/** Below the worker's 5 MB JSON parser limit, which answers 413 before validation. */
|
|
13
|
+
export const MAX_BATCH_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const MAX_TITLE = 200;
|
|
15
|
+
function sha256(data) {
|
|
16
|
+
return crypto.createHash("sha256").update(data).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
function lexists(p) {
|
|
19
|
+
try {
|
|
20
|
+
fs.lstatSync(p);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isDir(p) {
|
|
28
|
+
try {
|
|
29
|
+
return fs.statSync(p).isDirectory();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function errText(e) {
|
|
36
|
+
return e instanceof Error ? e.message : String(e);
|
|
37
|
+
}
|
|
38
|
+
/** Same name the engine derives for a cwd, so imported rows join the project's live ones. */
|
|
39
|
+
export function engineProjectName(dir) {
|
|
40
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
41
|
+
const root = r.status === 0 ? r.stdout.trim() : "";
|
|
42
|
+
return path.basename(root || dir) || "unknown-project";
|
|
43
|
+
}
|
|
44
|
+
/** Claude Code turns every non-alphanumeric character of the cwd into a dash; older names only the separators. */
|
|
45
|
+
function dirNameEncodings(p) {
|
|
46
|
+
return new Set([p.replace(/[\\/]/g, "-"), p.replace(/[^A-Za-z0-9]/g, "-")]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The encoding is lossy (a dash may stand for a separator or for a character of a name), so the
|
|
50
|
+
* cwd is recovered by walking the disk along the name rather than by string rewriting.
|
|
51
|
+
*/
|
|
52
|
+
function decodeProjectDir(name, root) {
|
|
53
|
+
let best = null;
|
|
54
|
+
const visit = (dir) => {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir).sort();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const child = path.join(dir, entry);
|
|
64
|
+
for (const enc of dirNameEncodings(child)) {
|
|
65
|
+
if (enc === name) {
|
|
66
|
+
if (isDir(child) && (best === null || child.length > best.length))
|
|
67
|
+
best = child;
|
|
68
|
+
}
|
|
69
|
+
else if (name.startsWith(`${enc}-`) && isDir(child)) {
|
|
70
|
+
visit(child);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
visit(root);
|
|
76
|
+
return best;
|
|
77
|
+
}
|
|
78
|
+
/** Target project of an auto-memory directory name such as `-Users-u-Proj`. */
|
|
79
|
+
export function autoMemoryProject(dirName, root = path.parse(os.homedir()).root) {
|
|
80
|
+
const cwd = decodeProjectDir(dirName, root);
|
|
81
|
+
return cwd ? engineProjectName(cwd) : dirName.replace(/^-/, "");
|
|
82
|
+
}
|
|
83
|
+
/** First `# ` heading, else the file name, capped at the worker's title length. */
|
|
84
|
+
export function titleOf(text, fileName) {
|
|
85
|
+
const heading = /^# +(\S.*?)\s*$/m.exec(text)?.[1];
|
|
86
|
+
let title = heading ?? path.basename(fileName).replace(/\.md$/i, "");
|
|
87
|
+
if (title.length > MAX_TITLE) {
|
|
88
|
+
title = title.slice(0, MAX_TITLE);
|
|
89
|
+
if (/[\uD800-\uDBFF]$/.test(title))
|
|
90
|
+
title = title.slice(0, -1);
|
|
91
|
+
}
|
|
92
|
+
return title;
|
|
93
|
+
}
|
|
94
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
95
|
+
/**
|
|
96
|
+
* Walks one memory root without following any link: a linked file or directory could point
|
|
97
|
+
* anywhere, and moving its target into the archive is not this command's call.
|
|
98
|
+
*/
|
|
99
|
+
function walkMemoryDir(root, kind, project, seen, out) {
|
|
100
|
+
let st;
|
|
101
|
+
try {
|
|
102
|
+
st = fs.lstatSync(root);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (st.isSymbolicLink()) {
|
|
108
|
+
out.skipped.push({ sourcePath: root, reason: "symlink, not followed" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!st.isDirectory())
|
|
112
|
+
return;
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
out.skipped.push({ sourcePath: root, reason: `unreadable directory (${errText(e)})` });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const e of entries) {
|
|
122
|
+
const p = path.join(root, e.name);
|
|
123
|
+
if (e.isSymbolicLink()) {
|
|
124
|
+
out.skipped.push({ sourcePath: p, reason: "symlink, not followed" });
|
|
125
|
+
}
|
|
126
|
+
else if (e.isDirectory()) {
|
|
127
|
+
walkMemoryDir(p, kind, project, seen, out);
|
|
128
|
+
}
|
|
129
|
+
else if (e.isFile() && /\.md$/i.test(e.name) && !seen.has(p)) {
|
|
130
|
+
seen.add(p);
|
|
131
|
+
readMemoryFile(p, kind, project, out);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function readMemoryFile(p, kind, project, out) {
|
|
136
|
+
let st;
|
|
137
|
+
let bytes;
|
|
138
|
+
try {
|
|
139
|
+
st = fs.lstatSync(p);
|
|
140
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
141
|
+
out.skipped.push({ sourcePath: p, reason: `${st.size} bytes, over the ${MAX_FILE_BYTES}-byte limit` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
bytes = fs.readFileSync(p);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
out.skipped.push({ sourcePath: p, reason: `unreadable (${errText(e)})` });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let text;
|
|
151
|
+
try {
|
|
152
|
+
text = UTF8.decode(bytes);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
out.skipped.push({ sourcePath: p, reason: "not valid UTF-8" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
out.files.push({
|
|
159
|
+
sourcePath: p,
|
|
160
|
+
kind,
|
|
161
|
+
project,
|
|
162
|
+
size: bytes.length,
|
|
163
|
+
title: titleOf(text, p),
|
|
164
|
+
text,
|
|
165
|
+
sha256: sha256(text),
|
|
166
|
+
mtimeEpochMs: Math.floor(st.mtimeMs),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const AGENT_DIRS = ["agent-memory", "agent-memory-local"];
|
|
170
|
+
/**
|
|
171
|
+
* A cloned repository controls its own `.claude`, so a link there could aim the walk, and the
|
|
172
|
+
* archive move, at any directory of the machine.
|
|
173
|
+
*/
|
|
174
|
+
function projectClaudeDir(proj, out) {
|
|
175
|
+
const dir = path.join(proj, ".claude");
|
|
176
|
+
let st;
|
|
177
|
+
try {
|
|
178
|
+
st = fs.lstatSync(dir);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
if (st.isSymbolicLink()) {
|
|
184
|
+
out.skipped.push({ sourcePath: dir, reason: "symlink, not followed" });
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (!st.isDirectory())
|
|
188
|
+
return undefined;
|
|
189
|
+
try {
|
|
190
|
+
const rel = path.relative(fs.realpathSync(proj), fs.realpathSync(dir));
|
|
191
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
192
|
+
out.skipped.push({ sourcePath: dir, reason: "resolves outside the project" });
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
out.skipped.push({ sourcePath: dir, reason: `unresolvable (${errText(e)})` });
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return dir;
|
|
201
|
+
}
|
|
202
|
+
/** The user scope is walked first, so a project that is the home itself adds no second copy. */
|
|
203
|
+
export function collectMemoryFiles(opts) {
|
|
204
|
+
const out = { files: [], skipped: [] };
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const projectsDir = path.join(opts.configDir, "projects");
|
|
207
|
+
let autoDirs = [];
|
|
208
|
+
try {
|
|
209
|
+
autoDirs = fs.readdirSync(projectsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* no auto memory */
|
|
213
|
+
}
|
|
214
|
+
for (const d of autoDirs) {
|
|
215
|
+
const dir = path.join(projectsDir, d.name);
|
|
216
|
+
if (d.isSymbolicLink()) {
|
|
217
|
+
out.skipped.push({ sourcePath: dir, reason: "symlink, not followed" });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!d.isDirectory() || !lexists(path.join(dir, "memory")))
|
|
221
|
+
continue;
|
|
222
|
+
walkMemoryDir(path.join(dir, "memory"), "auto-memory", autoMemoryProject(d.name), seen, out);
|
|
223
|
+
}
|
|
224
|
+
for (const sub of AGENT_DIRS)
|
|
225
|
+
walkMemoryDir(path.join(opts.configDir, sub), "agent-memory", "user", seen, out);
|
|
226
|
+
for (const proj of opts.projects) {
|
|
227
|
+
const claude = projectClaudeDir(proj, out);
|
|
228
|
+
if (claude === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
const name = engineProjectName(proj);
|
|
231
|
+
for (const sub of AGENT_DIRS)
|
|
232
|
+
walkMemoryDir(path.join(claude, sub), "agent-memory", name, seen, out);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
function wireFile(f) {
|
|
237
|
+
return { sourcePath: f.sourcePath, kind: f.kind, title: f.title, text: f.text, sha256: f.sha256, mtimeEpochMs: f.mtimeEpochMs };
|
|
238
|
+
}
|
|
239
|
+
/** Splits one project's files by count and by serialized size, so no body reaches the parser limit. */
|
|
240
|
+
export function batchFiles(project, files) {
|
|
241
|
+
const empty = Buffer.byteLength(JSON.stringify({ project, files: [] }), "utf8");
|
|
242
|
+
const batches = [];
|
|
243
|
+
let current = [];
|
|
244
|
+
let bytes = empty;
|
|
245
|
+
for (const f of files) {
|
|
246
|
+
const wire = wireFile(f);
|
|
247
|
+
const size = Buffer.byteLength(JSON.stringify(wire), "utf8") + 1;
|
|
248
|
+
if (current.length > 0 && (current.length >= MAX_BATCH_FILES || bytes + size > MAX_BATCH_BYTES)) {
|
|
249
|
+
batches.push({ project, files: current });
|
|
250
|
+
current = [];
|
|
251
|
+
bytes = empty;
|
|
252
|
+
}
|
|
253
|
+
current.push(wire);
|
|
254
|
+
bytes += size;
|
|
255
|
+
}
|
|
256
|
+
if (current.length > 0)
|
|
257
|
+
batches.push({ project, files: current });
|
|
258
|
+
return batches;
|
|
259
|
+
}
|
|
260
|
+
/** HOME-relative inside the home; anything else keeps its absolute path under `abs/`. */
|
|
261
|
+
export function archiveTarget(home, archiveRoot, sourcePath) {
|
|
262
|
+
const rel = path.relative(home, sourcePath);
|
|
263
|
+
const inside = rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
264
|
+
const base = path.join(archiveRoot, "memory-import");
|
|
265
|
+
return inside ? path.join(base, rel) : path.join(base, "abs", sourcePath.replace(/^[\\/]+/, ""));
|
|
266
|
+
}
|
|
267
|
+
/** An earlier archived copy is never overwritten. */
|
|
268
|
+
export function uniqueTarget(target) {
|
|
269
|
+
if (!lexists(target))
|
|
270
|
+
return target;
|
|
271
|
+
const ext = path.extname(target);
|
|
272
|
+
const stem = target.slice(0, target.length - ext.length);
|
|
273
|
+
for (let n = 1;; n++) {
|
|
274
|
+
const candidate = `${stem}-${n}${ext}`;
|
|
275
|
+
if (!lexists(candidate))
|
|
276
|
+
return candidate;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The bytes the worker stored are the bytes that must land in the archive, so a source edited
|
|
281
|
+
* since collection stays; a cross-device move is a verified copy before the source goes.
|
|
282
|
+
*/
|
|
283
|
+
function moveToArchive(file, dest, rename) {
|
|
284
|
+
let expected;
|
|
285
|
+
try {
|
|
286
|
+
const st = fs.lstatSync(file.sourcePath);
|
|
287
|
+
if (!st.isFile())
|
|
288
|
+
return "no longer a regular file";
|
|
289
|
+
expected = sha256(fs.readFileSync(file.sourcePath));
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
return `unreadable before the move (${errText(e)})`;
|
|
293
|
+
}
|
|
294
|
+
if (expected !== sha256(Buffer.from(file.text, "utf8")))
|
|
295
|
+
return "changed since it was read";
|
|
296
|
+
let target;
|
|
297
|
+
try {
|
|
298
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
|
299
|
+
target = uniqueTarget(dest);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
return `archive directory failed (${errText(e)})`;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
rename(file.sourcePath, target);
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
if (e.code !== "EXDEV")
|
|
310
|
+
return `move failed (${errText(e)})`;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
fs.copyFileSync(file.sourcePath, target, fs.constants.COPYFILE_EXCL);
|
|
314
|
+
const fd = fs.openSync(target, "r");
|
|
315
|
+
try {
|
|
316
|
+
fs.fsyncSync(fd);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
fs.closeSync(fd);
|
|
320
|
+
}
|
|
321
|
+
if (sha256(fs.readFileSync(target)) !== expected) {
|
|
322
|
+
fs.rmSync(target, { force: true });
|
|
323
|
+
return "archive copy did not verify";
|
|
324
|
+
}
|
|
325
|
+
fs.unlinkSync(file.sourcePath);
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
return `copy into the archive failed (${errText(e)})`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function parseAnswer(body) {
|
|
333
|
+
const b = body;
|
|
334
|
+
if (!b || typeof b.stored !== "number" || typeof b.skipped !== "number" || !Array.isArray(b.errors))
|
|
335
|
+
return null;
|
|
336
|
+
const errors = b.errors.filter((e) => !!e && typeof e.sourcePath === "string");
|
|
337
|
+
return { stored: b.stored, skipped: b.skipped, errors: errors.map((e) => ({ sourcePath: e.sourcePath, reason: String(e.reason ?? "") })) };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Whatever listens on the port may not be the worker: unless its counts account for every file
|
|
341
|
+
* of the batch, and each error names a distinct file of it, nothing proves a file was stored.
|
|
342
|
+
*/
|
|
343
|
+
function answerAccountsFor(answer, batch) {
|
|
344
|
+
const count = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
345
|
+
if (!count(answer.stored) || !count(answer.skipped))
|
|
346
|
+
return false;
|
|
347
|
+
const paths = new Set(batch.files.map((f) => f.sourcePath));
|
|
348
|
+
const named = new Set(answer.errors.map((e) => e.sourcePath).filter((p) => paths.has(p)));
|
|
349
|
+
return named.size === answer.errors.length && answer.stored + answer.skipped + answer.errors.length === batch.files.length;
|
|
350
|
+
}
|
|
351
|
+
async function workerHealthy(port) {
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) });
|
|
354
|
+
return res.ok ? undefined : `health answered HTTP ${res.status}`;
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
return errText(e);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function postBatch(port, batch) {
|
|
361
|
+
try {
|
|
362
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/import/observations`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "content-type": "application/json" },
|
|
365
|
+
body: JSON.stringify(batch),
|
|
366
|
+
signal: AbortSignal.timeout(120_000),
|
|
367
|
+
});
|
|
368
|
+
if (!res.ok)
|
|
369
|
+
return `worker answered HTTP ${res.status}`;
|
|
370
|
+
return parseAnswer(await res.json().catch(() => null)) ?? "worker answer is not an import result";
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
return `request failed (${errText(e)})`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function groupByProject(files) {
|
|
377
|
+
const groups = new Map();
|
|
378
|
+
for (const f of files)
|
|
379
|
+
groups.set(f.project, [...(groups.get(f.project) ?? []), f]);
|
|
380
|
+
return new Map([...groups.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Previews, then posts and archives. File text is untrusted model output: only paths, sizes
|
|
384
|
+
* and counts are ever printed, and nothing moves until the worker has answered for the file.
|
|
385
|
+
*/
|
|
386
|
+
export async function importMemoryFiles(opts) {
|
|
387
|
+
const home = opts.home ?? os.homedir();
|
|
388
|
+
const outcome = { exitCode: 0, stored: 0, skipped: 0, archived: [], failed: [] };
|
|
389
|
+
for (const p of opts.projects) {
|
|
390
|
+
if (!isDir(p)) {
|
|
391
|
+
opts.error(`FAIL --project ${printable(p)} is not a directory`);
|
|
392
|
+
return { ...outcome, exitCode: 1 };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const collection = collectMemoryFiles({ home, configDir: opts.configDir ?? defaultConfigDir(), projects: opts.projects });
|
|
396
|
+
const groups = groupByProject(collection.files);
|
|
397
|
+
opts.log(`${opts.dryRun ? "[dry-run] " : ""}memory import plan:`);
|
|
398
|
+
for (const [project, files] of groups) {
|
|
399
|
+
for (const f of files)
|
|
400
|
+
opts.log(` ${printable(f.sourcePath)} ${f.size} bytes ${f.kind} -> ${printable(project)}`);
|
|
401
|
+
}
|
|
402
|
+
for (const s of collection.skipped)
|
|
403
|
+
opts.log(` SKIP ${printable(s.sourcePath)}: ${s.reason}`);
|
|
404
|
+
opts.log(`${collection.files.length} file(s) to import into ${groups.size} project(s); ${collection.skipped.length} skipped`);
|
|
405
|
+
if (collection.files.length === 0) {
|
|
406
|
+
opts.log("no memory files to import");
|
|
407
|
+
return outcome;
|
|
408
|
+
}
|
|
409
|
+
if (opts.dryRun)
|
|
410
|
+
return outcome;
|
|
411
|
+
if (!(await opts.confirm())) {
|
|
412
|
+
opts.log("Aborted; nothing changed.");
|
|
413
|
+
return outcome;
|
|
414
|
+
}
|
|
415
|
+
const port = opts.port ?? workerPort(dataDir());
|
|
416
|
+
const down = await workerHealthy(port);
|
|
417
|
+
if (down !== undefined) {
|
|
418
|
+
opts.error(`FAIL free-mem worker not reachable on 127.0.0.1:${port} (${printable(down)}); nothing posted, nothing moved. Open a Claude Code session or run freeharness memory restart, then run this again.`);
|
|
419
|
+
return { ...outcome, exitCode: 1 };
|
|
420
|
+
}
|
|
421
|
+
const archiveRoot = archiveRootFor(home, opts.now);
|
|
422
|
+
const rename = opts.renameFile ?? fs.renameSync;
|
|
423
|
+
for (const [project, files] of groups) {
|
|
424
|
+
let stored = 0;
|
|
425
|
+
let skipped = 0;
|
|
426
|
+
let archived = 0;
|
|
427
|
+
for (const [i, batch] of batchFiles(project, files).entries()) {
|
|
428
|
+
const answer = await postBatch(port, batch);
|
|
429
|
+
const inBatch = files.filter((f) => batch.files.some((w) => w.sourcePath === f.sourcePath));
|
|
430
|
+
if (typeof answer === "string") {
|
|
431
|
+
for (const f of inBatch)
|
|
432
|
+
outcome.failed.push({ sourcePath: f.sourcePath, reason: answer });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!answerAccountsFor(answer, batch)) {
|
|
436
|
+
const counts = `${answer.stored}/${answer.skipped}/${answer.errors.length}`;
|
|
437
|
+
opts.error(`FAIL ${printable(project)} batch ${i + 1}: counts ${printable(counts)} differ from ${batch.files.length}; left in place`);
|
|
438
|
+
for (const f of inBatch)
|
|
439
|
+
outcome.failed.push({ sourcePath: f.sourcePath, reason: `worker counts do not cover batch ${i + 1}` });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
stored += answer.stored;
|
|
443
|
+
skipped += answer.skipped;
|
|
444
|
+
const rejected = new Map(answer.errors.map((e) => [e.sourcePath, e.reason]));
|
|
445
|
+
for (const f of inBatch) {
|
|
446
|
+
const reason = rejected.get(f.sourcePath);
|
|
447
|
+
if (reason !== undefined) {
|
|
448
|
+
outcome.failed.push({ sourcePath: f.sourcePath, reason: `worker error: ${reason.slice(0, 200)}` });
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const moveError = moveToArchive(f, archiveTarget(home, archiveRoot, f.sourcePath), rename);
|
|
452
|
+
if (moveError)
|
|
453
|
+
outcome.failed.push({ sourcePath: f.sourcePath, reason: moveError });
|
|
454
|
+
else {
|
|
455
|
+
archived++;
|
|
456
|
+
outcome.archived.push(f.sourcePath);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
outcome.stored += stored;
|
|
461
|
+
outcome.skipped += skipped;
|
|
462
|
+
opts.log(`OK ${printable(project)}: stored ${stored}, skipped ${skipped}, archived ${archived} -> ${printable(path.join(archiveRoot, "memory-import"))}`);
|
|
463
|
+
}
|
|
464
|
+
for (const f of outcome.failed)
|
|
465
|
+
opts.error(`FAIL ${printable(f.sourcePath)}: ${printable(f.reason)} (left in place)`);
|
|
466
|
+
if (outcome.failed.length > 0)
|
|
467
|
+
outcome.exitCode = 1;
|
|
468
|
+
return outcome;
|
|
469
|
+
}
|
|
@@ -17,6 +17,18 @@ export interface PluginValidation {
|
|
|
17
17
|
* `scripts/simulate-session.mjs` checks every emitted event against the same list.
|
|
18
18
|
*/
|
|
19
19
|
export declare const HOOK_EVENTS: Set<string>;
|
|
20
|
+
/** Why a `${CLAUDE_PLUGIN_ROOT}` use is unsafe; the warning text names it. */
|
|
21
|
+
export type PluginRootHazard = "unquoted" | "escaped";
|
|
22
|
+
/**
|
|
23
|
+
* The first unsafe `${CLAUDE_PLUGIN_ROOT}` use in a shell-form command: `unquoted` outside a
|
|
24
|
+
* double-quoted span (bare, the shell splits it on a plugin path with spaces; single-quoted, the
|
|
25
|
+
* shell never expands it), `escaped` behind a backslash-escaped dollar in or out of double quotes
|
|
26
|
+
* (the shell never expands it). Tracks quote state left to right, honoring backslash escapes
|
|
27
|
+
* outside single quotes (so `\"` opens no span); no shell grammar beyond that.
|
|
28
|
+
* @param command Shell command string.
|
|
29
|
+
* @returns The hazard of the first unsafe occurrence, or undefined when every use is safe.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pluginRootHazard(command: string): PluginRootHazard | undefined;
|
|
20
32
|
/**
|
|
21
33
|
* Validates a plugin directory (and, when present, the marketplace manifest in its parent).
|
|
22
34
|
* @param dir The plugin directory (holds `.claude-plugin/plugin.json`).
|