ostacky 0.7.2 → 0.7.3
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 +13 -9
- package/assets/agents/ostacky.md +26 -3
- package/assets/commands/install-stack.md +2 -2
- package/assets/mcp/ostacky-controller/index.js +847 -67
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/engram.ts +41 -5
- package/assets/plugins/ostacky-guard.ts +131 -0
- package/assets/skills/execution-mode-evaluation/SKILL.md +9 -1
- package/assets/skills/graceful-degradation/SKILL.md +9 -0
- package/assets/skills/using-git-worktrees/SKILL.md +8 -0
- package/dist/cli.js +218 -38
- package/manifest.json +31 -31
- package/package.json +1 -1
|
@@ -25,7 +25,7 @@ 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
30
|
|
|
31
31
|
// T1: non-blocking wait — replaces busy-wait spins that froze the event loop
|
|
@@ -33,10 +33,74 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
33
33
|
|
|
34
34
|
// --- Constants (Fase 5.5 — headroom generoso) ---
|
|
35
35
|
const MAX_TASKS = 100;
|
|
36
|
+
const MAX_TASKS_DEFAULT = 100;
|
|
37
|
+
const MAX_TASKS_CAP = 500;
|
|
36
38
|
const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
|
|
37
39
|
const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
|
|
38
40
|
const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
|
|
39
41
|
|
|
42
|
+
function getMaxTasks() {
|
|
43
|
+
const raw = process.env.OSTACKY_MAX_TASKS;
|
|
44
|
+
if (raw == null || raw === "") return MAX_TASKS_DEFAULT;
|
|
45
|
+
const n = parseInt(raw, 10);
|
|
46
|
+
if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
|
|
47
|
+
if (n > MAX_TASKS_CAP) {
|
|
48
|
+
log("warn:max_tasks_capped", { requested: n, capped: MAX_TASKS_CAP });
|
|
49
|
+
return MAX_TASKS_CAP;
|
|
50
|
+
}
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getProjectRoot(statePath) {
|
|
55
|
+
if (!statePath) return resolve(process.cwd());
|
|
56
|
+
return dirname(dirname(resolve(statePath)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPathInsideProject(filePath, statePath) {
|
|
60
|
+
if (!filePath) return true;
|
|
61
|
+
try {
|
|
62
|
+
const projectRoot = getProjectRoot(statePath);
|
|
63
|
+
const resolved = resolve(projectRoot, filePath);
|
|
64
|
+
const rel = relative(projectRoot, resolved);
|
|
65
|
+
// reject if rel starts with .. or is absolute outside
|
|
66
|
+
if (rel.startsWith('..' + join('', '')) || rel === '..' || rel.startsWith('..')) return false;
|
|
67
|
+
// also reject absolute paths outside project
|
|
68
|
+
if (resolve(filePath) !== resolved && filePath.startsWith('/')) {
|
|
69
|
+
const absRel = relative(projectRoot, resolve(filePath));
|
|
70
|
+
if (absRel.startsWith('..')) return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isValidTaskId(taskId) {
|
|
79
|
+
return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getAuditRetention() {
|
|
83
|
+
const raw = process.env.OSTACKY_AUDIT_RETENTION;
|
|
84
|
+
if (raw == null || raw === '') return 500;
|
|
85
|
+
const n = parseInt(raw, 10);
|
|
86
|
+
if (Number.isNaN(n) || n <= 0) return 500;
|
|
87
|
+
if (n > 2000) return 2000;
|
|
88
|
+
return n;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getAuditRetentionSafe() {
|
|
92
|
+
return getAuditRetention();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function redactSecrets(obj) {
|
|
96
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
97
|
+
const str = safeJsonStringify(obj);
|
|
98
|
+
// redact after stringify for persistence — handled in persist
|
|
99
|
+
return obj;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
|
|
103
|
+
|
|
40
104
|
// --- Transition table ---
|
|
41
105
|
const TRANSITIONS = {
|
|
42
106
|
INTERPRETATION_PENDING: [
|
|
@@ -139,10 +203,46 @@ function safeJsonStringify(obj, pretty = false) {
|
|
|
139
203
|
}
|
|
140
204
|
}
|
|
141
205
|
|
|
142
|
-
function
|
|
206
|
+
function redactForLog(data) {
|
|
207
|
+
if (!data || typeof data !== 'object') return data;
|
|
208
|
+
try {
|
|
209
|
+
const str = safeJsonStringify(data);
|
|
210
|
+
// redact sensitive keys
|
|
211
|
+
if (SENSITIVE_REDACT_RE.test(str)) {
|
|
212
|
+
const copy = JSON.parse(str);
|
|
213
|
+
const redactRecursively = (obj) => {
|
|
214
|
+
if (!obj || typeof obj !== 'object') return;
|
|
215
|
+
for (const k of Object.keys(obj)) {
|
|
216
|
+
if (SENSITIVE_REDACT_RE.test(k)) obj[k] = '[REDACTED]';
|
|
217
|
+
else if (typeof obj[k] === 'object') redactRecursively(obj[k]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
redactRecursively(copy);
|
|
221
|
+
return copy;
|
|
222
|
+
}
|
|
223
|
+
return data;
|
|
224
|
+
} catch { return data; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function log(eventOrLevel, maybeEventOrData, maybeData) {
|
|
228
|
+
let level = 'info';
|
|
229
|
+
let event = eventOrLevel;
|
|
230
|
+
let data = maybeEventOrData;
|
|
231
|
+
if (maybeData !== undefined) {
|
|
232
|
+
level = eventOrLevel;
|
|
233
|
+
event = maybeEventOrData;
|
|
234
|
+
data = maybeData;
|
|
235
|
+
} else {
|
|
236
|
+
// infer level from prefix
|
|
237
|
+
if (event.startsWith('warn:')) { level = 'warn'; }
|
|
238
|
+
else if (event.startsWith('error:')) { level = 'error'; }
|
|
239
|
+
else if (event.startsWith('info:')) { level = 'info'; }
|
|
240
|
+
else if (event.startsWith('degraded_')) { level = 'warn'; }
|
|
241
|
+
}
|
|
143
242
|
const ts = new Date().toISOString();
|
|
144
|
-
const
|
|
145
|
-
|
|
243
|
+
const safeData = redactForLog(data);
|
|
244
|
+
const payload = safeData ? ` ${safeJsonStringify(safeData)}` : '';
|
|
245
|
+
console.error(`[${ts}] ${level}:${event}${payload}`);
|
|
146
246
|
}
|
|
147
247
|
|
|
148
248
|
/**
|
|
@@ -249,6 +349,25 @@ const DEFAULT_STATE = Object.freeze({
|
|
|
249
349
|
expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
|
|
250
350
|
expectedTaskCount: null, // C2: count fallback when IDs not available
|
|
251
351
|
auditSeq: 0, // C1: persistent seq for audit IDs
|
|
352
|
+
degraded: false, // D2: persisted degraded flag for restart observability
|
|
353
|
+
schemaVersion: 1, // D3: schema version for migrations
|
|
354
|
+
stateOversizedCount: 0, // 2.3
|
|
355
|
+
codegraphBypassCount: 0, // 6.3 / 3.1
|
|
356
|
+
degradedEditsCount: 0, // 8.5
|
|
357
|
+
lastProposal: null, // 8.1
|
|
358
|
+
allowedFiles: {}, // 9.2
|
|
359
|
+
deniedFiles: {}, // 9.2
|
|
360
|
+
sensitivePatterns: ['**/.env*', '**/.secrets/**', '**/*.pem', '**/*.key', '**/.aws/**', '**/.ssh/**', '**/credentials.json', '**/.npmrc'], // 9.1
|
|
361
|
+
sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 }, // 9.3
|
|
362
|
+
staleContentAttempts: 0, // 10.4
|
|
363
|
+
completeWithoutValidateCount: 0, // 10.5
|
|
364
|
+
toolTimeoutCount: 0, // 11.1
|
|
365
|
+
lastToolDurationMs: 0, // 11.4
|
|
366
|
+
stateDurationMs: 0, // 11.4
|
|
367
|
+
subagentFailedCount: 0, // 10.6
|
|
368
|
+
lastValidated: null, // 10.5 {filePath, hash, ts}
|
|
369
|
+
pendingFileAccess: {}, // 9.2
|
|
370
|
+
ts: Date.now(), // for uptime
|
|
252
371
|
});
|
|
253
372
|
|
|
254
373
|
class OstackyController {
|
|
@@ -262,6 +381,7 @@ class OstackyController {
|
|
|
262
381
|
#lockPidPath;
|
|
263
382
|
#lockHeartbeatPath;
|
|
264
383
|
#lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
|
|
384
|
+
#lockOwner = false;
|
|
265
385
|
|
|
266
386
|
constructor(opts = {}) {
|
|
267
387
|
this.#statePath = opts.statePath;
|
|
@@ -272,7 +392,8 @@ class OstackyController {
|
|
|
272
392
|
this.#lockMaxAttempts = opts.lockMaxAttempts;
|
|
273
393
|
}
|
|
274
394
|
if (opts.initialState) {
|
|
275
|
-
this.#state = { ...DEFAULT_STATE, ...opts.initialState };
|
|
395
|
+
this.#state = { ...structuredClone(DEFAULT_STATE), ...opts.initialState };
|
|
396
|
+
this.#degraded = !!this.#state.degraded;
|
|
276
397
|
this.#loaded = true;
|
|
277
398
|
} else {
|
|
278
399
|
this.#state = null;
|
|
@@ -348,6 +469,7 @@ class OstackyController {
|
|
|
348
469
|
throw e;
|
|
349
470
|
}
|
|
350
471
|
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
472
|
+
this.#lockOwner = true;
|
|
351
473
|
return true;
|
|
352
474
|
} catch {
|
|
353
475
|
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
@@ -357,11 +479,22 @@ class OstackyController {
|
|
|
357
479
|
}
|
|
358
480
|
}
|
|
359
481
|
log('warn:lock_acquire_failed', { attempts: maxAttempts });
|
|
482
|
+
this.#lockOwner = false;
|
|
360
483
|
return false;
|
|
361
484
|
}
|
|
362
485
|
|
|
363
486
|
#releaseLock() {
|
|
364
487
|
if (!this.#lockPath) return;
|
|
488
|
+
// D2: verify ownership before deleting — never delete another process's lock
|
|
489
|
+
try {
|
|
490
|
+
const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
491
|
+
if (ownerPid !== String(process.pid)) {
|
|
492
|
+
this.#lockOwner = false;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
if (!this.#lockOwner) return;
|
|
497
|
+
}
|
|
365
498
|
try {
|
|
366
499
|
unlinkSync(this.#lockPidPath);
|
|
367
500
|
} catch {
|
|
@@ -372,6 +505,7 @@ class OstackyController {
|
|
|
372
505
|
} catch {
|
|
373
506
|
/* best-effort */
|
|
374
507
|
}
|
|
508
|
+
this.#lockOwner = false;
|
|
375
509
|
}
|
|
376
510
|
|
|
377
511
|
#heartbeatLock() {
|
|
@@ -386,7 +520,7 @@ class OstackyController {
|
|
|
386
520
|
#load() {
|
|
387
521
|
if (this.#loaded) return;
|
|
388
522
|
if (!this.#statePath) {
|
|
389
|
-
this.#state =
|
|
523
|
+
this.#state = structuredClone(DEFAULT_STATE);
|
|
390
524
|
this.#loaded = true;
|
|
391
525
|
return;
|
|
392
526
|
}
|
|
@@ -397,30 +531,75 @@ class OstackyController {
|
|
|
397
531
|
const parsed = JSON.parse(raw);
|
|
398
532
|
const validationError = this.#validateState(parsed);
|
|
399
533
|
if (validationError) throw new Error(`State validation failed: ${validationError}`);
|
|
400
|
-
this.#state = { ...DEFAULT_STATE, ...parsed };
|
|
534
|
+
this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed };
|
|
535
|
+
if ((parsed.schemaVersion ?? 0) < 1) {
|
|
536
|
+
let migrated = false;
|
|
537
|
+
if (typeof this.#state.snapshots?.codegraph === 'string') {
|
|
538
|
+
try { this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph); migrated = true; } catch {}
|
|
539
|
+
}
|
|
540
|
+
if (typeof this.#state.snapshots?.execution === 'string') {
|
|
541
|
+
try { this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution); migrated = true; } catch {}
|
|
542
|
+
}
|
|
543
|
+
if (typeof this.#state.expectedTasks === 'string') {
|
|
544
|
+
try {
|
|
545
|
+
const v = JSON.parse(this.#state.expectedTasks);
|
|
546
|
+
this.#state.expectedTasks = Array.isArray(v) ? v : v ? [String(v)] : null;
|
|
547
|
+
migrated = true;
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
550
|
+
if (Array.isArray(this.#state.audit)) {
|
|
551
|
+
for (const e of this.#state.audit) {
|
|
552
|
+
if (!e.id) { e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`; migrated = true; }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
this.#state.schemaVersion = 1;
|
|
556
|
+
if (migrated) log('info:schema_migrated', { from: parsed.schemaVersion ?? 0, to: 1 });
|
|
557
|
+
}
|
|
558
|
+
this.#degraded = !!this.#state.degraded;
|
|
401
559
|
this.#loaded = true;
|
|
402
560
|
return;
|
|
403
561
|
} catch (err) {
|
|
404
562
|
log('warn:load_primary_failed', { error: err.message });
|
|
405
563
|
}
|
|
406
|
-
// Fallback: try .backup
|
|
407
|
-
const
|
|
564
|
+
// Fallback: try .backup, .backup.1, .backup.2 (2.1 rotativo)
|
|
565
|
+
for (const suffix of ['.backup', '.backup.1', '.backup.2']) {
|
|
566
|
+
const backupPath = this.#statePath + suffix;
|
|
567
|
+
try {
|
|
568
|
+
const raw = readFileSync(backupPath, 'utf8');
|
|
569
|
+
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
|
|
570
|
+
const parsed = JSON.parse(raw);
|
|
571
|
+
const validationError = this.#validateState(parsed);
|
|
572
|
+
if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
|
|
573
|
+
this.#state = { ...structuredClone(DEFAULT_STATE), ...parsed, error: suffix === '.backup' ? 'State restored from backup' : `State restored from ${suffix}` };
|
|
574
|
+
if ((parsed.schemaVersion ?? 0) < 1) {
|
|
575
|
+
if (typeof this.#state.snapshots?.codegraph === 'string') {
|
|
576
|
+
try { this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph); } catch {}
|
|
577
|
+
}
|
|
578
|
+
if (typeof this.#state.snapshots?.execution === 'string') {
|
|
579
|
+
try { this.#state.snapshots.execution = JSON.parse(this.#state.snapshots.execution); } catch {}
|
|
580
|
+
}
|
|
581
|
+
if (Array.isArray(this.#state.audit)) {
|
|
582
|
+
for (const e of this.#state.audit) {
|
|
583
|
+
if (!e.id) e.id = `aud-${e.ts || Date.now()}-${this.#state.auditSeq++}`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
this.#state.schemaVersion = 1;
|
|
587
|
+
}
|
|
588
|
+
this.#degraded = !!this.#state.degraded;
|
|
589
|
+
log('warn:state_restored_from_backup', { suffix });
|
|
590
|
+
this.#loaded = true;
|
|
591
|
+
return;
|
|
592
|
+
} catch {}
|
|
593
|
+
}
|
|
408
594
|
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;
|
|
595
|
+
throw new Error('All backups failed');
|
|
418
596
|
} catch (backupErr) {
|
|
419
597
|
// No backup either — set error state instead of silent reset
|
|
420
598
|
this.#state = {
|
|
421
|
-
...DEFAULT_STATE,
|
|
599
|
+
...structuredClone(DEFAULT_STATE),
|
|
422
600
|
error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
|
|
423
601
|
};
|
|
602
|
+
this.#degraded = !!this.#state.degraded;
|
|
424
603
|
log('warn:state_reset', { error: backupErr.message });
|
|
425
604
|
}
|
|
426
605
|
this.#loaded = true;
|
|
@@ -442,6 +621,7 @@ class OstackyController {
|
|
|
442
621
|
throw err;
|
|
443
622
|
}
|
|
444
623
|
|
|
624
|
+
let didAcquire = false;
|
|
445
625
|
try {
|
|
446
626
|
// 3.4: Acquire lock before writing
|
|
447
627
|
const lockAcquired = await this.#acquireLock();
|
|
@@ -449,21 +629,54 @@ class OstackyController {
|
|
|
449
629
|
log('warn:persist_skipped_lock', { state: this.#state.state });
|
|
450
630
|
throw new Error('Could not acquire state file lock');
|
|
451
631
|
}
|
|
632
|
+
didAcquire = lockAcquired;
|
|
452
633
|
|
|
453
|
-
|
|
634
|
+
// 4.2: redact sensitive before serialize (do not mutate original long-term, but ensure file is redacted)
|
|
635
|
+
const stateForSerialize = (() => {
|
|
636
|
+
try {
|
|
637
|
+
const copy = JSON.parse(safeJsonStringify(this.#state));
|
|
638
|
+
const redactRecursively = (obj) => {
|
|
639
|
+
if (!obj || typeof obj !== 'object') return;
|
|
640
|
+
for (const k of Object.keys(obj)) {
|
|
641
|
+
if (SENSITIVE_REDACT_RE.test(k)) {
|
|
642
|
+
obj[k] = '[REDACTED]';
|
|
643
|
+
} else if (typeof obj[k] === 'string' && SENSITIVE_REDACT_RE.test(obj[k])) {
|
|
644
|
+
obj[k] = obj[k].replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]').replace(/sk-[a-zA-Z0-9_-]+/g, '[REDACTED]');
|
|
645
|
+
if (SENSITIVE_REDACT_RE.test(obj[k])) obj[k] = '[REDACTED]';
|
|
646
|
+
} else if (typeof obj[k] === 'object') {
|
|
647
|
+
redactRecursively(obj[k]);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
redactRecursively(copy);
|
|
652
|
+
if (copy.snapshots) redactRecursively(copy.snapshots);
|
|
653
|
+
if (copy.audit) copy.audit.forEach(redactRecursively);
|
|
654
|
+
return copy;
|
|
655
|
+
} catch { return this.#state; }
|
|
656
|
+
})();
|
|
657
|
+
let serialized = safeJsonStringify(stateForSerialize, true);
|
|
454
658
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
455
659
|
log('warn:state_oversized', { size: serialized.length });
|
|
456
|
-
|
|
660
|
+
this.#state.stateOversizedCount = (this.#state.stateOversizedCount || 0) + 1;
|
|
661
|
+
const trimmed = { ...stateForSerialize, snapshots: { codegraph: null, execution: null } };
|
|
457
662
|
serialized = safeJsonStringify(trimmed, true);
|
|
458
663
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
459
664
|
log('error:state_too_large_even_after_trim');
|
|
460
665
|
return;
|
|
461
666
|
}
|
|
462
667
|
this.#state.snapshots = { codegraph: null, execution: null };
|
|
668
|
+
// also reflect in file copy
|
|
669
|
+
stateForSerialize.snapshots = { codegraph: null, execution: null };
|
|
670
|
+
serialized = safeJsonStringify(stateForSerialize, true);
|
|
463
671
|
}
|
|
464
672
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
465
673
|
await writeFileAsync(tmp, serialized, 'utf8');
|
|
466
674
|
await renameAsync(tmp, this.#statePath);
|
|
675
|
+
// 2.1: backup rotativo 3 niveles best-effort
|
|
676
|
+
try {
|
|
677
|
+
try { renameSync(this.#statePath + '.backup.1', this.#statePath + '.backup.2'); } catch {}
|
|
678
|
+
try { renameSync(this.#statePath + '.backup', this.#statePath + '.backup.1'); } catch {}
|
|
679
|
+
} catch {}
|
|
467
680
|
try {
|
|
468
681
|
const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
|
|
469
682
|
await writeFileAsync(backupTmp, serialized, 'utf8');
|
|
@@ -471,11 +684,12 @@ class OstackyController {
|
|
|
471
684
|
} catch {
|
|
472
685
|
/* backup is best-effort */
|
|
473
686
|
}
|
|
474
|
-
// B1: persist success → reset failure counter
|
|
687
|
+
// B1: persist success → reset failure counter + auto-exit degraded
|
|
475
688
|
if (this.#consecutiveFailures > 0) {
|
|
476
689
|
log('info:persist_recovered', { after: this.#consecutiveFailures });
|
|
477
690
|
}
|
|
478
691
|
this.#consecutiveFailures = 0;
|
|
692
|
+
if (this.#degraded) this.#exitDegradedMode();
|
|
479
693
|
} catch (err) {
|
|
480
694
|
// B1: persist failure → increment counter, auto-degrade if threshold reached
|
|
481
695
|
this.#consecutiveFailures++;
|
|
@@ -487,7 +701,7 @@ class OstackyController {
|
|
|
487
701
|
}
|
|
488
702
|
throw err;
|
|
489
703
|
} finally {
|
|
490
|
-
this.#releaseLock();
|
|
704
|
+
if (didAcquire) this.#releaseLock();
|
|
491
705
|
}
|
|
492
706
|
}
|
|
493
707
|
|
|
@@ -501,17 +715,59 @@ class OstackyController {
|
|
|
501
715
|
|
|
502
716
|
#trimTasks() {
|
|
503
717
|
if (!this.#state.tasks) return;
|
|
718
|
+
const limit = getMaxTasks();
|
|
504
719
|
const entries = Object.entries(this.#state.tasks);
|
|
505
|
-
if (entries.length <=
|
|
506
|
-
|
|
507
|
-
entries.
|
|
720
|
+
if (entries.length <= limit) return;
|
|
721
|
+
const expectedSet = new Set(Array.isArray(this.#state.expectedTasks) ? this.#state.expectedTasks : []);
|
|
722
|
+
const expectedEntries = entries.filter(([id]) => expectedSet.has(id));
|
|
723
|
+
const nonExpectedEntries = entries.filter(([id]) => !expectedSet.has(id));
|
|
724
|
+
const excess = entries.length - limit;
|
|
725
|
+
if (nonExpectedEntries.length >= excess) {
|
|
726
|
+
nonExpectedEntries.sort((a, b) => {
|
|
727
|
+
const da = a[1].completedAt || '';
|
|
728
|
+
const db = b[1].completedAt || '';
|
|
729
|
+
return db.localeCompare(da);
|
|
730
|
+
});
|
|
731
|
+
const keepNonExpected = nonExpectedEntries.slice(0, nonExpectedEntries.length - excess);
|
|
732
|
+
const kept = [...expectedEntries, ...keepNonExpected];
|
|
733
|
+
kept.sort((a, b) => {
|
|
734
|
+
const da = a[1].completedAt || '';
|
|
735
|
+
const db = b[1].completedAt || '';
|
|
736
|
+
return db.localeCompare(da);
|
|
737
|
+
});
|
|
738
|
+
this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
|
|
739
|
+
log('warn:tasks_trimmed', { before: entries.length, after: limit, preservedExpected: expectedEntries.length });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const sortedExpected = [...expectedEntries].sort((a, b) => {
|
|
508
743
|
const da = a[1].completedAt || '';
|
|
509
744
|
const db = b[1].completedAt || '';
|
|
510
|
-
return
|
|
745
|
+
return da.localeCompare(db);
|
|
511
746
|
});
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
747
|
+
const needToArchive = excess - nonExpectedEntries.length;
|
|
748
|
+
if (needToArchive > 0) {
|
|
749
|
+
for (let i = 0; i < Math.min(needToArchive, sortedExpected.length); i++) {
|
|
750
|
+
const [taskId] = sortedExpected[i];
|
|
751
|
+
log('info:task_archived_to_engram', { taskId, topic: `harness/archive/${this.#state.requestId || 'unknown'}-${taskId}` });
|
|
752
|
+
}
|
|
753
|
+
sortedExpected.sort((a, b) => {
|
|
754
|
+
const da = a[1].completedAt || '';
|
|
755
|
+
const db = b[1].completedAt || '';
|
|
756
|
+
return db.localeCompare(da);
|
|
757
|
+
});
|
|
758
|
+
const keepExpectedCount = expectedEntries.length - needToArchive;
|
|
759
|
+
const keepExpected = sortedExpected.slice(0, keepExpectedCount);
|
|
760
|
+
const kept = [...keepExpected, ...nonExpectedEntries];
|
|
761
|
+
kept.sort((a, b) => {
|
|
762
|
+
const da = a[1].completedAt || '';
|
|
763
|
+
const db = b[1].completedAt || '';
|
|
764
|
+
return db.localeCompare(da);
|
|
765
|
+
});
|
|
766
|
+
this.#state.tasks = Object.fromEntries(kept.slice(0, limit));
|
|
767
|
+
log('warn:tasks_trimmed_with_archive', { before: entries.length, after: limit, archived: needToArchive });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
log('warn:tasks_over_limit_no_trim', { before: entries.length, limit, expected: expectedEntries.length });
|
|
515
771
|
}
|
|
516
772
|
|
|
517
773
|
async #transition(to, changes = {}) {
|
|
@@ -529,13 +785,19 @@ class OstackyController {
|
|
|
529
785
|
|
|
530
786
|
// --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
|
|
531
787
|
async #audit(phase, decision, reasoning) {
|
|
788
|
+
let redactedReasoning = reasoning ? String(reasoning).slice(0, 300) : undefined;
|
|
789
|
+
if (redactedReasoning && SENSITIVE_REDACT_RE.test(redactedReasoning)) {
|
|
790
|
+
redactedReasoning = redactedReasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
|
|
791
|
+
// also redact values after = if present
|
|
792
|
+
redactedReasoning = redactedReasoning.replace(/(apiKey|secret|token|password|api_key)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
|
|
793
|
+
}
|
|
532
794
|
const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
|
|
533
795
|
this.#auditBuffer.push({
|
|
534
796
|
id,
|
|
535
797
|
ts: Date.now(),
|
|
536
798
|
phase,
|
|
537
799
|
decision,
|
|
538
|
-
reasoning:
|
|
800
|
+
reasoning: redactedReasoning,
|
|
539
801
|
});
|
|
540
802
|
const isWarn = phase === 'WARN';
|
|
541
803
|
if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
|
|
@@ -549,10 +811,19 @@ class OstackyController {
|
|
|
549
811
|
for (const e of this.#auditBuffer) {
|
|
550
812
|
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
551
813
|
if (e.reasoning && e.reasoning.length > 300) e.reasoning = e.reasoning.slice(0, 300);
|
|
814
|
+
// 4.2: redact sensitive in audit
|
|
815
|
+
if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning)) {
|
|
816
|
+
e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
|
|
817
|
+
}
|
|
818
|
+
// redact any lingering snapshot data in reasoning
|
|
819
|
+
if (e.reasoning && /(apiKey|secret|token|password)/i.test(e.reasoning)) {
|
|
820
|
+
e.reasoning = e.reasoning.replace(/(apiKey|secret|token|password)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
|
|
821
|
+
}
|
|
552
822
|
}
|
|
553
823
|
this.#state.audit.push(...this.#auditBuffer);
|
|
554
|
-
|
|
555
|
-
|
|
824
|
+
const retention = getAuditRetentionSafe();
|
|
825
|
+
if (this.#state.audit.length > retention) {
|
|
826
|
+
this.#state.audit = this.#state.audit.slice(-retention);
|
|
556
827
|
}
|
|
557
828
|
this.#auditBuffer = [];
|
|
558
829
|
// O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
|
|
@@ -607,14 +878,22 @@ class OstackyController {
|
|
|
607
878
|
// --- 3.3: Degraded mode ---
|
|
608
879
|
#enterDegradedMode(reason) {
|
|
609
880
|
this.#degraded = true;
|
|
881
|
+
if (this.#state) this.#state.degraded = true;
|
|
610
882
|
log('degraded_mode_activated', { reason, state: this.#state?.state });
|
|
883
|
+
if (this.#state && this.#statePath) {
|
|
884
|
+
try { this.#persist().catch(() => {}); } catch {}
|
|
885
|
+
}
|
|
611
886
|
}
|
|
612
887
|
|
|
613
888
|
#exitDegradedMode() {
|
|
614
889
|
if (!this.#degraded) return;
|
|
615
890
|
this.#degraded = false;
|
|
616
891
|
this.#consecutiveFailures = 0;
|
|
892
|
+
if (this.#state) this.#state.degraded = false;
|
|
617
893
|
log('degraded_mode_exited', { state: this.#state?.state });
|
|
894
|
+
if (this.#state && this.#statePath) {
|
|
895
|
+
try { this.#persist().catch(() => {}); } catch {}
|
|
896
|
+
}
|
|
618
897
|
}
|
|
619
898
|
|
|
620
899
|
// --- Core transitions ---
|
|
@@ -690,6 +969,10 @@ class OstackyController {
|
|
|
690
969
|
|
|
691
970
|
async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
|
|
692
971
|
this.#load();
|
|
972
|
+
// 4.4: validación de enums
|
|
973
|
+
if (level && !['0', '0+1', '1+'].includes(level)) {
|
|
974
|
+
return { error: `invalid level: ${level}`, available: ['0', '0+1', '1+'] };
|
|
975
|
+
}
|
|
693
976
|
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
694
977
|
if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
|
|
695
978
|
|
|
@@ -711,18 +994,64 @@ class OstackyController {
|
|
|
711
994
|
}
|
|
712
995
|
|
|
713
996
|
const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
|
|
997
|
+
// 8.1/8.2: lastProposal handling — reasoning con plan exigido
|
|
998
|
+
let shownToUser = false;
|
|
999
|
+
let proposalFiles = [];
|
|
1000
|
+
let estLines = 0;
|
|
1001
|
+
if (snapshot?.reasoning && typeof snapshot.reasoning === 'object') {
|
|
1002
|
+
if (Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
|
|
1003
|
+
shownToUser = true;
|
|
1004
|
+
proposalFiles = snapshot.reasoning.files;
|
|
1005
|
+
estLines = snapshot.reasoning.estLines;
|
|
1006
|
+
}
|
|
1007
|
+
} else if (snapshot?.files && snapshot?.estLines) {
|
|
1008
|
+
shownToUser = true;
|
|
1009
|
+
proposalFiles = snapshot.files;
|
|
1010
|
+
estLines = snapshot.estLines;
|
|
1011
|
+
}
|
|
1012
|
+
const lastProposal = {
|
|
1013
|
+
ts: Date.now(),
|
|
1014
|
+
requestId: this.#state.requestId,
|
|
1015
|
+
summary: `recordDiscovery level=${level} files=${proposalFiles.join(',')} estLines=${estLines}`,
|
|
1016
|
+
files: proposalFiles,
|
|
1017
|
+
estLines,
|
|
1018
|
+
level,
|
|
1019
|
+
routeChoice: defaultChoice,
|
|
1020
|
+
shownToUser,
|
|
1021
|
+
};
|
|
714
1022
|
await this.#transition(to, {
|
|
715
1023
|
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
716
1024
|
routeChoice: defaultChoice, // O2: persist default suggested choice
|
|
717
1025
|
level, // O1: persist level for conditional persistence
|
|
718
1026
|
snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
|
|
1027
|
+
lastProposal,
|
|
719
1028
|
});
|
|
720
1029
|
await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
721
|
-
//
|
|
1030
|
+
// 8.2: reasoning sin plan → WARN
|
|
1031
|
+
if (!shownToUser && !isTrivial) {
|
|
1032
|
+
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1033
|
+
log('warn:proposal_without_transparent_plan', { level, auditId });
|
|
1034
|
+
await this.#audit('WARN', 'proposal_without_transparent_plan', `level=${level} reasoning missing files/estLines`);
|
|
1035
|
+
this.#state.lastProposal.shownToUser = false;
|
|
1036
|
+
await this.#persist();
|
|
1037
|
+
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
1038
|
+
return {
|
|
1039
|
+
state: this.#state.state,
|
|
1040
|
+
revision: this.#state.revision,
|
|
1041
|
+
level,
|
|
1042
|
+
routeDecisionId: this.#state.routeDecisionId,
|
|
1043
|
+
defaultChoice,
|
|
1044
|
+
warning: 'proposal without transparent plan',
|
|
1045
|
+
auditId: lastAudit?.id || auditId,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
// C2: warning if no evidence and not degraded and not trivial — also count bypass
|
|
722
1049
|
if (!hasEvidence && !this.#degraded && !isTrivial) {
|
|
1050
|
+
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
723
1051
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
724
1052
|
log('warn:discovery_without_codegraph', { level, auditId });
|
|
725
1053
|
await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
|
|
1054
|
+
await this.#persist();
|
|
726
1055
|
// auditId is the last pushed id
|
|
727
1056
|
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
728
1057
|
return {
|
|
@@ -735,6 +1064,16 @@ class OstackyController {
|
|
|
735
1064
|
auditId: lastAudit?.id || auditId,
|
|
736
1065
|
};
|
|
737
1066
|
}
|
|
1067
|
+
// 8.6: Bypass solo para CI
|
|
1068
|
+
if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'ROUTE_DECISION_PENDING') {
|
|
1069
|
+
await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume ${defaultChoice} for CI`);
|
|
1070
|
+
const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', defaultChoice);
|
|
1071
|
+
if (autoTo) {
|
|
1072
|
+
await this.#transition(autoTo, { routeChoice: defaultChoice });
|
|
1073
|
+
await this.#audit(autoTo, 'consume_route_decision', `choice=${defaultChoice} auto-confirm (CI)`);
|
|
1074
|
+
return { state: this.#state.state, revision: this.#state.revision, level, routeDecisionId: this.#state.routeDecisionId, defaultChoice, autoConfirmed: true };
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
738
1077
|
return {
|
|
739
1078
|
state: this.#state.state,
|
|
740
1079
|
revision: this.#state.revision,
|
|
@@ -764,6 +1103,9 @@ class OstackyController {
|
|
|
764
1103
|
|
|
765
1104
|
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
766
1105
|
this.#load();
|
|
1106
|
+
if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
|
|
1107
|
+
return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
|
|
1108
|
+
}
|
|
767
1109
|
if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
|
|
768
1110
|
return this.#makeError(
|
|
769
1111
|
`Cannot consume route decision from state ${this.#state.state}`,
|
|
@@ -807,24 +1149,77 @@ class OstackyController {
|
|
|
807
1149
|
if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
|
|
808
1150
|
return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
|
|
809
1151
|
}
|
|
1152
|
+
// 1.7: exigir expectedTaskIds/taskIds/taskCount cuando taskCount>0
|
|
1153
|
+
if (snapshot && typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0) {
|
|
1154
|
+
const hasExpectedIds = Array.isArray(snapshot.expectedTaskIds) && snapshot.expectedTaskIds.length > 0;
|
|
1155
|
+
const hasTaskIds = Array.isArray(snapshot.taskIds) && snapshot.taskIds.length > 0;
|
|
1156
|
+
const hasCount = typeof snapshot.taskCount === 'number' && snapshot.taskCount > 0;
|
|
1157
|
+
if (!hasExpectedIds && !hasTaskIds && !hasCount) {
|
|
1158
|
+
return this.#makeError('Snapshot missing expectedTaskIds/taskIds/taskCount when taskCount>0', 'record_execution_analysis');
|
|
1159
|
+
}
|
|
1160
|
+
if (!hasExpectedIds && !hasTaskIds) {
|
|
1161
|
+
return this.#makeError('Snapshot missing expectedTaskIds or taskIds when taskCount>0', 'record_execution_analysis');
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
810
1164
|
// C2: capture expected tasks for gate
|
|
811
1165
|
const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
|
|
812
1166
|
const expectedTaskCount = snapshot?.taskCount ?? (Array.isArray(expectedTasks) ? expectedTasks.length : null);
|
|
1167
|
+
const isEarlyExitExec = snapshot?.globalRuleTriggered === 'early-exit' && (snapshot?.taskCount ?? 0) <= 2;
|
|
1168
|
+
// 8.1/8.2: lastProposal for execution — reasoning con plan
|
|
1169
|
+
let execShown = false;
|
|
1170
|
+
let execFiles = [];
|
|
1171
|
+
let execEst = 0;
|
|
1172
|
+
if (snapshot?.reasoning && typeof snapshot.reasoning === 'object' && Array.isArray(snapshot.reasoning.files) && typeof snapshot.reasoning.estLines === 'number') {
|
|
1173
|
+
execShown = true;
|
|
1174
|
+
execFiles = snapshot.reasoning.files;
|
|
1175
|
+
execEst = snapshot.reasoning.estLines;
|
|
1176
|
+
} else if (snapshot?.files && snapshot?.estLines) {
|
|
1177
|
+
execShown = true;
|
|
1178
|
+
execFiles = snapshot.files;
|
|
1179
|
+
execEst = snapshot.estLines;
|
|
1180
|
+
} else if (snapshot?.sharedFiles && snapshot?.clusters) {
|
|
1181
|
+
// execution-mode-evaluation style: sharedFiles + clusters
|
|
1182
|
+
execShown = true;
|
|
1183
|
+
execFiles = snapshot.sharedFiles;
|
|
1184
|
+
execEst = snapshot.taskCount || 0;
|
|
1185
|
+
}
|
|
1186
|
+
const execLastProposal = {
|
|
1187
|
+
ts: Date.now(),
|
|
1188
|
+
requestId: this.#state.requestId,
|
|
1189
|
+
summary: `recordExecutionAnalysis files=${execFiles.join(',')} estLines=${execEst}`,
|
|
1190
|
+
files: execFiles,
|
|
1191
|
+
estLines: execEst,
|
|
1192
|
+
level: this.#state.level,
|
|
1193
|
+
routeChoice: this.#state.routeChoice,
|
|
1194
|
+
shownToUser: execShown,
|
|
1195
|
+
};
|
|
813
1196
|
await this.#transition(to, {
|
|
814
1197
|
executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
|
|
815
1198
|
executionMode: null,
|
|
816
|
-
snapshots: { ...this.#state.snapshots, execution: snapshot
|
|
817
|
-
expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
|
|
1199
|
+
snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
|
|
1200
|
+
expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
|
|
818
1201
|
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
1202
|
+
lastProposal: execLastProposal,
|
|
819
1203
|
});
|
|
820
1204
|
await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
821
|
-
//
|
|
1205
|
+
// 8.2: reasoning sin plan → WARN (but allow early-exit style)
|
|
1206
|
+
if (!execShown && snapshot && !isEarlyExitExec) {
|
|
1207
|
+
// Only warn if snapshot was expected to have reasoning (taskCount>2 or not early-exit)
|
|
1208
|
+
const auditId2 = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1209
|
+
log('warn:proposal_without_transparent_plan', { auditId: auditId2 });
|
|
1210
|
+
await this.#audit('WARN', 'proposal_without_transparent_plan', 'execution reasoning missing files/estLines');
|
|
1211
|
+
this.#state.lastProposal.shownToUser = false;
|
|
1212
|
+
await this.#persist();
|
|
1213
|
+
}
|
|
1214
|
+
// C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
|
|
1215
|
+
// 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
|
|
822
1216
|
const hasEvidence =
|
|
823
1217
|
snapshot &&
|
|
824
1218
|
Array.isArray(snapshot.codegraphUsed) &&
|
|
825
1219
|
snapshot.codegraphUsed.length > 0 &&
|
|
826
1220
|
snapshot.recommendation != null;
|
|
827
|
-
if (
|
|
1221
|
+
if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
|
|
1222
|
+
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
828
1223
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
829
1224
|
log('warn:execution_without_codegraph', { auditId });
|
|
830
1225
|
await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
|
|
@@ -837,6 +1232,17 @@ class OstackyController {
|
|
|
837
1232
|
auditId: lastAudit?.id || auditId,
|
|
838
1233
|
};
|
|
839
1234
|
}
|
|
1235
|
+
// 8.6: Bypass solo para CI
|
|
1236
|
+
if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'EXECUTION_DECISION_PENDING') {
|
|
1237
|
+
await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume for CI`);
|
|
1238
|
+
const defaultMode = snapshot?.recommendation && ['INLINE', 'SUBAGENT_DRIVEN'].includes(snapshot.recommendation) ? snapshot.recommendation : 'INLINE';
|
|
1239
|
+
const autoTo = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', defaultMode);
|
|
1240
|
+
if (autoTo) {
|
|
1241
|
+
await this.#transition(autoTo, { executionMode: defaultMode });
|
|
1242
|
+
await this.#audit(autoTo, 'consume_execution_decision', `mode=${defaultMode} auto-confirm (CI)`);
|
|
1243
|
+
return { state: this.#state.state, revision: this.#state.revision, executionDecisionId: this.#state.executionDecisionId, executionMode: defaultMode, autoConfirmed: true };
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
840
1246
|
return {
|
|
841
1247
|
state: this.#state.state,
|
|
842
1248
|
revision: this.#state.revision,
|
|
@@ -846,6 +1252,9 @@ class OstackyController {
|
|
|
846
1252
|
|
|
847
1253
|
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
848
1254
|
this.#load();
|
|
1255
|
+
if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
|
|
1256
|
+
return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
|
|
1257
|
+
}
|
|
849
1258
|
if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
|
|
850
1259
|
return this.#makeError(
|
|
851
1260
|
`Cannot consume execution decision from state ${this.#state.state}`,
|
|
@@ -883,15 +1292,17 @@ class OstackyController {
|
|
|
883
1292
|
}
|
|
884
1293
|
// T3: also block on stale fingerprints-vs-disk
|
|
885
1294
|
let staleFiles = [];
|
|
1295
|
+
const seenFp2 = new Set();
|
|
886
1296
|
try {
|
|
887
1297
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
888
1298
|
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
889
1299
|
const current = fastFingerprint(info.filePath);
|
|
890
1300
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
891
1301
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1302
|
+
seenFp2.add(info.filePath);
|
|
892
1303
|
}
|
|
893
1304
|
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
894
|
-
if (
|
|
1305
|
+
if (seenFp2.has(fp)) continue;
|
|
895
1306
|
const cur = fastFingerprint(fp);
|
|
896
1307
|
if (!cur) staleFiles.push(`${fp} (missing)`);
|
|
897
1308
|
else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
@@ -910,6 +1321,19 @@ class OstackyController {
|
|
|
910
1321
|
};
|
|
911
1322
|
}
|
|
912
1323
|
if (hasBlocking && force) {
|
|
1324
|
+
// 4.3: force requiere confirmación humana en últimas 5 entradas de audit
|
|
1325
|
+
const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
|
|
1326
|
+
const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
|
|
1327
|
+
if (!hasHuman) {
|
|
1328
|
+
return {
|
|
1329
|
+
error: 'force requires human confirmation',
|
|
1330
|
+
pending,
|
|
1331
|
+
staleFiles: staleFiles.length ? staleFiles : undefined,
|
|
1332
|
+
current_state: this.#state.state,
|
|
1333
|
+
attempted_transition: 'implementation_complete',
|
|
1334
|
+
suggestion: 'User must write forzar/confirmo/force in a prior block/replan/set_handoff reasoning',
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
913
1337
|
const all = [...pending, ...staleFiles].join(',');
|
|
914
1338
|
await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
|
|
915
1339
|
}
|
|
@@ -936,15 +1360,31 @@ class OstackyController {
|
|
|
936
1360
|
|
|
937
1361
|
async block({ reason } = {}) {
|
|
938
1362
|
this.#load();
|
|
1363
|
+
const from = this.#state.state;
|
|
939
1364
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
940
1365
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
1366
|
+
// 1.9: block desde EXECUTING_* preserva tasks/fileFingerprints/expectedTasks y audita WARN
|
|
1367
|
+
const isExecuting = from === 'EXECUTING_INLINE' || from === 'EXECUTING_SUBAGENTS';
|
|
941
1368
|
await this.#transition(to, { error: reason || 'Blocked' });
|
|
942
1369
|
await this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
1370
|
+
if (isExecuting) {
|
|
1371
|
+
await this.#audit('WARN', 'block_from_executing', `block from ${from} preserved tasks: ${Object.keys(this.#state.tasks || {}).length}`);
|
|
1372
|
+
}
|
|
1373
|
+
// 10.6: increment subagentFailedCount if block reason indicates subagent failure
|
|
1374
|
+
if (reason && /subagent.*failed/i.test(reason)) {
|
|
1375
|
+
this.#state.subagentFailedCount = (this.#state.subagentFailedCount || 0) + 1;
|
|
1376
|
+
await this.#audit('WARN', 'subagent_failed', reason);
|
|
1377
|
+
try { await this.#persist(); } catch {}
|
|
1378
|
+
}
|
|
943
1379
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
944
1380
|
}
|
|
945
1381
|
|
|
946
1382
|
async replan({ reason } = {}) {
|
|
947
1383
|
this.#load();
|
|
1384
|
+
// 1.9: replan desde EXECUTING_* rechazado sin limpiar tasks
|
|
1385
|
+
if (this.#state.state === 'EXECUTING_INLINE' || this.#state.state === 'EXECUTING_SUBAGENTS') {
|
|
1386
|
+
return this.#makeError(`Cannot replan from state ${this.#state.state} — replan only from BLOCKED`, 'replan');
|
|
1387
|
+
}
|
|
948
1388
|
const to = this.#isAllowedTransition(this.#state.state, 'replan');
|
|
949
1389
|
if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
|
|
950
1390
|
await this.#transition(to, {
|
|
@@ -998,15 +1438,17 @@ class OstackyController {
|
|
|
998
1438
|
}
|
|
999
1439
|
// T3: fingerprints-vs-disk — detect stale/missing files after complete_task
|
|
1000
1440
|
let staleFiles = [];
|
|
1441
|
+
const seenFp = new Set();
|
|
1001
1442
|
try {
|
|
1002
1443
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1003
1444
|
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
1004
1445
|
const current = fastFingerprint(info.filePath);
|
|
1005
1446
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1006
1447
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1448
|
+
seenFp.add(info.filePath);
|
|
1007
1449
|
}
|
|
1008
1450
|
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
1009
|
-
if (
|
|
1451
|
+
if (seenFp.has(fp)) continue;
|
|
1010
1452
|
const current = fastFingerprint(fp);
|
|
1011
1453
|
if (!current) staleFiles.push(`${fp} (missing)`);
|
|
1012
1454
|
else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
@@ -1023,9 +1465,11 @@ class OstackyController {
|
|
|
1023
1465
|
};
|
|
1024
1466
|
}
|
|
1025
1467
|
|
|
1026
|
-
async getAudit({ limit = 20, offset = 0 } = {}) {
|
|
1468
|
+
async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
|
|
1027
1469
|
this.#load();
|
|
1028
|
-
|
|
1470
|
+
let all = this.#state.audit || [];
|
|
1471
|
+
if (phase) all = all.filter((e) => e.phase === phase);
|
|
1472
|
+
if (since) all = all.filter((e) => e.ts >= since);
|
|
1029
1473
|
const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
|
|
1030
1474
|
return slice.map((e) => ({
|
|
1031
1475
|
id: e.id,
|
|
@@ -1036,6 +1480,159 @@ class OstackyController {
|
|
|
1036
1480
|
}));
|
|
1037
1481
|
}
|
|
1038
1482
|
|
|
1483
|
+
async getMetrics() {
|
|
1484
|
+
this.#load();
|
|
1485
|
+
let stateFileSize = 0;
|
|
1486
|
+
let auditSize = 0;
|
|
1487
|
+
let diskFreeMB = null;
|
|
1488
|
+
try {
|
|
1489
|
+
const stat = statSync(this.#statePath);
|
|
1490
|
+
stateFileSize = stat.size;
|
|
1491
|
+
} catch {}
|
|
1492
|
+
try {
|
|
1493
|
+
auditSize = (this.#state.audit || []).length;
|
|
1494
|
+
} catch {}
|
|
1495
|
+
try {
|
|
1496
|
+
// diskFree via statfs if available, fallback to null
|
|
1497
|
+
const { statfsSync } = await import('node:fs');
|
|
1498
|
+
if (typeof statfsSync === 'function' && this.#statePath) {
|
|
1499
|
+
try {
|
|
1500
|
+
const s = statfsSync(dirname(this.#statePath));
|
|
1501
|
+
diskFreeMB = Math.floor((s.bfree * s.bsize) / (1024 * 1024));
|
|
1502
|
+
} catch {}
|
|
1503
|
+
}
|
|
1504
|
+
} catch {}
|
|
1505
|
+
const completed = Object.values(this.#state.tasks || {}).filter((t) => t.status === 'COMPLETED').length;
|
|
1506
|
+
const total = Object.keys(this.#state.tasks || {}).length;
|
|
1507
|
+
const pending = Array.isArray(this.#state.expectedTasks)
|
|
1508
|
+
? this.#state.expectedTasks.filter((id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED').length
|
|
1509
|
+
: typeof this.#state.expectedTaskCount === 'number'
|
|
1510
|
+
? Math.max(0, this.#state.expectedTaskCount - completed)
|
|
1511
|
+
: 0;
|
|
1512
|
+
return {
|
|
1513
|
+
revision: this.#state.revision,
|
|
1514
|
+
state: this.#state.state,
|
|
1515
|
+
degraded: this.#degraded || !!this.#state.degraded,
|
|
1516
|
+
consecutiveFailures: this.#consecutiveFailures,
|
|
1517
|
+
taskCounts: { completed, pending, total, expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null },
|
|
1518
|
+
expectedTaskCount: this.#state.expectedTaskCount,
|
|
1519
|
+
auditSize,
|
|
1520
|
+
stateFileSize,
|
|
1521
|
+
diskFreeMB,
|
|
1522
|
+
uptimeMs: Date.now() - (this.#state.ts || Date.now()),
|
|
1523
|
+
stateOversizedCount: this.#state.stateOversizedCount || 0,
|
|
1524
|
+
codegraphBypassCount: this.#state.codegraphBypassCount || 0,
|
|
1525
|
+
degradedEditsCount: this.#state.degradedEditsCount || 0,
|
|
1526
|
+
sensitiveAccess: this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 },
|
|
1527
|
+
subagentFailedCount: this.#state.subagentFailedCount || 0,
|
|
1528
|
+
staleContentAttempts: this.#state.staleContentAttempts || 0,
|
|
1529
|
+
completeWithoutValidateCount: this.#state.completeWithoutValidateCount || 0,
|
|
1530
|
+
toolTimeoutCount: this.#state.toolTimeoutCount || 0,
|
|
1531
|
+
lastToolDurationMs: this.#state.lastToolDurationMs || 0,
|
|
1532
|
+
stateDurationMs: this.#state.stateDurationMs || 0,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async _recordToolTimeout() {
|
|
1537
|
+
this.#load();
|
|
1538
|
+
this.#state.toolTimeoutCount = (this.#state.toolTimeoutCount || 0) + 1;
|
|
1539
|
+
this.#state.lastToolDurationMs = 5000;
|
|
1540
|
+
try { await this.#persist(); } catch {}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async _recordToolDuration(ms) {
|
|
1544
|
+
this.#load();
|
|
1545
|
+
this.#state.lastToolDurationMs = ms;
|
|
1546
|
+
this.#state.stateDurationMs = Date.now() - (this.#state.ts || Date.now());
|
|
1547
|
+
try { await this.#persist(); } catch {}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async recordUserConfirmation({ decisionId, confirmationText } = {}) {
|
|
1551
|
+
this.#load();
|
|
1552
|
+
if (!decisionId || typeof confirmationText !== 'string') {
|
|
1553
|
+
return { error: 'decisionId and confirmationText required' };
|
|
1554
|
+
}
|
|
1555
|
+
await this.#audit('CONFIRMATION', 'record_user_confirmation', `user confirmed: ${confirmationText} for ${decisionId}`);
|
|
1556
|
+
await this.#flushAudit(true);
|
|
1557
|
+
await this.#persist();
|
|
1558
|
+
return { ok: true, decisionId, confirmationText, ts: Date.now() };
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// --- D11: Credential guard helpers ---
|
|
1562
|
+
isSensitiveFile(filePath) {
|
|
1563
|
+
if (!filePath) return false;
|
|
1564
|
+
this.#load();
|
|
1565
|
+
const lower = (filePath || '').toLowerCase();
|
|
1566
|
+
// allowlist
|
|
1567
|
+
if (lower.endsWith('.env.example') || lower.endsWith('.env.template') || lower.endsWith('.env.sample')) return false;
|
|
1568
|
+
const patterns = (this.#state && this.#state.sensitivePatterns) || DEFAULT_STATE.sensitivePatterns || [];
|
|
1569
|
+
for (const pat of patterns) {
|
|
1570
|
+
if (pat.includes('.env') && lower.split('/').pop().startsWith('.env')) return true;
|
|
1571
|
+
if (pat.includes('.secrets') && lower.includes('.secrets')) return true;
|
|
1572
|
+
if (pat.includes('*.pem') && lower.endsWith('.pem')) return true;
|
|
1573
|
+
if (pat.includes('*.key') && lower.endsWith('.key')) return true;
|
|
1574
|
+
if (pat.includes('.aws') && lower.includes('.aws')) return true;
|
|
1575
|
+
if (pat.includes('.ssh') && lower.includes('.ssh')) return true;
|
|
1576
|
+
if (pat.includes('credentials.json') && lower.endsWith('credentials.json')) return true;
|
|
1577
|
+
if (pat.includes('.npmrc') && lower.endsWith('.npmrc')) return true;
|
|
1578
|
+
}
|
|
1579
|
+
// fallback regex for generic
|
|
1580
|
+
if (/\.(pem|key)$/i.test(filePath)) return true;
|
|
1581
|
+
if (filePath.includes('.env')) {
|
|
1582
|
+
const base = filePath.split('/').pop();
|
|
1583
|
+
if (base.startsWith('.env')) return true;
|
|
1584
|
+
}
|
|
1585
|
+
return false;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async checkFileAccess({ filePath, reason } = {}) {
|
|
1589
|
+
this.#load();
|
|
1590
|
+
if (!filePath) return { error: 'filePath required' };
|
|
1591
|
+
if (!this.isSensitiveFile(filePath)) return { allowed: true, reason: 'not sensitive' };
|
|
1592
|
+
if (this.#state.allowedFiles?.[filePath]) return { allowed: true, reason: 'previously allowed' };
|
|
1593
|
+
if (this.#state.deniedFiles?.[filePath]) {
|
|
1594
|
+
return { error: `BLOCKED: File ${filePath} requires check_file_access (previously denied)`, denied: true, filePath };
|
|
1595
|
+
}
|
|
1596
|
+
const decisionId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1597
|
+
if (!this.#state.pendingFileAccess) this.#state.pendingFileAccess = {};
|
|
1598
|
+
this.#state.pendingFileAccess[decisionId] = { filePath, reason, ts: Date.now() };
|
|
1599
|
+
await this.#audit('SECURITY', 'check_file_access', `check ${filePath} reason=${reason || 'none'}`);
|
|
1600
|
+
this.#state.sensitiveAccess = this.#state.sensitiveAccess || { allowed: 0, denied: 0, blockedAttempts: 0 };
|
|
1601
|
+
this.#state.sensitiveAccess.blockedAttempts = (this.#state.sensitiveAccess.blockedAttempts || 0) + 1;
|
|
1602
|
+
await this.#persist();
|
|
1603
|
+
return { status: 'BLOCKED', decisionId, filePath, reason: `File ${filePath} requires check_file_access` };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
async consumeFileAccessDecision({ decisionId, choice } = {}) {
|
|
1607
|
+
this.#load();
|
|
1608
|
+
if (!decisionId || !choice) return { error: 'decisionId and choice required' };
|
|
1609
|
+
if (!['ALLOW', 'DENY'].includes(choice)) return { error: 'choice must be ALLOW or DENY', available: ['ALLOW', 'DENY'] };
|
|
1610
|
+
const pending = this.#state.pendingFileAccess?.[decisionId];
|
|
1611
|
+
let filePath = pending?.filePath;
|
|
1612
|
+
// fallback: if no pending, try to find by decisionId prefix? require filePath param alternative
|
|
1613
|
+
if (!filePath && decisionId.startsWith('file-')) {
|
|
1614
|
+
// try to extract from audit? For now return error if not found
|
|
1615
|
+
return { error: 'decisionId not found', decisionId };
|
|
1616
|
+
}
|
|
1617
|
+
if (!this.#state.allowedFiles) this.#state.allowedFiles = {};
|
|
1618
|
+
if (!this.#state.deniedFiles) this.#state.deniedFiles = {};
|
|
1619
|
+
if (!this.#state.sensitiveAccess) this.#state.sensitiveAccess = { allowed: 0, denied: 0, blockedAttempts: 0 };
|
|
1620
|
+
if (choice === 'ALLOW') {
|
|
1621
|
+
this.#state.allowedFiles[filePath] = true;
|
|
1622
|
+
delete this.#state.deniedFiles[filePath];
|
|
1623
|
+
this.#state.sensitiveAccess.allowed = (this.#state.sensitiveAccess.allowed || 0) + 1;
|
|
1624
|
+
await this.#audit('SECURITY', 'consume_file_access_decision', `ALLOW ${filePath}`);
|
|
1625
|
+
} else {
|
|
1626
|
+
this.#state.deniedFiles[filePath] = true;
|
|
1627
|
+
delete this.#state.allowedFiles[filePath];
|
|
1628
|
+
this.#state.sensitiveAccess.denied = (this.#state.sensitiveAccess.denied || 0) + 1;
|
|
1629
|
+
await this.#audit('WARN', 'consume_file_access_decision', `DENY ${filePath}`);
|
|
1630
|
+
}
|
|
1631
|
+
if (this.#state.pendingFileAccess) delete this.#state.pendingFileAccess[decisionId];
|
|
1632
|
+
await this.#persist();
|
|
1633
|
+
return { ok: true, decisionId, choice, filePath };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1039
1636
|
// --- B2: Handoff persistence for cross-session continuity ---
|
|
1040
1637
|
async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
|
|
1041
1638
|
this.#load();
|
|
@@ -1100,12 +1697,44 @@ class OstackyController {
|
|
|
1100
1697
|
};
|
|
1101
1698
|
}
|
|
1102
1699
|
|
|
1103
|
-
// --- O6: Validate edit with fast fingerprint ---
|
|
1104
|
-
async validateEdit({ oldString, newString, content, taskId } = {}) {
|
|
1700
|
+
// --- O6: Validate edit with fast fingerprint + D6/D4 hard gate + 10.4 freshness ---
|
|
1701
|
+
async validateEdit({ oldString, newString, content, taskId, filePath } = {}) {
|
|
1105
1702
|
this.#load();
|
|
1106
1703
|
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
1107
1704
|
return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
|
|
1108
1705
|
}
|
|
1706
|
+
if (taskId && !isValidTaskId(taskId)) {
|
|
1707
|
+
return { outcome: 'CONFLICT', reason: `invalid taskId: ${taskId}` };
|
|
1708
|
+
}
|
|
1709
|
+
if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
|
|
1710
|
+
return { outcome: 'CONFLICT', reason: `filePath outside projectRoot: ${filePath}` };
|
|
1711
|
+
}
|
|
1712
|
+
// 9.2: guard sensible en validate_edit
|
|
1713
|
+
if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
|
|
1714
|
+
return { outcome: 'CONFLICT', reason: `BLOCKED: File ${filePath} requires check_file_access` };
|
|
1715
|
+
}
|
|
1716
|
+
// 8.5: contar edits en degraded sin confirmación auditada
|
|
1717
|
+
if (this.#degraded) {
|
|
1718
|
+
this.#state.degradedEditsCount = (this.#state.degradedEditsCount || 0) + 1;
|
|
1719
|
+
try { await this.#persist(); } catch {}
|
|
1720
|
+
}
|
|
1721
|
+
// 10.4: validación de frescura — content debe coincidir con disco si filePath dado
|
|
1722
|
+
if (filePath && typeof content === 'string') {
|
|
1723
|
+
try {
|
|
1724
|
+
const projectRoot = getProjectRoot(this.#statePath);
|
|
1725
|
+
const absolutePath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath);
|
|
1726
|
+
const diskContent = readFileSync(absolutePath, 'utf8');
|
|
1727
|
+
if (diskContent !== content) {
|
|
1728
|
+
this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
|
|
1729
|
+
await this.#persist();
|
|
1730
|
+
return { outcome: 'CONFLICT', reason: 'content stale, re-read file', filePath };
|
|
1731
|
+
}
|
|
1732
|
+
} catch (e) {
|
|
1733
|
+
if (e.code && e.code !== 'ENOENT') {
|
|
1734
|
+
// ignore ENOENT (new file), but other errors considered stale
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1109
1738
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
1110
1739
|
return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
|
|
1111
1740
|
}
|
|
@@ -1141,6 +1770,14 @@ class OstackyController {
|
|
|
1141
1770
|
};
|
|
1142
1771
|
}
|
|
1143
1772
|
// oldString found exactly once → safe to replace
|
|
1773
|
+
// 10.5: ligadura validate → complete
|
|
1774
|
+
try {
|
|
1775
|
+
const projectRoot = getProjectRoot(this.#statePath);
|
|
1776
|
+
const absolutePath = filePath ? (filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) ? resolve(filePath) : resolve(projectRoot, filePath)) : null;
|
|
1777
|
+
const hash = absolutePath ? fastFingerprint(absolutePath) : null;
|
|
1778
|
+
this.#state.lastValidated = { filePath: filePath || null, hash, ts: Date.now() };
|
|
1779
|
+
await this.#persist();
|
|
1780
|
+
} catch {}
|
|
1144
1781
|
return { outcome: 'EDITABLE', taskId };
|
|
1145
1782
|
}
|
|
1146
1783
|
|
|
@@ -1154,10 +1791,32 @@ class OstackyController {
|
|
|
1154
1791
|
return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
|
|
1155
1792
|
}
|
|
1156
1793
|
if (!taskId) return { error: 'taskId is required' };
|
|
1794
|
+
if (!isValidTaskId(taskId)) return { error: 'invalid taskId: must match /^[a-zA-Z0-9-_.\/:]+$/', taskId };
|
|
1795
|
+
if (filePath && !isPathInsideProject(filePath, this.#statePath)) {
|
|
1796
|
+
return { error: 'filePath outside projectRoot', filePath };
|
|
1797
|
+
}
|
|
1798
|
+
// 9.2: guard de credenciales — rechazar sensibles sin ALLOW
|
|
1799
|
+
if (filePath && this.isSensitiveFile(filePath) && !this.#state.allowedFiles?.[filePath]) {
|
|
1800
|
+
return { error: `BLOCKED: File ${filePath} requires check_file_access`, filePath };
|
|
1801
|
+
}
|
|
1157
1802
|
if (!this.#state.tasks) this.#state.tasks = {};
|
|
1158
1803
|
|
|
1159
1804
|
// O6: Use fast fingerprint if no hash provided
|
|
1160
1805
|
const effectiveHash = fileHash || (filePath ? fastFingerprint(filePath) : null);
|
|
1806
|
+
// 1.6: fingerprint obligatorio si archivo existe
|
|
1807
|
+
if (filePath) {
|
|
1808
|
+
const existsCheck = fastFingerprint(filePath);
|
|
1809
|
+
if (existsCheck && !effectiveHash) {
|
|
1810
|
+
return { error: 'fingerprint required: file exists but fileHash is null' };
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
// 10.5: ligadura validate → complete — WARN si no hubo validate previo
|
|
1814
|
+
if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
|
|
1815
|
+
this.#state.completeWithoutValidateCount = (this.#state.completeWithoutValidateCount || 0) + 1;
|
|
1816
|
+
await this.#audit('WARN', 'complete_without_validate', `complete_task without prior validate_edit for ${filePath || taskId}`);
|
|
1817
|
+
} else {
|
|
1818
|
+
this.#state.lastValidated = null;
|
|
1819
|
+
}
|
|
1161
1820
|
|
|
1162
1821
|
this.#state.tasks[taskId] = {
|
|
1163
1822
|
status: 'COMPLETED',
|
|
@@ -1174,18 +1833,31 @@ class OstackyController {
|
|
|
1174
1833
|
(k) => this.#state.tasks[k].status === 'COMPLETED'
|
|
1175
1834
|
).length;
|
|
1176
1835
|
// C2: checkpoint count-based cada 3er complete_task — mismo persist, sin escritura extra
|
|
1836
|
+
// 1.8: preservación de handoff manual reciente (<60s) con pendingTasks distintos
|
|
1177
1837
|
if (totalCompleted % 3 === 0) {
|
|
1178
1838
|
const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
|
|
1179
1839
|
? this.#state.expectedTasks.filter(
|
|
1180
1840
|
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1181
1841
|
)
|
|
1182
1842
|
: [];
|
|
1183
|
-
this.#state.lastHandoff
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
pendingTasks
|
|
1188
|
-
|
|
1843
|
+
const existing = this.#state.lastHandoff;
|
|
1844
|
+
const isRecentManual = existing && (Date.now() - existing.ts < 60000) && existing.summary && !existing.summary.startsWith('Checkpoint auto');
|
|
1845
|
+
let shouldOverwrite = true;
|
|
1846
|
+
if (isRecentManual) {
|
|
1847
|
+
const existingPending = existing.pendingTasks || [];
|
|
1848
|
+
const isDistinct = pendingForHandoff.length !== existingPending.length || pendingForHandoff.some((id) => !existingPending.includes(id));
|
|
1849
|
+
if (isDistinct && existingPending.length > 0) {
|
|
1850
|
+
shouldOverwrite = false;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (shouldOverwrite) {
|
|
1854
|
+
this.#state.lastHandoff = {
|
|
1855
|
+
ts: Date.now(),
|
|
1856
|
+
summary: `Checkpoint auto: ${totalCompleted} tasks completadas`,
|
|
1857
|
+
nextSteps: pendingForHandoff.length ? [`Continuar con ${pendingForHandoff.join(', ')}`] : [],
|
|
1858
|
+
pendingTasks: pendingForHandoff,
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1189
1861
|
}
|
|
1190
1862
|
await this.#persist();
|
|
1191
1863
|
await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
|
|
@@ -1207,22 +1879,49 @@ class OstackyController {
|
|
|
1207
1879
|
if (!this.#state.audit) this.#state.audit = [];
|
|
1208
1880
|
for (const e of this.#auditBuffer) {
|
|
1209
1881
|
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
1882
|
+
if (e.reasoning && SENSITIVE_REDACT_RE.test(e.reasoning)) e.reasoning = e.reasoning.replace(SENSITIVE_REDACT_RE, '[REDACTED]');
|
|
1210
1883
|
}
|
|
1211
1884
|
this.#state.audit.push(...this.#auditBuffer);
|
|
1212
|
-
|
|
1885
|
+
const retention = getAuditRetentionSafe();
|
|
1886
|
+
if (this.#state.audit.length > retention) this.#state.audit = this.#state.audit.slice(-retention);
|
|
1213
1887
|
this.#auditBuffer = [];
|
|
1214
1888
|
}
|
|
1215
|
-
// T1: final persist path kept synchronous for graceful shutdown
|
|
1889
|
+
// T1: final persist path kept synchronous for graceful shutdown (+ D2 stale-aware 15s)
|
|
1216
1890
|
if (!this.#statePath || !this.#state || !this.#loaded) return;
|
|
1217
1891
|
try {
|
|
1892
|
+
// D2: replicate staleWindow logic sync — check timestamp before acquiring
|
|
1893
|
+
try {
|
|
1894
|
+
const tsRaw = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
1895
|
+
const age = Date.now() - parseInt(tsRaw, 10);
|
|
1896
|
+
if (!Number.isNaN(age) && age >= 15000) {
|
|
1897
|
+
try { unlinkSync(this.#lockPidPath); } catch {}
|
|
1898
|
+
try { unlinkSync(this.#lockHeartbeatPath); } catch {}
|
|
1899
|
+
this.#lockOwner = false;
|
|
1900
|
+
} else if (!Number.isNaN(age) && age < 15000) {
|
|
1901
|
+
try {
|
|
1902
|
+
const pidRaw = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
1903
|
+
if (pidRaw !== String(process.pid)) return;
|
|
1904
|
+
} catch {}
|
|
1905
|
+
}
|
|
1906
|
+
} catch {}
|
|
1218
1907
|
try {
|
|
1219
1908
|
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1220
1909
|
} catch (e) {
|
|
1221
|
-
if (e && e.code === 'EEXIST')
|
|
1222
|
-
|
|
1910
|
+
if (e && e.code === 'EEXIST') {
|
|
1911
|
+
try {
|
|
1912
|
+
const tsRaw2 = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
1913
|
+
const age2 = Date.now() - parseInt(tsRaw2, 10);
|
|
1914
|
+
if (!Number.isNaN(age2) && age2 >= 15000) {
|
|
1915
|
+
try { unlinkSync(this.#lockPidPath); } catch {}
|
|
1916
|
+
try { unlinkSync(this.#lockHeartbeatPath); } catch {}
|
|
1917
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1918
|
+
} else return;
|
|
1919
|
+
} catch { return; }
|
|
1920
|
+
} else throw e;
|
|
1223
1921
|
}
|
|
1224
1922
|
try {
|
|
1225
1923
|
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
1924
|
+
this.#lockOwner = true;
|
|
1226
1925
|
} catch {}
|
|
1227
1926
|
const serialized = safeJsonStringify(this.#state, true);
|
|
1228
1927
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
@@ -1245,10 +1944,25 @@ const controller = new OstackyController({ statePath });
|
|
|
1245
1944
|
*/
|
|
1246
1945
|
function safeHandler(fn) {
|
|
1247
1946
|
return async (params) => {
|
|
1947
|
+
const start = Date.now();
|
|
1248
1948
|
try {
|
|
1249
|
-
const result = await
|
|
1949
|
+
const result = await Promise.race([
|
|
1950
|
+
fn(params),
|
|
1951
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout 5s')), 5000)),
|
|
1952
|
+
]);
|
|
1953
|
+
const duration = Date.now() - start;
|
|
1954
|
+
try { await controller._recordToolDuration(duration); } catch {}
|
|
1250
1955
|
return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
|
|
1251
1956
|
} catch (error) {
|
|
1957
|
+
const isTimeout = error && error.message && error.message.includes('timeout 5s');
|
|
1958
|
+
if (isTimeout) {
|
|
1959
|
+
log('warn:tool_timeout', { tool: fn.name || 'anonymous', durationMs: 5000 });
|
|
1960
|
+
try { await controller._recordToolTimeout(); } catch {}
|
|
1961
|
+
return {
|
|
1962
|
+
content: [{ type: 'text', text: safeJsonStringify({ error: 'timeout 5s', degraded: true }) }],
|
|
1963
|
+
isError: true,
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1252
1966
|
log('tool:error', {
|
|
1253
1967
|
name: fn.name || 'anonymous',
|
|
1254
1968
|
error: error.message,
|
|
@@ -1264,7 +1978,7 @@ function safeHandler(fn) {
|
|
|
1264
1978
|
|
|
1265
1979
|
const server = new McpServer({
|
|
1266
1980
|
name: 'ostacky-controller',
|
|
1267
|
-
version: '0.7.
|
|
1981
|
+
version: '0.7.3',
|
|
1268
1982
|
});
|
|
1269
1983
|
|
|
1270
1984
|
server.registerTool(
|
|
@@ -1478,11 +2192,70 @@ server.registerTool(
|
|
|
1478
2192
|
inputSchema: z.object({
|
|
1479
2193
|
limit: z.number().optional().describe('Max entries (default 20)'),
|
|
1480
2194
|
offset: z.number().optional().describe('Offset from end (default 0)'),
|
|
2195
|
+
phase: z.string().optional().describe('Filter by phase (e.g. WARN, LEVEL_RESOLVED)'),
|
|
2196
|
+
since: z.number().optional().describe('Filter by timestamp >= since'),
|
|
1481
2197
|
}),
|
|
1482
2198
|
},
|
|
1483
|
-
safeHandler(async ({ limit, offset }) => {
|
|
1484
|
-
log('tool:get_audit', { limit, offset });
|
|
1485
|
-
return await controller.getAudit({ limit, offset });
|
|
2199
|
+
safeHandler(async ({ limit, offset, phase, since }) => {
|
|
2200
|
+
log('tool:get_audit', { limit, offset, phase, since });
|
|
2201
|
+
return await controller.getAudit({ limit, offset, phase, since });
|
|
2202
|
+
})
|
|
2203
|
+
);
|
|
2204
|
+
|
|
2205
|
+
server.registerTool(
|
|
2206
|
+
'get_metrics',
|
|
2207
|
+
{
|
|
2208
|
+
description: 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
|
|
2209
|
+
inputSchema: z.object({}),
|
|
2210
|
+
},
|
|
2211
|
+
safeHandler(async () => {
|
|
2212
|
+
log('tool:get_metrics');
|
|
2213
|
+
return await controller.getMetrics();
|
|
2214
|
+
})
|
|
2215
|
+
);
|
|
2216
|
+
|
|
2217
|
+
server.registerTool(
|
|
2218
|
+
'record_user_confirmation',
|
|
2219
|
+
{
|
|
2220
|
+
description: 'Record user confirmation with decisionId and literal text. Required for force and human-in-the-loop gates.',
|
|
2221
|
+
inputSchema: z.object({
|
|
2222
|
+
decisionId: z.string().describe('Decision ID from pending state'),
|
|
2223
|
+
confirmationText: z.string().describe('Literal user confirmation text'),
|
|
2224
|
+
}),
|
|
2225
|
+
},
|
|
2226
|
+
safeHandler(async ({ decisionId, confirmationText }) => {
|
|
2227
|
+
log('tool:record_user_confirmation', { decisionId });
|
|
2228
|
+
return await controller.recordUserConfirmation({ decisionId, confirmationText });
|
|
2229
|
+
})
|
|
2230
|
+
);
|
|
2231
|
+
|
|
2232
|
+
server.registerTool(
|
|
2233
|
+
'check_file_access',
|
|
2234
|
+
{
|
|
2235
|
+
description: 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
|
|
2236
|
+
inputSchema: z.object({
|
|
2237
|
+
filePath: z.string().describe('File path to check'),
|
|
2238
|
+
reason: z.string().optional().describe('Reason for access'),
|
|
2239
|
+
}),
|
|
2240
|
+
},
|
|
2241
|
+
safeHandler(async ({ filePath, reason }) => {
|
|
2242
|
+
log('tool:check_file_access', { filePath });
|
|
2243
|
+
return await controller.checkFileAccess({ filePath, reason });
|
|
2244
|
+
})
|
|
2245
|
+
);
|
|
2246
|
+
|
|
2247
|
+
server.registerTool(
|
|
2248
|
+
'consume_file_access_decision',
|
|
2249
|
+
{
|
|
2250
|
+
description: 'Consume file access decision: ALLOW or DENY. Persists allowedFiles/deniedFiles.',
|
|
2251
|
+
inputSchema: z.object({
|
|
2252
|
+
decisionId: z.string().describe('Decision ID from check_file_access'),
|
|
2253
|
+
choice: z.enum(['ALLOW', 'DENY']).describe('Choice'),
|
|
2254
|
+
}),
|
|
2255
|
+
},
|
|
2256
|
+
safeHandler(async ({ decisionId, choice }) => {
|
|
2257
|
+
log('tool:consume_file_access_decision', { decisionId, choice });
|
|
2258
|
+
return await controller.consumeFileAccessDecision({ decisionId, choice });
|
|
1486
2259
|
})
|
|
1487
2260
|
);
|
|
1488
2261
|
|
|
@@ -1521,14 +2294,19 @@ server.registerTool(
|
|
|
1521
2294
|
inputSchema: z.object({}),
|
|
1522
2295
|
},
|
|
1523
2296
|
safeHandler(async () => {
|
|
2297
|
+
const state = await controller.getState();
|
|
2298
|
+
const metrics = await controller.getMetrics().catch(() => ({}));
|
|
1524
2299
|
return {
|
|
1525
2300
|
pong: true,
|
|
1526
2301
|
degraded: controller.degraded,
|
|
1527
|
-
state:
|
|
1528
|
-
state:
|
|
1529
|
-
revision:
|
|
1530
|
-
requestId:
|
|
1531
|
-
}
|
|
2302
|
+
state: {
|
|
2303
|
+
state: state.state,
|
|
2304
|
+
revision: state.revision,
|
|
2305
|
+
requestId: state.requestId,
|
|
2306
|
+
},
|
|
2307
|
+
diskFreeMB: metrics.diskFreeMB ?? null,
|
|
2308
|
+
stateFileSize: metrics.stateFileSize ?? null,
|
|
2309
|
+
auditSize: metrics.auditSize ?? null,
|
|
1532
2310
|
};
|
|
1533
2311
|
})
|
|
1534
2312
|
);
|
|
@@ -1650,14 +2428,16 @@ server.registerTool(
|
|
|
1650
2428
|
'Without this parameter, validate_edit will fail.'
|
|
1651
2429
|
),
|
|
1652
2430
|
taskId: z.string().optional().describe('Optional task ID for tracking.'),
|
|
2431
|
+
filePath: z.string().optional().describe('Optional file path for traversal validation.'),
|
|
1653
2432
|
}),
|
|
1654
2433
|
},
|
|
1655
|
-
safeHandler(async ({ oldString, newString, content, taskId }) => {
|
|
2434
|
+
safeHandler(async ({ oldString, newString, content, taskId, filePath }) => {
|
|
1656
2435
|
log('tool:validate_edit', {
|
|
1657
2436
|
taskId,
|
|
1658
2437
|
oldLen: oldString?.length,
|
|
1659
2438
|
newLen: newString?.length,
|
|
1660
2439
|
hasContent: !!content,
|
|
2440
|
+
filePath,
|
|
1661
2441
|
});
|
|
1662
2442
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
1663
2443
|
return {
|
|
@@ -1665,7 +2445,7 @@ server.registerTool(
|
|
|
1665
2445
|
reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
|
|
1666
2446
|
};
|
|
1667
2447
|
}
|
|
1668
|
-
return await controller.validateEdit({ oldString, newString, content, taskId });
|
|
2448
|
+
return await controller.validateEdit({ oldString, newString, content, taskId, filePath });
|
|
1669
2449
|
})
|
|
1670
2450
|
);
|
|
1671
2451
|
|
|
@@ -1721,7 +2501,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
1721
2501
|
}
|
|
1722
2502
|
|
|
1723
2503
|
async function main() {
|
|
1724
|
-
log('Starting ostacky-controller MCP v0.7.
|
|
2504
|
+
log('Starting ostacky-controller MCP v0.7.3...');
|
|
1725
2505
|
log('State path:', { path: statePath });
|
|
1726
2506
|
// Clean up stale tmp/lock files from previous runs
|
|
1727
2507
|
cleanupTmpFiles(statePath);
|