ostacky 0.8.8 → 0.9.0

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.
@@ -176,6 +176,53 @@ export function parseTasksMd(changeId, statePath) {
176
176
  }
177
177
  }
178
178
 
179
+ export function parseTaskVerifications(changeId, statePath) {
180
+ if (!changeId || typeof changeId !== 'string') return {};
181
+ try {
182
+ const projectRoot = getProjectRoot(statePath);
183
+ const tasksPath = join(projectRoot, 'openspec', 'changes', changeId, 'tasks.md');
184
+ let content = null;
185
+ if (existsSync(tasksPath)) {
186
+ content = readFileSync(tasksPath, 'utf-8');
187
+ } else {
188
+ try {
189
+ const archiveDir = join(projectRoot, 'openspec', 'changes', 'archive');
190
+ if (existsSync(archiveDir)) {
191
+ for (const entry of readdirSync(archiveDir)) {
192
+ if (entry.endsWith(`-${changeId}`) || entry === changeId) {
193
+ const alt = join(archiveDir, entry, 'tasks.md');
194
+ if (existsSync(alt)) {
195
+ content = readFileSync(alt, 'utf-8');
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ } catch {}
202
+ if (content === null) return {};
203
+ }
204
+ const verifs = {};
205
+ const lines = content.split('\n');
206
+ for (const line of lines) {
207
+ const m = line.match(/^- \[[ x]\]\s+([A-Za-z0-9][A-Za-z0-9\-_.\/:]*)/);
208
+ if (!m) continue;
209
+ const id = m[1];
210
+ if (!isValidTaskId(id)) continue;
211
+ const idx = line.indexOf('verificar:');
212
+ if (idx !== -1) {
213
+ const v = line
214
+ .slice(idx + 'verificar:'.length)
215
+ .trim()
216
+ .slice(0, 200);
217
+ if (v) verifs[id] = v;
218
+ }
219
+ }
220
+ return verifs;
221
+ } catch {
222
+ return {};
223
+ }
224
+ }
225
+
179
226
  function isValidTaskId(taskId) {
180
227
  return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
181
228
  }
@@ -1662,16 +1709,62 @@ class OstackyController {
1662
1709
  else if (cur !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
1663
1710
  }
1664
1711
  } catch {}
1665
- const hasBlocking = pending.length > 0 || staleFiles.length > 0;
1712
+ // Generico: verificar tasks con verificacion definida
1713
+ let verificationFailed = [];
1714
+ try {
1715
+ const verifs = this.#state.taskVerifications || {};
1716
+ for (const [tid, verif] of Object.entries(verifs)) {
1717
+ const task = this.#state.tasks[tid];
1718
+ if (!task || task.status !== 'COMPLETED') continue;
1719
+ let needle = String(verif).trim();
1720
+ if (needle.startsWith('codegraph:')) needle = needle.slice('codegraph:'.length).trim();
1721
+ else if (needle.startsWith('file:')) {
1722
+ const rest = needle.slice('file:'.length).trim();
1723
+ const parts = rest.split('contiene');
1724
+ needle = (parts[1] || parts[0] || '').trim() || rest;
1725
+ } else if (needle.startsWith('test:')) continue;
1726
+ if (!needle) continue;
1727
+ let content = null;
1728
+ const fp = task.filePath;
1729
+ if (fp) {
1730
+ try {
1731
+ const abs =
1732
+ fp.startsWith('/') || /^[A-Za-z]:/.test(fp)
1733
+ ? resolve(fp)
1734
+ : resolve(getProjectRoot(this.#statePath), fp);
1735
+ if (existsSync(abs)) content = readFileSync(abs, 'utf-8');
1736
+ } catch {}
1737
+ }
1738
+ const found = content ? content.includes(needle) : false;
1739
+ if (!found) verificationFailed.push(`${tid}: ${verif} no encontrado en ${fp || 'archivo'}`);
1740
+ }
1741
+ } catch {}
1742
+ const hasBlocking = pending.length > 0 || staleFiles.length > 0 || verificationFailed.length > 0;
1666
1743
  if (hasBlocking && !force) {
1744
+ const pendingMsg =
1745
+ pending.length > 0 ? `Te faltan ${pending.length} task(s) por completar: ${pending.join(', ')}.` : '';
1746
+ const staleMsg =
1747
+ staleFiles.length > 0
1748
+ ? `Hay ${staleFiles.length} archivo(s) con fingerprint stale o faltante: ${staleFiles.join(', ')}.`
1749
+ : '';
1750
+ const verifMsg =
1751
+ verificationFailed.length > 0
1752
+ ? `Hay ${verificationFailed.length} verificación(es) fallida(s): ${verificationFailed.join(', ')}.`
1753
+ : '';
1754
+ const detail = [pendingMsg, staleMsg, verifMsg].filter(Boolean).join(' ');
1755
+ const isVerif = verificationFailed.length > 0;
1667
1756
  return {
1668
- error: staleFiles.length ? 'stale fingerprints' : 'tasks incomplete',
1757
+ error: isVerif
1758
+ ? `verification failed - No puedo cerrar: ${verificationFailed.length} verificación(es) fallida(s)`
1759
+ : staleFiles.length > 0
1760
+ ? `stale fingerprints - No puedo cerrar: ${pending.length} task(s) pendiente(s) y ${staleFiles.length} archivo(s) stale`
1761
+ : `tasks incomplete - No puedo cerrar: te faltan ${pending.length} task(s) por completar`,
1669
1762
  pending,
1670
1763
  staleFiles: staleFiles.length ? staleFiles : undefined,
1764
+ verificationFailed: verificationFailed.length ? verificationFailed : undefined,
1671
1765
  current_state: this.#state.state,
1672
1766
  attempted_transition: 'implementation_complete',
1673
- suggestion:
1674
- 'Complete pending tasks via complete_task or retry with {force:true} after explicit user confirmation',
1767
+ suggestion: `${detail} Completá cada task con Read fresco → validate_edit → edit → complete_task (con verificación) y luego reintentá implementation_complete sin force. Solo si querés descartar trabajo pendiente, escribí literal "confirmo forzar" en tu mensaje y reintentá con {force:true}. No propongo cancelar; propongo completar.`,
1675
1768
  };
1676
1769
  }
1677
1770
  if (hasBlocking && force) {
@@ -1679,16 +1772,23 @@ class OstackyController {
1679
1772
  const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
1680
1773
  const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
1681
1774
  if (!hasHuman) {
1775
+ const pendingMsg =
1776
+ pending.length > 0 ? `Te faltan ${pending.length} task(s): ${pending.join(', ')}.` : '';
1777
+ const staleMsg = staleFiles.length > 0 ? `Archivos stale: ${staleFiles.join(', ')}.` : '';
1778
+ const verifMsg =
1779
+ verificationFailed.length > 0 ? `Verificaciones fallidas: ${verificationFailed.join(', ')}.` : '';
1780
+ const detail = [pendingMsg, staleMsg, verifMsg].filter(Boolean).join(' ');
1682
1781
  return {
1683
- error: 'force requires human confirmation',
1782
+ error: 'force requires human confirmation - force requiere confirmación humana explícita',
1684
1783
  pending,
1685
1784
  staleFiles: staleFiles.length ? staleFiles : undefined,
1785
+ verificationFailed: verificationFailed.length ? verificationFailed : undefined,
1686
1786
  current_state: this.#state.state,
1687
1787
  attempted_transition: 'implementation_complete',
1688
- suggestion: 'User must write forzar/confirmo/force in a prior block/replan/set_handoff reasoning',
1788
+ 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, completá los pendientes y reintentá sin force.`,
1689
1789
  };
1690
1790
  }
1691
- const all = [...pending, ...staleFiles].join(',');
1791
+ const all = [...pending, ...staleFiles, ...verificationFailed].join(',');
1692
1792
  await this.#audit('FORCE', 'implementation_complete', `forced with pending: ${all}`);
1693
1793
  }
1694
1794
  await this.#transition(to);
@@ -1806,6 +1906,12 @@ class OstackyController {
1806
1906
  }
1807
1907
  this.#state.expectedTasks = [...tasksMdIds];
1808
1908
  this.#state.expectedTaskCount = tasksMdIds.length;
1909
+ // --- Generico: extraer verificaciones de tasks.md (codegraph:/file:/test:)
1910
+ try {
1911
+ const verifs = parseTaskVerifications(this.#state.changeId, this.#statePath);
1912
+ this.#state.taskVerifications = Object.keys(verifs).length ? verifs : null;
1913
+ if (!this.#state.taskVerificationResults) this.#state.taskVerificationResults = {};
1914
+ } catch {}
1809
1915
  await this.#persist();
1810
1916
  await this.#audit(
1811
1917
  'EXECUTING',
@@ -1816,15 +1922,18 @@ class OstackyController {
1816
1922
  ok: true,
1817
1923
  expectedTasks: this.#state.expectedTasks,
1818
1924
  expectedTaskCount: this.#state.expectedTaskCount,
1925
+ taskVerifications: this.#state.taskVerifications,
1819
1926
  source: 'tasks.md',
1820
1927
  };
1821
1928
  }
1822
1929
  if (Array.isArray(taskIds) && taskIds.length > 0) {
1823
1930
  this.#state.expectedTasks = [...taskIds];
1824
1931
  this.#state.expectedTaskCount = taskIds.length;
1932
+ this.#state.taskVerifications = null;
1825
1933
  } else if (typeof taskCount === 'number' && taskCount > 0) {
1826
1934
  this.#state.expectedTasks = null;
1827
1935
  this.#state.expectedTaskCount = taskCount;
1936
+ this.#state.taskVerifications = null;
1828
1937
  } else {
1829
1938
  return { error: 'taskIds (array) or taskCount (number) required' };
1830
1939
  }
@@ -1834,7 +1943,12 @@ class OstackyController {
1834
1943
  'set_expected_tasks',
1835
1944
  `expected=${this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length}`
1836
1945
  );
1837
- return { ok: true, expectedTasks: this.#state.expectedTasks, expectedTaskCount: this.#state.expectedTaskCount };
1946
+ return {
1947
+ ok: true,
1948
+ expectedTasks: this.#state.expectedTasks,
1949
+ expectedTaskCount: this.#state.expectedTaskCount,
1950
+ taskVerifications: this.#state.taskVerifications,
1951
+ };
1838
1952
  }
1839
1953
 
1840
1954
  async verifyIntegrity() {
@@ -1867,11 +1981,46 @@ class OstackyController {
1867
1981
  else if (current !== stored) staleFiles.push(`${fp} (stale fingerprint)`);
1868
1982
  }
1869
1983
  } catch {}
1870
- const ok = pending.length === 0 && staleFiles.length === 0;
1984
+ // Generico: verificar tasks con verificacion definida
1985
+ let verificationFailed = [];
1986
+ try {
1987
+ const verifs = this.#state.taskVerifications || {};
1988
+ for (const [tid, verif] of Object.entries(verifs)) {
1989
+ const task = this.#state.tasks[tid];
1990
+ if (!task || task.status !== 'COMPLETED') continue;
1991
+ // Re-verificar via file content
1992
+ let needle = String(verif).trim();
1993
+ if (needle.startsWith('codegraph:')) needle = needle.slice('codegraph:'.length).trim();
1994
+ else if (needle.startsWith('file:')) {
1995
+ const rest = needle.slice('file:'.length).trim();
1996
+ const parts = rest.split('contiene');
1997
+ needle = (parts[1] || parts[0] || '').trim() || rest;
1998
+ } else if (needle.startsWith('test:')) {
1999
+ // test: asumimos ok si ya está completa (test se corrió en agente)
2000
+ continue;
2001
+ }
2002
+ if (!needle) continue;
2003
+ let content = null;
2004
+ const fp = task.filePath;
2005
+ if (fp) {
2006
+ try {
2007
+ const abs =
2008
+ fp.startsWith('/') || /^[A-Za-z]:/.test(fp)
2009
+ ? resolve(fp)
2010
+ : resolve(getProjectRoot(this.#statePath), fp);
2011
+ if (existsSync(abs)) content = readFileSync(abs, 'utf-8');
2012
+ } catch {}
2013
+ }
2014
+ const found = content ? content.includes(needle) : false;
2015
+ if (!found) verificationFailed.push(`${tid}: ${verif} no encontrado en ${fp || 'archivo'}`);
2016
+ }
2017
+ } catch {}
2018
+ const ok = pending.length === 0 && staleFiles.length === 0 && verificationFailed.length === 0;
1871
2019
  return {
1872
2020
  ok,
1873
2021
  pending,
1874
2022
  staleFiles,
2023
+ verificationFailed: verificationFailed.length ? verificationFailed : undefined,
1875
2024
  completed: Object.keys(this.#state.tasks).filter((k) => this.#state.tasks[k].status === 'COMPLETED').length,
1876
2025
  expected: this.#state.expectedTaskCount ?? this.#state.expectedTasks?.length ?? null,
1877
2026
  state: this.#state.state,
@@ -1994,31 +2143,6 @@ class OstackyController {
1994
2143
  } catch {}
1995
2144
  }
1996
2145
 
1997
- // --- 5.4 hardening-v2: cache metrics (token efficiency) ---
1998
- async recordCacheHit({ tokensSaved = 500 } = {}) {
1999
- this.#load();
2000
- this.#state.cacheHitCount = (this.#state.cacheHitCount || 0) + 1;
2001
- const saved = typeof tokensSaved === 'number' && tokensSaved > 0 ? tokensSaved : 500;
2002
- this.#state.tokenSavingEstimate = (this.#state.tokenSavingEstimate || 0) + saved;
2003
- try {
2004
- await this.#persist();
2005
- } catch {}
2006
- return {
2007
- ok: true,
2008
- cacheHitCount: this.#state.cacheHitCount,
2009
- tokenSavingEstimate: this.#state.tokenSavingEstimate,
2010
- };
2011
- }
2012
-
2013
- async recordCacheMiss() {
2014
- this.#load();
2015
- this.#state.cacheMissCount = (this.#state.cacheMissCount || 0) + 1;
2016
- try {
2017
- await this.#persist();
2018
- } catch {}
2019
- return { ok: true, cacheMissCount: this.#state.cacheMissCount };
2020
- }
2021
-
2022
2146
  async recordUserConfirmation({ decisionId, confirmationText } = {}) {
2023
2147
  this.#load();
2024
2148
  if (!decisionId || typeof confirmationText !== 'string') {
@@ -2334,10 +2458,14 @@ class OstackyController {
2334
2458
  }
2335
2459
  }
2336
2460
  // harden-task-integrity 2.2: hard gate unificado (new files eximidos) — antes INLINE hard vs SUBAGENTS WARN
2461
+ // Generico: test: tasks no requieren archivo nuevo (son verificaciones de comportamiento)
2462
+ const verifEarly = this.#state.taskVerifications?.[taskId];
2463
+ const isTestTaskEarly = verifEarly && verifEarly.trim().startsWith('test:');
2337
2464
  const isNewFile = !!(
2338
- filePath &&
2339
- !this.#state.fileFingerprints?.[filePath] &&
2340
- !Object.values(this.#state.tasks || {}).some((t) => t.filePath === filePath)
2465
+ isTestTaskEarly ||
2466
+ (filePath &&
2467
+ !this.#state.fileFingerprints?.[filePath] &&
2468
+ !Object.values(this.#state.tasks || {}).some((t) => t.filePath === filePath))
2341
2469
  );
2342
2470
  if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
2343
2471
  if (!isNewFile) {
@@ -2357,6 +2485,26 @@ class OstackyController {
2357
2485
  this.#state.lastValidated = null;
2358
2486
  }
2359
2487
 
2488
+ // --- Generico: verificar task antes de marcar completa (evita invento)
2489
+ const verifForComplete = this.#state.taskVerifications?.[taskId];
2490
+ if (verifForComplete) {
2491
+ const vres = await this.verifyTask({ taskId, filePath });
2492
+ if (!vres.ok) {
2493
+ await this.#audit(
2494
+ 'WARN',
2495
+ 'verification_failed',
2496
+ `task ${taskId} verification failed: ${verifForComplete}`
2497
+ );
2498
+ return {
2499
+ error: `verification failed for ${taskId}: ${verifForComplete} not found in ${vres.fileHint || filePath || 'unknown file'}`,
2500
+ taskId,
2501
+ verification: verifForComplete,
2502
+ verificationFailed: true,
2503
+ vres,
2504
+ };
2505
+ }
2506
+ }
2507
+
2360
2508
  this.#state.tasks[taskId] = {
2361
2509
  status: 'COMPLETED',
2362
2510
  completedAt: new Date().toISOString(),
@@ -2413,6 +2561,67 @@ class OstackyController {
2413
2561
  };
2414
2562
  }
2415
2563
 
2564
+ async verifyTask({ taskId, filePath: filePathParam } = {}) {
2565
+ this.#load();
2566
+ if (!taskId) return { error: 'taskId required' };
2567
+ const verif = this.#state.taskVerifications?.[taskId];
2568
+ if (!verif) return { ok: true, taskId, reason: 'no verification defined' };
2569
+ try {
2570
+ const info = this.#state.tasks[taskId];
2571
+ // Try filePath from param, task, or verification prefix
2572
+ let needle = verif.trim();
2573
+ let fileHint = filePathParam || null;
2574
+ if (needle.startsWith('codegraph:')) {
2575
+ needle = needle.slice('codegraph:'.length).trim();
2576
+ fileHint = fileHint || info?.filePath || null;
2577
+ } else if (needle.startsWith('file:')) {
2578
+ const rest = needle.slice('file:'.length).trim();
2579
+ // file:path contiene string -> split
2580
+ const parts = rest.split('contiene');
2581
+ needle = (parts[1] || parts[0] || '').trim();
2582
+ const parsedPath = (parts[0] || '').trim();
2583
+ if (parsedPath) fileHint = parsedPath;
2584
+ else fileHint = fileHint || info?.filePath || null;
2585
+ if (!needle) needle = rest;
2586
+ } else if (needle.startsWith('test:')) {
2587
+ // test: no podemos ejecutar desde controller, consideramos ok si hay filePath (ya editado) o fileHash
2588
+ needle = needle.slice('test:'.length).trim();
2589
+ const hasFile = !!(fileHint || info?.filePath || info?.fileHash);
2590
+ const ok = hasFile;
2591
+ if (!this.#state.taskVerificationResults) this.#state.taskVerificationResults = {};
2592
+ this.#state.taskVerificationResults[taskId] = { ok, verif, ts: Date.now() };
2593
+ await this.#persist();
2594
+ return { ok, taskId, verif, reason: hasFile ? 'test verification assumed ok (has file)' : 'no file' };
2595
+ }
2596
+ // Generic: check file content contains needle
2597
+ let content = null;
2598
+ let checkPath = fileHint || info?.filePath || filePathParam || null;
2599
+ if (checkPath) {
2600
+ const abs =
2601
+ checkPath.startsWith('/') || /^[A-Za-z]:/.test(checkPath)
2602
+ ? resolve(checkPath)
2603
+ : resolve(getProjectRoot(this.#statePath), checkPath);
2604
+ if (existsSync(abs)) {
2605
+ try {
2606
+ content = readFileSync(abs, 'utf-8');
2607
+ } catch {}
2608
+ }
2609
+ }
2610
+ let ok = false;
2611
+ if (content && needle && content.includes(needle)) ok = true;
2612
+ else if (!checkPath && needle) {
2613
+ // No file hint, search in any task file? For generic, require filePath
2614
+ ok = false;
2615
+ }
2616
+ if (!this.#state.taskVerificationResults) this.#state.taskVerificationResults = {};
2617
+ this.#state.taskVerificationResults[taskId] = { ok, verif, needle, fileHint: checkPath, ts: Date.now() };
2618
+ await this.#persist();
2619
+ return { ok, taskId, verif, needle, fileHint: checkPath, found: ok };
2620
+ } catch (e) {
2621
+ return { ok: false, taskId, verif, error: e.message };
2622
+ }
2623
+ }
2624
+
2416
2625
  /**
2417
2626
  * Public flush — SYNCHRONOUS on purpose: SIGINT/SIGTERM handlers cannot await
2418
2627
  * (Node does not wait for async shutdown work). Drains the audit buffer into
@@ -2576,7 +2785,7 @@ function safeHandler(fn, options = {}) {
2576
2785
 
2577
2786
  const server = new McpServer({
2578
2787
  name: 'ostacky-controller',
2579
- version: '0.8.8',
2788
+ version: '0.9.0',
2580
2789
  });
2581
2790
 
2582
2791
  server.registerTool(
@@ -2819,34 +3028,6 @@ server.registerTool(
2819
3028
  )
2820
3029
  );
2821
3030
 
2822
- server.registerTool(
2823
- 'record_cache_hit',
2824
- {
2825
- description: 'Deprecated: cache hit',
2826
- inputSchema: z.object({
2827
- tokensSaved: z.number().optional().describe('Estimated tokens saved (default 500)'),
2828
- }),
2829
- },
2830
- safeHandler(async ({ tokensSaved }) => {
2831
- log('tool:record_cache_hit', { tokensSaved, deprecated: true });
2832
- const r = await controller.recordCacheHit({ tokensSaved });
2833
- return { ...r, deprecated: true };
2834
- })
2835
- );
2836
-
2837
- server.registerTool(
2838
- 'record_cache_miss',
2839
- {
2840
- description: 'Deprecated: cache miss',
2841
- inputSchema: z.object({}),
2842
- },
2843
- safeHandler(async () => {
2844
- log('tool:record_cache_miss', { deprecated: true });
2845
- const r = await controller.recordCacheMiss();
2846
- return { ...r, deprecated: true };
2847
- })
2848
- );
2849
-
2850
3031
  server.registerTool(
2851
3032
  'record_user_confirmation',
2852
3033
  {
@@ -3176,7 +3357,7 @@ function setupGracefulShutdown(ctrl) {
3176
3357
  }
3177
3358
 
3178
3359
  async function main() {
3179
- log('Starting ostacky-controller MCP v0.8.8...');
3360
+ log('Starting ostacky-controller MCP v0.9.0...');
3180
3361
  log('State path:', { path: statePath });
3181
3362
  // Clean up stale tmp/lock files from previous runs
3182
3363
  cleanupTmpFiles(statePath);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -110,6 +110,8 @@ export const DEFAULT_STATE: any = {
110
110
  lastHandoff: null,
111
111
  expectedTasks: null,
112
112
  expectedTaskCount: null,
113
+ taskVerifications: null,
114
+ taskVerificationResults: {},
113
115
  auditSeq: 0,
114
116
  degraded: false,
115
117
  schemaVersion: 2,