@haposoft/cafekit 0.15.1 → 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 (99) hide show
  1. package/README.md +6 -2
  2. package/bin/__tests__/codex-hooks.test.js +140 -0
  3. package/bin/__tests__/codex-native.test.js +230 -2
  4. package/bin/__tests__/develop-contract.test.js +1120 -0
  5. package/bin/__tests__/installer-safety.test.js +553 -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/i18n.js +12 -3
  14. package/bin/lib/instruction-blocks.js +85 -0
  15. package/bin/lib/managed-writer.js +4 -1
  16. package/bin/lib/opencode-install.js +75 -30
  17. package/bin/lib/skill-deps.js +45 -7
  18. package/bin/phases/claude-runtime.js +103 -4
  19. package/bin/phases/codex-runtime.js +41 -7
  20. package/bin/phases/post-install.js +29 -22
  21. package/bin/phases/root-config.js +89 -10
  22. package/bin/phases/select-platform.js +23 -0
  23. package/bin/phases/skills-setup.js +7 -3
  24. package/package.json +15 -5
  25. package/src/claude/CLAUDE.md +6 -122
  26. package/src/claude/agents/code-auditor.md +21 -22
  27. package/src/claude/agents/debugger.md +3 -3
  28. package/src/claude/agents/deployer.md +1 -1
  29. package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
  30. package/src/claude/agents/project-manager.md +1 -1
  31. package/src/claude/agents/spec-maker.md +2 -2
  32. package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
  33. package/src/claude/hooks/agent.cjs +7 -14
  34. package/src/claude/hooks/rules.cjs +33 -53
  35. package/src/claude/hooks/spec-gate.cjs +66 -21
  36. package/src/claude/hooks/spec-state.cjs +5 -0
  37. package/src/claude/migration-manifest.json +9 -2
  38. package/src/claude/runtime.json +0 -3
  39. package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
  40. package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
  41. package/src/claude/scripts/spec-ground.cjs +189 -83
  42. package/src/claude/scripts/validate-spec-output.cjs +294 -30
  43. package/src/claude/scripts/workflow-policy.cjs +609 -0
  44. package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
  45. package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
  46. package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
  47. package/src/claude/skills/backend-development/SKILL.md +4 -4
  48. package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
  49. package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
  50. package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
  51. package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
  52. package/src/claude/skills/backend-development/references/backend-security.md +3 -3
  53. package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
  54. package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
  55. package/src/claude/skills/brainstorm/SKILL.md +2 -3
  56. package/src/claude/skills/chrome-devtools/scripts/package-lock.json +261 -150
  57. package/src/claude/skills/chrome-devtools/scripts/package.json +1 -1
  58. package/src/claude/skills/code-review/SKILL.md +12 -8
  59. package/src/claude/skills/code-review/references/verification-gate.md +4 -4
  60. package/src/claude/skills/debug/SKILL.md +2 -0
  61. package/src/claude/skills/develop/SKILL.md +100 -52
  62. package/src/claude/skills/develop/references/parallel-waves.md +105 -26
  63. package/src/claude/skills/develop/references/quality-gate.md +58 -38
  64. package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
  65. package/src/claude/skills/git/SKILL.md +44 -50
  66. package/src/claude/skills/hotfix/SKILL.md +10 -8
  67. package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
  68. package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
  69. package/src/claude/skills/inspect/SKILL.md +12 -30
  70. package/src/claude/skills/mobile-development/SKILL.md +12 -12
  71. package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
  72. package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
  73. package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
  74. package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
  75. package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
  76. package/src/claude/skills/pptx/scripts/package-lock.json +868 -0
  77. package/src/claude/skills/pptx/scripts/package.json +1 -1
  78. package/src/claude/skills/question/SKILL.md +13 -13
  79. package/src/claude/skills/research/SKILL.md +2 -2
  80. package/src/claude/skills/specs/SKILL.md +32 -15
  81. package/src/claude/skills/specs/references/review.md +3 -3
  82. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  83. package/src/claude/skills/specs/templates/spec-state.json +7 -3
  84. package/src/claude/skills/sync/SKILL.md +26 -1
  85. package/src/claude/skills/sync/references/sync-protocols.md +3 -3
  86. package/src/claude/skills/test/SKILL.md +12 -4
  87. package/src/claude/skills/test/references/failure-triage.md +17 -17
  88. package/src/claude/skills/test/references/test-memory.md +2 -2
  89. package/src/codex/AGENTS.md +16 -24
  90. package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
  91. package/src/codex/hooks/rules.cjs +27 -26
  92. package/src/codex/hooks/spec-gate.cjs +19 -8
  93. package/src/codex/hooks/spec-state.cjs +10 -1
  94. package/src/codex/hooks.json +14 -14
  95. package/src/common/AGENTS.md +43 -0
  96. package/src/opencode/AGENTS.md +21 -121
  97. package/src/opencode/plugins/rules.ts +26 -34
  98. package/src/opencode/plugins/spec-gate.ts +283 -0
  99. package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
7
8
  const { test } = require('node:test');
8
9
 
9
10
  const backup = require('../lib/backup');
@@ -11,12 +12,83 @@ const {
11
12
  copyOpenCodeAgentsMdFile,
12
13
  normalizeOpenCodeBody
13
14
  } = require('../lib/opencode-install');
14
- const { copyClaudeMdFile } = require('../phases/claude-runtime');
15
+ const {
16
+ commandFailureReason,
17
+ resolvePackageCommand
18
+ } = require('../lib/skill-deps');
19
+ const { copyClaudeMdFile, copyClaudeAgentsMdFile, removeObsoleteAgents } = require('../phases/claude-runtime');
20
+ const { ensureGitignore } = require('../phases/root-config');
15
21
  const { configureAddressing, patchRuntimeLocale } = require('../phases/post-install');
22
+ const { upsertManagedCoreBlock } = require('../lib/instruction-blocks');
23
+ const { upsertManagedCodexBlock, normalizeCodexBody } = require('../lib/codex-install');
24
+ const { copyRecursive } = require('../lib/copy-utils');
25
+ const { createTracker, sha256 } = require('../lib/manifest');
16
26
 
17
27
  const START = '<!-- CAFEKIT CLAUDE START -->';
18
28
  const END = '<!-- CAFEKIT CLAUDE END -->';
29
+ const CORE_START = '<!-- CAFEKIT CORE START -->';
30
+ const CORE_END = '<!-- CAFEKIT CORE END -->';
19
31
  const TEMPLATE = fs.readFileSync(path.join(__dirname, '../../src/claude/CLAUDE.md'), 'utf8');
32
+ const SHARED_TEMPLATE = fs.readFileSync(path.join(__dirname, '../../src/common/AGENTS.md'), 'utf8');
33
+ const INSTALLER = path.join(__dirname, '../install.js');
34
+ const CODEX_START = '<!-- CAFEKIT CODEX START -->';
35
+ const CODEX_END = '<!-- CAFEKIT CODEX END -->';
36
+ const OPENCODE_START = '<!-- CAFEKIT OPENCODE START -->';
37
+ const OPENCODE_END = '<!-- CAFEKIT OPENCODE END -->';
38
+
39
+ function managedBody(content, start, end) {
40
+ const startIndex = content.indexOf(start);
41
+ const endIndex = content.indexOf(end);
42
+ assert.ok(startIndex >= 0 && endIndex > startIndex, `missing managed block: ${start}`);
43
+ return content.slice(startIndex + start.length, endIndex);
44
+ }
45
+
46
+ function runCombinedInstaller(root, extraArgs = []) {
47
+ return spawnSync(
48
+ process.execPath,
49
+ [INSTALLER, '--platform', 'claude,codex,opencode', '--yes', ...extraArgs],
50
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } }
51
+ );
52
+ }
53
+
54
+ test('skill dependency package commands use cmd.exe for Windows npm launchers', () => {
55
+ const windows = resolvePackageCommand(
56
+ 'npm',
57
+ ['install', '--no-audit', '--no-fund'],
58
+ 'win32',
59
+ { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
60
+ );
61
+ assert.deepEqual(windows, {
62
+ command: 'C:\\Windows\\System32\\cmd.exe',
63
+ args: ['/d', '/s', '/c', 'npm.cmd', 'install', '--no-audit', '--no-fund']
64
+ });
65
+
66
+ const posix = resolvePackageCommand('npx', ['playwright', '--version'], 'linux', {});
67
+ assert.deepEqual(posix, {
68
+ command: 'npx',
69
+ args: ['playwright', '--version']
70
+ });
71
+ });
72
+
73
+ test('skill dependency failures surface the useful npm or spawn error code', () => {
74
+ assert.equal(
75
+ commandFailureReason({
76
+ stderr: "npm error Request failed\nnpm error code ENOTCACHED\nnpm error Log unavailable\n",
77
+ stdout: '',
78
+ status: 1
79
+ }),
80
+ 'npm error code ENOTCACHED'
81
+ );
82
+ assert.equal(
83
+ commandFailureReason({
84
+ stderr: 'spawn npm.cmd EINVAL',
85
+ stdout: '',
86
+ status: null,
87
+ errorCode: 'EINVAL'
88
+ }),
89
+ 'spawn npm.cmd EINVAL'
90
+ );
91
+ });
20
92
 
21
93
  function inTempProject(run) {
22
94
  const originalCwd = process.cwd();
@@ -30,6 +102,306 @@ function inTempProject(run) {
30
102
  }
31
103
  }
32
104
 
105
+ test('legacy managed root gitignore migrates A13 plan patterns and preserves user content', () => {
106
+ inTempProject((root) => {
107
+ fs.writeFileSync('.gitignore', [
108
+ '# User preface',
109
+ 'keep-before.txt',
110
+ '',
111
+ '# CafeKit / Ecosystem',
112
+ '',
113
+ '# legacy managed note',
114
+ 'specs/_shared/',
115
+ 'plans/',
116
+ '!plans/templates/*',
117
+ '.cafekit-backup/',
118
+ '.cafekit.lock',
119
+ '.claude/',
120
+ '.opencode/',
121
+ '.codex/',
122
+ '.agents/',
123
+ '',
124
+ '# User-owned section',
125
+ 'custom-ignore/',
126
+ 'keep-after.txt',
127
+ ''
128
+ ].join('\n'), 'utf8');
129
+
130
+ const context = {
131
+ dryRun: false,
132
+ ui: { detail() {} },
133
+ results: { copied: 0, updated: 0, skipped: 0 }
134
+ };
135
+ ensureGitignore(context);
136
+
137
+ const migrated = fs.readFileSync('.gitignore', 'utf8');
138
+ const lines = migrated.split('\n');
139
+ const headerIndex = lines.indexOf('# CafeKit / Ecosystem');
140
+ const userSectionIndex = lines.indexOf('# User-owned section');
141
+ assert.deepEqual(lines.slice(headerIndex + 1, userSectionIndex - 1), [
142
+ '',
143
+ '# legacy managed note',
144
+ 'specs/_shared/',
145
+ 'plans/*',
146
+ '!plans/*.md',
147
+ '!plans/templates/',
148
+ '!plans/templates/**',
149
+ '.cafekit-backup/',
150
+ '.cafekit.lock',
151
+ '.claude/',
152
+ '.opencode/',
153
+ '.codex/',
154
+ '.agents/'
155
+ ]);
156
+ assert.match(migrated, /# User preface\nkeep-before\.txt/);
157
+ assert.match(migrated, /# User-owned section\ncustom-ignore\/\nkeep-after\.txt/);
158
+ assert.doesNotMatch(migrated, /^plans\/$/m);
159
+ assert.doesNotMatch(migrated, /^!plans\/templates\/\*$/m);
160
+
161
+ const firstPass = Buffer.from(migrated);
162
+ assert.equal(context.results.updated, 1);
163
+ ensureGitignore(context);
164
+ assert.deepEqual(fs.readFileSync('.gitignore'), firstPass);
165
+ assert.equal(context.results.updated, 1);
166
+ assert.equal(context.results.skipped, 1);
167
+
168
+ fs.mkdirSync(path.join(root, 'plans/reports'), { recursive: true });
169
+ fs.writeFileSync(path.join(root, 'plans/example.md'), 'top-level markdown\n');
170
+ fs.writeFileSync(path.join(root, 'plans/reports/example.md'), 'nested markdown\n');
171
+ fs.writeFileSync(path.join(root, 'plans/example.html'), 'html artifact\n');
172
+ const init = spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
173
+ assert.equal(init.status, 0, init.stderr);
174
+ const status = spawnSync(
175
+ 'git',
176
+ ['status', '--short', '--ignored', '--untracked-files=all'],
177
+ { cwd: root, encoding: 'utf8' }
178
+ );
179
+ assert.equal(status.status, 0, status.stderr);
180
+ assert.match(status.stdout, /^\?\? plans\/example\.md$/m);
181
+ assert.match(status.stdout, /^!! plans\/reports\/example\.md$/m);
182
+ assert.match(status.stdout, /^!! plans\/example\.html$/m);
183
+ assert.doesNotMatch(status.stdout, /^!! plans\/example\.md$/m);
184
+ });
185
+ });
186
+
187
+ test('malformed managed marker topology fails transactionally and preserves exact bytes', () => {
188
+ const markers = {
189
+ claude: ['<!-- CAFEKIT CORE START -->', '<!-- CAFEKIT CORE END -->'],
190
+ codex: ['<!-- CAFEKIT CODEX START -->', '<!-- CAFEKIT CODEX END -->'],
191
+ opencode: ['<!-- CAFEKIT OPENCODE START -->', '<!-- CAFEKIT OPENCODE END -->']
192
+ };
193
+ const topologies = (start, end) => [
194
+ `user\n${end}\n`,
195
+ `user\n${start}\n`,
196
+ `${start}\na\n${start}\nb\n${end}\n`,
197
+ `${start}\na\n${end}\nb\n${end}\n`,
198
+ `${end}\nuser\n${start}\n`
199
+ ];
200
+
201
+ for (const [platform, [start, end]] of Object.entries(markers)) {
202
+ for (const content of topologies(start, end)) {
203
+ inTempProject((root) => {
204
+ fs.writeFileSync('AGENTS.md', content, 'utf8');
205
+ const before = fs.readFileSync('AGENTS.md');
206
+ const result = spawnSync(
207
+ process.execPath,
208
+ [INSTALLER, '--platform', platform, '--yes', '--force-overwrite'],
209
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } }
210
+ );
211
+ assert.notEqual(result.status, 0, `${platform}\n${result.stdout}\n${result.stderr}`);
212
+ assert.deepEqual(fs.readFileSync('AGENTS.md'), before);
213
+ assert.match(`${result.stdout}\n${result.stderr}`, /malformed .*marker topology/i);
214
+ assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /unchanged|success/i);
215
+ });
216
+ }
217
+ }
218
+ });
219
+
220
+ test('combined install keeps CORE neutral and records native shared-root trade-off', () => {
221
+ inTempProject((root) => {
222
+ const result = runCombinedInstaller(root);
223
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
224
+
225
+ const agents = fs.readFileSync('AGENTS.md', 'utf8');
226
+ const claude = fs.readFileSync('CLAUDE.md', 'utf8');
227
+ const core = managedBody(agents, CORE_START, CORE_END);
228
+ const codex = managedBody(agents, CODEX_START, CODEX_END);
229
+ const opencode = managedBody(agents, OPENCODE_START, OPENCODE_END);
230
+ const claudeBlock = managedBody(claude, START, END);
231
+
232
+ for (const [content, marker] of [
233
+ [agents, CORE_START],
234
+ [claude, START],
235
+ [agents, CODEX_START],
236
+ [agents, OPENCODE_START]
237
+ ]) assert.equal((content.match(new RegExp(marker, 'g')) || []).length, 1);
238
+
239
+ assert.doesNotMatch(core, /Claude|Codex|OpenCode|\.claude|\.codex|\.opencode|\/hapo:|\$hapo-/i);
240
+ assert.doesNotMatch(claudeBlock, /Codex|OpenCode|\.codex|\.opencode|\$hapo-/i);
241
+ assert.match(codex, /native project instruction surface is root `AGENTS\.md`/);
242
+ assert.match(opencode, /native project instruction surface is root `AGENTS\.md`/);
243
+ assert.match(agents, /shared-root trade-off is intentional/);
244
+ // H5 remediation: ownership/ignore contract must be explicit — not just marker presence
245
+ assert.match(core, /runtime-neutral/i);
246
+ assert.match(core, /fail-safe/i);
247
+ assert.match(core, /Ignore managed blocks not owned by your runtime/i);
248
+ assert.match(codex, /owned by Codex/i);
249
+ assert.match(codex, /ignore this entire Codex block/i);
250
+ assert.match(codex, /fail-safe/i);
251
+ assert.match(opencode, /owned by OpenCode/i);
252
+ assert.match(opencode, /ignore this entire OpenCode block/i);
253
+ assert.match(opencode, /fail-safe/i);
254
+ // CORE must not contain runtime-specific directives that would leak
255
+ assert.doesNotMatch(core, /\$hapo-|hapo:/i);
256
+ // No cross-runtime directive leakage: Claude block must not contain foreign runtime directives
257
+ assert.doesNotMatch(claudeBlock, /<!-- CAFEKIT (CODEX|OPENCODE) /);
258
+ assert.doesNotMatch(codex, /<!-- CAFEKIT (CORE|OPENCODE) /);
259
+ assert.doesNotMatch(opencode, /<!-- CAFEKIT (CORE|CODEX) /);
260
+
261
+ const userAgents = '\n## User-owned combined note\nKeep this exact.\n';
262
+ const userClaude = '\n## User-owned Claude note\nKeep this exact.\n';
263
+ fs.appendFileSync('AGENTS.md', userAgents);
264
+ fs.appendFileSync('CLAUDE.md', userClaude);
265
+ const rerun = runCombinedInstaller(root, ['--force-overwrite']);
266
+ assert.equal(rerun.status, 0, `${rerun.stdout}\\n${rerun.stderr}`);
267
+ const afterAgents = fs.readFileSync('AGENTS.md', 'utf8');
268
+ const afterClaude = fs.readFileSync('CLAUDE.md', 'utf8');
269
+ assert.match(afterAgents, /User-owned combined note\nKeep this exact\./);
270
+ assert.match(afterClaude, /User-owned Claude note\nKeep this exact\./);
271
+ assert.equal((afterAgents.match(new RegExp(CORE_START, 'g')) || []).length, 1);
272
+ assert.equal((afterAgents.match(new RegExp(CODEX_START, 'g')) || []).length, 1);
273
+ assert.equal((afterAgents.match(new RegExp(OPENCODE_START, 'g')) || []).length, 1);
274
+ assert.equal((afterClaude.match(new RegExp(START, 'g')) || []).length, 1);
275
+ assert.equal(afterAgents.slice(-userAgents.length), userAgents);
276
+ assert.equal(afterClaude.slice(-userClaude.length), userClaude);
277
+ // Ownership contract must survive rerun
278
+ const afterCore = managedBody(afterAgents, CORE_START, CORE_END);
279
+ const afterCodex = managedBody(afterAgents, CODEX_START, CODEX_END);
280
+ const afterOpenCode = managedBody(afterAgents, OPENCODE_START, OPENCODE_END);
281
+ assert.match(afterCore, /runtime-neutral/i);
282
+ assert.match(afterCodex, /ignore this entire Codex block/i);
283
+ assert.match(afterOpenCode, /ignore this entire OpenCode block/i);
284
+ });
285
+ });
286
+
287
+ test('combined locale stays in CORE and addressing stays in each native managed block', () => {
288
+ inTempProject((root) => {
289
+ const result = spawnSync(
290
+ process.execPath,
291
+ [INSTALLER, '--platform', 'claude,codex,opencode', '--yes', '--lang', 'vi'],
292
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } }
293
+ );
294
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
295
+
296
+ const agents = fs.readFileSync('AGENTS.md', 'utf8');
297
+ const claude = fs.readFileSync('CLAUDE.md', 'utf8');
298
+ const core = managedBody(agents, CORE_START, CORE_END);
299
+ assert.equal((core.match(/## Language Consistency <!-- cafekit:lang -->/g) || []).length, 1);
300
+ assert.equal((claude.match(/## Language Consistency <!-- cafekit:lang -->/g) || []).length, 0);
301
+
302
+ configureAddressing({
303
+ platforms: ['claude', 'codex', 'opencode'],
304
+ ui: { success() {}, warn() {} },
305
+ t: () => 'updated'
306
+ }, 'anh');
307
+
308
+ const addressedAgents = fs.readFileSync('AGENTS.md', 'utf8');
309
+ const addressedClaude = fs.readFileSync('CLAUDE.md', 'utf8');
310
+ assert.equal((addressedAgents.match(/## Addressing \(Context Overflow Indicator\)/g) || []).length, 2);
311
+ assert.equal((addressedClaude.match(/## Addressing \(Context Overflow Indicator\)/g) || []).length, 1);
312
+ assert.equal((managedBody(addressedAgents, CORE_START, CORE_END).match(/## Addressing \(Context Overflow Indicator\)/g) || []).length, 0);
313
+ assert.match(managedBody(addressedAgents, CODEX_START, CODEX_END), /Codex CLI always addresses the user as "anh"/);
314
+ assert.match(managedBody(addressedAgents, OPENCODE_START, OPENCODE_END), /OpenCode always addresses the user as "anh"/);
315
+ assert.match(managedBody(addressedClaude, START, END), /Claude Code always addresses the user as "anh"/);
316
+ });
317
+ });
318
+ test('combined malformed runtime marker rolls back exact root bytes', () => {
319
+ inTempProject((root) => {
320
+ const before = `user bytes\n${CODEX_START}\nunterminated\n`;
321
+ fs.writeFileSync('AGENTS.md', before, 'utf8');
322
+ const result = runCombinedInstaller(root, ['--force-overwrite']);
323
+ assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
324
+ assert.equal(fs.readFileSync('AGENTS.md', 'utf8'), before);
325
+ assert.match(`${result.stdout}\n${result.stderr}`, /malformed .*marker topology/i);
326
+ assert.match(`${result.stdout}\n${result.stderr}`, /Installation failed|Changes rolled back/);
327
+ });
328
+ });
329
+
330
+ test('malformed CLAUDE.md marker topology preserves exact bytes and reports an error', () => {
331
+ inTempProject(() => {
332
+ const content = `${START}\nuser managed content without end\n`;
333
+ fs.writeFileSync('CLAUDE.md', content, 'utf8');
334
+ const ctx = installContext([]);
335
+ copyClaudeMdFile(ctx, 'claude');
336
+ assert.equal(fs.readFileSync('CLAUDE.md', 'utf8'), content);
337
+ assert.equal(ctx.results.errors, 1);
338
+ });
339
+ });
340
+
341
+ test('fresh no-lang install leaves locale unset and rules hook silent', () => {
342
+ inTempProject((root) => {
343
+ const result = spawnSync(
344
+ process.execPath,
345
+ [INSTALLER, '--platform', 'claude', '--yes'],
346
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } }
347
+ );
348
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
349
+ const runtime = JSON.parse(fs.readFileSync('.claude/runtime.json', 'utf8'));
350
+ assert.equal(runtime.locale.responseLanguage, null);
351
+ const hook = spawnSync(
352
+ process.execPath,
353
+ [path.join(root, '.claude/hooks/rules.cjs')],
354
+ { cwd: root, input: JSON.stringify({ cwd: root, session_id: 'fresh-no-lang' }), encoding: 'utf8' }
355
+ );
356
+ assert.equal(hook.status, 0, hook.stderr);
357
+ assert.doesNotMatch(hook.stdout, /Language Consistency|Always respond in|Respond in/);
358
+ });
359
+ });
360
+
361
+ test('obsolete agent pruning is ownership-aware across Claude, Codex, and OpenCode', () => {
362
+ const cases = {
363
+ claude: ['agents/god-developer.md', '.claude'],
364
+ codex: ['agents/god_developer.toml', '.codex'],
365
+ opencode: ['agents/god-developer.md', '.opencode']
366
+ };
367
+ for (const [platformKey, [relative, folder]] of Object.entries(cases)) {
368
+ for (const state of ['pristine', 'modified', 'untracked']) {
369
+ inTempProject(() => {
370
+ const target = path.join(folder, relative);
371
+ fs.mkdirSync(path.dirname(target), { recursive: true });
372
+ fs.writeFileSync(target, 'old agent\n', 'utf8');
373
+ const tracker = platformKey === 'codex'
374
+ ? createTracker(folder, 'test', { recordRoot: '.', allowedRoots: ['.codex', '.agents'] })
375
+ : createTracker(folder, 'test');
376
+ const key = tracker.keyFor(target);
377
+ const ownership = state === 'untracked'
378
+ ? { files: {} }
379
+ : { files: { [key]: { sha256: sha256(state === 'pristine' ? 'old agent\n' : 'different\n') } } };
380
+ const warnings = [];
381
+ const ctx = {
382
+ dryRun: false,
383
+ manifest: { obsolete: { agents: { [platformKey]: [relative] } } },
384
+ ownership: { [folder]: ownership },
385
+ trackers: { [platformKey]: tracker },
386
+ ui: { detail() {}, warn(message) { warnings.push(message); } },
387
+ results: { updated: 0, preserved: 0, preservedFiles: [] }
388
+ };
389
+ removeObsoleteAgents(ctx, platformKey);
390
+ if (state === 'pristine') {
391
+ assert.equal(fs.existsSync(target), false);
392
+ assert.equal(tracker._pruned.has(key), true);
393
+ assert.equal(warnings.length, 0);
394
+ } else {
395
+ assert.equal(fs.existsSync(target), true);
396
+ assert.equal(warnings.length, 1);
397
+ assert.equal(ctx.results.preserved, 1);
398
+ assert.equal(tracker._pruned.has(key), false);
399
+ }
400
+ });
401
+ }
402
+ }
403
+ });
404
+
33
405
  function installContext(pruned) {
34
406
  return {
35
407
  dryRun: false,
@@ -49,6 +421,7 @@ function installContext(pruned) {
49
421
  unchanged: 0,
50
422
  preserved: 0,
51
423
  missingDependencies: 0,
424
+ errors: 0,
52
425
  preservedFiles: []
53
426
  }
54
427
  };
@@ -86,6 +459,22 @@ test('legacy pristine CLAUDE template migrates to one managed block', () => {
86
459
  });
87
460
  });
88
461
 
462
+ test('legacy shared AGENTS block migrates to one core marker', () => {
463
+ inTempProject(() => {
464
+ const shared = fs.readFileSync(path.join(__dirname, '../../src/common/AGENTS.md'), 'utf8');
465
+ fs.writeFileSync(
466
+ 'AGENTS.md',
467
+ `# User instructions\n\n<!-- CAFEKIT CLAUDE AGENTS START -->\n${shared.trimEnd()}\n<!-- CAFEKIT CLAUDE AGENTS END -->\n`
468
+ );
469
+
470
+ copyClaudeAgentsMdFile(installContext([]), 'claude');
471
+
472
+ const content = fs.readFileSync('AGENTS.md', 'utf8');
473
+ assert.equal((content.match(/<!-- CAFEKIT CORE START -->/g) || []).length, 1);
474
+ assert.equal((content.match(/<!-- CAFEKIT CLAUDE AGENTS START -->/g) || []).length, 0);
475
+ assert.ok(content.startsWith('# User instructions'));
476
+ });
477
+ });
89
478
  test('addressing changes remain inside the managed CLAUDE block', () => {
90
479
  inTempProject(() => {
91
480
  const userSection = '## Addressing (Context Overflow Indicator)\n\nUser-owned wording.';
@@ -108,6 +497,31 @@ test('addressing changes remain inside the managed CLAUDE block', () => {
108
497
  });
109
498
  });
110
499
 
500
+ test('addressing appends when template has no section without entering shared CORE', () => {
501
+ inTempProject(() => {
502
+ const templateWithoutAddressing = TEMPLATE
503
+ .replace(/\n## Addressing \(Context Overflow Indicator\)[\s\S]*$/m, '')
504
+ .trimEnd();
505
+ fs.writeFileSync('CLAUDE.md', `${START}\n${templateWithoutAddressing}\n${END}\n`);
506
+ fs.writeFileSync('AGENTS.md', upsertManagedCoreBlock('', SHARED_TEMPLATE));
507
+ const ctx = {
508
+ platforms: ['claude'],
509
+ ui: { success() {}, warn() {} },
510
+ t: () => 'updated'
511
+ };
512
+
513
+ configureAddressing(ctx, 'bro');
514
+
515
+ const claude = fs.readFileSync('CLAUDE.md', 'utf8');
516
+ const agents = fs.readFileSync('AGENTS.md', 'utf8');
517
+ const core = agents.slice(agents.indexOf(CORE_START), agents.indexOf(CORE_END));
518
+ assert.match(claude, /## Addressing \(Context Overflow Indicator\)/);
519
+ assert.match(claude, /Claude Code always addresses the user as "bro"/);
520
+ assert.doesNotMatch(core, /## Addressing \(Context Overflow Indicator\)/);
521
+ assert.doesNotMatch(core, /always addresses the user as "bro"/);
522
+ });
523
+ });
524
+
111
525
  test('OpenCode force refresh preserves user and Codex AGENTS blocks', () => {
112
526
  inTempProject(() => {
113
527
  const userContent = '# User instructions\n\nKeep this exact.\n';
@@ -137,7 +551,7 @@ test('legacy unmarked OpenCode AGENTS content is wrapped without duplication', (
137
551
 
138
552
  const content = fs.readFileSync('AGENTS.md', 'utf8');
139
553
  assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
140
- assert.equal((content.match(/^# AGENTS\.md$/gm) || []).length, 1);
554
+ assert.ok(content.includes('## OpenCode Runtime Mapping'));
141
555
  assert.ok(content.includes(codexBlock.trim()));
142
556
  });
143
557
  });
@@ -257,3 +671,140 @@ test('managed targets and snapshots reject project symlink traversal', () => {
257
671
  );
258
672
  });
259
673
  });
674
+
675
+ test('managed Claude Addressing survives force refresh when template drops it', () => {
676
+ inTempProject(() => {
677
+ const before = '# User preface\n\n';
678
+ const after = '\n\n## User tail\nKeep this exactly.\n';
679
+ const configuredAddressing = '## Addressing (Context Overflow Indicator)\n\nThe AI always addresses the user as "anh" throughout the conversation.';
680
+ fs.writeFileSync(
681
+ 'CLAUDE.md',
682
+ `${before}${START}\nManaged line.\n\n${configuredAddressing}\n${END}${after}`
683
+ );
684
+
685
+ const pruned = [];
686
+ copyClaudeMdFile(installContext(pruned), 'claude');
687
+
688
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
689
+ assert.ok(content.startsWith(before));
690
+ assert.ok(content.endsWith(after));
691
+ assert.ok(content.includes(TEMPLATE.trimEnd()));
692
+ assert.equal((content.match(/## Addressing \(Context Overflow Indicator\)/g) || []).length, 1);
693
+ assert.ok(content.includes('The AI always addresses the user as "anh"'));
694
+ assert.equal(content.match(new RegExp(START, 'g')).length, 1);
695
+ assert.deepEqual(pruned, ['../CLAUDE.md']);
696
+ });
697
+ });
698
+
699
+ test('managed Codex block Addressing survives upsert with the current template', () => {
700
+ inTempProject(() => {
701
+ const codexTemplate = normalizeCodexBody(
702
+ fs.readFileSync(path.join(__dirname, '../../src/codex/AGENTS.md'), 'utf8')
703
+ );
704
+ const userTop = '# User codex notes\n\n';
705
+ const userBottom = '\n\n## User section\nKeep me.\n';
706
+ const configuredAddressing = '## Addressing (Context Overflow Indicator)\n\nThe AI always addresses the user as "bro" throughout the conversation.';
707
+ const existing =
708
+ `${userTop}<!-- CAFEKIT CODEX START -->\nCodex runtime line.\n\n${configuredAddressing}\n<!-- CAFEKIT CODEX END -->${userBottom}`;
709
+
710
+ const next = upsertManagedCodexBlock(existing, codexTemplate);
711
+
712
+ assert.ok(next.startsWith(userTop));
713
+ assert.ok(next.endsWith(userBottom));
714
+ assert.ok(next.includes(codexTemplate.trim()));
715
+ assert.equal((next.match(/## Addressing \(Context Overflow Indicator\)/g) || []).length, 1);
716
+ assert.ok(next.includes('The AI always addresses the user as "bro"'));
717
+ assert.equal((next.match(/<!-- CAFEKIT CODEX START -->/g) || []).length, 1);
718
+ });
719
+ });
720
+
721
+ test('managed OpenCode block Addressing survives force refresh with the current template', () => {
722
+ inTempProject(() => {
723
+ const userContent = '# User instructions\n\nKeep this exact.\n';
724
+ const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
725
+ const configuredAddressing = '## Addressing (Context Overflow Indicator)\n\nThe AI always addresses the user as "anh" throughout the conversation.';
726
+ fs.writeFileSync(
727
+ 'AGENTS.md',
728
+ `${userContent}<!-- CAFEKIT OPENCODE START -->\nOpenCode runtime line.\n\n${configuredAddressing}\n<!-- CAFEKIT OPENCODE END -->\n${codexBlock}`
729
+ );
730
+ const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
731
+
732
+ copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
733
+
734
+ const content = fs.readFileSync('AGENTS.md', 'utf8');
735
+ assert.ok(content.startsWith(userContent));
736
+ assert.ok(content.includes(codexBlock.trim()));
737
+ assert.equal((content.match(/## Addressing \(Context Overflow Indicator\)/g) || []).length, 1);
738
+ assert.ok(content.includes('The AI always addresses the user as "anh"'));
739
+ assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
740
+ assert.equal(results.updated, 1);
741
+ });
742
+ });
743
+
744
+ test('copyRecursive skips generated artifacts while copying normal files', () => {
745
+ inTempProject(() => {
746
+ fs.mkdirSync('src/__pycache__', { recursive: true });
747
+ fs.mkdirSync('src/extra', { recursive: true });
748
+ fs.writeFileSync('src/normal.txt', 'normal\n');
749
+ fs.writeFileSync('src/.coverage', 'coverage database\n');
750
+ fs.writeFileSync('src/module.pyc', 'bytecode\n');
751
+ fs.writeFileSync('src/__pycache__/module.pyc', 'cached bytecode\n');
752
+ fs.writeFileSync('src/extra/bundle.pyo', 'pyo bytecode\n');
753
+
754
+ copyRecursive('src', 'dest');
755
+
756
+ assert.equal(fs.readFileSync('dest/normal.txt', 'utf8'), 'normal\n');
757
+ assert.equal(fs.existsSync('dest/.coverage'), false);
758
+ assert.equal(fs.existsSync('dest/__pycache__'), false);
759
+ assert.equal(fs.existsSync('dest/module.pyc'), false);
760
+ assert.equal(fs.existsSync('dest/extra/bundle.pyo'), false);
761
+ });
762
+ });
763
+
764
+ test('OpenCode parity: AGENTS marks advisory completion gate and plugin provides best-effort spec-gate', () => {
765
+ const opencodeAgents = fs.readFileSync(path.join(__dirname, '../../src/opencode/AGENTS.md'), 'utf8');
766
+ assert.match(opencodeAgents, /advisory/i);
767
+ assert.match(opencodeAgents, /tier-2/i);
768
+ assert.match(opencodeAgents, /completion gate/i);
769
+ assert.match(opencodeAgents, /Do not claim parity with Claude\/Codex on `completion_gate`/);
770
+ assert.match(opencodeAgents, /spec-gate/);
771
+ // docs must describe actual after-hook, not before, and warning/banner semantics (not advisory-pass)
772
+ assert.match(opencodeAgents, /tool\.execute\.after/);
773
+ assert.doesNotMatch(opencodeAgents, /tool\.execute\.before/);
774
+ assert.match(opencodeAgents, /emits an advisory warning\/banner but does not hard-block the turn/);
775
+ assert.doesNotMatch(opencodeAgents, /treat `done` without a receipt as advisory-pass/);
776
+ // plugin file must exist and be advisory (never hard-block)
777
+ const pluginPath = path.join(__dirname, '../../src/opencode/plugins/spec-gate.ts');
778
+ assert.equal(fs.existsSync(pluginPath), true, 'OpenCode spec-gate plugin must exist for parity contract');
779
+ const plugin = fs.readFileSync(pluginPath, 'utf8');
780
+ assert.match(plugin, /advisory/i);
781
+ assert.match(plugin, /completion_gate/);
782
+ assert.match(plugin, /fail-open/i);
783
+ assert.match(plugin, /session\.idle|tool\.execute\.after/);
784
+ // JSDoc-safe wording: must not contain block-comment-closing pattern specs/*/spec.json
785
+ assert.doesNotMatch(plugin, /specs\/\*\/spec\.json/);
786
+ assert.match(plugin, /specs\/<feature>\/spec\.json/);
787
+ // traversal check must be sibling-prefix safe (resolve + relative, not simple startsWith)
788
+ assert.match(plugin, /relative\s*\(\s*resolvedFeatureDir/);
789
+ assert.match(plugin, /isAbsolute/);
790
+ assert.doesNotMatch(plugin, /if \(!abs\.startsWith\(featureDir\)\)/);
791
+ // provenance must require both endpoints (Base+Head or base_sha+head_sha) matching Claude/Codex policy
792
+ assert.match(plugin, /hasBase\s*&&\s*hasHead/);
793
+ assert.match(plugin, /hasBaseSha\s*&&\s*hasHeadSha/);
794
+ assert.match(plugin, /hasBase.*hasHead.*hasBaseSha.*hasHeadSha/s);
795
+ // hardened: non-empty same-line values required, not just field names
796
+ assert.match(plugin, /Base\\s\*:\[ \\t\]\*\\S/);
797
+ assert.match(plugin, /base_sha\\s\*:\[ \\t\]\*\\S/);
798
+ assert.match(plugin, /head_sha\\s\*:\[ \\t\]\*\\S/);
799
+ // shipped docs must not claim OpenCode parity for hard completion gate
800
+ const installerDocs = fs.readFileSync(path.join(__dirname, '../../../../docs/installer-architecture.md'), 'utf8');
801
+ assert.doesNotMatch(installerDocs, /OpenCode.*hard.*completion gate/i);
802
+ // combined install must copy the plugin
803
+ inTempProject((root) => {
804
+ const result = spawnSync(process.execPath, [INSTALLER, '--platform', 'opencode', '--yes'], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
805
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
806
+ assert.equal(fs.existsSync(path.join(root, '.opencode/plugins/spec-gate.ts')), true);
807
+ const installed = fs.readFileSync(path.join(root, '.opencode/plugins/spec-gate.ts'), 'utf8');
808
+ assert.match(installed, /advisory/i);
809
+ });
810
+ });