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.
@@ -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
- const idx = line.indexOf('verificar:');
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(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
1945
- pending = this.#state.expectedTasks.filter(
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 || !info.fileHash) continue;
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
- // 4.3: force requiere confirmación humana en últimas 5 entradas de audit
2031
- const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
2032
- const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
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, escribí literal "confirmo forzar" en tu mensaje (quedará auditado) y reintentá con {force:true}. Si no querés forzar y es solo stale por refino, llamá refresh_fingerprint({filePath}) y reintentá sin force.`,
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(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
2217
- pending = this.#state.expectedTasks.filter(
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 || !info.fileHash) continue;
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: asumimos ok si ya está completa (test se corrió en agente)
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: no podemos ejecutar desde controller, consideramos ok si hay filePath (ya editado) o fileHash
2971
- needle = needle.slice('test:'.length).trim();
2972
- const hasFile = !!(fileHint || info?.filePath || info?.fileHash);
2973
- const ok = hasFile;
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] = { ok, verif, ts: Date.now() };
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, reason: hasFile ? 'test verification assumed ok (has file)' : 'no file' };
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.0',
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.0...');
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);