opencode-resolve 0.1.7 → 0.1.8
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.ko.md +98 -53
- package/README.md +98 -53
- package/dist/agents.d.ts +26 -0
- package/dist/agents.js +355 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +398 -0
- package/dist/hooks/index.d.ts +18 -0
- package/dist/hooks/index.js +493 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +11 -702
- package/dist/state.d.ts +33 -0
- package/dist/state.js +20 -0
- package/dist/tools/index.d.ts +252 -0
- package/dist/tools/index.js +1209 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.js +371 -0
- package/opencode-resolve.example.json +5 -2
- package/opencode-resolve.reference.jsonc +78 -19
- package/package.json +6 -2
- package/scripts/postinstall.mjs +193 -31
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { stat, readFile, access } from "node:fs/promises";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { classifyBashCommand, runCommand, sanitizeShellArg, truncateOutput } from "../utils.js";
|
|
6
|
+
import { DIAGNOSTICS_TTL_MS } from "../state.js";
|
|
7
|
+
import { VALID_PROFILES, VALID_TIERS, VALID_AGENT_NAME_SET, VALID_AGENT_NAMES } from "../agents.js";
|
|
8
|
+
const WRITE_CAPABLE_AGENTS = new Set(["resolver", "coder", "glm", "gpt-coder", "debugger"]);
|
|
9
|
+
function canWriteFromTool(ctx) {
|
|
10
|
+
return typeof ctx.agent !== "string" || WRITE_CAPABLE_AGENTS.has(ctx.agent);
|
|
11
|
+
}
|
|
12
|
+
function readOnlyToolWriteDenied(ctx, action) {
|
|
13
|
+
return `Permission denied: agent '${ctx.agent ?? "unknown"}' is read-only and cannot ${action}. Dispatch resolver/coder for workspace writes.`;
|
|
14
|
+
}
|
|
15
|
+
function commandExecutionDenied(command) {
|
|
16
|
+
const action = classifyBashCommand(command);
|
|
17
|
+
if (action === "allow")
|
|
18
|
+
return undefined;
|
|
19
|
+
if (action === "deny") {
|
|
20
|
+
return `Command denied by opencode-resolve safety policy: ${command}`;
|
|
21
|
+
}
|
|
22
|
+
return `Command is not allowlisted for direct tool execution: ${command}. Run it through OpenCode bash so the normal permission flow can decide.`;
|
|
23
|
+
}
|
|
24
|
+
export function getTools(sessionState) {
|
|
25
|
+
return {
|
|
26
|
+
"resolve-verify": tool({
|
|
27
|
+
description: "Run project verification commands (typecheck, lint, test) and return results. Use after editing files to confirm correctness.",
|
|
28
|
+
args: {
|
|
29
|
+
command: tool.schema.string().optional().describe("Specific verify command to run. If omitted, runs the first detected verify command (e.g. typecheck or lint)."),
|
|
30
|
+
},
|
|
31
|
+
async execute(args, ctx) {
|
|
32
|
+
const projCtx = sessionState.storedProjectContext;
|
|
33
|
+
if (!projCtx || projCtx.verifyCommands.length === 0) {
|
|
34
|
+
return "No verify commands detected for this project. Add typecheck/lint/test scripts to package.json.";
|
|
35
|
+
}
|
|
36
|
+
const cmd = args.command ?? projCtx.verifyCommands[0];
|
|
37
|
+
const denied = commandExecutionDenied(cmd);
|
|
38
|
+
if (denied)
|
|
39
|
+
return denied;
|
|
40
|
+
try {
|
|
41
|
+
const result = await runCommand(cmd, ctx.directory, 30_000);
|
|
42
|
+
ctx.metadata({ title: `verify: ${cmd}` });
|
|
43
|
+
if (result.exitCode === 0) {
|
|
44
|
+
return { output: `✅ ${cmd} passed.\n${truncateOutput(result.stdout, 500)}`, metadata: { exitCode: 0 } };
|
|
45
|
+
}
|
|
46
|
+
return { output: `❌ ${cmd} failed (exit ${result.exitCode}).\n${truncateOutput(result.stderr || result.stdout, 1000)}`, metadata: { exitCode: result.exitCode } };
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return `⚠️ Failed to run '${cmd}': ${err instanceof Error ? err.message : String(err)}`;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
"resolve-diagnostics": tool({
|
|
54
|
+
description: "Get current LSP diagnostics snapshot. Returns errors and warnings per file from the language server.",
|
|
55
|
+
args: {
|
|
56
|
+
path: tool.schema.string().optional().describe("Specific file path to check. If omitted, returns all files with active diagnostics."),
|
|
57
|
+
},
|
|
58
|
+
async execute(args) {
|
|
59
|
+
if (sessionState.recentDiagnostics.size === 0) {
|
|
60
|
+
return "No active LSP diagnostics.";
|
|
61
|
+
}
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (const [filePath, diag] of sessionState.recentDiagnostics) {
|
|
65
|
+
if (now - diag.timestamp > DIAGNOSTICS_TTL_MS)
|
|
66
|
+
continue;
|
|
67
|
+
if (args.path && filePath !== args.path)
|
|
68
|
+
continue;
|
|
69
|
+
entries.push(`${filePath}: ${diag.errors} errors, ${diag.warnings} warnings`);
|
|
70
|
+
}
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
return args.path ? `No active diagnostics for ${args.path}.` : "No active LSP diagnostics.";
|
|
73
|
+
}
|
|
74
|
+
return entries.join("\n");
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
"resolve-context": tool({
|
|
78
|
+
description: "Get detected project context: knowledge files, verify commands, package manager, TypeScript status.",
|
|
79
|
+
args: {},
|
|
80
|
+
async execute() {
|
|
81
|
+
const ctx = sessionState.storedProjectContext;
|
|
82
|
+
if (!ctx)
|
|
83
|
+
return "No project context detected.";
|
|
84
|
+
const lines = [];
|
|
85
|
+
if (ctx.knowledgeFiles.length > 0)
|
|
86
|
+
lines.push(`Knowledge files: ${ctx.knowledgeFiles.join(", ")}`);
|
|
87
|
+
if (ctx.contextFiles.length > 0)
|
|
88
|
+
lines.push(`Context docs: ${ctx.contextFiles.join(", ")}`);
|
|
89
|
+
if (ctx.verifyCommands.length > 0)
|
|
90
|
+
lines.push(`Verify commands: ${ctx.verifyCommands.join("; ")}`);
|
|
91
|
+
if (ctx.packageManager)
|
|
92
|
+
lines.push(`Package manager: ${ctx.packageManager}`);
|
|
93
|
+
if (ctx.hasTypeScript)
|
|
94
|
+
lines.push("TypeScript: yes");
|
|
95
|
+
if (ctx.hasHarness)
|
|
96
|
+
lines.push("HARNESS.md: present");
|
|
97
|
+
if (ctx.hasAgents)
|
|
98
|
+
lines.push("AGENTS.md: present");
|
|
99
|
+
return lines.length > 0 ? lines.join("\n") : "Empty project — no context detected.";
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
"resolve-git-status": tool({
|
|
103
|
+
description: "Get git status summary: branch, staged/unstaged/untracked file counts, and short diff stat.",
|
|
104
|
+
args: {},
|
|
105
|
+
async execute(_args, ctx) {
|
|
106
|
+
try {
|
|
107
|
+
const branch = await runCommand("git rev-parse --abbrev-ref HEAD", ctx.directory, 5_000);
|
|
108
|
+
const status = await runCommand("git status --porcelain", ctx.directory, 5_000);
|
|
109
|
+
const diffStat = await runCommand("git diff --stat", ctx.directory, 5_000);
|
|
110
|
+
const lines = [
|
|
111
|
+
`Branch: ${branch.stdout.trim()}`,
|
|
112
|
+
`Changed files: ${status.stdout.trim().split("\n").filter(Boolean).length}`,
|
|
113
|
+
];
|
|
114
|
+
if (diffStat.stdout.trim()) {
|
|
115
|
+
lines.push(`Diff:\n${truncateOutput(diffStat.stdout, 500)}`);
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return "Not a git repository or git unavailable.";
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
"resolve-deps": tool({
|
|
125
|
+
description: "List dependencies and devDependencies from package.json with version info.",
|
|
126
|
+
args: {
|
|
127
|
+
dev: tool.schema.boolean().optional().describe("If true, show devDependencies only. If false/omitted, show dependencies."),
|
|
128
|
+
},
|
|
129
|
+
async execute(args, ctx) {
|
|
130
|
+
try {
|
|
131
|
+
const pkgRaw = await readFile(join(ctx.directory, "package.json"), "utf8");
|
|
132
|
+
const pkg = JSON.parse(pkgRaw);
|
|
133
|
+
const section = args.dev ? pkg.devDependencies : pkg.dependencies;
|
|
134
|
+
if (!section || Object.keys(section).length === 0) {
|
|
135
|
+
return args.dev ? "No devDependencies found." : "No dependencies found.";
|
|
136
|
+
}
|
|
137
|
+
return Object.entries(section).map(([name, ver]) => `${name}: ${ver}`).join("\n");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return "No package.json found or unreadable.";
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
"resolve-search": tool({
|
|
145
|
+
description: "Search codebase with ripgrep. Returns matching file paths, line numbers, and content. Faster and more targeted than grep tool.",
|
|
146
|
+
args: {
|
|
147
|
+
query: tool.schema.string().describe("Search pattern (regex supported)."),
|
|
148
|
+
glob: tool.schema.string().optional().describe("File glob filter (e.g. '*.ts', '*.{ts,tsx}')."),
|
|
149
|
+
max_results: tool.schema.number().optional().describe("Max results to return (default 30)."),
|
|
150
|
+
},
|
|
151
|
+
async execute(args, ctx) {
|
|
152
|
+
const maxResults = Math.min(args.max_results ?? 30, 100);
|
|
153
|
+
let cmd = `rg --no-heading --line-number --max-count ${maxResults} --color never`;
|
|
154
|
+
if (args.glob)
|
|
155
|
+
cmd += ` --glob '${sanitizeShellArg(args.glob)}'`;
|
|
156
|
+
cmd += ` '${sanitizeShellArg(args.query)}' .`;
|
|
157
|
+
try {
|
|
158
|
+
const result = await runCommand(cmd, ctx.directory, 15_000);
|
|
159
|
+
if (result.exitCode === 1)
|
|
160
|
+
return "No matches found.";
|
|
161
|
+
if (result.exitCode !== 0)
|
|
162
|
+
return `Search error: ${truncateOutput(result.stderr, 300)}`;
|
|
163
|
+
const lines = result.stdout.trim().split("\n").slice(0, maxResults);
|
|
164
|
+
ctx.metadata({ title: `search: ${args.query} (${lines.length} results)` });
|
|
165
|
+
return truncateOutput(lines.join("\n"), 3000);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
"resolve-test": tool({
|
|
173
|
+
description: "Run specific test file(s) or test pattern. Detects test runner from project context (npm/yarn/pnpm/bun).",
|
|
174
|
+
args: {
|
|
175
|
+
file: tool.schema.string().optional().describe("Test file path or glob pattern (e.g. 'test/plugin.test.mjs')."),
|
|
176
|
+
pattern: tool.schema.string().optional().describe("Test name pattern to filter (e.g. 'GLM profile')."),
|
|
177
|
+
runner: tool.schema.string().optional().describe("Override test runner command (e.g. 'vitest run', 'jest')."),
|
|
178
|
+
},
|
|
179
|
+
async execute(args, ctx) {
|
|
180
|
+
const projCtx = sessionState.storedProjectContext;
|
|
181
|
+
// Determine test command
|
|
182
|
+
let testCmd = args.runner;
|
|
183
|
+
if (!testCmd) {
|
|
184
|
+
// Find test runner from verify commands or package manager
|
|
185
|
+
const testVerify = projCtx?.verifyCommands.find(c => /\btest\b/.test(c));
|
|
186
|
+
if (testVerify) {
|
|
187
|
+
testCmd = testVerify;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const pm = projCtx?.packageManager ?? "npm";
|
|
191
|
+
testCmd = `${pm} test`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Append file filter
|
|
195
|
+
if (args.file)
|
|
196
|
+
testCmd += ` '${sanitizeShellArg(args.file)}'`;
|
|
197
|
+
// Append pattern filter
|
|
198
|
+
if (args.pattern) {
|
|
199
|
+
const safePattern = sanitizeShellArg(args.pattern);
|
|
200
|
+
if (testCmd.includes("vitest"))
|
|
201
|
+
testCmd += ` -t '${safePattern}'`;
|
|
202
|
+
else if (testCmd.includes("jest"))
|
|
203
|
+
testCmd += ` -t '${safePattern}'`;
|
|
204
|
+
else
|
|
205
|
+
testCmd += ` --grep '${safePattern}'`;
|
|
206
|
+
}
|
|
207
|
+
const denied = commandExecutionDenied(testCmd);
|
|
208
|
+
if (denied)
|
|
209
|
+
return denied;
|
|
210
|
+
try {
|
|
211
|
+
const result = await runCommand(testCmd, ctx.directory, 60_000);
|
|
212
|
+
ctx.metadata({ title: `test: ${args.file ?? "all"}${args.pattern ? ` /${args.pattern}/` : ""}` });
|
|
213
|
+
if (result.exitCode === 0) {
|
|
214
|
+
return { output: `✅ Tests passed.\n${truncateOutput(result.stdout, 800)}`, metadata: { exitCode: 0 } };
|
|
215
|
+
}
|
|
216
|
+
return { output: `❌ Tests failed (exit ${result.exitCode}).\n${truncateOutput(result.stderr || result.stdout, 1500)}`, metadata: { exitCode: result.exitCode } };
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return `⚠️ Test runner failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
"resolve-pattern": tool({
|
|
224
|
+
description: "Detect code anti-patterns in specified files. Scans for: 'as any', '@ts-ignore', '@ts-nocheck', empty catch blocks, console.log, TODO/FIXME, and large functions.",
|
|
225
|
+
args: {
|
|
226
|
+
paths: tool.schema.string().optional().describe("File or directory paths to scan (space-separated). Defaults to 'src/'."),
|
|
227
|
+
checks: tool.schema.array(tool.schema.string()).optional().describe("Specific checks to run: 'as-any', 'ts-ignore', 'empty-catch', 'console-log', 'todo', 'large-functions'. Default: all."),
|
|
228
|
+
},
|
|
229
|
+
async execute(args, ctx) {
|
|
230
|
+
const targets = args.paths ?? "src/";
|
|
231
|
+
const safeTargets = targets.split(" ").map(t => `'${sanitizeShellArg(t)}'`).join(" ");
|
|
232
|
+
const allChecks = ["as-any", "ts-ignore", "empty-catch", "console-log", "todo", "large-functions"];
|
|
233
|
+
const checks = (args.checks?.length ? args.checks : allChecks);
|
|
234
|
+
const patterns = {
|
|
235
|
+
"as-any": { regex: "\\bas\\s+any\\b", label: "as any" },
|
|
236
|
+
"ts-ignore": { regex: "@ts-(?:ignore|nocheck|expect-error)", label: "@ts-ignore/@ts-nocheck" },
|
|
237
|
+
"empty-catch": { regex: "catch\\s*\\([^)]*\\)\\s*\\{\\s*\\}", label: "empty catch" },
|
|
238
|
+
"console-log": { regex: "console\\.log\\(", label: "console.log" },
|
|
239
|
+
"todo": { regex: "\\b(?:TODO|FIXME|HACK|XXX)\\b", label: "TODO/FIXME" },
|
|
240
|
+
};
|
|
241
|
+
const results = [];
|
|
242
|
+
for (const check of checks) {
|
|
243
|
+
if (check === "large-functions") {
|
|
244
|
+
// Find files over 300 lines
|
|
245
|
+
try {
|
|
246
|
+
const wc = await runCommand(`find ${safeTargets} -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.mjs' | head -50 | xargs wc -l 2>/dev/null | sort -rn | head -20`, ctx.directory, 10_000);
|
|
247
|
+
if (wc.exitCode === 0) {
|
|
248
|
+
const bigFiles = wc.stdout.trim().split("\n").filter(l => {
|
|
249
|
+
const num = parseInt(l.trim());
|
|
250
|
+
return !isNaN(num) && num > 300;
|
|
251
|
+
});
|
|
252
|
+
if (bigFiles.length > 0)
|
|
253
|
+
results.push(`Large files (>300 lines):\n${bigFiles.join("\n")}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch { /* skip */ }
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const p = patterns[check];
|
|
260
|
+
if (!p)
|
|
261
|
+
continue;
|
|
262
|
+
try {
|
|
263
|
+
const rg = await runCommand(`rg --no-heading --line-number --color never '${p.regex}' ${safeTargets} 2>/dev/null | head -20`, ctx.directory, 10_000);
|
|
264
|
+
if (rg.exitCode === 0 && rg.stdout.trim()) {
|
|
265
|
+
const count = rg.stdout.trim().split("\n").length;
|
|
266
|
+
results.push(`${p.label} (${count} found):\n${truncateOutput(rg.stdout.trim(), 800)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch { /* skip */ }
|
|
270
|
+
}
|
|
271
|
+
ctx.metadata({ title: `pattern scan: ${checks.join(", ")}${results.length > 0 ? ` (${results.length} issues)` : " (clean)"}` });
|
|
272
|
+
return results.length > 0 ? results.join("\n\n") : "No anti-patterns detected. ✅";
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
"resolve-complexity": tool({
|
|
276
|
+
description: "Analyze file complexity: line count, import count, export count, and function count. Helps identify files that may need refactoring.",
|
|
277
|
+
args: {
|
|
278
|
+
paths: tool.schema.string().optional().describe("File or directory paths to analyze (space-separated). Defaults to 'src/'."),
|
|
279
|
+
threshold: tool.schema.number().optional().describe("Only show files with more than this many lines (default 50)."),
|
|
280
|
+
},
|
|
281
|
+
async execute(args, ctx) {
|
|
282
|
+
const targets = args.paths ?? "src/";
|
|
283
|
+
const safeTargets = targets.split(" ").map(t => `'${sanitizeShellArg(t)}'`).join(" ");
|
|
284
|
+
const threshold = args.threshold ?? 50;
|
|
285
|
+
try {
|
|
286
|
+
const result = await runCommand(`find ${safeTargets} -type f \\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.mjs' \\) | head -100 | xargs wc -l 2>/dev/null | sort -rn | head -30`, ctx.directory, 10_000);
|
|
287
|
+
if (result.exitCode !== 0 || !result.stdout.trim())
|
|
288
|
+
return "No source files found.";
|
|
289
|
+
const lines = result.stdout.trim().split("\n").filter(l => {
|
|
290
|
+
const num = parseInt(l.trim());
|
|
291
|
+
return !isNaN(num) && num >= threshold;
|
|
292
|
+
});
|
|
293
|
+
// Enrich with import/export/function counts for top files
|
|
294
|
+
const enriched = [];
|
|
295
|
+
for (const line of lines.slice(0, 10)) {
|
|
296
|
+
const parts = line.trim().split(/\s+/);
|
|
297
|
+
const lineCount = parseInt(parts[0]);
|
|
298
|
+
const filePath = parts.slice(1).join(" ");
|
|
299
|
+
if (!filePath || filePath === "total") {
|
|
300
|
+
enriched.push(line);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const imports = await runCommand(`grep -c '\\bimport\\b\\|\\brequire(' '${filePath}' 2>/dev/null || echo 0`, ctx.directory, 5_000);
|
|
305
|
+
const exports = await runCommand(`grep -c '\\bexport\\b' '${filePath}' 2>/dev/null || echo 0`, ctx.directory, 5_000);
|
|
306
|
+
const fns = await runCommand(`grep -cE '\\bfunction\\b|=>\\s*[{(]|\\basync\\b' '${filePath}' 2>/dev/null || echo 0`, ctx.directory, 5_000);
|
|
307
|
+
enriched.push(`${filePath}: ${lineCount} lines, ${imports.stdout.trim()} imports, ${exports.stdout.trim()} exports, ~${fns.stdout.trim()} functions`);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
enriched.push(`${filePath}: ${lineCount} lines`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
ctx.metadata({ title: `complexity: ${enriched.length} files analyzed` });
|
|
314
|
+
return enriched.length > 0 ? enriched.join("\n") : `All files under ${threshold} lines. ✅`;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return `Analysis failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
"resolve-file-info": tool({
|
|
322
|
+
description: "Get file metadata quickly: size, last modified, line count, language, and whether it's tracked by git. Faster than reading full file contents.",
|
|
323
|
+
args: {
|
|
324
|
+
path: tool.schema.string().describe("File path to inspect."),
|
|
325
|
+
},
|
|
326
|
+
async execute(args, ctx) {
|
|
327
|
+
const filePath = resolve(ctx.directory, args.path);
|
|
328
|
+
try {
|
|
329
|
+
const s = await stat(filePath);
|
|
330
|
+
if (!s.isFile())
|
|
331
|
+
return `${args.path}: not a file.`;
|
|
332
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
333
|
+
const langMap = {
|
|
334
|
+
ts: "TypeScript", tsx: "TypeScript (JSX)", js: "JavaScript", mjs: "JavaScript (ESM)",
|
|
335
|
+
json: "JSON", md: "Markdown", yml: "YAML", yaml: "YAML", py: "Python",
|
|
336
|
+
go: "Go", rs: "Rust", java: "Java", rb: "Ruby", sh: "Shell", css: "CSS", html: "HTML",
|
|
337
|
+
};
|
|
338
|
+
const lines = [
|
|
339
|
+
`Path: ${args.path}`,
|
|
340
|
+
`Size: ${s.size} bytes`,
|
|
341
|
+
`Modified: ${s.mtime.toISOString()}`,
|
|
342
|
+
`Language: ${langMap[ext] ?? ext.toUpperCase()}`,
|
|
343
|
+
];
|
|
344
|
+
// Quick line count — pure Node.js, no shell injection risk
|
|
345
|
+
try {
|
|
346
|
+
const content = await readFile(filePath, "utf8");
|
|
347
|
+
const lineCount = content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
348
|
+
lines.push(`Lines: ${lineCount}`);
|
|
349
|
+
}
|
|
350
|
+
catch { /* skip */ }
|
|
351
|
+
// Git tracked? — sanitize path to prevent shell injection
|
|
352
|
+
try {
|
|
353
|
+
const safePath = sanitizeShellArg(filePath);
|
|
354
|
+
const git = await runCommand(`git ls-files --error-unmatch '${safePath}' 2>/dev/null`, ctx.directory, 3_000);
|
|
355
|
+
lines.push(`Git: ${git.exitCode === 0 ? "tracked" : "untracked"}`);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
lines.push("Git: not a git repo");
|
|
359
|
+
}
|
|
360
|
+
ctx.metadata({ title: `file-info: ${args.path}` });
|
|
361
|
+
return lines.join("\n");
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return `File not found: ${args.path}`;
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
}),
|
|
368
|
+
"resolve-outdated": tool({
|
|
369
|
+
description: "Check which dependencies are outdated by comparing package.json versions against npm registry. Returns current vs latest for each package.",
|
|
370
|
+
args: {
|
|
371
|
+
dev: tool.schema.boolean().optional().describe("Check devDependencies instead of dependencies."),
|
|
372
|
+
filter: tool.schema.string().optional().describe("Only check packages matching this prefix (e.g. '@opencode-ai')."),
|
|
373
|
+
},
|
|
374
|
+
async execute(args, ctx) {
|
|
375
|
+
try {
|
|
376
|
+
const pkgRaw = await readFile(join(ctx.directory, "package.json"), "utf8");
|
|
377
|
+
const pkg = JSON.parse(pkgRaw);
|
|
378
|
+
const section = args.dev ? pkg.devDependencies : pkg.dependencies;
|
|
379
|
+
if (!section || Object.keys(section).length === 0) {
|
|
380
|
+
return args.dev ? "No devDependencies." : "No dependencies.";
|
|
381
|
+
}
|
|
382
|
+
const entries = Object.entries(section)
|
|
383
|
+
.filter(([name]) => !args.filter || name.startsWith(args.filter))
|
|
384
|
+
.slice(0, 20); // limit checks to avoid flooding npm
|
|
385
|
+
if (entries.length === 0)
|
|
386
|
+
return "No matching packages.";
|
|
387
|
+
const results = [];
|
|
388
|
+
// Batch check with npm outdated (fast, single command)
|
|
389
|
+
const pkgNames = entries.map(([name]) => `"${name}"`).join(" ");
|
|
390
|
+
const outdated = await runCommand(`npm outdated ${pkgNames} --json --long 2>/dev/null || true`, ctx.directory, 30_000);
|
|
391
|
+
if (outdated.stdout.trim()) {
|
|
392
|
+
try {
|
|
393
|
+
const data = JSON.parse(outdated.stdout);
|
|
394
|
+
for (const [name, info] of Object.entries(data)) {
|
|
395
|
+
results.push(`${name}: ${info.current ?? "?"} → ${info.latest ?? "?"}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// fallback: show raw
|
|
400
|
+
results.push(truncateOutput(outdated.stdout, 500));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
ctx.metadata({ title: `outdated: ${results.length} packages checked` });
|
|
404
|
+
return results.length > 0 ? `Outdated packages:\n${results.join("\n")}` : "All checked packages are up to date. ✅";
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return "No package.json found or npm unavailable.";
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
"resolve-readme": tool({
|
|
412
|
+
description: "Extract key information from project README: description, setup instructions, dependencies, and architecture notes. Saves reading the full file.",
|
|
413
|
+
args: {
|
|
414
|
+
max_length: tool.schema.number().optional().describe("Max summary length (default 2000)."),
|
|
415
|
+
},
|
|
416
|
+
async execute(args, ctx) {
|
|
417
|
+
const maxLen = args.max_length ?? 2000;
|
|
418
|
+
// Try common README locations
|
|
419
|
+
for (const name of ["README.md", "readme.md", "README.MD", "README", "README.txt"]) {
|
|
420
|
+
const filePath = join(ctx.directory, name);
|
|
421
|
+
try {
|
|
422
|
+
const content = await readFile(filePath, "utf8");
|
|
423
|
+
if (!content.trim())
|
|
424
|
+
continue;
|
|
425
|
+
// Extract structured info: first heading, first paragraph, any ## sections
|
|
426
|
+
const lines = content.split("\n");
|
|
427
|
+
const heading = lines.find(l => l.startsWith("#"));
|
|
428
|
+
const sections = [];
|
|
429
|
+
let currentSection = [];
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
if (line.startsWith("## ")) {
|
|
432
|
+
if (currentSection.length > 0) {
|
|
433
|
+
sections.push(currentSection.join("\n").trim());
|
|
434
|
+
}
|
|
435
|
+
currentSection = [line];
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
currentSection.push(line);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (currentSection.length > 0)
|
|
442
|
+
sections.push(currentSection.join("\n").trim());
|
|
443
|
+
// Build summary
|
|
444
|
+
const summaryParts = [];
|
|
445
|
+
if (heading)
|
|
446
|
+
summaryParts.push(heading);
|
|
447
|
+
// Extract key sections
|
|
448
|
+
for (const section of sections) {
|
|
449
|
+
const sectionLines = section.split("\n");
|
|
450
|
+
const title = sectionLines[0];
|
|
451
|
+
const keySections = /install|setup|usage|architect|config|getting.start|require|depend/i;
|
|
452
|
+
if (keySections.test(title)) {
|
|
453
|
+
summaryParts.push(section.slice(0, 500).trim());
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const summary = summaryParts.join("\n\n");
|
|
457
|
+
ctx.metadata({ title: `readme: ${name}` });
|
|
458
|
+
return truncateOutput(summary, maxLen) || "README exists but is empty or unparseable.";
|
|
459
|
+
}
|
|
460
|
+
catch { /* not found, try next */ }
|
|
461
|
+
}
|
|
462
|
+
return "No README found in project root.";
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
"resolve-init": tool({
|
|
466
|
+
description: "Initialize opencode-resolve config files for the project. Creates resolve.json with detected settings, and optionally HARNESS.md + AGENTS.md scaffolds.",
|
|
467
|
+
args: {
|
|
468
|
+
dry_run: tool.schema.boolean().optional().describe("If true, show what would be created without writing files."),
|
|
469
|
+
harness: tool.schema.boolean().optional().describe("Also create HARNESS.md scaffold."),
|
|
470
|
+
agents: tool.schema.boolean().optional().describe("Also create AGENTS.md scaffold."),
|
|
471
|
+
},
|
|
472
|
+
async execute(args, ctx) {
|
|
473
|
+
const projCtx = sessionState.storedProjectContext;
|
|
474
|
+
const results = [];
|
|
475
|
+
const dryRun = args.dry_run ?? false;
|
|
476
|
+
if (!dryRun && !canWriteFromTool(ctx)) {
|
|
477
|
+
return readOnlyToolWriteDenied(ctx, "initialize files");
|
|
478
|
+
}
|
|
479
|
+
// Build resolve.json content
|
|
480
|
+
const resolveConfig = {};
|
|
481
|
+
if (sessionState.storedConfig?.profile)
|
|
482
|
+
resolveConfig.profile = sessionState.storedConfig.profile;
|
|
483
|
+
if (sessionState.storedConfig?.tier)
|
|
484
|
+
resolveConfig.tier = sessionState.storedConfig.tier;
|
|
485
|
+
if (projCtx?.verifyCommands.length) {
|
|
486
|
+
results.push(`Detected verify: ${projCtx.verifyCommands.join(", ")}`);
|
|
487
|
+
}
|
|
488
|
+
if (projCtx?.packageManager) {
|
|
489
|
+
results.push(`Package manager: ${projCtx.packageManager}`);
|
|
490
|
+
}
|
|
491
|
+
if (projCtx?.hasTypeScript) {
|
|
492
|
+
results.push("TypeScript: yes");
|
|
493
|
+
}
|
|
494
|
+
if (!dryRun) {
|
|
495
|
+
const configPath = join(ctx.directory, "opencode-resolve.json");
|
|
496
|
+
try {
|
|
497
|
+
await access(configPath);
|
|
498
|
+
results.push("resolve.json: already exists, skipping");
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
writeFileSync(configPath, JSON.stringify(resolveConfig, null, 2) + "\n");
|
|
502
|
+
results.push("resolve.json: created");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
results.push(`[DRY RUN] Would create resolve.json: ${JSON.stringify(resolveConfig)}`);
|
|
507
|
+
}
|
|
508
|
+
// HARNESS.md scaffold
|
|
509
|
+
if (args.harness) {
|
|
510
|
+
const harnessContent = [
|
|
511
|
+
"# Project Infrastructure",
|
|
512
|
+
"",
|
|
513
|
+
"## Build & Verify",
|
|
514
|
+
...(projCtx?.verifyCommands.map(c => `- \`${c}\``) ?? []),
|
|
515
|
+
"",
|
|
516
|
+
"## Architecture Decisions",
|
|
517
|
+
"- _Add key decisions here_",
|
|
518
|
+
"",
|
|
519
|
+
"## Known Traps",
|
|
520
|
+
"- _Add project-specific pitfalls here_",
|
|
521
|
+
].join("\n");
|
|
522
|
+
if (!dryRun) {
|
|
523
|
+
const harnessPath = join(ctx.directory, "HARNESS.md");
|
|
524
|
+
try {
|
|
525
|
+
await access(harnessPath);
|
|
526
|
+
results.push("HARNESS.md: already exists, skipping");
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
writeFileSync(harnessPath, harnessContent + "\n");
|
|
530
|
+
results.push("HARNESS.md: created");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
results.push(`[DRY RUN] Would create HARNESS.md (${harnessContent.length} bytes)`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// AGENTS.md scaffold
|
|
538
|
+
if (args.agents) {
|
|
539
|
+
const agentsContent = [
|
|
540
|
+
"# Agent Behavior Patterns",
|
|
541
|
+
"",
|
|
542
|
+
"## Delegation Strategy",
|
|
543
|
+
"- _Document how tasks should be delegated here_",
|
|
544
|
+
"",
|
|
545
|
+
"## Verification Protocol",
|
|
546
|
+
"- _Document verification expectations here_",
|
|
547
|
+
"",
|
|
548
|
+
"## Model-Specific Notes",
|
|
549
|
+
"- _Add GLM/GPT specific patterns here_",
|
|
550
|
+
].join("\n");
|
|
551
|
+
if (!dryRun) {
|
|
552
|
+
const agentsPath = join(ctx.directory, "AGENTS.md");
|
|
553
|
+
try {
|
|
554
|
+
await access(agentsPath);
|
|
555
|
+
results.push("AGENTS.md: already exists, skipping");
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
writeFileSync(agentsPath, agentsContent + "\n");
|
|
559
|
+
results.push("AGENTS.md: created");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
results.push(`[DRY RUN] Would create AGENTS.md (${agentsContent.length} bytes)`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
ctx.metadata({ title: `init: ${results.length} items` });
|
|
567
|
+
return results.join("\n");
|
|
568
|
+
},
|
|
569
|
+
}),
|
|
570
|
+
"resolve-diff": tool({
|
|
571
|
+
description: "Show focused git diff summary. Supports comparing against last commit, a specific commit, or between branches. Much faster than reading full diff.",
|
|
572
|
+
args: {
|
|
573
|
+
ref: tool.schema.string().optional().describe("Git ref to compare against (e.g. 'HEAD~1', 'main', 'v1.0.0'). Defaults to staged+unstaged changes."),
|
|
574
|
+
file: tool.schema.string().optional().describe("Only show diff for this file path."),
|
|
575
|
+
stat_only: tool.schema.boolean().optional().describe("If true, only show file-level stat (no line diffs)."),
|
|
576
|
+
},
|
|
577
|
+
async execute(args, ctx) {
|
|
578
|
+
try {
|
|
579
|
+
let cmd;
|
|
580
|
+
const fileFilter = args.file ? ` -- '${sanitizeShellArg(args.file)}'` : "";
|
|
581
|
+
if (args.ref) {
|
|
582
|
+
const safeRef = sanitizeShellArg(args.ref);
|
|
583
|
+
if (args.stat_only) {
|
|
584
|
+
cmd = `git diff --stat ${safeRef}${fileFilter}`;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
cmd = `git diff --stat --patch ${safeRef}${fileFilter}`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
if (args.stat_only) {
|
|
592
|
+
cmd = `git diff --stat HEAD${fileFilter}`;
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
cmd = `git diff --stat --patch HEAD${fileFilter}`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const result = await runCommand(cmd, ctx.directory, 15_000);
|
|
599
|
+
if (result.exitCode !== 0)
|
|
600
|
+
return `Git diff failed: ${truncateOutput(result.stderr, 300)}`;
|
|
601
|
+
if (!result.stdout.trim())
|
|
602
|
+
return "No changes detected.";
|
|
603
|
+
ctx.metadata({ title: `diff: ${args.ref ?? "HEAD"}${args.file ? ` ${args.file}` : ""}` });
|
|
604
|
+
return truncateOutput(result.stdout, 3000);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return "Not a git repository or git unavailable.";
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
}),
|
|
611
|
+
"resolve-scripts": tool({
|
|
612
|
+
description: "List package.json scripts with their commands. Helps discover available build, test, lint, and dev commands.",
|
|
613
|
+
args: {
|
|
614
|
+
filter: tool.schema.string().optional().describe("Only show scripts matching this substring (e.g. 'test', 'build')."),
|
|
615
|
+
verbose: tool.schema.boolean().optional().describe("If true, also show the full command for each script."),
|
|
616
|
+
},
|
|
617
|
+
async execute(args, ctx) {
|
|
618
|
+
try {
|
|
619
|
+
const pkgRaw = await readFile(join(ctx.directory, "package.json"), "utf8");
|
|
620
|
+
const pkg = JSON.parse(pkgRaw);
|
|
621
|
+
const scripts = pkg.scripts;
|
|
622
|
+
if (!scripts || Object.keys(scripts).length === 0)
|
|
623
|
+
return "No scripts found in package.json.";
|
|
624
|
+
const entries = Object.entries(scripts)
|
|
625
|
+
.filter(([name]) => !args.filter || name.includes(args.filter));
|
|
626
|
+
if (entries.length === 0)
|
|
627
|
+
return `No scripts matching '${args.filter}'.`;
|
|
628
|
+
const lines = entries.map(([name, cmd]) => {
|
|
629
|
+
if (args.verbose)
|
|
630
|
+
return `${name}: ${cmd}`;
|
|
631
|
+
return name;
|
|
632
|
+
});
|
|
633
|
+
ctx.metadata({ title: `scripts: ${entries.length} found` });
|
|
634
|
+
return `Available scripts:\n${lines.join("\n")}`;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return "No package.json found or unreadable.";
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
}),
|
|
641
|
+
"resolve-env": tool({
|
|
642
|
+
description: "Check environment configuration. Reads .env.example if present, lists required variables, and shows which ones are set (names only, never values).",
|
|
643
|
+
args: {},
|
|
644
|
+
async execute(_args, ctx) {
|
|
645
|
+
const results = [];
|
|
646
|
+
// Check for .env.example
|
|
647
|
+
for (const name of [".env.example", ".env.sample", ".env.template"]) {
|
|
648
|
+
try {
|
|
649
|
+
const content = await readFile(join(ctx.directory, name), "utf8");
|
|
650
|
+
const vars = content.split("\n")
|
|
651
|
+
.map(l => l.trim())
|
|
652
|
+
.filter(l => l && !l.startsWith("#"))
|
|
653
|
+
.map(l => l.split("=")[0].trim())
|
|
654
|
+
.filter(Boolean);
|
|
655
|
+
if (vars.length > 0) {
|
|
656
|
+
results.push(`${name} variables: ${vars.join(", ")}`);
|
|
657
|
+
// Check which are set (names only — never expose values)
|
|
658
|
+
const set = [];
|
|
659
|
+
const missing = [];
|
|
660
|
+
for (const v of vars) {
|
|
661
|
+
if (process.env[v]) {
|
|
662
|
+
set.push(v);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
missing.push(v);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (set.length > 0)
|
|
669
|
+
results.push(`Set: ${set.join(", ")}`);
|
|
670
|
+
if (missing.length > 0)
|
|
671
|
+
results.push(`Missing: ${missing.join(", ")}`);
|
|
672
|
+
}
|
|
673
|
+
break; // found one, stop looking
|
|
674
|
+
}
|
|
675
|
+
catch { /* not found, try next */ }
|
|
676
|
+
}
|
|
677
|
+
// Check for .env
|
|
678
|
+
try {
|
|
679
|
+
await access(join(ctx.directory, ".env"));
|
|
680
|
+
results.push(".env: present (not reading for safety)");
|
|
681
|
+
}
|
|
682
|
+
catch { /* no .env */ }
|
|
683
|
+
if (results.length === 0)
|
|
684
|
+
return "No .env.example or .env files found.";
|
|
685
|
+
ctx.metadata({ title: `env: ${results.length} items` });
|
|
686
|
+
return results.join("\n");
|
|
687
|
+
},
|
|
688
|
+
}),
|
|
689
|
+
"resolve-coverage": tool({
|
|
690
|
+
description: "Run test coverage analysis. Detects coverage command from package.json scripts or uses npx c8/vitest --coverage. Returns coverage summary.",
|
|
691
|
+
args: {
|
|
692
|
+
command: tool.schema.string().optional().describe("Override coverage command (e.g. 'npm run test:coverage')."),
|
|
693
|
+
file: tool.schema.string().optional().describe("Only check coverage for this file or directory."),
|
|
694
|
+
},
|
|
695
|
+
async execute(args, ctx) {
|
|
696
|
+
const projCtx = sessionState.storedProjectContext;
|
|
697
|
+
let cmd = args.command;
|
|
698
|
+
if (!cmd) {
|
|
699
|
+
// Try to find coverage script
|
|
700
|
+
try {
|
|
701
|
+
const pkgRaw = await readFile(join(ctx.directory, "package.json"), "utf8");
|
|
702
|
+
const pkg = JSON.parse(pkgRaw);
|
|
703
|
+
const scripts = pkg.scripts;
|
|
704
|
+
const covScript = scripts?.["test:coverage"] ?? scripts?.["coverage"] ?? scripts?.["test:cov"];
|
|
705
|
+
if (covScript) {
|
|
706
|
+
const pm = projCtx?.packageManager ?? "npm";
|
|
707
|
+
const scriptName = Object.keys(scripts).find(k => scripts[k] === covScript);
|
|
708
|
+
cmd = `${pm} run ${scriptName}`;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
catch { /* no package.json */ }
|
|
712
|
+
if (!cmd) {
|
|
713
|
+
// Try common tools
|
|
714
|
+
cmd = "npx vitest run --coverage 2>/dev/null || npx c8 npm test 2>/dev/null || echo 'No coverage tool found'";
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (args.file)
|
|
718
|
+
cmd += ` '${sanitizeShellArg(args.file)}'`;
|
|
719
|
+
const denied = commandExecutionDenied(cmd);
|
|
720
|
+
if (denied)
|
|
721
|
+
return denied;
|
|
722
|
+
try {
|
|
723
|
+
const result = await runCommand(cmd, ctx.directory, 60_000);
|
|
724
|
+
ctx.metadata({ title: `coverage: ${args.file ?? "all"}` });
|
|
725
|
+
if (result.exitCode === 0) {
|
|
726
|
+
return { output: truncateOutput(result.stdout, 2000), metadata: { exitCode: 0 } };
|
|
727
|
+
}
|
|
728
|
+
return { output: `Coverage failed (exit ${result.exitCode}).\n${truncateOutput(result.stderr || result.stdout, 1000)}`, metadata: { exitCode: result.exitCode } };
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
return `Coverage error: ${err instanceof Error ? err.message : String(err)}`;
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
}),
|
|
735
|
+
"resolve-todo": tool({
|
|
736
|
+
description: "Extract TODO, FIXME, HACK, and XXX comments from source files. Shows file, line number, and comment text. Useful for finding incomplete work.",
|
|
737
|
+
args: {
|
|
738
|
+
paths: tool.schema.string().optional().describe("File or directory paths to scan (space-separated). Defaults to 'src/'."),
|
|
739
|
+
author: tool.schema.string().optional().describe("Filter by author name in comment (e.g. 'john')."),
|
|
740
|
+
},
|
|
741
|
+
async execute(args, ctx) {
|
|
742
|
+
const targets = args.paths ?? "src/";
|
|
743
|
+
const safeTargets = targets.split(" ").map(t => `'${sanitizeShellArg(t)}'`).join(" ");
|
|
744
|
+
const pattern = args.author
|
|
745
|
+
? `\\b(?:TODO|FIXME|HACK|XXX)\\b.*${sanitizeShellArg(args.author)}`
|
|
746
|
+
: `\\b(?:TODO|FIXME|HACK|XXX)\\b`;
|
|
747
|
+
try {
|
|
748
|
+
const result = await runCommand(`rg --no-heading --line-number --color never -i '${pattern}' ${safeTargets} 2>/dev/null | head -50`, ctx.directory, 10_000);
|
|
749
|
+
if (result.exitCode === 1)
|
|
750
|
+
return "No TODO/FIXME comments found. ✅";
|
|
751
|
+
if (result.exitCode !== 0)
|
|
752
|
+
return `Search error: ${truncateOutput(result.stderr, 300)}`;
|
|
753
|
+
const lines = result.stdout.trim().split("\n");
|
|
754
|
+
// Categorize
|
|
755
|
+
const todos = lines.filter(l => /\bTODO\b/i.test(l)).length;
|
|
756
|
+
const fixmes = lines.filter(l => /\bFIXME\b/i.test(l)).length;
|
|
757
|
+
const hacks = lines.filter(l => /\bHACK\b/i.test(l)).length;
|
|
758
|
+
const summary = `Found: ${todos} TODO, ${fixmes} FIXME, ${hacks} HACK`;
|
|
759
|
+
ctx.metadata({ title: `todo: ${summary}` });
|
|
760
|
+
return `${summary}\n${truncateOutput(result.stdout.trim(), 2000)}`;
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
767
|
+
"resolve-tree": tool({
|
|
768
|
+
description: "Show directory structure up to a given depth. Faster than running find or ls -R. Useful for understanding project layout.",
|
|
769
|
+
args: {
|
|
770
|
+
path: tool.schema.string().optional().describe("Directory path to tree. Defaults to '.' (project root)."),
|
|
771
|
+
depth: tool.schema.number().optional().describe("Maximum depth to traverse (default 3)."),
|
|
772
|
+
exclude: tool.schema.string().optional().describe("Comma-separated exclude patterns (default: 'node_modules,.git,dist,build,.next')."),
|
|
773
|
+
},
|
|
774
|
+
async execute(args, ctx) {
|
|
775
|
+
const dir = args.path ?? ".";
|
|
776
|
+
const maxDepth = Math.min(args.depth ?? 3, 6);
|
|
777
|
+
const excludes = (args.exclude ?? "node_modules,.git,dist,build,.next,.cache,target")
|
|
778
|
+
.split(",")
|
|
779
|
+
.map(e => `-I '${sanitizeShellArg(e.trim())}'`)
|
|
780
|
+
.join(" ");
|
|
781
|
+
try {
|
|
782
|
+
// Try tree first, fall back to find
|
|
783
|
+
const result = await runCommand(`tree -L ${maxDepth} ${excludes} '${sanitizeShellArg(dir)}' 2>/dev/null || find '${sanitizeShellArg(dir)}' -maxdepth ${maxDepth} -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null | head -100`, ctx.directory, 10_000);
|
|
784
|
+
if (result.exitCode !== 0 && !result.stdout.trim()) {
|
|
785
|
+
return `Directory not found: ${dir}`;
|
|
786
|
+
}
|
|
787
|
+
ctx.metadata({ title: `tree: ${dir} (depth ${maxDepth})` });
|
|
788
|
+
return truncateOutput(result.stdout, 3000);
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
return `Tree failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
}),
|
|
795
|
+
"resolve-metrics": tool({
|
|
796
|
+
description: "Quick project health overview: file counts, dependency counts, TODO/FIXME counts, test status, and git status. Aggregates data from multiple sources into a single summary.",
|
|
797
|
+
args: {
|
|
798
|
+
skip_test: tool.schema.boolean().optional().describe("Skip running tests (faster). Default: false."),
|
|
799
|
+
},
|
|
800
|
+
async execute(args, ctx) {
|
|
801
|
+
const results = [];
|
|
802
|
+
const projCtx = sessionState.storedProjectContext;
|
|
803
|
+
// 1. File counts by type
|
|
804
|
+
try {
|
|
805
|
+
const srcFiles = await runCommand("find src -type f 2>/dev/null | wc -l", ctx.directory, 5_000);
|
|
806
|
+
const testFiles = await runCommand("find test tests -type f 2>/dev/null | wc -l", ctx.directory, 5_000);
|
|
807
|
+
const totalFiles = await runCommand("find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' 2>/dev/null | wc -l", ctx.directory, 5_000);
|
|
808
|
+
results.push(`Files: ${totalFiles.stdout.trim()} total, ${srcFiles.stdout.trim() || "0"} src, ${testFiles.stdout.trim() || "0"} test`);
|
|
809
|
+
}
|
|
810
|
+
catch { /* skip */ }
|
|
811
|
+
// 2. Dependencies
|
|
812
|
+
try {
|
|
813
|
+
const pkgRaw = await readFile(join(ctx.directory, "package.json"), "utf8");
|
|
814
|
+
const pkg = JSON.parse(pkgRaw);
|
|
815
|
+
const deps = Object.keys(pkg.dependencies ?? {}).length;
|
|
816
|
+
const devDeps = Object.keys(pkg.devDependencies ?? {}).length;
|
|
817
|
+
results.push(`Dependencies: ${deps} prod, ${devDeps} dev`);
|
|
818
|
+
}
|
|
819
|
+
catch { /* skip */ }
|
|
820
|
+
// 3. TODO/FIXME count
|
|
821
|
+
try {
|
|
822
|
+
const todoResult = await runCommand("rg -c '\\b(?:TODO|FIXME|HACK|XXX)\\b' src 2>/dev/null | wc -l", ctx.directory, 5_000);
|
|
823
|
+
const todoCount = parseInt(todoResult.stdout.trim()) || 0;
|
|
824
|
+
if (todoCount > 0)
|
|
825
|
+
results.push(`TODO/FIXME: ${todoCount} files with action items`);
|
|
826
|
+
else
|
|
827
|
+
results.push("TODO/FIXME: clean ✅");
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
results.push("TODO/FIXME: not checked");
|
|
831
|
+
}
|
|
832
|
+
// 4. TypeScript check (if applicable)
|
|
833
|
+
if (projCtx?.hasTypeScript && projCtx.verifyCommands.length > 0) {
|
|
834
|
+
const tscCmd = projCtx.verifyCommands.find(c => /tsc|typecheck|type.check/i.test(c));
|
|
835
|
+
if (tscCmd) {
|
|
836
|
+
try {
|
|
837
|
+
const tsc = await runCommand(tscCmd, ctx.directory, 30_000);
|
|
838
|
+
results.push(`TypeCheck: ${tsc.exitCode === 0 ? "pass ✅" : "fail ❌"}`);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
results.push("TypeCheck: error running check");
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// 5. Test status
|
|
846
|
+
if (!args.skip_test && projCtx?.verifyCommands.some(c => /test/i.test(c))) {
|
|
847
|
+
const testCmd = projCtx.verifyCommands.find(c => /test/i.test(c));
|
|
848
|
+
try {
|
|
849
|
+
const test = await runCommand(testCmd, ctx.directory, 60_000);
|
|
850
|
+
results.push(`Tests: ${test.exitCode === 0 ? "pass ✅" : "fail ❌"}`);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
results.push("Tests: error running tests");
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else if (args.skip_test) {
|
|
857
|
+
results.push("Tests: skipped");
|
|
858
|
+
}
|
|
859
|
+
// 6. Git status
|
|
860
|
+
try {
|
|
861
|
+
const branch = await runCommand("git rev-parse --abbrev-ref HEAD 2>/dev/null", ctx.directory, 3_000);
|
|
862
|
+
const dirty = await runCommand("git status --porcelain 2>/dev/null | wc -l", ctx.directory, 3_000);
|
|
863
|
+
if (branch.exitCode === 0) {
|
|
864
|
+
const dirtyCount = parseInt(dirty.stdout.trim()) || 0;
|
|
865
|
+
results.push(`Git: ${branch.stdout.trim()}, ${dirtyCount} dirty files`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
catch { /* skip */ }
|
|
869
|
+
// 7. Project context info
|
|
870
|
+
if (projCtx) {
|
|
871
|
+
const info = [];
|
|
872
|
+
if (projCtx.packageManager)
|
|
873
|
+
info.push(`pm: ${projCtx.packageManager}`);
|
|
874
|
+
if (projCtx.hasTypeScript)
|
|
875
|
+
info.push("TS");
|
|
876
|
+
if (projCtx.hasHarness)
|
|
877
|
+
info.push("HARNESS.md");
|
|
878
|
+
if (projCtx.hasAgents)
|
|
879
|
+
info.push("AGENTS.md");
|
|
880
|
+
if (info.length > 0)
|
|
881
|
+
results.push(`Context: ${info.join(", ")}`);
|
|
882
|
+
}
|
|
883
|
+
ctx.metadata({ title: `metrics: ${results.length} items` });
|
|
884
|
+
return results.join("\n");
|
|
885
|
+
},
|
|
886
|
+
}),
|
|
887
|
+
// ── Ralph Loop tools ──────────────────────────────────────────────────
|
|
888
|
+
"resolve-changelog": tool({
|
|
889
|
+
description: "Show recent git changes. Useful for understanding what changed in the current session and detecting if edits are going in circles (Ralph Loop detection).",
|
|
890
|
+
args: {
|
|
891
|
+
count: tool.schema.number().optional().describe("Number of commits to show. Default: 10."),
|
|
892
|
+
file: tool.schema.string().optional().describe("Show changes for a specific file only."),
|
|
893
|
+
format: tool.schema.enum(["oneline", "stat", "full"]).optional().describe("Output format. Default: oneline."),
|
|
894
|
+
},
|
|
895
|
+
async execute(args, ctx) {
|
|
896
|
+
const n = Math.min(args.count ?? 10, 50);
|
|
897
|
+
const fmt = args.format ?? "oneline";
|
|
898
|
+
try {
|
|
899
|
+
let cmd;
|
|
900
|
+
if (args.file) {
|
|
901
|
+
const safeFile = sanitizeShellArg(args.file);
|
|
902
|
+
cmd = fmt === "stat"
|
|
903
|
+
? `git log --stat -${n} -- ${safeFile}`
|
|
904
|
+
: fmt === "full"
|
|
905
|
+
? `git log -${n} -- ${safeFile}`
|
|
906
|
+
: `git log --oneline -${n} -- ${safeFile}`;
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
cmd = fmt === "stat"
|
|
910
|
+
? `git log --stat -${n}`
|
|
911
|
+
: fmt === "full"
|
|
912
|
+
? `git log -${n}`
|
|
913
|
+
: `git log --oneline -${n}`;
|
|
914
|
+
}
|
|
915
|
+
const result = await runCommand(cmd, ctx.directory, 10_000);
|
|
916
|
+
if (result.exitCode !== 0)
|
|
917
|
+
return `Git log failed: ${result.stderr.trim()}`;
|
|
918
|
+
ctx.metadata({ title: `changelog: ${n} commits` });
|
|
919
|
+
return truncateOutput(result.stdout, 4000);
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
return `Changelog failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
"resolve-session": tool({
|
|
927
|
+
description: "Show current Ralph Loop session state: profile, tier, edit count, tool call count, failure warnings, loop warnings, and elapsed time. Use when you suspect you're going in circles.",
|
|
928
|
+
args: {},
|
|
929
|
+
async execute(_args, ctx) {
|
|
930
|
+
const lines = [];
|
|
931
|
+
const cfg = sessionState.storedConfig;
|
|
932
|
+
const projCtx = sessionState.storedProjectContext;
|
|
933
|
+
const elapsed = Math.round((Date.now() - sessionState.sessionStartTime) / 1000);
|
|
934
|
+
lines.push(`Session duration: ${elapsed}s`);
|
|
935
|
+
lines.push(`Tool calls: ${sessionState.totalToolCalls}`);
|
|
936
|
+
lines.push(`Edits: ${sessionState.totalEdits}`);
|
|
937
|
+
if (cfg?.profile)
|
|
938
|
+
lines.push(`Profile: ${cfg.profile}`);
|
|
939
|
+
if (cfg?.tier)
|
|
940
|
+
lines.push(`Tier: ${cfg.tier}`);
|
|
941
|
+
if (projCtx?.hasTypeScript)
|
|
942
|
+
lines.push("TypeScript: yes");
|
|
943
|
+
if (projCtx?.packageManager)
|
|
944
|
+
lines.push(`Package manager: ${projCtx.packageManager}`);
|
|
945
|
+
if (projCtx?.verifyCommands.length)
|
|
946
|
+
lines.push(`Verify commands: ${projCtx.verifyCommands.join(", ")}`);
|
|
947
|
+
// Edit hotspots
|
|
948
|
+
const hotspots = Array.from(sessionState.editHotspots.entries())
|
|
949
|
+
.filter(([, v]) => v.count >= 2)
|
|
950
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
951
|
+
.slice(0, 5);
|
|
952
|
+
if (hotspots.length > 0) {
|
|
953
|
+
lines.push("Edit hotspots:");
|
|
954
|
+
for (const [file, data] of hotspots) {
|
|
955
|
+
lines.push(` ${file}: ${data.count} edits`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// Failure warnings
|
|
959
|
+
if (sessionState.failureWarnings.length > 0) {
|
|
960
|
+
lines.push("Failure warnings:");
|
|
961
|
+
for (const w of sessionState.failureWarnings)
|
|
962
|
+
lines.push(` ⚠️ ${w}`);
|
|
963
|
+
}
|
|
964
|
+
// Loop warnings
|
|
965
|
+
if (sessionState.loopWarnings.length > 0) {
|
|
966
|
+
lines.push("Loop warnings:");
|
|
967
|
+
for (const w of sessionState.loopWarnings)
|
|
968
|
+
lines.push(` 🔄 ${w}`);
|
|
969
|
+
}
|
|
970
|
+
ctx.metadata({ title: `session: ${sessionState.totalEdits} edits, ${sessionState.totalToolCalls} tools, ${elapsed}s` });
|
|
971
|
+
return lines.join("\n");
|
|
972
|
+
},
|
|
973
|
+
}),
|
|
974
|
+
"resolve-audit": tool({
|
|
975
|
+
description: "Run a quick security audit: detect accidentally committed secrets, vulnerable dependency patterns, and common security issues in source files.",
|
|
976
|
+
args: {
|
|
977
|
+
paths: tool.schema.array(tool.schema.string()).optional().describe("Directories to scan. Default: ['src']."),
|
|
978
|
+
check_deps: tool.schema.boolean().optional().describe("Also check npm audit. Default: false."),
|
|
979
|
+
},
|
|
980
|
+
async execute(args, ctx) {
|
|
981
|
+
const dirs = args.paths ?? ["src"];
|
|
982
|
+
const results = [];
|
|
983
|
+
const safeDirs = dirs.map(d => sanitizeShellArg(d)).join(" ");
|
|
984
|
+
// 1. Secret detection
|
|
985
|
+
const secretPatterns = [
|
|
986
|
+
{ name: "Private keys", regex: "-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----" },
|
|
987
|
+
{ name: "API keys (generic)", regex: "(api[_-]?key|apikey)\\s*[:=]\\s*['\"][a-zA-Z0-9]{20,}" },
|
|
988
|
+
{ name: "AWS keys", regex: "AKIA[0-9A-Z]{16}" },
|
|
989
|
+
{ name: "Generic secrets", regex: "(secret|password|token|credential)\\s*[:=]\\s*['\"][^'\"]{8,}" },
|
|
990
|
+
];
|
|
991
|
+
for (const { name, regex } of secretPatterns) {
|
|
992
|
+
try {
|
|
993
|
+
const result = await runCommand(`rg -l '${regex}' ${safeDirs} 2>/dev/null`, ctx.directory, 5_000);
|
|
994
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
995
|
+
const files = result.stdout.trim().split("\n");
|
|
996
|
+
results.push(`🔴 ${name}: found in ${files.length} file(s): ${files.slice(0, 5).join(", ")}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
catch { /* rg not found */ }
|
|
1000
|
+
}
|
|
1001
|
+
// 2. Vulnerable patterns
|
|
1002
|
+
const vulnPatterns = [
|
|
1003
|
+
{ name: "eval() usage", regex: "\\beval\\s*\\(" },
|
|
1004
|
+
{ name: "innerHTML usage", regex: "\\.innerHTML\\s*=" },
|
|
1005
|
+
{ name: "exec() with string", regex: "\\bexec\\s*\\(.*\\$" },
|
|
1006
|
+
{ name: "SQL string concat", regex: "(SELECT|INSERT|UPDATE|DELETE).*\\+" },
|
|
1007
|
+
{ name: "HTTP (not HTTPS)", regex: "http://[^/]*[^s]\\b" },
|
|
1008
|
+
];
|
|
1009
|
+
for (const { name, regex } of vulnPatterns) {
|
|
1010
|
+
try {
|
|
1011
|
+
const result = await runCommand(`rg -c '${regex}' ${safeDirs} 2>/dev/null`, ctx.directory, 5_000);
|
|
1012
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
1013
|
+
const count = result.stdout.trim().split("\n").length;
|
|
1014
|
+
results.push(`🟡 ${name}: ${count} file(s)`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch { /* skip */ }
|
|
1018
|
+
}
|
|
1019
|
+
// 3. npm audit
|
|
1020
|
+
if (args.check_deps) {
|
|
1021
|
+
try {
|
|
1022
|
+
const audit = await runCommand("npm audit --json 2>/dev/null", ctx.directory, 30_000);
|
|
1023
|
+
if (audit.exitCode !== 0 && audit.stdout.trim()) {
|
|
1024
|
+
const auditData = JSON.parse(audit.stdout);
|
|
1025
|
+
const vulns = auditData.metadata?.vulnerabilities;
|
|
1026
|
+
if (vulns) {
|
|
1027
|
+
results.push(`📦 npm audit: ${vulns.high ?? 0} high, ${vulns.critical ?? 0} critical, ${vulns.moderate ?? 0} moderate`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
results.push("📦 npm audit: no vulnerabilities ✅");
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
results.push("📦 npm audit: not available");
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (results.length === 0) {
|
|
1039
|
+
results.push("No security issues detected ✅");
|
|
1040
|
+
}
|
|
1041
|
+
ctx.metadata({ title: `audit: ${results.length} findings` });
|
|
1042
|
+
return results.join("\n");
|
|
1043
|
+
},
|
|
1044
|
+
}),
|
|
1045
|
+
"resolve-config-check": tool({
|
|
1046
|
+
description: "Validate the current opencode-resolve configuration. Checks resolve.json validity, missing agents, conflicting settings, and suggests fixes.",
|
|
1047
|
+
args: {},
|
|
1048
|
+
async execute(_args, ctx) {
|
|
1049
|
+
const results = [];
|
|
1050
|
+
const cfg = sessionState.storedConfig;
|
|
1051
|
+
if (!cfg) {
|
|
1052
|
+
return "No resolve config loaded. Plugin may not be initialized.";
|
|
1053
|
+
}
|
|
1054
|
+
// 1. Profile check
|
|
1055
|
+
if (cfg.profile) {
|
|
1056
|
+
if (VALID_PROFILES.has(cfg.profile)) {
|
|
1057
|
+
results.push(`✅ Profile: ${cfg.profile}`);
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
results.push(`🔴 Invalid profile: '${cfg.profile}'. Valid: ${[...VALID_PROFILES].join(", ")}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
results.push("ℹ️ No profile set (using defaults)");
|
|
1065
|
+
}
|
|
1066
|
+
// 2. Tier check
|
|
1067
|
+
if (cfg.tier) {
|
|
1068
|
+
if (VALID_TIERS.has(cfg.tier)) {
|
|
1069
|
+
results.push(`✅ Tier: ${cfg.tier}`);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
results.push(`🔴 Invalid tier: '${cfg.tier}'. Valid: ${[...VALID_TIERS].join(", ")}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// 3. Enabled agents check
|
|
1076
|
+
if (cfg.enabled) {
|
|
1077
|
+
for (const name of cfg.enabled) {
|
|
1078
|
+
if (VALID_AGENT_NAME_SET.has(name)) {
|
|
1079
|
+
results.push(`✅ Agent '${name}' enabled`);
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
results.push(`🔴 Unknown agent: '${name}'. Valid: ${VALID_AGENT_NAMES.join(", ")}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// 4. Model aliases check
|
|
1087
|
+
if (cfg.models) {
|
|
1088
|
+
for (const [key, value] of Object.entries(cfg.models)) {
|
|
1089
|
+
if (typeof value !== "string") {
|
|
1090
|
+
results.push(`🔴 Model alias '${key}' must be a string, got ${typeof value}`);
|
|
1091
|
+
}
|
|
1092
|
+
else {
|
|
1093
|
+
results.push(`✅ Model '${key}' → '${value}'`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// 5. Agent overrides check
|
|
1098
|
+
if (cfg.agents) {
|
|
1099
|
+
for (const name of Object.keys(cfg.agents)) {
|
|
1100
|
+
if (!VALID_AGENT_NAME_SET.has(name)) {
|
|
1101
|
+
results.push(`🔴 Unknown agent override: '${name}'`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// 6. Project context check
|
|
1106
|
+
const projCtx = sessionState.storedProjectContext;
|
|
1107
|
+
if (projCtx) {
|
|
1108
|
+
if (projCtx.verifyCommands.length === 0) {
|
|
1109
|
+
results.push("⚠️ No verify commands detected — add typecheck/lint/test scripts to package.json");
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
results.push(`✅ Verify commands: ${projCtx.verifyCommands.join(", ")}`);
|
|
1113
|
+
}
|
|
1114
|
+
if (!projCtx.hasTypeScript) {
|
|
1115
|
+
results.push("ℹ️ Not a TypeScript project");
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// 7. Resolve.json file check
|
|
1119
|
+
try {
|
|
1120
|
+
const { readFileSync: rf } = await import("node:fs");
|
|
1121
|
+
const paths = [
|
|
1122
|
+
join(ctx.directory, ".opencode", "resolve.json"),
|
|
1123
|
+
join(ctx.directory, "opencode-resolve.json"),
|
|
1124
|
+
];
|
|
1125
|
+
let found = false;
|
|
1126
|
+
for (const p of paths) {
|
|
1127
|
+
try {
|
|
1128
|
+
rf(p, "utf8");
|
|
1129
|
+
results.push(`✅ Config file: ${p}`);
|
|
1130
|
+
found = true;
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
catch { /* not found */ }
|
|
1134
|
+
}
|
|
1135
|
+
if (!found)
|
|
1136
|
+
results.push("ℹ️ No local resolve.json found (using defaults)");
|
|
1137
|
+
}
|
|
1138
|
+
catch { /* skip */ }
|
|
1139
|
+
ctx.metadata({ title: `config-check: ${results.length} items` });
|
|
1140
|
+
return results.join("\n");
|
|
1141
|
+
},
|
|
1142
|
+
}),
|
|
1143
|
+
"resolve-state": tool({
|
|
1144
|
+
description: "Read or write session state checkpoints to .opencode/resolve-state.json. Enables session resumption and cross-turn state persistence. Use 'save' to checkpoint current progress, 'load' to read last checkpoint.",
|
|
1145
|
+
args: {
|
|
1146
|
+
action: tool.schema.union([tool.schema.literal("save"), tool.schema.literal("load")]).describe("'save' to write current state, 'load' to read last checkpoint."),
|
|
1147
|
+
note: tool.schema.string().optional().describe("Optional note to attach to the checkpoint (e.g. 'finished auth module, starting API routes')."),
|
|
1148
|
+
},
|
|
1149
|
+
async execute(args, ctx) {
|
|
1150
|
+
const stateDir = join(ctx.directory, ".opencode");
|
|
1151
|
+
const statePath = join(stateDir, "resolve-state.json");
|
|
1152
|
+
if (args.action === "load") {
|
|
1153
|
+
try {
|
|
1154
|
+
const data = await readFile(statePath, "utf8");
|
|
1155
|
+
const state = JSON.parse(data);
|
|
1156
|
+
return { output: `📋 Last checkpoint loaded:\n${JSON.stringify(state, null, 2)}`, metadata: state };
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
return "No previous checkpoint found. Use 'save' to create one.";
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
// save
|
|
1163
|
+
if (!canWriteFromTool(ctx)) {
|
|
1164
|
+
return readOnlyToolWriteDenied(ctx, "save session state");
|
|
1165
|
+
}
|
|
1166
|
+
const state = {
|
|
1167
|
+
timestamp: new Date().toISOString(),
|
|
1168
|
+
sessionId: ctx.sessionID ?? "unknown",
|
|
1169
|
+
edits: sessionState.totalEdits,
|
|
1170
|
+
toolCalls: sessionState.totalToolCalls,
|
|
1171
|
+
failures: sessionState.totalFailures,
|
|
1172
|
+
elapsedSeconds: Math.round((Date.now() - sessionState.sessionStartTime) / 1000),
|
|
1173
|
+
};
|
|
1174
|
+
if (sessionState.storedConfig?.profile)
|
|
1175
|
+
state.profile = sessionState.storedConfig.profile;
|
|
1176
|
+
if (sessionState.storedConfig?.tier)
|
|
1177
|
+
state.tier = sessionState.storedConfig.tier;
|
|
1178
|
+
if (sessionState.failureWarnings.length > 0)
|
|
1179
|
+
state.activeFailures = sessionState.failureWarnings;
|
|
1180
|
+
if (sessionState.loopWarnings.length > 0)
|
|
1181
|
+
state.loopWarnings = sessionState.loopWarnings;
|
|
1182
|
+
if (args.note)
|
|
1183
|
+
state.note = args.note;
|
|
1184
|
+
if (sessionState.storedProjectContext) {
|
|
1185
|
+
state.knowledgeFiles = sessionState.storedProjectContext.knowledgeFiles;
|
|
1186
|
+
state.contextFiles = sessionState.storedProjectContext.contextFiles;
|
|
1187
|
+
state.verifyCommands = sessionState.storedProjectContext.verifyCommands;
|
|
1188
|
+
}
|
|
1189
|
+
// Track hotspots
|
|
1190
|
+
const hotspots = [];
|
|
1191
|
+
for (const [file, data] of sessionState.editHotspots) {
|
|
1192
|
+
if (data.count >= 3)
|
|
1193
|
+
hotspots.push(`${file} (${data.count} edits)`);
|
|
1194
|
+
}
|
|
1195
|
+
if (hotspots.length > 0)
|
|
1196
|
+
state.hotspots = hotspots;
|
|
1197
|
+
try {
|
|
1198
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1199
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
1200
|
+
ctx.metadata({ title: `state: checkpoint saved` });
|
|
1201
|
+
return `✅ Checkpoint saved to .opencode/resolve-state.json\n${JSON.stringify(state, null, 2)}`;
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
return `⚠️ Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`;
|
|
1205
|
+
}
|
|
1206
|
+
},
|
|
1207
|
+
}),
|
|
1208
|
+
};
|
|
1209
|
+
}
|