mandrel 2.22.0 → 2.23.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.
@@ -291,6 +291,7 @@ top-level keys are validation errors.
291
291
  | `feedbackLoop` | No | `object` | — | Nested configuration block. |
292
292
  | `feedbackLoop.auditResultsAutoFile` | No | `boolean` | `true` | When true (default), the close-time audit-results graduator auto-files non-blocking audit-results findings as follow-up issues routed by source classification. Set to false to suppress auto-filing; findings remain accessible in the structured comments on the Story. |
293
293
  | `feedbackLoop.retroProposals` | No | `boolean` | `true` | When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap\|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas. |
294
+ | `feedbackLoop.frictionWindowDays` | No | `integer` | `30` | How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30. |
294
295
  | `auditToStories` | No | `object` | — | Nested configuration block. |
295
296
  | `auditToStories.severityFloor` | No | `"critical"` \| `"high"` \| `"medium"` \| `"low"` \| `"all"` | `"high"` | Minimum severity a finding must meet to be proposed as a Story on an unattended `/audit-to-stories --auto` sweep (Story #4626). Default high. |
296
297
  | `auditToStories.autoComment` | No | `boolean` | `true` | When true (default), `/audit-to-stories --auto` posts a re-detected comment on an already-open matched Issue instead of silently skipping it. |
@@ -1430,6 +1430,12 @@
1430
1430
  "type": "boolean",
1431
1431
  "default": true,
1432
1432
  "description": "When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas."
1433
+ },
1434
+ "frictionWindowDays": {
1435
+ "type": "integer",
1436
+ "minimum": 1,
1437
+ "default": 30,
1438
+ "description": "How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30."
1433
1439
  }
1434
1440
  },
1435
1441
  "additionalProperties": false
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import { spawnSync } from 'node:child_process';
33
33
  import crypto from 'node:crypto';
34
+ import { constants as osConstants } from 'node:os';
34
35
  import { getLimits, resolveConfig } from './lib/config-resolver.js';
35
36
  import { Logger } from './lib/Logger.js';
36
37
  import { appendSignal } from './lib/observability/signals-writer.js';
@@ -97,6 +98,72 @@ function classifyFrictionCategory(errorOutput) {
97
98
  return { category: matched.category, remediation: matched.remediation };
98
99
  }
99
100
 
101
+ /**
102
+ * `spawnSync`'s own `timeout` option kills the child with `SIGTERM`, so a
103
+ * SIGTERM observed here almost always means the interceptor's configured
104
+ * `executionTimeoutMs` bound fired. Any other signal — a `SIGKILL` from the
105
+ * OOM killer, an operator `kill -9` — originated outside this process.
106
+ *
107
+ * @type {string}
108
+ */
109
+ const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
110
+
111
+ /** Shell convention for "the process died by signal N": exit `128 + N`. */
112
+ const SIGNAL_EXIT_BASE = 128;
113
+
114
+ /**
115
+ * Describe a child that never exited normally — `status === null`, Node's
116
+ * documented representation of "did not exit normally". The raw status must
117
+ * never reach `process.exit`, because `process.exit(null)` exits **0**: the
118
+ * interceptor would report success for a command it just watched get killed
119
+ * (Story #4851).
120
+ *
121
+ * Which signal fired is the diagnostic value: SIGTERM points at the
122
+ * interceptor's own bound, anything else at the host. Recording that plus the
123
+ * bound itself is what makes the row actionable to a consumer who cannot edit
124
+ * the materialized framework tree.
125
+ *
126
+ * Deliberately module-local and pure — exporting it for tests would fail the
127
+ * `--production` dead-exports ratchet, and folding it into `main` would spend
128
+ * the file's per-file maintainability-delta headroom. The CLI contract is the
129
+ * seam the unit tests drive.
130
+ *
131
+ * @param {{signal: (string|null), error?: {message?: string}}} result A
132
+ * `spawnSync` result whose `status` is `null`.
133
+ * @param {number} executionTimeoutMs The resolved interceptor bound, in ms.
134
+ * @returns {{category: string, remediation: string, details: object,
135
+ * preview: string, exitCode: number}}
136
+ */
137
+ function describeAbnormalExit(result, executionTimeoutMs) {
138
+ const signal = typeof result.signal === 'string' ? result.signal : null;
139
+ if (signal === null) {
140
+ return {
141
+ category: FRICTION_DEFAULT.category,
142
+ remediation: FRICTION_DEFAULT.remediation,
143
+ details: {
144
+ killedBySignal: null,
145
+ killOrigin: 'spawn-failure',
146
+ executionTimeoutMs,
147
+ },
148
+ preview: `Command did not exit normally and reported no signal: ${result.error?.message ?? 'spawn produced no exit status'}.`,
149
+ exitCode: 1,
150
+ };
151
+ }
152
+
153
+ const timedOut = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
154
+ const killOrigin = timedOut ? 'interceptor-timeout' : 'external';
155
+ const signum = osConstants.signals[signal];
156
+ return {
157
+ category: timedOut ? 'Execution Timeout' : 'Execution Killed',
158
+ remediation: timedOut
159
+ ? ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`
160
+ : ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
161
+ details: { killedBySignal: signal, killOrigin, executionTimeoutMs },
162
+ preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
163
+ exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
164
+ };
165
+ }
166
+
100
167
  function toIntOrNull(value) {
101
168
  if (value == null) return null;
102
169
  const n = Number.parseInt(String(value), 10);
@@ -121,6 +188,7 @@ function buildFrictionSignal({
121
188
  category,
122
189
  commandStr,
123
190
  errorPreview,
191
+ terminationDetails = null,
124
192
  }) {
125
193
  return {
126
194
  kind: 'friction',
@@ -133,11 +201,14 @@ function buildFrictionSignal({
133
201
  // and always null.
134
202
  taskId: null,
135
203
  category,
204
+ // `emitter.command` is what `classifySignalSource` step 1 scans, so the
205
+ // command-scan stays authoritative for `source`: a consumer command killed
206
+ // by its own host remains consumer-actionable (Story #4851).
136
207
  emitter: {
137
208
  tool: 'diagnose-friction.js',
138
209
  command: commandStr,
139
210
  },
140
- details: { errorPreview },
211
+ details: { errorPreview, ...(terminationDetails ?? {}) },
141
212
  };
142
213
  }
143
214
 
@@ -176,10 +247,22 @@ export async function main(args = process.argv.slice(2)) {
176
247
  if (result.stderr) process.stderr.write(result.stderr);
177
248
 
178
249
  if (result.status !== 0) {
250
+ // A `null` status means the child never exited normally; the cause lives in
251
+ // `result.signal`, not in the status.
252
+ const abnormal =
253
+ result.status === null
254
+ ? describeAbnormalExit(result, executionTimeoutMs)
255
+ : null;
256
+ // With both streams empty an abnormal termination names its signal; the
257
+ // `Unknown exit code` fallback is therefore reachable only with a real
258
+ // numeric status, never as `Unknown exit code null`.
259
+ const noOutputFallback = abnormal
260
+ ? abnormal.preview
261
+ : `Unknown exit code ${result.status}`;
179
262
  const errorOutput = (
180
263
  result.stderr ||
181
264
  result.stdout ||
182
- `Unknown exit code ${result.status}`
265
+ noOutputFallback
183
266
  ).trim();
184
267
  const errorPreview = errorOutput.substring(0, 500);
185
268
 
@@ -188,7 +271,12 @@ export async function main(args = process.argv.slice(2)) {
188
271
  'Command failed. Appending friction signal to NDJSON stream...',
189
272
  );
190
273
 
191
- const { category, remediation } = classifyFrictionCategory(errorOutput);
274
+ // An abnormal termination classifies itself: the marker scan reads output
275
+ // the kill may have truncated (or never produced), so it cannot name the
276
+ // signal.
277
+ const classified = classifyFrictionCategory(errorOutput);
278
+ const category = abnormal?.category ?? classified.category;
279
+ const remediation = abnormal?.remediation ?? classified.remediation;
192
280
 
193
281
  const { storyId: resolvedStoryId, epicId: resolvedEpicId } =
194
282
  resolveContextIds({ storyId, epicId }, config);
@@ -199,6 +287,7 @@ export async function main(args = process.argv.slice(2)) {
199
287
  category,
200
288
  commandStr,
201
289
  errorPreview,
290
+ terminationDetails: abnormal?.details ?? null,
202
291
  });
203
292
 
204
293
  // Story #2874 — accept story-only context (no parent Epic). When
@@ -236,7 +325,9 @@ export async function main(args = process.argv.slice(2)) {
236
325
  Logger.error(remediation);
237
326
  Logger.error('----------------------------------------\n');
238
327
 
239
- process.exit(result.status);
328
+ // Never `process.exit(result.status)` on a null status — that exits 0 and
329
+ // reports success for a killed command.
330
+ process.exit(abnormal?.exitCode ?? result.status);
240
331
  } else {
241
332
  process.exit(0);
242
333
  }
@@ -322,12 +322,20 @@ const REVIEW_SCHEMA = {
322
322
  * issues via the graduator pre-parsed-findings seam, and the rendered retro
323
323
  * sections list the filed issue numbers instead of paste-ready `gh` command
324
324
  * stanzas; set it to `false` to fall back to the command stanzas.
325
+ *
326
+ * `frictionWindowDays` (Story #4850) bounds the run-scope friction recurrence
327
+ * window by row age. The window deliberately spans every surviving signal
328
+ * stream rather than the triggering run's own Stories — that is what lets a
329
+ * once-per-Story systemic defect reach the ≥ 2 actionable threshold — which
330
+ * left it unbounded in time, so a defect fixed weeks ago kept re-routing. An
331
+ * integer ≥ 1; unset means 30 days.
325
332
  */
326
333
  const FEEDBACK_LOOP_SCHEMA = {
327
334
  type: 'object',
328
335
  properties: {
329
336
  auditResultsAutoFile: { type: 'boolean' },
330
337
  retroProposals: { type: 'boolean' },
338
+ frictionWindowDays: { type: 'integer', minimum: 1 },
331
339
  },
332
340
  additionalProperties: false,
333
341
  };
@@ -355,9 +355,18 @@ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
355
355
  * `tool-degraded` from the scoped-lint runner from one out of lens
356
356
  * materialization. It is descriptive, never a routing key.
357
357
  *
358
+ * `ts` joined the shape in Story #4850, and it joined it **here** rather than
359
+ * in a second read beside the run-scope gather. The composer had no notion of
360
+ * *when* its corpus happened, so it borrowed the triggering run as the window
361
+ * and titled every proposal with a claim the evidence block below it already
362
+ * contradicted. Carrying the timestamp through the one shared normalizer is
363
+ * what lets the recurrence window be both bounded and describable; re-reading
364
+ * it beside one of the two gathers is precisely the drift that made the
365
+ * recovery-netting unreachable in Story #4649.
366
+ *
358
367
  * @param {unknown} parsed One parsed NDJSON row.
359
368
  * @param {number} fallbackStoryId Stream owner, used when the row has none.
360
- * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, details: object }|null}
369
+ * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, ts: string|null, details: object }|null}
361
370
  */
362
371
  export function normalizeGatheredSignal(parsed, fallbackStoryId) {
363
372
  if (!parsed || typeof parsed !== 'object') return null;
@@ -374,6 +383,7 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
374
383
  source: parsed.source === 'framework' ? 'framework' : 'consumer',
375
384
  storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
376
385
  tool: typeof emitterTool === 'string' ? emitterTool.trim() : '',
386
+ ts: parsableTimestamp(parsed.ts),
377
387
  details:
378
388
  parsed.details && typeof parsed.details === 'object'
379
389
  ? parsed.details
@@ -381,6 +391,24 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
381
391
  };
382
392
  }
383
393
 
394
+ /**
395
+ * The row's `ts` when it is a string a `Date` can read, else `null`.
396
+ *
397
+ * `signal-event.schema.json` requires `ts`, and both producers stamp
398
+ * `new Date().toISOString()` — but the gathers read whatever survives on disk,
399
+ * including rows a truncated write left half-formed. Resolving to `null`
400
+ * rather than guessing a time is what lets the recurrence window exclude an
401
+ * undateable row explicitly instead of aging it in as "recent".
402
+ *
403
+ * @param {unknown} value
404
+ * @returns {string|null}
405
+ */
406
+ function parsableTimestamp(value) {
407
+ if (typeof value !== 'string') return null;
408
+ const trimmed = value.trim();
409
+ return Number.isFinite(Date.parse(trimmed)) ? trimmed : null;
410
+ }
411
+
384
412
  /**
385
413
  * Pure predicate: is this signal a recovery marker for its own category?
386
414
  * Shared with the retro composer so the "recovered" discriminator is read
@@ -557,22 +557,29 @@ async function executeFollowUpRollup({
557
557
  // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
558
558
  // are what the composer's recovery-netting keys on, and two hand-rolled
559
559
  // copies of this loop are how they got dropped in the first place.
560
- const signals = await gatherRunFrictionSignals(stories, config);
560
+ const { signals, window: frictionWindow } = await gatherRunFrictionSignals(
561
+ stories,
562
+ config,
563
+ );
561
564
  const repos = resolveFollowUpRepos(config);
562
565
  const primaryId = Number(stories[0]);
566
+ // Story #4850 — `runToken` and `anchorStoryIds` are INPUTS. This used to
567
+ // compose with the primary Story's numeric id standing in for the run and
568
+ // then rewrite the rendered title/body by regex over a `plan-run \d+`
569
+ // substring, which meant the composer's own wording could not be changed
570
+ // without silently breaking the patch. `anchorStoryIds` is what lets the
571
+ // composer tell a corpus confined to this run from one spanning the whole
572
+ // surviving window, so it never titles the latter as if it were the former.
563
573
  const proposals = composeRoutedProposals({
564
574
  anchorId: Number.isInteger(primaryId) ? primaryId : 1,
565
575
  anchorKind: 'run',
576
+ runToken: String(planRunId ?? ''),
577
+ anchorStoryIds: stories,
566
578
  frameworkRepo: repos.frameworkRepo,
567
579
  consumerRepo: repos.consumerRepo,
568
580
  signals,
569
581
  unresolvedBlockedEvents: [],
570
582
  });
571
- // Patch titles to mention the plan-run token (anchorKind run uses numeric id).
572
- for (const item of [...proposals.framework, ...proposals.consumer]) {
573
- item.title = item.title.replace(/plan-run \d+/, `plan-run ${planRunId}`);
574
- item.body = item.body.replace(/plan-run \d+/g, `plan-run ${planRunId}`);
575
- }
576
583
  const graduated = await graduateFn({
577
584
  epicId: primaryId,
578
585
  provider,
@@ -619,6 +626,11 @@ async function executeFollowUpRollup({
619
626
  signalCount: signals.length,
620
627
  storyCount: stories.length,
621
628
  filed: graduated.filed?.length ?? 0,
629
+ // Story #4850 — the recurrence window the gather actually applied, and what
630
+ // it dropped. `signalCount` alone cannot distinguish "the window is bounded
631
+ // at 30 days and 40 rows aged out" from "nothing older exists", and an
632
+ // operator triaging a roll-up needs to know which corpus produced it.
633
+ frictionWindow,
622
634
  // Story #4828 — everything below is what the roll-up saw and what became
623
635
  // of it. The pre-#4828 result reported `signalCount` and `filed` and
624
636
  // nothing in between, so nine signals routing into one proposal whose
@@ -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:
@@ -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,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.23.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.22.0...mandrel-v2.23.0) (2026-07-30)
6
+
7
+
8
+ ### Added
9
+
10
+ * **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))
11
+
12
+
13
+ ### Fixed
14
+
15
+ * **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))
16
+ * **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))
17
+ * **rollup:** track anchorKind in the triggering-anchor label ([#4854](https://github.com/dsj1984/mandrel/issues/4854)) ([b7e4ddf](https://github.com/dsj1984/mandrel/commit/b7e4ddf8c362f7c31c2abd73ee181a94f78f6dd2))
18
+
5
19
  ## [2.22.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.21.0...mandrel-v2.22.0) (2026-07-30)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.22.0",
3
+ "version": "2.23.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/",