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.
Files changed (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. 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: normaliseField(finding?.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
- * Internal — the audit filers stamp the footer via {@link semanticKeyFooter};
146
- * only the confirmation path here reads it back.
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
- if (typeof body !== 'string') return [];
153
- const match = body.match(/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/);
154
- if (!match) return [];
155
- return match[1]
156
- .split(',')
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
- if (typeof body !== 'string') return [];
194
- const match = body.match(/<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/);
195
- if (!match) return [];
196
- return match[1]
197
- .split(',')
198
- .map((s) => s.trim())
199
- .filter((s) => SHA1_RE.test(s));
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
- export const DEFAULT_SEVERITY = 'info';
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
- return SEVERITY_SET.has(normalized) ? normalized : fallback;
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
+ };
@@ -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 `deriveAcceptanceEvalRound`.
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
- * Derive the current acceptance-eval round for a Story by counting the
205
- * `acceptance-eval` signals already appended to the Story's
206
- * `signals.ndjson` (Story #4019). Round = prior-signal count + 1, so the
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 derivation is restart-safe: the ledger lives on disk, so a subagent
211
- * that dies mid-loop and restarts still observes every prior round. A
212
- * missing or malformed ledger degrades to round 1 (no prior rounds), and
213
- * malformed lines are skipped — observability corruption never wedges the
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
- * @returns {number} The 1-based current round.
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
- export function deriveAcceptanceEvalRound({
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 1;
275
+ return [];
253
276
  }
254
277
 
255
- let priorRounds = 0;
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
- priorRounds += 1;
295
+ records.push(record);
273
296
  }
274
- return priorRounds + 1;
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
  }