@ikon85/agent-workflow-kit 0.29.1 → 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.
@@ -9,50 +9,128 @@ Use this skill when the user asks for a Codex adapter sync, Codex drift check,
9
9
  Claude-to-Codex migration check, or when changes to Claude-facing project
10
10
  knowledge may need to be reflected in Codex-facing files.
11
11
 
12
+ ## Audit mode (default)
13
+
14
+ Audit is a read-only diagnosis from the current checkout. Inventory and compare
15
+ the source and adapter surfaces, then report the exact proposed changes. Do not
16
+ create or switch branches or worktrees, edit files, or change external state.
17
+
18
+ ## Apply mode
19
+
20
+ Apply only when the user asked to update or fix the adapter. Create or reuse the
21
+ correct issue or slice worktree before the first edit. Never apply adapter
22
+ changes on `main`; move any accidental main-checkout diff into the worktree and
23
+ leave the main checkout clean.
24
+
25
+ ## Model routing
26
+
27
+ Default to inherited parent model configuration. Do not pin a model merely
28
+ because the source workflow delegates, and do not invent model or role fields
29
+ on a spawn call. The supported built-in agent names are `default`, `worker`, and
30
+ `explorer`; custom agents declare overrides in their TOML files.
31
+
32
+ When an explicit custom-agent model is genuinely justified, route by task
33
+ shape:
34
+
35
+ - `gpt-5.6-sol` for complex, open-ended judgment work.
36
+ - `gpt-5.6-terra` for everyday tool-using development.
37
+ - `gpt-5.6-luna` for clear, repeatable, high-volume work.
38
+
39
+ Use `model_reasoning_effort` for a supported reasoning override. The family
40
+ name `gpt-5.6` describes the family and is not a fourth concrete variant in
41
+ this routing table. Keep user gates, security judgment, and approval decisions
42
+ in the main thread.
43
+
12
44
  ## Scope
13
45
 
14
46
  Source side:
15
- - `CLAUDE.md`
16
- - `frontend/CLAUDE.md`
17
- - `backend/CLAUDE.md`
47
+ - every repository instruction file matched by `**/CLAUDE.md`
18
48
  - `~/.claude/CLAUDE.md` when the user explicitly mentions global Claude
19
49
  instruction drift or global Claude changes
20
50
  - `.claude/skills/`
21
51
  - `.claude/agents/`
52
+ - Claude hook declarations and implementations
22
53
  - `.gitignore`
23
54
 
24
55
  Codex adapter side:
25
- - `AGENTS.md`
56
+ - every repository instruction file matched by `**/AGENTS.md` or
57
+ `**/AGENTS.override.md`
26
58
  - `.agents/skills/`
27
- - `.codex/config.toml`
59
+ - every trusted project config layer matched by `**/.codex/config.toml`
28
60
  - `.codex/agents/`
61
+ - Codex hook declarations
29
62
  - `.gitignore`
30
63
 
64
+ ## Inventory
65
+
66
+ Derive a fresh, counted inventory from the repository root; do not assume only
67
+ known package names or root-level files. Include:
68
+
69
+ - arbitrary nested `**/CLAUDE.md`, `**/AGENTS.md`, and
70
+ `**/AGENTS.override.md` instruction layers;
71
+ - every `**/.codex/config.toml`, noting that Codex loads project config and
72
+ hooks only from trusted project layers;
73
+ - `.claude/skills/**`, `.agents/skills/**`, `.claude/agents/**`, and
74
+ `.codex/agents/**`;
75
+ - Claude hook definitions in `.claude/settings*.json` and implementations in
76
+ `.claude/hooks/**` as adaptation candidates;
77
+ - Codex targets in `.codex/hooks.json` and inline `[hooks]` tables in each
78
+ active config layer; and
79
+ - ignore rules and every skill reference, asset, script, template, and other
80
+ distributed support file, regardless of extension.
81
+
82
+ For every relevant Claude hook behavior, record one explicit classification:
83
+ **Codex-adapted** with its target, or **intentionally Claude-only** with the
84
+ reason. Never port hooks blindly.
85
+
86
+ ## Custom-agent validation
87
+
88
+ Codex custom-agent files are standalone TOML config layers. Parse every
89
+ `.codex/agents/*.toml` file and require non-empty string values for the required
90
+ `name`, `description`, and `developer_instructions` fields. Validate supported
91
+ optional `model` and `model_reasoning_effort` fields as strings when present,
92
+ then let strict Codex config validation reject unsupported keys or values.
93
+ Reject a file that is malformed, misses a required field, or uses the legacy
94
+ schema. A README or no-op note is not a custom-agent TOML file.
95
+
96
+ ## Validation
97
+
98
+ Run validation from the repository root and keep the evidence in the report:
99
+
100
+ 1. Run `codex --strict-config --version` from the root and from a representative
101
+ nested directory for every discovered project config layer. Any unknown
102
+ active key is a failure, not a warning to ignore.
103
+ 2. Validate skill metadata and loading with the repository's strict
104
+ skill-frontmatter guard when one is available, then start a fresh Codex
105
+ session and check that every enabled skill is discoverable without load
106
+ warnings.
107
+ 3. Parse and validate every custom-agent TOML file against the
108
+ Custom-agent validation section above; strict config validation must also
109
+ accept its supported optional fields.
110
+ 4. Use `git check-ignore --no-index` on `.codex/config.toml`, every
111
+ `.codex/agents/*.toml`, `.agents/skills/**`, `.codex/hooks.json`, and any
112
+ adapted hook implementation. A checked-in adapter target being ignored is a
113
+ failure; local state and override files should remain ignored.
114
+ 5. Resolve every path named by skill prose and verify that all references,
115
+ assets, scripts, templates, and other distributed support files exist. Run
116
+ the repository's all-file mirror-parity guard when available so non-Markdown
117
+ presence parity and Markdown body/`mirror-xform` parity are both checked.
118
+ 6. Recompute the dual-surface skill denominator from the manifest, compare all
119
+ distributed files in both trees, and report the fresh result as **X of Y**
120
+ mirrored skills. Do not reuse a remembered count.
121
+ 7. Enforce this repository's 1024-character description cap as a named
122
+ repository safeguard. It is not a Codex product limit; the local guard owns
123
+ the policy and must fail when a description exceeds it.
124
+
31
125
  ## Workflow
32
126
 
33
- 1. Establish a worktree before making adapter changes pick the mode by how
34
- the sync was triggered:
35
- - **In-current-worktree (default for a per-slice gate).** If you are already
36
- in a non-main slice/feature worktree e.g. invoked as the mandatory
37
- `codex-adapter-sync` gate at the end of a skill-touching slice — sync the
38
- adapter **here**, in that same worktree. Commit the adapter changes
39
- alongside the slice and ship them in the slice's own PR. Do **not** spin up
40
- a separate adapter branch or PR: a per-slice gate that demanded its own
41
- worktree/PR would collide with the slice it is gating.
42
- - **Dedicated adapter worktree.** Only when invoked standalone from the main
43
- checkout (no active slice worktree): create or reuse a dedicated adapter
44
- worktree before inventory or edits — your project's worktree helper (or
45
- `git worktree add`) on an issue-anchored branch when an anchor exists, or a
46
- `chore/codex-adapter-sync-<slug>` worktree for a deliberate no-issue chore
47
- — and ship it through its own PR.
48
- - Either way: never land adapter changes directly on `main`. Move any
49
- accidental main-checkout adapter diff into the chosen worktree first, then
50
- leave the main checkout clean.
51
- 2. Inventory the source side and adapter side from inside that worktree. If
52
- global Claude instructions are in scope, read them from the main thread but
53
- keep personal overrides, secrets, and machine-local state out of the repo
54
- diff.
55
- 3. Compare project knowledge:
127
+ 1. Select the mode from the user's request. Audit in the current checkout. For
128
+ Apply, reuse the active non-main slice worktree when the sync gates that
129
+ slice; otherwise create or reuse a dedicated issue-anchored adapter worktree.
130
+ 2. Build the Inventory before proposing or making changes. If global Claude
131
+ instructions are in scope, inspect them without exposing personal overrides,
132
+ secrets, credentials, or machine-local state.
133
+ 3. Compare project knowledge and instruction precedence:
56
134
  - New or changed durable conventions in `CLAUDE.md` should be reflected in
57
135
  `AGENTS.md` only as concise adapter guidance or references.
58
136
  - Do not duplicate long `CLAUDE.md` sections into `AGENTS.md`.
@@ -62,26 +140,16 @@ Codex adapter side:
62
140
  Codex-facing adapter rule in `AGENTS.md` or a minimal safe Codex config
63
141
  change; do not copy personal style, identities, credentials, or long
64
142
  global sections.
65
- 4. Compare skills:
143
+ 4. Compare every skill and distributed support file:
66
144
  - Important repo/domain/workflow skills from `.claude/skills/` belong in
67
145
  `.agents/skills/`.
68
146
  - Keep each `SKILL.md` and its support files together.
69
147
  - Do not copy skills into `.codex/skills/`.
70
148
  - Leave clearly Claude-only setup, hook, or personal/meta skills out unless
71
149
  the user explicitly wants them ported.
72
- - Translate Claude-specific model delegation instead of copying it
73
- literally (tier mapping per your routing doctrine; if the repo carries a
74
- Codex mirror table in `AGENTS.md`, keep it in sync). In particular, Claude `Agent` dispatches with
75
- `model: sonnet` should become Codex `spawn_agent` dispatches with the
76
- appropriate `agent_type`; for mechanical coding/git work use a `worker`
77
- subagent with `model: gpt-5.4-mini` and `reasoning_effort: low` unless the
78
- source skill gives a stronger task-specific reason. Claude `opus` /
79
- judgment-tier dispatches (subtle logic, review/verify verdicts) map to
80
- `model: gpt-5.5` with `reasoning_effort: high` (verdicts never below
81
- high). Translate Claude `effort:` params to the nearest
82
- `reasoning_effort` value (`minimal|low|medium|high|xhigh`; Claude `max`
83
- → `xhigh`). Keep user gates, security judgment, and approval decisions
84
- in the main thread.
150
+ - Translate Claude-specific delegation rather than copying it literally.
151
+ Apply the Model routing section above only when an explicit custom-agent
152
+ override is justified; otherwise preserve inherited parent configuration.
85
153
  - Keep dual-surface generic/vendored skill bodies content-synced. When a
86
154
  Codex mirror must intentionally differ from the Claude source, bracket the
87
155
  source region and the Codex replacement with a matching transform marker
@@ -111,36 +179,30 @@ Codex adapter side:
111
179
  `chaseai-yt/grill-me-codex`, `docs/agents/skills/grill-with-docs-codex.md`)
112
180
  is not an escalation target and must NOT be rewritten — rewriting it would
113
181
  break attribution/a real link.
114
- 5. Validate Codex skill frontmatter:
115
- - `name` and `description` are required.
116
- - Quote `description` when it contains colons, arrows, commas, or other
117
- YAML-sensitive punctuation.
118
- - Keep `description` under 1024 characters.
119
- - Keep trigger detail in the body if the original Claude description is too
120
- long.
121
- 6. Compare agents:
122
- - Claude agents are `.md` files under `.claude/agents/`.
123
- - Codex agents are `.toml` files under `.codex/agents/`.
124
- - Convert agent instruction bodies into `developer_instructions`.
125
- - If no Claude agents exist, keep only a short README or no-op note.
126
- 7. Compare config and ignore rules:
127
- - Keep `.codex/config.toml` minimal and safe.
128
- - Never add secrets, provider credentials, auth tokens, or local personal
129
- overrides.
130
- - Ensure `.gitignore` excludes local Codex state and override files while
131
- allowing checked-in project config, agents, and skills.
132
- 8. Before edits, show the exact files that will be created or changed.
133
- 9. After edits, validate:
134
- - `.codex/config.toml` parses as TOML.
135
- - All `.agents/skills/*/SKILL.md` files load without Codex warnings.
136
- - No skill description exceeds 1024 characters.
137
- - `.codex/config.toml`, `.codex/agents/*.toml`, and `.agents/skills/**`
138
- are not accidentally ignored.
139
- 10. Prepare the branch for review:
140
- - Commit the adapter changes on the worktree branch after checking for
141
- secrets and ignored files.
142
- - Push the branch and create or update a PR.
143
- - Report the PR URL, changed files, skipped items, and verification results.
182
+ 5. Compare agents, hooks, config, and ignore rules:
183
+ - Claude agents are `.md`; Codex custom agents are `.toml` and must satisfy
184
+ Custom-agent validation.
185
+ - Classify each Claude hook behavior using the Inventory contract and check
186
+ Codex hook targets at every active trusted config layer.
187
+ - Keep `.codex/config.toml` minimal and safe. Never add secrets, provider
188
+ credentials, auth tokens, or local personal overrides.
189
+ - Ensure ignore rules exclude local Codex state and override files while
190
+ allowing checked-in config, agents, hooks, skills, and support files.
191
+ 6. In Audit mode, run every read-only Validation step that the checkout
192
+ supports and report the proposed file changes without mutating anything.
193
+ 7. In Apply mode, show the exact files to create or change before editing, make
194
+ only those changes, then run the complete Validation section. Prepare the
195
+ existing worktree branch for review: check for secrets and ignored files,
196
+ commit the adapter changes, push, and create or update its PR.
197
+
198
+ Codex skill frontmatter must also satisfy these repository rules:
199
+
200
+ - `name` and `description` are required.
201
+ - Quote `description` when it contains colons, arrows, commas, or other
202
+ YAML-sensitive punctuation.
203
+ - Enforce the Validation section's named description safeguard.
204
+ - Keep trigger detail in the body if the original Claude description is too
205
+ long.
144
206
 
145
207
  ## Output
146
208
 
@@ -1,8 +1,37 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "description": "Public skill classification and publishing SSOT.",
4
+ "readiness": {
5
+ "contractVersion": 1,
6
+ "capabilities": {
7
+ "issueTracker": { "evidence": { "type": "sentinel", "paths": ["docs/agents/issue-tracker.md"], "allowLegacy": true } },
8
+ "triageLabels": { "evidence": { "type": "sentinel", "paths": ["docs/agents/triage-labels.md"], "allowLegacy": true } },
9
+ "managedBoard": { "allowNotApplicable": true, "evidence": { "type": "board-profile", "paths": ["docs/agents/board-sync.md"] } },
10
+ "specCompleteness": { "evidence": { "type": "sentinel", "paths": ["docs/conventions/spec-completeness.md"], "allowLegacy": true } },
11
+ "localCiRecipe": { "evidence": { "type": "sentinel", "paths": ["docs/agents/skills/local-ci.md"], "allowLegacy": true } },
12
+ "projectReleaseProfile": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "validator": "project-release" } },
13
+ "securityAuditRunbook": { "evidence": { "type": "runbook-reference", "paths": ["docs/agents/skills/security-audit.md"], "allowLegacy": true } },
14
+ "prodTarget": { "evidence": { "type": "prod-section", "paths": ["CLAUDE.md", "AGENTS.md"] } },
15
+ "orchestrateWaveRecipe": { "evidence": { "type": "sentinel", "paths": ["docs/agents/skills/orchestrate-wave.md"], "allowLegacy": true } },
16
+ "specCritiqueLayer": { "evidence": { "type": "sentinel", "paths": ["docs/agents/skills/spec-self-critique.md"], "allowLegacy": true } },
17
+ "codeReviewLayer": { "evidence": { "type": "sentinel", "paths": ["docs/agents/code-review.md"], "allowLegacy": true } },
18
+ "verifySpikeLayer": { "evidence": { "type": "sentinel", "paths": ["docs/agents/skills/verify-spike.md"], "allowLegacy": true } },
19
+ "auditSkillsLayer": { "evidence": { "type": "sentinel", "paths": ["docs/agents/skills/audit-skills.md"], "allowLegacy": true } },
20
+ "worktreeRecoveryLayer": { "evidence": { "type": "sentinel", "paths": ["docs/agents/skills/git-worktree-recover.md"], "allowLegacy": true } },
21
+ "domainDocs": { "evidence": { "type": "sentinel", "paths": ["docs/agents/domain.md"], "allowLegacy": true } },
22
+ "censusChoice": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "required": ["census"] } },
23
+ "memoryLifecycle": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "required": ["memoryLifecycle"] } },
24
+ "worktreeLifecycle": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "required": ["worktreeLifecycle"] } },
25
+ "workflowAdvisories": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "required": ["workflowAdvisories"] } },
26
+ "safetyGuardrails": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "required": ["safetyGuardrails"] } },
27
+ "workflowSummary": { "evidence": { "type": "nonempty", "paths": ["CLAUDE.md", "AGENTS.md"] } },
28
+ "locProfile": { "evidence": { "type": "json", "paths": ["docs/agents/max-lines-allowlist.json"] } },
29
+ "kitOriginHint": { "evidence": { "type": "json", "paths": ["docs/agents/workflow-capabilities.json"], "required": ["kitOriginHint"] } }
30
+ }
31
+ },
4
32
  "skills": {
5
33
  "to-prd": {
34
+ "readiness": { "required": ["issueTracker", "managedBoard"] },
6
35
  "class": "generic",
7
36
  "publish": true,
8
37
  "entryPoint": true,
@@ -13,6 +42,7 @@
13
42
  "provenance": "matt-pocock"
14
43
  },
15
44
  "to-issues": {
45
+ "readiness": { "required": ["issueTracker", "managedBoard", "specCompleteness"] },
16
46
  "class": "generic",
17
47
  "publish": true,
18
48
  "entryPoint": true,
@@ -51,6 +81,7 @@
51
81
  "provenance": "own"
52
82
  },
53
83
  "wrapup": {
84
+ "readiness": { "optionalBlocks": { "deployReport": "prodTarget" } },
54
85
  "class": "generic",
55
86
  "publish": true,
56
87
  "entryPoint": true,
@@ -61,6 +92,7 @@
61
92
  "provenance": "own"
62
93
  },
63
94
  "verify-spike": {
95
+ "readiness": { "optionalBlocks": { "projectPlacement": "verifySpikeLayer" } },
64
96
  "class": "generic",
65
97
  "publish": true,
66
98
  "entryPoint": true,
@@ -81,6 +113,7 @@
81
113
  "provenance": "own"
82
114
  },
83
115
  "spec-self-critique": {
116
+ "readiness": { "optionalBlocks": { "projectEnrichment": "specCritiqueLayer" } },
84
117
  "class": "generic",
85
118
  "publish": true,
86
119
  "surfaces": [
@@ -90,6 +123,7 @@
90
123
  "provenance": "own"
91
124
  },
92
125
  "code-review": {
126
+ "readiness": { "optionalBlocks": { "projectEnrichment": "codeReviewLayer" } },
93
127
  "class": "generic",
94
128
  "publish": true,
95
129
  "surfaces": [
@@ -118,6 +152,7 @@
118
152
  "provenance": "own"
119
153
  },
120
154
  "board-to-waves": {
155
+ "readiness": { "required": ["issueTracker", "managedBoard"] },
121
156
  "class": "generic",
122
157
  "publish": true,
123
158
  "entryPoint": true,
@@ -138,6 +173,7 @@
138
173
  "provenance": "own"
139
174
  },
140
175
  "to-waves": {
176
+ "readiness": { "required": ["issueTracker", "managedBoard", "specCompleteness"] },
141
177
  "class": "generic",
142
178
  "publish": true,
143
179
  "entryPoint": true,
@@ -187,6 +223,7 @@
187
223
  "provenance": "matt-pocock"
188
224
  },
189
225
  "triage": {
226
+ "readiness": { "required": ["issueTracker", "managedBoard", "triageLabels"] },
190
227
  "class": "vendored",
191
228
  "publish": true,
192
229
  "entryPoint": true,
@@ -327,6 +364,7 @@
327
364
  "provenance": "own"
328
365
  },
329
366
  "audit-skills": {
367
+ "readiness": { "optionalBlocks": { "projectChecks": "auditSkillsLayer" } },
330
368
  "class": "generic",
331
369
  "publish": true,
332
370
  "entryPoint": false,
@@ -337,6 +375,7 @@
337
375
  "provenance": "own"
338
376
  },
339
377
  "local-ci": {
378
+ "readiness": { "required": ["localCiRecipe"] },
340
379
  "class": "generic",
341
380
  "publish": true,
342
381
  "entryPoint": false,
@@ -347,6 +386,10 @@
347
386
  "provenance": "own"
348
387
  },
349
388
  "orchestrate-wave": {
389
+ "readiness": {
390
+ "required": ["issueTracker", "managedBoard"],
391
+ "optionalBlocks": { "projectRecipe": "orchestrateWaveRecipe" }
392
+ },
350
393
  "class": "generic",
351
394
  "publish": true,
352
395
  "entryPoint": true,
@@ -357,6 +400,7 @@
357
400
  "provenance": "own"
358
401
  },
359
402
  "git-worktree-recover": {
403
+ "readiness": { "optionalBlocks": { "projectRecovery": "worktreeRecoveryLayer" } },
360
404
  "class": "generic",
361
405
  "publish": true,
362
406
  "entryPoint": false,
@@ -367,6 +411,7 @@
367
411
  "provenance": "own"
368
412
  },
369
413
  "security-audit": {
414
+ "readiness": { "required": ["securityAuditRunbook"] },
370
415
  "class": "generic",
371
416
  "publish": true,
372
417
  "entryPoint": false,
@@ -388,6 +433,7 @@
388
433
  "provenance": "own"
389
434
  },
390
435
  "project-release": {
436
+ "readiness": { "required": ["projectReleaseProfile"] },
391
437
  "class": "generic",
392
438
  "publish": true,
393
439
  "entryPoint": true,
package/README.md CHANGED
@@ -348,6 +348,15 @@ concurrency-safe. Do not run manifest-mutating commands concurrently. Flags:
348
348
 
349
349
  ## Release notes
350
350
 
351
+ ### 0.30.0
352
+
353
+ - added: `.claude/skills/skill-manifest.json`
354
+ - added: `scripts/readiness.mjs`
355
+ - added: `src/lib/atomicWrite.mjs`
356
+ - added: `src/lib/manifest.mjs`
357
+ - added: `src/lib/sentinel.mjs`
358
+ - changed: `.agents/skills/codex-adapter-sync/SKILL.md`
359
+
351
360
  ### 0.29.1
352
361
 
353
362
  - Metadata-only release.
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.29.1",
2
+ "kitVersion": "0.30.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -107,7 +107,7 @@
107
107
  "ownerSkill": "codex-adapter-sync",
108
108
  "surface": "codex",
109
109
  "installRole": "consumer",
110
- "sha256": "3a8ad73c4bfce8c01e561d2dc0c7f0e24d9ac85c1bb2c8c0194a2de278cd6a2d",
110
+ "sha256": "ae398e40308e6ad8d6091c418435e2dca8a4f67e526ab85efa88e8e57c0354c2",
111
111
  "mode": 420,
112
112
  "origin": "kit"
113
113
  },
@@ -2023,6 +2023,14 @@
2023
2023
  "mode": 420,
2024
2024
  "origin": "kit"
2025
2025
  },
2026
+ {
2027
+ "path": ".claude/skills/skill-manifest.json",
2028
+ "kind": "doc",
2029
+ "installRole": "consumer",
2030
+ "sha256": "290cc3e847a10cb10b4ecb17cf0d45be0f60f95e96b7a70417b3e8da0a32e23c",
2031
+ "mode": 420,
2032
+ "origin": "kit"
2033
+ },
2026
2034
  {
2027
2035
  "path": ".claude/skills/spec-self-critique/scenarios.md",
2028
2036
  "kind": "skill",
@@ -2575,6 +2583,14 @@
2575
2583
  "mode": 493,
2576
2584
  "origin": "kit"
2577
2585
  },
2586
+ {
2587
+ "path": "scripts/readiness.mjs",
2588
+ "kind": "script",
2589
+ "installRole": "consumer",
2590
+ "sha256": "f5e8bc2e724f72d1aa9f94c4f8d87b8f4d3f9843797cd6ae59f48e563dc7c7fb",
2591
+ "mode": 493,
2592
+ "origin": "kit"
2593
+ },
2578
2594
  {
2579
2595
  "path": "scripts/release-delta-guard.mjs",
2580
2596
  "kind": "script",
@@ -2727,6 +2743,22 @@
2727
2743
  "mode": 493,
2728
2744
  "origin": "kit"
2729
2745
  },
2746
+ {
2747
+ "path": "src/lib/atomicWrite.mjs",
2748
+ "kind": "script",
2749
+ "installRole": "consumer",
2750
+ "sha256": "dbb971617ae641127857341a02cc029f30633ab5ef0c6efff2bab27b95794a9f",
2751
+ "mode": 420,
2752
+ "origin": "kit"
2753
+ },
2754
+ {
2755
+ "path": "src/lib/manifest.mjs",
2756
+ "kind": "script",
2757
+ "installRole": "consumer",
2758
+ "sha256": "171eba2662054379c40c1c629110fa8e34353de72e136004b861b2ac6a4edc13",
2759
+ "mode": 420,
2760
+ "origin": "kit"
2761
+ },
2730
2762
  {
2731
2763
  "path": "src/lib/release-apply.mjs",
2732
2764
  "kind": "script",
@@ -2750,6 +2782,14 @@
2750
2782
  "sha256": "7d0a8da3740ed1eb79edb80f0eedb3cd1de47692d4886c6b119b39073a6468d1",
2751
2783
  "mode": 420,
2752
2784
  "origin": "kit"
2785
+ },
2786
+ {
2787
+ "path": "src/lib/sentinel.mjs",
2788
+ "kind": "script",
2789
+ "installRole": "consumer",
2790
+ "sha256": "f99d28d2053d55222650d2ca426509d3f56eec5ef70709bbf8c4a278bf80024e",
2791
+ "mode": 420,
2792
+ "origin": "kit"
2753
2793
  }
2754
2794
  ]
2755
2795
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -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"}
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Manifest-derived readiness declaration and marker grammar guard."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ MANIFEST_PATH = ROOT / ".claude/skills/skill-manifest.json"
12
+ START = re.compile(r"^<!-- readiness:block ([a-z][A-Za-z0-9]*) -->$")
13
+ END = "<!-- readiness:end -->"
14
+ SURFACE = {"claude": ".claude/skills", "codex": ".agents/skills"}
15
+ REQUIRED = {
16
+ "to-prd": ["issueTracker", "managedBoard"],
17
+ "to-issues": ["issueTracker", "managedBoard", "specCompleteness"],
18
+ "to-waves": ["issueTracker", "managedBoard", "specCompleteness"],
19
+ "board-to-waves": ["issueTracker", "managedBoard"],
20
+ "triage": ["issueTracker", "managedBoard", "triageLabels"],
21
+ "orchestrate-wave": ["issueTracker", "managedBoard"],
22
+ "local-ci": ["localCiRecipe"],
23
+ "project-release": ["projectReleaseProfile"],
24
+ "security-audit": ["securityAuditRunbook"],
25
+ }
26
+ OPTIONAL = {
27
+ "wrapup": {"deployReport": "prodTarget"},
28
+ "orchestrate-wave": {"projectRecipe": "orchestrateWaveRecipe"},
29
+ "spec-self-critique": {"projectEnrichment": "specCritiqueLayer"},
30
+ "code-review": {"projectEnrichment": "codeReviewLayer"},
31
+ "verify-spike": {"projectPlacement": "verifySpikeLayer"},
32
+ "audit-skills": {"projectChecks": "auditSkillsLayer"},
33
+ "git-worktree-recover": {"projectRecovery": "worktreeRecoveryLayer"},
34
+ }
35
+
36
+
37
+ def load_manifest() -> dict:
38
+ return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
39
+
40
+
41
+ def marker_sequence(body: str, declared: set[str]) -> list[str]:
42
+ sequence: list[str] = []
43
+ active: str | None = None
44
+ seen: set[str] = set()
45
+ for number, line in enumerate(body.splitlines(), 1):
46
+ match = START.fullmatch(line)
47
+ if match:
48
+ block = match.group(1)
49
+ if active:
50
+ raise AssertionError(f"nested readiness block at line {number}")
51
+ if block in seen:
52
+ raise AssertionError(f"duplicate readiness block {block}")
53
+ if block not in declared:
54
+ raise AssertionError(f"unknown readiness block {block}")
55
+ active = block
56
+ seen.add(block)
57
+ sequence.append(block)
58
+ elif line == END:
59
+ if not active:
60
+ raise AssertionError(f"unbalanced readiness end at line {number}")
61
+ active = None
62
+ elif "readiness:block" in line or "readiness:end" in line:
63
+ raise AssertionError(f"invalid readiness marker grammar at line {number}")
64
+ if active:
65
+ raise AssertionError(f"unbalanced readiness block {active}")
66
+ return sequence
67
+
68
+
69
+ def validate_surface_sequences(sequences: list[list[str]]) -> None:
70
+ if any(sequence != sequences[0] for sequence in sequences[1:]):
71
+ raise AssertionError("cross-surface readiness marker drift")
72
+
73
+
74
+ class ReadinessContractTests(unittest.TestCase):
75
+ def setUp(self) -> None:
76
+ self.manifest = load_manifest()
77
+ self.catalog = self.manifest["readiness"]["capabilities"]
78
+ self.published = {
79
+ name: entry for name, entry in self.manifest["skills"].items()
80
+ if entry.get("publish")
81
+ }
82
+
83
+ def test_contract_and_skill_denominator_come_from_publish_manifest(self) -> None:
84
+ self.assertEqual(self.manifest["readiness"]["contractVersion"], 1)
85
+ manifest_source = (ROOT / "src/lib/manifest.mjs").read_text(encoding="utf-8")
86
+ version = re.search(r"READINESS_CONTRACT_VERSION = (\d+);", manifest_source)
87
+ self.assertIsNotNone(version)
88
+ self.assertEqual(int(version.group(1)), self.manifest["readiness"]["contractVersion"])
89
+ self.assertGreater(len(self.published), 0)
90
+ for name, entry in self.published.items():
91
+ self.assertTrue(entry.get("surfaces"), name)
92
+
93
+ def test_every_declaration_references_the_single_capability_catalog(self) -> None:
94
+ for skill, entry in self.published.items():
95
+ declaration = entry.get("readiness", {})
96
+ references = list(declaration.get("required", []))
97
+ references += list(declaration.get("optionalBlocks", {}).values())
98
+ for capability in references:
99
+ self.assertIn(capability, self.catalog, f"{skill}: {capability}")
100
+ blocks = list(declaration.get("optionalBlocks", {}))
101
+ self.assertEqual(len(blocks), len(set(blocks)), skill)
102
+
103
+ def test_initial_enforcement_mapping_is_exact(self) -> None:
104
+ for skill, required in REQUIRED.items():
105
+ self.assertEqual(self.published[skill]["readiness"]["required"], required)
106
+ for skill, blocks in OPTIONAL.items():
107
+ self.assertEqual(self.published[skill]["readiness"]["optionalBlocks"], blocks)
108
+
109
+ def test_markers_are_balanced_declared_and_surface_equal_when_activated(self) -> None:
110
+ for skill, entry in self.published.items():
111
+ declared = set(entry.get("readiness", {}).get("optionalBlocks", {}))
112
+ sequences: list[tuple[str, list[str]]] = []
113
+ for surface in entry["surfaces"]:
114
+ path = ROOT / SURFACE[surface] / skill / "SKILL.md"
115
+ self.assertTrue(path.is_file(), f"missing published surface: {path}")
116
+ sequence = marker_sequence(path.read_text(encoding="utf-8"), declared)
117
+ sequences.append((surface, sequence))
118
+ if len(sequences) > 1:
119
+ validate_surface_sequences([sequence for _, sequence in sequences])
120
+
121
+ def test_marker_parser_rejects_each_structural_failure(self) -> None:
122
+ declared = {"projectEnrichment"}
123
+ bad = [
124
+ "<!-- readiness:block projectEnrichment -->\n<!-- readiness:block projectEnrichment -->",
125
+ "<!-- readiness:block projectEnrichment -->\n<!-- readiness:end -->\n<!-- readiness:block projectEnrichment -->",
126
+ "<!-- readiness:end -->",
127
+ "<!-- readiness:block projectEnrichment -->",
128
+ "<!-- readiness:block unknownBlock -->\n<!-- readiness:end -->",
129
+ " <!-- readiness:block projectEnrichment -->",
130
+ ]
131
+ for body in bad:
132
+ with self.subTest(body=body), self.assertRaises(AssertionError):
133
+ marker_sequence(body, declared)
134
+ with self.assertRaises(AssertionError):
135
+ validate_surface_sequences([["projectEnrichment"], []])
136
+
137
+
138
+ if __name__ == "__main__":
139
+ unittest.main()
@@ -8,6 +8,7 @@ import {
8
8
  readManifest, writeManifest, emptyConsumerManifest,
9
9
  filesForInstallRole, CONSUMER_INSTALL_ROLE,
10
10
  indexByPath,
11
+ readReadinessContract,
11
12
  CONSUMER_ORIGIN,
12
13
  PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME,
13
14
  } from '../lib/manifest.mjs';
@@ -27,6 +28,7 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
27
28
  const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
28
29
  if (!pkg) throw new Error('kit package manifest not found');
29
30
  const prior = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
31
+ const readiness = await readReadinessContract(kitRoot);
30
32
  const tracked = new Set((prior?.installed ?? []).map((e) => e.path));
31
33
  const consumerOwned = new Set(
32
34
  (prior?.installed ?? []).filter((e) => e.origin === CONSUMER_ORIGIN).map((e) => e.path),
@@ -68,7 +70,7 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
68
70
 
69
71
  await writeManifest(
70
72
  join(consumerRoot, CONSUMER_MANIFEST_NAME),
71
- { ...emptyConsumerManifest(pkg.kitVersion), installed }
73
+ { ...emptyConsumerManifest(pkg.kitVersion, prior, readiness), installed }
72
74
  );
73
75
 
74
76
  for (const stub of STUB_TARGETS) {
@@ -5,6 +5,7 @@ import { hookReferenced } from '../lib/settings.mjs';
5
5
  import {
6
6
  writeManifest, readManifest, emptyConsumerManifest,
7
7
  CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN,
8
+ readReadinessContract,
8
9
  } from '../lib/manifest.mjs';
9
10
 
10
11
  const exists = (p) => access(p).then(() => true, () => false);
@@ -18,6 +19,7 @@ const exists = (p) => access(p).then(() => true, () => false);
18
19
  export async function uninstall({ consumerRoot }) {
19
20
  const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
20
21
  if (!consumer) throw new Error('not initialised — nothing to uninstall');
22
+ const readiness = await readReadinessContract(consumerRoot);
21
23
 
22
24
  const res = { removed: [], retained: [] };
23
25
  const retainedEntries = [];
@@ -42,7 +44,9 @@ export async function uninstall({ consumerRoot }) {
42
44
 
43
45
  const manifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
44
46
  if (retainedEntries.length) {
45
- await writeManifest(manifestPath, { ...emptyConsumerManifest(consumer.kitVersion), installed: retainedEntries });
47
+ await writeManifest(manifestPath, {
48
+ ...emptyConsumerManifest(consumer.kitVersion, consumer, readiness), installed: retainedEntries,
49
+ });
46
50
  } else {
47
51
  await rm(manifestPath, { force: true });
48
52
  }
@@ -4,6 +4,13 @@ import { join, relative } from 'node:path';
4
4
  // The planning skills are not usable without their helper ecosystem (Codex R2#1).
5
5
  // These ship alongside the skills. Paths are relative to the bundle/consumer root.
6
6
  export const HELPER_FILES = [
7
+ // Readiness declarations and their deterministic consumer-side command ship
8
+ // together; the manifest remains the only capability/dependency registry.
9
+ { path: '.claude/skills/skill-manifest.json', kind: 'doc', mode: 0o644 },
10
+ { path: 'scripts/readiness.mjs', kind: 'script', mode: 0o755 },
11
+ { path: 'src/lib/sentinel.mjs', kind: 'script', mode: 0o644 },
12
+ { path: 'src/lib/manifest.mjs', kind: 'script', mode: 0o644 },
13
+ { path: 'src/lib/atomicWrite.mjs', kind: 'script', mode: 0o644 },
7
14
  // Shared profile loader imported by the three planning scripts — they read
8
15
  // every board-specific value from docs/agents/board-sync.md through it, so it
9
16
  // MUST ship or they are broken-on-arrival. Library (imported, not run) → 0o644.
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
2
3
  import { writeAtomic } from './atomicWrite.mjs';
3
4
 
4
5
  // Two manifests (Codex R1#9 / R3#1):
@@ -15,6 +16,8 @@ export const PACKAGE_MANIFEST_NAME = 'agent-workflow-kit.package.json';
15
16
  export const CONSUMER_INSTALL_ROLE = 'consumer';
16
17
  export const KIT_ORIGIN = 'kit';
17
18
  export const CONSUMER_ORIGIN = 'consumer';
19
+ export const READINESS_CONTRACT_VERSION = 1;
20
+ export const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
18
21
 
19
22
  /**
20
23
  * Parse a JSON manifest, or null if the file does not exist. A corrupt file
@@ -51,8 +54,29 @@ export async function writeManifest(path, obj) {
51
54
  await writeAtomic(path, JSON.stringify(obj, null, 2) + '\n');
52
55
  }
53
56
 
54
- export function emptyConsumerManifest(kitVersion) {
55
- return { kitVersion, installRole: CONSUMER_INSTALL_ROLE, installed: [] };
57
+ export async function readReadinessContract(root) {
58
+ const manifest = await readManifest(join(root, READINESS_MANIFEST_PATH));
59
+ return manifest?.readiness ?? null;
60
+ }
61
+
62
+ function retainedDecisions(prior, readiness) {
63
+ const allowed = readiness?.capabilities ? new Set(Object.keys(readiness.capabilities)) : null;
64
+ return Object.fromEntries(Object.entries(prior.readinessDecisions ?? {}).filter(
65
+ ([name, value]) => (!allowed || allowed.has(name)) && (value === 'pending'
66
+ || (value === 'not-applicable' && readiness?.capabilities?.[name]?.allowNotApplicable)),
67
+ ));
68
+ }
69
+
70
+ export function emptyConsumerManifest(kitVersion, prior = {}, readiness = null) {
71
+ prior ??= {};
72
+ return {
73
+ ...prior,
74
+ kitVersion,
75
+ installRole: CONSUMER_INSTALL_ROLE,
76
+ readinessContractVersion: readiness?.contractVersion ?? READINESS_CONTRACT_VERSION,
77
+ readinessDecisions: retainedDecisions(prior, readiness),
78
+ installed: [],
79
+ };
56
80
  }
57
81
 
58
82
  /** Package entries without a role predate role-aware installs and remain consumer-owned. */
@@ -8,6 +8,7 @@ import {
8
8
  CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, emptyConsumerManifest,
9
9
  CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, KIT_ORIGIN, filesForInstallRole,
10
10
  indexByPath, readManifest, writeManifest,
11
+ readReadinessContract,
11
12
  } from './manifest.mjs';
12
13
 
13
14
  const exists = (path) => access(path).then(() => true, () => false);
@@ -18,6 +19,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
18
19
  if (!pkg) throw new Error('kit package manifest not found');
19
20
  const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
20
21
  if (!consumer) throw new Error('not initialised — run `init` first');
22
+ const readiness = await readReadinessContract(kitRoot);
21
23
 
22
24
  const installedIdx = indexByPath(consumer, 'installed');
23
25
  const packageIdx = indexByPath(pkg, 'files');
@@ -117,7 +119,7 @@ export async function reconcile({ kitRoot, consumerRoot, decide = () => false, d
117
119
 
118
120
  if (!dryRun) {
119
121
  await writeManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME), {
120
- ...emptyConsumerManifest(pkg.kitVersion), installed: nextInstalled,
122
+ ...emptyConsumerManifest(pkg.kitVersion, consumer, readiness), installed: nextInstalled,
121
123
  });
122
124
  }
123
125
  return result;