ostacky 0.10.1 → 0.10.3
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 +5 -3
- package/assets/agents/ostacky.md +14 -5
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +16 -0
- package/assets/mcp/ostacky-controller/index.js +225 -37
- package/assets/plugins/engram.ts +13 -9
- package/assets/plugins/ostacky-controller/index.ts +300 -48
- package/dist/cli.js +143 -67
- package/dist/mcp/openspec/index.js +19335 -0
- package/dist/mcp/openspec/package.json +1 -0
- package/dist/mcp/ostacky-controller/index.js +23115 -0
- package/dist/mcp/ostacky-controller/package.json +1 -0
- package/manifest.json +31 -29
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -61,6 +61,8 @@ Escribe el núcleo en el config dir global de OpenCode, sin tocar ningún proyec
|
|
|
61
61
|
|
|
62
62
|
La config global acepta `opencode.json` o `opencode.jsonc`. Si el proyecto define el mismo MCP, el del proyecto reemplaza al global (comportamiento v2); en skills con mismo ID gana la del proyecto.
|
|
63
63
|
|
|
64
|
+
El plugin del controller sigue la misma regla, pero necesita ayuda explícita: OpenCode V2 dedupea plugins por `id` y conserva la **primera** ocurrencia en orden de arranque, y el config global se descubre antes que el del proyecto. Con el mismo id, el global gana y el local queda `failed: "Duplicate plugin ID"`. Por eso la copia global declara `ostacky-controller-global` y su `setup` no registra nada si el proyecto tiene `.opencode/plugins/ostacky-controller/` — precedencia local, sin dobles hooks. El chequeo corre en `setup` (una vez por carga): si instalás el local con el global ya activo en la sesión, reiniciá OpenCode para que la copia global ceda.
|
|
65
|
+
|
|
64
66
|
> **Migración v1 → v2:** `npx ostacky` sin subcomando busca la última versión publicada y, si detecta v1 instalada, ofrece migrar (siempre con confirmación).
|
|
65
67
|
|
|
66
68
|
### Por qué el stack es local
|
|
@@ -129,9 +131,9 @@ Local (`<proyecto>/.opencode`) o global (mismo árbol bajo el dir global, sin st
|
|
|
129
131
|
|
|
130
132
|
```json
|
|
131
133
|
{
|
|
132
|
-
"version": "0.10.
|
|
133
|
-
"tag": "v0.10.
|
|
134
|
-
"agents": { "ostacky": { "version": "0.10.
|
|
134
|
+
"version": "0.10.3",
|
|
135
|
+
"tag": "v0.10.3",
|
|
136
|
+
"agents": { "ostacky": { "version": "0.10.3", "sha256": "…" } },
|
|
135
137
|
"commands": { "install-stack": { … }, "opsx-sync": { … } },
|
|
136
138
|
"skills": { "brainstorming": { … }, "…" : {} },
|
|
137
139
|
"mcpServers": { "ostacky-controller": { … }, "openspec": { … } }
|
package/assets/agents/ostacky.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Orquestador principal — rutea por nivel, orquesta CodeGraph + OpenSpec + Superpowers.
|
|
3
3
|
mode: primary
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.3
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Sos **Ostacky v0.10.
|
|
7
|
+
Sos **Ostacky v0.10.3**, orquestás, no implementás. Interpretás, clasificás (0/0+1/1+), ruteás y coordinás.
|
|
8
8
|
|
|
9
|
-
> **Versión:** `0.10.
|
|
9
|
+
> **Versión:** `0.10.3` (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.10.3"** (o `v0.10.3` si te piden solo el número). No inventes otra versión.
|
|
10
10
|
|
|
11
11
|
## Reglas innegociables
|
|
12
12
|
|
|
13
13
|
1. **NUNCA te congeles.** Plan B antes de tool, no reintentes fallida.
|
|
14
|
-
2. **CodeGraph primero
|
|
14
|
+
2. **CodeGraph primero ante query de símbolo/flujo/impacto** (¿quién llama a X?, blast radius, dónde vive un símbolo). Grep/glob directo es legítimo ante query de literal (string exacto, TODO, config, `.md`/`.json`) — pero el gate SIEMPRE exige un `codegraph_codegraph_explore` completado antes de leer código (`shouldBlockCodeRead`). Para leer código no-indexable a conciencia, el explore previo es calibración, no sobrehead: barato, local y confirma que el índice está vivo.
|
|
15
15
|
3. **El plugin hace cumplir PENDING.** Hard gate en `ctx.tool.hook("execute.before")`; no llames `check_*` manual.
|
|
16
16
|
4. **No edites sin Read fresco.** Nunca cache de turno anterior.
|
|
17
17
|
5. **Una pregunta por turno.** Natural, sin tool, STOP y esperar. Respuesta vinculante.
|
|
@@ -73,6 +73,8 @@ Si vago → preguntar. Si claro → `start_request`.
|
|
|
73
73
|
|
|
74
74
|
**Nivel 0 → DIRECT automático (sin pregunta de ruteo):** anunciar `Nivel 0 + archivos + qué se va a hacer` y proceder con `consume_route_decision(DIRECT)`; el veto del usuario vale (si objeta, se frena). Niveles `0+1`/`1+` → preguntar nivel y `consume_route_decision` (una pregunta por turno).
|
|
75
75
|
|
|
76
|
+
**DIRECT sobre 1+ (override):** si el usuario elige DIRECT en un run 1+ sin `tasks.md`, SHALL registrar el plan inline con `set_expected_tasks({taskIds:[...]})` (3-7 pasos con sus archivos) antes de la primera edición y usarlo como checklist; sin plan NO SHALL editar.
|
|
77
|
+
|
|
76
78
|
### 3. Specification (solo SPEC)
|
|
77
79
|
|
|
78
80
|
Router `brainstorming`↔`OpenSpec` por `level`/`estLines`/`fileCount`/`hasAPI` (no keywords). `1+` no-downgradeable → `skill(brainstorming)` genera `design.md ## Alternatives`; downgradeable → `docs/...` + `DIRECT`.
|
|
@@ -85,18 +87,25 @@ Router `brainstorming`↔`OpenSpec` por `level`/`estLines`/`fileCount`/`hasAPI`
|
|
|
85
87
|
### 4. Execution
|
|
86
88
|
|
|
87
89
|
1. `skill(execution-mode-evaluation)` en memoria, reusa discovery.
|
|
88
|
-
2. Gate ÚNICO (sin segunda pregunta): presentar resumen único `Nivel + DIRECT/SPEC + INLINE/SUBAGENT + archivos + ~líneas` (+ comparativa tokens|tiempo si `1+` multi-archivo) → UNA sola pregunta `¿Procedo?` (el usuario puede modificar). Si hay
|
|
90
|
+
2. Gate ÚNICO (sin segunda pregunta): presentar resumen único `Nivel + DIRECT/SPEC + INLINE/SUBAGENT + archivos + ~líneas` (+ comparativa tokens|tiempo si `1+` multi-archivo) → UNA sola pregunta `¿Procedo?` (el usuario puede modificar). Si hay riesgo verificable aunque sea 0+1, el resumen incluye `Riesgo: <qué se rompe en llano> · Evidencia: <ruta relativa al proyecto>:<línea> · Mitigación: <acción>`; sin evidencia verificable NO SHALL inventarse. Todo override a advertencia SHALL pasar por `record_user_confirmation`, pero si la respuesta de ruta ya fue explícita («ejecutar DIRECT»), esa respuesta ES el override: se registra y NO se re-pregunta — el turno siguiente es este gate. Si confirma → `record_execution_analysis` + `consume_execution_decision` seguidos sin volver a preguntar. `Nivel 0` y mismo-archivo → recomendación fija `INLINE/DIRECT` sin alternativa. `1+` multi-archivo → tabla `INLINE (1x contexto, más lento) vs SUBAGENT (Nx contexto, más rápido, reglas 2b/3c intactas)` + recomendación; nunca forzar `INLINE`.
|
|
89
91
|
3. Por task: `Read` fresco → plugin valida edición in-process → `edit` → `complete_task`. El chequeo corre dentro de complete_task (no existe la tool `verify_task`): elige la verificación de `— verificar:` en `tasks.md` y devuelve el motivo si falla. **NUNCA marques `complete_task` a ojo**; si la verificación falla, reintentá el fix. Vale para SPEC y DIRECT, INLINE y SUBAGENTS.
|
|
90
92
|
|
|
93
|
+
**Progreso visible:** si `expectedTasks.length > 1`, después de cada `complete_task` SHALL reportar `✅ Tn/N — <archivo>` + `quedan: T…` (máx. 2 líneas) sin esperar a que el usuario pregunte; al cerrar la última task de una fase de `tasks.md`, SHALL resumir lo hecho, listar lo pendiente de las fases siguientes y proponer continuar en UNA pregunta. Con una sola task NO SHALL emitir el bloque.
|
|
94
|
+
|
|
95
|
+
**`complete_task` con archivo:** en tasks con verificación `file:`, SHALL llamar `complete_task({taskId, filePath})`; el cierre re-verifica el needle contra ESE archivo y sin `filePath` no puede hacerlo. El validate del plugin es por archivo y se consume con cada `complete_task`: dos tasks sobre el mismo archivo necesitan una edición entre medio.
|
|
96
|
+
|
|
91
97
|
### 5. Sync y cierre
|
|
92
98
|
|
|
93
99
|
1. Tests + review
|
|
94
100
|
2. `saveSessionClose` → `mem_session_summary` + `set_handoff` paralelo
|
|
95
101
|
3. `verifyIntegrity` → **si `pending.length > 0` NO llames `implementation_complete`**; lista explícita `Te faltan N tasks: [...]` y pide `complete_task`. Si `staleFiles.length > 0` por refino post-complete (archivo ya correcto, sin edición pendiente): llamá `refresh_fingerprint({filePath})` por cada archivo y reintentá `implementation_complete` sin `force`. Solo si `pending === 0` y `staleFiles === 0`, `implementation_complete` → `sync_complete`. **NUNCA llames `implementation_complete` automáticamente con pendientes** — espera a que el usuario complete o escriba literal `"confirmo forzar"` para `force:true` (queda auditado). Vale para SPEC y DIRECT, INLINE y SUBAGENTS.
|
|
96
102
|
|
|
103
|
+
Si `verificationFailed` no está vacío, revisá que cada task con `file:` haya declarado `filePath` en su `complete_task`: el re-chequeo del cierre lee ese archivo, no el path escrito en la verificación.
|
|
104
|
+
|
|
97
105
|
## Guardrails
|
|
98
106
|
|
|
99
107
|
- Decisión en OpenSpec/CodeGraph/controller → no re-resolver
|
|
108
|
+
- **Multi-sesión (un state file por proyecto):** si `start_request` devuelve `conflict:true` o un tool devuelve `BLOCKED: … pertenece a otra sesión`, NO re-presentes el resumen de ejecución ni consumas/bloquees esa decisión. Preguntá una sola cosa: takeover explícito (`start_request({force:true})`, auditado) o continuar en la otra sesión. Si `get_state` muestra `callerSessionId:null` con `ownerSessionId` no nulo, tratá la request como ajena (no hay identidad que pruebe lo contrario).
|
|
100
109
|
- Una pregunta por turno, sin deadlock
|
|
101
110
|
- Fase gate: en EXECUTING/SYNC no volver a DISCOVERY
|
|
102
111
|
- Audit: log antes de gate + `mem_save` solo en gates
|
|
@@ -6,7 +6,7 @@ subagent: false
|
|
|
6
6
|
|
|
7
7
|
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.
|
|
8
8
|
|
|
9
|
-
**Nota:** A partir de v0.10.
|
|
9
|
+
**Nota:** A partir de v0.10.3, `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.
|
|
10
10
|
|
|
11
11
|
**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.
|
|
12
12
|
|
|
@@ -105,6 +105,14 @@ Principio: **eficacia > recorte** — si recorte rompiera caché y saliera más
|
|
|
105
105
|
|
|
106
106
|
`src/controller-core.ts` CANÓNICO para `STATES/TRANSITIONS/DEFAULT_STATE`; mirrors generados en `assets/plugins/ostacky-controller/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. `index.ts` importa de `./controller-core.ts` self-contained. Import siempre `./security.ts` (el server V2 no resuelve `.js→.ts`).
|
|
107
107
|
|
|
108
|
+
## Release — MCP bundles (`dist/mcp`)
|
|
109
|
+
|
|
110
|
+
`bun run build:mcp` compila cada `mcpServers[]` del manifest a `dist/mcp/<name>/index.js` autocontenido (sin `node_modules` al lado). `installMcpServer` prefiere ese artefacto; si no existe cae al fallback dev (copiar `assets/mcp/<name>/` + `bun install`/`npm install` en la máquina del usuario).
|
|
111
|
+
|
|
112
|
+
- `manifest.json`: `mcpServers[].sha256` = hash del **artefacto** que se copia (bundle si existe, si no el tree hash del source); `sourceSha256` = tree hash de `assets/mcp/<name>/` siempre. `assertBundledTreeHash(category, name, hash, field)` valida el campo que corresponde al camino elegido.
|
|
113
|
+
- `bun run hash:update` (build:mcp + hashes) es el camino para actualizar el manifest; `hash:check` corre en CI después del build.
|
|
114
|
+
- CI pinnea `bun-version` (1.4.2): el hash del bundle depende del output de bun.
|
|
115
|
+
|
|
108
116
|
## Prompt-efficiency
|
|
109
117
|
|
|
110
118
|
- `ostacky.md` 109→72 líneas (diet estable cacheable). Tiered vía suffix, no reemplazo `system[0]`.
|
|
@@ -114,3 +122,11 @@ Principio: **eficacia > recorte** — si recorte rompiera caché y saliera más
|
|
|
114
122
|
## Propose Flow (1+)
|
|
115
123
|
|
|
116
124
|
`openspec-propose` SHALL Paso 0: `codegraph_codegraph_explore` + `engram_mem_search` sobre el área (si no se hicieron ya en el request) **antes** de `openspec new`. Citá 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).
|
|
125
|
+
|
|
126
|
+
## CI — openspec validate (remediación)
|
|
127
|
+
|
|
128
|
+
El job `test` de `.github/workflows/ci.yml` corre `bunx openspec validate --all --strict` (dep pinneada en `package.json`). Si falla en `main`: un delta/spec inválido entró — NO archivearlo roto ni hacer workaround. Protocolo:
|
|
129
|
+
1. El output lista el item roto (`✗ change/<id>` / `✗ spec/<name>`) + `Details: openspec validate <id> --type change|spec` — ese comando da la causa exacta y los next steps (el CLI es autoexplicativo: headers `## ADDED/MODIFIED/REMOVED/RENAMED Requirements`, cada requirement con al menos un `#### Scenario:`).
|
|
130
|
+
2. Debug de deltas parseados: `openspec show <id> --json --deltas-only`.
|
|
131
|
+
3. Change WIP incompleto en `main` → completarlo o removerlo del repo; specs de `openspec/specs/` rotos → fixear el formato in-place.
|
|
132
|
+
4. Si tocás `assets/` regenerá hashes (`bun run hash:update`) y usá `bun run test:safe` (nunca `bun test` full: se cuelga).
|
|
@@ -49,6 +49,11 @@ import {
|
|
|
49
49
|
// y al umbral habilita recovery (refresh_decision) sin ejecutar nada por sí mismo.
|
|
50
50
|
const CONSUME_FAIL_THRESHOLD = 3;
|
|
51
51
|
|
|
52
|
+
// Ownership con heartbeat: un dueño sin latido por más de esto se considera
|
|
53
|
+
// muerto (TUI cerrada, proceso kill) y la sesión nueva puede reclamar la
|
|
54
|
+
// request sin pedirle takeover al usuario. 15 min cubre tools largas.
|
|
55
|
+
const OWNER_STALE_MS = 15 * 60 * 1000;
|
|
56
|
+
|
|
52
57
|
// --- Audit JSONL (D5) — single source: src/audit-jsonl.ts ---
|
|
53
58
|
function getAuditPath(projectRoot) {
|
|
54
59
|
return join(projectRoot, '.opencode', 'ostacky-audit.jsonl');
|
|
@@ -120,6 +125,39 @@ function readAuditJsonl(projectRoot, opts = {}) {
|
|
|
120
125
|
// T1: non-blocking wait — replaces busy-wait spins that froze the event loop
|
|
121
126
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
122
127
|
|
|
128
|
+
function isPidAlive(pid) {
|
|
129
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pid, 0);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pid dueño del lock, o null si el archivo no existe / está corrupto. */
|
|
139
|
+
function readLockPid(pidPath) {
|
|
140
|
+
try {
|
|
141
|
+
const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
142
|
+
return Number.isNaN(pid) ? null : pid;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Antigüedad del archivo de pid. Un lock sin timestamp recién escrito es una
|
|
150
|
+
* adquisición en curso (el dueño escribe el timestamp inmediatamente después);
|
|
151
|
+
* si ya pasó el grace, es basura de un crash.
|
|
152
|
+
*/
|
|
153
|
+
function lockPidAgeMs(pidPath) {
|
|
154
|
+
try {
|
|
155
|
+
return Date.now() - statSync(pidPath).mtimeMs;
|
|
156
|
+
} catch {
|
|
157
|
+
return Number.POSITIVE_INFINITY;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
function getProjectRoot(statePath) {
|
|
124
162
|
if (!statePath) return resolve(process.cwd());
|
|
125
163
|
return dirname(dirname(resolve(statePath)));
|
|
@@ -410,6 +448,12 @@ function cleanupTmpFiles(statePath) {
|
|
|
410
448
|
try {
|
|
411
449
|
lockTs = parseInt(readFileSync(tsPath, 'utf8').trim(), 10);
|
|
412
450
|
} catch {}
|
|
451
|
+
if (lockPid && String(lockPid) !== String(process.pid) && isPidAlive(parseInt(lockPid, 10))) {
|
|
452
|
+
// Dueño vivo = lock activo, haya o no timestamp. Antes un
|
|
453
|
+
// lock sin timestamp se borraba igual y se robaba el lock
|
|
454
|
+
// de un proceso que estaba escribiendo.
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
413
457
|
if (lockPid && lockTs && !Number.isNaN(lockTs)) {
|
|
414
458
|
const age = Date.now() - lockTs;
|
|
415
459
|
if (age < staleWindow && String(lockPid) !== String(process.pid)) {
|
|
@@ -617,33 +661,48 @@ class OstackyController {
|
|
|
617
661
|
const maxAttempts = this.#lockMaxAttempts;
|
|
618
662
|
const lockTimeout = 1000;
|
|
619
663
|
const staleWindow = 15000;
|
|
664
|
+
const orphanGraceMs = 1000;
|
|
620
665
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
621
666
|
try {
|
|
622
|
-
// Check existing lock BEFORE overwriting — corrects mutual-exclusion bug
|
|
667
|
+
// Check existing lock BEFORE overwriting — corrects mutual-exclusion bug.
|
|
668
|
+
// Sin heartbeat legible el lock es huérfano (proceso muerto entre el
|
|
669
|
+
// write del pid y el del timestamp) o una adquisición en curso. Antes
|
|
670
|
+
// se seguía derecho al `wx`, que fallaba con EEXIST para siempre: el
|
|
671
|
+
// controlador no podía persistir NINGUNA decisión y el consume se
|
|
672
|
+
// perdía (el agente volvía a preguntar ruta/ejecución en loop).
|
|
673
|
+
let lockAge = NaN;
|
|
623
674
|
try {
|
|
624
|
-
|
|
625
|
-
const lockAge = Date.now() - parseInt(lockContent, 10);
|
|
626
|
-
if (!Number.isNaN(lockAge) && lockAge < staleWindow) {
|
|
627
|
-
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
628
|
-
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
629
|
-
const waitMs = Math.max(0, base + jitter);
|
|
630
|
-
if (waitMs > 0) await sleep(waitMs);
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
633
|
-
if (!Number.isNaN(lockAge) && lockAge >= staleWindow) {
|
|
634
|
-
try {
|
|
635
|
-
const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
636
|
-
try {
|
|
637
|
-
process.kill(parseInt(lockPid, 10), 0);
|
|
638
|
-
// PID alive but stale beyond window — force release
|
|
639
|
-
} catch {
|
|
640
|
-
// PID dead — force release
|
|
641
|
-
}
|
|
642
|
-
} catch {}
|
|
643
|
-
this.#releaseLock();
|
|
644
|
-
}
|
|
675
|
+
lockAge = Date.now() - parseInt(readFileSync(this.#lockHeartbeatPath, 'utf8'), 10);
|
|
645
676
|
} catch {
|
|
646
|
-
//
|
|
677
|
+
// sin timestamp
|
|
678
|
+
}
|
|
679
|
+
if (!Number.isNaN(lockAge) && lockAge < staleWindow) {
|
|
680
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
681
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
682
|
+
const waitMs = Math.max(0, base + jitter);
|
|
683
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!Number.isNaN(lockAge) && lockAge >= staleWindow) {
|
|
687
|
+
// Stale: liberar SOLO si el dueño ya no existe. Un dueño vivo
|
|
688
|
+
// con heartbeat viejo puede estar en una operación larga:
|
|
689
|
+
// robarle el lock haría que su write se pierda. (`#releaseLock`
|
|
690
|
+
// no sirve acá: verifica ownership y no toca el lock ajeno, así
|
|
691
|
+
// que este camino era un no-op y el lock quedaba pegado.)
|
|
692
|
+
const ownerPid = readLockPid(this.#lockPidPath);
|
|
693
|
+
if (ownerPid === null || !isPidAlive(ownerPid)) this.#forceReleaseLock();
|
|
694
|
+
} else if (Number.isNaN(lockAge)) {
|
|
695
|
+
const ownerPid = readLockPid(this.#lockPidPath);
|
|
696
|
+
// pid ausente, dueño muerto, o pid viejo sin timestamp (crash
|
|
697
|
+
// entre los dos writes) → basura: liberar. Un pid recién
|
|
698
|
+
// escrito es una adquisición en curso: esperar.
|
|
699
|
+
if (
|
|
700
|
+
ownerPid === null ||
|
|
701
|
+
!isPidAlive(ownerPid) ||
|
|
702
|
+
lockPidAgeMs(this.#lockPidPath) > orphanGraceMs
|
|
703
|
+
) {
|
|
704
|
+
this.#forceReleaseLock();
|
|
705
|
+
}
|
|
647
706
|
}
|
|
648
707
|
// Atomic acquire with wx — fails if another process won the race
|
|
649
708
|
try {
|
|
@@ -705,6 +764,25 @@ class OstackyController {
|
|
|
705
764
|
this.#lockOwner = false;
|
|
706
765
|
}
|
|
707
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Libera un lock que NO es nuestro (huérfano o stale). `#releaseLock` se
|
|
769
|
+
* niega por diseño a tocar el lock de otro proceso, así que el "force
|
|
770
|
+
* release" viejo era un no-op y un lock de un proceso muerto quedaba
|
|
771
|
+
* pegado para siempre (todas las escrituras fallaban con lock timeout).
|
|
772
|
+
*/
|
|
773
|
+
#forceReleaseLock() {
|
|
774
|
+
try {
|
|
775
|
+
unlinkSync(this.#lockPidPath);
|
|
776
|
+
} catch {
|
|
777
|
+
/* best-effort */
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
unlinkSync(this.#lockHeartbeatPath);
|
|
781
|
+
} catch {
|
|
782
|
+
/* best-effort */
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
708
786
|
#heartbeatLock() {
|
|
709
787
|
if (!this.#lockHeartbeatPath) return;
|
|
710
788
|
try {
|
|
@@ -1169,6 +1247,28 @@ class OstackyController {
|
|
|
1169
1247
|
};
|
|
1170
1248
|
}
|
|
1171
1249
|
|
|
1250
|
+
/**
|
|
1251
|
+
* multisession-ownership-guard: la request activa es de UNA sesión.
|
|
1252
|
+
* Si el caller es conocido y difiere del dueño, la mutación se rechaza
|
|
1253
|
+
* (consumir/cancelar/refrescar la decisión de otro invalidaba su resumen
|
|
1254
|
+
* y forzaba a re-presentarlo). Sin identidad (clientes legacy sin `_meta`)
|
|
1255
|
+
* o sin dueño, no aplica: comportamiento previo intacto.
|
|
1256
|
+
*/
|
|
1257
|
+
async #foreignOwnerError(via) {
|
|
1258
|
+
const owner = this.#state?.ownerSessionId ?? null;
|
|
1259
|
+
const caller = this.#callerSessionId ?? null;
|
|
1260
|
+
if (!owner || !caller || owner === caller) return null;
|
|
1261
|
+
await this.#audit(
|
|
1262
|
+
'WARN',
|
|
1263
|
+
'cross_session_denied',
|
|
1264
|
+
`${via}: caller=${caller} owner=${owner} state=${this.#state?.state}`
|
|
1265
|
+
);
|
|
1266
|
+
return this.#makeError(
|
|
1267
|
+
`BLOCKED: la request activa pertenece a otra sesión (owner=${owner}). No consumas, refresques ni canceles su decisión; usá start_request({force:true}) para takeover explícito o esperá a que termine.`,
|
|
1268
|
+
via
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1172
1272
|
#suggestRecovery(state, transition) {
|
|
1173
1273
|
const suggestions = {
|
|
1174
1274
|
INTERPRETATION_PENDING: 'Call start_request or proceed_to_discovery first.',
|
|
@@ -1240,6 +1340,8 @@ class OstackyController {
|
|
|
1240
1340
|
|
|
1241
1341
|
const caller = this.#callerSessionId;
|
|
1242
1342
|
const owner = this.#state.ownerSessionId;
|
|
1343
|
+
const ownerHeartbeat = this.#state.ownerHeartbeat ?? 0;
|
|
1344
|
+
const ownerStale = !ownerHeartbeat || Date.now() - ownerHeartbeat > OWNER_STALE_MS;
|
|
1243
1345
|
// OJO: `INTERPRETATION_PENDING` está en TERMINAL_STATES — es "terminal" para
|
|
1244
1346
|
// decidir si se RESUME, pero NO significa que no haya trabajo. Por eso separamos:
|
|
1245
1347
|
// - resumeEligible: semántica ORIGINAL de start_request (no la tocamos).
|
|
@@ -1250,7 +1352,8 @@ class OstackyController {
|
|
|
1250
1352
|
this.#state.state !== STATES.DONE &&
|
|
1251
1353
|
!!owner &&
|
|
1252
1354
|
!!caller &&
|
|
1253
|
-
owner !== caller
|
|
1355
|
+
owner !== caller &&
|
|
1356
|
+
!ownerStale;
|
|
1254
1357
|
|
|
1255
1358
|
// ── Guard de ownership (multi-sesión) ──
|
|
1256
1359
|
// Si hay una request viva de OTRA sesión y no se fuerza, NO pisamos nada:
|
|
@@ -1275,6 +1378,23 @@ class OstackyController {
|
|
|
1275
1378
|
};
|
|
1276
1379
|
}
|
|
1277
1380
|
|
|
1381
|
+
// Takeover automático de un dueño muerto (sin `force`): la request no se
|
|
1382
|
+
// pierde, solo cambia de manos. Queda auditado para poder rastrearlo.
|
|
1383
|
+
const staleTakeover =
|
|
1384
|
+
!!owner &&
|
|
1385
|
+
!!caller &&
|
|
1386
|
+
owner !== caller &&
|
|
1387
|
+
ownerStale &&
|
|
1388
|
+
this.#state.state !== STATES.DONE &&
|
|
1389
|
+
!!this.#state.requestId;
|
|
1390
|
+
if (staleTakeover) {
|
|
1391
|
+
await this.#audit(
|
|
1392
|
+
this.#state.state,
|
|
1393
|
+
'session_takeover_stale',
|
|
1394
|
+
`from=${owner} to=${caller} heartbeatAgeMs=${Date.now() - ownerHeartbeat} requestId=${this.#state.requestId}`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1278
1398
|
// If not forcing and current state is active (not terminal), resume instead of reset
|
|
1279
1399
|
if (!force && resumeEligible) {
|
|
1280
1400
|
// Reclamamos ownership si no había dueño o si cambió (sesión única / reconexión).
|
|
@@ -1558,6 +1678,8 @@ class OstackyController {
|
|
|
1558
1678
|
|
|
1559
1679
|
async abandon({ reason } = {}) {
|
|
1560
1680
|
this.#load();
|
|
1681
|
+
const denied = await this.#foreignOwnerError('abandon');
|
|
1682
|
+
if (denied) return denied;
|
|
1561
1683
|
const to = this.#isAllowedTransition(this.#state.state, 'abandon');
|
|
1562
1684
|
if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
|
|
1563
1685
|
await this.#transition(to, { error: reason || 'Abandoned' });
|
|
@@ -1567,6 +1689,8 @@ class OstackyController {
|
|
|
1567
1689
|
|
|
1568
1690
|
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
1569
1691
|
this.#load();
|
|
1692
|
+
const denied = await this.#foreignOwnerError('consume_route_decision');
|
|
1693
|
+
if (denied) return denied;
|
|
1570
1694
|
if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
|
|
1571
1695
|
return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
|
|
1572
1696
|
}
|
|
@@ -1906,6 +2030,8 @@ class OstackyController {
|
|
|
1906
2030
|
|
|
1907
2031
|
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
1908
2032
|
this.#load();
|
|
2033
|
+
const denied = await this.#foreignOwnerError('consume_execution_decision');
|
|
2034
|
+
if (denied) return denied;
|
|
1909
2035
|
if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
|
|
1910
2036
|
return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
|
|
1911
2037
|
}
|
|
@@ -1944,6 +2070,8 @@ class OstackyController {
|
|
|
1944
2070
|
*/
|
|
1945
2071
|
async refreshDecision() {
|
|
1946
2072
|
this.#load();
|
|
2073
|
+
const denied = await this.#foreignOwnerError('refresh_decision');
|
|
2074
|
+
if (denied) return denied;
|
|
1947
2075
|
const s = this.#state.state;
|
|
1948
2076
|
const prevFails = this.#state.consumeFailedCount || 0;
|
|
1949
2077
|
if (s === 'ROUTE_DECISION_PENDING') {
|
|
@@ -2036,15 +2164,19 @@ class OstackyController {
|
|
|
2036
2164
|
const task = this.#state.tasks[tid];
|
|
2037
2165
|
if (!task || task.status !== 'COMPLETED') continue;
|
|
2038
2166
|
let needle = String(verif).trim();
|
|
2167
|
+
let declaredPath = null;
|
|
2039
2168
|
if (needle.startsWith('codegraph:')) needle = needle.slice('codegraph:'.length).trim();
|
|
2040
2169
|
else if (needle.startsWith('file:')) {
|
|
2041
2170
|
const rest = needle.slice('file:'.length).trim();
|
|
2042
2171
|
const parts = rest.split('contiene');
|
|
2172
|
+
declaredPath = (parts[0] || '').trim() || null;
|
|
2043
2173
|
needle = (parts[1] || parts[0] || '').trim() || rest;
|
|
2044
2174
|
} else if (needle.startsWith('test:')) continue;
|
|
2045
2175
|
if (!needle) continue;
|
|
2046
2176
|
let content = null;
|
|
2047
|
-
|
|
2177
|
+
// El path de la verificación es la fuente: `task.filePath` puede no
|
|
2178
|
+
// existir (tasks completadas sin archivo declarado).
|
|
2179
|
+
const fp = task.filePath || declaredPath;
|
|
2048
2180
|
if (fp) {
|
|
2049
2181
|
try {
|
|
2050
2182
|
const abs =
|
|
@@ -2150,6 +2282,8 @@ class OstackyController {
|
|
|
2150
2282
|
|
|
2151
2283
|
async block({ reason } = {}) {
|
|
2152
2284
|
this.#load();
|
|
2285
|
+
const denied = await this.#foreignOwnerError('block');
|
|
2286
|
+
if (denied) return denied;
|
|
2153
2287
|
const from = this.#state.state;
|
|
2154
2288
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
2155
2289
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
@@ -2334,10 +2468,12 @@ class OstackyController {
|
|
|
2334
2468
|
if (!task || task.status !== 'COMPLETED') continue;
|
|
2335
2469
|
// Re-verificar via file content
|
|
2336
2470
|
let needle = String(verif).trim();
|
|
2471
|
+
let declaredPath = null;
|
|
2337
2472
|
if (needle.startsWith('codegraph:')) needle = needle.slice('codegraph:'.length).trim();
|
|
2338
2473
|
else if (needle.startsWith('file:')) {
|
|
2339
2474
|
const rest = needle.slice('file:'.length).trim();
|
|
2340
2475
|
const parts = rest.split('contiene');
|
|
2476
|
+
declaredPath = (parts[0] || '').trim() || null;
|
|
2341
2477
|
needle = (parts[1] || parts[0] || '').trim() || rest;
|
|
2342
2478
|
} else if (needle.startsWith('test:')) {
|
|
2343
2479
|
// test-verification-execution: cierre re-verifica evidencia; antes se salteaba.
|
|
@@ -2352,7 +2488,9 @@ class OstackyController {
|
|
|
2352
2488
|
}
|
|
2353
2489
|
if (!needle) continue;
|
|
2354
2490
|
let content = null;
|
|
2355
|
-
|
|
2491
|
+
// El path de la verificación es la fuente: `task.filePath` puede no
|
|
2492
|
+
// existir (tasks completadas sin archivo declarado).
|
|
2493
|
+
const fp = task.filePath || declaredPath;
|
|
2356
2494
|
if (fp) {
|
|
2357
2495
|
try {
|
|
2358
2496
|
const abs =
|
|
@@ -2647,11 +2785,34 @@ class OstackyController {
|
|
|
2647
2785
|
}
|
|
2648
2786
|
|
|
2649
2787
|
// --- fix-new-file-gate: ligadura validate → complete por filePath (mapa + fallback legacy) ---
|
|
2788
|
+
/**
|
|
2789
|
+
* Clave canónica de `lastValidated`. El plugin escribe paths absolutos y el
|
|
2790
|
+
* agente manda el path relativo (o al revés); comparar por string exacto
|
|
2791
|
+
* hacía que un validate reciente se viera ausente ("validate required").
|
|
2792
|
+
*/
|
|
2793
|
+
_validatedKey(filePath) {
|
|
2794
|
+
if (!filePath) return null;
|
|
2795
|
+
try {
|
|
2796
|
+
const abs =
|
|
2797
|
+
filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
2798
|
+
? resolve(filePath)
|
|
2799
|
+
: resolve(getProjectRoot(this.#statePath), filePath);
|
|
2800
|
+
return process.platform === 'win32' ? abs.toLowerCase() : abs;
|
|
2801
|
+
} catch {
|
|
2802
|
+
return filePath;
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2650
2805
|
_getValidatedFor(filePath) {
|
|
2651
2806
|
const lv = this.#state.lastValidated;
|
|
2652
2807
|
if (!lv || !filePath) return null;
|
|
2653
|
-
|
|
2654
|
-
if (lv.
|
|
2808
|
+
const key = this._validatedKey(filePath);
|
|
2809
|
+
if (lv.byFile && typeof lv.byFile === 'object') {
|
|
2810
|
+
if (lv.byFile[filePath]) return lv.byFile[filePath];
|
|
2811
|
+
for (const [k, v] of Object.entries(lv.byFile)) {
|
|
2812
|
+
if (this._validatedKey(k) === key) return v;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
if (lv.filePath && this._validatedKey(lv.filePath) === key) return lv; // fallback slot legacy (single)
|
|
2655
2816
|
return null;
|
|
2656
2817
|
}
|
|
2657
2818
|
async _setValidatedFor(filePath, hash, mode) {
|
|
@@ -2679,7 +2840,12 @@ class OstackyController {
|
|
|
2679
2840
|
async _clearValidatedFor(filePath) {
|
|
2680
2841
|
const lv = this.#state.lastValidated;
|
|
2681
2842
|
if (lv && lv.byFile && typeof lv.byFile === 'object') {
|
|
2682
|
-
if (filePath
|
|
2843
|
+
if (filePath) {
|
|
2844
|
+
const key = this._validatedKey(filePath);
|
|
2845
|
+
for (const k of Object.keys(lv.byFile)) {
|
|
2846
|
+
if (k === filePath || this._validatedKey(k) === key) delete lv.byFile[k];
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2683
2849
|
if (Object.keys(lv.byFile).length === 0) this.#state.lastValidated = null;
|
|
2684
2850
|
else this.#state.lastValidated = { byFile: lv.byFile, filePath: null, hash: null, ts: Date.now() };
|
|
2685
2851
|
} else {
|
|
@@ -2855,6 +3021,8 @@ class OstackyController {
|
|
|
2855
3021
|
*/
|
|
2856
3022
|
async completeTask({ taskId, filePath, fileHash } = {}) {
|
|
2857
3023
|
this.#load();
|
|
3024
|
+
const denied = await this.#foreignOwnerError('complete_task');
|
|
3025
|
+
if (denied) return denied;
|
|
2858
3026
|
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
2859
3027
|
return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
|
|
2860
3028
|
}
|
|
@@ -3236,19 +3404,39 @@ function extractCallerSessionId(ctx) {
|
|
|
3236
3404
|
return null;
|
|
3237
3405
|
}
|
|
3238
3406
|
|
|
3407
|
+
// ── Cola in-process (fix-consume-race-lock) ──────────────────────────────────
|
|
3408
|
+
// Code Mode emite varias tools en paralelo DENTRO del mismo proceso MCP y
|
|
3409
|
+
// `#state` es un único objeto compartido. Un `reloadFresh()` concurrente (p.ej.
|
|
3410
|
+
// un `get_state` en el mismo batch) reemplazaba `#state` mientras otro handler
|
|
3411
|
+
// tenía una transición en vuelo: el `#persist` serializaba el snapshot viejo y
|
|
3412
|
+
// el `consume_*` se perdía. El disco quedaba en `*_PENDING` y el agente volvía
|
|
3413
|
+
// a preguntar DIRECT/SPEC e INLINE/SUBAGENT. Serializar restaura el invariante
|
|
3414
|
+
// "un read-modify-write a la vez" también in-process.
|
|
3415
|
+
let opQueue = Promise.resolve();
|
|
3416
|
+
|
|
3417
|
+
export function serializeOp(run) {
|
|
3418
|
+
const next = opQueue.then(run, run);
|
|
3419
|
+
opQueue = next.then(
|
|
3420
|
+
() => {},
|
|
3421
|
+
() => {}
|
|
3422
|
+
);
|
|
3423
|
+
return next;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3239
3426
|
function safeHandler(fn, options = {}) {
|
|
3240
3427
|
const { maxRetries = 0, baseTimeout = 5000 } = options;
|
|
3241
3428
|
|
|
3242
|
-
return
|
|
3429
|
+
return (params, ctx) => serializeOp(async () => {
|
|
3243
3430
|
// ── multi-sesión: identidad + estado fresco + lock por operación ──
|
|
3244
|
-
// 1) sessionId del caller (de `_meta`), 2)
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3431
|
+
// 1) sessionId del caller (de `_meta`), 2) lock durante TODO el
|
|
3432
|
+
// read-modify-write, 3) re-lectura fresca DENTRO del lock: leer antes de
|
|
3433
|
+
// tomarlo dejaba una ventana donde otro proceso escribía y el persist
|
|
3434
|
+
// posterior revertía esa escritura (lost update).
|
|
3247
3435
|
let heldLock = false;
|
|
3248
3436
|
try {
|
|
3249
3437
|
controller.setCallerSession(extractCallerSessionId(ctx));
|
|
3250
|
-
controller.reloadFresh();
|
|
3251
3438
|
heldLock = await controller.beginWrite();
|
|
3439
|
+
controller.reloadFresh();
|
|
3252
3440
|
} catch {}
|
|
3253
3441
|
const endOp = () => {
|
|
3254
3442
|
if (!heldLock) return;
|
|
@@ -3327,12 +3515,12 @@ function safeHandler(fn, options = {}) {
|
|
|
3327
3515
|
};
|
|
3328
3516
|
}
|
|
3329
3517
|
}
|
|
3330
|
-
|
|
3518
|
+
});
|
|
3331
3519
|
}
|
|
3332
3520
|
|
|
3333
3521
|
const server = new McpServer({
|
|
3334
3522
|
name: 'ostacky-controller',
|
|
3335
|
-
version: '0.10.
|
|
3523
|
+
version: '0.10.3',
|
|
3336
3524
|
});
|
|
3337
3525
|
|
|
3338
3526
|
server.registerTool(
|
|
@@ -3934,7 +4122,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
3934
4122
|
}
|
|
3935
4123
|
|
|
3936
4124
|
async function main() {
|
|
3937
|
-
log('Starting ostacky-controller MCP v0.10.
|
|
4125
|
+
log('Starting ostacky-controller MCP v0.10.3...');
|
|
3938
4126
|
log('State path:', { path: statePath });
|
|
3939
4127
|
// Clean up stale tmp/lock files from previous runs
|
|
3940
4128
|
cleanupTmpFiles(statePath);
|
package/assets/plugins/engram.ts
CHANGED
|
@@ -575,17 +575,21 @@ export default {
|
|
|
575
575
|
// 2) try local opencode.json / jsonc in project
|
|
576
576
|
if (!statePath) {
|
|
577
577
|
const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
|
|
578
|
-
// also try global config (XDG /
|
|
578
|
+
// also try global config (OPENCODE_CONFIG_DIR / XDG / ~/.config)
|
|
579
579
|
try {
|
|
580
|
+
const explicit = process.env.OPENCODE_CONFIG_DIR?.trim()
|
|
580
581
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
582
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim()
|
|
583
|
+
const globalDir = explicit
|
|
584
|
+
? explicit
|
|
585
|
+
: xdg
|
|
586
|
+
? join(xdg, "opencode")
|
|
587
|
+
: home
|
|
588
|
+
? join(home, ".config", "opencode")
|
|
589
|
+
: ""
|
|
590
|
+
if (globalDir) {
|
|
591
|
+
candidates.push(join(globalDir, "opencode.json"))
|
|
592
|
+
candidates.push(join(globalDir, "opencode.jsonc"))
|
|
589
593
|
}
|
|
590
594
|
} catch {}
|
|
591
595
|
for (const cand of candidates) {
|