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.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -49,32 +49,76 @@ import {
|
|
|
49
49
|
export const isAutoFileEnabled = makeIsAutoFileEnabled('retroProposals');
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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 {
|
|
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 `
|
|
73
|
-
* the record
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|