ostacky 0.6.0 → 0.6.2

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.
@@ -1,994 +1,989 @@
1
- #!/usr/bin/env node
2
-
3
- import { McpServer } from '@modelcontextprotocol/server';
4
- import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
5
- import * as z from 'zod/v4';
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;
11
- const MAX_STATE_FILE_SIZE = 1024 * 1024;
12
- const WATCHDOG_TIMEOUT_MS = 30_000;
13
- const WATCHDOG_CHECK_INTERVAL_MS = 10_000;
14
- let lastToolActivity = Date.now();
15
-
16
- const TRANSITIONS = {
17
- INTERPRETATION_PENDING: [
18
- { via: 'request_clarification', to: 'CLARIFICATION_PENDING' },
19
- { via: 'proceed_to_discovery', to: 'DISCOVERY' },
20
- { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
21
- { via: 'block', to: 'BLOCKED' },
22
- ],
23
- CLARIFICATION_PENDING: [
24
- { via: 'record_clarification', to: 'DISCOVERY' },
25
- { via: 'block', to: 'BLOCKED' },
26
- { via: 'abandon', to: 'BLOCKED' },
27
- ],
28
- DISCOVERY: [
29
- { via: 'record_discovery', to: 'LEVEL_RESOLVED' },
30
- { via: 'block', to: 'BLOCKED' },
31
- { via: 'abandon', to: 'BLOCKED' },
32
- ],
33
- LEVEL_RESOLVED: [
34
- { via: 'proceed_to_route', to: 'ROUTE_DECISION_PENDING' },
35
- { via: 'block', to: 'BLOCKED' },
36
- ],
37
- ROUTE_DECISION_PENDING: [
38
- { via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
39
- { via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
40
- { via: 'block', to: 'BLOCKED' },
41
- { via: 'abandon', to: 'BLOCKED' },
42
- ],
43
- SPECIFICATION: [
44
- { via: 'spec_complete', to: 'EXECUTION_ANALYSIS' },
45
- { via: 'block', to: 'BLOCKED' },
46
- { via: 'abandon', to: 'BLOCKED' },
47
- ],
48
- EXECUTION_ANALYSIS: [
49
- { via: 'analysis_complete', to: 'EXECUTION_DECISION_PENDING' },
50
- { via: 'block', to: 'BLOCKED' },
51
- { via: 'abandon', to: 'BLOCKED' },
52
- ],
53
- EXECUTION_DECISION_PENDING: [
54
- { via: 'consume_execution_decision', to: 'EXECUTING_INLINE', mode: 'INLINE' },
55
- { via: 'consume_execution_decision', to: 'EXECUTING_SUBAGENTS', mode: 'SUBAGENT_DRIVEN' },
56
- { via: 'block', to: 'BLOCKED' },
57
- { via: 'abandon', to: 'BLOCKED' },
58
- ],
59
- EXECUTING_INLINE: [
60
- { via: 'implementation_complete', to: 'SYNC' },
61
- { via: 'block', to: 'BLOCKED' },
62
- ],
63
- EXECUTING_SUBAGENTS: [
64
- { via: 'implementation_complete', to: 'SYNC' },
65
- { via: 'block', to: 'BLOCKED' },
66
- ],
67
- BLOCKED: [
68
- { via: 'replan', to: 'INTERPRETATION_PENDING' },
69
- { via: 'abandon', to: 'DONE' },
70
- ],
71
- SYNC: [
72
- { via: 'sync_complete', to: 'DONE' },
73
- { via: 'block', to: 'BLOCKED' },
74
- ],
75
- DONE: [],
76
- };
77
-
78
- /**
79
- * Safe JSON.stringify that won't throw on circular references.
80
- */
81
- function safeJsonStringify(obj, pretty = false) {
82
- const seen = new WeakSet();
83
- try {
84
- return JSON.stringify(
85
- obj,
86
- (key, value) => {
87
- if (typeof value === 'object' && value !== null) {
88
- if (seen.has(value)) return '[Circular]';
89
- seen.add(value);
90
- }
91
- return value;
92
- },
93
- pretty ? 2 : undefined
94
- );
95
- } catch (e) {
96
- return `[Unstringifiable: ${e.message}]`;
97
- }
98
- }
99
-
100
- function log(event, data) {
101
- const ts = new Date().toISOString();
102
- const payload = data ? ` ${safeJsonStringify(data)}` : '';
103
- console.error(`[${ts}] ${event}${payload}`);
104
- }
105
-
106
- /**
107
- * Cleans up stale .tmp.* files from a previous crash.
108
- */
109
- function cleanupTmpFiles(statePath) {
110
- if (!statePath) return;
111
- const dir = dirname(statePath);
112
- const name = basename(statePath);
113
- try {
114
- for (const entry of readdirSync(dir)) {
115
- if (entry.startsWith(name + '.tmp.')) {
116
- try {
117
- unlinkSync(dir + '/' + entry);
118
- } catch {
119
- /* best-effort */
120
- }
121
- }
122
- }
123
- } catch {
124
- /* directory may not exist yet */
125
- }
126
- }
127
-
128
- const STATES = Object.freeze({
129
- INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
130
- CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
131
- DISCOVERY: 'DISCOVERY',
132
- LEVEL_RESOLVED: 'LEVEL_RESOLVED',
133
- ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
134
- SPECIFICATION: 'SPECIFICATION',
135
- EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
136
- EXECUTION_DECISION_PENDING: 'EXECUTION_DECISION_PENDING',
137
- EXECUTING_INLINE: 'EXECUTING_INLINE',
138
- EXECUTING_SUBAGENTS: 'EXECUTING_SUBAGENTS',
139
- SYNC: 'SYNC',
140
- DONE: 'DONE',
141
- BLOCKED: 'BLOCKED',
142
- });
143
-
144
- const DEFAULT_STATE = Object.freeze({
145
- state: STATES.INTERPRETATION_PENDING,
146
- revision: 0,
147
- requestId: null,
148
- changeId: null,
149
- routeDecisionId: null,
150
- routeChoice: null,
151
- executionDecisionId: null,
152
- executionMode: null,
153
- snapshots: { codegraph: null, execution: null },
154
- tasks: {},
155
- fileFingerprints: {},
156
- error: null,
157
- });
158
-
159
- class OstackyController {
160
- #statePath;
161
- #state;
162
- #loaded;
163
-
164
- constructor(opts = {}) {
165
- this.#statePath = opts.statePath;
166
- if (opts.initialState) {
167
- this.#state = { ...DEFAULT_STATE, ...opts.initialState };
168
- this.#loaded = true;
169
- } else {
170
- this.#state = null;
171
- this.#loaded = false;
172
- }
173
- }
174
-
175
- /**
176
- * Validates that a parsed state object has the required fields and valid values.
177
- * Returns null if valid, or an error message if invalid.
178
- */
179
- #validateState(parsed) {
180
- if (typeof parsed !== 'object' || parsed === null) return 'State is not an object';
181
- if (typeof parsed.state !== 'string') return 'Missing or invalid "state" field';
182
- if (!STATES[parsed.state]) return `Unknown state: "${parsed.state}"`;
183
- if (typeof parsed.revision !== 'number') return 'Missing or invalid "revision" field';
184
- if (parsed.revision < 0) return `Invalid revision: ${parsed.revision}`;
185
- return null; // valid
186
- }
187
-
188
- #load() {
189
- if (this.#loaded) return;
190
- if (!this.#statePath) {
191
- this.#state = { ...DEFAULT_STATE };
192
- this.#loaded = true;
193
- return;
194
- }
195
- // Try primary state file
196
- try {
197
- const raw = readFileSync(this.#statePath, 'utf8');
198
- if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`State file too large: ${raw.length} bytes`);
199
- const parsed = JSON.parse(raw);
200
- const validationError = this.#validateState(parsed);
201
- if (validationError) throw new Error(`State validation failed: ${validationError}`);
202
- this.#state = { ...DEFAULT_STATE, ...parsed };
203
- this.#loaded = true;
204
- return;
205
- } catch (err) {
206
- log('warn:load_primary_failed', { error: err.message });
207
- }
208
- // Fallback: try .backup
209
- const backupPath = this.#statePath + '.backup';
210
- try {
211
- const raw = readFileSync(backupPath, 'utf8');
212
- if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
213
- const parsed = JSON.parse(raw);
214
- const validationError = this.#validateState(parsed);
215
- if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
216
- this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
217
- log('warn:state_restored_from_backup');
218
- this.#loaded = true;
219
- return;
220
- } catch (backupErr) {
221
- // No backup either set error state instead of silent reset
222
- this.#state = {
223
- ...DEFAULT_STATE,
224
- error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
225
- };
226
- log('warn:state_reset', { error: backupErr.message });
227
- }
228
- this.#loaded = true;
229
- }
230
-
231
- #persist() {
232
- if (!this.#statePath) return;
233
- const dir = dirname(this.#statePath);
234
- mkdirSync(dir, { recursive: true });
235
- let serialized = safeJsonStringify(this.#state, true);
236
- if (serialized.length > MAX_STATE_FILE_SIZE) {
237
- log('warn:state_oversized', { size: serialized.length });
238
- const trimmed = { ...this.#state, snapshots: { codegraph: null, execution: null } };
239
- serialized = safeJsonStringify(trimmed, true);
240
- if (serialized.length > MAX_STATE_FILE_SIZE) {
241
- log('error:state_too_large_even_after_trim');
242
- return;
243
- }
244
- this.#state.snapshots = { codegraph: null, execution: null };
245
- }
246
- const tmp = this.#statePath + '.tmp.' + process.pid;
247
- writeFileSync(tmp, serialized, 'utf8');
248
- renameSync(tmp, this.#statePath);
249
- try {
250
- const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
251
- writeFileSync(backupTmp, serialized, 'utf8');
252
- renameSync(backupTmp, this.#statePath + '.backup');
253
- } catch {
254
- /* backup is best-effort */
255
- }
256
- }
257
-
258
- /**
259
- * Trims old completed tasks when we exceed MAX_TASKS.
260
- * Keeps the most recent MAX_TASKS entries.
261
- */
262
- #trimTasks() {
263
- if (!this.#state.tasks) return;
264
- const entries = Object.entries(this.#state.tasks);
265
- if (entries.length <= MAX_TASKS) return;
266
- // Sort by completedAt (desc), keep newest MAX_TASKS
267
- entries.sort((a, b) => {
268
- const da = a[1].completedAt || '';
269
- const db = b[1].completedAt || '';
270
- return db.localeCompare(da);
271
- });
272
- const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
273
- this.#state.tasks = trimmed;
274
- log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
275
- }
276
-
277
- #transition(to, changes = {}) {
278
- this.#state.revision++;
279
- this.#state.state = to;
280
- Object.assign(this.#state, changes);
281
- this.#persist();
282
- }
283
-
284
- #isAllowedTransition(from, via, choiceOrMode) {
285
- const transitions = TRANSITIONS[from] || [];
286
- for (const t of transitions) {
287
- if (t.via !== via) continue;
288
- if (t.choice !== undefined && t.choice !== choiceOrMode) continue;
289
- if (t.mode !== undefined && t.mode !== choiceOrMode) continue;
290
- return t.to;
291
- }
292
- return null;
293
- }
294
-
295
- async startRequest({ requestId, changeId } = {}) {
296
- this.#load();
297
- if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
298
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
299
- }
300
- this.#transition('INTERPRETATION_PENDING', {
301
- requestId: requestId || 'req-' + Date.now(),
302
- changeId: changeId || null,
303
- routeDecisionId: null,
304
- routeChoice: null,
305
- executionDecisionId: null,
306
- executionMode: null,
307
- snapshots: { codegraph: null, execution: null },
308
- tasks: {},
309
- fileFingerprints: {},
310
- error: null,
311
- });
312
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
313
- }
314
-
315
- async requestClarification({ question } = {}) {
316
- this.#load();
317
- const to = this.#isAllowedTransition(this.#state.state, 'request_clarification');
318
- if (!to) return { error: `Cannot request clarification from state ${this.#state.state}` };
319
- this.#transition(to, { error: question ? `Clarification: ${question}` : null });
320
- return { state: this.#state.state, revision: this.#state.revision };
321
- }
322
-
323
- async recordClarification() {
324
- this.#load();
325
- const to = this.#isAllowedTransition(this.#state.state, 'record_clarification');
326
- if (!to) return { error: `Cannot record clarification from state ${this.#state.state}` };
327
- this.#transition(to, { error: null });
328
- return { state: this.#state.state, revision: this.#state.revision };
329
- }
330
-
331
- async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
332
- this.#load();
333
- const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
334
- if (!to) return { error: `Cannot record discovery from state ${this.#state.state}` };
335
- if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
336
- return { error: `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes` };
337
- }
338
- this.#transition(to, {
339
- routeDecisionId: routeDecisionId || 'route-' + Date.now(),
340
- routeChoice: null,
341
- snapshots: { ...this.#state.snapshots, codegraph: snapshot || this.#state.snapshots.codegraph },
342
- });
343
- const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
344
- return {
345
- state: this.#state.state,
346
- revision: this.#state.revision,
347
- level,
348
- routeDecisionId: this.#state.routeDecisionId,
349
- defaultChoice,
350
- };
351
- }
352
-
353
- async proceedToRoute() {
354
- this.#load();
355
- const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
356
- if (!to) return { error: `Cannot proceed to route from state ${this.#state.state}` };
357
- this.#transition(to);
358
- return { state: this.#state.state, revision: this.#state.revision };
359
- }
360
-
361
- async abandon({ reason } = {}) {
362
- this.#load();
363
- const to = this.#isAllowedTransition(this.#state.state, 'abandon');
364
- if (!to) return { error: `Cannot abandon from state ${this.#state.state}` };
365
- this.#transition(to, { error: reason || 'Abandoned' });
366
- return { state: this.#state.state, revision: this.#state.revision };
367
- }
368
-
369
- async consumeRouteDecision({ decisionId, choice } = {}) {
370
- this.#load();
371
- if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
372
- return { error: `Cannot consume route decision from state ${this.#state.state}` };
373
- }
374
- if (this.#state.routeDecisionId !== decisionId) return { error: `Decision ID mismatch` };
375
- const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
376
- if (!to) return { error: `Route ${choice} not allowed from ${this.#state.state}` };
377
- this.#transition(to, { routeChoice: choice });
378
- return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
379
- }
380
-
381
- async specComplete() {
382
- this.#load();
383
- const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
384
- if (!to) return { error: `Cannot complete spec from state ${this.#state.state}` };
385
- this.#transition(to);
386
- return { state: this.#state.state, revision: this.#state.revision };
387
- }
388
-
389
- async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
390
- this.#load();
391
- const to = this.#isAllowedTransition(this.#state.state, 'analysis_complete');
392
- if (!to) return { error: `Cannot record execution analysis from state ${this.#state.state}` };
393
- if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
394
- return { error: `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes` };
395
- }
396
- this.#transition(to, {
397
- executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
398
- executionMode: null,
399
- snapshots: { ...this.#state.snapshots, execution: snapshot || null },
400
- });
401
- return {
402
- state: this.#state.state,
403
- revision: this.#state.revision,
404
- executionDecisionId: this.#state.executionDecisionId,
405
- };
406
- }
407
-
408
- async consumeExecutionDecision({ decisionId, mode } = {}) {
409
- this.#load();
410
- if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
411
- return { error: `Cannot consume execution decision from state ${this.#state.state}` };
412
- }
413
- if (this.#state.executionDecisionId !== decisionId) return { error: `Decision ID mismatch` };
414
- const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
415
- if (!to) return { error: `Mode ${mode} not allowed from ${this.#state.state}` };
416
- this.#transition(to, { executionMode: mode });
417
- return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
418
- }
419
-
420
- async implementationComplete() {
421
- this.#load();
422
- const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
423
- if (!to) return { error: `Cannot complete implementation from state ${this.#state.state}` };
424
- this.#transition(to);
425
- return { state: this.#state.state, revision: this.#state.revision };
426
- }
427
-
428
- async syncComplete() {
429
- this.#load();
430
- const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
431
- if (!to) return { error: `Cannot complete sync from state ${this.#state.state}` };
432
- this.#transition(to);
433
- return { state: this.#state.state, revision: this.#state.revision };
434
- }
435
-
436
- async block({ reason } = {}) {
437
- this.#load();
438
- const to = this.#isAllowedTransition(this.#state.state, 'block');
439
- if (!to) return { error: `Cannot block from state ${this.#state.state}` };
440
- this.#transition(to, { error: reason || 'Blocked' });
441
- return { state: this.#state.state, revision: this.#state.revision };
442
- }
443
-
444
- async replan({ reason } = {}) {
445
- this.#load();
446
- const to = this.#isAllowedTransition(this.#state.state, 'replan');
447
- if (!to) return { error: `Cannot replan from state ${this.#state.state}` };
448
- this.#transition(to, {
449
- error: reason || null,
450
- routeDecisionId: null,
451
- routeChoice: null,
452
- executionDecisionId: null,
453
- executionMode: null,
454
- snapshots: { codegraph: null, execution: null },
455
- tasks: {},
456
- fileFingerprints: {},
457
- });
458
- return { state: this.#state.state, revision: this.#state.revision };
459
- }
460
-
461
- async getState() {
462
- this.#load();
463
- return structuredClone(this.#state);
464
- }
465
-
466
- async getTasks() {
467
- this.#load();
468
- return { ...this.#state.tasks };
469
- }
470
-
471
- async getAvailableTransitions() {
472
- this.#load();
473
- return {
474
- currentState: this.#state.state,
475
- transitions: TRANSITIONS[this.#state.state] || [],
476
- };
477
- }
478
-
479
- /**
480
- * Validates an edit against the current file content.
481
- * Returns one of: EDITABLE, ALREADY_APPLIED, CONFLICT.
482
- * - EDITABLE: oldString found exactly once, safe to replace.
483
- * - ALREADY_APPLIED: newString already present in content (idempotent skip).
484
- * - CONFLICT: oldString not found, or found multiple times.
485
- */
486
- async validateEdit({ oldString, newString, content, taskId } = {}) {
487
- this.#load();
488
- if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
489
- return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
490
- }
491
- if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
492
- return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
493
- }
494
- if (oldString.length === 0) {
495
- return { outcome: 'CONFLICT', reason: 'oldString cannot be empty' };
496
- }
497
- // Trivial idempotency: identical strings nothing to do
498
- if (oldString === newString) {
499
- return { outcome: 'ALREADY_APPLIED', taskId, reason: 'oldString and newString are identical' };
500
- }
501
- // Count occurrences of oldString in content
502
- let oldCount = 0;
503
- let idx = 0;
504
- while ((idx = content.indexOf(oldString, idx)) !== -1) {
505
- oldCount++;
506
- idx += oldString.length;
507
- }
508
- // If oldString not found, check if newString is already present (edit was already applied)
509
- if (oldCount === 0) {
510
- if (content.includes(newString)) {
511
- return {
512
- outcome: 'ALREADY_APPLIED',
513
- taskId,
514
- reason: 'oldString not found but newString is present — edit was already applied',
515
- };
516
- }
517
- return { outcome: 'CONFLICT', reason: 'oldString not found in content file was modified externally' };
518
- }
519
- if (oldCount > 1) {
520
- return {
521
- outcome: 'CONFLICT',
522
- reason: `oldString found ${oldCount} times — need more context to disambiguate`,
523
- };
524
- }
525
- // oldString found exactly once → safe to replace
526
- return { outcome: 'EDITABLE', taskId };
527
- }
528
-
529
- /**
530
- * Marks a task as completed and records a file fingerprint.
531
- * Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.
532
- */
533
- async completeTask({ taskId, filePath, fileHash } = {}) {
534
- this.#load();
535
- if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
536
- return { error: `Cannot complete task from state ${this.#state.state}` };
537
- }
538
- if (!taskId) return { error: 'taskId is required' };
539
- if (!this.#state.tasks) this.#state.tasks = {};
540
- this.#state.tasks[taskId] = {
541
- status: 'COMPLETED',
542
- completedAt: new Date().toISOString(),
543
- filePath: filePath || null,
544
- fileHash: fileHash || null,
545
- };
546
- if (filePath && fileHash) {
547
- if (!this.#state.fileFingerprints) this.#state.fileFingerprints = {};
548
- this.#state.fileFingerprints[filePath] = fileHash;
549
- }
550
- this.#trimTasks();
551
- this.#persist();
552
- return {
553
- taskId,
554
- status: 'COMPLETED',
555
- totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
556
- .length,
557
- };
558
- }
559
-
560
- /**
561
- * Public flush — force-persists current state to disk.
562
- * Used by graceful shutdown (private fields not accessible from outside).
563
- */
564
- flush() {
565
- this.#persist();
566
- }
567
- }
568
-
569
- const statePath = process.env.OSTACKY_STATE_PATH || '.opencode/ostacky-state.json';
570
- const controller = new OstackyController({ statePath });
571
-
572
- /**
573
- * Wraps an async tool handler to ALWAYS return a response (even on error).
574
- * Without this, an unhandled exception in any tool handler leaves the LLM
575
- * waiting forever — the root cause of agent freezes.
576
- */
577
- function safeHandler(fn) {
578
- return async (params) => {
579
- lastToolActivity = Date.now();
580
- try {
581
- const result = await fn(params);
582
- return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
583
- } catch (error) {
584
- log('tool:error', {
585
- name: fn.name || 'anonymous',
586
- error: error.message,
587
- stack: error.stack,
588
- });
589
- return {
590
- content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
591
- isError: true,
592
- };
593
- }
594
- };
595
- }
596
-
597
- const server = new McpServer({
598
- name: 'ostacky-controller',
599
- version: '0.6.0',
600
- });
601
-
602
- server.registerTool(
603
- 'start_request',
604
- {
605
- description: 'Start or reset a new request. Can be called from ANY state — resets state machine. Call this first.',
606
- inputSchema: z.object({
607
- requestId: z.string().optional().describe('Unique request ID'),
608
- changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
609
- }),
610
- },
611
- safeHandler(async ({ requestId, changeId }) => {
612
- log('tool:start_request');
613
- return await controller.startRequest({ requestId, changeId });
614
- })
615
- );
616
-
617
- server.registerTool(
618
- 'request_clarification',
619
- {
620
- description: 'Record that clarification was requested. Transitions to CLARIFICATION_PENDING.',
621
- inputSchema: z.object({
622
- question: z.string().optional().describe('The clarification question'),
623
- }),
624
- },
625
- safeHandler(async ({ question }) => {
626
- log('tool:request_clarification');
627
- return await controller.requestClarification({ question });
628
- })
629
- );
630
-
631
- server.registerTool(
632
- 'record_clarification',
633
- {
634
- description: 'Record that clarification was answered. Transitions to DISCOVERY.',
635
- inputSchema: z.object({}),
636
- },
637
- safeHandler(async () => {
638
- log('tool:record_clarification');
639
- return await controller.recordClarification();
640
- })
641
- );
642
-
643
- server.registerTool(
644
- 'record_discovery',
645
- {
646
- description: 'Record discovery complete with level classification. From INTERPRETATION_PENDING goes to ROUTE_DECISION_PENDING. From DISCOVERY goes to LEVEL_RESOLVED.',
647
- inputSchema: z.object({
648
- level: z.enum(['0', '0+1', '1+']).describe('Impact level'),
649
- routeDecisionId: z.string().optional().describe('Unique route decision ID'),
650
- snapshot: z.any().optional().describe('Optional CodeGraph snapshot'),
651
- }),
652
- },
653
- safeHandler(async ({ level, routeDecisionId, snapshot }) => {
654
- log('tool:record_discovery', { level });
655
- return await controller.recordDiscovery({ level, routeDecisionId, snapshot });
656
- })
657
- );
658
-
659
- server.registerTool(
660
- 'consume_route_decision',
661
- {
662
- description: 'Consume the route decision (SPEC or DIRECT). Valid only in ROUTE_DECISION_PENDING.',
663
- inputSchema: z.object({
664
- decisionId: z.string().describe('Route decision ID from record_discovery'),
665
- choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
666
- }),
667
- },
668
- safeHandler(async ({ decisionId, choice }) => {
669
- log('tool:consume_route_decision', { choice });
670
- return await controller.consumeRouteDecision({ decisionId, choice });
671
- })
672
- );
673
-
674
- server.registerTool(
675
- 'spec_complete',
676
- {
677
- description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
678
- inputSchema: z.object({}),
679
- },
680
- safeHandler(async () => {
681
- log('tool:spec_complete');
682
- return await controller.specComplete();
683
- })
684
- );
685
-
686
- server.registerTool(
687
- 'record_execution_analysis',
688
- {
689
- description: 'Record execution analysis with snapshot. Transitions to EXECUTION_DECISION_PENDING.',
690
- inputSchema: z.object({
691
- executionDecisionId: z.string().optional().describe('Unique execution decision ID'),
692
- snapshot: z.any().optional().describe('Execution analysis snapshot'),
693
- }),
694
- },
695
- safeHandler(async ({ executionDecisionId, snapshot }) => {
696
- log('tool:record_execution_analysis');
697
- return await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
698
- })
699
- );
700
-
701
- server.registerTool(
702
- 'consume_execution_decision',
703
- {
704
- description: 'Consume the execution mode decision (INLINE or SUBAGENT_DRIVEN).',
705
- inputSchema: z.object({
706
- decisionId: z.string().describe('Execution decision ID from record_execution_analysis'),
707
- mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
708
- }),
709
- },
710
- safeHandler(async ({ decisionId, mode }) => {
711
- log('tool:consume_execution_decision', { mode });
712
- return await controller.consumeExecutionDecision({ decisionId, mode });
713
- })
714
- );
715
-
716
- server.registerTool(
717
- 'implementation_complete',
718
- {
719
- description: 'Mark implementation as complete. Transitions to SYNC.',
720
- inputSchema: z.object({}),
721
- },
722
- safeHandler(async () => {
723
- log('tool:implementation_complete');
724
- return await controller.implementationComplete();
725
- })
726
- );
727
-
728
- server.registerTool(
729
- 'sync_complete',
730
- {
731
- description: 'Mark sync as complete. Transitions to DONE.',
732
- inputSchema: z.object({}),
733
- },
734
- safeHandler(async () => {
735
- log('tool:sync_complete');
736
- return await controller.syncComplete();
737
- })
738
- );
739
-
740
- server.registerTool(
741
- 'block',
742
- {
743
- description: 'Transition to BLOCKED state with an optional reason.',
744
- inputSchema: z.object({
745
- reason: z.string().optional().describe('Reason for blocking'),
746
- }),
747
- },
748
- safeHandler(async ({ reason }) => {
749
- log('tool:block');
750
- return await controller.block({ reason });
751
- })
752
- );
753
-
754
- server.registerTool(
755
- 'replan',
756
- {
757
- description: 'Replan from BLOCKED state back to INTERPRETATION_PENDING.',
758
- inputSchema: z.object({
759
- reason: z.string().optional().describe('Reason for replanning'),
760
- }),
761
- },
762
- safeHandler(async ({ reason }) => {
763
- log('tool:replan');
764
- return await controller.replan({ reason });
765
- })
766
- );
767
-
768
- server.registerTool(
769
- 'proceed_to_route',
770
- {
771
- description: 'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed.',
772
- inputSchema: z.object({}),
773
- },
774
- safeHandler(async () => {
775
- log('tool:proceed_to_route');
776
- return await controller.proceedToRoute();
777
- })
778
- );
779
-
780
- server.registerTool(
781
- 'abandon',
782
- {
783
- description: 'Abandon the current request. Transitions to BLOCKED from most states, or to DONE from BLOCKED.',
784
- inputSchema: z.object({
785
- reason: z.string().optional().describe('Reason for abandoning'),
786
- }),
787
- },
788
- safeHandler(async ({ reason }) => {
789
- log('tool:abandon');
790
- return await controller.abandon({ reason });
791
- })
792
- );
793
-
794
- server.registerTool(
795
- 'ping',
796
- {
797
- description: 'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
798
- inputSchema: z.object({}),
799
- },
800
- safeHandler(async () => {
801
- return {
802
- pong: true,
803
- state: await controller.getState().then(s => ({
804
- state: s.state,
805
- revision: s.revision,
806
- requestId: s.requestId,
807
- })),
808
- };
809
- })
810
- );
811
-
812
- server.registerTool(
813
- 'get_state',
814
- {
815
- description: 'Get the current controller state (reads persistent store).',
816
- inputSchema: z.object({}),
817
- },
818
- safeHandler(async () => {
819
- return await controller.getState();
820
- })
821
- );
822
-
823
- server.registerTool(
824
- 'get_tasks',
825
- {
826
- description: 'Get current task states.',
827
- inputSchema: z.object({}),
828
- },
829
- safeHandler(async () => {
830
- return await controller.getTasks();
831
- })
832
- );
833
-
834
- server.registerTool(
835
- 'get_available_transitions',
836
- {
837
- description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
838
- inputSchema: z.object({}),
839
- },
840
- safeHandler(async () => {
841
- return await controller.getAvailableTransitions();
842
- })
843
- );
844
-
845
- server.registerTool(
846
- 'check_pending_state',
847
- {
848
- description:
849
- 'Check if agent is in a pending state waiting for user input. ' +
850
- 'MUST be called before ANY tool call when controller is available. ' +
851
- 'Returns ALLOW or BLOCKED with reason. ' +
852
- 'EXCEPTION: controller tools (consume_route_decision, consume_execution_decision, ' +
853
- 'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
854
- inputSchema: z.object({}),
855
- },
856
- safeHandler(async () => {
857
- const state = await controller.getState();
858
- const pendingStates = [
859
- 'CLARIFICATION_PENDING',
860
- 'ROUTE_DECISION_PENDING',
861
- 'EXECUTION_DECISION_PENDING',
862
- ];
863
- if (pendingStates.includes(state.state)) {
864
- return {
865
- status: 'BLOCKED',
866
- state: state.state,
867
- revision: state.revision,
868
- reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
869
- };
870
- }
871
- return { status: 'ALLOW', state: state.state, revision: state.revision };
872
- })
873
- );
874
-
875
- server.registerTool(
876
- 'validate_edit',
877
- {
878
- description:
879
- 'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
880
- 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
881
- 'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
882
- inputSchema: z.object({
883
- oldString: z.string().describe('The exact string to find in content (must be unique).'),
884
- newString: z.string().describe('The replacement string.'),
885
- content: z
886
- .string()
887
- .describe(
888
- 'REQUIRED The current file content. Read the file first with Read tool, then pass the full content here.'
889
- ),
890
- taskId: z.string().optional().describe('Optional task ID for tracking.'),
891
- }),
892
- },
893
- safeHandler(async ({ oldString, newString, content, taskId }) => {
894
- log('tool:validate_edit', {
895
- taskId,
896
- oldLen: oldString?.length,
897
- newLen: newString?.length,
898
- hasContent: !!content,
899
- });
900
- if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
901
- return {
902
- outcome: 'CONFLICT',
903
- reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
904
- };
905
- }
906
- return await controller.validateEdit({ oldString, newString, content, taskId });
907
- })
908
- );
909
-
910
- server.registerTool(
911
- 'complete_task',
912
- {
913
- description:
914
- 'Mark a task as completed and optionally record a file fingerprint. ' +
915
- 'Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.',
916
- inputSchema: z.object({
917
- taskId: z.string().describe('The task ID to mark as completed.'),
918
- filePath: z.string().optional().describe('Optional file path that was modified.'),
919
- fileHash: z.string().optional().describe('Optional SHA-256 hash of the file after modification.'),
920
- }),
921
- },
922
- safeHandler(async ({ taskId, filePath, fileHash }) => {
923
- log('tool:complete_task', { taskId, filePath });
924
- return await controller.completeTask({ taskId, filePath, fileHash });
925
- })
926
- );
927
-
928
- /**
929
- * Graceful shutdown: clean up tmp files and flush state.
930
- */
931
- function setupGracefulShutdown(ctrl) {
932
- const shutdown = (signal) => {
933
- log('shutdown', { signal });
934
- // Final persist attempt (flush via public method, sync inside)
935
- try {
936
- if (ctrl) ctrl.flush();
937
- } catch {
938
- /* best-effort */
939
- }
940
- // Clean up own tmp files
941
- try {
942
- cleanupTmpFiles(statePath);
943
- } catch {
944
- /* best-effort */
945
- }
946
- process.exit(signal === 'SIGINT' ? 130 : 0);
947
- };
948
- process.on('SIGTERM', () => shutdown('SIGTERM'));
949
- process.on('SIGINT', () => shutdown('SIGINT'));
950
- process.on('SIGHUP', () => shutdown('SIGHUP'));
951
- process.on('SIGPIPE', () => shutdown('SIGPIPE'));
952
- // Prevent unhandled rejections from silently killing the server
953
- process.on('unhandledRejection', (reason) => {
954
- log('unhandled_rejection', { reason: String(reason) });
955
- });
956
- }
957
-
958
- /**
959
- * Watchdog that forces process restart if controller stops responding.
960
- * This prevents the agent from hanging when the MCP server is stuck.
961
- */
962
- function setupWatchdog() {
963
- setInterval(() => {
964
- const idleMs = Date.now() - lastToolActivity;
965
- if (idleMs > WATCHDOG_TIMEOUT_MS) {
966
- log('watchdog:timeout', { idleMs, threshold: WATCHDOG_TIMEOUT_MS });
967
- process.exit(1);
968
- }
969
- }, WATCHDOG_CHECK_INTERVAL_MS);
970
- }
971
-
972
- async function main() {
973
- log('Starting ostacky-controller MCP...');
974
- log('State path:', { path: statePath });
975
- // Clean up stale tmp files from previous runs
976
- cleanupTmpFiles(statePath);
977
- setupGracefulShutdown(controller);
978
- setupWatchdog();
979
- const transport = new StdioServerTransport();
980
- await server.connect(transport);
981
- log('ostacky-controller connected and ready');
982
- }
983
-
984
- const isDirectRun =
985
- process.argv[1] && (process.argv[1].endsWith('/index.js') || process.argv[1].endsWith('\\index.js'));
986
-
987
- if (isDirectRun) {
988
- main().catch((error) => {
989
- console.error('Fatal error:', error);
990
- process.exit(1);
991
- });
992
- }
993
-
994
- export { OstackyController };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Ostacky Controller MCP Server
5
+ *
6
+ * Máquina de estados persistida para Ostacky. Valida transiciones,
7
+ * consume decisiones, autoriza side effects y persiste snapshots.
8
+ *
9
+ * Usage: node .opencode/mcp/ostacky-controller/index.js
10
+ *
11
+ * Environment:
12
+ * OSTACKY_STATE_PATH Ruta al archivo de estado JSON (default: .opencode/ostacky-state.json)
13
+ */
14
+
15
+ import { McpServer } from '@modelcontextprotocol/server';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
17
+ import * as z from 'zod/v4';
18
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
19
+ import { dirname, basename, join, resolve } from 'node:path';
20
+
21
+ const MAX_TASKS = 50;
22
+ const MAX_SNAPSHOT_JSON_LENGTH = 100 * 1024;
23
+ const MAX_STATE_FILE_SIZE = 1024 * 1024;
24
+
25
+ const TRANSITIONS = {
26
+ INTERPRETATION_PENDING: [
27
+ { via: 'request_clarification', to: 'CLARIFICATION_PENDING' },
28
+ { via: 'proceed_to_discovery', to: 'DISCOVERY' },
29
+ { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
30
+ { via: 'block', to: 'BLOCKED' },
31
+ ],
32
+ CLARIFICATION_PENDING: [
33
+ { via: 'record_clarification', to: 'DISCOVERY' },
34
+ { via: 'block', to: 'BLOCKED' },
35
+ { via: 'abandon', to: 'BLOCKED' },
36
+ ],
37
+ DISCOVERY: [
38
+ { via: 'record_discovery', to: 'LEVEL_RESOLVED' },
39
+ { via: 'block', to: 'BLOCKED' },
40
+ { via: 'abandon', to: 'BLOCKED' },
41
+ ],
42
+ LEVEL_RESOLVED: [
43
+ { via: 'proceed_to_route', to: 'ROUTE_DECISION_PENDING' },
44
+ { via: 'block', to: 'BLOCKED' },
45
+ ],
46
+ ROUTE_DECISION_PENDING: [
47
+ { via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
48
+ { via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
49
+ { via: 'block', to: 'BLOCKED' },
50
+ { via: 'abandon', to: 'BLOCKED' },
51
+ ],
52
+ SPECIFICATION: [
53
+ { via: 'spec_complete', to: 'EXECUTION_ANALYSIS' },
54
+ { via: 'block', to: 'BLOCKED' },
55
+ { via: 'abandon', to: 'BLOCKED' },
56
+ ],
57
+ EXECUTION_ANALYSIS: [
58
+ { via: 'analysis_complete', to: 'EXECUTION_DECISION_PENDING' },
59
+ { via: 'block', to: 'BLOCKED' },
60
+ { via: 'abandon', to: 'BLOCKED' },
61
+ ],
62
+ EXECUTION_DECISION_PENDING: [
63
+ { via: 'consume_execution_decision', to: 'EXECUTING_INLINE', mode: 'INLINE' },
64
+ { via: 'consume_execution_decision', to: 'EXECUTING_SUBAGENTS', mode: 'SUBAGENT_DRIVEN' },
65
+ { via: 'block', to: 'BLOCKED' },
66
+ { via: 'abandon', to: 'BLOCKED' },
67
+ ],
68
+ EXECUTING_INLINE: [
69
+ { via: 'implementation_complete', to: 'SYNC' },
70
+ { via: 'block', to: 'BLOCKED' },
71
+ ],
72
+ EXECUTING_SUBAGENTS: [
73
+ { via: 'implementation_complete', to: 'SYNC' },
74
+ { via: 'block', to: 'BLOCKED' },
75
+ ],
76
+ BLOCKED: [
77
+ { via: 'replan', to: 'INTERPRETATION_PENDING' },
78
+ { via: 'abandon', to: 'DONE' },
79
+ ],
80
+ SYNC: [
81
+ { via: 'sync_complete', to: 'DONE' },
82
+ { via: 'block', to: 'BLOCKED' },
83
+ ],
84
+ DONE: [],
85
+ };
86
+
87
+ /**
88
+ * Safe JSON.stringify that won't throw on circular references.
89
+ */
90
+ function safeJsonStringify(obj, pretty = false) {
91
+ const seen = new WeakSet();
92
+ try {
93
+ return JSON.stringify(
94
+ obj,
95
+ (key, value) => {
96
+ if (typeof value === 'object' && value !== null) {
97
+ if (seen.has(value)) return '[Circular]';
98
+ seen.add(value);
99
+ }
100
+ return value;
101
+ },
102
+ pretty ? 2 : undefined
103
+ );
104
+ } catch (e) {
105
+ return `[Unstringifiable: ${e.message}]`;
106
+ }
107
+ }
108
+
109
+ function log(event, data) {
110
+ const ts = new Date().toISOString();
111
+ const payload = data ? ` ${safeJsonStringify(data)}` : '';
112
+ console.error(`[${ts}] ${event}${payload}`);
113
+ }
114
+
115
+ /**
116
+ * Cleans up stale .tmp.* files from a previous crash.
117
+ */
118
+ function cleanupTmpFiles(statePath) {
119
+ if (!statePath) return;
120
+ const dir = dirname(statePath);
121
+ const name = basename(statePath);
122
+ try {
123
+ for (const entry of readdirSync(dir)) {
124
+ if (entry.startsWith(name + '.tmp.')) {
125
+ try {
126
+ unlinkSync(join(dir, entry));
127
+ } catch {
128
+ /* best-effort */
129
+ }
130
+ }
131
+ }
132
+ } catch {
133
+ /* directory may not exist yet */
134
+ }
135
+ }
136
+
137
+ const STATES = Object.freeze({
138
+ INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
139
+ CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
140
+ DISCOVERY: 'DISCOVERY',
141
+ LEVEL_RESOLVED: 'LEVEL_RESOLVED',
142
+ ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
143
+ SPECIFICATION: 'SPECIFICATION',
144
+ EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
145
+ EXECUTION_DECISION_PENDING: 'EXECUTION_DECISION_PENDING',
146
+ EXECUTING_INLINE: 'EXECUTING_INLINE',
147
+ EXECUTING_SUBAGENTS: 'EXECUTING_SUBAGENTS',
148
+ SYNC: 'SYNC',
149
+ DONE: 'DONE',
150
+ BLOCKED: 'BLOCKED',
151
+ });
152
+
153
+ const DEFAULT_STATE = Object.freeze({
154
+ state: STATES.INTERPRETATION_PENDING,
155
+ revision: 0,
156
+ requestId: null,
157
+ changeId: null,
158
+ routeDecisionId: null,
159
+ routeChoice: null,
160
+ executionDecisionId: null,
161
+ executionMode: null,
162
+ snapshots: { codegraph: null, execution: null },
163
+ tasks: {},
164
+ fileFingerprints: {},
165
+ error: null,
166
+ });
167
+
168
+ class OstackyController {
169
+ #statePath;
170
+ #state;
171
+ #loaded;
172
+
173
+ constructor(opts = {}) {
174
+ this.#statePath = opts.statePath;
175
+ if (opts.initialState) {
176
+ this.#state = { ...DEFAULT_STATE, ...opts.initialState };
177
+ this.#loaded = true;
178
+ } else {
179
+ this.#state = null;
180
+ this.#loaded = false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Validates that a parsed state object has the required fields and valid values.
186
+ * Returns null if valid, or an error message if invalid.
187
+ */
188
+ #validateState(parsed) {
189
+ if (typeof parsed !== 'object' || parsed === null) return 'State is not an object';
190
+ if (typeof parsed.state !== 'string') return 'Missing or invalid "state" field';
191
+ if (!STATES[parsed.state]) return `Unknown state: "${parsed.state}"`;
192
+ if (typeof parsed.revision !== 'number') return 'Missing or invalid "revision" field';
193
+ if (parsed.revision < 0) return `Invalid revision: ${parsed.revision}`;
194
+ return null; // valid
195
+ }
196
+
197
+ #load() {
198
+ if (this.#loaded) return;
199
+ if (!this.#statePath) {
200
+ this.#state = { ...DEFAULT_STATE };
201
+ this.#loaded = true;
202
+ return;
203
+ }
204
+ // Try primary state file
205
+ try {
206
+ const raw = readFileSync(this.#statePath, 'utf8');
207
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`State file too large: ${raw.length} bytes`);
208
+ const parsed = JSON.parse(raw);
209
+ const validationError = this.#validateState(parsed);
210
+ if (validationError) throw new Error(`State validation failed: ${validationError}`);
211
+ this.#state = { ...DEFAULT_STATE, ...parsed };
212
+ this.#loaded = true;
213
+ return;
214
+ } catch (err) {
215
+ log('warn:load_primary_failed', { error: err.message });
216
+ }
217
+ // Fallback: try .backup
218
+ const backupPath = this.#statePath + '.backup';
219
+ try {
220
+ const raw = readFileSync(backupPath, 'utf8');
221
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
222
+ const parsed = JSON.parse(raw);
223
+ const validationError = this.#validateState(parsed);
224
+ if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
225
+ this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
226
+ log('warn:state_restored_from_backup');
227
+ this.#loaded = true;
228
+ return;
229
+ } catch (backupErr) {
230
+ // No backup either — set error state instead of silent reset
231
+ this.#state = {
232
+ ...DEFAULT_STATE,
233
+ error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
234
+ };
235
+ log('warn:state_reset', { error: backupErr.message });
236
+ }
237
+ this.#loaded = true;
238
+ }
239
+
240
+ #persist() {
241
+ if (!this.#statePath) return;
242
+ const dir = dirname(this.#statePath);
243
+ mkdirSync(dir, { recursive: true });
244
+ let serialized = safeJsonStringify(this.#state, true);
245
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
246
+ log('warn:state_oversized', { size: serialized.length });
247
+ const trimmed = { ...this.#state, snapshots: { codegraph: null, execution: null } };
248
+ serialized = safeJsonStringify(trimmed, true);
249
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
250
+ log('error:state_too_large_even_after_trim');
251
+ return;
252
+ }
253
+ this.#state.snapshots = { codegraph: null, execution: null };
254
+ }
255
+ const tmp = this.#statePath + '.tmp.' + process.pid;
256
+ writeFileSync(tmp, serialized, 'utf8');
257
+ renameSync(tmp, this.#statePath);
258
+ try {
259
+ const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
260
+ writeFileSync(backupTmp, serialized, 'utf8');
261
+ renameSync(backupTmp, this.#statePath + '.backup');
262
+ } catch {
263
+ /* backup is best-effort */
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Trims old completed tasks when we exceed MAX_TASKS.
269
+ * Keeps the most recent MAX_TASKS entries.
270
+ */
271
+ #trimTasks() {
272
+ if (!this.#state.tasks) return;
273
+ const entries = Object.entries(this.#state.tasks);
274
+ if (entries.length <= MAX_TASKS) return;
275
+ // Sort by completedAt (desc), keep newest MAX_TASKS
276
+ entries.sort((a, b) => {
277
+ const da = a[1].completedAt || '';
278
+ const db = b[1].completedAt || '';
279
+ return db.localeCompare(da);
280
+ });
281
+ const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
282
+ this.#state.tasks = trimmed;
283
+ log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
284
+ }
285
+
286
+ #transition(to, changes = {}) {
287
+ this.#state.revision++;
288
+ this.#state.state = to;
289
+ Object.assign(this.#state, changes);
290
+ this.#persist();
291
+ }
292
+
293
+ #isAllowedTransition(from, via, choiceOrMode) {
294
+ const transitions = TRANSITIONS[from] || [];
295
+ for (const t of transitions) {
296
+ if (t.via !== via) continue;
297
+ if (t.choice !== undefined && t.choice !== choiceOrMode) continue;
298
+ if (t.mode !== undefined && t.mode !== choiceOrMode) continue;
299
+ return t.to;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ async startRequest({ requestId, changeId } = {}) {
305
+ this.#load();
306
+ if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
307
+ return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
308
+ }
309
+ this.#transition('INTERPRETATION_PENDING', {
310
+ requestId: requestId || 'req-' + Date.now(),
311
+ changeId: changeId || null,
312
+ routeDecisionId: null,
313
+ routeChoice: null,
314
+ executionDecisionId: null,
315
+ executionMode: null,
316
+ snapshots: { codegraph: null, execution: null },
317
+ tasks: {},
318
+ fileFingerprints: {},
319
+ error: null,
320
+ });
321
+ return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
322
+ }
323
+
324
+ async requestClarification({ question } = {}) {
325
+ this.#load();
326
+ const to = this.#isAllowedTransition(this.#state.state, 'request_clarification');
327
+ if (!to) return { error: `Cannot request clarification from state ${this.#state.state}` };
328
+ this.#transition(to, { error: question ? `Clarification: ${question}` : null });
329
+ return { state: this.#state.state, revision: this.#state.revision };
330
+ }
331
+
332
+ async recordClarification() {
333
+ this.#load();
334
+ const to = this.#isAllowedTransition(this.#state.state, 'record_clarification');
335
+ if (!to) return { error: `Cannot record clarification from state ${this.#state.state}` };
336
+ this.#transition(to, { error: null });
337
+ return { state: this.#state.state, revision: this.#state.revision };
338
+ }
339
+
340
+ async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
341
+ this.#load();
342
+ const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
343
+ if (!to) return { error: `Cannot record discovery from state ${this.#state.state}` };
344
+ if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
345
+ return { error: `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes` };
346
+ }
347
+ this.#transition(to, {
348
+ routeDecisionId: routeDecisionId || 'route-' + Date.now(),
349
+ routeChoice: null,
350
+ snapshots: { ...this.#state.snapshots, codegraph: snapshot || this.#state.snapshots.codegraph },
351
+ });
352
+ const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
353
+ return {
354
+ state: this.#state.state,
355
+ revision: this.#state.revision,
356
+ level,
357
+ routeDecisionId: this.#state.routeDecisionId,
358
+ defaultChoice,
359
+ };
360
+ }
361
+
362
+ async proceedToRoute() {
363
+ this.#load();
364
+ const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
365
+ if (!to) return { error: `Cannot proceed to route from state ${this.#state.state}` };
366
+ this.#transition(to);
367
+ return { state: this.#state.state, revision: this.#state.revision };
368
+ }
369
+
370
+ async abandon({ reason } = {}) {
371
+ this.#load();
372
+ const to = this.#isAllowedTransition(this.#state.state, 'abandon');
373
+ if (!to) return { error: `Cannot abandon from state ${this.#state.state}` };
374
+ this.#transition(to, { error: reason || 'Abandoned' });
375
+ return { state: this.#state.state, revision: this.#state.revision };
376
+ }
377
+
378
+ async consumeRouteDecision({ decisionId, choice } = {}) {
379
+ this.#load();
380
+ if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
381
+ return { error: `Cannot consume route decision from state ${this.#state.state}` };
382
+ }
383
+ if (this.#state.routeDecisionId !== decisionId) return { error: `Decision ID mismatch` };
384
+ const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
385
+ if (!to) return { error: `Route ${choice} not allowed from ${this.#state.state}` };
386
+ this.#transition(to, { routeChoice: choice });
387
+ return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
388
+ }
389
+
390
+ async specComplete() {
391
+ this.#load();
392
+ const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
393
+ if (!to) return { error: `Cannot complete spec from state ${this.#state.state}` };
394
+ this.#transition(to);
395
+ return { state: this.#state.state, revision: this.#state.revision };
396
+ }
397
+
398
+ async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
399
+ this.#load();
400
+ const to = this.#isAllowedTransition(this.#state.state, 'analysis_complete');
401
+ if (!to) return { error: `Cannot record execution analysis from state ${this.#state.state}` };
402
+ if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
403
+ return { error: `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes` };
404
+ }
405
+ this.#transition(to, {
406
+ executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
407
+ executionMode: null,
408
+ snapshots: { ...this.#state.snapshots, execution: snapshot || null },
409
+ });
410
+ return {
411
+ state: this.#state.state,
412
+ revision: this.#state.revision,
413
+ executionDecisionId: this.#state.executionDecisionId,
414
+ };
415
+ }
416
+
417
+ async consumeExecutionDecision({ decisionId, mode } = {}) {
418
+ this.#load();
419
+ if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
420
+ return { error: `Cannot consume execution decision from state ${this.#state.state}` };
421
+ }
422
+ if (this.#state.executionDecisionId !== decisionId) return { error: `Decision ID mismatch` };
423
+ const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
424
+ if (!to) return { error: `Mode ${mode} not allowed from ${this.#state.state}` };
425
+ this.#transition(to, { executionMode: mode });
426
+ return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
427
+ }
428
+
429
+ async implementationComplete() {
430
+ this.#load();
431
+ const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
432
+ if (!to) return { error: `Cannot complete implementation from state ${this.#state.state}` };
433
+ this.#transition(to);
434
+ return { state: this.#state.state, revision: this.#state.revision };
435
+ }
436
+
437
+ async syncComplete() {
438
+ this.#load();
439
+ const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
440
+ if (!to) return { error: `Cannot complete sync from state ${this.#state.state}` };
441
+ this.#transition(to);
442
+ return { state: this.#state.state, revision: this.#state.revision };
443
+ }
444
+
445
+ async block({ reason } = {}) {
446
+ this.#load();
447
+ const to = this.#isAllowedTransition(this.#state.state, 'block');
448
+ if (!to) return { error: `Cannot block from state ${this.#state.state}` };
449
+ this.#transition(to, { error: reason || 'Blocked' });
450
+ return { state: this.#state.state, revision: this.#state.revision };
451
+ }
452
+
453
+ async replan({ reason } = {}) {
454
+ this.#load();
455
+ const to = this.#isAllowedTransition(this.#state.state, 'replan');
456
+ if (!to) return { error: `Cannot replan from state ${this.#state.state}` };
457
+ this.#transition(to, {
458
+ error: reason || null,
459
+ routeDecisionId: null,
460
+ routeChoice: null,
461
+ executionDecisionId: null,
462
+ executionMode: null,
463
+ snapshots: { codegraph: null, execution: null },
464
+ tasks: {},
465
+ fileFingerprints: {},
466
+ });
467
+ return { state: this.#state.state, revision: this.#state.revision };
468
+ }
469
+
470
+ async getState() {
471
+ this.#load();
472
+ return structuredClone(this.#state);
473
+ }
474
+
475
+ async getTasks() {
476
+ this.#load();
477
+ return { ...this.#state.tasks };
478
+ }
479
+
480
+ async getAvailableTransitions() {
481
+ this.#load();
482
+ return {
483
+ currentState: this.#state.state,
484
+ transitions: TRANSITIONS[this.#state.state] || [],
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Validates an edit against the current file content.
490
+ * Returns one of: EDITABLE, ALREADY_APPLIED, CONFLICT.
491
+ * - EDITABLE: oldString found exactly once, safe to replace.
492
+ * - ALREADY_APPLIED: newString already present in content (idempotent skip).
493
+ * - CONFLICT: oldString not found, or found multiple times.
494
+ */
495
+ async validateEdit({ oldString, newString, content, taskId } = {}) {
496
+ this.#load();
497
+ if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
498
+ return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
499
+ }
500
+ if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
501
+ return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
502
+ }
503
+ if (oldString.length === 0) {
504
+ return { outcome: 'CONFLICT', reason: 'oldString cannot be empty' };
505
+ }
506
+ // Trivial idempotency: identical strings → nothing to do
507
+ if (oldString === newString) {
508
+ return { outcome: 'ALREADY_APPLIED', taskId, reason: 'oldString and newString are identical' };
509
+ }
510
+ // Count occurrences of oldString in content
511
+ let oldCount = 0;
512
+ let idx = 0;
513
+ while ((idx = content.indexOf(oldString, idx)) !== -1) {
514
+ oldCount++;
515
+ idx += oldString.length;
516
+ }
517
+ // If oldString not found, check if newString is already present (edit was already applied)
518
+ if (oldCount === 0) {
519
+ if (content.includes(newString)) {
520
+ return {
521
+ outcome: 'ALREADY_APPLIED',
522
+ taskId,
523
+ reason: 'oldString not found but newString is present — edit was already applied',
524
+ };
525
+ }
526
+ return { outcome: 'CONFLICT', reason: 'oldString not found in content — file was modified externally' };
527
+ }
528
+ if (oldCount > 1) {
529
+ return {
530
+ outcome: 'CONFLICT',
531
+ reason: `oldString found ${oldCount} times need more context to disambiguate`,
532
+ };
533
+ }
534
+ // oldString found exactly once → safe to replace
535
+ return { outcome: 'EDITABLE', taskId };
536
+ }
537
+
538
+ /**
539
+ * Marks a task as completed and records a file fingerprint.
540
+ * Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.
541
+ */
542
+ async completeTask({ taskId, filePath, fileHash } = {}) {
543
+ this.#load();
544
+ if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
545
+ return { error: `Cannot complete task from state ${this.#state.state}` };
546
+ }
547
+ if (!taskId) return { error: 'taskId is required' };
548
+ if (!this.#state.tasks) this.#state.tasks = {};
549
+ this.#state.tasks[taskId] = {
550
+ status: 'COMPLETED',
551
+ completedAt: new Date().toISOString(),
552
+ filePath: filePath || null,
553
+ fileHash: fileHash || null,
554
+ };
555
+ if (filePath && fileHash) {
556
+ if (!this.#state.fileFingerprints) this.#state.fileFingerprints = {};
557
+ this.#state.fileFingerprints[filePath] = fileHash;
558
+ }
559
+ this.#trimTasks();
560
+ this.#persist();
561
+ return {
562
+ taskId,
563
+ status: 'COMPLETED',
564
+ totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
565
+ .length,
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Public flush force-persists current state to disk.
571
+ * Used by graceful shutdown (private fields not accessible from outside).
572
+ */
573
+ flush() {
574
+ this.#persist();
575
+ }
576
+ }
577
+
578
+ const statePath = resolve(process.env.OSTACKY_STATE_PATH || join(process.cwd(), '.opencode', 'ostacky-state.json'));
579
+ const controller = new OstackyController({ statePath });
580
+
581
+ /**
582
+ * Wraps an async tool handler to ALWAYS return a response (even on error).
583
+ * Without this, an unhandled exception in any tool handler leaves the LLM
584
+ * waiting forever — the root cause of agent freezes.
585
+ */
586
+ function safeHandler(fn) {
587
+ return async (params) => {
588
+ try {
589
+ const result = await fn(params);
590
+ return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
591
+ } catch (error) {
592
+ log('tool:error', {
593
+ name: fn.name || 'anonymous',
594
+ error: error.message,
595
+ stack: error.stack,
596
+ });
597
+ return {
598
+ content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
599
+ isError: true,
600
+ };
601
+ }
602
+ };
603
+ }
604
+
605
+ const server = new McpServer({
606
+ name: 'ostacky-controller',
607
+ version: '0.6.2',
608
+ });
609
+
610
+ server.registerTool(
611
+ 'start_request',
612
+ {
613
+ description:
614
+ 'Start or reset a new request. Can be called from ANY state — resets state machine. Call this first.',
615
+ inputSchema: z.object({
616
+ requestId: z.string().optional().describe('Unique request ID'),
617
+ changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
618
+ }),
619
+ },
620
+ safeHandler(async ({ requestId, changeId }) => {
621
+ log('tool:start_request');
622
+ return await controller.startRequest({ requestId, changeId });
623
+ })
624
+ );
625
+
626
+ server.registerTool(
627
+ 'request_clarification',
628
+ {
629
+ description: 'Record that clarification was requested. Transitions to CLARIFICATION_PENDING.',
630
+ inputSchema: z.object({
631
+ question: z.string().optional().describe('The clarification question'),
632
+ }),
633
+ },
634
+ safeHandler(async ({ question }) => {
635
+ log('tool:request_clarification');
636
+ return await controller.requestClarification({ question });
637
+ })
638
+ );
639
+
640
+ server.registerTool(
641
+ 'record_clarification',
642
+ {
643
+ description: 'Record that clarification was answered. Transitions to DISCOVERY.',
644
+ inputSchema: z.object({}),
645
+ },
646
+ safeHandler(async () => {
647
+ log('tool:record_clarification');
648
+ return await controller.recordClarification();
649
+ })
650
+ );
651
+
652
+ server.registerTool(
653
+ 'record_discovery',
654
+ {
655
+ description:
656
+ 'Record discovery complete with level classification. From INTERPRETATION_PENDING goes to ROUTE_DECISION_PENDING. From DISCOVERY goes to LEVEL_RESOLVED.',
657
+ inputSchema: z.object({
658
+ level: z.enum(['0', '0+1', '1+']).describe('Impact level'),
659
+ routeDecisionId: z.string().optional().describe('Unique route decision ID'),
660
+ snapshot: z.any().optional().describe('Optional CodeGraph snapshot'),
661
+ }),
662
+ },
663
+ safeHandler(async ({ level, routeDecisionId, snapshot }) => {
664
+ log('tool:record_discovery', { level });
665
+ return await controller.recordDiscovery({ level, routeDecisionId, snapshot });
666
+ })
667
+ );
668
+
669
+ server.registerTool(
670
+ 'consume_route_decision',
671
+ {
672
+ description: 'Consume the route decision (SPEC or DIRECT). Valid only in ROUTE_DECISION_PENDING.',
673
+ inputSchema: z.object({
674
+ decisionId: z.string().describe('Route decision ID from record_discovery'),
675
+ choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
676
+ }),
677
+ },
678
+ safeHandler(async ({ decisionId, choice }) => {
679
+ log('tool:consume_route_decision', { choice });
680
+ return await controller.consumeRouteDecision({ decisionId, choice });
681
+ })
682
+ );
683
+
684
+ server.registerTool(
685
+ 'spec_complete',
686
+ {
687
+ description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
688
+ inputSchema: z.object({}),
689
+ },
690
+ safeHandler(async () => {
691
+ log('tool:spec_complete');
692
+ return await controller.specComplete();
693
+ })
694
+ );
695
+
696
+ server.registerTool(
697
+ 'record_execution_analysis',
698
+ {
699
+ description: 'Record execution analysis with snapshot. Transitions to EXECUTION_DECISION_PENDING.',
700
+ inputSchema: z.object({
701
+ executionDecisionId: z.string().optional().describe('Unique execution decision ID'),
702
+ snapshot: z.any().optional().describe('Execution analysis snapshot'),
703
+ }),
704
+ },
705
+ safeHandler(async ({ executionDecisionId, snapshot }) => {
706
+ log('tool:record_execution_analysis');
707
+ return await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
708
+ })
709
+ );
710
+
711
+ server.registerTool(
712
+ 'consume_execution_decision',
713
+ {
714
+ description: 'Consume the execution mode decision (INLINE or SUBAGENT_DRIVEN).',
715
+ inputSchema: z.object({
716
+ decisionId: z.string().describe('Execution decision ID from record_execution_analysis'),
717
+ mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
718
+ }),
719
+ },
720
+ safeHandler(async ({ decisionId, mode }) => {
721
+ log('tool:consume_execution_decision', { mode });
722
+ return await controller.consumeExecutionDecision({ decisionId, mode });
723
+ })
724
+ );
725
+
726
+ server.registerTool(
727
+ 'implementation_complete',
728
+ {
729
+ description: 'Mark implementation as complete. Transitions to SYNC.',
730
+ inputSchema: z.object({}),
731
+ },
732
+ safeHandler(async () => {
733
+ log('tool:implementation_complete');
734
+ return await controller.implementationComplete();
735
+ })
736
+ );
737
+
738
+ server.registerTool(
739
+ 'sync_complete',
740
+ {
741
+ description: 'Mark sync as complete. Transitions to DONE.',
742
+ inputSchema: z.object({}),
743
+ },
744
+ safeHandler(async () => {
745
+ log('tool:sync_complete');
746
+ return await controller.syncComplete();
747
+ })
748
+ );
749
+
750
+ server.registerTool(
751
+ 'block',
752
+ {
753
+ description: 'Transition to BLOCKED state with an optional reason.',
754
+ inputSchema: z.object({
755
+ reason: z.string().optional().describe('Reason for blocking'),
756
+ }),
757
+ },
758
+ safeHandler(async ({ reason }) => {
759
+ log('tool:block');
760
+ return await controller.block({ reason });
761
+ })
762
+ );
763
+
764
+ server.registerTool(
765
+ 'replan',
766
+ {
767
+ description: 'Replan from BLOCKED state back to INTERPRETATION_PENDING.',
768
+ inputSchema: z.object({
769
+ reason: z.string().optional().describe('Reason for replanning'),
770
+ }),
771
+ },
772
+ safeHandler(async ({ reason }) => {
773
+ log('tool:replan');
774
+ return await controller.replan({ reason });
775
+ })
776
+ );
777
+
778
+ server.registerTool(
779
+ 'proceed_to_route',
780
+ {
781
+ description: 'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed.',
782
+ inputSchema: z.object({}),
783
+ },
784
+ safeHandler(async () => {
785
+ log('tool:proceed_to_route');
786
+ return await controller.proceedToRoute();
787
+ })
788
+ );
789
+
790
+ server.registerTool(
791
+ 'abandon',
792
+ {
793
+ description: 'Abandon the current request. Transitions to BLOCKED from most states, or to DONE from BLOCKED.',
794
+ inputSchema: z.object({
795
+ reason: z.string().optional().describe('Reason for abandoning'),
796
+ }),
797
+ },
798
+ safeHandler(async ({ reason }) => {
799
+ log('tool:abandon');
800
+ return await controller.abandon({ reason });
801
+ })
802
+ );
803
+
804
+ server.registerTool(
805
+ 'ping',
806
+ {
807
+ description:
808
+ 'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
809
+ inputSchema: z.object({}),
810
+ },
811
+ safeHandler(async () => {
812
+ return {
813
+ pong: true,
814
+ state: await controller.getState().then((s) => ({
815
+ state: s.state,
816
+ revision: s.revision,
817
+ requestId: s.requestId,
818
+ })),
819
+ };
820
+ })
821
+ );
822
+
823
+ server.registerTool(
824
+ 'get_state',
825
+ {
826
+ description: 'Get the current controller state (reads persistent store).',
827
+ inputSchema: z.object({}),
828
+ },
829
+ safeHandler(async () => {
830
+ return await controller.getState();
831
+ })
832
+ );
833
+
834
+ server.registerTool(
835
+ 'get_tasks',
836
+ {
837
+ description: 'Get current task states.',
838
+ inputSchema: z.object({}),
839
+ },
840
+ safeHandler(async () => {
841
+ return await controller.getTasks();
842
+ })
843
+ );
844
+
845
+ server.registerTool(
846
+ 'get_available_transitions',
847
+ {
848
+ description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
849
+ inputSchema: z.object({}),
850
+ },
851
+ safeHandler(async () => {
852
+ return await controller.getAvailableTransitions();
853
+ })
854
+ );
855
+
856
+ server.registerTool(
857
+ 'check_pending_state',
858
+ {
859
+ description:
860
+ 'Check if agent is in a pending state waiting for user input. ' +
861
+ 'MUST be called before ANY tool call when controller is available. ' +
862
+ 'Returns ALLOW or BLOCKED with reason. ' +
863
+ 'EXCEPTION: controller tools (consume_route_decision, consume_execution_decision, ' +
864
+ 'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
865
+ inputSchema: z.object({}),
866
+ },
867
+ safeHandler(async () => {
868
+ const state = await controller.getState();
869
+ const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
870
+ if (pendingStates.includes(state.state)) {
871
+ return {
872
+ status: 'BLOCKED',
873
+ state: state.state,
874
+ revision: state.revision,
875
+ reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
876
+ };
877
+ }
878
+ return { status: 'ALLOW', state: state.state, revision: state.revision };
879
+ })
880
+ );
881
+
882
+ server.registerTool(
883
+ 'validate_edit',
884
+ {
885
+ description:
886
+ 'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
887
+ 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
888
+ 'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
889
+ inputSchema: z.object({
890
+ oldString: z.string().describe('The exact string to find in content (must be unique).'),
891
+ newString: z.string().describe('The replacement string.'),
892
+ content: z
893
+ .string()
894
+ .describe(
895
+ 'REQUIRED — The full file content. ' +
896
+ 'You MUST read the file first with the Read tool, then pass the complete content here. ' +
897
+ 'Example: call Read on the file, store the output, then call validate_edit with that content. ' +
898
+ 'Without this parameter, validate_edit will fail.'
899
+ ),
900
+ taskId: z.string().optional().describe('Optional task ID for tracking.'),
901
+ }),
902
+ },
903
+ safeHandler(async ({ oldString, newString, content, taskId }) => {
904
+ log('tool:validate_edit', {
905
+ taskId,
906
+ oldLen: oldString?.length,
907
+ newLen: newString?.length,
908
+ hasContent: !!content,
909
+ });
910
+ if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
911
+ return {
912
+ outcome: 'CONFLICT',
913
+ reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
914
+ };
915
+ }
916
+ return await controller.validateEdit({ oldString, newString, content, taskId });
917
+ })
918
+ );
919
+
920
+ server.registerTool(
921
+ 'complete_task',
922
+ {
923
+ description:
924
+ 'Mark a task as completed and optionally record a file fingerprint. ' +
925
+ 'Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.',
926
+ inputSchema: z.object({
927
+ taskId: z.string().describe('The task ID to mark as completed.'),
928
+ filePath: z.string().optional().describe('Optional file path that was modified.'),
929
+ fileHash: z.string().optional().describe('Optional SHA-256 hash of the file after modification.'),
930
+ }),
931
+ },
932
+ safeHandler(async ({ taskId, filePath, fileHash }) => {
933
+ log('tool:complete_task', { taskId, filePath });
934
+ return await controller.completeTask({ taskId, filePath, fileHash });
935
+ })
936
+ );
937
+
938
+ /**
939
+ * Graceful shutdown: clean up tmp files and flush state.
940
+ */
941
+ function setupGracefulShutdown(ctrl) {
942
+ const shutdown = (signal) => {
943
+ log('shutdown', { signal });
944
+ // Final persist attempt (flush via public method, sync inside)
945
+ try {
946
+ if (ctrl) ctrl.flush();
947
+ } catch {
948
+ /* best-effort */
949
+ }
950
+ // Clean up own tmp files
951
+ try {
952
+ cleanupTmpFiles(statePath);
953
+ } catch {
954
+ /* best-effort */
955
+ }
956
+ process.exit(signal === 'SIGINT' ? 130 : 0);
957
+ };
958
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
959
+ process.on('SIGINT', () => shutdown('SIGINT'));
960
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
961
+ process.on('SIGPIPE', () => shutdown('SIGPIPE'));
962
+ // Prevent unhandled rejections from silently killing the server
963
+ process.on('unhandledRejection', (reason) => {
964
+ log('unhandled_rejection', { reason: String(reason) });
965
+ });
966
+ }
967
+
968
+ async function main() {
969
+ log('Starting ostacky-controller MCP...');
970
+ log('State path:', { path: statePath });
971
+ // Clean up stale tmp files from previous runs
972
+ cleanupTmpFiles(statePath);
973
+ setupGracefulShutdown(controller);
974
+ const transport = new StdioServerTransport();
975
+ await server.connect(transport);
976
+ log('ostacky-controller connected and ready');
977
+ }
978
+
979
+ const isDirectRun =
980
+ process.argv[1] && (process.argv[1].endsWith('/index.js') || process.argv[1].endsWith('\\index.js'));
981
+
982
+ if (isDirectRun) {
983
+ main().catch((error) => {
984
+ console.error('Fatal error:', error);
985
+ process.exit(1);
986
+ });
987
+ }
988
+
989
+ export { OstackyController };