ostacky 0.7.3 → 0.8.0

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: [
@@ -115,14 +118,10 @@ const TRANSITIONS = {
115
118
  { via: 'abandon', to: 'BLOCKED' },
116
119
  ],
117
120
  DISCOVERY: [
118
- { via: 'record_discovery', to: 'LEVEL_RESOLVED' },
121
+ { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
119
122
  { via: 'block', to: 'BLOCKED' },
120
123
  { via: 'abandon', to: 'BLOCKED' },
121
124
  ],
122
- LEVEL_RESOLVED: [
123
- { via: 'proceed_to_route', to: 'ROUTE_DECISION_PENDING' },
124
- { via: 'block', to: 'BLOCKED' },
125
- ],
126
125
  ROUTE_DECISION_PENDING: [
127
126
  { via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
128
127
  { via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
@@ -221,7 +220,9 @@ function redactForLog(data) {
221
220
  return copy;
222
221
  }
223
222
  return data;
224
- } catch { return data; }
223
+ } catch {
224
+ return data;
225
+ }
225
226
  }
226
227
 
227
228
  function log(eventOrLevel, maybeEventOrData, maybeData) {
@@ -234,10 +235,15 @@ function log(eventOrLevel, maybeEventOrData, maybeData) {
234
235
  data = maybeData;
235
236
  } else {
236
237
  // 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'; }
238
+ if (event.startsWith('warn:')) {
239
+ level = 'warn';
240
+ } else if (event.startsWith('error:')) {
241
+ level = 'error';
242
+ } else if (event.startsWith('info:')) {
243
+ level = 'info';
244
+ } else if (event.startsWith('degraded_')) {
245
+ level = 'warn';
246
+ }
241
247
  }
242
248
  const ts = new Date().toISOString();
243
249
  const safeData = redactForLog(data);
@@ -319,7 +325,6 @@ const STATES = Object.freeze({
319
325
  INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
320
326
  CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
321
327
  DISCOVERY: 'DISCOVERY',
322
- LEVEL_RESOLVED: 'LEVEL_RESOLVED',
323
328
  ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
324
329
  SPECIFICATION: 'SPECIFICATION',
325
330
  EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
@@ -331,6 +336,14 @@ const STATES = Object.freeze({
331
336
  BLOCKED: 'BLOCKED',
332
337
  });
333
338
 
339
+ // States where start_request should reset (not resume) when force=false
340
+ const TERMINAL_STATES = Object.freeze([
341
+ STATES.INTERPRETATION_PENDING,
342
+ STATES.CLARIFICATION_PENDING,
343
+ STATES.BLOCKED,
344
+ STATES.DONE,
345
+ ]);
346
+
334
347
  const DEFAULT_STATE = Object.freeze({
335
348
  state: STATES.INTERPRETATION_PENDING,
336
349
  revision: 0,
@@ -354,10 +367,27 @@ const DEFAULT_STATE = Object.freeze({
354
367
  stateOversizedCount: 0, // 2.3
355
368
  codegraphBypassCount: 0, // 6.3 / 3.1
356
369
  degradedEditsCount: 0, // 8.5
370
+ cacheHitCount: 0, // 5.4 hardening-v2
371
+ cacheMissCount: 0,
372
+ tokenSavingEstimate: 0,
373
+ discoveryCacheHitCount: 0, // mejora-acciones-controller F2
374
+ redundantCallCount: 0,
375
+ cacheMissWithoutPutCount: 0,
376
+ stateCheckCount: 0,
377
+ toolCallCount: 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;
@@ -638,10 +734,16 @@ class OstackyController {
638
734
  const redactRecursively = (obj) => {
639
735
  if (!obj || typeof obj !== 'object') return;
640
736
  for (const k of Object.keys(obj)) {
737
+ if (k === 'tokenSavingEstimate') {
738
+ if (typeof obj[k] === 'object') redactRecursively(obj[k]);
739
+ continue;
740
+ }
641
741
  if (SENSITIVE_REDACT_RE.test(k)) {
642
742
  obj[k] = '[REDACTED]';
643
743
  } 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]');
744
+ obj[k] = obj[k]
745
+ .replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
746
+ .replace(/sk-[a-zA-Z0-9_-]+/g, '[REDACTED]');
645
747
  if (SENSITIVE_REDACT_RE.test(obj[k])) obj[k] = '[REDACTED]';
646
748
  } else if (typeof obj[k] === 'object') {
647
749
  redactRecursively(obj[k]);
@@ -652,7 +754,9 @@ class OstackyController {
652
754
  if (copy.snapshots) redactRecursively(copy.snapshots);
653
755
  if (copy.audit) copy.audit.forEach(redactRecursively);
654
756
  return copy;
655
- } catch { return this.#state; }
757
+ } catch {
758
+ return this.#state;
759
+ }
656
760
  })();
657
761
  let serialized = safeJsonStringify(stateForSerialize, true);
658
762
  if (serialized.length > MAX_STATE_FILE_SIZE) {
@@ -674,8 +778,12 @@ class OstackyController {
674
778
  await renameAsync(tmp, this.#statePath);
675
779
  // 2.1: backup rotativo 3 niveles best-effort
676
780
  try {
677
- try { renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2'); } catch {}
678
- try { renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1'); } catch {}
781
+ try {
782
+ renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2');
783
+ } catch {}
784
+ try {
785
+ renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1');
786
+ } catch {}
679
787
  } catch {}
680
788
  try {
681
789
  const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
@@ -736,7 +844,11 @@ class OstackyController {
736
844
  return db.localeCompare(da);
737
845
  });
738
846
  this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
739
- log('warn:tasks_trimmed', { before: entries.length, after: limit, preservedExpected: expectedEntries.length });
847
+ log('warn:tasks_trimmed', {
848
+ before: entries.length,
849
+ after: limit,
850
+ preservedExpected: expectedEntries.length,
851
+ });
740
852
  return;
741
853
  }
742
854
  const sortedExpected = [...expectedEntries].sort((a, b) => {
@@ -748,7 +860,10 @@ class OstackyController {
748
860
  if (needToArchive > 0) {
749
861
  for (let i = 0; i < Math.min(needToArchive, sortedExpected.length); i++) {
750
862
  const [taskId] = sortedExpected[i];
751
- log('info:task_archived_to_engram', { taskId, topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}` });
863
+ log('info:task_archived_to_engram', {
864
+ taskId,
865
+ topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}`,
866
+ });
752
867
  }
753
868
  sortedExpected.sort((a, b) => {
754
869
  const da = a[1].completedAt || '';
@@ -789,7 +904,10 @@ class OstackyController {
789
904
  if (redactedReasoning && SENSITIVE_REDACT_RE.test(redactedReasoning)) {
790
905
  redactedReasoning = redactedReasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
791
906
  // also redact values after = if present
792
- redactedReasoning = redactedReasoning.replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
907
+ redactedReasoning = redactedReasoning.replace(
908
+ /(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi,
909
+ '$1=[REDACTED]'
910
+ );
793
911
  }
794
912
  const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
795
913
  this.#auditBuffer.push({
@@ -861,7 +979,6 @@ class OstackyController {
861
979
  INTERPRETATION_PENDING: 'Call start_request or proceed_to_discovery first.',
862
980
  CLARIFICATION_PENDING: 'Answer the clarification question, then call record_clarification.',
863
981
  DISCOVERY: 'Call record_discovery with a level classification.',
864
- LEVEL_RESOLVED: 'Call proceed_to_route to move to route decision.',
865
982
  ROUTE_DECISION_PENDING: 'Call consume_route_decision with SPEC or DIRECT.',
866
983
  SPECIFICATION: 'Call spec_complete when specification is done.',
867
984
  EXECUTION_ANALYSIS: 'Call record_execution_analysis with a snapshot.',
@@ -881,7 +998,9 @@ class OstackyController {
881
998
  if (this.#state) this.#state.degraded = true;
882
999
  log('degraded_mode_activated', { reason, state: this.#state?.state });
883
1000
  if (this.#state && this.#statePath) {
884
- try { this.#persist().catch(() => {}); } catch {}
1001
+ try {
1002
+ this.#persist().catch(() => {});
1003
+ } catch {}
885
1004
  }
886
1005
  }
887
1006
 
@@ -892,17 +1011,33 @@ class OstackyController {
892
1011
  if (this.#state) this.#state.degraded = false;
893
1012
  log('degraded_mode_exited', { state: this.#state?.state });
894
1013
  if (this.#state && this.#statePath) {
895
- try { this.#persist().catch(() => {}); } catch {}
1014
+ try {
1015
+ this.#persist().catch(() => {});
1016
+ } catch {}
896
1017
  }
897
1018
  }
898
1019
 
899
1020
  // --- Core transitions ---
900
1021
 
901
- async startRequest({ requestId, changeId } = {}) {
1022
+ async startRequest({ requestId, changeId, force = false } = {}) {
902
1023
  this.#load();
903
- if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
904
- return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
1024
+
1025
+ // If not forcing and current state is active (not terminal), resume instead of reset
1026
+ if (!force && !TERMINAL_STATES.includes(this.#state.state) && this.#state.requestId) {
1027
+ await this.#audit(
1028
+ this.#state.state,
1029
+ 'start_request',
1030
+ `resumed from ${this.#state.state}, requestId=${this.#state.requestId}`
1031
+ );
1032
+ return {
1033
+ state: this.#state.state,
1034
+ revision: this.#state.revision,
1035
+ requestId: this.#state.requestId,
1036
+ continued: true,
1037
+ };
905
1038
  }
1039
+
1040
+ // Force reset or terminal state: create new session
906
1041
  await this.#transition('INTERPRETATION_PENDING', {
907
1042
  requestId: requestId || 'req-' + Date.now(),
908
1043
  changeId: changeId || null,
@@ -917,8 +1052,17 @@ class OstackyController {
917
1052
  expectedTaskCount: null,
918
1053
  error: null,
919
1054
  });
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 };
1055
+ await this.#audit(
1056
+ 'INTERPRETATION_PENDING',
1057
+ 'start_request',
1058
+ `requestId=${this.#state.requestId}${force ? ' (forced)' : ''}`
1059
+ );
1060
+ return {
1061
+ state: this.#state.state,
1062
+ revision: this.#state.revision,
1063
+ requestId: this.#state.requestId,
1064
+ continued: false,
1065
+ };
922
1066
  }
923
1067
 
924
1068
  async requestClarification({ question } = {}) {
@@ -1026,12 +1170,16 @@ class OstackyController {
1026
1170
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
1027
1171
  lastProposal,
1028
1172
  });
1029
- await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
1173
+ await this.#audit('ROUTE_DECISION_PENDING', 'record_discovery', `level=${level}, default=${defaultChoice}`);
1030
1174
  // 8.2: reasoning sin plan → WARN
1031
1175
  if (!shownToUser && !isTrivial) {
1032
1176
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1033
1177
  log('warn:proposal_without_transparent_plan', { level, auditId });
1034
- await this.#audit('WARN', 'proposal_without_transparent_plan', `level=${level} reasoning missing files/estLines`);
1178
+ await this.#audit(
1179
+ 'WARN',
1180
+ 'proposal_without_transparent_plan',
1181
+ `level=${level} reasoning missing files/estLines`
1182
+ );
1035
1183
  this.#state.lastProposal.shownToUser = false;
1036
1184
  await this.#persist();
1037
1185
  const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
@@ -1064,6 +1212,29 @@ class OstackyController {
1064
1212
  auditId: lastAudit?.id || auditId,
1065
1213
  };
1066
1214
  }
1215
+ // Router determinista: 1+ exige Alternatives si estLines>30||fileCount>2||hasAPI, sino downgrade
1216
+ const fileCount = proposalFiles.length;
1217
+ const hasAPI = !!(snapshot?.hasAPI || snapshot?.reasoning?.hasAPI || snapshot?.hasExplicitContract);
1218
+ const estLinesVal = estLines || snapshot?.estLines || snapshot?.reasoning?.estLines || 0;
1219
+ const isOnePlus = level === '1+';
1220
+ const needsBrainstorming = isOnePlus && (estLinesVal > 30 || fileCount > 2 || hasAPI);
1221
+ const isDowngradeable = isOnePlus && estLinesVal < 30 && fileCount === 1 && !hasAPI;
1222
+ if (isOnePlus && needsBrainstorming) {
1223
+ // mark that Alternatives required — will be checked in openspec-propose gate
1224
+ this.#state._routerNeedsAlternatives = true;
1225
+ this.#state._routerDowngradeSuggested = false;
1226
+ log('info:router_brainstorming_required', { level, estLines: estLinesVal, fileCount, hasAPI });
1227
+ } else if (isDowngradeable) {
1228
+ this.#state._routerNeedsAlternatives = false;
1229
+ this.#state._routerDowngradeSuggested = true;
1230
+ log('info:router_downgrade_to_direct', { level, estLines: estLinesVal, fileCount, hasAPI });
1231
+ // override defaultChoice to DIRECT for downgradeable
1232
+ // keep stored defaultChoice as DIRECT already, but hint downgrade
1233
+ } else {
1234
+ this.#state._routerNeedsAlternatives = false;
1235
+ this.#state._routerDowngradeSuggested = false;
1236
+ }
1237
+ await this.#persist();
1067
1238
  // 8.6: Bypass solo para CI
1068
1239
  if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'ROUTE_DECISION_PENDING') {
1069
1240
  await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume ${defaultChoice} for CI`);
@@ -1071,7 +1242,14 @@ class OstackyController {
1071
1242
  if (autoTo) {
1072
1243
  await this.#transition(autoTo, { routeChoice: defaultChoice });
1073
1244
  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 };
1245
+ return {
1246
+ state: this.#state.state,
1247
+ revision: this.#state.revision,
1248
+ level,
1249
+ routeDecisionId: this.#state.routeDecisionId,
1250
+ defaultChoice,
1251
+ autoConfirmed: true,
1252
+ };
1075
1253
  }
1076
1254
  }
1077
1255
  return {
@@ -1080,16 +1258,28 @@ class OstackyController {
1080
1258
  level,
1081
1259
  routeDecisionId: this.#state.routeDecisionId,
1082
1260
  defaultChoice,
1261
+ routerNeedsAlternatives: this.#state._routerNeedsAlternatives || false,
1262
+ routerDowngradeSuggested: this.#state._routerDowngradeSuggested || false,
1083
1263
  };
1084
1264
  }
1085
1265
 
1086
1266
  async proceedToRoute() {
1087
1267
  this.#load();
1268
+ // deprecated alias: if already ROUTE_DECISION_PENDING, return no-op deprecated
1269
+ if (this.#state.state === 'ROUTE_DECISION_PENDING') {
1270
+ await this.#audit('WARN', 'proceed_to_route', 'deprecated: already in ROUTE_DECISION_PENDING');
1271
+ return {
1272
+ state: this.#state.state,
1273
+ revision: this.#state.revision,
1274
+ deprecated: true,
1275
+ warning: 'proceed_to_route deprecated, use record_discovery directly',
1276
+ };
1277
+ }
1088
1278
  const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
1089
1279
  if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
1090
1280
  await this.#transition(to);
1091
1281
  await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
1092
- return { state: this.#state.state, revision: this.#state.revision };
1282
+ return { state: this.#state.state, revision: this.#state.revision, deprecated: true };
1093
1283
  }
1094
1284
 
1095
1285
  async abandon({ reason } = {}) {
@@ -1155,10 +1345,16 @@ class OstackyController {
1155
1345
  const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
1156
1346
  const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
1157
1347
  if (!hasExpectedIds && !hasTaskIds && !hasCount) {
1158
- return this.#makeError('Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0', 'record_execution_analysis');
1348
+ return this.#makeError(
1349
+ 'Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0',
1350
+ 'record_execution_analysis'
1351
+ );
1159
1352
  }
1160
1353
  if (!hasExpectedIds && !hasTaskIds) {
1161
- return this.#makeError('Snapshot missing expectedTaskIds or taskIds when taskCount>0', 'record_execution_analysis');
1354
+ return this.#makeError(
1355
+ 'Snapshot missing expectedTaskIds or taskIds when taskCount>0',
1356
+ 'record_execution_analysis'
1357
+ );
1162
1358
  }
1163
1359
  }
1164
1360
  // C2: capture expected tasks for gate
@@ -1169,7 +1365,12 @@ class OstackyController {
1169
1365
  let execShown = false;
1170
1366
  let execFiles = [];
1171
1367
  let execEst = 0;
1172
- if (snapshot?.reasoning && typeof snapshot.reasoning === 'object' && Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
1368
+ if (
1369
+ snapshot?.reasoning &&
1370
+ typeof snapshot.reasoning === 'object' &&
1371
+ Array.isArray(snapshot.reasoning.files) &&
1372
+ typeof snapshot.reasoning.estLines === 'number'
1373
+ ) {
1173
1374
  execShown = true;
1174
1375
  execFiles = snapshot.reasoning.files;
1175
1376
  execEst = snapshot.reasoning.estLines;
@@ -1207,17 +1408,30 @@ class OstackyController {
1207
1408
  // Only warn if snapshot was expected to have reasoning (taskCount>2 or not early-exit)
1208
1409
  const auditId2 = `aud-${Date.now()}-${this.#state.auditSeq}`;
1209
1410
  log('warn:proposal_without_transparent_plan', { auditId: auditId2 });
1210
- await this.#audit('WARN', 'proposal_without_transparent_plan', 'execution reasoning missing files/estLines');
1411
+ await this.#audit(
1412
+ 'WARN',
1413
+ 'proposal_without_transparent_plan',
1414
+ 'execution reasoning missing files/estLines'
1415
+ );
1211
1416
  this.#state.lastProposal.shownToUser = false;
1212
1417
  await this.#persist();
1213
1418
  }
1214
1419
  // C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
1215
1420
  // 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
1216
- const hasEvidence =
1421
+ // mejora-acciones-controller F3: discovery-cache counts as valid evidence if discovery snapshot exists
1422
+ const isDiscoveryCacheEvidence =
1217
1423
  snapshot &&
1218
1424
  Array.isArray(snapshot.codegraphUsed) &&
1219
- snapshot.codegraphUsed.length > 0 &&
1220
- snapshot.recommendation != null;
1425
+ snapshot.codegraphUsed.includes('discovery-cache') &&
1426
+ this.#state.snapshots.codegraph != null &&
1427
+ Array.isArray(snapshot.expectedTaskIds) &&
1428
+ snapshot.expectedTaskIds.length > 0;
1429
+ const hasEvidence =
1430
+ (snapshot &&
1431
+ Array.isArray(snapshot.codegraphUsed) &&
1432
+ snapshot.codegraphUsed.length > 0 &&
1433
+ snapshot.recommendation != null) ||
1434
+ isDiscoveryCacheEvidence;
1221
1435
  if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
1222
1436
  this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
1223
1437
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
@@ -1233,14 +1447,26 @@ class OstackyController {
1233
1447
  };
1234
1448
  }
1235
1449
  // 8.6: Bypass solo para CI
1236
- if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'EXECUTION_DECISION_PENDING') {
1450
+ if (
1451
+ process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' &&
1452
+ this.#state.state === 'EXECUTION_DECISION_PENDING'
1453
+ ) {
1237
1454
  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';
1455
+ const defaultMode =
1456
+ snapshot?.recommendation && ['INLINE', 'SUBAGENT_DRIVEN'].includes(snapshot.recommendation)
1457
+ ? snapshot.recommendation
1458
+ : 'INLINE';
1239
1459
  const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', defaultMode);
1240
1460
  if (autoTo) {
1241
1461
  await this.#transition(autoTo, { executionMode: defaultMode });
1242
1462
  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 };
1463
+ return {
1464
+ state: this.#state.state,
1465
+ revision: this.#state.revision,
1466
+ executionDecisionId: this.#state.executionDecisionId,
1467
+ executionMode: defaultMode,
1468
+ autoConfirmed: true,
1469
+ };
1244
1470
  }
1245
1471
  }
1246
1472
  return {
@@ -1368,13 +1594,19 @@ class OstackyController {
1368
1594
  await this.#transition(to, { error: reason || 'Blocked' });
1369
1595
  await this.#audit('BLOCKED', 'block', reason || 'no reason');
1370
1596
  if (isExecuting) {
1371
- await this.#audit('WARN', 'block_from_executing', `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`);
1597
+ await this.#audit(
1598
+ 'WARN',
1599
+ 'block_from_executing',
1600
+ `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`
1601
+ );
1372
1602
  }
1373
1603
  // 10.6: increment subagentFailedCount if block reason indicates subagent failure
1374
1604
  if (reason && /subagent.*failed/i.test(reason)) {
1375
1605
  this.#state.subagentFailedCount = (this.#state.subagentFailedCount || 0) + 1;
1376
1606
  await this.#audit('WARN', 'subagent_failed', reason);
1377
- try { await this.#persist(); } catch {}
1607
+ try {
1608
+ await this.#persist();
1609
+ } catch {}
1378
1610
  }
1379
1611
  return { state: this.#state.state, revision: this.#state.revision };
1380
1612
  }
@@ -1383,7 +1615,10 @@ class OstackyController {
1383
1615
  this.#load();
1384
1616
  // 1.9: replan desde EXECUTING_* rechazado sin limpiar tasks
1385
1617
  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');
1618
+ return this.#makeError(
1619
+ `Cannot replan from state ${this.#state.state} — replan only from BLOCKED`,
1620
+ 'replan'
1621
+ );
1387
1622
  }
1388
1623
  const to = this.#isAllowedTransition(this.#state.state, 'replan');
1389
1624
  if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
@@ -1505,16 +1740,23 @@ class OstackyController {
1505
1740
  const completed = Object.values(this.#state.tasks || {}).filter((t) => t.status === 'COMPLETED').length;
1506
1741
  const total = Object.keys(this.#state.tasks || {}).length;
1507
1742
  const pending = Array.isArray(this.#state.expectedTasks)
1508
- ? this.#state.expectedTasks.filter((id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED').length
1743
+ ? this.#state.expectedTasks.filter(
1744
+ (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1745
+ ).length
1509
1746
  : typeof this.#state.expectedTaskCount === 'number'
1510
- ? Math.max(0, this.#state.expectedTaskCount - completed)
1511
- : 0;
1747
+ ? Math.max(0, this.#state.expectedTaskCount - completed)
1748
+ : 0;
1512
1749
  return {
1513
1750
  revision: this.#state.revision,
1514
1751
  state: this.#state.state,
1515
1752
  degraded: this.#degraded || !!this.#state.degraded,
1516
1753
  consecutiveFailures: this.#consecutiveFailures,
1517
- taskCounts: { completed, pending, total, expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null },
1754
+ taskCounts: {
1755
+ completed,
1756
+ pending,
1757
+ total,
1758
+ expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
1759
+ },
1518
1760
  expectedTaskCount: this.#state.expectedTaskCount,
1519
1761
  auditSize,
1520
1762
  stateFileSize,
@@ -1523,6 +1765,9 @@ class OstackyController {
1523
1765
  stateOversizedCount: this.#state.stateOversizedCount || 0,
1524
1766
  codegraphBypassCount: this.#state.codegraphBypassCount || 0,
1525
1767
  degradedEditsCount: this.#state.degradedEditsCount || 0,
1768
+ cacheHitCount: this.#state.cacheHitCount || 0,
1769
+ cacheMissCount: this.#state.cacheMissCount || 0,
1770
+ tokenSavingEstimate: this.#state.tokenSavingEstimate || 0,
1526
1771
  sensitiveAccess: this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 },
1527
1772
  subagentFailedCount: this.#state.subagentFailedCount || 0,
1528
1773
  staleContentAttempts: this.#state.staleContentAttempts || 0,
@@ -1530,6 +1775,11 @@ class OstackyController {
1530
1775
  toolTimeoutCount: this.#state.toolTimeoutCount || 0,
1531
1776
  lastToolDurationMs: this.#state.lastToolDurationMs || 0,
1532
1777
  stateDurationMs: this.#state.stateDurationMs || 0,
1778
+ discoveryCacheHitCount: this.#state.discoveryCacheHitCount || 0,
1779
+ redundantCallCount: this.#state.redundantCallCount || 0,
1780
+ cacheMissWithoutPutCount: this.#state.cacheMissWithoutPutCount || 0,
1781
+ stateCheckCount: this.#state.stateCheckCount || 0,
1782
+ toolCallCount: this.#state.toolCallCount || 0,
1533
1783
  };
1534
1784
  }
1535
1785
 
@@ -1537,14 +1787,43 @@ class OstackyController {
1537
1787
  this.#load();
1538
1788
  this.#state.toolTimeoutCount = (this.#state.toolTimeoutCount || 0) + 1;
1539
1789
  this.#state.lastToolDurationMs = 5000;
1540
- try { await this.#persist(); } catch {}
1790
+ try {
1791
+ await this.#persist();
1792
+ } catch {}
1541
1793
  }
1542
1794
 
1543
1795
  async _recordToolDuration(ms) {
1544
1796
  this.#load();
1545
1797
  this.#state.lastToolDurationMs = ms;
1546
1798
  this.#state.stateDurationMs = Date.now() - (this.#state.ts || Date.now());
1547
- try { await this.#persist(); } catch {}
1799
+ try {
1800
+ await this.#persist();
1801
+ } catch {}
1802
+ }
1803
+
1804
+ // --- 5.4 hardening-v2: cache metrics (token efficiency) ---
1805
+ async recordCacheHit({ tokensSaved = 500 } = {}) {
1806
+ this.#load();
1807
+ this.#state.cacheHitCount = (this.#state.cacheHitCount || 0) + 1;
1808
+ const saved = typeof tokensSaved === 'number' && tokensSaved > 0 ? tokensSaved : 500;
1809
+ this.#state.tokenSavingEstimate = (this.#state.tokenSavingEstimate || 0) + saved;
1810
+ try {
1811
+ await this.#persist();
1812
+ } catch {}
1813
+ return {
1814
+ ok: true,
1815
+ cacheHitCount: this.#state.cacheHitCount,
1816
+ tokenSavingEstimate: this.#state.tokenSavingEstimate,
1817
+ };
1818
+ }
1819
+
1820
+ async recordCacheMiss() {
1821
+ this.#load();
1822
+ this.#state.cacheMissCount = (this.#state.cacheMissCount || 0) + 1;
1823
+ try {
1824
+ await this.#persist();
1825
+ } catch {}
1826
+ return { ok: true, cacheMissCount: this.#state.cacheMissCount };
1548
1827
  }
1549
1828
 
1550
1829
  async recordUserConfirmation({ decisionId, confirmationText } = {}) {
@@ -1552,37 +1831,23 @@ class OstackyController {
1552
1831
  if (!decisionId || typeof confirmationText !== 'string') {
1553
1832
  return { error: 'decisionId and confirmationText required' };
1554
1833
  }
1555
- await this.#audit('CONFIRMATION', 'record_user_confirmation', `user confirmed: ${confirmationText} for ${decisionId}`);
1834
+ await this.#audit(
1835
+ 'CONFIRMATION',
1836
+ 'record_user_confirmation',
1837
+ `user confirmed: ${confirmationText} for ${decisionId}`
1838
+ );
1556
1839
  await this.#flushAudit(true);
1557
1840
  await this.#persist();
1558
1841
  return { ok: true, decisionId, confirmationText, ts: Date.now() };
1559
1842
  }
1560
1843
 
1561
- // --- D11: Credential guard helpers ---
1844
+ // --- D11: Credential guard helpers — source-of-truth is src/security.ts (hardening-v2 D1) ---
1562
1845
  isSensitiveFile(filePath) {
1563
1846
  if (!filePath) return false;
1564
1847
  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;
1848
+ const patterns =
1849
+ (this.#state && this.#state.sensitivePatterns) || DEFAULT_STATE.sensitivePatterns || SENSITIVE_DEFAULT;
1850
+ return isSensitive(filePath, patterns);
1586
1851
  }
1587
1852
 
1588
1853
  async checkFileAccess({ filePath, reason } = {}) {
@@ -1591,7 +1856,11 @@ class OstackyController {
1591
1856
  if (!this.isSensitiveFile(filePath)) return { allowed: true, reason: 'not sensitive' };
1592
1857
  if (this.#state.allowedFiles?.[filePath]) return { allowed: true, reason: 'previously allowed' };
1593
1858
  if (this.#state.deniedFiles?.[filePath]) {
1594
- return { error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`, denied: true, filePath };
1859
+ return {
1860
+ error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`,
1861
+ denied: true,
1862
+ filePath,
1863
+ };
1595
1864
  }
1596
1865
  const decisionId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1597
1866
  if (!this.#state.pendingFileAccess) this.#state.pendingFileAccess = {};
@@ -1606,7 +1875,8 @@ class OstackyController {
1606
1875
  async consumeFileAccessDecision({ decisionId, choice } = {}) {
1607
1876
  this.#load();
1608
1877
  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'] };
1878
+ if (!['ALLOW', 'DENY'].includes(choice))
1879
+ return { error: 'choice must be ALLOW or DENY', available: ['ALLOW', 'DENY'] };
1610
1880
  const pending = this.#state.pendingFileAccess?.[decisionId];
1611
1881
  let filePath = pending?.filePath;
1612
1882
  // fallback: if no pending, try to find by decisionId prefix? require filePath param alternative
@@ -1716,13 +1986,18 @@ class OstackyController {
1716
1986
  // 8.5: contar edits en degraded sin confirmación auditada
1717
1987
  if (this.#degraded) {
1718
1988
  this.#state.degradedEditsCount = (this.#state.degradedEditsCount || 0) + 1;
1719
- try { await this.#persist(); } catch {}
1989
+ try {
1990
+ await this.#persist();
1991
+ } catch {}
1720
1992
  }
1721
1993
  // 10.4: validación de frescura — content debe coincidir con disco si filePath dado
1722
1994
  if (filePath && typeof content === 'string') {
1723
1995
  try {
1724
1996
  const projectRoot = getProjectRoot(this.#statePath);
1725
- const absolutePath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath);
1997
+ const absolutePath =
1998
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1999
+ ? resolve(filePath)
2000
+ : resolve(projectRoot, filePath);
1726
2001
  const diskContent = readFileSync(absolutePath, 'utf8');
1727
2002
  if (diskContent !== content) {
1728
2003
  this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
@@ -1735,6 +2010,57 @@ class OstackyController {
1735
2010
  }
1736
2011
  }
1737
2012
  }
2013
+ // mejora-acciones-controller: hash: prefix alias for validate_edit without full content
2014
+ if (typeof content === 'string' && content.startsWith('hash:') && filePath) {
2015
+ const hashArg = content.slice(5);
2016
+ try {
2017
+ const projectRoot = getProjectRoot(this.#statePath);
2018
+ const absolutePath =
2019
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2020
+ ? resolve(filePath)
2021
+ : resolve(projectRoot, filePath);
2022
+ const currentHash = fastFingerprint(absolutePath);
2023
+ if (
2024
+ currentHash &&
2025
+ hashArg === currentHash &&
2026
+ this.#state.lastValidated?.filePath === filePath &&
2027
+ this.#state.lastValidated?.hash === currentHash
2028
+ ) {
2029
+ try {
2030
+ content = readFileSync(absolutePath, 'utf8');
2031
+ } catch {
2032
+ return { outcome: 'CONFLICT', reason: 'stale fingerprint', filePath };
2033
+ }
2034
+ } else {
2035
+ this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
2036
+ await this.#persist();
2037
+ return { outcome: 'CONFLICT', reason: 'stale fingerprint', filePath };
2038
+ }
2039
+ } catch (e) {
2040
+ return { outcome: 'CONFLICT', reason: 'hash validation failed' };
2041
+ }
2042
+ }
2043
+ // 5.3: optimization — si fastFingerprint no cambió, no re-enviar content completo
2044
+ if (
2045
+ (typeof content !== 'string' || content.length === 0) &&
2046
+ filePath &&
2047
+ this.#state.lastValidated?.filePath === filePath
2048
+ ) {
2049
+ try {
2050
+ const projectRoot = getProjectRoot(this.#statePath);
2051
+ const absolutePath =
2052
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2053
+ ? resolve(filePath)
2054
+ : resolve(projectRoot, filePath);
2055
+ const currentHash = fastFingerprint(absolutePath);
2056
+ if (currentHash && currentHash === this.#state.lastValidated.hash) {
2057
+ try {
2058
+ const diskContent = readFileSync(absolutePath, 'utf8');
2059
+ content = diskContent;
2060
+ } catch {}
2061
+ }
2062
+ } catch {}
2063
+ }
1738
2064
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
1739
2065
  return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
1740
2066
  }
@@ -1773,7 +2099,11 @@ class OstackyController {
1773
2099
  // 10.5: ligadura validate → complete
1774
2100
  try {
1775
2101
  const projectRoot = getProjectRoot(this.#statePath);
1776
- const absolutePath = filePath ? (filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath)) : null;
2102
+ const absolutePath = filePath
2103
+ ? filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2104
+ ? resolve(filePath)
2105
+ : resolve(projectRoot, filePath)
2106
+ : null;
1777
2107
  const hash = absolutePath ? fastFingerprint(absolutePath) : null;
1778
2108
  this.#state.lastValidated = { filePath: filePath || null, hash, ts: Date.now() };
1779
2109
  await this.#persist();
@@ -1813,7 +2143,11 @@ class OstackyController {
1813
2143
  // 10.5: ligadura validate → complete — WARN si no hubo validate previo
1814
2144
  if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
1815
2145
  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}`);
2146
+ await this.#audit(
2147
+ 'WARN',
2148
+ 'complete_without_validate',
2149
+ `complete_task without prior validate_edit for ${filePath || taskId}`
2150
+ );
1817
2151
  } else {
1818
2152
  this.#state.lastValidated = null;
1819
2153
  }
@@ -1841,11 +2175,17 @@ class OstackyController {
1841
2175
  )
1842
2176
  : [];
1843
2177
  const existing = this.#state.lastHandoff;
1844
- const isRecentManual = existing && (Date.now() - existing.ts < 60000) && existing.summary && !existing.summary.startsWith('Checkpoint auto');
2178
+ const isRecentManual =
2179
+ existing &&
2180
+ Date.now() - existing.ts < 60000 &&
2181
+ existing.summary &&
2182
+ !existing.summary.startsWith('Checkpoint auto');
1845
2183
  let shouldOverwrite = true;
1846
2184
  if (isRecentManual) {
1847
2185
  const existingPending = existing.pendingTasks || [];
1848
- const isDistinct = pendingForHandoff.length !== existingPending.length || pendingForHandoff.some((id) => !existingPending.includes(id));
2186
+ const isDistinct =
2187
+ pendingForHandoff.length !== existingPending.length ||
2188
+ pendingForHandoff.some((id) => !existingPending.includes(id));
1849
2189
  if (isDistinct && existingPending.length > 0) {
1850
2190
  shouldOverwrite = false;
1851
2191
  }
@@ -1879,7 +2219,8 @@ class OstackyController {
1879
2219
  if (!this.#state.audit) this.#state.audit = [];
1880
2220
  for (const e of this.#auditBuffer) {
1881
2221
  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]');
2222
+ if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning))
2223
+ e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
1883
2224
  }
1884
2225
  this.#state.audit.push(...this.#auditBuffer);
1885
2226
  const retention = getAuditRetentionSafe();
@@ -1894,8 +2235,12 @@ class OstackyController {
1894
2235
  const tsRaw = readFileSync(this.#lockHeartbeatPath, 'utf8');
1895
2236
  const age = Date.now() - parseInt(tsRaw, 10);
1896
2237
  if (!Number.isNaN(age) && age >= 15000) {
1897
- try { unlinkSync(this.#lockPidPath); } catch {}
1898
- try { unlinkSync(this.#lockHeartbeatPath); } catch {}
2238
+ try {
2239
+ unlinkSync(this.#lockPidPath);
2240
+ } catch {}
2241
+ try {
2242
+ unlinkSync(this.#lockHeartbeatPath);
2243
+ } catch {}
1899
2244
  this.#lockOwner = false;
1900
2245
  } else if (!Number.isNaN(age) && age < 15000) {
1901
2246
  try {
@@ -1912,15 +2257,22 @@ class OstackyController {
1912
2257
  const tsRaw2 = readFileSync(this.#lockHeartbeatPath, 'utf8');
1913
2258
  const age2 = Date.now() - parseInt(tsRaw2, 10);
1914
2259
  if (!Number.isNaN(age2) && age2 >= 15000) {
1915
- try { unlinkSync(this.#lockPidPath); } catch {}
1916
- try { unlinkSync(this.#lockHeartbeatPath); } catch {}
2260
+ try {
2261
+ unlinkSync(this.#lockPidPath);
2262
+ } catch {}
2263
+ try {
2264
+ unlinkSync(this.#lockHeartbeatPath);
2265
+ } catch {}
1917
2266
  writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1918
2267
  } else return;
1919
- } catch { return; }
2268
+ } catch {
2269
+ return;
2270
+ }
1920
2271
  } else throw e;
1921
2272
  }
1922
2273
  try {
1923
- writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
2274
+ writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
2275
+ this.#heartbeatLock();
1924
2276
  this.#lockOwner = true;
1925
2277
  } catch {}
1926
2278
  const serialized = safeJsonStringify(this.#state, true);
@@ -1941,67 +2293,107 @@ const controller = new OstackyController({ statePath });
1941
2293
  * Wraps an async tool handler to ALWAYS return a response (even on error).
1942
2294
  * Without this, an unhandled exception in any tool handler leaves the LLM
1943
2295
  * waiting forever — the root cause of agent freezes.
2296
+ * Supports configurable retry with exponential backoff for transient failures.
2297
+ * @param {Function} fn - The tool handler function
2298
+ * @param {Object} options - Retry options
2299
+ * @param {number} options.maxRetries - Maximum retry attempts (default: 0)
2300
+ * @param {number} options.baseTimeout - Base timeout in ms (default: 5000)
1944
2301
  */
1945
- function safeHandler(fn) {
2302
+ function safeHandler(fn, options = {}) {
2303
+ const { maxRetries = 0, baseTimeout = 5000 } = options;
2304
+
1946
2305
  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 {}
2306
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2307
+ const currentTimeout = baseTimeout * Math.pow(1.5, attempt);
2308
+ const start = Date.now();
2309
+ try {
2310
+ const result = await Promise.race([
2311
+ fn(params),
2312
+ new Promise((_, reject) =>
2313
+ setTimeout(() => reject(new Error(`timeout ${currentTimeout}ms`)), currentTimeout)
2314
+ ),
2315
+ ]);
2316
+ const duration = Date.now() - start;
2317
+ try {
2318
+ await controller._recordToolDuration(duration);
2319
+ } catch {}
2320
+ // Update heartbeat on successful completion
2321
+ controller.updateHeartbeat();
2322
+ return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
2323
+ } catch (error) {
2324
+ const isTimeout = error && error.message && error.message.includes('timeout');
2325
+ const isNetworkError =
2326
+ error && error.code && ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'].includes(error.code);
2327
+ const isRetryable = isTimeout || isNetworkError;
2328
+
2329
+ if (isRetryable && attempt < maxRetries) {
2330
+ const backoff = 200 * Math.pow(2, attempt);
2331
+ log('warn:tool_retry', {
2332
+ tool: fn.name || 'anonymous',
2333
+ attempt: attempt + 1,
2334
+ maxRetries,
2335
+ error: error.message,
2336
+ backoff,
2337
+ });
2338
+ await sleep(backoff);
2339
+ continue; // retry
2340
+ }
2341
+
2342
+ if (isTimeout) {
2343
+ log('warn:tool_timeout', { tool: fn.name || 'anonymous', durationMs: currentTimeout });
2344
+ try {
2345
+ await controller._recordToolTimeout();
2346
+ } catch {}
2347
+ return {
2348
+ content: [
2349
+ {
2350
+ type: 'text',
2351
+ text: safeJsonStringify({ error: `timeout ${currentTimeout}ms`, degraded: true }),
2352
+ },
2353
+ ],
2354
+ isError: true,
2355
+ };
2356
+ }
2357
+
2358
+ log('tool:error', {
2359
+ name: fn.name || 'anonymous',
2360
+ error: error.message,
2361
+ stack: error.stack,
2362
+ });
1961
2363
  return {
1962
- content: [{ type: 'text', text: safeJsonStringify({ error: 'timeout 5s', degraded: true }) }],
2364
+ content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
1963
2365
  isError: true,
1964
2366
  };
1965
2367
  }
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
2368
  }
1976
2369
  };
1977
2370
  }
1978
2371
 
1979
2372
  const server = new McpServer({
1980
2373
  name: 'ostacky-controller',
1981
- version: '0.7.3',
2374
+ version: '0.8.0',
1982
2375
  });
1983
2376
 
1984
2377
  server.registerTool(
1985
2378
  'start_request',
1986
2379
  {
1987
- description:
1988
- 'Start or reset a new request. Can be called from ANY state — resets state machine. Call this first.',
2380
+ description: 'Start or resume request',
1989
2381
  inputSchema: z.object({
1990
2382
  requestId: z.string().optional().describe('Unique request ID'),
1991
2383
  changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
2384
+ force: z.boolean().optional().describe('Force reset even if work in progress (default: false)'),
1992
2385
  }),
1993
2386
  },
1994
- safeHandler(async ({ requestId, changeId }) => {
1995
- log('tool:start_request');
1996
- return await controller.startRequest({ requestId, changeId });
2387
+ safeHandler(async ({ requestId, changeId, force }) => {
2388
+ log('tool:start_request', { force: !!force });
2389
+ return await controller.startRequest({ requestId, changeId, force: !!force });
1997
2390
  })
1998
2391
  );
1999
2392
 
2000
2393
  server.registerTool(
2001
2394
  'request_clarification',
2002
2395
  {
2003
- description:
2004
- 'Pause execution to ask the user for clarification. Use when the request is too vague to classify. Transitions to CLARIFICATION_PENDING — you MUST stop and wait for user response.',
2396
+ description: 'Request clarification',
2005
2397
  inputSchema: z.object({
2006
2398
  question: z.string().optional().describe('The clarification question'),
2007
2399
  }),
@@ -2015,7 +2407,7 @@ server.registerTool(
2015
2407
  server.registerTool(
2016
2408
  'record_clarification',
2017
2409
  {
2018
- description: 'Record that clarification was answered. Transitions to DISCOVERY.',
2410
+ description: 'Record clarification',
2019
2411
  inputSchema: z.object({}),
2020
2412
  },
2021
2413
  safeHandler(async () => {
@@ -2027,8 +2419,7 @@ server.registerTool(
2027
2419
  server.registerTool(
2028
2420
  'record_discovery',
2029
2421
  {
2030
- description:
2031
- 'Record discovery complete with level classification. From INTERPRETATION_PENDING goes to ROUTE_DECISION_PENDING. From DISCOVERY goes to LEVEL_RESOLVED.',
2422
+ description: 'Record discovery (level)',
2032
2423
  inputSchema: z.object({
2033
2424
  level: z.enum(['0', '0+1', '1+']).describe('Impact level'),
2034
2425
  routeDecisionId: z.string().optional().describe('Unique route decision ID'),
@@ -2044,7 +2435,7 @@ server.registerTool(
2044
2435
  server.registerTool(
2045
2436
  'consume_route_decision',
2046
2437
  {
2047
- description: 'Consume the route decision (SPEC or DIRECT). Valid only in ROUTE_DECISION_PENDING.',
2438
+ description: 'Consume route decision (SPEC/DIRECT)',
2048
2439
  inputSchema: z.object({
2049
2440
  decisionId: z.string().describe('Route decision ID from record_discovery'),
2050
2441
  choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
@@ -2059,7 +2450,7 @@ server.registerTool(
2059
2450
  server.registerTool(
2060
2451
  'spec_complete',
2061
2452
  {
2062
- description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
2453
+ description: 'Spec complete',
2063
2454
  inputSchema: z.object({}),
2064
2455
  },
2065
2456
  safeHandler(async () => {
@@ -2071,7 +2462,7 @@ server.registerTool(
2071
2462
  server.registerTool(
2072
2463
  'record_execution_analysis',
2073
2464
  {
2074
- description: 'Record execution analysis with snapshot. Transitions to EXECUTION_DECISION_PENDING.',
2465
+ description: 'Record execution analysis',
2075
2466
  inputSchema: z.object({
2076
2467
  executionDecisionId: z.string().optional().describe('Unique execution decision ID'),
2077
2468
  snapshot: z.any().optional().describe('Execution analysis snapshot'),
@@ -2086,7 +2477,7 @@ server.registerTool(
2086
2477
  server.registerTool(
2087
2478
  'consume_execution_decision',
2088
2479
  {
2089
- description: 'Consume the execution mode decision (INLINE or SUBAGENT_DRIVEN).',
2480
+ description: 'Consume execution decision (INLINE/SUBAGENT)',
2090
2481
  inputSchema: z.object({
2091
2482
  decisionId: z.string().describe('Execution decision ID from record_execution_analysis'),
2092
2483
  mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
@@ -2119,7 +2510,7 @@ server.registerTool(
2119
2510
  server.registerTool(
2120
2511
  'sync_complete',
2121
2512
  {
2122
- description: 'Mark sync as complete. Transitions to DONE.',
2513
+ description: 'Sync complete',
2123
2514
  inputSchema: z.object({}),
2124
2515
  },
2125
2516
  safeHandler(async () => {
@@ -2131,7 +2522,7 @@ server.registerTool(
2131
2522
  server.registerTool(
2132
2523
  'block',
2133
2524
  {
2134
- description: 'Transition to BLOCKED state with an optional reason.',
2525
+ description: 'Block',
2135
2526
  inputSchema: z.object({
2136
2527
  reason: z.string().optional().describe('Reason for blocking'),
2137
2528
  }),
@@ -2145,7 +2536,7 @@ server.registerTool(
2145
2536
  server.registerTool(
2146
2537
  'replan',
2147
2538
  {
2148
- description: 'Replan from BLOCKED state back to INTERPRETATION_PENDING.',
2539
+ description: 'Replan',
2149
2540
  inputSchema: z.object({
2150
2541
  reason: z.string().optional().describe('Reason for replanning'),
2151
2542
  }),
@@ -2179,45 +2570,83 @@ server.registerTool(
2179
2570
  'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
2180
2571
  inputSchema: z.object({}),
2181
2572
  },
2182
- safeHandler(async () => {
2183
- log('tool:verify_integrity');
2184
- return await controller.verifyIntegrity();
2185
- })
2573
+ safeHandler(
2574
+ async () => {
2575
+ log('tool:verify_integrity');
2576
+ return await controller.verifyIntegrity();
2577
+ },
2578
+ { maxRetries: 1 }
2579
+ )
2186
2580
  );
2187
2581
 
2188
2582
  server.registerTool(
2189
2583
  'get_audit',
2190
2584
  {
2191
- description: 'Get recent audit entries paginated. Read-only, truncated to 300 chars with unique id per entry.',
2585
+ description: 'Get audit (paginated)',
2192
2586
  inputSchema: z.object({
2193
2587
  limit: z.number().optional().describe('Max entries (default 20)'),
2194
2588
  offset: z.number().optional().describe('Offset from end (default 0)'),
2195
- phase: z.string().optional().describe('Filter by phase (e.g. WARN, LEVEL_RESOLVED)'),
2589
+ phase: z.string().optional().describe('Filter by phase (e.g. WARN, ROUTE_DECISION_PENDING)'),
2196
2590
  since: z.number().optional().describe('Filter by timestamp >= since'),
2197
2591
  }),
2198
2592
  },
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
- })
2593
+ safeHandler(
2594
+ async ({ limit, offset, phase, since }) => {
2595
+ log('tool:get_audit', { limit, offset, phase, since });
2596
+ return await controller.getAudit({ limit, offset, phase, since });
2597
+ },
2598
+ { maxRetries: 1 }
2599
+ )
2203
2600
  );
2204
2601
 
2205
2602
  server.registerTool(
2206
2603
  'get_metrics',
2207
2604
  {
2208
- description: 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
2605
+ description: 'Get metrics',
2606
+ inputSchema: z.object({}),
2607
+ },
2608
+ safeHandler(
2609
+ async () => {
2610
+ log('tool:get_metrics');
2611
+ return await controller.getMetrics();
2612
+ },
2613
+ { maxRetries: 1 }
2614
+ )
2615
+ );
2616
+
2617
+ server.registerTool(
2618
+ 'record_cache_hit',
2619
+ {
2620
+ description: 'Deprecated: cache hit',
2621
+ inputSchema: z.object({
2622
+ tokensSaved: z.number().optional().describe('Estimated tokens saved (default 500)'),
2623
+ }),
2624
+ },
2625
+ safeHandler(async ({ tokensSaved }) => {
2626
+ log('tool:record_cache_hit', { tokensSaved, deprecated: true });
2627
+ const r = await controller.recordCacheHit({ tokensSaved });
2628
+ return { ...r, deprecated: true };
2629
+ })
2630
+ );
2631
+
2632
+ server.registerTool(
2633
+ 'record_cache_miss',
2634
+ {
2635
+ description: 'Deprecated: cache miss',
2209
2636
  inputSchema: z.object({}),
2210
2637
  },
2211
2638
  safeHandler(async () => {
2212
- log('tool:get_metrics');
2213
- return await controller.getMetrics();
2639
+ log('tool:record_cache_miss', { deprecated: true });
2640
+ const r = await controller.recordCacheMiss();
2641
+ return { ...r, deprecated: true };
2214
2642
  })
2215
2643
  );
2216
2644
 
2217
2645
  server.registerTool(
2218
2646
  'record_user_confirmation',
2219
2647
  {
2220
- description: 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
2648
+ description:
2649
+ 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
2221
2650
  inputSchema: z.object({
2222
2651
  decisionId: z.string().describe('Decision ID from pending state'),
2223
2652
  confirmationText: z.string().describe('Literal user confirmation text'),
@@ -2232,7 +2661,8 @@ server.registerTool(
2232
2661
  server.registerTool(
2233
2662
  'check_file_access',
2234
2663
  {
2235
- description: 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
2664
+ description:
2665
+ '[deprecated if plugin active] Check if file is sensitive and requires ALLOW. plugin enforces when active.',
2236
2666
  inputSchema: z.object({
2237
2667
  filePath: z.string().describe('File path to check'),
2238
2668
  reason: z.string().optional().describe('Reason for access'),
@@ -2240,6 +2670,21 @@ server.registerTool(
2240
2670
  },
2241
2671
  safeHandler(async ({ filePath, reason }) => {
2242
2672
  log('tool:check_file_access', { filePath });
2673
+ try {
2674
+ const pluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-plugin.ts');
2675
+ const assetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-plugin.ts');
2676
+ const legacyPluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-controller.ts');
2677
+ const legacyAssetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-controller.ts');
2678
+ if (
2679
+ existsSync(pluginPath) ||
2680
+ existsSync(assetsPath) ||
2681
+ existsSync(legacyPluginPath) ||
2682
+ existsSync(legacyAssetsPath)
2683
+ ) {
2684
+ const res = await controller.checkFileAccess({ filePath, reason });
2685
+ return { ...res, deprecated: true, hint: 'plugin enforces' };
2686
+ }
2687
+ } catch {}
2243
2688
  return await controller.checkFileAccess({ filePath, reason });
2244
2689
  })
2245
2690
  );
@@ -2247,7 +2692,7 @@ server.registerTool(
2247
2692
  server.registerTool(
2248
2693
  'consume_file_access_decision',
2249
2694
  {
2250
- description: 'Consume file access decision: ALLOW or DENY. Persists allowedFiles/deniedFiles.',
2695
+ description: 'Consume file access decision',
2251
2696
  inputSchema: z.object({
2252
2697
  decisionId: z.string().describe('Decision ID from check_file_access'),
2253
2698
  choice: z.enum(['ALLOW', 'DENY']).describe('Choice'),
@@ -2262,20 +2707,19 @@ server.registerTool(
2262
2707
  server.registerTool(
2263
2708
  'proceed_to_route',
2264
2709
  {
2265
- description:
2266
- 'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed. Only valid from LEVEL_RESOLVED — call this after asking the user about the route decision.',
2710
+ description: 'Proceed to route (deprecated)',
2267
2711
  inputSchema: z.object({}),
2268
2712
  },
2269
2713
  safeHandler(async () => {
2270
- log('tool:proceed_to_route');
2271
- return await controller.proceedToRoute();
2714
+ log('tool:proceed_to_route deprecated');
2715
+ return { deprecated: true, state: 'ROUTE_DECISION_PENDING' };
2272
2716
  })
2273
2717
  );
2274
2718
 
2275
2719
  server.registerTool(
2276
2720
  'abandon',
2277
2721
  {
2278
- description: 'Abandon the current request. Transitions to BLOCKED from most states, or to DONE from BLOCKED.',
2722
+ description: 'Abandon request',
2279
2723
  inputSchema: z.object({
2280
2724
  reason: z.string().optional().describe('Reason for abandoning'),
2281
2725
  }),
@@ -2293,55 +2737,67 @@ server.registerTool(
2293
2737
  'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
2294
2738
  inputSchema: z.object({}),
2295
2739
  },
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
- })
2740
+ safeHandler(
2741
+ async () => {
2742
+ const state = await controller.getState();
2743
+ const metrics = await controller.getMetrics().catch(() => ({}));
2744
+ return {
2745
+ pong: true,
2746
+ degraded: controller.degraded,
2747
+ state: {
2748
+ state: state.state,
2749
+ revision: state.revision,
2750
+ requestId: state.requestId,
2751
+ },
2752
+ diskFreeMB: metrics.diskFreeMB ?? null,
2753
+ stateFileSize: metrics.stateFileSize ?? null,
2754
+ auditSize: metrics.auditSize ?? null,
2755
+ };
2756
+ },
2757
+ { maxRetries: 1 }
2758
+ )
2312
2759
  );
2313
2760
 
2314
2761
  server.registerTool(
2315
2762
  'get_state',
2316
2763
  {
2317
- description: 'Get the current controller state (reads persistent store).',
2764
+ description: 'Get state',
2318
2765
  inputSchema: z.object({}),
2319
2766
  },
2320
- safeHandler(async () => {
2321
- return await controller.getState();
2322
- })
2767
+ safeHandler(
2768
+ async () => {
2769
+ return await controller.getState();
2770
+ },
2771
+ { maxRetries: 1 }
2772
+ )
2323
2773
  );
2324
2774
 
2325
2775
  server.registerTool(
2326
2776
  'get_tasks',
2327
2777
  {
2328
- description: 'Get current task states.',
2778
+ description: 'Get tasks',
2329
2779
  inputSchema: z.object({}),
2330
2780
  },
2331
- safeHandler(async () => {
2332
- return await controller.getTasks();
2333
- })
2781
+ safeHandler(
2782
+ async () => {
2783
+ return await controller.getTasks();
2784
+ },
2785
+ { maxRetries: 1 }
2786
+ )
2334
2787
  );
2335
2788
 
2336
2789
  server.registerTool(
2337
2790
  'get_available_transitions',
2338
2791
  {
2339
- description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
2792
+ description: 'Get available transitions',
2340
2793
  inputSchema: z.object({}),
2341
2794
  },
2342
- safeHandler(async () => {
2343
- return await controller.getAvailableTransitions();
2344
- })
2795
+ safeHandler(
2796
+ async () => {
2797
+ return await controller.getAvailableTransitions();
2798
+ },
2799
+ { maxRetries: 1 }
2800
+ )
2345
2801
  );
2346
2802
 
2347
2803
  server.registerTool(
@@ -2363,18 +2819,21 @@ server.registerTool(
2363
2819
  server.registerTool(
2364
2820
  'get_handoff',
2365
2821
  {
2366
- description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
2822
+ description: 'Get handoff',
2367
2823
  inputSchema: z.object({}),
2368
2824
  },
2369
- safeHandler(async () => {
2370
- return await controller.getHandoff();
2371
- })
2825
+ safeHandler(
2826
+ async () => {
2827
+ return await controller.getHandoff();
2828
+ },
2829
+ { maxRetries: 1 }
2830
+ )
2372
2831
  );
2373
2832
 
2374
2833
  server.registerTool(
2375
2834
  'clear_handoff',
2376
2835
  {
2377
- description: 'Mark handoff as consumed after the agent has loaded the context.',
2836
+ description: 'Clear handoff',
2378
2837
  inputSchema: z.object({}),
2379
2838
  },
2380
2839
  safeHandler(async () => {
@@ -2386,47 +2845,44 @@ server.registerTool(
2386
2845
  'check_pending_state',
2387
2846
  {
2388
2847
  description:
2389
- 'Check if agent is in a pending state waiting for user input. ' +
2390
- 'MUST be called before ANY tool call when controller is available. ' +
2391
- 'Returns ALLOW or BLOCKED with reason. ' +
2392
- 'EXCEPTION: controller tools (consume_route_decision, consume_execution_decision, ' +
2393
- 'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
2848
+ '[deprecated] Check if in pending state. Returns ALLOW/BLOCKED. plugin enforces — deprecated:true if plugin active',
2394
2849
  inputSchema: z.object({}),
2395
2850
  },
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
- })
2851
+ safeHandler(
2852
+ async () => {
2853
+ // If plugin active, delegate to hint
2854
+ try {
2855
+ const pluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-controller.ts');
2856
+ const assetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-controller.ts');
2857
+ if (existsSync(pluginPath) || existsSync(assetsPath)) {
2858
+ return { deprecated: true, hint: 'plugin enforces', status: 'ALLOW' };
2859
+ }
2860
+ } catch {}
2861
+ const state = await controller.getState();
2862
+ const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
2863
+ if (pendingStates.includes(state.state)) {
2864
+ return {
2865
+ status: 'BLOCKED',
2866
+ state: state.state,
2867
+ revision: state.revision,
2868
+ reason: `Cannot execute tools while in ${state.state}. Wait for user response first.`,
2869
+ degraded: controller.degraded,
2870
+ };
2871
+ }
2872
+ return { status: 'ALLOW', state: state.state, revision: state.revision, degraded: controller.degraded };
2873
+ },
2874
+ { maxRetries: 1 }
2875
+ )
2410
2876
  );
2411
2877
 
2412
2878
  server.registerTool(
2413
2879
  'validate_edit',
2414
2880
  {
2415
- description:
2416
- 'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
2417
- 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
2418
- 'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
2881
+ description: '[deprecated] Validate edit',
2419
2882
  inputSchema: z.object({
2420
2883
  oldString: z.string().describe('The exact string to find in content (must be unique).'),
2421
2884
  newString: z.string().describe('The replacement string.'),
2422
- content: z
2423
- .string()
2424
- .describe(
2425
- 'REQUIRED — The full file content. ' +
2426
- 'You MUST read the file first with the Read tool, then pass the complete content here. ' +
2427
- 'Example: call Read on the file, store the output, then call validate_edit with that content. ' +
2428
- 'Without this parameter, validate_edit will fail.'
2429
- ),
2885
+ content: z.string().describe('Required: full file content or hash:<fp> if fingerprint unchanged.'),
2430
2886
  taskId: z.string().optional().describe('Optional task ID for tracking.'),
2431
2887
  filePath: z.string().optional().describe('Optional file path for traversal validation.'),
2432
2888
  }),
@@ -2439,6 +2895,20 @@ server.registerTool(
2439
2895
  hasContent: !!content,
2440
2896
  filePath,
2441
2897
  });
2898
+ try {
2899
+ const pluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-plugin.ts');
2900
+ const assetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-plugin.ts');
2901
+ const legacyPluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-controller.ts');
2902
+ const legacyAssetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-controller.ts');
2903
+ if (
2904
+ existsSync(pluginPath) ||
2905
+ existsSync(assetsPath) ||
2906
+ existsSync(legacyPluginPath) ||
2907
+ existsSync(legacyAssetsPath)
2908
+ ) {
2909
+ return { deprecated: true, hint: 'plugin enforces' };
2910
+ }
2911
+ } catch {}
2442
2912
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
2443
2913
  return {
2444
2914
  outcome: 'CONFLICT',
@@ -2501,7 +2971,7 @@ function setupGracefulShutdown(ctrl) {
2501
2971
  }
2502
2972
 
2503
2973
  async function main() {
2504
- log('Starting ostacky-controller MCP v0.7.3...');
2974
+ log('Starting ostacky-controller MCP v0.8.0...');
2505
2975
  log('State path:', { path: statePath });
2506
2976
  // Clean up stale tmp/lock files from previous runs
2507
2977
  cleanupTmpFiles(statePath);