ostacky 0.9.7 → 0.10.1
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 +92 -315
- package/assets/agents/ostacky.md +8 -8
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +33 -6
- package/assets/mcp/ostacky-controller/controller-core.js +4 -0
- package/assets/mcp/ostacky-controller/index.js +374 -38
- package/assets/plugins/ostacky-controller/controller-core.ts +4 -0
- package/assets/plugins/ostacky-controller/index.ts +437 -66
- package/assets/skills/brainstorming/SKILL.md +2 -2
- package/assets/skills/execution-mode-evaluation/SKILL.md +11 -12
- package/assets/skills/openspec-propose/SKILL.md +2 -2
- package/assets/skills/tdd/SKILL.md +1 -1
- package/dist/cli.js +95 -69
- package/manifest.json +32 -32
- package/package.json +1 -1
|
@@ -215,7 +215,10 @@ export function parseTaskVerifications(changeId, statePath) {
|
|
|
215
215
|
if (!m) continue;
|
|
216
216
|
const id = m[1];
|
|
217
217
|
if (!isValidTaskId(id)) continue;
|
|
218
|
-
|
|
218
|
+
// lastIndexOf: la convención es que el marker va al FINAL de la línea. Con indexOf,
|
|
219
|
+
// una task que MENCIONA el formato en su descripción ("usá — verificar: file:...") se
|
|
220
|
+
// parseaba a sí misma mal: ganaba el ejemplo, no el marker real.
|
|
221
|
+
const idx = line.lastIndexOf('verificar:');
|
|
219
222
|
if (idx !== -1) {
|
|
220
223
|
const v = line
|
|
221
224
|
.slice(idx + 'verificar:'.length)
|
|
@@ -230,6 +233,35 @@ export function parseTaskVerifications(changeId, statePath) {
|
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
/**
|
|
237
|
+
* test-verification-execution: evidencia real de una corrida de test.
|
|
238
|
+
*
|
|
239
|
+
* NO usa `status`: el shell tool de OpenCode reporta "completed" incluso con
|
|
240
|
+
* exit != 0 (medido: `sh -c 'exit 3'` -> status "completed", metadata.exit 3).
|
|
241
|
+
* El plugin registra {cmd, exit, ts} por cada shell; acá sólo se acepta el
|
|
242
|
+
* comando exacto, exit 0, y posterior a la última mutación.
|
|
243
|
+
*/
|
|
244
|
+
export function findTestEvidence({ runs, cmd, lastMutationTs, now = Date.now() } = {}) {
|
|
245
|
+
const expected = String(cmd || '').trim();
|
|
246
|
+
if (!expected) return { ok: false, reason: 'test: sin comando' };
|
|
247
|
+
if (!Array.isArray(runs) || runs.length === 0)
|
|
248
|
+
return { ok: false, reason: 'sin evidencia: el plugin no registró corridas de shell (¿está corriendo?)' };
|
|
249
|
+
const matches = runs.filter((r) => r && String(r.cmd || '').trim() === expected);
|
|
250
|
+
if (matches.length === 0) return { ok: false, reason: `nunca se ejecutó exactamente "${expected}"` };
|
|
251
|
+
const last = matches.reduce((a, b) => ((Number(b.ts) || 0) >= (Number(a.ts) || 0) ? b : a));
|
|
252
|
+
if (typeof last.ts !== 'number' || typeof lastMutationTs !== 'number')
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
reason: `sin timestamps: no se puede probar que "${expected}" sea posterior a la última edición`,
|
|
256
|
+
run: last,
|
|
257
|
+
};
|
|
258
|
+
if (last.ts <= lastMutationTs)
|
|
259
|
+
return { ok: false, reason: `la última corrida de "${expected}" es anterior a la última edición`, run: last };
|
|
260
|
+
if (last.exit !== 0)
|
|
261
|
+
return { ok: false, reason: `la última corrida de "${expected}" salió con exit ${last.exit}`, run: last };
|
|
262
|
+
return { ok: true, reason: `exit 0 hace ${Math.max(0, Math.round((now - last.ts) / 1000))}s`, run: last };
|
|
263
|
+
}
|
|
264
|
+
|
|
233
265
|
function isValidTaskId(taskId) {
|
|
234
266
|
return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
|
|
235
267
|
}
|
|
@@ -446,6 +478,11 @@ class OstackyController {
|
|
|
446
478
|
#lockHeartbeatPath;
|
|
447
479
|
#lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
|
|
448
480
|
#lockOwner = false;
|
|
481
|
+
#lockDepth = 0; // multi-sesión: reentrancy (el lock se toma una vez por tool call)
|
|
482
|
+
#callerSessionId = null; // sessionId del caller (de _meta), solo en memoria
|
|
483
|
+
#loadedSnapshot = null; // huella del estado recién leído, para detectar "dirty"
|
|
484
|
+
#persistQueue = Promise.resolve(); // cola in-process: las escrituras nunca se solapan
|
|
485
|
+
#persistSeq = 0; // sufijo único por escritura (tmp.<pid>.<seq>)
|
|
449
486
|
|
|
450
487
|
constructor(opts = {}) {
|
|
451
488
|
this.#statePath = opts.statePath;
|
|
@@ -484,6 +521,76 @@ class OstackyController {
|
|
|
484
521
|
}
|
|
485
522
|
}
|
|
486
523
|
|
|
524
|
+
// ─── multi-sesión ────────────────────────────────────────────────────────
|
|
525
|
+
/**
|
|
526
|
+
* Re-lectura fresca desde disco. Invalida el cache #loaded y reusa la lógica
|
|
527
|
+
* de #load() (migraciones + fallback de backups). Se llama al INICIO de cada
|
|
528
|
+
* tool call, así dos procesos/sesiones nunca operan sobre un snapshot viejo
|
|
529
|
+
* (antes: `if (this.#loaded) return` hacía que el estado quedara congelado).
|
|
530
|
+
*/
|
|
531
|
+
reloadFresh() {
|
|
532
|
+
this.#loaded = false;
|
|
533
|
+
this.#load();
|
|
534
|
+
this.#loadedSnapshot = this.#diffableState();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Huella serializada del estado (para el diff de "dirty").
|
|
539
|
+
* Excluye `lastHeartbeat` porque se actualiza en cada tool call y no debe
|
|
540
|
+
* forzar una escritura a disco (rotaría backups sin cambios reales).
|
|
541
|
+
*/
|
|
542
|
+
#diffableState() {
|
|
543
|
+
if (!this.#state) return '';
|
|
544
|
+
try {
|
|
545
|
+
return safeJsonStringify({ ...this.#state, lastHeartbeat: 0 });
|
|
546
|
+
} catch {
|
|
547
|
+
return '';
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Persiste si hubo mutaciones en memoria respecto de la última lectura/escritura.
|
|
553
|
+
* Es OBLIGATORIO ahora que reloadFresh() descarta el estado en memoria al inicio
|
|
554
|
+
* de cada tool call: sin esto, una mutación que no llamó a #persist (p.ej. el
|
|
555
|
+
* buffer de auditoría, o los campos de ownership) se perdería al siguiente call.
|
|
556
|
+
*/
|
|
557
|
+
async persistIfDirty() {
|
|
558
|
+
if (!this.#statePath || !this.#state) return false;
|
|
559
|
+
// Drenar auditoría pendiente: el buffer vive solo en memoria, así que si no
|
|
560
|
+
// lo volcamos acá se perdería en el próximo reloadFresh().
|
|
561
|
+
try {
|
|
562
|
+
if (this.#auditBuffer.length > 0) await this.#flushAudit(false);
|
|
563
|
+
} catch {}
|
|
564
|
+
if (this.#diffableState() === this.#loadedSnapshot) return false;
|
|
565
|
+
await this.#persist();
|
|
566
|
+
this.#loadedSnapshot = this.#diffableState();
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Registra el sessionId del caller actual (de `_meta["ai.opencode/sessionID"]`). */
|
|
571
|
+
setCallerSession(sessionId) {
|
|
572
|
+
this.#callerSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** sessionId del caller actual, o null si el cliente no lo mandó. */
|
|
576
|
+
get callerSessionId() {
|
|
577
|
+
return this.#callerSessionId;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Toma el lock de estado por toda la duración de un tool call (read-modify-write
|
|
582
|
+
* serializado entre procesos/sesiones). Reentrante: #persist puede volver a
|
|
583
|
+
* pedirlo dentro de la misma operación.
|
|
584
|
+
*/
|
|
585
|
+
async beginWrite() {
|
|
586
|
+
return this.#acquireLock();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Libera el lock tomado por beginWrite (reentrante). */
|
|
590
|
+
endWrite() {
|
|
591
|
+
this.#releaseLock();
|
|
592
|
+
}
|
|
593
|
+
|
|
487
594
|
/**
|
|
488
595
|
* Validates that a parsed state object has the required fields and valid values.
|
|
489
596
|
* Returns null if valid, or an error message if invalid.
|
|
@@ -500,6 +607,13 @@ class OstackyController {
|
|
|
500
607
|
// --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
|
|
501
608
|
async #acquireLock() {
|
|
502
609
|
if (!this.#lockPath) return true;
|
|
610
|
+
// Reentrante: el boundary del tool ya tomó el lock (beginWrite) y #persist lo
|
|
611
|
+
// vuelve a pedir dentro de la MISMA operación. Sin esto, el segundo intento
|
|
612
|
+
// chocaba con nuestro propio lock (EEXIST) y hacía fallar la persistencia.
|
|
613
|
+
if (this.#lockDepth > 0) {
|
|
614
|
+
this.#lockDepth++;
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
503
617
|
const maxAttempts = this.#lockMaxAttempts;
|
|
504
618
|
const lockTimeout = 1000;
|
|
505
619
|
const staleWindow = 15000;
|
|
@@ -546,6 +660,7 @@ class OstackyController {
|
|
|
546
660
|
}
|
|
547
661
|
this.#heartbeatLock();
|
|
548
662
|
this.#lockOwner = true;
|
|
663
|
+
this.#lockDepth = 1;
|
|
549
664
|
return true;
|
|
550
665
|
} catch {
|
|
551
666
|
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
@@ -561,6 +676,12 @@ class OstackyController {
|
|
|
561
676
|
|
|
562
677
|
#releaseLock() {
|
|
563
678
|
if (!this.#lockPath) return;
|
|
679
|
+
// Reentrante: solo el nivel más externo libera de verdad.
|
|
680
|
+
if (this.#lockDepth > 1) {
|
|
681
|
+
this.#lockDepth--;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
this.#lockDepth = 0;
|
|
564
685
|
// D2: verify ownership before deleting — never delete another process's lock
|
|
565
686
|
try {
|
|
566
687
|
const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
@@ -743,7 +864,24 @@ class OstackyController {
|
|
|
743
864
|
this.#loaded = true;
|
|
744
865
|
}
|
|
745
866
|
|
|
746
|
-
|
|
867
|
+
/**
|
|
868
|
+
* Serializa las escrituras de estado DENTRO del proceso.
|
|
869
|
+
* Sin esto, un flush fire-and-forget (#enterDegradedMode/#exitDegradedMode) o un
|
|
870
|
+
* flush del watchdog puede interleavearse con el persist de un handler: ambos
|
|
871
|
+
* escriben el MISMO `tmp.<pid>` y el segundo rename falla con ENOENT (o, peor,
|
|
872
|
+
* se pierde la escritura). Ahora las escrituras se encolan: nunca se solapan.
|
|
873
|
+
*/
|
|
874
|
+
#persist() {
|
|
875
|
+
const run = () => this.#persistInner();
|
|
876
|
+
const next = this.#persistQueue.then(run, run);
|
|
877
|
+
this.#persistQueue = next.then(
|
|
878
|
+
() => {},
|
|
879
|
+
() => {}
|
|
880
|
+
);
|
|
881
|
+
return next;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async #persistInner() {
|
|
747
885
|
if (!this.#statePath) return;
|
|
748
886
|
|
|
749
887
|
const dir = dirname(this.#statePath);
|
|
@@ -818,7 +956,7 @@ class OstackyController {
|
|
|
818
956
|
stateForSerialize.snapshots = { codegraph: null, execution: null };
|
|
819
957
|
serialized = safeJsonStringify(stateForSerialize, true);
|
|
820
958
|
}
|
|
821
|
-
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
959
|
+
const tmp = this.#statePath + '.tmp.' + process.pid + '.' + ++this.#persistSeq;
|
|
822
960
|
await writeFileAsync(tmp, serialized, 'utf8');
|
|
823
961
|
await renameAsync(tmp, this.#statePath);
|
|
824
962
|
// 2.1: backup rotativo 3 niveles best-effort
|
|
@@ -1100,25 +1238,79 @@ class OstackyController {
|
|
|
1100
1238
|
async startRequest({ requestId, changeId, force = false } = {}) {
|
|
1101
1239
|
this.#load();
|
|
1102
1240
|
|
|
1241
|
+
const caller = this.#callerSessionId;
|
|
1242
|
+
const owner = this.#state.ownerSessionId;
|
|
1243
|
+
// OJO: `INTERPRETATION_PENDING` está en TERMINAL_STATES — es "terminal" para
|
|
1244
|
+
// decidir si se RESUME, pero NO significa que no haya trabajo. Por eso separamos:
|
|
1245
|
+
// - resumeEligible: semántica ORIGINAL de start_request (no la tocamos).
|
|
1246
|
+
// - foreignOwnedWork: hay una request de OTRA sesión que todavía no terminó.
|
|
1247
|
+
const resumeEligible = !TERMINAL_STATES.includes(this.#state.state) && !!this.#state.requestId;
|
|
1248
|
+
const foreignOwnedWork =
|
|
1249
|
+
!!this.#state.requestId &&
|
|
1250
|
+
this.#state.state !== STATES.DONE &&
|
|
1251
|
+
!!owner &&
|
|
1252
|
+
!!caller &&
|
|
1253
|
+
owner !== caller;
|
|
1254
|
+
|
|
1255
|
+
// ── Guard de ownership (multi-sesión) ──
|
|
1256
|
+
// Si hay una request viva de OTRA sesión y no se fuerza, NO pisamos nada:
|
|
1257
|
+
// devolvemos el estado real para que el caller haga un takeover explícito
|
|
1258
|
+
// (force:true, auditado). Sin caller conocido (clientes que no mandan `_meta`)
|
|
1259
|
+
// el guard no aplica: comportamiento idéntico al anterior.
|
|
1260
|
+
if (foreignOwnedWork && !force) {
|
|
1261
|
+
await this.#audit(
|
|
1262
|
+
this.#state.state,
|
|
1263
|
+
'start_request_conflict',
|
|
1264
|
+
`caller=${caller} owner=${owner} requestId=${this.#state.requestId}`
|
|
1265
|
+
);
|
|
1266
|
+
return {
|
|
1267
|
+
conflict: true,
|
|
1268
|
+
state: this.#state.state,
|
|
1269
|
+
revision: this.#state.revision,
|
|
1270
|
+
requestId: this.#state.requestId,
|
|
1271
|
+
ownerSessionId: owner,
|
|
1272
|
+
callerSessionId: caller,
|
|
1273
|
+
message:
|
|
1274
|
+
'Otra sesión posee la request activa. Reintentá con force:true para hacer takeover (queda auditado).',
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1103
1278
|
// If not forcing and current state is active (not terminal), resume instead of reset
|
|
1104
|
-
if (!force &&
|
|
1279
|
+
if (!force && resumeEligible) {
|
|
1280
|
+
// Reclamamos ownership si no había dueño o si cambió (sesión única / reconexión).
|
|
1281
|
+
if (caller && this.#state.ownerSessionId !== caller) {
|
|
1282
|
+
this.#state.ownerSessionId = caller;
|
|
1283
|
+
}
|
|
1284
|
+
this.#state.ownerHeartbeat = Date.now();
|
|
1105
1285
|
await this.#audit(
|
|
1106
1286
|
this.#state.state,
|
|
1107
1287
|
'start_request',
|
|
1108
|
-
`resumed from ${this.#state.state}, requestId=${this.#state.requestId}`
|
|
1288
|
+
`resumed from ${this.#state.state}, requestId=${this.#state.requestId}${caller ? `, session=${caller}` : ''}`
|
|
1109
1289
|
);
|
|
1110
1290
|
return {
|
|
1111
1291
|
state: this.#state.state,
|
|
1112
1292
|
revision: this.#state.revision,
|
|
1113
1293
|
requestId: this.#state.requestId,
|
|
1294
|
+
ownerSessionId: this.#state.ownerSessionId,
|
|
1114
1295
|
continued: true,
|
|
1115
1296
|
};
|
|
1116
1297
|
}
|
|
1117
1298
|
|
|
1299
|
+
// Takeover EXPLÍCITO (force sobre una request viva de otra sesión) → auditado
|
|
1300
|
+
if (force && foreignOwnedWork) {
|
|
1301
|
+
await this.#audit(
|
|
1302
|
+
this.#state.state,
|
|
1303
|
+
'session_takeover',
|
|
1304
|
+
`from=${owner} to=${caller} requestId=${this.#state.requestId}`
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1118
1308
|
// Force reset or terminal state: create new session
|
|
1119
1309
|
await this.#transition('INTERPRETATION_PENDING', {
|
|
1120
1310
|
requestId: requestId || 'req-' + Date.now(),
|
|
1121
1311
|
changeId: changeId || null,
|
|
1312
|
+
ownerSessionId: caller || null,
|
|
1313
|
+
ownerHeartbeat: Date.now(),
|
|
1122
1314
|
routeDecisionId: null,
|
|
1123
1315
|
routeChoice: null,
|
|
1124
1316
|
executionDecisionId: null,
|
|
@@ -1140,6 +1332,7 @@ class OstackyController {
|
|
|
1140
1332
|
state: this.#state.state,
|
|
1141
1333
|
revision: this.#state.revision,
|
|
1142
1334
|
requestId: this.#state.requestId,
|
|
1335
|
+
ownerSessionId: this.#state.ownerSessionId,
|
|
1143
1336
|
continued: false,
|
|
1144
1337
|
};
|
|
1145
1338
|
}
|
|
@@ -1563,6 +1756,14 @@ class OstackyController {
|
|
|
1563
1756
|
expectedTasks = [...tasksMdIds];
|
|
1564
1757
|
expectedTaskCount = tasksMdIds.length;
|
|
1565
1758
|
}
|
|
1759
|
+
// task-verification-integrity D1: las verificaciones viajan con el MISMO parse de
|
|
1760
|
+
// tasks.md que ya usa expectedTasks. Antes sólo las cargaba set_expected_tasks, una
|
|
1761
|
+
// tool que el prompt nunca menciona -> `— verificar:` quedaba muerto en la práctica.
|
|
1762
|
+
let taskVerifs = null;
|
|
1763
|
+
try {
|
|
1764
|
+
const parsedVerifs = parseTaskVerifications(this.#state.changeId, this.#statePath);
|
|
1765
|
+
taskVerifs = Object.keys(parsedVerifs).length ? parsedVerifs : null;
|
|
1766
|
+
} catch {}
|
|
1566
1767
|
// 8.1/8.2: lastProposal for execution — reasoning con plan
|
|
1567
1768
|
let execShown = false;
|
|
1568
1769
|
let execFiles = [];
|
|
@@ -1602,6 +1803,7 @@ class OstackyController {
|
|
|
1602
1803
|
snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
|
|
1603
1804
|
expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
|
|
1604
1805
|
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
1806
|
+
taskVerifications: taskVerifs,
|
|
1605
1807
|
lastProposal: execLastProposal,
|
|
1606
1808
|
});
|
|
1607
1809
|
await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
@@ -1653,20 +1855,11 @@ class OstackyController {
|
|
|
1653
1855
|
}
|
|
1654
1856
|
// C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
|
|
1655
1857
|
// 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
|
|
1656
|
-
// mejora-acciones-controller F3: discovery-cache counts as valid evidence if discovery snapshot exists
|
|
1657
|
-
const isDiscoveryCacheEvidence =
|
|
1658
|
-
snapshot &&
|
|
1659
|
-
Array.isArray(snapshot.codegraphUsed) &&
|
|
1660
|
-
snapshot.codegraphUsed.includes('discovery-cache') &&
|
|
1661
|
-
this.#state.snapshots.codegraph != null &&
|
|
1662
|
-
Array.isArray(snapshot.expectedTaskIds) &&
|
|
1663
|
-
snapshot.expectedTaskIds.length > 0;
|
|
1664
1858
|
const hasEvidence =
|
|
1665
1859
|
(snapshot &&
|
|
1666
1860
|
Array.isArray(snapshot.codegraphUsed) &&
|
|
1667
1861
|
snapshot.codegraphUsed.length > 0 &&
|
|
1668
|
-
snapshot.recommendation != null)
|
|
1669
|
-
isDiscoveryCacheEvidence;
|
|
1862
|
+
snapshot.recommendation != null);
|
|
1670
1863
|
if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
|
|
1671
1864
|
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
1672
1865
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
@@ -1770,6 +1963,25 @@ class OstackyController {
|
|
|
1770
1963
|
return this.#makeError(`No decision to refresh from state ${s}`, 'refresh_decision');
|
|
1771
1964
|
}
|
|
1772
1965
|
|
|
1966
|
+
/**
|
|
1967
|
+
* task-verification-integrity D3: si el estado no tiene expectedTasks ni expectedTaskCount,
|
|
1968
|
+
* leer la fuente (tasks.md) en el momento del gate. El gate no puede quedar vacío sólo
|
|
1969
|
+
* porque record_execution_analysis nunca corrió o el cache se perdió.
|
|
1970
|
+
* Devuelve null cuando no hay tasks.md (trabajo no-spec: sin gate) o cuando manda el count.
|
|
1971
|
+
*/
|
|
1972
|
+
#deriveExpectedTasks() {
|
|
1973
|
+
if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
|
|
1974
|
+
return this.#state.expectedTasks;
|
|
1975
|
+
}
|
|
1976
|
+
if (typeof this.#state.expectedTaskCount === 'number') return null;
|
|
1977
|
+
try {
|
|
1978
|
+
const ids = parseTasksMd(this.#state.changeId, this.#statePath);
|
|
1979
|
+
return ids.length > 0 ? ids : null;
|
|
1980
|
+
} catch {
|
|
1981
|
+
return null;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1773
1985
|
async implementationComplete({ force } = {}) {
|
|
1774
1986
|
this.#load();
|
|
1775
1987
|
const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
|
|
@@ -1779,9 +1991,11 @@ class OstackyController {
|
|
|
1779
1991
|
'implementation_complete'
|
|
1780
1992
|
);
|
|
1781
1993
|
// C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
|
|
1994
|
+
// D3: un cache vacío no vacía el gate — se deriva de tasks.md.
|
|
1995
|
+
const derivedExpected = this.#deriveExpectedTasks();
|
|
1782
1996
|
let pending = [];
|
|
1783
|
-
if (Array.isArray(
|
|
1784
|
-
pending =
|
|
1997
|
+
if (Array.isArray(derivedExpected) && derivedExpected.length > 0) {
|
|
1998
|
+
pending = derivedExpected.filter(
|
|
1785
1999
|
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1786
2000
|
);
|
|
1787
2001
|
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
@@ -1794,7 +2008,14 @@ class OstackyController {
|
|
|
1794
2008
|
const seenFp2 = new Set();
|
|
1795
2009
|
try {
|
|
1796
2010
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1797
|
-
if (info.status !== 'COMPLETED' || !info.filePath
|
|
2011
|
+
if (info.status !== 'COMPLETED' || !info.filePath) continue;
|
|
2012
|
+
// task-verification-integrity D2: COMPLETED con filePath y sin hash es un fantasma
|
|
2013
|
+
// (complete_task lo aceptaba); antes se salteaba y quedaba invisible al cierre.
|
|
2014
|
+
if (!info.fileHash) {
|
|
2015
|
+
staleFiles.push(`${taskId}:${info.filePath} (missing hash)`);
|
|
2016
|
+
seenFp2.add(info.filePath);
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
1798
2019
|
const current = fingerprintFor(info.filePath, this.#statePath);
|
|
1799
2020
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1800
2021
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
@@ -1866,10 +2087,27 @@ class OstackyController {
|
|
|
1866
2087
|
};
|
|
1867
2088
|
}
|
|
1868
2089
|
if (hasBlocking && force) {
|
|
1869
|
-
//
|
|
1870
|
-
|
|
1871
|
-
|
|
2090
|
+
// task-verification-integrity D4: la evidencia es el TEXTO DEL USUARIO, no el audit.
|
|
2091
|
+
// El audit lo redacta el agente: aceptar /forzar|confirmo|force/ ahí era self-attestation
|
|
2092
|
+
// (cualquier tool call con esa palabra abría el gate). El plugin registra lastUserPrompts
|
|
2093
|
+
// desde ctx.session.hook("prompt"), antes de inyectar hints.
|
|
2094
|
+
// Durden: cada prompt lleva ts y cada rechazo setea lastForceAttemptTs — sólo
|
|
2095
|
+
// cuenta una confirmación POSTERIOR al último intento ("¿qué hace force:true?"
|
|
2096
|
+
// previo al attempt ya no habilita). Strings sin ts = estado legacy: válidos
|
|
2097
|
+
// sólo si nunca hubo intento registrado.
|
|
2098
|
+
const userPrompts = Array.isArray(this.#state.lastUserPrompts) ? this.#state.lastUserPrompts : [];
|
|
2099
|
+
const lastAttempt = Number(this.#state.lastForceAttemptTs) || 0;
|
|
2100
|
+
const isUserPhrase = (p) => {
|
|
2101
|
+
const text = typeof p === 'string' ? p : typeof p?.text === 'string' ? p.text : null;
|
|
2102
|
+
if (!text || !/forzar|confirmo|force/i.test(text)) return false;
|
|
2103
|
+
const ts = typeof p?.ts === 'number' ? p.ts : null;
|
|
2104
|
+
if (ts === null) return lastAttempt === 0;
|
|
2105
|
+
return ts > lastAttempt;
|
|
2106
|
+
};
|
|
2107
|
+
const hasHuman = userPrompts.some(isUserPhrase);
|
|
1872
2108
|
if (!hasHuman) {
|
|
2109
|
+
this.#state.lastForceAttemptTs = Date.now();
|
|
2110
|
+
await this.#persist();
|
|
1873
2111
|
const pendingMsg =
|
|
1874
2112
|
pending.length > 0 ? `Te faltan ${pending.length} task(s): ${pending.join(', ')}.` : '';
|
|
1875
2113
|
const staleMsg = staleFiles.length > 0 ? `Archivos stale: ${staleFiles.join(', ')}.` : '';
|
|
@@ -1883,7 +2121,7 @@ class OstackyController {
|
|
|
1883
2121
|
verificationFailed: verificationFailed.length ? verificationFailed : undefined,
|
|
1884
2122
|
current_state: this.#state.state,
|
|
1885
2123
|
attempted_transition: 'implementation_complete',
|
|
1886
|
-
suggestion: `${detail} Para forzar el cierre,
|
|
2124
|
+
suggestion: `${detail} Para forzar el cierre, el USUARIO debe escribir literal "confirmo forzar" en un mensaje NUEVO suyo (el plugin lo registra con timestamp; una frase previa a este aviso no cuenta, y el audit que redacta el agente tampoco) y luego reintentá con {force:true}. Si no querés forzar y es solo stale por refino, llamá refresh_fingerprint({filePath}) y reintentá sin force.`,
|
|
1887
2125
|
};
|
|
1888
2126
|
}
|
|
1889
2127
|
const all = [...pending, ...staleFiles, ...verificationFailed].join(',');
|
|
@@ -2051,9 +2289,11 @@ class OstackyController {
|
|
|
2051
2289
|
|
|
2052
2290
|
async verifyIntegrity() {
|
|
2053
2291
|
this.#load();
|
|
2292
|
+
// D3: un cache vacío no vacía el gate — se deriva de tasks.md.
|
|
2293
|
+
const derivedExpected = this.#deriveExpectedTasks();
|
|
2054
2294
|
let pending = [];
|
|
2055
|
-
if (Array.isArray(
|
|
2056
|
-
pending =
|
|
2295
|
+
if (Array.isArray(derivedExpected) && derivedExpected.length > 0) {
|
|
2296
|
+
pending = derivedExpected.filter(
|
|
2057
2297
|
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
2058
2298
|
);
|
|
2059
2299
|
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
@@ -2066,7 +2306,13 @@ class OstackyController {
|
|
|
2066
2306
|
const seenFp = new Set();
|
|
2067
2307
|
try {
|
|
2068
2308
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
2069
|
-
if (info.status !== 'COMPLETED' || !info.filePath
|
|
2309
|
+
if (info.status !== 'COMPLETED' || !info.filePath) continue;
|
|
2310
|
+
// task-verification-integrity D2: COMPLETED con filePath y sin hash es un fantasma
|
|
2311
|
+
if (!info.fileHash) {
|
|
2312
|
+
staleFiles.push(`${taskId}:${info.filePath} (missing hash)`);
|
|
2313
|
+
seenFp.add(info.filePath);
|
|
2314
|
+
continue;
|
|
2315
|
+
}
|
|
2070
2316
|
const current = fingerprintFor(info.filePath, this.#statePath);
|
|
2071
2317
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
2072
2318
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
@@ -2094,7 +2340,14 @@ class OstackyController {
|
|
|
2094
2340
|
const parts = rest.split('contiene');
|
|
2095
2341
|
needle = (parts[1] || parts[0] || '').trim() || rest;
|
|
2096
2342
|
} else if (needle.startsWith('test:')) {
|
|
2097
|
-
// test:
|
|
2343
|
+
// test-verification-execution: cierre re-verifica evidencia; antes se salteaba.
|
|
2344
|
+
const cmd = needle.slice('test:'.length).trim();
|
|
2345
|
+
const ev = findTestEvidence({
|
|
2346
|
+
runs: this.#state.shellRuns,
|
|
2347
|
+
cmd,
|
|
2348
|
+
lastMutationTs: this.#state.lastMutationTs,
|
|
2349
|
+
});
|
|
2350
|
+
if (!ev.ok) verificationFailed.push(`${tid}: ${verif} — ${ev.reason}`);
|
|
2098
2351
|
continue;
|
|
2099
2352
|
}
|
|
2100
2353
|
if (!needle) continue;
|
|
@@ -2189,6 +2442,15 @@ class OstackyController {
|
|
|
2189
2442
|
return {
|
|
2190
2443
|
revision: this.#state.revision,
|
|
2191
2444
|
state: this.#state.state,
|
|
2445
|
+
// multi-sesión: quién posee la request y quién está preguntando AHORA.
|
|
2446
|
+
// Se calcula con el callerSessionId de ESTA llamada (no está persistido).
|
|
2447
|
+
ownership: {
|
|
2448
|
+
ownerSessionId: this.#state.ownerSessionId ?? null,
|
|
2449
|
+
callerSessionId: this.#callerSessionId,
|
|
2450
|
+
isOwner:
|
|
2451
|
+
!!this.#callerSessionId && this.#callerSessionId === (this.#state.ownerSessionId ?? null),
|
|
2452
|
+
ownerHeartbeat: this.#state.ownerHeartbeat ?? 0,
|
|
2453
|
+
},
|
|
2192
2454
|
degraded: this.#degraded || !!this.#state.degraded,
|
|
2193
2455
|
consecutiveFailures: this.#consecutiveFailures,
|
|
2194
2456
|
taskCounts: {
|
|
@@ -2366,7 +2628,9 @@ class OstackyController {
|
|
|
2366
2628
|
|
|
2367
2629
|
async getState() {
|
|
2368
2630
|
this.#load();
|
|
2369
|
-
|
|
2631
|
+
// callerSessionId es de la llamada actual (no del estado persistido):
|
|
2632
|
+
// permite verificar de un vistazo qué sesión está operando.
|
|
2633
|
+
return { ...structuredClone(this.#state), callerSessionId: this.#callerSessionId };
|
|
2370
2634
|
}
|
|
2371
2635
|
|
|
2372
2636
|
async getTasks() {
|
|
@@ -2618,6 +2882,19 @@ class OstackyController {
|
|
|
2618
2882
|
// Generico: test: tasks no requieren archivo nuevo (son verificaciones de comportamiento)
|
|
2619
2883
|
const verifEarly = this.#state.taskVerifications?.[taskId];
|
|
2620
2884
|
const isTestTaskEarly = verifEarly && verifEarly.trim().startsWith('test:');
|
|
2885
|
+
// task-verification-integrity D2: un filePath que no existe no puede quedar COMPLETED.
|
|
2886
|
+
// Antes se aceptaba con fileHash:null y verifyIntegrity lo salteaba -> fantasma invisible.
|
|
2887
|
+
// Sin excepción por tipo de task: un path declarado debe existir; una task `test:` no
|
|
2888
|
+
// necesita declarar filePath (y si lo declara, ese archivo ya se creó antes de completarla).
|
|
2889
|
+
if (filePath) {
|
|
2890
|
+
const absPath =
|
|
2891
|
+
filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
2892
|
+
? resolve(filePath)
|
|
2893
|
+
: resolve(getProjectRoot(this.#statePath), filePath);
|
|
2894
|
+
if (!existsSync(absPath)) {
|
|
2895
|
+
return { error: `filePath does not exist: ${filePath}`, taskId, filePath };
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2621
2898
|
const isNewFile = !!(
|
|
2622
2899
|
isTestTaskEarly ||
|
|
2623
2900
|
(filePath &&
|
|
@@ -2655,7 +2932,7 @@ class OstackyController {
|
|
|
2655
2932
|
`task ${taskId} verification failed: ${verifForComplete}`
|
|
2656
2933
|
);
|
|
2657
2934
|
return {
|
|
2658
|
-
error: `verification failed for ${taskId}: ${verifForComplete} not found in ${vres.fileHint || filePath || 'unknown file'}`,
|
|
2935
|
+
error: `verification failed for ${taskId}: ${verifForComplete} — ${vres.reason || `not found in ${vres.fileHint || filePath || 'unknown file'}`}`,
|
|
2659
2936
|
taskId,
|
|
2660
2937
|
verification: verifForComplete,
|
|
2661
2938
|
verificationFailed: true,
|
|
@@ -2795,14 +3072,27 @@ class OstackyController {
|
|
|
2795
3072
|
else fileHint = fileHint || info?.filePath || null;
|
|
2796
3073
|
if (!needle) needle = rest;
|
|
2797
3074
|
} else if (needle.startsWith('test:')) {
|
|
2798
|
-
// test:
|
|
2799
|
-
|
|
2800
|
-
const
|
|
2801
|
-
const
|
|
3075
|
+
// test-verification-execution: evidencia real registrada por el plugin
|
|
3076
|
+
// (exit + timestamp). Nunca "assumed ok"; el comando debe coincidir exacto.
|
|
3077
|
+
const cmd = needle.slice('test:'.length).trim();
|
|
3078
|
+
const ev = findTestEvidence({
|
|
3079
|
+
runs: this.#state.shellRuns,
|
|
3080
|
+
cmd,
|
|
3081
|
+
lastMutationTs: this.#state.lastMutationTs,
|
|
3082
|
+
});
|
|
2802
3083
|
if (!this.#state.taskVerificationResults) this.#state.taskVerificationResults = {};
|
|
2803
|
-
this.#state.taskVerificationResults[taskId] = {
|
|
3084
|
+
this.#state.taskVerificationResults[taskId] = {
|
|
3085
|
+
ok: ev.ok,
|
|
3086
|
+
verif,
|
|
3087
|
+
reason: ev.reason,
|
|
3088
|
+
// Copia plana, NO la referencia de shellRuns: el serializador seguro
|
|
3089
|
+
// marca referencias repetidas como "[Circular]" y corrompería la
|
|
3090
|
+
// evidencia persistida (bug detectado por el test de integración).
|
|
3091
|
+
run: ev.run ? { cmd: ev.run.cmd, exit: ev.run.exit, ts: ev.run.ts } : null,
|
|
3092
|
+
ts: Date.now(),
|
|
3093
|
+
};
|
|
2804
3094
|
await this.#persist();
|
|
2805
|
-
return { ok, taskId, verif,
|
|
3095
|
+
return { ok: ev.ok, taskId, verif, command: cmd, reason: ev.reason, run: ev.run };
|
|
2806
3096
|
}
|
|
2807
3097
|
// Generic: check file content contains needle
|
|
2808
3098
|
let content = null;
|
|
@@ -2924,16 +3214,55 @@ const controller = new OstackyController({ statePath });
|
|
|
2924
3214
|
* @param {number} options.maxRetries - Maximum retry attempts (default: 0)
|
|
2925
3215
|
* @param {number} options.baseTimeout - Base timeout in ms (default: 5000)
|
|
2926
3216
|
*/
|
|
3217
|
+
/**
|
|
3218
|
+
* Extrae el sessionId de OpenCode del contexto del request.
|
|
3219
|
+
* OpenCode lo manda como `_meta["ai.opencode/sessionID"]` en cada tools/call
|
|
3220
|
+
* (aplica a tools directos y a Code Mode; puede faltar → degradamos a null,
|
|
3221
|
+
* y con null NO se aplica guard de ownership para no romper clientes viejos).
|
|
3222
|
+
*/
|
|
3223
|
+
function extractCallerSessionId(ctx) {
|
|
3224
|
+
try {
|
|
3225
|
+
const meta = ctx && ctx.mcpReq && ctx.mcpReq._meta;
|
|
3226
|
+
if (meta && typeof meta === 'object') {
|
|
3227
|
+
const v = meta['ai.opencode/sessionID'];
|
|
3228
|
+
if (typeof v === 'string' && v) return v;
|
|
3229
|
+
}
|
|
3230
|
+
} catch {}
|
|
3231
|
+
// Fallback: sessionId del transporte (stdio → puede ser único por proceso,
|
|
3232
|
+
// NO por sesión de OpenCode; por eso es solo fallback).
|
|
3233
|
+
try {
|
|
3234
|
+
if (ctx && typeof ctx.sessionId === 'string' && ctx.sessionId) return ctx.sessionId;
|
|
3235
|
+
} catch {}
|
|
3236
|
+
return null;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
2927
3239
|
function safeHandler(fn, options = {}) {
|
|
2928
3240
|
const { maxRetries = 0, baseTimeout = 5000 } = options;
|
|
2929
3241
|
|
|
2930
|
-
return async (params) => {
|
|
3242
|
+
return async (params, ctx) => {
|
|
3243
|
+
// ── multi-sesión: identidad + estado fresco + lock por operación ──
|
|
3244
|
+
// 1) sessionId del caller (de `_meta`), 2) re-lectura fresca desde disco
|
|
3245
|
+
// (mata el snapshot congelado del cache `#loaded` cuando hay 2+ procesos),
|
|
3246
|
+
// 3) lock reentrante durante TODO el read-modify-write → sin lost updates.
|
|
3247
|
+
let heldLock = false;
|
|
3248
|
+
try {
|
|
3249
|
+
controller.setCallerSession(extractCallerSessionId(ctx));
|
|
3250
|
+
controller.reloadFresh();
|
|
3251
|
+
heldLock = await controller.beginWrite();
|
|
3252
|
+
} catch {}
|
|
3253
|
+
const endOp = () => {
|
|
3254
|
+
if (!heldLock) return;
|
|
3255
|
+
heldLock = false;
|
|
3256
|
+
try {
|
|
3257
|
+
controller.endWrite();
|
|
3258
|
+
} catch {}
|
|
3259
|
+
};
|
|
2931
3260
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2932
3261
|
const currentTimeout = baseTimeout * Math.pow(1.5, attempt);
|
|
2933
3262
|
const start = Date.now();
|
|
2934
3263
|
try {
|
|
2935
3264
|
const result = await Promise.race([
|
|
2936
|
-
fn(params),
|
|
3265
|
+
fn(params, ctx),
|
|
2937
3266
|
new Promise((_, reject) =>
|
|
2938
3267
|
setTimeout(() => reject(new Error(`timeout ${currentTimeout}ms`)), currentTimeout)
|
|
2939
3268
|
),
|
|
@@ -2942,8 +3271,13 @@ function safeHandler(fn, options = {}) {
|
|
|
2942
3271
|
try {
|
|
2943
3272
|
await controller._recordToolDuration(duration);
|
|
2944
3273
|
} catch {}
|
|
3274
|
+
// Flush si el handler mutó el estado sin persistir (audit buffer, ownership…)
|
|
3275
|
+
try {
|
|
3276
|
+
await controller.persistIfDirty();
|
|
3277
|
+
} catch {}
|
|
2945
3278
|
// Update heartbeat on successful completion
|
|
2946
3279
|
controller.updateHeartbeat();
|
|
3280
|
+
endOp();
|
|
2947
3281
|
return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
|
|
2948
3282
|
} catch (error) {
|
|
2949
3283
|
const isTimeout = error && error.message && error.message.includes('timeout');
|
|
@@ -2969,6 +3303,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2969
3303
|
try {
|
|
2970
3304
|
await controller._recordToolTimeout();
|
|
2971
3305
|
} catch {}
|
|
3306
|
+
endOp();
|
|
2972
3307
|
return {
|
|
2973
3308
|
content: [
|
|
2974
3309
|
{
|
|
@@ -2985,6 +3320,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2985
3320
|
error: error.message,
|
|
2986
3321
|
stack: error.stack,
|
|
2987
3322
|
});
|
|
3323
|
+
endOp();
|
|
2988
3324
|
return {
|
|
2989
3325
|
content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
|
|
2990
3326
|
isError: true,
|
|
@@ -2996,7 +3332,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2996
3332
|
|
|
2997
3333
|
const server = new McpServer({
|
|
2998
3334
|
name: 'ostacky-controller',
|
|
2999
|
-
version: '0.
|
|
3335
|
+
version: '0.10.1',
|
|
3000
3336
|
});
|
|
3001
3337
|
|
|
3002
3338
|
server.registerTool(
|
|
@@ -3598,7 +3934,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
3598
3934
|
}
|
|
3599
3935
|
|
|
3600
3936
|
async function main() {
|
|
3601
|
-
log('Starting ostacky-controller MCP v0.
|
|
3937
|
+
log('Starting ostacky-controller MCP v0.10.1...');
|
|
3602
3938
|
log('State path:', { path: statePath });
|
|
3603
3939
|
// Clean up stale tmp/lock files from previous runs
|
|
3604
3940
|
cleanupTmpFiles(statePath);
|