ostacky 0.8.6 → 0.8.8
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 +13 -13
- package/assets/agents/ostacky.md +3 -3
- package/assets/commands/install-stack.md +2 -2
- package/assets/docs/ostacky-reference.md +10 -2
- package/assets/mcp/ostacky-controller/controller-core.js +2 -2
- package/assets/mcp/ostacky-controller/index.js +96 -20
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/mcp/ostacky-controller/security.js +33 -2
- package/assets/plugins/controller-core.ts +2 -2
- package/assets/plugins/ostacky-plugin.ts +35 -3
- package/assets/plugins/security.ts +118 -0
- package/assets/plugins/tiered.ts +46 -0
- package/assets/skills/dispatching-parallel-agents/SKILL.md +1 -0
- package/assets/skills/execution-mode-evaluation/SKILL.md +1 -1
- package/assets/skills/openspec-explore/SKILL.md +2 -1
- package/assets/skills/openspec-propose/SKILL.md +3 -1
- package/assets/skills/tdd/SKILL.md +2 -0
- package/dist/cli.js +55 -39
- package/manifest.json +33 -33
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -246,37 +246,37 @@ Tras instalar (`--scope local`), el proyecto queda así:
|
|
|
246
246
|
|
|
247
247
|
```json
|
|
248
248
|
{
|
|
249
|
-
"version": "0.8.
|
|
249
|
+
"version": "0.8.8",
|
|
250
250
|
"lockedAt": "2025-01-01T00:00:00.000Z",
|
|
251
251
|
"repo": "JaimeHoracio/Ostacky",
|
|
252
|
-
"tag": "v0.8.
|
|
252
|
+
"tag": "v0.8.8",
|
|
253
253
|
"agents": {
|
|
254
254
|
"ostacky": {
|
|
255
|
-
"version": "0.8.
|
|
255
|
+
"version": "0.8.8",
|
|
256
256
|
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
257
257
|
"sha256": "abc123..."
|
|
258
258
|
}
|
|
259
259
|
},
|
|
260
260
|
"commands": {
|
|
261
261
|
"install-stack": {
|
|
262
|
-
"version": "0.8.
|
|
262
|
+
"version": "0.8.8",
|
|
263
263
|
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
264
264
|
"sha256": "def456..."
|
|
265
265
|
},
|
|
266
266
|
"opsx-sync": {
|
|
267
|
-
"version": "0.8.
|
|
267
|
+
"version": "0.8.8",
|
|
268
268
|
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
269
269
|
"sha256": "ghi789..."
|
|
270
270
|
}
|
|
271
271
|
},
|
|
272
272
|
"skills": {
|
|
273
|
-
"brainstorming": { "version": "0.8.
|
|
274
|
-
"execution-mode-evaluation": { "version": "0.8.
|
|
275
|
-
"openspec-propose": { "version": "0.8.
|
|
273
|
+
"brainstorming": { "version": "0.8.8", ... },
|
|
274
|
+
"execution-mode-evaluation": { "version": "0.8.8", ... },
|
|
275
|
+
"openspec-propose": { "version": "0.8.8", ... }
|
|
276
276
|
},
|
|
277
277
|
"mcpServers": {
|
|
278
|
-
"ostacky-controller": { "version": "0.8.
|
|
279
|
-
"openspec": { "version": "0.8.
|
|
278
|
+
"ostacky-controller": { "version": "0.8.8", ... },
|
|
279
|
+
"openspec": { "version": "0.8.8", ... }
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
```
|
|
@@ -308,7 +308,7 @@ Es opcional y solo necesario si algo falló durante la instalación o si querés
|
|
|
308
308
|
## Seguridad
|
|
309
309
|
|
|
310
310
|
- `opencode.jsonc` se versiona en el repo para compartir permisos y MCP de forma reproducible.
|
|
311
|
-
- Las URLs de descarga usan **tags de GitHub** (ej. `v0.8.
|
|
311
|
+
- Las URLs de descarga usan **tags de GitHub** (ej. `v0.8.8`), nunca `main` — instalaciones reproducibles
|
|
312
312
|
- Cada path de archivo descargado es validado para prevenir **path traversal**
|
|
313
313
|
- Los archivos incluyen **checksum SHA-256** opcional; si el manifest lo define, el contenido se verifica antes de escribir
|
|
314
314
|
- El cache local (`.opencode/cache/`) también valida integridad al servir archivos cacheados
|
|
@@ -324,7 +324,7 @@ Es opcional y solo necesario si algo falló durante la instalación o si querés
|
|
|
324
324
|
- Variable `OSTACKY_SENSITIVE_PATTERNS` overridea los patrones por defecto (`**/.env*`, `**/.secrets/**`, `**/*.pem`, `**/*.key`, `**/.aws/**`, `**/.ssh/**`, `**/credentials.json`, `**/.npmrc`).
|
|
325
325
|
- Ejemplo: `OSTACKY_SENSITIVE_PATTERNS="**/.env*,**/.secrets/**" bunx ostacky doctor` — `doctor` imprime el patrón efectivo.
|
|
326
326
|
- Allowlist: `.env.example`, `.env.template`, `.env.sample` nunca se bloquean.
|
|
327
|
-
- Fuente única: `src/security.ts` (`SENSITIVE_DEFAULT`, `isSensitive`, `BASH_SENSITIVE_RE`) — `ostacky-
|
|
327
|
+
- Fuente única: `src/security.ts` (`SENSITIVE_DEFAULT`, `isSensitive`, `BASH_SENSITIVE_RE`) — mirrors generados en `assets/plugins/security.ts` y `assets/mcp/ostacky-controller/security.js` vía `bun run scripts/sync-controller-core.ts`; `ostacky-plugin.ts` importa de `./security.ts` (self-contained). Ver `doctor` para verificar.
|
|
328
328
|
|
|
329
329
|
#### Aislamiento de worktrees (harness-prod-hardening)
|
|
330
330
|
|
|
@@ -365,7 +365,7 @@ CodeGraph está instalado en `.opencode/tools/codegraph/bin/codegraph` y se conf
|
|
|
365
365
|
| `.opencode/tools/codegraph/bin/codegraph status` | Muestra estado del index y archivos pendientes |
|
|
366
366
|
| `.opencode/tools/codegraph/bin/codegraph install --target opencode --location local --yes` | Configura CodeGraph para OpenCode y genera AGENTS.md |
|
|
367
367
|
|
|
368
|
-
> **Windows:** si ves `Command failed: cmd.exe /d /c call ... codegraph.cmd init -i` durante `npx ostacky install`, es un _warning_ no fatal — el binario se instaló pero el índice no se pudo crear. Reintentá con `npx ostacky install-stack --scope local` o ejecutá manualmente `.opencode\tools\codegraph\bin\codegraph.exe init -i` (o `codegraph.cmd` si no hay `.exe`) dentro del proyecto. Desde v0.8.
|
|
368
|
+
> **Windows:** si ves `Command failed: cmd.exe /d /c call ... codegraph.cmd init -i` durante `npx ostacky install`, es un _warning_ no fatal — el binario se instaló pero el índice no se pudo crear. Reintentá con `npx ostacky install-stack --scope local` o ejecutá manualmente `.opencode\tools\codegraph\bin\codegraph.exe init -i` (o `codegraph.cmd` si no hay `.exe`) dentro del proyecto. Desde v0.8.8 el instalador usa el patrón resiliente de Engram (strip 0 + búsqueda recursiva) y loguea `projectRoot|toolsDir` para diagnosticar scope.
|
|
369
369
|
|
|
370
370
|
## Licencia
|
|
371
371
|
|
package/assets/agents/ostacky.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Orquestador principal — rutea por nivel, orquesta CodeGraph + OpenSpec + Superpowers.
|
|
3
3
|
mode: primary
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.8
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Sos **Ostacky v0.8.
|
|
7
|
+
Sos **Ostacky v0.8.8**, orquestás, no implementás. Interpretás, clasificás (0/0+1/1+), ruteás y coordinás.
|
|
8
8
|
|
|
9
|
-
> **Versión:** `0.8.
|
|
9
|
+
> **Versión:** `0.8.8` (sincronizada desde `package.json` vía `scripts/sync-version.ts`). Cuando te pregunten qué versión tenés, qué versión sos, o `¿qué versión tenés?` / `version` / `¿en qué versión estás?`, respondé exactamente: **"Ostacky v0.8.8"** (o `v0.8.8` si te piden solo el número). No inventes otra versión.
|
|
10
10
|
|
|
11
11
|
## Reglas innegociables
|
|
12
12
|
|
|
@@ -5,13 +5,13 @@ agent: build
|
|
|
5
5
|
|
|
6
6
|
Instala el stack tecnológico de desarrollo para OpenCode. **IMPORTANTE:** las herramientas se instalan por separado (cada una con su propio CLI/comando). `npx ostacky install` solo instala el agente y commands de Ostacky en `.opencode/`. Este comando (`/install-stack`) es la guía de referencia para la instalación manual completa paso a paso.
|
|
7
7
|
|
|
8
|
-
**Nota:** A partir de v0.8.
|
|
8
|
+
**Nota:** A partir de v0.8.8, `npx ostacky install` ya instala automáticamente el stack completo (CodeGraph, OpenSpec, Engram, MCPs bundleados) además del agente y skills. Este comando es útil para instalación manual, verificación, o cuando algo falló y necesita reinstalarse.
|
|
9
9
|
|
|
10
10
|
**RESTRICCIÓN ABSOLUTA:** instalar ÚNICAMENTE para OpenCode. Está terminantemente prohibido crear o modificar archivos en `.claude/`, `.kiro/`, `.cursor/`, `.gemini/`, `.codex/`, `.antigravity/`, `.windsurf/` o cualquier otro directorio de plataformas externas.
|
|
11
11
|
|
|
12
12
|
**Origen del set curado:** el set de 15 skills referenciado en `assets/agents/ostacky.md` está bundleado en `assets/skills/` dentro del paquete npm. La definición del set y su trazabilidad viven en `manifest.json` y `.opencode/ostacky-lock.json`.
|
|
13
13
|
|
|
14
|
-
## Scope de instalación — local vs global (desde v0.8.
|
|
14
|
+
## Scope de instalación — local vs global (desde v0.8.8)
|
|
15
15
|
|
|
16
16
|
`npx ostacky install` soporta `--scope local|global|auto` (también `--scope=...`):
|
|
17
17
|
|
|
@@ -64,14 +64,22 @@ Principio: **eficacia > recorte** — si recorte rompiera caché y saliera más
|
|
|
64
64
|
|
|
65
65
|
## Security single-source
|
|
66
66
|
|
|
67
|
-
`src/security.ts` único origen de `SENSITIVE_DEFAULT`/`BASH_SENSITIVE_RE`/`isSensitive`/`extractPathsFromBash
|
|
67
|
+
`src/security.ts` único origen de `SENSITIVE_DEFAULT`/`BASH_SENSITIVE_RE`/`isSensitive`/`extractPathsFromBash` (import `node:crypto`); mirrors generados en `assets/plugins/security.ts` (solo Sensitive guard, sin crypto) y `assets/mcp/ostacky-controller/security.js` vía `bun run scripts/sync-controller-core.ts`. `ostacky-plugin.ts` importa de `./security.ts` self-contained (no `../../src`). `BASH_SENSITIVE_RE` arriba. `OSTACKY_SENSITIVE_PATTERNS` override.
|
|
68
68
|
|
|
69
69
|
## Tiered single-source
|
|
70
70
|
|
|
71
|
-
`src/tiered.ts` único origen de `isTrivial(msg,state)` + `getControllerState(dir)`.
|
|
71
|
+
`src/tiered.ts` único origen de `isTrivial(msg,state)` + `getControllerState(dir)`. `ostacky-plugin.ts` importa de `./tiered.ts` mirror (generado vía `sync-controller-core.ts`), `engram.ts` inlined (misma regex, evita import dinámico). No duplicar regex. Ver `assets/plugins/ostacky-plugin.ts` y `assets/plugins/engram.ts`.
|
|
72
|
+
|
|
73
|
+
## Controller single-source
|
|
74
|
+
|
|
75
|
+
`src/controller-core.ts` CANÓNICO para `STATES/TRANSITIONS/DEFAULT_STATE`; mirrors generados en `assets/plugins/controller-core.ts` (TS) y `assets/mcp/ostacky-controller/controller-core.js` (JS strippado) vía `bun run scripts/sync-controller-core.ts` (prebuild). No editar mirrors. `ostacky-plugin.ts` importa de `./controller-core.ts` self-contained.
|
|
72
76
|
|
|
73
77
|
## Prompt-efficiency
|
|
74
78
|
|
|
75
79
|
- `ostacky.md` 109→72 líneas (diet estable cacheable). Tiered vía suffix, no reemplazo `system[0]`.
|
|
76
80
|
- `controller` descriptions <150 chars
|
|
77
81
|
- `MEMORY_INSTRUCTIONS` lazy: siempre pointer (~1 línea) en `system.transform`; full vive en `assets/docs/engram-protocol.md` on-demand via `Read` (ahorro ~1.2k en FULL, trivial ya era pointer)
|
|
82
|
+
|
|
83
|
+
## Propose Flow (1+)
|
|
84
|
+
|
|
85
|
+
`openspec-propose` SHALL Paso 0: `getDiscoverySnapshot(query)` + `getEngramDedup` → si miss `codegraph_codegraph_explore` + `engram_mem_search` + `putDiscoverySnapshot` **antes** de `openspec new`. Cita símbolos existentes en `proposal/design`; sin esto dispara `WARN:codegraphBypass` y `design.md` alucina API. Ver `assets/skills/openspec-propose/SKILL.md: Paso 0` y `brainstorming` (router por nivel: `brainstorming` decide 0/0+1/1+, `openspec-explore` es stance sin nivel).
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* controller-core — Single source of truth para TRANSITIONS, STATES, DEFAULT_STATE y helpers.
|
|
3
|
-
*
|
|
4
|
-
* para eliminar duplicación (D1). Ambos importan de acá.
|
|
3
|
+
* Generado desde src/controller-core.ts — NO EDITAR. Ejecutá `bun run scripts/sync-controller-core.ts`.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
import { SENSITIVE_DEFAULT } from "./security.js";
|
|
@@ -142,6 +141,7 @@ export const DEFAULT_STATE = {
|
|
|
142
141
|
watchdogEnabled: true,
|
|
143
142
|
ts: Date.now(),
|
|
144
143
|
specNotInSync: false,
|
|
144
|
+
engramBypassCount: 0,
|
|
145
145
|
};
|
|
146
146
|
|
|
147
147
|
export function safeJsonStringify(obj, pretty = false) {
|
|
@@ -1349,6 +1349,22 @@ class OstackyController {
|
|
|
1349
1349
|
);
|
|
1350
1350
|
}
|
|
1351
1351
|
// C2: strict contract — recommendation + reasons required (tiered recovery: fix-execution-analysis-validation)
|
|
1352
|
+
// Normalizar aliases para no bloquear snapshot válido enviado con nombre distinto
|
|
1353
|
+
if (snapshot && typeof snapshot === 'string') {
|
|
1354
|
+
try {
|
|
1355
|
+
snapshot = JSON.parse(snapshot);
|
|
1356
|
+
} catch {}
|
|
1357
|
+
}
|
|
1358
|
+
if (snapshot && !snapshot.recommendation && (snapshot.mode || snapshot.executionMode)) {
|
|
1359
|
+
snapshot.recommendation = snapshot.mode || snapshot.executionMode;
|
|
1360
|
+
}
|
|
1361
|
+
if (snapshot && !snapshot.reasons && snapshot.reason) {
|
|
1362
|
+
snapshot.reasons = Array.isArray(snapshot.reason) ? snapshot.reason : [String(snapshot.reason)];
|
|
1363
|
+
}
|
|
1364
|
+
if (snapshot && Array.isArray(snapshot.reasons) && snapshot.reasons.length === 0) {
|
|
1365
|
+
// vacío se considera missing para no dejar pasar snapshot inválido
|
|
1366
|
+
snapshot.reasons = undefined;
|
|
1367
|
+
}
|
|
1352
1368
|
let _snapshotDefaulted = false;
|
|
1353
1369
|
if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
|
|
1354
1370
|
const isDegradedOrEarlyExit =
|
|
@@ -1376,22 +1392,46 @@ class OstackyController {
|
|
|
1376
1392
|
};
|
|
1377
1393
|
}
|
|
1378
1394
|
}
|
|
1379
|
-
// 1.7: exigir expectedTaskIds/taskIds/taskCount cuando taskCount>0
|
|
1395
|
+
// 1.7: exigir expectedTaskIds/taskIds/taskCount cuando taskCount>0 — con retryAllowed para no dejar en BLOCKED
|
|
1380
1396
|
if (snapshot && typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0) {
|
|
1381
1397
|
const hasExpectedIds = Array.isArray(snapshot.expectedTaskIds) && snapshot.expectedTaskIds.length > 0;
|
|
1382
1398
|
const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
|
|
1383
1399
|
const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
|
|
1384
1400
|
if (!hasExpectedIds && !hasTaskIds && !hasCount) {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1401
|
+
const available = (TRANSITIONS[this.#state.state] || []).map((t) => {
|
|
1402
|
+
let desc = t.via;
|
|
1403
|
+
if (t.choice) desc += ` (choice=${t.choice})`;
|
|
1404
|
+
if (t.mode) desc += ` (mode=${t.mode})`;
|
|
1405
|
+
return desc;
|
|
1406
|
+
});
|
|
1407
|
+
return {
|
|
1408
|
+
error: 'Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0',
|
|
1409
|
+
current_state: this.#state.state,
|
|
1410
|
+
attempted_transition: 'record_execution_analysis',
|
|
1411
|
+
available_transitions: available,
|
|
1412
|
+
retryAllowed: true,
|
|
1413
|
+
suggestion:
|
|
1414
|
+
'Reintentá con {taskCount, expectedTaskIds:[...]} que matchee tasks.md — ver skill execution-mode-evaluation',
|
|
1415
|
+
timestamp: new Date().toISOString(),
|
|
1416
|
+
};
|
|
1389
1417
|
}
|
|
1390
1418
|
if (!hasExpectedIds && !hasTaskIds) {
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1419
|
+
const available = (TRANSITIONS[this.#state.state] || []).map((t) => {
|
|
1420
|
+
let desc = t.via;
|
|
1421
|
+
if (t.choice) desc += ` (choice=${t.choice})`;
|
|
1422
|
+
if (t.mode) desc += ` (mode=${t.mode})`;
|
|
1423
|
+
return desc;
|
|
1424
|
+
});
|
|
1425
|
+
return {
|
|
1426
|
+
error: 'Snapshot missing expectedTaskIds or taskIds when taskCount>0',
|
|
1427
|
+
current_state: this.#state.state,
|
|
1428
|
+
attempted_transition: 'record_execution_analysis',
|
|
1429
|
+
available_transitions: available,
|
|
1430
|
+
retryAllowed: true,
|
|
1431
|
+
suggestion:
|
|
1432
|
+
'Reintentá con expectedTaskIds/taskIds que matchee tasks.md — ver skill execution-mode-evaluation',
|
|
1433
|
+
timestamp: new Date().toISOString(),
|
|
1434
|
+
};
|
|
1395
1435
|
}
|
|
1396
1436
|
}
|
|
1397
1437
|
// C2: capture expected tasks for gate — harden-task-integrity: tasks.md canonical
|
|
@@ -1408,7 +1448,11 @@ class OstackyController {
|
|
|
1408
1448
|
const snapshotIds = Array.isArray(expectedTasks) ? expectedTasks : null;
|
|
1409
1449
|
const snapshotSet = snapshotIds ? new Set(snapshotIds) : new Set();
|
|
1410
1450
|
const tasksMdSet = new Set(tasksMdIds);
|
|
1411
|
-
const mismatch =
|
|
1451
|
+
const mismatch =
|
|
1452
|
+
!snapshotIds ||
|
|
1453
|
+
snapshotIds.length !== tasksMdIds.length ||
|
|
1454
|
+
[...snapshotSet].some((id) => !tasksMdSet.has(id)) ||
|
|
1455
|
+
[...tasksMdSet].some((id) => !snapshotSet.has(id));
|
|
1412
1456
|
if (mismatch) taskCountMismatch = true;
|
|
1413
1457
|
expectedTasks = [...tasksMdIds];
|
|
1414
1458
|
expectedTaskCount = tasksMdIds.length;
|
|
@@ -1476,8 +1520,16 @@ class OstackyController {
|
|
|
1476
1520
|
// harden-task-integrity: emit WARN if tasks.md mismatch
|
|
1477
1521
|
if (taskCountMismatch) {
|
|
1478
1522
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1479
|
-
log('warn:task_count_mismatch', {
|
|
1480
|
-
|
|
1523
|
+
log('warn:task_count_mismatch', {
|
|
1524
|
+
auditId,
|
|
1525
|
+
snapshotCount: snapshot?.expectedTaskIds?.length ?? 0,
|
|
1526
|
+
tasksMdCount: tasksMdIds.length,
|
|
1527
|
+
});
|
|
1528
|
+
await this.#audit(
|
|
1529
|
+
'WARN',
|
|
1530
|
+
'task_count_mismatch',
|
|
1531
|
+
`expectedTasks from snapshot (${snapshot?.expectedTaskIds?.length ?? 0}) differs from tasks.md (${tasksMdIds.length}) - using tasks.md`
|
|
1532
|
+
);
|
|
1481
1533
|
}
|
|
1482
1534
|
// 8.2: reasoning sin plan → WARN (but allow early-exit style)
|
|
1483
1535
|
if (!execShown && snapshot && !isEarlyExitExec) {
|
|
@@ -1726,22 +1778,46 @@ class OstackyController {
|
|
|
1726
1778
|
if (providedIds) {
|
|
1727
1779
|
const setA = new Set(providedIds);
|
|
1728
1780
|
const setB = new Set(tasksMdIds);
|
|
1729
|
-
const mismatch =
|
|
1781
|
+
const mismatch =
|
|
1782
|
+
providedIds.length !== tasksMdIds.length ||
|
|
1783
|
+
[...setA].some((id) => !setB.has(id)) ||
|
|
1784
|
+
[...setB].some((id) => !setA.has(id));
|
|
1730
1785
|
if (mismatch) {
|
|
1731
1786
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1732
|
-
log('warn:task_count_mismatch', {
|
|
1733
|
-
|
|
1787
|
+
log('warn:task_count_mismatch', {
|
|
1788
|
+
auditId,
|
|
1789
|
+
provided: providedIds.length,
|
|
1790
|
+
tasksMd: tasksMdIds.length,
|
|
1791
|
+
});
|
|
1792
|
+
await this.#audit(
|
|
1793
|
+
'WARN',
|
|
1794
|
+
'task_count_mismatch',
|
|
1795
|
+
`expectedTasks from caller (${providedIds.length}) differs from tasks.md (${tasksMdIds.length}) - using tasks.md`
|
|
1796
|
+
);
|
|
1734
1797
|
}
|
|
1735
1798
|
} else if (typeof taskCount === 'number' && taskCount !== tasksMdIds.length) {
|
|
1736
1799
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1737
1800
|
log('warn:task_count_mismatch', { auditId, provided: taskCount, tasksMd: tasksMdIds.length });
|
|
1738
|
-
await this.#audit(
|
|
1801
|
+
await this.#audit(
|
|
1802
|
+
'WARN',
|
|
1803
|
+
'task_count_mismatch',
|
|
1804
|
+
`expectedTaskCount from caller (${taskCount}) differs from tasks.md (${tasksMdIds.length}) - using tasks.md`
|
|
1805
|
+
);
|
|
1739
1806
|
}
|
|
1740
1807
|
this.#state.expectedTasks = [...tasksMdIds];
|
|
1741
1808
|
this.#state.expectedTaskCount = tasksMdIds.length;
|
|
1742
1809
|
await this.#persist();
|
|
1743
|
-
await this.#audit(
|
|
1744
|
-
|
|
1810
|
+
await this.#audit(
|
|
1811
|
+
'EXECUTING',
|
|
1812
|
+
'set_expected_tasks',
|
|
1813
|
+
`expected=${this.#state.expectedTaskCount} (from tasks.md)`
|
|
1814
|
+
);
|
|
1815
|
+
return {
|
|
1816
|
+
ok: true,
|
|
1817
|
+
expectedTasks: this.#state.expectedTasks,
|
|
1818
|
+
expectedTaskCount: this.#state.expectedTaskCount,
|
|
1819
|
+
source: 'tasks.md',
|
|
1820
|
+
};
|
|
1745
1821
|
}
|
|
1746
1822
|
if (Array.isArray(taskIds) && taskIds.length > 0) {
|
|
1747
1823
|
this.#state.expectedTasks = [...taskIds];
|
|
@@ -2500,7 +2576,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2500
2576
|
|
|
2501
2577
|
const server = new McpServer({
|
|
2502
2578
|
name: 'ostacky-controller',
|
|
2503
|
-
version: '0.8.
|
|
2579
|
+
version: '0.8.8',
|
|
2504
2580
|
});
|
|
2505
2581
|
|
|
2506
2582
|
server.registerTool(
|
|
@@ -3100,7 +3176,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
3100
3176
|
}
|
|
3101
3177
|
|
|
3102
3178
|
async function main() {
|
|
3103
|
-
log('Starting ostacky-controller MCP v0.8.
|
|
3179
|
+
log('Starting ostacky-controller MCP v0.8.8...');
|
|
3104
3180
|
log('State path:', { path: statePath });
|
|
3105
3181
|
// Clean up stale tmp/lock files from previous runs
|
|
3106
3182
|
cleanupTmpFiles(statePath);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* security.js — source-of-truth mirror of src/security.ts for controller (Node)
|
|
3
|
-
* Generado desde src/security.ts —
|
|
3
|
+
* Generado desde src/security.ts — NO EDITAR. Ejecutá `bun run scripts/sync-controller-core.ts`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// ─── Sensitive guard — source-of-truth (hardening-v2 D1) ───────────────
|
|
7
|
+
|
|
6
8
|
export const SENSITIVE_DEFAULT = [
|
|
7
9
|
"**/.env*",
|
|
8
10
|
"**/.secrets/**",
|
|
@@ -14,9 +16,20 @@ export const SENSITIVE_DEFAULT = [
|
|
|
14
16
|
"**/.npmrc",
|
|
15
17
|
];
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Regex que detecta acceso sensible en comandos bash.
|
|
21
|
+
* Cubre .env, .secrets, .pem, .key, .aws, .ssh, credentials.json, .npmrc
|
|
22
|
+
* Normalizar antes (strip quotes/backslashes) y testear con `BASH_SENSITIVE_RE.test(normalized)`.
|
|
23
|
+
*/
|
|
17
24
|
export const BASH_SENSITIVE_RE =
|
|
18
25
|
/(?:^|[^a-zA-Z0-9_.-])(\.env(\b|[_.-])|\.secrets\b|\.pem\b|\.key\b|credentials\.json|\.aws\b|\.ssh\b|\.npmrc\b)/i;
|
|
19
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if filePath is considered sensitive given patterns.
|
|
29
|
+
* - Allowlist: .env.example / .env.template / .env.sample nunca son sensibles.
|
|
30
|
+
* - Normaliza backslashes a "/" para Windows.
|
|
31
|
+
* - patterns: array de globs como SENSITIVE_DEFAULT; si no se pasa usa SENSITIVE_DEFAULT.
|
|
32
|
+
*/
|
|
20
33
|
export function isSensitive(filePath, patterns = SENSITIVE_DEFAULT) {
|
|
21
34
|
if (!filePath) return false;
|
|
22
35
|
const normalized = filePath.replace(/\\/g, "/");
|
|
@@ -38,25 +51,37 @@ export function isSensitive(filePath, patterns = SENSITIVE_DEFAULT) {
|
|
|
38
51
|
if (pat.includes("credentials.json") && lower.endsWith("credentials.json")) return true;
|
|
39
52
|
if (pat.includes(".npmrc") && lower.endsWith(".npmrc")) return true;
|
|
40
53
|
}
|
|
54
|
+
// fallback genérico por extensión
|
|
41
55
|
if (/\.(pem|key)$/i.test(normalized)) return true;
|
|
42
56
|
if (base.startsWith(".env")) return true;
|
|
43
57
|
return false;
|
|
44
58
|
}
|
|
45
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Extrae paths candidatos de un comando bash para chequear sensibilidad.
|
|
62
|
+
* Tokeniza por separadores shell `| ; && || > >> <` y respeta quotes.
|
|
63
|
+
* Normaliza quitando quotes y backslashes para detectar obfuscaciones como .e""nv.
|
|
64
|
+
*/
|
|
46
65
|
export function extractPathsFromBash(cmd) {
|
|
47
66
|
if (!cmd) return [];
|
|
67
|
+
// Normalizar && y || a ; para split único
|
|
48
68
|
const normalized = cmd.replace(/&&/g, ";").replace(/\|\|/g, ";");
|
|
69
|
+
// Split por separadores shell
|
|
49
70
|
const segments = normalized.split(/[|;><\n]+/);
|
|
50
71
|
const paths = [];
|
|
51
72
|
for (const seg of segments) {
|
|
52
73
|
const trimmed = seg.trim();
|
|
53
74
|
if (!trimmed) continue;
|
|
75
|
+
// Tokeniza respetando quotes — captura tokens entre quotes o sin espacios
|
|
54
76
|
const tokens = trimmed.match(/(?:[^\s"'`\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || [];
|
|
55
77
|
for (let token of tokens) {
|
|
78
|
+
// Normalización anti-obfuscación: quitar quotes y backslashes
|
|
56
79
|
const stripped = token.replace(/["'`]/g, "").replace(/\\/g, "");
|
|
57
80
|
if (!stripped) continue;
|
|
58
|
-
|
|
81
|
+
// Filtrar comandos comunes y flags
|
|
82
|
+
if (["cat", "grep", "ls", "echo", "awk", "sed", "cut", "head", "tail", "wc", "find", "xargs", "bash", "sh", "zsh", "env", "printenv", "node", "bun", "npm", "npx", "ls"].includes(stripped)) continue;
|
|
59
83
|
if (stripped.startsWith("-")) continue;
|
|
84
|
+
// Heurística de path: contiene / o . o es .env* o sensible
|
|
60
85
|
const lower = stripped.toLowerCase();
|
|
61
86
|
if (
|
|
62
87
|
stripped.includes("/") ||
|
|
@@ -70,6 +95,7 @@ export function extractPathsFromBash(cmd) {
|
|
|
70
95
|
lower.endsWith("credentials.json") ||
|
|
71
96
|
lower.endsWith(".npmrc")
|
|
72
97
|
) {
|
|
98
|
+
// Limpiar trailing chars como : , )
|
|
73
99
|
const cleaned = stripped.replace(/[,:;)\]]+$/, "");
|
|
74
100
|
if (cleaned) paths.push(cleaned);
|
|
75
101
|
} else if (stripped === ".env") {
|
|
@@ -77,9 +103,14 @@ export function extractPathsFromBash(cmd) {
|
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
105
|
}
|
|
106
|
+
// También buscar paths con regex global como fallback
|
|
107
|
+
// (detecta .env en strings complejos)
|
|
80
108
|
return [...new Set(paths)];
|
|
81
109
|
}
|
|
82
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Lee patrones sensibles desde env OSTACKY_SENSITIVE_PATTERNS o usa default.
|
|
113
|
+
*/
|
|
83
114
|
export function getSensitivePatterns() {
|
|
84
115
|
const raw = process.env.OSTACKY_SENSITIVE_PATTERNS;
|
|
85
116
|
if (!raw) return SENSITIVE_DEFAULT;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* controller-core — Single source of truth para TRANSITIONS, STATES, DEFAULT_STATE y helpers.
|
|
3
|
-
*
|
|
4
|
-
* para eliminar duplicación (D1). Ambos importan de acá.
|
|
3
|
+
* Generado desde src/controller-core.ts — NO EDITAR. Ejecutá `bun run scripts/sync-controller-core.ts`.
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
6
|
import { SENSITIVE_DEFAULT } from "./security.js";
|
|
@@ -142,6 +141,7 @@ export const DEFAULT_STATE: any = {
|
|
|
142
141
|
watchdogEnabled: true,
|
|
143
142
|
ts: Date.now(),
|
|
144
143
|
specNotInSync: false,
|
|
144
|
+
engramBypassCount: 0,
|
|
145
145
|
};
|
|
146
146
|
|
|
147
147
|
export function safeJsonStringify(obj: any, pretty = false): string {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Mantiene máquina de 13 estados en-process y aplica hard gates en tool.execute.before.
|
|
6
6
|
* MCP queda thin solo para observabilidad (get_*).
|
|
7
7
|
*
|
|
8
|
-
* Single source security:
|
|
8
|
+
* Single source security: mirrors generados desde src/security.ts via scripts/sync-controller-core.ts
|
|
9
9
|
* Cache único: getDiscoverySnapshot es único entrypoint.
|
|
10
10
|
* Tiered cache-friendly: isTrivial sin reemplazar system[0], suffix hint + SKIP.
|
|
11
11
|
* CodeGraph preventivo: bloquea Read/Grep masivo sin Discovery hit.
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
15
15
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync, readdirSync, unlinkSync } from "node:fs"
|
|
16
16
|
import { join, dirname, basename, resolve, relative } from "node:path"
|
|
17
|
-
import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "
|
|
18
|
-
import { isTrivial } from "
|
|
17
|
+
import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from "./security.ts"
|
|
18
|
+
import { isTrivial } from "./tiered.ts"
|
|
19
19
|
import { STATES, TRANSITIONS, DEFAULT_STATE } from "./controller-core.ts"
|
|
20
20
|
|
|
21
21
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -139,6 +139,8 @@ function isCodegraphAvailable(directory: string): boolean {
|
|
|
139
139
|
const trivialBySession = new Map<string, boolean>()
|
|
140
140
|
// Track discovery hit per requestId to avoid blocking after hit
|
|
141
141
|
const discoveryHitByRequest = new Map<string, boolean>()
|
|
142
|
+
// Track Engram hit per session to audit bypass (soft, no BLOCK)
|
|
143
|
+
const engramHitBySession = new Map<string, boolean>()
|
|
142
144
|
// ─── Heartbeat ping — evita sensación de trancado en tareas largas ──
|
|
143
145
|
let lastPingTs = 0
|
|
144
146
|
let pingInterval: ReturnType<typeof setInterval> | null = null
|
|
@@ -279,6 +281,26 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
279
281
|
lastCheck = { revision: freshState.revision, result: "ALLOW" }
|
|
280
282
|
}
|
|
281
283
|
|
|
284
|
+
// ── 1.6) Engram soft gate (auditable, no BLOCK) — refuerza SHALL 1 de ostacky.md ──
|
|
285
|
+
// Si propone/cambia nivel sin haber consultado Engram, cuenta bypass (exime trivial+DONE)
|
|
286
|
+
const isEngramRelevantTool = tool.includes("record_discovery") || (tool.includes("openspec") && (tool.includes("propose") || (typeof args?.filePath === "string" && args.filePath.includes("openspec/changes")) || (typeof args?.path === "string" && args.path.includes("openspec/changes"))))
|
|
287
|
+
if (isEngramRelevantTool) {
|
|
288
|
+
const hasEngramHit = engramHitBySession.get(sessionId) ?? false
|
|
289
|
+
const isTrivialForEngram = trivialBySession.get(sessionId) ?? false
|
|
290
|
+
const stateForEngram = freshState
|
|
291
|
+
const shouldAudit = !hasEngramHit && !isTrivialForEngram && stateForEngram && ["DISCOVERY", "ROUTE_DECISION_PENDING", "SPECIFICATION", "INTERPRETATION_PENDING"].includes(stateForEngram.state)
|
|
292
|
+
if (shouldAudit) {
|
|
293
|
+
try {
|
|
294
|
+
const st = readState(ctx.directory)
|
|
295
|
+
if (st) {
|
|
296
|
+
st.engramBypassCount = (st.engramBypassCount || 0) + 1
|
|
297
|
+
// no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
|
|
298
|
+
persistState(ctx.directory, st)
|
|
299
|
+
}
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
282
304
|
// ── 1.5) Router determinista: openspec-propose bloquea si 1+ no-downgradeable sin Alternatives ──
|
|
283
305
|
const isOpenspecPropose = tool.includes("openspec") && (tool.includes("propose") || args?.filePath?.includes("openspec/changes") || args?.path?.includes("openspec/changes"))
|
|
284
306
|
if (isOpenspecPropose) {
|
|
@@ -487,8 +509,16 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
487
509
|
if (text && !text.includes("null") && text.length > 10) {
|
|
488
510
|
discoveryHitByRequest.set(sessionId, true)
|
|
489
511
|
}
|
|
512
|
+
// Engram hit via discovery snapshot (contiene engramHits)
|
|
513
|
+
if (text && text.includes("engramHits")) {
|
|
514
|
+
engramHitBySession.set(sessionId, true)
|
|
515
|
+
}
|
|
490
516
|
} catch {}
|
|
491
517
|
}
|
|
518
|
+
// Track Engram hit (mem_search / mem_context / dedup) para soft gate 1.6
|
|
519
|
+
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
|
+
engramHitBySession.set(sessionId, true)
|
|
521
|
+
}
|
|
492
522
|
// Update state metrics for cache hit (best-effort)
|
|
493
523
|
if (tool.includes("getDiscoverySnapshot") && output) {
|
|
494
524
|
try {
|
|
@@ -549,6 +579,7 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
549
579
|
tokenSavingEstimate: state?.tokenSavingEstimate ?? 0,
|
|
550
580
|
stateCheckCount: state?.stateCheckCount ?? 0,
|
|
551
581
|
codegraphBypassCount: state?.codegraphBypassCount ?? 0,
|
|
582
|
+
engramBypassCount: state?.engramBypassCount ?? 0,
|
|
552
583
|
revision: state?.revision ?? 0,
|
|
553
584
|
}
|
|
554
585
|
},
|
|
@@ -605,6 +636,7 @@ export const OstackyController: Plugin = async (ctx) => {
|
|
|
605
636
|
if (sid) {
|
|
606
637
|
trivialBySession.delete(sid)
|
|
607
638
|
discoveryHitByRequest.delete(sid)
|
|
639
|
+
engramHitBySession.delete(sid)
|
|
608
640
|
}
|
|
609
641
|
}
|
|
610
642
|
},
|