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
@@ -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
@@ -87,7 +87,8 @@
87
87
  "statusResync",
88
88
  "refCleanup",
89
89
  "baseFastForward",
90
- "tempPurge"
90
+ "tempPurge",
91
+ "leaseRelease"
91
92
  ],
92
93
  "properties": {
93
94
  "followUps": { "type": "boolean" },
@@ -98,6 +99,10 @@
98
99
  "type": "boolean",
99
100
  "description": "Story #4794 — the merged Story's spent temp artifacts (gate transcripts, validation evidence) were purged under delivery.tempRetention. A disabled policy reports true: the operator turned the purge off, so doing nothing IS the correct outcome. Only a real failure — an unreadable temp root, an undeletable artifact — reports false, and like every tail step that degrades the report, never the land."
100
101
  },
102
+ "leaseRelease": {
103
+ "type": "boolean",
104
+ "description": "Story #4860 — the operator's assignee-lease on the Story was released now that the merge is confirmed. The close deliberately no longer releases it at PR creation, so the ticket stays assigned for the whole time its PR is open; every non-merged ending (merge.unlanded block, exhausted wait budget, --no-wait-merge, --no-auto-merge) retains the claim and never reaches this step. A no-op release — the operator is no longer the recorded owner, as on a re-run or a belated manual confirm — reports true: an already-unassigned ticket is the desired end state. Only a throw reports false, and like every tail step that degrades the report, never the land."
105
+ },
101
106
  "details": {
102
107
  "type": "object",
103
108
  "description": "Per-step diagnostic detail — the reason a false step reported false.",
@@ -61,10 +61,10 @@ import { parseArgs } from 'node:util';
61
61
  import { runAsCli } from './lib/cli-utils.js';
62
62
  import { resolveConfig } from './lib/config-resolver.js';
63
63
  import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
64
- import { computeChangeSet } from './lib/orchestration/change-set.js';
64
+ import { resolveBackstopOutcome } from './lib/orchestration/light-backstop.js';
65
+ import { recordGateRefusal } from './lib/orchestration/light-escalation.js';
65
66
  import {
66
67
  buildReceiptStoryTicket,
67
- checkLightDiffBackstop,
68
68
  deriveLightSuitability,
69
69
  resolveLightGateOutcome,
70
70
  } from './lib/orchestration/light-suitability.js';
@@ -121,7 +121,11 @@ Gate options:
121
121
  envelope and ENDS the session (no prompt, no fallback).
122
122
 
123
123
  Backstop options:
124
- --backstop Re-check the ACTUAL diff after implementation.
124
+ --backstop Re-check the ACTUAL diff after implementation. Bounds the
125
+ change's IMPLEMENTATION half by magnitude (changed lines +
126
+ file sprawl); test/doc/baseline companions are exempt from
127
+ the counts but still matched for sensitive paths. A block
128
+ emits a nextCommand recycling the receipt through /plan.
125
129
  --story <id> Story issue number whose story-<id> branch to diff.
126
130
 
127
131
  --pretty Pretty-print the JSON envelope.
@@ -130,8 +134,6 @@ Backstop options:
130
134
 
131
135
  /** Exit code when the gate did not resolve to proceed-light. */
132
136
  const EXIT_NOT_PROCEED = 2;
133
- /** Exit code when the diff backstop blocked the land. */
134
- const EXIT_BACKSTOP_BLOCKED = 3;
135
137
 
136
138
  /**
137
139
  * Split a comma-separated path list into trimmed, non-empty entries.
@@ -292,33 +294,6 @@ export function buildNextCommands(storyId) {
292
294
  };
293
295
  }
294
296
 
295
- /**
296
- * Run the diff backstop against a Story branch's actual change set.
297
- *
298
- * @param {{
299
- * storyId: number,
300
- * baseRef?: string,
301
- * cwd?: string,
302
- * computeFn?: typeof computeChangeSet,
303
- * injectedRules?: object,
304
- * }} args
305
- * @returns {ReturnType<typeof checkLightDiffBackstop>}
306
- */
307
- export function runDiffBackstop({
308
- storyId,
309
- baseRef = 'main',
310
- cwd = process.cwd(),
311
- computeFn = computeChangeSet,
312
- injectedRules,
313
- } = {}) {
314
- const { files } = computeFn({
315
- baseRef,
316
- headRef: `story-${storyId}`,
317
- cwd,
318
- });
319
- return checkLightDiffBackstop({ changedFiles: files, injectedRules });
320
- }
321
-
322
297
  /**
323
298
  * Was a non-blank `--operator-proceed-light` supplied? The gate core decides
324
299
  * whether it *applies*; this only asks whether the operator typed one, so the
@@ -348,27 +323,28 @@ function emit(envelope, pretty) {
348
323
  }
349
324
 
350
325
  /**
351
- * Backstop mode — re-check the actual diff.
326
+ * Backstop mode — re-check the actual diff. The decision lives in
327
+ * {@link module:lib/orchestration/light-backstop}; this branches and prints.
352
328
  *
353
- * @param {{ story?: string, pretty: boolean }} values
329
+ * @param {object} values Parsed CLI values.
330
+ * @param {{ resolveFn?: typeof resolveBackstopOutcome }} [deps]
354
331
  * @returns {Promise<number>}
355
332
  */
356
- async function runBackstopMode(values) {
333
+ async function runBackstopMode(values, deps = {}) {
334
+ const { resolveFn = resolveBackstopOutcome } = deps;
357
335
  const storyId = Number.parseInt(String(values.story ?? ''), 10);
358
336
  if (!Number.isInteger(storyId) || storyId <= 0) {
359
337
  process.stderr.write(HELP);
360
338
  throw new Error('[deliver-light] --backstop requires --story <id>');
361
339
  }
362
- const result = runDiffBackstop({ storyId });
363
- emit({ mode: 'backstop', storyId, ...result }, values.pretty);
364
- if (result.blocked) {
365
- Logger.warn(
366
- `[deliver-light] diff backstop BLOCKED Story #${storyId}: ${result.reasons.join('; ')}`,
367
- );
368
- return EXIT_BACKSTOP_BLOCKED;
369
- }
370
- Logger.info(`[deliver-light] diff backstop clean for Story #${storyId}.`);
371
- return 0;
340
+ const { result, nextCommand, exitCode, message } = await resolveFn({
341
+ storyId,
342
+ });
343
+ const extra = nextCommand === null ? {} : { nextCommand };
344
+ emit({ mode: 'backstop', storyId, ...result, ...extra }, values.pretty);
345
+ if (result.blocked) Logger.warn(message);
346
+ else Logger.info(message);
347
+ return exitCode;
372
348
  }
373
349
 
374
350
  /**
@@ -407,6 +383,7 @@ export async function runGateMode(values, deps = {}) {
407
383
  createReceiptFn = createLightReceipt,
408
384
  emitFn = emit,
409
385
  emitTerminalFn = emitTerminalEnvelope,
386
+ recordRefusalFn = recordGateRefusal,
410
387
  } = deps;
411
388
 
412
389
  if (!values.prompt || String(values.prompt).trim() === '') {
@@ -457,6 +434,7 @@ export async function runGateMode(values, deps = {}) {
457
434
  { mode: 'gate', action: gate.action, outcome: gate.outcome },
458
435
  values.pretty,
459
436
  );
437
+ await recordRefusalFn({ gate, amends: values.amends });
460
438
  Logger.warn(
461
439
  `[deliver-light] gate did not proceed light (${gate.action}): ${gate.outcome.reasons.join('; ')}`,
462
440
  );
@@ -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
  }
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { gitSpawn } from '../git-utils.js';
31
+ import { readNumstatRows } from '../orchestration/diff-magnitude.js';
31
32
  import { selectSensitivePathClasses } from './selector.js';
32
33
 
33
34
  /**
@@ -58,6 +59,12 @@ export function resolveLensDiffFloor(config) {
58
59
  * Count the changed lines (additions + deletions) in the
59
60
  * `baseRef...headRef` diff via `git diff --numstat`.
60
61
  *
62
+ * The read and the parse are shared with the light path's magnitude backstop
63
+ * ({@link module:lib/orchestration/diff-magnitude.readNumstatRows}) so the two
64
+ * cannot disagree about how a diff is measured. This one keeps a whole-diff
65
+ * total: the lens floor asks "is this diff small", not "is its implementation
66
+ * half small", so it deliberately does **not** apply the companion exemption.
67
+ *
61
68
  * Total — never throws. Returns `null` (the neutral "count unknown" signal
62
69
  * the floor fails open on) for any git failure or unparseable output, and
63
70
  * `0` for a genuinely empty diff. Binary rows (`-\t-\tpath`) contribute 0
@@ -77,31 +84,9 @@ export function countChangedLines({
77
84
  cwd = process.cwd(),
78
85
  gitSpawnFn = gitSpawn,
79
86
  } = {}) {
80
- if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
81
- if (typeof headRef !== 'string' || headRef.length === 0) return null;
82
- try {
83
- const result = gitSpawnFn(
84
- cwd,
85
- 'diff',
86
- '--numstat',
87
- `${baseRef}...${headRef}`,
88
- );
89
- if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
90
- return null;
91
- }
92
- let total = 0;
93
- for (const line of result.stdout.split('\n')) {
94
- const trimmedEnd = line.replace(/\s+$/, '');
95
- if (trimmedEnd.length === 0) continue;
96
- const match = /^(\d+|-)\t(\d+|-)\t/.exec(trimmedEnd);
97
- if (!match) return null; // Unexpected format — the count is not trustworthy.
98
- if (match[1] !== '-') total += Number(match[1]);
99
- if (match[2] !== '-') total += Number(match[2]);
100
- }
101
- return total;
102
- } catch {
103
- return null;
104
- }
87
+ const rows = readNumstatRows({ baseRef, headRef, cwd, gitSpawnFn });
88
+ if (rows === null) return null;
89
+ return rows.reduce((total, row) => total + row.additions + row.deletions, 0);
105
90
  }
106
91
 
107
92
  /**
@@ -30,10 +30,25 @@ export const name = 'maintainability';
30
30
  export const keyField = 'path';
31
31
 
32
32
  /**
33
- * Files the maintainability scorer cannot measure because `typhonjs-escomplex`
34
- * (the upstream kernel) parse-fails on syntax the runtime supports but the
35
- * library has never been updated for. Each entry must carry a one-line reason
36
- * so a future engine bump can audit the list and drop the exclusion.
33
+ * Files the maintainability scorer cannot measure because the upstream kernel
34
+ * throws on them. Each entry must carry a one-line reason so a future engine
35
+ * bump can audit the list and drop the exclusion.
36
+ *
37
+ * **This list is empty, and the goal is to keep it that way.** It previously
38
+ * held seven paths, all attributed to escomplex "choking on modern
39
+ * destructuring / regex-property patterns". That diagnosis was wrong. The real
40
+ * defect is that `typhonjs-escomplex`'s code generator is written against
41
+ * ESTree while its own parser emits Babel, so a regex literal in a loop head,
42
+ * an `await` in a loop head, a `?.`, a dynamic `import()`, or an object spread
43
+ * in a default parameter aborted the whole file's analysis. That is now
44
+ * repaired at the kernel boundary by `lib/escomplex-ast-compat.js`, which makes
45
+ * all four surviving entries scorable — and the audit incidentally showed the
46
+ * other three had been pointing at deleted files, because nothing audits an
47
+ * allowlist whose only effect is to suppress output.
48
+ *
49
+ * Before adding a path here, check whether `escomplex-ast-compat.js` can handle
50
+ * the construct instead. An entry here is a file nobody measures; a handler
51
+ * there gets it measured for every consumer.
37
52
  *
38
53
  * Story #2467 / Task #2494 — the prior behaviour stored these files with
39
54
  * `mi: 0` in `baselines/maintainability.json`, which corrupted the global
@@ -44,34 +59,7 @@ export const keyField = 'path';
44
59
  *
45
60
  * Canonicalised POSIX repo-relative paths.
46
61
  */
47
- export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(
48
- new Set([
49
- // escomplex: "Cannot read properties of undefined (reading 'pattern')" —
50
- // chokes on modern destructuring / regex-property patterns used in the
51
- // reconciler's spec walker.
52
- '.agents/scripts/acceptance-spec-reconciler.js',
53
- // escomplex: same "pattern" parse failure as acceptance-spec-reconciler;
54
- // both files share the lifecycle-lint regex-driven scan helpers.
55
- '.agents/scripts/check-lifecycle-lint.js',
56
- // escomplex: "this[node.callee.type] is not a function" — the cyclomatic
57
- // visitor lacks a handler for one of the quality-watch CLI's call shapes.
58
- '.agents/scripts/quality-watch.js',
59
- // escomplex: "Cannot read properties of undefined (reading 'pattern')" —
60
- // the audit-to-stories parser reuses the same regex-property scan
61
- // patterns as acceptance-spec-reconciler.
62
- '.agents/scripts/lib/audit-to-stories/parse-audit-md.js',
63
- // escomplex: same "pattern" parse failure family — the BDD scanner walks
64
- // source trees with regex visitors that hit the upstream destructuring
65
- // bug.
66
- '.agents/scripts/lib/bdd-scenario-scanner.js',
67
- // escomplex: same "pattern" parse failure — the wave-runner tick uses
68
- // the regex-property destructuring escomplex chokes on.
69
- '.agents/scripts/lib/wave-runner/tick.js',
70
- // escomplex: same "pattern" parse failure — exercises the same
71
- // destructuring shape in a test fixture.
72
- 'tests/scripts/story-close-merge-subject.test.js',
73
- ]),
74
- );
62
+ export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(new Set());
75
63
 
76
64
  /**
77
65
  * Filter parse-unscorable files out of a rows array. Used by the scorer
@@ -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
  };