mandrel 2.39.0 → 2.40.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 +6 -3
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/instructions.md +17 -16
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -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/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- 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 +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/docs/CHANGELOG.md +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
/** Heading the container's one prose section renders under. */
|
|
41
|
+
const GOAL_HEADING = '## Goal';
|
|
42
|
+
|
|
43
|
+
/** Heading the child checklist renders under. */
|
|
44
|
+
const CHILDREN_HEADING = '## Stories';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize an issue's labels to plain strings. GitHub hands labels back
|
|
48
|
+
* either as objects (`{ name }`) or, once mapped, as bare strings; callers
|
|
49
|
+
* should not have to care which shape they hold.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} raw
|
|
52
|
+
* @returns {string[]}
|
|
53
|
+
*/
|
|
54
|
+
function normalizeLabels(raw) {
|
|
55
|
+
if (!Array.isArray(raw)) return [];
|
|
56
|
+
return raw
|
|
57
|
+
.map((l) => (typeof l === 'string' ? l : l?.name))
|
|
58
|
+
.filter((n) => typeof n === 'string' && n.length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Is this issue a container Epic?
|
|
63
|
+
*
|
|
64
|
+
* Reads the `type::epic` label and nothing else — the label is the
|
|
65
|
+
* authoritative marker. Body shape is deliberately NOT part of the test: a
|
|
66
|
+
* hand-edited Epic whose checklist an operator reordered or annotated is
|
|
67
|
+
* still an Epic, and treating it otherwise would silently reclassify it as
|
|
68
|
+
* an ordinary non-Story and hard-error the delivery.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ labels?: unknown }} issue
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function isEpicTicket(issue) {
|
|
74
|
+
return normalizeLabels(issue?.labels).includes(TYPE_LABELS.EPIC);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render a container Epic's body.
|
|
79
|
+
*
|
|
80
|
+
* The output is intentionally minimal — a goal paragraph and a checklist.
|
|
81
|
+
* The Epic must carry **no information a child does not already carry**: it
|
|
82
|
+
* is a container, so anything unique living here would be a fact with no
|
|
83
|
+
* home in the tickets that actually get executed, invisible to every agent
|
|
84
|
+
* delivering them.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ goal: string, childIds?: number[] }} opts
|
|
87
|
+
* @returns {string} Canonical Epic body markdown.
|
|
88
|
+
*/
|
|
89
|
+
export function composeEpicBody({ goal, childIds = [] } = {}) {
|
|
90
|
+
const text = typeof goal === 'string' ? goal.trim() : '';
|
|
91
|
+
if (text === '') {
|
|
92
|
+
throw new Error('[epic-container] composeEpicBody requires a goal.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ids = normalizeChildIds(childIds);
|
|
96
|
+
const lines = [GOAL_HEADING, '', text, '', CHILDREN_HEADING, ''];
|
|
97
|
+
if (ids.length === 0) {
|
|
98
|
+
lines.push('_No child Stories linked._');
|
|
99
|
+
} else {
|
|
100
|
+
for (const id of ids) lines.push(`- [ ] #${id}`);
|
|
101
|
+
}
|
|
102
|
+
return `${lines.join('\n')}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Coerce a child-id list to positive integers, deduped, order-preserving.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} raw
|
|
109
|
+
* @returns {number[]}
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeChildIds(raw) {
|
|
112
|
+
if (!Array.isArray(raw)) return [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const entry of raw) {
|
|
116
|
+
const id = Number(entry);
|
|
117
|
+
if (!Number.isInteger(id) || id <= 0) continue;
|
|
118
|
+
if (seen.has(id)) continue;
|
|
119
|
+
seen.add(id);
|
|
120
|
+
out.push(id);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read the child issue numbers an Epic body declares.
|
|
127
|
+
*
|
|
128
|
+
* Body-only, by design: this is the fallback that works with nothing but the
|
|
129
|
+
* issue text. Callers that can reach the API should union this with the
|
|
130
|
+
* native sub-issue edges (`readEpicChildIdsFrom`), because an operator can
|
|
131
|
+
* link a child in the GitHub UI without touching the checklist.
|
|
132
|
+
*
|
|
133
|
+
* @param {string|null|undefined} body
|
|
134
|
+
* @returns {number[]}
|
|
135
|
+
*/
|
|
136
|
+
export function readEpicChildIds(body) {
|
|
137
|
+
if (typeof body !== 'string' || body === '') return [];
|
|
138
|
+
// `matchAll` on a /g regex starts from lastIndex; the literal is
|
|
139
|
+
// module-scoped, so reset it rather than leaking state across calls.
|
|
140
|
+
CHECKLIST_ITEM_RE.lastIndex = 0;
|
|
141
|
+
return normalizeChildIds(
|
|
142
|
+
[...body.matchAll(CHECKLIST_ITEM_RE)].map((m) => Number.parseInt(m[1], 10)),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve an Epic's children from **both** sources — the body checklist and
|
|
148
|
+
* the native sub-issue edges — as one deduped list.
|
|
149
|
+
*
|
|
150
|
+
* The two are unioned rather than ranked because each can hold a child the
|
|
151
|
+
* other misses: the API is authoritative for links made in the GitHub UI,
|
|
152
|
+
* and the checklist survives an API that is unavailable or was never
|
|
153
|
+
* written. A child present in either is a child.
|
|
154
|
+
*
|
|
155
|
+
* `readNativeChildIds` is injected and may be absent or throw; a failure
|
|
156
|
+
* degrades to the checklist rather than propagating, since a body-derived
|
|
157
|
+
* child list is a strictly better answer than an error.
|
|
158
|
+
*
|
|
159
|
+
* @param {{
|
|
160
|
+
* epic: { number?: number, id?: number, body?: string, nodeId?: string },
|
|
161
|
+
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
162
|
+
* onWarn?: (message: string) => void,
|
|
163
|
+
* }} opts
|
|
164
|
+
* @returns {Promise<number[]>}
|
|
165
|
+
*/
|
|
166
|
+
export async function readEpicChildIdsFrom({
|
|
167
|
+
epic,
|
|
168
|
+
readNativeChildIds,
|
|
169
|
+
onWarn,
|
|
170
|
+
} = {}) {
|
|
171
|
+
const fromBody = readEpicChildIds(epic?.body);
|
|
172
|
+
if (typeof readNativeChildIds !== 'function') return fromBody;
|
|
173
|
+
|
|
174
|
+
let native = [];
|
|
175
|
+
try {
|
|
176
|
+
native = normalizeChildIds(await readNativeChildIds(epic));
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const id = epic?.number ?? epic?.id ?? '?';
|
|
179
|
+
onWarn?.(
|
|
180
|
+
`[epic-container] native sub-issue read failed for Epic #${id} ` +
|
|
181
|
+
`(${err?.message ?? String(err)}); using the body checklist alone.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return normalizeChildIds([...native, ...fromBody]);
|
|
186
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-expansion.js — turn a container-Epic id into the open Story ids under it.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `resolve-stories.js` (Story #5139): Epic expansion is a
|
|
5
|
+
* distinct concern from Story resolution — it runs strictly *before* it and
|
|
6
|
+
* hands it an ordinary id list — and folding it into that already-dense module
|
|
7
|
+
* cost 4.19 maintainability points for no cohesion gain.
|
|
8
|
+
*
|
|
9
|
+
* @module lib/orchestration/epic-expansion
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { TYPE_LABELS } from '../label-constants.js';
|
|
13
|
+
import { isEpicTicket, readEpicChildIdsFrom } from './epic-container.js';
|
|
14
|
+
import { isSatisfiedBlocker } from './resolve-stories.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Does this issue carry the Story type label?
|
|
18
|
+
*
|
|
19
|
+
* @param {{ labels?: unknown }} issue
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
function isStoryTicket(issue) {
|
|
23
|
+
const raw = issue?.labels;
|
|
24
|
+
if (!Array.isArray(raw)) return false;
|
|
25
|
+
return raw
|
|
26
|
+
.map((l) => (typeof l === 'string' ? l : l?.name))
|
|
27
|
+
.includes(TYPE_LABELS.STORY);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Expand any container-Epic id in the requested set to its open child
|
|
32
|
+
* Stories, leaving every other id untouched.
|
|
33
|
+
*
|
|
34
|
+
* `/mandrel-deliver <epicId>` means "deliver everything under this Epic". The
|
|
35
|
+
* expansion happens **here, before resolution**, so everything downstream —
|
|
36
|
+
* the DAG, the ready set, the wave tick, the close tail — sees an ordinary
|
|
37
|
+
* list of Story ids and needs no Epic concept at all. That is the whole
|
|
38
|
+
* reason the Epic can exist without touching the delivery engine.
|
|
39
|
+
*
|
|
40
|
+
* Expansion is **per id**, so Epic and Story ids may be mixed freely in one
|
|
41
|
+
* invocation and the result is the deduped union in first-seen order.
|
|
42
|
+
*
|
|
43
|
+
* Two filters apply to children, and they are deliberately quieter than the
|
|
44
|
+
* treatment of a *named* id:
|
|
45
|
+
*
|
|
46
|
+
* - **A closed / `agent::done` child is dropped.** Delivering an Epic means
|
|
47
|
+
* delivering what is left of it. A dependent's edge onto a landed sibling
|
|
48
|
+
* still resolves: the sibling becomes a *foreign* blocker, and foreign
|
|
49
|
+
* blockers are checked against live state and enter `done[]`.
|
|
50
|
+
* - **A child that is not a `type::story` is dropped with a warning.** A
|
|
51
|
+
* named non-Story is an error because the operator asserted it was
|
|
52
|
+
* deliverable; a linked one is the Epic's assertion, and someone attaching
|
|
53
|
+
* a stray issue in the GitHub UI must not wedge the whole run.
|
|
54
|
+
*
|
|
55
|
+
* An Epic that expands to nothing is an **error**, not an empty success: a
|
|
56
|
+
* silent empty envelope would report a clean no-op for a delivery the
|
|
57
|
+
* operator asked for and never got.
|
|
58
|
+
*
|
|
59
|
+
* @param {{
|
|
60
|
+
* ids: number[],
|
|
61
|
+
* getTicket: (id: number) => Promise<object|null>,
|
|
62
|
+
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
63
|
+
* warn?: (msg: string) => void,
|
|
64
|
+
* }} opts
|
|
65
|
+
* @returns {Promise<{ ids: number[], expansions: Array<{ epicId: number, childIds: number[] }> }>}
|
|
66
|
+
*/
|
|
67
|
+
export async function expandEpicIds({
|
|
68
|
+
ids,
|
|
69
|
+
getTicket,
|
|
70
|
+
readNativeChildIds,
|
|
71
|
+
warn,
|
|
72
|
+
}) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const expansions = [];
|
|
76
|
+
|
|
77
|
+
const push = (id) => {
|
|
78
|
+
if (seen.has(id)) return;
|
|
79
|
+
seen.add(id);
|
|
80
|
+
out.push(id);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
const issue = await getTicket(id);
|
|
85
|
+
if (!issue) {
|
|
86
|
+
throw new Error(`[resolve-stories] Issue #${id} was not found.`);
|
|
87
|
+
}
|
|
88
|
+
if (!isEpicTicket(issue)) {
|
|
89
|
+
push(id);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const childIds = await readEpicChildIdsFrom({
|
|
94
|
+
epic: issue,
|
|
95
|
+
readNativeChildIds,
|
|
96
|
+
onWarn: warn,
|
|
97
|
+
});
|
|
98
|
+
if (childIds.length === 0) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`[resolve-stories] Epic #${id} lists no child Stories. An Epic is a container: ` +
|
|
101
|
+
`link its Stories (a "- [ ] #N" checklist line or a GitHub sub-issue) ` +
|
|
102
|
+
`or deliver the Story ids directly.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const open = [];
|
|
107
|
+
for (const childId of childIds) {
|
|
108
|
+
let child;
|
|
109
|
+
try {
|
|
110
|
+
child = await getTicket(childId);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
warn?.(
|
|
113
|
+
`[resolve-stories] Epic #${id}: could not read child #${childId} ` +
|
|
114
|
+
`(${err?.message ?? err}) — skipping it.`,
|
|
115
|
+
);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!child) {
|
|
119
|
+
warn?.(
|
|
120
|
+
`[resolve-stories] Epic #${id}: child #${childId} was not found — skipping it.`,
|
|
121
|
+
);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!isStoryTicket(child)) {
|
|
125
|
+
warn?.(
|
|
126
|
+
`[resolve-stories] Epic #${id}: child #${childId} is not a ${TYPE_LABELS.STORY} ` +
|
|
127
|
+
`— skipping it. Only Stories are deliverable.`,
|
|
128
|
+
);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (isSatisfiedBlocker(child)) continue;
|
|
132
|
+
open.push(childId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (open.length === 0) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[resolve-stories] Epic #${id} has ${childIds.length} child Story(ies), ` +
|
|
138
|
+
`but none are still open — every one is closed or agent::done. ` +
|
|
139
|
+
`There is nothing left to deliver.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expansions.push({ epicId: id, childIds: open });
|
|
144
|
+
for (const childId of open) push(childId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { ids: out, expansions };
|
|
148
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-ops.js — create the optional container Epic for a plan-persist run.
|
|
3
|
+
*
|
|
4
|
+
* Story #5139. When `/mandrel-plan` authors more than two Stories it offers to
|
|
5
|
+
* group them under one container Epic. The Epic is **not** a work item: it
|
|
6
|
+
* holds a `## Goal` paragraph and a child checklist, carries `type::epic` and
|
|
7
|
+
* nothing else, and is never branched, implemented or delivered.
|
|
8
|
+
*
|
|
9
|
+
* Ordering matters — the Epic is created **after** the Stories, because its
|
|
10
|
+
* body embeds their issue numbers and its sub-issue edges need their database
|
|
11
|
+
* ids. A container that exists before its contents would have to be written
|
|
12
|
+
* twice.
|
|
13
|
+
*
|
|
14
|
+
* @module lib/orchestration/plan-persist/epic-ops
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { linkStoriesToEpic } from '../../../providers/github/sub-issue-add.js';
|
|
19
|
+
import { Logger } from '../../Logger.js';
|
|
20
|
+
import { LABEL_COLORS, TYPE_LABELS } from '../../label-constants.js';
|
|
21
|
+
import { composeEpicBody } from '../epic-container.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Story count at or above which `/mandrel-plan` offers a container Epic.
|
|
25
|
+
*
|
|
26
|
+
* Three, i.e. "more than two" — at two Stories a pair of ids is as easy to
|
|
27
|
+
* carry as one, and the container earns nothing.
|
|
28
|
+
*/
|
|
29
|
+
export const EPIC_SUGGESTION_THRESHOLD = 3;
|
|
30
|
+
|
|
31
|
+
/** Length of the truncated hex digest stamped into the Epic marker. */
|
|
32
|
+
const EPIC_FINGERPRINT_LENGTH = 8;
|
|
33
|
+
|
|
34
|
+
/** Marker prefix identifying a persist-authored Epic in an issue body. */
|
|
35
|
+
const EPIC_FINGERPRINT_MARKER_PREFIX = 'mandrel-epic-fingerprint';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Derive the Epic's resume identity from its title and the exact child set.
|
|
39
|
+
*
|
|
40
|
+
* Keyed on the children, not just the title: two runs that group *different*
|
|
41
|
+
* Stories are different containers even under the same title, and adopting
|
|
42
|
+
* one for the other would silently leave a cohort unlinked.
|
|
43
|
+
*
|
|
44
|
+
* Fields join on NUL, written as the `\u0000` escape and never as a raw byte
|
|
45
|
+
* — a literal NUL makes git classify the file binary and drop its diffs.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ title: string, childIds: number[] }} opts
|
|
48
|
+
* @returns {string} Hex digest.
|
|
49
|
+
*/
|
|
50
|
+
function epicFingerprint({ title, childIds }) {
|
|
51
|
+
const ids = [...childIds].sort((a, b) => a - b).join(',');
|
|
52
|
+
return createHash('sha256')
|
|
53
|
+
.update(`${title}\u0000${ids}`)
|
|
54
|
+
.digest('hex')
|
|
55
|
+
.slice(0, EPIC_FINGERPRINT_LENGTH);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the invisible HTML-comment marker carrying the Epic's fingerprint.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} fingerprint
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function epicFingerprintMarker(fingerprint) {
|
|
65
|
+
return `<!-- ${EPIC_FINGERPRINT_MARKER_PREFIX} ${fingerprint} -->`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ensure the `type::epic` label exists, **failing closed**.
|
|
70
|
+
*
|
|
71
|
+
* This is deliberately the opposite posture to the cohort and route labels
|
|
72
|
+
* (`ensurePersistLabel` in `story-ops.js`), which degrade to "create without
|
|
73
|
+
* the label" because they are cosmetic. `type::epic` is not cosmetic: it is
|
|
74
|
+
* the sole marker `isEpicTicket` reads, so an Epic created without it is not
|
|
75
|
+
* an Epic — it is a stray issue that `/mandrel-deliver` will hard-error on and
|
|
76
|
+
* no expansion will ever find. Skipping creation leaves the Stories, which
|
|
77
|
+
* are the part that matters, perfectly deliverable by id.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ provider: object }} opts
|
|
80
|
+
* @returns {Promise<boolean>} Whether creation may proceed.
|
|
81
|
+
*/
|
|
82
|
+
async function ensureEpicLabel({ provider }) {
|
|
83
|
+
if (typeof provider?.ensureLabels !== 'function') return true;
|
|
84
|
+
try {
|
|
85
|
+
const result = await provider.ensureLabels([
|
|
86
|
+
{
|
|
87
|
+
name: TYPE_LABELS.EPIC,
|
|
88
|
+
color: LABEL_COLORS.TYPE,
|
|
89
|
+
description:
|
|
90
|
+
'Container-only grouping ticket — holds child Stories, carries no execution payload',
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
if (
|
|
94
|
+
Array.isArray(result?.missing) &&
|
|
95
|
+
result.missing.includes(TYPE_LABELS.EPIC)
|
|
96
|
+
) {
|
|
97
|
+
Logger.warn(
|
|
98
|
+
`[plan-persist] "${TYPE_LABELS.EPIC}" could not be verified on the remote — ` +
|
|
99
|
+
'skipping the container Epic. The Stories are unaffected and deliver by id.',
|
|
100
|
+
);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
Logger.warn(
|
|
106
|
+
`[plan-persist] "${TYPE_LABELS.EPIC}" label ensure failed (${err.message}) — ` +
|
|
107
|
+
'skipping the container Epic. The Stories are unaffected and deliver by id.',
|
|
108
|
+
);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find an already-created Epic carrying this fingerprint, so a resumed
|
|
115
|
+
* persist adopts it instead of opening a second container.
|
|
116
|
+
*
|
|
117
|
+
* Non-fatal: a search failure returns `null` and the caller creates. A
|
|
118
|
+
* duplicate Epic is cosmetic; a crash mid-persist is not.
|
|
119
|
+
*
|
|
120
|
+
* @param {{ provider: object, fingerprint: string }} opts
|
|
121
|
+
* @returns {Promise<{ id: number, url?: string }|null>}
|
|
122
|
+
*/
|
|
123
|
+
async function findExistingEpic({ provider, fingerprint }) {
|
|
124
|
+
if (typeof provider?.listIssuesByLabel !== 'function') return null;
|
|
125
|
+
try {
|
|
126
|
+
const marker = epicFingerprintMarker(fingerprint);
|
|
127
|
+
const found = await provider.listIssuesByLabel({
|
|
128
|
+
state: 'open',
|
|
129
|
+
labels: TYPE_LABELS.EPIC,
|
|
130
|
+
});
|
|
131
|
+
const hit = (Array.isArray(found) ? found : []).find((issue) =>
|
|
132
|
+
String(issue?.body ?? '').includes(marker),
|
|
133
|
+
);
|
|
134
|
+
if (!hit) return null;
|
|
135
|
+
const id = Number(hit.number ?? hit.id);
|
|
136
|
+
if (!Number.isInteger(id) || id <= 0) return null;
|
|
137
|
+
return { id, url: hit.html_url ?? hit.url ?? undefined };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
Logger.warn(
|
|
140
|
+
`[plan-persist] Epic resume lookup failed (${err.message}); creating a new container.`,
|
|
141
|
+
);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Link the created Stories under the Epic as native sub-issue edges.
|
|
148
|
+
*
|
|
149
|
+
* Non-fatal by design — the body checklist is the durable mirror, and
|
|
150
|
+
* `getSubTickets` reads it as a first-class child source. A lost edge costs
|
|
151
|
+
* the GitHub UI's nesting, not the grouping itself.
|
|
152
|
+
*
|
|
153
|
+
* @param {{ provider: object, epicNumber: number, childIds: number[] }} opts
|
|
154
|
+
* @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
|
|
155
|
+
*/
|
|
156
|
+
async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
|
|
157
|
+
if (
|
|
158
|
+
typeof provider?.getDependencyWriteContext !== 'function' ||
|
|
159
|
+
typeof provider?.getTicket !== 'function'
|
|
160
|
+
) {
|
|
161
|
+
Logger.warn(
|
|
162
|
+
'[plan-persist] provider exposes no getDependencyWriteContext/getTicket — ' +
|
|
163
|
+
'skipping native sub-issue edges. The Epic body checklist still lists every child.',
|
|
164
|
+
);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const { gh, owner, repo } = provider.getDependencyWriteContext();
|
|
170
|
+
const summary = await linkStoriesToEpic({
|
|
171
|
+
epicNumber,
|
|
172
|
+
childIssueNumbers: childIds,
|
|
173
|
+
getTicket: (issueNumber) => provider.getTicket(issueNumber),
|
|
174
|
+
owner,
|
|
175
|
+
repo,
|
|
176
|
+
gh,
|
|
177
|
+
});
|
|
178
|
+
if (summary.failed > 0) {
|
|
179
|
+
Logger.warn(
|
|
180
|
+
`[plan-persist] ${summary.failed} sub-issue edge(s) could not be written. ` +
|
|
181
|
+
'The Epic body checklist still lists every child; add the links by hand ' +
|
|
182
|
+
'if you want them nested in the GitHub UI.',
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
Logger.info(
|
|
186
|
+
`[plan-persist] sub-issue edges: ${summary.added} added, ` +
|
|
187
|
+
`${summary.skipped} already present.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return summary;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
Logger.warn(
|
|
193
|
+
`[plan-persist] native sub-issue mirroring failed (${err.message}) — ` +
|
|
194
|
+
'the Epic body checklist still lists every child.',
|
|
195
|
+
);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create the container Epic for a persisted cohort.
|
|
202
|
+
*
|
|
203
|
+
* Returns `null` whenever no Epic was created — not requested, too few
|
|
204
|
+
* Stories, or the label could not be ensured. Callers treat `null` as the
|
|
205
|
+
* ordinary no-Epic outcome, never as a failure.
|
|
206
|
+
*
|
|
207
|
+
* **The Epic never receives an `agent::*` label.** Its labels are exactly
|
|
208
|
+
* `[type::epic]`. That absence is load-bearing: it keeps the container out
|
|
209
|
+
* of the bare `/mandrel-deliver` ready list and outside `lint-issue-body.js`,
|
|
210
|
+
* which scopes itself to `type::story`.
|
|
211
|
+
*
|
|
212
|
+
* @param {{
|
|
213
|
+
* provider: object,
|
|
214
|
+
* epic: { title: string, goal: string }|null,
|
|
215
|
+
* created: Array<{ id: number, title: string }>,
|
|
216
|
+
* opts?: { dryRun?: boolean, minStories?: number },
|
|
217
|
+
* }} args
|
|
218
|
+
* @returns {Promise<{
|
|
219
|
+
* id: number,
|
|
220
|
+
* title: string,
|
|
221
|
+
* url?: string,
|
|
222
|
+
* childIds: number[],
|
|
223
|
+
* adopted: boolean,
|
|
224
|
+
* edges: { added: number, skipped: number, failed: number }|null,
|
|
225
|
+
* }|null>}
|
|
226
|
+
*/
|
|
227
|
+
export async function createContainerEpic({
|
|
228
|
+
provider,
|
|
229
|
+
epic,
|
|
230
|
+
created,
|
|
231
|
+
opts = {},
|
|
232
|
+
}) {
|
|
233
|
+
const { dryRun = false, minStories = EPIC_SUGGESTION_THRESHOLD } = opts;
|
|
234
|
+
if (!epic) return null;
|
|
235
|
+
|
|
236
|
+
const title = typeof epic.title === 'string' ? epic.title.trim() : '';
|
|
237
|
+
const goal = typeof epic.goal === 'string' ? epic.goal.trim() : '';
|
|
238
|
+
if (title === '' || goal === '') {
|
|
239
|
+
throw new Error(
|
|
240
|
+
'[plan-persist] A container Epic requires both a title and a goal.',
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const childIds = (Array.isArray(created) ? created : [])
|
|
245
|
+
.map((s) => s.id)
|
|
246
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
247
|
+
|
|
248
|
+
// Dry-run reports the intended container write-free. `created` carries
|
|
249
|
+
// negative placeholder ids there, so `childIds` is empty by construction —
|
|
250
|
+
// report the count from `created` itself rather than from the filtered list.
|
|
251
|
+
if (dryRun) {
|
|
252
|
+
return {
|
|
253
|
+
id: -1,
|
|
254
|
+
title,
|
|
255
|
+
childIds: (Array.isArray(created) ? created : []).map((s) => s.id),
|
|
256
|
+
adopted: false,
|
|
257
|
+
edges: null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (childIds.length < minStories) {
|
|
262
|
+
Logger.info(
|
|
263
|
+
`[plan-persist] ${childIds.length} Story(ies) is below the ${minStories}-Story ` +
|
|
264
|
+
'Epic threshold — no container created.',
|
|
265
|
+
);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!(await ensureEpicLabel({ provider }))) return null;
|
|
270
|
+
|
|
271
|
+
const fingerprint = epicFingerprint({ title, childIds });
|
|
272
|
+
const existing = await findExistingEpic({ provider, fingerprint });
|
|
273
|
+
if (existing) {
|
|
274
|
+
Logger.info(
|
|
275
|
+
`[plan-persist] resuming: container Epic #${existing.id} already groups ` +
|
|
276
|
+
'this exact cohort — skipping create.',
|
|
277
|
+
);
|
|
278
|
+
const edges = await mirrorSubIssueEdges({
|
|
279
|
+
provider,
|
|
280
|
+
epicNumber: existing.id,
|
|
281
|
+
childIds,
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
id: existing.id,
|
|
285
|
+
title,
|
|
286
|
+
url: existing.url,
|
|
287
|
+
childIds,
|
|
288
|
+
adopted: true,
|
|
289
|
+
edges,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const body = `${composeEpicBody({ goal, childIds })}\n${epicFingerprintMarker(fingerprint)}\n`;
|
|
294
|
+
const result = await provider.createIssue({
|
|
295
|
+
title,
|
|
296
|
+
body,
|
|
297
|
+
labels: [TYPE_LABELS.EPIC],
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const epicNumber = result.number ?? result.id;
|
|
301
|
+
const edges = await mirrorSubIssueEdges({
|
|
302
|
+
provider,
|
|
303
|
+
epicNumber,
|
|
304
|
+
childIds,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
Logger.info(
|
|
308
|
+
`[plan-persist] container Epic #${epicNumber} groups ${childIds.length} Story(ies): ` +
|
|
309
|
+
`deliver them all with /mandrel-deliver ${epicNumber}`,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
id: epicNumber,
|
|
314
|
+
title,
|
|
315
|
+
url: result.url,
|
|
316
|
+
childIds,
|
|
317
|
+
adopted: false,
|
|
318
|
+
edges,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
renderHardConflictError,
|
|
69
69
|
} from '../ticket-validator-conflicts.js';
|
|
70
70
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
71
|
+
import { createContainerEpic } from './epic-ops.js';
|
|
71
72
|
import {
|
|
72
73
|
enforceFanOutGate,
|
|
73
74
|
surfaceSoftConflictFindings,
|
|
@@ -711,6 +712,10 @@ export async function runPlanPersist({
|
|
|
711
712
|
closeSuperseded = true,
|
|
712
713
|
routeDowngradeReason = null,
|
|
713
714
|
injectedRules = undefined,
|
|
715
|
+
// Story #5139 — the optional container Epic. `null` (the default) is the
|
|
716
|
+
// ordinary shape: no Epic is created unless `/mandrel-plan` offered one
|
|
717
|
+
// above the threshold and the operator confirmed it.
|
|
718
|
+
epic = null,
|
|
714
719
|
} = opts;
|
|
715
720
|
|
|
716
721
|
// Boundary for the plan-metrics summary below: everything this invocation
|
|
@@ -840,6 +845,18 @@ export async function runPlanPersist({
|
|
|
840
845
|
});
|
|
841
846
|
}
|
|
842
847
|
|
|
848
|
+
// Story #5139 — the container Epic is created LAST among the writes: its
|
|
849
|
+
// body embeds the child issue numbers and its sub-issue edges need their
|
|
850
|
+
// database ids, neither of which exists until the Stories are live. It is
|
|
851
|
+
// never load-bearing, so a failure here degrades to "no container" and the
|
|
852
|
+
// Stories still deliver by id.
|
|
853
|
+
const containerEpic = await createContainerEpic({
|
|
854
|
+
provider,
|
|
855
|
+
epic,
|
|
856
|
+
created,
|
|
857
|
+
opts: { dryRun },
|
|
858
|
+
});
|
|
859
|
+
|
|
843
860
|
const supersede = await runSupersedePhase({
|
|
844
861
|
provider,
|
|
845
862
|
stories,
|
|
@@ -866,5 +883,6 @@ export async function runPlanPersist({
|
|
|
866
883
|
freshness,
|
|
867
884
|
waveTable,
|
|
868
885
|
supersede,
|
|
886
|
+
epic: containerEpic,
|
|
869
887
|
};
|
|
870
888
|
}
|