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.
Files changed (30) hide show
  1. package/.agents/instructions.md +7 -0
  2. package/.agents/rules/git-conventions.md +13 -1
  3. package/.agents/scripts/boot-sweep.js +36 -4
  4. package/.agents/scripts/git-cleanup.js +8 -0
  5. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  6. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  7. package/.agents/scripts/lib/framework-version.js +210 -0
  8. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  9. package/.agents/scripts/lib/orchestration/epic-cleanup.js +41 -5
  10. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  11. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  15. package/.agents/scripts/lib/single-story-sweep.js +60 -5
  16. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  17. package/.agents/scripts/providers/github/tickets.js +18 -1
  18. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  19. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  20. package/.agents/skills/skills.index.json +3 -3
  21. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  22. package/.agents/workflows/git-cleanup.md +72 -18
  23. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  24. package/.agents/workflows/helpers/deliver-epic.md +22 -3
  25. package/.agents/workflows/helpers/epic-audit.md +60 -2
  26. package/.agents/workflows/helpers/parallel-tooling.md +9 -2
  27. package/.agents/workflows/helpers/plan-epic.md +32 -14
  28. package/.agents/workflows/loops/nightly-audit.md +9 -1
  29. package/docs/CHANGELOG.md +15 -0
  30. 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
- if (plan.candidates.length === 0) {
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: plan.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 ${plan.candidates.length} candidate(s) protected; no reap.`,
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: plan.candidates.length,
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
- candidateCount: plan.candidates.length,
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 JSON byte sequence the parser's
817
- * meta round-trip and the unit suite assert against.
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 head = typeof body === 'string' ? body : '';
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 dispatcher passes the Epic ID as the Skill argument. The Skill itself
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 passes the Epic ID as the Skill argument. The Skill itself reads:
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-04T23:12:38.376Z",
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 main context window clean.",
381
- "policyCapsuleBullets": 7,
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 main context window clean.
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 main context window clean.
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 main agent must always verify the subagent's output
32
- before incorporating it into the final solution.
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 main agent's mental model remains the source of
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 main context window from being flooded.
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. With `--remote`,
22
- the planner additionally enumerates `refs/remotes/origin/*` and
23
- reaps any **remote-only** merged branches — branches whose local
24
- ref is already gone (or never existed) but whose `origin/<branch>`
25
- still points at a merged PR.
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 semantics:
204
-
205
- - A branch is a candidate iff it is not `<base>`, not the current
206
- HEAD, not in `git config branch.protectedBranches`, and either has a
207
- merged PR (`gh pr list --head <branch> --state merged`) or appears in
208
- `git branch --merged <base>`.
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 distinguishes two unreapable cases:
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
- The `--remote` flag also opts the planner into a **remote-only
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 the executor runs only the `git push --delete origin/<branch>`
229
- path for them (no local `git branch -D` is attempted — there is no
230
- local branch to delete).
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