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
@@ -0,0 +1,168 @@
1
+ // .agents/scripts/lib/skills/skills-index.js
2
+ //
3
+ // Shared I/O for the two skills manifests (Story #5135).
4
+ //
5
+ // Each skills root carries its own `skills.index.json`: the package payload's
6
+ // at `.agents/skills/`, and the consumer-writable zone's at
7
+ // `.agents/local/skills/`. The shipped one is a committed payload file that
8
+ // `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against the
9
+ // installed package, so the two manifests must never be merged — but they are
10
+ // read, compared and reported identically, and both CLIs need that logic.
11
+ // Before this module `generate-skills-index.js` and `validate-skills.js`
12
+ // each carried their own near-identical reader.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ /** Manifest filename, shared by both roots. */
18
+ export const INDEX_FILENAME = 'skills.index.json';
19
+
20
+ /**
21
+ * Absolute path of the manifest for one skills root.
22
+ *
23
+ * @param {string} repoRoot
24
+ * @param {readonly string[]} rootSegments From `walk-skill-files.js`.
25
+ * @returns {string}
26
+ */
27
+ export function indexPathFor(repoRoot, rootSegments) {
28
+ return path.join(repoRoot, ...rootSegments, INDEX_FILENAME);
29
+ }
30
+
31
+ /**
32
+ * Read a manifest from disk. Distinguishes "missing" from "unparseable" via
33
+ * the `reason` channel so callers can report which drift they hit rather than
34
+ * collapsing both into "not fresh".
35
+ *
36
+ * @param {string} indexPath
37
+ * @returns {{ manifest: object | null, reason: string | null }}
38
+ */
39
+ export function readManifest(indexPath) {
40
+ if (!fs.existsSync(indexPath)) {
41
+ return { manifest: null, reason: 'missing' };
42
+ }
43
+ let src;
44
+ try {
45
+ src = fs.readFileSync(indexPath, 'utf8');
46
+ } catch (err) {
47
+ return { manifest: null, reason: `read-error: ${err.message}` };
48
+ }
49
+ try {
50
+ return { manifest: JSON.parse(src), reason: null };
51
+ } catch (err) {
52
+ return { manifest: null, reason: `parse-error: ${err.message}` };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read a manifest and project its entry paths into a Set, the shape the
58
+ * validator's membership check consumes.
59
+ *
60
+ * @param {string} indexPath
61
+ * @returns {{ exists: boolean, paths: Set<string> | null, manifest: object | null, indexPath: string, parseError?: string }}
62
+ */
63
+ export function readIndexPaths(indexPath) {
64
+ const { manifest, reason } = readManifest(indexPath);
65
+ if (reason === 'missing') {
66
+ return { exists: false, paths: null, manifest: null, indexPath };
67
+ }
68
+ if (manifest === null) {
69
+ return {
70
+ exists: true,
71
+ paths: null,
72
+ manifest: null,
73
+ indexPath,
74
+ parseError: reason,
75
+ };
76
+ }
77
+ const paths = new Set(
78
+ Array.isArray(manifest.skills)
79
+ ? manifest.skills.map((s) => s.path).filter((p) => typeof p === 'string')
80
+ : [],
81
+ );
82
+ return { exists: true, paths, manifest, indexPath };
83
+ }
84
+
85
+ /**
86
+ * Compare two manifests ignoring `generatedAt` — the one volatile field, which
87
+ * changes on every write and is not content. Returns null when they match, or
88
+ * a diff-style message naming the entry counts.
89
+ *
90
+ * @param {object | null} diskManifest
91
+ * @param {object} freshManifest
92
+ * @param {string} label Manifest name for the message.
93
+ * @returns {string | null}
94
+ */
95
+ export function diffManifests(diskManifest, freshManifest, label) {
96
+ if (diskManifest === null) {
97
+ return `${label}: on-disk manifest is missing or unreadable`;
98
+ }
99
+ const a = { ...diskManifest };
100
+ const b = { ...freshManifest };
101
+ a.generatedAt = undefined;
102
+ b.generatedAt = undefined;
103
+ if (JSON.stringify(a) === JSON.stringify(b)) return null;
104
+ const count = (m) => (Array.isArray(m.skills) ? m.skills.length : 'n/a');
105
+ return [
106
+ `${label} drift detected:`,
107
+ ` on-disk entries: ${count(diskManifest)}`,
108
+ ` generated entries: ${count(freshManifest)}`,
109
+ " run 'node .agents/scripts/generate-skills-index.js' to refresh",
110
+ ].join('\n');
111
+ }
112
+
113
+ /**
114
+ * Render a manifest's schema violations as field-named findings. The compiled
115
+ * AJV validator is passed in so this module stays free of the schema-loading
116
+ * side effects the validator CLI owns.
117
+ *
118
+ * @param {object} manifest
119
+ * @param {string} indexRelPath Repo-relative manifest path, for the message.
120
+ * @param {(m: object) => boolean} validateManifest Compiled AJV validator.
121
+ * @returns {string[]}
122
+ */
123
+ function validateManifestSchema(manifest, indexRelPath, validateManifest) {
124
+ const findings = [];
125
+ if (validateManifest(manifest)) return findings;
126
+ for (const err of validateManifest.errors ?? []) {
127
+ const where = err.instancePath || '(root)';
128
+ findings.push(
129
+ `${indexRelPath}: manifest-schema: schema violation at ${where}: ${err.message}`,
130
+ );
131
+ }
132
+ return findings;
133
+ }
134
+
135
+ /**
136
+ * Audit one root's manifest: present, parseable, and schema-valid. Shared by
137
+ * both skills roots so a consumer-authored index is held to the same bar as
138
+ * the shipped one.
139
+ *
140
+ * @param {{ exists: boolean, paths: Set<string> | null, manifest: object | null, parseError?: string }} indexInfo
141
+ * @param {string} indexRelPath
142
+ * @param {(m: object) => boolean} validateManifest
143
+ * @param {{ required: boolean }} options
144
+ * @returns {string[]}
145
+ */
146
+ export function auditIndex(
147
+ indexInfo,
148
+ indexRelPath,
149
+ validateManifest,
150
+ { required },
151
+ ) {
152
+ if (!indexInfo.exists) {
153
+ return required
154
+ ? [
155
+ `index missing: ${indexRelPath} not found — run 'node .agents/scripts/generate-skills-index.js'`,
156
+ ]
157
+ : [];
158
+ }
159
+ if (indexInfo.paths === null) {
160
+ return [`index unparseable: ${indexRelPath} — ${indexInfo.parseError}`];
161
+ }
162
+ if (indexInfo.manifest === null) return [];
163
+ return validateManifestSchema(
164
+ indexInfo.manifest,
165
+ indexRelPath,
166
+ validateManifest,
167
+ );
168
+ }
@@ -1,12 +1,56 @@
1
1
  // .agents/scripts/lib/skills/walk-skill-files.js
2
2
  //
3
- // Shared traversal for SKILL.md files under `.agents/skills/{core,stack}/`.
3
+ // Shared traversal for SKILL.md files across the two skills roots:
4
+ // the package payload (`.agents/skills/{core,stack}/`) and the
5
+ // consumer-writable local zone (`.agents/local/skills/{core,stack}/`).
4
6
  // Used by validate-skills.js and generate-skills-index.js so both CLIs
5
7
  // enumerate the same paths in the same deterministic order.
8
+ //
9
+ // The two roots stay **separately enumerable** on purpose (Story #5135).
10
+ // `.agents/skills/skills.index.json` is a committed payload file that
11
+ // `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against
12
+ // the installed package; folding a consumer's local skills into it would
13
+ // make every consumer's regenerated index read as payload drift and cause
14
+ // those commands to refuse. The local zone therefore carries its own
15
+ // index artifact, and the two roots are unified only at *lookup* time, by
16
+ // `resolveSkillFile` — never for the shipped manifest.
6
17
 
7
18
  import fs from 'node:fs';
8
19
  import path from 'node:path';
9
20
 
21
+ /** Tier directories a skills root is enumerated under. */
22
+ const TIERS = Object.freeze(['core', 'stack']);
23
+
24
+ /**
25
+ * Path segments (from the repo root) of the package-payload skills root.
26
+ * Materialized by `mandrel sync`; every file under it is payload.
27
+ */
28
+ export const PAYLOAD_SKILLS_SEGMENTS = Object.freeze(['.agents', 'skills']);
29
+
30
+ /**
31
+ * Path segments (from the repo root) of the consumer-writable skills root.
32
+ * It sits inside the `.agents/local/` zone (Story #3498), which sync never
33
+ * copies into and never prunes, and which the agents-drift check cannot
34
+ * flag because that check only walks files present in the package payload.
35
+ */
36
+ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
37
+ '.agents',
38
+ 'local',
39
+ 'skills',
40
+ ]);
41
+
42
+ /**
43
+ * A skill id is the tier-relative path naming a skill — e.g.
44
+ * `core/scope-triage` or `stack/qa/playwright`. It is the value that
45
+ * appears in `skills.index.json` minus the root prefix, and the value a
46
+ * `qa.environments.*.signInSeam.skill` seam carries.
47
+ *
48
+ * The pattern is deliberately strict: ids resolve to filesystem paths, so
49
+ * anything that could escape a root (`..`, absolute paths, backslashes) or
50
+ * smuggle a shell metacharacter is rejected rather than normalized.
51
+ */
52
+ const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
53
+
10
54
  /**
11
55
  * Recursively enumerate `SKILL.md` paths under a directory.
12
56
  *
@@ -38,19 +82,99 @@ function walkSkillFiles(rootDir) {
38
82
  }
39
83
 
40
84
  /**
41
- * Build the list of SKILL.md files under `<repoRoot>/.agents/skills/{core,
42
- * stack}/`, sorted by POSIX repo-relative path for deterministic output.
85
+ * Sort absolute paths by their POSIX repo-relative form so output order is
86
+ * deterministic across platforms.
43
87
  *
88
+ * @param {string[]} files
44
89
  * @param {string} repoRoot
45
- * @returns {string[]} absolute paths
90
+ * @returns {string[]}
46
91
  */
47
- export function collectSkillFiles(repoRoot) {
48
- const skillsRoot = path.join(repoRoot, '.agents', 'skills');
49
- const coreFiles = walkSkillFiles(path.join(skillsRoot, 'core'));
50
- const stackFiles = walkSkillFiles(path.join(skillsRoot, 'stack'));
51
- return [...coreFiles, ...stackFiles].sort((a, b) => {
92
+ function sortByRepoRelative(files, repoRoot) {
93
+ return [...files].sort((a, b) => {
52
94
  const ra = path.relative(repoRoot, a).split(path.sep).join('/');
53
95
  const rb = path.relative(repoRoot, b).split(path.sep).join('/');
54
96
  return ra < rb ? -1 : ra > rb ? 1 : 0;
55
97
  });
56
98
  }
99
+
100
+ /**
101
+ * Enumerate the `SKILL.md` files under one skills root, sorted by POSIX
102
+ * repo-relative path.
103
+ *
104
+ * @param {string} repoRoot
105
+ * @param {readonly string[]} rootSegments One of the exported segment lists.
106
+ * @returns {string[]} absolute paths
107
+ */
108
+ function collectUnderRoot(repoRoot, rootSegments) {
109
+ const skillsRoot = path.join(repoRoot, ...rootSegments);
110
+ const files = TIERS.flatMap((tier) =>
111
+ walkSkillFiles(path.join(skillsRoot, tier)),
112
+ );
113
+ return sortByRepoRelative(files, repoRoot);
114
+ }
115
+
116
+ /**
117
+ * Build the list of payload SKILL.md files under
118
+ * `<repoRoot>/.agents/skills/{core,stack}/`.
119
+ *
120
+ * This is the set the **shipped** `skills.index.json` is generated from —
121
+ * it must never include local-zone skills (see the module header).
122
+ *
123
+ * @param {string} repoRoot
124
+ * @returns {string[]} absolute paths
125
+ */
126
+ export function collectSkillFiles(repoRoot) {
127
+ return collectUnderRoot(repoRoot, PAYLOAD_SKILLS_SEGMENTS);
128
+ }
129
+
130
+ /**
131
+ * Build the list of consumer-authored SKILL.md files under
132
+ * `<repoRoot>/.agents/local/skills/{core,stack}/`. Empty when the
133
+ * consumer has authored none — the common case, and the case in this
134
+ * repository itself.
135
+ *
136
+ * @param {string} repoRoot
137
+ * @returns {string[]} absolute paths
138
+ */
139
+ export function collectLocalSkillFiles(repoRoot) {
140
+ return collectUnderRoot(repoRoot, LOCAL_SKILLS_SEGMENTS);
141
+ }
142
+
143
+ /**
144
+ * Resolve a skill id to a readable `SKILL.md`, searching the payload root
145
+ * first and the local zone second (payload-wins, matching
146
+ * {@link collectAllSkillFiles}).
147
+ *
148
+ * Returns `null` rather than throwing so callers own the error message —
149
+ * a config resolver wants to name the offending config key, a workflow
150
+ * wants to name the seam.
151
+ *
152
+ * @param {string} repoRoot
153
+ * @param {string} skillId Tier-relative id, e.g. `stack/qa/acme-sso`.
154
+ * @returns {{ path: string, root: string } | null} absolute `SKILL.md`
155
+ * path and the POSIX repo-relative root it resolved under.
156
+ */
157
+ export function resolveSkillFile(repoRoot, skillId) {
158
+ if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) return null;
159
+ for (const segments of [PAYLOAD_SKILLS_SEGMENTS, LOCAL_SKILLS_SEGMENTS]) {
160
+ const candidate = path.join(repoRoot, ...segments, skillId, 'SKILL.md');
161
+ try {
162
+ if (fs.statSync(candidate).isFile()) {
163
+ return { path: candidate, root: segments.join('/') };
164
+ }
165
+ } catch {
166
+ // Unreadable or absent — try the next root.
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * The POSIX repo-relative skills roots, in search order. Exported so error
174
+ * messages can name exactly what was searched rather than restating the
175
+ * paths as literals.
176
+ */
177
+ export const SKILL_SEARCH_ROOTS = Object.freeze([
178
+ PAYLOAD_SKILLS_SEGMENTS.join('/'),
179
+ LOCAL_SKILLS_SEGMENTS.join('/'),
180
+ ]);
@@ -124,6 +124,8 @@ const CLI_OPTIONS = {
124
124
  'force-review': { type: 'boolean', default: false },
125
125
  'allow-over-budget': { type: 'boolean', default: false },
126
126
  'allow-large-fan-out': { type: 'boolean', default: false },
127
+ 'epic-title': { type: 'string' },
128
+ 'epic-goal': { type: 'string' },
127
129
  };
128
130
 
129
131
  const USAGE =
@@ -133,7 +135,8 @@ const USAGE =
133
135
  '[--source-tickets <ids>] [--no-close-superseded] ' +
134
136
  '[--route-downgrade-reason <text>] ' +
135
137
  '[--dry-run] [--chain-on-clean] [--force-review] ' +
136
- '[--allow-over-budget] [--allow-large-fan-out]';
138
+ '[--allow-over-budget] [--allow-large-fan-out] ' +
139
+ '[--epic-title <text> --epic-goal <text>]';
137
140
 
138
141
  async function readOptional(filePath, { required }) {
139
142
  try {
@@ -196,6 +199,32 @@ async function loadArtifacts(paths) {
196
199
  };
197
200
  }
198
201
 
202
+ /**
203
+ * Resolve the optional container-Epic request from the CLI flags.
204
+ *
205
+ * Both halves are required together: an Epic with a title and no goal is a
206
+ * container with nothing explaining the grouping, and a goal with no title
207
+ * cannot be opened at all. Supplying exactly one is a **usage error**, not a
208
+ * silent no-Epic run — the operator asked for a container and would otherwise
209
+ * never learn they did not get one.
210
+ *
211
+ * @param {object} values Parsed `parseArgs` values.
212
+ * @returns {{ title: string, goal: string }|null} `null` when no Epic was requested.
213
+ */
214
+ export function resolveEpicRequest(values) {
215
+ const title = (values['epic-title'] ?? '').trim();
216
+ const goal = (values['epic-goal'] ?? '').trim();
217
+ if (title === '' && goal === '') return null;
218
+ if (title === '' || goal === '') {
219
+ throw new Error(
220
+ '[plan-persist] --epic-title and --epic-goal must be supplied together ' +
221
+ '(a container Epic needs both a name and a one-paragraph reason it ' +
222
+ 'groups these Stories).',
223
+ );
224
+ }
225
+ return { title, goal };
226
+ }
227
+
199
228
  /**
200
229
  * Assemble the `runPlanPersist` opts bag from parsed CLI values.
201
230
  *
@@ -224,6 +253,7 @@ export function buildPersistOptions(values, paths, planContextEnvelope) {
224
253
  sourceTicketIds: source.ids,
225
254
  sourceTicketOrigin: source.origin,
226
255
  routeDowngradeReason: values['route-downgrade-reason'] ?? null,
256
+ epic: resolveEpicRequest(values),
227
257
  // Default-on: `--no-close-superseded` is the explicit escape and always
228
258
  // wins over the (default `true`) `--close-superseded`.
229
259
  closeSuperseded:
@@ -468,6 +498,14 @@ runAsCli(import.meta.url, main, {
468
498
  ],
469
499
  ['--allow-over-budget', 'Permit a Spec over the context budget.'],
470
500
  ['--allow-large-fan-out', 'Permit a Story count above the fan-out gate.'],
501
+ [
502
+ '--epic-title <text>',
503
+ 'Group the persisted Stories under a container Epic with this title (needs --epic-goal).',
504
+ ],
505
+ [
506
+ '--epic-goal <text>',
507
+ 'The container Epic’s one-paragraph goal (needs --epic-title).',
508
+ ],
471
509
  ],
472
510
  },
473
511
  });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * GitHub Provider — shared "link child issue to a parent" helper.
3
+ *
4
+ * Story #5139 — a container Epic holds its children as native GitHub
5
+ * sub-issue edges. The read side has existed since v1
6
+ * (`sub-issues.js` → `getNativeSubIssues`, and the three-strategy
7
+ * aggregator in `issues.js` → `getSubTickets`); this is the missing write.
8
+ *
9
+ * API surface used:
10
+ * Read: GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
11
+ * Write: POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
12
+ * body: { "sub_issue_id": <integer db id of the CHILD issue> }
13
+ *
14
+ * **`sub_issue_id` is the child's database id, not its issue number.** They
15
+ * are different integers and both are plausible, so a mix-up does not throw
16
+ * — it silently links the wrong issue, or a nonexistent one. This mirrors
17
+ * `blocked-by-add.js`, whose `issue_id` carries the same trap.
18
+ *
19
+ * Contract (deliberately identical to `blocked-by-add.js`):
20
+ * - **Idempotent** — reads existing edges first; only POSTs missing ones.
21
+ * - **Non-fatal** — catches all errors per edge, warns, and continues.
22
+ * The function never throws; failures are returned in the summary.
23
+ * The Epic body's checklist is the durable mirror, so a lost edge
24
+ * degrades discoverability rather than losing the child.
25
+ * - **No-op on empty input.**
26
+ */
27
+
28
+ import { Logger } from '../../lib/Logger.js';
29
+ import { concurrentMap } from '../../lib/util/concurrent-map.js';
30
+ import { paginateRest } from './request-helpers.js';
31
+
32
+ /**
33
+ * Bounded concurrency for the sub-issue round-trips. Matches the
34
+ * dependency-edge writer's cap: modest enough for GitHub's secondary rate
35
+ * limits while collapsing wall-clock from `sum(round-trips)` toward
36
+ * `sum(round-trips) / concurrency`.
37
+ */
38
+ const EDGE_CONCURRENCY = 5;
39
+
40
+ /**
41
+ * Fetch the database ids of a parent's existing sub-issues, **paginated to
42
+ * exhaustion**.
43
+ *
44
+ * This read is the idempotency check: an edge it fails to see is re-POSTed.
45
+ * Reading only the first page would therefore make the writer non-idempotent
46
+ * past the page boundary — the same defect Story #5046 fixed in
47
+ * `blocked-by-add.js`.
48
+ *
49
+ * Returns `[]` on any error so the caller falls back to posting the full
50
+ * set. Worst case is a duplicate POST, which GitHub rejects harmlessly and
51
+ * the per-edge catch absorbs.
52
+ *
53
+ * @param {{ gh: object, owner: string, repo: string, issueNumber: number, paginate?: Function }} opts
54
+ * @returns {Promise<number[]>} Database ids of the parent's current children.
55
+ */
56
+ async function fetchExistingSubIssueIds({
57
+ gh,
58
+ owner,
59
+ repo,
60
+ issueNumber,
61
+ paginate = paginateRest,
62
+ }) {
63
+ try {
64
+ const data = await paginate(
65
+ gh,
66
+ `/repos/${owner}/${repo}/issues/${issueNumber}/sub_issues`,
67
+ { label: `[sub-issue-add] sub_issues #${issueNumber}` },
68
+ );
69
+ if (!Array.isArray(data)) return [];
70
+ return data.map((item) => item?.id).filter((id) => typeof id === 'number');
71
+ } catch (err) {
72
+ Logger.warn(
73
+ `[sub-issue-add] Could not fetch existing sub-issues for #${issueNumber}: ${err.message}`,
74
+ );
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Link a set of child issues to one parent as native sub-issues.
81
+ *
82
+ * For each entry in `childInternalIds`, checks whether the edge already
83
+ * exists and POSTs only the missing ones. Every individual POST failure is
84
+ * caught, logged and counted — the function never throws.
85
+ *
86
+ * @param {{
87
+ * gh: object,
88
+ * owner: string,
89
+ * repo: string,
90
+ * issueNumber: number,
91
+ * childInternalIds: number[],
92
+ * paginate?: Function,
93
+ * }} opts
94
+ * @returns {Promise<{ added: number, skipped: number, failed: number }>}
95
+ */
96
+ export async function addSubIssueEdges({
97
+ gh,
98
+ owner,
99
+ repo,
100
+ issueNumber,
101
+ childInternalIds,
102
+ paginate = paginateRest,
103
+ }) {
104
+ const ids = Array.isArray(childInternalIds) ? childInternalIds : [];
105
+ if (ids.length === 0) return { added: 0, skipped: 0, failed: 0 };
106
+
107
+ const existing = await fetchExistingSubIssueIds({
108
+ gh,
109
+ owner,
110
+ repo,
111
+ issueNumber,
112
+ paginate,
113
+ });
114
+ const existingSet = new Set(existing);
115
+
116
+ // Partition up front so the skip count is deterministic regardless of the
117
+ // concurrent POST dispatch order.
118
+ const missing = ids.filter((id) => !existingSet.has(id));
119
+ const skipped = ids.length - missing.length;
120
+
121
+ const perEdge = await concurrentMap(
122
+ missing,
123
+ async (childId) => {
124
+ try {
125
+ await gh.api({
126
+ method: 'POST',
127
+ endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/sub_issues`,
128
+ body: { sub_issue_id: childId },
129
+ });
130
+ return { added: 1, failed: 0 };
131
+ } catch (err) {
132
+ Logger.warn(
133
+ `[sub-issue-add] Failed to link child(id=${childId}) under #${issueNumber}: ${err.message}`,
134
+ );
135
+ return { added: 0, failed: 1 };
136
+ }
137
+ },
138
+ { concurrency: EDGE_CONCURRENCY },
139
+ );
140
+
141
+ let added = 0;
142
+ let failed = 0;
143
+ for (const r of perEdge) {
144
+ added += r.added;
145
+ failed += r.failed;
146
+ }
147
+
148
+ return { added, skipped, failed };
149
+ }
150
+
151
+ /**
152
+ * Link child Stories to a container Epic, resolving each child's **database
153
+ * id** from its issue number via the injected `getTicket` hook.
154
+ *
155
+ * Callers hold issue numbers (that is what `plan-persist` creates and what
156
+ * an operator types); the API wants database ids. Doing the translation here
157
+ * keeps that trap in one place instead of at every call site.
158
+ *
159
+ * Never throws: a child whose id cannot be resolved is counted as failed and
160
+ * the remaining edges still go out.
161
+ *
162
+ * @param {{
163
+ * epicNumber: number,
164
+ * childIssueNumbers: number[],
165
+ * getTicket: (issueNumber: number) => Promise<{ internalId: number }>,
166
+ * owner: string,
167
+ * repo: string,
168
+ * gh: object,
169
+ * paginate?: Function,
170
+ * }} opts
171
+ * @returns {Promise<{ added: number, skipped: number, failed: number }>}
172
+ */
173
+ export async function linkStoriesToEpic({
174
+ epicNumber,
175
+ childIssueNumbers,
176
+ getTicket,
177
+ owner,
178
+ repo,
179
+ gh,
180
+ paginate = paginateRest,
181
+ }) {
182
+ const numbers = Array.isArray(childIssueNumbers) ? childIssueNumbers : [];
183
+ if (numbers.length === 0) return { added: 0, skipped: 0, failed: 0 };
184
+
185
+ let failed = 0;
186
+ const childInternalIds = [];
187
+
188
+ for (const childNumber of numbers) {
189
+ try {
190
+ const ticket = await getTicket(childNumber);
191
+ const internalId = ticket?.internalId;
192
+ if (typeof internalId !== 'number') {
193
+ Logger.warn(
194
+ `[sub-issue-add] Child #${childNumber} has no resolvable database id; skipping edge.`,
195
+ );
196
+ failed++;
197
+ continue;
198
+ }
199
+ childInternalIds.push(internalId);
200
+ } catch (err) {
201
+ Logger.warn(
202
+ `[sub-issue-add] Could not resolve child #${childNumber}: ${err.message}`,
203
+ );
204
+ failed++;
205
+ }
206
+ }
207
+
208
+ const summary = await addSubIssueEdges({
209
+ gh,
210
+ owner,
211
+ repo,
212
+ issueNumber: epicNumber,
213
+ childInternalIds,
214
+ paginate,
215
+ });
216
+
217
+ return { ...summary, failed: summary.failed + failed };
218
+ }