mandrel 1.62.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/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/README.md +1 -1
- package/docs/CHANGELOG.md +28 -0
- 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]
|
package/README.md
CHANGED
|
@@ -163,7 +163,7 @@ Deeper reference material lives in `docs/` rather than inline here:
|
|
|
163
163
|
- [`.agents/docs/workflows.md`](.agents/docs/workflows.md) — slash-command
|
|
164
164
|
index (auto-generated from the workflow set).
|
|
165
165
|
- [`docs/CHANGELOG.md`](docs/CHANGELOG.md) — release history.
|
|
166
|
-
- [`AGENTS.md`](AGENTS.md) — repository onboarding, the
|
|
166
|
+
- [`AGENTS.md`](AGENTS.md) — repository onboarding, the single-package release
|
|
167
167
|
topology, PAT / npm-token setup, and major-version policy. Releases are
|
|
168
168
|
automated by `release-please`: land Conventional Commits on `main` and it
|
|
169
169
|
opens a combined `chore: release main` PR that squash-merges itself once
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.62.0...mandrel-v1.63.0) (2026-06-13)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* **decompose:** set native GitHub blocked-by dependencies from Story depends_on graph (refs [#4067](https://github.com/dsj1984/mandrel/issues/4067)) ([#4068](https://github.com/dsj1984/mandrel/issues/4068)) ([1799659](https://github.com/dsj1984/mandrel/commit/1799659a84b06555c9caa1193aec59113c943b78))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **audit-architecture:** add Impact severity field to Detailed Findings (refs [#4085](https://github.com/dsj1984/mandrel/issues/4085)) ([#4096](https://github.com/dsj1984/mandrel/issues/4096)) ([d72c109](https://github.com/dsj1984/mandrel/commit/d72c109195955eff70d7d926abb36a88e32d3f6e))
|
|
16
|
+
* fix stale architecture.md/README docs and broaden the import-cycle gate scan root ([#4071](https://github.com/dsj1984/mandrel/issues/4071)) ([#4090](https://github.com/dsj1984/mandrel/issues/4090)) ([2e97b45](https://github.com/dsj1984/mandrel/commit/2e97b45f9ed43398dc703dd735394c7483329033))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Performance
|
|
20
|
+
|
|
21
|
+
* **decompose:** parallelize blocked-by edge creation with bounded concurrentMap (refs [#4082](https://github.com/dsj1984/mandrel/issues/4082)) ([#4094](https://github.com/dsj1984/mandrel/issues/4094)) ([a9f38da](https://github.com/dsj1984/mandrel/commit/a9f38da2d44941a164865a6ad291c2da01ee4ed6))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
* `parseProviderFindings` is a CC-30 ternary thicket ([#4074](https://github.com/dsj1984/mandrel/issues/4074)) ([#4089](https://github.com/dsj1984/mandrel/issues/4089)) ([5ed693c](https://github.com/dsj1984/mandrel/commit/5ed693c3325c05a36251b859be5873ab0378031c))
|
|
27
|
+
* extract shared createDriftDetector skeleton for CRAP and MI drift detectors (refs [#4076](https://github.com/dsj1984/mandrel/issues/4076)) ([#4091](https://github.com/dsj1984/mandrel/issues/4091)) ([e8a81a8](https://github.com/dsj1984/mandrel/commit/e8a81a82307a12c3d6e58275d4c4554b07659f4f))
|
|
28
|
+
* **scripts:** reduce CC of CLI orchestration bodies below the must-fix band (refs [#4075](https://github.com/dsj1984/mandrel/issues/4075)) ([#4093](https://github.com/dsj1984/mandrel/issues/4093)) ([53519de](https://github.com/dsj1984/mandrel/commit/53519de7968e59c08479c886ca3713f53f5c039d))
|
|
29
|
+
* **signals:** hoist shared detector validation preamble into common.js (refs [#4077](https://github.com/dsj1984/mandrel/issues/4077)) ([#4092](https://github.com/dsj1984/mandrel/issues/4092)) ([2f6fe7b](https://github.com/dsj1984/mandrel/commit/2f6fe7b103c208fbd3ea4cfedf63d2a7aa412e24))
|
|
30
|
+
* **single-story-init:** inject spawnSync boundary into makeGhRunner (refs [#4073](https://github.com/dsj1984/mandrel/issues/4073)) ([#4087](https://github.com/dsj1984/mandrel/issues/4087)) ([8fae355](https://github.com/dsj1984/mandrel/commit/8fae35568523e3997d8f7e79580c0cc8e0625072))
|
|
31
|
+
* **story-body:** extract per-section sub-parsers from parse() (refs [#4072](https://github.com/dsj1984/mandrel/issues/4072)) ([#4088](https://github.com/dsj1984/mandrel/issues/4088)) ([aa9e472](https://github.com/dsj1984/mandrel/commit/aa9e47204728da0ab4f547927af251fce6a85b69))
|
|
32
|
+
|
|
5
33
|
## [1.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.61.0...mandrel-v1.62.0) (2026-06-12)
|
|
6
34
|
|
|
7
35
|
|
package/package.json
CHANGED