ostacky 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Ostacky Controller — Plugin híbrido como alma
3
+ *
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.
6
+ * MCP queda thin solo para observabilidad (get_*).
7
+ *
8
+ * Single source security: importa desde src/security.ts (no copia regex).
9
+ * Cache único: getDiscoverySnapshot es único entrypoint.
10
+ * Tiered cache-friendly: isTrivial sin reemplazar system[0], suffix hint + SKIP.
11
+ * CodeGraph preventivo: bloquea Read/Grep masivo sin Discovery hit.
12
+ */
13
+
14
+ import type { Plugin } from "@opencode-ai/plugin"
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"
19
+
20
+ // ─── Constants ───────────────────────────────────────────────────────────────
21
+
22
+ const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024
23
+ const PING_INTERVAL_MS = 60_000
24
+ const IDLE_THRESHOLD_MS = 45_000
25
+ const PURPLE_TENUE = "\x1b[38;5;183m"
26
+ const PURPLE_RESET = "\x1b[0m"
27
+
28
+ const STATES = Object.freeze({
29
+ INTERPRETATION_PENDING: "INTERPRETATION_PENDING",
30
+ CLARIFICATION_PENDING: "CLARIFICATION_PENDING",
31
+ DISCOVERY: "DISCOVERY",
32
+ ROUTE_DECISION_PENDING: "ROUTE_DECISION_PENDING",
33
+ SPECIFICATION: "SPECIFICATION",
34
+ EXECUTION_ANALYSIS: "EXECUTION_ANALYSIS",
35
+ EXECUTION_DECISION_PENDING: "EXECUTION_DECISION_PENDING",
36
+ EXECUTING_INLINE: "EXECUTING_INLINE",
37
+ EXECUTING_SUBAGENTS: "EXECUTING_SUBAGENTS",
38
+ SYNC: "SYNC",
39
+ DONE: "DONE",
40
+ BLOCKED: "BLOCKED",
41
+ } as const)
42
+
43
+ const TRANSITIONS: Record<string, Array<{ via: string; to: string; choice?: string; mode?: string }>> = {
44
+ INTERPRETATION_PENDING: [
45
+ { via: "request_clarification", to: "CLARIFICATION_PENDING" },
46
+ { via: "proceed_to_discovery", to: "DISCOVERY" },
47
+ { via: "record_discovery", to: "ROUTE_DECISION_PENDING" },
48
+ { via: "block", to: "BLOCKED" },
49
+ ],
50
+ CLARIFICATION_PENDING: [
51
+ { via: "record_clarification", to: "DISCOVERY" },
52
+ { via: "block", to: "BLOCKED" },
53
+ { via: "abandon", to: "BLOCKED" },
54
+ ],
55
+ DISCOVERY: [
56
+ { via: "record_discovery", to: "ROUTE_DECISION_PENDING" },
57
+ { via: "block", to: "BLOCKED" },
58
+ { via: "abandon", to: "BLOCKED" },
59
+ ],
60
+ ROUTE_DECISION_PENDING: [
61
+ { via: "consume_route_decision", to: "SPECIFICATION", choice: "SPEC" },
62
+ { via: "consume_route_decision", to: "EXECUTION_ANALYSIS", choice: "DIRECT" },
63
+ { via: "block", to: "BLOCKED" },
64
+ { via: "abandon", to: "BLOCKED" },
65
+ ],
66
+ SPECIFICATION: [
67
+ { via: "spec_complete", to: "EXECUTION_ANALYSIS" },
68
+ { via: "block", to: "BLOCKED" },
69
+ { via: "abandon", to: "BLOCKED" },
70
+ ],
71
+ EXECUTION_ANALYSIS: [
72
+ { via: "record_execution_analysis", to: "EXECUTION_DECISION_PENDING" },
73
+ { via: "block", to: "BLOCKED" },
74
+ { via: "abandon", to: "BLOCKED" },
75
+ ],
76
+ EXECUTION_DECISION_PENDING: [
77
+ { via: "consume_execution_decision", to: "EXECUTING_INLINE", mode: "INLINE" },
78
+ { via: "consume_execution_decision", to: "EXECUTING_SUBAGENTS", mode: "SUBAGENT_DRIVEN" },
79
+ { via: "block", to: "BLOCKED" },
80
+ { via: "abandon", to: "BLOCKED" },
81
+ ],
82
+ EXECUTING_INLINE: [
83
+ { via: "implementation_complete", to: "SYNC" },
84
+ { via: "block", to: "BLOCKED" },
85
+ ],
86
+ EXECUTING_SUBAGENTS: [
87
+ { via: "implementation_complete", to: "SYNC" },
88
+ { via: "block", to: "BLOCKED" },
89
+ ],
90
+ BLOCKED: [
91
+ { via: "replan", to: "INTERPRETATION_PENDING" },
92
+ { via: "abandon", to: "DONE" },
93
+ ],
94
+ SYNC: [
95
+ { via: "sync_complete", to: "DONE" },
96
+ { via: "block", to: "BLOCKED" },
97
+ ],
98
+ DONE: [],
99
+ }
100
+
101
+ const DEFAULT_STATE: any = {
102
+ state: STATES.INTERPRETATION_PENDING,
103
+ revision: 0,
104
+ requestId: null,
105
+ changeId: null,
106
+ routeDecisionId: null,
107
+ routeChoice: null,
108
+ level: null,
109
+ executionDecisionId: null,
110
+ executionMode: null,
111
+ snapshots: { codegraph: null, execution: null },
112
+ tasks: {},
113
+ fileFingerprints: {},
114
+ error: null,
115
+ lastHandoff: null,
116
+ expectedTasks: null,
117
+ expectedTaskCount: null,
118
+ auditSeq: 0,
119
+ degraded: false,
120
+ schemaVersion: 1,
121
+ stateOversizedCount: 0,
122
+ codegraphBypassCount: 0,
123
+ degradedEditsCount: 0,
124
+ cacheHitCount: 0,
125
+ cacheMissCount: 0,
126
+ tokenSavingEstimate: 0,
127
+ discoveryCacheHitCount: 0,
128
+ redundantCallCount: 0,
129
+ cacheMissWithoutPutCount: 0,
130
+ stateCheckCount: 0,
131
+ toolCallCount: 0,
132
+ lastProposal: null,
133
+ allowedFiles: {},
134
+ deniedFiles: {},
135
+ sensitivePatterns: SENSITIVE_DEFAULT,
136
+ sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 },
137
+ staleContentAttempts: 0,
138
+ completeWithoutValidateCount: 0,
139
+ toolTimeoutCount: 0,
140
+ lastToolDurationMs: 0,
141
+ stateDurationMs: 0,
142
+ subagentFailedCount: 0,
143
+ lastValidated: null,
144
+ pendingFileAccess: {},
145
+ lastHeartbeat: 0,
146
+ watchdogEnabled: true,
147
+ ts: Date.now(),
148
+ }
149
+
150
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
151
+
152
+ function getStatePath(directory: string): string {
153
+ if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
154
+ const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
155
+ for (const cand of candidates) {
156
+ try {
157
+ const raw = readFileSync(cand, "utf-8")
158
+ const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
159
+ const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
160
+ if (typeof envPath === "string" && envPath) return envPath
161
+ } catch {}
162
+ }
163
+ return join(directory, ".opencode", "ostacky-state.json")
164
+ }
165
+
166
+ function readState(directory: string): any | null {
167
+ const p = getStatePath(directory)
168
+ try {
169
+ const raw = readFileSync(p, "utf-8")
170
+ return JSON.parse(raw)
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
176
+ function persistState(directory: string, state: any): void {
177
+ const p = getStatePath(directory)
178
+ const dir = dirname(p)
179
+ try { mkdirSync(dir, { recursive: true }) } catch {}
180
+ let serialized = JSON.stringify(state, null, 2)
181
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
182
+ state.stateOversizedCount = (state.stateOversizedCount || 0) + 1
183
+ const trimmed = { ...state, snapshots: { codegraph: null, execution: null }, audit: (state.audit || []).slice(-50) }
184
+ serialized = JSON.stringify(trimmed, null, 2)
185
+ if (serialized.length > MAX_STATE_FILE_SIZE) return
186
+ state.snapshots = { codegraph: null, execution: null }
187
+ serialized = JSON.stringify(state, null, 2)
188
+ }
189
+ const tmp = p + ".tmp." + process.pid
190
+ try {
191
+ writeFileSync(tmp, serialized, "utf-8")
192
+ renameSync(tmp, p)
193
+ try { renameSync(p + ".backup", p + ".backup.1") } catch {}
194
+ try { renameSync(p + ".backup.1", p + ".backup.2") } catch {}
195
+ try {
196
+ const backupTmp = p + ".backup.tmp." + process.pid
197
+ writeFileSync(backupTmp, serialized, "utf-8")
198
+ renameSync(backupTmp, p + ".backup")
199
+ } catch {}
200
+ } catch {}
201
+ }
202
+
203
+ function fastFingerprint(filePath: string): string | null {
204
+ try {
205
+ const s = statSync(filePath)
206
+ return `${s.mtimeMs}-${s.size}`
207
+ } catch { return null }
208
+ }
209
+
210
+ function isPathInsideProject(filePath: string, directory: string): boolean {
211
+ if (!filePath) return true
212
+ try {
213
+ const projectRoot = directory
214
+ const resolved = resolve(projectRoot, filePath)
215
+ const rel = relative(projectRoot, resolved)
216
+ if (rel.startsWith("..")) return false
217
+ if (resolve(filePath) !== resolved && filePath.startsWith("/")) {
218
+ const absRel = relative(projectRoot, resolve(filePath))
219
+ if (absRel.startsWith("..")) return false
220
+ }
221
+ return true
222
+ } catch { return false }
223
+ }
224
+
225
+ function getDiscoveryCacheHit(directory: string): boolean {
226
+ try {
227
+ const cacheDir = join(directory, ".opencode", "cache", "codegraph")
228
+ if (!existsSync(cacheDir)) return false
229
+ const files = readdirSync(cacheDir).filter(f => f.startsWith("discovery-"))
230
+ if (files.length === 0) return false
231
+ // check if any file is recent (<1h) and valid
232
+ const now = Date.now()
233
+ for (const f of files) {
234
+ try {
235
+ const raw = readFileSync(join(cacheDir, f), "utf-8")
236
+ const data = JSON.parse(raw)
237
+ if (typeof data.ts === "number" && now - data.ts < 60*60*1000) return true
238
+ } catch {}
239
+ }
240
+ return false
241
+ } catch { return false }
242
+ }
243
+
244
+ function isCodegraphAvailable(directory: string): boolean {
245
+ // If codegraph binary exists, assume OK. Otherwise fallback allows Read.
246
+ try {
247
+ const bin = join(directory, ".opencode", "tools", "codegraph", "bin", "codegraph")
248
+ const binExe = bin + ".exe"
249
+ if (existsSync(bin) || existsSync(binExe)) return true
250
+ // also check global which
251
+ const which = (Bun as any).which?.("codegraph")
252
+ if (which) return true
253
+ return false
254
+ } catch { return false }
255
+ }
256
+
257
+ // Track trivial flag per session to gate tools
258
+ const trivialBySession = new Map<string, boolean>()
259
+ // Track discovery hit per requestId to avoid blocking after hit
260
+ const discoveryHitByRequest = new Map<string, boolean>()
261
+ // ─── Heartbeat ping — evita sensación de trancado en tareas largas ──
262
+ let lastPingTs = 0
263
+ let pingInterval: ReturnType<typeof setInterval> | null = null
264
+
265
+ // ─── Plugin ──────────────────────────────────────────────────────────────────
266
+
267
+ export const OstackyController: Plugin = async (ctx) => {
268
+ const patterns = (() => {
269
+ const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
270
+ if (!raw) return SENSITIVE_DEFAULT
271
+ return raw.split(",").map(s => s.trim()).filter(Boolean)
272
+ })()
273
+
274
+ let lastCheck: { revision: number; result: string } | null = null
275
+ let checkCount = 0
276
+
277
+ // Heartbeat ping — purple tenue, evita sensación de trancado en tareas largas
278
+ if (!pingInterval) {
279
+ pingInterval = setInterval(async () => {
280
+ try {
281
+ const s = readState(ctx.directory)
282
+ if (!s || !["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state)) return
283
+ const now = Date.now()
284
+ const lastHeartbeat = s.lastHeartbeat || s.ts || now
285
+ const idle = now - lastHeartbeat
286
+ if (idle < IDLE_THRESHOLD_MS) return
287
+ if (now - lastPingTs < PING_INTERVAL_MS) return
288
+ lastPingTs = now
289
+ const completed = Object.values(s.tasks || {}).filter((t: any) => t.status === "COMPLETED").length
290
+ const total = s.expectedTaskCount ?? s.expectedTasks?.length ?? "?"
291
+ const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
292
+ const msg = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ⏳ Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
293
+ try { await (ctx as any).client?.tui?.showToast?.({ body: { message: msg, variant: "info" } } as any) } catch {}
294
+ try { await (ctx as any).client?.app?.log?.({ body: { service: "ostacky-ping", level: "info", message: msg } } as any) } catch {}
295
+ try { const st = readState(ctx.directory); if (st) { (st as any).lastPingTs = now; persistState(ctx.directory, st) } } catch {}
296
+ } catch {}
297
+ }, 30_000)
298
+ if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
299
+ }
300
+
301
+ return {
302
+ // ── Tiered: suffix hint on user message (cache-friendly, no system replace) ──
303
+ "chat.message": async (input: any, output: any) => {
304
+ const sessionId: string = input.sessionID ?? "default"
305
+ const parts: any[] = output.parts || []
306
+ const text = parts.filter((p: any) => p.type === "text").map((p: any) => p.text ?? "").join("\n").trim()
307
+ || output.message?.summary?.title || ""
308
+ const state = readState(ctx.directory)
309
+ const currentState = state?.state ?? "DONE"
310
+ const trivial = isTrivial(text, currentState)
311
+ trivialBySession.set(sessionId, trivial)
312
+ if (trivial) {
313
+ // Preserve system[0] FULL cacheable, add suffix hint to user message
314
+ const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
315
+ if (output.parts && output.parts.length > 0) {
316
+ const last = output.parts[output.parts.length - 1]
317
+ if (last.type === "text") last.text = (last.text ?? "") + hint
318
+ else output.parts.push({ type: "text", text: hint })
319
+ } else if (output.message) {
320
+ output.parts = [{ type: "text", text: text + hint }]
321
+ }
322
+ } else {
323
+ // Also handle TIER1 hint for small tasks without replacing system
324
+ // Intent detection for downgradeable 0/0+1 is done in record_discovery router, not here
325
+ }
326
+ },
327
+
328
+ // ── Hard gates before any tool ──
329
+ "tool.execute.before": async (input: any, _output: any) => {
330
+ const tool: string = (input as any).tool as string
331
+ const args: any = (input as any).args as any
332
+ const sessionId: string = (input as any).sessionID ?? "default"
333
+
334
+ // ── 0) Trivial greeting blocks for expensive tools (SKIP, not BLOCKED) ──
335
+ const isTrivialSession = trivialBySession.get(sessionId) ?? false
336
+ const stateForTrivial = readState(ctx.directory)
337
+ if (isTrivialSession && stateForTrivial?.state === "DONE") {
338
+ const blockedForTrivial = [
339
+ "engram_mem_context", "mem_context",
340
+ "codegraph_codegraph_explore", "codegraph_explore",
341
+ "codegraph_codegraph_status", "codegraph_status",
342
+ ]
343
+ if (blockedForTrivial.some(t => tool.includes(t))) {
344
+ throw new Error(`SKIP: trivial greeting, answer directly`)
345
+ }
346
+ }
347
+
348
+ // ── 1) PENDING hard gate (0 tokens) ──
349
+ checkCount++
350
+ const freshState = readState(ctx.directory)
351
+ const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
352
+ if (freshState && pendingStates.includes(freshState.state)) {
353
+ // cache ALLOW per revision, BLOCKED never cached
354
+ if (lastCheck && freshState.revision === lastCheck.revision && checkCount % 5 !== 0 && lastCheck.result === "ALLOW") {
355
+ // reuse cached ALLOW
356
+ } else {
357
+ lastCheck = { revision: freshState.revision, result: pendingStates.includes(freshState.state) ? "BLOCKED" : "ALLOW" }
358
+ }
359
+ if (lastCheck.result === "BLOCKED") {
360
+ const allowed = ["consume_route_decision", "consume_execution_decision", "record_clarification", "abandon", "check_file_access", "consume_file_access_decision", "record_user_confirmation"]
361
+ const isControllerTool = tool.startsWith("ostacky-controller_") || tool.startsWith("ostacky_") || allowed.some(t => tool.includes(t))
362
+ if (!isControllerTool) {
363
+ throw new Error(`BLOCKED: call consume_* first — controller is in ${freshState.state}`)
364
+ }
365
+ }
366
+ } else if (freshState) {
367
+ lastCheck = { revision: freshState.revision, result: "ALLOW" }
368
+ }
369
+
370
+ // ── 1.5) Router determinista: openspec-propose bloquea si 1+ no-downgradeable sin Alternatives ──
371
+ const isOpenspecPropose = tool.includes("openspec") && (tool.includes("propose") || args?.filePath?.includes("openspec/changes") || args?.path?.includes("openspec/changes"))
372
+ if (isOpenspecPropose) {
373
+ const s = readState(ctx.directory)
374
+ if (s?.level === "1+" && s?._routerNeedsAlternatives) {
375
+ // check if design.md has Alternatives
376
+ try {
377
+ const changeId = args?.changeId || s?.changeId || ""
378
+ let designPath: string | null = null
379
+ if (changeId) designPath = join(ctx.directory, "openspec", "changes", changeId, "design.md")
380
+ else {
381
+ // try to find any change dir with _routerNeedsAlternatives
382
+ const changesDir = join(ctx.directory, "openspec", "changes")
383
+ if (existsSync(changesDir)) {
384
+ for (const entry of readdirSync(changesDir)) {
385
+ const p = join(changesDir, entry, "design.md")
386
+ if (existsSync(p)) { designPath = p; break }
387
+ }
388
+ }
389
+ }
390
+ if (designPath && existsSync(designPath)) {
391
+ const design = readFileSync(designPath, "utf-8")
392
+ if (!design.includes("## Alternatives")) {
393
+ throw new Error(`BLOCKED: 1+ requiere brainstorming Alternatives en design.md antes de openspec-propose. Ejecuta skill(brainstorming) primero.`)
394
+ }
395
+ } else if (s?._routerNeedsAlternatives) {
396
+ throw new Error(`BLOCKED: 1+ requiere brainstorming Alternatives en design.md antes de openspec-propose. Ejecuta skill(brainstorming) primero.`)
397
+ }
398
+ } catch (e: any) {
399
+ if (e.message?.startsWith("BLOCKED")) throw e
400
+ }
401
+ }
402
+ }
403
+
404
+ // ── 2) CodeGraph preventivo: Read/Grep/Glob sobre código sin Discovery hit → BLOCKED ──
405
+ const isCodeTool = tool === "read" || tool === "grep" || tool === "glob" || tool === "read_mcp_resource"
406
+ if (isCodeTool) {
407
+ let targetPath: string = args?.filePath || args?.path || args?.pattern || args?.include || args?.uri || ""
408
+ if (tool === "grep" && args?.include) targetPath = args.include
409
+ if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
410
+ const u = args.uri as string
411
+ if (u.startsWith("file://")) targetPath = u.slice(7)
412
+ }
413
+ const isCodeFile = /\.(ts|js|tsx|jsx|mts|cts)$/i.test(targetPath) || targetPath.includes("src/") || targetPath.includes("assets/") || args?.pattern?.includes("*.ts")
414
+ // Grep on *.md should not be blocked
415
+ const isLiteralGrep = tool === "grep" && (args?.include?.endsWith(".md") || args?.include?.endsWith(".json"))
416
+ if (isCodeFile && !isLiteralGrep) {
417
+ const hasDiscoveryHit = discoveryHitByRequest.get(sessionId) ?? getDiscoveryCacheHit(ctx.directory)
418
+ const codegraphOk = isCodegraphAvailable(ctx.directory)
419
+ if (codegraphOk && !hasDiscoveryHit) {
420
+ // Allow if file is not indexable or trivial?
421
+ // Block with suggestion
422
+ throw new Error(`BLOCKED: Usá getDiscoverySnapshot primero — sugerencia: getDiscoverySnapshot("${targetPath || "query"}")`)
423
+ }
424
+ }
425
+ }
426
+
427
+ // ── 3) Sensitive gate (hard, even in degraded) ──
428
+ if (tool === "bash") {
429
+ const cmd: string = args?.command || args?.cmd || ""
430
+ if (typeof cmd === "string" && cmd) {
431
+ const normalized = cmd.replace(/["'`]/g, "").replace(/\\/g, "")
432
+ const hasSensitivePattern = BASH_SENSITIVE_RE.test(normalized)
433
+ const paths = extractPathsFromBash(cmd)
434
+ const sensitivePaths = paths.filter((p) => isSensitive(p, patterns))
435
+ if (hasSensitivePattern || sensitivePaths.length > 0) {
436
+ if (sensitivePaths.length > 0) {
437
+ for (const p of sensitivePaths) {
438
+ const s = readState(ctx.directory)
439
+ const candidates = [p, resolve(ctx.directory, p), join(ctx.directory, p)]
440
+ const allowed = candidates.some((c) => s?.allowedFiles?.[c] || s?.allowedFiles?.[p])
441
+ const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(ctx.directory, p)]
442
+ if (!allowed && !baseAllowed) {
443
+ const denied = s?.deniedFiles?.[p] || s?.deniedFiles?.[basename(p)]
444
+ if (denied) throw new Error(`BLOCKED: bash contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
445
+ throw new Error(`BLOCKED: bash contiene acceso sensible (${p}). Llamá check_file_access con reason antes.`)
446
+ }
447
+ }
448
+ } else if (hasSensitivePattern) {
449
+ const s = readState(ctx.directory)
450
+ const hasAllowed = Object.keys(s?.allowedFiles || {}).some((k) => isSensitive(k, patterns))
451
+ if (!hasAllowed) throw new Error(`BLOCKED: bash contiene acceso sensible (.env). Llamá check_file_access con reason antes.`)
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ let filePath: string | undefined
458
+ if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "write" || tool === "edit") {
459
+ filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
460
+ if (tool === "grep" && args?.include) filePath = args.include
461
+ if (tool === "write" || tool === "edit") filePath = args?.filePath || args?.path || ""
462
+ if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
463
+ try {
464
+ const u = args.uri as string
465
+ if (u.startsWith("file://")) filePath = u.slice(7)
466
+ else if (u.includes("/")) filePath = u
467
+ } catch {}
468
+ }
469
+ }
470
+ if (filePath && isSensitive(filePath, patterns)) {
471
+ const s = readState(ctx.directory)
472
+ const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(ctx.directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
473
+ if (!allowed) {
474
+ const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
475
+ if (denied) throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
476
+ throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
477
+ }
478
+ }
479
+
480
+ // ── 4) validate_edit in-process for write/edit ──
481
+ if (tool === "write" || tool === "edit") {
482
+ const oldString: string = args?.oldString ?? ""
483
+ const newString: string = args?.newString ?? args?.content ?? ""
484
+ const targetPath: string = args?.filePath || args?.path || ""
485
+ if (targetPath && typeof oldString === "string" && typeof newString === "string") {
486
+ if (oldString === newString) {
487
+ throw new Error(`CONFLICT: oldString === newString`)
488
+ }
489
+ if (!isPathInsideProject(targetPath, ctx.directory)) {
490
+ throw new Error(`BLOCKED: path outside project: ${targetPath}`)
491
+ }
492
+ // If oldString is hash:<fp> handle stale check
493
+ if (oldString.startsWith("hash:")) {
494
+ const claimed = oldString.slice(5)
495
+ const currentFp = fastFingerprint(resolve(ctx.directory, targetPath))
496
+ const s = readState(ctx.directory)
497
+ const last = s?.lastValidated
498
+ if (claimed !== currentFp || (last && last.filePath === targetPath && last.hash !== currentFp)) {
499
+ // mark stale attempt
500
+ try {
501
+ const st = readState(ctx.directory) || { ...DEFAULT_STATE }
502
+ st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
503
+ persistState(ctx.directory, st)
504
+ } catch {}
505
+ throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
506
+ }
507
+ } else if (oldString.length > 0) {
508
+ // Check fresh content has exactly one occurrence
509
+ try {
510
+ const fullPath = resolve(ctx.directory, targetPath)
511
+ if (existsSync(fullPath)) {
512
+ const content = readFileSync(fullPath, "utf-8")
513
+ const escaped = oldString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
514
+ // count occurrences literally, not regex
515
+ let count = 0
516
+ let idx = 0
517
+ while ((idx = content.indexOf(oldString, idx)) !== -1) {
518
+ count++
519
+ idx += oldString.length
520
+ if (count > 1) break
521
+ }
522
+ if (count === 0) throw new Error(`CONFLICT: oldString not found in ${targetPath}`)
523
+ if (count > 1) throw new Error(`CONFLICT: oldString appears ${count} times in ${targetPath} (must be exactly 1)`)
524
+ }
525
+ } catch (e: any) {
526
+ if (e.message?.startsWith("CONFLICT")) throw e
527
+ // file not exists -> allow write?
528
+ }
529
+ }
530
+ // Record lastValidated for future hash checks
531
+ try {
532
+ const st = readState(ctx.directory)
533
+ if (st) {
534
+ const fp = fastFingerprint(resolve(ctx.directory, targetPath))
535
+ st.lastValidated = { filePath: targetPath, hash: fp, ts: Date.now() }
536
+ }
537
+ } catch {}
538
+ }
539
+ }
540
+ },
541
+
542
+ "tool.execute.after": async (input: any, output: any) => {
543
+ // Track discovery hit to allow subsequent Reads
544
+ const tool: string = (input as any).tool as string
545
+ const sessionId: string = (input as any).sessionID ?? "default"
546
+ if (tool.includes("getDiscoverySnapshot") || tool.includes("get_discovery_snapshot")) {
547
+ // if output indicates hit, mark it
548
+ try {
549
+ const text = typeof output === "string" ? output : JSON.stringify(output)
550
+ if (text && !text.includes("null") && text.length > 10) {
551
+ discoveryHitByRequest.set(sessionId, true)
552
+ }
553
+ } catch {}
554
+ }
555
+ // Update state metrics for cache hit (best-effort)
556
+ if (tool.includes("getDiscoverySnapshot") && output) {
557
+ try {
558
+ const s = readState((input as any).ctx?.directory ?? "")
559
+ } catch {}
560
+ }
561
+ // Heartbeat + color purple tenue: Ostacky vs modelo (fácil)
562
+ try {
563
+ const dir = ctx.directory
564
+ const s = readState(dir)
565
+ if (s) {
566
+ s.lastHeartbeat = Date.now()
567
+ try { persistState(dir, s) } catch {}
568
+ if (["EXECUTING_INLINE", "EXECUTING_SUBAGENTS", "SYNC"].includes(s.state) && output && typeof output.title === "string" && output.title && !output.title.includes("🟣")) {
569
+ output.title = `🟣 ${PURPLE_TENUE}[OSTACKY]${PURPLE_RESET} ${output.title}`
570
+ }
571
+ }
572
+ } catch {}
573
+ },
574
+
575
+ // ── Observable tools (MCP thin replacement) ──
576
+ tool: {
577
+ ostacky_get_state: {
578
+ description: "Get Ostacky controller state (plugin, no MCP needed)",
579
+ parameters: {} as any,
580
+ execute: async (_args: any, ctx2: any) => {
581
+ const dir = ctx2?.directory ?? ctx.directory
582
+ const state = readState(dir)
583
+ if (!state) return { error: "no state", state: "UNKNOWN", revision: 0 }
584
+ return { state: state.state, revision: state.revision, requestId: state.requestId, degraded: !!state.degraded, level: state.level, routeChoice: state.routeChoice }
585
+ },
586
+ },
587
+ ostacky_get_audit: {
588
+ description: "Get audit trail (plugin)",
589
+ parameters: {} as any,
590
+ execute: async (_args: any, ctx2: any) => {
591
+ const dir = ctx2?.directory ?? ctx.directory
592
+ const state = readState(dir)
593
+ return { audit: state?.audit ?? [], revision: state?.revision ?? 0 }
594
+ },
595
+ },
596
+ ostacky_get_metrics: {
597
+ description: "Get controller metrics (plugin)",
598
+ parameters: {} as any,
599
+ execute: async (_args: any, ctx2: any) => {
600
+ const dir = ctx2?.directory ?? ctx.directory
601
+ const state = readState(dir)
602
+ return {
603
+ cacheHitCount: state?.cacheHitCount ?? 0,
604
+ discoveryCacheHitCount: state?.discoveryCacheHitCount ?? 0,
605
+ tokenSavingEstimate: state?.tokenSavingEstimate ?? 0,
606
+ stateCheckCount: state?.stateCheckCount ?? 0,
607
+ codegraphBypassCount: state?.codegraphBypassCount ?? 0,
608
+ revision: state?.revision ?? 0,
609
+ }
610
+ },
611
+ },
612
+ ostacky_get_handoff: {
613
+ description: "Get last handoff (plugin)",
614
+ parameters: {} as any,
615
+ execute: async (_args: any, ctx2: any) => {
616
+ const dir = ctx2?.directory ?? ctx.directory
617
+ const state = readState(dir)
618
+ // also check compaction fallback
619
+ try {
620
+ const fallback = join(dirname(getStatePath(dir)), ".ostacky-handoff-compaction.json")
621
+ if (existsSync(fallback)) {
622
+ const raw = readFileSync(fallback, "utf-8")
623
+ const data = JSON.parse(raw)
624
+ if (data && !state?.lastHandoff) return data
625
+ }
626
+ } catch {}
627
+ return state?.lastHandoff ?? null
628
+ },
629
+ },
630
+ ostacky_get_available_transitions: {
631
+ description: "Get available transitions from current state",
632
+ parameters: {} as any,
633
+ execute: async (_args: any, ctx2: any) => {
634
+ const dir = ctx2?.directory ?? ctx.directory
635
+ const state = readState(dir)
636
+ const cur = state?.state ?? "INTERPRETATION_PENDING"
637
+ const trans = TRANSITIONS[cur] ?? []
638
+ return { currentState: cur, transitions: trans }
639
+ },
640
+ },
641
+ // Compatibility for old MCP calls that expect deprecated response
642
+ ostacky_check_pending_state: {
643
+ description: "[deprecated] plugin enforces — use tool before hook",
644
+ parameters: {} as any,
645
+ execute: async (_args: any) => {
646
+ return { deprecated: true, hint: "plugin enforces", allowed: true }
647
+ },
648
+ },
649
+ ostacky_validate_edit: {
650
+ description: "[deprecated] plugin enforces validate_edit in-process",
651
+ parameters: {} as any,
652
+ execute: async (_args: any) => {
653
+ return { deprecated: true, hint: "plugin enforces" }
654
+ },
655
+ },
656
+ },
657
+
658
+ event: async ({ event }: any) => {
659
+ if (event.type === "session.deleted") {
660
+ const sid = (event.properties as any)?.info?.id
661
+ if (sid) {
662
+ trivialBySession.delete(sid)
663
+ discoveryHitByRequest.delete(sid)
664
+ }
665
+ }
666
+ },
667
+
668
+ dispose: async () => {
669
+ try { if (pingInterval) clearInterval(pingInterval) } catch {}
670
+ pingInterval = null
671
+ },
672
+
673
+ "experimental.session.compacting": async (input: any, output: any) => {
674
+ try {
675
+ const statePath = getStatePath(ctx.directory)
676
+ const dir = dirname(statePath)
677
+ try { mkdirSync(dir, { recursive: true }) } catch {}
678
+ const fallbackPath = join(dir, ".ostacky-handoff-compaction.json")
679
+ const payload = {
680
+ summary: `Compaction fallback for session ${input.sessionID ?? "unknown"} — plugin`,
681
+ nextSteps: [] as string[],
682
+ pendingTasks: [] as string[],
683
+ ts: Date.now(),
684
+ contextSnippet: output.context?.slice(0, 2).join("\n\n").slice(0, 800) ?? "",
685
+ }
686
+ const tmp = `${fallbackPath}.tmp.${process.pid}`
687
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
688
+ renameSync(tmp, fallbackPath)
689
+ } catch {}
690
+ },
691
+ }
692
+ }
693
+
694
+ export default OstackyController