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.
- package/README.md +17 -19
- package/assets/agents/ostacky.md +45 -512
- package/assets/commands/install-stack.md +2 -2
- package/assets/docs/engram-protocol.md +79 -0
- package/assets/docs/ostacky-reference.md +79 -0
- package/assets/mcp/ostacky-controller/index.js +169 -69
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/engram.ts +47 -79
- package/assets/plugins/ostacky-guard.ts +11 -205
- package/assets/plugins/ostacky-plugin.ts +646 -0
- package/assets/skills/brainstorming/SKILL.md +14 -14
- package/assets/skills/execution-mode-evaluation/SKILL.md +9 -9
- package/dist/cli.js +233 -83
- package/manifest.json +30 -30
- package/package.json +1 -1
|
@@ -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
|