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.
- 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/schemas/story-deliver-terminal.schema.json +6 -1
- 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 +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- 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/kinds/maintainability.js +20 -32
- 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/escomplex-ast-compat.js +360 -0
- 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/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -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/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- 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/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- 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/lib/workers/maintainability-worker.js +14 -9
- 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-light.md +21 -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 +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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 `
|
|
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
|
}
|