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