@ikon85/agent-workflow-kit 0.29.0 → 0.30.0

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.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { firstLineState } from '../src/lib/sentinel.mjs';
6
+ import { CONSUMER_MANIFEST_NAME, readManifest, writeManifest } from '../src/lib/manifest.mjs';
7
+
8
+ const SOURCE_MANIFEST = '.claude/skills/skill-manifest.json';
9
+ const DECISIONS = new Set(['pending', 'not-applicable']);
10
+
11
+ async function readText(root, path) {
12
+ try { return await readFile(join(root, path), 'utf8'); }
13
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
14
+ }
15
+
16
+ function sentinelVerdict(text, allowLegacy) {
17
+ if (text === null) return 'absent';
18
+ const state = firstLineState(text);
19
+ const content = text.split('\n').slice(1).join('\n').trim();
20
+ if (state === 'filled') return content ? 'valid' : 'invalid';
21
+ if (state === null && allowLegacy && text.trim()) return 'valid';
22
+ return 'invalid';
23
+ }
24
+
25
+ function nonemptyVerdict(text) {
26
+ return text === null ? 'absent' : (text.trim() ? 'valid' : 'invalid');
27
+ }
28
+
29
+ function jsonValue(text, required) {
30
+ if (text === null) return 'absent';
31
+ try {
32
+ let value = JSON.parse(text);
33
+ for (const key of required ?? []) value = value?.[key];
34
+ return value === undefined || value === null ? 'invalid' : value;
35
+ } catch { return 'invalid'; }
36
+ }
37
+
38
+ function jsonVerdict(text, evidence) {
39
+ if (evidence.validator === 'project-release') {
40
+ if (text === null) return 'absent';
41
+ let profile;
42
+ try { profile = JSON.parse(text); } catch { return 'invalid'; }
43
+ const value = profile.schemaVersion === 1 ? profile.projectRelease : null;
44
+ const files = value?.versionFiles;
45
+ const tag = value?.tagPrefix;
46
+ return Array.isArray(files) && files.length > 0
47
+ && files.every((path) => typeof path === 'string' && path.trim())
48
+ && (tag === undefined || typeof tag === 'string') ? 'valid' : 'invalid';
49
+ }
50
+ const value = jsonValue(text, evidence.required);
51
+ if (['absent', 'invalid'].includes(value)) return value;
52
+ if (!(evidence.required?.length) && typeof value === 'object'
53
+ && !Array.isArray(value) && Object.keys(value).length === 0) return 'invalid';
54
+ return 'valid';
55
+ }
56
+
57
+ function fencedJsonAfter(text, marker) {
58
+ const start = text.indexOf(marker);
59
+ if (start < 0) return null;
60
+ const match = /```json\s*([\s\S]*?)```/.exec(text.slice(start + marker.length));
61
+ if (!match) return 'invalid';
62
+ try { return JSON.parse(match[1]); } catch { return 'invalid'; }
63
+ }
64
+
65
+ function boardVerdict(text) {
66
+ if (text === null) return 'absent';
67
+ const profile = fencedJsonAfter(text, '<!-- board-sync:profile -->');
68
+ if (!profile || profile === 'invalid') return 'invalid';
69
+ const status = profile.fields?.status;
70
+ const required = [profile.repo, profile.project?.owner, profile.project?.number,
71
+ profile.project?.nodeId, status?.id, status?.options, status?.roles,
72
+ profile.fields?.wave, profile.fields?.cluster, profile.labels];
73
+ return required.every(Boolean) ? 'valid' : 'invalid';
74
+ }
75
+
76
+ async function runbookVerdict(root, evidence) {
77
+ const layer = await readText(root, evidence.paths[0]);
78
+ const layerState = sentinelVerdict(layer, evidence.allowLegacy);
79
+ if (layerState !== 'valid') return layerState;
80
+ const paths = [...layer.matchAll(/`([^`\n]+\.md)`/g)].map((match) => match[1]);
81
+ for (const path of paths) {
82
+ if (path.includes('template')) continue;
83
+ const runbook = await readText(root, path);
84
+ if (runbook?.trim() && !runbook.includes('<placeholder>')) return 'valid';
85
+ }
86
+ return 'invalid';
87
+ }
88
+
89
+ function section(text, heading) {
90
+ if (text === null) return null;
91
+ const lines = text.split('\n');
92
+ const start = lines.findIndex((line) => line.trim() === heading);
93
+ if (start < 0) return null;
94
+ const body = [];
95
+ for (const line of lines.slice(start + 1)) {
96
+ if (/^##\s+/.test(line)) break;
97
+ body.push(line);
98
+ }
99
+ return body.join('\n').trim();
100
+ }
101
+
102
+ async function prodVerdict(root, paths) {
103
+ const bodies = [];
104
+ for (const path of paths) {
105
+ const text = await readText(root, path);
106
+ if (text === null) continue;
107
+ const body = section(text, '## Prod');
108
+ bodies.push(body);
109
+ }
110
+ if (!bodies.length) return 'absent';
111
+ if (bodies.every((body) => body === null)) return 'absent';
112
+ if (bodies.some((body) => !body)) return 'invalid';
113
+ return bodies.every((body) => body === bodies[0]) ? 'valid' : 'invalid';
114
+ }
115
+
116
+ async function evidenceVerdict(root, evidence) {
117
+ if (evidence.type === 'prod-section') return prodVerdict(root, evidence.paths);
118
+ if (evidence.type === 'runbook-reference') return runbookVerdict(root, evidence);
119
+ const verdicts = await Promise.all((evidence.paths ?? []).map(async (path) => {
120
+ const text = await readText(root, path);
121
+ if (evidence.type === 'sentinel') return sentinelVerdict(text, evidence.allowLegacy);
122
+ if (evidence.type === 'json') return jsonVerdict(text, evidence);
123
+ if (evidence.type === 'board-profile') return boardVerdict(text);
124
+ return nonemptyVerdict(text);
125
+ }));
126
+ if (verdicts.some((value) => value === 'invalid')) return 'invalid';
127
+ if (verdicts.some((value) => value === 'valid')) return 'valid';
128
+ return 'absent';
129
+ }
130
+
131
+ export async function evaluateCapability({ root, capability, decision }) {
132
+ const evidence = await evidenceVerdict(root, capability.evidence);
133
+ if (evidence === 'invalid') return { state: 'invalid', clearDecision: false };
134
+ if (evidence === 'valid') return { state: 'ready', clearDecision: Boolean(decision) };
135
+ if (decision === 'pending') return { state: 'pending', clearDecision: false };
136
+ if (decision === 'not-applicable' && capability.allowNotApplicable) {
137
+ return { state: 'not-applicable', clearDecision: false };
138
+ }
139
+ return { state: 'missing', clearDecision: false };
140
+ }
141
+
142
+ async function loadManifest(root) {
143
+ const body = await readText(root, SOURCE_MANIFEST);
144
+ if (body === null) throw new Error(`readiness manifest not found: ${SOURCE_MANIFEST}`);
145
+ return JSON.parse(body);
146
+ }
147
+
148
+ export async function checkSkill({ root, skill, manifest }) {
149
+ manifest ??= await loadManifest(root);
150
+ const declaration = manifest.skills?.[skill]?.readiness;
151
+ if (!declaration) throw new Error(`skill has no readiness declaration: ${skill}`);
152
+ const consumer = await readManifest(join(root, CONSUMER_MANIFEST_NAME));
153
+ const decisions = consumer?.readinessDecisions ?? {};
154
+ const names = new Set([
155
+ ...(declaration.required ?? []), ...Object.values(declaration.optionalBlocks ?? {}),
156
+ ]);
157
+ const capabilities = {};
158
+ for (const name of names) {
159
+ const catalogEntry = manifest.readiness?.capabilities?.[name];
160
+ if (!catalogEntry) throw new Error(`unknown readiness capability: ${name}`);
161
+ capabilities[name] = await evaluateCapability({
162
+ root, capability: catalogEntry, decision: decisions[name],
163
+ });
164
+ }
165
+ const requiredBlocked = (declaration.required ?? []).some(
166
+ (name) => capabilities[name].state !== 'ready',
167
+ );
168
+ const activeBlocks = [];
169
+ const inactiveBlocks = [];
170
+ for (const [block, name] of Object.entries(declaration.optionalBlocks ?? {})) {
171
+ (capabilities[name].state === 'ready' ? activeBlocks : inactiveBlocks).push(block);
172
+ }
173
+ const invalid = Object.values(capabilities).some(({ state }) => state === 'invalid');
174
+ const verdict = requiredBlocked ? 'blocked' : (inactiveBlocks.length || invalid ? 'degraded' : 'ready');
175
+ return { contractVersion: manifest.readiness.contractVersion, verdict, capabilities, activeBlocks, inactiveBlocks };
176
+ }
177
+
178
+ async function changeDecision(root, capability, value) {
179
+ const path = join(root, CONSUMER_MANIFEST_NAME);
180
+ const manifest = await readManifest(path);
181
+ if (!manifest) throw new Error('not initialised — run `init` first');
182
+ const readiness = (await loadManifest(root)).readiness;
183
+ const catalog = readiness.capabilities;
184
+ if (!catalog[capability]) throw new Error(`unknown readiness capability: ${capability}`);
185
+ if (value && !DECISIONS.has(value)) throw new Error(`invalid readiness decision: ${value}`);
186
+ if (value === 'not-applicable' && !catalog[capability].allowNotApplicable) {
187
+ throw new Error(`${capability} does not allow not-applicable`);
188
+ }
189
+ const decisions = { ...(manifest.readinessDecisions ?? {}) };
190
+ if (value) decisions[capability] = value; else delete decisions[capability];
191
+ await writeManifest(path, {
192
+ ...manifest, readinessContractVersion: readiness.contractVersion, readinessDecisions: decisions,
193
+ });
194
+ }
195
+
196
+ function option(args, name, fallback) {
197
+ const index = args.indexOf(name);
198
+ return index < 0 ? fallback : args[index + 1];
199
+ }
200
+
201
+ async function main(args = process.argv.slice(2)) {
202
+ const root = resolve(option(args, '--root', process.cwd()));
203
+ if (args[0] === 'check') {
204
+ const result = await checkSkill({ root, skill: option(args, '--skill') });
205
+ console.log(JSON.stringify(result, null, args.includes('--json') ? 2 : 0));
206
+ return;
207
+ }
208
+ if (args[0] === 'decision' && args[1] === 'set') return changeDecision(root, args[2], args[3]);
209
+ if (args[0] === 'decision' && args[1] === 'clear') return changeDecision(root, args[2], null);
210
+ throw new Error('usage: readiness check --skill <name> [--json] [--root <path>] | decision <set|clear> <capability> [value]');
211
+ }
212
+
213
+ if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
214
+ main().catch((error) => { console.error(`readiness: ${error.message}`); process.exitCode = 1; });
215
+ }
@@ -1,7 +1,10 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFile } from 'node:fs/promises';
3
+ import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
4
6
  import { dirname, join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
5
8
  import { fileURLToPath } from 'node:url';
6
9
  import {
7
10
  createCommandAdapter, githubReleaseArgs, inspectRelease, isMissingRelease,
@@ -50,6 +53,55 @@ test('post-merge status inspection is read-only and reports the reconstructable
50
53
  assert.deepEqual(fixture.events, ['read npm', 'read github']);
51
54
  });
52
55
 
56
+ test('local Claude overrides cannot change the packed release identity or enter the tarball', async () => {
57
+ const fixture = await mkdtemp(join(tmpdir(), 'awkit-local-overrides-'));
58
+ await mkdir(join(fixture, '.claude'), { recursive: true });
59
+ await Promise.all([
60
+ ['package.json', 'package.json'],
61
+ ['agent-workflow-kit.package.json', 'agent-workflow-kit.package.json'],
62
+ ['.claude/.npmignore', '.claude/.npmignore'],
63
+ ].map(([source, destination]) => copyFile(join(REPO, source), join(fixture, destination))));
64
+ await writeFile(join(fixture, '.claude/shipped.json'), '{"shipped":true}\n');
65
+
66
+ const overrides = [
67
+ ['.claude/settings.local.json', randomUUID()],
68
+ ['.claude/settings.local.json.backup', randomUUID()],
69
+ ];
70
+
71
+ const clean = await createCommandAdapter({ repoRoot: fixture });
72
+ const local = await createCommandAdapter({ repoRoot: fixture });
73
+ try {
74
+ const { identity: publishedIdentity } = await clean.local();
75
+ for (const [path, canary] of overrides) {
76
+ await writeFile(join(fixture, path), `${canary}\n`);
77
+ }
78
+
79
+ const { identity, tarball } = await local.local();
80
+ assert.deepEqual(identity, publishedIdentity);
81
+ assert.deepEqual(await inspectRelease({
82
+ local: async () => ({ identity, tarball }),
83
+ npm: async () => publishedIdentity,
84
+ github: async () => publishedIdentity,
85
+ }), { status: 'released', identity: publishedIdentity });
86
+
87
+ const paths = new Set(
88
+ execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' })
89
+ .trim().split('\n').map((path) => path.replace(/^package\//, '')),
90
+ );
91
+ assert.ok(paths.has('.claude/shipped.json'));
92
+ const contents = execFileSync('tar', ['-xOzf', tarball], {
93
+ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
94
+ });
95
+ for (const [path, canary] of overrides) {
96
+ assert.ok(!paths.has(path), `packed local override: ${path}`);
97
+ assert.doesNotMatch(contents, new RegExp(canary));
98
+ }
99
+ } finally {
100
+ await Promise.all([clean.dispose(), local.dispose()]);
101
+ await rm(fixture, { recursive: true, force: true });
102
+ }
103
+ });
104
+
53
105
  test('an unpublished npm version is reconstructable when npm reports ETARGET', () => {
54
106
  assert.equal(isMissingRelease({
55
107
  stderr: 'npm error code ETARGET\nnpm error notarget No matching version found',
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Regression contract for the Codex-only adapter audit skill."""
3
+
4
+ import importlib.util
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+
9
+ REPO_ROOT = Path(__file__).resolve().parent.parent
10
+ ADAPTER = REPO_ROOT / ".agents/skills/codex-adapter-sync/SKILL.md"
11
+ _FRONTMATTER_SPEC = importlib.util.spec_from_file_location(
12
+ "skill_frontmatter_lint", REPO_ROOT / "scripts/test_skill_frontmatter_lint.py")
13
+ frontmatter = importlib.util.module_from_spec(_FRONTMATTER_SPEC)
14
+ _FRONTMATTER_SPEC.loader.exec_module(frontmatter)
15
+
16
+
17
+ def section(body: str, heading: str) -> str:
18
+ """Return one second-level Markdown section, excluding the next one."""
19
+ marker = f"## {heading}\n"
20
+ start = body.index(marker) + len(marker)
21
+ end = body.find("\n## ", start)
22
+ return body[start:] if end == -1 else body[start:end]
23
+
24
+
25
+ class AdapterModesContract(unittest.TestCase):
26
+ @classmethod
27
+ def setUpClass(cls):
28
+ cls.body = ADAPTER.read_text(encoding="utf-8")
29
+
30
+ def test_audit_is_read_only_and_apply_is_worktree_gated(self):
31
+ audit = " ".join(section(self.body, "Audit mode (default)").split())
32
+ apply = " ".join(section(self.body, "Apply mode").split())
33
+
34
+ self.assertIn("current checkout", audit)
35
+ self.assertIn("read-only", audit)
36
+ self.assertIn("Do not create or switch branches or worktrees", audit)
37
+ self.assertIn("before the first edit", apply)
38
+ self.assertIn("Never apply adapter changes on `main`", apply)
39
+ self.assertNotIn("before inventory or edits", self.body)
40
+
41
+
42
+ class AdapterModelContract(unittest.TestCase):
43
+ @classmethod
44
+ def setUpClass(cls):
45
+ cls.body = ADAPTER.read_text(encoding="utf-8")
46
+
47
+ def test_current_models_inherit_by_default_and_route_by_task_shape(self):
48
+ routing = " ".join(section(self.body, "Model routing").split())
49
+
50
+ self.assertIn("inherited parent model configuration", routing)
51
+ self.assertIn("`gpt-5.6-sol`", routing)
52
+ self.assertIn("complex, open-ended judgment work", routing)
53
+ self.assertIn("`gpt-5.6-terra`", routing)
54
+ self.assertIn("everyday tool-using development", routing)
55
+ self.assertIn("`gpt-5.6-luna`", routing)
56
+ self.assertIn("clear, repeatable, high-volume work", routing)
57
+ self.assertIn("`model_reasoning_effort`", routing)
58
+ self.assertIn("`default`, `worker`, and `explorer`", routing)
59
+ self.assertIn("not a fourth concrete variant", routing)
60
+
61
+ for stale in ("gpt-5.4-mini", "gpt-5.5"):
62
+ self.assertNotIn(stale, self.body)
63
+ self.assertNotRegex(self.body, r"(?<!model_)\breasoning_effort\b")
64
+ self.assertNotRegex(self.body, r"\bagent_type\b")
65
+
66
+
67
+ class AdapterInventoryContract(unittest.TestCase):
68
+ @classmethod
69
+ def setUpClass(cls):
70
+ cls.body = ADAPTER.read_text(encoding="utf-8")
71
+
72
+ def test_nested_instructions_configs_and_hooks_are_inventoried(self):
73
+ inventory = section(self.body, "Inventory")
74
+
75
+ for surface in (
76
+ "`**/CLAUDE.md`",
77
+ "`**/AGENTS.md`",
78
+ "`**/AGENTS.override.md`",
79
+ "`**/.codex/config.toml`",
80
+ "`.claude/settings*.json`",
81
+ "`.claude/hooks/**`",
82
+ "`.codex/hooks.json`",
83
+ "inline `[hooks]`",
84
+ ):
85
+ self.assertIn(surface, inventory)
86
+ self.assertIn("trusted project layers", inventory)
87
+ self.assertIn("Codex-adapted", inventory)
88
+ self.assertIn("intentionally Claude-only", inventory)
89
+ self.assertNotIn("`frontend/CLAUDE.md`", self.body)
90
+ self.assertNotIn("`backend/CLAUDE.md`", self.body)
91
+
92
+
93
+ class AdapterAgentContract(unittest.TestCase):
94
+ @classmethod
95
+ def setUpClass(cls):
96
+ cls.body = ADAPTER.read_text(encoding="utf-8")
97
+
98
+ def test_custom_agent_toml_uses_the_current_schema(self):
99
+ agents = " ".join(section(self.body, "Custom-agent validation").split())
100
+
101
+ self.assertIn(
102
+ "required `name`, `description`, and `developer_instructions`", agents)
103
+ self.assertIn("optional `model` and `model_reasoning_effort`", agents)
104
+ self.assertIn("Parse every `.codex/agents/*.toml`", agents)
105
+ self.assertIn("Reject a file", agents)
106
+
107
+
108
+ class AdapterValidationContract(unittest.TestCase):
109
+ @classmethod
110
+ def setUpClass(cls):
111
+ cls.body = ADAPTER.read_text(encoding="utf-8")
112
+
113
+ def test_validation_covers_every_current_adapter_surface(self):
114
+ validation = " ".join(section(self.body, "Validation").split())
115
+
116
+ for proof in (
117
+ "`codex --strict-config --version`",
118
+ "skill-frontmatter guard",
119
+ "skill metadata and loading",
120
+ "custom-agent TOML",
121
+ "`git check-ignore",
122
+ "references, assets, scripts",
123
+ "mirror-parity guard",
124
+ "X of Y",
125
+ ):
126
+ self.assertIn(proof, validation)
127
+ for maintainer_only_path in (
128
+ "scripts/test_skill_frontmatter_lint.py",
129
+ "scripts/test_skill_portability_lint.py",
130
+ ):
131
+ self.assertNotIn(maintainer_only_path, self.body)
132
+
133
+ def test_description_cap_is_an_enforced_repository_safeguard(self):
134
+ validation = " ".join(section(self.body, "Validation").split())
135
+ self.assertIn("1024-character description cap", validation)
136
+ self.assertIn("repository safeguard", validation)
137
+ self.assertIn("not a Codex product limit", validation)
138
+
139
+ problems = []
140
+ for skill in (REPO_ROOT / ".agents/skills").glob("*/SKILL.md"):
141
+ data = frontmatter.parse_frontmatter(
142
+ frontmatter.extract_frontmatter(skill.read_text(encoding="utf-8")))
143
+ description = data.get("description")
144
+ if isinstance(description, str) and len(description) > 1024:
145
+ problems.append(f"{skill.relative_to(REPO_ROOT)}: {len(description)}")
146
+ self.assertEqual(
147
+ problems,
148
+ [],
149
+ "skill descriptions over 1024 characters:\n" + "\n".join(problems),
150
+ )
151
+
152
+
153
+ if __name__ == "__main__":
154
+ unittest.main(verbosity=2)
@@ -23,8 +23,8 @@ A line carrying `portability-lint: ok` is exempt — line-scoped, for deliberate
23
23
  doc counterexamples.
24
24
 
25
25
  This file also asserts mirror parity:
26
- - every dual-surface skill has the same set of distributed `*.md` files in both
27
- trees (catches a forgotten mirror file);
26
+ - every dual-surface skill has the same set of distributed files in both trees
27
+ (catches a forgotten mirror support file of any type);
28
28
  - generic/vendored dual-surface skill bodies match after stripping paired
29
29
  `mirror-xform` regions. The paired marker convention lets codex-adapter-sync
30
30
  keep legitimate Codex body translations local instead of maintaining a central
@@ -220,12 +220,36 @@ def enforced_skills() -> set[str]:
220
220
  if e["class"] in ENFORCED_CLASSES}
221
221
 
222
222
 
223
- def skill_md_set(tree: str, name: str) -> set[str]:
224
- """Relative posix paths of every distributed *.md under one tree's skill dir."""
225
- d = REPO_ROOT / tree / name
226
- if not d.is_dir():
223
+ def distributed_file_set(skill_dir: Path) -> set[str]:
224
+ """Relative paths of every distributed file under one skill directory."""
225
+ if not skill_dir.is_dir():
227
226
  return set()
228
- return {p.relative_to(d).as_posix() for p in d.rglob("*.md")}
227
+ return {
228
+ p.relative_to(skill_dir).as_posix()
229
+ for p in skill_dir.rglob("*")
230
+ if p.is_file()
231
+ }
232
+
233
+
234
+ def skill_file_set(tree: str, name: str) -> set[str]:
235
+ return distributed_file_set(REPO_ROOT / tree / name)
236
+
237
+
238
+ def skill_md_set(tree: str, name: str) -> set[str]:
239
+ """Markdown subset used by the body and structure parity checks."""
240
+ return {p for p in skill_file_set(tree, name) if p.endswith(".md")}
241
+
242
+
243
+ def mirror_presence_drift(name: str, claude: set[str], codex: set[str]) -> list[str]:
244
+ problems = [
245
+ f"{name}: .claude/skills/{name}/{rel} has no codex mirror"
246
+ for rel in sorted(claude - codex)
247
+ ]
248
+ problems.extend(
249
+ f"{name}: .agents/skills/{name}/{rel} has no claude source"
250
+ for rel in sorted(codex - claude)
251
+ )
252
+ return problems
229
253
 
230
254
 
231
255
  def dual_surface_skills() -> list[str]:
@@ -486,21 +510,16 @@ class ProfileValueLiteralNegativeFixture(unittest.TestCase):
486
510
 
487
511
  class MirrorPresenceParity(unittest.TestCase):
488
512
  """LEAN mirror parity: a dual-surface skill has the SAME set of distributed
489
- *.md files in both trees. Presence/file-set only not content (codex-adapter-sync
490
- legitimately translates body model-dispatch; full content parity is a follow-up).
513
+ files in both trees. Presence/file-set only; Markdown content parity is handled
514
+ separately because codex-adapter-sync legitimately translates body model-dispatch.
491
515
  Catches a forgotten / orphaned mirror file (Codex R1#11 first half)."""
492
516
 
493
- def test_dual_surface_md_filesets_match(self):
517
+ def test_dual_surface_distributed_filesets_match(self):
494
518
  problems = []
495
519
  for name in sorted(dual_surface_skills()):
496
- claude = skill_md_set(".claude/skills", name)
497
- codex = skill_md_set(".agents/skills", name)
498
- only_claude = claude - codex
499
- only_codex = codex - claude
500
- for f in sorted(only_claude):
501
- problems.append(f"{name}: .claude/skills/{name}/{f} has no codex mirror")
502
- for f in sorted(only_codex):
503
- problems.append(f"{name}: .agents/skills/{name}/{f} has no claude source")
520
+ claude = skill_file_set(".claude/skills", name)
521
+ codex = skill_file_set(".agents/skills", name)
522
+ problems.extend(mirror_presence_drift(name, claude, codex))
504
523
  self.assertEqual(
505
524
  problems, [],
506
525
  "mirror file-set drift — run codex-adapter-sync to add/remove the mirror "
@@ -540,6 +559,26 @@ class MirrorContentParity(unittest.TestCase):
540
559
  class MirrorParityFixture(unittest.TestCase):
541
560
  """The file-set comparison catches an orphaned mirror file (regression guard)."""
542
561
 
562
+ def test_non_markdown_support_file_is_detected(self):
563
+ import tempfile
564
+ with tempfile.TemporaryDirectory() as d:
565
+ claude = Path(d) / "claude"
566
+ codex = Path(d) / "codex"
567
+ (claude / "assets").mkdir(parents=True)
568
+ codex.mkdir()
569
+ (claude / "SKILL.md").write_text("# Example\n", encoding="utf-8")
570
+ (codex / "SKILL.md").write_text("# Example\n", encoding="utf-8")
571
+ (claude / "assets/example.yml").write_text("enabled: true\n", encoding="utf-8")
572
+
573
+ self.assertEqual(
574
+ mirror_presence_drift(
575
+ "example",
576
+ distributed_file_set(claude),
577
+ distributed_file_set(codex),
578
+ ),
579
+ ["example: .claude/skills/example/assets/example.yml has no codex mirror"],
580
+ )
581
+
543
582
  def test_extra_mirror_file_is_detected(self):
544
583
  claude = {"SKILL.md", "tests.md"}
545
584
  codex = {"SKILL.md"}