ostacky 0.7.2 → 0.7.3

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,7 +25,7 @@ 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
30
 
31
31
  // T1: non-blocking wait — replaces busy-wait spins that froze the event loop
@@ -33,10 +33,74 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
33
 
34
34
  // --- Constants (Fase 5.5 — headroom generoso) ---
35
35
  const MAX_TASKS = 100;
36
+ const MAX_TASKS_DEFAULT = 100;
37
+ const MAX_TASKS_CAP = 500;
36
38
  const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
37
39
  const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
38
40
  const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
39
41
 
42
+ function getMaxTasks() {
43
+ const raw = process.env.OSTACKY_MAX_TASKS;
44
+ if (raw == null || raw === "") return MAX_TASKS_DEFAULT;
45
+ const n = parseInt(raw, 10);
46
+ if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
47
+ if (n > MAX_TASKS_CAP) {
48
+ log("warn:max_tasks_capped", { requested: n, capped: MAX_TASKS_CAP });
49
+ return MAX_TASKS_CAP;
50
+ }
51
+ return n;
52
+ }
53
+
54
+ function getProjectRoot(statePath) {
55
+ if (!statePath) return resolve(process.cwd());
56
+ return dirname(dirname(resolve(statePath)));
57
+ }
58
+
59
+ function isPathInsideProject(filePath, statePath) {
60
+ if (!filePath) return true;
61
+ try {
62
+ const projectRoot = getProjectRoot(statePath);
63
+ const resolved = resolve(projectRoot, filePath);
64
+ const rel = relative(projectRoot, resolved);
65
+ // reject if rel starts with .. or is absolute outside
66
+ if (rel.startsWith('..' + join('', '')) || rel === '..' || rel.startsWith('..')) return false;
67
+ // also reject absolute paths outside project
68
+ if (resolve(filePath) !== resolved && filePath.startsWith('/')) {
69
+ const absRel = relative(projectRoot, resolve(filePath));
70
+ if (absRel.startsWith('..')) return false;
71
+ }
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function isValidTaskId(taskId) {
79
+ return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
80
+ }
81
+
82
+ function getAuditRetention() {
83
+ const raw = process.env.OSTACKY_AUDIT_RETENTION;
84
+ if (raw == null || raw === '') return 500;
85
+ const n = parseInt(raw, 10);
86
+ if (Number.isNaN(n) || n <= 0) return 500;
87
+ if (n > 2000) return 2000;
88
+ return n;
89
+ }
90
+
91
+ function getAuditRetentionSafe() {
92
+ return getAuditRetention();
93
+ }
94
+
95
+ function redactSecrets(obj) {
96
+ if (!obj || typeof obj !== 'object') return obj;
97
+ const str = safeJsonStringify(obj);
98
+ // redact after stringify for persistence — handled in persist
99
+ return obj;
100
+ }
101
+
102
+ const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
103
+
40
104
  // --- Transition table ---
41
105
  const TRANSITIONS = {
42
106
  INTERPRETATION_PENDING: [
@@ -139,10 +203,46 @@ function safeJsonStringify(obj, pretty = false) {
139
203
  }
140
204
  }
141
205
 
142
- function log(event, data) {
206
+ function redactForLog(data) {
207
+ if (!data || typeof data !== 'object') return data;
208
+ try {
209
+ const str = safeJsonStringify(data);
210
+ // redact sensitive keys
211
+ if (SENSITIVE_REDACT_RE.test(str)) {
212
+ const copy = JSON.parse(str);
213
+ const redactRecursively = (obj) => {
214
+ if (!obj || typeof obj !== 'object') return;
215
+ for (const k of Object.keys(obj)) {
216
+ if (SENSITIVE_REDACT_RE.test(k)) obj[k] = '[REDACTED]';
217
+ else if (typeof obj[k] === 'object') redactRecursively(obj[k]);
218
+ }
219
+ };
220
+ redactRecursively(copy);
221
+ return copy;
222
+ }
223
+ return data;
224
+ } catch { return data; }
225
+ }
226
+
227
+ function log(eventOrLevel, maybeEventOrData, maybeData) {
228
+ let level = 'info';
229
+ let event = eventOrLevel;
230
+ let data = maybeEventOrData;
231
+ if (maybeData !== undefined) {
232
+ level = eventOrLevel;
233
+ event = maybeEventOrData;
234
+ data = maybeData;
235
+ } else {
236
+ // infer level from prefix
237
+ if (event.startsWith('warn:')) { level = 'warn'; }
238
+ else if (event.startsWith('error:')) { level = 'error'; }
239
+ else if (event.startsWith('info:')) { level = 'info'; }
240
+ else if (event.startsWith('degraded_')) { level = 'warn'; }
241
+ }
143
242
  const ts = new Date().toISOString();
144
- const payload = data ? ` ${safeJsonStringify(data)}` : '';
145
- console.error(`[${ts}] ${event}${payload}`);
243
+ const safeData = redactForLog(data);
244
+ const payload = safeData ? ` ${safeJsonStringify(safeData)}` : '';
245
+ console.error(`[${ts}] ${level}:${event}${payload}`);
146
246
  }
147
247
 
148
248
  /**
@@ -249,6 +349,25 @@ const DEFAULT_STATE = Object.freeze({
249
349
  expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
250
350
  expectedTaskCount: null, // C2: count fallback when IDs not available
251
351
  auditSeq: 0, // C1: persistent seq for audit IDs
352
+ degraded: false, // D2: persisted degraded flag for restart observability
353
+ schemaVersion: 1, // D3: schema version for migrations
354
+ stateOversizedCount: 0, // 2.3
355
+ codegraphBypassCount: 0, // 6.3 / 3.1
356
+ degradedEditsCount: 0, // 8.5
357
+ lastProposal: null, // 8.1
358
+ allowedFiles: {}, // 9.2
359
+ deniedFiles: {}, // 9.2
360
+ sensitivePatterns: ['**/.env*', '**/.secrets/**', '**/*.pem', '**/*.key', '**/.aws/**', '**/.ssh/**', '**/credentials.json', '**/.npmrc'], // 9.1
361
+ sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 }, // 9.3
362
+ staleContentAttempts: 0, // 10.4
363
+ completeWithoutValidateCount: 0, // 10.5
364
+ toolTimeoutCount: 0, // 11.1
365
+ lastToolDurationMs: 0, // 11.4
366
+ stateDurationMs: 0, // 11.4
367
+ subagentFailedCount: 0, // 10.6
368
+ lastValidated: null, // 10.5 {filePath, hash, ts}
369
+ pendingFileAccess: {}, // 9.2
370
+ ts: Date.now(), // for uptime
252
371
  });
253
372
 
254
373
  class OstackyController {
@@ -262,6 +381,7 @@ class OstackyController {
262
381
  #lockPidPath;
263
382
  #lockHeartbeatPath;
264
383
  #lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
384
+ #lockOwner = false;
265
385
 
266
386
  constructor(opts = {}) {
267
387
  this.#statePath = opts.statePath;
@@ -272,7 +392,8 @@ class OstackyController {
272
392
  this.#lockMaxAttempts = opts.lockMaxAttempts;
273
393
  }
274
394
  if (opts.initialState) {
275
- this.#state = { ...DEFAULT_STATE, ...opts.initialState };
395
+ this.#state = { ...structuredClone(DEFAULT_STATE), ...opts.initialState };
396
+ this.#degraded = !!this.#state.degraded;
276
397
  this.#loaded = true;
277
398
  } else {
278
399
  this.#state = null;
@@ -348,6 +469,7 @@ class OstackyController {
348
469
  throw e;
349
470
  }
350
471
  writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
472
+ this.#lockOwner = true;
351
473
  return true;
352
474
  } catch {
353
475
  const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
@@ -357,11 +479,22 @@ class OstackyController {
357
479
  }
358
480
  }
359
481
  log('warn:lock_acquire_failed', { attempts: maxAttempts });
482
+ this.#lockOwner = false;
360
483
  return false;
361
484
  }
362
485
 
363
486
  #releaseLock() {
364
487
  if (!this.#lockPath) return;
488
+ // D2: verify ownership before deleting — never delete another process's lock
489
+ try {
490
+ const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
491
+ if (ownerPid !== String(process.pid)) {
492
+ this.#lockOwner = false;
493
+ return;
494
+ }
495
+ } catch {
496
+ if (!this.#lockOwner) return;
497
+ }
365
498
  try {
366
499
  unlinkSync(this.#lockPidPath);
367
500
  } catch {
@@ -372,6 +505,7 @@ class OstackyController {
372
505
  } catch {
373
506
  /* best-effort */
374
507
  }
508
+ this.#lockOwner = false;
375
509
  }
376
510
 
377
511
  #heartbeatLock() {
@@ -386,7 +520,7 @@ class OstackyController {
386
520
  #load() {
387
521
  if (this.#loaded) return;
388
522
  if (!this.#statePath) {
389
- this.#state = { ...DEFAULT_STATE };
523
+ this.#state = structuredClone(DEFAULT_STATE);
390
524
  this.#loaded = true;
391
525
  return;
392
526
  }
@@ -397,30 +531,75 @@ class OstackyController {
397
531
  const parsed = JSON.parse(raw);
398
532
  const validationError = this.#validateState(parsed);
399
533
  if (validationError) throw new Error(`State validation failed: ${validationError}`);
400
- this.#state = { ...DEFAULT_STATE, ...parsed };
534
+ this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed };
535
+ if ((parsed.schemaVersion ?? 0) < 1) {
536
+ let migrated = false;
537
+ if (typeof this.#state.snapshots?.codegraph === 'string') {
538
+ try { this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph); migrated = true; } catch {}
539
+ }
540
+ if (typeof this.#state.snapshots?.execution === 'string') {
541
+ try { this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution); migrated = true; } catch {}
542
+ }
543
+ if (typeof this.#state.expectedTasks === 'string') {
544
+ try {
545
+ const v = JSON.parse(this.#state.expectedTasks);
546
+ this.#state.expectedTasks = Array.isArray(v) ? v : v ? [String(v)] : null;
547
+ migrated = true;
548
+ } catch {}
549
+ }
550
+ if (Array.isArray(this.#state.audit)) {
551
+ for (const e of this.#state.audit) {
552
+ if (!e.id) { e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`; migrated = true; }
553
+ }
554
+ }
555
+ this.#state.schemaVersion = 1;
556
+ if (migrated) log('info:schema_migrated', { from: parsed.schemaVersion ?? 0, to: 1 });
557
+ }
558
+ this.#degraded = !!this.#state.degraded;
401
559
  this.#loaded = true;
402
560
  return;
403
561
  } catch (err) {
404
562
  log('warn:load_primary_failed', { error: err.message });
405
563
  }
406
- // Fallback: try .backup
407
- const backupPath = this.#statePath + '.backup';
564
+ // Fallback: try .backup, .backup.1, .backup.2 (2.1 rotativo)
565
+ for (const suffix of ['.backup', '.backup.1', '.backup.2']) {
566
+ const backupPath = this.#statePath + suffix;
567
+ try {
568
+ const raw = readFileSync(backupPath, 'utf8');
569
+ if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
570
+ const parsed = JSON.parse(raw);
571
+ const validationError = this.#validateState(parsed);
572
+ if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
573
+ this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed, error: suffix === '.backup' ? 'State restored from backup' : `State restored from ${suffix}` };
574
+ if ((parsed.schemaVersion ?? 0) < 1) {
575
+ if (typeof this.#state.snapshots?.codegraph === 'string') {
576
+ try { this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph); } catch {}
577
+ }
578
+ if (typeof this.#state.snapshots?.execution === 'string') {
579
+ try { this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution); } catch {}
580
+ }
581
+ if (Array.isArray(this.#state.audit)) {
582
+ for (const e of this.#state.audit) {
583
+ if (!e.id) e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
584
+ }
585
+ }
586
+ this.#state.schemaVersion = 1;
587
+ }
588
+ this.#degraded = !!this.#state.degraded;
589
+ log('warn:state_restored_from_backup', { suffix });
590
+ this.#loaded = true;
591
+ return;
592
+ } catch {}
593
+ }
408
594
  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;
595
+ throw new Error('All backups failed');
418
596
  } catch (backupErr) {
419
597
  // No backup either — set error state instead of silent reset
420
598
  this.#state = {
421
- ...DEFAULT_STATE,
599
+ ...structuredClone(DEFAULT_STATE),
422
600
  error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
423
601
  };
602
+ this.#degraded = !!this.#state.degraded;
424
603
  log('warn:state_reset', { error: backupErr.message });
425
604
  }
426
605
  this.#loaded = true;
@@ -442,6 +621,7 @@ class OstackyController {
442
621
  throw err;
443
622
  }
444
623
 
624
+ let didAcquire = false;
445
625
  try {
446
626
  // 3.4: Acquire lock before writing
447
627
  const lockAcquired = await this.#acquireLock();
@@ -449,21 +629,54 @@ class OstackyController {
449
629
  log('warn:persist_skipped_lock', { state: this.#state.state });
450
630
  throw new Error('Could not acquire state file lock');
451
631
  }
632
+ didAcquire = lockAcquired;
452
633
 
453
- let serialized = safeJsonStringify(this.#state, true);
634
+ // 4.2: redact sensitive before serialize (do not mutate original long-term, but ensure file is redacted)
635
+ const stateForSerialize = (() => {
636
+ try {
637
+ const copy = JSON.parse(safeJsonStringify(this.#state));
638
+ const redactRecursively = (obj) => {
639
+ if (!obj || typeof obj !== 'object') return;
640
+ for (const k of Object.keys(obj)) {
641
+ if (SENSITIVE_REDACT_RE.test(k)) {
642
+ obj[k] = '[REDACTED]';
643
+ } else if (typeof obj[k] === 'string' && SENSITIVE_REDACT_RE.test(obj[k])) {
644
+ obj[k] = obj[k].replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]').replace(/sk-[a-zA-Z0-9_-]+/g, '[REDACTED]');
645
+ if (SENSITIVE_REDACT_RE.test(obj[k])) obj[k] = '[REDACTED]';
646
+ } else if (typeof obj[k] === 'object') {
647
+ redactRecursively(obj[k]);
648
+ }
649
+ }
650
+ };
651
+ redactRecursively(copy);
652
+ if (copy.snapshots) redactRecursively(copy.snapshots);
653
+ if (copy.audit) copy.audit.forEach(redactRecursively);
654
+ return copy;
655
+ } catch { return this.#state; }
656
+ })();
657
+ let serialized = safeJsonStringify(stateForSerialize, true);
454
658
  if (serialized.length > MAX_STATE_FILE_SIZE) {
455
659
  log('warn:state_oversized', { size: serialized.length });
456
- const trimmed = { ...this.#state, snapshots: { codegraph: null, execution: null } };
660
+ this.#state.stateOversizedCount = (this.#state.stateOversizedCount || 0) + 1;
661
+ const trimmed = { ...stateForSerialize, snapshots: { codegraph: null, execution: null } };
457
662
  serialized = safeJsonStringify(trimmed, true);
458
663
  if (serialized.length > MAX_STATE_FILE_SIZE) {
459
664
  log('error:state_too_large_even_after_trim');
460
665
  return;
461
666
  }
462
667
  this.#state.snapshots = { codegraph: null, execution: null };
668
+ // also reflect in file copy
669
+ stateForSerialize.snapshots = { codegraph: null, execution: null };
670
+ serialized = safeJsonStringify(stateForSerialize, true);
463
671
  }
464
672
  const tmp = this.#statePath + '.tmp.' + process.pid;
465
673
  await writeFileAsync(tmp, serialized, 'utf8');
466
674
  await renameAsync(tmp, this.#statePath);
675
+ // 2.1: backup rotativo 3 niveles best-effort
676
+ try {
677
+ try { renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2'); } catch {}
678
+ try { renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1'); } catch {}
679
+ } catch {}
467
680
  try {
468
681
  const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
469
682
  await writeFileAsync(backupTmp, serialized, 'utf8');
@@ -471,11 +684,12 @@ class OstackyController {
471
684
  } catch {
472
685
  /* backup is best-effort */
473
686
  }
474
- // B1: persist success → reset failure counter
687
+ // B1: persist success → reset failure counter + auto-exit degraded
475
688
  if (this.#consecutiveFailures > 0) {
476
689
  log('info:persist_recovered', { after: this.#consecutiveFailures });
477
690
  }
478
691
  this.#consecutiveFailures = 0;
692
+ if (this.#degraded) this.#exitDegradedMode();
479
693
  } catch (err) {
480
694
  // B1: persist failure → increment counter, auto-degrade if threshold reached
481
695
  this.#consecutiveFailures++;
@@ -487,7 +701,7 @@ class OstackyController {
487
701
  }
488
702
  throw err;
489
703
  } finally {
490
- this.#releaseLock();
704
+ if (didAcquire) this.#releaseLock();
491
705
  }
492
706
  }
493
707
 
@@ -501,17 +715,59 @@ class OstackyController {
501
715
 
502
716
  #trimTasks() {
503
717
  if (!this.#state.tasks) return;
718
+ const limit = getMaxTasks();
504
719
  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) => {
720
+ if (entries.length <= limit) return;
721
+ const expectedSet = new Set(Array.isArray(this.#state.expectedTasks) ? this.#state.expectedTasks : []);
722
+ const expectedEntries = entries.filter(([id]) => expectedSet.has(id));
723
+ const nonExpectedEntries = entries.filter(([id]) => !expectedSet.has(id));
724
+ const excess = entries.length - limit;
725
+ if (nonExpectedEntries.length >= excess) {
726
+ nonExpectedEntries.sort((a, b) => {
727
+ const da = a[1].completedAt || '';
728
+ const db = b[1].completedAt || '';
729
+ return db.localeCompare(da);
730
+ });
731
+ const keepNonExpected = nonExpectedEntries.slice(0, nonExpectedEntries.length - excess);
732
+ const kept = [...expectedEntries, ...keepNonExpected];
733
+ kept.sort((a, b) => {
734
+ const da = a[1].completedAt || '';
735
+ const db = b[1].completedAt || '';
736
+ return db.localeCompare(da);
737
+ });
738
+ this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
739
+ log('warn:tasks_trimmed', { before: entries.length, after: limit, preservedExpected: expectedEntries.length });
740
+ return;
741
+ }
742
+ const sortedExpected = [...expectedEntries].sort((a, b) => {
508
743
  const da = a[1].completedAt || '';
509
744
  const db = b[1].completedAt || '';
510
- return db.localeCompare(da);
745
+ return da.localeCompare(db);
511
746
  });
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 });
747
+ const needToArchive = excess - nonExpectedEntries.length;
748
+ if (needToArchive > 0) {
749
+ for (let i = 0; i < Math.min(needToArchive, sortedExpected.length); i++) {
750
+ const [taskId] = sortedExpected[i];
751
+ log('info:task_archived_to_engram', { taskId, topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}` });
752
+ }
753
+ sortedExpected.sort((a, b) => {
754
+ const da = a[1].completedAt || '';
755
+ const db = b[1].completedAt || '';
756
+ return db.localeCompare(da);
757
+ });
758
+ const keepExpectedCount = expectedEntries.length - needToArchive;
759
+ const keepExpected = sortedExpected.slice(0, keepExpectedCount);
760
+ const kept = [...keepExpected, ...nonExpectedEntries];
761
+ kept.sort((a, b) => {
762
+ const da = a[1].completedAt || '';
763
+ const db = b[1].completedAt || '';
764
+ return db.localeCompare(da);
765
+ });
766
+ this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
767
+ log('warn:tasks_trimmed_with_archive', { before: entries.length, after: limit, archived: needToArchive });
768
+ return;
769
+ }
770
+ log('warn:tasks_over_limit_no_trim', { before: entries.length, limit, expected: expectedEntries.length });
515
771
  }
516
772
 
517
773
  async #transition(to, changes = {}) {
@@ -529,13 +785,19 @@ class OstackyController {
529
785
 
530
786
  // --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
531
787
  async #audit(phase, decision, reasoning) {
788
+ let redactedReasoning = reasoning ? String(reasoning).slice(0, 300) : undefined;
789
+ if (redactedReasoning && SENSITIVE_REDACT_RE.test(redactedReasoning)) {
790
+ redactedReasoning = redactedReasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
791
+ // also redact values after = if present
792
+ redactedReasoning = redactedReasoning.replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
793
+ }
532
794
  const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
533
795
  this.#auditBuffer.push({
534
796
  id,
535
797
  ts: Date.now(),
536
798
  phase,
537
799
  decision,
538
- reasoning: reasoning ? String(reasoning).slice(0, 300) : undefined,
800
+ reasoning: redactedReasoning,
539
801
  });
540
802
  const isWarn = phase === 'WARN';
541
803
  if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
@@ -549,10 +811,19 @@ class OstackyController {
549
811
  for (const e of this.#auditBuffer) {
550
812
  if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
551
813
  if (e.reasoning && e.reasoning.length > 300) e.reasoning = e.reasoning.slice(0, 300);
814
+ // 4.2: redact sensitive in audit
815
+ if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning)) {
816
+ e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
817
+ }
818
+ // redact any lingering snapshot data in reasoning
819
+ if (e.reasoning && /(apiKey|secret|token|password)/i.test(e.reasoning)) {
820
+ e.reasoning = e.reasoning.replace(/(apiKey|secret|token|password)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
821
+ }
552
822
  }
553
823
  this.#state.audit.push(...this.#auditBuffer);
554
- if (this.#state.audit.length > 100) {
555
- this.#state.audit = this.#state.audit.slice(-100);
824
+ const retention = getAuditRetentionSafe();
825
+ if (this.#state.audit.length > retention) {
826
+ this.#state.audit = this.#state.audit.slice(-retention);
556
827
  }
557
828
  this.#auditBuffer = [];
558
829
  // O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
@@ -607,14 +878,22 @@ class OstackyController {
607
878
  // --- 3.3: Degraded mode ---
608
879
  #enterDegradedMode(reason) {
609
880
  this.#degraded = true;
881
+ if (this.#state) this.#state.degraded = true;
610
882
  log('degraded_mode_activated', { reason, state: this.#state?.state });
883
+ if (this.#state && this.#statePath) {
884
+ try { this.#persist().catch(() => {}); } catch {}
885
+ }
611
886
  }
612
887
 
613
888
  #exitDegradedMode() {
614
889
  if (!this.#degraded) return;
615
890
  this.#degraded = false;
616
891
  this.#consecutiveFailures = 0;
892
+ if (this.#state) this.#state.degraded = false;
617
893
  log('degraded_mode_exited', { state: this.#state?.state });
894
+ if (this.#state && this.#statePath) {
895
+ try { this.#persist().catch(() => {}); } catch {}
896
+ }
618
897
  }
619
898
 
620
899
  // --- Core transitions ---
@@ -690,6 +969,10 @@ class OstackyController {
690
969
 
691
970
  async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
692
971
  this.#load();
972
+ // 4.4: validación de enums
973
+ if (level && !['0', '0+1', '1+'].includes(level)) {
974
+ return { error: `invalid level: ${level}`, available: ['0', '0+1', '1+'] };
975
+ }
693
976
  const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
694
977
  if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
695
978
 
@@ -711,18 +994,64 @@ class OstackyController {
711
994
  }
712
995
 
713
996
  const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
997
+ // 8.1/8.2: lastProposal handling — reasoning con plan exigido
998
+ let shownToUser = false;
999
+ let proposalFiles = [];
1000
+ let estLines = 0;
1001
+ if (snapshot?.reasoning && typeof snapshot.reasoning === 'object') {
1002
+ if (Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
1003
+ shownToUser = true;
1004
+ proposalFiles = snapshot.reasoning.files;
1005
+ estLines = snapshot.reasoning.estLines;
1006
+ }
1007
+ } else if (snapshot?.files && snapshot?.estLines) {
1008
+ shownToUser = true;
1009
+ proposalFiles = snapshot.files;
1010
+ estLines = snapshot.estLines;
1011
+ }
1012
+ const lastProposal = {
1013
+ ts: Date.now(),
1014
+ requestId: this.#state.requestId,
1015
+ summary: `recordDiscovery level=${level} files=${proposalFiles.join(',')} estLines=${estLines}`,
1016
+ files: proposalFiles,
1017
+ estLines,
1018
+ level,
1019
+ routeChoice: defaultChoice,
1020
+ shownToUser,
1021
+ };
714
1022
  await this.#transition(to, {
715
1023
  routeDecisionId: routeDecisionId || 'route-' + Date.now(),
716
1024
  routeChoice: defaultChoice, // O2: persist default suggested choice
717
1025
  level, // O1: persist level for conditional persistence
718
1026
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
1027
+ lastProposal,
719
1028
  });
720
1029
  await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
721
- // C2: warning if no evidence and not degraded and not trivial
1030
+ // 8.2: reasoning sin plan WARN
1031
+ if (!shownToUser && !isTrivial) {
1032
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1033
+ log('warn:proposal_without_transparent_plan', { level, auditId });
1034
+ await this.#audit('WARN', 'proposal_without_transparent_plan', `level=${level} reasoning missing files/estLines`);
1035
+ this.#state.lastProposal.shownToUser = false;
1036
+ await this.#persist();
1037
+ const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
1038
+ return {
1039
+ state: this.#state.state,
1040
+ revision: this.#state.revision,
1041
+ level,
1042
+ routeDecisionId: this.#state.routeDecisionId,
1043
+ defaultChoice,
1044
+ warning: 'proposal without transparent plan',
1045
+ auditId: lastAudit?.id || auditId,
1046
+ };
1047
+ }
1048
+ // C2: warning if no evidence and not degraded and not trivial — also count bypass
722
1049
  if (!hasEvidence && !this.#degraded && !isTrivial) {
1050
+ this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
723
1051
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
724
1052
  log('warn:discovery_without_codegraph', { level, auditId });
725
1053
  await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
1054
+ await this.#persist();
726
1055
  // auditId is the last pushed id
727
1056
  const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
728
1057
  return {
@@ -735,6 +1064,16 @@ class OstackyController {
735
1064
  auditId: lastAudit?.id || auditId,
736
1065
  };
737
1066
  }
1067
+ // 8.6: Bypass solo para CI
1068
+ if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'ROUTE_DECISION_PENDING') {
1069
+ await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume ${defaultChoice} for CI`);
1070
+ const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', defaultChoice);
1071
+ if (autoTo) {
1072
+ await this.#transition(autoTo, { routeChoice: defaultChoice });
1073
+ await this.#audit(autoTo, 'consume_route_decision', `choice=${defaultChoice} auto-confirm (CI)`);
1074
+ return { state: this.#state.state, revision: this.#state.revision, level, routeDecisionId: this.#state.routeDecisionId, defaultChoice, autoConfirmed: true };
1075
+ }
1076
+ }
738
1077
  return {
739
1078
  state: this.#state.state,
740
1079
  revision: this.#state.revision,
@@ -764,6 +1103,9 @@ class OstackyController {
764
1103
 
765
1104
  async consumeRouteDecision({ decisionId, choice } = {}) {
766
1105
  this.#load();
1106
+ if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
1107
+ return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
1108
+ }
767
1109
  if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
768
1110
  return this.#makeError(
769
1111
  `Cannot consume route decision from state ${this.#state.state}`,
@@ -807,24 +1149,77 @@ class OstackyController {
807
1149
  if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
808
1150
  return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
809
1151
  }
1152
+ // 1.7: exigir expectedTaskIds/taskIds/taskCount cuando taskCount>0
1153
+ if (snapshot && typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0) {
1154
+ const hasExpectedIds = Array.isArray(snapshot.expectedTaskIds) && snapshot.expectedTaskIds.length > 0;
1155
+ const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
1156
+ const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
1157
+ if (!hasExpectedIds && !hasTaskIds && !hasCount) {
1158
+ return this.#makeError('Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0', 'record_execution_analysis');
1159
+ }
1160
+ if (!hasExpectedIds && !hasTaskIds) {
1161
+ return this.#makeError('Snapshot missing expectedTaskIds or taskIds when taskCount>0', 'record_execution_analysis');
1162
+ }
1163
+ }
810
1164
  // C2: capture expected tasks for gate
811
1165
  const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
812
1166
  const expectedTaskCount = snapshot?.taskCount ?? (Array.isArray(expectedTasks) ? expectedTasks.length : null);
1167
+ const isEarlyExitExec = snapshot?.globalRuleTriggered === 'early-exit' && (snapshot?.taskCount ?? 0) <= 2;
1168
+ // 8.1/8.2: lastProposal for execution — reasoning con plan
1169
+ let execShown = false;
1170
+ let execFiles = [];
1171
+ let execEst = 0;
1172
+ if (snapshot?.reasoning && typeof snapshot.reasoning === 'object' && Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
1173
+ execShown = true;
1174
+ execFiles = snapshot.reasoning.files;
1175
+ execEst = snapshot.reasoning.estLines;
1176
+ } else if (snapshot?.files && snapshot?.estLines) {
1177
+ execShown = true;
1178
+ execFiles = snapshot.files;
1179
+ execEst = snapshot.estLines;
1180
+ } else if (snapshot?.sharedFiles && snapshot?.clusters) {
1181
+ // execution-mode-evaluation style: sharedFiles + clusters
1182
+ execShown = true;
1183
+ execFiles = snapshot.sharedFiles;
1184
+ execEst = snapshot.taskCount || 0;
1185
+ }
1186
+ const execLastProposal = {
1187
+ ts: Date.now(),
1188
+ requestId: this.#state.requestId,
1189
+ summary: `recordExecutionAnalysis files=${execFiles.join(',')} estLines=${execEst}`,
1190
+ files: execFiles,
1191
+ estLines: execEst,
1192
+ level: this.#state.level,
1193
+ routeChoice: this.#state.routeChoice,
1194
+ shownToUser: execShown,
1195
+ };
813
1196
  await this.#transition(to, {
814
1197
  executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
815
1198
  executionMode: null,
816
- snapshots: { ...this.#state.snapshots, execution: snapshot || null },
817
- expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
1199
+ snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
1200
+ expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
818
1201
  expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
1202
+ lastProposal: execLastProposal,
819
1203
  });
820
1204
  await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
821
- // C2: warning if missing codegraphUsed+recommendation and not degraded
1205
+ // 8.2: reasoning sin plan WARN (but allow early-exit style)
1206
+ if (!execShown && snapshot && !isEarlyExitExec) {
1207
+ // Only warn if snapshot was expected to have reasoning (taskCount>2 or not early-exit)
1208
+ const auditId2 = `aud-${Date.now()}-${this.#state.auditSeq}`;
1209
+ log('warn:proposal_without_transparent_plan', { auditId: auditId2 });
1210
+ await this.#audit('WARN', 'proposal_without_transparent_plan', 'execution reasoning missing files/estLines');
1211
+ this.#state.lastProposal.shownToUser = false;
1212
+ await this.#persist();
1213
+ }
1214
+ // C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
1215
+ // 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
822
1216
  const hasEvidence =
823
1217
  snapshot &&
824
1218
  Array.isArray(snapshot.codegraphUsed) &&
825
1219
  snapshot.codegraphUsed.length > 0 &&
826
1220
  snapshot.recommendation != null;
827
- if (snapshot && !hasEvidence && !this.#degraded) {
1221
+ if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
1222
+ this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
828
1223
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
829
1224
  log('warn:execution_without_codegraph', { auditId });
830
1225
  await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
@@ -837,6 +1232,17 @@ class OstackyController {
837
1232
  auditId: lastAudit?.id || auditId,
838
1233
  };
839
1234
  }
1235
+ // 8.6: Bypass solo para CI
1236
+ if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'EXECUTION_DECISION_PENDING') {
1237
+ await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume for CI`);
1238
+ const defaultMode = snapshot?.recommendation && ['INLINE', 'SUBAGENT_DRIVEN'].includes(snapshot.recommendation) ? snapshot.recommendation : 'INLINE';
1239
+ const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', defaultMode);
1240
+ if (autoTo) {
1241
+ await this.#transition(autoTo, { executionMode: defaultMode });
1242
+ await this.#audit(autoTo, 'consume_execution_decision', `mode=${defaultMode} auto-confirm (CI)`);
1243
+ return { state: this.#state.state, revision: this.#state.revision, executionDecisionId: this.#state.executionDecisionId, executionMode: defaultMode, autoConfirmed: true };
1244
+ }
1245
+ }
840
1246
  return {
841
1247
  state: this.#state.state,
842
1248
  revision: this.#state.revision,
@@ -846,6 +1252,9 @@ class OstackyController {
846
1252
 
847
1253
  async consumeExecutionDecision({ decisionId, mode } = {}) {
848
1254
  this.#load();
1255
+ if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
1256
+ return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
1257
+ }
849
1258
  if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
850
1259
  return this.#makeError(
851
1260
  `Cannot consume execution decision from state ${this.#state.state}`,
@@ -883,15 +1292,17 @@ class OstackyController {
883
1292
  }
884
1293
  // T3: also block on stale fingerprints-vs-disk
885
1294
  let staleFiles = [];
1295
+ const seenFp2 = new Set();
886
1296
  try {
887
1297
  for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
888
1298
  if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
889
1299
  const current = fastFingerprint(info.filePath);
890
1300
  if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
891
1301
  else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1302
+ seenFp2.add(info.filePath);
892
1303
  }
893
1304
  for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
894
- if (staleFiles.some((s) => s.includes(fp))) continue;
1305
+ if (seenFp2.has(fp)) continue;
895
1306
  const cur = fastFingerprint(fp);
896
1307
  if (!cur) staleFiles.push(`${fp} (missing)`);
897
1308
  else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
@@ -910,6 +1321,19 @@ class OstackyController {
910
1321
  };
911
1322
  }
912
1323
  if (hasBlocking && force) {
1324
+ // 4.3: force requiere confirmación humana en últimas 5 entradas de audit
1325
+ const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
1326
+ const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
1327
+ if (!hasHuman) {
1328
+ return {
1329
+ error: 'force requires human confirmation',
1330
+ pending,
1331
+ staleFiles: staleFiles.length ? staleFiles : undefined,
1332
+ current_state: this.#state.state,
1333
+ attempted_transition: 'implementation_complete',
1334
+ suggestion: 'User must write forzar/confirmo/force in a prior block/replan/set_handoff reasoning',
1335
+ };
1336
+ }
913
1337
  const all = [...pending, ...staleFiles].join(',');
914
1338
  await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
915
1339
  }
@@ -936,15 +1360,31 @@ class OstackyController {
936
1360
 
937
1361
  async block({ reason } = {}) {
938
1362
  this.#load();
1363
+ const from = this.#state.state;
939
1364
  const to = this.#isAllowedTransition(this.#state.state, 'block');
940
1365
  if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
1366
+ // 1.9: block desde EXECUTING_* preserva tasks/fileFingerprints/expectedTasks y audita WARN
1367
+ const isExecuting = from === 'EXECUTING_INLINE' || from === 'EXECUTING_SUBAGENTS';
941
1368
  await this.#transition(to, { error: reason || 'Blocked' });
942
1369
  await this.#audit('BLOCKED', 'block', reason || 'no reason');
1370
+ if (isExecuting) {
1371
+ await this.#audit('WARN', 'block_from_executing', `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`);
1372
+ }
1373
+ // 10.6: increment subagentFailedCount if block reason indicates subagent failure
1374
+ if (reason && /subagent.*failed/i.test(reason)) {
1375
+ this.#state.subagentFailedCount = (this.#state.subagentFailedCount || 0) + 1;
1376
+ await this.#audit('WARN', 'subagent_failed', reason);
1377
+ try { await this.#persist(); } catch {}
1378
+ }
943
1379
  return { state: this.#state.state, revision: this.#state.revision };
944
1380
  }
945
1381
 
946
1382
  async replan({ reason } = {}) {
947
1383
  this.#load();
1384
+ // 1.9: replan desde EXECUTING_* rechazado sin limpiar tasks
1385
+ if (this.#state.state === 'EXECUTING_INLINE' || this.#state.state === 'EXECUTING_SUBAGENTS') {
1386
+ return this.#makeError(`Cannot replan from state ${this.#state.state} — replan only from BLOCKED`, 'replan');
1387
+ }
948
1388
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
949
1389
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
950
1390
  await this.#transition(to, {
@@ -998,15 +1438,17 @@ class OstackyController {
998
1438
  }
999
1439
  // T3: fingerprints-vs-disk — detect stale/missing files after complete_task
1000
1440
  let staleFiles = [];
1441
+ const seenFp = new Set();
1001
1442
  try {
1002
1443
  for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
1003
1444
  if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
1004
1445
  const current = fastFingerprint(info.filePath);
1005
1446
  if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
1006
1447
  else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1448
+ seenFp.add(info.filePath);
1007
1449
  }
1008
1450
  for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
1009
- if (staleFiles.some((s) => s.includes(fp))) continue;
1451
+ if (seenFp.has(fp)) continue;
1010
1452
  const current = fastFingerprint(fp);
1011
1453
  if (!current) staleFiles.push(`${fp} (missing)`);
1012
1454
  else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
@@ -1023,9 +1465,11 @@ class OstackyController {
1023
1465
  };
1024
1466
  }
1025
1467
 
1026
- async getAudit({ limit = 20, offset = 0 } = {}) {
1468
+ async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
1027
1469
  this.#load();
1028
- const all = this.#state.audit || [];
1470
+ let all = this.#state.audit || [];
1471
+ if (phase) all = all.filter((e) => e.phase === phase);
1472
+ if (since) all = all.filter((e) => e.ts >= since);
1029
1473
  const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
1030
1474
  return slice.map((e) => ({
1031
1475
  id: e.id,
@@ -1036,6 +1480,159 @@ class OstackyController {
1036
1480
  }));
1037
1481
  }
1038
1482
 
1483
+ async getMetrics() {
1484
+ this.#load();
1485
+ let stateFileSize = 0;
1486
+ let auditSize = 0;
1487
+ let diskFreeMB = null;
1488
+ try {
1489
+ const stat = statSync(this.#statePath);
1490
+ stateFileSize = stat.size;
1491
+ } catch {}
1492
+ try {
1493
+ auditSize = (this.#state.audit || []).length;
1494
+ } catch {}
1495
+ try {
1496
+ // diskFree via statfs if available, fallback to null
1497
+ const { statfsSync } = await import('node:fs');
1498
+ if (typeof statfsSync === 'function' && this.#statePath) {
1499
+ try {
1500
+ const s = statfsSync(dirname(this.#statePath));
1501
+ diskFreeMB = Math.floor((s.bfree * s.bsize) / (1024 * 1024));
1502
+ } catch {}
1503
+ }
1504
+ } catch {}
1505
+ const completed = Object.values(this.#state.tasks || {}).filter((t) => t.status === 'COMPLETED').length;
1506
+ const total = Object.keys(this.#state.tasks || {}).length;
1507
+ const pending = Array.isArray(this.#state.expectedTasks)
1508
+ ? this.#state.expectedTasks.filter((id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED').length
1509
+ : typeof this.#state.expectedTaskCount === 'number'
1510
+ ? Math.max(0, this.#state.expectedTaskCount - completed)
1511
+ : 0;
1512
+ return {
1513
+ revision: this.#state.revision,
1514
+ state: this.#state.state,
1515
+ degraded: this.#degraded || !!this.#state.degraded,
1516
+ consecutiveFailures: this.#consecutiveFailures,
1517
+ taskCounts: { completed, pending, total, expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null },
1518
+ expectedTaskCount: this.#state.expectedTaskCount,
1519
+ auditSize,
1520
+ stateFileSize,
1521
+ diskFreeMB,
1522
+ uptimeMs: Date.now() - (this.#state.ts || Date.now()),
1523
+ stateOversizedCount: this.#state.stateOversizedCount || 0,
1524
+ codegraphBypassCount: this.#state.codegraphBypassCount || 0,
1525
+ degradedEditsCount: this.#state.degradedEditsCount || 0,
1526
+ sensitiveAccess: this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 },
1527
+ subagentFailedCount: this.#state.subagentFailedCount || 0,
1528
+ staleContentAttempts: this.#state.staleContentAttempts || 0,
1529
+ completeWithoutValidateCount: this.#state.completeWithoutValidateCount || 0,
1530
+ toolTimeoutCount: this.#state.toolTimeoutCount || 0,
1531
+ lastToolDurationMs: this.#state.lastToolDurationMs || 0,
1532
+ stateDurationMs: this.#state.stateDurationMs || 0,
1533
+ };
1534
+ }
1535
+
1536
+ async _recordToolTimeout() {
1537
+ this.#load();
1538
+ this.#state.toolTimeoutCount = (this.#state.toolTimeoutCount || 0) + 1;
1539
+ this.#state.lastToolDurationMs = 5000;
1540
+ try { await this.#persist(); } catch {}
1541
+ }
1542
+
1543
+ async _recordToolDuration(ms) {
1544
+ this.#load();
1545
+ this.#state.lastToolDurationMs = ms;
1546
+ this.#state.stateDurationMs = Date.now() - (this.#state.ts || Date.now());
1547
+ try { await this.#persist(); } catch {}
1548
+ }
1549
+
1550
+ async recordUserConfirmation({ decisionId, confirmationText } = {}) {
1551
+ this.#load();
1552
+ if (!decisionId || typeof confirmationText !== 'string') {
1553
+ return { error: 'decisionId and confirmationText required' };
1554
+ }
1555
+ await this.#audit('CONFIRMATION', 'record_user_confirmation', `user confirmed: ${confirmationText} for ${decisionId}`);
1556
+ await this.#flushAudit(true);
1557
+ await this.#persist();
1558
+ return { ok: true, decisionId, confirmationText, ts: Date.now() };
1559
+ }
1560
+
1561
+ // --- D11: Credential guard helpers ---
1562
+ isSensitiveFile(filePath) {
1563
+ if (!filePath) return false;
1564
+ this.#load();
1565
+ const lower = (filePath || '').toLowerCase();
1566
+ // allowlist
1567
+ if (lower.endsWith('.env.example') || lower.endsWith('.env.template') || lower.endsWith('.env.sample')) return false;
1568
+ const patterns = (this.#state && this.#state.sensitivePatterns) || DEFAULT_STATE.sensitivePatterns || [];
1569
+ for (const pat of patterns) {
1570
+ if (pat.includes('.env') && lower.split('/').pop().startsWith('.env')) return true;
1571
+ if (pat.includes('.secrets') && lower.includes('.secrets')) return true;
1572
+ if (pat.includes('*.pem') && lower.endsWith('.pem')) return true;
1573
+ if (pat.includes('*.key') && lower.endsWith('.key')) return true;
1574
+ if (pat.includes('.aws') && lower.includes('.aws')) return true;
1575
+ if (pat.includes('.ssh') && lower.includes('.ssh')) return true;
1576
+ if (pat.includes('credentials.json') && lower.endsWith('credentials.json')) return true;
1577
+ if (pat.includes('.npmrc') && lower.endsWith('.npmrc')) return true;
1578
+ }
1579
+ // fallback regex for generic
1580
+ if (/\.(pem|key)$/i.test(filePath)) return true;
1581
+ if (filePath.includes('.env')) {
1582
+ const base = filePath.split('/').pop();
1583
+ if (base.startsWith('.env')) return true;
1584
+ }
1585
+ return false;
1586
+ }
1587
+
1588
+ async checkFileAccess({ filePath, reason } = {}) {
1589
+ this.#load();
1590
+ if (!filePath) return { error: 'filePath required' };
1591
+ if (!this.isSensitiveFile(filePath)) return { allowed: true, reason: 'not sensitive' };
1592
+ if (this.#state.allowedFiles?.[filePath]) return { allowed: true, reason: 'previously allowed' };
1593
+ if (this.#state.deniedFiles?.[filePath]) {
1594
+ return { error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`, denied: true, filePath };
1595
+ }
1596
+ const decisionId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1597
+ if (!this.#state.pendingFileAccess) this.#state.pendingFileAccess = {};
1598
+ this.#state.pendingFileAccess[decisionId] = { filePath, reason, ts: Date.now() };
1599
+ await this.#audit('SECURITY', 'check_file_access', `check ${filePath} reason=${reason || 'none'}`);
1600
+ this.#state.sensitiveAccess = this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 };
1601
+ this.#state.sensitiveAccess.blockedAttempts = (this.#state.sensitiveAccess.blockedAttempts || 0) + 1;
1602
+ await this.#persist();
1603
+ return { status: 'BLOCKED', decisionId, filePath, reason: `File ${filePath} requires check_file_access` };
1604
+ }
1605
+
1606
+ async consumeFileAccessDecision({ decisionId, choice } = {}) {
1607
+ this.#load();
1608
+ if (!decisionId || !choice) return { error: 'decisionId and choice required' };
1609
+ if (!['ALLOW', 'DENY'].includes(choice)) return { error: 'choice must be ALLOW or DENY', available: ['ALLOW', 'DENY'] };
1610
+ const pending = this.#state.pendingFileAccess?.[decisionId];
1611
+ let filePath = pending?.filePath;
1612
+ // fallback: if no pending, try to find by decisionId prefix? require filePath param alternative
1613
+ if (!filePath && decisionId.startsWith('file-')) {
1614
+ // try to extract from audit? For now return error if not found
1615
+ return { error: 'decisionId not found', decisionId };
1616
+ }
1617
+ if (!this.#state.allowedFiles) this.#state.allowedFiles = {};
1618
+ if (!this.#state.deniedFiles) this.#state.deniedFiles = {};
1619
+ if (!this.#state.sensitiveAccess) this.#state.sensitiveAccess = { allowed: 0, denied: 0, blockedAttempts: 0 };
1620
+ if (choice === 'ALLOW') {
1621
+ this.#state.allowedFiles[filePath] = true;
1622
+ delete this.#state.deniedFiles[filePath];
1623
+ this.#state.sensitiveAccess.allowed = (this.#state.sensitiveAccess.allowed || 0) + 1;
1624
+ await this.#audit('SECURITY', 'consume_file_access_decision', `ALLOW ${filePath}`);
1625
+ } else {
1626
+ this.#state.deniedFiles[filePath] = true;
1627
+ delete this.#state.allowedFiles[filePath];
1628
+ this.#state.sensitiveAccess.denied = (this.#state.sensitiveAccess.denied || 0) + 1;
1629
+ await this.#audit('WARN', 'consume_file_access_decision', `DENY ${filePath}`);
1630
+ }
1631
+ if (this.#state.pendingFileAccess) delete this.#state.pendingFileAccess[decisionId];
1632
+ await this.#persist();
1633
+ return { ok: true, decisionId, choice, filePath };
1634
+ }
1635
+
1039
1636
  // --- B2: Handoff persistence for cross-session continuity ---
1040
1637
  async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
1041
1638
  this.#load();
@@ -1100,12 +1697,44 @@ class OstackyController {
1100
1697
  };
1101
1698
  }
1102
1699
 
1103
- // --- O6: Validate edit with fast fingerprint ---
1104
- async validateEdit({ oldString, newString, content, taskId } = {}) {
1700
+ // --- O6: Validate edit with fast fingerprint + D6/D4 hard gate + 10.4 freshness ---
1701
+ async validateEdit({ oldString, newString, content, taskId, filePath } = {}) {
1105
1702
  this.#load();
1106
1703
  if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
1107
1704
  return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
1108
1705
  }
1706
+ if (taskId && !isValidTaskId(taskId)) {
1707
+ return { outcome: 'CONFLICT', reason: `invalid taskId: ${taskId}` };
1708
+ }
1709
+ if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
1710
+ return { outcome: 'CONFLICT', reason: `filePath outside projectRoot: ${filePath}` };
1711
+ }
1712
+ // 9.2: guard sensible en validate_edit
1713
+ if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
1714
+ return { outcome: 'CONFLICT', reason: `BLOCKED: File ${filePath} requires check_file_access` };
1715
+ }
1716
+ // 8.5: contar edits en degraded sin confirmación auditada
1717
+ if (this.#degraded) {
1718
+ this.#state.degradedEditsCount = (this.#state.degradedEditsCount || 0) + 1;
1719
+ try { await this.#persist(); } catch {}
1720
+ }
1721
+ // 10.4: validación de frescura — content debe coincidir con disco si filePath dado
1722
+ if (filePath && typeof content === 'string') {
1723
+ try {
1724
+ const projectRoot = getProjectRoot(this.#statePath);
1725
+ const absolutePath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath);
1726
+ const diskContent = readFileSync(absolutePath, 'utf8');
1727
+ if (diskContent !== content) {
1728
+ this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
1729
+ await this.#persist();
1730
+ return { outcome: 'CONFLICT', reason: 'content stale, re-read file', filePath };
1731
+ }
1732
+ } catch (e) {
1733
+ if (e.code && e.code !== 'ENOENT') {
1734
+ // ignore ENOENT (new file), but other errors considered stale
1735
+ }
1736
+ }
1737
+ }
1109
1738
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1110
1739
  return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
1111
1740
  }
@@ -1141,6 +1770,14 @@ class OstackyController {
1141
1770
  };
1142
1771
  }
1143
1772
  // oldString found exactly once → safe to replace
1773
+ // 10.5: ligadura validate → complete
1774
+ try {
1775
+ const projectRoot = getProjectRoot(this.#statePath);
1776
+ const absolutePath = filePath ? (filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath)) : null;
1777
+ const hash = absolutePath ? fastFingerprint(absolutePath) : null;
1778
+ this.#state.lastValidated = { filePath: filePath || null, hash, ts: Date.now() };
1779
+ await this.#persist();
1780
+ } catch {}
1144
1781
  return { outcome: 'EDITABLE', taskId };
1145
1782
  }
1146
1783
 
@@ -1154,10 +1791,32 @@ class OstackyController {
1154
1791
  return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
1155
1792
  }
1156
1793
  if (!taskId) return { error: 'taskId is required' };
1794
+ if (!isValidTaskId(taskId)) return { error: 'invalid taskId: must match /^[a-zA-Z0-9-_.\/:]+$/', taskId };
1795
+ if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
1796
+ return { error: 'filePath outside projectRoot', filePath };
1797
+ }
1798
+ // 9.2: guard de credenciales — rechazar sensibles sin ALLOW
1799
+ if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
1800
+ return { error: `BLOCKED: File ${filePath} requires check_file_access`, filePath };
1801
+ }
1157
1802
  if (!this.#state.tasks) this.#state.tasks = {};
1158
1803
 
1159
1804
  // O6: Use fast fingerprint if no hash provided
1160
1805
  const effectiveHash = fileHash || (filePath ? fastFingerprint(filePath) : null);
1806
+ // 1.6: fingerprint obligatorio si archivo existe
1807
+ if (filePath) {
1808
+ const existsCheck = fastFingerprint(filePath);
1809
+ if (existsCheck && !effectiveHash) {
1810
+ return { error: 'fingerprint required: file exists but fileHash is null' };
1811
+ }
1812
+ }
1813
+ // 10.5: ligadura validate → complete — WARN si no hubo validate previo
1814
+ if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
1815
+ this.#state.completeWithoutValidateCount = (this.#state.completeWithoutValidateCount || 0) + 1;
1816
+ await this.#audit('WARN', 'complete_without_validate', `complete_task without prior validate_edit for ${filePath || taskId}`);
1817
+ } else {
1818
+ this.#state.lastValidated = null;
1819
+ }
1161
1820
 
1162
1821
  this.#state.tasks[taskId] = {
1163
1822
  status: 'COMPLETED',
@@ -1174,18 +1833,31 @@ class OstackyController {
1174
1833
  (k) => this.#state.tasks[k].status === 'COMPLETED'
1175
1834
  ).length;
1176
1835
  // C2: checkpoint count-based cada 3er complete_task — mismo persist, sin escritura extra
1836
+ // 1.8: preservación de handoff manual reciente (<60s) con pendingTasks distintos
1177
1837
  if (totalCompleted % 3 === 0) {
1178
1838
  const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
1179
1839
  ? this.#state.expectedTasks.filter(
1180
1840
  (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1181
1841
  )
1182
1842
  : [];
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
- };
1843
+ const existing = this.#state.lastHandoff;
1844
+ const isRecentManual = existing && (Date.now() - existing.ts < 60000) && existing.summary && !existing.summary.startsWith('Checkpoint auto');
1845
+ let shouldOverwrite = true;
1846
+ if (isRecentManual) {
1847
+ const existingPending = existing.pendingTasks || [];
1848
+ const isDistinct = pendingForHandoff.length !== existingPending.length || pendingForHandoff.some((id) => !existingPending.includes(id));
1849
+ if (isDistinct && existingPending.length > 0) {
1850
+ shouldOverwrite = false;
1851
+ }
1852
+ }
1853
+ if (shouldOverwrite) {
1854
+ this.#state.lastHandoff = {
1855
+ ts: Date.now(),
1856
+ summary: `Checkpoint auto: ${totalCompleted} tasks completadas`,
1857
+ nextSteps: pendingForHandoff.length ? [`Continuar con ${pendingForHandoff.join(', ')}`] : [],
1858
+ pendingTasks: pendingForHandoff,
1859
+ };
1860
+ }
1189
1861
  }
1190
1862
  await this.#persist();
1191
1863
  await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
@@ -1207,22 +1879,49 @@ class OstackyController {
1207
1879
  if (!this.#state.audit) this.#state.audit = [];
1208
1880
  for (const e of this.#auditBuffer) {
1209
1881
  if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
1882
+ if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning)) e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
1210
1883
  }
1211
1884
  this.#state.audit.push(...this.#auditBuffer);
1212
- if (this.#state.audit.length > 100) this.#state.audit = this.#state.audit.slice(-100);
1885
+ const retention = getAuditRetentionSafe();
1886
+ if (this.#state.audit.length > retention) this.#state.audit = this.#state.audit.slice(-retention);
1213
1887
  this.#auditBuffer = [];
1214
1888
  }
1215
- // T1: final persist path kept synchronous for graceful shutdown
1889
+ // T1: final persist path kept synchronous for graceful shutdown (+ D2 stale-aware 15s)
1216
1890
  if (!this.#statePath || !this.#state || !this.#loaded) return;
1217
1891
  try {
1892
+ // D2: replicate staleWindow logic sync — check timestamp before acquiring
1893
+ try {
1894
+ const tsRaw = readFileSync(this.#lockHeartbeatPath, 'utf8');
1895
+ const age = Date.now() - parseInt(tsRaw, 10);
1896
+ if (!Number.isNaN(age) && age >= 15000) {
1897
+ try { unlinkSync(this.#lockPidPath); } catch {}
1898
+ try { unlinkSync(this.#lockHeartbeatPath); } catch {}
1899
+ this.#lockOwner = false;
1900
+ } else if (!Number.isNaN(age) && age < 15000) {
1901
+ try {
1902
+ const pidRaw = readFileSync(this.#lockPidPath, 'utf8').trim();
1903
+ if (pidRaw !== String(process.pid)) return;
1904
+ } catch {}
1905
+ }
1906
+ } catch {}
1218
1907
  try {
1219
1908
  writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1220
1909
  } catch (e) {
1221
- if (e && e.code === 'EEXIST') return; // another process holds the lock — skip best-effort persist
1222
- throw e;
1910
+ if (e && e.code === 'EEXIST') {
1911
+ try {
1912
+ const tsRaw2 = readFileSync(this.#lockHeartbeatPath, 'utf8');
1913
+ const age2 = Date.now() - parseInt(tsRaw2, 10);
1914
+ if (!Number.isNaN(age2) && age2 >= 15000) {
1915
+ try { unlinkSync(this.#lockPidPath); } catch {}
1916
+ try { unlinkSync(this.#lockHeartbeatPath); } catch {}
1917
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1918
+ } else return;
1919
+ } catch { return; }
1920
+ } else throw e;
1223
1921
  }
1224
1922
  try {
1225
1923
  writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
1924
+ this.#lockOwner = true;
1226
1925
  } catch {}
1227
1926
  const serialized = safeJsonStringify(this.#state, true);
1228
1927
  const tmp = this.#statePath + '.tmp.' + process.pid;
@@ -1245,10 +1944,25 @@ const controller = new OstackyController({ statePath });
1245
1944
  */
1246
1945
  function safeHandler(fn) {
1247
1946
  return async (params) => {
1947
+ const start = Date.now();
1248
1948
  try {
1249
- const result = await fn(params);
1949
+ const result = await Promise.race([
1950
+ fn(params),
1951
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout 5s')), 5000)),
1952
+ ]);
1953
+ const duration = Date.now() - start;
1954
+ try { await controller._recordToolDuration(duration); } catch {}
1250
1955
  return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
1251
1956
  } catch (error) {
1957
+ const isTimeout = error && error.message && error.message.includes('timeout 5s');
1958
+ if (isTimeout) {
1959
+ log('warn:tool_timeout', { tool: fn.name || 'anonymous', durationMs: 5000 });
1960
+ try { await controller._recordToolTimeout(); } catch {}
1961
+ return {
1962
+ content: [{ type: 'text', text: safeJsonStringify({ error: 'timeout 5s', degraded: true }) }],
1963
+ isError: true,
1964
+ };
1965
+ }
1252
1966
  log('tool:error', {
1253
1967
  name: fn.name || 'anonymous',
1254
1968
  error: error.message,
@@ -1264,7 +1978,7 @@ function safeHandler(fn) {
1264
1978
 
1265
1979
  const server = new McpServer({
1266
1980
  name: 'ostacky-controller',
1267
- version: '0.7.2',
1981
+ version: '0.7.3',
1268
1982
  });
1269
1983
 
1270
1984
  server.registerTool(
@@ -1478,11 +2192,70 @@ server.registerTool(
1478
2192
  inputSchema: z.object({
1479
2193
  limit: z.number().optional().describe('Max entries (default 20)'),
1480
2194
  offset: z.number().optional().describe('Offset from end (default 0)'),
2195
+ phase: z.string().optional().describe('Filter by phase (e.g. WARN, LEVEL_RESOLVED)'),
2196
+ since: z.number().optional().describe('Filter by timestamp >= since'),
1481
2197
  }),
1482
2198
  },
1483
- safeHandler(async ({ limit, offset }) => {
1484
- log('tool:get_audit', { limit, offset });
1485
- return await controller.getAudit({ limit, offset });
2199
+ safeHandler(async ({ limit, offset, phase, since }) => {
2200
+ log('tool:get_audit', { limit, offset, phase, since });
2201
+ return await controller.getAudit({ limit, offset, phase, since });
2202
+ })
2203
+ );
2204
+
2205
+ server.registerTool(
2206
+ 'get_metrics',
2207
+ {
2208
+ description: 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
2209
+ inputSchema: z.object({}),
2210
+ },
2211
+ safeHandler(async () => {
2212
+ log('tool:get_metrics');
2213
+ return await controller.getMetrics();
2214
+ })
2215
+ );
2216
+
2217
+ server.registerTool(
2218
+ 'record_user_confirmation',
2219
+ {
2220
+ description: 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
2221
+ inputSchema: z.object({
2222
+ decisionId: z.string().describe('Decision ID from pending state'),
2223
+ confirmationText: z.string().describe('Literal user confirmation text'),
2224
+ }),
2225
+ },
2226
+ safeHandler(async ({ decisionId, confirmationText }) => {
2227
+ log('tool:record_user_confirmation', { decisionId });
2228
+ return await controller.recordUserConfirmation({ decisionId, confirmationText });
2229
+ })
2230
+ );
2231
+
2232
+ server.registerTool(
2233
+ 'check_file_access',
2234
+ {
2235
+ description: 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
2236
+ inputSchema: z.object({
2237
+ filePath: z.string().describe('File path to check'),
2238
+ reason: z.string().optional().describe('Reason for access'),
2239
+ }),
2240
+ },
2241
+ safeHandler(async ({ filePath, reason }) => {
2242
+ log('tool:check_file_access', { filePath });
2243
+ return await controller.checkFileAccess({ filePath, reason });
2244
+ })
2245
+ );
2246
+
2247
+ server.registerTool(
2248
+ 'consume_file_access_decision',
2249
+ {
2250
+ description: 'Consume file access decision: ALLOW or DENY. Persists allowedFiles/deniedFiles.',
2251
+ inputSchema: z.object({
2252
+ decisionId: z.string().describe('Decision ID from check_file_access'),
2253
+ choice: z.enum(['ALLOW', 'DENY']).describe('Choice'),
2254
+ }),
2255
+ },
2256
+ safeHandler(async ({ decisionId, choice }) => {
2257
+ log('tool:consume_file_access_decision', { decisionId, choice });
2258
+ return await controller.consumeFileAccessDecision({ decisionId, choice });
1486
2259
  })
1487
2260
  );
1488
2261
 
@@ -1521,14 +2294,19 @@ server.registerTool(
1521
2294
  inputSchema: z.object({}),
1522
2295
  },
1523
2296
  safeHandler(async () => {
2297
+ const state = await controller.getState();
2298
+ const metrics = await controller.getMetrics().catch(() => ({}));
1524
2299
  return {
1525
2300
  pong: true,
1526
2301
  degraded: controller.degraded,
1527
- state: await controller.getState().then((s) => ({
1528
- state: s.state,
1529
- revision: s.revision,
1530
- requestId: s.requestId,
1531
- })),
2302
+ state: {
2303
+ state: state.state,
2304
+ revision: state.revision,
2305
+ requestId: state.requestId,
2306
+ },
2307
+ diskFreeMB: metrics.diskFreeMB ?? null,
2308
+ stateFileSize: metrics.stateFileSize ?? null,
2309
+ auditSize: metrics.auditSize ?? null,
1532
2310
  };
1533
2311
  })
1534
2312
  );
@@ -1650,14 +2428,16 @@ server.registerTool(
1650
2428
  'Without this parameter, validate_edit will fail.'
1651
2429
  ),
1652
2430
  taskId: z.string().optional().describe('Optional task ID for tracking.'),
2431
+ filePath: z.string().optional().describe('Optional file path for traversal validation.'),
1653
2432
  }),
1654
2433
  },
1655
- safeHandler(async ({ oldString, newString, content, taskId }) => {
2434
+ safeHandler(async ({ oldString, newString, content, taskId, filePath }) => {
1656
2435
  log('tool:validate_edit', {
1657
2436
  taskId,
1658
2437
  oldLen: oldString?.length,
1659
2438
  newLen: newString?.length,
1660
2439
  hasContent: !!content,
2440
+ filePath,
1661
2441
  });
1662
2442
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1663
2443
  return {
@@ -1665,7 +2445,7 @@ server.registerTool(
1665
2445
  reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
1666
2446
  };
1667
2447
  }
1668
- return await controller.validateEdit({ oldString, newString, content, taskId });
2448
+ return await controller.validateEdit({ oldString, newString, content, taskId, filePath });
1669
2449
  })
1670
2450
  );
1671
2451
 
@@ -1721,7 +2501,7 @@ function setupGracefulShutdown(ctrl) {
1721
2501
  }
1722
2502
 
1723
2503
  async function main() {
1724
- log('Starting ostacky-controller MCP v0.7.2...');
2504
+ log('Starting ostacky-controller MCP v0.7.3...');
1725
2505
  log('State path:', { path: statePath });
1726
2506
  // Clean up stale tmp/lock files from previous runs
1727
2507
  cleanupTmpFiles(statePath);