ostacky 0.10.0 → 0.10.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 +15 -22
- package/assets/agents/ostacky.md +10 -9
- package/assets/commands/install-stack.md +1 -1
- package/assets/docs/ostacky-reference.md +41 -6
- package/assets/mcp/ostacky-controller/index.js +183 -31
- package/assets/plugins/ostacky-controller/index.ts +362 -45
- 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 +83 -72
- package/manifest.json +32 -32
- package/package.json +2 -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
|
}
|
|
@@ -1137,6 +1169,28 @@ class OstackyController {
|
|
|
1137
1169
|
};
|
|
1138
1170
|
}
|
|
1139
1171
|
|
|
1172
|
+
/**
|
|
1173
|
+
* multisession-ownership-guard: la request activa es de UNA sesión.
|
|
1174
|
+
* Si el caller es conocido y difiere del dueño, la mutación se rechaza
|
|
1175
|
+
* (consumir/cancelar/refrescar la decisión de otro invalidaba su resumen
|
|
1176
|
+
* y forzaba a re-presentarlo). Sin identidad (clientes legacy sin `_meta`)
|
|
1177
|
+
* o sin dueño, no aplica: comportamiento previo intacto.
|
|
1178
|
+
*/
|
|
1179
|
+
async #foreignOwnerError(via) {
|
|
1180
|
+
const owner = this.#state?.ownerSessionId ?? null;
|
|
1181
|
+
const caller = this.#callerSessionId ?? null;
|
|
1182
|
+
if (!owner || !caller || owner === caller) return null;
|
|
1183
|
+
await this.#audit(
|
|
1184
|
+
'WARN',
|
|
1185
|
+
'cross_session_denied',
|
|
1186
|
+
`${via}: caller=${caller} owner=${owner} state=${this.#state?.state}`
|
|
1187
|
+
);
|
|
1188
|
+
return this.#makeError(
|
|
1189
|
+
`BLOCKED: la request activa pertenece a otra sesión (owner=${owner}). No consumas, refresques ni canceles su decisión; usá start_request({force:true}) para takeover explícito o esperá a que termine.`,
|
|
1190
|
+
via
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1140
1194
|
#suggestRecovery(state, transition) {
|
|
1141
1195
|
const suggestions = {
|
|
1142
1196
|
INTERPRETATION_PENDING: 'Call start_request or proceed_to_discovery first.',
|
|
@@ -1526,6 +1580,8 @@ class OstackyController {
|
|
|
1526
1580
|
|
|
1527
1581
|
async abandon({ reason } = {}) {
|
|
1528
1582
|
this.#load();
|
|
1583
|
+
const denied = await this.#foreignOwnerError('abandon');
|
|
1584
|
+
if (denied) return denied;
|
|
1529
1585
|
const to = this.#isAllowedTransition(this.#state.state, 'abandon');
|
|
1530
1586
|
if (!to) return this.#makeError(`Cannot abandon from state ${this.#state.state}`, 'abandon');
|
|
1531
1587
|
await this.#transition(to, { error: reason || 'Abandoned' });
|
|
@@ -1535,6 +1591,8 @@ class OstackyController {
|
|
|
1535
1591
|
|
|
1536
1592
|
async consumeRouteDecision({ decisionId, choice } = {}) {
|
|
1537
1593
|
this.#load();
|
|
1594
|
+
const denied = await this.#foreignOwnerError('consume_route_decision');
|
|
1595
|
+
if (denied) return denied;
|
|
1538
1596
|
if (choice && !['SPEC', 'DIRECT'].includes(choice)) {
|
|
1539
1597
|
return { error: `invalid choice: ${choice}`, available: ['SPEC', 'DIRECT'] };
|
|
1540
1598
|
}
|
|
@@ -1724,6 +1782,14 @@ class OstackyController {
|
|
|
1724
1782
|
expectedTasks = [...tasksMdIds];
|
|
1725
1783
|
expectedTaskCount = tasksMdIds.length;
|
|
1726
1784
|
}
|
|
1785
|
+
// task-verification-integrity D1: las verificaciones viajan con el MISMO parse de
|
|
1786
|
+
// tasks.md que ya usa expectedTasks. Antes sólo las cargaba set_expected_tasks, una
|
|
1787
|
+
// tool que el prompt nunca menciona -> `— verificar:` quedaba muerto en la práctica.
|
|
1788
|
+
let taskVerifs = null;
|
|
1789
|
+
try {
|
|
1790
|
+
const parsedVerifs = parseTaskVerifications(this.#state.changeId, this.#statePath);
|
|
1791
|
+
taskVerifs = Object.keys(parsedVerifs).length ? parsedVerifs : null;
|
|
1792
|
+
} catch {}
|
|
1727
1793
|
// 8.1/8.2: lastProposal for execution — reasoning con plan
|
|
1728
1794
|
let execShown = false;
|
|
1729
1795
|
let execFiles = [];
|
|
@@ -1763,6 +1829,7 @@ class OstackyController {
|
|
|
1763
1829
|
snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
|
|
1764
1830
|
expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
|
|
1765
1831
|
expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
|
|
1832
|
+
taskVerifications: taskVerifs,
|
|
1766
1833
|
lastProposal: execLastProposal,
|
|
1767
1834
|
});
|
|
1768
1835
|
await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
|
|
@@ -1814,20 +1881,11 @@ class OstackyController {
|
|
|
1814
1881
|
}
|
|
1815
1882
|
// C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
|
|
1816
1883
|
// 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
|
|
1817
|
-
// mejora-acciones-controller F3: discovery-cache counts as valid evidence if discovery snapshot exists
|
|
1818
|
-
const isDiscoveryCacheEvidence =
|
|
1819
|
-
snapshot &&
|
|
1820
|
-
Array.isArray(snapshot.codegraphUsed) &&
|
|
1821
|
-
snapshot.codegraphUsed.includes('discovery-cache') &&
|
|
1822
|
-
this.#state.snapshots.codegraph != null &&
|
|
1823
|
-
Array.isArray(snapshot.expectedTaskIds) &&
|
|
1824
|
-
snapshot.expectedTaskIds.length > 0;
|
|
1825
1884
|
const hasEvidence =
|
|
1826
1885
|
(snapshot &&
|
|
1827
1886
|
Array.isArray(snapshot.codegraphUsed) &&
|
|
1828
1887
|
snapshot.codegraphUsed.length > 0 &&
|
|
1829
|
-
snapshot.recommendation != null)
|
|
1830
|
-
isDiscoveryCacheEvidence;
|
|
1888
|
+
snapshot.recommendation != null);
|
|
1831
1889
|
if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
|
|
1832
1890
|
this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
|
|
1833
1891
|
const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
|
|
@@ -1874,6 +1932,8 @@ class OstackyController {
|
|
|
1874
1932
|
|
|
1875
1933
|
async consumeExecutionDecision({ decisionId, mode } = {}) {
|
|
1876
1934
|
this.#load();
|
|
1935
|
+
const denied = await this.#foreignOwnerError('consume_execution_decision');
|
|
1936
|
+
if (denied) return denied;
|
|
1877
1937
|
if (mode && !['INLINE', 'SUBAGENT_DRIVEN'].includes(mode)) {
|
|
1878
1938
|
return { error: `invalid mode: ${mode}`, available: ['INLINE', 'SUBAGENT_DRIVEN'] };
|
|
1879
1939
|
}
|
|
@@ -1912,6 +1972,8 @@ class OstackyController {
|
|
|
1912
1972
|
*/
|
|
1913
1973
|
async refreshDecision() {
|
|
1914
1974
|
this.#load();
|
|
1975
|
+
const denied = await this.#foreignOwnerError('refresh_decision');
|
|
1976
|
+
if (denied) return denied;
|
|
1915
1977
|
const s = this.#state.state;
|
|
1916
1978
|
const prevFails = this.#state.consumeFailedCount || 0;
|
|
1917
1979
|
if (s === 'ROUTE_DECISION_PENDING') {
|
|
@@ -1931,6 +1993,25 @@ class OstackyController {
|
|
|
1931
1993
|
return this.#makeError(`No decision to refresh from state ${s}`, 'refresh_decision');
|
|
1932
1994
|
}
|
|
1933
1995
|
|
|
1996
|
+
/**
|
|
1997
|
+
* task-verification-integrity D3: si el estado no tiene expectedTasks ni expectedTaskCount,
|
|
1998
|
+
* leer la fuente (tasks.md) en el momento del gate. El gate no puede quedar vacío sólo
|
|
1999
|
+
* porque record_execution_analysis nunca corrió o el cache se perdió.
|
|
2000
|
+
* Devuelve null cuando no hay tasks.md (trabajo no-spec: sin gate) o cuando manda el count.
|
|
2001
|
+
*/
|
|
2002
|
+
#deriveExpectedTasks() {
|
|
2003
|
+
if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
|
|
2004
|
+
return this.#state.expectedTasks;
|
|
2005
|
+
}
|
|
2006
|
+
if (typeof this.#state.expectedTaskCount === 'number') return null;
|
|
2007
|
+
try {
|
|
2008
|
+
const ids = parseTasksMd(this.#state.changeId, this.#statePath);
|
|
2009
|
+
return ids.length > 0 ? ids : null;
|
|
2010
|
+
} catch {
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1934
2015
|
async implementationComplete({ force } = {}) {
|
|
1935
2016
|
this.#load();
|
|
1936
2017
|
const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
|
|
@@ -1940,9 +2021,11 @@ class OstackyController {
|
|
|
1940
2021
|
'implementation_complete'
|
|
1941
2022
|
);
|
|
1942
2023
|
// C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
|
|
2024
|
+
// D3: un cache vacío no vacía el gate — se deriva de tasks.md.
|
|
2025
|
+
const derivedExpected = this.#deriveExpectedTasks();
|
|
1943
2026
|
let pending = [];
|
|
1944
|
-
if (Array.isArray(
|
|
1945
|
-
pending =
|
|
2027
|
+
if (Array.isArray(derivedExpected) && derivedExpected.length > 0) {
|
|
2028
|
+
pending = derivedExpected.filter(
|
|
1946
2029
|
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
1947
2030
|
);
|
|
1948
2031
|
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
@@ -1955,7 +2038,14 @@ class OstackyController {
|
|
|
1955
2038
|
const seenFp2 = new Set();
|
|
1956
2039
|
try {
|
|
1957
2040
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
1958
|
-
if (info.status !== 'COMPLETED' || !info.filePath
|
|
2041
|
+
if (info.status !== 'COMPLETED' || !info.filePath) continue;
|
|
2042
|
+
// task-verification-integrity D2: COMPLETED con filePath y sin hash es un fantasma
|
|
2043
|
+
// (complete_task lo aceptaba); antes se salteaba y quedaba invisible al cierre.
|
|
2044
|
+
if (!info.fileHash) {
|
|
2045
|
+
staleFiles.push(`${taskId}:${info.filePath} (missing hash)`);
|
|
2046
|
+
seenFp2.add(info.filePath);
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
1959
2049
|
const current = fingerprintFor(info.filePath, this.#statePath);
|
|
1960
2050
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
1961
2051
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
@@ -2027,10 +2117,27 @@ class OstackyController {
|
|
|
2027
2117
|
};
|
|
2028
2118
|
}
|
|
2029
2119
|
if (hasBlocking && force) {
|
|
2030
|
-
//
|
|
2031
|
-
|
|
2032
|
-
|
|
2120
|
+
// task-verification-integrity D4: la evidencia es el TEXTO DEL USUARIO, no el audit.
|
|
2121
|
+
// El audit lo redacta el agente: aceptar /forzar|confirmo|force/ ahí era self-attestation
|
|
2122
|
+
// (cualquier tool call con esa palabra abría el gate). El plugin registra lastUserPrompts
|
|
2123
|
+
// desde ctx.session.hook("prompt"), antes de inyectar hints.
|
|
2124
|
+
// Durden: cada prompt lleva ts y cada rechazo setea lastForceAttemptTs — sólo
|
|
2125
|
+
// cuenta una confirmación POSTERIOR al último intento ("¿qué hace force:true?"
|
|
2126
|
+
// previo al attempt ya no habilita). Strings sin ts = estado legacy: válidos
|
|
2127
|
+
// sólo si nunca hubo intento registrado.
|
|
2128
|
+
const userPrompts = Array.isArray(this.#state.lastUserPrompts) ? this.#state.lastUserPrompts : [];
|
|
2129
|
+
const lastAttempt = Number(this.#state.lastForceAttemptTs) || 0;
|
|
2130
|
+
const isUserPhrase = (p) => {
|
|
2131
|
+
const text = typeof p === 'string' ? p : typeof p?.text === 'string' ? p.text : null;
|
|
2132
|
+
if (!text || !/forzar|confirmo|force/i.test(text)) return false;
|
|
2133
|
+
const ts = typeof p?.ts === 'number' ? p.ts : null;
|
|
2134
|
+
if (ts === null) return lastAttempt === 0;
|
|
2135
|
+
return ts > lastAttempt;
|
|
2136
|
+
};
|
|
2137
|
+
const hasHuman = userPrompts.some(isUserPhrase);
|
|
2033
2138
|
if (!hasHuman) {
|
|
2139
|
+
this.#state.lastForceAttemptTs = Date.now();
|
|
2140
|
+
await this.#persist();
|
|
2034
2141
|
const pendingMsg =
|
|
2035
2142
|
pending.length > 0 ? `Te faltan ${pending.length} task(s): ${pending.join(', ')}.` : '';
|
|
2036
2143
|
const staleMsg = staleFiles.length > 0 ? `Archivos stale: ${staleFiles.join(', ')}.` : '';
|
|
@@ -2044,7 +2151,7 @@ class OstackyController {
|
|
|
2044
2151
|
verificationFailed: verificationFailed.length ? verificationFailed : undefined,
|
|
2045
2152
|
current_state: this.#state.state,
|
|
2046
2153
|
attempted_transition: 'implementation_complete',
|
|
2047
|
-
suggestion: `${detail} Para forzar el cierre,
|
|
2154
|
+
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.`,
|
|
2048
2155
|
};
|
|
2049
2156
|
}
|
|
2050
2157
|
const all = [...pending, ...staleFiles, ...verificationFailed].join(',');
|
|
@@ -2073,6 +2180,8 @@ class OstackyController {
|
|
|
2073
2180
|
|
|
2074
2181
|
async block({ reason } = {}) {
|
|
2075
2182
|
this.#load();
|
|
2183
|
+
const denied = await this.#foreignOwnerError('block');
|
|
2184
|
+
if (denied) return denied;
|
|
2076
2185
|
const from = this.#state.state;
|
|
2077
2186
|
const to = this.#isAllowedTransition(this.#state.state, 'block');
|
|
2078
2187
|
if (!to) return this.#makeError(`Cannot block from state ${this.#state.state}`, 'block');
|
|
@@ -2212,9 +2321,11 @@ class OstackyController {
|
|
|
2212
2321
|
|
|
2213
2322
|
async verifyIntegrity() {
|
|
2214
2323
|
this.#load();
|
|
2324
|
+
// D3: un cache vacío no vacía el gate — se deriva de tasks.md.
|
|
2325
|
+
const derivedExpected = this.#deriveExpectedTasks();
|
|
2215
2326
|
let pending = [];
|
|
2216
|
-
if (Array.isArray(
|
|
2217
|
-
pending =
|
|
2327
|
+
if (Array.isArray(derivedExpected) && derivedExpected.length > 0) {
|
|
2328
|
+
pending = derivedExpected.filter(
|
|
2218
2329
|
(id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
|
|
2219
2330
|
);
|
|
2220
2331
|
} else if (typeof this.#state.expectedTaskCount === 'number') {
|
|
@@ -2227,7 +2338,13 @@ class OstackyController {
|
|
|
2227
2338
|
const seenFp = new Set();
|
|
2228
2339
|
try {
|
|
2229
2340
|
for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
|
|
2230
|
-
if (info.status !== 'COMPLETED' || !info.filePath
|
|
2341
|
+
if (info.status !== 'COMPLETED' || !info.filePath) continue;
|
|
2342
|
+
// task-verification-integrity D2: COMPLETED con filePath y sin hash es un fantasma
|
|
2343
|
+
if (!info.fileHash) {
|
|
2344
|
+
staleFiles.push(`${taskId}:${info.filePath} (missing hash)`);
|
|
2345
|
+
seenFp.add(info.filePath);
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2231
2348
|
const current = fingerprintFor(info.filePath, this.#statePath);
|
|
2232
2349
|
if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
|
|
2233
2350
|
else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
|
|
@@ -2255,7 +2372,14 @@ class OstackyController {
|
|
|
2255
2372
|
const parts = rest.split('contiene');
|
|
2256
2373
|
needle = (parts[1] || parts[0] || '').trim() || rest;
|
|
2257
2374
|
} else if (needle.startsWith('test:')) {
|
|
2258
|
-
// test:
|
|
2375
|
+
// test-verification-execution: cierre re-verifica evidencia; antes se salteaba.
|
|
2376
|
+
const cmd = needle.slice('test:'.length).trim();
|
|
2377
|
+
const ev = findTestEvidence({
|
|
2378
|
+
runs: this.#state.shellRuns,
|
|
2379
|
+
cmd,
|
|
2380
|
+
lastMutationTs: this.#state.lastMutationTs,
|
|
2381
|
+
});
|
|
2382
|
+
if (!ev.ok) verificationFailed.push(`${tid}: ${verif} — ${ev.reason}`);
|
|
2259
2383
|
continue;
|
|
2260
2384
|
}
|
|
2261
2385
|
if (!needle) continue;
|
|
@@ -2763,6 +2887,8 @@ class OstackyController {
|
|
|
2763
2887
|
*/
|
|
2764
2888
|
async completeTask({ taskId, filePath, fileHash } = {}) {
|
|
2765
2889
|
this.#load();
|
|
2890
|
+
const denied = await this.#foreignOwnerError('complete_task');
|
|
2891
|
+
if (denied) return denied;
|
|
2766
2892
|
if (this.#state.state !== 'EXECUTING_INLINE' && this.#state.state !== 'EXECUTING_SUBAGENTS') {
|
|
2767
2893
|
return this.#makeError(`Cannot complete task from state ${this.#state.state}`, 'complete_task');
|
|
2768
2894
|
}
|
|
@@ -2790,6 +2916,19 @@ class OstackyController {
|
|
|
2790
2916
|
// Generico: test: tasks no requieren archivo nuevo (son verificaciones de comportamiento)
|
|
2791
2917
|
const verifEarly = this.#state.taskVerifications?.[taskId];
|
|
2792
2918
|
const isTestTaskEarly = verifEarly && verifEarly.trim().startsWith('test:');
|
|
2919
|
+
// task-verification-integrity D2: un filePath que no existe no puede quedar COMPLETED.
|
|
2920
|
+
// Antes se aceptaba con fileHash:null y verifyIntegrity lo salteaba -> fantasma invisible.
|
|
2921
|
+
// Sin excepción por tipo de task: un path declarado debe existir; una task `test:` no
|
|
2922
|
+
// necesita declarar filePath (y si lo declara, ese archivo ya se creó antes de completarla).
|
|
2923
|
+
if (filePath) {
|
|
2924
|
+
const absPath =
|
|
2925
|
+
filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
2926
|
+
? resolve(filePath)
|
|
2927
|
+
: resolve(getProjectRoot(this.#statePath), filePath);
|
|
2928
|
+
if (!existsSync(absPath)) {
|
|
2929
|
+
return { error: `filePath does not exist: ${filePath}`, taskId, filePath };
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2793
2932
|
const isNewFile = !!(
|
|
2794
2933
|
isTestTaskEarly ||
|
|
2795
2934
|
(filePath &&
|
|
@@ -2827,7 +2966,7 @@ class OstackyController {
|
|
|
2827
2966
|
`task ${taskId} verification failed: ${verifForComplete}`
|
|
2828
2967
|
);
|
|
2829
2968
|
return {
|
|
2830
|
-
error: `verification failed for ${taskId}: ${verifForComplete} not found in ${vres.fileHint || filePath || 'unknown file'}`,
|
|
2969
|
+
error: `verification failed for ${taskId}: ${verifForComplete} — ${vres.reason || `not found in ${vres.fileHint || filePath || 'unknown file'}`}`,
|
|
2831
2970
|
taskId,
|
|
2832
2971
|
verification: verifForComplete,
|
|
2833
2972
|
verificationFailed: true,
|
|
@@ -2967,14 +3106,27 @@ class OstackyController {
|
|
|
2967
3106
|
else fileHint = fileHint || info?.filePath || null;
|
|
2968
3107
|
if (!needle) needle = rest;
|
|
2969
3108
|
} else if (needle.startsWith('test:')) {
|
|
2970
|
-
// test:
|
|
2971
|
-
|
|
2972
|
-
const
|
|
2973
|
-
const
|
|
3109
|
+
// test-verification-execution: evidencia real registrada por el plugin
|
|
3110
|
+
// (exit + timestamp). Nunca "assumed ok"; el comando debe coincidir exacto.
|
|
3111
|
+
const cmd = needle.slice('test:'.length).trim();
|
|
3112
|
+
const ev = findTestEvidence({
|
|
3113
|
+
runs: this.#state.shellRuns,
|
|
3114
|
+
cmd,
|
|
3115
|
+
lastMutationTs: this.#state.lastMutationTs,
|
|
3116
|
+
});
|
|
2974
3117
|
if (!this.#state.taskVerificationResults) this.#state.taskVerificationResults = {};
|
|
2975
|
-
this.#state.taskVerificationResults[taskId] = {
|
|
3118
|
+
this.#state.taskVerificationResults[taskId] = {
|
|
3119
|
+
ok: ev.ok,
|
|
3120
|
+
verif,
|
|
3121
|
+
reason: ev.reason,
|
|
3122
|
+
// Copia plana, NO la referencia de shellRuns: el serializador seguro
|
|
3123
|
+
// marca referencias repetidas como "[Circular]" y corrompería la
|
|
3124
|
+
// evidencia persistida (bug detectado por el test de integración).
|
|
3125
|
+
run: ev.run ? { cmd: ev.run.cmd, exit: ev.run.exit, ts: ev.run.ts } : null,
|
|
3126
|
+
ts: Date.now(),
|
|
3127
|
+
};
|
|
2976
3128
|
await this.#persist();
|
|
2977
|
-
return { ok, taskId, verif,
|
|
3129
|
+
return { ok: ev.ok, taskId, verif, command: cmd, reason: ev.reason, run: ev.run };
|
|
2978
3130
|
}
|
|
2979
3131
|
// Generic: check file content contains needle
|
|
2980
3132
|
let content = null;
|
|
@@ -3214,7 +3366,7 @@ function safeHandler(fn, options = {}) {
|
|
|
3214
3366
|
|
|
3215
3367
|
const server = new McpServer({
|
|
3216
3368
|
name: 'ostacky-controller',
|
|
3217
|
-
version: '0.10.
|
|
3369
|
+
version: '0.10.2',
|
|
3218
3370
|
});
|
|
3219
3371
|
|
|
3220
3372
|
server.registerTool(
|
|
@@ -3816,7 +3968,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
3816
3968
|
}
|
|
3817
3969
|
|
|
3818
3970
|
async function main() {
|
|
3819
|
-
log('Starting ostacky-controller MCP v0.10.
|
|
3971
|
+
log('Starting ostacky-controller MCP v0.10.2...');
|
|
3820
3972
|
log('State path:', { path: statePath });
|
|
3821
3973
|
// Clean up stale tmp/lock files from previous runs
|
|
3822
3974
|
cleanupTmpFiles(statePath);
|