mandrel 2.39.0 → 2.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +8 -5
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/instructions.md +22 -22
- package/.agents/rules/api-conventions.md +43 -7
- package/.agents/rules/ci-remediation.md +3 -14
- package/.agents/rules/gherkin-standards.md +21 -6
- package/.agents/rules/git-conventions.md +6 -5
- package/.agents/rules/security-baseline.md +6 -7
- package/.agents/rules/testing-standards.md +75 -198
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/install-matrix-assert.js +2 -2
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +45 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- 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 +202 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- 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 +324 -0
- package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +30 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
- package/.agents/scripts/plan-persist.js +98 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/resolve-stories.js +42 -2
- 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/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/git-cleanup.md +3 -2
- package/.agents/workflows/git-deliver.md +3 -2
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +109 -1
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +61 -55
- 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 +30 -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,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
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-container.js — the one module describing a container Epic.
|
|
3
|
+
*
|
|
4
|
+
* An Epic here is a **pure container**: a `type::epic` issue whose body is a
|
|
5
|
+
* short `## Goal` paragraph and a `- [ ] #N` child checklist, and nothing
|
|
6
|
+
* else. It carries no `## Spec`, no `acceptance[]` / `verify[]`, and no
|
|
7
|
+
* `agent::*` label. It is never branched, never implemented and never
|
|
8
|
+
* delivered — `/mandrel-deliver <epicId>` expands it to its open children and
|
|
9
|
+
* delivers those.
|
|
10
|
+
*
|
|
11
|
+
* **Linkage is parent→child only.** The Epic holds every edge; Story bodies
|
|
12
|
+
* are never touched. That is the whole reason this can exist without
|
|
13
|
+
* reversing ADR `20260726-v2-story-collapse`: the `Epic: #N` footer stays
|
|
14
|
+
* retired and every refusal that reads it still fires, so each Story remains
|
|
15
|
+
* independently deliverable and the delivery engine stays Story-only.
|
|
16
|
+
*
|
|
17
|
+
* Both consumers — `plan-persist` (which writes an Epic) and
|
|
18
|
+
* `resolve-stories` (which expands one) — import from here so the written
|
|
19
|
+
* shape and the read shape cannot drift apart.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/orchestration/epic-container
|
|
22
|
+
* @see Story #5139
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { TYPE_LABELS } from '../label-constants.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The checklist grammar. `getSubTickets` (`providers/github/issues.js`)
|
|
29
|
+
* already parses this exact form as its strategy-2 child source, so the
|
|
30
|
+
* checklist is a durable mirror of the native sub-issue edges rather than a
|
|
31
|
+
* second, competing representation: when the sub-issues API is unavailable
|
|
32
|
+
* — an older GHES, a revoked scope, a partial write — the children are still
|
|
33
|
+
* discoverable from the body alone.
|
|
34
|
+
*
|
|
35
|
+
* Kept in sync with `_getChecklistChildren` deliberately; a divergence here
|
|
36
|
+
* would strand children the writer believes it linked.
|
|
37
|
+
*/
|
|
38
|
+
const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+#(\d+)\s*$/gm;
|
|
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
|
+
|
|
46
|
+
/** Heading the container's one prose section renders under. */
|
|
47
|
+
const GOAL_HEADING = '## Goal';
|
|
48
|
+
|
|
49
|
+
/** Heading the child checklist renders under. */
|
|
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._';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalize an issue's labels to plain strings. GitHub hands labels back
|
|
64
|
+
* either as objects (`{ name }`) or, once mapped, as bare strings; callers
|
|
65
|
+
* should not have to care which shape they hold.
|
|
66
|
+
*
|
|
67
|
+
* @param {unknown} raw
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
function normalizeLabels(raw) {
|
|
71
|
+
if (!Array.isArray(raw)) return [];
|
|
72
|
+
return raw
|
|
73
|
+
.map((l) => (typeof l === 'string' ? l : l?.name))
|
|
74
|
+
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this issue a container Epic?
|
|
79
|
+
*
|
|
80
|
+
* Reads the `type::epic` label and nothing else — the label is the
|
|
81
|
+
* authoritative marker. Body shape is deliberately NOT part of the test: a
|
|
82
|
+
* hand-edited Epic whose checklist an operator reordered or annotated is
|
|
83
|
+
* still an Epic, and treating it otherwise would silently reclassify it as
|
|
84
|
+
* an ordinary non-Story and hard-error the delivery.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ labels?: unknown }} issue
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isEpicTicket(issue) {
|
|
90
|
+
return normalizeLabels(issue?.labels).includes(TYPE_LABELS.EPIC);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render a container Epic's body.
|
|
95
|
+
*
|
|
96
|
+
* The output is intentionally minimal — a goal paragraph and a checklist.
|
|
97
|
+
* The Epic must carry **no information a child does not already carry**: it
|
|
98
|
+
* is a container, so anything unique living here would be a fact with no
|
|
99
|
+
* home in the tickets that actually get executed, invisible to every agent
|
|
100
|
+
* delivering them.
|
|
101
|
+
*
|
|
102
|
+
* @param {{ goal: string, childIds?: number[] }} opts
|
|
103
|
+
* @returns {string} Canonical Epic body markdown.
|
|
104
|
+
*/
|
|
105
|
+
export function composeEpicBody({ goal, childIds = [] } = {}) {
|
|
106
|
+
const text = typeof goal === 'string' ? goal.trim() : '';
|
|
107
|
+
if (text === '') {
|
|
108
|
+
throw new Error('[epic-container] composeEpicBody requires a goal.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ids = normalizeChildIds(childIds);
|
|
112
|
+
const lines = [GOAL_HEADING, '', text, '', CHILDREN_HEADING, ''];
|
|
113
|
+
if (ids.length === 0) {
|
|
114
|
+
lines.push(NO_CHILDREN_PLACEHOLDER);
|
|
115
|
+
} else {
|
|
116
|
+
for (const id of ids) lines.push(`- [ ] #${id}`);
|
|
117
|
+
}
|
|
118
|
+
return `${lines.join('\n')}\n`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Coerce a child-id list to positive integers, deduped, order-preserving.
|
|
123
|
+
*
|
|
124
|
+
* @param {unknown} raw
|
|
125
|
+
* @returns {number[]}
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeChildIds(raw) {
|
|
128
|
+
if (!Array.isArray(raw)) return [];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const entry of raw) {
|
|
132
|
+
const id = Number(entry);
|
|
133
|
+
if (!Number.isInteger(id) || id <= 0) continue;
|
|
134
|
+
if (seen.has(id)) continue;
|
|
135
|
+
seen.add(id);
|
|
136
|
+
out.push(id);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read the child issue numbers an Epic body declares.
|
|
143
|
+
*
|
|
144
|
+
* Body-only, by design: this is the fallback that works with nothing but the
|
|
145
|
+
* issue text. Callers that can reach the API should union this with the
|
|
146
|
+
* native sub-issue edges (`readEpicChildIdsFrom`), because an operator can
|
|
147
|
+
* link a child in the GitHub UI without touching the checklist.
|
|
148
|
+
*
|
|
149
|
+
* @param {string|null|undefined} body
|
|
150
|
+
* @returns {number[]}
|
|
151
|
+
*/
|
|
152
|
+
export function readEpicChildIds(body) {
|
|
153
|
+
if (typeof body !== 'string' || body === '') return [];
|
|
154
|
+
// `matchAll` on a /g regex starts from lastIndex; the literal is
|
|
155
|
+
// module-scoped, so reset it rather than leaking state across calls.
|
|
156
|
+
CHECKLIST_ITEM_RE.lastIndex = 0;
|
|
157
|
+
return normalizeChildIds(
|
|
158
|
+
[...body.matchAll(CHECKLIST_ITEM_RE)].map((m) => Number.parseInt(m[1], 10)),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve an Epic's children from **both** sources — the body checklist and
|
|
164
|
+
* the native sub-issue edges — as one deduped list.
|
|
165
|
+
*
|
|
166
|
+
* The two are unioned rather than ranked because each can hold a child the
|
|
167
|
+
* other misses: the API is authoritative for links made in the GitHub UI,
|
|
168
|
+
* and the checklist survives an API that is unavailable or was never
|
|
169
|
+
* written. A child present in either is a child.
|
|
170
|
+
*
|
|
171
|
+
* `readNativeChildIds` is injected and may be absent or throw; a failure
|
|
172
|
+
* degrades to the checklist rather than propagating, since a body-derived
|
|
173
|
+
* child list is a strictly better answer than an error.
|
|
174
|
+
*
|
|
175
|
+
* @param {{
|
|
176
|
+
* epic: { number?: number, id?: number, body?: string, nodeId?: string },
|
|
177
|
+
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
178
|
+
* onWarn?: (message: string) => void,
|
|
179
|
+
* }} opts
|
|
180
|
+
* @returns {Promise<number[]>}
|
|
181
|
+
*/
|
|
182
|
+
export async function readEpicChildIdsFrom({
|
|
183
|
+
epic,
|
|
184
|
+
readNativeChildIds,
|
|
185
|
+
onWarn,
|
|
186
|
+
} = {}) {
|
|
187
|
+
const fromBody = readEpicChildIds(epic?.body);
|
|
188
|
+
if (typeof readNativeChildIds !== 'function') return fromBody;
|
|
189
|
+
|
|
190
|
+
let native = [];
|
|
191
|
+
try {
|
|
192
|
+
native = normalizeChildIds(await readNativeChildIds(epic));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const id = epic?.number ?? epic?.id ?? '?';
|
|
195
|
+
onWarn?.(
|
|
196
|
+
`[epic-container] native sub-issue read failed for Epic #${id} ` +
|
|
197
|
+
`(${err?.message ?? String(err)}); using the body checklist alone.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return normalizeChildIds([...native, ...fromBody]);
|
|
202
|
+
}
|