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.
Files changed (59) hide show
  1. package/.agents/README.md +2 -2
  2. package/.agents/instructions.md +5 -6
  3. package/.agents/rules/api-conventions.md +43 -7
  4. package/.agents/rules/ci-remediation.md +3 -14
  5. package/.agents/rules/gherkin-standards.md +21 -6
  6. package/.agents/rules/git-conventions.md +6 -5
  7. package/.agents/rules/security-baseline.md +6 -7
  8. package/.agents/rules/testing-standards.md +75 -198
  9. package/.agents/scripts/install-matrix-assert.js +2 -2
  10. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
  11. package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
  12. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  13. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  14. package/.agents/scripts/lib/orchestration/epic-container.js +18 -2
  15. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  16. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  17. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  18. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +5 -1
  19. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  20. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +14 -2
  21. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  22. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  23. package/.agents/scripts/plan-persist.js +60 -1
  24. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  25. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  26. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  27. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  28. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  29. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  30. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  31. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  32. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  33. package/.agents/skills/skills.index.json +10 -70
  34. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  35. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  36. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  37. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  38. package/.agents/workflows/git-cleanup.md +3 -2
  39. package/.agents/workflows/git-deliver.md +3 -2
  40. package/.agents/workflows/helpers/plan-reference.md +82 -2
  41. package/.agents/workflows/mandrel-plan.md +45 -45
  42. package/.agents/workflows/qa-assist.md +20 -17
  43. package/.agents/workflows/qa-explore.md +30 -29
  44. package/.agents/workflows/qa-run.md +2 -1
  45. package/docs/CHANGELOG.md +14 -0
  46. package/package.json +1 -1
  47. package/.agents/rules/changelog-style.md +0 -180
  48. package/.agents/rules/shell-conventions.md +0 -61
  49. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  50. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  51. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  52. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  53. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  54. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  55. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  56. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  57. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  58. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  59. 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 { createContainerEpic } from './epic-ops.js';
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 createContainerEpic({
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((slug) => !known.has(slug));
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.every((slug) => scheduled.has(slug)),
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) => `#${idBySlug.get(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: Object.fromEntries(idBySlug),
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
- slugAdjacency.set(t.slug, t.depends_on ?? []);
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
  }