ostacky 0.7.2 → 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 +25 -14
- package/assets/agents/ostacky.md +548 -501
- package/assets/commands/install-stack.md +2 -2
- package/assets/mcp/ostacky-controller/index.js +1277 -127
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/mcp/ostacky-controller/security.js +87 -0
- package/assets/plugins/engram.ts +41 -5
- package/assets/plugins/ostacky-guard.ts +212 -0
- package/assets/skills/brainstorming/SKILL.md +198 -197
- package/assets/skills/execution-mode-evaluation/SKILL.md +9 -1
- package/assets/skills/graceful-degradation/SKILL.md +251 -239
- package/assets/skills/using-git-worktrees/SKILL.md +8 -0
- package/dist/cli.js +417 -90
- package/manifest.json +32 -32
- package/package.json +1 -1
|
@@ -25,18 +25,85 @@ import {
|
|
|
25
25
|
statSync,
|
|
26
26
|
existsSync,
|
|
27
27
|
} from 'node:fs';
|
|
28
|
-
import { dirname, basename, join, resolve } from 'node:path';
|
|
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));
|
|
33
34
|
|
|
34
35
|
// --- Constants (Fase 5.5 — headroom generoso) ---
|
|
35
36
|
const MAX_TASKS = 100;
|
|
37
|
+
const MAX_TASKS_DEFAULT = 100;
|
|
38
|
+
const MAX_TASKS_CAP = 500;
|
|
36
39
|
const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
|
|
37
40
|
const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
|
|
38
41
|
const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
|
|
39
42
|
|
|
43
|
+
function getMaxTasks() {
|
|
44
|
+
const raw = process.env.OSTACKY_MAX_TASKS;
|
|
45
|
+
if (raw == null || raw === '') return MAX_TASKS_DEFAULT;
|
|
46
|
+
const n = parseInt(raw, 10);
|
|
47
|
+
if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
|
|
48
|
+
if (n > MAX_TASKS_CAP) {
|
|
49
|
+
log('warn:max_tasks_capped', { requested: n, capped: MAX_TASKS_CAP });
|
|
50
|
+
return MAX_TASKS_CAP;
|
|
51
|
+
}
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getProjectRoot(statePath) {
|
|
56
|
+
if (!statePath) return resolve(process.cwd());
|
|
57
|
+
return dirname(dirname(resolve(statePath)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPathInsideProject(filePath, statePath) {
|
|
61
|
+
if (!filePath) return true;
|
|
62
|
+
try {
|
|
63
|
+
const projectRoot = getProjectRoot(statePath);
|
|
64
|
+
const resolved = resolve(projectRoot, filePath);
|
|
65
|
+
const rel = relative(projectRoot, resolved);
|
|
66
|
+
// reject if rel starts with .. or is absolute outside
|
|
67
|
+
if (rel.startsWith('..' + join('', '')) || rel === '..' || rel.startsWith('..')) return false;
|
|
68
|
+
// also reject absolute paths outside project
|
|
69
|
+
if (resolve(filePath) !== resolved && filePath.startsWith('/')) {
|
|
70
|
+
const absRel = relative(projectRoot, resolve(filePath));
|
|
71
|
+
if (absRel.startsWith('..')) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isValidTaskId(taskId) {
|
|
80
|
+
return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getAuditRetention() {
|
|
84
|
+
const raw = process.env.OSTACKY_AUDIT_RETENTION;
|
|
85
|
+
if (raw == null || raw === '') return 500;
|
|
86
|
+
const n = parseInt(raw, 10);
|
|
87
|
+
if (Number.isNaN(n) || n <= 0) return 500;
|
|
88
|
+
if (n > 2000) return 2000;
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getAuditRetentionSafe() {
|
|
93
|
+
return getAuditRetention();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function redactSecrets(obj) {
|
|
97
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
98
|
+
const str = safeJsonStringify(obj);
|
|
99
|
+
// redact after stringify for persistence — handled in persist
|
|
100
|
+
return obj;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
|
|
104
|
+
|
|
105
|
+
// D1: source-of-truth — src/security.ts (via ./security.js) — isSensitive, SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, extractPathsFromBash imported above
|
|
106
|
+
|
|
40
107
|
// --- Transition table ---
|
|
41
108
|
const TRANSITIONS = {
|
|
42
109
|
INTERPRETATION_PENDING: [
|
|
@@ -139,10 +206,53 @@ function safeJsonStringify(obj, pretty = false) {
|
|
|
139
206
|
}
|
|
140
207
|
}
|
|
141
208
|
|
|
142
|
-
function
|
|
209
|
+
function redactForLog(data) {
|
|
210
|
+
if (!data || typeof data !== 'object') return data;
|
|
211
|
+
try {
|
|
212
|
+
const str = safeJsonStringify(data);
|
|
213
|
+
// redact sensitive keys
|
|
214
|
+
if (SENSITIVE_REDACT_RE.test(str)) {
|
|
215
|
+
const copy = JSON.parse(str);
|
|
216
|
+
const redactRecursively = (obj) => {
|
|
217
|
+
if (!obj || typeof obj !== 'object') return;
|
|
218
|
+
for (const k of Object.keys(obj)) {
|
|
219
|
+
if (SENSITIVE_REDACT_RE.test(k)) obj[k] = '[REDACTED]';
|
|
220
|
+
else if (typeof obj[k] === 'object') redactRecursively(obj[k]);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
redactRecursively(copy);
|
|
224
|
+
return copy;
|
|
225
|
+
}
|
|
226
|
+
return data;
|
|
227
|
+
} catch {
|
|
228
|
+
return data;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function log(eventOrLevel, maybeEventOrData, maybeData) {
|
|
233
|
+
let level = 'info';
|
|
234
|
+
let event = eventOrLevel;
|
|
235
|
+
let data = maybeEventOrData;
|
|
236
|
+
if (maybeData !== undefined) {
|
|
237
|
+
level = eventOrLevel;
|
|
238
|
+
event = maybeEventOrData;
|
|
239
|
+
data = maybeData;
|
|
240
|
+
} else {
|
|
241
|
+
// infer level from prefix
|
|
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
|
+
}
|
|
251
|
+
}
|
|
143
252
|
const ts = new Date().toISOString();
|
|
144
|
-
const
|
|
145
|
-
|
|
253
|
+
const safeData = redactForLog(data);
|
|
254
|
+
const payload = safeData ? ` ${safeJsonStringify(safeData)}` : '';
|
|
255
|
+
console.error(`[${ts}] ${level}:${event}${payload}`);
|
|
146
256
|
}
|
|
147
257
|
|
|
148
258
|
/**
|
|
@@ -231,6 +341,14 @@ const STATES = Object.freeze({
|
|
|
231
341
|
BLOCKED: 'BLOCKED',
|
|
232
342
|
});
|
|
233
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
|
+
|
|
234
352
|
const DEFAULT_STATE = Object.freeze({
|
|
235
353
|
state: STATES.INTERPRETATION_PENDING,
|
|
236
354
|
revision: 0,
|
|
@@ -249,6 +367,40 @@ const DEFAULT_STATE = Object.freeze({
|
|
|
249
367
|
expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
|
|
250
368
|
expectedTaskCount: null, // C2: count fallback when IDs not available
|
|
251
369
|
auditSeq: 0, // C1: persistent seq for audit IDs
|
|
370
|
+
degraded: false, // D2: persisted degraded flag for restart observability
|
|
371
|
+
schemaVersion: 1, // D3: schema version for migrations
|
|
372
|
+
stateOversizedCount: 0, // 2.3
|
|
373
|
+
codegraphBypassCount: 0, // 6.3 / 3.1
|
|
374
|
+
degradedEditsCount: 0, // 8.5
|
|
375
|
+
cacheHitCount: 0, // 5.4 hardening-v2
|
|
376
|
+
cacheMissCount: 0,
|
|
377
|
+
tokenSavingEstimate: 0,
|
|
378
|
+
lastProposal: null, // 8.1
|
|
379
|
+
allowedFiles: {}, // 9.2
|
|
380
|
+
deniedFiles: {}, // 9.2
|
|
381
|
+
sensitivePatterns: [
|
|
382
|
+
'**/.env*',
|
|
383
|
+
'**/.secrets/**',
|
|
384
|
+
'**/*.pem',
|
|
385
|
+
'**/*.key',
|
|
386
|
+
'**/.aws/**',
|
|
387
|
+
'**/.ssh/**',
|
|
388
|
+
'**/credentials.json',
|
|
389
|
+
'**/.npmrc',
|
|
390
|
+
], // 9.1
|
|
391
|
+
sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 }, // 9.3
|
|
392
|
+
staleContentAttempts: 0, // 10.4
|
|
393
|
+
completeWithoutValidateCount: 0, // 10.5
|
|
394
|
+
toolTimeoutCount: 0, // 11.1
|
|
395
|
+
lastToolDurationMs: 0, // 11.4
|
|
396
|
+
stateDurationMs: 0, // 11.4
|
|
397
|
+
subagentFailedCount: 0, // 10.6
|
|
398
|
+
lastValidated: null, // 10.5 {filePath, hash, ts}
|
|
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
|
|
403
|
+
ts: Date.now(), // for uptime
|
|
252
404
|
});
|
|
253
405
|
|
|
254
406
|
class OstackyController {
|
|
@@ -262,6 +414,7 @@ class OstackyController {
|
|
|
262
414
|
#lockPidPath;
|
|
263
415
|
#lockHeartbeatPath;
|
|
264
416
|
#lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
|
|
417
|
+
#lockOwner = false;
|
|
265
418
|
|
|
266
419
|
constructor(opts = {}) {
|
|
267
420
|
this.#statePath = opts.statePath;
|
|
@@ -272,7 +425,8 @@ class OstackyController {
|
|
|
272
425
|
this.#lockMaxAttempts = opts.lockMaxAttempts;
|
|
273
426
|
}
|
|
274
427
|
if (opts.initialState) {
|
|
275
|
-
this.#state = { ...DEFAULT_STATE, ...opts.initialState };
|
|
428
|
+
this.#state = { ...structuredClone(DEFAULT_STATE), ...opts.initialState };
|
|
429
|
+
this.#degraded = !!this.#state.degraded;
|
|
276
430
|
this.#loaded = true;
|
|
277
431
|
} else {
|
|
278
432
|
this.#state = null;
|
|
@@ -287,6 +441,18 @@ class OstackyController {
|
|
|
287
441
|
return this.#degraded;
|
|
288
442
|
}
|
|
289
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
|
+
|
|
290
456
|
/**
|
|
291
457
|
* Validates that a parsed state object has the required fields and valid values.
|
|
292
458
|
* Returns null if valid, or an error message if invalid.
|
|
@@ -347,7 +513,8 @@ class OstackyController {
|
|
|
347
513
|
}
|
|
348
514
|
throw e;
|
|
349
515
|
}
|
|
350
|
-
|
|
516
|
+
this.#heartbeatLock();
|
|
517
|
+
this.#lockOwner = true;
|
|
351
518
|
return true;
|
|
352
519
|
} catch {
|
|
353
520
|
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
@@ -357,11 +524,22 @@ class OstackyController {
|
|
|
357
524
|
}
|
|
358
525
|
}
|
|
359
526
|
log('warn:lock_acquire_failed', { attempts: maxAttempts });
|
|
527
|
+
this.#lockOwner = false;
|
|
360
528
|
return false;
|
|
361
529
|
}
|
|
362
530
|
|
|
363
531
|
#releaseLock() {
|
|
364
532
|
if (!this.#lockPath) return;
|
|
533
|
+
// D2: verify ownership before deleting — never delete another process's lock
|
|
534
|
+
try {
|
|
535
|
+
const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
536
|
+
if (ownerPid !== String(process.pid)) {
|
|
537
|
+
this.#lockOwner = false;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
if (!this.#lockOwner) return;
|
|
542
|
+
}
|
|
365
543
|
try {
|
|
366
544
|
unlinkSync(this.#lockPidPath);
|
|
367
545
|
} catch {
|
|
@@ -372,6 +550,7 @@ class OstackyController {
|
|
|
372
550
|
} catch {
|
|
373
551
|
/* best-effort */
|
|
374
552
|
}
|
|
553
|
+
this.#lockOwner = false;
|
|
375
554
|
}
|
|
376
555
|
|
|
377
556
|
#heartbeatLock() {
|
|
@@ -386,7 +565,7 @@ class OstackyController {
|
|
|
386
565
|
#load() {
|
|
387
566
|
if (this.#loaded) return;
|
|
388
567
|
if (!this.#statePath) {
|
|
389
|
-
this.#state =
|
|
568
|
+
this.#state = structuredClone(DEFAULT_STATE);
|
|
390
569
|
this.#loaded = true;
|
|
391
570
|
return;
|
|
392
571
|
}
|
|
@@ -397,30 +576,126 @@ class OstackyController {
|
|
|
397
576
|
const parsed = JSON.parse(raw);
|
|
398
577
|
const validationError = this.#validateState(parsed);
|
|
399
578
|
if (validationError) throw new Error(`State validation failed: ${validationError}`);
|
|
400
|
-
this.#state = { ...DEFAULT_STATE, ...parsed };
|
|
579
|
+
this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed };
|
|
580
|
+
let migrated = false;
|
|
581
|
+
if ((parsed.schemaVersion ?? 0) < 1) {
|
|
582
|
+
if (typeof this.#state.snapshots?.codegraph === 'string') {
|
|
583
|
+
try {
|
|
584
|
+
this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
|
|
585
|
+
migrated = true;
|
|
586
|
+
} catch {}
|
|
587
|
+
}
|
|
588
|
+
if (typeof this.#state.snapshots?.execution === 'string') {
|
|
589
|
+
try {
|
|
590
|
+
this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution);
|
|
591
|
+
migrated = true;
|
|
592
|
+
} catch {}
|
|
593
|
+
}
|
|
594
|
+
if (typeof this.#state.expectedTasks === 'string') {
|
|
595
|
+
try {
|
|
596
|
+
const v = JSON.parse(this.#state.expectedTasks);
|
|
597
|
+
this.#state.expectedTasks = Array.isArray(v) ? v : v ? [String(v)] : null;
|
|
598
|
+
migrated = true;
|
|
599
|
+
} catch {}
|
|
600
|
+
}
|
|
601
|
+
if (Array.isArray(this.#state.audit)) {
|
|
602
|
+
for (const e of this.#state.audit) {
|
|
603
|
+
if (!e.id) {
|
|
604
|
+
e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
|
|
605
|
+
migrated = true;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
this.#state.schemaVersion = 1;
|
|
610
|
+
if (migrated) log('info:schema_migrated', { from: parsed.schemaVersion ?? 0, to: 1 });
|
|
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
|
+
});
|
|
626
|
+
this.#degraded = !!this.#state.degraded;
|
|
401
627
|
this.#loaded = true;
|
|
402
628
|
return;
|
|
403
629
|
} catch (err) {
|
|
404
630
|
log('warn:load_primary_failed', { error: err.message });
|
|
405
631
|
}
|
|
406
|
-
// Fallback: try .backup
|
|
407
|
-
const
|
|
632
|
+
// Fallback: try .backup, .backup.1, .backup.2 (2.1 rotativo)
|
|
633
|
+
for (const suffix of ['.backup', '.backup.1', '.backup.2']) {
|
|
634
|
+
const backupPath = this.#statePath + suffix;
|
|
635
|
+
try {
|
|
636
|
+
const raw = readFileSync(backupPath, 'utf8');
|
|
637
|
+
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
|
|
638
|
+
const parsed = JSON.parse(raw);
|
|
639
|
+
const validationError = this.#validateState(parsed);
|
|
640
|
+
if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
|
|
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;
|
|
647
|
+
if ((parsed.schemaVersion ?? 0) < 1) {
|
|
648
|
+
if (typeof this.#state.snapshots?.codegraph === 'string') {
|
|
649
|
+
try {
|
|
650
|
+
this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
|
|
651
|
+
backupMigrated = true;
|
|
652
|
+
} catch {}
|
|
653
|
+
}
|
|
654
|
+
if (typeof this.#state.snapshots?.execution === 'string') {
|
|
655
|
+
try {
|
|
656
|
+
this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution);
|
|
657
|
+
backupMigrated = true;
|
|
658
|
+
} catch {}
|
|
659
|
+
}
|
|
660
|
+
if (Array.isArray(this.#state.audit)) {
|
|
661
|
+
for (const e of this.#state.audit) {
|
|
662
|
+
if (!e.id) {
|
|
663
|
+
e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
|
|
664
|
+
backupMigrated = true;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
this.#state.schemaVersion = 1;
|
|
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
|
+
});
|
|
684
|
+
this.#degraded = !!this.#state.degraded;
|
|
685
|
+
log('warn:state_restored_from_backup', { suffix });
|
|
686
|
+
this.#loaded = true;
|
|
687
|
+
return;
|
|
688
|
+
} catch {}
|
|
689
|
+
}
|
|
408
690
|
try {
|
|
409
|
-
|
|
410
|
-
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
|
|
411
|
-
const parsed = JSON.parse(raw);
|
|
412
|
-
const validationError = this.#validateState(parsed);
|
|
413
|
-
if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
|
|
414
|
-
this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
|
|
415
|
-
log('warn:state_restored_from_backup');
|
|
416
|
-
this.#loaded = true;
|
|
417
|
-
return;
|
|
691
|
+
throw new Error('All backups failed');
|
|
418
692
|
} catch (backupErr) {
|
|
419
693
|
// No backup either — set error state instead of silent reset
|
|
420
694
|
this.#state = {
|
|
421
|
-
...DEFAULT_STATE,
|
|
695
|
+
...structuredClone(DEFAULT_STATE),
|
|
422
696
|
error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
|
|
423
697
|
};
|
|
698
|
+
this.#degraded = !!this.#state.degraded;
|
|
424
699
|
log('warn:state_reset', { error: backupErr.message });
|
|
425
700
|
}
|
|
426
701
|
this.#loaded = true;
|
|
@@ -442,6 +717,7 @@ class OstackyController {
|
|
|
442
717
|
throw err;
|
|
443
718
|
}
|
|
444
719
|
|
|
720
|
+
let didAcquire = false;
|
|
445
721
|
try {
|
|
446
722
|
// 3.4: Acquire lock before writing
|
|
447
723
|
const lockAcquired = await this.#acquireLock();
|
|
@@ -449,21 +725,62 @@ class OstackyController {
|
|
|
449
725
|
log('warn:persist_skipped_lock', { state: this.#state.state });
|
|
450
726
|
throw new Error('Could not acquire state file lock');
|
|
451
727
|
}
|
|
728
|
+
didAcquire = lockAcquired;
|
|
452
729
|
|
|
453
|
-
|
|
730
|
+
// 4.2: redact sensitive before serialize (do not mutate original long-term, but ensure file is redacted)
|
|
731
|
+
const stateForSerialize = (() => {
|
|
732
|
+
try {
|
|
733
|
+
const copy = JSON.parse(safeJsonStringify(this.#state));
|
|
734
|
+
const redactRecursively = (obj) => {
|
|
735
|
+
if (!obj || typeof obj !== 'object') return;
|
|
736
|
+
for (const k of Object.keys(obj)) {
|
|
737
|
+
if (SENSITIVE_REDACT_RE.test(k)) {
|
|
738
|
+
obj[k] = '[REDACTED]';
|
|
739
|
+
} else if (typeof obj[k] === 'string' && SENSITIVE_REDACT_RE.test(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]');
|
|
743
|
+
if (SENSITIVE_REDACT_RE.test(obj[k])) obj[k] = '[REDACTED]';
|
|
744
|
+
} else if (typeof obj[k] === 'object') {
|
|
745
|
+
redactRecursively(obj[k]);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
redactRecursively(copy);
|
|
750
|
+
if (copy.snapshots) redactRecursively(copy.snapshots);
|
|
751
|
+
if (copy.audit) copy.audit.forEach(redactRecursively);
|
|
752
|
+
return copy;
|
|
753
|
+
} catch {
|
|
754
|
+
return this.#state;
|
|
755
|
+
}
|
|
756
|
+
})();
|
|
757
|
+
let serialized = safeJsonStringify(stateForSerialize, true);
|
|
454
758
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
455
759
|
log('warn:state_oversized', { size: serialized.length });
|
|
456
|
-
|
|
760
|
+
this.#state.stateOversizedCount = (this.#state.stateOversizedCount || 0) + 1;
|
|
761
|
+
const trimmed = { ...stateForSerialize, snapshots: { codegraph: null, execution: null } };
|
|
457
762
|
serialized = safeJsonStringify(trimmed, true);
|
|
458
763
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
459
764
|
log('error:state_too_large_even_after_trim');
|
|
460
765
|
return;
|
|
461
766
|
}
|
|
462
767
|
this.#state.snapshots = { codegraph: null, execution: null };
|
|
768
|
+
// also reflect in file copy
|
|
769
|
+
stateForSerialize.snapshots = { codegraph: null, execution: null };
|
|
770
|
+
serialized = safeJsonStringify(stateForSerialize, true);
|
|
463
771
|
}
|
|
464
772
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
465
773
|
await writeFileAsync(tmp, serialized, 'utf8');
|
|
466
774
|
await renameAsync(tmp, this.#statePath);
|
|
775
|
+
// 2.1: backup rotativo 3 niveles best-effort
|
|
776
|
+
try {
|
|
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 {}
|
|
783
|
+
} catch {}
|
|
467
784
|
try {
|
|
468
785
|
const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
|
|
469
786
|
await writeFileAsync(backupTmp, serialized, 'utf8');
|
|
@@ -471,11 +788,12 @@ class OstackyController {
|
|
|
471
788
|
} catch {
|
|
472
789
|
/* backup is best-effort */
|
|
473
790
|
}
|
|
474
|
-
// B1: persist success → reset failure counter
|
|
791
|
+
// B1: persist success → reset failure counter + auto-exit degraded
|
|
475
792
|
if (this.#consecutiveFailures > 0) {
|
|
476
793
|
log('info:persist_recovered', { after: this.#consecutiveFailures });
|
|
477
794
|
}
|
|
478
795
|
this.#consecutiveFailures = 0;
|
|
796
|
+
if (this.#degraded) this.#exitDegradedMode();
|
|
479
797
|
} catch (err) {
|
|
480
798
|
// B1: persist failure → increment counter, auto-degrade if threshold reached
|
|
481
799
|
this.#consecutiveFailures++;
|
|
@@ -487,7 +805,7 @@ class OstackyController {
|
|
|
487
805
|
}
|
|
488
806
|
throw err;
|
|
489
807
|
} finally {
|
|
490
|
-
this.#releaseLock();
|
|
808
|
+
if (didAcquire) this.#releaseLock();
|
|
491
809
|
}
|
|
492
810
|
}
|
|
493
811
|
|
|
@@ -501,17 +819,66 @@ class OstackyController {
|
|
|
501
819
|
|
|
502
820
|
#trimTasks() {
|
|
503
821
|
if (!this.#state.tasks) return;
|
|
822
|
+
const limit = getMaxTasks();
|
|
504
823
|
const entries = Object.entries(this.#state.tasks);
|
|
505
|
-
if (entries.length <=
|
|
506
|
-
|
|
507
|
-
entries.
|
|
824
|
+
if (entries.length <= limit) return;
|
|
825
|
+
const expectedSet = new Set(Array.isArray(this.#state.expectedTasks) ? this.#state.expectedTasks : []);
|
|
826
|
+
const expectedEntries = entries.filter(([id]) => expectedSet.has(id));
|
|
827
|
+
const nonExpectedEntries = entries.filter(([id]) => !expectedSet.has(id));
|
|
828
|
+
const excess = entries.length - limit;
|
|
829
|
+
if (nonExpectedEntries.length >= excess) {
|
|
830
|
+
nonExpectedEntries.sort((a, b) => {
|
|
831
|
+
const da = a[1].completedAt || '';
|
|
832
|
+
const db = b[1].completedAt || '';
|
|
833
|
+
return db.localeCompare(da);
|
|
834
|
+
});
|
|
835
|
+
const keepNonExpected = nonExpectedEntries.slice(0, nonExpectedEntries.length - excess);
|
|
836
|
+
const kept = [...expectedEntries, ...keepNonExpected];
|
|
837
|
+
kept.sort((a, b) => {
|
|
838
|
+
const da = a[1].completedAt || '';
|
|
839
|
+
const db = b[1].completedAt || '';
|
|
840
|
+
return db.localeCompare(da);
|
|
841
|
+
});
|
|
842
|
+
this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
|
|
843
|
+
log('warn:tasks_trimmed', {
|
|
844
|
+
before: entries.length,
|
|
845
|
+
after: limit,
|
|
846
|
+
preservedExpected: expectedEntries.length,
|
|
847
|
+
});
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const sortedExpected = [...expectedEntries].sort((a, b) => {
|
|
508
851
|
const da = a[1].completedAt || '';
|
|
509
852
|
const db = b[1].completedAt || '';
|
|
510
|
-
return
|
|
853
|
+
return da.localeCompare(db);
|
|
511
854
|
});
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
855
|
+
const needToArchive = excess - nonExpectedEntries.length;
|
|
856
|
+
if (needToArchive > 0) {
|
|
857
|
+
for (let i = 0; i < Math.min(needToArchive, sortedExpected.length); i++) {
|
|
858
|
+
const [taskId] = sortedExpected[i];
|
|
859
|
+
log('info:task_archived_to_engram', {
|
|
860
|
+
taskId,
|
|
861
|
+
topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}`,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
sortedExpected.sort((a, b) => {
|
|
865
|
+
const da = a[1].completedAt || '';
|
|
866
|
+
const db = b[1].completedAt || '';
|
|
867
|
+
return db.localeCompare(da);
|
|
868
|
+
});
|
|
869
|
+
const keepExpectedCount = expectedEntries.length - needToArchive;
|
|
870
|
+
const keepExpected = sortedExpected.slice(0, keepExpectedCount);
|
|
871
|
+
const kept = [...keepExpected, ...nonExpectedEntries];
|
|
872
|
+
kept.sort((a, b) => {
|
|
873
|
+
const da = a[1].completedAt || '';
|
|
874
|
+
const db = b[1].completedAt || '';
|
|
875
|
+
return db.localeCompare(da);
|
|
876
|
+
});
|
|
877
|
+
this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
|
|
878
|
+
log('warn:tasks_trimmed_with_archive', { before: entries.length, after: limit, archived: needToArchive });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
log('warn:tasks_over_limit_no_trim', { before: entries.length, limit, expected: expectedEntries.length });
|
|
515
882
|
}
|
|
516
883
|
|
|
517
884
|
async #transition(to, changes = {}) {
|
|
@@ -529,13 +896,22 @@ class OstackyController {
|
|
|
529
896
|
|
|
530
897
|
// --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
|
|
531
898
|
async #audit(phase, decision, reasoning) {
|
|
899
|
+
let redactedReasoning = reasoning ? String(reasoning).slice(0, 300) : undefined;
|
|
900
|
+
if (redactedReasoning && SENSITIVE_REDACT_RE.test(redactedReasoning)) {
|
|
901
|
+
redactedReasoning = redactedReasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
|
|
902
|
+
// also redact values after = if present
|
|
903
|
+
redactedReasoning = redactedReasoning.replace(
|
|
904
|
+
/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi,
|
|
905
|
+
'$1=[REDACTED]'
|
|
906
|
+
);
|
|
907
|
+
}
|
|
532
908
|
const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
|
|
533
909
|
this.#auditBuffer.push({
|
|
534
910
|
id,
|
|
535
911
|
ts: Date.now(),
|
|
536
912
|
phase,
|
|
537
913
|
decision,
|
|
538
|
-
reasoning:
|
|
914
|
+
reasoning: redactedReasoning,
|
|
539
915
|
});
|
|
540
916
|
const isWarn = phase === 'WARN';
|
|
541
917
|
if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
|
|
@@ -549,10 +925,19 @@ class OstackyController {
|
|
|
549
925
|
for (const e of this.#auditBuffer) {
|
|
550
926
|
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
551
927
|
if (e.reasoning && e.reasoning.length > 300) e.reasoning = e.reasoning.slice(0, 300);
|
|
928
|
+
// 4.2: redact sensitive in audit
|
|
929
|
+
if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning)) {
|
|
930
|
+
e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
|
|
931
|
+
}
|
|
932
|
+
// redact any lingering snapshot data in reasoning
|
|
933
|
+
if (e.reasoning && /(apiKey|secret|token|password)/i.test(e.reasoning)) {
|
|
934
|
+
e.reasoning = e.reasoning.replace(/(apiKey|secret|token|password)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
|
|
935
|
+
}
|
|
552
936
|
}
|
|
553
937
|
this.#state.audit.push(...this.#auditBuffer);
|
|
554
|
-
|
|
555
|
-
|
|
938
|
+
const retention = getAuditRetentionSafe();
|
|
939
|
+
if (this.#state.audit.length > retention) {
|
|
940
|
+
this.#state.audit = this.#state.audit.slice(-retention);
|
|
556
941
|
}
|
|
557
942
|
this.#auditBuffer = [];
|
|
558
943
|
// O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
|
|
@@ -607,23 +992,49 @@ class OstackyController {
|
|
|
607
992
|
// --- 3.3: Degraded mode ---
|
|
608
993
|
#enterDegradedMode(reason) {
|
|
609
994
|
this.#degraded = true;
|
|
995
|
+
if (this.#state) this.#state.degraded = true;
|
|
610
996
|
log('degraded_mode_activated', { reason, state: this.#state?.state });
|
|
997
|
+
if (this.#state && this.#statePath) {
|
|
998
|
+
try {
|
|
999
|
+
this.#persist().catch(() => {});
|
|
1000
|
+
} catch {}
|
|
1001
|
+
}
|
|
611
1002
|
}
|
|
612
1003
|
|
|
613
1004
|
#exitDegradedMode() {
|
|
614
1005
|
if (!this.#degraded) return;
|
|
615
1006
|
this.#degraded = false;
|
|
616
1007
|
this.#consecutiveFailures = 0;
|
|
1008
|
+
if (this.#state) this.#state.degraded = false;
|
|
617
1009
|
log('degraded_mode_exited', { state: this.#state?.state });
|
|
1010
|
+
if (this.#state && this.#statePath) {
|
|
1011
|
+
try {
|
|
1012
|
+
this.#persist().catch(() => {});
|
|
1013
|
+
} catch {}
|
|
1014
|
+
}
|
|
618
1015
|
}
|
|
619
1016
|
|
|
620
1017
|
// --- Core transitions ---
|
|
621
1018
|
|
|
622
|
-
async startRequest({ requestId, changeId } = {}) {
|
|
1019
|
+
async startRequest({ requestId, changeId, force = false } = {}) {
|
|
623
1020
|
this.#load();
|
|
624
|
-
|
|
625
|
-
|
|
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
|
+
};
|
|
626
1035
|
}
|
|
1036
|
+
|
|
1037
|
+
// Force reset or terminal state: create new session
|
|
627
1038
|
await this.#transition('INTERPRETATION_PENDING', {
|
|
628
1039
|
requestId: requestId || 'req-' + Date.now(),
|
|
629
1040
|
changeId: changeId || null,
|
|
@@ -638,8 +1049,17 @@ class OstackyController {
|
|
|
638
1049
|
expectedTaskCount: null,
|
|
639
1050
|
error: null,
|
|
640
1051
|
});
|
|
641
|
-
await this.#audit(
|
|
642
|
-
|
|
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
|
+
};
|
|
643
1063
|
}
|
|
644
1064
|
|
|
645
1065
|
async requestClarification({ question } = {}) {
|
|
@@ -690,6 +1110,10 @@ class OstackyController {
|
|
|
690
1110
|
|
|
691
1111
|
async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
|
|
692
1112
|
this.#load();
|
|
1113
|
+
// 4.4: validación de enums
|
|
1114
|
+
if (level && !['0', '0+1', '1+'].includes(level)) {
|
|
1115
|
+
return { error: `invalid level: ${level}`, available: ['0', '0+1', '1+'] };
|
|
1116
|
+
}
|
|
693
1117
|
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
694
1118
|
if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
|
|
695
1119
|
|
|
@@ -711,18 +1135,68 @@ class OstackyController {
|
|
|
711
1135
|
}
|
|
712
1136
|
|
|
713
1137
|
const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
|
|
1138
|
+
// 8.1/8.2: lastProposal handling — reasoning con plan exigido
|
|
1139
|
+
let shownToUser = false;
|
|
1140
|
+
let proposalFiles = [];
|
|
1141
|
+
let estLines = 0;
|
|
1142
|
+
if (snapshot?.reasoning && typeof snapshot.reasoning === 'object') {
|
|
1143
|
+
if (Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
|
|
1144
|
+
shownToUser = true;
|
|
1145
|
+
proposalFiles = snapshot.reasoning.files;
|
|
1146
|
+
estLines = snapshot.reasoning.estLines;
|
|
1147
|
+
}
|
|
1148
|
+
} else if (snapshot?.files && snapshot?.estLines) {
|
|
1149
|
+
shownToUser = true;
|
|
1150
|
+
proposalFiles = snapshot.files;
|
|
1151
|
+
estLines = snapshot.estLines;
|
|
1152
|
+
}
|
|
1153
|
+
const lastProposal = {
|
|
1154
|
+
ts: Date.now(),
|
|
1155
|
+
requestId: this.#state.requestId,
|
|
1156
|
+
summary: `recordDiscovery level=${level} files=${proposalFiles.join(',')} estLines=${estLines}`,
|
|
1157
|
+
files: proposalFiles,
|
|
1158
|
+
estLines,
|
|
1159
|
+
level,
|
|
1160
|
+
routeChoice: defaultChoice,
|
|
1161
|
+
shownToUser,
|
|
1162
|
+
};
|
|
714
1163
|
await this.#transition(to, {
|
|
715
1164
|
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
716
1165
|
routeChoice: defaultChoice, // O2: persist default suggested choice
|
|
717
1166
|
level, // O1: persist level for conditional persistence
|
|
718
1167
|
snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
|
|
1168
|
+
lastProposal,
|
|
719
1169
|
});
|
|
720
1170
|
await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
721
|
-
//
|
|
1171
|
+
// 8.2: reasoning sin plan → WARN
|
|
1172
|
+
if (!shownToUser && !isTrivial) {
|
|
1173
|
+
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1174
|
+
log('warn:proposal_without_transparent_plan', { level, auditId });
|
|
1175
|
+
await this.#audit(
|
|
1176
|
+
'WARN',
|
|
1177
|
+
'proposal_without_transparent_plan',
|
|
1178
|
+
`level=${level} reasoning missing files/estLines`
|
|
1179
|
+
);
|
|
1180
|
+
this.#state.lastProposal.shownToUser = false;
|
|
1181
|
+
await this.#persist();
|
|
1182
|
+
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
1183
|
+
return {
|
|
1184
|
+
state: this.#state.state,
|
|
1185
|
+
revision: this.#state.revision,
|
|
1186
|
+
level,
|
|
1187
|
+
routeDecisionId: this.#state.routeDecisionId,
|
|
1188
|
+
defaultChoice,
|
|
1189
|
+
warning: 'proposal without transparent plan',
|
|
1190
|
+
auditId: lastAudit?.id || auditId,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
// C2: warning if no evidence and not degraded and not trivial — also count bypass
|
|
722
1194
|
if (!hasEvidence && !this.#degraded && !isTrivial) {
|
|
1195
|
+
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
723
1196
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
724
1197
|
log('warn:discovery_without_codegraph', { level, auditId });
|
|
725
1198
|
await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
|
|
1199
|
+
await this.#persist();
|
|
726
1200
|
// auditId is the last pushed id
|
|
727
1201
|
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
728
1202
|
return {
|
|
@@ -735,6 +1209,23 @@ class OstackyController {
|
|
|
735
1209
|
auditId: lastAudit?.id || auditId,
|
|
736
1210
|
};
|
|
737
1211
|
}
|
|
1212
|
+
// 8.6: Bypass solo para CI
|
|
1213
|
+
if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'ROUTE_DECISION_PENDING') {
|
|
1214
|
+
await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume ${defaultChoice} for CI`);
|
|
1215
|
+
const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', defaultChoice);
|
|
1216
|
+
if (autoTo) {
|
|
1217
|
+
await this.#transition(autoTo, { routeChoice: defaultChoice });
|
|
1218
|
+
await this.#audit(autoTo, 'consume_route_decision', `choice=${defaultChoice} auto-confirm (CI)`);
|
|
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
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
738
1229
|
return {
|
|
739
1230
|
state: this.#state.state,
|
|
740
1231
|
revision: this.#state.revision,
|
|
@@ -764,6 +1255,9 @@ class OstackyController {
|
|
|
764
1255
|
|
|
765
1256
|
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
766
1257
|
this.#load();
|
|
1258
|
+
if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
|
|
1259
|
+
return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
|
|
1260
|
+
}
|
|
767
1261
|
if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
|
|
768
1262
|
return this.#makeError(
|
|
769
1263
|
`Cannot consume route decision from state ${this.#state.state}`,
|
|
@@ -807,24 +1301,92 @@ class OstackyController {
|
|
|
807
1301
|
if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
|
|
808
1302
|
return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
|
|
809
1303
|
}
|
|
1304
|
+
// 1.7: exigir expectedTaskIds/taskIds/taskCount cuando taskCount>0
|
|
1305
|
+
if (snapshot && typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0) {
|
|
1306
|
+
const hasExpectedIds = Array.isArray(snapshot.expectedTaskIds) && snapshot.expectedTaskIds.length > 0;
|
|
1307
|
+
const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
|
|
1308
|
+
const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
|
|
1309
|
+
if (!hasExpectedIds && !hasTaskIds && !hasCount) {
|
|
1310
|
+
return this.#makeError(
|
|
1311
|
+
'Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0',
|
|
1312
|
+
'record_execution_analysis'
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
if (!hasExpectedIds && !hasTaskIds) {
|
|
1316
|
+
return this.#makeError(
|
|
1317
|
+
'Snapshot missing expectedTaskIds or taskIds when taskCount>0',
|
|
1318
|
+
'record_execution_analysis'
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
810
1322
|
// C2: capture expected tasks for gate
|
|
811
1323
|
const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
|
|
812
1324
|
const expectedTaskCount = snapshot?.taskCount ?? (Array.isArray(expectedTasks) ? expectedTasks.length : null);
|
|
1325
|
+
const isEarlyExitExec = snapshot?.globalRuleTriggered === 'early-exit' && (snapshot?.taskCount ?? 0) <= 2;
|
|
1326
|
+
// 8.1/8.2: lastProposal for execution — reasoning con plan
|
|
1327
|
+
let execShown = false;
|
|
1328
|
+
let execFiles = [];
|
|
1329
|
+
let execEst = 0;
|
|
1330
|
+
if (
|
|
1331
|
+
snapshot?.reasoning &&
|
|
1332
|
+
typeof snapshot.reasoning === 'object' &&
|
|
1333
|
+
Array.isArray(snapshot.reasoning.files) &&
|
|
1334
|
+
typeof snapshot.reasoning.estLines === 'number'
|
|
1335
|
+
) {
|
|
1336
|
+
execShown = true;
|
|
1337
|
+
execFiles = snapshot.reasoning.files;
|
|
1338
|
+
execEst = snapshot.reasoning.estLines;
|
|
1339
|
+
} else if (snapshot?.files && snapshot?.estLines) {
|
|
1340
|
+
execShown = true;
|
|
1341
|
+
execFiles = snapshot.files;
|
|
1342
|
+
execEst = snapshot.estLines;
|
|
1343
|
+
} else if (snapshot?.sharedFiles && snapshot?.clusters) {
|
|
1344
|
+
// execution-mode-evaluation style: sharedFiles + clusters
|
|
1345
|
+
execShown = true;
|
|
1346
|
+
execFiles = snapshot.sharedFiles;
|
|
1347
|
+
execEst = snapshot.taskCount || 0;
|
|
1348
|
+
}
|
|
1349
|
+
const execLastProposal = {
|
|
1350
|
+
ts: Date.now(),
|
|
1351
|
+
requestId: this.#state.requestId,
|
|
1352
|
+
summary: `recordExecutionAnalysis files=${execFiles.join(',')} estLines=${execEst}`,
|
|
1353
|
+
files: execFiles,
|
|
1354
|
+
estLines: execEst,
|
|
1355
|
+
level: this.#state.level,
|
|
1356
|
+
routeChoice: this.#state.routeChoice,
|
|
1357
|
+
shownToUser: execShown,
|
|
1358
|
+
};
|
|
813
1359
|
await this.#transition(to, {
|
|
814
1360
|
executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
|
|
815
1361
|
executionMode: null,
|
|
816
|
-
snapshots: { ...this.#state.snapshots, execution: snapshot
|
|
817
|
-
expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
|
|
1362
|
+
snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
|
|
1363
|
+
expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
|
|
818
1364
|
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
1365
|
+
lastProposal: execLastProposal,
|
|
819
1366
|
});
|
|
820
1367
|
await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
821
|
-
//
|
|
1368
|
+
// 8.2: reasoning sin plan → WARN (but allow early-exit style)
|
|
1369
|
+
if (!execShown && snapshot && !isEarlyExitExec) {
|
|
1370
|
+
// Only warn if snapshot was expected to have reasoning (taskCount>2 or not early-exit)
|
|
1371
|
+
const auditId2 = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1372
|
+
log('warn:proposal_without_transparent_plan', { auditId: auditId2 });
|
|
1373
|
+
await this.#audit(
|
|
1374
|
+
'WARN',
|
|
1375
|
+
'proposal_without_transparent_plan',
|
|
1376
|
+
'execution reasoning missing files/estLines'
|
|
1377
|
+
);
|
|
1378
|
+
this.#state.lastProposal.shownToUser = false;
|
|
1379
|
+
await this.#persist();
|
|
1380
|
+
}
|
|
1381
|
+
// C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
|
|
1382
|
+
// 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
|
|
822
1383
|
const hasEvidence =
|
|
823
1384
|
snapshot &&
|
|
824
1385
|
Array.isArray(snapshot.codegraphUsed) &&
|
|
825
1386
|
snapshot.codegraphUsed.length > 0 &&
|
|
826
1387
|
snapshot.recommendation != null;
|
|
827
|
-
if (
|
|
1388
|
+
if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
|
|
1389
|
+
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
828
1390
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
829
1391
|
log('warn:execution_without_codegraph', { auditId });
|
|
830
1392
|
await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
|
|
@@ -837,6 +1399,29 @@ class OstackyController {
|
|
|
837
1399
|
auditId: lastAudit?.id || auditId,
|
|
838
1400
|
};
|
|
839
1401
|
}
|
|
1402
|
+
// 8.6: Bypass solo para CI
|
|
1403
|
+
if (
|
|
1404
|
+
process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' &&
|
|
1405
|
+
this.#state.state === 'EXECUTION_DECISION_PENDING'
|
|
1406
|
+
) {
|
|
1407
|
+
await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume for CI`);
|
|
1408
|
+
const defaultMode =
|
|
1409
|
+
snapshot?.recommendation && ['INLINE', 'SUBAGENT_DRIVEN'].includes(snapshot.recommendation)
|
|
1410
|
+
? snapshot.recommendation
|
|
1411
|
+
: 'INLINE';
|
|
1412
|
+
const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', defaultMode);
|
|
1413
|
+
if (autoTo) {
|
|
1414
|
+
await this.#transition(autoTo, { executionMode: defaultMode });
|
|
1415
|
+
await this.#audit(autoTo, 'consume_execution_decision', `mode=${defaultMode} auto-confirm (CI)`);
|
|
1416
|
+
return {
|
|
1417
|
+
state: this.#state.state,
|
|
1418
|
+
revision: this.#state.revision,
|
|
1419
|
+
executionDecisionId: this.#state.executionDecisionId,
|
|
1420
|
+
executionMode: defaultMode,
|
|
1421
|
+
autoConfirmed: true,
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
840
1425
|
return {
|
|
841
1426
|
state: this.#state.state,
|
|
842
1427
|
revision: this.#state.revision,
|
|
@@ -846,6 +1431,9 @@ class OstackyController {
|
|
|
846
1431
|
|
|
847
1432
|
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
848
1433
|
this.#load();
|
|
1434
|
+
if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
|
|
1435
|
+
return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
|
|
1436
|
+
}
|
|
849
1437
|
if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
|
|
850
1438
|
return this.#makeError(
|
|
851
1439
|
`Cannot consume execution decision from state ${this.#state.state}`,
|
|
@@ -883,15 +1471,17 @@ class OstackyController {
|
|
|
883
1471
|
}
|
|
884
1472
|
// T3: also block on stale fingerprints-vs-disk
|
|
885
1473
|
let staleFiles = [];
|
|
1474
|
+
const seenFp2 = new Set();
|
|
886
1475
|
try {
|
|
887
1476
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
888
1477
|
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
889
1478
|
const current = fastFingerprint(info.filePath);
|
|
890
1479
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
891
1480
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1481
|
+
seenFp2.add(info.filePath);
|
|
892
1482
|
}
|
|
893
1483
|
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
894
|
-
if (
|
|
1484
|
+
if (seenFp2.has(fp)) continue;
|
|
895
1485
|
const cur = fastFingerprint(fp);
|
|
896
1486
|
if (!cur) staleFiles.push(`${fp} (missing)`);
|
|
897
1487
|
else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
@@ -910,6 +1500,19 @@ class OstackyController {
|
|
|
910
1500
|
};
|
|
911
1501
|
}
|
|
912
1502
|
if (hasBlocking && force) {
|
|
1503
|
+
// 4.3: force requiere confirmación humana en últimas 5 entradas de audit
|
|
1504
|
+
const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
|
|
1505
|
+
const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
|
|
1506
|
+
if (!hasHuman) {
|
|
1507
|
+
return {
|
|
1508
|
+
error: 'force requires human confirmation',
|
|
1509
|
+
pending,
|
|
1510
|
+
staleFiles: staleFiles.length ? staleFiles : undefined,
|
|
1511
|
+
current_state: this.#state.state,
|
|
1512
|
+
attempted_transition: 'implementation_complete',
|
|
1513
|
+
suggestion: 'User must write forzar/confirmo/force in a prior block/replan/set_handoff reasoning',
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
913
1516
|
const all = [...pending, ...staleFiles].join(',');
|
|
914
1517
|
await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
|
|
915
1518
|
}
|
|
@@ -936,15 +1539,40 @@ class OstackyController {
|
|
|
936
1539
|
|
|
937
1540
|
async block({ reason } = {}) {
|
|
938
1541
|
this.#load();
|
|
1542
|
+
const from = this.#state.state;
|
|
939
1543
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
940
1544
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
1545
|
+
// 1.9: block desde EXECUTING_* preserva tasks/fileFingerprints/expectedTasks y audita WARN
|
|
1546
|
+
const isExecuting = from === 'EXECUTING_INLINE' || from === 'EXECUTING_SUBAGENTS';
|
|
941
1547
|
await this.#transition(to, { error: reason || 'Blocked' });
|
|
942
1548
|
await this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
1549
|
+
if (isExecuting) {
|
|
1550
|
+
await this.#audit(
|
|
1551
|
+
'WARN',
|
|
1552
|
+
'block_from_executing',
|
|
1553
|
+
`block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
// 10.6: increment subagentFailedCount if block reason indicates subagent failure
|
|
1557
|
+
if (reason && /subagent.*failed/i.test(reason)) {
|
|
1558
|
+
this.#state.subagentFailedCount = (this.#state.subagentFailedCount || 0) + 1;
|
|
1559
|
+
await this.#audit('WARN', 'subagent_failed', reason);
|
|
1560
|
+
try {
|
|
1561
|
+
await this.#persist();
|
|
1562
|
+
} catch {}
|
|
1563
|
+
}
|
|
943
1564
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
944
1565
|
}
|
|
945
1566
|
|
|
946
1567
|
async replan({ reason } = {}) {
|
|
947
1568
|
this.#load();
|
|
1569
|
+
// 1.9: replan desde EXECUTING_* rechazado sin limpiar tasks
|
|
1570
|
+
if (this.#state.state === 'EXECUTING_INLINE' || this.#state.state === 'EXECUTING_SUBAGENTS') {
|
|
1571
|
+
return this.#makeError(
|
|
1572
|
+
`Cannot replan from state ${this.#state.state} — replan only from BLOCKED`,
|
|
1573
|
+
'replan'
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
948
1576
|
const to = this.#isAllowedTransition(this.#state.state, 'replan');
|
|
949
1577
|
if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
|
|
950
1578
|
await this.#transition(to, {
|
|
@@ -998,15 +1626,17 @@ class OstackyController {
|
|
|
998
1626
|
}
|
|
999
1627
|
// T3: fingerprints-vs-disk — detect stale/missing files after complete_task
|
|
1000
1628
|
let staleFiles = [];
|
|
1629
|
+
const seenFp = new Set();
|
|
1001
1630
|
try {
|
|
1002
1631
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1003
1632
|
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
1004
1633
|
const current = fastFingerprint(info.filePath);
|
|
1005
1634
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1006
1635
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1636
|
+
seenFp.add(info.filePath);
|
|
1007
1637
|
}
|
|
1008
1638
|
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
1009
|
-
if (
|
|
1639
|
+
if (seenFp.has(fp)) continue;
|
|
1010
1640
|
const current = fastFingerprint(fp);
|
|
1011
1641
|
if (!current) staleFiles.push(`${fp} (missing)`);
|
|
1012
1642
|
else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
@@ -1023,9 +1653,11 @@ class OstackyController {
|
|
|
1023
1653
|
};
|
|
1024
1654
|
}
|
|
1025
1655
|
|
|
1026
|
-
async getAudit({ limit = 20, offset = 0 } = {}) {
|
|
1656
|
+
async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
|
|
1027
1657
|
this.#load();
|
|
1028
|
-
|
|
1658
|
+
let all = this.#state.audit || [];
|
|
1659
|
+
if (phase) all = all.filter((e) => e.phase === phase);
|
|
1660
|
+
if (since) all = all.filter((e) => e.ts >= since);
|
|
1029
1661
|
const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
|
|
1030
1662
|
return slice.map((e) => ({
|
|
1031
1663
|
id: e.id,
|
|
@@ -1036,6 +1668,189 @@ class OstackyController {
|
|
|
1036
1668
|
}));
|
|
1037
1669
|
}
|
|
1038
1670
|
|
|
1671
|
+
async getMetrics() {
|
|
1672
|
+
this.#load();
|
|
1673
|
+
let stateFileSize = 0;
|
|
1674
|
+
let auditSize = 0;
|
|
1675
|
+
let diskFreeMB = null;
|
|
1676
|
+
try {
|
|
1677
|
+
const stat = statSync(this.#statePath);
|
|
1678
|
+
stateFileSize = stat.size;
|
|
1679
|
+
} catch {}
|
|
1680
|
+
try {
|
|
1681
|
+
auditSize = (this.#state.audit || []).length;
|
|
1682
|
+
} catch {}
|
|
1683
|
+
try {
|
|
1684
|
+
// diskFree via statfs if available, fallback to null
|
|
1685
|
+
const { statfsSync } = await import('node:fs');
|
|
1686
|
+
if (typeof statfsSync === 'function' && this.#statePath) {
|
|
1687
|
+
try {
|
|
1688
|
+
const s = statfsSync(dirname(this.#statePath));
|
|
1689
|
+
diskFreeMB = Math.floor((s.bfree * s.bsize) / (1024 * 1024));
|
|
1690
|
+
} catch {}
|
|
1691
|
+
}
|
|
1692
|
+
} catch {}
|
|
1693
|
+
const completed = Object.values(this.#state.tasks || {}).filter((t) => t.status === 'COMPLETED').length;
|
|
1694
|
+
const total = Object.keys(this.#state.tasks || {}).length;
|
|
1695
|
+
const pending = Array.isArray(this.#state.expectedTasks)
|
|
1696
|
+
? this.#state.expectedTasks.filter(
|
|
1697
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1698
|
+
).length
|
|
1699
|
+
: typeof this.#state.expectedTaskCount === 'number'
|
|
1700
|
+
? Math.max(0, this.#state.expectedTaskCount - completed)
|
|
1701
|
+
: 0;
|
|
1702
|
+
return {
|
|
1703
|
+
revision: this.#state.revision,
|
|
1704
|
+
state: this.#state.state,
|
|
1705
|
+
degraded: this.#degraded || !!this.#state.degraded,
|
|
1706
|
+
consecutiveFailures: this.#consecutiveFailures,
|
|
1707
|
+
taskCounts: {
|
|
1708
|
+
completed,
|
|
1709
|
+
pending,
|
|
1710
|
+
total,
|
|
1711
|
+
expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
|
|
1712
|
+
},
|
|
1713
|
+
expectedTaskCount: this.#state.expectedTaskCount,
|
|
1714
|
+
auditSize,
|
|
1715
|
+
stateFileSize,
|
|
1716
|
+
diskFreeMB,
|
|
1717
|
+
uptimeMs: Date.now() - (this.#state.ts || Date.now()),
|
|
1718
|
+
stateOversizedCount: this.#state.stateOversizedCount || 0,
|
|
1719
|
+
codegraphBypassCount: this.#state.codegraphBypassCount || 0,
|
|
1720
|
+
degradedEditsCount: this.#state.degradedEditsCount || 0,
|
|
1721
|
+
cacheHitCount: this.#state.cacheHitCount || 0,
|
|
1722
|
+
cacheMissCount: this.#state.cacheMissCount || 0,
|
|
1723
|
+
tokenSavingEstimate: this.#state.tokenSavingEstimate || 0,
|
|
1724
|
+
sensitiveAccess: this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 },
|
|
1725
|
+
subagentFailedCount: this.#state.subagentFailedCount || 0,
|
|
1726
|
+
staleContentAttempts: this.#state.staleContentAttempts || 0,
|
|
1727
|
+
completeWithoutValidateCount: this.#state.completeWithoutValidateCount || 0,
|
|
1728
|
+
toolTimeoutCount: this.#state.toolTimeoutCount || 0,
|
|
1729
|
+
lastToolDurationMs: this.#state.lastToolDurationMs || 0,
|
|
1730
|
+
stateDurationMs: this.#state.stateDurationMs || 0,
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
async _recordToolTimeout() {
|
|
1735
|
+
this.#load();
|
|
1736
|
+
this.#state.toolTimeoutCount = (this.#state.toolTimeoutCount || 0) + 1;
|
|
1737
|
+
this.#state.lastToolDurationMs = 5000;
|
|
1738
|
+
try {
|
|
1739
|
+
await this.#persist();
|
|
1740
|
+
} catch {}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
async _recordToolDuration(ms) {
|
|
1744
|
+
this.#load();
|
|
1745
|
+
this.#state.lastToolDurationMs = ms;
|
|
1746
|
+
this.#state.stateDurationMs = Date.now() - (this.#state.ts || Date.now());
|
|
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 };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
async recordUserConfirmation({ decisionId, confirmationText } = {}) {
|
|
1778
|
+
this.#load();
|
|
1779
|
+
if (!decisionId || typeof confirmationText !== 'string') {
|
|
1780
|
+
return { error: 'decisionId and confirmationText required' };
|
|
1781
|
+
}
|
|
1782
|
+
await this.#audit(
|
|
1783
|
+
'CONFIRMATION',
|
|
1784
|
+
'record_user_confirmation',
|
|
1785
|
+
`user confirmed: ${confirmationText} for ${decisionId}`
|
|
1786
|
+
);
|
|
1787
|
+
await this.#flushAudit(true);
|
|
1788
|
+
await this.#persist();
|
|
1789
|
+
return { ok: true, decisionId, confirmationText, ts: Date.now() };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// --- D11: Credential guard helpers — source-of-truth is src/security.ts (hardening-v2 D1) ---
|
|
1793
|
+
isSensitiveFile(filePath) {
|
|
1794
|
+
if (!filePath) return false;
|
|
1795
|
+
this.#load();
|
|
1796
|
+
const patterns =
|
|
1797
|
+
(this.#state && this.#state.sensitivePatterns) || DEFAULT_STATE.sensitivePatterns || SENSITIVE_DEFAULT;
|
|
1798
|
+
return isSensitive(filePath, patterns);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async checkFileAccess({ filePath, reason } = {}) {
|
|
1802
|
+
this.#load();
|
|
1803
|
+
if (!filePath) return { error: 'filePath required' };
|
|
1804
|
+
if (!this.isSensitiveFile(filePath)) return { allowed: true, reason: 'not sensitive' };
|
|
1805
|
+
if (this.#state.allowedFiles?.[filePath]) return { allowed: true, reason: 'previously allowed' };
|
|
1806
|
+
if (this.#state.deniedFiles?.[filePath]) {
|
|
1807
|
+
return {
|
|
1808
|
+
error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`,
|
|
1809
|
+
denied: true,
|
|
1810
|
+
filePath,
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
const decisionId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1814
|
+
if (!this.#state.pendingFileAccess) this.#state.pendingFileAccess = {};
|
|
1815
|
+
this.#state.pendingFileAccess[decisionId] = { filePath, reason, ts: Date.now() };
|
|
1816
|
+
await this.#audit('SECURITY', 'check_file_access', `check ${filePath} reason=${reason || 'none'}`);
|
|
1817
|
+
this.#state.sensitiveAccess = this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 };
|
|
1818
|
+
this.#state.sensitiveAccess.blockedAttempts = (this.#state.sensitiveAccess.blockedAttempts || 0) + 1;
|
|
1819
|
+
await this.#persist();
|
|
1820
|
+
return { status: 'BLOCKED', decisionId, filePath, reason: `File ${filePath} requires check_file_access` };
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
async consumeFileAccessDecision({ decisionId, choice } = {}) {
|
|
1824
|
+
this.#load();
|
|
1825
|
+
if (!decisionId || !choice) return { error: 'decisionId and choice required' };
|
|
1826
|
+
if (!['ALLOW', 'DENY'].includes(choice))
|
|
1827
|
+
return { error: 'choice must be ALLOW or DENY', available: ['ALLOW', 'DENY'] };
|
|
1828
|
+
const pending = this.#state.pendingFileAccess?.[decisionId];
|
|
1829
|
+
let filePath = pending?.filePath;
|
|
1830
|
+
// fallback: if no pending, try to find by decisionId prefix? require filePath param alternative
|
|
1831
|
+
if (!filePath && decisionId.startsWith('file-')) {
|
|
1832
|
+
// try to extract from audit? For now return error if not found
|
|
1833
|
+
return { error: 'decisionId not found', decisionId };
|
|
1834
|
+
}
|
|
1835
|
+
if (!this.#state.allowedFiles) this.#state.allowedFiles = {};
|
|
1836
|
+
if (!this.#state.deniedFiles) this.#state.deniedFiles = {};
|
|
1837
|
+
if (!this.#state.sensitiveAccess) this.#state.sensitiveAccess = { allowed: 0, denied: 0, blockedAttempts: 0 };
|
|
1838
|
+
if (choice === 'ALLOW') {
|
|
1839
|
+
this.#state.allowedFiles[filePath] = true;
|
|
1840
|
+
delete this.#state.deniedFiles[filePath];
|
|
1841
|
+
this.#state.sensitiveAccess.allowed = (this.#state.sensitiveAccess.allowed || 0) + 1;
|
|
1842
|
+
await this.#audit('SECURITY', 'consume_file_access_decision', `ALLOW ${filePath}`);
|
|
1843
|
+
} else {
|
|
1844
|
+
this.#state.deniedFiles[filePath] = true;
|
|
1845
|
+
delete this.#state.allowedFiles[filePath];
|
|
1846
|
+
this.#state.sensitiveAccess.denied = (this.#state.sensitiveAccess.denied || 0) + 1;
|
|
1847
|
+
await this.#audit('WARN', 'consume_file_access_decision', `DENY ${filePath}`);
|
|
1848
|
+
}
|
|
1849
|
+
if (this.#state.pendingFileAccess) delete this.#state.pendingFileAccess[decisionId];
|
|
1850
|
+
await this.#persist();
|
|
1851
|
+
return { ok: true, decisionId, choice, filePath };
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1039
1854
|
// --- B2: Handoff persistence for cross-session continuity ---
|
|
1040
1855
|
async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
|
|
1041
1856
|
this.#load();
|
|
@@ -1100,12 +1915,70 @@ class OstackyController {
|
|
|
1100
1915
|
};
|
|
1101
1916
|
}
|
|
1102
1917
|
|
|
1103
|
-
// --- O6: Validate edit with fast fingerprint ---
|
|
1104
|
-
async validateEdit({ oldString, newString, content, taskId } = {}) {
|
|
1918
|
+
// --- O6: Validate edit with fast fingerprint + D6/D4 hard gate + 10.4 freshness ---
|
|
1919
|
+
async validateEdit({ oldString, newString, content, taskId, filePath } = {}) {
|
|
1105
1920
|
this.#load();
|
|
1106
1921
|
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
1107
1922
|
return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
|
|
1108
1923
|
}
|
|
1924
|
+
if (taskId && !isValidTaskId(taskId)) {
|
|
1925
|
+
return { outcome: 'CONFLICT', reason: `invalid taskId: ${taskId}` };
|
|
1926
|
+
}
|
|
1927
|
+
if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
|
|
1928
|
+
return { outcome: 'CONFLICT', reason: `filePath outside projectRoot: ${filePath}` };
|
|
1929
|
+
}
|
|
1930
|
+
// 9.2: guard sensible en validate_edit
|
|
1931
|
+
if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
|
|
1932
|
+
return { outcome: 'CONFLICT', reason: `BLOCKED: File ${filePath} requires check_file_access` };
|
|
1933
|
+
}
|
|
1934
|
+
// 8.5: contar edits en degraded sin confirmación auditada
|
|
1935
|
+
if (this.#degraded) {
|
|
1936
|
+
this.#state.degradedEditsCount = (this.#state.degradedEditsCount || 0) + 1;
|
|
1937
|
+
try {
|
|
1938
|
+
await this.#persist();
|
|
1939
|
+
} catch {}
|
|
1940
|
+
}
|
|
1941
|
+
// 10.4: validación de frescura — content debe coincidir con disco si filePath dado
|
|
1942
|
+
if (filePath && typeof content === 'string') {
|
|
1943
|
+
try {
|
|
1944
|
+
const projectRoot = getProjectRoot(this.#statePath);
|
|
1945
|
+
const absolutePath =
|
|
1946
|
+
filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
1947
|
+
? resolve(filePath)
|
|
1948
|
+
: resolve(projectRoot, filePath);
|
|
1949
|
+
const diskContent = readFileSync(absolutePath, 'utf8');
|
|
1950
|
+
if (diskContent !== content) {
|
|
1951
|
+
this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
|
|
1952
|
+
await this.#persist();
|
|
1953
|
+
return { outcome: 'CONFLICT', reason: 'content stale, re-read file', filePath };
|
|
1954
|
+
}
|
|
1955
|
+
} catch (e) {
|
|
1956
|
+
if (e.code && e.code !== 'ENOENT') {
|
|
1957
|
+
// ignore ENOENT (new file), but other errors considered stale
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
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
|
+
}
|
|
1109
1982
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
1110
1983
|
return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
|
|
1111
1984
|
}
|
|
@@ -1141,6 +2014,18 @@ class OstackyController {
|
|
|
1141
2014
|
};
|
|
1142
2015
|
}
|
|
1143
2016
|
// oldString found exactly once → safe to replace
|
|
2017
|
+
// 10.5: ligadura validate → complete
|
|
2018
|
+
try {
|
|
2019
|
+
const projectRoot = getProjectRoot(this.#statePath);
|
|
2020
|
+
const absolutePath = filePath
|
|
2021
|
+
? filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
2022
|
+
? resolve(filePath)
|
|
2023
|
+
: resolve(projectRoot, filePath)
|
|
2024
|
+
: null;
|
|
2025
|
+
const hash = absolutePath ? fastFingerprint(absolutePath) : null;
|
|
2026
|
+
this.#state.lastValidated = { filePath: filePath || null, hash, ts: Date.now() };
|
|
2027
|
+
await this.#persist();
|
|
2028
|
+
} catch {}
|
|
1144
2029
|
return { outcome: 'EDITABLE', taskId };
|
|
1145
2030
|
}
|
|
1146
2031
|
|
|
@@ -1154,10 +2039,36 @@ class OstackyController {
|
|
|
1154
2039
|
return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
|
|
1155
2040
|
}
|
|
1156
2041
|
if (!taskId) return { error: 'taskId is required' };
|
|
2042
|
+
if (!isValidTaskId(taskId)) return { error: 'invalid taskId: must match /^[a-zA-Z0-9-_.\/:]+$/', taskId };
|
|
2043
|
+
if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
|
|
2044
|
+
return { error: 'filePath outside projectRoot', filePath };
|
|
2045
|
+
}
|
|
2046
|
+
// 9.2: guard de credenciales — rechazar sensibles sin ALLOW
|
|
2047
|
+
if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
|
|
2048
|
+
return { error: `BLOCKED: File ${filePath} requires check_file_access`, filePath };
|
|
2049
|
+
}
|
|
1157
2050
|
if (!this.#state.tasks) this.#state.tasks = {};
|
|
1158
2051
|
|
|
1159
2052
|
// O6: Use fast fingerprint if no hash provided
|
|
1160
2053
|
const effectiveHash = fileHash || (filePath ? fastFingerprint(filePath) : null);
|
|
2054
|
+
// 1.6: fingerprint obligatorio si archivo existe
|
|
2055
|
+
if (filePath) {
|
|
2056
|
+
const existsCheck = fastFingerprint(filePath);
|
|
2057
|
+
if (existsCheck && !effectiveHash) {
|
|
2058
|
+
return { error: 'fingerprint required: file exists but fileHash is null' };
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
// 10.5: ligadura validate → complete — WARN si no hubo validate previo
|
|
2062
|
+
if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
|
|
2063
|
+
this.#state.completeWithoutValidateCount = (this.#state.completeWithoutValidateCount || 0) + 1;
|
|
2064
|
+
await this.#audit(
|
|
2065
|
+
'WARN',
|
|
2066
|
+
'complete_without_validate',
|
|
2067
|
+
`complete_task without prior validate_edit for ${filePath || taskId}`
|
|
2068
|
+
);
|
|
2069
|
+
} else {
|
|
2070
|
+
this.#state.lastValidated = null;
|
|
2071
|
+
}
|
|
1161
2072
|
|
|
1162
2073
|
this.#state.tasks[taskId] = {
|
|
1163
2074
|
status: 'COMPLETED',
|
|
@@ -1174,18 +2085,37 @@ class OstackyController {
|
|
|
1174
2085
|
(k) => this.#state.tasks[k].status === 'COMPLETED'
|
|
1175
2086
|
).length;
|
|
1176
2087
|
// C2: checkpoint count-based cada 3er complete_task — mismo persist, sin escritura extra
|
|
2088
|
+
// 1.8: preservación de handoff manual reciente (<60s) con pendingTasks distintos
|
|
1177
2089
|
if (totalCompleted % 3 === 0) {
|
|
1178
2090
|
const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
|
|
1179
2091
|
? this.#state.expectedTasks.filter(
|
|
1180
2092
|
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1181
2093
|
)
|
|
1182
2094
|
: [];
|
|
1183
|
-
this.#state.lastHandoff
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
2095
|
+
const existing = this.#state.lastHandoff;
|
|
2096
|
+
const isRecentManual =
|
|
2097
|
+
existing &&
|
|
2098
|
+
Date.now() - existing.ts < 60000 &&
|
|
2099
|
+
existing.summary &&
|
|
2100
|
+
!existing.summary.startsWith('Checkpoint auto');
|
|
2101
|
+
let shouldOverwrite = true;
|
|
2102
|
+
if (isRecentManual) {
|
|
2103
|
+
const existingPending = existing.pendingTasks || [];
|
|
2104
|
+
const isDistinct =
|
|
2105
|
+
pendingForHandoff.length !== existingPending.length ||
|
|
2106
|
+
pendingForHandoff.some((id) => !existingPending.includes(id));
|
|
2107
|
+
if (isDistinct && existingPending.length > 0) {
|
|
2108
|
+
shouldOverwrite = false;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (shouldOverwrite) {
|
|
2112
|
+
this.#state.lastHandoff = {
|
|
2113
|
+
ts: Date.now(),
|
|
2114
|
+
summary: `Checkpoint auto: ${totalCompleted} tasks completadas`,
|
|
2115
|
+
nextSteps: pendingForHandoff.length ? [`Continuar con ${pendingForHandoff.join(', ')}`] : [],
|
|
2116
|
+
pendingTasks: pendingForHandoff,
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
1189
2119
|
}
|
|
1190
2120
|
await this.#persist();
|
|
1191
2121
|
await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
|
|
@@ -1207,22 +2137,61 @@ class OstackyController {
|
|
|
1207
2137
|
if (!this.#state.audit) this.#state.audit = [];
|
|
1208
2138
|
for (const e of this.#auditBuffer) {
|
|
1209
2139
|
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
2140
|
+
if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning))
|
|
2141
|
+
e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
|
|
1210
2142
|
}
|
|
1211
2143
|
this.#state.audit.push(...this.#auditBuffer);
|
|
1212
|
-
|
|
2144
|
+
const retention = getAuditRetentionSafe();
|
|
2145
|
+
if (this.#state.audit.length > retention) this.#state.audit = this.#state.audit.slice(-retention);
|
|
1213
2146
|
this.#auditBuffer = [];
|
|
1214
2147
|
}
|
|
1215
|
-
// T1: final persist path kept synchronous for graceful shutdown
|
|
2148
|
+
// T1: final persist path kept synchronous for graceful shutdown (+ D2 stale-aware 15s)
|
|
1216
2149
|
if (!this.#statePath || !this.#state || !this.#loaded) return;
|
|
1217
2150
|
try {
|
|
2151
|
+
// D2: replicate staleWindow logic sync — check timestamp before acquiring
|
|
2152
|
+
try {
|
|
2153
|
+
const tsRaw = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
2154
|
+
const age = Date.now() - parseInt(tsRaw, 10);
|
|
2155
|
+
if (!Number.isNaN(age) && age >= 15000) {
|
|
2156
|
+
try {
|
|
2157
|
+
unlinkSync(this.#lockPidPath);
|
|
2158
|
+
} catch {}
|
|
2159
|
+
try {
|
|
2160
|
+
unlinkSync(this.#lockHeartbeatPath);
|
|
2161
|
+
} catch {}
|
|
2162
|
+
this.#lockOwner = false;
|
|
2163
|
+
} else if (!Number.isNaN(age) && age < 15000) {
|
|
2164
|
+
try {
|
|
2165
|
+
const pidRaw = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
2166
|
+
if (pidRaw !== String(process.pid)) return;
|
|
2167
|
+
} catch {}
|
|
2168
|
+
}
|
|
2169
|
+
} catch {}
|
|
1218
2170
|
try {
|
|
1219
2171
|
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1220
2172
|
} catch (e) {
|
|
1221
|
-
if (e && e.code === 'EEXIST')
|
|
1222
|
-
|
|
2173
|
+
if (e && e.code === 'EEXIST') {
|
|
2174
|
+
try {
|
|
2175
|
+
const tsRaw2 = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
2176
|
+
const age2 = Date.now() - parseInt(tsRaw2, 10);
|
|
2177
|
+
if (!Number.isNaN(age2) && age2 >= 15000) {
|
|
2178
|
+
try {
|
|
2179
|
+
unlinkSync(this.#lockPidPath);
|
|
2180
|
+
} catch {}
|
|
2181
|
+
try {
|
|
2182
|
+
unlinkSync(this.#lockHeartbeatPath);
|
|
2183
|
+
} catch {}
|
|
2184
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
2185
|
+
} else return;
|
|
2186
|
+
} catch {
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
} else throw e;
|
|
1223
2190
|
}
|
|
1224
2191
|
try {
|
|
1225
|
-
writeFileSync(this.#
|
|
2192
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
2193
|
+
this.#heartbeatLock();
|
|
2194
|
+
this.#lockOwner = true;
|
|
1226
2195
|
} catch {}
|
|
1227
2196
|
const serialized = safeJsonStringify(this.#state, true);
|
|
1228
2197
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
@@ -1242,44 +2211,101 @@ const controller = new OstackyController({ statePath });
|
|
|
1242
2211
|
* Wraps an async tool handler to ALWAYS return a response (even on error).
|
|
1243
2212
|
* Without this, an unhandled exception in any tool handler leaves the LLM
|
|
1244
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)
|
|
1245
2219
|
*/
|
|
1246
|
-
function safeHandler(fn) {
|
|
2220
|
+
function safeHandler(fn, options = {}) {
|
|
2221
|
+
const { maxRetries = 0, baseTimeout = 5000 } = options;
|
|
2222
|
+
|
|
1247
2223
|
return async (params) => {
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
+
});
|
|
2281
|
+
return {
|
|
2282
|
+
content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
|
|
2283
|
+
isError: true,
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
1261
2286
|
}
|
|
1262
2287
|
};
|
|
1263
2288
|
}
|
|
1264
2289
|
|
|
1265
2290
|
const server = new McpServer({
|
|
1266
2291
|
name: 'ostacky-controller',
|
|
1267
|
-
version: '0.7.
|
|
2292
|
+
version: '0.7.4',
|
|
1268
2293
|
});
|
|
1269
2294
|
|
|
1270
2295
|
server.registerTool(
|
|
1271
2296
|
'start_request',
|
|
1272
2297
|
{
|
|
1273
2298
|
description:
|
|
1274
|
-
'Start or
|
|
2299
|
+
'Start or resume a request. By default, resumes in-progress work (non-terminal states). Use force=true to always reset.',
|
|
1275
2300
|
inputSchema: z.object({
|
|
1276
2301
|
requestId: z.string().optional().describe('Unique request ID'),
|
|
1277
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)'),
|
|
1278
2304
|
}),
|
|
1279
2305
|
},
|
|
1280
|
-
safeHandler(async ({ requestId, changeId }) => {
|
|
1281
|
-
log('tool:start_request');
|
|
1282
|
-
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 });
|
|
1283
2309
|
})
|
|
1284
2310
|
);
|
|
1285
2311
|
|
|
@@ -1465,10 +2491,13 @@ server.registerTool(
|
|
|
1465
2491
|
'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
|
|
1466
2492
|
inputSchema: z.object({}),
|
|
1467
2493
|
},
|
|
1468
|
-
safeHandler(
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
2494
|
+
safeHandler(
|
|
2495
|
+
async () => {
|
|
2496
|
+
log('tool:verify_integrity');
|
|
2497
|
+
return await controller.verifyIntegrity();
|
|
2498
|
+
},
|
|
2499
|
+
{ maxRetries: 1 }
|
|
2500
|
+
)
|
|
1472
2501
|
);
|
|
1473
2502
|
|
|
1474
2503
|
server.registerTool(
|
|
@@ -1478,11 +2507,107 @@ server.registerTool(
|
|
|
1478
2507
|
inputSchema: z.object({
|
|
1479
2508
|
limit: z.number().optional().describe('Max entries (default 20)'),
|
|
1480
2509
|
offset: z.number().optional().describe('Offset from end (default 0)'),
|
|
2510
|
+
phase: z.string().optional().describe('Filter by phase (e.g. WARN, LEVEL_RESOLVED)'),
|
|
2511
|
+
since: z.number().optional().describe('Filter by timestamp >= since'),
|
|
2512
|
+
}),
|
|
2513
|
+
},
|
|
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
|
+
)
|
|
2521
|
+
);
|
|
2522
|
+
|
|
2523
|
+
server.registerTool(
|
|
2524
|
+
'get_metrics',
|
|
2525
|
+
{
|
|
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.',
|
|
2559
|
+
inputSchema: z.object({}),
|
|
2560
|
+
},
|
|
2561
|
+
safeHandler(async () => {
|
|
2562
|
+
log('tool:record_cache_miss');
|
|
2563
|
+
return await controller.recordCacheMiss();
|
|
2564
|
+
})
|
|
2565
|
+
);
|
|
2566
|
+
|
|
2567
|
+
server.registerTool(
|
|
2568
|
+
'record_user_confirmation',
|
|
2569
|
+
{
|
|
2570
|
+
description:
|
|
2571
|
+
'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
|
|
2572
|
+
inputSchema: z.object({
|
|
2573
|
+
decisionId: z.string().describe('Decision ID from pending state'),
|
|
2574
|
+
confirmationText: z.string().describe('Literal user confirmation text'),
|
|
1481
2575
|
}),
|
|
1482
2576
|
},
|
|
1483
|
-
safeHandler(async ({
|
|
1484
|
-
log('tool:
|
|
1485
|
-
return await controller.
|
|
2577
|
+
safeHandler(async ({ decisionId, confirmationText }) => {
|
|
2578
|
+
log('tool:record_user_confirmation', { decisionId });
|
|
2579
|
+
return await controller.recordUserConfirmation({ decisionId, confirmationText });
|
|
2580
|
+
})
|
|
2581
|
+
);
|
|
2582
|
+
|
|
2583
|
+
server.registerTool(
|
|
2584
|
+
'check_file_access',
|
|
2585
|
+
{
|
|
2586
|
+
description:
|
|
2587
|
+
'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
|
|
2588
|
+
inputSchema: z.object({
|
|
2589
|
+
filePath: z.string().describe('File path to check'),
|
|
2590
|
+
reason: z.string().optional().describe('Reason for access'),
|
|
2591
|
+
}),
|
|
2592
|
+
},
|
|
2593
|
+
safeHandler(async ({ filePath, reason }) => {
|
|
2594
|
+
log('tool:check_file_access', { filePath });
|
|
2595
|
+
return await controller.checkFileAccess({ filePath, reason });
|
|
2596
|
+
})
|
|
2597
|
+
);
|
|
2598
|
+
|
|
2599
|
+
server.registerTool(
|
|
2600
|
+
'consume_file_access_decision',
|
|
2601
|
+
{
|
|
2602
|
+
description: 'Consume file access decision: ALLOW or DENY. Persists allowedFiles/deniedFiles.',
|
|
2603
|
+
inputSchema: z.object({
|
|
2604
|
+
decisionId: z.string().describe('Decision ID from check_file_access'),
|
|
2605
|
+
choice: z.enum(['ALLOW', 'DENY']).describe('Choice'),
|
|
2606
|
+
}),
|
|
2607
|
+
},
|
|
2608
|
+
safeHandler(async ({ decisionId, choice }) => {
|
|
2609
|
+
log('tool:consume_file_access_decision', { decisionId, choice });
|
|
2610
|
+
return await controller.consumeFileAccessDecision({ decisionId, choice });
|
|
1486
2611
|
})
|
|
1487
2612
|
);
|
|
1488
2613
|
|
|
@@ -1520,17 +2645,25 @@ server.registerTool(
|
|
|
1520
2645
|
'Health check — returns pong if controller is alive. Use this to verify controller availability before making other calls.',
|
|
1521
2646
|
inputSchema: z.object({}),
|
|
1522
2647
|
},
|
|
1523
|
-
safeHandler(
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
+
)
|
|
1534
2667
|
);
|
|
1535
2668
|
|
|
1536
2669
|
server.registerTool(
|
|
@@ -1539,9 +2672,12 @@ server.registerTool(
|
|
|
1539
2672
|
description: 'Get the current controller state (reads persistent store).',
|
|
1540
2673
|
inputSchema: z.object({}),
|
|
1541
2674
|
},
|
|
1542
|
-
safeHandler(
|
|
1543
|
-
|
|
1544
|
-
|
|
2675
|
+
safeHandler(
|
|
2676
|
+
async () => {
|
|
2677
|
+
return await controller.getState();
|
|
2678
|
+
},
|
|
2679
|
+
{ maxRetries: 1 }
|
|
2680
|
+
)
|
|
1545
2681
|
);
|
|
1546
2682
|
|
|
1547
2683
|
server.registerTool(
|
|
@@ -1550,9 +2686,12 @@ server.registerTool(
|
|
|
1550
2686
|
description: 'Get current task states.',
|
|
1551
2687
|
inputSchema: z.object({}),
|
|
1552
2688
|
},
|
|
1553
|
-
safeHandler(
|
|
1554
|
-
|
|
1555
|
-
|
|
2689
|
+
safeHandler(
|
|
2690
|
+
async () => {
|
|
2691
|
+
return await controller.getTasks();
|
|
2692
|
+
},
|
|
2693
|
+
{ maxRetries: 1 }
|
|
2694
|
+
)
|
|
1556
2695
|
);
|
|
1557
2696
|
|
|
1558
2697
|
server.registerTool(
|
|
@@ -1561,9 +2700,12 @@ server.registerTool(
|
|
|
1561
2700
|
description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
|
|
1562
2701
|
inputSchema: z.object({}),
|
|
1563
2702
|
},
|
|
1564
|
-
safeHandler(
|
|
1565
|
-
|
|
1566
|
-
|
|
2703
|
+
safeHandler(
|
|
2704
|
+
async () => {
|
|
2705
|
+
return await controller.getAvailableTransitions();
|
|
2706
|
+
},
|
|
2707
|
+
{ maxRetries: 1 }
|
|
2708
|
+
)
|
|
1567
2709
|
);
|
|
1568
2710
|
|
|
1569
2711
|
server.registerTool(
|
|
@@ -1588,9 +2730,12 @@ server.registerTool(
|
|
|
1588
2730
|
description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
|
|
1589
2731
|
inputSchema: z.object({}),
|
|
1590
2732
|
},
|
|
1591
|
-
safeHandler(
|
|
1592
|
-
|
|
1593
|
-
|
|
2733
|
+
safeHandler(
|
|
2734
|
+
async () => {
|
|
2735
|
+
return await controller.getHandoff();
|
|
2736
|
+
},
|
|
2737
|
+
{ maxRetries: 1 }
|
|
2738
|
+
)
|
|
1594
2739
|
);
|
|
1595
2740
|
|
|
1596
2741
|
server.registerTool(
|
|
@@ -1615,20 +2760,23 @@ server.registerTool(
|
|
|
1615
2760
|
'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
|
|
1616
2761
|
inputSchema: z.object({}),
|
|
1617
2762
|
},
|
|
1618
|
-
safeHandler(
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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
|
+
)
|
|
1632
2780
|
);
|
|
1633
2781
|
|
|
1634
2782
|
server.registerTool(
|
|
@@ -1650,14 +2798,16 @@ server.registerTool(
|
|
|
1650
2798
|
'Without this parameter, validate_edit will fail.'
|
|
1651
2799
|
),
|
|
1652
2800
|
taskId: z.string().optional().describe('Optional task ID for tracking.'),
|
|
2801
|
+
filePath: z.string().optional().describe('Optional file path for traversal validation.'),
|
|
1653
2802
|
}),
|
|
1654
2803
|
},
|
|
1655
|
-
safeHandler(async ({ oldString, newString, content, taskId }) => {
|
|
2804
|
+
safeHandler(async ({ oldString, newString, content, taskId, filePath }) => {
|
|
1656
2805
|
log('tool:validate_edit', {
|
|
1657
2806
|
taskId,
|
|
1658
2807
|
oldLen: oldString?.length,
|
|
1659
2808
|
newLen: newString?.length,
|
|
1660
2809
|
hasContent: !!content,
|
|
2810
|
+
filePath,
|
|
1661
2811
|
});
|
|
1662
2812
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
1663
2813
|
return {
|
|
@@ -1665,7 +2815,7 @@ server.registerTool(
|
|
|
1665
2815
|
reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
|
|
1666
2816
|
};
|
|
1667
2817
|
}
|
|
1668
|
-
return await controller.validateEdit({ oldString, newString, content, taskId });
|
|
2818
|
+
return await controller.validateEdit({ oldString, newString, content, taskId, filePath });
|
|
1669
2819
|
})
|
|
1670
2820
|
);
|
|
1671
2821
|
|
|
@@ -1721,7 +2871,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
1721
2871
|
}
|
|
1722
2872
|
|
|
1723
2873
|
async function main() {
|
|
1724
|
-
log('Starting ostacky-controller MCP v0.7.
|
|
2874
|
+
log('Starting ostacky-controller MCP v0.7.4...');
|
|
1725
2875
|
log('State path:', { path: statePath });
|
|
1726
2876
|
// Clean up stale tmp/lock files from previous runs
|
|
1727
2877
|
cleanupTmpFiles(statePath);
|