ostacky 0.7.1 → 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 +471 -104
- 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 +297 -136
- 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
|
}
|
|
@@ -448,11 +514,11 @@ class OstackyController {
|
|
|
448
514
|
log('warn:tasks_trimmed', { before: entries.length, after: MAX_TASKS });
|
|
449
515
|
}
|
|
450
516
|
|
|
451
|
-
#transition(to, changes = {}) {
|
|
517
|
+
async #transition(to, changes = {}) {
|
|
452
518
|
this.#state.revision++;
|
|
453
519
|
this.#state.state = to;
|
|
454
520
|
Object.assign(this.#state, changes);
|
|
455
|
-
this.#persist();
|
|
521
|
+
await this.#persist();
|
|
456
522
|
}
|
|
457
523
|
|
|
458
524
|
// --- O4: O(1) transition lookup via pre-computed cache ---
|
|
@@ -461,28 +527,44 @@ class OstackyController {
|
|
|
461
527
|
return ALLOWED_TRANSITIONS[from]?.get(key) || null;
|
|
462
528
|
}
|
|
463
529
|
|
|
464
|
-
// --- O5: Batched audit trail ---
|
|
465
|
-
#audit(phase, decision, reasoning) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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);
|
|
469
543
|
}
|
|
470
544
|
}
|
|
471
545
|
|
|
472
|
-
#flushAudit() {
|
|
546
|
+
async #flushAudit(forcePersist = false) {
|
|
473
547
|
if (this.#auditBuffer.length === 0) return;
|
|
474
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
|
+
}
|
|
475
553
|
this.#state.audit.push(...this.#auditBuffer);
|
|
476
554
|
if (this.#state.audit.length > 100) {
|
|
477
555
|
this.#state.audit = this.#state.audit.slice(-100);
|
|
478
556
|
}
|
|
479
557
|
this.#auditBuffer = [];
|
|
480
|
-
// O1: Skip persist for trivial Level 0
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
) {
|
|
483
565
|
return;
|
|
484
566
|
}
|
|
485
|
-
this.#persist();
|
|
567
|
+
await this.#persist();
|
|
486
568
|
}
|
|
487
569
|
|
|
488
570
|
// --- 3.5: Enriched error with available transitions ---
|
|
@@ -542,7 +624,7 @@ class OstackyController {
|
|
|
542
624
|
if (this.#state.state === 'INTERPRETATION_PENDING' && !requestId) {
|
|
543
625
|
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
544
626
|
}
|
|
545
|
-
this.#transition('INTERPRETATION_PENDING', {
|
|
627
|
+
await this.#transition('INTERPRETATION_PENDING', {
|
|
546
628
|
requestId: requestId || 'req-' + Date.now(),
|
|
547
629
|
changeId: changeId || null,
|
|
548
630
|
routeDecisionId: null,
|
|
@@ -552,9 +634,11 @@ class OstackyController {
|
|
|
552
634
|
snapshots: { codegraph: null, execution: null },
|
|
553
635
|
tasks: {},
|
|
554
636
|
fileFingerprints: {},
|
|
637
|
+
expectedTasks: null,
|
|
638
|
+
expectedTaskCount: null,
|
|
555
639
|
error: null,
|
|
556
640
|
});
|
|
557
|
-
this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
641
|
+
await this.#audit('INTERPRETATION_PENDING', 'start_request', `requestId=${this.#state.requestId}`);
|
|
558
642
|
return { state: this.#state.state, revision: this.#state.revision, requestId: this.#state.requestId };
|
|
559
643
|
}
|
|
560
644
|
|
|
@@ -566,8 +650,8 @@ class OstackyController {
|
|
|
566
650
|
`Cannot request clarification from state ${this.#state.state}`,
|
|
567
651
|
'request_clarification'
|
|
568
652
|
);
|
|
569
|
-
this.#transition(to, { error: question ? `Clarification: ${question}` : null });
|
|
570
|
-
this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
|
|
653
|
+
await this.#transition(to, { error: question ? `Clarification: ${question}` : null });
|
|
654
|
+
await this.#audit('CLARIFICATION_PENDING', 'request_clarification', question || 'no question');
|
|
571
655
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
572
656
|
}
|
|
573
657
|
|
|
@@ -579,8 +663,8 @@ class OstackyController {
|
|
|
579
663
|
`Cannot record clarification from state ${this.#state.state}`,
|
|
580
664
|
'record_clarification'
|
|
581
665
|
);
|
|
582
|
-
this.#transition(to, { error: null });
|
|
583
|
-
this.#audit('DISCOVERY', 'record_clarification');
|
|
666
|
+
await this.#transition(to, { error: null });
|
|
667
|
+
await this.#audit('DISCOVERY', 'record_clarification');
|
|
584
668
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
585
669
|
}
|
|
586
670
|
|
|
@@ -609,6 +693,11 @@ class OstackyController {
|
|
|
609
693
|
const to = this.#isAllowedTransition(this.#state.state, 'record_discovery');
|
|
610
694
|
if (!to) return this.#makeError(`Cannot record discovery from state ${this.#state.state}`, 'record_discovery');
|
|
611
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
|
+
|
|
612
701
|
// O3: Compress snapshot before persisting
|
|
613
702
|
const compressedSnapshot = snapshot
|
|
614
703
|
? this.#compressCodegraphSnapshot(snapshot)
|
|
@@ -622,13 +711,30 @@ class OstackyController {
|
|
|
622
711
|
}
|
|
623
712
|
|
|
624
713
|
const defaultChoice = level === '1+' ? 'SPEC' : 'DIRECT';
|
|
625
|
-
this.#transition(to, {
|
|
714
|
+
await this.#transition(to, {
|
|
626
715
|
routeDecisionId: routeDecisionId || 'route-' + Date.now(),
|
|
627
716
|
routeChoice: defaultChoice, // O2: persist default suggested choice
|
|
628
717
|
level, // O1: persist level for conditional persistence
|
|
629
718
|
snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
|
|
630
719
|
});
|
|
631
|
-
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
|
+
}
|
|
632
738
|
return {
|
|
633
739
|
state: this.#state.state,
|
|
634
740
|
revision: this.#state.revision,
|
|
@@ -642,8 +748,8 @@ class OstackyController {
|
|
|
642
748
|
this.#load();
|
|
643
749
|
const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
|
|
644
750
|
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');
|
|
751
|
+
await this.#transition(to);
|
|
752
|
+
await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
|
|
647
753
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
648
754
|
}
|
|
649
755
|
|
|
@@ -651,8 +757,8 @@ class OstackyController {
|
|
|
651
757
|
this.#load();
|
|
652
758
|
const to = this.#isAllowedTransition(this.#state.state, 'abandon');
|
|
653
759
|
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');
|
|
760
|
+
await this.#transition(to, { error: reason || 'Abandoned' });
|
|
761
|
+
await this.#audit('BLOCKED/DONE', 'abandon', reason || 'no reason');
|
|
656
762
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
657
763
|
}
|
|
658
764
|
|
|
@@ -669,8 +775,8 @@ class OstackyController {
|
|
|
669
775
|
const to = this.#isAllowedTransition(this.#state.state, 'consume_route_decision', choice);
|
|
670
776
|
if (!to)
|
|
671
777
|
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}`);
|
|
778
|
+
await this.#transition(to, { routeChoice: choice });
|
|
779
|
+
await this.#audit(to, 'consume_route_decision', `choice=${choice}`);
|
|
674
780
|
return { state: this.#state.state, revision: this.#state.revision, routeChoice: choice };
|
|
675
781
|
}
|
|
676
782
|
|
|
@@ -678,8 +784,8 @@ class OstackyController {
|
|
|
678
784
|
this.#load();
|
|
679
785
|
const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
|
|
680
786
|
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');
|
|
787
|
+
await this.#transition(to);
|
|
788
|
+
await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
|
|
683
789
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
684
790
|
}
|
|
685
791
|
|
|
@@ -697,12 +803,40 @@ class OstackyController {
|
|
|
697
803
|
'record_execution_analysis'
|
|
698
804
|
);
|
|
699
805
|
}
|
|
700
|
-
|
|
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, {
|
|
701
814
|
executionDecisionId: executionDecisionId || 'exec-' + Date.now(),
|
|
702
815
|
executionMode: null,
|
|
703
816
|
snapshots: { ...this.#state.snapshots, execution: snapshot || null },
|
|
817
|
+
expectedTasks: Array.isArray(expectedTasks) ? expectedTasks : null,
|
|
818
|
+
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
704
819
|
});
|
|
705
|
-
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
|
+
}
|
|
706
840
|
return {
|
|
707
841
|
state: this.#state.state,
|
|
708
842
|
revision: this.#state.revision,
|
|
@@ -723,12 +857,12 @@ class OstackyController {
|
|
|
723
857
|
const to = this.#isAllowedTransition(this.#state.state, 'consume_execution_decision', mode);
|
|
724
858
|
if (!to)
|
|
725
859
|
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}`);
|
|
860
|
+
await this.#transition(to, { executionMode: mode });
|
|
861
|
+
await this.#audit(to, 'consume_execution_decision', `mode=${mode}`);
|
|
728
862
|
return { state: this.#state.state, revision: this.#state.revision, executionMode: mode };
|
|
729
863
|
}
|
|
730
864
|
|
|
731
|
-
async implementationComplete() {
|
|
865
|
+
async implementationComplete({ force } = {}) {
|
|
732
866
|
this.#load();
|
|
733
867
|
const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
|
|
734
868
|
if (!to)
|
|
@@ -736,19 +870,67 @@ class OstackyController {
|
|
|
736
870
|
`Cannot complete implementation from state ${this.#state.state}`,
|
|
737
871
|
'implementation_complete'
|
|
738
872
|
);
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
+
};
|
|
742
924
|
}
|
|
743
925
|
|
|
744
926
|
async syncComplete() {
|
|
745
927
|
this.#load();
|
|
746
928
|
const to = this.#isAllowedTransition(this.#state.state, 'sync_complete');
|
|
747
929
|
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');
|
|
930
|
+
await this.#transition(to);
|
|
931
|
+
await this.#audit('DONE', 'sync_complete');
|
|
750
932
|
// Flush remaining audit entries
|
|
751
|
-
this.#flushAudit();
|
|
933
|
+
await this.#flushAudit();
|
|
752
934
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
753
935
|
}
|
|
754
936
|
|
|
@@ -756,8 +938,8 @@ class OstackyController {
|
|
|
756
938
|
this.#load();
|
|
757
939
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
758
940
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
759
|
-
this.#transition(to, { error: reason || 'Blocked' });
|
|
760
|
-
this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
941
|
+
await this.#transition(to, { error: reason || 'Blocked' });
|
|
942
|
+
await this.#audit('BLOCKED', 'block', reason || 'no reason');
|
|
761
943
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
762
944
|
}
|
|
763
945
|
|
|
@@ -765,7 +947,7 @@ class OstackyController {
|
|
|
765
947
|
this.#load();
|
|
766
948
|
const to = this.#isAllowedTransition(this.#state.state, 'replan');
|
|
767
949
|
if (!to) return this.#makeError(`Cannot replan from state ${this.#state.state}`, 'replan');
|
|
768
|
-
this.#transition(to, {
|
|
950
|
+
await this.#transition(to, {
|
|
769
951
|
error: reason || null,
|
|
770
952
|
routeDecisionId: null,
|
|
771
953
|
routeChoice: null,
|
|
@@ -774,11 +956,86 @@ class OstackyController {
|
|
|
774
956
|
snapshots: { codegraph: null, execution: null },
|
|
775
957
|
tasks: {},
|
|
776
958
|
fileFingerprints: {},
|
|
959
|
+
expectedTasks: null,
|
|
960
|
+
expectedTaskCount: null,
|
|
777
961
|
});
|
|
778
|
-
this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
962
|
+
await this.#audit('INTERPRETATION_PENDING', 'replan', reason || 'no reason');
|
|
779
963
|
return { state: this.#state.state, revision: this.#state.revision };
|
|
780
964
|
}
|
|
781
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
|
+
|
|
782
1039
|
// --- B2: Handoff persistence for cross-session continuity ---
|
|
783
1040
|
async setHandoff({ summary, nextSteps, pendingTasks } = {}) {
|
|
784
1041
|
this.#load();
|
|
@@ -791,22 +1048,37 @@ class OstackyController {
|
|
|
791
1048
|
nextSteps: Array.isArray(nextSteps) ? nextSteps : [],
|
|
792
1049
|
pendingTasks: Array.isArray(pendingTasks) ? pendingTasks : [],
|
|
793
1050
|
};
|
|
794
|
-
this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
795
|
-
this.#persist();
|
|
1051
|
+
await this.#audit('HANDOFF', 'set_handoff', summary.slice(0, 100));
|
|
1052
|
+
await this.#persist();
|
|
796
1053
|
return { ok: true, lastHandoff: this.#state.lastHandoff };
|
|
797
1054
|
}
|
|
798
1055
|
|
|
799
1056
|
async getHandoff() {
|
|
800
1057
|
this.#load();
|
|
801
|
-
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;
|
|
802
1068
|
}
|
|
803
1069
|
|
|
804
1070
|
async clearHandoff() {
|
|
805
1071
|
this.#load();
|
|
806
1072
|
const prev = this.#state.lastHandoff;
|
|
807
1073
|
this.#state.lastHandoff = null;
|
|
808
|
-
|
|
809
|
-
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();
|
|
810
1082
|
return { ok: true, cleared: prev };
|
|
811
1083
|
}
|
|
812
1084
|
|
|
@@ -898,23 +1170,68 @@ class OstackyController {
|
|
|
898
1170
|
this.#state.fileFingerprints[filePath] = effectiveHash;
|
|
899
1171
|
}
|
|
900
1172
|
this.#trimTasks();
|
|
901
|
-
this.#
|
|
902
|
-
|
|
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}`);
|
|
903
1192
|
return {
|
|
904
1193
|
taskId,
|
|
905
1194
|
status: 'COMPLETED',
|
|
906
|
-
totalCompleted
|
|
907
|
-
.length,
|
|
1195
|
+
totalCompleted,
|
|
908
1196
|
};
|
|
909
1197
|
}
|
|
910
1198
|
|
|
911
1199
|
/**
|
|
912
|
-
* Public flush —
|
|
913
|
-
*
|
|
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.
|
|
914
1204
|
*/
|
|
915
1205
|
flush() {
|
|
916
|
-
this.#
|
|
917
|
-
|
|
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
|
+
}
|
|
918
1235
|
}
|
|
919
1236
|
}
|
|
920
1237
|
|
|
@@ -947,7 +1264,7 @@ function safeHandler(fn) {
|
|
|
947
1264
|
|
|
948
1265
|
const server = new McpServer({
|
|
949
1266
|
name: 'ostacky-controller',
|
|
950
|
-
version: '0.7.
|
|
1267
|
+
version: '0.7.2',
|
|
951
1268
|
});
|
|
952
1269
|
|
|
953
1270
|
server.registerTool(
|
|
@@ -1070,12 +1387,18 @@ server.registerTool(
|
|
|
1070
1387
|
server.registerTool(
|
|
1071
1388
|
'implementation_complete',
|
|
1072
1389
|
{
|
|
1073
|
-
description:
|
|
1074
|
-
|
|
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
|
+
}),
|
|
1075
1398
|
},
|
|
1076
|
-
safeHandler(async () => {
|
|
1077
|
-
log('tool:implementation_complete');
|
|
1078
|
-
return await controller.implementationComplete();
|
|
1399
|
+
safeHandler(async ({ force }) => {
|
|
1400
|
+
log('tool:implementation_complete', { force: !!force });
|
|
1401
|
+
return await controller.implementationComplete({ force: !!force });
|
|
1079
1402
|
})
|
|
1080
1403
|
);
|
|
1081
1404
|
|
|
@@ -1119,6 +1442,50 @@ server.registerTool(
|
|
|
1119
1442
|
})
|
|
1120
1443
|
);
|
|
1121
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
|
+
|
|
1122
1489
|
server.registerTool(
|
|
1123
1490
|
'proceed_to_route',
|
|
1124
1491
|
{
|
|
@@ -1354,7 +1721,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
1354
1721
|
}
|
|
1355
1722
|
|
|
1356
1723
|
async function main() {
|
|
1357
|
-
log('Starting ostacky-controller MCP v0.7.
|
|
1724
|
+
log('Starting ostacky-controller MCP v0.7.2...');
|
|
1358
1725
|
log('State path:', { path: statePath });
|
|
1359
1726
|
// Clean up stale tmp/lock files from previous runs
|
|
1360
1727
|
cleanupTmpFiles(statePath);
|
|
@@ -1374,4 +1741,4 @@ if (isDirectRun) {
|
|
|
1374
1741
|
});
|
|
1375
1742
|
}
|
|
1376
1743
|
|
|
1377
|
-
export { OstackyController };
|
|
1744
|
+
export { OstackyController, STATES, DEFAULT_STATE };
|