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.
- package/.agents/README.md +6 -3
- package/.agents/agents/story-worker.md +12 -11
- package/.agents/docs/SDLC.md +6 -7
- package/.agents/docs/quality-gates.md +1 -1
- package/.agents/instructions.md +2 -3
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/scripts/evidence-gate.js +17 -1
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/orchestration/code-review.js +7 -3
- package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +41 -27
- package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +45 -31
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +15 -5
- package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/story-body/story-body.js +36 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +73 -21
- package/.agents/scripts/lib/test-run-credit.js +23 -12
- package/.agents/scripts/plan-persist.js +0 -11
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/deliver-digest.md +22 -15
- package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
- package/.agents/workflows/helpers/deliver-story.md +6 -5
- package/.agents/workflows/helpers/plan-reference.md +53 -13
- package/.agents/workflows/mandrel-plan.md +19 -14
- package/README.md +3 -3
- package/docs/CHANGELOG.md +21 -0
- package/lib/cli/registry.js +143 -27
- package/package.json +7 -2
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- package/.agents/skills/core/scope-triage/SKILL.md +0 -48
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* plan-text-hygiene.js — the `open-question`
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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} (${
|
|
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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
406
|
-
* so on the real payload
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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** —
|
|
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:
|