talking-stick 0.2.0 → 0.4.0
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 +51 -49
- package/dist/cli/install-commands.js +83 -36
- package/dist/cli/instructions-commands.js +113 -0
- package/dist/cli/output.js +5 -2
- package/dist/cli/registry.js +18 -27
- package/dist/cli/room-commands.js +1 -1
- package/dist/cli/startup-maintenance.js +27 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +2 -2
- package/dist/identity.js +4 -4
- package/dist/index.js +3 -2
- package/dist/install-audit.js +21 -0
- package/dist/install-migration.js +84 -0
- package/dist/install.js +0 -69
- package/dist/instructions.js +256 -0
- package/dist/update-migration.js +135 -0
- package/docs/plans/2026-05-04-diff-walker-design.md +585 -0
- package/docs/plans/2026-05-05-cli-only-coordination.md +224 -0
- package/docs/plans/2026-05-06-harness-instructions-v6-converged.md +220 -0
- package/docs/plans/out-of-band-signaling-implementation.md +5 -5
- package/docs/receive-consumer-contract.md +8 -6
- package/docs/releases/0.3.0.md +77 -0
- package/docs/releases/0.4.0.md +71 -0
- package/docs/talking-stick-plan.md +3 -2
- package/package.json +4 -3
- package/scripts/postinstall-mcp-cleanup.cjs +25 -0
- package/skills/talking-stick/SKILL.md +132 -104
- package/dist/mcp-server.js +0 -244
- package/dist/server.js +0 -3
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { syncInstalledSkills } from "../skill-install.js";
|
|
2
|
+
import { runFirstRunMcpMigration } from "../update-migration.js";
|
|
3
|
+
import { detectInstallSource, resolveCurrentBinaryPath } from "../self-update.js";
|
|
2
4
|
import { isKnownHarnessCliEnv } from "./identity.js";
|
|
3
5
|
import { getCommand } from "./registry.js";
|
|
4
|
-
export function runStartupMaintenance(parsed, env = process.env) {
|
|
6
|
+
export async function runStartupMaintenance(parsed, cliEntryUrl, env = process.env) {
|
|
7
|
+
if (shouldRunFirstRunMcpMigration(parsed, cliEntryUrl, env)) {
|
|
8
|
+
try {
|
|
9
|
+
await runFirstRunMcpMigration({
|
|
10
|
+
installOptions: { env }
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Startup cleanup is best-effort. Explicit install, uninstall, and
|
|
15
|
+
// self-update paths surface cleanup failures directly.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
5
18
|
if (!shouldAutoSyncInstalledSkills(parsed, env)) {
|
|
6
19
|
return;
|
|
7
20
|
}
|
|
@@ -13,6 +26,19 @@ export function runStartupMaintenance(parsed, env = process.env) {
|
|
|
13
26
|
// unrelated tt command fail.
|
|
14
27
|
}
|
|
15
28
|
}
|
|
29
|
+
export function shouldRunFirstRunMcpMigration(parsed, cliEntryUrl, env = process.env) {
|
|
30
|
+
if (env.TALKING_STICK_DISABLE_MCP_MIGRATION?.trim()) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const command = getCommand(parsed.name);
|
|
34
|
+
if (!command?.startupMaintenance) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const source = detectInstallSource({
|
|
38
|
+
binaryPath: resolveCurrentBinaryPath(cliEntryUrl)
|
|
39
|
+
});
|
|
40
|
+
return source !== "dev" && source !== "unknown";
|
|
41
|
+
}
|
|
16
42
|
export function shouldAutoSyncInstalledSkills(parsed, env = process.env) {
|
|
17
43
|
if (env.TALKING_STICK_DISABLE_SKILL_SYNC?.trim()) {
|
|
18
44
|
return false;
|
package/dist/cli.js
CHANGED
|
@@ -11,10 +11,10 @@ import { runStartupMaintenance } from "./cli/startup-maintenance.js";
|
|
|
11
11
|
export { checkGuardianLiveness } from "./cli/guardian.js";
|
|
12
12
|
export { parseHandoffJson } from "./cli/handoff.js";
|
|
13
13
|
export { formatRelativeTime, shouldUseJson } from "./cli/output.js";
|
|
14
|
-
export { shouldAutoSyncInstalledSkills } from "./cli/startup-maintenance.js";
|
|
14
|
+
export { shouldAutoSyncInstalledSkills, shouldRunFirstRunMcpMigration } from "./cli/startup-maintenance.js";
|
|
15
15
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
16
16
|
const parsed = parseCommand(argv);
|
|
17
|
-
runStartupMaintenance(parsed);
|
|
17
|
+
await runStartupMaintenance(parsed, import.meta.url);
|
|
18
18
|
if (!parsed.name || parsed.name === "help" || parsed.name === "--help") {
|
|
19
19
|
printHelp();
|
|
20
20
|
return;
|
package/dist/config.js
CHANGED
|
@@ -4,9 +4,9 @@ export const defaultPolicy = {
|
|
|
4
4
|
ownerLeaseTtlMs: 45 * 60 * 1000,
|
|
5
5
|
heartbeatIntervalMs: 5 * 60 * 1000,
|
|
6
6
|
claimTtlMs: 20 * 60 * 1000,
|
|
7
|
-
waitForTurnMaxWaitMs:
|
|
7
|
+
waitForTurnMaxWaitMs: 110 * 1000,
|
|
8
8
|
waitForTurnPollMs: 250,
|
|
9
|
-
waitForEventsMaxWaitMs:
|
|
9
|
+
waitForEventsMaxWaitMs: 110 * 1000,
|
|
10
10
|
waitForEventsPollMs: 250,
|
|
11
11
|
waitForEventsBatchLimit: 100,
|
|
12
12
|
presenceTtlMs: 4 * 60 * 60 * 1000,
|
package/dist/identity.js
CHANGED
|
@@ -127,13 +127,13 @@ function harnessAgentId(harness, sessionId, hostId, username) {
|
|
|
127
127
|
function resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId, inspector) {
|
|
128
128
|
if (signal.sessionId)
|
|
129
129
|
return `harness:${signal.sessionId}`;
|
|
130
|
-
const terminalId = resolveTerminalSessionId(env);
|
|
131
|
-
if (terminalId)
|
|
132
|
-
return terminalId;
|
|
133
130
|
const harnessRoot = findHarnessRootInAncestry(signal.harness, parentPid, parentInspection, inspector);
|
|
134
131
|
if (harnessRoot) {
|
|
135
132
|
return `pid:${harnessRoot.pid}@${harnessRoot.startTime}`;
|
|
136
133
|
}
|
|
134
|
+
const terminalId = resolveTerminalSessionId(env);
|
|
135
|
+
if (terminalId)
|
|
136
|
+
return terminalId;
|
|
137
137
|
if (parentInspection?.startTime) {
|
|
138
138
|
return `pid:${parentPid}@${parentInspection.startTime}`;
|
|
139
139
|
}
|
|
@@ -224,7 +224,7 @@ function detectHarnessSignal(env) {
|
|
|
224
224
|
if (env.CLAUDECODE === "1") {
|
|
225
225
|
return {
|
|
226
226
|
harness: "claude",
|
|
227
|
-
sessionId:
|
|
227
|
+
sessionId: nonEmpty(env.CLAUDE_CODE_SESSION_ID),
|
|
228
228
|
pidHint: parsePositiveInteger(env.CMUX_CLAUDE_PID)
|
|
229
229
|
};
|
|
230
230
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,10 @@ export { applyPragmas, assertLocalFilesystem, detectFilesystemType, migrate, ope
|
|
|
4
4
|
export { ProtocolError, isProtocolError } from "./errors.js";
|
|
5
5
|
export { deriveHarnessCliIdentity, deriveHumanCliIdentity, deriveMcpHarnessIdentity } from "./identity.js";
|
|
6
6
|
export { ancestorPaths, canonicalizeContextPath, resolveContextPath, resolveWorkspaceRoot } from "./path-resolution.js";
|
|
7
|
-
export {
|
|
8
|
-
export { SUPPORTED_HARNESSES, MissingHarnessError, detectHarness, parseHarnessList,
|
|
7
|
+
export { DEFAULT_MAX_INSTRUCTION_FILE_BYTES, DEFAULT_INSTRUCTIONS_MARKDOWN, editInstructions, extractHarnessInstructions, normalizeInstructionHarness, parseInstructionScope, resetInstructions, resolveInstructionHarness, resolveInstructionPaths, showInstructions } from "./instructions.js";
|
|
8
|
+
export { SUPPORTED_HARNESSES, MissingHarnessError, detectHarness, parseHarnessList, planUninstall, resolveHarnessConfigDir, resolveOpencodeConfigDir, resolveOpencodeConfigPath, runAction, skipAction } from "./install.js";
|
|
9
9
|
export { DEFAULT_SKILL_NAME, planSkillInstall, planSkillUninstall, resolveBundledSkillPath, resolveSkillTargetPath, syncInstalledSkills } from "./skill-install.js";
|
|
10
|
+
export { readPackageVersion, readUpdateMigrationState, resolveUpdateMigrationStatePath, runFirstRunMcpMigration, runStaleMcpCleanup, writeUpdateMigrationState } from "./update-migration.js";
|
|
10
11
|
export { createSystemProcessInspector, terminateKnownProcess } from "./process-utils.js";
|
|
11
12
|
export { clearCliSessionLease, findCliSessionByRoom, findCliSessionForContextPath, readCliSessions, removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath, upsertCliSession, upsertJoinedCliSession, writeCliSessions } from "./session-store.js";
|
|
12
13
|
export { TalkingStickService, createDefaultProcessLivenessChecker } from "./service.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class FileAuditLog {
|
|
4
|
+
filePath;
|
|
5
|
+
constructor(filePath) {
|
|
6
|
+
this.filePath = filePath;
|
|
7
|
+
}
|
|
8
|
+
append(entry) {
|
|
9
|
+
const fullEntry = { ts: entry.ts ?? new Date().toISOString(), ...entry };
|
|
10
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
11
|
+
fs.appendFileSync(this.filePath, `${JSON.stringify(fullEntry)}\n`, "utf8");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class NoopAuditLog {
|
|
15
|
+
append() {
|
|
16
|
+
// intentionally blank
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function defaultAuditLogPath(dataDir) {
|
|
20
|
+
return path.join(dataDir, "update-migrations.log");
|
|
21
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { SUPPORTED_HARNESSES, planUninstall, runAction } from "./install.js";
|
|
2
|
+
import { NoopAuditLog } from "./install-audit.js";
|
|
3
|
+
export async function removeStaleMcpRegistrations(options) {
|
|
4
|
+
const audit = options.audit ?? new NoopAuditLog();
|
|
5
|
+
const strict = options.strict ?? true;
|
|
6
|
+
const harnesses = options.harnesses === undefined || options.harnesses === "all"
|
|
7
|
+
? [...SUPPORTED_HARNESSES]
|
|
8
|
+
: options.harnesses;
|
|
9
|
+
const installOptions = {
|
|
10
|
+
skipMissing: true,
|
|
11
|
+
...(options.installOptions ?? {})
|
|
12
|
+
};
|
|
13
|
+
const results = [];
|
|
14
|
+
for (const harness of harnesses) {
|
|
15
|
+
const result = await removeOneHarness(harness, installOptions, strict);
|
|
16
|
+
results.push(result);
|
|
17
|
+
audit.append({
|
|
18
|
+
reason: options.reason,
|
|
19
|
+
package_version_from: options.packageVersionFrom,
|
|
20
|
+
package_version_to: options.packageVersionTo,
|
|
21
|
+
harness,
|
|
22
|
+
config_path: result.configPath,
|
|
23
|
+
action: result.action,
|
|
24
|
+
server_name: result.serverName,
|
|
25
|
+
detail: result.message
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return results.map(({ harness, action, message }) => ({ harness, action, message }));
|
|
29
|
+
}
|
|
30
|
+
async function removeOneHarness(harness, installOptions, strict) {
|
|
31
|
+
const action = planUninstall(harness, installOptions);
|
|
32
|
+
if (action.kind === "skip") {
|
|
33
|
+
return {
|
|
34
|
+
harness,
|
|
35
|
+
action: "skipped",
|
|
36
|
+
message: action.message,
|
|
37
|
+
serverName: installOptions.serverName ?? "talking-stick"
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (action.kind === "file-patch") {
|
|
41
|
+
const state = action.inspect ? action.inspect() : "unknown";
|
|
42
|
+
const serverName = action.serverName ?? "talking-stick";
|
|
43
|
+
if (state === "absent") {
|
|
44
|
+
return {
|
|
45
|
+
harness,
|
|
46
|
+
action: "absent",
|
|
47
|
+
message: `${harness}: no Talking Stick MCP entry to remove`,
|
|
48
|
+
configPath: action.filePath,
|
|
49
|
+
serverName
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (strict && state !== "present") {
|
|
53
|
+
return {
|
|
54
|
+
harness,
|
|
55
|
+
action: "preserved",
|
|
56
|
+
message: `${harness}: hand-edited entry left alone (state=${state})`,
|
|
57
|
+
configPath: action.filePath,
|
|
58
|
+
serverName
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const installResult = await runAction(action, installOptions);
|
|
63
|
+
return mapInstallResult(harness, action, installResult);
|
|
64
|
+
}
|
|
65
|
+
function mapInstallResult(harness, action, result) {
|
|
66
|
+
let serverName = "talking-stick";
|
|
67
|
+
if ("serverName" in action && typeof action.serverName === "string") {
|
|
68
|
+
serverName = action.serverName;
|
|
69
|
+
}
|
|
70
|
+
const configPath = action.kind === "file-patch" ? action.filePath : undefined;
|
|
71
|
+
if (!result.ok) {
|
|
72
|
+
return { harness, action: "failed", message: result.message, configPath, serverName };
|
|
73
|
+
}
|
|
74
|
+
switch (result.status) {
|
|
75
|
+
case "already_absent":
|
|
76
|
+
return { harness, action: "absent", message: result.message, configPath, serverName };
|
|
77
|
+
case "removed":
|
|
78
|
+
return { harness, action: "removed", message: result.message, configPath, serverName };
|
|
79
|
+
case "skipped":
|
|
80
|
+
return { harness, action: "skipped", message: result.message, configPath, serverName };
|
|
81
|
+
default:
|
|
82
|
+
return { harness, action: "failed", message: result.message, configPath, serverName };
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -115,75 +115,6 @@ function resolveHarnessConfigDirFromResolved(harness, resolved) {
|
|
|
115
115
|
throw new Error(`Unknown harness: ${harness}`);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
-
export function planInstall(harness, options = {}) {
|
|
119
|
-
const resolved = resolveOptions(options);
|
|
120
|
-
const [serverBin, ...serverArgs] = resolved.serverCommand;
|
|
121
|
-
if (!serverBin)
|
|
122
|
-
throw new Error("serverCommand must include at least the binary");
|
|
123
|
-
switch (harness) {
|
|
124
|
-
case "claude-code":
|
|
125
|
-
if (resolved.skipMissing && !resolved.hooks.which("claude")) {
|
|
126
|
-
return skipAction(harness, "claude not on PATH");
|
|
127
|
-
}
|
|
128
|
-
return {
|
|
129
|
-
kind: "exec",
|
|
130
|
-
harness,
|
|
131
|
-
command: "claude",
|
|
132
|
-
args: ["mcp", "add", "-s", "user", resolved.serverName, "--", serverBin, ...serverArgs],
|
|
133
|
-
description: `claude mcp add -s user ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
|
|
134
|
-
operation: "install",
|
|
135
|
-
serverName: resolved.serverName,
|
|
136
|
-
serverCommand: resolved.serverCommand
|
|
137
|
-
};
|
|
138
|
-
case "codex":
|
|
139
|
-
if (resolved.skipMissing && !resolved.hooks.which("codex")) {
|
|
140
|
-
return skipAction(harness, "codex not on PATH");
|
|
141
|
-
}
|
|
142
|
-
return {
|
|
143
|
-
kind: "exec",
|
|
144
|
-
harness,
|
|
145
|
-
command: "codex",
|
|
146
|
-
args: ["mcp", "add", resolved.serverName, "--", serverBin, ...serverArgs],
|
|
147
|
-
description: `codex mcp add ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
|
|
148
|
-
operation: "install",
|
|
149
|
-
serverName: resolved.serverName,
|
|
150
|
-
serverCommand: resolved.serverCommand
|
|
151
|
-
};
|
|
152
|
-
case "gemini":
|
|
153
|
-
if (resolved.skipMissing && !resolved.hooks.which("gemini")) {
|
|
154
|
-
return skipAction(harness, "gemini not on PATH");
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
kind: "exec",
|
|
158
|
-
harness,
|
|
159
|
-
command: "gemini",
|
|
160
|
-
args: ["mcp", "add", "-s", "user", "-t", "stdio", resolved.serverName, serverBin, ...serverArgs],
|
|
161
|
-
description: `gemini mcp add -s user -t stdio ${resolved.serverName} ${resolved.serverCommand.join(" ")}`,
|
|
162
|
-
operation: "install",
|
|
163
|
-
serverName: resolved.serverName,
|
|
164
|
-
serverCommand: resolved.serverCommand
|
|
165
|
-
};
|
|
166
|
-
case "opencode": {
|
|
167
|
-
const filePath = resolveOpencodeConfigPath(options);
|
|
168
|
-
const configDir = path.dirname(filePath);
|
|
169
|
-
if (resolved.skipMissing && !resolved.hooks.pathExists(configDir)) {
|
|
170
|
-
return skipAction(harness, `opencode config directory not found: ${configDir}`);
|
|
171
|
-
}
|
|
172
|
-
return {
|
|
173
|
-
kind: "file-patch",
|
|
174
|
-
harness,
|
|
175
|
-
filePath,
|
|
176
|
-
description: `merge mcp.${resolved.serverName} into ${filePath}`,
|
|
177
|
-
operation: "install",
|
|
178
|
-
serverName: resolved.serverName,
|
|
179
|
-
inspect: () => inspectOpencodeConfig(filePath, resolved),
|
|
180
|
-
apply: () => patchOpencodeConfig(filePath, resolved, "install")
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
default:
|
|
184
|
-
throw new Error(`Unknown harness: ${harness}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
118
|
export function planUninstall(harness, options = {}) {
|
|
188
119
|
const resolved = resolveOptions(options);
|
|
189
120
|
switch (harness) {
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveDataDir } from "./config.js";
|
|
5
|
+
import { resolveContextPath } from "./path-resolution.js";
|
|
6
|
+
export const DEFAULT_MAX_INSTRUCTION_FILE_BYTES = 256 * 1024;
|
|
7
|
+
export const DEFAULT_INSTRUCTIONS_MARKDOWN = `# Talking Stick collaboration instructions
|
|
8
|
+
|
|
9
|
+
Keep using Talking Stick until the shared task is done. After releasing or handing off, re-enter the wait loop by default. Prefer continued action unless the task is complete or the operator explicitly redirects or stops the room.
|
|
10
|
+
|
|
11
|
+
Use phase names in handoffs when they clarify the work: draft, adversarial review, convergence, implementation, implementation review, test review, and release. These phases are vocabulary, not protocol state.
|
|
12
|
+
|
|
13
|
+
Typical fits are advisory. Claude is usually strong at prose, first-pass synthesis, tool-running, implementation review, and test review. Codex is usually strong at adversarial review, convergence, implementation, edge cases, and release mechanics after operator approval. Gemini and OpenCode start with conservative local guidance until project dogfood says otherwise.
|
|
14
|
+
|
|
15
|
+
For multi-agent design work, prefer independent read-only drafts first, then adversarial review and convergence. Do not impose a draft file structure on the workspace by default. If scratch draft files are useful, delete superseded pre-convergence drafts after the converged plan exists unless the operator asks to keep them.
|
|
16
|
+
|
|
17
|
+
Default to normal release handoffs. Use named assignment only when a specific member must go next because of unique context, credentials, capability, or direct operator routing.
|
|
18
|
+
|
|
19
|
+
## Claude
|
|
20
|
+
|
|
21
|
+
Lean into drafting, synthesis, tool-running, implementation review, and test review. Watch for scope creep and messy first-pass artifacts. When implementation belongs elsewhere, make the next phase explicit in the handoff.
|
|
22
|
+
|
|
23
|
+
## Codex
|
|
24
|
+
|
|
25
|
+
Lean into adversarial review, convergence, precise implementation, edge-case sweeps, and release mechanics after operator approval. Watch for over-indexing on mechanics when the operator still needs to decide direction.
|
|
26
|
+
|
|
27
|
+
## Gemini
|
|
28
|
+
|
|
29
|
+
Use broad context review and exploration conservatively until the project has stronger Gemini-specific dogfood. Keep handoffs concrete and do not assume responsibility that the operator assigned to another harness.
|
|
30
|
+
|
|
31
|
+
## OpenCode
|
|
32
|
+
|
|
33
|
+
Use terminal-native local exploration and implementation conservatively until the project has stronger OpenCode-specific dogfood. Keep coordination safety ahead of speed.
|
|
34
|
+
`;
|
|
35
|
+
const HARNESS_ALIASES = {
|
|
36
|
+
all: "all",
|
|
37
|
+
base: "all",
|
|
38
|
+
claude: "claude",
|
|
39
|
+
"claude-code": "claude",
|
|
40
|
+
codex: "codex",
|
|
41
|
+
gemini: "gemini",
|
|
42
|
+
opencode: "opencode"
|
|
43
|
+
};
|
|
44
|
+
export function resolveInstructionPaths(options = {}) {
|
|
45
|
+
const contextPath = options.contextPath ?? process.cwd();
|
|
46
|
+
const workspaceRoot = resolveContextPath(contextPath).workspace_root;
|
|
47
|
+
return {
|
|
48
|
+
user: path.join(resolveDataDir(options), "instructions.md"),
|
|
49
|
+
project: path.join(workspaceRoot, ".talking-stick", "instructions.md")
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function showInstructions(input = {}) {
|
|
53
|
+
const options = input.options ?? {};
|
|
54
|
+
const harness = resolveInstructionHarness(input.harness, options.identity);
|
|
55
|
+
const scope = input.scope ?? "effective";
|
|
56
|
+
const paths = resolveInstructionPaths(options);
|
|
57
|
+
const layers = readInstructionLayers(paths, options.maxInstructionFileBytes ?? DEFAULT_MAX_INSTRUCTION_FILE_BYTES);
|
|
58
|
+
const selectedLayers = selectLayers(scope, layers);
|
|
59
|
+
const text = joinInstructionTexts(selectedLayers.map((layer) => extractHarnessInstructions(layer.text, harness)));
|
|
60
|
+
return {
|
|
61
|
+
harness,
|
|
62
|
+
scope,
|
|
63
|
+
text,
|
|
64
|
+
sources: selectedLayers.map((layer) => ({
|
|
65
|
+
scope: layer.scope,
|
|
66
|
+
path: layer.path
|
|
67
|
+
})),
|
|
68
|
+
paths
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export async function editInstructions(input = {}) {
|
|
72
|
+
const scope = input.scope ?? "user";
|
|
73
|
+
const options = input.options ?? {};
|
|
74
|
+
const paths = resolveInstructionPaths(options);
|
|
75
|
+
const filePath = paths[scope];
|
|
76
|
+
const created = ensureInstructionFile(filePath);
|
|
77
|
+
const editor = chooseEditor(options);
|
|
78
|
+
if (!editor) {
|
|
79
|
+
return { scope, path: filePath, created, opened: false, editor: null };
|
|
80
|
+
}
|
|
81
|
+
await runEditor(editor, filePath);
|
|
82
|
+
return { scope, path: filePath, created, opened: true, editor };
|
|
83
|
+
}
|
|
84
|
+
export function resetInstructions(input) {
|
|
85
|
+
const paths = resolveInstructionPaths(input.options ?? {});
|
|
86
|
+
const filePath = paths[input.scope];
|
|
87
|
+
const removed = fs.existsSync(filePath);
|
|
88
|
+
if (removed) {
|
|
89
|
+
fs.rmSync(filePath, { force: true });
|
|
90
|
+
}
|
|
91
|
+
return { scope: input.scope, path: filePath, removed };
|
|
92
|
+
}
|
|
93
|
+
export function resolveInstructionHarness(explicitHarness, identity) {
|
|
94
|
+
if (explicitHarness) {
|
|
95
|
+
return normalizeInstructionHarness(explicitHarness);
|
|
96
|
+
}
|
|
97
|
+
const displayName = identity?.process_metadata.display_name ?? undefined;
|
|
98
|
+
const fromDisplay = displayName ? HARNESS_ALIASES[normalizeKey(displayName)] : undefined;
|
|
99
|
+
if (fromDisplay) {
|
|
100
|
+
return fromDisplay;
|
|
101
|
+
}
|
|
102
|
+
const prefix = identity?.agent_id.split(":")[0];
|
|
103
|
+
const fromPrefix = prefix ? HARNESS_ALIASES[normalizeKey(prefix)] : undefined;
|
|
104
|
+
return fromPrefix ?? "all";
|
|
105
|
+
}
|
|
106
|
+
export function normalizeInstructionHarness(value) {
|
|
107
|
+
const normalized = HARNESS_ALIASES[normalizeKey(value)];
|
|
108
|
+
if (!normalized) {
|
|
109
|
+
throw new Error(`--harness must be one of claude, codex, gemini, opencode, all (got ${value}).`);
|
|
110
|
+
}
|
|
111
|
+
return normalized;
|
|
112
|
+
}
|
|
113
|
+
export function parseInstructionScope(value) {
|
|
114
|
+
if (!value) {
|
|
115
|
+
return "effective";
|
|
116
|
+
}
|
|
117
|
+
if (value === "effective" ||
|
|
118
|
+
value === "bundled" ||
|
|
119
|
+
value === "user" ||
|
|
120
|
+
value === "project") {
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`--scope must be one of effective, bundled, user, project (got ${value}).`);
|
|
124
|
+
}
|
|
125
|
+
export function extractHarnessInstructions(markdown, harness) {
|
|
126
|
+
const trimmed = markdown.trim();
|
|
127
|
+
if (!trimmed || harness === "all") {
|
|
128
|
+
return trimmed;
|
|
129
|
+
}
|
|
130
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
131
|
+
const shared = [];
|
|
132
|
+
const sections = new Map();
|
|
133
|
+
let current = null;
|
|
134
|
+
let sawSection = false;
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const header = parseHarnessHeader(line);
|
|
137
|
+
if (header) {
|
|
138
|
+
sawSection = true;
|
|
139
|
+
current = header;
|
|
140
|
+
if (!sections.has(current)) {
|
|
141
|
+
sections.set(current, []);
|
|
142
|
+
}
|
|
143
|
+
sections.get(current)?.push(line);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!sawSection) {
|
|
147
|
+
shared.push(line);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (current) {
|
|
151
|
+
sections.get(current)?.push(line);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return joinInstructionTexts([
|
|
155
|
+
shared.join("\n").trim(),
|
|
156
|
+
sections.get(harness)?.join("\n").trim() ?? ""
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
function readInstructionLayers(paths, maxInstructionFileBytes) {
|
|
160
|
+
const layers = [
|
|
161
|
+
{ scope: "bundled", path: null, text: DEFAULT_INSTRUCTIONS_MARKDOWN }
|
|
162
|
+
];
|
|
163
|
+
for (const scope of ["user", "project"]) {
|
|
164
|
+
const filePath = paths[scope];
|
|
165
|
+
if (!fs.existsSync(filePath)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const stat = fs.statSync(filePath);
|
|
169
|
+
if (stat.size > maxInstructionFileBytes) {
|
|
170
|
+
throw new Error(`${scope} instructions file is too large (${stat.size} bytes, max ${maxInstructionFileBytes}): ${filePath}`);
|
|
171
|
+
}
|
|
172
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
173
|
+
layers.push({ scope, path: filePath, text });
|
|
174
|
+
}
|
|
175
|
+
return layers;
|
|
176
|
+
}
|
|
177
|
+
function selectLayers(scope, layers) {
|
|
178
|
+
if (scope === "effective") {
|
|
179
|
+
return layers;
|
|
180
|
+
}
|
|
181
|
+
return layers.filter((layer) => layer.scope === scope);
|
|
182
|
+
}
|
|
183
|
+
function joinInstructionTexts(parts) {
|
|
184
|
+
return parts
|
|
185
|
+
.map((part) => part.trim())
|
|
186
|
+
.filter((part) => part.length > 0)
|
|
187
|
+
.join("\n\n");
|
|
188
|
+
}
|
|
189
|
+
function ensureInstructionFile(filePath) {
|
|
190
|
+
if (fs.existsSync(filePath)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
194
|
+
fs.writeFileSync(filePath, DEFAULT_INSTRUCTIONS_MARKDOWN);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
function parseHarnessHeader(line) {
|
|
198
|
+
const match = line.match(/^##\s+(.+?)\s*$/);
|
|
199
|
+
if (!match) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const key = normalizeKey(match[1]);
|
|
203
|
+
if (key.startsWith("claude"))
|
|
204
|
+
return "claude";
|
|
205
|
+
if (key.startsWith("codex"))
|
|
206
|
+
return "codex";
|
|
207
|
+
if (key.startsWith("gemini"))
|
|
208
|
+
return "gemini";
|
|
209
|
+
if (key.startsWith("opencode"))
|
|
210
|
+
return "opencode";
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
function chooseEditor(options) {
|
|
214
|
+
const env = options.env ?? process.env;
|
|
215
|
+
const explicit = env.VISUAL?.trim() || env.EDITOR?.trim();
|
|
216
|
+
if (explicit) {
|
|
217
|
+
return explicit;
|
|
218
|
+
}
|
|
219
|
+
switch (options.platform ?? process.platform) {
|
|
220
|
+
case "darwin":
|
|
221
|
+
return "open -t";
|
|
222
|
+
case "win32":
|
|
223
|
+
return "notepad.exe";
|
|
224
|
+
default:
|
|
225
|
+
if (env.DISPLAY || env.WAYLAND_DISPLAY) {
|
|
226
|
+
return "xdg-open";
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function runEditor(editor, filePath) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const child = spawn(`${editor} ${shellQuote(filePath)}`, {
|
|
234
|
+
stdio: "inherit",
|
|
235
|
+
shell: true
|
|
236
|
+
});
|
|
237
|
+
child.on("error", reject);
|
|
238
|
+
child.on("close", (code) => {
|
|
239
|
+
if (code === 0) {
|
|
240
|
+
resolve();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
reject(new Error(`${editor} exited with code ${code}.`));
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function shellQuote(value) {
|
|
248
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
249
|
+
}
|
|
250
|
+
function normalizeKey(value) {
|
|
251
|
+
return value
|
|
252
|
+
.trim()
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
255
|
+
.replace(/^-+|-+$/g, "");
|
|
256
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { resolveDataDir } from "./config.js";
|
|
5
|
+
import { FileAuditLog, defaultAuditLogPath } from "./install-audit.js";
|
|
6
|
+
import { removeStaleMcpRegistrations } from "./install-migration.js";
|
|
7
|
+
export const UPDATE_MIGRATION_STATE_FILE = "update-migrations-state.json";
|
|
8
|
+
export async function runStaleMcpCleanup(options) {
|
|
9
|
+
const packageVersionTo = options.packageVersionTo ?? options.packageVersion ?? readPackageVersion();
|
|
10
|
+
const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
|
|
11
|
+
const statePath = resolveUpdateMigrationStatePath(dataDir);
|
|
12
|
+
const auditPath = defaultAuditLogPath(dataDir);
|
|
13
|
+
const audit = options.audit ?? new FileAuditLog(auditPath);
|
|
14
|
+
const results = await removeStaleMcpRegistrations({
|
|
15
|
+
harnesses: options.harnesses ?? "all",
|
|
16
|
+
reason: options.reason,
|
|
17
|
+
packageVersionFrom: options.packageVersionFrom,
|
|
18
|
+
packageVersionTo,
|
|
19
|
+
audit,
|
|
20
|
+
installOptions: options.installOptions
|
|
21
|
+
});
|
|
22
|
+
if (options.updateState !== false && !results.some((result) => result.action === "failed")) {
|
|
23
|
+
writeUpdateMigrationState(statePath, {
|
|
24
|
+
mcp_cleanup_version: packageVersionTo,
|
|
25
|
+
updated_at: new Date().toISOString()
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
status: "ran",
|
|
30
|
+
packageVersionFrom: options.packageVersionFrom,
|
|
31
|
+
packageVersionTo,
|
|
32
|
+
statePath,
|
|
33
|
+
auditPath,
|
|
34
|
+
results
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function runFirstRunMcpMigration(options = {}) {
|
|
38
|
+
const packageVersion = options.packageVersion ?? readPackageVersion();
|
|
39
|
+
const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
|
|
40
|
+
const statePath = resolveUpdateMigrationStatePath(dataDir);
|
|
41
|
+
const auditPath = defaultAuditLogPath(dataDir);
|
|
42
|
+
const state = readUpdateMigrationState(statePath);
|
|
43
|
+
if (state.mcp_cleanup_version === packageVersion) {
|
|
44
|
+
return {
|
|
45
|
+
status: "current",
|
|
46
|
+
packageVersion,
|
|
47
|
+
statePath,
|
|
48
|
+
auditPath,
|
|
49
|
+
results: []
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return runStaleMcpCleanup({
|
|
53
|
+
harnesses: "all",
|
|
54
|
+
reason: "first-run",
|
|
55
|
+
packageVersionFrom: state.mcp_cleanup_version,
|
|
56
|
+
packageVersionTo: packageVersion,
|
|
57
|
+
dataDir,
|
|
58
|
+
audit: options.audit,
|
|
59
|
+
installOptions: options.installOptions
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function resolveUpdateMigrationStatePath(dataDir) {
|
|
63
|
+
return path.join(dataDir, UPDATE_MIGRATION_STATE_FILE);
|
|
64
|
+
}
|
|
65
|
+
export function readUpdateMigrationState(statePath) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = fs.readFileSync(statePath, "utf8");
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!isPlainObject(parsed))
|
|
70
|
+
return {};
|
|
71
|
+
return {
|
|
72
|
+
mcp_cleanup_version: typeof parsed.mcp_cleanup_version === "string"
|
|
73
|
+
? parsed.mcp_cleanup_version
|
|
74
|
+
: undefined,
|
|
75
|
+
updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : undefined
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === "ENOENT")
|
|
80
|
+
return {};
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function writeUpdateMigrationState(statePath, state) {
|
|
85
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
86
|
+
const tmpPath = `${statePath}.${process.pid}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
88
|
+
fs.renameSync(tmpPath, statePath);
|
|
89
|
+
}
|
|
90
|
+
export function readPackageVersion(startUrl = import.meta.url) {
|
|
91
|
+
const root = findPackageRoot(fileURLToPath(startUrl));
|
|
92
|
+
if (!root)
|
|
93
|
+
return "unknown";
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(path.join(root, "package.json"), "utf8");
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
return typeof parsed.version === "string" && parsed.version.trim()
|
|
98
|
+
? parsed.version
|
|
99
|
+
: "unknown";
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return "unknown";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function resolveMigrationDataDir(installOptions) {
|
|
106
|
+
const options = {
|
|
107
|
+
env: installOptions?.env,
|
|
108
|
+
platform: installOptions?.platform,
|
|
109
|
+
homeDir: installOptions?.homeDir
|
|
110
|
+
};
|
|
111
|
+
return resolveDataDir(options);
|
|
112
|
+
}
|
|
113
|
+
function findPackageRoot(startPath) {
|
|
114
|
+
let current;
|
|
115
|
+
try {
|
|
116
|
+
current = fs.statSync(startPath).isDirectory()
|
|
117
|
+
? startPath
|
|
118
|
+
: path.dirname(startPath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
current = path.dirname(startPath);
|
|
122
|
+
}
|
|
123
|
+
while (true) {
|
|
124
|
+
const candidate = path.join(current, "package.json");
|
|
125
|
+
if (fs.existsSync(candidate))
|
|
126
|
+
return current;
|
|
127
|
+
const parent = path.dirname(current);
|
|
128
|
+
if (parent === current)
|
|
129
|
+
return null;
|
|
130
|
+
current = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function isPlainObject(value) {
|
|
134
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
135
|
+
}
|