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.
Files changed (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. 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
- // Extract depends_on from footer even for legacy bodies.
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
- // --- Parse goal ---
450
- const goalLines = sections.get('goal') ?? [];
451
- const goal = goalLines
452
- .map((l) => l.trim())
453
- .filter(Boolean)
454
- .join(' ');
455
-
456
- // --- Parse changes ---
457
- const changeLines = sections.get('changes') ?? [];
458
- const changes = [];
459
- for (const line of changeLines) {
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 = spawnSync('gh', args, {
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 two-package release
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.62.0",
3
+ "version": "1.63.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",