@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 +7 -7
- package/CHANGELOG.md +122 -0
- package/README.md +145 -0
- package/package.json +1 -1
- package/sbom.json +6 -6
- package/templates/.claude/skills/feature-adr/modules/06-implementation-plan.md +7 -0
- package/templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs +146 -9
- package/templates/.claude/workflows/feature-adr.js +707 -46
package/.dz-manifest.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "CHANGELOG.md",
|
|
8
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
|
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 =
|
|
111
|
-
|
|
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
|
-
|
|
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
|