mandrel 1.86.0 → 1.88.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/docs/SDLC.md +15 -3
- package/.agents/docs/configuration.md +2 -0
- package/.agents/instructions.md +7 -0
- package/.agents/rules/git-conventions.md +13 -1
- package/.agents/schemas/agentrc.schema.json +12 -0
- 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/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +13 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +19 -5
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +17 -0
- 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/code-review.md +83 -7
- package/.agents/workflows/helpers/deliver-epic.md +46 -7
- package/.agents/workflows/helpers/epic-audit.md +153 -12
- 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 +22 -0
- package/package.json +1 -1
package/.agents/docs/SDLC.md
CHANGED
|
@@ -891,11 +891,23 @@ watch / auto-merge / cleanup tail that drives the PR to merge:
|
|
|
891
891
|
fixed on a hotfix branch and re-merged into the Epic.
|
|
892
892
|
2. **Audit (Phase 4).** The change-set audit lenses run against the Epic
|
|
893
893
|
diff; findings flow through as advisory signal to inform the code
|
|
894
|
-
review that follows.
|
|
894
|
+
review that follows. Remediation routing is **threshold-aware**
|
|
895
|
+
(`delivery.epicAudit.autoFixSeverity`, default `medium`): at `medium`
|
|
896
|
+
the host LLM fixes 🔴/🟠/🟡 findings on-branch (Mediums batched per
|
|
897
|
+
lens) while 🟢 Suggestions graduate to follow-up issues; `high`
|
|
898
|
+
reproduces the older Critical/High-only routing. Findings fixed
|
|
899
|
+
on-branch are recorded under the `audit-results` comment's
|
|
900
|
+
`## Fixed on-branch` section, which the graduator skips so they never
|
|
901
|
+
spawn duplicate follow-up issues.
|
|
895
902
|
3. **Code-review (Phase 5).** `lib/orchestration/code-review.js` (extracted
|
|
896
903
|
from the `code-review.md` helper) audits the diff and posts the
|
|
897
|
-
findings as a `code-review` structured comment on the Epic.
|
|
898
|
-
|
|
904
|
+
findings as a `code-review` structured comment on the Epic. Focused-fix
|
|
905
|
+
routing is threshold-aware in the same way
|
|
906
|
+
(`delivery.codeReview.autoFixSeverity`, default `medium` — fixes
|
|
907
|
+
🔴/🟠/🟡 on-branch, 🟢 stays on the comment), and fixed findings land
|
|
908
|
+
under the comment's `## Fixed on-branch` section so the graduator skips
|
|
909
|
+
them. The severity gate is unchanged: surviving 🔴 Critical findings
|
|
910
|
+
halt the run; surviving 🟠/🟡/🟢 flow through as non-blocking.
|
|
899
911
|
4. **Retro (Phase 6).** `lib/orchestration/retro-runner.js` (extracted from the old
|
|
900
912
|
retro helper) aggregates perf signals, friction counts, hotfix counts,
|
|
901
913
|
recut counts, parked counts, and HITL count using
|
|
@@ -275,12 +275,14 @@ top-level keys are validation errors.
|
|
|
275
275
|
| `epicAudit` | No | `object` | — | Nested configuration block. |
|
|
276
276
|
| `epicAudit.maxFixAttempts` | No | `integer` | — | Maximum auto-fix retry attempts per finding in /deliver Phase 4 (epic-audit). 0 disables auto-fix. Default 3. |
|
|
277
277
|
| `epicAudit.maxFixScopeFiles` | No | `integer` | — | Maximum file count a single auto-fix may modify before escalating to agent::blocked. Default 5. |
|
|
278
|
+
| `epicAudit.autoFixSeverity` | No | `"high"` \| `"medium"` | `"medium"` | Severity threshold for on-branch remediation in /deliver Phase 4 (epic-audit). `medium` (default) routes 🔴/🟠/🟡 findings into the host-LLM remediation loop (Mediums batched per lens: one commit per lens, a single validation + overlapping-lens rescan at the end) while 🟢 suggestions still graduate to follow-up issues; `high` reproduces the pre-4399 Critical/High-only routing. Hard cutover — no back-compat flag. |
|
|
278
279
|
| `codeReview` | No | `object` | — | Nested configuration block. |
|
|
279
280
|
| `codeReview.provider` | No | `"native"` \| `"codex"` \| `"security-review"` | `"native"` | Legacy single-adapter selection. ReviewProvider that produces the Finding[] consumed by runCodeReview(). Story #2833 registered `native` (in-process maintainability/lint); Story #2830 added `codex` (invokes `/codex:review` plugin); Story #2871 added `security-review` (shells out to `claude --print /security-review`). When `providers` (chain shape) is set this field is ignored with a warning. Selecting an adapter whose probe fails hard-fails at factory construction unless declared `optional: true` in the chain. |
|
|
280
281
|
| `codeReview.providers[]` | No | `array<object>` | — | Multi-provider chain (Story #2871). When set and non-empty, takes precedence over the legacy `provider` field. The orchestrator iterates inline entries in declaration order and merges their Finding[] before posting one structured comment; manual-prompt entries (e.g. ultrareview) contribute a trailing 'Manual review suggestions' section. Each item has: name, scopes, optional, manualPrompt, when. |
|
|
281
282
|
| `codeReview.providerConfig` | No | `object` | — | Optional escape hatch for adapter-specific configuration. No documented keys in Epic #2815; reserved so future adapters can be configured without another schema migration. |
|
|
282
283
|
| `codeReview.maxFixAttempts` | No | `integer` | — | Maximum auto-fix retry attempts per finding in /deliver Phase 5 (code-review). 0 disables auto-fix. Default 3. |
|
|
283
284
|
| `codeReview.maxFixScopeFiles` | No | `integer` | — | Maximum file count a single auto-fix may modify before escalating to agent::blocked. Default 5. |
|
|
285
|
+
| `codeReview.autoFixSeverity` | No | `"high"` \| `"medium"` | `"medium"` | Severity threshold for on-branch remediation in /deliver Phase 5 (code-review). `medium` (default) routes 🔴/🟠/🟡 findings into the host-LLM focused-fix routing (Mediums batched per lens: one commit per lens, a single validation + rescan at the end) while 🟢 suggestions still graduate to follow-up issues; `high` reproduces the pre-4399 Critical/High-only routing. Hard cutover — no back-compat flag. |
|
|
284
286
|
| `retro` | No | `object` | — | Story #3042 (Epic #3019). Operator-tunable retro behaviour. Currently exposes `perfThresholds`, the gates the retro perf-signals classifier uses to decide which signals to surface in the `## Performance Signals` / `## Recommended Follow-Ons` retro sections. |
|
|
285
287
|
| `retro.perfThresholds` | No | `object` | — | Gates for `classifyPerfSignals` (lib/orchestration/retro-perf-heuristics.js). Defaults are 0.6 / 0.4 / 2. |
|
|
286
288
|
| `retro.perfThresholds.utilisation` | No | `number` | — | Per-wave utilisation threshold. Waves whose `utilisation` is strictly below this value emit a `low-utilisation` signal. Default 0.6. |
|
package/.agents/instructions.md
CHANGED
|
@@ -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
|
|
@@ -142,7 +142,19 @@ operator intervention:
|
|
|
142
142
|
candidate with unpushed work, a dirty worktree, or a still-open parent
|
|
143
143
|
ticket. A branch a flow leaves behind (e.g. a `/git-deliver` feature branch
|
|
144
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.
|
|
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.
|
|
146
158
|
- **`/git-cleanup` is recovery, not routine.** Run it by hand only to recover
|
|
147
159
|
an unusual state the automated hygiene does not cover — triaging stashes,
|
|
148
160
|
reaping across non-standard branch namespaces, or `--remote` pruning after a
|
|
@@ -542,6 +542,12 @@
|
|
|
542
542
|
"type": "integer",
|
|
543
543
|
"minimum": 1,
|
|
544
544
|
"description": "Maximum file count a single auto-fix may modify before escalating to agent::blocked. Default 5."
|
|
545
|
+
},
|
|
546
|
+
"autoFixSeverity": {
|
|
547
|
+
"type": "string",
|
|
548
|
+
"enum": ["high", "medium"],
|
|
549
|
+
"default": "medium",
|
|
550
|
+
"description": "Severity threshold for on-branch remediation in /deliver Phase 4 (epic-audit). `medium` (default) routes 🔴/🟠/🟡 findings into the host-LLM remediation loop (Mediums batched per lens: one commit per lens, a single validation + overlapping-lens rescan at the end) while 🟢 suggestions still graduate to follow-up issues; `high` reproduces the pre-4399 Critical/High-only routing. Hard cutover — no back-compat flag."
|
|
545
551
|
}
|
|
546
552
|
},
|
|
547
553
|
"additionalProperties": false
|
|
@@ -624,6 +630,12 @@
|
|
|
624
630
|
"type": "integer",
|
|
625
631
|
"minimum": 1,
|
|
626
632
|
"description": "Maximum file count a single auto-fix may modify before escalating to agent::blocked. Default 5."
|
|
633
|
+
},
|
|
634
|
+
"autoFixSeverity": {
|
|
635
|
+
"type": "string",
|
|
636
|
+
"enum": ["high", "medium"],
|
|
637
|
+
"default": "medium",
|
|
638
|
+
"description": "Severity threshold for on-branch remediation in /deliver Phase 5 (code-review). `medium` (default) routes 🔴/🟠/🟡 findings into the host-LLM focused-fix routing (Mediums batched per lens: one commit per lens, a single validation + rescan at the end) while 🟢 suggestions still graduate to follow-up issues; `high` reproduces the pre-4399 Critical/High-only routing. Hard cutover — no back-compat flag."
|
|
627
639
|
}
|
|
628
640
|
},
|
|
629
641
|
"additionalProperties": false
|
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
* is swallowed and reported in the result envelope, never thrown, so a
|
|
18
18
|
* caller can wire it into a boot path without risking the host run.
|
|
19
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
|
+
*
|
|
20
31
|
* Usage:
|
|
21
32
|
* node .agents/scripts/boot-sweep.js [--include <glob>...] \
|
|
22
33
|
* [--exclude <glob>...] [--current <branch>] [--base <branch>] \
|
|
@@ -42,6 +53,10 @@ Runs the protected merged-branch boot sweep non-interactively: reaps every
|
|
|
42
53
|
local branch whose PR is MERGED and whose HEAD matches the merged headRefOid,
|
|
43
54
|
skipping any candidate the protection partition flags (unpushed work, dirty
|
|
44
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.
|
|
45
60
|
|
|
46
61
|
Options:
|
|
47
62
|
--include <glob> Branch glob to sweep (repeatable). Default: story-*
|
|
@@ -136,11 +151,31 @@ export async function runBootSweep({
|
|
|
136
151
|
localDeleted: 0,
|
|
137
152
|
remoteDeleted: 0,
|
|
138
153
|
protected: [],
|
|
154
|
+
contentMerged: [],
|
|
139
155
|
failures: [],
|
|
140
156
|
};
|
|
141
157
|
}
|
|
142
158
|
}
|
|
143
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
|
+
|
|
144
179
|
async function main() {
|
|
145
180
|
const { values } = parseArgs({
|
|
146
181
|
options: {
|
|
@@ -173,10 +208,7 @@ async function main() {
|
|
|
173
208
|
if (values.json) {
|
|
174
209
|
Logger.info(JSON.stringify(result, null, 2));
|
|
175
210
|
} else {
|
|
176
|
-
|
|
177
|
-
Logger.info(
|
|
178
|
-
`[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}.`,
|
|
179
|
-
);
|
|
211
|
+
Logger.info(buildSummaryLine(result));
|
|
180
212
|
}
|
|
181
213
|
}
|
|
182
214
|
|
|
@@ -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
|
|
2
|
+
* subagent-agent-tool-required — supported-depth guard (refuse-and-print).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `Agent` in
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
16
|
-
* list would silently change runtime behavior in ways the
|
|
17
|
-
* not have intended
|
|
18
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* "sub-agent" is the phrase `sub-agent`
|
|
24
|
-
* overview region.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
'#
|
|
132
|
-
|
|
133
|
-
'#
|
|
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
|
-
'#
|
|
137
|
-
'#
|
|
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
|
-
'#
|
|
140
|
-
'#
|
|
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
|
|
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
|
|
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,
|
|
@@ -196,6 +196,10 @@ const KEY_MEANINGS = Object.freeze({
|
|
|
196
196
|
'Maximum auto-fix attempts the code-review phase makes.',
|
|
197
197
|
'delivery.codeReview.maxFixScopeFiles':
|
|
198
198
|
'Maximum files an auto-fix may touch in one attempt.',
|
|
199
|
+
'delivery.codeReview.autoFixSeverity':
|
|
200
|
+
'Severity threshold for on-branch code-review remediation (medium fixes 🔴/🟠/🟡, high fixes 🔴/🟠 only; default medium).',
|
|
201
|
+
'delivery.epicAudit.autoFixSeverity':
|
|
202
|
+
'Severity threshold for on-branch epic-audit remediation (medium fixes 🔴/🟠/🟡, high fixes 🔴/🟠 only; default medium).',
|
|
199
203
|
'delivery.refactorStage.enabled':
|
|
200
204
|
'Whether a dedicated refactor stage runs during delivery.',
|
|
201
205
|
'delivery.acceptanceEval.maxRounds':
|
|
@@ -48,15 +48,22 @@ const DEFAULT_DELIVER_RUNNER = Object.freeze({
|
|
|
48
48
|
* and Phase 5 (code-review). Operators override via
|
|
49
49
|
* `delivery.epicAudit.*` and `delivery.codeReview.*` in `.agentrc.json`
|
|
50
50
|
* (Story #2611, Epic #2586).
|
|
51
|
+
*
|
|
52
|
+
* `autoFixSeverity` (Story #4399) defaults to `'medium'` — the phase
|
|
53
|
+
* remediates 🔴/🟠/🟡 findings on-branch while 🟢 suggestions graduate to
|
|
54
|
+
* follow-up issues. `'high'` reproduces the pre-4399 Critical/High-only
|
|
55
|
+
* routing. Hard cutover per `rules/git-conventions.md` — no back-compat flag.
|
|
51
56
|
*/
|
|
52
57
|
export const DEFAULT_EPIC_AUDIT = Object.freeze({
|
|
53
58
|
maxFixAttempts: 3,
|
|
54
59
|
maxFixScopeFiles: 5,
|
|
60
|
+
autoFixSeverity: 'medium',
|
|
55
61
|
});
|
|
56
62
|
|
|
57
63
|
export const DEFAULT_CODE_REVIEW = Object.freeze({
|
|
58
64
|
maxFixAttempts: 3,
|
|
59
65
|
maxFixScopeFiles: 5,
|
|
66
|
+
autoFixSeverity: 'medium',
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
/**
|
|
@@ -65,8 +72,8 @@ export const DEFAULT_CODE_REVIEW = Object.freeze({
|
|
|
65
72
|
* @param {object | null | undefined} config
|
|
66
73
|
* @returns {{
|
|
67
74
|
* deliverRunner: { concurrencyCap: number, progressReportIntervalSec: number, verifyConcurrencyCap: number },
|
|
68
|
-
* epicAudit: { maxFixAttempts: number, maxFixScopeFiles: number },
|
|
69
|
-
* codeReview: { maxFixAttempts: number, maxFixScopeFiles: number },
|
|
75
|
+
* epicAudit: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
|
|
76
|
+
* codeReview: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
|
|
70
77
|
* storyMergeRetry: { maxAttempts: number, backoffMs: readonly number[] },
|
|
71
78
|
* decomposer: { concurrencyCap: number },
|
|
72
79
|
* }}
|
|
@@ -92,12 +99,16 @@ export function getRunners(config) {
|
|
|
92
99
|
epicAuditUser.maxFixAttempts ?? DEFAULT_EPIC_AUDIT.maxFixAttempts,
|
|
93
100
|
maxFixScopeFiles:
|
|
94
101
|
epicAuditUser.maxFixScopeFiles ?? DEFAULT_EPIC_AUDIT.maxFixScopeFiles,
|
|
102
|
+
autoFixSeverity:
|
|
103
|
+
epicAuditUser.autoFixSeverity ?? DEFAULT_EPIC_AUDIT.autoFixSeverity,
|
|
95
104
|
},
|
|
96
105
|
codeReview: {
|
|
97
106
|
maxFixAttempts:
|
|
98
107
|
codeReviewUser.maxFixAttempts ?? DEFAULT_CODE_REVIEW.maxFixAttempts,
|
|
99
108
|
maxFixScopeFiles:
|
|
100
109
|
codeReviewUser.maxFixScopeFiles ?? DEFAULT_CODE_REVIEW.maxFixScopeFiles,
|
|
110
|
+
autoFixSeverity:
|
|
111
|
+
codeReviewUser.autoFixSeverity ?? DEFAULT_CODE_REVIEW.autoFixSeverity,
|
|
101
112
|
},
|
|
102
113
|
storyMergeRetry: DEFAULT_STORY_MERGE_RETRY,
|
|
103
114
|
decomposer: DEFAULT_DECOMPOSER,
|
|
@@ -210,12 +210,19 @@ const MERGE_WATCH_SCHEMA = {
|
|
|
210
210
|
* `agent::blocked` (default 5) — a deliberately narrow bound for
|
|
211
211
|
* unattended auto-fixes, independent of the Story-sizing thresholds in
|
|
212
212
|
* `ticket-validator-sizing.js`.
|
|
213
|
+
*
|
|
214
|
+
* `autoFixSeverity` (Story #4399) is the threshold that governs which
|
|
215
|
+
* findings the Phase 4 host-LLM remediation loop fixes on-branch: `medium`
|
|
216
|
+
* (the default) routes 🔴/🟠/🟡 into remediation while 🟢 still graduates;
|
|
217
|
+
* `high` reproduces the pre-4399 Critical/High-only routing. It is a hard
|
|
218
|
+
* cutover per `rules/git-conventions.md` — there is no back-compat flag.
|
|
213
219
|
*/
|
|
214
220
|
const EPIC_AUDIT_SCHEMA = {
|
|
215
221
|
type: 'object',
|
|
216
222
|
properties: {
|
|
217
223
|
maxFixAttempts: { type: 'integer', minimum: 0 },
|
|
218
224
|
maxFixScopeFiles: { type: 'integer', minimum: 1 },
|
|
225
|
+
autoFixSeverity: { type: 'string', enum: ['high', 'medium'] },
|
|
219
226
|
},
|
|
220
227
|
additionalProperties: false,
|
|
221
228
|
};
|
|
@@ -116,6 +116,12 @@ export const QUALITY_SCHEMA = {
|
|
|
116
116
|
/**
|
|
117
117
|
* `delivery.codeReview` — sibling to `delivery.epicAudit`. Same bounded
|
|
118
118
|
* retry + scope cap, applied to /deliver Phase 5 (code-review).
|
|
119
|
+
*
|
|
120
|
+
* `autoFixSeverity` (Story #4399) is the sibling of
|
|
121
|
+
* `delivery.epicAudit.autoFixSeverity`: the threshold that governs which
|
|
122
|
+
* Phase 5 findings the host-LLM focused-fix routing remediates on-branch —
|
|
123
|
+
* `medium` (default) routes 🔴/🟠/🟡 while 🟢 still graduates, `high`
|
|
124
|
+
* reproduces the pre-4399 Critical/High-only routing.
|
|
119
125
|
*/
|
|
120
126
|
export const CODE_REVIEW_SCHEMA = {
|
|
121
127
|
type: 'object',
|
|
@@ -173,6 +179,7 @@ export const CODE_REVIEW_SCHEMA = {
|
|
|
173
179
|
providerConfig: { type: 'object', additionalProperties: true },
|
|
174
180
|
maxFixAttempts: { type: 'integer', minimum: 0 },
|
|
175
181
|
maxFixScopeFiles: { type: 'integer', minimum: 1 },
|
|
182
|
+
autoFixSeverity: { type: 'string', enum: ['high', 'medium'] },
|
|
176
183
|
},
|
|
177
184
|
additionalProperties: false,
|
|
178
185
|
};
|
|
@@ -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
|
|
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
|
-
|
|
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 };
|
|
@@ -98,6 +98,13 @@ export function buildIdempotencyMarker(epicId, index) {
|
|
|
98
98
|
* emoji and embeds the cited path inside backticks. 🔴 critical findings
|
|
99
99
|
* are filtered out (they're blocking — the Epic stops on those).
|
|
100
100
|
*
|
|
101
|
+
* Findings the Phase 4 remediation loop already fixed on-branch are
|
|
102
|
+
* rendered under a **"Fixed on-branch"** heading (Story #4399) with a ✅
|
|
103
|
+
* prefix so they no longer parse as open findings. As a belt-and-suspenders
|
|
104
|
+
* guard the parser also skips every line inside a Fixed-on-branch section
|
|
105
|
+
* outright, so a remediated 🟡 Medium never spawns a ghost follow-up issue
|
|
106
|
+
* even if its line retains its original severity emoji.
|
|
107
|
+
*
|
|
101
108
|
* Pure. Exported so the parser can be unit-tested in isolation.
|
|
102
109
|
*
|
|
103
110
|
* @param {string} body
|
|
@@ -109,18 +116,25 @@ export function parseFindings(body) {
|
|
|
109
116
|
const lines = body.split(/\r?\n/);
|
|
110
117
|
let idx = 0;
|
|
111
118
|
let lens = 'unknown';
|
|
119
|
+
let inFixedSection = false;
|
|
112
120
|
for (const rawLine of lines) {
|
|
113
121
|
const trimmed = rawLine.trim();
|
|
114
122
|
if (trimmed.length === 0) continue;
|
|
115
123
|
|
|
116
|
-
//
|
|
117
|
-
// names a known audit family (`audit-*`).
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
// Any markdown heading resets the Fixed-on-branch guard and, when it
|
|
125
|
+
// names a known audit family (`audit-*`), sets the active lens. A
|
|
126
|
+
// "Fixed on-branch" heading opens a section whose entries never
|
|
127
|
+
// graduate (Story #4399).
|
|
128
|
+
const headingMatch = trimmed.match(/^#{2,6}\s+(.+)$/);
|
|
129
|
+
if (headingMatch) {
|
|
130
|
+
inFixedSection = /fixed on-branch/i.test(headingMatch[1]);
|
|
131
|
+
const lensMatch = headingMatch[1].match(/^(audit-[a-z0-9-]+)/i);
|
|
132
|
+
if (lensMatch) lens = lensMatch[1];
|
|
121
133
|
continue;
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
if (inFixedSection) continue;
|
|
137
|
+
|
|
124
138
|
let severity = null;
|
|
125
139
|
if (trimmed.startsWith('🔴')) {
|
|
126
140
|
// Critical Blocker — skip; never graduates.
|