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.
- package/.agents/README.md +51 -11
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/instructions.md +17 -16
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/generate-skills-index.js +158 -75
- 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/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- 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/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/scripts/validate-skills.js +53 -66
- 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/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +28 -0
- 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
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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[]}
|
|
90
|
+
* @returns {string[]}
|
|
46
91
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|