ostacky 0.10.1 → 0.10.3

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.
@@ -17,7 +17,9 @@
17
17
  // (solo tipos, borrado al transpilar) cargan sin dependencias externas.
18
18
  import type { Plugin } from "@opencode/plugin"
19
19
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"
20
- import { join, dirname, basename, resolve, relative, delimiter } from "node:path"
20
+ import { join, dirname, basename, resolve, relative, delimiter, sep } from "node:path"
21
+ import { homedir } from "node:os"
22
+ import { fileURLToPath } from "node:url"
21
23
  import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
22
24
  import { isTrivial } from "./tiered.ts"
23
25
  import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
@@ -30,6 +32,92 @@ const IDLE_THRESHOLD_MS = 45_000
30
32
 
31
33
  // DEFAULT_STATE imported from controller-core.ts
32
34
 
35
+ // ─── Scope (global vs local) ─────────────────────────────────────────────────
36
+ //
37
+ // OpenCode V2 dedupea plugins por `id` y conserva la PRIMERA ocurrencia en
38
+ // orden de arranque (el config global se descubre antes que el del proyecto).
39
+ // Con el mismo id en las dos copias, la global gana y la local queda
40
+ // `failed: "Duplicate plugin ID"` — el controller deja de aplicar sus gates sin
41
+ // avisar. Para que la precedencia sea local-primero (igual que el resto de la
42
+ // config de OpenCode), la copia global declara OTRO id y se retira sola cuando
43
+ // el proyecto tiene instalación local.
44
+
45
+ /**
46
+ * Config dir GLOBAL de OpenCode. Espeja `Global.Path.config` de OpenCode v2 en
47
+ * las tres plataformas: `OPENCODE_CONFIG_DIR` (override total, sin sufijo) →
48
+ * `$XDG_CONFIG_HOME/opencode` → `~/.config/opencode`. En Windows NO se usa
49
+ * `%APPDATA%` (OpenCode tampoco lo usa para config).
50
+ */
51
+ export function resolveGlobalConfigDir(opts?: {
52
+ env?: NodeJS.ProcessEnv
53
+ home?: string
54
+ }): string {
55
+ const env = opts?.env ?? process.env
56
+ const home = opts?.home ?? homedir()
57
+ const explicit = env.OPENCODE_CONFIG_DIR?.trim()
58
+ if (explicit) return explicit
59
+ const xdg = env.XDG_CONFIG_HOME?.trim()
60
+ if (xdg) return join(xdg, "opencode")
61
+ return join(home, ".config", "opencode")
62
+ }
63
+
64
+ /**
65
+ * id por copia: la global usa uno propio para no colisionar con la local.
66
+ * En win32 la comparación es case-insensitive porque los paths de Windows lo
67
+ * son: si el path real y el config dir difieren en mayúsculas, sin esto la
68
+ * copia global se clasificaría como local y volvería la colisión de ids.
69
+ */
70
+ export function pluginIdFor(
71
+ pluginDir: string,
72
+ globalDir: string,
73
+ platform: string = process.platform
74
+ ): string {
75
+ if (!pluginDir) return "ostacky-controller"
76
+ try {
77
+ const norm = (p: string) => {
78
+ const abs = resolve(p)
79
+ return platform === "win32" ? abs.toLowerCase() : abs
80
+ }
81
+ const d = norm(pluginDir)
82
+ const g = norm(globalDir)
83
+ if (d === g || d.startsWith(g + sep)) return "ostacky-controller-global"
84
+ } catch {}
85
+ return "ostacky-controller"
86
+ }
87
+
88
+ /**
89
+ * ¿El proyecto tiene el plugin instalado en scope local? Mira solo el location
90
+ * dir (lo que descubre OpenCode), no ancestros: si el local vive en un ancestro
91
+ * que el server no carga, ceder la precedencia dejaría el proyecto sin gates.
92
+ */
93
+ export function hasLocalPluginInstall(directory: string): boolean {
94
+ let dir: string
95
+ try {
96
+ dir = resolve(directory)
97
+ } catch {
98
+ return false
99
+ }
100
+ for (const kind of ["plugins", "plugin"]) {
101
+ for (const entry of ["index.ts", "index.js"]) {
102
+ try {
103
+ if (existsSync(join(dir, ".opencode", kind, "ostacky-controller", entry))) return true
104
+ } catch {}
105
+ }
106
+ }
107
+ return false
108
+ }
109
+
110
+ const PLUGIN_DIR = (() => {
111
+ try {
112
+ return dirname(fileURLToPath(import.meta.url))
113
+ } catch {
114
+ return ""
115
+ }
116
+ })()
117
+
118
+ const IS_GLOBAL_COPY = PLUGIN_DIR !== "" && pluginIdFor(PLUGIN_DIR, resolveGlobalConfigDir()) === "ostacky-controller-global"
119
+ const PLUGIN_ID = IS_GLOBAL_COPY ? "ostacky-controller-global" : "ostacky-controller"
120
+
33
121
  // ─── Helpers ─────────────────────────────────────────────────────────────────
34
122
 
35
123
  function lookupOnPath(name: string): string | null {
@@ -48,9 +136,18 @@ function lookupOnPath(name: string): string | null {
48
136
  return null
49
137
  }
50
138
 
51
- function getStatePath(directory: string): string {
139
+ export function getStatePath(directory: string): string {
52
140
  if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
53
141
  const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
142
+ // El MCP puede estar configurado en el opencode.json GLOBAL: si el plugin solo
143
+ // mira el del proyecto, lee/escribe OTRO state file y todos los gates (PENDING,
144
+ // ownership, lastValidated) quedan ciegos. Mismo orden que assets/plugins/engram.ts.
145
+ try {
146
+ const globalDir = resolveGlobalConfigDir()
147
+ if (globalDir) {
148
+ candidates.push(join(globalDir, "opencode.json"), join(globalDir, "opencode.jsonc"))
149
+ }
150
+ } catch {}
54
151
  for (const cand of candidates) {
55
152
  try {
56
153
  const raw = readFileSync(cand, "utf-8")
@@ -73,6 +170,59 @@ function readState(directory: string): any | null {
73
170
  }
74
171
  }
75
172
 
173
+ /**
174
+ * Pendientes para recovery post-compaction. Fuente única del hook `compaction`
175
+ * y del hint de `chat.message`: lastHandoff → fallback <24h → expectedTasks.
176
+ * El checkpoint count-based del MCP solo escribe lastHandoff cada 3er
177
+ * complete_task, así que sin la tercera fuente el hint quedaba mudo en T1/T2.
178
+ */
179
+ export function recoveryPending(state: any, directory?: string): { pending: string[]; nextSteps: string[] } {
180
+ const isPending = (id: string) => !state?.tasks?.[id] || state.tasks[id].status !== "COMPLETED"
181
+ const fromExpected = () => (Array.isArray(state?.expectedTasks) ? state.expectedTasks.filter(isPending) : [])
182
+ let pending: string[] = Array.isArray(state?.lastHandoff?.pendingTasks)
183
+ ? state.lastHandoff.pendingTasks.filter(isPending)
184
+ : []
185
+ let nextSteps: string[] = Array.isArray(state?.lastHandoff?.nextSteps) ? state.lastHandoff.nextSteps : []
186
+ if (pending.length === 0 && directory) {
187
+ try {
188
+ const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
189
+ if (existsSync(fallbackPath)) {
190
+ const data = JSON.parse(readFileSync(fallbackPath, "utf-8"))
191
+ if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
192
+ pending = data.pendingTasks.filter(isPending)
193
+ if (Array.isArray(data.nextSteps) && data.nextSteps.length > 0) nextSteps = data.nextSteps
194
+ }
195
+ }
196
+ } catch {}
197
+ }
198
+ if (pending.length === 0) pending = fromExpected()
199
+ if (pending.length === 0) nextSteps = []
200
+ else if (nextSteps.length === 0) nextSteps = [`Continuar con ${pending.join(", ")}`]
201
+ return { pending, nextSteps }
202
+ }
203
+
204
+ /** Texto del hint de recovery (null si no hay pendientes). Puro: testeable sin hooks. */
205
+ export function recoveryHint(state: any, directory?: string): string | null {
206
+ if (!state || ["DONE", "INTERPRETATION_PENDING"].includes(state.state)) return null
207
+ const { pending } = recoveryPending(state, directory)
208
+ if (pending.length === 0) return null
209
+ return `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
210
+ }
211
+
212
+ /** Payload del fallback de compaction, con los pendientes reales del state. */
213
+ export function compactionPayload(state: any, directory: string, sessionID?: string, contextSnippet = "") {
214
+ const { pending, nextSteps } = recoveryPending(state, directory)
215
+ return {
216
+ summary: state?.requestId
217
+ ? `Compaction fallback for request ${state.requestId} — ${pending.length} pendientes`
218
+ : `Compaction fallback for session ${sessionID ?? "unknown"} — plugin`,
219
+ nextSteps,
220
+ pendingTasks: pending,
221
+ ts: Date.now(),
222
+ contextSnippet,
223
+ }
224
+ }
225
+
76
226
  /** Sleep sincrónico acotado — los call sites del plugin son sync. */
77
227
  function sleepSync(ms: number): void {
78
228
  try {
@@ -97,10 +247,11 @@ function isPidAlive(pid: number): boolean {
97
247
 
98
248
  /**
99
249
  * 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).
250
+ * plugin ↔ MCP cross-proceso. Devuelve la función de release, o `null` si no se
251
+ * pudo tomar: el caller NO debe escribir sin lock — un `read` viejo + `rename`
252
+ * posterior puede revertir la transición que el MCP todavía no escribió.
102
253
  */
103
- function acquireStateLock(statePath: string): () => void {
254
+ function acquireStateLock(statePath: string): (() => void) | null {
104
255
  const pidPath = statePath + ".lock.pid"
105
256
  const tsPath = statePath + ".lock.timestamp"
106
257
  const staleWindow = 15_000
@@ -119,13 +270,21 @@ function acquireStateLock(statePath: string): () => void {
119
270
  } catch {}
120
271
  try {
121
272
  writeFileSync(pidPath, String(process.pid), { encoding: "utf-8", flag: "wx" })
122
- try { writeFileSync(tsPath, String(Date.now()), "utf-8") } catch {}
123
- acquired = true
273
+ try {
274
+ writeFileSync(tsPath, String(Date.now()), "utf-8")
275
+ acquired = true
276
+ } catch {
277
+ // Sin timestamp el lock es indistinguible de un huérfano y el MCP no
278
+ // puede liberarlo: queda pegado y ninguna escritura vuelve a entrar.
279
+ // Mejor soltarlo y reintentar.
280
+ try { unlinkSync(pidPath) } catch {}
281
+ sleepSync(5 + attempt * 10)
282
+ }
124
283
  } catch {
125
284
  sleepSync(5 + attempt * 10)
126
285
  }
127
286
  }
128
- if (!acquired) return () => {}
287
+ if (!acquired) return null
129
288
  return () => {
130
289
  try {
131
290
  if (readFileSync(pidPath, "utf-8").trim() !== String(process.pid)) return
@@ -139,6 +298,25 @@ function acquireStateLock(statePath: string): () => void {
139
298
 
140
299
  let persistSeq = 0
141
300
 
301
+ /**
302
+ * Patch que no se pudo escribir por falta de lock. Se aplica en la próxima
303
+ * escritura que sí lo consiga: perder un `lastValidated` rompería el gate de
304
+ * `complete_task`, pero escribir sin lock puede revertir un avance del MCP.
305
+ */
306
+ let pendingPatch: { patch: Record<string, unknown>; ts: number } | null = null
307
+ const PENDING_PATCH_TTL_MS = 60_000
308
+
309
+ function queuePatch(state: any, ownedKeys?: string[]): void {
310
+ if (!ownedKeys || ownedKeys.length === 0) return
311
+ const patch: Record<string, unknown> = {}
312
+ for (const k of ownedKeys) {
313
+ if (Object.prototype.hasOwnProperty.call(state, k)) patch[k] = state[k]
314
+ }
315
+ if (Object.keys(patch).length > 0) {
316
+ pendingPatch = { patch: { ...(pendingPatch?.patch ?? {}), ...patch }, ts: Date.now() }
317
+ }
318
+ }
319
+
142
320
  /**
143
321
  * Merge puro: parte del estado FRESCO de disco y le aplica SOLO las claves que
144
322
  * el plugin posee. Garantiza estructuralmente que una escritura del plugin no
@@ -152,6 +330,34 @@ export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[
152
330
  return payload
153
331
  }
154
332
 
333
+ /**
334
+ * multisession-ownership-guard: hay UN state file por proyecto, así que el PENDING
335
+ * puede ser de otra sesión. El gate no debe congelar sesiones ajenas a esa request
336
+ * (antes, cualquier sesión quedaba bloqueada por la confirmación pendiente de otra).
337
+ *
338
+ * No aplica cuando no hay dueño (estado legacy), cuando el caller es el dueño, ni
339
+ * cuando el caller es desconocido (`event.sessionID` ausente → "default"): sin
340
+ * identidad no se puede probar ajeneidad.
341
+ */
342
+ export function isForeignPendingOwner(state: any, sessionId: string | null | undefined): boolean {
343
+ const owner = state?.ownerSessionId
344
+ if (!owner || !sessionId || sessionId === "default") return false
345
+ return owner !== sessionId
346
+ }
347
+
348
+ /**
349
+ * Cache del gate PENDING. El cache es por `revision`, NO por dueño: si el paso de
350
+ * una sesión ajena dejara un ALLOW, la dueña lo reutilizaría y se saltearía su
351
+ * propia confirmación. Por eso un PENDING ajeno no cachea nada.
352
+ */
353
+ export function nextPendingGateCache(
354
+ revision: number,
355
+ foreignPendingOwner: boolean
356
+ ): { revision: number; result: string } | null {
357
+ if (foreignPendingOwner) return null
358
+ return { revision, result: "ALLOW" }
359
+ }
360
+
155
361
  /**
156
362
  * Persiste estado SIN poder pisar al MCP.
157
363
  *
@@ -171,6 +377,10 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
171
377
  try { mkdirSync(dir, { recursive: true }) } catch {}
172
378
 
173
379
  const release = acquireStateLock(p)
380
+ if (!release) {
381
+ queuePatch(state, ownedKeys)
382
+ return
383
+ }
174
384
  try {
175
385
  let payload: any = state
176
386
  if (ownedKeys && ownedKeys.length > 0) {
@@ -179,6 +389,16 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
179
389
  if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
180
390
  payload = mergePluginOwnedState(fresh, state, ownedKeys)
181
391
  }
392
+ if (pendingPatch) {
393
+ // TTL: un patch viejo (p.ej. de otra request) no debe satisfacer gates
394
+ // actuales, como un `lastValidated` que ya no corresponde.
395
+ if (Date.now() - pendingPatch.ts > PENDING_PATCH_TTL_MS) {
396
+ pendingPatch = null
397
+ } else {
398
+ payload = { ...payload, ...pendingPatch.patch }
399
+ pendingPatch = null
400
+ }
401
+ }
182
402
  }
183
403
  let serialized = JSON.stringify(payload, null, 2)
184
404
  if (serialized.length > MAX_STATE_FILE_SIZE) {
@@ -206,6 +426,27 @@ export function persistState(directory: string, state: any, ownedKeys?: string[]
206
426
  }
207
427
  }
208
428
 
429
+ /**
430
+ * Proyección del state que expone `ostacky_get_state`. Incluye los decision IDs:
431
+ * sin ellos el agente no puede consumir la decisión pendiente sin volver a
432
+ * llamar a get_state por MCP (fuente del doble pregunta).
433
+ */
434
+ export function projectStateForTool(state: any): Record<string, unknown> {
435
+ if (!state) return { error: "no state", state: "UNKNOWN", revision: 0 }
436
+ return {
437
+ state: state.state,
438
+ revision: state.revision,
439
+ requestId: state.requestId,
440
+ changeId: state.changeId ?? null,
441
+ degraded: !!state.degraded,
442
+ level: state.level ?? null,
443
+ routeChoice: state.routeChoice ?? null,
444
+ routeDecisionId: state.routeDecisionId ?? null,
445
+ executionDecisionId: state.executionDecisionId ?? null,
446
+ executionMode: state.executionMode ?? null,
447
+ }
448
+ }
449
+
209
450
  function fastFingerprint(filePath: string): string | null {
210
451
  try {
211
452
  const s = statSync(filePath)
@@ -297,6 +538,20 @@ export function marksDiscovery(toolName: string): boolean {
297
538
  )
298
539
  }
299
540
 
541
+ /**
542
+ * Predicado puro del horizonte del gate de discovery: ¿este prompt invalida el
543
+ * hit estructural de la sesión?
544
+ *
545
+ * El contrato de reuso es POR REQUEST ("reuso del output ya obtenido en el request",
546
+ * remove-discovery-cache-phantom archivado 2026-10-19), no por sesión: la instrucción
547
+ * §1 del agente y record_discovery son por request; el gate era el único desalineado.
548
+ * Los prompts TRIVIALES no invalidan: el plugin les inyecta "No hagas Discovery"
549
+ * (hint :555) — limpiar el hit ahí crearía instrucción y gate contradictorias.
550
+ */
551
+ export function shouldInvalidateDiscoveryHit(trivial: boolean): boolean {
552
+ return !trivial
553
+ }
554
+
300
555
  /**
301
556
  * Mismo criterio de "archivo de código" que el gate de read (index.ts:552),
302
557
  * sin la cláusula de `pattern` que es específica de glob. Docs/JSON quedan fuera:
@@ -483,10 +738,15 @@ function extractPathsFromPatch(patchText: string): string[] {
483
738
  // Objeto literal directo (ver nota en imports): el server V2 acepta
484
739
  // `{ id, setup }` sin pasar por `Plugin.define()`.
485
740
  export default {
486
- id: "ostacky-controller",
741
+ id: PLUGIN_ID,
487
742
  async setup(ctx) {
488
743
  const directory = ctx.location.directory
489
744
 
745
+ // Precedencia local: si el proyecto tiene su propia instalación, esta copia
746
+ // global no registra hooks ni tools (OpenCode ya la cargó porque el config
747
+ // global se descubre primero; ver nota de scope arriba).
748
+ if (IS_GLOBAL_COPY && hasLocalPluginInstall(directory)) return
749
+
490
750
  // Track trivial flag per session to gate tools
491
751
  const trivialBySession = new Map<string, boolean>()
492
752
  // Track discovery hit per requestId to avoid blocking after hit
@@ -550,6 +810,10 @@ export default {
550
810
  const currentState = state?.state ?? "DONE"
551
811
  const trivial = isTrivial(text, currentState)
552
812
  trivialBySession.set(sessionId, trivial)
813
+ // Horizonte por request: prompts NO-triviales invalidan el discovery hit —
814
+ // la siguiente lectura de código exige un explore fresco (contrato
815
+ // remove-discovery-cache-phantom + instrucción §1 del agente).
816
+ if (shouldInvalidateDiscoveryHit(trivial)) discoveryHitByRequest.delete(sessionId)
553
817
  if (trivial) {
554
818
  // Preserve system FULL cacheable, add suffix hint to user prompt text
555
819
  const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
@@ -558,26 +822,8 @@ export default {
558
822
  // harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
559
823
  if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
560
824
  try {
561
- let pending: string[] = Array.isArray(state.lastHandoff?.pendingTasks)
562
- ? state.lastHandoff.pendingTasks.filter((id: string) => !state.tasks?.[id] || state.tasks[id].status !== "COMPLETED")
563
- : []
564
- if (pending.length === 0) {
565
- try {
566
- const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
567
- if (existsSync(fallbackPath)) {
568
- const raw = readFileSync(fallbackPath, "utf-8")
569
- const data = JSON.parse(raw)
570
- if (data && Array.isArray(data.pendingTasks) && typeof data.ts === "number" && Date.now() - data.ts < 24 * 60 * 60 * 1000) {
571
- const fbPending = data.pendingTasks.filter((id: string) => !state.tasks?.[id] || state.tasks[id].status !== "COMPLETED")
572
- if (fbPending.length > 0) pending = fbPending
573
- }
574
- }
575
- } catch {}
576
- }
577
- if (pending.length > 0) {
578
- const hint = `\n\n[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
579
- event.prompt.text = (event.prompt.text ?? "") + hint
580
- }
825
+ const hint = recoveryHint(state, directory)
826
+ if (hint) event.prompt.text = (event.prompt.text ?? "") + hint
581
827
  } catch {}
582
828
  }
583
829
  })
@@ -606,7 +852,9 @@ export default {
606
852
  checkCount++
607
853
  const freshState = readState(directory)
608
854
  const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
609
- if (freshState && pendingStates.includes(freshState.state)) {
855
+ // multisession-ownership-guard: el PENDING de OTRA sesión no congela esta sesión.
856
+ const foreignPendingOwner = !!freshState && isForeignPendingOwner(freshState, sessionId)
857
+ if (freshState && pendingStates.includes(freshState.state) && !foreignPendingOwner) {
610
858
  // cache ALLOW per revision, BLOCKED never cached
611
859
  if (lastCheck && freshState.revision === lastCheck.revision && checkCount % 5 !== 0 && lastCheck.result === "ALLOW") {
612
860
  // reuse cached ALLOW
@@ -628,7 +876,7 @@ export default {
628
876
  }
629
877
  }
630
878
  } else if (freshState) {
631
- lastCheck = { revision: freshState.revision, result: "ALLOW" }
879
+ lastCheck = nextPendingGateCache(freshState.revision, foreignPendingOwner)
632
880
  }
633
881
 
634
882
  // ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
@@ -728,12 +976,22 @@ export default {
728
976
  const gated = isCodeReadGated({ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked })
729
977
  const codegraphOk = gated ? isCodegraphAvailable(directory) : false
730
978
  // 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.
979
+ // COMPLETADO en este request (discoveryHitByRequest; el hook "prompt"
980
+ // invalida el hit en cada prompt no-trivial). session.deleted también lo limpia.
733
981
  const hasDiscoveryHit = codegraphOk ? discoveryHitByRequest.get(sessionId) === true : false
734
982
  if (shouldBlockCodeRead({
735
983
  isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked, codegraphOk, hasDiscoveryHit,
736
984
  })) {
985
+ // Métrica de evasión real (misma convención que engramBypassCount:648):
986
+ // cuántas lecturas de código saltaron el gate sin explore completado.
987
+ try {
988
+ const st = readState(directory)
989
+ if (st) {
990
+ st.codeReadBlockedCount = (st.codeReadBlockedCount || 0) + 1
991
+ persistState(directory, st, ["codeReadBlockedCount"])
992
+ }
993
+ } catch {}
994
+ try { console.warn(`[ostacky] code_read_blocked: sin codegraph_explore completado en sesión (${targetPath || "query"})`) } catch {}
737
995
  throw new Error(
738
996
  `BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${targetPath || "query"}") antes de Read/Grep/Glob de código`
739
997
  )
@@ -952,9 +1210,10 @@ export default {
952
1210
  // Track discovery hit to allow subsequent Reads
953
1211
  const tool: string = event.tool as string
954
1212
  const sessionId: string = (event.sessionID ?? "default") as string
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
1213
+ // (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en ESTE
1214
+ // request abre el gate de lecturas de código (el hook "prompt" invalida el hit en cada
1215
+ // prompt no-trivial). Se marca aunque el resultado venga vacío, a propósito:
1216
+ // "CodeGraph no tiene este archivo en su índice" es justamente el caso donde el
958
1217
  // Read TIENE que poder pasar si hubo `codegraph_explore`.
959
1218
  if (marksDiscovery(tool)) {
960
1219
  try {
@@ -1026,11 +1285,7 @@ export default {
1026
1285
  name: "ostacky_get_state",
1027
1286
  description: "Get Ostacky controller state (plugin, no MCP needed)",
1028
1287
  input: emptySchema,
1029
- execute: async () => {
1030
- const state = readState(directory)
1031
- if (!state) return jsonContent({ error: "no state", state: "UNKNOWN", revision: 0 })
1032
- return jsonContent({ state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice })
1033
- },
1288
+ execute: async () => jsonContent(projectStateForTool(readState(directory))),
1034
1289
  })
1035
1290
  editor.add({
1036
1291
  name: "ostacky_get_audit",
@@ -1131,13 +1386,10 @@ export default {
1131
1386
  return JSON.stringify(event.messages.slice(0, 2)).slice(0, 800)
1132
1387
  } catch { return "" }
1133
1388
  })()
1134
- const payload = {
1135
- summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — plugin`,
1136
- nextSteps: [] as string[],
1137
- pendingTasks: [] as string[],
1138
- ts: Date.now(),
1139
- contextSnippet,
1140
- }
1389
+ // Los pendientes salen del state real, no de una lista vacía: sin esto
1390
+ // el hint de recovery nunca disparaba mid-run (checkpoint cada 3 tasks).
1391
+ const current = readState(directory)
1392
+ const payload = compactionPayload(current, directory, event.sessionID as string | undefined, contextSnippet)
1141
1393
  const tmp = `${fallbackPath}.tmp.${process.pid}`
1142
1394
  writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
1143
1395
  renameSync(tmp, fallbackPath)