ostacky 0.7.0 → 0.7.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.
@@ -15,8 +15,21 @@
15
15
  import { McpServer } from '@modelcontextprotocol/server';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
17
17
  import * as z from 'zod/v4';
18
- import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
18
+ import {
19
+ readFileSync,
20
+ writeFileSync,
21
+ renameSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ unlinkSync,
25
+ statSync,
26
+ existsSync,
27
+ } from 'node:fs';
19
28
  import { dirname, basename, join, resolve } from 'node:path';
29
+ import { writeFile as writeFileAsync, rename as renameAsync, mkdir as mkdirAsync } from 'node:fs/promises';
30
+
31
+ // T1: non-blocking wait — replaces busy-wait spins that froze the event loop
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
33
 
21
34
  // --- Constants (Fase 5.5 — headroom generoso) ---
22
35
  const MAX_TASKS = 100;
@@ -133,21 +146,59 @@ function log(event, data) {
133
146
  }
134
147
 
135
148
  /**
136
- * Cleans up stale .tmp.* and .lock.* files from a previous crash.
149
+ * Cleans up stale .tmp.* and .lock.* files from a previous crash — C3 fix: never delete active locks of another process.
150
+ * Also handles orphaned .ostacky-handoff-compaction.json (only if ts>24h).
137
151
  */
138
152
  function cleanupTmpFiles(statePath) {
139
153
  if (!statePath) return;
140
154
  const dir = dirname(statePath);
141
155
  const name = basename(statePath);
156
+ const staleWindow = 15000;
157
+ const handoffTtl = 24 * 60 * 60 * 1000;
142
158
  try {
143
159
  for (const entry of readdirSync(dir)) {
144
- if (entry.startsWith(name + '.tmp.') || entry.startsWith(name + '.lock')) {
160
+ const isTmp = entry.startsWith(name + '.tmp.');
161
+ const isLock = entry.startsWith(name + '.lock');
162
+ const isHandoff = entry === '.ostacky-handoff-compaction.json';
163
+ if (!isTmp && !isLock && !isHandoff) continue;
164
+ // C3: don't delete active lock of another process
165
+ if (isLock) {
166
+ try {
167
+ const pidPath = join(dir, name + '.lock.pid');
168
+ const tsPath = join(dir, name + '.lock.timestamp');
169
+ // If we are checking a lock file, verify liveness
170
+ let lockPid = null;
171
+ let lockTs = null;
172
+ try {
173
+ lockPid = readFileSync(pidPath, 'utf8').trim();
174
+ } catch {}
175
+ try {
176
+ lockTs = parseInt(readFileSync(tsPath, 'utf8').trim(), 10);
177
+ } catch {}
178
+ if (lockPid && lockTs && !Number.isNaN(lockTs)) {
179
+ const age = Date.now() - lockTs;
180
+ if (age < staleWindow && String(lockPid) !== String(process.pid)) {
181
+ continue; // active lock of another process — skip
182
+ }
183
+ }
184
+ } catch {}
185
+ }
186
+ if (isHandoff) {
145
187
  try {
146
- unlinkSync(join(dir, entry));
188
+ const handoffPath = join(dir, entry);
189
+ const raw = readFileSync(handoffPath, 'utf8');
190
+ const data = JSON.parse(raw);
191
+ const ts = data?.ts ?? data?.timestamp ?? 0;
192
+ if (ts && Date.now() - ts < handoffTtl) continue; // keep recent handoff
147
193
  } catch {
148
- /* best-effort */
194
+ // If unreadable, treat as stale and delete
149
195
  }
150
196
  }
197
+ try {
198
+ unlinkSync(join(dir, entry));
199
+ } catch {
200
+ /* best-effort */
201
+ }
151
202
  }
152
203
  } catch {
153
204
  /* directory may not exist yet */
@@ -195,6 +246,9 @@ const DEFAULT_STATE = Object.freeze({
195
246
  fileFingerprints: {},
196
247
  error: null,
197
248
  lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
249
+ expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
250
+ expectedTaskCount: null, // C2: count fallback when IDs not available
251
+ auditSeq: 0, // C1: persistent seq for audit IDs
198
252
  });
199
253
 
200
254
  class OstackyController {
@@ -207,7 +261,7 @@ class OstackyController {
207
261
  #lockPath;
208
262
  #lockPidPath;
209
263
  #lockHeartbeatPath;
210
- #lockMaxAttempts = 10; // overridable via opts for fast tests
264
+ #lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
211
265
 
212
266
  constructor(opts = {}) {
213
267
  this.#statePath = opts.statePath;
@@ -246,48 +300,60 @@ class OstackyController {
246
300
  return null; // valid
247
301
  }
248
302
 
249
- // --- 3.4: State file locking ---
250
- #acquireLock() {
303
+ // --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
304
+ async #acquireLock() {
251
305
  if (!this.#lockPath) return true;
252
- // Allow tests to shorten retry loops via opts.lockMaxAttempts
253
306
  const maxAttempts = this.#lockMaxAttempts;
254
- const lockTimeout = 5000;
307
+ const lockTimeout = 1000;
308
+ const staleWindow = 15000;
255
309
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
256
310
  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)
311
+ // Check existing lock BEFORE overwriting — corrects mutual-exclusion bug
260
312
  try {
261
313
  const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
262
314
  const lockAge = Date.now() - parseInt(lockContent, 10);
263
- if (lockAge > 30000) {
264
- const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
315
+ if (!Number.isNaN(lockAge) && lockAge < staleWindow) {
316
+ const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
317
+ const jitter = Math.floor(Math.random() * 200) - 100;
318
+ const waitMs = Math.max(0, base + jitter);
319
+ if (waitMs > 0) await sleep(waitMs);
320
+ continue;
321
+ }
322
+ if (!Number.isNaN(lockAge) && lockAge >= staleWindow) {
265
323
  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 */
324
+ const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
325
+ try {
326
+ process.kill(parseInt(lockPid, 10), 0);
327
+ // PID alive but stale beyond window — force release
328
+ } catch {
329
+ // PID dead — force release
271
330
  }
272
- } catch {
273
- // PID doesn't exist — force release
274
- }
331
+ } catch {}
275
332
  this.#releaseLock();
276
- continue;
277
333
  }
278
334
  } catch {
279
- // Can't read heartbeat — assume stale
280
- this.#releaseLock();
281
- continue;
335
+ // No heartbeat file try to acquire
336
+ }
337
+ // Atomic acquire with wx — fails if another process won the race
338
+ try {
339
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
340
+ } catch (e) {
341
+ if (e && e.code === 'EEXIST') {
342
+ const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
343
+ const jitter = Math.floor(Math.random() * 200) - 100;
344
+ const waitMs = Math.max(0, base + jitter);
345
+ if (waitMs > 0) await sleep(waitMs);
346
+ continue;
347
+ }
348
+ throw e;
282
349
  }
350
+ writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
283
351
  return true;
284
352
  } 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
- }
353
+ const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
354
+ const jitter = Math.floor(Math.random() * 200) - 100;
355
+ const waitMs = Math.max(0, base + jitter);
356
+ if (waitMs > 0) await sleep(waitMs);
291
357
  }
292
358
  }
293
359
  log('warn:lock_acquire_failed', { attempts: maxAttempts });
@@ -360,12 +426,12 @@ class OstackyController {
360
426
  this.#loaded = true;
361
427
  }
362
428
 
363
- #persist() {
429
+ async #persist() {
364
430
  if (!this.#statePath) return;
365
431
 
366
432
  const dir = dirname(this.#statePath);
367
433
  try {
368
- mkdirSync(dir, { recursive: true });
434
+ await mkdirAsync(dir, { recursive: true });
369
435
  } catch (err) {
370
436
  // mkdir failures also count toward degraded mode
371
437
  this.#consecutiveFailures++;
@@ -378,7 +444,7 @@ class OstackyController {
378
444
 
379
445
  try {
380
446
  // 3.4: Acquire lock before writing
381
- const lockAcquired = this.#acquireLock();
447
+ const lockAcquired = await this.#acquireLock();
382
448
  if (!lockAcquired) {
383
449
  log('warn:persist_skipped_lock', { state: this.#state.state });
384
450
  throw new Error('Could not acquire state file lock');
@@ -396,12 +462,12 @@ class OstackyController {
396
462
  this.#state.snapshots = { codegraph: null, execution: null };
397
463
  }
398
464
  const tmp = this.#statePath + '.tmp.' + process.pid;
399
- writeFileSync(tmp, serialized, 'utf8');
400
- renameSync(tmp, this.#statePath);
465
+ await writeFileAsync(tmp, serialized, 'utf8');
466
+ await renameAsync(tmp, this.#statePath);
401
467
  try {
402
468
  const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
403
- writeFileSync(backupTmp, serialized, 'utf8');
404
- renameSync(backupTmp, this.#statePath + '.backup');
469
+ await writeFileAsync(backupTmp, serialized, 'utf8');
470
+ await renameAsync(backupTmp, this.#statePath + '.backup');
405
471
  } catch {
406
472
  /* backup is best-effort */
407
473
  }
@@ -415,7 +481,9 @@ class OstackyController {
415
481
  this.#consecutiveFailures++;
416
482
  log('error:persist_failed', { consecutive: this.#consecutiveFailures, error: err.message });
417
483
  if (this.#consecutiveFailures >= DEGRADED_THRESHOLD && !this.#degraded) {
418
- this.#enterDegradedMode(`persistence_failures: ${this.#consecutiveFailures} consecutive persists: ${err.message}`);
484
+ this.#enterDegradedMode(
485
+ `persistence_failures: ${this.#consecutiveFailures} consecutive persists: ${err.message}`
486
+ );
419
487
  }
420
488
  throw err;
421
489
  } finally {
@@ -446,11 +514,11 @@ class OstackyController {
446
514
  log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
447
515
  }
448
516
 
449
- #transition(to, changes = {}) {
517
+ async #transition(to, changes = {}) {
450
518
  this.#state.revision++;
451
519
  this.#state.state = to;
452
520
  Object.assign(this.#state, changes);
453
- this.#persist();
521
+ await this.#persist();
454
522
  }
455
523
 
456
524
  // --- O4: O(1) transition lookup via pre-computed cache ---
@@ -459,28 +527,44 @@ class OstackyController {
459
527
  return ALLOWED_TRANSITIONS[from]?.get(key) || null;
460
528
  }
461
529
 
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();
530
+ // --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
531
+ async #audit(phase, decision, reasoning) {
532
+ const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
533
+ this.#auditBuffer.push({
534
+ id,
535
+ ts: Date.now(),
536
+ phase,
537
+ decision,
538
+ reasoning: reasoning ? String(reasoning).slice(0, 300) : undefined,
539
+ });
540
+ const isWarn = phase === 'WARN';
541
+ if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
542
+ await this.#flushAudit(isWarn);
467
543
  }
468
544
  }
469
545
 
470
- #flushAudit() {
546
+ async #flushAudit(forcePersist = false) {
471
547
  if (this.#auditBuffer.length === 0) return;
472
548
  if (!this.#state.audit) this.#state.audit = [];
549
+ for (const e of this.#auditBuffer) {
550
+ if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
551
+ if (e.reasoning && e.reasoning.length > 300) e.reasoning = e.reasoning.slice(0, 300);
552
+ }
473
553
  this.#state.audit.push(...this.#auditBuffer);
474
554
  if (this.#state.audit.length > 100) {
475
555
  this.#state.audit = this.#state.audit.slice(-100);
476
556
  }
477
557
  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') {
558
+ // O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
559
+ if (
560
+ !forcePersist &&
561
+ this.#state.level === '0' &&
562
+ this.#state.state !== 'DONE' &&
563
+ this.#state.state !== 'BLOCKED'
564
+ ) {
481
565
  return;
482
566
  }
483
- this.#persist();
567
+ await this.#persist();
484
568
  }
485
569
 
486
570
  // --- 3.5: Enriched error with available transitions ---
@@ -540,7 +624,7 @@ class OstackyController {
540
624
  if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
541
625
  return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
542
626
  }
543
- this.#transition('INTERPRETATION_PENDING', {
627
+ await this.#transition('INTERPRETATION_PENDING', {
544
628
  requestId: requestId || 'req-' + Date.now(),
545
629
  changeId: changeId || null,
546
630
  routeDecisionId: null,
@@ -550,27 +634,37 @@ class OstackyController {
550
634
  snapshots: { codegraph: null, execution: null },
551
635
  tasks: {},
552
636
  fileFingerprints: {},
637
+ expectedTasks: null,
638
+ expectedTaskCount: null,
553
639
  error: null,
554
640
  });
555
- this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
641
+ await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
556
642
  return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
557
643
  }
558
644
 
559
645
  async requestClarification({ question } = {}) {
560
646
  this.#load();
561
647
  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');
648
+ if (!to)
649
+ return this.#makeError(
650
+ `Cannot request clarification from state ${this.#state.state}`,
651
+ 'request_clarification'
652
+ );
653
+ await this.#transition(to, { error: question ? `Clarification: ${question}` : null });
654
+ await this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
565
655
  return { state: this.#state.state, revision: this.#state.revision };
566
656
  }
567
657
 
568
658
  async recordClarification() {
569
659
  this.#load();
570
660
  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');
661
+ if (!to)
662
+ return this.#makeError(
663
+ `Cannot record clarification from state ${this.#state.state}`,
664
+ 'record_clarification'
665
+ );
666
+ await this.#transition(to, { error: null });
667
+ await this.#audit('DISCOVERY', 'record_clarification');
574
668
  return { state: this.#state.state, revision: this.#state.revision };
575
669
  }
576
670
 
@@ -599,8 +693,15 @@ class OstackyController {
599
693
  const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
600
694
  if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
601
695
 
696
+ // C2/H3: validate evidence BEFORE compress — _compressed is NOT valid evidence
697
+ const hasEvidence =
698
+ snapshot && !snapshot._compressed && Array.isArray(snapshot.symbols) && snapshot.symbols.length > 0;
699
+ const isTrivial = level === '0';
700
+
602
701
  // O3: Compress snapshot before persisting
603
- const compressedSnapshot = snapshot ? this.#compressCodegraphSnapshot(snapshot) : this.#state.snapshots.codegraph;
702
+ const compressedSnapshot = snapshot
703
+ ? this.#compressCodegraphSnapshot(snapshot)
704
+ : this.#state.snapshots.codegraph;
604
705
  const snapshotJson = compressedSnapshot ? safeJsonStringify(compressedSnapshot) : '';
605
706
  if (snapshotJson.length > MAX_SNAPSHOT_JSON_LENGTH) {
606
707
  return this.#makeError(
@@ -610,13 +711,30 @@ class OstackyController {
610
711
  }
611
712
 
612
713
  const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
613
- this.#transition(to, {
714
+ await this.#transition(to, {
614
715
  routeDecisionId: routeDecisionId || 'route-' + Date.now(),
615
716
  routeChoice: defaultChoice, // O2: persist default suggested choice
616
717
  level, // O1: persist level for conditional persistence
617
718
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
618
719
  });
619
- this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
720
+ await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
721
+ // C2: warning if no evidence and not degraded and not trivial
722
+ if (!hasEvidence && !this.#degraded && !isTrivial) {
723
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
724
+ log('warn:discovery_without_codegraph', { level, auditId });
725
+ await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
726
+ // auditId is the last pushed id
727
+ const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
728
+ return {
729
+ state: this.#state.state,
730
+ revision: this.#state.revision,
731
+ level,
732
+ routeDecisionId: this.#state.routeDecisionId,
733
+ defaultChoice,
734
+ warning: 'discovery without codegraph evidence',
735
+ auditId: lastAudit?.id || auditId,
736
+ };
737
+ }
620
738
  return {
621
739
  state: this.#state.state,
622
740
  revision: this.#state.revision,
@@ -630,8 +748,8 @@ class OstackyController {
630
748
  this.#load();
631
749
  const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
632
750
  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');
751
+ await this.#transition(to);
752
+ await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
635
753
  return { state: this.#state.state, revision: this.#state.revision };
636
754
  }
637
755
 
@@ -639,21 +757,26 @@ class OstackyController {
639
757
  this.#load();
640
758
  const to = this.#isAllowedTransition(this.#state.state, 'abandon');
641
759
  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');
760
+ await this.#transition(to, { error: reason || 'Abandoned' });
761
+ await this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
644
762
  return { state: this.#state.state, revision: this.#state.revision };
645
763
  }
646
764
 
647
765
  async consumeRouteDecision({ decisionId, choice } = {}) {
648
766
  this.#load();
649
767
  if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
650
- return this.#makeError(`Cannot consume route decision from state ${this.#state.state}`, 'consume_route_decision');
768
+ return this.#makeError(
769
+ `Cannot consume route decision from state ${this.#state.state}`,
770
+ 'consume_route_decision'
771
+ );
651
772
  }
652
- if (this.#state.routeDecisionId !== decisionId) return this.#makeError('Decision ID mismatch', 'consume_route_decision');
773
+ if (this.#state.routeDecisionId !== decisionId)
774
+ return this.#makeError('Decision ID mismatch', 'consume_route_decision');
653
775
  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}`);
776
+ if (!to)
777
+ return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
778
+ await this.#transition(to, { routeChoice: choice });
779
+ await this.#audit(to, 'consume_route_decision', `choice=${choice}`);
657
780
  return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
658
781
  }
659
782
 
@@ -661,27 +784,59 @@ class OstackyController {
661
784
  this.#load();
662
785
  const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
663
786
  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');
787
+ await this.#transition(to);
788
+ await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
666
789
  return { state: this.#state.state, revision: this.#state.revision };
667
790
  }
668
791
 
669
792
  async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
670
793
  this.#load();
671
794
  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');
795
+ if (!to)
796
+ return this.#makeError(
797
+ `Cannot record execution analysis from state ${this.#state.state}`,
798
+ 'record_execution_analysis'
799
+ );
673
800
  if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
674
801
  return this.#makeError(
675
802
  `Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes`,
676
803
  'record_execution_analysis'
677
804
  );
678
805
  }
679
- this.#transition(to, {
806
+ // C2: strict contract — recommendation + reasons required
807
+ if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
808
+ return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
809
+ }
810
+ // C2: capture expected tasks for gate
811
+ const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
812
+ const expectedTaskCount = snapshot?.taskCount ?? (Array.isArray(expectedTasks) ? expectedTasks.length : null);
813
+ await this.#transition(to, {
680
814
  executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
681
815
  executionMode: null,
682
816
  snapshots: { ...this.#state.snapshots, execution: snapshot || null },
817
+ expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
818
+ expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
683
819
  });
684
- this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
820
+ await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
821
+ // C2: warning if missing codegraphUsed+recommendation and not degraded
822
+ const hasEvidence =
823
+ snapshot &&
824
+ Array.isArray(snapshot.codegraphUsed) &&
825
+ snapshot.codegraphUsed.length > 0 &&
826
+ snapshot.recommendation != null;
827
+ if (snapshot && !hasEvidence && !this.#degraded) {
828
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
829
+ log('warn:execution_without_codegraph', { auditId });
830
+ await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
831
+ const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
832
+ return {
833
+ state: this.#state.state,
834
+ revision: this.#state.revision,
835
+ executionDecisionId: this.#state.executionDecisionId,
836
+ warning: 'execution analysis without execution-mode-evaluation',
837
+ auditId: lastAudit?.id || auditId,
838
+ };
839
+ }
685
840
  return {
686
841
  state: this.#state.state,
687
842
  revision: this.#state.revision,
@@ -692,33 +847,90 @@ class OstackyController {
692
847
  async consumeExecutionDecision({ decisionId, mode } = {}) {
693
848
  this.#load();
694
849
  if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
695
- return this.#makeError(`Cannot consume execution decision from state ${this.#state.state}`, 'consume_execution_decision');
850
+ return this.#makeError(
851
+ `Cannot consume execution decision from state ${this.#state.state}`,
852
+ 'consume_execution_decision'
853
+ );
696
854
  }
697
- if (this.#state.executionDecisionId !== decisionId) return this.#makeError('Decision ID mismatch', 'consume_execution_decision');
855
+ if (this.#state.executionDecisionId !== decisionId)
856
+ return this.#makeError('Decision ID mismatch', 'consume_execution_decision');
698
857
  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}`);
858
+ if (!to)
859
+ return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
860
+ await this.#transition(to, { executionMode: mode });
861
+ await this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
702
862
  return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
703
863
  }
704
864
 
705
- async implementationComplete() {
865
+ async implementationComplete({ force } = {}) {
706
866
  this.#load();
707
867
  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 };
868
+ if (!to)
869
+ return this.#makeError(
870
+ `Cannot complete implementation from state ${this.#state.state}`,
871
+ 'implementation_complete'
872
+ );
873
+ // C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
874
+ let pending = [];
875
+ if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
876
+ pending = this.#state.expectedTasks.filter(
877
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
878
+ );
879
+ } else if (typeof this.#state.expectedTaskCount === 'number') {
880
+ const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
881
+ if (completed < this.#state.expectedTaskCount)
882
+ pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
883
+ }
884
+ // T3: also block on stale fingerprints-vs-disk
885
+ let staleFiles = [];
886
+ try {
887
+ for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
888
+ if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
889
+ const current = fastFingerprint(info.filePath);
890
+ if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
891
+ else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
892
+ }
893
+ for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
894
+ if (staleFiles.some((s) => s.includes(fp))) continue;
895
+ const cur = fastFingerprint(fp);
896
+ if (!cur) staleFiles.push(`${fp} (missing)`);
897
+ else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
898
+ }
899
+ } catch {}
900
+ const hasBlocking = pending.length > 0 || staleFiles.length > 0;
901
+ if (hasBlocking && !force) {
902
+ return {
903
+ error: staleFiles.length ? 'stale fingerprints' : 'tasks incomplete',
904
+ pending,
905
+ staleFiles: staleFiles.length ? staleFiles : undefined,
906
+ current_state: this.#state.state,
907
+ attempted_transition: 'implementation_complete',
908
+ suggestion:
909
+ 'Complete pending tasks via complete_task or retry with {force:true} after explicit user confirmation',
910
+ };
911
+ }
912
+ if (hasBlocking && force) {
913
+ const all = [...pending, ...staleFiles].join(',');
914
+ await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
915
+ }
916
+ await this.#transition(to);
917
+ await this.#audit('SYNC', 'implementation_complete');
918
+ return {
919
+ state: this.#state.state,
920
+ revision: this.#state.revision,
921
+ forced: !!force,
922
+ pending: pending.length ? pending : undefined,
923
+ };
712
924
  }
713
925
 
714
926
  async syncComplete() {
715
927
  this.#load();
716
928
  const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
717
929
  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');
930
+ await this.#transition(to);
931
+ await this.#audit('DONE', 'sync_complete');
720
932
  // Flush remaining audit entries
721
- this.#flushAudit();
933
+ await this.#flushAudit();
722
934
  return { state: this.#state.state, revision: this.#state.revision };
723
935
  }
724
936
 
@@ -726,8 +938,8 @@ class OstackyController {
726
938
  this.#load();
727
939
  const to = this.#isAllowedTransition(this.#state.state, 'block');
728
940
  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');
941
+ await this.#transition(to, { error: reason || 'Blocked' });
942
+ await this.#audit('BLOCKED', 'block', reason || 'no reason');
731
943
  return { state: this.#state.state, revision: this.#state.revision };
732
944
  }
733
945
 
@@ -735,7 +947,7 @@ class OstackyController {
735
947
  this.#load();
736
948
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
737
949
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
738
- this.#transition(to, {
950
+ await this.#transition(to, {
739
951
  error: reason || null,
740
952
  routeDecisionId: null,
741
953
  routeChoice: null,
@@ -744,11 +956,86 @@ class OstackyController {
744
956
  snapshots: { codegraph: null, execution: null },
745
957
  tasks: {},
746
958
  fileFingerprints: {},
959
+ expectedTasks: null,
960
+ expectedTaskCount: null,
747
961
  });
748
- this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
962
+ await this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
749
963
  return { state: this.#state.state, revision: this.#state.revision };
750
964
  }
751
965
 
966
+ // --- C2: Expected tasks gate (controller as source of truth) ---
967
+ async setExpectedTasks({ taskIds, taskCount } = {}) {
968
+ this.#load();
969
+ if (Array.isArray(taskIds) && taskIds.length > 0) {
970
+ this.#state.expectedTasks = [...taskIds];
971
+ this.#state.expectedTaskCount = taskIds.length;
972
+ } else if (typeof taskCount === 'number' && taskCount > 0) {
973
+ this.#state.expectedTasks = null;
974
+ this.#state.expectedTaskCount = taskCount;
975
+ } else {
976
+ return { error: 'taskIds (array) or taskCount (number) required' };
977
+ }
978
+ await this.#persist();
979
+ await this.#audit(
980
+ 'EXECUTING',
981
+ 'set_expected_tasks',
982
+ `expected=${this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length}`
983
+ );
984
+ return { ok: true, expectedTasks: this.#state.expectedTasks, expectedTaskCount: this.#state.expectedTaskCount };
985
+ }
986
+
987
+ async verifyIntegrity() {
988
+ this.#load();
989
+ let pending = [];
990
+ if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
991
+ pending = this.#state.expectedTasks.filter(
992
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
993
+ );
994
+ } else if (typeof this.#state.expectedTaskCount === 'number') {
995
+ const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
996
+ if (completed < this.#state.expectedTaskCount)
997
+ pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
998
+ }
999
+ // T3: fingerprints-vs-disk — detect stale/missing files after complete_task
1000
+ let staleFiles = [];
1001
+ try {
1002
+ for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
1003
+ if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
1004
+ const current = fastFingerprint(info.filePath);
1005
+ if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
1006
+ else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1007
+ }
1008
+ for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
1009
+ if (staleFiles.some((s) => s.includes(fp))) continue;
1010
+ const current = fastFingerprint(fp);
1011
+ if (!current) staleFiles.push(`${fp} (missing)`);
1012
+ else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
1013
+ }
1014
+ } catch {}
1015
+ const ok = pending.length === 0 && staleFiles.length === 0;
1016
+ return {
1017
+ ok,
1018
+ pending,
1019
+ staleFiles,
1020
+ completed: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED').length,
1021
+ expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
1022
+ state: this.#state.state,
1023
+ };
1024
+ }
1025
+
1026
+ async getAudit({ limit = 20, offset = 0 } = {}) {
1027
+ this.#load();
1028
+ const all = this.#state.audit || [];
1029
+ const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
1030
+ return slice.map((e) => ({
1031
+ id: e.id,
1032
+ ts: e.ts,
1033
+ phase: e.phase,
1034
+ decision: e.decision,
1035
+ reasoning: e.reasoning ? String(e.reasoning).slice(0, 300) : undefined,
1036
+ }));
1037
+ }
1038
+
752
1039
  // --- B2: Handoff persistence for cross-session continuity ---
753
1040
  async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
754
1041
  this.#load();
@@ -761,22 +1048,37 @@ class OstackyController {
761
1048
  nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
762
1049
  pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
763
1050
  };
764
- this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
765
- this.#persist();
1051
+ await this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
1052
+ await this.#persist();
766
1053
  return { ok: true, lastHandoff: this.#state.lastHandoff };
767
1054
  }
768
1055
 
769
1056
  async getHandoff() {
770
1057
  this.#load();
771
- return this.#state.lastHandoff;
1058
+ if (this.#state.lastHandoff) return this.#state.lastHandoff;
1059
+ // C3: fallback to compaction file — same anchor as writer (dirname(statePath))
1060
+ if (!this.#statePath) return null;
1061
+ try {
1062
+ const fallbackPath = join(dirname(this.#statePath), '.ostacky-handoff-compaction.json');
1063
+ const raw = readFileSync(fallbackPath, 'utf8');
1064
+ const data = JSON.parse(raw);
1065
+ if (data && typeof data.summary === 'string') return data;
1066
+ } catch {}
1067
+ return null;
772
1068
  }
773
1069
 
774
1070
  async clearHandoff() {
775
1071
  this.#load();
776
1072
  const prev = this.#state.lastHandoff;
777
1073
  this.#state.lastHandoff = null;
778
- this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
779
- this.#persist();
1074
+ // C3: also delete fallback compaction file (same anchor)
1075
+ if (this.#statePath) {
1076
+ try {
1077
+ unlinkSync(join(dirname(this.#statePath), '.ostacky-handoff-compaction.json'));
1078
+ } catch {}
1079
+ }
1080
+ await this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
1081
+ await this.#persist();
780
1082
  return { ok: true, cleared: prev };
781
1083
  }
782
1084
 
@@ -868,23 +1170,68 @@ class OstackyController {
868
1170
  this.#state.fileFingerprints[filePath] = effectiveHash;
869
1171
  }
870
1172
  this.#trimTasks();
871
- this.#persist();
872
- this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
1173
+ const totalCompleted = Object.keys(this.#state.tasks).filter(
1174
+ (k) => this.#state.tasks[k].status === 'COMPLETED'
1175
+ ).length;
1176
+ // C2: checkpoint count-based cada 3er complete_task — mismo persist, sin escritura extra
1177
+ if (totalCompleted % 3 === 0) {
1178
+ const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
1179
+ ? this.#state.expectedTasks.filter(
1180
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1181
+ )
1182
+ : [];
1183
+ this.#state.lastHandoff = {
1184
+ ts: Date.now(),
1185
+ summary: `Checkpoint auto: ${totalCompleted} tasks completadas`,
1186
+ nextSteps: pendingForHandoff.length ? [`Continuar con ${pendingForHandoff.join(', ')}`] : [],
1187
+ pendingTasks: pendingForHandoff,
1188
+ };
1189
+ }
1190
+ await this.#persist();
1191
+ await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
873
1192
  return {
874
1193
  taskId,
875
1194
  status: 'COMPLETED',
876
- totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
877
- .length,
1195
+ totalCompleted,
878
1196
  };
879
1197
  }
880
1198
 
881
1199
  /**
882
- * Public flush — force-persists current state to disk.
883
- * Used by graceful shutdown (private fields not accessible from outside).
1200
+ * Public flush — SYNCHRONOUS on purpose: SIGINT/SIGTERM handlers cannot await
1201
+ * (Node does not wait for async shutdown work). Drains the audit buffer into
1202
+ * state and runs a best-effort sync persist with a single non-spinning lock
1203
+ * attempt; skips persisting if another process currently holds the lock.
884
1204
  */
885
1205
  flush() {
886
- this.#flushAudit();
887
- this.#persist();
1206
+ if (this.#auditBuffer.length > 0 && this.#state) {
1207
+ if (!this.#state.audit) this.#state.audit = [];
1208
+ for (const e of this.#auditBuffer) {
1209
+ if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
1210
+ }
1211
+ this.#state.audit.push(...this.#auditBuffer);
1212
+ if (this.#state.audit.length > 100) this.#state.audit = this.#state.audit.slice(-100);
1213
+ this.#auditBuffer = [];
1214
+ }
1215
+ // T1: final persist path kept synchronous for graceful shutdown
1216
+ if (!this.#statePath || !this.#state || !this.#loaded) return;
1217
+ try {
1218
+ try {
1219
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1220
+ } catch (e) {
1221
+ if (e && e.code === 'EEXIST') return; // another process holds the lock — skip best-effort persist
1222
+ throw e;
1223
+ }
1224
+ try {
1225
+ writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
1226
+ } catch {}
1227
+ const serialized = safeJsonStringify(this.#state, true);
1228
+ const tmp = this.#statePath + '.tmp.' + process.pid;
1229
+ writeFileSync(tmp, serialized, 'utf8');
1230
+ renameSync(tmp, this.#statePath);
1231
+ this.#releaseLock();
1232
+ } catch {
1233
+ /* shutdown persist is best-effort */
1234
+ }
888
1235
  }
889
1236
  }
890
1237
 
@@ -917,7 +1264,7 @@ function safeHandler(fn) {
917
1264
 
918
1265
  const server = new McpServer({
919
1266
  name: 'ostacky-controller',
920
- version: '0.7.0',
1267
+ version: '0.7.2',
921
1268
  });
922
1269
 
923
1270
  server.registerTool(
@@ -939,7 +1286,8 @@ server.registerTool(
939
1286
  server.registerTool(
940
1287
  'request_clarification',
941
1288
  {
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.',
1289
+ description:
1290
+ '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
1291
  inputSchema: z.object({
944
1292
  question: z.string().optional().describe('The clarification question'),
945
1293
  }),
@@ -1039,12 +1387,18 @@ server.registerTool(
1039
1387
  server.registerTool(
1040
1388
  'implementation_complete',
1041
1389
  {
1042
- description: 'Mark implementation as complete. Transitions to SYNC.',
1043
- inputSchema: z.object({}),
1390
+ description:
1391
+ 'Mark implementation as complete. Transitions to SYNC. Returns error without transitioning if tasks pending unless {force:true}.',
1392
+ inputSchema: z.object({
1393
+ force: z
1394
+ .boolean()
1395
+ .optional()
1396
+ .describe('Force transition even with pending tasks (requires explicit user confirmation)'),
1397
+ }),
1044
1398
  },
1045
- safeHandler(async () => {
1046
- log('tool:implementation_complete');
1047
- return await controller.implementationComplete();
1399
+ safeHandler(async ({ force }) => {
1400
+ log('tool:implementation_complete', { force: !!force });
1401
+ return await controller.implementationComplete({ force: !!force });
1048
1402
  })
1049
1403
  );
1050
1404
 
@@ -1088,10 +1442,55 @@ server.registerTool(
1088
1442
  })
1089
1443
  );
1090
1444
 
1445
+ server.registerTool(
1446
+ 'set_expected_tasks',
1447
+ {
1448
+ description:
1449
+ 'Register expected task IDs for the integrity gate (controller as source of truth). Call after execution analysis.',
1450
+ inputSchema: z.object({
1451
+ taskIds: z.array(z.string()).optional().describe('Array of expected task IDs'),
1452
+ taskCount: z.number().optional().describe('Fallback count when IDs not available'),
1453
+ }),
1454
+ },
1455
+ safeHandler(async ({ taskIds, taskCount }) => {
1456
+ log('tool:set_expected_tasks', { count: taskIds?.length ?? taskCount });
1457
+ return await controller.setExpectedTasks({ taskIds, taskCount });
1458
+ })
1459
+ );
1460
+
1461
+ server.registerTool(
1462
+ 'verify_integrity',
1463
+ {
1464
+ description:
1465
+ 'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
1466
+ inputSchema: z.object({}),
1467
+ },
1468
+ safeHandler(async () => {
1469
+ log('tool:verify_integrity');
1470
+ return await controller.verifyIntegrity();
1471
+ })
1472
+ );
1473
+
1474
+ server.registerTool(
1475
+ 'get_audit',
1476
+ {
1477
+ description: 'Get recent audit entries paginated. Read-only, truncated to 300 chars with unique id per entry.',
1478
+ inputSchema: z.object({
1479
+ limit: z.number().optional().describe('Max entries (default 20)'),
1480
+ offset: z.number().optional().describe('Offset from end (default 0)'),
1481
+ }),
1482
+ },
1483
+ safeHandler(async ({ limit, offset }) => {
1484
+ log('tool:get_audit', { limit, offset });
1485
+ return await controller.getAudit({ limit, offset });
1486
+ })
1487
+ );
1488
+
1091
1489
  server.registerTool(
1092
1490
  'proceed_to_route',
1093
1491
  {
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.',
1492
+ description:
1493
+ '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
1494
  inputSchema: z.object({}),
1096
1495
  },
1097
1496
  safeHandler(async () => {
@@ -1170,7 +1569,8 @@ server.registerTool(
1170
1569
  server.registerTool(
1171
1570
  'set_handoff',
1172
1571
  {
1173
- description: 'Save handoff context for the next session. Call at session end if interrupted or before a context switch. Persists to controller state.',
1572
+ description:
1573
+ 'Save handoff context for the next session. Call at session end if interrupted or before a context switch. Persists to controller state.',
1174
1574
  inputSchema: z.object({
1175
1575
  summary: z.string().describe('What we were working on (1-3 sentences)'),
1176
1576
  nextSteps: z.array(z.string()).optional().describe('Concrete next actions'),
@@ -1278,7 +1678,10 @@ server.registerTool(
1278
1678
  inputSchema: z.object({
1279
1679
  taskId: z.string().describe('The task ID to mark as completed.'),
1280
1680
  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.'),
1681
+ fileHash: z
1682
+ .string()
1683
+ .optional()
1684
+ .describe('Optional SHA-256 or fast fingerprint of the file after modification.'),
1282
1685
  }),
1283
1686
  },
1284
1687
  safeHandler(async ({ taskId, filePath, fileHash }) => {
@@ -1318,7 +1721,7 @@ function setupGracefulShutdown(ctrl) {
1318
1721
  }
1319
1722
 
1320
1723
  async function main() {
1321
- log('Starting ostacky-controller MCP v0.7.0...');
1724
+ log('Starting ostacky-controller MCP v0.7.2...');
1322
1725
  log('State path:', { path: statePath });
1323
1726
  // Clean up stale tmp/lock files from previous runs
1324
1727
  cleanupTmpFiles(statePath);
@@ -1338,4 +1741,4 @@ if (isDirectRun) {
1338
1741
  });
1339
1742
  }
1340
1743
 
1341
- export { OstackyController };
1744
+ export { OstackyController, STATES, DEFAULT_STATE };