@haposoft/cafekit 0.18.1 → 0.18.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 (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
@@ -0,0 +1,419 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Plan which files of an npx install `migrate` removes and which it keeps.
5
+ *
6
+ * Pure planner: it reads the Claude and Codex install manifests and the files on
7
+ * disk and never writes. A file is removed only when every check below agrees it
8
+ * is CafeKit's untouched copy of something a plugin now ships; any doubt keeps it
9
+ * and names the reason, so the dry-run shows exactly what `--apply` will do.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { sha256, MANIFEST_FILE } = require('./manifest');
15
+ const { isInside } = require('./path-safety');
16
+ const { PLATFORMS } = require('./context');
17
+ const { codexAgentName } = require('./codex-install');
18
+ const { pluginSkills, pluginAgents } = require('./plugin-inventory');
19
+
20
+ const MIGRATED_PLATFORMS = ['claude', 'codex'];
21
+ const METADATA_FILES = ['cafekit.json', MANIFEST_FILE];
22
+ // Written by the installer outside the manifest; the merged-file planner owns them.
23
+ const MERGED_FILES = new Set(['.claude/settings.json', '.codex/hooks.json']);
24
+ // Claude Code stores an /output-style choice in settings.local.json, which overrides settings.json.
25
+ const STYLE_SETTINGS = ['.claude/settings.json', '.claude/settings.local.json'];
26
+ const MAX_SCANNED_BYTES = 1024 * 1024;
27
+ // Hook logs and dependency installs name runtime paths without depending on them.
28
+ const UNSCANNED_DIRS = new Set(['.logs', '.privacy', 'session-state', 'agent-memory', 'worktrees', 'node_modules', '.venv', 'venv', '__pycache__']);
29
+ // Created by the operating system, not by a user customizing a skill.
30
+ const SYSTEM_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
31
+
32
+ const REASONS = {
33
+ merged: 'written outside the manifest, planned separately',
34
+ 'outside-root': 'manifest key points outside the platform folders',
35
+ symlinked: 'path goes through a symlink',
36
+ modified: 'edited since install',
37
+ runtime: 'runtime configuration stays',
38
+ 'npx-only': 'no plugin ships this skill or agent',
39
+ 'group-kept': 'another file of the same skill or agent is kept',
40
+ 'required-by-kept': 'a kept file names it',
41
+ 'protects-kept': 'ignore rules still protect kept files',
42
+ missing: 'already gone',
43
+ untracked: 'not installed by CafeKit'
44
+ };
45
+
46
+ const toPosix = (value) => value.split(path.sep).join('/');
47
+
48
+ function lstatOrNull(target) {
49
+ try {
50
+ return fs.lstatSync(target);
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ /** True when any existing component of `rel` below `root` is a symlink. */
58
+ function createSymlinkProbe(root) {
59
+ const cache = new Map();
60
+ return (rel) => {
61
+ let cursor = root;
62
+ for (const segment of rel.split('/').filter(Boolean)) {
63
+ cursor = path.join(cursor, segment);
64
+ if (!cache.has(cursor)) {
65
+ const stat = lstatOrNull(cursor);
66
+ cache.set(cursor, stat ? (stat.isSymbolicLink() ? 'link' : 'ok') : 'absent');
67
+ }
68
+ const state = cache.get(cursor);
69
+ if (state === 'link') return true;
70
+ if (state === 'absent') return false;
71
+ }
72
+ return false;
73
+ };
74
+ }
75
+
76
+ function readManifestRaw(file) {
77
+ let raw;
78
+ try {
79
+ raw = fs.readFileSync(file, 'utf8');
80
+ } catch (error) {
81
+ if (error.code === 'ENOENT') return { state: 'absent' };
82
+ return { state: 'corrupt' };
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ if (!parsed || typeof parsed !== 'object' || !parsed.files || typeof parsed.files !== 'object' || Array.isArray(parsed.files)) {
87
+ return { state: 'corrupt' };
88
+ }
89
+ return { state: 'ok', files: parsed.files };
90
+ } catch {
91
+ return { state: 'corrupt' };
92
+ }
93
+ }
94
+
95
+ /** Skill folder or agent file an installed path belongs to; null for shared runtime files. */
96
+ function groupOf(rel) {
97
+ let match = rel.match(/^(\.claude\/skills|\.agents\/skills)\/([^/]+)\//);
98
+ if (match) return { kind: 'skill', name: match[2], label: `${match[1]}/${match[2]}/` };
99
+ match = rel.match(/^\.claude\/agents\/([^/]+)\.md$/);
100
+ if (match) return { kind: 'agent', name: match[1], label: rel };
101
+ match = rel.match(/^\.codex\/agents\/([^/]+)\.toml$/);
102
+ if (match) return { kind: 'codex-agent', name: match[1], label: rel };
103
+ return null;
104
+ }
105
+
106
+ function createPluginShips() {
107
+ const skills = new Set(pluginSkills());
108
+ const agents = new Set(pluginAgents());
109
+ const codexAgents = new Set([...agents].map(codexAgentName));
110
+ return (group) => {
111
+ if (group.kind === 'skill') return skills.has(group.name);
112
+ if (group.kind === 'agent') return agents.has(group.name);
113
+ return codexAgents.has(group.name);
114
+ };
115
+ }
116
+
117
+ function platformFolders(key) {
118
+ return PLATFORMS[key].ownership?.allowedRoots || [PLATFORMS[key].folder];
119
+ }
120
+
121
+ function planEntry(root, platform, folders, recordRoot, key, record, isSymlinked) {
122
+ const norm = String(key).replace(/\\/g, '/');
123
+ const recorded = record && typeof record.sha256 === 'string' ? record.sha256 : null;
124
+ const base = { platform, key, sha256: recorded, group: null };
125
+ const outside = !norm || path.posix.isAbsolute(norm) || /^[A-Za-z]:/.test(norm) || norm.split('/').includes('..');
126
+ const abs = path.resolve(root, recordRoot, norm);
127
+ const rel = toPosix(path.relative(root, abs));
128
+ if (outside || !folders.some((folder) => {
129
+ const folderAbs = path.join(root, folder);
130
+ return abs !== folderAbs && isInside(folderAbs, abs);
131
+ })) {
132
+ return { ...base, path: rel, action: 'keep', reason: 'outside-root' };
133
+ }
134
+ const entry = { ...base, path: rel, group: groupOf(rel) };
135
+ if (MERGED_FILES.has(rel) || METADATA_FILES.some((name) => folders.some((folder) => rel === `${folder}/${name}`))) {
136
+ return { ...entry, action: 'keep', reason: 'merged' };
137
+ }
138
+ if (isSymlinked(rel)) return { ...entry, action: 'keep', reason: 'symlinked' };
139
+ const stat = lstatOrNull(abs);
140
+ if (!stat) return { ...entry, action: 'missing', reason: 'missing' };
141
+ if (!stat.isFile() || !recorded || sha256(fs.readFileSync(abs)) !== recorded) {
142
+ return { ...entry, action: 'keep', reason: 'modified' };
143
+ }
144
+ const folder = folders.find((name) => rel.startsWith(`${name}/`));
145
+ if (rel === `${folder}/runtime.json`) return { ...entry, action: 'keep', reason: 'runtime' };
146
+ if (rel === `${folder}/runtime.schema.json` && lstatOrNull(path.join(root, folder, 'runtime.json'))) {
147
+ return { ...entry, action: 'keep', reason: 'runtime' };
148
+ }
149
+ return { ...entry, action: 'remove', reason: null };
150
+ }
151
+
152
+ function planPlatform(root, platform, isSymlinked) {
153
+ const config = PLATFORMS[platform];
154
+ const folders = platformFolders(platform);
155
+ const result = { platform, name: config.name, folders, status: 'absent', reason: null, metadata: [], entries: [] };
156
+ if (!lstatOrNull(path.join(root, config.folder))) return result;
157
+ if (folders.some((folder) => lstatOrNull(path.join(root, folder))?.isSymbolicLink())) {
158
+ return { ...result, status: 'refused', reason: 'a platform folder is a symlink' };
159
+ }
160
+ result.metadata = METADATA_FILES
161
+ .map((name) => `${config.folder}/${name}`)
162
+ .filter((rel) => lstatOrNull(path.join(root, rel)));
163
+ const manifest = readManifestRaw(path.join(root, config.folder, MANIFEST_FILE));
164
+ if (manifest.state === 'absent') {
165
+ const managed = result.metadata.length > 0;
166
+ return { ...result, status: managed ? 'unmanaged' : 'absent', reason: managed ? 'cafekit.json without an install manifest' : null };
167
+ }
168
+ if (manifest.state === 'corrupt') return { ...result, status: 'refused', reason: 'the install manifest does not parse' };
169
+
170
+ const recordRoot = config.ownership?.recordRoot || config.folder;
171
+ result.entries = Object.entries(manifest.files)
172
+ .map(([key, record]) => planEntry(root, platform, folders, recordRoot, key, record, isSymlinked));
173
+ result.status = 'planned';
174
+ return result;
175
+ }
176
+
177
+ /** A symlink that points back into the repository keeps the files it reaches. */
178
+ function keepSymlinkTargets(root, entries) {
179
+ const byPath = new Map(entries.map((entry) => [entry.path, entry]));
180
+ for (const entry of entries) {
181
+ if (entry.reason !== 'symlinked') continue;
182
+ let real;
183
+ try {
184
+ real = fs.realpathSync(path.join(root, entry.path));
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (!isInside(root, real)) continue;
189
+ const target = byPath.get(toPosix(path.relative(root, real)));
190
+ if (target && target.action === 'remove') Object.assign(target, { action: 'keep', reason: 'symlinked' });
191
+ }
192
+ }
193
+
194
+ function applyGroupRules(entries, pluginShips, untracked) {
195
+ const groups = new Map();
196
+ for (const entry of entries) {
197
+ if (!entry.group) continue;
198
+ const id = `${entry.platform}:${entry.group.label}`;
199
+ if (!groups.has(id)) groups.set(id, []);
200
+ groups.get(id).push(entry);
201
+ }
202
+ for (const members of groups.values()) {
203
+ const { label } = members[0].group;
204
+ // A user file added inside a skill folder customizes that skill, so the skill stays whole.
205
+ const holdsUserFile = label.endsWith('/') && untracked.some((rel) => rel.startsWith(label)
206
+ && !SYSTEM_FILES.has(path.posix.basename(rel))
207
+ && !rel.split('/').some((segment) => UNSCANNED_DIRS.has(segment)));
208
+ const reason = !pluginShips(members[0].group) ? 'npx-only'
209
+ : members.some((entry) => entry.action === 'keep') || holdsUserFile ? 'group-kept' : null;
210
+ if (!reason) continue;
211
+ for (const entry of members) {
212
+ if (entry.action === 'remove') Object.assign(entry, { action: 'keep', reason });
213
+ }
214
+ }
215
+ }
216
+
217
+ function readText(abs, { follow = false } = {}) {
218
+ let stat;
219
+ try {
220
+ stat = follow ? fs.statSync(abs) : lstatOrNull(abs);
221
+ } catch {
222
+ return null;
223
+ }
224
+ if (!stat || !stat.isFile() || stat.size > MAX_SCANNED_BYTES) return null;
225
+ const buffer = fs.readFileSync(abs);
226
+ return buffer.includes(0) ? null : buffer.toString('utf8');
227
+ }
228
+
229
+ /** Installed paths a file names: `.claude/…`, `.codex/…`, `.agents/…`, and relative requires. */
230
+ function referencedPaths(text, fromRel) {
231
+ const refs = new Set();
232
+ for (const match of text.matchAll(/\.(?:claude|codex|agents)[/\\][A-Za-z0-9._/\\-]+/g)) {
233
+ // JSON escapes a backslash as `\\`, so separators may arrive doubled.
234
+ refs.add(match[0].replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/[./]+$/, ''));
235
+ }
236
+ for (const match of text.matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
237
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), match[1]));
238
+ for (const suffix of ['', '.cjs', '.js', '/index.js']) refs.add(target + suffix);
239
+ }
240
+ return refs;
241
+ }
242
+
243
+ function selectedStyleNames(root) {
244
+ const names = new Set();
245
+ for (const rel of STYLE_SETTINGS) {
246
+ try {
247
+ const settings = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
248
+ if (typeof settings.outputStyle === 'string' && settings.outputStyle.trim()) names.add(settings.outputStyle.trim());
249
+ } catch {
250
+ // A missing or unparsable settings file selects nothing.
251
+ }
252
+ }
253
+ return names;
254
+ }
255
+
256
+ /** A style is named by its frontmatter `name:`, or by its file name when that is absent. */
257
+ function styleName(text, rel) {
258
+ const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
259
+ const name = frontmatter && frontmatter[1].match(/^name:\s*(.+)$/m);
260
+ return name ? name[1].trim().replace(/^(['"])(.*)\1$/, '$2') : path.posix.basename(rel, '.md');
261
+ }
262
+
263
+ /**
264
+ * Text files of the untracked list, descending into collapsed folders. Symlinks are
265
+ * followed because reading a user's linked skill can only keep more files, never fewer.
266
+ */
267
+ function untrackedFiles(root, untracked) {
268
+ const files = [];
269
+ const seen = new Set();
270
+ const visit = (rel) => {
271
+ if (rel.split('/').some((segment) => UNSCANNED_DIRS.has(segment))) return;
272
+ let real;
273
+ let stat;
274
+ try {
275
+ real = fs.realpathSync(path.join(root, rel));
276
+ stat = fs.statSync(real);
277
+ } catch {
278
+ return;
279
+ }
280
+ if (seen.has(real)) return;
281
+ seen.add(real);
282
+ if (stat.isDirectory()) {
283
+ for (const name of fs.readdirSync(real).sort()) visit(`${rel}/${name}`);
284
+ } else if (stat.isFile()) {
285
+ files.push(rel);
286
+ }
287
+ };
288
+ for (const rel of untracked) visit(rel.replace(/\/$/, ''));
289
+ return files;
290
+ }
291
+
292
+ /** Keep every shared file a kept or user file names, transitively, and the selected output styles. */
293
+ function keepRequiredFiles(root, entries, untracked) {
294
+ const byPath = new Map(entries.map((entry) => [entry.path, entry]));
295
+ const readable = (entry) => !['outside-root', 'symlinked', 'merged'].includes(entry.reason);
296
+ const queue = [
297
+ ...entries.filter((entry) => entry.action === 'keep' && readable(entry)),
298
+ ...untrackedFiles(root, untracked).map((rel) => ({ path: rel, follow: true }))
299
+ ];
300
+ const keep = (entry) => {
301
+ if (entry.action !== 'remove') return;
302
+ Object.assign(entry, { action: 'keep', reason: 'required-by-kept' });
303
+ queue.push(entry);
304
+ };
305
+
306
+ const styles = selectedStyleNames(root);
307
+ if (styles.size > 0) {
308
+ for (const entry of entries) {
309
+ if (!/^\.claude\/output-styles\/[^/]+\.md$/.test(entry.path) || entry.action === 'missing') continue;
310
+ const text = readText(path.join(root, entry.path));
311
+ if (text && styles.has(styleName(text, entry.path))) keep(entry);
312
+ }
313
+ }
314
+
315
+ while (queue.length > 0) {
316
+ const entry = queue.pop();
317
+ const text = readText(path.join(root, entry.path), { follow: entry.follow === true });
318
+ if (!text) continue;
319
+ for (const ref of referencedPaths(text, entry.path)) {
320
+ const target = byPath.get(ref);
321
+ if (target && !target.group) keep(target);
322
+ }
323
+ }
324
+ }
325
+
326
+ /** Files in the platform folders that no manifest records, collapsing folders that hold none. */
327
+ function listUntracked(root, platforms, entries) {
328
+ const managed = new Set(entries.map((entry) => entry.path));
329
+ const managedPrefixes = new Set();
330
+ for (const rel of managed) {
331
+ const parts = rel.split('/');
332
+ for (let i = 1; i < parts.length; i++) managedPrefixes.add(parts.slice(0, i).join('/'));
333
+ }
334
+ const skipped = new Set([...MERGED_FILES]);
335
+ for (const platform of platforms) for (const rel of platform.metadata) skipped.add(rel);
336
+
337
+ const untracked = [];
338
+ const visit = (rel) => {
339
+ if (managed.has(rel) || skipped.has(rel)) return;
340
+ const stat = lstatOrNull(path.join(root, rel));
341
+ if (!stat) return;
342
+ if (stat.isDirectory() && !stat.isSymbolicLink() && managedPrefixes.has(rel)) {
343
+ for (const name of fs.readdirSync(path.join(root, rel)).sort()) visit(`${rel}/${name}`);
344
+ return;
345
+ }
346
+ // A symlink that holds manifest keys is already reported as `symlinked`.
347
+ if (stat.isSymbolicLink() && managedPrefixes.has(rel)) return;
348
+ untracked.push(stat.isDirectory() ? `${rel}/` : rel);
349
+ };
350
+ for (const platform of platforms) {
351
+ if (platform.status !== 'planned') continue;
352
+ for (const folder of platform.folders) visit(folder);
353
+ }
354
+ return untracked;
355
+ }
356
+
357
+ /** A shared `.gitignore` stays while anything below its folder stays. */
358
+ function keepIgnoreFiles(entries, untracked) {
359
+ for (const entry of entries) {
360
+ if (entry.group || entry.action !== 'remove' || path.posix.basename(entry.path) !== '.gitignore') continue;
361
+ const prefix = `${path.posix.dirname(entry.path)}/`;
362
+ const kept = entries.some((other) => other !== entry && other.action === 'keep' && other.path.startsWith(prefix))
363
+ || untracked.some((rel) => rel.startsWith(prefix));
364
+ if (kept) Object.assign(entry, { action: 'keep', reason: 'protects-kept' });
365
+ }
366
+ }
367
+
368
+ function planManifestFiles(projectRoot) {
369
+ const root = fs.realpathSync(projectRoot);
370
+ const isSymlinked = createSymlinkProbe(root);
371
+ const platforms = MIGRATED_PLATFORMS.map((platform) => planPlatform(root, platform, isSymlinked));
372
+ const entries = platforms.flatMap((platform) => platform.entries);
373
+ const untracked = listUntracked(root, platforms, entries);
374
+ keepSymlinkTargets(root, entries);
375
+ applyGroupRules(entries, createPluginShips(), untracked);
376
+ keepRequiredFiles(root, entries, untracked);
377
+ keepIgnoreFiles(entries, untracked);
378
+ return {
379
+ projectRoot: root,
380
+ platforms: Object.fromEntries(platforms.map(({ entries: _entries, ...platform }) => [platform.platform, platform])),
381
+ entries,
382
+ untracked
383
+ };
384
+ }
385
+
386
+ function renderManifestPlan(plan) {
387
+ const lines = [];
388
+ for (const platform of Object.values(plan.platforms)) {
389
+ if (platform.status === 'absent') continue;
390
+ const where = platform.folders.map((folder) => `${folder}/`).join(', ');
391
+ lines.push(`${platform.name} (${where}): ${platform.status}${platform.reason ? ` — ${platform.reason}` : ''}`);
392
+ }
393
+ const buckets = new Map();
394
+ for (const entry of plan.entries) {
395
+ const id = entry.action === 'remove' ? 'remove' : `${entry.action}:${entry.reason}`;
396
+ if (!buckets.has(id)) buckets.set(id, new Map());
397
+ const label = entry.group && entry.group.kind === 'skill' ? entry.group.label : entry.path;
398
+ const bucket = buckets.get(id);
399
+ bucket.set(label, (bucket.get(label) || 0) + 1);
400
+ }
401
+ const order = ['remove', ...Object.keys(REASONS).map((reason) => `${reason === 'missing' ? 'missing' : 'keep'}:${reason}`)];
402
+ for (const id of order) {
403
+ const bucket = buckets.get(id);
404
+ if (!bucket) continue;
405
+ const total = [...bucket.values()].reduce((sum, count) => sum + count, 0);
406
+ const reason = id.split(':')[1];
407
+ lines.push('', id === 'remove' ? `Remove (${total} files):` : `${id.startsWith('missing') ? 'Missing' : 'Keep'} — ${REASONS[reason]} (${total} files):`);
408
+ for (const [label, count] of [...bucket.entries()].sort(([a], [b]) => a.localeCompare(b))) {
409
+ lines.push(` ${label}${label.endsWith('/') ? ` (${count} files)` : ''}`);
410
+ }
411
+ }
412
+ if (plan.untracked.length > 0) {
413
+ lines.push('', `Keep — ${REASONS.untracked} (${plan.untracked.length}):`);
414
+ for (const rel of plan.untracked) lines.push(` ${rel}`);
415
+ }
416
+ return lines.join('\n');
417
+ }
418
+
419
+ module.exports = { REASONS, planManifestFiles, renderManifestPlan, groupOf };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which CafeKit skills and agents the Claude and Codex plugins ship.
5
+ *
6
+ * Lives under bin/ because the npm package ships bin and src but not scripts:
7
+ * the plugin build and the `migrate` command must agree on the same lists.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const SOURCE = path.join(__dirname, '../../src/claude');
14
+
15
+ // These need a Python venv, npm install or a browser binary, which a plugin has
16
+ // no install step for; they stay on the npx path for now.
17
+ const EXCLUDED_SKILLS = ['chrome-devtools', 'ui-ux', 'ui-ux-pro-max'];
18
+ const EXCLUDED_AGENTS = ['ui-ux-designer'];
19
+
20
+ function pluginSkills() {
21
+ const manifest = JSON.parse(fs.readFileSync(path.join(SOURCE, 'migration-manifest.json'), 'utf8'));
22
+ return manifest.skills.required.filter((name) => !EXCLUDED_SKILLS.includes(name));
23
+ }
24
+
25
+ function pluginAgents() {
26
+ return fs.readdirSync(path.join(SOURCE, 'agents'))
27
+ .filter((name) => name.endsWith('.md'))
28
+ .map((name) => name.slice(0, -3))
29
+ .filter((name) => !EXCLUDED_AGENTS.includes(name))
30
+ .sort();
31
+ }
32
+
33
+ module.exports = { EXCLUDED_SKILLS, EXCLUDED_AGENTS, pluginSkills, pluginAgents };
@@ -16,6 +16,7 @@ const {
16
16
  getCopyOptions
17
17
  } = require('../lib/context');
18
18
  const { sha256 } = require('../lib/manifest');
19
+ const { assertNoSymlinkPath } = require('../lib/path-safety');
19
20
  const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
20
21
  const { report, treeAction } = require('./report');
21
22
  const {
@@ -164,30 +165,86 @@ function copyClaudeRuntimeFiles(ctx, platformKey) {
164
165
  });
165
166
  }
166
167
 
167
- /** Delete runtime files marked obsolete by the manifest. Claude only. */
168
- function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
169
- if (platformKey !== 'claude') return;
170
-
171
- const obsoleteFiles = ctx.manifest?.obsolete?.runtimeFiles || [];
172
- const targetBase = PLATFORMS.claude.folder;
173
-
174
- obsoleteFiles.forEach((relPath) => {
175
- const targetPath = path.join(targetBase, relPath);
176
- if (!fs.existsSync(targetPath)) return;
177
-
178
- const isDir = fs.statSync(targetPath).isDirectory();
179
- if (!ctx.dryRun) {
180
- fs.rmSync(targetPath, { force: true, recursive: isDir });
181
- // Remove the entry from the manifest so it doesn't linger as a zombie.
182
- if (ctx.trackers && ctx.trackers.claude) {
183
- ctx.trackers.claude.prune(relPath);
184
- // Directories: prune every tracked file underneath as well.
185
- if (isDir) ctx.trackers.claude.prunePrefix(`${relPath}/`);
168
+ /** Remove only obsolete runtime bytes proven unchanged by the ownership manifest. */
169
+ function removeObsoleteRuntimeFiles(ctx, platformKey) {
170
+ const completed = (ctx.obsoleteRuntimeCleanedPlatforms ||= new Set());
171
+ if (completed.has(platformKey)) return;
172
+ completed.add(platformKey);
173
+ const preserved = new Set();
174
+ for (const key of [platformKey]) {
175
+ const platform = PLATFORMS[key];
176
+ const tracker = ctx.trackers?.[key];
177
+ const ownership = ctx.ownership?.[platform.folder] || { files: {} };
178
+ const obsoleteFiles = (ctx.manifest?.obsolete?.runtimeFiles || [])
179
+ // omp installs no skills: the shared .agents/skills directory belongs to the Codex install.
180
+ .filter(relative => key === 'omp' ? /^(?:hooks|scripts|extensions)\//.test(relative)
181
+ : key === 'claude' || /^(?:hooks|scripts|skills)\//.test(relative));
182
+ for (const relative of obsoleteFiles) {
183
+ const targetPath = key === 'codex' && relative.startsWith('skills/')
184
+ ? path.join(platform.skillsDir, relative.slice('skills/'.length))
185
+ : path.join(platform.folder, relative);
186
+ let stat;
187
+ try {
188
+ assertNoSymlinkPath(targetPath);
189
+ stat = fs.statSync(targetPath);
190
+ } catch (error) {
191
+ if (error.code === 'ENOENT') {
192
+ if ((key === 'codex' || key === 'omp') && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
193
+ continue;
194
+ }
195
+ preserved.add(targetPath);
196
+ continue;
197
+ }
198
+ try {
199
+ if (!tracker) throw new Error('Missing ownership tracker');
200
+ const trackedKeys = [];
201
+ const inspect = file => {
202
+ assertNoSymlinkPath(file);
203
+ const current = fs.statSync(file);
204
+ if (current.isDirectory()) {
205
+ for (const name of fs.readdirSync(file)) inspect(path.join(file, name));
206
+ return;
207
+ }
208
+ if (!current.isFile()) throw new Error('Unsupported obsolete file type');
209
+ const trackedKey = tracker.keyFor(file);
210
+ const recordedHash = ownership.files?.[trackedKey]?.sha256;
211
+ if (!recordedHash || recordedHash !== sha256(fs.readFileSync(file))) {
212
+ throw new Error('Obsolete file is not proven unchanged');
213
+ }
214
+ trackedKeys.push(trackedKey);
215
+ };
216
+ inspect(targetPath);
217
+ assertNoSymlinkPath(targetPath);
218
+ const targetKey = tracker.keyFor(targetPath);
219
+ if (!ctx.dryRun) {
220
+ fs.rmSync(targetPath, { force: true, recursive: stat.isDirectory() });
221
+ for (const trackedKey of trackedKeys) tracker.prune(trackedKey);
222
+ if (stat.isDirectory()) {
223
+ tracker.prune(targetKey);
224
+ tracker.prunePrefix(`${targetKey}/`);
225
+ }
226
+ }
227
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete ${key} runtime: ${relative}`);
228
+ ctx.results.updated++;
229
+ } catch {
230
+ preserved.add(targetPath);
186
231
  }
187
232
  }
188
- ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete runtime: ${relPath}`);
189
- ctx.results.updated++;
190
- });
233
+ }
234
+ if (preserved.size) {
235
+ ctx.results.preserved = (ctx.results.preserved || 0) + preserved.size;
236
+ (ctx.results.preservedFiles ||= []).push(...preserved);
237
+ }
238
+ const allPreserved = (ctx.obsoleteRuntimePreserved ||= new Set());
239
+ for (const target of preserved) allPreserved.add(target);
240
+ const selected = (ctx.platforms || [platformKey]).filter(key => ['claude', 'codex', 'omp'].includes(key));
241
+ if (selected.every(key => completed.has(key)) && allPreserved.size) {
242
+ ctx.ui.warn(`Preserved ${allPreserved.size} obsolete runtime item(s); review and remove manually if no longer needed: ${[...allPreserved].join(', ')}`);
243
+ }
244
+ }
245
+
246
+ function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
247
+ if (platformKey === 'claude') removeObsoleteRuntimeFiles(ctx, platformKey);
191
248
  }
192
249
 
193
250
  /** Remove renamed agents only when ownership manifest proves pristine bytes. */
@@ -258,7 +315,7 @@ function copyClaudeMdFile(ctx, platformKey) {
258
315
  }
259
316
 
260
317
  /** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
261
- function copyRulesDirectory(ctx, platformKey) {
318
+ function copyRulesDirectory(ctx, platformKey, excludedNames = new Set()) {
262
319
  if (!hasPlatformCapability(platformKey, 'rules')) return;
263
320
 
264
321
  const src = path.join(SRC, 'claude/rules');
@@ -270,9 +327,19 @@ function copyRulesDirectory(ctx, platformKey) {
270
327
  }
271
328
 
272
329
  const transform = getCopyOptions(platformKey, {}).transform;
273
- const agg = copyManagedTree({
330
+ const options = {
274
331
  src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
275
- });
332
+ };
333
+ let agg;
334
+ if (excludedNames.size === 0) agg = copyManagedTree(options);
335
+ else {
336
+ agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
337
+ for (const name of fs.readdirSync(src)) {
338
+ if (excludedNames.has(name)) continue;
339
+ const child = copyManagedTree({ ...options, sourceRoot: src, src: path.join(src, name), dest: path.join(dest, name) });
340
+ for (const key of Object.keys(agg)) agg[key] += child[key];
341
+ }
342
+ }
276
343
  report(ctx, treeAction(agg), 'rules/ directory');
277
344
  }
278
345
 
@@ -303,6 +370,7 @@ module.exports = {
303
370
  copyOutputStylesDirectory,
304
371
  copyClaudeRuntimeFiles,
305
372
  removeObsoleteClaudeRuntimeFiles,
373
+ removeObsoleteRuntimeFiles,
306
374
  copyClaudeMdFile,
307
375
  copyClaudeAgentsMdFile,
308
376
  ensureSharedAgentsMdCore,
@@ -12,6 +12,26 @@ const { PLATFORMS } = require('../lib/context');
12
12
 
13
13
  const SRC_CLAUDE = path.join(__dirname, '../../src/claude');
14
14
 
15
+ const FORMER_MATCHERS = new Set([
16
+ 'Read|Write|Edit|MultiEdit|Bash|Glob',
17
+ 'Read|Write|Edit|MultiEdit|Bash|Glob|Grep',
18
+ ]);
19
+
20
+ function refreshManagedMatchers(settings, template, ctx) {
21
+ if (!Array.isArray(settings.hooks?.PreToolUse)) return settings;
22
+ const targets = template.hooks?.PreToolUse || [];
23
+ const entries = settings.hooks.PreToolUse.map(entry => {
24
+ if (!FORMER_MATCHERS.has(entry?.matcher) || !Array.isArray(entry.hooks) || !entry.hooks.length) return entry;
25
+ const candidates = targets.filter(target =>
26
+ entry.hooks.every(hook => target.hooks.some(managed => managed.command === hook?.command)));
27
+ if (candidates.length !== 1) return entry;
28
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook PreToolUse matcher updated`);
29
+ ctx.results.updated++;
30
+ return { ...entry, matcher: candidates[0].matcher };
31
+ });
32
+ return { ...settings, hooks: { ...settings.hooks, PreToolUse: entries } };
33
+ }
34
+
15
35
  /**
16
36
  * Remove obsolete hook entries from a settings object whose command matches any
17
37
  * manifest-listed substring (e.g. a retired router hook). Returns a possibly
@@ -97,7 +117,8 @@ function mergeClaudeSettings(ctx, platformKey) {
97
117
  }
98
118
  }
99
119
 
100
- const mergedSettings = pruneObsoleteSettingsHooks({ ...existingSettings }, ctx);
120
+ const mergedSettings = refreshManagedMatchers(
121
+ pruneObsoleteSettingsHooks({ ...existingSettings }, ctx), managedSettings, ctx);
101
122
 
102
123
  // statusLine: adopt managed value if upgrading, missing, or CafeKit-owned.
103
124
  if (managedSettings.statusLine) {