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 CHANGED
@@ -246,37 +246,37 @@ Tras instalar (`--scope local`), el proyecto queda así:
246
246
 
247
247
  ```json
248
248
  {
249
- "version": "0.8.6",
249
+ "version": "0.8.8",
250
250
  "lockedAt": "2025-01-01T00:00:00.000Z",
251
251
  "repo": "JaimeHoracio/Ostacky",
252
- "tag": "v0.8.6",
252
+ "tag": "v0.8.8",
253
253
  "agents": {
254
254
  "ostacky": {
255
- "version": "0.8.6",
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.6",
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.6",
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.6", ... },
274
- "execution-mode-evaluation": { "version": "0.8.6", ... },
275
- "openspec-propose": { "version": "0.8.6", ... }
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.6", ... },
279
- "openspec": { "version": "0.8.6", ... }
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.6`), nunca `main` — instalaciones reproducibles
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-plugin.ts` importa de ahí (legacy `ostacky-guard.ts` eliminado, fusionado en el plugin). Ver `doctor` para verificar.
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.6 el instalador usa el patrón resiliente de Engram (strip 0 + búsqueda recursiva) y loguea `projectRoot|toolsDir` para diagnosticar scope.
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
 
@@ -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.6
4
+ version: 0.8.8
5
5
  ---
6
6
 
7
- Sos **Ostacky v0.8.6**, orquestás, no implementás. Interpretás, clasificás (0/0+1/1+), ruteás y coordinás.
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.6` (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.6"** (o `v0.8.6` si te piden solo el número). No inventes otra versión.
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.6, `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.
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.6)
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`; plugin y guard importan, no copian. `BASH_SENSITIVE_RE` arriba. `OSTACKY_SENSITIVE_PATTERNS` override.
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)`. Ambos plugins importan lógica idéntica, no duplican regex. Ver `assets/plugins/ostacky-plugin.ts` y `assets/plugins/engram.ts`.
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
- * Extraído de assets/mcp/ostacky-controller/index.js y assets/plugins/ostacky-plugin.ts
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
- return this.#makeError(
1386
- 'Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0',
1387
- 'record_execution_analysis'
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
- return this.#makeError(
1392
- 'Snapshot missing expectedTaskIds or taskIds when taskCount>0',
1393
- 'record_execution_analysis'
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 = !snapshotIds || snapshotIds.length !== tasksMdIds.length || [...snapshotSet].some((id) => !tasksMdSet.has(id)) || [...tasksMdSet].some((id) => !snapshotSet.has(id));
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', { auditId, snapshotCount: snapshot?.expectedTaskIds?.length ?? 0, tasksMdCount: tasksMdIds.length });
1480
- await this.#audit('WARN', 'task_count_mismatch', `expectedTasks from snapshot (${snapshot?.expectedTaskIds?.length ?? 0}) differs from tasks.md (${tasksMdIds.length}) - using tasks.md`);
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 = providedIds.length !== tasksMdIds.length || [...setA].some((id) => !setB.has(id)) || [...setB].some((id) => !setA.has(id));
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', { auditId, provided: providedIds.length, tasksMd: tasksMdIds.length });
1733
- await this.#audit('WARN', 'task_count_mismatch', `expectedTasks from caller (${providedIds.length}) differs from tasks.md (${tasksMdIds.length}) - using tasks.md`);
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('WARN', 'task_count_mismatch', `expectedTaskCount from caller (${taskCount}) differs from tasks.md (${tasksMdIds.length}) - using tasks.md`);
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('EXECUTING', 'set_expected_tasks', `expected=${this.#state.expectedTaskCount} (from tasks.md)`);
1744
- return { ok: true, expectedTasks: this.#state.expectedTasks, expectedTaskCount: this.#state.expectedTaskCount, source: 'tasks.md' };
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.6',
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.6...');
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,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -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 — mantener sincronizado via `bun run hash:check` y test controller-source
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
- 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;
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
- * Extraído de assets/mcp/ostacky-controller/index.js y assets/plugins/ostacky-plugin.ts
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: importa desde src/security.ts (no copia regex).
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 "../../src/security.ts"
18
- import { isTrivial } from "../../src/tiered.ts"
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
  },