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
|
@@ -48,9 +48,14 @@
|
|
|
48
48
|
*
|
|
49
49
|
* Stdout: a single JSON envelope
|
|
50
50
|
* { storyId, epicId, decision, round, cap, capReached, totalCriteria,
|
|
51
|
-
* metCount, unmetCriteria[], signalEmitted }
|
|
51
|
+
* metCount, unmetCriteria[], signalEmitted, replay, verdictFingerprint }
|
|
52
52
|
* (`epicId` is retained as a always-null field for envelope stability.)
|
|
53
53
|
*
|
|
54
|
+
* Reading is free (Story #4874): re-invoking the gate over a verdict the
|
|
55
|
+
* ledger has already scored replays that round (`replay: true`,
|
|
56
|
+
* `signalEmitted: false`) instead of consuming one, so an unchanged verdict
|
|
57
|
+
* can never escalate from `redraft` to `block` by being looked at twice.
|
|
58
|
+
*
|
|
54
59
|
* @see .agents/scripts/lib/orchestration/acceptance-eval-decision.js
|
|
55
60
|
* @see .agents/schemas/acceptance-eval-verdict.schema.json
|
|
56
61
|
*/
|
|
@@ -69,8 +74,9 @@ import { Logger } from './lib/Logger.js';
|
|
|
69
74
|
import { appendSignal } from './lib/observability/signals-writer.js';
|
|
70
75
|
import {
|
|
71
76
|
buildAcceptanceEvalSignal,
|
|
77
|
+
computeVerdictFingerprint,
|
|
72
78
|
decideAcceptanceEval,
|
|
73
|
-
|
|
79
|
+
resolveAcceptanceEvalRound,
|
|
74
80
|
} from './lib/orchestration/acceptance-eval-decision.js';
|
|
75
81
|
|
|
76
82
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -166,7 +172,8 @@ function parseCliArgs(argv) {
|
|
|
166
172
|
* self-reported `round` is never load-bearing for the cap.
|
|
167
173
|
* @param {object} [deps]
|
|
168
174
|
* @param {Function} [deps.appendSignalFn]
|
|
169
|
-
* @param {Function} [deps.
|
|
175
|
+
* @param {Function} [deps.resolveRoundFn]
|
|
176
|
+
* @param {Function} [deps.fingerprintFn]
|
|
170
177
|
* @returns {Promise<{ envelope: object, exitCode: number }>}
|
|
171
178
|
*/
|
|
172
179
|
export async function runAcceptanceEval(
|
|
@@ -175,13 +182,23 @@ export async function runAcceptanceEval(
|
|
|
175
182
|
) {
|
|
176
183
|
const {
|
|
177
184
|
appendSignalFn = appendSignal,
|
|
178
|
-
|
|
185
|
+
resolveRoundFn = resolveAcceptanceEvalRound,
|
|
186
|
+
fingerprintFn = computeVerdictFingerprint,
|
|
179
187
|
} = deps;
|
|
180
188
|
const { maxRounds } = getAcceptanceEval(config);
|
|
189
|
+
const verdictFingerprint = fingerprintFn(verdict);
|
|
190
|
+
// Story #4874: re-reading an already-scored verdict is a replay — it
|
|
191
|
+
// reports the round that verdict was scored under and appends nothing, so
|
|
192
|
+
// observation alone can never advance the counter or escalate a redraft.
|
|
193
|
+
const resolved = resolveRoundFn({
|
|
194
|
+
epicId: null,
|
|
195
|
+
storyId,
|
|
196
|
+
config,
|
|
197
|
+
verdictFingerprint,
|
|
198
|
+
});
|
|
199
|
+
const replay = resolved.replay === true;
|
|
181
200
|
const resolvedRound =
|
|
182
|
-
Number.isInteger(round) && round >= 1
|
|
183
|
-
? round
|
|
184
|
-
: deriveRoundFn({ epicId: null, storyId, config });
|
|
201
|
+
Number.isInteger(round) && round >= 1 ? round : resolved.round;
|
|
185
202
|
const outcome = decideAcceptanceEval({
|
|
186
203
|
verdict,
|
|
187
204
|
maxRounds,
|
|
@@ -189,9 +206,14 @@ export async function runAcceptanceEval(
|
|
|
189
206
|
});
|
|
190
207
|
|
|
191
208
|
let signalEmitted = false;
|
|
192
|
-
if (emitSignal) {
|
|
209
|
+
if (emitSignal && !replay) {
|
|
193
210
|
const signal = {
|
|
194
|
-
...buildAcceptanceEvalSignal({
|
|
211
|
+
...buildAcceptanceEvalSignal({
|
|
212
|
+
storyId,
|
|
213
|
+
epicId: null,
|
|
214
|
+
outcome,
|
|
215
|
+
verdictFingerprint,
|
|
216
|
+
}),
|
|
195
217
|
ts: new Date().toISOString(),
|
|
196
218
|
};
|
|
197
219
|
try {
|
|
@@ -228,6 +250,10 @@ export async function runAcceptanceEval(
|
|
|
228
250
|
evidence: c.evidence,
|
|
229
251
|
})),
|
|
230
252
|
signalEmitted,
|
|
253
|
+
// True when this invocation re-read a verdict the ledger had already
|
|
254
|
+
// scored: the round was replayed, not advanced, and nothing was appended.
|
|
255
|
+
replay,
|
|
256
|
+
verdictFingerprint,
|
|
231
257
|
};
|
|
232
258
|
|
|
233
259
|
// `block` is the only non-zero exit: the loop has exhausted its bounded
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* audit-labels-bootstrap.js — Idempotently create the
|
|
3
|
-
*
|
|
2
|
+
* audit-labels-bootstrap.js — Idempotently create the audit label taxonomy in
|
|
3
|
+
* the configured GitHub repo.
|
|
4
4
|
*
|
|
5
5
|
* Run this once per repo before `/audit-to-stories` opens its first
|
|
6
6
|
* Story. Re-runs are safe — existing labels are skipped, only missing
|
|
7
7
|
* ones are created. Story #2583 acceptance criterion #6.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* (Story #
|
|
17
|
-
*
|
|
18
|
-
* `AUDIT_LENSES` and (optionally) a `LENS_META` entry.
|
|
9
|
+
* This CLI is a thin creator over
|
|
10
|
+
* [`lib/audit-to-stories/audit-label-taxonomy.js`](lib/audit-to-stories/audit-label-taxonomy.js),
|
|
11
|
+
* which is the SSOT for **every** label an audit sweep creates or generates:
|
|
12
|
+
* the `audit::<lens>` set (derived from the shared `AUDIT_LENSES` list, one per
|
|
13
|
+
* `/audit-<lens>` workflow) plus the story-axis labels the filer applies. The
|
|
14
|
+
* creator and the generator (`build-story-body.js`) read that one list, so the
|
|
15
|
+
* bootstrap cannot fall behind the filer — the drift that left `risk::high`
|
|
16
|
+
* generated but defined nowhere (Story #4877), and that made `audit::<dimension>`
|
|
17
|
+
* labels mint from free-form prose before Story #4195.
|
|
19
18
|
*
|
|
20
19
|
* Delegates to `gh label create` so the script works without any
|
|
21
20
|
* provider plumbing — `gh auth status` is the only prerequisite. Per
|
|
@@ -26,88 +25,11 @@
|
|
|
26
25
|
import process from 'node:process';
|
|
27
26
|
import { parseArgs } from 'node:util';
|
|
28
27
|
|
|
29
|
-
import {
|
|
28
|
+
import { AUDIT_LABEL_TAXONOMY } from './lib/audit-to-stories/audit-label-taxonomy.js';
|
|
30
29
|
import { runAsCli } from './lib/cli-utils.js';
|
|
31
30
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
32
31
|
import { gh as defaultGh, GhExecError } from './lib/gh-exec.js';
|
|
33
32
|
|
|
34
|
-
/**
|
|
35
|
-
* Per-lens label presentation. Keyed by canonical lens name. A lens absent
|
|
36
|
-
* from this map falls back to {@link DEFAULT_LENS_META} so a newly-added
|
|
37
|
-
* `AUDIT_LENSES` entry still gets a label without a hard requirement to
|
|
38
|
-
* register colour/description here first.
|
|
39
|
-
*/
|
|
40
|
-
const LENS_META = Object.freeze({
|
|
41
|
-
accessibility: {
|
|
42
|
-
color: 'c5def5',
|
|
43
|
-
description: 'Audit-sourced finding: WCAG accessibility conformance',
|
|
44
|
-
},
|
|
45
|
-
architecture: {
|
|
46
|
-
color: '6f42c1',
|
|
47
|
-
description: 'Audit-sourced finding: architectural concerns',
|
|
48
|
-
},
|
|
49
|
-
'clean-code': {
|
|
50
|
-
color: '0e8a16',
|
|
51
|
-
description: 'Audit-sourced finding: clean-code / maintainability',
|
|
52
|
-
},
|
|
53
|
-
dependencies: {
|
|
54
|
-
color: 'd4c5f9',
|
|
55
|
-
description: 'Audit-sourced finding: dependencies / supply chain',
|
|
56
|
-
},
|
|
57
|
-
devops: {
|
|
58
|
-
color: 'fbca04',
|
|
59
|
-
description: 'Audit-sourced finding: DevOps / CI / CD',
|
|
60
|
-
},
|
|
61
|
-
documentation: {
|
|
62
|
-
color: '1d76db',
|
|
63
|
-
description: 'Audit-sourced finding: documentation staleness / gaps',
|
|
64
|
-
},
|
|
65
|
-
navigability: {
|
|
66
|
-
color: 'bfdadc',
|
|
67
|
-
description: 'Audit-sourced finding: route / nav reachability',
|
|
68
|
-
},
|
|
69
|
-
performance: {
|
|
70
|
-
color: 'b60205',
|
|
71
|
-
description: 'Audit-sourced finding: performance / latency',
|
|
72
|
-
},
|
|
73
|
-
privacy: {
|
|
74
|
-
color: 'fef2c0',
|
|
75
|
-
description: 'Audit-sourced finding: privacy / data handling',
|
|
76
|
-
},
|
|
77
|
-
quality: {
|
|
78
|
-
color: '0052cc',
|
|
79
|
-
description: 'Audit-sourced finding: test quality / coverage gaps',
|
|
80
|
-
},
|
|
81
|
-
security: {
|
|
82
|
-
color: 'b60205',
|
|
83
|
-
description: 'Audit-sourced finding: security / OWASP',
|
|
84
|
-
},
|
|
85
|
-
seo: {
|
|
86
|
-
color: 'fbca04',
|
|
87
|
-
description: 'Audit-sourced finding: SEO / discoverability',
|
|
88
|
-
},
|
|
89
|
-
sre: {
|
|
90
|
-
color: '0052cc',
|
|
91
|
-
description: 'Audit-sourced finding: SRE / observability / reliability',
|
|
92
|
-
},
|
|
93
|
-
'ux-ui': {
|
|
94
|
-
color: 'd4c5f9',
|
|
95
|
-
description: 'Audit-sourced finding: UX / UI concerns',
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const DEFAULT_LENS_META = Object.freeze({
|
|
100
|
-
color: 'ededed',
|
|
101
|
-
description: 'Audit-sourced finding',
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const DIMENSIONS = Object.freeze(
|
|
105
|
-
AUDIT_LENSES.map((name) => ({
|
|
106
|
-
name,
|
|
107
|
-
...(LENS_META[name] ?? DEFAULT_LENS_META),
|
|
108
|
-
})),
|
|
109
|
-
);
|
|
110
|
-
|
|
111
33
|
async function labelExists(gh, owner, repo, name) {
|
|
112
34
|
try {
|
|
113
35
|
const list = await gh.label.list(
|
|
@@ -166,9 +88,8 @@ export async function bootstrapAuditLabels({
|
|
|
166
88
|
const skipped = [];
|
|
167
89
|
const failed = [];
|
|
168
90
|
|
|
169
|
-
for (const
|
|
170
|
-
const labelName =
|
|
171
|
-
const candidate = { ...dim, name: labelName };
|
|
91
|
+
for (const candidate of AUDIT_LABEL_TAXONOMY) {
|
|
92
|
+
const labelName = candidate.name;
|
|
172
93
|
|
|
173
94
|
if (dryRun) {
|
|
174
95
|
created.push(labelName);
|
|
@@ -190,7 +111,7 @@ export async function bootstrapAuditLabels({
|
|
|
190
111
|
}
|
|
191
112
|
}
|
|
192
113
|
|
|
193
|
-
return { created, skipped, failed, total:
|
|
114
|
+
return { created, skipped, failed, total: AUDIT_LABEL_TAXONOMY.length };
|
|
194
115
|
}
|
|
195
116
|
|
|
196
117
|
/**
|
|
@@ -241,7 +162,7 @@ export function formatBootstrapReport(result) {
|
|
|
241
162
|
};
|
|
242
163
|
}
|
|
243
164
|
|
|
244
|
-
export const __testing = {
|
|
165
|
+
export const __testing = { AUDIT_LABEL_TAXONOMY };
|
|
245
166
|
|
|
246
167
|
async function main() {
|
|
247
168
|
const { values } = parseArgs({
|
|
@@ -47,17 +47,34 @@ import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
|
|
|
47
47
|
import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
|
|
48
48
|
import { runAsCli } from './lib/cli-utils.js';
|
|
49
49
|
import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
|
|
50
|
+
import { SEVERITIES, SEVERITY_RANK } from './lib/findings/severity.js';
|
|
50
51
|
import { Logger } from './lib/Logger.js';
|
|
51
52
|
import { parse as parseStoryBody } from './lib/story-body/story-body.js';
|
|
52
53
|
|
|
53
|
-
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
54
54
|
const DEFAULT_GLOB = 'temp/audits/audit-*-results.md';
|
|
55
55
|
const FAN_OUT_REPORT = 'audit-fan-out-results.md';
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Does `finding` clear the `threshold` severity floor?
|
|
59
|
+
*
|
|
60
|
+
* `SEVERITY_RANK` is imported from the canonical scale rather than declared
|
|
61
|
+
* here (Story #4877). The local copy this replaces ranked only four levels
|
|
62
|
+
* (`critical|high|medium|low`), so `info` — the canonical floor — ranked `0`,
|
|
63
|
+
* below even `--severity low`, and every informational finding was silently
|
|
64
|
+
* dropped from every filtered run. Sourcing the ranking from the SSOT means a
|
|
65
|
+
* level cannot exist in the vocabulary and be invisible to the filter.
|
|
66
|
+
*
|
|
67
|
+
* An unrecognised or absent severity still ranks below every real floor: it
|
|
68
|
+
* failed to parse, so it is not evidence that a threshold was met.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ severity?: string }} finding
|
|
71
|
+
* @param {string} [threshold] — a canonical level, `'all'`, or falsy for no floor.
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
57
74
|
function meetsSeverity(finding, threshold) {
|
|
58
75
|
if (!threshold || threshold === 'all') return true;
|
|
59
76
|
const minRank = SEVERITY_RANK[threshold] ?? 0;
|
|
60
|
-
const fRank = SEVERITY_RANK[finding.severity] ??
|
|
77
|
+
const fRank = SEVERITY_RANK[finding.severity] ?? -1;
|
|
61
78
|
return fRank >= minRank;
|
|
62
79
|
}
|
|
63
80
|
|
|
@@ -77,8 +94,21 @@ function readReports(paths) {
|
|
|
77
94
|
}));
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Count findings per severity bucket. The buckets are the canonical levels plus
|
|
99
|
+
* `unknown` for a finding whose severity did not parse — kept as a visible
|
|
100
|
+
* bucket so an unparseable severity is reported rather than absorbed into a
|
|
101
|
+
* real level. Derived from `SEVERITIES` so a new level appears in the tally
|
|
102
|
+
* automatically instead of falling into `unknown` (Story #4877).
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<{ severity?: string }>} findings
|
|
105
|
+
* @returns {Record<string, number>}
|
|
106
|
+
*/
|
|
80
107
|
function tallyBySeverity(findings) {
|
|
81
|
-
const t = {
|
|
108
|
+
const t = {
|
|
109
|
+
...Object.fromEntries(SEVERITIES.map((s) => [s, 0])),
|
|
110
|
+
unknown: 0,
|
|
111
|
+
};
|
|
82
112
|
for (const f of findings) {
|
|
83
113
|
if (Object.hasOwn(t, f.severity)) t[f.severity] += 1;
|
|
84
114
|
else t.unknown += 1;
|
|
@@ -47,9 +47,18 @@
|
|
|
47
47
|
* Ratchet semantics (mirroring the sibling ratchets):
|
|
48
48
|
* - A gated tier grows beyond `baseline.tiers.<tier>.totalBytes +
|
|
49
49
|
* baseline.toleranceBytes` → exit 1, naming the tier and its delta.
|
|
50
|
-
* - A gated tier shrinks below its baseline total →
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* - A gated tier shrinks below its baseline total → exit 1 (Story #4872).
|
|
51
|
+
* A ratchet that only tightens in one direction lets every measured
|
|
52
|
+
* improvement evaporate: the recorded total keeps promising headroom the
|
|
53
|
+
* tree no longer spends, so the next growth is absorbed by stale slack
|
|
54
|
+
* instead of being reported. Shrinkage is therefore **actionable** —
|
|
55
|
+
* refresh the baseline down and the gain is locked in. Unlike growth this
|
|
56
|
+
* is deliberately **zero-tolerance**: `toleranceBytes` exists to keep a
|
|
57
|
+
* trivial addition from churning the file, and applying it downward would
|
|
58
|
+
* silently discard every sub-tolerance gain.
|
|
59
|
+
* - A recorded row naming a path the measured tier no longer contains →
|
|
60
|
+
* exit 1. The row describes a file that has been deleted or de-listed, so
|
|
61
|
+
* the bytes it contributes to the recorded total are fiction.
|
|
53
62
|
* - Within tolerance / clean → exit 0.
|
|
54
63
|
* - Baseline file absent → warn + exit 0 (no-op; nothing to ratchet against).
|
|
55
64
|
*
|
|
@@ -303,16 +312,45 @@ export function buildBaseline(tierMap, toleranceBytes) {
|
|
|
303
312
|
};
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Collect the recorded rows of one gated tier that name a path the measured
|
|
317
|
+
* tier no longer contains (Story #4872). A deleted file drops out of the
|
|
318
|
+
* resolved tier, and so does one that has been de-listed from the read set —
|
|
319
|
+
* either way the row's bytes are counted into a recorded total that no live
|
|
320
|
+
* file backs, so the row is drift and not a detail.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} tier
|
|
323
|
+
* @param {Array<{ path: string, bytes: number }>} files live tier measurement
|
|
324
|
+
* @param {{ files?: Array<{ path: string, bytes?: number }> }} baseTier recorded tier
|
|
325
|
+
* @returns {Array<{ tier: string, path: string, bytes: number|null }>}
|
|
326
|
+
*/
|
|
327
|
+
function absentRows(tier, files, baseTier) {
|
|
328
|
+
const live = new Set(files.map((f) => f.path));
|
|
329
|
+
const out = [];
|
|
330
|
+
for (const row of baseTier?.files ?? []) {
|
|
331
|
+
if (typeof row?.path !== 'string' || live.has(row.path)) continue;
|
|
332
|
+
out.push({
|
|
333
|
+
tier,
|
|
334
|
+
path: row.path,
|
|
335
|
+
bytes: Number.isFinite(row.bytes) ? row.bytes : null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
306
341
|
/**
|
|
307
342
|
* Pure diff: compare the current tier map against the committed baseline. A
|
|
308
343
|
* gated tier with no current files is skipped; a tier absent from the baseline
|
|
309
|
-
* is skipped. `grown` entries fail the gate
|
|
344
|
+
* is skipped. `grown`, `shrunk` and `absent` entries all fail the gate — see
|
|
345
|
+
* the ratchet semantics in the module header for why shrinkage is actionable
|
|
346
|
+
* rather than informational (Story #4872).
|
|
310
347
|
*
|
|
311
348
|
* @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
|
|
312
349
|
* @param {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number }> }} baseline
|
|
313
350
|
* @returns {{
|
|
314
351
|
* grown: Array<{ tier: string, current: number, baseline: number, tolerance: number, delta: number }>,
|
|
315
|
-
* shrunk: Array<{ tier: string, current: number, baseline: number }>,
|
|
352
|
+
* shrunk: Array<{ tier: string, current: number, baseline: number, delta: number }>,
|
|
353
|
+
* absent: Array<{ tier: string, path: string, bytes: number|null }>,
|
|
316
354
|
* skipped: string[],
|
|
317
355
|
* }}
|
|
318
356
|
*/
|
|
@@ -322,6 +360,7 @@ export function diffBudget(tierMap, baseline) {
|
|
|
322
360
|
: 0;
|
|
323
361
|
const grown = [];
|
|
324
362
|
const shrunk = [];
|
|
363
|
+
const absent = [];
|
|
325
364
|
const skipped = [];
|
|
326
365
|
for (const tier of GATED_TIERS) {
|
|
327
366
|
const files = tierMap.tiers[tier] ?? [];
|
|
@@ -345,16 +384,42 @@ export function diffBudget(tierMap, baseline) {
|
|
|
345
384
|
delta: current - baselineBytes,
|
|
346
385
|
});
|
|
347
386
|
} else if (current < baselineBytes) {
|
|
348
|
-
|
|
387
|
+
// Deliberately zero-tolerance: `tolerance` guards against churn from a
|
|
388
|
+
// trivial *addition*; mirroring it downward would discard every gain
|
|
389
|
+
// smaller than the tolerance, which is the leak this branch closes.
|
|
390
|
+
shrunk.push({
|
|
391
|
+
tier,
|
|
392
|
+
current,
|
|
393
|
+
baseline: baselineBytes,
|
|
394
|
+
delta: baselineBytes - current,
|
|
395
|
+
});
|
|
349
396
|
}
|
|
397
|
+
absent.push(...absentRows(tier, files, baseTier));
|
|
350
398
|
}
|
|
351
|
-
return { grown, shrunk, skipped };
|
|
399
|
+
return { grown, shrunk, absent, skipped };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Count the drift entries that fail the gate. Every direction is actionable
|
|
404
|
+
* (Story #4872), so this is the one place the failure set is defined and both
|
|
405
|
+
* the summary tag and the exit code read it.
|
|
406
|
+
*
|
|
407
|
+
* @param {ReturnType<typeof diffBudget>} diff
|
|
408
|
+
* @returns {number}
|
|
409
|
+
*/
|
|
410
|
+
export function budgetFailureCount(diff) {
|
|
411
|
+
return (
|
|
412
|
+
(diff?.grown?.length ?? 0) +
|
|
413
|
+
(diff?.shrunk?.length ?? 0) +
|
|
414
|
+
(diff?.absent?.length ?? 0)
|
|
415
|
+
);
|
|
352
416
|
}
|
|
353
417
|
|
|
354
418
|
/**
|
|
355
419
|
* Render the human-readable diff. `+` lines are tiers that grew beyond
|
|
356
|
-
* tolerance
|
|
357
|
-
*
|
|
420
|
+
* tolerance; `-` lines are tiers that shrank below their recorded total or
|
|
421
|
+
* rows naming a path the tree no longer carries. All three fail the gate. A
|
|
422
|
+
* one-line summary always follows.
|
|
358
423
|
*
|
|
359
424
|
* @param {ReturnType<typeof diffBudget>} diff
|
|
360
425
|
* @returns {string}
|
|
@@ -368,12 +433,17 @@ export function renderDiff(diff) {
|
|
|
368
433
|
}
|
|
369
434
|
for (const s of diff.shrunk) {
|
|
370
435
|
lines.push(
|
|
371
|
-
`- ${s.tier}: ${s.current} bytes
|
|
436
|
+
`- ${s.tier}: ${s.current} bytes is under the recorded ${s.baseline} (delta -${s.delta}) — the ratchet is holding slack the tree no longer spends; refresh baselines/context-budget.json`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
for (const a of diff.absent ?? []) {
|
|
440
|
+
lines.push(
|
|
441
|
+
`- ${a.tier}: recorded row ${a.path} names a path the measured tier no longer contains — refresh baselines/context-budget.json`,
|
|
372
442
|
);
|
|
373
443
|
}
|
|
374
|
-
const tag = diff
|
|
444
|
+
const tag = budgetFailureCount(diff) > 0 ? '(gate fail)' : '(ok)';
|
|
375
445
|
lines.push(
|
|
376
|
-
`[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} skipped=${diff.skipped.length} ${tag}`,
|
|
446
|
+
`[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} absent=${diff.absent?.length ?? 0} skipped=${diff.skipped.length} ${tag}`,
|
|
377
447
|
);
|
|
378
448
|
return lines.join('\n');
|
|
379
449
|
}
|
|
@@ -454,7 +524,7 @@ export async function runCli({
|
|
|
454
524
|
if (!baseline) {
|
|
455
525
|
if (json) {
|
|
456
526
|
stdout.write(
|
|
457
|
-
`${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
|
|
527
|
+
`${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
|
|
458
528
|
);
|
|
459
529
|
} else {
|
|
460
530
|
stderr.write(
|
|
@@ -472,7 +542,7 @@ export async function runCli({
|
|
|
472
542
|
const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
|
|
473
543
|
const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
|
|
474
544
|
const exitCode =
|
|
475
|
-
diff
|
|
545
|
+
budgetFailureCount(diff) > 0 ||
|
|
476
546
|
bootOverflow.length > 0 ||
|
|
477
547
|
permissiveDrift.length > 0
|
|
478
548
|
? 1
|
|
@@ -490,6 +560,7 @@ export async function runCli({
|
|
|
490
560
|
),
|
|
491
561
|
grown: diff.grown,
|
|
492
562
|
shrunk: diff.shrunk,
|
|
563
|
+
absent: diff.absent,
|
|
493
564
|
skipped: diff.skipped,
|
|
494
565
|
agentBootCeilingBytes: ceiling,
|
|
495
566
|
agentBootOverflow: bootOverflow,
|
|
@@ -527,6 +598,16 @@ export async function runCli({
|
|
|
527
598
|
`[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
|
|
528
599
|
);
|
|
529
600
|
}
|
|
601
|
+
if (diff.shrunk.length > 0) {
|
|
602
|
+
stderr.write(
|
|
603
|
+
`[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (diff.absent.length > 0) {
|
|
607
|
+
stderr.write(
|
|
608
|
+
`[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
530
611
|
}
|
|
531
612
|
}
|
|
532
613
|
|
|
@@ -78,6 +78,11 @@ import os from 'node:os';
|
|
|
78
78
|
import path from 'node:path';
|
|
79
79
|
import { fileURLToPath } from 'node:url';
|
|
80
80
|
import { runAsCli } from './lib/cli-utils.js';
|
|
81
|
+
import { mainCheckoutRoot } from './lib/config/temp-paths.js';
|
|
82
|
+
import {
|
|
83
|
+
isReservedTestId,
|
|
84
|
+
RESERVED_TEST_ID_BAND,
|
|
85
|
+
} from './lib/reserved-test-ids.js';
|
|
81
86
|
import {
|
|
82
87
|
findRawTmpdirMkdtemp,
|
|
83
88
|
listSuiteTempRoots,
|
|
@@ -369,6 +374,84 @@ export function cleanFixtureDirs({
|
|
|
369
374
|
return { candidates, removed };
|
|
370
375
|
}
|
|
371
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Every Epic / Story id a stream file's own path attributes it to.
|
|
379
|
+
*
|
|
380
|
+
* Both canonical layouts are read, and a nested Epic-attached stream yields
|
|
381
|
+
* both ids (`run-<eid>/stories/story-<sid>/…`): either half being a fixture id
|
|
382
|
+
* makes the stream fixture-owned, and taking only the outer one is how a
|
|
383
|
+
* fixture Story under a real run would slip past.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} rel POSIX-normalised path relative to `temp/`.
|
|
386
|
+
* @returns {number[]}
|
|
387
|
+
*/
|
|
388
|
+
function streamOwnerIds(rel) {
|
|
389
|
+
const ids = [];
|
|
390
|
+
const run = /^run-(\d+)$/.exec(rel.split('/')[0]);
|
|
391
|
+
if (run) ids.push(Number(run[1]));
|
|
392
|
+
const story = /(?:^|\/)story-(\d+)\//.exec(rel);
|
|
393
|
+
if (story) ids.push(Number(story[1]));
|
|
394
|
+
return ids;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Stream files under `tempDir` owned by a **reserved test-fixture id**
|
|
399
|
+
* (Story #4892).
|
|
400
|
+
*
|
|
401
|
+
* This is the residual-pollution dimension the snapshot/assert bracket cannot
|
|
402
|
+
* cover: the bracket only runs in CI, where `temp/` starts empty, so a local
|
|
403
|
+
* run that appends fixture telemetry to the operator's live ledger was only
|
|
404
|
+
* ever discovered from the ticket the retro graduator filed off it (issue
|
|
405
|
+
* #4870 cited `#999999`, a `--story 999999` CLI spawn from the suite).
|
|
406
|
+
*
|
|
407
|
+
* Unlike the snapshot diff this needs no baseline and is immune to a
|
|
408
|
+
* concurrent delivery in another checkout: a reserved id is reserved *from*
|
|
409
|
+
* real work, so nothing but a test can own one of these files.
|
|
410
|
+
*
|
|
411
|
+
* @param {string} tempDir
|
|
412
|
+
* @returns {string[]} POSIX-normalised paths relative to `tempDir`, sorted.
|
|
413
|
+
*/
|
|
414
|
+
export function findReservedIdStreamFiles(tempDir) {
|
|
415
|
+
return listStreamFiles(tempDir).filter((rel) =>
|
|
416
|
+
streamOwnerIds(rel).some(isReservedTestId),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Post-run guard: fail when a test run left a fixture-id telemetry stream in
|
|
422
|
+
* the **repository-root** temp tree (Story #4892).
|
|
423
|
+
*
|
|
424
|
+
* Resolution matters more than it looks: every writer anchors a relative
|
|
425
|
+
* `tempRoot` to the *main checkout* (so a Story worktree and its `/deliver`
|
|
426
|
+
* host converge on one ledger), so a guard that scanned `cwd` would scan an
|
|
427
|
+
* empty worktree tree and pass vacuously on the very tree it is meant to
|
|
428
|
+
* protect. `resolveRoot` is the injection seam for tests.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} [opts]
|
|
431
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
432
|
+
* @param {(line: string) => void} [opts.log]
|
|
433
|
+
* @param {(cwd: string) => string|null} [opts.resolveRoot]
|
|
434
|
+
* @returns {number} exit code (0 clean, 1 polluted).
|
|
435
|
+
*/
|
|
436
|
+
export function assertNoReservedIdStreams({
|
|
437
|
+
cwd = process.cwd(),
|
|
438
|
+
log = (l) => process.stderr.write(`${l}\n`),
|
|
439
|
+
resolveRoot = mainCheckoutRoot,
|
|
440
|
+
} = {}) {
|
|
441
|
+
const root = resolveRoot(cwd) ?? cwd;
|
|
442
|
+
const tempDir = tempDirFor(root);
|
|
443
|
+
const found = findReservedIdStreamFiles(tempDir);
|
|
444
|
+
if (found.length === 0) return 0;
|
|
445
|
+
log(
|
|
446
|
+
`[test-temp-hygiene] FAIL — ${found.length} fixture-id telemetry stream(s) in the real temp tree (${tempDir}):`,
|
|
447
|
+
);
|
|
448
|
+
for (const rel of found) log(` + fixture ${rel}`);
|
|
449
|
+
log(
|
|
450
|
+
`[test-temp-hygiene] ids ${RESERVED_TEST_ID_BAND} are reserved for fixtures, so a test wrote to the live ledger — the retro graduator reads these streams and files tickets off them. Inject an absolute per-test tempRoot on the offending spawn, then remove the stream(s) with --clean --ids <id> --yes.`,
|
|
451
|
+
);
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
372
455
|
/**
|
|
373
456
|
* Parse the CLI argv into a normalised options object.
|
|
374
457
|
* @param {string[]} argv
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Removes **test-reserved** per-Epic trees under `<repo>/temp/epic-<id>/` after
|
|
6
|
-
* test runs. Only Epic IDs in the band
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* test runs. Only Epic IDs in the band declared by `lib/reserved-test-ids.js`
|
|
7
|
+
* are deleted (999000–999999). Real Epic scratch (e.g. `temp/epic-1143/`) is
|
|
8
|
+
* never touched.
|
|
9
9
|
*
|
|
10
10
|
* Keeps sibling dirs like `temp/epic-runner-logs/`, loose files, and
|
|
11
11
|
* `temp/epic-<id>/` when `<id>` is outside that reserved band.
|
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { runAsCli } from './lib/cli-utils.js';
|
|
23
|
+
import { isReservedTestId } from './lib/reserved-test-ids.js';
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const EPIC_TEMP_DIR = /^epic-(\d+)$/;
|
|
25
26
|
|
|
26
27
|
function isReservedTestEpicTempDirName(name) {
|
|
27
|
-
|
|
28
|
+
const match = EPIC_TEMP_DIR.exec(name);
|
|
29
|
+
return match !== null && isReservedTestId(Number(match[1]));
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -337,10 +337,9 @@ async function runBackstopMode(values, deps = {}) {
|
|
|
337
337
|
process.stderr.write(HELP);
|
|
338
338
|
throw new Error('[deliver-light] --backstop requires --story <id>');
|
|
339
339
|
}
|
|
340
|
-
const { result, nextCommand, exitCode, message } =
|
|
341
|
-
storyId
|
|
342
|
-
}
|
|
343
|
-
const extra = nextCommand === null ? {} : { nextCommand };
|
|
340
|
+
const { result, nextCommand, preservation, exitCode, message } =
|
|
341
|
+
await resolveFn({ storyId });
|
|
342
|
+
const extra = nextCommand === null ? {} : { nextCommand, preservation };
|
|
344
343
|
emit({ mode: 'backstop', storyId, ...result, ...extra }, values.pretty);
|
|
345
344
|
if (result.blocked) Logger.warn(message);
|
|
346
345
|
else Logger.info(message);
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
* `single-story-init.js` hard-errors on an already-closed Story. Before this
|
|
17
17
|
* surface, that Story had no automated way back.
|
|
18
18
|
*
|
|
19
|
+
* Every command it prints **resumes** — it re-enters the in-flight worker or
|
|
20
|
+
* close, or closes the branch that is already pushed. None of them re-dispatch
|
|
21
|
+
* the Story, and that is deliberate: since Story #4876 the close-and-land tail
|
|
22
|
+
* belongs to the dispatching orchestrator, so a worker returning without a
|
|
23
|
+
* terminal envelope is the expected shape rather than evidence the Story never
|
|
24
|
+
* ran. Answering it with a fresh dispatch re-runs `single-story-init.js`
|
|
25
|
+
* underneath live work and puts a second close on one PR.
|
|
26
|
+
*
|
|
19
27
|
* Usage:
|
|
20
28
|
* node .agents/scripts/deliver-recover.js --story <STORY_ID> [--cwd <main-repo>]
|
|
21
29
|
* [--json]
|
|
@@ -46,6 +54,11 @@ Probes a Story's live delivery state — labels, lease, branch, worktree, PR
|
|
|
46
54
|
state and checks — and prints the single next command that resumes it, with
|
|
47
55
|
the evidence it was derived from. Read-only: mutates nothing.
|
|
48
56
|
|
|
57
|
+
The command always resumes the existing worker or close (or closes an already
|
|
58
|
+
pushed branch); it never re-dispatches the Story, which would re-run init
|
|
59
|
+
underneath live work. A worker that returned no terminal envelope is expected —
|
|
60
|
+
the orchestrator owns the close-and-land tail.
|
|
61
|
+
|
|
49
62
|
Mid-flight shapes (executing-*/closing-*) get a stability re-probe after a
|
|
50
63
|
short settle window: matching shapes return the fresher verdict; diverging
|
|
51
64
|
shapes report \`in-transition\` (a live delivery process is mutating the
|