mandrel 1.86.0 → 1.87.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/instructions.md +7 -0
- package/.agents/rules/git-conventions.md +13 -1
- package/.agents/scripts/boot-sweep.js +36 -4
- package/.agents/scripts/git-cleanup.js +8 -0
- package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
- package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
- package/.agents/scripts/lib/framework-version.js +210 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +41 -5
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
- package/.agents/scripts/lib/single-story-sweep.js +60 -5
- package/.agents/scripts/lib/story-body/story-body.js +81 -4
- package/.agents/scripts/providers/github/tickets.js +18 -1
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
- package/.agents/workflows/git-cleanup.md +72 -18
- package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
- package/.agents/workflows/helpers/deliver-epic.md +22 -3
- package/.agents/workflows/helpers/epic-audit.md +60 -2
- package/.agents/workflows/helpers/parallel-tooling.md +9 -2
- package/.agents/workflows/helpers/plan-epic.md +32 -14
- package/.agents/workflows/loops/nightly-audit.md +9 -1
- package/docs/CHANGELOG.md +15 -0
- package/package.json +1 -1
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
* lockfile around plan + execute. On lock contention the
|
|
29
29
|
* sweep is skipped (the host continues — same contract as a
|
|
30
30
|
* plan failure).
|
|
31
|
+
* - Content-merged (Story #4396, report-only): a plan candidate the
|
|
32
|
+
* `git-cleanup` planner classified `detectedBy: 'content-merged'`
|
|
33
|
+
* (Story #4395's `git merge-tree --write-tree` content-equivalence
|
|
34
|
+
* probe) is a **weaker** signal than a merged PR or git ancestry —
|
|
35
|
+
* no CI/GitHub merge check ever validated its exact diff. This
|
|
36
|
+
* engine never reaps on that signal alone: content-merged
|
|
37
|
+
* candidates are pulled out of the plan before protection +
|
|
38
|
+
* execute and surfaced under `contentMerged` in the envelope so
|
|
39
|
+
* the operator can route them to `/git-cleanup` for a confirmed,
|
|
40
|
+
* eyeballed reap.
|
|
31
41
|
* - Never touches the stash stack.
|
|
32
42
|
* - Errors are caught and surfaced in the envelope. Callers MUST NOT
|
|
33
43
|
* propagate sweep failures — the host proceeds either way.
|
|
@@ -81,6 +91,7 @@ const STORY_BRANCH_INCLUDE = 'story-*';
|
|
|
81
91
|
* localDeleted: number,
|
|
82
92
|
* remoteDeleted: number,
|
|
83
93
|
* protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
|
|
94
|
+
* contentMerged: Array<{ branch: string, worktreePath: string|null }>,
|
|
84
95
|
* failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
|
|
85
96
|
* fastForward?: object,
|
|
86
97
|
* error?: string,
|
|
@@ -137,6 +148,7 @@ export async function sweepMergedBranches({
|
|
|
137
148
|
localDeleted: 0,
|
|
138
149
|
remoteDeleted: 0,
|
|
139
150
|
protected: [],
|
|
151
|
+
contentMerged: [],
|
|
140
152
|
failures: [],
|
|
141
153
|
};
|
|
142
154
|
}
|
|
@@ -213,6 +225,31 @@ export function sweepMergedStoryBranches(args = {}) {
|
|
|
213
225
|
});
|
|
214
226
|
}
|
|
215
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Split a plan's candidates into the reapable set and the report-only
|
|
230
|
+
* `content-merged` set (Story #4396). A candidate the `git-cleanup`
|
|
231
|
+
* planner classified `detectedBy: 'content-merged'` (Story #4395's
|
|
232
|
+
* `git merge-tree --write-tree` probe) never reaches protection or
|
|
233
|
+
* `executeCleanup` — it is a weaker signal than a merged PR or git
|
|
234
|
+
* ancestry, so the engine only reports it for the operator to route to
|
|
235
|
+
* `/git-cleanup`.
|
|
236
|
+
*/
|
|
237
|
+
function partitionContentMerged(candidates) {
|
|
238
|
+
const contentMerged = [];
|
|
239
|
+
const reapCandidates = [];
|
|
240
|
+
for (const candidate of candidates) {
|
|
241
|
+
if (candidate.detectedBy === 'content-merged') {
|
|
242
|
+
contentMerged.push({
|
|
243
|
+
branch: candidate.branch,
|
|
244
|
+
worktreePath: candidate.worktreePath ?? null,
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
reapCandidates.push(candidate);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { contentMerged, reapCandidates };
|
|
251
|
+
}
|
|
252
|
+
|
|
216
253
|
/**
|
|
217
254
|
* Inner: the plan + protect + execute pipeline. Kept separate so the
|
|
218
255
|
* outer engine can stay focused on the lock and fast-forward wrappers.
|
|
@@ -240,7 +277,18 @@ async function runSweepUnderLock({
|
|
|
240
277
|
return zeroResult({ error: `plan: ${msg}` });
|
|
241
278
|
}
|
|
242
279
|
|
|
243
|
-
|
|
280
|
+
const { contentMerged, reapCandidates } = partitionContentMerged(
|
|
281
|
+
plan.candidates,
|
|
282
|
+
);
|
|
283
|
+
if (contentMerged.length > 0) {
|
|
284
|
+
log.info(
|
|
285
|
+
`${logTag} ${contentMerged.length} content-merged branch(es) detected (report-only, not reaped): ${contentMerged
|
|
286
|
+
.map((c) => c.branch)
|
|
287
|
+
.join(', ')}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (reapCandidates.length === 0) {
|
|
244
292
|
log.info(`${logTag} no merged branches to reap.`);
|
|
245
293
|
return {
|
|
246
294
|
ok: true,
|
|
@@ -249,12 +297,13 @@ async function runSweepUnderLock({
|
|
|
249
297
|
localDeleted: 0,
|
|
250
298
|
remoteDeleted: 0,
|
|
251
299
|
protected: [],
|
|
300
|
+
contentMerged,
|
|
252
301
|
failures: [],
|
|
253
302
|
};
|
|
254
303
|
}
|
|
255
304
|
|
|
256
305
|
const { reapable, protectedList } = await partitionCandidates({
|
|
257
|
-
candidates:
|
|
306
|
+
candidates: reapCandidates,
|
|
258
307
|
protectionFn,
|
|
259
308
|
protectionCtx,
|
|
260
309
|
log,
|
|
@@ -263,15 +312,16 @@ async function runSweepUnderLock({
|
|
|
263
312
|
|
|
264
313
|
if (reapable.length === 0) {
|
|
265
314
|
log.info(
|
|
266
|
-
`${logTag} all ${
|
|
315
|
+
`${logTag} all ${reapCandidates.length} candidate(s) protected; no reap.`,
|
|
267
316
|
);
|
|
268
317
|
return {
|
|
269
318
|
ok: true,
|
|
270
319
|
skipped: false,
|
|
271
|
-
candidates:
|
|
320
|
+
candidates: reapCandidates.length,
|
|
272
321
|
localDeleted: 0,
|
|
273
322
|
remoteDeleted: 0,
|
|
274
323
|
protected: protectedList,
|
|
324
|
+
contentMerged,
|
|
275
325
|
failures: [],
|
|
276
326
|
};
|
|
277
327
|
}
|
|
@@ -279,7 +329,8 @@ async function runSweepUnderLock({
|
|
|
279
329
|
return executeReap({
|
|
280
330
|
reapable,
|
|
281
331
|
protectedList,
|
|
282
|
-
|
|
332
|
+
contentMerged,
|
|
333
|
+
candidateCount: reapCandidates.length,
|
|
283
334
|
cwd,
|
|
284
335
|
executeCleanupFn,
|
|
285
336
|
log,
|
|
@@ -295,6 +346,7 @@ async function runSweepUnderLock({
|
|
|
295
346
|
function executeReap({
|
|
296
347
|
reapable,
|
|
297
348
|
protectedList,
|
|
349
|
+
contentMerged,
|
|
298
350
|
candidateCount,
|
|
299
351
|
cwd,
|
|
300
352
|
executeCleanupFn,
|
|
@@ -314,6 +366,7 @@ function executeReap({
|
|
|
314
366
|
localDeleted: 0,
|
|
315
367
|
remoteDeleted: 0,
|
|
316
368
|
protected: protectedList,
|
|
369
|
+
contentMerged,
|
|
317
370
|
failures: [{ branch: null, scope: 'execute', stderr: msg }],
|
|
318
371
|
error: `execute: ${msg}`,
|
|
319
372
|
};
|
|
@@ -346,6 +399,7 @@ function executeReap({
|
|
|
346
399
|
localDeleted,
|
|
347
400
|
remoteDeleted,
|
|
348
401
|
protected: protectedList,
|
|
402
|
+
contentMerged,
|
|
349
403
|
failures: result.failures,
|
|
350
404
|
};
|
|
351
405
|
}
|
|
@@ -450,6 +504,7 @@ function zeroResult({ error }) {
|
|
|
450
504
|
localDeleted: 0,
|
|
451
505
|
remoteDeleted: 0,
|
|
452
506
|
protected: [],
|
|
507
|
+
contentMerged: [],
|
|
453
508
|
failures: [],
|
|
454
509
|
error,
|
|
455
510
|
};
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
* @module story-body
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import {
|
|
46
|
+
AUTHORED_MARKER_LINE_RE,
|
|
47
|
+
authoredMarkerLine,
|
|
48
|
+
} from '../framework-version.js';
|
|
45
49
|
import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
|
|
46
50
|
|
|
47
51
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +79,8 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
75
79
|
* @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
|
|
76
80
|
* @property {string[]} depends_on - Blocking story slugs / issue refs.
|
|
77
81
|
* @property {number|null} estimated_test_files - Test surface count or null.
|
|
82
|
+
* @property {string|null} mandrel_version - Framework version stamped at authoring, or null.
|
|
83
|
+
* @property {string|null} authored_at - Authoring date (YYYY-MM-DD) stamped at authoring, or null.
|
|
78
84
|
*/
|
|
79
85
|
|
|
80
86
|
/**
|
|
@@ -253,14 +259,21 @@ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
|
|
|
253
259
|
* otherwise-valid Story body. A parse failure degrades to the absent-meta
|
|
254
260
|
* defaults instead of throwing.
|
|
255
261
|
*
|
|
262
|
+
* The `mandrel_version` / `authored_at` provenance stamp (written once at
|
|
263
|
+
* authoring time by the ticket-creation path) is recovered here too so a later
|
|
264
|
+
* `parse → serialize` preserves the originally-authored version verbatim
|
|
265
|
+
* rather than dropping or re-deriving it.
|
|
266
|
+
*
|
|
256
267
|
* @param {string} markdown
|
|
257
|
-
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null }}
|
|
268
|
+
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null, mandrel_version: string|null, authored_at: string|null }}
|
|
258
269
|
*/
|
|
259
270
|
function extractMeta(markdown) {
|
|
260
271
|
const result = {
|
|
261
272
|
wide: null,
|
|
262
273
|
reason_to_exist: null,
|
|
263
274
|
estimated_test_files: null,
|
|
275
|
+
mandrel_version: null,
|
|
276
|
+
authored_at: null,
|
|
264
277
|
};
|
|
265
278
|
const match = markdown.match(META_BLOCK_RE);
|
|
266
279
|
if (!match) return result;
|
|
@@ -279,6 +292,15 @@ function extractMeta(markdown) {
|
|
|
279
292
|
if (typeof parsed.estimated_test_files === 'number') {
|
|
280
293
|
result.estimated_test_files = parsed.estimated_test_files;
|
|
281
294
|
}
|
|
295
|
+
if (
|
|
296
|
+
typeof parsed.mandrel_version === 'string' &&
|
|
297
|
+
parsed.mandrel_version.trim()
|
|
298
|
+
) {
|
|
299
|
+
result.mandrel_version = parsed.mandrel_version.trim();
|
|
300
|
+
}
|
|
301
|
+
if (typeof parsed.authored_at === 'string' && parsed.authored_at.trim()) {
|
|
302
|
+
result.authored_at = parsed.authored_at.trim();
|
|
303
|
+
}
|
|
282
304
|
return result;
|
|
283
305
|
}
|
|
284
306
|
|
|
@@ -409,6 +431,14 @@ function splitSections(markdown) {
|
|
|
409
431
|
continue;
|
|
410
432
|
}
|
|
411
433
|
|
|
434
|
+
// The visible `> 🏷️ Authored with Mandrel …` provenance marker is
|
|
435
|
+
// machine-managed metadata too (emitted alongside the meta block by the
|
|
436
|
+
// authoring path). Skip it so it never bleeds into the trailing structured
|
|
437
|
+
// section (e.g. `## Verify`); the value round-trips via the meta block.
|
|
438
|
+
if (AUTHORED_MARKER_LINE_RE.test(line)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
412
442
|
if (inPreamble) {
|
|
413
443
|
preambleLines.push(line);
|
|
414
444
|
} else if (currentSection !== null) {
|
|
@@ -456,6 +486,8 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
456
486
|
reason_to_exist: null,
|
|
457
487
|
depends_on: extractBlockedBy(footer),
|
|
458
488
|
estimated_test_files: null,
|
|
489
|
+
mandrel_version: null,
|
|
490
|
+
authored_at: null,
|
|
459
491
|
};
|
|
460
492
|
return {
|
|
461
493
|
body,
|
|
@@ -602,6 +634,8 @@ export function parse(input) {
|
|
|
602
634
|
const estimated_test_files = meta.estimated_test_files;
|
|
603
635
|
const wide = meta.wide;
|
|
604
636
|
const reason_to_exist = meta.reason_to_exist;
|
|
637
|
+
const mandrel_version = meta.mandrel_version;
|
|
638
|
+
const authored_at = meta.authored_at;
|
|
605
639
|
if (estimated_test_files === null) {
|
|
606
640
|
warnings.push(
|
|
607
641
|
'test-surface-unestimated: estimated_test_files not present.',
|
|
@@ -619,6 +653,8 @@ export function parse(input) {
|
|
|
619
653
|
reason_to_exist,
|
|
620
654
|
depends_on: dependsOn,
|
|
621
655
|
estimated_test_files,
|
|
656
|
+
mandrel_version,
|
|
657
|
+
authored_at,
|
|
622
658
|
};
|
|
623
659
|
|
|
624
660
|
return {
|
|
@@ -699,6 +735,16 @@ function parseStructuredObject(obj) {
|
|
|
699
735
|
);
|
|
700
736
|
}
|
|
701
737
|
|
|
738
|
+
// Provenance stamp (preserved verbatim; never re-derived here).
|
|
739
|
+
const mandrel_version =
|
|
740
|
+
typeof obj.mandrel_version === 'string' && obj.mandrel_version.trim()
|
|
741
|
+
? obj.mandrel_version.trim()
|
|
742
|
+
: null;
|
|
743
|
+
const authored_at =
|
|
744
|
+
typeof obj.authored_at === 'string' && obj.authored_at.trim()
|
|
745
|
+
? obj.authored_at.trim()
|
|
746
|
+
: null;
|
|
747
|
+
|
|
702
748
|
const body = {
|
|
703
749
|
goal,
|
|
704
750
|
changes,
|
|
@@ -710,6 +756,8 @@ function parseStructuredObject(obj) {
|
|
|
710
756
|
reason_to_exist,
|
|
711
757
|
depends_on,
|
|
712
758
|
estimated_test_files,
|
|
759
|
+
mandrel_version,
|
|
760
|
+
authored_at,
|
|
713
761
|
};
|
|
714
762
|
|
|
715
763
|
return {
|
|
@@ -812,9 +860,11 @@ const SERIALIZE_SECTIONS = [
|
|
|
812
860
|
* `estimated_test_files`). Returns the empty string when no meta field is
|
|
813
861
|
* present so {@link serialize} appends nothing.
|
|
814
862
|
*
|
|
815
|
-
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`
|
|
816
|
-
* is load-bearing: it fixes the serialized
|
|
817
|
-
* meta round-trip and the unit suite assert
|
|
863
|
+
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files` →
|
|
864
|
+
* `mandrel_version` → `authored_at`) is load-bearing: it fixes the serialized
|
|
865
|
+
* JSON byte sequence the parser's meta round-trip and the unit suite assert
|
|
866
|
+
* against. The provenance stamp keys are appended **last** so every
|
|
867
|
+
* pre-existing (stamp-less) body serialises byte-identically to before.
|
|
818
868
|
*
|
|
819
869
|
* @param {StoryBody} body
|
|
820
870
|
* @returns {string}
|
|
@@ -832,10 +882,36 @@ function serializeMetaBlock(body) {
|
|
|
832
882
|
if (typeof body.estimated_test_files === 'number') {
|
|
833
883
|
metaFields.estimated_test_files = body.estimated_test_files;
|
|
834
884
|
}
|
|
885
|
+
if (typeof body.mandrel_version === 'string' && body.mandrel_version.trim()) {
|
|
886
|
+
metaFields.mandrel_version = body.mandrel_version.trim();
|
|
887
|
+
}
|
|
888
|
+
if (typeof body.authored_at === 'string' && body.authored_at.trim()) {
|
|
889
|
+
metaFields.authored_at = body.authored_at.trim();
|
|
890
|
+
}
|
|
835
891
|
if (Object.keys(metaFields).length === 0) return '';
|
|
836
892
|
return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
837
893
|
}
|
|
838
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Build the visible `> 🏷️ Authored with Mandrel v<version> · <date>` marker
|
|
897
|
+
* line when the body carries a complete provenance stamp
|
|
898
|
+
* (`mandrel_version` + `authored_at`). Emitted just above the meta block so it
|
|
899
|
+
* round-trips with the hidden field. Returns the empty string when either
|
|
900
|
+
* field is absent, so every pre-existing (stamp-less) body serialises
|
|
901
|
+
* byte-identically to before.
|
|
902
|
+
*
|
|
903
|
+
* @param {StoryBody} body
|
|
904
|
+
* @returns {string}
|
|
905
|
+
*/
|
|
906
|
+
function serializeAuthoredMarker(body) {
|
|
907
|
+
const version =
|
|
908
|
+
typeof body.mandrel_version === 'string' ? body.mandrel_version.trim() : '';
|
|
909
|
+
const authoredAt =
|
|
910
|
+
typeof body.authored_at === 'string' ? body.authored_at.trim() : '';
|
|
911
|
+
if (!version || !authoredAt) return '';
|
|
912
|
+
return `\n\n${authoredMarkerLine({ version, authoredAt })}`;
|
|
913
|
+
}
|
|
914
|
+
|
|
839
915
|
/**
|
|
840
916
|
* Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
|
|
841
917
|
* lines). Returns the empty string when `opts.includeFooter` is falsy.
|
|
@@ -888,6 +964,7 @@ export function serialize(body, opts = {}) {
|
|
|
888
964
|
|
|
889
965
|
return (
|
|
890
966
|
sections.join('\n\n') +
|
|
967
|
+
serializeAuthoredMarker(body) +
|
|
891
968
|
serializeMetaBlock(body) +
|
|
892
969
|
serializeFooter(body, opts)
|
|
893
970
|
);
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { parseBlockedBy, parseBlocks } from '../../lib/dependency-parser.js';
|
|
25
|
+
import { stampFrameworkVersion } from '../../lib/framework-version.js';
|
|
25
26
|
import { Logger } from '../../lib/Logger.js';
|
|
26
27
|
import { TYPE_LABELS } from '../../lib/label-constants.js';
|
|
27
28
|
import { addIssueToBoard } from './board-add.js';
|
|
@@ -56,11 +57,20 @@ const SEARCH_PAGE_CAP = 10;
|
|
|
56
57
|
* arrays on the Story body authored by the decomposer; there is no
|
|
57
58
|
* server-side rendering of a four-section payload at create time.
|
|
58
59
|
*
|
|
60
|
+
* Story #4382 — this is also where a Story body is stamped, once, with the
|
|
61
|
+
* running Mandrel framework version and authoring date (hidden `mandrel_version`
|
|
62
|
+
* / `authored_at` meta field + a visible `> 🏷️ Authored with Mandrel …`
|
|
63
|
+
* marker) via {@link stampFrameworkVersion}. The stamp is immutable: a body
|
|
64
|
+
* that already carries a version (e.g. a reconciler re-create) is preserved
|
|
65
|
+
* verbatim. The `stamp` override exists for deterministic tests; production
|
|
66
|
+
* callers omit it so the running version and today's date are used.
|
|
67
|
+
*
|
|
59
68
|
* @param {{
|
|
60
69
|
* body: string,
|
|
61
70
|
* parentId: number,
|
|
62
71
|
* epicId?: number,
|
|
63
72
|
* dependencies?: number[],
|
|
73
|
+
* stamp?: { version?: string, authoredAt?: string } | false,
|
|
64
74
|
* }} opts
|
|
65
75
|
* @returns {string}
|
|
66
76
|
*
|
|
@@ -75,8 +85,15 @@ export function composeStoryBody({
|
|
|
75
85
|
parentId,
|
|
76
86
|
epicId,
|
|
77
87
|
dependencies = [],
|
|
88
|
+
stamp,
|
|
78
89
|
}) {
|
|
79
|
-
const
|
|
90
|
+
const rawHead = typeof body === 'string' ? body : '';
|
|
91
|
+
// `stamp === false` → footer-only recomposition: the caller (the reconciler
|
|
92
|
+
// UPDATE/diff path) owns stamp preservation itself and must NOT introduce a
|
|
93
|
+
// fresh authoring stamp, which would churn or bump the version on every
|
|
94
|
+
// reconcile. Every other call is a create — stamp once (immutably).
|
|
95
|
+
const head =
|
|
96
|
+
stamp === false ? rawHead : stampFrameworkVersion(rawHead, stamp ?? {});
|
|
80
97
|
const lines = ['---', `parent: #${parentId}`];
|
|
81
98
|
if (epicId !== undefined && epicId !== null) {
|
|
82
99
|
lines.push(`Epic: #${epicId}`);
|
|
@@ -32,6 +32,11 @@ allowed_tools:
|
|
|
32
32
|
Senior Project Manager + Orchestrator, acting as a **holistic critic** with
|
|
33
33
|
fresh context — deliberately *separate* from `epic-plan-decompose-author` (the
|
|
34
34
|
generator) so the pass is a fresh-context review, not a same-pass self-critique.
|
|
35
|
+
The `/plan` workflow delivers that fresh context by **dispatching this skill
|
|
36
|
+
inside a genuine sub-agent** (`Agent` tool, `subagent_type: general-purpose`) at
|
|
37
|
+
Phase 8.3, rather than activating it inline in the authoring turn — the
|
|
38
|
+
sub-agent does not inherit the conversation that authored the draft, so the
|
|
39
|
+
critic cannot grade its own homework.
|
|
35
40
|
|
|
36
41
|
> **Read [`examples.md`](./examples.md) on demand** for the extended rationale:
|
|
37
42
|
> why this critic runs with fresh context, why scope conservation is your
|
|
@@ -50,8 +55,8 @@ emit a plan the validator would reject.
|
|
|
50
55
|
|
|
51
56
|
## Inputs
|
|
52
57
|
|
|
53
|
-
The
|
|
54
|
-
reads:
|
|
58
|
+
The `/plan` workflow dispatches this skill inside a fresh-context sub-agent,
|
|
59
|
+
passing the Epic ID as the Skill argument. The Skill itself reads:
|
|
55
60
|
|
|
56
61
|
- `temp/epic-<Epic_ID>/tickets.json` — the **draft** Story array the
|
|
57
62
|
`epic-plan-decompose-author` Skill wrote. This is the consolidation input.
|
|
@@ -31,7 +31,12 @@ allowed_tools:
|
|
|
31
31
|
Senior Engineer + Architect, acting as a **fresh-context pre-mortem critic** —
|
|
32
32
|
deliberately *separate* from `epic-plan-decompose-author` (the generator) and
|
|
33
33
|
`epic-plan-consolidate` (the scope-preserving merge critic) so it is a
|
|
34
|
-
fresh-context, code-reading review, not a same-pass self-critique.
|
|
34
|
+
fresh-context, code-reading review, not a same-pass self-critique. The `/plan`
|
|
35
|
+
workflow delivers that fresh context by **dispatching this skill inside a
|
|
36
|
+
genuine sub-agent** (`Agent` tool, `subagent_type: general-purpose`) at Phase
|
|
37
|
+
8.5, rather than activating it inline in the authoring turn — the sub-agent does
|
|
38
|
+
not inherit the authoring conversation, so its code-reading review is
|
|
39
|
+
independent of the draft it grades.
|
|
35
40
|
|
|
36
41
|
> **Read [`examples.md`](./examples.md) on demand** for the extended rationale:
|
|
37
42
|
> why this critic opens the actual cited code, why it is additive-recommendation
|
|
@@ -52,7 +57,8 @@ surfaces reaches GitHub unreviewed.
|
|
|
52
57
|
|
|
53
58
|
## Inputs
|
|
54
59
|
|
|
55
|
-
The workflow
|
|
60
|
+
The `/plan` workflow dispatches this skill inside a fresh-context sub-agent,
|
|
61
|
+
passing the Epic ID as the Skill argument. The Skill itself reads:
|
|
56
62
|
|
|
57
63
|
- `temp/epic-<Epic_ID>/tickets.json` — the **draft** (or consolidated) Story
|
|
58
64
|
array. This is the pre-mortem subject.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-07-
|
|
2
|
+
"generatedAt": "2026-07-08T12:24:41.101Z",
|
|
3
3
|
"generator": "generate-skills-index.js@1",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
@@ -377,8 +377,8 @@
|
|
|
377
377
|
"tier": "stack",
|
|
378
378
|
"category": "architecture",
|
|
379
379
|
"path": ".agents/skills/stack/architecture/subagent-orchestration/SKILL.md",
|
|
380
|
-
"description": "Coordinates complex tasks via task-isolated subagents. Use when one objective is too large for a single agent or when independent work streams should run concurrently with minimal context bleed. One objective per subagent; summarize before returning to keep the
|
|
381
|
-
"policyCapsuleBullets":
|
|
380
|
+
"description": "Coordinates complex tasks via task-isolated subagents. Use when one objective is too large for a single agent or when independent work streams should run concurrently with minimal context bleed. One objective per subagent; summarize before returning to keep the orchestrator's context window clean. Applies recursively — an orchestrator at any supported nesting depth applies the same policy to its own children.",
|
|
381
|
+
"policyCapsuleBullets": 8,
|
|
382
382
|
"allowedTools": null,
|
|
383
383
|
"vendor": null
|
|
384
384
|
},
|
|
@@ -4,11 +4,33 @@ description:
|
|
|
4
4
|
Coordinates complex tasks via task-isolated subagents. Use when one objective
|
|
5
5
|
is too large for a single agent or when independent work streams should run
|
|
6
6
|
concurrently with minimal context bleed. One objective per subagent;
|
|
7
|
-
summarize before returning to keep the
|
|
7
|
+
summarize before returning to keep the orchestrator's context window clean.
|
|
8
|
+
Applies recursively — an orchestrator at any supported nesting depth applies
|
|
9
|
+
the same policy to its own children.
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
# Skill: Subagent Orchestration
|
|
11
13
|
|
|
14
|
+
## Recursive orchestration model
|
|
15
|
+
|
|
16
|
+
This skill describes **recursive orchestration**, not a fixed two-tier
|
|
17
|
+
"main agent vs. subagents" split. An **orchestrator** is any agent that
|
|
18
|
+
dispatches sub-agents; a sub-agent is itself an orchestrator over its own
|
|
19
|
+
children. The Claude Code harness carries the `Agent` tool into sub-agents
|
|
20
|
+
(verified nesting depth 2, announced max depth 5; see
|
|
21
|
+
[#2870](https://github.com/dsj1984/mandrel/issues/2870)), so the same
|
|
22
|
+
one-objective / verify / parallelize policy applies **at every level** —
|
|
23
|
+
substitute "orchestrator" for "main agent" and "child" for "subagent"
|
|
24
|
+
throughout and the rules hold unchanged. Keeping a given dispatch level
|
|
25
|
+
flat remains a legitimate **design choice** (e.g. the `/deliver` wave
|
|
26
|
+
loop), but it is no longer forced by a harness limitation.
|
|
27
|
+
|
|
28
|
+
The cost caution compounds with depth: every nesting level re-pays the
|
|
29
|
+
full always-loaded context, so an orchestrator MUST weigh the depth it
|
|
30
|
+
opens against its budget (see
|
|
31
|
+
[`instructions.md` § 4](../../../../instructions.md)) and stay within the
|
|
32
|
+
supported depth envelope.
|
|
33
|
+
|
|
12
34
|
## Policy Capsule
|
|
13
35
|
|
|
14
36
|
- Dispatch one objective per subagent; never bundle unrelated goals into a single delegation.
|
|
@@ -16,11 +38,13 @@ description:
|
|
|
16
38
|
- Specify the expected return format explicitly (JSON summary, diff, bullet list) in every handoff.
|
|
17
39
|
- Verify the subagent's output before incorporating it; treat returned artifacts as untrusted until checked.
|
|
18
40
|
- Run non-dependent subagents in parallel; serialize only when one subagent's output is required input for another.
|
|
19
|
-
- Require a concise summary back from each subagent to keep the
|
|
41
|
+
- Require a concise summary back from each subagent to keep the orchestrator's context window clean.
|
|
20
42
|
- Investigate subagent failures rather than retrying blindly with the same prompt.
|
|
43
|
+
- Respect the nesting depth budget; each level opened re-pays the always-loaded context, so orchestrate deeper only when the isolation or parallelism gain justifies the cost.
|
|
21
44
|
|
|
22
45
|
Internal protocol for managing complex tasks through the creation and
|
|
23
|
-
coordination of subagents
|
|
46
|
+
coordination of subagents, applied recursively by the orchestrator at any
|
|
47
|
+
supported depth.
|
|
24
48
|
|
|
25
49
|
## 1. Core Principles
|
|
26
50
|
|
|
@@ -28,8 +52,11 @@ coordination of subagents.
|
|
|
28
52
|
with multiple unrelated tasks.
|
|
29
53
|
- **Minimal Context:** Provide only the necessary context (files, docs, specific
|
|
30
54
|
goal) to keep the subagent focused and token-efficient.
|
|
31
|
-
- **Verification:** The
|
|
32
|
-
before incorporating it into the
|
|
55
|
+
- **Verification:** The orchestrator must always verify each child's output
|
|
56
|
+
before incorporating it into its own result — at every level of the tree.
|
|
57
|
+
- **Depth Awareness:** Orchestration is recursive; before opening a deeper
|
|
58
|
+
level, confirm the work justifies re-paying the always-loaded context and
|
|
59
|
+
that the nesting stays within the supported depth envelope.
|
|
33
60
|
|
|
34
61
|
## 2. Operation Standards
|
|
35
62
|
|
|
@@ -38,11 +65,12 @@ coordination of subagents.
|
|
|
38
65
|
- **Error Handling:** If a subagent fails or returns an ambiguous result,
|
|
39
66
|
investigate the failure rather than retrying blindly.
|
|
40
67
|
- **Parallelism:** Use subagents to perform non-dependent tasks concurrently
|
|
41
|
-
(e.g., auditing three different modules simultaneously).
|
|
68
|
+
(e.g., auditing three different modules simultaneously). A child that is
|
|
69
|
+
itself an orchestrator may parallelize its own sub-units the same way.
|
|
42
70
|
|
|
43
71
|
## 3. Best Practices
|
|
44
72
|
|
|
45
|
-
- **State Sync:** Ensure the
|
|
73
|
+
- **State Sync:** Ensure the orchestrator's mental model remains the source of
|
|
46
74
|
truth if multiple subagents modify the codebase.
|
|
47
75
|
- **Summarization:** Require subagents to provide a concise summary of their
|
|
48
|
-
findings to prevent the
|
|
76
|
+
findings to prevent the orchestrator's context window from being flooded.
|
|
@@ -18,11 +18,17 @@ confirmation:
|
|
|
18
18
|
3. **reap merged local branches** — the existing squash-aware
|
|
19
19
|
`gh pr list --state merged` + `git branch --merged <base>` sweep,
|
|
20
20
|
with attached worktrees removed first. Optionally also deletes the
|
|
21
|
-
`origin/<branch>` ref when `--remote` is passed.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
`origin/<branch>` ref when `--remote` is passed. Every default run
|
|
22
|
+
also **enumerates** `refs/remotes/origin/*` and reports any
|
|
23
|
+
**remote-only** merged branches — branches whose local ref is
|
|
24
|
+
already gone (or never existed) but whose `origin/<branch>` still
|
|
25
|
+
points at a merged PR — even without `--remote`; `--remote` is still
|
|
26
|
+
required to *delete* them. A third branch, whose content already
|
|
27
|
+
landed in `<base>` by another route (a squash-merged Epic PR, a
|
|
28
|
+
cherry-pick, a manual `merge --squash`), is caught by a
|
|
29
|
+
**content-equivalence probe** (`git merge-tree --write-tree`,
|
|
30
|
+
git ≥ 2.38) even when it has no merged PR of its own and is not a
|
|
31
|
+
git ancestor of `<base>`.
|
|
26
32
|
4. **triage `git stash` entries** — list every stash and prompt for
|
|
27
33
|
`drop / keep / quit` per entry (or pass `--drop-stashes <ref>` for
|
|
28
34
|
non-interactive use).
|
|
@@ -150,6 +156,10 @@ programmatic consumption:
|
|
|
150
156
|
"detectedBy": "gh"
|
|
151
157
|
}
|
|
152
158
|
],
|
|
159
|
+
"skipped": [
|
|
160
|
+
{ "branch": "story-4200", "reason": "not-merged", "lastCommitAt": "2026-05-01T00:00:00Z" }
|
|
161
|
+
],
|
|
162
|
+
"ghDegraded": false,
|
|
153
163
|
"worktrees": [{ "path": "C:/repo/.worktrees/fix-foo", "ok": true, "dirty": false }],
|
|
154
164
|
"local": [{ "branch": "fix/foo", "ok": true, "alreadyGone": false }],
|
|
155
165
|
"remote": [{ "branch": "fix/foo", "ok": true, "alreadyGone": true }],
|
|
@@ -200,18 +210,51 @@ follow-up prune when `--remote` is set, so passing both is idempotent
|
|
|
200
210
|
|
|
201
211
|
### branches
|
|
202
212
|
|
|
203
|
-
The merged-branch sweep
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
The merged-branch sweep recognizes three detection signals, in order:
|
|
214
|
+
|
|
215
|
+
1. **`detectedBy: 'gh'`** — the branch has a merged PR
|
|
216
|
+
(`gh pr list --head <branch> --state all`, classified by the
|
|
217
|
+
**latest** PR's state).
|
|
218
|
+
2. **`detectedBy: 'git-merged'`** — the branch is a git ancestor of
|
|
219
|
+
`<base>` (`git branch --merged <base>`), or of `origin/<base>` when
|
|
220
|
+
that remote-tracking ref exists (unioned so a stale local `<base>` —
|
|
221
|
+
fast-forward phase skipped, or `--branches` run alone — no longer
|
|
222
|
+
hides a branch already merged on the remote).
|
|
223
|
+
3. **`detectedBy: 'content-merged'`** (Story #4395) — the branch has no
|
|
224
|
+
reapable PR verdict and is not an ancestor of `<base>` under either
|
|
225
|
+
anchor, but simulating the merge via
|
|
226
|
+
`git merge-tree --write-tree <base> <branch>` (git ≥ 2.38) produces a
|
|
227
|
+
tree identical to `<base>`'s own tree — i.e. applying the branch's
|
|
228
|
+
changes on top of `<base>` is a content no-op. This catches
|
|
229
|
+
`story-<id>` branches merged into `epic/<id>` whose Epic PR
|
|
230
|
+
**squash-merged** to `main` (the story commits are not ancestors of
|
|
231
|
+
`main` and the story branch usually has no PR of its own), and any
|
|
232
|
+
other branch whose content landed via a different route (a renamed
|
|
233
|
+
head, a cherry-pick, a manual `merge --squash`). When git rejects
|
|
234
|
+
`--write-tree` (git < 2.38) or the simulated merge conflicts, the
|
|
235
|
+
probe is inconclusive and the branch keeps its existing `not-merged`
|
|
236
|
+
skip — the signal never guesses. `content-merged` candidates render
|
|
237
|
+
with a "weaker signal — verify before deleting" annotation in the
|
|
238
|
+
dry-run list and are called out separately in the confirmation
|
|
239
|
+
prompt, since — unlike a merged PR or git ancestry — no CI or GitHub
|
|
240
|
+
merge check ever validated this branch's exact diff.
|
|
241
|
+
|
|
242
|
+
Other candidate semantics:
|
|
243
|
+
|
|
244
|
+
- A branch is a candidate iff it is not `<base>`, not the current HEAD,
|
|
245
|
+
not in `git config branch.protectedBranches`, and matches one of the
|
|
246
|
+
three signals above.
|
|
209
247
|
- When a candidate has an attached worktree, the worktree is removed
|
|
210
248
|
(force if dirty) **before** `git branch -D`, mirroring the pattern in
|
|
211
249
|
[`worktree-lifecycle.md`](helpers/worktree-lifecycle.md).
|
|
212
250
|
- `--remote` is required on top of `--execute` to touch `origin/`.
|
|
251
|
+
- A throwing `gh` runner (auth failure, rate limit, missing binary) no
|
|
252
|
+
longer aborts the run: the branches phase logs one warning and
|
|
253
|
+
continues with the git-only signals (ancestry + content-equivalence).
|
|
254
|
+
The JSON envelope's `ghDegraded: true` records that this happened for
|
|
255
|
+
the run, and the dry-run text carries a matching warning line.
|
|
213
256
|
|
|
214
|
-
The skip taxonomy
|
|
257
|
+
The skip taxonomy:
|
|
215
258
|
|
|
216
259
|
- `reason: 'protected'` — the base branch or a name in
|
|
217
260
|
`git config branch.protectedBranches`. Not reapable; ignore.
|
|
@@ -219,15 +262,26 @@ The skip taxonomy distinguishes two unreapable cases:
|
|
|
219
262
|
`git checkout <base>`. The dry-run output surfaces a remediation
|
|
220
263
|
hint so the operator sees the recovery path without having to look
|
|
221
264
|
in the JSON envelope.
|
|
222
|
-
|
|
223
|
-
|
|
265
|
+
- `reason: 'tip-diverged-from-merge'` — the latest PR merged, but the
|
|
266
|
+
branch's tip has since moved past the merged commit (a post-merge
|
|
267
|
+
force-push). The dry-run line names both SHAs and a remediation hint
|
|
268
|
+
(delete manually via `git branch -D <branch>`, or push the follow-up
|
|
269
|
+
commit).
|
|
270
|
+
- `reason: 'not-merged'` — none of the three detection signals matched.
|
|
271
|
+
Previously silent; the dry-run output now lists every surviving
|
|
272
|
+
`not-merged` branch as a one-line-per-branch summary with its
|
|
273
|
+
last-commit age, so the operator can see why a leftover branch isn't
|
|
274
|
+
reaped instead of hunting for it by hand.
|
|
275
|
+
|
|
276
|
+
Every default run also opts the planner into a **remote-only
|
|
224
277
|
enumeration pass**: in addition to walking `refs/heads/*`, the planner
|
|
225
278
|
also walks `refs/remotes/origin/*` and emits candidates for any branch
|
|
226
279
|
that exists on `origin` with a merged PR but has no local ref. These
|
|
227
|
-
candidates carry `detectedBy: 'remote-only'` and `localExists: false
|
|
228
|
-
and
|
|
229
|
-
path
|
|
230
|
-
local branch
|
|
280
|
+
candidates carry `detectedBy: 'remote-only'` and `localExists: false`
|
|
281
|
+
and are always shown in the dry-run list; **deleting** them (via the
|
|
282
|
+
`git push --delete origin/<branch>` path — no local `git branch -D` is
|
|
283
|
+
attempted, since there is no local branch) still requires `--remote` on
|
|
284
|
+
top of `--execute`, unchanged.
|
|
231
285
|
|
|
232
286
|
### stashes
|
|
233
287
|
|