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.
Files changed (42) hide show
  1. package/.agents/agents/acceptance-critic.md +11 -2
  2. package/.agents/agents/story-worker.md +4 -2
  3. package/.agents/docs/SDLC.md +11 -4
  4. package/.agents/docs/configuration.md +1 -1
  5. package/.agents/docs/quality-gates.md +3 -3
  6. package/.agents/rules/gherkin-standards.md +10 -0
  7. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  8. package/.agents/schemas/agentrc.schema.json +1 -1
  9. package/.agents/scripts/acceptance-eval.js +2 -2
  10. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
  12. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  13. package/.agents/scripts/lib/orchestration/code-review.js +24 -35
  14. package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
  15. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
  16. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
  17. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
  18. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  19. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  20. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
  22. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  23. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +75 -8
  25. package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
  26. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  27. package/.agents/scripts/plan-context.js +0 -1
  28. package/.agents/scripts/plan-critics.js +203 -0
  29. package/.agents/scripts/quality-preview.js +13 -6
  30. package/.agents/scripts/stories-wave-tick.js +307 -55
  31. package/.agents/workflows/deliver.md +50 -15
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
  33. package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
  34. package/.agents/workflows/helpers/code-review.md +2 -2
  35. package/.agents/workflows/helpers/deliver-story.md +22 -6
  36. package/.agents/workflows/plan.md +55 -0
  37. package/bin/mandrel.js +0 -0
  38. package/docs/CHANGELOG.md +30 -0
  39. package/lib/cli/update.js +83 -34
  40. package/lib/migrations/index.js +6 -1
  41. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  42. 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` with `--changed-since HEAD`, which exercises the
19
- same maintainability and CRAP engines (`escomplex` + `c8` coverage) that
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 --changed-since HEAD` shows the per-file MI delta in the
70
- working tree before the commit lands.
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/code-review.js).
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/code-review.js)
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). Derive the level with
209
- [`deriveChangeLevel`](../../scripts/lib/orchestration/review-depth.js) over the
210
- Story's changed files (`git diff --name-only main...story-<id>`): a diff
211
- touching a sensitive path registered in `.agents/schemas/audit-rules.json`
212
- derives `high`, one touching none derives `low`, and an unenumerable diff
213
- derives `null`.
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)-&lt;id&gt;-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
- * binary** (`<cwd>/node_modules/.bin/mandrel`) rather than in the running
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) similarly uses `shell: true` only on
124
- * Windows: the new binary resolves from `node_modules/.bin/mandrel` (a fixed,
125
- * non-operator-supplied path) and the per-phase argv vector is a constant
126
- * fixed list (e.g. `['sync']`, `['migrate', '--from', v, '--to', v]`,
127
- * `['doctor']`) with no injection risk regardless of the shell flag.
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 path to the `mandrel` binary inside `node_modules/.bin/` for the
678
- * given project root. On Windows the binary is a `.cmd` shim; on POSIX it is a
679
- * plain executable. The resolved path is used as the target for the post-install
680
- * phase re-exec (Story #4034).
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
- * @returns {string} Absolute path to the new binary.
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 resolveNewBinPath(projectRoot) {
686
- const binName = process.platform === 'win32' ? 'mandrel.cmd' : 'mandrel';
687
- return path.join(projectRoot, 'node_modules', '.bin', binName);
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` binary and stream its stdout/stderr through the
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 spawn uses `shell: true` only on Windows where the binary is a `.cmd`
697
- * shim (CVE-2024-27980 parity). The argv vector is a fixed constant list
698
- * per phase no operator-supplied data enters the vector, so the shell flag
699
- * carries no injection risk (security-baseline § Output & Rendering).
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
- * binPath: string,
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
- binPath,
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
- binPath,
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) from the
1255
- * newly-installed binary (`node_modules/.bin/mandrel`). This is the
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 from
1327
- // node_modules/.bin/mandrel (the newly-installed binary). This is the sole
1328
- // post-install execution path (No-Shim Story #4182 retired the in-process
1329
- // runSync/runMigrations/runDoctor seam set). spawnFn is injectable so tests
1330
- // can stub the spawn boundary without running a real child process.
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
  }
@@ -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 = [retireMiDropKnobs, retireVerifyConcurrencyCap];
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.1.0",
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/quality-preview.js --changed-since HEAD && node .agents/scripts/check-dead-exports.js",
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",