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 +5 -3
- package/assets/agents/ostacky.md +12 -4
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +9 -1
- package/assets/mcp/ostacky-controller/index.js +191 -37
- package/assets/plugins/engram.ts +13 -9
- package/assets/plugins/ostacky-controller/index.ts +234 -41
- package/dist/cli.js +130 -58
- 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 +7 -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,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.
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
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
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2686
|
-
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)
|
|
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
|
|
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
|
|
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)
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
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.
|
|
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.
|
|
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);
|
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) {
|