ostacky 0.7.0 → 0.7.2
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 +14 -11
- package/assets/agents/ostacky.md +18 -11
- package/assets/commands/install-stack.md +20 -1
- package/assets/mcp/ostacky-controller/index.js +523 -120
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/engram.ts +82 -8
- package/assets/skills/graceful-degradation/SKILL.md +4 -0
- package/dist/cli.js +370 -158
- package/manifest.json +29 -29
- package/package.json +1 -1
|
@@ -15,8 +15,21 @@
|
|
|
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 {
|
|
18
|
+
import {
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
statSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
} from 'node:fs';
|
|
19
28
|
import { dirname, basename, join, resolve } 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;
|
|
@@ -133,21 +146,59 @@ function log(event, data) {
|
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
/**
|
|
136
|
-
* Cleans up stale .tmp.* and .lock.* files from a previous crash.
|
|
149
|
+
* Cleans up stale .tmp.* and .lock.* files from a previous crash — C3 fix: never delete active locks of another process.
|
|
150
|
+
* Also handles orphaned .ostacky-handoff-compaction.json (only if ts>24h).
|
|
137
151
|
*/
|
|
138
152
|
function cleanupTmpFiles(statePath) {
|
|
139
153
|
if (!statePath) return;
|
|
140
154
|
const dir = dirname(statePath);
|
|
141
155
|
const name = basename(statePath);
|
|
156
|
+
const staleWindow = 15000;
|
|
157
|
+
const handoffTtl = 24 * 60 * 60 * 1000;
|
|
142
158
|
try {
|
|
143
159
|
for (const entry of readdirSync(dir)) {
|
|
144
|
-
|
|
160
|
+
const isTmp = entry.startsWith(name + '.tmp.');
|
|
161
|
+
const isLock = entry.startsWith(name + '.lock');
|
|
162
|
+
const isHandoff = entry === '.ostacky-handoff-compaction.json';
|
|
163
|
+
if (!isTmp && !isLock && !isHandoff) continue;
|
|
164
|
+
// C3: don't delete active lock of another process
|
|
165
|
+
if (isLock) {
|
|
166
|
+
try {
|
|
167
|
+
const pidPath = join(dir, name + '.lock.pid');
|
|
168
|
+
const tsPath = join(dir, name + '.lock.timestamp');
|
|
169
|
+
// If we are checking a lock file, verify liveness
|
|
170
|
+
let lockPid = null;
|
|
171
|
+
let lockTs = null;
|
|
172
|
+
try {
|
|
173
|
+
lockPid = readFileSync(pidPath, 'utf8').trim();
|
|
174
|
+
} catch {}
|
|
175
|
+
try {
|
|
176
|
+
lockTs = parseInt(readFileSync(tsPath, 'utf8').trim(), 10);
|
|
177
|
+
} catch {}
|
|
178
|
+
if (lockPid && lockTs && !Number.isNaN(lockTs)) {
|
|
179
|
+
const age = Date.now() - lockTs;
|
|
180
|
+
if (age < staleWindow && String(lockPid) !== String(process.pid)) {
|
|
181
|
+
continue; // active lock of another process — skip
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
if (isHandoff) {
|
|
145
187
|
try {
|
|
146
|
-
|
|
188
|
+
const handoffPath = join(dir, entry);
|
|
189
|
+
const raw = readFileSync(handoffPath, 'utf8');
|
|
190
|
+
const data = JSON.parse(raw);
|
|
191
|
+
const ts = data?.ts ?? data?.timestamp ?? 0;
|
|
192
|
+
if (ts && Date.now() - ts < handoffTtl) continue; // keep recent handoff
|
|
147
193
|
} catch {
|
|
148
|
-
|
|
194
|
+
// If unreadable, treat as stale and delete
|
|
149
195
|
}
|
|
150
196
|
}
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(join(dir, entry));
|
|
199
|
+
} catch {
|
|
200
|
+
/* best-effort */
|
|
201
|
+
}
|
|
151
202
|
}
|
|
152
203
|
} catch {
|
|
153
204
|
/* directory may not exist yet */
|
|
@@ -195,6 +246,9 @@ const DEFAULT_STATE = Object.freeze({
|
|
|
195
246
|
fileFingerprints: {},
|
|
196
247
|
error: null,
|
|
197
248
|
lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
|
|
249
|
+
expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
|
|
250
|
+
expectedTaskCount: null, // C2: count fallback when IDs not available
|
|
251
|
+
auditSeq: 0, // C1: persistent seq for audit IDs
|
|
198
252
|
});
|
|
199
253
|
|
|
200
254
|
class OstackyController {
|
|
@@ -207,7 +261,7 @@ class OstackyController {
|
|
|
207
261
|
#lockPath;
|
|
208
262
|
#lockPidPath;
|
|
209
263
|
#lockHeartbeatPath;
|
|
210
|
-
#lockMaxAttempts =
|
|
264
|
+
#lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
|
|
211
265
|
|
|
212
266
|
constructor(opts = {}) {
|
|
213
267
|
this.#statePath = opts.statePath;
|
|
@@ -246,48 +300,60 @@ class OstackyController {
|
|
|
246
300
|
return null; // valid
|
|
247
301
|
}
|
|
248
302
|
|
|
249
|
-
// --- 3.4: State file locking ---
|
|
250
|
-
#acquireLock() {
|
|
303
|
+
// --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
|
|
304
|
+
async #acquireLock() {
|
|
251
305
|
if (!this.#lockPath) return true;
|
|
252
|
-
// Allow tests to shorten retry loops via opts.lockMaxAttempts
|
|
253
306
|
const maxAttempts = this.#lockMaxAttempts;
|
|
254
|
-
const lockTimeout =
|
|
307
|
+
const lockTimeout = 1000;
|
|
308
|
+
const staleWindow = 15000;
|
|
255
309
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
256
310
|
try {
|
|
257
|
-
|
|
258
|
-
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
259
|
-
// Check if lock is stale (>30s without heartbeat)
|
|
311
|
+
// Check existing lock BEFORE overwriting — corrects mutual-exclusion bug
|
|
260
312
|
try {
|
|
261
313
|
const lockContent = readFileSync(this.#lockHeartbeatPath, 'utf8');
|
|
262
314
|
const lockAge = Date.now() - parseInt(lockContent, 10);
|
|
263
|
-
if (lockAge
|
|
264
|
-
const
|
|
315
|
+
if (!Number.isNaN(lockAge) && lockAge < staleWindow) {
|
|
316
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
317
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
318
|
+
const waitMs = Math.max(0, base + jitter);
|
|
319
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (!Number.isNaN(lockAge) && lockAge >= staleWindow) {
|
|
265
323
|
try {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
324
|
+
const lockPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
325
|
+
try {
|
|
326
|
+
process.kill(parseInt(lockPid, 10), 0);
|
|
327
|
+
// PID alive but stale beyond window — force release
|
|
328
|
+
} catch {
|
|
329
|
+
// PID dead — force release
|
|
271
330
|
}
|
|
272
|
-
} catch {
|
|
273
|
-
// PID doesn't exist — force release
|
|
274
|
-
}
|
|
331
|
+
} catch {}
|
|
275
332
|
this.#releaseLock();
|
|
276
|
-
continue;
|
|
277
333
|
}
|
|
278
334
|
} catch {
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
335
|
+
// No heartbeat file — try to acquire
|
|
336
|
+
}
|
|
337
|
+
// Atomic acquire with wx — fails if another process won the race
|
|
338
|
+
try {
|
|
339
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (e && e.code === 'EEXIST') {
|
|
342
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
343
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
344
|
+
const waitMs = Math.max(0, base + jitter);
|
|
345
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
throw e;
|
|
282
349
|
}
|
|
350
|
+
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
283
351
|
return true;
|
|
284
352
|
} catch {
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
/* spin wait */
|
|
290
|
-
}
|
|
353
|
+
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
354
|
+
const jitter = Math.floor(Math.random() * 200) - 100;
|
|
355
|
+
const waitMs = Math.max(0, base + jitter);
|
|
356
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
291
357
|
}
|
|
292
358
|
}
|
|
293
359
|
log('warn:lock_acquire_failed', { attempts: maxAttempts });
|
|
@@ -360,12 +426,12 @@ class OstackyController {
|
|
|
360
426
|
this.#loaded = true;
|
|
361
427
|
}
|
|
362
428
|
|
|
363
|
-
#persist() {
|
|
429
|
+
async #persist() {
|
|
364
430
|
if (!this.#statePath) return;
|
|
365
431
|
|
|
366
432
|
const dir = dirname(this.#statePath);
|
|
367
433
|
try {
|
|
368
|
-
|
|
434
|
+
await mkdirAsync(dir, { recursive: true });
|
|
369
435
|
} catch (err) {
|
|
370
436
|
// mkdir failures also count toward degraded mode
|
|
371
437
|
this.#consecutiveFailures++;
|
|
@@ -378,7 +444,7 @@ class OstackyController {
|
|
|
378
444
|
|
|
379
445
|
try {
|
|
380
446
|
// 3.4: Acquire lock before writing
|
|
381
|
-
const lockAcquired = this.#acquireLock();
|
|
447
|
+
const lockAcquired = await this.#acquireLock();
|
|
382
448
|
if (!lockAcquired) {
|
|
383
449
|
log('warn:persist_skipped_lock', { state: this.#state.state });
|
|
384
450
|
throw new Error('Could not acquire state file lock');
|
|
@@ -396,12 +462,12 @@ class OstackyController {
|
|
|
396
462
|
this.#state.snapshots = { codegraph: null, execution: null };
|
|
397
463
|
}
|
|
398
464
|
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
399
|
-
|
|
400
|
-
|
|
465
|
+
await writeFileAsync(tmp, serialized, 'utf8');
|
|
466
|
+
await renameAsync(tmp, this.#statePath);
|
|
401
467
|
try {
|
|
402
468
|
const backupTmp = this.#statePath + '.backup.tmp.' + process.pid;
|
|
403
|
-
|
|
404
|
-
|
|
469
|
+
await writeFileAsync(backupTmp, serialized, 'utf8');
|
|
470
|
+
await renameAsync(backupTmp, this.#statePath + '.backup');
|
|
405
471
|
} catch {
|
|
406
472
|
/* backup is best-effort */
|
|
407
473
|
}
|
|
@@ -415,7 +481,9 @@ class OstackyController {
|
|
|
415
481
|
this.#consecutiveFailures++;
|
|
416
482
|
log('error:persist_failed', { consecutive: this.#consecutiveFailures, error: err.message });
|
|
417
483
|
if (this.#consecutiveFailures >= DEGRADED_THRESHOLD && !this.#degraded) {
|
|
418
|
-
this.#enterDegradedMode(
|
|
484
|
+
this.#enterDegradedMode(
|
|
485
|
+
`persistence_failures: ${this.#consecutiveFailures} consecutive persists: ${err.message}`
|
|
486
|
+
);
|
|
419
487
|
}
|
|
420
488
|
throw err;
|
|
421
489
|
} finally {
|
|
@@ -446,11 +514,11 @@ class OstackyController {
|
|
|
446
514
|
log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
|
|
447
515
|
}
|
|
448
516
|
|
|
449
|
-
#transition(to, changes = {}) {
|
|
517
|
+
async #transition(to, changes = {}) {
|
|
450
518
|
this.#state.revision++;
|
|
451
519
|
this.#state.state = to;
|
|
452
520
|
Object.assign(this.#state, changes);
|
|
453
|
-
this.#persist();
|
|
521
|
+
await this.#persist();
|
|
454
522
|
}
|
|
455
523
|
|
|
456
524
|
// --- O4: O(1) transition lookup via pre-computed cache ---
|
|
@@ -459,28 +527,44 @@ class OstackyController {
|
|
|
459
527
|
return ALLOWED_TRANSITIONS[from]?.get(key) || null;
|
|
460
528
|
}
|
|
461
529
|
|
|
462
|
-
// --- O5: Batched audit trail ---
|
|
463
|
-
#audit(phase, decision, reasoning) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
530
|
+
// --- O5: Batched audit trail (C1: persistent ids + WARN force-flush) ---
|
|
531
|
+
async #audit(phase, decision, reasoning) {
|
|
532
|
+
const id = `aud-${Date.now()}-${this.#state.auditSeq++}`;
|
|
533
|
+
this.#auditBuffer.push({
|
|
534
|
+
id,
|
|
535
|
+
ts: Date.now(),
|
|
536
|
+
phase,
|
|
537
|
+
decision,
|
|
538
|
+
reasoning: reasoning ? String(reasoning).slice(0, 300) : undefined,
|
|
539
|
+
});
|
|
540
|
+
const isWarn = phase === 'WARN';
|
|
541
|
+
if (this.#auditBuffer.length >= 10 || phase === 'DONE' || isWarn) {
|
|
542
|
+
await this.#flushAudit(isWarn);
|
|
467
543
|
}
|
|
468
544
|
}
|
|
469
545
|
|
|
470
|
-
#flushAudit() {
|
|
546
|
+
async #flushAudit(forcePersist = false) {
|
|
471
547
|
if (this.#auditBuffer.length === 0) return;
|
|
472
548
|
if (!this.#state.audit) this.#state.audit = [];
|
|
549
|
+
for (const e of this.#auditBuffer) {
|
|
550
|
+
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
551
|
+
if (e.reasoning && e.reasoning.length > 300) e.reasoning = e.reasoning.slice(0, 300);
|
|
552
|
+
}
|
|
473
553
|
this.#state.audit.push(...this.#auditBuffer);
|
|
474
554
|
if (this.#state.audit.length > 100) {
|
|
475
555
|
this.#state.audit = this.#state.audit.slice(-100);
|
|
476
556
|
}
|
|
477
557
|
this.#auditBuffer = [];
|
|
478
|
-
// O1: Skip persist for trivial Level 0
|
|
479
|
-
|
|
480
|
-
|
|
558
|
+
// O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
|
|
559
|
+
if (
|
|
560
|
+
!forcePersist &&
|
|
561
|
+
this.#state.level === '0' &&
|
|
562
|
+
this.#state.state !== 'DONE' &&
|
|
563
|
+
this.#state.state !== 'BLOCKED'
|
|
564
|
+
) {
|
|
481
565
|
return;
|
|
482
566
|
}
|
|
483
|
-
this.#persist();
|
|
567
|
+
await this.#persist();
|
|
484
568
|
}
|
|
485
569
|
|
|
486
570
|
// --- 3.5: Enriched error with available transitions ---
|
|
@@ -540,7 +624,7 @@ class OstackyController {
|
|
|
540
624
|
if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
|
|
541
625
|
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
542
626
|
}
|
|
543
|
-
this.#transition('INTERPRETATION_PENDING', {
|
|
627
|
+
await this.#transition('INTERPRETATION_PENDING', {
|
|
544
628
|
requestId: requestId || 'req-' + Date.now(),
|
|
545
629
|
changeId: changeId || null,
|
|
546
630
|
routeDecisionId: null,
|
|
@@ -550,27 +634,37 @@ class OstackyController {
|
|
|
550
634
|
snapshots: { codegraph: null, execution: null },
|
|
551
635
|
tasks: {},
|
|
552
636
|
fileFingerprints: {},
|
|
637
|
+
expectedTasks: null,
|
|
638
|
+
expectedTaskCount: null,
|
|
553
639
|
error: null,
|
|
554
640
|
});
|
|
555
|
-
this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
641
|
+
await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
556
642
|
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
557
643
|
}
|
|
558
644
|
|
|
559
645
|
async requestClarification({ question } = {}) {
|
|
560
646
|
this.#load();
|
|
561
647
|
const to = this.#isAllowedTransition(this.#state.state, 'request_clarification');
|
|
562
|
-
if (!to)
|
|
563
|
-
|
|
564
|
-
|
|
648
|
+
if (!to)
|
|
649
|
+
return this.#makeError(
|
|
650
|
+
`Cannot request clarification from state ${this.#state.state}`,
|
|
651
|
+
'request_clarification'
|
|
652
|
+
);
|
|
653
|
+
await this.#transition(to, { error: question ? `Clarification: ${question}` : null });
|
|
654
|
+
await this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
|
|
565
655
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
566
656
|
}
|
|
567
657
|
|
|
568
658
|
async recordClarification() {
|
|
569
659
|
this.#load();
|
|
570
660
|
const to = this.#isAllowedTransition(this.#state.state, 'record_clarification');
|
|
571
|
-
if (!to)
|
|
572
|
-
|
|
573
|
-
|
|
661
|
+
if (!to)
|
|
662
|
+
return this.#makeError(
|
|
663
|
+
`Cannot record clarification from state ${this.#state.state}`,
|
|
664
|
+
'record_clarification'
|
|
665
|
+
);
|
|
666
|
+
await this.#transition(to, { error: null });
|
|
667
|
+
await this.#audit('DISCOVERY', 'record_clarification');
|
|
574
668
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
575
669
|
}
|
|
576
670
|
|
|
@@ -599,8 +693,15 @@ class OstackyController {
|
|
|
599
693
|
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
600
694
|
if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
|
|
601
695
|
|
|
696
|
+
// C2/H3: validate evidence BEFORE compress — _compressed is NOT valid evidence
|
|
697
|
+
const hasEvidence =
|
|
698
|
+
snapshot && !snapshot._compressed && Array.isArray(snapshot.symbols) && snapshot.symbols.length > 0;
|
|
699
|
+
const isTrivial = level === '0';
|
|
700
|
+
|
|
602
701
|
// O3: Compress snapshot before persisting
|
|
603
|
-
const compressedSnapshot = snapshot
|
|
702
|
+
const compressedSnapshot = snapshot
|
|
703
|
+
? this.#compressCodegraphSnapshot(snapshot)
|
|
704
|
+
: this.#state.snapshots.codegraph;
|
|
604
705
|
const snapshotJson = compressedSnapshot ? safeJsonStringify(compressedSnapshot) : '';
|
|
605
706
|
if (snapshotJson.length > MAX_SNAPSHOT_JSON_LENGTH) {
|
|
606
707
|
return this.#makeError(
|
|
@@ -610,13 +711,30 @@ class OstackyController {
|
|
|
610
711
|
}
|
|
611
712
|
|
|
612
713
|
const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
|
|
613
|
-
this.#transition(to, {
|
|
714
|
+
await this.#transition(to, {
|
|
614
715
|
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
615
716
|
routeChoice: defaultChoice, // O2: persist default suggested choice
|
|
616
717
|
level, // O1: persist level for conditional persistence
|
|
617
718
|
snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
|
|
618
719
|
});
|
|
619
|
-
this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
720
|
+
await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
|
|
721
|
+
// C2: warning if no evidence and not degraded and not trivial
|
|
722
|
+
if (!hasEvidence && !this.#degraded && !isTrivial) {
|
|
723
|
+
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
724
|
+
log('warn:discovery_without_codegraph', { level, auditId });
|
|
725
|
+
await this.#audit('WARN', 'discovery_without_codegraph', `level=${level} symbols missing`);
|
|
726
|
+
// auditId is the last pushed id
|
|
727
|
+
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
728
|
+
return {
|
|
729
|
+
state: this.#state.state,
|
|
730
|
+
revision: this.#state.revision,
|
|
731
|
+
level,
|
|
732
|
+
routeDecisionId: this.#state.routeDecisionId,
|
|
733
|
+
defaultChoice,
|
|
734
|
+
warning: 'discovery without codegraph evidence',
|
|
735
|
+
auditId: lastAudit?.id || auditId,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
620
738
|
return {
|
|
621
739
|
state: this.#state.state,
|
|
622
740
|
revision: this.#state.revision,
|
|
@@ -630,8 +748,8 @@ class OstackyController {
|
|
|
630
748
|
this.#load();
|
|
631
749
|
const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
|
|
632
750
|
if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
|
|
633
|
-
this.#transition(to);
|
|
634
|
-
this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
|
|
751
|
+
await this.#transition(to);
|
|
752
|
+
await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
|
|
635
753
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
636
754
|
}
|
|
637
755
|
|
|
@@ -639,21 +757,26 @@ class OstackyController {
|
|
|
639
757
|
this.#load();
|
|
640
758
|
const to = this.#isAllowedTransition(this.#state.state, 'abandon');
|
|
641
759
|
if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
|
|
642
|
-
this.#transition(to, { error: reason || 'Abandoned' });
|
|
643
|
-
this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
|
|
760
|
+
await this.#transition(to, { error: reason || 'Abandoned' });
|
|
761
|
+
await this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
|
|
644
762
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
645
763
|
}
|
|
646
764
|
|
|
647
765
|
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
648
766
|
this.#load();
|
|
649
767
|
if (this.#state.state !== 'ROUTE_DECISION_PENDING') {
|
|
650
|
-
return this.#makeError(
|
|
768
|
+
return this.#makeError(
|
|
769
|
+
`Cannot consume route decision from state ${this.#state.state}`,
|
|
770
|
+
'consume_route_decision'
|
|
771
|
+
);
|
|
651
772
|
}
|
|
652
|
-
if (this.#state.routeDecisionId !== decisionId)
|
|
773
|
+
if (this.#state.routeDecisionId !== decisionId)
|
|
774
|
+
return this.#makeError('Decision ID mismatch', 'consume_route_decision');
|
|
653
775
|
const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
|
|
654
|
-
if (!to)
|
|
655
|
-
|
|
656
|
-
this.#
|
|
776
|
+
if (!to)
|
|
777
|
+
return this.#makeError(`Route ${choice} not allowed from ${this.#state.state}`, 'consume_route_decision');
|
|
778
|
+
await this.#transition(to, { routeChoice: choice });
|
|
779
|
+
await this.#audit(to, 'consume_route_decision', `choice=${choice}`);
|
|
657
780
|
return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
|
|
658
781
|
}
|
|
659
782
|
|
|
@@ -661,27 +784,59 @@ class OstackyController {
|
|
|
661
784
|
this.#load();
|
|
662
785
|
const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
|
|
663
786
|
if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
|
|
664
|
-
this.#transition(to);
|
|
665
|
-
this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
|
|
787
|
+
await this.#transition(to);
|
|
788
|
+
await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
|
|
666
789
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
667
790
|
}
|
|
668
791
|
|
|
669
792
|
async recordExecutionAnalysis({ executionDecisionId, snapshot } = {}) {
|
|
670
793
|
this.#load();
|
|
671
794
|
const to = this.#isAllowedTransition(this.#state.state, 'record_execution_analysis');
|
|
672
|
-
if (!to)
|
|
795
|
+
if (!to)
|
|
796
|
+
return this.#makeError(
|
|
797
|
+
`Cannot record execution analysis from state ${this.#state.state}`,
|
|
798
|
+
'record_execution_analysis'
|
|
799
|
+
);
|
|
673
800
|
if (snapshot && safeJsonStringify(snapshot).length > MAX_SNAPSHOT_JSON_LENGTH) {
|
|
674
801
|
return this.#makeError(
|
|
675
802
|
`Snapshot exceeds maximum size of ${MAX_SNAPSHOT_JSON_LENGTH} bytes`,
|
|
676
803
|
'record_execution_analysis'
|
|
677
804
|
);
|
|
678
805
|
}
|
|
679
|
-
|
|
806
|
+
// C2: strict contract — recommendation + reasons required
|
|
807
|
+
if (snapshot && (!snapshot.recommendation || !snapshot.reasons)) {
|
|
808
|
+
return this.#makeError('Snapshot missing recommendation/reasons', 'record_execution_analysis');
|
|
809
|
+
}
|
|
810
|
+
// C2: capture expected tasks for gate
|
|
811
|
+
const expectedTasks = snapshot?.expectedTaskIds || snapshot?.taskIds || null;
|
|
812
|
+
const expectedTaskCount = snapshot?.taskCount ?? (Array.isArray(expectedTasks) ? expectedTasks.length : null);
|
|
813
|
+
await this.#transition(to, {
|
|
680
814
|
executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
|
|
681
815
|
executionMode: null,
|
|
682
816
|
snapshots: { ...this.#state.snapshots, execution: snapshot || null },
|
|
817
|
+
expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
|
|
818
|
+
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
683
819
|
});
|
|
684
|
-
this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
820
|
+
await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
821
|
+
// C2: warning if missing codegraphUsed+recommendation and not degraded
|
|
822
|
+
const hasEvidence =
|
|
823
|
+
snapshot &&
|
|
824
|
+
Array.isArray(snapshot.codegraphUsed) &&
|
|
825
|
+
snapshot.codegraphUsed.length > 0 &&
|
|
826
|
+
snapshot.recommendation != null;
|
|
827
|
+
if (snapshot && !hasEvidence && !this.#degraded) {
|
|
828
|
+
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
829
|
+
log('warn:execution_without_codegraph', { auditId });
|
|
830
|
+
await this.#audit('WARN', 'execution_without_codegraph', 'codegraphUsed/recommendation missing');
|
|
831
|
+
const lastAudit = this.#state.audit?.[this.#state.audit.length - 1];
|
|
832
|
+
return {
|
|
833
|
+
state: this.#state.state,
|
|
834
|
+
revision: this.#state.revision,
|
|
835
|
+
executionDecisionId: this.#state.executionDecisionId,
|
|
836
|
+
warning: 'execution analysis without execution-mode-evaluation',
|
|
837
|
+
auditId: lastAudit?.id || auditId,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
685
840
|
return {
|
|
686
841
|
state: this.#state.state,
|
|
687
842
|
revision: this.#state.revision,
|
|
@@ -692,33 +847,90 @@ class OstackyController {
|
|
|
692
847
|
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
693
848
|
this.#load();
|
|
694
849
|
if (this.#state.state !== 'EXECUTION_DECISION_PENDING') {
|
|
695
|
-
return this.#makeError(
|
|
850
|
+
return this.#makeError(
|
|
851
|
+
`Cannot consume execution decision from state ${this.#state.state}`,
|
|
852
|
+
'consume_execution_decision'
|
|
853
|
+
);
|
|
696
854
|
}
|
|
697
|
-
if (this.#state.executionDecisionId !== decisionId)
|
|
855
|
+
if (this.#state.executionDecisionId !== decisionId)
|
|
856
|
+
return this.#makeError('Decision ID mismatch', 'consume_execution_decision');
|
|
698
857
|
const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
|
|
699
|
-
if (!to)
|
|
700
|
-
|
|
701
|
-
this.#
|
|
858
|
+
if (!to)
|
|
859
|
+
return this.#makeError(`Mode ${mode} not allowed from ${this.#state.state}`, 'consume_execution_decision');
|
|
860
|
+
await this.#transition(to, { executionMode: mode });
|
|
861
|
+
await this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
|
|
702
862
|
return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
|
|
703
863
|
}
|
|
704
864
|
|
|
705
|
-
async implementationComplete() {
|
|
865
|
+
async implementationComplete({ force } = {}) {
|
|
706
866
|
this.#load();
|
|
707
867
|
const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
|
|
708
|
-
if (!to)
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
868
|
+
if (!to)
|
|
869
|
+
return this.#makeError(
|
|
870
|
+
`Cannot complete implementation from state ${this.#state.state}`,
|
|
871
|
+
'implementation_complete'
|
|
872
|
+
);
|
|
873
|
+
// C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
|
|
874
|
+
let pending = [];
|
|
875
|
+
if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
|
|
876
|
+
pending = this.#state.expectedTasks.filter(
|
|
877
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
878
|
+
);
|
|
879
|
+
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
880
|
+
const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
|
|
881
|
+
if (completed < this.#state.expectedTaskCount)
|
|
882
|
+
pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
|
|
883
|
+
}
|
|
884
|
+
// T3: also block on stale fingerprints-vs-disk
|
|
885
|
+
let staleFiles = [];
|
|
886
|
+
try {
|
|
887
|
+
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
888
|
+
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
889
|
+
const current = fastFingerprint(info.filePath);
|
|
890
|
+
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
891
|
+
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
892
|
+
}
|
|
893
|
+
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
894
|
+
if (staleFiles.some((s) => s.includes(fp))) continue;
|
|
895
|
+
const cur = fastFingerprint(fp);
|
|
896
|
+
if (!cur) staleFiles.push(`${fp} (missing)`);
|
|
897
|
+
else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
898
|
+
}
|
|
899
|
+
} catch {}
|
|
900
|
+
const hasBlocking = pending.length > 0 || staleFiles.length > 0;
|
|
901
|
+
if (hasBlocking && !force) {
|
|
902
|
+
return {
|
|
903
|
+
error: staleFiles.length ? 'stale fingerprints' : 'tasks incomplete',
|
|
904
|
+
pending,
|
|
905
|
+
staleFiles: staleFiles.length ? staleFiles : undefined,
|
|
906
|
+
current_state: this.#state.state,
|
|
907
|
+
attempted_transition: 'implementation_complete',
|
|
908
|
+
suggestion:
|
|
909
|
+
'Complete pending tasks via complete_task or retry with {force:true} after explicit user confirmation',
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
if (hasBlocking && force) {
|
|
913
|
+
const all = [...pending, ...staleFiles].join(',');
|
|
914
|
+
await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
|
|
915
|
+
}
|
|
916
|
+
await this.#transition(to);
|
|
917
|
+
await this.#audit('SYNC', 'implementation_complete');
|
|
918
|
+
return {
|
|
919
|
+
state: this.#state.state,
|
|
920
|
+
revision: this.#state.revision,
|
|
921
|
+
forced: !!force,
|
|
922
|
+
pending: pending.length ? pending : undefined,
|
|
923
|
+
};
|
|
712
924
|
}
|
|
713
925
|
|
|
714
926
|
async syncComplete() {
|
|
715
927
|
this.#load();
|
|
716
928
|
const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
|
|
717
929
|
if (!to) return this.#makeError(`Cannot complete sync from state ${this.#state.state}`, 'sync_complete');
|
|
718
|
-
this.#transition(to);
|
|
719
|
-
this.#audit('DONE', 'sync_complete');
|
|
930
|
+
await this.#transition(to);
|
|
931
|
+
await this.#audit('DONE', 'sync_complete');
|
|
720
932
|
// Flush remaining audit entries
|
|
721
|
-
this.#flushAudit();
|
|
933
|
+
await this.#flushAudit();
|
|
722
934
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
723
935
|
}
|
|
724
936
|
|
|
@@ -726,8 +938,8 @@ class OstackyController {
|
|
|
726
938
|
this.#load();
|
|
727
939
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
728
940
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
729
|
-
this.#transition(to, { error: reason || 'Blocked' });
|
|
730
|
-
this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
941
|
+
await this.#transition(to, { error: reason || 'Blocked' });
|
|
942
|
+
await this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
731
943
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
732
944
|
}
|
|
733
945
|
|
|
@@ -735,7 +947,7 @@ class OstackyController {
|
|
|
735
947
|
this.#load();
|
|
736
948
|
const to = this.#isAllowedTransition(this.#state.state, 'replan');
|
|
737
949
|
if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
|
|
738
|
-
this.#transition(to, {
|
|
950
|
+
await this.#transition(to, {
|
|
739
951
|
error: reason || null,
|
|
740
952
|
routeDecisionId: null,
|
|
741
953
|
routeChoice: null,
|
|
@@ -744,11 +956,86 @@ class OstackyController {
|
|
|
744
956
|
snapshots: { codegraph: null, execution: null },
|
|
745
957
|
tasks: {},
|
|
746
958
|
fileFingerprints: {},
|
|
959
|
+
expectedTasks: null,
|
|
960
|
+
expectedTaskCount: null,
|
|
747
961
|
});
|
|
748
|
-
this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
962
|
+
await this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
749
963
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
750
964
|
}
|
|
751
965
|
|
|
966
|
+
// --- C2: Expected tasks gate (controller as source of truth) ---
|
|
967
|
+
async setExpectedTasks({ taskIds, taskCount } = {}) {
|
|
968
|
+
this.#load();
|
|
969
|
+
if (Array.isArray(taskIds) && taskIds.length > 0) {
|
|
970
|
+
this.#state.expectedTasks = [...taskIds];
|
|
971
|
+
this.#state.expectedTaskCount = taskIds.length;
|
|
972
|
+
} else if (typeof taskCount === 'number' && taskCount > 0) {
|
|
973
|
+
this.#state.expectedTasks = null;
|
|
974
|
+
this.#state.expectedTaskCount = taskCount;
|
|
975
|
+
} else {
|
|
976
|
+
return { error: 'taskIds (array) or taskCount (number) required' };
|
|
977
|
+
}
|
|
978
|
+
await this.#persist();
|
|
979
|
+
await this.#audit(
|
|
980
|
+
'EXECUTING',
|
|
981
|
+
'set_expected_tasks',
|
|
982
|
+
`expected=${this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length}`
|
|
983
|
+
);
|
|
984
|
+
return { ok: true, expectedTasks: this.#state.expectedTasks, expectedTaskCount: this.#state.expectedTaskCount };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async verifyIntegrity() {
|
|
988
|
+
this.#load();
|
|
989
|
+
let pending = [];
|
|
990
|
+
if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
|
|
991
|
+
pending = this.#state.expectedTasks.filter(
|
|
992
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
993
|
+
);
|
|
994
|
+
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
995
|
+
const completed = Object.values(this.#state.tasks).filter((t) => t.status === 'COMPLETED').length;
|
|
996
|
+
if (completed < this.#state.expectedTaskCount)
|
|
997
|
+
pending = [`${completed}/${this.#state.expectedTaskCount} completed`];
|
|
998
|
+
}
|
|
999
|
+
// T3: fingerprints-vs-disk — detect stale/missing files after complete_task
|
|
1000
|
+
let staleFiles = [];
|
|
1001
|
+
try {
|
|
1002
|
+
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1003
|
+
if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
|
|
1004
|
+
const current = fastFingerprint(info.filePath);
|
|
1005
|
+
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1006
|
+
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
1007
|
+
}
|
|
1008
|
+
for (const [fp, stored] of Object.entries(this.#state.fileFingerprints || {})) {
|
|
1009
|
+
if (staleFiles.some((s) => s.includes(fp))) continue;
|
|
1010
|
+
const current = fastFingerprint(fp);
|
|
1011
|
+
if (!current) staleFiles.push(`${fp} (missing)`);
|
|
1012
|
+
else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
|
|
1013
|
+
}
|
|
1014
|
+
} catch {}
|
|
1015
|
+
const ok = pending.length === 0 && staleFiles.length === 0;
|
|
1016
|
+
return {
|
|
1017
|
+
ok,
|
|
1018
|
+
pending,
|
|
1019
|
+
staleFiles,
|
|
1020
|
+
completed: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED').length,
|
|
1021
|
+
expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
|
|
1022
|
+
state: this.#state.state,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async getAudit({ limit = 20, offset = 0 } = {}) {
|
|
1027
|
+
this.#load();
|
|
1028
|
+
const all = this.#state.audit || [];
|
|
1029
|
+
const slice = all.slice(Math.max(0, all.length - limit - offset), all.length - offset).reverse();
|
|
1030
|
+
return slice.map((e) => ({
|
|
1031
|
+
id: e.id,
|
|
1032
|
+
ts: e.ts,
|
|
1033
|
+
phase: e.phase,
|
|
1034
|
+
decision: e.decision,
|
|
1035
|
+
reasoning: e.reasoning ? String(e.reasoning).slice(0, 300) : undefined,
|
|
1036
|
+
}));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
752
1039
|
// --- B2: Handoff persistence for cross-session continuity ---
|
|
753
1040
|
async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
|
|
754
1041
|
this.#load();
|
|
@@ -761,22 +1048,37 @@ class OstackyController {
|
|
|
761
1048
|
nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
|
|
762
1049
|
pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
|
|
763
1050
|
};
|
|
764
|
-
this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
765
|
-
this.#persist();
|
|
1051
|
+
await this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
1052
|
+
await this.#persist();
|
|
766
1053
|
return { ok: true, lastHandoff: this.#state.lastHandoff };
|
|
767
1054
|
}
|
|
768
1055
|
|
|
769
1056
|
async getHandoff() {
|
|
770
1057
|
this.#load();
|
|
771
|
-
return this.#state.lastHandoff;
|
|
1058
|
+
if (this.#state.lastHandoff) return this.#state.lastHandoff;
|
|
1059
|
+
// C3: fallback to compaction file — same anchor as writer (dirname(statePath))
|
|
1060
|
+
if (!this.#statePath) return null;
|
|
1061
|
+
try {
|
|
1062
|
+
const fallbackPath = join(dirname(this.#statePath), '.ostacky-handoff-compaction.json');
|
|
1063
|
+
const raw = readFileSync(fallbackPath, 'utf8');
|
|
1064
|
+
const data = JSON.parse(raw);
|
|
1065
|
+
if (data && typeof data.summary === 'string') return data;
|
|
1066
|
+
} catch {}
|
|
1067
|
+
return null;
|
|
772
1068
|
}
|
|
773
1069
|
|
|
774
1070
|
async clearHandoff() {
|
|
775
1071
|
this.#load();
|
|
776
1072
|
const prev = this.#state.lastHandoff;
|
|
777
1073
|
this.#state.lastHandoff = null;
|
|
778
|
-
|
|
779
|
-
this.#
|
|
1074
|
+
// C3: also delete fallback compaction file (same anchor)
|
|
1075
|
+
if (this.#statePath) {
|
|
1076
|
+
try {
|
|
1077
|
+
unlinkSync(join(dirname(this.#statePath), '.ostacky-handoff-compaction.json'));
|
|
1078
|
+
} catch {}
|
|
1079
|
+
}
|
|
1080
|
+
await this.#audit('HANDOFF', 'clear_handoff', prev?.summary?.slice(0, 100) || 'none');
|
|
1081
|
+
await this.#persist();
|
|
780
1082
|
return { ok: true, cleared: prev };
|
|
781
1083
|
}
|
|
782
1084
|
|
|
@@ -868,23 +1170,68 @@ class OstackyController {
|
|
|
868
1170
|
this.#state.fileFingerprints[filePath] = effectiveHash;
|
|
869
1171
|
}
|
|
870
1172
|
this.#trimTasks();
|
|
871
|
-
this.#
|
|
872
|
-
|
|
1173
|
+
const totalCompleted = Object.keys(this.#state.tasks).filter(
|
|
1174
|
+
(k) => this.#state.tasks[k].status === 'COMPLETED'
|
|
1175
|
+
).length;
|
|
1176
|
+
// C2: checkpoint count-based cada 3er complete_task — mismo persist, sin escritura extra
|
|
1177
|
+
if (totalCompleted % 3 === 0) {
|
|
1178
|
+
const pendingForHandoff = Array.isArray(this.#state.expectedTasks)
|
|
1179
|
+
? this.#state.expectedTasks.filter(
|
|
1180
|
+
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1181
|
+
)
|
|
1182
|
+
: [];
|
|
1183
|
+
this.#state.lastHandoff = {
|
|
1184
|
+
ts: Date.now(),
|
|
1185
|
+
summary: `Checkpoint auto: ${totalCompleted} tasks completadas`,
|
|
1186
|
+
nextSteps: pendingForHandoff.length ? [`Continuar con ${pendingForHandoff.join(', ')}`] : [],
|
|
1187
|
+
pendingTasks: pendingForHandoff,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
await this.#persist();
|
|
1191
|
+
await this.#audit('EXECUTING', 'complete_task', `taskId=${taskId}`);
|
|
873
1192
|
return {
|
|
874
1193
|
taskId,
|
|
875
1194
|
status: 'COMPLETED',
|
|
876
|
-
totalCompleted
|
|
877
|
-
.length,
|
|
1195
|
+
totalCompleted,
|
|
878
1196
|
};
|
|
879
1197
|
}
|
|
880
1198
|
|
|
881
1199
|
/**
|
|
882
|
-
* Public flush —
|
|
883
|
-
*
|
|
1200
|
+
* Public flush — SYNCHRONOUS on purpose: SIGINT/SIGTERM handlers cannot await
|
|
1201
|
+
* (Node does not wait for async shutdown work). Drains the audit buffer into
|
|
1202
|
+
* state and runs a best-effort sync persist with a single non-spinning lock
|
|
1203
|
+
* attempt; skips persisting if another process currently holds the lock.
|
|
884
1204
|
*/
|
|
885
1205
|
flush() {
|
|
886
|
-
this.#
|
|
887
|
-
|
|
1206
|
+
if (this.#auditBuffer.length > 0 && this.#state) {
|
|
1207
|
+
if (!this.#state.audit) this.#state.audit = [];
|
|
1208
|
+
for (const e of this.#auditBuffer) {
|
|
1209
|
+
if (!e.id) e.id = `aud-${e.ts}-${this.#state.auditSeq++}`;
|
|
1210
|
+
}
|
|
1211
|
+
this.#state.audit.push(...this.#auditBuffer);
|
|
1212
|
+
if (this.#state.audit.length > 100) this.#state.audit = this.#state.audit.slice(-100);
|
|
1213
|
+
this.#auditBuffer = [];
|
|
1214
|
+
}
|
|
1215
|
+
// T1: final persist path kept synchronous for graceful shutdown
|
|
1216
|
+
if (!this.#statePath || !this.#state || !this.#loaded) return;
|
|
1217
|
+
try {
|
|
1218
|
+
try {
|
|
1219
|
+
writeFileSync(this.#lockPidPath, String(process.pid), { encoding: 'utf8', flag: 'wx' });
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
if (e && e.code === 'EEXIST') return; // another process holds the lock — skip best-effort persist
|
|
1222
|
+
throw e;
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
writeFileSync(this.#lockHeartbeatPath, String(Date.now()), 'utf8');
|
|
1226
|
+
} catch {}
|
|
1227
|
+
const serialized = safeJsonStringify(this.#state, true);
|
|
1228
|
+
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
1229
|
+
writeFileSync(tmp, serialized, 'utf8');
|
|
1230
|
+
renameSync(tmp, this.#statePath);
|
|
1231
|
+
this.#releaseLock();
|
|
1232
|
+
} catch {
|
|
1233
|
+
/* shutdown persist is best-effort */
|
|
1234
|
+
}
|
|
888
1235
|
}
|
|
889
1236
|
}
|
|
890
1237
|
|
|
@@ -917,7 +1264,7 @@ function safeHandler(fn) {
|
|
|
917
1264
|
|
|
918
1265
|
const server = new McpServer({
|
|
919
1266
|
name: 'ostacky-controller',
|
|
920
|
-
version: '0.7.
|
|
1267
|
+
version: '0.7.2',
|
|
921
1268
|
});
|
|
922
1269
|
|
|
923
1270
|
server.registerTool(
|
|
@@ -939,7 +1286,8 @@ server.registerTool(
|
|
|
939
1286
|
server.registerTool(
|
|
940
1287
|
'request_clarification',
|
|
941
1288
|
{
|
|
942
|
-
description:
|
|
1289
|
+
description:
|
|
1290
|
+
'Pause execution to ask the user for clarification. Use when the request is too vague to classify. Transitions to CLARIFICATION_PENDING — you MUST stop and wait for user response.',
|
|
943
1291
|
inputSchema: z.object({
|
|
944
1292
|
question: z.string().optional().describe('The clarification question'),
|
|
945
1293
|
}),
|
|
@@ -1039,12 +1387,18 @@ server.registerTool(
|
|
|
1039
1387
|
server.registerTool(
|
|
1040
1388
|
'implementation_complete',
|
|
1041
1389
|
{
|
|
1042
|
-
description:
|
|
1043
|
-
|
|
1390
|
+
description:
|
|
1391
|
+
'Mark implementation as complete. Transitions to SYNC. Returns error without transitioning if tasks pending unless {force:true}.',
|
|
1392
|
+
inputSchema: z.object({
|
|
1393
|
+
force: z
|
|
1394
|
+
.boolean()
|
|
1395
|
+
.optional()
|
|
1396
|
+
.describe('Force transition even with pending tasks (requires explicit user confirmation)'),
|
|
1397
|
+
}),
|
|
1044
1398
|
},
|
|
1045
|
-
safeHandler(async () => {
|
|
1046
|
-
log('tool:implementation_complete');
|
|
1047
|
-
return await controller.implementationComplete();
|
|
1399
|
+
safeHandler(async ({ force }) => {
|
|
1400
|
+
log('tool:implementation_complete', { force: !!force });
|
|
1401
|
+
return await controller.implementationComplete({ force: !!force });
|
|
1048
1402
|
})
|
|
1049
1403
|
);
|
|
1050
1404
|
|
|
@@ -1088,10 +1442,55 @@ server.registerTool(
|
|
|
1088
1442
|
})
|
|
1089
1443
|
);
|
|
1090
1444
|
|
|
1445
|
+
server.registerTool(
|
|
1446
|
+
'set_expected_tasks',
|
|
1447
|
+
{
|
|
1448
|
+
description:
|
|
1449
|
+
'Register expected task IDs for the integrity gate (controller as source of truth). Call after execution analysis.',
|
|
1450
|
+
inputSchema: z.object({
|
|
1451
|
+
taskIds: z.array(z.string()).optional().describe('Array of expected task IDs'),
|
|
1452
|
+
taskCount: z.number().optional().describe('Fallback count when IDs not available'),
|
|
1453
|
+
}),
|
|
1454
|
+
},
|
|
1455
|
+
safeHandler(async ({ taskIds, taskCount }) => {
|
|
1456
|
+
log('tool:set_expected_tasks', { count: taskIds?.length ?? taskCount });
|
|
1457
|
+
return await controller.setExpectedTasks({ taskIds, taskCount });
|
|
1458
|
+
})
|
|
1459
|
+
);
|
|
1460
|
+
|
|
1461
|
+
server.registerTool(
|
|
1462
|
+
'verify_integrity',
|
|
1463
|
+
{
|
|
1464
|
+
description:
|
|
1465
|
+
'Verify execution integrity: compare expectedTasks vs completed tasks. Use before implementation_complete.',
|
|
1466
|
+
inputSchema: z.object({}),
|
|
1467
|
+
},
|
|
1468
|
+
safeHandler(async () => {
|
|
1469
|
+
log('tool:verify_integrity');
|
|
1470
|
+
return await controller.verifyIntegrity();
|
|
1471
|
+
})
|
|
1472
|
+
);
|
|
1473
|
+
|
|
1474
|
+
server.registerTool(
|
|
1475
|
+
'get_audit',
|
|
1476
|
+
{
|
|
1477
|
+
description: 'Get recent audit entries paginated. Read-only, truncated to 300 chars with unique id per entry.',
|
|
1478
|
+
inputSchema: z.object({
|
|
1479
|
+
limit: z.number().optional().describe('Max entries (default 20)'),
|
|
1480
|
+
offset: z.number().optional().describe('Offset from end (default 0)'),
|
|
1481
|
+
}),
|
|
1482
|
+
},
|
|
1483
|
+
safeHandler(async ({ limit, offset }) => {
|
|
1484
|
+
log('tool:get_audit', { limit, offset });
|
|
1485
|
+
return await controller.getAudit({ limit, offset });
|
|
1486
|
+
})
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1091
1489
|
server.registerTool(
|
|
1092
1490
|
'proceed_to_route',
|
|
1093
1491
|
{
|
|
1094
|
-
description:
|
|
1492
|
+
description:
|
|
1493
|
+
'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed. Only valid from LEVEL_RESOLVED — call this after asking the user about the route decision.',
|
|
1095
1494
|
inputSchema: z.object({}),
|
|
1096
1495
|
},
|
|
1097
1496
|
safeHandler(async () => {
|
|
@@ -1170,7 +1569,8 @@ server.registerTool(
|
|
|
1170
1569
|
server.registerTool(
|
|
1171
1570
|
'set_handoff',
|
|
1172
1571
|
{
|
|
1173
|
-
description:
|
|
1572
|
+
description:
|
|
1573
|
+
'Save handoff context for the next session. Call at session end if interrupted or before a context switch. Persists to controller state.',
|
|
1174
1574
|
inputSchema: z.object({
|
|
1175
1575
|
summary: z.string().describe('What we were working on (1-3 sentences)'),
|
|
1176
1576
|
nextSteps: z.array(z.string()).optional().describe('Concrete next actions'),
|
|
@@ -1278,7 +1678,10 @@ server.registerTool(
|
|
|
1278
1678
|
inputSchema: z.object({
|
|
1279
1679
|
taskId: z.string().describe('The task ID to mark as completed.'),
|
|
1280
1680
|
filePath: z.string().optional().describe('Optional file path that was modified.'),
|
|
1281
|
-
fileHash: z
|
|
1681
|
+
fileHash: z
|
|
1682
|
+
.string()
|
|
1683
|
+
.optional()
|
|
1684
|
+
.describe('Optional SHA-256 or fast fingerprint of the file after modification.'),
|
|
1282
1685
|
}),
|
|
1283
1686
|
},
|
|
1284
1687
|
safeHandler(async ({ taskId, filePath, fileHash }) => {
|
|
@@ -1318,7 +1721,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
1318
1721
|
}
|
|
1319
1722
|
|
|
1320
1723
|
async function main() {
|
|
1321
|
-
log('Starting ostacky-controller MCP v0.7.
|
|
1724
|
+
log('Starting ostacky-controller MCP v0.7.2...');
|
|
1322
1725
|
log('State path:', { path: statePath });
|
|
1323
1726
|
// Clean up stale tmp/lock files from previous runs
|
|
1324
1727
|
cleanupTmpFiles(statePath);
|
|
@@ -1338,4 +1741,4 @@ if (isDirectRun) {
|
|
|
1338
1741
|
});
|
|
1339
1742
|
}
|
|
1340
1743
|
|
|
1341
|
-
export { OstackyController };
|
|
1744
|
+
export { OstackyController, STATES, DEFAULT_STATE };
|