catalyst-core-internal 0.1.2 → 0.1.3

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.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/bin/catalyst.js +8 -1
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +3 -11
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +12 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +18 -3
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +5 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +240 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/SecurityBridgeTest.kt +199 -0
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +121 -0
  11. package/dist/native/bridge/hooks.js +4 -4
  12. package/dist/native/bridge/useBaseHook.js +5 -4
  13. package/dist/native/bridge/utils/NativeBridge.js +4 -4
  14. package/dist/native/buildAppAndroid.js +2 -2
  15. package/dist/native/buildAppIos.js +10 -17
  16. package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
  17. package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
  18. package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
  19. package/dist/native/internalPluginUtils.js +1 -0
  20. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  21. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  22. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  23. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +13 -2
  24. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +13 -2
  25. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  26. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +6 -0
  27. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +36 -0
  29. package/dist/native/iosnativeWebView/iosnativeWebView.xctestplan +1 -0
  30. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeCommandHandlerSecurityTests.swift +212 -0
  31. package/dist/native/iosnativeWebView/iosnativeWebViewTests/FrameworkServerUtilsTests.swift +14 -4
  32. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  33. package/dist/native/iosnativeWebView/iosnativeWebViewTests/ScreenSecureManagerTests.swift +121 -0
  34. package/dist/native/iosnativeWebView/iosnativeWebViewTests/WebViewTests.swift +9 -21
  35. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  36. package/dist/native/pluginComposerAndroid.js +9 -0
  37. package/dist/native/pluginComposerIos.js +7 -0
  38. package/dist/scripts/plugins.js +1 -0
  39. package/package.json +3 -2
  40. package/mcp_v2/conversion-tasks.json +0 -371
  41. package/mcp_v2/knowledge-base.json +0 -1450
  42. package/mcp_v2/lib/helpers.js +0 -145
  43. package/mcp_v2/mcp.js +0 -366
  44. package/mcp_v2/package.json +0 -13
  45. package/mcp_v2/schema.sql +0 -88
  46. package/mcp_v2/setup.js +0 -262
  47. package/mcp_v2/tools/build.js +0 -449
  48. package/mcp_v2/tools/config.js +0 -262
  49. package/mcp_v2/tools/conversion.js +0 -492
  50. package/mcp_v2/tools/debug.js +0 -62
  51. package/mcp_v2/tools/knowledge.js +0 -213
  52. package/mcp_v2/tools/sync.js +0 -21
  53. package/mcp_v2/tools/tasks.js +0 -844
@@ -1,844 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { findCatalystRoot } = require('../lib/helpers');
6
- const conversion = require('./conversion');
7
-
8
- let _db;
9
-
10
- function init(db) {
11
- _db = db;
12
- }
13
-
14
- // ─── Helpers ─────────────────────────────────────────────────────────────────
15
-
16
- function slugify(str) {
17
- return str
18
- .toLowerCase()
19
- .replace(/[^a-z0-9\s-]/g, '')
20
- .trim()
21
- .replace(/\s+/g, '-')
22
- .slice(0, 60);
23
- }
24
-
25
- function now() {
26
- return new Date().toISOString().replace('T', ' ').slice(0, 19);
27
- }
28
-
29
- // ─── Improvement 1: Resolve needs_review inline before plan write ─────────────
30
- //
31
- // Reads signal_files from disk for each needs_review task.
32
- // Returns { verdict: 'gap'|'completed', evidence } so the plan step is final.
33
- // No needs_review items survive into the plan.
34
-
35
- function resolveNeedsReview(task, projectRoot) {
36
- const rc = task.review_context;
37
- if (!rc || !rc.signal_files) {
38
- // No signal files to read — treat as gap (conservative)
39
- return { verdict: 'gap', evidence: 'No signal files available. Treating as gap to be safe.' };
40
- }
41
-
42
- const sf = rc.signal_files;
43
-
44
- // Collect all file lists from signal_files object
45
- const oldFiles = sf.old_pattern_files || [];
46
- const hookFiles = sf.hook_usage_files || [];
47
- const fetcherFiles = sf.converted_files || [];
48
- const correctFiles = sf.correct_pattern_files || [];
49
-
50
- // Read a sample of the old-pattern files (up to 3) to check for hook presence
51
- const samplesToCheck = oldFiles.slice(0, 3);
52
- const hookNames = {
53
- T4_DATA_FETCHING: /serverFetcher|clientFetcher/,
54
- T17a_USE_FILEPICKER: /useFilePicker/,
55
- T17b_USE_CAMERA: /useCamera/,
56
- T18_USE_HAPTIC: /useHapticFeedback/,
57
- T20_USE_DEVICE_INFO: /__PLATFORM__|window\.__PLATFORM__/,
58
- };
59
- const hookRegex = hookNames[task.id];
60
-
61
- // If the task has hook_usage_files or correct_pattern_files already found → leaning completed
62
- const mitigationFiles = hookFiles.length ? hookFiles : correctFiles;
63
-
64
- if (mitigationFiles.length > 0) {
65
- // Hook/correct pattern IS present somewhere — check if it's in the same files
66
- // or at minimum in the project. We read the old-pattern files to see if they
67
- // import the hook directly, or accept it as a prop.
68
- let nativeBranchFound = false;
69
- for (const f of samplesToCheck) {
70
- try {
71
- const absPath = path.isAbsolute(f) ? f : path.join(projectRoot, f);
72
- const content = fs.readFileSync(absPath, 'utf8');
73
- if (hookRegex && hookRegex.test(content)) {
74
- nativeBranchFound = true;
75
- break;
76
- }
77
- // Props pattern: isNative or execute passed as props
78
- if (/isNative|execute\s*\(/.test(content)) {
79
- nativeBranchFound = true;
80
- break;
81
- }
82
- } catch {
83
- // file unreadable — skip
84
- }
85
- }
86
-
87
- if (nativeBranchFound) {
88
- return {
89
- verdict: 'completed',
90
- evidence: `Native branch confirmed in ${samplesToCheck.join(', ')}. Hook or isNative pattern found.`,
91
- };
92
- }
93
-
94
- // Hook exists elsewhere in project but NOT in the old-pattern files themselves.
95
- // Component tree pass-through is possible but unconfirmed — treat as gap.
96
- return {
97
- verdict: 'gap',
98
- evidence: `Hook found in ${mitigationFiles.slice(0,2).join(', ')} but not inside old-pattern files. ` +
99
- `Likely unconverted: ${oldFiles.slice(0,3).join(', ')}.`,
100
- };
101
- }
102
-
103
- // No mitigation files at all → definite gap
104
- return {
105
- verdict: 'gap',
106
- evidence: `No mitigation found. ${oldFiles.length} file(s) with old pattern: ${oldFiles.slice(0,3).join(', ')}.`,
107
- };
108
- }
109
-
110
- // ─── Improvement 2: Derive files_to_touch + missing_items per step ────────────
111
- //
112
- // For gap steps:
113
- // - files_to_touch: from gap.files (already collected by detector)
114
- // - missing_items: for config tasks → missing config keys; for file-absence tasks → list missing files
115
- // For resolved needs_review steps (verdict=gap):
116
- // - files_to_touch: from signal_files.old_pattern_files
117
- // - missing_items: describe what needs to change in each file
118
-
119
- function deriveFilesTouched(task, projectRoot) {
120
- // task here is a raw gap/needs_review item from get_conversion_status output
121
- const out = { files_to_touch: [], missing_items: [] };
122
-
123
- // From detector files array (T19_USE_NOTIFICATIONS, T13/T14 use this)
124
- if (task.files && task.files.length) {
125
- out.files_to_touch = task.files.slice(0, 10);
126
- }
127
-
128
- // From signal_files (needs_review-derived gaps)
129
- if (task.review_context?.signal_files) {
130
- const sf = task.review_context.signal_files;
131
- const candidates = (sf.old_pattern_files || []).slice(0, 10);
132
- out.files_to_touch = [...new Set([...out.files_to_touch, ...candidates])];
133
- }
134
-
135
- // Missing config keys (from reason string pattern "Missing fields: X, Y")
136
- const missingFieldsMatch = (task.reason || '').match(/Missing(?:\s+(?:fields|Android icons|iOS icons|Firebase files|Splash asset files|server files|client files))?:\s*(.+)/i);
137
- if (missingFieldsMatch) {
138
- out.missing_items = missingFieldsMatch[1].split(',').map(s => s.trim()).filter(Boolean);
139
- }
140
-
141
- // Files that don't exist yet (from reason string "not found" pattern)
142
- const notFoundMatch = (task.reason || '').match(/^(.+)\s+not found$/i);
143
- if (notFoundMatch && !out.files_to_touch.length) {
144
- out.files_to_touch = [notFoundMatch[1].trim()];
145
- out.missing_items = ['File must be created'];
146
- }
147
-
148
- return out;
149
- }
150
-
151
- // ─── Improvement 3: bare_minimum section ─────────────────────────────────────
152
- //
153
- // Derives the ordered subset of tasks required for the first native build to run.
154
- // = All Tier 1 gaps + Tier 2 gaps, sorted by depends_on (topological order).
155
- // Blocked tasks are included only if their blocker is also in the set.
156
-
157
- const TIER_1_IDS = ['T1_CONFIG','T2_ROUTER_DEP','T3_ROUTES_FILE','T4_DATA_FETCHING',
158
- 'T5_ROUTER_DATA_PROVIDER','T6_APP_SHELL','T7_SERVER_FILES','T8_CLIENT_ENTRY'];
159
- const TIER_2_IDS = ['T9_WEBVIEW_ANDROID','T10_WEBVIEW_IOS','T11_ACCESS_CONTROL',
160
- 'T12_SPLASH_SCREEN','T13_ANDROID_ICONS','T14_IOS_ICONS','T15_OFFLINE_HTML'];
161
-
162
- function buildBareMinimum(steps) {
163
- const bareIds = new Set([...TIER_1_IDS, ...TIER_2_IDS]);
164
- const bareSteps = steps.filter(s => bareIds.has(s.id) && s.status !== 'done');
165
-
166
- // Topological sort by depends_on
167
- const sorted = [];
168
- const visited = new Set();
169
-
170
- function visit(step) {
171
- if (visited.has(step.id)) return;
172
- visited.add(step.id);
173
- for (const depId of (step.depends_on || [])) {
174
- const depStep = bareSteps.find(s => s.id === depId);
175
- if (depStep) visit(depStep);
176
- }
177
- sorted.push(step);
178
- }
179
-
180
- for (const s of bareSteps) visit(s);
181
-
182
- return sorted.map((s, i) => ({
183
- order: i + 1,
184
- id: s.id,
185
- tier: s.tier,
186
- title: s.title,
187
- status: s.status,
188
- files_to_touch: s.files_to_touch || [],
189
- missing_items: s.missing_items || [],
190
- }));
191
- }
192
-
193
- // ─── Main: build conversion steps with all 3 improvements ────────────────────
194
-
195
- // Run live conversion detection and build a personalised step list from results.
196
- // Completed tasks → pre-marked done. Gaps → pending with native_risk + fix_guide.
197
- // needs_review → resolved inline to gap or completed. blocked → blocked status.
198
- // Adds files_to_touch + missing_items per step. Builds bare_minimum block.
199
- function getConversionStepsForGoal(goal, projectRoot) {
200
- if (!/convert|universal|native|migration|migrate/i.test(goal)) return null;
201
-
202
- let status;
203
- try {
204
- status = conversion.handle_get_conversion_status({});
205
- } catch {
206
- return null;
207
- }
208
- if (!status || status.error) return null;
209
-
210
- const steps = [];
211
- let i = 0;
212
-
213
- // Completed tasks — pre-marked done
214
- for (const t of (status.completed || [])) {
215
- steps.push({
216
- index: i++,
217
- id: t.id,
218
- tier: t.tier,
219
- title: t.title,
220
- detail: `Already complete.${t.note ? ' Note: ' + t.note : ''}`,
221
- guide: null,
222
- status: 'done',
223
- note: t.note || null,
224
- updated_at: null,
225
- });
226
- }
227
-
228
- // Gaps — what needs fixing, ordered by tier
229
- for (const t of (status.gaps || [])) {
230
- const { files_to_touch, missing_items } = deriveFilesTouched(t, projectRoot);
231
- steps.push({
232
- index: i++,
233
- id: t.id,
234
- tier: t.tier,
235
- title: t.title,
236
- detail: t.reason,
237
- native_risk: t.native_risk,
238
- guide: t.fix_guide,
239
- verify_hint: t.verify_hint || null,
240
- depends_on: t.depends_on,
241
- files_to_touch,
242
- missing_items,
243
- status: 'pending',
244
- note: null,
245
- updated_at: null,
246
- });
247
- }
248
-
249
- // needs_review — resolve inline; no needs_review in final plan
250
- for (const t of (status.needs_review || [])) {
251
- const resolution = resolveNeedsReview(t, projectRoot);
252
- if (resolution.verdict === 'completed') {
253
- steps.push({
254
- index: i++,
255
- id: t.id,
256
- tier: t.tier,
257
- title: t.title,
258
- detail: `Resolved as complete. ${resolution.evidence}`,
259
- guide: null,
260
- status: 'done',
261
- note: resolution.evidence,
262
- updated_at: null,
263
- });
264
- } else {
265
- // Resolved as gap
266
- const { files_to_touch, missing_items } = deriveFilesTouched(t, projectRoot);
267
- steps.push({
268
- index: i++,
269
- id: t.id,
270
- tier: t.tier,
271
- title: t.title,
272
- detail: resolution.evidence,
273
- native_risk: t.native_risk,
274
- guide: t.fix_guide,
275
- verify_hint: t.verify_hint || null,
276
- depends_on: t.depends_on,
277
- files_to_touch,
278
- missing_items,
279
- status: 'pending',
280
- resolved_from: 'needs_review',
281
- note: null,
282
- updated_at: null,
283
- });
284
- }
285
- }
286
-
287
- // Blocked — dependency not met
288
- for (const t of (status.blocked || [])) {
289
- steps.push({
290
- index: i++,
291
- id: t.id,
292
- tier: t.tier,
293
- title: t.title,
294
- detail: `Blocked — depends on: ${(t.depends_on || []).join(', ')}`,
295
- depends_on: t.depends_on,
296
- status: 'blocked',
297
- note: null,
298
- updated_at: null,
299
- });
300
- }
301
-
302
- if (steps.length === 0) return null;
303
-
304
- // Re-index
305
- steps.forEach((s, idx) => { s.index = idx; });
306
-
307
- // Build bare_minimum block (Improvement 3)
308
- const bare_minimum = buildBareMinimum(steps);
309
-
310
- return { steps, scan_summary: status.summary, bare_minimum };
311
- }
312
-
313
- // Generate generic steps when no conversion tasks apply.
314
- // These are scaffolded from the goal string — Claude will flesh them out.
315
- function scaffoldStepsFromGoal(goal) {
316
- return [
317
- { index: 0, title: 'Understand current state', detail: `Review existing code relevant to: ${goal}`, status: 'pending', note: null, updated_at: null },
318
- { index: 1, title: 'Identify gaps', detail: 'List what needs to change vs. what is already done.', status: 'pending', note: null, updated_at: null },
319
- { index: 2, title: 'Implement changes', detail: 'Make the required code/config changes.', status: 'pending', note: null, updated_at: null },
320
- { index: 3, title: 'Test on target platform', detail: 'Verify the change works. Check for regressions.', status: 'pending', note: null, updated_at: null },
321
- { index: 4, title: 'Mark complete + document', detail: 'Note any findings, edge cases, or follow-up tasks.', status: 'pending', note: null, updated_at: null },
322
- ];
323
- }
324
-
325
- function getActivePlan(projectRoot) {
326
- return _db.prepare(`
327
- SELECT * FROM task_plans
328
- WHERE project_root = ? AND status = 'active'
329
- ORDER BY updated_at DESC LIMIT 1
330
- `).get(projectRoot);
331
- }
332
-
333
- function parsePlan(row) {
334
- return {
335
- ...row,
336
- steps: JSON.parse(row.steps),
337
- };
338
- }
339
-
340
- function summarisePlan(plan) {
341
- const steps = plan.steps;
342
- const done = steps.filter(s => s.status === 'done').length;
343
- const blocked = steps.filter(s => s.status === 'blocked').length;
344
- const pending = steps.filter(s => s.status === 'pending').length;
345
- const next = steps.find(s => s.status === 'pending') || null;
346
- const current = steps.find(s => s.status === 'in_progress') || next;
347
- return { total: steps.length, done, blocked, pending, current_step: current };
348
- }
349
-
350
- // ─── MD file helpers ─────────────────────────────────────────────────────────
351
-
352
- function getMdPath(projectRoot, slug) {
353
- return path.join(projectRoot, '.mcp_tasks', `${slug}.md`);
354
- }
355
-
356
- function buildUserReviewWarnings(projectRoot) {
357
- const warnings = [];
358
- const configPath = path.join(projectRoot, 'config', 'config.json');
359
- let config = null;
360
- try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return warnings; }
361
-
362
- const wc = config.WEBVIEW_CONFIG || {};
363
-
364
- // Android warnings
365
- const a = wc.android || {};
366
- if (a.sdkPath && !fs.existsSync(a.sdkPath)) {
367
- warnings.push(`Android SDK path "${a.sdkPath}" does not exist on this machine. Update WEBVIEW_CONFIG.android.sdkPath.`);
368
- }
369
- if (!a.emulatorName) {
370
- warnings.push('Android emulator name not set. Add WEBVIEW_CONFIG.android.emulatorName (run `emulator -list-avds` to find yours).');
371
- }
372
-
373
- // iOS warnings
374
- const ios = wc.ios || {};
375
- if (!wc.ios) {
376
- warnings.push('WEBVIEW_CONFIG.ios block is missing. iOS build will not work until added.');
377
- } else {
378
- if (!ios.appBundleId) {
379
- warnings.push('iOS app bundle ID not set. Add WEBVIEW_CONFIG.ios.appBundleId (e.g. com.company.appname). Must match your Apple Developer provisioning profile.');
380
- }
381
- if (!ios.simulatorName) {
382
- warnings.push('iOS simulator name not set. Add WEBVIEW_CONFIG.ios.simulatorName (run `xcrun simctl list devices` to find yours).');
383
- }
384
- if (!ios.appName) {
385
- warnings.push('iOS app name not set. Add WEBVIEW_CONFIG.ios.appName.');
386
- }
387
- }
388
-
389
- return warnings;
390
- }
391
-
392
- function writeMdFile(projectRoot, slug, goal, steps, bareMinimum, userReviewWarnings) {
393
- const dir = path.join(projectRoot, '.mcp_tasks');
394
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
395
-
396
- const mdPath = getMdPath(projectRoot, slug);
397
- const today = now().slice(0, 10);
398
-
399
- // Steps section
400
- const stepLines = steps.map(s => {
401
- const check = s.status === 'done' ? 'x' : ' ';
402
- const tier = s.tier ? ` [T${s.tier}]` : '';
403
- const risk = s.native_risk ? ` ⚠ ${s.native_risk}` : '';
404
- const verify = s.verify_hint ? `\n > ✓ ${s.verify_hint}` : '';
405
- return `- [${check}]${tier} ${s.title}${risk}${verify}`;
406
- }).join('\n');
407
-
408
- // Current step
409
- const currentStep = steps.find(s => s.status === 'in_progress') || steps.find(s => s.status === 'pending');
410
-
411
- // Bare minimum section
412
- const bareLines = bareMinimum && bareMinimum.length
413
- ? bareMinimum.map(s => `${s.order}. [${s.status === 'done' ? 'x' : ' '}] ${s.title}`).join('\n')
414
- : '_All Tier 1+2 tasks complete._';
415
-
416
- // User review warnings
417
- const reviewLines = userReviewWarnings && userReviewWarnings.length
418
- ? userReviewWarnings.map(w => `- ⚠ ${w}`).join('\n')
419
- : '_No manual review items._';
420
-
421
- const content = `# Task: ${slug}
422
- **Status:** in_progress
423
- **Created:** ${today}
424
- **Project:** ${path.basename(projectRoot)}
425
-
426
- ---
427
-
428
- ## Goal
429
- ${goal}
430
-
431
- ---
432
-
433
- ## User Review Required
434
- ${reviewLines}
435
-
436
- ---
437
-
438
- ## Bare Minimum (first native build)
439
- ${bareLines}
440
-
441
- ---
442
-
443
- ## Steps
444
- ${stepLines}
445
-
446
- ---
447
-
448
- ## Current Step
449
- ${currentStep ? currentStep.title : 'All steps complete.'}
450
-
451
- ---
452
-
453
- ## Findings
454
- <!-- APPEND ONLY — never edit above this line -->
455
-
456
- ## Blockers
457
- <!-- APPEND ONLY -->
458
-
459
- ## Decisions
460
- <!-- APPEND ONLY -->
461
- `;
462
-
463
- fs.writeFileSync(mdPath, content, 'utf8');
464
- return mdPath;
465
- }
466
-
467
- function updateMdFile(projectRoot, slug, steps) {
468
- const mdPath = getMdPath(projectRoot, slug);
469
- if (!fs.existsSync(mdPath)) return; // MD missing — skip silently, DB is source of truth
470
-
471
- let content = fs.readFileSync(mdPath, 'utf8');
472
-
473
- // Rebuild Steps section
474
- const stepLines = steps.map(s => {
475
- const check = s.status === 'done' ? 'x' : ' ';
476
- const tier = s.tier ? ` [T${s.tier}]` : '';
477
- const risk = s.native_risk ? ` ⚠ ${s.native_risk}` : '';
478
- return `- [${check}]${tier} ${s.title}${risk}`;
479
- }).join('\n');
480
-
481
- content = content.replace(
482
- /^## Steps\n[\s\S]*?(?=\n---)/m,
483
- `## Steps\n${stepLines}`
484
- );
485
-
486
- // Update Current Step
487
- const currentStep = steps.find(s => s.status === 'in_progress') || steps.find(s => s.status === 'pending');
488
- content = content.replace(
489
- /^## Current Step\n.*/m,
490
- `## Current Step\n${currentStep ? currentStep.title : 'All steps complete.'}`
491
- );
492
-
493
- fs.writeFileSync(mdPath, content, 'utf8');
494
- }
495
-
496
- function appendToMdFindings(projectRoot, slug, finding) {
497
- const mdPath = getMdPath(projectRoot, slug);
498
- if (!fs.existsSync(mdPath)) return;
499
-
500
- const line = `\n- ${now()} | ${finding}`;
501
- let content = fs.readFileSync(mdPath, 'utf8');
502
- content = content.replace('## Findings\n<!-- APPEND ONLY — never edit above this line -->', `## Findings\n<!-- APPEND ONLY — never edit above this line -->${line}`);
503
- fs.writeFileSync(mdPath, content, 'utf8');
504
- }
505
-
506
- // ─── Tool: create_task_plan ──────────────────────────────────────────────────
507
-
508
- // Commands that are verified to exist in catalyst-core projects
509
- const VALID_COMMANDS = new Set([
510
- 'npm run build:android',
511
- 'npm run build:android:release',
512
- 'npm run build:ios',
513
- 'npm run buildApp:android',
514
- 'npm run buildApp:ios',
515
- 'npm run buildApp:android:release',
516
- 'npm run buildApp:ios:release',
517
- 'npm run setupEmulator:android',
518
- 'npm run setupEmulator:ios',
519
- 'npm run devBuild',
520
- 'npm run devServe',
521
- 'npm run prepare',
522
- 'node .catalyst/mcp/setup.js',
523
- ]);
524
-
525
- function validateStepCommands(steps) {
526
- const invalid = [];
527
- for (const s of steps) {
528
- const text = `${typeof s === 'string' ? s : (s.title || '') + ' ' + (s.detail || '')}`;
529
- const cmds = text.match(/npm [\w:]+(?:\s[\w:]+)?|npx [\w@/-]+|node [\w./]+/g) || [];
530
- for (const cmd of cmds) {
531
- if (!VALID_COMMANDS.has(cmd)) {
532
- invalid.push(cmd);
533
- }
534
- }
535
- }
536
- return invalid;
537
- }
538
-
539
- function handle_create_task_plan({ goal, steps: customSteps } = {}) {
540
- if (!goal) return { error: 'goal is required.' };
541
-
542
- const catalystRoot = findCatalystRoot();
543
- if (!catalystRoot) return { error: 'No catalyst-core project found.' };
544
-
545
- const projectRoot = catalystRoot.dir;
546
-
547
- // Abandon any existing active plan for this project
548
- const existing = getActivePlan(projectRoot);
549
- if (existing) {
550
- _db.prepare(`UPDATE task_plans SET status='abandoned', updated_at=? WHERE id=?`)
551
- .run(now(), existing.id);
552
- }
553
-
554
- // Build steps
555
- let steps;
556
- let scan_summary = null;
557
- let bare_minimum = null;
558
-
559
- if (customSteps && Array.isArray(customSteps) && customSteps.length) {
560
- // Validate any commands in custom steps against known catalyst commands
561
- const invalidCmds = validateStepCommands(customSteps);
562
- if (invalidCmds.length > 0) {
563
- return {
564
- error: 'invalid_commands_in_steps',
565
- message: `Steps contain commands that do not exist in catalyst-core projects: ${invalidCmds.join(', ')}. Do not invent commands. Valid catalyst commands are: ${[...VALID_COMMANDS].join(', ')}. For conversion/migration goals, omit steps entirely — auto-generation runs live file detection and builds accurate steps.`,
566
- invalid_commands: invalidCmds,
567
- valid_commands: [...VALID_COMMANDS],
568
- };
569
- }
570
- steps = customSteps.map((s, i) => ({
571
- index: i,
572
- title: typeof s === 'string' ? s : s.title,
573
- detail: typeof s === 'string' ? '' : (s.detail || ''),
574
- status: 'pending',
575
- note: null,
576
- updated_at: null,
577
- }));
578
- } else {
579
- const result = getConversionStepsForGoal(goal, projectRoot);
580
- if (result) {
581
- steps = result.steps;
582
- scan_summary = result.scan_summary;
583
- bare_minimum = result.bare_minimum;
584
- } else {
585
- steps = scaffoldStepsFromGoal(goal);
586
- }
587
- }
588
-
589
- // Mark first non-done step in_progress
590
- const firstPending = steps.find(s => s.status === 'pending');
591
- if (firstPending) firstPending.status = 'in_progress';
592
-
593
- const slug = slugify(goal);
594
- const uniqueSlug = `${slug}-${Date.now()}`;
595
-
596
- _db.prepare(`
597
- INSERT INTO task_plans (slug, goal, project_root, status, steps, created_at, updated_at)
598
- VALUES (?, ?, ?, 'active', ?, ?, ?)
599
- `).run(uniqueSlug, goal, projectRoot, JSON.stringify(steps), now(), now());
600
-
601
- // Write .mcp_tasks/<slug>.md
602
- const userReviewWarnings = buildUserReviewWarnings(projectRoot);
603
- const mdPath = writeMdFile(projectRoot, uniqueSlug, goal, steps, bare_minimum, userReviewWarnings);
604
-
605
- const summary = summarisePlan({ steps });
606
- const pendingSteps = steps.filter(s => s.status === 'pending' || s.status === 'in_progress');
607
- const resolvedFromReview = steps.filter(s => s.resolved_from === 'needs_review' && s.status !== 'done').length;
608
-
609
- return {
610
- created: true,
611
- slug: uniqueSlug,
612
- goal,
613
- project_root: projectRoot,
614
- task_file: mdPath,
615
- scan_summary,
616
- total_steps: steps.length,
617
- done_already: steps.filter(s => s.status === 'done').length,
618
- gaps: pendingSteps.length,
619
- resolved_from_needs_review: resolvedFromReview || undefined,
620
- blocked: steps.filter(s => s.status === 'blocked').length,
621
- current_step: summary.current_step,
622
- user_review: userReviewWarnings.length ? userReviewWarnings : undefined,
623
- bare_minimum,
624
- steps,
625
- tip: `Task file written to ${mdPath}. Call get_active_task to resume. Call update_task_step to mark progress.`,
626
- };
627
- }
628
-
629
- // ─── Tool: update_task_step ──────────────────────────────────────────────────
630
-
631
- function handle_update_task_step({ step_index, status, note, plan_slug } = {}) {
632
- const catalystRoot = findCatalystRoot();
633
- if (!catalystRoot) return { error: 'No catalyst-core project found.' };
634
-
635
- const projectRoot = catalystRoot.dir;
636
-
637
- // Resolve plan
638
- let row = plan_slug
639
- ? _db.prepare(`SELECT * FROM task_plans WHERE slug=?`).get(plan_slug)
640
- : getActivePlan(projectRoot);
641
-
642
- if (!row) return { error: 'No active task plan found. Call create_task_plan first.' };
643
-
644
- const plan = parsePlan(row);
645
- const steps = plan.steps;
646
-
647
- if (step_index == null || step_index < 0 || step_index >= steps.length) {
648
- return { error: `step_index out of range. Plan has ${steps.length} steps (0-based).` };
649
- }
650
-
651
- const validStatuses = ['done', 'blocked', 'skipped', 'in_progress', 'pending'];
652
- const newStatus = status || 'done';
653
- if (!validStatuses.includes(newStatus)) {
654
- return { error: `Invalid status "${newStatus}". Use: ${validStatuses.join(' | ')}` };
655
- }
656
-
657
- // Update the step
658
- steps[step_index].status = newStatus;
659
- steps[step_index].note = note || steps[step_index].note;
660
- steps[step_index].updated_at = now();
661
-
662
- // Auto-advance: if marking done, set next pending step to in_progress
663
- let next_step = null;
664
- if (newStatus === 'done' || newStatus === 'skipped') {
665
- const nextPending = steps.find(s => s.index > step_index && s.status === 'pending');
666
- if (nextPending) {
667
- nextPending.status = 'in_progress';
668
- nextPending.updated_at = now();
669
- next_step = nextPending;
670
- }
671
- }
672
-
673
- // Check if all steps are terminal
674
- const allDone = steps.every(s => ['done', 'skipped', 'blocked'].includes(s.status));
675
- const planStatus = allDone ? 'completed' : 'active';
676
-
677
- _db.prepare(`
678
- UPDATE task_plans SET steps=?, status=?, updated_at=? WHERE id=?
679
- `).run(JSON.stringify(steps), planStatus, now(), row.id);
680
-
681
- // Sync MD file
682
- updateMdFile(plan.project_root, plan.slug, steps);
683
- if (note) appendToMdFindings(plan.project_root, plan.slug, note);
684
-
685
- const summary = summarisePlan({ steps });
686
-
687
- const result = {
688
- updated: true,
689
- step_index,
690
- new_status: newStatus,
691
- note: note || null,
692
- plan_status: planStatus,
693
- progress: `${summary.done}/${summary.total} done`,
694
- next_step,
695
- all_steps: steps,
696
- };
697
-
698
- if (allDone) {
699
- result.next_tool_call = {
700
- tool: 'close_task_plan',
701
- reason: `All ${summary.total} steps complete. You MUST call close_task_plan now. Ask the user: "Task complete — should I delete the task file at .mcp_tasks/${plan.slug}.md? (yes/no)" then call close_task_plan with delete_file:true or delete_file:false based on their answer.`,
702
- };
703
- }
704
-
705
- return result;
706
- }
707
-
708
- // ─── Tool: get_active_task ───────────────────────────────────────────────────
709
-
710
- function handle_get_active_task({ include_all_steps } = {}) {
711
- const catalystRoot = findCatalystRoot();
712
- if (!catalystRoot) return { error: 'No catalyst-core project found.' };
713
-
714
- const projectRoot = catalystRoot.dir;
715
- const row = getActivePlan(projectRoot);
716
-
717
- if (!row) {
718
- // Also check if there's a recently completed plan
719
- const last = _db.prepare(`
720
- SELECT * FROM task_plans WHERE project_root=?
721
- ORDER BY updated_at DESC LIMIT 1
722
- `).get(projectRoot);
723
-
724
- return {
725
- active_plan: null,
726
- last_plan: last ? {
727
- slug: last.slug,
728
- goal: last.goal,
729
- status: last.status,
730
- updated_at: last.updated_at,
731
- } : null,
732
- message: last
733
- ? `No active plan. Last plan "${last.goal}" is ${last.status}. Call create_task_plan to start a new one.`
734
- : 'No task plans found for this project. Call create_task_plan to start.',
735
- };
736
- }
737
-
738
- const plan = parsePlan(row);
739
- const summary = summarisePlan(plan);
740
-
741
- // By default only show non-done steps to keep response tight
742
- const visibleSteps = include_all_steps
743
- ? plan.steps
744
- : plan.steps.filter(s => s.status !== 'done' && s.status !== 'skipped');
745
-
746
- return {
747
- active_plan: true,
748
- slug: plan.slug,
749
- goal: plan.goal,
750
- project_root: plan.project_root,
751
- task_file: getMdPath(plan.project_root, plan.slug),
752
- created_at: plan.created_at,
753
- updated_at: plan.updated_at,
754
- progress: `${summary.done}/${summary.total} done`,
755
- blocked: summary.blocked > 0 ? summary.blocked : undefined,
756
- current_step: summary.current_step,
757
- pending_steps: visibleSteps,
758
- tip: include_all_steps ? undefined : 'Pass include_all_steps:true to see completed steps too.',
759
- };
760
- }
761
-
762
- // ─── Tool: close_task_plan ───────────────────────────────────────────────────
763
-
764
- function handle_close_task_plan({ delete_file = false, plan_slug } = {}) {
765
- const catalystRoot = findCatalystRoot();
766
- if (!catalystRoot) return { error: 'No catalyst-core project found.' };
767
-
768
- const projectRoot = catalystRoot.dir;
769
-
770
- const row = plan_slug
771
- ? _db.prepare(`SELECT * FROM task_plans WHERE slug=?`).get(plan_slug)
772
- : getActivePlan(projectRoot) || _db.prepare(`
773
- SELECT * FROM task_plans WHERE project_root=? AND status='completed'
774
- ORDER BY updated_at DESC LIMIT 1
775
- `).get(projectRoot);
776
-
777
- if (!row) return { error: 'No active or recently completed task plan found.' };
778
-
779
- const plan = parsePlan(row);
780
- const steps = plan.steps;
781
- const incomplete = steps.filter(s => !['done', 'skipped', 'blocked'].includes(s.status));
782
-
783
- if (incomplete.length > 0) {
784
- return {
785
- error: 'Plan has incomplete steps. Mark all steps done before closing.',
786
- incomplete: incomplete.map(s => ({ index: s.index, title: s.title, status: s.status })),
787
- tip: 'Use update_task_step to mark remaining steps, or pass status:"skipped" to skip them.',
788
- };
789
- }
790
-
791
- // Mark DB record as closed
792
- _db.prepare(`UPDATE task_plans SET status='closed', updated_at=? WHERE id=?`)
793
- .run(now(), row.id);
794
-
795
- const mdPath = getMdPath(projectRoot, plan.slug);
796
- let file_deleted = false;
797
-
798
- if (delete_file) {
799
- if (fs.existsSync(mdPath)) {
800
- fs.unlinkSync(mdPath);
801
- file_deleted = true;
802
- // Clean up .mcp_tasks/ dir if empty
803
- const dir = path.dirname(mdPath);
804
- try {
805
- if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
806
- } catch { /* ignore */ }
807
- }
808
- } else {
809
- // Update MD status to done
810
- if (fs.existsSync(mdPath)) {
811
- let content = fs.readFileSync(mdPath, 'utf8');
812
- content = content.replace('**Status:** in_progress', `**Status:** done`);
813
- content = content.replace(
814
- '## Findings\n<!-- APPEND ONLY — never edit above this line -->',
815
- `## Findings\n<!-- APPEND ONLY — never edit above this line -->\n- ${now()} | Task closed. All steps complete.`
816
- );
817
- fs.writeFileSync(mdPath, content, 'utf8');
818
- }
819
- }
820
-
821
- const done = steps.filter(s => s.status === 'done').length;
822
- const skipped = steps.filter(s => s.status === 'skipped').length;
823
- const blocked = steps.filter(s => s.status === 'blocked').length;
824
-
825
- return {
826
- closed: true,
827
- slug: plan.slug,
828
- goal: plan.goal,
829
- summary: `${done} done, ${skipped} skipped, ${blocked} blocked of ${steps.length} total.`,
830
- file_deleted,
831
- task_file: file_deleted ? null : mdPath,
832
- message: file_deleted
833
- ? `Plan closed and task file deleted.`
834
- : `Plan closed. Task file kept at ${mdPath}. Delete .mcp_tasks/ manually when done reviewing.`,
835
- };
836
- }
837
-
838
- module.exports = {
839
- init,
840
- handle_create_task_plan,
841
- handle_update_task_step,
842
- handle_get_active_task,
843
- handle_close_task_plan,
844
- };