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
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// The CLI cannot import the engine: a copy of engine/shared/data-dir.ts
|
|
1
|
+
// The CLI cannot import the engine: a copy of engine/shared/data-dir.ts resolveDataDir.
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
export const FREE_DIR_NAME = ".free-mem";
|
|
5
|
+
/** The pre-rename data directory: never read, only kept protected and named by doctor. */
|
|
5
6
|
export const LEGACY_DIR_NAME = ".claude-mem";
|
|
6
7
|
export const DB_FILENAME = "free-mem.db";
|
|
7
|
-
|
|
8
|
-
const DATA_DIR_KEYS = ["FREE_MEM_DATA_DIR", "CLAUDE_MEM_DATA_DIR"];
|
|
8
|
+
const DATA_DIR_KEY = "FREE_MEM_DATA_DIR";
|
|
9
9
|
function expandHome(p, home) {
|
|
10
10
|
if (p === "~")
|
|
11
11
|
return home;
|
|
@@ -13,14 +13,6 @@ function expandHome(p, home) {
|
|
|
13
13
|
return path.join(home, p.slice(2));
|
|
14
14
|
return p;
|
|
15
15
|
}
|
|
16
|
-
function isDirectory(p) {
|
|
17
|
-
try {
|
|
18
|
-
return fs.statSync(p).isDirectory();
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
16
|
/** A settings file's keys as the engine reads them (BOM dropped, `env` first); null when unreadable. */
|
|
25
17
|
export function readMemorySettings(settingsPath) {
|
|
26
18
|
try {
|
|
@@ -34,52 +26,22 @@ export function readMemorySettings(settingsPath) {
|
|
|
34
26
|
return null;
|
|
35
27
|
}
|
|
36
28
|
}
|
|
37
|
-
//
|
|
29
|
+
// A seeded or hand-copied default is no redirect; honoring it pins later starts.
|
|
38
30
|
function settingsDataDir(settingsPath, home, defaultDirs) {
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
31
|
+
const value = readMemorySettings(settingsPath)?.[DATA_DIR_KEY];
|
|
32
|
+
if (typeof value !== "string" || !value)
|
|
41
33
|
return undefined;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (typeof value !== "string" || !value)
|
|
45
|
-
continue;
|
|
46
|
-
const dir = expandHome(value, home);
|
|
47
|
-
if (!defaultDirs.includes(path.resolve(dir)))
|
|
48
|
-
return dir;
|
|
49
|
-
}
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
function dbFileIn(dataDir) {
|
|
53
|
-
const current = path.join(dataDir, DB_FILENAME);
|
|
54
|
-
const legacy = path.join(dataDir, LEGACY_DB_FILENAME);
|
|
55
|
-
return !fs.existsSync(current) && fs.existsSync(legacy) ? legacy : current;
|
|
34
|
+
const dir = expandHome(value, home);
|
|
35
|
+
return defaultDirs.includes(path.resolve(dir)) ? undefined : dir;
|
|
56
36
|
}
|
|
57
|
-
/**
|
|
58
|
-
* Resolves the memory data directory and database from `env` and `home` alone, never from
|
|
59
|
-
* process globals, so callers and tests can pass fake values.
|
|
60
|
-
* @param env Environment to read `FREE_MEM_DATA_DIR` and `CLAUDE_MEM_DATA_DIR` from.
|
|
61
|
-
* @param home Home directory that holds `.free-mem` and `.claude-mem` and expands `~`.
|
|
62
|
-
*/
|
|
37
|
+
/** The data directory and database from `env` and `home` alone, never process globals, so tests pass fakes. */
|
|
63
38
|
export function resolveMemoryPaths(env, home) {
|
|
64
39
|
const freeDir = path.join(home, FREE_DIR_NAME);
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
73
|
-
for (const key of DATA_DIR_KEYS) {
|
|
74
|
-
const value = env[key];
|
|
75
|
-
if (value)
|
|
76
|
-
return result(expandHome(value, home), key);
|
|
77
|
-
}
|
|
78
|
-
for (const dir of [freeDir, legacyDir]) {
|
|
79
|
-
const redirect = settingsDataDir(path.join(dir, "settings.json"), home, defaultDirs);
|
|
80
|
-
if (redirect)
|
|
81
|
-
return result(redirect, "settings");
|
|
82
|
-
}
|
|
83
|
-
// An unmigrated install keeps serving its legacy directory until the copy creates ~/.free-mem.
|
|
84
|
-
return isDirectory(freeDir) || !isDirectory(legacyDir) ? result(freeDir, "default") : result(legacyDir, "fallback");
|
|
40
|
+
const defaultDirs = [path.resolve(freeDir), path.resolve(home, LEGACY_DIR_NAME)];
|
|
41
|
+
const result = (dataDir, source) => ({ dataDir, dbFile: path.join(dataDir, DB_FILENAME), source });
|
|
42
|
+
const value = env[DATA_DIR_KEY];
|
|
43
|
+
if (value)
|
|
44
|
+
return result(expandHome(value, home), DATA_DIR_KEY);
|
|
45
|
+
const redirect = settingsDataDir(path.join(freeDir, "settings.json"), home, defaultDirs);
|
|
46
|
+
return redirect ? result(redirect, "settings") : result(freeDir, "default");
|
|
85
47
|
}
|
|
@@ -116,7 +116,7 @@ export declare function isUpstreamRegistered(installed: {
|
|
|
116
116
|
}, settings: {
|
|
117
117
|
enabledPlugins?: Record<string, boolean>;
|
|
118
118
|
}): boolean;
|
|
119
|
-
/** Write the `.install-version` marker that tells our build apart; never writes through a symlink.
|
|
119
|
+
/** Write the `.install-version` marker that tells our build apart; never writes through a symlink. */
|
|
120
120
|
export declare function writeInstallMarker(dir: string, version: string, bun: string): string;
|
|
121
121
|
/**
|
|
122
122
|
* Version from a plugin dir's manifest ("0.0.0" when absent). The version names a cache
|
|
@@ -167,11 +167,6 @@ export declare class VendoredMemory implements MemoryPort {
|
|
|
167
167
|
* An upstream registration (UPSTREAM_PLUGIN_KEY) is never written: doctor warns about it.
|
|
168
168
|
*/
|
|
169
169
|
registerHooks(_scope: Scope, ctx: InstallContext): Promise<AdapterResult>;
|
|
170
|
-
|
|
171
|
-
* Copy a legacy ~/.claude-mem forward into ~/.free-mem: pass 1 prints the plan, pass 2 applies
|
|
172
|
-
* it. A worker it stops is restarted by `update` once the bundle is staged, else at SessionStart.
|
|
173
|
-
* The copy is never recorded in `written`, since uninstall removes what the manifest lists.
|
|
174
|
-
*/
|
|
175
|
-
migrateExisting(ctx: InstallContext): Promise<AdapterResult>;
|
|
170
|
+
migrateExisting(_ctx: InstallContext): Promise<AdapterResult>;
|
|
176
171
|
private run;
|
|
177
172
|
}
|
|
@@ -1,32 +1,22 @@
|
|
|
1
1
|
// MemoryPort adapter for the vendored claude-mem engine (FreeHarness V3-P0 memory-runtime).
|
|
2
2
|
//
|
|
3
|
-
// Installs free-mem as a real Claude Code plugin from OUR build, reproducing the
|
|
4
|
-
//
|
|
5
|
-
// utils/paths.ts + install/setup-runtime.ts) in TypeScript:
|
|
3
|
+
// Installs free-mem as a real Claude Code plugin from OUR build, reproducing the upstream `npx claude-mem
|
|
4
|
+
// install` mechanics (src/npx-cli/commands/install.ts + utils/paths.ts + install/setup-runtime.ts) in TypeScript:
|
|
6
5
|
//
|
|
7
6
|
// ensurePrerequisites Bun probe (build + hook runtime), the only prerequisite.
|
|
8
|
-
// install bun run build:plugin -> copyPluginToCache(version)
|
|
9
|
-
// ->
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// all ADDITIVE merges (other entries survive). The upstream
|
|
14
|
-
// key (UPSTREAM_PLUGIN_KEY) is never written; doctor warns
|
|
15
|
-
// while it is live.
|
|
16
|
-
// migrateExisting copies a legacy ~/.claude-mem forward into ~/.free-mem
|
|
17
|
-
// (migrate.ts); the legacy directory is only read.
|
|
7
|
+
// install bun run build:plugin -> copyPluginToCache(version) -> copyPluginToMarketplace
|
|
8
|
+
// -> plugin deps install -> .install-version markers.
|
|
9
|
+
// registerHooks known_marketplaces.json + installed_plugins.json + enabledPlugins
|
|
10
|
+
// ["free-mem@freeharness"], all ADDITIVE merges; the upstream key
|
|
11
|
+
// (UPSTREAM_PLUGIN_KEY) is never written; doctor warns while it is live.
|
|
18
12
|
//
|
|
19
|
-
// The FreeHarness build declares its own identity (marketplace freeharness, plugin
|
|
20
|
-
//
|
|
21
|
-
// pre-rename install. Every write honors ctx.dryRun (writes nothing, reports the plan).
|
|
13
|
+
// The FreeHarness build declares its own identity (marketplace freeharness, plugin free-mem) so `doctor` and
|
|
14
|
+
// `cleanup` can tell it apart from an upstream or pre-rename install. Every write honors ctx.dryRun.
|
|
22
15
|
import crypto from "node:crypto";
|
|
23
16
|
import fs from "node:fs";
|
|
24
17
|
import os from "node:os";
|
|
25
18
|
import path from "node:path";
|
|
26
19
|
import { spawnSync } from "node:child_process";
|
|
27
|
-
import { cliVersion } from "../../infra/paths.js";
|
|
28
|
-
import { applyMemoryMigration, bunIntegrityCheck, describePlan, describeResult, planMemoryMigration } from "./migrate.js";
|
|
29
|
-
import { FREE_DIR_NAME, LEGACY_DIR_NAME, resolveMemoryPaths } from "./paths.js";
|
|
30
20
|
const IS_WINDOWS = process.platform === "win32";
|
|
31
21
|
/** Name of the digest marker written into the plugin cache directory after a copy. */
|
|
32
22
|
const BUNDLE_MARKER_NAME = "bundle.sha256";
|
|
@@ -284,7 +274,7 @@ function writeJsonAtomic(file, value) {
|
|
|
284
274
|
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
|
|
285
275
|
fs.renameSync(tmp, file);
|
|
286
276
|
}
|
|
287
|
-
/** Write the `.install-version` marker that tells our build apart; never writes through a symlink.
|
|
277
|
+
/** Write the `.install-version` marker that tells our build apart; never writes through a symlink. */
|
|
288
278
|
export function writeInstallMarker(dir, version, bun) {
|
|
289
279
|
const marker = path.join(dir, ".install-version");
|
|
290
280
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -476,43 +466,8 @@ export class VendoredMemory {
|
|
|
476
466
|
res.notes.push("Lifecycle hooks and the mem-search MCP come from the plugin's own hooks.json / .mcp.json (no settings.json hook entries are written).");
|
|
477
467
|
return res;
|
|
478
468
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
* it. A worker it stops is restarted by `update` once the bundle is staged, else at SessionStart.
|
|
482
|
-
* The copy is never recorded in `written`, since uninstall removes what the manifest lists.
|
|
483
|
-
*/
|
|
484
|
-
async migrateExisting(ctx) {
|
|
485
|
-
const res = this.result();
|
|
486
|
-
const home = os.homedir();
|
|
487
|
-
const plan = planMemoryMigration(home, process.env);
|
|
488
|
-
if (!plan) {
|
|
489
|
-
const p = resolveMemoryPaths(process.env, home);
|
|
490
|
-
const freeDir = path.join(home, FREE_DIR_NAME);
|
|
491
|
-
const db = fs.existsSync(p.dbFile) ? ` (${path.basename(p.dbFile)} present)` : "";
|
|
492
|
-
if (p.source !== "default")
|
|
493
|
-
res.notes.push(`Existing memory data kept at ${p.dataDir}${db}; the ${PLUGIN_NAME} plugin reads it through ${p.source}, so no copy is planned. Nothing deleted.`);
|
|
494
|
-
else if (fs.existsSync(freeDir))
|
|
495
|
-
res.notes.push(`Memory data already at ${freeDir}; no copy planned.`);
|
|
496
|
-
else
|
|
497
|
-
res.notes.push(`No legacy memory data at ${path.join(home, LEGACY_DIR_NAME)}; the worker creates ${freeDir} on first start.`);
|
|
498
|
-
return res;
|
|
499
|
-
}
|
|
500
|
-
if (ctx.dryRun) {
|
|
501
|
-
res.notes.push(...describePlan(plan));
|
|
502
|
-
return res;
|
|
503
|
-
}
|
|
504
|
-
const result = await applyMemoryMigration(plan, {
|
|
505
|
-
stopWorker: async () => (await import("./doctor.js")).stopWorker("shutdown"),
|
|
506
|
-
now: Date.now,
|
|
507
|
-
integrityCheck: bunIntegrityCheck(probeBun().path),
|
|
508
|
-
version: cliVersion(),
|
|
509
|
-
});
|
|
510
|
-
const lines = describeResult(plan, result);
|
|
511
|
-
res.notes.push(...lines);
|
|
512
|
-
// The executor prints only the preview's notes, so the outcome of pass 2 is printed here.
|
|
513
|
-
for (const l of lines)
|
|
514
|
-
console.log(`memory: ${l}`);
|
|
515
|
-
return res;
|
|
469
|
+
async migrateExisting(_ctx) {
|
|
470
|
+
return this.result();
|
|
516
471
|
}
|
|
517
472
|
run(cmd, args, cwd, label) {
|
|
518
473
|
const r = spawnSync(cmd, args, { cwd, stdio: "inherit", windowsHide: true, env: process.env });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export interface StopResult {
|
|
3
|
+
/** True when no worker answers and no live pid file remains. */
|
|
4
|
+
stopped: boolean;
|
|
5
|
+
wasRunning: boolean;
|
|
6
|
+
reason?: string;
|
|
7
|
+
}
|
|
8
|
+
/** True when `pidFile` exists and names a live process, or cannot be read as a pid record. */
|
|
9
|
+
export declare function pidFileLive(pidFile: string, f?: typeof fs): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The worker's pid-file check and the stop outcome shared by the doctor's stop and restart paths.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
function readJsonText(raw) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(raw) ?? {};
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** True when `pidFile` exists and names a live process, or cannot be read as a pid record. */
|
|
12
|
+
export function pidFileLive(pidFile, f = fs) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = f.readFileSync(pidFile, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const pid = Number(readJsonText(raw).pid);
|
|
21
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
22
|
+
return true;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
return e.code === "EPERM";
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/cli/cleanup.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export declare function globalNpmPackages(): Record<string, string> | null;
|
|
|
71
71
|
export declare function globalNpmFreeharnessBin(): string | undefined;
|
|
72
72
|
/**
|
|
73
73
|
* Memory data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), sorted.
|
|
74
|
-
* Backups of
|
|
74
|
+
* Backups of the default directory count even while a redirected directory is in use.
|
|
75
75
|
*/
|
|
76
76
|
export declare function memoryBackupDirs(env?: Record<string, string | undefined>, home?: string): string[];
|
|
77
77
|
/** Drops install-manifest entries whose file no longer exists (else every removal is "drift"); 0 without a manifest. */
|
package/dist/cli/cleanup.js
CHANGED
|
@@ -17,8 +17,8 @@ import path from "node:path";
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { PLUGIN_KEY, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
|
|
19
19
|
import { manifestPath, readManifest } from "../infra/manifest.js";
|
|
20
|
-
import { configDir, diagnoseMemory } from "../adapters/memory/doctor.js";
|
|
21
|
-
import { FREE_DIR_NAME,
|
|
20
|
+
import { configDir, diagnoseMemory, foreignWorkerStep } from "../adapters/memory/doctor.js";
|
|
21
|
+
import { FREE_DIR_NAME, resolveMemoryPaths } from "../adapters/memory/paths.js";
|
|
22
22
|
import { canonicalNames, memoryPluginInstalled, RETIRED, retiredFindings, runsRetiredHook } from "./install-facts.js";
|
|
23
23
|
import { isClaudeMemHook, isOurHook, pruneHooksMatching } from "./uninstall.js";
|
|
24
24
|
import { archiveMove, archiveRootFor, isOrHolds, moveWarning, pluginCacheArchive, pluginCacheOverlap, pluginCacheRefusal } from "./hard-cleanup.js";
|
|
@@ -113,11 +113,11 @@ export function globalNpmFreeharnessBin() {
|
|
|
113
113
|
}
|
|
114
114
|
/**
|
|
115
115
|
* Memory data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), sorted.
|
|
116
|
-
* Backups of
|
|
116
|
+
* Backups of the default directory count even while a redirected directory is in use.
|
|
117
117
|
*/
|
|
118
118
|
export function memoryBackupDirs(env = process.env, home = os.homedir()) {
|
|
119
119
|
const live = resolveMemoryPaths(env, home).dataDir;
|
|
120
|
-
const dirs = [live, path.join(home, FREE_DIR_NAME)
|
|
120
|
+
const dirs = [live, path.join(home, FREE_DIR_NAME)];
|
|
121
121
|
const found = new Set();
|
|
122
122
|
for (const data of dirs) {
|
|
123
123
|
const parent = path.dirname(data);
|
|
@@ -160,6 +160,18 @@ function toArchive(item, cfg, archiveRoot) {
|
|
|
160
160
|
return { ...item, archiveDest: archive.destination, refused: item.refused ?? archive.refused };
|
|
161
161
|
}
|
|
162
162
|
const UPSTREAM_ENABLED_REFUSAL = `the ${UPSTREAM_PLUGIN_KEY} key is still enabled — disable it first: \`claude plugin disable ${UPSTREAM_PLUGIN_KEY}\``;
|
|
163
|
+
/**
|
|
164
|
+
* Refusal while the build is not the sole active plugin. `memory activate` cannot clear a foreign
|
|
165
|
+
* worker beside our build, so that case names the doctor FAIL line's steps, as the mixed VERDICT does.
|
|
166
|
+
*/
|
|
167
|
+
function notOursRefusal(d) {
|
|
168
|
+
const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
|
|
169
|
+
const head = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")})`;
|
|
170
|
+
const step = d.verdict === "mixed" ? foreignWorkerStep(d.worker) : null;
|
|
171
|
+
if (step === null)
|
|
172
|
+
return `${head}; run \`freeharness memory activate\` first`;
|
|
173
|
+
return `${head}; stop the foreign worker first (${step})`;
|
|
174
|
+
}
|
|
163
175
|
/** Computes the user-scope cleanup list; nothing is touched. */
|
|
164
176
|
async function planUser(deps) {
|
|
165
177
|
const archiveRoot = deps.archiveRoot ?? archiveRootFor(os.homedir(), new Date());
|
|
@@ -171,10 +183,8 @@ async function planUser(deps) {
|
|
|
171
183
|
if (fs.existsSync(disabled)) {
|
|
172
184
|
const d = deps.memory ?? (await diagnoseMemory());
|
|
173
185
|
const item = { kind: "dir", target: disabled, reason: "upstream claude-mem parked by `memory activate`", group: "memory", bytes: dirBytes(disabled) };
|
|
174
|
-
if (d.verdict !== "ours")
|
|
175
|
-
|
|
176
|
-
item.refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
|
|
177
|
-
}
|
|
186
|
+
if (d.verdict !== "ours")
|
|
187
|
+
item.refused = notOursRefusal(d);
|
|
178
188
|
plan.items.push(toArchive(item, cfg, archiveRoot));
|
|
179
189
|
}
|
|
180
190
|
// Superseded FreeHarness builds: our own cache versions (.install-version present) that are
|
|
@@ -232,8 +242,7 @@ async function planUser(deps) {
|
|
|
232
242
|
refused = UPSTREAM_ENABLED_REFUSAL;
|
|
233
243
|
}
|
|
234
244
|
else if (d.verdict !== "ours") {
|
|
235
|
-
|
|
236
|
-
refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
|
|
245
|
+
refused = notOursRefusal(d);
|
|
237
246
|
}
|
|
238
247
|
if (fs.existsSync(oldCacheDir)) {
|
|
239
248
|
plan.items.push(toArchive({ kind: "dir", target: oldCacheDir, reason: "previous registration, left in the plugin cache", group: "memory", bytes: dirBytes(oldCacheDir), refused }, cfg, archiveRoot));
|
|
@@ -282,17 +291,23 @@ async function planUser(deps) {
|
|
|
282
291
|
plan.items.push({ kind: "npm-global", target: CLI_PACKAGE, reason: `npm -g ${CLI_PACKAGE} (old npm link; shadows ~/.local/bin)${linkedBin ? ` at ${linkedBin}` : ""}`, group: "npm", unlink: true });
|
|
283
292
|
}
|
|
284
293
|
}
|
|
285
|
-
// Retired names: a skill or
|
|
286
|
-
// same-named user-owned one stays untouched (doctor reports that case as INFO, never here).
|
|
294
|
+
// Retired names: a skill, agent or file is listed only when the manifest records the path,
|
|
295
|
+
// so a same-named user-owned one stays untouched (doctor reports that case as INFO, never here).
|
|
287
296
|
// A hook script lives in FreeHarness's own hooks dir, so its presence is enough.
|
|
288
297
|
const manifest = readManifest(manifestPath("user", ""));
|
|
298
|
+
const retiredReason = {
|
|
299
|
+
hook: (name) => `retired hook ${name} (removed from FreeHarness; update never deletes the installed copy)`,
|
|
300
|
+
file: () => "retired rules file (FreeHarness no longer emits it; update never deletes the installed copy)",
|
|
301
|
+
skill: () => "retired skill (renamed or folded elsewhere; kept on disk by update's additive manifest merge)",
|
|
302
|
+
agent: () => "retired agent (renamed or folded elsewhere; kept on disk by update's additive manifest merge)",
|
|
303
|
+
};
|
|
289
304
|
for (const f of retiredFindings(path.join(os.homedir(), ".claude"), manifest)) {
|
|
290
305
|
if (!f.recorded && f.kind !== "hook")
|
|
291
306
|
continue;
|
|
292
307
|
plan.items.push({
|
|
293
308
|
kind: f.kind === "skill" ? "dir" : "file",
|
|
294
309
|
target: f.path,
|
|
295
|
-
reason: f.kind
|
|
310
|
+
reason: retiredReason[f.kind](f.name),
|
|
296
311
|
group: "retired",
|
|
297
312
|
bytes: f.kind === "skill" ? dirBytes(f.path) : undefined,
|
|
298
313
|
});
|
|
@@ -347,7 +362,7 @@ function planProject(projectRoot, deps) {
|
|
|
347
362
|
}
|
|
348
363
|
const oldSafety = path.join(fhDir, "safety.md");
|
|
349
364
|
if (fs.existsSync(oldSafety))
|
|
350
|
-
plan.items.push({ kind: "file", target: oldSafety, reason: "old project-scope safety.md (
|
|
365
|
+
plan.items.push({ kind: "file", target: oldSafety, reason: "old project-scope safety.md (FreeHarness emits no safety.md)", group: "duplicates" });
|
|
351
366
|
plan.notes.push(`kept: ${path.join(claude, "commands", "opsx")} and ${path.join(claude, "skills", "openspec-*")} (ours)`);
|
|
352
367
|
return plan;
|
|
353
368
|
}
|
package/dist/cli/cost.js
CHANGED
|
@@ -1948,7 +1948,7 @@ export function formatRoutingReport(result) {
|
|
|
1948
1948
|
if (missingTasksFiles.length > 0)
|
|
1949
1949
|
trailers.push(`tasks.md not found: ${missingTasksFiles.map(printable).join(", ")}`);
|
|
1950
1950
|
if (skippedFiles.length > 0)
|
|
1951
|
-
trailers.push(`skipped files (symlink or not a regular file, ledger over 16 MiB, brief over 1 MiB, brief past the 500-brief cap or with an over-cap header line, unlistable change directory, or unreadable): ${skippedFiles.map(printable).join(", ")}`);
|
|
1951
|
+
trailers.push(`skipped files (symlink or not a regular file, ledger, tasks.md or follow-ups.md over 16 MiB, brief or review package over 1 MiB, brief past the 500-brief cap or with an over-cap header line, unlistable change directory, or unreadable): ${skippedFiles.map(printable).join(", ")}`);
|
|
1952
1952
|
if (result.calibrationWarning)
|
|
1953
1953
|
trailers.push(printable(result.calibrationWarning));
|
|
1954
1954
|
if (rows.length === 0)
|
|
@@ -1996,7 +1996,7 @@ function formatRoutingFigures({ reviewRows = [], lead, totalsPerAccepted, follow
|
|
|
1996
1996
|
}
|
|
1997
1997
|
}
|
|
1998
1998
|
if (lead) {
|
|
1999
|
-
lines.push(`lead (main-session tokens of telemetry records
|
|
1999
|
+
lines.push(`lead (main-session tokens of telemetry records; a session is attributed by its changes array, its tokens apportioned across the changes): ${lead.sessions} session${lead.sessions === 1 ? "" : "s"}`);
|
|
2000
2000
|
for (const [family, g] of nonZero(lead.tokens))
|
|
2001
2001
|
lines.push(` ${family}: cache-read ${fmtTokens(g.cache_read)}, fresh ${fmtTokens(g.fresh)}`);
|
|
2002
2002
|
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare function depLines(deps: DepStatus[]): string[];
|
|
|
21
21
|
/** Hook conflicts in a settings.json: duplicate commands, a foreign memory-injecting SessionStart hook, claude-mem hooks kept while the plugin is installed; empty when settings.json is absent. */
|
|
22
22
|
export declare function hookConflictLines(settingsPath: string, pluginInstalled: boolean): string[];
|
|
23
23
|
/**
|
|
24
|
-
* Skills and
|
|
24
|
+
* Skills, agents and retired files under a Claude dir that FreeHarness did not emit (the user's own content); never a global `openspec-*` copy or a manifest-recorded retired name.
|
|
25
25
|
* @param home Home whose `.claude` holds the user-scope manifest; never derived from `claudeDir`.
|
|
26
26
|
*/
|
|
27
27
|
export declare function foreignContentLines(claudeDir: string, canonicalDir: string, scope: Scope, projectRoot: string, home?: string): string[];
|
|
@@ -71,17 +71,17 @@ export declare function openspecChangeLines(projectRoot: string): string[];
|
|
|
71
71
|
*/
|
|
72
72
|
export declare function staleTreeLines(projectRoot: string): string[];
|
|
73
73
|
/**
|
|
74
|
-
* Warns when
|
|
75
|
-
* missing runtime
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* without a shell.
|
|
74
|
+
* Warns when a guard hook entry exists and node is not resolvable: a guard that cannot run
|
|
75
|
+
* allows, so a missing runtime silently leaves every Edit, Write and Bash call unguarded and
|
|
76
|
+
* the owner must hear it. Any guard entry counts, whatever its onFailure field. PATH is
|
|
77
|
+
* scanned for absolute entries only and no shell runs, so a relative entry cannot supply
|
|
78
|
+
* the binary; the settings file is only read. Only the first existing binary is probed because
|
|
79
|
+
* it is the one the hook shell runs; a later healthy node cannot rescue the hooks. win32 looks
|
|
80
|
+
* for node.exe alone, since spawnSync cannot run a .cmd without a shell.
|
|
81
81
|
* @param settingsPath The user settings.json.
|
|
82
82
|
* @param env Environment whose PATH is searched.
|
|
83
83
|
* @param timeoutMs Version probe limit; tests shorten it.
|
|
84
|
-
* @returns One OK or WARN line, or none when no
|
|
84
|
+
* @returns One OK or WARN line, or none when no guard entry exists.
|
|
85
85
|
*/
|
|
86
86
|
export declare function guardRuntimeLines(settingsPath: string, env?: NodeJS.ProcessEnv, timeoutMs?: number): string[];
|
|
87
87
|
/**
|
package/dist/cli/doctor.js
CHANGED
|
@@ -98,14 +98,18 @@ export function hookConflictLines(settingsPath, pluginInstalled) {
|
|
|
98
98
|
return lines;
|
|
99
99
|
}
|
|
100
100
|
/**
|
|
101
|
-
* Skills and
|
|
101
|
+
* Skills, agents and retired files under a Claude dir that FreeHarness did not emit (the user's own content); never a global `openspec-*` copy or a manifest-recorded retired name.
|
|
102
102
|
* @param home Home whose `.claude` holds the user-scope manifest; never derived from `claudeDir`.
|
|
103
103
|
*/
|
|
104
104
|
export function foreignContentLines(claudeDir, canonicalDir, scope, projectRoot, home = os.homedir()) {
|
|
105
105
|
const names = canonicalNames(canonicalDir);
|
|
106
106
|
const manifest = readManifest(manifestPath(scope, projectRoot, home));
|
|
107
|
-
const
|
|
107
|
+
const retired = retiredFindings(claudeDir, manifest);
|
|
108
|
+
const recordedRetired = new Set(retired.filter((f) => f.recorded).map((f) => f.path));
|
|
108
109
|
const lines = [];
|
|
110
|
+
for (const f of retired)
|
|
111
|
+
if (f.kind === "file" && !f.recorded)
|
|
112
|
+
lines.push(`INFO file ${f.name} not from FreeHarness (yours; kept)`);
|
|
109
113
|
const skillsDir = path.join(claudeDir, "skills");
|
|
110
114
|
if (fs.existsSync(skillsDir)) {
|
|
111
115
|
for (const e of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
@@ -280,20 +284,20 @@ export function staleTreeLines(projectRoot) {
|
|
|
280
284
|
return lines;
|
|
281
285
|
}
|
|
282
286
|
/**
|
|
283
|
-
* Warns when
|
|
284
|
-
* missing runtime
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* without a shell.
|
|
287
|
+
* Warns when a guard hook entry exists and node is not resolvable: a guard that cannot run
|
|
288
|
+
* allows, so a missing runtime silently leaves every Edit, Write and Bash call unguarded and
|
|
289
|
+
* the owner must hear it. Any guard entry counts, whatever its onFailure field. PATH is
|
|
290
|
+
* scanned for absolute entries only and no shell runs, so a relative entry cannot supply
|
|
291
|
+
* the binary; the settings file is only read. Only the first existing binary is probed because
|
|
292
|
+
* it is the one the hook shell runs; a later healthy node cannot rescue the hooks. win32 looks
|
|
293
|
+
* for node.exe alone, since spawnSync cannot run a .cmd without a shell.
|
|
290
294
|
* @param settingsPath The user settings.json.
|
|
291
295
|
* @param env Environment whose PATH is searched.
|
|
292
296
|
* @param timeoutMs Version probe limit; tests shorten it.
|
|
293
|
-
* @returns One OK or WARN line, or none when no
|
|
297
|
+
* @returns One OK or WARN line, or none when no guard entry exists.
|
|
294
298
|
*/
|
|
295
299
|
export function guardRuntimeLines(settingsPath, env = process.env, timeoutMs = 5000) {
|
|
296
|
-
const tail = "the guard hooks
|
|
300
|
+
const tail = "the guard hooks cannot run and allow every Edit, Write and Bash call";
|
|
297
301
|
let settings;
|
|
298
302
|
try {
|
|
299
303
|
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
@@ -306,8 +310,8 @@ export function guardRuntimeLines(settingsPath, env = process.env, timeoutMs = 5
|
|
|
306
310
|
const asList = (v) => (Array.isArray(v) ? v : []);
|
|
307
311
|
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
308
312
|
const events = isRecord(settings) && isRecord(settings.hooks) ? Object.values(settings.hooks) : [];
|
|
309
|
-
const
|
|
310
|
-
if (!
|
|
313
|
+
const guarded = events.some((groups) => asList(groups).some((g) => isRecord(g) && asList(g.hooks).some((h) => isRecord(h) && typeof h.command === "string" && /freeharness-(config|memory)-guard/.test(h.command))));
|
|
314
|
+
if (!guarded)
|
|
311
315
|
return [];
|
|
312
316
|
const names = process.platform === "win32" ? ["node.exe"] : ["node"];
|
|
313
317
|
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
@@ -511,15 +515,16 @@ export function autoMemoryLine(settingsFile, freeMemEnabled) {
|
|
|
511
515
|
: `INFO auto memory on (autoMemoryEnabled ${shown}); ${PLUGIN_KEY} not enabled`;
|
|
512
516
|
}
|
|
513
517
|
/**
|
|
514
|
-
* With agent teams on, a named Agent dispatch launches as a teammate
|
|
515
|
-
*
|
|
518
|
+
* With agent teams on, a named Agent dispatch launches as a teammate that inherits the session
|
|
519
|
+
* effort, except a plugin-defined agent spawned by name, which keeps its own; the owner is told
|
|
520
|
+
* why SDD dispatches carry no name. Either the settings `env` or the process
|
|
516
521
|
* environment turns the flag on.
|
|
517
522
|
*/
|
|
518
523
|
function withAgentTeamsNotice(line, settings) {
|
|
519
524
|
const s = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
|
|
520
525
|
const env = s.env && typeof s.env === "object" && !Array.isArray(s.env) ? s.env : {};
|
|
521
526
|
const on = env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" || process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1";
|
|
522
|
-
return on ? `${line}\nINFO agent teams on (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1): a named Agent dispatch launches as a teammate at the
|
|
527
|
+
return on ? `${line}\nINFO agent teams on (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1): a named Agent dispatch launches as a teammate at the session effort (a plugin-defined agent spawned by name keeps its own); SDD dispatches pass no name` : line;
|
|
523
528
|
}
|
|
524
529
|
/** ms; a local server that misses this is down. */
|
|
525
530
|
export const PROBE_TIMEOUT_MS = 1500;
|
package/dist/cli/hard-cleanup.js
CHANGED
|
@@ -13,7 +13,7 @@ import { removeJsonKey } from "./json-key-edit.js";
|
|
|
13
13
|
export const LOG_RETENTION_DAYS = 14;
|
|
14
14
|
/** Build and OS junk moved out of a FreeHarness checkout's canonical/. */
|
|
15
15
|
const CANONICAL_JUNK = new Set(["node_modules", "__pycache__", ".venv", ".DS_Store"]);
|
|
16
|
-
const LOG_NAME = /^
|
|
16
|
+
const LOG_NAME = /^free-mem-(\d{4})-(\d{2})-(\d{2})\.log$/;
|
|
17
17
|
/** `<file>.<stamp>.bak`, the stamp being `toISOString()` (always with milliseconds) with `:` and `.` replaced by `-`. */
|
|
18
18
|
const BACKUP_NAME = /^(.+)\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.bak$/;
|
|
19
19
|
const REINSTALL = "freeharness init --scope user --only memory";
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { InstallManifest } from "../infra/manifest.js";
|
|
2
|
-
/** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`). */
|
|
2
|
+
/** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`; files: paths relative to `.claude`). */
|
|
3
3
|
export declare const RETIRED: {
|
|
4
4
|
readonly skills: readonly ["local-code-intel"];
|
|
5
5
|
readonly agents: readonly ["mle-reviewer", "mle-evaluator", "implementer-medium", "implementer-high"];
|
|
6
6
|
readonly hooks: readonly ["freeharness-auto-memory.mjs"];
|
|
7
|
+
readonly files: readonly ["freeharness/safety.md"];
|
|
7
8
|
};
|
|
8
|
-
export type RetiredKind = "skill" | "agent" | "hook";
|
|
9
|
+
export type RetiredKind = "skill" | "agent" | "hook" | "file";
|
|
9
10
|
export interface RetiredFinding {
|
|
10
11
|
kind: RetiredKind;
|
|
11
12
|
name: string;
|
|
12
13
|
path: string;
|
|
13
|
-
/** Manifest lists `path` (agent, hook) or a file under it (skill dir). */
|
|
14
|
+
/** Manifest lists `path` (agent, hook, file) or a file under it (skill dir). */
|
|
14
15
|
recorded: boolean;
|
|
15
16
|
}
|
|
16
17
|
/** Whether a settings hook command runs a retired hook script; the entries `cleanup` prunes beside the script. */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { PLUGIN_KEY } from "../adapters/memory/vendored.js";
|
|
4
|
-
/** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`). */
|
|
4
|
+
/** Retired by a rename, a fold or a removal, bare names under `~/.claude` (hooks: script names under `freeharness/hooks`; files: paths relative to `.claude`). */
|
|
5
5
|
export const RETIRED = {
|
|
6
6
|
skills: ["local-code-intel"],
|
|
7
7
|
agents: ["mle-reviewer", "mle-evaluator", "implementer-medium", "implementer-high"],
|
|
8
8
|
hooks: ["freeharness-auto-memory.mjs"],
|
|
9
|
+
files: ["freeharness/safety.md"],
|
|
9
10
|
};
|
|
10
11
|
/** Whether a settings hook command runs a retired hook script; the entries `cleanup` prunes beside the script. */
|
|
11
12
|
export function runsRetiredHook(command) {
|
|
@@ -17,6 +18,7 @@ export function retiredFindings(claudeDir, manifest) {
|
|
|
17
18
|
{ kind: "skill", dir: "skills", names: RETIRED.skills },
|
|
18
19
|
{ kind: "agent", dir: "agents", names: RETIRED.agents },
|
|
19
20
|
{ kind: "hook", dir: path.join("freeharness", "hooks"), names: RETIRED.hooks },
|
|
21
|
+
{ kind: "file", dir: "", names: RETIRED.files },
|
|
20
22
|
];
|
|
21
23
|
const found = [];
|
|
22
24
|
for (const { kind, dir, names } of groups) {
|
package/dist/cli/profile.d.ts
CHANGED
|
@@ -144,7 +144,7 @@ export declare function applyProviderProfile(profile: Profile, data: ProviderPro
|
|
|
144
144
|
*/
|
|
145
145
|
export declare function mcpCatalogItems(data: McpProfileFile): SelectItem[];
|
|
146
146
|
/**
|
|
147
|
-
* The `claude mcp add` command
|
|
147
|
+
* The `claude mcp add` command guarded by `claude mcp get` (as the `mcp` capability emits).
|
|
148
148
|
* @param s Catalog entry.
|
|
149
149
|
* @param scope user -> `-s user`, project -> `-s local`.
|
|
150
150
|
* @returns Shell command.
|
package/dist/cli/profile.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// Opt-in profiles
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `--profile none` removes exactly the keys and files a profile wrote. index.ts composes
|
|
7
|
-
// the two passes; nothing here prompts.
|
|
1
|
+
// Opt-in profiles: `init --profile <id>` applies one data file from canonical/profiles/
|
|
2
|
+
// to an existing target (Claude Code env block, OpenCode provider block + notes file, or
|
|
3
|
+
// an MCP catalog). Every write is additive, previewed first, confirmed second, and
|
|
4
|
+
// recorded in the install manifest so `--profile none` removes exactly what a profile
|
|
5
|
+
// wrote. index.ts composes the two passes; nothing here prompts.
|
|
8
6
|
import fs from "node:fs";
|
|
9
7
|
import path from "node:path";
|
|
10
8
|
import { PROFILES, PROFILE_NONE, getProfile, tierEnv } from "@freeharness/core";
|
|
@@ -211,7 +209,7 @@ export function mcpCatalogItems(data) {
|
|
|
211
209
|
return data.servers.map((s) => ({ id: s.name, label: `~${s.tools_approx} tools — ${s.description} [${s.context_cost}]` }));
|
|
212
210
|
}
|
|
213
211
|
/**
|
|
214
|
-
* The `claude mcp add` command
|
|
212
|
+
* The `claude mcp add` command guarded by `claude mcp get` (as the `mcp` capability emits).
|
|
215
213
|
* @param s Catalog entry.
|
|
216
214
|
* @param scope user -> `-s user`, project -> `-s local`.
|
|
217
215
|
* @returns Shell command.
|
|
@@ -219,9 +217,11 @@ export function mcpCatalogItems(data) {
|
|
|
219
217
|
export function mcpAddCommand(s, scope) {
|
|
220
218
|
const sc = scope === "user" ? "user" : "local";
|
|
221
219
|
const envFlags = Object.entries(s.env ?? {}).map(([k, v]) => `-e ${k}=${v}`).join(" ");
|
|
220
|
+
// `claude mcp add` exits 1 for a registered name, so get runs first.
|
|
221
|
+
const guard = `claude mcp get ${s.name} >/dev/null 2>&1 || `;
|
|
222
222
|
if (s.type === "http" || s.type === "sse")
|
|
223
|
-
return
|
|
224
|
-
return
|
|
223
|
+
return `${guard}claude mcp add --transport ${s.type} ${s.name} -s ${sc} ${s.url}`;
|
|
224
|
+
return `${guard}claude mcp add ${s.name} -s ${sc}${envFlags ? ` ${envFlags}` : ""} -- ${s.command} ${(s.args ?? []).join(" ")}`;
|
|
225
225
|
}
|
|
226
226
|
/**
|
|
227
227
|
* Builds the MCP profile plan for a selection: one `claude mcp add` action per selected
|