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.
- package/README.md +29 -24
- package/assets/agents/ostacky.md +82 -525
- package/assets/commands/install-stack.md +2 -2
- package/assets/docs/engram-protocol.md +79 -0
- package/assets/docs/ostacky-reference.md +79 -0
- package/assets/mcp/ostacky-controller/index.js +698 -228
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/mcp/ostacky-controller/security.js +87 -0
- package/assets/plugins/engram.ts +47 -79
- package/assets/plugins/ostacky-guard.ts +11 -124
- package/assets/plugins/ostacky-plugin.ts +646 -0
- package/assets/skills/brainstorming/SKILL.md +198 -197
- package/assets/skills/execution-mode-evaluation/SKILL.md +9 -9
- package/assets/skills/graceful-degradation/SKILL.md +251 -248
- package/dist/cli.js +432 -135
- package/manifest.json +31 -31
- package/package.json +1 -1
|
@@ -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 ===
|
|
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(
|
|
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: '
|
|
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 {
|
|
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:')) {
|
|
238
|
-
|
|
239
|
-
else if (event.startsWith('
|
|
240
|
-
|
|
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: [
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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) {
|
|
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 = {
|
|
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 {
|
|
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 {
|
|
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)
|
|
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]
|
|
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 {
|
|
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 {
|
|
678
|
-
|
|
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', {
|
|
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', {
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
904
|
-
|
|
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(
|
|
921
|
-
|
|
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('
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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.
|
|
1220
|
-
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1511
|
-
|
|
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: {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
|
1566
|
-
|
|
1567
|
-
|
|
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 {
|
|
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))
|
|
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 {
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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))
|
|
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 {
|
|
1898
|
-
|
|
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 {
|
|
1916
|
-
|
|
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 {
|
|
2268
|
+
} catch {
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
1920
2271
|
} else throw e;
|
|
1921
2272
|
}
|
|
1922
2273
|
try {
|
|
1923
|
-
writeFileSync(this.#
|
|
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
|
-
|
|
1948
|
-
|
|
1949
|
-
const
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
|
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(
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
|
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,
|
|
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(
|
|
2200
|
-
|
|
2201
|
-
|
|
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
|
|
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:
|
|
2213
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
state:
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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
|
|
2764
|
+
description: 'Get state',
|
|
2318
2765
|
inputSchema: z.object({}),
|
|
2319
2766
|
},
|
|
2320
|
-
safeHandler(
|
|
2321
|
-
|
|
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
|
|
2778
|
+
description: 'Get tasks',
|
|
2329
2779
|
inputSchema: z.object({}),
|
|
2330
2780
|
},
|
|
2331
|
-
safeHandler(
|
|
2332
|
-
|
|
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
|
|
2792
|
+
description: 'Get available transitions',
|
|
2340
2793
|
inputSchema: z.object({}),
|
|
2341
2794
|
},
|
|
2342
|
-
safeHandler(
|
|
2343
|
-
|
|
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: '
|
|
2822
|
+
description: 'Get handoff',
|
|
2367
2823
|
inputSchema: z.object({}),
|
|
2368
2824
|
},
|
|
2369
|
-
safeHandler(
|
|
2370
|
-
|
|
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: '
|
|
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
|
|
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(
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
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.
|
|
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);
|