mandrel 2.24.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
32
|
|
|
33
|
+
import { fingerprintSeverity } from './severity.js';
|
|
34
|
+
|
|
33
35
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
34
36
|
const MARKER = 'audit-fingerprints:';
|
|
35
37
|
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
@@ -65,6 +67,20 @@ function normaliseLabels(labels) {
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* Compute the stable identity payload for a finding.
|
|
70
|
+
*
|
|
71
|
+
* **Severity is projected, not raw (Story #4877).** The severity vocabulary was
|
|
72
|
+
* normalised onto the canonical five-level scale in the same change that wrote
|
|
73
|
+
* this comment, and severity is an identity field — so a naive
|
|
74
|
+
* `normaliseField(finding.severity)` here would have re-minted the fingerprint
|
|
75
|
+
* of every finding whose spelling the normalisation touched, silently breaking
|
|
76
|
+
* dedup against every Issue already filed. {@link fingerprintSeverity} is the
|
|
77
|
+
* projection that makes the hash **invariant** under that normalisation: it
|
|
78
|
+
* resolves aliases onto their canonical level, keeps an absent severity as the
|
|
79
|
+
* empty string (what the raw call produced), and passes an unrecognised value
|
|
80
|
+
* through verbatim. Labels are deliberately left on the raw
|
|
81
|
+
* lower-case/trim/sort path for the same reason — order- and case-insensitive
|
|
82
|
+
* already, and any further folding would move existing shas.
|
|
83
|
+
*
|
|
68
84
|
* @param {object} finding
|
|
69
85
|
* @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string }}
|
|
70
86
|
*/
|
|
@@ -73,7 +89,7 @@ function fingerprintComponents(finding) {
|
|
|
73
89
|
title: normaliseField(finding?.title),
|
|
74
90
|
area: normaliseField(finding?.area),
|
|
75
91
|
primaryFile: normaliseField(finding?.primaryFile),
|
|
76
|
-
severity:
|
|
92
|
+
severity: fingerprintSeverity(finding?.severity),
|
|
77
93
|
labels: normaliseLabels(finding?.labels),
|
|
78
94
|
};
|
|
79
95
|
}
|
|
@@ -142,20 +158,18 @@ export function semanticKeyFooter(keys) {
|
|
|
142
158
|
|
|
143
159
|
/**
|
|
144
160
|
* Extract semantic keys from an Issue body carrying the semantic-key footer.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
161
|
+
* The audit filers stamp the footer via {@link semanticKeyFooter}; the
|
|
162
|
+
* confirmation path here and {@link carryProvenanceFooters} read it back.
|
|
147
163
|
*
|
|
148
164
|
* @param {string} body
|
|
149
165
|
* @returns {string[]}
|
|
150
166
|
*/
|
|
151
167
|
function parseSemanticKeyFooter(body) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.map((s) => s.trim())
|
|
158
|
-
.filter((s) => s.length > 0);
|
|
168
|
+
return parseAllFooterValues(
|
|
169
|
+
body,
|
|
170
|
+
/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
|
|
171
|
+
(s) => s.length > 0,
|
|
172
|
+
);
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
/**
|
|
@@ -190,13 +204,104 @@ export function fingerprintFooter(shas) {
|
|
|
190
204
|
* @returns {string[]}
|
|
191
205
|
*/
|
|
192
206
|
export function parseFingerprintFooter(body) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
return parseAllFooterValues(
|
|
208
|
+
body,
|
|
209
|
+
/<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/g,
|
|
210
|
+
(s) => SHA1_RE.test(s),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Collect the comma-separated values out of **every** occurrence of a footer
|
|
216
|
+
* marker in `text`, de-duplicated, in first-seen order.
|
|
217
|
+
*
|
|
218
|
+
* Scanning every occurrence rather than only the first matters for the
|
|
219
|
+
* provenance carry (Story #4877): the audit Single-plan seed stamps one footer
|
|
220
|
+
* pair per MVP Scope bullet, so a multi-group seed carries several. A
|
|
221
|
+
* first-match-only parse silently dropped every group but the first, which
|
|
222
|
+
* would have made the carry look wired while leaking most of the provenance.
|
|
223
|
+
* Reading all footers is also strictly more correct for issue-body confirmation
|
|
224
|
+
* — a body that accumulated two footer lines confirms against either.
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} text
|
|
227
|
+
* @param {RegExp} pattern — a global regex whose first capture group is the
|
|
228
|
+
* comma-separated value list.
|
|
229
|
+
* @param {(value: string) => boolean} isValid
|
|
230
|
+
* @returns {string[]}
|
|
231
|
+
*/
|
|
232
|
+
function parseAllFooterValues(text, pattern, isValid) {
|
|
233
|
+
if (typeof text !== 'string') return [];
|
|
234
|
+
const out = [];
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
for (const match of text.matchAll(pattern)) {
|
|
237
|
+
for (const raw of match[1].split(',')) {
|
|
238
|
+
const value = raw.trim();
|
|
239
|
+
if (!isValid(value) || seen.has(value)) continue;
|
|
240
|
+
seen.add(value);
|
|
241
|
+
out.push(value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Carry audit dedup provenance from a source document into a target body
|
|
249
|
+
* (Story #4877).
|
|
250
|
+
*
|
|
251
|
+
* The audit sweep's Single-plan path emits a `/plan` seed whose MVP Scope
|
|
252
|
+
* bullets already carry the `audit-fingerprints` / `audit-semantic-keys`
|
|
253
|
+
* footers (Story #4626). Nothing then copied them into the Story `/plan`
|
|
254
|
+
* actually persisted, so the recommended path filed Stories that the next
|
|
255
|
+
* sweep could not recognise and re-filed as new. It was left to the authoring
|
|
256
|
+
* agent to notice HTML comments in a one-pager and hand-carry them — a
|
|
257
|
+
* remembered step, which is to say no step at all.
|
|
258
|
+
*
|
|
259
|
+
* This is that carry, as a function: harvest both footers out of `from`, and
|
|
260
|
+
* append whichever provenance `into` is missing. It is deliberately:
|
|
261
|
+
*
|
|
262
|
+
* - **Additive.** Shas and keys already present in `into` are never duplicated,
|
|
263
|
+
* and a footer `into` already carries is left exactly as authored.
|
|
264
|
+
* - **Union-preserving.** When both sides carry footers the result carries the
|
|
265
|
+
* union, so a hand-authored fingerprint is not dropped in favour of the seed's.
|
|
266
|
+
* - **Idempotent.** Re-running over its own output is a no-op, so a resumed
|
|
267
|
+
* persist cannot stack footers.
|
|
268
|
+
* - **Silent on nothing-to-do.** No provenance in `from` returns `into`
|
|
269
|
+
* unchanged with `carried: false`, so a non-audit plan run is untouched.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ from?: string, into?: string }} args — `from` is the provenance
|
|
272
|
+
* source (the seed markdown); `into` is the body being persisted.
|
|
273
|
+
* @returns {{ body: string, carried: boolean, fingerprints: string[], semanticKeys: string[] }}
|
|
274
|
+
* `body` is the augmented text; `fingerprints` / `semanticKeys` are the values
|
|
275
|
+
* newly carried (empty when there was nothing to carry).
|
|
276
|
+
*/
|
|
277
|
+
export function carryProvenanceFooters({ from = '', into = '' } = {}) {
|
|
278
|
+
const body = typeof into === 'string' ? into : '';
|
|
279
|
+
const source = typeof from === 'string' ? from : '';
|
|
280
|
+
|
|
281
|
+
const have = new Set(parseFingerprintFooter(body));
|
|
282
|
+
const haveKeys = new Set(parseSemanticKeyFooter(body));
|
|
283
|
+
const fingerprints = parseFingerprintFooter(source).filter(
|
|
284
|
+
(sha) => !have.has(sha),
|
|
285
|
+
);
|
|
286
|
+
const semanticKeys = parseSemanticKeyFooter(source).filter(
|
|
287
|
+
(key) => !haveKeys.has(key),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (fingerprints.length === 0 && semanticKeys.length === 0) {
|
|
291
|
+
return { body, carried: false, fingerprints: [], semanticKeys: [] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const appended = [];
|
|
295
|
+
if (fingerprints.length > 0) appended.push(fingerprintFooter(fingerprints));
|
|
296
|
+
if (semanticKeys.length > 0) appended.push(semanticKeyFooter(semanticKeys));
|
|
297
|
+
|
|
298
|
+
const separator = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
299
|
+
return {
|
|
300
|
+
body: `${body}${separator}\n${appended.join('\n')}\n`,
|
|
301
|
+
carried: true,
|
|
302
|
+
fingerprints,
|
|
303
|
+
semanticKeys,
|
|
304
|
+
};
|
|
200
305
|
}
|
|
201
306
|
|
|
202
307
|
/**
|
|
@@ -378,4 +483,5 @@ export const __testing = {
|
|
|
378
483
|
confirmCandidates,
|
|
379
484
|
decideFromConfirmed,
|
|
380
485
|
issueCarriesSemanticKey,
|
|
486
|
+
parseSemanticKeyFooter,
|
|
381
487
|
};
|
|
@@ -40,10 +40,38 @@ export const SEVERITIES = Object.freeze([
|
|
|
40
40
|
* malformed input — and because both the classify and promote paths share it,
|
|
41
41
|
* malformed input still fingerprints identically across the two paths.
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
const DEFAULT_SEVERITY = 'info';
|
|
44
44
|
|
|
45
45
|
const SEVERITY_SET = new Set(SEVERITIES);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Non-canonical spellings that resolve onto a canonical level (Story #4877).
|
|
49
|
+
*
|
|
50
|
+
* The vocabulary drifted because four modules each carried their own partial
|
|
51
|
+
* copy of it: `audit-to-stories/parse-audit-md.js` recognised
|
|
52
|
+
* `critical|high|medium|mod|moderate|low` and nothing else, `audit-to-stories.js`
|
|
53
|
+
* ranked `critical|high|medium|low`, and `audit-to-stories/seed-from-findings.js`
|
|
54
|
+
* ordered the same four. None of them knew `info` — the canonical floor — so an
|
|
55
|
+
* `Info` / `Informational` finding parsed to `null`, tallied as `unknown`, and
|
|
56
|
+
* was dropped by EVERY severity-filtered run (including `--severity low`).
|
|
57
|
+
* Folding the alias table in here makes this module the only place the
|
|
58
|
+
* vocabulary is written down.
|
|
59
|
+
*
|
|
60
|
+
* Keys are already lower-cased and trimmed by {@link normalizeSeverity}.
|
|
61
|
+
*
|
|
62
|
+
* @type {Readonly<Record<string, string>>}
|
|
63
|
+
*/
|
|
64
|
+
const SEVERITY_ALIASES = Object.freeze({
|
|
65
|
+
blocker: 'critical',
|
|
66
|
+
major: 'high',
|
|
67
|
+
mod: 'medium',
|
|
68
|
+
moderate: 'medium',
|
|
69
|
+
minor: 'low',
|
|
70
|
+
informational: 'info',
|
|
71
|
+
nit: 'info',
|
|
72
|
+
trivial: 'info',
|
|
73
|
+
});
|
|
74
|
+
|
|
47
75
|
/**
|
|
48
76
|
* Numeric rank for "highest severity wins" comparisons, derived from the
|
|
49
77
|
* canonical order so the ranking has exactly one source. `critical` is the
|
|
@@ -74,7 +102,44 @@ export const SEVERITY_RANK = Object.freeze(
|
|
|
74
102
|
export function normalizeSeverity(value, fallback = DEFAULT_SEVERITY) {
|
|
75
103
|
if (typeof value !== 'string') return fallback;
|
|
76
104
|
const normalized = value.trim().toLowerCase();
|
|
77
|
-
|
|
105
|
+
if (SEVERITY_SET.has(normalized)) return normalized;
|
|
106
|
+
return SEVERITY_ALIASES[normalized] ?? fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The **identity projection** of a severity, for use inside a finding
|
|
111
|
+
* fingerprint — and nowhere else (Story #4877).
|
|
112
|
+
*
|
|
113
|
+
* `severity` is a `fingerprintFinding` identity field
|
|
114
|
+
* (`route-finding.js`), so whatever this returns is folded into the sha that
|
|
115
|
+
* deduplicates findings against already-filed Issues. That makes the severity
|
|
116
|
+
* pipeline and the severity *identity* two different jobs with two different
|
|
117
|
+
* failure modes:
|
|
118
|
+
*
|
|
119
|
+
* - {@link normalizeSeverity} resolves an absent or unrecognised severity to
|
|
120
|
+
* `info` so downstream filtering and tallies have a level to work with.
|
|
121
|
+
* - This function must NOT. Folding `info` in where the previous
|
|
122
|
+
* implementation folded the empty string would re-mint the fingerprint of
|
|
123
|
+
* every finding that carries no severity, silently breaking dedup for all of
|
|
124
|
+
* them. An absent severity therefore stays the empty string, exactly as the
|
|
125
|
+
* raw `String(value).toLowerCase().trim()` it replaces produced.
|
|
126
|
+
*
|
|
127
|
+
* What it *does* change is alias resolution: `Informational` and `info` project
|
|
128
|
+
* onto the same `info`, so the fingerprint is **invariant** under the
|
|
129
|
+
* normalization this Story introduces — a finding hashes the same whether it is
|
|
130
|
+
* fingerprinted before or after {@link normalizeSeverity} has run over it. An
|
|
131
|
+
* unrecognised non-empty value is passed through verbatim rather than collapsed,
|
|
132
|
+
* again so no already-filed fingerprint moves.
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} value — the raw severity field off a finding.
|
|
135
|
+
* @returns {string} a canonical level, the empty string when absent, or the
|
|
136
|
+
* lower-cased raw value when it is neither canonical nor a known alias.
|
|
137
|
+
*/
|
|
138
|
+
export function fingerprintSeverity(value) {
|
|
139
|
+
if (typeof value !== 'string') return '';
|
|
140
|
+
const raw = value.trim().toLowerCase();
|
|
141
|
+
if (raw.length === 0) return '';
|
|
142
|
+
return normalizeSeverity(raw, raw);
|
|
78
143
|
}
|
|
79
144
|
|
|
80
145
|
/**
|
|
@@ -99,3 +164,16 @@ export function highestSeverity(values) {
|
|
|
99
164
|
}
|
|
100
165
|
return best;
|
|
101
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Internals reached by the unit tests only. The floor and the alias table are
|
|
170
|
+
* consumed exclusively by this module's own exported functions, so they are
|
|
171
|
+
* not public API — but the alias table carries a structural invariant (no
|
|
172
|
+
* alias shadows a canonical level, every target IS one) that cannot be proven
|
|
173
|
+
* through `normalizeSeverity` alone, since that function returns a canonical
|
|
174
|
+
* value by construction.
|
|
175
|
+
*/
|
|
176
|
+
export const __testing = {
|
|
177
|
+
DEFAULT_SEVERITY,
|
|
178
|
+
SEVERITY_ALIASES,
|
|
179
|
+
};
|
|
Binary file
|
|
@@ -111,6 +111,22 @@ export const META_LABELS = {
|
|
|
111
111
|
CONSUMER_IMPROVEMENT: 'meta::consumer-improvement',
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Risk-axis labels. Planning/audit metadata only — `risk::high` carries no
|
|
116
|
+
* runtime behaviour (the single runtime pause point is `agent::blocked`, see
|
|
117
|
+
* `.agents/instructions.md` § 1.J); it marks a ticket a human should look at
|
|
118
|
+
* first. Applied by the audit filer to a Story whose merge contains a Critical
|
|
119
|
+
* finding.
|
|
120
|
+
*
|
|
121
|
+
* Named here because it was a bare `'risk::high'` string literal in
|
|
122
|
+
* `audit-to-stories/build-story-body.js` and defined by no taxonomy at all —
|
|
123
|
+
* neither `LABEL_TAXONOMY` nor the audit bootstrap created it, so the audit
|
|
124
|
+
* filer generated a label the repo did not have (Story #4877).
|
|
125
|
+
*/
|
|
126
|
+
export const RISK_LABELS = {
|
|
127
|
+
HIGH: 'risk::high',
|
|
128
|
+
};
|
|
129
|
+
|
|
114
130
|
/**
|
|
115
131
|
* Planning-axis labels (Epic #2880 F7). Currently scoped to the
|
|
116
132
|
* `planning::healthcheck-waived` operator-applied waiver — a historical
|
|
@@ -147,6 +163,7 @@ export const PLANNING_HEALTHCHECK_WAIVED = 'planning::healthcheck-waived';
|
|
|
147
163
|
*/
|
|
148
164
|
export const LABEL_COLORS = {
|
|
149
165
|
TYPE: '#7057FF',
|
|
166
|
+
RISK_HIGH: '#B60205',
|
|
150
167
|
AGENT: '#0E8A16',
|
|
151
168
|
STATUS_BLOCKED: '#D93F0B',
|
|
152
169
|
ACCEPTANCE: '#FBCA04',
|
|
@@ -17,6 +17,20 @@
|
|
|
17
17
|
* from the cap enforcement path — a critic that always reports `round: 1`
|
|
18
18
|
* can no longer defeat the bounded-loop guarantee.
|
|
19
19
|
*
|
|
20
|
+
* ## Reading a verdict is not a round (Story #4874)
|
|
21
|
+
*
|
|
22
|
+
* Counting signals made *observation* costly: re-running the gate over an
|
|
23
|
+
* already-scored verdict — a resumed sub-agent re-reading its own verdict,
|
|
24
|
+
* an operator re-checking why the loop said `redraft` — appended another
|
|
25
|
+
* signal and advanced the derived round, so a `redraft` could escalate to
|
|
26
|
+
* `block` with no work in between. Every appended signal therefore carries
|
|
27
|
+
* a `details.verdictFingerprint` content-addressing the verdict it scored,
|
|
28
|
+
* and {@link resolveAcceptanceEvalRound} replays the round already recorded
|
|
29
|
+
* for that fingerprint instead of minting a new one. A replay is observably
|
|
30
|
+
* free: the round does not advance and no signal is appended. New work
|
|
31
|
+
* produces different verdict content, hence a new fingerprint, hence a
|
|
32
|
+
* genuine round.
|
|
33
|
+
*
|
|
20
34
|
* ## The three terminal actions
|
|
21
35
|
*
|
|
22
36
|
* - `proceed` — every criterion is `met`. The Story may flip to
|
|
@@ -39,6 +53,7 @@
|
|
|
39
53
|
* possible action is `block`.
|
|
40
54
|
*/
|
|
41
55
|
|
|
56
|
+
import { createHash } from 'node:crypto';
|
|
42
57
|
import { readFileSync } from 'node:fs';
|
|
43
58
|
|
|
44
59
|
import { runArtifactPath, signalsFile } from '../config/temp-paths.js';
|
|
@@ -112,7 +127,7 @@ function partitionCriteria(criteria) {
|
|
|
112
127
|
* The resolved (already-clamped) redraft ceiling from
|
|
113
128
|
* `getAcceptanceEval(config).maxRounds`.
|
|
114
129
|
* @param {number} [args.round]
|
|
115
|
-
* The current round number, derived via `
|
|
130
|
+
* The current round number, derived via `resolveAcceptanceEvalRound`.
|
|
116
131
|
* Defaults to 1 when absent or invalid.
|
|
117
132
|
* @returns {{
|
|
118
133
|
* decision: 'proceed' | 'redraft' | 'block',
|
|
@@ -172,6 +187,7 @@ export function buildAcceptanceEvalSignal({
|
|
|
172
187
|
outcome,
|
|
173
188
|
phase = 'implement',
|
|
174
189
|
clusterId = null,
|
|
190
|
+
verdictFingerprint = null,
|
|
175
191
|
}) {
|
|
176
192
|
return {
|
|
177
193
|
kind: 'acceptance-eval',
|
|
@@ -186,6 +202,13 @@ export function buildAcceptanceEvalSignal({
|
|
|
186
202
|
phase,
|
|
187
203
|
emitter: { tool: 'acceptance-eval.js' },
|
|
188
204
|
details: {
|
|
205
|
+
// Content address of the verdict this signal scored. The replay guard
|
|
206
|
+
// in `resolveAcceptanceEvalRound` matches on it, so a re-read of an
|
|
207
|
+
// already-scored verdict reuses its round instead of minting one.
|
|
208
|
+
...(typeof verdictFingerprint === 'string' &&
|
|
209
|
+
verdictFingerprint.length > 0
|
|
210
|
+
? { verdictFingerprint }
|
|
211
|
+
: {}),
|
|
189
212
|
decision: outcome.decision,
|
|
190
213
|
round: outcome.round,
|
|
191
214
|
cap: outcome.cap,
|
|
@@ -201,29 +224,29 @@ export function buildAcceptanceEvalSignal({
|
|
|
201
224
|
}
|
|
202
225
|
|
|
203
226
|
/**
|
|
204
|
-
*
|
|
205
|
-
* `
|
|
206
|
-
*
|
|
207
|
-
* first run reports round 1 and each completed round (which appends one
|
|
208
|
-
* signal via `acceptance-eval.js`) advances the derived round by one.
|
|
227
|
+
* Read the `acceptance-eval` records already appended to the Story's (or AC
|
|
228
|
+
* cluster's) `signals.ndjson`, in append order — the prior rounds
|
|
229
|
+
* {@link resolveAcceptanceEvalRound} counts from (Story #4019).
|
|
209
230
|
*
|
|
210
|
-
* The
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* gate.
|
|
231
|
+
* The read is restart-safe: the ledger lives on disk, so a subagent that
|
|
232
|
+
* dies mid-loop and restarts still observes every prior round. A missing or
|
|
233
|
+
* unreadable ledger degrades to "no prior rounds" and malformed lines are
|
|
234
|
+
* skipped — observability corruption never wedges the gate.
|
|
215
235
|
*
|
|
216
236
|
* @param {object} args
|
|
217
237
|
* @param {number|null} args.epicId Parent Epic ID, or `null` for a
|
|
218
238
|
* standalone Story (routes to `<tempRoot>/standalone/stories/...`).
|
|
219
239
|
* @param {number} args.storyId
|
|
240
|
+
* @param {string|null} [args.clusterId] AC-cluster id (Epic #4475 M4-B).
|
|
220
241
|
* @param {object} [args.config] Resolved config (tempRoot resolution).
|
|
221
242
|
* @param {(p: string) => string} [args.readFile] Injectable reader (tests).
|
|
222
243
|
* @param {(eid: number|null, sid: number, config?: object) => string} [args.signalsPathResolver]
|
|
223
244
|
* Injectable path resolver (tests). Defaults to `signalsFile`.
|
|
224
|
-
* @
|
|
245
|
+
* @param {(eid: number, config?: object) => string} [args.epicSignalsPathResolver]
|
|
246
|
+
* Injectable epic-stream path resolver (tests).
|
|
247
|
+
* @returns {object[]} The matching records, oldest first.
|
|
225
248
|
*/
|
|
226
|
-
|
|
249
|
+
function readPriorAcceptanceEvalRecords({
|
|
227
250
|
epicId,
|
|
228
251
|
storyId,
|
|
229
252
|
clusterId = null,
|
|
@@ -249,10 +272,10 @@ export function deriveAcceptanceEvalRound({
|
|
|
249
272
|
: readFile(signalsPathResolver(epicId ?? null, storyId, config));
|
|
250
273
|
} catch (_err) {
|
|
251
274
|
// No ledger yet → no prior rounds.
|
|
252
|
-
return
|
|
275
|
+
return [];
|
|
253
276
|
}
|
|
254
277
|
|
|
255
|
-
|
|
278
|
+
const records = [];
|
|
256
279
|
for (const line of String(text).split('\n')) {
|
|
257
280
|
const trimmed = line.trim();
|
|
258
281
|
if (trimmed === '') continue;
|
|
@@ -269,7 +292,77 @@ export function deriveAcceptanceEvalRound({
|
|
|
269
292
|
} else if (record.storyId !== storyId) {
|
|
270
293
|
continue;
|
|
271
294
|
}
|
|
272
|
-
|
|
295
|
+
records.push(record);
|
|
273
296
|
}
|
|
274
|
-
return
|
|
297
|
+
return records;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Content-address a verdict so re-scoring the same verdict is recognisable
|
|
302
|
+
* as a re-read rather than a new round (Story #4874).
|
|
303
|
+
*
|
|
304
|
+
* The fingerprint covers exactly what the decision depends on — the ordered
|
|
305
|
+
* per-criterion `index` / `criterion` / `verdict` / `evidence` tuples — and
|
|
306
|
+
* deliberately nothing else: the verdict's self-reported `round`, its
|
|
307
|
+
* timestamp, and any authoring scratch must not make an unchanged
|
|
308
|
+
* evaluation look like a new one. Conversely, real rework changes at least
|
|
309
|
+
* one criterion's verdict or its evidence, so a genuine re-evaluation
|
|
310
|
+
* always fingerprints differently.
|
|
311
|
+
*
|
|
312
|
+
* @param {{ criteria?: Array<object> }} verdict
|
|
313
|
+
* @returns {string} 16 hex chars of a SHA-256 over the canonical form.
|
|
314
|
+
*/
|
|
315
|
+
export function computeVerdictFingerprint(verdict) {
|
|
316
|
+
const criteria = Array.isArray(verdict?.criteria) ? verdict.criteria : [];
|
|
317
|
+
const canonical = criteria.map((c) => [
|
|
318
|
+
Number.isInteger(c?.index) ? c.index : null,
|
|
319
|
+
typeof c?.criterion === 'string' ? c.criterion : '',
|
|
320
|
+
typeof c?.verdict === 'string' ? c.verdict : '',
|
|
321
|
+
typeof c?.evidence === 'string' ? c.evidence : '',
|
|
322
|
+
]);
|
|
323
|
+
return createHash('sha256')
|
|
324
|
+
.update(JSON.stringify(canonical))
|
|
325
|
+
.digest('hex')
|
|
326
|
+
.slice(0, 16);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolve the round a verdict should be scored under, distinguishing a
|
|
331
|
+
* genuine evaluation from a re-read of an already-scored verdict
|
|
332
|
+
* (Story #4874). A genuine round is `prior-signal count + 1`, so the first
|
|
333
|
+
* run reports round 1 and each completed round advances it by one.
|
|
334
|
+
*
|
|
335
|
+
* When the ledger already carries an `acceptance-eval` signal whose
|
|
336
|
+
* `details.verdictFingerprint` matches, this invocation is a **replay**: it
|
|
337
|
+
* reports that signal's round and tells the caller not to append a second
|
|
338
|
+
* one, so reading is observably free and cannot escalate a `redraft` into a
|
|
339
|
+
* `block`. Otherwise it is a genuine round and the counter advances exactly
|
|
340
|
+
* as the pre-#4874 count-based derivation always did.
|
|
341
|
+
*
|
|
342
|
+
* Signals written before this field existed carry no fingerprint; they can
|
|
343
|
+
* never match, so legacy ledgers keep their count-based behaviour.
|
|
344
|
+
*
|
|
345
|
+
* @param {object} args — {@link readPriorAcceptanceEvalRecords}'s arguments
|
|
346
|
+
* plus:
|
|
347
|
+
* @param {string} args.verdictFingerprint
|
|
348
|
+
* @returns {{ round: number, replay: boolean }}
|
|
349
|
+
*/
|
|
350
|
+
export function resolveAcceptanceEvalRound(args) {
|
|
351
|
+
const { verdictFingerprint } = args;
|
|
352
|
+
const records = readPriorAcceptanceEvalRecords(args);
|
|
353
|
+
const priorIndex =
|
|
354
|
+
typeof verdictFingerprint === 'string' && verdictFingerprint.length > 0
|
|
355
|
+
? records.findIndex(
|
|
356
|
+
(r) => r?.details?.verdictFingerprint === verdictFingerprint,
|
|
357
|
+
)
|
|
358
|
+
: -1;
|
|
359
|
+
if (priorIndex === -1) {
|
|
360
|
+
return { round: records.length + 1, replay: false };
|
|
361
|
+
}
|
|
362
|
+
const recorded = records[priorIndex]?.details?.round;
|
|
363
|
+
return {
|
|
364
|
+
round:
|
|
365
|
+
Number.isInteger(recorded) && recorded >= 1 ? recorded : priorIndex + 1,
|
|
366
|
+
replay: true,
|
|
367
|
+
};
|
|
275
368
|
}
|