mandrel 2.1.0 → 2.3.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/.agents/agents/acceptance-critic.md +11 -2
- package/.agents/agents/story-worker.md +4 -2
- package/.agents/docs/SDLC.md +11 -4
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +3 -3
- package/.agents/rules/gherkin-standards.md +10 -0
- package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
- package/.agents/scripts/lib/orchestration/change-set.js +103 -0
- package/.agents/scripts/lib/orchestration/code-review.js +24 -35
- package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
- package/.agents/scripts/lib/story-body/story-body.js +75 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
- package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
- package/.agents/scripts/plan-context.js +0 -1
- package/.agents/scripts/plan-critics.js +203 -0
- package/.agents/scripts/quality-preview.js +13 -6
- package/.agents/scripts/stories-wave-tick.js +307 -55
- package/.agents/workflows/deliver.md +50 -15
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
- package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
- package/.agents/workflows/helpers/code-review.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +22 -6
- package/.agents/workflows/plan.md +55 -0
- package/bin/mandrel.js +0 -0
- package/docs/CHANGELOG.md +30 -0
- package/lib/cli/update.js +83 -34
- package/lib/migrations/index.js +6 -1
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
- package/package.json +2 -2
|
@@ -15,8 +15,10 @@ read the same numbers from here so a "high cyclomatic complexity" finding in
|
|
|
15
15
|
|
|
16
16
|
Run [`npm run quality:preview`](../../../package.json) before committing
|
|
17
17
|
on any Story that touches production source. The preview runs
|
|
18
|
-
`quality-preview.js
|
|
19
|
-
|
|
18
|
+
`quality-preview.js`, which scopes the diff to `HEAD` by default (the
|
|
19
|
+
alias passes no `--changed-since`; the script defaults to `HEAD`) and
|
|
20
|
+
exercises the same maintainability and CRAP engines (`escomplex` +
|
|
21
|
+
`c8` coverage) that
|
|
20
22
|
`check-baselines.js` enforces at merge time, then merges the results
|
|
21
23
|
into a single per-file delta table. A clean preview means the commit
|
|
22
24
|
will not bounce off the unified baselines gate. The `.husky/pre-commit`
|
|
@@ -66,8 +68,9 @@ must-refactor ceiling — any drop past it is treated as a regression that
|
|
|
66
68
|
must be undone or offset, not absorbed. Set `tolerance` higher only when the
|
|
67
69
|
project deliberately wants a looser MI-drop budget.
|
|
68
70
|
|
|
69
|
-
`quality:preview
|
|
70
|
-
|
|
71
|
+
`quality:preview` shows the per-file MI delta in the working tree before
|
|
72
|
+
the commit lands (scoped to `HEAD` by default — the alias passes no
|
|
73
|
+
`--changed-since`; the script defaults to `HEAD`).
|
|
71
74
|
|
|
72
75
|
## Rename = baseline-refresh
|
|
73
76
|
|
|
@@ -30,7 +30,7 @@ the change set is reviewed by a process the maker cannot influence. The
|
|
|
30
30
|
enforcing code path is
|
|
31
31
|
[`runStoryScopeReview`](../../scripts/lib/orchestration/single-story-close/phases/code-review.js)
|
|
32
32
|
→ shared
|
|
33
|
-
[`runStoryReviewCore`](../../scripts/lib/orchestration/story-close/phases/
|
|
33
|
+
[`runStoryReviewCore`](../../scripts/lib/orchestration/story-close/phases/review-core.js).
|
|
34
34
|
A future refactor MUST preserve this isolation: do not move Story-scope
|
|
35
35
|
review into the maker's context or run it as a step of the delivering
|
|
36
36
|
child.
|
|
@@ -122,7 +122,7 @@ The pipeline will:
|
|
|
122
122
|
### Step 1a — Story-scope local-lens pass (`scope: story` only, Epic #4405)
|
|
123
123
|
|
|
124
124
|
When `scope === 'story'`, the shared review spine
|
|
125
|
-
[`runStoryReviewCore`](../../scripts/lib/orchestration/story-close/phases/
|
|
125
|
+
[`runStoryReviewCore`](../../scripts/lib/orchestration/story-close/phases/review-core.js)
|
|
126
126
|
runs a **shift-left local-lens pass** in the same close subprocess, *before*
|
|
127
127
|
returning the review envelope. It:
|
|
128
128
|
|
|
@@ -205,12 +205,28 @@ Story-path specifics:
|
|
|
205
205
|
Per-Story ceremony is selected by `delivery.routing.ceremonyProfile`
|
|
206
206
|
(`minimal` | `standard` | `strict`, default `standard`) and the Story's
|
|
207
207
|
**derived change level** — not a planner-authored verdict (Story #4542 retired
|
|
208
|
-
that).
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
that).
|
|
209
|
+
|
|
210
|
+
**Compute the change set once** (Story #4593) with the shared enumerator
|
|
211
|
+
[`computeChangeSet`](../../scripts/lib/orchestration/change-set.js) — the same
|
|
212
|
+
module close uses — and reuse that one list for everything downstream:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
node --input-type=module -e '
|
|
216
|
+
import { computeChangeSet } from "<main-repo>/.agents/scripts/lib/orchestration/change-set.js";
|
|
217
|
+
const { files } = computeChangeSet({ baseRef: "main", headRef: "story-<storyId>" });
|
|
218
|
+
console.log(JSON.stringify(files));
|
|
219
|
+
'
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Then derive the level with
|
|
223
|
+
[`deriveChangeLevel`](../../scripts/lib/orchestration/review-depth.js) over that
|
|
224
|
+
list: a diff touching a sensitive path registered in
|
|
225
|
+
`.agents/schemas/audit-rules.json` derives `high`, one touching none derives
|
|
226
|
+
`low`, and an unenumerable diff (`files === null`) derives `null`. Hand the
|
|
227
|
+
**same** list to every acceptance critic you spawn (Step 1a) — a critic that
|
|
228
|
+
re-ran its own `git diff` could score against a different set than the one that
|
|
229
|
+
routed it.
|
|
214
230
|
|
|
215
231
|
Resolve fresh-vs-inline acceptance critics per AC-cluster with
|
|
216
232
|
[`resolveCeremonyForRisk`](../../scripts/lib/orchestration/ceremony-routing.js)
|
|
@@ -141,6 +141,61 @@ total by default — an authored map is the only thing that can say
|
|
|
141
141
|
`#4525-#4528 → #4530` while `#4529 → #4531`, which a blanket "superseded by
|
|
142
142
|
this plan-run" reference could not.
|
|
143
143
|
|
|
144
|
+
### 2.5 Critics
|
|
145
|
+
|
|
146
|
+
Evaluate the critic-dispatch conditions against the authored draft — here,
|
|
147
|
+
**before** persist, because this is the last point where a finding can still
|
|
148
|
+
be folded into a re-author round rather than into live issues:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
node .agents/scripts/plan-critics.js \
|
|
152
|
+
--stories temp/plan-<slug>/stories.json \
|
|
153
|
+
[--tech-spec temp/plan-<slug>/techspec.md]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
It prints a verdict on stdout and exits 0 on **any** verdict — the verdict
|
|
157
|
+
routes work, it does not gate the run. It exits **1** only on a usage/IO
|
|
158
|
+
error (an unreadable or malformed `--stories` / `--tech-spec` path). That is
|
|
159
|
+
not an advisory "proceed": no critic ran and no skip was ledgered, so **do
|
|
160
|
+
not proceed to Persist** — fix the path and re-run:
|
|
161
|
+
|
|
162
|
+
```jsonc
|
|
163
|
+
{
|
|
164
|
+
"consolidation": { "critic": "consolidation", "dispatch": false, "reasons": ["…"] },
|
|
165
|
+
"premortem": { "critic": "pre-mortem", "dispatch": true, "reasons": ["…"] },
|
|
166
|
+
"textHygiene": { "critic": "text-hygiene", "findings": [] }
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The verdict's third entry, `textHygiene`, is advisory-only (Story #4599): it
|
|
171
|
+
carries deterministic body lints (`dangling-citation` / `open-question` /
|
|
172
|
+
`slicing-mass`) with no dispatch semantics — it spawns nothing and never
|
|
173
|
+
gates the run. Fold `textHygiene.findings[]` into the re-author round the
|
|
174
|
+
same way critic findings fold in: fix each named defect in `stories.json`
|
|
175
|
+
(anchor or inline the citation, resolve the question into a declarative
|
|
176
|
+
assumption, thin the Slicing checkpoint) and re-run this step. Empty
|
|
177
|
+
`findings` add nothing to the round.
|
|
178
|
+
|
|
179
|
+
- **Both `dispatch: false`** — proceed straight to Persist. The conditions
|
|
180
|
+
provably have nothing for a critic to find, and each skip is recorded on the
|
|
181
|
+
plan-metrics ledger so under-firing stays auditable.
|
|
182
|
+
- **Either `dispatch: true`** — dispatch **one fresh-context sub-agent per
|
|
183
|
+
firing critic** (a generic sub-agent), then fold its findings into the
|
|
184
|
+
Gate #2 view or a re-author round before persist. Each critic is
|
|
185
|
+
**maker-blind**: hand it the draft artifacts (`stories.json`, and
|
|
186
|
+
`techspec.md` when present) plus its charter below — never the authoring
|
|
187
|
+
transcript or the reasons the planner believed its own draft is sound. A
|
|
188
|
+
critic that reads the maker's case grades the case, not the draft.
|
|
189
|
+
- `consolidation` — the draft's shape: Stories that should be one cohesive
|
|
190
|
+
slice, a slice split per-module rather than per-capability, and
|
|
191
|
+
`depends_on` edges that disagree with the Delivery Slicing table.
|
|
192
|
+
- `pre-mortem` — assume the plan shipped and failed: name the most likely
|
|
193
|
+
failure modes and what the draft would have to say to prevent them.
|
|
194
|
+
|
|
195
|
+
Fold what survives back into `stories.json` and re-run this step. Findings are
|
|
196
|
+
advisory input to the operator's Gate #2 decision, not an automatic re-author
|
|
197
|
+
mandate.
|
|
198
|
+
|
|
144
199
|
### 3. Persist
|
|
145
200
|
|
|
146
201
|
**Gate #2** — when the operator passed `--force-review`, STOP for approval of
|
package/bin/mandrel.js
CHANGED
|
File without changes
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.3.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.2.0...mandrel-v2.3.0) (2026-07-17)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
* **test:** resolve Windows drive-letter path bug in quality-preview test ([#4614](https://github.com/dsj1984/mandrel/issues/4614)) ([a7da94d](https://github.com/dsj1984/mandrel/commit/a7da94d1c4a83b9b721e632e468bb34df07465dd))
|
|
11
|
+
* **update:** make post-install bin re-exec pnpm/layout-agnostic ([#4613](https://github.com/dsj1984/mandrel/issues/4613)) ([#4616](https://github.com/dsj1984/mandrel/issues/4616)) ([82dc5a2](https://github.com/dsj1984/mandrel/commit/82dc5a2e9ce662f9b9c0c7880ea684fd370a507f))
|
|
12
|
+
|
|
13
|
+
## [2.2.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.1.0...mandrel-v2.2.0) (2026-07-17)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
* **deliver:** probe live state per beat, retiring hand-maintained --done/--in-flight (refs [#4594](https://github.com/dsj1984/mandrel/issues/4594)) ([#4597](https://github.com/dsj1984/mandrel/issues/4597)) ([96b2ef6](https://github.com/dsj1984/mandrel/commit/96b2ef6ce5f0526de2e5033e8cdb0ca02f6d5d89))
|
|
19
|
+
* **plan:** add deterministic text-hygiene lints to the critic gate and codify authoring conventions (refs [#4599](https://github.com/dsj1984/mandrel/issues/4599)) ([#4607](https://github.com/dsj1984/mandrel/issues/4607)) ([8794459](https://github.com/dsj1984/mandrel/commit/87944593f8ba5c304cdbaef40a4d3e4430e5d71d))
|
|
20
|
+
* **plan:** retire the [@epic](https://github.com/epic)-<id>-ac-N Gherkin AC tag namespace (refs [#4604](https://github.com/dsj1984/mandrel/issues/4604)) ([#4608](https://github.com/dsj1984/mandrel/issues/4608)) ([7388c58](https://github.com/dsj1984/mandrel/commit/7388c581d43e10a26c538e19a15897d2b5adafa7))
|
|
21
|
+
* rewire the /plan critics into a live pre-persist gate ([#4592](https://github.com/dsj1984/mandrel/issues/4592)) ([#4598](https://github.com/dsj1984/mandrel/issues/4598)) ([349367d](https://github.com/dsj1984/mandrel/commit/349367d46bfbe59be5ce56952bca46cac8e5828c))
|
|
22
|
+
* **story-body:** render numbered ACs, humanized path bullets, and visible wide rationale (refs [#4600](https://github.com/dsj1984/mandrel/issues/4600)) ([#4606](https://github.com/dsj1984/mandrel/issues/4606)) ([843779e](https://github.com/dsj1984/mandrel/commit/843779e2730832a1d7a6e081a0a18b40dd621981))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
* fix probe mode's incomplete live classification: double-dispatch window and blocked-Story wedge ([#4601](https://github.com/dsj1984/mandrel/issues/4601)) ([#4605](https://github.com/dsj1984/mandrel/issues/4605)) ([0875b6a](https://github.com/dsj1984/mandrel/commit/0875b6af857fcfadc423e4e3731d792dc2a5fe60))
|
|
28
|
+
* **story-close:** close [#4593](https://github.com/dsj1984/mandrel/issues/4593)'s single-enumeration leak and root-cause the un-fired MI gate ([#4610](https://github.com/dsj1984/mandrel/issues/4610)) ([1edf5a6](https://github.com/dsj1984/mandrel/commit/1edf5a67ceaf868f8faae92aec1c6280f191d33f))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
* **orchestration:** compute the Story change-set once and thread it through delivery (refs [#4593](https://github.com/dsj1984/mandrel/issues/4593)) ([#4595](https://github.com/dsj1984/mandrel/issues/4595)) ([d116015](https://github.com/dsj1984/mandrel/commit/d1160150836e8bf7107fac24189cde258ea93458))
|
|
34
|
+
|
|
5
35
|
## [2.1.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.0.0...mandrel-v2.1.0) (2026-07-17)
|
|
6
36
|
|
|
7
37
|
|
package/lib/cli/update.js
CHANGED
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
* ## Re-exec of post-install phases (Story #4034)
|
|
37
37
|
*
|
|
38
38
|
* Steps 4–6 execute as **child processes spawned from the newly-installed
|
|
39
|
-
*
|
|
39
|
+
* bin script** (`node <cwd>/node_modules/mandrel/bin/mandrel.js`; Story #4613
|
|
40
|
+
* resolves the script layout-agnostically rather than via the `.bin` shim)
|
|
41
|
+
* rather than in the running
|
|
40
42
|
* process. Node cannot hot-swap a `require`d module mid-process, so without
|
|
41
43
|
* re-exec, the still-running old binary's `runSync`/`runMigrations`/`runDoctor`
|
|
42
44
|
* code would materialise the old payload even though the package on disk has
|
|
@@ -120,11 +122,12 @@
|
|
|
120
122
|
* resolved semver string — see `lib/install-cmd-parser.js` for the shared
|
|
121
123
|
* tokenize-and-spawn rationale this module reuses (no duplicated workaround).
|
|
122
124
|
*
|
|
123
|
-
* The `spawnPhase` default (Story #4034)
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* `['
|
|
125
|
+
* The `spawnPhase` default (Story #4034) does **not** use the win32 shell flag:
|
|
126
|
+
* it spawns `process.execPath` (node) against the resolved `bin/mandrel.js`
|
|
127
|
+
* script (Story #4613), so it never touches a `.cmd` shim and needs no
|
|
128
|
+
* shell on any platform. The per-phase argv vector is a constant fixed list
|
|
129
|
+
* (e.g. `['sync']`, `['migrate', '--from', v, '--to', v]`, `['doctor']`) with
|
|
130
|
+
* no operator-supplied data.
|
|
128
131
|
*/
|
|
129
132
|
|
|
130
133
|
import { spawnSync } from 'node:child_process';
|
|
@@ -674,29 +677,55 @@ function parseChangelogSections(raw) {
|
|
|
674
677
|
}
|
|
675
678
|
|
|
676
679
|
/**
|
|
677
|
-
* Resolve the
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
680
|
+
* Resolve the newly-installed `mandrel` bin **script**
|
|
681
|
+
* (`<packageRoot>/bin/mandrel.js`) from the consumer project root. This is the
|
|
682
|
+
* target for the post-install phase re-exec (Story #4034), spawned via
|
|
683
|
+
* `process.execPath` (node) rather than executed directly — see
|
|
684
|
+
* {@link defaultSpawnPhase}.
|
|
685
|
+
*
|
|
686
|
+
* It deliberately does **not** return the `node_modules/.bin/mandrel` shim.
|
|
687
|
+
* That shim only works because npm chmods the bin target `+x` at install time:
|
|
688
|
+
* `bin/mandrel.js` ships non-executable in the published tarball, and pnpm
|
|
689
|
+
* symlinks `.bin/mandrel` straight at it, so spawning the shim directly fails
|
|
690
|
+
* with `EACCES` under pnpm (Story #4613). Spawning node against the resolved
|
|
691
|
+
* `.js` script removes the dependency on the exec bit, the shebang, and the
|
|
692
|
+
* Windows `.cmd` shim entirely.
|
|
693
|
+
*
|
|
694
|
+
* Resolution reuses the same consumer-anchored resolver
|
|
695
|
+
* (`defaultResolvePackageRoot`) that {@link resolveCurrentVersionForUpdate}
|
|
696
|
+
* uses, so it points at the consumer's install rather than a copy hoisted next
|
|
697
|
+
* to this CLI module. The `mandrel` package directory is version-invariant
|
|
698
|
+
* (`node_modules/mandrel/`), so resolving it before the in-place `npm-update`
|
|
699
|
+
* step still yields the directory whose `bin/mandrel.js` the install overwrites.
|
|
681
700
|
*
|
|
682
701
|
* @param {string} projectRoot - Absolute path to the consumer project.
|
|
683
|
-
* @
|
|
702
|
+
* @param {{ resolvePackageRoot?: (fromDir: string) => string }} [opts] - test
|
|
703
|
+
* seam for the `node_modules` resolution; defaults to the real
|
|
704
|
+
* `defaultResolvePackageRoot` from `sync.js`.
|
|
705
|
+
* @returns {string} Absolute path to the new bin script.
|
|
684
706
|
*/
|
|
685
|
-
export function
|
|
686
|
-
|
|
687
|
-
|
|
707
|
+
export function resolveNewBinScriptPath(
|
|
708
|
+
projectRoot,
|
|
709
|
+
{ resolvePackageRoot = defaultResolvePackageRoot } = {},
|
|
710
|
+
) {
|
|
711
|
+
const packageRoot = resolvePackageRoot(projectRoot);
|
|
712
|
+
return path.join(packageRoot, 'bin', 'mandrel.js');
|
|
688
713
|
}
|
|
689
714
|
|
|
690
715
|
/**
|
|
691
716
|
* Default `spawnPhase` seam (Story #4034): spawn a post-install phase from the
|
|
692
|
-
* newly-installed `mandrel`
|
|
717
|
+
* newly-installed `mandrel` bin script and stream its stdout/stderr through the
|
|
693
718
|
* parent's write sinks. Each phase runs as an isolated child process so the
|
|
694
719
|
* newly-installed module code (not the currently-loaded old module) executes.
|
|
695
720
|
*
|
|
696
|
-
* The
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
721
|
+
* The child is spawned as `process.execPath <binScript> <phase> …` — node run
|
|
722
|
+
* against the resolved `bin/mandrel.js` (see {@link resolveNewBinScriptPath}).
|
|
723
|
+
* Spawning node against a plain `.js` file removes any dependency on the bin's
|
|
724
|
+
* exec bit, its shebang, or a Windows `.cmd` shim, so **no** `shell` flag is
|
|
725
|
+
* needed on any platform (this is the pnpm/layout-agnostic fix, Story #4613,
|
|
726
|
+
* that retired the former win32-only `shell: true` branch). The argv vector is
|
|
727
|
+
* a fixed constant list per phase — no operator-supplied data enters it
|
|
728
|
+
* (security-baseline § Output & Rendering).
|
|
700
729
|
*
|
|
701
730
|
* Throws when the child exits non-zero so the orchestrator can surface the
|
|
702
731
|
* failure to the operator.
|
|
@@ -709,7 +738,8 @@ export function resolveNewBinPath(projectRoot) {
|
|
|
709
738
|
* write: (s: string) => void,
|
|
710
739
|
* writeErr: (s: string) => void,
|
|
711
740
|
* spawnFn?: typeof spawnSync,
|
|
712
|
-
* }} opts
|
|
741
|
+
* }} opts - `binPath` is the resolved bin **script** path (not the
|
|
742
|
+
* `node_modules/.bin` shim); it becomes node's first argv entry.
|
|
713
743
|
* @returns {{ ok: boolean, stdout: string, stderr: string }}
|
|
714
744
|
*/
|
|
715
745
|
export function defaultSpawnPhase(
|
|
@@ -718,10 +748,9 @@ export function defaultSpawnPhase(
|
|
|
718
748
|
{ binPath, cwd, write, writeErr, spawnFn = spawnSync },
|
|
719
749
|
) {
|
|
720
750
|
const argv = [phase, ...args];
|
|
721
|
-
const r = spawnFn(binPath, argv, {
|
|
751
|
+
const r = spawnFn(process.execPath, [binPath, ...argv], {
|
|
722
752
|
cwd,
|
|
723
753
|
encoding: 'utf8',
|
|
724
|
-
shell: process.platform === 'win32',
|
|
725
754
|
});
|
|
726
755
|
const stdout = typeof r.stdout === 'string' ? r.stdout : '';
|
|
727
756
|
const stderr = typeof r.stderr === 'string' ? r.stderr : '';
|
|
@@ -980,7 +1009,7 @@ async function resolveDrift(checkDrift) {
|
|
|
980
1009
|
* npmUpdate: ((version: string, opts: { installCmd?: string }) => unknown | Promise<unknown>) | undefined,
|
|
981
1010
|
* spawnPhase: ((phase: string, args: string[], opts: object) => { ok: boolean } | Promise<{ ok: boolean }>) | undefined,
|
|
982
1011
|
* surfaceChangelog: ((version: string) => unknown | Promise<unknown>) | undefined,
|
|
983
|
-
*
|
|
1012
|
+
* resolveBinScript: (projectRoot: string) => string,
|
|
984
1013
|
* projectRoot: string,
|
|
985
1014
|
* write: (s: string) => void,
|
|
986
1015
|
* writeErr: (s: string) => void,
|
|
@@ -995,7 +1024,7 @@ async function executePlan({
|
|
|
995
1024
|
npmUpdate,
|
|
996
1025
|
spawnPhase,
|
|
997
1026
|
surfaceChangelog,
|
|
998
|
-
|
|
1027
|
+
resolveBinScript,
|
|
999
1028
|
projectRoot,
|
|
1000
1029
|
write,
|
|
1001
1030
|
writeErr,
|
|
@@ -1004,6 +1033,17 @@ async function executePlan({
|
|
|
1004
1033
|
const stepsRun = [];
|
|
1005
1034
|
let doctorOk = true;
|
|
1006
1035
|
|
|
1036
|
+
// Resolve the new bin script lazily and once, on the first spawn phase.
|
|
1037
|
+
// Deferring it past the `npm-update` step means (a) a missing `npmUpdate`
|
|
1038
|
+
// seam surfaces its own clear error first, and (b) resolution reflects the
|
|
1039
|
+
// just-installed package. The `mandrel` package directory is version-stable,
|
|
1040
|
+
// so resolving after the in-place bump yields the same directory either way.
|
|
1041
|
+
let binPath;
|
|
1042
|
+
const binScript = () => {
|
|
1043
|
+
if (binPath === undefined) binPath = resolveBinScript(projectRoot);
|
|
1044
|
+
return binPath;
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1007
1047
|
for (const step of steps) {
|
|
1008
1048
|
if (step.kind === 'npm-update') {
|
|
1009
1049
|
// Bump the dependency. The lockfile change is left STAGED on disk; this
|
|
@@ -1024,7 +1064,7 @@ async function executePlan({
|
|
|
1024
1064
|
// package's module code — not the old loaded module — executes.
|
|
1025
1065
|
// eslint-disable-next-line no-await-in-loop
|
|
1026
1066
|
const result = await spawnPhase(step.phase, step.args, {
|
|
1027
|
-
binPath,
|
|
1067
|
+
binPath: binScript(),
|
|
1028
1068
|
cwd: projectRoot,
|
|
1029
1069
|
write,
|
|
1030
1070
|
writeErr,
|
|
@@ -1080,6 +1120,7 @@ async function executePlan({
|
|
|
1080
1120
|
* writeErr?: (s: string) => void,
|
|
1081
1121
|
* exit?: (code: number) => void,
|
|
1082
1122
|
* cwd?: () => string,
|
|
1123
|
+
* resolveBinScript?: (projectRoot: string) => string,
|
|
1083
1124
|
* }} [opts]
|
|
1084
1125
|
* @returns {Promise<{
|
|
1085
1126
|
* ok: boolean,
|
|
@@ -1102,6 +1143,7 @@ export async function runUpdate({
|
|
|
1102
1143
|
writeErr = (s) => process.stderr.write(s),
|
|
1103
1144
|
exit = (code) => process.exit(code),
|
|
1104
1145
|
cwd = () => process.cwd(),
|
|
1146
|
+
resolveBinScript = resolveNewBinScriptPath,
|
|
1105
1147
|
} = {}) {
|
|
1106
1148
|
const dryRun = argv.includes('--dry-run');
|
|
1107
1149
|
const installCmd = parseInstallCmdFlag(argv);
|
|
@@ -1176,7 +1218,6 @@ export async function runUpdate({
|
|
|
1176
1218
|
|
|
1177
1219
|
// --- resynced / updated: execute the phase plan ---------------------------
|
|
1178
1220
|
const projectRoot = cwd();
|
|
1179
|
-
const binPath = resolveNewBinPath(projectRoot);
|
|
1180
1221
|
|
|
1181
1222
|
if (plan.action === 'resynced') {
|
|
1182
1223
|
write(
|
|
@@ -1193,7 +1234,7 @@ export async function runUpdate({
|
|
|
1193
1234
|
npmUpdate,
|
|
1194
1235
|
spawnPhase,
|
|
1195
1236
|
surfaceChangelog,
|
|
1196
|
-
|
|
1237
|
+
resolveBinScript,
|
|
1197
1238
|
projectRoot,
|
|
1198
1239
|
write,
|
|
1199
1240
|
writeErr,
|
|
@@ -1251,8 +1292,9 @@ export async function runUpdate({
|
|
|
1251
1292
|
* through the shared `runInstallCommand` helper — no git mutation;
|
|
1252
1293
|
* lockfile left staged.
|
|
1253
1294
|
* - `spawnPhase` is wired to `defaultSpawnPhase`, which spawns each
|
|
1254
|
-
* post-install phase (sync, sync-commands, migrate, doctor)
|
|
1255
|
-
*
|
|
1295
|
+
* post-install phase (sync, sync-commands, migrate, doctor) as
|
|
1296
|
+
* `node <packageRoot>/bin/mandrel.js …` (Story #4613 — the resolved bin
|
|
1297
|
+
* script, not the `node_modules/.bin` shim). This is the
|
|
1256
1298
|
* Story #4034 fix: the new bin loads the new package's module code and
|
|
1257
1299
|
* resolves paths against the new install dir, so these phases can never
|
|
1258
1300
|
* observe the old payload.
|
|
@@ -1289,6 +1331,7 @@ export async function runUpdate({
|
|
|
1289
1331
|
* fetchChangelog?: (version: string) => Promise<string>,
|
|
1290
1332
|
* runUpdate?: typeof runUpdate,
|
|
1291
1333
|
* cwd?: () => string,
|
|
1334
|
+
* resolveBinScript?: (projectRoot: string) => string,
|
|
1292
1335
|
* checkDrift?: () => (boolean | Promise<boolean>),
|
|
1293
1336
|
* write?: (s: string) => void,
|
|
1294
1337
|
* writeErr?: (s: string) => void,
|
|
@@ -1313,6 +1356,7 @@ export default async function run(argv = [], deps = {}) {
|
|
|
1313
1356
|
exit = (code) => process.exit(code),
|
|
1314
1357
|
log,
|
|
1315
1358
|
cwd,
|
|
1359
|
+
resolveBinScript,
|
|
1316
1360
|
checkDrift,
|
|
1317
1361
|
} = deps;
|
|
1318
1362
|
|
|
@@ -1323,11 +1367,13 @@ export default async function run(argv = [], deps = {}) {
|
|
|
1323
1367
|
const current =
|
|
1324
1368
|
deps.currentVersion ?? resolveCurrentVersionForUpdate(cwdFn(), fs);
|
|
1325
1369
|
|
|
1326
|
-
// The production spawnPhase: spawn each post-install phase
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1370
|
+
// The production spawnPhase: spawn each post-install phase as
|
|
1371
|
+
// `node <packageRoot>/bin/mandrel.js …` (the newly-installed bin script,
|
|
1372
|
+
// resolved layout-agnostically per Story #4613 — not the node_modules/.bin
|
|
1373
|
+
// shim). This is the sole post-install execution path (No-Shim — Story #4182
|
|
1374
|
+
// retired the in-process runSync/runMigrations/runDoctor seam set). spawnFn
|
|
1375
|
+
// is injectable so tests can stub the spawn boundary without running a real
|
|
1376
|
+
// child process.
|
|
1331
1377
|
const productionSpawnPhase = (phase, args, opts) =>
|
|
1332
1378
|
defaultSpawnPhase(phase, args, {
|
|
1333
1379
|
...opts,
|
|
@@ -1370,5 +1416,8 @@ export default async function run(argv = [], deps = {}) {
|
|
|
1370
1416
|
writeErr,
|
|
1371
1417
|
exit,
|
|
1372
1418
|
cwd: cwdFn,
|
|
1419
|
+
// Pass through undefined in production so runUpdate applies its default
|
|
1420
|
+
// resolver (resolveNewBinScriptPath); tests inject a stub for a fake root.
|
|
1421
|
+
resolveBinScript,
|
|
1373
1422
|
});
|
|
1374
1423
|
}
|
package/lib/migrations/index.js
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
import { retireMiDropKnobs } from './steps/2.1.0-retire-mi-drop-knobs.js';
|
|
56
56
|
import { retireVerifyConcurrencyCap } from './steps/2.1.0-retire-verify-concurrency-cap.js';
|
|
57
|
+
import { retireEpicAcTags } from './steps/2.2.0-retire-epic-ac-tags.js';
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* Ordered registry of migration steps. MUST stay sorted ascending by
|
|
@@ -66,7 +67,11 @@ import { retireVerifyConcurrencyCap } from './steps/2.1.0-retire-verify-concurre
|
|
|
66
67
|
* apply: (ctx: unknown) => void,
|
|
67
68
|
* }>}
|
|
68
69
|
*/
|
|
69
|
-
export const migrations = [
|
|
70
|
+
export const migrations = [
|
|
71
|
+
retireMiDropKnobs,
|
|
72
|
+
retireVerifyConcurrencyCap,
|
|
73
|
+
retireEpicAcTags,
|
|
74
|
+
];
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* Parse a dotted semver-ish string into a numeric tuple for comparison.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// lib/migrations/steps/2.2.0-retire-epic-ac-tags.js
|
|
2
|
+
/**
|
|
3
|
+
* Story #4604 — strip the retired `@epic-<id>-ac-N` Gherkin AC tag namespace
|
|
4
|
+
* from consumer feature files.
|
|
5
|
+
*
|
|
6
|
+
* The v2 Epic removal deleted `acceptance-spec-reconciler.js`, the only
|
|
7
|
+
* consumer of the namespaced per-Epic AC tags, and the `/plan` authoring
|
|
8
|
+
* prompt no longer mandates them. Surviving tags in consumer `.feature`
|
|
9
|
+
* files are inert and violate the gherkin-standards tag taxonomy's
|
|
10
|
+
* no-ad-hoc-tags rule, so this step removes them: each `@epic-<digits>-ac-<digits>`
|
|
11
|
+
* token is deleted from tag lines, a tag line left with no tags is dropped
|
|
12
|
+
* entirely, and every other tag and line is preserved byte-for-byte. Files
|
|
13
|
+
* with no stale tags are never rewritten.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import nodeFs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mirror of `CANONICAL_FEATURE_ROOTS` in
|
|
21
|
+
* `.agents/scripts/lib/bdd-runner-detect.js`. Duplicated deliberately:
|
|
22
|
+
* `lib/` runs from the installed npm package inside a consumer project and
|
|
23
|
+
* must not import from the materialized `.agents/` payload.
|
|
24
|
+
*/
|
|
25
|
+
const CANONICAL_FEATURE_ROOTS = Object.freeze([
|
|
26
|
+
'tests/features',
|
|
27
|
+
'features',
|
|
28
|
+
'test/features',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const EPIC_AC_TAG_RE = /@epic-\d+-ac-\d+/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A Gherkin tag line: optional indentation followed by one or more
|
|
35
|
+
* whitespace-separated `@tag` tokens and nothing else.
|
|
36
|
+
*/
|
|
37
|
+
const TAG_LINE_RE = /^(\s*)(@\S+(?:\s+@\S+)*)\s*$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Recursively collect `.feature` file paths under `root`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} root
|
|
43
|
+
* @param {typeof nodeFs} fsImpl
|
|
44
|
+
* @returns {string[]}
|
|
45
|
+
*/
|
|
46
|
+
function collectFeatureFiles(root, fsImpl) {
|
|
47
|
+
/** @type {string[]} */
|
|
48
|
+
const found = [];
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const queue = [root];
|
|
51
|
+
while (queue.length > 0) {
|
|
52
|
+
const dir = queue.pop();
|
|
53
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fsImpl.readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const full = path.join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
queue.push(full);
|
|
64
|
+
} else if (entry.isFile() && entry.name.endsWith('.feature')) {
|
|
65
|
+
found.push(full);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return found.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} ctx
|
|
74
|
+
* @param {typeof nodeFs} fsImpl
|
|
75
|
+
* @returns {string[]} Absolute paths of every `.feature` file under the
|
|
76
|
+
* canonical feature roots that exist in the consumer tree.
|
|
77
|
+
*/
|
|
78
|
+
function resolveFeatureFiles(ctx, fsImpl) {
|
|
79
|
+
const projectRoot = ctx?.projectRoot ?? process.cwd();
|
|
80
|
+
return CANONICAL_FEATURE_ROOTS.flatMap((root) =>
|
|
81
|
+
collectFeatureFiles(path.join(projectRoot, root), fsImpl),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Strip retired `@epic-<id>-ac-<n>` tokens from one file's content.
|
|
87
|
+
* Only tag lines are touched; a tag line whose every tag was retired is
|
|
88
|
+
* dropped. Returns the original string when nothing matched.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} content
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function stripEpicAcTags(content) {
|
|
94
|
+
if (!EPIC_AC_TAG_RE.test(content)) return content;
|
|
95
|
+
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
|
96
|
+
const lines = content.split(newline);
|
|
97
|
+
/** @type {string[]} */
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
const match = line.match(TAG_LINE_RE);
|
|
101
|
+
if (!match || !EPIC_AC_TAG_RE.test(line)) {
|
|
102
|
+
out.push(line);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const [, indent, tagBlock] = match;
|
|
106
|
+
const kept = tagBlock
|
|
107
|
+
.split(/\s+/)
|
|
108
|
+
.filter((tag) => !EPIC_AC_TAG_RE.test(tag));
|
|
109
|
+
if (kept.length === 0) continue;
|
|
110
|
+
out.push(`${indent}${kept.join(' ')}`);
|
|
111
|
+
}
|
|
112
|
+
return out.join(newline);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const retireEpicAcTags = {
|
|
116
|
+
version: '2.2.0',
|
|
117
|
+
description:
|
|
118
|
+
'strip retired @epic-<id>-ac-N Gherkin AC tags from feature files ' +
|
|
119
|
+
'(their reconciler consumer was deleted in the v2 Epic removal)',
|
|
120
|
+
/**
|
|
121
|
+
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
detect(ctx) {
|
|
125
|
+
const fsImpl = ctx?.fs ?? nodeFs;
|
|
126
|
+
return resolveFeatureFiles(ctx, fsImpl).some((file) => {
|
|
127
|
+
try {
|
|
128
|
+
return EPIC_AC_TAG_RE.test(fsImpl.readFileSync(file, 'utf8'));
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
/**
|
|
135
|
+
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
136
|
+
* @returns {void}
|
|
137
|
+
*/
|
|
138
|
+
apply(ctx) {
|
|
139
|
+
const fsImpl = ctx?.fs ?? nodeFs;
|
|
140
|
+
for (const file of resolveFeatureFiles(ctx, fsImpl)) {
|
|
141
|
+
/** @type {string} */
|
|
142
|
+
let content;
|
|
143
|
+
try {
|
|
144
|
+
content = fsImpl.readFileSync(file, 'utf8');
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const stripped = stripEpicAcTags(content);
|
|
149
|
+
if (stripped !== content) {
|
|
150
|
+
fsImpl.writeFileSync(file, stripped);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
|
|
5
5
|
"files": [
|
|
6
6
|
".agents/",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"crap:update": "node .agents/scripts/update-crap-baseline.js",
|
|
46
46
|
"duplication:check": "node .agents/scripts/check-baselines.js --gate duplication",
|
|
47
47
|
"duplication:update": "node .agents/scripts/update-duplication-baseline.js",
|
|
48
|
-
"quality:preview": "node .agents/scripts/
|
|
48
|
+
"quality:preview": "node .agents/scripts/check-dead-exports.js && node .agents/scripts/quality-preview.js",
|
|
49
49
|
"quality:watch": "node .agents/scripts/quality-watch.js",
|
|
50
50
|
"sync:commands": "node bin/mandrel.js sync-commands",
|
|
51
51
|
"sync:agents": "node .agents/scripts/sync-claude-agents.js",
|