catalyst-core-internal 0.1.4 → 0.1.6

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