mandrel 2.23.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 (101) 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/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -1,25 +1,80 @@
1
1
  import fs from 'node:fs';
2
2
  import escomplex from 'typhonjs-escomplex';
3
+ import { install as installAstCompat } from './escomplex-ast-compat.js';
3
4
  import { transpileIfNeeded } from './transpile.js';
4
5
 
5
6
  /**
6
7
  * Calculates the maintainability score of a JavaScript source file or string.
7
8
  * Uses `typhonjs-escomplex` internally, which provides a maintainability index
8
9
  * based on the Halstead Volume, Cyclomatic Complexity, and Lines of Code.
10
+ *
11
+ * The kernel's code generator predates the Babel AST its own parser emits, so
12
+ * ordinary modern syntax (`?.`, `await` in a loop head, a regex in a loop
13
+ * head, object spread in a default parameter) aborts the whole analysis.
14
+ * `escomplex-ast-compat` repairs that before any scoring runs — see that
15
+ * module for the defect and the upstream status.
16
+ */
17
+ installAstCompat();
18
+
19
+ /**
20
+ * Sentinel score for a file the kernel cannot analyse.
21
+ *
22
+ * A real maintainability index never reaches 0 for runnable code — the
23
+ * escomplex floor is ~10–20 — so 0 has long been used as an out-of-band
24
+ * "unscorable" marker. That overload is the bug: consumers drop `mi === 0`
25
+ * rows, so an unscorable file silently vanishes from the baseline instead of
26
+ * being reported, and no amount of re-seeding can ever give it a row.
27
+ *
28
+ * Deliberately module-private. The numeric return is kept for backwards
29
+ * compatibility, but the *value* is not something a caller should branch on —
30
+ * that is the overload this change exists to stop propagating. Callers that
31
+ * need to tell "unscorable" from "genuinely terrible" read the `unscorable`
32
+ * flag from {@link scoreSource} / {@link scoreFile}.
33
+ */
34
+ const UNSCORABLE = 0;
35
+
36
+ /**
37
+ * Score a raw string, distinguishing "the kernel could not analyse this" from
38
+ * "this scored badly".
39
+ *
40
+ * @param {string} sourceCode The JavaScript source code.
41
+ * @returns {{ score: number, unscorable: boolean, reason: string|null }}
42
+ * `score` is {@link UNSCORABLE} when `unscorable` is true; `reason` carries
43
+ * the kernel's own error message so a consumer can report *why* rather than
44
+ * just omitting the file.
45
+ */
46
+ export function scoreSource(sourceCode) {
47
+ try {
48
+ const score = escomplex.analyzeModule(sourceCode)?.maintainability;
49
+ return Number.isFinite(score)
50
+ ? { score, unscorable: false, reason: null }
51
+ : unscorable(`kernel returned a non-finite index (${String(score)})`);
52
+ } catch (err) {
53
+ return unscorable(
54
+ `${err?.constructor?.name ?? 'Error'}: ${err?.message ?? 'unknown kernel failure'}`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} reason
61
+ * @returns {{ score: number, unscorable: boolean, reason: string }}
9
62
  */
63
+ function unscorable(reason) {
64
+ return { score: UNSCORABLE, unscorable: true, reason };
65
+ }
66
+
10
67
  /**
11
68
  * Calculate score for a raw string of source code.
69
+ *
70
+ * Returns 0 for unscorable input, which is ambiguous by construction — see
71
+ * {@link UNSCORABLE}. Prefer {@link scoreSource} in new code.
72
+ *
12
73
  * @param {string} sourceCode The JavaScript source code.
13
74
  * @returns {number} Score between 0 and 171. Higher is better.
14
75
  */
15
76
  export function calculateForSource(sourceCode) {
16
- try {
17
- const result = escomplex.analyzeModule(sourceCode);
18
- return result.maintainability;
19
- } catch (_err) {
20
- // Return 0 if the parser fails (e.g. invalid syntax)
21
- return 0;
22
- }
77
+ return scoreSource(sourceCode).score;
23
78
  }
24
79
 
25
80
  /**
@@ -34,17 +89,34 @@ export function calculateForSource(sourceCode) {
34
89
  * be parsed (escomplex parse error or TS transpile failure).
35
90
  */
36
91
  export function calculateForFile(filePath) {
92
+ return scoreFile(filePath).score;
93
+ }
94
+
95
+ /**
96
+ * Score a file, distinguishing "unscorable" from "scored badly".
97
+ *
98
+ * The transpile-failure and kernel-failure cases are reported separately
99
+ * because they need different fixes: a transpile failure is usually the
100
+ * consumer's own syntax or `tsconfig`, whereas a kernel failure is the
101
+ * upstream generator gap described in `escomplex-ast-compat.js`.
102
+ *
103
+ * @param {string} filePath Path to the JS/TS source file.
104
+ * @returns {{ score: number, unscorable: boolean, reason: string|null }}
105
+ */
106
+ export function scoreFile(filePath) {
107
+ let sourceCode;
37
108
  try {
38
- const sourceCode = fs.readFileSync(filePath, 'utf-8');
39
- const prepared = transpileIfNeeded(filePath, sourceCode);
40
- if (prepared === null) return 0;
41
- return calculateForSource(prepared);
109
+ sourceCode = fs.readFileSync(filePath, 'utf-8');
42
110
  } catch (err) {
43
111
  if (err.code === 'ENOENT') {
44
112
  throw new Error(`File not found: ${filePath}`);
45
113
  }
46
114
  throw err;
47
115
  }
116
+
117
+ const prepared = transpileIfNeeded(filePath, sourceCode);
118
+ if (prepared === null) return unscorable('TypeScript transpile failed');
119
+ return scoreSource(prepared);
48
120
  }
49
121
 
50
122
  /**
@@ -0,0 +1,60 @@
1
+ /**
2
+ * maintainability-unscorable.js — reporting for files the MI kernel cannot
3
+ * analyse.
4
+ *
5
+ * A file the kernel throws on has no maintainability index, so it gets no
6
+ * baseline row — a phantom `mi: 0` would poison the `min`/p50 rollup and let
7
+ * real regressions hide behind it. Dropping the row is therefore correct; doing
8
+ * it *silently* is not. Without a report, an unscorable file is
9
+ * indistinguishable from a file nobody added yet: the scorer emits nothing, the
10
+ * scope gate sees an absence it cannot explain, and re-seeding the baseline can
11
+ * never produce the missing row no matter how many times it runs.
12
+ *
13
+ * Kept separate from `maintainability-utils.js` so the scoring path stays about
14
+ * scoring and this stays about explaining.
15
+ */
16
+
17
+ import { Logger } from './Logger.js';
18
+
19
+ /**
20
+ * Report every unscorable file, then summarise.
21
+ *
22
+ * @param {Array<{ relPath: string, unscorable?: boolean, reason?: string|null }>} perFile
23
+ * @returns {number} how many files were unscorable, for the caller's own use.
24
+ */
25
+ export function reportUnscorable(perFile) {
26
+ const unscorable = (perFile ?? []).filter((entry) => entry?.unscorable);
27
+ if (unscorable.length === 0) return 0;
28
+
29
+ for (const { relPath, reason } of unscorable) {
30
+ Logger.error(
31
+ `[Maintainability] UNSCORABLE ${relPath}: ${reason ?? 'unknown kernel failure'}`,
32
+ );
33
+ }
34
+ Logger.error(
35
+ `[Maintainability] ${unscorable.length} file(s) could not be scored and will have ` +
36
+ 'no baseline row, so the maintainability gate cannot see them. If the cause is a ' +
37
+ 'kernel AST gap, add a handler in lib/escomplex-ast-compat.js rather than an ' +
38
+ 'allowlist entry.',
39
+ );
40
+ return unscorable.length;
41
+ }
42
+
43
+ /**
44
+ * Whether a per-file entry carries a real maintainability index and so belongs
45
+ * in the baseline.
46
+ *
47
+ * An unscorable entry carries the sentinel score, not an index — letting it
48
+ * through would write an `mi: 0` phantom and drag the rollup floor down with it
49
+ * (Story #2467). `score === null` is the separate I/O-failure case.
50
+ *
51
+ * Tests for a *number* rather than `score !== null`, because the latter passes
52
+ * anything absent: `undefined !== null` is true, so a missing entry or one with
53
+ * no `score` key at all would have been treated as scored.
54
+ *
55
+ * @param {{ score?: number|null, unscorable?: boolean }} entry
56
+ * @returns {boolean}
57
+ */
58
+ export function isScored(entry) {
59
+ return typeof entry?.score === 'number' && !entry.unscorable;
60
+ }
@@ -4,7 +4,8 @@ import { minimatch } from 'minimatch';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
5
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
7
- import { calculateForFile } from './maintainability-engine.js';
7
+ import { scoreFile } from './maintainability-engine.js';
8
+ import { isScored, reportUnscorable } from './maintainability-unscorable.js';
8
9
 
9
10
  const MAINTAINABILITY_WORKER_URL = new URL(
10
11
  './workers/maintainability-worker.js',
@@ -128,6 +129,13 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
128
129
  * worker-side per-item failures surface as a `null` score that is
129
130
  * filtered out before assembly.
130
131
  *
132
+ * A file the kernel cannot analyse is also dropped — a phantom `mi: 0` row
133
+ * poisons the rollup — but it is **reported** on the way out, with the
134
+ * kernel's own error text, and the count is summarised at the end of the run.
135
+ * Silently omitting these is what let a file sit unmeasured indefinitely: the
136
+ * scorer emitted no row, so no amount of re-seeding could ever produce one,
137
+ * and nothing said so.
138
+ *
131
139
  * @param {string[]} paths
132
140
  * @returns {Promise<Record<string, number>>}
133
141
  */
@@ -142,7 +150,7 @@ export async function calculateAll(paths) {
142
150
  if (indexed.length < SERIAL_THRESHOLD) {
143
151
  perFile = indexed.map(({ abs, relPath }) => {
144
152
  try {
145
- return { relPath, score: calculateForFile(abs) };
153
+ return { relPath, ...scoreFile(abs) };
146
154
  } catch (err) {
147
155
  Logger.error(
148
156
  `[Maintainability] Failed to process ${abs}: ${err.message}`,
@@ -166,7 +174,7 @@ export async function calculateAll(paths) {
166
174
  if (r.score === null && r.error) {
167
175
  Logger.error(`[Maintainability] Failed to process ${abs}: ${r.error}`);
168
176
  }
169
- return { relPath, score: r.score };
177
+ return { relPath, ...r };
170
178
  });
171
179
  }
172
180
 
@@ -174,9 +182,10 @@ export async function calculateAll(paths) {
174
182
  a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0,
175
183
  );
176
184
 
185
+ reportUnscorable(perFile);
186
+
177
187
  const scores = {};
178
- for (const { relPath, score } of perFile) {
179
- if (score === null) continue;
188
+ for (const { relPath, score } of perFile.filter(isScored)) {
180
189
  scores[relPath] = score;
181
190
  }
182
191
  return scores;
@@ -75,6 +75,14 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
75
75
  * reflect code findings only.
76
76
  */
77
77
  TOOL_DEGRADED: 'tool-degraded',
78
+ /**
79
+ * The light delivery path refused a scope — a suitability-gate `ask-operator`
80
+ * or a blocked diff backstop. Story #4856 added it because neither rejection
81
+ * emitted anything, so an over-tight ceiling could only reach the framework
82
+ * as anecdote; the roll-up aggregating these by category is what makes the
83
+ * ceilings recalibratable from recorded evidence.
84
+ */
85
+ LIGHT_SCOPE_REJECTED: 'light-scope-rejected',
78
86
  });
79
87
 
80
88
  /** Cap on free-form reason text copied into a signal's `details`. */
@@ -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
  }