mandrel 2.20.0 → 2.22.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 (46) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +15 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  9. package/.agents/scripts/lib/config/temp-paths.js +113 -7
  10. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  11. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  12. package/.agents/scripts/lib/label-constants.js +12 -1
  13. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  14. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  15. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  16. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  17. package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
  18. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  19. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  21. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  22. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  24. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  25. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  26. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  27. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  28. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  32. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  33. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  34. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  35. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  36. package/.agents/scripts/lib/temp-retention.js +23 -8
  37. package/.agents/scripts/resolve-stories.js +52 -33
  38. package/.agents/scripts/single-story-confirm-merge.js +6 -8
  39. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  40. package/.agents/workflows/helpers/deliver-light.md +45 -5
  41. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  42. package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
  43. package/.agents/workflows/helpers/deliver-story.md +8 -5
  44. package/.agents/workflows/helpers/plan-reference.md +5 -4
  45. package/docs/CHANGELOG.md +28 -0
  46. package/package.json +1 -1
@@ -49,32 +49,76 @@ import {
49
49
  export const isAutoFileEnabled = makeIsAutoFileEnabled('retroProposals');
50
50
 
51
51
  /**
52
- * Build the content-hash idempotency marker embedded in freshly filed
53
- * follow-up bodies. Derived from the proposal's CATEGORY (retro proposals
54
- * are path-less and the rendered title embeds a mutable recurrence count)
55
- * so the marker is stable across sibling insert/remove/reorder churn AND
56
- * across re-runs that change the count. An HTML comment so it survives
57
- * markdown rendering without leaking into the visible body; the idempotency
58
- * probe strips the comment delimiters before querying `gh search` (the raw
59
- * `<!-- … -->` form never matches the index — Story #4657).
52
+ * The content fingerprint identifying one retro proposal.
60
53
  *
61
- * @param {number} epicId
62
- * @param {{ category?: string, title?: string }} finding
63
- * @returns {string}
54
+ * Fingerprinted on the CATEGORY only — never the rendered title. The title
55
+ * embeds the mutable recurrence count ("… recurred <N> times in …"), so
56
+ * hashing it made a retro re-run after the count changed mint a fresh
57
+ * fingerprint and re-file a duplicate issue for the same category.
58
+ *
59
+ * @param {{ category?: string }} finding
60
+ * @returns {string} 16-char lowercase hex digest.
64
61
  */
65
- export function buildContentMarker(epicId, finding) {
66
- // Fingerprint on the CATEGORY only — never the rendered title. The
67
- // title embeds the mutable recurrence count ("… recurred <N> times in
68
- // Epic #X"), so hashing it made a retro re-run after the count changed
69
- // mint a fresh fingerprint and re-file a duplicate issue for the same
70
- // category. The idempotency identity of a retro proposal is
71
- // (epic, category); the epic id is already carried in the marker text.
72
- const fp = contentFingerprint({
73
- category: finding.category,
62
+ function proposalFingerprint(finding) {
63
+ return contentFingerprint({
64
+ category: finding?.category,
74
65
  path: '',
75
66
  title: '',
76
67
  });
77
- return `<!-- retro-proposal-followup: epic-${epicId}-${fp} -->`;
68
+ }
69
+
70
+ /**
71
+ * Build the content-hash idempotency marker embedded in filed follow-up
72
+ * bodies. An HTML comment so it survives markdown rendering without leaking
73
+ * into the visible body; the idempotency probe strips the comment delimiters
74
+ * before querying `gh search` (the raw `<!-- … -->` form never matches the
75
+ * index — Story #4657).
76
+ *
77
+ * **Anchor-free by construction (Story #4837).** The marker used to read
78
+ * `epic-<anchorId>-<fp>`. The fingerprint half was stable, but the anchor
79
+ * half was the run's FIRST STORY for a run-scoped roll-up
80
+ * (`run-epilogue.js` passes `anchorId: Number(stories[0])`), so it changed
81
+ * every run and the key could never match a prior filing. Measured: closed
82
+ * issues #4833 and #4834 are one `story-blocked` finding under two anchors —
83
+ * `epic-101-e59976671e0b848b` and `epic-777-e59976671e0b848b` — identical
84
+ * fingerprint, different anchor. Dedup did not malfunction; its key was too
85
+ * narrow. The identity of a recurring friction category is the category,
86
+ * full stop: the anchor is evidence about one occurrence and belongs in the
87
+ * body, never in the key.
88
+ *
89
+ * The `epicId` parameter is retained because `graduate()` owns the calling
90
+ * convention for every graduator's `buildContentMarker`; this one
91
+ * deliberately does not consult it.
92
+ *
93
+ * @param {number} _epicId — unused; see above.
94
+ * @param {{ category?: string }} finding
95
+ * @returns {string}
96
+ */
97
+ export function buildContentMarker(_epicId, finding) {
98
+ return `<!-- retro-proposal-followup: ${proposalFingerprint(finding)} -->`;
99
+ }
100
+
101
+ /**
102
+ * Body substrings that identify a follow-up already filed for this finding,
103
+ * handed to the graduator's strongly-consistent read (Story #4837).
104
+ *
105
+ * Two shapes, because the marker format changed under a live backlog:
106
+ *
107
+ * 1. The current anchor-free marker.
108
+ * 2. `-<fp> -->` — the tail every pre-cutover anchored marker ends with
109
+ * (`<!-- retro-proposal-followup: epic-4828-d185e7279a48d03d -->`),
110
+ * whatever anchor it carried. Without it the cutover would re-file the
111
+ * whole already-filed backlog exactly once, which is the failure this
112
+ * Story exists to stop rather than to perform one last time.
113
+ *
114
+ * Module-local: it reaches `graduate()` on the spec bundle, so the walk is
115
+ * the seam and a second exported symbol would only be one nothing imports.
116
+ *
117
+ * @param {{ finding: { category?: string }, contentMarker: string }} args
118
+ * @returns {string[]}
119
+ */
120
+ function buildMatchTokens({ finding, contentMarker }) {
121
+ return [contentMarker, `-${proposalFingerprint(finding)} -->`];
78
122
  }
79
123
 
80
124
  /**
@@ -108,6 +152,7 @@ function makeSpec(source) {
108
152
  noCommentReason: 'no-retro-comment',
109
153
  parseFindings: () => [],
110
154
  buildContentMarker,
155
+ buildMatchTokens,
111
156
  crossRepoCommentAttrs: { graduator: 'retro-proposals' },
112
157
  decorateRecord: (record, finding) => {
113
158
  record.category = finding.category;
@@ -232,6 +277,11 @@ export async function graduateRetroProposals({
232
277
  // circuit the repeat with no spawn.
233
278
  const filedMarkers = new Set();
234
279
 
280
+ // One live-label-set read per routed repo across both buckets (Story #4828):
281
+ // the two buckets file into at most two repos, and every finding in a bucket
282
+ // wants the same `meta::*` name.
283
+ const labelCache = new Map();
284
+
235
285
  let remaining = maxFilingsPerRun;
236
286
  for (const { source, items } of buckets) {
237
287
  if (items.length === 0) continue;
@@ -252,6 +302,7 @@ export async function graduateRetroProposals({
252
302
  maxFilingsPerRun: Math.max(0, remaining),
253
303
  findings,
254
304
  filedMarkers,
305
+ labelCache,
255
306
  logger,
256
307
  spec: makeSpec(source),
257
308
  });
@@ -135,11 +135,22 @@ export const PLANNING_LABELS = {
135
135
  */
136
136
  export const PLANNING_HEALTHCHECK_WAIVED = 'planning::healthcheck-waived';
137
137
 
138
- /** Palette for the taxonomy; consumed by label-taxonomy.js. */
138
+ /**
139
+ * Palette for the taxonomy; consumed by label-taxonomy.js and by the
140
+ * feedback loop's just-in-time label mint (`graduator-core.ensureIssueLabels`).
141
+ *
142
+ * `META` and `FRICTION` are not in `LABEL_TAXONOMY`: the bootstrap runs once
143
+ * at repo setup, but `friction::<category>` names are minted from live
144
+ * telemetry and cannot be enumerated ahead of time. The graduator mints both
145
+ * axes on demand instead — Story #4828, where their absence made every
146
+ * `gh issue create` fail and the whole feedback loop file zero.
147
+ */
139
148
  export const LABEL_COLORS = {
140
149
  TYPE: '#7057FF',
141
150
  AGENT: '#0E8A16',
142
151
  STATUS_BLOCKED: '#D93F0B',
143
152
  ACCEPTANCE: '#FBCA04',
144
153
  PLANNING: '#FEF2C0',
154
+ META: '#1D76DB',
155
+ FRICTION: '#D4C5F9',
145
156
  };
@@ -348,9 +348,16 @@ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
348
348
  * foreign rows attributes each one correctly; the fallback covers records
349
349
  * written before the field existed.
350
350
  *
351
+ * `tool` (from `emitter.tool`) joined the shape in Story #4824. `category`
352
+ * remains the routing and de-duplication unit — it is what titles and
353
+ * de-duplicates a filed issue — but a roll-up that discards a candidate has
354
+ * to be able to *name* it, and a bare category cannot distinguish a
355
+ * `tool-degraded` from the scoped-lint runner from one out of lens
356
+ * materialization. It is descriptive, never a routing key.
357
+ *
351
358
  * @param {unknown} parsed One parsed NDJSON row.
352
359
  * @param {number} fallbackStoryId Stream owner, used when the row has none.
353
- * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, details: object }|null}
360
+ * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, details: object }|null}
354
361
  */
355
362
  export function normalizeGatheredSignal(parsed, fallbackStoryId) {
356
363
  if (!parsed || typeof parsed !== 'object') return null;
@@ -358,10 +365,15 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
358
365
  typeof parsed.category === 'string' ? parsed.category.trim() : '';
359
366
  if (!category) return null;
360
367
  const recordStoryId = Number(parsed.storyId);
368
+ const emitterTool =
369
+ parsed.emitter && typeof parsed.emitter === 'object'
370
+ ? parsed.emitter.tool
371
+ : undefined;
361
372
  return {
362
373
  category,
363
374
  source: parsed.source === 'framework' ? 'framework' : 'consumer',
364
375
  storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
376
+ tool: typeof emitterTool === 'string' ? emitterTool.trim() : '',
365
377
  details:
366
378
  parsed.details && typeof parsed.details === 'object'
367
379
  ? parsed.details
@@ -36,10 +36,17 @@ import fs from 'node:fs/promises';
36
36
  import path from 'node:path';
37
37
  import { createInterface } from 'node:readline';
38
38
 
39
- import { signalsFile, storyTempDir } from '../config/temp-paths.js';
39
+ import {
40
+ resolvedTempRoot,
41
+ SIGNALS_BASENAME,
42
+ STANDALONE_DIRNAME,
43
+ STORIES_DIRNAME,
44
+ signalsFile,
45
+ storyTempDir,
46
+ } from '../config/temp-paths.js';
40
47
  import { Logger } from '../Logger.js';
41
48
  import { recordSignalReject, validateSignal } from './signal-validator.js';
42
- import { classifyPathSource } from './source-classifier.js';
49
+ import { classifySignalSource } from './source-classifier.js';
43
50
 
44
51
  const TRACES_BASENAME = 'traces.ndjson';
45
52
 
@@ -69,10 +76,14 @@ function tracesFile(eid, sid, config) {
69
76
  * `"consumer"`, preserve it verbatim — some detectors classify
70
77
  * upstream and we MUST NOT overwrite their intentional tag.
71
78
  * - Otherwise (absent, or any other value — defense in depth against a
72
- * stray non-canonical `source`), invoke `classifyPathSource` against
73
- * the record's `failingPath` / `path` and `command` /
74
- * `emitter.command` fields and inject/overwrite `source` with the
75
- * result.
79
+ * stray non-canonical `source`), invoke `classifySignalSource` against
80
+ * the whole record and inject/overwrite `source` with the result.
81
+ *
82
+ * Story #4824 moved the field extraction into the classifier. The writer
83
+ * used to hand it only `failingPath` / `command`, which meant every
84
+ * runtime-emitted record — which populates neither — took the `consumer`
85
+ * default and the framework limb of the feedback loop was unreachable. The
86
+ * classifier now sees the `category` and `details` it needs to resolve those.
76
87
  *
77
88
  * @param {unknown} signal
78
89
  * @returns {unknown}
@@ -86,14 +97,7 @@ function tagSignalSource(signal) {
86
97
  return record;
87
98
  }
88
99
  try {
89
- const failingPath = record.failingPath ?? record.path;
90
- const emitter =
91
- record.emitter && typeof record.emitter === 'object'
92
- ? /** @type {Record<string, unknown>} */ (record.emitter)
93
- : null;
94
- const command = record.command ?? emitter?.command;
95
- const source = classifyPathSource(failingPath, command);
96
- return { ...record, source };
100
+ return { ...record, source: classifySignalSource(record) };
97
101
  } catch (err) {
98
102
  Logger.warn(
99
103
  `signals-writer: source classifier failed (${
@@ -321,3 +325,118 @@ export async function forEachLine(epicId, storyId, cb, config) {
321
325
 
322
326
  return forEachLineIn(target, cb, 'forEachLine');
323
327
  }
328
+
329
+ /** Matches an Epic run directory (`run-<eid>`) directly under `tempRoot`. */
330
+ const RUN_DIR_RE = /^run-\d+$/;
331
+
332
+ /** Matches a per-Story directory (`story-<sid>`) under a `stories/` parent. */
333
+ const STORY_DIR_RE = /^story-(\d+)$/;
334
+
335
+ /**
336
+ * List directory entries, or `[]` when the directory is absent/unreadable.
337
+ * Absence is the common case (a fresh checkout has no temp tree at all) and
338
+ * must not throw — the discovery walk feeds a roll-up that may not fail the
339
+ * land.
340
+ *
341
+ * @param {string} dir
342
+ * @returns {Promise<import('node:fs').Dirent[]>}
343
+ */
344
+ async function readDirEntries(dir) {
345
+ try {
346
+ return await fs.readdir(dir, { withFileTypes: true });
347
+ } catch {
348
+ return [];
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Discover every per-Story `signals.ndjson` surviving under the configured
354
+ * temp root (Story #4824).
355
+ *
356
+ * Both canonical layouts are walked — `<tempRoot>/standalone/stories/story-<sid>/`
357
+ * (v2 standalone Stories, where every close writes) and
358
+ * `<tempRoot>/run-<eid>/stories/story-<sid>/` (Epic-attached streams). The
359
+ * walk is the **recurrence window** the follow-up composer reduces over: a
360
+ * defect that fires exactly once per Story is invisible inside a single
361
+ * Story's stream and only becomes a recurrence across the surviving ones.
362
+ *
363
+ * Sorted by absolute path so a given tree always yields the same order and
364
+ * the composed proposals stay byte-identical.
365
+ *
366
+ * @param {object} [config]
367
+ * @returns {Promise<Array<{ storyId: number, file: string }>>}
368
+ */
369
+ async function listStorySignalStreams(config) {
370
+ let root;
371
+ try {
372
+ root = resolvedTempRoot(config);
373
+ } catch (err) {
374
+ Logger.warn(
375
+ `signals-writer: cannot resolve tempRoot for stream discovery: ${
376
+ err instanceof Error ? err.message : String(err)
377
+ }`,
378
+ );
379
+ return [];
380
+ }
381
+
382
+ const storiesDirs = [];
383
+ for (const entry of await readDirEntries(root)) {
384
+ if (!entry.isDirectory()) continue;
385
+ if (entry.name !== STANDALONE_DIRNAME && !RUN_DIR_RE.test(entry.name)) {
386
+ continue;
387
+ }
388
+ storiesDirs.push(path.join(root, entry.name, STORIES_DIRNAME));
389
+ }
390
+
391
+ const streams = [];
392
+ for (const storiesDir of storiesDirs) {
393
+ for (const entry of await readDirEntries(storiesDir)) {
394
+ if (!entry.isDirectory()) continue;
395
+ const match = STORY_DIR_RE.exec(entry.name);
396
+ if (match === null) continue;
397
+ streams.push({
398
+ storyId: Number(match[1]),
399
+ file: path.join(storiesDir, entry.name, SIGNALS_BASENAME),
400
+ });
401
+ }
402
+ }
403
+ return streams.sort((a, b) => a.file.localeCompare(b.file));
404
+ }
405
+
406
+ /**
407
+ * Stream **every** surviving per-Story `signals.ndjson` under the configured
408
+ * temp root, invoking `cb(parsed, context)` per parsed row (Story #4824).
409
+ *
410
+ * `context` carries `{ storyId, file, lineNumber }` — `storyId` is the
411
+ * **stream owner** (from the directory name), used only as the fallback when
412
+ * a row carries no `storyId` of its own; `file` + `lineNumber` identify the
413
+ * physical row, which is what lets a caller de-duplicate a legacy row that
414
+ * predates `eventId`.
415
+ *
416
+ * Same robustness contract as `forEachLine`: a missing tree, an unreadable
417
+ * directory, a malformed line, or a throwing callback each degrade to a
418
+ * warning rather than a rejection.
419
+ *
420
+ * @param {(parsed: unknown, context: { storyId: number, file: string, lineNumber: number }) => unknown | Promise<unknown>} cb
421
+ * @param {object} [config]
422
+ * @returns {Promise<{ streams: number, linesParsed: number }>}
423
+ */
424
+ export async function forEachSignalStreamLine(cb, config) {
425
+ if (typeof cb !== 'function') {
426
+ Logger.warn(
427
+ 'signals-writer: forEachSignalStreamLine called without a callback',
428
+ );
429
+ return { streams: 0, linesParsed: 0 };
430
+ }
431
+ const streams = await listStorySignalStreams(config);
432
+ let linesParsed = 0;
433
+ for (const { storyId, file } of streams) {
434
+ const result = await forEachLineIn(
435
+ file,
436
+ (parsed, lineNumber) => cb(parsed, { storyId, file, lineNumber }),
437
+ 'forEachSignalStreamLine',
438
+ );
439
+ linesParsed += result.linesParsed;
440
+ }
441
+ return { streams: streams.length, linesParsed };
442
+ }
@@ -22,6 +22,12 @@
22
22
  * framework tooling, and we'd rather under-tag than mis-route a
23
23
  * consumer signal into the framework retro stream.
24
24
  *
25
+ * Story #4824 adds {@link classifySignalSource} alongside it — the
26
+ * record-level entry point the writer now calls, which resolves a
27
+ * runtime-emitted record (no `failingPath`, no `command`) instead of letting
28
+ * the `consumer` default win unconditionally. `classifyPathSource` keeps its
29
+ * two-argument signature and behaviour verbatim.
30
+ *
25
31
  * The classifier is pure: no I/O, no logging, no throws on weird input.
26
32
  * Callers (the writer) are responsible for swallowing classifier-level
27
33
  * faults — but the only way this function can throw is via a programmer
@@ -100,4 +106,128 @@ export function classifyPathSource(failingPath, command) {
100
106
  return 'consumer';
101
107
  }
102
108
 
103
- export const __testing = Object.freeze({ FRAMEWORK_PREFIXES });
109
+ /**
110
+ * The one friction category that is definitionally a framework-surface
111
+ * failure (Story #4824).
112
+ *
113
+ * Kept as a local literal rather than imported from
114
+ * `RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED` because `runtime-friction.js`
115
+ * imports `signals-writer.js`, which imports this module — the import would
116
+ * close a cycle for a single string. The unit test asserts the two spellings
117
+ * agree, so the duplication cannot drift silently.
118
+ *
119
+ * @type {string}
120
+ */
121
+ const TOOL_DEGRADED_CATEGORY = 'tool-degraded';
122
+
123
+ /**
124
+ * Detail keys whose free text names the surface a runtime record blames.
125
+ * Scanned (step 2 below) because a runtime emitter puts the framework path
126
+ * it was executing here, not in `failingPath` / `command`.
127
+ *
128
+ * @type {readonly string[]}
129
+ */
130
+ const DETAIL_SCAN_KEYS = Object.freeze(['reason', 'surface', 'phase']);
131
+
132
+ /**
133
+ * True when `value` is a non-empty string — the test for "the operator (or a
134
+ * detector) actually supplied this field", as opposed to a runtime emitter
135
+ * that leaves it absent.
136
+ *
137
+ * @param {unknown} value
138
+ * @returns {boolean}
139
+ */
140
+ function isSupplied(value) {
141
+ return typeof value === 'string' && value.trim().length > 0;
142
+ }
143
+
144
+ /**
145
+ * Concatenate the scannable free text out of a record's `details` payload.
146
+ *
147
+ * @param {unknown} details
148
+ * @returns {string}
149
+ */
150
+ function detailsScanText(details) {
151
+ if (details === null || typeof details !== 'object') return '';
152
+ const record = /** @type {Record<string, unknown>} */ (details);
153
+ const parts = [];
154
+ for (const key of DETAIL_SCAN_KEYS) {
155
+ const value = record[key];
156
+ if (typeof value === 'string') parts.push(value);
157
+ }
158
+ return parts.join('\n');
159
+ }
160
+
161
+ /**
162
+ * Classify a whole signal record as `"framework"` or `"consumer"`
163
+ * (Story #4824).
164
+ *
165
+ * `classifyPathSource` above requires **positive evidence** of a framework
166
+ * prefix in an operator-supplied `failingPath` / `command`. Every
167
+ * runtime-emitted friction record (`runtime-friction.js`) populates neither
168
+ * field, so the `consumer` default always won and the framework limb of the
169
+ * feedback loop was unreachable — 78% of real records mis-tagged.
170
+ *
171
+ * The naive repair — "runtime-emitted means framework" — is measurably
172
+ * **wrong**. `close-failed` records from `single-story-close` carry
173
+ * consumer-owned failures (a base-sync conflict in the consumer's own source
174
+ * tree, a consumer `npm run test:coverage` exiting non-zero); tagging those
175
+ * `framework` would flood the framework repo with consumer test failures,
176
+ * which is worse than the current silence.
177
+ *
178
+ * So the discriminator is resolved in four steps, first match wins:
179
+ *
180
+ * 1. An operator-supplied `failingPath` / `path` / `command` /
181
+ * `emitter.command` → the existing {@link classifyPathSource} prefix scan,
182
+ * **unchanged**. A consumer command invoked through `diagnose-friction.js`
183
+ * stays `consumer`.
184
+ * 2. Else the `details` payload's free text (`reason`, `surface`, `phase`)
185
+ * naming a framework prefix → `framework`. A runtime emitter blames its
186
+ * surface here, not in a `command` field.
187
+ * 3. Else `category === "tool-degraded"` → `framework`.
188
+ * `RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED` is defined as a tool that
189
+ * failed to *execute* — an operational degradation, not a code finding.
190
+ * The framework's own tooling could not run, which says nothing about
191
+ * consumer code.
192
+ * 4. Else `consumer` — the existing safe default, unchanged.
193
+ *
194
+ * Steps 1, 2 and 4 leave every currently-exercised input byte-identical;
195
+ * step 3 is the limb that was unreachable. There is deliberately no emitter
196
+ * allowlist: nothing to maintain, nothing to rot.
197
+ *
198
+ * Pure, and never throws — the writer's `tagSignalSource` still guards it.
199
+ *
200
+ * @param {unknown} record One signal record (the `appendSignal` payload).
201
+ * @returns {"framework"|"consumer"}
202
+ */
203
+ export function classifySignalSource(record) {
204
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
205
+ return 'consumer';
206
+ }
207
+ const signal = /** @type {Record<string, unknown>} */ (record);
208
+ const emitter =
209
+ signal.emitter !== null && typeof signal.emitter === 'object'
210
+ ? /** @type {Record<string, unknown>} */ (signal.emitter)
211
+ : null;
212
+ const failingPath = signal.failingPath ?? signal.path;
213
+ const command = signal.command ?? emitter?.command;
214
+
215
+ if (isSupplied(failingPath) || isSupplied(command)) {
216
+ return classifyPathSource(failingPath, command);
217
+ }
218
+ if (containsFrameworkPrefix(detailsScanText(signal.details))) {
219
+ return 'framework';
220
+ }
221
+ if (
222
+ typeof signal.category === 'string' &&
223
+ signal.category.trim() === TOOL_DEGRADED_CATEGORY
224
+ ) {
225
+ return 'framework';
226
+ }
227
+ return 'consumer';
228
+ }
229
+
230
+ export const __testing = Object.freeze({
231
+ FRAMEWORK_PREFIXES,
232
+ TOOL_DEGRADED_CATEGORY,
233
+ });
@@ -43,6 +43,10 @@ import { hasSurvivingCritical } from '../audit-suite/findings.js';
43
43
  import { resolveConfig } from '../config-resolver.js';
44
44
  import { computeChangeSet } from './change-set.js';
45
45
  import { deriveChangeLevel, resolveDepth } from './review-depth.js';
46
+ import {
47
+ collectProviderDegradations,
48
+ degradationEnvelope,
49
+ } from './review-providers/degraded-gates.js';
46
50
  import {
47
51
  countBySeverity,
48
52
  renderFindings,
@@ -176,6 +180,7 @@ function resolveScopeEnvelope(opts, config) {
176
180
  * postedCommentId: number|null,
177
181
  * commentTargetId: number,
178
182
  * halted: boolean,
183
+ * degraded: boolean, degradations: Array<object>,
179
184
  * blockerReason: string|null,
180
185
  * }>}
181
186
  */
@@ -357,6 +362,11 @@ async function executeReviewPipeline({ opts, config, envelope }) {
357
362
  logger,
358
363
  );
359
364
 
365
+ // Story #4839 — degraded gates ride beside the findings, never inside them.
366
+ const degradations = await collectProviderDegradations(
367
+ reviewProvider,
368
+ logger,
369
+ );
360
370
  const severity = countBySeverity(findings);
361
371
  const halted = hasSurvivingCritical(severity);
362
372
  const report = renderFindingsFn({
@@ -367,6 +377,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
367
377
  findings,
368
378
  provider: providerName,
369
379
  promptMessages,
380
+ degradations,
370
381
  });
371
382
 
372
383
  const { posted, postedCommentId } = await postReviewComment({
@@ -385,6 +396,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
385
396
  postedCommentId,
386
397
  commentTargetId,
387
398
  halted,
399
+ ...degradationEnvelope(degradations),
388
400
  blockerReason: halted
389
401
  ? `code-review reported ${severity.critical} critical blocker(s)`
390
402
  : null,