mandrel 2.39.0 → 2.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +8 -5
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/instructions.md +22 -22
- package/.agents/rules/api-conventions.md +43 -7
- package/.agents/rules/ci-remediation.md +3 -14
- package/.agents/rules/gherkin-standards.md +21 -6
- package/.agents/rules/git-conventions.md +6 -5
- package/.agents/rules/security-baseline.md +6 -7
- package/.agents/rules/testing-standards.md +75 -198
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/install-matrix-assert.js +2 -2
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +45 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
- package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
- package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +202 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
- package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +324 -0
- package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +30 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
- package/.agents/scripts/plan-persist.js +98 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
- package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
- package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
- package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
- package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
- package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
- package/.agents/skills/core/security-and-hardening/reference.md +0 -70
- package/.agents/skills/skills.index.json +10 -70
- package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
- package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/git-cleanup.md +3 -2
- package/.agents/workflows/git-deliver.md +3 -2
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +109 -1
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +61 -55
- package/.agents/workflows/qa-assist.md +20 -17
- package/.agents/workflows/qa-explore.md +30 -29
- package/.agents/workflows/qa-run.md +2 -1
- package/docs/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/.agents/rules/changelog-style.md +0 -180
- package/.agents/rules/shell-conventions.md +0 -61
- package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
- package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
- package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
- package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
- package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
- package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
- package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
- package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
|
|
35
35
|
import { buildComplexitySignals } from './complexity-gate.js';
|
|
36
36
|
import { parseDeliverySlicingTable } from './consolidation-precondition.js';
|
|
37
|
+
import { findDependencyCandidates } from './dependency-candidates.js';
|
|
37
38
|
import { buildDocsDigest } from './docs-digest.js';
|
|
39
|
+
import { findOpenEpicCandidates } from './epic-candidates.js';
|
|
38
40
|
import { buildAuthoringContext } from './planning/authoring-context.js';
|
|
39
41
|
import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
|
|
40
42
|
|
|
@@ -849,11 +851,12 @@ async function searchStoryDuplicates({
|
|
|
849
851
|
}
|
|
850
852
|
|
|
851
853
|
/**
|
|
852
|
-
* Gather the
|
|
853
|
-
*
|
|
854
|
-
*
|
|
854
|
+
* Gather the independent envelope inputs — the open-Story duplicate search,
|
|
855
|
+
* the folded authoring context, the inline docs digest, and (Story #5155) the
|
|
856
|
+
* open-Epic and cross-plan-dependency candidate lists — under bounded
|
|
857
|
+
* concurrency (Story #4952).
|
|
855
858
|
*
|
|
856
|
-
* None of
|
|
859
|
+
* None of them reads a value the others produce, so the result is a pure
|
|
857
860
|
* function of `seed` and the injected config: the assembled envelope is
|
|
858
861
|
* **byte-identical** to the serial build for the same inputs, whichever order
|
|
859
862
|
* the three happen to settle in. `concurrentMap` preserves input order, so the
|
|
@@ -875,6 +878,8 @@ async function searchStoryDuplicates({
|
|
|
875
878
|
* @returns {Promise<{
|
|
876
879
|
* duplicates: Array<object>,
|
|
877
880
|
* authoring: object,
|
|
881
|
+
* epicCandidates: Array<object>,
|
|
882
|
+
* dependencyCandidates: Array<object>,
|
|
878
883
|
* docsContext: { mode: 'digest-inline', digest: string }|null,
|
|
879
884
|
* }>}
|
|
880
885
|
*/
|
|
@@ -882,13 +887,20 @@ async function gatherEnvelopeInputs({
|
|
|
882
887
|
seed,
|
|
883
888
|
epicTitle,
|
|
884
889
|
excludeIds = [],
|
|
890
|
+
predictedPaths = [],
|
|
885
891
|
provider,
|
|
886
892
|
config,
|
|
887
893
|
settings,
|
|
888
894
|
cwd,
|
|
889
895
|
}) {
|
|
890
896
|
const paths = settings?.paths ?? {};
|
|
891
|
-
const [
|
|
897
|
+
const [
|
|
898
|
+
duplicates,
|
|
899
|
+
authoring,
|
|
900
|
+
inlineDigest,
|
|
901
|
+
epicCandidates,
|
|
902
|
+
dependencyCandidates,
|
|
903
|
+
] = await concurrentMap(
|
|
892
904
|
[
|
|
893
905
|
() => searchStoryDuplicates({ seed, provider, config, excludeIds }),
|
|
894
906
|
() =>
|
|
@@ -907,6 +919,25 @@ async function gatherEnvelopeInputs({
|
|
|
907
919
|
docsContextFiles: settings?.docsContextFiles,
|
|
908
920
|
docsRoot: paths.docsRoot,
|
|
909
921
|
}),
|
|
922
|
+
// Story #5155 — the two cross-plan lookups. Both are advisory triage
|
|
923
|
+
// lists offered at Gate #3, independent of every other gather and of
|
|
924
|
+
// each other, so they join the same bounded fan-out rather than adding
|
|
925
|
+
// two more serial round-trips to the operator's wait.
|
|
926
|
+
() =>
|
|
927
|
+
findOpenEpicCandidates({
|
|
928
|
+
seed,
|
|
929
|
+
provider,
|
|
930
|
+
owner: config.github?.owner,
|
|
931
|
+
repo: config.github?.repo,
|
|
932
|
+
}),
|
|
933
|
+
() =>
|
|
934
|
+
findDependencyCandidates({
|
|
935
|
+
predictedPaths,
|
|
936
|
+
provider,
|
|
937
|
+
owner: config.github?.owner,
|
|
938
|
+
repo: config.github?.repo,
|
|
939
|
+
excludeIds,
|
|
940
|
+
}),
|
|
910
941
|
],
|
|
911
942
|
(gather) => gather(),
|
|
912
943
|
// The per-mode envelope gathers (Story #4952): the duplicate search, the
|
|
@@ -919,6 +950,8 @@ async function gatherEnvelopeInputs({
|
|
|
919
950
|
return {
|
|
920
951
|
duplicates,
|
|
921
952
|
authoring,
|
|
953
|
+
epicCandidates,
|
|
954
|
+
dependencyCandidates,
|
|
922
955
|
docsContext:
|
|
923
956
|
inlineDigest == null
|
|
924
957
|
? null
|
|
@@ -949,20 +982,42 @@ async function buildSeedFileModeEnvelope({
|
|
|
949
982
|
);
|
|
950
983
|
}
|
|
951
984
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
985
|
+
const limits = getLimits(config);
|
|
986
|
+
const heuristics = resolveRiskHeuristics(config);
|
|
987
|
+
|
|
988
|
+
// Hoisted above the gather (Story #5155): the dependency-candidate lookup
|
|
989
|
+
// intersects against `predictedPaths`, so the signals have to exist before
|
|
990
|
+
// the fan-out starts. `buildComplexitySignals` is synchronous and reads
|
|
991
|
+
// nothing the gather produces, so hoisting it changes cost, not output.
|
|
992
|
+
const complexitySignals = withAdvisorySignals(
|
|
993
|
+
buildComplexitySignals({
|
|
994
|
+
seedText: content,
|
|
995
|
+
config,
|
|
996
|
+
riskHeuristics: heuristics,
|
|
997
|
+
cwd,
|
|
998
|
+
}),
|
|
999
|
+
{ config, cwd },
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
// Dup search, the authoring-context fold grounded in the seed prose, the
|
|
1003
|
+
// inline docs digest and the two cross-plan candidate lists are independent
|
|
1004
|
+
// — gathered concurrently (Story #4952, Story #5155).
|
|
1005
|
+
const {
|
|
1006
|
+
duplicates,
|
|
1007
|
+
authoring,
|
|
1008
|
+
docsContext,
|
|
1009
|
+
epicCandidates,
|
|
1010
|
+
dependencyCandidates,
|
|
1011
|
+
} = await gatherEnvelopeInputs({
|
|
955
1012
|
seed: content,
|
|
956
1013
|
epicTitle: seedFilePath ?? 'seed',
|
|
1014
|
+
predictedPaths: complexitySignals.predictedPaths,
|
|
957
1015
|
provider,
|
|
958
1016
|
config,
|
|
959
1017
|
settings,
|
|
960
1018
|
cwd,
|
|
961
1019
|
});
|
|
962
1020
|
|
|
963
|
-
const limits = getLimits(config);
|
|
964
|
-
const heuristics = resolveRiskHeuristics(config);
|
|
965
|
-
|
|
966
1021
|
return {
|
|
967
1022
|
mode: modeLabel,
|
|
968
1023
|
seed: { path: seedFilePath ?? null, content },
|
|
@@ -972,16 +1027,10 @@ async function buildSeedFileModeEnvelope({
|
|
|
972
1027
|
// `deliverLightSuggestion` is the advisory plan-side routing handshake
|
|
973
1028
|
// (Story #4741 AC-6) and `uiSurface` the advisory /prototype offer —
|
|
974
1029
|
// neither is ever an automatic reroute.
|
|
975
|
-
complexitySignals
|
|
976
|
-
buildComplexitySignals({
|
|
977
|
-
seedText: content,
|
|
978
|
-
config,
|
|
979
|
-
riskHeuristics: heuristics,
|
|
980
|
-
cwd,
|
|
981
|
-
}),
|
|
982
|
-
{ config, cwd },
|
|
983
|
-
),
|
|
1030
|
+
complexitySignals,
|
|
984
1031
|
duplicates,
|
|
1032
|
+
epicCandidates,
|
|
1033
|
+
dependencyCandidates,
|
|
985
1034
|
docsContext,
|
|
986
1035
|
bddRunner: authoring.bddRunner,
|
|
987
1036
|
bddScenarios: authoring.bddScenarios,
|
|
@@ -1095,36 +1144,48 @@ async function buildTicketsModeEnvelope({
|
|
|
1095
1144
|
.map((t) => `# ${t.title}\n\n${t.body}`)
|
|
1096
1145
|
.join('\n\n---\n\n');
|
|
1097
1146
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1147
|
+
const limits = getLimits(config);
|
|
1148
|
+
const heuristics = resolveRiskHeuristics(config);
|
|
1149
|
+
|
|
1150
|
+
// Hoisted for the same reason as seed-file mode (Story #5155).
|
|
1151
|
+
const complexitySignals = withAdvisorySignals(
|
|
1152
|
+
buildComplexitySignals({
|
|
1153
|
+
seedText: seed,
|
|
1154
|
+
config,
|
|
1155
|
+
riskHeuristics: heuristics,
|
|
1156
|
+
cwd,
|
|
1157
|
+
}),
|
|
1158
|
+
{ config, cwd },
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// Same independent gathers as seed-file mode, concurrent under the same
|
|
1162
|
+
// bound (Story #4952); only the source-ticket hydration above is a genuine
|
|
1163
|
+
// data dependency, because `seed` is derived from it.
|
|
1164
|
+
const {
|
|
1165
|
+
duplicates,
|
|
1166
|
+
authoring,
|
|
1167
|
+
docsContext,
|
|
1168
|
+
epicCandidates,
|
|
1169
|
+
dependencyCandidates,
|
|
1170
|
+
} = await gatherEnvelopeInputs({
|
|
1102
1171
|
seed,
|
|
1103
1172
|
epicTitle: sourceTickets[0]?.title ?? 'tickets',
|
|
1104
1173
|
excludeIds: ticketIds,
|
|
1174
|
+
predictedPaths: complexitySignals.predictedPaths,
|
|
1105
1175
|
provider,
|
|
1106
1176
|
config,
|
|
1107
1177
|
settings,
|
|
1108
1178
|
cwd,
|
|
1109
1179
|
});
|
|
1110
1180
|
|
|
1111
|
-
const limits = getLimits(config);
|
|
1112
|
-
const heuristics = resolveRiskHeuristics(config);
|
|
1113
|
-
|
|
1114
1181
|
return {
|
|
1115
1182
|
mode: 'tickets',
|
|
1116
1183
|
sourceTickets,
|
|
1117
1184
|
seed: { text: seed, path: null },
|
|
1118
|
-
complexitySignals
|
|
1119
|
-
buildComplexitySignals({
|
|
1120
|
-
seedText: seed,
|
|
1121
|
-
config,
|
|
1122
|
-
riskHeuristics: heuristics,
|
|
1123
|
-
cwd,
|
|
1124
|
-
}),
|
|
1125
|
-
{ config, cwd },
|
|
1126
|
-
),
|
|
1185
|
+
complexitySignals,
|
|
1127
1186
|
duplicates,
|
|
1187
|
+
epicCandidates,
|
|
1188
|
+
dependencyCandidates,
|
|
1128
1189
|
docsContext,
|
|
1129
1190
|
bddRunner: authoring.bddRunner,
|
|
1130
1191
|
bddScenarios: authoring.bddScenarios,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cross-plan-links.js — resolve every reference a plan makes to something
|
|
3
|
+
* outside itself, before anything is written.
|
|
4
|
+
*
|
|
5
|
+
* Story #5155. A plan can now point at two things it did not author: the
|
|
6
|
+
* container Epic it wants to join (`--epic <id>`) and the open Stories it must
|
|
7
|
+
* wait for (`depends_on: ["#<id>"]`). They are different features with
|
|
8
|
+
* different owners, but they share the one property that decides *when* they
|
|
9
|
+
* are checked, and that is what this module exists to express: both name live
|
|
10
|
+
* tracker state, so both are verified **before the first `createIssue`**, dry
|
|
11
|
+
* run included.
|
|
12
|
+
*
|
|
13
|
+
* The timing is the whole point. Either reference is free to fix while nothing
|
|
14
|
+
* has been written and expensive afterwards — an unresolvable blocker left on
|
|
15
|
+
* a live Story reads to the delivery engine as a permanent wedge rather than
|
|
16
|
+
* an error worth reporting, and a mistyped Epic id would leave the operator
|
|
17
|
+
* believing their Stories were filed somewhere they were not. Resolving them
|
|
18
|
+
* together, at one call site, is what keeps a later edit from quietly moving
|
|
19
|
+
* one of them after the creates.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/orchestration/plan-persist/cross-plan-links
|
|
22
|
+
* @see Story #5155
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { adoptContainerEpic, resolveAdoptionTarget } from './epic-adoption.js';
|
|
26
|
+
import { createContainerEpic } from './epic-ops.js';
|
|
27
|
+
import { assertExternalDependenciesResolvable } from './external-deps.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify a plan's outward references and return the Epic it adopts.
|
|
31
|
+
*
|
|
32
|
+
* @param {{
|
|
33
|
+
* provider: object,
|
|
34
|
+
* stories: Array<{ slug: string, depends_on?: string[] }>,
|
|
35
|
+
* epicId: number|null,
|
|
36
|
+
* }} args
|
|
37
|
+
* @returns {Promise<{ id: number, title: string, body: string }|null>}
|
|
38
|
+
* The resolved adoption target, or `null` when none was requested.
|
|
39
|
+
* @throws {Error} When a `#<id>` blocker or the named Epic cannot be used.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveCrossPlanLinks({ provider, stories, epicId }) {
|
|
42
|
+
await assertExternalDependenciesResolvable({ provider, stories });
|
|
43
|
+
return resolveAdoptionTarget({ provider, epicId });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve this run's container Epic — adopted or newly created.
|
|
48
|
+
*
|
|
49
|
+
* The one entry point `run-plan-persist` calls, so the engine holds a single
|
|
50
|
+
* statement rather than a branch it has to keep straight: which of the two
|
|
51
|
+
* paths applies is decided by whether an adoption target was resolved before
|
|
52
|
+
* the creates, and the two have opposite failure postures that are easy to
|
|
53
|
+
* apply to the wrong one when the choice is inlined at the call site.
|
|
54
|
+
*
|
|
55
|
+
* @param {{
|
|
56
|
+
* provider: object,
|
|
57
|
+
* adoptionTarget: { id: number, title: string, body: string }|null,
|
|
58
|
+
* epic: { title: string, goal: string }|null,
|
|
59
|
+
* created: Array<{ id: number }>,
|
|
60
|
+
* opts?: { dryRun?: boolean },
|
|
61
|
+
* }} args
|
|
62
|
+
* @returns {Promise<object|null>} `null` when this run has no container.
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveContainerEpic({
|
|
65
|
+
provider,
|
|
66
|
+
adoptionTarget,
|
|
67
|
+
epic,
|
|
68
|
+
created,
|
|
69
|
+
opts = {},
|
|
70
|
+
}) {
|
|
71
|
+
if (adoptionTarget) {
|
|
72
|
+
return adoptContainerEpic({
|
|
73
|
+
provider,
|
|
74
|
+
target: adoptionTarget,
|
|
75
|
+
created,
|
|
76
|
+
opts,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return createContainerEpic({ provider, epic, created, opts });
|
|
80
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-adoption.js — join the Stories of this persist run to an Epic that
|
|
3
|
+
* already exists.
|
|
4
|
+
*
|
|
5
|
+
* Story #5155. `epic-ops.js` opens a *new* container and, on a resumed run,
|
|
6
|
+
* re-adopts the one carrying its exact fingerprint. This module covers the
|
|
7
|
+
* case neither does: an operator pointing a fresh plan at an Epic an earlier
|
|
8
|
+
* plan opened, with a different cohort and no fingerprint in common.
|
|
9
|
+
*
|
|
10
|
+
* **The posture is deliberately stricter than creation's.** Creation degrades
|
|
11
|
+
* — an unensurable label just skips the container, because the Stories are the
|
|
12
|
+
* part that matters and a missing Epic costs only tidiness. Adoption cannot
|
|
13
|
+
* degrade the same way: the operator named a specific id, so silently not
|
|
14
|
+
* adopting it would leave them believing their Stories were filed somewhere
|
|
15
|
+
* they were not. A bad target is therefore a **hard error, raised before the
|
|
16
|
+
* first Story is created** (dry run included), when nothing has been written
|
|
17
|
+
* and the fix is free. Once the Stories exist, the posture flips to creation's
|
|
18
|
+
* — a failed checklist write or sub-issue edge warns, because by then refusing
|
|
19
|
+
* would strand live Stories over a cosmetic link.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/orchestration/plan-persist/epic-adoption
|
|
22
|
+
* @see Story #5155
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Logger } from '../../Logger.js';
|
|
26
|
+
import { TYPE_LABELS } from '../../label-constants.js';
|
|
27
|
+
import { appendEpicChildIds } from '../epic-checklist.js';
|
|
28
|
+
import { isEpicTicket } from '../epic-container.js';
|
|
29
|
+
import { mirrorSubIssueEdges } from './epic-ops.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve and validate the Epic an operator asked to adopt.
|
|
33
|
+
*
|
|
34
|
+
* Called **before any create**, so every refusal below costs the operator a
|
|
35
|
+
* re-run of a command that wrote nothing.
|
|
36
|
+
*
|
|
37
|
+
* A null/absent `epicId` is the ordinary "no adoption requested" case and
|
|
38
|
+
* resolves to `null` — only a *supplied* id can be wrong, and every wrong one
|
|
39
|
+
* throws.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ provider: object, epicId: number|null }} opts
|
|
42
|
+
* @returns {Promise<{ id: number, title: string, body: string }|null>}
|
|
43
|
+
* @throws {Error} When a supplied id is missing, closed, or not a container Epic.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveAdoptionTarget({ provider, epicId }) {
|
|
46
|
+
if (epicId === null || epicId === undefined) return null;
|
|
47
|
+
const id = Number(epicId);
|
|
48
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[plan-persist] --epic expects a positive issue id (got "${epicId}").`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (typeof provider?.getTicket !== 'function') {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'[plan-persist] provider exposes no getTicket — cannot verify the Epic to adopt.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let issue;
|
|
60
|
+
try {
|
|
61
|
+
issue = await provider.getTicket(id);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[plan-persist] --epic #${id} could not be read (${err?.message ?? err}). ` +
|
|
65
|
+
'Adoption needs an existing open container Epic.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!issue) {
|
|
69
|
+
throw new Error(`[plan-persist] --epic #${id} does not exist.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const state = String(issue.state ?? 'open').toLowerCase();
|
|
73
|
+
if (state !== 'open') {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[plan-persist] --epic #${id} is ${state}. A closed Epic is a finished body of ` +
|
|
76
|
+
'work and is never reopened by a plan — open a new container, or reopen it by hand first.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!isEpicTicket(issue)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`[plan-persist] --epic #${id} does not carry "${TYPE_LABELS.EPIC}" — it is not a ` +
|
|
82
|
+
'container Epic. Adopting an ordinary Story would file this plan under a work item.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
title: typeof issue.title === 'string' ? issue.title : '',
|
|
89
|
+
body: typeof issue.body === 'string' ? issue.body : '',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Link this run's Stories into an already-resolved Epic.
|
|
95
|
+
*
|
|
96
|
+
* Runs **after** the Stories exist, because both halves of the linkage need
|
|
97
|
+
* their real ids: the checklist embeds issue numbers and the sub-issue edges
|
|
98
|
+
* need database ids.
|
|
99
|
+
*
|
|
100
|
+
* @param {{
|
|
101
|
+
* provider: object,
|
|
102
|
+
* target: { id: number, title: string, body: string },
|
|
103
|
+
* created: Array<{ id: number }>,
|
|
104
|
+
* opts?: { dryRun?: boolean },
|
|
105
|
+
* }} args
|
|
106
|
+
* @returns {Promise<{
|
|
107
|
+
* id: number,
|
|
108
|
+
* title: string,
|
|
109
|
+
* childIds: number[],
|
|
110
|
+
* adopted: true,
|
|
111
|
+
* edges: { added: number, skipped: number, failed: number }|null,
|
|
112
|
+
* }|null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function adoptContainerEpic({
|
|
115
|
+
provider,
|
|
116
|
+
target,
|
|
117
|
+
created,
|
|
118
|
+
opts = {},
|
|
119
|
+
}) {
|
|
120
|
+
const { dryRun = false } = opts;
|
|
121
|
+
if (!target) return null;
|
|
122
|
+
|
|
123
|
+
const all = Array.isArray(created) ? created : [];
|
|
124
|
+
|
|
125
|
+
// Dry run reports the intent write-free. `created` carries negative
|
|
126
|
+
// placeholder ids there, so report them as-is rather than filtering to the
|
|
127
|
+
// positives and claiming an empty adoption.
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
return {
|
|
130
|
+
id: target.id,
|
|
131
|
+
title: target.title,
|
|
132
|
+
childIds: all.map((s) => s.id),
|
|
133
|
+
adopted: true,
|
|
134
|
+
edges: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const childIds = all
|
|
139
|
+
.map((s) => s.id)
|
|
140
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
141
|
+
if (childIds.length === 0) return null;
|
|
142
|
+
|
|
143
|
+
await appendChecklist({ provider, target, childIds });
|
|
144
|
+
const edges = await mirrorSubIssueEdges({
|
|
145
|
+
provider,
|
|
146
|
+
epicNumber: target.id,
|
|
147
|
+
childIds,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
Logger.info(
|
|
151
|
+
`[plan-persist] adopted container Epic #${target.id} — it now groups ` +
|
|
152
|
+
`${childIds.length} more Story(ies): /mandrel-deliver ${target.id}`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id: target.id,
|
|
157
|
+
title: target.title,
|
|
158
|
+
childIds,
|
|
159
|
+
adopted: true,
|
|
160
|
+
edges,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Write the appended checklist back to the Epic body.
|
|
166
|
+
*
|
|
167
|
+
* Non-fatal: the Stories are already live, and the native sub-issue edges
|
|
168
|
+
* written next are the other half of the linkage. Losing the checklist costs
|
|
169
|
+
* the body-only fallback path, not the grouping.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ provider: object, target: { id: number, body: string }, childIds: number[] }} opts
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async function appendChecklist({ provider, target, childIds }) {
|
|
175
|
+
if (typeof provider?.updateTicket !== 'function') {
|
|
176
|
+
Logger.warn(
|
|
177
|
+
'[plan-persist] provider exposes no updateTicket — the adopted Epic body was not ' +
|
|
178
|
+
'updated. The native sub-issue edges still record the grouping.',
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const next = appendEpicChildIds(target.body, childIds);
|
|
183
|
+
if (next === target.body) return;
|
|
184
|
+
try {
|
|
185
|
+
await provider.updateTicket(target.id, { body: next });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
Logger.warn(
|
|
188
|
+
`[plan-persist] could not update Epic #${target.id}'s checklist ` +
|
|
189
|
+
`(${err?.message ?? err}). The native sub-issue edges still record the grouping.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|