ostacky 0.10.0 → 0.10.2
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 +15 -22
- package/assets/agents/ostacky.md +10 -9
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +41 -6
- package/assets/mcp/ostacky-controller/index.js +183 -31
- package/assets/plugins/ostacky-controller/index.ts +362 -45
- 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 +83 -72
- package/manifest.json +32 -32
- package/package.json +2 -1
|
@@ -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
|
*/
|
|
@@ -152,6 +152,34 @@ export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[
|
|
|
152
152
|
return payload
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* multisession-ownership-guard: hay UN state file por proyecto, así que el PENDING
|
|
157
|
+
* puede ser de otra sesión. El gate no debe congelar sesiones ajenas a esa request
|
|
158
|
+
* (antes, cualquier sesión quedaba bloqueada por la confirmación pendiente de otra).
|
|
159
|
+
*
|
|
160
|
+
* No aplica cuando no hay dueño (estado legacy), cuando el caller es el dueño, ni
|
|
161
|
+
* cuando el caller es desconocido (`event.sessionID` ausente → "default"): sin
|
|
162
|
+
* identidad no se puede probar ajeneidad.
|
|
163
|
+
*/
|
|
164
|
+
export function isForeignPendingOwner(state: any, sessionId: string | null | undefined): boolean {
|
|
165
|
+
const owner = state?.ownerSessionId
|
|
166
|
+
if (!owner || !sessionId || sessionId === "default") return false
|
|
167
|
+
return owner !== sessionId
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Cache del gate PENDING. El cache es por `revision`, NO por dueño: si el paso de
|
|
172
|
+
* una sesión ajena dejara un ALLOW, la dueña lo reutilizaría y se saltearía su
|
|
173
|
+
* propia confirmación. Por eso un PENDING ajeno no cachea nada.
|
|
174
|
+
*/
|
|
175
|
+
export function nextPendingGateCache(
|
|
176
|
+
revision: number,
|
|
177
|
+
foreignPendingOwner: boolean
|
|
178
|
+
): { revision: number; result: string } | null {
|
|
179
|
+
if (foreignPendingOwner) return null
|
|
180
|
+
return { revision, result: "ALLOW" }
|
|
181
|
+
}
|
|
182
|
+
|
|
155
183
|
/**
|
|
156
184
|
* Persiste estado SIN poder pisar al MCP.
|
|
157
185
|
*
|
|
@@ -228,25 +256,6 @@ function isPathInsideProject(filePath: string, directory: string): boolean {
|
|
|
228
256
|
} catch { return false }
|
|
229
257
|
}
|
|
230
258
|
|
|
231
|
-
function getDiscoveryCacheHit(directory: string): boolean {
|
|
232
|
-
try {
|
|
233
|
-
const cacheDir = join(directory, ".opencode", "cache", "codegraph")
|
|
234
|
-
if (!existsSync(cacheDir)) return false
|
|
235
|
-
const files = readdirSync(cacheDir).filter((f: string) => f.startsWith("discovery-"))
|
|
236
|
-
if (files.length === 0) return false
|
|
237
|
-
// check if any file is recent (<1h) and valid
|
|
238
|
-
const now = Date.now()
|
|
239
|
-
for (const f of files) {
|
|
240
|
-
try {
|
|
241
|
-
const raw = readFileSync(join(cacheDir, f), "utf-8")
|
|
242
|
-
const data = JSON.parse(raw)
|
|
243
|
-
if (typeof data.ts === "number" && now - data.ts < 60*60*1000) return true
|
|
244
|
-
} catch {}
|
|
245
|
-
}
|
|
246
|
-
return false
|
|
247
|
-
} catch { return false }
|
|
248
|
-
}
|
|
249
|
-
|
|
250
259
|
function isCodegraphAvailable(directory: string): boolean {
|
|
251
260
|
// If codegraph binary exists, assume OK. Otherwise fallback allows Read.
|
|
252
261
|
try {
|
|
@@ -259,6 +268,231 @@ function isCodegraphAvailable(directory: string): boolean {
|
|
|
259
268
|
} catch { return false }
|
|
260
269
|
}
|
|
261
270
|
|
|
271
|
+
/**
|
|
272
|
+
* ¿Este read/grep/glob esta sujeto al gate de discovery?
|
|
273
|
+
* Exenciones: docs/json/md, openspec/changes|specs, grep de literales, y archivos ya
|
|
274
|
+
* trackeados (refresh honesto post-complete necesita Read fresco sin Discovery hit).
|
|
275
|
+
*/
|
|
276
|
+
export function isCodeReadGated(input: {
|
|
277
|
+
isCodeFile: boolean
|
|
278
|
+
isLiteralGrep: boolean
|
|
279
|
+
isSpecTasksPath: boolean
|
|
280
|
+
isDocsPath: boolean
|
|
281
|
+
isTaskTracked: boolean
|
|
282
|
+
}): boolean {
|
|
283
|
+
return (
|
|
284
|
+
input.isCodeFile &&
|
|
285
|
+
!input.isLiteralGrep &&
|
|
286
|
+
!input.isSpecTasksPath &&
|
|
287
|
+
!input.isDocsPath &&
|
|
288
|
+
!input.isTaskTracked
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Predicado puro del gate de CodeGraph: ¿bloquear esta lectura de codigo?
|
|
294
|
+
*
|
|
295
|
+
* Extraido a funcion para poder testear el INVARIANTE, no el mensaje. El test anterior hacia
|
|
296
|
+
* `expect(src).toContain("Usa getDiscoverySnapshot primero")`: verificaba que el texto existiera
|
|
297
|
+
* y pasaba verde con el gate inescapable adentro (fix-codegraph-gate).
|
|
298
|
+
*/
|
|
299
|
+
export function shouldBlockCodeRead(input: {
|
|
300
|
+
isCodeFile: boolean
|
|
301
|
+
isLiteralGrep: boolean
|
|
302
|
+
isSpecTasksPath: boolean
|
|
303
|
+
isDocsPath: boolean
|
|
304
|
+
isTaskTracked: boolean
|
|
305
|
+
codegraphOk: boolean
|
|
306
|
+
hasDiscoveryHit: boolean
|
|
307
|
+
}): boolean {
|
|
308
|
+
return isCodeReadGated(input) && input.codegraphOk && !input.hasDiscoveryHit
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Tools cuyo completado constituye evidencia REAL de discovery estructural en la sesion.
|
|
313
|
+
*
|
|
314
|
+
* `codegraph_explore` es la senal fuerte: llamada real, resultado inspeccionable por el plugin.
|
|
315
|
+
* No hay cache de discovery: `getDiscoverySnapshot` nunca existió y se retiró.
|
|
316
|
+
*
|
|
317
|
+
* Historia (fix-codegraph-gate): antes la UNICA senal era `getDiscoverySnapshot`, una tool que nunca
|
|
318
|
+
* se registro ni en el plugin ni en el MCP. `hasDiscoveryHit` era siempre false y el gate bloqueaba
|
|
319
|
+
* todo read/grep/glob de codigo sin salida posible; un `codegraph_explore` completado NO lo abria,
|
|
320
|
+
* contradiciendo la instruccion "CodeGraph primero" del agente.
|
|
321
|
+
*/
|
|
322
|
+
export function marksDiscovery(toolName: string): boolean {
|
|
323
|
+
return (
|
|
324
|
+
toolName.includes("codegraph_explore")
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Predicado puro del horizonte del gate de discovery: ¿este prompt invalida el
|
|
330
|
+
* hit estructural de la sesión?
|
|
331
|
+
*
|
|
332
|
+
* El contrato de reuso es POR REQUEST ("reuso del output ya obtenido en el request",
|
|
333
|
+
* remove-discovery-cache-phantom archivado 2026-10-19), no por sesión: la instrucción
|
|
334
|
+
* §1 del agente y record_discovery son por request; el gate era el único desalineado.
|
|
335
|
+
* Los prompts TRIVIALES no invalidan: el plugin les inyecta "No hagas Discovery"
|
|
336
|
+
* (hint :555) — limpiar el hit ahí crearía instrucción y gate contradictorias.
|
|
337
|
+
*/
|
|
338
|
+
export function shouldInvalidateDiscoveryHit(trivial: boolean): boolean {
|
|
339
|
+
return !trivial
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Mismo criterio de "archivo de código" que el gate de read (index.ts:552),
|
|
344
|
+
* sin la cláusula de `pattern` que es específica de glob. Docs/JSON quedan fuera:
|
|
345
|
+
* un .md bajo assets/ es documentación, no código.
|
|
346
|
+
*/
|
|
347
|
+
export function isCodePath(p: string): boolean {
|
|
348
|
+
if (!p) return false
|
|
349
|
+
if (/\.(md|json)$/i.test(p)) return false
|
|
350
|
+
return /\.(ts|js|tsx|jsx|mts|cts)$/i.test(p) || p.includes("src/") || p.includes("assets/")
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Evidencia de una corrida de shell para las tasks `test:` (change
|
|
355
|
+
* test-verification-execution). Devuelve null si el evento no permite afirmar
|
|
356
|
+
* nada: sin comando o sin `exit` numérico.
|
|
357
|
+
*
|
|
358
|
+
* NO consulta `status`: medido en OpenCode 2.0.32, `sh -c 'exit 3'` llega con
|
|
359
|
+
* `status: "completed"` y `result.metadata.status: "completed"`. El único dato
|
|
360
|
+
* veraz es `result.metadata.exit`.
|
|
361
|
+
*/
|
|
362
|
+
export function shellEvidence(event: any): { cmd: string; exit: number; ts: number } | null {
|
|
363
|
+
if (!event || event.tool !== "shell") return null
|
|
364
|
+
const cmd = event.input?.command
|
|
365
|
+
const exit = event.result?.metadata?.exit
|
|
366
|
+
if (typeof cmd !== "string" || !cmd.trim()) return null
|
|
367
|
+
if (typeof exit !== "number") return null
|
|
368
|
+
return { cmd: cmd.trim(), exit, ts: Date.now() }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Tools que mutan archivos: avanzan `lastMutationTs`. */
|
|
372
|
+
export function isMutatingTool(tool: string): boolean {
|
|
373
|
+
return tool === "edit" || tool === "write" || tool === "patch"
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Comando shell que escribe en disco. Predicado compartido por el gate de
|
|
378
|
+
* mutación y por `lastMutationTs`: si no se compartiera, un `sed -i` aprobado
|
|
379
|
+
* por el gate mutaría el código sin avanzar el timestamp, y una corrida de test
|
|
380
|
+
* previa seguiría contando como evidencia.
|
|
381
|
+
*/
|
|
382
|
+
export function isShellMutation(command: string): boolean {
|
|
383
|
+
if (!command) return false
|
|
384
|
+
// `2>&1`, `1>&2`, `2>/dev/null` son redirects de stderr, no mutación.
|
|
385
|
+
const cmdSansStderr = command.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
|
|
386
|
+
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)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Verbos cuyo output revela el CONTENIDO de un archivo. No incluye verbos que
|
|
391
|
+
* sólo mencionan paths (`bun test x.ts`, `tsc src/x.ts`, `git diff`): el gate
|
|
392
|
+
* distingue "revela" de "menciona" — ver change shell-code-read-gate.
|
|
393
|
+
*/
|
|
394
|
+
const SHELL_READ_VERBS = new Set([
|
|
395
|
+
"cat", "head", "tail", "sed", "awk", "grep", "rg", "ag", "ack", "less", "more",
|
|
396
|
+
"nl", "bat", "tac", "strings", "xxd", "od", "base64", "diff", "cut", "paste",
|
|
397
|
+
"sort", "uniq", "column", "expand", "fold",
|
|
398
|
+
])
|
|
399
|
+
|
|
400
|
+
const SHELL_WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "nohup"])
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Paths de código que `command` REVELA (no los que menciona).
|
|
404
|
+
*
|
|
405
|
+
* Fail-open por diseño: redirección => escritura, `sed -i` => escritura, verbo
|
|
406
|
+
* fuera de la allowlist => []. Un gate de shell nunca es hermético; el objetivo
|
|
407
|
+
* es quitar el bypass casual (`cat src/x.ts`), no demostrar imposibilidad.
|
|
408
|
+
* Bypass residual documentado en el spec: `git show`, `python -c`, `node -e`,
|
|
409
|
+
* `find -exec`, `xargs`.
|
|
410
|
+
*/
|
|
411
|
+
export function shellReadTargets(command: string): string[] {
|
|
412
|
+
if (!command) return []
|
|
413
|
+
const targets: string[] = []
|
|
414
|
+
// Split por pipes/separadores, NO por `>`: necesitamos ver la redirección.
|
|
415
|
+
const segments = command.replace(/&&/g, ";").replace(/\|\|/g, ";").split(/[|;\n]+/)
|
|
416
|
+
for (const seg of segments) {
|
|
417
|
+
const trimmed = seg
|
|
418
|
+
// Redirects de stderr no son escritura: sin esto, `cat src/x.ts 2>&1`
|
|
419
|
+
// pasaba el check `>` de abajo y se colaba del gate (bypass casual).
|
|
420
|
+
// Mismo criterio que isShellMutation, el sibling de este módulo.
|
|
421
|
+
.replace(/2>&1/g, "")
|
|
422
|
+
.replace(/1>&2/g, "")
|
|
423
|
+
.replace(/2>\s*\/dev\/null/g, "")
|
|
424
|
+
.trim()
|
|
425
|
+
if (!trimmed) continue
|
|
426
|
+
// Escritura real (cat > f, tee, heredoc): no es lectura.
|
|
427
|
+
if (/>/.test(trimmed)) continue
|
|
428
|
+
const tokens = (trimmed.match(/(?:[^\s"'`\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || [])
|
|
429
|
+
.map((t) => t.replace(/["'`]/g, "").replace(/\\/g, ""))
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
if (tokens.length === 0) continue
|
|
432
|
+
let i = 0
|
|
433
|
+
while (i < tokens.length && SHELL_WRAPPERS.has(tokens[i])) i++
|
|
434
|
+
const verb = tokens[i] || ""
|
|
435
|
+
const base = verb.split("/").pop() || verb
|
|
436
|
+
if (!SHELL_READ_VERBS.has(base)) continue
|
|
437
|
+
// sed -i es escritura in-place, no lectura.
|
|
438
|
+
if (base === "sed" && tokens.some((t) => t === "-i" || t.startsWith("-i") || t === "--in-place" || t.startsWith("--in-place"))) continue
|
|
439
|
+
// diff -q/--brief/--stat sólo compara metadatos: no revela contenido.
|
|
440
|
+
if (base === "diff" && tokens.some((t) => t === "-q" || t === "--quiet" || t === "--brief" || t === "--stat" || /^-[a-zA-Z]*q[a-zA-Z]*$/.test(t))) continue
|
|
441
|
+
for (const p of extractPathsFromBash(trimmed)) {
|
|
442
|
+
if (isCodePath(p)) targets.push(p)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return [...new Set(targets)]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Predicado puro del gate de shell: ¿leer este path por shell está EXENTO?
|
|
450
|
+
*
|
|
451
|
+
* Extraído del hook para poder testear el INVARIANTE (mismo criterio que
|
|
452
|
+
* shouldBlockCodeRead). Exenciones:
|
|
453
|
+
* 1. Paths FUERA del proyecto: no son código de este codebase (fixtures
|
|
454
|
+
* temporales de tests, reportes generados); bloquearlos rompe pipelines sin
|
|
455
|
+
* escape hatch — el falso positivo que el diseño prohibió.
|
|
456
|
+
* 2. Specs/docs (.md/.json, openspec, assets/docs).
|
|
457
|
+
* 3. Archivos ya trackeados en tasks/fileFingerprints/lastValidated (el refresh
|
|
458
|
+
* honesto post-complete necesita re-leerlos).
|
|
459
|
+
*/
|
|
460
|
+
export function isShellPathExempt(p: string, directory: string, freshState: any): boolean {
|
|
461
|
+
if (!isPathInsideProject(p, directory)) return true
|
|
462
|
+
if (p.includes("openspec/changes") || p.includes("openspec/specs")) return true
|
|
463
|
+
if (p.includes("assets/docs") || p.includes("/docs/") || /\.md$/i.test(p) || /\.json$/i.test(p)) return true
|
|
464
|
+
try {
|
|
465
|
+
if (freshState) {
|
|
466
|
+
const fps = Object.keys(freshState.fileFingerprints || {})
|
|
467
|
+
const taskFiles = Object.values(freshState.tasks || {}).map((t: any) => t.filePath).filter(Boolean)
|
|
468
|
+
const lv: any = (freshState as any).lastValidated || null
|
|
469
|
+
const lvByFile = lv?.byFile && typeof lv.byFile === "object" ? lv.byFile : null
|
|
470
|
+
const lvPaths: string[] = [
|
|
471
|
+
...(lvByFile ? Object.keys(lvByFile) : []),
|
|
472
|
+
...(lv?.filePath ? [lv.filePath as string] : []),
|
|
473
|
+
]
|
|
474
|
+
const matches = (f: string): boolean => p.endsWith(f) || f.endsWith(p) || p.includes(f) || f.includes(p)
|
|
475
|
+
if (fps.some(matches) || taskFiles.some(matches)) return true
|
|
476
|
+
if (lvPaths.some((lf) => p.endsWith(lf) || lf.endsWith(p))) return true
|
|
477
|
+
}
|
|
478
|
+
} catch {}
|
|
479
|
+
return false
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Comandos `test:` declarados en taskVerifications, ya normalizados (sin prefijo). */
|
|
483
|
+
export function testVerifyCommands(state: any): Set<string> {
|
|
484
|
+
const out = new Set<string>()
|
|
485
|
+
try {
|
|
486
|
+
for (const v of Object.values((state as any)?.taskVerifications || {})) {
|
|
487
|
+
if (typeof v === "string" && v.trim().startsWith("test:")) {
|
|
488
|
+
const cmd = v.trim().slice("test:".length).trim()
|
|
489
|
+
if (cmd) out.add(cmd)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {}
|
|
493
|
+
return out
|
|
494
|
+
}
|
|
495
|
+
|
|
262
496
|
function jsonContent(value: unknown): { content: string } {
|
|
263
497
|
try {
|
|
264
498
|
return { content: JSON.stringify(value) ?? "null" }
|
|
@@ -343,9 +577,25 @@ export default {
|
|
|
343
577
|
const sessionId: string = event.sessionID ?? "default"
|
|
344
578
|
const text = (event.prompt.text ?? "").trim()
|
|
345
579
|
const state = readState(directory)
|
|
580
|
+
// task-verification-integrity D4: registrar el texto ORIGINAL del usuario (antes de los
|
|
581
|
+
// hints de abajo) para que `implementation_complete({force:true})` se apoye en evidencia
|
|
582
|
+
// real y no en el audit, que lo redacta el agente. `ownedKeys` evita pisar el avance del MCP.
|
|
583
|
+
// Cada prompt lleva `ts`: el MCP sólo acepta confirmaciones POSTERIORES al último
|
|
584
|
+
// intento de force, así un "¿qué hace force:true?" viejo no abre el gate.
|
|
585
|
+
if (text && state) {
|
|
586
|
+
try {
|
|
587
|
+
const prev = Array.isArray((state as any).lastUserPrompts) ? (state as any).lastUserPrompts : []
|
|
588
|
+
;(state as any).lastUserPrompts = [...prev, { text: text.slice(0, 500), ts: Date.now() }].slice(-5)
|
|
589
|
+
persistState(directory, state, ["lastUserPrompts"])
|
|
590
|
+
} catch {}
|
|
591
|
+
}
|
|
346
592
|
const currentState = state?.state ?? "DONE"
|
|
347
593
|
const trivial = isTrivial(text, currentState)
|
|
348
594
|
trivialBySession.set(sessionId, trivial)
|
|
595
|
+
// Horizonte por request: prompts NO-triviales invalidan el discovery hit —
|
|
596
|
+
// la siguiente lectura de código exige un explore fresco (contrato
|
|
597
|
+
// remove-discovery-cache-phantom + instrucción §1 del agente).
|
|
598
|
+
if (shouldInvalidateDiscoveryHit(trivial)) discoveryHitByRequest.delete(sessionId)
|
|
349
599
|
if (trivial) {
|
|
350
600
|
// Preserve system FULL cacheable, add suffix hint to user prompt text
|
|
351
601
|
const hint = "\n\n[PLUGIN HINT: Saludo trivial — responde breve sin tools. No hagas Discovery.]"
|
|
@@ -402,7 +652,9 @@ export default {
|
|
|
402
652
|
checkCount++
|
|
403
653
|
const freshState = readState(directory)
|
|
404
654
|
const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
|
|
405
|
-
|
|
655
|
+
// multisession-ownership-guard: el PENDING de OTRA sesión no congela esta sesión.
|
|
656
|
+
const foreignPendingOwner = !!freshState && isForeignPendingOwner(freshState, sessionId)
|
|
657
|
+
if (freshState && pendingStates.includes(freshState.state) && !foreignPendingOwner) {
|
|
406
658
|
// cache ALLOW per revision, BLOCKED never cached
|
|
407
659
|
if (lastCheck && freshState.revision === lastCheck.revision && checkCount % 5 !== 0 && lastCheck.result === "ALLOW") {
|
|
408
660
|
// reuse cached ALLOW
|
|
@@ -424,7 +676,7 @@ export default {
|
|
|
424
676
|
}
|
|
425
677
|
}
|
|
426
678
|
} else if (freshState) {
|
|
427
|
-
lastCheck =
|
|
679
|
+
lastCheck = nextPendingGateCache(freshState.revision, foreignPendingOwner)
|
|
428
680
|
}
|
|
429
681
|
|
|
430
682
|
// ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
|
|
@@ -519,14 +771,30 @@ export default {
|
|
|
519
771
|
(lvFile ? lvFile.some((lf) => targetPath.endsWith(lf) || lf.endsWith(targetPath)) : false)
|
|
520
772
|
}
|
|
521
773
|
} catch {}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
774
|
+
// Orden cheap -> caro: el path no hace I/O; codegraph y la cache en disco solo se evalúan
|
|
775
|
+
// si el path esta realmente sujeto al gate (`read`/`grep`/`glob` corren en cada turno).
|
|
776
|
+
const gated = isCodeReadGated({ isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked })
|
|
777
|
+
const codegraphOk = gated ? isCodegraphAvailable(directory) : false
|
|
778
|
+
// Sin fallback a disco: la única evidencia válida es un codegraph_explore
|
|
779
|
+
// COMPLETADO en este request (discoveryHitByRequest; el hook "prompt"
|
|
780
|
+
// invalida el hit en cada prompt no-trivial). session.deleted también lo limpia.
|
|
781
|
+
const hasDiscoveryHit = codegraphOk ? discoveryHitByRequest.get(sessionId) === true : false
|
|
782
|
+
if (shouldBlockCodeRead({
|
|
783
|
+
isCodeFile, isLiteralGrep, isSpecTasksPath, isDocsPath, isTaskTracked, codegraphOk, hasDiscoveryHit,
|
|
784
|
+
})) {
|
|
785
|
+
// Métrica de evasión real (misma convención que engramBypassCount:648):
|
|
786
|
+
// cuántas lecturas de código saltaron el gate sin explore completado.
|
|
787
|
+
try {
|
|
788
|
+
const st = readState(directory)
|
|
789
|
+
if (st) {
|
|
790
|
+
st.codeReadBlockedCount = (st.codeReadBlockedCount || 0) + 1
|
|
791
|
+
persistState(directory, st, ["codeReadBlockedCount"])
|
|
792
|
+
}
|
|
793
|
+
} catch {}
|
|
794
|
+
try { console.warn(`[ostacky] code_read_blocked: sin codegraph_explore completado en sesión (${targetPath || "query"})`) } catch {}
|
|
795
|
+
throw new Error(
|
|
796
|
+
`BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${targetPath || "query"}") antes de Read/Grep/Glob de código`
|
|
797
|
+
)
|
|
530
798
|
}
|
|
531
799
|
}
|
|
532
800
|
|
|
@@ -560,12 +828,32 @@ export default {
|
|
|
560
828
|
}
|
|
561
829
|
}
|
|
562
830
|
|
|
831
|
+
// ── 3.2) CodeGraph gate para shell: leer código sin discovery → BLOCKED ──
|
|
832
|
+
// El gate de código (arriba) sólo mira read/grep/glob; `cat src/x.ts` revela
|
|
833
|
+
// lo mismo y no costaba nada. Fail-open a propósito: `read` falla cerrado
|
|
834
|
+
// porque es inequívoco; `shell` es ambiguo y un falso positivo rompe tests o
|
|
835
|
+
// builds sin escape hatch para el usuario (change shell-code-read-gate).
|
|
836
|
+
if (tool === "shell") {
|
|
837
|
+
const cmdForRead: string = args?.command || args?.cmd || ""
|
|
838
|
+
if (typeof cmdForRead === "string" && cmdForRead) {
|
|
839
|
+
const shellTargets = shellReadTargets(cmdForRead)
|
|
840
|
+
if (shellTargets.length > 0) {
|
|
841
|
+
const gatedShell = shellTargets.filter((p) => !isShellPathExempt(p, directory, freshState))
|
|
842
|
+
const codegraphOkShell = gatedShell.length > 0 ? isCodegraphAvailable(directory) : false
|
|
843
|
+
const hasDiscoveryShell = codegraphOkShell ? discoveryHitByRequest.get(sessionId) === true : false
|
|
844
|
+
if (gatedShell.length > 0 && codegraphOkShell && !hasDiscoveryShell) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`BLOCKED: sin discovery estructural en esta sesión — llamá codegraph_codegraph_explore("${gatedShell[0]}") antes de leer código por shell`
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
563
853
|
// ── 3.5) Shell file-mutation gate (harden-task-integrity: shell mutante requiere validate_edit) ──
|
|
564
854
|
if (tool === "shell" && freshState && ["EXECUTING_INLINE", "EXECUTING_SUBAGENTS"].includes(freshState.state)) {
|
|
565
855
|
const cmdForMutation: string = args?.command || args?.cmd || ""
|
|
566
|
-
|
|
567
|
-
const cmdSansStderr = cmdForMutation.replace(/2>&1/g, "").replace(/1>&2/g, "").replace(/2>\s*\/dev\/null/g, "")
|
|
568
|
-
const isMutating = /(^|[^0-9&])>{1,2}\s*(?!&)\S+|\bsed\b[^|;]*-i|\btruncate\b|\btee\b|\bcp\s+|\bmv\s+|python.*open.*w/.test(cmdSansStderr)
|
|
856
|
+
const isMutating = isShellMutation(cmdForMutation)
|
|
569
857
|
if (isMutating) {
|
|
570
858
|
const mPaths = extractPathsFromBash(cmdForMutation)
|
|
571
859
|
for (const p of mPaths) {
|
|
@@ -722,24 +1010,53 @@ export default {
|
|
|
722
1010
|
// Track discovery hit to allow subsequent Reads
|
|
723
1011
|
const tool: string = event.tool as string
|
|
724
1012
|
const sessionId: string = (event.sessionID ?? "default") as string
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1013
|
+
// (fix-codegraph-gate) Evidencia REAL de discovery: un `codegraph_explore` completado en ESTE
|
|
1014
|
+
// request abre el gate de lecturas de código (el hook "prompt" invalida el hit en cada
|
|
1015
|
+
// prompt no-trivial). Se marca aunque el resultado venga vacío, a propósito:
|
|
1016
|
+
// "CodeGraph no tiene este archivo en su índice" es justamente el caso donde el
|
|
1017
|
+
// Read TIENE que poder pasar si hubo `codegraph_explore`.
|
|
1018
|
+
if (marksDiscovery(tool)) {
|
|
728
1019
|
try {
|
|
729
|
-
|
|
730
|
-
if (text && !text.includes("null") && text.length > 10) {
|
|
731
|
-
discoveryHitByRequest.set(sessionId, true)
|
|
732
|
-
}
|
|
733
|
-
// Engram hit via discovery snapshot (contiene engramHits)
|
|
734
|
-
if (text && text.includes("engramHits")) {
|
|
735
|
-
engramHitBySession.set(sessionId, true)
|
|
736
|
-
}
|
|
1020
|
+
if (event.status === "completed") discoveryHitByRequest.set(sessionId, true)
|
|
737
1021
|
} catch {}
|
|
738
1022
|
}
|
|
739
1023
|
// Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
|
|
740
|
-
if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("
|
|
1024
|
+
if (tool.includes("engram_mem_search") || tool.includes("engram_mem_context") || tool.includes("mem_search") || tool.includes("mem_context") || tool.includes("engram_mem_")) {
|
|
741
1025
|
engramHitBySession.set(sessionId, true)
|
|
742
1026
|
}
|
|
1027
|
+
// test-verification-execution: evidencia para tasks `test:`.
|
|
1028
|
+
// `status` miente con exit != 0 (ver shellEvidence): sólo se registra con exit numérico.
|
|
1029
|
+
// D10 respectado: NO cada shell persiste (git/ls/builds no tienen valor de evidencia y
|
|
1030
|
+
// cada persist cuesta lock + re-read + write + 3 renames de backup). Se persisten sólo
|
|
1031
|
+
// las corridas cuyo comando matchea un `test:` declarado en taskVerifications — que son
|
|
1032
|
+
// las ÚNICAS que complete_task / verifyIntegrity consultan via findTestEvidence.
|
|
1033
|
+
// Tradeoff fail-closed: si taskVerifications aún no llegó al state cuando se corre el
|
|
1034
|
+
// test, la evidencia no se registra → complete_task pide re-correr (razón explícita).
|
|
1035
|
+
try {
|
|
1036
|
+
const ev = shellEvidence(event as any)
|
|
1037
|
+
// Un shell que escribe (exit 0) también invalida evidencia de test:
|
|
1038
|
+
// si no, `sed -i` mutaría el código sin avanzar el timestamp.
|
|
1039
|
+
const mutating =
|
|
1040
|
+
(isMutatingTool(tool) && event.status === "completed") ||
|
|
1041
|
+
(!!ev && ev.exit === 0 && isShellMutation(ev.cmd))
|
|
1042
|
+
const isTestRun = !!ev && testVerifyCommands(readState(directory)).has(ev.cmd)
|
|
1043
|
+
if (isTestRun || mutating) {
|
|
1044
|
+
const s = readState(directory)
|
|
1045
|
+
if (s) {
|
|
1046
|
+
const owned: string[] = []
|
|
1047
|
+
if (ev && isTestRun) {
|
|
1048
|
+
const prev = Array.isArray((s as any).shellRuns) ? (s as any).shellRuns : []
|
|
1049
|
+
;(s as any).shellRuns = [...prev, ev].slice(-25)
|
|
1050
|
+
owned.push("shellRuns")
|
|
1051
|
+
}
|
|
1052
|
+
if (mutating) {
|
|
1053
|
+
;(s as any).lastMutationTs = Date.now()
|
|
1054
|
+
owned.push("lastMutationTs")
|
|
1055
|
+
}
|
|
1056
|
+
if (owned.length > 0) persistState(directory, s, owned)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
} catch {}
|
|
743
1060
|
// Heartbeat: single-writer — plugin memo, no persist cada tool (D10)
|
|
744
1061
|
try {
|
|
745
1062
|
const s = readState(directory)
|
|
@@ -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`.
|
|
@@ -25,14 +25,13 @@ Determinar el modo de ejecución óptimo entre **inline** y **subagent-driven**
|
|
|
25
25
|
|
|
26
26
|
## Procedimiento
|
|
27
27
|
|
|
28
|
-
### Paso 0:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
### Paso 0.1: Consultar Engram por decisiones previas
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
### Paso 0: Reusar el discovery ya obtenido en este request
|
|
29
|
+
|
|
30
|
+
Si en este mismo request ya llamaste `codegraph_codegraph_explore` para el área, **reusá ese output** (sigue en tu contexto) para construir `sharedFiles/fileClusters`; solo `codegraph_impact` para símbolos no cubiertos. Si el área difiere materialmente (overlap <30%) o todavía no hiciste discovery → `codegraph_codegraph_explore` fresco. No hay cache de archivo: no repitas la misma query sin motivo.
|
|
31
|
+
|
|
32
|
+
### Paso 0.1: Consultar Engram por decisiones previas
|
|
33
|
+
|
|
34
|
+
`engram_mem_search` con keywords del cambio, salvo que ya lo hayas hecho para el mismo tema en este request. Si existe decisión previa, usarla como referencia no vinculante.
|
|
36
35
|
### Paso 0.5: Early exit para cambios pequeños
|
|
37
36
|
|
|
38
37
|
Si el change tiene **≤2 tasks** Y **no comparten archivos entre sí** → devolver directamente:
|
|
@@ -48,10 +47,10 @@ Si el change tiene **≤2 tasks** Y **no comparten archivos entre sí** → devo
|
|
|
48
47
|
}
|
|
49
48
|
```
|
|
50
49
|
|
|
51
|
-
### Paso 1: Obtener datos de CodeGraph (solo si discovery
|
|
52
|
-
|
|
53
|
-
Si
|
|
54
|
-
Si
|
|
50
|
+
### Paso 1: Obtener datos de CodeGraph (solo si no hiciste discovery en este request)
|
|
51
|
+
|
|
52
|
+
Si ya hay discovery en contexto → **SKIP este paso**; derivar `fileClusters/sharedFiles` del output (`state.snapshots.codegraph` + `tasks.md`) con `codegraphUsed:["codegraph_explore"]` y `reuseDiscovery:true`.
|
|
53
|
+
Si no:
|
|
55
54
|
```
|
|
56
55
|
codegraph_codegraph_explore con query: "<área del cambio>"
|
|
57
56
|
```
|
|
@@ -26,9 +26,9 @@ When ready to implement, run /opsx-apply
|
|
|
26
26
|
|
|
27
27
|
**Steps**
|
|
28
28
|
|
|
29
|
-
0. **Ground with CodeGraph + Engram (SHALL — Ostacky)** — Antes de `openspec new`,
|
|
29
|
+
0. **Ground with CodeGraph + Engram (SHALL — Ostacky)** — Antes de `openspec new`, usá `codegraph_codegraph_explore` + `engram_mem_search` sobre el área (si no los hiciste ya en este request). Citá símbolos existentes en `proposal/design`; sin esto el `design.md` puede alucinar API inexistente y dispara `WARN:codegraphBypass` en `doctor`.
|
|
30
30
|
|
|
31
|
-
1. **Check Engram for similar changes** — `engram_mem_search` con keywords del *paso 0* (
|
|
31
|
+
1. **Check Engram for similar changes** — `engram_mem_search` con keywords del *paso 0* (no repetir la misma query en el mismo request). Si hay change similar previo, surfacealo. Pueden extender trabajo existente en vez de empezar de cero.
|
|
32
32
|
|
|
33
33
|
2. **If no clear input provided, ask what they want to build**
|
|
34
34
|
|
|
@@ -28,7 +28,7 @@ Write the test first. Watch it fail. Write minimal code to pass.
|
|
|
28
28
|
|
|
29
29
|
Thinking "skip TDD just this once"? Stop. That's rationalization.
|
|
30
30
|
|
|
31
|
-
> **Ostacky ground (SHALL):** Antes de RED,
|
|
31
|
+
> **Ostacky ground (SHALL):** Antes de RED, verificá el área con `codegraph_codegraph_explore` + `engram_mem_search` (si no los hiciste ya en este request). Citá símbolos existentes para no testear API inexistente. Ver `execution-mode-evaluation`.
|
|
32
32
|
|
|
33
33
|
## The Iron Law
|
|
34
34
|
|