ostacky 0.9.7 → 0.10.1

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.
@@ -141,6 +141,10 @@ export const DEFAULT_STATE: any = {
141
141
  pendingFileAccess: {},
142
142
  lastHeartbeat: 0,
143
143
  watchdogEnabled: true,
144
+ // multi-sesión: dueño de la request activa (sessionId de OpenCode vía _meta).
145
+ // null = sin dueño conocido. Es UN campo (se sobreescribe) — nunca un archivo por sesión.
146
+ ownerSessionId: null,
147
+ ownerHeartbeat: 0,
144
148
  ts: Date.now(),
145
149
  specNotInSync: false,
146
150
  engramBypassCount: 0,
@@ -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
  */
@@ -73,31 +73,137 @@ function readState(directory: string): any | null {
73
73
  }
74
74
  }
75
75
 
76
- function persistState(directory: string, state: any): void {
76
+ /** Sleep sincrónico acotado — los call sites del plugin son sync. */
77
+ function sleepSync(ms: number): void {
78
+ try {
79
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
80
+ } catch {
81
+ const end = Date.now() + ms
82
+ while (Date.now() < end) {
83
+ /* spin de último recurso */
84
+ }
85
+ }
86
+ }
87
+
88
+ function isPidAlive(pid: number): boolean {
89
+ if (!Number.isInteger(pid) || pid <= 0) return false
90
+ try {
91
+ process.kill(pid, 0)
92
+ return true
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Toma EL MISMO lock que el MCP (`<state>.lock.pid` con `wx`) para serializar
100
+ * plugin ↔ MCP cross-proceso. Devuelve una función de release; si no se pudo
101
+ * tomar, devuelve un no-op (la escritura sigue siendo segura por el merge).
102
+ */
103
+ function acquireStateLock(statePath: string): () => void {
104
+ const pidPath = statePath + ".lock.pid"
105
+ const tsPath = statePath + ".lock.timestamp"
106
+ const staleWindow = 15_000
107
+ let acquired = false
108
+ for (let attempt = 0; attempt < 8 && !acquired; attempt++) {
109
+ // Lock viejo: solo lo liberamos si el dueño NO está vivo (mismo criterio que el MCP).
110
+ try {
111
+ const ts = parseInt(readFileSync(tsPath, "utf-8"), 10)
112
+ if (!Number.isNaN(ts) && Date.now() - ts >= staleWindow) {
113
+ const owner = parseInt(readFileSync(pidPath, "utf-8").trim(), 10)
114
+ if (!isPidAlive(owner)) {
115
+ try { unlinkSync(pidPath) } catch {}
116
+ try { unlinkSync(tsPath) } catch {}
117
+ }
118
+ }
119
+ } catch {}
120
+ try {
121
+ writeFileSync(pidPath, String(process.pid), { encoding: "utf-8", flag: "wx" })
122
+ try { writeFileSync(tsPath, String(Date.now()), "utf-8") } catch {}
123
+ acquired = true
124
+ } catch {
125
+ sleepSync(5 + attempt * 10)
126
+ }
127
+ }
128
+ if (!acquired) return () => {}
129
+ return () => {
130
+ try {
131
+ if (readFileSync(pidPath, "utf-8").trim() !== String(process.pid)) return
132
+ } catch {
133
+ return
134
+ }
135
+ try { unlinkSync(pidPath) } catch {}
136
+ try { unlinkSync(tsPath) } catch {}
137
+ }
138
+ }
139
+
140
+ let persistSeq = 0
141
+
142
+ /**
143
+ * Merge puro: parte del estado FRESCO de disco y le aplica SOLO las claves que
144
+ * el plugin posee. Garantiza estructuralmente que una escritura del plugin no
145
+ * pueda borrar un avance del MCP (transición, audit, tasks, revision).
146
+ */
147
+ export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[]): any {
148
+ const payload: any = { ...fresh }
149
+ for (const k of ownedKeys) {
150
+ if (Object.prototype.hasOwnProperty.call(state, k)) payload[k] = state[k]
151
+ }
152
+ return payload
153
+ }
154
+
155
+ /**
156
+ * Persiste estado SIN poder pisar al MCP.
157
+ *
158
+ * El MCP es la autoridad de la máquina de estados; el plugin solo escribe un
159
+ * puñado de campos "advisory" (`ownedKeys`). Antes el plugin hacía
160
+ * readState → mutar → write del snapshot COMPLETO: si el MCP avanzaba en el
161
+ * medio (transición/audit/tasks), la escritura del plugin borraba ese avance.
162
+ *
163
+ * Ahora: (1) lock compartido con el MCP, (2) re-lectura fresca de disco DENTRO
164
+ * del lock, (3) se aplican SOLO las claves propias sobre el estado fresco,
165
+ * (4) write atómico (tmp único + rename). El `revision` se preserva del disco:
166
+ * las escrituras del plugin no son transiciones de la máquina de estados.
167
+ */
168
+ export function persistState(directory: string, state: any, ownedKeys?: string[]): void {
77
169
  const p = getStatePath(directory)
78
170
  const dir = dirname(p)
79
171
  try { mkdirSync(dir, { recursive: true }) } catch {}
80
- let serialized = JSON.stringify(state, null, 2)
81
- if (serialized.length > MAX_STATE_FILE_SIZE) {
82
- state.stateOversizedCount = (state.stateOversizedCount || 0) + 1
83
- const trimmed = { ...state, snapshots: { codegraph: null, execution: null }, audit: (state.audit || []).slice(-50) }
84
- serialized = JSON.stringify(trimmed, null, 2)
85
- if (serialized.length > MAX_STATE_FILE_SIZE) return
86
- state.snapshots = { codegraph: null, execution: null }
87
- serialized = JSON.stringify(state, null, 2)
88
- }
89
- const tmp = p + ".tmp." + process.pid
172
+
173
+ const release = acquireStateLock(p)
90
174
  try {
91
- writeFileSync(tmp, serialized, "utf-8")
92
- renameSync(tmp, p)
93
- try { renameSync(p + ".backup", p + ".backup.1") } catch {}
94
- try { renameSync(p + ".backup.1", p + ".backup.2") } catch {}
175
+ let payload: any = state
176
+ if (ownedKeys && ownedKeys.length > 0) {
177
+ let fresh: any = null
178
+ try { fresh = JSON.parse(readFileSync(p, "utf-8")) } catch {}
179
+ if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
180
+ payload = mergePluginOwnedState(fresh, state, ownedKeys)
181
+ }
182
+ }
183
+ let serialized = JSON.stringify(payload, null, 2)
184
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
185
+ payload.stateOversizedCount = (payload.stateOversizedCount || 0) + 1
186
+ const trimmed = { ...payload, snapshots: { codegraph: null, execution: null }, audit: (payload.audit || []).slice(-50) }
187
+ serialized = JSON.stringify(trimmed, null, 2)
188
+ if (serialized.length > MAX_STATE_FILE_SIZE) return
189
+ payload.snapshots = { codegraph: null, execution: null }
190
+ serialized = JSON.stringify(payload, null, 2)
191
+ }
192
+ const tmp = p + ".tmp." + process.pid + "." + ++persistSeq
95
193
  try {
96
- const backupTmp = p + ".backup.tmp." + process.pid
97
- writeFileSync(backupTmp, serialized, "utf-8")
98
- renameSync(backupTmp, p + ".backup")
194
+ writeFileSync(tmp, serialized, "utf-8")
195
+ renameSync(tmp, p)
196
+ try { renameSync(p + ".backup", p + ".backup.1") } catch {}
197
+ try { renameSync(p + ".backup.1", p + ".backup.2") } catch {}
198
+ try {
199
+ const backupTmp = p + ".backup.tmp." + process.pid + "." + persistSeq
200
+ writeFileSync(backupTmp, serialized, "utf-8")
201
+ renameSync(backupTmp, p + ".backup")
202
+ } catch {}
99
203
  } catch {}
100
- } catch {}
204
+ } finally {
205
+ release()
206
+ }
101
207
  }
102
208
 
103
209
  function fastFingerprint(filePath: string): string | null {
@@ -122,25 +228,6 @@ function isPathInsideProject(filePath: string, directory: string): boolean {
122
228
  } catch { return false }
123
229
  }
124
230
 
125
- function getDiscoveryCacheHit(directory: string): boolean {
126
- try {
127
- const cacheDir = join(directory, ".opencode", "cache", "codegraph")
128
- if (!existsSync(cacheDir)) return false
129
- const files = readdirSync(cacheDir).filter((f: string) => f.startsWith("discovery-"))
130
- if (files.length === 0) return false
131
- // check if any file is recent (<1h) and valid
132
- const now = Date.now()
133
- for (const f of files) {
134
- try {
135
- const raw = readFileSync(join(cacheDir, f), "utf-8")
136
- const data = JSON.parse(raw)
137
- if (typeof data.ts === "number" && now - data.ts < 60*60*1000) return true
138
- } catch {}
139
- }
140
- return false
141
- } catch { return false }
142
- }
143
-
144
231
  function isCodegraphAvailable(directory: string): boolean {
145
232
  // If codegraph binary exists, assume OK. Otherwise fallback allows Read.
146
233
  try {
@@ -153,6 +240,217 @@ function isCodegraphAvailable(directory: string): boolean {
153
240
  } catch { return false }
154
241
  }
155
242
 
243
+ /**
244
+ * ¿Este read/grep/glob esta sujeto al gate de discovery?
245
+ * Exenciones: docs/json/md, openspec/changes|specs, grep de literales, y archivos ya
246
+ * trackeados (refresh honesto post-complete necesita Read fresco sin Discovery hit).
247
+ */
248
+ export function isCodeReadGated(input: {
249
+ isCodeFile: boolean
250
+ isLiteralGrep: boolean
251
+ isSpecTasksPath: boolean
252
+ isDocsPath: boolean
253
+ isTaskTracked: boolean
254
+ }): boolean {
255
+ return (
256
+ input.isCodeFile &&
257
+ !input.isLiteralGrep &&
258
+ !input.isSpecTasksPath &&
259
+ !input.isDocsPath &&
260
+ !input.isTaskTracked
261
+ )
262
+ }
263
+
264
+ /**
265
+ * Predicado puro del gate de CodeGraph: ¿bloquear esta lectura de codigo?
266
+ *
267
+ * Extraido a funcion para poder testear el INVARIANTE, no el mensaje. El test anterior hacia
268
+ * `expect(src).toContain("Usa getDiscoverySnapshot primero")`: verificaba que el texto existiera
269
+ * y pasaba verde con el gate inescapable adentro (fix-codegraph-gate).
270
+ */
271
+ export function shouldBlockCodeRead(input: {
272
+ isCodeFile: boolean
273
+ isLiteralGrep: boolean
274
+ isSpecTasksPath: boolean
275
+ isDocsPath: boolean
276
+ isTaskTracked: boolean
277
+ codegraphOk: boolean
278
+ hasDiscoveryHit: boolean
279
+ }): boolean {
280
+ return isCodeReadGated(input) && input.codegraphOk && !input.hasDiscoveryHit
281
+ }
282
+
283
+ /**
284
+ * Tools cuyo completado constituye evidencia REAL de discovery estructural en la sesion.
285
+ *
286
+ * `codegraph_explore` es la senal fuerte: llamada real, resultado inspeccionable por el plugin.
287
+ * No hay cache de discovery: `getDiscoverySnapshot` nunca existió y se retiró.
288
+ *
289
+ * Historia (fix-codegraph-gate): antes la UNICA senal era `getDiscoverySnapshot`, una tool que nunca
290
+ * se registro ni en el plugin ni en el MCP. `hasDiscoveryHit` era siempre false y el gate bloqueaba
291
+ * todo read/grep/glob de codigo sin salida posible; un `codegraph_explore` completado NO lo abria,
292
+ * contradiciendo la instruccion "CodeGraph primero" del agente.
293
+ */
294
+ export function marksDiscovery(toolName: string): boolean {
295
+ return (
296
+ toolName.includes("codegraph_explore")
297
+ )
298
+ }
299
+
300
+ /**
301
+ * Mismo criterio de "archivo de código" que el gate de read (index.ts:552),
302
+ * sin la cláusula de `pattern` que es específica de glob. Docs/JSON quedan fuera:
303
+ * un .md bajo assets/ es documentación, no código.
304
+ */
305
+ export function isCodePath(p: string): boolean {
306
+ if (!p) return false
307
+ if (/\.(md|json)$/i.test(p)) return false
308
+ return /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p) || p.includes("src/") || p.includes("assets/")
309
+ }
310
+
311
+ /**
312
+ * Evidencia de una corrida de shell para las tasks `test:` (change
313
+ * test-verification-execution). Devuelve null si el evento no permite afirmar
314
+ * nada: sin comando o sin `exit` numérico.
315
+ *
316
+ * NO consulta `status`: medido en OpenCode 2.0.32, `sh -c 'exit 3'` llega con
317
+ * `status: "completed"` y `result.metadata.status: "completed"`. El único dato
318
+ * veraz es `result.metadata.exit`.
319
+ */
320
+ export function shellEvidence(event: any): { cmd: string; exit: number; ts: number } | null {
321
+ if (!event || event.tool !== "shell") return null
322
+ const cmd = event.input?.command
323
+ const exit = event.result?.metadata?.exit
324
+ if (typeof cmd !== "string" || !cmd.trim()) return null
325
+ if (typeof exit !== "number") return null
326
+ return { cmd: cmd.trim(), exit, ts: Date.now() }
327
+ }
328
+
329
+ /** Tools que mutan archivos: avanzan `lastMutationTs`. */
330
+ export function isMutatingTool(tool: string): boolean {
331
+ return tool === "edit" || tool === "write" || tool === "patch"
332
+ }
333
+
334
+ /**
335
+ * Comando shell que escribe en disco. Predicado compartido por el gate de
336
+ * mutación y por `lastMutationTs`: si no se compartiera, un `sed -i` aprobado
337
+ * por el gate mutaría el código sin avanzar el timestamp, y una corrida de test
338
+ * previa seguiría contando como evidencia.
339
+ */
340
+ export function isShellMutation(command: string): boolean {
341
+ if (!command) return false
342
+ // `2>&1`, `1>&2`, `2>/dev/null` son redirects de stderr, no mutación.
343
+ const cmdSansStderr = command.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
344
+ 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)
345
+ }
346
+
347
+ /**
348
+ * Verbos cuyo output revela el CONTENIDO de un archivo. No incluye verbos que
349
+ * sólo mencionan paths (`bun test x.ts`, `tsc src/x.ts`, `git diff`): el gate
350
+ * distingue "revela" de "menciona" — ver change shell-code-read-gate.
351
+ */
352
+ const SHELL_READ_VERBS = new Set([
353
+ "cat", "head", "tail", "sed", "awk", "grep", "rg", "ag", "ack", "less", "more",
354
+ "nl", "bat", "tac", "strings", "xxd", "od", "base64", "diff", "cut", "paste",
355
+ "sort", "uniq", "column", "expand", "fold",
356
+ ])
357
+
358
+ const SHELL_WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "nohup"])
359
+
360
+ /**
361
+ * Paths de código que `command` REVELA (no los que menciona).
362
+ *
363
+ * Fail-open por diseño: redirección => escritura, `sed -i` => escritura, verbo
364
+ * fuera de la allowlist => []. Un gate de shell nunca es hermético; el objetivo
365
+ * es quitar el bypass casual (`cat src/x.ts`), no demostrar imposibilidad.
366
+ * Bypass residual documentado en el spec: `git show`, `python -c`, `node -e`,
367
+ * `find -exec`, `xargs`.
368
+ */
369
+ export function shellReadTargets(command: string): string[] {
370
+ if (!command) return []
371
+ const targets: string[] = []
372
+ // Split por pipes/separadores, NO por `>`: necesitamos ver la redirección.
373
+ const segments = command.replace(/&&/g, ";").replace(/\|\|/g, ";").split(/[|;\n]+/)
374
+ for (const seg of segments) {
375
+ const trimmed = seg
376
+ // Redirects de stderr no son escritura: sin esto, `cat src/x.ts 2>&1`
377
+ // pasaba el check `>` de abajo y se colaba del gate (bypass casual).
378
+ // Mismo criterio que isShellMutation, el sibling de este módulo.
379
+ .replace(/2>&1/g, "")
380
+ .replace(/1>&2/g, "")
381
+ .replace(/2>\s*\/dev\/null/g, "")
382
+ .trim()
383
+ if (!trimmed) continue
384
+ // Escritura real (cat > f, tee, heredoc): no es lectura.
385
+ if (/>/.test(trimmed)) continue
386
+ const tokens = (trimmed.match(/(?:[^\s"'`\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || [])
387
+ .map((t) => t.replace(/["'`]/g, "").replace(/\\/g, ""))
388
+ .filter(Boolean)
389
+ if (tokens.length === 0) continue
390
+ let i = 0
391
+ while (i < tokens.length && SHELL_WRAPPERS.has(tokens[i])) i++
392
+ const verb = tokens[i] || ""
393
+ const base = verb.split("/").pop() || verb
394
+ if (!SHELL_READ_VERBS.has(base)) continue
395
+ // sed -i es escritura in-place, no lectura.
396
+ if (base === "sed" && tokens.some((t) => t === "-i" || t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place"))) continue
397
+ // diff -q/--brief/--stat sólo compara metadatos: no revela contenido.
398
+ if (base === "diff" && tokens.some((t) => t === "-q" || t === "--quiet" || t === "--brief" || t === "--stat" || /^-[a-zA-Z]*q[a-zA-Z]*$/.test(t))) continue
399
+ for (const p of extractPathsFromBash(trimmed)) {
400
+ if (isCodePath(p)) targets.push(p)
401
+ }
402
+ }
403
+ return [...new Set(targets)]
404
+ }
405
+
406
+ /**
407
+ * Predicado puro del gate de shell: ¿leer este path por shell está EXENTO?
408
+ *
409
+ * Extraído del hook para poder testear el INVARIANTE (mismo criterio que
410
+ * shouldBlockCodeRead). Exenciones:
411
+ * 1. Paths FUERA del proyecto: no son código de este codebase (fixtures
412
+ * temporales de tests, reportes generados); bloquearlos rompe pipelines sin
413
+ * escape hatch — el falso positivo que el diseño prohibió.
414
+ * 2. Specs/docs (.md/.json, openspec, assets/docs).
415
+ * 3. Archivos ya trackeados en tasks/fileFingerprints/lastValidated (el refresh
416
+ * honesto post-complete necesita re-leerlos).
417
+ */
418
+ export function isShellPathExempt(p: string, directory: string, freshState: any): boolean {
419
+ if (!isPathInsideProject(p, directory)) return true
420
+ if (p.includes("openspec/changes") || p.includes("openspec/specs")) return true
421
+ if (p.includes("assets/docs") || p.includes("/docs/") || /\.md$/i.test(p) || /\.json$/i.test(p)) return true
422
+ try {
423
+ if (freshState) {
424
+ const fps = Object.keys(freshState.fileFingerprints || {})
425
+ const taskFiles = Object.values(freshState.tasks || {}).map((t: any) => t.filePath).filter(Boolean)
426
+ const lv: any = (freshState as any).lastValidated || null
427
+ const lvByFile = lv?.byFile && typeof lv.byFile === "object" ? lv.byFile : null
428
+ const lvPaths: string[] = [
429
+ ...(lvByFile ? Object.keys(lvByFile) : []),
430
+ ...(lv?.filePath ? [lv.filePath as string] : []),
431
+ ]
432
+ const matches = (f: string): boolean => p.endsWith(f) || f.endsWith(p) || p.includes(f) || f.includes(p)
433
+ if (fps.some(matches) || taskFiles.some(matches)) return true
434
+ if (lvPaths.some((lf) => p.endsWith(lf) || lf.endsWith(p))) return true
435
+ }
436
+ } catch {}
437
+ return false
438
+ }
439
+
440
+ /** Comandos `test:` declarados en taskVerifications, ya normalizados (sin prefijo). */
441
+ export function testVerifyCommands(state: any): Set<string> {
442
+ const out = new Set<string>()
443
+ try {
444
+ for (const v of Object.values((state as any)?.taskVerifications || {})) {
445
+ if (typeof v === "string" && v.trim().startsWith("test:")) {
446
+ const cmd = v.trim().slice("test:".length).trim()
447
+ if (cmd) out.add(cmd)
448
+ }
449
+ }
450
+ } catch {}
451
+ return out
452
+ }
453
+
156
454
  function jsonContent(value: unknown): { content: string } {
157
455
  try {
158
456
  return { content: JSON.stringify(value) ?? "null" }
@@ -226,7 +524,7 @@ export default {
226
524
  const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
227
525
  const msg = `[ostacky] Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
228
526
  try { console.log(msg) } catch {}
229
- try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st) } } catch {}
527
+ try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st, ["lastPingTs"]) } } catch {}
230
528
  } catch {}
231
529
  }, 30_000)
232
530
  if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
@@ -237,6 +535,18 @@ export default {
237
535
  const sessionId: string = event.sessionID ?? "default"
238
536
  const text = (event.prompt.text ?? "").trim()
239
537
  const state = readState(directory)
538
+ // task-verification-integrity D4: registrar el texto ORIGINAL del usuario (antes de los
539
+ // hints de abajo) para que `implementation_complete({force:true})` se apoye en evidencia
540
+ // real y no en el audit, que lo redacta el agente. `ownedKeys` evita pisar el avance del MCP.
541
+ // Cada prompt lleva `ts`: el MCP sólo acepta confirmaciones POSTERIORES al último
542
+ // intento de force, así un "¿qué hace force:true?" viejo no abre el gate.
543
+ if (text && state) {
544
+ try {
545
+ const prev = Array.isArray((state as any).lastUserPrompts) ? (state as any).lastUserPrompts : []
546
+ ;(state as any).lastUserPrompts = [...prev, { text: text.slice(0, 500), ts: Date.now() }].slice(-5)
547
+ persistState(directory, state, ["lastUserPrompts"])
548
+ } catch {}
549
+ }
240
550
  const currentState = state?.state ?? "DONE"
241
551
  const trivial = isTrivial(text, currentState)
242
552
  trivialBySession.set(sessionId, trivial)
@@ -335,7 +645,7 @@ export default {
335
645
  if (st) {
336
646
  st.engramBypassCount = (st.engramBypassCount || 0) + 1
337
647
  // no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
338
- persistState(directory, st)
648
+ persistState(directory, st, ["engramBypassCount"])
339
649
  }
340
650
  } catch {}
341
651
  }
@@ -413,14 +723,20 @@ export default {
413
723
  (lvFile ? lvFile.some((lf) => targetPath.endsWith(lf) || lf.endsWith(targetPath)) : false)
414
724
  }
415
725
  } catch {}
416
- if (isCodeFile && !isLiteralGrep && !isSpecTasksPath && !isDocsPath && !isTaskTracked) {
417
- const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(directory)
418
- const codegraphOk = isCodegraphAvailable(directory)
419
- if (codegraphOk && !hasDiscoveryHit) {
420
- // Allow if file is not indexable or trivial?
421
- // Block with suggestion
422
- throw new Error(`BLOCKED: Usá getDiscoverySnapshot primero — sugerencia: getDiscoverySnapshot("${targetPath || "query"}")`)
423
- }
726
+ // Orden cheap -> caro: el path no hace I/O; codegraph y la cache en disco solo se evalúan
727
+ // si el path esta realmente sujeto al gate (`read`/`grep`/`glob` corren en cada turno).
728
+ const gated = isCodeReadGated({ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked })
729
+ const codegraphOk = gated ? isCodegraphAvailable(directory) : false
730
+ // Sin fallback a disco: la única evidencia válida es un codegraph_explore
731
+ // COMPLETADO en esta sesión (discoveryHitByRequest). El memo se limpia con
732
+ // session.deleted, así "esta sesión" es literal.
733
+ const hasDiscoveryHit = codegraphOk ? discoveryHitByRequest.get(sessionId) === true : false
734
+ if (shouldBlockCodeRead({
735
+ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked, codegraphOk, hasDiscoveryHit,
736
+ })) {
737
+ throw new Error(
738
+ `BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${targetPath || "query"}") antes de Read/Grep/Glob de código`
739
+ )
424
740
  }
425
741
  }
426
742
 
@@ -454,12 +770,32 @@ export default {
454
770
  }
455
771
  }
456
772
 
773
+ // ── 3.2) CodeGraph gate para shell: leer código sin discovery → BLOCKED ──
774
+ // El gate de código (arriba) sólo mira read/grep/glob; `cat src/x.ts` revela
775
+ // lo mismo y no costaba nada. Fail-open a propósito: `read` falla cerrado
776
+ // porque es inequívoco; `shell` es ambiguo y un falso positivo rompe tests o
777
+ // builds sin escape hatch para el usuario (change shell-code-read-gate).
778
+ if (tool === "shell") {
779
+ const cmdForRead: string = args?.command || args?.cmd || ""
780
+ if (typeof cmdForRead === "string" && cmdForRead) {
781
+ const shellTargets = shellReadTargets(cmdForRead)
782
+ if (shellTargets.length > 0) {
783
+ const gatedShell = shellTargets.filter((p) => !isShellPathExempt(p, directory, freshState))
784
+ const codegraphOkShell = gatedShell.length > 0 ? isCodegraphAvailable(directory) : false
785
+ const hasDiscoveryShell = codegraphOkShell ? discoveryHitByRequest.get(sessionId) === true : false
786
+ if (gatedShell.length > 0 && codegraphOkShell && !hasDiscoveryShell) {
787
+ throw new Error(
788
+ `BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${gatedShell[0]}") antes de leer código por shell`
789
+ )
790
+ }
791
+ }
792
+ }
793
+ }
794
+
457
795
  // ── 3.5) Shell file-mutation gate (harden-task-integrity: shell mutante requiere validate_edit) ──
458
796
  if (tool === "shell" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
459
797
  const cmdForMutation: string = args?.command || args?.cmd || ""
460
- // Fix falso positivo: `2>&1`, `1>&2`, `2>/dev/null` son redirects de stderr, no mutación.
461
- const cmdSansStderr = cmdForMutation.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
462
- const isMutating = /(^|[^0-9&])>{1,2}\s*(?!&)\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdSansStderr)
798
+ const isMutating = isShellMutation(cmdForMutation)
463
799
  if (isMutating) {
464
800
  const mPaths = extractPathsFromBash(cmdForMutation)
465
801
  for (const p of mPaths) {
@@ -541,7 +877,7 @@ export default {
541
877
  try {
542
878
  const st = readState(directory) || { ...DEFAULT_STATE }
543
879
  st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
544
- persistState(directory, st)
880
+ persistState(directory, st, ["staleContentAttempts"])
545
881
  } catch {}
546
882
  throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
547
883
  }
@@ -599,6 +935,13 @@ export default {
599
935
  ts: Date.now(),
600
936
  mode: isCreate ? 'create' : 'replace',
601
937
  }
938
+ // FIX multi-sesión: persistir lastValidated. Antes se mutaba un objeto
939
+ // local y se descartaba → complete_task leía un lastValidated viejo
940
+ // desde disco → CONFLICT → las tareas quedaban pending y el cierre
941
+ // (implementation_complete) se bloqueaba.
942
+ // `["lastValidated"]` = se escribe SOLO esa clave sobre el estado
943
+ // fresco que se re-lee bajo el lock compartido con el MCP.
944
+ persistState(directory, st, ["lastValidated"])
602
945
  }
603
946
  } catch {}
604
947
  }
@@ -609,24 +952,52 @@ export default {
609
952
  // Track discovery hit to allow subsequent Reads
610
953
  const tool: string = event.tool as string
611
954
  const sessionId: string = (event.sessionID ?? "default") as string
612
- const resultText = event.status === "completed" && event.result ? JSON.stringify(event.result) : ""
613
- if (tool.includes("getDiscoverySnapshot") || tool.includes("get_discovery_snapshot")) {
614
- // if output indicates hit, mark it
955
+ // (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en ESTA
956
+ // sesión abre el gate de lecturas de código. Se marca aunque el resultado venga vacío, a
957
+ // propósito: "CodeGraph no tiene este archivo en su índice" es justamente el caso donde el
958
+ // Read TIENE que poder pasar si hubo `codegraph_explore`.
959
+ if (marksDiscovery(tool)) {
615
960
  try {
616
- const text = resultText
617
- if (text && !text.includes("null") && text.length > 10) {
618
- discoveryHitByRequest.set(sessionId, true)
619
- }
620
- // Engram hit via discovery snapshot (contiene engramHits)
621
- if (text && text.includes("engramHits")) {
622
- engramHitBySession.set(sessionId, true)
623
- }
961
+ if (event.status === "completed") discoveryHitByRequest.set(sessionId, true)
624
962
  } catch {}
625
963
  }
626
964
  // Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
627
- 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_")) {
965
+ if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("engram_mem_")) {
628
966
  engramHitBySession.set(sessionId, true)
629
967
  }
968
+ // test-verification-execution: evidencia para tasks `test:`.
969
+ // `status` miente con exit != 0 (ver shellEvidence): sólo se registra con exit numérico.
970
+ // D10 respectado: NO cada shell persiste (git/ls/builds no tienen valor de evidencia y
971
+ // cada persist cuesta lock + re-read + write + 3 renames de backup). Se persisten sólo
972
+ // las corridas cuyo comando matchea un `test:` declarado en taskVerifications — que son
973
+ // las ÚNICAS que complete_task / verifyIntegrity consultan via findTestEvidence.
974
+ // Tradeoff fail-closed: si taskVerifications aún no llegó al state cuando se corre el
975
+ // test, la evidencia no se registra → complete_task pide re-correr (razón explícita).
976
+ try {
977
+ const ev = shellEvidence(event as any)
978
+ // Un shell que escribe (exit 0) también invalida evidencia de test:
979
+ // si no, `sed -i` mutaría el código sin avanzar el timestamp.
980
+ const mutating =
981
+ (isMutatingTool(tool) && event.status === "completed") ||
982
+ (!!ev && ev.exit === 0 && isShellMutation(ev.cmd))
983
+ const isTestRun = !!ev && testVerifyCommands(readState(directory)).has(ev.cmd)
984
+ if (isTestRun || mutating) {
985
+ const s = readState(directory)
986
+ if (s) {
987
+ const owned: string[] = []
988
+ if (ev && isTestRun) {
989
+ const prev = Array.isArray((s as any).shellRuns) ? (s as any).shellRuns : []
990
+ ;(s as any).shellRuns = [...prev, ev].slice(-25)
991
+ owned.push("shellRuns")
992
+ }
993
+ if (mutating) {
994
+ ;(s as any).lastMutationTs = Date.now()
995
+ owned.push("lastMutationTs")
996
+ }
997
+ if (owned.length > 0) persistState(directory, s, owned)
998
+ }
999
+ }
1000
+ } catch {}
630
1001
  // Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
631
1002
  try {
632
1003
  const s = readState(directory)
@@ -636,7 +1007,7 @@ export default {
636
1007
  const shouldPersist = process.env.OSTACKY_PLUGIN_PERSIST === "1" || (now - (s.lastHeartbeat || 0) > 30000)
637
1008
  if (shouldPersist) {
638
1009
  s.lastHeartbeat = now
639
- try { persistState(directory, s) } catch {}
1010
+ try { persistState(directory, s, ["lastHeartbeat"]) } catch {}
640
1011
  }
641
1012
  }
642
1013
  } catch {}
@@ -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`.