@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
package/bin/install.js CHANGED
@@ -16,7 +16,8 @@
16
16
  */
17
17
 
18
18
  const { spawnSync } = require('child_process');
19
- const { buildContext, PLATFORMS, packageJson } = require('./lib/context');
19
+ const fs = require('fs');
20
+ const { buildContext, parseInstallerArgs, PLATFORMS, packageJson } = require('./lib/context');
20
21
  const lock = require('./lib/lock');
21
22
  const backup = require('./lib/backup');
22
23
  const manifestLib = require('./lib/manifest');
@@ -45,6 +46,28 @@ const { runPostInstall } = require('./phases/post-install');
45
46
  const { setupSkillDeps } = require('./phases/skills-setup');
46
47
  const { printSummary } = require('./phases/summary');
47
48
 
49
+ function preflightTrackedRootFiles(ctx) {
50
+ const files = ['AGENTS.md'];
51
+ if (ctx.platforms.includes('claude')) files.push('CLAUDE.md');
52
+ for (const file of files) {
53
+ try {
54
+ fs.lstatSync(file);
55
+ continue;
56
+ } catch (error) {
57
+ if (error.code !== 'ENOENT') throw error;
58
+ }
59
+ const result = spawnSync('git', ['ls-files', '-v', '--error-unmatch', '--', file], {
60
+ cwd: process.cwd(), encoding: 'utf8', timeout: 3000, shell: false,
61
+ });
62
+ if (!result.error && result.status === 0) {
63
+ if (result.stdout[0]?.toUpperCase() === 'S') continue;
64
+ throw new Error(`${file} is tracked by git but missing from the working tree; restore it with \`git checkout -- ${file}\`, or \`git rm ${file}\` if the deletion is intended, then rerun the installer.`);
65
+ }
66
+ if (!result.error && (result.status === 1 || /not a git repository/i.test(result.stderr || ''))) continue;
67
+ ctx.ui.warn(`Could not confirm git state for ${file}; installing a fresh copy.`);
68
+ }
69
+ }
70
+
48
71
  /** Install a single platform: payload + runtime + metadata, under one spinner. */
49
72
  function installPlatform(ctx, platformKey) {
50
73
  const platform = PLATFORMS[platformKey];
@@ -97,6 +120,7 @@ function printHelp() {
97
120
  console.log(`CafeKit installer (cafekit) v${packageJson.version}
98
121
 
99
122
  Usage: npx @haposoft/cafekit [options]
123
+ npx @haposoft/cafekit@latest migrate [--apply]
100
124
 
101
125
  Installs CafeKit skills, agents, rules and runtime into the current project
102
126
  for Claude Code and/or Codex CLI. Re-runs are selective: managed files are updated,
@@ -116,12 +140,22 @@ Options:
116
140
  -h, --help Show this help
117
141
  -v, --version Print version
118
142
 
143
+ Commands:
144
+ migrate Preview removing this repository's npx install so the
145
+ CafeKit Claude Code and Codex plugins take over; edited
146
+ files and runtime.json stay. Writes nothing.
147
+ migrate --apply Remove it, with a backup in .cafekit-backup/ that is
148
+ restored if anything fails.
149
+
119
150
  Docs: https://github.com/haposoft/cafekit`);
120
151
  }
121
152
 
122
153
  async function main() {
123
154
  // Info flags short-circuit before any side effects.
124
155
  const argv = process.argv.slice(2);
156
+ if (argv[0] === 'migrate') {
157
+ process.exit(await runMigrate(argv.slice(1)));
158
+ }
125
159
  if (argv.includes('--help') || argv.includes('-h')) {
126
160
  printHelp();
127
161
  process.exit(0);
@@ -131,6 +165,15 @@ async function main() {
131
165
  process.exit(0);
132
166
  }
133
167
 
168
+ // Reject a mistyped argument before the lock and before anything is written.
169
+ try {
170
+ parseInstallerArgs(process.argv);
171
+ } catch (error) {
172
+ console.error(`✗ ${error.message}`);
173
+ console.error(' Run npx @haposoft/cafekit --help for the options.');
174
+ process.exit(error.exitCode || 1);
175
+ }
176
+
134
177
  // ── Concurrency guard (before context; plain output) ────
135
178
  const got = lock.acquire();
136
179
  if (!got.acquired) {
@@ -175,6 +218,8 @@ async function main() {
175
218
  await selectDocumentSkills(ctx);
176
219
  if (ctx.cancelled) { lock.release(); process.exit(0); }
177
220
 
221
+ preflightTrackedRootFiles(ctx);
222
+
178
223
  // ── Pre-run snapshot for rollback ─────────────────────
179
224
  // Capture platform folders AND the root files the pipeline mutates
180
225
  // (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
@@ -226,4 +271,82 @@ async function main() {
226
271
  process.exit(exitCode);
227
272
  }
228
273
 
274
+ function printMigrateHelp(stream = console.log) {
275
+ stream(`Usage: npx @haposoft/cafekit@latest migrate [--apply]
276
+
277
+ Removes this repository's CafeKit npx install so the Claude Code and Codex
278
+ plugins take over. Without --apply it only prints the plan.
279
+
280
+ Removed: installed files that still match their install hash and have a plugin
281
+ equivalent, CafeKit hook entries, and the CafeKit instruction blocks.
282
+ Kept: edited files, runtime.json, files you added, skills no plugin ships yet,
283
+ and every user entry in settings.json, hooks.json, AGENTS.md and CLAUDE.md.
284
+ A repository with an .omp/ install is refused.`);
285
+ }
286
+
287
+ /** `migrate [--apply]`: returns the exit code (0 done or nothing to do, 1 failed or refused, 2 bad usage). */
288
+ async function runMigrate(args) {
289
+ let apply = false;
290
+ for (const arg of args) {
291
+ if (arg === '--apply') apply = true;
292
+ else if (arg === '--help' || arg === '-h') {
293
+ printMigrateHelp();
294
+ return 0;
295
+ } else {
296
+ console.error(`✗ Unknown argument for migrate: ${arg}`);
297
+ printMigrateHelp(console.error);
298
+ return 2;
299
+ }
300
+ }
301
+
302
+ const { planMigration, planSteps, applyMigration } = require('./lib/migrate-apply');
303
+ const { renderManifestPlan } = require('./lib/migrate-plan');
304
+ const { renderMergedPlan } = require('./lib/migrate-merged');
305
+ const root = process.cwd();
306
+ let plan;
307
+ try {
308
+ plan = planMigration(root);
309
+ } catch (error) {
310
+ console.error(`✗ migrate could not read this repository: ${error.message}`);
311
+ return 1;
312
+ }
313
+ const steps = planSteps(plan);
314
+ console.log(`CafeKit migrate — ${apply ? 'apply' : 'dry run (nothing is written)'}\n`);
315
+ // The merged-file planner may mark hook scripts kept, so the manifest plan renders after it.
316
+ const manifestText = renderManifestPlan(plan.manifest);
317
+ if (manifestText) console.log(manifestText);
318
+ const mergedText = renderMergedPlan(plan.merged);
319
+ if (mergedText) console.log(`\nMerged files:\n${mergedText}`);
320
+
321
+ // An install migrate cannot read (refused) or does not manage (unmanaged) would be left
322
+ // behind while the rest goes, so nothing is changed until it is fixed.
323
+ const blocked = Object.values(plan.manifest.platforms).filter((platform) => ['refused', 'unmanaged'].includes(platform.status));
324
+ if (plan.merged.refused || blocked.length > 0) {
325
+ if (plan.merged.refused) console.error(`\n✗ Refused: ${plan.merged.refused}`);
326
+ for (const platform of blocked) console.error(`\n✗ ${platform.name} cannot be migrated: ${platform.reason}`);
327
+ console.error(' Nothing was changed. Fix or remove that install by hand, then run migrate again.');
328
+ return 1;
329
+ }
330
+ if (steps.length === 0) {
331
+ console.log('\nNothing to migrate: no CafeKit npx file here is planned for removal.');
332
+ return 0;
333
+ }
334
+ if (!apply) {
335
+ console.log(`\n${steps.length} changes planned. Folders left empty are removed too.`);
336
+ console.log('Run `npx @haposoft/cafekit@latest migrate --apply` to apply them, with a backup.');
337
+ return 0;
338
+ }
339
+
340
+ const result = await applyMigration(root, { plan });
341
+ if (result.ok) {
342
+ console.log(`\n✓ Removed ${result.removed} files and rewrote ${result.written}. Backup: ${result.backupDir}`);
343
+ console.log(' Install the plugins if you have not: see the CafeKit README.');
344
+ return 0;
345
+ }
346
+ console.error(`\n✗ migrate stopped (${result.reason}): ${result.message}`);
347
+ if (result.backupDir) console.error(` Backup: ${result.backupDir}`);
348
+ for (const step of result.manualSteps || []) console.error(` ${step}`);
349
+ return 1;
350
+ }
351
+
229
352
  main();
package/bin/lib/backup.js CHANGED
@@ -47,30 +47,30 @@ function validateRunId(runId) {
47
47
  * Recursively copy a directory tree (verbatim, no transforms). Used for
48
48
  * snapshot and restore. Skips if the source does not exist.
49
49
  */
50
- function copyTree(src, dest) {
50
+ function copyTree(src, dest, io = fs) {
51
51
  let stat;
52
52
  try {
53
- stat = fs.lstatSync(src);
53
+ stat = io.lstatSync(src);
54
54
  } catch (error) {
55
55
  if (error.code === 'ENOENT') return;
56
56
  throw error;
57
57
  }
58
58
  if (stat.isSymbolicLink()) {
59
- fs.mkdirSync(path.dirname(dest), { recursive: true });
60
- fs.symlinkSync(fs.readlinkSync(src), dest);
59
+ io.mkdirSync(path.dirname(dest), { recursive: true });
60
+ io.symlinkSync(io.readlinkSync(src), dest);
61
61
  return;
62
62
  }
63
63
  if (stat.isDirectory()) {
64
- fs.mkdirSync(dest, { recursive: true });
65
- for (const entry of fs.readdirSync(src)) {
66
- copyTree(path.join(src, entry), path.join(dest, entry));
64
+ io.mkdirSync(dest, { recursive: true });
65
+ for (const entry of io.readdirSync(src)) {
66
+ copyTree(path.join(src, entry), path.join(dest, entry), io);
67
67
  }
68
68
  } else {
69
- fs.mkdirSync(path.dirname(dest), { recursive: true });
70
- fs.copyFileSync(src, dest);
69
+ io.mkdirSync(path.dirname(dest), { recursive: true });
70
+ io.copyFileSync(src, dest);
71
71
  // Preserve restrictive perms (e.g. .env at 0o600) on the backup copy.
72
72
  try {
73
- fs.chmodSync(dest, stat.mode);
73
+ io.chmodSync(dest, stat.mode);
74
74
  } catch {
75
75
  /* best-effort: chmod may fail on some filesystems */
76
76
  }
@@ -111,35 +111,54 @@ function snapshot(folders, runId) {
111
111
  * Restore every explicit target to its pre-run state. Existing targets are
112
112
  * replaced from backup; targets absent before the run are removed.
113
113
  *
114
+ * A target that fails does not stop the others: every target is attempted, and
115
+ * the failures are thrown together at the end as `error.restoreErrors`
116
+ * ([{ target, message }]) so the caller can name what to put back by hand.
117
+ *
114
118
  * @param {string} backupDir path returned by snapshot()
119
+ * @param {{fs?: object}} options file-system implementation (tests inject failures)
115
120
  */
116
- function restore(backupDir) {
117
- if (!backupDir || !fs.existsSync(backupDir)) return;
121
+ function restore(backupDir, options = {}) {
122
+ const io = options.fs || fs;
123
+ if (!backupDir || !io.existsSync(backupDir)) return;
118
124
 
119
125
  const metadataPath = path.join(backupDir, SNAPSHOT_METADATA);
120
- const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
126
+ const metadata = JSON.parse(io.readFileSync(metadataPath, 'utf8'));
121
127
  if (metadata?.schemaVersion !== 1 || !Array.isArray(metadata.targets)) {
122
128
  throw new Error(`Invalid backup metadata: ${metadataPath}`);
123
129
  }
124
-
125
130
  for (const record of metadata.targets) {
126
131
  if (!record || typeof record.existed !== 'boolean') {
127
132
  throw new Error(`Invalid backup target metadata: ${metadataPath}`);
128
133
  }
134
+ validateTarget(record.target);
135
+ }
136
+
137
+ const restoreErrors = [];
138
+ for (const record of metadata.targets) {
129
139
  const target = validateTarget(record.target);
130
- const source = path.join(backupDir, SNAPSHOT_DATA, target);
131
- let sourceExists = true;
132
140
  try {
133
- fs.lstatSync(source);
141
+ const source = path.join(backupDir, SNAPSHOT_DATA, target);
142
+ let sourceExists = true;
143
+ try {
144
+ io.lstatSync(source);
145
+ } catch (error) {
146
+ if (error.code === 'ENOENT') sourceExists = false;
147
+ else throw error;
148
+ }
149
+ if (record.existed && !sourceExists) {
150
+ throw new Error(`Backup data missing for target: ${target}`);
151
+ }
152
+ io.rmSync(target, { recursive: true, force: true });
153
+ if (record.existed) copyTree(source, target, io);
134
154
  } catch (error) {
135
- if (error.code === 'ENOENT') sourceExists = false;
136
- else throw error;
137
- }
138
- if (record.existed && !sourceExists) {
139
- throw new Error(`Backup data missing for target: ${target}`);
155
+ restoreErrors.push({ target, message: error.message });
140
156
  }
141
- fs.rmSync(target, { recursive: true, force: true });
142
- if (record.existed) copyTree(source, target);
157
+ }
158
+ if (restoreErrors.length > 0) {
159
+ const error = new Error(`Could not restore ${restoreErrors.map((item) => item.target).join(', ')} from ${backupDir}`);
160
+ error.restoreErrors = restoreErrors;
161
+ throw error;
143
162
  }
144
163
  }
145
164
 
@@ -8,6 +8,22 @@ const { preserveAddressingSection } = require('./instruction-blocks');
8
8
  const CODEX_BLOCK_START = '<!-- CAFEKIT CODEX START -->';
9
9
  const CODEX_BLOCK_END = '<!-- CAFEKIT CODEX END -->';
10
10
  const INSTRUCTION_EXTENSIONS = new Set(['.md', '.mdx', '.txt']);
11
+ const CODEX_ASK_DESCRIPTION_BOUNDARY = 'A request to fix or change something is still answered without editing any file; point the user to $cf-fix.';
12
+ const CODEX_ASK_NOTE = 'Rule 1 (Codex): never edit, create, or delete files in this skill, '
13
+ + 'even when the user says fix it or sửa giúp luôn. Do not run commands that write files. '
14
+ + 'Answer the cause with evidence and tell the user to run $cf-fix.';
15
+ const CODEX_RESEARCH_DEPTH_MARKER = 'Codex research depth: treat a settled repository decision that remains in force as a known option.';
16
+ const CODEX_RESEARCH_DEPTH_NOTE = CODEX_RESEARCH_DEPTH_MARKER
17
+ + ' Use Quick for that decision. An outdated supporting rationale alone does not raise depth '
18
+ + 'unless correcting it changes the recommendation. Conflicting evidence that changes the '
19
+ + 'recommendation still requires escalation under the depth table.';
20
+ const CODEX_SPECS_SCOPE_MARKER = 'Codex scope gate: put the GATE-SCOPE question in the final answer of the turn.';
21
+ const CODEX_SPECS_SCOPE_NOTE = CODEX_SPECS_SCOPE_MARKER
22
+ + ' Ask it there, not in commentary or through `request_user_input`. Do not send a summary after the question. '
23
+ + 'For each item needing a decision, use `[NEEDS CLARIFICATION: <question>]`, replacing <question> '
24
+ + 'with the real question, not a placeholder or a quotation of this note. Offer EXPAND, KEEP, and CUT together. '
25
+ + 'Preserve the exact words NEEDS CLARIFICATION, EXPAND, KEEP, and CUT; do not translate them. '
26
+ + 'Wait for the user to decide; do not choose scope or implement code on their behalf.';
11
27
 
12
28
  const AGENT_NAMES = [
13
29
  'brainstormer',
@@ -200,11 +216,15 @@ function normalizeAskUserQuestion(content) {
200
216
  const oneSeparator = '(?:[^\\S\\r\\n]+|[^\\S\\r\\n]*\\r?\\n[^\\S\\r\\n]*)';
201
217
  const pattern = new RegExp(
202
218
  `(?:(\\b(?:${determinerPattern}|[0-9]+)\\b)(${oneSeparator}))?`
203
- + `(\\bAskUserQuestion\\b|${masked.askMarkerPattern})`,
219
+ + `(\\bAskUserQuestion\\b|${masked.askMarkerPattern}|\\bnative(?:\\s+structured user-input|\\s+input)\\s+tool\\b)`,
204
220
  'g'
205
221
  );
206
- const normalized = masked.content.replace(pattern, (match, determiner, spacing, _token, offset, source) => {
207
- const literalIndex = match.indexOf('AskUserQuestion');
222
+ const withoutRuntimePossessive = masked.content.replace(
223
+ /\bthe runtime['’]s\s+native(?:\s+structured user-input|\s+input)\s+tool\b/g,
224
+ "a question in the turn's final answer"
225
+ );
226
+ const normalized = withoutRuntimePossessive.replace(pattern, (match, determiner, spacing, _token, offset, source) => {
227
+ const literalIndex = match.search(/AskUserQuestion|\bnative\b/);
208
228
  const tokenOffset = offset + (literalIndex >= 0
209
229
  ? literalIndex
210
230
  : match.indexOf(masked.askMarkerPrefix));
@@ -219,18 +239,33 @@ function normalizeAskUserQuestion(content) {
219
239
  const normalizedDeterminer = /^an$/i.test(determiner)
220
240
  ? (determiner[0] === 'A' ? 'A' : 'a')
221
241
  : determiner;
222
- return `${normalizedDeterminer}${spacing}structured user-input request`;
242
+ return `${normalizedDeterminer}${spacing}question in the turn's final answer`;
223
243
  }
224
244
 
225
245
  const trailing = source.slice(offset + match.length);
226
246
  const pluralContext = new RegExp(`^${oneSeparator}(?:calls|batches)\\b`, 'i').test(trailing);
227
- return `${pluralContext ? '' : 'a '}structured user-input request`;
247
+ return `${pluralContext ? '' : 'a '}question in the turn's final answer`;
228
248
  });
229
249
  return masked.restore(normalized);
230
250
  }
231
251
 
232
- function normalizeRuntimePaths(content) {
233
- return normalizeSourcePaths(content, {
252
+ function normalizeRuntimePaths(content, sourcePath = '') {
253
+ const stateRootsPattern = /const RUNTIME_STATE_ROOTS = Object\.freeze\(\[[\s\S]*?\]\);/;
254
+ const sharedStateRoots = path.basename(sourcePath) === 'provenance.cjs'
255
+ ? content.match(stateRootsPattern)?.[0] : null;
256
+ const preservedRoots = [];
257
+ let markerPrefix = '\uE000CAFEKIT_HOST_';
258
+ while (content.includes(markerPrefix)) markerPrefix += '_';
259
+ const rootToken = /(?<![\w/\\])\.claude(?:[/\\])?(?![\w/\\.-])/g;
260
+ const instruction = isInstructionAsset(sourcePath) ? content.replace(/^.*$/gm, line => {
261
+ if (!/(?<![\w/\\])\.codex(?:[/\\])?(?![\w/\\.-])/.test(line)) return line;
262
+ return line.replace(rootToken, original => {
263
+ const marker = `${markerPrefix}${preservedRoots.length}\uE001`;
264
+ preservedRoots.push([marker, original]);
265
+ return marker;
266
+ });
267
+ }) : content;
268
+ let next = normalizeSourcePaths(instruction, {
234
269
  runtimeRoot: '.codex',
235
270
  skillsRoot: '.agents/skills'
236
271
  })
@@ -239,8 +274,21 @@ function normalizeRuntimePaths(content) {
239
274
  .replace(/(?<!~\/)\.claude(?=[/\\])/g, '.codex')
240
275
  .replace(/(['"])\.claude\1/g, '$1.codex$1')
241
276
  .replace(/`\.claude`/g, '`.codex`');
277
+ // This table describes every runtime's generated state, not the install root.
278
+ if (sharedStateRoots) next = next.replace(stateRootsPattern, () => sharedStateRoots);
279
+ if (/\/ai-multimodal\/scripts\/(?:media_optimizer|document_converter)\.py$/.test(sourcePath.replace(/\\/g, '/'))) {
280
+ // Shared skills live under .agents; native support/config still lives in .codex.
281
+ next = next.replace(/\bclaude_dir = skills_dir\.parent\b/g,
282
+ "claude_dir = skills_dir.parent.parent / '.codex'");
283
+ }
284
+ for (const [marker, original] of preservedRoots) next = next.split(marker).join(original);
285
+ return next;
242
286
  }
243
287
 
288
+ // Matches the vibe invocation list across a hard line wrap.
289
+ const VIBE_INVOCATION_CLAUDE = new RegExp(
290
+ '\\(`/cf:vibe` from the plugin, `/vibe` from a project install, `\\$cf-vibe` on Codex\\)'.replace(/ /g, '\\s+'), 'g');
291
+
244
292
  function normalizeSkillNames(content) {
245
293
  return content
246
294
  .replace(/\/cf:([a-z0-9-]+)/gi, (_match, name) => `$cf-${name}`)
@@ -262,6 +310,7 @@ function normalizeAgentNames(content) {
262
310
 
263
311
  function normalizeAgentInvocations(content) {
264
312
  return content
313
+ .replace(/\bTask\s*\(\s*Explore\s*\)/g, 'spawn_agent(agent_type="explorer", fork_turns="none", task_name="explore", message="Describe the scan")')
265
314
  .replace(/\bAgent\s*\(/g, 'spawn_agent(')
266
315
  .replace(/\bsubagent_type\s*=\s*"([^"]+)"/g, (_match, name) => (
267
316
  `agent_type="${normalizeAgentRole(name)}", fork_turns="none"`
@@ -286,15 +335,63 @@ function isInstructionAsset(sourcePath) {
286
335
  }
287
336
 
288
337
  function normalizeCodexBody(content, sourcePath = '') {
289
- let next = normalizeRuntimePaths(String(content));
338
+ let next = normalizeRuntimePaths(String(content), sourcePath);
290
339
  next = next
291
- .replace(/\bCLAUDE\.md\b/g, 'AGENTS.md')
340
+ .replace(/^.*$/gm, line => line.replace(/\bCLAUDE\.md\b/g, (name, offset) => (
341
+ isInstructionAsset(sourcePath) && /(?<![\w/\\])AGENTS\.md\b/.test(line)
342
+ && !/[\w/\\]/.test(line[offset - 1] || '') ? name : 'AGENTS.md'
343
+ )))
292
344
  .replace(/\bClaude Code\b/g, 'Codex CLI');
293
345
 
294
346
  if (!isInstructionAsset(sourcePath)) return next;
295
347
 
348
+ if (/(?:^|\/)rules\/orchestrator\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
349
+ next = next
350
+ .replace('When the current session or a subagent runs on a model below `fable` (e.g. `opus`, `sonnet`, `haiku`) and hits a hard problem',
351
+ 'When the current session or a subagent hits a hard problem')
352
+ .replace('It runs autonomously on the strongest available model',
353
+ 'It runs autonomously with high reasoning effort on the session model unless configuration selects another');
354
+ }
355
+
356
+ // The vibe-run exception lists every way to invoke vibe; on Codex only the `$cf-vibe` mention exists, and the
357
+ // generic rename below would turn the Claude names into the false "`$cf-vibe` from the plugin".
358
+ next = next.replace(VIBE_INVOCATION_CLAUDE, () => '(`$cf-vibe`)');
296
359
  next = normalizeAgentInvocations(normalizeAgentNames(normalizeSkillNames(next)));
297
- return applyReplacements(normalizeAskUserQuestion(next), INSTRUCTION_REPLACEMENTS);
360
+ next = next.replace(/`?CLAUDE_CODE_SUBAGENT_MODEL`?/g,
361
+ 'the configured agent model (`model` in `.codex/agents/<agent>.toml`, or `[agents].default_subagent_model` in native Codex configuration)');
362
+ next = applyReplacements(normalizeAskUserQuestion(next), INSTRUCTION_REPLACEMENTS);
363
+ const { frontmatter, body } = splitFrontmatter(next);
364
+ if (frontmatter.when_to_use && frontmatter.description) {
365
+ const description = `${frontmatter.description} Use when: ${frontmatter.when_to_use}`;
366
+ if (description.length <= 1024) {
367
+ const header = next.slice(0, next.length - body.length)
368
+ .replace(/^description:[^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)*/m,
369
+ () => `description: ${JSON.stringify(description)}`);
370
+ next = header + body;
371
+ }
372
+ }
373
+ // Only the installed ask skill receives this Codex-specific boundary, never eval prompts.
374
+ if (/(?:^|\/)skills\/ask\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
375
+ const { frontmatter, body } = splitFrontmatter(next);
376
+ const description = `${frontmatter.description} ${CODEX_ASK_DESCRIPTION_BOUNDARY}`;
377
+ if (description.length > 1024) throw new Error('Codex ask description exceeds 1024 characters');
378
+ const header = next.slice(0, next.length - body.length)
379
+ .replace(/^description:[^\r\n]*$/m, () => `description: ${JSON.stringify(description)}`);
380
+ next = `${header}\n${CODEX_ASK_NOTE}\n\n${body}`;
381
+ }
382
+ // Only the installed research skill receives this note, never prompts or templates.
383
+ if (/(?:^|\/)skills\/research\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
384
+ const { body } = splitFrontmatter(next);
385
+ const header = next.slice(0, next.length - body.length);
386
+ next = `${header}\n${CODEX_RESEARCH_DEPTH_NOTE}\n\n${body}`;
387
+ }
388
+ // Only the installed specs skill receives this note, never prompts or templates.
389
+ if (/(?:^|\/)skills\/specs\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/')) && !next.includes(CODEX_SPECS_SCOPE_NOTE)) {
390
+ const { body } = splitFrontmatter(next);
391
+ const header = next.slice(0, next.length - body.length);
392
+ next = `${header}\n${CODEX_SPECS_SCOPE_NOTE}\n\n${body}`;
393
+ }
394
+ return next;
298
395
  }
299
396
 
300
397
  function getCodexCopyOptions(baseOptions = {}) {
@@ -307,14 +404,42 @@ function getCodexCopyOptions(baseOptions = {}) {
307
404
  function convertCodexAgentContent(content, fileName = '') {
308
405
  const { frontmatter, body } = splitFrontmatter(content);
309
406
  const name = codexAgentName(frontmatter.name || fileName);
310
- const description = normalizeCodexBody(
407
+ let description = normalizeCodexBody(
311
408
  frontmatter.description || `CafeKit Codex agent: ${name}`
312
409
  );
313
- const instructions = normalizeCodexBody(body).trim();
410
+ let agentBody = body;
411
+ if (['git_ops', 'project_manager'].includes(name)) {
412
+ let inTeamSection = false;
413
+ agentBody = body.split('\n').flatMap(line => {
414
+ if (/^## /.test(line)) {
415
+ inTeamSection = /^## (?:Team Mode|Collaborative Interlocking \(Swarm Protocol\))\s*$/.test(line);
416
+ }
417
+ if (!inTeamSection) return [line];
418
+ if (/\b(?:TaskList|TaskGet|TaskUpdate|SendMessage|shutdown_request)\b|When instantiated as a team member|When triggered as an active teammate/.test(line)) return [];
419
+ return [line.replace(/^(\s*)\d+\. /, '$1- ')];
420
+ }).join('\n');
421
+ }
422
+ let instructions = normalizeCodexBody(agentBody).trim();
423
+ const readOnly = ['brainstormer', 'code_auditor', 'inspector', 'researcher'].includes(name);
424
+ if (readOnly) {
425
+ instructions += '\n\n## Read-only role\n\nRead-only role: never create, never edit, and never delete files, and never run commands that write to disk; return findings in your reply.';
426
+ }
427
+ const reasoning = [];
428
+ if (name === 'strategist') {
429
+ description = 'Autonomous advisory counsel with high reasoning effort; inherits the session model unless native spawn or agent defaults select another. No session model switch or user interview. Mention @strategist or spawn it for hard design, debugging, or trade-off calls. Advisory-only; returns advice, not code.';
430
+ instructions = instructions
431
+ .replace('consulted for counsel and running on the strongest available model',
432
+ 'consulted for counsel with high reasoning effort on the configured native model')
433
+ .replace(/## Runtime note\s+[\s\S]*$/,
434
+ '## Runtime note\n\nThis agent sets `model_reasoning_effort = "high"` and omits `model`. It inherits the session model unless an explicit native spawn or `[agents].default_subagent_model` selects another. No strongest-model selection is guaranteed. For deployment control, set `model` in `.codex/agents/strategist.toml` or configure native agent defaults in native Codex configuration; choose a model that supports high reasoning effort. Advice remains advisory-only and never counts as execution proof.');
435
+ reasoning.push('model_reasoning_effort = "high"');
436
+ }
314
437
 
315
438
  return [
316
439
  `name = ${JSON.stringify(name)}`,
317
440
  `description = ${JSON.stringify(description)}`,
441
+ ...(readOnly ? ['sandbox_mode = "read-only"'] : []),
442
+ ...reasoning,
318
443
  `developer_instructions = ${JSON.stringify(instructions)}`,
319
444
  ''
320
445
  ].join('\n');
@@ -365,6 +490,10 @@ function transformManagedCodexContent(content, transform) {
365
490
  }
366
491
 
367
492
  module.exports = {
493
+ CODEX_RESEARCH_DEPTH_MARKER,
494
+ CODEX_RESEARCH_DEPTH_NOTE,
495
+ CODEX_SPECS_SCOPE_MARKER,
496
+ CODEX_SPECS_SCOPE_NOTE,
368
497
  normalizeCodexBody,
369
498
  getCodexCopyOptions,
370
499
  codexAgentName,
@@ -128,7 +128,7 @@ const PLATFORMS = {
128
128
  detectFiles: ['.omp'],
129
129
  commandsDir: null,
130
130
  // omp discovers .claude/skills and .agents/skills itself, so CafeKit copies
131
- // no skill payload for it. See specs/omp-runtime-support/plan.md.
131
+ // no skill payload for it. See specs/archive/omp-runtime-support/plan.md.
132
132
  skillsDir: '.agents/skills',
133
133
  agentsDir: null,
134
134
  skillsRef: '.agents/skills',
@@ -245,6 +245,7 @@ function getCopyOptions(platformKey, baseOptions = {}) {
245
245
  * --upgrade / -u / --force / -f / --force-overwrite → forceOverwrite:true
246
246
  * (overwrite user-modified managed files, backing up the user copy first)
247
247
  * --dry-run → dryRun:true
248
+ * an unknown positional argument → throws with exitCode 2
248
249
  *
249
250
  * `upgrade` is kept as an alias of `forceOverwrite` for backward compatibility
250
251
  * with existing docs and muscle memory.
@@ -283,6 +284,10 @@ function parseInstallerArgs(argv) {
283
284
  args.lang = argv[++i];
284
285
  } else if (arg.startsWith('--lang=')) {
285
286
  args.lang = arg.slice('--lang='.length);
287
+ } else if (!arg.startsWith('-')) {
288
+ // A typo of a subcommand must not run an install.
289
+ const hint = arg === 'migrate' ? ' (migrate must be the first argument)' : '';
290
+ throw Object.assign(new Error(`Unknown argument: ${JSON.stringify(arg)}${hint}`), { exitCode: 2 });
286
291
  }
287
292
  }
288
293