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
@@ -1,6 +1,7 @@
1
1
  /**
2
- * plan-text-hygiene.js — the `open-question` lint over draft Story bodies
3
- * (Story #4599; narrowed to one lint by Story #5312).
2
+ * plan-text-hygiene.js — the advisory draft-Story lints: `open-question`
3
+ * over body prose (Story #4599; narrowed to one lint by Story #5312) and
4
+ * `pinned-identifier` over `acceptance[]` (Story #5323).
4
5
  *
5
6
  * A Story is executed by a non-interactive sub-agent, so an operator-directed
6
7
  * open question persisted into its body ("Flag if…", "TBD", "confirm with the
@@ -12,6 +13,11 @@
12
13
  * lints with the critic gate that surfaced them: both scored prose shape the
13
14
  * authoring model already judges, and neither ever changed a persisted body.
14
15
  *
16
+ * `pinned-identifier` is the one lint that scores the **binding** half of the
17
+ * ticket; its classifier lives in
18
+ * [`pinned-identifier-lint.js`](pinned-identifier-lint.js), which shares no
19
+ * vocabulary with the prose heuristic here.
20
+ *
15
21
  * Advisory by contract: findings are deterministic text for the dry-run's
16
22
  * warning list. They never gate persist and spawn nothing.
17
23
  *
@@ -24,6 +30,7 @@
24
30
  */
25
31
 
26
32
  import { parse } from '../story-body/story-body.js';
33
+ import { findPinnedIdentifiers } from './pinned-identifier-lint.js';
27
34
 
28
35
  /** Truncation length for the `evidence` excerpt on each finding. */
29
36
  const EVIDENCE_MAX_CHARS = 160;
@@ -41,7 +48,7 @@ const OPEN_QUESTION_MARKERS = [
41
48
 
42
49
  /**
43
50
  * @typedef {Object} TextHygieneFinding
44
- * @property {'open-question'} kind
51
+ * @property {'open-question'|'pinned-identifier'} kind
45
52
  * @property {string} slug - The draft Story's slug ('' when absent).
46
53
  * @property {string} evidence - Excerpt of the offending text.
47
54
  * @property {string} message - Human-readable, re-author-actionable text.
@@ -123,7 +130,7 @@ function findOpenQuestions(prose, slug) {
123
130
  }
124
131
 
125
132
  /**
126
- * Evaluate the open-question lint over a draft Story array.
133
+ * Evaluate the advisory lints over a draft Story array.
127
134
  *
128
135
  * @param {{ draftStories?: Array<object>|null }} args - The draft
129
136
  * `stories.json` array (raw Story objects with top-level `slug` /
@@ -146,7 +153,10 @@ export function evaluateTextHygiene({ draftStories = null } = {}) {
146
153
  }
147
154
  const goal = typeof body.goal === 'string' ? body.goal : '';
148
155
  const spec = typeof body.spec === 'string' ? body.spec : '';
149
- findings.push(...findOpenQuestions([goal, spec].join('\n'), slug));
156
+ findings.push(
157
+ ...findOpenQuestions([goal, spec].join('\n'), slug),
158
+ ...findPinnedIdentifiers(story, body, slug, excerpt),
159
+ );
150
160
  }
151
161
  return { findings };
152
162
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * lib/orchestration/review-base-ref.js — the base ref a Story-scope review is
3
+ * allowed to measure against (Story #5325).
4
+ *
5
+ * ## The defect this closes
6
+ *
7
+ * A close runs base-sync and the Story-scope review against what everyone
8
+ * called "the base branch" — but the two meant different refs. Base-sync
9
+ * fetches and merges `origin/<base>`; the review passed the **bare** branch
10
+ * name, which git resolves to the LOCAL `refs/heads/<base>`, i.e. to whatever
11
+ * that ref last fast-forwarded to. On a checkout whose local base is behind
12
+ * its remote, the review's `<base>...<head>` diff therefore contains every
13
+ * commit the local ref is missing — other people's landed work, scored as if
14
+ * this Story had written it. Those findings gate the land, and the operator's
15
+ * only exit is a `review-block-overridden` on blockers that were never real.
16
+ *
17
+ * ## Contract
18
+ *
19
+ * One resolution, at the review phase boundary, threaded into the change-set
20
+ * enumeration, the provider review and the local lens pass — so both arms of
21
+ * the review agree about what changed and neither can inherit local drift.
22
+ *
23
+ * Resolution **fails safe rather than falling back**. Base-sync normally
24
+ * guarantees the remote ref is present, but a `--skip-sync` close or a
25
+ * remote-less checkout can reach the review with no `origin/<base>` at all.
26
+ * Reviewing the local ref anyway is the defect, so an unresolvable base
27
+ * produces a degradation record — carried on the review's existing
28
+ * `degraded` / `degradations[]` envelope — and no findings whatsoever.
29
+ */
30
+
31
+ import { gitSpawn } from '../git-utils.js';
32
+ import { degradationEnvelope } from './review-providers/degraded-gates.js';
33
+
34
+ /**
35
+ * The remote-tracking spelling of a base branch — `main` → `origin/main`.
36
+ * Already-qualified input passes through, so a caller naming `origin/main`
37
+ * is not double-prefixed.
38
+ *
39
+ * @param {unknown} baseBranch
40
+ * @returns {string|null} the remote-tracking ref, or `null` when unnameable.
41
+ */
42
+ export function remoteBaseRef(baseBranch) {
43
+ const base = typeof baseBranch === 'string' ? baseBranch.trim() : '';
44
+ if (base.length === 0) return null;
45
+ return base.startsWith('origin/') ? base : `origin/${base}`;
46
+ }
47
+
48
+ /**
49
+ * Resolve the base ref base-sync merged from, **verified present** in this
50
+ * clone. See the module header for why there is no local-ref fallback.
51
+ *
52
+ * @param {{
53
+ * baseBranch: string,
54
+ * cwd?: string,
55
+ * gitSpawnFn?: typeof gitSpawn,
56
+ * }} args
57
+ * @returns {{ ref: string|null, resolved: boolean, remoteRef: string|null }}
58
+ * `ref` is non-null only when `resolved`; `remoteRef` is the ref that was
59
+ * probed, for the caller's degradation surface.
60
+ */
61
+ export function resolveSharedBaseRef({
62
+ baseBranch,
63
+ cwd = process.cwd(),
64
+ gitSpawnFn = gitSpawn,
65
+ } = {}) {
66
+ const remoteRef = remoteBaseRef(baseBranch);
67
+ if (remoteRef === null) {
68
+ return { ref: null, resolved: false, remoteRef: null };
69
+ }
70
+ try {
71
+ const probe = gitSpawnFn(
72
+ cwd,
73
+ 'rev-parse',
74
+ '--verify',
75
+ '--quiet',
76
+ `${remoteRef}^{commit}`,
77
+ );
78
+ if (probe?.status === 0) {
79
+ return { ref: remoteRef, resolved: true, remoteRef };
80
+ }
81
+ } catch {
82
+ // A spawn failure and a missing ref are the same answer here: the shared
83
+ // base cannot be vouched for.
84
+ }
85
+ return { ref: null, resolved: false, remoteRef };
86
+ }
87
+
88
+ /**
89
+ * The review outcome for a close that cannot establish the shared base.
90
+ *
91
+ * Shaped as the same envelope a completed review returns — an all-zero
92
+ * severity tally, nothing posted, and the `degraded` / `degradations[]` pair
93
+ * the close and the rendered comment already read — so the surfaces reading
94
+ * it cannot mistake "no findings" for "reviewed and clean". Reported, not
95
+ * blocking, matching the posture of every other degraded review gate.
96
+ *
97
+ * @param {{
98
+ * storyId: number|string,
99
+ * baseBranch: string,
100
+ * remoteRef: string|null,
101
+ * progress: (tag: string, msg: string) => void,
102
+ * progressTag?: string,
103
+ * }} args
104
+ * @returns {object}
105
+ */
106
+ export function unresolvedBaseReviewOutcome({
107
+ storyId,
108
+ baseBranch,
109
+ remoteRef,
110
+ progress,
111
+ progressTag = 'REVIEW',
112
+ }) {
113
+ const surface = remoteRef ?? `origin/${baseBranch}`;
114
+ progress(
115
+ progressTag,
116
+ `⚠️ Story-scope review for Story #${storyId} did not run: cannot resolve ` +
117
+ `${surface}, the base ref base-sync merges from. Diffing the local ` +
118
+ `${baseBranch} instead would score commits this Story never made, so no ` +
119
+ 'findings are raised. Fetch the base ref (or re-run without ' +
120
+ '--skip-sync) to restore the review.',
121
+ );
122
+ return {
123
+ halted: false,
124
+ skipped: true,
125
+ severity: { critical: 0, high: 0, medium: 0, suggestion: 0 },
126
+ posted: false,
127
+ postedCommentId: null,
128
+ ...degradationEnvelope([
129
+ {
130
+ tool: 'story-scope-review',
131
+ gate: 'base-ref-resolution',
132
+ surface,
133
+ reason: 'remote-base-ref-unresolved',
134
+ },
135
+ ]),
136
+ crossRefPosted: false,
137
+ };
138
+ }
@@ -25,9 +25,21 @@
25
25
  * shares a single invocation pattern (Story #3653). Review depth needs no
26
26
  * input here: it is derived from this Story's own diff inside `runCodeReview`
27
27
  * (Story #4542).
28
+ *
29
+ * Story #5325 — this phase boundary is where the base ref is resolved, once.
30
+ * The resolved ref threads into `runStoryReviewCore`, which hands it to both
31
+ * the change-set enumeration (and through it the provider review) and the
32
+ * local lens pass, so one resolution corrects both arms. It resolves to
33
+ * `origin/<baseBranch>` — the ref base-sync merged from — rather than the bare
34
+ * branch name git would resolve to the local `refs/heads/<baseBranch>`, whose
35
+ * drift would otherwise be scored as this Story's own change.
28
36
  */
29
37
 
30
38
  import { parsePrNumberFromUrl } from '../../../github-url.js';
39
+ import {
40
+ resolveSharedBaseRef,
41
+ unresolvedBaseReviewOutcome,
42
+ } from '../../review-base-ref.js';
31
43
  import { degradationEnvelope } from '../../review-providers/degraded-gates.js';
32
44
  import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
33
45
  import { postStructuredComment } from '../../ticketing/state.js';
@@ -78,23 +90,25 @@ export function buildStoryReviewCrossRefBody({
78
90
  async function invokeStoryReviewCore({
79
91
  storyId,
80
92
  storyBranch,
81
- baseBranch,
93
+ baseRef,
82
94
  prNumber,
83
95
  provider,
84
96
  runCodeReviewFn,
85
97
  runLocalLensReviewFn,
86
98
  appendFindingsYieldFn,
99
+ gitSpawnFn,
87
100
  progress,
88
101
  }) {
89
102
  return runStoryReviewCore({
90
103
  storyId,
91
- baseRef: baseBranch,
104
+ baseRef,
92
105
  headRef: storyBranch,
93
106
  commentTargetId: prNumber,
94
107
  provider,
95
108
  progress,
96
109
  progressTag: 'REVIEW',
97
110
  runCodeReviewFn,
111
+ gitSpawnFn,
98
112
  // Forward the seams only when the caller injects them; otherwise
99
113
  // `runStoryReviewCore` uses its defaults. `undefined` deep-merges to
100
114
  // the default via the destructuring default there.
@@ -153,6 +167,9 @@ async function postStoryReviewCrossRef({
153
167
  * Failure modes:
154
168
  * - When `prNumber` is null (couldn't parse), the review is skipped
155
169
  * and the function returns `{ halted: false, skipped: true }`.
170
+ * - When `origin/<baseBranch>` cannot be resolved, the review is skipped,
171
+ * a `base-ref-resolution` degradation is recorded on the returned
172
+ * envelope, and no findings are raised (Story #5325).
156
173
  * - When the runner throws, the close fails non-zero (the throw
157
174
  * propagates) — a Story-scope review failure is not silently
158
175
  * ignored.
@@ -170,6 +187,7 @@ async function postStoryReviewCrossRef({
170
187
  * runCodeReviewFn: Function,
171
188
  * runLocalLensReviewFn?: Function,
172
189
  * appendFindingsYieldFn?: Function,
190
+ * gitSpawnFn?: Function,
173
191
  * progress: (tag: string, msg: string) => void,
174
192
  * }} args
175
193
  * @returns {Promise<{
@@ -185,7 +203,7 @@ async function postStoryReviewCrossRef({
185
203
  * }>}
186
204
  */
187
205
  export async function runStoryScopeReview({
188
- cwd: _cwd,
206
+ cwd,
189
207
  storyId,
190
208
  storyBranch,
191
209
  baseBranch,
@@ -195,6 +213,7 @@ export async function runStoryScopeReview({
195
213
  runCodeReviewFn,
196
214
  runLocalLensReviewFn,
197
215
  appendFindingsYieldFn,
216
+ gitSpawnFn,
198
217
  progress,
199
218
  }) {
200
219
  if (prNumber == null) {
@@ -205,20 +224,33 @@ export async function runStoryScopeReview({
205
224
  return { halted: false, skipped: true };
206
225
  }
207
226
 
227
+ // One resolution per close, at the phase boundary: `baseRef` threads from
228
+ // here into the change set, the provider review and the local lens pass.
229
+ const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
230
+ if (!base.resolved) {
231
+ return unresolvedBaseReviewOutcome({
232
+ storyId,
233
+ baseBranch,
234
+ remoteRef: base.remoteRef,
235
+ progress,
236
+ });
237
+ }
238
+
208
239
  progress(
209
240
  'REVIEW',
210
- `Running Story-scope code review for Story #${storyId} (${baseBranch}...${storyBranch}) → PR #${prNumber}...`,
241
+ `Running Story-scope code review for Story #${storyId} (${base.ref}...${storyBranch}) → PR #${prNumber}...`,
211
242
  );
212
243
 
213
244
  const result = await invokeStoryReviewCore({
214
245
  storyId,
215
246
  storyBranch,
216
- baseBranch,
247
+ baseRef: base.ref,
217
248
  prNumber,
218
249
  provider,
219
250
  runCodeReviewFn,
220
251
  runLocalLensReviewFn,
221
252
  appendFindingsYieldFn,
253
+ gitSpawnFn,
222
254
  progress,
223
255
  });
224
256
 
@@ -7,7 +7,7 @@ import {
7
7
  import { runCloseValidation } from '../../close-validation/runner.js';
8
8
  import { getCiDelivery } from '../../config/ci.js';
9
9
  import { resolveConfig } from '../../config-resolver.js';
10
- import { getStoryBranch, gitSync } from '../../git-utils.js';
10
+ import { getStoryBranch, gitSpawn, gitSync } from '../../git-utils.js';
11
11
  import { Logger } from '../../Logger.js';
12
12
  import { emitTerminalFriction } from '../../observability/runtime-friction.js';
13
13
  import { emitTerseResult } from '../../observability/terse-result.js';
@@ -318,6 +318,11 @@ async function openAndReviewPr({
318
318
  prNumber,
319
319
  provider,
320
320
  runCodeReviewFn: injectedRunCodeReview ?? runCodeReviewDefault,
321
+ // The runner owns the git seam it hands its phases (same as `gitSync` to
322
+ // `pushStoryBranch`). The review needs it to resolve `origin/<base>` —
323
+ // the ref base-sync merged from — before it will score anything
324
+ // (Story #5325).
325
+ gitSpawnFn: gitSpawn,
321
326
  progress,
322
327
  });
323
328
  if (reviewOutcome.halted) {
@@ -8,13 +8,12 @@ import { computeStoryReachability } from './story-reachability.js';
8
8
  * `collectStoryAssumptionEntries` (Story #3302) and the sizing gate's
9
9
  * `resolveStoryBody` (Story #4271).
10
10
  *
11
- * The decomposer emits `body` as the canonical serialized **string**, but
12
- * the conflict passes (`indexConsumers`, `computeMissingBddScaffoldFindings`,
13
- * and the producer path scan in `collectStoryProducerPaths`) historically
14
- * read `story.body` only when it was already an object — so on the
15
- * production string shape the `implicit-cross-story-dep` and
16
- * `missing-bdd-scaffold` findings emitted nothing. Parsing the body once at the entry point and
17
- * threading the normalized Story through every pass restores parity.
11
+ * The decomposer emits `body` as the canonical serialized **string**, but the
12
+ * conflict passes (the producer path scan in `collectStoryProducerPaths`, and
13
+ * the two substring-match advisories Story #5332 retired) historically read
14
+ * `story.body` only when it was already an object — so on the production
15
+ * string shape they emitted nothing. Parsing the body once at the entry point
16
+ * and threading the normalized Story through every pass restores parity.
18
17
  *
19
18
  * `collectStoryAssumptionEntries` already parses string bodies itself, so a
20
19
  * normalized object body round-trips through it unchanged. The returned Story
@@ -76,16 +75,20 @@ function normalizeStoryBody(story) {
76
75
  * @property {string} path Producer path written by ≥2 Stories.
77
76
  * @property {string[]} storySlugs Story slugs in the conflict cluster.
78
77
  *
79
- * @typedef {object} ImplicitCrossStoryDepFinding
80
- * @property {'implicit-cross-story-dep'} kind
81
- * @property {'hard'|'soft'} severity
82
- * @property {string} path Path consumed without a depends_on link.
83
- * @property {{ storySlug: string, taskSlug: string }} producer
84
- * @property {{ storySlug: string, taskSlug: string, sourceField: 'acceptance'|'verify' }} consumer
85
- *
86
- * @typedef {SharedEditorFinding | ImplicitCrossStoryDepFinding} ConflictFinding
78
+ * @typedef {SharedEditorFinding} ConflictFinding
87
79
  */
88
80
 
81
+ /**
82
+ * Story #5332 retired the `implicit-cross-story-dep` and
83
+ * `missing-bdd-scaffold` findings, leaving `shared-editor` as the one
84
+ * conflict kind. Both matched a producer path as a **substring** of a
85
+ * consumer's `acceptance[]` / `verify[]` text — the noise-prone shape the
86
+ * planning-diet ADR (`20260912-5312`) itself calls out — and both had been
87
+ * unreachable on the real payload for most of their life (see
88
+ * {@link computeAssembledConflictFindings}). What they nudged for, ordering a
89
+ * consumer after its producer, the same-wave collision refusal now enforces
90
+ * on declarations rather than guesses at from prose.
91
+
89
92
  /**
90
93
  * Every conflict class is advisory (`'soft'`) since Story #5312: the
91
94
  * `planning.failOnSharedEditors` / `requireExplicitCrossStoryDeps` /
@@ -160,46 +163,6 @@ function indexProducers(stories) {
160
163
  return producers;
161
164
  }
162
165
 
163
- /**
164
- * Build the consumers index — `Array<{path, storySlug, taskSlug, sourceField}>`.
165
- *
166
- * For each Task, scan `body.acceptance` and `body.verify` joined text for
167
- * literal substring occurrences of any known producer path. Only producer
168
- * paths are matched (intersect-then-test), so free-text path-like tokens
169
- * that no one writes never produce false positives.
170
- *
171
- * A Story is not its own consumer — entries whose producer is the same
172
- * Story are skipped to keep the surface focused on cross-Story signal.
173
- */
174
- function indexConsumers(stories, producers) {
175
- const consumers = [];
176
- if (producers.size === 0) return consumers;
177
- const producerPaths = Array.from(producers.keys()).sort(
178
- (a, b) => b.length - a.length,
179
- );
180
- for (const story of stories) {
181
- const body = story.body;
182
- if (!body || typeof body !== 'object') continue;
183
- for (const sourceField of ['acceptance', 'verify']) {
184
- const items = Array.isArray(body[sourceField]) ? body[sourceField] : [];
185
- if (items.length === 0) continue;
186
- const joined = items.map((it) => String(it ?? '')).join('\n');
187
- for (const path of producerPaths) {
188
- if (!joined.includes(path)) continue;
189
- const producerEntries = producers.get(path) ?? [];
190
- if (producerEntries.some((p) => p.taskSlug === story.slug)) continue;
191
- consumers.push({
192
- path,
193
- storySlug: storySlugOf(story),
194
- taskSlug: story.slug,
195
- sourceField,
196
- });
197
- }
198
- }
199
- }
200
- return consumers;
201
- }
202
-
203
166
  function inSameWave(reach, slugA, slugB) {
204
167
  if (slugA === slugB) return false;
205
168
  const a = reach.get(slugA);
@@ -240,133 +203,6 @@ function computeSharedEditorFindings(producers, reach, severity) {
240
203
  return findings;
241
204
  }
242
205
 
243
- /**
244
- * Emit one `implicit-cross-story-dep` finding per consumer entry whose
245
- * producer Story is not transitively reachable from the consumer Story.
246
- *
247
- * Multiple producers per path are possible — the finding pins the *first*
248
- * producer in declaration order (sufficient signal; the operator typically
249
- * fixes the missing `depends_on` by linking to whichever Story they
250
- * recognize). Consumers already covered by a transitive dependency to
251
- * *some* producer are silently allowed even if other producers exist.
252
- */
253
- function computeImplicitDepFindings(consumers, producers, reach, severity) {
254
- const findings = [];
255
- for (const consumer of consumers) {
256
- const producerEntries = producers.get(consumer.path) ?? [];
257
- if (producerEntries.length === 0) continue;
258
- const reachable = reach.get(consumer.storySlug) ?? new Set();
259
- const alreadyDependsOnSome = producerEntries.some(
260
- (p) => p.storySlug === consumer.storySlug || reachable.has(p.storySlug),
261
- );
262
- if (alreadyDependsOnSome) continue;
263
- const producer = producerEntries[0];
264
- findings.push({
265
- kind: 'implicit-cross-story-dep',
266
- severity,
267
- path: consumer.path,
268
- producer: {
269
- storySlug: producer.storySlug,
270
- taskSlug: producer.taskSlug,
271
- },
272
- consumer: {
273
- storySlug: consumer.storySlug,
274
- taskSlug: consumer.taskSlug,
275
- sourceField: consumer.sourceField,
276
- },
277
- });
278
- }
279
- return findings;
280
- }
281
-
282
- /**
283
- * Compute `missing-bdd-scaffold` findings (Story #3857).
284
- *
285
- * The features-first delivery model requires every `.feature` file a Story
286
- * verifies against to already exist when that Story runs. When a Story's
287
- * `verify[]` references a `.feature` path that another Story declares with
288
- * `assumption: "creates"`, the consumer is correct only if the producer
289
- * lands in an *earlier* wave — otherwise the consumer's `verify[]` runs
290
- * against a file that does not yet exist and verification fails mid-delivery.
291
- *
292
- * A finding fires for each consumer/producer pair where:
293
- * - the path ends in `.feature`,
294
- * - a *different* Story declares that path as `assumption: "creates"`, and
295
- * - the consumer Story does not transitively `depends_on` the producer
296
- * (i.e. they share a wave, or the producer runs later).
297
- *
298
- * The finding is advisory (`'soft'`) — it is a nudge to add a `depends_on`
299
- * link to the wave-0 scaffold Story (or to the producing Story), not a hard
300
- * block. The remediation is the same shape as `implicit-cross-story-dep`:
301
- * order the consumer after the producer so the scaffold lands first.
302
- *
303
- * @param {object[]} stories
304
- * @param {Map<string, Set<string>>} reach Transitive predecessor sets.
305
- * @param {'soft'|'hard'} severity
306
- * @returns {object[]} `missing-bdd-scaffold` findings.
307
- */
308
- function computeMissingBddScaffoldFindings(stories, reach, severity) {
309
- // Index every `.feature` path declared `creates` to its producing Story.
310
- // A path may be created by more than one Story (unusual); pin the first in
311
- // declaration order, mirroring the implicit-dep finding's single-producer
312
- // shape.
313
- const featureCreators = new Map(); // path -> storySlug (first creator)
314
- for (const story of stories) {
315
- const body = story?.body;
316
- if (!body || typeof body !== 'object') continue;
317
- const changes = Array.isArray(body.changes) ? body.changes : [];
318
- for (const change of changes) {
319
- if (
320
- change === null ||
321
- typeof change !== 'object' ||
322
- change.assumption !== 'creates' ||
323
- typeof change.path !== 'string' ||
324
- !change.path.endsWith('.feature')
325
- )
326
- continue;
327
- if (!featureCreators.has(change.path)) {
328
- featureCreators.set(change.path, storySlugOf(story));
329
- }
330
- }
331
- }
332
- if (featureCreators.size === 0) return [];
333
-
334
- const creatorPaths = Array.from(featureCreators.keys()).sort(
335
- (a, b) => b.length - a.length,
336
- );
337
- const findings = [];
338
- const seen = new Set(); // dedupe `${consumerSlug}::${path}` pairs
339
- for (const story of stories) {
340
- const body = story?.body;
341
- if (!body || typeof body !== 'object') continue;
342
- const verifyItems = Array.isArray(body.verify) ? body.verify : [];
343
- if (verifyItems.length === 0) continue;
344
- const joined = verifyItems.map((it) => String(it ?? '')).join('\n');
345
- const consumerSlug = storySlugOf(story);
346
- for (const path of creatorPaths) {
347
- if (!joined.includes(path)) continue;
348
- const producerSlug = featureCreators.get(path);
349
- // A Story that creates the file it verifies is fine — no cross-Story gap.
350
- if (producerSlug === consumerSlug) continue;
351
- // Producer already runs in an earlier wave → consumer is correctly
352
- // ordered, scaffold lands first, no finding.
353
- const reachable = reach.get(consumerSlug) ?? new Set();
354
- if (reachable.has(producerSlug)) continue;
355
- const key = `${consumerSlug}::${path}`;
356
- if (seen.has(key)) continue;
357
- seen.add(key);
358
- findings.push({
359
- kind: 'missing-bdd-scaffold',
360
- severity,
361
- path,
362
- producer: { storySlug: producerSlug },
363
- consumer: { storySlug: consumerSlug, sourceField: 'verify' },
364
- });
365
- }
366
- }
367
- return findings;
368
- }
369
-
370
206
  /**
371
207
  * Public entry point. Walks the normalized ticket spec once and returns
372
208
  * the structured cross-Story findings array. Every finding is `'soft'`
@@ -384,13 +220,8 @@ export function computeConflictFindings({ stories } = {}) {
384
220
  // shape across every conflict pass.
385
221
  const storyList = (stories ?? []).map(normalizeStoryBody);
386
222
  const producers = indexProducers(storyList);
387
- const consumers = indexConsumers(storyList, producers);
388
223
  const reach = computeStoryReachability(storyList);
389
- return [
390
- ...computeSharedEditorFindings(producers, reach, SOFT),
391
- ...computeImplicitDepFindings(consumers, producers, reach, SOFT),
392
- ...computeMissingBddScaffoldFindings(storyList, reach, SOFT),
393
- ];
224
+ return computeSharedEditorFindings(producers, reach, SOFT);
394
225
  }
395
226
 
396
227
  /**
@@ -402,11 +233,11 @@ export function computeConflictFindings({ stories } = {}) {
402
233
  * persisted. That is not a cosmetic ordering nit: the canonical authoring shape
403
234
  * carries `acceptance[]` / `verify[]` at the ticket's **top level**, and it is
404
235
  * assembly's `syncContractFieldFromTopLevel` that folds them into the body.
405
- * `indexConsumers` scans `body.acceptance` / `body.verify` for producer paths —
406
- * so on the real payload it scanned two empty arrays, and every
407
- * `implicit-cross-story-dep` and `missing-bdd-scaffold` finding was silently
408
- * unreachable. Running the passes again over the serialized bodies restores
409
- * them.
236
+ * The two retired advisories scanned `body.acceptance` / `body.verify` for
237
+ * producer paths, so on the real payload they scanned two empty arrays and
238
+ * were silently unreachable. Running the passes again over the serialized
239
+ * bodies is what keeps the surviving `shared-editor` pass honest about what
240
+ * persist actually writes.
410
241
  *
411
242
  * @param {{ stories: Array<{ slug: string, title: string, body: string, depends_on?: string[] }> }} args
412
243
  * @returns {ConflictFinding[]}
@@ -457,13 +288,7 @@ export function conflictFindingKey(finding) {
457
288
  * of this list is how readers drift apart, so it is defined exactly once and
458
289
  * imported.
459
290
  */
460
- export const CONFLICT_KINDS = Object.freeze(
461
- new Set([
462
- 'shared-editor',
463
- 'implicit-cross-story-dep',
464
- 'missing-bdd-scaffold',
465
- ]),
466
- );
291
+ export const CONFLICT_KINDS = Object.freeze(new Set(['shared-editor']));
467
292
 
468
293
  /**
469
294
  * Render a conflict finding as a human-readable line. Every finding is soft
@@ -476,12 +301,6 @@ export function renderHardConflictError(finding) {
476
301
  const stories = finding.storySlugs.map((s) => `"${s}"`).join(', ');
477
302
  return `Shared-editor conflict: "${finding.path}" is written by ${finding.storySlugs.length} concurrent Stories (${stories}). Add depends_on chains between them or split the edits into a dedicated late-wave wiring Story.`;
478
303
  }
479
- if (finding.kind === 'implicit-cross-story-dep') {
480
- return `Implicit cross-Story dependency: Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
481
- }
482
- if (finding.kind === 'missing-bdd-scaffold') {
483
- return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
484
- }
485
304
  // Findings from other passes carry their own message — render it rather
486
305
  // than a shape-blind generic line, so the soft surface
487
306
  // (`surfaceSoftConflictFindings`) stays legible for every kind.
@@ -496,10 +315,7 @@ export const _internal = {
496
315
  collectStoryProducerPaths,
497
316
  WRITE_IMPLYING_ASSUMPTIONS,
498
317
  indexProducers,
499
- indexConsumers,
500
318
  computeStoryReachability,
501
319
  inSameWave,
502
320
  computeSharedEditorFindings,
503
- computeImplicitDepFindings,
504
- computeMissingBddScaffoldFindings,
505
321
  };
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Story authoring guidance — the two prose constants the story-author prompt
3
- * and the `core/scope-triage` skill both cite, stated once so the surfaces
4
- * cannot drift.
3
+ * cites, stated once so no second copy can drift.
5
4
  *
6
5
  * Story #5312 deleted the numeric sizing model that used to live beside
7
6
  * them: `DEFAULT_MODEL_CAPACITY` with its soft / hard session-mass ceilings,
@@ -18,12 +17,16 @@
18
17
  /**
19
18
  * `DELIVERABLE_GRANULARITY_GUIDANCE` is the **single source of truth** for the
20
19
  * deliverable-granularity definition of a Story (Story #3777). It is stated
21
- * ONCE here and consumed by BOTH the story-author prompt template and the
22
- * authoring SKILL.
20
+ * ONCE here and consumed by the story-author prompt template.
21
+ *
22
+ * Story #5332 re-anchored the definition off "a single reviewer-sized PR":
23
+ * that anchor read as a size ceiling and fragmented cohesive sweeps, so the
24
+ * only stated sizing test is now cohesion — one coherent change with one
25
+ * reason to exist.
23
26
  */
24
27
  export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
25
28
  definition:
26
- 'A Story is a **capability slice a frontier model delivers and self-verifies in one pass** — a shippable slice a reviewer would accept as a single PR, a capability or user-visible surface, **not a single module or file**. Fold module-level slices into the capability they belong to rather than emitting one Story per module.',
29
+ 'A Story is a **capability slice a frontier model delivers and self-verifies in one pass** — one coherent change with one reason to exist, a capability or user-visible surface, **not a single module or file**. Fold module-level slices into the capability they belong to rather than emitting one Story per module. A remediation sweep over one subsystem is one Story; its stages belong in `## Slicing`, not in sibling tickets.',
27
30
  singleConsumerRule:
28
31
  '**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
29
32
  envelopeFloor: