ostacky 0.10.2 → 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.2",
133
- "tag": "v0.10.2",
134
- "agents": { "ostacky": { "version": "0.10.2", "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,12 +1,12 @@
1
1
  ---
2
2
  description: Orquestador principal — rutea por nivel, orquesta CodeGraph + OpenSpec + Superpowers.
3
3
  mode: primary
4
- version: 0.10.2
4
+ version: 0.10.3
5
5
  ---
6
6
 
7
- Sos **Ostacky v0.10.2**, 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.2` (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.2"** (o `v0.10.2` 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
 
@@ -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,15 +87,21 @@ 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
@@ -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.2, `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]`.
@@ -121,4 +129,4 @@ El job `test` de `.github/workflows/ci.yml` corre `bunx openspec validate --all
121
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:`).
122
130
  2. Debug de deltas parseados: `openspec show <id> --json --deltas-only`.
123
131
  3. Change WIP incompleto en `main` → completarlo o removerlo del repo; specs de `openspec/specs/` rotos → fixear el formato in-place.
124
- 4. Si tocás `assets/` regenerá hashes (`bun run scripts/update-manifest-hashes.ts`) y usá `bun run test:safe` (nunca `bun test` full: se cuelga).
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 {
@@ -1262,6 +1340,8 @@ class OstackyController {
1262
1340
 
1263
1341
  const caller = this.#callerSessionId;
1264
1342
  const owner = this.#state.ownerSessionId;
1343
+ const ownerHeartbeat = this.#state.ownerHeartbeat ?? 0;
1344
+ const ownerStale = !ownerHeartbeat || Date.now() - ownerHeartbeat > OWNER_STALE_MS;
1265
1345
  // OJO: `INTERPRETATION_PENDING` está en TERMINAL_STATES — es "terminal" para
1266
1346
  // decidir si se RESUME, pero NO significa que no haya trabajo. Por eso separamos:
1267
1347
  // - resumeEligible: semántica ORIGINAL de start_request (no la tocamos).
@@ -1272,7 +1352,8 @@ class OstackyController {
1272
1352
  this.#state.state !== STATES.DONE &&
1273
1353
  !!owner &&
1274
1354
  !!caller &&
1275
- owner !== caller;
1355
+ owner !== caller &&
1356
+ !ownerStale;
1276
1357
 
1277
1358
  // ── Guard de ownership (multi-sesión) ──
1278
1359
  // Si hay una request viva de OTRA sesión y no se fuerza, NO pisamos nada:
@@ -1297,6 +1378,23 @@ class OstackyController {
1297
1378
  };
1298
1379
  }
1299
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
+
1300
1398
  // If not forcing and current state is active (not terminal), resume instead of reset
1301
1399
  if (!force && resumeEligible) {
1302
1400
  // Reclamamos ownership si no había dueño o si cambió (sesión única / reconexión).
@@ -2066,15 +2164,19 @@ class OstackyController {
2066
2164
  const task = this.#state.tasks[tid];
2067
2165
  if (!task || task.status !== 'COMPLETED') continue;
2068
2166
  let needle = String(verif).trim();
2167
+ let declaredPath = null;
2069
2168
  if (needle.startsWith('codegraph:')) needle = needle.slice('codegraph:'.length).trim();
2070
2169
  else if (needle.startsWith('file:')) {
2071
2170
  const rest = needle.slice('file:'.length).trim();
2072
2171
  const parts = rest.split('contiene');
2172
+ declaredPath = (parts[0] || '').trim() || null;
2073
2173
  needle = (parts[1] || parts[0] || '').trim() || rest;
2074
2174
  } else if (needle.startsWith('test:')) continue;
2075
2175
  if (!needle) continue;
2076
2176
  let content = null;
2077
- 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;
2078
2180
  if (fp) {
2079
2181
  try {
2080
2182
  const abs =
@@ -2366,10 +2468,12 @@ class OstackyController {
2366
2468
  if (!task || task.status !== 'COMPLETED') continue;
2367
2469
  // Re-verificar via file content
2368
2470
  let needle = String(verif).trim();
2471
+ let declaredPath = null;
2369
2472
  if (needle.startsWith('codegraph:')) needle = needle.slice('codegraph:'.length).trim();
2370
2473
  else if (needle.startsWith('file:')) {
2371
2474
  const rest = needle.slice('file:'.length).trim();
2372
2475
  const parts = rest.split('contiene');
2476
+ declaredPath = (parts[0] || '').trim() || null;
2373
2477
  needle = (parts[1] || parts[0] || '').trim() || rest;
2374
2478
  } else if (needle.startsWith('test:')) {
2375
2479
  // test-verification-execution: cierre re-verifica evidencia; antes se salteaba.
@@ -2384,7 +2488,9 @@ class OstackyController {
2384
2488
  }
2385
2489
  if (!needle) continue;
2386
2490
  let content = null;
2387
- 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;
2388
2494
  if (fp) {
2389
2495
  try {
2390
2496
  const abs =
@@ -2679,11 +2785,34 @@ class OstackyController {
2679
2785
  }
2680
2786
 
2681
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
+ }
2682
2805
  _getValidatedFor(filePath) {
2683
2806
  const lv = this.#state.lastValidated;
2684
2807
  if (!lv || !filePath) return null;
2685
- if (lv.byFile && typeof lv.byFile === 'object' && lv.byFile[filePath]) return lv.byFile[filePath];
2686
- 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)
2687
2816
  return null;
2688
2817
  }
2689
2818
  async _setValidatedFor(filePath, hash, mode) {
@@ -2711,7 +2840,12 @@ class OstackyController {
2711
2840
  async _clearValidatedFor(filePath) {
2712
2841
  const lv = this.#state.lastValidated;
2713
2842
  if (lv && lv.byFile && typeof lv.byFile === 'object') {
2714
- 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
+ }
2715
2849
  if (Object.keys(lv.byFile).length === 0) this.#state.lastValidated = null;
2716
2850
  else this.#state.lastValidated = { byFile: lv.byFile, filePath: null, hash: null, ts: Date.now() };
2717
2851
  } else {
@@ -3270,19 +3404,39 @@ function extractCallerSessionId(ctx) {
3270
3404
  return null;
3271
3405
  }
3272
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
+
3273
3426
  function safeHandler(fn, options = {}) {
3274
3427
  const { maxRetries = 0, baseTimeout = 5000 } = options;
3275
3428
 
3276
- return async (params, ctx) => {
3429
+ return (params, ctx) => serializeOp(async () => {
3277
3430
  // ── multi-sesión: identidad + estado fresco + lock por operación ──
3278
- // 1) sessionId del caller (de `_meta`), 2) re-lectura fresca desde disco
3279
- // (mata el snapshot congelado del cache `#loaded` cuando hay 2+ procesos),
3280
- // 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).
3281
3435
  let heldLock = false;
3282
3436
  try {
3283
3437
  controller.setCallerSession(extractCallerSessionId(ctx));
3284
- controller.reloadFresh();
3285
3438
  heldLock = await controller.beginWrite();
3439
+ controller.reloadFresh();
3286
3440
  } catch {}
3287
3441
  const endOp = () => {
3288
3442
  if (!heldLock) return;
@@ -3361,12 +3515,12 @@ function safeHandler(fn, options = {}) {
3361
3515
  };
3362
3516
  }
3363
3517
  }
3364
- };
3518
+ });
3365
3519
  }
3366
3520
 
3367
3521
  const server = new McpServer({
3368
3522
  name: 'ostacky-controller',
3369
- version: '0.10.2',
3523
+ version: '0.10.3',
3370
3524
  });
3371
3525
 
3372
3526
  server.registerTool(
@@ -3968,7 +4122,7 @@ function setupGracefulShutdown(ctrl) {
3968
4122
  }
3969
4123
 
3970
4124
  async function main() {
3971
- log('Starting ostacky-controller MCP v0.10.2...');
4125
+ log('Starting ostacky-controller MCP v0.10.3...');
3972
4126
  log('State path:', { path: statePath });
3973
4127
  // Clean up stale tmp/lock files from previous runs
3974
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) {