mandrel 2.38.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.
Files changed (41) hide show
  1. package/.agents/README.md +51 -11
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/docs/agentrc-reference.json +1 -4
  5. package/.agents/docs/configuration.md +2 -2
  6. package/.agents/instructions.md +17 -16
  7. package/.agents/schemas/agentrc.schema.json +6 -7
  8. package/.agents/scripts/audit-to-stories.js +510 -66
  9. package/.agents/scripts/generate-skills-index.js +158 -75
  10. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  11. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  12. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  13. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  14. package/.agents/scripts/lib/changed-files.js +100 -9
  15. package/.agents/scripts/lib/config-settings-schema.js +25 -7
  16. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  17. package/.agents/scripts/lib/label-constants.js +18 -0
  18. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  19. package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  21. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  22. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  23. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  24. package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
  25. package/.agents/scripts/lib/skills/skills-index.js +168 -0
  26. package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
  27. package/.agents/scripts/plan-persist.js +39 -1
  28. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  29. package/.agents/scripts/quality-preview.js +50 -9
  30. package/.agents/scripts/resolve-stories.js +42 -2
  31. package/.agents/scripts/validate-skills.js +53 -66
  32. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  33. package/.agents/workflows/audit-to-stories.md +85 -7
  34. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  35. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  36. package/.agents/workflows/helpers/plan-reference.md +28 -0
  37. package/.agents/workflows/mandrel-deliver.md +47 -43
  38. package/.agents/workflows/mandrel-plan.md +44 -38
  39. package/.agents/workflows/qa-run.md +13 -5
  40. package/docs/CHANGELOG.md +28 -0
  41. package/package.json +1 -1
@@ -74,8 +74,26 @@ export function isValidTransition(fromState, toState) {
74
74
  return allowed.includes(toState);
75
75
  }
76
76
 
77
+ /**
78
+ * Ticket-type axis.
79
+ *
80
+ * `STORY` is the only type carrying an execution payload — it is what
81
+ * `/mandrel-deliver` branches, implements and lands.
82
+ *
83
+ * `EPIC` (Story #5139) is a **pure container**: a grouping ticket that holds
84
+ * a `## Goal` paragraph and a child checklist and nothing else. It is never
85
+ * branched, never implemented, and never carries an `agent::*` label — that
86
+ * absence is what keeps it out of the bare `/mandrel-deliver` ready list and
87
+ * outside `lint-issue-body.js`, which is `type::story`-scoped.
88
+ *
89
+ * This is deliberately NOT a revival of the v1 Epic tier. Linkage runs
90
+ * parent→child only (the Epic body's checklist plus native sub-issue edges),
91
+ * so Story bodies stay untouched and the `Epic: #N` footer stays retired and
92
+ * refused. See ADR `20260905-container-epic` in `docs/decisions.md`.
93
+ */
77
94
  export const TYPE_LABELS = {
78
95
  STORY: 'type::story',
96
+ EPIC: 'type::epic',
79
97
  };
80
98
 
81
99
  export const STATUS_LABELS = {
@@ -20,14 +20,27 @@ import {
20
20
  TYPE_LABELS,
21
21
  } from './label-constants.js';
22
22
 
23
+ /**
24
+ * The ticket-type axis. Both rows share one colour, so they are derived from
25
+ * `[name, description]` pairs rather than restated as full literals — Story
26
+ * #5139 added `type::epic` here, and the derived form absorbs it without
27
+ * growing the file's structural weight.
28
+ *
29
+ * @type {Array<{ name: string, color: string, description: string }>}
30
+ */
31
+ const TYPE_LABEL_ROWS = [
32
+ [TYPE_LABELS.STORY, 'Story work item'],
33
+ [TYPE_LABELS.EPIC, 'Container-only grouping ticket for child Stories'],
34
+ ].map(([name, description]) => ({
35
+ name,
36
+ color: LABEL_COLORS.TYPE,
37
+ description,
38
+ }));
39
+
23
40
  /** @type {Array<{ name: string, color: string, description: string }>} */
24
41
  export const LABEL_TAXONOMY = [
25
42
  // Type
26
- {
27
- name: TYPE_LABELS.STORY,
28
- color: LABEL_COLORS.TYPE,
29
- description: 'Story work item',
30
- },
43
+ ...TYPE_LABEL_ROWS,
31
44
 
32
45
  // Agent State
33
46
  {
@@ -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
+ }