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.
@@ -1,7 +1,80 @@
1
- import { DIAGNOSTICS_TTL_MS, FAILURE_PATTERN_TTL_MS, FAILURE_THRESHOLD, STRATEGY_PIVOT_THRESHOLD, EDIT_HOTSPOT_TTL_MS, EDIT_HOTSPOT_THRESHOLD } from "../state.js";
2
- import { classifyBashCommand, maybeAutoUpdate, detectProjectContext } from "../utils.js";
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 '${toolName}' failed ${v.count} times. Last: ${v.lastMessage}`);
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
- if (resolveConfig.autoUpdate !== false && process.env.OPENCODE_RESOLVE_NO_AUTO_UPDATE !== "1") {
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: "true",
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
- const action = classifyBashCommand(cmd);
238
- if (action !== "ask")
239
- output.status = action;
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
- const profile = sessionState.storedConfig?.profile;
247
- if (profile === "glm") {
248
- output.temperature = capTemperature(output.temperature, 0.4);
249
- if (output.maxOutputTokens === undefined || output.maxOutputTokens > 16384) {
250
- output.maxOutputTokens = 16384;
251
- }
252
- // GLM: tighter topP for deterministic output
253
- if (output.topP === undefined || output.topP > 0.9) {
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
- else if (profile === "gpt") {
258
- output.temperature = capTemperature(output.temperature, 0.7);
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: contextMessage(sessionState.currentAgent, "system.driveResolution"),
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 = `This file has been edited ${hotspot.count} times. Consider a different approach.`;
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(`File '${file}' edited ${data.count} times — consider a different approach for this file. Keep iterating.`);
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
- // Profile and tier info
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
- const replacements = [
438
- // Exact: default OpenCode "continue" prompt
439
- ["Summarize the task tool output above and continue with your task.",
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
- for (const [pattern, replacement] of replacements) {
474
- if (typeof pattern === "string" ? part.text === pattern : pattern.test(part.text)) {
475
- part.text = replacement;
476
- break; // first match wins
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
- if (!ctx && !hasFailures && !hasLoops)
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
- export default OpencodeResolve;
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
- export default OpencodeResolve;
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 };
@@ -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.codex" | "dispatch.glm" | "dispatch.gpt" | "dispatch.gptCoder" | "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.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.suggestionLabel";
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;