mandrel 2.58.0 → 2.60.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 (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -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
  *
@@ -22,7 +22,10 @@
22
22
  *
23
23
  * Story #5312 deleted the `verify-tier-suffix` / `verify-manual-reason` lints
24
24
  * with the tier suffix itself: a `verify[]` entry is any command, and the
25
- * `manual:<reason>` escape is gone with the rule it escaped.
25
+ * `manual:<reason>` escape is gone with the rule it escaped. Story #5342
26
+ * deleted `verify-non-empty`: an empty `verify[]` is a dry-run warning, not a
27
+ * refusal, so it is no longer a rejecting lint — this registry carries only
28
+ * the rules that still refuse.
26
29
  *
27
30
  * Import hygiene: this module imports only the cycle-free
28
31
  * `file-assumption-enum.js` leaf. It must NOT import `story-body.js` or
@@ -40,6 +43,53 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
40
43
  */
41
44
  const DEFAULT_SUGGESTED_ASSUMPTION = 'refactors-existing';
42
45
 
46
+ /**
47
+ * The bare-path bullet grammar (Story #5342, widened by Story #5361) — the
48
+ * **one** definition of what a `## Changes` / `## References` bullet has to
49
+ * look like to be a path rather than prose.
50
+ *
51
+ * It lives here, in the cycle-free lint leaf, because two consumers need the
52
+ * identical judgment and neither may import the other: the story-body parser
53
+ * (`story-body.js#parsePathEntry`) and the persist repair pass
54
+ * (`plan-persist/changes-repair.js`). They carried a copy each, and the
55
+ * copies drifted.
56
+ *
57
+ * A bullet is a path when it is a single token git could track: any run of
58
+ * non-whitespace, optionally wrapped in a matched pair of backticks. That
59
+ * admits the shapes the earlier `[\w@*-]*[/.][\w@./*-]+` class refused
60
+ * outright — route-segment paths (`app/[slug]/page.tsx`,
61
+ * `app/(marketing)/page.tsx`, `src/routes/$id.svelte`) and extensionless
62
+ * top-level files (`Makefile`) — while still refusing prose, since
63
+ * whitespace is the one thing that reliably marks a sentence.
64
+ */
65
+ const BARE_PATH_TOKEN_RE = /^(?:`([^\s`]+)`|([^\s`]+))$/;
66
+
67
+ /**
68
+ * Read the bare path token out of a bullet, or `null` when the bullet is not
69
+ * one single token.
70
+ *
71
+ * @param {unknown} raw
72
+ * @returns {string|null} The path, with any wrapping backticks peeled.
73
+ */
74
+ export function matchBarePathToken(raw) {
75
+ if (typeof raw !== 'string') return null;
76
+ const match = raw.trim().match(BARE_PATH_TOKEN_RE);
77
+ return match ? (match[1] ?? match[2]) : null;
78
+ }
79
+
80
+ /**
81
+ * Whether a rejected bullet is prose — it carries whitespace, so no path
82
+ * grammar could ever have admitted it. The counterpart failure is a
83
+ * single token that still names no usable path; the two get different
84
+ * refusals because they need different fixes.
85
+ *
86
+ * @param {unknown} raw
87
+ * @returns {boolean}
88
+ */
89
+ export function isProseBullet(raw) {
90
+ return typeof raw === 'string' && /\s/.test(raw.trim());
91
+ }
92
+
43
93
  // A token that looks like a file path / glob / module id: it carries a `/` or a
44
94
  // `.`-separated segment. Deliberately loose — the suggestion is best-effort, and
45
95
  // a false positive only produces an unhelpful (still-valid) fix-it string.
@@ -114,10 +164,13 @@ export const BODY_FORMAT_LINTS = Object.freeze([
114
164
  {
115
165
  id: 'changes-path-entry-shape',
116
166
  summary:
117
- 'Every `## Changes` / `## References` bullet MUST be a `{ path, assumption }` object (assumption ∈ ' +
118
- `${FILE_ASSUMPTION_VALUES.join(' | ')}); plain path strings are rejected.`,
119
- badExample: '- src/app.js',
120
- goodExample: '- {"path": "src/app.js", "assumption": "refactors-existing"}',
167
+ 'Every `## Changes` / `## References` bullet MUST name a path — a bare ' +
168
+ 'path string is the default form and persist derives its assumption by ' +
169
+ 'probing the base branch. Use the `{ path, assumption }` object ' +
170
+ `(assumption ∈ ${FILE_ASSUMPTION_VALUES.join(' | ')}) only to pin one ` +
171
+ 'yourself; `deletes` always needs it. Prose bullets are rejected.',
172
+ badExample: '- the routing module and its tests',
173
+ goodExample: '- src/app.js',
121
174
  autoFixable: true,
122
175
  },
123
176
  {
@@ -127,13 +180,6 @@ export const BODY_FORMAT_LINTS = Object.freeze([
127
180
  goodExample: '- {"path": "src/app.js", "assumption": "creates"}',
128
181
  autoFixable: false,
129
182
  },
130
- {
131
- id: 'verify-non-empty',
132
- summary: 'A Story MUST list at least one `verify[]` entry.',
133
- badExample: '"verify": []',
134
- goodExample: '"verify": ["npm run validate"]',
135
- autoFixable: false,
136
- },
137
183
  {
138
184
  id: 'acceptance-non-empty',
139
185
  summary:
@@ -41,7 +41,11 @@
41
41
  */
42
42
 
43
43
  import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
44
- import { suggestPathEntryFix } from './body-format-lints.js';
44
+ import {
45
+ isProseBullet,
46
+ matchBarePathToken,
47
+ suggestPathEntryFix,
48
+ } from './body-format-lints.js';
45
49
  import { isFooterSeparator, parseFooterBlockedByRefs } from './footer-block.js';
46
50
 
47
51
  // ---------------------------------------------------------------------------
@@ -161,6 +165,14 @@ function stripListMarker(line) {
161
165
  // issue bodies are never rewritten).
162
166
  const HUMANIZED_PATH_ENTRY_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
163
167
 
168
+ // Bare path bullet (Story #5342): `src/app.js` or `` `src/app.js` `` with no
169
+ // assumption. The assumption is a fact about the base branch, not a thing the
170
+ // author knows better than a probe does, so the default authored form omits
171
+ // it and persist derives it. The grammar itself — a single whitespace-free
172
+ // token, so prose bullets do not match and are still rejected — is
173
+ // `body-format-lints.js#matchBarePathToken`, the one definition the persist
174
+ // repair pass scores against too (Story #5361).
175
+
164
176
  // AC-<n> presentation prefix on acceptance checkboxes (Story #4600). The
165
177
  // numbering is a stable 1-based human handle only — parse() strips it so the
166
178
  // top-level acceptance[] machine contract round-trips byte-identical.
@@ -189,8 +201,10 @@ const META_BLOCK_RE = /<!--\s*meta:[\s\S]*?-->/;
189
201
  /**
190
202
  * Parse a single `changes` / `references` bullet into a `PathEntry`.
191
203
  *
192
- * Accepted markdown shapes (both parsed indefinitely — live issue bodies
204
+ * Accepted markdown shapes (all parsed indefinitely — live issue bodies
193
205
  * are never rewritten):
206
+ * - Bare path (the default authored form since Story #5342):
207
+ * `` `src/x.js` `` or `src/x.js`, parsed with `assumption: null`
194
208
  * - Humanized bullet (canonical serialize() output since Story #4600):
195
209
  * `` `src/x.js` — refactors-existing ``
196
210
  * - Legacy inline-JSON object bullet:
@@ -209,18 +223,65 @@ function parsePathEntry(raw, warnings) {
209
223
  return pathEntryFromObject(raw);
210
224
  }
211
225
 
212
- const str = typeof raw === 'string' ? raw.trim() : String(raw).trim();
226
+ const str = String(raw).trim();
213
227
  if (str.length === 0) return null;
214
228
 
215
229
  const entry = pathEntryFromHumanized(str) ?? pathEntryFromInlineJson(str);
216
230
  if (entry) return entry;
231
+ return pathEntryFromBare(str);
232
+ }
233
+
234
+ /**
235
+ * Parse the bare path bullet — the default authored form since Story #5342 —
236
+ * or refuse the bullet. This is the last shape `parsePathEntry` tries, so it
237
+ * owns the rejection too.
238
+ *
239
+ * `assumption: null` records only what the author said; persist derives the
240
+ * rest by probing the base branch. A `{`-leading string reached here because
241
+ * it failed to parse as the inline JSON object it announced itself as, so it
242
+ * is malformed JSON rather than a path and keeps failing closed.
243
+ *
244
+ * Story #5361: the two failures need different fixes, so they get different
245
+ * refusals — rewrite a sentence as a path, versus fix a token that is not one.
246
+ *
247
+ * @param {string} str
248
+ * @returns {PathEntry}
249
+ */
250
+ function pathEntryFromBare(str) {
251
+ const bare = str.startsWith('{') ? null : matchBarePathToken(str);
252
+ if (bare !== null) return { path: bare, assumption: null };
217
253
 
254
+ const shape = isProseBullet(str)
255
+ ? 'is prose, not a path'
256
+ : 'names no usable path';
218
257
  throw new StoryBodyParseError(
219
- `changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
258
+ `changes/references entry ${shape} — a bullet must be a single ` +
259
+ `whitespace-free path token, or a { path, assumption } object: ` +
260
+ `${str.slice(0, 120)}${pathEntryFixIt(str)}`,
220
261
  { field: 'changes', raw: str },
221
262
  );
222
263
  }
223
264
 
265
+ /**
266
+ * Read the assumption a raw entry declares, collapsing the three cases the
267
+ * parser has to tell apart into one value:
268
+ *
269
+ * - a canonical `FILE_ASSUMPTION_VALUES` member — the author pinned one;
270
+ * - `null` — the author named no assumption at all. Since Story #5342 that
271
+ * is the **bare form**, and persist derives the value by probing the base
272
+ * branch rather than the author guessing it;
273
+ * - `undefined` — the author named something that is not an assumption.
274
+ * Distinct from absent on purpose: a typo must fail closed where an
275
+ * omission is the default shape.
276
+ *
277
+ * @param {unknown} raw
278
+ * @returns {string|null|undefined}
279
+ */
280
+ function readAssumption(raw) {
281
+ if (FILE_ASSUMPTION_VALUES.includes(raw)) return raw;
282
+ return raw == null ? null : undefined;
283
+ }
284
+
224
285
  /**
225
286
  * Validate an already-structured `{ path, assumption }` object. Fails closed
226
287
  * on a malformed object.
@@ -229,12 +290,10 @@ function parsePathEntry(raw, warnings) {
229
290
  * @returns {PathEntry}
230
291
  */
231
292
  function pathEntryFromObject(raw) {
232
- if (
233
- typeof raw.path === 'string' &&
234
- raw.path.trim().length > 0 &&
235
- FILE_ASSUMPTION_VALUES.includes(raw.assumption)
236
- ) {
237
- return { path: raw.path.trim(), assumption: raw.assumption };
293
+ const path = typeof raw.path === 'string' ? raw.path.trim() : '';
294
+ const assumption = readAssumption(raw.assumption);
295
+ if (path !== '' && assumption !== undefined) {
296
+ return { path, assumption };
238
297
  }
239
298
  // Malformed object: fail closed.
240
299
  throw new StoryBodyParseError(
@@ -270,8 +329,9 @@ function pathEntryFromHumanized(str) {
270
329
  * Parse the legacy inline-JSON object bullet:
271
330
  * `{ "path": "...", "assumption": "..." }`. Returns `null` when the line is
272
331
  * not a JSON object at all (including a JSON parse failure — the caller then
273
- * rejects the plain-string form); fails closed when it parses to an object
274
- * without valid PathEntry fields.
332
+ * tries the bare-path form and finally rejects the bullet); delegates the
333
+ * field check to {@link pathEntryFromObject}, which is the same judgment on
334
+ * the same shape and fails closed the same way.
275
335
  *
276
336
  * @param {string} str
277
337
  * @returns {PathEntry|null}
@@ -282,21 +342,12 @@ function pathEntryFromInlineJson(str) {
282
342
  try {
283
343
  parsed = JSON.parse(str);
284
344
  } catch {
285
- // JSON parse failed — the caller rejects the plain-string form.
345
+ // JSON parse failed — the caller falls through to the bare-path form.
286
346
  return null;
287
347
  }
288
- if (typeof parsed !== 'object' || parsed === null) return null;
289
- if (
290
- typeof parsed.path === 'string' &&
291
- FILE_ASSUMPTION_VALUES.includes(parsed.assumption)
292
- ) {
293
- return { path: parsed.path.trim(), assumption: parsed.assumption };
294
- }
295
- // Parsed successfully as JSON object but has invalid fields — fail closed.
296
- throw new StoryBodyParseError(
297
- `changes/references entry is a JSON object but not a valid PathEntry: ${str}`,
298
- { field: 'changes', raw: str },
299
- );
348
+ return parsed !== null && typeof parsed === 'object'
349
+ ? pathEntryFromObject(parsed)
350
+ : null;
300
351
  }
301
352
 
302
353
  /**
@@ -789,10 +840,13 @@ const STRUCTURED_FIELD_NORMALIZERS = {
789
840
  */
790
841
  function serializePathEntry(entry) {
791
842
  if (typeof entry === 'string') return entry;
792
- // Canonical object form (Story #4600): render as a human-readable bullet —
793
- // path in backticks, em-dash, assumption. parsePathEntry recognizes this
794
- // shape (and the legacy inline-JSON shape) for round-trip fidelity.
795
- return `\`${entry.path}\` — ${entry.assumption}`;
843
+ // Canonical object form (Story #4600): a human-readable bullet — path in
844
+ // backticks, em-dash, assumption. parsePathEntry recognizes this shape (and
845
+ // the legacy inline-JSON one) for round-trip fidelity. Story #5342: a bare
846
+ // entry — a path the author wrote with no assumption — serializes back bare
847
+ // rather than silently acquiring a derivation they never made; persist
848
+ // fills it in by probing base before it writes a body.
849
+ return [`\`${entry.path}\``, entry.assumption].filter(Boolean).join(' — ');
796
850
  }
797
851
 
798
852
  /**
@@ -24,10 +24,11 @@ import { BODY_FORMAT_LINTS } from '../story-body/body-format-lints.js';
24
24
  * no verify-tier suffix — every one of those either scored a shape the
25
25
  * authoring model already judges or prescribed a proxy that became the
26
26
  * goal.
27
- * - **The N>1 rules** ({@link renderStorySplitRules}) — the schedule and
28
- * partition rules that only mean anything once a draft has siblings:
29
- * every Story must earn its slot in the wave schedule, and every
30
- * acceptance criterion belongs to exactly one Story.
27
+ * - **The N>1 rules** ({@link renderStorySplitRules}) — the schedule rules
28
+ * that only mean anything once a draft has siblings: every Story must
29
+ * earn its slot in the wave schedule, and no same-wave pair may collide
30
+ * on a declared path (Story #5332 replaced the acceptance partition with
31
+ * the dispatcher's own collision predicate, armed as a refusal).
31
32
  * - **The tickets-mode rules** ({@link ticketsModePromptField}, Story
32
33
  * #5323) — what to re-derive rather than carry when the seed is an
33
34
  * existing ticket whose body is already in Story shape.
@@ -116,7 +117,8 @@ The **persisted** \`body\` renders these markdown sections (in order) — you au
116
117
  <optional technical approach at contract level — do NOT restate Goal / Acceptance / Verify>
117
118
 
118
119
  ## Changes
119
- - {"path": "<file path>", "assumption": "creates" | "refactors-existing" | "deletes"}
120
+ - <file path>
121
+ - {"path": "<file path>", "assumption": "deletes"}
120
122
  - ...
121
123
 
122
124
  ## Acceptance <-- synthesized by persist from acceptance[]; do not author
@@ -135,10 +137,10 @@ The **persisted** \`body\` renders these markdown sections (in order) — you au
135
137
 
136
138
  - **goal** (in body string): One sentence stating WHY this Story exists.
137
139
  - **spec** (optional, in body string as \`## Spec\`): The technical approach at the altitude the SPEC PROSE CONTRACT below fixes — contract and invariants, never implementation narration. Write as much as the work needs and no more; persist keeps Specs inline at any length and never writes them under \`docs/\`.
138
- - **slicing** (optional): Ordered intra-session checkpoints for one Story, one line each. Not a fan-out table and not a duplicate of Acceptance.
139
- - **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. **Name the files the deliverer authors, and omit generated artifacts** — quality baselines, generated test indexes, migration journals, lockfiles and the like are regenerated by the work itself, the refresh is a close-gate concern, and declaring one needlessly reserves a footprint that serializes sibling Stories at dispatch. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals. Persist probes every path against the base branch and repairs a plain-string bullet or a trailing parenthetical into the object form for you; a \`creates\` on an existing path or a \`refactors-existing\` on an absent one is a dry-run warning, and only a \`deletes\` naming an absent path is refused.
140
- - **acceptance** (top-level array on the ticket object): Each item is an **outcome a PR reviewer can confirm from the diff and the verify output** — what is true of the codebase once the Story lands, stated at the altitude of the capability (a command that now exits 0 against a named input, a behavior a named test now asserts, a config that now fails validation on a retired key, a document that now records a decision). Aim for **three to six** items: fewer than three usually means the outcome is under-specified; more than six usually means acceptance is re-listing the footprint or the mechanical checks that belong in \`verify[]\`. Push grep-shaped probes, file-exists checks and exit-code tests down into \`verify[]\`; never pin an internal helper name or a private file path into an acceptance item the advisory \`changes[]\` is free to reshape. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec".
141
- - **verify** (top-level array on the ticket object): The **mechanical checks** — exact commands or test paths the deliverer runs and the acceptance critic consumes as evidence: \`node --test tests/x.test.js\`, \`npm run lint\`, \`npm run validate\`, a scoped grep. Every acceptance item should be confirmable from at least one verify entry's output plus the diff. Stories with zero verify entries fail validation.
140
+ - **slicing** (optional): Ordered intra-session checkpoints for one Story, one line each. A checkpoint is a **stage of the work** — a commit boundary the deliverer passes through inside one session, stated as the step it performs. An acceptance item is a **state of the codebase** a PR reviewer confirms once the Story has landed. The same Story therefore carries both: the checkpoints say in what order it is built, \`acceptance[]\` says what must then be true. Never a fan-out table, never a second acceptance list, and never sibling tickets — a broad sweep with many stages is still one Story, sliced here.
141
+ - **changes** (in body string): Each entry is a **bare path string** — the default form; persist derives its assumption by probing the base branch and reports the derivation. Use the object form \`{ path, assumption }\` (\`assumption\` one of \`creates | refactors-existing | deletes\`) only to pin one yourself, and always for a \`deletes\`, which a bare path can never express. **Name the files the deliverer authors, and omit generated artifacts** — quality baselines, generated test indexes, migration journals, lockfiles and the like are regenerated by the work itself, the refresh is a close-gate concern, and declaring one needlessly reserves a footprint that serializes sibling Stories at dispatch. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Pin \`refactors-existing\` for an in-place edit, \`creates\` for a net-new file, \`deletes\` for a removal, whenever the probe would get it wrong. Persist probes every path against the base branch and derives the assumption for you, so a \`creates\` on an existing path or a \`refactors-existing\` on an absent one is a dry-run warning; only a \`deletes\` naming an absent path is refused.
142
+ - **acceptance** (top-level array on the ticket object): Each item is an **outcome a PR reviewer can confirm from the diff and the verify output** — what is true of the codebase once the Story lands, stated at the altitude of the capability (a command that now exits 0 against a named input, a behavior a named test now asserts, a config that now fails validation on a retired key, a document that now records a decision). State as many outcomes as the capability has and no more — the list has no target, floor or ceiling, and a long one is never a reason to split the Story. Push grep-shaped probes, file-exists checks and exit-code tests down into \`verify[]\`; never pin an internal helper name or a private file path into an acceptance item the advisory \`changes[]\` is free to reshape. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec".
143
+ - **verify** (top-level array on the ticket object): The **mechanical checks** — exact commands or test paths the deliverer runs and the acceptance critic consumes as evidence: \`node --test tests/x.test.js\`, \`npm run lint\`, \`npm run validate\`, a scoped grep. Every acceptance item should be confirmable from at least one verify entry's output plus the diff. A Story with no verify entry is warned about, not rejected — but the critic then has nothing to read as evidence, so author the commands.
142
144
  - **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it, and the dry-run warns on every one it finds. Triage each unknown by who can resolve it: an AFK-shaped unknown (a fact in docs, a third-party API surface, observable repo behavior) MUST be resolved by your own research before authoring — never restated as an assumption; only a HITL-shaped unknown (a genuine product or architecture call the operator owns) may be restated as a declarative Key Assumption the agent can act on, stating the default chosen (a decision-made-by-default).
143
145
  - **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
144
146
 
@@ -171,27 +173,18 @@ ${advisoryCaveat}
171
173
 
172
174
  **Decompose at deliverable granularity, not module/task level.** ${granularityDefinition}
173
175
 
174
- The only sizing question is **cohesion**: *is this one coherent change with one reason to exist?* There is no ceiling on a Story's footprint, Spec length or acceptance count — a broad contract cutover is one Story when every changed site changes for the same reason. Frontier models one-shot capability-sized work in a single pass; do not fragment a coherent capability into dependent slices to stay "small", and do not pad a Story with adjacent work to look "complete".
176
+ The only sizing question is **cohesion**: *is this one coherent change with one reason to exist?* There is no target, floor or ceiling on a Story's footprint, Spec length or acceptance count, and a long acceptance list is a description of a broad capability, never a reason to split. A broad contract cutover is one Story when every changed site changes for the same reason. Frontier models one-shot capability-sized work in a single pass; do not fragment a coherent capability into dependent slices to stay "small", and do not pad a Story with adjacent work to look "complete".
175
177
 
176
178
  ${envelopeFloor}
177
179
 
178
- - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one.
180
+ - **One Story = one coherent change with one reason to exist.**
181
+ - **A remediation sweep over one subsystem is one Story.** A batch of findings in the same subsystem shares one reason to exist — the subsystem is wrong — so it arrives as one Story whose \`## Slicing\` checkpoints carry the stages, not as one Story per finding.
179
182
  - ${singleConsumerRule}
180
- - **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
181
183
 
182
- #### UI / TESTID INVARIANCE (per CLAUDE.md safety rule):
184
+ #### UI AND COPY WORK — where the contract is written down:
183
185
 
184
- Every \`changes[]\` entry is a \`{ path, assumption }\` object — a prose bullet there is rejected by the parser, so the testid contract is carried where prose belongs:
185
-
186
- - Stories that touch UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, components folders) MUST carry the testid contract as a top-level \`acceptance[]\` item, one of:
187
- - \`"data-testid invariance: <list of testids that MUST be preserved>"\`, or
188
- - \`"data-testid changes: <old> -> <new>, with the matching tests/e2e/*.spec.ts selector updated"\` — paired with that \`tests/e2e/*.spec.ts\` file in \`changes[]\`, in the same Story or a depends_on Story.
189
- - State the preserved-testid set in \`## Non-Goals\` prose as well when the Story deliberately renames nothing.
190
- - Renaming a testid without the matching e2e edit is FORBIDDEN.
191
-
192
- #### BRAND / COPY / STYLE WORK:
193
-
194
- - Stories that touch user-visible copy, brand assets, or visual style MUST cite the relevant section of \`docs/style-guide.md\` in \`acceptance\` (e.g. \`"acceptance": ["Hero copy matches docs/style-guide.md §3 (voice & tone)"]\`). If \`docs/style-guide.md\` does not exist or has no relevant section, state that explicitly: \`"acceptance": ["docs/style-guide.md absent — copy reviewed against the inline brand brief in the plan seed"]\`. Silence on style sourcing is a smell.
186
+ - A Story touching UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, a components folder) states the \`data-testid\` contract in \`acceptance[]\` per the testid contract in \`.agents/skills/stack/qa/playwright/SKILL.md\`.
187
+ - A Story touching user-visible copy, brand assets or visual style cites the relevant section of \`docs/style-guide.md\` in \`acceptance[]\` when that file exists.
195
188
 
196
189
  CRITICAL: Dependencies should follow execution blockers. There is no parent ticket — never emit a 'parent_slug' field.
197
190
  IMPORTANT DEPENDENCY RULE: Story-to-Story dependencies are expressed via \`depends_on\` (one Story depends_on another Story's slug). Use this to express execution ordering across the plan.
@@ -201,7 +194,7 @@ IMPORTANT DEPENDENCY RULE: Story-to-Story dependencies are expressed via \`depen
201
194
  /**
202
195
  * The rules that only apply once a draft has more than one Story: the
203
196
  * delivery-schedule simulation that makes each Story earn its slot, and the
204
- * acceptance partition persist enforces at N>1.
197
+ * same-wave collision refusal persist enforces at N>1.
205
198
  *
206
199
  * @returns {string}
207
200
  */
@@ -213,16 +206,18 @@ You are splitting past the default-single policy, so simulate the delivery sched
213
206
  1. **Build the wave schedule.** A Story runs only after every \`depends_on\` completes, and two Stories that name the same file in \`changes[]\` cannot run in the same wave (the scheduler serializes file-overlapping Stories even when no \`depends_on\` edge links them).
214
207
  2. **Every Story must earn its slot** by at least one of:
215
208
  - **(a) parallelism** — it actually runs concurrently with a sibling in the schedule you just built ("logically independent" does not count; *schedule*-independent does);
216
- - **(b) risk isolation** — it isolates a consumer-facing behavior change or high-risk cutover into its own reviewable, revertable unit;
217
- - **(c) cohesion break** — merged into its neighbor it would no longer be one coherent change with one reason to exist.
218
- 3. **A dependent link with none of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains: N Stories that deliver no faster than one Story pay N delivery sessions (branch, PR, review, CI) for nothing.
209
+ - **(b) cohesion break** — merged into its neighbor it would no longer be one coherent change with one reason to exist.
210
+ 3. **A dependent link with neither of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains: N Stories that deliver no faster than one Story pay N delivery sessions (branch, PR, review, CI) for nothing.
219
211
  4. **When one file appears in the \`changes[]\` of most of your Stories, the slicing axis cuts across a shared seam** — merge the Stories that co-edit it, or re-slice along the seam so each Story owns its files.
220
212
 
221
- #### ACCEPTANCE PARTITION (persist-enforced at N>1):
213
+ #### THE COLLISION REFUSAL (persist-enforced at N>1):
214
+
215
+ Persist runs the **dispatcher's own** collision predicate pairwise over your draft, before it creates a single issue, and **refuses** the plan when any two same-wave Stories collide — both declaring a path in \`changes[]\`, or one declaring a glob that covers the other's path. Such a pair cannot be co-dispatched, so the split buys no parallelism and costs a delivery session. Two remedies, both yours to choose at authoring time:
216
+
217
+ - **Merge the pair** into the one Story they already are, with \`## Slicing\` checkpoints for the stages; or
218
+ - **Order them** with \`depends_on\` so they sit in different waves, when they genuinely have separate reasons to exist.
222
219
 
223
- - Every acceptance criterion of the plan belongs to **exactly one** Story — no criterion is shared, and none is dropped. Persist refuses a draft whose criteria overlap or leave a plan-level criterion unclaimed.
224
- - Each Story carries its **own** \`## Spec\`; a shared \`techspec.md\` cannot be folded into N>1 Stories.
225
- - Express ordering with \`depends_on\` (a sibling slug, or \`#<id>\` for an open Story from an earlier plan). A Story whose \`verify[]\` runs against a file a sibling creates MUST \`depends_on\` that sibling, so the file exists when verification runs.`;
220
+ Each Story carries its **own** \`## Spec\`; a shared \`techspec.md\` cannot be folded into N>1 Stories. Express ordering with \`depends_on\` (a sibling slug, or \`#<id>\` for an open Story from an earlier plan). A Story whose \`verify[]\` runs against a file a sibling creates MUST \`depends_on\` that sibling, so the file exists when verification runs.`;
226
221
  }
227
222
 
228
223
  /**
@@ -244,7 +239,7 @@ function renderStoryTicketsRules() {
244
239
 
245
240
  You are planning from one or more existing tickets. Read them for **what the work is** — the problem, the constraints, the commands that verify it — and re-derive everything else. Specifically:
246
241
 
247
- 1. **Re-derive \`acceptance[]\` from the goal.** Do not copy the source's \`## Acceptance\` list, and never carry its \`AC-<n>:\` handles — the body renderer numbers the checkboxes itself, so a copied handle renders doubled. A source ticket carrying fifteen criteria is telling you its acceptance was over-specified, not that yours must be: state the three to six outcomes a PR reviewer can confirm, and let the rest fall to \`verify[]\`.
242
+ 1. **Re-derive \`acceptance[]\` from the goal.** Do not copy the source's \`## Acceptance\` list, and never carry its \`AC-<n>:\` handles — the body renderer numbers the checkboxes itself, so a copied handle renders doubled. A source ticket carrying fifteen criteria is telling you its acceptance was over-specified, not that yours must be: state the outcomes a PR reviewer can confirm, and let the mechanical checks fall to \`verify[]\`.
248
243
  2. **A mechanical check is a \`verify[]\` command, not an acceptance item.** "Baselines refreshed", "lint exits 0", "the generated index is regenerated", "the quality gate passes" are commands the deliverer runs and the critic reads as evidence. Carrying them as acceptance items inflates the binding contract with work every close already gates.
249
244
  3. **Read the source's \`verify[]\` for the commands it names, not for its shape.** Take the test paths and scripts; drop any trailing tier suffix (\`(unit)\`, \`(contract)\`, \`(e2e)\`, \`(validate)\`) and any \`manual:<reason>\` escape — a verify entry is a bare command.
250
245
  4. **Re-derive the footprint against the tree as it is now.** The source ticket's \`## Changes\` predicted a repository that has since moved; probe the paths you cite and omit the generated artifacts it listed.