@luanpdd/kit-mcp 1.20.0 → 1.21.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.
Files changed (259) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +648 -648
  3. package/gates/dept-cycle-prevention.md +179 -0
  4. package/gates/multi-tenant-rls-coverage.md +102 -0
  5. package/gates/service-role-not-in-user-facing.md +113 -0
  6. package/kit/COMANDOS.md +138 -138
  7. package/kit/README.md +52 -52
  8. package/kit/agents/advisor-researcher.md +106 -106
  9. package/kit/agents/assumptions-analyzer.md +107 -107
  10. package/kit/agents/audit-log-implementer.md +175 -0
  11. package/kit/agents/b2b-saas-architect.md +156 -0
  12. package/kit/agents/codebase-mapper.md +768 -768
  13. package/kit/agents/crm-pipeline-implementer.md +150 -0
  14. package/kit/agents/debugger.md +772 -772
  15. package/kit/agents/evolution-go-integrator.md +179 -0
  16. package/kit/agents/example-reviewer.md +21 -21
  17. package/kit/agents/executor.md +523 -523
  18. package/kit/agents/integration-checker.md +200 -200
  19. package/kit/agents/invite-flow-implementer.md +137 -0
  20. package/kit/agents/lgpd-compliance-auditor.md +206 -0
  21. package/kit/agents/multi-tenant-isolation-auditor.md +243 -0
  22. package/kit/agents/multi-tenant-rls-writer.md +262 -0
  23. package/kit/agents/nyquist-auditor.md +178 -178
  24. package/kit/agents/org-onboarding-implementer.md +202 -0
  25. package/kit/agents/phase-researcher.md +696 -696
  26. package/kit/agents/plan-checker.md +272 -272
  27. package/kit/agents/planner.md +891 -891
  28. package/kit/agents/project-researcher.md +652 -652
  29. package/kit/agents/research-synthesizer.md +245 -245
  30. package/kit/agents/roadmapper.md +677 -677
  31. package/kit/agents/super-admin-implementer.md +182 -0
  32. package/kit/agents/ui-auditor.md +437 -437
  33. package/kit/agents/ui-checker.md +302 -302
  34. package/kit/agents/ui-researcher.md +355 -355
  35. package/kit/agents/user-profiler.md +175 -175
  36. package/kit/agents/verifier.md +728 -728
  37. package/kit/commands/adicionar-backlog.md +75 -75
  38. package/kit/commands/adicionar-fase.md +42 -42
  39. package/kit/commands/adicionar-tarefa.md +45 -45
  40. package/kit/commands/adicionar-testes.md +41 -41
  41. package/kit/commands/ajuda.md +21 -21
  42. package/kit/commands/atualizar.md +37 -37
  43. package/kit/commands/auditar-marco.md +179 -179
  44. package/kit/commands/auditar-uat.md +23 -23
  45. package/kit/commands/autonomo.md +40 -40
  46. package/kit/commands/branch-pr.md +24 -24
  47. package/kit/commands/concluir-marco.md +247 -247
  48. package/kit/commands/configuracoes.md +36 -36
  49. package/kit/commands/definir-perfil.md +10 -10
  50. package/kit/commands/depurar.md +190 -190
  51. package/kit/commands/discutir-fase.md +131 -131
  52. package/kit/commands/entrar-discord.md +17 -17
  53. package/kit/commands/estatisticas.md +18 -18
  54. package/kit/commands/example-greeting.md +33 -33
  55. package/kit/commands/executar-fase.md +58 -58
  56. package/kit/commands/expresso.md +56 -56
  57. package/kit/commands/fase-ui.md +34 -34
  58. package/kit/commands/fazer.md +57 -57
  59. package/kit/commands/fio.md +125 -125
  60. package/kit/commands/fluxos-trabalho.md +64 -64
  61. package/kit/commands/forense.md +176 -176
  62. package/kit/commands/gerenciador.md +38 -38
  63. package/kit/commands/inserir-fase.md +31 -31
  64. package/kit/commands/limpeza.md +17 -17
  65. package/kit/commands/listar-hipoteses-fase.md +45 -45
  66. package/kit/commands/listar-workspaces.md +18 -18
  67. package/kit/commands/mapear-codebase.md +70 -70
  68. package/kit/commands/multi-tenant.md +163 -0
  69. package/kit/commands/nota.md +33 -33
  70. package/kit/commands/novo-marco.md +43 -43
  71. package/kit/commands/novo-projeto.md +41 -41
  72. package/kit/commands/novo-workspace.md +43 -43
  73. package/kit/commands/pausar-trabalho.md +37 -37
  74. package/kit/commands/perfil-usuario.md +45 -45
  75. package/kit/commands/pesquisar-fase.md +195 -195
  76. package/kit/commands/planejar-fase.md +67 -67
  77. package/kit/commands/planejar-lacunas.md +33 -33
  78. package/kit/commands/plantar-ideia.md +25 -25
  79. package/kit/commands/progresso.md +24 -24
  80. package/kit/commands/proximo.md +30 -30
  81. package/kit/commands/publicar.md +490 -490
  82. package/kit/commands/rapido.md +35 -35
  83. package/kit/commands/reaplicar-patches.md +124 -124
  84. package/kit/commands/relatorio-sessao.md +19 -19
  85. package/kit/commands/remover-fase.md +31 -31
  86. package/kit/commands/remover-workspace.md +26 -26
  87. package/kit/commands/resumo-marco.md +50 -50
  88. package/kit/commands/retomar-trabalho.md +40 -40
  89. package/kit/commands/revisar-backlog.md +60 -60
  90. package/kit/commands/revisar-ui.md +32 -32
  91. package/kit/commands/revisar.md +37 -37
  92. package/kit/commands/saude.md +21 -21
  93. package/kit/commands/setup-notion.md +93 -93
  94. package/kit/commands/sync-main.md +68 -68
  95. package/kit/commands/validar-fase.md +35 -35
  96. package/kit/commands/verificar-tarefas.md +44 -44
  97. package/kit/commands/verificar-trabalho.md +64 -64
  98. package/kit/file-manifest.json +30 -3
  99. package/kit/framework/bin/lib/commands.cjs +959 -959
  100. package/kit/framework/bin/lib/config.cjs +442 -442
  101. package/kit/framework/bin/lib/core.cjs +1230 -1230
  102. package/kit/framework/bin/lib/frontmatter.cjs +336 -336
  103. package/kit/framework/bin/lib/init.cjs +1442 -1442
  104. package/kit/framework/bin/lib/milestone.cjs +252 -252
  105. package/kit/framework/bin/lib/model-profiles.cjs +68 -68
  106. package/kit/framework/bin/lib/phase.cjs +888 -888
  107. package/kit/framework/bin/lib/profile-output.cjs +952 -952
  108. package/kit/framework/bin/lib/profile-pipeline.cjs +539 -539
  109. package/kit/framework/bin/lib/roadmap.cjs +329 -329
  110. package/kit/framework/bin/lib/security.cjs +382 -382
  111. package/kit/framework/bin/lib/state.cjs +1031 -1031
  112. package/kit/framework/bin/lib/template.cjs +222 -222
  113. package/kit/framework/bin/lib/uat.cjs +282 -282
  114. package/kit/framework/bin/lib/verify.cjs +888 -888
  115. package/kit/framework/bin/lib/workstream.cjs +491 -491
  116. package/kit/framework/bin/tools.cjs +918 -918
  117. package/kit/framework/commands/workstreams.md +63 -63
  118. package/kit/framework/references/checkpoints.md +778 -778
  119. package/kit/framework/references/continuation-format.md +249 -249
  120. package/kit/framework/references/decimal-phase-calculation.md +64 -64
  121. package/kit/framework/references/git-integration.md +295 -295
  122. package/kit/framework/references/git-planning-commit.md +38 -38
  123. package/kit/framework/references/model-profile-resolution.md +36 -36
  124. package/kit/framework/references/model-profiles.md +139 -139
  125. package/kit/framework/references/phase-argument-parsing.md +61 -61
  126. package/kit/framework/references/planning-config.md +202 -202
  127. package/kit/framework/references/questioning.md +162 -162
  128. package/kit/framework/references/tdd.md +263 -263
  129. package/kit/framework/references/ui-brand.md +160 -160
  130. package/kit/framework/references/user-profiling.md +657 -657
  131. package/kit/framework/references/verification-patterns.md +612 -612
  132. package/kit/framework/references/workstream-flag.md +58 -58
  133. package/kit/framework/templates/DEBUG.md +164 -164
  134. package/kit/framework/templates/UAT.md +265 -265
  135. package/kit/framework/templates/UI-SPEC.md +100 -100
  136. package/kit/framework/templates/VALIDATION.md +76 -76
  137. package/kit/framework/templates/claude-md.md +122 -122
  138. package/kit/framework/templates/codebase/architecture.md +185 -185
  139. package/kit/framework/templates/codebase/concerns.md +205 -205
  140. package/kit/framework/templates/codebase/conventions.md +204 -204
  141. package/kit/framework/templates/codebase/integrations.md +192 -192
  142. package/kit/framework/templates/codebase/stack.md +158 -158
  143. package/kit/framework/templates/codebase/structure.md +199 -199
  144. package/kit/framework/templates/codebase/testing.md +301 -301
  145. package/kit/framework/templates/config.json +44 -44
  146. package/kit/framework/templates/context.md +352 -352
  147. package/kit/framework/templates/continue-here.md +78 -78
  148. package/kit/framework/templates/copilot-instructions.md +7 -7
  149. package/kit/framework/templates/debug-subagent-prompt.md +91 -91
  150. package/kit/framework/templates/dev-preferences.md +20 -20
  151. package/kit/framework/templates/discovery.md +146 -146
  152. package/kit/framework/templates/discussion-log.md +63 -63
  153. package/kit/framework/templates/milestone-archive.md +123 -123
  154. package/kit/framework/templates/milestone.md +115 -115
  155. package/kit/framework/templates/phase-prompt.md +610 -610
  156. package/kit/framework/templates/planner-subagent-prompt.md +117 -117
  157. package/kit/framework/templates/project.md +186 -186
  158. package/kit/framework/templates/requirements.md +231 -231
  159. package/kit/framework/templates/research-project/ARCHITECTURE.md +204 -204
  160. package/kit/framework/templates/research-project/FEATURES.md +147 -147
  161. package/kit/framework/templates/research-project/PITFALLS.md +200 -200
  162. package/kit/framework/templates/research-project/STACK.md +120 -120
  163. package/kit/framework/templates/research-project/SUMMARY.md +170 -170
  164. package/kit/framework/templates/research.md +419 -419
  165. package/kit/framework/templates/retrospective.md +54 -54
  166. package/kit/framework/templates/roadmap.md +202 -202
  167. package/kit/framework/templates/state.md +176 -176
  168. package/kit/framework/templates/summary-complex.md +59 -59
  169. package/kit/framework/templates/summary-minimal.md +41 -41
  170. package/kit/framework/templates/summary-standard.md +48 -48
  171. package/kit/framework/templates/summary.md +209 -209
  172. package/kit/framework/templates/user-profile.md +146 -146
  173. package/kit/framework/templates/user-setup.md +256 -256
  174. package/kit/framework/templates/verification-report.md +258 -258
  175. package/kit/framework/workflows/add-phase.md +112 -112
  176. package/kit/framework/workflows/add-tests.md +351 -351
  177. package/kit/framework/workflows/add-todo.md +158 -158
  178. package/kit/framework/workflows/audit-milestone.md +340 -340
  179. package/kit/framework/workflows/audit-uat.md +109 -109
  180. package/kit/framework/workflows/autonomous.md +891 -891
  181. package/kit/framework/workflows/check-todos.md +177 -177
  182. package/kit/framework/workflows/cleanup.md +152 -152
  183. package/kit/framework/workflows/complete-milestone.md +696 -696
  184. package/kit/framework/workflows/diagnose-issues.md +231 -231
  185. package/kit/framework/workflows/discovery-phase.md +289 -289
  186. package/kit/framework/workflows/discuss-phase-assumptions.md +653 -653
  187. package/kit/framework/workflows/discuss-phase.md +784 -784
  188. package/kit/framework/workflows/do.md +104 -104
  189. package/kit/framework/workflows/execute-phase.md +838 -838
  190. package/kit/framework/workflows/execute-plan.md +510 -510
  191. package/kit/framework/workflows/fast.md +102 -102
  192. package/kit/framework/workflows/forensics.md +265 -265
  193. package/kit/framework/workflows/health.md +181 -181
  194. package/kit/framework/workflows/help.md +619 -619
  195. package/kit/framework/workflows/insert-phase.md +130 -130
  196. package/kit/framework/workflows/list-phase-assumptions.md +178 -178
  197. package/kit/framework/workflows/list-workspaces.md +56 -56
  198. package/kit/framework/workflows/manager.md +362 -362
  199. package/kit/framework/workflows/map-codebase.md +377 -377
  200. package/kit/framework/workflows/milestone-summary.md +223 -223
  201. package/kit/framework/workflows/new-milestone.md +486 -486
  202. package/kit/framework/workflows/new-project.md +1159 -1159
  203. package/kit/framework/workflows/new-workspace.md +237 -237
  204. package/kit/framework/workflows/next.md +97 -97
  205. package/kit/framework/workflows/node-repair.md +92 -92
  206. package/kit/framework/workflows/note.md +156 -156
  207. package/kit/framework/workflows/pause-work.md +176 -176
  208. package/kit/framework/workflows/plan-milestone-gaps.md +273 -273
  209. package/kit/framework/workflows/plan-phase.md +765 -765
  210. package/kit/framework/workflows/plant-seed.md +169 -169
  211. package/kit/framework/workflows/pr-branch.md +129 -129
  212. package/kit/framework/workflows/profile-user.md +450 -450
  213. package/kit/framework/workflows/progress.md +507 -507
  214. package/kit/framework/workflows/quick.md +757 -757
  215. package/kit/framework/workflows/remove-phase.md +155 -155
  216. package/kit/framework/workflows/remove-workspace.md +90 -90
  217. package/kit/framework/workflows/research-phase.md +82 -82
  218. package/kit/framework/workflows/resume-project.md +326 -326
  219. package/kit/framework/workflows/review.md +228 -228
  220. package/kit/framework/workflows/session-report.md +146 -146
  221. package/kit/framework/workflows/settings.md +283 -283
  222. package/kit/framework/workflows/ship.md +228 -228
  223. package/kit/framework/workflows/stats.md +60 -60
  224. package/kit/framework/workflows/transition.md +671 -671
  225. package/kit/framework/workflows/ui-phase.md +302 -302
  226. package/kit/framework/workflows/ui-review.md +165 -165
  227. package/kit/framework/workflows/update.md +323 -323
  228. package/kit/framework/workflows/validate-phase.md +174 -174
  229. package/kit/framework/workflows/verify-phase.md +252 -252
  230. package/kit/framework/workflows/verify-work.md +637 -637
  231. package/kit/hooks/check-update.js +118 -118
  232. package/kit/hooks/context-monitor.js +163 -163
  233. package/kit/hooks/prompt-guard.js +103 -103
  234. package/kit/hooks/statusline.js +125 -125
  235. package/kit/hooks/workflow-guard.js +101 -101
  236. package/kit/settings.json +45 -45
  237. package/kit/skills/_shared-multi-tenant/glossary.md +186 -0
  238. package/kit/skills/audit-log-multi-tenant/SKILL.md +334 -0
  239. package/kit/skills/b2b-saas-architecture/SKILL.md +300 -0
  240. package/kit/skills/crm-lead-pipeline-patterns/SKILL.md +326 -0
  241. package/kit/skills/evolution-go-whatsapp-integration/SKILL.md +322 -0
  242. package/kit/skills/example-skill/SKILL.md +42 -42
  243. package/kit/skills/lgpd-multi-tenant-compliance/SKILL.md +340 -0
  244. package/kit/skills/member-invite-flow/SKILL.md +305 -0
  245. package/kit/skills/member-management-react-shadcn/SKILL.md +328 -0
  246. package/kit/skills/multi-tenant-performance-scaling/SKILL.md +312 -0
  247. package/kit/skills/multi-tenant-rls-hierarchy/SKILL.md +338 -0
  248. package/kit/skills/org-onboarding-flow/SKILL.md +257 -0
  249. package/kit/skills/org-switcher-react-pattern/SKILL.md +349 -0
  250. package/kit/skills/permission-gate-react-pattern/SKILL.md +271 -0
  251. package/kit/skills/rbac-permissions-matrix-supabase/SKILL.md +301 -0
  252. package/kit/skills/super-admin-platform-pattern/SKILL.md +322 -0
  253. package/kit/skills/whatsapp-conversation-state-machine/SKILL.md +287 -0
  254. package/package.json +63 -63
  255. package/src/core/kit.js +216 -216
  256. package/src/core/reflect.js +247 -247
  257. package/src/core/reverse-sync.js +372 -372
  258. package/src/core/sync.js +418 -418
  259. package/src/core/watch.js +121 -121
@@ -1,372 +1,372 @@
1
- // Reverse-sync — bring edits made directly in an IDE's layout back into the
2
- // canonical kit/.
3
- //
4
- // Workflow:
5
- // detect(target) → list candidates: files modified or added in the IDE
6
- // that don't exist (or differ from) the canonical
7
- // apply(target, strategy) → for each candidate, apply: skip | overwrite | merge | rename
8
- //
9
- // We are conservative on purpose: detection is read-only, application requires
10
- // an explicit strategy, and we never touch files we generated ourselves
11
- // (those carry STUB_MARKER and the boilerplate footer).
12
-
13
- import path from 'node:path';
14
- import fs from 'node:fs/promises';
15
- import { getTarget } from './registry.js';
16
- import { listKit, resolveKitRoot } from './kit.js';
17
-
18
- const STUB_MARKER = '<!-- kit-mcp:reference -->';
19
- const STUB_FOOTER = 'Edit the source file in the kit, not this stub.';
20
- const STUB_GENERATED = 'Generated by kit-mcp at';
21
- const STUB_CANONICAL = 'Canonical source:';
22
-
23
- // --- detect ---
24
-
25
- export async function detectReverse(targetId, opts = {}) {
26
- const target = getTarget(targetId);
27
- const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
28
- const kitRoot = resolveKitRoot(opts.kitRoot);
29
- // PERF-03: accept a pre-loaded kit; reduces sync+reverse-sync from 2 walks to 1.
30
- const kit = opts.kit ?? await listKit(kitRoot);
31
-
32
- const candidates = [];
33
-
34
- // PERF-16-03: parallelize the 5 scans. Each scan reads a distinct subdirectory
35
- // of the IDE layout (.claude/agents, .claude/commands, .claude/skills,
36
- // .claude/framework, .claude/hooks) — there is no I/O contention between them.
37
- //
38
- // Each scan continues to push into the shared `candidates` array. This is safe
39
- // under the single-threaded JS event loop: `Array.prototype.push` is a
40
- // synchronous operation that completes between awaits, so concurrent scans
41
- // never produce a torn write. The trade-off is that candidate ordering is no
42
- // longer deterministic across categories — existing reverse-sync tests use
43
- // `.find` / `.some` / `.filter` and never index `candidates[N]`, so this is a
44
- // safe widening of the contract.
45
- //
46
- // Error semantics: `Promise.all` rejects on the first rejection — identical to
47
- // the previous sequential `await` chain (which also propagated the first error
48
- // and aborted the rest). Fail-fast is preserved.
49
- const pending = [];
50
-
51
- if (target.agents) pending.push(scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot));
52
- if (target.commands) pending.push(scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot));
53
- if (target.skills) pending.push(scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot));
54
- for (const cap of ['framework', 'hooks']) {
55
- const spec = target[cap];
56
- if (!spec || spec.mode !== 'mirror-tree') continue;
57
- pending.push(scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot));
58
- }
59
-
60
- await Promise.all(pending);
61
-
62
- return { target: targetId, projectRoot, kitRoot, candidates };
63
- }
64
-
65
- async function scanCapability(candidates, kind, capCfg, projectRoot, kitItems, kitRoot) {
66
- const dir = path.join(projectRoot, capCfg.path);
67
- let entries;
68
- try { entries = await fs.readdir(dir, { withFileTypes: true }); }
69
- catch { return; }
70
-
71
- for (const e of entries) {
72
- if (!e.isFile()) continue;
73
- const ext = capCfg.extension || '.md';
74
- if (!e.name.endsWith(ext)) continue;
75
- const name = e.name.slice(0, -ext.length);
76
- const destPath = path.join(dir, e.name);
77
- const destContent = await fs.readFile(destPath, 'utf8');
78
-
79
- if (isCleanStub(destContent)) continue; // we generated it, not edited
80
-
81
- const kitItem = kitItems.find(x => x.name === name);
82
- if (!kitItem) {
83
- candidates.push({
84
- kind, name, target: capCfg.path, destPath,
85
- kitPath: path.join(kitRoot, kindToFolder(kind), `${name}.md`),
86
- reason: 'new-in-ide',
87
- diffSummary: `+${destContent.length} bytes (no kit source)`,
88
- });
89
- continue;
90
- }
91
-
92
- const stripped = stripStubBoilerplate(destContent);
93
- if (normalize(stripped) === normalize(kitItem.content)) continue; // same as canonical
94
-
95
- candidates.push({
96
- kind, name, target: capCfg.path, destPath,
97
- kitPath: kitItem.absPath,
98
- reason: 'modified-in-ide',
99
- diffSummary: summarizeDiff(kitItem.content, stripped),
100
- });
101
- }
102
- }
103
-
104
- async function scanSkills(candidates, capCfg, projectRoot, kitSkills, kitRoot) {
105
- const dir = path.join(projectRoot, capCfg.path);
106
- let entries;
107
- try { entries = await fs.readdir(dir, { withFileTypes: true }); }
108
- catch { return; }
109
-
110
- for (const e of entries) {
111
- if (!e.isDirectory()) continue;
112
- const skillName = e.name;
113
- const skillFile = path.join(dir, skillName, 'SKILL.md');
114
- let destContent;
115
- try { destContent = await fs.readFile(skillFile, 'utf8'); }
116
- catch { continue; }
117
-
118
- if (isCleanStub(destContent)) continue;
119
-
120
- const kitItem = kitSkills.find(x => x.name === skillName);
121
- if (!kitItem) {
122
- candidates.push({
123
- kind: 'skill', name: skillName, target: capCfg.path, destPath: skillFile,
124
- kitPath: path.join(kitRoot, 'skills', skillName, 'SKILL.md'),
125
- reason: 'new-in-ide',
126
- diffSummary: `+${destContent.length} bytes (no kit source)`,
127
- });
128
- continue;
129
- }
130
-
131
- const stripped = stripStubBoilerplate(destContent);
132
- if (normalize(stripped) === normalize(kitItem.skillContent)) continue;
133
-
134
- candidates.push({
135
- kind: 'skill', name: skillName, target: capCfg.path, destPath: skillFile,
136
- kitPath: kitItem.absPath,
137
- reason: 'modified-in-ide',
138
- diffSummary: summarizeDiff(kitItem.skillContent, stripped),
139
- });
140
- }
141
- }
142
-
143
- async function scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot) {
144
- const dstRoot = path.join(projectRoot, spec.path);
145
- const srcRoot = path.join(kitRoot, spec.source);
146
- const files = await walkRel(dstRoot);
147
- for (const rel of files) {
148
- if (rel === '.kit-mcp-managed' || path.basename(rel) === '.kit-mcp-managed') continue;
149
- const dstPath = path.join(dstRoot, rel);
150
- const srcPath = path.join(srcRoot, rel);
151
- let dstBuf, srcBuf;
152
- try { dstBuf = await fs.readFile(dstPath); } catch { continue; }
153
- try { srcBuf = await fs.readFile(srcPath); } catch { srcBuf = null; }
154
- if (!srcBuf) {
155
- candidates.push({
156
- kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath,
157
- reason: 'new-in-ide',
158
- diffSummary: `+${dstBuf.length} bytes (no kit source)`,
159
- });
160
- continue;
161
- }
162
- if (dstBuf.equals(srcBuf)) continue;
163
- candidates.push({
164
- kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath,
165
- reason: 'modified-in-ide',
166
- diffSummary: `${dstBuf.length} bytes vs ${srcBuf.length} canonical (${dstBuf.length - srcBuf.length >= 0 ? '+' : ''}${dstBuf.length - srcBuf.length})`,
167
- });
168
- }
169
- }
170
-
171
- async function walkRel(root) {
172
- const out = [];
173
- async function visit(current, prefix) {
174
- let entries;
175
- try { entries = await fs.readdir(current, { withFileTypes: true }); }
176
- catch { return; }
177
- for (const e of entries) {
178
- const abs = path.join(current, e.name);
179
- const rel = prefix ? `${prefix}/${e.name}` : e.name;
180
- if (e.isDirectory()) await visit(abs, rel);
181
- else if (e.isFile()) out.push(rel);
182
- }
183
- }
184
- await visit(root, '');
185
- return out;
186
- }
187
-
188
- // --- apply ---
189
-
190
- export async function applyReverse(targetId, opts = {}) {
191
- const strategy = opts.strategy ?? 'skip';
192
- const onProgress = opts.onProgress ?? (() => {});
193
- const { candidates } = await detectReverse(targetId, opts);
194
- const results = [];
195
-
196
- for (let i = 0; i < candidates.length; i++) {
197
- const c = candidates[i];
198
- if (opts.only && !opts.only.includes(`${c.kind}/${c.name}`)) {
199
- results.push({ ...c, action: 'skipped (filter)' });
200
- onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
201
- continue;
202
- }
203
-
204
- const action = await applyOne(c, strategy, opts);
205
- results.push({ ...c, action });
206
- onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
207
- }
208
-
209
- return { target: targetId, strategy, results };
210
- }
211
-
212
- async function applyOne(c, strategy, opts) {
213
- const dryRun = !!opts.dryRun;
214
- const isMirrorTree = c.kind === 'framework' || c.kind === 'hooks';
215
-
216
- // Mirror-tree files don't have stub boilerplate — copy bytes verbatim.
217
- if (isMirrorTree) {
218
- return applyMirrorTreeOne(c, strategy, dryRun);
219
- }
220
-
221
- const destContent = await fs.readFile(c.destPath, 'utf8');
222
- const stripped = stripStubBoilerplate(destContent);
223
-
224
- switch (strategy) {
225
- case 'skip':
226
- return 'skipped';
227
-
228
- case 'overwrite': {
229
- if (!dryRun) {
230
- await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
231
- await fs.writeFile(c.kitPath, stripped, 'utf8');
232
- }
233
- return dryRun ? 'overwrite (dry-run)' : 'overwritten';
234
- }
235
-
236
- case 'merge': {
237
- let merged = stripped;
238
- if (c.reason === 'modified-in-ide') {
239
- try {
240
- const canonical = await fs.readFile(c.kitPath, 'utf8');
241
- merged = mergeFrontmatter(canonical, stripped);
242
- } catch { /* canonical missing → just take stripped */ }
243
- }
244
- if (!dryRun) {
245
- await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
246
- await fs.writeFile(c.kitPath, merged, 'utf8');
247
- }
248
- return dryRun ? 'merge (dry-run)' : 'merged';
249
- }
250
-
251
- case 'rename': {
252
- const base = c.kitPath.replace(/\.md$/, '');
253
- const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '');
254
- const out = `${base}-from-${tag || 'ide'}.md`;
255
- if (!dryRun) {
256
- await fs.mkdir(path.dirname(out), { recursive: true });
257
- await fs.writeFile(out, stripped, 'utf8');
258
- }
259
- return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`;
260
- }
261
-
262
- default:
263
- return `unknown strategy: ${strategy}`;
264
- }
265
- }
266
-
267
- async function applyMirrorTreeOne(c, strategy, dryRun) {
268
- switch (strategy) {
269
- case 'skip':
270
- return 'skipped';
271
-
272
- case 'overwrite':
273
- case 'merge': {
274
- // For framework/hooks files there's no frontmatter to preserve,
275
- // so 'merge' degenerates to overwrite. Returning a verb that
276
- // signals the degradation.
277
- const verb = strategy === 'merge' ? 'merged (overwrite, no frontmatter)' : 'overwritten';
278
- if (!dryRun) {
279
- await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
280
- await fs.copyFile(c.destPath, c.kitPath);
281
- }
282
- return dryRun ? `${strategy} (dry-run)` : verb;
283
- }
284
-
285
- case 'rename': {
286
- // Write to kit/<source>/<rel>.from-<tag> preserving extension after the tag.
287
- const ext = path.extname(c.kitPath);
288
- const stem = c.kitPath.slice(0, c.kitPath.length - ext.length);
289
- const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '') || 'ide';
290
- const out = `${stem}.from-${tag}${ext}`;
291
- if (!dryRun) {
292
- await fs.mkdir(path.dirname(out), { recursive: true });
293
- await fs.copyFile(c.destPath, out);
294
- }
295
- return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`;
296
- }
297
-
298
- default:
299
- return `unknown strategy: ${strategy}`;
300
- }
301
- }
302
-
303
- // --- helpers ---
304
-
305
- function isCleanStub(content) {
306
- // A "clean" stub has all four markers. If the user removed any (likely by editing),
307
- // we treat it as user content.
308
- return content.includes(STUB_MARKER) &&
309
- content.includes(STUB_CANONICAL) &&
310
- content.includes(STUB_GENERATED) &&
311
- content.includes(STUB_FOOTER);
312
- }
313
-
314
- function stripStubBoilerplate(content) {
315
- // Remove the kit-mcp boilerplate so we can compare against the canonical.
316
- // This handles partially-edited stubs (user kept some markers but added body).
317
- if (!content.includes(STUB_MARKER)) return content;
318
-
319
- const lines = content.split(/\r?\n/);
320
- const filtered = [];
321
- let inBoilerplate = false;
322
- for (const line of lines) {
323
- if (line.includes(STUB_MARKER)) { inBoilerplate = true; continue; }
324
- if (inBoilerplate) {
325
- // Boilerplate ends after we've consumed the auto-generated header block
326
- if (/^>\s*Edit the source file/.test(line)) { inBoilerplate = false; continue; }
327
- // Also skip the "# name", "> Canonical source:", description and timestamp lines
328
- if (/^#\s+\S+\s*$/.test(line) ||
329
- /^>\s*Canonical source:/.test(line) ||
330
- /^>\s*Generated by kit-mcp at/.test(line) ||
331
- /^>\s*\S/.test(line) ||
332
- line.trim() === '') {
333
- continue;
334
- }
335
- // First non-boilerplate line — flush and stop skipping
336
- inBoilerplate = false;
337
- }
338
- filtered.push(line);
339
- }
340
- return filtered.join('\n').replace(/^\s+/, ''); // drop leading blank lines
341
- }
342
-
343
- function normalize(content) {
344
- return content.replace(/\s+/g, ' ').trim();
345
- }
346
-
347
- function summarizeDiff(canonical, edited) {
348
- const cLines = canonical.split(/\r?\n/).length;
349
- const eLines = edited.split(/\r?\n/).length;
350
- const delta = eLines - cLines;
351
- const pct = canonical.length === 0 ? 100 : Math.round((edited.length - canonical.length) / canonical.length * 100);
352
- return `${eLines} lines (${delta >= 0 ? '+' : ''}${delta}); ${pct >= 0 ? '+' : ''}${pct}% size`;
353
- }
354
-
355
- function mergeFrontmatter(canonical, edited) {
356
- // Take the canonical frontmatter (has the formal metadata like tools, color, hooks)
357
- // and append the edited body (everything after the first --- block, or the whole
358
- // edited content if it has no frontmatter).
359
- const fmMatch = canonical.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)/);
360
- if (!fmMatch) return edited;
361
-
362
- const editedBody = edited.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
363
- const body = editedBody ? editedBody[1] : edited;
364
- return fmMatch[1] + body;
365
- }
366
-
367
- function kindToFolder(kind) {
368
- if (kind === 'agent') return 'agents';
369
- if (kind === 'command') return 'commands';
370
- if (kind === 'skill') return 'skills';
371
- return kind;
372
- }
1
+ // Reverse-sync — bring edits made directly in an IDE's layout back into the
2
+ // canonical kit/.
3
+ //
4
+ // Workflow:
5
+ // detect(target) → list candidates: files modified or added in the IDE
6
+ // that don't exist (or differ from) the canonical
7
+ // apply(target, strategy) → for each candidate, apply: skip | overwrite | merge | rename
8
+ //
9
+ // We are conservative on purpose: detection is read-only, application requires
10
+ // an explicit strategy, and we never touch files we generated ourselves
11
+ // (those carry STUB_MARKER and the boilerplate footer).
12
+
13
+ import path from 'node:path';
14
+ import fs from 'node:fs/promises';
15
+ import { getTarget } from './registry.js';
16
+ import { listKit, resolveKitRoot } from './kit.js';
17
+
18
+ const STUB_MARKER = '<!-- kit-mcp:reference -->';
19
+ const STUB_FOOTER = 'Edit the source file in the kit, not this stub.';
20
+ const STUB_GENERATED = 'Generated by kit-mcp at';
21
+ const STUB_CANONICAL = 'Canonical source:';
22
+
23
+ // --- detect ---
24
+
25
+ export async function detectReverse(targetId, opts = {}) {
26
+ const target = getTarget(targetId);
27
+ const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
28
+ const kitRoot = resolveKitRoot(opts.kitRoot);
29
+ // PERF-03: accept a pre-loaded kit; reduces sync+reverse-sync from 2 walks to 1.
30
+ const kit = opts.kit ?? await listKit(kitRoot);
31
+
32
+ const candidates = [];
33
+
34
+ // PERF-16-03: parallelize the 5 scans. Each scan reads a distinct subdirectory
35
+ // of the IDE layout (.claude/agents, .claude/commands, .claude/skills,
36
+ // .claude/framework, .claude/hooks) — there is no I/O contention between them.
37
+ //
38
+ // Each scan continues to push into the shared `candidates` array. This is safe
39
+ // under the single-threaded JS event loop: `Array.prototype.push` is a
40
+ // synchronous operation that completes between awaits, so concurrent scans
41
+ // never produce a torn write. The trade-off is that candidate ordering is no
42
+ // longer deterministic across categories — existing reverse-sync tests use
43
+ // `.find` / `.some` / `.filter` and never index `candidates[N]`, so this is a
44
+ // safe widening of the contract.
45
+ //
46
+ // Error semantics: `Promise.all` rejects on the first rejection — identical to
47
+ // the previous sequential `await` chain (which also propagated the first error
48
+ // and aborted the rest). Fail-fast is preserved.
49
+ const pending = [];
50
+
51
+ if (target.agents) pending.push(scanCapability(candidates, 'agent', target.agents, projectRoot, kit.agents, kitRoot));
52
+ if (target.commands) pending.push(scanCapability(candidates, 'command', target.commands, projectRoot, kit.commands, kitRoot));
53
+ if (target.skills) pending.push(scanSkills (candidates, target.skills, projectRoot, [...kit.skills, ...kit.skillsExtras], kitRoot));
54
+ for (const cap of ['framework', 'hooks']) {
55
+ const spec = target[cap];
56
+ if (!spec || spec.mode !== 'mirror-tree') continue;
57
+ pending.push(scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot));
58
+ }
59
+
60
+ await Promise.all(pending);
61
+
62
+ return { target: targetId, projectRoot, kitRoot, candidates };
63
+ }
64
+
65
+ async function scanCapability(candidates, kind, capCfg, projectRoot, kitItems, kitRoot) {
66
+ const dir = path.join(projectRoot, capCfg.path);
67
+ let entries;
68
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
69
+ catch { return; }
70
+
71
+ for (const e of entries) {
72
+ if (!e.isFile()) continue;
73
+ const ext = capCfg.extension || '.md';
74
+ if (!e.name.endsWith(ext)) continue;
75
+ const name = e.name.slice(0, -ext.length);
76
+ const destPath = path.join(dir, e.name);
77
+ const destContent = await fs.readFile(destPath, 'utf8');
78
+
79
+ if (isCleanStub(destContent)) continue; // we generated it, not edited
80
+
81
+ const kitItem = kitItems.find(x => x.name === name);
82
+ if (!kitItem) {
83
+ candidates.push({
84
+ kind, name, target: capCfg.path, destPath,
85
+ kitPath: path.join(kitRoot, kindToFolder(kind), `${name}.md`),
86
+ reason: 'new-in-ide',
87
+ diffSummary: `+${destContent.length} bytes (no kit source)`,
88
+ });
89
+ continue;
90
+ }
91
+
92
+ const stripped = stripStubBoilerplate(destContent);
93
+ if (normalize(stripped) === normalize(kitItem.content)) continue; // same as canonical
94
+
95
+ candidates.push({
96
+ kind, name, target: capCfg.path, destPath,
97
+ kitPath: kitItem.absPath,
98
+ reason: 'modified-in-ide',
99
+ diffSummary: summarizeDiff(kitItem.content, stripped),
100
+ });
101
+ }
102
+ }
103
+
104
+ async function scanSkills(candidates, capCfg, projectRoot, kitSkills, kitRoot) {
105
+ const dir = path.join(projectRoot, capCfg.path);
106
+ let entries;
107
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
108
+ catch { return; }
109
+
110
+ for (const e of entries) {
111
+ if (!e.isDirectory()) continue;
112
+ const skillName = e.name;
113
+ const skillFile = path.join(dir, skillName, 'SKILL.md');
114
+ let destContent;
115
+ try { destContent = await fs.readFile(skillFile, 'utf8'); }
116
+ catch { continue; }
117
+
118
+ if (isCleanStub(destContent)) continue;
119
+
120
+ const kitItem = kitSkills.find(x => x.name === skillName);
121
+ if (!kitItem) {
122
+ candidates.push({
123
+ kind: 'skill', name: skillName, target: capCfg.path, destPath: skillFile,
124
+ kitPath: path.join(kitRoot, 'skills', skillName, 'SKILL.md'),
125
+ reason: 'new-in-ide',
126
+ diffSummary: `+${destContent.length} bytes (no kit source)`,
127
+ });
128
+ continue;
129
+ }
130
+
131
+ const stripped = stripStubBoilerplate(destContent);
132
+ if (normalize(stripped) === normalize(kitItem.skillContent)) continue;
133
+
134
+ candidates.push({
135
+ kind: 'skill', name: skillName, target: capCfg.path, destPath: skillFile,
136
+ kitPath: kitItem.absPath,
137
+ reason: 'modified-in-ide',
138
+ diffSummary: summarizeDiff(kitItem.skillContent, stripped),
139
+ });
140
+ }
141
+ }
142
+
143
+ async function scanMirrorTree(candidates, cap, spec, projectRoot, kitRoot) {
144
+ const dstRoot = path.join(projectRoot, spec.path);
145
+ const srcRoot = path.join(kitRoot, spec.source);
146
+ const files = await walkRel(dstRoot);
147
+ for (const rel of files) {
148
+ if (rel === '.kit-mcp-managed' || path.basename(rel) === '.kit-mcp-managed') continue;
149
+ const dstPath = path.join(dstRoot, rel);
150
+ const srcPath = path.join(srcRoot, rel);
151
+ let dstBuf, srcBuf;
152
+ try { dstBuf = await fs.readFile(dstPath); } catch { continue; }
153
+ try { srcBuf = await fs.readFile(srcPath); } catch { srcBuf = null; }
154
+ if (!srcBuf) {
155
+ candidates.push({
156
+ kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath,
157
+ reason: 'new-in-ide',
158
+ diffSummary: `+${dstBuf.length} bytes (no kit source)`,
159
+ });
160
+ continue;
161
+ }
162
+ if (dstBuf.equals(srcBuf)) continue;
163
+ candidates.push({
164
+ kind: cap, name: rel, target: spec.path, destPath: dstPath, kitPath: srcPath,
165
+ reason: 'modified-in-ide',
166
+ diffSummary: `${dstBuf.length} bytes vs ${srcBuf.length} canonical (${dstBuf.length - srcBuf.length >= 0 ? '+' : ''}${dstBuf.length - srcBuf.length})`,
167
+ });
168
+ }
169
+ }
170
+
171
+ async function walkRel(root) {
172
+ const out = [];
173
+ async function visit(current, prefix) {
174
+ let entries;
175
+ try { entries = await fs.readdir(current, { withFileTypes: true }); }
176
+ catch { return; }
177
+ for (const e of entries) {
178
+ const abs = path.join(current, e.name);
179
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
180
+ if (e.isDirectory()) await visit(abs, rel);
181
+ else if (e.isFile()) out.push(rel);
182
+ }
183
+ }
184
+ await visit(root, '');
185
+ return out;
186
+ }
187
+
188
+ // --- apply ---
189
+
190
+ export async function applyReverse(targetId, opts = {}) {
191
+ const strategy = opts.strategy ?? 'skip';
192
+ const onProgress = opts.onProgress ?? (() => {});
193
+ const { candidates } = await detectReverse(targetId, opts);
194
+ const results = [];
195
+
196
+ for (let i = 0; i < candidates.length; i++) {
197
+ const c = candidates[i];
198
+ if (opts.only && !opts.only.includes(`${c.kind}/${c.name}`)) {
199
+ results.push({ ...c, action: 'skipped (filter)' });
200
+ onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
201
+ continue;
202
+ }
203
+
204
+ const action = await applyOne(c, strategy, opts);
205
+ results.push({ ...c, action });
206
+ onProgress({ phase: c.kind, current: i + 1, total: candidates.length, label: c.name });
207
+ }
208
+
209
+ return { target: targetId, strategy, results };
210
+ }
211
+
212
+ async function applyOne(c, strategy, opts) {
213
+ const dryRun = !!opts.dryRun;
214
+ const isMirrorTree = c.kind === 'framework' || c.kind === 'hooks';
215
+
216
+ // Mirror-tree files don't have stub boilerplate — copy bytes verbatim.
217
+ if (isMirrorTree) {
218
+ return applyMirrorTreeOne(c, strategy, dryRun);
219
+ }
220
+
221
+ const destContent = await fs.readFile(c.destPath, 'utf8');
222
+ const stripped = stripStubBoilerplate(destContent);
223
+
224
+ switch (strategy) {
225
+ case 'skip':
226
+ return 'skipped';
227
+
228
+ case 'overwrite': {
229
+ if (!dryRun) {
230
+ await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
231
+ await fs.writeFile(c.kitPath, stripped, 'utf8');
232
+ }
233
+ return dryRun ? 'overwrite (dry-run)' : 'overwritten';
234
+ }
235
+
236
+ case 'merge': {
237
+ let merged = stripped;
238
+ if (c.reason === 'modified-in-ide') {
239
+ try {
240
+ const canonical = await fs.readFile(c.kitPath, 'utf8');
241
+ merged = mergeFrontmatter(canonical, stripped);
242
+ } catch { /* canonical missing → just take stripped */ }
243
+ }
244
+ if (!dryRun) {
245
+ await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
246
+ await fs.writeFile(c.kitPath, merged, 'utf8');
247
+ }
248
+ return dryRun ? 'merge (dry-run)' : 'merged';
249
+ }
250
+
251
+ case 'rename': {
252
+ const base = c.kitPath.replace(/\.md$/, '');
253
+ const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '');
254
+ const out = `${base}-from-${tag || 'ide'}.md`;
255
+ if (!dryRun) {
256
+ await fs.mkdir(path.dirname(out), { recursive: true });
257
+ await fs.writeFile(out, stripped, 'utf8');
258
+ }
259
+ return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`;
260
+ }
261
+
262
+ default:
263
+ return `unknown strategy: ${strategy}`;
264
+ }
265
+ }
266
+
267
+ async function applyMirrorTreeOne(c, strategy, dryRun) {
268
+ switch (strategy) {
269
+ case 'skip':
270
+ return 'skipped';
271
+
272
+ case 'overwrite':
273
+ case 'merge': {
274
+ // For framework/hooks files there's no frontmatter to preserve,
275
+ // so 'merge' degenerates to overwrite. Returning a verb that
276
+ // signals the degradation.
277
+ const verb = strategy === 'merge' ? 'merged (overwrite, no frontmatter)' : 'overwritten';
278
+ if (!dryRun) {
279
+ await fs.mkdir(path.dirname(c.kitPath), { recursive: true });
280
+ await fs.copyFile(c.destPath, c.kitPath);
281
+ }
282
+ return dryRun ? `${strategy} (dry-run)` : verb;
283
+ }
284
+
285
+ case 'rename': {
286
+ // Write to kit/<source>/<rel>.from-<tag> preserving extension after the tag.
287
+ const ext = path.extname(c.kitPath);
288
+ const stem = c.kitPath.slice(0, c.kitPath.length - ext.length);
289
+ const tag = path.basename(path.dirname(path.dirname(c.destPath))).replace(/^\./, '') || 'ide';
290
+ const out = `${stem}.from-${tag}${ext}`;
291
+ if (!dryRun) {
292
+ await fs.mkdir(path.dirname(out), { recursive: true });
293
+ await fs.copyFile(c.destPath, out);
294
+ }
295
+ return dryRun ? `rename → ${out} (dry-run)` : `renamed → ${out}`;
296
+ }
297
+
298
+ default:
299
+ return `unknown strategy: ${strategy}`;
300
+ }
301
+ }
302
+
303
+ // --- helpers ---
304
+
305
+ function isCleanStub(content) {
306
+ // A "clean" stub has all four markers. If the user removed any (likely by editing),
307
+ // we treat it as user content.
308
+ return content.includes(STUB_MARKER) &&
309
+ content.includes(STUB_CANONICAL) &&
310
+ content.includes(STUB_GENERATED) &&
311
+ content.includes(STUB_FOOTER);
312
+ }
313
+
314
+ function stripStubBoilerplate(content) {
315
+ // Remove the kit-mcp boilerplate so we can compare against the canonical.
316
+ // This handles partially-edited stubs (user kept some markers but added body).
317
+ if (!content.includes(STUB_MARKER)) return content;
318
+
319
+ const lines = content.split(/\r?\n/);
320
+ const filtered = [];
321
+ let inBoilerplate = false;
322
+ for (const line of lines) {
323
+ if (line.includes(STUB_MARKER)) { inBoilerplate = true; continue; }
324
+ if (inBoilerplate) {
325
+ // Boilerplate ends after we've consumed the auto-generated header block
326
+ if (/^>\s*Edit the source file/.test(line)) { inBoilerplate = false; continue; }
327
+ // Also skip the "# name", "> Canonical source:", description and timestamp lines
328
+ if (/^#\s+\S+\s*$/.test(line) ||
329
+ /^>\s*Canonical source:/.test(line) ||
330
+ /^>\s*Generated by kit-mcp at/.test(line) ||
331
+ /^>\s*\S/.test(line) ||
332
+ line.trim() === '') {
333
+ continue;
334
+ }
335
+ // First non-boilerplate line — flush and stop skipping
336
+ inBoilerplate = false;
337
+ }
338
+ filtered.push(line);
339
+ }
340
+ return filtered.join('\n').replace(/^\s+/, ''); // drop leading blank lines
341
+ }
342
+
343
+ function normalize(content) {
344
+ return content.replace(/\s+/g, ' ').trim();
345
+ }
346
+
347
+ function summarizeDiff(canonical, edited) {
348
+ const cLines = canonical.split(/\r?\n/).length;
349
+ const eLines = edited.split(/\r?\n/).length;
350
+ const delta = eLines - cLines;
351
+ const pct = canonical.length === 0 ? 100 : Math.round((edited.length - canonical.length) / canonical.length * 100);
352
+ return `${eLines} lines (${delta >= 0 ? '+' : ''}${delta}); ${pct >= 0 ? '+' : ''}${pct}% size`;
353
+ }
354
+
355
+ function mergeFrontmatter(canonical, edited) {
356
+ // Take the canonical frontmatter (has the formal metadata like tools, color, hooks)
357
+ // and append the edited body (everything after the first --- block, or the whole
358
+ // edited content if it has no frontmatter).
359
+ const fmMatch = canonical.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)/);
360
+ if (!fmMatch) return edited;
361
+
362
+ const editedBody = edited.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
363
+ const body = editedBody ? editedBody[1] : edited;
364
+ return fmMatch[1] + body;
365
+ }
366
+
367
+ function kindToFolder(kind) {
368
+ if (kind === 'agent') return 'agents';
369
+ if (kind === 'command') return 'commands';
370
+ if (kind === 'skill') return 'skills';
371
+ return kind;
372
+ }