mandrel 2.40.0 → 2.42.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 +2 -2
- package/.agents/agents/story-worker.md +24 -14
- package/.agents/docs/agentrc-reference.json +7 -2
- package/.agents/docs/configuration.md +5 -2
- package/.agents/instructions.md +5 -6
- package/.agents/rules/api-conventions.md +43 -7
- package/.agents/rules/ci-remediation.md +3 -14
- package/.agents/rules/gherkin-standards.md +21 -6
- package/.agents/rules/git-conventions.md +6 -5
- package/.agents/rules/security-baseline.md +6 -7
- package/.agents/rules/testing-standards.md +75 -198
- package/.agents/schemas/agentrc.schema.json +17 -2
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +68 -3
- package/.agents/scripts/coverage-capture.js +25 -8
- package/.agents/scripts/install-matrix-assert.js +2 -2
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
- package/.agents/scripts/lib/baselines/git-base.js +74 -38
- package/.agents/scripts/lib/close-validation/gates.js +153 -25
- package/.agents/scripts/lib/close-validation/process.js +30 -1
- package/.agents/scripts/lib/close-validation/runner.js +5 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
- package/.agents/scripts/lib/config/quality.js +36 -21
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
- package/.agents/scripts/lib/crap-baseline-join.js +11 -7
- package/.agents/scripts/lib/full-suite-lock.js +311 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
- package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
- package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +18 -2
- package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
- package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +5 -1
- package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +14 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
- package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
- package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
- package/.agents/scripts/plan-persist.js +60 -1
- package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
- package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
- package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
- package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
- package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
- package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
- package/.agents/skills/core/security-and-hardening/reference.md +0 -70
- package/.agents/skills/skills.index.json +10 -70
- package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
- package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
- package/.agents/workflows/git-cleanup.md +3 -2
- package/.agents/workflows/git-deliver.md +3 -2
- package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
- package/.agents/workflows/helpers/deliver-digest.md +31 -10
- package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
- package/.agents/workflows/helpers/deliver-story.md +23 -21
- package/.agents/workflows/helpers/plan-reference.md +82 -2
- package/.agents/workflows/mandrel-plan.md +45 -45
- package/.agents/workflows/qa-assist.md +20 -17
- package/.agents/workflows/qa-explore.md +30 -29
- package/.agents/workflows/qa-run.md +2 -1
- package/docs/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/.agents/rules/changelog-style.md +0 -180
- package/.agents/rules/shell-conventions.md +0 -61
- package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
- package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
- package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
- package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
- package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
- package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
- package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
- package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-adoption.js — join the Stories of this persist run to an Epic that
|
|
3
|
+
* already exists.
|
|
4
|
+
*
|
|
5
|
+
* Story #5155. `epic-ops.js` opens a *new* container and, on a resumed run,
|
|
6
|
+
* re-adopts the one carrying its exact fingerprint. This module covers the
|
|
7
|
+
* case neither does: an operator pointing a fresh plan at an Epic an earlier
|
|
8
|
+
* plan opened, with a different cohort and no fingerprint in common.
|
|
9
|
+
*
|
|
10
|
+
* **The posture is deliberately stricter than creation's.** Creation degrades
|
|
11
|
+
* — an unensurable label just skips the container, because the Stories are the
|
|
12
|
+
* part that matters and a missing Epic costs only tidiness. Adoption cannot
|
|
13
|
+
* degrade the same way: the operator named a specific id, so silently not
|
|
14
|
+
* adopting it would leave them believing their Stories were filed somewhere
|
|
15
|
+
* they were not. A bad target is therefore a **hard error, raised before the
|
|
16
|
+
* first Story is created** (dry run included), when nothing has been written
|
|
17
|
+
* and the fix is free. Once the Stories exist, the posture flips to creation's
|
|
18
|
+
* — a failed checklist write or sub-issue edge warns, because by then refusing
|
|
19
|
+
* would strand live Stories over a cosmetic link.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/orchestration/plan-persist/epic-adoption
|
|
22
|
+
* @see Story #5155
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Logger } from '../../Logger.js';
|
|
26
|
+
import { TYPE_LABELS } from '../../label-constants.js';
|
|
27
|
+
import { appendEpicChildIds } from '../epic-checklist.js';
|
|
28
|
+
import { isEpicTicket } from '../epic-container.js';
|
|
29
|
+
import { mirrorSubIssueEdges } from './epic-ops.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve and validate the Epic an operator asked to adopt.
|
|
33
|
+
*
|
|
34
|
+
* Called **before any create**, so every refusal below costs the operator a
|
|
35
|
+
* re-run of a command that wrote nothing.
|
|
36
|
+
*
|
|
37
|
+
* A null/absent `epicId` is the ordinary "no adoption requested" case and
|
|
38
|
+
* resolves to `null` — only a *supplied* id can be wrong, and every wrong one
|
|
39
|
+
* throws.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ provider: object, epicId: number|null }} opts
|
|
42
|
+
* @returns {Promise<{ id: number, title: string, body: string }|null>}
|
|
43
|
+
* @throws {Error} When a supplied id is missing, closed, or not a container Epic.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveAdoptionTarget({ provider, epicId }) {
|
|
46
|
+
if (epicId === null || epicId === undefined) return null;
|
|
47
|
+
const id = Number(epicId);
|
|
48
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[plan-persist] --epic expects a positive issue id (got "${epicId}").`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (typeof provider?.getTicket !== 'function') {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'[plan-persist] provider exposes no getTicket — cannot verify the Epic to adopt.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let issue;
|
|
60
|
+
try {
|
|
61
|
+
issue = await provider.getTicket(id);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[plan-persist] --epic #${id} could not be read (${err?.message ?? err}). ` +
|
|
65
|
+
'Adoption needs an existing open container Epic.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!issue) {
|
|
69
|
+
throw new Error(`[plan-persist] --epic #${id} does not exist.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const state = String(issue.state ?? 'open').toLowerCase();
|
|
73
|
+
if (state !== 'open') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[plan-persist] --epic #${id} is ${state}. A closed Epic is a finished body of ` +
|
|
76
|
+
'work and is never reopened by a plan — open a new container, or reopen it by hand first.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!isEpicTicket(issue)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`[plan-persist] --epic #${id} does not carry "${TYPE_LABELS.EPIC}" — it is not a ` +
|
|
82
|
+
'container Epic. Adopting an ordinary Story would file this plan under a work item.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
title: typeof issue.title === 'string' ? issue.title : '',
|
|
89
|
+
body: typeof issue.body === 'string' ? issue.body : '',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Link this run's Stories into an already-resolved Epic.
|
|
95
|
+
*
|
|
96
|
+
* Runs **after** the Stories exist, because both halves of the linkage need
|
|
97
|
+
* their real ids: the checklist embeds issue numbers and the sub-issue edges
|
|
98
|
+
* need database ids.
|
|
99
|
+
*
|
|
100
|
+
* @param {{
|
|
101
|
+
* provider: object,
|
|
102
|
+
* target: { id: number, title: string, body: string },
|
|
103
|
+
* created: Array<{ id: number }>,
|
|
104
|
+
* opts?: { dryRun?: boolean },
|
|
105
|
+
* }} args
|
|
106
|
+
* @returns {Promise<{
|
|
107
|
+
* id: number,
|
|
108
|
+
* title: string,
|
|
109
|
+
* childIds: number[],
|
|
110
|
+
* adopted: true,
|
|
111
|
+
* edges: { added: number, skipped: number, failed: number }|null,
|
|
112
|
+
* }|null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function adoptContainerEpic({
|
|
115
|
+
provider,
|
|
116
|
+
target,
|
|
117
|
+
created,
|
|
118
|
+
opts = {},
|
|
119
|
+
}) {
|
|
120
|
+
const { dryRun = false } = opts;
|
|
121
|
+
if (!target) return null;
|
|
122
|
+
|
|
123
|
+
const all = Array.isArray(created) ? created : [];
|
|
124
|
+
|
|
125
|
+
// Dry run reports the intent write-free. `created` carries negative
|
|
126
|
+
// placeholder ids there, so report them as-is rather than filtering to the
|
|
127
|
+
// positives and claiming an empty adoption.
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
return {
|
|
130
|
+
id: target.id,
|
|
131
|
+
title: target.title,
|
|
132
|
+
childIds: all.map((s) => s.id),
|
|
133
|
+
adopted: true,
|
|
134
|
+
edges: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const childIds = all
|
|
139
|
+
.map((s) => s.id)
|
|
140
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
141
|
+
if (childIds.length === 0) return null;
|
|
142
|
+
|
|
143
|
+
await appendChecklist({ provider, target, childIds });
|
|
144
|
+
const edges = await mirrorSubIssueEdges({
|
|
145
|
+
provider,
|
|
146
|
+
epicNumber: target.id,
|
|
147
|
+
childIds,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
Logger.info(
|
|
151
|
+
`[plan-persist] adopted container Epic #${target.id} — it now groups ` +
|
|
152
|
+
`${childIds.length} more Story(ies): /mandrel-deliver ${target.id}`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id: target.id,
|
|
157
|
+
title: target.title,
|
|
158
|
+
childIds,
|
|
159
|
+
adopted: true,
|
|
160
|
+
edges,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Write the appended checklist back to the Epic body.
|
|
166
|
+
*
|
|
167
|
+
* Non-fatal: the Stories are already live, and the native sub-issue edges
|
|
168
|
+
* written next are the other half of the linkage. Losing the checklist costs
|
|
169
|
+
* the body-only fallback path, not the grouping.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ provider: object, target: { id: number, body: string }, childIds: number[] }} opts
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async function appendChecklist({ provider, target, childIds }) {
|
|
175
|
+
if (typeof provider?.updateTicket !== 'function') {
|
|
176
|
+
Logger.warn(
|
|
177
|
+
'[plan-persist] provider exposes no updateTicket — the adopted Epic body was not ' +
|
|
178
|
+
'updated. The native sub-issue edges still record the grouping.',
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const next = appendEpicChildIds(target.body, childIds);
|
|
183
|
+
if (next === target.body) return;
|
|
184
|
+
try {
|
|
185
|
+
await provider.updateTicket(target.id, { body: next });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
Logger.warn(
|
|
188
|
+
`[plan-persist] could not update Epic #${target.id}'s checklist ` +
|
|
189
|
+
`(${err?.message ?? err}). The native sub-issue edges still record the grouping.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -150,10 +150,14 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
150
150
|
* `getSubTickets` reads it as a first-class child source. A lost edge costs
|
|
151
151
|
* the GitHub UI's nesting, not the grouping itself.
|
|
152
152
|
*
|
|
153
|
+
* Exported since Story #5155 so the adoption path (`epic-adoption.js`) links
|
|
154
|
+
* children exactly the way creation does — one mirroring rule, not two that
|
|
155
|
+
* drift.
|
|
156
|
+
*
|
|
153
157
|
* @param {{ provider: object, epicNumber: number, childIds: number[] }} opts
|
|
154
158
|
* @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
|
|
155
159
|
*/
|
|
156
|
-
async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
|
|
160
|
+
export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
|
|
157
161
|
if (
|
|
158
162
|
typeof provider?.getDependencyWriteContext !== 'function' ||
|
|
159
163
|
typeof provider?.getTicket !== 'function'
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-deps.js — `depends_on` entries that point outside this plan run.
|
|
3
|
+
*
|
|
4
|
+
* Story #5155. A `depends_on[]` entry has always been a **sibling slug**: a
|
|
5
|
+
* name resolvable only inside the `stories.json` being persisted. That makes
|
|
6
|
+
* every ordering edge intra-plan by construction, and leaves the cross-plan
|
|
7
|
+
* case — a new Story that must wait for an open Story from an earlier run —
|
|
8
|
+
* expressible only by hand-editing the issue body after persist.
|
|
9
|
+
*
|
|
10
|
+
* An entry of the form `#1234` is that missing case: an **external** blocker,
|
|
11
|
+
* already live on the tracker. The two forms are distinguished lexically and
|
|
12
|
+
* totally, so nothing has to guess:
|
|
13
|
+
*
|
|
14
|
+
* - `some-slug` → a sibling, resolved against this run's slug map;
|
|
15
|
+
* - `#1234` → an existing issue, resolved against the tracker.
|
|
16
|
+
*
|
|
17
|
+
* External refs are excluded from sibling ordering and cycle detection. They
|
|
18
|
+
* cannot participate in either: a Story already open is not scheduled by this
|
|
19
|
+
* run, so it has no position in the topological sort, and it cannot close a
|
|
20
|
+
* cycle back into a Story that does not exist yet. Treating them as siblings
|
|
21
|
+
* is what would break — the unknown-slug guard would reject every one.
|
|
22
|
+
*
|
|
23
|
+
* They are validated strictly, and **before any create**: an unresolvable
|
|
24
|
+
* blocker that surfaced after the fact would leave a live Story gated on
|
|
25
|
+
* something that can never satisfy it, which the delivery engine reads as a
|
|
26
|
+
* permanent wedge rather than an error.
|
|
27
|
+
*
|
|
28
|
+
* @module lib/orchestration/plan-persist/external-deps
|
|
29
|
+
* @see Story #5155
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { TYPE_LABELS } from '../../label-constants.js';
|
|
33
|
+
|
|
34
|
+
/** A `depends_on` entry naming an existing issue: `#` followed by digits. */
|
|
35
|
+
const EXTERNAL_REF_RE = /^#(\d+)$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Is this `depends_on` entry an external issue reference?
|
|
39
|
+
*
|
|
40
|
+
* @param {unknown} entry
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function isExternalDependencyRef(entry) {
|
|
44
|
+
return typeof entry === 'string' && EXTERNAL_REF_RE.test(entry.trim());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The issue number an external ref names, or `null` for a sibling slug.
|
|
49
|
+
*
|
|
50
|
+
* @param {unknown} entry
|
|
51
|
+
* @returns {number|null}
|
|
52
|
+
*/
|
|
53
|
+
export function externalDependencyId(entry) {
|
|
54
|
+
if (typeof entry !== 'string') return null;
|
|
55
|
+
const match = entry.trim().match(EXTERNAL_REF_RE);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
const id = Number.parseInt(match[1], 10);
|
|
58
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every distinct external id declared across a plan's Stories, in first-seen
|
|
63
|
+
* order.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{ depends_on?: string[] }>} stories
|
|
66
|
+
* @returns {number[]}
|
|
67
|
+
*/
|
|
68
|
+
export function collectExternalDependencyIds(stories) {
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const story of Array.isArray(stories) ? stories : []) {
|
|
72
|
+
for (const entry of story?.depends_on ?? []) {
|
|
73
|
+
const id = externalDependencyId(entry);
|
|
74
|
+
if (id === null || seen.has(id)) continue;
|
|
75
|
+
seen.add(id);
|
|
76
|
+
out.push(id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize an issue's labels to plain strings.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} raw
|
|
86
|
+
* @returns {string[]}
|
|
87
|
+
*/
|
|
88
|
+
function labelNames(raw) {
|
|
89
|
+
if (!Array.isArray(raw)) return [];
|
|
90
|
+
return raw
|
|
91
|
+
.map((l) => (typeof l === 'string' ? l : l?.name))
|
|
92
|
+
.filter((n) => typeof n === 'string');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Explain why one external blocker is unusable, or `null` when it is fine.
|
|
97
|
+
*
|
|
98
|
+
* @param {number} id
|
|
99
|
+
* @param {object|null} issue
|
|
100
|
+
* @returns {string|null}
|
|
101
|
+
*/
|
|
102
|
+
function rejectionReason(id, issue) {
|
|
103
|
+
if (!issue) return `#${id} does not exist`;
|
|
104
|
+
const state = String(issue.state ?? 'open').toLowerCase();
|
|
105
|
+
if (state !== 'open') {
|
|
106
|
+
return `#${id} is ${state} — a landed Story cannot gate new work, so the edge would never lift`;
|
|
107
|
+
}
|
|
108
|
+
const labels = labelNames(issue.labels);
|
|
109
|
+
if (labels.includes(TYPE_LABELS.EPIC)) {
|
|
110
|
+
return `#${id} is a container Epic — Epics are never delivered, so nothing would ever satisfy the edge`;
|
|
111
|
+
}
|
|
112
|
+
if (!labels.includes(TYPE_LABELS.STORY)) {
|
|
113
|
+
return `#${id} is not a ${TYPE_LABELS.STORY} — only a Story can be delivered and thereby unblock this one`;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Verify every external `depends_on` ref resolves to an open Story.
|
|
120
|
+
*
|
|
121
|
+
* Hard-errors listing **every** bad ref rather than the first, so an operator
|
|
122
|
+
* fixing a plan sees the whole set in one pass.
|
|
123
|
+
*
|
|
124
|
+
* @param {{ provider: object, stories: Array<{ slug: string, depends_on?: string[] }> }} args
|
|
125
|
+
* @returns {Promise<number[]>} The validated external ids (possibly empty).
|
|
126
|
+
* @throws {Error} When any ref is missing, closed, an Epic, or not a Story.
|
|
127
|
+
*/
|
|
128
|
+
export async function assertExternalDependenciesResolvable({
|
|
129
|
+
provider,
|
|
130
|
+
stories,
|
|
131
|
+
}) {
|
|
132
|
+
const ids = collectExternalDependencyIds(stories);
|
|
133
|
+
if (ids.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
if (typeof provider?.getTicket !== 'function') {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'[plan-persist] provider exposes no getTicket — cannot verify the external ' +
|
|
138
|
+
`depends_on reference(s): ${ids.map((i) => `#${i}`).join(', ')}.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const problems = [];
|
|
143
|
+
for (const id of ids) {
|
|
144
|
+
let issue = null;
|
|
145
|
+
try {
|
|
146
|
+
issue = await provider.getTicket(id);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
problems.push(`#${id} could not be read (${err?.message ?? err})`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const reason = rejectionReason(id, issue);
|
|
152
|
+
if (reason) problems.push(reason);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (problems.length > 0) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[plan-persist] ${problems.length} external depends_on reference(s) cannot gate ` +
|
|
158
|
+
`this plan:\n - ${problems.join('\n - ')}\n\nEvery "#<id>" entry must name an ` +
|
|
159
|
+
`open ${TYPE_LABELS.STORY}. Drop the entry, or point it at a Story that is still open.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return ids;
|
|
164
|
+
}
|
|
@@ -68,7 +68,10 @@ import {
|
|
|
68
68
|
renderHardConflictError,
|
|
69
69
|
} from '../ticket-validator-conflicts.js';
|
|
70
70
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
71
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
resolveContainerEpic,
|
|
73
|
+
resolveCrossPlanLinks,
|
|
74
|
+
} from './cross-plan-links.js';
|
|
72
75
|
import {
|
|
73
76
|
enforceFanOutGate,
|
|
74
77
|
surfaceSoftConflictFindings,
|
|
@@ -751,6 +754,14 @@ export async function runPlanPersist({
|
|
|
751
754
|
});
|
|
752
755
|
await enforceReachability(reachability, config);
|
|
753
756
|
|
|
757
|
+
// Story #5155 — the plan's outward references (`--epic <id>`, and any
|
|
758
|
+
// `#<id>` blocker) resolve BEFORE the first create, dry run included.
|
|
759
|
+
const adoptionTarget = await resolveCrossPlanLinks({
|
|
760
|
+
provider,
|
|
761
|
+
stories: rawStories,
|
|
762
|
+
epicId: opts.adoptEpicId ?? null,
|
|
763
|
+
});
|
|
764
|
+
|
|
754
765
|
// Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
|
|
755
766
|
const { stories } = assemblePlanStories(rawStories, {
|
|
756
767
|
sharedSpec: techSpecContent,
|
|
@@ -850,8 +861,9 @@ export async function runPlanPersist({
|
|
|
850
861
|
// database ids, neither of which exists until the Stories are live. It is
|
|
851
862
|
// never load-bearing, so a failure here degrades to "no container" and the
|
|
852
863
|
// Stories still deliver by id.
|
|
853
|
-
const containerEpic = await
|
|
864
|
+
const containerEpic = await resolveContainerEpic({
|
|
854
865
|
provider,
|
|
866
|
+
adoptionTarget,
|
|
855
867
|
epic,
|
|
856
868
|
created,
|
|
857
869
|
opts: { dryRun },
|
|
@@ -34,6 +34,10 @@ import {
|
|
|
34
34
|
} from '../../util/concurrent-map.js';
|
|
35
35
|
import { assertSpecWithinBudget } from '../spec-spill.js';
|
|
36
36
|
import { assertAcceptancePartition } from '../split-policy-validator.js';
|
|
37
|
+
import {
|
|
38
|
+
externalDependencyId,
|
|
39
|
+
isExternalDependencyRef,
|
|
40
|
+
} from './external-deps.js';
|
|
37
41
|
import {
|
|
38
42
|
assertSupersedePartition,
|
|
39
43
|
normalizeSupersedes,
|
|
@@ -557,7 +561,9 @@ function orderStoriesByDependencies(stories) {
|
|
|
557
561
|
const list = Array.isArray(stories) ? stories : [];
|
|
558
562
|
const known = new Set(list.map((story) => story.slug));
|
|
559
563
|
for (const story of list) {
|
|
560
|
-
const unknown = story.depends_on.filter(
|
|
564
|
+
const unknown = story.depends_on.filter(
|
|
565
|
+
(slug) => !isExternalDependencyRef(slug) && !known.has(slug),
|
|
566
|
+
);
|
|
561
567
|
if (unknown.length > 0) {
|
|
562
568
|
throw new Error(
|
|
563
569
|
`[plan-persist] Story "${story.slug}" depends on unknown sibling(s): ${unknown.join(', ')}`,
|
|
@@ -568,8 +574,13 @@ function orderStoriesByDependencies(stories) {
|
|
|
568
574
|
const scheduled = new Set();
|
|
569
575
|
const pending = [...list];
|
|
570
576
|
while (pending.length > 0) {
|
|
577
|
+
// External refs gate delivery, never creation order: the blocker is
|
|
578
|
+
// already live, so it can never become "scheduled" in this run and would
|
|
579
|
+
// otherwise wedge the sort into a false cycle (Story #5155).
|
|
571
580
|
const index = pending.findIndex((story) =>
|
|
572
|
-
story.depends_on
|
|
581
|
+
story.depends_on
|
|
582
|
+
.filter((slug) => !isExternalDependencyRef(slug))
|
|
583
|
+
.every((slug) => scheduled.has(slug)),
|
|
573
584
|
);
|
|
574
585
|
if (index === -1) {
|
|
575
586
|
throw new Error(
|
|
@@ -720,8 +731,8 @@ function warnOnDivergentSameTitleStory(story, idsByTitle) {
|
|
|
720
731
|
* @returns {string}
|
|
721
732
|
*/
|
|
722
733
|
function renderStoryBodyForCreate(story, idBySlug) {
|
|
723
|
-
const dependencyRefs = story.depends_on.map(
|
|
724
|
-
(slug)
|
|
734
|
+
const dependencyRefs = story.depends_on.map((slug) =>
|
|
735
|
+
isExternalDependencyRef(slug) ? slug.trim() : `#${idBySlug.get(slug)}`,
|
|
725
736
|
);
|
|
726
737
|
let base = story.body;
|
|
727
738
|
if (dependencyRefs.length > 0) {
|
|
@@ -798,12 +809,22 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
|
|
|
798
809
|
|
|
799
810
|
try {
|
|
800
811
|
const { gh, owner, repo } = provider.getDependencyWriteContext();
|
|
812
|
+
// `applyBlockedByDependencies` resolves every entry through this one map,
|
|
813
|
+
// so an external `#<id>` ref only needs an identity entry to be mirrored
|
|
814
|
+
// by the same code path as a sibling (Story #5155).
|
|
815
|
+
const slugToIssueNumber = Object.fromEntries(idBySlug);
|
|
816
|
+
for (const story of stories) {
|
|
817
|
+
for (const entry of story.depends_on) {
|
|
818
|
+
const externalId = externalDependencyId(entry);
|
|
819
|
+
if (externalId !== null) slugToIssueNumber[entry.trim()] = externalId;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
801
822
|
const summary = await applyBlockedByDependencies({
|
|
802
823
|
stories: stories.map((story) => ({
|
|
803
824
|
slug: story.slug,
|
|
804
825
|
dependsOn: story.depends_on,
|
|
805
826
|
})),
|
|
806
|
-
slugToIssueNumber
|
|
827
|
+
slugToIssueNumber,
|
|
807
828
|
getTicket: (issueNumber) => provider.getTicket(issueNumber),
|
|
808
829
|
owner,
|
|
809
830
|
repo,
|
|
@@ -27,8 +27,12 @@ import {
|
|
|
27
27
|
const PHASE_ORDER = Object.freeze([
|
|
28
28
|
'init',
|
|
29
29
|
'wrong-tree-guard',
|
|
30
|
-
|
|
30
|
+
// Story #5172 — base-sync now precedes close-validation, so the tree the
|
|
31
|
+
// gates validate is the tree the push sends. The order here is not
|
|
32
|
+
// decoration: it is how a failed terminal decides which gates had already
|
|
33
|
+
// cleared, so it MUST track `runPrePushPhases`.
|
|
31
34
|
'base-sync',
|
|
35
|
+
'close-validation',
|
|
32
36
|
'push',
|
|
33
37
|
'pull-request',
|
|
34
38
|
'code-review',
|
|
@@ -45,6 +49,62 @@ const GATE_PHASES = Object.freeze([
|
|
|
45
49
|
['codeReview', 'code-review'],
|
|
46
50
|
]);
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The names the split baselines gate registers under, mirrored from
|
|
54
|
+
* `BASELINES_GATE_NAMES` in `lib/close-validation/gates.js` (Story #5172).
|
|
55
|
+
*
|
|
56
|
+
* Deliberately a local copy rather than an import: several close suites
|
|
57
|
+
* replace that module wholesale via `t.mock.module`, and a named import here
|
|
58
|
+
* would fail to link against a mock that does not re-export the constant —
|
|
59
|
+
* turning an unrelated test's mock into a load error on the CLI's own entry
|
|
60
|
+
* path. `tests/close-validation-gates-enum.test.js` pins the two lists
|
|
61
|
+
* against each other so the copy cannot drift.
|
|
62
|
+
*/
|
|
63
|
+
const BASELINES_ENTRY_NAMES = Object.freeze([
|
|
64
|
+
'check-baselines-independent',
|
|
65
|
+
'check-baselines-coverage',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Outcome for each split baselines entry on a run that died at `phase`.
|
|
70
|
+
*
|
|
71
|
+
* The two entries sit in ONE pipeline phase, so the phase walk alone cannot
|
|
72
|
+
* separate them — `failedGate` (tagged onto the error by the close-validation
|
|
73
|
+
* phase) is what names the entry that actually broke. Rules, in the module's
|
|
74
|
+
* house style of never claiming a pass it cannot prove:
|
|
75
|
+
* - validation skipped, or the run died before reaching it → both `skipped`.
|
|
76
|
+
* - the run cleared validation entirely → both `passed`.
|
|
77
|
+
* - the run died IN validation on the coverage-independent entry → that one
|
|
78
|
+
* `failed`, the coverage one `skipped` (it runs behind `coverage-capture`,
|
|
79
|
+
* which the failure pre-empted).
|
|
80
|
+
* - died on the coverage-consuming entry → that one `failed`, and the
|
|
81
|
+
* independent one `passed`: it is in the parallel partition that must go
|
|
82
|
+
* green before any serial gate starts.
|
|
83
|
+
* - died in validation on some other gate → both `skipped`; which of them
|
|
84
|
+
* had run is not knowable from the phase alone.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} phase
|
|
87
|
+
* @param {{ skipValidation?: boolean, failedGate?: string|null }} args
|
|
88
|
+
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
89
|
+
*/
|
|
90
|
+
function baselinesGatesForFailedPhase(phase, { skipValidation, failedGate }) {
|
|
91
|
+
const [independent, coverage] = BASELINES_ENTRY_NAMES;
|
|
92
|
+
const both = (outcome) => ({ [independent]: outcome, [coverage]: outcome });
|
|
93
|
+
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
94
|
+
const validationAt = PHASE_ORDER.indexOf('close-validation');
|
|
95
|
+
if (skipValidation || failedAt < 0 || failedAt < validationAt) {
|
|
96
|
+
return both('skipped');
|
|
97
|
+
}
|
|
98
|
+
if (failedAt > validationAt) return both('passed');
|
|
99
|
+
if (failedGate === independent) {
|
|
100
|
+
return { [independent]: 'failed', [coverage]: 'skipped' };
|
|
101
|
+
}
|
|
102
|
+
if (failedGate === coverage) {
|
|
103
|
+
return { [independent]: 'passed', [coverage]: 'failed' };
|
|
104
|
+
}
|
|
105
|
+
return both('skipped');
|
|
106
|
+
}
|
|
107
|
+
|
|
48
108
|
/**
|
|
49
109
|
* Report every gate's outcome for a run that died at `phase`.
|
|
50
110
|
*
|
|
@@ -59,8 +119,14 @@ const GATE_PHASES = Object.freeze([
|
|
|
59
119
|
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
60
120
|
* not pass — it never ran).
|
|
61
121
|
*
|
|
122
|
+
* Story #5172 — the reported set also carries the two split baselines
|
|
123
|
+
* entries under their own names, so a failed close says WHICH half of the
|
|
124
|
+
* baselines gate breached instead of a single generic verdict.
|
|
125
|
+
*
|
|
62
126
|
* @param {string} phase The phase the run died in.
|
|
63
|
-
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args
|
|
127
|
+
* @param {{ skipValidation?: boolean, skipSync?: boolean, failedGate?: string|null }} args
|
|
128
|
+
* Parsed CLI args, plus the gate name tagged onto the error by the
|
|
129
|
+
* close-validation phase.
|
|
64
130
|
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
65
131
|
*/
|
|
66
132
|
export function gatesForFailedPhase(phase, args = {}) {
|
|
@@ -73,7 +139,13 @@ export function gatesForFailedPhase(phase, args = {}) {
|
|
|
73
139
|
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
74
140
|
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
75
141
|
}
|
|
76
|
-
return
|
|
142
|
+
return {
|
|
143
|
+
...gates,
|
|
144
|
+
...baselinesGatesForFailedPhase(phase, {
|
|
145
|
+
skipValidation: args.skipValidation,
|
|
146
|
+
failedGate: args.failedGate ?? null,
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
77
149
|
}
|
|
78
150
|
|
|
79
151
|
/**
|
|
@@ -91,6 +163,10 @@ export function gatesForFailedPhase(phase, args = {}) {
|
|
|
91
163
|
* holding the script had been reaped mid-run. On failure this returns null
|
|
92
164
|
* and the caller rethrows the original.
|
|
93
165
|
*
|
|
166
|
+
* `err.closeGate` — tagged by the close-validation phase — names the gate that
|
|
167
|
+
* died inside that phase, which is what lets the reported gates separate the
|
|
168
|
+
* two split baselines entries (Story #5172).
|
|
169
|
+
*
|
|
94
170
|
* @param {unknown} err
|
|
95
171
|
* @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
|
|
96
172
|
* Parsed CLI args — the story id the envelope reports on, plus the skip
|
|
@@ -108,7 +184,10 @@ export function failedTerminalFor(err, args = {}) {
|
|
|
108
184
|
storyId,
|
|
109
185
|
status: 'failed',
|
|
110
186
|
phase,
|
|
111
|
-
gates: gatesForFailedPhase(phase,
|
|
187
|
+
gates: gatesForFailedPhase(phase, {
|
|
188
|
+
...args,
|
|
189
|
+
failedGate: err?.closeGate ?? null,
|
|
190
|
+
}),
|
|
112
191
|
failure: { reason: String(err?.message ?? err) },
|
|
113
192
|
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
114
193
|
elapsedSeconds: 0,
|
|
@@ -79,6 +79,10 @@ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
|
79
79
|
* runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
|
|
80
80
|
* createGateLogSink?: typeof defaultCreateGateLogSink,
|
|
81
81
|
* }} args
|
|
82
|
+
* @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
|
|
83
|
+
* outcomes keyed by gate name — the terminal envelope reports the split
|
|
84
|
+
* baselines entries from this (Story #5172). A failure throws instead, with
|
|
85
|
+
* `err.closeGate` naming the gate that died.
|
|
82
86
|
*/
|
|
83
87
|
export async function runCloseValidationPhase({
|
|
84
88
|
cwd,
|
|
@@ -144,17 +148,18 @@ export async function runCloseValidationPhase({
|
|
|
144
148
|
// Story #4736 — one sink for both `log` seams (gate construction and gate
|
|
145
149
|
// execution), so nothing in the chain can route around the artifact.
|
|
146
150
|
const gateLog = createGateLogSink({ storyId, config });
|
|
151
|
+
const gateList = buildDefaultGates({
|
|
152
|
+
config,
|
|
153
|
+
baseBranch,
|
|
154
|
+
cwd: worktreePath || cwd,
|
|
155
|
+
log: gateLog.log,
|
|
156
|
+
});
|
|
147
157
|
let validation;
|
|
148
158
|
try {
|
|
149
159
|
validation = await runCloseValidation({
|
|
150
160
|
cwd,
|
|
151
161
|
worktreePath,
|
|
152
|
-
gates:
|
|
153
|
-
config,
|
|
154
|
-
baseBranch,
|
|
155
|
-
cwd: worktreePath || cwd,
|
|
156
|
-
log: gateLog.log,
|
|
157
|
-
}),
|
|
162
|
+
gates: gateList,
|
|
158
163
|
log: gateLog.log,
|
|
159
164
|
storyId,
|
|
160
165
|
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
@@ -182,10 +187,37 @@ export async function runCloseValidationPhase({
|
|
|
182
187
|
// The evidence is the point on this path: replay the captured tail inline
|
|
183
188
|
// rather than making the caller open a file to learn why close stopped.
|
|
184
189
|
gateLog.replay();
|
|
185
|
-
|
|
190
|
+
const err = new Error(
|
|
186
191
|
`[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
|
|
187
192
|
(gate.hint ? ` ${gate.hint}` : ''),
|
|
188
193
|
);
|
|
194
|
+
// Story #5172 — the phase tracker tags `closePhase`; this tags WHICH gate
|
|
195
|
+
// inside the phase died, so the failed terminal can name the split
|
|
196
|
+
// baselines entry rather than reporting a generic validation failure.
|
|
197
|
+
err.closeGate = gate.name;
|
|
198
|
+
throw err;
|
|
189
199
|
}
|
|
190
200
|
progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
|
|
201
|
+
return { gates: gateOutcomes(gateList, validation) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Per-gate outcomes for a validation run that passed (Story #5172).
|
|
206
|
+
*
|
|
207
|
+
* Every registered gate passed unless the runner reported it skipped — an
|
|
208
|
+
* evidence short-circuit at unchanged HEAD, or a changed-file scope that
|
|
209
|
+
* matched nothing. `skipped` is the honest verdict for both: the gate did not
|
|
210
|
+
* run in THIS invocation, and the terminal schema's own contract is that a
|
|
211
|
+
* skipped gate is reported as skipped rather than quietly counted as a pass.
|
|
212
|
+
*
|
|
213
|
+
* @param {Array<{ name: string }>} gateList The gates this run registered.
|
|
214
|
+
* @param {{ skipped?: Array<{ gate: { name: string } }> }} validation
|
|
215
|
+
* @returns {Record<string, 'passed'|'skipped'>}
|
|
216
|
+
*/
|
|
217
|
+
function gateOutcomes(gateList, validation) {
|
|
218
|
+
const outcomes = {};
|
|
219
|
+
for (const gate of gateList) outcomes[gate.name] = 'passed';
|
|
220
|
+
for (const { gate } of validation.skipped ?? [])
|
|
221
|
+
outcomes[gate.name] = 'skipped';
|
|
222
|
+
return outcomes;
|
|
191
223
|
}
|