@glrs-dev/harness-plugin-opencode 2.1.0 → 2.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +42 -106
  3. package/SECURITY.md +1 -1
  4. package/dist/agents/prompts/build.md +34 -4
  5. package/dist/agents/prompts/build.open.md +18 -4
  6. package/dist/agents/prompts/code-reviewer-thorough.md +77 -0
  7. package/dist/agents/prompts/code-reviewer.md +80 -0
  8. package/dist/agents/prompts/code-reviewer.open.md +68 -0
  9. package/dist/agents/prompts/debriefer.md +55 -0
  10. package/dist/agents/prompts/gap-analyzer.md +2 -0
  11. package/dist/agents/prompts/plan-reviewer.md +5 -1
  12. package/dist/agents/prompts/plan.md +119 -10
  13. package/dist/agents/prompts/prime.md +149 -88
  14. package/dist/agents/prompts/research-auto.md +1 -1
  15. package/dist/agents/prompts/research-local.md +1 -1
  16. package/dist/agents/prompts/research-web.md +1 -1
  17. package/dist/agents/prompts/research.md +2 -0
  18. package/dist/agents/prompts/scoper.md +129 -0
  19. package/dist/agents/prompts/spec-reviewer.md +53 -0
  20. package/dist/agents/prompts/spec-reviewer.open.md +56 -0
  21. package/dist/agents/shared/index.ts +1 -0
  22. package/dist/agents/shared/ui-evaluation-ladder.md +50 -0
  23. package/dist/agents/shared/workflow-mechanics.md +5 -5
  24. package/dist/autopilot/prompt-template.md +104 -0
  25. package/dist/chunk-GCWHRUOK.js +259 -0
  26. package/dist/chunk-MJSMBY2Y.js +87 -0
  27. package/dist/chunk-NIFAVPNN.js +544 -0
  28. package/dist/{chunk-VJUETC6A.js → chunk-PDMXYZM4.js} +53 -1
  29. package/dist/cli.js +1596 -1964
  30. package/dist/commands/prompts/fresh.md +27 -24
  31. package/dist/commands/prompts/review.md +3 -3
  32. package/dist/commands/prompts/ship.md +2 -0
  33. package/dist/index.js +188 -633
  34. package/dist/loop-session-J35NILUZ.js +30 -0
  35. package/dist/opencode-server-KPCDFYAX.js +22 -0
  36. package/dist/plan-parser-TMHEKT22.js +6 -0
  37. package/dist/plan-session-7VS32P52.js +117 -0
  38. package/dist/scoper-S77SOK7X.js +326 -0
  39. package/dist/skills/adversarial-review-rubric/SKILL.md +47 -0
  40. package/dist/skills/code-quality/SKILL.md +1 -1
  41. package/dist/skills/root-cause-diagnosis/SKILL.md +24 -0
  42. package/dist/skills/spear-protocol/SKILL.md +167 -0
  43. package/package.json +3 -1
  44. package/dist/agents/prompts/pilot-assessor.md +0 -77
  45. package/dist/agents/prompts/pilot-builder.md +0 -40
  46. package/dist/agents/prompts/pilot-planner.md +0 -56
  47. package/dist/agents/prompts/pilot-scoper.md +0 -58
  48. package/dist/agents/prompts/qa-reviewer.md +0 -68
  49. package/dist/agents/prompts/qa-reviewer.open.md +0 -58
  50. package/dist/agents/prompts/qa-thorough.md +0 -63
  51. package/dist/bin/plan-check.sh +0 -255
  52. package/dist/chunk-6CZPRUMJ.js +0 -869
  53. package/dist/chunk-DZG4D3OH.js +0 -54
  54. package/dist/chunk-OYRKOEXK.js +0 -88
  55. package/dist/commands/prompts/autopilot.md +0 -96
  56. package/dist/install-6775ZBDG.js +0 -13
  57. package/dist/paths-WZ23ZQOV.js +0 -18
@@ -0,0 +1,544 @@
1
+ import {
2
+ parsePlanState
3
+ } from "./chunk-MJSMBY2Y.js";
4
+ import {
5
+ createSession,
6
+ getLastAssistantMessage,
7
+ getSessionCost,
8
+ sendAndWait,
9
+ startServer
10
+ } from "./chunk-GCWHRUOK.js";
11
+
12
+ // src/autopilot/loop.ts
13
+ import { execFile as execFileCb } from "child_process";
14
+ import { promisify } from "util";
15
+ import { readFileSync } from "fs";
16
+ import { join as join3 } from "path";
17
+
18
+ // src/lib/logger.ts
19
+ import pino from "pino";
20
+ import PinoPretty from "pino-pretty";
21
+ import { existsSync, mkdirSync } from "fs";
22
+ import { dirname, join } from "path";
23
+ function resolveStderrLevel() {
24
+ const env = process.env["GLRS_LOG_LEVEL"];
25
+ if (env && ["fatal", "error", "warn", "info", "debug", "trace", "silent"].includes(env)) {
26
+ return env;
27
+ }
28
+ return "info";
29
+ }
30
+ function shouldPrettyPrint() {
31
+ if (process.env["GLRS_LOG_FORMAT"] === "json") return false;
32
+ if (process.env["GLRS_LOG_FORMAT"] === "pretty") return true;
33
+ return process.stderr.isTTY ?? false;
34
+ }
35
+ function resolveLogFilePath(cwd) {
36
+ const env = process.env["GLRS_AUTOPILOT_LOG_FILE"];
37
+ if (env === "off") return null;
38
+ if (env) return env;
39
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
40
+ return join(cwd, ".agent", "autopilot-logs", `${timestamp}.log`);
41
+ }
42
+ function buildStderrStream(level) {
43
+ if (shouldPrettyPrint()) {
44
+ const pretty = PinoPretty({
45
+ colorize: true,
46
+ translateTime: "HH:MM:ss.l",
47
+ ignore: "pid,hostname,component",
48
+ messageFormat: "[{component}] {msg}",
49
+ destination: 2
50
+ // stderr
51
+ });
52
+ return { level, stream: pretty };
53
+ }
54
+ return {
55
+ level,
56
+ stream: pino.destination({ fd: 2, sync: false })
57
+ };
58
+ }
59
+ function buildFileStream(cwd) {
60
+ const filePath = resolveLogFilePath(cwd);
61
+ if (!filePath) return null;
62
+ const parent = dirname(filePath);
63
+ if (!existsSync(parent)) {
64
+ mkdirSync(parent, { recursive: true });
65
+ }
66
+ return {
67
+ path: filePath,
68
+ entry: {
69
+ level: "trace",
70
+ // sync: true gives deterministic flushSync semantics so the file
71
+ // log is safe to read immediately after flush() returns (critical
72
+ // for tests and for reliable postmortem after a crash). Autopilot
73
+ // runs are long-lived enough that sync writes aren't a bottleneck.
74
+ stream: pino.destination({ dest: filePath, sync: true, mkdir: true })
75
+ }
76
+ };
77
+ }
78
+ function createAutopilotLogger(opts) {
79
+ const stderrLevel = resolveStderrLevel();
80
+ const fileSink = buildFileStream(opts.cwd);
81
+ const streams = [buildStderrStream(stderrLevel)];
82
+ if (fileSink) streams.push(fileSink.entry);
83
+ const ms = pino.multistream(streams);
84
+ const root = pino(
85
+ {
86
+ level: "trace",
87
+ // router gate; individual streams apply their own levels
88
+ timestamp: pino.stdTimeFunctions.isoTime
89
+ },
90
+ ms
91
+ );
92
+ const flush = async () => {
93
+ try {
94
+ ms.flushSync();
95
+ } catch {
96
+ }
97
+ };
98
+ return {
99
+ root,
100
+ logFilePath: fileSink?.path ?? null,
101
+ flush
102
+ };
103
+ }
104
+ function childLogger(root, component) {
105
+ return root.child({ component });
106
+ }
107
+
108
+ // src/autopilot/status.ts
109
+ function formatElapsed(ms) {
110
+ const totalSeconds = Math.floor(ms / 1e3);
111
+ const hours = Math.floor(totalSeconds / 3600);
112
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
113
+ const seconds = totalSeconds % 60;
114
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
115
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
116
+ return `${seconds}s`;
117
+ }
118
+ function formatCost(usd) {
119
+ if (usd === 0) return "$0.00";
120
+ return `$${usd.toFixed(3)}`;
121
+ }
122
+ function composeStatusMessage(state, now) {
123
+ const elapsed = formatElapsed(now - state.startedAt);
124
+ const cost = formatCost(state.cumulativeCostUsd);
125
+ const iterNote = state.iterationsCompleted === 0 ? "iteration 1 in flight" : `${state.iterationsCompleted} iteration${state.iterationsCompleted === 1 ? "" : "s"} complete`;
126
+ let planNote = "";
127
+ if (state.phaseCount !== void 0 && state.phasesCompleted !== void 0 && state.mainCheckboxesTotal !== void 0 && state.mainCheckboxesCompleted !== void 0) {
128
+ planNote = `, phase ${state.phasesCompleted}/${state.phaseCount}, ${state.mainCheckboxesCompleted}/${state.mainCheckboxesTotal} boxes`;
129
+ }
130
+ if (state.lastIterationErrored) {
131
+ return `working (${iterNote}, ${elapsed} elapsed, ${cost} used${planNote}) \u2014 last iteration errored`;
132
+ }
133
+ return `working (${iterNote}, ${elapsed} elapsed, ${cost} used${planNote})`;
134
+ }
135
+ function createStatusHeartbeat(opts) {
136
+ const now = opts._deps?.now ?? (() => Date.now());
137
+ const setIntervalFn = opts._deps?.setInterval ?? setInterval;
138
+ const clearIntervalFn = opts._deps?.clearInterval ?? clearInterval;
139
+ const state = {
140
+ startedAt: now(),
141
+ iterationsCompleted: 0,
142
+ cumulativeCostUsd: 0,
143
+ lastIterationProgress: false,
144
+ lastIterationErrored: false
145
+ };
146
+ let timerId = null;
147
+ const tick = () => {
148
+ if (opts.pollCost) {
149
+ opts.pollCost().then((cost) => {
150
+ if (cost > 0) state.cumulativeCostUsd = cost;
151
+ }).catch(() => {
152
+ });
153
+ }
154
+ const message = composeStatusMessage(state, now());
155
+ opts.logger.info(
156
+ {
157
+ elapsedMs: now() - state.startedAt,
158
+ iterationsCompleted: state.iterationsCompleted,
159
+ cumulativeCostUsd: state.cumulativeCostUsd,
160
+ lastIterationProgress: state.lastIterationProgress,
161
+ lastIterationErrored: state.lastIterationErrored
162
+ },
163
+ message
164
+ );
165
+ };
166
+ return {
167
+ start() {
168
+ if (timerId !== null) return;
169
+ timerId = setIntervalFn(tick, opts.intervalMs);
170
+ },
171
+ stop() {
172
+ if (timerId === null) return;
173
+ clearIntervalFn(timerId);
174
+ timerId = null;
175
+ },
176
+ update(patch) {
177
+ Object.assign(state, patch);
178
+ },
179
+ getState() {
180
+ return { ...state };
181
+ }
182
+ };
183
+ }
184
+
185
+ // src/autopilot/config.ts
186
+ var MAX_ITERATIONS = 50;
187
+ var STRUGGLE_THRESHOLD = 3;
188
+ var TIMEOUT_MS = 4 * 60 * 60 * 1e3;
189
+ var STALL_MS = 60 * 60 * 1e3;
190
+ var KILL_SWITCH_PATH = ".agent/autopilot-disable";
191
+ var SENTINEL_TAG = "<autopilot-done>";
192
+ var STATUS_INTERVAL_MS = (() => {
193
+ const env = process.env["GLRS_AUTOPILOT_STATUS_INTERVAL_MS"];
194
+ if (!env) return 5 * 60 * 1e3;
195
+ const n = Number.parseInt(env, 10);
196
+ if (Number.isNaN(n) || n < 1e3 || n > 60 * 60 * 1e3) return 5 * 60 * 1e3;
197
+ return n;
198
+ })();
199
+
200
+ // src/autopilot/sentinel.ts
201
+ function detectSentinel(text) {
202
+ if (!text.includes(SENTINEL_TAG)) {
203
+ return false;
204
+ }
205
+ const withoutFences = text.replace(/```[\s\S]*?```/g, "");
206
+ const withoutInline = withoutFences.replace(/`[^`\n]*`/g, "");
207
+ return withoutInline.includes(SENTINEL_TAG);
208
+ }
209
+
210
+ // src/autopilot/struggle.ts
211
+ import * as fs from "fs";
212
+ import * as path from "path";
213
+ var StruggleDetector = class {
214
+ _consecutiveStalls = 0;
215
+ _threshold;
216
+ constructor(threshold) {
217
+ this._threshold = threshold;
218
+ }
219
+ /** Number of consecutive stall iterations recorded so far. */
220
+ get consecutiveStalls() {
221
+ return this._consecutiveStalls;
222
+ }
223
+ /**
224
+ * Record the result of one iteration.
225
+ * @param madeProgress - true if the agent made filesystem changes this iteration.
226
+ */
227
+ record(madeProgress) {
228
+ if (madeProgress) {
229
+ this._consecutiveStalls = 0;
230
+ } else {
231
+ this._consecutiveStalls++;
232
+ }
233
+ }
234
+ /**
235
+ * Returns true if the agent has stalled for `threshold` consecutive
236
+ * iterations without making progress.
237
+ */
238
+ isStruggling() {
239
+ return this._consecutiveStalls >= this._threshold;
240
+ }
241
+ };
242
+ function checkKillSwitch(cwd) {
243
+ const killSwitchFile = path.join(cwd, KILL_SWITCH_PATH);
244
+ return fs.existsSync(killSwitchFile);
245
+ }
246
+
247
+ // src/autopilot/loop.ts
248
+ var execFile = promisify(execFileCb);
249
+ function buildFullPrompt(userPrompt) {
250
+ const candidates = [
251
+ join3(import.meta.dir, "prompt-template.md"),
252
+ join3(import.meta.dir, "..", "..", "src", "autopilot", "prompt-template.md")
253
+ ];
254
+ let template = "";
255
+ for (const candidate of candidates) {
256
+ try {
257
+ const raw = readFileSync(candidate, "utf8");
258
+ template = raw.replace(/^---\n[\s\S]*?\n---\n/, "");
259
+ break;
260
+ } catch {
261
+ }
262
+ }
263
+ const withArgs = template.replace("$ARGUMENTS", userPrompt);
264
+ return withArgs || userPrompt;
265
+ }
266
+ async function checkProgress(cwd, baseRef) {
267
+ try {
268
+ const { stdout } = await execFile("git", ["diff", "--stat", baseRef], { cwd });
269
+ return stdout.trim().length > 0;
270
+ } catch {
271
+ return true;
272
+ }
273
+ }
274
+ async function getHeadSha(cwd) {
275
+ try {
276
+ const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd });
277
+ return stdout.trim();
278
+ } catch {
279
+ return "HEAD";
280
+ }
281
+ }
282
+ async function runRalphLoop(opts) {
283
+ const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
284
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
285
+ const stallMs = opts.stallMs ?? STALL_MS;
286
+ const struggleThreshold = opts.struggleThreshold ?? STRUGGLE_THRESHOLD;
287
+ const _startServer = opts._deps?.startServer ?? startServer;
288
+ const _createSession = opts._deps?.createSession ?? createSession;
289
+ const _sendAndWait = opts._deps?.sendAndWait ?? sendAndWait;
290
+ const _getLastAssistantMessage = opts._deps?.getLastAssistantMessage ?? getLastAssistantMessage;
291
+ const fullPrompt = buildFullPrompt(opts.prompt);
292
+ const struggle = new StruggleDetector(struggleThreshold);
293
+ const startTime = Date.now();
294
+ const autopilotLog = createAutopilotLogger({ cwd: opts.cwd });
295
+ const log = childLogger(autopilotLog.root, "autopilot.loop");
296
+ const toolLog = childLogger(autopilotLog.root, "autopilot.tool");
297
+ const streamLog = childLogger(autopilotLog.root, "autopilot.stream");
298
+ const statusLog = childLogger(autopilotLog.root, "autopilot.status");
299
+ let heartbeat = null;
300
+ if (autopilotLog.logFilePath) {
301
+ log.info({ file: autopilotLog.logFilePath }, `Logging to ${autopilotLog.logFilePath}`);
302
+ }
303
+ log.info({ cwd: opts.cwd, maxIterations, timeoutMs }, "Starting OpenCode server");
304
+ const server = await _startServer({ cwd: opts.cwd });
305
+ log.info({ url: server.url }, "Server ready");
306
+ const abort = new AbortController();
307
+ const timeoutHandle = setTimeout(() => {
308
+ abort.abort();
309
+ }, timeoutMs);
310
+ let sessionId;
311
+ try {
312
+ sessionId = await _createSession(server.client, {
313
+ cwd: opts.cwd,
314
+ agentName: "autopilot-prime"
315
+ });
316
+ log.info({ sessionId }, "Session created with autopilot-prime");
317
+ heartbeat = createStatusHeartbeat({
318
+ logger: statusLog,
319
+ intervalMs: STATUS_INTERVAL_MS,
320
+ pollCost: async () => getSessionCost(server.client, sessionId)
321
+ });
322
+ heartbeat.start();
323
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
324
+ if (checkKillSwitch(opts.cwd)) {
325
+ log.warn({ iteration: iteration - 1 }, "Kill switch active \u2014 stopping");
326
+ return {
327
+ exitReason: "kill-switch",
328
+ iterations: iteration - 1,
329
+ message: `Kill switch active (.agent/autopilot-disable exists). Stopping after ${iteration - 1} iteration(s).`,
330
+ sessionId
331
+ };
332
+ }
333
+ if (Date.now() - startTime >= timeoutMs) {
334
+ log.warn({ iteration: iteration - 1, timeoutMs }, "Total timeout exceeded");
335
+ return {
336
+ exitReason: "timeout",
337
+ iterations: iteration - 1,
338
+ message: `Total timeout (${timeoutMs}ms) exceeded after ${iteration - 1} iteration(s).`,
339
+ sessionId
340
+ };
341
+ }
342
+ const headBefore = await getHeadSha(opts.cwd);
343
+ const iterationBaseCost = heartbeat.getState().cumulativeCostUsd;
344
+ const iterStart = Date.now();
345
+ log.debug({ iteration, maxIterations }, `Iteration ${iteration}/${maxIterations} \u2014 sending prompt`);
346
+ let streamDeltaCount = 0;
347
+ let streamCharCount = 0;
348
+ let lastStreamLogAt = 0;
349
+ let lastToolOrStreamLogAt = Date.now();
350
+ const DEBUG_STREAM_INTERVAL_MS = 15e3;
351
+ const INFO_STREAM_INTERVAL_MS = 6e4;
352
+ const result = await _sendAndWait(server.client, {
353
+ sessionId,
354
+ message: fullPrompt,
355
+ stallMs,
356
+ abortSignal: abort.signal,
357
+ // Autopilot is lights-out: auto-reject every permission prompt
358
+ // (question tool, edit gates, bash confirmations) so the agent
359
+ // can't deadlock waiting on a human response.
360
+ autoRejectPermissions: true,
361
+ serverUrl: server.url,
362
+ onPermissionRejected: (perm) => {
363
+ log.warn(
364
+ { iteration, permissionId: perm.id, permissionType: perm.type, title: perm.title },
365
+ `Auto-rejected permission: ${perm.type} \u2014 "${perm.title}"`
366
+ );
367
+ },
368
+ onToolCall: (toolName) => {
369
+ toolLog.debug({ iteration, tool: toolName }, toolName);
370
+ lastToolOrStreamLogAt = Date.now();
371
+ streamDeltaCount = 0;
372
+ streamCharCount = 0;
373
+ lastStreamLogAt = Date.now();
374
+ },
375
+ onCostUpdate: (cost, tokens) => {
376
+ heartbeat.update({
377
+ cumulativeCostUsd: iterationBaseCost + cost
378
+ });
379
+ },
380
+ onTextDelta: (charCount) => {
381
+ streamDeltaCount += 1;
382
+ streamCharCount += charCount;
383
+ const now = Date.now();
384
+ if (now - lastStreamLogAt >= DEBUG_STREAM_INTERVAL_MS) {
385
+ streamLog.debug(
386
+ { iteration, deltas: streamDeltaCount, chars: streamCharCount },
387
+ `streaming (${streamDeltaCount} deltas, ${streamCharCount} chars)`
388
+ );
389
+ lastStreamLogAt = now;
390
+ }
391
+ const silenceSinceLastTool = now - lastToolOrStreamLogAt;
392
+ if (silenceSinceLastTool >= INFO_STREAM_INTERVAL_MS) {
393
+ streamLog.info(
394
+ {
395
+ iteration,
396
+ deltas: streamDeltaCount,
397
+ chars: streamCharCount,
398
+ silenceMs: silenceSinceLastTool
399
+ },
400
+ `still streaming (${streamDeltaCount} deltas, ${streamCharCount} chars, ${Math.round(silenceSinceLastTool / 1e3)}s since last tool)`
401
+ );
402
+ lastToolOrStreamLogAt = now;
403
+ }
404
+ }
405
+ });
406
+ const iterDurationMs = Date.now() - iterStart;
407
+ if (result.kind === "abort") {
408
+ log.warn({ iteration, iterDurationMs }, "Iteration aborted (total timeout)");
409
+ return {
410
+ exitReason: "timeout",
411
+ iterations: iteration,
412
+ message: `Aborted after ${iteration} iteration(s) (total timeout exceeded).`,
413
+ sessionId
414
+ };
415
+ }
416
+ if (result.kind === "stall") {
417
+ log.warn({ iteration, stallMs: result.stallMs }, "Iteration stalled");
418
+ return {
419
+ exitReason: "stall",
420
+ iterations: iteration,
421
+ message: `Iteration ${iteration} stalled for ${result.stallMs}ms with no idle signal.`,
422
+ sessionId
423
+ };
424
+ }
425
+ if (result.kind === "error") {
426
+ log.error({ iteration, err: result.message }, "Iteration errored");
427
+ heartbeat.update({
428
+ iterationsCompleted: iteration,
429
+ lastIterationErrored: true
430
+ });
431
+ return {
432
+ exitReason: "error",
433
+ iterations: iteration,
434
+ message: `Error in iteration ${iteration}: ${result.message}`,
435
+ sessionId
436
+ };
437
+ }
438
+ if (result.kind === "question_rejected") {
439
+ log.warn(
440
+ { iteration, questionTitle: result.title },
441
+ `Question rejected \u2014 re-sending prompt with reminder (iteration ${iteration})`
442
+ );
443
+ const reminderResult = await _sendAndWait(server.client, {
444
+ sessionId,
445
+ message: fullPrompt + "\n\nIMPORTANT: Your previous attempt to use the question tool was rejected. The question tool is not available in autopilot mode. You must solve this without asking the user. Pick a sensible default, document the decision in the plan's ## Open questions, and continue working.",
446
+ stallMs,
447
+ abortSignal: abort.signal,
448
+ autoRejectPermissions: true,
449
+ serverUrl: server.url,
450
+ onPermissionRejected: (perm) => {
451
+ log.warn(
452
+ { iteration, permissionId: perm.id, permissionType: perm.type },
453
+ `Auto-rejected permission on retry: ${perm.type}`
454
+ );
455
+ },
456
+ onToolCall: (toolName) => {
457
+ toolLog.debug({ iteration, tool: toolName }, toolName);
458
+ }
459
+ });
460
+ if (reminderResult.kind === "question_rejected") {
461
+ log.error(
462
+ { iteration },
463
+ "Agent invoked question tool twice in same iteration \u2014 giving up on this iteration"
464
+ );
465
+ } else if (reminderResult.kind !== "idle") {
466
+ log.warn(
467
+ { iteration, kind: reminderResult.kind },
468
+ `Retry after question rejection returned ${reminderResult.kind}`
469
+ );
470
+ }
471
+ }
472
+ const lastMessage = await _getLastAssistantMessage(server.client, sessionId);
473
+ if (detectSentinel(lastMessage)) {
474
+ log.info({ iteration, iterDurationMs }, "Sentinel detected \u2014 autopilot done");
475
+ return {
476
+ exitReason: "sentinel",
477
+ iterations: iteration,
478
+ message: `Agent emitted <autopilot-done> at iteration ${iteration}.`,
479
+ sessionId
480
+ };
481
+ }
482
+ const madeProgress = await checkProgress(opts.cwd, headBefore);
483
+ struggle.record(madeProgress);
484
+ const cumulativeCostUsd = await getSessionCost(server.client, sessionId);
485
+ const planPathMatch = opts.prompt.match(/plans\/([^/\s]+(?:\/[^/\s]+)?)/);
486
+ let planProgressPatch = {};
487
+ if (planPathMatch) {
488
+ try {
489
+ const planPath = planPathMatch[0];
490
+ const planState = parsePlanState(planPath);
491
+ if (planState.type === "multi") {
492
+ planProgressPatch = {
493
+ phaseCount: planState.phaseCount,
494
+ phasesCompleted: planState.phasesCompleted,
495
+ mainCheckboxesTotal: planState.totalItems,
496
+ mainCheckboxesCompleted: planState.checkedItems
497
+ };
498
+ }
499
+ } catch (err) {
500
+ log.debug({ err }, "plan-parser error \u2014 falling back to plan-blind heartbeat");
501
+ }
502
+ }
503
+ heartbeat.update({
504
+ iterationsCompleted: iteration,
505
+ cumulativeCostUsd,
506
+ lastIterationProgress: madeProgress,
507
+ lastIterationErrored: false,
508
+ ...planProgressPatch
509
+ });
510
+ log.debug(
511
+ { iteration, iterDurationMs, madeProgress, cumulativeCostUsd },
512
+ `Iteration ${iteration} idle (${(iterDurationMs / 1e3).toFixed(1)}s, ${madeProgress ? "progress" : "no progress"})`
513
+ );
514
+ if (struggle.isStruggling()) {
515
+ log.warn({ iteration, struggleThreshold }, "Struggle detected \u2014 stopping");
516
+ return {
517
+ exitReason: "struggle",
518
+ iterations: iteration,
519
+ message: `Agent made no filesystem progress for ${struggleThreshold} consecutive iteration(s). Stopping at iteration ${iteration}.`,
520
+ sessionId
521
+ };
522
+ }
523
+ }
524
+ log.warn({ maxIterations }, "Reached max iterations");
525
+ return {
526
+ exitReason: "max-iterations",
527
+ iterations: maxIterations,
528
+ message: `Reached maximum iterations (${maxIterations}). Stopping.`,
529
+ sessionId
530
+ };
531
+ } finally {
532
+ clearTimeout(timeoutHandle);
533
+ heartbeat?.stop();
534
+ log.info({}, "Shutting down server");
535
+ await server.shutdown();
536
+ await autopilotLog.flush();
537
+ }
538
+ }
539
+
540
+ export {
541
+ MAX_ITERATIONS,
542
+ TIMEOUT_MS,
543
+ runRalphLoop
544
+ };
@@ -196,10 +196,62 @@ function readOurPackageVersion(fromFileUrl) {
196
196
  return "0.0.0";
197
197
  }
198
198
 
199
+ // src/model-validator.ts
200
+ var CATWALK_PROVIDER_PATTERN = /^(?:bedrock|vertex|vertexai)\//;
201
+ var LEGACY_PRE_100_PATTERN = /^(bedrock|vertex|vertexai)\/claude-(opus|sonnet|haiku)(-\d+)?$/;
202
+ var LEGACY_TO_MODELS_DEV = {
203
+ // --- Pre-PR-#100 Bedrock (no subpath) ---
204
+ "bedrock/claude-opus": "amazon-bedrock/global.anthropic.claude-opus-4-7",
205
+ "bedrock/claude-opus-4": "amazon-bedrock/global.anthropic.claude-opus-4-7",
206
+ "bedrock/claude-sonnet": "amazon-bedrock/global.anthropic.claude-sonnet-4-6",
207
+ "bedrock/claude-sonnet-4": "amazon-bedrock/global.anthropic.claude-sonnet-4-6",
208
+ "bedrock/claude-haiku": "amazon-bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0",
209
+ "bedrock/claude-haiku-4": "amazon-bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0",
210
+ // --- Pre-Models.dev Bedrock (had subpath, but wrong provider prefix) ---
211
+ "bedrock/anthropic.claude-opus-4-6": "amazon-bedrock/global.anthropic.claude-opus-4-7",
212
+ "bedrock/anthropic.claude-opus-4-7": "amazon-bedrock/global.anthropic.claude-opus-4-7",
213
+ "bedrock/anthropic.claude-sonnet-4-6": "amazon-bedrock/global.anthropic.claude-sonnet-4-6",
214
+ "bedrock/anthropic.claude-haiku-4-5-20251001-v1:0": "amazon-bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0",
215
+ // --- Pre-PR-#100 Vertex (no @date suffix) ---
216
+ "vertex/claude-opus": "google-vertex-anthropic/claude-opus-4-7@default",
217
+ "vertex/claude-opus-4": "google-vertex-anthropic/claude-opus-4-7@default",
218
+ "vertex/claude-sonnet": "google-vertex-anthropic/claude-sonnet-4-6@default",
219
+ "vertex/claude-sonnet-4": "google-vertex-anthropic/claude-sonnet-4-6@default",
220
+ "vertex/claude-haiku": "google-vertex-anthropic/claude-haiku-4-5@20251001",
221
+ "vertex/claude-haiku-4": "google-vertex-anthropic/claude-haiku-4-5@20251001",
222
+ "vertexai/claude-opus": "google-vertex-anthropic/claude-opus-4-7@default",
223
+ "vertexai/claude-opus-4": "google-vertex-anthropic/claude-opus-4-7@default",
224
+ "vertexai/claude-sonnet": "google-vertex-anthropic/claude-sonnet-4-6@default",
225
+ "vertexai/claude-sonnet-4": "google-vertex-anthropic/claude-sonnet-4-6@default",
226
+ "vertexai/claude-haiku": "google-vertex-anthropic/claude-haiku-4-5@20251001",
227
+ "vertexai/claude-haiku-4": "google-vertex-anthropic/claude-haiku-4-5@20251001",
228
+ // --- Pre-Models.dev Vertex (had @date suffix, wrong provider prefix) ---
229
+ "vertexai/claude-opus-4-6@20250610": "google-vertex-anthropic/claude-opus-4-6@default",
230
+ "vertexai/claude-opus-4-7@20250610": "google-vertex-anthropic/claude-opus-4-7@default",
231
+ "vertexai/claude-sonnet-4-6@20250725": "google-vertex-anthropic/claude-sonnet-4-6@default",
232
+ "vertexai/claude-haiku-4-5@20251001": "google-vertex-anthropic/claude-haiku-4-5@20251001"
233
+ };
234
+ function validateModelOverride(id) {
235
+ if (typeof id !== "string") return { valid: true };
236
+ if (id.length === 0) return { valid: true };
237
+ if (CATWALK_PROVIDER_PATTERN.test(id)) {
238
+ const suggestion = LEGACY_TO_MODELS_DEV[id] ?? "run `bunx @glrs-dev/harness-plugin-opencode install` to pick a current preset";
239
+ const reason = LEGACY_PRE_100_PATTERN.test(id) ? `"${id}" is a pre-PR-#100 model ID format that does not resolve in OpenCode. Bedrock IDs need the \`amazon-bedrock\` provider prefix (not \`bedrock\`); Vertex Claude IDs need the \`google-vertex-anthropic\` provider prefix (not \`vertex\` / \`vertexai\`).` : `"${id}" uses a provider prefix (\`${id.split("/")[0]}\`) that does not exist in OpenCode's runtime. AWS Bedrock's provider ID is \`amazon-bedrock\`; Vertex Claude's is \`google-vertex-anthropic\`.`;
240
+ return { valid: false, reason, suggestion };
241
+ }
242
+ return { valid: true };
243
+ }
244
+ function formatModelOverrideWarning(id, source, suggestion) {
245
+ const suggestionText = suggestion ? ` Suggested replacement: \`${suggestion}\`.` : "";
246
+ return `[@glrs-dev/harness-plugin-opencode] Warning: invalid model override "${id}" (from ${source}).${suggestionText} Run \`bunx @glrs-dev/harness-plugin-opencode doctor\` for details.`;
247
+ }
248
+
199
249
  export {
200
250
  PACKAGE_NAME,
201
251
  getOpenCodeCachePackageDir,
202
252
  inspectCachePin,
203
253
  refreshPluginCache,
204
- readOurPackageVersion
254
+ readOurPackageVersion,
255
+ validateModelOverride,
256
+ formatModelOverrideWarning
205
257
  };