@dzhechkov/skills-feature-adr 1.4.0 → 1.5.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.
package/.dz-manifest.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "files": [
6
6
  {
7
7
  "path": "CHANGELOG.md",
8
- "sha256": "bbb43101a686369a8662f47fe7b7aa75073b614a188c2752cd81d9570620bb3d"
8
+ "sha256": "59592f0b4612a14bdec61c96c20655644701cd77a0bf9d68315e1c98461900ff"
9
9
  },
10
10
  {
11
11
  "path": "LICENSE",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "README.md",
16
- "sha256": "469873d967c4438afd0b25ec8b013347841e07328f508f6786f90cc6aa2f09b9"
16
+ "sha256": "8df52c2c7d7c70ef29670339ac01a6c0b0b6a36bb51a0be15dcc03357097cca8"
17
17
  },
18
18
  {
19
19
  "path": "bin/cli.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  {
27
27
  "path": "package.json",
28
- "sha256": "c1e4d257167fd2462e0c8e09f248fc1aef332aa34fb09dddd5940bdcc30ea34b"
28
+ "sha256": "0fc1ae047774c2c67c6acf77a88eef8c9532c609bfc68b9e9cb37591cb61158f"
29
29
  },
30
30
  {
31
31
  "path": "scripts/sync-templates.js",
@@ -157,7 +157,7 @@
157
157
  },
158
158
  {
159
159
  "path": "templates/.claude/skills/feature-adr/modules/06-implementation-plan.md",
160
- "sha256": "c8fdd1877d8d9515ab29f52c3db49ddd9612b5797bc071e6208def860c02e6f2"
160
+ "sha256": "3903e2694ad47782914b35869bbf19c5f0803b6dafdcfb629df343a5ff00d1b9"
161
161
  },
162
162
  {
163
163
  "path": "templates/.claude/skills/feature-adr/modules/07-code.md",
@@ -257,7 +257,7 @@
257
257
  },
258
258
  {
259
259
  "path": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
260
- "sha256": "41d85b012a9d6cc172eaa5d7777294f2c01fb54b752ed61c7cbe2d2cd7b79b67"
260
+ "sha256": "2e1afeb8cbc08ce872fc8280a0809e7712ee76879d08b48caa5a0d4082cb8647"
261
261
  },
262
262
  {
263
263
  "path": "templates/.claude/skills/frontend-design/LICENSE.txt",
@@ -321,7 +321,7 @@
321
321
  },
322
322
  {
323
323
  "path": "templates/.claude/workflows/feature-adr.js",
324
- "sha256": "1be239d2f02612bc33027767d9930eae8a2d6d5afd427d1485198bbdd821a3c7"
324
+ "sha256": "3c8838c6a5107ea8a4b909cd9ccfa4d416324bb4ed9341f50b8e23f1b569d244"
325
325
  },
326
326
  {
327
327
  "path": "templates/lib/memory-protocol.md",
@@ -349,5 +349,5 @@
349
349
  }
350
350
  ]
351
351
  },
352
- "signature": "gixeRY/3+gjSSS3a+DfVwn40SVlD9Gr2sCh3H8afGWd1+Dzo8R8CmD61qG0cLeDdHn2NgzRWwvICHe+0HFlYCA=="
352
+ "signature": "8JbfbAiP55HvylGRoFfLwEtYCoo02jM9PwfOcTiYWQXfN/Utcg+Wc7uFo08yfqPUyONXsMCcEfqB7Y5eKWIvDg=="
353
353
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,127 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-08-21
4
+
5
+ ### Changed — feature `qe-scoped-review`
6
+
7
+ ### Changed — Step-8 Codex QE is SCOPED, and every fallback names its cause
8
+
9
+ Not published. Behaviour change to the Step-8 QE dispatch only; routing, the Claude belt, checkpoints
10
+ and training-pair capture are untouched.
11
+
12
+ - **The measurement that forced it** (same question, same model `gpt-5.6-sol` at effort `high`,
13
+ 2026-08-21): an UNSCOPED `codex exec` QE prompt of 19 038 chars spent 280 s, exited 124 and produced
14
+ 416 KB of exploration with NO verdict — and did it again under a 1500 s ceiling. The same question
15
+ scoped to two named files answered in 41 s with `Grade: B`; `codex review --commit` answered in
16
+ 146 s with the scope derived from the diff. The budget went on RECONNAISSANCE, so raising the
17
+ timeout buys more of it, and the prompt ceiling was never the binding constraint (19 038 < 24 000,
18
+ with ~5 000 chars of headroom). The harm was not the minutes: on timeout the dispatch returns null,
19
+ the belt runs a Claude reviewer, and cross-family QE is lost SILENTLY on exactly the large features
20
+ that need it most.
21
+ - **Mode A — `codex review`** is now the primary Step-8 Codex pass; the diff defines the scope.
22
+ Default `--uncommitted`, overridable with `args.qeScope` / `args.qeScopeRef`. The builder can never
23
+ emit `-m` and never appends a positional prompt: MEASURED, `codex review` rejects `-m` with exit 2,
24
+ and EVERY scope flag (`--commit`, `--base`, `--uncommitted`) rejects `[PROMPT]` with exit 2. Either
25
+ mistake would present as a review that silently did not happen.
26
+ - **Mode B — a NARROWED `codex exec`** carries our own questions over at most 3 named files and ends
27
+ with `Grade: <A|B|C|D>`. An unscoped mode-B dispatch is not constructible: an empty file list yields
28
+ `''`, and `codexExecPlan` now refuses an unscoped `qe` prompt outright.
29
+ - **A locked decline taxonomy** — `timeout | no-verdict | tool-error | unusable-output | unavailable |
30
+ over-ceiling` — replaces the single generic reason. A timeout ("narrow the scope") and a broken
31
+ invocation ("fix the command") can no longer render the same string inside
32
+ `opus (cross-family QE DID NOT happen — …)`. An unknown kind throws rather than rendering something
33
+ plausible.
34
+ - **A machine sentinel** (`CODEX-QE-SIGNAL exit=… elapsed=…s bytes=…`, the Step-7.5 landing-signal
35
+ grammar) carries the exit code past the shell agent, so a timeout is knowable without asking a model
36
+ to self-report its own failure. A MISSING sentinel is classified `tool-error` on the pipeline path —
37
+ never a pass.
38
+ - **Zero findings is never an `A`.** Mode A cannot be asked for a letter, so its grade is derived from
39
+ the severities the reviewer reported and labelled `gradeSource: 'derived-from-findings'`; an empty or
40
+ unparseable finding set yields `null` and declines. MEASURED: `codex review` on a clean tree exits 0
41
+ with a well-formed, entirely empty review — the exact input that a default letter would have turned
42
+ into a clean bill of health.
43
+ - **`qe` now returns `gaps` from the reviewer's actual findings**, plus `gradeSource` and
44
+ `qeScope {mode, ref, files}`; the review scope enters the `qe` checkpoint hash so a resume cannot
45
+ present a mode-B verdict as a mode-A one.
46
+ - **`CODEX_EXEC_PROMPT_CEILING_CHARS` is retained but DEMOTED** — it is a sanity bound on an absurd
47
+ payload, and is no longer documented as the thing that prevents a stall. The defence is scope.
48
+ - **Unchanged on purpose:** `resolveQeSpec` / `coderIsCodex` / `qeShouldUseCodex` (verified correct by
49
+ direct call), the `crossFamilyQe` label format, and the Claude belt — the pipeline still never blocks
50
+ on Codex.
51
+
52
+ ### Fixed — the K2 plan gate could not complete a run on a non-JS repository
53
+
54
+ - **C2 recognises test paths in every ecosystem.** The gate demanded a `.test.(ts|mjs|js)` suffix, so
55
+ a Python/Go/Rust/JVM/.NET feature could never satisfy it (measured: 4/4 JS forms matched, 0/11
56
+ non-JS). Replaced by a two-stage predicate — a language-neutral candidate-path extractor plus one
57
+ `$`-anchored rule per ecosystem (JS/TS, pytest `test_*.py` + `*_test.py`, Go, Rust `tests/*.rs` +
58
+ `tests.rs`, JVM, .NET). Widening it to "contains the word test" was rejected: `docs/testing.md`,
59
+ `src/latest.rs` and prose are still refused. An unknown ecosystem stays a FAIL, never a WARN.
60
+ - **New opt-in `testPathRules`** in the existing `architecture/project-skills.json`: additive rules,
61
+ each anchored by wrapping so an alternation cannot leak, capped at 200 characters, and FATAL when
62
+ malformed (`NOT-ESTABLISHED`, exit 3) rather than silently falling back to the built-ins.
63
+ - **The gate script is resolved from the WORKSPACE first.** The command `cd`s into the target repo,
64
+ so the repo-relative path died with `Cannot find module` on any repo without its own feature-adr
65
+ install. Order: `args.gateScript` (new, absolute, validated) → workspace copy → repo copy, with
66
+ `K2_GATE_SCRIPT=` / `K2_GATE_TRIED=` echoed for audit. Nothing found ⇒ reason `tooling-missing`
67
+ (a NOT-ESTABLISHED, never a skip), and the operator note now says the gate could not be RUN and
68
+ that this is not a plan defect — it no longer tells you to fix a plan that is fine.
69
+ - **A relative `args.dzBin` is pinned to the workspace root once**, before any `cd`, so the six
70
+ commands that splice it stop resolving against three different bases (a null usage probe was being
71
+ read upstream as "the Claude limit was hit").
72
+
73
+ ### Changed — `EXPECTED_CODE_TARGETS` admissibility (C3)
74
+
75
+ - Dotfile targets (`.claude/…`, `.github/workflows/ci.yml`, `.gitignore`, `.env.example`) are
76
+ admissible — previously feature-adr could not name its own files or any CI config, in any language.
77
+ - Directory-shaped targets are now REJECTED (`trailing slash — names a directory, not a file`): a bare
78
+ directory gives plan-vs-diff matching nothing concrete to verify.
79
+ - Every refusal names its own defect instead of one catch-all: `path traversal ('..' segment)`,
80
+ `degenerate path segment`, `empty path segment`, `path segment ends with '.'`,
81
+ `illegal character '<c>'`, `empty stem after the leading dot`. The traversal check is a SEGMENT
82
+ test now, so the ordinary filename `foo..bar.ts` is no longer called a path traversal.
83
+
84
+ ### Requires
85
+
86
+ - Nothing new at runtime: the workflow INLINES the gate command builder and its parser (the sandbox
87
+ cannot import), so this release is self-contained. The pure halves it mirrors live in
88
+ `@dzhechkov/harness-core` (`planCompletenessGateCmd` gained an optional 4th argument;
89
+ `refusalNoteFor` and `normalizeDzBin` are new) and a drift test pins the two copies together.
90
+
91
+
92
+ ### Fixed — Step 0 wrote nothing down, so the tier was unreadable and the acid check had no input
93
+
94
+ - **Step 0 now WRITES `00_complexity_assessment.md`** before returning: the tier with its decisive
95
+ criterion, the active steps, the recalled patterns, and an acid-case table whose rows are pinned to
96
+ the exact `| A<n> | <bad input> | <what must happen> |` shape the K2 gate parses. MEASURED
97
+ 2026-08-21: 66 of 199 features carried that file and the last four in a row did not. Two silent
98
+ consequences — the tier was recorded NOWHERE while a run was alive (the checkpoint lands at phase
99
+ end, the result object only at the very end), and the C4 acid check quietly switched itself off,
100
+ including for the features that introduced it. An honest no-acid-cases run still writes prose and
101
+ no table; the gate now tells an ABSENT file from a deliberate skip.
102
+ - **A caller-forced tier is the tier of record.** `tier = args.tier || router.tier` means the run
103
+ executes the override while Step 0 wrote down its own classification. The artifact now carries
104
+ `Effective tier: <forced> (forced by the caller)` alongside the router's own recommendation, and
105
+ sizes the acid table for the effective tier. With no override the prompt is byte-identical.
106
+ - **A pre-contract router checkpoint no longer resumes into the new contract** — the resume gate only
107
+ asks whether the artifact is PRESENT, and any of the 66 features with a tableless file satisfied it.
108
+
109
+ ### Fixed — the Step-8 QE stage measured the wrong thing in four different ways
110
+
111
+ - the change set is the run's DELTA (pre-code baseline vs after), not the working tree's current dirt;
112
+ - a finding whose location cannot be parsed is `unlocatable` and stays in the graded set — it used to
113
+ be filed as someone else's dirt, i.e. unknown counted as clean;
114
+ - mode B refuses a scope built from unlanded code, an unmeasured change set or an empty intersection;
115
+ - the QE scribe is WITNESSED: the report is re-hashed before and after, so an existing file from an
116
+ earlier run can no longer stand in for one this run never wrote.
117
+
118
+ ### Fixed — durable state is written by a command, not by hand
119
+
120
+ - checkpoint lines now go through `dz feature-adr-checkpoint`. The subagent RUNS a command instead of
121
+ hand-writing state into a file: a security classifier blocked NINE consecutive checkpoint writes in
122
+ one run, `.fa-state/checkpoints.jsonl` was never created, resume was silently dead, and the run
123
+ reported success.
124
+
3
125
  ## [1.4.0] - 2026-08-20
4
126
 
5
127
  ### Changed — the composite design checkpoint
package/README.md CHANGED
@@ -106,6 +106,108 @@ ARCHITECTURE → IMPLEMENTATION → CODE → QE → FLEET QE
106
106
  # Full protocols + 6 extra skills, up to 7 fleet QE agents
107
107
  ```
108
108
 
109
+ ### The K2 plan gate runs on ANY repo, not just JS/TS ones (v1.5.0)
110
+
111
+ Between Step 6 and Step 7 the pipeline runs a machine plan-completeness gate (K2). Until v1.5.0 it
112
+ could not finish a run on a non-JS repository — three separate defects, all closed here.
113
+
114
+ **1. Test paths in every ecosystem.** C2 checks that each ADR's Confirmation names a test file and
115
+ that the plan names the same path. It used to demand a `.test.(ts|mjs|js)` suffix, so on a Python,
116
+ Go, Rust, JVM or .NET repo it found nothing and failed the plan (measured: 4/4 JS forms matched,
117
+ 0/11 non-JS forms did). It now uses a two-stage predicate — candidate paths are extracted from the
118
+ Confirmation text, then matched against one anchored rule per ecosystem:
119
+
120
+ | Ecosystem | Recognised |
121
+ |---|---|
122
+ | JS/TS | `*.test.*` / `*.spec.*` (`ts tsx mts cts mjs cjs js jsx`) |
123
+ | pytest | `test_*.py` **and** `*_test.py` |
124
+ | Go | `*_test.go` |
125
+ | Rust | `tests/*.rs` **and** `tests.rs` |
126
+ | JVM | `*Test|Tests|IT|Spec.(java kt kts scala groovy)` |
127
+ | .NET | `*Test|Tests.(cs fs vb)` |
128
+
129
+ Deliberately NOT "any path containing the word test": `docs/testing.md`, `src/latest.rs`,
130
+ `contests/results.py` and a sentence like *"the staleness test will verify this"* are all refused. A
131
+ gate that passes prose is worse than one that fails Python. An unknown ecosystem is still a FAIL,
132
+ never a warning — the message names the recognised set and points at the override below.
133
+
134
+ **Extending the vocabulary — `testPathRules`.** Add rules to the manifest the pipeline already
135
+ reads, `architecture/project-skills.json` (no second dotfile):
136
+
137
+ ```json
138
+ {
139
+ "testPathRules": [
140
+ { "ecosystem": "elixir", "pattern": "(?:^|/)test/[^/]+_test\\.exs" }
141
+ ]
142
+ }
143
+ ```
144
+
145
+ Rules are ADDED to the built-ins, never replace them, and each pattern is anchored by wrapping
146
+ (`(?:<pattern>)$`) so every branch of an alternation is anchored. A malformed `testPathRules`
147
+ (bad JSON, not an array, missing `ecosystem`/`pattern`, an invalid regex, over 200 characters, or a
148
+ nested quantifier) is **NOT-ESTABLISHED, exit 3** — never a quiet fall-back to the built-ins.
149
+
150
+ **2. The gate script is found where it is installed — `args.gateScript`.** The skill lives in your
151
+ WORKSPACE; the gate command `cd`s into the TARGET repo, so a repo-relative lookup died with
152
+ `Cannot find module` on every repo that is not itself a feature-adr install. The resolution order is
153
+ now:
154
+
155
+ 1. `args.gateScript` — an explicit ABSOLUTE path (validated: absolute, no `..`, or the run fails loudly);
156
+ 2. the WORKSPACE copy (`$WS/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs`);
157
+ 3. the target-repo copy.
158
+
159
+ Workspace before repo on purpose: the verdict contract is defined by the parser inside the running
160
+ workflow, so only that installation's own copy is known to speak it. Every run echoes
161
+ `K2_GATE_SCRIPT=` (which copy ran) and `K2_GATE_TRIED=` (all candidates) for audit. If none exists
162
+ the run refuses with reason `tooling-missing` — a NOT-ESTABLISHED, never a skip — and the operator
163
+ message says the gate could not be RUN and that this is **not** a plan defect.
164
+
165
+ **3. Dotfile targets are admissible; directory-shaped ones are not.** `EXPECTED_CODE_TARGETS` may
166
+ now name `.claude/…`, `.github/workflows/ci.yml`, `.gitignore`, `.env.example`. What is refused is
167
+ refused BY NAME: `path traversal ('..' segment)`, `degenerate path segment`, `empty path segment`,
168
+ `trailing slash — names a directory, not a file`, `path segment ends with '.'`,
169
+ `illegal character '<c>'`, `empty stem after the leading dot`. The old substring traversal test also
170
+ rejected the ordinary filename `foo..bar.ts`; it is a segment test now.
171
+
172
+ ### Step 0 writes the assessment down, and the acid check gets its input back (v1.5.0)
173
+
174
+ Step 0 classifies the feature and now **writes `00_complexity_assessment.md` before it returns** — the
175
+ tier with the criterion that decided it, the active steps, the recalled patterns, and an acid-case
176
+ table. MEASURED 2026-08-21: 66 of 199 features carried that file and the last four in a row did not,
177
+ with two silent consequences. The tier was recorded NOWHERE while a run was alive (the router
178
+ checkpoint lands at phase end and the result object only at the very end), so mid-run it had to be
179
+ guessed from which artifacts happened to exist. And the K2 gate reads its acid corpus from that file,
180
+ so the C4 check quietly switched itself off — including for the features that introduced it.
181
+
182
+ Acid rows are pinned to the exact shape the gate parses, `| A<n> | <the bad input> | <what must
183
+ happen> |`; a loose shape disables the check just as silently as a missing file. A feature with no
184
+ acid cases still says so in prose and writes no table — the gate now tells an ABSENT file (a warning
185
+ that names the missing artifact) from a deliberate skip.
186
+
187
+ Two ways the tier could still go unrecorded, both closed here:
188
+
189
+ - a **pre-contract router checkpoint** used to resume into the new contract. The resume gate only asks
190
+ whether the artifact is PRESENT, and any of the 66 features carrying a tableless file satisfied it,
191
+ so Step 0 never re-ran. A router-scoped hash token now makes those entries re-run — once, and only
192
+ the router, rather than every in-flight stage.
193
+ - a **caller-forced `args.tier`** is now the tier of record. The run executes the override, so the
194
+ file carries `Effective tier: <forced> (forced by the caller)` alongside the router's own
195
+ recommendation, and sizes the acid table for the effective tier. With no override the prompt is
196
+ byte-identical to before.
197
+
198
+ Requires `@dzhechkov/harness-core >= 0.6.0`.
199
+
200
+ ### The Step-8 QE stage measures the run, not the room (v1.5.0)
201
+
202
+ Four separate ways the QE stage graded the wrong thing, all closed: the change set is this run's DELTA
203
+ (a pre-code baseline against the state after) instead of the working tree's current dirt, so an
204
+ unrelated dirty file can no longer grade your feature; a finding whose location cannot be parsed is
205
+ `unlocatable` and stays in the graded set, where it used to be filed as someone else's dirt — unknown
206
+ counted as clean, inside the function whose job is honest attribution; mode B refuses a scope built
207
+ from unlanded code, an unmeasured change set, or an empty intersection; and the QE scribe is
208
+ WITNESSED — the report is re-hashed before and after, so a file left by an earlier run can no longer
209
+ stand in for one this run never wrote.
210
+
109
211
  ### The design fan resumes per sibling, and an incomplete design is REFUSED (v1.4.0)
110
212
 
111
213
  Step 1–5 run as one parallel fan — requirements, ADR, QCSD, architecture. Until v1.4.0 the whole fan
@@ -237,6 +339,49 @@ family than the coder** — a model that writes code must not also self-QE; inde
237
339
  catches what self-review misses. coder=Codex ⇒ QE=Claude (`opus`); coder=Claude ⇒ QE=Codex
238
340
  (`codex:<top>:high`), or a Claude reviewer if Codex is unavailable (never blocks).
239
341
 
342
+ ### Step 8 on Codex is SCOPED — two modes, and every fallback names its cause
343
+
344
+ When QE routes to Codex, Step 8 no longer hands it one unscoped `codex exec` prompt. Why, MEASURED
345
+ 2026-08-21 — same question, same model (`gpt-5.6-sol`, effort `high`), three dispatches:
346
+
347
+ | dispatch | prompt | wall time | exit | verdict? |
348
+ |---|---|---|---|---|
349
+ | `codex exec`, unscoped | 19 038 chars | 280 s (and again under a 1500 s ceiling) | 124 | **none** — 416 KB of exploration |
350
+ | `codex exec`, scoped (*"read ONLY these two files"*) | 1 461 chars | 41 s | 0 | `Grade: B` + findings |
351
+ | `codex review --commit <SHA>` | scope from the diff | 146 s | 0 | verdict + findings |
352
+
353
+ The unscoped run spent its budget on **reconnaissance of the tree**, not on reasoning about the change.
354
+ So raising the timeout does not help, and the prompt-length ceiling was never the binding constraint
355
+ (19 038 sat under `CODEX_EXEC_PROMPT_CEILING_CHARS` = 24 000 with ~5 000 to spare). The cost is not the
356
+ wasted minutes: on timeout the dispatch returns `null`, the belt runs a Claude reviewer, and
357
+ cross-family QE is silently lost — on exactly the big features that need it most.
358
+
359
+ - **Mode A (primary): `codex review`.** The scope comes from the DIFF, so Codex computes for free the
360
+ thing we were paying a model to do badly. Default scope `--uncommitted`; override with
361
+ `args.qeScope: 'commit' | 'base'` + `args.qeScopeRef`. Two CLI facts are encoded as refusals, both
362
+ measured: `codex review` rejects `-m` (exit 2 — the model goes through `-c model=`), and **every**
363
+ scope flag rejects a positional prompt (`--commit`, `--base` and `--uncommitted` alike, exit 2). So
364
+ mode A runs on Codex's built-in review instructions and cannot be asked our questions.
365
+ - **Mode B (follow-up): a NARROWED `codex exec`.** Carries our own questions over at most 3 named
366
+ files with the load-bearing clause *"read ONLY these files, do not explore the repository"*, and
367
+ ends with `Grade: <A|B|C|D>`. An unscoped mode-B prompt is not constructible: an empty file list
368
+ returns `''` and `codexExecPlan` refuses an unscoped `qe` prompt outright.
369
+ - **The verdict is parsed, never synthesised.** A `Grade: D` is a SUCCESSFUL cross-family review; a
370
+ review with no grade is a FAILED one. Mode A cannot state a letter, so its grade is DERIVED from the
371
+ severities it reported and labelled `gradeSource: 'derived-from-findings'` — and **zero findings
372
+ yields `null`, never `A`** (measured: `codex review` on a clean tree exits 0 with a polite, entirely
373
+ empty review; calling that an `A` would be a review of nothing reported as a pass).
374
+ - **Every fallback names its cause.** The reason carried into
375
+ `opus (cross-family QE DID NOT happen — …)` comes from a locked taxonomy —
376
+ `timeout` (narrow the scope) · `no-verdict` · `tool-error` (fix the invocation) · `unusable-output` ·
377
+ `unavailable` (fix the account/model) · `over-ceiling`. A timeout and an unusable output can never
378
+ render the same string, because the operator's next move differs.
379
+ - **The pipeline still never blocks on Codex.** Both modes fail into the same Claude belt as before.
380
+
381
+ After a Codex verdict a cheap Claude agent transcribes it into `08_qe_report.md` (mode A takes no
382
+ prompt, so the reviewer cannot be asked to write anything). It is a scribe, not a second reviewer: the
383
+ grade is Codex's and is stated as final.
384
+
240
385
  **Precedence:** `args.models[stage]` wins; the legacy `planner`/`coder`/`qeReviewer`/`codexModel` knobs
241
386
  fill only unspecified stages. `codexModel` seeds the id for a bare `'codex'` spec. gpt-5.6-ready: a new
242
387
  Codex id is a data-only allowlist edit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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"
package/sbom.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "hashes": [
16
16
  {
17
17
  "alg": "SHA-256",
18
- "content": "bbb43101a686369a8662f47fe7b7aa75073b614a188c2752cd81d9570620bb3d"
18
+ "content": "59592f0b4612a14bdec61c96c20655644701cd77a0bf9d68315e1c98461900ff"
19
19
  }
20
20
  ]
21
21
  },
@@ -35,7 +35,7 @@
35
35
  "hashes": [
36
36
  {
37
37
  "alg": "SHA-256",
38
- "content": "469873d967c4438afd0b25ec8b013347841e07328f508f6786f90cc6aa2f09b9"
38
+ "content": "8df52c2c7d7c70ef29670339ac01a6c0b0b6a36bb51a0be15dcc03357097cca8"
39
39
  }
40
40
  ]
41
41
  },
@@ -65,7 +65,7 @@
65
65
  "hashes": [
66
66
  {
67
67
  "alg": "SHA-256",
68
- "content": "c1e4d257167fd2462e0c8e09f248fc1aef332aa34fb09dddd5940bdcc30ea34b"
68
+ "content": "0fc1ae047774c2c67c6acf77a88eef8c9532c609bfc68b9e9cb37591cb61158f"
69
69
  }
70
70
  ]
71
71
  },
@@ -395,7 +395,7 @@
395
395
  "hashes": [
396
396
  {
397
397
  "alg": "SHA-256",
398
- "content": "c8fdd1877d8d9515ab29f52c3db49ddd9612b5797bc071e6208def860c02e6f2"
398
+ "content": "3903e2694ad47782914b35869bbf19c5f0803b6dafdcfb629df343a5ff00d1b9"
399
399
  }
400
400
  ]
401
401
  },
@@ -645,7 +645,7 @@
645
645
  "hashes": [
646
646
  {
647
647
  "alg": "SHA-256",
648
- "content": "41d85b012a9d6cc172eaa5d7777294f2c01fb54b752ed61c7cbe2d2cd7b79b67"
648
+ "content": "2e1afeb8cbc08ce872fc8280a0809e7712ee76879d08b48caa5a0d4082cb8647"
649
649
  }
650
650
  ]
651
651
  },
@@ -805,7 +805,7 @@
805
805
  "hashes": [
806
806
  {
807
807
  "alg": "SHA-256",
808
- "content": "1be239d2f02612bc33027767d9930eae8a2d6d5afd427d1485198bbdd821a3c7"
808
+ "content": "3c8838c6a5107ea8a4b909cd9ccfa4d416324bb4ed9341f50b8e23f1b569d244"
809
809
  }
810
810
  ]
811
811
  },
@@ -198,6 +198,13 @@ After the plan is written, run the gate. It is a script, not a judgement call:
198
198
  node .claude/skills/feature-adr/scripts/check-plan-completeness.mjs features/<slug>
199
199
  ```
200
200
 
201
+ The path above is relative to the workspace the skill is INSTALLED in. Working inside a target repo
202
+ that has no feature-adr install of its own? Pass the absolute path to the script instead — the
203
+ ultracode workflow does this for you (it tries `args.gateScript`, then the workspace copy, then the
204
+ target-repo copy, and refuses with `tooling-missing` if none exists). The gate is language-neutral:
205
+ C2 recognises JS/TS, pytest, Go, Rust, JVM and .NET test paths, extensible per project with
206
+ `testPathRules` in `architecture/project-skills.json`.
207
+
201
208
  | Exit | Verdict | What you do |
202
209
  |------|---------|-------------|
203
210
  | `0` | PASS | proceed to Step 7 |
@@ -85,6 +85,79 @@ const adrFiles = existsSync(adrDir) ? readdirSync(adrDir).filter(f => f.endsWith
85
85
  const planClaimsAdrWork = /\bADR-\d+/.test(plan);
86
86
  if (adrFiles.length === 0 && planClaimsAdrWork) notEstablished('no ADR files under 03_adr/, yet the plan cites ADR-<n> — completeness cannot be established');
87
87
 
88
+ // ── C2 test-path predicate (P16/D1) — language-neutral, two stages ────────────────────────────
89
+ // The shipped rule demanded a `.test.(ts|mjs|js)` SUFFIX, so C2 could never be satisfied outside
90
+ // JS/TS: MEASURED 2026-08-20 over a 15-path corpus, 4/4 JS forms matched and 0/11 non-JS forms did
91
+ // (`tests/test_x.py`, `x_test.go`, `tests/x.rs`, `XTest.java`, `XTests.cs`, …) — and zero matches is
92
+ // the C2 FAIL branch, so the gate blocked every non-JS repo. The fix is a PREDICATE, not a wider
93
+ // regex: "contains the word test" would admit `docs/testing.md` and `src/latest.rs`, and a gate that
94
+ // passes prose is worse than one that fails Python.
95
+ //
96
+ // STAGE 1 (extractCandidatePaths) — pull candidate PATHS out of prose. The left edge is ANCHORED on
97
+ // a boundary character so a longer token can never donate a substring (`xcontests/foo.test.ts` must
98
+ // not yield `foo.test.ts`), and a candidate MUST carry at least one directory segment and an
99
+ // extension — a bare `test_staleness.py` written inside a sentence is a name, not a path.
100
+ // STAGE 2 (isTestPath) — ONE `$`-anchored rule per ecosystem. The anchoring IS the safety argument:
101
+ // every acid item is refused by construction rather than by a blocklist.
102
+ // DELIBERATELY NOT DONE: no `existsSync` decides whether a Confirmation path is valid — C2 runs
103
+ // BEFORE Step 7 writes the test, so a file-existence probe there would fail every honest plan.
104
+ const CANDIDATE_PATH_RE = /(?:^|[\s`'"(|])((?:[.\w@][\w@.-]*\/)+[\w@.-]+\.[A-Za-z0-9]+)/g;
105
+ const extractCandidatePaths = (text) => {
106
+ const found = [];
107
+ CANDIDATE_PATH_RE.lastIndex = 0;
108
+ let m;
109
+ while ((m = CANDIDATE_PATH_RE.exec(String(text))) !== null) found.push(m[1]);
110
+ return [...new Set(found)];
111
+ };
112
+
113
+ // One rule per ecosystem, each anchored at the END of the path. Adding an ecosystem is a data edit.
114
+ const BUILTIN_TEST_PATH_RULES = [
115
+ { ecosystem: 'js', re: /\.(?:test|spec)\.(?:ts|tsx|mts|cts|mjs|cjs|js|jsx)$/ },
116
+ { ecosystem: 'pytest', re: /(?:^|\/)test_[^/]+\.py$/ },
117
+ { ecosystem: 'pytest', re: /[^/]+_test\.py$/ },
118
+ { ecosystem: 'go', re: /[^/]+_test\.go$/ },
119
+ { ecosystem: 'rust', re: /(?:^|\/)tests\/[^/]+\.rs$/ },
120
+ { ecosystem: 'rust', re: /(?:^|\/)tests\.rs$/ },
121
+ { ecosystem: 'jvm', re: /[^/]+(?:Test|Tests|IT|Spec)\.(?:java|kt|kts|scala|groovy)$/ },
122
+ { ecosystem: 'dotnet', re: /[^/]+(?:Test|Tests)\.(?:cs|fs|vb)$/ },
123
+ ];
124
+ const KNOWN_ECOSYSTEMS = 'JS/TS *.test|spec.[tj]s, pytest test_*.py + *_test.py, Go *_test.go, Rust tests/*.rs + tests.rs, JVM *Test|Tests|IT|Spec.java|kt|kts|scala|groovy, .NET *Test|Tests.cs|fs|vb';
125
+ // The override channel lives in the manifest the pipeline ALREADY probes at Step 0 — a second
126
+ // dotfile would be a second thing to keep in sync. Resolved against the TARGET REPO cwd (the gate
127
+ // command cd's into the repo first); never shelled out to `dz project-skills`, because a gate that
128
+ // needs a working `dz` to decide whether a path is a test inherits every one of dz's own failures.
129
+ const MANIFEST_REL = 'architecture/project-skills.json';
130
+ function loadTestPathRules() {
131
+ const rules = BUILTIN_TEST_PATH_RULES.slice();
132
+ const manifestPath = join(process.cwd(), MANIFEST_REL);
133
+ if (!existsSync(manifestPath)) return rules;
134
+ let text = null;
135
+ try { text = readFileSync(manifestPath, 'utf-8'); } catch { notEstablished(`testPathRules unreadable: ${MANIFEST_REL} could not be read`); }
136
+ let manifest = null;
137
+ try { manifest = JSON.parse(text); } catch { notEstablished(`testPathRules unreadable: ${MANIFEST_REL} is not valid JSON`); }
138
+ const extra = (manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)) ? manifest.testPathRules : undefined;
139
+ // ABSENT is not an error — the built-ins stand. Only a PRESENT-but-broken override is fatal: a
140
+ // silent fall-back to built-ins would tell a project its rules are live when they are not.
141
+ if (extra === undefined || extra === null) return rules;
142
+ if (!Array.isArray(extra)) notEstablished('testPathRules unreadable: testPathRules is present but is not an array');
143
+ for (const entry of extra) {
144
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) notEstablished('testPathRules unreadable: an entry is not a {ecosystem, pattern} object');
145
+ const eco = typeof entry.ecosystem === 'string' ? entry.ecosystem.trim() : '';
146
+ if (eco === '') notEstablished('testPathRules unreadable: an entry has no non-empty "ecosystem"');
147
+ if (typeof entry.pattern !== 'string' || entry.pattern === '') notEstablished(`testPathRules unreadable: entry "${eco}" has no non-empty "pattern"`);
148
+ if (entry.pattern.length > 200) notEstablished(`testPathRules unreadable: entry "${eco}" pattern exceeds 200 characters`);
149
+ // The ONE catastrophic-backtracking shape a length cap does not bound. Refused by SHAPE, because
150
+ // this sandboxless script cannot enforce a match timeout.
151
+ if (/\([^)]*[+*][^)]*\)[+*]/.test(entry.pattern)) notEstablished(`testPathRules unreadable: entry "${eco}" pattern nests a quantifier over a quantified group (catastrophic backtracking)`);
152
+ let re = null;
153
+ // WRAP, never append a bare `$`: `foo|bar` + `$` anchors only the LAST branch, so `src/foolish.py`
154
+ // would match `foo` unanchored and an arbitrary source file would count as a Confirmation test.
155
+ try { re = new RegExp('(?:' + entry.pattern + ')$'); } catch { notEstablished(`testPathRules unreadable: entry "${eco}" pattern is not a valid regular expression`); }
156
+ rules.push({ ecosystem: eco, re: re });
157
+ }
158
+ return rules;
159
+ }
160
+
88
161
  if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
89
162
  failures.push(`C1: tier ${TIER} has NO ADR files under 03_adr/ — an M/L/XL feature owes at least one ADR; the plan cannot be complete against decisions that were never written`);
90
163
  failures.push(`C2: tier ${TIER} has no 03_adr/ — no Confirmation test can be checked`);
@@ -101,18 +174,78 @@ if (adrFiles.length === 0 && TIER_REQUIRES_ADR) {
101
174
  }
102
175
 
103
176
  // C2 — every Confirmation-listed test file path appears in the plan
104
- const testPathRe = /(?:packages\/[\w@/.-]+\/test\/[\w./-]+\.test\.(?:ts|mjs|js)|tests?\/[\w./-]+\.test\.(?:ts|mjs|js))/g;
177
+ const testPathRules = loadTestPathRules();
178
+ const isTestPath = (p) => testPathRules.some((r) => r.re.test(p));
105
179
  for (const f of adrFiles) {
106
180
  const adr = readFileSync(join(adrDir, f), 'utf-8');
107
181
  const confIdx = adr.search(/^##+\s*Confirmation/mi);
108
182
  if (confIdx < 0) { failures.push(`C2: ${safe(f)} has no Confirmation section`); continue; }
109
183
  const conf = adr.slice(confIdx);
110
- const paths = [...new Set((conf.match(testPathRe) ?? []))];
111
- if (paths.length === 0) { failures.push(`C2: ${safe(f)} Confirmation names no test file paths`); continue; }
184
+ const paths = extractCandidatePaths(conf).filter(isTestPath);
185
+ // UNKNOWN ECOSYSTEM STAYS A FAILURE, never a WARN: a gate that downgrades itself on the one
186
+ // repo it cannot read is a gate that is off exactly where it is needed. The remedy is named
187
+ // in the message instead.
188
+ if (paths.length === 0) { failures.push(`C2: ${safe(f)} Confirmation names no test file paths (recognised: ${KNOWN_ECOSYSTEMS}; extend the vocabulary with a "testPathRules" array in ${MANIFEST_REL})`); continue; }
112
189
  for (const p of paths) if (!plan.includes(p)) failures.push(`C2: ${safe(f)} Confirmation test ${safe(p)} NOT named in the plan`);
113
190
  }
114
191
  }
115
192
 
193
+ // ── C3 target-path admissibility (P16/D3) — named rules, not one character class ───────────────
194
+ // A LEADING DOT is legitimate: `.claude/`, `.github/workflows/ci.yml`, `.gitignore`, `.env.example`
195
+ // are ordinary targets in every ecosystem, and until the 2026-08-20 bootstrap fix the `^[\w@]…`
196
+ // class rejected every one of them — so feature-adr could not name its OWN files and no feature
197
+ // could touch CI config. Found by a run that stopped at this very gate while trying to fix the gate.
198
+ //
199
+ // This is the HARDENING of that minimal fix, and it answers the three holes it left:
200
+ // (a) the traversal rule was a SUBSTRING test, so an ordinary filename `foo..bar.ts` collected the
201
+ // label `path traversal` (MEASURED). It is replaced by SEGMENT rules: a segment exactly `..` is
202
+ // traversal; a segment of nothing but dots is a DEGENERATE NAME — a different defect that must
203
+ // not borrow the traversal label. Coverage is not weakened: `..`, `../`, `../etc/passwd` and
204
+ // `.a/../b` all carry a `..` SEGMENT and are still refused.
205
+ // (b) a directory-shaped target (`.claude/`) was ACCEPTED and gives Step-6/7 plan-vs-diff matching
206
+ // nothing concrete to verify against — rejected now, by its own name.
207
+ // (c) every refusal names WHAT is wrong. The old catch-all `not a plain repo-relative path` fired
208
+ // for an illegal character, a bad first character and a stray dot alike.
209
+ // A named reason that LIES is worse than a generic one: the leading-stem message is emitted ONLY for
210
+ // a path that actually starts with a dot, never for `-foo.ts` or `+x.ts`.
211
+ const MARKDOWN_RESIDUE_RE = /[*#\[\]()]/;
212
+ const LEGAL_TARGET_CHAR_RE = /[A-Za-z0-9_@./-]/;
213
+ function classifyTargetPath(path) {
214
+ const reasons = [];
215
+ if (/\s/.test(path)) reasons.push('contains whitespace');
216
+ if (MARKDOWN_RESIDUE_RE.test(path)) reasons.push('markdown residue');
217
+ if (path.startsWith('/')) reasons.push('absolute path — targets are repo-relative');
218
+ if (path.startsWith('~')) reasons.push('home-relative path — targets are repo-relative');
219
+ const anchoredHere = path === '.' || path.startsWith('./');
220
+ if (anchoredHere) reasons.push('not repo-relative (anchored at the current directory)');
221
+ const segs = path.split('/');
222
+ let dotDot = false, degenerate = false, emptySeg = false, trailingDotSeg = false;
223
+ for (let i = 0; i < segs.length; i++) {
224
+ const s = segs[i];
225
+ if (s === '..') { dotDot = true; continue; }
226
+ if (/^\.+$/.test(s)) { if (s.length >= 3 || (i > 0 && !anchoredHere)) degenerate = true; continue; }
227
+ if (s === '') { if (i > 0 && i < segs.length - 1) emptySeg = true; continue; }
228
+ if (s.endsWith('.')) trailingDotSeg = true;
229
+ }
230
+ if (dotDot) reasons.push("path traversal ('..' segment)");
231
+ if (degenerate) reasons.push('degenerate path segment');
232
+ if (emptySeg) reasons.push('empty path segment');
233
+ if (path.endsWith('/')) reasons.push('trailing slash — names a directory, not a file');
234
+ if (trailingDotSeg) reasons.push("path segment ends with '.'");
235
+ // Residual character check — LAST, and it names the first offender instead of shrugging. Characters
236
+ // already explained by the whitespace/markdown rules are skipped so one defect gets one reason.
237
+ const scanned = path.startsWith('~') ? path.slice(1) : path;
238
+ const illegal = [...scanned].find((c) => !LEGAL_TARGET_CHAR_RE.test(c) && !/\s/.test(c) && !MARKDOWN_RESIDUE_RE.test(c));
239
+ if (illegal !== undefined) reasons.push(`illegal character '${illegal}'`);
240
+ // The leading stem: a dot must be followed by a REAL name (`.-` has an empty stem), and any other
241
+ // path must start with a word character, '@' or '.'. Suppressed where a structural rule above
242
+ // already explains the first character, so the operator is never handed a reason that is false.
243
+ else if (!/^\.?[\w@]/.test(path) && !path.startsWith('/') && !path.startsWith('~') && !anchoredHere && !dotDot && !degenerate) {
244
+ reasons.push(path.startsWith('.') ? 'empty stem after the leading dot' : "path does not start with a letter, digit, '_', '@' or '.'");
245
+ }
246
+ return reasons;
247
+ }
248
+
116
249
  // C3 — EXPECTED_CODE_TARGETS block, line-level validation
117
250
  const blockM = plan.match(/EXPECTED_CODE_TARGETS:\s*\n((?:\s*[-*]\s*.+\n?)+)/);
118
251
  if (!blockM) failures.push('C3: no EXPECTED_CODE_TARGETS: block in the plan');
@@ -121,11 +254,7 @@ else {
121
254
  if (lines.length === 0) failures.push('C3: EXPECTED_CODE_TARGETS block is empty');
122
255
  for (const ln of lines) {
123
256
  const path = ln.replace(/^[-*]\s*/, '').replace(/`/g, '').trim();
124
- const reasons = [];
125
- if (/\s/.test(path)) reasons.push('contains whitespace');
126
- if (/\.\./.test(path)) reasons.push('path traversal');
127
- if (/[*#\[\]()]/.test(path)) reasons.push('markdown residue');
128
- if (!/^[\w@][\w@/.-]*$/.test(path)) reasons.push('not a plain repo-relative path');
257
+ const reasons = classifyTargetPath(path);
129
258
  if (reasons.length) failures.push(`C3: target line rejected: "${safe(ln)}" — ${reasons.join(', ')}`);
130
259
  }
131
260
  }
@@ -152,7 +281,15 @@ if (acidArg) {
152
281
  const declaresTable = /\|\s*Acid case\s*\|/i.test(complexity);
153
282
  if (declaresTable && acidTokens.length === 0) failures.push('C4-malformed: 00_complexity_assessment.md declares an acid-case table but NO `| A<n> |` row parsed from it');
154
283
  }
155
- if (acidTokens.length === 0) skips.push('C4: no acid corpus declared (no --acid, no `| A<n> |` table in 00_complexity_assessment.md) — acid-naming check SKIPPED');
284
+ // ABSENT INPUT is not a deliberate skip. Until 2026-08-21 both read the same: `SKIP C4`. The owner
285
+ // asked why the tier is recorded nowhere, and the answer was that Step 0 never writes
286
+ // 00_complexity_assessment.md at all (MEASURED: 66 of 199 features have it; the last four in a row
287
+ // did not) — so the acid check quietly switched itself off for every one of them, including the
288
+ // features that INTRODUCED it. A check that could not read its input must not look like a check that
289
+ // read its input and found nothing to do.
290
+ if (acidTokens.length === 0 && !acidArg && !existsSync(complexityPath)) {
291
+ warnings.push('C4: 00_complexity_assessment.md is ABSENT — the acid-naming check had no input to read (a missing Step-0 artifact, not a clean skip)');
292
+ } else if (acidTokens.length === 0) skips.push('C4: no acid corpus declared (no --acid, no `| A<n> |` table in 00_complexity_assessment.md) — acid-naming check SKIPPED');
156
293
  else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(plan)) failures.push(`C4: acid token "${safe(t)}" (from ${acidSource}) not named in the plan`);
157
294
 
158
295
  // C5 — Inputs read line