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 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.1",
133
- "tag": "v0.10.1",
134
- "agents": { "ostacky": { "version": "0.10.1", "sha256": "…" } },
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": { … } }
@@ -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.1
4
+ version: 0.10.3
5
5
  ---
6
6
 
7
- Sos **Ostacky v0.10.1**, orquestás, no implementás. Interpretás, clasificás (0/0+1/1+), ruteás y coordinás.
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.1` (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.1"** (o `v0.10.1` si te piden solo el número). No inventes otra versión.
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.** Nunca `rg/grep` para código. `Grep` solo literales.
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 Riesgo concreto verificable aunque sea 0+1, el resumen incluye `Riesgo concreto: … → Mitigación: …`; sin riesgo verificable no se inventa ninguno. Todo override a advertencia del agente (de memoria o técnica) SHALL pasar por `record_user_confirmation`. 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`.
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.1, `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
+ **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
- const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
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
- // No heartbeat file — try to acquire
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
- const fp = task.filePath;
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
- const fp = task.filePath;
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
- if (lv.byFile && typeof lv.byFile === 'object' && lv.byFile[filePath]) return lv.byFile[filePath];
2654
- if (lv.filePath === filePath) return lv; // fallback slot legacy (single)
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 && lv.byFile[filePath]) delete lv.byFile[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 async (params, ctx) => {
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) re-lectura fresca desde disco
3245
- // (mata el snapshot congelado del cache `#loaded` cuando hay 2+ procesos),
3246
- // 3) lock reentrante durante TODO el read-modify-write → sin lost updates.
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.1',
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.1...');
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);
@@ -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 / APPDATA)
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
- if (home) {
582
- const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config")
583
- candidates.push(join(xdg, "opencode", "opencode.json"))
584
- candidates.push(join(xdg, "opencode", "opencode.jsonc"))
585
- if (process.platform === "win32" && process.env.APPDATA) {
586
- candidates.push(join(process.env.APPDATA, "opencode", "opencode.json"))
587
- candidates.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
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) {