ostacky 0.7.1 → 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
  }
@@ -448,11 +514,11 @@ class OstackyController {
448
514
  log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
449
515
  }
450
516
 
451
- #transition(to, changes = {}) {
517
+ async #transition(to, changes = {}) {
452
518
  this.#state.revision++;
453
519
  this.#state.state = to;
454
520
  Object.assign(this.#state, changes);
455
- this.#persist();
521
+ await this.#persist();
456
522
  }
457
523
 
458
524
  // --- O4: O(1) transition lookup via pre-computed cache ---
@@ -461,28 +527,44 @@ class OstackyController {
461
527
  return ALLOWED_TRANSITIONS[from]?.get(key) || null;
462
528
  }
463
529
 
464
- // --- O5: Batched audit trail ---
465
- #audit(phase, decision, reasoning) {
466
- this.#auditBuffer.push({ ts: Date.now(), phase, decision, reasoning });
467
- if (this.#auditBuffer.length >= 10 || phase === 'DONE') {
468
- 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);
469
543
  }
470
544
  }
471
545
 
472
- #flushAudit() {
546
+ async #flushAudit(forcePersist = false) {
473
547
  if (this.#auditBuffer.length === 0) return;
474
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
+ }
475
553
  this.#state.audit.push(...this.#auditBuffer);
476
554
  if (this.#state.audit.length > 100) {
477
555
  this.#state.audit = this.#state.audit.slice(-100);
478
556
  }
479
557
  this.#auditBuffer = [];
480
- // O1: Skip persist for trivial Level 0 requests (non-terminal states).
481
- // Final persist still happens via #transition() and on DONE/BLOCKED via setupGracefulShutdown.
482
- 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
+ ) {
483
565
  return;
484
566
  }
485
- this.#persist();
567
+ await this.#persist();
486
568
  }
487
569
 
488
570
  // --- 3.5: Enriched error with available transitions ---
@@ -542,7 +624,7 @@ class OstackyController {
542
624
  if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
543
625
  return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
544
626
  }
545
- this.#transition('INTERPRETATION_PENDING', {
627
+ await this.#transition('INTERPRETATION_PENDING', {
546
628
  requestId: requestId || 'req-' + Date.now(),
547
629
  changeId: changeId || null,
548
630
  routeDecisionId: null,
@@ -552,9 +634,11 @@ class OstackyController {
552
634
  snapshots: { codegraph: null, execution: null },
553
635
  tasks: {},
554
636
  fileFingerprints: {},
637
+ expectedTasks: null,
638
+ expectedTaskCount: null,
555
639
  error: null,
556
640
  });
557
- this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
641
+ await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
558
642
  return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
559
643
  }
560
644
 
@@ -566,8 +650,8 @@ class OstackyController {
566
650
  `Cannot request clarification from state ${this.#state.state}`,
567
651
  'request_clarification'
568
652
  );
569
- this.#transition(to, { error: question ? `Clarification: ${question}` : null });
570
- this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
653
+ await this.#transition(to, { error: question ? `Clarification: ${question}` : null });
654
+ await this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
571
655
  return { state: this.#state.state, revision: this.#state.revision };
572
656
  }
573
657
 
@@ -579,8 +663,8 @@ class OstackyController {
579
663
  `Cannot record clarification from state ${this.#state.state}`,
580
664
  'record_clarification'
581
665
  );
582
- this.#transition(to, { error: null });
583
- this.#audit('DISCOVERY', 'record_clarification');
666
+ await this.#transition(to, { error: null });
667
+ await this.#audit('DISCOVERY', 'record_clarification');
584
668
  return { state: this.#state.state, revision: this.#state.revision };
585
669
  }
586
670
 
@@ -609,6 +693,11 @@ class OstackyController {
609
693
  const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
610
694
  if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
611
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
+
612
701
  // O3: Compress snapshot before persisting
613
702
  const compressedSnapshot = snapshot
614
703
  ? this.#compressCodegraphSnapshot(snapshot)
@@ -622,13 +711,30 @@ class OstackyController {
622
711
  }
623
712
 
624
713
  const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
625
- this.#transition(to, {
714
+ await this.#transition(to, {
626
715
  routeDecisionId: routeDecisionId || 'route-' + Date.now(),
627
716
  routeChoice: defaultChoice, // O2: persist default suggested choice
628
717
  level, // O1: persist level for conditional persistence
629
718
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
630
719
  });
631
- 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
+ }
632
738
  return {
633
739
  state: this.#state.state,
634
740
  revision: this.#state.revision,
@@ -642,8 +748,8 @@ class OstackyController {
642
748
  this.#load();
643
749
  const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
644
750
  if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
645
- this.#transition(to);
646
- this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
751
+ await this.#transition(to);
752
+ await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
647
753
  return { state: this.#state.state, revision: this.#state.revision };
648
754
  }
649
755
 
@@ -651,8 +757,8 @@ class OstackyController {
651
757
  this.#load();
652
758
  const to = this.#isAllowedTransition(this.#state.state, 'abandon');
653
759
  if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
654
- this.#transition(to, { error: reason || 'Abandoned' });
655
- 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');
656
762
  return { state: this.#state.state, revision: this.#state.revision };
657
763
  }
658
764
 
@@ -669,8 +775,8 @@ class OstackyController {
669
775
  const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
670
776
  if (!to)
671
777
  return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
672
- this.#transition(to, { routeChoice: choice });
673
- this.#audit(to, 'consume_route_decision', `choice=${choice}`);
778
+ await this.#transition(to, { routeChoice: choice });
779
+ await this.#audit(to, 'consume_route_decision', `choice=${choice}`);
674
780
  return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
675
781
  }
676
782
 
@@ -678,8 +784,8 @@ class OstackyController {
678
784
  this.#load();
679
785
  const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
680
786
  if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
681
- this.#transition(to);
682
- this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
787
+ await this.#transition(to);
788
+ await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
683
789
  return { state: this.#state.state, revision: this.#state.revision };
684
790
  }
685
791
 
@@ -697,12 +803,40 @@ class OstackyController {
697
803
  'record_execution_analysis'
698
804
  );
699
805
  }
700
- 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, {
701
814
  executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
702
815
  executionMode: null,
703
816
  snapshots: { ...this.#state.snapshots, execution: snapshot || null },
817
+ expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
818
+ expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
704
819
  });
705
- 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
+ }
706
840
  return {
707
841
  state: this.#state.state,
708
842
  revision: this.#state.revision,
@@ -723,12 +857,12 @@ class OstackyController {
723
857
  const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
724
858
  if (!to)
725
859
  return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
726
- this.#transition(to, { executionMode: mode });
727
- this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
860
+ await this.#transition(to, { executionMode: mode });
861
+ await this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
728
862
  return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
729
863
  }
730
864
 
731
- async implementationComplete() {
865
+ async implementationComplete({ force } = {}) {
732
866
  this.#load();
733
867
  const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
734
868
  if (!to)
@@ -736,19 +870,67 @@ class OstackyController {
736
870
  `Cannot complete implementation from state ${this.#state.state}`,
737
871
  'implementation_complete'
738
872
  );
739
- this.#transition(to);
740
- this.#audit('SYNC', 'implementation_complete');
741
- return { state: this.#state.state, revision: this.#state.revision };
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
+ };
742
924
  }
743
925
 
744
926
  async syncComplete() {
745
927
  this.#load();
746
928
  const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
747
929
  if (!to) return this.#makeError(`Cannot complete sync from state ${this.#state.state}`, 'sync_complete');
748
- this.#transition(to);
749
- this.#audit('DONE', 'sync_complete');
930
+ await this.#transition(to);
931
+ await this.#audit('DONE', 'sync_complete');
750
932
  // Flush remaining audit entries
751
- this.#flushAudit();
933
+ await this.#flushAudit();
752
934
  return { state: this.#state.state, revision: this.#state.revision };
753
935
  }
754
936
 
@@ -756,8 +938,8 @@ class OstackyController {
756
938
  this.#load();
757
939
  const to = this.#isAllowedTransition(this.#state.state, 'block');
758
940
  if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
759
- this.#transition(to, { error: reason || 'Blocked' });
760
- this.#audit('BLOCKED', 'block', reason || 'no reason');
941
+ await this.#transition(to, { error: reason || 'Blocked' });
942
+ await this.#audit('BLOCKED', 'block', reason || 'no reason');
761
943
  return { state: this.#state.state, revision: this.#state.revision };
762
944
  }
763
945
 
@@ -765,7 +947,7 @@ class OstackyController {
765
947
  this.#load();
766
948
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
767
949
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
768
- this.#transition(to, {
950
+ await this.#transition(to, {
769
951
  error: reason || null,
770
952
  routeDecisionId: null,
771
953
  routeChoice: null,
@@ -774,11 +956,86 @@ class OstackyController {
774
956
  snapshots: { codegraph: null, execution: null },
775
957
  tasks: {},
776
958
  fileFingerprints: {},
959
+ expectedTasks: null,
960
+ expectedTaskCount: null,
777
961
  });
778
- this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
962
+ await this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
779
963
  return { state: this.#state.state, revision: this.#state.revision };
780
964
  }
781
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
+
782
1039
  // --- B2: Handoff persistence for cross-session continuity ---
783
1040
  async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
784
1041
  this.#load();
@@ -791,22 +1048,37 @@ class OstackyController {
791
1048
  nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
792
1049
  pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
793
1050
  };
794
- this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
795
- this.#persist();
1051
+ await this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
1052
+ await this.#persist();
796
1053
  return { ok: true, lastHandoff: this.#state.lastHandoff };
797
1054
  }
798
1055
 
799
1056
  async getHandoff() {
800
1057
  this.#load();
801
- 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;
802
1068
  }
803
1069
 
804
1070
  async clearHandoff() {
805
1071
  this.#load();
806
1072
  const prev = this.#state.lastHandoff;
807
1073
  this.#state.lastHandoff = null;
808
- this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
809
- 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();
810
1082
  return { ok: true, cleared: prev };
811
1083
  }
812
1084
 
@@ -898,23 +1170,68 @@ class OstackyController {
898
1170
  this.#state.fileFingerprints[filePath] = effectiveHash;
899
1171
  }
900
1172
  this.#trimTasks();
901
- this.#persist();
902
- 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}`);
903
1192
  return {
904
1193
  taskId,
905
1194
  status: 'COMPLETED',
906
- totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
907
- .length,
1195
+ totalCompleted,
908
1196
  };
909
1197
  }
910
1198
 
911
1199
  /**
912
- * Public flush — force-persists current state to disk.
913
- * 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.
914
1204
  */
915
1205
  flush() {
916
- this.#flushAudit();
917
- 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
+ }
918
1235
  }
919
1236
  }
920
1237
 
@@ -947,7 +1264,7 @@ function safeHandler(fn) {
947
1264
 
948
1265
  const server = new McpServer({
949
1266
  name: 'ostacky-controller',
950
- version: '0.7.1',
1267
+ version: '0.7.2',
951
1268
  });
952
1269
 
953
1270
  server.registerTool(
@@ -1070,12 +1387,18 @@ server.registerTool(
1070
1387
  server.registerTool(
1071
1388
  'implementation_complete',
1072
1389
  {
1073
- description: 'Mark implementation as complete. Transitions to SYNC.',
1074
- 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
+ }),
1075
1398
  },
1076
- safeHandler(async () => {
1077
- log('tool:implementation_complete');
1078
- return await controller.implementationComplete();
1399
+ safeHandler(async ({ force }) => {
1400
+ log('tool:implementation_complete', { force: !!force });
1401
+ return await controller.implementationComplete({ force: !!force });
1079
1402
  })
1080
1403
  );
1081
1404
 
@@ -1119,6 +1442,50 @@ server.registerTool(
1119
1442
  })
1120
1443
  );
1121
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
+
1122
1489
  server.registerTool(
1123
1490
  'proceed_to_route',
1124
1491
  {
@@ -1354,7 +1721,7 @@ function setupGracefulShutdown(ctrl) {
1354
1721
  }
1355
1722
 
1356
1723
  async function main() {
1357
- log('Starting ostacky-controller MCP v0.7.1...');
1724
+ log('Starting ostacky-controller MCP v0.7.2...');
1358
1725
  log('State path:', { path: statePath });
1359
1726
  // Clean up stale tmp/lock files from previous runs
1360
1727
  cleanupTmpFiles(statePath);
@@ -1374,4 +1741,4 @@ if (isDirectRun) {
1374
1741
  });
1375
1742
  }
1376
1743
 
1377
- export { OstackyController };
1744
+ export { OstackyController, STATES, DEFAULT_STATE };