ostacky 0.9.7 → 0.10.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 +92 -315
- package/assets/agents/ostacky.md +8 -8
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +33 -6
- package/assets/mcp/ostacky-controller/controller-core.js +4 -0
- package/assets/mcp/ostacky-controller/index.js +374 -38
- package/assets/plugins/ostacky-controller/controller-core.ts +4 -0
- package/assets/plugins/ostacky-controller/index.ts +437 -66
- package/assets/skills/brainstorming/SKILL.md +2 -2
- package/assets/skills/execution-mode-evaluation/SKILL.md +11 -12
- package/assets/skills/openspec-propose/SKILL.md +2 -2
- package/assets/skills/tdd/SKILL.md +1 -1
- package/dist/cli.js +95 -69
- package/manifest.json +32 -32
- package/package.json +1 -1
|
@@ -141,6 +141,10 @@ export const DEFAULT_STATE: any = {
|
|
|
141
141
|
pendingFileAccess: {},
|
|
142
142
|
lastHeartbeat: 0,
|
|
143
143
|
watchdogEnabled: true,
|
|
144
|
+
// multi-sesión: dueño de la request activa (sessionId de OpenCode vía _meta).
|
|
145
|
+
// null = sin dueño conocido. Es UN campo (se sobreescribe) — nunca un archivo por sesión.
|
|
146
|
+
ownerSessionId: null,
|
|
147
|
+
ownerHeartbeat: 0,
|
|
144
148
|
ts: Date.now(),
|
|
145
149
|
specNotInSync: false,
|
|
146
150
|
engramBypassCount: 0,
|
|
@@ -6,7 +6,7 @@
|
|
|
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
|
|
9
|
-
*
|
|
9
|
+
* Discovery: `codegraph_explore` completado; no hay cache de archivo.
|
|
10
10
|
* Tiered cache-friendly: isTrivial sin reemplazar system[0], suffix hint + SKIP.
|
|
11
11
|
* CodeGraph preventivo: bloquea Read/Grep masivo sin Discovery hit.
|
|
12
12
|
*/
|
|
@@ -73,31 +73,137 @@ function readState(directory: string): any | null {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/** Sleep sincrónico acotado — los call sites del plugin son sync. */
|
|
77
|
+
function sleepSync(ms: number): void {
|
|
78
|
+
try {
|
|
79
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
80
|
+
} catch {
|
|
81
|
+
const end = Date.now() + ms
|
|
82
|
+
while (Date.now() < end) {
|
|
83
|
+
/* spin de último recurso */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPidAlive(pid: number): boolean {
|
|
89
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0)
|
|
92
|
+
return true
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Toma EL MISMO lock que el MCP (`<state>.lock.pid` con `wx`) para serializar
|
|
100
|
+
* plugin ↔ MCP cross-proceso. Devuelve una función de release; si no se pudo
|
|
101
|
+
* tomar, devuelve un no-op (la escritura sigue siendo segura por el merge).
|
|
102
|
+
*/
|
|
103
|
+
function acquireStateLock(statePath: string): () => void {
|
|
104
|
+
const pidPath = statePath + ".lock.pid"
|
|
105
|
+
const tsPath = statePath + ".lock.timestamp"
|
|
106
|
+
const staleWindow = 15_000
|
|
107
|
+
let acquired = false
|
|
108
|
+
for (let attempt = 0; attempt < 8 && !acquired; attempt++) {
|
|
109
|
+
// Lock viejo: solo lo liberamos si el dueño NO está vivo (mismo criterio que el MCP).
|
|
110
|
+
try {
|
|
111
|
+
const ts = parseInt(readFileSync(tsPath, "utf-8"), 10)
|
|
112
|
+
if (!Number.isNaN(ts) && Date.now() - ts >= staleWindow) {
|
|
113
|
+
const owner = parseInt(readFileSync(pidPath, "utf-8").trim(), 10)
|
|
114
|
+
if (!isPidAlive(owner)) {
|
|
115
|
+
try { unlinkSync(pidPath) } catch {}
|
|
116
|
+
try { unlinkSync(tsPath) } catch {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
try {
|
|
121
|
+
writeFileSync(pidPath, String(process.pid), { encoding: "utf-8", flag: "wx" })
|
|
122
|
+
try { writeFileSync(tsPath, String(Date.now()), "utf-8") } catch {}
|
|
123
|
+
acquired = true
|
|
124
|
+
} catch {
|
|
125
|
+
sleepSync(5 + attempt * 10)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!acquired) return () => {}
|
|
129
|
+
return () => {
|
|
130
|
+
try {
|
|
131
|
+
if (readFileSync(pidPath, "utf-8").trim() !== String(process.pid)) return
|
|
132
|
+
} catch {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
try { unlinkSync(pidPath) } catch {}
|
|
136
|
+
try { unlinkSync(tsPath) } catch {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let persistSeq = 0
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Merge puro: parte del estado FRESCO de disco y le aplica SOLO las claves que
|
|
144
|
+
* el plugin posee. Garantiza estructuralmente que una escritura del plugin no
|
|
145
|
+
* pueda borrar un avance del MCP (transición, audit, tasks, revision).
|
|
146
|
+
*/
|
|
147
|
+
export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[]): any {
|
|
148
|
+
const payload: any = { ...fresh }
|
|
149
|
+
for (const k of ownedKeys) {
|
|
150
|
+
if (Object.prototype.hasOwnProperty.call(state, k)) payload[k] = state[k]
|
|
151
|
+
}
|
|
152
|
+
return payload
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persiste estado SIN poder pisar al MCP.
|
|
157
|
+
*
|
|
158
|
+
* El MCP es la autoridad de la máquina de estados; el plugin solo escribe un
|
|
159
|
+
* puñado de campos "advisory" (`ownedKeys`). Antes el plugin hacía
|
|
160
|
+
* readState → mutar → write del snapshot COMPLETO: si el MCP avanzaba en el
|
|
161
|
+
* medio (transición/audit/tasks), la escritura del plugin borraba ese avance.
|
|
162
|
+
*
|
|
163
|
+
* Ahora: (1) lock compartido con el MCP, (2) re-lectura fresca de disco DENTRO
|
|
164
|
+
* del lock, (3) se aplican SOLO las claves propias sobre el estado fresco,
|
|
165
|
+
* (4) write atómico (tmp único + rename). El `revision` se preserva del disco:
|
|
166
|
+
* las escrituras del plugin no son transiciones de la máquina de estados.
|
|
167
|
+
*/
|
|
168
|
+
export function persistState(directory: string, state: any, ownedKeys?: string[]): void {
|
|
77
169
|
const p = getStatePath(directory)
|
|
78
170
|
const dir = dirname(p)
|
|
79
171
|
try { mkdirSync(dir, { recursive: true }) } catch {}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
state.stateOversizedCount = (state.stateOversizedCount || 0) + 1
|
|
83
|
-
const trimmed = { ...state, snapshots: { codegraph: null, execution: null }, audit: (state.audit || []).slice(-50) }
|
|
84
|
-
serialized = JSON.stringify(trimmed, null, 2)
|
|
85
|
-
if (serialized.length > MAX_STATE_FILE_SIZE) return
|
|
86
|
-
state.snapshots = { codegraph: null, execution: null }
|
|
87
|
-
serialized = JSON.stringify(state, null, 2)
|
|
88
|
-
}
|
|
89
|
-
const tmp = p + ".tmp." + process.pid
|
|
172
|
+
|
|
173
|
+
const release = acquireStateLock(p)
|
|
90
174
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
175
|
+
let payload: any = state
|
|
176
|
+
if (ownedKeys && ownedKeys.length > 0) {
|
|
177
|
+
let fresh: any = null
|
|
178
|
+
try { fresh = JSON.parse(readFileSync(p, "utf-8")) } catch {}
|
|
179
|
+
if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
|
|
180
|
+
payload = mergePluginOwnedState(fresh, state, ownedKeys)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let serialized = JSON.stringify(payload, null, 2)
|
|
184
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
185
|
+
payload.stateOversizedCount = (payload.stateOversizedCount || 0) + 1
|
|
186
|
+
const trimmed = { ...payload, snapshots: { codegraph: null, execution: null }, audit: (payload.audit || []).slice(-50) }
|
|
187
|
+
serialized = JSON.stringify(trimmed, null, 2)
|
|
188
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) return
|
|
189
|
+
payload.snapshots = { codegraph: null, execution: null }
|
|
190
|
+
serialized = JSON.stringify(payload, null, 2)
|
|
191
|
+
}
|
|
192
|
+
const tmp = p + ".tmp." + process.pid + "." + ++persistSeq
|
|
95
193
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
renameSync(
|
|
194
|
+
writeFileSync(tmp, serialized, "utf-8")
|
|
195
|
+
renameSync(tmp, p)
|
|
196
|
+
try { renameSync(p + ".backup", p + ".backup.1") } catch {}
|
|
197
|
+
try { renameSync(p + ".backup.1", p + ".backup.2") } catch {}
|
|
198
|
+
try {
|
|
199
|
+
const backupTmp = p + ".backup.tmp." + process.pid + "." + persistSeq
|
|
200
|
+
writeFileSync(backupTmp, serialized, "utf-8")
|
|
201
|
+
renameSync(backupTmp, p + ".backup")
|
|
202
|
+
} catch {}
|
|
99
203
|
} catch {}
|
|
100
|
-
}
|
|
204
|
+
} finally {
|
|
205
|
+
release()
|
|
206
|
+
}
|
|
101
207
|
}
|
|
102
208
|
|
|
103
209
|
function fastFingerprint(filePath: string): string | null {
|
|
@@ -122,25 +228,6 @@ function isPathInsideProject(filePath: string, directory: string): boolean {
|
|
|
122
228
|
} catch { return false }
|
|
123
229
|
}
|
|
124
230
|
|
|
125
|
-
function getDiscoveryCacheHit(directory: string): boolean {
|
|
126
|
-
try {
|
|
127
|
-
const cacheDir = join(directory, ".opencode", "cache", "codegraph")
|
|
128
|
-
if (!existsSync(cacheDir)) return false
|
|
129
|
-
const files = readdirSync(cacheDir).filter((f: string) => f.startsWith("discovery-"))
|
|
130
|
-
if (files.length === 0) return false
|
|
131
|
-
// check if any file is recent (<1h) and valid
|
|
132
|
-
const now = Date.now()
|
|
133
|
-
for (const f of files) {
|
|
134
|
-
try {
|
|
135
|
-
const raw = readFileSync(join(cacheDir, f), "utf-8")
|
|
136
|
-
const data = JSON.parse(raw)
|
|
137
|
-
if (typeof data.ts === "number" && now - data.ts < 60*60*1000) return true
|
|
138
|
-
} catch {}
|
|
139
|
-
}
|
|
140
|
-
return false
|
|
141
|
-
} catch { return false }
|
|
142
|
-
}
|
|
143
|
-
|
|
144
231
|
function isCodegraphAvailable(directory: string): boolean {
|
|
145
232
|
// If codegraph binary exists, assume OK. Otherwise fallback allows Read.
|
|
146
233
|
try {
|
|
@@ -153,6 +240,217 @@ function isCodegraphAvailable(directory: string): boolean {
|
|
|
153
240
|
} catch { return false }
|
|
154
241
|
}
|
|
155
242
|
|
|
243
|
+
/**
|
|
244
|
+
* ¿Este read/grep/glob esta sujeto al gate de discovery?
|
|
245
|
+
* Exenciones: docs/json/md, openspec/changes|specs, grep de literales, y archivos ya
|
|
246
|
+
* trackeados (refresh honesto post-complete necesita Read fresco sin Discovery hit).
|
|
247
|
+
*/
|
|
248
|
+
export function isCodeReadGated(input: {
|
|
249
|
+
isCodeFile: boolean
|
|
250
|
+
isLiteralGrep: boolean
|
|
251
|
+
isSpecTasksPath: boolean
|
|
252
|
+
isDocsPath: boolean
|
|
253
|
+
isTaskTracked: boolean
|
|
254
|
+
}): boolean {
|
|
255
|
+
return (
|
|
256
|
+
input.isCodeFile &&
|
|
257
|
+
!input.isLiteralGrep &&
|
|
258
|
+
!input.isSpecTasksPath &&
|
|
259
|
+
!input.isDocsPath &&
|
|
260
|
+
!input.isTaskTracked
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Predicado puro del gate de CodeGraph: ¿bloquear esta lectura de codigo?
|
|
266
|
+
*
|
|
267
|
+
* Extraido a funcion para poder testear el INVARIANTE, no el mensaje. El test anterior hacia
|
|
268
|
+
* `expect(src).toContain("Usa getDiscoverySnapshot primero")`: verificaba que el texto existiera
|
|
269
|
+
* y pasaba verde con el gate inescapable adentro (fix-codegraph-gate).
|
|
270
|
+
*/
|
|
271
|
+
export function shouldBlockCodeRead(input: {
|
|
272
|
+
isCodeFile: boolean
|
|
273
|
+
isLiteralGrep: boolean
|
|
274
|
+
isSpecTasksPath: boolean
|
|
275
|
+
isDocsPath: boolean
|
|
276
|
+
isTaskTracked: boolean
|
|
277
|
+
codegraphOk: boolean
|
|
278
|
+
hasDiscoveryHit: boolean
|
|
279
|
+
}): boolean {
|
|
280
|
+
return isCodeReadGated(input) && input.codegraphOk && !input.hasDiscoveryHit
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Tools cuyo completado constituye evidencia REAL de discovery estructural en la sesion.
|
|
285
|
+
*
|
|
286
|
+
* `codegraph_explore` es la senal fuerte: llamada real, resultado inspeccionable por el plugin.
|
|
287
|
+
* No hay cache de discovery: `getDiscoverySnapshot` nunca existió y se retiró.
|
|
288
|
+
*
|
|
289
|
+
* Historia (fix-codegraph-gate): antes la UNICA senal era `getDiscoverySnapshot`, una tool que nunca
|
|
290
|
+
* se registro ni en el plugin ni en el MCP. `hasDiscoveryHit` era siempre false y el gate bloqueaba
|
|
291
|
+
* todo read/grep/glob de codigo sin salida posible; un `codegraph_explore` completado NO lo abria,
|
|
292
|
+
* contradiciendo la instruccion "CodeGraph primero" del agente.
|
|
293
|
+
*/
|
|
294
|
+
export function marksDiscovery(toolName: string): boolean {
|
|
295
|
+
return (
|
|
296
|
+
toolName.includes("codegraph_explore")
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Mismo criterio de "archivo de código" que el gate de read (index.ts:552),
|
|
302
|
+
* sin la cláusula de `pattern` que es específica de glob. Docs/JSON quedan fuera:
|
|
303
|
+
* un .md bajo assets/ es documentación, no código.
|
|
304
|
+
*/
|
|
305
|
+
export function isCodePath(p: string): boolean {
|
|
306
|
+
if (!p) return false
|
|
307
|
+
if (/\.(md|json)$/i.test(p)) return false
|
|
308
|
+
return /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p) || p.includes("src/") || p.includes("assets/")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Evidencia de una corrida de shell para las tasks `test:` (change
|
|
313
|
+
* test-verification-execution). Devuelve null si el evento no permite afirmar
|
|
314
|
+
* nada: sin comando o sin `exit` numérico.
|
|
315
|
+
*
|
|
316
|
+
* NO consulta `status`: medido en OpenCode 2.0.32, `sh -c 'exit 3'` llega con
|
|
317
|
+
* `status: "completed"` y `result.metadata.status: "completed"`. El único dato
|
|
318
|
+
* veraz es `result.metadata.exit`.
|
|
319
|
+
*/
|
|
320
|
+
export function shellEvidence(event: any): { cmd: string; exit: number; ts: number } | null {
|
|
321
|
+
if (!event || event.tool !== "shell") return null
|
|
322
|
+
const cmd = event.input?.command
|
|
323
|
+
const exit = event.result?.metadata?.exit
|
|
324
|
+
if (typeof cmd !== "string" || !cmd.trim()) return null
|
|
325
|
+
if (typeof exit !== "number") return null
|
|
326
|
+
return { cmd: cmd.trim(), exit, ts: Date.now() }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Tools que mutan archivos: avanzan `lastMutationTs`. */
|
|
330
|
+
export function isMutatingTool(tool: string): boolean {
|
|
331
|
+
return tool === "edit" || tool === "write" || tool === "patch"
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Comando shell que escribe en disco. Predicado compartido por el gate de
|
|
336
|
+
* mutación y por `lastMutationTs`: si no se compartiera, un `sed -i` aprobado
|
|
337
|
+
* por el gate mutaría el código sin avanzar el timestamp, y una corrida de test
|
|
338
|
+
* previa seguiría contando como evidencia.
|
|
339
|
+
*/
|
|
340
|
+
export function isShellMutation(command: string): boolean {
|
|
341
|
+
if (!command) return false
|
|
342
|
+
// `2>&1`, `1>&2`, `2>/dev/null` son redirects de stderr, no mutación.
|
|
343
|
+
const cmdSansStderr = command.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
|
|
344
|
+
return /(^|[^0-9&])>{1,2}\s*(?!&)\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|\brm\s+|python.*open.*w/.test(cmdSansStderr)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Verbos cuyo output revela el CONTENIDO de un archivo. No incluye verbos que
|
|
349
|
+
* sólo mencionan paths (`bun test x.ts`, `tsc src/x.ts`, `git diff`): el gate
|
|
350
|
+
* distingue "revela" de "menciona" — ver change shell-code-read-gate.
|
|
351
|
+
*/
|
|
352
|
+
const SHELL_READ_VERBS = new Set([
|
|
353
|
+
"cat", "head", "tail", "sed", "awk", "grep", "rg", "ag", "ack", "less", "more",
|
|
354
|
+
"nl", "bat", "tac", "strings", "xxd", "od", "base64", "diff", "cut", "paste",
|
|
355
|
+
"sort", "uniq", "column", "expand", "fold",
|
|
356
|
+
])
|
|
357
|
+
|
|
358
|
+
const SHELL_WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "nohup"])
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Paths de código que `command` REVELA (no los que menciona).
|
|
362
|
+
*
|
|
363
|
+
* Fail-open por diseño: redirección => escritura, `sed -i` => escritura, verbo
|
|
364
|
+
* fuera de la allowlist => []. Un gate de shell nunca es hermético; el objetivo
|
|
365
|
+
* es quitar el bypass casual (`cat src/x.ts`), no demostrar imposibilidad.
|
|
366
|
+
* Bypass residual documentado en el spec: `git show`, `python -c`, `node -e`,
|
|
367
|
+
* `find -exec`, `xargs`.
|
|
368
|
+
*/
|
|
369
|
+
export function shellReadTargets(command: string): string[] {
|
|
370
|
+
if (!command) return []
|
|
371
|
+
const targets: string[] = []
|
|
372
|
+
// Split por pipes/separadores, NO por `>`: necesitamos ver la redirección.
|
|
373
|
+
const segments = command.replace(/&&/g, ";").replace(/\|\|/g, ";").split(/[|;\n]+/)
|
|
374
|
+
for (const seg of segments) {
|
|
375
|
+
const trimmed = seg
|
|
376
|
+
// Redirects de stderr no son escritura: sin esto, `cat src/x.ts 2>&1`
|
|
377
|
+
// pasaba el check `>` de abajo y se colaba del gate (bypass casual).
|
|
378
|
+
// Mismo criterio que isShellMutation, el sibling de este módulo.
|
|
379
|
+
.replace(/2>&1/g, "")
|
|
380
|
+
.replace(/1>&2/g, "")
|
|
381
|
+
.replace(/2>\s*\/dev\/null/g, "")
|
|
382
|
+
.trim()
|
|
383
|
+
if (!trimmed) continue
|
|
384
|
+
// Escritura real (cat > f, tee, heredoc): no es lectura.
|
|
385
|
+
if (/>/.test(trimmed)) continue
|
|
386
|
+
const tokens = (trimmed.match(/(?:[^\s"'`\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || [])
|
|
387
|
+
.map((t) => t.replace(/["'`]/g, "").replace(/\\/g, ""))
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
if (tokens.length === 0) continue
|
|
390
|
+
let i = 0
|
|
391
|
+
while (i < tokens.length && SHELL_WRAPPERS.has(tokens[i])) i++
|
|
392
|
+
const verb = tokens[i] || ""
|
|
393
|
+
const base = verb.split("/").pop() || verb
|
|
394
|
+
if (!SHELL_READ_VERBS.has(base)) continue
|
|
395
|
+
// sed -i es escritura in-place, no lectura.
|
|
396
|
+
if (base === "sed" && tokens.some((t) => t === "-i" || t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place"))) continue
|
|
397
|
+
// diff -q/--brief/--stat sólo compara metadatos: no revela contenido.
|
|
398
|
+
if (base === "diff" && tokens.some((t) => t === "-q" || t === "--quiet" || t === "--brief" || t === "--stat" || /^-[a-zA-Z]*q[a-zA-Z]*$/.test(t))) continue
|
|
399
|
+
for (const p of extractPathsFromBash(trimmed)) {
|
|
400
|
+
if (isCodePath(p)) targets.push(p)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return [...new Set(targets)]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Predicado puro del gate de shell: ¿leer este path por shell está EXENTO?
|
|
408
|
+
*
|
|
409
|
+
* Extraído del hook para poder testear el INVARIANTE (mismo criterio que
|
|
410
|
+
* shouldBlockCodeRead). Exenciones:
|
|
411
|
+
* 1. Paths FUERA del proyecto: no son código de este codebase (fixtures
|
|
412
|
+
* temporales de tests, reportes generados); bloquearlos rompe pipelines sin
|
|
413
|
+
* escape hatch — el falso positivo que el diseño prohibió.
|
|
414
|
+
* 2. Specs/docs (.md/.json, openspec, assets/docs).
|
|
415
|
+
* 3. Archivos ya trackeados en tasks/fileFingerprints/lastValidated (el refresh
|
|
416
|
+
* honesto post-complete necesita re-leerlos).
|
|
417
|
+
*/
|
|
418
|
+
export function isShellPathExempt(p: string, directory: string, freshState: any): boolean {
|
|
419
|
+
if (!isPathInsideProject(p, directory)) return true
|
|
420
|
+
if (p.includes("openspec/changes") || p.includes("openspec/specs")) return true
|
|
421
|
+
if (p.includes("assets/docs") || p.includes("/docs/") || /\.md$/i.test(p) || /\.json$/i.test(p)) return true
|
|
422
|
+
try {
|
|
423
|
+
if (freshState) {
|
|
424
|
+
const fps = Object.keys(freshState.fileFingerprints || {})
|
|
425
|
+
const taskFiles = Object.values(freshState.tasks || {}).map((t: any) => t.filePath).filter(Boolean)
|
|
426
|
+
const lv: any = (freshState as any).lastValidated || null
|
|
427
|
+
const lvByFile = lv?.byFile && typeof lv.byFile === "object" ? lv.byFile : null
|
|
428
|
+
const lvPaths: string[] = [
|
|
429
|
+
...(lvByFile ? Object.keys(lvByFile) : []),
|
|
430
|
+
...(lv?.filePath ? [lv.filePath as string] : []),
|
|
431
|
+
]
|
|
432
|
+
const matches = (f: string): boolean => p.endsWith(f) || f.endsWith(p) || p.includes(f) || f.includes(p)
|
|
433
|
+
if (fps.some(matches) || taskFiles.some(matches)) return true
|
|
434
|
+
if (lvPaths.some((lf) => p.endsWith(lf) || lf.endsWith(p))) return true
|
|
435
|
+
}
|
|
436
|
+
} catch {}
|
|
437
|
+
return false
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Comandos `test:` declarados en taskVerifications, ya normalizados (sin prefijo). */
|
|
441
|
+
export function testVerifyCommands(state: any): Set<string> {
|
|
442
|
+
const out = new Set<string>()
|
|
443
|
+
try {
|
|
444
|
+
for (const v of Object.values((state as any)?.taskVerifications || {})) {
|
|
445
|
+
if (typeof v === "string" && v.trim().startsWith("test:")) {
|
|
446
|
+
const cmd = v.trim().slice("test:".length).trim()
|
|
447
|
+
if (cmd) out.add(cmd)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch {}
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
|
|
156
454
|
function jsonContent(value: unknown): { content: string } {
|
|
157
455
|
try {
|
|
158
456
|
return { content: JSON.stringify(value) ?? "null" }
|
|
@@ -226,7 +524,7 @@ export default {
|
|
|
226
524
|
const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
|
|
227
525
|
const msg = `[ostacky] Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
|
|
228
526
|
try { console.log(msg) } catch {}
|
|
229
|
-
try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st) } } catch {}
|
|
527
|
+
try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st, ["lastPingTs"]) } } catch {}
|
|
230
528
|
} catch {}
|
|
231
529
|
}, 30_000)
|
|
232
530
|
if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
|
|
@@ -237,6 +535,18 @@ export default {
|
|
|
237
535
|
const sessionId: string = event.sessionID ?? "default"
|
|
238
536
|
const text = (event.prompt.text ?? "").trim()
|
|
239
537
|
const state = readState(directory)
|
|
538
|
+
// task-verification-integrity D4: registrar el texto ORIGINAL del usuario (antes de los
|
|
539
|
+
// hints de abajo) para que `implementation_complete({force:true})` se apoye en evidencia
|
|
540
|
+
// real y no en el audit, que lo redacta el agente. `ownedKeys` evita pisar el avance del MCP.
|
|
541
|
+
// Cada prompt lleva `ts`: el MCP sólo acepta confirmaciones POSTERIORES al último
|
|
542
|
+
// intento de force, así un "¿qué hace force:true?" viejo no abre el gate.
|
|
543
|
+
if (text && state) {
|
|
544
|
+
try {
|
|
545
|
+
const prev = Array.isArray((state as any).lastUserPrompts) ? (state as any).lastUserPrompts : []
|
|
546
|
+
;(state as any).lastUserPrompts = [...prev, { text: text.slice(0, 500), ts: Date.now() }].slice(-5)
|
|
547
|
+
persistState(directory, state, ["lastUserPrompts"])
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
240
550
|
const currentState = state?.state ?? "DONE"
|
|
241
551
|
const trivial = isTrivial(text, currentState)
|
|
242
552
|
trivialBySession.set(sessionId, trivial)
|
|
@@ -335,7 +645,7 @@ export default {
|
|
|
335
645
|
if (st) {
|
|
336
646
|
st.engramBypassCount = (st.engramBypassCount || 0) + 1
|
|
337
647
|
// no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
|
|
338
|
-
persistState(directory, st)
|
|
648
|
+
persistState(directory, st, ["engramBypassCount"])
|
|
339
649
|
}
|
|
340
650
|
} catch {}
|
|
341
651
|
}
|
|
@@ -413,14 +723,20 @@ export default {
|
|
|
413
723
|
(lvFile ? lvFile.some((lf) => targetPath.endsWith(lf) || lf.endsWith(targetPath)) : false)
|
|
414
724
|
}
|
|
415
725
|
} catch {}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
726
|
+
// Orden cheap -> caro: el path no hace I/O; codegraph y la cache en disco solo se evalúan
|
|
727
|
+
// si el path esta realmente sujeto al gate (`read`/`grep`/`glob` corren en cada turno).
|
|
728
|
+
const gated = isCodeReadGated({ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked })
|
|
729
|
+
const codegraphOk = gated ? isCodegraphAvailable(directory) : false
|
|
730
|
+
// Sin fallback a disco: la única evidencia válida es un codegraph_explore
|
|
731
|
+
// COMPLETADO en esta sesión (discoveryHitByRequest). El memo se limpia con
|
|
732
|
+
// session.deleted, así "esta sesión" es literal.
|
|
733
|
+
const hasDiscoveryHit = codegraphOk ? discoveryHitByRequest.get(sessionId) === true : false
|
|
734
|
+
if (shouldBlockCodeRead({
|
|
735
|
+
isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked, codegraphOk, hasDiscoveryHit,
|
|
736
|
+
})) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${targetPath || "query"}") antes de Read/Grep/Glob de código`
|
|
739
|
+
)
|
|
424
740
|
}
|
|
425
741
|
}
|
|
426
742
|
|
|
@@ -454,12 +770,32 @@ export default {
|
|
|
454
770
|
}
|
|
455
771
|
}
|
|
456
772
|
|
|
773
|
+
// ── 3.2) CodeGraph gate para shell: leer código sin discovery → BLOCKED ──
|
|
774
|
+
// El gate de código (arriba) sólo mira read/grep/glob; `cat src/x.ts` revela
|
|
775
|
+
// lo mismo y no costaba nada. Fail-open a propósito: `read` falla cerrado
|
|
776
|
+
// porque es inequívoco; `shell` es ambiguo y un falso positivo rompe tests o
|
|
777
|
+
// builds sin escape hatch para el usuario (change shell-code-read-gate).
|
|
778
|
+
if (tool === "shell") {
|
|
779
|
+
const cmdForRead: string = args?.command || args?.cmd || ""
|
|
780
|
+
if (typeof cmdForRead === "string" && cmdForRead) {
|
|
781
|
+
const shellTargets = shellReadTargets(cmdForRead)
|
|
782
|
+
if (shellTargets.length > 0) {
|
|
783
|
+
const gatedShell = shellTargets.filter((p) => !isShellPathExempt(p, directory, freshState))
|
|
784
|
+
const codegraphOkShell = gatedShell.length > 0 ? isCodegraphAvailable(directory) : false
|
|
785
|
+
const hasDiscoveryShell = codegraphOkShell ? discoveryHitByRequest.get(sessionId) === true : false
|
|
786
|
+
if (gatedShell.length > 0 && codegraphOkShell && !hasDiscoveryShell) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${gatedShell[0]}") antes de leer código por shell`
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
457
795
|
// ── 3.5) Shell file-mutation gate (harden-task-integrity: shell mutante requiere validate_edit) ──
|
|
458
796
|
if (tool === "shell" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
|
|
459
797
|
const cmdForMutation: string = args?.command || args?.cmd || ""
|
|
460
|
-
|
|
461
|
-
const cmdSansStderr = cmdForMutation.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
|
|
462
|
-
const isMutating = /(^|[^0-9&])>{1,2}\s*(?!&)\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdSansStderr)
|
|
798
|
+
const isMutating = isShellMutation(cmdForMutation)
|
|
463
799
|
if (isMutating) {
|
|
464
800
|
const mPaths = extractPathsFromBash(cmdForMutation)
|
|
465
801
|
for (const p of mPaths) {
|
|
@@ -541,7 +877,7 @@ export default {
|
|
|
541
877
|
try {
|
|
542
878
|
const st = readState(directory) || { ...DEFAULT_STATE }
|
|
543
879
|
st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
|
|
544
|
-
persistState(directory, st)
|
|
880
|
+
persistState(directory, st, ["staleContentAttempts"])
|
|
545
881
|
} catch {}
|
|
546
882
|
throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
|
|
547
883
|
}
|
|
@@ -599,6 +935,13 @@ export default {
|
|
|
599
935
|
ts: Date.now(),
|
|
600
936
|
mode: isCreate ? 'create' : 'replace',
|
|
601
937
|
}
|
|
938
|
+
// FIX multi-sesión: persistir lastValidated. Antes se mutaba un objeto
|
|
939
|
+
// local y se descartaba → complete_task leía un lastValidated viejo
|
|
940
|
+
// desde disco → CONFLICT → las tareas quedaban pending y el cierre
|
|
941
|
+
// (implementation_complete) se bloqueaba.
|
|
942
|
+
// `["lastValidated"]` = se escribe SOLO esa clave sobre el estado
|
|
943
|
+
// fresco que se re-lee bajo el lock compartido con el MCP.
|
|
944
|
+
persistState(directory, st, ["lastValidated"])
|
|
602
945
|
}
|
|
603
946
|
} catch {}
|
|
604
947
|
}
|
|
@@ -609,24 +952,52 @@ export default {
|
|
|
609
952
|
// Track discovery hit to allow subsequent Reads
|
|
610
953
|
const tool: string = event.tool as string
|
|
611
954
|
const sessionId: string = (event.sessionID ?? "default") as string
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
955
|
+
// (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en ESTA
|
|
956
|
+
// sesión abre el gate de lecturas de código. Se marca aunque el resultado venga vacío, a
|
|
957
|
+
// propósito: "CodeGraph no tiene este archivo en su índice" es justamente el caso donde el
|
|
958
|
+
// Read TIENE que poder pasar si hubo `codegraph_explore`.
|
|
959
|
+
if (marksDiscovery(tool)) {
|
|
615
960
|
try {
|
|
616
|
-
|
|
617
|
-
if (text && !text.includes("null") && text.length > 10) {
|
|
618
|
-
discoveryHitByRequest.set(sessionId, true)
|
|
619
|
-
}
|
|
620
|
-
// Engram hit via discovery snapshot (contiene engramHits)
|
|
621
|
-
if (text && text.includes("engramHits")) {
|
|
622
|
-
engramHitBySession.set(sessionId, true)
|
|
623
|
-
}
|
|
961
|
+
if (event.status === "completed") discoveryHitByRequest.set(sessionId, true)
|
|
624
962
|
} catch {}
|
|
625
963
|
}
|
|
626
964
|
// Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
|
|
627
|
-
if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("
|
|
965
|
+
if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("engram_mem_")) {
|
|
628
966
|
engramHitBySession.set(sessionId, true)
|
|
629
967
|
}
|
|
968
|
+
// test-verification-execution: evidencia para tasks `test:`.
|
|
969
|
+
// `status` miente con exit != 0 (ver shellEvidence): sólo se registra con exit numérico.
|
|
970
|
+
// D10 respectado: NO cada shell persiste (git/ls/builds no tienen valor de evidencia y
|
|
971
|
+
// cada persist cuesta lock + re-read + write + 3 renames de backup). Se persisten sólo
|
|
972
|
+
// las corridas cuyo comando matchea un `test:` declarado en taskVerifications — que son
|
|
973
|
+
// las ÚNICAS que complete_task / verifyIntegrity consultan via findTestEvidence.
|
|
974
|
+
// Tradeoff fail-closed: si taskVerifications aún no llegó al state cuando se corre el
|
|
975
|
+
// test, la evidencia no se registra → complete_task pide re-correr (razón explícita).
|
|
976
|
+
try {
|
|
977
|
+
const ev = shellEvidence(event as any)
|
|
978
|
+
// Un shell que escribe (exit 0) también invalida evidencia de test:
|
|
979
|
+
// si no, `sed -i` mutaría el código sin avanzar el timestamp.
|
|
980
|
+
const mutating =
|
|
981
|
+
(isMutatingTool(tool) && event.status === "completed") ||
|
|
982
|
+
(!!ev && ev.exit === 0 && isShellMutation(ev.cmd))
|
|
983
|
+
const isTestRun = !!ev && testVerifyCommands(readState(directory)).has(ev.cmd)
|
|
984
|
+
if (isTestRun || mutating) {
|
|
985
|
+
const s = readState(directory)
|
|
986
|
+
if (s) {
|
|
987
|
+
const owned: string[] = []
|
|
988
|
+
if (ev && isTestRun) {
|
|
989
|
+
const prev = Array.isArray((s as any).shellRuns) ? (s as any).shellRuns : []
|
|
990
|
+
;(s as any).shellRuns = [...prev, ev].slice(-25)
|
|
991
|
+
owned.push("shellRuns")
|
|
992
|
+
}
|
|
993
|
+
if (mutating) {
|
|
994
|
+
;(s as any).lastMutationTs = Date.now()
|
|
995
|
+
owned.push("lastMutationTs")
|
|
996
|
+
}
|
|
997
|
+
if (owned.length > 0) persistState(directory, s, owned)
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
} catch {}
|
|
630
1001
|
// Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
|
|
631
1002
|
try {
|
|
632
1003
|
const s = readState(directory)
|
|
@@ -636,7 +1007,7 @@ export default {
|
|
|
636
1007
|
const shouldPersist = process.env.OSTACKY_PLUGIN_PERSIST === "1" || (now - (s.lastHeartbeat || 0) > 30000)
|
|
637
1008
|
if (shouldPersist) {
|
|
638
1009
|
s.lastHeartbeat = now
|
|
639
|
-
try { persistState(directory, s) } catch {}
|
|
1010
|
+
try { persistState(directory, s, ["lastHeartbeat"]) } catch {}
|
|
640
1011
|
}
|
|
641
1012
|
}
|
|
642
1013
|
} catch {}
|
|
@@ -36,12 +36,12 @@ Do NOT invoke any implementation skill, write any code, or scaffold any project
|
|
|
36
36
|
|
|
37
37
|
### Process
|
|
38
38
|
|
|
39
|
-
1. **
|
|
39
|
+
1. **Reusar discovery** — si ya llamaste `codegraph_codegraph_explore` para el área en este request, reusá ese output; si no, `engram_mem_search` + `codegraph_codegraph_explore`. No repitas la misma query sin motivo. No hay cache de archivo.
|
|
40
40
|
2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria
|
|
41
41
|
4. **Propose 2-3 approaches (hardening-v2 — SHALL)** — con tabla trade-offs (coste|riesgo|complejidad) + evidencia CodeGraph+Engram sin alucinar, YAGNI, y recomendación con razón; cada approach cita symbols existentes y mem_search hits verificables
|
|
42
42
|
5. **Present design** — in sections scaled to complexity, get user approval after each section. **Gate post-brainstorming (hardening-v2):** tras presentar diseño, preguntar "¿Procedo con este diseño o querés ajustar algo?" y esperar confirmación explícita antes de `record_discovery`, `openspec-propose` o implementación directa
|
|
43
43
|
6. **Write design doc (output path condicional — router exclusivo) — SHALL read fresco + edit no write + sync proactiva:**
|
|
44
|
-
- Antes de `write` SHALL `read` fresco de `openspec/changes/<id>/{proposal,design,tasks}` o `docs/superpowers/specs/*.md` si existen; si existen SHALL `edit` (no `write`); `write` solo si no existía.
|
|
44
|
+
- Antes de `write` SHALL `read` fresco de `openspec/changes/<id>/{proposal,design,tasks}` o `docs/superpowers/specs/*.md` si existen; si existen SHALL `edit` (no `write`); `write` solo si no existía. El discovery no aplica a specs/docs. Para `open-explore` sin archivo SHALL `mem_save topic_key:brainstorm/<hash>` por iteración.
|
|
45
45
|
- Si trigger + `level 1+` no-downgradeable (`estLines>30` o `fileCount>2` o API pública) y change activo → escribir `openspec/changes/<id>/design.md` sección `## Alternatives Considered` con 2-3 approaches (tabla coste|riesgo|complejidad + evidencia CodeGraph+Engram), no `docs/`. No invocar `openspec-propose` separado — este es el diseño. Si ya existe, SHALL `edit` no `write`.
|
|
46
46
|
- Si trigger + `0/0+1` o `1+` downgradeable (`estLines<30`&&`fileCount==1`&&sin API) → solo `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` + gate único `¿Procedo?` → DIRECT sin change. SHALL sugerir downgrade: `"Esto parece 0+1 (~X líneas, 1 archivo). ¿Lo tratamos sin spec?"` y esperar. Si `docsSpec` ya existe SHALL `read` + `edit`.
|
|
47
47
|
- Gate post-brainstorming es **único**; no hay segundo `¿Procedo con spec?`. `get_audit` `WARN:duplicate_design_generated` si ambos artefactos mismo `requestId`.
|