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,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(
|
|
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
|
|
853
|
-
*
|
|
854
|
-
*
|
|
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
|
|
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 [
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
+
}
|