ostacky 0.5.9 → 0.5.10
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 +9 -9
- package/assets/agents/ostacky.md +18 -0
- package/assets/commands/install-stack.md +1 -1
- package/assets/mcp/ostacky-controller/index.js +231 -82
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/tests/agent-contract.test.js +4 -3
- package/dist/cli.js +27 -28
- package/dist/mcp/ostacky-controller/index.js +191 -89
- package/manifest.json +23 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -218,33 +218,33 @@ Tras instalar, el proyecto queda así:
|
|
|
218
218
|
|
|
219
219
|
```json
|
|
220
220
|
{
|
|
221
|
-
"version": "0.5.
|
|
221
|
+
"version": "0.5.10",
|
|
222
222
|
"lockedAt": "2025-01-01T00:00:00.000Z",
|
|
223
223
|
"repo": "JaimeHoracio/Ostacky",
|
|
224
|
-
"tag": "v0.5.
|
|
224
|
+
"tag": "v0.5.10",
|
|
225
225
|
"agents": {
|
|
226
226
|
"ostacky": {
|
|
227
|
-
"version": "0.5.
|
|
227
|
+
"version": "0.5.10",
|
|
228
228
|
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
229
229
|
"sha256": "abc123..."
|
|
230
230
|
}
|
|
231
231
|
},
|
|
232
232
|
"commands": {
|
|
233
233
|
"install-stack": {
|
|
234
|
-
"version": "0.5.
|
|
234
|
+
"version": "0.5.10",
|
|
235
235
|
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
236
236
|
"sha256": "def456..."
|
|
237
237
|
},
|
|
238
238
|
"opsx-sync": {
|
|
239
|
-
"version": "0.5.
|
|
239
|
+
"version": "0.5.10",
|
|
240
240
|
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
241
241
|
"sha256": "ghi789..."
|
|
242
242
|
}
|
|
243
243
|
},
|
|
244
244
|
"skills": {
|
|
245
|
-
"thinking": { "version": "0.5.
|
|
246
|
-
"execution-mode-evaluation": { "version": "0.5.
|
|
247
|
-
"openspec-propose": { "version": "0.5.
|
|
245
|
+
"thinking": { "version": "0.5.10", ... },
|
|
246
|
+
"execution-mode-evaluation": { "version": "0.5.10", ... },
|
|
247
|
+
"openspec-propose": { "version": "0.5.10", ... }
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
```
|
|
@@ -274,7 +274,7 @@ Es opcional y solo necesario si algo falló durante la instalación o si querés
|
|
|
274
274
|
## Seguridad
|
|
275
275
|
|
|
276
276
|
- `opencode.jsonc` se versiona en el repo para compartir permisos y MCP de forma reproducible.
|
|
277
|
-
- Las URLs de descarga usan **tags de GitHub** (ej. `v0.5.
|
|
277
|
+
- Las URLs de descarga usan **tags de GitHub** (ej. `v0.5.10`), nunca `main` — instalaciones reproducibles
|
|
278
278
|
- Cada path de archivo descargado es validado para prevenir **path traversal**
|
|
279
279
|
- Los archivos incluyen **checksum SHA-256** opcional; si el manifest lo define, el contenido se verifica antes de escribir
|
|
280
280
|
- El cache local (`~/.opencode/cache/`) también valida integridad al servir archivos cacheados
|
package/assets/agents/ostacky.md
CHANGED
|
@@ -160,6 +160,24 @@ La opción por defecto va primera. **La primera respuesta del usuario es vincula
|
|
|
160
160
|
|
|
161
161
|
**Cierre obligatorio:** DESPUÉS de `implementation_complete`, llamá `sync_complete` en el MISMO turno. Si te olvidás, el controller queda en SYNC y el próximo request falla.
|
|
162
162
|
|
|
163
|
+
## Manejo de errores de tools MCP
|
|
164
|
+
|
|
165
|
+
**Si una tool MCP no responde o devuelve error, la jerarquía de recursos es:**
|
|
166
|
+
|
|
167
|
+
1. **CodeGraph** (primera opción siempre) → explora símbolos, callers, impacto, flujos
|
|
168
|
+
2. **Engram** (si CodeGraph no está disponible) → `engram_mem_context` y `engram_mem_search` para recuperar contexto de sesiones previas, decisiones, bugs. Engram sabe lo que se hizo antes — no caigas a Grep si Engram está vivo.
|
|
169
|
+
3. **Read + Glob** (último recurso) → solo cuando **ninguno** de los dos anteriores funciona
|
|
170
|
+
|
|
171
|
+
**Por tool:**
|
|
172
|
+
|
|
173
|
+
1. **CodeGraph**: Si `codegraph_explore` falla o no responde → si Engram está funcionando, usá `engram_mem_context` + `engram_mem_search` para recuperar contexto. Como último recurso, `Read` archivos directamente. **NUNCA uses Grep para explorar si Engram está disponible.**
|
|
174
|
+
2. **Controller**: Si el ostacky-controller no está disponible → operá sin validación de estado. Todas las transiciones se manejan en lenguaje natural. No ejecutes subagentes sin autorización explícita.
|
|
175
|
+
3. **Engram**: Si `engram_mem_*` falla → continuá sin memoria persistente. No bloquees el flujo por falta de memoria.
|
|
176
|
+
4. **Timeout en tool call**: Si una tool no responde después de un intento, **no reintentes**. Reportá el error al usuario y seguí con lo que tenés. No te quedes en loop.
|
|
177
|
+
5. **Tool no encontrada**: Si llamás una tool y recibís "tool not found" o "unavailable tool" → esa tool no está registrada. Reportalo. Si Engram está disponible, usalo para contexto. Si no, `Read` + `Glob`. Grep es el ABSOLUTO último recurso.
|
|
178
|
+
|
|
179
|
+
**Regla de oro: ninguna tool failure debe congelar el agente.** Siempre tené un plan B antes de llamar a cualquier tool. Engram > Read > Grep.
|
|
180
|
+
|
|
163
181
|
## Guardrails
|
|
164
182
|
|
|
165
183
|
### Decisiones y estado
|
|
@@ -5,7 +5,7 @@ agent: build
|
|
|
5
5
|
|
|
6
6
|
Instala el stack tecnológico de desarrollo para OpenCode. **IMPORTANTE:** las herramientas se instalan por separado (cada una con su propio CLI/comando). `npx ostacky install` solo instala el agente y commands de Ostacky en `.opencode/`. Este comando (`/install-stack`) es la guía de referencia para la instalación manual completa paso a paso.
|
|
7
7
|
|
|
8
|
-
**Nota:** A partir de v0.5.
|
|
8
|
+
**Nota:** A partir de v0.5.10, `npx ostacky install` ya instala automáticamente el stack completo (CodeGraph, OpenSpec, Engram, Context7, MCPs bundleados) además del agente y skills. Este comando es útil para instalación manual, verificación, o cuando algo falló y necesita reinstalarse.
|
|
9
9
|
|
|
10
10
|
**RESTRICCIÓN ABSOLUTA:** instalar ÚNICAMENTE para OpenCode. Está terminantemente prohibido crear o modificar archivos en `.claude/`, `.kiro/`, `.cursor/`, `.gemini/`, `.codex/`, `.antigravity/`, `.windsurf/` o cualquier otro directorio de plataformas externas.
|
|
11
11
|
|
|
@@ -3,15 +3,63 @@
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
5
5
|
import * as z from 'zod/v4';
|
|
6
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
7
|
-
import { dirname } from 'node:path';
|
|
6
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { dirname, basename } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const MAX_TASKS = 50;
|
|
10
|
+
const MAX_SNAPSHOT_JSON_LENGTH = 100 * 1024; // 100KB per snapshot serialized
|
|
11
|
+
const MAX_STATE_FILE_SIZE = 1024 * 1024; // 1MB hard cap for the entire state file
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Safe JSON.stringify that won't throw on circular references.
|
|
15
|
+
*/
|
|
16
|
+
function safeJsonStringify(obj, pretty = false) {
|
|
17
|
+
const seen = new WeakSet();
|
|
18
|
+
try {
|
|
19
|
+
return JSON.stringify(
|
|
20
|
+
obj,
|
|
21
|
+
(key, value) => {
|
|
22
|
+
if (typeof value === 'object' && value !== null) {
|
|
23
|
+
if (seen.has(value)) return '[Circular]';
|
|
24
|
+
seen.add(value);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
},
|
|
28
|
+
pretty ? 2 : undefined
|
|
29
|
+
);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return `[Unstringifiable: ${e.message}]`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
8
34
|
|
|
9
35
|
function log(event, data) {
|
|
10
36
|
const ts = new Date().toISOString();
|
|
11
|
-
const payload = data ? ` ${
|
|
37
|
+
const payload = data ? ` ${safeJsonStringify(data)}` : '';
|
|
12
38
|
console.error(`[${ts}] ${event}${payload}`);
|
|
13
39
|
}
|
|
14
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Cleans up stale .tmp.* files from a previous crash.
|
|
43
|
+
*/
|
|
44
|
+
function cleanupTmpFiles(statePath) {
|
|
45
|
+
if (!statePath) return;
|
|
46
|
+
const dir = dirname(statePath);
|
|
47
|
+
const name = basename(statePath);
|
|
48
|
+
try {
|
|
49
|
+
for (const entry of readdirSync(dir)) {
|
|
50
|
+
if (entry.startsWith(name + '.tmp.')) {
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(dir + '/' + entry);
|
|
53
|
+
} catch {
|
|
54
|
+
/* best-effort */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* directory may not exist yet */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
15
63
|
const STATES = Object.freeze({
|
|
16
64
|
INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
|
|
17
65
|
CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
|
|
@@ -66,11 +114,32 @@ class OstackyController {
|
|
|
66
114
|
this.#loaded = true;
|
|
67
115
|
return;
|
|
68
116
|
}
|
|
117
|
+
// Try primary state file
|
|
69
118
|
try {
|
|
70
119
|
const raw = readFileSync(this.#statePath, 'utf8');
|
|
120
|
+
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`State file too large: ${raw.length} bytes`);
|
|
71
121
|
this.#state = { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
122
|
+
this.#loaded = true;
|
|
123
|
+
return;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log('warn:load_primary_failed', { error: err.message });
|
|
126
|
+
}
|
|
127
|
+
// Fallback: try .backup
|
|
128
|
+
const backupPath = this.#statePath + '.backup';
|
|
129
|
+
try {
|
|
130
|
+
const raw = readFileSync(backupPath, 'utf8');
|
|
131
|
+
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
|
|
132
|
+
this.#state = { ...DEFAULT_STATE, ...JSON.parse(raw), error: 'State restored from backup' };
|
|
133
|
+
log('warn:state_restored_from_backup');
|
|
134
|
+
this.#loaded = true;
|
|
135
|
+
return;
|
|
72
136
|
} catch {
|
|
73
|
-
|
|
137
|
+
// No backup either — set error state instead of silent reset
|
|
138
|
+
this.#state = {
|
|
139
|
+
...DEFAULT_STATE,
|
|
140
|
+
error: `State file corrupt: ${err.message}. No backup available. State reset to default.`,
|
|
141
|
+
};
|
|
142
|
+
log('warn:state_reset', { error: err.message });
|
|
74
143
|
}
|
|
75
144
|
this.#loaded = true;
|
|
76
145
|
}
|
|
@@ -79,15 +148,57 @@ class OstackyController {
|
|
|
79
148
|
if (!this.#statePath) return;
|
|
80
149
|
const dir = dirname(this.#statePath);
|
|
81
150
|
mkdirSync(dir, { recursive: true });
|
|
151
|
+
const serialized = safeJsonStringify(this.#state, true);
|
|
152
|
+
// Hard cap — if state exceeds 1MB, trim snapshots and retry
|
|
153
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
154
|
+
log('warn:state_oversized', { size: serialized.length });
|
|
155
|
+
this.#state.snapshots = { codegraph: null, execution: null };
|
|
156
|
+
const trimmed = safeJsonStringify(this.#state, true);
|
|
157
|
+
if (trimmed.length > MAX_STATE_FILE_SIZE) {
|
|
158
|
+
log('error:state_too_large_even_after_trim');
|
|
159
|
+
return; // Don't persist — better to keep old state than write garbage
|
|
160
|
+
}
|
|
161
|
+
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
162
|
+
writeFileSync(tmp, trimmed, 'utf8');
|
|
163
|
+
renameSync(tmp, this.#statePath);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
82
166
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
83
|
-
writeFileSync(tmp,
|
|
167
|
+
writeFileSync(tmp, serialized, 'utf8');
|
|
84
168
|
renameSync(tmp, this.#statePath);
|
|
169
|
+
// Best-effort backup
|
|
170
|
+
try {
|
|
171
|
+
const backupPath = this.#statePath + '.backup';
|
|
172
|
+
writeFileSync(backupPath, serialized, 'utf8');
|
|
173
|
+
} catch {
|
|
174
|
+
/* backup is best-effort */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Trims old completed tasks when we exceed MAX_TASKS.
|
|
180
|
+
* Keeps the most recent MAX_TASKS entries.
|
|
181
|
+
*/
|
|
182
|
+
#trimTasks() {
|
|
183
|
+
if (!this.#state.tasks) return;
|
|
184
|
+
const entries = Object.entries(this.#state.tasks);
|
|
185
|
+
if (entries.length <= MAX_TASKS) return;
|
|
186
|
+
// Sort by completedAt (desc), keep newest MAX_TASKS
|
|
187
|
+
entries.sort((a, b) => {
|
|
188
|
+
const da = a[1].completedAt || '';
|
|
189
|
+
const db = b[1].completedAt || '';
|
|
190
|
+
return db.localeCompare(da);
|
|
191
|
+
});
|
|
192
|
+
const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
|
|
193
|
+
this.#state.tasks = trimmed;
|
|
194
|
+
log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
|
|
85
195
|
}
|
|
86
196
|
|
|
87
197
|
#transition(to, changes = {}) {
|
|
88
198
|
this.#state.revision++;
|
|
89
199
|
this.#state.state = to;
|
|
90
200
|
Object.assign(this.#state, changes);
|
|
201
|
+
this.#trimTasks();
|
|
91
202
|
this.#persist();
|
|
92
203
|
}
|
|
93
204
|
|
|
@@ -206,7 +317,7 @@ class OstackyController {
|
|
|
206
317
|
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
207
318
|
if (!to) return { error: `Cannot record discovery from state ${this.#state.state}` };
|
|
208
319
|
if (!['0', '0+1', '1+'].includes(level)) return { error: `Invalid level: ${level}` };
|
|
209
|
-
this.#transition(
|
|
320
|
+
this.#transition(to, {
|
|
210
321
|
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
211
322
|
routeChoice: null,
|
|
212
323
|
snapshots: { ...this.#state.snapshots, codegraph: snapshot || this.#state.snapshots.codegraph },
|
|
@@ -401,6 +512,7 @@ class OstackyController {
|
|
|
401
512
|
if (!this.#state.fileFingerprints) this.#state.fileFingerprints = {};
|
|
402
513
|
this.#state.fileFingerprints[filePath] = fileHash;
|
|
403
514
|
}
|
|
515
|
+
this.#trimTasks();
|
|
404
516
|
this.#persist();
|
|
405
517
|
return {
|
|
406
518
|
taskId,
|
|
@@ -409,14 +521,42 @@ class OstackyController {
|
|
|
409
521
|
.length,
|
|
410
522
|
};
|
|
411
523
|
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Public flush — force-persists current state to disk.
|
|
527
|
+
* Used by graceful shutdown (private fields not accessible from outside).
|
|
528
|
+
*/
|
|
529
|
+
flush() {
|
|
530
|
+
this.#persist();
|
|
531
|
+
}
|
|
412
532
|
}
|
|
413
533
|
|
|
414
534
|
const statePath = process.env.OSTACKY_STATE_PATH || '.opencode/ostacky-state.json';
|
|
415
535
|
const controller = new OstackyController({ statePath });
|
|
416
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Wraps an async tool handler to ALWAYS return a response (even on error).
|
|
539
|
+
* Without this, an unhandled exception in any tool handler leaves the LLM
|
|
540
|
+
* waiting forever — the root cause of agent freezes.
|
|
541
|
+
*/
|
|
542
|
+
function safeHandler(fn) {
|
|
543
|
+
return async (params) => {
|
|
544
|
+
try {
|
|
545
|
+
const result = await fn(params);
|
|
546
|
+
return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
|
|
547
|
+
} catch (error) {
|
|
548
|
+
log('tool:error', { name: fn.name || 'anonymous', error: error.message });
|
|
549
|
+
return {
|
|
550
|
+
content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
|
|
551
|
+
isError: true,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
417
557
|
const server = new McpServer({
|
|
418
558
|
name: 'ostacky-controller',
|
|
419
|
-
version: '0.5.
|
|
559
|
+
version: '0.5.10',
|
|
420
560
|
});
|
|
421
561
|
|
|
422
562
|
server.registerTool(
|
|
@@ -428,11 +568,10 @@ server.registerTool(
|
|
|
428
568
|
changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
|
|
429
569
|
}),
|
|
430
570
|
},
|
|
431
|
-
async ({ requestId, changeId }) => {
|
|
571
|
+
safeHandler(async ({ requestId, changeId }) => {
|
|
432
572
|
log('tool:start_request');
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
573
|
+
return await controller.startRequest({ requestId, changeId });
|
|
574
|
+
})
|
|
436
575
|
);
|
|
437
576
|
|
|
438
577
|
server.registerTool(
|
|
@@ -443,11 +582,10 @@ server.registerTool(
|
|
|
443
582
|
question: z.string().optional().describe('The clarification question'),
|
|
444
583
|
}),
|
|
445
584
|
},
|
|
446
|
-
async ({ question }) => {
|
|
585
|
+
safeHandler(async ({ question }) => {
|
|
447
586
|
log('tool:request_clarification');
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
587
|
+
return await controller.requestClarification({ question });
|
|
588
|
+
})
|
|
451
589
|
);
|
|
452
590
|
|
|
453
591
|
server.registerTool(
|
|
@@ -456,11 +594,10 @@ server.registerTool(
|
|
|
456
594
|
description: 'Record that clarification was answered. Transitions to DISCOVERY.',
|
|
457
595
|
inputSchema: z.object({}),
|
|
458
596
|
},
|
|
459
|
-
async () => {
|
|
597
|
+
safeHandler(async () => {
|
|
460
598
|
log('tool:record_clarification');
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
599
|
+
return await controller.recordClarification();
|
|
600
|
+
})
|
|
464
601
|
);
|
|
465
602
|
|
|
466
603
|
server.registerTool(
|
|
@@ -473,11 +610,10 @@ server.registerTool(
|
|
|
473
610
|
snapshot: z.any().optional().describe('Optional CodeGraph snapshot'),
|
|
474
611
|
}),
|
|
475
612
|
},
|
|
476
|
-
async ({ level, routeDecisionId, snapshot }) => {
|
|
613
|
+
safeHandler(async ({ level, routeDecisionId, snapshot }) => {
|
|
477
614
|
log('tool:record_discovery', { level });
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
615
|
+
return await controller.recordDiscovery({ level, routeDecisionId, snapshot });
|
|
616
|
+
})
|
|
481
617
|
);
|
|
482
618
|
|
|
483
619
|
server.registerTool(
|
|
@@ -489,11 +625,10 @@ server.registerTool(
|
|
|
489
625
|
choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
|
|
490
626
|
}),
|
|
491
627
|
},
|
|
492
|
-
async ({ decisionId, choice }) => {
|
|
628
|
+
safeHandler(async ({ decisionId, choice }) => {
|
|
493
629
|
log('tool:consume_route_decision', { choice });
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
630
|
+
return await controller.consumeRouteDecision({ decisionId, choice });
|
|
631
|
+
})
|
|
497
632
|
);
|
|
498
633
|
|
|
499
634
|
server.registerTool(
|
|
@@ -502,11 +637,10 @@ server.registerTool(
|
|
|
502
637
|
description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
|
|
503
638
|
inputSchema: z.object({}),
|
|
504
639
|
},
|
|
505
|
-
async () => {
|
|
640
|
+
safeHandler(async () => {
|
|
506
641
|
log('tool:spec_complete');
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
642
|
+
return await controller.specComplete();
|
|
643
|
+
})
|
|
510
644
|
);
|
|
511
645
|
|
|
512
646
|
server.registerTool(
|
|
@@ -518,11 +652,10 @@ server.registerTool(
|
|
|
518
652
|
snapshot: z.any().optional().describe('Execution analysis snapshot'),
|
|
519
653
|
}),
|
|
520
654
|
},
|
|
521
|
-
async ({ executionDecisionId, snapshot }) => {
|
|
655
|
+
safeHandler(async ({ executionDecisionId, snapshot }) => {
|
|
522
656
|
log('tool:record_execution_analysis');
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
657
|
+
return await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
|
|
658
|
+
})
|
|
526
659
|
);
|
|
527
660
|
|
|
528
661
|
server.registerTool(
|
|
@@ -534,11 +667,10 @@ server.registerTool(
|
|
|
534
667
|
mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
|
|
535
668
|
}),
|
|
536
669
|
},
|
|
537
|
-
async ({ decisionId, mode }) => {
|
|
670
|
+
safeHandler(async ({ decisionId, mode }) => {
|
|
538
671
|
log('tool:consume_execution_decision', { mode });
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
672
|
+
return await controller.consumeExecutionDecision({ decisionId, mode });
|
|
673
|
+
})
|
|
542
674
|
);
|
|
543
675
|
|
|
544
676
|
server.registerTool(
|
|
@@ -547,11 +679,10 @@ server.registerTool(
|
|
|
547
679
|
description: 'Mark implementation as complete. Transitions to SYNC.',
|
|
548
680
|
inputSchema: z.object({}),
|
|
549
681
|
},
|
|
550
|
-
async () => {
|
|
682
|
+
safeHandler(async () => {
|
|
551
683
|
log('tool:implementation_complete');
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
}
|
|
684
|
+
return await controller.implementationComplete();
|
|
685
|
+
})
|
|
555
686
|
);
|
|
556
687
|
|
|
557
688
|
server.registerTool(
|
|
@@ -560,11 +691,10 @@ server.registerTool(
|
|
|
560
691
|
description: 'Mark sync as complete. Transitions to DONE.',
|
|
561
692
|
inputSchema: z.object({}),
|
|
562
693
|
},
|
|
563
|
-
async () => {
|
|
694
|
+
safeHandler(async () => {
|
|
564
695
|
log('tool:sync_complete');
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
696
|
+
return await controller.syncComplete();
|
|
697
|
+
})
|
|
568
698
|
);
|
|
569
699
|
|
|
570
700
|
server.registerTool(
|
|
@@ -575,11 +705,10 @@ server.registerTool(
|
|
|
575
705
|
reason: z.string().optional().describe('Reason for blocking'),
|
|
576
706
|
}),
|
|
577
707
|
},
|
|
578
|
-
async ({ reason }) => {
|
|
708
|
+
safeHandler(async ({ reason }) => {
|
|
579
709
|
log('tool:block');
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
710
|
+
return await controller.block({ reason });
|
|
711
|
+
})
|
|
583
712
|
);
|
|
584
713
|
|
|
585
714
|
server.registerTool(
|
|
@@ -590,11 +719,10 @@ server.registerTool(
|
|
|
590
719
|
reason: z.string().optional().describe('Reason for replanning'),
|
|
591
720
|
}),
|
|
592
721
|
},
|
|
593
|
-
async ({ reason }) => {
|
|
722
|
+
safeHandler(async ({ reason }) => {
|
|
594
723
|
log('tool:replan');
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
724
|
+
return await controller.replan({ reason });
|
|
725
|
+
})
|
|
598
726
|
);
|
|
599
727
|
|
|
600
728
|
server.registerTool(
|
|
@@ -603,10 +731,9 @@ server.registerTool(
|
|
|
603
731
|
description: 'Get the current controller state (reads persistent store).',
|
|
604
732
|
inputSchema: z.object({}),
|
|
605
733
|
},
|
|
606
|
-
async () => {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
734
|
+
safeHandler(async () => {
|
|
735
|
+
return await controller.getState();
|
|
736
|
+
})
|
|
610
737
|
);
|
|
611
738
|
|
|
612
739
|
server.registerTool(
|
|
@@ -615,10 +742,9 @@ server.registerTool(
|
|
|
615
742
|
description: 'Get current task states.',
|
|
616
743
|
inputSchema: z.object({}),
|
|
617
744
|
},
|
|
618
|
-
async () => {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
745
|
+
safeHandler(async () => {
|
|
746
|
+
return await controller.getTasks();
|
|
747
|
+
})
|
|
622
748
|
);
|
|
623
749
|
|
|
624
750
|
server.registerTool(
|
|
@@ -633,12 +759,13 @@ server.registerTool(
|
|
|
633
759
|
newString: z.string().describe('The replacement string.'),
|
|
634
760
|
content: z
|
|
635
761
|
.string()
|
|
636
|
-
.
|
|
637
|
-
|
|
762
|
+
.describe(
|
|
763
|
+
'REQUIRED — The current file content. Read the file first with Read tool, then pass the full content here.'
|
|
764
|
+
),
|
|
638
765
|
taskId: z.string().optional().describe('Optional task ID for tracking.'),
|
|
639
766
|
}),
|
|
640
767
|
},
|
|
641
|
-
async ({ oldString, newString, content, taskId }) => {
|
|
768
|
+
safeHandler(async ({ oldString, newString, content, taskId }) => {
|
|
642
769
|
log('tool:validate_edit', {
|
|
643
770
|
taskId,
|
|
644
771
|
oldLen: oldString?.length,
|
|
@@ -647,20 +774,12 @@ server.registerTool(
|
|
|
647
774
|
});
|
|
648
775
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
649
776
|
return {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
type: 'text',
|
|
653
|
-
text: JSON.stringify({
|
|
654
|
-
outcome: 'CONFLICT',
|
|
655
|
-
reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
|
|
656
|
-
}),
|
|
657
|
-
},
|
|
658
|
-
],
|
|
777
|
+
outcome: 'CONFLICT',
|
|
778
|
+
reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
|
|
659
779
|
};
|
|
660
780
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
781
|
+
return await controller.validateEdit({ oldString, newString, content, taskId });
|
|
782
|
+
})
|
|
664
783
|
);
|
|
665
784
|
|
|
666
785
|
server.registerTool(
|
|
@@ -675,16 +794,46 @@ server.registerTool(
|
|
|
675
794
|
fileHash: z.string().optional().describe('Optional SHA-256 hash of the file after modification.'),
|
|
676
795
|
}),
|
|
677
796
|
},
|
|
678
|
-
async ({ taskId, filePath, fileHash }) => {
|
|
797
|
+
safeHandler(async ({ taskId, filePath, fileHash }) => {
|
|
679
798
|
log('tool:complete_task', { taskId, filePath });
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
799
|
+
return await controller.completeTask({ taskId, filePath, fileHash });
|
|
800
|
+
})
|
|
683
801
|
);
|
|
684
802
|
|
|
803
|
+
/**
|
|
804
|
+
* Graceful shutdown: clean up tmp files and flush state.
|
|
805
|
+
*/
|
|
806
|
+
function setupGracefulShutdown(ctrl) {
|
|
807
|
+
const shutdown = (signal) => {
|
|
808
|
+
log('shutdown', { signal });
|
|
809
|
+
// Final persist attempt (flush via public method, sync inside)
|
|
810
|
+
try {
|
|
811
|
+
if (ctrl) ctrl.flush();
|
|
812
|
+
} catch {
|
|
813
|
+
/* best-effort */
|
|
814
|
+
}
|
|
815
|
+
// Clean up own tmp files
|
|
816
|
+
try {
|
|
817
|
+
cleanupTmpFiles(statePath);
|
|
818
|
+
} catch {
|
|
819
|
+
/* best-effort */
|
|
820
|
+
}
|
|
821
|
+
process.exit(signal === 'SIGINT' ? 130 : 0);
|
|
822
|
+
};
|
|
823
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
824
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
825
|
+
// Prevent unhandled rejections from silently killing the server
|
|
826
|
+
process.on('unhandledRejection', (reason) => {
|
|
827
|
+
log('unhandled_rejection', { reason: String(reason) });
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
685
831
|
async function main() {
|
|
686
832
|
log('Starting ostacky-controller MCP...');
|
|
687
833
|
log('State path:', { path: statePath });
|
|
834
|
+
// Clean up stale tmp files from previous runs
|
|
835
|
+
cleanupTmpFiles(statePath);
|
|
836
|
+
setupGracefulShutdown(controller);
|
|
688
837
|
const transport = new StdioServerTransport();
|
|
689
838
|
await server.connect(transport);
|
|
690
839
|
log('ostacky-controller connected and ready');
|
|
@@ -205,14 +205,15 @@ describe('Controller contract — index.js', () => {
|
|
|
205
205
|
'validate_edit not registered as MCP tool');
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
it('validate_edit schema has content as
|
|
208
|
+
it('validate_edit schema has content as required (z.string())', () => {
|
|
209
209
|
// Find the validate_edit schema section
|
|
210
210
|
const validateSection = controllerContent.substring(
|
|
211
211
|
controllerContent.indexOf("'validate_edit'"),
|
|
212
212
|
controllerContent.indexOf("'complete_task'")
|
|
213
213
|
);
|
|
214
|
-
assert.ok(validateSection.includes('content: z.string()
|
|
215
|
-
|
|
214
|
+
assert.ok(validateSection.includes('content: z.string()') &&
|
|
215
|
+
!validateSection.includes('content: z.string().optional()'),
|
|
216
|
+
'validate_edit content param should be z.string() required — prevents silent state corruption');
|
|
216
217
|
});
|
|
217
218
|
|
|
218
219
|
it('validate_edit handler validates content type', () => {
|