mandrel 1.85.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 (41) hide show
  1. package/.agents/instructions.md +7 -0
  2. package/.agents/rules/git-conventions.md +45 -0
  3. package/.agents/scripts/boot-sweep.js +215 -0
  4. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  5. package/.agents/scripts/git-cleanup.js +8 -0
  6. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  7. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  8. package/.agents/scripts/lib/framework-version.js +210 -0
  9. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  10. package/.agents/scripts/lib/orchestration/epic-cleanup.js +330 -6
  11. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  15. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  16. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  18. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  19. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  20. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  21. package/.agents/scripts/lib/single-story-sweep.js +239 -57
  22. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  23. package/.agents/scripts/providers/github/tickets.js +18 -1
  24. package/.agents/scripts/single-story-init.js +7 -51
  25. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  26. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  27. package/.agents/skills/skills.index.json +3 -3
  28. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  29. package/.agents/workflows/git-cleanup.md +72 -18
  30. package/.agents/workflows/git-deliver.md +36 -0
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  32. package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
  33. package/.agents/workflows/helpers/deliver-epic.md +47 -3
  34. package/.agents/workflows/helpers/deliver-stories.md +16 -3
  35. package/.agents/workflows/helpers/epic-audit.md +60 -2
  36. package/.agents/workflows/helpers/parallel-tooling.md +9 -2
  37. package/.agents/workflows/helpers/plan-epic.md +32 -14
  38. package/.agents/workflows/loops/nightly-audit.md +9 -1
  39. package/.agents/workflows/plan.md +32 -4
  40. package/docs/CHANGELOG.md +27 -0
  41. package/package.json +1 -1
@@ -334,6 +334,13 @@ budget grounds.
334
334
  spawns (search, doc regeneration, lint, log triage) and keep
335
335
  **implementation and design** work on the default capability; name no
336
336
  specific model — let the host and operator own the concrete mapping.
337
+ **Depth compounds the cost.** Sub-agents now carry the `Agent` tool and
338
+ can nest further (verified depth 2, announced max depth 5; see
339
+ [#2870](https://github.com/dsj1984/mandrel/issues/2870)), so this
340
+ spend-per-spawn caution is not one-level — **every** nesting level
341
+ re-pays the full always-loaded context. Weigh the whole subtree's cost,
342
+ not just the immediate spawn, before opening a deeper orchestration
343
+ level, and stay within the supported depth envelope.
337
344
  - **Anti-Laziness:** NEVER use placeholder comments like
338
345
  `// ... existing code ...`, `/* rest of file */`, or
339
346
  `// implementation here`. You MUST output the ENTIRE file or the ENTIRE
@@ -120,6 +120,51 @@ rejected by `pre-push` hooks):
120
120
  (`--no-errors-on-unmatched` or equivalent) before escalating via
121
121
  `agent::blocked`.
122
122
 
123
+ ## Local checkout hygiene
124
+
125
+ **Invariant: the delivering flow owns tidying the local checkout — reaping its
126
+ own merged refs and fast-forwarding the base branch. `/git-cleanup` is a
127
+ recovery tool, not a routine chore.**
128
+
129
+ Every flow that lands work — `/deliver` (Epic and standalone-Story paths),
130
+ `/git-deliver` — is responsible for leaving the local checkout tidy without
131
+ operator intervention:
132
+
133
+ - **Fast-forwarding the base branch is owned by the flow.** The standalone
134
+ multi-Story path fast-forwards `main` itself in its summary phase (via
135
+ `git-cleanup.js --fast-forward-main --execute --yes`); the Epic path
136
+ fast-forwards `epic/<id>` / `main` on its merge-and-reap beat. No workflow
137
+ ends by telling the operator to "run `/git-cleanup` afterwards to catch up".
138
+ - **Reaping merged local refs is owned by the flow's next boot.** `/plan` and
139
+ `/git-deliver` open with a **protected boot sweep**
140
+ (`boot-sweep.js`) that fast-forwards `main`, prunes stale remote-tracking
141
+ refs, and reaps every local branch whose PR is already merged — skipping any
142
+ candidate with unpushed work, a dirty worktree, or a still-open parent
143
+ ticket. A branch a flow leaves behind (e.g. a `/git-deliver` feature branch
144
+ whose PR merges out of band) is therefore reaped automatically at the next
145
+ workflow boot, not left for the operator to sweep by hand. `boot-sweep.js`
146
+ defaults its `--include` glob to `story-*` — a bare invocation only sweeps
147
+ Story branches; `/plan` and `/git-deliver` widen the scope to their own
148
+ branch namespaces (`epic/*`, `feat/*`, `fix/*`, `chore/*`, `docs/*`,
149
+ `refactor/*`) by passing `--include` explicitly at their boot call site.
150
+ A branch the planner detects only via the weaker content-equivalence
151
+ signal (`detectedBy: 'content-merged'`, Story #4395's
152
+ `git merge-tree --write-tree` probe — content already landed in the base
153
+ branch by another route, such as a squash-merged Epic PR, with no merged
154
+ PR or git ancestry of its own) is **never** reaped by the boot sweep: it
155
+ is report-only, surfaced under `contentMerged` in the result envelope and
156
+ a routing hint in the summary line (Story #4396), so the operator can
157
+ send it to `/git-cleanup` for a confirmed, eyeballed reap.
158
+ - **`/git-cleanup` is recovery, not routine.** Run it by hand only to recover
159
+ an unusual state the automated hygiene does not cover — triaging stashes,
160
+ reaping across non-standard branch namespaces, or `--remote` pruning after a
161
+ force-push diverged a tip. It is **not** the expected way to keep `main`
162
+ current or to clear merged branches after a normal delivery; the delivering
163
+ flows already own that. If you find yourself reaching for `/git-cleanup`
164
+ after every routine `/deliver` or `/git-deliver` run, that is a signal the
165
+ owning flow's hygiene step regressed — fix the flow, do not codify the manual
166
+ sweep.
167
+
123
168
  ## Meta Labels (Retrospective Signal Routing)
124
169
 
125
170
  Two `meta::*` labels route retrospective signals into durable substrates so
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ /* node:coverage ignore file */
3
+
4
+ /**
5
+ * boot-sweep.js — protected boot-sweep CLI (Story #4373).
6
+ *
7
+ * A thin, non-interactive wrapper over the scope-agnostic
8
+ * [`sweepMergedBranches`](./lib/single-story-sweep.js) engine, exposed so
9
+ * workflow prose can invoke a *protected* boot sweep directly. Unlike the
10
+ * plain `git-cleanup.js --branches` phase (which reaps every merged
11
+ * candidate the planner surfaces), this surface always applies the
12
+ * `evaluateProtection` partition — a merged branch with unpushed work, a
13
+ * dirty worktree, or a still-open parent Story ticket is skipped, not
14
+ * reaped.
15
+ *
16
+ * The sweep is best-effort: any failure (lock contention, git/gh error)
17
+ * is swallowed and reported in the result envelope, never thrown, so a
18
+ * caller can wire it into a boot path without risking the host run.
19
+ *
20
+ * **Content-merged branches are report-only (Story #4396).** The planner
21
+ * also surfaces branches whose content already landed in the base branch
22
+ * by another route (a squash-merged Epic PR, a renamed head, a manual
23
+ * squash merge) via `detectedBy: 'content-merged'` (Story #4395's
24
+ * `git merge-tree --write-tree` probe) — a weaker signal than a merged PR
25
+ * or git ancestry, since no CI/GitHub merge check ever validated that
26
+ * branch's exact diff. This sweep never reaps on that signal alone; it
27
+ * surfaces the branches under `contentMerged` in the result envelope (and
28
+ * a routing hint in the human summary) so the operator can send them to
29
+ * `/git-cleanup` for a confirmed, eyeballed reap.
30
+ *
31
+ * Usage:
32
+ * node .agents/scripts/boot-sweep.js [--include <glob>...] \
33
+ * [--exclude <glob>...] [--current <branch>] [--base <branch>] \
34
+ * [--no-fast-forward] [--json]
35
+ *
36
+ * Defaults: `--include story-*`, fast-forward the base branch on.
37
+ * Exit code is always 0 — a boot sweep never fails its host.
38
+ */
39
+
40
+ import path from 'node:path';
41
+ import { parseArgs } from 'node:util';
42
+
43
+ import { runAsCli } from './lib/cli-utils.js';
44
+ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
45
+ import { Logger } from './lib/Logger.js';
46
+ import { createProvider } from './lib/provider-factory.js';
47
+ import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
48
+ import { sweepMergedBranches } from './lib/single-story-sweep.js';
49
+
50
+ const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
51
+
52
+ Runs the protected merged-branch boot sweep non-interactively: reaps every
53
+ local branch whose PR is MERGED and whose HEAD matches the merged headRefOid,
54
+ skipping any candidate the protection partition flags (unpushed work, dirty
55
+ worktree, still-open parent Story), then fast-forwards the base branch.
56
+ Branches detected only via the weaker content-equivalence signal
57
+ (detectedBy: 'content-merged') are never reaped here — they are reported
58
+ under "contentMerged" (and a routing hint in the summary line) for the
59
+ operator to send to /git-cleanup.
60
+
61
+ Options:
62
+ --include <glob> Branch glob to sweep (repeatable). Default: story-*
63
+ --exclude <glob> Branch glob to exclude (repeatable).
64
+ --current <branch> A branch to always exclude (e.g. the active story).
65
+ --base <branch> Base branch to fast-forward. Default: project baseBranch.
66
+ --no-fast-forward Skip the base-branch fast-forward step.
67
+ --json Emit the result envelope as JSON.
68
+ `;
69
+
70
+ /**
71
+ * Run the protected boot sweep. Best-effort: swallows any error and
72
+ * returns the sweep envelope so no caller can be blocked by a failure.
73
+ *
74
+ * DI-friendly: `injectedConfig` / `injectedProvider` let a caller (e.g.
75
+ * `epic-deliver-prepare.js`) reuse an already-resolved config + provider,
76
+ * and `injectedSweep` swaps the engine for unit tests.
77
+ *
78
+ * @param {{
79
+ * cwd?: string,
80
+ * base?: string,
81
+ * include?: string[],
82
+ * exclude?: string[],
83
+ * current?: string,
84
+ * fastForward?: boolean,
85
+ * injectedConfig?: object,
86
+ * injectedProvider?: object,
87
+ * injectedSweep?: Function,
88
+ * logger?: { info?: Function, warn?: Function },
89
+ * }} [args]
90
+ * @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
91
+ */
92
+ export async function runBootSweep({
93
+ cwd,
94
+ base,
95
+ include,
96
+ exclude,
97
+ current,
98
+ fastForward = true,
99
+ injectedConfig,
100
+ injectedProvider,
101
+ injectedSweep,
102
+ logger = Logger,
103
+ } = {}) {
104
+ const root = path.resolve(cwd ?? PROJECT_ROOT);
105
+ try {
106
+ // Config/provider resolution is inside the try so a malformed
107
+ // `.agentrc.json` (or a provider-construction throw) degrades to the
108
+ // swallowed `ok:false` envelope below rather than propagating and
109
+ // exiting non-zero — the "host continues, exit 0" boot-sweep contract
110
+ // must hold even when config resolution is the thing that fails.
111
+ const config = injectedConfig ?? resolveConfig({ cwd: root });
112
+ const provider = injectedProvider ?? createProvider(config);
113
+ const baseBranch = base ?? config.project?.baseBranch ?? 'main';
114
+
115
+ const includeGlobs =
116
+ Array.isArray(include) && include.length > 0 ? include : ['story-*'];
117
+ const excludeGlobs = Array.isArray(exclude) ? [...exclude] : [];
118
+ if (typeof current === 'string' && current.length > 0) {
119
+ excludeGlobs.push(current);
120
+ }
121
+
122
+ const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
123
+ const lockPath = path.resolve(root, tempRoot, 'boot-sweep.lock');
124
+ const lockTimeoutMs =
125
+ config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
126
+
127
+ const sweepFn = injectedSweep ?? sweepMergedBranches;
128
+ return await sweepFn({
129
+ cwd: root,
130
+ baseBranch,
131
+ include: includeGlobs,
132
+ exclude: excludeGlobs,
133
+ fastForward,
134
+ logTag: '[boot-sweep]',
135
+ logger: {
136
+ info: (m) => logger.info?.(m),
137
+ warn: (m) => logger.warn?.(m),
138
+ },
139
+ protectionCtx: buildProtectionCtx({ cwd: root, provider }),
140
+ lockPath,
141
+ lockTimeoutMs,
142
+ });
143
+ } catch (err) {
144
+ const msg = err?.message ?? String(err);
145
+ logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
146
+ return {
147
+ ok: false,
148
+ skipped: true,
149
+ error: msg,
150
+ candidates: 0,
151
+ localDeleted: 0,
152
+ remoteDeleted: 0,
153
+ protected: [],
154
+ contentMerged: [],
155
+ failures: [],
156
+ };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Build the human-readable one-line summary for a sweep result envelope.
162
+ * Exported for unit tests (Story #4396). A zero `contentMerged` count keeps
163
+ * the pre-Story #4396 line byte-identical (silent no-op summary); a nonzero
164
+ * count appends a routing hint pointing the operator at `/git-cleanup`.
165
+ *
166
+ * @param {{ localDeleted: number, remoteDeleted: number, protected?: Array, contentMerged?: Array }} result
167
+ * @returns {string}
168
+ */
169
+ export function buildSummaryLine(result) {
170
+ const protectedCount = result.protected?.length ?? 0;
171
+ const contentMergedCount = result.contentMerged?.length ?? 0;
172
+ const contentMergedSuffix =
173
+ contentMergedCount > 0
174
+ ? `; ${contentMergedCount} content-merged branch(es) left for /git-cleanup`
175
+ : '';
176
+ return `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}${contentMergedSuffix}.`;
177
+ }
178
+
179
+ async function main() {
180
+ const { values } = parseArgs({
181
+ options: {
182
+ base: { type: 'string' },
183
+ cwd: { type: 'string' },
184
+ include: { type: 'string', multiple: true, default: [] },
185
+ exclude: { type: 'string', multiple: true, default: [] },
186
+ current: { type: 'string' },
187
+ 'no-fast-forward': { type: 'boolean', default: false },
188
+ json: { type: 'boolean', default: false },
189
+ help: { type: 'boolean', short: 'h' },
190
+ },
191
+ strict: false,
192
+ });
193
+
194
+ if (values.help) {
195
+ Logger.info(HELP);
196
+ return;
197
+ }
198
+
199
+ const result = await runBootSweep({
200
+ cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
201
+ base: typeof values.base === 'string' ? values.base : undefined,
202
+ include: Array.isArray(values.include) ? values.include : [],
203
+ exclude: Array.isArray(values.exclude) ? values.exclude : [],
204
+ current: typeof values.current === 'string' ? values.current : undefined,
205
+ fastForward: values['no-fast-forward'] !== true,
206
+ });
207
+
208
+ if (values.json) {
209
+ Logger.info(JSON.stringify(result, null, 2));
210
+ } else {
211
+ Logger.info(buildSummaryLine(result));
212
+ }
213
+ }
214
+
215
+ runAsCli(import.meta.url, main, { source: 'boot-sweep' });
@@ -36,6 +36,7 @@ import fs from 'node:fs';
36
36
  import path from 'node:path';
37
37
  import { parseArgs } from 'node:util';
38
38
 
39
+ import { runBootSweep } from './boot-sweep.js';
39
40
  import { runAsCli } from './lib/cli-utils.js';
40
41
  import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
41
42
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
@@ -218,6 +219,49 @@ async function runPreflightGuardsForPrepare({
218
219
  });
219
220
  }
220
221
 
222
+ /**
223
+ * Route the Epic boot cleanup through the shared protected boot-sweep
224
+ * engine (Story #4373). Reaps merged, done `story-*` branches left over
225
+ * from prior runs — the protection partition skips any branch with
226
+ * unpushed work, a dirty worktree, or a still-open parent Story, so an
227
+ * in-flight Story is never touched. Fast-forward is off: the prepare may
228
+ * run on the Epic branch, and the fast-forward phase would otherwise
229
+ * check out the base branch.
230
+ *
231
+ * Best-effort — a sweep failure (lock contention, git/gh error) is
232
+ * swallowed and never blocks or fails the prepare. Skipped in the same
233
+ * injected-test shape the preflight guards use (a provider injected with
234
+ * no git seam) so unit tests never spawn real git/gh.
235
+ */
236
+ async function runBootSweepForPrepare({
237
+ cwd,
238
+ config,
239
+ provider,
240
+ injectedProvider,
241
+ injectedGit,
242
+ injectedSweep,
243
+ skipPreflightGuards,
244
+ }) {
245
+ const suppressed =
246
+ skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
247
+ if (suppressed) return;
248
+ try {
249
+ await runBootSweep({
250
+ cwd,
251
+ include: ['story-*'],
252
+ fastForward: false,
253
+ injectedConfig: config,
254
+ injectedProvider: provider,
255
+ injectedSweep,
256
+ logger: Logger,
257
+ });
258
+ } catch (err) {
259
+ Logger.warn(
260
+ `[epic-deliver-prepare] ⚠️ boot sweep threw (prepare continues): ${err?.message ?? err}`,
261
+ );
262
+ }
263
+ }
264
+
221
265
  /**
222
266
  * Resolve the Epic state, preferring the preflight cache (Story #3027) and
223
267
  * falling back to a fresh snapshot + wave-DAG pass on miss or baseSha
@@ -333,6 +377,7 @@ export async function runEpicDeliverPrepare({
333
377
  steal = false,
334
378
  asOperator,
335
379
  injectedGit,
380
+ injectedSweep,
336
381
  leaseHeartbeatAt,
337
382
  leaseNow,
338
383
  skipPreflightGuards = false,
@@ -365,6 +410,16 @@ export async function runEpicDeliverPrepare({
365
410
  skipPreflightGuards,
366
411
  });
367
412
 
413
+ await runBootSweepForPrepare({
414
+ cwd,
415
+ config,
416
+ provider,
417
+ injectedProvider,
418
+ injectedGit,
419
+ injectedSweep,
420
+ skipPreflightGuards,
421
+ });
422
+
368
423
  const { state, cacheStatus } = await resolvePrepareState({
369
424
  epicId,
370
425
  cwd,
@@ -55,11 +55,14 @@ import {
55
55
  computeProtectedSet,
56
56
  } from './lib/orchestration/git-cleanup/phases/filters.js';
57
57
  import {
58
+ branchLastCommitAt,
58
59
  branchTipSha,
59
60
  classifyLatestPr,
60
61
  probeAllPrs,
62
+ probeContentEquivalent,
61
63
  probeLatestPr,
62
64
  probeMergedPr,
65
+ refExists,
63
66
  } from './lib/orchestration/git-cleanup/phases/git-probes.js';
64
67
  import { parseCleanupArgs } from './lib/orchestration/git-cleanup/phases/parse-args.js';
65
68
  import {
@@ -74,6 +77,7 @@ import {
74
77
  renderExecutionLine,
75
78
  renderExecutionSummary,
76
79
  renderLatestPrSkipLine,
80
+ renderNotMergedSkipLine,
77
81
  renderPruneLine,
78
82
  } from './lib/orchestration/git-cleanup/phases/render.js';
79
83
  import {
@@ -86,6 +90,7 @@ import {
86
90
 
87
91
  // Public surface preserved for tests + `single-story-sweep.js`.
88
92
  export {
93
+ branchLastCommitAt,
89
94
  branchTipSha,
90
95
  buildAllowlistDecider,
91
96
  buildGlobFilter,
@@ -105,13 +110,16 @@ export {
105
110
  planFastForward,
106
111
  planStashes,
107
112
  probeAllPrs,
113
+ probeContentEquivalent,
108
114
  probeLatestPr,
109
115
  probeMergedPr,
116
+ refExists,
110
117
  renderDeferredLine,
111
118
  renderDryRun,
112
119
  renderExecutionLine,
113
120
  renderExecutionSummary,
114
121
  renderLatestPrSkipLine,
122
+ renderNotMergedSkipLine,
115
123
  renderPruneLine,
116
124
  stashRefIndex,
117
125
  };
@@ -1,30 +1,44 @@
1
1
  /**
2
- * subagent-agent-tool-required — refuse-and-print check.
2
+ * subagent-agent-tool-required — supported-depth guard (refuse-and-print).
3
3
  *
4
- * Detects sub-agent workflow definitions that declare access to the
5
- * `Agent` tool (or otherwise document nested Agent dispatch). Nested
6
- * Agent dispatch is not supported in this Claude Code, so any
7
- * wave-runner-as-sub-agent or cascading fan-out design that declares
8
- * `Agent` in its tool list will silently fail at runtime. The remediation
9
- * is to flatten the fan-out back to the host agent and run sub-agents at
10
- * one level only.
4
+ * Nested `Agent` dispatch from a sub-agent is **supported** on this Claude
5
+ * Code build (verified depth 2, announced max depth 5 — Claude Code
6
+ * 2.1.202, re-spiked 2026-07-08; see Epic #4385 / watch #2870). A level-1
7
+ * sub-agent carries `Agent` in its primary toolset and can spawn a working
8
+ * level-2 sub-agent. Declaring `Agent` in a sub-agent workflow is therefore
9
+ * a legitimate design choice, **not** an automatic runtime failure.
10
+ *
11
+ * What this check guards is the one case that still fails: a fan-out whose
12
+ * declared nesting depth exceeds the announced/supported ceiling. A dispatch
13
+ * chain deeper than the harness supports will silently fail at runtime, so a
14
+ * workflow that declares `Agent` together with a `nesting-depth` beyond the
15
+ * ceiling is flagged as a blocker. A sub-agent that declares `Agent` at a
16
+ * supported depth (the common case — an undeclared depth is treated as the
17
+ * shallow level-1 fan-out) produces no finding.
18
+ *
19
+ * This inverts the historical guard (Story #4387): the check used to refuse
20
+ * `Agent` in *any* sub-agent workflow on the now-false rationale that
21
+ * sub-agents cannot dispatch. It no longer strips a real capability; it only
22
+ * catches an over-deep fan-out. The self-healing surface is preserved — it is
23
+ * re-scoped, not removed.
11
24
  *
12
25
  * Scope: 'epic-deliver', 'retro'. Surfaces as a blocker at preflight for
13
26
  * `epic-deliver` (the fan-out site) and as audit signal at retro.
14
27
  *
15
- * The check is `refuse-and-print` — auto-rewriting a workflow's tool
16
- * list would silently change runtime behavior in ways the operator may
17
- * not have intended (the workflow's logic may depend on the missing
18
- * tool). The fixCommand cites the flatten-fan-out remediation pattern.
28
+ * The check is `refuse-and-print` — auto-rewriting a workflow's declared
29
+ * depth or tool list would silently change runtime behavior in ways the
30
+ * operator may not have intended. The fixCommand explains how to bring the
31
+ * fan-out back under the ceiling (reduce the declared depth or split the
32
+ * deepest level out), and is explicit that stripping `Agent` is NOT the fix.
19
33
  *
20
- * Implementation note: we scan `.agents/workflows/*.md` for workflow
21
- * files whose frontmatter or body identifies them as a sub-agent role
22
- * AND whose `tools:` declaration includes `Agent`. The marker for
23
- * "sub-agent" is the phrase `sub-agent` appearing in the description /
24
- * overview region. This keeps the check stable while the workflow
25
- * surface evolves the project doesn't yet have a structured
26
- * `role: sub-agent` frontmatter field, and the textual marker is what
27
- * the human contributors actually grep for.
34
+ * Implementation note: we scan `.agents/workflows/*.md` for workflow files
35
+ * whose frontmatter or body identifies them as a sub-agent role AND whose
36
+ * `tools:` declaration includes `Agent`, then read the workflow's declared
37
+ * `nesting-depth`. The marker for "sub-agent" is the phrase `sub-agent`
38
+ * appearing in the description / overview region. The depth is read from a
39
+ * `nesting-depth:` (or `agent-depth:`) frontmatter field, or a
40
+ * `<!-- nesting-depth: N -->` body marker; an absent declaration is treated
41
+ * as depth 1 (a single, shallow fan-out level).
28
42
  */
29
43
 
30
44
  import { readdirSync, readFileSync } from 'node:fs';
@@ -32,6 +46,17 @@ import path from 'node:path';
32
46
 
33
47
  const WORKFLOWS_DIR_DEFAULT = path.join('.agents', 'workflows');
34
48
 
49
+ /**
50
+ * Announced maximum nesting depth the Claude Code harness supports for
51
+ * sub-agent fan-out. Depth 2 is independently verified; depths 3–5 are
52
+ * announced but not yet re-spiked (Epic #4385 / watch #2870). A workflow
53
+ * declaring a fan-out deeper than this ceiling is flagged. Operators can pin
54
+ * a stricter (or, once verified, looser) ceiling via `state.supportedDepth`.
55
+ *
56
+ * @type {number}
57
+ */
58
+ export const ANNOUNCED_MAX_DEPTH = 5;
59
+
35
60
  /**
36
61
  * Walk a workflow directory and return absolute `.md` file paths
37
62
  * (non-recursive — the workflows surface is one level deep; helpers/
@@ -127,17 +152,57 @@ function findAgentToolDeclaration(parts) {
127
152
  return null;
128
153
  }
129
154
 
155
+ /**
156
+ * Parse the workflow's declared nesting depth. A sub-agent that declares
157
+ * `Agent` may also declare how deep its fan-out reaches via a
158
+ * `nesting-depth:` (or `agent-depth:`) frontmatter field, or a
159
+ * `<!-- nesting-depth: N -->` marker in the body. Returns the integer
160
+ * depth, or `null` when no depth is declared (the caller treats an absent
161
+ * declaration as the shallow level-1 fan-out).
162
+ *
163
+ * @param {{ frontmatter: string, body: string }} parts
164
+ * @returns {number | null}
165
+ */
166
+ function parseDeclaredDepth(parts) {
167
+ const { frontmatter, body } = parts;
168
+ const fmMatch = frontmatter.match(
169
+ /^[ \t]*(?:nesting-depth|agent-depth)\s*:\s*(\d+)\s*$/m,
170
+ );
171
+ if (fmMatch) return Number.parseInt(fmMatch[1], 10);
172
+ const bodyMatch = body.match(
173
+ /<!--\s*(?:nesting-depth|agent-depth)\s*:\s*(\d+)\s*-->/,
174
+ );
175
+ if (bodyMatch) return Number.parseInt(bodyMatch[1], 10);
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Resolve the supported depth ceiling for a detect run. Operators may pin a
181
+ * stricter (or, once verified, looser) ceiling via `state.supportedDepth`;
182
+ * an unset or non-positive-integer override falls back to the announced max.
183
+ *
184
+ * @param {{ supportedDepth?: unknown } | null | undefined} state
185
+ * @returns {number}
186
+ */
187
+ function resolveCeiling(state) {
188
+ const override = state?.supportedDepth;
189
+ if (Number.isInteger(override) && override > 0) return override;
190
+ return ANNOUNCED_MAX_DEPTH;
191
+ }
192
+
130
193
  const FIX_COMMAND = [
131
- '# Flatten fan-out to the host. Sub-agents cannot dispatch other agents.',
132
- "# Remove the `Agent` entry from this workflow's `tools:` declaration,",
133
- '# move any fan-out logic up to the parent / host invocation, and have',
134
- '# the parent dispatch the leaf sub-agents directly.',
194
+ '# Nested Agent dispatch IS supported (verified depth 2, announced max 5 —',
195
+ '# Claude Code 2.1.202). This workflow declares a fan-out deeper than the',
196
+ '# supported ceiling, so the deepest dispatch chain will fail at runtime.',
135
197
  '#',
136
- '# Pattern (host workflow):',
137
- '# for each child: Agent(prompt=<child workflow>, args=<id>)',
198
+ '# Bring the fan-out back under the ceiling — either:',
199
+ '# 1. Lower the declared `nesting-depth` to <= the supported ceiling, or',
200
+ '# 2. Split the deepest level out to a shallower sibling fan-out so no',
201
+ '# single dispatch chain exceeds the supported depth.',
138
202
  '#',
139
- '# Pattern (sub-agent workflow):',
140
- '# tools: [Bash, Read, Edit, Grep, Glob, Write] # NO Agent',
203
+ '# Do NOT strip `Agent` from the tool list to silence this. Sub-agents CAN',
204
+ '# dispatch nested agents at a supported depth; removing the tool would',
205
+ '# disable a legitimate capability, not fix the depth overflow.',
141
206
  ].join('\n');
142
207
 
143
208
  export default {
@@ -149,6 +214,7 @@ export default {
149
214
  detect(state) {
150
215
  const cwd = state?.cwd ?? process.cwd();
151
216
  const root = state?.scanRoot ?? path.join(cwd, WORKFLOWS_DIR_DEFAULT);
217
+ const ceiling = resolveCeiling(state);
152
218
  const files = listWorkflowFiles(root);
153
219
  const offences = [];
154
220
  for (const file of files) {
@@ -162,18 +228,29 @@ export default {
162
228
  const parts = splitFrontmatter(src);
163
229
  const where = findAgentToolDeclaration(parts);
164
230
  if (!where) continue;
231
+ // Declaring `Agent` is legitimate. Only a fan-out deeper than the
232
+ // supported ceiling is a runtime hazard; an undeclared depth is the
233
+ // shallow level-1 fan-out and always within the ceiling.
234
+ const depth = parseDeclaredDepth(parts) ?? 1;
235
+ if (depth <= ceiling) continue;
165
236
  offences.push({
166
237
  file: path.relative(root, file).replace(/\\/g, '/'),
167
238
  where,
239
+ depth,
168
240
  });
169
241
  }
170
242
  if (offences.length === 0) return null;
171
- const detail = offences.map((o) => `${o.file} — ${o.where}`).join('\n');
243
+ const detail = offences
244
+ .map(
245
+ (o) =>
246
+ `${o.file} — declares Agent at nesting-depth ${o.depth} (exceeds supported ceiling ${ceiling}); ${o.where}`,
247
+ )
248
+ .join('\n');
172
249
  return {
173
250
  id: 'subagent-agent-tool-required',
174
251
  severity: 'blocker',
175
252
  scope: state?.scope ?? 'epic-deliver',
176
- summary: `${offences.length} sub-agent workflow(s) declare Agent in their tool list nested Agent dispatch is unsupported`,
253
+ summary: `${offences.length} sub-agent workflow(s) declare an Agent fan-out deeper than the supported nesting ceiling (${ceiling})`,
177
254
  detail,
178
255
  fixCommand: FIX_COMMAND,
179
256
  autoCorrectable: false,
@@ -13,6 +13,10 @@
13
13
  * mock the provider call without touching the GitHub HTTP client.
14
14
  */
15
15
 
16
+ import {
17
+ extractFrameworkStamp,
18
+ stampFrameworkVersion,
19
+ } from './framework-version.js';
16
20
  import { TYPE_LABELS } from './label-constants.js';
17
21
 
18
22
  // Canonical section keys match the rendered template at
@@ -113,24 +117,33 @@ export function parseOnePager(onePager) {
113
117
  * sections are rendered as `_(not specified)_` so the operator can spot
114
118
  * gaps during the HITL review (Phase 3).
115
119
  *
120
+ * Story #4382 — the rendered body is stamped with the Mandrel framework
121
+ * version + authoring date (hidden `mandrel_version` / `authored_at` meta
122
+ * field + a visible `> 🏷️ Authored with Mandrel …` marker) via
123
+ * {@link stampFrameworkVersion}. Pass `stamp` to preserve a
124
+ * previously-authored version on re-render (the Epic edit path does this);
125
+ * omit it to stamp the running version and today's date.
126
+ *
116
127
  * @param {{
117
128
  * onePager: string,
118
129
  * template: string,
130
+ * stamp?: { version?: string, authoredAt?: string },
119
131
  * }} args
120
132
  * @returns {{ title: string, body: string }}
121
133
  */
122
- export function renderEpicBody({ onePager, template }) {
134
+ export function renderEpicBody({ onePager, template, stamp }) {
123
135
  if (!template || typeof template !== 'string') {
124
136
  throw new Error('renderEpicBody: template must be a non-empty string');
125
137
  }
126
138
  const parsed = parseOnePager(onePager);
127
139
 
128
- const body = template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
140
+ const rendered = template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
129
141
  if (key === 'title') return parsed.title;
130
142
  const value = parsed[key];
131
143
  return value && value.length > 0 ? value : '_(not specified)_';
132
144
  });
133
145
 
146
+ const body = stampFrameworkVersion(rendered, stamp ?? {});
134
147
  return { title: parsed.title, body };
135
148
  }
136
149
 
@@ -226,7 +239,15 @@ export async function updateEpicFromOnePager({
226
239
  );
227
240
  }
228
241
 
229
- const { title, body } = renderEpicBody({ onePager, template });
242
+ // Story #4382 preserve the originally-authored version stamp on edit
243
+ // rather than bumping it to whatever version is running now. When the
244
+ // current body carries no stamp (legacy Epic), a fresh stamp is applied.
245
+ const priorStamp = extractFrameworkStamp(currentBody ?? '');
246
+ const stamp = priorStamp
247
+ ? { version: priorStamp.version, authoredAt: priorStamp.authoredAt }
248
+ : undefined;
249
+
250
+ const { title, body } = renderEpicBody({ onePager, template, stamp });
230
251
 
231
252
  if (typeof currentBody === 'string' && currentBody === body) {
232
253
  return { epicId, title, body, changed: false };