mandrel 1.61.0 → 1.63.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/docs/SDLC.md +10 -3
- package/.agents/docs/workflows.md +1 -1
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/deliver.md +87 -26
- package/.agents/workflows/helpers/deliver-epic.md +12 -5
- package/.agents/workflows/helpers/deliver-stories.md +13 -7
- package/.agents/workflows/plan.md +3 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +40 -0
- package/lib/cli/registry.js +1 -1
- package/lib/cli/update.js +114 -8
- package/package.json +1 -1
|
@@ -355,6 +355,96 @@ function splitSections(markdown) {
|
|
|
355
355
|
return { sections, footer, preamble };
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Parser — per-section sub-parsers
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Build the minimal {@link ParseResult} returned for a legacy string body —
|
|
364
|
+
* markdown that carries no recognised structured section. The goal falls
|
|
365
|
+
* back to the preamble text (or the whole trimmed input), `depends_on` is
|
|
366
|
+
* still recovered from the footer, and all section arrays are empty.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} input - The original markdown string.
|
|
369
|
+
* @param {string} preamble - Text before the first heading (from splitSections).
|
|
370
|
+
* @param {string} footer - Footer block text (from splitSections).
|
|
371
|
+
* @returns {ParseResult}
|
|
372
|
+
*/
|
|
373
|
+
function parseLegacyStringBody(input, preamble, footer) {
|
|
374
|
+
const warnings = [
|
|
375
|
+
'legacy-string-body: no structured sections found; returning minimal body from preamble text.',
|
|
376
|
+
'test-surface-unestimated: estimated_test_files not present.',
|
|
377
|
+
];
|
|
378
|
+
const body = {
|
|
379
|
+
goal: preamble || input.trim(),
|
|
380
|
+
changes: [],
|
|
381
|
+
acceptance: [],
|
|
382
|
+
verify: [],
|
|
383
|
+
references: [],
|
|
384
|
+
wide: null,
|
|
385
|
+
depends_on: extractBlockedBy(footer),
|
|
386
|
+
estimated_test_files: null,
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
body,
|
|
390
|
+
warnings,
|
|
391
|
+
info: {
|
|
392
|
+
hasGoalSection: false,
|
|
393
|
+
hasChangesSection: false,
|
|
394
|
+
hasAcceptanceSection: false,
|
|
395
|
+
hasVerifySection: false,
|
|
396
|
+
hasReferencesSection: false,
|
|
397
|
+
isLegacyStringBody: true,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Parse the `## Goal` section: join its non-empty content lines into a
|
|
404
|
+
* single one-line goal string.
|
|
405
|
+
*
|
|
406
|
+
* @param {string[]} lines - Raw content lines under the heading.
|
|
407
|
+
* @returns {string}
|
|
408
|
+
*/
|
|
409
|
+
function parseGoalSection(lines) {
|
|
410
|
+
return lines
|
|
411
|
+
.map((l) => l.trim())
|
|
412
|
+
.filter(Boolean)
|
|
413
|
+
.join(' ');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Parse a `## Changes` / `## References` section into a list of
|
|
418
|
+
* `PathEntry | string` entries. List markers are stripped, blank entries are
|
|
419
|
+
* dropped, and each surviving entry is normalized via {@link parsePathEntry}
|
|
420
|
+
* (which appends `legacy-path-entry` warnings for bare-string bullets).
|
|
421
|
+
*
|
|
422
|
+
* @param {string[]} lines - Raw content lines under the heading.
|
|
423
|
+
* @param {string[]} warnings - Mutable warnings sink.
|
|
424
|
+
* @returns {Array<PathEntry|string>}
|
|
425
|
+
*/
|
|
426
|
+
function parsePathEntrySection(lines, warnings) {
|
|
427
|
+
const entries = [];
|
|
428
|
+
for (const line of lines) {
|
|
429
|
+
const stripped = stripListMarker(line);
|
|
430
|
+
if (!stripped) continue;
|
|
431
|
+
const entry = parsePathEntry(stripped, warnings);
|
|
432
|
+
if (entry !== null) entries.push(entry);
|
|
433
|
+
}
|
|
434
|
+
return entries;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Parse a plain bullet-list section (`## Acceptance` / `## Verify`) into a
|
|
439
|
+
* list of trimmed strings, dropping blank entries.
|
|
440
|
+
*
|
|
441
|
+
* @param {string[]} lines - Raw content lines under the heading.
|
|
442
|
+
* @returns {string[]}
|
|
443
|
+
*/
|
|
444
|
+
function parseTextListSection(lines) {
|
|
445
|
+
return lines.map((l) => stripListMarker(l)).filter(Boolean);
|
|
446
|
+
}
|
|
447
|
+
|
|
358
448
|
// ---------------------------------------------------------------------------
|
|
359
449
|
// Parser
|
|
360
450
|
// ---------------------------------------------------------------------------
|
|
@@ -414,84 +504,20 @@ export function parse(input) {
|
|
|
414
504
|
!hasVerifySection;
|
|
415
505
|
|
|
416
506
|
if (isLegacyStringBody) {
|
|
417
|
-
|
|
418
|
-
const dependsOn = extractBlockedBy(footer);
|
|
419
|
-
warnings.push(
|
|
420
|
-
'legacy-string-body: no structured sections found; returning minimal body from preamble text.',
|
|
421
|
-
);
|
|
422
|
-
warnings.push(
|
|
423
|
-
'test-surface-unestimated: estimated_test_files not present.',
|
|
424
|
-
);
|
|
425
|
-
const body = {
|
|
426
|
-
goal: preamble || input.trim(),
|
|
427
|
-
changes: [],
|
|
428
|
-
acceptance: [],
|
|
429
|
-
verify: [],
|
|
430
|
-
references: [],
|
|
431
|
-
wide: null,
|
|
432
|
-
depends_on: dependsOn,
|
|
433
|
-
estimated_test_files: null,
|
|
434
|
-
};
|
|
435
|
-
return {
|
|
436
|
-
body,
|
|
437
|
-
warnings,
|
|
438
|
-
info: {
|
|
439
|
-
hasGoalSection: false,
|
|
440
|
-
hasChangesSection: false,
|
|
441
|
-
hasAcceptanceSection: false,
|
|
442
|
-
hasVerifySection: false,
|
|
443
|
-
hasReferencesSection: false,
|
|
444
|
-
isLegacyStringBody: true,
|
|
445
|
-
},
|
|
446
|
-
};
|
|
507
|
+
return parseLegacyStringBody(input, preamble, footer);
|
|
447
508
|
}
|
|
448
509
|
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const stripped = stripListMarker(line);
|
|
461
|
-
if (!stripped) continue;
|
|
462
|
-
const entry = parsePathEntry(stripped, warnings);
|
|
463
|
-
if (entry !== null) changes.push(entry);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// --- Parse acceptance ---
|
|
467
|
-
const acceptanceLines = sections.get('acceptance') ?? [];
|
|
468
|
-
const acceptance = acceptanceLines
|
|
469
|
-
.map((l) => stripListMarker(l))
|
|
470
|
-
.filter(Boolean);
|
|
471
|
-
|
|
472
|
-
// --- Parse verify ---
|
|
473
|
-
const verifyLines = sections.get('verify') ?? [];
|
|
474
|
-
const verify = verifyLines.map((l) => stripListMarker(l)).filter(Boolean);
|
|
475
|
-
|
|
476
|
-
// --- Parse references (optional) ---
|
|
477
|
-
const referenceLines = sections.get('references') ?? [];
|
|
478
|
-
const references = [];
|
|
479
|
-
for (const line of referenceLines) {
|
|
480
|
-
const stripped = stripListMarker(line);
|
|
481
|
-
if (!stripped) continue;
|
|
482
|
-
const entry = parsePathEntry(stripped, warnings);
|
|
483
|
-
if (entry !== null) {
|
|
484
|
-
// References MUST be object form (canonical).
|
|
485
|
-
if (typeof entry === 'string') {
|
|
486
|
-
// Already warned as legacy-path-entry; keep as string for now.
|
|
487
|
-
references.push(entry);
|
|
488
|
-
} else {
|
|
489
|
-
references.push(entry);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// --- Parse footer ---
|
|
510
|
+
const goal = parseGoalSection(sections.get('goal') ?? []);
|
|
511
|
+
const changes = parsePathEntrySection(
|
|
512
|
+
sections.get('changes') ?? [],
|
|
513
|
+
warnings,
|
|
514
|
+
);
|
|
515
|
+
const acceptance = parseTextListSection(sections.get('acceptance') ?? []);
|
|
516
|
+
const verify = parseTextListSection(sections.get('verify') ?? []);
|
|
517
|
+
const references = parsePathEntrySection(
|
|
518
|
+
sections.get('references') ?? [],
|
|
519
|
+
warnings,
|
|
520
|
+
);
|
|
495
521
|
const dependsOn = extractBlockedBy(footer);
|
|
496
522
|
|
|
497
523
|
// --- Recover wide / estimated_test_files from the meta block ---
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Provider — shared "set native blocked-by dependency" helper.
|
|
3
|
+
*
|
|
4
|
+
* Story #4067 — after issue creation during Phase 8 decomposition, each
|
|
5
|
+
* Story's `depends_on` graph is known as a set of sibling slugs. This
|
|
6
|
+
* helper translates those slug-to-issueNumber pairs into native GitHub
|
|
7
|
+
* "blocked by" dependency edges so maintainers can see blocking
|
|
8
|
+
* relationships directly in the GitHub UI.
|
|
9
|
+
*
|
|
10
|
+
* API surface used:
|
|
11
|
+
* Read: GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by
|
|
12
|
+
* Write: POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by
|
|
13
|
+
* body: { "issue_id": <integer db id of the blocking issue> }
|
|
14
|
+
*
|
|
15
|
+
* Contract:
|
|
16
|
+
* - **Idempotent** — reads existing edges first; only POSTs missing ones.
|
|
17
|
+
* - **Non-fatal** — catches all errors per edge, warns, and continues.
|
|
18
|
+
* The overall function never throws; errors are returned in a summary.
|
|
19
|
+
* - **No-op on empty input** — returns immediately when no depends_on
|
|
20
|
+
* edges are present or the slug→issueNumber map is empty.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Logger } from '../../lib/Logger.js';
|
|
24
|
+
import { concurrentMap } from '../../lib/util/concurrent-map.js';
|
|
25
|
+
import { parseApiJson } from './request-helpers.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bounded concurrency for the GitHub dependency-edge round-trips. Kept modest
|
|
29
|
+
* to respect GitHub's secondary rate limits while still collapsing the wall-
|
|
30
|
+
* clock latency from `sum(round-trips)` toward `sum(round-trips) / concurrency`.
|
|
31
|
+
*/
|
|
32
|
+
const EDGE_CONCURRENCY = 5;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch the existing blocked-by issue numbers for a given issue.
|
|
36
|
+
*
|
|
37
|
+
* Returns `[]` on any error so the caller falls back to posting the full
|
|
38
|
+
* set of missing edges (worst case: a duplicate POST, which GitHub
|
|
39
|
+
* handles idempotently).
|
|
40
|
+
*
|
|
41
|
+
* @param {{ gh: object, owner: string, repo: string, issueNumber: number }} opts
|
|
42
|
+
* @returns {Promise<number[]>} Database ids of the issues that currently block `issueNumber`.
|
|
43
|
+
*/
|
|
44
|
+
async function fetchExistingBlockedBy({ gh, owner, repo, issueNumber }) {
|
|
45
|
+
try {
|
|
46
|
+
const result = await gh.api({
|
|
47
|
+
method: 'GET',
|
|
48
|
+
endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
|
|
49
|
+
});
|
|
50
|
+
const data = parseApiJson(result);
|
|
51
|
+
if (!Array.isArray(data)) return [];
|
|
52
|
+
return data.map((item) => item?.id).filter((id) => typeof id === 'number');
|
|
53
|
+
} catch (err) {
|
|
54
|
+
Logger.warn(
|
|
55
|
+
`[blocked-by-add] Could not fetch existing blocked-by for #${issueNumber}: ${err.message}`,
|
|
56
|
+
);
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set native GitHub "blocked by" dependency edges for a single issue.
|
|
63
|
+
*
|
|
64
|
+
* For each entry in `blockerInternalIds`, checks whether the edge already
|
|
65
|
+
* exists and POSTs only the missing ones. Every individual POST failure is
|
|
66
|
+
* caught, logged as a warning, and counted — the function never throws.
|
|
67
|
+
*
|
|
68
|
+
* @param {{
|
|
69
|
+
* gh: object,
|
|
70
|
+
* owner: string,
|
|
71
|
+
* repo: string,
|
|
72
|
+
* issueNumber: number,
|
|
73
|
+
* blockerInternalIds: number[],
|
|
74
|
+
* }} opts
|
|
75
|
+
* @returns {Promise<{ added: number, skipped: number, failed: number }>}
|
|
76
|
+
*/
|
|
77
|
+
async function addBlockedByEdges({
|
|
78
|
+
gh,
|
|
79
|
+
owner,
|
|
80
|
+
repo,
|
|
81
|
+
issueNumber,
|
|
82
|
+
blockerInternalIds,
|
|
83
|
+
}) {
|
|
84
|
+
if (blockerInternalIds.length === 0) {
|
|
85
|
+
return { added: 0, skipped: 0, failed: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const existing = await fetchExistingBlockedBy({
|
|
89
|
+
gh,
|
|
90
|
+
owner,
|
|
91
|
+
repo,
|
|
92
|
+
issueNumber,
|
|
93
|
+
});
|
|
94
|
+
const existingSet = new Set(existing);
|
|
95
|
+
|
|
96
|
+
// Partition up front so the skip count is deterministic regardless of the
|
|
97
|
+
// concurrent POST dispatch order, then POST only the missing edges in
|
|
98
|
+
// parallel under a modest cap.
|
|
99
|
+
const missing = blockerInternalIds.filter((id) => !existingSet.has(id));
|
|
100
|
+
const skipped = blockerInternalIds.length - missing.length;
|
|
101
|
+
|
|
102
|
+
const perEdge = await concurrentMap(
|
|
103
|
+
missing,
|
|
104
|
+
async (blockerId) => {
|
|
105
|
+
try {
|
|
106
|
+
await gh.api({
|
|
107
|
+
method: 'POST',
|
|
108
|
+
endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
|
|
109
|
+
body: { issue_id: blockerId },
|
|
110
|
+
});
|
|
111
|
+
return { added: 1, failed: 0 };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
Logger.warn(
|
|
114
|
+
`[blocked-by-add] Failed to add blocked-by edge #${issueNumber} ← blocker(id=${blockerId}): ${err.message}`,
|
|
115
|
+
);
|
|
116
|
+
return { added: 0, failed: 1 };
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
{ concurrency: EDGE_CONCURRENCY },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
let added = 0;
|
|
123
|
+
let failed = 0;
|
|
124
|
+
for (const r of perEdge) {
|
|
125
|
+
added += r.added;
|
|
126
|
+
failed += r.failed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { added, skipped, failed };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Translate a Story's `depends_on` slug list into native GitHub "blocked
|
|
134
|
+
* by" dependency edges, given the slug→issueNumber map from the reconciler
|
|
135
|
+
* state and a `getTicket` hook to resolve database ids.
|
|
136
|
+
*
|
|
137
|
+
* Iterates every Story that has a non-empty `dependsOn` array; for each
|
|
138
|
+
* depended-on slug, resolves the blocker's issue number (via the slug map),
|
|
139
|
+
* then resolves the blocker's database id via `getTicket`, and calls
|
|
140
|
+
* `addBlockedByEdges`. Any failure at any step is caught, logged as a
|
|
141
|
+
* warning, and reflected in the returned summary — the function never
|
|
142
|
+
* throws.
|
|
143
|
+
*
|
|
144
|
+
* @param {{
|
|
145
|
+
* stories: Array<{ slug: string, dependsOn?: string[] }>,
|
|
146
|
+
* slugToIssueNumber: Record<string, number>,
|
|
147
|
+
* getTicket: (issueNumber: number) => Promise<{ internalId: number }>,
|
|
148
|
+
* owner: string,
|
|
149
|
+
* repo: string,
|
|
150
|
+
* gh: object,
|
|
151
|
+
* }} opts
|
|
152
|
+
* @returns {Promise<{
|
|
153
|
+
* edgesAdded: number,
|
|
154
|
+
* edgesSkipped: number,
|
|
155
|
+
* edgesFailed: number,
|
|
156
|
+
* storiesProcessed: number,
|
|
157
|
+
* }>}
|
|
158
|
+
*/
|
|
159
|
+
export async function applyBlockedByDependencies({
|
|
160
|
+
stories,
|
|
161
|
+
slugToIssueNumber,
|
|
162
|
+
getTicket,
|
|
163
|
+
owner,
|
|
164
|
+
repo,
|
|
165
|
+
gh,
|
|
166
|
+
}) {
|
|
167
|
+
let edgesAdded = 0;
|
|
168
|
+
let edgesSkipped = 0;
|
|
169
|
+
let edgesFailed = 0;
|
|
170
|
+
let storiesProcessed = 0;
|
|
171
|
+
|
|
172
|
+
// Process each story's GET + its POST batch under one bounded concurrentMap
|
|
173
|
+
// so the per-story round-trips overlap. Each mapper returns its own counter
|
|
174
|
+
// contribution; we sum them after the pass. The bodies swallow their own
|
|
175
|
+
// errors (per-edge try/catch + the non-fatal contract), so concurrentMap
|
|
176
|
+
// never sees a rejection and the no-throw guarantee is preserved.
|
|
177
|
+
const perStory = await concurrentMap(
|
|
178
|
+
stories,
|
|
179
|
+
async (story) => {
|
|
180
|
+
const deps = Array.isArray(story.dependsOn) ? story.dependsOn : [];
|
|
181
|
+
if (deps.length === 0) {
|
|
182
|
+
return { added: 0, skipped: 0, failed: 0, processed: 0 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const storyIssueNumber = slugToIssueNumber[story.slug];
|
|
186
|
+
if (typeof storyIssueNumber !== 'number') {
|
|
187
|
+
Logger.warn(
|
|
188
|
+
`[blocked-by-add] No issue number for story slug "${story.slug}"; skipping depends_on edges.`,
|
|
189
|
+
);
|
|
190
|
+
return { added: 0, skipped: 0, failed: 0, processed: 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let failed = 0;
|
|
194
|
+
const blockerInternalIds = [];
|
|
195
|
+
|
|
196
|
+
for (const depSlug of deps) {
|
|
197
|
+
const blockerIssueNumber = slugToIssueNumber[depSlug];
|
|
198
|
+
if (typeof blockerIssueNumber !== 'number') {
|
|
199
|
+
Logger.warn(
|
|
200
|
+
`[blocked-by-add] depends_on slug "${depSlug}" (from "${story.slug}") has no mapped issue number; skipping edge.`,
|
|
201
|
+
);
|
|
202
|
+
failed++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const blocker = await getTicket(blockerIssueNumber);
|
|
207
|
+
if (typeof blocker?.internalId !== 'number') {
|
|
208
|
+
Logger.warn(
|
|
209
|
+
`[blocked-by-add] Blocker #${blockerIssueNumber} ("${depSlug}") has no internalId; skipping edge.`,
|
|
210
|
+
);
|
|
211
|
+
failed++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
blockerInternalIds.push(blocker.internalId);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
Logger.warn(
|
|
217
|
+
`[blocked-by-add] Could not resolve blocker "${depSlug}" (#${blockerIssueNumber}): ${err.message}`,
|
|
218
|
+
);
|
|
219
|
+
failed++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (blockerInternalIds.length === 0) {
|
|
224
|
+
return { added: 0, skipped: 0, failed, processed: 1 };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = await addBlockedByEdges({
|
|
228
|
+
gh,
|
|
229
|
+
owner,
|
|
230
|
+
repo,
|
|
231
|
+
issueNumber: storyIssueNumber,
|
|
232
|
+
blockerInternalIds,
|
|
233
|
+
});
|
|
234
|
+
return {
|
|
235
|
+
added: result.added,
|
|
236
|
+
skipped: result.skipped,
|
|
237
|
+
failed: failed + result.failed,
|
|
238
|
+
processed: 1,
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
{ concurrency: EDGE_CONCURRENCY },
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
for (const r of perStory) {
|
|
245
|
+
edgesAdded += r.added;
|
|
246
|
+
edgesSkipped += r.skipped;
|
|
247
|
+
edgesFailed += r.failed;
|
|
248
|
+
storiesProcessed += r.processed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { edgesAdded, edgesSkipped, edgesFailed, storiesProcessed };
|
|
252
|
+
}
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* @see .agents/workflows/helpers/single-story-deliver.md
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { spawnSync } from 'node:child_process';
|
|
39
|
+
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
40
40
|
import { existsSync } from 'node:fs';
|
|
41
41
|
import path from 'node:path';
|
|
42
42
|
import { parseSprintArgs } from './lib/cli-args.js';
|
|
@@ -109,12 +109,25 @@ const progress = Logger.createProgress('single-story-init', { stderr: true });
|
|
|
109
109
|
* ripple into the single-story-sweep planner, which is intentionally out
|
|
110
110
|
* of scope for the callers-only provider migration.
|
|
111
111
|
*
|
|
112
|
+
* Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so the
|
|
113
|
+
* runner's success/error handling can be unit-tested without a live `gh`
|
|
114
|
+
* binary. It defaults to `child_process.spawnSync` (mirroring the
|
|
115
|
+
* `spawnImpl` seam in `lib/gh-exec.js` and the `runner` seam in
|
|
116
|
+
* `lib/bootstrap/gh-preflight.js`), so the production CLI path is unchanged.
|
|
117
|
+
* The synchronous `spawnSync` shape is preserved deliberately — the
|
|
118
|
+
* candidate-filter loop in `executeCleanup` is synchronous, so converting
|
|
119
|
+
* this to the async `lib/gh-exec.js` facade would ripple into the
|
|
120
|
+
* single-story-sweep planner.
|
|
121
|
+
*
|
|
112
122
|
* @param {string} cwd Repo root used as the default spawn cwd.
|
|
123
|
+
* @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
|
|
124
|
+
* defaults to `child_process.spawnSync`. Tests pass a fake to assert the
|
|
125
|
+
* error/exit-code handling without spawning a real child process.
|
|
113
126
|
* @returns {(args: string[], opts?: { cwd?: string }) => string}
|
|
114
127
|
*/
|
|
115
|
-
export function makeGhRunner(cwd) {
|
|
128
|
+
export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
|
|
116
129
|
return (args, opts) => {
|
|
117
|
-
const result =
|
|
130
|
+
const result = spawnImpl('gh', args, {
|
|
118
131
|
cwd: opts?.cwd ?? cwd,
|
|
119
132
|
encoding: 'utf-8',
|
|
120
133
|
shell: false,
|
|
@@ -129,6 +129,14 @@ legitimately have no layered architecture to guard.
|
|
|
129
129
|
|
|
130
130
|
## Step 2: Analysis Dimensions
|
|
131
131
|
|
|
132
|
+
For every finding you surface, grade **Impact** on a High / Medium / Low axis
|
|
133
|
+
reflecting the severity of the architectural risk (how much correctness,
|
|
134
|
+
maintainability, or testability the gap erodes), independent of the
|
|
135
|
+
**Category** effort axis — a Quick Win can still be High Impact, and a
|
|
136
|
+
Structural Change can be Medium. As a loose default, Quick Wins typically land
|
|
137
|
+
High (cheap to fix, real payoff) and Structural Changes Medium/High, but grade
|
|
138
|
+
Impact on the risk itself rather than deriving it mechanically from Category.
|
|
139
|
+
|
|
132
140
|
Evaluate the gathered context against the following clean code dimensions:
|
|
133
141
|
|
|
134
142
|
1. **Over-Engineering & Abstractions:** Identify "dry-run" complexity, premature
|
|
@@ -294,6 +302,7 @@ marked `n/a`.]
|
|
|
294
302
|
|
|
295
303
|
### [Short Title of the Issue]
|
|
296
304
|
|
|
305
|
+
- **Impact:** [High | Medium | Low]
|
|
297
306
|
- **Category:** [Quick Win | Structural Change]
|
|
298
307
|
- **Dimension:** [e.g., Cognitive Load & Nesting | Testable Surface (Humble-Object Boundary) | Automated Architecture Guardrails]
|
|
299
308
|
- **Current State:** [The specific file/function and why it is problematic]
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
description:
|
|
3
3
|
Unified delivery entry point. Inspects the ticket type(s) and
|
|
4
|
-
Epic-reference state of the supplied IDs,
|
|
5
|
-
|
|
6
|
-
the
|
|
4
|
+
Epic-reference state of the supplied IDs, composes a sequential segment
|
|
5
|
+
plan over any mix of Epics and standalone Stories, then delegates each
|
|
6
|
+
segment to the Epic wave loop or the standalone multi-Story fan-out —
|
|
7
|
+
preserving every flag and the parallel-delivery contract of the retired
|
|
8
|
+
commands.
|
|
7
9
|
---
|
|
8
10
|
|
|
9
|
-
# /deliver [Epic
|
|
11
|
+
# /deliver [Epic IDs...] | [Story IDs...]
|
|
10
12
|
|
|
11
13
|
## Role
|
|
12
14
|
|
|
13
|
-
Router. `/deliver` owns input classification
|
|
14
|
-
phase content lives in the two path helpers:
|
|
15
|
+
Router. `/deliver` owns input classification, segment-plan composition, and
|
|
16
|
+
path selection only — all phase content lives in the two path helpers:
|
|
15
17
|
|
|
16
18
|
- [`helpers/deliver-epic.md`](helpers/deliver-epic.md) — the full Epic
|
|
17
19
|
delivery loop (preflight, wave loop fanning out
|
|
@@ -30,20 +32,67 @@ reference) before routing:
|
|
|
30
32
|
|
|
31
33
|
| Input | Route |
|
|
32
34
|
| --- | --- |
|
|
33
|
-
| Exactly one `type::epic` ID | **Epic path** — run [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9 unchanged. |
|
|
34
|
-
| One or more `type::story` IDs, none carrying an `Epic: #N` reference | **Standalone path** — run [`helpers/deliver-stories.md`](helpers/deliver-stories.md) Phases 0–3. |
|
|
35
|
-
| Any
|
|
36
|
-
|
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
| Exactly one `type::epic` ID | **Epic path** — run [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9 unchanged (single-segment plan; no confirmation prompt). |
|
|
36
|
+
| One or more `type::story` IDs, none carrying an `Epic: #N` reference | **Standalone path** — run [`helpers/deliver-stories.md`](helpers/deliver-stories.md) Phases 0–3 (single-segment plan; no confirmation prompt). |
|
|
37
|
+
| Any combination of ≥1 `type::epic` IDs and ≥0 standalone `type::story` IDs | **Segment plan** — compose and execute the sequential segment plan below. |
|
|
38
|
+
| Any Story carrying an `Epic: #N` reference (alone or mixed into an otherwise-valid set) | **Error**, naming every affected ID and the fix: `Story #<id> belongs to Epic #<n> — run /deliver <n>`. |
|
|
39
|
+
|
|
40
|
+
Per-ID classification is unchanged: fetch the `type::*` label and probe the
|
|
41
|
+
body for an `Epic: #N` reference before routing. Never guess a route.
|
|
42
|
+
|
|
43
|
+
## Segment plan (mixed / multi-Epic input)
|
|
44
|
+
|
|
45
|
+
When the supplied IDs span more than one Epic, or mix Epics with standalone
|
|
46
|
+
Stories, the router composes a **segment plan** and executes the segments
|
|
47
|
+
**strictly sequentially**:
|
|
48
|
+
|
|
49
|
+
1. **Standalone segment first** (when any standalone Story IDs are
|
|
50
|
+
present): the full standalone-Story set forms **one** segment,
|
|
51
|
+
delivered via [`helpers/deliver-stories.md`](helpers/deliver-stories.md)
|
|
52
|
+
Phases 0–3 unchanged. It runs first because it is fast, each Story
|
|
53
|
+
merges to `main` independently, and each subsequent Epic segment's
|
|
54
|
+
Phase 7.0 base-sync then integrates those merges naturally instead of
|
|
55
|
+
the Epic PR opening behind base.
|
|
56
|
+
2. **Epic segments in input order**: each `type::epic` ID forms its own
|
|
57
|
+
segment, delivered via
|
|
58
|
+
[`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9
|
|
59
|
+
unchanged.
|
|
60
|
+
|
|
61
|
+
Sequential execution is a deliberate design decision: the Epic path assumes
|
|
62
|
+
a single main checkout (prepare's checkout guard, Phase 7.0
|
|
63
|
+
`git checkout epic/<id>`), holds a per-Epic lease, serializes same-machine
|
|
64
|
+
sessions via `epic-merge-lock.js`, and constrains dispatch to one wave at a
|
|
65
|
+
time. Segments are never interleaved or parallelized; running them one at a
|
|
66
|
+
time keeps both helpers' machinery untouched.
|
|
67
|
+
|
|
68
|
+
**Confirmation gate.** When the composed plan has more than one segment,
|
|
69
|
+
present it to the operator before dispatching — the segments, the IDs in
|
|
70
|
+
each, and the execution order — and wait for confirmation. `--yes`
|
|
71
|
+
suppresses this prompt. Single-segment plans route directly with today's
|
|
72
|
+
behavior (no new prompt; the standalone path's own Phase 1 confirmation
|
|
73
|
+
still applies as before).
|
|
74
|
+
|
|
75
|
+
**Failure policy.** A segment that ends non-complete (blocked, failed, or
|
|
76
|
+
halted at a gate) **stops the run** — no subsequent segment dispatches.
|
|
77
|
+
Report the terminal state: which segments completed, which segment halted
|
|
78
|
+
(and why), and which segments never started. Name the resume command:
|
|
79
|
+
re-running `/deliver` with the same IDs — both path helpers short-circuit
|
|
80
|
+
already-done work (the Epic path resumes idempotently from its checkpoint;
|
|
81
|
+
merged standalone Stories no-op).
|
|
82
|
+
|
|
83
|
+
## Flags (scoped per segment)
|
|
39
84
|
|
|
40
85
|
| Path | Flags |
|
|
41
86
|
| --- | --- |
|
|
42
87
|
| Epic | `--skip-epic-audit`, `--skip-code-review`, `--skip-retro`, `--full-retro`, `--steal`, `--as <handle>` |
|
|
43
88
|
| Story | `--dep <from>:<to>`, `--yes`, `--concurrency <n>` |
|
|
44
89
|
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
In a segment plan, Epic-path flags apply to **every** Epic segment;
|
|
91
|
+
Story-path flags apply to the standalone segment. `--yes` additionally
|
|
92
|
+
suppresses the router's segment-plan confirmation gate above. A flag with
|
|
93
|
+
no applicable segment in the plan is reported once as a no-op warning and
|
|
94
|
+
ignored — never an error (the existing convention, restated for segment
|
|
95
|
+
plans).
|
|
47
96
|
|
|
48
97
|
**Multi-Story parallel contract (preserved verbatim).**
|
|
49
98
|
|
|
@@ -55,31 +104,43 @@ behaves exactly as the retired multi-Story command did: the same
|
|
|
55
104
|
`stories-wave-tick.js` wave plan, the same operator confirmation gate
|
|
56
105
|
(suppressed by `--yes`), and the same parallel fan-out — one Agent call per
|
|
57
106
|
Story per wave, capped by the resolved `concurrencyCap` — to
|
|
58
|
-
[`helpers/single-story-deliver`](helpers/single-story-deliver.md).
|
|
107
|
+
[`helpers/single-story-deliver`](helpers/single-story-deliver.md). The
|
|
108
|
+
parallelism lives **inside** the standalone segment; segments themselves
|
|
109
|
+
remain strictly sequential.
|
|
59
110
|
|
|
60
111
|
## Procedure
|
|
61
112
|
|
|
62
113
|
1. **Parse args.** At least one positive-integer ID is required.
|
|
63
114
|
2. **Classify.** Fetch each ticket's labels + body and apply the input
|
|
64
|
-
matrix above.
|
|
65
|
-
never guess a route.
|
|
66
|
-
3. **
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
115
|
+
matrix above. Any Epic-attached Story ID is a hard error naming the
|
|
116
|
+
affected IDs and the fix — never guess a route.
|
|
117
|
+
3. **Compose the segment plan.** Standalone-Story set (when present) as
|
|
118
|
+
one segment, then one segment per Epic ID in input order. For a
|
|
119
|
+
multi-segment plan, present it and wait for operator confirmation
|
|
120
|
+
(`--yes` suppresses).
|
|
121
|
+
4. **Execute segments sequentially.** For each segment in order, read the
|
|
122
|
+
selected path helper **in full** and execute it from its entry phase,
|
|
123
|
+
forwarding the segment's scoped flags. The helper's phase numbering,
|
|
124
|
+
watchdogs, gates, and scripts are unchanged — this router adds no phase
|
|
125
|
+
content. Stop on the first non-complete segment per the failure policy.
|
|
126
|
+
5. **Report.** On completion (or halt), summarize per-segment outcomes and,
|
|
127
|
+
when halted, the resume command.
|
|
70
128
|
|
|
71
129
|
## Constraints
|
|
72
130
|
|
|
73
|
-
- `/deliver` requires
|
|
74
|
-
Epic helper's preflight enforces this) or well-formed
|
|
75
|
-
Planning happens in [`/plan`](plan.md); the
|
|
76
|
-
two commands is a hard boundary.
|
|
131
|
+
- `/deliver` requires planned tickets: Epics at `agent::ready` (the
|
|
132
|
+
Epic helper's preflight enforces this, per segment) or well-formed
|
|
133
|
+
standalone Stories. Planning happens in [`/plan`](plan.md); the
|
|
134
|
+
plan-review gate between the two commands is a hard boundary.
|
|
77
135
|
- The router performs no git or label mutations itself; the path helpers
|
|
78
136
|
own every script invocation.
|
|
137
|
+
- Segments execute strictly sequentially — never interleave a standalone
|
|
138
|
+
Story fan-out with an Epic wave loop, and never run two Epic segments
|
|
139
|
+
concurrently.
|
|
79
140
|
|
|
80
141
|
## See also
|
|
81
142
|
|
|
82
143
|
- [`/plan`](plan.md) — the unified planning entry point.
|
|
83
144
|
- [`helpers/deliver-epic.md`](helpers/deliver-epic.md) /
|
|
84
145
|
[`helpers/deliver-stories.md`](helpers/deliver-stories.md) — the path
|
|
85
|
-
helpers.
|
|
146
|
+
helpers, delegated to per segment.
|