mandrel 2.22.0 → 2.24.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/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +6 -0
  3. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  4. package/.agents/scripts/deliver-light.js +23 -45
  5. package/.agents/scripts/diagnose-friction.js +95 -4
  6. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  7. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  8. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  9. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  10. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  11. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  12. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  13. package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
  14. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  15. package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
  16. package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
  17. package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
  18. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  19. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  20. package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
  23. package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
  24. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  25. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  26. package/.agents/workflows/helpers/deliver-light.md +21 -4
  27. package/.agents/workflows/helpers/plan-reference.md +40 -0
  28. package/.agents/workflows/plan.md +21 -16
  29. package/docs/CHANGELOG.md +23 -0
  30. package/package.json +1 -1
@@ -26,6 +26,33 @@ import { upsertStructuredComment } from './ticketing.js';
26
26
 
27
27
  export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
28
28
 
29
+ /** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
30
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
31
+
32
+ /** Window bound applied when `frictionWindowDays` is unset. */
33
+ const DEFAULT_FRICTION_WINDOW_DAYS = 30;
34
+
35
+ /**
36
+ * How many days back the run-scope recurrence window reaches (Story #4850).
37
+ *
38
+ * Defaults to 30 rather than to "unbounded": the widened cross-run window
39
+ * exists to let a once-per-Story defect reach the ≥ 2 threshold, and 30 days is
40
+ * long enough for that while short enough that a defect fixed last month stops
41
+ * re-routing. An absent, non-integer, or sub-1 value takes the default — the
42
+ * runtime AJV in `config-settings-schema-delivery.js` rejects those at load, so
43
+ * reaching this fallback means the config never went through the validator.
44
+ *
45
+ * @param {object} [config]
46
+ * @returns {number}
47
+ */
48
+ function resolveFrictionWindowDays(config) {
49
+ const raw = config?.delivery?.feedbackLoop?.frictionWindowDays;
50
+ const days = Number(raw);
51
+ return Number.isInteger(days) && days >= 1
52
+ ? days
53
+ : DEFAULT_FRICTION_WINDOW_DAYS;
54
+ }
55
+
29
56
  /**
30
57
  * @param {object} [config]
31
58
  * @returns {{ frameworkRepo: string, consumerRepo: string, currentRepo: { owner: string, repo: string } }}
@@ -145,18 +172,55 @@ function signalIdentity(parsed, file, lineNumber) {
145
172
  * Unusable ids are skipped rather than throwing — a roll-up must not fail the
146
173
  * epilogue over one malformed entry.
147
174
  *
175
+ * **Bounded by age, not by run (Story #4850).** Widening the window to the
176
+ * whole surviving temp tree also made it unbounded in *time*: a defect fixed
177
+ * weeks ago kept its occurrences on disk and kept re-routing forever, burying
178
+ * a genuine new regression underneath a historical ledger. Rows older than
179
+ * `delivery.feedbackLoop.frictionWindowDays` (default 30) are excluded, as are
180
+ * rows carrying no `ts` a `Date` can read — excluding an undateable row is the
181
+ * direction that fails toward under-counting, and under-counting fails toward
182
+ * not filing. Both exclusions are **counted and reported**, so a caller can
183
+ * tell a bounded window from an unbounded one without reading prose.
184
+ *
185
+ * A recovery marker is written after the incident it cancels, so a marker can
186
+ * never fall outside a window its incident is inside — the netting cannot be
187
+ * broken by the age floor.
188
+ *
148
189
  * @param {Array<number|string>} storyIds The run's own Stories.
149
190
  * @param {object} [config]
150
- * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
191
+ * @param {{ now?: number }} [clock] Injected epoch-ms seam so a test can pin
192
+ * the window without touching the system clock.
193
+ * @returns {Promise<{
194
+ * signals: Array<{ category: string, source: 'framework'|'consumer', storyId: number, ts: string|null, details: object }>,
195
+ * window: { days: number, cutoff: string, excludedStale: number, excludedUnparseable: number },
196
+ * }>}
151
197
  */
152
- export async function gatherRunFrictionSignals(storyIds, config) {
198
+ export async function gatherRunFrictionSignals(
199
+ storyIds,
200
+ config,
201
+ { now = Date.now() } = {},
202
+ ) {
203
+ const days = resolveFrictionWindowDays(config);
204
+ const cutoffMs = now - days * MS_PER_DAY;
153
205
  const signals = [];
154
206
  const seen = new Set();
207
+ let excludedStale = 0;
208
+ let excludedUnparseable = 0;
155
209
  const take = (parsed, fallbackStoryId, identity) => {
156
210
  if (seen.has(identity)) return;
157
211
  seen.add(identity);
158
212
  const signal = normalizeGatheredSignal(parsed, fallbackStoryId);
159
- if (signal) signals.push(signal);
213
+ if (!signal) return;
214
+ const ms = signal.ts === null ? Number.NaN : Date.parse(signal.ts);
215
+ if (!Number.isFinite(ms)) {
216
+ excludedUnparseable += 1;
217
+ return;
218
+ }
219
+ if (ms < cutoffMs) {
220
+ excludedStale += 1;
221
+ return;
222
+ }
223
+ signals.push(signal);
160
224
  };
161
225
 
162
226
  for (const raw of Array.isArray(storyIds) ? storyIds : []) {
@@ -178,7 +242,15 @@ export async function gatherRunFrictionSignals(storyIds, config) {
178
242
  config,
179
243
  );
180
244
 
181
- return signals;
245
+ return {
246
+ signals,
247
+ window: {
248
+ days,
249
+ cutoff: new Date(cutoffMs).toISOString(),
250
+ excludedStale,
251
+ excludedUnparseable,
252
+ },
253
+ };
182
254
  }
183
255
 
184
256
  /**
@@ -155,7 +155,7 @@ The serialized \`body\` string renders these markdown sections (in order):
155
155
  - **Observed-behavior claims open with \`Current state (verified <date>)\`.** Any Spec claim about how the codebase behaves today MUST open with that preamble (e.g. \`Current state (verified 2026-07-17): …\`) so a reader can tell a verified observation from an assumption, and can tell when the observation went stale.
156
156
  - **Intent-then-proxy acceptance shape.** When an acceptance item verifies through a proxy check (a grep, a file-exists probe, an exit-code test), state the intent clause before the proxy check — what outcome the check stands in for — so the proxy never becomes the goal (e.g. "the workflow names hygiene findings as re-author input: \`grep -n "textHygiene" …\` exits 0").
157
157
  - **Slicing checkpoints are one line each.** Each \`## Slicing\` checkpoint is a single line naming the checkpoint; implementation detail lives in \`## Spec\`, never duplicated into Slicing. A Slicing section outweighing its Spec is a defect the text-hygiene lint flags.
158
- - **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Resolve the unknown before authoring, or restate it as a declarative Key Assumption the agent can act on.
158
+ - **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Triage each unknown by who can resolve it: an AFK-shaped unknown (a fact in docs, a third-party API surface, observable repo behavior) MUST be resolved by your own research before authoring — never restated as an assumption; only a HITL-shaped unknown (a genuine product or architecture call the operator owns) may be restated as a declarative Key Assumption the agent can act on, stating the default chosen (a decision-made-by-default).
159
159
  - **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
160
160
 
161
161
  #### SPEC PROSE CONTRACT — state the contract, not the implementation:
@@ -7,16 +7,20 @@
7
7
  * Message contract — see lib/cpu-pool.js:
8
8
  * IN : { item: string } — absolute file path to score
9
9
  * { exit: true } — drain & terminate
10
- * OUT : { ok: true, result: { filePath, score: number | null } }
10
+ * OUT : { ok: true, result: { filePath, score, unscorable?, reason? } }
11
11
  *
12
12
  * `score` is `null` only when the file genuinely cannot be read (ENOENT
13
- * or other I/O error). Parse failures inside escomplex still resolve to
14
- * `0` to preserve byte-for-byte parity with the pre-pool serial path
15
- * (calculateForSource swallows parse errors and returns 0).
13
+ * or other I/O error).
14
+ *
15
+ * A file the kernel cannot analyse comes back as `unscorable: true` with the
16
+ * kernel's own `reason`, rather than as a bare `0`. The `0` is still carried in
17
+ * `score` for wire compatibility, but it is no longer the only signal — the
18
+ * point of the flag is that the caller can *report* the file instead of
19
+ * silently dropping it (see `maintainability-engine.js`'s `UNSCORABLE`).
16
20
  */
17
21
 
18
22
  import { parentPort } from 'node:worker_threads';
19
- import { calculateForFile } from '../maintainability-engine.js';
23
+ import { scoreFile } from '../maintainability-engine.js';
20
24
 
21
25
  /**
22
26
  * Pure handler for a single inbound worker message. Exported so unit
@@ -24,7 +28,7 @@ import { calculateForFile } from '../maintainability-engine.js';
24
28
  * without spawning a real `Worker` thread.
25
29
  *
26
30
  * @param {unknown} msg
27
- * @param {{ score?: (filePath: string) => number | null }} [deps]
31
+ * @param {{ score?: (filePath: string) => { score: number, unscorable: boolean, reason: string|null } }} [deps]
28
32
  * @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
29
33
  */
30
34
  export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
@@ -40,12 +44,13 @@ export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
40
44
  };
41
45
  }
42
46
  const filePath = msg.item;
43
- const scoreFn = deps.score ?? calculateForFile;
47
+ const scoreFn = deps.score ?? scoreFile;
44
48
  try {
45
- const score = scoreFn(filePath);
49
+ // `scoreFn` returns `{ score, unscorable, reason }` — spread so the flag
50
+ // and its reason reach the pool caller intact.
46
51
  return {
47
52
  kind: 'reply',
48
- message: { ok: true, result: { filePath, score } },
53
+ message: { ok: true, result: { filePath, ...scoreFn(filePath) } },
49
54
  };
50
55
  } catch (err) {
51
56
  // I/O or other unexpected error — surface as a per-item null score
@@ -48,6 +48,11 @@ Because the predicted footprint is a *declaration* — a guess, and a gameable o
48
48
  multi-capability enumeration). Size is enforced where ground truth is available:
49
49
  the diff backstop in step 4. Do not talk yourself past that one.
50
50
 
51
+ **The backstop counts by the same principle.** It reads magnitude — changed
52
+ lines over implementation files — not artifacts, and exempts the test and doc
53
+ companions the framework itself mandates. A ceiling that punishes a repo for
54
+ obeying its own test-first rule is a ceiling that over-fires.
55
+
51
56
  Sensitivity is the exception and stays absolute: a footprint touching an auth,
52
57
  crypto, billing, or migration class routes `full` however small or mechanical —
53
58
  and unlike a ceiling, it is **not overridable** (§ Recording a proceed-light
@@ -136,10 +141,22 @@ answer).
136
141
  node .agents/scripts/deliver-light.js --backstop --story <storyId>
137
142
  ```
138
143
 
139
- Exit `3` (`blocked: true`) means the landed diff exceeds the light ceilings
140
- (file count or a sensitive-path class). STOP, flip `agent::blocked`, and
141
- escalate to `/plan` do not land. This is the pass that actually bounds
142
- size, which is why the prediction gate above can afford to be coarse.
144
+ This is the pass that actually bounds size, which is why the prediction gate
145
+ above can afford to be coarse. It measures **magnitude on the change's
146
+ implementation half**changed lines (additions + deletions) plus a file
147
+ sprawl tripwire never raw artifact count. Tests, `docs/**`, `**/*.md`,
148
+ `baselines/**`, and lockfiles are exempt from the counts, because the
149
+ framework mandates those companions and obeying it must not inflate the
150
+ number that then rejects the change. They are **not** exempt from
151
+ sensitive-path matching, which runs over the full change set.
152
+
153
+ Exit `3` (`blocked: true`) means the diff exceeds a light ceiling or touches a
154
+ sensitive-path class. STOP, flip `agent::blocked`, and **recycle the receipt**
155
+ through the envelope's `nextCommand` (`/plan <storyId>`) — tickets mode
156
+ rewrites it into properly-planned Stories and closes it as superseded. Do not
157
+ land, and do not leave the receipt open with no successor: it already carries
158
+ the branch, the worktree, and the implementation, all of which are evidence
159
+ the plan should read.
143
160
 
144
161
  5. **Close and land (same engine).** Exactly [`/deliver`](../deliver.md)'s close:
145
162
 
@@ -36,6 +36,46 @@ whole surface exists to remove.
36
36
  Mixed ids and prose in one invocation is a **hard error**: refuse and ask which
37
37
  was meant, rather than guessing a mode and doing the wrong work.
38
38
 
39
+ ## Unknown triage — AFK vs HITL
40
+
41
+ Every open question interrogation surfaces is triaged by **who can resolve
42
+ it**, not parked in one bucket (a shape borrowed from the Wayfinder skill's
43
+ HITL/AFK ticket typing):
44
+
45
+ - **AFK** (away from keyboard — the agent resolves it alone): the answer is a
46
+ fact something already records — third-party docs, a dependency's API
47
+ surface, observable behavior of this repo. Research it during interrogation
48
+ (per `.agents/instructions.md` § 1.C) and fold the answer into the plan as a
49
+ verified claim. An AFK unknown never becomes a Key Assumption — an
50
+ assumption standing in for a checkable fact is just an unchecked fact.
51
+ - **HITL** (human in the loop — only the operator can resolve it): a genuine
52
+ product or architecture call — what to support, what to drop, which
53
+ trade-off to prefer. Nothing the agent reads can answer it; presenting a
54
+ researched recommendation is fine, deciding is not.
55
+
56
+ Boundary examples: *"does library X support streaming?"* is AFK (read its
57
+ docs); *"should we drop Node 18 support?"* is HITL (a support-policy call);
58
+ *"does our CLI already validate this flag?"* is AFK (read the code);
59
+ *"which of two valid schema shapes should the new field use?"* is HITL when
60
+ both fit — but first verify it is not settled by an existing convention,
61
+ which would make it AFK.
62
+
63
+ **Attended runs** present the HITL list at Gate #1 as "needs your decision",
64
+ one line each, alongside the sharpened intent. **Under `--yes`** nobody is at
65
+ the keyboard: AFK unknowns are researched exactly as in an attended run, and
66
+ each HITL unknown degrades to a declarative Key Assumption that names the
67
+ default chosen and marks it a decision-made-by-default, e.g.:
68
+
69
+ > **Key Assumption (decision-made-by-default):** new-style envelopes only;
70
+ > re-emitting legacy envelopes was ruled out by default, not by the operator.
71
+
72
+ (Keep the assumption itself declarative — "flag if wrong" phrasing trips the
73
+ open-question hygiene lint, and the deliverer cannot answer it anyway.)
74
+
75
+ The marker keeps the operator's undelegated decisions findable after the
76
+ fact: reviewing a `--yes` plan means scanning its decisions-made-by-default,
77
+ not re-deriving which assumptions were really the agent's to make.
78
+
39
79
  ## Gate #1 → the light path (in-session handoff)
40
80
 
41
81
  On a confirmed `deliverLightSuggestion`, `/plan` routes into
@@ -14,7 +14,7 @@ description:
14
14
 
15
15
  Single planning path — there is no Epic/Story router, no scope-triage
16
16
  `epic|story` verdict. **Derive the mode from what the operator typed, announce
17
- it, then act**; there is nothing for them to remember:
17
+ it, then act**:
18
18
 
19
19
  | Invocation | Mode | Behavior |
20
20
  | --- | --- | --- |
@@ -24,11 +24,10 @@ it, then act**; there is nothing for them to remember:
24
24
  | `/plan 4712[,4713…]` | tickets | Fetch issue(s), analyze into proper Stories (prefer N=1 rewrite). |
25
25
  | `/plan 4712`, already delivered | amends | Amend a shipped Story from a **delta envelope**, not a re-interrogation. |
26
26
 
27
- **Resolving a bare id.** Ids alone are ambiguous between *amend* and *tickets*,
28
- so read live state rather than asking: `agent::done` can only be amended, an
29
- open unplanned issue can only be planned. **Announce the derivation** "4712
30
- is `agent::done` amending" so a wrong read costs one correction, not a
31
- wasted run. Ask **only** for an open Story already at `agent::ready`.
27
+ **Resolving a bare id.** Read live state rather than asking: `agent::done` can
28
+ only be amended, an open unplanned issue can only be planned. **Announce the
29
+ derivation** "4712 is `agent::done` amending" so a wrong read costs one
30
+ correction. Ask **only** for an open Story already at `agent::ready`.
32
31
 
33
32
  `--body` is **not** a `/plan` entry; persist goes through `plan-persist.js`.
34
33
 
@@ -62,19 +61,26 @@ node .agents/scripts/plan-context.js --seed "<seed>" \
62
61
 
63
62
  **Always pass `--out`.** Persist auto-discovers the envelope from `--plan-dir`
64
63
  and derives source ids from its `sourceTickets[]`; the CLI also writes
65
- **`stories.template.json`**, the skeleton step 2 starts from.
64
+ **`stories.template.json`**, step 2's skeleton.
66
65
 
67
66
  The envelope carries docs context, the story-author
68
67
  prompt, `sourceTickets[]`, `duplicates[]` (open **Stories**, never Epics), and
69
68
  advisory `complexitySignals` (**no routing authority**). A trivial scope earns
70
69
  `--route-downgrade-reason "<why>"` at persist — shape-validated, failing closed
71
- to `full` ([detail](helpers/plan-reference.md)). Under `--yes`, do not ask
72
- free-form operator questions — unknowns land in Key Assumptions.
70
+ to `full` ([detail](helpers/plan-reference.md)).
71
+
72
+ **Triage each unknown by resolver**
73
+ ([detail](helpers/plan-reference.md)): an **AFK** unknown (research settles
74
+ it) is resolved before authoring, never assumed; a **HITL** unknown (an
75
+ operator call) goes to Gate #1 as "needs your decision". Under `--yes`, do
76
+ not ask free-form operator questions — AFK unknowns are still researched;
77
+ only HITL unknowns land in Key Assumptions, each marked a
78
+ decision-made-by-default.
73
79
 
74
80
  **Gate #1** — STOP to confirm the sharpened plan intent and any
75
81
  duplicate-candidate review. Under `--yes`, auto-proceed.
76
82
 
77
- When `complexitySignals.deliverLightSuggestion.suggested` is `true`, offer —
83
+ On a truthy `deliverLightSuggestion.suggested`, offer —
78
84
  **advisory, never an automatic reroute** — to deliver the seed instead of
79
85
  planning it. On confirm, route **in this session** into
80
86
  [`helpers/deliver-light.md`](helpers/deliver-light.md), filling its gate from
@@ -88,9 +94,9 @@ A truthy `complexitySignals.uiSurface` marks a UI-touching plan: name
88
94
  ### 2. Author
89
95
 
90
96
  **One-shot authoring.** Start from `stories.template.json`; author
91
- `stories.json` in one pass. Entries are pre-resolved; keep tiers/assumptions
92
- valid. `body` is a markdown string **or** a structured object; persist parses
93
- either, serializes the canonical markdown, and syncs top-level `acceptance[]` /
97
+ `stories.json` in one pass. `body` is a markdown string **or** a structured
98
+ object; persist parses either, serializes the canonical markdown, and syncs
99
+ top-level `acceptance[]` /
94
100
  `verify[]` into it — never dual-author those lists.
95
101
 
96
102
  **Grounding = your reads + Phase 8.** Nothing inventories the repo for you:
@@ -149,9 +155,8 @@ node .agents/scripts/plan-persist.js \
149
155
  [--source-tickets 123,456]
150
156
  ```
151
157
 
152
- At lite shape, `--chain-on-clean` chains that dry-run into the real persist in
153
- one round-trip **only** when it is clean and `lite`; a full plan keeps its
154
- review round-trip.
158
+ At lite shape, `--chain-on-clean` chains a clean dry-run into the real persist
159
+ in one round-trip; a full plan keeps its review round-trip.
155
160
 
156
161
  Persist creates `type::story` issue(s) plus a `plan-run::<id>` grouping label
157
162
  (**metadata only**); N>1 `depends_on` edges become `blocked by #<id>` footers.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.24.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.23.0...mandrel-v2.24.0) (2026-07-31)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * **deliver:** hold the Story assignee-lease until the PR is confirmed merged (refs [#4860](https://github.com/dsj1984/mandrel/issues/4860)) ([#4861](https://github.com/dsj1984/mandrel/issues/4861)) ([d722a65](https://github.com/dsj1984/mandrel/commit/d722a6558f2fa1af6aa428d2f8549101c4bedc23))
11
+ * **maintainability:** score the Babel AST the escomplex kernel actually parses ([#4859](https://github.com/dsj1984/mandrel/issues/4859)) ([279b86f](https://github.com/dsj1984/mandrel/commit/279b86fa49c48454b5e8421ca1e0bb01f54a3bba))
12
+ * scope the light diff backstop by change magnitude, and recycle the receipt instead of orphaning it ([#4856](https://github.com/dsj1984/mandrel/issues/4856)) ([#4857](https://github.com/dsj1984/mandrel/issues/4857)) ([114b479](https://github.com/dsj1984/mandrel/commit/114b4797f248bf00064206f32f490413e28276fb))
13
+
14
+ ## [2.23.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.22.0...mandrel-v2.23.0) (2026-07-30)
15
+
16
+
17
+ ### Added
18
+
19
+ * **plan:** triage interrogation unknowns by resolver — AFK research vs HITL operator decision (refs [#4845](https://github.com/dsj1984/mandrel/issues/4845)) ([#4846](https://github.com/dsj1984/mandrel/issues/4846)) ([3cfcd00](https://github.com/dsj1984/mandrel/commit/3cfcd00f3885fc588bdf2bb4597118a790f9b270))
20
+
21
+
22
+ ### Fixed
23
+
24
+ * **diagnose-friction:** name the signal that killed a child instead of reporting "Unknown exit code null" and exiting 0 ([#4851](https://github.com/dsj1984/mandrel/issues/4851)) ([#4853](https://github.com/dsj1984/mandrel/issues/4853)) ([1985be1](https://github.com/dsj1984/mandrel/commit/1985be1b97d751d094acf7dd371b97124039cc6d))
25
+ * **rollup:** describe the friction corpus by its own window, not the triggering run (refs [#4850](https://github.com/dsj1984/mandrel/issues/4850)) ([#4852](https://github.com/dsj1984/mandrel/issues/4852)) ([d866697](https://github.com/dsj1984/mandrel/commit/d8666976be30d218115b9db32602359d55379f88))
26
+ * **rollup:** track anchorKind in the triggering-anchor label ([#4854](https://github.com/dsj1984/mandrel/issues/4854)) ([b7e4ddf](https://github.com/dsj1984/mandrel/commit/b7e4ddf8c362f7c31c2abd73ee181a94f78f6dd2))
27
+
5
28
  ## [2.22.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.21.0...mandrel-v2.22.0) (2026-07-30)
6
29
 
7
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.22.0",
3
+ "version": "2.24.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",