ostacky 0.7.1 → 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 +18 -11
- package/assets/agents/ostacky.md +44 -14
- package/assets/commands/install-stack.md +20 -1
- package/assets/mcp/ostacky-controller/index.js +1294 -147
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/engram.ts +118 -8
- 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 +13 -0
- package/assets/skills/using-git-worktrees/SKILL.md +8 -0
- package/dist/cli.js +479 -138
- package/manifest.json +31 -31
- package/package.json +1 -1
|
@@ -15,15 +15,92 @@
|
|
|
15
15
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
16
16
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
17
17
|
import * as z from 'zod/v4';
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
statSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
} from 'node:fs';
|
|
28
|
+
import { dirname, basename, join, resolve, relative } from 'node:path';
|
|
29
|
+
import { writeFile as writeFileAsync, rename as renameAsync, mkdir as mkdirAsync } from 'node:fs/promises';
|
|
30
|
+
|
|
31
|
+
// T1: non-blocking wait — replaces busy-wait spins that froze the event loop
|
|
32
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
33
|
|
|
21
34
|
// --- Constants (Fase 5.5 — headroom generoso) ---
|
|
22
35
|
const MAX_TASKS = 100;
|
|
36
|
+
const MAX_TASKS_DEFAULT = 100;
|
|
37
|
+
const MAX_TASKS_CAP = 500;
|
|
23
38
|
const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
|
|
24
39
|
const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
|
|
25
40
|
const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
|
|
26
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
|
+
|
|
27
104
|
// --- Transition table ---
|
|
28
105
|
const TRANSITIONS = {
|
|
29
106
|
INTERPRETATION_PENDING: [
|
|
@@ -126,28 +203,102 @@ function safeJsonStringify(obj, pretty = false) {
|
|
|
126
203
|
}
|
|
127
204
|
}
|
|
128
205
|
|
|
129
|
-
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
|
+
}
|
|
130
242
|
const ts = new Date().toISOString();
|
|
131
|
-
const
|
|
132
|
-
|
|
243
|
+
const safeData = redactForLog(data);
|
|
244
|
+
const payload = safeData ? ` ${safeJsonStringify(safeData)}` : '';
|
|
245
|
+
console.error(`[${ts}] ${level}:${event}${payload}`);
|
|
133
246
|
}
|
|
134
247
|
|
|
135
248
|
/**
|
|
136
|
-
* Cleans up stale .tmp.* and .lock.* files from a previous crash.
|
|
249
|
+
* Cleans up stale .tmp.* and .lock.* files from a previous crash — C3 fix: never delete active locks of another process.
|
|
250
|
+
* Also handles orphaned .ostacky-handoff-compaction.json (only if ts>24h).
|
|
137
251
|
*/
|
|
138
252
|
function cleanupTmpFiles(statePath) {
|
|
139
253
|
if (!statePath) return;
|
|
140
254
|
const dir = dirname(statePath);
|
|
141
255
|
const name = basename(statePath);
|
|
256
|
+
const staleWindow = 15000;
|
|
257
|
+
const handoffTtl = 24 * 60 * 60 * 1000;
|
|
142
258
|
try {
|
|
143
259
|
for (const entry of readdirSync(dir)) {
|
|
144
|
-
|
|
260
|
+
const isTmp = entry.startsWith(name + '.tmp.');
|
|
261
|
+
const isLock = entry.startsWith(name + '.lock');
|
|
262
|
+
const isHandoff = entry === '.ostacky-handoff-compaction.json';
|
|
263
|
+
if (!isTmp && !isLock && !isHandoff) continue;
|
|
264
|
+
// C3: don't delete active lock of another process
|
|
265
|
+
if (isLock) {
|
|
145
266
|
try {
|
|
146
|
-
|
|
267
|
+
const pidPath = join(dir, name + '.lock.pid');
|
|
268
|
+
const tsPath = join(dir, name + '.lock.timestamp');
|
|
269
|
+
// If we are checking a lock file, verify liveness
|
|
270
|
+
let lockPid = null;
|
|
271
|
+
let lockTs = null;
|
|
272
|
+
try {
|
|
273
|
+
lockPid = readFileSync(pidPath, 'utf8').trim();
|
|
274
|
+
} catch {}
|
|
275
|
+
try {
|
|
276
|
+
lockTs = parseInt(readFileSync(tsPath, 'utf8').trim(), 10);
|
|
277
|
+
} catch {}
|
|
278
|
+
if (lockPid && lockTs && !Number.isNaN(lockTs)) {
|
|
279
|
+
const age = Date.now() - lockTs;
|
|
280
|
+
if (age < staleWindow && String(lockPid) !== String(process.pid)) {
|
|
281
|
+
continue; // active lock of another process — skip
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
if (isHandoff) {
|
|
287
|
+
try {
|
|
288
|
+
const handoffPath = join(dir, entry);
|
|
289
|
+
const raw = readFileSync(handoffPath, 'utf8');
|
|
290
|
+
const data = JSON.parse(raw);
|
|
291
|
+
const ts = data?.ts ?? data?.timestamp ?? 0;
|
|
292
|
+
if (ts && Date.now() - ts < handoffTtl) continue; // keep recent handoff
|
|
147
293
|
} catch {
|
|
148
|
-
|
|
294
|
+
// If unreadable, treat as stale and delete
|
|
149
295
|
}
|
|
150
296
|
}
|
|
297
|
+
try {
|
|
298
|
+
unlinkSync(join(dir, entry));
|
|
299
|
+
} catch {
|
|
300
|
+
/* best-effort */
|
|
301
|
+
}
|
|
151
302
|
}
|
|
152
303
|
} catch {
|
|
153
304
|
/* directory may not exist yet */
|
|
@@ -195,6 +346,28 @@ const DEFAULT_STATE = Object.freeze({
|
|
|
195
346
|
fileFingerprints: {},
|
|
196
347
|
error: null,
|
|
197
348
|
lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
|
|
349
|
+
expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
|
|
350
|
+
expectedTaskCount: null, // C2: count fallback when IDs not available
|
|
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
|
|
198
371
|
});
|
|
199
372
|
|
|
200
373
|
class OstackyController {
|
|
@@ -207,7 +380,8 @@ class OstackyController {
|
|
|
207
380
|
#lockPath;
|
|
208
381
|
#lockPidPath;
|
|
209
382
|
#lockHeartbeatPath;
|
|
210
|
-
#lockMaxAttempts =
|
|
383
|
+
#lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
|
|
384
|
+
#lockOwner = false;
|
|
211
385
|
|
|
212
386
|
constructor(opts = {}) {
|
|
213
387
|
this.#statePath = opts.statePath;
|
|
@@ -218,7 +392,8 @@ class OstackyController {
|
|
|
218
392
|
this.#lockMaxAttempts = opts.lockMaxAttempts;
|
|
219
393
|
}
|
|
220
394
|
if (opts.initialState) {
|
|
221
|
-
this.#state = { ...DEFAULT_STATE, ...opts.initialState };
|
|
395
|
+
this.#state = { ...structuredClone(DEFAULT_STATE), ...opts.initialState };
|
|
396
|
+
this.#degraded = !!this.#state.degraded;
|
|
222
397
|
this.#loaded = true;
|
|
223
398
|
} else {
|
|
224
399
|
this.#state = null;
|
|
@@ -246,56 +421,80 @@ class OstackyController {
|
|
|
246
421
|
return null; // valid
|
|
247
422
|
}
|
|
248
423
|
|
|
249
|
-
// --- 3.4: State file locking ---
|
|
250
|
-
#acquireLock() {
|
|
424
|
+
// --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
|
|
425
|
+
async #acquireLock() {
|
|
251
426
|
if (!this.#lockPath) return true;
|
|
252
|
-
// Allow tests to shorten retry loops via opts.lockMaxAttempts
|
|
253
427
|
const maxAttempts = this.#lockMaxAttempts;
|
|
254
|
-
const lockTimeout =
|
|
428
|
+
const lockTimeout = 1000;
|
|
429
|
+
const staleWindow = 15000;
|
|
255
430
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
256
431
|
try {
|
|
257
|
-
|
|
258
|
-
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
259
|
-
// Check if lock is stale (>30s without heartbeat)
|
|
432
|
+
// Check existing lock BEFORE overwriting — corrects mutual-exclusion bug
|
|
260
433
|
try {
|
|
261
434
|
const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
262
435
|
const lockAge = Date.now() - parseInt(lockContent, 10);
|
|
263
|
-
if (lockAge
|
|
264
|
-
const
|
|
436
|
+
if (!Number.isNaN(lockAge) && lockAge < staleWindow) {
|
|
437
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
438
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
439
|
+
const waitMs = Math.max(0, base + jitter);
|
|
440
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (!Number.isNaN(lockAge) && lockAge >= staleWindow) {
|
|
265
444
|
try {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
445
|
+
const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
446
|
+
try {
|
|
447
|
+
process.kill(parseInt(lockPid, 10), 0);
|
|
448
|
+
// PID alive but stale beyond window — force release
|
|
449
|
+
} catch {
|
|
450
|
+
// PID dead — force release
|
|
271
451
|
}
|
|
272
|
-
} catch {
|
|
273
|
-
// PID doesn't exist — force release
|
|
274
|
-
}
|
|
452
|
+
} catch {}
|
|
275
453
|
this.#releaseLock();
|
|
276
|
-
continue;
|
|
277
454
|
}
|
|
278
455
|
} catch {
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
456
|
+
// No heartbeat file — try to acquire
|
|
457
|
+
}
|
|
458
|
+
// Atomic acquire with wx — fails if another process won the race
|
|
459
|
+
try {
|
|
460
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
461
|
+
} catch (e) {
|
|
462
|
+
if (e && e.code === 'EEXIST') {
|
|
463
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
464
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
465
|
+
const waitMs = Math.max(0, base + jitter);
|
|
466
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
throw e;
|
|
282
470
|
}
|
|
471
|
+
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
472
|
+
this.#lockOwner = true;
|
|
283
473
|
return true;
|
|
284
474
|
} catch {
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
/* spin wait */
|
|
290
|
-
}
|
|
475
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
476
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
477
|
+
const waitMs = Math.max(0, base + jitter);
|
|
478
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
291
479
|
}
|
|
292
480
|
}
|
|
293
481
|
log('warn:lock_acquire_failed', { attempts: maxAttempts });
|
|
482
|
+
this.#lockOwner = false;
|
|
294
483
|
return false;
|
|
295
484
|
}
|
|
296
485
|
|
|
297
486
|
#releaseLock() {
|
|
298
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
|
+
}
|
|
299
498
|
try {
|
|
300
499
|
unlinkSync(this.#lockPidPath);
|
|
301
500
|
} catch {
|
|
@@ -306,6 +505,7 @@ class OstackyController {
|
|
|
306
505
|
} catch {
|
|
307
506
|
/* best-effort */
|
|
308
507
|
}
|
|
508
|
+
this.#lockOwner = false;
|
|
309
509
|
}
|
|
310
510
|
|
|
311
511
|
#heartbeatLock() {
|
|
@@ -320,7 +520,7 @@ class OstackyController {
|
|
|
320
520
|
#load() {
|
|
321
521
|
if (this.#loaded) return;
|
|
322
522
|
if (!this.#statePath) {
|
|
323
|
-
this.#state =
|
|
523
|
+
this.#state = structuredClone(DEFAULT_STATE);
|
|
324
524
|
this.#loaded = true;
|
|
325
525
|
return;
|
|
326
526
|
}
|
|
@@ -331,41 +531,86 @@ class OstackyController {
|
|
|
331
531
|
const parsed = JSON.parse(raw);
|
|
332
532
|
const validationError = this.#validateState(parsed);
|
|
333
533
|
if (validationError) throw new Error(`State validation failed: ${validationError}`);
|
|
334
|
-
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;
|
|
335
559
|
this.#loaded = true;
|
|
336
560
|
return;
|
|
337
561
|
} catch (err) {
|
|
338
562
|
log('warn:load_primary_failed', { error: err.message });
|
|
339
563
|
}
|
|
340
|
-
// Fallback: try .backup
|
|
341
|
-
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
|
+
}
|
|
342
594
|
try {
|
|
343
|
-
|
|
344
|
-
if (raw.length > MAX_STATE_FILE_SIZE) throw new Error(`Backup too large: ${raw.length} bytes`);
|
|
345
|
-
const parsed = JSON.parse(raw);
|
|
346
|
-
const validationError = this.#validateState(parsed);
|
|
347
|
-
if (validationError) throw new Error(`Backup validation failed: ${validationError}`);
|
|
348
|
-
this.#state = { ...DEFAULT_STATE, ...parsed, error: 'State restored from backup' };
|
|
349
|
-
log('warn:state_restored_from_backup');
|
|
350
|
-
this.#loaded = true;
|
|
351
|
-
return;
|
|
595
|
+
throw new Error('All backups failed');
|
|
352
596
|
} catch (backupErr) {
|
|
353
597
|
// No backup either — set error state instead of silent reset
|
|
354
598
|
this.#state = {
|
|
355
|
-
...DEFAULT_STATE,
|
|
599
|
+
...structuredClone(DEFAULT_STATE),
|
|
356
600
|
error: `State file corrupt: ${backupErr.message}. No backup available. State reset to default.`,
|
|
357
601
|
};
|
|
602
|
+
this.#degraded = !!this.#state.degraded;
|
|
358
603
|
log('warn:state_reset', { error: backupErr.message });
|
|
359
604
|
}
|
|
360
605
|
this.#loaded = true;
|
|
361
606
|
}
|
|
362
607
|
|
|
363
|
-
#persist() {
|
|
608
|
+
async #persist() {
|
|
364
609
|
if (!this.#statePath) return;
|
|
365
610
|
|
|
366
611
|
const dir = dirname(this.#statePath);
|
|
367
612
|
try {
|
|
368
|
-
|
|
613
|
+
await mkdirAsync(dir, { recursive: true });
|
|
369
614
|
} catch (err) {
|
|
370
615
|
// mkdir failures also count toward degraded mode
|
|
371
616
|
this.#consecutiveFailures++;
|
|
@@ -376,40 +621,75 @@ class OstackyController {
|
|
|
376
621
|
throw err;
|
|
377
622
|
}
|
|
378
623
|
|
|
624
|
+
let didAcquire = false;
|
|
379
625
|
try {
|
|
380
626
|
// 3.4: Acquire lock before writing
|
|
381
|
-
const lockAcquired = this.#acquireLock();
|
|
627
|
+
const lockAcquired = await this.#acquireLock();
|
|
382
628
|
if (!lockAcquired) {
|
|
383
629
|
log('warn:persist_skipped_lock', { state: this.#state.state });
|
|
384
630
|
throw new Error('Could not acquire state file lock');
|
|
385
631
|
}
|
|
632
|
+
didAcquire = lockAcquired;
|
|
386
633
|
|
|
387
|
-
|
|
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);
|
|
388
658
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
389
659
|
log('warn:state_oversized', { size: serialized.length });
|
|
390
|
-
|
|
660
|
+
this.#state.stateOversizedCount = (this.#state.stateOversizedCount || 0) + 1;
|
|
661
|
+
const trimmed = { ...stateForSerialize, snapshots: { codegraph: null, execution: null } };
|
|
391
662
|
serialized = safeJsonStringify(trimmed, true);
|
|
392
663
|
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
393
664
|
log('error:state_too_large_even_after_trim');
|
|
394
665
|
return;
|
|
395
666
|
}
|
|
396
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);
|
|
397
671
|
}
|
|
398
672
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
399
|
-
|
|
400
|
-
|
|
673
|
+
await writeFileAsync(tmp, serialized, 'utf8');
|
|
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 {}
|
|
401
680
|
try {
|
|
402
681
|
const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
|
|
403
|
-
|
|
404
|
-
|
|
682
|
+
await writeFileAsync(backupTmp, serialized, 'utf8');
|
|
683
|
+
await renameAsync(backupTmp, this.#statePath + '.backup');
|
|
405
684
|
} catch {
|
|
406
685
|
/* backup is best-effort */
|
|
407
686
|
}
|
|
408
|
-
// B1: persist success → reset failure counter
|
|
687
|
+
// B1: persist success → reset failure counter + auto-exit degraded
|
|
409
688
|
if (this.#consecutiveFailures > 0) {
|
|
410
689
|
log('info:persist_recovered', { after: this.#consecutiveFailures });
|
|
411
690
|
}
|
|
412
691
|
this.#consecutiveFailures = 0;
|
|
692
|
+
if (this.#degraded) this.#exitDegradedMode();
|
|
413
693
|
} catch (err) {
|
|
414
694
|
// B1: persist failure → increment counter, auto-degrade if threshold reached
|
|
415
695
|
this.#consecutiveFailures++;
|
|
@@ -421,7 +701,7 @@ class OstackyController {
|
|
|
421
701
|
}
|
|
422
702
|
throw err;
|
|
423
703
|
} finally {
|
|
424
|
-
this.#releaseLock();
|
|
704
|
+
if (didAcquire) this.#releaseLock();
|
|
425
705
|
}
|
|
426
706
|
}
|
|
427
707
|
|
|
@@ -435,24 +715,66 @@ class OstackyController {
|
|
|
435
715
|
|
|
436
716
|
#trimTasks() {
|
|
437
717
|
if (!this.#state.tasks) return;
|
|
718
|
+
const limit = getMaxTasks();
|
|
438
719
|
const entries = Object.entries(this.#state.tasks);
|
|
439
|
-
if (entries.length <=
|
|
440
|
-
|
|
441
|
-
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) => {
|
|
442
743
|
const da = a[1].completedAt || '';
|
|
443
744
|
const db = b[1].completedAt || '';
|
|
444
|
-
return
|
|
745
|
+
return da.localeCompare(db);
|
|
445
746
|
});
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
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 });
|
|
449
771
|
}
|
|
450
772
|
|
|
451
|
-
#transition(to, changes = {}) {
|
|
773
|
+
async #transition(to, changes = {}) {
|
|
452
774
|
this.#state.revision++;
|
|
453
775
|
this.#state.state = to;
|
|
454
776
|
Object.assign(this.#state, changes);
|
|
455
|
-
this.#persist();
|
|
777
|
+
await this.#persist();
|
|
456
778
|
}
|
|
457
779
|
|
|
458
780
|
// --- O4: O(1) transition lookup via pre-computed cache ---
|
|
@@ -461,28 +783,59 @@ class OstackyController {
|
|
|
461
783
|
return ALLOWED_TRANSITIONS[from]?.get(key) || null;
|
|
462
784
|
}
|
|
463
785
|
|
|
464
|
-
// --- O5: Batched audit trail ---
|
|
465
|
-
#audit(phase, decision, reasoning) {
|
|
466
|
-
|
|
467
|
-
if (
|
|
468
|
-
|
|
786
|
+
// --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
|
|
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
|
+
}
|
|
794
|
+
const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
|
|
795
|
+
this.#auditBuffer.push({
|
|
796
|
+
id,
|
|
797
|
+
ts: Date.now(),
|
|
798
|
+
phase,
|
|
799
|
+
decision,
|
|
800
|
+
reasoning: redactedReasoning,
|
|
801
|
+
});
|
|
802
|
+
const isWarn = phase === 'WARN';
|
|
803
|
+
if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
|
|
804
|
+
await this.#flushAudit(isWarn);
|
|
469
805
|
}
|
|
470
806
|
}
|
|
471
807
|
|
|
472
|
-
#flushAudit() {
|
|
808
|
+
async #flushAudit(forcePersist = false) {
|
|
473
809
|
if (this.#auditBuffer.length === 0) return;
|
|
474
810
|
if (!this.#state.audit) this.#state.audit = [];
|
|
811
|
+
for (const e of this.#auditBuffer) {
|
|
812
|
+
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
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
|
+
}
|
|
822
|
+
}
|
|
475
823
|
this.#state.audit.push(...this.#auditBuffer);
|
|
476
|
-
|
|
477
|
-
|
|
824
|
+
const retention = getAuditRetentionSafe();
|
|
825
|
+
if (this.#state.audit.length > retention) {
|
|
826
|
+
this.#state.audit = this.#state.audit.slice(-retention);
|
|
478
827
|
}
|
|
479
828
|
this.#auditBuffer = [];
|
|
480
|
-
// O1: Skip persist for trivial Level 0
|
|
481
|
-
|
|
482
|
-
|
|
829
|
+
// O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
|
|
830
|
+
if (
|
|
831
|
+
!forcePersist &&
|
|
832
|
+
this.#state.level === '0' &&
|
|
833
|
+
this.#state.state !== 'DONE' &&
|
|
834
|
+
this.#state.state !== 'BLOCKED'
|
|
835
|
+
) {
|
|
483
836
|
return;
|
|
484
837
|
}
|
|
485
|
-
this.#persist();
|
|
838
|
+
await this.#persist();
|
|
486
839
|
}
|
|
487
840
|
|
|
488
841
|
// --- 3.5: Enriched error with available transitions ---
|
|
@@ -525,14 +878,22 @@ class OstackyController {
|
|
|
525
878
|
// --- 3.3: Degraded mode ---
|
|
526
879
|
#enterDegradedMode(reason) {
|
|
527
880
|
this.#degraded = true;
|
|
881
|
+
if (this.#state) this.#state.degraded = true;
|
|
528
882
|
log('degraded_mode_activated', { reason, state: this.#state?.state });
|
|
883
|
+
if (this.#state && this.#statePath) {
|
|
884
|
+
try { this.#persist().catch(() => {}); } catch {}
|
|
885
|
+
}
|
|
529
886
|
}
|
|
530
887
|
|
|
531
888
|
#exitDegradedMode() {
|
|
532
889
|
if (!this.#degraded) return;
|
|
533
890
|
this.#degraded = false;
|
|
534
891
|
this.#consecutiveFailures = 0;
|
|
892
|
+
if (this.#state) this.#state.degraded = false;
|
|
535
893
|
log('degraded_mode_exited', { state: this.#state?.state });
|
|
894
|
+
if (this.#state && this.#statePath) {
|
|
895
|
+
try { this.#persist().catch(() => {}); } catch {}
|
|
896
|
+
}
|
|
536
897
|
}
|
|
537
898
|
|
|
538
899
|
// --- Core transitions ---
|
|
@@ -542,7 +903,7 @@ class OstackyController {
|
|
|
542
903
|
if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
|
|
543
904
|
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
544
905
|
}
|
|
545
|
-
this.#transition('INTERPRETATION_PENDING', {
|
|
906
|
+
await this.#transition('INTERPRETATION_PENDING', {
|
|
546
907
|
requestId: requestId || 'req-' + Date.now(),
|
|
547
908
|
changeId: changeId || null,
|
|
548
909
|
routeDecisionId: null,
|
|
@@ -552,9 +913,11 @@ class OstackyController {
|
|
|
552
913
|
snapshots: { codegraph: null, execution: null },
|
|
553
914
|
tasks: {},
|
|
554
915
|
fileFingerprints: {},
|
|
916
|
+
expectedTasks: null,
|
|
917
|
+
expectedTaskCount: null,
|
|
555
918
|
error: null,
|
|
556
919
|
});
|
|
557
|
-
this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
920
|
+
await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
558
921
|
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
559
922
|
}
|
|
560
923
|
|
|
@@ -566,8 +929,8 @@ class OstackyController {
|
|
|
566
929
|
`Cannot request clarification from state ${this.#state.state}`,
|
|
567
930
|
'request_clarification'
|
|
568
931
|
);
|
|
569
|
-
this.#transition(to, { error: question ? `Clarification: ${question}` : null });
|
|
570
|
-
this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
|
|
932
|
+
await this.#transition(to, { error: question ? `Clarification: ${question}` : null });
|
|
933
|
+
await this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
|
|
571
934
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
572
935
|
}
|
|
573
936
|
|
|
@@ -579,8 +942,8 @@ class OstackyController {
|
|
|
579
942
|
`Cannot record clarification from state ${this.#state.state}`,
|
|
580
943
|
'record_clarification'
|
|
581
944
|
);
|
|
582
|
-
this.#transition(to, { error: null });
|
|
583
|
-
this.#audit('DISCOVERY', 'record_clarification');
|
|
945
|
+
await this.#transition(to, { error: null });
|
|
946
|
+
await this.#audit('DISCOVERY', 'record_clarification');
|
|
584
947
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
585
948
|
}
|
|
586
949
|
|
|
@@ -606,9 +969,18 @@ class OstackyController {
|
|
|
606
969
|
|
|
607
970
|
async recordDiscovery({ level, routeDecisionId, snapshot } = {}) {
|
|
608
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
|
+
}
|
|
609
976
|
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
610
977
|
if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
|
|
611
978
|
|
|
979
|
+
// C2/H3: validate evidence BEFORE compress — _compressed is NOT valid evidence
|
|
980
|
+
const hasEvidence =
|
|
981
|
+
snapshot && !snapshot._compressed && Array.isArray(snapshot.symbols) && snapshot.symbols.length > 0;
|
|
982
|
+
const isTrivial = level === '0';
|
|
983
|
+
|
|
612
984
|
// O3: Compress snapshot before persisting
|
|
613
985
|
const compressedSnapshot = snapshot
|
|
614
986
|
? this.#compressCodegraphSnapshot(snapshot)
|
|
@@ -622,13 +994,86 @@ class OstackyController {
|
|
|
622
994
|
}
|
|
623
995
|
|
|
624
996
|
const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
|
|
625
|
-
|
|
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
|
+
};
|
|
1022
|
+
await this.#transition(to, {
|
|
626
1023
|
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
627
1024
|
routeChoice: defaultChoice, // O2: persist default suggested choice
|
|
628
1025
|
level, // O1: persist level for conditional persistence
|
|
629
1026
|
snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
|
|
1027
|
+
lastProposal,
|
|
630
1028
|
});
|
|
631
|
-
this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
1029
|
+
await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
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
|
|
1049
|
+
if (!hasEvidence && !this.#degraded && !isTrivial) {
|
|
1050
|
+
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
1051
|
+
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1052
|
+
log('warn:discovery_without_codegraph', { level, auditId });
|
|
1053
|
+
await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
|
|
1054
|
+
await this.#persist();
|
|
1055
|
+
// auditId is the last pushed id
|
|
1056
|
+
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
1057
|
+
return {
|
|
1058
|
+
state: this.#state.state,
|
|
1059
|
+
revision: this.#state.revision,
|
|
1060
|
+
level,
|
|
1061
|
+
routeDecisionId: this.#state.routeDecisionId,
|
|
1062
|
+
defaultChoice,
|
|
1063
|
+
warning: 'discovery without codegraph evidence',
|
|
1064
|
+
auditId: lastAudit?.id || auditId,
|
|
1065
|
+
};
|
|
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
|
+
}
|
|
632
1077
|
return {
|
|
633
1078
|
state: this.#state.state,
|
|
634
1079
|
revision: this.#state.revision,
|
|
@@ -642,8 +1087,8 @@ class OstackyController {
|
|
|
642
1087
|
this.#load();
|
|
643
1088
|
const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
|
|
644
1089
|
if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
|
|
645
|
-
this.#transition(to);
|
|
646
|
-
this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
|
|
1090
|
+
await this.#transition(to);
|
|
1091
|
+
await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
|
|
647
1092
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
648
1093
|
}
|
|
649
1094
|
|
|
@@ -651,13 +1096,16 @@ class OstackyController {
|
|
|
651
1096
|
this.#load();
|
|
652
1097
|
const to = this.#isAllowedTransition(this.#state.state, 'abandon');
|
|
653
1098
|
if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
|
|
654
|
-
this.#transition(to, { error: reason || 'Abandoned' });
|
|
655
|
-
this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
|
|
1099
|
+
await this.#transition(to, { error: reason || 'Abandoned' });
|
|
1100
|
+
await this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
|
|
656
1101
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
657
1102
|
}
|
|
658
1103
|
|
|
659
1104
|
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
660
1105
|
this.#load();
|
|
1106
|
+
if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
|
|
1107
|
+
return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
|
|
1108
|
+
}
|
|
661
1109
|
if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
|
|
662
1110
|
return this.#makeError(
|
|
663
1111
|
`Cannot consume route decision from state ${this.#state.state}`,
|
|
@@ -669,8 +1117,8 @@ class OstackyController {
|
|
|
669
1117
|
const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
|
|
670
1118
|
if (!to)
|
|
671
1119
|
return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
|
|
672
|
-
this.#transition(to, { routeChoice: choice });
|
|
673
|
-
this.#audit(to, 'consume_route_decision', `choice=${choice}`);
|
|
1120
|
+
await this.#transition(to, { routeChoice: choice });
|
|
1121
|
+
await this.#audit(to, 'consume_route_decision', `choice=${choice}`);
|
|
674
1122
|
return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
|
|
675
1123
|
}
|
|
676
1124
|
|
|
@@ -678,8 +1126,8 @@ class OstackyController {
|
|
|
678
1126
|
this.#load();
|
|
679
1127
|
const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
|
|
680
1128
|
if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
|
|
681
|
-
this.#transition(to);
|
|
682
|
-
this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
|
|
1129
|
+
await this.#transition(to);
|
|
1130
|
+
await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
|
|
683
1131
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
684
1132
|
}
|
|
685
1133
|
|
|
@@ -697,12 +1145,104 @@ class OstackyController {
|
|
|
697
1145
|
'record_execution_analysis'
|
|
698
1146
|
);
|
|
699
1147
|
}
|
|
700
|
-
|
|
1148
|
+
// C2: strict contract — recommendation + reasons required
|
|
1149
|
+
if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
|
|
1150
|
+
return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
|
|
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
|
+
}
|
|
1164
|
+
// C2: capture expected tasks for gate
|
|
1165
|
+
const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
|
|
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
|
+
};
|
|
1196
|
+
await this.#transition(to, {
|
|
701
1197
|
executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
|
|
702
1198
|
executionMode: null,
|
|
703
|
-
snapshots: { ...this.#state.snapshots, execution: snapshot
|
|
1199
|
+
snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
|
|
1200
|
+
expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
|
|
1201
|
+
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
1202
|
+
lastProposal: execLastProposal,
|
|
704
1203
|
});
|
|
705
|
-
this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
1204
|
+
await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
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
|
|
1216
|
+
const hasEvidence =
|
|
1217
|
+
snapshot &&
|
|
1218
|
+
Array.isArray(snapshot.codegraphUsed) &&
|
|
1219
|
+
snapshot.codegraphUsed.length > 0 &&
|
|
1220
|
+
snapshot.recommendation != null;
|
|
1221
|
+
if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
|
|
1222
|
+
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
1223
|
+
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
1224
|
+
log('warn:execution_without_codegraph', { auditId });
|
|
1225
|
+
await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
|
|
1226
|
+
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
1227
|
+
return {
|
|
1228
|
+
state: this.#state.state,
|
|
1229
|
+
revision: this.#state.revision,
|
|
1230
|
+
executionDecisionId: this.#state.executionDecisionId,
|
|
1231
|
+
warning: 'execution analysis without execution-mode-evaluation',
|
|
1232
|
+
auditId: lastAudit?.id || auditId,
|
|
1233
|
+
};
|
|
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
|
+
}
|
|
706
1246
|
return {
|
|
707
1247
|
state: this.#state.state,
|
|
708
1248
|
revision: this.#state.revision,
|
|
@@ -712,6 +1252,9 @@ class OstackyController {
|
|
|
712
1252
|
|
|
713
1253
|
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
714
1254
|
this.#load();
|
|
1255
|
+
if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
|
|
1256
|
+
return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
|
|
1257
|
+
}
|
|
715
1258
|
if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
|
|
716
1259
|
return this.#makeError(
|
|
717
1260
|
`Cannot consume execution decision from state ${this.#state.state}`,
|
|
@@ -723,12 +1266,12 @@ class OstackyController {
|
|
|
723
1266
|
const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
|
|
724
1267
|
if (!to)
|
|
725
1268
|
return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
|
|
726
|
-
this.#transition(to, { executionMode: mode });
|
|
727
|
-
this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
|
|
1269
|
+
await this.#transition(to, { executionMode: mode });
|
|
1270
|
+
await this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
|
|
728
1271
|
return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
|
|
729
1272
|
}
|
|
730
1273
|
|
|
731
|
-
async implementationComplete() {
|
|
1274
|
+
async implementationComplete({ force } = {}) {
|
|
732
1275
|
this.#load();
|
|
733
1276
|
const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
|
|
734
1277
|
if (!to)
|
|
@@ -736,36 +1279,115 @@ class OstackyController {
|
|
|
736
1279
|
`Cannot complete implementation from state ${this.#state.state}`,
|
|
737
1280
|
'implementation_complete'
|
|
738
1281
|
);
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1282
|
+
// C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
|
|
1283
|
+
let pending = [];
|
|
1284
|
+
if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
|
|
1285
|
+
pending = this.#state.expectedTasks.filter(
|
|
1286
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1287
|
+
);
|
|
1288
|
+
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
1289
|
+
const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
|
|
1290
|
+
if (completed < this.#state.expectedTaskCount)
|
|
1291
|
+
pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
|
|
1292
|
+
}
|
|
1293
|
+
// T3: also block on stale fingerprints-vs-disk
|
|
1294
|
+
let staleFiles = [];
|
|
1295
|
+
const seenFp2 = new Set();
|
|
1296
|
+
try {
|
|
1297
|
+
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1298
|
+
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
1299
|
+
const current = fastFingerprint(info.filePath);
|
|
1300
|
+
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1301
|
+
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1302
|
+
seenFp2.add(info.filePath);
|
|
1303
|
+
}
|
|
1304
|
+
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
1305
|
+
if (seenFp2.has(fp)) continue;
|
|
1306
|
+
const cur = fastFingerprint(fp);
|
|
1307
|
+
if (!cur) staleFiles.push(`${fp} (missing)`);
|
|
1308
|
+
else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
1309
|
+
}
|
|
1310
|
+
} catch {}
|
|
1311
|
+
const hasBlocking = pending.length > 0 || staleFiles.length > 0;
|
|
1312
|
+
if (hasBlocking && !force) {
|
|
1313
|
+
return {
|
|
1314
|
+
error: staleFiles.length ? 'stale fingerprints' : 'tasks incomplete',
|
|
1315
|
+
pending,
|
|
1316
|
+
staleFiles: staleFiles.length ? staleFiles : undefined,
|
|
1317
|
+
current_state: this.#state.state,
|
|
1318
|
+
attempted_transition: 'implementation_complete',
|
|
1319
|
+
suggestion:
|
|
1320
|
+
'Complete pending tasks via complete_task or retry with {force:true} after explicit user confirmation',
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
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
|
+
}
|
|
1337
|
+
const all = [...pending, ...staleFiles].join(',');
|
|
1338
|
+
await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
|
|
1339
|
+
}
|
|
1340
|
+
await this.#transition(to);
|
|
1341
|
+
await this.#audit('SYNC', 'implementation_complete');
|
|
1342
|
+
return {
|
|
1343
|
+
state: this.#state.state,
|
|
1344
|
+
revision: this.#state.revision,
|
|
1345
|
+
forced: !!force,
|
|
1346
|
+
pending: pending.length ? pending : undefined,
|
|
1347
|
+
};
|
|
742
1348
|
}
|
|
743
1349
|
|
|
744
1350
|
async syncComplete() {
|
|
745
1351
|
this.#load();
|
|
746
1352
|
const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
|
|
747
1353
|
if (!to) return this.#makeError(`Cannot complete sync from state ${this.#state.state}`, 'sync_complete');
|
|
748
|
-
this.#transition(to);
|
|
749
|
-
this.#audit('DONE', 'sync_complete');
|
|
1354
|
+
await this.#transition(to);
|
|
1355
|
+
await this.#audit('DONE', 'sync_complete');
|
|
750
1356
|
// Flush remaining audit entries
|
|
751
|
-
this.#flushAudit();
|
|
1357
|
+
await this.#flushAudit();
|
|
752
1358
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
753
1359
|
}
|
|
754
1360
|
|
|
755
1361
|
async block({ reason } = {}) {
|
|
756
1362
|
this.#load();
|
|
1363
|
+
const from = this.#state.state;
|
|
757
1364
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
758
1365
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
759
|
-
|
|
760
|
-
|
|
1366
|
+
// 1.9: block desde EXECUTING_* preserva tasks/fileFingerprints/expectedTasks y audita WARN
|
|
1367
|
+
const isExecuting = from === 'EXECUTING_INLINE' || from === 'EXECUTING_SUBAGENTS';
|
|
1368
|
+
await this.#transition(to, { error: reason || 'Blocked' });
|
|
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
|
+
}
|
|
761
1379
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
762
1380
|
}
|
|
763
1381
|
|
|
764
1382
|
async replan({ reason } = {}) {
|
|
765
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
|
+
}
|
|
766
1388
|
const to = this.#isAllowedTransition(this.#state.state, 'replan');
|
|
767
1389
|
if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
|
|
768
|
-
this.#transition(to, {
|
|
1390
|
+
await this.#transition(to, {
|
|
769
1391
|
error: reason || null,
|
|
770
1392
|
routeDecisionId: null,
|
|
771
1393
|
routeChoice: null,
|
|
@@ -774,11 +1396,243 @@ class OstackyController {
|
|
|
774
1396
|
snapshots: { codegraph: null, execution: null },
|
|
775
1397
|
tasks: {},
|
|
776
1398
|
fileFingerprints: {},
|
|
1399
|
+
expectedTasks: null,
|
|
1400
|
+
expectedTaskCount: null,
|
|
777
1401
|
});
|
|
778
|
-
this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
1402
|
+
await this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
779
1403
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
780
1404
|
}
|
|
781
1405
|
|
|
1406
|
+
// --- C2: Expected tasks gate (controller as source of truth) ---
|
|
1407
|
+
async setExpectedTasks({ taskIds, taskCount } = {}) {
|
|
1408
|
+
this.#load();
|
|
1409
|
+
if (Array.isArray(taskIds) && taskIds.length > 0) {
|
|
1410
|
+
this.#state.expectedTasks = [...taskIds];
|
|
1411
|
+
this.#state.expectedTaskCount = taskIds.length;
|
|
1412
|
+
} else if (typeof taskCount === 'number' && taskCount > 0) {
|
|
1413
|
+
this.#state.expectedTasks = null;
|
|
1414
|
+
this.#state.expectedTaskCount = taskCount;
|
|
1415
|
+
} else {
|
|
1416
|
+
return { error: 'taskIds (array) or taskCount (number) required' };
|
|
1417
|
+
}
|
|
1418
|
+
await this.#persist();
|
|
1419
|
+
await this.#audit(
|
|
1420
|
+
'EXECUTING',
|
|
1421
|
+
'set_expected_tasks',
|
|
1422
|
+
`expected=${this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length}`
|
|
1423
|
+
);
|
|
1424
|
+
return { ok: true, expectedTasks: this.#state.expectedTasks, expectedTaskCount: this.#state.expectedTaskCount };
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
async verifyIntegrity() {
|
|
1428
|
+
this.#load();
|
|
1429
|
+
let pending = [];
|
|
1430
|
+
if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
|
|
1431
|
+
pending = this.#state.expectedTasks.filter(
|
|
1432
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1433
|
+
);
|
|
1434
|
+
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
1435
|
+
const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
|
|
1436
|
+
if (completed < this.#state.expectedTaskCount)
|
|
1437
|
+
pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
|
|
1438
|
+
}
|
|
1439
|
+
// T3: fingerprints-vs-disk — detect stale/missing files after complete_task
|
|
1440
|
+
let staleFiles = [];
|
|
1441
|
+
const seenFp = new Set();
|
|
1442
|
+
try {
|
|
1443
|
+
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1444
|
+
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
1445
|
+
const current = fastFingerprint(info.filePath);
|
|
1446
|
+
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1447
|
+
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1448
|
+
seenFp.add(info.filePath);
|
|
1449
|
+
}
|
|
1450
|
+
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
1451
|
+
if (seenFp.has(fp)) continue;
|
|
1452
|
+
const current = fastFingerprint(fp);
|
|
1453
|
+
if (!current) staleFiles.push(`${fp} (missing)`);
|
|
1454
|
+
else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
1455
|
+
}
|
|
1456
|
+
} catch {}
|
|
1457
|
+
const ok = pending.length === 0 && staleFiles.length === 0;
|
|
1458
|
+
return {
|
|
1459
|
+
ok,
|
|
1460
|
+
pending,
|
|
1461
|
+
staleFiles,
|
|
1462
|
+
completed: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED').length,
|
|
1463
|
+
expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
|
|
1464
|
+
state: this.#state.state,
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
|
|
1469
|
+
this.#load();
|
|
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);
|
|
1473
|
+
const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
|
|
1474
|
+
return slice.map((e) => ({
|
|
1475
|
+
id: e.id,
|
|
1476
|
+
ts: e.ts,
|
|
1477
|
+
phase: e.phase,
|
|
1478
|
+
decision: e.decision,
|
|
1479
|
+
reasoning: e.reasoning ? String(e.reasoning).slice(0, 300) : undefined,
|
|
1480
|
+
}));
|
|
1481
|
+
}
|
|
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
|
+
|
|
782
1636
|
// --- B2: Handoff persistence for cross-session continuity ---
|
|
783
1637
|
async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
|
|
784
1638
|
this.#load();
|
|
@@ -791,22 +1645,37 @@ class OstackyController {
|
|
|
791
1645
|
nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
|
|
792
1646
|
pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
|
|
793
1647
|
};
|
|
794
|
-
this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
795
|
-
this.#persist();
|
|
1648
|
+
await this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
1649
|
+
await this.#persist();
|
|
796
1650
|
return { ok: true, lastHandoff: this.#state.lastHandoff };
|
|
797
1651
|
}
|
|
798
1652
|
|
|
799
1653
|
async getHandoff() {
|
|
800
1654
|
this.#load();
|
|
801
|
-
return this.#state.lastHandoff;
|
|
1655
|
+
if (this.#state.lastHandoff) return this.#state.lastHandoff;
|
|
1656
|
+
// C3: fallback to compaction file — same anchor as writer (dirname(statePath))
|
|
1657
|
+
if (!this.#statePath) return null;
|
|
1658
|
+
try {
|
|
1659
|
+
const fallbackPath = join(dirname(this.#statePath), '.ostacky-handoff-compaction.json');
|
|
1660
|
+
const raw = readFileSync(fallbackPath, 'utf8');
|
|
1661
|
+
const data = JSON.parse(raw);
|
|
1662
|
+
if (data && typeof data.summary === 'string') return data;
|
|
1663
|
+
} catch {}
|
|
1664
|
+
return null;
|
|
802
1665
|
}
|
|
803
1666
|
|
|
804
1667
|
async clearHandoff() {
|
|
805
1668
|
this.#load();
|
|
806
1669
|
const prev = this.#state.lastHandoff;
|
|
807
1670
|
this.#state.lastHandoff = null;
|
|
808
|
-
|
|
809
|
-
this.#
|
|
1671
|
+
// C3: also delete fallback compaction file (same anchor)
|
|
1672
|
+
if (this.#statePath) {
|
|
1673
|
+
try {
|
|
1674
|
+
unlinkSync(join(dirname(this.#statePath), '.ostacky-handoff-compaction.json'));
|
|
1675
|
+
} catch {}
|
|
1676
|
+
}
|
|
1677
|
+
await this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
|
|
1678
|
+
await this.#persist();
|
|
810
1679
|
return { ok: true, cleared: prev };
|
|
811
1680
|
}
|
|
812
1681
|
|
|
@@ -828,12 +1697,44 @@ class OstackyController {
|
|
|
828
1697
|
};
|
|
829
1698
|
}
|
|
830
1699
|
|
|
831
|
-
// --- O6: Validate edit with fast fingerprint ---
|
|
832
|
-
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 } = {}) {
|
|
833
1702
|
this.#load();
|
|
834
1703
|
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
835
1704
|
return { outcome: 'CONFLICT', reason: `Cannot validate edit from state ${this.#state.state}` };
|
|
836
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
|
+
}
|
|
837
1738
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
838
1739
|
return { outcome: 'CONFLICT', reason: 'Missing required fields: content, oldString, newString' };
|
|
839
1740
|
}
|
|
@@ -869,6 +1770,14 @@ class OstackyController {
|
|
|
869
1770
|
};
|
|
870
1771
|
}
|
|
871
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 {}
|
|
872
1781
|
return { outcome: 'EDITABLE', taskId };
|
|
873
1782
|
}
|
|
874
1783
|
|
|
@@ -882,10 +1791,32 @@ class OstackyController {
|
|
|
882
1791
|
return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
|
|
883
1792
|
}
|
|
884
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
|
+
}
|
|
885
1802
|
if (!this.#state.tasks) this.#state.tasks = {};
|
|
886
1803
|
|
|
887
1804
|
// O6: Use fast fingerprint if no hash provided
|
|
888
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
|
+
}
|
|
889
1820
|
|
|
890
1821
|
this.#state.tasks[taskId] = {
|
|
891
1822
|
status: 'COMPLETED',
|
|
@@ -898,23 +1829,108 @@ class OstackyController {
|
|
|
898
1829
|
this.#state.fileFingerprints[filePath] = effectiveHash;
|
|
899
1830
|
}
|
|
900
1831
|
this.#trimTasks();
|
|
901
|
-
this.#
|
|
902
|
-
|
|
1832
|
+
const totalCompleted = Object.keys(this.#state.tasks).filter(
|
|
1833
|
+
(k) => this.#state.tasks[k].status === 'COMPLETED'
|
|
1834
|
+
).length;
|
|
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
|
|
1837
|
+
if (totalCompleted % 3 === 0) {
|
|
1838
|
+
const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
|
|
1839
|
+
? this.#state.expectedTasks.filter(
|
|
1840
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1841
|
+
)
|
|
1842
|
+
: [];
|
|
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
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
await this.#persist();
|
|
1863
|
+
await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
|
|
903
1864
|
return {
|
|
904
1865
|
taskId,
|
|
905
1866
|
status: 'COMPLETED',
|
|
906
|
-
totalCompleted
|
|
907
|
-
.length,
|
|
1867
|
+
totalCompleted,
|
|
908
1868
|
};
|
|
909
1869
|
}
|
|
910
1870
|
|
|
911
1871
|
/**
|
|
912
|
-
* Public flush —
|
|
913
|
-
*
|
|
1872
|
+
* Public flush — SYNCHRONOUS on purpose: SIGINT/SIGTERM handlers cannot await
|
|
1873
|
+
* (Node does not wait for async shutdown work). Drains the audit buffer into
|
|
1874
|
+
* state and runs a best-effort sync persist with a single non-spinning lock
|
|
1875
|
+
* attempt; skips persisting if another process currently holds the lock.
|
|
914
1876
|
*/
|
|
915
1877
|
flush() {
|
|
916
|
-
this.#
|
|
917
|
-
|
|
1878
|
+
if (this.#auditBuffer.length > 0 && this.#state) {
|
|
1879
|
+
if (!this.#state.audit) this.#state.audit = [];
|
|
1880
|
+
for (const e of this.#auditBuffer) {
|
|
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]');
|
|
1883
|
+
}
|
|
1884
|
+
this.#state.audit.push(...this.#auditBuffer);
|
|
1885
|
+
const retention = getAuditRetentionSafe();
|
|
1886
|
+
if (this.#state.audit.length > retention) this.#state.audit = this.#state.audit.slice(-retention);
|
|
1887
|
+
this.#auditBuffer = [];
|
|
1888
|
+
}
|
|
1889
|
+
// T1: final persist path kept synchronous for graceful shutdown (+ D2 stale-aware 15s)
|
|
1890
|
+
if (!this.#statePath || !this.#state || !this.#loaded) return;
|
|
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 {}
|
|
1907
|
+
try {
|
|
1908
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1909
|
+
} catch (e) {
|
|
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;
|
|
1921
|
+
}
|
|
1922
|
+
try {
|
|
1923
|
+
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
1924
|
+
this.#lockOwner = true;
|
|
1925
|
+
} catch {}
|
|
1926
|
+
const serialized = safeJsonStringify(this.#state, true);
|
|
1927
|
+
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
1928
|
+
writeFileSync(tmp, serialized, 'utf8');
|
|
1929
|
+
renameSync(tmp, this.#statePath);
|
|
1930
|
+
this.#releaseLock();
|
|
1931
|
+
} catch {
|
|
1932
|
+
/* shutdown persist is best-effort */
|
|
1933
|
+
}
|
|
918
1934
|
}
|
|
919
1935
|
}
|
|
920
1936
|
|
|
@@ -928,10 +1944,25 @@ const controller = new OstackyController({ statePath });
|
|
|
928
1944
|
*/
|
|
929
1945
|
function safeHandler(fn) {
|
|
930
1946
|
return async (params) => {
|
|
1947
|
+
const start = Date.now();
|
|
931
1948
|
try {
|
|
932
|
-
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 {}
|
|
933
1955
|
return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
|
|
934
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
|
+
}
|
|
935
1966
|
log('tool:error', {
|
|
936
1967
|
name: fn.name || 'anonymous',
|
|
937
1968
|
error: error.message,
|
|
@@ -947,7 +1978,7 @@ function safeHandler(fn) {
|
|
|
947
1978
|
|
|
948
1979
|
const server = new McpServer({
|
|
949
1980
|
name: 'ostacky-controller',
|
|
950
|
-
version: '0.7.
|
|
1981
|
+
version: '0.7.3',
|
|
951
1982
|
});
|
|
952
1983
|
|
|
953
1984
|
server.registerTool(
|
|
@@ -1070,12 +2101,18 @@ server.registerTool(
|
|
|
1070
2101
|
server.registerTool(
|
|
1071
2102
|
'implementation_complete',
|
|
1072
2103
|
{
|
|
1073
|
-
description:
|
|
1074
|
-
|
|
2104
|
+
description:
|
|
2105
|
+
'Mark implementation as complete. Transitions to SYNC. Returns error without transitioning if tasks pending unless {force:true}.',
|
|
2106
|
+
inputSchema: z.object({
|
|
2107
|
+
force: z
|
|
2108
|
+
.boolean()
|
|
2109
|
+
.optional()
|
|
2110
|
+
.describe('Force transition even with pending tasks (requires explicit user confirmation)'),
|
|
2111
|
+
}),
|
|
1075
2112
|
},
|
|
1076
|
-
safeHandler(async () => {
|
|
1077
|
-
log('tool:implementation_complete');
|
|
1078
|
-
return await controller.implementationComplete();
|
|
2113
|
+
safeHandler(async ({ force }) => {
|
|
2114
|
+
log('tool:implementation_complete', { force: !!force });
|
|
2115
|
+
return await controller.implementationComplete({ force: !!force });
|
|
1079
2116
|
})
|
|
1080
2117
|
);
|
|
1081
2118
|
|
|
@@ -1119,6 +2156,109 @@ server.registerTool(
|
|
|
1119
2156
|
})
|
|
1120
2157
|
);
|
|
1121
2158
|
|
|
2159
|
+
server.registerTool(
|
|
2160
|
+
'set_expected_tasks',
|
|
2161
|
+
{
|
|
2162
|
+
description:
|
|
2163
|
+
'Register expected task IDs for the integrity gate (controller as source of truth). Call after execution analysis.',
|
|
2164
|
+
inputSchema: z.object({
|
|
2165
|
+
taskIds: z.array(z.string()).optional().describe('Array of expected task IDs'),
|
|
2166
|
+
taskCount: z.number().optional().describe('Fallback count when IDs not available'),
|
|
2167
|
+
}),
|
|
2168
|
+
},
|
|
2169
|
+
safeHandler(async ({ taskIds, taskCount }) => {
|
|
2170
|
+
log('tool:set_expected_tasks', { count: taskIds?.length ?? taskCount });
|
|
2171
|
+
return await controller.setExpectedTasks({ taskIds, taskCount });
|
|
2172
|
+
})
|
|
2173
|
+
);
|
|
2174
|
+
|
|
2175
|
+
server.registerTool(
|
|
2176
|
+
'verify_integrity',
|
|
2177
|
+
{
|
|
2178
|
+
description:
|
|
2179
|
+
'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
|
|
2180
|
+
inputSchema: z.object({}),
|
|
2181
|
+
},
|
|
2182
|
+
safeHandler(async () => {
|
|
2183
|
+
log('tool:verify_integrity');
|
|
2184
|
+
return await controller.verifyIntegrity();
|
|
2185
|
+
})
|
|
2186
|
+
);
|
|
2187
|
+
|
|
2188
|
+
server.registerTool(
|
|
2189
|
+
'get_audit',
|
|
2190
|
+
{
|
|
2191
|
+
description: 'Get recent audit entries paginated. Read-only, truncated to 300 chars with unique id per entry.',
|
|
2192
|
+
inputSchema: z.object({
|
|
2193
|
+
limit: z.number().optional().describe('Max entries (default 20)'),
|
|
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'),
|
|
2197
|
+
}),
|
|
2198
|
+
},
|
|
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 });
|
|
2259
|
+
})
|
|
2260
|
+
);
|
|
2261
|
+
|
|
1122
2262
|
server.registerTool(
|
|
1123
2263
|
'proceed_to_route',
|
|
1124
2264
|
{
|
|
@@ -1154,14 +2294,19 @@ server.registerTool(
|
|
|
1154
2294
|
inputSchema: z.object({}),
|
|
1155
2295
|
},
|
|
1156
2296
|
safeHandler(async () => {
|
|
2297
|
+
const state = await controller.getState();
|
|
2298
|
+
const metrics = await controller.getMetrics().catch(() => ({}));
|
|
1157
2299
|
return {
|
|
1158
2300
|
pong: true,
|
|
1159
2301
|
degraded: controller.degraded,
|
|
1160
|
-
state:
|
|
1161
|
-
state:
|
|
1162
|
-
revision:
|
|
1163
|
-
requestId:
|
|
1164
|
-
}
|
|
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,
|
|
1165
2310
|
};
|
|
1166
2311
|
})
|
|
1167
2312
|
);
|
|
@@ -1283,14 +2428,16 @@ server.registerTool(
|
|
|
1283
2428
|
'Without this parameter, validate_edit will fail.'
|
|
1284
2429
|
),
|
|
1285
2430
|
taskId: z.string().optional().describe('Optional task ID for tracking.'),
|
|
2431
|
+
filePath: z.string().optional().describe('Optional file path for traversal validation.'),
|
|
1286
2432
|
}),
|
|
1287
2433
|
},
|
|
1288
|
-
safeHandler(async ({ oldString, newString, content, taskId }) => {
|
|
2434
|
+
safeHandler(async ({ oldString, newString, content, taskId, filePath }) => {
|
|
1289
2435
|
log('tool:validate_edit', {
|
|
1290
2436
|
taskId,
|
|
1291
2437
|
oldLen: oldString?.length,
|
|
1292
2438
|
newLen: newString?.length,
|
|
1293
2439
|
hasContent: !!content,
|
|
2440
|
+
filePath,
|
|
1294
2441
|
});
|
|
1295
2442
|
if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
|
|
1296
2443
|
return {
|
|
@@ -1298,7 +2445,7 @@ server.registerTool(
|
|
|
1298
2445
|
reason: 'Missing required fields: content, oldString, and newString are all required. Read the file first, then pass content to validate_edit.',
|
|
1299
2446
|
};
|
|
1300
2447
|
}
|
|
1301
|
-
return await controller.validateEdit({ oldString, newString, content, taskId });
|
|
2448
|
+
return await controller.validateEdit({ oldString, newString, content, taskId, filePath });
|
|
1302
2449
|
})
|
|
1303
2450
|
);
|
|
1304
2451
|
|
|
@@ -1354,7 +2501,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
1354
2501
|
}
|
|
1355
2502
|
|
|
1356
2503
|
async function main() {
|
|
1357
|
-
log('Starting ostacky-controller MCP v0.7.
|
|
2504
|
+
log('Starting ostacky-controller MCP v0.7.3...');
|
|
1358
2505
|
log('State path:', { path: statePath });
|
|
1359
2506
|
// Clean up stale tmp/lock files from previous runs
|
|
1360
2507
|
cleanupTmpFiles(statePath);
|
|
@@ -1374,4 +2521,4 @@ if (isDirectRun) {
|
|
|
1374
2521
|
});
|
|
1375
2522
|
}
|
|
1376
2523
|
|
|
1377
|
-
export { OstackyController };
|
|
2524
|
+
export { OstackyController, STATES, DEFAULT_STATE };
|