@haposoft/cafekit 0.18.1 → 0.18.2

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 (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -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,6 +274,15 @@ 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
 
244
288
  function normalizeSkillNames(content) {
@@ -262,6 +306,7 @@ function normalizeAgentNames(content) {
262
306
 
263
307
  function normalizeAgentInvocations(content) {
264
308
  return content
309
+ .replace(/\bTask\s*\(\s*Explore\s*\)/g, 'spawn_agent(agent_type="explorer", fork_turns="none", task_name="explore", message="Describe the scan")')
265
310
  .replace(/\bAgent\s*\(/g, 'spawn_agent(')
266
311
  .replace(/\bsubagent_type\s*=\s*"([^"]+)"/g, (_match, name) => (
267
312
  `agent_type="${normalizeAgentRole(name)}", fork_turns="none"`
@@ -286,15 +331,60 @@ function isInstructionAsset(sourcePath) {
286
331
  }
287
332
 
288
333
  function normalizeCodexBody(content, sourcePath = '') {
289
- let next = normalizeRuntimePaths(String(content));
334
+ let next = normalizeRuntimePaths(String(content), sourcePath);
290
335
  next = next
291
- .replace(/\bCLAUDE\.md\b/g, 'AGENTS.md')
336
+ .replace(/^.*$/gm, line => line.replace(/\bCLAUDE\.md\b/g, (name, offset) => (
337
+ isInstructionAsset(sourcePath) && /(?<![\w/\\])AGENTS\.md\b/.test(line)
338
+ && !/[\w/\\]/.test(line[offset - 1] || '') ? name : 'AGENTS.md'
339
+ )))
292
340
  .replace(/\bClaude Code\b/g, 'Codex CLI');
293
341
 
294
342
  if (!isInstructionAsset(sourcePath)) return next;
295
343
 
344
+ if (/(?:^|\/)rules\/orchestrator\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
345
+ next = next
346
+ .replace('When the current session or a subagent runs on a model below `fable` (e.g. `opus`, `sonnet`, `haiku`) and hits a hard problem',
347
+ 'When the current session or a subagent hits a hard problem')
348
+ .replace('It runs autonomously on the strongest available model',
349
+ 'It runs autonomously with high reasoning effort on the session model unless configuration selects another');
350
+ }
351
+
296
352
  next = normalizeAgentInvocations(normalizeAgentNames(normalizeSkillNames(next)));
297
- return applyReplacements(normalizeAskUserQuestion(next), INSTRUCTION_REPLACEMENTS);
353
+ next = next.replace(/`?CLAUDE_CODE_SUBAGENT_MODEL`?/g,
354
+ 'the configured agent model (`model` in `.codex/agents/<agent>.toml`, or `[agents].default_subagent_model` in native Codex configuration)');
355
+ next = applyReplacements(normalizeAskUserQuestion(next), INSTRUCTION_REPLACEMENTS);
356
+ const { frontmatter, body } = splitFrontmatter(next);
357
+ if (frontmatter.when_to_use && frontmatter.description) {
358
+ const description = `${frontmatter.description} Use when: ${frontmatter.when_to_use}`;
359
+ if (description.length <= 1024) {
360
+ const header = next.slice(0, next.length - body.length)
361
+ .replace(/^description:[^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)*/m,
362
+ () => `description: ${JSON.stringify(description)}`);
363
+ next = header + body;
364
+ }
365
+ }
366
+ // Only the installed ask skill receives this Codex-specific boundary, never eval prompts.
367
+ if (/(?:^|\/)skills\/ask\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
368
+ const { frontmatter, body } = splitFrontmatter(next);
369
+ const description = `${frontmatter.description} ${CODEX_ASK_DESCRIPTION_BOUNDARY}`;
370
+ if (description.length > 1024) throw new Error('Codex ask description exceeds 1024 characters');
371
+ const header = next.slice(0, next.length - body.length)
372
+ .replace(/^description:[^\r\n]*$/m, () => `description: ${JSON.stringify(description)}`);
373
+ next = `${header}\n${CODEX_ASK_NOTE}\n\n${body}`;
374
+ }
375
+ // Only the installed research skill receives this note, never prompts or templates.
376
+ if (/(?:^|\/)skills\/research\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
377
+ const { body } = splitFrontmatter(next);
378
+ const header = next.slice(0, next.length - body.length);
379
+ next = `${header}\n${CODEX_RESEARCH_DEPTH_NOTE}\n\n${body}`;
380
+ }
381
+ // Only the installed specs skill receives this note, never prompts or templates.
382
+ if (/(?:^|\/)skills\/specs\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/')) && !next.includes(CODEX_SPECS_SCOPE_NOTE)) {
383
+ const { body } = splitFrontmatter(next);
384
+ const header = next.slice(0, next.length - body.length);
385
+ next = `${header}\n${CODEX_SPECS_SCOPE_NOTE}\n\n${body}`;
386
+ }
387
+ return next;
298
388
  }
299
389
 
300
390
  function getCodexCopyOptions(baseOptions = {}) {
@@ -307,14 +397,42 @@ function getCodexCopyOptions(baseOptions = {}) {
307
397
  function convertCodexAgentContent(content, fileName = '') {
308
398
  const { frontmatter, body } = splitFrontmatter(content);
309
399
  const name = codexAgentName(frontmatter.name || fileName);
310
- const description = normalizeCodexBody(
400
+ let description = normalizeCodexBody(
311
401
  frontmatter.description || `CafeKit Codex agent: ${name}`
312
402
  );
313
- const instructions = normalizeCodexBody(body).trim();
403
+ let agentBody = body;
404
+ if (['git_ops', 'project_manager'].includes(name)) {
405
+ let inTeamSection = false;
406
+ agentBody = body.split('\n').flatMap(line => {
407
+ if (/^## /.test(line)) {
408
+ inTeamSection = /^## (?:Team Mode|Collaborative Interlocking \(Swarm Protocol\))\s*$/.test(line);
409
+ }
410
+ if (!inTeamSection) return [line];
411
+ if (/\b(?:TaskList|TaskGet|TaskUpdate|SendMessage|shutdown_request)\b|When instantiated as a team member|When triggered as an active teammate/.test(line)) return [];
412
+ return [line.replace(/^(\s*)\d+\. /, '$1- ')];
413
+ }).join('\n');
414
+ }
415
+ let instructions = normalizeCodexBody(agentBody).trim();
416
+ const readOnly = ['brainstormer', 'code_auditor', 'inspector', 'researcher'].includes(name);
417
+ if (readOnly) {
418
+ 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.';
419
+ }
420
+ const reasoning = [];
421
+ if (name === 'strategist') {
422
+ 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.';
423
+ instructions = instructions
424
+ .replace('consulted for counsel and running on the strongest available model',
425
+ 'consulted for counsel with high reasoning effort on the configured native model')
426
+ .replace(/## Runtime note\s+[\s\S]*$/,
427
+ '## 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.');
428
+ reasoning.push('model_reasoning_effort = "high"');
429
+ }
314
430
 
315
431
  return [
316
432
  `name = ${JSON.stringify(name)}`,
317
433
  `description = ${JSON.stringify(description)}`,
434
+ ...(readOnly ? ['sandbox_mode = "read-only"'] : []),
435
+ ...reasoning,
318
436
  `developer_instructions = ${JSON.stringify(instructions)}`,
319
437
  ''
320
438
  ].join('\n');
@@ -365,6 +483,10 @@ function transformManagedCodexContent(content, transform) {
365
483
  }
366
484
 
367
485
  module.exports = {
486
+ CODEX_RESEARCH_DEPTH_MARKER,
487
+ CODEX_RESEARCH_DEPTH_NOTE,
488
+ CODEX_SPECS_SCOPE_MARKER,
489
+ CODEX_SPECS_SCOPE_NOTE,
368
490
  normalizeCodexBody,
369
491
  getCodexCopyOptions,
370
492
  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',
@@ -16,6 +16,7 @@ const {
16
16
  getCopyOptions
17
17
  } = require('../lib/context');
18
18
  const { sha256 } = require('../lib/manifest');
19
+ const { assertNoSymlinkPath } = require('../lib/path-safety');
19
20
  const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
20
21
  const { report, treeAction } = require('./report');
21
22
  const {
@@ -164,30 +165,84 @@ function copyClaudeRuntimeFiles(ctx, platformKey) {
164
165
  });
165
166
  }
166
167
 
167
- /** Delete runtime files marked obsolete by the manifest. Claude only. */
168
- function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
169
- if (platformKey !== 'claude') return;
170
-
171
- const obsoleteFiles = ctx.manifest?.obsolete?.runtimeFiles || [];
172
- const targetBase = PLATFORMS.claude.folder;
173
-
174
- obsoleteFiles.forEach((relPath) => {
175
- const targetPath = path.join(targetBase, relPath);
176
- if (!fs.existsSync(targetPath)) return;
177
-
178
- const isDir = fs.statSync(targetPath).isDirectory();
179
- if (!ctx.dryRun) {
180
- fs.rmSync(targetPath, { force: true, recursive: isDir });
181
- // Remove the entry from the manifest so it doesn't linger as a zombie.
182
- if (ctx.trackers && ctx.trackers.claude) {
183
- ctx.trackers.claude.prune(relPath);
184
- // Directories: prune every tracked file underneath as well.
185
- if (isDir) ctx.trackers.claude.prunePrefix(`${relPath}/`);
168
+ /** Remove only obsolete runtime bytes proven unchanged by the ownership manifest. */
169
+ function removeObsoleteRuntimeFiles(ctx, platformKey) {
170
+ const completed = (ctx.obsoleteRuntimeCleanedPlatforms ||= new Set());
171
+ if (completed.has(platformKey)) return;
172
+ completed.add(platformKey);
173
+ const preserved = new Set();
174
+ for (const key of [platformKey]) {
175
+ const platform = PLATFORMS[key];
176
+ const tracker = ctx.trackers?.[key];
177
+ const ownership = ctx.ownership?.[platform.folder] || { files: {} };
178
+ const obsoleteFiles = (ctx.manifest?.obsolete?.runtimeFiles || [])
179
+ .filter(relative => key === 'claude' || /^(?:hooks|scripts|skills)\//.test(relative));
180
+ for (const relative of obsoleteFiles) {
181
+ const targetPath = key === 'codex' && relative.startsWith('skills/')
182
+ ? path.join(platform.skillsDir, relative.slice('skills/'.length))
183
+ : path.join(platform.folder, relative);
184
+ let stat;
185
+ try {
186
+ assertNoSymlinkPath(targetPath);
187
+ stat = fs.statSync(targetPath);
188
+ } catch (error) {
189
+ if (error.code === 'ENOENT') {
190
+ if (key === 'codex' && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
191
+ continue;
192
+ }
193
+ preserved.add(targetPath);
194
+ continue;
195
+ }
196
+ try {
197
+ if (!tracker) throw new Error('Missing ownership tracker');
198
+ const trackedKeys = [];
199
+ const inspect = file => {
200
+ assertNoSymlinkPath(file);
201
+ const current = fs.statSync(file);
202
+ if (current.isDirectory()) {
203
+ for (const name of fs.readdirSync(file)) inspect(path.join(file, name));
204
+ return;
205
+ }
206
+ if (!current.isFile()) throw new Error('Unsupported obsolete file type');
207
+ const trackedKey = tracker.keyFor(file);
208
+ const recordedHash = ownership.files?.[trackedKey]?.sha256;
209
+ if (!recordedHash || recordedHash !== sha256(fs.readFileSync(file))) {
210
+ throw new Error('Obsolete file is not proven unchanged');
211
+ }
212
+ trackedKeys.push(trackedKey);
213
+ };
214
+ inspect(targetPath);
215
+ assertNoSymlinkPath(targetPath);
216
+ const targetKey = tracker.keyFor(targetPath);
217
+ if (!ctx.dryRun) {
218
+ fs.rmSync(targetPath, { force: true, recursive: stat.isDirectory() });
219
+ for (const trackedKey of trackedKeys) tracker.prune(trackedKey);
220
+ if (stat.isDirectory()) {
221
+ tracker.prune(targetKey);
222
+ tracker.prunePrefix(`${targetKey}/`);
223
+ }
224
+ }
225
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete ${key} runtime: ${relative}`);
226
+ ctx.results.updated++;
227
+ } catch {
228
+ preserved.add(targetPath);
186
229
  }
187
230
  }
188
- ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete runtime: ${relPath}`);
189
- ctx.results.updated++;
190
- });
231
+ }
232
+ if (preserved.size) {
233
+ ctx.results.preserved = (ctx.results.preserved || 0) + preserved.size;
234
+ (ctx.results.preservedFiles ||= []).push(...preserved);
235
+ }
236
+ const allPreserved = (ctx.obsoleteRuntimePreserved ||= new Set());
237
+ for (const target of preserved) allPreserved.add(target);
238
+ const selected = (ctx.platforms || [platformKey]).filter(key => key === 'claude' || key === 'codex');
239
+ if (selected.every(key => completed.has(key)) && allPreserved.size) {
240
+ ctx.ui.warn(`Preserved ${allPreserved.size} obsolete runtime item(s); review and remove manually if no longer needed: ${[...allPreserved].join(', ')}`);
241
+ }
242
+ }
243
+
244
+ function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
245
+ if (platformKey === 'claude') removeObsoleteRuntimeFiles(ctx, platformKey);
191
246
  }
192
247
 
193
248
  /** Remove renamed agents only when ownership manifest proves pristine bytes. */
@@ -258,7 +313,7 @@ function copyClaudeMdFile(ctx, platformKey) {
258
313
  }
259
314
 
260
315
  /** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
261
- function copyRulesDirectory(ctx, platformKey) {
316
+ function copyRulesDirectory(ctx, platformKey, excludedNames = new Set()) {
262
317
  if (!hasPlatformCapability(platformKey, 'rules')) return;
263
318
 
264
319
  const src = path.join(SRC, 'claude/rules');
@@ -270,9 +325,19 @@ function copyRulesDirectory(ctx, platformKey) {
270
325
  }
271
326
 
272
327
  const transform = getCopyOptions(platformKey, {}).transform;
273
- const agg = copyManagedTree({
328
+ const options = {
274
329
  src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
275
- });
330
+ };
331
+ let agg;
332
+ if (excludedNames.size === 0) agg = copyManagedTree(options);
333
+ else {
334
+ agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
335
+ for (const name of fs.readdirSync(src)) {
336
+ if (excludedNames.has(name)) continue;
337
+ const child = copyManagedTree({ ...options, sourceRoot: src, src: path.join(src, name), dest: path.join(dest, name) });
338
+ for (const key of Object.keys(agg)) agg[key] += child[key];
339
+ }
340
+ }
276
341
  report(ctx, treeAction(agg), 'rules/ directory');
277
342
  }
278
343
 
@@ -303,6 +368,7 @@ module.exports = {
303
368
  copyOutputStylesDirectory,
304
369
  copyClaudeRuntimeFiles,
305
370
  removeObsoleteClaudeRuntimeFiles,
371
+ removeObsoleteRuntimeFiles,
306
372
  copyClaudeMdFile,
307
373
  copyClaudeAgentsMdFile,
308
374
  ensureSharedAgentsMdCore,
@@ -12,6 +12,26 @@ const { PLATFORMS } = require('../lib/context');
12
12
 
13
13
  const SRC_CLAUDE = path.join(__dirname, '../../src/claude');
14
14
 
15
+ const FORMER_MATCHERS = new Set([
16
+ 'Read|Write|Edit|MultiEdit|Bash|Glob',
17
+ 'Read|Write|Edit|MultiEdit|Bash|Glob|Grep',
18
+ ]);
19
+
20
+ function refreshManagedMatchers(settings, template, ctx) {
21
+ if (!Array.isArray(settings.hooks?.PreToolUse)) return settings;
22
+ const targets = template.hooks?.PreToolUse || [];
23
+ const entries = settings.hooks.PreToolUse.map(entry => {
24
+ if (!FORMER_MATCHERS.has(entry?.matcher) || !Array.isArray(entry.hooks) || !entry.hooks.length) return entry;
25
+ const candidates = targets.filter(target =>
26
+ entry.hooks.every(hook => target.hooks.some(managed => managed.command === hook?.command)));
27
+ if (candidates.length !== 1) return entry;
28
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook PreToolUse matcher updated`);
29
+ ctx.results.updated++;
30
+ return { ...entry, matcher: candidates[0].matcher };
31
+ });
32
+ return { ...settings, hooks: { ...settings.hooks, PreToolUse: entries } };
33
+ }
34
+
15
35
  /**
16
36
  * Remove obsolete hook entries from a settings object whose command matches any
17
37
  * manifest-listed substring (e.g. a retired router hook). Returns a possibly
@@ -97,7 +117,8 @@ function mergeClaudeSettings(ctx, platformKey) {
97
117
  }
98
118
  }
99
119
 
100
- const mergedSettings = pruneObsoleteSettingsHooks({ ...existingSettings }, ctx);
120
+ const mergedSettings = refreshManagedMatchers(
121
+ pruneObsoleteSettingsHooks({ ...existingSettings }, ctx), managedSettings, ctx);
101
122
 
102
123
  // statusLine: adopt managed value if upgrading, missing, or CafeKit-owned.
103
124
  if (managedSettings.statusLine) {
@@ -17,21 +17,81 @@ const { PLATFORMS } = require('../lib/context');
17
17
 
18
18
  const CODEX_SRC = path.join(__dirname, '../../src/codex');
19
19
  const HOOK_COMMAND_TEMPLATE = /^node "\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
20
- // Exact shapes earlier installers wrote, kept only to recognize and upgrade them. Before
21
- // 0.16.2 the path came from Git; 0.16.2 fixed the path but still resolved `node` through
22
- // PATH. Matching the whole command keeps a hook the user wrote from ever being rewritten.
23
- const LEGACY_POSIX_COMMANDS = [
24
- /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/,
25
- /^node '(?:[^']|'\\'')*[/\\]hooks[/\\]([a-z0-9-]+\.cjs)'$/,
26
- ];
27
-
28
- function legacyPosixScript(command) {
29
- if (typeof command !== 'string') return null;
30
- for (const pattern of LEGACY_POSIX_COMMANDS) {
31
- const match = command.match(pattern);
32
- if (match) return match[1];
20
+ const LEGACY_GIT_COMMAND = /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
21
+ const SHELL_QUOTED = "'(?:[^']|'\\\\'')*'";
22
+ const POSIX_COMMAND = new RegExp(`^(?:PATH="\\$PATH"(?::${SHELL_QUOTED})+ )?node (${SHELL_QUOTED})$`);
23
+ const WINDOWS_LAUNCHER = 'node -e "process.argv[1]=Buffer.from(process.argv[1],\'base64url\').toString(\'utf8\');require(\'module\').runMain()" ';
24
+
25
+ // Former template values from the hooks.json history. Custom expressions never
26
+ // establish ownership, even when every handler belongs to CafeKit.
27
+ const FORMER_MATCHERS = {
28
+ PreToolUse: new Set([
29
+ 'Bash|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*',
30
+ 'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell',
31
+ ]),
32
+ PostToolUse: new Set([
33
+ 'Agent|spawn_agent|update_plan|task|Task|TodoWrite',
34
+ 'apply_patch|Edit|Write|MultiEdit',
35
+ ]),
36
+ };
37
+
38
+ function refreshManagedMatchers(config, template, ctx) {
39
+ let updated = 0;
40
+ const hooks = { ...(config.hooks || {}) };
41
+ for (const [event, former] of Object.entries(FORMER_MATCHERS)) {
42
+ if (!Array.isArray(hooks[event])) continue;
43
+ hooks[event] = hooks[event].map(group => {
44
+ const handlers = Array.isArray(group?.hooks) ? group.hooks : [];
45
+ const scripts = handlers.map(managedHandlerScript);
46
+ if (!scripts.some(Boolean)) return group;
47
+ const candidates = handlers.length && scripts.every(Boolean)
48
+ ? (template.hooks[event] || []).filter(target => {
49
+ const wanted = target.hooks.map(managedHandlerScript);
50
+ return scripts.every(script => wanted.includes(script));
51
+ }) : [];
52
+ const target = candidates.length === 1 ? candidates[0] : null;
53
+ if (target && (group.matcher || '') === (target.matcher || '')) return group;
54
+ if (target && former.has(group.matcher)) {
55
+ updated += 1;
56
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: ${event} matcher updated`);
57
+ return { ...group, matcher: target.matcher };
58
+ }
59
+ ctx.ui.warn(`Codex hooks: ${event} matcher preserved; custom, mixed, or ambiguous group. Review it manually.`);
60
+ return group;
61
+ });
33
62
  }
34
- return null;
63
+ return { config: { ...config, hooks }, updated };
64
+ }
65
+
66
+ // A basename alone cannot prove ownership: users may also run tools/hooks/session.cjs.
67
+ // Only known source scripts at the native runtime path and an exact emitted launcher
68
+ // shape may be rebound or count as already registered.
69
+ const MANAGED_SCRIPTS = new Set(fs.readdirSync(path.join(CODEX_SRC, 'hooks')).filter((name) => /^[a-z0-9-]+\.cjs$/.test(name)));
70
+
71
+ function nativeScript(target) {
72
+ if (!path.posix.isAbsolute(target) && !path.win32.isAbsolute(target)) return null;
73
+ const match = target.match(/[/\\]\.codex[/\\]hooks[/\\]([a-z0-9-]+\.cjs)$/);
74
+ return match && MANAGED_SCRIPTS.has(match[1]) ? match[1] : null;
75
+ }
76
+
77
+ function managedCommandScript(command) {
78
+ if (typeof command !== 'string') return null;
79
+ const template = command.match(HOOK_COMMAND_TEMPLATE) || command.match(LEGACY_GIT_COMMAND);
80
+ if (template) return MANAGED_SCRIPTS.has(template[1]) ? template[1] : null;
81
+ const posix = command.match(POSIX_COMMAND);
82
+ if (posix) return nativeScript(posix[1].slice(1, -1).replace(/'\\''/g, "'"));
83
+ if (!command.startsWith(WINDOWS_LAUNCHER)) return null;
84
+ const encoded = command.slice(WINDOWS_LAUNCHER.length);
85
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
86
+ const target = Buffer.from(encoded, 'base64url').toString('utf8');
87
+ return Buffer.from(target, 'utf8').toString('base64url') === encoded ? nativeScript(target) : null;
88
+ }
89
+
90
+ function managedHandlerScript(handler) {
91
+ if (handler?.type !== 'command') return null;
92
+ const fields = ['command', 'commandWindows'].filter((field) => handler[field] !== undefined);
93
+ const scripts = fields.map((field) => managedCommandScript(handler[field]));
94
+ return scripts.length > 0 && scripts.every((script) => script && script === scripts[0]) ? scripts[0] : null;
35
95
  }
36
96
 
37
97
  /** The hook script a command runs, which is stable across installs and platforms. */
@@ -57,8 +117,8 @@ function hookPathFor(script, projectRoot) {
57
117
  * nothing outside a repository and the *outer* root for a project nested inside one. Both
58
118
  * cases produced a path that does not exist, so Codex started every hook, every hook died,
59
119
  * and the gates were silently absent. The path is known at install time, so it is written
60
- * directly. The literal `/hooks/<script>.cjs` segment must survive, because hookScript()
61
- * reads it as the identity that lets a reinstall recognize CafeKit's own entries.
120
+ * directly. The literal `.codex/hooks/<script>.cjs` path and emitted launcher shape
121
+ * let managedHandlerScript() recognize CafeKit's own entries on a reinstall.
62
122
  */
63
123
  /**
64
124
  * Directories to fall back on when PATH carries no `node`.
@@ -88,7 +148,7 @@ function posixHookCommand(script, projectRoot) {
88
148
  */
89
149
  function windowsHookCommand(script, projectRoot) {
90
150
  const encoded = Buffer.from(hookPathFor(script, projectRoot), 'utf8').toString('base64url');
91
- return 'node -e "process.argv[1]=Buffer.from(process.argv[1],\'base64url\').toString(\'utf8\');require(\'module\').runMain()" ' + encoded;
151
+ return WINDOWS_LAUNCHER + encoded;
92
152
  }
93
153
 
94
154
  /** Rewrite a managed template handler into absolute launchers for both platforms. */
@@ -104,10 +164,9 @@ function materializeHookCommands(handler, projectRoot) {
104
164
  }
105
165
 
106
166
  /**
107
- * Repair launchers an older installer wrote. A reinstall treats an already-registered
108
- * script as the user's placement and leaves it alone, so without this pass every existing
109
- * project would keep its broken git-derived command forever. Only the exact byte pattern
110
- * CafeKit itself emitted is rewritten; anything else stays untouched.
167
+ * Rebind recognized CafeKit launchers after an upgrade or a project move. Registration
168
+ * preserves the user's event/matcher placement; only emitted command shapes change,
169
+ * and a custom field for another platform remains untouched.
111
170
  */
112
171
  function repairLegacyCodexLaunchers(config, projectRoot) {
113
172
  if (!config.hooks) return { config, repaired: 0 };
@@ -120,13 +179,13 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
120
179
  return {
121
180
  ...group,
122
181
  hooks: group.hooks.map((handler) => {
123
- const legacy = legacyPosixScript(handler?.command);
124
- const template = typeof handler?.commandWindows === 'string'
125
- && handler.commandWindows.match(HOOK_COMMAND_TEMPLATE);
126
- if (!legacy && !template) return handler;
182
+ if (handler?.type !== 'command') return handler;
127
183
  const next = { ...handler };
128
- if (legacy) next.command = posixHookCommand(legacy, projectRoot);
129
- if (template) next.commandWindows = windowsHookCommand(template[1], projectRoot);
184
+ for (const [field, build] of [['command', posixHookCommand], ['commandWindows', windowsHookCommand]]) {
185
+ const script = managedCommandScript(handler[field]);
186
+ if (script) next[field] = build(script, projectRoot);
187
+ }
188
+ if (next.command === handler.command && next.commandWindows === handler.commandWindows) return handler;
130
189
  repaired += 1;
131
190
  return next;
132
191
  }),
@@ -139,7 +198,7 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
139
198
  /** Drop CafeKit hooks the manifest has retired, leaving foreign entries untouched. */
140
199
  function pruneObsoleteCodexHooks(config, ctx) {
141
200
  const obsolete = ctx.manifest?.obsolete?.settingsHookCommandSubstrings || [];
142
- if (!config.hooks || obsolete.length === 0) return { config, removed: 0 };
201
+ if (!config.hooks) return { config, removed: 0 };
143
202
 
144
203
  let removed = 0;
145
204
  const pruned = {};
@@ -150,7 +209,8 @@ function pruneObsoleteCodexHooks(config, ctx) {
150
209
  if (!Array.isArray(group?.hooks)) { keptGroups.push(group); continue; }
151
210
  const keptHooks = group.hooks.filter((handler) => {
152
211
  const command = handler?.command || '';
153
- const isObsolete = obsolete.some((substring) => command.includes(substring));
212
+ const isObsolete = obsolete.some((substring) => command.includes(substring))
213
+ || (eventName === 'SessionStart' && managedHandlerScript(handler) === 'docs-sync.cjs');
154
214
  if (isObsolete) removed += 1;
155
215
  return !isObsolete;
156
216
  });
@@ -196,12 +256,15 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
196
256
  ctx.results.updated++;
197
257
  }
198
258
 
199
- const { config: base, repaired } = repairLegacyCodexLaunchers(pruned, projectRoot);
259
+ const { config: rebound, repaired } = repairLegacyCodexLaunchers(pruned, projectRoot);
200
260
  if (repaired > 0) {
201
- ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s) written by an older installer`);
261
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s) to the current project`);
202
262
  ctx.results.updated++;
203
263
  }
204
264
 
265
+ const { config: base, updated: matcherUpdates } = refreshManagedMatchers(rebound, managed, ctx);
266
+ ctx.results.updated += matcherUpdates;
267
+
205
268
  const merged = { ...base };
206
269
  if (typeof managed.description === 'string' && typeof merged.description !== 'string') {
207
270
  merged.description = managed.description;
@@ -215,7 +278,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
215
278
  // it. Matching on the script rather than the whole command survives the path
216
279
  // differences between installs and platforms.
217
280
  const registered = new Set(
218
- groups.flatMap((group) => (group?.hooks || []).map((handler) => hookScript(handler?.command))).filter(Boolean),
281
+ groups.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []).map(managedHandlerScript)).filter(Boolean),
219
282
  );
220
283
 
221
284
  for (const managedGroup of managedGroups) {
@@ -242,7 +305,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
242
305
  merged.hooks[eventName] = groups;
243
306
  }
244
307
 
245
- if (added === 0 && removed === 0) {
308
+ if (added === 0 && removed === 0 && matcherUpdates === 0) {
246
309
  ctx.ui.detail(` → ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: already registered`);
247
310
  ctx.results.unchanged++;
248
311
  }