@ikon85/agent-workflow-kit 0.33.0 → 0.34.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.
Files changed (79) 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-update/SKILL.md +13 -0
  10. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  11. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  12. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  13. package/.agents/skills/research/SKILL.md +6 -0
  14. package/.agents/skills/scale-check/SKILL.md +9 -7
  15. package/.agents/skills/setup-workflow/SKILL.md +47 -1
  16. package/.agents/skills/setup-workflow/board-sync.md +7 -2
  17. package/.agents/skills/setup-workflow/workflow-overview.md +1 -2
  18. package/.agents/skills/to-issues/SKILL.md +66 -8
  19. package/.agents/skills/to-waves/SKILL.md +24 -12
  20. package/.claude/skills/ask-matt/SKILL.md +4 -3
  21. package/.claude/skills/audit-skills/SKILL.md +6 -0
  22. package/.claude/skills/board-to-waves/SKILL.md +6 -2
  23. package/.claude/skills/code-review/SKILL.md +6 -0
  24. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +8 -0
  25. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  26. package/.claude/skills/improve-codebase-architecture/SKILL.md +6 -0
  27. package/.claude/skills/kit-update/SKILL.md +13 -0
  28. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  29. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  30. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  31. package/.claude/skills/research/SKILL.md +6 -0
  32. package/.claude/skills/scale-check/SKILL.md +9 -7
  33. package/.claude/skills/setup-workflow/SKILL.md +47 -1
  34. package/.claude/skills/setup-workflow/board-sync.md +7 -2
  35. package/.claude/skills/setup-workflow/workflow-overview.md +1 -2
  36. package/.claude/skills/to-issues/SKILL.md +66 -8
  37. package/.claude/skills/to-waves/SKILL.md +24 -12
  38. package/README.md +79 -11
  39. package/agent-workflow-kit.package.json +201 -41
  40. package/docs/adr/0005-to-issues-is-the-planning-facade.md +42 -0
  41. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +91 -0
  42. package/docs/agents/skills/local-ci.md +89 -0
  43. package/docs/agents/wave-anchor-template.md +2 -2
  44. package/docs/methodology.html +1 -1
  45. package/docs/methodology.svg +1 -1
  46. package/docs/workflow.html +2 -2
  47. package/docs/workflow.png +0 -0
  48. package/package.json +1 -1
  49. package/scripts/codex-exec.sh +47 -8
  50. package/scripts/codex-exec.test.mjs +56 -0
  51. package/scripts/test_codex_adapter_sync_contract.py +30 -15
  52. package/scripts/test_orchestrate_wave_contract.py +73 -0
  53. package/scripts/test_program_planning_contract.py +70 -0
  54. package/scripts/test_skill_portability_lint.py +54 -0
  55. package/src/cli.mjs +109 -2
  56. package/src/commands/init.mjs +17 -1
  57. package/src/commands/routing-policy-update.mjs +204 -0
  58. package/src/commands/update.mjs +22 -0
  59. package/src/lib/agentSurfaceRegistry.mjs +60 -0
  60. package/src/lib/bundle.mjs +24 -0
  61. package/src/lib/capabilityMatrix.mjs +85 -0
  62. package/src/lib/dispatchReceipt.mjs +162 -0
  63. package/src/lib/frontendWorkloads.mjs +170 -0
  64. package/src/lib/routeDispatcher.mjs +158 -0
  65. package/src/lib/routingAccessGraph.mjs +105 -0
  66. package/src/lib/routingAdapters/claude.mjs +62 -0
  67. package/src/lib/routingAdapters/codex.mjs +136 -0
  68. package/src/lib/routingCatalog.mjs +123 -0
  69. package/src/lib/routingEvidenceCache.mjs +222 -0
  70. package/src/lib/routingIntent.mjs +93 -0
  71. package/src/lib/routingPolicy.mjs +67 -0
  72. package/src/lib/routingProfile.mjs +334 -0
  73. package/src/lib/routingResolver.mjs +176 -0
  74. package/src/lib/routingSources/artificialAnalysis.mjs +129 -0
  75. package/src/lib/routingSources/benchlm.mjs +151 -0
  76. package/src/lib/routingSources/codeArena.mjs +162 -0
  77. package/src/lib/routingSources/deepswe.mjs +106 -0
  78. package/src/lib/routingSources/openhands.mjs +102 -0
  79. package/src/lib/routingSources/openhandsFrontend.mjs +135 -0
@@ -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.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": {
@@ -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;
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  REPO_ROOT = Path(__file__).resolve().parent.parent
10
10
  ADAPTER = REPO_ROOT / ".agents/skills/codex-adapter-sync/SKILL.md"
11
+ AGENTS_README = REPO_ROOT / ".codex/agents/README.md"
11
12
  _FRONTMATTER_SPEC = importlib.util.spec_from_file_location(
12
13
  "skill_frontmatter_lint", REPO_ROOT / "scripts/test_skill_frontmatter_lint.py")
13
14
  frontmatter = importlib.util.module_from_spec(_FRONTMATTER_SPEC)
@@ -39,27 +40,24 @@ class AdapterModesContract(unittest.TestCase):
39
40
  self.assertNotIn("before inventory or edits", self.body)
40
41
 
41
42
 
42
- class AdapterModelContract(unittest.TestCase):
43
+ class AdapterRoutingContract(unittest.TestCase):
43
44
  @classmethod
44
45
  def setUpClass(cls):
45
46
  cls.body = ADAPTER.read_text(encoding="utf-8")
46
47
 
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)
48
+ def test_durable_routing_is_provider_neutral_and_resolved_at_dispatch(self):
49
+ routing = " ".join(section(self.body, "Routing intent").split())
50
+
51
+ self.assertIn("`routing-intent`", routing)
52
+ self.assertIn("`reasoning-intent`", routing)
53
+ self.assertIn("Evidence catalog", routing)
54
+ self.assertIn("Access graph", routing)
55
+ self.assertIn("Routing policy", routing)
56
+ self.assertIn("dispatch time", routing)
57
+ self.assertIn("explicit `inherit`", routing)
58
+ self.assertIn("Dispatch receipt", routing)
58
59
  self.assertIn("`default`, `worker`, and `explorer`", routing)
59
- self.assertIn("not a fourth concrete variant", routing)
60
60
 
61
- for stale in ("gpt-5.4-mini", "gpt-5.5"):
62
- self.assertNotIn(stale, self.body)
63
61
  self.assertNotRegex(self.body, r"(?<!model_)\breasoning_effort\b")
64
62
  self.assertNotRegex(self.body, r"\bagent_type\b")
65
63
 
@@ -104,6 +102,23 @@ class AdapterAgentContract(unittest.TestCase):
104
102
  self.assertIn("Parse every `.codex/agents/*.toml`", agents)
105
103
  self.assertIn("Reject a file", agents)
106
104
 
105
+ def test_codex_host_inventory_is_dated_and_does_not_invent_spawn_selectors(self):
106
+ inventory = " ".join(
107
+ AGENTS_README.read_text(encoding="utf-8").split()
108
+ )
109
+ for fragment in (
110
+ "2026-07-23",
111
+ "`task_name`",
112
+ "`message`",
113
+ "`fork_turns`",
114
+ "no per-spawn `model`",
115
+ "no per-spawn effort selector",
116
+ "`src/lib/routingAdapters/codex.mjs`",
117
+ "blocks differentiated AFK",
118
+ "Dispatch receipt v2",
119
+ ):
120
+ self.assertIn(fragment, inventory)
121
+
107
122
 
108
123
  class AdapterValidationContract(unittest.TestCase):
109
124
  @classmethod
@@ -34,6 +34,25 @@ CODEX_SUBAGENTS = (
34
34
  REPO / ".agents/skills/orchestrate-wave/references/dispatch-subagents.md"
35
35
  )
36
36
  CODEX_SURFACE = REPO / ".agents/skills/orchestrate-wave"
37
+ EXPECTED_DIRECT_SPAWN_CONTRACTS = (
38
+ "audit-skills/SKILL.md",
39
+ "code-review/SKILL.md",
40
+ "codebase-design/DESIGN-IT-TWICE.md",
41
+ "improve-codebase-architecture/INTERFACE-DESIGN.md",
42
+ "improve-codebase-architecture/SKILL.md",
43
+ "research/SKILL.md",
44
+ )
45
+ DIRECT_SPAWN_PATTERN = re.compile(
46
+ r"Run \*\*one read-only research subagent|"
47
+ r"Run both axes as \*\*parallel sub-agents|"
48
+ r"Spawn 3\+ sub-agents|"
49
+ r"spawn 3\+ sub-agents|"
50
+ r"make 3\+ parallel\s+`spawn_agent` calls|"
51
+ r"Then use the Agent tool|"
52
+ r"use `spawn_agent` with `agent_type: explorer`|"
53
+ r"use `spawn_agent` with only|"
54
+ r"Spin up a \*\*background agent",
55
+ )
37
56
 
38
57
 
39
58
  # Outcome -> fragments whose conjunction proves that portable behavior.
@@ -86,6 +105,15 @@ def markdown_body(text: str) -> str:
86
105
  return text.split("\n---\n", 1)[1]
87
106
 
88
107
 
108
+ def direct_spawn_contracts(surface: str) -> tuple[str, ...]:
109
+ root = REPO / surface
110
+ return tuple(sorted(
111
+ str(path.relative_to(root))
112
+ for path in root.rglob("*.md")
113
+ if DIRECT_SPAWN_PATTERN.search(path.read_text(encoding="utf-8"))
114
+ ))
115
+
116
+
89
117
  class OrchestrateWaveContract(unittest.TestCase):
90
118
  @classmethod
91
119
  def setUpClass(cls):
@@ -176,6 +204,47 @@ class OrchestrateWaveContract(unittest.TestCase):
176
204
  self.assertIn("(b) tier + effort", prose)
177
205
  self.assertIn("Standing rules", prose)
178
206
 
207
+ def test_every_surface_dispatch_crosses_resolver_and_spawn_guard(self):
208
+ prose = " ".join(self.skill.split())
209
+ for fragment in (
210
+ "provider-neutral Routing intent",
211
+ "shared resolver",
212
+ "spawn guard",
213
+ "Claude or Codex adapter",
214
+ "routingAdapters/codex.mjs",
215
+ "Dispatch receipt",
216
+ "requested and applied route",
217
+ "environment precedence",
218
+ "detected transport is not authorization",
219
+ "AFK",
220
+ ):
221
+ self.assertIn(fragment, prose)
222
+
223
+ def test_every_direct_spawn_contract_routes_through_the_shared_guard(self):
224
+ required = (
225
+ "provider-neutral Routing intent",
226
+ "routeDispatcher.mjs",
227
+ "shared spawn guard",
228
+ "Dispatch receipt",
229
+ )
230
+ claude_actual = direct_spawn_contracts(".claude/skills")
231
+ codex_actual = direct_spawn_contracts(".agents/skills")
232
+ self.assertEqual(claude_actual, EXPECTED_DIRECT_SPAWN_CONTRACTS)
233
+ self.assertEqual(codex_actual, EXPECTED_DIRECT_SPAWN_CONTRACTS)
234
+ for relative in claude_actual:
235
+ claude = " ".join(
236
+ (REPO / ".claude/skills" / relative).read_text(encoding="utf-8").split()
237
+ )
238
+ codex = " ".join(
239
+ (REPO / ".agents/skills" / relative).read_text(encoding="utf-8").split()
240
+ )
241
+ with self.subTest(contract=relative):
242
+ for fragment in required:
243
+ self.assertIn(fragment, claude)
244
+ self.assertIn(fragment, codex)
245
+ for unsupported in ("Agent tool", "subagent_type", "agent_type"):
246
+ self.assertNotIn(unsupported, codex)
247
+
179
248
  def test_path_a_reference_locks_the_two_run_dispatch_contract(self):
180
249
  workflow = CLAUDE_WORKFLOW.read_text(encoding="utf-8")
181
250
  for fragment in (
@@ -214,6 +283,10 @@ class OrchestrateWaveContract(unittest.TestCase):
214
283
  "spawn_agents_on_csv",
215
284
  "output_schema",
216
285
  "dormant",
286
+ "task_name`, `message`, and `fork_turns",
287
+ "no model or effort selector",
288
+ "blocks differentiated AFK",
289
+ "routingAdapters/codex.mjs",
217
290
  ):
218
291
  self.assertIn(" ".join(fragment.split()), prose)
219
292
 
@@ -11,6 +11,76 @@ class ProgramPlanningContractTest(unittest.TestCase):
11
11
  def skill(self, name: str) -> str:
12
12
  return (ROOT / ".claude" / "skills" / name / "SKILL.md").read_text()
13
13
 
14
+ def file(self, path: str) -> str:
15
+ return (ROOT / path).read_text()
16
+
17
+ def test_to_issues_is_the_public_planning_facade(self):
18
+ text = self.skill("to-issues")
19
+ self.assertIn("single public Planning facade", text)
20
+ self.assertIn("planning-mode=feature", text)
21
+ self.assertIn("planning-mode=program", text)
22
+ self.assertIn("Missing or contradictory identity", text)
23
+ self.assertIn("Size, prose, and model judgment never select the mode", text)
24
+
25
+ def test_program_identity_delegates_to_the_internal_graph_engine(self):
26
+ issues = self.skill("to-issues")
27
+ waves = self.skill("to-waves")
28
+ self.assertIn("delegate to the internal", issues)
29
+ self.assertIn("`to-waves` graph engine", issues)
30
+ self.assertIn("complete Program preview", issues)
31
+ self.assertIn("Internal Program graph engine", waves)
32
+ self.assertIn("compatibility entrypoint", waves)
33
+ self.assertIn("carries both", waves)
34
+ self.assertIn("never infer Program mode", waves)
35
+
36
+ def test_feature_identity_keeps_the_existing_decomposition_path(self):
37
+ text = self.skill("to-issues")
38
+ self.assertIn("Feature identity", text)
39
+ self.assertIn("PROMOTE", text)
40
+ self.assertIn("ATOMIC", text)
41
+
42
+ def test_normal_routes_name_only_the_planning_facade(self):
43
+ route_files = (
44
+ ".claude/skills/ask-matt/SKILL.md",
45
+ ".claude/skills/scale-check/SKILL.md",
46
+ ".claude/skills/setup-workflow/workflow-overview.md",
47
+ ".claude/skills/board-to-waves/SKILL.md",
48
+ "CLAUDE.md",
49
+ "README.md",
50
+ "docs/workflow.html",
51
+ "docs/methodology.html",
52
+ "docs/methodology.svg",
53
+ )
54
+ for path in route_files:
55
+ with self.subTest(path=path):
56
+ text = self.file(path)
57
+ self.assertIn("to-issues", text)
58
+ self.assertNotIn("→ `to-waves`", text)
59
+ self.assertNotIn("→ to-waves", text)
60
+
61
+ def test_dual_surface_planning_skills_are_coherent(self):
62
+ skill_snippets = (
63
+ ("to-issues", "single public Planning facade"),
64
+ ("to-waves", "Internal Program graph engine"),
65
+ ("ask-matt", "the user never chooses that engine"),
66
+ ("scale-check", "explicit Program identity selects the internal graph path"),
67
+ ("setup-workflow", "explicit Program identity"),
68
+ ("board-to-waves", "not directly to a hidden engine"),
69
+ )
70
+ for skill, snippet in skill_snippets:
71
+ with self.subTest(skill=skill):
72
+ claude = self.file(f".claude/skills/{skill}/SKILL.md")
73
+ codex = self.file(f".agents/skills/{skill}/SKILL.md")
74
+ self.assertIn(snippet, claude)
75
+ self.assertIn(snippet, codex)
76
+
77
+ support_files = ("workflow-overview.md", "board-sync.md")
78
+ for name in support_files:
79
+ with self.subTest(support=name):
80
+ claude = self.file(f".claude/skills/setup-workflow/{name}")
81
+ codex = self.file(f".agents/skills/setup-workflow/{name}")
82
+ self.assertEqual(claude, codex)
83
+
14
84
  def test_to_waves_finishes_with_every_wave_execute_ready_by_default(self):
15
85
  text = self.skill("to-waves")
16
86
  self.assertIn("## 7. Program completion contract", text)