@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,202 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Apply a `migrate` plan: remove exactly the planned files of an npx install and
5
+ * rewrite the merged files, with a backup that is restored on any failure.
6
+ *
7
+ * Order: cwd check → lock → snapshot → re-plan and compare → per-file re-check →
8
+ * writes → metadata → empty folders. Any error, or SIGINT between steps, restores
9
+ * every snapshotted target. No console output; the caller reports the result.
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { sha256 } = require('./manifest');
16
+ const backup = require('./backup');
17
+ const lock = require('./lock');
18
+ const { planManifestFiles } = require('./migrate-plan');
19
+ const { planMergedFiles } = require('./migrate-merged');
20
+
21
+ class MigrateError extends Error {
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ }
27
+
28
+ function planMigration(projectRoot) {
29
+ const manifest = planManifestFiles(projectRoot);
30
+ const merged = planMergedFiles(projectRoot, manifest);
31
+ return { manifest, merged };
32
+ }
33
+
34
+ /** Every change a plan makes, in apply order; two plans agree when their steps are equal. */
35
+ function planSteps(plan) {
36
+ const steps = [];
37
+ for (const entry of plan.manifest.entries) {
38
+ if (entry.action === 'remove') steps.push({ op: 'unlink', path: entry.path, sha256: entry.sha256 });
39
+ }
40
+ for (const file of plan.merged.files) {
41
+ if (file.action === 'write') steps.push({ op: 'write', path: file.path, sha256: sha256(file.before), content: file.after });
42
+ if (file.action === 'remove') steps.push({ op: 'unlink', path: file.path, sha256: sha256(file.before) });
43
+ }
44
+ // The install metadata goes last, and only once the install owns no file any more.
45
+ for (const platform of Object.values(plan.manifest.platforms)) {
46
+ if (platform.status !== 'planned') continue;
47
+ const owns = plan.manifest.entries.some((entry) => entry.platform === platform.platform && entry.action === 'keep');
48
+ if (!owns) for (const rel of platform.metadata) steps.push({ op: 'unlink-metadata', path: rel });
49
+ }
50
+ return steps;
51
+ }
52
+
53
+ /** Top-level paths the steps touch, which is what the backup snapshots. */
54
+ function snapshotTargets(steps) {
55
+ return [...new Set(steps.map((step) => step.path.split('/')[0]))].sort();
56
+ }
57
+
58
+ const nextTick = () => new Promise((resolve) => setImmediate(resolve));
59
+
60
+ /** Refuse to touch a path that changed since it was planned or that is not a plain file. */
61
+ function verifyStep(io, abs, step) {
62
+ if (step.op === 'unlink-metadata') return;
63
+ const stat = io.lstatSync(abs);
64
+ if (stat.isSymbolicLink() || !stat.isFile() || sha256(io.readFileSync(abs)) !== step.sha256) {
65
+ throw new MigrateError('stale', `${step.path} changed since it was planned`);
66
+ }
67
+ }
68
+
69
+ function performStep(io, abs, step) {
70
+ if (step.op === 'write') {
71
+ // Write beside the target and rename over it, so the file is never half written. The
72
+ // temp name is unpredictable and created with `wx`, so a planted symlink is never
73
+ // followed; it keeps the target's mode, and it is removed again if the write fails,
74
+ // because a temp file beside a root-level file lies outside every snapshot target.
75
+ const temp = `${abs}.cafekit-migrate-${crypto.randomBytes(6).toString('hex')}`;
76
+ const mode = io.lstatSync(abs).mode & 0o7777;
77
+ let opened = false;
78
+ try {
79
+ io.writeFileSync(temp, step.content, { flag: 'wx', mode });
80
+ opened = true;
81
+ io.chmodSync(temp, mode);
82
+ io.renameSync(temp, abs);
83
+ } catch (error) {
84
+ // writeFileSync creates the file before writing it, so a failed write (ENOSPC)
85
+ // still leaves a temp behind; only an EEXIST from the create means the name is
86
+ // someone else's.
87
+ if (opened || error.code !== 'EEXIST') {
88
+ try {
89
+ io.unlinkSync(temp);
90
+ } catch {
91
+ // Never created, or already gone.
92
+ }
93
+ }
94
+ throw error;
95
+ }
96
+ return;
97
+ }
98
+ if (step.op === 'unlink-metadata' && !io.existsSync(abs)) return;
99
+ io.unlinkSync(abs);
100
+ }
101
+
102
+ /** Remove folders the plan emptied, deepest first; a folder that still holds anything stays. */
103
+ function removeEmptyFolders(io, root, steps) {
104
+ const folders = new Set();
105
+ for (const step of steps) {
106
+ let dir = path.posix.dirname(step.path);
107
+ while (dir !== '.') {
108
+ folders.add(dir);
109
+ dir = path.posix.dirname(dir);
110
+ }
111
+ }
112
+ const deepestFirst = [...folders].sort((a, b) => b.split('/').length - a.split('/').length || a.localeCompare(b));
113
+ for (const rel of deepestFirst) {
114
+ try {
115
+ io.rmdirSync(path.join(root, rel));
116
+ } catch (error) {
117
+ if (!['ENOTEMPTY', 'EEXIST', 'ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param {string} projectRoot repository root; must equal the current working directory
124
+ * @param {{plan?: object, fs?: object, runId?: string, onStep?: Function}} options
125
+ * plan: the plan the user saw (re-planned and compared before any change)
126
+ * fs: file-system implementation for apply and restore (tests inject failures)
127
+ * @returns {Promise<object>} { ok, reason?, message?, backupDir?, removed, written, restoreErrors?, manualSteps? }
128
+ */
129
+ async function applyMigration(projectRoot, options = {}) {
130
+ const io = options.fs || fs;
131
+ const root = fs.realpathSync(projectRoot);
132
+ // backup.js and lock.js resolve paths from the working directory.
133
+ if (fs.realpathSync(process.cwd()) !== root) {
134
+ return { ok: false, reason: 'wrong-cwd', message: `Run migrate from the repository root: ${root}` };
135
+ }
136
+ const given = options.plan || planMigration(root);
137
+ if (given.merged.refused) return { ok: false, reason: 'refused', message: given.merged.refused };
138
+ const steps = planSteps(given);
139
+ if (steps.length === 0) return { ok: true, nothing: true, removed: 0, written: 0 };
140
+
141
+ const got = lock.acquire(root);
142
+ if (!got.acquired) {
143
+ const hint = got.reason === 'reclaiming'
144
+ ? `; if no CafeKit run is active, delete ${lock.LOCK_FILE} and ${lock.LOCK_FILE}.reclaim`
145
+ : '';
146
+ return { ok: false, reason: 'locked', message: `Another CafeKit run holds ${lock.LOCK_FILE}${got.pid ? ` (pid ${got.pid})` : ''}${hint}` };
147
+ }
148
+
149
+ let interrupted = false;
150
+ const onSigint = () => { interrupted = true; };
151
+ process.on('SIGINT', onSigint);
152
+ let backupDir = null;
153
+ let touched = false;
154
+ const done = { removed: 0, written: 0 };
155
+ try {
156
+ backupDir = backup.snapshot(snapshotTargets(steps), options.runId || `${Date.now()}-migrate`);
157
+ const fresh = planMigration(root);
158
+ if (fresh.merged.refused || JSON.stringify(planSteps(fresh)) !== JSON.stringify(steps)) {
159
+ throw new MigrateError('stale', 'The repository changed since the plan was shown; run migrate again to see the new plan');
160
+ }
161
+ for (const step of steps) {
162
+ await nextTick();
163
+ if (interrupted) throw new MigrateError('interrupted', 'Interrupted');
164
+ const abs = path.join(root, step.path);
165
+ verifyStep(io, abs, step);
166
+ touched = true;
167
+ performStep(io, abs, step);
168
+ if (step.op === 'write') done.written++;
169
+ else done.removed++;
170
+ if (typeof options.onStep === 'function') options.onStep(step);
171
+ }
172
+ await nextTick();
173
+ if (interrupted) throw new MigrateError('interrupted', 'Interrupted');
174
+ removeEmptyFolders(io, root, steps);
175
+ return { ok: true, backupDir, ...done };
176
+ } catch (error) {
177
+ let restoreErrors = [];
178
+ if (touched) {
179
+ try {
180
+ backup.restore(backupDir, { fs: io });
181
+ } catch (restoreError) {
182
+ restoreErrors = restoreError.restoreErrors || [{ target: '*', message: restoreError.message }];
183
+ }
184
+ }
185
+ const manualSteps = restoreErrors.map(({ target }) => (target === '*'
186
+ ? `Restore every folder and file under ${path.join(backupDir, 'data')} by hand`
187
+ : `Copy ${path.join(backupDir, 'data', target)} back to ${target}`));
188
+ // The rollback puts back the snapshot taken when apply started, so a change made to
189
+ // those folders while apply ran is not kept either.
190
+ const failed = new Set(restoreErrors.map((item) => item.target));
191
+ const rolledBack = snapshotTargets(steps).filter((target) => !failed.has(target) && !failed.has('*'));
192
+ const note = touched && rolledBack.length > 0
193
+ ? ` Rolled back ${rolledBack.join(', ')} to the snapshot taken when migrate started; edits made there while it ran were not kept.`
194
+ : '';
195
+ return { ok: false, reason: error.code || 'failed', message: `${error.message.replace(/\.$/, '')}.${note}`, backupDir, restored: touched, restoreErrors, manualSteps, ...done };
196
+ } finally {
197
+ process.removeListener('SIGINT', onSigint);
198
+ lock.release(root);
199
+ }
200
+ }
201
+
202
+ module.exports = { applyMigration, planMigration, planSteps };
@@ -0,0 +1,342 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Plan the edits `migrate` makes to files CafeKit merged into rather than owned:
5
+ * Claude settings, Codex hooks, the instruction-file marker blocks and the root
6
+ * `.gitignore`. Only CafeKit entries go; every user entry and the ignore
7
+ * protection of every kept path stay. It reads files and never writes; when a hook
8
+ * config does not parse it marks the hook scripts of `manifestPlan` kept, so render
9
+ * the manifest plan after calling it.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { managedRange } = require('./instruction-blocks');
15
+ const { managedHandlerScript } = require('../phases/codex-hooks');
16
+
17
+ const SETTINGS_TEMPLATE = require('../../src/claude/settings/settings.json');
18
+ const CODEX_HOOKS_TEMPLATE = require('../../src/codex/hooks.json');
19
+
20
+ const CLAUDE_START = '<!-- CAFEKIT CLAUDE START -->';
21
+ const CLAUDE_END = '<!-- CAFEKIT CLAUDE END -->';
22
+ const CORE_START = '<!-- CAFEKIT CORE START -->';
23
+ const CORE_END = '<!-- CAFEKIT CORE END -->';
24
+ const CODEX_START = '<!-- CAFEKIT CODEX START -->';
25
+ const CODEX_END = '<!-- CAFEKIT CODEX END -->';
26
+
27
+ const GITIGNORE_HEADER = '# CafeKit / Ecosystem';
28
+ // The lines ensureGitignore() writes under its header (bin/phases/root-config.js),
29
+ // with the older plan patterns it migrates from.
30
+ const GITIGNORE_PATTERNS = new Set([
31
+ 'specs/_shared/', '.cafekit-backup/', '.cafekit.lock', '.omp/',
32
+ 'plans', 'plans/', 'plans/*', 'plans/**', 'plans/**/*', '!plans/*.md',
33
+ '!plans/templates', '!plans/templates/', '!plans/templates/*', '!plans/templates/**', '!plans/templates/**/*',
34
+ '.claude', '.claude/', '.codex', '.codex/', '.agents', '.agents/'
35
+ ]);
36
+ // Only the platform folders can disappear; every other block line always stays.
37
+ const FOLDER_OWNER = { '.claude/': 'claude', '.codex/': 'codex', '.agents/': 'codex' };
38
+ const REMOVABLE_FOLDERS = { '.claude': '.claude/', '.claude/': '.claude/', '.codex': '.codex/', '.codex/': '.codex/', '.agents': '.agents/', '.agents/': '.agents/' };
39
+
40
+ const CLAUDE_HOOK_COMMAND = /^node\s+(["']?)(?:\$\{?CLAUDE_PROJECT_DIR\}?\/)?\.claude\/hooks\/([a-z0-9-]+\.cjs)\1$/;
41
+ // Any mention of a top-level hook script, used to report handlers left pointing at a removed one.
42
+ const HOOK_SCRIPT_MENTION = /\.(claude|codex)[/\\]hooks[/\\]([A-Za-z0-9._-]+\.cjs)/g;
43
+ const CODEX_HOOK_SCRIPT = /'([^']*[/\\]\.codex[/\\]hooks[/\\][a-z0-9-]+\.cjs)'\s*$/;
44
+
45
+ function readOrNull(file) {
46
+ try {
47
+ return fs.readFileSync(file, 'utf8');
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return null;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ function templateCommands() {
55
+ const commands = new Set();
56
+ for (const groups of Object.values(SETTINGS_TEMPLATE.hooks || {})) {
57
+ for (const group of groups) for (const handler of group.hooks || []) commands.add(handler.command);
58
+ }
59
+ return commands;
60
+ }
61
+
62
+ /** Drop handlers `isCafekit` claims, then every group, event and hooks key left empty. */
63
+ function pruneHandlers(config, isCafekit) {
64
+ const dropped = [];
65
+ const hooks = {};
66
+ for (const [event, groups] of Object.entries(config.hooks || {})) {
67
+ if (!Array.isArray(groups)) {
68
+ hooks[event] = groups;
69
+ continue;
70
+ }
71
+ const kept = [];
72
+ for (const group of groups) {
73
+ if (!group || !Array.isArray(group.hooks)) {
74
+ kept.push(group);
75
+ continue;
76
+ }
77
+ const handlers = group.hooks.filter((handler) => {
78
+ const cafekit = isCafekit(handler);
79
+ if (cafekit) dropped.push(handler);
80
+ return !cafekit;
81
+ });
82
+ if (handlers.length > 0) kept.push({ ...group, hooks: handlers });
83
+ }
84
+ if (kept.length > 0) hooks[event] = kept;
85
+ }
86
+ const next = { ...config };
87
+ if (Object.keys(hooks).length > 0) next.hooks = hooks;
88
+ else delete next.hooks;
89
+ return { config: next, dropped };
90
+ }
91
+
92
+ const jsonText = (value) => `${JSON.stringify(value, null, 2)}\n`;
93
+
94
+ function fileAction(rel, before, after, extra = {}) {
95
+ if (after === null) return { path: rel, action: 'remove', before, after: null, ...extra };
96
+ return { path: rel, action: after === before ? 'unchanged' : 'write', before, after, ...extra };
97
+ }
98
+
99
+ function planClaudeSettings(root, removedPaths, keptPaths, reports) {
100
+ const rel = '.claude/settings.json';
101
+ const before = readOrNull(path.join(root, rel));
102
+ if (before === null) return null;
103
+ let config;
104
+ try {
105
+ config = JSON.parse(before);
106
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not an object');
107
+ } catch {
108
+ return { path: rel, action: 'unparsed', before, after: before, keepPrefixes: ['.claude/hooks/', '.claude/status.cjs'] };
109
+ }
110
+ const templates = templateCommands();
111
+ const { config: next, dropped } = pruneHandlers(config, (handler) => {
112
+ const command = typeof handler?.command === 'string' ? handler.command.trim() : '';
113
+ if (templates.has(command)) return true;
114
+ const script = command.match(CLAUDE_HOOK_COMMAND);
115
+ return Boolean(script && removedPaths.has(`.claude/hooks/${script[2]}`));
116
+ });
117
+ for (const handler of dropped) {
118
+ const script = String(handler.command).match(CLAUDE_HOOK_COMMAND);
119
+ if (script && keptPaths.has(`.claude/hooks/${script[2]}`)) {
120
+ reports.push(`.claude/hooks/${script[2]} is kept (edited) but no longer registered; the Claude plugin runs its own copy`);
121
+ }
122
+ }
123
+ reportDanglingHandlers(rel, next, removedPaths, reports);
124
+ if (next.statusLine?.command === SETTINGS_TEMPLATE.statusLine.command && removedPaths.has('.claude/status.cjs')) {
125
+ delete next.statusLine;
126
+ reports.push('The CafeKit status line goes with .claude/status.cjs; run `/cf:setup-statusline` to set it up from the plugin');
127
+ }
128
+ const after = Object.keys(next).length === 0 ? null : jsonText(next);
129
+ return fileAction(rel, before, after === jsonText(config) ? before : after, { dropped: dropped.length });
130
+ }
131
+
132
+ /** A handler that stays but still runs a hook script the plan removes would fail on every event. */
133
+ function reportDanglingHandlers(rel, config, removedPaths, reports) {
134
+ for (const handler of Object.values(config.hooks || {}).flat().flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []))) {
135
+ const text = [handler?.command, handler?.commandWindows].filter((value) => typeof value === 'string').join(' ');
136
+ for (const match of text.matchAll(HOOK_SCRIPT_MENTION)) {
137
+ if (removedPaths.has(`.${match[1]}/hooks/${match[2]}`)) {
138
+ reports.push(`A handler kept in ${rel} still runs .${match[1]}/hooks/${match[2]}, which is removed; edit or delete it: ${handler.command}`);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ function planCodexHooks(root, removedPaths, keptPaths, reports) {
145
+ const rel = '.codex/hooks.json';
146
+ const before = readOrNull(path.join(root, rel));
147
+ if (before === null) return null;
148
+ let config;
149
+ try {
150
+ config = JSON.parse(before);
151
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not an object');
152
+ } catch {
153
+ return { path: rel, action: 'unparsed', before, after: before, keepPrefixes: ['.codex/hooks/'] };
154
+ }
155
+ const { config: next, dropped } = pruneHandlers(config, (handler) => Boolean(managedHandlerScript(handler)));
156
+ if (!next.hooks && next.description === CODEX_HOOKS_TEMPLATE.description) delete next.description;
157
+ for (const handler of dropped) {
158
+ const script = managedHandlerScript(handler);
159
+ if (keptPaths.has(`.codex/hooks/${script}`)) {
160
+ reports.push(`.codex/hooks/${script} is kept (edited) but no longer registered; the Codex plugin runs its own copy`);
161
+ }
162
+ }
163
+ reportDanglingHandlers(rel, next, removedPaths, reports);
164
+ const after = Object.keys(next).length === 0 ? null : jsonText(next);
165
+ const action = fileAction(rel, before, after === jsonText(config) ? before : after, { dropped: dropped.length });
166
+ action.standDown = codexStandDown(root, next, removedPaths);
167
+ if (action.standDown.length > 0) {
168
+ reports.push(`The Codex plugin keeps standing down while these handlers stay in .codex/hooks.json: ${action.standDown.join('; ')}`);
169
+ }
170
+ return action;
171
+ }
172
+
173
+ /**
174
+ * The handlers that would still make the Codex plugin stand down after the plan.
175
+ * Mirrors hasProjectCafekitHooks() in src/codex/hooks/lib/hook-context.cjs, evaluated
176
+ * on the planned contents and on the files that survive the plan.
177
+ */
178
+ function codexStandDown(root, config, removedPaths) {
179
+ const commands = Object.values(config?.hooks || {}).flat()
180
+ .flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []))
181
+ .map((handler) => String(handler?.command || ''));
182
+ const matched = commands
183
+ .map((command) => ({ command, script: command.match(CODEX_HOOK_SCRIPT)?.[1] }))
184
+ .filter((item) => item.script);
185
+ if (matched.length === 0) return [];
186
+ const expectedDir = path.join(root, '.codex', 'hooks');
187
+ let hooksDir;
188
+ try {
189
+ hooksDir = fs.realpathSync(expectedDir);
190
+ } catch {
191
+ return [];
192
+ }
193
+ const survives = ({ script }) => {
194
+ if (!path.isAbsolute(script) || path.dirname(script) !== expectedDir) return false;
195
+ const rel = path.relative(root, script).split(path.sep).join('/');
196
+ if (removedPaths.has(rel)) return false;
197
+ try {
198
+ const real = fs.realpathSync(script);
199
+ return path.dirname(real) === hooksDir && fs.statSync(real).isFile();
200
+ } catch {
201
+ return false;
202
+ }
203
+ };
204
+ return matched.every(survives) ? matched.map((item) => item.command) : [];
205
+ }
206
+
207
+ /**
208
+ * Remove one marker block, joining what surrounds it with a single blank line in the
209
+ * file's own line ending. Only blank lines are trimmed, so user indentation stays.
210
+ */
211
+ function cutRange(content, range) {
212
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
213
+ const before = content.slice(0, range.start).replace(/(?:\r?\n[ \t]*)+$/, '');
214
+ const after = content.slice(range.end).replace(/^[ \t]*(?:\r?\n[ \t]*)*\r?\n/, '').replace(/^[ \t]*$/, '');
215
+ if (!before.trim()) return after;
216
+ if (!after.trim()) return `${before}${eol}`;
217
+ return `${before}${eol}${eol}${after}`;
218
+ }
219
+
220
+ function planAgentsMd(root, blocks, reports) {
221
+ const rel = 'AGENTS.md';
222
+ const before = readOrNull(path.join(root, rel));
223
+ if (before === null) return null;
224
+ let after = before;
225
+ for (const [start, end] of blocks) {
226
+ const range = managedRange(after, start, end);
227
+ if (range === false) return { path: rel, action: 'unparsed', before, after: before, userText: true };
228
+ if (!range) continue;
229
+ if (start === CORE_START && after.slice(range.bodyStart, range.bodyEnd).includes('cafekit:lang')) {
230
+ reports.push('The response-language choice stored in the AGENTS.md CafeKit block goes with the block');
231
+ }
232
+ after = cutRange(after, range);
233
+ }
234
+ const userText = after.trim().length > 0;
235
+ return fileAction(rel, before, userText ? after : null, { userText });
236
+ }
237
+
238
+ function planClaudeMd(root, agents) {
239
+ const rel = 'CLAUDE.md';
240
+ const before = readOrNull(path.join(root, rel));
241
+ if (before === null) return null;
242
+ const range = managedRange(before, CLAUDE_START, CLAUDE_END);
243
+ if (range === false) return { path: rel, action: 'unparsed', before, after: before };
244
+ if (!range) return fileAction(rel, before, before);
245
+ let after = cutRange(before, range);
246
+ // The CafeKit block carried the `@AGENTS.md` import; keep loading the user's AGENTS.md text.
247
+ if (agents?.userText && !/^@AGENTS\.md\s*$/m.test(after)) {
248
+ after = after.trim() ? `@AGENTS.md\n\n${after}` : '@AGENTS.md\n';
249
+ }
250
+ return fileAction(rel, before, after.trim() ? after : null);
251
+ }
252
+
253
+ function planGitignore(root, survivingFolders) {
254
+ const rel = '.gitignore';
255
+ const before = readOrNull(path.join(root, rel));
256
+ if (before === null) return null;
257
+ const lines = before.split('\n');
258
+ const header = lines.findIndex((line) => line.trim() === GITIGNORE_HEADER);
259
+ if (header < 0) return fileAction(rel, before, before);
260
+ const removed = new Set();
261
+ for (let index = header + 1; index < lines.length && GITIGNORE_PATTERNS.has(lines[index].trim()); index++) {
262
+ const folder = REMOVABLE_FOLDERS[lines[index].trim()];
263
+ if (folder && !survivingFolders.has(folder)) removed.add(index);
264
+ }
265
+ const after = lines.filter((_, index) => !removed.has(index)).join('\n');
266
+ return fileAction(rel, before, after, { removedLines: [...removed].map((index) => lines[index].trim()) });
267
+ }
268
+
269
+ function planMergedFiles(projectRoot, manifestPlan) {
270
+ const root = fs.realpathSync(projectRoot);
271
+ const reports = [];
272
+ const refused = fs.existsSync(path.join(root, '.omp', 'cafekit-manifest.json'))
273
+ ? 'omp: an .omp/ install shares the instruction blocks and skill folders; migrate does not change it'
274
+ : null;
275
+
276
+ const removedPaths = new Set(manifestPlan.entries.filter((entry) => entry.action === 'remove').map((entry) => entry.path));
277
+ const keptPaths = new Set(manifestPlan.entries.filter((entry) => entry.action === 'keep').map((entry) => entry.path));
278
+ const claudePlanned = manifestPlan.platforms.claude?.status === 'planned';
279
+ const codexPlanned = manifestPlan.platforms.codex?.status === 'planned';
280
+
281
+ const files = [];
282
+ const settings = claudePlanned ? planClaudeSettings(root, removedPaths, keptPaths, reports) : null;
283
+ const hooks = codexPlanned ? planCodexHooks(root, removedPaths, keptPaths, reports) : null;
284
+ // CORE is shared by every runtime, so it goes only when no other install still reads it.
285
+ const installed = Object.values(manifestPlan.platforms).filter((platform) => platform.status !== 'absent');
286
+ const blocks = [
287
+ ...(installed.every((platform) => platform.status === 'planned') ? [[CORE_START, CORE_END]] : []),
288
+ ...(codexPlanned ? [[CODEX_START, CODEX_END]] : [])
289
+ ];
290
+ const agents = planAgentsMd(root, blocks, reports);
291
+ const claudeMd = claudePlanned ? planClaudeMd(root, agents) : null;
292
+ files.push(settings, hooks, agents, claudeMd);
293
+
294
+ // An unparsable hook config may still register any hook script, so all of them stay.
295
+ for (const unparsed of [settings, hooks].filter((file) => file?.action === 'unparsed')) {
296
+ for (const entry of manifestPlan.entries) {
297
+ if (entry.action === 'remove' && unparsed.keepPrefixes.some((prefix) => entry.path.startsWith(prefix))) {
298
+ Object.assign(entry, { action: 'keep', reason: 'required-by-kept' });
299
+ removedPaths.delete(entry.path);
300
+ }
301
+ }
302
+ }
303
+
304
+ const surviving = [
305
+ ...manifestPlan.entries.filter((entry) => entry.action === 'keep').map((entry) => entry.path),
306
+ ...manifestPlan.untracked,
307
+ ...files.filter((file) => file && file.action !== 'remove').map((file) => file.path)
308
+ ];
309
+ // A folder of an install migrate leaves alone (absent, refused, unmanaged) is never gone while it exists.
310
+ const survives = (folder) => (manifestPlan.platforms[FOLDER_OWNER[folder]]?.status === 'planned'
311
+ ? surviving.some((rel) => rel.startsWith(folder))
312
+ : fs.existsSync(path.join(root, folder)));
313
+ const survivingFolders = new Set(Object.values(REMOVABLE_FOLDERS).filter(survives));
314
+ files.push(planGitignore(root, survivingFolders));
315
+
316
+ const npxOnly = new Set(manifestPlan.entries.filter((entry) => entry.reason === 'npx-only' && entry.group)
317
+ .map((entry) => entry.group.name.replace(/_/g, '-')));
318
+ if (npxOnly.size > 0) {
319
+ reports.push(`No plugin ships these, so their npx copies stay: ${[...npxOnly].sort().join(', ')}`);
320
+ }
321
+ if (claudePlanned) {
322
+ reports.push('Grok CLI reads hooks from .claude/settings.json; a Grok setup that used this Claude install has no CafeKit hooks afterwards');
323
+ }
324
+ return { refused, files: files.filter(Boolean), reports };
325
+ }
326
+
327
+ function renderMergedPlan(plan) {
328
+ const lines = [];
329
+ if (plan.refused) lines.push(`Refused — ${plan.refused}`);
330
+ for (const file of plan.files) {
331
+ const detail = file.action === 'write' && file.dropped !== undefined ? ` (${file.dropped} CafeKit handlers removed)`
332
+ : file.removedLines?.length ? ` (lines removed: ${file.removedLines.join(', ')})` : '';
333
+ lines.push(` ${file.action.padEnd(9)} ${file.path}${detail}`);
334
+ }
335
+ if (plan.reports.length > 0) {
336
+ lines.push('', 'The plugins will not take over:');
337
+ for (const report of plan.reports) lines.push(` - ${report}`);
338
+ }
339
+ return lines.join('\n');
340
+ }
341
+
342
+ module.exports = { planMergedFiles, renderMergedPlan };