ostacky 0.7.1 → 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.
@@ -15,15 +15,92 @@
15
15
  import { McpServer } from '@modelcontextprotocol/server';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
17
17
  import * as z from 'zod/v4';
18
- import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
19
- import { dirname, basename, join, resolve } from 'node:path';
18
+ import {
19
+ readFileSync,
20
+ writeFileSync,
21
+ renameSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ unlinkSync,
25
+ statSync,
26
+ existsSync,
27
+ } from 'node:fs';
28
+ import { dirname, basename, join, resolve, relative } from 'node:path';
29
+ import { writeFile as writeFileAsync, rename as renameAsync, mkdir as mkdirAsync } from 'node:fs/promises';
30
+
31
+ // T1: non-blocking wait — replaces busy-wait spins that froze the event loop
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
33
 
21
34
  // --- Constants (Fase 5.5 — headroom generoso) ---
22
35
  const MAX_TASKS = 100;
36
+ const MAX_TASKS_DEFAULT = 100;
37
+ const MAX_TASKS_CAP = 500;
23
38
  const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
24
39
  const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
25
40
  const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
26
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
+
27
104
  // --- Transition table ---
28
105
  const TRANSITIONS = {
29
106
  INTERPRETATION_PENDING: [
@@ -126,28 +203,102 @@ function safeJsonStringify(obj, pretty = false) {
126
203
  }
127
204
  }
128
205
 
129
- 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
+ }
130
242
  const ts = new Date().toISOString();
131
- const payload = data ? ` ${safeJsonStringify(data)}` : '';
132
- console.error(`[${ts}] ${event}${payload}`);
243
+ const safeData = redactForLog(data);
244
+ const payload = safeData ? ` ${safeJsonStringify(safeData)}` : '';
245
+ console.error(`[${ts}] ${level}:${event}${payload}`);
133
246
  }
134
247
 
135
248
  /**
136
- * Cleans up stale .tmp.* and .lock.* files from a previous crash.
249
+ * Cleans up stale .tmp.* and .lock.* files from a previous crash — C3 fix: never delete active locks of another process.
250
+ * Also handles orphaned .ostacky-handoff-compaction.json (only if ts>24h).
137
251
  */
138
252
  function cleanupTmpFiles(statePath) {
139
253
  if (!statePath) return;
140
254
  const dir = dirname(statePath);
141
255
  const name = basename(statePath);
256
+ const staleWindow = 15000;
257
+ const handoffTtl = 24 * 60 * 60 * 1000;
142
258
  try {
143
259
  for (const entry of readdirSync(dir)) {
144
- if (entry.startsWith(name + '.tmp.') || entry.startsWith(name + '.lock')) {
260
+ const isTmp = entry.startsWith(name + '.tmp.');
261
+ const isLock = entry.startsWith(name + '.lock');
262
+ const isHandoff = entry === '.ostacky-handoff-compaction.json';
263
+ if (!isTmp && !isLock && !isHandoff) continue;
264
+ // C3: don't delete active lock of another process
265
+ if (isLock) {
145
266
  try {
146
- unlinkSync(join(dir, entry));
267
+ const pidPath = join(dir, name + '.lock.pid');
268
+ const tsPath = join(dir, name + '.lock.timestamp');
269
+ // If we are checking a lock file, verify liveness
270
+ let lockPid = null;
271
+ let lockTs = null;
272
+ try {
273
+ lockPid = readFileSync(pidPath, 'utf8').trim();
274
+ } catch {}
275
+ try {
276
+ lockTs = parseInt(readFileSync(tsPath, 'utf8').trim(), 10);
277
+ } catch {}
278
+ if (lockPid && lockTs && !Number.isNaN(lockTs)) {
279
+ const age = Date.now() - lockTs;
280
+ if (age < staleWindow && String(lockPid) !== String(process.pid)) {
281
+ continue; // active lock of another process — skip
282
+ }
283
+ }
284
+ } catch {}
285
+ }
286
+ if (isHandoff) {
287
+ try {
288
+ const handoffPath = join(dir, entry);
289
+ const raw = readFileSync(handoffPath, 'utf8');
290
+ const data = JSON.parse(raw);
291
+ const ts = data?.ts ?? data?.timestamp ?? 0;
292
+ if (ts && Date.now() - ts < handoffTtl) continue; // keep recent handoff
147
293
  } catch {
148
- /* best-effort */
294
+ // If unreadable, treat as stale and delete
149
295
  }
150
296
  }
297
+ try {
298
+ unlinkSync(join(dir, entry));
299
+ } catch {
300
+ /* best-effort */
301
+ }
151
302
  }
152
303
  } catch {
153
304
  /* directory may not exist yet */
@@ -195,6 +346,28 @@ const DEFAULT_STATE = Object.freeze({
195
346
  fileFingerprints: {},
196
347
  error: null,
197
348
  lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
349
+ expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
350
+ expectedTaskCount: null, // C2: count fallback when IDs not available
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
198
371
  });
199
372
 
200
373
  class OstackyController {
@@ -207,7 +380,8 @@ class OstackyController {
207
380
  #lockPath;
208
381
  #lockPidPath;
209
382
  #lockHeartbeatPath;
210
- #lockMaxAttempts = 10; // overridable via opts for fast tests
383
+ #lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
384
+ #lockOwner = false;
211
385
 
212
386
  constructor(opts = {}) {
213
387
  this.#statePath = opts.statePath;
@@ -218,7 +392,8 @@ class OstackyController {
218
392
  this.#lockMaxAttempts = opts.lockMaxAttempts;
219
393
  }
220
394
  if (opts.initialState) {
221
- this.#state = { ...DEFAULT_STATE, ...opts.initialState };
395
+ this.#state = { ...structuredClone(DEFAULT_STATE), ...opts.initialState };
396
+ this.#degraded = !!this.#state.degraded;
222
397
  this.#loaded = true;
223
398
  } else {
224
399
  this.#state = null;
@@ -246,56 +421,80 @@ class OstackyController {
246
421
  return null; // valid
247
422
  }
248
423
 
249
- // --- 3.4: State file locking ---
250
- #acquireLock() {
424
+ // --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
425
+ async #acquireLock() {
251
426
  if (!this.#lockPath) return true;
252
- // Allow tests to shorten retry loops via opts.lockMaxAttempts
253
427
  const maxAttempts = this.#lockMaxAttempts;
254
- const lockTimeout = 5000;
428
+ const lockTimeout = 1000;
429
+ const staleWindow = 15000;
255
430
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
256
431
  try {
257
- writeFileSync(this.#lockPidPath, String(process.pid), 'utf8');
258
- writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
259
- // Check if lock is stale (>30s without heartbeat)
432
+ // Check existing lock BEFORE overwriting — corrects mutual-exclusion bug
260
433
  try {
261
434
  const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
262
435
  const lockAge = Date.now() - parseInt(lockContent, 10);
263
- if (lockAge > 30000) {
264
- const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
436
+ if (!Number.isNaN(lockAge) && lockAge < staleWindow) {
437
+ const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
438
+ const jitter = Math.floor(Math.random() * 200) - 100;
439
+ const waitMs = Math.max(0, base + jitter);
440
+ if (waitMs > 0) await sleep(waitMs);
441
+ continue;
442
+ }
443
+ if (!Number.isNaN(lockAge) && lockAge >= staleWindow) {
265
444
  try {
266
- process.kill(parseInt(lockPid, 10), 0); // check if PID alive
267
- // PID exists but lock is stale — wait briefly then force
268
- const waitStart = Date.now();
269
- while (Date.now() - waitStart < 10000) {
270
- /* spin wait */
445
+ const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
446
+ try {
447
+ process.kill(parseInt(lockPid, 10), 0);
448
+ // PID alive but stale beyond window — force release
449
+ } catch {
450
+ // PID dead — force release
271
451
  }
272
- } catch {
273
- // PID doesn't exist — force release
274
- }
452
+ } catch {}
275
453
  this.#releaseLock();
276
- continue;
277
454
  }
278
455
  } catch {
279
- // Can't read heartbeat — assume stale
280
- this.#releaseLock();
281
- continue;
456
+ // No heartbeat file try to acquire
457
+ }
458
+ // Atomic acquire with wx — fails if another process won the race
459
+ try {
460
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
461
+ } catch (e) {
462
+ if (e && e.code === 'EEXIST') {
463
+ const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
464
+ const jitter = Math.floor(Math.random() * 200) - 100;
465
+ const waitMs = Math.max(0, base + jitter);
466
+ if (waitMs > 0) await sleep(waitMs);
467
+ continue;
468
+ }
469
+ throw e;
282
470
  }
471
+ writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
472
+ this.#lockOwner = true;
283
473
  return true;
284
474
  } catch {
285
- // Lock held by another process wait and retry
286
- const waitMs = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
287
- const waitStart = Date.now();
288
- while (Date.now() - waitStart < waitMs) {
289
- /* spin wait */
290
- }
475
+ const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
476
+ const jitter = Math.floor(Math.random() * 200) - 100;
477
+ const waitMs = Math.max(0, base + jitter);
478
+ if (waitMs > 0) await sleep(waitMs);
291
479
  }
292
480
  }
293
481
  log('warn:lock_acquire_failed', { attempts: maxAttempts });
482
+ this.#lockOwner = false;
294
483
  return false;
295
484
  }
296
485
 
297
486
  #releaseLock() {
298
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
+ }
299
498
  try {
300
499
  unlinkSync(this.#lockPidPath);
301
500
  } catch {
@@ -306,6 +505,7 @@ class OstackyController {
306
505
  } catch {
307
506
  /* best-effort */
308
507
  }
508
+ this.#lockOwner = false;
309
509
  }
310
510
 
311
511
  #heartbeatLock() {
@@ -320,7 +520,7 @@ class OstackyController {
320
520
  #load() {
321
521
  if (this.#loaded) return;
322
522
  if (!this.#statePath) {
323
- this.#state = { ...DEFAULT_STATE };
523
+ this.#state = structuredClone(DEFAULT_STATE);
324
524
  this.#loaded = true;
325
525
  return;
326
526
  }
@@ -331,41 +531,86 @@ class OstackyController {
331
531
  const parsed = JSON.parse(raw);
332
532
  const validationError = this.#validateState(parsed);
333
533
  if (validationError) throw new Error(`State validation failed: ${validationError}`);
334
- 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;
335
559
  this.#loaded = true;
336
560
  return;
337
561
  } catch (err) {
338
562
  log('warn:load_primary_failed', { error: err.message });
339
563
  }
340
- // Fallback: try .backup
341
- 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
+ }
342
594
  try {
343
- const raw = readFileSync(backupPath, 'utf8');
344
- if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
345
- const parsed = JSON.parse(raw);
346
- const validationError = this.#validateState(parsed);
347
- if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
348
- this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
349
- log('warn:state_restored_from_backup');
350
- this.#loaded = true;
351
- return;
595
+ throw new Error('All backups failed');
352
596
  } catch (backupErr) {
353
597
  // No backup either — set error state instead of silent reset
354
598
  this.#state = {
355
- ...DEFAULT_STATE,
599
+ ...structuredClone(DEFAULT_STATE),
356
600
  error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
357
601
  };
602
+ this.#degraded = !!this.#state.degraded;
358
603
  log('warn:state_reset', { error: backupErr.message });
359
604
  }
360
605
  this.#loaded = true;
361
606
  }
362
607
 
363
- #persist() {
608
+ async #persist() {
364
609
  if (!this.#statePath) return;
365
610
 
366
611
  const dir = dirname(this.#statePath);
367
612
  try {
368
- mkdirSync(dir, { recursive: true });
613
+ await mkdirAsync(dir, { recursive: true });
369
614
  } catch (err) {
370
615
  // mkdir failures also count toward degraded mode
371
616
  this.#consecutiveFailures++;
@@ -376,40 +621,75 @@ class OstackyController {
376
621
  throw err;
377
622
  }
378
623
 
624
+ let didAcquire = false;
379
625
  try {
380
626
  // 3.4: Acquire lock before writing
381
- const lockAcquired = this.#acquireLock();
627
+ const lockAcquired = await this.#acquireLock();
382
628
  if (!lockAcquired) {
383
629
  log('warn:persist_skipped_lock', { state: this.#state.state });
384
630
  throw new Error('Could not acquire state file lock');
385
631
  }
632
+ didAcquire = lockAcquired;
386
633
 
387
- 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);
388
658
  if (serialized.length > MAX_STATE_FILE_SIZE) {
389
659
  log('warn:state_oversized', { size: serialized.length });
390
- 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 } };
391
662
  serialized = safeJsonStringify(trimmed, true);
392
663
  if (serialized.length > MAX_STATE_FILE_SIZE) {
393
664
  log('error:state_too_large_even_after_trim');
394
665
  return;
395
666
  }
396
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);
397
671
  }
398
672
  const tmp = this.#statePath + '.tmp.' + process.pid;
399
- writeFileSync(tmp, serialized, 'utf8');
400
- renameSync(tmp, this.#statePath);
673
+ await writeFileAsync(tmp, serialized, 'utf8');
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 {}
401
680
  try {
402
681
  const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
403
- writeFileSync(backupTmp, serialized, 'utf8');
404
- renameSync(backupTmp, this.#statePath + '.backup');
682
+ await writeFileAsync(backupTmp, serialized, 'utf8');
683
+ await renameAsync(backupTmp, this.#statePath + '.backup');
405
684
  } catch {
406
685
  /* backup is best-effort */
407
686
  }
408
- // B1: persist success → reset failure counter
687
+ // B1: persist success → reset failure counter + auto-exit degraded
409
688
  if (this.#consecutiveFailures > 0) {
410
689
  log('info:persist_recovered', { after: this.#consecutiveFailures });
411
690
  }
412
691
  this.#consecutiveFailures = 0;
692
+ if (this.#degraded) this.#exitDegradedMode();
413
693
  } catch (err) {
414
694
  // B1: persist failure → increment counter, auto-degrade if threshold reached
415
695
  this.#consecutiveFailures++;
@@ -421,7 +701,7 @@ class OstackyController {
421
701
  }
422
702
  throw err;
423
703
  } finally {
424
- this.#releaseLock();
704
+ if (didAcquire) this.#releaseLock();
425
705
  }
426
706
  }
427
707
 
@@ -435,24 +715,66 @@ class OstackyController {
435
715
 
436
716
  #trimTasks() {
437
717
  if (!this.#state.tasks) return;
718
+ const limit = getMaxTasks();
438
719
  const entries = Object.entries(this.#state.tasks);
439
- if (entries.length <= MAX_TASKS) return;
440
- // Sort by completedAt (desc), keep newest MAX_TASKS
441
- 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) => {
442
743
  const da = a[1].completedAt || '';
443
744
  const db = b[1].completedAt || '';
444
- return db.localeCompare(da);
745
+ return da.localeCompare(db);
445
746
  });
446
- const trimmed = Object.fromEntries(entries.slice(0, MAX_TASKS));
447
- this.#state.tasks = trimmed;
448
- 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 });
449
771
  }
450
772
 
451
- #transition(to, changes = {}) {
773
+ async #transition(to, changes = {}) {
452
774
  this.#state.revision++;
453
775
  this.#state.state = to;
454
776
  Object.assign(this.#state, changes);
455
- this.#persist();
777
+ await this.#persist();
456
778
  }
457
779
 
458
780
  // --- O4: O(1) transition lookup via pre-computed cache ---
@@ -461,28 +783,59 @@ class OstackyController {
461
783
  return ALLOWED_TRANSITIONS[from]?.get(key) || null;
462
784
  }
463
785
 
464
- // --- O5: Batched audit trail ---
465
- #audit(phase, decision, reasoning) {
466
- this.#auditBuffer.push({ ts: Date.now(), phase, decision, reasoning });
467
- if (this.#auditBuffer.length >= 10 || phase === 'DONE') {
468
- this.#flushAudit();
786
+ // --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
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
+ }
794
+ const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
795
+ this.#auditBuffer.push({
796
+ id,
797
+ ts: Date.now(),
798
+ phase,
799
+ decision,
800
+ reasoning: redactedReasoning,
801
+ });
802
+ const isWarn = phase === 'WARN';
803
+ if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
804
+ await this.#flushAudit(isWarn);
469
805
  }
470
806
  }
471
807
 
472
- #flushAudit() {
808
+ async #flushAudit(forcePersist = false) {
473
809
  if (this.#auditBuffer.length === 0) return;
474
810
  if (!this.#state.audit) this.#state.audit = [];
811
+ for (const e of this.#auditBuffer) {
812
+ if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
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
+ }
822
+ }
475
823
  this.#state.audit.push(...this.#auditBuffer);
476
- if (this.#state.audit.length > 100) {
477
- 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);
478
827
  }
479
828
  this.#auditBuffer = [];
480
- // O1: Skip persist for trivial Level 0 requests (non-terminal states).
481
- // Final persist still happens via #transition() and on DONE/BLOCKED via setupGracefulShutdown.
482
- if (this.#state.level === '0' && this.#state.state !== 'DONE' && this.#state.state !== 'BLOCKED') {
829
+ // O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
830
+ if (
831
+ !forcePersist &&
832
+ this.#state.level === '0' &&
833
+ this.#state.state !== 'DONE' &&
834
+ this.#state.state !== 'BLOCKED'
835
+ ) {
483
836
  return;
484
837
  }
485
- this.#persist();
838
+ await this.#persist();
486
839
  }
487
840
 
488
841
  // --- 3.5: Enriched error with available transitions ---
@@ -525,14 +878,22 @@ class OstackyController {
525
878
  // --- 3.3: Degraded mode ---
526
879
  #enterDegradedMode(reason) {
527
880
  this.#degraded = true;
881
+ if (this.#state) this.#state.degraded = true;
528
882
  log('degraded_mode_activated', { reason, state: this.#state?.state });
883
+ if (this.#state && this.#statePath) {
884
+ try { this.#persist().catch(() => {}); } catch {}
885
+ }
529
886
  }
530
887
 
531
888
  #exitDegradedMode() {
532
889
  if (!this.#degraded) return;
533
890
  this.#degraded = false;
534
891
  this.#consecutiveFailures = 0;
892
+ if (this.#state) this.#state.degraded = false;
535
893
  log('degraded_mode_exited', { state: this.#state?.state });
894
+ if (this.#state && this.#statePath) {
895
+ try { this.#persist().catch(() => {}); } catch {}
896
+ }
536
897
  }
537
898
 
538
899
  // --- Core transitions ---
@@ -542,7 +903,7 @@ class OstackyController {
542
903
  if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
543
904
  return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
544
905
  }
545
- this.#transition('INTERPRETATION_PENDING', {
906
+ await this.#transition('INTERPRETATION_PENDING', {
546
907
  requestId: requestId || 'req-' + Date.now(),
547
908
  changeId: changeId || null,
548
909
  routeDecisionId: null,
@@ -552,9 +913,11 @@ class OstackyController {
552
913
  snapshots: { codegraph: null, execution: null },
553
914
  tasks: {},
554
915
  fileFingerprints: {},
916
+ expectedTasks: null,
917
+ expectedTaskCount: null,
555
918
  error: null,
556
919
  });
557
- this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
920
+ await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
558
921
  return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
559
922
  }
560
923
 
@@ -566,8 +929,8 @@ class OstackyController {
566
929
  `Cannot request clarification from state ${this.#state.state}`,
567
930
  'request_clarification'
568
931
  );
569
- this.#transition(to, { error: question ? `Clarification: ${question}` : null });
570
- this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
932
+ await this.#transition(to, { error: question ? `Clarification: ${question}` : null });
933
+ await this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
571
934
  return { state: this.#state.state, revision: this.#state.revision };
572
935
  }
573
936
 
@@ -579,8 +942,8 @@ class OstackyController {
579
942
  `Cannot record clarification from state ${this.#state.state}`,
580
943
  'record_clarification'
581
944
  );
582
- this.#transition(to, { error: null });
583
- this.#audit('DISCOVERY', 'record_clarification');
945
+ await this.#transition(to, { error: null });
946
+ await this.#audit('DISCOVERY', 'record_clarification');
584
947
  return { state: this.#state.state, revision: this.#state.revision };
585
948
  }
586
949
 
@@ -606,9 +969,18 @@ class OstackyController {
606
969
 
607
970
  async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
608
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
+ }
609
976
  const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
610
977
  if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
611
978
 
979
+ // C2/H3: validate evidence BEFORE compress — _compressed is NOT valid evidence
980
+ const hasEvidence =
981
+ snapshot && !snapshot._compressed && Array.isArray(snapshot.symbols) && snapshot.symbols.length > 0;
982
+ const isTrivial = level === '0';
983
+
612
984
  // O3: Compress snapshot before persisting
613
985
  const compressedSnapshot = snapshot
614
986
  ? this.#compressCodegraphSnapshot(snapshot)
@@ -622,13 +994,86 @@ class OstackyController {
622
994
  }
623
995
 
624
996
  const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
625
- this.#transition(to, {
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
+ };
1022
+ await this.#transition(to, {
626
1023
  routeDecisionId: routeDecisionId || 'route-' + Date.now(),
627
1024
  routeChoice: defaultChoice, // O2: persist default suggested choice
628
1025
  level, // O1: persist level for conditional persistence
629
1026
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
1027
+ lastProposal,
630
1028
  });
631
- this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
1029
+ await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
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
1049
+ if (!hasEvidence && !this.#degraded && !isTrivial) {
1050
+ this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
1051
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1052
+ log('warn:discovery_without_codegraph', { level, auditId });
1053
+ await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
1054
+ await this.#persist();
1055
+ // auditId is the last pushed id
1056
+ const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
1057
+ return {
1058
+ state: this.#state.state,
1059
+ revision: this.#state.revision,
1060
+ level,
1061
+ routeDecisionId: this.#state.routeDecisionId,
1062
+ defaultChoice,
1063
+ warning: 'discovery without codegraph evidence',
1064
+ auditId: lastAudit?.id || auditId,
1065
+ };
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
+ }
632
1077
  return {
633
1078
  state: this.#state.state,
634
1079
  revision: this.#state.revision,
@@ -642,8 +1087,8 @@ class OstackyController {
642
1087
  this.#load();
643
1088
  const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
644
1089
  if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
645
- this.#transition(to);
646
- this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
1090
+ await this.#transition(to);
1091
+ await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
647
1092
  return { state: this.#state.state, revision: this.#state.revision };
648
1093
  }
649
1094
 
@@ -651,13 +1096,16 @@ class OstackyController {
651
1096
  this.#load();
652
1097
  const to = this.#isAllowedTransition(this.#state.state, 'abandon');
653
1098
  if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
654
- this.#transition(to, { error: reason || 'Abandoned' });
655
- this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
1099
+ await this.#transition(to, { error: reason || 'Abandoned' });
1100
+ await this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
656
1101
  return { state: this.#state.state, revision: this.#state.revision };
657
1102
  }
658
1103
 
659
1104
  async consumeRouteDecision({ decisionId, choice } = {}) {
660
1105
  this.#load();
1106
+ if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
1107
+ return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
1108
+ }
661
1109
  if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
662
1110
  return this.#makeError(
663
1111
  `Cannot consume route decision from state ${this.#state.state}`,
@@ -669,8 +1117,8 @@ class OstackyController {
669
1117
  const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
670
1118
  if (!to)
671
1119
  return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
672
- this.#transition(to, { routeChoice: choice });
673
- this.#audit(to, 'consume_route_decision', `choice=${choice}`);
1120
+ await this.#transition(to, { routeChoice: choice });
1121
+ await this.#audit(to, 'consume_route_decision', `choice=${choice}`);
674
1122
  return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
675
1123
  }
676
1124
 
@@ -678,8 +1126,8 @@ class OstackyController {
678
1126
  this.#load();
679
1127
  const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
680
1128
  if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
681
- this.#transition(to);
682
- this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
1129
+ await this.#transition(to);
1130
+ await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
683
1131
  return { state: this.#state.state, revision: this.#state.revision };
684
1132
  }
685
1133
 
@@ -697,12 +1145,104 @@ class OstackyController {
697
1145
  'record_execution_analysis'
698
1146
  );
699
1147
  }
700
- this.#transition(to, {
1148
+ // C2: strict contract — recommendation + reasons required
1149
+ if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
1150
+ return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
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
+ }
1164
+ // C2: capture expected tasks for gate
1165
+ const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
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
+ };
1196
+ await this.#transition(to, {
701
1197
  executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
702
1198
  executionMode: null,
703
- snapshots: { ...this.#state.snapshots, execution: snapshot || null },
1199
+ snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
1200
+ expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
1201
+ expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
1202
+ lastProposal: execLastProposal,
704
1203
  });
705
- this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
1204
+ await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
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
1216
+ const hasEvidence =
1217
+ snapshot &&
1218
+ Array.isArray(snapshot.codegraphUsed) &&
1219
+ snapshot.codegraphUsed.length > 0 &&
1220
+ snapshot.recommendation != null;
1221
+ if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
1222
+ this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
1223
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1224
+ log('warn:execution_without_codegraph', { auditId });
1225
+ await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
1226
+ const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
1227
+ return {
1228
+ state: this.#state.state,
1229
+ revision: this.#state.revision,
1230
+ executionDecisionId: this.#state.executionDecisionId,
1231
+ warning: 'execution analysis without execution-mode-evaluation',
1232
+ auditId: lastAudit?.id || auditId,
1233
+ };
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
+ }
706
1246
  return {
707
1247
  state: this.#state.state,
708
1248
  revision: this.#state.revision,
@@ -712,6 +1252,9 @@ class OstackyController {
712
1252
 
713
1253
  async consumeExecutionDecision({ decisionId, mode } = {}) {
714
1254
  this.#load();
1255
+ if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
1256
+ return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
1257
+ }
715
1258
  if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
716
1259
  return this.#makeError(
717
1260
  `Cannot consume execution decision from state ${this.#state.state}`,
@@ -723,12 +1266,12 @@ class OstackyController {
723
1266
  const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
724
1267
  if (!to)
725
1268
  return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
726
- this.#transition(to, { executionMode: mode });
727
- this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
1269
+ await this.#transition(to, { executionMode: mode });
1270
+ await this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
728
1271
  return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
729
1272
  }
730
1273
 
731
- async implementationComplete() {
1274
+ async implementationComplete({ force } = {}) {
732
1275
  this.#load();
733
1276
  const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
734
1277
  if (!to)
@@ -736,36 +1279,115 @@ class OstackyController {
736
1279
  `Cannot complete implementation from state ${this.#state.state}`,
737
1280
  'implementation_complete'
738
1281
  );
739
- this.#transition(to);
740
- this.#audit('SYNC', 'implementation_complete');
741
- return { state: this.#state.state, revision: this.#state.revision };
1282
+ // C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
1283
+ let pending = [];
1284
+ if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
1285
+ pending = this.#state.expectedTasks.filter(
1286
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1287
+ );
1288
+ } else if (typeof this.#state.expectedTaskCount === 'number') {
1289
+ const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
1290
+ if (completed < this.#state.expectedTaskCount)
1291
+ pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
1292
+ }
1293
+ // T3: also block on stale fingerprints-vs-disk
1294
+ let staleFiles = [];
1295
+ const seenFp2 = new Set();
1296
+ try {
1297
+ for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
1298
+ if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
1299
+ const current = fastFingerprint(info.filePath);
1300
+ if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
1301
+ else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1302
+ seenFp2.add(info.filePath);
1303
+ }
1304
+ for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
1305
+ if (seenFp2.has(fp)) continue;
1306
+ const cur = fastFingerprint(fp);
1307
+ if (!cur) staleFiles.push(`${fp} (missing)`);
1308
+ else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
1309
+ }
1310
+ } catch {}
1311
+ const hasBlocking = pending.length > 0 || staleFiles.length > 0;
1312
+ if (hasBlocking && !force) {
1313
+ return {
1314
+ error: staleFiles.length ? 'stale fingerprints' : 'tasks incomplete',
1315
+ pending,
1316
+ staleFiles: staleFiles.length ? staleFiles : undefined,
1317
+ current_state: this.#state.state,
1318
+ attempted_transition: 'implementation_complete',
1319
+ suggestion:
1320
+ 'Complete pending tasks via complete_task or retry with {force:true} after explicit user confirmation',
1321
+ };
1322
+ }
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
+ }
1337
+ const all = [...pending, ...staleFiles].join(',');
1338
+ await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
1339
+ }
1340
+ await this.#transition(to);
1341
+ await this.#audit('SYNC', 'implementation_complete');
1342
+ return {
1343
+ state: this.#state.state,
1344
+ revision: this.#state.revision,
1345
+ forced: !!force,
1346
+ pending: pending.length ? pending : undefined,
1347
+ };
742
1348
  }
743
1349
 
744
1350
  async syncComplete() {
745
1351
  this.#load();
746
1352
  const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
747
1353
  if (!to) return this.#makeError(`Cannot complete sync from state ${this.#state.state}`, 'sync_complete');
748
- this.#transition(to);
749
- this.#audit('DONE', 'sync_complete');
1354
+ await this.#transition(to);
1355
+ await this.#audit('DONE', 'sync_complete');
750
1356
  // Flush remaining audit entries
751
- this.#flushAudit();
1357
+ await this.#flushAudit();
752
1358
  return { state: this.#state.state, revision: this.#state.revision };
753
1359
  }
754
1360
 
755
1361
  async block({ reason } = {}) {
756
1362
  this.#load();
1363
+ const from = this.#state.state;
757
1364
  const to = this.#isAllowedTransition(this.#state.state, 'block');
758
1365
  if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
759
- this.#transition(to, { error: reason || 'Blocked' });
760
- this.#audit('BLOCKED', 'block', reason || 'no reason');
1366
+ // 1.9: block desde EXECUTING_* preserva tasks/fileFingerprints/expectedTasks y audita WARN
1367
+ const isExecuting = from === 'EXECUTING_INLINE' || from === 'EXECUTING_SUBAGENTS';
1368
+ await this.#transition(to, { error: reason || 'Blocked' });
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
+ }
761
1379
  return { state: this.#state.state, revision: this.#state.revision };
762
1380
  }
763
1381
 
764
1382
  async replan({ reason } = {}) {
765
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
+ }
766
1388
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
767
1389
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
768
- this.#transition(to, {
1390
+ await this.#transition(to, {
769
1391
  error: reason || null,
770
1392
  routeDecisionId: null,
771
1393
  routeChoice: null,
@@ -774,11 +1396,243 @@ class OstackyController {
774
1396
  snapshots: { codegraph: null, execution: null },
775
1397
  tasks: {},
776
1398
  fileFingerprints: {},
1399
+ expectedTasks: null,
1400
+ expectedTaskCount: null,
777
1401
  });
778
- this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
1402
+ await this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
779
1403
  return { state: this.#state.state, revision: this.#state.revision };
780
1404
  }
781
1405
 
1406
+ // --- C2: Expected tasks gate (controller as source of truth) ---
1407
+ async setExpectedTasks({ taskIds, taskCount } = {}) {
1408
+ this.#load();
1409
+ if (Array.isArray(taskIds) && taskIds.length > 0) {
1410
+ this.#state.expectedTasks = [...taskIds];
1411
+ this.#state.expectedTaskCount = taskIds.length;
1412
+ } else if (typeof taskCount === 'number' && taskCount > 0) {
1413
+ this.#state.expectedTasks = null;
1414
+ this.#state.expectedTaskCount = taskCount;
1415
+ } else {
1416
+ return { error: 'taskIds (array) or taskCount (number) required' };
1417
+ }
1418
+ await this.#persist();
1419
+ await this.#audit(
1420
+ 'EXECUTING',
1421
+ 'set_expected_tasks',
1422
+ `expected=${this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length}`
1423
+ );
1424
+ return { ok: true, expectedTasks: this.#state.expectedTasks, expectedTaskCount: this.#state.expectedTaskCount };
1425
+ }
1426
+
1427
+ async verifyIntegrity() {
1428
+ this.#load();
1429
+ let pending = [];
1430
+ if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
1431
+ pending = this.#state.expectedTasks.filter(
1432
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1433
+ );
1434
+ } else if (typeof this.#state.expectedTaskCount === 'number') {
1435
+ const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
1436
+ if (completed < this.#state.expectedTaskCount)
1437
+ pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
1438
+ }
1439
+ // T3: fingerprints-vs-disk — detect stale/missing files after complete_task
1440
+ let staleFiles = [];
1441
+ const seenFp = new Set();
1442
+ try {
1443
+ for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
1444
+ if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
1445
+ const current = fastFingerprint(info.filePath);
1446
+ if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
1447
+ else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
1448
+ seenFp.add(info.filePath);
1449
+ }
1450
+ for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
1451
+ if (seenFp.has(fp)) continue;
1452
+ const current = fastFingerprint(fp);
1453
+ if (!current) staleFiles.push(`${fp} (missing)`);
1454
+ else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
1455
+ }
1456
+ } catch {}
1457
+ const ok = pending.length === 0 && staleFiles.length === 0;
1458
+ return {
1459
+ ok,
1460
+ pending,
1461
+ staleFiles,
1462
+ completed: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED').length,
1463
+ expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
1464
+ state: this.#state.state,
1465
+ };
1466
+ }
1467
+
1468
+ async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
1469
+ this.#load();
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);
1473
+ const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
1474
+ return slice.map((e) => ({
1475
+ id: e.id,
1476
+ ts: e.ts,
1477
+ phase: e.phase,
1478
+ decision: e.decision,
1479
+ reasoning: e.reasoning ? String(e.reasoning).slice(0, 300) : undefined,
1480
+ }));
1481
+ }
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
+
782
1636
  // --- B2: Handoff persistence for cross-session continuity ---
783
1637
  async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
784
1638
  this.#load();
@@ -791,22 +1645,37 @@ class OstackyController {
791
1645
  nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
792
1646
  pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
793
1647
  };
794
- this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
795
- this.#persist();
1648
+ await this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
1649
+ await this.#persist();
796
1650
  return { ok: true, lastHandoff: this.#state.lastHandoff };
797
1651
  }
798
1652
 
799
1653
  async getHandoff() {
800
1654
  this.#load();
801
- return this.#state.lastHandoff;
1655
+ if (this.#state.lastHandoff) return this.#state.lastHandoff;
1656
+ // C3: fallback to compaction file — same anchor as writer (dirname(statePath))
1657
+ if (!this.#statePath) return null;
1658
+ try {
1659
+ const fallbackPath = join(dirname(this.#statePath), '.ostacky-handoff-compaction.json');
1660
+ const raw = readFileSync(fallbackPath, 'utf8');
1661
+ const data = JSON.parse(raw);
1662
+ if (data && typeof data.summary === 'string') return data;
1663
+ } catch {}
1664
+ return null;
802
1665
  }
803
1666
 
804
1667
  async clearHandoff() {
805
1668
  this.#load();
806
1669
  const prev = this.#state.lastHandoff;
807
1670
  this.#state.lastHandoff = null;
808
- this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
809
- this.#persist();
1671
+ // C3: also delete fallback compaction file (same anchor)
1672
+ if (this.#statePath) {
1673
+ try {
1674
+ unlinkSync(join(dirname(this.#statePath), '.ostacky-handoff-compaction.json'));
1675
+ } catch {}
1676
+ }
1677
+ await this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
1678
+ await this.#persist();
810
1679
  return { ok: true, cleared: prev };
811
1680
  }
812
1681
 
@@ -828,12 +1697,44 @@ class OstackyController {
828
1697
  };
829
1698
  }
830
1699
 
831
- // --- O6: Validate edit with fast fingerprint ---
832
- 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 } = {}) {
833
1702
  this.#load();
834
1703
  if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
835
1704
  return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
836
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
+ }
837
1738
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
838
1739
  return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
839
1740
  }
@@ -869,6 +1770,14 @@ class OstackyController {
869
1770
  };
870
1771
  }
871
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 {}
872
1781
  return { outcome: 'EDITABLE', taskId };
873
1782
  }
874
1783
 
@@ -882,10 +1791,32 @@ class OstackyController {
882
1791
  return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
883
1792
  }
884
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
+ }
885
1802
  if (!this.#state.tasks) this.#state.tasks = {};
886
1803
 
887
1804
  // O6: Use fast fingerprint if no hash provided
888
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
+ }
889
1820
 
890
1821
  this.#state.tasks[taskId] = {
891
1822
  status: 'COMPLETED',
@@ -898,23 +1829,108 @@ class OstackyController {
898
1829
  this.#state.fileFingerprints[filePath] = effectiveHash;
899
1830
  }
900
1831
  this.#trimTasks();
901
- this.#persist();
902
- this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
1832
+ const totalCompleted = Object.keys(this.#state.tasks).filter(
1833
+ (k) => this.#state.tasks[k].status === 'COMPLETED'
1834
+ ).length;
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
1837
+ if (totalCompleted % 3 === 0) {
1838
+ const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
1839
+ ? this.#state.expectedTasks.filter(
1840
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1841
+ )
1842
+ : [];
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
+ }
1861
+ }
1862
+ await this.#persist();
1863
+ await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
903
1864
  return {
904
1865
  taskId,
905
1866
  status: 'COMPLETED',
906
- totalCompleted: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED')
907
- .length,
1867
+ totalCompleted,
908
1868
  };
909
1869
  }
910
1870
 
911
1871
  /**
912
- * Public flush — force-persists current state to disk.
913
- * Used by graceful shutdown (private fields not accessible from outside).
1872
+ * Public flush — SYNCHRONOUS on purpose: SIGINT/SIGTERM handlers cannot await
1873
+ * (Node does not wait for async shutdown work). Drains the audit buffer into
1874
+ * state and runs a best-effort sync persist with a single non-spinning lock
1875
+ * attempt; skips persisting if another process currently holds the lock.
914
1876
  */
915
1877
  flush() {
916
- this.#flushAudit();
917
- this.#persist();
1878
+ if (this.#auditBuffer.length > 0 && this.#state) {
1879
+ if (!this.#state.audit) this.#state.audit = [];
1880
+ for (const e of this.#auditBuffer) {
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]');
1883
+ }
1884
+ this.#state.audit.push(...this.#auditBuffer);
1885
+ const retention = getAuditRetentionSafe();
1886
+ if (this.#state.audit.length > retention) this.#state.audit = this.#state.audit.slice(-retention);
1887
+ this.#auditBuffer = [];
1888
+ }
1889
+ // T1: final persist path kept synchronous for graceful shutdown (+ D2 stale-aware 15s)
1890
+ if (!this.#statePath || !this.#state || !this.#loaded) return;
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 {}
1907
+ try {
1908
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1909
+ } catch (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;
1921
+ }
1922
+ try {
1923
+ writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
1924
+ this.#lockOwner = true;
1925
+ } catch {}
1926
+ const serialized = safeJsonStringify(this.#state, true);
1927
+ const tmp = this.#statePath + '.tmp.' + process.pid;
1928
+ writeFileSync(tmp, serialized, 'utf8');
1929
+ renameSync(tmp, this.#statePath);
1930
+ this.#releaseLock();
1931
+ } catch {
1932
+ /* shutdown persist is best-effort */
1933
+ }
918
1934
  }
919
1935
  }
920
1936
 
@@ -928,10 +1944,25 @@ const controller = new OstackyController({ statePath });
928
1944
  */
929
1945
  function safeHandler(fn) {
930
1946
  return async (params) => {
1947
+ const start = Date.now();
931
1948
  try {
932
- 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 {}
933
1955
  return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
934
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
+ }
935
1966
  log('tool:error', {
936
1967
  name: fn.name || 'anonymous',
937
1968
  error: error.message,
@@ -947,7 +1978,7 @@ function safeHandler(fn) {
947
1978
 
948
1979
  const server = new McpServer({
949
1980
  name: 'ostacky-controller',
950
- version: '0.7.1',
1981
+ version: '0.7.3',
951
1982
  });
952
1983
 
953
1984
  server.registerTool(
@@ -1070,12 +2101,18 @@ server.registerTool(
1070
2101
  server.registerTool(
1071
2102
  'implementation_complete',
1072
2103
  {
1073
- description: 'Mark implementation as complete. Transitions to SYNC.',
1074
- inputSchema: z.object({}),
2104
+ description:
2105
+ 'Mark implementation as complete. Transitions to SYNC. Returns error without transitioning if tasks pending unless {force:true}.',
2106
+ inputSchema: z.object({
2107
+ force: z
2108
+ .boolean()
2109
+ .optional()
2110
+ .describe('Force transition even with pending tasks (requires explicit user confirmation)'),
2111
+ }),
1075
2112
  },
1076
- safeHandler(async () => {
1077
- log('tool:implementation_complete');
1078
- return await controller.implementationComplete();
2113
+ safeHandler(async ({ force }) => {
2114
+ log('tool:implementation_complete', { force: !!force });
2115
+ return await controller.implementationComplete({ force: !!force });
1079
2116
  })
1080
2117
  );
1081
2118
 
@@ -1119,6 +2156,109 @@ server.registerTool(
1119
2156
  })
1120
2157
  );
1121
2158
 
2159
+ server.registerTool(
2160
+ 'set_expected_tasks',
2161
+ {
2162
+ description:
2163
+ 'Register expected task IDs for the integrity gate (controller as source of truth). Call after execution analysis.',
2164
+ inputSchema: z.object({
2165
+ taskIds: z.array(z.string()).optional().describe('Array of expected task IDs'),
2166
+ taskCount: z.number().optional().describe('Fallback count when IDs not available'),
2167
+ }),
2168
+ },
2169
+ safeHandler(async ({ taskIds, taskCount }) => {
2170
+ log('tool:set_expected_tasks', { count: taskIds?.length ?? taskCount });
2171
+ return await controller.setExpectedTasks({ taskIds, taskCount });
2172
+ })
2173
+ );
2174
+
2175
+ server.registerTool(
2176
+ 'verify_integrity',
2177
+ {
2178
+ description:
2179
+ 'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
2180
+ inputSchema: z.object({}),
2181
+ },
2182
+ safeHandler(async () => {
2183
+ log('tool:verify_integrity');
2184
+ return await controller.verifyIntegrity();
2185
+ })
2186
+ );
2187
+
2188
+ server.registerTool(
2189
+ 'get_audit',
2190
+ {
2191
+ description: 'Get recent audit entries paginated. Read-only, truncated to 300 chars with unique id per entry.',
2192
+ inputSchema: z.object({
2193
+ limit: z.number().optional().describe('Max entries (default 20)'),
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'),
2197
+ }),
2198
+ },
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 });
2259
+ })
2260
+ );
2261
+
1122
2262
  server.registerTool(
1123
2263
  'proceed_to_route',
1124
2264
  {
@@ -1154,14 +2294,19 @@ server.registerTool(
1154
2294
  inputSchema: z.object({}),
1155
2295
  },
1156
2296
  safeHandler(async () => {
2297
+ const state = await controller.getState();
2298
+ const metrics = await controller.getMetrics().catch(() => ({}));
1157
2299
  return {
1158
2300
  pong: true,
1159
2301
  degraded: controller.degraded,
1160
- state: await controller.getState().then((s) => ({
1161
- state: s.state,
1162
- revision: s.revision,
1163
- requestId: s.requestId,
1164
- })),
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,
1165
2310
  };
1166
2311
  })
1167
2312
  );
@@ -1283,14 +2428,16 @@ server.registerTool(
1283
2428
  'Without this parameter, validate_edit will fail.'
1284
2429
  ),
1285
2430
  taskId: z.string().optional().describe('Optional task ID for tracking.'),
2431
+ filePath: z.string().optional().describe('Optional file path for traversal validation.'),
1286
2432
  }),
1287
2433
  },
1288
- safeHandler(async ({ oldString, newString, content, taskId }) => {
2434
+ safeHandler(async ({ oldString, newString, content, taskId, filePath }) => {
1289
2435
  log('tool:validate_edit', {
1290
2436
  taskId,
1291
2437
  oldLen: oldString?.length,
1292
2438
  newLen: newString?.length,
1293
2439
  hasContent: !!content,
2440
+ filePath,
1294
2441
  });
1295
2442
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1296
2443
  return {
@@ -1298,7 +2445,7 @@ server.registerTool(
1298
2445
  reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
1299
2446
  };
1300
2447
  }
1301
- return await controller.validateEdit({ oldString, newString, content, taskId });
2448
+ return await controller.validateEdit({ oldString, newString, content, taskId, filePath });
1302
2449
  })
1303
2450
  );
1304
2451
 
@@ -1354,7 +2501,7 @@ function setupGracefulShutdown(ctrl) {
1354
2501
  }
1355
2502
 
1356
2503
  async function main() {
1357
- log('Starting ostacky-controller MCP v0.7.1...');
2504
+ log('Starting ostacky-controller MCP v0.7.3...');
1358
2505
  log('State path:', { path: statePath });
1359
2506
  // Clean up stale tmp/lock files from previous runs
1360
2507
  cleanupTmpFiles(statePath);
@@ -1374,4 +2521,4 @@ if (isDirectRun) {
1374
2521
  });
1375
2522
  }
1376
2523
 
1377
- export { OstackyController };
2524
+ export { OstackyController, STATES, DEFAULT_STATE };