ostacky 0.8.8 → 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,8 +1,8 @@
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
8
  * Single source security: mirrors generados desde src/security.ts via scripts/sync-controller-core.ts
@@ -11,9 +11,9 @@
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"
16
+ import { join, dirname, basename, resolve, relative, delimiter } from "node:path"
17
17
  import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
18
18
  import { isTrivial } from "./tiered.ts"
19
19
  import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
@@ -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,83 +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
- // Track Engram hit per session to audit bypass (soft, no BLOCK)
143
- const engramHitBySession = new Map<string, boolean>()
144
- // ─── Heartbeat ping — evita sensación de trancado en tareas largas ──
145
- let lastPingTs = 0
146
- let pingInterval: ReturnType<typeof setInterval> | null = null
147
-
148
- // ─── 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
+ }
149
159
 
150
- export const OstackyController: Plugin = async (ctx) => {
151
- const patterns = (() => {
152
- const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
153
- if (!raw) return SENSITIVE_DEFAULT
154
- return raw.split(",").map(s => s.trim()).filter(Boolean)
155
- })()
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
+ }
156
178
 
157
- let lastCheck: { revision: number; result: string } | null = null
158
- let checkCount = 0
179
+ // ─── Plugin ──────────────────────────────────────────────────────────────────
159
180
 
160
- // Heartbeat ping — purple tenue, evita sensación de trancado en tareas largas
161
- if (!pingInterval) {
162
- pingInterval = setInterval(async () => {
163
- try {
164
- const s = readState(ctx.directory)
165
- if (!s || !["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state)) return
166
- const now = Date.now()
167
- const lastHeartbeat = s.lastHeartbeat || s.ts || now
168
- const idle = now - lastHeartbeat
169
- if (idle < IDLE_THRESHOLD_MS) return
170
- if (now - lastPingTs < PING_INTERVAL_MS) return
171
- lastPingTs = now
172
- const completed = Object.values(s.tasks || {}).filter((t: any) => t.status === "COMPLETED").length
173
- const total = s.expectedTaskCount ?? s.expectedTasks?.length ?? "?"
174
- const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
175
- const msg = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ⏳ Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
176
- try { await (ctx as any).client?.tui?.showToast?.({ body: { message: msg, variant: "info" } } as any) } catch {}
177
- try { await (ctx as any).client?.app?.log?.({ body: { service: "ostacky-ping", level: "info", message: msg } } as any) } catch {}
178
- try { const st = readState(ctx.directory); if (st) { (st as any).lastPingTs = now; persistState(ctx.directory, st) } } catch {}
179
- } catch {}
180
- }, 30_000)
181
- if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
182
- }
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
+ }
183
228
 
184
- return {
185
229
  // ── Tiered: suffix hint on user message (cache-friendly, no system replace) ──
186
- "chat.message": async (input: any, output: any) => {
187
- const sessionId: string = input.sessionID ?? "default"
188
- const parts: any[] = output.parts || []
189
- const text = parts.filter((p: any) => p.type === "text").map((p: any) => p.text ?? "").join("\n").trim()
190
- || output.message?.summary?.title || ""
191
- 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)
192
234
  const currentState = state?.state ?? "DONE"
193
235
  const trivial = isTrivial(text, currentState)
194
236
  trivialBySession.set(sessionId, trivial)
195
237
  if (trivial) {
196
- // Preserve system[0] FULL cacheable, add suffix hint to user message
238
+ // Preserve system FULL cacheable, add suffix hint to user prompt text
197
239
  const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
198
- if (output.parts && output.parts.length > 0) {
199
- const last = output.parts[output.parts.length - 1]
200
- if (last.type === "text") last.text = (last.text ?? "") + hint
201
- else output.parts.push({ type: "text", text: hint })
202
- } else if (output.message) {
203
- output.parts = [{ type: "text", text: text + hint }]
204
- }
205
- } else {
206
- // Also handle TIER1 hint for small tasks without replacing system
207
- // Intent detection for downgradeable 0/0+1 is done in record_discovery router, not here
240
+ event.prompt.text = (event.prompt.text ?? "") + hint
208
241
  }
209
242
  // harden-compaction-resume: auto-inject recovery hint when pending (determinístico, no depende del modelo)
210
243
  if (!trivial && state && !["DONE", "INTERPRETATION_PENDING"].includes(state.state)) {
@@ -214,7 +247,7 @@ export const OstackyController: Plugin = async (ctx) => {
214
247
  : []
215
248
  if (pending.length === 0) {
216
249
  try {
217
- const fallbackPath = join(dirname(getStatePath(ctx.directory)), ".ostacky-handoff-compaction.json")
250
+ const fallbackPath = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
218
251
  if (existsSync(fallbackPath)) {
219
252
  const raw = readFileSync(fallbackPath, "utf-8")
220
253
  const data = JSON.parse(raw)
@@ -227,27 +260,21 @@ export const OstackyController: Plugin = async (ctx) => {
227
260
  }
228
261
  if (pending.length > 0) {
229
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]`
230
- if (output.parts && output.parts.length > 0) {
231
- const last = output.parts[output.parts.length - 1]
232
- if (last.type === "text") last.text = (last.text ?? "") + hint
233
- else output.parts.push({ type: "text", text: hint })
234
- } else {
235
- output.parts = [{ type: "text", text: text + hint }]
236
- }
263
+ event.prompt.text = (event.prompt.text ?? "") + hint
237
264
  }
238
265
  } catch {}
239
266
  }
240
- },
267
+ })
241
268
 
242
269
  // ── Hard gates before any tool ──
243
- "tool.execute.before": async (input: any, _output: any) => {
244
- const tool: string = (input as any).tool as string
245
- const args: any = (input as any).args as any
246
- 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
247
274
 
248
275
  // ── 0) Trivial greeting blocks for expensive tools (SKIP, not BLOCKED) ──
249
276
  const isTrivialSession = trivialBySession.get(sessionId) ?? false
250
- const stateForTrivial = readState(ctx.directory)
277
+ const stateForTrivial = readState(directory)
251
278
  if (isTrivialSession && stateForTrivial?.state === "DONE") {
252
279
  const blockedForTrivial = [
253
280
  "engram_mem_context", "mem_context",
@@ -261,7 +288,7 @@ export const OstackyController: Plugin = async (ctx) => {
261
288
 
262
289
  // ── 1) PENDING hard gate (0 tokens) ──
263
290
  checkCount++
264
- const freshState = readState(ctx.directory)
291
+ const freshState = readState(directory)
265
292
  const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
266
293
  if (freshState && pendingStates.includes(freshState.state)) {
267
294
  // cache ALLOW per revision, BLOCKED never cached
@@ -291,11 +318,11 @@ export const OstackyController: Plugin = async (ctx) => {
291
318
  const shouldAudit = !hasEngramHit && !isTrivialForEngram && stateForEngram && ["DISCOVERY", "ROUTE_DECISION_PENDING", "SPECIFICATION", "INTERPRETATION_PENDING"].includes(stateForEngram.state)
292
319
  if (shouldAudit) {
293
320
  try {
294
- const st = readState(ctx.directory)
321
+ const st = readState(directory)
295
322
  if (st) {
296
323
  st.engramBypassCount = (st.engramBypassCount || 0) + 1
297
324
  // no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
298
- persistState(ctx.directory, st)
325
+ persistState(directory, st)
299
326
  }
300
327
  } catch {}
301
328
  }
@@ -304,16 +331,16 @@ export const OstackyController: Plugin = async (ctx) => {
304
331
  // ── 1.5) Router determinista: openspec-propose bloquea si 1+ no-downgradeable sin Alternatives ──
305
332
  const isOpenspecPropose = tool.includes("openspec") && (tool.includes("propose") || args?.filePath?.includes("openspec/changes") || args?.path?.includes("openspec/changes"))
306
333
  if (isOpenspecPropose) {
307
- const s = readState(ctx.directory)
334
+ const s = readState(directory)
308
335
  if (s?.level === "1+" && s?._routerNeedsAlternatives) {
309
336
  // check if design.md has Alternatives
310
337
  try {
311
338
  const changeId = args?.changeId || s?.changeId || ""
312
339
  let designPath: string | null = null
313
- if (changeId) designPath = join(ctx.directory, "openspec", "changes", changeId, "design.md")
340
+ if (changeId) designPath = join(directory, "openspec", "changes", changeId, "design.md")
314
341
  else {
315
342
  // try to find any change dir with _routerNeedsAlternatives
316
- const changesDir = join(ctx.directory, "openspec", "changes")
343
+ const changesDir = join(directory, "openspec", "changes")
317
344
  if (existsSync(changesDir)) {
318
345
  for (const entry of readdirSync(changesDir)) {
319
346
  const p = join(changesDir, entry, "design.md")
@@ -350,8 +377,8 @@ export const OstackyController: Plugin = async (ctx) => {
350
377
  // Grep on *.md should not be blocked
351
378
  const isLiteralGrep = tool === "grep" && (args?.include?.endsWith(".md") || args?.include?.endsWith(".json"))
352
379
  if (isCodeFile && !isLiteralGrep && !isSpecTasksPath) {
353
- const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(ctx.directory)
354
- const codegraphOk = isCodegraphAvailable(ctx.directory)
380
+ const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(directory)
381
+ const codegraphOk = isCodegraphAvailable(directory)
355
382
  if (codegraphOk && !hasDiscoveryHit) {
356
383
  // Allow if file is not indexable or trivial?
357
384
  // Block with suggestion
@@ -361,7 +388,7 @@ export const OstackyController: Plugin = async (ctx) => {
361
388
  }
362
389
 
363
390
  // ── 3) Sensitive gate (hard, even in degraded) ──
364
- if (tool === "bash") {
391
+ if (tool === "shell") {
365
392
  const cmd: string = args?.command || args?.cmd || ""
366
393
  if (typeof cmd === "string" && cmd) {
367
394
  const normalized = cmd.replace(/["'`]/g, "").replace(/\\/g, "")
@@ -371,27 +398,27 @@ export const OstackyController: Plugin = async (ctx) => {
371
398
  if (hasSensitivePattern || sensitivePaths.length > 0) {
372
399
  if (sensitivePaths.length > 0) {
373
400
  for (const p of sensitivePaths) {
374
- const s = readState(ctx.directory)
375
- 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)]
376
403
  const allowed = candidates.some((c) => s?.allowedFiles?.[c] || s?.allowedFiles?.[p])
377
- 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)]
378
405
  if (!allowed && !baseAllowed) {
379
406
  const denied = s?.deniedFiles?.[p] || s?.deniedFiles?.[basename(p)]
380
- if (denied) throw new Error(`BLOCKED: bash contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
381
- 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.`)
382
409
  }
383
410
  }
384
411
  } else if (hasSensitivePattern) {
385
- const s = readState(ctx.directory)
412
+ const s = readState(directory)
386
413
  const hasAllowed = Object.keys(s?.allowedFiles || {}).some((k) => isSensitive(k, patterns))
387
- 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.`)
388
415
  }
389
416
  }
390
417
  }
391
418
  }
392
419
 
393
- // ── 3.5) Bash file-mutation gate (harden-task-integrity: bash mutante requiere validate_edit) ──
394
- 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)) {
395
422
  const cmdForMutation: string = args?.command || args?.cmd || ""
396
423
  const isMutating = /[>]{1,2}\s*\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdForMutation)
397
424
  if (isMutating) {
@@ -400,24 +427,24 @@ export const OstackyController: Plugin = async (ctx) => {
400
427
  if (!p) continue
401
428
  const isProjectCode = p.includes("src/") || p.includes("assets/") || /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p)
402
429
  if (!isProjectCode) continue
403
- if (!isPathInsideProject(p, ctx.directory)) continue
404
- const abs = resolve(ctx.directory, p)
430
+ if (!isPathInsideProject(p, directory)) continue
431
+ const abs = resolve(directory, p)
405
432
  const exists = existsSync(abs)
406
433
  const isNewFile = !exists && !freshState.fileFingerprints?.[p] && !Object.values(freshState.tasks || {}).some((t: any) => t.filePath === p)
407
434
  if (isNewFile) continue
408
435
  const lv = freshState.lastValidated
409
436
  if (!lv || lv.filePath !== p) {
410
- 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`)
411
438
  }
412
439
  }
413
440
  }
414
441
  }
415
442
 
416
443
  let filePath: string | undefined
417
- 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") {
418
445
  filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
419
446
  if (tool === "grep" && args?.include) filePath = args.include
420
- if (tool === "write" || tool === "edit") filePath = args?.filePath || args?.path || ""
447
+ if (tool === "edit" || tool === "write") filePath = args?.filePath || args?.path || ""
421
448
  if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
422
449
  try {
423
450
  const u = args.uri as string
@@ -427,17 +454,30 @@ export const OstackyController: Plugin = async (ctx) => {
427
454
  }
428
455
  }
429
456
  if (filePath && isSensitive(filePath, patterns)) {
430
- const s = readState(ctx.directory)
431
- 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)]
432
459
  if (!allowed) {
433
460
  const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
434
461
  if (denied) throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
435
462
  throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
436
463
  }
437
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
+ }
438
478
 
439
- // ── 4) validate_edit in-process for write/edit ──
440
- 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") {
441
481
  const oldString: string = args?.oldString ?? ""
442
482
  const newString: string = args?.newString ?? args?.content ?? ""
443
483
  const targetPath: string = args?.filePath || args?.path || ""
@@ -445,31 +485,30 @@ export const OstackyController: Plugin = async (ctx) => {
445
485
  if (oldString === newString) {
446
486
  throw new Error(`CONFLICT: oldString === newString`)
447
487
  }
448
- if (!isPathInsideProject(targetPath, ctx.directory)) {
488
+ if (!isPathInsideProject(targetPath, directory)) {
449
489
  throw new Error(`BLOCKED: path outside project: ${targetPath}`)
450
490
  }
451
491
  // If oldString is hash:<fp> handle stale check
452
492
  if (oldString.startsWith("hash:")) {
453
493
  const claimed = oldString.slice(5)
454
- const currentFp = fastFingerprint(resolve(ctx.directory, targetPath))
455
- const s = readState(ctx.directory)
494
+ const currentFp = fastFingerprint(resolve(directory, targetPath))
495
+ const s = readState(directory)
456
496
  const last = s?.lastValidated
457
497
  if (claimed !== currentFp || (last && last.filePath === targetPath && last.hash !== currentFp)) {
458
498
  // mark stale attempt
459
499
  try {
460
- const st = readState(ctx.directory) || { ...DEFAULT_STATE }
500
+ const st = readState(directory) || { ...DEFAULT_STATE }
461
501
  st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
462
- persistState(ctx.directory, st)
502
+ persistState(directory, st)
463
503
  } catch {}
464
504
  throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
465
505
  }
466
506
  } else if (oldString.length > 0) {
467
507
  // Check fresh content has exactly one occurrence
468
508
  try {
469
- const fullPath = resolve(ctx.directory, targetPath)
509
+ const fullPath = resolve(directory, targetPath)
470
510
  if (existsSync(fullPath)) {
471
511
  const content = readFileSync(fullPath, "utf-8")
472
- const escaped = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
473
512
  // count occurrences literally, not regex
474
513
  let count = 0
475
514
  let idx = 0
@@ -488,24 +527,25 @@ export const OstackyController: Plugin = async (ctx) => {
488
527
  }
489
528
  // Record lastValidated for future hash checks
490
529
  try {
491
- const st = readState(ctx.directory)
530
+ const st = readState(directory)
492
531
  if (st) {
493
- const fp = fastFingerprint(resolve(ctx.directory, targetPath))
532
+ const fp = fastFingerprint(resolve(directory, targetPath))
494
533
  st.lastValidated = { filePath: targetPath, hash: fp, ts: Date.now() }
495
534
  }
496
535
  } catch {}
497
536
  }
498
537
  }
499
- },
538
+ })
500
539
 
501
- "tool.execute.after": async (input: any, output: any) => {
540
+ await ctx.tool.hook("execute.after", (event) => {
502
541
  // Track discovery hit to allow subsequent Reads
503
- const tool: string = (input as any).tool as string
504
- 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) : ""
505
545
  if (tool.includes("getDiscoverySnapshot") || tool.includes("get_discovery_snapshot")) {
506
546
  // if output indicates hit, mark it
507
547
  try {
508
- const text = typeof output === "string" ? output : JSON.stringify(output)
548
+ const text = resultText
509
549
  if (text && !text.includes("null") && text.length > 10) {
510
550
  discoveryHitByRequest.set(sessionId, true)
511
551
  }
@@ -519,61 +559,56 @@ export const OstackyController: Plugin = async (ctx) => {
519
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_")) {
520
560
  engramHitBySession.set(sessionId, true)
521
561
  }
522
- // Update state metrics for cache hit (best-effort)
523
- if (tool.includes("getDiscoverySnapshot") && output) {
524
- try {
525
- const s = readState((input as any).ctx?.directory ?? "")
526
- } catch {}
527
- }
528
- // Heartbeat + color purple tenue: Ostacky vs modelo (fácil) — single-writer: plugin memo, no persist cada tool (D10)
529
- let lastHeartbeatMem = 0
562
+ // Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
530
563
  try {
531
- const dir = ctx.directory
532
- const s = readState(dir)
564
+ const s = readState(directory)
533
565
  if (s) {
534
566
  const now = Date.now()
535
- lastHeartbeatMem = now
536
567
  // Single-writer: solo persiste si OSTACKY_PLUGIN_PERSIST=1 o idle>30s o state cambió
537
568
  const shouldPersist = process.env.OSTACKY_PLUGIN_PERSIST === "1" || (now - (s.lastHeartbeat || 0) > 30000)
538
569
  if (shouldPersist) {
539
570
  s.lastHeartbeat = now
540
- try { persistState(dir, s) } catch {}
541
- }
542
- if (["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state) && output && typeof output.title === "string" && output.title && !output.title.includes("🟣")) {
543
- output.title = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ${output.title}`
571
+ try { persistState(directory, s) } catch {}
544
572
  }
545
573
  }
546
574
  } catch {}
547
- },
575
+ })
576
+
577
+ const emptySchema = {
578
+ type: "object",
579
+ properties: {},
580
+ required: [],
581
+ additionalProperties: false,
582
+ } as const
548
583
 
549
584
  // ── Observable tools (MCP thin replacement) ──
550
- tool: {
551
- ostacky_get_state: {
585
+ await ctx.tool.transform((editor) => {
586
+ editor.add({
587
+ name: "ostacky_get_state",
552
588
  description: "Get Ostacky controller state (plugin, no MCP needed)",
553
- parameters: {} as any,
554
- execute: async (_args: any, ctx2: any) => {
555
- const dir = ctx2?.directory ?? ctx.directory
556
- const state = readState(dir)
557
- if (!state) return { error: "no state", state: "UNKNOWN", revision: 0 }
558
- 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 })
559
594
  },
560
- },
561
- ostacky_get_audit: {
595
+ })
596
+ editor.add({
597
+ name: "ostacky_get_audit",
562
598
  description: "Get audit trail (plugin)",
563
- parameters: {} as any,
564
- execute: async (_args: any, ctx2: any) => {
565
- const dir = ctx2?.directory ?? ctx.directory
566
- const state = readState(dir)
567
- 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 })
568
603
  },
569
- },
570
- ostacky_get_metrics: {
604
+ })
605
+ editor.add({
606
+ name: "ostacky_get_metrics",
571
607
  description: "Get controller metrics (plugin)",
572
- parameters: {} as any,
573
- execute: async (_args: any, ctx2: any) => {
574
- const dir = ctx2?.directory ?? ctx.directory
575
- const state = readState(dir)
576
- return {
608
+ input: emptySchema,
609
+ execute: async () => {
610
+ const state = readState(directory)
611
+ return jsonContent({
577
612
  cacheHitCount: state?.cacheHitCount ?? 0,
578
613
  discoveryCacheHitCount: state?.discoveryCacheHitCount ?? 0,
579
614
  tokenSavingEstimate: state?.tokenSavingEstimate ?? 0,
@@ -581,90 +616,99 @@ export const OstackyController: Plugin = async (ctx) => {
581
616
  codegraphBypassCount: state?.codegraphBypassCount ?? 0,
582
617
  engramBypassCount: state?.engramBypassCount ?? 0,
583
618
  revision: state?.revision ?? 0,
584
- }
619
+ })
585
620
  },
586
- },
587
- ostacky_get_handoff: {
621
+ })
622
+ editor.add({
623
+ name: "ostacky_get_handoff",
588
624
  description: "Get last handoff (plugin)",
589
- parameters: {} as any,
590
- execute: async (_args: any, ctx2: any) => {
591
- const dir = ctx2?.directory ?? ctx.directory
592
- const state = readState(dir)
625
+ input: emptySchema,
626
+ execute: async () => {
627
+ const state = readState(directory)
593
628
  // also check compaction fallback
594
629
  try {
595
- const fallback = join(dirname(getStatePath(dir)), ".ostacky-handoff-compaction.json")
630
+ const fallback = join(dirname(getStatePath(directory)), ".ostacky-handoff-compaction.json")
596
631
  if (existsSync(fallback)) {
597
632
  const raw = readFileSync(fallback, "utf-8")
598
633
  const data = JSON.parse(raw)
599
- if (data && !state?.lastHandoff) return data
634
+ if (data && !state?.lastHandoff) return jsonContent(data)
600
635
  }
601
636
  } catch {}
602
- return state?.lastHandoff ?? null
637
+ return jsonContent(state?.lastHandoff ?? null)
603
638
  },
604
- },
605
- ostacky_get_available_transitions: {
639
+ })
640
+ editor.add({
641
+ name: "ostacky_get_available_transitions",
606
642
  description: "Get available transitions from current state",
607
- parameters: {} as any,
608
- execute: async (_args: any, ctx2: any) => {
609
- const dir = ctx2?.directory ?? ctx.directory
610
- const state = readState(dir)
643
+ input: emptySchema,
644
+ execute: async () => {
645
+ const state = readState(directory)
611
646
  const cur = state?.state ?? "INTERPRETATION_PENDING"
612
647
  const trans = TRANSITIONS[cur] ?? []
613
- return { currentState: cur, transitions: trans }
648
+ return jsonContent({ currentState: cur, transitions: trans })
614
649
  },
615
- },
650
+ })
616
651
  // Compatibility for old MCP calls that expect deprecated response
617
- ostacky_check_pending_state: {
652
+ editor.add({
653
+ name: "ostacky_check_pending_state",
618
654
  description: "[deprecated] plugin enforces — use tool before hook",
619
- parameters: {} as any,
620
- execute: async (_args: any) => {
621
- return { deprecated: true, hint: "plugin enforces", allowed: true }
655
+ input: emptySchema,
656
+ execute: async () => {
657
+ return jsonContent({ deprecated: true, hint: "plugin enforces", allowed: true })
622
658
  },
623
- },
624
- ostacky_validate_edit: {
659
+ })
660
+ editor.add({
661
+ name: "ostacky_validate_edit",
625
662
  description: "[deprecated] plugin enforces validate_edit in-process",
626
- parameters: {} as any,
627
- execute: async (_args: any) => {
628
- return { deprecated: true, hint: "plugin enforces" }
663
+ input: emptySchema,
664
+ execute: async () => {
665
+ return jsonContent({ deprecated: true, hint: "plugin enforces" })
629
666
  },
630
- },
631
- },
632
-
633
- event: async ({ event }: any) => {
634
- if (event.type === "session.deleted") {
635
- const sid = (event.properties as any)?.info?.id
636
- if (sid) {
637
- trivialBySession.delete(sid)
638
- discoveryHitByRequest.delete(sid)
639
- engramHitBySession.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
+ }
640
680
  }
641
681
  }
642
- },
643
-
644
- dispose: async () => {
645
- try { if (pingInterval) clearInterval(pingInterval) } catch {}
646
- pingInterval = null
647
- },
682
+ })()
648
683
 
649
- "experimental.session.compacting": async (input: any, output: any) => {
684
+ await ctx.session.hook("compaction", (event) => {
650
685
  try {
651
- const statePath = getStatePath(ctx.directory)
686
+ const statePath = getStatePath(directory)
652
687
  const dir = dirname(statePath)
653
688
  try { mkdirSync(dir, { recursive: true }) } catch {}
654
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
+ })()
655
695
  const payload = {
656
- summary: `Compaction fallback for session ${input.sessionID ?? "unknown"} — plugin`,
696
+ summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — plugin`,
657
697
  nextSteps: [] as string[],
658
698
  pendingTasks: [] as string[],
659
699
  ts: Date.now(),
660
- contextSnippet: output.context?.slice(0, 2).join("\n\n").slice(0, 800) ?? "",
700
+ contextSnippet,
661
701
  }
662
702
  const tmp = `${fallbackPath}.tmp.${process.pid}`
663
703
  writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
664
704
  renameSync(tmp, fallbackPath)
665
705
  } catch {}
666
- },
667
- }
668
- }
706
+ })
669
707
 
670
- export default OstackyController
708
+ return () => {
709
+ try { if (pingInterval) clearInterval(pingInterval) } catch {}
710
+ pingInterval = null
711
+ eventController.abort()
712
+ }
713
+ },
714
+ })