ostacky 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,7 @@ import {
27
27
  } from 'node:fs';
28
28
  import { dirname, basename, join, resolve, relative } from 'node:path';
29
29
  import { writeFile as writeFileAsync, rename as renameAsync, mkdir as mkdirAsync } from 'node:fs/promises';
30
+ import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from './security.js';
30
31
 
31
32
  // T1: non-blocking wait — replaces busy-wait spins that froze the event loop
32
33
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -41,11 +42,11 @@ const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
41
42
 
42
43
  function getMaxTasks() {
43
44
  const raw = process.env.OSTACKY_MAX_TASKS;
44
- if (raw == null || raw === "") return MAX_TASKS_DEFAULT;
45
+ if (raw == null || raw === '') return MAX_TASKS_DEFAULT;
45
46
  const n = parseInt(raw, 10);
46
47
  if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
47
48
  if (n > MAX_TASKS_CAP) {
48
- log("warn:max_tasks_capped", { requested: n, capped: MAX_TASKS_CAP });
49
+ log('warn:max_tasks_capped', { requested: n, capped: MAX_TASKS_CAP });
49
50
  return MAX_TASKS_CAP;
50
51
  }
51
52
  return n;
@@ -101,6 +102,8 @@ function redactSecrets(obj) {
101
102
 
102
103
  const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
103
104
 
105
+ // D1: source-of-truth — src/security.ts (via ./security.js) — isSensitive, SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, extractPathsFromBash imported above
106
+
104
107
  // --- Transition table ---
105
108
  const TRANSITIONS = {
106
109
  INTERPRETATION_PENDING: [
@@ -221,7 +224,9 @@ function redactForLog(data) {
221
224
  return copy;
222
225
  }
223
226
  return data;
224
- } catch { return data; }
227
+ } catch {
228
+ return data;
229
+ }
225
230
  }
226
231
 
227
232
  function log(eventOrLevel, maybeEventOrData, maybeData) {
@@ -234,10 +239,15 @@ function log(eventOrLevel, maybeEventOrData, maybeData) {
234
239
  data = maybeData;
235
240
  } else {
236
241
  // 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'; }
242
+ if (event.startsWith('warn:')) {
243
+ level = 'warn';
244
+ } else if (event.startsWith('error:')) {
245
+ level = 'error';
246
+ } else if (event.startsWith('info:')) {
247
+ level = 'info';
248
+ } else if (event.startsWith('degraded_')) {
249
+ level = 'warn';
250
+ }
241
251
  }
242
252
  const ts = new Date().toISOString();
243
253
  const safeData = redactForLog(data);
@@ -331,6 +341,14 @@ const STATES = Object.freeze({
331
341
  BLOCKED: 'BLOCKED',
332
342
  });
333
343
 
344
+ // States where start_request should reset (not resume) when force=false
345
+ const TERMINAL_STATES = Object.freeze([
346
+ STATES.INTERPRETATION_PENDING,
347
+ STATES.CLARIFICATION_PENDING,
348
+ STATES.BLOCKED,
349
+ STATES.DONE,
350
+ ]);
351
+
334
352
  const DEFAULT_STATE = Object.freeze({
335
353
  state: STATES.INTERPRETATION_PENDING,
336
354
  revision: 0,
@@ -354,10 +372,22 @@ const DEFAULT_STATE = Object.freeze({
354
372
  stateOversizedCount: 0, // 2.3
355
373
  codegraphBypassCount: 0, // 6.3 / 3.1
356
374
  degradedEditsCount: 0, // 8.5
375
+ cacheHitCount: 0, // 5.4 hardening-v2
376
+ cacheMissCount: 0,
377
+ tokenSavingEstimate: 0,
357
378
  lastProposal: null, // 8.1
358
379
  allowedFiles: {}, // 9.2
359
380
  deniedFiles: {}, // 9.2
360
- sensitivePatterns: ['**/.env*', '**/.secrets/**', '**/*.pem', '**/*.key', '**/.aws/**', '**/.ssh/**', '**/credentials.json', '**/.npmrc'], // 9.1
381
+ sensitivePatterns: [
382
+ '**/.env*',
383
+ '**/.secrets/**',
384
+ '**/*.pem',
385
+ '**/*.key',
386
+ '**/.aws/**',
387
+ '**/.ssh/**',
388
+ '**/credentials.json',
389
+ '**/.npmrc',
390
+ ], // 9.1
361
391
  sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 }, // 9.3
362
392
  staleContentAttempts: 0, // 10.4
363
393
  completeWithoutValidateCount: 0, // 10.5
@@ -367,6 +397,9 @@ const DEFAULT_STATE = Object.freeze({
367
397
  subagentFailedCount: 0, // 10.6
368
398
  lastValidated: null, // 10.5 {filePath, hash, ts}
369
399
  pendingFileAccess: {}, // 9.2
400
+ // Heartbeat monitoring for external watchdog (30s stale threshold)
401
+ lastHeartbeat: 0, // epoch ms, updated on each successful tool completion
402
+ watchdogEnabled: true, // when false, external watchdog should not restart based on heartbeat
370
403
  ts: Date.now(), // for uptime
371
404
  });
372
405
 
@@ -408,6 +441,18 @@ class OstackyController {
408
441
  return this.#degraded;
409
442
  }
410
443
 
444
+ /**
445
+ * Updates the lastHeartbeat timestamp to now.
446
+ * Called after successful tool completion for external watchdog monitoring.
447
+ * External watchdog contract: if Date.now() - lastHeartbeat > 30000 and watchdogEnabled === true,
448
+ * the watchdog should restart the MCP server process.
449
+ */
450
+ updateHeartbeat() {
451
+ if (this.#state) {
452
+ this.#state.lastHeartbeat = Date.now();
453
+ }
454
+ }
455
+
411
456
  /**
412
457
  * Validates that a parsed state object has the required fields and valid values.
413
458
  * Returns null if valid, or an error message if invalid.
@@ -468,7 +513,7 @@ class OstackyController {
468
513
  }
469
514
  throw e;
470
515
  }
471
- writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
516
+ this.#heartbeatLock();
472
517
  this.#lockOwner = true;
473
518
  return true;
474
519
  } catch {
@@ -532,13 +577,19 @@ class OstackyController {
532
577
  const validationError = this.#validateState(parsed);
533
578
  if (validationError) throw new Error(`State validation failed: ${validationError}`);
534
579
  this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed };
580
+ let migrated = false;
535
581
  if ((parsed.schemaVersion ?? 0) < 1) {
536
- let migrated = false;
537
582
  if (typeof this.#state.snapshots?.codegraph === 'string') {
538
- try { this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph); migrated = true; } catch {}
583
+ try {
584
+ this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
585
+ migrated = true;
586
+ } catch {}
539
587
  }
540
588
  if (typeof this.#state.snapshots?.execution === 'string') {
541
- try { this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution); migrated = true; } catch {}
589
+ try {
590
+ this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution);
591
+ migrated = true;
592
+ } catch {}
542
593
  }
543
594
  if (typeof this.#state.expectedTasks === 'string') {
544
595
  try {
@@ -549,12 +600,29 @@ class OstackyController {
549
600
  }
550
601
  if (Array.isArray(this.#state.audit)) {
551
602
  for (const e of this.#state.audit) {
552
- if (!e.id) { e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`; migrated = true; }
603
+ if (!e.id) {
604
+ e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
605
+ migrated = true;
606
+ }
553
607
  }
554
608
  }
555
609
  this.#state.schemaVersion = 1;
556
610
  if (migrated) log('info:schema_migrated', { from: parsed.schemaVersion ?? 0, to: 1 });
557
611
  }
612
+ // Migration for heartbeat fields (added in controller-resilience-improvements)
613
+ if (this.#state.lastHeartbeat === undefined) {
614
+ this.#state.lastHeartbeat = 0;
615
+ migrated = true;
616
+ }
617
+ if (this.#state.watchdogEnabled === undefined) {
618
+ this.#state.watchdogEnabled = true;
619
+ migrated = true;
620
+ }
621
+ if (migrated)
622
+ log('info:heartbeat_fields_migrated', {
623
+ lastHeartbeat: this.#state.lastHeartbeat,
624
+ watchdogEnabled: this.#state.watchdogEnabled,
625
+ });
558
626
  this.#degraded = !!this.#state.degraded;
559
627
  this.#loaded = true;
560
628
  return;
@@ -570,21 +638,49 @@ class OstackyController {
570
638
  const parsed = JSON.parse(raw);
571
639
  const validationError = this.#validateState(parsed);
572
640
  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}` };
641
+ this.#state = {
642
+ ...structuredClone(DEFAULT_STATE),
643
+ ...parsed,
644
+ error: suffix === '.backup' ? 'State restored from backup' : `State restored from ${suffix}`,
645
+ };
646
+ let backupMigrated = false;
574
647
  if ((parsed.schemaVersion ?? 0) < 1) {
575
648
  if (typeof this.#state.snapshots?.codegraph === 'string') {
576
- try { this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph); } catch {}
649
+ try {
650
+ this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
651
+ backupMigrated = true;
652
+ } catch {}
577
653
  }
578
654
  if (typeof this.#state.snapshots?.execution === 'string') {
579
- try { this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution); } catch {}
655
+ try {
656
+ this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution);
657
+ backupMigrated = true;
658
+ } catch {}
580
659
  }
581
660
  if (Array.isArray(this.#state.audit)) {
582
661
  for (const e of this.#state.audit) {
583
- if (!e.id) e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
662
+ if (!e.id) {
663
+ e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
664
+ backupMigrated = true;
665
+ }
584
666
  }
585
667
  }
586
668
  this.#state.schemaVersion = 1;
587
669
  }
670
+ // Migration for heartbeat fields in backups
671
+ if (this.#state.lastHeartbeat === undefined) {
672
+ this.#state.lastHeartbeat = 0;
673
+ backupMigrated = true;
674
+ }
675
+ if (this.#state.watchdogEnabled === undefined) {
676
+ this.#state.watchdogEnabled = true;
677
+ backupMigrated = true;
678
+ }
679
+ if (backupMigrated)
680
+ log('info:backup_heartbeat_fields_migrated', {
681
+ lastHeartbeat: this.#state.lastHeartbeat,
682
+ watchdogEnabled: this.#state.watchdogEnabled,
683
+ });
588
684
  this.#degraded = !!this.#state.degraded;
589
685
  log('warn:state_restored_from_backup', { suffix });
590
686
  this.#loaded = true;
@@ -641,7 +737,9 @@ class OstackyController {
641
737
  if (SENSITIVE_REDACT_RE.test(k)) {
642
738
  obj[k] = '[REDACTED]';
643
739
  } 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]');
740
+ obj[k] = obj[k]
741
+ .replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
742
+ .replace(/sk-[a-zA-Z0-9_-]+/g, '[REDACTED]');
645
743
  if (SENSITIVE_REDACT_RE.test(obj[k])) obj[k] = '[REDACTED]';
646
744
  } else if (typeof obj[k] === 'object') {
647
745
  redactRecursively(obj[k]);
@@ -652,7 +750,9 @@ class OstackyController {
652
750
  if (copy.snapshots) redactRecursively(copy.snapshots);
653
751
  if (copy.audit) copy.audit.forEach(redactRecursively);
654
752
  return copy;
655
- } catch { return this.#state; }
753
+ } catch {
754
+ return this.#state;
755
+ }
656
756
  })();
657
757
  let serialized = safeJsonStringify(stateForSerialize, true);
658
758
  if (serialized.length > MAX_STATE_FILE_SIZE) {
@@ -674,8 +774,12 @@ class OstackyController {
674
774
  await renameAsync(tmp, this.#statePath);
675
775
  // 2.1: backup rotativo 3 niveles best-effort
676
776
  try {
677
- try { renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2'); } catch {}
678
- try { renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1'); } catch {}
777
+ try {
778
+ renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2');
779
+ } catch {}
780
+ try {
781
+ renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1');
782
+ } catch {}
679
783
  } catch {}
680
784
  try {
681
785
  const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
@@ -736,7 +840,11 @@ class OstackyController {
736
840
  return db.localeCompare(da);
737
841
  });
738
842
  this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
739
- log('warn:tasks_trimmed', { before: entries.length, after: limit, preservedExpected: expectedEntries.length });
843
+ log('warn:tasks_trimmed', {
844
+ before: entries.length,
845
+ after: limit,
846
+ preservedExpected: expectedEntries.length,
847
+ });
740
848
  return;
741
849
  }
742
850
  const sortedExpected = [...expectedEntries].sort((a, b) => {
@@ -748,7 +856,10 @@ class OstackyController {
748
856
  if (needToArchive > 0) {
749
857
  for (let i = 0; i < Math.min(needToArchive, sortedExpected.length); i++) {
750
858
  const [taskId] = sortedExpected[i];
751
- log('info:task_archived_to_engram', { taskId, topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}` });
859
+ log('info:task_archived_to_engram', {
860
+ taskId,
861
+ topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}`,
862
+ });
752
863
  }
753
864
  sortedExpected.sort((a, b) => {
754
865
  const da = a[1].completedAt || '';
@@ -789,7 +900,10 @@ class OstackyController {
789
900
  if (redactedReasoning && SENSITIVE_REDACT_RE.test(redactedReasoning)) {
790
901
  redactedReasoning = redactedReasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
791
902
  // also redact values after = if present
792
- redactedReasoning = redactedReasoning.replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
903
+ redactedReasoning = redactedReasoning.replace(
904
+ /(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi,
905
+ '$1=[REDACTED]'
906
+ );
793
907
  }
794
908
  const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
795
909
  this.#auditBuffer.push({
@@ -881,7 +995,9 @@ class OstackyController {
881
995
  if (this.#state) this.#state.degraded = true;
882
996
  log('degraded_mode_activated', { reason, state: this.#state?.state });
883
997
  if (this.#state && this.#statePath) {
884
- try { this.#persist().catch(() => {}); } catch {}
998
+ try {
999
+ this.#persist().catch(() => {});
1000
+ } catch {}
885
1001
  }
886
1002
  }
887
1003
 
@@ -892,17 +1008,33 @@ class OstackyController {
892
1008
  if (this.#state) this.#state.degraded = false;
893
1009
  log('degraded_mode_exited', { state: this.#state?.state });
894
1010
  if (this.#state && this.#statePath) {
895
- try { this.#persist().catch(() => {}); } catch {}
1011
+ try {
1012
+ this.#persist().catch(() => {});
1013
+ } catch {}
896
1014
  }
897
1015
  }
898
1016
 
899
1017
  // --- Core transitions ---
900
1018
 
901
- async startRequest({ requestId, changeId } = {}) {
1019
+ async startRequest({ requestId, changeId, force = false } = {}) {
902
1020
  this.#load();
903
- if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
904
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
1021
+
1022
+ // If not forcing and current state is active (not terminal), resume instead of reset
1023
+ if (!force && !TERMINAL_STATES.includes(this.#state.state) && this.#state.requestId) {
1024
+ await this.#audit(
1025
+ this.#state.state,
1026
+ 'start_request',
1027
+ `resumed from ${this.#state.state}, requestId=${this.#state.requestId}`
1028
+ );
1029
+ return {
1030
+ state: this.#state.state,
1031
+ revision: this.#state.revision,
1032
+ requestId: this.#state.requestId,
1033
+ continued: true,
1034
+ };
905
1035
  }
1036
+
1037
+ // Force reset or terminal state: create new session
906
1038
  await this.#transition('INTERPRETATION_PENDING', {
907
1039
  requestId: requestId || 'req-' + Date.now(),
908
1040
  changeId: changeId || null,
@@ -917,8 +1049,17 @@ class OstackyController {
917
1049
  expectedTaskCount: null,
918
1050
  error: null,
919
1051
  });
920
- await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
921
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
1052
+ await this.#audit(
1053
+ 'INTERPRETATION_PENDING',
1054
+ 'start_request',
1055
+ `requestId=${this.#state.requestId}${force ? ' (forced)' : ''}`
1056
+ );
1057
+ return {
1058
+ state: this.#state.state,
1059
+ revision: this.#state.revision,
1060
+ requestId: this.#state.requestId,
1061
+ continued: false,
1062
+ };
922
1063
  }
923
1064
 
924
1065
  async requestClarification({ question } = {}) {
@@ -1031,7 +1172,11 @@ class OstackyController {
1031
1172
  if (!shownToUser && !isTrivial) {
1032
1173
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1033
1174
  log('warn:proposal_without_transparent_plan', { level, auditId });
1034
- await this.#audit('WARN', 'proposal_without_transparent_plan', `level=${level} reasoning missing files/estLines`);
1175
+ await this.#audit(
1176
+ 'WARN',
1177
+ 'proposal_without_transparent_plan',
1178
+ `level=${level} reasoning missing files/estLines`
1179
+ );
1035
1180
  this.#state.lastProposal.shownToUser = false;
1036
1181
  await this.#persist();
1037
1182
  const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
@@ -1071,7 +1216,14 @@ class OstackyController {
1071
1216
  if (autoTo) {
1072
1217
  await this.#transition(autoTo, { routeChoice: defaultChoice });
1073
1218
  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 };
1219
+ return {
1220
+ state: this.#state.state,
1221
+ revision: this.#state.revision,
1222
+ level,
1223
+ routeDecisionId: this.#state.routeDecisionId,
1224
+ defaultChoice,
1225
+ autoConfirmed: true,
1226
+ };
1075
1227
  }
1076
1228
  }
1077
1229
  return {
@@ -1155,10 +1307,16 @@ class OstackyController {
1155
1307
  const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
1156
1308
  const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
1157
1309
  if (!hasExpectedIds && !hasTaskIds && !hasCount) {
1158
- return this.#makeError('Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0', 'record_execution_analysis');
1310
+ return this.#makeError(
1311
+ 'Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0',
1312
+ 'record_execution_analysis'
1313
+ );
1159
1314
  }
1160
1315
  if (!hasExpectedIds && !hasTaskIds) {
1161
- return this.#makeError('Snapshot missing expectedTaskIds or taskIds when taskCount>0', 'record_execution_analysis');
1316
+ return this.#makeError(
1317
+ 'Snapshot missing expectedTaskIds or taskIds when taskCount>0',
1318
+ 'record_execution_analysis'
1319
+ );
1162
1320
  }
1163
1321
  }
1164
1322
  // C2: capture expected tasks for gate
@@ -1169,7 +1327,12 @@ class OstackyController {
1169
1327
  let execShown = false;
1170
1328
  let execFiles = [];
1171
1329
  let execEst = 0;
1172
- if (snapshot?.reasoning && typeof snapshot.reasoning === 'object' && Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
1330
+ if (
1331
+ snapshot?.reasoning &&
1332
+ typeof snapshot.reasoning === 'object' &&
1333
+ Array.isArray(snapshot.reasoning.files) &&
1334
+ typeof snapshot.reasoning.estLines === 'number'
1335
+ ) {
1173
1336
  execShown = true;
1174
1337
  execFiles = snapshot.reasoning.files;
1175
1338
  execEst = snapshot.reasoning.estLines;
@@ -1207,7 +1370,11 @@ class OstackyController {
1207
1370
  // Only warn if snapshot was expected to have reasoning (taskCount>2 or not early-exit)
1208
1371
  const auditId2 = `aud-${Date.now()}-${this.#state.auditSeq}`;
1209
1372
  log('warn:proposal_without_transparent_plan', { auditId: auditId2 });
1210
- await this.#audit('WARN', 'proposal_without_transparent_plan', 'execution reasoning missing files/estLines');
1373
+ await this.#audit(
1374
+ 'WARN',
1375
+ 'proposal_without_transparent_plan',
1376
+ 'execution reasoning missing files/estLines'
1377
+ );
1211
1378
  this.#state.lastProposal.shownToUser = false;
1212
1379
  await this.#persist();
1213
1380
  }
@@ -1233,14 +1400,26 @@ class OstackyController {
1233
1400
  };
1234
1401
  }
1235
1402
  // 8.6: Bypass solo para CI
1236
- if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'EXECUTION_DECISION_PENDING') {
1403
+ if (
1404
+ process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' &&
1405
+ this.#state.state === 'EXECUTION_DECISION_PENDING'
1406
+ ) {
1237
1407
  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';
1408
+ const defaultMode =
1409
+ snapshot?.recommendation && ['INLINE', 'SUBAGENT_DRIVEN'].includes(snapshot.recommendation)
1410
+ ? snapshot.recommendation
1411
+ : 'INLINE';
1239
1412
  const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', defaultMode);
1240
1413
  if (autoTo) {
1241
1414
  await this.#transition(autoTo, { executionMode: defaultMode });
1242
1415
  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 };
1416
+ return {
1417
+ state: this.#state.state,
1418
+ revision: this.#state.revision,
1419
+ executionDecisionId: this.#state.executionDecisionId,
1420
+ executionMode: defaultMode,
1421
+ autoConfirmed: true,
1422
+ };
1244
1423
  }
1245
1424
  }
1246
1425
  return {
@@ -1368,13 +1547,19 @@ class OstackyController {
1368
1547
  await this.#transition(to, { error: reason || 'Blocked' });
1369
1548
  await this.#audit('BLOCKED', 'block', reason || 'no reason');
1370
1549
  if (isExecuting) {
1371
- await this.#audit('WARN', 'block_from_executing', `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`);
1550
+ await this.#audit(
1551
+ 'WARN',
1552
+ 'block_from_executing',
1553
+ `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`
1554
+ );
1372
1555
  }
1373
1556
  // 10.6: increment subagentFailedCount if block reason indicates subagent failure
1374
1557
  if (reason && /subagent.*failed/i.test(reason)) {
1375
1558
  this.#state.subagentFailedCount = (this.#state.subagentFailedCount || 0) + 1;
1376
1559
  await this.#audit('WARN', 'subagent_failed', reason);
1377
- try { await this.#persist(); } catch {}
1560
+ try {
1561
+ await this.#persist();
1562
+ } catch {}
1378
1563
  }
1379
1564
  return { state: this.#state.state, revision: this.#state.revision };
1380
1565
  }
@@ -1383,7 +1568,10 @@ class OstackyController {
1383
1568
  this.#load();
1384
1569
  // 1.9: replan desde EXECUTING_* rechazado sin limpiar tasks
1385
1570
  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');
1571
+ return this.#makeError(
1572
+ `Cannot replan from state ${this.#state.state} — replan only from BLOCKED`,
1573
+ 'replan'
1574
+ );
1387
1575
  }
1388
1576
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
1389
1577
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
@@ -1505,16 +1693,23 @@ class OstackyController {
1505
1693
  const completed = Object.values(this.#state.tasks || {}).filter((t) => t.status === 'COMPLETED').length;
1506
1694
  const total = Object.keys(this.#state.tasks || {}).length;
1507
1695
  const pending = Array.isArray(this.#state.expectedTasks)
1508
- ? this.#state.expectedTasks.filter((id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED').length
1696
+ ? this.#state.expectedTasks.filter(
1697
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1698
+ ).length
1509
1699
  : typeof this.#state.expectedTaskCount === 'number'
1510
- ? Math.max(0, this.#state.expectedTaskCount - completed)
1511
- : 0;
1700
+ ? Math.max(0, this.#state.expectedTaskCount - completed)
1701
+ : 0;
1512
1702
  return {
1513
1703
  revision: this.#state.revision,
1514
1704
  state: this.#state.state,
1515
1705
  degraded: this.#degraded || !!this.#state.degraded,
1516
1706
  consecutiveFailures: this.#consecutiveFailures,
1517
- taskCounts: { completed, pending, total, expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null },
1707
+ taskCounts: {
1708
+ completed,
1709
+ pending,
1710
+ total,
1711
+ expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
1712
+ },
1518
1713
  expectedTaskCount: this.#state.expectedTaskCount,
1519
1714
  auditSize,
1520
1715
  stateFileSize,
@@ -1523,6 +1718,9 @@ class OstackyController {
1523
1718
  stateOversizedCount: this.#state.stateOversizedCount || 0,
1524
1719
  codegraphBypassCount: this.#state.codegraphBypassCount || 0,
1525
1720
  degradedEditsCount: this.#state.degradedEditsCount || 0,
1721
+ cacheHitCount: this.#state.cacheHitCount || 0,
1722
+ cacheMissCount: this.#state.cacheMissCount || 0,
1723
+ tokenSavingEstimate: this.#state.tokenSavingEstimate || 0,
1526
1724
  sensitiveAccess: this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 },
1527
1725
  subagentFailedCount: this.#state.subagentFailedCount || 0,
1528
1726
  staleContentAttempts: this.#state.staleContentAttempts || 0,
@@ -1537,14 +1735,43 @@ class OstackyController {
1537
1735
  this.#load();
1538
1736
  this.#state.toolTimeoutCount = (this.#state.toolTimeoutCount || 0) + 1;
1539
1737
  this.#state.lastToolDurationMs = 5000;
1540
- try { await this.#persist(); } catch {}
1738
+ try {
1739
+ await this.#persist();
1740
+ } catch {}
1541
1741
  }
1542
1742
 
1543
1743
  async _recordToolDuration(ms) {
1544
1744
  this.#load();
1545
1745
  this.#state.lastToolDurationMs = ms;
1546
1746
  this.#state.stateDurationMs = Date.now() - (this.#state.ts || Date.now());
1547
- try { await this.#persist(); } catch {}
1747
+ try {
1748
+ await this.#persist();
1749
+ } catch {}
1750
+ }
1751
+
1752
+ // --- 5.4 hardening-v2: cache metrics (token efficiency) ---
1753
+ async recordCacheHit({ tokensSaved = 500 } = {}) {
1754
+ this.#load();
1755
+ this.#state.cacheHitCount = (this.#state.cacheHitCount || 0) + 1;
1756
+ const saved = typeof tokensSaved === 'number' && tokensSaved > 0 ? tokensSaved : 500;
1757
+ this.#state.tokenSavingEstimate = (this.#state.tokenSavingEstimate || 0) + saved;
1758
+ try {
1759
+ await this.#persist();
1760
+ } catch {}
1761
+ return {
1762
+ ok: true,
1763
+ cacheHitCount: this.#state.cacheHitCount,
1764
+ tokenSavingEstimate: this.#state.tokenSavingEstimate,
1765
+ };
1766
+ }
1767
+
1768
+ async recordCacheMiss() {
1769
+ this.#load();
1770
+ this.#state.cacheMissCount = (this.#state.cacheMissCount || 0) + 1;
1771
+ try {
1772
+ await this.#persist();
1773
+ } catch {}
1774
+ return { ok: true, cacheMissCount: this.#state.cacheMissCount };
1548
1775
  }
1549
1776
 
1550
1777
  async recordUserConfirmation({ decisionId, confirmationText } = {}) {
@@ -1552,37 +1779,23 @@ class OstackyController {
1552
1779
  if (!decisionId || typeof confirmationText !== 'string') {
1553
1780
  return { error: 'decisionId and confirmationText required' };
1554
1781
  }
1555
- await this.#audit('CONFIRMATION', 'record_user_confirmation', `user confirmed: ${confirmationText} for ${decisionId}`);
1782
+ await this.#audit(
1783
+ 'CONFIRMATION',
1784
+ 'record_user_confirmation',
1785
+ `user confirmed: ${confirmationText} for ${decisionId}`
1786
+ );
1556
1787
  await this.#flushAudit(true);
1557
1788
  await this.#persist();
1558
1789
  return { ok: true, decisionId, confirmationText, ts: Date.now() };
1559
1790
  }
1560
1791
 
1561
- // --- D11: Credential guard helpers ---
1792
+ // --- D11: Credential guard helpers — source-of-truth is src/security.ts (hardening-v2 D1) ---
1562
1793
  isSensitiveFile(filePath) {
1563
1794
  if (!filePath) return false;
1564
1795
  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;
1796
+ const patterns =
1797
+ (this.#state && this.#state.sensitivePatterns) || DEFAULT_STATE.sensitivePatterns || SENSITIVE_DEFAULT;
1798
+ return isSensitive(filePath, patterns);
1586
1799
  }
1587
1800
 
1588
1801
  async checkFileAccess({ filePath, reason } = {}) {
@@ -1591,7 +1804,11 @@ class OstackyController {
1591
1804
  if (!this.isSensitiveFile(filePath)) return { allowed: true, reason: 'not sensitive' };
1592
1805
  if (this.#state.allowedFiles?.[filePath]) return { allowed: true, reason: 'previously allowed' };
1593
1806
  if (this.#state.deniedFiles?.[filePath]) {
1594
- return { error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`, denied: true, filePath };
1807
+ return {
1808
+ error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`,
1809
+ denied: true,
1810
+ filePath,
1811
+ };
1595
1812
  }
1596
1813
  const decisionId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1597
1814
  if (!this.#state.pendingFileAccess) this.#state.pendingFileAccess = {};
@@ -1606,7 +1823,8 @@ class OstackyController {
1606
1823
  async consumeFileAccessDecision({ decisionId, choice } = {}) {
1607
1824
  this.#load();
1608
1825
  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'] };
1826
+ if (!['ALLOW', 'DENY'].includes(choice))
1827
+ return { error: 'choice must be ALLOW or DENY', available: ['ALLOW', 'DENY'] };
1610
1828
  const pending = this.#state.pendingFileAccess?.[decisionId];
1611
1829
  let filePath = pending?.filePath;
1612
1830
  // fallback: if no pending, try to find by decisionId prefix? require filePath param alternative
@@ -1716,13 +1934,18 @@ class OstackyController {
1716
1934
  // 8.5: contar edits en degraded sin confirmación auditada
1717
1935
  if (this.#degraded) {
1718
1936
  this.#state.degradedEditsCount = (this.#state.degradedEditsCount || 0) + 1;
1719
- try { await this.#persist(); } catch {}
1937
+ try {
1938
+ await this.#persist();
1939
+ } catch {}
1720
1940
  }
1721
1941
  // 10.4: validación de frescura — content debe coincidir con disco si filePath dado
1722
1942
  if (filePath && typeof content === 'string') {
1723
1943
  try {
1724
1944
  const projectRoot = getProjectRoot(this.#statePath);
1725
- const absolutePath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath);
1945
+ const absolutePath =
1946
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1947
+ ? resolve(filePath)
1948
+ : resolve(projectRoot, filePath);
1726
1949
  const diskContent = readFileSync(absolutePath, 'utf8');
1727
1950
  if (diskContent !== content) {
1728
1951
  this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
@@ -1735,6 +1958,27 @@ class OstackyController {
1735
1958
  }
1736
1959
  }
1737
1960
  }
1961
+ // 5.3: optimization — si fastFingerprint no cambió, no re-enviar content completo
1962
+ if (
1963
+ (typeof content !== 'string' || content.length === 0) &&
1964
+ filePath &&
1965
+ this.#state.lastValidated?.filePath === filePath
1966
+ ) {
1967
+ try {
1968
+ const projectRoot = getProjectRoot(this.#statePath);
1969
+ const absolutePath =
1970
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1971
+ ? resolve(filePath)
1972
+ : resolve(projectRoot, filePath);
1973
+ const currentHash = fastFingerprint(absolutePath);
1974
+ if (currentHash && currentHash === this.#state.lastValidated.hash) {
1975
+ try {
1976
+ const diskContent = readFileSync(absolutePath, 'utf8');
1977
+ content = diskContent;
1978
+ } catch {}
1979
+ }
1980
+ } catch {}
1981
+ }
1738
1982
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1739
1983
  return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
1740
1984
  }
@@ -1773,7 +2017,11 @@ class OstackyController {
1773
2017
  // 10.5: ligadura validate → complete
1774
2018
  try {
1775
2019
  const projectRoot = getProjectRoot(this.#statePath);
1776
- const absolutePath = filePath ? (filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath)) : null;
2020
+ const absolutePath = filePath
2021
+ ? filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2022
+ ? resolve(filePath)
2023
+ : resolve(projectRoot, filePath)
2024
+ : null;
1777
2025
  const hash = absolutePath ? fastFingerprint(absolutePath) : null;
1778
2026
  this.#state.lastValidated = { filePath: filePath || null, hash, ts: Date.now() };
1779
2027
  await this.#persist();
@@ -1813,7 +2061,11 @@ class OstackyController {
1813
2061
  // 10.5: ligadura validate → complete — WARN si no hubo validate previo
1814
2062
  if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
1815
2063
  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}`);
2064
+ await this.#audit(
2065
+ 'WARN',
2066
+ 'complete_without_validate',
2067
+ `complete_task without prior validate_edit for ${filePath || taskId}`
2068
+ );
1817
2069
  } else {
1818
2070
  this.#state.lastValidated = null;
1819
2071
  }
@@ -1841,11 +2093,17 @@ class OstackyController {
1841
2093
  )
1842
2094
  : [];
1843
2095
  const existing = this.#state.lastHandoff;
1844
- const isRecentManual = existing && (Date.now() - existing.ts < 60000) && existing.summary && !existing.summary.startsWith('Checkpoint auto');
2096
+ const isRecentManual =
2097
+ existing &&
2098
+ Date.now() - existing.ts < 60000 &&
2099
+ existing.summary &&
2100
+ !existing.summary.startsWith('Checkpoint auto');
1845
2101
  let shouldOverwrite = true;
1846
2102
  if (isRecentManual) {
1847
2103
  const existingPending = existing.pendingTasks || [];
1848
- const isDistinct = pendingForHandoff.length !== existingPending.length || pendingForHandoff.some((id) => !existingPending.includes(id));
2104
+ const isDistinct =
2105
+ pendingForHandoff.length !== existingPending.length ||
2106
+ pendingForHandoff.some((id) => !existingPending.includes(id));
1849
2107
  if (isDistinct && existingPending.length > 0) {
1850
2108
  shouldOverwrite = false;
1851
2109
  }
@@ -1879,7 +2137,8 @@ class OstackyController {
1879
2137
  if (!this.#state.audit) this.#state.audit = [];
1880
2138
  for (const e of this.#auditBuffer) {
1881
2139
  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]');
2140
+ if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning))
2141
+ e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
1883
2142
  }
1884
2143
  this.#state.audit.push(...this.#auditBuffer);
1885
2144
  const retention = getAuditRetentionSafe();
@@ -1894,8 +2153,12 @@ class OstackyController {
1894
2153
  const tsRaw = readFileSync(this.#lockHeartbeatPath, 'utf8');
1895
2154
  const age = Date.now() - parseInt(tsRaw, 10);
1896
2155
  if (!Number.isNaN(age) && age >= 15000) {
1897
- try { unlinkSync(this.#lockPidPath); } catch {}
1898
- try { unlinkSync(this.#lockHeartbeatPath); } catch {}
2156
+ try {
2157
+ unlinkSync(this.#lockPidPath);
2158
+ } catch {}
2159
+ try {
2160
+ unlinkSync(this.#lockHeartbeatPath);
2161
+ } catch {}
1899
2162
  this.#lockOwner = false;
1900
2163
  } else if (!Number.isNaN(age) && age < 15000) {
1901
2164
  try {
@@ -1912,15 +2175,22 @@ class OstackyController {
1912
2175
  const tsRaw2 = readFileSync(this.#lockHeartbeatPath, 'utf8');
1913
2176
  const age2 = Date.now() - parseInt(tsRaw2, 10);
1914
2177
  if (!Number.isNaN(age2) && age2 >= 15000) {
1915
- try { unlinkSync(this.#lockPidPath); } catch {}
1916
- try { unlinkSync(this.#lockHeartbeatPath); } catch {}
2178
+ try {
2179
+ unlinkSync(this.#lockPidPath);
2180
+ } catch {}
2181
+ try {
2182
+ unlinkSync(this.#lockHeartbeatPath);
2183
+ } catch {}
1917
2184
  writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1918
2185
  } else return;
1919
- } catch { return; }
2186
+ } catch {
2187
+ return;
2188
+ }
1920
2189
  } else throw e;
1921
2190
  }
1922
2191
  try {
1923
- writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
2192
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
2193
+ this.#heartbeatLock();
1924
2194
  this.#lockOwner = true;
1925
2195
  } catch {}
1926
2196
  const serialized = safeJsonStringify(this.#state, true);
@@ -1941,59 +2211,101 @@ const controller = new OstackyController({ statePath });
1941
2211
  * Wraps an async tool handler to ALWAYS return a response (even on error).
1942
2212
  * Without this, an unhandled exception in any tool handler leaves the LLM
1943
2213
  * waiting forever — the root cause of agent freezes.
2214
+ * Supports configurable retry with exponential backoff for transient failures.
2215
+ * @param {Function} fn - The tool handler function
2216
+ * @param {Object} options - Retry options
2217
+ * @param {number} options.maxRetries - Maximum retry attempts (default: 0)
2218
+ * @param {number} options.baseTimeout - Base timeout in ms (default: 5000)
1944
2219
  */
1945
- function safeHandler(fn) {
2220
+ function safeHandler(fn, options = {}) {
2221
+ const { maxRetries = 0, baseTimeout = 5000 } = options;
2222
+
1946
2223
  return async (params) => {
1947
- const start = Date.now();
1948
- try {
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 {}
1955
- return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
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 {}
2224
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2225
+ const currentTimeout = baseTimeout * Math.pow(1.5, attempt);
2226
+ const start = Date.now();
2227
+ try {
2228
+ const result = await Promise.race([
2229
+ fn(params),
2230
+ new Promise((_, reject) =>
2231
+ setTimeout(() => reject(new Error(`timeout ${currentTimeout}ms`)), currentTimeout)
2232
+ ),
2233
+ ]);
2234
+ const duration = Date.now() - start;
2235
+ try {
2236
+ await controller._recordToolDuration(duration);
2237
+ } catch {}
2238
+ // Update heartbeat on successful completion
2239
+ controller.updateHeartbeat();
2240
+ return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
2241
+ } catch (error) {
2242
+ const isTimeout = error && error.message && error.message.includes('timeout');
2243
+ const isNetworkError =
2244
+ error && error.code && ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].includes(error.code);
2245
+ const isRetryable = isTimeout || isNetworkError;
2246
+
2247
+ if (isRetryable && attempt < maxRetries) {
2248
+ const backoff = 200 * Math.pow(2, attempt);
2249
+ log('warn:tool_retry', {
2250
+ tool: fn.name || 'anonymous',
2251
+ attempt: attempt + 1,
2252
+ maxRetries,
2253
+ error: error.message,
2254
+ backoff,
2255
+ });
2256
+ await sleep(backoff);
2257
+ continue; // retry
2258
+ }
2259
+
2260
+ if (isTimeout) {
2261
+ log('warn:tool_timeout', { tool: fn.name || 'anonymous', durationMs: currentTimeout });
2262
+ try {
2263
+ await controller._recordToolTimeout();
2264
+ } catch {}
2265
+ return {
2266
+ content: [
2267
+ {
2268
+ type: 'text',
2269
+ text: safeJsonStringify({ error: `timeout ${currentTimeout}ms`, degraded: true }),
2270
+ },
2271
+ ],
2272
+ isError: true,
2273
+ };
2274
+ }
2275
+
2276
+ log('tool:error', {
2277
+ name: fn.name || 'anonymous',
2278
+ error: error.message,
2279
+ stack: error.stack,
2280
+ });
1961
2281
  return {
1962
- content: [{ type: 'text', text: safeJsonStringify({ error: 'timeout 5s', degraded: true }) }],
2282
+ content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
1963
2283
  isError: true,
1964
2284
  };
1965
2285
  }
1966
- log('tool:error', {
1967
- name: fn.name || 'anonymous',
1968
- error: error.message,
1969
- stack: error.stack,
1970
- });
1971
- return {
1972
- content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
1973
- isError: true,
1974
- };
1975
2286
  }
1976
2287
  };
1977
2288
  }
1978
2289
 
1979
2290
  const server = new McpServer({
1980
2291
  name: 'ostacky-controller',
1981
- version: '0.7.3',
2292
+ version: '0.7.4',
1982
2293
  });
1983
2294
 
1984
2295
  server.registerTool(
1985
2296
  'start_request',
1986
2297
  {
1987
2298
  description:
1988
- 'Start or reset a new request. Can be called from ANY state resets state machine. Call this first.',
2299
+ 'Start or resume a request. By default, resumes in-progress work (non-terminal states). Use force=true to always reset.',
1989
2300
  inputSchema: z.object({
1990
2301
  requestId: z.string().optional().describe('Unique request ID'),
1991
2302
  changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
2303
+ force: z.boolean().optional().describe('Force reset even if work in progress (default: false)'),
1992
2304
  }),
1993
2305
  },
1994
- safeHandler(async ({ requestId, changeId }) => {
1995
- log('tool:start_request');
1996
- return await controller.startRequest({ requestId, changeId });
2306
+ safeHandler(async ({ requestId, changeId, force }) => {
2307
+ log('tool:start_request', { force: !!force });
2308
+ return await controller.startRequest({ requestId, changeId, force: !!force });
1997
2309
  })
1998
2310
  );
1999
2311
 
@@ -2179,10 +2491,13 @@ server.registerTool(
2179
2491
  'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
2180
2492
  inputSchema: z.object({}),
2181
2493
  },
2182
- safeHandler(async () => {
2183
- log('tool:verify_integrity');
2184
- return await controller.verifyIntegrity();
2185
- })
2494
+ safeHandler(
2495
+ async () => {
2496
+ log('tool:verify_integrity');
2497
+ return await controller.verifyIntegrity();
2498
+ },
2499
+ { maxRetries: 1 }
2500
+ )
2186
2501
  );
2187
2502
 
2188
2503
  server.registerTool(
@@ -2196,28 +2511,64 @@ server.registerTool(
2196
2511
  since: z.number().optional().describe('Filter by timestamp >= since'),
2197
2512
  }),
2198
2513
  },
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
- })
2514
+ safeHandler(
2515
+ async ({ limit, offset, phase, since }) => {
2516
+ log('tool:get_audit', { limit, offset, phase, since });
2517
+ return await controller.getAudit({ limit, offset, phase, since });
2518
+ },
2519
+ { maxRetries: 1 }
2520
+ )
2203
2521
  );
2204
2522
 
2205
2523
  server.registerTool(
2206
2524
  'get_metrics',
2207
2525
  {
2208
- description: 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
2526
+ description:
2527
+ 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
2528
+ inputSchema: z.object({}),
2529
+ },
2530
+ safeHandler(
2531
+ async () => {
2532
+ log('tool:get_metrics');
2533
+ return await controller.getMetrics();
2534
+ },
2535
+ { maxRetries: 1 }
2536
+ )
2537
+ );
2538
+
2539
+ server.registerTool(
2540
+ 'record_cache_hit',
2541
+ {
2542
+ description:
2543
+ 'Record a CodeGraph cache hit — increments cacheHitCount and tokenSavingEstimate. Call after reusing getCachedCodegraph result.',
2544
+ inputSchema: z.object({
2545
+ tokensSaved: z.number().optional().describe('Estimated tokens saved (default 500)'),
2546
+ }),
2547
+ },
2548
+ safeHandler(async ({ tokensSaved }) => {
2549
+ log('tool:record_cache_hit', { tokensSaved });
2550
+ return await controller.recordCacheHit({ tokensSaved });
2551
+ })
2552
+ );
2553
+
2554
+ server.registerTool(
2555
+ 'record_cache_miss',
2556
+ {
2557
+ description:
2558
+ 'Record a CodeGraph cache miss — increments cacheMissCount. Call after getCachedCodegraph returns null.',
2209
2559
  inputSchema: z.object({}),
2210
2560
  },
2211
2561
  safeHandler(async () => {
2212
- log('tool:get_metrics');
2213
- return await controller.getMetrics();
2562
+ log('tool:record_cache_miss');
2563
+ return await controller.recordCacheMiss();
2214
2564
  })
2215
2565
  );
2216
2566
 
2217
2567
  server.registerTool(
2218
2568
  'record_user_confirmation',
2219
2569
  {
2220
- description: 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
2570
+ description:
2571
+ 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
2221
2572
  inputSchema: z.object({
2222
2573
  decisionId: z.string().describe('Decision ID from pending state'),
2223
2574
  confirmationText: z.string().describe('Literal user confirmation text'),
@@ -2232,7 +2583,8 @@ server.registerTool(
2232
2583
  server.registerTool(
2233
2584
  'check_file_access',
2234
2585
  {
2235
- description: 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
2586
+ description:
2587
+ 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
2236
2588
  inputSchema: z.object({
2237
2589
  filePath: z.string().describe('File path to check'),
2238
2590
  reason: z.string().optional().describe('Reason for access'),
@@ -2293,22 +2645,25 @@ server.registerTool(
2293
2645
  'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
2294
2646
  inputSchema: z.object({}),
2295
2647
  },
2296
- safeHandler(async () => {
2297
- const state = await controller.getState();
2298
- const metrics = await controller.getMetrics().catch(() => ({}));
2299
- return {
2300
- pong: true,
2301
- degraded: controller.degraded,
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,
2310
- };
2311
- })
2648
+ safeHandler(
2649
+ async () => {
2650
+ const state = await controller.getState();
2651
+ const metrics = await controller.getMetrics().catch(() => ({}));
2652
+ return {
2653
+ pong: true,
2654
+ degraded: controller.degraded,
2655
+ state: {
2656
+ state: state.state,
2657
+ revision: state.revision,
2658
+ requestId: state.requestId,
2659
+ },
2660
+ diskFreeMB: metrics.diskFreeMB ?? null,
2661
+ stateFileSize: metrics.stateFileSize ?? null,
2662
+ auditSize: metrics.auditSize ?? null,
2663
+ };
2664
+ },
2665
+ { maxRetries: 1 }
2666
+ )
2312
2667
  );
2313
2668
 
2314
2669
  server.registerTool(
@@ -2317,9 +2672,12 @@ server.registerTool(
2317
2672
  description: 'Get the current controller state (reads persistent store).',
2318
2673
  inputSchema: z.object({}),
2319
2674
  },
2320
- safeHandler(async () => {
2321
- return await controller.getState();
2322
- })
2675
+ safeHandler(
2676
+ async () => {
2677
+ return await controller.getState();
2678
+ },
2679
+ { maxRetries: 1 }
2680
+ )
2323
2681
  );
2324
2682
 
2325
2683
  server.registerTool(
@@ -2328,9 +2686,12 @@ server.registerTool(
2328
2686
  description: 'Get current task states.',
2329
2687
  inputSchema: z.object({}),
2330
2688
  },
2331
- safeHandler(async () => {
2332
- return await controller.getTasks();
2333
- })
2689
+ safeHandler(
2690
+ async () => {
2691
+ return await controller.getTasks();
2692
+ },
2693
+ { maxRetries: 1 }
2694
+ )
2334
2695
  );
2335
2696
 
2336
2697
  server.registerTool(
@@ -2339,9 +2700,12 @@ server.registerTool(
2339
2700
  description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
2340
2701
  inputSchema: z.object({}),
2341
2702
  },
2342
- safeHandler(async () => {
2343
- return await controller.getAvailableTransitions();
2344
- })
2703
+ safeHandler(
2704
+ async () => {
2705
+ return await controller.getAvailableTransitions();
2706
+ },
2707
+ { maxRetries: 1 }
2708
+ )
2345
2709
  );
2346
2710
 
2347
2711
  server.registerTool(
@@ -2366,9 +2730,12 @@ server.registerTool(
2366
2730
  description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
2367
2731
  inputSchema: z.object({}),
2368
2732
  },
2369
- safeHandler(async () => {
2370
- return await controller.getHandoff();
2371
- })
2733
+ safeHandler(
2734
+ async () => {
2735
+ return await controller.getHandoff();
2736
+ },
2737
+ { maxRetries: 1 }
2738
+ )
2372
2739
  );
2373
2740
 
2374
2741
  server.registerTool(
@@ -2393,20 +2760,23 @@ server.registerTool(
2393
2760
  'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
2394
2761
  inputSchema: z.object({}),
2395
2762
  },
2396
- safeHandler(async () => {
2397
- const state = await controller.getState();
2398
- const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
2399
- if (pendingStates.includes(state.state)) {
2400
- return {
2401
- status: 'BLOCKED',
2402
- state: state.state,
2403
- revision: state.revision,
2404
- reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
2405
- degraded: controller.degraded,
2406
- };
2407
- }
2408
- return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
2409
- })
2763
+ safeHandler(
2764
+ async () => {
2765
+ const state = await controller.getState();
2766
+ const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
2767
+ if (pendingStates.includes(state.state)) {
2768
+ return {
2769
+ status: 'BLOCKED',
2770
+ state: state.state,
2771
+ revision: state.revision,
2772
+ reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
2773
+ degraded: controller.degraded,
2774
+ };
2775
+ }
2776
+ return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
2777
+ },
2778
+ { maxRetries: 1 }
2779
+ )
2410
2780
  );
2411
2781
 
2412
2782
  server.registerTool(
@@ -2501,7 +2871,7 @@ function setupGracefulShutdown(ctrl) {
2501
2871
  }
2502
2872
 
2503
2873
  async function main() {
2504
- log('Starting ostacky-controller MCP v0.7.3...');
2874
+ log('Starting ostacky-controller MCP v0.7.4...');
2505
2875
  log('State path:', { path: statePath });
2506
2876
  // Clean up stale tmp/lock files from previous runs
2507
2877
  cleanupTmpFiles(statePath);