mandrel 2.57.0 → 2.59.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 (58) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/agents/story-worker.md +12 -11
  3. package/.agents/docs/SDLC.md +6 -7
  4. package/.agents/docs/quality-gates.md +1 -1
  5. package/.agents/instructions.md +2 -3
  6. package/.agents/runtime-deps.json +7 -2
  7. package/.agents/schemas/crap-baseline.schema.json +1 -1
  8. package/.agents/schemas/crap-report.schema.json +1 -1
  9. package/.agents/scripts/evidence-gate.js +17 -1
  10. package/.agents/scripts/install-matrix-assert.js +48 -3
  11. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  12. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  13. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  14. package/.agents/scripts/lib/crap-engine.js +2 -2
  15. package/.agents/scripts/lib/crap-utils.js +21 -5
  16. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  17. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  18. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  19. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  20. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  21. package/.agents/scripts/lib/orchestration/plan-context.js +41 -27
  22. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  23. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
  24. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
  25. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +45 -31
  26. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
  27. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
  28. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  29. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +15 -5
  30. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  32. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  34. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  35. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  36. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  37. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  38. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  39. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  40. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  41. package/.agents/scripts/lib/story-body/story-body.js +36 -2
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +73 -21
  43. package/.agents/scripts/lib/test-run-credit.js +23 -12
  44. package/.agents/scripts/plan-persist.js +0 -11
  45. package/.agents/skills/skills.index.json +1 -11
  46. package/.agents/workflows/audit-to-stories.md +14 -11
  47. package/.agents/workflows/helpers/deliver-digest.md +22 -15
  48. package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
  49. package/.agents/workflows/helpers/deliver-story.md +6 -5
  50. package/.agents/workflows/helpers/plan-reference.md +53 -13
  51. package/.agents/workflows/mandrel-plan.md +19 -14
  52. package/README.md +3 -3
  53. package/docs/CHANGELOG.md +21 -0
  54. package/lib/cli/registry.js +143 -27
  55. package/package.json +7 -2
  56. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  57. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  58. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -0,0 +1,155 @@
1
+ /**
2
+ * runtime-deps/dep-resolution — is a declared runtime dependency actually
3
+ * there, is it the right major, and how do we say so.
4
+ *
5
+ * `.agents/` materializes into the consumer's repository root, so every
6
+ * framework runtime dependency resolves from *their* `node_modules`. A range
7
+ * in `.agents/runtime-deps.json` therefore documents a requirement it cannot
8
+ * enforce, and the preflight guard needs to compare the range against what
9
+ * actually resolved.
10
+ *
11
+ * Deliberately major-only, and deliberately not `semver`. The framework's
12
+ * runtime ranges are all `^`, whose whole contract is "this major"; pulling in
13
+ * a semver implementation to decide one comparison would add a dependency to
14
+ * the very closure this module exists to keep honest.
15
+ *
16
+ * @module lib/runtime-deps/dep-resolution
17
+ */
18
+
19
+ /**
20
+ * Leading major number of a version or a caret/tilde range, or `null`.
21
+ *
22
+ * Module-local: `majorMismatch` is the only question callers have.
23
+ *
24
+ * Anything this cannot read as `<major>.` — `*`, a tag, a git URL, a
25
+ * `>=x <y` span — yields `null` and is treated as "not range-checked". That
26
+ * asymmetry is intentional: a conservative miss is a no-op, while a false
27
+ * positive blocks a working install.
28
+ *
29
+ * @param {string|null|undefined} spec
30
+ * @returns {number|null}
31
+ */
32
+ function majorOf(spec) {
33
+ if (typeof spec !== 'string') return null;
34
+ const match = /^[\^~]?(\d+)\./.exec(spec.trim());
35
+ return match ? Number(match[1]) : null;
36
+ }
37
+
38
+ /**
39
+ * Does `resolved` sit outside the major `range` names?
40
+ *
41
+ * Module-local: `checkRuntimeDeps` is the only caller, and exporting it only
42
+ * for a test would be a production-dead export.
43
+ *
44
+ * `false` whenever either side is unreadable, so an unparseable range or an
45
+ * unreadable installed version is never reported as a mismatch.
46
+ *
47
+ * `0.x` majors compare as written: `^0.1.0` and `0.2.1` differ in minor, not
48
+ * major, so this does not separate them. Accepted — the `0.x` packages in the
49
+ * closure are terminal, and the range this exists to enforce is
50
+ * `@babel/parser`'s `^7`.
51
+ *
52
+ * @param {string|null|undefined} range
53
+ * @param {string|null|undefined} resolved
54
+ * @returns {boolean}
55
+ */
56
+ function majorMismatch(range, resolved) {
57
+ const want = majorOf(range);
58
+ if (want === null) return false;
59
+ const got = majorOf(resolved);
60
+ if (got === null) return false;
61
+ return want !== got;
62
+ }
63
+
64
+ /**
65
+ * Is a package present in the resolvable tree?
66
+ *
67
+ * The bare specifier is tried first, then `<name>/package.json`. The fallback
68
+ * is not belt-and-braces: a package with no `main` and no `exports` — which
69
+ * `typhonjs-escomplex-commons` and `babel-runtime` both are — cannot be
70
+ * resolved by name at all, and is reached only by deep path. Probing the bare
71
+ * name alone would report such a package missing while it sits installed, and
72
+ * this guard exits the process on that verdict.
73
+ *
74
+ * @param {string} dep
75
+ * @param {(specifier: string) => string} resolve
76
+ * @returns {boolean}
77
+ */
78
+ export function isResolvable(dep, resolve) {
79
+ try {
80
+ resolve(dep);
81
+ return true;
82
+ } catch {
83
+ // fall through to the manifest probe
84
+ }
85
+ try {
86
+ resolve(`${dep}/package.json`);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Remediation text for a resolved dependency whose major differs from the
95
+ * range the framework declares.
96
+ *
97
+ * Named separately from the missing-deps message because the remedy differs:
98
+ * the package is installed, so installing again changes nothing. What is
99
+ * wrong is the version the consumer's own tree resolves, which only they can
100
+ * change.
101
+ *
102
+ * @param {{name: string, required: string, resolved: string}[]} mismatched
103
+ * @param {{ root: string }} ctx
104
+ * @returns {string}
105
+ */
106
+ export function formatMismatchedDepsMessage(mismatched, { root }) {
107
+ const lines = mismatched.map(
108
+ (m) => ` - ${m.name}: need ${m.required}, resolved ${m.resolved}`,
109
+ );
110
+ return [
111
+ 'Mandrel framework runtime dependency version mismatch:',
112
+ ...lines,
113
+ '',
114
+ `Resolved from: ${root}`,
115
+ 'These packages are resolved from your repository, not from mandrel, so',
116
+ 'the version your tree installs is the version the framework gets. Pin a',
117
+ 'compatible major in your package.json and reinstall.',
118
+ ].join('\n');
119
+ }
120
+
121
+ /**
122
+ * Resolve each required package via the injected `resolve` seam and collect
123
+ * the ones that fail. `resolve` is typically `require.resolve` bound to the
124
+ * framework module location; it throws `MODULE_NOT_FOUND` when a package is
125
+ * absent from the resolvable `node_modules`.
126
+ *
127
+ * @param {{ required: string[], resolve: (specifier: string) => string }} opts
128
+ * @returns {{ ok: boolean, missing: string[] }}
129
+ */
130
+ export function checkRuntimeDeps({
131
+ required,
132
+ resolve,
133
+ ranges = null,
134
+ readVersion = null,
135
+ }) {
136
+ const missing = [];
137
+ const mismatched = [];
138
+ for (const dep of required) {
139
+ if (!isResolvable(dep, resolve)) {
140
+ missing.push(dep);
141
+ continue;
142
+ }
143
+ if (!ranges || !readVersion) continue;
144
+ const range = ranges[dep];
145
+ const resolved = readVersion(dep);
146
+ if (majorMismatch(range, resolved)) {
147
+ mismatched.push({ name: dep, required: range, resolved });
148
+ }
149
+ }
150
+ return {
151
+ ok: missing.length === 0 && mismatched.length === 0,
152
+ missing,
153
+ mismatched,
154
+ };
155
+ }
@@ -28,12 +28,13 @@
28
28
  */
29
29
 
30
30
  import { createRequire } from 'node:module';
31
- import { loadRuntimeDepsManifest } from './manifest.js';
31
+ import { resolveDependencyVersion } from '../dependency-version.js';
32
32
  import {
33
33
  checkRuntimeDeps,
34
- detectPackageManager,
35
- formatMissingDepsMessage,
36
- } from './preflight.js';
34
+ formatMismatchedDepsMessage,
35
+ } from './dep-resolution.js';
36
+ import { loadRuntimeDepsManifest } from './manifest.js';
37
+ import { detectPackageManager, formatMissingDepsMessage } from './preflight.js';
37
38
 
38
39
  // `require.resolve` bound to this module's location walks `node_modules`
39
40
  // upward from `.agents/scripts/lib/runtime-deps/` to the consumer root —
@@ -50,7 +51,8 @@ const frameworkRequire = createRequire(import.meta.url);
50
51
  * cwd?: string,
51
52
  * stderr?: { write: (s: string) => void },
52
53
  * exit?: (code: number) => void,
53
- * manifest?: { required: string[] },
54
+ * manifest?: { required: string[], dependencies?: Record<string,string> },
55
+ * readVersion?: (name: string) => string | null,
54
56
  * }} [opts]
55
57
  * @returns {{ ok: boolean, missing: string[] }}
56
58
  */
@@ -61,6 +63,7 @@ export function ensureRuntimeDepsInstalled(opts = {}) {
61
63
  stderr = process.stderr,
62
64
  exit = process.exit,
63
65
  manifest = safeLoadManifest(),
66
+ readVersion,
64
67
  } = opts;
65
68
 
66
69
  // A manifest we cannot read is a packaging defect the drift test owns —
@@ -70,17 +73,49 @@ export function ensureRuntimeDepsInstalled(opts = {}) {
70
73
  const result = checkRuntimeDeps({
71
74
  required: manifest.required,
72
75
  resolve: requireResolve,
76
+ ranges: manifest.dependencies ?? null,
77
+ readVersion: readVersion ?? defaultReadVersion,
73
78
  });
74
79
  if (result.ok) return result;
75
80
 
76
- const packageManager = detectPackageManager(cwd);
77
- stderr.write(
78
- `${formatMissingDepsMessage(result.missing, { root: cwd, packageManager })}\n`,
79
- );
81
+ stderr.write(`${describeFailure(result, cwd)}\n`);
80
82
  exit(1);
81
83
  return result;
82
84
  }
83
85
 
86
+ /**
87
+ * Remediation text for a failed check.
88
+ *
89
+ * Absence is reported first: a package that is not installed cannot have a
90
+ * version, and installing it is the prerequisite for any version complaint
91
+ * being actionable.
92
+ *
93
+ * @param {{ missing: string[], mismatched: {name: string, required: string, resolved: string}[] }} result
94
+ * @param {string} cwd
95
+ * @returns {string}
96
+ */
97
+ function describeFailure(result, cwd) {
98
+ if (result.missing.length === 0) {
99
+ return formatMismatchedDepsMessage(result.mismatched, { root: cwd });
100
+ }
101
+ const packageManager = detectPackageManager(cwd);
102
+ return formatMissingDepsMessage(result.missing, {
103
+ root: cwd,
104
+ packageManager,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Read a resolved package's version through the framework's own resolution,
110
+ * so the version checked is the one the framework's imports will load.
111
+ *
112
+ * @param {string} name
113
+ * @returns {string | null}
114
+ */
115
+ function defaultReadVersion(name) {
116
+ return resolveDependencyVersion(name, frameworkRequire);
117
+ }
118
+
84
119
  /**
85
120
  * Load the manifest, swallowing a read/parse failure to `null` so the guard
86
121
  * stays inert on a packaging defect (see `ensureRuntimeDepsInstalled`).
@@ -0,0 +1,110 @@
1
+ /**
2
+ * runtime-deps/parser-major — which `@babel/parser` major the complexity
3
+ * kernel can actually parse with, and how to say so when it is wrong.
4
+ *
5
+ * Its own module because two very different callers need the same answer and
6
+ * neither should drag the other in: the kernel asserts it at load, and
7
+ * `mandrel doctor` reports it to a consumer. Importing the kernel into doctor
8
+ * to ask one version question would pull the whole metric core and install the
9
+ * AST compatibility patch as a side effect.
10
+ *
11
+ * `.agents/` materializes into the consumer's repository root, so
12
+ * `@babel/parser` resolves from *their* `node_modules`. A range in
13
+ * `runtime-deps.json` documents the requirement; it cannot enforce it. This is
14
+ * the enforcement.
15
+ *
16
+ * @module lib/runtime-deps/parser-major
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+
22
+ /**
23
+ * The only `@babel/parser` major the kernel supports.
24
+ *
25
+ * Module-local, with `describeParserMajorError` as the single public door:
26
+ * every caller wants the verdict and the remedy, not the number.
27
+ *
28
+ * 8.x removed several plugin names from the kernel's fixed list (they became
29
+ * default syntax), so it does not merely warn — it throws on the plugin list
30
+ * itself. Adopting it is a deliberate change with a baseline recut attached,
31
+ * not something to absorb from a consumer's resolution.
32
+ */
33
+ const SUPPORTED_PARSER_MAJOR = 7;
34
+
35
+ /** Package whose resolved major gates the kernel. */
36
+ const PARSER_PACKAGE = '@babel/parser';
37
+
38
+ /** Memoised resolved parser version: `undefined` unread, `null` unresolvable. */
39
+ let parserVersion;
40
+
41
+ /**
42
+ * Read the resolved `@babel/parser` version from its own manifest.
43
+ *
44
+ * The package exports no version, so its `package.json` is the only source.
45
+ * This is the one non-static resolution in the file and it deliberately
46
+ * targets a manifest rather than code: `@babel/parser` itself is reached by a
47
+ * static import above, so it is declared and preflighted like every other
48
+ * dependency.
49
+ *
50
+ * @returns {string|null} The resolved version, or `null` when the manifest
51
+ * cannot be read (a layout that hides `package.json` behind `exports`, say).
52
+ */
53
+ function resolveParserVersion() {
54
+ if (parserVersion !== undefined) return parserVersion;
55
+ try {
56
+ const require = createRequire(import.meta.url);
57
+ const manifest = require.resolve(`${PARSER_PACKAGE}/package.json`);
58
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf-8'));
59
+ parserVersion = typeof parsed?.version === 'string' ? parsed.version : null;
60
+ } catch {
61
+ parserVersion = null;
62
+ }
63
+ return parserVersion;
64
+ }
65
+
66
+ /**
67
+ * The resolved parser's major version.
68
+ *
69
+ * @returns {number|null} `null` when the version could not be resolved or
70
+ * does not lead with an integer.
71
+ */
72
+ function resolveParserMajor() {
73
+ const version = resolveParserVersion();
74
+ if (version === null) return null;
75
+ const major = Number.parseInt(version, 10);
76
+ return Number.isInteger(major) ? major : null;
77
+ }
78
+
79
+ /**
80
+ * Describe the resolved-parser problem, if there is one.
81
+ *
82
+ * Single-sourced so the load-time assertion below and the preflight guard
83
+ * (`runtime-deps/ensure-installed.js`) emit the *same* named, actionable
84
+ * message — the point of AC-5 is that a consumer never meets this as a
85
+ * plugin-list syntax error mid-scan.
86
+ *
87
+ * An unresolvable version is **not** a problem: a consumer layout that hides
88
+ * the manifest still resolves the parser itself, and refusing to score would
89
+ * be a worse answer than scoring with an unverified parser. Only a
90
+ * *known-wrong* major is reported.
91
+ *
92
+ * @param {{major?: number|null, version?: string|null}} [resolved] Overrides
93
+ * the resolved parser, so the message a consumer on an unsupported major
94
+ * would read is assertable without installing one.
95
+ * @returns {string|null} The message, or `null` when the resolved parser is
96
+ * supported (or its version is unknowable).
97
+ */
98
+ export function describeParserMajorError(resolved = {}) {
99
+ const { major = resolveParserMajor(), version = resolveParserVersion() } =
100
+ resolved;
101
+ if (major === null || major === SUPPORTED_PARSER_MAJOR) return null;
102
+ return (
103
+ `unsupported ${PARSER_PACKAGE} major: resolved ${version}, ` +
104
+ `the complexity kernel requires ${SUPPORTED_PARSER_MAJOR}.x. It parses ` +
105
+ `with a fixed plugin list that later majors reject, so CRAP and ` +
106
+ `maintainability scoring would fail mid-scan with an opaque plugin-list ` +
107
+ `error. Declare "${PARSER_PACKAGE}": "^${SUPPORTED_PARSER_MAJOR}" in ` +
108
+ `your package.json (see .agents/runtime-deps.json).`
109
+ );
110
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * runtime-deps/preflight — pure helpers for the dependency-presence check.
2
+ * runtime-deps/preflight — pure helpers for the dependency-presence check's
3
+ * *messaging* half.
3
4
  *
4
- * These functions hold no side effects so they are unit-testable in
5
- * isolation: `checkRuntimeDeps` takes an injected `resolve` seam,
6
- * `detectPackageManager` takes an injected `exists` seam, and
5
+ * The check itself moved to `dep-resolution.js`, which owns resolving a
6
+ * declared dependency, judging its major, and explaining a mismatch. What is
7
+ * left here holds no side effects and stays unit-testable in isolation:
8
+ * `detectPackageManager` takes an injected `exists` seam and
7
9
  * `formatMissingDepsMessage` is a pure string builder. The side-effecting
8
10
  * guard that wires them to the real process lives in `ensure-installed.js`.
9
11
  *
@@ -14,27 +16,6 @@
14
16
  import fs from 'node:fs';
15
17
  import { detectPackageManager as detectPm } from '../detect-package-manager.js';
16
18
 
17
- /**
18
- * Resolve each required package via the injected `resolve` seam and collect
19
- * the ones that fail. `resolve` is typically `require.resolve` bound to the
20
- * framework module location; it throws `MODULE_NOT_FOUND` when a package is
21
- * absent from the resolvable `node_modules`.
22
- *
23
- * @param {{ required: string[], resolve: (specifier: string) => string }} opts
24
- * @returns {{ ok: boolean, missing: string[] }}
25
- */
26
- export function checkRuntimeDeps({ required, resolve }) {
27
- const missing = [];
28
- for (const dep of required) {
29
- try {
30
- resolve(dep);
31
- } catch {
32
- missing.push(dep);
33
- }
34
- }
35
- return { ok: missing.length === 0, missing };
36
- }
37
-
38
19
  /**
39
20
  * Detect the consumer's package manager from lockfile presence so the
40
21
  * remediation message names the right install command. Defaults to `npm`.
@@ -71,6 +71,51 @@ const STATIC_FROM =
71
71
  const SIDE_EFFECT = /^\s*import\s*['"]([^'"]+)['"]/gm;
72
72
  // `require(...)` and dynamic `import(...)` may appear mid-expression.
73
73
  const CALL_FORM = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
74
+ // Names bound to a `createRequire(...)` result, e.g.
75
+ // `const fromReader = createRequire(x)`. Such a binding is a require function
76
+ // under a different name, so calls through it are real runtime imports that
77
+ // `CALL_FORM` cannot see — it matches the literal callees `require`/`import`.
78
+ // A module reached only that way would be an undeclared, unpreflighted
79
+ // dependency that this scanner reported as absent.
80
+ const REQUIRE_ALIAS =
81
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*createRequire\s*\(/g;
82
+
83
+ /**
84
+ * Build a matcher for calls through `createRequire`-bound identifiers.
85
+ *
86
+ * Returns `null` when the source binds none, so the common case adds no pass.
87
+ * Aliases named `require` need no entry — `CALL_FORM` already covers them.
88
+ *
89
+ * @param {string} cleaned Comment-stripped source.
90
+ * @returns {RegExp|null}
91
+ */
92
+ function aliasedRequireMatcher(cleaned) {
93
+ REQUIRE_ALIAS.lastIndex = 0;
94
+ const names = new Set();
95
+ let m = REQUIRE_ALIAS.exec(cleaned);
96
+ while (m !== null) {
97
+ if (m[1] !== 'require') names.add(m[1]);
98
+ m = REQUIRE_ALIAS.exec(cleaned);
99
+ }
100
+ if (names.size === 0) return null;
101
+ const alternation = [...names]
102
+ .map((n) => n.replace(/[$]/g, '\\$$'))
103
+ .join('|');
104
+ return new RegExp(`\\b(?:${alternation})\\s*\\(\\s*['"]([^'"]+)['"]`, 'g');
105
+ }
106
+
107
+ /**
108
+ * The specifier patterns to run over one source: the three fixed forms, plus
109
+ * an alias matcher when the source binds a `createRequire` result.
110
+ *
111
+ * @param {string} cleaned Comment-stripped source.
112
+ * @returns {RegExp[]}
113
+ */
114
+ function specifierMatchers(cleaned) {
115
+ const aliased = aliasedRequireMatcher(cleaned);
116
+ if (!aliased) return [STATIC_FROM, SIDE_EFFECT, CALL_FORM];
117
+ return [STATIC_FROM, SIDE_EFFECT, CALL_FORM, aliased];
118
+ }
74
119
 
75
120
  /**
76
121
  * Extract the set of third-party top-level package names imported by a
@@ -82,7 +127,7 @@ const CALL_FORM = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
82
127
  export function extractThirdPartyImports(source) {
83
128
  const found = new Set();
84
129
  const cleaned = stripJsComments(source);
85
- for (const re of [STATIC_FROM, SIDE_EFFECT, CALL_FORM]) {
130
+ for (const re of specifierMatchers(cleaned)) {
86
131
  re.lastIndex = 0;
87
132
  let match = re.exec(cleaned);
88
133
  while (match !== null) {
@@ -41,7 +41,7 @@ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
41
41
 
42
42
  /**
43
43
  * A skill id is the tier-relative path naming a skill — e.g.
44
- * `core/scope-triage` or `stack/qa/playwright`. It is the value that
44
+ * `core/test-first` or `stack/qa/playwright`. It is the value that
45
45
  * appears in `skills.index.json` minus the root prefix, and the value a
46
46
  * `qa.environments.*.signInSeam.skill` seam carries.
47
47
  *
@@ -164,7 +164,14 @@ const HUMANIZED_PATH_ENTRY_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
164
164
  // AC-<n> presentation prefix on acceptance checkboxes (Story #4600). The
165
165
  // numbering is a stable 1-based human handle only — parse() strips it so the
166
166
  // top-level acceptance[] machine contract round-trips byte-identical.
167
- const AC_PREFIX_RE = /^AC-\d+:\s+/;
167
+ //
168
+ // The lettered form (`AC-14a:`) is accepted too (Story #5323). Nothing emits
169
+ // one — `serialize()` numbers from the array index — but a Story planned from
170
+ // an existing ticket can copy one out of the source issue's rendered
171
+ // checkboxes, and a body that already carries one must still parse to the
172
+ // handle-free text or the round-trip invariant breaks for it alone. Trailing
173
+ // whitespace is optional so `AC-3:text` normalises as readily as `AC-3: text`.
174
+ const AC_PREFIX_RE = /^AC-\d+[a-z]?:\s*/i;
168
175
 
169
176
  // Machine-managed marker lines a body authored before Story #5312 may still
170
177
  // carry: the `> **Wide:** <reason>` rationale line (Story #4600), the
@@ -560,6 +567,33 @@ function parseTextListSection(lines) {
560
567
  * @returns {ParseResult}
561
568
  * @throws {StoryBodyParseError} When the body is structurally unrecoverable.
562
569
  */
570
+ /**
571
+ * Strip the presentation `AC-<n>:` handle off one acceptance item.
572
+ *
573
+ * The handle belongs to {@link serialize}, which numbers every checkbox from
574
+ * its position in `acceptance[]`; an authored item that already carries one
575
+ * would render doubled (`- [ ] AC-1: AC-1: …`) and a lettered handle copied
576
+ * from a source ticket would survive into the machine contract. Both parse
577
+ * and the persist-side normalisation resolve the grammar here so the two can
578
+ * never disagree about what a handle is (Story #5323).
579
+ *
580
+ * Stacked handles are peeled in full — a body persisted while the doubling
581
+ * was live carries two, and leaving the inner one would normalise to
582
+ * something that still is not the authored text.
583
+ *
584
+ * @param {string} item
585
+ * @returns {{ text: string, stripped: boolean }} The handle-free text, and
586
+ * whether anything was removed.
587
+ */
588
+ export function stripAcceptanceHandle(item) {
589
+ const original = String(item ?? '');
590
+ let text = original;
591
+ while (AC_PREFIX_RE.test(text)) {
592
+ text = text.replace(AC_PREFIX_RE, '');
593
+ }
594
+ return { text, stripped: text !== original };
595
+ }
596
+
563
597
  export function parse(input) {
564
598
  if (input === null || input === undefined) {
565
599
  throw new StoryBodyParseError('Story body is null or undefined', {
@@ -618,7 +652,7 @@ export function parse(input) {
618
652
  // The AC-<n> checkbox prefix is presentation-only (Story #4600): strip it
619
653
  // so acceptance[] round-trips byte-identical to the authored array.
620
654
  const acceptance = parseTextListSection(sections.get('acceptance') ?? []).map(
621
- (a) => a.replace(AC_PREFIX_RE, ''),
655
+ (a) => stripAcceptanceHandle(a).text,
622
656
  );
623
657
  const verify = parseTextListSection(sections.get('verify') ?? []);
624
658
  const references = parsePathEntrySection(