opencode-resolve 0.1.20 → 0.3.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.ko.md +39 -46
- package/README.md +39 -46
- package/dist/agents.d.ts +3 -19
- package/dist/agents.js +45 -205
- package/dist/checkpoint.d.ts +22 -0
- package/dist/checkpoint.js +72 -0
- package/dist/config.d.ts +3 -1
- package/dist/config.js +30 -47
- package/dist/hooks/index.js +299 -118
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/messages.d.ts +1 -1
- package/dist/messages.js +28 -56
- package/dist/state.d.ts +13 -0
- package/dist/state.js +9 -0
- package/dist/tools/index.d.ts +11 -2
- package/dist/tools/index.js +131 -21
- package/dist/types.d.ts +12 -5
- package/dist/utils.d.ts +11 -11
- package/dist/utils.js +55 -97
- package/opencode-resolve.example.json +0 -15
- package/opencode-resolve.reference.jsonc +94 -130
- package/package.json +1 -1
- package/scripts/cli.mjs +13 -9
- package/scripts/install-local.mjs +4 -18
- package/scripts/postinstall.mjs +132 -495
package/dist/hooks/index.js
CHANGED
|
@@ -1,7 +1,80 @@
|
|
|
1
|
-
import { DIAGNOSTICS_TTL_MS, FAILURE_PATTERN_TTL_MS, FAILURE_THRESHOLD, STRATEGY_PIVOT_THRESHOLD,
|
|
2
|
-
import { classifyBashCommand,
|
|
1
|
+
import { DIAGNOSTICS_TTL_MS, FAILURE_PATTERN_TTL_MS, FAILURE_THRESHOLD, STRATEGY_PIVOT_THRESHOLD, EDIT_HOTSPOT_THRESHOLD, DISPATCH_STOP_THRESHOLD, DISPATCH_PIVOT_THRESHOLD } from "../state.js";
|
|
2
|
+
import { classifyBashCommand, detectRollbackCommand, formatError, detectProjectContext } from "../utils.js";
|
|
3
|
+
import { createRollbackCheckpoint, isGitRepository } from "../checkpoint.js";
|
|
3
4
|
import { loadResolveConfig, applyResolveConfig } from "../config.js";
|
|
4
|
-
import { contextMessage, narrate, resolveLocale, t, agentDisplayName, PLUGIN_BRAND } from "../messages.js";
|
|
5
|
+
import { contextMessage, narrate, resolveLocale, t, agentDisplayName, brand, PLUGIN_BRAND } from "../messages.js";
|
|
6
|
+
import { VALID_AGENT_NAMES } from "../agents.js";
|
|
7
|
+
// Agents this plugin registers. Hooks that modify chat/tool behavior gate on
|
|
8
|
+
// isActiveResolve() so native opencode agents (build/plan/default chat) pass
|
|
9
|
+
// through unmodified — the plugin only intervenes when a resolve agent drives
|
|
10
|
+
// the current turn.
|
|
11
|
+
const RESOLVE_AGENTS = new Set(VALID_AGENT_NAMES);
|
|
12
|
+
// Collaborative handoff / question patterns. A turn ending in one of these is a
|
|
13
|
+
// genuine question to the user and must NEVER be rewritten into an autonomous
|
|
14
|
+
// directive — doing so severs the user↔model feedback loop.
|
|
15
|
+
const HANDOFF_PATTERNS = [
|
|
16
|
+
/\?\s*$/, // ends with a question mark
|
|
17
|
+
/\blet me know\b/i,
|
|
18
|
+
/\bwould you like\b/i,
|
|
19
|
+
/\bshall i\b/i,
|
|
20
|
+
/\bdo you want\b/i,
|
|
21
|
+
/\bwhat do you think\b/i,
|
|
22
|
+
/\bcan i\b/i,
|
|
23
|
+
];
|
|
24
|
+
// Code-file extensions. Only edits to code trigger the verify gate; docs/config
|
|
25
|
+
// (.md/.txt/.json/...) are exempt so trivial doc changes don't force a build.
|
|
26
|
+
const CODE_EXTENSIONS = [
|
|
27
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts",
|
|
28
|
+
"py", "go", "rs", "java", "kt", "scala", "rb", "php",
|
|
29
|
+
"c", "cc", "cpp", "cxx", "h", "hh", "hpp", "hxx",
|
|
30
|
+
"cs", "swift", "sh", "bash", "zsh", "fish",
|
|
31
|
+
"vue", "svelte", "astro", "gleam", "ex", "exs", "erl", "lua",
|
|
32
|
+
];
|
|
33
|
+
function isCodeFile(filePath) {
|
|
34
|
+
if (!filePath)
|
|
35
|
+
return true; // unknown target — be conservative, require verify
|
|
36
|
+
const dot = filePath.lastIndexOf(".");
|
|
37
|
+
if (dot < 0)
|
|
38
|
+
return true; // no extension — treat as code
|
|
39
|
+
return CODE_EXTENSIONS.includes(filePath.slice(dot + 1).toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
// Harness boilerplate — not the model's words, so rewriting them wholesale
|
|
42
|
+
// costs nothing and buys a sharper instruction.
|
|
43
|
+
const HARNESS_BOILERPLATE_REWRITES = [
|
|
44
|
+
["Summarize the task tool output above and continue with your task.",
|
|
45
|
+
"Analyze the subtask result above. If it succeeded, continue. If it failed, diagnose and retry. Report completion only when verified."],
|
|
46
|
+
[/Summarize the .+ output above and continue/i,
|
|
47
|
+
"Analyze the result above. If it succeeded, continue to the next step. If it failed, diagnose root cause and retry with a fix."],
|
|
48
|
+
[/continue with your task\.$/i,
|
|
49
|
+
"continue driving toward verified completion."],
|
|
50
|
+
];
|
|
51
|
+
// Marker on every appended nudge. Also the idempotency key: a part that already
|
|
52
|
+
// carries it is never nudged again.
|
|
53
|
+
const NUDGE_MARKER = `[${PLUGIN_BRAND}] self-check:`;
|
|
54
|
+
// The model's own words — uncertainty, low confidence, retry intent. Overwriting
|
|
55
|
+
// these erases the reasoning trace the model needs to notice it is going in
|
|
56
|
+
// circles, so the original text stays and a nudge is appended after it.
|
|
57
|
+
const SELF_REVIEW_NUDGES = [
|
|
58
|
+
[/I('ve| have) (completed|finished|done) (the )?.*\.$/i,
|
|
59
|
+
"Before this counts as complete: did typecheck/lint/test actually run and pass? Verify, then report."],
|
|
60
|
+
[/I('ll| will) (try again|retry|attempt again|redo)/i,
|
|
61
|
+
"Name the ROOT CAUSE before retrying — a retry that repeats the same fix fails the same way."],
|
|
62
|
+
[/I('m| am) (not sure|unsure|uncertain) .*/i,
|
|
63
|
+
"The uncertainty is worth stating. Now settle it with evidence: read the code, check resolve-diagnostics, or search."],
|
|
64
|
+
[/this (might|should|could|may) work/i,
|
|
65
|
+
"CONFIRM it works by running verification before moving on. Do not assume."],
|
|
66
|
+
[/it (seems|appears|looks) to (be )?(working|fine|correct)/i,
|
|
67
|
+
"VERIFY with typecheck/lint/test — 'seems to work' is not evidence."],
|
|
68
|
+
];
|
|
69
|
+
// Edit-hotspot feedback. Repeatedly editing one file is often the *correct*
|
|
70
|
+
// path through a hard bug — the failure mode is not the repetition itself but
|
|
71
|
+
// re-applying the same change blind. So the nudge asks the model to inspect its
|
|
72
|
+
// own diff, never to go edit some other file (that spreads the damage).
|
|
73
|
+
function hotspotDiffPrompt(filePath, count) {
|
|
74
|
+
return `'${filePath}' has been edited ${count} times. Before the next edit, run \`git diff -- ${filePath}\` ` +
|
|
75
|
+
`and check whether recent edits repeat the same substitution or undo each other. ` +
|
|
76
|
+
`If the root cause is genuinely in this file, keep working here.`;
|
|
77
|
+
}
|
|
5
78
|
function capTemperature(current, cap) {
|
|
6
79
|
return typeof current === "number" && Number.isFinite(current)
|
|
7
80
|
? Math.min(current, cap)
|
|
@@ -16,10 +89,6 @@ const DISPATCH_KEYS = {
|
|
|
16
89
|
architect: "dispatch.architect",
|
|
17
90
|
researcher: "dispatch.researcher",
|
|
18
91
|
debugger: "dispatch.debugger",
|
|
19
|
-
codex: "dispatch.codex",
|
|
20
|
-
glm: "dispatch.glm",
|
|
21
|
-
gpt: "dispatch.gpt",
|
|
22
|
-
"gpt-coder": "dispatch.gptCoder",
|
|
23
92
|
};
|
|
24
93
|
function extractSubagentType(args) {
|
|
25
94
|
if (!args || typeof args !== "object")
|
|
@@ -36,6 +105,26 @@ function extractDispatchGoal(args) {
|
|
|
36
105
|
const trimmed = candidate.trim().split("\n")[0] ?? "";
|
|
37
106
|
return trimmed.length > 80 ? trimmed.slice(0, 77) + "…" : trimmed;
|
|
38
107
|
}
|
|
108
|
+
// Ralph Loop: does a bash command run one of the project's verify commands
|
|
109
|
+
// (typecheck / lint / test)? Used to clear the awaitingVerify flag so the
|
|
110
|
+
// loop can tell the resolver the edit was actually checked.
|
|
111
|
+
function isVerifyCommand(cmd, verifyCommands) {
|
|
112
|
+
if (typeof cmd !== "string" || cmd.length === 0)
|
|
113
|
+
return false;
|
|
114
|
+
if (!Array.isArray(verifyCommands) || verifyCommands.length === 0)
|
|
115
|
+
return false;
|
|
116
|
+
const normalized = cmd.trim();
|
|
117
|
+
return verifyCommands.some((vc) => typeof vc === "string" && vc.length > 0 && normalized.includes(vc));
|
|
118
|
+
}
|
|
119
|
+
// Ralph Loop: classify a task (subagent dispatch) result as success/failure.
|
|
120
|
+
// A dispatch is failed if the tool errored or carried an error in metadata.
|
|
121
|
+
function dispatchFailed(output) {
|
|
122
|
+
if (Boolean(output?.error))
|
|
123
|
+
return true;
|
|
124
|
+
if (typeof output?.metadata === "object" && output.metadata?.error)
|
|
125
|
+
return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
39
128
|
export function getHooks(directory, options, sessionState) {
|
|
40
129
|
const narrateToolStart = (input, output) => {
|
|
41
130
|
const tool = input.tool;
|
|
@@ -77,6 +166,9 @@ export function getHooks(directory, options, sessionState) {
|
|
|
77
166
|
(typeof output?.metadata === "object" && output.metadata?.error);
|
|
78
167
|
narrate(sessionState, errorPresent ? "dispatch.failed" : "dispatch.completed", { to });
|
|
79
168
|
};
|
|
169
|
+
// Is the current turn driven by an agent this plugin registered? Native
|
|
170
|
+
// opencode agents (build/plan/default chat) return false → hooks no-op.
|
|
171
|
+
const isActiveResolve = () => RESOLVE_AGENTS.has(sessionState.currentAgent ?? "");
|
|
80
172
|
return {
|
|
81
173
|
event: async (input) => {
|
|
82
174
|
const evt = input.event;
|
|
@@ -90,8 +182,17 @@ export function getHooks(directory, options, sessionState) {
|
|
|
90
182
|
: [];
|
|
91
183
|
const errors = diagnostics.filter((d) => d.severity === 1 || d.severity === "error").length;
|
|
92
184
|
const warnings = diagnostics.filter((d) => d.severity === 2 || d.severity === "warning").length;
|
|
185
|
+
// Phase 1.1: capture error messages + line numbers so resolve-diagnostics
|
|
186
|
+
// can show ±3 lines of context, saving the coder a separate read call.
|
|
187
|
+
const errorMessages = diagnostics
|
|
188
|
+
.filter((d) => d.severity === 1 || d.severity === "error")
|
|
189
|
+
.slice(0, 20)
|
|
190
|
+
.map((d) => ({
|
|
191
|
+
line: d.range?.start?.line ?? d.line ?? 0,
|
|
192
|
+
message: String(d.message ?? "").slice(0, 200),
|
|
193
|
+
}));
|
|
93
194
|
if (errors > 0 || warnings > 0) {
|
|
94
|
-
sessionState.recentDiagnostics.set(props.path, { errors, warnings, timestamp: Date.now() });
|
|
195
|
+
sessionState.recentDiagnostics.set(props.path, { errors, warnings, timestamp: Date.now(), errorMessages });
|
|
95
196
|
}
|
|
96
197
|
else {
|
|
97
198
|
sessionState.recentDiagnostics.delete(props.path);
|
|
@@ -131,9 +232,9 @@ export function getHooks(directory, options, sessionState) {
|
|
|
131
232
|
// Generate warnings for recurring failures
|
|
132
233
|
sessionState.totalFailures++;
|
|
133
234
|
sessionState.failureWarnings = [];
|
|
134
|
-
for (const [, v] of sessionState.failurePatterns) {
|
|
235
|
+
for (const [k, v] of sessionState.failurePatterns) {
|
|
135
236
|
if (v.count >= FAILURE_THRESHOLD) {
|
|
136
|
-
sessionState.failureWarnings.push(`Tool '${
|
|
237
|
+
sessionState.failureWarnings.push(`Tool '${k}' failed ${v.count} times. Last: ${v.lastMessage}`);
|
|
137
238
|
}
|
|
138
239
|
}
|
|
139
240
|
}
|
|
@@ -162,42 +263,6 @@ export function getHooks(directory, options, sessionState) {
|
|
|
162
263
|
}
|
|
163
264
|
}
|
|
164
265
|
}
|
|
165
|
-
// ── Ralph Loop: track edit tool calls for hotspot detection ────────
|
|
166
|
-
if (evt.type === "message.part.updated") {
|
|
167
|
-
const props = evt.properties;
|
|
168
|
-
const part = props.part;
|
|
169
|
-
if (part?.type === "tool-invocation" || part?.type === "tool-use") {
|
|
170
|
-
sessionState.totalToolCalls++;
|
|
171
|
-
const toolName = part.tool ?? part.toolName ?? "";
|
|
172
|
-
if (toolName === "edit" || toolName === "write") {
|
|
173
|
-
sessionState.totalEdits++;
|
|
174
|
-
const filePath = part.args?.filePath ?? part.args?.path ?? "";
|
|
175
|
-
if (filePath) {
|
|
176
|
-
const existing = sessionState.editHotspots.get(filePath);
|
|
177
|
-
if (existing) {
|
|
178
|
-
existing.count++;
|
|
179
|
-
existing.lastEditTime = Date.now();
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
sessionState.editHotspots.set(filePath, { count: 1, lastEditTime: Date.now() });
|
|
183
|
-
}
|
|
184
|
-
// Prune stale entries
|
|
185
|
-
const now = Date.now();
|
|
186
|
-
for (const [k, v] of sessionState.editHotspots) {
|
|
187
|
-
if (now - v.lastEditTime > EDIT_HOTSPOT_TTL_MS)
|
|
188
|
-
sessionState.editHotspots.delete(k);
|
|
189
|
-
}
|
|
190
|
-
// Generate loop warnings
|
|
191
|
-
sessionState.loopWarnings = [];
|
|
192
|
-
for (const [file, data] of sessionState.editHotspots) {
|
|
193
|
-
if (data.count >= EDIT_HOTSPOT_THRESHOLD) {
|
|
194
|
-
sessionState.loopWarnings.push(`File '${file}' edited ${data.count} times — consider a different approach. Keep iterating.`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
266
|
},
|
|
202
267
|
config: async (config) => {
|
|
203
268
|
const resolveConfig = await loadResolveConfig(directory, config, options);
|
|
@@ -206,14 +271,15 @@ export function getHooks(directory, options, sessionState) {
|
|
|
206
271
|
sessionState.storedProjectContext = projectContext;
|
|
207
272
|
sessionState.locale = resolveLocale(resolveConfig.language, process.env.LANG);
|
|
208
273
|
applyResolveConfig(config, resolveConfig, projectContext);
|
|
209
|
-
|
|
210
|
-
maybeAutoUpdate().catch(() => { });
|
|
211
|
-
}
|
|
274
|
+
// Auto-update removed — see src/utils.ts header. Users update manually.
|
|
212
275
|
},
|
|
213
276
|
"shell.env": async (_input, output) => {
|
|
214
277
|
output.env = {
|
|
215
278
|
...output.env,
|
|
216
|
-
CI
|
|
279
|
+
// CI=true is intentionally NOT forced — it strips color output from
|
|
280
|
+
// test/lint runners (jest/vitest/eslint go --no-colors under CI) and
|
|
281
|
+
// pollutes native usage. Only non-interactive guard vars are set, to
|
|
282
|
+
// prevent prompts (npm/git/pip/debconf) from hanging the agent.
|
|
217
283
|
DEBIAN_FRONTEND: "noninteractive",
|
|
218
284
|
GIT_TERMINAL_PROMPT: "0",
|
|
219
285
|
GIT_EDITOR: "true",
|
|
@@ -234,44 +300,48 @@ export function getHooks(directory, options, sessionState) {
|
|
|
234
300
|
: Array.isArray(input.pattern)
|
|
235
301
|
? input.pattern.join(" ")
|
|
236
302
|
: "";
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
303
|
+
// `permissions.allowGitReset` / `allowGitClean` only relax the policy for
|
|
304
|
+
// resolve agents — they are the ones `tool.execute.before` checkpoints.
|
|
305
|
+
// Native agents keep the universal deny.
|
|
306
|
+
const permissions = isActiveResolve() ? sessionState.storedConfig?.permissions : undefined;
|
|
307
|
+
const action = classifyBashCommand(cmd, permissions);
|
|
308
|
+
// Banned/dangerous commands are denied universally (protect everyone).
|
|
309
|
+
if (action === "deny") {
|
|
310
|
+
output.status = "deny";
|
|
311
|
+
}
|
|
312
|
+
else if (action === "allow" && isActiveResolve()) {
|
|
313
|
+
// Auto-approve safe commands only when a resolve agent drives the turn;
|
|
314
|
+
// native agents keep opencode's own permission flow.
|
|
315
|
+
output.status = "allow";
|
|
316
|
+
}
|
|
240
317
|
}
|
|
241
318
|
},
|
|
242
319
|
"chat.params": async (input, output) => {
|
|
243
320
|
if (typeof input.agent === "string" && input.agent.length > 0) {
|
|
244
321
|
sessionState.currentAgent = input.agent;
|
|
245
322
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
output.topP = 0.85;
|
|
255
|
-
}
|
|
323
|
+
// Only shape params for resolve agents — native opencode agents (build/plan)
|
|
324
|
+
// pass through so the plugin never alters their model behavior.
|
|
325
|
+
if (!isActiveResolve())
|
|
326
|
+
return;
|
|
327
|
+
// Deterministic params keep the resolve loop on-rails (coding benefits from low temperature).
|
|
328
|
+
output.temperature = capTemperature(output.temperature, 0.4);
|
|
329
|
+
if (output.maxOutputTokens === undefined || output.maxOutputTokens > 16384) {
|
|
330
|
+
output.maxOutputTokens = 16384;
|
|
256
331
|
}
|
|
257
|
-
|
|
258
|
-
output.
|
|
259
|
-
if (output.maxOutputTokens === undefined) {
|
|
260
|
-
output.maxOutputTokens = 32768;
|
|
261
|
-
}
|
|
332
|
+
if (output.topP === undefined || output.topP > 0.9) {
|
|
333
|
+
output.topP = 0.85;
|
|
262
334
|
}
|
|
263
335
|
// Read-only agents: lower temperature always
|
|
264
336
|
const readOnlyAgents = new Set(["reviewer", "deep-reviewer", "explorer", "planner", "researcher", "architect"]);
|
|
265
337
|
if (readOnlyAgents.has(input.agent)) {
|
|
266
338
|
output.temperature = capTemperature(output.temperature, 0.3);
|
|
267
339
|
}
|
|
268
|
-
// Write agents: slightly higher temperature for creative problem-solving
|
|
269
|
-
const writeAgents = new Set(["coder", "resolver", "codex", "glm", "gpt-coder"]);
|
|
270
|
-
if (writeAgents.has(input.agent) && output.temperature === undefined) {
|
|
271
|
-
output.temperature = 0.5;
|
|
272
|
-
}
|
|
273
340
|
},
|
|
274
341
|
"tool.definition": async (input, output) => {
|
|
342
|
+
// Don't pollute tool descriptions for native agents — resolve agents only.
|
|
343
|
+
if (!isActiveResolve())
|
|
344
|
+
return;
|
|
275
345
|
const prefix = `[${PLUGIN_BRAND}]`;
|
|
276
346
|
const hintKeys = {
|
|
277
347
|
edit: "tool.edit",
|
|
@@ -292,6 +362,9 @@ export function getHooks(directory, options, sessionState) {
|
|
|
292
362
|
}
|
|
293
363
|
},
|
|
294
364
|
"command.execute.before": async (_input, output) => {
|
|
365
|
+
// Only prepend the discipline reminder when a resolve agent drives the turn.
|
|
366
|
+
if (!isActiveResolve())
|
|
367
|
+
return;
|
|
295
368
|
// Prepend a discipline reminder to all command executions.
|
|
296
369
|
// The parts array is typed as Part[] — TextPart requires id/sessionID/messageID.
|
|
297
370
|
// We provide placeholder values; OpenCode replaces them if needed.
|
|
@@ -300,16 +373,43 @@ export function getHooks(directory, options, sessionState) {
|
|
|
300
373
|
sessionID: "",
|
|
301
374
|
messageID: "",
|
|
302
375
|
type: "text",
|
|
303
|
-
text:
|
|
376
|
+
text: `[${PLUGIN_BRAND}] ${t("system.driveResolution", "en")}`,
|
|
304
377
|
});
|
|
305
378
|
},
|
|
306
379
|
"tool.execute.before": async (input, output) => {
|
|
380
|
+
// Native agents get unmodified tool execution — resolve agents only.
|
|
381
|
+
if (!isActiveResolve())
|
|
382
|
+
return;
|
|
307
383
|
// For bash: inject hints for common mistakes
|
|
308
384
|
if (input.tool === "bash" && output.args && typeof output.args === "object") {
|
|
309
385
|
const cmd = output.args.command ?? output.args.cmd;
|
|
310
386
|
if (typeof cmd === "string" && cmd.includes("git commit") && !cmd.includes("-m")) {
|
|
311
387
|
output.args = { ...output.args, _resolve_hint: "Use 'git commit -m \"message\"' — interactive commit is blocked." };
|
|
312
388
|
}
|
|
389
|
+
// Rollback safety net: snapshot the worktree before `git reset --hard` /
|
|
390
|
+
// `git clean -f` destroys uncommitted work. Runs whenever the command is
|
|
391
|
+
// about to execute — whether it was un-gated by `permissions.allow*` or
|
|
392
|
+
// approved by the user at the prompt. If the snapshot cannot be written
|
|
393
|
+
// in a git repo, abort rather than run the command unprotected.
|
|
394
|
+
if (typeof cmd === "string") {
|
|
395
|
+
const rollback = detectRollbackCommand(cmd);
|
|
396
|
+
if (rollback && await isGitRepository(directory)) {
|
|
397
|
+
let checkpoint;
|
|
398
|
+
try {
|
|
399
|
+
checkpoint = await createRollbackCheckpoint(directory, rollback);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
throw new Error(`[${PLUGIN_BRAND}] refused to run '${cmd}': could not create a rollback checkpoint (${formatError(error)}). ` +
|
|
403
|
+
`Commit or stash your work first.`);
|
|
404
|
+
}
|
|
405
|
+
narrate(sessionState, "narration.checkpoint");
|
|
406
|
+
output.args = {
|
|
407
|
+
...output.args,
|
|
408
|
+
_resolve_checkpoint_ref: checkpoint.ref,
|
|
409
|
+
_resolve_checkpoint_restore: checkpoint.restoreCommand,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
313
413
|
}
|
|
314
414
|
// For write: warn about overwriting existing files
|
|
315
415
|
if (input.tool === "write" && output.args && typeof output.args === "object") {
|
|
@@ -331,7 +431,21 @@ export function getHooks(directory, options, sessionState) {
|
|
|
331
431
|
}
|
|
332
432
|
},
|
|
333
433
|
"tool.execute.after": async (input, output) => {
|
|
434
|
+
// Only track/count/instrument tool calls driven by resolve agents.
|
|
435
|
+
if (!isActiveResolve())
|
|
436
|
+
return;
|
|
437
|
+
// Count every completed tool call from the reliable tool.execute.after
|
|
438
|
+
// hook (the message-stream event hook was unreliable + double-counted).
|
|
439
|
+
sessionState.totalToolCalls++;
|
|
440
|
+
// Phase 1.0: measure cumulative tool output bytes for token-budget
|
|
441
|
+
// visibility. Only counts text the resolver actually consumes.
|
|
442
|
+
const _outText = typeof output?.output === "string" ? output.output
|
|
443
|
+
: typeof output?.output?.output === "string" ? output.output.output
|
|
444
|
+
: typeof output === "string" ? output : "";
|
|
445
|
+
if (_outText)
|
|
446
|
+
sessionState.totalOutputBytes += Buffer.byteLength(_outText, "utf8");
|
|
334
447
|
if (input.tool === "edit" || input.tool === "write") {
|
|
448
|
+
sessionState.totalEdits++;
|
|
335
449
|
const verifyCommands = sessionState.storedProjectContext?.verifyCommands;
|
|
336
450
|
const meta = { ...(output.metadata ?? {}) };
|
|
337
451
|
if (verifyCommands && verifyCommands.length > 0) {
|
|
@@ -358,22 +472,69 @@ export function getHooks(directory, options, sessionState) {
|
|
|
358
472
|
// Ralph Loop: inject loop warning into metadata
|
|
359
473
|
const hotspot = sessionState.editHotspots.get(editedPath);
|
|
360
474
|
if (hotspot && hotspot.count >= EDIT_HOTSPOT_THRESHOLD) {
|
|
361
|
-
meta._resolve_loop_warning =
|
|
475
|
+
meta._resolve_loop_warning = hotspotDiffPrompt(editedPath, hotspot.count);
|
|
362
476
|
}
|
|
363
477
|
}
|
|
364
478
|
output.metadata = meta;
|
|
479
|
+
// Ralph Loop: an edit just happened — mark that verification is now
|
|
480
|
+
// required before the resolver may report completion. Cleared when a
|
|
481
|
+
// verify command (typecheck/lint/test) runs via bash. Adaptive: skip
|
|
482
|
+
// the gate for non-code files (.md/.txt/.json/...) so trivial doc
|
|
483
|
+
// changes don't force a build/test round-trip.
|
|
484
|
+
if (isCodeFile(editedPath)) {
|
|
485
|
+
sessionState.awaitingVerify = true;
|
|
486
|
+
sessionState.awaitingVerifyFile = editedPath;
|
|
487
|
+
}
|
|
365
488
|
// Ralph Loop: update sessionState.loopWarnings after every edit/write
|
|
366
489
|
sessionState.loopWarnings = [];
|
|
367
490
|
for (const [file, data] of sessionState.editHotspots) {
|
|
368
491
|
if (data.count >= EDIT_HOTSPOT_THRESHOLD) {
|
|
369
|
-
sessionState.loopWarnings.push(
|
|
492
|
+
sessionState.loopWarnings.push(hotspotDiffPrompt(file, data.count));
|
|
370
493
|
}
|
|
371
494
|
}
|
|
372
495
|
}
|
|
373
496
|
// Role-play narration → terminal only. Does NOT enter LLM context.
|
|
374
497
|
narrateToolEnd(input, output);
|
|
498
|
+
// Ralph Loop: dispatch lifecycle — track resolver→coder results so the
|
|
499
|
+
// loop closes. A failed dispatch must trigger diagnosis before retry;
|
|
500
|
+
// repeated failures escalate (diagnose → STOP → architect pivot).
|
|
501
|
+
if (input.tool === "task") {
|
|
502
|
+
const sub = extractSubagentType(input?.args);
|
|
503
|
+
const failed = dispatchFailed(output);
|
|
504
|
+
sessionState.lastDispatchAgent = sub;
|
|
505
|
+
sessionState.lastDispatchSucceeded = !failed;
|
|
506
|
+
sessionState.lastDispatchAt = Date.now();
|
|
507
|
+
if (failed) {
|
|
508
|
+
sessionState.consecutiveDispatchFailures++;
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
sessionState.consecutiveDispatchFailures = 0;
|
|
512
|
+
}
|
|
513
|
+
// Attach a directive the resolver sees in tool metadata.
|
|
514
|
+
const dmeta = { ...(output.metadata ?? {}) };
|
|
515
|
+
const c = sessionState.consecutiveDispatchFailures;
|
|
516
|
+
if (failed) {
|
|
517
|
+
if (c >= DISPATCH_PIVOT_THRESHOLD) {
|
|
518
|
+
dmeta._resolve_loop_directive = `Ralph Loop: ${c} consecutive dispatch failures. Dispatch ARCHITECT to rethink the approach.`;
|
|
519
|
+
}
|
|
520
|
+
else if (c >= DISPATCH_STOP_THRESHOLD) {
|
|
521
|
+
dmeta._resolve_loop_directive = `Ralph Loop: ${c} consecutive dispatch failures. STOP — revert the last change and report to the user exactly what is blocked.`;
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
dmeta._resolve_loop_directive = `Ralph Loop: ${sub ?? "subagent"} dispatch failed (${c}x). Diagnose the ROOT CAUSE before re-dispatching. Do NOT retry the same way.`;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
output.metadata = dmeta;
|
|
528
|
+
}
|
|
375
529
|
// For bash: extract key error lines from failing commands
|
|
376
530
|
if (input.tool === "bash") {
|
|
531
|
+
// Ralph Loop: running a verify command clears the awaitingVerify flag —
|
|
532
|
+
// the resolver has now checked the edit it made.
|
|
533
|
+
const cmd = input.args?.command ?? input.args?.cmd;
|
|
534
|
+
if (isVerifyCommand(cmd, sessionState.storedProjectContext?.verifyCommands)) {
|
|
535
|
+
sessionState.awaitingVerify = false;
|
|
536
|
+
sessionState.awaitingVerifyFile = undefined;
|
|
537
|
+
}
|
|
377
538
|
const outputText = typeof output.output === "string" ? output.output
|
|
378
539
|
: output.output?.output ?? "";
|
|
379
540
|
const exitCode = output.metadata?.exitCode ?? output.output?.metadata?.exitCode;
|
|
@@ -390,15 +551,16 @@ export function getHooks(directory, options, sessionState) {
|
|
|
390
551
|
}
|
|
391
552
|
},
|
|
392
553
|
"experimental.session.compacting": async (_input, output) => {
|
|
554
|
+
// Only preserve resolve-loop context; native compaction is untouched.
|
|
555
|
+
if (!isActiveResolve())
|
|
556
|
+
return;
|
|
393
557
|
narrate(sessionState, "narration.compacting");
|
|
394
558
|
const ctx = sessionState.storedProjectContext;
|
|
395
559
|
const cfg = sessionState.storedConfig;
|
|
396
560
|
if (!ctx && !cfg)
|
|
397
561
|
return;
|
|
398
562
|
const contextLines = [];
|
|
399
|
-
//
|
|
400
|
-
if (cfg?.profile)
|
|
401
|
-
contextLines.push(`Profile: ${cfg.profile}.`);
|
|
563
|
+
// Tier info
|
|
402
564
|
if (cfg?.tier)
|
|
403
565
|
contextLines.push(`Tier: ${cfg.tier}.`);
|
|
404
566
|
// Project context
|
|
@@ -434,60 +596,50 @@ export function getHooks(directory, options, sessionState) {
|
|
|
434
596
|
}
|
|
435
597
|
},
|
|
436
598
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
"Analyze the subtask result above. If it succeeded, continue. If it failed, diagnose and retry. Report completion only when verified."],
|
|
441
|
-
// Regex: any "Summarize ... and continue" variant
|
|
442
|
-
[/Summarize the .+ output above and continue/i,
|
|
443
|
-
"Analyze the result above. If it succeeded, continue to the next step. If it failed, diagnose root cause and retry with a fix."],
|
|
444
|
-
// Regex: generic "continue with your task" ending
|
|
445
|
-
[/continue with your task\.$/i,
|
|
446
|
-
"continue driving toward verified completion."],
|
|
447
|
-
// Regex: "I've completed..." without verification
|
|
448
|
-
[/I('ve| have) (completed|finished|done) (the )?.*\.$/i,
|
|
449
|
-
"Verify your changes pass typecheck/lint/test before reporting completion."],
|
|
450
|
-
// Regex: passive "Let me know if..."
|
|
451
|
-
[/let me know if (you|you'd like) .*/i,
|
|
452
|
-
"Proceed with the next step. If blocked, diagnose and report specifically what failed."],
|
|
453
|
-
// Regex: "Would you like me to..."
|
|
454
|
-
[/would you like me to .*/i,
|
|
455
|
-
"Proceed with the most effective next step autonomously."],
|
|
456
|
-
// Ralph Loop: detect "I'll try again" — encourage different approach, don't stop
|
|
457
|
-
[/I('ll| will) (try again|retry|attempt again|redo)/i,
|
|
458
|
-
"Diagnose the ROOT CAUSE of the failure, then apply a DIFFERENT fix. The Ralph Loop keeps going."],
|
|
459
|
-
// Regex: "I'm not sure" — uncertainty without action
|
|
460
|
-
[/I('m| am) (not sure|unsure|uncertain) .*/i,
|
|
461
|
-
"Resolve uncertainty by reading the code, checking diagnostics, or using resolve-search. Keep driving."],
|
|
462
|
-
// Regex: "This might work" — low confidence
|
|
463
|
-
[/this (might|should|could|may) work/i,
|
|
464
|
-
"CONFIRM it works by running verification. Do not assume."],
|
|
465
|
-
// Regex: "It seems to be working" — unverified claim
|
|
466
|
-
[/it (seems|appears|looks) to (be )?(working|fine|correct)/i,
|
|
467
|
-
"VERIFY with typecheck/lint/test. 'Seems to work' is not evidence."],
|
|
468
|
-
];
|
|
599
|
+
// Rewrite passive language only within resolve turns — native chat is verbatim.
|
|
600
|
+
if (!isActiveResolve())
|
|
601
|
+
return;
|
|
469
602
|
for (const msg of output.messages) {
|
|
470
603
|
for (const part of msg.parts) {
|
|
471
604
|
if (part.type !== "text")
|
|
472
605
|
continue;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
606
|
+
// Preserve collaborative handoffs/questions — never touch a turn that
|
|
607
|
+
// asks the user something.
|
|
608
|
+
if (HANDOFF_PATTERNS.some((p) => p.test(part.text.trim())))
|
|
609
|
+
continue;
|
|
610
|
+
const boilerplate = HARNESS_BOILERPLATE_REWRITES.find(([pattern]) => typeof pattern === "string" ? part.text === pattern : pattern.test(part.text));
|
|
611
|
+
if (boilerplate) {
|
|
612
|
+
part.text = boilerplate[1];
|
|
613
|
+
continue;
|
|
478
614
|
}
|
|
615
|
+
// transform() sees the whole message list every turn, so an appended
|
|
616
|
+
// nudge would compound. One nudge per part, ever.
|
|
617
|
+
if (part.text.includes(NUDGE_MARKER))
|
|
618
|
+
continue;
|
|
619
|
+
const nudge = SELF_REVIEW_NUDGES.find(([pattern]) => pattern.test(part.text));
|
|
620
|
+
if (nudge)
|
|
621
|
+
part.text = `${part.text}\n\n${NUDGE_MARKER} ${nudge[1]}`;
|
|
479
622
|
}
|
|
480
623
|
}
|
|
481
624
|
},
|
|
482
625
|
"experimental.compaction.autocontinue": async (_input, output) => {
|
|
626
|
+
// Only force auto-continue for resolve agents — native agents keep their own cadence.
|
|
627
|
+
if (!isActiveResolve())
|
|
628
|
+
return;
|
|
483
629
|
// Always enable auto-continue — the resolver should keep driving
|
|
484
630
|
output.enabled = true;
|
|
485
631
|
},
|
|
486
632
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
633
|
+
// Inject system context/warnings only into resolve-agent turns — native
|
|
634
|
+
// agents (build/plan/default chat) get their own system prompt untouched.
|
|
635
|
+
if (!isActiveResolve())
|
|
636
|
+
return;
|
|
487
637
|
const ctx = sessionState.storedProjectContext;
|
|
488
638
|
const hasFailures = sessionState.failureWarnings.length > 0;
|
|
489
639
|
const hasLoops = sessionState.loopWarnings.length > 0;
|
|
490
|
-
|
|
640
|
+
const hasDispatchFailures = sessionState.consecutiveDispatchFailures > 0;
|
|
641
|
+
const needsVerify = sessionState.awaitingVerify;
|
|
642
|
+
if (!ctx && !hasFailures && !hasLoops && !hasDispatchFailures && !needsVerify)
|
|
491
643
|
return;
|
|
492
644
|
const lines = [];
|
|
493
645
|
const agent = sessionState.currentAgent;
|
|
@@ -524,7 +676,10 @@ export function getHooks(directory, options, sessionState) {
|
|
|
524
676
|
for (const w of sessionState.loopWarnings.slice(0, 3)) {
|
|
525
677
|
lines.push(` - ${w}`);
|
|
526
678
|
}
|
|
679
|
+
// Self-observation first — reading your own diff is the cheapest way out
|
|
680
|
+
// of a hotspot, and the only one that cannot pollute an unrelated file.
|
|
527
681
|
const strategyKeys = [
|
|
682
|
+
"strategy.reviewDiff",
|
|
528
683
|
"strategy.rereadFile",
|
|
529
684
|
"strategy.tryDifferent",
|
|
530
685
|
"strategy.useDiagnostics",
|
|
@@ -552,11 +707,37 @@ export function getHooks(directory, options, sessionState) {
|
|
|
552
707
|
}));
|
|
553
708
|
lines.push(t("system.iterationWarning", "en"));
|
|
554
709
|
}
|
|
710
|
+
// Ralph Loop: enforced loop closure — dispatch lifecycle escalation.
|
|
711
|
+
// 1-2 failures: diagnose + retry. 3: STOP. 6+: architect pivot.
|
|
712
|
+
const dc = sessionState.consecutiveDispatchFailures;
|
|
713
|
+
if (dc > 0) {
|
|
714
|
+
if (dc >= DISPATCH_PIVOT_THRESHOLD) {
|
|
715
|
+
lines.push(contextMessage(agent, "system.dispatchPivot"));
|
|
716
|
+
}
|
|
717
|
+
else if (dc >= DISPATCH_STOP_THRESHOLD) {
|
|
718
|
+
lines.push(contextMessage(agent, "system.dispatchStop", { count: dc }));
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
lines.push(contextMessage(agent, "system.dispatchEscalate", {
|
|
722
|
+
count: dc,
|
|
723
|
+
agent: sessionState.lastDispatchAgent ?? "subagent",
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Ralph Loop: a pending edit MUST be verified before reporting done.
|
|
728
|
+
if (sessionState.awaitingVerify) {
|
|
729
|
+
lines.push(contextMessage(agent, "system.awaitingVerify", {
|
|
730
|
+
file: sessionState.awaitingVerifyFile ?? "",
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
555
733
|
if (lines.length > 0) {
|
|
556
|
-
output.system.push(lines.join("\n"));
|
|
734
|
+
output.system.push(`${brand(undefined)}\n${lines.join("\n")}`);
|
|
557
735
|
}
|
|
558
736
|
},
|
|
559
737
|
"experimental.text.complete": async (_input, output) => {
|
|
738
|
+
// Append verify reminders only to resolve-agent completions — native output is verbatim.
|
|
739
|
+
if (!isActiveResolve())
|
|
740
|
+
return;
|
|
560
741
|
const text = output.text ?? "";
|
|
561
742
|
if (!text)
|
|
562
743
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,8 @@ export * from "./config.js";
|
|
|
5
5
|
export * from "./state.js";
|
|
6
6
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
7
7
|
export declare const OpencodeResolve: Plugin;
|
|
8
|
-
|
|
8
|
+
declare const _default: {
|
|
9
|
+
id: string;
|
|
10
|
+
server: Plugin;
|
|
11
|
+
};
|
|
12
|
+
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -24,4 +24,7 @@ export const OpencodeResolve = async ({ directory }, options) => {
|
|
|
24
24
|
tool: getTools(sessionState)
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
|
-
|
|
27
|
+
// V1 plugin format — avoids opencode's legacy getLegacyPlugins fallback path,
|
|
28
|
+
// which iterates Object.values(mod) and throws on non-function exports
|
|
29
|
+
// (DEFAULT_MODELS, VALID_MODES, PLUGIN_VERSION, etc. from the re-exports above).
|
|
30
|
+
export default { id: "opencode-resolve", server: OpencodeResolve };
|
package/dist/messages.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare function resolveLocale(configured: string | undefined, envLang: s
|
|
|
6
6
|
export declare function brand(agent: string | undefined): string;
|
|
7
7
|
/** Friendly display name per agent (used in role-play narration). */
|
|
8
8
|
export declare function agentDisplayName(agent: string | undefined, locale: Locale): string;
|
|
9
|
-
export type MessageKey = "reminder.verify" | "reminder.ralphLoopText" | "system.driveResolution" | "system.projectKnowledge" | "system.contextDocs" | "system.verifyCommands" | "system.typescriptMandatory" | "system.failuresHeader" | "system.failuresFooter" | "system.strategyPivotHeader" | "system.strategyPivotBody" | "system.strategyPivotTail" | "system.ralphHeader" | "system.ralphKeepGoing" | "system.sessionStats" | "system.iterationWarning" | "compaction.contextHeader" | "tool.edit" | "tool.write" | "tool.bash" | "tool.task" | "tool.glob" | "tool.grep" | "tool.read" | "tool.webfetch" | "tool.todowrite" | "dispatch.toSubagent" | "dispatch.fromResolver" | "dispatch.coder" | "dispatch.reviewer" | "dispatch.deepReviewer" | "dispatch.explorer" | "dispatch.planner" | "dispatch.architect" | "dispatch.researcher" | "dispatch.debugger" | "dispatch.
|
|
9
|
+
export type MessageKey = "reminder.verify" | "reminder.ralphLoopText" | "system.driveResolution" | "system.projectKnowledge" | "system.contextDocs" | "system.verifyCommands" | "system.typescriptMandatory" | "system.failuresHeader" | "system.failuresFooter" | "system.strategyPivotHeader" | "system.strategyPivotBody" | "system.strategyPivotTail" | "system.ralphHeader" | "system.ralphKeepGoing" | "system.sessionStats" | "system.iterationWarning" | "system.dispatchEscalate" | "system.dispatchStop" | "system.dispatchPivot" | "system.awaitingVerify" | "compaction.contextHeader" | "tool.edit" | "tool.write" | "tool.bash" | "tool.task" | "tool.glob" | "tool.grep" | "tool.read" | "tool.webfetch" | "tool.todowrite" | "dispatch.toSubagent" | "dispatch.fromResolver" | "dispatch.coder" | "dispatch.reviewer" | "dispatch.deepReviewer" | "dispatch.explorer" | "dispatch.planner" | "dispatch.architect" | "dispatch.researcher" | "dispatch.debugger" | "dispatch.completed" | "dispatch.failed" | "narration.editing" | "narration.searching" | "narration.reading" | "narration.thinking" | "narration.bashing" | "narration.compacting" | "narration.writing" | "narration.testing" | "narration.typechecking" | "narration.linting" | "narration.git" | "narration.checkpoint" | "narration.fetch" | "narration.todo" | "narration.diagnostics" | "narration.context" | "narration.verifyPass" | "narration.verifyFail" | "narration.idle" | "strategy.smallerPieces" | "strategy.differentFile" | "strategy.readTest" | "strategy.checkImports" | "strategy.searchSimilar" | "strategy.rereadFile" | "strategy.tryDifferent" | "strategy.useDiagnostics" | "strategy.reviewDiff" | "strategy.suggestionLabel";
|
|
10
10
|
type Params = Record<string, string | number>;
|
|
11
11
|
/** Render a message in the requested locale. Picks a random variant if multiple are defined. */
|
|
12
12
|
export declare function t(key: MessageKey, locale: Locale, params?: Params): string;
|