mandrel 1.62.0 → 1.64.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/agents-bootstrap-github.js +40 -48
- package/.agents/scripts/bootstrap.js +74 -60
- 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/bootstrap/branch-protection.js +8 -8
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
- package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
- package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
- package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
- package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
- package/.agents/scripts/lib/detect-package-manager.js +2 -2
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/onboard/init-tail.js +60 -69
- 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/providers/github/tickets.js +1 -1
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/helpers/deliver-stories.md +24 -2
- package/.agents/workflows/helpers/single-story-deliver.md +84 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/init.js +66 -21
- package/lib/cli/sync.js +3 -3
- package/package.json +1 -1
- package/.agents/scripts/lib/onboard/detect-stack.js +0 -300
|
@@ -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
|
+
}
|
|
@@ -77,7 +77,7 @@ export function composeStoryBody({
|
|
|
77
77
|
}) {
|
|
78
78
|
const head = typeof body === 'string' ? body : '';
|
|
79
79
|
const lines = ['---', `parent: #${parentId}`];
|
|
80
|
-
if (epicId !== undefined && epicId !== null
|
|
80
|
+
if (epicId !== undefined && epicId !== null) {
|
|
81
81
|
lines.push(`Epic: #${epicId}`);
|
|
82
82
|
}
|
|
83
83
|
if (dependencies.length > 0) {
|
|
@@ -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]
|
|
@@ -192,7 +192,18 @@ Each Agent call:
|
|
|
192
192
|
1. Names the Story ID and instructs the child to invoke
|
|
193
193
|
[`helpers/single-story-deliver`](single-story-deliver.md)
|
|
194
194
|
for that Story.
|
|
195
|
-
2. States the **return contract** (see § 2c)
|
|
195
|
+
2. States the **return contract** (see § 2c) and the **no-park rule**: the
|
|
196
|
+
child MUST drive the close → CI-watch → merge-confirm → `agent::done`
|
|
197
|
+
sequence to a terminal state *within its own turn* and end **only** by
|
|
198
|
+
returning the § 2c JSON object. The auto-merge wait is an
|
|
199
|
+
internally-blocking step (`gh pr checks --watch` blocks the turn), **not**
|
|
200
|
+
a reason to suspend and hand back. A child that ends its turn with
|
|
201
|
+
free-form prose and an unconfirmed merge (e.g. "I'll wait for the
|
|
202
|
+
background watch task…") has violated the contract — the wave loop cannot
|
|
203
|
+
advance, and the Story strands at `agent::closing` (the Story #1553 /
|
|
204
|
+
PR #1554 failure mode). There is no "pending" return status: the child
|
|
205
|
+
returns `done` (merge confirmed), `blocked` (transitioned + friction
|
|
206
|
+
posted), or `failed`.
|
|
196
207
|
3. Reminds the child of the **non-interactive contract**: no clarifying
|
|
197
208
|
questions — if stuck, transition to `agent::blocked`, post a
|
|
198
209
|
`friction` comment, and exit non-zero.
|
|
@@ -209,7 +220,8 @@ Agent call has returned a result (success, blocked, or failed).
|
|
|
209
220
|
|
|
210
221
|
### 2c. Per-Story return contract
|
|
211
222
|
|
|
212
|
-
Each child
|
|
223
|
+
Each child ends its turn by returning **exactly one** JSON object — never
|
|
224
|
+
free-form prose:
|
|
213
225
|
|
|
214
226
|
```json
|
|
215
227
|
{
|
|
@@ -223,6 +235,16 @@ Each child returns:
|
|
|
223
235
|
}
|
|
224
236
|
```
|
|
225
237
|
|
|
238
|
+
The status enum is **closed** — `done`, `blocked`, or `failed`. There is no
|
|
239
|
+
"pending" / "waiting" status, because the close-phase auto-merge wait is
|
|
240
|
+
**not** a returnable suspension: the child blocks on `gh pr checks --watch`
|
|
241
|
+
*inside its own turn*, confirms the merge, flips `agent::done`, and only then
|
|
242
|
+
returns `status: "done"`. A child that returns prose instead — parking on the
|
|
243
|
+
CI wait with an unconfirmed merge — breaks the wave loop's ability to advance
|
|
244
|
+
and leaves the Story at `agent::closing` (Story #1553 / PR #1554). The
|
|
245
|
+
single-homed restatement of this no-park rule for the child's own perspective
|
|
246
|
+
is [`single-story-deliver.md` § Step 7](single-story-deliver.md#return-contract).
|
|
247
|
+
|
|
226
248
|
### 2d. Wave outcome handling
|
|
227
249
|
|
|
228
250
|
After every Story in a wave returns:
|
|
@@ -336,6 +336,26 @@ coverage rounding, platform-conditional branches, and timing-sensitive
|
|
|
336
336
|
tests routinely drift between the two. The agent owns the green-CI
|
|
337
337
|
outcome, not just the push.
|
|
338
338
|
|
|
339
|
+
> **The auto-merge wait is an internally-blocking step, not a reason to end
|
|
340
|
+
> your turn.** This is the single most important contract of this workflow,
|
|
341
|
+
> and the seam where a worker most often misbehaves: it delivers up to arming
|
|
342
|
+
> auto-merge, then ends its turn with **free-form prose** — e.g. "I'll wait
|
|
343
|
+
> for the background watch task to complete" or "the next event will be its
|
|
344
|
+
> completion notification" — leaving the merge unconfirmed and the Story
|
|
345
|
+
> stranded at `agent::closing` (observed on Story #1553 / PR #1554). **Do not
|
|
346
|
+
> do this.** `gh pr checks <prNumber> --watch` *blocks the current turn* until
|
|
347
|
+
> CI resolves — that is the mechanism by which you wait. You MUST keep your
|
|
348
|
+
> turn alive across the wait: watch → (fix + push + re-watch on red) → confirm
|
|
349
|
+
> the merge (Step 5) → flip `agent::done` → run the post-merge steps → and
|
|
350
|
+
> only then return the terminal JSON status contract (Step 4 of
|
|
351
|
+
> [`deliver-stories.md` § 2c](deliver-stories.md), mirrored in
|
|
352
|
+
> [§ Return contract](#return-contract) for the standalone caller). The CI
|
|
353
|
+
> wait NEVER terminates your turn; **only** a confirmed-`MERGED` PR (→
|
|
354
|
+
> `status: "done"`), an `agent::blocked` transition (→ `status: "blocked"`),
|
|
355
|
+
> or an unrecoverable failure (→ `status: "failed"`) does. Ending your turn
|
|
356
|
+
> with prose and an unconfirmed merge is a contract violation — it is the very
|
|
357
|
+
> bug this workflow exists to prevent.
|
|
358
|
+
|
|
339
359
|
After `single-story-close.js` succeeds, enter the watch + fix loop:
|
|
340
360
|
|
|
341
361
|
```bash
|
|
@@ -348,7 +368,9 @@ When the watch exits:
|
|
|
348
368
|
still at `agent::closing` with its issue OPEN at this point (Step 3
|
|
349
369
|
deferred the `agent::done` flip). The `Closes #<id>` footer closes the
|
|
350
370
|
Story issue when the merge lands; Step 5 confirms the merge and Step 5.5
|
|
351
|
-
flips the Story to `agent::done`. Proceed to Step 5
|
|
371
|
+
flips the Story to `agent::done`. **Proceed to Step 5 within the same
|
|
372
|
+
turn** — do not end your turn here. Green CI is the *start* of the
|
|
373
|
+
merge-confirm sequence, not a terminal state (see Step 7's no-park rule).
|
|
352
374
|
- **Any check ✗** — diagnose, fix, and push a new commit on
|
|
353
375
|
`story-<storyId>`, then re-watch. Auto-merge stays enabled across
|
|
354
376
|
retries; no need to re-arm it. The Story stays at `agent::closing`
|
|
@@ -582,6 +604,67 @@ cleanup.
|
|
|
582
604
|
|
|
583
605
|
---
|
|
584
606
|
|
|
607
|
+
## Step 7 — Return contract (**required when dispatched as a sub-agent**) {#return-contract}
|
|
608
|
+
|
|
609
|
+
When this workflow runs as a per-Story sub-agent (dispatched by `/deliver`
|
|
610
|
+
via [`deliver-stories.md` § 2a/2c](deliver-stories.md)), the **only**
|
|
611
|
+
acceptable way to end your turn is to **return a single terminal JSON status
|
|
612
|
+
object** — never free-form prose:
|
|
613
|
+
|
|
614
|
+
```json
|
|
615
|
+
{
|
|
616
|
+
"storyId": <number>,
|
|
617
|
+
"status": "done" | "blocked" | "failed",
|
|
618
|
+
"phase": "init|implementing|closing|blocked|done",
|
|
619
|
+
"branchDeleted": <boolean>,
|
|
620
|
+
"blockerCommentId": <string|null>,
|
|
621
|
+
"detail": "<one-liner: what changed + what was verified, e.g. PR #N merged>",
|
|
622
|
+
"renderedBody": "<terminal Story body>"
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
This is the same envelope [`deliver-stories.md` § 2c](deliver-stories.md)
|
|
627
|
+
mandates; this section is its single-homed restatement for the standalone
|
|
628
|
+
worker so the contract is self-contained when this workflow is the entry
|
|
629
|
+
point.
|
|
630
|
+
|
|
631
|
+
**The auto-merge wait does not produce a fourth status.** There is no
|
|
632
|
+
"pending" or "waiting" terminal — the CI/auto-merge wait is handled
|
|
633
|
+
*internally* by blocking on `gh pr checks --watch` (Step 4) and confirming
|
|
634
|
+
the merge (Step 5). You return **only** when you have reached a genuinely
|
|
635
|
+
terminal state:
|
|
636
|
+
|
|
637
|
+
- **`status: "done"`** — the PR is confirmed `state: "MERGED"` (Step 5),
|
|
638
|
+
the Story carries `agent::done`, and Steps 5.5 / 6 have run. `phase: "done"`,
|
|
639
|
+
`branchDeleted: true`.
|
|
640
|
+
- **`status: "blocked"`** — you transitioned the Story to `agent::blocked`
|
|
641
|
+
and posted a `friction` comment (acceptance self-eval block in Step 1a, a
|
|
642
|
+
base-sync conflict, or an operator-blocking CI failure / Anti-Thrashing
|
|
643
|
+
stop in Step 4). `phase: "blocked"`, `blockerCommentId` set.
|
|
644
|
+
- **`status: "failed"`** — an unrecoverable failure outside the blocked
|
|
645
|
+
protocol. `phase` reflects where it died.
|
|
646
|
+
|
|
647
|
+
A turn that ends with prose ("I'll wait for the watch task…", "the next event
|
|
648
|
+
will be its completion notification…") and an **unconfirmed merge** is a
|
|
649
|
+
**contract violation** (the Story #1553 / PR #1554 failure mode): the parent
|
|
650
|
+
wave loop cannot distinguish "still working" from "done but silent", and the
|
|
651
|
+
Story strands at `agent::closing`. If you genuinely cannot confirm the merge,
|
|
652
|
+
that is a `blocked` or `failed` outcome with the JSON contract above — not a
|
|
653
|
+
prose hand-off.
|
|
654
|
+
|
|
655
|
+
> **Handoff discipline — report state, not process.** Populate the envelope
|
|
656
|
+
> with essential terminal state only (mirroring the fields
|
|
657
|
+
> `single-story-close.js` / `story-phase.js` already emit). Do not narrate the
|
|
658
|
+
> steps you took, and do not prescribe how the next stage should work. Prose
|
|
659
|
+
> process commentary only bloats the hydrated prompt
|
|
660
|
+
> (`delivery.maxTokenBudget` elision). When run **interactively** (no parent
|
|
661
|
+
> aggregator), this JSON envelope is optional — relay terminal state to the
|
|
662
|
+
> operator in prose instead — but the **no-park rule still holds**: never end
|
|
663
|
+
> an interactive turn with an unconfirmed merge either; block on the watch,
|
|
664
|
+
> confirm, and report the merged outcome.
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
585
668
|
## Idempotence
|
|
586
669
|
|
|
587
670
|
- `single-story-init.js` re-prints the same `workCwd` without recreating
|
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
|