@haposoft/cafekit 0.18.0 → 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 (162) 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 +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  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 +102 -1362
  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-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
package/bin/install.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  const { spawnSync } = require('child_process');
19
+ const fs = require('fs');
19
20
  const { buildContext, PLATFORMS, packageJson } = require('./lib/context');
20
21
  const lock = require('./lib/lock');
21
22
  const backup = require('./lib/backup');
@@ -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];
@@ -175,6 +198,8 @@ async function main() {
175
198
  await selectDocumentSkills(ctx);
176
199
  if (ctx.cancelled) { lock.release(); process.exit(0); }
177
200
 
201
+ preflightTrackedRootFiles(ctx);
202
+
178
203
  // ── Pre-run snapshot for rollback ─────────────────────
179
204
  // Capture platform folders AND the root files the pipeline mutates
180
205
  // (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
@@ -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',
@@ -60,9 +60,8 @@ function writeManagedFile(opts) {
60
60
  // Ownership manifest for this platform (read at run start), NOT the migration manifest.
61
61
  const ownership = (ctx.ownership && ctx.ownership[platformFolder]) || { files: {} };
62
62
 
63
- // A file already written earlier THIS run (e.g. a spec template copied as part
64
- // of the specs/ tree, then revisited by the template-sync loop) is ours — treat
65
- // it as pristine against what we just wrote, not as a user-created file.
63
+ // A file already written earlier THIS run is ours — treat it as pristine
64
+ // against what we just wrote, not as a user-created file.
66
65
  const relForRun = tracker?.keyFor
67
66
  ? tracker.keyFor(dest)
68
67
  : path.relative(platformFolder, dest).replace(/\\/g, '/');
@@ -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) {