@haposoft/cafekit 0.15.3 → 0.16.0-rc.1

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 (90) hide show
  1. package/README.md +4 -2
  2. package/bin/__tests__/codex-hooks.test.js +140 -0
  3. package/bin/__tests__/codex-native.test.js +2 -2
  4. package/bin/__tests__/develop-contract.test.js +1120 -0
  5. package/bin/__tests__/installer-safety.test.js +510 -2
  6. package/bin/__tests__/package-inventory.test.js +258 -0
  7. package/bin/__tests__/secret-scanner.test.js +157 -0
  8. package/bin/__tests__/validator-grounding.test.js +713 -0
  9. package/bin/install.js +5 -1
  10. package/bin/lib/codex-install.js +21 -6
  11. package/bin/lib/context.js +17 -6
  12. package/bin/lib/copy-utils.js +31 -0
  13. package/bin/lib/instruction-blocks.js +85 -0
  14. package/bin/lib/managed-writer.js +4 -1
  15. package/bin/lib/opencode-install.js +75 -30
  16. package/bin/phases/claude-runtime.js +103 -4
  17. package/bin/phases/codex-runtime.js +9 -5
  18. package/bin/phases/post-install.js +29 -22
  19. package/bin/phases/root-config.js +89 -10
  20. package/package.json +14 -4
  21. package/src/claude/CLAUDE.md +6 -122
  22. package/src/claude/agents/code-auditor.md +21 -22
  23. package/src/claude/agents/debugger.md +3 -3
  24. package/src/claude/agents/deployer.md +1 -1
  25. package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
  26. package/src/claude/agents/project-manager.md +1 -1
  27. package/src/claude/agents/spec-maker.md +2 -2
  28. package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
  29. package/src/claude/hooks/agent.cjs +7 -14
  30. package/src/claude/hooks/rules.cjs +33 -53
  31. package/src/claude/hooks/spec-gate.cjs +66 -21
  32. package/src/claude/hooks/spec-state.cjs +5 -0
  33. package/src/claude/migration-manifest.json +9 -2
  34. package/src/claude/runtime.json +0 -3
  35. package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
  36. package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
  37. package/src/claude/scripts/spec-ground.cjs +189 -83
  38. package/src/claude/scripts/validate-spec-output.cjs +294 -30
  39. package/src/claude/scripts/workflow-policy.cjs +609 -0
  40. package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
  41. package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
  42. package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
  43. package/src/claude/skills/backend-development/SKILL.md +4 -4
  44. package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
  45. package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
  46. package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
  47. package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
  48. package/src/claude/skills/backend-development/references/backend-security.md +3 -3
  49. package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
  50. package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
  51. package/src/claude/skills/brainstorm/SKILL.md +2 -3
  52. package/src/claude/skills/code-review/SKILL.md +12 -8
  53. package/src/claude/skills/code-review/references/verification-gate.md +4 -4
  54. package/src/claude/skills/debug/SKILL.md +2 -0
  55. package/src/claude/skills/develop/SKILL.md +100 -52
  56. package/src/claude/skills/develop/references/parallel-waves.md +105 -26
  57. package/src/claude/skills/develop/references/quality-gate.md +58 -38
  58. package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
  59. package/src/claude/skills/git/SKILL.md +44 -50
  60. package/src/claude/skills/hotfix/SKILL.md +10 -8
  61. package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
  62. package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
  63. package/src/claude/skills/inspect/SKILL.md +12 -30
  64. package/src/claude/skills/mobile-development/SKILL.md +12 -12
  65. package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
  66. package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
  67. package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
  68. package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
  69. package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
  70. package/src/claude/skills/question/SKILL.md +13 -13
  71. package/src/claude/skills/research/SKILL.md +2 -2
  72. package/src/claude/skills/specs/SKILL.md +32 -15
  73. package/src/claude/skills/specs/references/review.md +3 -3
  74. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  75. package/src/claude/skills/specs/templates/spec-state.json +7 -3
  76. package/src/claude/skills/sync/SKILL.md +26 -1
  77. package/src/claude/skills/sync/references/sync-protocols.md +3 -3
  78. package/src/claude/skills/test/SKILL.md +12 -4
  79. package/src/claude/skills/test/references/failure-triage.md +17 -17
  80. package/src/claude/skills/test/references/test-memory.md +2 -2
  81. package/src/codex/AGENTS.md +16 -24
  82. package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
  83. package/src/codex/hooks/rules.cjs +27 -26
  84. package/src/codex/hooks/spec-gate.cjs +19 -8
  85. package/src/codex/hooks/spec-state.cjs +10 -1
  86. package/src/common/AGENTS.md +43 -0
  87. package/src/opencode/AGENTS.md +21 -121
  88. package/src/opencode/plugins/rules.ts +26 -34
  89. package/src/opencode/plugins/spec-gate.ts +283 -0
  90. package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
package/bin/install.js CHANGED
@@ -28,7 +28,9 @@ const {
28
28
  copyClaudeRuntimeFiles,
29
29
  removeObsoleteClaudeRuntimeFiles,
30
30
  copyClaudeMdFile,
31
- copyRulesDirectory
31
+ ensureSharedAgentsMdCore,
32
+ copyRulesDirectory,
33
+ removeObsoleteAgents
32
34
  } = require('./phases/claude-runtime');
33
35
  const { mergeClaudeSettings } = require('./phases/claude-settings');
34
36
  const { installOpenCodeRuntime } = require('./phases/opencode-runtime');
@@ -57,6 +59,7 @@ function installPlatform(ctx, platformKey) {
57
59
  ctx.ui.startSpinner(ctx.t('installingPlatform', { name: platform.name }));
58
60
 
59
61
  copyPlatformFiles(ctx, platformKey);
62
+ removeObsoleteAgents(ctx, platformKey);
60
63
 
61
64
  if (platformKey === 'claude') {
62
65
  copyRoutingFile(ctx, platformKey);
@@ -174,6 +177,7 @@ async function main() {
174
177
  ctx.backupDir = backup.snapshot([...targets, '.gitignore'], ctx.runId);
175
178
  }
176
179
 
180
+ ensureSharedAgentsMdCore(ctx);
177
181
  for (const platformKey of ctx.platforms) {
178
182
  installPlatform(ctx, platformKey);
179
183
  }
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('path');
4
+ const { normalizeSourcePaths } = require('./copy-utils');
4
5
  const { splitFrontmatter } = require('./codex-frontmatter');
6
+ const { preserveAddressingSection } = require('./instruction-blocks');
5
7
 
6
8
  const CODEX_BLOCK_START = '<!-- CAFEKIT CODEX START -->';
7
9
  const CODEX_BLOCK_END = '<!-- CAFEKIT CODEX END -->';
@@ -14,7 +16,7 @@ const AGENT_NAMES = [
14
16
  'deployer',
15
17
  'docs-keeper',
16
18
  'git-ops',
17
- 'god-developer',
19
+ 'implementer',
18
20
  'inspector',
19
21
  'project-manager',
20
22
  'researcher',
@@ -63,10 +65,13 @@ function applyReplacements(content, replacements) {
63
65
  }
64
66
 
65
67
  function normalizeRuntimePaths(content) {
66
- return content
67
- .replace(/\.claude\/skills\b/g, '.agents/skills')
68
- .replace(/\.claude\\skills\b/g, '.agents\\skills')
69
- .replace(/\.claude(?=[/\\])/g, '.codex')
68
+ return normalizeSourcePaths(content, {
69
+ runtimeRoot: '.codex',
70
+ skillsRoot: '.agents/skills'
71
+ })
72
+ .replace(/(?<!~\/)\.claude\/skills\b/g, '.agents/skills')
73
+ .replace(/(?<!~\\)\.claude\\skills\b/g, '.agents\\skills')
74
+ .replace(/(?<!~\/)\.claude(?=[/\\])/g, '.codex')
70
75
  .replace(/(['"])\.claude\1/g, '$1.codex$1')
71
76
  .replace(/`\.claude`/g, '`.codex`');
72
77
  }
@@ -163,8 +168,17 @@ function managedRange(content) {
163
168
 
164
169
  function upsertManagedCodexBlock(existingContent, blockContent) {
165
170
  const existing = String(existingContent || '');
166
- const block = `${CODEX_BLOCK_START}\n${String(blockContent).trim()}\n${CODEX_BLOCK_END}`;
171
+ let body = String(blockContent).trim();
172
+ // Reinstall replaces the managed block in place. If the new template dropped
173
+ // its Addressing section, carry the exact section over from the existing
174
+ // managed block so the saved address survives for setupAddressing. Only reads
175
+ // the managed body — never user-owned sections outside the block.
167
176
  const range = managedRange(existing);
177
+ if (range) {
178
+ const existingBody = existing.slice(range.start + CODEX_BLOCK_START.length, range.end);
179
+ body = preserveAddressingSection(body, existingBody);
180
+ }
181
+ const block = `${CODEX_BLOCK_START}\n${body}\n${CODEX_BLOCK_END}`;
168
182
  if (range === false) return existing;
169
183
  if (range) {
170
184
  return `${existing.slice(0, range.start)}${block}${existing.slice(range.end + CODEX_BLOCK_END.length)}`;
@@ -190,6 +204,7 @@ module.exports = {
190
204
  getCodexCopyOptions,
191
205
  codexAgentName,
192
206
  convertCodexAgentContent,
207
+ managedRange,
193
208
  upsertManagedCodexBlock,
194
209
  transformManagedCodexContent
195
210
  };
@@ -16,6 +16,7 @@ const packageJson = require('../../package.json');
16
16
  const { getOpenCodeCopyOptions } = require('./opencode-install');
17
17
  const { isInteractive, createUI } = require('./ui');
18
18
  const { resolveLang, createTranslator } = require('./i18n');
19
+ const { normalizeSourcePaths } = require('./copy-utils');
19
20
 
20
21
  const INSTALL_COMMAND = `npx ${packageJson.name}@${packageJson.version}`;
21
22
 
@@ -82,7 +83,7 @@ const PLATFORMS = {
82
83
  commandPrefix: '/',
83
84
  sourceDir: 'claude', // Maps to src/claude/
84
85
  sourceSubdir: 'commands', // Source subfolder within src/claude/
85
- backupTargets: ['.claude', 'CLAUDE.md'],
86
+ backupTargets: ['.claude', 'CLAUDE.md', 'AGENTS.md'],
86
87
  capabilities: {
87
88
  skills: true,
88
89
  agents: true,
@@ -216,7 +217,16 @@ function getCopyOptions(platformKey, baseOptions = {}) {
216
217
  // during module initialization.
217
218
  return require('./codex-install').getCodexCopyOptions(baseOptions);
218
219
  }
219
- return baseOptions;
220
+ if (platformKey !== 'claude') return baseOptions;
221
+ return {
222
+ ...baseOptions,
223
+ transform: (content, sourcePath) => normalizeSourcePaths(
224
+ typeof baseOptions.transform === 'function'
225
+ ? baseOptions.transform(content, sourcePath)
226
+ : content,
227
+ { runtimeRoot: '.claude', skillsRoot: '.claude/skills' }
228
+ )
229
+ };
220
230
  }
221
231
 
222
232
  // ═══════════════════════════════════════════════════════════
@@ -305,11 +315,12 @@ function buildContext(argv, runId) {
305
315
  const options = parseInstallerArgs(argv);
306
316
  // Interactive only on a real TTY and not when --yes/CI forces non-interactive.
307
317
  const interactive = isInteractive() && !options.yes;
308
- // Language: --lang wins; otherwise English (an interactive prompt may change it).
318
+ // Language: --lang wins; otherwise English for installer UI only (an interactive
319
+ // prompt or saved runtime locale may change both values).
309
320
  const lang = options.lang ? resolveLang(options.lang) : 'en';
310
- // ctx.locale = the string that gets written to CLAUDE.md / runtime.json as the AI's response language.
311
- // For known codes it mirrors lang; for "other", it's set later by setLang.
312
- const locale = options.lang || 'en';
321
+ // A missing locale means "follow the user's language". Never persist the UI
322
+ // fallback as an AI response-language override on fresh installs/upgrades.
323
+ const locale = options.lang ? options.lang : null;
313
324
  return {
314
325
  argv,
315
326
  runId,
@@ -11,6 +11,33 @@ function isTextAsset(filePath) {
11
11
  return TEXT_REWRITE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
12
12
  }
13
13
 
14
+ // Generated artifacts never ship as runtime payload: the coverage db, the
15
+ // Python bytecode cache directory, and bytecode files. Skipping them here keeps
16
+ // both copyRecursive and copyManagedTree (and the OpenCode plugin copy path
17
+ // built on copyRecursive) from ever copying build residue.
18
+ function isGeneratedArtifact(name) {
19
+ const base = String(name || '');
20
+ return (
21
+ base === '.coverage'
22
+ || base === '__pycache__'
23
+ || base.endsWith('.pyc')
24
+ || base.endsWith('.pyo')
25
+ );
26
+ }
27
+
28
+ function normalizeSourcePaths(content, options = {}) {
29
+ const runtimeRoot = String(options.runtimeRoot || '.claude');
30
+ const skillsRoot = String(options.skillsRoot || `${runtimeRoot}/skills`);
31
+ const runtimeRootWindows = runtimeRoot.replace(/\//g, '\\');
32
+ const skillsRootWindows = skillsRoot.replace(/\//g, '\\');
33
+
34
+ return String(content)
35
+ .replace(/packages\/spec\/src\/claude\/skills(?=\/|$)/g, skillsRoot)
36
+ .replace(/packages\\spec\\src\\claude\\skills(?=\\|$)/g, skillsRootWindows)
37
+ .replace(/packages\/spec\/src\/claude(?=\/|$)/g, runtimeRoot)
38
+ .replace(/packages\\spec\\src\\claude(?=\\|$)/g, runtimeRootWindows);
39
+ }
40
+
14
41
  function copyRecursive(src, dest, options = {}) {
15
42
  const exists = fs.existsSync(src);
16
43
  const stats = exists && fs.statSync(src);
@@ -22,10 +49,12 @@ function copyRecursive(src, dest, options = {}) {
22
49
  fs.mkdirSync(dest, { recursive: true });
23
50
  }
24
51
  fs.readdirSync(src).forEach(childItemName => {
52
+ if (isGeneratedArtifact(childItemName)) return;
25
53
  const destItemName = childItemName === 'gitignore' ? '.gitignore' : childItemName;
26
54
  copyRecursive(path.join(src, childItemName), path.join(dest, destItemName), options);
27
55
  });
28
56
  } else {
57
+ if (isGeneratedArtifact(path.basename(src))) return;
29
58
  if (fs.existsSync(dest) && !shouldOverwriteManagedFiles) {
30
59
  return;
31
60
  }
@@ -56,6 +85,8 @@ function readJsonFile(filePath) {
56
85
  module.exports = {
57
86
  TEXT_REWRITE_EXTENSIONS,
58
87
  isTextAsset,
88
+ isGeneratedArtifact,
89
+ normalizeSourcePaths,
59
90
  copyRecursive,
60
91
  readJsonFile
61
92
  };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const CORE_BLOCK_START = '<!-- CAFEKIT CORE START -->';
4
+ const CORE_BLOCK_END = '<!-- CAFEKIT CORE END -->';
5
+
6
+ function managedRange(content, startMarker = CORE_BLOCK_START, endMarker = CORE_BLOCK_END) {
7
+ const existing = String(content || '');
8
+ const start = existing.indexOf(startMarker);
9
+ const end = existing.indexOf(endMarker);
10
+ if (start < 0 && end < 0) return null;
11
+ const duplicateStart = existing.indexOf(startMarker, start + startMarker.length);
12
+ const duplicateEnd = existing.indexOf(endMarker, end + endMarker.length);
13
+ if (start < 0 || end <= start || duplicateStart >= 0 || duplicateEnd >= 0) return false;
14
+ return {
15
+ start,
16
+ end: end + endMarker.length,
17
+ bodyStart: start + startMarker.length,
18
+ bodyEnd: end
19
+ };
20
+ }
21
+
22
+ function migrateExactLegacyBlock(existingContent, startMarker, endMarker, replacementContent) {
23
+ const existing = String(existingContent || '');
24
+ const range = managedRange(existing, startMarker, endMarker);
25
+ if (!range) return existing;
26
+ const body = existing.slice(range.bodyStart, range.bodyEnd).trim();
27
+ if (body !== String(replacementContent).trim()) return existing;
28
+ const block = `${CORE_BLOCK_START}\n${String(replacementContent).trim()}\n${CORE_BLOCK_END}`;
29
+ return `${existing.slice(0, range.start)}${block}${existing.slice(range.end)}`;
30
+ }
31
+
32
+ function upsertManagedCoreBlock(existingContent, coreContent) {
33
+ const existing = String(existingContent || '');
34
+ const block = `${CORE_BLOCK_START}\n${String(coreContent).trim()}\n${CORE_BLOCK_END}`;
35
+ const range = managedRange(existing);
36
+ if (range === false) return existing;
37
+ if (range) {
38
+ return `${existing.slice(0, range.start)}${block}${existing.slice(range.end)}`;
39
+ }
40
+ if (!existing) return `${block}\n`;
41
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
42
+ return `${existing}${separator}${block}\n`;
43
+ }
44
+
45
+ function transformManagedCoreContent(content, transform) {
46
+ const existing = String(content || '');
47
+ const range = managedRange(existing);
48
+ if (!range || typeof transform !== 'function') return existing;
49
+ const body = existing.slice(range.bodyStart, range.bodyEnd);
50
+ return `${existing.slice(0, range.bodyStart)}${transform(body)}${existing.slice(range.bodyEnd)}`;
51
+ }
52
+
53
+ // Mirrors the section regex used by post-install configureAddressing so an
54
+ // extracted section round-trips exactly back into the managed body.
55
+ const ADDRESSING_SECTION_RE = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
56
+
57
+ /** Extract the exact Addressing section (heading through next `##`) from a body, or null. */
58
+ function extractAddressingSection(body) {
59
+ const match = String(body || '').match(ADDRESSING_SECTION_RE);
60
+ return match ? match[0].replace(/\n+$/, '') : null;
61
+ }
62
+
63
+ /**
64
+ * When a reinstall template drops the Addressing section, carry the exact one
65
+ * from the existing CafeKit-managed block so the saved address survives for
66
+ * setupAddressing. Only reads the managed block body — never user-owned
67
+ * sections outside the block, never the shared CORE block.
68
+ */
69
+ function preserveAddressingSection(newBody, existingManagedBody) {
70
+ if (extractAddressingSection(newBody) !== null) return newBody;
71
+ const section = extractAddressingSection(existingManagedBody);
72
+ if (section === null) return newBody;
73
+ return `${String(newBody).trimEnd()}\n\n${section}`;
74
+ }
75
+
76
+ module.exports = {
77
+ CORE_BLOCK_START,
78
+ CORE_BLOCK_END,
79
+ managedRange,
80
+ migrateExactLegacyBlock,
81
+ upsertManagedCoreBlock,
82
+ transformManagedCoreContent,
83
+ extractAddressingSection,
84
+ preserveAddressingSection
85
+ };
@@ -20,7 +20,7 @@
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
- const { isTextAsset } = require('./copy-utils');
23
+ const { isTextAsset, isGeneratedArtifact } = require('./copy-utils');
24
24
  const manifest = require('./manifest');
25
25
  const { assertNoSymlinkPath } = require('./path-safety');
26
26
 
@@ -151,6 +151,9 @@ function copyManagedTree(opts) {
151
151
  const stats = fs.statSync(src);
152
152
  if (stats.isDirectory()) {
153
153
  for (const childName of fs.readdirSync(src)) {
154
+ // Generated artifacts (coverage, Python bytecode) are not runtime
155
+ // payload — skip so they never ship.
156
+ if (isGeneratedArtifact(childName)) continue;
154
157
  const destName = childName === 'gitignore' ? '.gitignore' : childName;
155
158
  const childAgg = copyManagedTree({
156
159
  ...opts,
@@ -3,7 +3,8 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
- const { copyRecursive, isTextAsset } = require('./copy-utils');
6
+ const { copyRecursive, isTextAsset, isGeneratedArtifact, normalizeSourcePaths } = require('./copy-utils');
7
+ const { preserveAddressingSection } = require('./instruction-blocks');
7
8
 
8
9
  const SPEC_ROOT = path.resolve(__dirname, '..', '..');
9
10
  const OPENCODE_FOLDER = '.opencode';
@@ -48,7 +49,7 @@ const OPENCODE_COMMAND_TEMPLATES = [
48
49
  {
49
50
  fileName: 'develop.md',
50
51
  skillName: 'develop',
51
- agent: 'god-developer',
52
+ agent: 'implementer',
52
53
  subtask: true,
53
54
  description: 'Implement approved CafeKit spec tasks with scope fidelity.'
54
55
  },
@@ -76,7 +77,7 @@ const OPENCODE_COMMAND_TEMPLATES = [
76
77
  {
77
78
  fileName: 'hotfix.md',
78
79
  skillName: 'hotfix',
79
- agent: 'god-developer',
80
+ agent: 'implementer',
80
81
  subtask: true,
81
82
  description: 'Apply a scout-first, narrow production hotfix.'
82
83
  },
@@ -113,7 +114,7 @@ const OPENCODE_COMMAND_TEMPLATES = [
113
114
  {
114
115
  fileName: 'backend-development.md',
115
116
  skillName: 'backend-development',
116
- agent: 'god-developer',
117
+ agent: 'implementer',
117
118
  subtask: true,
118
119
  description: 'Implement backend services, APIs, data layers, and integrations.'
119
120
  },
@@ -148,7 +149,7 @@ const OPENCODE_COMMAND_TEMPLATES = [
148
149
  {
149
150
  fileName: 'frontend-development.md',
150
151
  skillName: 'frontend-development',
151
- agent: 'god-developer',
152
+ agent: 'implementer',
152
153
  subtask: true,
153
154
  description: 'Build React/TypeScript UI components and frontend features.'
154
155
  },
@@ -162,7 +163,7 @@ const OPENCODE_COMMAND_TEMPLATES = [
162
163
  {
163
164
  fileName: 'mobile-development.md',
164
165
  skillName: 'mobile-development',
165
- agent: 'god-developer',
166
+ agent: 'implementer',
166
167
  subtask: true,
167
168
  description: 'Implement iOS, Android, React Native, or Flutter mobile features.'
168
169
  },
@@ -268,9 +269,12 @@ function formatOpenCodeValue(value) {
268
269
  // Applied to every text asset copied into .opencode/ so the installed
269
270
  // content points at the self-contained OpenCode runtime layout.
270
271
  function normalizeOpenCodeBody(content) {
271
- return content
272
- .replace(/\.claude\/(agents|commands|skills|rules|scripts|references|hooks)\b/g, '.opencode/$1')
273
- .replace(/\.claude\\(agents|commands|skills|rules|scripts|references|hooks)\b/g, '.opencode\\$1')
272
+ return normalizeSourcePaths(content, {
273
+ runtimeRoot: '.opencode',
274
+ skillsRoot: '.opencode/skills'
275
+ })
276
+ .replace(/(?<!~\/)\.claude\/(agents|commands|skills|rules|scripts|references|hooks)\b/g, '.opencode/$1')
277
+ .replace(/(?<!~\\)\.claude\\(agents|commands|skills|rules|scripts|references|hooks)\b/g, '.opencode\\$1')
274
278
  .replace(/\.claude\/runtime\.json/g, '.opencode/runtime.json')
275
279
  .replace(/\.claude\/ROUTING\.md/g, '.opencode/ROUTING.md')
276
280
  .replace(/\.claude\/\.env/g, '.opencode/.env')
@@ -409,16 +413,44 @@ function createOpenCodeAgentsContent(sourceContent) {
409
413
  return adaptedContent.replace('## Core Objective', `${openCodeRuntimeBlock}\n## Core Objective`);
410
414
  }
411
415
 
416
+ function managedOpenCodeRange(content) {
417
+ const existing = String(content || '');
418
+ const startMarker = '<!-- CAFEKIT OPENCODE START -->';
419
+ const endMarker = '<!-- CAFEKIT OPENCODE END -->';
420
+ const start = existing.indexOf(startMarker);
421
+ const end = existing.indexOf(endMarker);
422
+ if (start < 0 && end < 0) return null;
423
+ const duplicateStart = existing.indexOf(startMarker, start + startMarker.length);
424
+ const duplicateEnd = existing.indexOf(endMarker, end + endMarker.length);
425
+ if (start < 0 || end <= start || duplicateStart >= 0 || duplicateEnd >= 0) return false;
426
+ return {
427
+ start,
428
+ end: end + endMarker.length,
429
+ bodyStart: start + startMarker.length,
430
+ bodyEnd: end
431
+ };
432
+ }
433
+
434
+
412
435
  function upsertCafeKitAgentsBlock(existingContent, blockContent) {
413
436
  const start = '<!-- CAFEKIT OPENCODE START -->';
414
437
  const end = '<!-- CAFEKIT OPENCODE END -->';
415
- const managedBlock = `${start}\n${blockContent.trim()}\n${end}`;
416
- const blockPattern = new RegExp(`${start}[\\s\\S]*?${end}`);
417
-
418
- if (blockPattern.test(existingContent)) {
419
- return existingContent.replace(blockPattern, managedBlock);
438
+ let body = String(blockContent).trim();
439
+ // Reinstall replaces the managed block in place. If the new template dropped
440
+ // its Addressing section, carry the exact section over from the existing
441
+ // managed block so the saved address survives for setupAddressing. Only reads
442
+ // the managed body — never user-owned sections outside the block.
443
+ const range = managedOpenCodeRange(existingContent);
444
+ if (range) {
445
+ const existingBody = existingContent.slice(range.bodyStart, range.bodyEnd);
446
+ body = preserveAddressingSection(body, existingBody);
447
+ }
448
+ const managedBlock = `${start}\n${body}\n${end}`;
449
+ if (range === false) return existingContent;
450
+ if (range) {
451
+ return `${existingContent.slice(0, range.start)}${managedBlock}${existingContent.slice(range.end)}`;
420
452
  }
421
-
453
+ if (!existingContent) return `${managedBlock}\n`;
422
454
  // Pre-managed-block releases wrote the shipped template as the whole file.
423
455
  // Wrap only an exact shipped prefix and preserve any user/Codex suffix.
424
456
  if (existingContent.startsWith(blockContent)) {
@@ -431,25 +463,23 @@ function upsertCafeKitAgentsBlock(existingContent, blockContent) {
431
463
  ) {
432
464
  return `${managedBlock}${existingContent.slice(trimmedLegacy.length)}`;
433
465
  }
434
- if (!existingContent) return `${managedBlock}\n`;
435
-
436
466
  const separator = existingContent.endsWith('\n') ? '\n' : '\n\n';
437
467
  return `${existingContent}${separator}${managedBlock}\n`;
438
468
  }
439
469
 
470
+ /** Transform only the CafeKit-owned OpenCode body, preserving surrounding bytes. */
471
+ function transformManagedOpenCodeContent(content, transform) {
472
+ const existing = String(content || '');
473
+ const range = managedOpenCodeRange(existing);
474
+ if (!range || typeof transform !== 'function') return existing;
475
+ const body = existing.slice(range.bodyStart, range.bodyEnd);
476
+ return `${existing.slice(0, range.bodyStart)}${transform(body)}${existing.slice(range.bodyEnd)}`;
477
+ }
478
+
440
479
  function getOpenCodeAgentsTemplateContent() {
441
480
  const openCodeTemplate = path.join(SPEC_ROOT, 'src/opencode/AGENTS.md');
442
-
443
- if (fs.existsSync(openCodeTemplate)) {
444
- return normalizeOpenCodeBody(fs.readFileSync(openCodeTemplate, 'utf8'));
445
- }
446
-
447
- const claudeTemplate = path.join(SPEC_ROOT, 'src/claude/CLAUDE.md');
448
- if (!fs.existsSync(claudeTemplate)) {
449
- return null;
450
- }
451
-
452
- return createOpenCodeAgentsContent(fs.readFileSync(claudeTemplate, 'utf8'));
481
+ if (!fs.existsSync(openCodeTemplate)) return null;
482
+ return normalizeOpenCodeBody(fs.readFileSync(openCodeTemplate, 'utf8'));
453
483
  }
454
484
 
455
485
  // Copy OpenCode project instructions to AGENTS.md at the project root.
@@ -462,6 +492,11 @@ function copyOpenCodeAgentsMdFile(platformKey, results, options = {}) {
462
492
  if (content) {
463
493
  const destinationExists = fs.existsSync(dest);
464
494
  const existingContent = destinationExists ? fs.readFileSync(dest, 'utf8') : '';
495
+ if (managedOpenCodeRange(existingContent) === false) {
496
+ console.log(` ⚠ AGENTS.md: malformed CafeKit OPENCODE marker topology; preserved ${dest} without writing`);
497
+ results.errors++;
498
+ return;
499
+ }
465
500
  const nextContent = upsertCafeKitAgentsBlock(existingContent, content);
466
501
 
467
502
  if (nextContent === existingContent) {
@@ -537,6 +572,10 @@ function copyOpenCodePlugins(platformKey, results, options = {}) {
537
572
 
538
573
  const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
539
574
  entries.forEach(entry => {
575
+ // Skip generated artifacts before any directory/file handling, matching
576
+ // the filter copyRecursive applies to directory children.
577
+ if (isGeneratedArtifact(entry.name)) return;
578
+
540
579
  const sourcePath = path.join(sourceDir, entry.name);
541
580
  const destPath = path.join(targetDir, entry.name);
542
581
 
@@ -555,8 +594,12 @@ function copyOpenCodePlugins(platformKey, results, options = {}) {
555
594
  return;
556
595
  }
557
596
 
558
- const sourceContent = fs.readFileSync(sourcePath, 'utf8');
559
- fs.writeFileSync(destPath, sourceContent, 'utf8');
597
+ if (isTextAsset(sourcePath)) {
598
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
599
+ fs.writeFileSync(destPath, normalizeOpenCodeBody(sourceContent), 'utf8');
600
+ } else {
601
+ fs.copyFileSync(sourcePath, destPath);
602
+ }
560
603
 
561
604
  if (destExists) {
562
605
  console.log(` ↻ Plugin updated: ${entry.name}`);
@@ -846,6 +889,8 @@ module.exports = {
846
889
  convertOpenCodeAgentContent,
847
890
  convertOpenCodeCommandContent,
848
891
  copyOpenCodeAgentsMdFile,
892
+ managedOpenCodeRange,
893
+ transformManagedOpenCodeContent,
849
894
  copyOpenCodeSharedRuntimeFiles,
850
895
  copyOpenCodePlugins,
851
896
  copyOpenCodeCommandTemplates,
@@ -19,10 +19,18 @@ const { sha256 } = require('../lib/manifest');
19
19
  const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
20
20
  const { report, treeAction } = require('./report');
21
21
  const { normalizeOpenCodeBody } = require('../lib/opencode-install');
22
+ const {
23
+ upsertManagedCoreBlock,
24
+ managedRange,
25
+ migrateExactLegacyBlock,
26
+ preserveAddressingSection
27
+ } = require('../lib/instruction-blocks');
22
28
 
23
29
  const SRC = path.join(__dirname, '../../src');
24
30
  const CLAUDE_START = '<!-- CAFEKIT CLAUDE START -->';
25
31
  const CLAUDE_END = '<!-- CAFEKIT CLAUDE END -->';
32
+ const LEGACY_CORE_START = '<!-- CAFEKIT CLAUDE AGENTS START -->';
33
+ const LEGACY_CORE_END = '<!-- CAFEKIT CLAUDE AGENTS END -->';
26
34
 
27
35
  function managedClaudeBlock(template) {
28
36
  return `${CLAUDE_START}\n${template.trimEnd()}\n${CLAUDE_END}`;
@@ -30,9 +38,11 @@ function managedClaudeBlock(template) {
30
38
 
31
39
  function managedClaudeRange(content) {
32
40
  const start = content.indexOf(CLAUDE_START);
33
- if (start === -1) return null;
34
- const endStart = content.indexOf(CLAUDE_END, start + CLAUDE_START.length);
35
- if (endStart === -1) return null;
41
+ const endStart = content.indexOf(CLAUDE_END);
42
+ if (start === -1 && endStart === -1) return null;
43
+ const duplicateStart = content.indexOf(CLAUDE_START, start + CLAUDE_START.length);
44
+ const duplicateEnd = content.indexOf(CLAUDE_END, endStart + CLAUDE_END.length);
45
+ if (start === -1 || endStart <= start || duplicateStart >= 0 || duplicateEnd >= 0) return false;
36
46
  return { start, end: endStart + CLAUDE_END.length, bodyStart: start + CLAUDE_START.length, bodyEnd: endStart };
37
47
  }
38
48
 
@@ -45,7 +55,19 @@ function transformManagedClaudeContent(content, transform) {
45
55
  }
46
56
 
47
57
  function upsertManagedClaudeBlock(existing, template, legacyOwned) {
48
- const block = managedClaudeBlock(template);
58
+ // When an existing managed block present, reinstall replaces it. If the new
59
+ // template dropped its Addressing section, carry the saved section over from
60
+ // the existing managed block so the user's address survives for setupAddressing.
61
+ const existingRange = managedClaudeRange(existing);
62
+ if (existingRange === false) return existing;
63
+ const existingManagedBody = existingRange
64
+ ? existing.slice(existingRange.bodyStart, existingRange.bodyEnd)
65
+ : '';
66
+ let body = template;
67
+ if (existingRange) {
68
+ body = preserveAddressingSection(template, existingManagedBody);
69
+ }
70
+ const block = managedClaudeBlock(body);
49
71
  const range = managedClaudeRange(existing);
50
72
  if (range) {
51
73
  return `${existing.slice(0, range.start)}${block}${existing.slice(range.end)}`;
@@ -60,6 +82,44 @@ function upsertManagedClaudeBlock(existing, template, legacyOwned) {
60
82
  return `${existing}${separator}${block}\n`;
61
83
  }
62
84
 
85
+ function ensureSharedAgentsMdCore(ctx) {
86
+ const src = path.join(SRC, 'common/AGENTS.md');
87
+ const dest = 'AGENTS.md';
88
+ if (!fs.existsSync(src)) {
89
+ ctx.ui.warn('Shared AGENTS.md template not found');
90
+ ctx.results.missingDependencies++;
91
+ return;
92
+ }
93
+
94
+ const template = fs.readFileSync(src, 'utf8');
95
+ const exists = fs.existsSync(dest);
96
+ const existing = exists ? fs.readFileSync(dest, 'utf8') : '';
97
+ if (managedRange(existing) === false) {
98
+ ctx.ui.warn(`AGENTS.md: malformed CafeKit CORE marker topology; preserved ${dest} without writing`);
99
+ ctx.results.errors++;
100
+ return;
101
+ }
102
+ const migrated = migrateExactLegacyBlock(
103
+ existing,
104
+ LEGACY_CORE_START,
105
+ LEGACY_CORE_END,
106
+ template
107
+ );
108
+ const next = upsertManagedCoreBlock(migrated, template);
109
+ const action = !exists ? 'created' : next === existing ? 'unchanged' : 'updated';
110
+
111
+ if (!ctx.dryRun && action !== 'unchanged') {
112
+ fs.writeFileSync(dest, next, 'utf8');
113
+ }
114
+ report(ctx, action, 'AGENTS.md (shared CafeKit core)');
115
+ }
116
+
117
+ /** Install the shared AGENTS.md core used by Claude's @AGENTS.md import. */
118
+ function copyClaudeAgentsMdFile(ctx, platformKey) {
119
+ if (platformKey !== 'claude') return;
120
+ ensureSharedAgentsMdCore(ctx);
121
+ }
122
+
63
123
  /** Copy ROUTING.md (SKILLS_DIR substituted; OpenCode body normalized). */
64
124
  function copyRoutingFile(ctx, platformKey) {
65
125
  const platform = PLATFORMS[platformKey];
@@ -132,6 +192,37 @@ function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
132
192
  });
133
193
  }
134
194
 
195
+ /** Remove renamed agents only when ownership manifest proves pristine bytes. */
196
+ function removeObsoleteAgents(ctx, platformKey) {
197
+ const platform = PLATFORMS[platformKey];
198
+ const obsolete = ctx.manifest?.obsolete?.agents?.[platformKey] || [];
199
+ const tracker = ctx.trackers?.[platformKey];
200
+ if (!platform || !tracker || obsolete.length === 0) return;
201
+
202
+ const ownership = ctx.ownership?.[platform.folder] || { files: {} };
203
+ for (const relPath of obsolete) {
204
+ const targetPath = path.join(platform.folder, relPath);
205
+ if (!fs.existsSync(targetPath)) continue;
206
+
207
+ const key = tracker.keyFor(targetPath);
208
+ const recorded = ownership.files?.[key];
209
+ const currentHash = sha256(fs.readFileSync(targetPath));
210
+ if (recorded?.sha256 === currentHash) {
211
+ if (!ctx.dryRun) {
212
+ fs.rmSync(targetPath, { force: true });
213
+ tracker.prune(key);
214
+ }
215
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete agent: ${targetPath}`);
216
+ ctx.results.updated++;
217
+ continue;
218
+ }
219
+
220
+ ctx.ui.warn(`Preserved user-owned obsolete agent: ${targetPath}`);
221
+ ctx.results.preserved++;
222
+ ctx.results.preservedFiles.push(targetPath);
223
+ }
224
+ }
225
+
135
226
  /** Install CLAUDE.md at the project root (ownership-aware). Claude only. */
136
227
  function copyClaudeMdFile(ctx, platformKey) {
137
228
  if (platformKey !== 'claude') return;
@@ -152,6 +243,11 @@ function copyClaudeMdFile(ctx, platformKey) {
152
243
  const template = fs.readFileSync(src, 'utf8');
153
244
  const exists = fs.existsSync(dest);
154
245
  const existing = exists ? fs.readFileSync(dest, 'utf8') : '';
246
+ if (managedClaudeRange(existing) === false) {
247
+ ctx.ui.warn(`CLAUDE.md: malformed CafeKit CLAUDE marker topology; preserved ${dest} without writing`);
248
+ ctx.results.errors++;
249
+ return;
250
+ }
155
251
  const legacyRecord = ctx.ownership?.[PLATFORMS.claude.folder]?.files?.['../CLAUDE.md'];
156
252
  const legacyOwned = Boolean(legacyRecord && legacyRecord.sha256 === sha256(existing));
157
253
  const next = upsertManagedClaudeBlock(existing, template, legacyOwned);
@@ -187,6 +283,9 @@ module.exports = {
187
283
  copyClaudeRuntimeFiles,
188
284
  removeObsoleteClaudeRuntimeFiles,
189
285
  copyClaudeMdFile,
286
+ copyClaudeAgentsMdFile,
287
+ ensureSharedAgentsMdCore,
190
288
  copyRulesDirectory,
289
+ removeObsoleteAgents,
191
290
  transformManagedClaudeContent
192
291
  };