ostacky 0.10.0 → 0.10.2

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.
@@ -6,7 +6,7 @@
6
6
  * MCP queda thin solo para observabilidad (get_*).
7
7
  *
8
8
  * Single source security: mirrors generados desde src/security.ts via scripts/sync-controller-core.ts
9
- * Cache único: getDiscoverySnapshot es único entrypoint.
9
+ * Discovery: `codegraph_explore` completado; no hay cache de archivo.
10
10
  * Tiered cache-friendly: isTrivial sin reemplazar system[0], suffix hint + SKIP.
11
11
  * CodeGraph preventivo: bloquea Read/Grep masivo sin Discovery hit.
12
12
  */
@@ -152,6 +152,34 @@ export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[
152
152
  return payload
153
153
  }
154
154
 
155
+ /**
156
+ * multisession-ownership-guard: hay UN state file por proyecto, así que el PENDING
157
+ * puede ser de otra sesión. El gate no debe congelar sesiones ajenas a esa request
158
+ * (antes, cualquier sesión quedaba bloqueada por la confirmación pendiente de otra).
159
+ *
160
+ * No aplica cuando no hay dueño (estado legacy), cuando el caller es el dueño, ni
161
+ * cuando el caller es desconocido (`event.sessionID` ausente → "default"): sin
162
+ * identidad no se puede probar ajeneidad.
163
+ */
164
+ export function isForeignPendingOwner(state: any, sessionId: string | null | undefined): boolean {
165
+ const owner = state?.ownerSessionId
166
+ if (!owner || !sessionId || sessionId === "default") return false
167
+ return owner !== sessionId
168
+ }
169
+
170
+ /**
171
+ * Cache del gate PENDING. El cache es por `revision`, NO por dueño: si el paso de
172
+ * una sesión ajena dejara un ALLOW, la dueña lo reutilizaría y se saltearía su
173
+ * propia confirmación. Por eso un PENDING ajeno no cachea nada.
174
+ */
175
+ export function nextPendingGateCache(
176
+ revision: number,
177
+ foreignPendingOwner: boolean
178
+ ): { revision: number; result: string } | null {
179
+ if (foreignPendingOwner) return null
180
+ return { revision, result: "ALLOW" }
181
+ }
182
+
155
183
  /**
156
184
  * Persiste estado SIN poder pisar al MCP.
157
185
  *
@@ -228,25 +256,6 @@ function isPathInsideProject(filePath: string, directory: string): boolean {
228
256
  } catch { return false }
229
257
  }
230
258
 
231
- function getDiscoveryCacheHit(directory: string): boolean {
232
- try {
233
- const cacheDir = join(directory, ".opencode", "cache", "codegraph")
234
- if (!existsSync(cacheDir)) return false
235
- const files = readdirSync(cacheDir).filter((f: string) => f.startsWith("discovery-"))
236
- if (files.length === 0) return false
237
- // check if any file is recent (<1h) and valid
238
- const now = Date.now()
239
- for (const f of files) {
240
- try {
241
- const raw = readFileSync(join(cacheDir, f), "utf-8")
242
- const data = JSON.parse(raw)
243
- if (typeof data.ts === "number" && now - data.ts < 60*60*1000) return true
244
- } catch {}
245
- }
246
- return false
247
- } catch { return false }
248
- }
249
-
250
259
  function isCodegraphAvailable(directory: string): boolean {
251
260
  // If codegraph binary exists, assume OK. Otherwise fallback allows Read.
252
261
  try {
@@ -259,6 +268,231 @@ function isCodegraphAvailable(directory: string): boolean {
259
268
  } catch { return false }
260
269
  }
261
270
 
271
+ /**
272
+ * ¿Este read/grep/glob esta sujeto al gate de discovery?
273
+ * Exenciones: docs/json/md, openspec/changes|specs, grep de literales, y archivos ya
274
+ * trackeados (refresh honesto post-complete necesita Read fresco sin Discovery hit).
275
+ */
276
+ export function isCodeReadGated(input: {
277
+ isCodeFile: boolean
278
+ isLiteralGrep: boolean
279
+ isSpecTasksPath: boolean
280
+ isDocsPath: boolean
281
+ isTaskTracked: boolean
282
+ }): boolean {
283
+ return (
284
+ input.isCodeFile &&
285
+ !input.isLiteralGrep &&
286
+ !input.isSpecTasksPath &&
287
+ !input.isDocsPath &&
288
+ !input.isTaskTracked
289
+ )
290
+ }
291
+
292
+ /**
293
+ * Predicado puro del gate de CodeGraph: ¿bloquear esta lectura de codigo?
294
+ *
295
+ * Extraido a funcion para poder testear el INVARIANTE, no el mensaje. El test anterior hacia
296
+ * `expect(src).toContain("Usa getDiscoverySnapshot primero")`: verificaba que el texto existiera
297
+ * y pasaba verde con el gate inescapable adentro (fix-codegraph-gate).
298
+ */
299
+ export function shouldBlockCodeRead(input: {
300
+ isCodeFile: boolean
301
+ isLiteralGrep: boolean
302
+ isSpecTasksPath: boolean
303
+ isDocsPath: boolean
304
+ isTaskTracked: boolean
305
+ codegraphOk: boolean
306
+ hasDiscoveryHit: boolean
307
+ }): boolean {
308
+ return isCodeReadGated(input) && input.codegraphOk && !input.hasDiscoveryHit
309
+ }
310
+
311
+ /**
312
+ * Tools cuyo completado constituye evidencia REAL de discovery estructural en la sesion.
313
+ *
314
+ * `codegraph_explore` es la senal fuerte: llamada real, resultado inspeccionable por el plugin.
315
+ * No hay cache de discovery: `getDiscoverySnapshot` nunca existió y se retiró.
316
+ *
317
+ * Historia (fix-codegraph-gate): antes la UNICA senal era `getDiscoverySnapshot`, una tool que nunca
318
+ * se registro ni en el plugin ni en el MCP. `hasDiscoveryHit` era siempre false y el gate bloqueaba
319
+ * todo read/grep/glob de codigo sin salida posible; un `codegraph_explore` completado NO lo abria,
320
+ * contradiciendo la instruccion "CodeGraph primero" del agente.
321
+ */
322
+ export function marksDiscovery(toolName: string): boolean {
323
+ return (
324
+ toolName.includes("codegraph_explore")
325
+ )
326
+ }
327
+
328
+ /**
329
+ * Predicado puro del horizonte del gate de discovery: ¿este prompt invalida el
330
+ * hit estructural de la sesión?
331
+ *
332
+ * El contrato de reuso es POR REQUEST ("reuso del output ya obtenido en el request",
333
+ * remove-discovery-cache-phantom archivado 2026-10-19), no por sesión: la instrucción
334
+ * §1 del agente y record_discovery son por request; el gate era el único desalineado.
335
+ * Los prompts TRIVIALES no invalidan: el plugin les inyecta "No hagas Discovery"
336
+ * (hint :555) — limpiar el hit ahí crearía instrucción y gate contradictorias.
337
+ */
338
+ export function shouldInvalidateDiscoveryHit(trivial: boolean): boolean {
339
+ return !trivial
340
+ }
341
+
342
+ /**
343
+ * Mismo criterio de "archivo de código" que el gate de read (index.ts:552),
344
+ * sin la cláusula de `pattern` que es específica de glob. Docs/JSON quedan fuera:
345
+ * un .md bajo assets/ es documentación, no código.
346
+ */
347
+ export function isCodePath(p: string): boolean {
348
+ if (!p) return false
349
+ if (/\.(md|json)$/i.test(p)) return false
350
+ return /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p) || p.includes("src/") || p.includes("assets/")
351
+ }
352
+
353
+ /**
354
+ * Evidencia de una corrida de shell para las tasks `test:` (change
355
+ * test-verification-execution). Devuelve null si el evento no permite afirmar
356
+ * nada: sin comando o sin `exit` numérico.
357
+ *
358
+ * NO consulta `status`: medido en OpenCode 2.0.32, `sh -c 'exit 3'` llega con
359
+ * `status: "completed"` y `result.metadata.status: "completed"`. El único dato
360
+ * veraz es `result.metadata.exit`.
361
+ */
362
+ export function shellEvidence(event: any): { cmd: string; exit: number; ts: number } | null {
363
+ if (!event || event.tool !== "shell") return null
364
+ const cmd = event.input?.command
365
+ const exit = event.result?.metadata?.exit
366
+ if (typeof cmd !== "string" || !cmd.trim()) return null
367
+ if (typeof exit !== "number") return null
368
+ return { cmd: cmd.trim(), exit, ts: Date.now() }
369
+ }
370
+
371
+ /** Tools que mutan archivos: avanzan `lastMutationTs`. */
372
+ export function isMutatingTool(tool: string): boolean {
373
+ return tool === "edit" || tool === "write" || tool === "patch"
374
+ }
375
+
376
+ /**
377
+ * Comando shell que escribe en disco. Predicado compartido por el gate de
378
+ * mutación y por `lastMutationTs`: si no se compartiera, un `sed -i` aprobado
379
+ * por el gate mutaría el código sin avanzar el timestamp, y una corrida de test
380
+ * previa seguiría contando como evidencia.
381
+ */
382
+ export function isShellMutation(command: string): boolean {
383
+ if (!command) return false
384
+ // `2>&1`, `1>&2`, `2>/dev/null` son redirects de stderr, no mutación.
385
+ const cmdSansStderr = command.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
386
+ return /(^|[^0-9&])>{1,2}\s*(?!&)\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|\brm\s+|python.*open.*w/.test(cmdSansStderr)
387
+ }
388
+
389
+ /**
390
+ * Verbos cuyo output revela el CONTENIDO de un archivo. No incluye verbos que
391
+ * sólo mencionan paths (`bun test x.ts`, `tsc src/x.ts`, `git diff`): el gate
392
+ * distingue "revela" de "menciona" — ver change shell-code-read-gate.
393
+ */
394
+ const SHELL_READ_VERBS = new Set([
395
+ "cat", "head", "tail", "sed", "awk", "grep", "rg", "ag", "ack", "less", "more",
396
+ "nl", "bat", "tac", "strings", "xxd", "od", "base64", "diff", "cut", "paste",
397
+ "sort", "uniq", "column", "expand", "fold",
398
+ ])
399
+
400
+ const SHELL_WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "nohup"])
401
+
402
+ /**
403
+ * Paths de código que `command` REVELA (no los que menciona).
404
+ *
405
+ * Fail-open por diseño: redirección => escritura, `sed -i` => escritura, verbo
406
+ * fuera de la allowlist => []. Un gate de shell nunca es hermético; el objetivo
407
+ * es quitar el bypass casual (`cat src/x.ts`), no demostrar imposibilidad.
408
+ * Bypass residual documentado en el spec: `git show`, `python -c`, `node -e`,
409
+ * `find -exec`, `xargs`.
410
+ */
411
+ export function shellReadTargets(command: string): string[] {
412
+ if (!command) return []
413
+ const targets: string[] = []
414
+ // Split por pipes/separadores, NO por `>`: necesitamos ver la redirección.
415
+ const segments = command.replace(/&&/g, ";").replace(/\|\|/g, ";").split(/[|;\n]+/)
416
+ for (const seg of segments) {
417
+ const trimmed = seg
418
+ // Redirects de stderr no son escritura: sin esto, `cat src/x.ts 2>&1`
419
+ // pasaba el check `>` de abajo y se colaba del gate (bypass casual).
420
+ // Mismo criterio que isShellMutation, el sibling de este módulo.
421
+ .replace(/2>&1/g, "")
422
+ .replace(/1>&2/g, "")
423
+ .replace(/2>\s*\/dev\/null/g, "")
424
+ .trim()
425
+ if (!trimmed) continue
426
+ // Escritura real (cat > f, tee, heredoc): no es lectura.
427
+ if (/>/.test(trimmed)) continue
428
+ const tokens = (trimmed.match(/(?:[^\s"'`\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || [])
429
+ .map((t) => t.replace(/["'`]/g, "").replace(/\\/g, ""))
430
+ .filter(Boolean)
431
+ if (tokens.length === 0) continue
432
+ let i = 0
433
+ while (i < tokens.length && SHELL_WRAPPERS.has(tokens[i])) i++
434
+ const verb = tokens[i] || ""
435
+ const base = verb.split("/").pop() || verb
436
+ if (!SHELL_READ_VERBS.has(base)) continue
437
+ // sed -i es escritura in-place, no lectura.
438
+ if (base === "sed" && tokens.some((t) => t === "-i" || t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place"))) continue
439
+ // diff -q/--brief/--stat sólo compara metadatos: no revela contenido.
440
+ if (base === "diff" && tokens.some((t) => t === "-q" || t === "--quiet" || t === "--brief" || t === "--stat" || /^-[a-zA-Z]*q[a-zA-Z]*$/.test(t))) continue
441
+ for (const p of extractPathsFromBash(trimmed)) {
442
+ if (isCodePath(p)) targets.push(p)
443
+ }
444
+ }
445
+ return [...new Set(targets)]
446
+ }
447
+
448
+ /**
449
+ * Predicado puro del gate de shell: ¿leer este path por shell está EXENTO?
450
+ *
451
+ * Extraído del hook para poder testear el INVARIANTE (mismo criterio que
452
+ * shouldBlockCodeRead). Exenciones:
453
+ * 1. Paths FUERA del proyecto: no son código de este codebase (fixtures
454
+ * temporales de tests, reportes generados); bloquearlos rompe pipelines sin
455
+ * escape hatch — el falso positivo que el diseño prohibió.
456
+ * 2. Specs/docs (.md/.json, openspec, assets/docs).
457
+ * 3. Archivos ya trackeados en tasks/fileFingerprints/lastValidated (el refresh
458
+ * honesto post-complete necesita re-leerlos).
459
+ */
460
+ export function isShellPathExempt(p: string, directory: string, freshState: any): boolean {
461
+ if (!isPathInsideProject(p, directory)) return true
462
+ if (p.includes("openspec/changes") || p.includes("openspec/specs")) return true
463
+ if (p.includes("assets/docs") || p.includes("/docs/") || /\.md$/i.test(p) || /\.json$/i.test(p)) return true
464
+ try {
465
+ if (freshState) {
466
+ const fps = Object.keys(freshState.fileFingerprints || {})
467
+ const taskFiles = Object.values(freshState.tasks || {}).map((t: any) => t.filePath).filter(Boolean)
468
+ const lv: any = (freshState as any).lastValidated || null
469
+ const lvByFile = lv?.byFile && typeof lv.byFile === "object" ? lv.byFile : null
470
+ const lvPaths: string[] = [
471
+ ...(lvByFile ? Object.keys(lvByFile) : []),
472
+ ...(lv?.filePath ? [lv.filePath as string] : []),
473
+ ]
474
+ const matches = (f: string): boolean => p.endsWith(f) || f.endsWith(p) || p.includes(f) || f.includes(p)
475
+ if (fps.some(matches) || taskFiles.some(matches)) return true
476
+ if (lvPaths.some((lf) => p.endsWith(lf) || lf.endsWith(p))) return true
477
+ }
478
+ } catch {}
479
+ return false
480
+ }
481
+
482
+ /** Comandos `test:` declarados en taskVerifications, ya normalizados (sin prefijo). */
483
+ export function testVerifyCommands(state: any): Set<string> {
484
+ const out = new Set<string>()
485
+ try {
486
+ for (const v of Object.values((state as any)?.taskVerifications || {})) {
487
+ if (typeof v === "string" && v.trim().startsWith("test:")) {
488
+ const cmd = v.trim().slice("test:".length).trim()
489
+ if (cmd) out.add(cmd)
490
+ }
491
+ }
492
+ } catch {}
493
+ return out
494
+ }
495
+
262
496
  function jsonContent(value: unknown): { content: string } {
263
497
  try {
264
498
  return { content: JSON.stringify(value) ?? "null" }
@@ -343,9 +577,25 @@ export default {
343
577
  const sessionId: string = event.sessionID ?? "default"
344
578
  const text = (event.prompt.text ?? "").trim()
345
579
  const state = readState(directory)
580
+ // task-verification-integrity D4: registrar el texto ORIGINAL del usuario (antes de los
581
+ // hints de abajo) para que `implementation_complete({force:true})` se apoye en evidencia
582
+ // real y no en el audit, que lo redacta el agente. `ownedKeys` evita pisar el avance del MCP.
583
+ // Cada prompt lleva `ts`: el MCP sólo acepta confirmaciones POSTERIORES al último
584
+ // intento de force, así un "¿qué hace force:true?" viejo no abre el gate.
585
+ if (text && state) {
586
+ try {
587
+ const prev = Array.isArray((state as any).lastUserPrompts) ? (state as any).lastUserPrompts : []
588
+ ;(state as any).lastUserPrompts = [...prev, { text: text.slice(0, 500), ts: Date.now() }].slice(-5)
589
+ persistState(directory, state, ["lastUserPrompts"])
590
+ } catch {}
591
+ }
346
592
  const currentState = state?.state ?? "DONE"
347
593
  const trivial = isTrivial(text, currentState)
348
594
  trivialBySession.set(sessionId, trivial)
595
+ // Horizonte por request: prompts NO-triviales invalidan el discovery hit —
596
+ // la siguiente lectura de código exige un explore fresco (contrato
597
+ // remove-discovery-cache-phantom + instrucción §1 del agente).
598
+ if (shouldInvalidateDiscoveryHit(trivial)) discoveryHitByRequest.delete(sessionId)
349
599
  if (trivial) {
350
600
  // Preserve system FULL cacheable, add suffix hint to user prompt text
351
601
  const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
@@ -402,7 +652,9 @@ export default {
402
652
  checkCount++
403
653
  const freshState = readState(directory)
404
654
  const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
405
- if (freshState && pendingStates.includes(freshState.state)) {
655
+ // multisession-ownership-guard: el PENDING de OTRA sesión no congela esta sesión.
656
+ const foreignPendingOwner = !!freshState && isForeignPendingOwner(freshState, sessionId)
657
+ if (freshState && pendingStates.includes(freshState.state) && !foreignPendingOwner) {
406
658
  // cache ALLOW per revision, BLOCKED never cached
407
659
  if (lastCheck && freshState.revision === lastCheck.revision && checkCount % 5 !== 0 && lastCheck.result === "ALLOW") {
408
660
  // reuse cached ALLOW
@@ -424,7 +676,7 @@ export default {
424
676
  }
425
677
  }
426
678
  } else if (freshState) {
427
- lastCheck = { revision: freshState.revision, result: "ALLOW" }
679
+ lastCheck = nextPendingGateCache(freshState.revision, foreignPendingOwner)
428
680
  }
429
681
 
430
682
  // ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
@@ -519,14 +771,30 @@ export default {
519
771
  (lvFile ? lvFile.some((lf) => targetPath.endsWith(lf) || lf.endsWith(targetPath)) : false)
520
772
  }
521
773
  } catch {}
522
- if (isCodeFile && !isLiteralGrep && !isSpecTasksPath && !isDocsPath && !isTaskTracked) {
523
- const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(directory)
524
- const codegraphOk = isCodegraphAvailable(directory)
525
- if (codegraphOk && !hasDiscoveryHit) {
526
- // Allow if file is not indexable or trivial?
527
- // Block with suggestion
528
- throw new Error(`BLOCKED: Usá getDiscoverySnapshot primero — sugerencia: getDiscoverySnapshot("${targetPath || "query"}")`)
529
- }
774
+ // Orden cheap -> caro: el path no hace I/O; codegraph y la cache en disco solo se evalúan
775
+ // si el path esta realmente sujeto al gate (`read`/`grep`/`glob` corren en cada turno).
776
+ const gated = isCodeReadGated({ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked })
777
+ const codegraphOk = gated ? isCodegraphAvailable(directory) : false
778
+ // Sin fallback a disco: la única evidencia válida es un codegraph_explore
779
+ // COMPLETADO en este request (discoveryHitByRequest; el hook "prompt"
780
+ // invalida el hit en cada prompt no-trivial). session.deleted también lo limpia.
781
+ const hasDiscoveryHit = codegraphOk ? discoveryHitByRequest.get(sessionId) === true : false
782
+ if (shouldBlockCodeRead({
783
+ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked, codegraphOk, hasDiscoveryHit,
784
+ })) {
785
+ // Métrica de evasión real (misma convención que engramBypassCount:648):
786
+ // cuántas lecturas de código saltaron el gate sin explore completado.
787
+ try {
788
+ const st = readState(directory)
789
+ if (st) {
790
+ st.codeReadBlockedCount = (st.codeReadBlockedCount || 0) + 1
791
+ persistState(directory, st, ["codeReadBlockedCount"])
792
+ }
793
+ } catch {}
794
+ try { console.warn(`[ostacky] code_read_blocked: sin codegraph_explore completado en sesión (${targetPath || "query"})`) } catch {}
795
+ throw new Error(
796
+ `BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${targetPath || "query"}") antes de Read/Grep/Glob de código`
797
+ )
530
798
  }
531
799
  }
532
800
 
@@ -560,12 +828,32 @@ export default {
560
828
  }
561
829
  }
562
830
 
831
+ // ── 3.2) CodeGraph gate para shell: leer código sin discovery → BLOCKED ──
832
+ // El gate de código (arriba) sólo mira read/grep/glob; `cat src/x.ts` revela
833
+ // lo mismo y no costaba nada. Fail-open a propósito: `read` falla cerrado
834
+ // porque es inequívoco; `shell` es ambiguo y un falso positivo rompe tests o
835
+ // builds sin escape hatch para el usuario (change shell-code-read-gate).
836
+ if (tool === "shell") {
837
+ const cmdForRead: string = args?.command || args?.cmd || ""
838
+ if (typeof cmdForRead === "string" && cmdForRead) {
839
+ const shellTargets = shellReadTargets(cmdForRead)
840
+ if (shellTargets.length > 0) {
841
+ const gatedShell = shellTargets.filter((p) => !isShellPathExempt(p, directory, freshState))
842
+ const codegraphOkShell = gatedShell.length > 0 ? isCodegraphAvailable(directory) : false
843
+ const hasDiscoveryShell = codegraphOkShell ? discoveryHitByRequest.get(sessionId) === true : false
844
+ if (gatedShell.length > 0 && codegraphOkShell && !hasDiscoveryShell) {
845
+ throw new Error(
846
+ `BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${gatedShell[0]}") antes de leer código por shell`
847
+ )
848
+ }
849
+ }
850
+ }
851
+ }
852
+
563
853
  // ── 3.5) Shell file-mutation gate (harden-task-integrity: shell mutante requiere validate_edit) ──
564
854
  if (tool === "shell" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
565
855
  const cmdForMutation: string = args?.command || args?.cmd || ""
566
- // Fix falso positivo: `2>&1`, `1>&2`, `2>/dev/null` son redirects de stderr, no mutación.
567
- const cmdSansStderr = cmdForMutation.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
568
- const isMutating = /(^|[^0-9&])>{1,2}\s*(?!&)\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdSansStderr)
856
+ const isMutating = isShellMutation(cmdForMutation)
569
857
  if (isMutating) {
570
858
  const mPaths = extractPathsFromBash(cmdForMutation)
571
859
  for (const p of mPaths) {
@@ -722,24 +1010,53 @@ export default {
722
1010
  // Track discovery hit to allow subsequent Reads
723
1011
  const tool: string = event.tool as string
724
1012
  const sessionId: string = (event.sessionID ?? "default") as string
725
- const resultText = event.status === "completed" && event.result ? JSON.stringify(event.result) : ""
726
- if (tool.includes("getDiscoverySnapshot") || tool.includes("get_discovery_snapshot")) {
727
- // if output indicates hit, mark it
1013
+ // (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en ESTE
1014
+ // request abre el gate de lecturas de código (el hook "prompt" invalida el hit en cada
1015
+ // prompt no-trivial). Se marca aunque el resultado venga vacío, a propósito:
1016
+ // "CodeGraph no tiene este archivo en su índice" es justamente el caso donde el
1017
+ // Read TIENE que poder pasar si hubo `codegraph_explore`.
1018
+ if (marksDiscovery(tool)) {
728
1019
  try {
729
- const text = resultText
730
- if (text && !text.includes("null") && text.length > 10) {
731
- discoveryHitByRequest.set(sessionId, true)
732
- }
733
- // Engram hit via discovery snapshot (contiene engramHits)
734
- if (text && text.includes("engramHits")) {
735
- engramHitBySession.set(sessionId, true)
736
- }
1020
+ if (event.status === "completed") discoveryHitByRequest.set(sessionId, true)
737
1021
  } catch {}
738
1022
  }
739
1023
  // Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
740
- if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("getEngramDedup") || tool.includes("engram_mem_")) {
1024
+ if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("engram_mem_")) {
741
1025
  engramHitBySession.set(sessionId, true)
742
1026
  }
1027
+ // test-verification-execution: evidencia para tasks `test:`.
1028
+ // `status` miente con exit != 0 (ver shellEvidence): sólo se registra con exit numérico.
1029
+ // D10 respectado: NO cada shell persiste (git/ls/builds no tienen valor de evidencia y
1030
+ // cada persist cuesta lock + re-read + write + 3 renames de backup). Se persisten sólo
1031
+ // las corridas cuyo comando matchea un `test:` declarado en taskVerifications — que son
1032
+ // las ÚNICAS que complete_task / verifyIntegrity consultan via findTestEvidence.
1033
+ // Tradeoff fail-closed: si taskVerifications aún no llegó al state cuando se corre el
1034
+ // test, la evidencia no se registra → complete_task pide re-correr (razón explícita).
1035
+ try {
1036
+ const ev = shellEvidence(event as any)
1037
+ // Un shell que escribe (exit 0) también invalida evidencia de test:
1038
+ // si no, `sed -i` mutaría el código sin avanzar el timestamp.
1039
+ const mutating =
1040
+ (isMutatingTool(tool) && event.status === "completed") ||
1041
+ (!!ev && ev.exit === 0 && isShellMutation(ev.cmd))
1042
+ const isTestRun = !!ev && testVerifyCommands(readState(directory)).has(ev.cmd)
1043
+ if (isTestRun || mutating) {
1044
+ const s = readState(directory)
1045
+ if (s) {
1046
+ const owned: string[] = []
1047
+ if (ev && isTestRun) {
1048
+ const prev = Array.isArray((s as any).shellRuns) ? (s as any).shellRuns : []
1049
+ ;(s as any).shellRuns = [...prev, ev].slice(-25)
1050
+ owned.push("shellRuns")
1051
+ }
1052
+ if (mutating) {
1053
+ ;(s as any).lastMutationTs = Date.now()
1054
+ owned.push("lastMutationTs")
1055
+ }
1056
+ if (owned.length > 0) persistState(directory, s, owned)
1057
+ }
1058
+ }
1059
+ } catch {}
743
1060
  // Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
744
1061
  try {
745
1062
  const s = readState(directory)
@@ -36,12 +36,12 @@ Do NOT invoke any implementation skill, write any code, or scaffold any project
36
36
 
37
37
  ### Process
38
38
 
39
- 1. **Check discovery-cache first** — `src/discovery-cache.ts` `getDiscoverySnapshot(query)` + `getEngramDedup(query, requestId)`. Si hit válido (TTL+gitDiffHash), **reusar** sin llamar tools. Solo si miss → `engram_mem_search` + `codegraph_codegraph_explore` y **SHALL `putDiscoverySnapshot`** antes de avanzar. No re-llamar si área difiere <30% del snapshot.
39
+ 1. **Reusar discovery** — si ya llamaste `codegraph_codegraph_explore` para el área en este request, reusá ese output; si no, `engram_mem_search` + `codegraph_codegraph_explore`. No repitas la misma query sin motivo. No hay cache de archivo.
40
40
  2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria
41
41
  4. **Propose 2-3 approaches (hardening-v2 — SHALL)** — con tabla trade-offs (coste|riesgo|complejidad) + evidencia CodeGraph+Engram sin alucinar, YAGNI, y recomendación con razón; cada approach cita symbols existentes y mem_search hits verificables
42
42
  5. **Present design** — in sections scaled to complexity, get user approval after each section. **Gate post-brainstorming (hardening-v2):** tras presentar diseño, preguntar "¿Procedo con este diseño o querés ajustar algo?" y esperar confirmación explícita antes de `record_discovery`, `openspec-propose` o implementación directa
43
43
  6. **Write design doc (output path condicional — router exclusivo) — SHALL read fresco + edit no write + sync proactiva:**
44
- - Antes de `write` SHALL `read` fresco de `openspec/changes/<id>/{proposal,design,tasks}` o `docs/superpowers/specs/*.md` si existen; si existen SHALL `edit` (no `write`); `write` solo si no existía. `getDiscoverySnapshot` no aplica a specs/docs. Para `open-explore` sin archivo SHALL `mem_save topic_key:brainstorm/<hash>` por iteración.
44
+ - Antes de `write` SHALL `read` fresco de `openspec/changes/<id>/{proposal,design,tasks}` o `docs/superpowers/specs/*.md` si existen; si existen SHALL `edit` (no `write`); `write` solo si no existía. El discovery no aplica a specs/docs. Para `open-explore` sin archivo SHALL `mem_save topic_key:brainstorm/<hash>` por iteración.
45
45
  - Si trigger + `level 1+` no-downgradeable (`estLines>30` o `fileCount>2` o API pública) y change activo → escribir `openspec/changes/<id>/design.md` sección `## Alternatives Considered` con 2-3 approaches (tabla coste|riesgo|complejidad + evidencia CodeGraph+Engram), no `docs/`. No invocar `openspec-propose` separado — este es el diseño. Si ya existe, SHALL `edit` no `write`.
46
46
  - Si trigger + `0/0+1` o `1+` downgradeable (`estLines<30`&&`fileCount==1`&&sin API) → solo `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` + gate único `¿Procedo?` → DIRECT sin change. SHALL sugerir downgrade: `"Esto parece 0+1 (~X líneas, 1 archivo). ¿Lo tratamos sin spec?"` y esperar. Si `docsSpec` ya existe SHALL `read` + `edit`.
47
47
  - Gate post-brainstorming es **único**; no hay segundo `¿Procedo con spec?`. `get_audit` `WARN:duplicate_design_generated` si ambos artefactos mismo `requestId`.
@@ -25,14 +25,13 @@ Determinar el modo de ejecución óptimo entre **inline** y **subagent-driven**
25
25
 
26
26
  ## Procedimiento
27
27
 
28
- ### Paso 0: Verificar discovery-cache primero (ÚNICO entrypoint)
29
-
30
- **SHALL** llamar `src/discovery-cache.ts` `getDiscoverySnapshot(query)` y `getEngramDedup(query, requestId)` antes de cualquier `codegraph_codegraph_explore` o `engram_mem_search`. Si hit válido (TTL+gitDiffHash) → **reusar** `codegraph+engramHits` para construir `sharedFiles/fileClusters`; solo `codegraph_impact` para símbolos no cubiertos. Si miss o área difiere >30% → fetch fresco y `putDiscoverySnapshot`. Segunda llamada misma query en mismo `requestId` SHALL ser dedup (no `redundantCallCount`).
31
-
32
- ### Paso 0.1: Consultar Engram por decisiones previas (solo si no hay dedup hit)
33
-
34
- Si `getEngramDedup` fue miss, `engram_mem_search` con keywords del cambio. Si existe decisión previa, usar como referencia no vinculante.
35
-
28
+ ### Paso 0: Reusar el discovery ya obtenido en este request
29
+
30
+ Si en este mismo request ya llamaste `codegraph_codegraph_explore` para el área, **reusá ese output** (sigue en tu contexto) para construir `sharedFiles/fileClusters`; solo `codegraph_impact` para símbolos no cubiertos. Si el área difiere materialmente (overlap <30%) o todavía no hiciste discovery → `codegraph_codegraph_explore` fresco. No hay cache de archivo: no repitas la misma query sin motivo.
31
+
32
+ ### Paso 0.1: Consultar Engram por decisiones previas
33
+
34
+ `engram_mem_search` con keywords del cambio, salvo que ya lo hayas hecho para el mismo tema en este request. Si existe decisión previa, usarla como referencia no vinculante.
36
35
  ### Paso 0.5: Early exit para cambios pequeños
37
36
 
38
37
  Si el change tiene **≤2 tasks** Y **no comparten archivos entre sí** → devolver directamente:
@@ -48,10 +47,10 @@ Si el change tiene **≤2 tasks** Y **no comparten archivos entre sí** → devo
48
47
  }
49
48
  ```
50
49
 
51
- ### Paso 1: Obtener datos de CodeGraph (solo si discovery-cache miss)
52
-
53
- Si Paso 0 fue hit → **SKIP este paso**, derivar `fileClusters/sharedFiles` del snapshot (`state.snapshots.codegraph` + `tasks.md`). Snapshot resultante SHALL usar `codegraphUsed:["discovery-cache"]` (válido, no genera `WARN:execution_without_codegraph` si `snapshots.codegraph` existe) y `reuseDiscovery:true`.
54
- Si miss:
50
+ ### Paso 1: Obtener datos de CodeGraph (solo si no hiciste discovery en este request)
51
+
52
+ Si ya hay discovery en contexto → **SKIP este paso**; derivar `fileClusters/sharedFiles` del output (`state.snapshots.codegraph` + `tasks.md`) con `codegraphUsed:["codegraph_explore"]` y `reuseDiscovery:true`.
53
+ Si no:
55
54
  ```
56
55
  codegraph_codegraph_explore con query: "<área del cambio>"
57
56
  ```
@@ -26,9 +26,9 @@ When ready to implement, run /opsx-apply
26
26
 
27
27
  **Steps**
28
28
 
29
- 0. **Ground with CodeGraph + Engram (SHALL — Ostacky)** — Antes de `openspec new`, verifica `getDiscoverySnapshot(query)` + `getEngramDedup`. Si miss → `codegraph_codegraph_explore` + `engram_mem_search` + `putDiscoverySnapshot`. Cita símbolos existentes en `proposal/design`; sin esto el `design.md` puede alucinar API inexistente y dispara `WARN:codegraphBypass` en `doctor`.
29
+ 0. **Ground with CodeGraph + Engram (SHALL — Ostacky)** — Antes de `openspec new`, usá `codegraph_codegraph_explore` + `engram_mem_search` sobre el área (si no los hiciste ya en este request). Citá símbolos existentes en `proposal/design`; sin esto el `design.md` puede alucinar API inexistente y dispara `WARN:codegraphBypass` en `doctor`.
30
30
 
31
- 1. **Check Engram for similar changes** — `engram_mem_search` con keywords del *paso 0* (reusar si ya fue hit; no duplicar `mem_search` mismo `requestId`). Si hay change similar previo, surfacealo. Pueden extender trabajo existente en vez de empezar de cero.
31
+ 1. **Check Engram for similar changes** — `engram_mem_search` con keywords del *paso 0* (no repetir la misma query en el mismo request). Si hay change similar previo, surfacealo. Pueden extender trabajo existente en vez de empezar de cero.
32
32
 
33
33
  2. **If no clear input provided, ask what they want to build**
34
34
 
@@ -28,7 +28,7 @@ Write the test first. Watch it fail. Write minimal code to pass.
28
28
 
29
29
  Thinking "skip TDD just this once"? Stop. That's rationalization.
30
30
 
31
- > **Ostacky ground (SHALL):** Antes de RED, verifica `getDiscoverySnapshot(query)` + `getEngramDedup`. Si hit → reusar símbolos/decisiones; si miss → `codegraph_codegraph_explore` + `engram_mem_search` + `putDiscoverySnapshot`. Evita testear API inexistente y respeta `discovery-cache (único)` de `ostacky.md §1`. Ver `execution-mode-evaluation` para cache TTL.
31
+ > **Ostacky ground (SHALL):** Antes de RED, verificá el área con `codegraph_codegraph_explore` + `engram_mem_search` (si no los hiciste ya en este request). Citá símbolos existentes para no testear API inexistente. Ver `execution-mode-evaluation`.
32
32
 
33
33
  ## The Iron Law
34
34