mandrel 2.8.0 → 2.10.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 -1
- 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 +44 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/audit-to-stories.js +51 -0
- 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/audit-to-stories/dedupe-against-github.js +120 -55
- 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 +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
- 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 +60 -0
- 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 +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- 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/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- 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/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -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/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +79 -10
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -422
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -279
- package/docs/CHANGELOG.md +47 -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?$`);
|
|
@@ -36,6 +36,92 @@ import { toCanonicalFinding } from './finding-adapter.js';
|
|
|
36
36
|
* @property {string[]} matchedFingerprints — full sha1 list that triggered the match.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Render a short, operator-legible reason from a dedup-lookup failure. Pure —
|
|
41
|
+
* no imports, no I/O — so the module stays pure orchestration (Story #4678).
|
|
42
|
+
* @param {unknown} err
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function describeDegradeReason(err) {
|
|
46
|
+
const status = err?.status;
|
|
47
|
+
const message = err?.message ?? String(err);
|
|
48
|
+
if (status === 422 || /\b422\b/.test(message)) {
|
|
49
|
+
return 'search query rejected (HTTP 422)';
|
|
50
|
+
}
|
|
51
|
+
if (/rate limit/i.test(message)) {
|
|
52
|
+
return 'rate limit still exhausted after cooldown';
|
|
53
|
+
}
|
|
54
|
+
return `dedup lookup failed: ${message}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stable operator-facing label for a group in a degrade report.
|
|
59
|
+
* @param {object} group
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function groupLabel(group) {
|
|
63
|
+
return group?.groupKey ?? group?.title ?? '(unlabelled group)';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Route every finding in one group and fold the per-finding decisions up to a
|
|
68
|
+
* group action. Extracted so the top-level loop can wrap it in one try/catch:
|
|
69
|
+
* a search failure that survives the endpoint budget (an HTTP 422, or a rate
|
|
70
|
+
* limit still exhausted after the cooldown) throws out of here and is caught
|
|
71
|
+
* once per group rather than aborting the whole scan (Story #4678).
|
|
72
|
+
*
|
|
73
|
+
* @param {object} group
|
|
74
|
+
* @param {object} routing — `{ searchIssues, semanticPort, routeOptions }`.
|
|
75
|
+
* @returns {Promise<{ action: string, matchedIssues: Array, matchedFingerprints: string[] }>}
|
|
76
|
+
*/
|
|
77
|
+
async function classifyOneGroup(
|
|
78
|
+
group,
|
|
79
|
+
{ searchIssues, semanticPort, routeOptions },
|
|
80
|
+
) {
|
|
81
|
+
const findings = group.findings ?? [];
|
|
82
|
+
const matchedIssues = [];
|
|
83
|
+
const matchedFingerprints = [];
|
|
84
|
+
let sawOpen = false;
|
|
85
|
+
let sawClosed = false;
|
|
86
|
+
|
|
87
|
+
for (const finding of findings) {
|
|
88
|
+
const sha = finding?.fingerprint?.full;
|
|
89
|
+
if (typeof sha !== 'string' || sha.length !== 40) continue;
|
|
90
|
+
|
|
91
|
+
const canonical = toCanonicalFinding(finding);
|
|
92
|
+
const { decision, matchedIssue, fingerprint } = await routeFinding(
|
|
93
|
+
canonical,
|
|
94
|
+
semanticPort
|
|
95
|
+
? { searchIssues, searchCandidates: () => semanticPort(canonical) }
|
|
96
|
+
: { searchIssues },
|
|
97
|
+
routeOptions,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (decision === 'new') continue;
|
|
101
|
+
|
|
102
|
+
if (matchedIssue) {
|
|
103
|
+
matchedIssues.push({
|
|
104
|
+
number: matchedIssue.number,
|
|
105
|
+
state: matchedIssue.state,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!matchedFingerprints.includes(fingerprint)) {
|
|
109
|
+
matchedFingerprints.push(fingerprint);
|
|
110
|
+
}
|
|
111
|
+
if (decision === 'update-existing' || decision === 'duplicate') {
|
|
112
|
+
sawOpen = true;
|
|
113
|
+
} else if (decision === 'regression-of-closed') {
|
|
114
|
+
sawClosed = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let action = 'create';
|
|
119
|
+
if (sawOpen) action = 'skip-open';
|
|
120
|
+
else if (sawClosed) action = 'skip-reoccurring';
|
|
121
|
+
|
|
122
|
+
return { action, matchedIssues, matchedFingerprints };
|
|
123
|
+
}
|
|
124
|
+
|
|
39
125
|
/**
|
|
40
126
|
* @param {object} params
|
|
41
127
|
* @param {Array<object>} params.groups — output of `groupFindings`.
|
|
@@ -44,12 +130,18 @@ import { toCanonicalFinding } from './finding-adapter.js';
|
|
|
44
130
|
* Optional meaning-first candidate search (production: `semantic-issue-search.js`).
|
|
45
131
|
* When supplied, routing runs the Stage-1 semantic pass and opts into
|
|
46
132
|
* location-based semantic-key confirmation.
|
|
47
|
-
* @
|
|
133
|
+
* @param {(entry: { group: object, reason: string }) => void} [params.onDegraded]
|
|
134
|
+
* Optional sink notified once per group whose dedup lookup could not complete
|
|
135
|
+
* (Story #4678). The group is then classified `create` — a soft-fail, never
|
|
136
|
+
* fatal. Pure orchestration: this module performs no network I/O and swallows
|
|
137
|
+
* no failure silently.
|
|
138
|
+
* @returns {Promise<{ classifications: GroupClassification[], summary: { create: number, skipOpen: number, skipReoccurring: number, dedupDegraded: { count: number, groups: Array<{ group: string, reason: string }> } } }>}
|
|
48
139
|
*/
|
|
49
140
|
export async function classifyGroupsAgainstGitHub({
|
|
50
141
|
groups,
|
|
51
142
|
provider,
|
|
52
143
|
searchCandidates,
|
|
144
|
+
onDegraded,
|
|
53
145
|
}) {
|
|
54
146
|
if (!Array.isArray(groups)) {
|
|
55
147
|
throw new Error('classifyGroupsAgainstGitHub: groups must be an array');
|
|
@@ -67,67 +159,40 @@ export async function classifyGroupsAgainstGitHub({
|
|
|
67
159
|
const searchIssues = (sha) => provider.findIssuesByFingerprint(sha);
|
|
68
160
|
const semanticPort =
|
|
69
161
|
typeof searchCandidates === 'function' ? searchCandidates : undefined;
|
|
70
|
-
const
|
|
162
|
+
const routing = {
|
|
163
|
+
searchIssues,
|
|
164
|
+
semanticPort,
|
|
165
|
+
routeOptions: { semanticKeyConfirm: Boolean(semanticPort) },
|
|
166
|
+
};
|
|
71
167
|
|
|
72
168
|
const classifications = [];
|
|
73
|
-
const summary = {
|
|
169
|
+
const summary = {
|
|
170
|
+
create: 0,
|
|
171
|
+
skipOpen: 0,
|
|
172
|
+
skipReoccurring: 0,
|
|
173
|
+
dedupDegraded: { count: 0, groups: [] },
|
|
174
|
+
};
|
|
74
175
|
|
|
75
176
|
for (const group of groups) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const { decision, matchedIssue, fingerprint } = await routeFinding(
|
|
89
|
-
canonical,
|
|
90
|
-
semanticPort
|
|
91
|
-
? { searchIssues, searchCandidates: () => semanticPort(canonical) }
|
|
92
|
-
: { searchIssues },
|
|
93
|
-
routeOptions,
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
if (decision === 'new') continue;
|
|
97
|
-
|
|
98
|
-
if (matchedIssue) {
|
|
99
|
-
matchedIssues.push({
|
|
100
|
-
number: matchedIssue.number,
|
|
101
|
-
state: matchedIssue.state,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
if (!matchedFingerprints.includes(fingerprint)) {
|
|
105
|
-
matchedFingerprints.push(fingerprint);
|
|
106
|
-
}
|
|
107
|
-
if (decision === 'update-existing' || decision === 'duplicate') {
|
|
108
|
-
sawOpen = true;
|
|
109
|
-
} else if (decision === 'regression-of-closed') {
|
|
110
|
-
sawClosed = true;
|
|
111
|
-
}
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
result = await classifyOneGroup(group, routing);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// A dedup lookup that cannot complete degrades this group to `create`
|
|
182
|
+
// with a recorded reason — never aborts the whole scan.
|
|
183
|
+
const reason = describeDegradeReason(err);
|
|
184
|
+
const entry = { group: groupLabel(group), reason };
|
|
185
|
+
summary.dedupDegraded.count += 1;
|
|
186
|
+
summary.dedupDegraded.groups.push(entry);
|
|
187
|
+
if (typeof onDegraded === 'function') onDegraded({ group, reason });
|
|
188
|
+
result = { action: 'create', matchedIssues: [], matchedFingerprints: [] };
|
|
112
189
|
}
|
|
113
190
|
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
summary.skipOpen += 1;
|
|
118
|
-
} else if (sawClosed) {
|
|
119
|
-
action = 'skip-reoccurring';
|
|
120
|
-
summary.skipReoccurring += 1;
|
|
121
|
-
} else {
|
|
122
|
-
summary.create += 1;
|
|
123
|
-
}
|
|
191
|
+
if (result.action === 'skip-open') summary.skipOpen += 1;
|
|
192
|
+
else if (result.action === 'skip-reoccurring') summary.skipReoccurring += 1;
|
|
193
|
+
else summary.create += 1;
|
|
124
194
|
|
|
125
|
-
classifications.push({
|
|
126
|
-
group,
|
|
127
|
-
action,
|
|
128
|
-
matchedIssues,
|
|
129
|
-
matchedFingerprints,
|
|
130
|
-
});
|
|
195
|
+
classifications.push({ group, ...result });
|
|
131
196
|
}
|
|
132
197
|
|
|
133
198
|
return { classifications, summary };
|
|
@@ -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,6 +278,38 @@ const PLANNING_SCHEMA = {
|
|
|
278
278
|
properties: {
|
|
279
279
|
riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
|
|
280
280
|
codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
|
|
281
|
+
// Story #4683 — plan-time ceremony-lite complexity gate. Routes a trivial
|
|
282
|
+
// single-artifact seed onto a collapsed plan/deliver path while a
|
|
283
|
+
// multi-capability seed keeps the full ceremony. Deterministic and
|
|
284
|
+
// conservative (full on any doubt); the lite path never relaxes a
|
|
285
|
+
// non-negotiable (Story ticket, PR-to-main, repo gates, security baseline).
|
|
286
|
+
// Defaults live on DEFAULT_COMPLEXITY_GATE in
|
|
287
|
+
// `lib/orchestration/complexity-gate.js`.
|
|
288
|
+
complexityGate: {
|
|
289
|
+
type: 'object',
|
|
290
|
+
description:
|
|
291
|
+
'Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.',
|
|
292
|
+
properties: {
|
|
293
|
+
enabled: {
|
|
294
|
+
type: 'boolean',
|
|
295
|
+
description:
|
|
296
|
+
'Master switch. When false, every seed takes the full plan/deliver ceremony. Default true.',
|
|
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 150.',
|
|
303
|
+
},
|
|
304
|
+
maxArtifacts: {
|
|
305
|
+
type: 'integer',
|
|
306
|
+
minimum: 0,
|
|
307
|
+
description:
|
|
308
|
+
'Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1.',
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
},
|
|
281
313
|
// Cross-Story conflict-finding severity gates. Off by default so
|
|
282
314
|
// existing repos keep advisory-only behaviour; flipping either to
|
|
283
315
|
// `true` upgrades the matching finding class to `'hard'`, which routes
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
const DEFAULT_LIMIT = 25;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Character budget for the query {@link buildQuery} emits. Default 200 leaves
|
|
28
|
+
* headroom under GitHub Search's 256-char limit for the `repo:` / `type:`
|
|
29
|
+
* qualifiers `searchIssues` appends (Story #4678).
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_QUERY_BUDGET = 200;
|
|
32
|
+
|
|
26
33
|
/**
|
|
27
34
|
* Normalise a free-text string for token comparison: lowercased, trimmed,
|
|
28
35
|
* punctuation collapsed to spaces.
|
|
@@ -68,18 +75,49 @@ function jaccard(a, b) {
|
|
|
68
75
|
return union === 0 ? 0 : intersection / union;
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The trailing path segment of a slash-or-backslash-delimited path. A deep
|
|
80
|
+
* `primaryFile` like `src/very/deep/module.js` contributes only `module.js`
|
|
81
|
+
* to the query — the basename carries the discriminating signal while the
|
|
82
|
+
* mangled path spends the character budget for nothing (Story #4678).
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function basename(value) {
|
|
87
|
+
const str = String(value ?? '');
|
|
88
|
+
const cut = str.split(/[\\/]/).filter(Boolean);
|
|
89
|
+
return cut.length > 0 ? cut[cut.length - 1] : '';
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
/**
|
|
72
93
|
* Build the search query text for a finding. The title carries the strongest
|
|
73
|
-
* signal; area and primaryFile sharpen it. This is the text
|
|
74
|
-
* (production) full-text search port and the local relevance scorer
|
|
94
|
+
* signal; area and the primaryFile **basename** sharpen it. This is the text
|
|
95
|
+
* both the (production) full-text search port and the local relevance scorer
|
|
96
|
+
* key on. The result is filled highest-signal-first (title, then area, then
|
|
97
|
+
* basename) up to `budget` characters on a whole-token boundary so a long title
|
|
98
|
+
* over a deep path never blows GitHub Search's length limit (Story #4678).
|
|
75
99
|
* @param {object} finding
|
|
100
|
+
* @param {object} [options]
|
|
101
|
+
* @param {number} [options.budget] — max query length in characters.
|
|
76
102
|
* @returns {string}
|
|
77
103
|
*/
|
|
78
|
-
export function buildQuery(finding) {
|
|
79
|
-
|
|
104
|
+
export function buildQuery(finding, { budget = DEFAULT_QUERY_BUDGET } = {}) {
|
|
105
|
+
const tokens = [finding?.title, finding?.area, basename(finding?.primaryFile)]
|
|
80
106
|
.map((v) => normaliseText(v))
|
|
81
107
|
.filter((v) => v.length > 0)
|
|
82
|
-
.join(' ')
|
|
108
|
+
.join(' ')
|
|
109
|
+
.split(' ')
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
const kept = [];
|
|
113
|
+
let length = 0;
|
|
114
|
+
for (const token of tokens) {
|
|
115
|
+
const cost = kept.length === 0 ? token.length : token.length + 1;
|
|
116
|
+
if (length + cost > budget) break;
|
|
117
|
+
kept.push(token);
|
|
118
|
+
length += cost;
|
|
119
|
+
}
|
|
120
|
+
return kept.join(' ');
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
/**
|