@dzhechkov/skills-feature-adr 1.3.47 → 1.3.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,7 +163,8 @@ The three Codex knobs above are shortcuts. `args.models` is the **general dial**
163
163
  routes each of the 11 pipeline stages to an optimal model. Keys: `{router, requirements, research, adr,
164
164
  ideation, ddd, architecture, plan, code, qe, fleet}`. Each value is a **spec** — Claude
165
165
  `fable|opus|sonnet|haiku`, or Codex `codex` / `codex:<id>` / `codex:<id>:<reasoning>`
166
- (`reasoning ∈ low|medium|high|xhigh`; ids incl. `gpt-5.5`, `gpt-5.6`).
166
+ (`reasoning ∈ low|medium|high|xhigh`; ids incl. `gpt-5.5`, `gpt-5.6`, `gpt-5.6-sol` — the last is the
167
+ default auto-top reviewer; ids are account-specific, so probe with `codex exec -m <id>` before pinning).
167
168
 
168
169
  **Recommended DEFAULT TABLE** (applied only when you opt in — one `args.models` key or any Codex knob
169
170
  turns it on; otherwise every stage is session-inherited, byte-identical to today):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.3.47",
3
+ "version": "1.3.49",
4
4
  "description": "Adaptive Feature Development skill pack for Claude Code — 11-step pipeline with Complexity Router (S/M/L/XL), ADR-driven architecture, 15 agentic-qe skills, multi-agent fleet QE. Supports --full-qe, --full-qe-extended, --with-learning, and --knowledge-extractor modes.",
5
5
  "bin": {
6
6
  "skills-feature-adr": "./bin/cli.js"
@@ -23,7 +23,16 @@ The script lives at `.claude/workflows/feature-adr.js`.
23
23
 
24
24
  The pipeline ALWAYS runs the feature-adr Pattern memory loop **in-process** — non-optional:
25
25
  - **Step 0** recalls learned patterns (`dz recall`) → folds the top ones into requirements/ADR as `{LEARNED_PATTERNS}` + records the recalled count in the live panel (`dz statusline --fa-record`).
26
- - **Step 8** teaches the durable lessons back (`dz teach`) + records the stored count. The loop pays off (recall) and grows (teach) every run.
26
+ - **Step 8** compares candidate lessons against the Step-0 recalled pattern text. If a lesson is already covered, it reinforces the existing pattern (`dz teach --reinforce <id-or-text>`) and records the reinforced count; only genuinely new lessons are taught with `dz teach`. The loop pays off (recall), reinforces under-ranked patterns, and grows only when there is new knowledge.
27
+
28
+ **Canonical brain pin (`args.brain`) — never fragment the loop.** The loop only compounds if Step-0 recall
29
+ and Step-8 teach hit ONE store. Both are pinned to a canonical **brain store** via `args.brain` (default =
30
+ the workspace `REPO`) — the workflow emits `cd <brain> && dz recall/teach … --project <brain>`, so a lesson
31
+ taught from a Step-7 coder that `cd`'d into a target repo still lands in the brain, not that repo's `.dz`.
32
+ Omitting `args.brain` is byte-identical to today for a workspace-CWD run. Override it with a stable absolute
33
+ path when the coder works in a target checkout. Recover an accidentally-fragmented store by exporting from
34
+ the stray repo and merging into the brain: `cd <stray> && dz recall --all --json > /tmp/stray.json` → `dz
35
+ teach --from-json /tmp/stray.json --project <brain>` (exact-text dedup, idempotent).
27
36
 
28
37
  ## Optional Codex routing (opt-in, pre-flight ASK)
29
38
 
@@ -38,7 +47,50 @@ overridable via `args`:
38
47
  | `qeReviewer` | 8 (QE/tests) | `claude` (default) · `codex` · `codex-fallback` | same fallback, for the QE/testing stage |
39
48
  | `codexModel` | — | `auto` (default — Codex self-selects the top available) · or a specific id your account exposes (e.g. `gpt-5.5`, `gpt-5.4`) | which Codex model to use |
40
49
 
41
- The result reports `plannerUsed` / `coderUsed` / `qeReviewerUsed` / `codexModel` so you can see who did what.
50
+ The result reports `plannerUsed` / `coderUsed` / `qeReviewerUsed` / `codexModel` / `modelsUsed` so you can see who did what.
51
+
52
+ ### Per-stage model routing — `args.models` (the general mechanism)
53
+
54
+ `args.models` is an optional per-stage map that routes EACH pipeline stage to an optimal model — one dial
55
+ over the 11 stages `{router, requirements, research, adr, ideation, ddd, architecture, plan, code, qe,
56
+ fleet}`. Each value is a **spec**: Claude `fable|opus|sonnet|haiku`, or Codex `codex` / `codex:<id>` /
57
+ `codex:<id>:<reasoning>` (reasoning ∈ `none|minimal|low|medium|high|xhigh`; ids incl. `gpt-5.5`, `gpt-5.6`). A resolver
58
+ turns the spec into that stage's `agent()` opts (`{model}` for Claude — role `agentType`s preserved;
59
+ `{agentType:'codex:codex-rescue', codexModel, _reasoning}` for Codex).
60
+
61
+ > **`<reasoning>` effort plumbing (honest scope):** the resolved `_reasoning` is RECORDED in
62
+ > `modelsUsed` (the who-did-what report) and is plumbed into each Codex dispatch prompt as an
63
+ > explicit per-call effort hint, e.g. `(If you are the Codex runtime, run at --effort xhigh.)`.
64
+ > The hint is derived from the same resolved opts that render `codex:<id>:<reasoning>`, so the
65
+ > dispatched effort and `modelsUsed` label must match for that call. `~/.codex/config.toml`
66
+ > `model_reasoning_effort` remains the fallback default when no Codex stage runs or no per-call
67
+ > effort hint is emitted.
68
+
69
+ **Recommended DEFAULT TABLE** (applied ONLY when routing is opted into — any one `args.models` key OR any
70
+ Codex knob turns it on; otherwise every stage is session-inherited, byte-identical to today):
71
+
72
+ | router | requirements | research | adr | ideation | ddd | architecture | plan | code | qe | fleet |
73
+ |---|---|---|---|---|---|---|---|---|---|---|
74
+ | `fable` | `sonnet` | `sonnet`¹ | `opus` | `sonnet` | `opus`¹ | `opus` | `sonnet` | coder² | cross-model³ | `sonnet` |
75
+
76
+ ¹ `research` folds into `requirements`, `ddd` into `architecture` (single shared call) — recorded in
77
+ `modelsUsed` but not a separate call. ² `code` defaults to the `coder` knob (Claude `opus` by default).
78
+ ³ `qe` — see below.
79
+
80
+ **Cross-model QE default (load-bearing rationale).** When `args.models.qe` is UNSET, QE auto-routes to the
81
+ **OTHER family than the coder** — the model that writes code must NOT also self-QE; independent cross-model
82
+ review catches what self-review misses (this is the ADR-named safety property with a dedicated test). So
83
+ coder=Codex ⇒ QE=Claude (`opus`); coder=Claude ⇒ QE=Codex (`codex:<top>:high`), falling back to a Claude
84
+ reviewer if Codex is unavailable (never blocks).
85
+
86
+ **Precedence (documented once):** `args.models[stage]` > the legacy `planner`/`coder`/`qeReviewer`/
87
+ `codexModel` knob > `DEFAULT_MODELS[stage]`. The knobs remain shortcuts that fill only unspecified stages;
88
+ `codexModel` seeds the default id for a bare `'codex'` spec. Codex-fallback (Claude-first, Codex on
89
+ limit-null) stays a knob-only behavior; a direct `models.code='codex'` means codex-first.
90
+
91
+ **gpt-5.6-ready:** adding a new Codex id is a DATA-ONLY edit to the `KNOWN_CODEX` allowlist — no new control
92
+ flow. Example: `Workflow({ scriptPath: '.claude/workflows/feature-adr.js', args: { slug, description,
93
+ tier: 'L', models: { code: 'opus', qe: 'codex:gpt-5.6:high' } } })` — Claude codes, Codex independently QEs.
42
94
 
43
95
  **Pre-flight — detect + ASK (do this BEFORE launching a run):**
44
96
  ```bash
@@ -60,6 +112,32 @@ Without this, the Code agent returns null and the run stalls. With `coder: 'code
60
112
  detects the null, logs *"Claude unavailable (limit?) — falling back to Codex auto"*, and finishes
61
113
  the code + tests on Codex — no restart, no lost work.
62
114
 
115
+ **Codex dispatch is decided by the DELIVERABLE, not by the knob (ADR-001, codex-routing-honesty).**
116
+ `codex:codex-rescue` is a fire-and-forget Claude *wrapper*: it dispatches and returns immediately, so its
117
+ return value is a **stub**. That is correct for a stage whose deliverable is a FILE written out-of-band
118
+ **and** which verifies the write landed — `code` (Step-7.5 `git status` poll) and `plan` (requires
119
+ `06_implementation_plan.md` to appear, else it falls back to the Claude planner). For every other stage the
120
+ deliverable **is** the return value, and a stub reads exactly like a clean review. Those stages now dispatch
121
+ through `codex exec` — synchronous, real stdout. The workflow script is sandboxed (no `child_process`), so
122
+ an ordinary Claude agent runs the command and returns Codex's words verbatim: **the agent is the shell.**
123
+
124
+ Three consequences you must know before routing a stage to Codex:
125
+
126
+ - **A QE verdict is parsed, never synthesised.** Empty, whitespace-only, or sentinel output is *not* a
127
+ clean review; text without a grade is *not* a verdict. Either case falls back to a Claude reviewer and
128
+ logs that cross-model QE did **not** happen. The deleted `{grade: 'codex-review', gaps: []}` was the bug.
129
+ - **There is a prompt ceiling.** `codex exec` answers a trivial prompt in ~6 s and a one-line code question
130
+ in ~12 s, but stalls past 280 s on a 55-line payload (MEASURED). Prompts over
131
+ `CODEX_EXEC_PROMPT_CEILING_CHARS` (1200) are not sent; the stage falls back to Claude **loudly**, because
132
+ a silent downgrade to same-model QE is a worse lie than the stub.
133
+ - **The exec agent is a Claude agent.** Under true Claude exhaustion it dies exactly like the wrapper. This
134
+ change makes the review honest; it does **not** make Codex a lifeboat for a Claude limit. The pre-emptive
135
+ usage switch is the limit defence — and it is disarmed while `memory.usage.*` is unset.
136
+
137
+ `safeCodexAgent` demotes an "agent type not found" error to `null` → Claude fallback, and rethrows every
138
+ other error, so real bugs still surface. The model id is **probed once per run**: the allowlist says a name
139
+ is spellable, only a probe says it answers.
140
+
63
141
  **Codex-landed barrier (Step 7.5):** Codex applies edits OUT-OF-BAND via its own runtime, so a naive
64
142
  pipeline runs Step-8 QE before the async write flushes and false-grades *"Step 7 never ran"* (grade D on
65
143
  real, landed code — observed on the goap-ed25519 fix). When Codex was the coder, feature-adr now polls
@@ -80,6 +158,46 @@ Workflow({ scriptPath: '.claude/workflows/feature-adr.js', args: { slug: 'add-x'
80
158
  **Headless Codex login on a VPS (no browser):** `codex login --device-auth` (prints a code + URL you
81
159
  approve on another device) or `printenv OPENAI_API_KEY | codex login --with-api-key`.
82
160
 
161
+ ## Usage-adaptive routing (pre-emptive Codex switch under limit pressure)
162
+
163
+ When routing is opted into, feature-adr probes Claude SESSION (active 5h-block) + WEEKLY (rolling 7d)
164
+ usage at EACH phase boundary via a minimal effort-low agent running `dz usage --json`, and PRE-EMPTIVELY
165
+ switches all remaining stages to Codex when usage is high — BEFORE the phase launches. Three knobs:
166
+
167
+ | Knob (`args.*`) | Default | Effect |
168
+ |---|---|---|
169
+ | `usageAdaptive` | `true` when routing is requested; `false` otherwise | `true` forces it on without other routing; `false` disables ALL probes (byte-identical to today — zero probes, no override) |
170
+ | `usageThreshold` | `70` | the `>=` percent (SESSION or WEEKLY) that triggers the switch (boundary `=70` counts as over) |
171
+ | `usageReasoning` | the `OVERRIDE_REASONING` map | per-stage reasoning under the override (merge over: design/code/plan → `xhigh`; router/qe/fleet → `high`) |
172
+
173
+ **Two-way, hysteretic.** `>= usageThreshold` on EITHER metric ⇒ switch ALL remaining stages to
174
+ `codex:<top>` (`gpt-5.6`-ready via `KNOWN_CODEX`). BOTH metrics back below (positive numbers, not nulls)
175
+ ⇒ RESTORE the normal mix. **Fail-safe asymmetry:** a probe that returns null (the agent DIED — often
176
+ MEANS the limit was hit) fail-safe-switches TO Codex; a value-null (unconfigured limits) flips NOTHING —
177
+ it never sends a fresh run to Codex and never restores an active override (no flapping). A reactive belt
178
+ also flips the override when a stage agent returns null while not already overridden, so the *remaining*
179
+ stages skip the wall.
180
+
181
+ **The wrapper lesson (why pre-emptive, not just reactive).** `codex:codex-rescue` is a Claude wrapper
182
+ subagent — at TRUE exhaustion even the Codex dispatch dies. So the switch MUST happen BEFORE exhaustion,
183
+ which is exactly what the 70% probe does; the reactive belt is a best-effort backstop, not the defense.
184
+
185
+ **Cross-model-QE bounded exception (FR-2.9).** Under the override, coder AND QE are both Codex (the
186
+ cross-model self-QE guard is consciously suspended — a Claude QE reviewer is precisely the agent that
187
+ dies under limit pressure). The ` (usage-switched)` suffix + `usageEvents` keep it auditable; run an
188
+ independent re-QE after limits reset if the switch fired during Step 8.
189
+
190
+ **Config + honesty.** Set `memory.usage.sessionTokenLimit` / `memory.usage.weeklyTokenLimit` in
191
+ `.dz/config.json` (OPTIONAL — absent ⇒ `pct=null`, no switch). Percentages are ESTIMATES from local
192
+ transcript aggregation (no official API); calibrate by scaling a limit by `X/100` when a real limit-hit
193
+ lands at an estimated `X%`. Observability: switched stages carry ` (usage-switched)` in `modelsUsed`;
194
+ both return objects gain `usageEvents` (`[{phase, sessionPct, weeklyPct, action}]`, action ∈ the LOCKED
195
+ 6-value set `switch|restore|keep|fail-safe-switch|reactive-switch|none`) + `usageThreshold`.
196
+
197
+ **Example:** `Workflow({ scriptPath: '.claude/workflows/feature-adr.js', args: { slug, description,
198
+ tier: 'L', models: { code: 'opus' }, usageAdaptive: true, usageThreshold: 70 } })` — Claude codes until
199
+ usage crosses 70%, then every remaining stage runs on Codex until it drops back below.
200
+
83
201
  ## Hybrid checkpoints (router decides)
84
202
 
85
203
  - **S / M** → run autonomously to completion; present a final consolidated review (ADR + plan + QE).
@@ -493,6 +493,12 @@ only the statusline:
493
493
  🎓 Learning: {recalled} patterns recalled for this feature, {stored} new stored this run
494
494
  ```
495
495
 
496
+ **Record the panel at the START of every step, not only at Steps 0/8/9.** The panel shows the last step
497
+ that reported; a pipeline that reports three times per run shows a stale step for most of its life. Emit
498
+ `dz statusline --fa-record --slug <slug> --step "<Step N Name>" --recalled <n> --stored <n>` as the first
499
+ action of each step. The recall/teach counts only change at Steps 0/8/9; the *step label* changes at every
500
+ one of them.
501
+
496
502
  *Honesty note:* the panel is live only insofar as the pipeline records state — it reflects what the
497
503
  pipeline actually did with the loop (recalls that ran, stores that landed), not an aspirational count.
498
504
 
@@ -21,7 +21,65 @@ const STOP_AFTER = A.stopAfter || null
21
21
  // PORTABLE: project root comes from args.repo (default '.', i.e. the cwd the workflow's agents run in),
22
22
  // never a hardcoded path — so this ships inside @dzhechkov/skills-feature-adr and runs in any project.
23
23
  // The monorepo passes args.repo + args.dzBin explicitly to target its dev build.
24
- const REPO = (A.repo || '.').replace(/\/+$/, '')
24
+ // ADR-001 (absolute-artifact-paths): a RELATIVE root means different things to different agents. Once a
25
+ // coder cd's elsewhere, a later agent resolves ./features/<slug>/03_adr against another cwd and reports a
26
+ // confident FALSE BLOCKER. The sandbox has no filesystem and no Node API, so the script cannot call
27
+ // process.cwd(): the absolute root arrives as an absolute args.repo, or from an agent that runs pwd.
28
+ function isAbsolutePosix(p) { return typeof p === 'string' && p.charAt(0) === '/' }
29
+ function normalizeRepoPath(p) {
30
+ const collapsed = String(p).replace(/\/{2,}/g, '/').replace(/\/\.(?=\/|$)/g, '')
31
+ const trimmed = collapsed.replace(/\/+$/, '')
32
+ return trimmed === '' ? '/' : trimmed
33
+ }
34
+ function absolutizeRepo(raw, cwd) {
35
+ const r = (typeof raw === 'string' && raw.length > 0) ? raw : '.'
36
+ if (isAbsolutePosix(r)) return normalizeRepoPath(r)
37
+ const base = normalizeRepoPath(cwd)
38
+ const rel = r.replace(/^\.\/+/, '').replace(/^\.$/, '')
39
+ return rel === '' ? base : normalizeRepoPath(base + '/' + rel)
40
+ }
41
+ const ABSOLUTE_PATH_NOTE = ' All artifact paths in this prompt are ABSOLUTE. Read and write them exactly as given; do not cd first and do not re-relativize them.'
42
+
43
+ const UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f]/
44
+ const DOT_DOT_SEGMENT = /(^|\/)\.\.(\/|$)/
45
+ function hasUnsafePathChars(p) { return UNSAFE_PATH_CHARS.test(String(p)) }
46
+ function hasDotDotSegment(p) { return DOT_DOT_SEGMENT.test(String(p)) }
47
+ function isSafeSlug(slug) { return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(slug) }
48
+ function checkArtifactRoot(root) {
49
+ if (!isAbsolutePosix(root)) return 'artifact root is not absolute: ' + JSON.stringify(root)
50
+ if (hasUnsafePathChars(root)) return 'artifact root contains control characters'
51
+ if (hasDotDotSegment(root)) return 'artifact root contains a ".." segment: ' + root
52
+ return null
53
+ }
54
+ // args.repo may be anything the caller passed; a non-string must not throw on .replace (CM-3).
55
+ function coerceRepoArg(raw) { return (typeof raw === 'string' && raw.length > 0) ? raw.replace(/\/+$/, '') : '.' }
56
+ // pwd output can be chatty; take the LAST line that is actually an absolute path, not the last line (CM-4).
57
+ function pickAbsolutePathLine(text) {
58
+ if (typeof text !== 'string') return null
59
+ const abs = text.split('\n').map(function (l) { return l.trim() }).filter(isAbsolutePosix)
60
+ return abs.length ? abs[abs.length - 1] : null
61
+ }
62
+ const REPO_RAW = coerceRepoArg(A.repo)
63
+ let REPO = REPO_RAW
64
+ if (!isAbsolutePosix(REPO_RAW)) {
65
+ // The agent is the shell (same pattern as codexExecAgent). Two attempts, then STOP.
66
+ let cwd = null
67
+ for (let attempt = 1; attempt <= 2 && !cwd; attempt++) {
68
+ const pwdOut = await agent('Run EXACTLY this via Bash and reply with ONLY the absolute path it prints, nothing else: pwd -P', { label: 'resolve-root:' + attempt, phase: 'Route', model: 'haiku', effort: 'low' })
69
+ cwd = pickAbsolutePathLine(pwdOut === null ? null : String(pwdOut))
70
+ if (!cwd) log('root: attempt ' + attempt + ' could not resolve the absolute root (pwd agent returned no absolute path)')
71
+ }
72
+ // CM-1/CM-2: continuing on a relative root would violate the ADR's load-bearing property and let a
73
+ // downstream agent emit a false BLOCKER. A log is not a guard. Fail fast instead of lying later.
74
+ if (!cwd) throw new Error('feature-adr: could not resolve an absolute artifact root after 2 attempts; refusing to run with a relative root (a downstream agent would report a false BLOCKER). Pass an absolute args.repo.')
75
+ REPO = absolutizeRepo(REPO_RAW, cwd)
76
+ log('root: resolved ' + REPO_RAW + ' -> ' + REPO)
77
+ }
78
+ // Round-2 review: validate the root and the slug BEFORE either is embedded in a prompt. A newline in
79
+ // args.repo splits a path across lines; a '..' slug escapes the features directory.
80
+ const rootProblem = checkArtifactRoot(REPO)
81
+ if (rootProblem) throw new Error('feature-adr: ' + rootProblem + ' - refusing to embed it in agent prompts')
82
+ if (!isSafeSlug(SLUG)) throw new Error('feature-adr: unsafe slug ' + JSON.stringify(SLUG) + ' - kebab-case Latin, max 40 chars (a ".." slug would escape features/)')
25
83
  const FDIR = REPO + '/features/' + SLUG
26
84
  // The dz CLI: bare `dz` (on PATH for installed users) unless the caller overrides with a bin path.
27
85
  const DZ = A.dzBin || 'dz'
@@ -70,7 +128,7 @@ const PLANNER = (A.planner === 'codex') ? 'codex' : 'claude'
70
128
  // (string concat, explicit if/return, object-literal tables — NO template literals, NO inline ?:agent())
71
129
  // mirror of src/feature-adr-routing.ts; keep the two in lock-step (a drift test asserts it).
72
130
  const MODELS = (A.models && typeof A.models === 'object') ? A.models : {}
73
- const KNOWN_CODEX = { 'auto': 1, 'gpt-5.5': 1, 'gpt-5.6': 1 }
131
+ const KNOWN_CODEX = { 'auto': 1, 'gpt-5.5': 1, 'gpt-5.6': 1, 'gpt-5.6-sol': 1 }
74
132
  const CLAUDE_NAMES = { fable: 1, opus: 1, sonnet: 1, haiku: 1 }
75
133
  const VALID_REASONING = { none: 1, minimal: 1, low: 1, medium: 1, high: 1, xhigh: 1 }
76
134
  const DEFAULT_MODELS = { router: 'fable', requirements: 'sonnet', research: 'sonnet', adr: 'opus', ideation: 'sonnet', ddd: 'opus', architecture: 'opus', plan: 'sonnet', code: null, qe: null, fleet: 'sonnet' }
@@ -248,6 +306,102 @@ function stageLabel(baseLabel, opts) {
248
306
  // Codex-QE run, do ZERO extra work (byte-identical to today).
249
307
  function needsLandedBarrier(opts) { return !!(opts && opts.agentType === 'codex:codex-rescue') }
250
308
 
309
+ // ── CODEX DISPATCH BY DELIVERABLE (ADR-001, mirror of feature-adr-routing.ts) ──────────────────
310
+ // codex:codex-rescue is a fire-and-forget Claude WRAPPER: its return value is a STUB. That is fine
311
+ // for a stage whose deliverable is a FILE written out-of-band AND which verifies the write landed
312
+ // (code → Step-7.5 git poll; plan → requires 06_implementation_plan.md to land, else Claude).
313
+ // It is catastrophic for a stage whose deliverable is its RETURN VALUE: a stub reads exactly like a
314
+ // clean review. This workflow is sandboxed (no child_process), so an ordinary Claude agent runs
315
+ // codex exec and returns Codex stdout verbatim — the agent is the shell.
316
+ const WRAPPER_STAGES = { code: 1, plan: 1 }
317
+ function codexDispatchMode(stage) { return WRAPPER_STAGES[stage] ? 'wrapper' : 'exec' }
318
+
319
+ // A SANITY bound on prompt size, NOT a stall guard. The earlier 1200-char ceiling was justified by
320
+ // "codex exec stalls on a 55-line payload"; twin experiments refuted that (2026-07-10): 4000 chars of
321
+ // padding answered in 4s and a 3156-char / 56-line adversarial code review in 14s. The stalls are
322
+ // INTERMITTENT latency — the same input hung at 60s and answered at 14s minutes apart. The real guard
323
+ // is the bounded timeout + the CODEX_UNAVAILABLE sentinel: a slow exec becomes an explicit
324
+ // "unavailable", never a passed review.
325
+ const CODEX_EXEC_PROMPT_CEILING_CHARS = 24000
326
+ const CODEX_UNAVAILABLE = 'CODEX_UNAVAILABLE'
327
+
328
+ function codexExecPlan(stage, promptChars, probedId) {
329
+ if (codexDispatchMode(stage) === 'wrapper') return { mode: 'wrapper', reason: 'deliverable is a landed file' }
330
+ if (!probedId) return { mode: 'claude', reason: 'no codex model id answered the probe' }
331
+ if (promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {
332
+ return { mode: 'claude', reason: 'prompt is ' + promptChars + ' chars, over the ' + CODEX_EXEC_PROMPT_CEILING_CHARS + '-char codex exec ceiling (it would stall)' }
333
+ }
334
+ return { mode: 'exec', reason: 'codex exec on ' + probedId }
335
+ }
336
+
337
+ // A model id is user input (args.codexModel) and lands in a shell command the agent runs. Cross-model
338
+ // review (codex exec, 2026-07-10) found it interpolated unquoted. Plain ids only, quoted anyway.
339
+ function isSafeCodexId(id) { return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(String(id)) }
340
+ function codexProbeCommand(id) {
341
+ if (!isSafeCodexId(id)) return null
342
+ return "timeout 60 codex exec -m '" + id + "' 'Reply with exactly: OK' < /dev/null"
343
+ }
344
+
345
+ // A verdict must NAME its grade. "Looks good" is not a review — cross-model review caught this too.
346
+ function parseCodexGrade(text) {
347
+ const m = /\bgrade\s*[:=]?\s*([A-D])\b/i.exec(String(text))
348
+ return (m && m[1]) ? m[1].toUpperCase() : null
349
+ }
350
+
351
+ // R2, the most dangerous line here: an EMPTY reply must NEVER read as "no findings".
352
+ function parseCodexExecResult(text) {
353
+ const t = (typeof text === 'string') ? text.trim() : ''
354
+ if (t.length === 0) return { ok: false, text: '', reason: 'codex exec returned no text' }
355
+ if (t.indexOf(CODEX_UNAVAILABLE) !== -1) return { ok: false, text: t, reason: 'codex exec reported it could not run' }
356
+ return { ok: true, text: t, reason: 'codex answered' }
357
+ }
358
+
359
+ function isAgentTypeMissingError(err) {
360
+ const msg = (err && err.message) ? err.message : String(err)
361
+ return /agent type .*not found|unknown agent type|no such agent/i.test(msg)
362
+ }
363
+
364
+ // CX-3: a workflow naming an agent type the harness lacks must FALL BACK, not die. Any other error
365
+ // still propagates — we do not hide real bugs behind a fallback.
366
+ async function safeCodexAgent(prompt, opts) {
367
+ try { return await agent(prompt, opts) }
368
+ catch (err) {
369
+ if (isAgentTypeMissingError(err)) { log('codex: agent type unavailable — falling back to Claude (' + String(err) + ')'); return null }
370
+ throw err
371
+ }
372
+ }
373
+
374
+ // CX-1: the allowlist says a name is spellable; only a probe says it answers. One probe per run.
375
+ let _probedCodexId
376
+ async function probeCodexId() {
377
+ if (_probedCodexId !== undefined) return _probedCodexId
378
+ const raw = (CODEX_MODEL && CODEX_MODEL !== 'auto') ? [CODEX_MODEL, 'gpt-5.6-sol', 'gpt-5.5'] : ['gpt-5.6-sol', 'gpt-5.5']
379
+ const ids = raw.filter(isSafeCodexId)
380
+ for (const id of ids) {
381
+ const cmd = codexProbeCommand(id)
382
+ if (!cmd) { log('codex: refusing unsafe model id ' + id); continue }
383
+ const out = await agent('Run EXACTLY this via Bash and reply with its stdout only: ' + cmd + ' — if it fails or times out reply with exactly ' + CODEX_UNAVAILABLE, { label: 'probe:' + id, phase: 'Route', model: 'haiku', effort: 'low' })
384
+ if (out && /\bOK\b/.test(String(out)) && String(out).indexOf(CODEX_UNAVAILABLE) === -1) {
385
+ log('codex: probed ' + id + ' — available'); _probedCodexId = id; return id
386
+ }
387
+ log('codex: probed ' + id + ' — NOT available')
388
+ }
389
+ _probedCodexId = null
390
+ return null
391
+ }
392
+
393
+ // Dispatch a data-returning stage to Codex synchronously. Returns Codex's own words, or null.
394
+ async function codexExecAgent(stage, prompt, phaseName) {
395
+ const probed = await probeCodexId()
396
+ const plan = codexExecPlan(stage, prompt.length, probed)
397
+ if (plan.mode !== 'exec') { log(stage + ': codex not used — ' + plan.reason); return null }
398
+ const wrapped = 'Run EXACTLY this via Bash, with a 280s timeout, and reply with its stdout VERBATIM and nothing else. If it errors, times out, or prints nothing, reply with exactly ' + CODEX_UNAVAILABLE + '.\n\ncodex exec -m ' + probed + ' ' + JSON.stringify(prompt) + ' < /dev/null'
399
+ const raw = await agent(wrapped, { label: stageLabel(stage + ':codex-exec', { agentType: 'codex:codex-rescue', codexModel: probed, _reasoning: 'high' }), phase: phaseName, model: 'haiku', effort: 'low' })
400
+ const parsed = parseCodexExecResult(raw === null ? '' : String(raw))
401
+ if (!parsed.ok) { log(stage + ': codex exec unusable — ' + parsed.reason); return null }
402
+ return parsed.text
403
+ }
404
+
251
405
  const DEFAULT_CODE_LANDING_MAX_WAIT_MS = 120000
252
406
  const DEFAULT_CODE_LANDING_BACKOFF_MS = [1000, 2000, 2000, 5000, 5000, 10000, 10000, 15000, 20000, 25000, 25000]
253
407
  const CODE_LANDED_BARRIER_SLEEPS_SECONDS = DEFAULT_CODE_LANDING_BACKOFF_MS.map((ms) => ms / 1000)
@@ -453,7 +607,7 @@ const design = await parallel(designThunks)
453
607
  // unavailable/errors — the pipeline never blocks on Codex.
454
608
  phase('Plan')
455
609
  await usageProbe('Plan')
456
- const planPrompt = 'Step 6 (SPARC-GOAP implementation plan) of /feature-adr for "' + DESC + '" (' + SLUG + ', tier ' + tier + '). Given the requirements + ADR + architecture in ' + FDIR + ', decompose into milestones + concrete tasks with success metrics. Write ' + FDIR + '/06_implementation_plan.md. Return wrote[] + summary.'
610
+ const planPrompt = 'Step 6 (SPARC-GOAP implementation plan) of /feature-adr for "' + DESC + '" (' + SLUG + ', tier ' + tier + '). Given the requirements + ADR + architecture in ' + FDIR + ', decompose into milestones + concrete tasks with success metrics. Write ' + FDIR + '/06_implementation_plan.md. Return wrote[] + summary.' + ABSOLUTE_PATH_NOTE
457
611
  // Resolve the plan model. args.models.plan wins; else the planner:'codex' knob (via routingRequested +
458
612
  // DEFAULT_MODELS/coder-fold) or the DEFAULT_MODELS.plan ('sonnet') under routing; else {} (BC).
459
613
  const planModel = resolveStageModel('plan')
@@ -463,7 +617,7 @@ if (planIsCodex) {
463
617
  const planCodexLabelOpts = (planModel.agentType === 'codex:codex-rescue') ? planModel : specToOpts('codex:' + CODEX_MODEL + ':high')
464
618
  modelsUsed.plan = modelLabel(planCodexLabelOpts)
465
619
  const codexPlanOpts = mergeOpts({ label: stageLabel('plan:codex', planCodexLabelOpts), phase: 'Plan', agentType: 'codex:codex-rescue' }, planCodexLabelOpts)
466
- const codexPlan = await agent(planPrompt + codexEffortHint(codexPlanOpts) + ' IMPORTANT: run the Codex task in FOREGROUND (synchronous — do NOT pass --background) so this call blocks until 06_implementation_plan.md is fully written to disk.', codexPlanOpts)
620
+ const codexPlan = await safeCodexAgent(planPrompt + codexEffortHint(codexPlanOpts) + ' IMPORTANT: run the Codex task in FOREGROUND (synchronous — do NOT pass --background) so this call blocks until 06_implementation_plan.md is fully written to disk.', codexPlanOpts)
467
621
  // Codex-landed barrier for the plan artifact: a stub return is NOT proof the file was written
468
622
  // (codex writes out-of-band). Require the artifact to LAND; otherwise fall through to the Claude planner.
469
623
  const planLanded = codexPlan ? await agent('Confirm the Codex plan write has LANDED. Run EXACTLY this via Bash and return its stdout verbatim, nothing else:\n' + landedProbeCmd(FDIR + '/06_implementation_plan.md'), { label: 'plan:confirm-landed', phase: 'Plan', effort: 'low' }) : null
@@ -499,7 +653,7 @@ if (stopHere) {
499
653
  // Step 7: Code (optional Codex fallback on Claude-limit exhaustion)
500
654
  phase('Code')
501
655
  await usageProbe('Code')
502
- const codePrompt = 'Step 7 (Code) of /feature-adr for "' + DESC + '" (' + SLUG + '). Implement the feature per the plan + ADR + architecture in ' + FDIR + '. Write the ACTUAL production code + its tests (mirror the closest existing implementation named in research/architecture). Follow repo conventions; build must pass. Write a change manifest ' + FDIR + '/07_code_changes/change_manifest.md listing every file touched. Return wrote[] (incl. real source files) + summary.'
656
+ const codePrompt = 'Step 7 (Code) of /feature-adr for "' + DESC + '" (' + SLUG + '). Implement the feature per the plan + ADR + architecture in ' + FDIR + '. Write the ACTUAL production code + its tests (mirror the closest existing implementation named in research/architecture). Follow repo conventions; build must pass. Write a change manifest ' + FDIR + '/07_code_changes/change_manifest.md listing every file touched. Return wrote[] (incl. real source files) + summary.' + ABSOLUTE_PATH_NOTE
503
657
  // Resolve the coder model. args.models.code wins (a direct 'codex' spec = codex-first); else the legacy
504
658
  // CODER knob drives it (with its codex-fallback null-guard). resolveStageModel('code') folds both via the
505
659
  // code:null sentinel → resolveCoderSpec(). A Claude resolution merges {model} onto the Claude branch;
@@ -521,7 +675,7 @@ if (code === null && (codeIsCodexFirst || CODER === 'codex-fallback')) {
521
675
  const codeCodexLabelOpts = codeModel.agentType ? codeModel : specToOpts('codex:' + CODEX_MODEL + ':high')
522
676
  const codeCodexOpts = mergeOpts({ label: stageLabel('code:codex', codeCodexLabelOpts), phase: 'Code', agentType: 'codex:codex-rescue' }, codeCodexLabelOpts)
523
677
  const codexExpectedTargetsHint = '\n\nBecause this is running on Codex, include a final EXPECTED_CODE_TARGETS: block listing the repo-relative production/test files you expect to create or modify. List only real code/test/config/docs targets outside features/, .dz/, .agentic-qe/, and roam/. Example:\nEXPECTED_CODE_TARGETS:\n- packages/example/src/file.ts\n- packages/example/test/file.test.ts'
524
- const codexCode = await agent(codePrompt + CODEX_HINT + codexEffortHint(codeCodexOpts) + codexExpectedTargetsHint, codeCodexOpts)
678
+ const codexCode = await safeCodexAgent(codePrompt + CODEX_HINT + codexEffortHint(codeCodexOpts) + codexExpectedTargetsHint, codeCodexOpts)
525
679
  if (codexCode) { codexCodeText = String(codexCode); code = { wrote: [FDIR + '/07_code_changes/change_manifest.md'], summary: codexCodeText.slice(0, 500) }; coderUsed = codeIsCodexFirst ? 'codex' : 'codex-fallback'; modelsUsed.code = modelLabel(codeCodexLabelOpts) }
526
680
  }
527
681
 
@@ -541,7 +695,7 @@ if (needsCodeLandedBarrier(coderUsed)) {
541
695
  // Step 8: QE (brutal-honesty, agentic-qe) + MANDATORY teach
542
696
  phase('QE')
543
697
  await usageProbe('QE')
544
- const qePrompt = 'Step 8 (QE - brutal-honesty review, agentic-qe) of /feature-adr for "' + DESC + '" (' + SLUG + '). Adversarially review the SHIPPED code (read it): correctness, edge cases, error handling, and the LOAD-BEARING property the ADR named (ASSERT it has a test - the recurring lesson). Run this ADR gate before final grading: ' + ADR_FITNESS_CHECKLIST + ' Grade A/B/C/D honestly. Assess code-test adequacy + doc-test presence. List CONFIRMED gaps with severity. Write ' + FDIR + '/08_qe_report.md with an ADR Fitness Checklist section showing PASS/FAIL per ADR and evidence for the Confirmation-linked test. MANDATORY SELF-LEARNING STORE (close the loop, never skip): compare every candidate lesson against the Step-0 recalled LEARNED patterns above. Teach ONLY lessons NOT covered by Step-0 recall. On overlap, run `dz teach --reinforce "<recalled pattern id or exact text>" --project ' + BRAIN + '` instead of minting a near-duplicate; if --reinforce is unavailable, skip the duplicate teach and report `reinforced existing pattern <id>` in the QE report. Store every genuinely new lesson in the CANONICAL BRAIN store at `' + BRAIN + '` so it is NOT lost to a target repo you may have cd`d into. Via Bash run EXACTLY `' + DZ_TEACH('<a durable reusable lesson from this feature - a rule/pattern/pitfall, NOT a checkpoint echo>', '<0.7-0.95>', '<area>') + '` for each genuine NEW lesson (1-3 max, high-signal) — the `cd ' + BRAIN + ' &&` prefix + `--project ' + BRAIN + '` pin guarantee the lesson lands in the brain regardless of your CWD. Then run `' + DZ + ' statusline --fa-record --slug ' + SLUG + ' --step "Step 8 QE" --recalled 3 --stored <count taught> --reinforced <count reinforced> --mode ' + MODE + ' --project ' + REPO + '` (run it verbatim via Bash, do not skip). Do NOT teach trivia or invent gaps. AUTHORING-TIME CLAIM-CHECK (Deliverable of claim-check-authoring-time): after writing ' + FDIR + '/08_qe_report.md, run EXACTLY `dz claim-check ' + FDIR + '/08_qe_report.md --json --fail-on none` via Bash, parse the {ok, findings, scanned} JSON, and report claimCheck: {findings: N, high: N, medium: N} (counts by severity) in your return object. TAG EVERY QUANTITATIVE CLAIM you write in the report using the convention the checker recognizes as honest — write "1131 tests pass (MEASURED — `npx vitest run`)", never a bare "1131 tests pass" — and where you QUOTE a forbidden phrase as an example (e.g. the retracted "100% passing" framing), backtick the literal so it reads as code, not an assertion, so your own compliant report scans clean. Return {grade, gaps, codeTestsAdequate, docTestsPresent, claimCheck}.' + landedNote
698
+ const qePrompt = 'Step 8 (QE - brutal-honesty review, agentic-qe) of /feature-adr for "' + DESC + '" (' + SLUG + '). Adversarially review the SHIPPED code (read it): correctness, edge cases, error handling, and the LOAD-BEARING property the ADR named (ASSERT it has a test - the recurring lesson). Run this ADR gate before final grading: ' + ADR_FITNESS_CHECKLIST + ' Grade A/B/C/D honestly. Assess code-test adequacy + doc-test presence. List CONFIRMED gaps with severity. Write ' + FDIR + '/08_qe_report.md with an ADR Fitness Checklist section showing PASS/FAIL per ADR and evidence for the Confirmation-linked test. MANDATORY SELF-LEARNING STORE (close the loop, never skip): compare every candidate lesson against the Step-0 recalled LEARNED patterns above. Teach ONLY lessons NOT covered by Step-0 recall. On overlap, run `dz teach --reinforce "<recalled pattern id or exact text>" --project ' + BRAIN + '` instead of minting a near-duplicate; if --reinforce is unavailable, skip the duplicate teach and report `reinforced existing pattern <id>` in the QE report. Store every genuinely new lesson in the CANONICAL BRAIN store at `' + BRAIN + '` so it is NOT lost to a target repo you may have cd`d into. Via Bash run EXACTLY `' + DZ_TEACH('<a durable reusable lesson from this feature - a rule/pattern/pitfall, NOT a checkpoint echo>', '<0.7-0.95>', '<area>') + '` for each genuine NEW lesson (1-3 max, high-signal) — the `cd ' + BRAIN + ' &&` prefix + `--project ' + BRAIN + '` pin guarantee the lesson lands in the brain regardless of your CWD. Then run `' + DZ + ' statusline --fa-record --slug ' + SLUG + ' --step "Step 8 QE" --recalled 3 --stored <count taught> --reinforced <count reinforced> --mode ' + MODE + ' --project ' + REPO + '` (run it verbatim via Bash, do not skip). Do NOT teach trivia or invent gaps. AUTHORING-TIME CLAIM-CHECK (Deliverable of claim-check-authoring-time): after writing ' + FDIR + '/08_qe_report.md, run EXACTLY `dz claim-check ' + FDIR + '/08_qe_report.md --json --fail-on none` via Bash, parse the {ok, findings, scanned} JSON, and report claimCheck: {findings: N, high: N, medium: N} (counts by severity) in your return object. TAG EVERY QUANTITATIVE CLAIM you write in the report using the convention the checker recognizes as honest — write "1131 tests pass (MEASURED — `npx vitest run`)", never a bare "1131 tests pass" — and where you QUOTE a forbidden phrase as an example (e.g. the retracted "100% passing" framing), backtick the literal so it reads as code, not an assertion, so your own compliant report scans clean. Return {grade, gaps, codeTestsAdequate, docTestsPresent, claimCheck}.' + ABSOLUTE_PATH_NOTE + landedNote
545
699
  // CROSS-MODEL QE (load-bearing): resolveStageModel('qe') derives the OTHER family than the resolved
546
700
  // coder when args.models.qe is unset (coder-codex ⇒ opus; coder-Claude ⇒ codex, or opus if codex absent).
547
701
  // An explicit args.models.qe wins. A Claude qe spec is merged onto the qe-code-reviewer base (role
@@ -564,9 +718,22 @@ if (qe === null && !qeIsCodex) reactiveBelt('QE')
564
718
  if (qe === null && (qeIsCodex || QE_REVIEWER === 'codex-fallback')) {
565
719
  if (QE_REVIEWER === 'codex-fallback' && !qeIsCodex) log('QE: Claude unavailable (limit?) — falling back to Codex ' + CODEX_MODEL)
566
720
  const qeCodexLabelOpts = qeModel.agentType ? qeModel : specToOpts('codex:' + CODEX_MODEL + ':high')
567
- const qeCodexOpts = mergeOpts({ label: stageLabel('qe:codex', qeCodexLabelOpts), phase: 'QE', agentType: 'codex:codex-rescue' }, qeCodexLabelOpts)
568
- const codexQe = await agent(qePrompt + CODEX_HINT + codexEffortHint(qeCodexOpts), qeCodexOpts)
569
- if (codexQe) { qe = { grade: 'codex-review', gaps: [], codeTestsAdequate: null, docTestsPresent: null, summary: String(codexQe).slice(0, 500) }; qeReviewerUsed = qeIsCodex ? 'codex' : 'codex-fallback'; modelsUsed.qe = modelLabel(qeCodexLabelOpts) }
721
+ // ADR-001: QE's deliverable is its RETURN VALUE, so it dispatches through `codex exec` (synchronous,
722
+ // real text), never the fire-and-forget wrapper. And the verdict is PARSED, never synthesised: the
723
+ // deleted `{grade:'codex-review', gaps: []}` turned a stub into a clean review.
724
+ const codexQeText = await codexExecAgent('qe', qePrompt + CODEX_HINT + codexEffortHint(qeCodexLabelOpts), 'QE')
725
+ const codexGrade = codexQeText ? parseCodexGrade(codexQeText) : null
726
+ if (codexQeText && codexGrade) {
727
+ qe = { grade: codexGrade, gaps: [], codeTestsAdequate: null, docTestsPresent: null, summary: codexQeText.slice(0, 1500) }
728
+ qeReviewerUsed = qeIsCodex ? 'codex' : 'codex-fallback'
729
+ modelsUsed.qe = modelLabel(qeCodexLabelOpts)
730
+ } else if (codexQeText) {
731
+ // Text without a grade is not a verdict. Defaulting to 'C' would re-invent the fabrication this
732
+ // ADR deletes, only quieter. Fall through to the Claude belt.
733
+ log('QE: codex answered but named no grade — not a verdict; falling back to a Claude reviewer')
734
+ } else {
735
+ log('QE: codex produced no usable review — the Claude belt below will run (cross-model QE did NOT happen)')
736
+ }
570
737
  }
571
738
  // Belt: if a codex-first QE returned null (codex unavailable), fall back to a Claude reviewer — never block.
572
739
  if (qe === null && qeIsCodex) {