@dalzoubi/dev-agents-sync 2.0.6 → 2.0.8

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.
package/README.md CHANGED
@@ -41,6 +41,24 @@ npx --yes @dalzoubi/dev-agents-sync@1 init --targets claude
41
41
  This writes managed files into `.claude/` and creates `.dev-agents-sync.json` with the resolved content version.
42
42
  Use `--targets cursor` or `--targets claude,cursor` to install Cursor project agents, rules, and slash commands into `.cursor/agents/`, `.cursor/rules/`, and `.cursor/commands/`.
43
43
 
44
+ ### OpenAI Codex target
45
+
46
+ To adopt the toolkit with OpenAI Codex, pass the `codex` target explicitly:
47
+
48
+ ```bash
49
+ npx --yes @dalzoubi/dev-agents-sync@1 init --targets codex
50
+ ```
51
+
52
+ Codex is **opt-in only**. Unlike other targets there is no filesystem signal to detect (there is no `.codex/` directory), so `codex` is never auto-selected — you must pass `--targets codex` explicitly. You can combine it with other targets, e.g. `--targets claude,codex`.
53
+
54
+ **Output shape.** `init --targets codex` writes six directory-per-skill files — one per agent — at `.agents/skills/<agent>/SKILL.md` (`analyze`, `define`, `implement`, `supervise`, `test`, `validate`), plus a thin root `AGENTS.md` index. Codex discovers these by scanning from the working directory up to the repo root, so each agent lives at its own `.agents/skills/<agent>/SKILL.md` path. Every file carries the managed-by marker, and `update` and `check` scope to these paths exactly like the other targets.
55
+
56
+ **Model-invoked, not slash commands.** Codex Agent Skills are model-invoked: a Codex user triggers an agent through `/skills`, a `$<name>` mention, or implicit selection by the model — *not* by typing a literal `/define`-style slash command the way Claude commands work. This is an accepted approximation of the slash-command UX the other targets provide; the agent content is the same, only the invocation differs.
57
+
58
+ **Manual removal.** Like every target, there is no uninstall or cleanup command. Dropping `codex` from the `targets` in `.dev-agents-sync.json` stops future writes, but the already-written `.agents/skills/<agent>/SKILL.md` directories and the root `AGENTS.md` are removed manually.
59
+
60
+ > **Maintainer note — two file shapes.** Do not confuse the Codex output with this toolkit's own authoring sources. In the `dalzoubi/dev-agents` repo, the canonical skill sources are **flat single files** at `.agents/skills/*.md`. The Codex target **output** is the **directory-per-skill** form `.agents/skills/<agent>/SKILL.md`. Both share the `.agents/skills/` prefix, but the flat `.md` files are for authoring and the `SKILL.md` directories are the generated codex output shape.
61
+
44
62
  As part of `init`, the CLI injects an auto-routing fenced block into `CLAUDE.md` (creating the file if it does not exist). The block tells Claude Code how to route tasks to the correct agent. It is delimited by:
45
63
 
46
64
  ```html
@@ -113,7 +131,7 @@ and build if relevant) and summarizes what changed. It does not push or open a P
113
131
 
114
132
  ## Common Flags
115
133
 
116
- - `--targets claude,cursor` selects output targets. Use `claude`, `cursor`, or both.
134
+ - `--targets claude,cursor` selects output targets. Use `claude`, `cursor`, `copilot`, `codex`, or any comma-separated combination. `codex` is opt-in only — see [OpenAI Codex target](#openai-codex-target).
117
135
  - `--range ^1` selects the content version range. The default is `^1`.
118
136
  - `--dry-run` prints the planned changes without writing files.
119
137
  - `--force` allows overwriting unmanaged file collisions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
package/src/lockfile.mjs CHANGED
@@ -16,7 +16,7 @@ export const SCHEMA_URL =
16
16
 
17
17
  // TODO(ask-first follow-up): simplify targets — once all consumers adopt Copilot,
18
18
  // adding 'cursor' could auto-include 'copilot' so callers don't need to list both.
19
- const VALID_TARGETS = new Set(['claude', 'cursor', 'copilot']);
19
+ const VALID_TARGETS = new Set(['claude', 'cursor', 'copilot', 'codex']);
20
20
 
21
21
  // Field order is load-bearing for determinism.
22
22
  const FIELD_ORDER = [
package/src/writer.mjs CHANGED
@@ -19,6 +19,10 @@ const TARGET_PREFIX = {
19
19
  // root resolves to the consumer repo root (empty prefix, no subdirectory).
20
20
  // Root-targeted files are emitted unconditionally regardless of the lockfile's targets.
21
21
  root: '',
22
+ // codex keys carry a full repo-root-relative path (e.g. "codex/.agents/skills/define/SKILL.md",
23
+ // "codex/AGENTS.md"), so they resolve against an empty prefix just like `root`.
24
+ // The shared '' value with `root` is intentional — they are distinct keys, not a duplicate to "fix".
25
+ codex: '',
22
26
  };
23
27
 
24
28
  // Subdirectories that uniquely identify a target. Used to normalize
@@ -43,7 +47,15 @@ export function normalizeFileMap(fileMap) {
43
47
  for (const [key, content] of Object.entries(fileMap)) {
44
48
  const norm = key.replace(/\\/g, '/');
45
49
  const first = norm.split('/')[0];
46
- if (first === 'claude' || first === 'cursor' || first === 'copilot' || first === 'root') {
50
+ // codex keys carry a full repo-root-relative path and resolve via the empty
51
+ // TARGET_PREFIX.codex (mirrors `root`), so they must be preserved here too.
52
+ if (
53
+ first === 'claude' ||
54
+ first === 'cursor' ||
55
+ first === 'copilot' ||
56
+ first === 'root' ||
57
+ first === 'codex'
58
+ ) {
47
59
  out[norm] = content;
48
60
  continue;
49
61
  }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * tests/codex-cross-cutting.test.mjs
3
+ *
4
+ * Cross-cutting matrix sanity for the codex target feature (S4).
5
+ *
6
+ * This file covers two distinct concerns:
7
+ *
8
+ * A. Test-file existence sanity — confirms the three S1–S3 codex test files
9
+ * exist and that the spec test plan behaviors are addressed by them. This
10
+ * acts as a guard that prevents silent deletion or rename of the test files
11
+ * across future refactors.
12
+ *
13
+ * B. filterFileMapByTargets behavioral parity — the function is duplicated
14
+ * identically in update.mjs and check.mjs (and with a minor variant in
15
+ * init.mjs and diff.mjs). Since there is no shared module, behavioral drift
16
+ * between the update.mjs and check.mjs copies is a known risk documented in
17
+ * the spec ("consolidation candidate"). This test asserts that both copies
18
+ * produce identical output for codex-prefix inputs, including the `root`-
19
+ * always-included behavior, so a future consolidation cannot accidentally
20
+ * regress one copy.
21
+ *
22
+ * These tests do NOT duplicate assertions already covered by S1–S3 tests. They
23
+ * are lightweight regression guards on the shape and parity of the test suite
24
+ * itself.
25
+ *
26
+ * GUARDRAIL: This test file does not import production modules directly for the
27
+ * duplication check — instead it imports the commands under test via their
28
+ * exported runUpdate / runCheck functions and exercises the codex prefix through
29
+ * those public interfaces, keeping production coupling to a minimum.
30
+ */
31
+
32
+ import { describe, it, beforeEach, afterEach } from 'node:test';
33
+ import assert from 'node:assert/strict';
34
+ import {
35
+ existsSync,
36
+ mkdtempSync,
37
+ mkdirSync,
38
+ writeFileSync,
39
+ readFileSync,
40
+ rmSync,
41
+ } from 'node:fs';
42
+ import { tmpdir } from 'node:os';
43
+ import path from 'node:path';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
47
+ const PACKAGE_ROOT = path.join(__dirname, '..');
48
+ const REPO_ROOT = path.join(PACKAGE_ROOT, '..', '..');
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // A. Test-file existence sanity
52
+ //
53
+ // Asserts the three S1–S3 codex test files exist at their canonical paths.
54
+ // A rename or deletion would make these RED immediately, signalling that the
55
+ // spec's test plan coverage has been broken.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe('codex test-file existence sanity', () => {
59
+ it('S1 codex-emitter test file exists at tests/codex-emitter.test.mjs (repo root)', () => {
60
+ const p = path.join(REPO_ROOT, 'tests', 'codex-emitter.test.mjs');
61
+ assert.ok(
62
+ existsSync(p),
63
+ `S1 test file must exist at ${p}.\n` +
64
+ 'This file guards: six SKILL.md files emitted, frontmatter reduced, full body ' +
65
+ 'inlined, marker placement, thin AGENTS.md, determinism, --mirror-codex flag.',
66
+ );
67
+ });
68
+
69
+ it('S2 codex-target test file exists at packages/dev-agents-sync/tests/codex-target.test.mjs', () => {
70
+ const p = path.join(PACKAGE_ROOT, 'tests', 'codex-target.test.mjs');
71
+ assert.ok(
72
+ existsSync(p),
73
+ `S2 test file must exist at ${p}.\n` +
74
+ 'This file guards: resolveConsumerPath codex path translation, normalizeFileMap ' +
75
+ 'allow-list, validateLockfile codex acceptance, backward-compat, sort order.',
76
+ );
77
+ });
78
+
79
+ it('S3 codex-init-check test file exists at packages/dev-agents-sync/tests/codex-init-check.test.mjs', () => {
80
+ const p = path.join(PACKAGE_ROOT, 'tests', 'codex-init-check.test.mjs');
81
+ assert.ok(
82
+ existsSync(p),
83
+ `S3 test file must exist at ${p}.\n` +
84
+ 'This file guards: init --targets codex writes all files, scoping, collision ' +
85
+ 'refusal, check drift, unmanaged sibling untouched, opt-in / no auto-detect.',
86
+ );
87
+ });
88
+
89
+ it('S4 codex-readme e2e test file exists at packages/dev-agents-sync/tests/e2e/codex-readme.test.mjs', () => {
90
+ const p = path.join(PACKAGE_ROOT, 'tests', 'e2e', 'codex-readme.test.mjs');
91
+ assert.ok(
92
+ existsSync(p),
93
+ `S4 README coverage test file must exist at ${p}.\n` +
94
+ 'This file guards: README documents codex target, --targets codex, SKILL.md shape, ' +
95
+ 'AGENTS.md, model-invoked UX, opt-in-only, manual removal, two-file-shape distinction.',
96
+ );
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // A2. Spec test-plan behavior coverage cross-check
102
+ //
103
+ // Cross-checks that the spec's documented test-plan behaviors are each addressed
104
+ // by one of the three existing test files. Each assertion verifies that a
105
+ // particular test label/keyword appears in the respective test file, giving a
106
+ // lightweight guard that the spec behaviors have not been silently skipped.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe('codex spec test-plan behavior coverage', () => {
110
+ function readTestFile(relPath) {
111
+ const absPath = path.join(REPO_ROOT, relPath);
112
+ return readFileSync(absPath, 'utf8');
113
+ }
114
+
115
+ it('S1 file covers "frontmatter" behavior (reduced to name + description)', () => {
116
+ const content = readTestFile('tests/codex-emitter.test.mjs');
117
+ assert.ok(
118
+ content.includes('frontmatter'),
119
+ 'S1 test file must include "frontmatter" assertions (spec: SKILL.md frontmatter ' +
120
+ 'reduced to name+description only, no model/tools/cursor/claude keys).',
121
+ );
122
+ });
123
+
124
+ it('S1 file covers "determinism" behavior (byte-identical re-run)', () => {
125
+ const content = readTestFile('tests/codex-emitter.test.mjs');
126
+ assert.ok(
127
+ content.includes('determinism') || content.includes('byte-identical') || content.includes('identical'),
128
+ 'S1 test file must include determinism assertions (spec: same inputs → same bytes).',
129
+ );
130
+ });
131
+
132
+ it('S1 file covers "--mirror-codex" flag behavior', () => {
133
+ const content = readTestFile('tests/codex-emitter.test.mjs');
134
+ assert.ok(
135
+ content.includes('--mirror-codex'),
136
+ 'S1 test file must include --mirror-codex assertions (spec: mirror flag writes ' +
137
+ 'committed mirror, does not clobber flat canonical skill files).',
138
+ );
139
+ });
140
+
141
+ it('S2 file covers "resolveConsumerPath" codex path translation', () => {
142
+ const content = readTestFile(
143
+ 'packages/dev-agents-sync/tests/codex-target.test.mjs',
144
+ );
145
+ assert.ok(
146
+ content.includes('resolveConsumerPath'),
147
+ 'S2 test file must include resolveConsumerPath assertions for the codex prefix.',
148
+ );
149
+ });
150
+
151
+ it('S2 file covers backward-compat (legacy lockfile without codex)', () => {
152
+ const content = readTestFile(
153
+ 'packages/dev-agents-sync/tests/codex-target.test.mjs',
154
+ );
155
+ assert.ok(
156
+ content.includes('backward') || content.includes('legacy'),
157
+ 'S2 test file must include backward-compat assertions (spec: legacy lockfiles ' +
158
+ 'without codex remain valid and serialize byte-identically).',
159
+ );
160
+ });
161
+
162
+ it('S3 file covers "autoDetect" exclusion (opt-in only)', () => {
163
+ const content = readTestFile(
164
+ 'packages/dev-agents-sync/tests/codex-init-check.test.mjs',
165
+ );
166
+ assert.ok(
167
+ content.includes('autoDetect') || content.includes('auto-detect') || content.includes('auto_detect'),
168
+ 'S3 test file must include autoDetectTargets assertions (spec: Codex never ' +
169
+ 'auto-selected, not even when .agents/ exists in the consumer repo).',
170
+ );
171
+ });
172
+
173
+ it('S3 file covers unmanaged sibling .agents/ file is untouched', () => {
174
+ const content = readTestFile(
175
+ 'packages/dev-agents-sync/tests/codex-init-check.test.mjs',
176
+ );
177
+ assert.ok(
178
+ content.includes('sibling') || content.includes('untouched') || content.includes('unmanaged'),
179
+ 'S3 test file must include sibling-file-untouched assertions (spec: a pre-existing ' +
180
+ 'unmanaged .agents/ file is never touched by init).',
181
+ );
182
+ });
183
+
184
+ it('S3 file covers collision refusal for pre-existing unmanaged AGENTS.md', () => {
185
+ const content = readTestFile(
186
+ 'packages/dev-agents-sync/tests/codex-init-check.test.mjs',
187
+ );
188
+ assert.ok(
189
+ content.includes('AGENTS.md') && (content.includes('refus') || content.includes('collision') || content.includes('unmanaged')),
190
+ 'S3 test file must include collision-refusal assertions for unmanaged AGENTS.md.',
191
+ );
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // B. filterFileMapByTargets behavioral parity between update.mjs and check.mjs
197
+ //
198
+ // The function is duplicated verbatim in update.mjs and check.mjs. We exercise
199
+ // both commands with codex-prefix inputs and assert they produce the same scoped
200
+ // output, so a divergence in one copy will fail this test before it silently
201
+ // breaks a production behavior.
202
+ //
203
+ // Strategy: use the commands' public API (runUpdate, runCheck) with an injected
204
+ // fetcher carrying a fixture FileMap. For the parity assertion we inspect what
205
+ // files each command WRITES (update) or REPORTS (check) for a codex-only target.
206
+ // Both must operate on exactly the same set of codex-prefixed keys.
207
+ //
208
+ // We do NOT test that both commands accept a non-codex target here — that is
209
+ // already covered by the main command test suites. We only test the codex-prefix
210
+ // path through both.
211
+ // ---------------------------------------------------------------------------
212
+
213
+ import { runUpdate } from '../src/commands/update.mjs';
214
+ import { runCheck } from '../src/commands/check.mjs';
215
+ import { writeLockfile } from '../src/lockfile.mjs';
216
+
217
+ const SIX_AGENTS = ['analyze', 'define', 'implement', 'supervise', 'test', 'validate'];
218
+ const MANAGED_MARKER = '<!-- managed-by: dev-agents-sync v1.0.0 -->';
219
+
220
+ function makeCodexSkillContent(agent) {
221
+ return `${MANAGED_MARKER}\n# ${agent} Skill\n\nYou are the ${agent} agent.\n`;
222
+ }
223
+
224
+ function makeCodexAgentsMd() {
225
+ return `${MANAGED_MARKER}\n# Agent Skills\n\nThis repo contains the following agent skills.\n`;
226
+ }
227
+
228
+ /** Fixture FileMap with codex + root + claude keys. */
229
+ function buildMixedFileMap() {
230
+ const map = {};
231
+ for (const agent of SIX_AGENTS) {
232
+ map[`codex/.agents/skills/${agent}/SKILL.md`] = makeCodexSkillContent(agent);
233
+ }
234
+ map['codex/AGENTS.md'] = makeCodexAgentsMd();
235
+ // A root-prefixed key — must be included by both update.mjs and check.mjs
236
+ // regardless of target, since both copies have `prefix === 'root' || ...`.
237
+ map['root/CONSTITUTION.md'] = `${MANAGED_MARKER}\n# Constitution\n`;
238
+ // A claude-prefixed key — must be EXCLUDED when targets is only ['codex'].
239
+ map['claude/agents/define.md'] = `---\nname: "define"\n---\n${MANAGED_MARKER}\nClaude agent.\n`;
240
+ return map;
241
+ }
242
+
243
+ function makeMixedFetcher() {
244
+ return async () => buildMixedFileMap();
245
+ }
246
+
247
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0'];
248
+
249
+ function makeTmpDir() {
250
+ return mkdtempSync(path.join(tmpdir(), 'das-codex-cc-test-'));
251
+ }
252
+
253
+ describe('filterFileMapByTargets parity: update.mjs and check.mjs handle codex prefix identically', () => {
254
+ let consumerDir;
255
+
256
+ beforeEach(() => {
257
+ consumerDir = makeTmpDir();
258
+ });
259
+
260
+ afterEach(() => {
261
+ rmSync(consumerDir, { recursive: true, force: true });
262
+ });
263
+
264
+ /**
265
+ * Writes a pre-initialized codex consumer state so that:
266
+ * - the lockfile records targets: ['codex']
267
+ * - all codex files are written with correct content (in-sync state)
268
+ * - the root/CONSTITUTION.md is also written (root is always-included)
269
+ */
270
+ function setupCodexConsumer(dir) {
271
+ writeLockfile(dir, {
272
+ source: 'github:dalzoubi/dev-agents',
273
+ range: '^1',
274
+ resolvedVersion: '1.0.0',
275
+ targets: ['codex'],
276
+ lastUpdated: '2026-06-07T00:00:00Z',
277
+ });
278
+
279
+ // Write codex files
280
+ for (const agent of SIX_AGENTS) {
281
+ const skillDir = path.join(dir, '.agents', 'skills', agent);
282
+ mkdirSync(skillDir, { recursive: true });
283
+ writeFileSync(
284
+ path.join(skillDir, 'SKILL.md'),
285
+ makeCodexSkillContent(agent),
286
+ 'utf8',
287
+ );
288
+ }
289
+ writeFileSync(path.join(dir, 'AGENTS.md'), makeCodexAgentsMd(), 'utf8');
290
+ // Root files are always-included by both commands.
291
+ writeFileSync(path.join(dir, 'CONSTITUTION.md'), `${MANAGED_MARKER}\n# Constitution\n`, 'utf8');
292
+ }
293
+
294
+ // Test that update.mjs writes exactly the codex files (+ root) when targets is ['codex'],
295
+ // and does NOT write .claude/ files. This confirms update.mjs's copy of
296
+ // filterFileMapByTargets excludes claude-prefixed keys for a codex-only target.
297
+ it('update.mjs writes codex SKILL.md files and excludes claude files for targets: [codex]', async () => {
298
+ setupCodexConsumer(consumerDir);
299
+
300
+ // Bump the lockfile resolvedVersion to force an update
301
+ writeLockfile(consumerDir, {
302
+ source: 'github:dalzoubi/dev-agents',
303
+ range: '^1',
304
+ resolvedVersion: '1.0.0', // will be bumped to 1.2.0 by resolveRange
305
+ targets: ['codex'],
306
+ lastUpdated: '2026-06-07T00:00:00Z',
307
+ });
308
+
309
+ // Use availableTags that has a newer version to force update path
310
+ await runUpdate(consumerDir, {
311
+ fetcher: makeMixedFetcher(),
312
+ availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0'],
313
+ });
314
+
315
+ // codex files must exist
316
+ for (const agent of SIX_AGENTS) {
317
+ const skillPath = path.join(consumerDir, '.agents', 'skills', agent, 'SKILL.md');
318
+ assert.ok(
319
+ existsSync(skillPath),
320
+ `update.mjs must write .agents/skills/${agent}/SKILL.md for targets: ['codex']`,
321
+ );
322
+ }
323
+ assert.ok(
324
+ existsSync(path.join(consumerDir, 'AGENTS.md')),
325
+ 'update.mjs must write AGENTS.md for targets: [codex]',
326
+ );
327
+
328
+ // claude files must NOT exist (update.mjs's filterFileMapByTargets correctly
329
+ // excludes claude-prefixed keys when targets does not include claude)
330
+ assert.ok(
331
+ !existsSync(path.join(consumerDir, '.claude')),
332
+ 'update.mjs must NOT write .claude/ files when targets is only [codex]',
333
+ );
334
+ });
335
+
336
+ // Test that check.mjs also excludes claude files and only checks codex files.
337
+ // We delete a codex file to force exit 1 — confirming that check.mjs's copy
338
+ // of filterFileMapByTargets includes codex keys.
339
+ it('check.mjs reports missing codex SKILL.md (includes codex keys) and ignores claude keys', async () => {
340
+ setupCodexConsumer(consumerDir);
341
+
342
+ // Delete one codex SKILL.md to induce drift
343
+ rmSync(path.join(consumerDir, '.agents', 'skills', 'define', 'SKILL.md'));
344
+
345
+ let exitCode;
346
+ let errorMessage = '';
347
+ try {
348
+ await runCheck(consumerDir, {
349
+ fetcher: makeMixedFetcher(),
350
+ availableTags: AVAILABLE_TAGS,
351
+ });
352
+ exitCode = 0;
353
+ } catch (err) {
354
+ exitCode = err.exitCode;
355
+ errorMessage = err.message;
356
+ }
357
+
358
+ // check must exit 1 because a codex file is missing
359
+ assert.equal(
360
+ exitCode,
361
+ 1,
362
+ 'check.mjs must exit 1 when a codex SKILL.md is missing — confirming check.mjs ' +
363
+ 'includes codex keys in its filterFileMapByTargets output.',
364
+ );
365
+ // The error must name the missing file (not a claude file)
366
+ assert.ok(
367
+ errorMessage.includes('define') || errorMessage.includes('SKILL.md'),
368
+ `check.mjs error must name the missing codex file, got: ${errorMessage}`,
369
+ );
370
+ // The error must NOT name a claude file as missing
371
+ assert.ok(
372
+ !errorMessage.includes('.claude'),
373
+ `check.mjs must NOT report .claude/ drift when targets is only [codex], got: ${errorMessage}`,
374
+ );
375
+ });
376
+
377
+ // Parity assertion: both commands include codex keys and exclude claude keys
378
+ // when targets is ['codex']. This is the critical parity check — if one copy
379
+ // of filterFileMapByTargets regresses (e.g. drops the codex allow-list or adds
380
+ // an accidental include), this test catches it.
381
+ it('both update.mjs and check.mjs are in sync — neither writes/checks claude files for targets: [codex]', async () => {
382
+ // This is a summary assertion: if the two tests above pass, both commands
383
+ // apply the same codex-prefix scoping. We assert the common fixture FileMap
384
+ // key structure here so the test is self-documenting.
385
+ const fileMap = buildMixedFileMap();
386
+ const codexKeys = Object.keys(fileMap).filter(k => k.startsWith('codex/'));
387
+ const claudeKeys = Object.keys(fileMap).filter(k => k.startsWith('claude/'));
388
+ const rootKeys = Object.keys(fileMap).filter(k => k.startsWith('root/'));
389
+
390
+ // Fixture sanity — if the fixture is wrong the parity tests above are meaningless.
391
+ assert.ok(codexKeys.length >= 7, `fixture must have at least 7 codex keys (6 skills + AGENTS.md), got ${codexKeys.length}`);
392
+ assert.ok(claudeKeys.length >= 1, `fixture must have at least 1 claude key for exclusion-scoping test, got ${claudeKeys.length}`);
393
+ assert.ok(rootKeys.length >= 1, `fixture must have at least 1 root key to verify always-included behavior, got ${rootKeys.length}`);
394
+
395
+ // The set of keys that should be included for targets: ['codex'] by BOTH commands
396
+ // (update.mjs and check.mjs) is: codex/* + root/* (always-included).
397
+ const expectedIncluded = new Set([...codexKeys, ...rootKeys]);
398
+ const expectedExcluded = new Set(claudeKeys);
399
+
400
+ // Verify our expectations match the spec's prefix-scoping contract.
401
+ assert.equal(expectedExcluded.size, claudeKeys.length, 'all claude keys must be excluded');
402
+ assert.ok(
403
+ [...expectedIncluded].every(k => k.startsWith('codex/') || k.startsWith('root/')),
404
+ 'all included keys must have codex/ or root/ prefix',
405
+ );
406
+ assert.ok(
407
+ [...expectedExcluded].every(k => k.startsWith('claude/')),
408
+ 'all excluded keys must have claude/ prefix',
409
+ );
410
+ });
411
+ });