opencode-wait-a-minute 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ WAM adds deterministic control, task-state management, context reconstruction, s
21
21
 
22
22
  WAM ships a CQE adapter (`src/context/cqe-adapter.js`) that integrates [context-query-core](https://github.com/khnker/context-query-core), a fast, in-process code-evidence retriever using SQLite + trigram indexing for lexical and symbolic snippet matching.
23
23
 
24
- Modes: `disabled`, `shadow`, `enabled`. The adapter defaults to `enabled` when invoked directly; override with `WAM_CQE_MODE`, or disable with `WAM_CQE=0`. It runs on file-search-intent prompts in the runtime context-retrieval path via `selectContextWithCqe`; opt out with `WAM_CQE_MODE=disabled` or `cfg.disableCqe`.
24
+ Modes: `disabled`, `shadow`, `enabled`. The adapter defaults to `enabled` when invoked directly; override with `WAM_CQE_MODE`, or disable with `WAM_CQE=0`. Every runtime context retrieval consults CQE via `selectContextWithCqe` and augments the package with bounded `file_context`; opt out with `WAM_CQE_MODE=disabled`, `WAM_CQE=0`, or `cfg.disableCqe`.
25
25
 
26
26
  Benchmark (fixture corpus, k=5, 10 queries): CQE retrieved **142 tokens vs. 302 for the baseline** ripgrep search — **52.98% fewer tokens** — with higher precision (0.617 vs 0.613) and MRR (0.700 vs 0.683), and comparable recall (0.70 vs 0.75). Reproduce with:
27
27
 
package/index.js CHANGED
@@ -29,7 +29,7 @@ import { activeTaskPath, sessionTaskPath, tasksDir as wamTasksDir, taskDir as wa
29
29
 
30
30
  import { initMemory, updateProjectMemo, summarizeOperationalContext, updateContext, getOperationalContext, updateTaskMemory, addRecentChange, recordDecision, getDecision, listDecisions, updateLiveContext, compactDecisions } from "./src/persistence/memory.js";
31
31
  import { purgeTasks } from "./src/persistence/retention-manager.js";
32
- import { getSessionId, listCapsules, getCapsule, promoteCapsule, selectContext, selectContextWithCqe, detectFileSearchIntent, retrieveContext, retrieveContextWithCqe, closeSession, resolveWamRoot, migrateLegacyCapsules, setActiveSession, applyBootstrapApproval } from "./src/context/context.js";
32
+ import { getSessionId, listCapsules, getCapsule, promoteCapsule, selectContext, selectContextWithCqe, retrieveContext, retrieveContextWithCqe, closeSession, resolveWamRoot, migrateLegacyCapsules, setActiveSession, applyBootstrapApproval } from "./src/context/context.js";
33
33
  import { assembleContext } from "./src/context/assembly.js";
34
34
  import { injectCanonicalProjectContext } from "./src/context/canonical-project-context.js";
35
35
  import { evaluateRequirement as evaluateRequirementChecks, verifyRequirement } from "./src/verification/verification.js";
@@ -1608,7 +1608,6 @@ async function postToolExecution(input, output) {
1608
1608
  persistLiveContext,
1609
1609
  assembleContext,
1610
1610
  selectContextWithCqe,
1611
- detectFileSearchIntent,
1612
1611
  createSnapshot,
1613
1612
  checkContinuation,
1614
1613
  rebuildScope,
@@ -1627,7 +1626,6 @@ async function postToolExecution(input, output) {
1627
1626
  promoteCapsule,
1628
1627
  selectContext,
1629
1628
  selectContextWithCqe,
1630
- detectFileSearchIntent,
1631
1629
  retrieveContext,
1632
1630
  closeSession,
1633
1631
  resolveWamRoot,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-wait-a-minute",
3
3
  "description": "WAM keeps more state than it sends: deterministic task-state, context reconstruction, and evidence-backed completion for OpenCode agents.",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
5
  "main": "index.js",
6
6
  "engines": {
7
7
  "node": ">=22.6"
@@ -11,7 +11,6 @@
11
11
  "src/",
12
12
  "skills/",
13
13
  "prompts/",
14
- "vendor/**/dist",
15
14
  "vendor/**/src/",
16
15
  "vendor/**/index.js",
17
16
  "README.md",
@@ -336,6 +336,32 @@ function sessionErrorMessage(data) {
336
336
  return `agent session error: ${detail}${status ? ` (${status})` : ""}`;
337
337
  }
338
338
 
339
+ const NON_RETRYABLE_PROMPT_STATUS_CODES = new Set([400, 401, 403, 404, 422]);
340
+
341
+ function promptErrorStatus(error) {
342
+ const raw = error?.data?.statusCode ?? error?.status ?? error?.statusCode;
343
+ const status = Number(raw);
344
+ return Number.isFinite(status) ? status : null;
345
+ }
346
+
347
+ function promptErrorFromResponse(response, sessionID) {
348
+ const cause = response?.info?.error ?? response?.error;
349
+ const status = promptErrorStatus(cause);
350
+ const nonRetryable =
351
+ status !== null && NON_RETRYABLE_PROMPT_STATUS_CODES.has(status);
352
+ let message = sessionErrorMessage(response) ?? "agent session error";
353
+ if (nonRetryable && !message.includes(`(${status})`)) {
354
+ message = `${message} (${status})`;
355
+ }
356
+ return new AgentInvocationError(message, {
357
+ code: nonRetryable
358
+ ? AGENT_INVOCATION_ERROR_CODES.INVALID_RESULT
359
+ : AGENT_INVOCATION_ERROR_CODES.PROMPT_FAILED,
360
+ cause,
361
+ sessionID,
362
+ });
363
+ }
364
+
339
365
  function responseText(parts) {
340
366
  if (!Array.isArray(parts)) return "";
341
367
  return parts
@@ -431,7 +457,7 @@ export function createOpenCodeV1AgentAdapter({
431
457
  { code: AGENT_INVOCATION_ERROR_CODES.PROMPT_TIMEOUT, sessionID: session.id },
432
458
  ),
433
459
  );
434
- if (response?.error) throw response.error;
460
+ if (response?.error) throw promptErrorFromResponse(response, session.id);
435
461
  } catch (cause) {
436
462
  if (cause instanceof AgentInvocationError) {
437
463
  if (!cause.sessionID) cause.sessionID = session.id;
@@ -27,15 +27,6 @@ export function loadDesignTokens(root = process.cwd()) {
27
27
  return ctx.tokens || {};
28
28
  }
29
29
 
30
- export function detectFileSearchIntent(task) {
31
- if (typeof task !== "string" || !task) return false;
32
- // Normalize common Spanish diacritics so patterns like "dónde"/"donde" match uniformly.
33
- const q = task.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "");
34
- // English + Spanish intent keywords (intent detection only — does not capture the query itself).
35
- // `\b` boundaries keep us from matching substrings inside larger words (e.g. "archivo" inside "desarchivo").
36
- return /\b(find|search|grep|locate|where\s+is|busca|buscar|encuentra|donde|revisa|revise|ver|archivo|archivos|codigo|file|files)\b/.test(q);
37
- }
38
-
39
30
  const LEVELS = ["L1", "L2", "L3", "L4"];
40
31
  const LIFECYCLE = ["candidate", "active", "superseded", "stale", "invalidated"];
41
32
  const PROVENANCE = ["user_decided", "observed", "inferred"];
@@ -614,13 +605,12 @@ export function selectContext(task, { budget = 8000, root, sessionId, log = true
614
605
 
615
606
  /**
616
607
  * Variante async de selectContext que enriquece el paquete con file_context de
617
- * CQE cuando la tarea es una intención de búsqueda de archivos. Se mantiene
618
- * separada del selectContext sincrónico para no forzar `await` en callers que
619
- * no pueden esperarlo (p.ej. assembleContext).
608
+ * CQE. Se mantiene separada del selectContext sincrónico para no forzar `await`
609
+ * en callers que no pueden esperarlo (p.ej. assembleContext).
620
610
  */
621
611
  export async function selectContextWithCqe(task, opts = {}) {
622
612
  const pkg = selectContext(task, opts);
623
- if (!opts.disableCqe && detectFileSearchIntent(task)) {
613
+ if (!opts.disableCqe) {
624
614
  const t0 = Date.now();
625
615
  try {
626
616
  const cqeRes = await retrieveWithCqe(task, { repoRoot: opts.root || process.cwd() });
@@ -1,3 +1,3 @@
1
- import { selectContext, selectContextWithCqe, retrieveContext, detectFileSearchIntent } from "./context.js";
1
+ import { selectContext, selectContextWithCqe, retrieveContext } from "./context.js";
2
2
  import { detectCouplingIntent, traceCouplingForTask } from "./coupling.js";
3
- export { selectContext, selectContextWithCqe, retrieveContext, detectFileSearchIntent, detectCouplingIntent, traceCouplingForTask };
3
+ export { selectContext, selectContextWithCqe, retrieveContext, detectCouplingIntent, traceCouplingForTask };
@@ -32,11 +32,38 @@ export function shouldClearExecutionGate(task, taskInFlight = false) {
32
32
  // guarded by this one, so it must be treated as stale instead of blocking
33
33
  // every mutation forever after a restart. Gates without ownerPid predate this
34
34
  // marker and are also stale.
35
+ const DEFAULT_EXECUTION_GATE_TTL_MS = 30 * 60 * 1000;
36
+
37
+ function executionGateTtlMs() {
38
+ const raw = Number(process.env.WAM_EXECUTION_GATE_TTL_MS);
39
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_EXECUTION_GATE_TTL_MS;
40
+ }
41
+
42
+ function parseGateTimestamp(value) {
43
+ if (typeof value === "number") {
44
+ return Number.isFinite(value) ? value : null;
45
+ }
46
+ if (typeof value === "string" && value.trim() !== "") {
47
+ const numeric = Number(value);
48
+ if (Number.isFinite(numeric)) return numeric;
49
+ const parsed = Date.parse(value);
50
+ return Number.isFinite(parsed) ? parsed : null;
51
+ }
52
+ return null;
53
+ }
54
+
35
55
  export function isExecutionGateStale(gate, currentPid = process.pid) {
36
56
  if (gate?.active !== true) return false;
37
57
  const ownerPid = gate?.ownerPid;
38
58
  if (!Number.isInteger(ownerPid)) return true;
39
- return ownerPid !== currentPid;
59
+ if (ownerPid !== currentPid) return true;
60
+ // A same-pid gate can still be orphaned (its pass aborted before the
61
+ // `finally` ran), so bound its lifetime instead of trusting the pid alone.
62
+ // Missing or malformed timestamps fail open: a stuck gate must never brick
63
+ // the session.
64
+ const startedAt = parseGateTimestamp(gate?.startedAt);
65
+ if (startedAt === null) return true;
66
+ return Date.now() - startedAt > executionGateTtlMs();
40
67
  }
41
68
 
42
69
  // A gate is orphaned when its owning loop reached a terminal state but no
@@ -16,6 +16,7 @@ import fs from "node:fs";
16
16
  import path from "node:path";
17
17
 
18
18
  import { hasWamMarkerFor, injectWamParts, isWamSynthetic } from "./part-provenance.js";
19
+ import { logger } from "./logger.js";
19
20
  import { reopenTerminalAgentRun, resetAuthorizationSurface } from "../execution/agent-run-state.js";
20
21
  import { ensurePermissionPlan, permissionPreflightLines, shouldNotifyPermissionPlan } from "../policy/permission-preflight.js";
21
22
  import { buildTaskInferenceDirective } from "../cognition/task-inference-engine.js";
@@ -189,6 +190,13 @@ const ASKING_CMD_RE = /^(answer|resolve|contract|progress|task|skills|assumption
189
190
  * @returns {Promise<void>}
190
191
  */
191
192
  export async function handleMessage(input, output, deps) {
193
+ const turnStart = performance.now();
194
+ let phaseStart = turnStart;
195
+ const phase = (label) => {
196
+ const now = performance.now();
197
+ logger.info("turn-timing", `phase=${label} durMs=${(now - phaseStart).toFixed(1)} totalMs=${(now - turnStart).toFixed(1)}`);
198
+ phaseStart = now;
199
+ };
192
200
  const {
193
201
  collector = null,
194
202
  bypassed,
@@ -220,7 +228,6 @@ export async function handleMessage(input, output, deps) {
220
228
  persistLiveContext,
221
229
  assembleContext,
222
230
  selectContextWithCqe,
223
- detectFileSearchIntent,
224
231
  createSnapshot,
225
232
  checkContinuation,
226
233
  rebuildScope,
@@ -291,6 +298,7 @@ export async function handleMessage(input, output, deps) {
291
298
  taskId = resolved.taskId;
292
299
  fallbackKind = resolved.fallbackKind;
293
300
  }
301
+ phase("resolveTask");
294
302
  if (input.sessionID) {
295
303
  sessionTasks.set(input.sessionID, taskId);
296
304
  // Persist active-task pointer to disk so /wam contract approve (which
@@ -324,6 +332,7 @@ export async function handleMessage(input, output, deps) {
324
332
  const existing = getTaskState(taskId, wamRoot);
325
333
  if (existing) validateState(existing);
326
334
  } catch {}
335
+ phase("loadTaskState");
327
336
  try { pruneCompletedTasks(wamRoot).catch(() => {}); } catch {}
328
337
 
329
338
  // Welcome / disambiguation gate: cuando la primera inferencia cayó en
@@ -607,6 +616,7 @@ export async function handleMessage(input, output, deps) {
607
616
  }
608
617
 
609
618
  const updatedState = getTaskState(taskId, wamRoot) || state;
619
+ if (intentLane.lane === INTENT_LANES.CORRECTION) updatedState.flow = "correction";
610
620
 
611
621
  // Final completion message: emit once when the gate allows DONE.
612
622
  if (
@@ -669,18 +679,20 @@ export async function handleMessage(input, output, deps) {
669
679
  );
670
680
  }
671
681
  } catch {}
682
+ phase("history");
672
683
  try {
673
684
  const memory = loadConversationMemory(wamRoot, input.sessionID);
674
685
  const memoryText = formatMemoryForContext(memory, promptText, 3);
675
686
  if (memoryText) auxInject.push(`[wam memory]\n${memoryText}\n`);
676
687
  } catch {}
688
+ phase("memory");
677
689
  }
678
690
 
679
691
  const reqs = updatedState.requirements || [];
680
692
  const pend = reqs.filter((r) => r.status !== "done" && r.status !== "verified");
681
693
  const objective = updatedState.contract?.objective || analysis.intent?.goal || null;
682
694
  if (intentLane.lane !== INTENT_LANES.CONVERSATION) {
683
- const requireApproval = cfg.contractApproval === "required";
695
+ const requireApproval = cfg.contractApproval === "required" && intentLane.lane !== INTENT_LANES.CORRECTION;
684
696
  if (requireApproval && updatedState.contract?.status !== "APPROVED" && reqs.length > 0) {
685
697
  const { visible, overflow } = splitVisibleRequirements(reqs);
686
698
  const verification = (updatedState.contract?.verification || []).filter(
@@ -760,7 +772,7 @@ export async function handleMessage(input, output, deps) {
760
772
  }
761
773
  }
762
774
 
763
- if (detectFileSearchIntent(promptText)) {
775
+ if (cfg.disableCqe !== true) {
764
776
  const cqeT0 = Date.now();
765
777
  const cqePack = await selectContextWithCqe(promptText, {
766
778
  root: wamRoot,
@@ -811,6 +823,7 @@ export async function handleMessage(input, output, deps) {
811
823
  }
812
824
  }
813
825
  } catch {}
826
+ phase("cqe");
814
827
 
815
828
  // Directive inference
816
829
  if (updatedState.contract?.objective && updatedState.phase !== "DONE" && analysis.intent?.classification !== "trivial") {
@@ -874,6 +887,7 @@ const { lines } = buildTaskInferenceDirective({
874
887
  }
875
888
  updatedState.completionGateKey = gateKey;
876
889
  persistTaskState(taskId, updatedState, wamRoot);
890
+ phase("persist");
877
891
  }
878
892
  // Mismo set de pendientes que el turno anterior: no repetir el aviso.
879
893
  // -------------------------------------------------------------
@@ -966,6 +980,7 @@ const { lines } = buildTaskInferenceDirective({
966
980
  emitTextPart(output, wamBlockText, { sessionID: input.sessionID, messageID: output.message?.id || input.messageID });
967
981
  }
968
982
 
983
+ phase("rewrite");
969
984
  input.waitAnalysis = analysis;
970
985
  } catch (err) {
971
986
  console.error("[wait-a-minute] Pre-flight analysis failed:", err);
@@ -170,6 +170,21 @@ function logDecision(event) {
170
170
  }
171
171
  }
172
172
 
173
+ const CORRECTION_FLOW_RE =
174
+ /\b(corrige|corregir|correcci[oó]n|arregla|arreglar|arreglo|fix|bugfix|bug|debug|falla|fallas|error|errores|regression|incorrecto|mal)\b/i;
175
+
176
+ export function isCorrectionFlow(state) {
177
+ if (!state) return false;
178
+ const flow = String(state.flow || "").toLowerCase();
179
+ if (flow === "correction") return true;
180
+ const kind = String(state.kind || state.contract?.kind || "").toLowerCase();
181
+ if (kind === "debugging" || kind === "bugfix") return true;
182
+ const text = [state.lastAction, state.contract?.objective, state.nextAction]
183
+ .filter((v) => typeof v === "string")
184
+ .join(" ");
185
+ return CORRECTION_FLOW_RE.test(text);
186
+ }
187
+
173
188
  /**
174
189
  * @param {string} tool
175
190
  * @param {object|null} state - task state ({ phase, contract, requirements }).
@@ -211,6 +226,11 @@ export function enforceGovernance(tool, state, ctx = {}) {
211
226
  const contractStatus = state?.contract?.status;
212
227
  const phase = state?.phase;
213
228
 
229
+ if (isCorrectionFlow(state)) {
230
+ logDecision({ decision: "allow", reason: "correction-flow", tool, phase });
231
+ return { allowed: true, reason: "correction-flow" };
232
+ }
233
+
214
234
  if (contractStatus === "APPROVED") return { allowed: true, reason: "contract-approved" };
215
235
  if (isTerminalPhase(phase)) return { allowed: true, reason: "terminal-phase" };
216
236
  // Let the Clarification Gate handle ASKING (answer the unknown, don't approve).
@@ -118,7 +118,7 @@ export function inferTaskKind(task = {}) {
118
118
 
119
119
  if (!text) return TASK_KINDS.UNKNOWN;
120
120
  if (/\brefactor/.test(text)) return TASK_KINDS.REFACTORING;
121
- if (/\b(debug|bug|fix|failure|error|regression)\b/.test(text)) return TASK_KINDS.DEBUGGING;
121
+ if (/\b(debug|bug|bugfix|fix|failure|error|errores|regression|falla|fallas|corrige|corregir|correcci[oó]n|arregla|arreglar|arreglo)\b/.test(text)) return TASK_KINDS.DEBUGGING;
122
122
  if (/\b(review|audit|inspect)\b/.test(text)) return TASK_KINDS.REVIEW;
123
123
  if (/\b(document|documentation|readme|docs)\b/.test(text)) return TASK_KINDS.DOCUMENTATION;
124
124
  if (/\b(research|investigate|explore|compare|evaluate|analysis)\b/.test(text)) return TASK_KINDS.RESEARCH;