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