ostacky 0.8.8 → 0.9.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.
- package/README.md +17 -17
- package/assets/agents/ostacky.md +7 -7
- package/assets/commands/install-stack.md +38 -30
- package/assets/docs/ostacky-reference.md +1 -1
- package/assets/mcp/ostacky-controller/controller-core.js +2 -0
- package/assets/mcp/ostacky-controller/index.js +248 -67
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/controller-core.ts +2 -0
- package/assets/plugins/engram.ts +648 -646
- package/assets/plugins/ostacky-plugin.ts +258 -214
- package/dist/cli.js +288 -108
- package/manifest.json +28 -28
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
let checkCount = 0
|
|
179
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────────
|
|
159
180
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
187
|
-
const sessionId: string =
|
|
188
|
-
const
|
|
189
|
-
const
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
const tool: string =
|
|
245
|
-
const args: any = (input
|
|
246
|
-
const sessionId: string = (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
354
|
-
const codegraphOk = isCodegraphAvailable(
|
|
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 === "
|
|
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(
|
|
375
|
-
const candidates = [p, resolve(
|
|
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(
|
|
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:
|
|
381
|
-
throw new Error(`BLOCKED:
|
|
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(
|
|
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:
|
|
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)
|
|
394
|
-
if (tool === "
|
|
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,
|
|
404
|
-
const abs = resolve(
|
|
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
|
|
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 === "
|
|
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 === "
|
|
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(
|
|
431
|
-
const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(
|
|
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
|
|
440
|
-
if (tool === "
|
|
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,
|
|
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(
|
|
455
|
-
const s = readState(
|
|
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(
|
|
500
|
+
const st = readState(directory) || { ...DEFAULT_STATE }
|
|
461
501
|
st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
|
|
462
|
-
persistState(
|
|
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(
|
|
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(
|
|
530
|
+
const st = readState(directory)
|
|
492
531
|
if (st) {
|
|
493
|
-
const fp = fastFingerprint(resolve(
|
|
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
|
-
|
|
540
|
+
await ctx.tool.hook("execute.after", (event) => {
|
|
502
541
|
// Track discovery hit to allow subsequent Reads
|
|
503
|
-
const tool: string =
|
|
504
|
-
const sessionId: string = (
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
execute: async (
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
595
|
+
})
|
|
596
|
+
editor.add({
|
|
597
|
+
name: "ostacky_get_audit",
|
|
562
598
|
description: "Get audit trail (plugin)",
|
|
563
|
-
|
|
564
|
-
execute: async (
|
|
565
|
-
const
|
|
566
|
-
|
|
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
|
-
|
|
604
|
+
})
|
|
605
|
+
editor.add({
|
|
606
|
+
name: "ostacky_get_metrics",
|
|
571
607
|
description: "Get controller metrics (plugin)",
|
|
572
|
-
|
|
573
|
-
execute: async (
|
|
574
|
-
const
|
|
575
|
-
|
|
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
|
-
|
|
621
|
+
})
|
|
622
|
+
editor.add({
|
|
623
|
+
name: "ostacky_get_handoff",
|
|
588
624
|
description: "Get last handoff (plugin)",
|
|
589
|
-
|
|
590
|
-
execute: async (
|
|
591
|
-
const
|
|
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(
|
|
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
|
-
|
|
639
|
+
})
|
|
640
|
+
editor.add({
|
|
641
|
+
name: "ostacky_get_available_transitions",
|
|
606
642
|
description: "Get available transitions from current state",
|
|
607
|
-
|
|
608
|
-
execute: async (
|
|
609
|
-
const
|
|
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
|
-
|
|
652
|
+
editor.add({
|
|
653
|
+
name: "ostacky_check_pending_state",
|
|
618
654
|
description: "[deprecated] plugin enforces — use tool before hook",
|
|
619
|
-
|
|
620
|
-
execute: async (
|
|
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
|
-
|
|
659
|
+
})
|
|
660
|
+
editor.add({
|
|
661
|
+
name: "ostacky_validate_edit",
|
|
625
662
|
description: "[deprecated] plugin enforces validate_edit in-process",
|
|
626
|
-
|
|
627
|
-
execute: async (
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
684
|
+
await ctx.session.hook("compaction", (event) => {
|
|
650
685
|
try {
|
|
651
|
-
const statePath = getStatePath(
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
708
|
+
return () => {
|
|
709
|
+
try { if (pingInterval) clearInterval(pingInterval) } catch {}
|
|
710
|
+
pingInterval = null
|
|
711
|
+
eventController.abort()
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
})
|