ostacky 0.7.2 → 0.7.4

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.
@@ -25,18 +25,85 @@ import {
25
25
  statSync,
26
26
  existsSync,
27
27
  } from 'node:fs';
28
- import { dirname, basename, join, resolve } from 'node:path';
28
+ import { dirname, basename, join, resolve, relative } from 'node:path';
29
29
  import { writeFile as writeFileAsync, rename as renameAsync, mkdir as mkdirAsync } from 'node:fs/promises';
30
+ import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from './security.js';
30
31
 
31
32
  // T1: non-blocking wait — replaces busy-wait spins that froze the event loop
32
33
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
34
 
34
35
  // --- Constants (Fase 5.5 — headroom generoso) ---
35
36
  const MAX_TASKS = 100;
37
+ const MAX_TASKS_DEFAULT = 100;
38
+ const MAX_TASKS_CAP = 500;
36
39
  const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
37
40
  const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
38
41
  const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
39
42
 
43
+ function getMaxTasks() {
44
+ const raw = process.env.OSTACKY_MAX_TASKS;
45
+ if (raw == null || raw === '') return MAX_TASKS_DEFAULT;
46
+ const n = parseInt(raw, 10);
47
+ if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
48
+ if (n > MAX_TASKS_CAP) {
49
+ log('warn:max_tasks_capped', { requested: n, capped: MAX_TASKS_CAP });
50
+ return MAX_TASKS_CAP;
51
+ }
52
+ return n;
53
+ }
54
+
55
+ function getProjectRoot(statePath) {
56
+ if (!statePath) return resolve(process.cwd());
57
+ return dirname(dirname(resolve(statePath)));
58
+ }
59
+
60
+ function isPathInsideProject(filePath, statePath) {
61
+ if (!filePath) return true;
62
+ try {
63
+ const projectRoot = getProjectRoot(statePath);
64
+ const resolved = resolve(projectRoot, filePath);
65
+ const rel = relative(projectRoot, resolved);
66
+ // reject if rel starts with .. or is absolute outside
67
+ if (rel.startsWith('..' + join('', '')) || rel === '..' || rel.startsWith('..')) return false;
68
+ // also reject absolute paths outside project
69
+ if (resolve(filePath) !== resolved && filePath.startsWith('/')) {
70
+ const absRel = relative(projectRoot, resolve(filePath));
71
+ if (absRel.startsWith('..')) return false;
72
+ }
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function isValidTaskId(taskId) {
80
+ return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
81
+ }
82
+
83
+ function getAuditRetention() {
84
+ const raw = process.env.OSTACKY_AUDIT_RETENTION;
85
+ if (raw == null || raw === '') return 500;
86
+ const n = parseInt(raw, 10);
87
+ if (Number.isNaN(n) || n <= 0) return 500;
88
+ if (n > 2000) return 2000;
89
+ return n;
90
+ }
91
+
92
+ function getAuditRetentionSafe() {
93
+ return getAuditRetention();
94
+ }
95
+
96
+ function redactSecrets(obj) {
97
+ if (!obj || typeof obj !== 'object') return obj;
98
+ const str = safeJsonStringify(obj);
99
+ // redact after stringify for persistence — handled in persist
100
+ return obj;
101
+ }
102
+
103
+ const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
104
+
105
+ // D1: source-of-truth — src/security.ts (via ./security.js) — isSensitive, SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, extractPathsFromBash imported above
106
+
40
107
  // --- Transition table ---
41
108
  const TRANSITIONS = {
42
109
  INTERPRETATION_PENDING: [
@@ -139,10 +206,53 @@ function safeJsonStringify(obj, pretty = false) {
139
206
  }
140
207
  }
141
208
 
142
- function log(event, data) {
209
+ function redactForLog(data) {
210
+ if (!data || typeof data !== 'object') return data;
211
+ try {
212
+ const str = safeJsonStringify(data);
213
+ // redact sensitive keys
214
+ if (SENSITIVE_REDACT_RE.test(str)) {
215
+ const copy = JSON.parse(str);
216
+ const redactRecursively = (obj) => {
217
+ if (!obj || typeof obj !== 'object') return;
218
+ for (const k of Object.keys(obj)) {
219
+ if (SENSITIVE_REDACT_RE.test(k)) obj[k] = '[REDACTED]';
220
+ else if (typeof obj[k] === 'object') redactRecursively(obj[k]);
221
+ }
222
+ };
223
+ redactRecursively(copy);
224
+ return copy;
225
+ }
226
+ return data;
227
+ } catch {
228
+ return data;
229
+ }
230
+ }
231
+
232
+ function log(eventOrLevel, maybeEventOrData, maybeData) {
233
+ let level = 'info';
234
+ let event = eventOrLevel;
235
+ let data = maybeEventOrData;
236
+ if (maybeData !== undefined) {
237
+ level = eventOrLevel;
238
+ event = maybeEventOrData;
239
+ data = maybeData;
240
+ } else {
241
+ // infer level from prefix
242
+ if (event.startsWith('warn:')) {
243
+ level = 'warn';
244
+ } else if (event.startsWith('error:')) {
245
+ level = 'error';
246
+ } else if (event.startsWith('info:')) {
247
+ level = 'info';
248
+ } else if (event.startsWith('degraded_')) {
249
+ level = 'warn';
250
+ }
251
+ }
143
252
  const ts = new Date().toISOString();
144
- const payload = data ? ` ${safeJsonStringify(data)}` : '';
145
- console.error(`[${ts}] ${event}${payload}`);
253
+ const safeData = redactForLog(data);
254
+ const payload = safeData ? ` ${safeJsonStringify(safeData)}` : '';
255
+ console.error(`[${ts}] ${level}:${event}${payload}`);
146
256
  }
147
257
 
148
258
  /**
@@ -231,6 +341,14 @@ const STATES = Object.freeze({
231
341
  BLOCKED: 'BLOCKED',
232
342
  });
233
343
 
344
+ // States where start_request should reset (not resume) when force=false
345
+ const TERMINAL_STATES = Object.freeze([
346
+ STATES.INTERPRETATION_PENDING,
347
+ STATES.CLARIFICATION_PENDING,
348
+ STATES.BLOCKED,
349
+ STATES.DONE,
350
+ ]);
351
+
234
352
  const DEFAULT_STATE = Object.freeze({
235
353
  state: STATES.INTERPRETATION_PENDING,
236
354
  revision: 0,
@@ -249,6 +367,40 @@ const DEFAULT_STATE = Object.freeze({
249
367
  expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
250
368
  expectedTaskCount: null, // C2: count fallback when IDs not available
251
369
  auditSeq: 0, // C1: persistent seq for audit IDs
370
+ degraded: false, // D2: persisted degraded flag for restart observability
371
+ schemaVersion: 1, // D3: schema version for migrations
372
+ stateOversizedCount: 0, // 2.3
373
+ codegraphBypassCount: 0, // 6.3 / 3.1
374
+ degradedEditsCount: 0, // 8.5
375
+ cacheHitCount: 0, // 5.4 hardening-v2
376
+ cacheMissCount: 0,
377
+ tokenSavingEstimate: 0,
378
+ lastProposal: null, // 8.1
379
+ allowedFiles: {}, // 9.2
380
+ deniedFiles: {}, // 9.2
381
+ sensitivePatterns: [
382
+ '**/.env*',
383
+ '**/.secrets/**',
384
+ '**/*.pem',
385
+ '**/*.key',
386
+ '**/.aws/**',
387
+ '**/.ssh/**',
388
+ '**/credentials.json',
389
+ '**/.npmrc',
390
+ ], // 9.1
391
+ sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 }, // 9.3
392
+ staleContentAttempts: 0, // 10.4
393
+ completeWithoutValidateCount: 0, // 10.5
394
+ toolTimeoutCount: 0, // 11.1
395
+ lastToolDurationMs: 0, // 11.4
396
+ stateDurationMs: 0, // 11.4
397
+ subagentFailedCount: 0, // 10.6
398
+ lastValidated: null, // 10.5 {filePath, hash, ts}
399
+ pendingFileAccess: {}, // 9.2
400
+ // Heartbeat monitoring for external watchdog (30s stale threshold)
401
+ lastHeartbeat: 0, // epoch ms, updated on each successful tool completion
402
+ watchdogEnabled: true, // when false, external watchdog should not restart based on heartbeat
403
+ ts: Date.now(), // for uptime
252
404
  });
253
405
 
254
406
  class OstackyController {
@@ -262,6 +414,7 @@ class OstackyController {
262
414
  #lockPidPath;
263
415
  #lockHeartbeatPath;
264
416
  #lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
417
+ #lockOwner = false;
265
418
 
266
419
  constructor(opts = {}) {
267
420
  this.#statePath = opts.statePath;
@@ -272,7 +425,8 @@ class OstackyController {
272
425
  this.#lockMaxAttempts = opts.lockMaxAttempts;
273
426
  }
274
427
  if (opts.initialState) {
275
- this.#state = { ...DEFAULT_STATE, ...opts.initialState };
428
+ this.#state = { ...structuredClone(DEFAULT_STATE), ...opts.initialState };
429
+ this.#degraded = !!this.#state.degraded;
276
430
  this.#loaded = true;
277
431
  } else {
278
432
  this.#state = null;
@@ -287,6 +441,18 @@ class OstackyController {
287
441
  return this.#degraded;
288
442
  }
289
443
 
444
+ /**
445
+ * Updates the lastHeartbeat timestamp to now.
446
+ * Called after successful tool completion for external watchdog monitoring.
447
+ * External watchdog contract: if Date.now() - lastHeartbeat > 30000 and watchdogEnabled === true,
448
+ * the watchdog should restart the MCP server process.
449
+ */
450
+ updateHeartbeat() {
451
+ if (this.#state) {
452
+ this.#state.lastHeartbeat = Date.now();
453
+ }
454
+ }
455
+
290
456
  /**
291
457
  * Validates that a parsed state object has the required fields and valid values.
292
458
  * Returns null if valid, or an error message if invalid.
@@ -347,7 +513,8 @@ class OstackyController {
347
513
  }
348
514
  throw e;
349
515
  }
350
- writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
516
+ this.#heartbeatLock();
517
+ this.#lockOwner = true;
351
518
  return true;
352
519
  } catch {
353
520
  const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
@@ -357,11 +524,22 @@ class OstackyController {
357
524
  }
358
525
  }
359
526
  log('warn:lock_acquire_failed', { attempts: maxAttempts });
527
+ this.#lockOwner = false;
360
528
  return false;
361
529
  }
362
530
 
363
531
  #releaseLock() {
364
532
  if (!this.#lockPath) return;
533
+ // D2: verify ownership before deleting — never delete another process's lock
534
+ try {
535
+ const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
536
+ if (ownerPid !== String(process.pid)) {
537
+ this.#lockOwner = false;
538
+ return;
539
+ }
540
+ } catch {
541
+ if (!this.#lockOwner) return;
542
+ }
365
543
  try {
366
544
  unlinkSync(this.#lockPidPath);
367
545
  } catch {
@@ -372,6 +550,7 @@ class OstackyController {
372
550
  } catch {
373
551
  /* best-effort */
374
552
  }
553
+ this.#lockOwner = false;
375
554
  }
376
555
 
377
556
  #heartbeatLock() {
@@ -386,7 +565,7 @@ class OstackyController {
386
565
  #load() {
387
566
  if (this.#loaded) return;
388
567
  if (!this.#statePath) {
389
- this.#state = { ...DEFAULT_STATE };
568
+ this.#state = structuredClone(DEFAULT_STATE);
390
569
  this.#loaded = true;
391
570
  return;
392
571
  }
@@ -397,30 +576,126 @@ class OstackyController {
397
576
  const parsed = JSON.parse(raw);
398
577
  const validationError = this.#validateState(parsed);
399
578
  if (validationError) throw new Error(`State validation failed: ${validationError}`);
400
- this.#state = { ...DEFAULT_STATE, ...parsed };
579
+ this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed };
580
+ let migrated = false;
581
+ if ((parsed.schemaVersion ?? 0) < 1) {
582
+ if (typeof this.#state.snapshots?.codegraph === 'string') {
583
+ try {
584
+ this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
585
+ migrated = true;
586
+ } catch {}
587
+ }
588
+ if (typeof this.#state.snapshots?.execution === 'string') {
589
+ try {
590
+ this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution);
591
+ migrated = true;
592
+ } catch {}
593
+ }
594
+ if (typeof this.#state.expectedTasks === 'string') {
595
+ try {
596
+ const v = JSON.parse(this.#state.expectedTasks);
597
+ this.#state.expectedTasks = Array.isArray(v) ? v : v ? [String(v)] : null;
598
+ migrated = true;
599
+ } catch {}
600
+ }
601
+ if (Array.isArray(this.#state.audit)) {
602
+ for (const e of this.#state.audit) {
603
+ if (!e.id) {
604
+ e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
605
+ migrated = true;
606
+ }
607
+ }
608
+ }
609
+ this.#state.schemaVersion = 1;
610
+ if (migrated) log('info:schema_migrated', { from: parsed.schemaVersion ?? 0, to: 1 });
611
+ }
612
+ // Migration for heartbeat fields (added in controller-resilience-improvements)
613
+ if (this.#state.lastHeartbeat === undefined) {
614
+ this.#state.lastHeartbeat = 0;
615
+ migrated = true;
616
+ }
617
+ if (this.#state.watchdogEnabled === undefined) {
618
+ this.#state.watchdogEnabled = true;
619
+ migrated = true;
620
+ }
621
+ if (migrated)
622
+ log('info:heartbeat_fields_migrated', {
623
+ lastHeartbeat: this.#state.lastHeartbeat,
624
+ watchdogEnabled: this.#state.watchdogEnabled,
625
+ });
626
+ this.#degraded = !!this.#state.degraded;
401
627
  this.#loaded = true;
402
628
  return;
403
629
  } catch (err) {
404
630
  log('warn:load_primary_failed', { error: err.message });
405
631
  }
406
- // Fallback: try .backup
407
- const backupPath = this.#statePath + '.backup';
632
+ // Fallback: try .backup, .backup.1, .backup.2 (2.1 rotativo)
633
+ for (const suffix of ['.backup', '.backup.1', '.backup.2']) {
634
+ const backupPath = this.#statePath + suffix;
635
+ try {
636
+ const raw = readFileSync(backupPath, 'utf8');
637
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
638
+ const parsed = JSON.parse(raw);
639
+ const validationError = this.#validateState(parsed);
640
+ if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
641
+ this.#state = {
642
+ ...structuredClone(DEFAULT_STATE),
643
+ ...parsed,
644
+ error: suffix === '.backup' ? 'State restored from backup' : `State restored from ${suffix}`,
645
+ };
646
+ let backupMigrated = false;
647
+ if ((parsed.schemaVersion ?? 0) < 1) {
648
+ if (typeof this.#state.snapshots?.codegraph === 'string') {
649
+ try {
650
+ this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
651
+ backupMigrated = true;
652
+ } catch {}
653
+ }
654
+ if (typeof this.#state.snapshots?.execution === 'string') {
655
+ try {
656
+ this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution);
657
+ backupMigrated = true;
658
+ } catch {}
659
+ }
660
+ if (Array.isArray(this.#state.audit)) {
661
+ for (const e of this.#state.audit) {
662
+ if (!e.id) {
663
+ e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
664
+ backupMigrated = true;
665
+ }
666
+ }
667
+ }
668
+ this.#state.schemaVersion = 1;
669
+ }
670
+ // Migration for heartbeat fields in backups
671
+ if (this.#state.lastHeartbeat === undefined) {
672
+ this.#state.lastHeartbeat = 0;
673
+ backupMigrated = true;
674
+ }
675
+ if (this.#state.watchdogEnabled === undefined) {
676
+ this.#state.watchdogEnabled = true;
677
+ backupMigrated = true;
678
+ }
679
+ if (backupMigrated)
680
+ log('info:backup_heartbeat_fields_migrated', {
681
+ lastHeartbeat: this.#state.lastHeartbeat,
682
+ watchdogEnabled: this.#state.watchdogEnabled,
683
+ });
684
+ this.#degraded = !!this.#state.degraded;
685
+ log('warn:state_restored_from_backup', { suffix });
686
+ this.#loaded = true;
687
+ return;
688
+ } catch {}
689
+ }
408
690
  try {
409
- const raw = readFileSync(backupPath, 'utf8');
410
- if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
411
- const parsed = JSON.parse(raw);
412
- const validationError = this.#validateState(parsed);
413
- if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
414
- this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
415
- log('warn:state_restored_from_backup');
416
- this.#loaded = true;
417
- return;
691
+ throw new Error('All backups failed');
418
692
  } catch (backupErr) {
419
693
  // No backup either — set error state instead of silent reset
420
694
  this.#state = {
421
- ...DEFAULT_STATE,
695
+ ...structuredClone(DEFAULT_STATE),
422
696
  error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
423
697
  };
698
+ this.#degraded = !!this.#state.degraded;
424
699
  log('warn:state_reset', { error: backupErr.message });
425
700
  }
426
701
  this.#loaded = true;
@@ -442,6 +717,7 @@ class OstackyController {
442
717
  throw err;
443
718
  }
444
719
 
720
+ let didAcquire = false;
445
721
  try {
446
722
  // 3.4: Acquire lock before writing
447
723
  const lockAcquired = await this.#acquireLock();
@@ -449,21 +725,62 @@ class OstackyController {
449
725
  log('warn:persist_skipped_lock', { state: this.#state.state });
450
726
  throw new Error('Could not acquire state file lock');
451
727
  }
728
+ didAcquire = lockAcquired;
452
729
 
453
- let serialized = safeJsonStringify(this.#state, true);
730
+ // 4.2: redact sensitive before serialize (do not mutate original long-term, but ensure file is redacted)
731
+ const stateForSerialize = (() => {
732
+ try {
733
+ const copy = JSON.parse(safeJsonStringify(this.#state));
734
+ const redactRecursively = (obj) => {
735
+ if (!obj || typeof obj !== 'object') return;
736
+ for (const k of Object.keys(obj)) {
737
+ if (SENSITIVE_REDACT_RE.test(k)) {
738
+ obj[k] = '[REDACTED]';
739
+ } else if (typeof obj[k] === 'string' && SENSITIVE_REDACT_RE.test(obj[k])) {
740
+ obj[k] = obj[k]
741
+ .replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
742
+ .replace(/sk-[a-zA-Z0-9_-]+/g, '[REDACTED]');
743
+ if (SENSITIVE_REDACT_RE.test(obj[k])) obj[k] = '[REDACTED]';
744
+ } else if (typeof obj[k] === 'object') {
745
+ redactRecursively(obj[k]);
746
+ }
747
+ }
748
+ };
749
+ redactRecursively(copy);
750
+ if (copy.snapshots) redactRecursively(copy.snapshots);
751
+ if (copy.audit) copy.audit.forEach(redactRecursively);
752
+ return copy;
753
+ } catch {
754
+ return this.#state;
755
+ }
756
+ })();
757
+ let serialized = safeJsonStringify(stateForSerialize, true);
454
758
  if (serialized.length > MAX_STATE_FILE_SIZE) {
455
759
  log('warn:state_oversized', { size: serialized.length });
456
- const trimmed = { ...this.#state, snapshots: { codegraph: null, execution: null } };
760
+ this.#state.stateOversizedCount = (this.#state.stateOversizedCount || 0) + 1;
761
+ const trimmed = { ...stateForSerialize, snapshots: { codegraph: null, execution: null } };
457
762
  serialized = safeJsonStringify(trimmed, true);
458
763
  if (serialized.length > MAX_STATE_FILE_SIZE) {
459
764
  log('error:state_too_large_even_after_trim');
460
765
  return;
461
766
  }
462
767
  this.#state.snapshots = { codegraph: null, execution: null };
768
+ // also reflect in file copy
769
+ stateForSerialize.snapshots = { codegraph: null, execution: null };
770
+ serialized = safeJsonStringify(stateForSerialize, true);
463
771
  }
464
772
  const tmp = this.#statePath + '.tmp.' + process.pid;
465
773
  await writeFileAsync(tmp, serialized, 'utf8');
466
774
  await renameAsync(tmp, this.#statePath);
775
+ // 2.1: backup rotativo 3 niveles best-effort
776
+ try {
777
+ try {
778
+ renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2');
779
+ } catch {}
780
+ try {
781
+ renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1');
782
+ } catch {}
783
+ } catch {}
467
784
  try {
468
785
  const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
469
786
  await writeFileAsync(backupTmp, serialized, 'utf8');
@@ -471,11 +788,12 @@ class OstackyController {
471
788
  } catch {
472
789
  /* backup is best-effort */
473
790
  }
474
- // B1: persist success → reset failure counter
791
+ // B1: persist success → reset failure counter + auto-exit degraded
475
792
  if (this.#consecutiveFailures > 0) {
476
793
  log('info:persist_recovered', { after: this.#consecutiveFailures });
477
794
  }
478
795
  this.#consecutiveFailures = 0;
796
+ if (this.#degraded) this.#exitDegradedMode();
479
797
  } catch (err) {
480
798
  // B1: persist failure → increment counter, auto-degrade if threshold reached
481
799
  this.#consecutiveFailures++;
@@ -487,7 +805,7 @@ class OstackyController {
487
805
  }
488
806
  throw err;
489
807
  } finally {
490
- this.#releaseLock();
808
+ if (didAcquire) this.#releaseLock();
491
809
  }
492
810
  }
493
811
 
@@ -501,17 +819,66 @@ class OstackyController {
501
819
 
502
820
  #trimTasks() {
503
821
  if (!this.#state.tasks) return;
822
+ const limit = getMaxTasks();
504
823
  const entries = Object.entries(this.#state.tasks);
505
- if (entries.length <= MAX_TASKS) return;
506
- // Sort by completedAt (desc), keep newest MAX_TASKS
507
- entries.sort((a, b) => {
824
+ if (entries.length <= limit) return;
825
+ const expectedSet = new Set(Array.isArray(this.#state.expectedTasks) ? this.#state.expectedTasks : []);
826
+ const expectedEntries = entries.filter(([id]) => expectedSet.has(id));
827
+ const nonExpectedEntries = entries.filter(([id]) => !expectedSet.has(id));
828
+ const excess = entries.length - limit;
829
+ if (nonExpectedEntries.length >= excess) {
830
+ nonExpectedEntries.sort((a, b) => {
831
+ const da = a[1].completedAt || '';
832
+ const db = b[1].completedAt || '';
833
+ return db.localeCompare(da);
834
+ });
835
+ const keepNonExpected = nonExpectedEntries.slice(0, nonExpectedEntries.length - excess);
836
+ const kept = [...expectedEntries, ...keepNonExpected];
837
+ kept.sort((a, b) => {
838
+ const da = a[1].completedAt || '';
839
+ const db = b[1].completedAt || '';
840
+ return db.localeCompare(da);
841
+ });
842
+ this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
843
+ log('warn:tasks_trimmed', {
844
+ before: entries.length,
845
+ after: limit,
846
+ preservedExpected: expectedEntries.length,
847
+ });
848
+ return;
849
+ }
850
+ const sortedExpected = [...expectedEntries].sort((a, b) => {
508
851
  const da = a[1].completedAt || '';
509
852
  const db = b[1].completedAt || '';
510
- return db.localeCompare(da);
853
+ return da.localeCompare(db);
511
854
  });
512
- const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
513
- this.#state.tasks = trimmed;
514
- log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
855
+ const needToArchive = excess - nonExpectedEntries.length;
856
+ if (needToArchive > 0) {
857
+ for (let i = 0; i < Math.min(needToArchive, sortedExpected.length); i++) {
858
+ const [taskId] = sortedExpected[i];
859
+ log('info:task_archived_to_engram', {
860
+ taskId,
861
+ topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}`,
862
+ });
863
+ }
864
+ sortedExpected.sort((a, b) => {
865
+ const da = a[1].completedAt || '';
866
+ const db = b[1].completedAt || '';
867
+ return db.localeCompare(da);
868
+ });
869
+ const keepExpectedCount = expectedEntries.length - needToArchive;
870
+ const keepExpected = sortedExpected.slice(0, keepExpectedCount);
871
+ const kept = [...keepExpected, ...nonExpectedEntries];
872
+ kept.sort((a, b) => {
873
+ const da = a[1].completedAt || '';
874
+ const db = b[1].completedAt || '';
875
+ return db.localeCompare(da);
876
+ });
877
+ this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
878
+ log('warn:tasks_trimmed_with_archive', { before: entries.length, after: limit, archived: needToArchive });
879
+ return;
880
+ }
881
+ log('warn:tasks_over_limit_no_trim', { before: entries.length, limit, expected: expectedEntries.length });
515
882
  }
516
883
 
517
884
  async #transition(to, changes = {}) {
@@ -529,13 +896,22 @@ class OstackyController {
529
896
 
530
897
  // --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
531
898
  async #audit(phase, decision, reasoning) {
899
+ let redactedReasoning = reasoning ? String(reasoning).slice(0, 300) : undefined;
900
+ if (redactedReasoning && SENSITIVE_REDACT_RE.test(redactedReasoning)) {
901
+ redactedReasoning = redactedReasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
902
+ // also redact values after = if present
903
+ redactedReasoning = redactedReasoning.replace(
904
+ /(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi,
905
+ '$1=[REDACTED]'
906
+ );
907
+ }
532
908
  const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
533
909
  this.#auditBuffer.push({
534
910
  id,
535
911
  ts: Date.now(),
536
912
  phase,
537
913
  decision,
538
- reasoning: reasoning ? String(reasoning).slice(0, 300) : undefined,
914
+ reasoning: redactedReasoning,
539
915
  });
540
916
  const isWarn = phase === 'WARN';
541
917
  if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
@@ -549,10 +925,19 @@ class OstackyController {
549
925
  for (const e of this.#auditBuffer) {
550
926
  if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
551
927
  if (e.reasoning && e.reasoning.length > 300) e.reasoning = e.reasoning.slice(0, 300);
928
+ // 4.2: redact sensitive in audit
929
+ if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning)) {
930
+ e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
931
+ }
932
+ // redact any lingering snapshot data in reasoning
933
+ if (e.reasoning && /(apiKey|secret|token|password)/i.test(e.reasoning)) {
934
+ e.reasoning = e.reasoning.replace(/(apiKey|secret|token|password)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
935
+ }
552
936
  }
553
937
  this.#state.audit.push(...this.#auditBuffer);
554
- if (this.#state.audit.length > 100) {
555
- this.#state.audit = this.#state.audit.slice(-100);
938
+ const retention = getAuditRetentionSafe();
939
+ if (this.#state.audit.length > retention) {
940
+ this.#state.audit = this.#state.audit.slice(-retention);
556
941
  }
557
942
  this.#auditBuffer = [];
558
943
  // O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
@@ -607,23 +992,49 @@ class OstackyController {
607
992
  // --- 3.3: Degraded mode ---
608
993
  #enterDegradedMode(reason) {
609
994
  this.#degraded = true;
995
+ if (this.#state) this.#state.degraded = true;
610
996
  log('degraded_mode_activated', { reason, state: this.#state?.state });
997
+ if (this.#state && this.#statePath) {
998
+ try {
999
+ this.#persist().catch(() => {});
1000
+ } catch {}
1001
+ }
611
1002
  }
612
1003
 
613
1004
  #exitDegradedMode() {
614
1005
  if (!this.#degraded) return;
615
1006
  this.#degraded = false;
616
1007
  this.#consecutiveFailures = 0;
1008
+ if (this.#state) this.#state.degraded = false;
617
1009
  log('degraded_mode_exited', { state: this.#state?.state });
1010
+ if (this.#state && this.#statePath) {
1011
+ try {
1012
+ this.#persist().catch(() => {});
1013
+ } catch {}
1014
+ }
618
1015
  }
619
1016
 
620
1017
  // --- Core transitions ---
621
1018
 
622
- async startRequest({ requestId, changeId } = {}) {
1019
+ async startRequest({ requestId, changeId, force = false } = {}) {
623
1020
  this.#load();
624
- if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
625
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
1021
+
1022
+ // If not forcing and current state is active (not terminal), resume instead of reset
1023
+ if (!force && !TERMINAL_STATES.includes(this.#state.state) && this.#state.requestId) {
1024
+ await this.#audit(
1025
+ this.#state.state,
1026
+ 'start_request',
1027
+ `resumed from ${this.#state.state}, requestId=${this.#state.requestId}`
1028
+ );
1029
+ return {
1030
+ state: this.#state.state,
1031
+ revision: this.#state.revision,
1032
+ requestId: this.#state.requestId,
1033
+ continued: true,
1034
+ };
626
1035
  }
1036
+
1037
+ // Force reset or terminal state: create new session
627
1038
  await this.#transition('INTERPRETATION_PENDING', {
628
1039
  requestId: requestId || 'req-' + Date.now(),
629
1040
  changeId: changeId || null,
@@ -638,8 +1049,17 @@ class OstackyController {
638
1049
  expectedTaskCount: null,
639
1050
  error: null,
640
1051
  });
641
- await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
642
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
1052
+ await this.#audit(
1053
+ 'INTERPRETATION_PENDING',
1054
+ 'start_request',
1055
+ `requestId=${this.#state.requestId}${force ? ' (forced)' : ''}`
1056
+ );
1057
+ return {
1058
+ state: this.#state.state,
1059
+ revision: this.#state.revision,
1060
+ requestId: this.#state.requestId,
1061
+ continued: false,
1062
+ };
643
1063
  }
644
1064
 
645
1065
  async requestClarification({ question } = {}) {
@@ -690,6 +1110,10 @@ class OstackyController {
690
1110
 
691
1111
  async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
692
1112
  this.#load();
1113
+ // 4.4: validación de enums
1114
+ if (level && !['0', '0+1', '1+'].includes(level)) {
1115
+ return { error: `invalid level: ${level}`, available: ['0', '0+1', '1+'] };
1116
+ }
693
1117
  const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
694
1118
  if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
695
1119
 
@@ -711,18 +1135,68 @@ class OstackyController {
711
1135
  }
712
1136
 
713
1137
  const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
1138
+ // 8.1/8.2: lastProposal handling — reasoning con plan exigido
1139
+ let shownToUser = false;
1140
+ let proposalFiles = [];
1141
+ let estLines = 0;
1142
+ if (snapshot?.reasoning && typeof snapshot.reasoning === 'object') {
1143
+ if (Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
1144
+ shownToUser = true;
1145
+ proposalFiles = snapshot.reasoning.files;
1146
+ estLines = snapshot.reasoning.estLines;
1147
+ }
1148
+ } else if (snapshot?.files && snapshot?.estLines) {
1149
+ shownToUser = true;
1150
+ proposalFiles = snapshot.files;
1151
+ estLines = snapshot.estLines;
1152
+ }
1153
+ const lastProposal = {
1154
+ ts: Date.now(),
1155
+ requestId: this.#state.requestId,
1156
+ summary: `recordDiscovery level=${level} files=${proposalFiles.join(',')} estLines=${estLines}`,
1157
+ files: proposalFiles,
1158
+ estLines,
1159
+ level,
1160
+ routeChoice: defaultChoice,
1161
+ shownToUser,
1162
+ };
714
1163
  await this.#transition(to, {
715
1164
  routeDecisionId: routeDecisionId || 'route-' + Date.now(),
716
1165
  routeChoice: defaultChoice, // O2: persist default suggested choice
717
1166
  level, // O1: persist level for conditional persistence
718
1167
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
1168
+ lastProposal,
719
1169
  });
720
1170
  await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
721
- // C2: warning if no evidence and not degraded and not trivial
1171
+ // 8.2: reasoning sin plan WARN
1172
+ if (!shownToUser && !isTrivial) {
1173
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1174
+ log('warn:proposal_without_transparent_plan', { level, auditId });
1175
+ await this.#audit(
1176
+ 'WARN',
1177
+ 'proposal_without_transparent_plan',
1178
+ `level=${level} reasoning missing files/estLines`
1179
+ );
1180
+ this.#state.lastProposal.shownToUser = false;
1181
+ await this.#persist();
1182
+ const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
1183
+ return {
1184
+ state: this.#state.state,
1185
+ revision: this.#state.revision,
1186
+ level,
1187
+ routeDecisionId: this.#state.routeDecisionId,
1188
+ defaultChoice,
1189
+ warning: 'proposal without transparent plan',
1190
+ auditId: lastAudit?.id || auditId,
1191
+ };
1192
+ }
1193
+ // C2: warning if no evidence and not degraded and not trivial — also count bypass
722
1194
  if (!hasEvidence && !this.#degraded && !isTrivial) {
1195
+ this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
723
1196
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
724
1197
  log('warn:discovery_without_codegraph', { level, auditId });
725
1198
  await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
1199
+ await this.#persist();
726
1200
  // auditId is the last pushed id
727
1201
  const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
728
1202
  return {
@@ -735,6 +1209,23 @@ class OstackyController {
735
1209
  auditId: lastAudit?.id || auditId,
736
1210
  };
737
1211
  }
1212
+ // 8.6: Bypass solo para CI
1213
+ if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'ROUTE_DECISION_PENDING') {
1214
+ await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume ${defaultChoice} for CI`);
1215
+ const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', defaultChoice);
1216
+ if (autoTo) {
1217
+ await this.#transition(autoTo, { routeChoice: defaultChoice });
1218
+ await this.#audit(autoTo, 'consume_route_decision', `choice=${defaultChoice} auto-confirm (CI)`);
1219
+ return {
1220
+ state: this.#state.state,
1221
+ revision: this.#state.revision,
1222
+ level,
1223
+ routeDecisionId: this.#state.routeDecisionId,
1224
+ defaultChoice,
1225
+ autoConfirmed: true,
1226
+ };
1227
+ }
1228
+ }
738
1229
  return {
739
1230
  state: this.#state.state,
740
1231
  revision: this.#state.revision,
@@ -764,6 +1255,9 @@ class OstackyController {
764
1255
 
765
1256
  async consumeRouteDecision({ decisionId, choice } = {}) {
766
1257
  this.#load();
1258
+ if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
1259
+ return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
1260
+ }
767
1261
  if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
768
1262
  return this.#makeError(
769
1263
  `Cannot consume route decision from state ${this.#state.state}`,
@@ -807,24 +1301,92 @@ class OstackyController {
807
1301
  if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
808
1302
  return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
809
1303
  }
1304
+ // 1.7: exigir expectedTaskIds/taskIds/taskCount cuando taskCount>0
1305
+ if (snapshot && typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0) {
1306
+ const hasExpectedIds = Array.isArray(snapshot.expectedTaskIds) && snapshot.expectedTaskIds.length > 0;
1307
+ const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
1308
+ const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
1309
+ if (!hasExpectedIds && !hasTaskIds && !hasCount) {
1310
+ return this.#makeError(
1311
+ 'Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0',
1312
+ 'record_execution_analysis'
1313
+ );
1314
+ }
1315
+ if (!hasExpectedIds && !hasTaskIds) {
1316
+ return this.#makeError(
1317
+ 'Snapshot missing expectedTaskIds or taskIds when taskCount>0',
1318
+ 'record_execution_analysis'
1319
+ );
1320
+ }
1321
+ }
810
1322
  // C2: capture expected tasks for gate
811
1323
  const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
812
1324
  const expectedTaskCount = snapshot?.taskCount ?? (Array.isArray(expectedTasks) ? expectedTasks.length : null);
1325
+ const isEarlyExitExec = snapshot?.globalRuleTriggered === 'early-exit' && (snapshot?.taskCount ?? 0) <= 2;
1326
+ // 8.1/8.2: lastProposal for execution — reasoning con plan
1327
+ let execShown = false;
1328
+ let execFiles = [];
1329
+ let execEst = 0;
1330
+ if (
1331
+ snapshot?.reasoning &&
1332
+ typeof snapshot.reasoning === 'object' &&
1333
+ Array.isArray(snapshot.reasoning.files) &&
1334
+ typeof snapshot.reasoning.estLines === 'number'
1335
+ ) {
1336
+ execShown = true;
1337
+ execFiles = snapshot.reasoning.files;
1338
+ execEst = snapshot.reasoning.estLines;
1339
+ } else if (snapshot?.files && snapshot?.estLines) {
1340
+ execShown = true;
1341
+ execFiles = snapshot.files;
1342
+ execEst = snapshot.estLines;
1343
+ } else if (snapshot?.sharedFiles && snapshot?.clusters) {
1344
+ // execution-mode-evaluation style: sharedFiles + clusters
1345
+ execShown = true;
1346
+ execFiles = snapshot.sharedFiles;
1347
+ execEst = snapshot.taskCount || 0;
1348
+ }
1349
+ const execLastProposal = {
1350
+ ts: Date.now(),
1351
+ requestId: this.#state.requestId,
1352
+ summary: `recordExecutionAnalysis files=${execFiles.join(',')} estLines=${execEst}`,
1353
+ files: execFiles,
1354
+ estLines: execEst,
1355
+ level: this.#state.level,
1356
+ routeChoice: this.#state.routeChoice,
1357
+ shownToUser: execShown,
1358
+ };
813
1359
  await this.#transition(to, {
814
1360
  executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
815
1361
  executionMode: null,
816
- snapshots: { ...this.#state.snapshots, execution: snapshot || null },
817
- expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
1362
+ snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
1363
+ expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
818
1364
  expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
1365
+ lastProposal: execLastProposal,
819
1366
  });
820
1367
  await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
821
- // C2: warning if missing codegraphUsed+recommendation and not degraded
1368
+ // 8.2: reasoning sin plan WARN (but allow early-exit style)
1369
+ if (!execShown && snapshot && !isEarlyExitExec) {
1370
+ // Only warn if snapshot was expected to have reasoning (taskCount>2 or not early-exit)
1371
+ const auditId2 = `aud-${Date.now()}-${this.#state.auditSeq}`;
1372
+ log('warn:proposal_without_transparent_plan', { auditId: auditId2 });
1373
+ await this.#audit(
1374
+ 'WARN',
1375
+ 'proposal_without_transparent_plan',
1376
+ 'execution reasoning missing files/estLines'
1377
+ );
1378
+ this.#state.lastProposal.shownToUser = false;
1379
+ await this.#persist();
1380
+ }
1381
+ // C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
1382
+ // 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
822
1383
  const hasEvidence =
823
1384
  snapshot &&
824
1385
  Array.isArray(snapshot.codegraphUsed) &&
825
1386
  snapshot.codegraphUsed.length > 0 &&
826
1387
  snapshot.recommendation != null;
827
- if (snapshot && !hasEvidence && !this.#degraded) {
1388
+ if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
1389
+ this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
828
1390
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
829
1391
  log('warn:execution_without_codegraph', { auditId });
830
1392
  await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
@@ -837,6 +1399,29 @@ class OstackyController {
837
1399
  auditId: lastAudit?.id || auditId,
838
1400
  };
839
1401
  }
1402
+ // 8.6: Bypass solo para CI
1403
+ if (
1404
+ process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' &&
1405
+ this.#state.state === 'EXECUTION_DECISION_PENDING'
1406
+ ) {
1407
+ await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume for CI`);
1408
+ const defaultMode =
1409
+ snapshot?.recommendation && ['INLINE', 'SUBAGENT_DRIVEN'].includes(snapshot.recommendation)
1410
+ ? snapshot.recommendation
1411
+ : 'INLINE';
1412
+ const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', defaultMode);
1413
+ if (autoTo) {
1414
+ await this.#transition(autoTo, { executionMode: defaultMode });
1415
+ await this.#audit(autoTo, 'consume_execution_decision', `mode=${defaultMode} auto-confirm (CI)`);
1416
+ return {
1417
+ state: this.#state.state,
1418
+ revision: this.#state.revision,
1419
+ executionDecisionId: this.#state.executionDecisionId,
1420
+ executionMode: defaultMode,
1421
+ autoConfirmed: true,
1422
+ };
1423
+ }
1424
+ }
840
1425
  return {
841
1426
  state: this.#state.state,
842
1427
  revision: this.#state.revision,
@@ -846,6 +1431,9 @@ class OstackyController {
846
1431
 
847
1432
  async consumeExecutionDecision({ decisionId, mode } = {}) {
848
1433
  this.#load();
1434
+ if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
1435
+ return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
1436
+ }
849
1437
  if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
850
1438
  return this.#makeError(
851
1439
  `Cannot consume execution decision from state ${this.#state.state}`,
@@ -883,15 +1471,17 @@ class OstackyController {
883
1471
  }
884
1472
  // T3: also block on stale fingerprints-vs-disk
885
1473
  let staleFiles = [];
1474
+ const seenFp2 = new Set();
886
1475
  try {
887
1476
  for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
888
1477
  if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
889
1478
  const current = fastFingerprint(info.filePath);
890
1479
  if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
891
1480
  else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1481
+ seenFp2.add(info.filePath);
892
1482
  }
893
1483
  for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
894
- if (staleFiles.some((s) => s.includes(fp))) continue;
1484
+ if (seenFp2.has(fp)) continue;
895
1485
  const cur = fastFingerprint(fp);
896
1486
  if (!cur) staleFiles.push(`${fp} (missing)`);
897
1487
  else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
@@ -910,6 +1500,19 @@ class OstackyController {
910
1500
  };
911
1501
  }
912
1502
  if (hasBlocking && force) {
1503
+ // 4.3: force requiere confirmación humana en últimas 5 entradas de audit
1504
+ const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
1505
+ const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
1506
+ if (!hasHuman) {
1507
+ return {
1508
+ error: 'force requires human confirmation',
1509
+ pending,
1510
+ staleFiles: staleFiles.length ? staleFiles : undefined,
1511
+ current_state: this.#state.state,
1512
+ attempted_transition: 'implementation_complete',
1513
+ suggestion: 'User must write forzar/confirmo/force in a prior block/replan/set_handoff reasoning',
1514
+ };
1515
+ }
913
1516
  const all = [...pending, ...staleFiles].join(',');
914
1517
  await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
915
1518
  }
@@ -936,15 +1539,40 @@ class OstackyController {
936
1539
 
937
1540
  async block({ reason } = {}) {
938
1541
  this.#load();
1542
+ const from = this.#state.state;
939
1543
  const to = this.#isAllowedTransition(this.#state.state, 'block');
940
1544
  if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
1545
+ // 1.9: block desde EXECUTING_* preserva tasks/fileFingerprints/expectedTasks y audita WARN
1546
+ const isExecuting = from === 'EXECUTING_INLINE' || from === 'EXECUTING_SUBAGENTS';
941
1547
  await this.#transition(to, { error: reason || 'Blocked' });
942
1548
  await this.#audit('BLOCKED', 'block', reason || 'no reason');
1549
+ if (isExecuting) {
1550
+ await this.#audit(
1551
+ 'WARN',
1552
+ 'block_from_executing',
1553
+ `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`
1554
+ );
1555
+ }
1556
+ // 10.6: increment subagentFailedCount if block reason indicates subagent failure
1557
+ if (reason && /subagent.*failed/i.test(reason)) {
1558
+ this.#state.subagentFailedCount = (this.#state.subagentFailedCount || 0) + 1;
1559
+ await this.#audit('WARN', 'subagent_failed', reason);
1560
+ try {
1561
+ await this.#persist();
1562
+ } catch {}
1563
+ }
943
1564
  return { state: this.#state.state, revision: this.#state.revision };
944
1565
  }
945
1566
 
946
1567
  async replan({ reason } = {}) {
947
1568
  this.#load();
1569
+ // 1.9: replan desde EXECUTING_* rechazado sin limpiar tasks
1570
+ if (this.#state.state === 'EXECUTING_INLINE' || this.#state.state === 'EXECUTING_SUBAGENTS') {
1571
+ return this.#makeError(
1572
+ `Cannot replan from state ${this.#state.state} — replan only from BLOCKED`,
1573
+ 'replan'
1574
+ );
1575
+ }
948
1576
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
949
1577
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
950
1578
  await this.#transition(to, {
@@ -998,15 +1626,17 @@ class OstackyController {
998
1626
  }
999
1627
  // T3: fingerprints-vs-disk — detect stale/missing files after complete_task
1000
1628
  let staleFiles = [];
1629
+ const seenFp = new Set();
1001
1630
  try {
1002
1631
  for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
1003
1632
  if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
1004
1633
  const current = fastFingerprint(info.filePath);
1005
1634
  if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
1006
1635
  else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1636
+ seenFp.add(info.filePath);
1007
1637
  }
1008
1638
  for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
1009
- if (staleFiles.some((s) => s.includes(fp))) continue;
1639
+ if (seenFp.has(fp)) continue;
1010
1640
  const current = fastFingerprint(fp);
1011
1641
  if (!current) staleFiles.push(`${fp} (missing)`);
1012
1642
  else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
@@ -1023,9 +1653,11 @@ class OstackyController {
1023
1653
  };
1024
1654
  }
1025
1655
 
1026
- async getAudit({ limit = 20, offset = 0 } = {}) {
1656
+ async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
1027
1657
  this.#load();
1028
- const all = this.#state.audit || [];
1658
+ let all = this.#state.audit || [];
1659
+ if (phase) all = all.filter((e) => e.phase === phase);
1660
+ if (since) all = all.filter((e) => e.ts >= since);
1029
1661
  const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
1030
1662
  return slice.map((e) => ({
1031
1663
  id: e.id,
@@ -1036,6 +1668,189 @@ class OstackyController {
1036
1668
  }));
1037
1669
  }
1038
1670
 
1671
+ async getMetrics() {
1672
+ this.#load();
1673
+ let stateFileSize = 0;
1674
+ let auditSize = 0;
1675
+ let diskFreeMB = null;
1676
+ try {
1677
+ const stat = statSync(this.#statePath);
1678
+ stateFileSize = stat.size;
1679
+ } catch {}
1680
+ try {
1681
+ auditSize = (this.#state.audit || []).length;
1682
+ } catch {}
1683
+ try {
1684
+ // diskFree via statfs if available, fallback to null
1685
+ const { statfsSync } = await import('node:fs');
1686
+ if (typeof statfsSync === 'function' && this.#statePath) {
1687
+ try {
1688
+ const s = statfsSync(dirname(this.#statePath));
1689
+ diskFreeMB = Math.floor((s.bfree * s.bsize) / (1024 * 1024));
1690
+ } catch {}
1691
+ }
1692
+ } catch {}
1693
+ const completed = Object.values(this.#state.tasks || {}).filter((t) => t.status === 'COMPLETED').length;
1694
+ const total = Object.keys(this.#state.tasks || {}).length;
1695
+ const pending = Array.isArray(this.#state.expectedTasks)
1696
+ ? this.#state.expectedTasks.filter(
1697
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1698
+ ).length
1699
+ : typeof this.#state.expectedTaskCount === 'number'
1700
+ ? Math.max(0, this.#state.expectedTaskCount - completed)
1701
+ : 0;
1702
+ return {
1703
+ revision: this.#state.revision,
1704
+ state: this.#state.state,
1705
+ degraded: this.#degraded || !!this.#state.degraded,
1706
+ consecutiveFailures: this.#consecutiveFailures,
1707
+ taskCounts: {
1708
+ completed,
1709
+ pending,
1710
+ total,
1711
+ expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
1712
+ },
1713
+ expectedTaskCount: this.#state.expectedTaskCount,
1714
+ auditSize,
1715
+ stateFileSize,
1716
+ diskFreeMB,
1717
+ uptimeMs: Date.now() - (this.#state.ts || Date.now()),
1718
+ stateOversizedCount: this.#state.stateOversizedCount || 0,
1719
+ codegraphBypassCount: this.#state.codegraphBypassCount || 0,
1720
+ degradedEditsCount: this.#state.degradedEditsCount || 0,
1721
+ cacheHitCount: this.#state.cacheHitCount || 0,
1722
+ cacheMissCount: this.#state.cacheMissCount || 0,
1723
+ tokenSavingEstimate: this.#state.tokenSavingEstimate || 0,
1724
+ sensitiveAccess: this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 },
1725
+ subagentFailedCount: this.#state.subagentFailedCount || 0,
1726
+ staleContentAttempts: this.#state.staleContentAttempts || 0,
1727
+ completeWithoutValidateCount: this.#state.completeWithoutValidateCount || 0,
1728
+ toolTimeoutCount: this.#state.toolTimeoutCount || 0,
1729
+ lastToolDurationMs: this.#state.lastToolDurationMs || 0,
1730
+ stateDurationMs: this.#state.stateDurationMs || 0,
1731
+ };
1732
+ }
1733
+
1734
+ async _recordToolTimeout() {
1735
+ this.#load();
1736
+ this.#state.toolTimeoutCount = (this.#state.toolTimeoutCount || 0) + 1;
1737
+ this.#state.lastToolDurationMs = 5000;
1738
+ try {
1739
+ await this.#persist();
1740
+ } catch {}
1741
+ }
1742
+
1743
+ async _recordToolDuration(ms) {
1744
+ this.#load();
1745
+ this.#state.lastToolDurationMs = ms;
1746
+ this.#state.stateDurationMs = Date.now() - (this.#state.ts || Date.now());
1747
+ try {
1748
+ await this.#persist();
1749
+ } catch {}
1750
+ }
1751
+
1752
+ // --- 5.4 hardening-v2: cache metrics (token efficiency) ---
1753
+ async recordCacheHit({ tokensSaved = 500 } = {}) {
1754
+ this.#load();
1755
+ this.#state.cacheHitCount = (this.#state.cacheHitCount || 0) + 1;
1756
+ const saved = typeof tokensSaved === 'number' && tokensSaved > 0 ? tokensSaved : 500;
1757
+ this.#state.tokenSavingEstimate = (this.#state.tokenSavingEstimate || 0) + saved;
1758
+ try {
1759
+ await this.#persist();
1760
+ } catch {}
1761
+ return {
1762
+ ok: true,
1763
+ cacheHitCount: this.#state.cacheHitCount,
1764
+ tokenSavingEstimate: this.#state.tokenSavingEstimate,
1765
+ };
1766
+ }
1767
+
1768
+ async recordCacheMiss() {
1769
+ this.#load();
1770
+ this.#state.cacheMissCount = (this.#state.cacheMissCount || 0) + 1;
1771
+ try {
1772
+ await this.#persist();
1773
+ } catch {}
1774
+ return { ok: true, cacheMissCount: this.#state.cacheMissCount };
1775
+ }
1776
+
1777
+ async recordUserConfirmation({ decisionId, confirmationText } = {}) {
1778
+ this.#load();
1779
+ if (!decisionId || typeof confirmationText !== 'string') {
1780
+ return { error: 'decisionId and confirmationText required' };
1781
+ }
1782
+ await this.#audit(
1783
+ 'CONFIRMATION',
1784
+ 'record_user_confirmation',
1785
+ `user confirmed: ${confirmationText} for ${decisionId}`
1786
+ );
1787
+ await this.#flushAudit(true);
1788
+ await this.#persist();
1789
+ return { ok: true, decisionId, confirmationText, ts: Date.now() };
1790
+ }
1791
+
1792
+ // --- D11: Credential guard helpers — source-of-truth is src/security.ts (hardening-v2 D1) ---
1793
+ isSensitiveFile(filePath) {
1794
+ if (!filePath) return false;
1795
+ this.#load();
1796
+ const patterns =
1797
+ (this.#state && this.#state.sensitivePatterns) || DEFAULT_STATE.sensitivePatterns || SENSITIVE_DEFAULT;
1798
+ return isSensitive(filePath, patterns);
1799
+ }
1800
+
1801
+ async checkFileAccess({ filePath, reason } = {}) {
1802
+ this.#load();
1803
+ if (!filePath) return { error: 'filePath required' };
1804
+ if (!this.isSensitiveFile(filePath)) return { allowed: true, reason: 'not sensitive' };
1805
+ if (this.#state.allowedFiles?.[filePath]) return { allowed: true, reason: 'previously allowed' };
1806
+ if (this.#state.deniedFiles?.[filePath]) {
1807
+ return {
1808
+ error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`,
1809
+ denied: true,
1810
+ filePath,
1811
+ };
1812
+ }
1813
+ const decisionId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1814
+ if (!this.#state.pendingFileAccess) this.#state.pendingFileAccess = {};
1815
+ this.#state.pendingFileAccess[decisionId] = { filePath, reason, ts: Date.now() };
1816
+ await this.#audit('SECURITY', 'check_file_access', `check ${filePath} reason=${reason || 'none'}`);
1817
+ this.#state.sensitiveAccess = this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 };
1818
+ this.#state.sensitiveAccess.blockedAttempts = (this.#state.sensitiveAccess.blockedAttempts || 0) + 1;
1819
+ await this.#persist();
1820
+ return { status: 'BLOCKED', decisionId, filePath, reason: `File ${filePath} requires check_file_access` };
1821
+ }
1822
+
1823
+ async consumeFileAccessDecision({ decisionId, choice } = {}) {
1824
+ this.#load();
1825
+ if (!decisionId || !choice) return { error: 'decisionId and choice required' };
1826
+ if (!['ALLOW', 'DENY'].includes(choice))
1827
+ return { error: 'choice must be ALLOW or DENY', available: ['ALLOW', 'DENY'] };
1828
+ const pending = this.#state.pendingFileAccess?.[decisionId];
1829
+ let filePath = pending?.filePath;
1830
+ // fallback: if no pending, try to find by decisionId prefix? require filePath param alternative
1831
+ if (!filePath && decisionId.startsWith('file-')) {
1832
+ // try to extract from audit? For now return error if not found
1833
+ return { error: 'decisionId not found', decisionId };
1834
+ }
1835
+ if (!this.#state.allowedFiles) this.#state.allowedFiles = {};
1836
+ if (!this.#state.deniedFiles) this.#state.deniedFiles = {};
1837
+ if (!this.#state.sensitiveAccess) this.#state.sensitiveAccess = { allowed: 0, denied: 0, blockedAttempts: 0 };
1838
+ if (choice === 'ALLOW') {
1839
+ this.#state.allowedFiles[filePath] = true;
1840
+ delete this.#state.deniedFiles[filePath];
1841
+ this.#state.sensitiveAccess.allowed = (this.#state.sensitiveAccess.allowed || 0) + 1;
1842
+ await this.#audit('SECURITY', 'consume_file_access_decision', `ALLOW ${filePath}`);
1843
+ } else {
1844
+ this.#state.deniedFiles[filePath] = true;
1845
+ delete this.#state.allowedFiles[filePath];
1846
+ this.#state.sensitiveAccess.denied = (this.#state.sensitiveAccess.denied || 0) + 1;
1847
+ await this.#audit('WARN', 'consume_file_access_decision', `DENY ${filePath}`);
1848
+ }
1849
+ if (this.#state.pendingFileAccess) delete this.#state.pendingFileAccess[decisionId];
1850
+ await this.#persist();
1851
+ return { ok: true, decisionId, choice, filePath };
1852
+ }
1853
+
1039
1854
  // --- B2: Handoff persistence for cross-session continuity ---
1040
1855
  async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
1041
1856
  this.#load();
@@ -1100,12 +1915,70 @@ class OstackyController {
1100
1915
  };
1101
1916
  }
1102
1917
 
1103
- // --- O6: Validate edit with fast fingerprint ---
1104
- async validateEdit({ oldString, newString, content, taskId } = {}) {
1918
+ // --- O6: Validate edit with fast fingerprint + D6/D4 hard gate + 10.4 freshness ---
1919
+ async validateEdit({ oldString, newString, content, taskId, filePath } = {}) {
1105
1920
  this.#load();
1106
1921
  if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
1107
1922
  return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
1108
1923
  }
1924
+ if (taskId && !isValidTaskId(taskId)) {
1925
+ return { outcome: 'CONFLICT', reason: `invalid taskId: ${taskId}` };
1926
+ }
1927
+ if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
1928
+ return { outcome: 'CONFLICT', reason: `filePath outside projectRoot: ${filePath}` };
1929
+ }
1930
+ // 9.2: guard sensible en validate_edit
1931
+ if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
1932
+ return { outcome: 'CONFLICT', reason: `BLOCKED: File ${filePath} requires check_file_access` };
1933
+ }
1934
+ // 8.5: contar edits en degraded sin confirmación auditada
1935
+ if (this.#degraded) {
1936
+ this.#state.degradedEditsCount = (this.#state.degradedEditsCount || 0) + 1;
1937
+ try {
1938
+ await this.#persist();
1939
+ } catch {}
1940
+ }
1941
+ // 10.4: validación de frescura — content debe coincidir con disco si filePath dado
1942
+ if (filePath && typeof content === 'string') {
1943
+ try {
1944
+ const projectRoot = getProjectRoot(this.#statePath);
1945
+ const absolutePath =
1946
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1947
+ ? resolve(filePath)
1948
+ : resolve(projectRoot, filePath);
1949
+ const diskContent = readFileSync(absolutePath, 'utf8');
1950
+ if (diskContent !== content) {
1951
+ this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
1952
+ await this.#persist();
1953
+ return { outcome: 'CONFLICT', reason: 'content stale, re-read file', filePath };
1954
+ }
1955
+ } catch (e) {
1956
+ if (e.code && e.code !== 'ENOENT') {
1957
+ // ignore ENOENT (new file), but other errors considered stale
1958
+ }
1959
+ }
1960
+ }
1961
+ // 5.3: optimization — si fastFingerprint no cambió, no re-enviar content completo
1962
+ if (
1963
+ (typeof content !== 'string' || content.length === 0) &&
1964
+ filePath &&
1965
+ this.#state.lastValidated?.filePath === filePath
1966
+ ) {
1967
+ try {
1968
+ const projectRoot = getProjectRoot(this.#statePath);
1969
+ const absolutePath =
1970
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1971
+ ? resolve(filePath)
1972
+ : resolve(projectRoot, filePath);
1973
+ const currentHash = fastFingerprint(absolutePath);
1974
+ if (currentHash && currentHash === this.#state.lastValidated.hash) {
1975
+ try {
1976
+ const diskContent = readFileSync(absolutePath, 'utf8');
1977
+ content = diskContent;
1978
+ } catch {}
1979
+ }
1980
+ } catch {}
1981
+ }
1109
1982
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1110
1983
  return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
1111
1984
  }
@@ -1141,6 +2014,18 @@ class OstackyController {
1141
2014
  };
1142
2015
  }
1143
2016
  // oldString found exactly once → safe to replace
2017
+ // 10.5: ligadura validate → complete
2018
+ try {
2019
+ const projectRoot = getProjectRoot(this.#statePath);
2020
+ const absolutePath = filePath
2021
+ ? filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2022
+ ? resolve(filePath)
2023
+ : resolve(projectRoot, filePath)
2024
+ : null;
2025
+ const hash = absolutePath ? fastFingerprint(absolutePath) : null;
2026
+ this.#state.lastValidated = { filePath: filePath || null, hash, ts: Date.now() };
2027
+ await this.#persist();
2028
+ } catch {}
1144
2029
  return { outcome: 'EDITABLE', taskId };
1145
2030
  }
1146
2031
 
@@ -1154,10 +2039,36 @@ class OstackyController {
1154
2039
  return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
1155
2040
  }
1156
2041
  if (!taskId) return { error: 'taskId is required' };
2042
+ if (!isValidTaskId(taskId)) return { error: 'invalid taskId: must match /^[a-zA-Z0-9-_.\/:]+$/', taskId };
2043
+ if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
2044
+ return { error: 'filePath outside projectRoot', filePath };
2045
+ }
2046
+ // 9.2: guard de credenciales — rechazar sensibles sin ALLOW
2047
+ if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
2048
+ return { error: `BLOCKED: File ${filePath} requires check_file_access`, filePath };
2049
+ }
1157
2050
  if (!this.#state.tasks) this.#state.tasks = {};
1158
2051
 
1159
2052
  // O6: Use fast fingerprint if no hash provided
1160
2053
  const effectiveHash = fileHash || (filePath ? fastFingerprint(filePath) : null);
2054
+ // 1.6: fingerprint obligatorio si archivo existe
2055
+ if (filePath) {
2056
+ const existsCheck = fastFingerprint(filePath);
2057
+ if (existsCheck && !effectiveHash) {
2058
+ return { error: 'fingerprint required: file exists but fileHash is null' };
2059
+ }
2060
+ }
2061
+ // 10.5: ligadura validate → complete — WARN si no hubo validate previo
2062
+ if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
2063
+ this.#state.completeWithoutValidateCount = (this.#state.completeWithoutValidateCount || 0) + 1;
2064
+ await this.#audit(
2065
+ 'WARN',
2066
+ 'complete_without_validate',
2067
+ `complete_task without prior validate_edit for ${filePath || taskId}`
2068
+ );
2069
+ } else {
2070
+ this.#state.lastValidated = null;
2071
+ }
1161
2072
 
1162
2073
  this.#state.tasks[taskId] = {
1163
2074
  status: 'COMPLETED',
@@ -1174,18 +2085,37 @@ class OstackyController {
1174
2085
  (k) => this.#state.tasks[k].status === 'COMPLETED'
1175
2086
  ).length;
1176
2087
  // C2: checkpoint count-based cada 3er complete_task — mismo persist, sin escritura extra
2088
+ // 1.8: preservación de handoff manual reciente (<60s) con pendingTasks distintos
1177
2089
  if (totalCompleted % 3 === 0) {
1178
2090
  const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
1179
2091
  ? this.#state.expectedTasks.filter(
1180
2092
  (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1181
2093
  )
1182
2094
  : [];
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
- };
2095
+ const existing = this.#state.lastHandoff;
2096
+ const isRecentManual =
2097
+ existing &&
2098
+ Date.now() - existing.ts < 60000 &&
2099
+ existing.summary &&
2100
+ !existing.summary.startsWith('Checkpoint auto');
2101
+ let shouldOverwrite = true;
2102
+ if (isRecentManual) {
2103
+ const existingPending = existing.pendingTasks || [];
2104
+ const isDistinct =
2105
+ pendingForHandoff.length !== existingPending.length ||
2106
+ pendingForHandoff.some((id) => !existingPending.includes(id));
2107
+ if (isDistinct && existingPending.length > 0) {
2108
+ shouldOverwrite = false;
2109
+ }
2110
+ }
2111
+ if (shouldOverwrite) {
2112
+ this.#state.lastHandoff = {
2113
+ ts: Date.now(),
2114
+ summary: `Checkpoint auto: ${totalCompleted} tasks completadas`,
2115
+ nextSteps: pendingForHandoff.length ? [`Continuar con ${pendingForHandoff.join(', ')}`] : [],
2116
+ pendingTasks: pendingForHandoff,
2117
+ };
2118
+ }
1189
2119
  }
1190
2120
  await this.#persist();
1191
2121
  await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
@@ -1207,22 +2137,61 @@ class OstackyController {
1207
2137
  if (!this.#state.audit) this.#state.audit = [];
1208
2138
  for (const e of this.#auditBuffer) {
1209
2139
  if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
2140
+ if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning))
2141
+ e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
1210
2142
  }
1211
2143
  this.#state.audit.push(...this.#auditBuffer);
1212
- if (this.#state.audit.length > 100) this.#state.audit = this.#state.audit.slice(-100);
2144
+ const retention = getAuditRetentionSafe();
2145
+ if (this.#state.audit.length > retention) this.#state.audit = this.#state.audit.slice(-retention);
1213
2146
  this.#auditBuffer = [];
1214
2147
  }
1215
- // T1: final persist path kept synchronous for graceful shutdown
2148
+ // T1: final persist path kept synchronous for graceful shutdown (+ D2 stale-aware 15s)
1216
2149
  if (!this.#statePath || !this.#state || !this.#loaded) return;
1217
2150
  try {
2151
+ // D2: replicate staleWindow logic sync — check timestamp before acquiring
2152
+ try {
2153
+ const tsRaw = readFileSync(this.#lockHeartbeatPath, 'utf8');
2154
+ const age = Date.now() - parseInt(tsRaw, 10);
2155
+ if (!Number.isNaN(age) && age >= 15000) {
2156
+ try {
2157
+ unlinkSync(this.#lockPidPath);
2158
+ } catch {}
2159
+ try {
2160
+ unlinkSync(this.#lockHeartbeatPath);
2161
+ } catch {}
2162
+ this.#lockOwner = false;
2163
+ } else if (!Number.isNaN(age) && age < 15000) {
2164
+ try {
2165
+ const pidRaw = readFileSync(this.#lockPidPath, 'utf8').trim();
2166
+ if (pidRaw !== String(process.pid)) return;
2167
+ } catch {}
2168
+ }
2169
+ } catch {}
1218
2170
  try {
1219
2171
  writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1220
2172
  } catch (e) {
1221
- if (e && e.code === 'EEXIST') return; // another process holds the lock — skip best-effort persist
1222
- throw e;
2173
+ if (e && e.code === 'EEXIST') {
2174
+ try {
2175
+ const tsRaw2 = readFileSync(this.#lockHeartbeatPath, 'utf8');
2176
+ const age2 = Date.now() - parseInt(tsRaw2, 10);
2177
+ if (!Number.isNaN(age2) && age2 >= 15000) {
2178
+ try {
2179
+ unlinkSync(this.#lockPidPath);
2180
+ } catch {}
2181
+ try {
2182
+ unlinkSync(this.#lockHeartbeatPath);
2183
+ } catch {}
2184
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
2185
+ } else return;
2186
+ } catch {
2187
+ return;
2188
+ }
2189
+ } else throw e;
1223
2190
  }
1224
2191
  try {
1225
- writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
2192
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
2193
+ this.#heartbeatLock();
2194
+ this.#lockOwner = true;
1226
2195
  } catch {}
1227
2196
  const serialized = safeJsonStringify(this.#state, true);
1228
2197
  const tmp = this.#statePath + '.tmp.' + process.pid;
@@ -1242,44 +2211,101 @@ const controller = new OstackyController({ statePath });
1242
2211
  * Wraps an async tool handler to ALWAYS return a response (even on error).
1243
2212
  * Without this, an unhandled exception in any tool handler leaves the LLM
1244
2213
  * waiting forever — the root cause of agent freezes.
2214
+ * Supports configurable retry with exponential backoff for transient failures.
2215
+ * @param {Function} fn - The tool handler function
2216
+ * @param {Object} options - Retry options
2217
+ * @param {number} options.maxRetries - Maximum retry attempts (default: 0)
2218
+ * @param {number} options.baseTimeout - Base timeout in ms (default: 5000)
1245
2219
  */
1246
- function safeHandler(fn) {
2220
+ function safeHandler(fn, options = {}) {
2221
+ const { maxRetries = 0, baseTimeout = 5000 } = options;
2222
+
1247
2223
  return async (params) => {
1248
- try {
1249
- const result = await fn(params);
1250
- return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
1251
- } catch (error) {
1252
- log('tool:error', {
1253
- name: fn.name || 'anonymous',
1254
- error: error.message,
1255
- stack: error.stack,
1256
- });
1257
- return {
1258
- content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
1259
- isError: true,
1260
- };
2224
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2225
+ const currentTimeout = baseTimeout * Math.pow(1.5, attempt);
2226
+ const start = Date.now();
2227
+ try {
2228
+ const result = await Promise.race([
2229
+ fn(params),
2230
+ new Promise((_, reject) =>
2231
+ setTimeout(() => reject(new Error(`timeout ${currentTimeout}ms`)), currentTimeout)
2232
+ ),
2233
+ ]);
2234
+ const duration = Date.now() - start;
2235
+ try {
2236
+ await controller._recordToolDuration(duration);
2237
+ } catch {}
2238
+ // Update heartbeat on successful completion
2239
+ controller.updateHeartbeat();
2240
+ return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
2241
+ } catch (error) {
2242
+ const isTimeout = error && error.message && error.message.includes('timeout');
2243
+ const isNetworkError =
2244
+ error && error.code && ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].includes(error.code);
2245
+ const isRetryable = isTimeout || isNetworkError;
2246
+
2247
+ if (isRetryable && attempt < maxRetries) {
2248
+ const backoff = 200 * Math.pow(2, attempt);
2249
+ log('warn:tool_retry', {
2250
+ tool: fn.name || 'anonymous',
2251
+ attempt: attempt + 1,
2252
+ maxRetries,
2253
+ error: error.message,
2254
+ backoff,
2255
+ });
2256
+ await sleep(backoff);
2257
+ continue; // retry
2258
+ }
2259
+
2260
+ if (isTimeout) {
2261
+ log('warn:tool_timeout', { tool: fn.name || 'anonymous', durationMs: currentTimeout });
2262
+ try {
2263
+ await controller._recordToolTimeout();
2264
+ } catch {}
2265
+ return {
2266
+ content: [
2267
+ {
2268
+ type: 'text',
2269
+ text: safeJsonStringify({ error: `timeout ${currentTimeout}ms`, degraded: true }),
2270
+ },
2271
+ ],
2272
+ isError: true,
2273
+ };
2274
+ }
2275
+
2276
+ log('tool:error', {
2277
+ name: fn.name || 'anonymous',
2278
+ error: error.message,
2279
+ stack: error.stack,
2280
+ });
2281
+ return {
2282
+ content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
2283
+ isError: true,
2284
+ };
2285
+ }
1261
2286
  }
1262
2287
  };
1263
2288
  }
1264
2289
 
1265
2290
  const server = new McpServer({
1266
2291
  name: 'ostacky-controller',
1267
- version: '0.7.2',
2292
+ version: '0.7.4',
1268
2293
  });
1269
2294
 
1270
2295
  server.registerTool(
1271
2296
  'start_request',
1272
2297
  {
1273
2298
  description:
1274
- 'Start or reset a new request. Can be called from ANY state resets state machine. Call this first.',
2299
+ 'Start or resume a request. By default, resumes in-progress work (non-terminal states). Use force=true to always reset.',
1275
2300
  inputSchema: z.object({
1276
2301
  requestId: z.string().optional().describe('Unique request ID'),
1277
2302
  changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
2303
+ force: z.boolean().optional().describe('Force reset even if work in progress (default: false)'),
1278
2304
  }),
1279
2305
  },
1280
- safeHandler(async ({ requestId, changeId }) => {
1281
- log('tool:start_request');
1282
- return await controller.startRequest({ requestId, changeId });
2306
+ safeHandler(async ({ requestId, changeId, force }) => {
2307
+ log('tool:start_request', { force: !!force });
2308
+ return await controller.startRequest({ requestId, changeId, force: !!force });
1283
2309
  })
1284
2310
  );
1285
2311
 
@@ -1465,10 +2491,13 @@ server.registerTool(
1465
2491
  'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
1466
2492
  inputSchema: z.object({}),
1467
2493
  },
1468
- safeHandler(async () => {
1469
- log('tool:verify_integrity');
1470
- return await controller.verifyIntegrity();
1471
- })
2494
+ safeHandler(
2495
+ async () => {
2496
+ log('tool:verify_integrity');
2497
+ return await controller.verifyIntegrity();
2498
+ },
2499
+ { maxRetries: 1 }
2500
+ )
1472
2501
  );
1473
2502
 
1474
2503
  server.registerTool(
@@ -1478,11 +2507,107 @@ server.registerTool(
1478
2507
  inputSchema: z.object({
1479
2508
  limit: z.number().optional().describe('Max entries (default 20)'),
1480
2509
  offset: z.number().optional().describe('Offset from end (default 0)'),
2510
+ phase: z.string().optional().describe('Filter by phase (e.g. WARN, LEVEL_RESOLVED)'),
2511
+ since: z.number().optional().describe('Filter by timestamp >= since'),
2512
+ }),
2513
+ },
2514
+ safeHandler(
2515
+ async ({ limit, offset, phase, since }) => {
2516
+ log('tool:get_audit', { limit, offset, phase, since });
2517
+ return await controller.getAudit({ limit, offset, phase, since });
2518
+ },
2519
+ { maxRetries: 1 }
2520
+ )
2521
+ );
2522
+
2523
+ server.registerTool(
2524
+ 'get_metrics',
2525
+ {
2526
+ description:
2527
+ 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
2528
+ inputSchema: z.object({}),
2529
+ },
2530
+ safeHandler(
2531
+ async () => {
2532
+ log('tool:get_metrics');
2533
+ return await controller.getMetrics();
2534
+ },
2535
+ { maxRetries: 1 }
2536
+ )
2537
+ );
2538
+
2539
+ server.registerTool(
2540
+ 'record_cache_hit',
2541
+ {
2542
+ description:
2543
+ 'Record a CodeGraph cache hit — increments cacheHitCount and tokenSavingEstimate. Call after reusing getCachedCodegraph result.',
2544
+ inputSchema: z.object({
2545
+ tokensSaved: z.number().optional().describe('Estimated tokens saved (default 500)'),
2546
+ }),
2547
+ },
2548
+ safeHandler(async ({ tokensSaved }) => {
2549
+ log('tool:record_cache_hit', { tokensSaved });
2550
+ return await controller.recordCacheHit({ tokensSaved });
2551
+ })
2552
+ );
2553
+
2554
+ server.registerTool(
2555
+ 'record_cache_miss',
2556
+ {
2557
+ description:
2558
+ 'Record a CodeGraph cache miss — increments cacheMissCount. Call after getCachedCodegraph returns null.',
2559
+ inputSchema: z.object({}),
2560
+ },
2561
+ safeHandler(async () => {
2562
+ log('tool:record_cache_miss');
2563
+ return await controller.recordCacheMiss();
2564
+ })
2565
+ );
2566
+
2567
+ server.registerTool(
2568
+ 'record_user_confirmation',
2569
+ {
2570
+ description:
2571
+ 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
2572
+ inputSchema: z.object({
2573
+ decisionId: z.string().describe('Decision ID from pending state'),
2574
+ confirmationText: z.string().describe('Literal user confirmation text'),
1481
2575
  }),
1482
2576
  },
1483
- safeHandler(async ({ limit, offset }) => {
1484
- log('tool:get_audit', { limit, offset });
1485
- return await controller.getAudit({ limit, offset });
2577
+ safeHandler(async ({ decisionId, confirmationText }) => {
2578
+ log('tool:record_user_confirmation', { decisionId });
2579
+ return await controller.recordUserConfirmation({ decisionId, confirmationText });
2580
+ })
2581
+ );
2582
+
2583
+ server.registerTool(
2584
+ 'check_file_access',
2585
+ {
2586
+ description:
2587
+ 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
2588
+ inputSchema: z.object({
2589
+ filePath: z.string().describe('File path to check'),
2590
+ reason: z.string().optional().describe('Reason for access'),
2591
+ }),
2592
+ },
2593
+ safeHandler(async ({ filePath, reason }) => {
2594
+ log('tool:check_file_access', { filePath });
2595
+ return await controller.checkFileAccess({ filePath, reason });
2596
+ })
2597
+ );
2598
+
2599
+ server.registerTool(
2600
+ 'consume_file_access_decision',
2601
+ {
2602
+ description: 'Consume file access decision: ALLOW or DENY. Persists allowedFiles/deniedFiles.',
2603
+ inputSchema: z.object({
2604
+ decisionId: z.string().describe('Decision ID from check_file_access'),
2605
+ choice: z.enum(['ALLOW', 'DENY']).describe('Choice'),
2606
+ }),
2607
+ },
2608
+ safeHandler(async ({ decisionId, choice }) => {
2609
+ log('tool:consume_file_access_decision', { decisionId, choice });
2610
+ return await controller.consumeFileAccessDecision({ decisionId, choice });
1486
2611
  })
1487
2612
  );
1488
2613
 
@@ -1520,17 +2645,25 @@ server.registerTool(
1520
2645
  'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
1521
2646
  inputSchema: z.object({}),
1522
2647
  },
1523
- safeHandler(async () => {
1524
- return {
1525
- pong: true,
1526
- degraded: controller.degraded,
1527
- state: await controller.getState().then((s) => ({
1528
- state: s.state,
1529
- revision: s.revision,
1530
- requestId: s.requestId,
1531
- })),
1532
- };
1533
- })
2648
+ safeHandler(
2649
+ async () => {
2650
+ const state = await controller.getState();
2651
+ const metrics = await controller.getMetrics().catch(() => ({}));
2652
+ return {
2653
+ pong: true,
2654
+ degraded: controller.degraded,
2655
+ state: {
2656
+ state: state.state,
2657
+ revision: state.revision,
2658
+ requestId: state.requestId,
2659
+ },
2660
+ diskFreeMB: metrics.diskFreeMB ?? null,
2661
+ stateFileSize: metrics.stateFileSize ?? null,
2662
+ auditSize: metrics.auditSize ?? null,
2663
+ };
2664
+ },
2665
+ { maxRetries: 1 }
2666
+ )
1534
2667
  );
1535
2668
 
1536
2669
  server.registerTool(
@@ -1539,9 +2672,12 @@ server.registerTool(
1539
2672
  description: 'Get the current controller state (reads persistent store).',
1540
2673
  inputSchema: z.object({}),
1541
2674
  },
1542
- safeHandler(async () => {
1543
- return await controller.getState();
1544
- })
2675
+ safeHandler(
2676
+ async () => {
2677
+ return await controller.getState();
2678
+ },
2679
+ { maxRetries: 1 }
2680
+ )
1545
2681
  );
1546
2682
 
1547
2683
  server.registerTool(
@@ -1550,9 +2686,12 @@ server.registerTool(
1550
2686
  description: 'Get current task states.',
1551
2687
  inputSchema: z.object({}),
1552
2688
  },
1553
- safeHandler(async () => {
1554
- return await controller.getTasks();
1555
- })
2689
+ safeHandler(
2690
+ async () => {
2691
+ return await controller.getTasks();
2692
+ },
2693
+ { maxRetries: 1 }
2694
+ )
1556
2695
  );
1557
2696
 
1558
2697
  server.registerTool(
@@ -1561,9 +2700,12 @@ server.registerTool(
1561
2700
  description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
1562
2701
  inputSchema: z.object({}),
1563
2702
  },
1564
- safeHandler(async () => {
1565
- return await controller.getAvailableTransitions();
1566
- })
2703
+ safeHandler(
2704
+ async () => {
2705
+ return await controller.getAvailableTransitions();
2706
+ },
2707
+ { maxRetries: 1 }
2708
+ )
1567
2709
  );
1568
2710
 
1569
2711
  server.registerTool(
@@ -1588,9 +2730,12 @@ server.registerTool(
1588
2730
  description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
1589
2731
  inputSchema: z.object({}),
1590
2732
  },
1591
- safeHandler(async () => {
1592
- return await controller.getHandoff();
1593
- })
2733
+ safeHandler(
2734
+ async () => {
2735
+ return await controller.getHandoff();
2736
+ },
2737
+ { maxRetries: 1 }
2738
+ )
1594
2739
  );
1595
2740
 
1596
2741
  server.registerTool(
@@ -1615,20 +2760,23 @@ server.registerTool(
1615
2760
  'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
1616
2761
  inputSchema: z.object({}),
1617
2762
  },
1618
- safeHandler(async () => {
1619
- const state = await controller.getState();
1620
- const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
1621
- if (pendingStates.includes(state.state)) {
1622
- return {
1623
- status: 'BLOCKED',
1624
- state: state.state,
1625
- revision: state.revision,
1626
- reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
1627
- degraded: controller.degraded,
1628
- };
1629
- }
1630
- return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
1631
- })
2763
+ safeHandler(
2764
+ async () => {
2765
+ const state = await controller.getState();
2766
+ const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
2767
+ if (pendingStates.includes(state.state)) {
2768
+ return {
2769
+ status: 'BLOCKED',
2770
+ state: state.state,
2771
+ revision: state.revision,
2772
+ reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
2773
+ degraded: controller.degraded,
2774
+ };
2775
+ }
2776
+ return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
2777
+ },
2778
+ { maxRetries: 1 }
2779
+ )
1632
2780
  );
1633
2781
 
1634
2782
  server.registerTool(
@@ -1650,14 +2798,16 @@ server.registerTool(
1650
2798
  'Without this parameter, validate_edit will fail.'
1651
2799
  ),
1652
2800
  taskId: z.string().optional().describe('Optional task ID for tracking.'),
2801
+ filePath: z.string().optional().describe('Optional file path for traversal validation.'),
1653
2802
  }),
1654
2803
  },
1655
- safeHandler(async ({ oldString, newString, content, taskId }) => {
2804
+ safeHandler(async ({ oldString, newString, content, taskId, filePath }) => {
1656
2805
  log('tool:validate_edit', {
1657
2806
  taskId,
1658
2807
  oldLen: oldString?.length,
1659
2808
  newLen: newString?.length,
1660
2809
  hasContent: !!content,
2810
+ filePath,
1661
2811
  });
1662
2812
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1663
2813
  return {
@@ -1665,7 +2815,7 @@ server.registerTool(
1665
2815
  reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
1666
2816
  };
1667
2817
  }
1668
- return await controller.validateEdit({ oldString, newString, content, taskId });
2818
+ return await controller.validateEdit({ oldString, newString, content, taskId, filePath });
1669
2819
  })
1670
2820
  );
1671
2821
 
@@ -1721,7 +2871,7 @@ function setupGracefulShutdown(ctrl) {
1721
2871
  }
1722
2872
 
1723
2873
  async function main() {
1724
- log('Starting ostacky-controller MCP v0.7.2...');
2874
+ log('Starting ostacky-controller MCP v0.7.4...');
1725
2875
  log('State path:', { path: statePath });
1726
2876
  // Clean up stale tmp/lock files from previous runs
1727
2877
  cleanupTmpFiles(statePath);