ostacky 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,21 @@
1
1
  /**
2
- * Ostacky Controller — Plugin híbrido como alma
2
+ * Ostacky Controller — Plugin híbrido como alma (OpenCode V2)
3
3
  *
4
4
  * Fusiona assets/mcp/ostacky-controller/index.js + assets/plugins/ostacky-guard.ts
5
- * Mantiene máquina de 13 estados en-process y aplica hard gates en tool.execute.before.
5
+ * Mantiene máquina de 13 estados en-process y aplica hard gates en tool hook execute.before.
6
6
  * MCP queda thin solo para observabilidad (get_*).
7
7
  *
8
- * Single source security: importa desde src/security.ts (no copia regex).
8
+ * Single source security: mirrors generados desde src/security.ts via scripts/sync-controller-core.ts
9
9
  * Cache único: getDiscoverySnapshot es único entrypoint.
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
  */
13
13
 
14
- import type { Plugin } from "@opencode-ai/plugin"
14
+ import { Plugin } from "@opencode/plugin"
15
15
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"
16
- import { join, dirname, basename, resolve, relative } from "node:path"
17
- import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "../../src/security.ts"
18
- import { isTrivial } from "../../src/tiered.ts"
16
+ import { join, dirname, basename, resolve, relative, delimiter } from "node:path"
17
+ import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
18
+ import { isTrivial } from "./tiered.ts"
19
19
  import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
20
20
 
21
21
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -23,13 +23,27 @@ import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
23
23
  const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024
24
24
  const PING_INTERVAL_MS = 60_000
25
25
  const IDLE_THRESHOLD_MS = 45_000
26
- const PURPLE_TENUE = "\x1b[38;5;183m"
27
- const PURPLE_RESET = "\x1b[0m"
28
26
 
29
27
  // DEFAULT_STATE imported from controller-core.ts
30
28
 
31
29
  // ─── Helpers ─────────────────────────────────────────────────────────────────
32
30
 
31
+ function lookupOnPath(name: string): string | null {
32
+ try {
33
+ const pathEnv = process.env.PATH ?? ""
34
+ const suffix = process.platform === "win32" ? ".exe" : ""
35
+ for (const dir of pathEnv.split(delimiter)) {
36
+ if (!dir) continue
37
+ try {
38
+ const cand = join(dir, name + suffix)
39
+ if (existsSync(cand)) return cand
40
+ if (suffix && existsSync(join(dir, name))) return join(dir, name)
41
+ } catch {}
42
+ }
43
+ } catch {}
44
+ return null
45
+ }
46
+
33
47
  function getStatePath(directory: string): string {
34
48
  if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
35
49
  const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
@@ -38,6 +52,7 @@ function getStatePath(directory: string): string {
38
52
  const raw = readFileSync(cand, "utf-8")
39
53
  const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
40
54
  const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
55
+ ?? (json as any)?.mcp?.servers?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
41
56
  if (typeof envPath === "string" && envPath) return envPath
42
57
  } catch {}
43
58
  }
@@ -107,7 +122,7 @@ function getDiscoveryCacheHit(directory: string): boolean {
107
122
  try {
108
123
  const cacheDir = join(directory, ".opencode", "cache", "codegraph")
109
124
  if (!existsSync(cacheDir)) return false
110
- const files = readdirSync(cacheDir).filter(f => f.startsWith("discovery-"))
125
+ const files = readdirSync(cacheDir).filter((f: string) => f.startsWith("discovery-"))
111
126
  if (files.length === 0) return false
112
127
  // check if any file is recent (<1h) and valid
113
128
  const now = Date.now()
@@ -128,81 +143,101 @@ function isCodegraphAvailable(directory: string): boolean {
128
143
  const bin = join(directory, ".opencode", "tools", "codegraph", "bin", "codegraph")
129
144
  const binExe = bin + ".exe"
130
145
  if (existsSync(bin) || existsSync(binExe)) return true
131
- // also check global which
132
- const which = (Bun as any).which?.("codegraph")
133
- if (which) return true
146
+ // also check PATH
147
+ if (lookupOnPath("codegraph")) return true
134
148
  return false
135
149
  } catch { return false }
136
150
  }
137
151
 
138
- // Track trivial flag per session to gate tools
139
- const trivialBySession = new Map<string, boolean>()
140
- // Track discovery hit per requestId to avoid blocking after hit
141
- const discoveryHitByRequest = new Map<string, boolean>()
142
- // ─── Heartbeat ping — evita sensación de trancado en tareas largas ──
143
- let lastPingTs = 0
144
- let pingInterval: ReturnType<typeof setInterval> | null = null
145
-
146
- // ─── Plugin ──────────────────────────────────────────────────────────────────
152
+ function jsonContent(value: unknown): { content: string } {
153
+ try {
154
+ return { content: JSON.stringify(value) ?? "null" }
155
+ } catch {
156
+ return { content: "null" }
157
+ }
158
+ }
147
159
 
148
- export const OstackyController: Plugin = async (ctx) => {
149
- const patterns = (() => {
150
- const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
151
- if (!raw) return SENSITIVE_DEFAULT
152
- return raw.split(",").map(s => s.trim()).filter(Boolean)
153
- })()
160
+ /**
161
+ * Extrae paths de un patchText V2 (`*** Add File: <path>`, Update/Delete/Move).
162
+ * Move trae `old --> new`: se revisan ambos lados.
163
+ */
164
+ function extractPathsFromPatch(patchText: string): string[] {
165
+ if (!patchText) return []
166
+ const paths: string[] = []
167
+ const re = /^\*\*\* (?:Add File|Update File|Delete File|Move File):\s*(.+)$/gm
168
+ let m: RegExpExecArray | null
169
+ while ((m = re.exec(patchText)) !== null) {
170
+ const rest = (m[1] ?? "").trim()
171
+ for (const part of rest.split(/\s+-->\s+|\s+->\s+/)) {
172
+ const p = part.trim().replace(/^["']|["']$/g, "")
173
+ if (p) paths.push(p)
174
+ }
175
+ }
176
+ return [...new Set(paths)]
177
+ }
154
178
 
155
- let lastCheck: { revision: number; result: string } | null = null
156
- let checkCount = 0
179
+ // ─── Plugin ──────────────────────────────────────────────────────────────────
157
180
 
158
- // Heartbeat ping — purple tenue, evita sensación de trancado en tareas largas
159
- if (!pingInterval) {
160
- pingInterval = setInterval(async () => {
161
- try {
162
- const s = readState(ctx.directory)
163
- if (!s || !["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state)) return
164
- const now = Date.now()
165
- const lastHeartbeat = s.lastHeartbeat || s.ts || now
166
- const idle = now - lastHeartbeat
167
- if (idle < IDLE_THRESHOLD_MS) return
168
- if (now - lastPingTs < PING_INTERVAL_MS) return
169
- lastPingTs = now
170
- const completed = Object.values(s.tasks || {}).filter((t: any) => t.status === "COMPLETED").length
171
- const total = s.expectedTaskCount ?? s.expectedTasks?.length ?? "?"
172
- const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
173
- const msg = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ⏳ Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
174
- try { await (ctx as any).client?.tui?.showToast?.({ body: { message: msg, variant: "info" } } as any) } catch {}
175
- try { await (ctx as any).client?.app?.log?.({ body: { service: "ostacky-ping", level: "info", message: msg } } as any) } catch {}
176
- try { const st = readState(ctx.directory); if (st) { (st as any).lastPingTs = now; persistState(ctx.directory, st) } } catch {}
177
- } catch {}
178
- }, 30_000)
179
- if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
180
- }
181
+ export default Plugin.define({
182
+ id: "ostacky-controller",
183
+ async setup(ctx) {
184
+ const directory = ctx.location.directory
185
+
186
+ // Track trivial flag per session to gate tools
187
+ const trivialBySession = new Map<string, boolean>()
188
+ // Track discovery hit per requestId to avoid blocking after hit
189
+ const discoveryHitByRequest = new Map<string, boolean>()
190
+ // Track Engram hit per session to audit bypass (soft, no BLOCK)
191
+ const engramHitBySession = new Map<string, boolean>()
192
+ // ─── Heartbeat ping — evita sensación de trancado en tareas largas ──
193
+ let lastPingTs = 0
194
+ let pingInterval: ReturnType<typeof setInterval> | null = null
195
+
196
+ const patterns = (() => {
197
+ const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
198
+ if (!raw) return SENSITIVE_DEFAULT
199
+ return raw.split(",").map((s: string) => s.trim()).filter(Boolean)
200
+ })()
201
+
202
+ let lastCheck: { revision: number; result: string } | null = null
203
+ let checkCount = 0
204
+
205
+ // Heartbeat ping — evita sensación de trancado en tareas largas
206
+ // (V2: sin canal TUI en ctx; se loguea al server log y se persiste lastPingTs)
207
+ if (!pingInterval) {
208
+ pingInterval = setInterval(() => {
209
+ try {
210
+ const s = readState(directory)
211
+ if (!s || !["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state)) return
212
+ const now = Date.now()
213
+ const lastHeartbeat = s.lastHeartbeat || s.ts || now
214
+ const idle = now - lastHeartbeat
215
+ if (idle < IDLE_THRESHOLD_MS) return
216
+ if (now - lastPingTs < PING_INTERVAL_MS) return
217
+ lastPingTs = now
218
+ const completed = Object.values(s.tasks || {}).filter((t: any) => t.status === "COMPLETED").length
219
+ const total = s.expectedTaskCount ?? s.expectedTasks?.length ?? "?"
220
+ const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
221
+ const msg = `[ostacky] Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
222
+ try { console.log(msg) } catch {}
223
+ try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st) } } catch {}
224
+ } catch {}
225
+ }, 30_000)
226
+ if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
227
+ }
181
228
 
182
- return {
183
229
  // ── Tiered: suffix hint on user message (cache-friendly, no system replace) ──
184
- "chat.message": async (input: any, output: any) => {
185
- const sessionId: string = input.sessionID ?? "default"
186
- const parts: any[] = output.parts || []
187
- const text = parts.filter((p: any) => p.type === "text").map((p: any) => p.text ?? "").join("\n").trim()
188
- || output.message?.summary?.title || ""
189
- const state = readState(ctx.directory)
230
+ await ctx.session.hook("prompt", (event) => {
231
+ const sessionId: string = event.sessionID ?? "default"
232
+ const text = (event.prompt.text ?? "").trim()
233
+ const state = readState(directory)
190
234
  const currentState = state?.state ?? "DONE"
191
235
  const trivial = isTrivial(text, currentState)
192
236
  trivialBySession.set(sessionId, trivial)
193
237
  if (trivial) {
194
- // Preserve system[0] FULL cacheable, add suffix hint to user message
238
+ // Preserve system FULL cacheable, add suffix hint to user prompt text
195
239
  const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
196
- if (output.parts && output.parts.length > 0) {
197
- const last = output.parts[output.parts.length - 1]
198
- if (last.type === "text") last.text = (last.text ?? "") + hint
199
- else output.parts.push({ type: "text", text: hint })
200
- } else if (output.message) {
201
- output.parts = [{ type: "text", text: text + hint }]
202
- }
203
- } else {
204
- // Also handle TIER1 hint for small tasks without replacing system
205
- // Intent detection for downgradeable 0/0+1 is done in record_discovery router, not here
240
+ event.prompt.text = (event.prompt.text ?? "") + hint
206
241
  }
207
242
  // harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
208
243
  if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
@@ -212,7 +247,7 @@ export const OstackyController: Plugin = async (ctx) => {
212
247
  : []
213
248
  if (pending.length === 0) {
214
249
  try {
215
- const fallbackPath = join(dirname(getStatePath(ctx.directory)), ".ostacky-handoff-compaction.json")
250
+ const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
216
251
  if (existsSync(fallbackPath)) {
217
252
  const raw = readFileSync(fallbackPath, "utf-8")
218
253
  const data = JSON.parse(raw)
@@ -225,27 +260,21 @@ export const OstackyController: Plugin = async (ctx) => {
225
260
  }
226
261
  if (pending.length > 0) {
227
262
  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]`
228
- if (output.parts && output.parts.length > 0) {
229
- const last = output.parts[output.parts.length - 1]
230
- if (last.type === "text") last.text = (last.text ?? "") + hint
231
- else output.parts.push({ type: "text", text: hint })
232
- } else {
233
- output.parts = [{ type: "text", text: text + hint }]
234
- }
263
+ event.prompt.text = (event.prompt.text ?? "") + hint
235
264
  }
236
265
  } catch {}
237
266
  }
238
- },
267
+ })
239
268
 
240
269
  // ── Hard gates before any tool ──
241
- "tool.execute.before": async (input: any, _output: any) => {
242
- const tool: string = (input as any).tool as string
243
- const args: any = (input as any).args as any
244
- const sessionId: string = (input as any).sessionID ?? "default"
270
+ await ctx.tool.hook("execute.before", (event) => {
271
+ const tool: string = event.tool as string
272
+ const args: any = (event.input ?? {}) as any
273
+ const sessionId: string = (event.sessionID ?? "default") as string
245
274
 
246
275
  // ── 0) Trivial greeting blocks for expensive tools (SKIP, not BLOCKED) ──
247
276
  const isTrivialSession = trivialBySession.get(sessionId) ?? false
248
- const stateForTrivial = readState(ctx.directory)
277
+ const stateForTrivial = readState(directory)
249
278
  if (isTrivialSession && stateForTrivial?.state === "DONE") {
250
279
  const blockedForTrivial = [
251
280
  "engram_mem_context", "mem_context",
@@ -259,7 +288,7 @@ export const OstackyController: Plugin = async (ctx) => {
259
288
 
260
289
  // ── 1) PENDING hard gate (0 tokens) ──
261
290
  checkCount++
262
- const freshState = readState(ctx.directory)
291
+ const freshState = readState(directory)
263
292
  const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
264
293
  if (freshState && pendingStates.includes(freshState.state)) {
265
294
  // cache ALLOW per revision, BLOCKED never cached
@@ -279,19 +308,39 @@ export const OstackyController: Plugin = async (ctx) => {
279
308
  lastCheck = { revision: freshState.revision, result: "ALLOW" }
280
309
  }
281
310
 
311
+ // ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
312
+ // Si propone/cambia nivel sin haber consultado Engram, cuenta bypass (exime trivial+DONE)
313
+ const isEngramRelevantTool = tool.includes("record_discovery") || (tool.includes("openspec") && (tool.includes("propose") || (typeof args?.filePath === "string" && args.filePath.includes("openspec/changes")) || (typeof args?.path === "string" && args.path.includes("openspec/changes"))))
314
+ if (isEngramRelevantTool) {
315
+ const hasEngramHit = engramHitBySession.get(sessionId) ?? false
316
+ const isTrivialForEngram = trivialBySession.get(sessionId) ?? false
317
+ const stateForEngram = freshState
318
+ const shouldAudit = !hasEngramHit && !isTrivialForEngram && stateForEngram && ["DISCOVERY", "ROUTE_DECISION_PENDING", "SPECIFICATION", "INTERPRETATION_PENDING"].includes(stateForEngram.state)
319
+ if (shouldAudit) {
320
+ try {
321
+ const st = readState(directory)
322
+ if (st) {
323
+ st.engramBypassCount = (st.engramBypassCount || 0) + 1
324
+ // no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
325
+ persistState(directory, st)
326
+ }
327
+ } catch {}
328
+ }
329
+ }
330
+
282
331
  // ── 1.5) Router determinista: openspec-propose bloquea si 1+ no-downgradeable sin Alternatives ──
283
332
  const isOpenspecPropose = tool.includes("openspec") && (tool.includes("propose") || args?.filePath?.includes("openspec/changes") || args?.path?.includes("openspec/changes"))
284
333
  if (isOpenspecPropose) {
285
- const s = readState(ctx.directory)
334
+ const s = readState(directory)
286
335
  if (s?.level === "1+" && s?._routerNeedsAlternatives) {
287
336
  // check if design.md has Alternatives
288
337
  try {
289
338
  const changeId = args?.changeId || s?.changeId || ""
290
339
  let designPath: string | null = null
291
- if (changeId) designPath = join(ctx.directory, "openspec", "changes", changeId, "design.md")
340
+ if (changeId) designPath = join(directory, "openspec", "changes", changeId, "design.md")
292
341
  else {
293
342
  // try to find any change dir with _routerNeedsAlternatives
294
- const changesDir = join(ctx.directory, "openspec", "changes")
343
+ const changesDir = join(directory, "openspec", "changes")
295
344
  if (existsSync(changesDir)) {
296
345
  for (const entry of readdirSync(changesDir)) {
297
346
  const p = join(changesDir, entry, "design.md")
@@ -328,8 +377,8 @@ export const OstackyController: Plugin = async (ctx) => {
328
377
  // Grep on *.md should not be blocked
329
378
  const isLiteralGrep = tool === "grep" && (args?.include?.endsWith(".md") || args?.include?.endsWith(".json"))
330
379
  if (isCodeFile && !isLiteralGrep && !isSpecTasksPath) {
331
- const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(ctx.directory)
332
- const codegraphOk = isCodegraphAvailable(ctx.directory)
380
+ const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(directory)
381
+ const codegraphOk = isCodegraphAvailable(directory)
333
382
  if (codegraphOk && !hasDiscoveryHit) {
334
383
  // Allow if file is not indexable or trivial?
335
384
  // Block with suggestion
@@ -339,7 +388,7 @@ export const OstackyController: Plugin = async (ctx) => {
339
388
  }
340
389
 
341
390
  // ── 3) Sensitive gate (hard, even in degraded) ──
342
- if (tool === "bash") {
391
+ if (tool === "shell") {
343
392
  const cmd: string = args?.command || args?.cmd || ""
344
393
  if (typeof cmd === "string" && cmd) {
345
394
  const normalized = cmd.replace(/["'`]/g, "").replace(/\\/g, "")
@@ -349,27 +398,27 @@ export const OstackyController: Plugin = async (ctx) => {
349
398
  if (hasSensitivePattern || sensitivePaths.length > 0) {
350
399
  if (sensitivePaths.length > 0) {
351
400
  for (const p of sensitivePaths) {
352
- const s = readState(ctx.directory)
353
- const candidates = [p, resolve(ctx.directory, p), join(ctx.directory, p)]
401
+ const s = readState(directory)
402
+ const candidates = [p, resolve(directory, p), join(directory, p)]
354
403
  const allowed = candidates.some((c) => s?.allowedFiles?.[c] || s?.allowedFiles?.[p])
355
- const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(ctx.directory, p)]
404
+ const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(directory, p)]
356
405
  if (!allowed && !baseAllowed) {
357
406
  const denied = s?.deniedFiles?.[p] || s?.deniedFiles?.[basename(p)]
358
- if (denied) throw new Error(`BLOCKED: bash contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
359
- throw new Error(`BLOCKED: bash contiene acceso sensible (${p}). Llamá check_file_access con reason antes.`)
407
+ if (denied) throw new Error(`BLOCKED: shell contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
408
+ throw new Error(`BLOCKED: shell contiene acceso sensible (${p}). Llamá check_file_access con reason antes.`)
360
409
  }
361
410
  }
362
411
  } else if (hasSensitivePattern) {
363
- const s = readState(ctx.directory)
412
+ const s = readState(directory)
364
413
  const hasAllowed = Object.keys(s?.allowedFiles || {}).some((k) => isSensitive(k, patterns))
365
- if (!hasAllowed) throw new Error(`BLOCKED: bash contiene acceso sensible (.env). Llamá check_file_access con reason antes.`)
414
+ if (!hasAllowed) throw new Error(`BLOCKED: shell contiene acceso sensible (.env). Llamá check_file_access con reason antes.`)
366
415
  }
367
416
  }
368
417
  }
369
418
  }
370
419
 
371
- // ── 3.5) Bash file-mutation gate (harden-task-integrity: bash mutante requiere validate_edit) ──
372
- if (tool === "bash" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
420
+ // ── 3.5) Shell file-mutation gate (harden-task-integrity: shell mutante requiere validate_edit) ──
421
+ if (tool === "shell" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
373
422
  const cmdForMutation: string = args?.command || args?.cmd || ""
374
423
  const isMutating = /[>]{1,2}\s*\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdForMutation)
375
424
  if (isMutating) {
@@ -378,24 +427,24 @@ export const OstackyController: Plugin = async (ctx) => {
378
427
  if (!p) continue
379
428
  const isProjectCode = p.includes("src/") || p.includes("assets/") || /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p)
380
429
  if (!isProjectCode) continue
381
- if (!isPathInsideProject(p, ctx.directory)) continue
382
- const abs = resolve(ctx.directory, p)
430
+ if (!isPathInsideProject(p, directory)) continue
431
+ const abs = resolve(directory, p)
383
432
  const exists = existsSync(abs)
384
433
  const isNewFile = !exists && !freshState.fileFingerprints?.[p] && !Object.values(freshState.tasks || {}).some((t: any) => t.filePath === p)
385
434
  if (isNewFile) continue
386
435
  const lv = freshState.lastValidated
387
436
  if (!lv || lv.filePath !== p) {
388
- throw new Error(`BLOCKED: file mutation requires validate_edit/expectedTask for ${p} — usa validate_edit antes de bash mutante`)
437
+ throw new Error(`BLOCKED: file mutation requires validate_edit/expectedTask for ${p} — usa validate_edit antes de shell mutante`)
389
438
  }
390
439
  }
391
440
  }
392
441
  }
393
442
 
394
443
  let filePath: string | undefined
395
- if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "write" || tool === "edit") {
444
+ if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "edit" || tool === "write") {
396
445
  filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
397
446
  if (tool === "grep" && args?.include) filePath = args.include
398
- if (tool === "write" || tool === "edit") filePath = args?.filePath || args?.path || ""
447
+ if (tool === "edit" || tool === "write") filePath = args?.filePath || args?.path || ""
399
448
  if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
400
449
  try {
401
450
  const u = args.uri as string
@@ -405,17 +454,30 @@ export const OstackyController: Plugin = async (ctx) => {
405
454
  }
406
455
  }
407
456
  if (filePath && isSensitive(filePath, patterns)) {
408
- const s = readState(ctx.directory)
409
- const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(ctx.directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
457
+ const s = readState(directory)
458
+ const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
410
459
  if (!allowed) {
411
460
  const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
412
461
  if (denied) throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
413
462
  throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
414
463
  }
415
464
  }
465
+ // patch usa patchText (no filePath): se chequean los paths mencionados
466
+ if (tool === "patch" && typeof args?.patchText === "string" && args.patchText) {
467
+ for (const patchPath of extractPathsFromPatch(args.patchText)) {
468
+ if (!patchPath || !isSensitive(patchPath, patterns)) continue
469
+ const s = readState(directory)
470
+ const allowed = s?.allowedFiles?.[patchPath] || s?.allowedFiles?.[resolve(directory, patchPath)] || s?.allowedFiles?.[basename(patchPath)]
471
+ if (!allowed) {
472
+ const denied = s?.deniedFiles?.[patchPath] || s?.deniedFiles?.[basename(patchPath)]
473
+ if (denied) throw new Error(`BLOCKED: File ${patchPath} requires check_file_access (previously denied)`)
474
+ throw new Error(`BLOCKED: File ${patchPath} requires check_file_access`)
475
+ }
476
+ }
477
+ }
416
478
 
417
- // ── 4) validate_edit in-process for write/edit ──
418
- if (tool === "write" || tool === "edit") {
479
+ // ── 4) validate_edit in-process for edit/write (V2 keeps both tools; patch usa patchText y queda fuera — follow-up) ──
480
+ if (tool === "edit" || tool === "write") {
419
481
  const oldString: string = args?.oldString ?? ""
420
482
  const newString: string = args?.newString ?? args?.content ?? ""
421
483
  const targetPath: string = args?.filePath || args?.path || ""
@@ -423,31 +485,30 @@ export const OstackyController: Plugin = async (ctx) => {
423
485
  if (oldString === newString) {
424
486
  throw new Error(`CONFLICT: oldString === newString`)
425
487
  }
426
- if (!isPathInsideProject(targetPath, ctx.directory)) {
488
+ if (!isPathInsideProject(targetPath, directory)) {
427
489
  throw new Error(`BLOCKED: path outside project: ${targetPath}`)
428
490
  }
429
491
  // If oldString is hash:<fp> handle stale check
430
492
  if (oldString.startsWith("hash:")) {
431
493
  const claimed = oldString.slice(5)
432
- const currentFp = fastFingerprint(resolve(ctx.directory, targetPath))
433
- const s = readState(ctx.directory)
494
+ const currentFp = fastFingerprint(resolve(directory, targetPath))
495
+ const s = readState(directory)
434
496
  const last = s?.lastValidated
435
497
  if (claimed !== currentFp || (last && last.filePath === targetPath && last.hash !== currentFp)) {
436
498
  // mark stale attempt
437
499
  try {
438
- const st = readState(ctx.directory) || { ...DEFAULT_STATE }
500
+ const st = readState(directory) || { ...DEFAULT_STATE }
439
501
  st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
440
- persistState(ctx.directory, st)
502
+ persistState(directory, st)
441
503
  } catch {}
442
504
  throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
443
505
  }
444
506
  } else if (oldString.length > 0) {
445
507
  // Check fresh content has exactly one occurrence
446
508
  try {
447
- const fullPath = resolve(ctx.directory, targetPath)
509
+ const fullPath = resolve(directory, targetPath)
448
510
  if (existsSync(fullPath)) {
449
511
  const content = readFileSync(fullPath, "utf-8")
450
- const escaped = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
451
512
  // count occurrences literally, not regex
452
513
  let count = 0
453
514
  let idx = 0
@@ -466,173 +527,188 @@ export const OstackyController: Plugin = async (ctx) => {
466
527
  }
467
528
  // Record lastValidated for future hash checks
468
529
  try {
469
- const st = readState(ctx.directory)
530
+ const st = readState(directory)
470
531
  if (st) {
471
- const fp = fastFingerprint(resolve(ctx.directory, targetPath))
532
+ const fp = fastFingerprint(resolve(directory, targetPath))
472
533
  st.lastValidated = { filePath: targetPath, hash: fp, ts: Date.now() }
473
534
  }
474
535
  } catch {}
475
536
  }
476
537
  }
477
- },
538
+ })
478
539
 
479
- "tool.execute.after": async (input: any, output: any) => {
540
+ await ctx.tool.hook("execute.after", (event) => {
480
541
  // Track discovery hit to allow subsequent Reads
481
- const tool: string = (input as any).tool as string
482
- const sessionId: string = (input as any).sessionID ?? "default"
542
+ const tool: string = event.tool as string
543
+ const sessionId: string = (event.sessionID ?? "default") as string
544
+ const resultText = event.status === "completed" && event.result ? JSON.stringify(event.result) : ""
483
545
  if (tool.includes("getDiscoverySnapshot") || tool.includes("get_discovery_snapshot")) {
484
546
  // if output indicates hit, mark it
485
547
  try {
486
- const text = typeof output === "string" ? output : JSON.stringify(output)
548
+ const text = resultText
487
549
  if (text && !text.includes("null") && text.length > 10) {
488
550
  discoveryHitByRequest.set(sessionId, true)
489
551
  }
552
+ // Engram hit via discovery snapshot (contiene engramHits)
553
+ if (text && text.includes("engramHits")) {
554
+ engramHitBySession.set(sessionId, true)
555
+ }
490
556
  } catch {}
491
557
  }
492
- // Update state metrics for cache hit (best-effort)
493
- if (tool.includes("getDiscoverySnapshot") && output) {
494
- try {
495
- const s = readState((input as any).ctx?.directory ?? "")
496
- } catch {}
558
+ // Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
559
+ 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_")) {
560
+ engramHitBySession.set(sessionId, true)
497
561
  }
498
- // Heartbeat + color purple tenue: Ostacky vs modelo (fácil) — single-writer: plugin memo, no persist cada tool (D10)
499
- let lastHeartbeatMem = 0
562
+ // Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
500
563
  try {
501
- const dir = ctx.directory
502
- const s = readState(dir)
564
+ const s = readState(directory)
503
565
  if (s) {
504
566
  const now = Date.now()
505
- lastHeartbeatMem = now
506
567
  // Single-writer: solo persiste si OSTACKY_PLUGIN_PERSIST=1 o idle>30s o state cambió
507
568
  const shouldPersist = process.env.OSTACKY_PLUGIN_PERSIST === "1" || (now - (s.lastHeartbeat || 0) > 30000)
508
569
  if (shouldPersist) {
509
570
  s.lastHeartbeat = now
510
- try { persistState(dir, s) } catch {}
511
- }
512
- if (["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state) && output && typeof output.title === "string" && output.title && !output.title.includes("🟣")) {
513
- output.title = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ${output.title}`
571
+ try { persistState(directory, s) } catch {}
514
572
  }
515
573
  }
516
574
  } catch {}
517
- },
575
+ })
576
+
577
+ const emptySchema = {
578
+ type: "object",
579
+ properties: {},
580
+ required: [],
581
+ additionalProperties: false,
582
+ } as const
518
583
 
519
584
  // ── Observable tools (MCP thin replacement) ──
520
- tool: {
521
- ostacky_get_state: {
585
+ await ctx.tool.transform((editor) => {
586
+ editor.add({
587
+ name: "ostacky_get_state",
522
588
  description: "Get Ostacky controller state (plugin, no MCP needed)",
523
- parameters: {} as any,
524
- execute: async (_args: any, ctx2: any) => {
525
- const dir = ctx2?.directory ?? ctx.directory
526
- const state = readState(dir)
527
- if (!state) return { error: "no state", state: "UNKNOWN", revision: 0 }
528
- return { state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice }
589
+ input: emptySchema,
590
+ execute: async () => {
591
+ const state = readState(directory)
592
+ if (!state) return jsonContent({ error: "no state", state: "UNKNOWN", revision: 0 })
593
+ return jsonContent({ state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice })
529
594
  },
530
- },
531
- ostacky_get_audit: {
595
+ })
596
+ editor.add({
597
+ name: "ostacky_get_audit",
532
598
  description: "Get audit trail (plugin)",
533
- parameters: {} as any,
534
- execute: async (_args: any, ctx2: any) => {
535
- const dir = ctx2?.directory ?? ctx.directory
536
- const state = readState(dir)
537
- return { audit: state?.audit ?? [], revision: state?.revision ?? 0 }
599
+ input: emptySchema,
600
+ execute: async () => {
601
+ const state = readState(directory)
602
+ return jsonContent({ audit: state?.audit ?? [], revision: state?.revision ?? 0 })
538
603
  },
539
- },
540
- ostacky_get_metrics: {
604
+ })
605
+ editor.add({
606
+ name: "ostacky_get_metrics",
541
607
  description: "Get controller metrics (plugin)",
542
- parameters: {} as any,
543
- execute: async (_args: any, ctx2: any) => {
544
- const dir = ctx2?.directory ?? ctx.directory
545
- const state = readState(dir)
546
- return {
608
+ input: emptySchema,
609
+ execute: async () => {
610
+ const state = readState(directory)
611
+ return jsonContent({
547
612
  cacheHitCount: state?.cacheHitCount ?? 0,
548
613
  discoveryCacheHitCount: state?.discoveryCacheHitCount ?? 0,
549
614
  tokenSavingEstimate: state?.tokenSavingEstimate ?? 0,
550
615
  stateCheckCount: state?.stateCheckCount ?? 0,
551
616
  codegraphBypassCount: state?.codegraphBypassCount ?? 0,
617
+ engramBypassCount: state?.engramBypassCount ?? 0,
552
618
  revision: state?.revision ?? 0,
553
- }
619
+ })
554
620
  },
555
- },
556
- ostacky_get_handoff: {
621
+ })
622
+ editor.add({
623
+ name: "ostacky_get_handoff",
557
624
  description: "Get last handoff (plugin)",
558
- parameters: {} as any,
559
- execute: async (_args: any, ctx2: any) => {
560
- const dir = ctx2?.directory ?? ctx.directory
561
- const state = readState(dir)
625
+ input: emptySchema,
626
+ execute: async () => {
627
+ const state = readState(directory)
562
628
  // also check compaction fallback
563
629
  try {
564
- const fallback = join(dirname(getStatePath(dir)), ".ostacky-handoff-compaction.json")
630
+ const fallback = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
565
631
  if (existsSync(fallback)) {
566
632
  const raw = readFileSync(fallback, "utf-8")
567
633
  const data = JSON.parse(raw)
568
- if (data && !state?.lastHandoff) return data
634
+ if (data && !state?.lastHandoff) return jsonContent(data)
569
635
  }
570
636
  } catch {}
571
- return state?.lastHandoff ?? null
637
+ return jsonContent(state?.lastHandoff ?? null)
572
638
  },
573
- },
574
- ostacky_get_available_transitions: {
639
+ })
640
+ editor.add({
641
+ name: "ostacky_get_available_transitions",
575
642
  description: "Get available transitions from current state",
576
- parameters: {} as any,
577
- execute: async (_args: any, ctx2: any) => {
578
- const dir = ctx2?.directory ?? ctx.directory
579
- const state = readState(dir)
643
+ input: emptySchema,
644
+ execute: async () => {
645
+ const state = readState(directory)
580
646
  const cur = state?.state ?? "INTERPRETATION_PENDING"
581
647
  const trans = TRANSITIONS[cur] ?? []
582
- return { currentState: cur, transitions: trans }
648
+ return jsonContent({ currentState: cur, transitions: trans })
583
649
  },
584
- },
650
+ })
585
651
  // Compatibility for old MCP calls that expect deprecated response
586
- ostacky_check_pending_state: {
652
+ editor.add({
653
+ name: "ostacky_check_pending_state",
587
654
  description: "[deprecated] plugin enforces — use tool before hook",
588
- parameters: {} as any,
589
- execute: async (_args: any) => {
590
- return { deprecated: true, hint: "plugin enforces", allowed: true }
655
+ input: emptySchema,
656
+ execute: async () => {
657
+ return jsonContent({ deprecated: true, hint: "plugin enforces", allowed: true })
591
658
  },
592
- },
593
- ostacky_validate_edit: {
659
+ })
660
+ editor.add({
661
+ name: "ostacky_validate_edit",
594
662
  description: "[deprecated] plugin enforces validate_edit in-process",
595
- parameters: {} as any,
596
- execute: async (_args: any) => {
597
- return { deprecated: true, hint: "plugin enforces" }
663
+ input: emptySchema,
664
+ execute: async () => {
665
+ return jsonContent({ deprecated: true, hint: "plugin enforces" })
598
666
  },
599
- },
600
- },
601
-
602
- event: async ({ event }: any) => {
603
- if (event.type === "session.deleted") {
604
- const sid = (event.properties as any)?.info?.id
605
- if (sid) {
606
- trivialBySession.delete(sid)
607
- discoveryHitByRequest.delete(sid)
667
+ })
668
+ })
669
+
670
+ const eventController = new AbortController()
671
+ void (async () => {
672
+ for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
673
+ if (event.type === "session.deleted") {
674
+ const sid = event.data.sessionID as string
675
+ if (sid) {
676
+ trivialBySession.delete(sid)
677
+ discoveryHitByRequest.delete(sid)
678
+ engramHitBySession.delete(sid)
679
+ }
608
680
  }
609
681
  }
610
- },
611
-
612
- dispose: async () => {
613
- try { if (pingInterval) clearInterval(pingInterval) } catch {}
614
- pingInterval = null
615
- },
682
+ })()
616
683
 
617
- "experimental.session.compacting": async (input: any, output: any) => {
684
+ await ctx.session.hook("compaction", (event) => {
618
685
  try {
619
- const statePath = getStatePath(ctx.directory)
686
+ const statePath = getStatePath(directory)
620
687
  const dir = dirname(statePath)
621
688
  try { mkdirSync(dir, { recursive: true }) } catch {}
622
689
  const fallbackPath = join(dir, ".ostacky-handoff-compaction.json")
690
+ const contextSnippet = (() => {
691
+ try {
692
+ return JSON.stringify(event.messages.slice(0, 2)).slice(0, 800)
693
+ } catch { return "" }
694
+ })()
623
695
  const payload = {
624
- summary: `Compaction fallback for session ${input.sessionID ?? "unknown"} — plugin`,
696
+ summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — plugin`,
625
697
  nextSteps: [] as string[],
626
698
  pendingTasks: [] as string[],
627
699
  ts: Date.now(),
628
- contextSnippet: output.context?.slice(0, 2).join("\n\n").slice(0, 800) ?? "",
700
+ contextSnippet,
629
701
  }
630
702
  const tmp = `${fallbackPath}.tmp.${process.pid}`
631
703
  writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
632
704
  renameSync(tmp, fallbackPath)
633
705
  } catch {}
634
- },
635
- }
636
- }
706
+ })
637
707
 
638
- export default OstackyController
708
+ return () => {
709
+ try { if (pingInterval) clearInterval(pingInterval) } catch {}
710
+ pingInterval = null
711
+ eventController.abort()
712
+ }
713
+ },
714
+ })