@ikon85/agent-workflow-kit 0.33.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +4 -3
  2. package/.agents/skills/audit-skills/SKILL.md +6 -0
  3. package/.agents/skills/board-to-waves/SKILL.md +6 -2
  4. package/.agents/skills/code-review/SKILL.md +6 -0
  5. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +11 -1
  6. package/.agents/skills/codex-adapter-sync/SKILL.md +23 -19
  7. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  8. package/.agents/skills/improve-codebase-architecture/SKILL.md +10 -1
  9. package/.agents/skills/kit-release/SKILL.md +29 -4
  10. package/.agents/skills/kit-update/SKILL.md +13 -0
  11. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  12. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  13. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  14. package/.agents/skills/research/SKILL.md +6 -0
  15. package/.agents/skills/scale-check/SKILL.md +9 -7
  16. package/.agents/skills/setup-workflow/SKILL.md +47 -1
  17. package/.agents/skills/setup-workflow/board-sync.md +7 -2
  18. package/.agents/skills/setup-workflow/workflow-overview.md +1 -2
  19. package/.agents/skills/to-issues/SKILL.md +66 -8
  20. package/.agents/skills/to-waves/SKILL.md +24 -12
  21. package/.claude/skills/ask-matt/SKILL.md +4 -3
  22. package/.claude/skills/audit-skills/SKILL.md +6 -0
  23. package/.claude/skills/board-to-waves/SKILL.md +6 -2
  24. package/.claude/skills/code-review/SKILL.md +6 -0
  25. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +8 -0
  26. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  27. package/.claude/skills/improve-codebase-architecture/SKILL.md +6 -0
  28. package/.claude/skills/kit-release/SKILL.md +29 -4
  29. package/.claude/skills/kit-update/SKILL.md +13 -0
  30. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  31. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  32. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  33. package/.claude/skills/research/SKILL.md +6 -0
  34. package/.claude/skills/scale-check/SKILL.md +9 -7
  35. package/.claude/skills/setup-workflow/SKILL.md +47 -1
  36. package/.claude/skills/setup-workflow/board-sync.md +7 -2
  37. package/.claude/skills/setup-workflow/workflow-overview.md +1 -2
  38. package/.claude/skills/to-issues/SKILL.md +66 -8
  39. package/.claude/skills/to-waves/SKILL.md +24 -12
  40. package/README.md +89 -13
  41. package/agent-workflow-kit.package.json +204 -44
  42. package/docs/adr/0004-release-intent-is-a-version-tag.md +42 -0
  43. package/docs/adr/0005-to-issues-is-the-planning-facade.md +42 -0
  44. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +91 -0
  45. package/docs/agents/skills/local-ci.md +89 -0
  46. package/docs/agents/wave-anchor-template.md +2 -2
  47. package/docs/methodology.html +1 -1
  48. package/docs/methodology.svg +1 -1
  49. package/docs/workflow.html +2 -2
  50. package/docs/workflow.png +0 -0
  51. package/package.json +1 -1
  52. package/scripts/codex-exec.sh +47 -8
  53. package/scripts/codex-exec.test.mjs +56 -0
  54. package/scripts/kit-release.test.mjs +178 -0
  55. package/scripts/release-state.mjs +35 -5
  56. package/scripts/release-state.test.mjs +122 -5
  57. package/scripts/test_codex_adapter_sync_contract.py +30 -15
  58. package/scripts/test_orchestrate_wave_contract.py +73 -0
  59. package/scripts/test_program_planning_contract.py +70 -0
  60. package/scripts/test_skill_portability_lint.py +54 -0
  61. package/src/cli.mjs +109 -2
  62. package/src/commands/init.mjs +17 -1
  63. package/src/commands/routing-policy-update.mjs +204 -0
  64. package/src/commands/update.mjs +22 -0
  65. package/src/lib/agentSurfaceRegistry.mjs +60 -0
  66. package/src/lib/bundle.mjs +24 -0
  67. package/src/lib/capabilityMatrix.mjs +85 -0
  68. package/src/lib/dispatchReceipt.mjs +162 -0
  69. package/src/lib/frontendWorkloads.mjs +170 -0
  70. package/src/lib/routeDispatcher.mjs +158 -0
  71. package/src/lib/routingAccessGraph.mjs +105 -0
  72. package/src/lib/routingAdapters/claude.mjs +62 -0
  73. package/src/lib/routingAdapters/codex.mjs +136 -0
  74. package/src/lib/routingCatalog.mjs +123 -0
  75. package/src/lib/routingEvidenceCache.mjs +222 -0
  76. package/src/lib/routingIntent.mjs +93 -0
  77. package/src/lib/routingPolicy.mjs +67 -0
  78. package/src/lib/routingProfile.mjs +334 -0
  79. package/src/lib/routingResolver.mjs +176 -0
  80. package/src/lib/routingSources/artificialAnalysis.mjs +129 -0
  81. package/src/lib/routingSources/benchlm.mjs +151 -0
  82. package/src/lib/routingSources/codeArena.mjs +162 -0
  83. package/src/lib/routingSources/deepswe.mjs +106 -0
  84. package/src/lib/routingSources/openhands.mjs +102 -0
  85. package/src/lib/routingSources/openhandsFrontend.mjs +135 -0
@@ -0,0 +1,91 @@
1
+ # Routing knowledge, access, and policy are separate
2
+
3
+ Status: accepted (2026-07-22, Program #197)
4
+
5
+ Planning cannot safely persist a concrete recommended model. Provider catalogs
6
+ change, model names age, effort behavior differs by model, agent surfaces expose
7
+ different controls, and a surface may reach another provider through a plugin
8
+ or CLI transport. A recommendation that is correct in one session can therefore
9
+ be nonexistent, unreachable, or unenforceable in the session that implements
10
+ the work.
11
+
12
+ We decided to keep three identities separate:
13
+
14
+ 1. The Kit-maintained **Evidence catalog** contains all known provider models
15
+ and observations, whether or not the active surface can use them. Each
16
+ decision-grade observation retains the complete configuration identity:
17
+ model, effort, harness, workload, source, benchmark version, uncertainty,
18
+ freshness, and cost.
19
+ 2. The user-local **Access graph** records the native and cross-provider paths
20
+ available from each agent surface. Claude Code and Codex attest only their
21
+ own runtime capabilities. A detected transport is not automatically approved,
22
+ and neither surface claims another surface's unverified capabilities.
23
+ 3. The user-local **Routing policy** records allowed surfaces and transports,
24
+ switching autonomy, optimization goals, and optional advanced overrides.
25
+ Personal choices do not alter catalog facts.
26
+
27
+ Durable plans and issues contain only a provider-neutral **Routing intent**.
28
+ At execution time a resolver compares all evidence-backed candidates, filters
29
+ them through the Access graph and Routing policy, and produces a one-execution
30
+ **Route decision**. It reports the best overall route separately from the best
31
+ currently executable route. An unreachable preference follows the user's
32
+ explicit handoff, fallback, or block policy.
33
+
34
+ Every surface adapter declares how independently it can control model and
35
+ effort: per spawn, through a named agent definition, through a session default,
36
+ or not at all. Environment precedence is part of the capability proof. An AFK
37
+ subagent requires enforced model and effort selection and emits a **Dispatch
38
+ receipt** containing the requested and applied route, enforcement method, and
39
+ policy/evidence revisions. Silent inheritance and unverified degradation are
40
+ not valid AFK routes.
41
+
42
+ Setup keeps the technical model behind a simple user decision. It presents a
43
+ registry-driven list of familiar agent surfaces, preselects detected entries,
44
+ and asks whether the Kit may switch automatically, ask before switching, or use
45
+ only the current surface. Adapters establish providers, transports, model
46
+ selectors, and effort controls. Model preferences and optimization overrides
47
+ remain optional advanced settings.
48
+
49
+ An existing installation without a routing profile receives this choice once
50
+ through setup or the first compatible Kit update. Later updates perform a
51
+ read-only preflight and ask again only when the profile is missing, invalid,
52
+ materially stale, references a removed route, or a newly detected surface makes
53
+ a meaningful choice available. Installing Kit mechanics and changing personal
54
+ policy are separate transactions; unattended update never invents a decision.
55
+
56
+ BenchLM may populate discovery, catalog, pricing, freshness, coverage, and
57
+ corroboration signals. Its aggregate scores are not decision-grade routing
58
+ observations because effort and harness identity may be collapsed and missing
59
+ evidence may be estimated. DeepSWE, Artificial Analysis, OpenHands, Arena, and
60
+ other benchmark-owner artifacts remain authoritative for the claims they
61
+ actually measure. Local experience may calibrate policy but does not rewrite
62
+ public evidence.
63
+
64
+ ## Considered options
65
+
66
+ - **Persist a concrete model and effort in every issue:** rejected because the
67
+ recommendation ages and may not be executable on the implementing surface.
68
+ - **Maintain separate Claude and Codex routing tables:** rejected because it
69
+ duplicates evidence, hides cross-provider routes, and lets the two surfaces
70
+ drift.
71
+ - **Filter the shared table during setup:** rejected because current access is
72
+ a user/runtime fact, not a limit on routing knowledge.
73
+ - **Ask users to describe transports and enforcement capabilities:** rejected
74
+ because these are adapter facts most users cannot answer reliably.
75
+ - **Route directly from a composite leaderboard:** rejected because benchmark,
76
+ harness, effort, uncertainty, and workload boundaries would be lost.
77
+ - **Re-run the routing interview on every Kit update:** rejected because a valid
78
+ user-owned profile should remain stable until a material choice changes.
79
+
80
+ ## Consequences
81
+
82
+ - Model/provider churn updates the catalog and resolver without rewriting
83
+ durable issues.
84
+ - Both Claude Code and Codex read one local policy while proving their own
85
+ execution capabilities independently.
86
+ - Cross-provider dispatch is expressible without pretending that every surface
87
+ can reach every model.
88
+ - The Kit must maintain source adapters, schema migration, capability probes,
89
+ last-known-good evidence, and dispatch receipts.
90
+ - Different machines do not share personal routing state unless the user later
91
+ chooses an explicit export or private synchronization mechanism.
@@ -0,0 +1,89 @@
1
+ <!-- setup-workflow: state=filled -->
2
+ # Project layer — local-ci
3
+
4
+ This repo is a Node + stdlib-Python repo with no database, no dev server and no
5
+ typecheck step, so the two generic profiles map onto a short command set. Run the
6
+ two commands below; do not infer others.
7
+
8
+ Prerequisite once per clone (worktrees inherit it):
9
+
10
+ ```sh
11
+ git config core.hooksPath .githooks
12
+ ```
13
+
14
+ ## Fast static guards
15
+
16
+ The skill/manifest lints, ~3s, no network, no DB. This is exactly what
17
+ `.githooks/pre-commit` runs, so a normal commit already covers it:
18
+
19
+ ```sh
20
+ python3 -m unittest discover -s scripts -p 'test_skill_*.py' -q
21
+ ```
22
+
23
+ ## Full gate
24
+
25
+ Mirrors the `test` job in `.github/workflows/ci.yml` step for step. Run it from
26
+ the branch's worktree root before opening a PR:
27
+
28
+ ```sh
29
+ npm test
30
+ npm run kit:staleness
31
+ npm run release:guard -- --base "$(git merge-base origin/main HEAD)"
32
+ npm pack --dry-run
33
+ ```
34
+
35
+ Notes on the individual steps:
36
+
37
+ - `npm test` is `test:node` (`node --test`) plus `test:python`
38
+ (`python3 -m unittest discover -s scripts -p 'test_*.py'`). Several negative-path
39
+ tests print `[FAIL] …` lines by design; only the runner's own summary and exit
40
+ code decide red or green.
41
+ - CI runs `npm install --ignore-scripts` first. Locally that is only needed after
42
+ a dependency change.
43
+ - `release:guard` runs in CI on `pull_request` only, against
44
+ `github.event.pull_request.base.sha`. `git merge-base origin/main HEAD` is the
45
+ local equivalent; `git fetch origin main` first if `origin/main` is stale.
46
+ - `npm pack --dry-run` catches packaging drift (a file added outside the
47
+ published set) without publishing anything.
48
+
49
+ ## Enforcement
50
+
51
+ Since #220 the host **does** enforce: the `main protection` ruleset makes the CI
52
+ job `test` a required status check on `main`, with no bypass actor. So the
53
+ generic skill's "When the host CAN enforce" branch applies to this repo — the
54
+ local gate is a pre-flight that shortens the feedback loop, not the only thing
55
+ standing between a red branch and `main`.
56
+
57
+ Layered, from cheapest to strongest:
58
+
59
+ | Layer | Runs | Scope |
60
+ | --- | --- | --- |
61
+ | `pre-commit` | automatic, every commit | fast skill/manifest lints |
62
+ | `pre-push` | automatic, every push | full `npm test` |
63
+ | this full gate | explicit, before a PR | `npm test` + staleness + release guard + pack |
64
+ | CI `test` | automatic, on the PR | the same set, machine-enforced at merge |
65
+
66
+ Never bypass a hook with `--no-verify`.
67
+
68
+ **Drift rule.** The full gate above and `.github/workflows/ci.yml` are two copies
69
+ of one list. Change one, change the other in the same PR — otherwise the local
70
+ gate goes green on a set the required check does not accept.
71
+
72
+ ## Contention
73
+
74
+ Not applicable here. This repo has no dev server and the suites are process-level
75
+ (`node --test`, `unittest`), so the generic boot-contention false-red guidance has
76
+ no target. A red is a real red until an isolated re-run of that one test proves
77
+ otherwise.
78
+
79
+ ## On a red
80
+
81
+ 1. Read the failing assertion — the guards print the exact drift (`file:line`,
82
+ the offending token, the missing manifest key).
83
+ 2. Fix the source. A guard that legitimately cannot be satisfied yet gets a
84
+ documented allowlist entry with a reason; never widen a guard silently.
85
+ 3. Re-run the single failing suite, then the full gate for sign-off.
86
+
87
+ `kit:staleness` red almost always means the generated kit artefacts lag the
88
+ skill sources — run `npm run kit:build` and commit the result rather than
89
+ editing the generated output by hand.
@@ -25,7 +25,7 @@ The **candidate stub** (Stage 1) has no cluster/Wave. On **`to-issues` promotion
25
25
  - **Source:** <board-to-waves | external-prd | raw-issue | plan | grill> *(provenance-neutral — the shape is the same regardless of origin; fill the following lines where they apply, delete where they don't)*
26
26
  - **Member issues:** #<a> #<b> #<c> … *(listed; linked via `to-issues` promotion, To-Do below)*
27
27
  - **Why together (firing criteria):** Gate=<Outcome> · <B1 code proximity / B2 type homogeneity / B3 dependency / B4 verify surface, where applicable>
28
- - **Size + risk:** ~<N> slices · Backend: <yes/no> · Model mix: <Model [Effort], e.g. Sonnet [medium] / Opus [high] / gpt-5.5 [medium]> · Risk: <low/medium/high — reason, e.g. race/cache/forecast/migration>
28
+ - **Size + risk:** ~<N> slices · Backend: <yes/no> · Routing-intent mix: <judgment/development/mechanical + deep/balanced/light> · Risk: <low/medium/high — reason, e.g. race/cache/forecast/migration>
29
29
  - **`grill-needed`:** <no> | <yes — this session> | <yes — own session (too big/fuzzy)>
30
30
 
31
31
  ### To-Do (maturation: grill → to-prd → to-issues) — *(Stage 1/1p only; at promotion this whole checklist collapses to ONE line: `Maturation: grill <✓/–> · to-prd ✓ · to-issues ✓ (<Date>) — Wave gate + tracking open`)*
@@ -53,7 +53,7 @@ Order (WSJF-lite): visible + low-risk first → logic/backend → cleanup. Depen
53
53
  | 1 | ⬜ | <Slice title> | #<sub> | <—/🧭/🔬/📐/📝> | <closes #x / refs #y> |
54
54
  <!-- slice-table:end -->
55
55
 
56
- Status legend: ⬜ open · 🔄 in progress · ✅ merged #<PR>. **Every slice = one sub-issue** (`#<sub>`). **The volatile Status column is generated by `board-sync.py anchor-sync <anchor#>` from the board** (between the `<!-- slice-table:start/end -->` markers; `wrapup` Step 5e.1 calls it on merge) — monotone (never flips a `✅`/`🔄` back), drift-free idempotent; **stable plan columns (Slice/Gate/refs) stay hand-maintained** and survive verbatim. It appends missing sub-issue rows (gen-b split). **Don't delete the markers** — without them `anchor-sync` can't locate the table (the first run locates it via the `Status`+`Sub-Issue` header row and sets the markers itself). The native "Sub-issues progress" rollup is the secondary %-view. *(Slimmed in: Branch is derivable from the `feat/<#>-<slug>` convention, the model recommendation lives in the leaf's handoff, Backend? carried no navigation value — legacy anchors that still have those columns keep working, `anchor-sync` matches columns by header name and refreshes Branch/Blocked-by only where the column exists.)*
56
+ Status legend: ⬜ open · 🔄 in progress · ✅ merged #<PR>. **Every slice = one sub-issue** (`#<sub>`). **The volatile Status column is generated by `board-sync.py anchor-sync <anchor#>` from the board** (between the `<!-- slice-table:start/end -->` markers; `wrapup` Step 5e.1 calls it on merge) — monotone (never flips a `✅`/`🔄` back), drift-free idempotent; **stable plan columns (Slice/Gate/refs) stay hand-maintained** and survive verbatim. It appends missing sub-issue rows (gen-b split). **Don't delete the markers** — without them `anchor-sync` can't locate the table (the first run locates it via the `Status`+`Sub-Issue` header row and sets the markers itself). The native "Sub-issues progress" rollup is the secondary %-view. *(Slimmed in: Branch is derivable from the `feat/<#>-<slug>` convention, provider-neutral routing intent lives in the leaf's handoff, Backend? carried no navigation value — legacy anchors that still have those columns keep working, `anchor-sync` matches columns by header name and refreshes Branch/Blocked-by only where the column exists.)*
57
57
 
58
58
  **Gate legend (retro):** `—` AFK build (`/implement`) · 🧭 design grill (`grill-with-docs-codex`, ADR) · 🔬 verify spike (read-only fact question) · 📐 trade-off/research (read-only, below grill threshold) · 📝 review note (not a build slice). A gate slice (🧭/🔬/📐) sits **before** its dependent build slice (gate-before-build) and blocks it.
59
59
 
@@ -324,7 +324,7 @@
324
324
  <span class="station st-program">grill</span><span class="arrow">→</span>
325
325
  <span class="station st-program">Program document</span><span class="arrow">→</span>
326
326
  <span class="station st-gate">preview gate</span><span class="arrow">→</span>
327
- <span class="station st-wave">to-waves</span><span class="arrow">→</span>
327
+ <span class="station st-wave">to-issues · Program mode</span><span class="arrow">→</span>
328
328
  <span class="station st-phase">phases stamped</span><span class="arrow">+</span>
329
329
  <span class="station st-wave">Wave 1…n + slices</span>
330
330
  </div>
@@ -49,7 +49,7 @@
49
49
 
50
50
  <!-- top branch: planned top-down -->
51
51
  <rect x="350" y="350" width="260" height="40" rx="7" fill="#2E3850" stroke="B2" stroke-width="1.2"/>
52
- <text x="480" y="375" font-size="12.5" fill="#8FA9C4" text-anchor="middle">scale-check → grill → to-waves</text>
52
+ <text x="480" y="375" font-size="12.5" fill="#8FA9C4" text-anchor="middle">scale-check → grill → to-issues</text>
53
53
  <line x1="480" y1="390" x2="553" y2="430" stroke="#8A95A6" stroke-width="1.4" marker-end="url(#arrow)"/>
54
54
 
55
55
  <!-- main spine -->
@@ -181,8 +181,8 @@
181
181
  <div class="sw-track" style="--lbg: var(--plan-bg);">
182
182
  <span class="station st-plan">scale-check</span><span class="arrow">→</span>
183
183
  <span class="station st-gate">grill</span><span class="arrow">→</span>
184
- <span class="station st-plan">to-waves</span>
185
- <span class="decision">preview gate · zero board writes until yes</span><span class="arrow">→</span>
184
+ <span class="station st-plan">to-issues</span>
185
+ <span class="decision">Program identity · preview gate · zero writes until yes</span><span class="arrow">→</span>
186
186
  <span class="station st-exec">orchestrate-wave</span>
187
187
  <span class="decision">per wave, often AFK</span><span class="arrow">→</span>
188
188
  <span class="station st-land">wrapup</span><span class="arrow">→</span>
package/docs/workflow.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
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": {
@@ -4,6 +4,7 @@ set -u
4
4
  SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
5
  PROC_HELPER="$SCRIPT_DIR/codex_proc.py"
6
6
  TESTED_VERSIONS=("0.137.0" "0.144.6")
7
+ SUPPORTED_EFFORTS=("low" "medium" "high" "xhigh" "max" "ultra")
7
8
  STATE_ROOT=${CODEX_EXEC_STATE_ROOT:-${TMPDIR:-/tmp}/codex-exec-state}
8
9
 
9
10
  emit_json() {
@@ -118,10 +119,10 @@ preflight() {
118
119
  }
119
120
 
120
121
  parse_options() {
121
- CODEX_BIN=codex PROFILE= MODE= SANDBOX= PROMPT= PROMPT_FILE= RUN_ID= TIMEOUT= PROBE_TIMEOUT= DEBUG_RETAIN=false
122
+ CODEX_BIN=codex PROFILE= MODE= SANDBOX= PROMPT= PROMPT_FILE= RUN_ID= TIMEOUT= PROBE_TIMEOUT= MODEL= EFFORT= DEBUG_RETAIN=false
122
123
  while (($#)); do
123
124
  case $1 in
124
- --codex-bin|--profile|--mode|--prompt|--prompt-file|--run-id|--timeout|--probe-timeout)
125
+ --codex-bin|--profile|--mode|--prompt|--prompt-file|--run-id|--timeout|--probe-timeout|--model|--effort)
125
126
  (($# >= 2)) || { fail INVALID_ARGUMENT "Missing value for $1"; return 1; }
126
127
  case $1 in
127
128
  --codex-bin) CODEX_BIN=$2 ;;
@@ -132,6 +133,8 @@ parse_options() {
132
133
  --run-id) RUN_ID=$2 ;;
133
134
  --timeout) TIMEOUT=$2 ;;
134
135
  --probe-timeout) PROBE_TIMEOUT=$2 ;;
136
+ --model) MODEL=$2 ;;
137
+ --effort) EFFORT=$2 ;;
135
138
  esac
136
139
  shift 2 ;;
137
140
  --debug-retain) DEBUG_RETAIN=true; shift ;;
@@ -140,6 +143,22 @@ parse_options() {
140
143
  done
141
144
  }
142
145
 
146
+ validate_route_controls() {
147
+ if [[ -z $MODEL && -z $EFFORT ]]; then
148
+ return 0
149
+ fi
150
+ local effort_supported=false supported
151
+ for supported in "${SUPPORTED_EFFORTS[@]}"; do
152
+ [[ $EFFORT == "$supported" ]] && effort_supported=true
153
+ done
154
+ if [[ -z $MODEL || -z $EFFORT \
155
+ || ! $MODEL =~ ^[A-Za-z0-9._:-]+$ \
156
+ || $effort_supported != true ]]; then
157
+ fail INVALID_ROUTE_CONTROL "Model and effort must be supplied together from the exact safe vocabulary"
158
+ return 1
159
+ fi
160
+ }
161
+
143
162
  prepare_prompt() {
144
163
  local state_dir=$1
145
164
  TEMP_PROMPT="$state_dir/.prompt-input"
@@ -160,10 +179,14 @@ launch_round() {
160
179
  prepare_prompt "$state_dir" || return 1
161
180
  local command
162
181
  if [[ -z $thread_id ]]; then
163
- command=("$CODEX_BIN" exec --json --sandbox "$SANDBOX" -)
182
+ command=("$CODEX_BIN" exec --json --sandbox "$SANDBOX")
164
183
  else
165
- command=("$CODEX_BIN" exec resume "$thread_id" -c "sandbox_mode=$SANDBOX" --json -)
184
+ command=("$CODEX_BIN" exec resume "$thread_id" -c "sandbox_mode=$SANDBOX")
166
185
  fi
186
+ if [[ -n $MODEL ]]; then
187
+ command+=(-c "model=$MODEL" -c "model_reasoning_effort=$EFFORT")
188
+ fi
189
+ command+=(--json -)
167
190
  python3 "$PROC_HELPER" run --state-dir "$state_dir" --round "$round" \
168
191
  --profile "$PROFILE" --sandbox "$SANDBOX" --timeout "$TIMEOUT" \
169
192
  --probe-timeout "$PROBE_TIMEOUT" --prompt-file "$TEMP_PROMPT" \
@@ -252,6 +275,7 @@ PY
252
275
 
253
276
  new_run() {
254
277
  parse_options "$@" || return 1
278
+ validate_route_controls || return 1
255
279
  PROFILE=${PROFILE:-review}
256
280
  MODE=${MODE:-read-only}
257
281
  [[ $PROFILE == review || $PROFILE == build ]] || { fail INVALID_PROFILE "Profile must be review or build"; return 1; }
@@ -285,15 +309,18 @@ new_run() {
285
309
  state_dir=$(mktemp -d "$STATE_ROOT/codex-exec.XXXXXXXX") || return 1
286
310
  chmod 700 "$state_dir"
287
311
  run_id=${state_dir##*.}
288
- printf '%s\n' "$run_id" >"$state_dir/run-id"
289
312
  printf '%s\n' "$PROFILE" >"$state_dir/profile"
290
313
  printf '%s\n' "$SANDBOX" >"$state_dir/sandbox"
314
+ printf '%s\n' "$MODEL" >"$state_dir/model"
315
+ printf '%s\n' "$EFFORT" >"$state_dir/effort"
291
316
  printf '1\n' >"$state_dir/next-round"
292
317
  chmod 600 "$state_dir"/*
293
318
  lease_token=$(acquire_lease "$state_dir") || {
294
319
  fail ACTIVE_RUN "Run state is already owned by another lifecycle action"
295
320
  return 1
296
321
  }
322
+ printf '%s\n' "$run_id" >"$state_dir/run-id"
323
+ chmod 600 "$state_dir/run-id"
297
324
  output=$(launch_round "$state_dir" 1 "$lease_token")
298
325
  rc=$?
299
326
  release_or_fail "$state_dir" "$lease_token" "$run_id" "$output" || return 1
@@ -307,16 +334,20 @@ new_run() {
307
334
  }
308
335
 
309
336
  resume_run_locked() {
310
- local state_dir=$1 lease_token=$2 stored_profile stored_sandbox thread_id round
337
+ local state_dir=$1 lease_token=$2 stored_profile stored_sandbox stored_model stored_effort thread_id round
311
338
  [[ ! -f $state_dir/debug-retain ]] || {
312
339
  fail RUN_FINALIZED "Run was finalized with debug retention"; return 1;
313
340
  }
314
341
  [[ -f $state_dir/profile && ! -L $state_dir/profile \
315
- && -f $state_dir/sandbox && ! -L $state_dir/sandbox ]] || {
316
- fail INVALID_STATE "Persisted profile or sandbox state is missing or unsafe"; return 1;
342
+ && -f $state_dir/sandbox && ! -L $state_dir/sandbox \
343
+ && -f $state_dir/model && ! -L $state_dir/model \
344
+ && -f $state_dir/effort && ! -L $state_dir/effort ]] || {
345
+ fail INVALID_STATE "Persisted profile, sandbox, or route controls are missing or unsafe"; return 1;
317
346
  }
318
347
  stored_profile=$(<"$state_dir/profile")
319
348
  stored_sandbox=$(<"$state_dir/sandbox")
349
+ stored_model=$(<"$state_dir/model")
350
+ stored_effort=$(<"$state_dir/effort")
320
351
  [[ $stored_profile == review || $stored_profile == build ]] || {
321
352
  fail INVALID_STATE "Persisted profile is not in the exact allowlist"; return 1;
322
353
  }
@@ -328,6 +359,13 @@ resume_run_locked() {
328
359
  fail MODE_MISMATCH "Resume mode differs from persisted mode"
329
360
  return 1
330
361
  fi
362
+ if [[ -n $MODEL && $MODEL != "$stored_model" ]] || [[ -n $EFFORT && $EFFORT != "$stored_effort" ]]; then
363
+ fail ROUTE_CONTROL_MISMATCH "Resume model or effort differs from the persisted applied route"
364
+ return 1
365
+ fi
366
+ MODEL=$stored_model
367
+ EFFORT=$stored_effort
368
+ validate_route_controls || return 1
331
369
  SANDBOX=$stored_sandbox
332
370
  [[ -f $state_dir/thread-id ]] || { fail NO_THREAD "Run has no resumable thread" NO-THREAD; return 1; }
333
371
  thread_id=$(<"$state_dir/thread-id")
@@ -346,6 +384,7 @@ resume_run_locked() {
346
384
 
347
385
  resume_run() {
348
386
  parse_options "$@" || return 1
387
+ validate_route_controls || return 1
349
388
  [[ -n $RUN_ID ]] || { fail RUN_ID_REQUIRED "Resume requires --run-id"; return 1; }
350
389
  local state_dir lease_token rc output
351
390
  state_dir=$(find_state "$RUN_ID") || { fail RUN_NOT_FOUND "Run state does not exist"; return 1; }
@@ -143,6 +143,62 @@ test('invalid timeout and mode inputs fail before launch', () => {
143
143
  assert.equal(exists(fx.launchLog), false);
144
144
  });
145
145
 
146
+ test('approved route passes explicit model and effort controls to Codex', () => {
147
+ const fx = fixture();
148
+ const result = invoke(fx, [
149
+ ...launchArgs(),
150
+ '--model', 'coding-model',
151
+ '--effort', 'high',
152
+ ]);
153
+ assert.equal(result.output.status, 'OK');
154
+ const command = JSON.parse(readFileSync(fx.launchLog, 'utf8').trim());
155
+ assert.ok(command.includes('model=coding-model'));
156
+ assert.ok(command.includes('model_reasoning_effort=high'));
157
+
158
+ const resumed = invoke(fx, [
159
+ 'resume', result.output.runId, '--codex-bin', fake,
160
+ '--prompt', 'Again', '--timeout', '2',
161
+ ]);
162
+ assert.equal(resumed.output.status, 'OK');
163
+ const resumeCommand = JSON.parse(readFileSync(fx.launchLog, 'utf8').trim().split('\n').at(-1));
164
+ assert.ok(resumeCommand.includes('model=coding-model'));
165
+ assert.ok(resumeCommand.includes('model_reasoning_effort=high'));
166
+
167
+ const mismatch = invoke(fx, [
168
+ 'resume', result.output.runId, '--codex-bin', fake,
169
+ '--model', 'other-model', '--effort', 'high', '--prompt', 'No',
170
+ ]);
171
+ assert.equal(mismatch.output.error, 'ROUTE_CONTROL_MISMATCH');
172
+ });
173
+
174
+ test('route controls fail closed when incomplete or unsafe', () => {
175
+ for (const args of [
176
+ [...launchArgs(), '--model', 'coding-model'],
177
+ [...launchArgs(), '--effort', 'high'],
178
+ [...launchArgs(), '--model', 'unsafe value', '--effort', 'high'],
179
+ [...launchArgs(), '--model', 'coding-model', '--effort', 'unknown'],
180
+ ]) {
181
+ const fx = fixture();
182
+ const result = invoke(fx, args);
183
+ assert.equal(result.output.error, 'INVALID_ROUTE_CONTROL');
184
+ assert.equal(exists(fx.launchLog), false);
185
+ }
186
+ });
187
+
188
+ test('current extended reasoning efforts are passed through explicitly', () => {
189
+ for (const effort of ['max', 'ultra']) {
190
+ const fx = fixture();
191
+ const result = invoke(fx, [
192
+ ...launchArgs(),
193
+ '--model', 'coding-model',
194
+ '--effort', effort,
195
+ ]);
196
+ assert.equal(result.output.status, 'OK', effort);
197
+ const command = JSON.parse(readFileSync(fx.launchLog, 'utf8').trim());
198
+ assert.ok(command.includes(`model_reasoning_effort=${effort}`));
199
+ }
200
+ });
201
+
146
202
  test('finalize deletes state, debug-retain preserves diagnostics, and finalized resume fails', () => {
147
203
  const fx = fixture();
148
204
  const run = invoke(fx, launchArgs()).output;
@@ -1,12 +1,170 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { execFile } from 'node:child_process';
3
4
  import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { dirname, join } from 'node:path';
7
+ import { promisify } from 'node:util';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import { nextVersion, prepareRelease } from './kit-release.mjs';
8
10
 
9
11
  const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+ const exec = promisify(execFile);
13
+
14
+ async function releaseWorkflow() {
15
+ return readFile(join(REPO, '.github/workflows/release.yml'), 'utf8');
16
+ }
17
+
18
+ function workflowRunScript(workflow, stepName) {
19
+ const lines = workflow.split('\n');
20
+ const nameIndex = lines.findIndex((line) => line.trim() === `- name: ${stepName}`);
21
+ assert.notEqual(nameIndex, -1, `workflow step not found: ${stepName}`);
22
+ const runIndex = lines.findIndex(
23
+ (line, index) => index > nameIndex && line.trim() === 'run: |',
24
+ );
25
+ assert.notEqual(runIndex, -1, `run block not found for workflow step: ${stepName}`);
26
+ const indent = lines[runIndex].match(/^\s*/)[0].length + 2;
27
+ const body = [];
28
+ for (const line of lines.slice(runIndex + 1)) {
29
+ if (line.trim() && line.match(/^\s*/)[0].length < indent) break;
30
+ body.push(line.slice(indent));
31
+ }
32
+ return body.join('\n');
33
+ }
34
+
35
+ async function git(runCwd, args, options = {}) {
36
+ return exec('git', args, { cwd: runCwd, ...options });
37
+ }
38
+
39
+ async function releaseIntentFixture({
40
+ packageVersion = '1.2.3',
41
+ annotated = true,
42
+ onMain = true,
43
+ mainAhead = false,
44
+ } = {}) {
45
+ const root = await mkdtemp(join(tmpdir(), 'kit-release-intent-'));
46
+ const origin = join(root, 'origin.git');
47
+ const repo = join(root, 'repo');
48
+ await git(root, ['init', '--bare', origin]);
49
+ await git(root, ['init', '--initial-branch=main', repo]);
50
+ await git(repo, ['config', 'user.name', 'Release Test']);
51
+ await git(repo, ['config', 'user.email', 'release-test@example.invalid']);
52
+ await writeFile(join(repo, 'package.json'), `${JSON.stringify({
53
+ name: '@ikon85/agent-workflow-kit',
54
+ version: packageVersion,
55
+ }, null, 2)}\n`);
56
+ await git(repo, ['add', 'package.json']);
57
+ await git(repo, ['commit', '-m', 'release fixture']);
58
+ await git(repo, ['remote', 'add', 'origin', origin]);
59
+ await git(repo, ['push', '-u', 'origin', 'main']);
60
+ if (!onMain) {
61
+ await git(repo, ['checkout', '-b', 'not-main']);
62
+ await writeFile(join(repo, 'outside-main.txt'), 'not canonical\n');
63
+ await git(repo, ['add', 'outside-main.txt']);
64
+ await git(repo, ['commit', '-m', 'outside main']);
65
+ }
66
+ const tag = 'v1.2.3';
67
+ await git(repo, annotated
68
+ ? ['tag', '-a', tag, '-m', `Release ${tag}`]
69
+ : ['tag', tag]);
70
+ if (mainAhead) {
71
+ await writeFile(join(repo, 'after-release.txt'), 'main moved\n');
72
+ await git(repo, ['add', 'after-release.txt']);
73
+ await git(repo, ['commit', '-m', 'move canonical main']);
74
+ await git(repo, ['push', 'origin', 'main']);
75
+ }
76
+ return { root, repo, tag };
77
+ }
78
+
79
+ async function validateReleaseIntent(fixture, releaseTag = fixture.tag) {
80
+ const workflow = await releaseWorkflow();
81
+ const script = workflowRunScript(workflow, 'Validate release intent');
82
+ const output = join(fixture.root, 'github-output');
83
+ try {
84
+ const result = await exec('bash', ['-euo', 'pipefail', '-c', script], {
85
+ cwd: fixture.repo,
86
+ env: {
87
+ ...process.env,
88
+ GITHUB_OUTPUT: output,
89
+ RELEASE_TAG: releaseTag,
90
+ },
91
+ });
92
+ return { ...result, output: await readFile(output, 'utf8') };
93
+ } catch (error) {
94
+ return { error, stderr: error.stderr ?? '', stdout: error.stdout ?? '' };
95
+ }
96
+ }
97
+
98
+ test('release workflow treats an annotated version tag as normal intent and merge as integration only', async () => {
99
+ const workflow = await releaseWorkflow();
100
+ assert.match(workflow, /push:\s*\n\s+tags:\s*\n\s+- ['"]v\*\.\*\.\*['"]/);
101
+ assert.doesNotMatch(workflow, /push:\s*\n\s+branches:\s*\[main\]/);
102
+ assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+tag:/);
103
+ assert.match(workflow, /tag:[\s\S]*?required:\s*true/);
104
+ assert.match(
105
+ workflow,
106
+ /group:\s*release-\$\{\{ github\.event_name == 'workflow_dispatch' && inputs\.tag \|\| github\.ref_name \}\}/,
107
+ );
108
+ assert.doesNotMatch(workflow, /git diff --name-only[\s\S]*package\.json/);
109
+ assert.ok(
110
+ workflow.indexOf('- name: Validate release intent')
111
+ < workflow.indexOf('node scripts/release-state.mjs'),
112
+ 'release intent must be validated before the reconciler can publish',
113
+ );
114
+ assert.equal(workflow.match(/node scripts\/release-state\.mjs/g)?.length, 1);
115
+ });
116
+
117
+ test('the same pre-publish validator accepts a matching annotated tag', async () => {
118
+ const fixture = await releaseIntentFixture();
119
+ try {
120
+ const result = await validateReleaseIntent(fixture);
121
+ assert.equal(result.error, undefined, result.stderr);
122
+ assert.match(result.output, /^tag=v1\.2\.3$/m);
123
+ assert.match(result.output, /^commit=[0-9a-f]{40}$/m);
124
+ } finally {
125
+ await rm(fixture.root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ for (const scenario of [
130
+ {
131
+ name: 'missing manual recovery tag',
132
+ options: {},
133
+ tag: 'v9.9.9',
134
+ error: /does not exist/,
135
+ },
136
+ {
137
+ name: 'tag/package version mismatch',
138
+ options: { packageVersion: '1.2.4' },
139
+ error: /does not match package version/,
140
+ },
141
+ {
142
+ name: 'lightweight tag',
143
+ options: { annotated: false },
144
+ error: /must be annotated/,
145
+ },
146
+ {
147
+ name: 'tag outside canonical main ancestry',
148
+ options: { onMain: false },
149
+ error: /not an ancestor of origin\/main/,
150
+ },
151
+ {
152
+ name: 'stale canonical main commit with the same package version',
153
+ options: { mainAhead: true },
154
+ error: /does not identify the current origin\/main commit/,
155
+ },
156
+ ]) {
157
+ test(`the pre-publish validator rejects a ${scenario.name}`, async () => {
158
+ const fixture = await releaseIntentFixture(scenario.options);
159
+ try {
160
+ const result = await validateReleaseIntent(fixture, scenario.tag);
161
+ assert.ok(result.error, 'validation unexpectedly succeeded');
162
+ assert.match(`${result.stderr}\n${result.stdout}`, scenario.error);
163
+ } finally {
164
+ await rm(fixture.root, { recursive: true, force: true });
165
+ }
166
+ });
167
+ }
10
168
 
11
169
  test('the install manifest ships both release primitives named by the skill', async () => {
12
170
  const manifest = JSON.parse(await readFile(join(REPO, 'agent-workflow-kit.package.json')));
@@ -20,11 +178,31 @@ test('the install manifest ships both release primitives named by the skill', as
20
178
  test('both release skill surfaces name only the owned scoped npm package', async () => {
21
179
  const claude = await readFile(join(REPO, '.claude/skills/kit-release/SKILL.md'), 'utf8');
22
180
  const codex = await readFile(join(REPO, '.agents/skills/kit-release/SKILL.md'), 'utf8');
181
+ assert.equal(codex, claude);
23
182
  for (const body of [claude, codex]) {
24
183
  assert.match(body, /`@ikon85\/agent-workflow-kit`/);
25
184
  }
26
185
  });
27
186
 
187
+ test('maintainer docs and the accepted ADR agree that merge integrates and an annotated tag publishes', async () => {
188
+ const paths = [
189
+ 'CLAUDE.md',
190
+ 'AGENTS.md',
191
+ 'README.md',
192
+ '.claude/skills/kit-release/SKILL.md',
193
+ '.agents/skills/kit-release/SKILL.md',
194
+ 'docs/adr/0004-release-intent-is-a-version-tag.md',
195
+ ];
196
+ const bodies = await Promise.all(
197
+ paths.map((path) => readFile(join(REPO, path), 'utf8')),
198
+ );
199
+ for (const [index, body] of bodies.entries()) {
200
+ assert.match(body, /annotated\s+[`*]?v<version>[`*]?\s+tag/i, paths[index]);
201
+ assert.match(body, /integrat/i, paths[index]);
202
+ }
203
+ assert.match(bodies.at(-1), /Status: accepted \(2026-07-22, issue #204\)/);
204
+ });
205
+
28
206
  test('patch, minor, and major confirmations select exactly one target version', () => {
29
207
  assert.equal(nextVersion('1.2.3', 'patch'), '1.2.4');
30
208
  assert.equal(nextVersion('1.2.3', 'minor'), '1.3.0');