freeharness 0.4.2 → 0.4.3
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/NOTICE +2 -1
- package/README.md +20 -16
- package/canonical/HARVEST.yaml +2 -2
- package/canonical/PROVENANCE.yaml +2 -2
- package/canonical/agents/security-reviewer.md +8 -2
- package/canonical/hooks/freeharness-config-guard.mjs +644 -4567
- package/canonical/hooks/freeharness-cost-tracker.mjs +14 -27
- package/canonical/hooks/freeharness-precompact.mjs +12 -23
- package/canonical/hooks/freeharness-sdd-context.mjs +12 -23
- package/canonical/hooks/hooks.config-guard.claude-code.json +2 -3
- package/canonical/hooks/hooks.memory-guard.claude-code.json +2 -3
- package/canonical/skills/code-review/SKILL.md +2 -2
- package/canonical/skills/code-review/evals/cases.yaml +1 -1
- package/canonical/skills/subagent-driven-development/SKILL.md +4 -4
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +149 -20
- package/canonical/skills/subagent-driven-development/reference/brief.md +15 -13
- package/canonical/skills/subagent-driven-development/reference/handoff.md +29 -15
- package/canonical/skills/subagent-driven-development/reference/loop.md +4 -5
- package/canonical/skills/subagent-driven-development/reference/workspace.md +2 -2
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +301 -1038
- package/dist/adapters/harness/claude-code.d.ts +7 -0
- package/dist/adapters/harness/claude-code.js +27 -21
- package/dist/adapters/harness/opencode.js +1 -1
- package/dist/adapters/harness/plugin.js +9 -9
- package/dist/adapters/memory/doctor.d.ts +11 -17
- package/dist/adapters/memory/doctor.js +55 -68
- package/dist/adapters/memory/paths.d.ts +3 -10
- package/dist/adapters/memory/paths.js +16 -54
- package/dist/adapters/memory/vendored.d.ts +2 -7
- package/dist/adapters/memory/vendored.js +12 -57
- package/dist/adapters/memory/worker-control.d.ts +9 -0
- package/dist/adapters/memory/worker-control.js +30 -0
- package/dist/cli/cleanup.d.ts +1 -1
- package/dist/cli/cleanup.js +29 -14
- package/dist/cli/cost.js +2 -2
- package/dist/cli/doctor.d.ts +9 -9
- package/dist/cli/doctor.js +21 -16
- package/dist/cli/hard-cleanup.js +1 -1
- package/dist/cli/install-facts.d.ts +4 -3
- package/dist/cli/install-facts.js +3 -1
- package/dist/cli/profile.d.ts +1 -1
- package/dist/cli/profile.js +10 -10
- package/dist/index.js +7 -5
- package/dist/infra/fsutil.d.ts +4 -5
- package/dist/infra/fsutil.js +5 -6
- package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
- package/node_modules/@freeharness/core/dist/registry.js +3 -3
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/package.json +3 -3
- package/provenance/canonical.PROVENANCE.yaml +2 -2
- package/provenance/memory.PROVENANCE.yaml +18 -4
- package/canonical/openspec/config.base.yaml +0 -89
- package/canonical/rules/safety.md +0 -41
|
@@ -38,6 +38,8 @@ export declare const QUOTA_TAP_SCRIPT = "freeharness-quota-tap.mjs";
|
|
|
38
38
|
export declare const HOOK_SCRIPTS: readonly string[];
|
|
39
39
|
export declare class ClaudeCodeAdapter implements HarnessPort {
|
|
40
40
|
readonly target: "claude-code";
|
|
41
|
+
private readonly autoMemoryMerged;
|
|
42
|
+
private turnOffAutoMemoryOnce;
|
|
41
43
|
apply(cap: Capability, ctx: InstallContext): Promise<AdapterResult>;
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
@@ -46,6 +48,11 @@ export declare class ClaudeCodeAdapter implements HarnessPort {
|
|
|
46
48
|
* test can hold both copies to the same answers.
|
|
47
49
|
*/
|
|
48
50
|
export declare function freeMemEnabled(claudeDir: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
|
|
53
|
+
* keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
|
|
54
|
+
*/
|
|
55
|
+
export declare function turnOffAutoMemory(settingsPath: string, dry: boolean, res: AdapterResult): void;
|
|
49
56
|
/**
|
|
50
57
|
* Reports and, only once confirmed, strips a trailing `[1m]` suffix from the scope's own
|
|
51
58
|
* `model` value. The emitted defaults never force or replace a model family: a value with no
|
|
@@ -83,6 +83,15 @@ export const HOOK_SCRIPTS = [
|
|
|
83
83
|
];
|
|
84
84
|
export class ClaudeCodeAdapter {
|
|
85
85
|
target = "claude-code";
|
|
86
|
+
// The runner keeps one adapter per pass, so a later capability case sees the key this pass
|
|
87
|
+
// already merged and would report it as the owner's value.
|
|
88
|
+
autoMemoryMerged = new Set();
|
|
89
|
+
turnOffAutoMemoryOnce(settingsPath, dry, res) {
|
|
90
|
+
if (this.autoMemoryMerged.has(settingsPath))
|
|
91
|
+
return;
|
|
92
|
+
this.autoMemoryMerged.add(settingsPath);
|
|
93
|
+
turnOffAutoMemory(settingsPath, dry, res);
|
|
94
|
+
}
|
|
86
95
|
async apply(cap, ctx) {
|
|
87
96
|
const res = { capabilityId: cap.id, target: this.target, written: [], actions: [], notes: [] };
|
|
88
97
|
const C = ctx.canonicalDir;
|
|
@@ -104,9 +113,6 @@ export class ClaudeCodeAdapter {
|
|
|
104
113
|
const hooksDir = user
|
|
105
114
|
? path.join(home, ".claude", "freeharness", "hooks")
|
|
106
115
|
: path.join(ctx.projectRoot, ".freeharness", "hooks");
|
|
107
|
-
const safetyFile = user
|
|
108
|
-
? path.join(home, ".claude", "freeharness", "safety.md")
|
|
109
|
-
: path.join(ctx.projectRoot, ".freeharness", "safety.md");
|
|
110
116
|
const ssScript = path.join(hooksDir, "freeharness-session-start");
|
|
111
117
|
const mgScript = path.join(hooksDir, "freeharness-memory-guard.mjs");
|
|
112
118
|
// user scope bakes an absolute path; project scope uses ${CLAUDE_PROJECT_DIR}.
|
|
@@ -125,7 +131,7 @@ export class ClaudeCodeAdapter {
|
|
|
125
131
|
: 'node "${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-cost-tracker.mjs"';
|
|
126
132
|
const hookCmd = (script, flags) => `${user ? `node "${path.join(hooksDir, script)}"` : `node "\${CLAUDE_PROJECT_DIR}/.freeharness/hooks/${script}"`}${flags ? ` ${flags}` : ""}`;
|
|
127
133
|
const mergeOwned = (event, entry, ownerKey) => ({ event, r: mergeClaudeHook(settingsPath, event, entry, dry, ownerKey) });
|
|
128
|
-
const mainNote = (script, cmd, merged, addedNote, presentNote,
|
|
134
|
+
const mainNote = (script, cmd, merged, addedNote, presentNote, guard) => {
|
|
129
135
|
const replaced = merged.flatMap((m) => (m.r.outcome === "replaced" ? [m.r] : []));
|
|
130
136
|
const hasAdded = merged.some((m) => m.r.outcome === "added");
|
|
131
137
|
if (replaced.length > 0) {
|
|
@@ -134,7 +140,7 @@ export class ClaudeCodeAdapter {
|
|
|
134
140
|
? `Updated ${script} hook (flags changed: ${stale} -> ${cmd}). Update resets FreeHarness hook flags to the shipped values.`
|
|
135
141
|
: replaced.some((r) => r.onFailureChanged)
|
|
136
142
|
? `Updated ${script} hook (onFailure reset to the shipped value).`
|
|
137
|
-
: `Updated ${script} hook (collapsed duplicate ${
|
|
143
|
+
: `Updated ${script} hook (collapsed duplicate ${guard ? "guard" : "hook"} entries).`;
|
|
138
144
|
if (hasAdded)
|
|
139
145
|
return [updatedNote, addedNote];
|
|
140
146
|
return [updatedNote];
|
|
@@ -143,20 +149,19 @@ export class ClaudeCodeAdapter {
|
|
|
143
149
|
return [addedNote];
|
|
144
150
|
return [presentNote];
|
|
145
151
|
};
|
|
146
|
-
// A shared entry is never rewritten, so
|
|
147
|
-
const hookNotes = (script, cmd, merged, addedNote, presentNote,
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
return [...mainNote(script, cmd, merged, addedNote, presentNote, failClosed), ...shared];
|
|
152
|
+
// A shared entry is never rewritten, so the owner removes that copy by hand.
|
|
153
|
+
const hookNotes = (script, cmd, merged, addedNote, presentNote, guard = false) => {
|
|
154
|
+
const shared = merged.filter((m) => m.r.hasSharedCopy).map((m) => `${script} also runs inside a hook entry shared with other commands in ${m.event}; remove it there by hand.`);
|
|
155
|
+
return [...mainNote(script, cmd, merged, addedNote, presentNote, guard), ...shared];
|
|
151
156
|
};
|
|
152
|
-
const emitHook = (script, flags, events, note,
|
|
157
|
+
const emitHook = (script, flags, events, note, guard = false) => {
|
|
153
158
|
res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
|
|
154
159
|
const cmd = hookCmd(script, flags);
|
|
155
160
|
const merged = events.map((e) => {
|
|
156
|
-
const hook = { type: "command", command: cmd, ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {})
|
|
161
|
+
const hook = { type: "command", command: cmd, ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}) };
|
|
157
162
|
return mergeOwned(e.event, { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] }, hookCmd(script, ""));
|
|
158
163
|
});
|
|
159
|
-
res.notes.push(...hookNotes(script, cmd, merged, `Added ${note}.`, `${script} hook already present.`,
|
|
164
|
+
res.notes.push(...hookNotes(script, cmd, merged, `Added ${note}.`, `${script} hook already present.`, guard));
|
|
160
165
|
};
|
|
161
166
|
switch (cap.id) {
|
|
162
167
|
case "rules": {
|
|
@@ -192,8 +197,8 @@ export class ClaudeCodeAdapter {
|
|
|
192
197
|
// Routing table beside the script the hook reads it from.
|
|
193
198
|
res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
|
|
194
199
|
res.written.push(writeText(mgScript, readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
|
|
195
|
-
// The guards
|
|
196
|
-
const mgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: mgCmd
|
|
200
|
+
// The guards sit under the permission layer, so a guard that cannot run allows the call.
|
|
201
|
+
const mgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: mgCmd }] };
|
|
197
202
|
const mg = mergeOwned("PreToolUse", mgEntry, mgCmd);
|
|
198
203
|
res.notes.push(...hookNotes("freeharness-memory-guard.mjs", mgCmd, [mg], "Added memory-guard hook (PreToolUse denies memory .md writes; saves tokens).", "Memory-guard hook already present.", true));
|
|
199
204
|
emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }], `config-guard hook (PreToolUse denies edits to live configuration by location: settings in a .claude directory, .mcp.json, the user .claude.json and CLAUDE.md, hook directories and the plugin tree, unless an allow marker from the last ${CONFIG_GUARD_TTL_MIN} minutes is present)`, true);
|
|
@@ -202,7 +207,7 @@ export class ClaudeCodeAdapter {
|
|
|
202
207
|
emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }], "precompact hook (PreCompact writes the checkpoint skeleton + file-op list for SessionStart and asks a running memory worker for a summary; worker down = skipped)");
|
|
203
208
|
emitHook("freeharness-reload-context.mjs", "", [{ event: "UserPromptSubmit" }], "reload-context hook (UserPromptSubmit answers the exact prompt `reload context` with the newest saved handoff or PreCompact skeleton; other prompts untouched)");
|
|
204
209
|
if (user && freeMemEnabled(claudeDir))
|
|
205
|
-
|
|
210
|
+
this.turnOffAutoMemoryOnce(settingsPath, dry, res);
|
|
206
211
|
break;
|
|
207
212
|
}
|
|
208
213
|
case "skills": {
|
|
@@ -233,7 +238,6 @@ export class ClaudeCodeAdapter {
|
|
|
233
238
|
}));
|
|
234
239
|
break;
|
|
235
240
|
case "safety": {
|
|
236
|
-
res.written.push(writeText(safetyFile, readText(path.join(C, "rules", "safety.md")), dry));
|
|
237
241
|
// Advisory PreToolUse hook: warns on vulnerability sinks in Edit/Write content,
|
|
238
242
|
// never blocks. Same additive merge as the memory guard.
|
|
239
243
|
res.written.push(writeText(sgScript, readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
|
|
@@ -315,7 +319,7 @@ export class ClaudeCodeAdapter {
|
|
|
315
319
|
merge(await mem.registerHooks(ctx.scope, ctx));
|
|
316
320
|
// Registering enables free-mem, so the key follows in the same run; pass 1 previews it.
|
|
317
321
|
if (user)
|
|
318
|
-
|
|
322
|
+
this.turnOffAutoMemoryOnce(settingsPath, dry, res);
|
|
319
323
|
res.notes.push("Worker start + recall are verified on the machine (needs the Claude Agent SDK key / network); not exercised by the installer.");
|
|
320
324
|
break;
|
|
321
325
|
}
|
|
@@ -327,7 +331,8 @@ export class ClaudeCodeAdapter {
|
|
|
327
331
|
continue;
|
|
328
332
|
res.actions.push({
|
|
329
333
|
description: `Register MCP server ${s.name}${s.optional ? " (optional)" : ""}`,
|
|
330
|
-
|
|
334
|
+
// `claude mcp add` exits 1 for a registered name; the get guard keeps a re-run quiet.
|
|
335
|
+
command: `claude mcp get ${s.name} >/dev/null 2>&1 || claude mcp add ${s.name} -s ${sc} -- ${s.command} ${s.args.join(" ")}`,
|
|
331
336
|
optional: true, // a failing/missing `claude` must not abort the whole install
|
|
332
337
|
});
|
|
333
338
|
}
|
|
@@ -384,13 +389,14 @@ export function freeMemEnabled(claudeDir) {
|
|
|
384
389
|
* free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
|
|
385
390
|
* keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
|
|
386
391
|
*/
|
|
387
|
-
function turnOffAutoMemory(settingsPath, dry, res) {
|
|
392
|
+
export function turnOffAutoMemory(settingsPath, dry, res) {
|
|
388
393
|
let merged;
|
|
389
394
|
try {
|
|
390
395
|
merged = mergeSettingsKeys(settingsPath, { autoMemoryEnabled: false }, dry);
|
|
391
396
|
}
|
|
392
397
|
catch (e) {
|
|
393
|
-
|
|
398
|
+
const reason = e instanceof SyntaxError ? "not parsed" : "not updated";
|
|
399
|
+
res.notes.push(`autoMemoryEnabled not set: ${settingsPath} ${reason} (${e instanceof Error ? e.message : String(e)}).`);
|
|
394
400
|
return;
|
|
395
401
|
}
|
|
396
402
|
if (merged.added.length > 0) {
|
|
@@ -108,7 +108,7 @@ export class OpenCodeAdapter {
|
|
|
108
108
|
break;
|
|
109
109
|
}
|
|
110
110
|
case "safety":
|
|
111
|
-
res.
|
|
111
|
+
res.notes.push("safety: OpenCode has no hook contract FreeHarness emits to; nothing emitted.");
|
|
112
112
|
break;
|
|
113
113
|
case "sdd": {
|
|
114
114
|
const src = path.join(C, "openspec");
|
|
@@ -17,7 +17,7 @@ export const MARKETPLACE_NAME = "freeharness-local";
|
|
|
17
17
|
/**
|
|
18
18
|
* Additively merges a hook entry into the plugin's hooks.json (dedupe by command; the file is
|
|
19
19
|
* created with the official `description` + `hooks` shape). Entries running only this command
|
|
20
|
-
* fold into the first one, rewritten to the current entry, so a re-emit
|
|
20
|
+
* fold into the first one, rewritten to the current entry, so a re-emit drops a stale `onFailure`
|
|
21
21
|
* field and a stale copy never keeps running beside a current one; an entry mixing it with other
|
|
22
22
|
* commands is left as is.
|
|
23
23
|
* @param file Absolute path of hooks/hooks.json.
|
|
@@ -68,10 +68,10 @@ export class PluginAdapter {
|
|
|
68
68
|
const hooksFile = path.join(hooksDir, "hooks.json");
|
|
69
69
|
const hookCmd = (script, runner = "node", flags = "") => `${runner} "\${CLAUDE_PLUGIN_ROOT}/hooks/${script}"${flags ? ` ${flags}` : ""}`;
|
|
70
70
|
/** Copies a canonical hook script and registers it under each event (dedupe by command). */
|
|
71
|
-
const emitHook = (script, flags, events
|
|
71
|
+
const emitHook = (script, flags, events) => {
|
|
72
72
|
res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
|
|
73
73
|
for (const e of events) {
|
|
74
|
-
const hook = { type: "command", command: hookCmd(script, "node", flags), ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {})
|
|
74
|
+
const hook = { type: "command", command: hookCmd(script, "node", flags), ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}) };
|
|
75
75
|
const entry = { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] };
|
|
76
76
|
mergePluginHook(hooksFile, e.event, entry, dry);
|
|
77
77
|
}
|
|
@@ -91,10 +91,10 @@ export class PluginAdapter {
|
|
|
91
91
|
// it from its own directory regardless of which target installed it.
|
|
92
92
|
res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
|
|
93
93
|
res.written.push(writeText(path.join(hooksDir, "freeharness-memory-guard.mjs"), readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
|
|
94
|
-
// The guards
|
|
95
|
-
const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs")
|
|
94
|
+
// The guards sit under the permission layer, so a guard that cannot run allows the call.
|
|
95
|
+
const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs") }] };
|
|
96
96
|
mergePluginHook(hooksFile, "PreToolUse", mg, dry);
|
|
97
|
-
emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }]
|
|
97
|
+
emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }]);
|
|
98
98
|
emitHook("freeharness-repeat-tool.mjs", REPEAT_TOOL_FLAGS, [{ event: "PreToolUse" }, { event: "UserPromptSubmit" }]);
|
|
99
99
|
emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }]);
|
|
100
100
|
emitHook("freeharness-reload-context.mjs", "", [{ event: "UserPromptSubmit" }]);
|
|
@@ -116,7 +116,7 @@ export class PluginAdapter {
|
|
|
116
116
|
}));
|
|
117
117
|
break;
|
|
118
118
|
case "safety": {
|
|
119
|
-
res.notes.push("safety:
|
|
119
|
+
res.notes.push("safety: the advisory security-guidance hook ships in the plugin.");
|
|
120
120
|
res.written.push(writeText(path.join(hooksDir, "freeharness-security-guidance.mjs"), readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
|
|
121
121
|
const sg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-security-guidance.mjs") }] };
|
|
122
122
|
mergePluginHook(hooksFile, "PreToolUse", sg, dry);
|
|
@@ -256,8 +256,8 @@ its agents, skills, and hooks.
|
|
|
256
256
|
|
|
257
257
|
## What is not in the plugin
|
|
258
258
|
|
|
259
|
-
- Behavior rules (\`CLAUDE.md\`)
|
|
260
|
-
\`freeharness init --scope user --only rules
|
|
259
|
+
- Behavior rules (\`CLAUDE.md\`): not a plugin component. Install them per user with
|
|
260
|
+
\`freeharness init --scope user --only rules\`. The security-guidance hook ships in the plugin.
|
|
261
261
|
- SDD (OpenSpec \`/opsx\`): per-project init, \`freeharness bootstrap\` inside the project.
|
|
262
262
|
- Memory (free-mem): its own plugin, \`freeharness init --scope user --only memory\`.
|
|
263
263
|
- Report-formatting runtime deps: after install run \`npm install\` in
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { type StageResult } from "./vendored.js";
|
|
3
3
|
import { type MemoryPaths } from "./paths.js";
|
|
4
|
-
import { type StopResult } from "./
|
|
4
|
+
import { type StopResult } from "./worker-control.js";
|
|
5
5
|
export interface CacheVersion {
|
|
6
6
|
version: string;
|
|
7
7
|
dir: string;
|
|
8
8
|
ours: boolean;
|
|
9
9
|
skills: string[];
|
|
10
|
-
/** Found under the upstream (thedotmack/claude-mem) cache root: a
|
|
11
|
-
* build
|
|
10
|
+
/** Found under the upstream (thedotmack/claude-mem) cache root: a pre-rename FreeHarness
|
|
11
|
+
* build or an upstream install, never our native path. */
|
|
12
12
|
legacy: boolean;
|
|
13
13
|
}
|
|
14
14
|
/** What the foreign responder said about itself, already filtered for printing. */
|
|
@@ -153,8 +153,7 @@ export declare const OUR_SCHEMA_VERSION = 49;
|
|
|
153
153
|
* FreeHarness migration is never reported as a newer upstream engine. A store that records
|
|
154
154
|
* some FreeHarness versions but not all of them has not finished migrating (a migration
|
|
155
155
|
* rolled back, or the worker predates the update), so the line warns and names each missing
|
|
156
|
-
* version; a store with none
|
|
157
|
-
* by a FreeHarness worker and keeps the upstream verdict.
|
|
156
|
+
* version; a store with none was never opened by a FreeHarness worker and keeps the upstream verdict.
|
|
158
157
|
*
|
|
159
158
|
* Returns:
|
|
160
159
|
* The line, or null when the db/sqlite3 is unavailable.
|
|
@@ -189,8 +188,7 @@ export interface SpendUnavailable {
|
|
|
189
188
|
*
|
|
190
189
|
* Args:
|
|
191
190
|
* dbPath: Path of claude-mem.db.
|
|
192
|
-
* spawn: Injectable spawnSync, so the missing-binary path
|
|
193
|
-
* regardless of whether sqlite3 happens to be on the test machine's PATH.
|
|
191
|
+
* spawn: Injectable spawnSync, so a test of the missing-binary path does not depend on sqlite3 being on PATH.
|
|
194
192
|
*
|
|
195
193
|
* Returns:
|
|
196
194
|
* One row per (window, model) with a row, or the reason spend could not be read.
|
|
@@ -494,21 +492,17 @@ export declare function configDir(): string;
|
|
|
494
492
|
/** The engine's data directory and database for this process. */
|
|
495
493
|
export declare function memoryPaths(): MemoryPaths;
|
|
496
494
|
export declare function dataDir(): string;
|
|
497
|
-
/**
|
|
498
|
-
* One WARN per `CLAUDE_MEM_*` variable in `env`, naming its `FREE_MEM_*` replacement, and one
|
|
499
|
-
* WARN for all `CLAUDE_MEM_*` keys in the active settings file: a seeded legacy file holds dozens,
|
|
500
|
-
* and a line per key would bury the rest of the report.
|
|
501
|
-
*/
|
|
495
|
+
/** One WARN per ignored `CLAUDE_MEM_*` variable in `env` and key in the settings file, naming its replacement. */
|
|
502
496
|
export declare function deprecatedNameLines(env: Record<string, string | undefined>, settingsPath: string): string[];
|
|
503
|
-
/**
|
|
504
|
-
*
|
|
505
|
-
|
|
506
|
-
*/
|
|
507
|
-
export declare function legacyWriteLines(home: string): string[];
|
|
497
|
+
/** A WARN while a legacy database sits beside a free-mem store with no observation: the worker creates
|
|
498
|
+
* an empty store at its first start, so existence cannot silence it; an unreadable store stays silent. */
|
|
499
|
+
export declare function legacyHomeLines(env: Record<string, string | undefined>, home: string, spawn?: SpawnSyncFn): string[];
|
|
508
500
|
/** Engine worker port: env, then settings.json, then 37700 + uid % 100. */
|
|
509
501
|
export declare function workerPort(dir: string): number;
|
|
510
502
|
/** One WARN while the upstream plugin is still registered: both workers bind the same port. */
|
|
511
503
|
export declare function upstreamLines(cfg: string, port: number): string[];
|
|
504
|
+
/** Steps that clear a reachable foreign worker, else null; no-pid fallback as in foreignWorkerLine. */
|
|
505
|
+
export declare function foreignWorkerStep(worker: WorkerProbe): string | null;
|
|
512
506
|
/**
|
|
513
507
|
* Diagnose which claude-mem is active.
|
|
514
508
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Memory doctor: tell which claude-mem is actually active on this machine (ours vs an
|
|
2
2
|
// upstream install), back up its data, and switch the machine to the FreeHarness build.
|
|
3
3
|
//
|
|
4
|
-
// Evidence used
|
|
4
|
+
// Evidence used:
|
|
5
5
|
// - plugin cache versions under <cfg>/plugins/cache/thedotmack/claude-mem/<ver>/ — ours
|
|
6
6
|
// carries the `.install-version` marker written by VendoredMemory and ships exactly
|
|
7
7
|
// one skill (mem-search); an upstream install ships many (cloud-sync, babysit, ...).
|
|
@@ -9,30 +9,26 @@
|
|
|
9
9
|
// - the running worker — /api/health reports its version and, on our build, the product
|
|
10
10
|
// marker "free-mem"; an unmarked answer is an older build of ours only when the free-mem
|
|
11
11
|
// PID file names its pid and port. GET / proves nothing: upstream also answers 404 there.
|
|
12
|
-
// - the data dir (resolveMemoryPaths in ./paths.ts): the database (a leftover
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// - the data dir (resolveMemoryPaths in ./paths.ts): the database (a leftover
|
|
13
|
+
// chroma/ is only sized and
|
|
14
|
+
// reported, never touched) and the engine's observer ledgers, which `memory status`
|
|
15
|
+
// renders as rejections / cooldowns.
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { spawn, spawnSync } from "node:child_process";
|
|
20
20
|
import { MARKETPLACE_OWNER, PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME, claudePluginPaths, isUpstreamRegistered, declaresPluginDeps, installPluginDeps, missingPluginBundles, missingPluginDeps, pluginVersion, probeBun, stagePluginCache, writeInstallMarker, } from "./vendored.js";
|
|
21
|
-
import {
|
|
22
|
-
import { pidFileLive } from "./
|
|
21
|
+
import { LEGACY_DIR_NAME, readMemorySettings, resolveMemoryPaths } from "./paths.js";
|
|
22
|
+
import { pidFileLive } from "./worker-control.js";
|
|
23
23
|
import { resolveCanonicalDir, sourceRepoRoot } from "../../infra/paths.js";
|
|
24
24
|
const OLD_PREFIX = "CLAUDE_MEM_";
|
|
25
25
|
const NEW_PREFIX = "FREE_MEM_";
|
|
26
26
|
function legacyName(key) {
|
|
27
27
|
return OLD_PREFIX + key.slice(NEW_PREFIX.length);
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
function setting(values, key) {
|
|
31
|
-
return values[key] ?? values[legacyName(key)];
|
|
32
|
-
}
|
|
33
|
-
/** Like `setting`, but an empty value counts as unset, as the engine's envGet reads the environment. */
|
|
29
|
+
/** FREE_MEM_ names only, as the engine reads them (CLAUDE_MEM_ twins are ignored); an empty value is unset. */
|
|
34
30
|
function envSetting(env, key) {
|
|
35
|
-
return env[key] ||
|
|
31
|
+
return env[key] || undefined;
|
|
36
32
|
}
|
|
37
33
|
/** The /api/health product marker of our worker; a test pins it to the engine's and the hooks' copies. */
|
|
38
34
|
export const FREE_MEM_PRODUCT = "free-mem";
|
|
@@ -218,8 +214,7 @@ function dbSchemaVersions(dbPath) {
|
|
|
218
214
|
* FreeHarness migration is never reported as a newer upstream engine. A store that records
|
|
219
215
|
* some FreeHarness versions but not all of them has not finished migrating (a migration
|
|
220
216
|
* rolled back, or the worker predates the update), so the line warns and names each missing
|
|
221
|
-
* version; a store with none
|
|
222
|
-
* by a FreeHarness worker and keeps the upstream verdict.
|
|
217
|
+
* version; a store with none was never opened by a FreeHarness worker and keeps the upstream verdict.
|
|
223
218
|
*
|
|
224
219
|
* Returns:
|
|
225
220
|
* The line, or null when the db/sqlite3 is unavailable.
|
|
@@ -256,8 +251,7 @@ const MISSING_CONSOLIDATION_TABLE = /no such table: (?:main\.)?consolidation_(?:
|
|
|
256
251
|
*
|
|
257
252
|
* Args:
|
|
258
253
|
* dbPath: Path of claude-mem.db.
|
|
259
|
-
* spawn: Injectable spawnSync, so the missing-binary path
|
|
260
|
-
* regardless of whether sqlite3 happens to be on the test machine's PATH.
|
|
254
|
+
* spawn: Injectable spawnSync, so a test of the missing-binary path does not depend on sqlite3 being on PATH.
|
|
261
255
|
*
|
|
262
256
|
* Returns:
|
|
263
257
|
* One row per (window, model) with a row, or the reason spend could not be read.
|
|
@@ -822,9 +816,9 @@ export function effectiveObserverModels(settingsPath) {
|
|
|
822
816
|
const defaults = engineDefaultObserverModels();
|
|
823
817
|
const file = readFlatSettings(settingsPath);
|
|
824
818
|
const str = (v, fallback) => (typeof v === "string" && v.length > 0 ? v : fallback);
|
|
825
|
-
const base = str(
|
|
826
|
-
const simple = str(
|
|
827
|
-
const summaryValue =
|
|
819
|
+
const base = str(file.FREE_MEM_MODEL, defaults.base);
|
|
820
|
+
const simple = str(file.FREE_MEM_TIER_SIMPLE_MODEL, defaults.simple);
|
|
821
|
+
const summaryValue = file.FREE_MEM_TIER_SUMMARY_MODEL;
|
|
828
822
|
const rawSummary = typeof summaryValue === "string" ? summaryValue : defaults.summary;
|
|
829
823
|
const summary = rawSummary.length > 0 ? rawSummary : base;
|
|
830
824
|
return { base, simple, summary };
|
|
@@ -846,9 +840,9 @@ const ENGINE_LOCAL_MODEL_DEFAULT = "local-model";
|
|
|
846
840
|
*/
|
|
847
841
|
export function effectiveObserverProvider(settingsPath) {
|
|
848
842
|
const values = readFlatSettings(settingsPath);
|
|
849
|
-
if (
|
|
843
|
+
if (values.FREE_MEM_PROVIDER !== "local")
|
|
850
844
|
return { provider: "claude", model: null };
|
|
851
|
-
const raw =
|
|
845
|
+
const raw = values.FREE_MEM_LOCAL_MODEL;
|
|
852
846
|
return { provider: "local", model: typeof raw === "string" && raw.length > 0 ? raw : ENGINE_LOCAL_MODEL_DEFAULT };
|
|
853
847
|
}
|
|
854
848
|
/**
|
|
@@ -991,7 +985,7 @@ export function previewObserverSettings(settingsPath, opts) {
|
|
|
991
985
|
}
|
|
992
986
|
}
|
|
993
987
|
const rows = Object.entries(keySet).map(([key, recommended]) => {
|
|
994
|
-
const raw =
|
|
988
|
+
const raw = values[key];
|
|
995
989
|
const current = raw === undefined ? undefined : typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
996
990
|
return { key, current, recommended, changed: current !== recommended };
|
|
997
991
|
});
|
|
@@ -1156,12 +1150,12 @@ export async function probeMemoryProvider(baseUrl, model, timeoutMs = 3000) {
|
|
|
1156
1150
|
*/
|
|
1157
1151
|
export async function memoryProviderLines(settingsPath, probe = probeMemoryProvider) {
|
|
1158
1152
|
const values = readFlatSettings(settingsPath);
|
|
1159
|
-
if (
|
|
1153
|
+
if (values.FREE_MEM_PROVIDER !== "local")
|
|
1160
1154
|
return [];
|
|
1161
1155
|
// The worker applies these same defaults for an absent key, so probing "" would report a
|
|
1162
1156
|
// URL-parse failure for a server the observer is in fact reaching.
|
|
1163
1157
|
const configured = (key, fallback) => {
|
|
1164
|
-
const v =
|
|
1158
|
+
const v = values[key];
|
|
1165
1159
|
return typeof v === "string" && v.trim() !== "" ? v : fallback;
|
|
1166
1160
|
};
|
|
1167
1161
|
const baseUrl = configured("FREE_MEM_LOCAL_BASE_URL", ENGINE_LOCAL_BASE_URL_DEFAULT);
|
|
@@ -1180,52 +1174,33 @@ export function memoryPaths() {
|
|
|
1180
1174
|
export function dataDir() {
|
|
1181
1175
|
return memoryPaths().dataDir;
|
|
1182
1176
|
}
|
|
1183
|
-
/**
|
|
1184
|
-
* One WARN per `CLAUDE_MEM_*` variable in `env`, naming its `FREE_MEM_*` replacement, and one
|
|
1185
|
-
* WARN for all `CLAUDE_MEM_*` keys in the active settings file: a seeded legacy file holds dozens,
|
|
1186
|
-
* and a line per key would bury the rest of the report.
|
|
1187
|
-
*/
|
|
1177
|
+
/** One WARN per ignored `CLAUDE_MEM_*` variable in `env` and key in the settings file, naming its replacement. */
|
|
1188
1178
|
export function deprecatedNameLines(env, settingsPath) {
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
.sort()
|
|
1192
|
-
.map((k) => `WARN deprecated environment variable ${k} is set; use FREE_MEM_${k.slice(OLD_PREFIX.length)}`);
|
|
1179
|
+
const replacement = (k) => NEW_PREFIX + k.slice(OLD_PREFIX.length);
|
|
1180
|
+
const vars = Object.keys(env).filter((k) => k.startsWith(OLD_PREFIX) && env[k] !== undefined).sort();
|
|
1193
1181
|
const keys = Object.keys(readMemorySettings(settingsPath) ?? {}).filter((k) => k.startsWith(OLD_PREFIX)).sort();
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1182
|
+
return [
|
|
1183
|
+
...vars.map((k) => `WARN environment variable ${k} is ignored; set ${replacement(k)} instead`),
|
|
1184
|
+
...keys.map((k) => `WARN settings key ${k} in ${settingsPath} is ignored; rename it to ${replacement(k)}`),
|
|
1185
|
+
];
|
|
1198
1186
|
}
|
|
1199
|
-
/**
|
|
1200
|
-
*
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1187
|
+
/** A WARN while a legacy database sits beside a free-mem store with no observation: the worker creates
|
|
1188
|
+
* an empty store at its first start, so existence cannot silence it; an unreadable store stays silent. */
|
|
1189
|
+
export function legacyHomeLines(env, home, spawn = spawnSync) {
|
|
1190
|
+
const legacyDir = path.join(home, LEGACY_DIR_NAME);
|
|
1191
|
+
if (env.FREE_MEM_DATA_DIR || fs.statSync(path.join(legacyDir, "claude-mem.db"), { throwIfNoEntry: false })?.isFile() !== true)
|
|
1192
|
+
return [];
|
|
1193
|
+
const { dataDir: freeDir, dbFile } = resolveMemoryPaths(env, home);
|
|
1194
|
+
const warn = [`WARN legacy memory directory ${legacyDir} is not read; free-mem uses ${freeDir}, which holds no observation yet: copy the data by hand to keep the old memories`];
|
|
1195
|
+
if (!fs.existsSync(dbFile))
|
|
1196
|
+
return warn;
|
|
1206
1197
|
try {
|
|
1207
|
-
|
|
1198
|
+
const r = spawn("sqlite3", ["-readonly", dbFile, "SELECT COUNT(*) FROM observations;"], { encoding: "utf8", timeout: 2000 });
|
|
1199
|
+
return !r.error && r.status === 0 && String(r.stdout).trim() === "0" ? warn : [];
|
|
1208
1200
|
}
|
|
1209
1201
|
catch {
|
|
1210
1202
|
return [];
|
|
1211
1203
|
}
|
|
1212
|
-
const at = typeof marker.at === "string" ? Date.parse(marker.at) : NaN;
|
|
1213
|
-
if (Number.isNaN(at))
|
|
1214
|
-
return [];
|
|
1215
|
-
const source = typeof marker.source === "string" && marker.source ? marker.source : path.join(home, LEGACY_DIR_NAME);
|
|
1216
|
-
const db = path.join(source, LEGACY_DB_FILENAME);
|
|
1217
|
-
let changed = 0;
|
|
1218
|
-
for (const file of [db, `${db}-wal`]) {
|
|
1219
|
-
try {
|
|
1220
|
-
changed = Math.max(changed, fs.statSync(file).mtimeMs);
|
|
1221
|
-
}
|
|
1222
|
-
catch {
|
|
1223
|
-
/* absent */
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
if (changed <= at)
|
|
1227
|
-
return [];
|
|
1228
|
-
return [`WARN legacy database ${db} changed at ${new Date(changed).toISOString()}, after the migration at ${new Date(at).toISOString()}; a build still on the old name may be writing there, and those memories are not in ${freeDir}`];
|
|
1229
1204
|
}
|
|
1230
1205
|
/** Leading decimal integer, as the engine's parseInt reads it, when in 1..65535. */
|
|
1231
1206
|
function validPort(value) {
|
|
@@ -1240,7 +1215,7 @@ export function workerPort(dir) {
|
|
|
1240
1215
|
try {
|
|
1241
1216
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf8"));
|
|
1242
1217
|
const s = raw.env ?? raw;
|
|
1243
|
-
const p = validPort(
|
|
1218
|
+
const p = validPort(s.FREE_MEM_WORKER_PORT);
|
|
1244
1219
|
if (p !== null)
|
|
1245
1220
|
return p;
|
|
1246
1221
|
}
|
|
@@ -1323,6 +1298,14 @@ function foreignWorkerLine(port, facts) {
|
|
|
1323
1298
|
}
|
|
1324
1299
|
return `FAIL worker on :${port} is another program (${where}, version ${version}); memory is off — stop the program listening on port ${port} and ${cleanup}`;
|
|
1325
1300
|
}
|
|
1301
|
+
/** Steps that clear a reachable foreign worker, else null; no-pid fallback as in foreignWorkerLine. */
|
|
1302
|
+
export function foreignWorkerStep(worker) {
|
|
1303
|
+
if (!worker.reachable || worker.identity !== "foreign")
|
|
1304
|
+
return null;
|
|
1305
|
+
const pid = worker.foreign?.pid;
|
|
1306
|
+
const stop = pid !== undefined ? `kill ${pid}` : `stop the program listening on port ${worker.port}`;
|
|
1307
|
+
return `${stop}, then freeharness cleanup --scope user`;
|
|
1308
|
+
}
|
|
1326
1309
|
async function probeWorker(port, dir) {
|
|
1327
1310
|
let res;
|
|
1328
1311
|
try {
|
|
@@ -1401,7 +1384,7 @@ function cacheTag(c) {
|
|
|
1401
1384
|
return "FreeHarness build";
|
|
1402
1385
|
}
|
|
1403
1386
|
const TREE_BYTES_MAX_ENTRIES = 100_000;
|
|
1404
|
-
/**
|
|
1387
|
+
/** A size for the report only: symlinks are not followed and the walk is capped, so a huge tree yields a lower bound. */
|
|
1405
1388
|
function treeBytes(dir) {
|
|
1406
1389
|
let bytes = 0;
|
|
1407
1390
|
let seen = 0;
|
|
@@ -1505,7 +1488,7 @@ export async function diagnoseMemory() {
|
|
|
1505
1488
|
if (provider.provider === "local") {
|
|
1506
1489
|
lines.push("WARN observer FREE_MEM_PROVIDER=local runs beside the claude observer; run `freeharness memory settings`");
|
|
1507
1490
|
}
|
|
1508
|
-
const skipRaw =
|
|
1491
|
+
const skipRaw = readFlatSettings(path.join(data, "settings.json")).FREE_MEM_SKIP_TOOLS;
|
|
1509
1492
|
if (typeof skipRaw === "string") {
|
|
1510
1493
|
const skipped = skipRaw.split(",").map((t) => t.trim());
|
|
1511
1494
|
const missing = LOCAL_PROVIDER_SERVED_TOOLS.filter((t) => !skipped.includes(t));
|
|
@@ -1529,7 +1512,7 @@ export async function diagnoseMemory() {
|
|
|
1529
1512
|
lines.push(`INFO memory data dir ${data}: chroma/ (${mb} MB) is no longer used by free-mem and is kept; move it aside by hand to reclaim the space`);
|
|
1530
1513
|
}
|
|
1531
1514
|
lines.push(...deprecatedNameLines(process.env, path.join(data, "settings.json")));
|
|
1532
|
-
lines.push(...
|
|
1515
|
+
lines.push(...legacyHomeLines(process.env, os.homedir()));
|
|
1533
1516
|
const schemaLine = schemaVersionLine(dbPath, dbName);
|
|
1534
1517
|
if (schemaLine !== null)
|
|
1535
1518
|
lines.push(schemaLine);
|
|
@@ -1544,11 +1527,15 @@ export async function diagnoseMemory() {
|
|
|
1544
1527
|
else
|
|
1545
1528
|
verdict = "mixed";
|
|
1546
1529
|
}
|
|
1530
|
+
// `memory activate` cannot clear a foreign worker beside our build; the upstream verdict keeps the backup-and-activate advice.
|
|
1531
|
+
const foreignStep = verdict === "mixed" ? foreignWorkerStep(worker) : null;
|
|
1547
1532
|
lines.push(verdict === "ours"
|
|
1548
1533
|
? "OK VERDICT: the FreeHarness build is the only active memory plugin"
|
|
1549
1534
|
: verdict === "none"
|
|
1550
1535
|
? "WARN VERDICT: memory plugin not installed"
|
|
1551
|
-
:
|
|
1536
|
+
: foreignStep !== null
|
|
1537
|
+
? `WARN VERDICT: MIXED — a foreign worker holds the memory port (see the FAIL line: ${foreignStep})`
|
|
1538
|
+
: `WARN VERDICT: ${verdict === "upstream" ? "the UPSTREAM install is active" : "MIXED — upstream pieces still active beside ours"} (run: freeharness memory backup && freeharness memory activate)`);
|
|
1552
1539
|
return { configDir: cfg, dataDir: data, dbPath, dbBytes, chromaPresent, cache, activeInstallPath, activeVersion, activeIsOurs, worker, observer, verdict, lines };
|
|
1553
1540
|
}
|
|
1554
1541
|
/**
|
|
@@ -1,22 +1,15 @@
|
|
|
1
|
-
export type MemoryPathSource = "FREE_MEM_DATA_DIR" | "
|
|
1
|
+
export type MemoryPathSource = "FREE_MEM_DATA_DIR" | "settings" | "default";
|
|
2
2
|
export interface MemoryPaths {
|
|
3
3
|
dataDir: string;
|
|
4
4
|
/** Absolute path of the database the engine opens in `dataDir`. */
|
|
5
5
|
dbFile: string;
|
|
6
|
-
/** True when `dataDir` is `<home>/.claude-mem`. */
|
|
7
|
-
legacy: boolean;
|
|
8
6
|
source: MemoryPathSource;
|
|
9
7
|
}
|
|
10
8
|
export declare const FREE_DIR_NAME = ".free-mem";
|
|
9
|
+
/** The pre-rename data directory: never read, only kept protected and named by doctor. */
|
|
11
10
|
export declare const LEGACY_DIR_NAME = ".claude-mem";
|
|
12
11
|
export declare const DB_FILENAME = "free-mem.db";
|
|
13
|
-
export declare const LEGACY_DB_FILENAME = "claude-mem.db";
|
|
14
12
|
/** A settings file's keys as the engine reads them (BOM dropped, `env` first); null when unreadable. */
|
|
15
13
|
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
|
-
*/
|
|
14
|
+
/** The data directory and database from `env` and `home` alone, never process globals, so tests pass fakes. */
|
|
22
15
|
export declare function resolveMemoryPaths(env: Record<string, string | undefined>, home: string): MemoryPaths;
|