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.
Files changed (76) hide show
  1. package/.agents/README.md +8 -5
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/instructions.md +22 -22
  5. package/.agents/rules/api-conventions.md +43 -7
  6. package/.agents/rules/ci-remediation.md +3 -14
  7. package/.agents/rules/gherkin-standards.md +21 -6
  8. package/.agents/rules/git-conventions.md +6 -5
  9. package/.agents/rules/security-baseline.md +6 -7
  10. package/.agents/rules/testing-standards.md +75 -198
  11. package/.agents/scripts/audit-to-stories.js +510 -66
  12. package/.agents/scripts/install-matrix-assert.js +2 -2
  13. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +45 -0
  14. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  15. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  16. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  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/dependency-candidates.js +139 -0
  20. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  21. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  22. package/.agents/scripts/lib/orchestration/epic-container.js +202 -0
  23. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  24. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  25. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  26. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  27. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +324 -0
  28. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  29. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +30 -0
  30. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  31. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  32. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  33. package/.agents/scripts/plan-persist.js +98 -1
  34. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  35. package/.agents/scripts/resolve-stories.js +42 -2
  36. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  37. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  38. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  39. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  40. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  41. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  42. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  43. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  44. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  45. package/.agents/skills/skills.index.json +10 -70
  46. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  47. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  48. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  49. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  50. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  51. package/.agents/workflows/audit-to-stories.md +85 -7
  52. package/.agents/workflows/git-cleanup.md +3 -2
  53. package/.agents/workflows/git-deliver.md +3 -2
  54. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  55. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  56. package/.agents/workflows/helpers/plan-reference.md +109 -1
  57. package/.agents/workflows/mandrel-deliver.md +47 -43
  58. package/.agents/workflows/mandrel-plan.md +61 -55
  59. package/.agents/workflows/qa-assist.md +20 -17
  60. package/.agents/workflows/qa-explore.md +30 -29
  61. package/.agents/workflows/qa-run.md +2 -1
  62. package/docs/CHANGELOG.md +30 -0
  63. package/package.json +1 -1
  64. package/.agents/rules/changelog-style.md +0 -180
  65. package/.agents/rules/shell-conventions.md +0 -61
  66. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  67. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  68. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  69. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  70. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  71. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  72. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  73. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  74. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  75. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  76. 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 three independent envelope inputs — the open-Story duplicate
853
- * search, the folded authoring context, and the inline docs digest under
854
- * bounded concurrency (Story #4952).
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 the three reads a value the others produce, so the result is a pure
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 [duplicates, authoring, inlineDigest] = await concurrentMap(
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
- // Dup search, the authoring-context fold grounded in the seed prose, and the
953
- // inline docs digest are independent — gathered concurrently (Story #4952).
954
- const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
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: withAdvisorySignals(
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
- // Same three independent gathers as seed-file mode, concurrent under the
1099
- // same bound (Story #4952); only the source-ticket hydration above is a
1100
- // genuine data dependency, because `seed` is derived from it.
1101
- const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
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: withAdvisorySignals(
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
+ }