mandrel 2.40.0 → 2.42.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 (90) hide show
  1. package/.agents/README.md +2 -2
  2. package/.agents/agents/story-worker.md +24 -14
  3. package/.agents/docs/agentrc-reference.json +7 -2
  4. package/.agents/docs/configuration.md +5 -2
  5. package/.agents/instructions.md +5 -6
  6. package/.agents/rules/api-conventions.md +43 -7
  7. package/.agents/rules/ci-remediation.md +3 -14
  8. package/.agents/rules/gherkin-standards.md +21 -6
  9. package/.agents/rules/git-conventions.md +6 -5
  10. package/.agents/rules/security-baseline.md +6 -7
  11. package/.agents/rules/testing-standards.md +75 -198
  12. package/.agents/schemas/agentrc.schema.json +17 -2
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +68 -3
  15. package/.agents/scripts/coverage-capture.js +25 -8
  16. package/.agents/scripts/install-matrix-assert.js +2 -2
  17. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  19. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  20. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  21. package/.agents/scripts/lib/close-validation/process.js +30 -1
  22. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  23. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  24. package/.agents/scripts/lib/config/quality.js +36 -21
  25. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  26. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  27. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  28. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  29. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  30. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  31. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  33. package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
  34. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  35. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  36. package/.agents/scripts/lib/orchestration/epic-container.js +18 -2
  37. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  38. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  39. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  40. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +5 -1
  41. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +14 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  46. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  47. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  48. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  49. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  50. package/.agents/scripts/plan-persist.js +60 -1
  51. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  52. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  53. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  54. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  55. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  56. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  57. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  58. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  59. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  60. package/.agents/skills/skills.index.json +10 -70
  61. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  62. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  63. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  64. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  65. package/.agents/workflows/git-cleanup.md +3 -2
  66. package/.agents/workflows/git-deliver.md +3 -2
  67. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  68. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  69. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  70. package/.agents/workflows/helpers/deliver-story.md +23 -21
  71. package/.agents/workflows/helpers/plan-reference.md +82 -2
  72. package/.agents/workflows/mandrel-plan.md +45 -45
  73. package/.agents/workflows/qa-assist.md +20 -17
  74. package/.agents/workflows/qa-explore.md +30 -29
  75. package/.agents/workflows/qa-run.md +2 -1
  76. package/docs/CHANGELOG.md +32 -0
  77. package/package.json +1 -1
  78. package/.agents/rules/changelog-style.md +0 -180
  79. package/.agents/rules/shell-conventions.md +0 -61
  80. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  81. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  82. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  83. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  84. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  85. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  86. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  87. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  88. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  89. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  90. package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
@@ -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
+ }
@@ -150,10 +150,14 @@ async function findExistingEpic({ provider, fingerprint }) {
150
150
  * `getSubTickets` reads it as a first-class child source. A lost edge costs
151
151
  * the GitHub UI's nesting, not the grouping itself.
152
152
  *
153
+ * Exported since Story #5155 so the adoption path (`epic-adoption.js`) links
154
+ * children exactly the way creation does — one mirroring rule, not two that
155
+ * drift.
156
+ *
153
157
  * @param {{ provider: object, epicNumber: number, childIds: number[] }} opts
154
158
  * @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
155
159
  */
156
- async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
160
+ export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
157
161
  if (
158
162
  typeof provider?.getDependencyWriteContext !== 'function' ||
159
163
  typeof provider?.getTicket !== 'function'
@@ -0,0 +1,164 @@
1
+ /**
2
+ * external-deps.js — `depends_on` entries that point outside this plan run.
3
+ *
4
+ * Story #5155. A `depends_on[]` entry has always been a **sibling slug**: a
5
+ * name resolvable only inside the `stories.json` being persisted. That makes
6
+ * every ordering edge intra-plan by construction, and leaves the cross-plan
7
+ * case — a new Story that must wait for an open Story from an earlier run —
8
+ * expressible only by hand-editing the issue body after persist.
9
+ *
10
+ * An entry of the form `#1234` is that missing case: an **external** blocker,
11
+ * already live on the tracker. The two forms are distinguished lexically and
12
+ * totally, so nothing has to guess:
13
+ *
14
+ * - `some-slug` → a sibling, resolved against this run's slug map;
15
+ * - `#1234` → an existing issue, resolved against the tracker.
16
+ *
17
+ * External refs are excluded from sibling ordering and cycle detection. They
18
+ * cannot participate in either: a Story already open is not scheduled by this
19
+ * run, so it has no position in the topological sort, and it cannot close a
20
+ * cycle back into a Story that does not exist yet. Treating them as siblings
21
+ * is what would break — the unknown-slug guard would reject every one.
22
+ *
23
+ * They are validated strictly, and **before any create**: an unresolvable
24
+ * blocker that surfaced after the fact would leave a live Story gated on
25
+ * something that can never satisfy it, which the delivery engine reads as a
26
+ * permanent wedge rather than an error.
27
+ *
28
+ * @module lib/orchestration/plan-persist/external-deps
29
+ * @see Story #5155
30
+ */
31
+
32
+ import { TYPE_LABELS } from '../../label-constants.js';
33
+
34
+ /** A `depends_on` entry naming an existing issue: `#` followed by digits. */
35
+ const EXTERNAL_REF_RE = /^#(\d+)$/;
36
+
37
+ /**
38
+ * Is this `depends_on` entry an external issue reference?
39
+ *
40
+ * @param {unknown} entry
41
+ * @returns {boolean}
42
+ */
43
+ export function isExternalDependencyRef(entry) {
44
+ return typeof entry === 'string' && EXTERNAL_REF_RE.test(entry.trim());
45
+ }
46
+
47
+ /**
48
+ * The issue number an external ref names, or `null` for a sibling slug.
49
+ *
50
+ * @param {unknown} entry
51
+ * @returns {number|null}
52
+ */
53
+ export function externalDependencyId(entry) {
54
+ if (typeof entry !== 'string') return null;
55
+ const match = entry.trim().match(EXTERNAL_REF_RE);
56
+ if (!match) return null;
57
+ const id = Number.parseInt(match[1], 10);
58
+ return Number.isInteger(id) && id > 0 ? id : null;
59
+ }
60
+
61
+ /**
62
+ * Every distinct external id declared across a plan's Stories, in first-seen
63
+ * order.
64
+ *
65
+ * @param {Array<{ depends_on?: string[] }>} stories
66
+ * @returns {number[]}
67
+ */
68
+ export function collectExternalDependencyIds(stories) {
69
+ const seen = new Set();
70
+ const out = [];
71
+ for (const story of Array.isArray(stories) ? stories : []) {
72
+ for (const entry of story?.depends_on ?? []) {
73
+ const id = externalDependencyId(entry);
74
+ if (id === null || seen.has(id)) continue;
75
+ seen.add(id);
76
+ out.push(id);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Normalize an issue's labels to plain strings.
84
+ *
85
+ * @param {unknown} raw
86
+ * @returns {string[]}
87
+ */
88
+ function labelNames(raw) {
89
+ if (!Array.isArray(raw)) return [];
90
+ return raw
91
+ .map((l) => (typeof l === 'string' ? l : l?.name))
92
+ .filter((n) => typeof n === 'string');
93
+ }
94
+
95
+ /**
96
+ * Explain why one external blocker is unusable, or `null` when it is fine.
97
+ *
98
+ * @param {number} id
99
+ * @param {object|null} issue
100
+ * @returns {string|null}
101
+ */
102
+ function rejectionReason(id, issue) {
103
+ if (!issue) return `#${id} does not exist`;
104
+ const state = String(issue.state ?? 'open').toLowerCase();
105
+ if (state !== 'open') {
106
+ return `#${id} is ${state} — a landed Story cannot gate new work, so the edge would never lift`;
107
+ }
108
+ const labels = labelNames(issue.labels);
109
+ if (labels.includes(TYPE_LABELS.EPIC)) {
110
+ return `#${id} is a container Epic — Epics are never delivered, so nothing would ever satisfy the edge`;
111
+ }
112
+ if (!labels.includes(TYPE_LABELS.STORY)) {
113
+ return `#${id} is not a ${TYPE_LABELS.STORY} — only a Story can be delivered and thereby unblock this one`;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Verify every external `depends_on` ref resolves to an open Story.
120
+ *
121
+ * Hard-errors listing **every** bad ref rather than the first, so an operator
122
+ * fixing a plan sees the whole set in one pass.
123
+ *
124
+ * @param {{ provider: object, stories: Array<{ slug: string, depends_on?: string[] }> }} args
125
+ * @returns {Promise<number[]>} The validated external ids (possibly empty).
126
+ * @throws {Error} When any ref is missing, closed, an Epic, or not a Story.
127
+ */
128
+ export async function assertExternalDependenciesResolvable({
129
+ provider,
130
+ stories,
131
+ }) {
132
+ const ids = collectExternalDependencyIds(stories);
133
+ if (ids.length === 0) return [];
134
+
135
+ if (typeof provider?.getTicket !== 'function') {
136
+ throw new Error(
137
+ '[plan-persist] provider exposes no getTicket — cannot verify the external ' +
138
+ `depends_on reference(s): ${ids.map((i) => `#${i}`).join(', ')}.`,
139
+ );
140
+ }
141
+
142
+ const problems = [];
143
+ for (const id of ids) {
144
+ let issue = null;
145
+ try {
146
+ issue = await provider.getTicket(id);
147
+ } catch (err) {
148
+ problems.push(`#${id} could not be read (${err?.message ?? err})`);
149
+ continue;
150
+ }
151
+ const reason = rejectionReason(id, issue);
152
+ if (reason) problems.push(reason);
153
+ }
154
+
155
+ if (problems.length > 0) {
156
+ throw new Error(
157
+ `[plan-persist] ${problems.length} external depends_on reference(s) cannot gate ` +
158
+ `this plan:\n - ${problems.join('\n - ')}\n\nEvery "#<id>" entry must name an ` +
159
+ `open ${TYPE_LABELS.STORY}. Drop the entry, or point it at a Story that is still open.`,
160
+ );
161
+ }
162
+
163
+ return ids;
164
+ }
@@ -68,7 +68,10 @@ import {
68
68
  renderHardConflictError,
69
69
  } from '../ticket-validator-conflicts.js';
70
70
  import { upsertStructuredComment } from '../ticketing.js';
71
- import { createContainerEpic } from './epic-ops.js';
71
+ import {
72
+ resolveContainerEpic,
73
+ resolveCrossPlanLinks,
74
+ } from './cross-plan-links.js';
72
75
  import {
73
76
  enforceFanOutGate,
74
77
  surfaceSoftConflictFindings,
@@ -751,6 +754,14 @@ export async function runPlanPersist({
751
754
  });
752
755
  await enforceReachability(reachability, config);
753
756
 
757
+ // Story #5155 — the plan's outward references (`--epic <id>`, and any
758
+ // `#<id>` blocker) resolve BEFORE the first create, dry run included.
759
+ const adoptionTarget = await resolveCrossPlanLinks({
760
+ provider,
761
+ stories: rawStories,
762
+ epicId: opts.adoptEpicId ?? null,
763
+ });
764
+
754
765
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
755
766
  const { stories } = assemblePlanStories(rawStories, {
756
767
  sharedSpec: techSpecContent,
@@ -850,8 +861,9 @@ export async function runPlanPersist({
850
861
  // database ids, neither of which exists until the Stories are live. It is
851
862
  // never load-bearing, so a failure here degrades to "no container" and the
852
863
  // Stories still deliver by id.
853
- const containerEpic = await createContainerEpic({
864
+ const containerEpic = await resolveContainerEpic({
854
865
  provider,
866
+ adoptionTarget,
855
867
  epic,
856
868
  created,
857
869
  opts: { dryRun },
@@ -34,6 +34,10 @@ import {
34
34
  } from '../../util/concurrent-map.js';
35
35
  import { assertSpecWithinBudget } from '../spec-spill.js';
36
36
  import { assertAcceptancePartition } from '../split-policy-validator.js';
37
+ import {
38
+ externalDependencyId,
39
+ isExternalDependencyRef,
40
+ } from './external-deps.js';
37
41
  import {
38
42
  assertSupersedePartition,
39
43
  normalizeSupersedes,
@@ -557,7 +561,9 @@ function orderStoriesByDependencies(stories) {
557
561
  const list = Array.isArray(stories) ? stories : [];
558
562
  const known = new Set(list.map((story) => story.slug));
559
563
  for (const story of list) {
560
- const unknown = story.depends_on.filter((slug) => !known.has(slug));
564
+ const unknown = story.depends_on.filter(
565
+ (slug) => !isExternalDependencyRef(slug) && !known.has(slug),
566
+ );
561
567
  if (unknown.length > 0) {
562
568
  throw new Error(
563
569
  `[plan-persist] Story "${story.slug}" depends on unknown sibling(s): ${unknown.join(', ')}`,
@@ -568,8 +574,13 @@ function orderStoriesByDependencies(stories) {
568
574
  const scheduled = new Set();
569
575
  const pending = [...list];
570
576
  while (pending.length > 0) {
577
+ // External refs gate delivery, never creation order: the blocker is
578
+ // already live, so it can never become "scheduled" in this run and would
579
+ // otherwise wedge the sort into a false cycle (Story #5155).
571
580
  const index = pending.findIndex((story) =>
572
- story.depends_on.every((slug) => scheduled.has(slug)),
581
+ story.depends_on
582
+ .filter((slug) => !isExternalDependencyRef(slug))
583
+ .every((slug) => scheduled.has(slug)),
573
584
  );
574
585
  if (index === -1) {
575
586
  throw new Error(
@@ -720,8 +731,8 @@ function warnOnDivergentSameTitleStory(story, idsByTitle) {
720
731
  * @returns {string}
721
732
  */
722
733
  function renderStoryBodyForCreate(story, idBySlug) {
723
- const dependencyRefs = story.depends_on.map(
724
- (slug) => `#${idBySlug.get(slug)}`,
734
+ const dependencyRefs = story.depends_on.map((slug) =>
735
+ isExternalDependencyRef(slug) ? slug.trim() : `#${idBySlug.get(slug)}`,
725
736
  );
726
737
  let base = story.body;
727
738
  if (dependencyRefs.length > 0) {
@@ -798,12 +809,22 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
798
809
 
799
810
  try {
800
811
  const { gh, owner, repo } = provider.getDependencyWriteContext();
812
+ // `applyBlockedByDependencies` resolves every entry through this one map,
813
+ // so an external `#<id>` ref only needs an identity entry to be mirrored
814
+ // by the same code path as a sibling (Story #5155).
815
+ const slugToIssueNumber = Object.fromEntries(idBySlug);
816
+ for (const story of stories) {
817
+ for (const entry of story.depends_on) {
818
+ const externalId = externalDependencyId(entry);
819
+ if (externalId !== null) slugToIssueNumber[entry.trim()] = externalId;
820
+ }
821
+ }
801
822
  const summary = await applyBlockedByDependencies({
802
823
  stories: stories.map((story) => ({
803
824
  slug: story.slug,
804
825
  dependsOn: story.depends_on,
805
826
  })),
806
- slugToIssueNumber: Object.fromEntries(idBySlug),
827
+ slugToIssueNumber,
807
828
  getTicket: (issueNumber) => provider.getTicket(issueNumber),
808
829
  owner,
809
830
  repo,
@@ -27,8 +27,12 @@ import {
27
27
  const PHASE_ORDER = Object.freeze([
28
28
  'init',
29
29
  'wrong-tree-guard',
30
- 'close-validation',
30
+ // Story #5172 — base-sync now precedes close-validation, so the tree the
31
+ // gates validate is the tree the push sends. The order here is not
32
+ // decoration: it is how a failed terminal decides which gates had already
33
+ // cleared, so it MUST track `runPrePushPhases`.
31
34
  'base-sync',
35
+ 'close-validation',
32
36
  'push',
33
37
  'pull-request',
34
38
  'code-review',
@@ -45,6 +49,62 @@ const GATE_PHASES = Object.freeze([
45
49
  ['codeReview', 'code-review'],
46
50
  ]);
47
51
 
52
+ /**
53
+ * The names the split baselines gate registers under, mirrored from
54
+ * `BASELINES_GATE_NAMES` in `lib/close-validation/gates.js` (Story #5172).
55
+ *
56
+ * Deliberately a local copy rather than an import: several close suites
57
+ * replace that module wholesale via `t.mock.module`, and a named import here
58
+ * would fail to link against a mock that does not re-export the constant —
59
+ * turning an unrelated test's mock into a load error on the CLI's own entry
60
+ * path. `tests/close-validation-gates-enum.test.js` pins the two lists
61
+ * against each other so the copy cannot drift.
62
+ */
63
+ const BASELINES_ENTRY_NAMES = Object.freeze([
64
+ 'check-baselines-independent',
65
+ 'check-baselines-coverage',
66
+ ]);
67
+
68
+ /**
69
+ * Outcome for each split baselines entry on a run that died at `phase`.
70
+ *
71
+ * The two entries sit in ONE pipeline phase, so the phase walk alone cannot
72
+ * separate them — `failedGate` (tagged onto the error by the close-validation
73
+ * phase) is what names the entry that actually broke. Rules, in the module's
74
+ * house style of never claiming a pass it cannot prove:
75
+ * - validation skipped, or the run died before reaching it → both `skipped`.
76
+ * - the run cleared validation entirely → both `passed`.
77
+ * - the run died IN validation on the coverage-independent entry → that one
78
+ * `failed`, the coverage one `skipped` (it runs behind `coverage-capture`,
79
+ * which the failure pre-empted).
80
+ * - died on the coverage-consuming entry → that one `failed`, and the
81
+ * independent one `passed`: it is in the parallel partition that must go
82
+ * green before any serial gate starts.
83
+ * - died in validation on some other gate → both `skipped`; which of them
84
+ * had run is not knowable from the phase alone.
85
+ *
86
+ * @param {string} phase
87
+ * @param {{ skipValidation?: boolean, failedGate?: string|null }} args
88
+ * @returns {Record<string, 'passed'|'failed'|'skipped'>}
89
+ */
90
+ function baselinesGatesForFailedPhase(phase, { skipValidation, failedGate }) {
91
+ const [independent, coverage] = BASELINES_ENTRY_NAMES;
92
+ const both = (outcome) => ({ [independent]: outcome, [coverage]: outcome });
93
+ const failedAt = PHASE_ORDER.indexOf(phase);
94
+ const validationAt = PHASE_ORDER.indexOf('close-validation');
95
+ if (skipValidation || failedAt < 0 || failedAt < validationAt) {
96
+ return both('skipped');
97
+ }
98
+ if (failedAt > validationAt) return both('passed');
99
+ if (failedGate === independent) {
100
+ return { [independent]: 'failed', [coverage]: 'skipped' };
101
+ }
102
+ if (failedGate === coverage) {
103
+ return { [independent]: 'passed', [coverage]: 'failed' };
104
+ }
105
+ return both('skipped');
106
+ }
107
+
48
108
  /**
49
109
  * Report every gate's outcome for a run that died at `phase`.
50
110
  *
@@ -59,8 +119,14 @@ const GATE_PHASES = Object.freeze([
59
119
  * turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
60
120
  * not pass — it never ran).
61
121
  *
122
+ * Story #5172 — the reported set also carries the two split baselines
123
+ * entries under their own names, so a failed close says WHICH half of the
124
+ * baselines gate breached instead of a single generic verdict.
125
+ *
62
126
  * @param {string} phase The phase the run died in.
63
- * @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
127
+ * @param {{ skipValidation?: boolean, skipSync?: boolean, failedGate?: string|null }} args
128
+ * Parsed CLI args, plus the gate name tagged onto the error by the
129
+ * close-validation phase.
64
130
  * @returns {Record<string, 'passed'|'failed'|'skipped'>}
65
131
  */
66
132
  export function gatesForFailedPhase(phase, args = {}) {
@@ -73,7 +139,13 @@ export function gatesForFailedPhase(phase, args = {}) {
73
139
  else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
74
140
  else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
75
141
  }
76
- return gates;
142
+ return {
143
+ ...gates,
144
+ ...baselinesGatesForFailedPhase(phase, {
145
+ skipValidation: args.skipValidation,
146
+ failedGate: args.failedGate ?? null,
147
+ }),
148
+ };
77
149
  }
78
150
 
79
151
  /**
@@ -91,6 +163,10 @@ export function gatesForFailedPhase(phase, args = {}) {
91
163
  * holding the script had been reaped mid-run. On failure this returns null
92
164
  * and the caller rethrows the original.
93
165
  *
166
+ * `err.closeGate` — tagged by the close-validation phase — names the gate that
167
+ * died inside that phase, which is what lets the reported gates separate the
168
+ * two split baselines entries (Story #5172).
169
+ *
94
170
  * @param {unknown} err
95
171
  * @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
96
172
  * Parsed CLI args — the story id the envelope reports on, plus the skip
@@ -108,7 +184,10 @@ export function failedTerminalFor(err, args = {}) {
108
184
  storyId,
109
185
  status: 'failed',
110
186
  phase,
111
- gates: gatesForFailedPhase(phase, args),
187
+ gates: gatesForFailedPhase(phase, {
188
+ ...args,
189
+ failedGate: err?.closeGate ?? null,
190
+ }),
112
191
  failure: { reason: String(err?.message ?? err) },
113
192
  nextCommand: NEXT_COMMANDS.recover(storyId),
114
193
  elapsedSeconds: 0,
@@ -79,6 +79,10 @@ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
79
79
  * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
80
80
  * createGateLogSink?: typeof defaultCreateGateLogSink,
81
81
  * }} args
82
+ * @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
83
+ * outcomes keyed by gate name — the terminal envelope reports the split
84
+ * baselines entries from this (Story #5172). A failure throws instead, with
85
+ * `err.closeGate` naming the gate that died.
82
86
  */
83
87
  export async function runCloseValidationPhase({
84
88
  cwd,
@@ -144,17 +148,18 @@ export async function runCloseValidationPhase({
144
148
  // Story #4736 — one sink for both `log` seams (gate construction and gate
145
149
  // execution), so nothing in the chain can route around the artifact.
146
150
  const gateLog = createGateLogSink({ storyId, config });
151
+ const gateList = buildDefaultGates({
152
+ config,
153
+ baseBranch,
154
+ cwd: worktreePath || cwd,
155
+ log: gateLog.log,
156
+ });
147
157
  let validation;
148
158
  try {
149
159
  validation = await runCloseValidation({
150
160
  cwd,
151
161
  worktreePath,
152
- gates: buildDefaultGates({
153
- config,
154
- baseBranch,
155
- cwd: worktreePath || cwd,
156
- log: gateLog.log,
157
- }),
162
+ gates: gateList,
158
163
  log: gateLog.log,
159
164
  storyId,
160
165
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
@@ -182,10 +187,37 @@ export async function runCloseValidationPhase({
182
187
  // The evidence is the point on this path: replay the captured tail inline
183
188
  // rather than making the caller open a file to learn why close stopped.
184
189
  gateLog.replay();
185
- throw new Error(
190
+ const err = new Error(
186
191
  `[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
187
192
  (gate.hint ? ` ${gate.hint}` : ''),
188
193
  );
194
+ // Story #5172 — the phase tracker tags `closePhase`; this tags WHICH gate
195
+ // inside the phase died, so the failed terminal can name the split
196
+ // baselines entry rather than reporting a generic validation failure.
197
+ err.closeGate = gate.name;
198
+ throw err;
189
199
  }
190
200
  progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
201
+ return { gates: gateOutcomes(gateList, validation) };
202
+ }
203
+
204
+ /**
205
+ * Per-gate outcomes for a validation run that passed (Story #5172).
206
+ *
207
+ * Every registered gate passed unless the runner reported it skipped — an
208
+ * evidence short-circuit at unchanged HEAD, or a changed-file scope that
209
+ * matched nothing. `skipped` is the honest verdict for both: the gate did not
210
+ * run in THIS invocation, and the terminal schema's own contract is that a
211
+ * skipped gate is reported as skipped rather than quietly counted as a pass.
212
+ *
213
+ * @param {Array<{ name: string }>} gateList The gates this run registered.
214
+ * @param {{ skipped?: Array<{ gate: { name: string } }> }} validation
215
+ * @returns {Record<string, 'passed'|'skipped'>}
216
+ */
217
+ function gateOutcomes(gateList, validation) {
218
+ const outcomes = {};
219
+ for (const gate of gateList) outcomes[gate.name] = 'passed';
220
+ for (const { gate } of validation.skipped ?? [])
221
+ outcomes[gate.name] = 'skipped';
222
+ return outcomes;
191
223
  }