mandrel 2.40.0 → 2.41.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/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/scripts/install-matrix-assert.js +2 -2
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
- 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/ticket-validator.js +11 -1
- 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/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 +14 -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,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cross-plan-links.js — resolve every reference a plan makes to something
|
|
3
|
+
* outside itself, before anything is written.
|
|
4
|
+
*
|
|
5
|
+
* Story #5155. A plan can now point at two things it did not author: the
|
|
6
|
+
* container Epic it wants to join (`--epic <id>`) and the open Stories it must
|
|
7
|
+
* wait for (`depends_on: ["#<id>"]`). They are different features with
|
|
8
|
+
* different owners, but they share the one property that decides *when* they
|
|
9
|
+
* are checked, and that is what this module exists to express: both name live
|
|
10
|
+
* tracker state, so both are verified **before the first `createIssue`**, dry
|
|
11
|
+
* run included.
|
|
12
|
+
*
|
|
13
|
+
* The timing is the whole point. Either reference is free to fix while nothing
|
|
14
|
+
* has been written and expensive afterwards — an unresolvable blocker left on
|
|
15
|
+
* a live Story reads to the delivery engine as a permanent wedge rather than
|
|
16
|
+
* an error worth reporting, and a mistyped Epic id would leave the operator
|
|
17
|
+
* believing their Stories were filed somewhere they were not. Resolving them
|
|
18
|
+
* together, at one call site, is what keeps a later edit from quietly moving
|
|
19
|
+
* one of them after the creates.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/orchestration/plan-persist/cross-plan-links
|
|
22
|
+
* @see Story #5155
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { adoptContainerEpic, resolveAdoptionTarget } from './epic-adoption.js';
|
|
26
|
+
import { createContainerEpic } from './epic-ops.js';
|
|
27
|
+
import { assertExternalDependenciesResolvable } from './external-deps.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify a plan's outward references and return the Epic it adopts.
|
|
31
|
+
*
|
|
32
|
+
* @param {{
|
|
33
|
+
* provider: object,
|
|
34
|
+
* stories: Array<{ slug: string, depends_on?: string[] }>,
|
|
35
|
+
* epicId: number|null,
|
|
36
|
+
* }} args
|
|
37
|
+
* @returns {Promise<{ id: number, title: string, body: string }|null>}
|
|
38
|
+
* The resolved adoption target, or `null` when none was requested.
|
|
39
|
+
* @throws {Error} When a `#<id>` blocker or the named Epic cannot be used.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveCrossPlanLinks({ provider, stories, epicId }) {
|
|
42
|
+
await assertExternalDependenciesResolvable({ provider, stories });
|
|
43
|
+
return resolveAdoptionTarget({ provider, epicId });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve this run's container Epic — adopted or newly created.
|
|
48
|
+
*
|
|
49
|
+
* The one entry point `run-plan-persist` calls, so the engine holds a single
|
|
50
|
+
* statement rather than a branch it has to keep straight: which of the two
|
|
51
|
+
* paths applies is decided by whether an adoption target was resolved before
|
|
52
|
+
* the creates, and the two have opposite failure postures that are easy to
|
|
53
|
+
* apply to the wrong one when the choice is inlined at the call site.
|
|
54
|
+
*
|
|
55
|
+
* @param {{
|
|
56
|
+
* provider: object,
|
|
57
|
+
* adoptionTarget: { id: number, title: string, body: string }|null,
|
|
58
|
+
* epic: { title: string, goal: string }|null,
|
|
59
|
+
* created: Array<{ id: number }>,
|
|
60
|
+
* opts?: { dryRun?: boolean },
|
|
61
|
+
* }} args
|
|
62
|
+
* @returns {Promise<object|null>} `null` when this run has no container.
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveContainerEpic({
|
|
65
|
+
provider,
|
|
66
|
+
adoptionTarget,
|
|
67
|
+
epic,
|
|
68
|
+
created,
|
|
69
|
+
opts = {},
|
|
70
|
+
}) {
|
|
71
|
+
if (adoptionTarget) {
|
|
72
|
+
return adoptContainerEpic({
|
|
73
|
+
provider,
|
|
74
|
+
target: adoptionTarget,
|
|
75
|
+
created,
|
|
76
|
+
opts,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return createContainerEpic({ provider, epic, created, opts });
|
|
80
|
+
}
|
|
@@ -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,
|
|
@@ -5,6 +5,7 @@ import { gitSpawn } from '../git-utils.js';
|
|
|
5
5
|
|
|
6
6
|
import { Logger } from '../Logger.js';
|
|
7
7
|
import { validateStoryFileAssumptions } from './file-assumptions.js';
|
|
8
|
+
import { isExternalDependencyRef } from './plan-persist/external-deps.js';
|
|
8
9
|
import { computeSpecBudgetFindings } from './spec-budget.js';
|
|
9
10
|
import {
|
|
10
11
|
assertStoryBodiesParse,
|
|
@@ -461,7 +462,13 @@ function indexTicketsBySlug(tickets) {
|
|
|
461
462
|
}
|
|
462
463
|
ticketBySlug.set(t.slug, t);
|
|
463
464
|
}
|
|
464
|
-
|
|
465
|
+
// External `#<id>` refs (Story #5155) name issues already on the tracker,
|
|
466
|
+
// not nodes in this run's graph — they cannot close a cycle back into a
|
|
467
|
+
// Story that does not exist yet, so they are not edges here.
|
|
468
|
+
slugAdjacency.set(
|
|
469
|
+
t.slug,
|
|
470
|
+
(t.depends_on ?? []).filter((d) => !isExternalDependencyRef(d)),
|
|
471
|
+
);
|
|
465
472
|
if (t.type === 'story') stories.push(t);
|
|
466
473
|
}
|
|
467
474
|
return { ticketBySlug, stories, slugAdjacency };
|
|
@@ -568,6 +575,9 @@ function assertNoUnknownDeps({ tickets, ticketBySlug }) {
|
|
|
568
575
|
const unknownDeps = [];
|
|
569
576
|
for (const t of tickets) {
|
|
570
577
|
for (const depSlug of t.depends_on ?? []) {
|
|
578
|
+
// An external `#<id>` ref is resolved against the tracker at persist
|
|
579
|
+
// (`assertExternalDependenciesResolvable`), never against this backlog.
|
|
580
|
+
if (isExternalDependencyRef(depSlug)) continue;
|
|
571
581
|
if (!ticketBySlug.has(depSlug)) {
|
|
572
582
|
unknownDeps.push({ slug: t.slug, title: t.title, dep: depSlug });
|
|
573
583
|
}
|