mandrel 2.9.0 → 2.11.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/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +39 -25
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +36 -9
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +10 -6
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
- package/.agents/scripts/lib/config/quality.js +13 -0
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -16
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -3
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +153 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
- package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
- package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
- package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +84 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/resolve-stories.js +2 -0
- package/.agents/scripts/single-story-confirm-merge.js +65 -5
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/workflows/deliver.md +88 -230
- package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
- package/.agents/workflows/helpers/deliver-reference.md +171 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
- package/.agents/workflows/helpers/deliver-story.md +115 -432
- package/.agents/workflows/helpers/plan-reference.md +246 -0
- package/.agents/workflows/plan.md +108 -304
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +31 -14
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
- package/package.json +1 -1
|
@@ -702,7 +702,7 @@ const SOURCE_CODE_EXTENSIONS = Object.freeze([
|
|
|
702
702
|
const TEST_FILE_RE = new RegExp(String.raw`\.(test|spec)\.[cm]?[jt]sx?$`);
|
|
703
703
|
// biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
|
|
704
704
|
const TEST_DIR_RE = new RegExp(
|
|
705
|
-
|
|
705
|
+
'(^|/)(tests?|__tests__|spec|e2e|__mocks__)(/|$)',
|
|
706
706
|
);
|
|
707
707
|
// biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
|
|
708
708
|
const CODE_EXT_RE = new RegExp(String.raw`\.[cm]?[jt]sx?$`);
|
|
@@ -117,6 +117,39 @@ export function resolveBundleSizeEnvOverrides(env) {
|
|
|
117
117
|
return { acknowledged, overrides };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Pure helper: resolve the one-shot maintainability refresh/acknowledge flag
|
|
122
|
+
* (Story #4731). This is the env-parity sibling of
|
|
123
|
+
* `resolveBundleSizeEnvOverrides`: `MAINTAINABILITY_REFRESH=1` (or `true`,
|
|
124
|
+
* case-insensitive) tells `check-baselines --gate maintainability` to demote
|
|
125
|
+
* this run's head-vs-base maintainability regressions to `unchanged` for this
|
|
126
|
+
* invocation only. Floors still apply — an acknowledged run can still fail on
|
|
127
|
+
* an absolute floor breach (e.g. a row below `min` 70); only the
|
|
128
|
+
* ratchet-vs-base regression comparison is suspended.
|
|
129
|
+
*
|
|
130
|
+
* Unlike bundle-size, maintainability also has a **commit-tagged** trigger
|
|
131
|
+
* (a `baseline-refresh:`-tagged commit in the compared range that touches the
|
|
132
|
+
* maintainability baseline file) resolved in the evaluate phase — this env
|
|
133
|
+
* flag is the manual override the two share by shape. Neither is persisted:
|
|
134
|
+
* the next run without the flag / tag re-enforces the ratchet at full
|
|
135
|
+
* strength automatically.
|
|
136
|
+
*
|
|
137
|
+
* Accepted truthy values: `1`, `true` (case-insensitive). Anything else
|
|
138
|
+
* (including unset/empty) resolves to `acknowledged: false`.
|
|
139
|
+
*
|
|
140
|
+
* @param {NodeJS.ProcessEnv} env
|
|
141
|
+
* @returns {{ acknowledged: boolean, overrides: string[] }}
|
|
142
|
+
*/
|
|
143
|
+
export function resolveMaintainabilityRefreshOverrides(env) {
|
|
144
|
+
const raw = env?.MAINTAINABILITY_REFRESH;
|
|
145
|
+
const acknowledged =
|
|
146
|
+
typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
|
|
147
|
+
const overrides = acknowledged
|
|
148
|
+
? [`acknowledged=true (MAINTAINABILITY_REFRESH=${raw})`]
|
|
149
|
+
: [];
|
|
150
|
+
return { acknowledged, overrides };
|
|
151
|
+
}
|
|
152
|
+
|
|
120
153
|
/**
|
|
121
154
|
* Pure helper: resolve the effective MI tolerance by layering precedence:
|
|
122
155
|
* 1. `CRAP_TOLERANCE` env-var (CI override — the baseline-refresh-
|
|
Binary file
|
|
@@ -263,6 +263,11 @@ export async function runCrapPreview({
|
|
|
263
263
|
const requireCoverage = crap.requireCoverage !== false;
|
|
264
264
|
const coveragePath = crap.coveragePath ?? 'coverage/coverage-final.json';
|
|
265
265
|
const coverage = loadCoverage(path.resolve(cwd, coveragePath));
|
|
266
|
+
// Story #4731 (AC-3) — feed the CRAP regression compare the *configured*
|
|
267
|
+
// crap tolerance (env override → `gates.crap.tolerance` → framework default)
|
|
268
|
+
// so `compareCrap` demotes positive deltas at or under tolerance rather than
|
|
269
|
+
// failing on any positive delta; over-tolerance deltas still fail. This keeps
|
|
270
|
+
// the pre-commit/pre-push preview aligned with the authoritative gate.
|
|
266
271
|
const { newMethodCeiling, tolerance } = resolveCrapEnvOverrides(
|
|
267
272
|
crap,
|
|
268
273
|
process.env,
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/* node:coverage ignore file -- AJV schema declaration (data-as-code) */
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
GATE_BASE,
|
|
5
|
+
LIST_OR_EXTENDER_OF_STRINGS,
|
|
6
|
+
SAFE_STRING,
|
|
7
|
+
} from './shared.js';
|
|
4
8
|
|
|
5
9
|
export const MAINTAINABILITY_GATE = {
|
|
6
10
|
type: 'object',
|
|
7
11
|
properties: {
|
|
8
12
|
...GATE_BASE,
|
|
9
13
|
targetDirs: LIST_OR_EXTENDER_OF_STRINGS,
|
|
14
|
+
// Story #4731 — commit-subject substring that acknowledges a deliberate
|
|
15
|
+
// maintainability baseline refresh in the compared range. Mirrors the CRAP
|
|
16
|
+
// gate's `refreshTag`; a range commit carrying it that touches the baseline
|
|
17
|
+
// file demotes head-vs-base regressions (floors still enforced).
|
|
18
|
+
refreshTag: { ...SAFE_STRING, minLength: 1 },
|
|
10
19
|
// Story #2165 — bounded timeout for `npm run maintainability:update`
|
|
11
20
|
// spawned by the baseline-attribution refresh path. Mirrors
|
|
12
21
|
// `coverage.timeoutMs` (Story #2142).
|
|
@@ -110,6 +110,13 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
|
|
|
110
110
|
tolerance: DEFAULT_MI_TOLERANCE,
|
|
111
111
|
floors: DEFAULT_MI_FLOORS,
|
|
112
112
|
targetDirs: Object.freeze([]),
|
|
113
|
+
// Story #4731 — the commit-subject substring that acknowledges a deliberate
|
|
114
|
+
// maintainability baseline refresh in the compared range. Mirrors the CRAP
|
|
115
|
+
// gate's `refreshTag`; the evaluate phase demotes head-vs-base regressions
|
|
116
|
+
// (floors still enforced) when a range commit carrying this tag touches the
|
|
117
|
+
// baseline file. Kept identical to the CRAP default so one refresh commit
|
|
118
|
+
// can acknowledge both gates.
|
|
119
|
+
refreshTag: 'baseline-refresh:',
|
|
113
120
|
// Story #2165 — bounded timeout (ms) for `npm run maintainability:update`
|
|
114
121
|
// spawned by the baseline-attribution refresh path. Defaults to 60 s.
|
|
115
122
|
refreshTimeoutMs: 60_000,
|
|
@@ -154,6 +161,7 @@ const MI_GATE_KEYS = new Set([
|
|
|
154
161
|
'tolerance',
|
|
155
162
|
'floors',
|
|
156
163
|
'targetDirs',
|
|
164
|
+
'refreshTag',
|
|
157
165
|
'refreshTimeoutMs',
|
|
158
166
|
'ignoreGlobs',
|
|
159
167
|
]);
|
|
@@ -268,6 +276,7 @@ function resolveMaintainabilityQuality(userBlock, gateScoping) {
|
|
|
268
276
|
if (userBlock == null || typeof userBlock !== 'object') {
|
|
269
277
|
return {
|
|
270
278
|
targetDirs: [...defaults.targetDirs],
|
|
279
|
+
refreshTag: defaults.refreshTag,
|
|
271
280
|
refreshTimeoutMs: defaults.refreshTimeoutMs,
|
|
272
281
|
ignoreGlobs: [...defaults.ignoreGlobs],
|
|
273
282
|
defaultScope: scoping.defaultScope,
|
|
@@ -277,6 +286,10 @@ function resolveMaintainabilityQuality(userBlock, gateScoping) {
|
|
|
277
286
|
warnUnknownKeys(userBlock, MI_GATE_KEYS, 'quality.gates.maintainability');
|
|
278
287
|
const out = {
|
|
279
288
|
targetDirs: resolveListValue(defaults.targetDirs, userBlock.targetDirs),
|
|
289
|
+
refreshTag:
|
|
290
|
+
typeof userBlock.refreshTag === 'string' && userBlock.refreshTag.length
|
|
291
|
+
? userBlock.refreshTag
|
|
292
|
+
: defaults.refreshTag,
|
|
280
293
|
refreshTimeoutMs: resolvePositiveIntegerMs(
|
|
281
294
|
userBlock.refreshTimeoutMs,
|
|
282
295
|
defaults.refreshTimeoutMs,
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
54
|
import { execFileSync } from 'node:child_process';
|
|
55
|
+
import { mkdtempSync } from 'node:fs';
|
|
56
|
+
import os from 'node:os';
|
|
55
57
|
import path from 'node:path';
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -114,6 +116,81 @@ export function _clearMainCheckoutRootCache() {
|
|
|
114
116
|
_mainCheckoutRootCache.clear();
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Environment variable naming an absolute per-process scratch tempRoot that
|
|
121
|
+
* every stream writer must land in during a test run (Story #4696).
|
|
122
|
+
*
|
|
123
|
+
* The shared test bootstrap (`lib/test-env.js`) sets this to a fresh
|
|
124
|
+
* `os.tmpdir()` directory before spawning the test runner, so any test that
|
|
125
|
+
* reaches a writer (`signals-writer`, the lifecycle `LedgerWriter`, etc.)
|
|
126
|
+
* *without* explicitly injecting an absolute tempRoot still resolves under
|
|
127
|
+
* scratch instead of the repo's real `temp/` tree. This is the single
|
|
128
|
+
* injection seam: because every path helper funnels a relative root through
|
|
129
|
+
* `anchorTempRoot`, one redirect here covers all writers regardless of how
|
|
130
|
+
* each one resolved its root.
|
|
131
|
+
*/
|
|
132
|
+
export const TEST_TEMP_ROOT_ENV = 'MANDREL_TEST_TEMP_ROOT';
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the absolute scratch tempRoot override, or `null` when none is
|
|
136
|
+
* configured. Only an **absolute** value is honoured — a relative override
|
|
137
|
+
* would re-anchor against the repo tree and defeat the isolation, so it is
|
|
138
|
+
* ignored (treated as unset). Module-internal: the behaviour is exercised
|
|
139
|
+
* through `anchorTempRoot`, so it is deliberately not exported (keeps the
|
|
140
|
+
* public seam to `anchorTempRoot` + `TEST_TEMP_ROOT_ENV`).
|
|
141
|
+
*
|
|
142
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
143
|
+
* @returns {string|null}
|
|
144
|
+
*/
|
|
145
|
+
function testScratchTempRoot(env = process.env) {
|
|
146
|
+
const override = env?.[TEST_TEMP_ROOT_ENV];
|
|
147
|
+
return typeof override === 'string' &&
|
|
148
|
+
override.length > 0 &&
|
|
149
|
+
path.isAbsolute(override)
|
|
150
|
+
? override
|
|
151
|
+
: null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Environment variable that opts a test-context process back into the real
|
|
156
|
+
* `temp/` tree (Story #4711). The `anchorTempRoot` test-context fallback
|
|
157
|
+
* refuses to anchor a relative root into the repo's telemetry tree when the
|
|
158
|
+
* process is a node:test context; a test that genuinely needs the real tree
|
|
159
|
+
* sets this to `'1'` on its own spawn — an explicit, greppable opt-out
|
|
160
|
+
* instead of a silent bypass.
|
|
161
|
+
*/
|
|
162
|
+
export const TEST_ALLOW_REAL_TEMP_ENV = 'MANDREL_TEST_ALLOW_REAL_TEMP';
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Per-process memo for the lazily-created test-context scratch dir, so every
|
|
166
|
+
* relative-root resolution in one test process converges on a single scratch
|
|
167
|
+
* tree (mirrors the `_mainCheckoutRootCache` pattern above).
|
|
168
|
+
*/
|
|
169
|
+
let _testContextScratchDir = null;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Test-only: clear the test-context scratch memo so a suite can exercise the
|
|
173
|
+
* lazy-arming branch repeatedly in one process.
|
|
174
|
+
*/
|
|
175
|
+
export function _clearTestContextScratchCache() {
|
|
176
|
+
_testContextScratchDir = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Is this process a node:test context? True when the env carries
|
|
181
|
+
* `NODE_TEST_CONTEXT` (set by the node:test runner on every spawned test
|
|
182
|
+
* child, regardless of how the runner itself was launched) or when the
|
|
183
|
+
* process was started with the `--test` flag (a direct `node --test <file>`
|
|
184
|
+
* runner process, or in-process isolation modes).
|
|
185
|
+
*
|
|
186
|
+
* @param {NodeJS.ProcessEnv} env
|
|
187
|
+
* @param {string[]} execArgv
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function inNodeTestContext(env, execArgv) {
|
|
191
|
+
return Boolean(env?.NODE_TEST_CONTEXT) || execArgv.includes('--test');
|
|
192
|
+
}
|
|
193
|
+
|
|
117
194
|
/**
|
|
118
195
|
* Anchor a resolved `tempRoot` to the main checkout root when it is a
|
|
119
196
|
* relative path (Story #3900). Absolute roots are returned verbatim; a
|
|
@@ -123,11 +200,54 @@ export function _clearMainCheckoutRootCache() {
|
|
|
123
200
|
* root is returned unchanged so behaviour degrades to the prior
|
|
124
201
|
* cwd-relative semantics rather than throwing.
|
|
125
202
|
*
|
|
203
|
+
* Test isolation (Story #4696): when the scratch override
|
|
204
|
+
* (`MANDREL_TEST_TEMP_ROOT`) is set, a relative root is joined onto the
|
|
205
|
+
* scratch dir instead of the main checkout root, so a writer that reaches
|
|
206
|
+
* the default (or any relative) root under the test bootstrap lands in
|
|
207
|
+
* scratch and never pollutes the repo's real `temp/` telemetry tree. An
|
|
208
|
+
* absolute root injected by a well-behaved test still bypasses the redirect
|
|
209
|
+
* verbatim.
|
|
210
|
+
*
|
|
211
|
+
* Process-level arming (Story #4711): the wrapper-armed override above only
|
|
212
|
+
* covers processes spawned by `run-tests.js` — a direct `node --test <file>`
|
|
213
|
+
* run used to bypass it and append fixture records to the real tree. When no
|
|
214
|
+
* override is armed but the process *is* a node:test context (see
|
|
215
|
+
* `inNodeTestContext`), a per-process scratch dir is created lazily and the
|
|
216
|
+
* relative root anchors there instead. The scratch dir is memoized and — for
|
|
217
|
+
* the real `process.env` — written back to `MANDREL_TEST_TEMP_ROOT` so child
|
|
218
|
+
* processes the test spawns inherit the same scratch tree. Escape hatch: a
|
|
219
|
+
* test that genuinely needs the real tree sets
|
|
220
|
+
* `MANDREL_TEST_ALLOW_REAL_TEMP=1` (`TEST_ALLOW_REAL_TEMP_ENV`) to restore
|
|
221
|
+
* main-checkout anchoring; the `check-test-temp-hygiene` guard remains the
|
|
222
|
+
* backstop either way.
|
|
223
|
+
*
|
|
126
224
|
* @param {string} tempRoot
|
|
225
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
226
|
+
* @param {{ mkdtemp?: typeof mkdtempSync, execArgv?: string[] }} [deps]
|
|
227
|
+
* Injectable for tests.
|
|
127
228
|
* @returns {string}
|
|
128
229
|
*/
|
|
129
|
-
export function anchorTempRoot(tempRoot) {
|
|
230
|
+
export function anchorTempRoot(tempRoot, env = process.env, deps = {}) {
|
|
130
231
|
if (path.isAbsolute(tempRoot)) return tempRoot;
|
|
232
|
+
const scratch = testScratchTempRoot(env);
|
|
233
|
+
if (scratch) return path.join(scratch, tempRoot);
|
|
234
|
+
const execArgv = deps.execArgv ?? process.execArgv;
|
|
235
|
+
if (
|
|
236
|
+
inNodeTestContext(env, execArgv) &&
|
|
237
|
+
env?.[TEST_ALLOW_REAL_TEMP_ENV] !== '1'
|
|
238
|
+
) {
|
|
239
|
+
if (_testContextScratchDir === null) {
|
|
240
|
+
const mkdtemp = deps.mkdtemp ?? mkdtempSync;
|
|
241
|
+
_testContextScratchDir = mkdtemp(
|
|
242
|
+
path.join(os.tmpdir(), 'mandrel-test-temp-'),
|
|
243
|
+
);
|
|
244
|
+
if (env === process.env) {
|
|
245
|
+
// Children spawned by this test process inherit the same scratch.
|
|
246
|
+
process.env[TEST_TEMP_ROOT_ENV] = _testContextScratchDir;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return path.join(_testContextScratchDir, tempRoot);
|
|
250
|
+
}
|
|
131
251
|
const root = mainCheckoutRoot();
|
|
132
252
|
return root ? path.join(root, tempRoot) : tempRoot;
|
|
133
253
|
}
|
|
@@ -160,10 +160,19 @@ const SIGNALS_SCHEMA = {
|
|
|
160
160
|
* behind-the-base PR up to date before giving the branch up as unwinnable,
|
|
161
161
|
* rather than waiting out the budget behind a base it could have merged.
|
|
162
162
|
* All keys default in the consumer when omitted (30s / 300s / 3600s / 3).
|
|
163
|
+
*
|
|
164
|
+
* `mode` selects the close-time merge posture (Story #4698). Default `sync`
|
|
165
|
+
* keeps the in-close foreground wait unchanged. `async` caps the per-invocation
|
|
166
|
+
* wait to a short probe window (~60s: catches instant merges and instantly-red
|
|
167
|
+
* required checks) and then returns the resumable `pending` terminal, so a
|
|
168
|
+
* slow-CI consumer no longer burns ~5 minutes of the host tool slot on a merge
|
|
169
|
+
* that lands after the wait would have expired anyway — the worker launches the
|
|
170
|
+
* `pending` envelope's `nextCommand` in the background instead.
|
|
163
171
|
*/
|
|
164
172
|
const MERGE_WATCH_SCHEMA = {
|
|
165
173
|
type: 'object',
|
|
166
174
|
properties: {
|
|
175
|
+
mode: { type: 'string', enum: ['sync', 'async'] },
|
|
167
176
|
intervalSeconds: { type: 'integer', minimum: 1 },
|
|
168
177
|
maxWaitSeconds: { type: 'integer', minimum: 1 },
|
|
169
178
|
maxBudgetSeconds: { type: 'integer', minimum: 1 },
|
|
@@ -280,6 +289,26 @@ const ACCEPTANCE_EVAL_SCHEMA = {
|
|
|
280
289
|
additionalProperties: false,
|
|
281
290
|
};
|
|
282
291
|
|
|
292
|
+
/**
|
|
293
|
+
* `delivery.review` — close-scope review tuning (Story #4699). `lensDiffFloor`
|
|
294
|
+
* is the changed-line floor below which the Story-scope local-lens pass skips
|
|
295
|
+
* lens materialization for a diff with zero sensitive-path hits (default 40
|
|
296
|
+
* via `lib/audit-suite/lens-diff-floor.js`; `0` disables the skip). The
|
|
297
|
+
* maker-blind code-review pass and all hard gates are untouched by the floor.
|
|
298
|
+
*/
|
|
299
|
+
const REVIEW_SCHEMA = {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: {
|
|
302
|
+
lensDiffFloor: {
|
|
303
|
+
type: 'integer',
|
|
304
|
+
minimum: 0,
|
|
305
|
+
description:
|
|
306
|
+
'Changed-line floor for the close-scope lens walk (Story #4699). A diff strictly below this many changed lines (additions + deletions) with zero sensitive-path hits skips lens materialization and records the skip in the findings-yield ledger. Default 40; 0 disables the skip. Hard gates and the maker-blind code-review pass are unaffected.',
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
additionalProperties: false,
|
|
310
|
+
};
|
|
311
|
+
|
|
283
312
|
/**
|
|
284
313
|
* `delivery.feedbackLoop` — opt-out toggles consumed by the Epic finalize
|
|
285
314
|
* listener's auto-file graduators (`lib/feedback-loop/*-graduator.js`, read
|
|
@@ -335,6 +364,7 @@ export const DELIVERY_SCHEMA = {
|
|
|
335
364
|
quality: QUALITY_SCHEMA,
|
|
336
365
|
mergeWatch: MERGE_WATCH_SCHEMA,
|
|
337
366
|
codeReview: CODE_REVIEW_SCHEMA,
|
|
367
|
+
review: REVIEW_SCHEMA,
|
|
338
368
|
refactorStage: REFACTOR_STAGE_SCHEMA,
|
|
339
369
|
acceptanceEval: ACCEPTANCE_EVAL_SCHEMA,
|
|
340
370
|
feedbackLoop: FEEDBACK_LOOP_SCHEMA,
|
|
@@ -278,34 +278,30 @@ const PLANNING_SCHEMA = {
|
|
|
278
278
|
properties: {
|
|
279
279
|
riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
|
|
280
280
|
codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
|
|
281
|
-
// Story #
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
281
|
+
// Story #4722 (superseding #4683's word-count gate) — shape-derived
|
|
282
|
+
// ceremony-lite routing. Complexity routes on the objective shape of the
|
|
283
|
+
// authored work (changes[] count, acceptance count, creates-vs-refactors
|
|
284
|
+
// mix, sensitive-path classes), never on seed word count: `maxSeedWords`
|
|
285
|
+
// was removed in the hard cutover and is rejected as an additional
|
|
286
|
+
// property. The lite path never relaxes a non-negotiable (Story ticket,
|
|
287
|
+
// PR-to-main, repo gates, security baseline). Defaults live on
|
|
288
|
+
// DEFAULT_COMPLEXITY_GATE in `lib/orchestration/complexity-gate.js`;
|
|
289
|
+
// shape ceilings are the framework constants STORY_SHAPE_CEILINGS.
|
|
288
290
|
complexityGate: {
|
|
289
291
|
type: 'object',
|
|
290
292
|
description:
|
|
291
|
-
'
|
|
293
|
+
'Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.',
|
|
292
294
|
properties: {
|
|
293
295
|
enabled: {
|
|
294
296
|
type: 'boolean',
|
|
295
297
|
description:
|
|
296
|
-
'Master switch. When false,
|
|
297
|
-
},
|
|
298
|
-
maxSeedWords: {
|
|
299
|
-
type: 'integer',
|
|
300
|
-
minimum: 0,
|
|
301
|
-
description:
|
|
302
|
-
'Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 60.',
|
|
298
|
+
'Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true.',
|
|
303
299
|
},
|
|
304
300
|
maxArtifacts: {
|
|
305
301
|
type: 'integer',
|
|
306
302
|
minimum: 0,
|
|
307
303
|
description:
|
|
308
|
-
'Enumerated-artifact
|
|
304
|
+
'Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict — carries no routing authority. Default 1.',
|
|
309
305
|
},
|
|
310
306
|
},
|
|
311
307
|
additionalProperties: false,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* metrics-ledger.js — the narrow shared metrics-ledger boundary (Story #4712).
|
|
3
|
+
*
|
|
4
|
+
* Owns the one append-tail implementation for the plan-metrics ledger:
|
|
5
|
+
* path resolution, serialization, lazy directory creation, size-capped
|
|
6
|
+
* rotation, and the append itself. Every ledger writer — the plan-domain
|
|
7
|
+
* appenders in `../orchestration/plan-metrics.js` and the close-domain
|
|
8
|
+
* findings-yield entry point below — routes through
|
|
9
|
+
* {@link appendLedgerRecord}, so the open→append→rotate→close tail exists
|
|
10
|
+
* exactly once and cannot drift between call sites.
|
|
11
|
+
*
|
|
12
|
+
* The findings-yield entry point (Story #4699) lives here rather than in
|
|
13
|
+
* the plan-domain module so the story-close review spine
|
|
14
|
+
* (`../orchestration/story-close/phases/review-core.js`) depends on this
|
|
15
|
+
* narrow shared ledger module instead of plan-domain internals.
|
|
16
|
+
*
|
|
17
|
+
* Wire contract (unchanged by the #4712 re-home): record shapes, file
|
|
18
|
+
* locations (`temp/run-<id>/plan-metrics.json` / the standalone stream),
|
|
19
|
+
* and the rotation threshold are exactly what `plan-metrics.js` shipped —
|
|
20
|
+
* readers (`readPlanMetrics` / `summarizePlanMetrics`) stay plan-side and
|
|
21
|
+
* key kinded records on `kind`, never on absent fields.
|
|
22
|
+
*
|
|
23
|
+
* Robustness contract (mirrors `signals-writer.js`):
|
|
24
|
+
* - **No buffering.** Each append opens, writes one line, closes.
|
|
25
|
+
* - **Rotation.** When an append would push the ledger past
|
|
26
|
+
* `maxBytes`, the current file is renamed to `<name>.1` (replacing any
|
|
27
|
+
* prior rollover) and the append starts a fresh ledger.
|
|
28
|
+
* - **Best-effort at the entry points.** {@link appendLedgerRecord}
|
|
29
|
+
* itself throws on fs failure; each public appender catches, warns via
|
|
30
|
+
* `Logger`, and returns `false` so metric capture can never fail the
|
|
31
|
+
* wrapped plan phase or Story close.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs/promises';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
anchorTempRoot,
|
|
39
|
+
runArtifactPath,
|
|
40
|
+
tempRootFrom,
|
|
41
|
+
} from '../config/temp-paths.js';
|
|
42
|
+
import { Logger } from '../Logger.js';
|
|
43
|
+
|
|
44
|
+
export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
|
|
45
|
+
export const PLAN_METRICS_SCHEMA_VERSION = 1;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record kind for a per-close, per-lens findings-yield entry (Story #4699).
|
|
49
|
+
* One record per Story close whose review ran (or floor-skipped) at least
|
|
50
|
+
* one local lens:
|
|
51
|
+
*
|
|
52
|
+
* ```json
|
|
53
|
+
* { "v": 1, "kind": "findings-yield", "cli": "story-close-review",
|
|
54
|
+
* "storyId": 4699, "epicId": null,
|
|
55
|
+
* "lenses": [{ "lens": "audit-clean-code", "findings": 0,
|
|
56
|
+
* "skippedByFloor": false }],
|
|
57
|
+
* "diffFloor": { "skip": false, "reason": "at-or-above-floor",
|
|
58
|
+
* "floor": 40, "changedLineCount": 120 },
|
|
59
|
+
* "at": "..." }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* The ledger only records — no roster behavior changes ride on it. Its
|
|
63
|
+
* purpose is evidentiary: a lens that stays at zero findings across N
|
|
64
|
+
* closes becomes droppable on measurement instead of assumption.
|
|
65
|
+
*/
|
|
66
|
+
const PLAN_METRICS_KIND_FINDINGS_YIELD = 'findings-yield';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rotation threshold. At ~200 bytes per record this is ~5000 invocations —
|
|
70
|
+
* far beyond any real plan run, so rotation only fires on pathological
|
|
71
|
+
* accumulation.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_LEDGER_BYTES = 1024 * 1024;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the ledger path for an Epic (or the standalone stream when
|
|
77
|
+
* `epicId` is `null` — the `story-plan.js` / Epic-less healthcheck case).
|
|
78
|
+
*
|
|
79
|
+
* @param {number|null} epicId
|
|
80
|
+
* @param {object} [config] Resolved config (threads `project.paths.tempRoot`).
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function planMetricsPath(epicId, config) {
|
|
84
|
+
if (epicId === null || epicId === undefined) {
|
|
85
|
+
return path.join(
|
|
86
|
+
anchorTempRoot(tempRootFrom(config)),
|
|
87
|
+
'standalone',
|
|
88
|
+
PLAN_METRICS_BASENAME,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return runArtifactPath(epicId, PLAN_METRICS_BASENAME, config);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rotate the ledger when appending `incomingBytes` would exceed
|
|
96
|
+
* `maxBytes`. Single-generation rollover: `plan-metrics.json` →
|
|
97
|
+
* `plan-metrics.json.1` (any prior `.1` is replaced).
|
|
98
|
+
*
|
|
99
|
+
* @param {string} filePath
|
|
100
|
+
* @param {number} incomingBytes
|
|
101
|
+
* @param {number} maxBytes
|
|
102
|
+
* @returns {Promise<boolean>} true when a rotation happened.
|
|
103
|
+
*/
|
|
104
|
+
async function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
105
|
+
let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
size = (await fs.stat(filePath)).size;
|
|
108
|
+
} catch {
|
|
109
|
+
return false; // No existing ledger — nothing to rotate.
|
|
110
|
+
}
|
|
111
|
+
if (size + incomingBytes <= maxBytes) return false;
|
|
112
|
+
await fs.rename(filePath, `${filePath}.1`);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The single ledger append tail (Story #4712): serialize the record to one
|
|
118
|
+
* newline-terminated JSON line, create the ledger directory lazily, rotate
|
|
119
|
+
* when the byte cap would be exceeded, and append.
|
|
120
|
+
*
|
|
121
|
+
* Throws on any fs failure — the best-effort posture (warn + `false`,
|
|
122
|
+
* never throw) belongs to the public appenders that wrap this, because
|
|
123
|
+
* each labels its own failure mode.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} record Fully-built ledger record (already validated).
|
|
126
|
+
* @param {{
|
|
127
|
+
* epicId?: number|null,
|
|
128
|
+
* config?: object,
|
|
129
|
+
* maxBytes?: number,
|
|
130
|
+
* }} [opts] `maxBytes` is a test seam for the rotation threshold.
|
|
131
|
+
* @returns {Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
export async function appendLedgerRecord(record, opts = {}) {
|
|
134
|
+
const filePath = planMetricsPath(opts.epicId ?? null, opts.config);
|
|
135
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
136
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
137
|
+
await rotateIfNeeded(
|
|
138
|
+
filePath,
|
|
139
|
+
Buffer.byteLength(line),
|
|
140
|
+
opts.maxBytes ?? MAX_LEDGER_BYTES,
|
|
141
|
+
);
|
|
142
|
+
await fs.appendFile(filePath, line, 'utf8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Append one findings-yield record (Story #4699). Called once per Story
|
|
147
|
+
* close by the review spine when the local-lens pass matched (or
|
|
148
|
+
* floor-skipped) at least one lens. Best-effort: returns `false` (after a
|
|
149
|
+
* `Logger.warn`) instead of throwing on any failure, so a ledger failure
|
|
150
|
+
* can never fail the close.
|
|
151
|
+
*
|
|
152
|
+
* @param {{
|
|
153
|
+
* storyId: number,
|
|
154
|
+
* lenses: Array<{ lens: string, findings?: number, skippedByFloor?: boolean }>,
|
|
155
|
+
* cli?: string,
|
|
156
|
+
* epicId?: number|null,
|
|
157
|
+
* diffFloor?: object|null,
|
|
158
|
+
* }} entry
|
|
159
|
+
* @param {object} [config]
|
|
160
|
+
* @param {{ maxBytes?: number }} [opts] Test seam for the rotation threshold.
|
|
161
|
+
* @returns {Promise<boolean>} true when the line was written.
|
|
162
|
+
*/
|
|
163
|
+
export async function appendFindingsYield(entry, config, opts = {}) {
|
|
164
|
+
try {
|
|
165
|
+
if (!entry || typeof entry !== 'object') {
|
|
166
|
+
throw new TypeError('appendFindingsYield requires an entry object');
|
|
167
|
+
}
|
|
168
|
+
const storyId = Number(entry.storyId);
|
|
169
|
+
if (!Number.isInteger(storyId) || storyId <= 0) {
|
|
170
|
+
throw new TypeError(
|
|
171
|
+
'appendFindingsYield requires a positive integer entry.storyId',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(entry.lenses) || entry.lenses.length === 0) {
|
|
175
|
+
throw new TypeError(
|
|
176
|
+
'appendFindingsYield requires a non-empty entry.lenses array',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const epicId = entry.epicId ?? null;
|
|
180
|
+
const record = {
|
|
181
|
+
v: PLAN_METRICS_SCHEMA_VERSION,
|
|
182
|
+
kind: PLAN_METRICS_KIND_FINDINGS_YIELD,
|
|
183
|
+
cli:
|
|
184
|
+
typeof entry.cli === 'string' && entry.cli.length > 0
|
|
185
|
+
? entry.cli
|
|
186
|
+
: 'story-close-review',
|
|
187
|
+
storyId,
|
|
188
|
+
epicId,
|
|
189
|
+
lenses: entry.lenses
|
|
190
|
+
.filter((l) => l && typeof l.lens === 'string' && l.lens.length > 0)
|
|
191
|
+
.map((l) => ({
|
|
192
|
+
lens: l.lens,
|
|
193
|
+
findings:
|
|
194
|
+
typeof l.findings === 'number' && Number.isFinite(l.findings)
|
|
195
|
+
? l.findings
|
|
196
|
+
: 0,
|
|
197
|
+
skippedByFloor: l.skippedByFloor === true,
|
|
198
|
+
})),
|
|
199
|
+
diffFloor:
|
|
200
|
+
entry.diffFloor && typeof entry.diffFloor === 'object'
|
|
201
|
+
? entry.diffFloor
|
|
202
|
+
: null,
|
|
203
|
+
at: new Date().toISOString(),
|
|
204
|
+
};
|
|
205
|
+
await appendLedgerRecord(record, {
|
|
206
|
+
epicId,
|
|
207
|
+
config,
|
|
208
|
+
maxBytes: opts.maxBytes,
|
|
209
|
+
});
|
|
210
|
+
return true;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
Logger.warn(
|
|
213
|
+
`[plan-metrics] findings-yield append failed (non-fatal): ${err?.message ?? err}`,
|
|
214
|
+
);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -68,6 +68,13 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
|
|
|
68
68
|
CLOSE_FAILED: 'close-failed',
|
|
69
69
|
/** A bounded merge wait expired with the PR still in flight. */
|
|
70
70
|
MERGE_WAIT_EXHAUSTED: 'merge-wait-exhausted',
|
|
71
|
+
/**
|
|
72
|
+
* A review/lens tool failed to *execute* (binary missing, unparseable
|
|
73
|
+
* output, materialization crash) — an operational degradation, not a code
|
|
74
|
+
* finding. Story #4699 routes these here so findings severity tiers
|
|
75
|
+
* reflect code findings only.
|
|
76
|
+
*/
|
|
77
|
+
TOOL_DEGRADED: 'tool-degraded',
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
/** Cap on free-form reason text copied into a signal's `details`. */
|