ostacky 0.6.3 → 0.7.0

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,989 +1,1341 @@
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.3',
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 };
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, statSync } from 'node:fs';
19
+ import { dirname, basename, join, resolve } from 'node:path';
20
+
21
+ // --- Constants (Fase 5.5 — headroom generoso) ---
22
+ const MAX_TASKS = 100;
23
+ const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
24
+ const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
25
+ const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
26
+
27
+ // --- Transition table ---
28
+ const TRANSITIONS = {
29
+ INTERPRETATION_PENDING: [
30
+ { via: 'request_clarification', to: 'CLARIFICATION_PENDING' },
31
+ { via: 'proceed_to_discovery', to: 'DISCOVERY' },
32
+ { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
33
+ { via: 'block', to: 'BLOCKED' },
34
+ ],
35
+ CLARIFICATION_PENDING: [
36
+ { via: 'record_clarification', to: 'DISCOVERY' },
37
+ { via: 'block', to: 'BLOCKED' },
38
+ { via: 'abandon', to: 'BLOCKED' },
39
+ ],
40
+ DISCOVERY: [
41
+ { via: 'record_discovery', to: 'LEVEL_RESOLVED' },
42
+ { via: 'block', to: 'BLOCKED' },
43
+ { via: 'abandon', to: 'BLOCKED' },
44
+ ],
45
+ LEVEL_RESOLVED: [
46
+ { via: 'proceed_to_route', to: 'ROUTE_DECISION_PENDING' },
47
+ { via: 'block', to: 'BLOCKED' },
48
+ ],
49
+ ROUTE_DECISION_PENDING: [
50
+ { via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
51
+ { via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
52
+ { via: 'block', to: 'BLOCKED' },
53
+ { via: 'abandon', to: 'BLOCKED' },
54
+ ],
55
+ SPECIFICATION: [
56
+ { via: 'spec_complete', to: 'EXECUTION_ANALYSIS' },
57
+ { via: 'block', to: 'BLOCKED' },
58
+ { via: 'abandon', to: 'BLOCKED' },
59
+ ],
60
+ EXECUTION_ANALYSIS: [
61
+ { via: 'record_execution_analysis', to: 'EXECUTION_DECISION_PENDING' },
62
+ { via: 'block', to: 'BLOCKED' },
63
+ { via: 'abandon', to: 'BLOCKED' },
64
+ ],
65
+ EXECUTION_DECISION_PENDING: [
66
+ { via: 'consume_execution_decision', to: 'EXECUTING_INLINE', mode: 'INLINE' },
67
+ { via: 'consume_execution_decision', to: 'EXECUTING_SUBAGENTS', mode: 'SUBAGENT_DRIVEN' },
68
+ { via: 'block', to: 'BLOCKED' },
69
+ { via: 'abandon', to: 'BLOCKED' },
70
+ ],
71
+ EXECUTING_INLINE: [
72
+ { via: 'implementation_complete', to: 'SYNC' },
73
+ { via: 'block', to: 'BLOCKED' },
74
+ ],
75
+ EXECUTING_SUBAGENTS: [
76
+ { via: 'implementation_complete', to: 'SYNC' },
77
+ { via: 'block', to: 'BLOCKED' },
78
+ ],
79
+ BLOCKED: [
80
+ { via: 'replan', to: 'INTERPRETATION_PENDING' },
81
+ { via: 'abandon', to: 'DONE' },
82
+ ],
83
+ SYNC: [
84
+ { via: 'sync_complete', to: 'DONE' },
85
+ { via: 'block', to: 'BLOCKED' },
86
+ ],
87
+ DONE: [],
88
+ };
89
+
90
+ // --- O4: Pre-computed transition cache (O(1) lookup) ---
91
+ const ALLOWED_TRANSITIONS = Object.freeze(
92
+ Object.fromEntries(
93
+ Object.entries(TRANSITIONS).map(([state, transitions]) => [
94
+ state,
95
+ new Map(transitions.map((t) => [`${t.via}:${t.choice || t.mode || ''}`, t.to])),
96
+ ])
97
+ )
98
+ );
99
+
100
+ /**
101
+ * Safe JSON.stringify that won't throw on circular references.
102
+ *
103
+ * Uses WeakSet (not Map/Set) so:
104
+ * - Object references are tracked without preventing GC
105
+ * - Nested non-cyclic objects are still serialized fully
106
+ * - Symbol keys are silently dropped (JSON limitation, not a bug)
107
+ * - Functions are dropped (JSON limitation)
108
+ * - Returns "[Unstringifiable: ...]" on hard failures (BigInt, etc.)
109
+ */
110
+ function safeJsonStringify(obj, pretty = false) {
111
+ const seen = new WeakSet();
112
+ try {
113
+ return JSON.stringify(
114
+ obj,
115
+ (key, value) => {
116
+ if (typeof value === 'object' && value !== null) {
117
+ if (seen.has(value)) return '[Circular]';
118
+ seen.add(value);
119
+ }
120
+ return value;
121
+ },
122
+ pretty ? 2 : undefined
123
+ );
124
+ } catch (e) {
125
+ return `[Unstringifiable: ${e.message}]`;
126
+ }
127
+ }
128
+
129
+ function log(event, data) {
130
+ const ts = new Date().toISOString();
131
+ const payload = data ? ` ${safeJsonStringify(data)}` : '';
132
+ console.error(`[${ts}] ${event}${payload}`);
133
+ }
134
+
135
+ /**
136
+ * Cleans up stale .tmp.* and .lock.* files from a previous crash.
137
+ */
138
+ function cleanupTmpFiles(statePath) {
139
+ if (!statePath) return;
140
+ const dir = dirname(statePath);
141
+ const name = basename(statePath);
142
+ try {
143
+ for (const entry of readdirSync(dir)) {
144
+ if (entry.startsWith(name + '.tmp.') || entry.startsWith(name + '.lock')) {
145
+ try {
146
+ unlinkSync(join(dir, entry));
147
+ } catch {
148
+ /* best-effort */
149
+ }
150
+ }
151
+ }
152
+ } catch {
153
+ /* directory may not exist yet */
154
+ }
155
+ }
156
+
157
+ // --- O6: Fast fingerprint (mtime + size) ---
158
+ function fastFingerprint(filePath) {
159
+ try {
160
+ const stat = statSync(filePath);
161
+ return `${stat.mtimeMs}-${stat.size}`;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ const STATES = Object.freeze({
168
+ INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
169
+ CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
170
+ DISCOVERY: 'DISCOVERY',
171
+ LEVEL_RESOLVED: 'LEVEL_RESOLVED',
172
+ ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
173
+ SPECIFICATION: 'SPECIFICATION',
174
+ EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
175
+ EXECUTION_DECISION_PENDING: 'EXECUTION_DECISION_PENDING',
176
+ EXECUTING_INLINE: 'EXECUTING_INLINE',
177
+ EXECUTING_SUBAGENTS: 'EXECUTING_SUBAGENTS',
178
+ SYNC: 'SYNC',
179
+ DONE: 'DONE',
180
+ BLOCKED: 'BLOCKED',
181
+ });
182
+
183
+ const DEFAULT_STATE = Object.freeze({
184
+ state: STATES.INTERPRETATION_PENDING,
185
+ revision: 0,
186
+ requestId: null,
187
+ changeId: null,
188
+ routeDecisionId: null,
189
+ routeChoice: null,
190
+ level: null,
191
+ executionDecisionId: null,
192
+ executionMode: null,
193
+ snapshots: { codegraph: null, execution: null },
194
+ tasks: {},
195
+ fileFingerprints: {},
196
+ error: null,
197
+ lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
198
+ });
199
+
200
+ class OstackyController {
201
+ #statePath;
202
+ #state;
203
+ #loaded;
204
+ #degraded = false;
205
+ #consecutiveFailures = 0;
206
+ #auditBuffer = [];
207
+ #lockPath;
208
+ #lockPidPath;
209
+ #lockHeartbeatPath;
210
+ #lockMaxAttempts = 10; // overridable via opts for fast tests
211
+
212
+ constructor(opts = {}) {
213
+ this.#statePath = opts.statePath;
214
+ this.#lockPath = opts.statePath ? opts.statePath + '.lock' : null;
215
+ this.#lockPidPath = opts.statePath ? opts.statePath + '.lock.pid' : null;
216
+ this.#lockHeartbeatPath = opts.statePath ? opts.statePath + '.lock.timestamp' : null;
217
+ if (typeof opts.lockMaxAttempts === 'number' && opts.lockMaxAttempts > 0) {
218
+ this.#lockMaxAttempts = opts.lockMaxAttempts;
219
+ }
220
+ if (opts.initialState) {
221
+ this.#state = { ...DEFAULT_STATE, ...opts.initialState };
222
+ this.#loaded = true;
223
+ } else {
224
+ this.#state = null;
225
+ this.#loaded = false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Returns whether the controller is in degraded mode.
231
+ */
232
+ get degraded() {
233
+ return this.#degraded;
234
+ }
235
+
236
+ /**
237
+ * Validates that a parsed state object has the required fields and valid values.
238
+ * Returns null if valid, or an error message if invalid.
239
+ */
240
+ #validateState(parsed) {
241
+ if (typeof parsed !== 'object' || parsed === null) return 'State is not an object';
242
+ if (typeof parsed.state !== 'string') return 'Missing or invalid "state" field';
243
+ if (!STATES[parsed.state]) return `Unknown state: "${parsed.state}"`;
244
+ if (typeof parsed.revision !== 'number') return 'Missing or invalid "revision" field';
245
+ if (parsed.revision < 0) return `Invalid revision: ${parsed.revision}`;
246
+ return null; // valid
247
+ }
248
+
249
+ // --- 3.4: State file locking ---
250
+ #acquireLock() {
251
+ if (!this.#lockPath) return true;
252
+ // Allow tests to shorten retry loops via opts.lockMaxAttempts
253
+ const maxAttempts = this.#lockMaxAttempts;
254
+ const lockTimeout = 5000;
255
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
256
+ try {
257
+ writeFileSync(this.#lockPidPath, String(process.pid), 'utf8');
258
+ writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
259
+ // Check if lock is stale (>30s without heartbeat)
260
+ try {
261
+ const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
262
+ const lockAge = Date.now() - parseInt(lockContent, 10);
263
+ if (lockAge > 30000) {
264
+ const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
265
+ try {
266
+ process.kill(parseInt(lockPid, 10), 0); // check if PID alive
267
+ // PID exists but lock is stale — wait briefly then force
268
+ const waitStart = Date.now();
269
+ while (Date.now() - waitStart < 10000) {
270
+ /* spin wait */
271
+ }
272
+ } catch {
273
+ // PID doesn't exist — force release
274
+ }
275
+ this.#releaseLock();
276
+ continue;
277
+ }
278
+ } catch {
279
+ // Can't read heartbeat — assume stale
280
+ this.#releaseLock();
281
+ continue;
282
+ }
283
+ return true;
284
+ } catch {
285
+ // Lock held by another process — wait and retry
286
+ const waitMs = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
287
+ const waitStart = Date.now();
288
+ while (Date.now() - waitStart < waitMs) {
289
+ /* spin wait */
290
+ }
291
+ }
292
+ }
293
+ log('warn:lock_acquire_failed', { attempts: maxAttempts });
294
+ return false;
295
+ }
296
+
297
+ #releaseLock() {
298
+ if (!this.#lockPath) return;
299
+ try {
300
+ unlinkSync(this.#lockPidPath);
301
+ } catch {
302
+ /* best-effort */
303
+ }
304
+ try {
305
+ unlinkSync(this.#lockHeartbeatPath);
306
+ } catch {
307
+ /* best-effort */
308
+ }
309
+ }
310
+
311
+ #heartbeatLock() {
312
+ if (!this.#lockHeartbeatPath) return;
313
+ try {
314
+ writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
315
+ } catch {
316
+ /* best-effort */
317
+ }
318
+ }
319
+
320
+ #load() {
321
+ if (this.#loaded) return;
322
+ if (!this.#statePath) {
323
+ this.#state = { ...DEFAULT_STATE };
324
+ this.#loaded = true;
325
+ return;
326
+ }
327
+ // Try primary state file
328
+ try {
329
+ const raw = readFileSync(this.#statePath, 'utf8');
330
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`State file too large: ${raw.length} bytes`);
331
+ const parsed = JSON.parse(raw);
332
+ const validationError = this.#validateState(parsed);
333
+ if (validationError) throw new Error(`State validation failed: ${validationError}`);
334
+ this.#state = { ...DEFAULT_STATE, ...parsed };
335
+ this.#loaded = true;
336
+ return;
337
+ } catch (err) {
338
+ log('warn:load_primary_failed', { error: err.message });
339
+ }
340
+ // Fallback: try .backup
341
+ const backupPath = this.#statePath + '.backup';
342
+ try {
343
+ const raw = readFileSync(backupPath, 'utf8');
344
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
345
+ const parsed = JSON.parse(raw);
346
+ const validationError = this.#validateState(parsed);
347
+ if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
348
+ this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
349
+ log('warn:state_restored_from_backup');
350
+ this.#loaded = true;
351
+ return;
352
+ } catch (backupErr) {
353
+ // No backup either — set error state instead of silent reset
354
+ this.#state = {
355
+ ...DEFAULT_STATE,
356
+ error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
357
+ };
358
+ log('warn:state_reset', { error: backupErr.message });
359
+ }
360
+ this.#loaded = true;
361
+ }
362
+
363
+ #persist() {
364
+ if (!this.#statePath) return;
365
+
366
+ const dir = dirname(this.#statePath);
367
+ try {
368
+ mkdirSync(dir, { recursive: true });
369
+ } catch (err) {
370
+ // mkdir failures also count toward degraded mode
371
+ this.#consecutiveFailures++;
372
+ log('error:persist_mkdir_failed', { consecutive: this.#consecutiveFailures, error: err.message });
373
+ if (this.#consecutiveFailures >= DEGRADED_THRESHOLD && !this.#degraded) {
374
+ this.#enterDegradedMode(`mkdir_failures: ${this.#consecutiveFailures} consecutive: ${err.message}`);
375
+ }
376
+ throw err;
377
+ }
378
+
379
+ try {
380
+ // 3.4: Acquire lock before writing
381
+ const lockAcquired = this.#acquireLock();
382
+ if (!lockAcquired) {
383
+ log('warn:persist_skipped_lock', { state: this.#state.state });
384
+ throw new Error('Could not acquire state file lock');
385
+ }
386
+
387
+ let serialized = safeJsonStringify(this.#state, true);
388
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
389
+ log('warn:state_oversized', { size: serialized.length });
390
+ const trimmed = { ...this.#state, snapshots: { codegraph: null, execution: null } };
391
+ serialized = safeJsonStringify(trimmed, true);
392
+ if (serialized.length > MAX_STATE_FILE_SIZE) {
393
+ log('error:state_too_large_even_after_trim');
394
+ return;
395
+ }
396
+ this.#state.snapshots = { codegraph: null, execution: null };
397
+ }
398
+ const tmp = this.#statePath + '.tmp.' + process.pid;
399
+ writeFileSync(tmp, serialized, 'utf8');
400
+ renameSync(tmp, this.#statePath);
401
+ try {
402
+ const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
403
+ writeFileSync(backupTmp, serialized, 'utf8');
404
+ renameSync(backupTmp, this.#statePath + '.backup');
405
+ } catch {
406
+ /* backup is best-effort */
407
+ }
408
+ // B1: persist success reset failure counter
409
+ if (this.#consecutiveFailures > 0) {
410
+ log('info:persist_recovered', { after: this.#consecutiveFailures });
411
+ }
412
+ this.#consecutiveFailures = 0;
413
+ } catch (err) {
414
+ // B1: persist failure → increment counter, auto-degrade if threshold reached
415
+ this.#consecutiveFailures++;
416
+ log('error:persist_failed', { consecutive: this.#consecutiveFailures, error: err.message });
417
+ if (this.#consecutiveFailures >= DEGRADED_THRESHOLD && !this.#degraded) {
418
+ this.#enterDegradedMode(`persistence_failures: ${this.#consecutiveFailures} consecutive persists: ${err.message}`);
419
+ }
420
+ throw err;
421
+ } finally {
422
+ this.#releaseLock();
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Trims old completed tasks when we exceed MAX_TASKS.
428
+ *
429
+ * Invariant: entries in `state.tasks` are kept sorted by `completedAt` descending
430
+ * (most recent first) as a side-effect of insertion order. We slice(0, MAX_TASKS)
431
+ * to keep the newest MAX_TASKS entries and discard older ones.
432
+ */
433
+
434
+ #trimTasks() {
435
+ if (!this.#state.tasks) return;
436
+ const entries = Object.entries(this.#state.tasks);
437
+ if (entries.length <= MAX_TASKS) return;
438
+ // Sort by completedAt (desc), keep newest MAX_TASKS
439
+ entries.sort((a, b) => {
440
+ const da = a[1].completedAt || '';
441
+ const db = b[1].completedAt || '';
442
+ return db.localeCompare(da);
443
+ });
444
+ const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
445
+ this.#state.tasks = trimmed;
446
+ log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
447
+ }
448
+
449
+ #transition(to, changes = {}) {
450
+ this.#state.revision++;
451
+ this.#state.state = to;
452
+ Object.assign(this.#state, changes);
453
+ this.#persist();
454
+ }
455
+
456
+ // --- O4: O(1) transition lookup via pre-computed cache ---
457
+ #isAllowedTransition(from, via, choiceOrMode) {
458
+ const key = `${via}:${choiceOrMode || ''}`;
459
+ return ALLOWED_TRANSITIONS[from]?.get(key) || null;
460
+ }
461
+
462
+ // --- O5: Batched audit trail ---
463
+ #audit(phase, decision, reasoning) {
464
+ this.#auditBuffer.push({ ts: Date.now(), phase, decision, reasoning });
465
+ if (this.#auditBuffer.length >= 10 || phase === 'DONE') {
466
+ this.#flushAudit();
467
+ }
468
+ }
469
+
470
+ #flushAudit() {
471
+ if (this.#auditBuffer.length === 0) return;
472
+ if (!this.#state.audit) this.#state.audit = [];
473
+ this.#state.audit.push(...this.#auditBuffer);
474
+ if (this.#state.audit.length > 100) {
475
+ this.#state.audit = this.#state.audit.slice(-100);
476
+ }
477
+ this.#auditBuffer = [];
478
+ // O1: Skip persist for trivial Level 0 requests (non-terminal states).
479
+ // Final persist still happens via #transition() and on DONE/BLOCKED via setupGracefulShutdown.
480
+ if (this.#state.level === '0' && this.#state.state !== 'DONE' && this.#state.state !== 'BLOCKED') {
481
+ return;
482
+ }
483
+ this.#persist();
484
+ }
485
+
486
+ // --- 3.5: Enriched error with available transitions ---
487
+ #makeError(message, attemptedTransition) {
488
+ const available = (TRANSITIONS[this.#state?.state] || []).map((t) => {
489
+ let desc = t.via;
490
+ if (t.choice) desc += ` (choice=${t.choice})`;
491
+ if (t.mode) desc += ` (mode=${t.mode})`;
492
+ return desc;
493
+ });
494
+ return {
495
+ error: message,
496
+ current_state: this.#state?.state || 'UNKNOWN',
497
+ attempted_transition: attemptedTransition || null,
498
+ available_transitions: available,
499
+ suggestion: this.#suggestRecovery(this.#state?.state, attemptedTransition),
500
+ timestamp: new Date().toISOString(),
501
+ };
502
+ }
503
+
504
+ #suggestRecovery(state, transition) {
505
+ const suggestions = {
506
+ INTERPRETATION_PENDING: 'Call start_request or proceed_to_discovery first.',
507
+ CLARIFICATION_PENDING: 'Answer the clarification question, then call record_clarification.',
508
+ DISCOVERY: 'Call record_discovery with a level classification.',
509
+ LEVEL_RESOLVED: 'Call proceed_to_route to move to route decision.',
510
+ ROUTE_DECISION_PENDING: 'Call consume_route_decision with SPEC or DIRECT.',
511
+ SPECIFICATION: 'Call spec_complete when specification is done.',
512
+ EXECUTION_ANALYSIS: 'Call record_execution_analysis with a snapshot.',
513
+ EXECUTION_DECISION_PENDING: 'Call consume_execution_decision with INLINE or SUBAGENT_DRIVEN.',
514
+ EXECUTING_INLINE: 'Call implementation_complete when done.',
515
+ EXECUTING_SUBAGENTS: 'Call implementation_complete when done.',
516
+ BLOCKED: 'Call replan to restart, or abandon to stop.',
517
+ SYNC: 'Call sync_complete to finish.',
518
+ DONE: 'Session complete. Call start_request for a new session.',
519
+ };
520
+ return suggestions[state] || `Unexpected state: ${state}`;
521
+ }
522
+
523
+ // --- 3.3: Degraded mode ---
524
+ #enterDegradedMode(reason) {
525
+ this.#degraded = true;
526
+ log('degraded_mode_activated', { reason, state: this.#state?.state });
527
+ }
528
+
529
+ #exitDegradedMode() {
530
+ if (!this.#degraded) return;
531
+ this.#degraded = false;
532
+ this.#consecutiveFailures = 0;
533
+ log('degraded_mode_exited', { state: this.#state?.state });
534
+ }
535
+
536
+ // --- Core transitions ---
537
+
538
+ async startRequest({ requestId, changeId } = {}) {
539
+ this.#load();
540
+ if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
541
+ return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
542
+ }
543
+ this.#transition('INTERPRETATION_PENDING', {
544
+ requestId: requestId || 'req-' + Date.now(),
545
+ changeId: changeId || null,
546
+ routeDecisionId: null,
547
+ routeChoice: null,
548
+ executionDecisionId: null,
549
+ executionMode: null,
550
+ snapshots: { codegraph: null, execution: null },
551
+ tasks: {},
552
+ fileFingerprints: {},
553
+ error: null,
554
+ });
555
+ this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
556
+ return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
557
+ }
558
+
559
+ async requestClarification({ question } = {}) {
560
+ this.#load();
561
+ const to = this.#isAllowedTransition(this.#state.state, 'request_clarification');
562
+ if (!to) return this.#makeError(`Cannot request clarification from state ${this.#state.state}`, 'request_clarification');
563
+ this.#transition(to, { error: question ? `Clarification: ${question}` : null });
564
+ this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
565
+ return { state: this.#state.state, revision: this.#state.revision };
566
+ }
567
+
568
+ async recordClarification() {
569
+ this.#load();
570
+ const to = this.#isAllowedTransition(this.#state.state, 'record_clarification');
571
+ if (!to) return this.#makeError(`Cannot record clarification from state ${this.#state.state}`, 'record_clarification');
572
+ this.#transition(to, { error: null });
573
+ this.#audit('DISCOVERY', 'record_clarification');
574
+ return { state: this.#state.state, revision: this.#state.revision };
575
+ }
576
+
577
+ // --- O3: Compressed CodeGraph snapshots ---
578
+ #compressCodegraphSnapshot(fullResult) {
579
+ if (!fullResult || typeof fullResult !== 'object') return fullResult;
580
+ // If it's already compressed (has our marker), return as-is
581
+ if (fullResult._compressed) return fullResult;
582
+ return {
583
+ _compressed: true,
584
+ symbols: (fullResult.symbols || []).map((s) => ({
585
+ name: s.name,
586
+ kind: s.kind,
587
+ file: s.file,
588
+ })),
589
+ blastRadius: fullResult.blastRadius || null,
590
+ fileCount: (fullResult.files || []).length,
591
+ callPaths: fullResult.callPaths?.map((p) => p.map((s) => s.name || s)) || null,
592
+ timestamp: Date.now(),
593
+ // Intentionally NOT including: fullResult.source (too large)
594
+ };
595
+ }
596
+
597
+ async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
598
+ this.#load();
599
+ const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
600
+ if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
601
+
602
+ // O3: Compress snapshot before persisting
603
+ const compressedSnapshot = snapshot ? this.#compressCodegraphSnapshot(snapshot) : this.#state.snapshots.codegraph;
604
+ const snapshotJson = compressedSnapshot ? safeJsonStringify(compressedSnapshot) : '';
605
+ if (snapshotJson.length > MAX_SNAPSHOT_JSON_LENGTH) {
606
+ return this.#makeError(
607
+ `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes (compressed: ${snapshotJson.length})`,
608
+ 'record_discovery'
609
+ );
610
+ }
611
+
612
+ const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
613
+ this.#transition(to, {
614
+ routeDecisionId: routeDecisionId || 'route-' + Date.now(),
615
+ routeChoice: defaultChoice, // O2: persist default suggested choice
616
+ level, // O1: persist level for conditional persistence
617
+ snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
618
+ });
619
+ this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
620
+ return {
621
+ state: this.#state.state,
622
+ revision: this.#state.revision,
623
+ level,
624
+ routeDecisionId: this.#state.routeDecisionId,
625
+ defaultChoice,
626
+ };
627
+ }
628
+
629
+ async proceedToRoute() {
630
+ this.#load();
631
+ const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
632
+ if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
633
+ this.#transition(to);
634
+ this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
635
+ return { state: this.#state.state, revision: this.#state.revision };
636
+ }
637
+
638
+ async abandon({ reason } = {}) {
639
+ this.#load();
640
+ const to = this.#isAllowedTransition(this.#state.state, 'abandon');
641
+ if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
642
+ this.#transition(to, { error: reason || 'Abandoned' });
643
+ this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
644
+ return { state: this.#state.state, revision: this.#state.revision };
645
+ }
646
+
647
+ async consumeRouteDecision({ decisionId, choice } = {}) {
648
+ this.#load();
649
+ if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
650
+ return this.#makeError(`Cannot consume route decision from state ${this.#state.state}`, 'consume_route_decision');
651
+ }
652
+ if (this.#state.routeDecisionId !== decisionId) return this.#makeError('Decision ID mismatch', 'consume_route_decision');
653
+ const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
654
+ if (!to) return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
655
+ this.#transition(to, { routeChoice: choice });
656
+ this.#audit(to, 'consume_route_decision', `choice=${choice}`);
657
+ return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
658
+ }
659
+
660
+ async specComplete() {
661
+ this.#load();
662
+ const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
663
+ if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
664
+ this.#transition(to);
665
+ this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
666
+ return { state: this.#state.state, revision: this.#state.revision };
667
+ }
668
+
669
+ async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
670
+ this.#load();
671
+ const to = this.#isAllowedTransition(this.#state.state, 'record_execution_analysis');
672
+ if (!to) return this.#makeError(`Cannot record execution analysis from state ${this.#state.state}`, 'record_execution_analysis');
673
+ if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
674
+ return this.#makeError(
675
+ `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes`,
676
+ 'record_execution_analysis'
677
+ );
678
+ }
679
+ this.#transition(to, {
680
+ executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
681
+ executionMode: null,
682
+ snapshots: { ...this.#state.snapshots, execution: snapshot || null },
683
+ });
684
+ this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
685
+ return {
686
+ state: this.#state.state,
687
+ revision: this.#state.revision,
688
+ executionDecisionId: this.#state.executionDecisionId,
689
+ };
690
+ }
691
+
692
+ async consumeExecutionDecision({ decisionId, mode } = {}) {
693
+ this.#load();
694
+ if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
695
+ return this.#makeError(`Cannot consume execution decision from state ${this.#state.state}`, 'consume_execution_decision');
696
+ }
697
+ if (this.#state.executionDecisionId !== decisionId) return this.#makeError('Decision ID mismatch', 'consume_execution_decision');
698
+ const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
699
+ if (!to) return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
700
+ this.#transition(to, { executionMode: mode });
701
+ this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
702
+ return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
703
+ }
704
+
705
+ async implementationComplete() {
706
+ this.#load();
707
+ const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
708
+ if (!to) return this.#makeError(`Cannot complete implementation from state ${this.#state.state}`, 'implementation_complete');
709
+ this.#transition(to);
710
+ this.#audit('SYNC', 'implementation_complete');
711
+ return { state: this.#state.state, revision: this.#state.revision };
712
+ }
713
+
714
+ async syncComplete() {
715
+ this.#load();
716
+ const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
717
+ if (!to) return this.#makeError(`Cannot complete sync from state ${this.#state.state}`, 'sync_complete');
718
+ this.#transition(to);
719
+ this.#audit('DONE', 'sync_complete');
720
+ // Flush remaining audit entries
721
+ this.#flushAudit();
722
+ return { state: this.#state.state, revision: this.#state.revision };
723
+ }
724
+
725
+ async block({ reason } = {}) {
726
+ this.#load();
727
+ const to = this.#isAllowedTransition(this.#state.state, 'block');
728
+ if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
729
+ this.#transition(to, { error: reason || 'Blocked' });
730
+ this.#audit('BLOCKED', 'block', reason || 'no reason');
731
+ return { state: this.#state.state, revision: this.#state.revision };
732
+ }
733
+
734
+ async replan({ reason } = {}) {
735
+ this.#load();
736
+ const to = this.#isAllowedTransition(this.#state.state, 'replan');
737
+ if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
738
+ this.#transition(to, {
739
+ error: reason || null,
740
+ routeDecisionId: null,
741
+ routeChoice: null,
742
+ executionDecisionId: null,
743
+ executionMode: null,
744
+ snapshots: { codegraph: null, execution: null },
745
+ tasks: {},
746
+ fileFingerprints: {},
747
+ });
748
+ this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
749
+ return { state: this.#state.state, revision: this.#state.revision };
750
+ }
751
+
752
+ // --- B2: Handoff persistence for cross-session continuity ---
753
+ async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
754
+ this.#load();
755
+ if (!summary || typeof summary !== 'string') {
756
+ return this.#makeError('summary is required and must be a string', 'set_handoff');
757
+ }
758
+ this.#state.lastHandoff = {
759
+ ts: Date.now(),
760
+ summary,
761
+ nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
762
+ pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
763
+ };
764
+ this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
765
+ this.#persist();
766
+ return { ok: true, lastHandoff: this.#state.lastHandoff };
767
+ }
768
+
769
+ async getHandoff() {
770
+ this.#load();
771
+ return this.#state.lastHandoff;
772
+ }
773
+
774
+ async clearHandoff() {
775
+ this.#load();
776
+ const prev = this.#state.lastHandoff;
777
+ this.#state.lastHandoff = null;
778
+ this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
779
+ this.#persist();
780
+ return { ok: true, cleared: prev };
781
+ }
782
+
783
+ async getState() {
784
+ this.#load();
785
+ return structuredClone(this.#state);
786
+ }
787
+
788
+ async getTasks() {
789
+ this.#load();
790
+ return { ...this.#state.tasks };
791
+ }
792
+
793
+ async getAvailableTransitions() {
794
+ this.#load();
795
+ return {
796
+ currentState: this.#state.state,
797
+ transitions: TRANSITIONS[this.#state.state] || [],
798
+ };
799
+ }
800
+
801
+ // --- O6: Validate edit with fast fingerprint ---
802
+ async validateEdit({ oldString, newString, content, taskId } = {}) {
803
+ this.#load();
804
+ if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
805
+ return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
806
+ }
807
+ if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
808
+ return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
809
+ }
810
+ if (oldString.length === 0) {
811
+ return { outcome: 'CONFLICT', reason: 'oldString cannot be empty' };
812
+ }
813
+ // Trivial idempotency: identical strings → nothing to do
814
+ if (oldString === newString) {
815
+ return { outcome: 'ALREADY_APPLIED', taskId, reason: 'oldString and newString are identical' };
816
+ }
817
+ // Count occurrences of oldString in content
818
+ let oldCount = 0;
819
+ let idx = 0;
820
+ while ((idx = content.indexOf(oldString, idx)) !== -1) {
821
+ oldCount++;
822
+ idx += oldString.length;
823
+ }
824
+ // If oldString not found, check if newString is already present (edit was already applied)
825
+ if (oldCount === 0) {
826
+ if (content.includes(newString)) {
827
+ return {
828
+ outcome: 'ALREADY_APPLIED',
829
+ taskId,
830
+ reason: 'oldString not found but newString is present — edit was already applied',
831
+ };
832
+ }
833
+ return { outcome: 'CONFLICT', reason: 'oldString not found in content — file was modified externally' };
834
+ }
835
+ if (oldCount > 1) {
836
+ return {
837
+ outcome: 'CONFLICT',
838
+ reason: `oldString found ${oldCount} times — need more context to disambiguate`,
839
+ };
840
+ }
841
+ // oldString found exactly once → safe to replace
842
+ return { outcome: 'EDITABLE', taskId };
843
+ }
844
+
845
+ /**
846
+ * Marks a task as completed and records a file fingerprint.
847
+ * Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.
848
+ */
849
+ async completeTask({ taskId, filePath, fileHash } = {}) {
850
+ this.#load();
851
+ if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
852
+ return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
853
+ }
854
+ if (!taskId) return { error: 'taskId is required' };
855
+ if (!this.#state.tasks) this.#state.tasks = {};
856
+
857
+ // O6: Use fast fingerprint if no hash provided
858
+ const effectiveHash = fileHash || (filePath ? fastFingerprint(filePath) : null);
859
+
860
+ this.#state.tasks[taskId] = {
861
+ status: 'COMPLETED',
862
+ completedAt: new Date().toISOString(),
863
+ filePath: filePath || null,
864
+ fileHash: effectiveHash,
865
+ };
866
+ if (filePath && effectiveHash) {
867
+ if (!this.#state.fileFingerprints) this.#state.fileFingerprints = {};
868
+ this.#state.fileFingerprints[filePath] = effectiveHash;
869
+ }
870
+ this.#trimTasks();
871
+ this.#persist();
872
+ this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
873
+ return {
874
+ taskId,
875
+ status: 'COMPLETED',
876
+ totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
877
+ .length,
878
+ };
879
+ }
880
+
881
+ /**
882
+ * Public flush — force-persists current state to disk.
883
+ * Used by graceful shutdown (private fields not accessible from outside).
884
+ */
885
+ flush() {
886
+ this.#flushAudit();
887
+ this.#persist();
888
+ }
889
+ }
890
+
891
+ const statePath = resolve(process.env.OSTACKY_STATE_PATH || join(process.cwd(), '.opencode', 'ostacky-state.json'));
892
+ const controller = new OstackyController({ statePath });
893
+
894
+ /**
895
+ * Wraps an async tool handler to ALWAYS return a response (even on error).
896
+ * Without this, an unhandled exception in any tool handler leaves the LLM
897
+ * waiting forever the root cause of agent freezes.
898
+ */
899
+ function safeHandler(fn) {
900
+ return async (params) => {
901
+ try {
902
+ const result = await fn(params);
903
+ return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
904
+ } catch (error) {
905
+ log('tool:error', {
906
+ name: fn.name || 'anonymous',
907
+ error: error.message,
908
+ stack: error.stack,
909
+ });
910
+ return {
911
+ content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
912
+ isError: true,
913
+ };
914
+ }
915
+ };
916
+ }
917
+
918
+ const server = new McpServer({
919
+ name: 'ostacky-controller',
920
+ version: '0.7.0',
921
+ });
922
+
923
+ server.registerTool(
924
+ 'start_request',
925
+ {
926
+ description:
927
+ 'Start or reset a new request. Can be called from ANY state — resets state machine. Call this first.',
928
+ inputSchema: z.object({
929
+ requestId: z.string().optional().describe('Unique request ID'),
930
+ changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
931
+ }),
932
+ },
933
+ safeHandler(async ({ requestId, changeId }) => {
934
+ log('tool:start_request');
935
+ return await controller.startRequest({ requestId, changeId });
936
+ })
937
+ );
938
+
939
+ server.registerTool(
940
+ 'request_clarification',
941
+ {
942
+ description: 'Pause execution to ask the user for clarification. Use when the request is too vague to classify. Transitions to CLARIFICATION_PENDING — you MUST stop and wait for user response.',
943
+ inputSchema: z.object({
944
+ question: z.string().optional().describe('The clarification question'),
945
+ }),
946
+ },
947
+ safeHandler(async ({ question }) => {
948
+ log('tool:request_clarification');
949
+ return await controller.requestClarification({ question });
950
+ })
951
+ );
952
+
953
+ server.registerTool(
954
+ 'record_clarification',
955
+ {
956
+ description: 'Record that clarification was answered. Transitions to DISCOVERY.',
957
+ inputSchema: z.object({}),
958
+ },
959
+ safeHandler(async () => {
960
+ log('tool:record_clarification');
961
+ return await controller.recordClarification();
962
+ })
963
+ );
964
+
965
+ server.registerTool(
966
+ 'record_discovery',
967
+ {
968
+ description:
969
+ 'Record discovery complete with level classification. From INTERPRETATION_PENDING goes to ROUTE_DECISION_PENDING. From DISCOVERY goes to LEVEL_RESOLVED.',
970
+ inputSchema: z.object({
971
+ level: z.enum(['0', '0+1', '1+']).describe('Impact level'),
972
+ routeDecisionId: z.string().optional().describe('Unique route decision ID'),
973
+ snapshot: z.any().optional().describe('Optional CodeGraph snapshot (auto-compressed)'),
974
+ }),
975
+ },
976
+ safeHandler(async ({ level, routeDecisionId, snapshot }) => {
977
+ log('tool:record_discovery', { level });
978
+ return await controller.recordDiscovery({ level, routeDecisionId, snapshot });
979
+ })
980
+ );
981
+
982
+ server.registerTool(
983
+ 'consume_route_decision',
984
+ {
985
+ description: 'Consume the route decision (SPEC or DIRECT). Valid only in ROUTE_DECISION_PENDING.',
986
+ inputSchema: z.object({
987
+ decisionId: z.string().describe('Route decision ID from record_discovery'),
988
+ choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
989
+ }),
990
+ },
991
+ safeHandler(async ({ decisionId, choice }) => {
992
+ log('tool:consume_route_decision', { choice });
993
+ return await controller.consumeRouteDecision({ decisionId, choice });
994
+ })
995
+ );
996
+
997
+ server.registerTool(
998
+ 'spec_complete',
999
+ {
1000
+ description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
1001
+ inputSchema: z.object({}),
1002
+ },
1003
+ safeHandler(async () => {
1004
+ log('tool:spec_complete');
1005
+ return await controller.specComplete();
1006
+ })
1007
+ );
1008
+
1009
+ server.registerTool(
1010
+ 'record_execution_analysis',
1011
+ {
1012
+ description: 'Record execution analysis with snapshot. Transitions to EXECUTION_DECISION_PENDING.',
1013
+ inputSchema: z.object({
1014
+ executionDecisionId: z.string().optional().describe('Unique execution decision ID'),
1015
+ snapshot: z.any().optional().describe('Execution analysis snapshot'),
1016
+ }),
1017
+ },
1018
+ safeHandler(async ({ executionDecisionId, snapshot }) => {
1019
+ log('tool:record_execution_analysis');
1020
+ return await controller.recordExecutionAnalysis({ executionDecisionId, snapshot });
1021
+ })
1022
+ );
1023
+
1024
+ server.registerTool(
1025
+ 'consume_execution_decision',
1026
+ {
1027
+ description: 'Consume the execution mode decision (INLINE or SUBAGENT_DRIVEN).',
1028
+ inputSchema: z.object({
1029
+ decisionId: z.string().describe('Execution decision ID from record_execution_analysis'),
1030
+ mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
1031
+ }),
1032
+ },
1033
+ safeHandler(async ({ decisionId, mode }) => {
1034
+ log('tool:consume_execution_decision', { mode });
1035
+ return await controller.consumeExecutionDecision({ decisionId, mode });
1036
+ })
1037
+ );
1038
+
1039
+ server.registerTool(
1040
+ 'implementation_complete',
1041
+ {
1042
+ description: 'Mark implementation as complete. Transitions to SYNC.',
1043
+ inputSchema: z.object({}),
1044
+ },
1045
+ safeHandler(async () => {
1046
+ log('tool:implementation_complete');
1047
+ return await controller.implementationComplete();
1048
+ })
1049
+ );
1050
+
1051
+ server.registerTool(
1052
+ 'sync_complete',
1053
+ {
1054
+ description: 'Mark sync as complete. Transitions to DONE.',
1055
+ inputSchema: z.object({}),
1056
+ },
1057
+ safeHandler(async () => {
1058
+ log('tool:sync_complete');
1059
+ return await controller.syncComplete();
1060
+ })
1061
+ );
1062
+
1063
+ server.registerTool(
1064
+ 'block',
1065
+ {
1066
+ description: 'Transition to BLOCKED state with an optional reason.',
1067
+ inputSchema: z.object({
1068
+ reason: z.string().optional().describe('Reason for blocking'),
1069
+ }),
1070
+ },
1071
+ safeHandler(async ({ reason }) => {
1072
+ log('tool:block');
1073
+ return await controller.block({ reason });
1074
+ })
1075
+ );
1076
+
1077
+ server.registerTool(
1078
+ 'replan',
1079
+ {
1080
+ description: 'Replan from BLOCKED state back to INTERPRETATION_PENDING.',
1081
+ inputSchema: z.object({
1082
+ reason: z.string().optional().describe('Reason for replanning'),
1083
+ }),
1084
+ },
1085
+ safeHandler(async ({ reason }) => {
1086
+ log('tool:replan');
1087
+ return await controller.replan({ reason });
1088
+ })
1089
+ );
1090
+
1091
+ server.registerTool(
1092
+ 'proceed_to_route',
1093
+ {
1094
+ description: 'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed. Only valid from LEVEL_RESOLVED — call this after asking the user about the route decision.',
1095
+ inputSchema: z.object({}),
1096
+ },
1097
+ safeHandler(async () => {
1098
+ log('tool:proceed_to_route');
1099
+ return await controller.proceedToRoute();
1100
+ })
1101
+ );
1102
+
1103
+ server.registerTool(
1104
+ 'abandon',
1105
+ {
1106
+ description: 'Abandon the current request. Transitions to BLOCKED from most states, or to DONE from BLOCKED.',
1107
+ inputSchema: z.object({
1108
+ reason: z.string().optional().describe('Reason for abandoning'),
1109
+ }),
1110
+ },
1111
+ safeHandler(async ({ reason }) => {
1112
+ log('tool:abandon');
1113
+ return await controller.abandon({ reason });
1114
+ })
1115
+ );
1116
+
1117
+ server.registerTool(
1118
+ 'ping',
1119
+ {
1120
+ description:
1121
+ 'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
1122
+ inputSchema: z.object({}),
1123
+ },
1124
+ safeHandler(async () => {
1125
+ return {
1126
+ pong: true,
1127
+ degraded: controller.degraded,
1128
+ state: await controller.getState().then((s) => ({
1129
+ state: s.state,
1130
+ revision: s.revision,
1131
+ requestId: s.requestId,
1132
+ })),
1133
+ };
1134
+ })
1135
+ );
1136
+
1137
+ server.registerTool(
1138
+ 'get_state',
1139
+ {
1140
+ description: 'Get the current controller state (reads persistent store).',
1141
+ inputSchema: z.object({}),
1142
+ },
1143
+ safeHandler(async () => {
1144
+ return await controller.getState();
1145
+ })
1146
+ );
1147
+
1148
+ server.registerTool(
1149
+ 'get_tasks',
1150
+ {
1151
+ description: 'Get current task states.',
1152
+ inputSchema: z.object({}),
1153
+ },
1154
+ safeHandler(async () => {
1155
+ return await controller.getTasks();
1156
+ })
1157
+ );
1158
+
1159
+ server.registerTool(
1160
+ 'get_available_transitions',
1161
+ {
1162
+ description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
1163
+ inputSchema: z.object({}),
1164
+ },
1165
+ safeHandler(async () => {
1166
+ return await controller.getAvailableTransitions();
1167
+ })
1168
+ );
1169
+
1170
+ server.registerTool(
1171
+ 'set_handoff',
1172
+ {
1173
+ description: 'Save handoff context for the next session. Call at session end if interrupted or before a context switch. Persists to controller state.',
1174
+ inputSchema: z.object({
1175
+ summary: z.string().describe('What we were working on (1-3 sentences)'),
1176
+ nextSteps: z.array(z.string()).optional().describe('Concrete next actions'),
1177
+ pendingTasks: z.array(z.string()).optional().describe('Task IDs or descriptions of pending work'),
1178
+ }),
1179
+ },
1180
+ safeHandler(async ({ summary, nextSteps, pendingTasks }) => {
1181
+ return await controller.setHandoff({ summary, nextSteps, pendingTasks });
1182
+ })
1183
+ );
1184
+
1185
+ server.registerTool(
1186
+ 'get_handoff',
1187
+ {
1188
+ description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
1189
+ inputSchema: z.object({}),
1190
+ },
1191
+ safeHandler(async () => {
1192
+ return await controller.getHandoff();
1193
+ })
1194
+ );
1195
+
1196
+ server.registerTool(
1197
+ 'clear_handoff',
1198
+ {
1199
+ description: 'Mark handoff as consumed after the agent has loaded the context.',
1200
+ inputSchema: z.object({}),
1201
+ },
1202
+ safeHandler(async () => {
1203
+ return await controller.clearHandoff();
1204
+ })
1205
+ );
1206
+
1207
+ server.registerTool(
1208
+ 'check_pending_state',
1209
+ {
1210
+ description:
1211
+ 'Check if agent is in a pending state waiting for user input. ' +
1212
+ 'MUST be called before ANY tool call when controller is available. ' +
1213
+ 'Returns ALLOW or BLOCKED with reason. ' +
1214
+ 'EXCEPTION: controller tools (consume_route_decision, consume_execution_decision, ' +
1215
+ 'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
1216
+ inputSchema: z.object({}),
1217
+ },
1218
+ safeHandler(async () => {
1219
+ const state = await controller.getState();
1220
+ const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
1221
+ if (pendingStates.includes(state.state)) {
1222
+ return {
1223
+ status: 'BLOCKED',
1224
+ state: state.state,
1225
+ revision: state.revision,
1226
+ reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
1227
+ degraded: controller.degraded,
1228
+ };
1229
+ }
1230
+ return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
1231
+ })
1232
+ );
1233
+
1234
+ server.registerTool(
1235
+ 'validate_edit',
1236
+ {
1237
+ description:
1238
+ 'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
1239
+ 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
1240
+ 'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
1241
+ inputSchema: z.object({
1242
+ oldString: z.string().describe('The exact string to find in content (must be unique).'),
1243
+ newString: z.string().describe('The replacement string.'),
1244
+ content: z
1245
+ .string()
1246
+ .describe(
1247
+ 'REQUIRED — The full file content. ' +
1248
+ 'You MUST read the file first with the Read tool, then pass the complete content here. ' +
1249
+ 'Example: call Read on the file, store the output, then call validate_edit with that content. ' +
1250
+ 'Without this parameter, validate_edit will fail.'
1251
+ ),
1252
+ taskId: z.string().optional().describe('Optional task ID for tracking.'),
1253
+ }),
1254
+ },
1255
+ safeHandler(async ({ oldString, newString, content, taskId }) => {
1256
+ log('tool:validate_edit', {
1257
+ taskId,
1258
+ oldLen: oldString?.length,
1259
+ newLen: newString?.length,
1260
+ hasContent: !!content,
1261
+ });
1262
+ if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1263
+ return {
1264
+ outcome: 'CONFLICT',
1265
+ reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
1266
+ };
1267
+ }
1268
+ return await controller.validateEdit({ oldString, newString, content, taskId });
1269
+ })
1270
+ );
1271
+
1272
+ server.registerTool(
1273
+ 'complete_task',
1274
+ {
1275
+ description:
1276
+ 'Mark a task as completed and optionally record a file fingerprint. ' +
1277
+ 'Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states.',
1278
+ inputSchema: z.object({
1279
+ taskId: z.string().describe('The task ID to mark as completed.'),
1280
+ filePath: z.string().optional().describe('Optional file path that was modified.'),
1281
+ fileHash: z.string().optional().describe('Optional SHA-256 or fast fingerprint of the file after modification.'),
1282
+ }),
1283
+ },
1284
+ safeHandler(async ({ taskId, filePath, fileHash }) => {
1285
+ log('tool:complete_task', { taskId, filePath });
1286
+ return await controller.completeTask({ taskId, filePath, fileHash });
1287
+ })
1288
+ );
1289
+
1290
+ /**
1291
+ * Graceful shutdown: clean up tmp/lock files and flush state.
1292
+ */
1293
+ function setupGracefulShutdown(ctrl) {
1294
+ const shutdown = (signal) => {
1295
+ log('shutdown', { signal });
1296
+ // Final persist attempt (flush via public method, sync inside)
1297
+ try {
1298
+ if (ctrl) ctrl.flush();
1299
+ } catch {
1300
+ /* best-effort */
1301
+ }
1302
+ // Clean up own tmp and lock files
1303
+ try {
1304
+ cleanupTmpFiles(statePath);
1305
+ } catch {
1306
+ /* best-effort */
1307
+ }
1308
+ process.exit(signal === 'SIGINT' ? 130 : 0);
1309
+ };
1310
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1311
+ process.on('SIGINT', () => shutdown('SIGINT'));
1312
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
1313
+ process.on('SIGPIPE', () => shutdown('SIGPIPE'));
1314
+ // Prevent unhandled rejections from silently killing the server
1315
+ process.on('unhandledRejection', (reason) => {
1316
+ log('unhandled_rejection', { reason: String(reason) });
1317
+ });
1318
+ }
1319
+
1320
+ async function main() {
1321
+ log('Starting ostacky-controller MCP v0.7.0...');
1322
+ log('State path:', { path: statePath });
1323
+ // Clean up stale tmp/lock files from previous runs
1324
+ cleanupTmpFiles(statePath);
1325
+ setupGracefulShutdown(controller);
1326
+ const transport = new StdioServerTransport();
1327
+ await server.connect(transport);
1328
+ log('ostacky-controller connected and ready');
1329
+ }
1330
+
1331
+ const isDirectRun =
1332
+ process.argv[1] && (process.argv[1].endsWith('/index.js') || process.argv[1].endsWith('\\index.js'));
1333
+
1334
+ if (isDirectRun) {
1335
+ main().catch((error) => {
1336
+ console.error('Fatal error:', error);
1337
+ process.exit(1);
1338
+ });
1339
+ }
1340
+
1341
+ export { OstackyController };