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
|
@@ -20,6 +20,44 @@ export const HOOK_EVENTS = new Set([
|
|
|
20
20
|
/** Plugin names are kebab-case (marketplace and `claude plugin install <name>@<market>`). */
|
|
21
21
|
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
22
22
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
|
23
|
+
const PLUGIN_ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}";
|
|
24
|
+
/**
|
|
25
|
+
* The first unsafe `${CLAUDE_PLUGIN_ROOT}` use in a shell-form command: `unquoted` outside a
|
|
26
|
+
* double-quoted span (bare, the shell splits it on a plugin path with spaces; single-quoted, the
|
|
27
|
+
* shell never expands it), `escaped` behind a backslash-escaped dollar in or out of double quotes
|
|
28
|
+
* (the shell never expands it). Tracks quote state left to right, honoring backslash escapes
|
|
29
|
+
* outside single quotes (so `\"` opens no span); no shell grammar beyond that.
|
|
30
|
+
* @param command Shell command string.
|
|
31
|
+
* @returns The hazard of the first unsafe occurrence, or undefined when every use is safe.
|
|
32
|
+
*/
|
|
33
|
+
export function pluginRootHazard(command) {
|
|
34
|
+
let inDouble = false;
|
|
35
|
+
let inSingle = false;
|
|
36
|
+
for (let i = 0; i < command.length; i++) {
|
|
37
|
+
const c = command[i];
|
|
38
|
+
if (!inSingle && c === "\\") {
|
|
39
|
+
if (command.startsWith(PLUGIN_ROOT_TOKEN, i + 1))
|
|
40
|
+
return "escaped";
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!inSingle && c === '"') {
|
|
45
|
+
inDouble = !inDouble;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!inDouble && c === "'") {
|
|
49
|
+
inSingle = !inSingle;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!inDouble && command.startsWith(PLUGIN_ROOT_TOKEN, i))
|
|
53
|
+
return "unquoted";
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const PLUGIN_ROOT_HAZARD_TEXT = {
|
|
58
|
+
unquoted: "outside double quotes",
|
|
59
|
+
escaped: "behind an escaped dollar",
|
|
60
|
+
};
|
|
23
61
|
/**
|
|
24
62
|
* Reads and parses a JSON file; a parse failure becomes one error.
|
|
25
63
|
* @param file Path.
|
|
@@ -170,6 +208,8 @@ export function validatePluginDir(dir, opts = {}) {
|
|
|
170
208
|
errors.push(`hooks/hooks.json: ${event}[${i}].hooks[${j}].command missing`);
|
|
171
209
|
if (hook.timeout !== undefined && typeof hook.timeout !== "number")
|
|
172
210
|
errors.push(`hooks/hooks.json: ${event}[${i}].hooks[${j}].timeout must be a number`);
|
|
211
|
+
if (hook.onFailure !== undefined && hook.onFailure !== "block" && hook.onFailure !== "continue")
|
|
212
|
+
errors.push(`hooks/hooks.json: ${event}[${i}].hooks[${j}].onFailure must be block|continue`);
|
|
173
213
|
if (typeof hook.command === "string") {
|
|
174
214
|
for (const m of hook.command.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"']+)/g)) {
|
|
175
215
|
const target = path.join(root, m[1]);
|
|
@@ -178,6 +218,9 @@ export function validatePluginDir(dir, opts = {}) {
|
|
|
178
218
|
}
|
|
179
219
|
if (!hook.command.includes("${CLAUDE_PLUGIN_ROOT}") && /(^|\s)\/\S+/.test(hook.command))
|
|
180
220
|
warnings.push(`hooks/hooks.json: ${event}[${i}].hooks[${j}] uses an absolute path instead of \${CLAUDE_PLUGIN_ROOT}`);
|
|
221
|
+
const hazard = hook.type === "command" ? pluginRootHazard(hook.command) : undefined;
|
|
222
|
+
if (hazard)
|
|
223
|
+
warnings.push(`hooks/hooks.json: ${event}[${i}].hooks[${j}] uses \${CLAUDE_PLUGIN_ROOT} ${PLUGIN_ROOT_HAZARD_TEXT[hazard]}`);
|
|
181
224
|
}
|
|
182
225
|
});
|
|
183
226
|
});
|
package/dist/cli/uninstall.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Scope } from "@freeharness/core";
|
|
2
2
|
/** Command substrings that identify the hook entries FreeHarness writes into settings.json. */
|
|
3
|
-
export declare const OUR_HOOK_MARKERS: readonly ["freeharness-session-start", "freeharness-memory-guard", "freeharness-config-guard", "freeharness-repeat-tool", "freeharness-security-guidance", "freeharness-cost-tracker", "freeharness-output-pruner", "freeharness-context-monitor", "freeharness-precompact", "freeharness-auto-memory", "freeharness-converge"];
|
|
3
|
+
export declare const OUR_HOOK_MARKERS: readonly ["freeharness-session-start", "freeharness-memory-guard", "freeharness-config-guard", "freeharness-repeat-tool", "freeharness-security-guidance", "freeharness-cost-tracker", "freeharness-output-pruner", "freeharness-context-monitor", "freeharness-precompact", "freeharness-auto-memory", "freeharness-converge", "freeharness-sdd-context", "freeharness-reload-context"];
|
|
4
4
|
/** What an uninstall pass would (or did) do. */
|
|
5
5
|
export interface UninstallPlan {
|
|
6
6
|
manifest: string;
|
package/dist/cli/uninstall.js
CHANGED
|
@@ -8,7 +8,7 @@ import { manifestPath, readManifest } from "../infra/manifest.js";
|
|
|
8
8
|
import { dataDir } from "../adapters/memory/doctor.js";
|
|
9
9
|
import { PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
|
|
10
10
|
/** Command substrings that identify the hook entries FreeHarness writes into settings.json. */
|
|
11
|
-
export const OUR_HOOK_MARKERS = ["freeharness-session-start", "freeharness-memory-guard", "freeharness-config-guard", "freeharness-repeat-tool", "freeharness-security-guidance", "freeharness-cost-tracker", "freeharness-output-pruner", "freeharness-context-monitor", "freeharness-precompact", "freeharness-auto-memory", "freeharness-converge"];
|
|
11
|
+
export const OUR_HOOK_MARKERS = ["freeharness-session-start", "freeharness-memory-guard", "freeharness-config-guard", "freeharness-repeat-tool", "freeharness-security-guidance", "freeharness-cost-tracker", "freeharness-output-pruner", "freeharness-context-monitor", "freeharness-precompact", "freeharness-auto-memory", "freeharness-converge", "freeharness-sdd-context", "freeharness-reload-context"];
|
|
12
12
|
/**
|
|
13
13
|
* Tells whether a hook entry is one FreeHarness wrote.
|
|
14
14
|
* @param e A settings.json hook entry.
|
|
@@ -107,7 +107,7 @@ export function uninstallScope(scope, projectRoot, dryRun) {
|
|
|
107
107
|
continue;
|
|
108
108
|
}
|
|
109
109
|
if (p.startsWith(pluginsDir) || (p + path.sep).startsWith(memData)) {
|
|
110
|
-
plan.kept.push({ path: p, reason: `
|
|
110
|
+
plan.kept.push({ path: p, reason: `memory plugin/data: left alone (use \`claude plugin uninstall ${PLUGIN_KEY}\`)` });
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
113
|
if (!fs.existsSync(p)) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CleanupItem } from "./cleanup.js";
|
|
2
|
+
/** Client configs under HOME that may launch the upstream MCP server, with the key holding their servers. */
|
|
3
|
+
export declare const UPSTREAM_LAUNCHER_FILES: ReadonlyArray<{
|
|
4
|
+
rel: string;
|
|
5
|
+
key: "mcpServers" | "mcp";
|
|
6
|
+
}>;
|
|
7
|
+
/** The upstream marketplace clone under the Claude Code config dir; Claude Code re-installs the upstream plugin from it. */
|
|
8
|
+
export declare const UPSTREAM_MARKETPLACE_DIR: string;
|
|
9
|
+
/** Registrations of the upstream marketplace, relative to the Claude Code config dir. */
|
|
10
|
+
export declare const UPSTREAM_MARKETPLACE_KEYS: ReadonlyArray<{
|
|
11
|
+
rel: string;
|
|
12
|
+
keyPath: string[];
|
|
13
|
+
}>;
|
|
14
|
+
/** A free-mem path wins over every upstream sign, so our own MCP server is never edited away. */
|
|
15
|
+
export declare function isUpstreamMcpEntry(name: string, entry: unknown): boolean;
|
|
16
|
+
/** Server names come from files other programs write and reach the terminal through the plan lines. */
|
|
17
|
+
export declare function launcherText(s: string): string;
|
|
18
|
+
/** The copy path names the file it restores; a file outside HOME has none, so it is refused rather than edited without a copy. */
|
|
19
|
+
export declare function configCopyArchive(file: string, home: string, archiveRoot: string): {
|
|
20
|
+
destination: string;
|
|
21
|
+
refused?: string;
|
|
22
|
+
};
|
|
23
|
+
export interface LauncherScope {
|
|
24
|
+
home: string;
|
|
25
|
+
/** The Claude Code config dir. */
|
|
26
|
+
cfg: string;
|
|
27
|
+
archiveRoot: string;
|
|
28
|
+
/** Set while the upstream plugin is enabled: removing its marketplace then breaks a live install. */
|
|
29
|
+
marketplaceRefusal?: string;
|
|
30
|
+
}
|
|
31
|
+
/** The launcher memory items of the fixed list; reads only. */
|
|
32
|
+
export declare function planUpstreamLaunchers(scope: LauncherScope): {
|
|
33
|
+
items: CleanupItem[];
|
|
34
|
+
notes: string[];
|
|
35
|
+
};
|
|
36
|
+
/** Why a planned marketplace move must not run now; the destination stays in its own inbox so it never lands over other archive content. */
|
|
37
|
+
export declare function marketplaceRefusal(source: string, dest: string, archiveRoot: string | undefined): string | undefined;
|
|
38
|
+
/** A launcher line in a file cleanup never writes; `text` is the raw line, kept off the terminal. */
|
|
39
|
+
export interface ReportOnlyMatch {
|
|
40
|
+
file: string;
|
|
41
|
+
line: number;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
/** No TOML parser may be added and an rc edit can break the owner's shell, so these are only named, never edited. */
|
|
45
|
+
export declare const REPORT_ONLY_FILES: readonly string[];
|
|
46
|
+
/** Upstream launchers in the Codex config and the shell rc files under `home`; reads only and never throws. */
|
|
47
|
+
export declare function scanReportOnly(home: string): ReportOnlyMatch[];
|
|
48
|
+
/** Pass 2 re-check per launcher item, so a value rewritten since pass 1 is left as the other program wrote it. */
|
|
49
|
+
export declare function launcherStillMatches(item: CleanupItem): ((value: unknown) => boolean) | undefined;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Another MCP client restarting the upstream worker on free-mem's port survives every reboot, so
|
|
2
|
+
// cleanup must reach those launchers; it reaches them only through this fixed file list and one
|
|
3
|
+
// fixed entry rule, since discovery could edit a config nobody asked it to touch.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
|
|
7
|
+
import { freeDestination } from "./hard-cleanup.js";
|
|
8
|
+
/** Client configs under HOME that may launch the upstream MCP server, with the key holding their servers. */
|
|
9
|
+
export const UPSTREAM_LAUNCHER_FILES = [
|
|
10
|
+
{ rel: path.join(".gemini", "config", "mcp_config.json"), key: "mcpServers" },
|
|
11
|
+
{ rel: path.join(".gemini", "antigravity", "mcp_config.json"), key: "mcpServers" },
|
|
12
|
+
{ rel: path.join(".gemini", "antigravity-ide", "mcp_config.json"), key: "mcpServers" },
|
|
13
|
+
{ rel: path.join(".gemini", "antigravity-backup", "mcp_config.json"), key: "mcpServers" },
|
|
14
|
+
{ rel: path.join(".gemini", "settings.json"), key: "mcpServers" },
|
|
15
|
+
{ rel: path.join("Library", "Application Support", "Claude", "claude_desktop_config.json"), key: "mcpServers" },
|
|
16
|
+
{ rel: path.join(".cursor", "mcp.json"), key: "mcpServers" },
|
|
17
|
+
{ rel: path.join(".config", "opencode", "opencode.json"), key: "mcp" },
|
|
18
|
+
];
|
|
19
|
+
/** The upstream marketplace clone under the Claude Code config dir; Claude Code re-installs the upstream plugin from it. */
|
|
20
|
+
export const UPSTREAM_MARKETPLACE_DIR = path.join("plugins", "marketplaces", UPSTREAM_MARKETPLACE_OWNER);
|
|
21
|
+
/** Registrations of the upstream marketplace, relative to the Claude Code config dir. */
|
|
22
|
+
export const UPSTREAM_MARKETPLACE_KEYS = [
|
|
23
|
+
{ rel: path.join("plugins", "known_marketplaces.json"), keyPath: [UPSTREAM_MARKETPLACE_OWNER] },
|
|
24
|
+
{ rel: "settings.json", keyPath: ["extraKnownMarketplaces", UPSTREAM_MARKETPLACE_OWNER] },
|
|
25
|
+
];
|
|
26
|
+
const OURS = ["plugins/cache/freeharness", "plugins/marketplaces/freeharness"];
|
|
27
|
+
const PACKAGE_TOKEN = new RegExp(`(^|[\\s/@])${UPSTREAM_PLUGIN_NAME}($|[\\s/@])`);
|
|
28
|
+
const ANSI_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
29
|
+
const UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]|[\ud800-\udfff]/gu;
|
|
30
|
+
const NAME_MAX = 64;
|
|
31
|
+
function isPlainObject(v) {
|
|
32
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
33
|
+
}
|
|
34
|
+
function strings(v) {
|
|
35
|
+
if (typeof v === "string")
|
|
36
|
+
return [v];
|
|
37
|
+
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
|
|
38
|
+
}
|
|
39
|
+
/** A free-mem path wins over every upstream sign, so our own MCP server is never edited away. */
|
|
40
|
+
export function isUpstreamMcpEntry(name, entry) {
|
|
41
|
+
if (!isPlainObject(entry))
|
|
42
|
+
return false;
|
|
43
|
+
const args = Array.isArray(entry.args) ? strings(entry.args) : [];
|
|
44
|
+
const text = [...strings(entry.command), ...args].join(" ").replace(/\\/g, "/");
|
|
45
|
+
if (OURS.some((p) => text.includes(p)))
|
|
46
|
+
return false;
|
|
47
|
+
return name === UPSTREAM_PLUGIN_NAME || text.includes(UPSTREAM_MARKETPLACE_OWNER) || text.includes(`${UPSTREAM_PLUGIN_NAME}@`) || PACKAGE_TOKEN.test(text);
|
|
48
|
+
}
|
|
49
|
+
/** Server names come from files other programs write and reach the terminal through the plan lines. */
|
|
50
|
+
export function launcherText(s) {
|
|
51
|
+
return Array.from(s.replace(ANSI_SEQUENCE, "").replace(UNPRINTABLE, "")).slice(0, NAME_MAX).join("");
|
|
52
|
+
}
|
|
53
|
+
/** The copy path names the file it restores; a file outside HOME has none, so it is refused rather than edited without a copy. */
|
|
54
|
+
export function configCopyArchive(file, home, archiveRoot) {
|
|
55
|
+
const inbox = path.join(archiveRoot, "configs");
|
|
56
|
+
const rel = path.relative(home, file);
|
|
57
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
58
|
+
return { destination: inbox, refused: `is outside the home dir, so its pre-edit copy has no place under ${inbox}` };
|
|
59
|
+
}
|
|
60
|
+
return { destination: path.join(inbox, rel) };
|
|
61
|
+
}
|
|
62
|
+
/** Only a regular file (or a link to one) is read; non-blocking, so a FIFO swapped in after the check cannot hang the plan. */
|
|
63
|
+
function readJsonObject(file) {
|
|
64
|
+
let fd;
|
|
65
|
+
try {
|
|
66
|
+
if (!fs.statSync(file).isFile())
|
|
67
|
+
return { state: "absent" };
|
|
68
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
69
|
+
if (!fs.fstatSync(fd).isFile())
|
|
70
|
+
return { state: "absent" };
|
|
71
|
+
const root = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
72
|
+
return isPlainObject(root) ? { state: "object", root } : { state: "invalid", why: "not a JSON object" };
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const code = e.code;
|
|
76
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
77
|
+
return { state: "absent" };
|
|
78
|
+
return { state: "invalid", why: e instanceof SyntaxError ? "not valid JSON" : `unreadable (${code ?? "error"})` };
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (fd !== undefined)
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function hasKeyPath(root, keyPath) {
|
|
86
|
+
let node = root;
|
|
87
|
+
for (const key of keyPath) {
|
|
88
|
+
if (!isPlainObject(node) || !Object.prototype.hasOwnProperty.call(node, key))
|
|
89
|
+
return false;
|
|
90
|
+
node = node[key];
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
function isRealDir(p) {
|
|
95
|
+
try {
|
|
96
|
+
return fs.lstatSync(p).isDirectory();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The launcher memory items of the fixed list; reads only. */
|
|
103
|
+
export function planUpstreamLaunchers(scope) {
|
|
104
|
+
const items = [];
|
|
105
|
+
const notes = [];
|
|
106
|
+
const configs = path.join(scope.archiveRoot, "configs");
|
|
107
|
+
for (const { rel, key } of UPSTREAM_LAUNCHER_FILES) {
|
|
108
|
+
const file = path.join(scope.home, rel);
|
|
109
|
+
const read = readJsonObject(file);
|
|
110
|
+
if (read.state === "absent")
|
|
111
|
+
continue;
|
|
112
|
+
if (read.state === "invalid") {
|
|
113
|
+
notes.push(`${file}: ${read.why}; its upstream ${UPSTREAM_PLUGIN_NAME} launchers are not checked`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!Object.prototype.hasOwnProperty.call(read.root, key))
|
|
117
|
+
continue;
|
|
118
|
+
const servers = read.root[key];
|
|
119
|
+
if (!isPlainObject(servers)) {
|
|
120
|
+
notes.push(`${file}: "${key}" is not an object; its upstream ${UPSTREAM_PLUGIN_NAME} launchers are not checked`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
124
|
+
if (!isUpstreamMcpEntry(name, entry))
|
|
125
|
+
continue;
|
|
126
|
+
const reason = `upstream ${UPSTREAM_PLUGIN_NAME} MCP server "${launcherText(name)}" (another client starts the upstream worker)`;
|
|
127
|
+
items.push({ kind: "json-key", target: file, reason, group: "memory", keyPath: [key, name], archiveDest: path.join(configs, rel), launcher: "mcp" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const refused = scope.marketplaceRefusal;
|
|
131
|
+
const market = path.join(scope.cfg, UPSTREAM_MARKETPLACE_DIR);
|
|
132
|
+
if (isRealDir(market)) {
|
|
133
|
+
const archiveDest = freeDestination(path.join(scope.archiveRoot, "plugins", "marketplaces", UPSTREAM_MARKETPLACE_OWNER));
|
|
134
|
+
items.push({ kind: "dir", target: market, reason: `upstream ${UPSTREAM_MARKETPLACE_OWNER} marketplace clone (the upstream plugin installs from it)`, group: "memory", archiveDest, launcher: "marketplace", refused });
|
|
135
|
+
}
|
|
136
|
+
for (const { rel, keyPath } of UPSTREAM_MARKETPLACE_KEYS) {
|
|
137
|
+
const file = path.join(scope.cfg, rel);
|
|
138
|
+
const read = readJsonObject(file);
|
|
139
|
+
if (read.state !== "object" || !hasKeyPath(read.root, keyPath))
|
|
140
|
+
continue;
|
|
141
|
+
const copy = configCopyArchive(file, scope.home, scope.archiveRoot);
|
|
142
|
+
items.push({ kind: "json-key", target: file, reason: `upstream ${UPSTREAM_MARKETPLACE_OWNER} marketplace registration`, group: "memory", keyPath, archiveDest: copy.destination, launcher: "marketplace", refused: refused ?? copy.refused });
|
|
143
|
+
}
|
|
144
|
+
return { items, notes };
|
|
145
|
+
}
|
|
146
|
+
/** Why a planned marketplace move must not run now; the destination stays in its own inbox so it never lands over other archive content. */
|
|
147
|
+
export function marketplaceRefusal(source, dest, archiveRoot) {
|
|
148
|
+
if (archiveRoot === undefined)
|
|
149
|
+
return "no archive root in the plan";
|
|
150
|
+
const inbox = path.join(archiveRoot, "plugins", "marketplaces");
|
|
151
|
+
const rel = path.relative(inbox, dest);
|
|
152
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
153
|
+
return `destination ${dest} is outside ${inbox}`;
|
|
154
|
+
// A link or file put there since pass 1 is not the clone the plan listed.
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.lstatSync(source).isDirectory())
|
|
157
|
+
return "is no longer a directory";
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// gone: the move reports it
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const CODEX_CONFIG = path.join(".codex", "config.toml");
|
|
165
|
+
/** No TOML parser may be added and an rc edit can break the owner's shell, so these are only named, never edited. */
|
|
166
|
+
export const REPORT_ONLY_FILES = [CODEX_CONFIG, ".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"];
|
|
167
|
+
const REPORT_ONLY_MAX_BYTES = 1024 * 1024;
|
|
168
|
+
const TOML_SERVER_HEADER = /^\s*\[\s*mcp_servers\s*\.\s*(?:"((?:[^"\\]|\\.)*)"|([A-Za-z0-9_-]+))\s*\]\s*(?:#.*)?$/;
|
|
169
|
+
const TOML_KEY = /^\s*(command|args)\s*=/;
|
|
170
|
+
const TOML_STRING = /"((?:[^"\\]|\\.)*)"|'([^']*)'/g;
|
|
171
|
+
// Shell words end at quotes, `=`, `;` and the like, so the package token is bounded by anything a file name cannot hold.
|
|
172
|
+
const RC_PACKAGE_TOKEN = new RegExp(`(^|[^A-Za-z0-9_.-])${UPSTREAM_PLUGIN_NAME}($|[^A-Za-z0-9_.-])`);
|
|
173
|
+
const RC_RUNNER = /(^|[^A-Za-z0-9_.-])(npx|bunx|node)(\s|$)/g;
|
|
174
|
+
const WORKER_SCRIPT = "worker-service.cjs";
|
|
175
|
+
/** The text of a small regular file (or the file a link names), or undefined; a FIFO or an oversized file is never read through. */
|
|
176
|
+
function readSmallText(file) {
|
|
177
|
+
let fd;
|
|
178
|
+
try {
|
|
179
|
+
if (!fs.statSync(file).isFile())
|
|
180
|
+
return undefined;
|
|
181
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
182
|
+
const st = fs.fstatSync(fd);
|
|
183
|
+
if (!st.isFile() || st.size > REPORT_ONLY_MAX_BYTES)
|
|
184
|
+
return undefined;
|
|
185
|
+
// Bounded read: a file grown since fstat is skipped rather than read past the cap.
|
|
186
|
+
const buf = Buffer.alloc(REPORT_ONLY_MAX_BYTES + 1);
|
|
187
|
+
let total = 0;
|
|
188
|
+
for (let n = 1; n > 0 && total < buf.length; total += n)
|
|
189
|
+
n = fs.readSync(fd, buf, total, buf.length - total, null);
|
|
190
|
+
return total > REPORT_ONLY_MAX_BYTES ? undefined : buf.subarray(0, total).toString("utf8");
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (fd !== undefined) {
|
|
197
|
+
try {
|
|
198
|
+
fs.closeSync(fd);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// the scan never throws
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function tomlStrings(line) {
|
|
207
|
+
return Array.from(line.matchAll(TOML_STRING), (m) => (m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : m[2] ?? ""));
|
|
208
|
+
}
|
|
209
|
+
function withoutTomlStrings(line) {
|
|
210
|
+
return line.replace(TOML_STRING, "");
|
|
211
|
+
}
|
|
212
|
+
/** A line scan, not a parse: only `[mcp_servers.<name>]` tables and their `command`/`args` values are read. */
|
|
213
|
+
function scanCodexTables(lines) {
|
|
214
|
+
const hits = [];
|
|
215
|
+
let table;
|
|
216
|
+
let inArgs = false;
|
|
217
|
+
const close = () => {
|
|
218
|
+
if (table && isUpstreamMcpEntry(table.name, { command: table.command.join(" "), args: table.args }))
|
|
219
|
+
hits.push(table.header);
|
|
220
|
+
table = undefined;
|
|
221
|
+
inArgs = false;
|
|
222
|
+
};
|
|
223
|
+
lines.forEach((line, i) => {
|
|
224
|
+
if (line.trimStart().startsWith("[")) {
|
|
225
|
+
close();
|
|
226
|
+
const header = TOML_SERVER_HEADER.exec(line);
|
|
227
|
+
if (header)
|
|
228
|
+
table = { name: header[1] !== undefined ? header[1].replace(/\\(.)/g, "$1") : header[2] ?? "", header: i + 1, command: [], args: [] };
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!table)
|
|
232
|
+
return;
|
|
233
|
+
if (inArgs) {
|
|
234
|
+
table.args.push(...tomlStrings(line));
|
|
235
|
+
if (withoutTomlStrings(line).includes("]"))
|
|
236
|
+
inArgs = false;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const key = TOML_KEY.exec(line);
|
|
240
|
+
if (!key)
|
|
241
|
+
return;
|
|
242
|
+
const value = line.slice(key[0].length);
|
|
243
|
+
if (key[1] === "command") {
|
|
244
|
+
table.command.push(...tomlStrings(value));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
table.args.push(...tomlStrings(value));
|
|
248
|
+
const bare = withoutTomlStrings(value);
|
|
249
|
+
inArgs = bare.includes("[") && !bare.includes("]");
|
|
250
|
+
});
|
|
251
|
+
close();
|
|
252
|
+
return hits;
|
|
253
|
+
}
|
|
254
|
+
/** The owner decides about a commented line, so a leading `#` does not hide a match. */
|
|
255
|
+
function isRcLauncherLine(line) {
|
|
256
|
+
if (line.includes(WORKER_SCRIPT) && (line.includes(UPSTREAM_MARKETPLACE_OWNER) || RC_PACKAGE_TOKEN.test(line)))
|
|
257
|
+
return true;
|
|
258
|
+
for (const runner of line.matchAll(RC_RUNNER)) {
|
|
259
|
+
if (RC_PACKAGE_TOKEN.test(line.slice((runner.index ?? 0) + runner[0].length)))
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
/** Upstream launchers in the Codex config and the shell rc files under `home`; reads only and never throws. */
|
|
265
|
+
export function scanReportOnly(home) {
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const rel of REPORT_ONLY_FILES) {
|
|
268
|
+
const file = path.join(home, rel);
|
|
269
|
+
const text = readSmallText(file);
|
|
270
|
+
if (text === undefined)
|
|
271
|
+
continue;
|
|
272
|
+
const lines = text.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
273
|
+
const hits = rel === CODEX_CONFIG ? scanCodexTables(lines) : lines.flatMap((l, i) => (isRcLauncherLine(l) ? [i + 1] : []));
|
|
274
|
+
for (const line of hits)
|
|
275
|
+
out.push({ file, line, text: lines[line - 1] ?? "" });
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/** Pass 2 re-check per launcher item, so a value rewritten since pass 1 is left as the other program wrote it. */
|
|
280
|
+
export function launcherStillMatches(item) {
|
|
281
|
+
const name = item.keyPath?.[item.keyPath.length - 1];
|
|
282
|
+
if (item.launcher === "mcp" && name !== undefined)
|
|
283
|
+
return (value) => isUpstreamMcpEntry(name, value);
|
|
284
|
+
if (item.launcher === "marketplace")
|
|
285
|
+
return (value) => value !== undefined;
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|