ostacky 0.7.4 → 0.8.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.
@@ -118,14 +118,10 @@ const TRANSITIONS = {
118
118
  { via: 'abandon', to: 'BLOCKED' },
119
119
  ],
120
120
  DISCOVERY: [
121
- { via: 'record_discovery', to: 'LEVEL_RESOLVED' },
121
+ { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
122
122
  { via: 'block', to: 'BLOCKED' },
123
123
  { via: 'abandon', to: 'BLOCKED' },
124
124
  ],
125
- LEVEL_RESOLVED: [
126
- { via: 'proceed_to_route', to: 'ROUTE_DECISION_PENDING' },
127
- { via: 'block', to: 'BLOCKED' },
128
- ],
129
125
  ROUTE_DECISION_PENDING: [
130
126
  { via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
131
127
  { via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
@@ -329,7 +325,6 @@ const STATES = Object.freeze({
329
325
  INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
330
326
  CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
331
327
  DISCOVERY: 'DISCOVERY',
332
- LEVEL_RESOLVED: 'LEVEL_RESOLVED',
333
328
  ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
334
329
  SPECIFICATION: 'SPECIFICATION',
335
330
  EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
@@ -375,6 +370,11 @@ const DEFAULT_STATE = Object.freeze({
375
370
  cacheHitCount: 0, // 5.4 hardening-v2
376
371
  cacheMissCount: 0,
377
372
  tokenSavingEstimate: 0,
373
+ discoveryCacheHitCount: 0, // mejora-acciones-controller F2
374
+ redundantCallCount: 0,
375
+ cacheMissWithoutPutCount: 0,
376
+ stateCheckCount: 0,
377
+ toolCallCount: 0,
378
378
  lastProposal: null, // 8.1
379
379
  allowedFiles: {}, // 9.2
380
380
  deniedFiles: {}, // 9.2
@@ -734,6 +734,10 @@ class OstackyController {
734
734
  const redactRecursively = (obj) => {
735
735
  if (!obj || typeof obj !== 'object') return;
736
736
  for (const k of Object.keys(obj)) {
737
+ if (k === 'tokenSavingEstimate') {
738
+ if (typeof obj[k] === 'object') redactRecursively(obj[k]);
739
+ continue;
740
+ }
737
741
  if (SENSITIVE_REDACT_RE.test(k)) {
738
742
  obj[k] = '[REDACTED]';
739
743
  } else if (typeof obj[k] === 'string' && SENSITIVE_REDACT_RE.test(obj[k])) {
@@ -975,7 +979,6 @@ class OstackyController {
975
979
  INTERPRETATION_PENDING: 'Call start_request or proceed_to_discovery first.',
976
980
  CLARIFICATION_PENDING: 'Answer the clarification question, then call record_clarification.',
977
981
  DISCOVERY: 'Call record_discovery with a level classification.',
978
- LEVEL_RESOLVED: 'Call proceed_to_route to move to route decision.',
979
982
  ROUTE_DECISION_PENDING: 'Call consume_route_decision with SPEC or DIRECT.',
980
983
  SPECIFICATION: 'Call spec_complete when specification is done.',
981
984
  EXECUTION_ANALYSIS: 'Call record_execution_analysis with a snapshot.',
@@ -1167,7 +1170,7 @@ class OstackyController {
1167
1170
  snapshots: { ...this.#state.snapshots, codegraph: compressedSnapshot },
1168
1171
  lastProposal,
1169
1172
  });
1170
- await this.#audit('LEVEL_RESOLVED', 'record_discovery', `level=${level}, default=${defaultChoice}`);
1173
+ await this.#audit('ROUTE_DECISION_PENDING', 'record_discovery', `level=${level}, default=${defaultChoice}`);
1171
1174
  // 8.2: reasoning sin plan → WARN
1172
1175
  if (!shownToUser && !isTrivial) {
1173
1176
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
@@ -1209,6 +1212,29 @@ class OstackyController {
1209
1212
  auditId: lastAudit?.id || auditId,
1210
1213
  };
1211
1214
  }
1215
+ // Router determinista: 1+ exige Alternatives si estLines>30||fileCount>2||hasAPI, sino downgrade
1216
+ const fileCount = proposalFiles.length;
1217
+ const hasAPI = !!(snapshot?.hasAPI || snapshot?.reasoning?.hasAPI || snapshot?.hasExplicitContract);
1218
+ const estLinesVal = estLines || snapshot?.estLines || snapshot?.reasoning?.estLines || 0;
1219
+ const isOnePlus = level === '1+';
1220
+ const needsBrainstorming = isOnePlus && (estLinesVal > 30 || fileCount > 2 || hasAPI);
1221
+ const isDowngradeable = isOnePlus && estLinesVal < 30 && fileCount === 1 && !hasAPI;
1222
+ if (isOnePlus && needsBrainstorming) {
1223
+ // mark that Alternatives required — will be checked in openspec-propose gate
1224
+ this.#state._routerNeedsAlternatives = true;
1225
+ this.#state._routerDowngradeSuggested = false;
1226
+ log('info:router_brainstorming_required', { level, estLines: estLinesVal, fileCount, hasAPI });
1227
+ } else if (isDowngradeable) {
1228
+ this.#state._routerNeedsAlternatives = false;
1229
+ this.#state._routerDowngradeSuggested = true;
1230
+ log('info:router_downgrade_to_direct', { level, estLines: estLinesVal, fileCount, hasAPI });
1231
+ // override defaultChoice to DIRECT for downgradeable
1232
+ // keep stored defaultChoice as DIRECT already, but hint downgrade
1233
+ } else {
1234
+ this.#state._routerNeedsAlternatives = false;
1235
+ this.#state._routerDowngradeSuggested = false;
1236
+ }
1237
+ await this.#persist();
1212
1238
  // 8.6: Bypass solo para CI
1213
1239
  if (process.env.OSTACKY_REQUIRE_CONFIRMATION === 'false' && this.#state.state === 'ROUTE_DECISION_PENDING') {
1214
1240
  await this.#audit('AUTO', 'auto-confirm (CI)', `auto-consume ${defaultChoice} for CI`);
@@ -1232,16 +1258,28 @@ class OstackyController {
1232
1258
  level,
1233
1259
  routeDecisionId: this.#state.routeDecisionId,
1234
1260
  defaultChoice,
1261
+ routerNeedsAlternatives: this.#state._routerNeedsAlternatives || false,
1262
+ routerDowngradeSuggested: this.#state._routerDowngradeSuggested || false,
1235
1263
  };
1236
1264
  }
1237
1265
 
1238
1266
  async proceedToRoute() {
1239
1267
  this.#load();
1268
+ // deprecated alias: if already ROUTE_DECISION_PENDING, return no-op deprecated
1269
+ if (this.#state.state === 'ROUTE_DECISION_PENDING') {
1270
+ await this.#audit('WARN', 'proceed_to_route', 'deprecated: already in ROUTE_DECISION_PENDING');
1271
+ return {
1272
+ state: this.#state.state,
1273
+ revision: this.#state.revision,
1274
+ deprecated: true,
1275
+ warning: 'proceed_to_route deprecated, use record_discovery directly',
1276
+ };
1277
+ }
1240
1278
  const to = this.#isAllowedTransition(this.#state.state, 'proceed_to_route');
1241
1279
  if (!to) return this.#makeError(`Cannot proceed to route from state ${this.#state.state}`, 'proceed_to_route');
1242
1280
  await this.#transition(to);
1243
1281
  await this.#audit('ROUTE_DECISION_PENDING', 'proceed_to_route');
1244
- return { state: this.#state.state, revision: this.#state.revision };
1282
+ return { state: this.#state.state, revision: this.#state.revision, deprecated: true };
1245
1283
  }
1246
1284
 
1247
1285
  async abandon({ reason } = {}) {
@@ -1380,11 +1418,20 @@ class OstackyController {
1380
1418
  }
1381
1419
  // C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
1382
1420
  // 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
1383
- const hasEvidence =
1421
+ // mejora-acciones-controller F3: discovery-cache counts as valid evidence if discovery snapshot exists
1422
+ const isDiscoveryCacheEvidence =
1384
1423
  snapshot &&
1385
1424
  Array.isArray(snapshot.codegraphUsed) &&
1386
- snapshot.codegraphUsed.length > 0 &&
1387
- snapshot.recommendation != null;
1425
+ snapshot.codegraphUsed.includes('discovery-cache') &&
1426
+ this.#state.snapshots.codegraph != null &&
1427
+ Array.isArray(snapshot.expectedTaskIds) &&
1428
+ snapshot.expectedTaskIds.length > 0;
1429
+ const hasEvidence =
1430
+ (snapshot &&
1431
+ Array.isArray(snapshot.codegraphUsed) &&
1432
+ snapshot.codegraphUsed.length > 0 &&
1433
+ snapshot.recommendation != null) ||
1434
+ isDiscoveryCacheEvidence;
1388
1435
  if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
1389
1436
  this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
1390
1437
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
@@ -1728,6 +1775,11 @@ class OstackyController {
1728
1775
  toolTimeoutCount: this.#state.toolTimeoutCount || 0,
1729
1776
  lastToolDurationMs: this.#state.lastToolDurationMs || 0,
1730
1777
  stateDurationMs: this.#state.stateDurationMs || 0,
1778
+ discoveryCacheHitCount: this.#state.discoveryCacheHitCount || 0,
1779
+ redundantCallCount: this.#state.redundantCallCount || 0,
1780
+ cacheMissWithoutPutCount: this.#state.cacheMissWithoutPutCount || 0,
1781
+ stateCheckCount: this.#state.stateCheckCount || 0,
1782
+ toolCallCount: this.#state.toolCallCount || 0,
1731
1783
  };
1732
1784
  }
1733
1785
 
@@ -1958,6 +2010,36 @@ class OstackyController {
1958
2010
  }
1959
2011
  }
1960
2012
  }
2013
+ // mejora-acciones-controller: hash: prefix alias for validate_edit without full content
2014
+ if (typeof content === 'string' && content.startsWith('hash:') && filePath) {
2015
+ const hashArg = content.slice(5);
2016
+ try {
2017
+ const projectRoot = getProjectRoot(this.#statePath);
2018
+ const absolutePath =
2019
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2020
+ ? resolve(filePath)
2021
+ : resolve(projectRoot, filePath);
2022
+ const currentHash = fastFingerprint(absolutePath);
2023
+ if (
2024
+ currentHash &&
2025
+ hashArg === currentHash &&
2026
+ this.#state.lastValidated?.filePath === filePath &&
2027
+ this.#state.lastValidated?.hash === currentHash
2028
+ ) {
2029
+ try {
2030
+ content = readFileSync(absolutePath, 'utf8');
2031
+ } catch {
2032
+ return { outcome: 'CONFLICT', reason: 'stale fingerprint', filePath };
2033
+ }
2034
+ } else {
2035
+ this.#state.staleContentAttempts = (this.#state.staleContentAttempts || 0) + 1;
2036
+ await this.#persist();
2037
+ return { outcome: 'CONFLICT', reason: 'stale fingerprint', filePath };
2038
+ }
2039
+ } catch (e) {
2040
+ return { outcome: 'CONFLICT', reason: 'hash validation failed' };
2041
+ }
2042
+ }
1961
2043
  // 5.3: optimization — si fastFingerprint no cambió, no re-enviar content completo
1962
2044
  if (
1963
2045
  (typeof content !== 'string' || content.length === 0) &&
@@ -2289,14 +2371,13 @@ function safeHandler(fn, options = {}) {
2289
2371
 
2290
2372
  const server = new McpServer({
2291
2373
  name: 'ostacky-controller',
2292
- version: '0.7.4',
2374
+ version: '0.8.0',
2293
2375
  });
2294
2376
 
2295
2377
  server.registerTool(
2296
2378
  'start_request',
2297
2379
  {
2298
- description:
2299
- 'Start or resume a request. By default, resumes in-progress work (non-terminal states). Use force=true to always reset.',
2380
+ description: 'Start or resume request',
2300
2381
  inputSchema: z.object({
2301
2382
  requestId: z.string().optional().describe('Unique request ID'),
2302
2383
  changeId: z.string().optional().describe('Optional change ID for OpenSpec tracking'),
@@ -2312,8 +2393,7 @@ server.registerTool(
2312
2393
  server.registerTool(
2313
2394
  'request_clarification',
2314
2395
  {
2315
- description:
2316
- 'Pause execution to ask the user for clarification. Use when the request is too vague to classify. Transitions to CLARIFICATION_PENDING — you MUST stop and wait for user response.',
2396
+ description: 'Request clarification',
2317
2397
  inputSchema: z.object({
2318
2398
  question: z.string().optional().describe('The clarification question'),
2319
2399
  }),
@@ -2327,7 +2407,7 @@ server.registerTool(
2327
2407
  server.registerTool(
2328
2408
  'record_clarification',
2329
2409
  {
2330
- description: 'Record that clarification was answered. Transitions to DISCOVERY.',
2410
+ description: 'Record clarification',
2331
2411
  inputSchema: z.object({}),
2332
2412
  },
2333
2413
  safeHandler(async () => {
@@ -2339,8 +2419,7 @@ server.registerTool(
2339
2419
  server.registerTool(
2340
2420
  'record_discovery',
2341
2421
  {
2342
- description:
2343
- 'Record discovery complete with level classification. From INTERPRETATION_PENDING goes to ROUTE_DECISION_PENDING. From DISCOVERY goes to LEVEL_RESOLVED.',
2422
+ description: 'Record discovery (level)',
2344
2423
  inputSchema: z.object({
2345
2424
  level: z.enum(['0', '0+1', '1+']).describe('Impact level'),
2346
2425
  routeDecisionId: z.string().optional().describe('Unique route decision ID'),
@@ -2356,7 +2435,7 @@ server.registerTool(
2356
2435
  server.registerTool(
2357
2436
  'consume_route_decision',
2358
2437
  {
2359
- description: 'Consume the route decision (SPEC or DIRECT). Valid only in ROUTE_DECISION_PENDING.',
2438
+ description: 'Consume route decision (SPEC/DIRECT)',
2360
2439
  inputSchema: z.object({
2361
2440
  decisionId: z.string().describe('Route decision ID from record_discovery'),
2362
2441
  choice: z.enum(['SPEC', 'DIRECT']).describe('Route choice'),
@@ -2371,7 +2450,7 @@ server.registerTool(
2371
2450
  server.registerTool(
2372
2451
  'spec_complete',
2373
2452
  {
2374
- description: 'Mark specification phase as complete. Transitions to EXECUTION_ANALYSIS.',
2453
+ description: 'Spec complete',
2375
2454
  inputSchema: z.object({}),
2376
2455
  },
2377
2456
  safeHandler(async () => {
@@ -2383,7 +2462,7 @@ server.registerTool(
2383
2462
  server.registerTool(
2384
2463
  'record_execution_analysis',
2385
2464
  {
2386
- description: 'Record execution analysis with snapshot. Transitions to EXECUTION_DECISION_PENDING.',
2465
+ description: 'Record execution analysis',
2387
2466
  inputSchema: z.object({
2388
2467
  executionDecisionId: z.string().optional().describe('Unique execution decision ID'),
2389
2468
  snapshot: z.any().optional().describe('Execution analysis snapshot'),
@@ -2398,7 +2477,7 @@ server.registerTool(
2398
2477
  server.registerTool(
2399
2478
  'consume_execution_decision',
2400
2479
  {
2401
- description: 'Consume the execution mode decision (INLINE or SUBAGENT_DRIVEN).',
2480
+ description: 'Consume execution decision (INLINE/SUBAGENT)',
2402
2481
  inputSchema: z.object({
2403
2482
  decisionId: z.string().describe('Execution decision ID from record_execution_analysis'),
2404
2483
  mode: z.enum(['INLINE', 'SUBAGENT_DRIVEN']).describe('Execution mode'),
@@ -2431,7 +2510,7 @@ server.registerTool(
2431
2510
  server.registerTool(
2432
2511
  'sync_complete',
2433
2512
  {
2434
- description: 'Mark sync as complete. Transitions to DONE.',
2513
+ description: 'Sync complete',
2435
2514
  inputSchema: z.object({}),
2436
2515
  },
2437
2516
  safeHandler(async () => {
@@ -2443,7 +2522,7 @@ server.registerTool(
2443
2522
  server.registerTool(
2444
2523
  'block',
2445
2524
  {
2446
- description: 'Transition to BLOCKED state with an optional reason.',
2525
+ description: 'Block',
2447
2526
  inputSchema: z.object({
2448
2527
  reason: z.string().optional().describe('Reason for blocking'),
2449
2528
  }),
@@ -2457,7 +2536,7 @@ server.registerTool(
2457
2536
  server.registerTool(
2458
2537
  'replan',
2459
2538
  {
2460
- description: 'Replan from BLOCKED state back to INTERPRETATION_PENDING.',
2539
+ description: 'Replan',
2461
2540
  inputSchema: z.object({
2462
2541
  reason: z.string().optional().describe('Reason for replanning'),
2463
2542
  }),
@@ -2503,11 +2582,11 @@ server.registerTool(
2503
2582
  server.registerTool(
2504
2583
  'get_audit',
2505
2584
  {
2506
- description: 'Get recent audit entries paginated. Read-only, truncated to 300 chars with unique id per entry.',
2585
+ description: 'Get audit (paginated)',
2507
2586
  inputSchema: z.object({
2508
2587
  limit: z.number().optional().describe('Max entries (default 20)'),
2509
2588
  offset: z.number().optional().describe('Offset from end (default 0)'),
2510
- phase: z.string().optional().describe('Filter by phase (e.g. WARN, LEVEL_RESOLVED)'),
2589
+ phase: z.string().optional().describe('Filter by phase (e.g. WARN, ROUTE_DECISION_PENDING)'),
2511
2590
  since: z.number().optional().describe('Filter by timestamp >= since'),
2512
2591
  }),
2513
2592
  },
@@ -2523,8 +2602,7 @@ server.registerTool(
2523
2602
  server.registerTool(
2524
2603
  'get_metrics',
2525
2604
  {
2526
- description:
2527
- 'Get controller metrics read-only (revision, state, degraded, taskCounts, auditSize, stateFileSize, diskFreeMB, uptimeMs, stateOversizedCount, codegraphBypassCount)',
2605
+ description: 'Get metrics',
2528
2606
  inputSchema: z.object({}),
2529
2607
  },
2530
2608
  safeHandler(
@@ -2539,28 +2617,28 @@ server.registerTool(
2539
2617
  server.registerTool(
2540
2618
  'record_cache_hit',
2541
2619
  {
2542
- description:
2543
- 'Record a CodeGraph cache hit — increments cacheHitCount and tokenSavingEstimate. Call after reusing getCachedCodegraph result.',
2620
+ description: 'Deprecated: cache hit',
2544
2621
  inputSchema: z.object({
2545
2622
  tokensSaved: z.number().optional().describe('Estimated tokens saved (default 500)'),
2546
2623
  }),
2547
2624
  },
2548
2625
  safeHandler(async ({ tokensSaved }) => {
2549
- log('tool:record_cache_hit', { tokensSaved });
2550
- return await controller.recordCacheHit({ tokensSaved });
2626
+ log('tool:record_cache_hit', { tokensSaved, deprecated: true });
2627
+ const r = await controller.recordCacheHit({ tokensSaved });
2628
+ return { ...r, deprecated: true };
2551
2629
  })
2552
2630
  );
2553
2631
 
2554
2632
  server.registerTool(
2555
2633
  'record_cache_miss',
2556
2634
  {
2557
- description:
2558
- 'Record a CodeGraph cache miss — increments cacheMissCount. Call after getCachedCodegraph returns null.',
2635
+ description: 'Deprecated: cache miss',
2559
2636
  inputSchema: z.object({}),
2560
2637
  },
2561
2638
  safeHandler(async () => {
2562
- log('tool:record_cache_miss');
2563
- return await controller.recordCacheMiss();
2639
+ log('tool:record_cache_miss', { deprecated: true });
2640
+ const r = await controller.recordCacheMiss();
2641
+ return { ...r, deprecated: true };
2564
2642
  })
2565
2643
  );
2566
2644
 
@@ -2584,7 +2662,7 @@ server.registerTool(
2584
2662
  'check_file_access',
2585
2663
  {
2586
2664
  description:
2587
- 'Check if file is sensitive and requires ALLOW. Returns BLOCKED with decisionId if sensitive and not allowed.',
2665
+ '[deprecated if plugin active] Check if file is sensitive and requires ALLOW. plugin enforces when active.',
2588
2666
  inputSchema: z.object({
2589
2667
  filePath: z.string().describe('File path to check'),
2590
2668
  reason: z.string().optional().describe('Reason for access'),
@@ -2592,6 +2670,21 @@ server.registerTool(
2592
2670
  },
2593
2671
  safeHandler(async ({ filePath, reason }) => {
2594
2672
  log('tool:check_file_access', { filePath });
2673
+ try {
2674
+ const pluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-plugin.ts');
2675
+ const assetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-plugin.ts');
2676
+ const legacyPluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-controller.ts');
2677
+ const legacyAssetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-controller.ts');
2678
+ if (
2679
+ existsSync(pluginPath) ||
2680
+ existsSync(assetsPath) ||
2681
+ existsSync(legacyPluginPath) ||
2682
+ existsSync(legacyAssetsPath)
2683
+ ) {
2684
+ const res = await controller.checkFileAccess({ filePath, reason });
2685
+ return { ...res, deprecated: true, hint: 'plugin enforces' };
2686
+ }
2687
+ } catch {}
2595
2688
  return await controller.checkFileAccess({ filePath, reason });
2596
2689
  })
2597
2690
  );
@@ -2599,7 +2692,7 @@ server.registerTool(
2599
2692
  server.registerTool(
2600
2693
  'consume_file_access_decision',
2601
2694
  {
2602
- description: 'Consume file access decision: ALLOW or DENY. Persists allowedFiles/deniedFiles.',
2695
+ description: 'Consume file access decision',
2603
2696
  inputSchema: z.object({
2604
2697
  decisionId: z.string().describe('Decision ID from check_file_access'),
2605
2698
  choice: z.enum(['ALLOW', 'DENY']).describe('Choice'),
@@ -2614,20 +2707,19 @@ server.registerTool(
2614
2707
  server.registerTool(
2615
2708
  'proceed_to_route',
2616
2709
  {
2617
- description:
2618
- 'Proceed from LEVEL_RESOLVED to ROUTE_DECISION_PENDING after discovery is confirmed. Only valid from LEVEL_RESOLVED — call this after asking the user about the route decision.',
2710
+ description: 'Proceed to route (deprecated)',
2619
2711
  inputSchema: z.object({}),
2620
2712
  },
2621
2713
  safeHandler(async () => {
2622
- log('tool:proceed_to_route');
2623
- return await controller.proceedToRoute();
2714
+ log('tool:proceed_to_route deprecated');
2715
+ return { deprecated: true, state: 'ROUTE_DECISION_PENDING' };
2624
2716
  })
2625
2717
  );
2626
2718
 
2627
2719
  server.registerTool(
2628
2720
  'abandon',
2629
2721
  {
2630
- description: 'Abandon the current request. Transitions to BLOCKED from most states, or to DONE from BLOCKED.',
2722
+ description: 'Abandon request',
2631
2723
  inputSchema: z.object({
2632
2724
  reason: z.string().optional().describe('Reason for abandoning'),
2633
2725
  }),
@@ -2669,7 +2761,7 @@ server.registerTool(
2669
2761
  server.registerTool(
2670
2762
  'get_state',
2671
2763
  {
2672
- description: 'Get the current controller state (reads persistent store).',
2764
+ description: 'Get state',
2673
2765
  inputSchema: z.object({}),
2674
2766
  },
2675
2767
  safeHandler(
@@ -2683,7 +2775,7 @@ server.registerTool(
2683
2775
  server.registerTool(
2684
2776
  'get_tasks',
2685
2777
  {
2686
- description: 'Get current task states.',
2778
+ description: 'Get tasks',
2687
2779
  inputSchema: z.object({}),
2688
2780
  },
2689
2781
  safeHandler(
@@ -2697,7 +2789,7 @@ server.registerTool(
2697
2789
  server.registerTool(
2698
2790
  'get_available_transitions',
2699
2791
  {
2700
- description: 'Get valid transitions from current state. Useful for debugging state machine issues.',
2792
+ description: 'Get available transitions',
2701
2793
  inputSchema: z.object({}),
2702
2794
  },
2703
2795
  safeHandler(
@@ -2727,7 +2819,7 @@ server.registerTool(
2727
2819
  server.registerTool(
2728
2820
  'get_handoff',
2729
2821
  {
2730
- description: 'Read pending handoff from previous session. Call at start of new request to recover context.',
2822
+ description: 'Get handoff',
2731
2823
  inputSchema: z.object({}),
2732
2824
  },
2733
2825
  safeHandler(
@@ -2741,7 +2833,7 @@ server.registerTool(
2741
2833
  server.registerTool(
2742
2834
  'clear_handoff',
2743
2835
  {
2744
- description: 'Mark handoff as consumed after the agent has loaded the context.',
2836
+ description: 'Clear handoff',
2745
2837
  inputSchema: z.object({}),
2746
2838
  },
2747
2839
  safeHandler(async () => {
@@ -2753,15 +2845,19 @@ server.registerTool(
2753
2845
  'check_pending_state',
2754
2846
  {
2755
2847
  description:
2756
- 'Check if agent is in a pending state waiting for user input. ' +
2757
- 'MUST be called before ANY tool call when controller is available. ' +
2758
- 'Returns ALLOW or BLOCKED with reason. ' +
2759
- 'EXCEPTION: controller tools (consume_route_decision, consume_execution_decision, ' +
2760
- 'record_clarification, abandon) are ALWAYS allowed — they unlock the state.',
2848
+ '[deprecated] Check if in pending state. Returns ALLOW/BLOCKED. plugin enforces — deprecated:true if plugin active',
2761
2849
  inputSchema: z.object({}),
2762
2850
  },
2763
2851
  safeHandler(
2764
2852
  async () => {
2853
+ // If plugin active, delegate to hint
2854
+ try {
2855
+ const pluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-controller.ts');
2856
+ const assetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-controller.ts');
2857
+ if (existsSync(pluginPath) || existsSync(assetsPath)) {
2858
+ return { deprecated: true, hint: 'plugin enforces', status: 'ALLOW' };
2859
+ }
2860
+ } catch {}
2765
2861
  const state = await controller.getState();
2766
2862
  const pendingStates = ['CLARIFICATION_PENDING', 'ROUTE_DECISION_PENDING', 'EXECUTION_DECISION_PENDING'];
2767
2863
  if (pendingStates.includes(state.state)) {
@@ -2782,21 +2878,11 @@ server.registerTool(
2782
2878
  server.registerTool(
2783
2879
  'validate_edit',
2784
2880
  {
2785
- description:
2786
- 'Validate an edit against current file content. Returns EDITABLE, ALREADY_APPLIED, or CONFLICT. ' +
2787
- 'Call BEFORE executing an edit tool. Only valid in EXECUTING_INLINE or EXECUTING_SUBAGENTS states. ' +
2788
- 'IMPORTANT: content parameter is REQUIRED. Read the file first, then pass the full content.',
2881
+ description: '[deprecated] Validate edit',
2789
2882
  inputSchema: z.object({
2790
2883
  oldString: z.string().describe('The exact string to find in content (must be unique).'),
2791
2884
  newString: z.string().describe('The replacement string.'),
2792
- content: z
2793
- .string()
2794
- .describe(
2795
- 'REQUIRED — The full file content. ' +
2796
- 'You MUST read the file first with the Read tool, then pass the complete content here. ' +
2797
- 'Example: call Read on the file, store the output, then call validate_edit with that content. ' +
2798
- 'Without this parameter, validate_edit will fail.'
2799
- ),
2885
+ content: z.string().describe('Required: full file content or hash:<fp> if fingerprint unchanged.'),
2800
2886
  taskId: z.string().optional().describe('Optional task ID for tracking.'),
2801
2887
  filePath: z.string().optional().describe('Optional file path for traversal validation.'),
2802
2888
  }),
@@ -2809,6 +2895,20 @@ server.registerTool(
2809
2895
  hasContent: !!content,
2810
2896
  filePath,
2811
2897
  });
2898
+ try {
2899
+ const pluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-plugin.ts');
2900
+ const assetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-plugin.ts');
2901
+ const legacyPluginPath = join(process.cwd(), '.opencode', 'plugins', 'ostacky-controller.ts');
2902
+ const legacyAssetsPath = join(process.cwd(), 'assets', 'plugins', 'ostacky-controller.ts');
2903
+ if (
2904
+ existsSync(pluginPath) ||
2905
+ existsSync(assetsPath) ||
2906
+ existsSync(legacyPluginPath) ||
2907
+ existsSync(legacyAssetsPath)
2908
+ ) {
2909
+ return { deprecated: true, hint: 'plugin enforces' };
2910
+ }
2911
+ } catch {}
2812
2912
  if (typeof content !== 'string' || typeof oldString !== 'string' || typeof newString !== 'string') {
2813
2913
  return {
2814
2914
  outcome: 'CONFLICT',
@@ -2871,7 +2971,7 @@ function setupGracefulShutdown(ctrl) {
2871
2971
  }
2872
2972
 
2873
2973
  async function main() {
2874
- log('Starting ostacky-controller MCP v0.7.4...');
2974
+ log('Starting ostacky-controller MCP v0.8.0...');
2875
2975
  log('State path:', { path: statePath });
2876
2976
  // Clean up stale tmp/lock files from previous runs
2877
2977
  cleanupTmpFiles(statePath);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {