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 CHANGED
@@ -218,33 +218,33 @@ Tras instalar, el proyecto queda así:
218
218
 
219
219
  ```json
220
220
  {
221
- "version": "0.5.9",
221
+ "version": "0.5.10",
222
222
  "lockedAt": "2025-01-01T00:00:00.000Z",
223
223
  "repo": "JaimeHoracio/Ostacky",
224
- "tag": "v0.5.9",
224
+ "tag": "v0.5.10",
225
225
  "agents": {
226
226
  "ostacky": {
227
- "version": "0.5.9",
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.9",
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.9",
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.9", ... },
246
- "execution-mode-evaluation": { "version": "0.5.9", ... },
247
- "openspec-propose": { "version": "0.5.9", ... }
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.9`), nunca `main` — instalaciones reproducibles
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
@@ -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.9, `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.
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 ? ` ${JSON.stringify(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
- this.#state = { ...DEFAULT_STATE };
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, JSON.stringify(this.#state, null, 2), 'utf8');
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('ROUTE_DECISION_PENDING', {
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.9',
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
- const result = await controller.startRequest({ requestId, changeId });
434
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.requestClarification({ question });
449
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.recordClarification();
462
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.recordDiscovery({ level, routeDecisionId, snapshot });
479
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.consumeRouteDecision({ decisionId, choice });
495
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.specComplete();
508
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
524
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.consumeExecutionDecision({ decisionId, mode });
540
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.implementationComplete();
553
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.syncComplete();
566
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.block({ reason });
581
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.replan({ reason });
596
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.getState();
608
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.getTasks();
620
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- .optional()
637
- .describe('The current file content. REQUIRED — read the file first with Read tool.'),
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
- content: [
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
- const result = await controller.validateEdit({ oldString, newString, content, taskId });
662
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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
- const result = await controller.completeTask({ taskId, filePath, fileHash });
681
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
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');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -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 optional (not required)', () => {
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().optional()'),
215
- 'validate_edit content param should be z.string().optional() — agent may omit it');
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', () => {