@haposoft/cafekit 0.18.2 → 0.18.4

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 (141) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/adv-skill.test.js +370 -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__/codex-hook-runtime-fixture.cjs +26 -0
  7. package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
  8. package/bin/__tests__/codex-hooks.test.js +87 -85
  9. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  10. package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
  11. package/bin/__tests__/codex-native.test.js +136 -2298
  12. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  13. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  14. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  15. package/bin/__tests__/codex-quiet-hooks.test.js +2 -8
  16. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  17. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  18. package/bin/__tests__/codex-skill-projections.test.js +101 -0
  19. package/bin/__tests__/develop-contract.test.js +100 -874
  20. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  21. package/bin/__tests__/migrate-apply.test.js +373 -0
  22. package/bin/__tests__/migrate-cli.test.js +152 -0
  23. package/bin/__tests__/migrate-merged.test.js +269 -0
  24. package/bin/__tests__/migrate-plan.test.js +271 -0
  25. package/bin/__tests__/omp-bridge.test.js +81 -5
  26. package/bin/__tests__/omp-hooks.test.js +24 -0
  27. package/bin/__tests__/orca-session.test.js +2 -16
  28. package/bin/__tests__/package-inventory.test.js +228 -2044
  29. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  30. package/bin/__tests__/privacy-paths.test.js +184 -0
  31. package/bin/__tests__/secret-scanner.test.js +73 -0
  32. package/bin/__tests__/skill-routing-source.test.js +6 -0
  33. package/bin/__tests__/toaa-skill.test.js +195 -0
  34. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  35. package/bin/__tests__/validator-grounding.test.js +0 -198
  36. package/bin/__tests__/vibe-run.test.js +215 -0
  37. package/bin/__tests__/vibe-skill.test.js +548 -0
  38. package/bin/__tests__/website-docs.test.js +58 -0
  39. package/bin/install.js +99 -1
  40. package/bin/lib/backup.js +43 -24
  41. package/bin/lib/codex-install.js +12 -3
  42. package/bin/lib/context.js +5 -0
  43. package/bin/lib/lock.js +98 -18
  44. package/bin/lib/managed-writer.js +16 -10
  45. package/bin/lib/migrate-apply.js +202 -0
  46. package/bin/lib/migrate-merged.js +342 -0
  47. package/bin/lib/migrate-plan.js +419 -0
  48. package/bin/lib/plugin-inventory.js +33 -0
  49. package/bin/phases/claude-runtime.js +5 -3
  50. package/bin/phases/codex-hooks.js +38 -0
  51. package/bin/phases/codex-runtime.js +106 -74
  52. package/bin/phases/copy-payload.js +49 -1
  53. package/bin/phases/omp-runtime.js +4 -26
  54. package/bin/phases/root-config.js +5 -2
  55. package/package.json +1 -1
  56. package/src/claude/CLAUDE.md +1 -1
  57. package/src/claude/agents/advisor.md +55 -0
  58. package/src/claude/agents/debugger.md +2 -3
  59. package/src/claude/agents/deployer.md +2 -1
  60. package/src/claude/agents/docs-keeper.md +12 -0
  61. package/src/claude/agents/inspector.md +1 -1
  62. package/src/claude/agents/project-manager.md +2 -2
  63. package/src/claude/agents/researcher.md +0 -1
  64. package/src/claude/agents/spec-maker.md +4 -1
  65. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  66. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  67. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  68. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  69. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -35
  70. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  71. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
  72. package/src/claude/hooks/__tests__/state.test.js +21 -0
  73. package/src/claude/hooks/agent.cjs +5 -2
  74. package/src/claude/hooks/inspect-block.cjs +3 -1
  75. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  76. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  77. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  78. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  79. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  80. package/src/claude/hooks/plugin-context.cjs +80 -0
  81. package/src/claude/hooks/privacy-block.cjs +2 -1
  82. package/src/claude/hooks/rules.cjs +5 -2
  83. package/src/claude/hooks/session.cjs +4 -1
  84. package/src/claude/hooks/spec-state.cjs +5 -4
  85. package/src/claude/hooks/state.cjs +27 -1
  86. package/src/claude/migration-manifest.json +7 -1
  87. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  88. package/src/claude/plugin/core.md +71 -0
  89. package/src/claude/rules/manage-docs.md +1 -2
  90. package/src/claude/rules/review-audit-self-decision.md +1 -1
  91. package/src/claude/rules/skill-workflow-routing.md +1 -1
  92. package/src/claude/rules/state-sync.md +14 -0
  93. package/src/claude/rules/workflow.md +1 -1
  94. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  95. package/src/claude/scripts/provenance.cjs +40 -16
  96. package/src/claude/scripts/scan-staged-secrets.cjs +49 -3
  97. package/src/claude/scripts/setup-statusline.cjs +88 -0
  98. package/src/claude/skills/adv/SKILL.md +139 -0
  99. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  100. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  101. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  102. package/src/claude/skills/code-review/SKILL.md +4 -1
  103. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  104. package/src/claude/skills/develop/SKILL.md +11 -3
  105. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  106. package/src/claude/skills/fix/SKILL.md +2 -2
  107. package/src/claude/skills/git/SKILL.md +4 -2
  108. package/src/claude/skills/git/references/merge-pr.md +125 -0
  109. package/src/claude/skills/specs/SKILL.md +2 -2
  110. package/src/claude/skills/specs/references/review.md +1 -1
  111. package/src/claude/skills/specs/references/templates.md +2 -0
  112. package/src/claude/skills/sync/SKILL.md +4 -0
  113. package/src/claude/skills/toaa/SKILL.md +82 -0
  114. package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
  115. package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -0
  116. package/src/claude/skills/vibe/SKILL.md +69 -0
  117. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  118. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  119. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  120. package/src/codex/AGENTS.md +1 -1
  121. package/src/codex/hooks/inspect-block.cjs +7 -4
  122. package/src/codex/hooks/lib/hook-context.cjs +119 -4
  123. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  124. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  125. package/src/codex/hooks/lib/state-store.cjs +31 -6
  126. package/src/codex/hooks/precompact.cjs +9 -8
  127. package/src/codex/hooks/privacy-approval.cjs +2 -2
  128. package/src/codex/hooks/privacy-block.cjs +5 -4
  129. package/src/codex/hooks/rules.cjs +12 -6
  130. package/src/codex/hooks/session.cjs +22 -10
  131. package/src/codex/plugin/core.md +76 -0
  132. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  133. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  134. package/src/codex/scripts/setup-agents.cjs +124 -0
  135. package/src/common/AGENTS.md +1 -1
  136. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  137. package/src/omp/hooks/privacy-block.cjs +2 -1
  138. package/bin/__tests__/codex-research-projection.test.js +0 -70
  139. package/bin/__tests__/codex-specs-projection.test.js +0 -76
  140. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
  141. package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
@@ -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 };
@@ -176,7 +176,9 @@ function removeObsoleteRuntimeFiles(ctx, platformKey) {
176
176
  const tracker = ctx.trackers?.[key];
177
177
  const ownership = ctx.ownership?.[platform.folder] || { files: {} };
178
178
  const obsoleteFiles = (ctx.manifest?.obsolete?.runtimeFiles || [])
179
- .filter(relative => key === 'claude' || /^(?:hooks|scripts|skills)\//.test(relative));
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));
180
182
  for (const relative of obsoleteFiles) {
181
183
  const targetPath = key === 'codex' && relative.startsWith('skills/')
182
184
  ? path.join(platform.skillsDir, relative.slice('skills/'.length))
@@ -187,7 +189,7 @@ function removeObsoleteRuntimeFiles(ctx, platformKey) {
187
189
  stat = fs.statSync(targetPath);
188
190
  } catch (error) {
189
191
  if (error.code === 'ENOENT') {
190
- if (key === 'codex' && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
192
+ if ((key === 'codex' || key === 'omp') && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
191
193
  continue;
192
194
  }
193
195
  preserved.add(targetPath);
@@ -235,7 +237,7 @@ function removeObsoleteRuntimeFiles(ctx, platformKey) {
235
237
  }
236
238
  const allPreserved = (ctx.obsoleteRuntimePreserved ||= new Set());
237
239
  for (const target of preserved) allPreserved.add(target);
238
- const selected = (ctx.platforms || [platformKey]).filter(key => key === 'claude' || key === 'codex');
240
+ const selected = (ctx.platforms || [platformKey]).filter(key => ['claude', 'codex', 'omp'].includes(key));
239
241
  if (selected.every(key => completed.has(key)) && allPreserved.size) {
240
242
  ctx.ui.warn(`Preserved ${allPreserved.size} obsolete runtime item(s); review and remove manually if no longer needed: ${[...allPreserved].join(', ')}`);
241
243
  }
@@ -151,6 +151,41 @@ function windowsHookCommand(script, projectRoot) {
151
151
  return WINDOWS_LAUNCHER + encoded;
152
152
  }
153
153
 
154
+ // The Codex plugin cannot carry absolute paths: its root moves on every update and
155
+ // differs per machine. Codex exports PLUGIN_ROOT to plugin hooks, so both launchers
156
+ // resolve it at run time, and the PATH floor lists only machine-independent aliases.
157
+ const PLUGIN_NODE_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin'];
158
+
159
+ /** Launchers for one hook script inside the Codex plugin tree. */
160
+ function pluginHookCommands(script) {
161
+ if (!/^[a-z0-9-]+\.cjs$/.test(script)) throw new Error(`Unsupported Codex plugin hook script: ${script}`);
162
+ const fallbacks = PLUGIN_NODE_FALLBACKS.map((dir) => `:${shellQuote(dir)}`).join('');
163
+ return {
164
+ command: `PATH="$PATH"${fallbacks} node "\${PLUGIN_ROOT}/hooks/${script}"`,
165
+ commandWindows: `node -e "process.argv[1]=require('path').join(process.env.PLUGIN_ROOT,'hooks','${script}');require('module').runMain()"`,
166
+ };
167
+ }
168
+
169
+ /** The plugin's hooks.json: the managed template with every launcher rebound to PLUGIN_ROOT. */
170
+ function pluginHooksConfig(template) {
171
+ const hooks = {};
172
+ for (const [eventName, groups] of Object.entries(template.hooks || {})) {
173
+ hooks[eventName] = groups.map((group) => ({
174
+ ...group,
175
+ hooks: group.hooks.map((handler) => {
176
+ const match = String(handler.command).match(HOOK_COMMAND_TEMPLATE);
177
+ if (!match) throw new Error(`Unsupported Codex hook command: ${handler.command}`);
178
+ return { ...handler, ...pluginHookCommands(match[1]) };
179
+ }),
180
+ }));
181
+ }
182
+ return {
183
+ ...template,
184
+ description: 'CafeKit lifecycle hooks for the Codex plugin. Review and trust them with /hooks before they run.',
185
+ hooks,
186
+ };
187
+ }
188
+
154
189
  /** Rewrite a managed template handler into absolute launchers for both platforms. */
155
190
  function materializeHookCommands(handler, projectRoot) {
156
191
  const next = { ...handler };
@@ -317,8 +352,11 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
317
352
  }
318
353
 
319
354
  module.exports = {
355
+ managedHandlerScript,
320
356
  mergeCodexHooks,
321
357
  pruneObsoleteCodexHooks,
322
358
  repairLegacyCodexLaunchers,
323
359
  hookScript,
360
+ pluginHookCommands,
361
+ pluginHooksConfig,
324
362
  };