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,139 @@
1
+ /**
2
+ * dependency-candidates.js — open Stories a newly planned Story may need to
3
+ * wait for.
4
+ *
5
+ * Story #5155. `depends_on[]` has always ordered *siblings within one plan*.
6
+ * Nothing surfaced the other ordering that actually bites: a Story authored
7
+ * today that edits a file an already-open Story from an earlier plan is going
8
+ * to rewrite. Delivered concurrently, the second lands on a base the first
9
+ * just changed — and the planner had no way to see it coming, because the
10
+ * duplicate search asks "is this the same Story?" (title/body similarity),
11
+ * never "does this Story touch what I am about to touch?".
12
+ *
13
+ * Overlap here is therefore computed on **declared footprints**, not prose:
14
+ * the seed's `predictedPaths` against each open Story's parsed `changes[]`,
15
+ * via the same `storyFootprint` the wave runner uses to withhold colliding
16
+ * Stories at dispatch. That is deliberate — the planner sees the collision the
17
+ * runtime would later enforce, one layer earlier and while it is still cheap
18
+ * to order around.
19
+ *
20
+ * The result is **advisory**: an overlap is a prompt to consider an edge, not
21
+ * proof one is needed. Two Stories can touch a shared barrel file with no real
22
+ * ordering between them; only the operator knows.
23
+ *
24
+ * @module lib/orchestration/dependency-candidates
25
+ * @see Story #5155
26
+ */
27
+
28
+ import { Logger } from '../Logger.js';
29
+ import { TYPE_LABELS } from '../label-constants.js';
30
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
31
+ import { storyFootprint } from '../wave-runner/footprint.js';
32
+
33
+ /**
34
+ * Build an issue URL for a Story the provider returned without one.
35
+ *
36
+ * @param {number} id
37
+ * @param {{ owner?: string, repo?: string }} [opts]
38
+ * @returns {string}
39
+ */
40
+ function buildStoryUrl(id, { owner, repo } = {}) {
41
+ if (owner && repo) return `https://github.com/${owner}/${repo}/issues/${id}`;
42
+ return `#${id}`;
43
+ }
44
+
45
+ /**
46
+ * Read one open Story's declared footprint.
47
+ *
48
+ * Total by construction: an unparseable body yields an empty footprint, which
49
+ * intersects with nothing and drops the Story from the candidate list. A
50
+ * hand-written Story with no `## Changes` section is exactly that case, and it
51
+ * is the right outcome — there is no declared footprint to collide with.
52
+ *
53
+ * @param {object} issue
54
+ * @returns {Set<string>}
55
+ */
56
+ function footprintOf(issue) {
57
+ const body = typeof issue?.body === 'string' ? issue.body : '';
58
+ if (body === '') return new Set();
59
+ try {
60
+ return storyFootprint(parseStoryBody(body).body);
61
+ } catch {
62
+ return new Set();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Find open Stories whose declared footprint intersects the seed's predicted
68
+ * paths.
69
+ *
70
+ * Returns `[]` **without contacting the provider** when the seed named no
71
+ * paths: with nothing to intersect, every candidate would score empty, and the
72
+ * round-trip would buy nothing. That short-circuit is load-bearing for the
73
+ * common one-line seed, which mentions no file at all.
74
+ *
75
+ * @param {{
76
+ * predictedPaths: string[],
77
+ * provider: object,
78
+ * owner?: string,
79
+ * repo?: string,
80
+ * excludeIds?: Iterable<number|string>,
81
+ * }} args
82
+ * @returns {Promise<Array<{ id: number, title: string, url: string, state: string, overlappingPaths: string[] }>>}
83
+ */
84
+ export async function findDependencyCandidates({
85
+ predictedPaths,
86
+ provider,
87
+ owner,
88
+ repo,
89
+ excludeIds = [],
90
+ }) {
91
+ const wanted = (Array.isArray(predictedPaths) ? predictedPaths : []).filter(
92
+ (p) => typeof p === 'string' && p.trim() !== '',
93
+ );
94
+ if (wanted.length === 0) return [];
95
+ if (typeof provider?.listIssuesByLabel !== 'function') return [];
96
+
97
+ const excluded = new Set(
98
+ [...excludeIds].map((id) => Number(id)).filter((n) => Number.isFinite(n)),
99
+ );
100
+
101
+ let issues;
102
+ try {
103
+ issues = await provider.listIssuesByLabel({
104
+ state: 'open',
105
+ labels: TYPE_LABELS.STORY,
106
+ });
107
+ } catch (err) {
108
+ Logger.warn(
109
+ `[dependency-candidates] open-Story listing degraded to no candidates: ${err?.message ?? err}`,
110
+ );
111
+ return [];
112
+ }
113
+
114
+ const out = [];
115
+ for (const issue of Array.isArray(issues) ? issues : []) {
116
+ const id = Number(issue?.number ?? issue?.id);
117
+ if (!Number.isInteger(id) || id <= 0 || excluded.has(id)) continue;
118
+
119
+ const footprint = footprintOf(issue);
120
+ if (footprint.size === 0) continue;
121
+
122
+ const overlappingPaths = wanted.filter((p) => footprint.has(p));
123
+ if (overlappingPaths.length === 0) continue;
124
+
125
+ out.push({
126
+ id,
127
+ title: typeof issue?.title === 'string' ? issue.title : '',
128
+ url: issue?.html_url ?? issue?.url ?? buildStoryUrl(id, { owner, repo }),
129
+ state: typeof issue?.state === 'string' ? issue.state : 'open',
130
+ overlappingPaths,
131
+ });
132
+ }
133
+
134
+ // Most-entangled first, then ascending id for a stable render.
135
+ return out.sort(
136
+ (a, b) =>
137
+ b.overlappingPaths.length - a.overlappingPaths.length || a.id - b.id,
138
+ );
139
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * epic-candidates.js — rank the open container Epics a new plan could join.
3
+ *
4
+ * Story #5155. `plan-persist` has always been able to *create* a container
5
+ * Epic, and to re-adopt one whose fingerprint matches the exact cohort it is
6
+ * re-persisting. Neither helps the case this module exists for: a second plan,
7
+ * days later, adding work that belongs under the Epic a first plan opened. The
8
+ * fingerprint is keyed on the child set, so a different cohort never matches
9
+ * it — by design, since adopting the wrong container silently mis-files a run.
10
+ *
11
+ * So the join has to be a **decision**, not a hash collision: this module
12
+ * surfaces every open Epic with an overlap score, and the operator picks at
13
+ * Gate #3. The list is deliberately **complete rather than thresholded** — a
14
+ * low score is evidence for the operator to weigh, and hiding a candidate is
15
+ * how a plan silently opens its second container for one body of work.
16
+ *
17
+ * Scoring reuses `duplicate-search.js`'s tokenizer and Jaccard overlap rather
18
+ * than growing a second similarity notion in the codebase; like that module's,
19
+ * it is a triage signal and not a semantic search.
20
+ *
21
+ * @module lib/orchestration/epic-candidates
22
+ * @see Story #5155
23
+ */
24
+
25
+ import { overlapScore, tokenize } from '../duplicate-search.js';
26
+ import { Logger } from '../Logger.js';
27
+ import { TYPE_LABELS } from '../label-constants.js';
28
+ import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
29
+ import { isEpicTicket, readEpicChildIds } from './epic-container.js';
30
+
31
+ /**
32
+ * Build an issue URL for an Epic the provider returned without one.
33
+ *
34
+ * @param {number} id
35
+ * @param {{ owner?: string, repo?: string }} [opts]
36
+ * @returns {string}
37
+ */
38
+ function buildEpicUrl(id, { owner, repo } = {}) {
39
+ if (owner && repo) return `https://github.com/${owner}/${repo}/issues/${id}`;
40
+ return `#${id}`;
41
+ }
42
+
43
+ /**
44
+ * Fetch the titles of an Epic's children, for scoring only.
45
+ *
46
+ * Child titles matter because a container's own title and goal are short and
47
+ * abstract ("Auth hardening", one paragraph), while the seed that should match
48
+ * it is concrete. The children are where the shared vocabulary actually lives.
49
+ *
50
+ * Entirely best-effort: no `getTicket`, an unreadable child, or a throw all
51
+ * degrade to fewer title tokens, never to a failed envelope. A candidate that
52
+ * scores low because its children could not be read is still *listed* — the
53
+ * operator sees every open Epic regardless.
54
+ *
55
+ * @param {{ childIds: number[], provider: object }} opts
56
+ * @returns {Promise<string>} Space-joined child titles ('' when none resolved).
57
+ */
58
+ async function readChildTitles({ childIds, provider }) {
59
+ if (childIds.length === 0 || typeof provider?.getTicket !== 'function') {
60
+ return '';
61
+ }
62
+ const titles = await concurrentMap(
63
+ childIds,
64
+ async (id) => {
65
+ try {
66
+ const child = await provider.getTicket(id);
67
+ return typeof child?.title === 'string' ? child.title : '';
68
+ } catch {
69
+ return '';
70
+ }
71
+ },
72
+ { concurrency: FANOUT_CONCURRENCY },
73
+ );
74
+ return titles.filter((t) => t !== '').join(' ');
75
+ }
76
+
77
+ /**
78
+ * Score one open Epic against the seed.
79
+ *
80
+ * @param {{ epic: object, seedTokens: Set<string>, provider: object, owner?: string, repo?: string }} opts
81
+ * @returns {Promise<{ id: number, title: string, url: string, score: number, childIds: number[] }|null>}
82
+ */
83
+ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
84
+ const id = Number(epic?.number ?? epic?.id);
85
+ if (!Number.isInteger(id) || id <= 0) return null;
86
+
87
+ const title = typeof epic?.title === 'string' ? epic.title : '';
88
+ const body = typeof epic?.body === 'string' ? epic.body : '';
89
+ const childIds = readEpicChildIds(body);
90
+ const childTitles = await readChildTitles({ childIds, provider });
91
+
92
+ // The fingerprint marker and checklist ids are machine noise; the tokenizer
93
+ // drops short and non-alphabetic tokens, so the corpus is effectively the
94
+ // title, the `## Goal` prose and the child titles.
95
+ const corpus = `${title}\n${body}\n${childTitles}`;
96
+ const score = overlapScore(seedTokens, tokenize(corpus));
97
+
98
+ return {
99
+ id,
100
+ title,
101
+ url: epic?.html_url ?? epic?.url ?? buildEpicUrl(id, { owner, repo }),
102
+ score: Number(score.toFixed(4)),
103
+ childIds,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Find every open container Epic, ranked by overlap with the seed.
109
+ *
110
+ * **Only open Epics are candidates.** A closed Epic is a completed body of
111
+ * work; joining one would reopen a container the epilogue deliberately closed,
112
+ * and silently re-scope a finished plan. The operator files a new Epic or
113
+ * reopens the old one by hand.
114
+ *
115
+ * Failures degrade to `[]` — like the duplicate search, this is a triage
116
+ * signal offered at a gate, and no plan should fail to be authored because
117
+ * the Epic listing was unavailable.
118
+ *
119
+ * @param {{
120
+ * seed: string,
121
+ * provider: object,
122
+ * owner?: string,
123
+ * repo?: string,
124
+ * }} args
125
+ * @returns {Promise<Array<{ id: number, title: string, url: string, score: number, childIds: number[] }>>}
126
+ */
127
+ export async function findOpenEpicCandidates({ seed, provider, owner, repo }) {
128
+ if (typeof seed !== 'string' || seed.trim() === '') return [];
129
+ if (typeof provider?.listIssuesByLabel !== 'function') return [];
130
+
131
+ const seedTokens = tokenize(seed);
132
+ if (seedTokens.size === 0) return [];
133
+
134
+ let issues;
135
+ try {
136
+ issues = await provider.listIssuesByLabel({
137
+ state: 'open',
138
+ labels: TYPE_LABELS.EPIC,
139
+ });
140
+ } catch (err) {
141
+ Logger.warn(
142
+ `[epic-candidates] open-Epic listing degraded to no candidates: ${err?.message ?? err}`,
143
+ );
144
+ return [];
145
+ }
146
+
147
+ const epics = (Array.isArray(issues) ? issues : []).filter(isEpicTicket);
148
+ const scored = await concurrentMap(
149
+ epics,
150
+ (epic) => scoreEpic({ epic, seedTokens, provider, owner, repo }),
151
+ { concurrency: FANOUT_CONCURRENCY },
152
+ );
153
+
154
+ // Descending score, then ascending id: a stable order for two Epics that
155
+ // tie, so the same backlog always renders the same list.
156
+ return scored
157
+ .filter((c) => c !== null)
158
+ .sort((a, b) => b.score - a.score || a.id - b.id);
159
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * epic-checklist.js — edit a live Epic body's child checklist in place.
3
+ *
4
+ * Story #5155. `epic-container.js` describes the *shape* of a container and
5
+ * renders one from scratch; this module is the other operation adoption needs
6
+ * — amending a body that already exists, written by someone else, possibly
7
+ * hand-edited since.
8
+ *
9
+ * The two are deliberately separate. Composing may assume everything about the
10
+ * text because it produced all of it; amending may assume almost nothing and
11
+ * must treat every line it did not come to change as untouchable: the `## Goal`
12
+ * prose, the fingerprint marker, the item order, and above all the **checked
13
+ * state** of existing rows. An operator who ticked `- [x] #41` is recording
14
+ * that the Story landed, and a re-render would silently discard it.
15
+ *
16
+ * So the edit is a surgical line insertion rather than a re-render.
17
+ *
18
+ * @module lib/orchestration/epic-checklist
19
+ * @see Story #5155
20
+ */
21
+
22
+ import {
23
+ CHECKLIST_ITEM_LINE_RE,
24
+ CHILDREN_HEADING,
25
+ NO_CHILDREN_PLACEHOLDER,
26
+ normalizeChildIds,
27
+ readEpicChildIds,
28
+ } from './epic-container.js';
29
+
30
+ /**
31
+ * Index of the last `- [ ] #N` row in a body's lines, or -1.
32
+ *
33
+ * @param {string[]} lines
34
+ * @returns {number}
35
+ */
36
+ function findLastChecklistIndex(lines) {
37
+ for (let i = lines.length - 1; i >= 0; i--) {
38
+ if (CHECKLIST_ITEM_LINE_RE.test(lines[i])) return i;
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ /**
44
+ * Where the new rows go, as a `[index, deleteCount]` splice target.
45
+ *
46
+ * Three placements, in priority order, each preserving a different thing:
47
+ * replacing the empty-container placeholder (which would otherwise stand above
48
+ * rows that contradict it), appending after the last existing row (which keeps
49
+ * original-then-appended order on read-back), or opening the section for a body
50
+ * that never had one.
51
+ *
52
+ * @param {string[]} lines
53
+ * @returns {[number, number]|null} `null` when there is no section to extend.
54
+ */
55
+ function locateInsertion(lines) {
56
+ const placeholderAt = lines.findIndex(
57
+ (line) => line.trim() === NO_CHILDREN_PLACEHOLDER,
58
+ );
59
+ if (placeholderAt !== -1) return [placeholderAt, 1];
60
+
61
+ const lastItemAt = findLastChecklistIndex(lines);
62
+ if (lastItemAt !== -1) return [lastItemAt + 1, 0];
63
+
64
+ const headingAt = lines.findIndex((line) => line.trim() === CHILDREN_HEADING);
65
+ if (headingAt === -1) return null;
66
+ // Keep the blank line a composed body puts under the heading.
67
+ const blank = lines[headingAt + 1]?.trim() === '' ? 1 : 0;
68
+ return [headingAt + 1 + blank, 0];
69
+ }
70
+
71
+ /**
72
+ * Append child ids to an existing Epic body's checklist, idempotently.
73
+ *
74
+ * Idempotence is the load-bearing property: a resumed or re-run persist calls
75
+ * this again with the same cohort, and a second copy of every row would make
76
+ * the container claim children it does not have.
77
+ *
78
+ * @param {string} body The Epic's current body.
79
+ * @param {number[]} childIds Ids to add.
80
+ * @returns {string} The updated body (byte-identical when nothing was added).
81
+ */
82
+ export function appendEpicChildIds(body, childIds) {
83
+ const text = typeof body === 'string' ? body : '';
84
+ const existing = new Set(readEpicChildIds(text));
85
+ const additions = normalizeChildIds(childIds).filter(
86
+ (id) => !existing.has(id),
87
+ );
88
+ if (additions.length === 0) return text;
89
+
90
+ const rows = additions.map((id) => `- [ ] #${id}`);
91
+ const lines = text.split('\n');
92
+ const target = locateInsertion(lines);
93
+
94
+ if (target === null) {
95
+ // A hand-written or foreign Epic with no checklist section. Add one rather
96
+ // than refusing: `isEpicTicket` already treats such a ticket as a real
97
+ // Epic, so the adoption must not be the one place that disagrees.
98
+ return `${text.replace(/\n+$/, '')}\n\n${CHILDREN_HEADING}\n\n${rows.join('\n')}\n`;
99
+ }
100
+
101
+ lines.splice(target[0], target[1], ...rows);
102
+ return lines.join('\n');
103
+ }
@@ -37,11 +37,27 @@ import { TYPE_LABELS } from '../label-constants.js';
37
37
  */
38
38
  const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+#(\d+)\s*$/gm;
39
39
 
40
+ /**
41
+ * The same grammar, unanchored to a global cursor — for callers testing one
42
+ * line at a time. Kept beside its `/g` twin so the two cannot drift.
43
+ */
44
+ export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s+#\d+\s*$/;
45
+
40
46
  /** Heading the container's one prose section renders under. */
41
47
  const GOAL_HEADING = '## Goal';
42
48
 
43
49
  /** Heading the child checklist renders under. */
44
- const CHILDREN_HEADING = '## Stories';
50
+ export const CHILDREN_HEADING = '## Stories';
51
+
52
+ /**
53
+ * Rendered in place of the checklist when a container has no children yet.
54
+ *
55
+ * Exported because {@link appendEpicChildIds} must *remove* it when the first
56
+ * child arrives: a container that lists a Story and still claims to be empty
57
+ * is a body that contradicts itself, and the two writers have to agree on the
58
+ * exact string to keep that from happening.
59
+ */
60
+ export const NO_CHILDREN_PLACEHOLDER = '_No child Stories linked._';
45
61
 
46
62
  /**
47
63
  * Normalize an issue's labels to plain strings. GitHub hands labels back
@@ -95,7 +111,7 @@ export function composeEpicBody({ goal, childIds = [] } = {}) {
95
111
  const ids = normalizeChildIds(childIds);
96
112
  const lines = [GOAL_HEADING, '', text, '', CHILDREN_HEADING, ''];
97
113
  if (ids.length === 0) {
98
- lines.push('_No child Stories linked._');
114
+ lines.push(NO_CHILDREN_PLACEHOLDER);
99
115
  } else {
100
116
  for (const id of ids) lines.push(`- [ ] #${id}`);
101
117
  }
@@ -34,7 +34,9 @@ import {
34
34
  import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
35
35
  import { buildComplexitySignals } from './complexity-gate.js';
36
36
  import { parseDeliverySlicingTable } from './consolidation-precondition.js';
37
+ import { findDependencyCandidates } from './dependency-candidates.js';
37
38
  import { buildDocsDigest } from './docs-digest.js';
39
+ import { findOpenEpicCandidates } from './epic-candidates.js';
38
40
  import { buildAuthoringContext } from './planning/authoring-context.js';
39
41
  import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
40
42
 
@@ -849,11 +851,12 @@ async function searchStoryDuplicates({
849
851
  }
850
852
 
851
853
  /**
852
- * Gather the three independent envelope inputs — the open-Story duplicate
853
- * search, the folded authoring context, and the inline docs digest under
854
- * bounded concurrency (Story #4952).
854
+ * Gather the independent envelope inputs — the open-Story duplicate search,
855
+ * the folded authoring context, the inline docs digest, and (Story #5155) the
856
+ * open-Epic and cross-plan-dependency candidate lists — under bounded
857
+ * concurrency (Story #4952).
855
858
  *
856
- * None of the three reads a value the others produce, so the result is a pure
859
+ * None of them reads a value the others produce, so the result is a pure
857
860
  * function of `seed` and the injected config: the assembled envelope is
858
861
  * **byte-identical** to the serial build for the same inputs, whichever order
859
862
  * the three happen to settle in. `concurrentMap` preserves input order, so the
@@ -875,6 +878,8 @@ async function searchStoryDuplicates({
875
878
  * @returns {Promise<{
876
879
  * duplicates: Array<object>,
877
880
  * authoring: object,
881
+ * epicCandidates: Array<object>,
882
+ * dependencyCandidates: Array<object>,
878
883
  * docsContext: { mode: 'digest-inline', digest: string }|null,
879
884
  * }>}
880
885
  */
@@ -882,13 +887,20 @@ async function gatherEnvelopeInputs({
882
887
  seed,
883
888
  epicTitle,
884
889
  excludeIds = [],
890
+ predictedPaths = [],
885
891
  provider,
886
892
  config,
887
893
  settings,
888
894
  cwd,
889
895
  }) {
890
896
  const paths = settings?.paths ?? {};
891
- const [duplicates, authoring, inlineDigest] = await concurrentMap(
897
+ const [
898
+ duplicates,
899
+ authoring,
900
+ inlineDigest,
901
+ epicCandidates,
902
+ dependencyCandidates,
903
+ ] = await concurrentMap(
892
904
  [
893
905
  () => searchStoryDuplicates({ seed, provider, config, excludeIds }),
894
906
  () =>
@@ -907,6 +919,25 @@ async function gatherEnvelopeInputs({
907
919
  docsContextFiles: settings?.docsContextFiles,
908
920
  docsRoot: paths.docsRoot,
909
921
  }),
922
+ // Story #5155 — the two cross-plan lookups. Both are advisory triage
923
+ // lists offered at Gate #3, independent of every other gather and of
924
+ // each other, so they join the same bounded fan-out rather than adding
925
+ // two more serial round-trips to the operator's wait.
926
+ () =>
927
+ findOpenEpicCandidates({
928
+ seed,
929
+ provider,
930
+ owner: config.github?.owner,
931
+ repo: config.github?.repo,
932
+ }),
933
+ () =>
934
+ findDependencyCandidates({
935
+ predictedPaths,
936
+ provider,
937
+ owner: config.github?.owner,
938
+ repo: config.github?.repo,
939
+ excludeIds,
940
+ }),
910
941
  ],
911
942
  (gather) => gather(),
912
943
  // The per-mode envelope gathers (Story #4952): the duplicate search, the
@@ -919,6 +950,8 @@ async function gatherEnvelopeInputs({
919
950
  return {
920
951
  duplicates,
921
952
  authoring,
953
+ epicCandidates,
954
+ dependencyCandidates,
922
955
  docsContext:
923
956
  inlineDigest == null
924
957
  ? null
@@ -949,20 +982,42 @@ async function buildSeedFileModeEnvelope({
949
982
  );
950
983
  }
951
984
 
952
- // Dup search, the authoring-context fold grounded in the seed prose, and the
953
- // inline docs digest are independent — gathered concurrently (Story #4952).
954
- const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
985
+ const limits = getLimits(config);
986
+ const heuristics = resolveRiskHeuristics(config);
987
+
988
+ // Hoisted above the gather (Story #5155): the dependency-candidate lookup
989
+ // intersects against `predictedPaths`, so the signals have to exist before
990
+ // the fan-out starts. `buildComplexitySignals` is synchronous and reads
991
+ // nothing the gather produces, so hoisting it changes cost, not output.
992
+ const complexitySignals = withAdvisorySignals(
993
+ buildComplexitySignals({
994
+ seedText: content,
995
+ config,
996
+ riskHeuristics: heuristics,
997
+ cwd,
998
+ }),
999
+ { config, cwd },
1000
+ );
1001
+
1002
+ // Dup search, the authoring-context fold grounded in the seed prose, the
1003
+ // inline docs digest and the two cross-plan candidate lists are independent
1004
+ // — gathered concurrently (Story #4952, Story #5155).
1005
+ const {
1006
+ duplicates,
1007
+ authoring,
1008
+ docsContext,
1009
+ epicCandidates,
1010
+ dependencyCandidates,
1011
+ } = await gatherEnvelopeInputs({
955
1012
  seed: content,
956
1013
  epicTitle: seedFilePath ?? 'seed',
1014
+ predictedPaths: complexitySignals.predictedPaths,
957
1015
  provider,
958
1016
  config,
959
1017
  settings,
960
1018
  cwd,
961
1019
  });
962
1020
 
963
- const limits = getLimits(config);
964
- const heuristics = resolveRiskHeuristics(config);
965
-
966
1021
  return {
967
1022
  mode: modeLabel,
968
1023
  seed: { path: seedFilePath ?? null, content },
@@ -972,16 +1027,10 @@ async function buildSeedFileModeEnvelope({
972
1027
  // `deliverLightSuggestion` is the advisory plan-side routing handshake
973
1028
  // (Story #4741 AC-6) and `uiSurface` the advisory /prototype offer —
974
1029
  // neither is ever an automatic reroute.
975
- complexitySignals: withAdvisorySignals(
976
- buildComplexitySignals({
977
- seedText: content,
978
- config,
979
- riskHeuristics: heuristics,
980
- cwd,
981
- }),
982
- { config, cwd },
983
- ),
1030
+ complexitySignals,
984
1031
  duplicates,
1032
+ epicCandidates,
1033
+ dependencyCandidates,
985
1034
  docsContext,
986
1035
  bddRunner: authoring.bddRunner,
987
1036
  bddScenarios: authoring.bddScenarios,
@@ -1095,36 +1144,48 @@ async function buildTicketsModeEnvelope({
1095
1144
  .map((t) => `# ${t.title}\n\n${t.body}`)
1096
1145
  .join('\n\n---\n\n');
1097
1146
 
1098
- // Same three independent gathers as seed-file mode, concurrent under the
1099
- // same bound (Story #4952); only the source-ticket hydration above is a
1100
- // genuine data dependency, because `seed` is derived from it.
1101
- const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
1147
+ const limits = getLimits(config);
1148
+ const heuristics = resolveRiskHeuristics(config);
1149
+
1150
+ // Hoisted for the same reason as seed-file mode (Story #5155).
1151
+ const complexitySignals = withAdvisorySignals(
1152
+ buildComplexitySignals({
1153
+ seedText: seed,
1154
+ config,
1155
+ riskHeuristics: heuristics,
1156
+ cwd,
1157
+ }),
1158
+ { config, cwd },
1159
+ );
1160
+
1161
+ // Same independent gathers as seed-file mode, concurrent under the same
1162
+ // bound (Story #4952); only the source-ticket hydration above is a genuine
1163
+ // data dependency, because `seed` is derived from it.
1164
+ const {
1165
+ duplicates,
1166
+ authoring,
1167
+ docsContext,
1168
+ epicCandidates,
1169
+ dependencyCandidates,
1170
+ } = await gatherEnvelopeInputs({
1102
1171
  seed,
1103
1172
  epicTitle: sourceTickets[0]?.title ?? 'tickets',
1104
1173
  excludeIds: ticketIds,
1174
+ predictedPaths: complexitySignals.predictedPaths,
1105
1175
  provider,
1106
1176
  config,
1107
1177
  settings,
1108
1178
  cwd,
1109
1179
  });
1110
1180
 
1111
- const limits = getLimits(config);
1112
- const heuristics = resolveRiskHeuristics(config);
1113
-
1114
1181
  return {
1115
1182
  mode: 'tickets',
1116
1183
  sourceTickets,
1117
1184
  seed: { text: seed, path: null },
1118
- complexitySignals: withAdvisorySignals(
1119
- buildComplexitySignals({
1120
- seedText: seed,
1121
- config,
1122
- riskHeuristics: heuristics,
1123
- cwd,
1124
- }),
1125
- { config, cwd },
1126
- ),
1185
+ complexitySignals,
1127
1186
  duplicates,
1187
+ epicCandidates,
1188
+ dependencyCandidates,
1128
1189
  docsContext,
1129
1190
  bddRunner: authoring.bddRunner,
1130
1191
  bddScenarios: authoring.bddScenarios,