mandrel 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -9,16 +9,24 @@
|
|
|
9
9
|
* - DEFAULT_REFINE_THRESHOLD — seed length below which refinement
|
|
10
10
|
* is auto-suggested.
|
|
11
11
|
* - REQUIRED_SECTIONS — canonical section headings the body
|
|
12
|
-
* must carry
|
|
13
|
-
* /
|
|
12
|
+
* must carry itself. Story #4874: the
|
|
13
|
+
* `## Acceptance` / `## Verify` sections
|
|
14
|
+
* are deliberately NOT among them —
|
|
15
|
+
* those lists are the ticket's top-level
|
|
16
|
+
* machine contract and persist
|
|
17
|
+
* synthesizes their sections, exactly as
|
|
18
|
+
* the story-author prompt instructs.
|
|
14
19
|
* - rankDuplicateCandidates({ seed, openStories, maxResults })
|
|
15
20
|
* — Jaccard-overlap ranking of open
|
|
16
21
|
* Stories whose titles fuzzy-match
|
|
17
22
|
* the seed.
|
|
18
|
-
* - validateStoryBody(body)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* - validateStoryBody(body, contract)
|
|
24
|
+
* — schema-light shape check against the
|
|
25
|
+
* canonical Story-body contract: `##
|
|
26
|
+
* Goal` / `## Changes` present, no
|
|
27
|
+
* `Epic:` reference, and an
|
|
28
|
+
* acceptance/verify contract resolvable
|
|
29
|
+
* from the top-level arrays or the body.
|
|
22
30
|
* - buildContextEnvelope(opts) — assemble the context envelope the
|
|
23
31
|
* host LLM consumes.
|
|
24
32
|
*/
|
|
@@ -26,21 +34,25 @@
|
|
|
26
34
|
import { readFile } from 'node:fs/promises';
|
|
27
35
|
import path from 'node:path';
|
|
28
36
|
import { overlapScore, tokenize } from './duplicate-search.js';
|
|
37
|
+
import { parse as parseStoryBody } from './story-body/story-body.js';
|
|
29
38
|
|
|
30
39
|
export const DEFAULT_REFINE_THRESHOLD = 200;
|
|
31
40
|
export const DEFAULT_DUPLICATE_MAX_RESULTS = 5;
|
|
32
41
|
export const DEFAULT_DUPLICATE_MIN_SCORE = 0.15;
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The sections the authored body must carry itself. Story #4874 reconciled
|
|
45
|
+
* this list with the story-author prompt: the prompt tells the author to
|
|
46
|
+
* write `acceptance[]` / `verify[]` **once** at the ticket's top level and
|
|
47
|
+
* omit the matching body sections, so demanding those sections here made a
|
|
48
|
+
* prompt-faithful body unpersistable and cost a re-author round.
|
|
49
|
+
*/
|
|
50
|
+
export const REQUIRED_SECTIONS = ['Goal', 'Changes'];
|
|
51
|
+
|
|
52
|
+
/** The contract lists persist synthesizes into the body from the top level. */
|
|
53
|
+
const CONTRACT_FIELDS = /** @type {const} */ (['acceptance', 'verify']);
|
|
40
54
|
|
|
41
55
|
const EPIC_REF_PATTERN = /^\s*Epic:\s*#\d+/m;
|
|
42
|
-
const AC_HEADING_PATTERN = /^##\s+Acceptance Criteria\s*$/m;
|
|
43
|
-
const CHECKLIST_PATTERN = /^\s*-\s*\[\s?\]/m;
|
|
44
56
|
|
|
45
57
|
/**
|
|
46
58
|
* Rank open Stories by title-overlap with the seed. Reuses the same
|
|
@@ -115,54 +127,137 @@ export function shouldRefine({
|
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
130
|
+
* Collect the violations for one contract list (`acceptance` / `verify`).
|
|
131
|
+
*
|
|
132
|
+
* The list may be authored at the ticket's top level (the shape the
|
|
133
|
+
* story-author prompt prescribes) or written into the body as a section —
|
|
134
|
+
* either satisfies the contract, and the top level is preferred because
|
|
135
|
+
* persist synthesizes the section from it. Only two shapes are violations:
|
|
136
|
+
* the list is nowhere, or it is in both places and the two disagree (fail
|
|
137
|
+
* closed rather than guess which is authoritative, mirroring
|
|
138
|
+
* `plan-persist`'s `syncContractFieldFromTopLevel`).
|
|
139
|
+
*
|
|
140
|
+
* @param {'acceptance'|'verify'} field
|
|
141
|
+
* @param {unknown} bodyList The parsed body's section entries.
|
|
142
|
+
* @param {unknown} topLevel The ticket's top-level array.
|
|
143
|
+
* @returns {string[]}
|
|
144
|
+
*/
|
|
145
|
+
function collectContractErrors(field, bodyList, topLevel) {
|
|
146
|
+
const inBody = (Array.isArray(bodyList) ? bodyList : []).map(String);
|
|
147
|
+
const inTicket = (Array.isArray(topLevel) ? topLevel : []).map(String);
|
|
148
|
+
if (inBody.length === 0 && inTicket.length === 0) {
|
|
149
|
+
return [
|
|
150
|
+
`${field} must list at least one entry — author it as the ticket's top-level ${field}[] array; persist synthesizes the "## ${field[0].toUpperCase()}${field.slice(1)}" section from it`,
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
inBody.length > 0 &&
|
|
155
|
+
inTicket.length > 0 &&
|
|
156
|
+
(inBody.length !== inTicket.length ||
|
|
157
|
+
inBody.some((v, i) => v !== inTicket[i]))
|
|
158
|
+
) {
|
|
159
|
+
return [
|
|
160
|
+
`${field} disagrees between the body section and the top-level ${field}[] array — author it once, at the top level`,
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Schema-light validator for a standalone-Story body, stated against the
|
|
168
|
+
* same authoring shape the story-author prompt prescribes (Story #4874).
|
|
169
|
+
*
|
|
170
|
+
* It parses the body with the canonical Story-body parser and asserts:
|
|
171
|
+
* a non-empty `## Goal`, at least one `## Changes` entry, no leaking
|
|
172
|
+
* `Epic: #N` reference (the standalone contract), and an acceptance +
|
|
173
|
+
* verify contract resolvable from the ticket's top-level arrays **or** the
|
|
174
|
+
* body's own sections. It deliberately does not demand the `## Acceptance`
|
|
175
|
+
* / `## Verify` sections of the author — persist synthesizes them.
|
|
124
176
|
*
|
|
125
177
|
* @param {string} body
|
|
178
|
+
* @param {{ acceptance?: string[], verify?: string[] }} [contract]
|
|
179
|
+
* The ticket's top-level contract arrays, when the caller has them.
|
|
126
180
|
* @returns {{ ok:boolean, errors:string[] }}
|
|
127
181
|
*/
|
|
128
|
-
export function validateStoryBody(body) {
|
|
129
|
-
const errors = [];
|
|
182
|
+
export function validateStoryBody(body, contract = {}) {
|
|
130
183
|
if (typeof body !== 'string' || body.trim().length === 0) {
|
|
131
184
|
return { ok: false, errors: ['body is empty'] };
|
|
132
185
|
}
|
|
133
186
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = parseStoryBody(body).body;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
errors: [`body is not a parseable Story body: ${err.message}`],
|
|
194
|
+
};
|
|
139
195
|
}
|
|
140
196
|
|
|
197
|
+
const errors = [];
|
|
198
|
+
if (typeof parsed.goal !== 'string' || parsed.goal.trim() === '') {
|
|
199
|
+
errors.push('missing required section: "## Goal"');
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(parsed.changes) || parsed.changes.length === 0) {
|
|
202
|
+
errors.push('missing required section: "## Changes"');
|
|
203
|
+
}
|
|
141
204
|
if (EPIC_REF_PATTERN.test(body)) {
|
|
142
205
|
errors.push(
|
|
143
206
|
'body contains an "Epic: #N" reference — standalone Stories must not link to an Epic',
|
|
144
207
|
);
|
|
145
208
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (acStart !== -1) {
|
|
151
|
-
const rest = body.slice(acStart);
|
|
152
|
-
const nextHeading = rest.slice(1).search(/^##\s+/m);
|
|
153
|
-
const acBlock = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
|
|
154
|
-
if (!CHECKLIST_PATTERN.test(acBlock)) {
|
|
155
|
-
errors.push(
|
|
156
|
-
'Acceptance Criteria section has no unchecked checklist items (`- [ ] ...`)',
|
|
157
|
-
);
|
|
158
|
-
}
|
|
209
|
+
for (const field of CONTRACT_FIELDS) {
|
|
210
|
+
errors.push(
|
|
211
|
+
...collectContractErrors(field, parsed[field], contract?.[field]),
|
|
212
|
+
);
|
|
159
213
|
}
|
|
160
214
|
|
|
161
215
|
return { ok: errors.length === 0, errors };
|
|
162
216
|
}
|
|
163
217
|
|
|
164
|
-
|
|
165
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Render the canonical `## Acceptance` / `## Verify` sections into a body
|
|
220
|
+
* that omits them, from the ticket's top-level arrays (Story #4874).
|
|
221
|
+
*
|
|
222
|
+
* This is the synthesis the story-author prompt promises: the author writes
|
|
223
|
+
* each list once at top level, and the persisted GitHub issue still reads as
|
|
224
|
+
* a complete executable document. A body that already carries a section is
|
|
225
|
+
* returned untouched — `validateStoryBody` has already refused the case
|
|
226
|
+
* where the two disagree.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} body
|
|
229
|
+
* @param {{ acceptance?: string[], verify?: string[] }} [contract]
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
export function synthesizeContractSections(body, contract = {}) {
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = parseStoryBody(body).body;
|
|
236
|
+
} catch {
|
|
237
|
+
return body;
|
|
238
|
+
}
|
|
239
|
+
const blocks = [];
|
|
240
|
+
if (
|
|
241
|
+
(parsed.acceptance ?? []).length === 0 &&
|
|
242
|
+
Array.isArray(contract.acceptance) &&
|
|
243
|
+
contract.acceptance.length > 0
|
|
244
|
+
) {
|
|
245
|
+
const items = contract.acceptance
|
|
246
|
+
.map((a, i) => `- [ ] AC-${i + 1}: ${a}`)
|
|
247
|
+
.join('\n');
|
|
248
|
+
blocks.push(`## Acceptance\n${items}`);
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
(parsed.verify ?? []).length === 0 &&
|
|
252
|
+
Array.isArray(contract.verify) &&
|
|
253
|
+
contract.verify.length > 0
|
|
254
|
+
) {
|
|
255
|
+
blocks.push(
|
|
256
|
+
`## Verify\n${contract.verify.map((v) => `- ${v}`).join('\n')}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (blocks.length === 0) return body;
|
|
260
|
+
return `${body.trimEnd()}\n\n${blocks.join('\n\n')}\n`;
|
|
166
261
|
}
|
|
167
262
|
|
|
168
263
|
/**
|
|
@@ -113,9 +113,9 @@ You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown
|
|
|
113
113
|
|
|
114
114
|
The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
Never hand-mirror either list into the \`body\` — there is no step that asks you to. Persist fails closed on a body section that disagrees with its top-level array rather than guessing which list is authoritative, so writing one anyway only adds a way to be wrong. Do **not** invent a second criteria list inside \`## Spec\`, and do not author a separate Acceptance Spec / PRD artifact.
|
|
117
117
|
|
|
118
|
-
The
|
|
118
|
+
The **persisted** \`body\` renders these markdown sections (in order) — you author every one of them except \`## Acceptance\` / \`## Verify\`, which persist synthesizes from the top-level arrays:
|
|
119
119
|
|
|
120
120
|
## Goal
|
|
121
121
|
<one sentence — why this Story exists>
|
|
@@ -130,11 +130,11 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
130
130
|
- {"path": "<file path>", "assumption": "creates" | "refactors-existing" | "deletes"}
|
|
131
131
|
- ...
|
|
132
132
|
|
|
133
|
-
## Acceptance
|
|
133
|
+
## Acceptance <-- synthesized by persist from acceptance[]; do not author
|
|
134
134
|
- [ ] <testable, observable criterion>
|
|
135
135
|
- ...
|
|
136
136
|
|
|
137
|
-
## Verify
|
|
137
|
+
## Verify <-- synthesized by persist from verify[]; do not author
|
|
138
138
|
- <exact command or test path> (<tier>)
|
|
139
139
|
- ...
|
|
140
140
|
|
|
@@ -235,7 +235,7 @@ export function sliceTicketBodyForDelivery(body) {
|
|
|
235
235
|
// survive verbatim.
|
|
236
236
|
const techLoc = locate(working, 'techSpec');
|
|
237
237
|
let techRegion = null;
|
|
238
|
-
const PLACEHOLDER = '
|
|
238
|
+
const PLACEHOLDER = '\u0000MANDREL_TECH_SPEC_PLACEHOLDER\u0000';
|
|
239
239
|
if (techLoc) {
|
|
240
240
|
const { end } = descriptor('techSpec');
|
|
241
241
|
techRegion = working.slice(techLoc.startIdx, techLoc.endIdx + end.length);
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* `sleep` is exported as the cancellable delay primitive used internally;
|
|
14
14
|
* callers that already have their own cadence (e.g. a continuous ticker)
|
|
15
15
|
* can import it directly.
|
|
16
|
+
*
|
|
17
|
+
* `sleepFn` is the inter-tick delay seam (Story #4873). A caller that already
|
|
18
|
+
* owns a delay seam of its own — the close-and-land merge wait, whose suite
|
|
19
|
+
* must never actually sleep out a 30s poll interval — passes it here so
|
|
20
|
+
* adopting this primitive does not cost it that seam. It is called as
|
|
21
|
+
* `sleepFn(intervalMs, signal)`; a one-argument stub simply ignores the
|
|
22
|
+
* signal, and the default is the cancellable {@link sleep} below.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
/**
|
|
@@ -23,11 +30,20 @@
|
|
|
23
30
|
* timeoutMs?: number,
|
|
24
31
|
* signal?: AbortSignal,
|
|
25
32
|
* logger?: { warn?: Function },
|
|
33
|
+
* sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
|
|
26
34
|
* }} opts
|
|
27
35
|
* @returns {Promise<any | undefined>}
|
|
28
36
|
*/
|
|
29
37
|
export async function pollUntil(opts) {
|
|
30
|
-
const {
|
|
38
|
+
const {
|
|
39
|
+
fn,
|
|
40
|
+
predicate,
|
|
41
|
+
intervalMs,
|
|
42
|
+
timeoutMs,
|
|
43
|
+
signal,
|
|
44
|
+
logger,
|
|
45
|
+
sleepFn = sleep,
|
|
46
|
+
} = opts;
|
|
31
47
|
if (typeof fn !== 'function') throw new TypeError('pollUntil: fn required');
|
|
32
48
|
if (typeof predicate !== 'function') {
|
|
33
49
|
throw new TypeError('pollUntil: predicate required');
|
|
@@ -52,7 +68,7 @@ export async function pollUntil(opts) {
|
|
|
52
68
|
if (deadline !== null && Date.now() >= deadline) {
|
|
53
69
|
throw new Error(`pollUntil: timed out after ${timeoutMs}ms`);
|
|
54
70
|
}
|
|
55
|
-
await
|
|
71
|
+
await sleepFn(intervalMs, signal);
|
|
56
72
|
}
|
|
57
73
|
return undefined;
|
|
58
74
|
}
|
|
@@ -246,7 +246,7 @@ export function createProbeContext({
|
|
|
246
246
|
* caller tracked in-flight itself; probe mode must put them back.
|
|
247
247
|
*
|
|
248
248
|
* @returns {Promise<{
|
|
249
|
-
* nodes: Array<{id: number, dependsOn: number[], files: string[], labels: string[]}>,
|
|
249
|
+
* nodes: Array<{id: number, dependsOn: number[], files: string[], body: string, labels: string[]}>,
|
|
250
250
|
* doneIds: Set<number>,
|
|
251
251
|
* inFlight: number,
|
|
252
252
|
* blockedIds: number[],
|
|
@@ -282,6 +282,11 @@ export async function probeLiveState({
|
|
|
282
282
|
});
|
|
283
283
|
|
|
284
284
|
const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
|
|
285
|
+
// Bodies ride along with the node so the co-dispatch guard can widen a
|
|
286
|
+
// declared footprint from the paths a Story's own text names (Story #4875).
|
|
287
|
+
// They are consumed in-process by `selectReadySet` and never serialized into
|
|
288
|
+
// the beat envelope, which stays a list of ids.
|
|
289
|
+
const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
|
|
285
290
|
const inFlightIds = deriveInFlightIds(stories, dispatched);
|
|
286
291
|
// A Story another operator's lease holds occupies a (global) dispatch slot
|
|
287
292
|
// just like an in-flight one: fold it into the in-flight set so it is both
|
|
@@ -292,6 +297,7 @@ export async function probeLiveState({
|
|
|
292
297
|
return {
|
|
293
298
|
nodes: envelope.dag.map((node) => ({
|
|
294
299
|
...node,
|
|
300
|
+
body: bodyById.get(node.id) ?? '',
|
|
295
301
|
labels: projectInFlightLabels(
|
|
296
302
|
labelsById.get(node.id) ?? [],
|
|
297
303
|
inFlightIds.has(node.id),
|
|
@@ -26,10 +26,14 @@
|
|
|
26
26
|
* (`agent::done` OR closed issue) so a Story closed manually through
|
|
27
27
|
* the GitHub UI is recognised as done.
|
|
28
28
|
* - `storiesOverlap(a, b)` — the file-overlap co-dispatch guard: true
|
|
29
|
-
* when two Stories'
|
|
30
|
-
*
|
|
29
|
+
* when two Stories' file footprints intersect. Two Stories that would
|
|
30
|
+
* touch the same file MUST NOT be dispatched onto parallel
|
|
31
31
|
* `story-<id>` branches in the same beat (they would race the same
|
|
32
|
-
* path and produce a merge conflict at close).
|
|
32
|
+
* path and produce a merge conflict at close). The comparison runs over
|
|
33
|
+
* the **widened** footprint (`storyWidenedFootprint`) — a declared
|
|
34
|
+
* `changes[]` is treated as a lower bound and widened from the paths the
|
|
35
|
+
* Story's own text names, because a guard that trusts a prediction cannot
|
|
36
|
+
* prevent the collision nobody predicted (Story #4875).
|
|
33
37
|
* - `selectReadySet({ stories, doneIds, inFlight, globalCap })` — the
|
|
34
38
|
* scheduler. Returns the deterministic, overlap-free set of ready
|
|
35
39
|
* Stories, capped at `globalCap − inFlight`.
|
|
@@ -168,6 +172,59 @@ function isGlobPath(path) {
|
|
|
168
172
|
return path.includes('*') || path.includes('?') || path.includes('{');
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Repo-relative file paths as they appear in Story prose: at least one `/`
|
|
177
|
+
* separator and a short file extension. Deliberately narrow — a token has to
|
|
178
|
+
* look like a real path before it can widen a footprint and withhold a Story.
|
|
179
|
+
*/
|
|
180
|
+
const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Scrape file paths a Story's **text** mentions but its `changes[]` never
|
|
184
|
+
* declared (Story #4875).
|
|
185
|
+
*
|
|
186
|
+
* The declared footprint is a planner's *prediction*, and it is systematically
|
|
187
|
+
* a lower bound: a Story's `## Spec` names the module it must also touch, its
|
|
188
|
+
* acceptance criteria name the caller that must be updated, and none of that
|
|
189
|
+
* reaches `changes[]`. The overlap guard exists to stop two Stories racing the
|
|
190
|
+
* same file, so trusting the declaration outright means the guard is blind to
|
|
191
|
+
* precisely the collisions nobody predicted.
|
|
192
|
+
*
|
|
193
|
+
* Evidence is only ever **added** — nothing here can shrink a declared
|
|
194
|
+
* footprint, so widening can withhold a Story for a beat but can never
|
|
195
|
+
* co-dispatch one the declared comparison would have caught.
|
|
196
|
+
*
|
|
197
|
+
* @param {StoryRecord} story
|
|
198
|
+
* @returns {Set<string>}
|
|
199
|
+
*/
|
|
200
|
+
function storyEvidencePaths(story) {
|
|
201
|
+
const out = new Set();
|
|
202
|
+
for (const field of [story?.title, story?.body, story?.spec]) {
|
|
203
|
+
if (typeof field !== 'string' || field === '') continue;
|
|
204
|
+
for (const match of field.matchAll(PROSE_PATH_RE)) {
|
|
205
|
+
const trimmed = match[0].trim();
|
|
206
|
+
if (trimmed) out.add(trimmed);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A Story's footprint **widened from observable evidence** — the set the
|
|
214
|
+
* co-dispatch guard actually compares (Story #4875).
|
|
215
|
+
*
|
|
216
|
+
* `declared ∪ scraped-from-prose`. See {@link storyEvidencePaths} for why the
|
|
217
|
+
* declaration is treated as a lower bound rather than the answer.
|
|
218
|
+
*
|
|
219
|
+
* @param {StoryRecord} story
|
|
220
|
+
* @returns {Set<string>}
|
|
221
|
+
*/
|
|
222
|
+
function storyWidenedFootprint(story) {
|
|
223
|
+
const out = storyFootprint(story);
|
|
224
|
+
for (const path of storyEvidencePaths(story)) out.add(path);
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
171
228
|
/**
|
|
172
229
|
* File-overlap co-dispatch guard. Returns `true` when two Stories' declared
|
|
173
230
|
* file footprints intersect — meaning they would race the same file if
|
|
@@ -175,12 +232,19 @@ function isGlobPath(path) {
|
|
|
175
232
|
* Stories that overlap MUST NOT both appear in one dispatch set; one is
|
|
176
233
|
* withheld until the other clears.
|
|
177
234
|
*
|
|
235
|
+
* Comparison runs over the **widened** footprint
|
|
236
|
+
* ({@link storyWidenedFootprint}), not the declaration: a declared `changes[]`
|
|
237
|
+
* is a lower bound on what a Story will touch, and two Stories whose real edits
|
|
238
|
+
* collide were being co-dispatched whenever the collision was not predicted
|
|
239
|
+
* (Story #4875).
|
|
240
|
+
*
|
|
178
241
|
* Two deliberate asymmetries:
|
|
179
242
|
*
|
|
180
|
-
* - **An empty footprint means "no known overlap"** → `false`. A Story
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* absence would serialize every
|
|
243
|
+
* - **An empty footprint means "no known overlap"** → `false`. A Story with
|
|
244
|
+
* no declared footprint and no path evidence in its text is never
|
|
245
|
+
* withheld. This is permissive by necessity: a genuinely unknown footprint
|
|
246
|
+
* carries no information, and withholding on absence would serialize every
|
|
247
|
+
* run.
|
|
184
248
|
* - **A glob footprint overlaps EVERYTHING** → `true` (Story #4539/#4540).
|
|
185
249
|
* Comparison is exact-string, so a Story declaring
|
|
186
250
|
* `.agents/scripts/lib/**` would not match another declaring
|
|
@@ -193,9 +257,9 @@ function isGlobPath(path) {
|
|
|
193
257
|
* @returns {boolean}
|
|
194
258
|
*/
|
|
195
259
|
export function storiesOverlap(a, b) {
|
|
196
|
-
const fa =
|
|
260
|
+
const fa = storyWidenedFootprint(a);
|
|
197
261
|
if (fa.size === 0) return false;
|
|
198
|
-
const fb =
|
|
262
|
+
const fb = storyWidenedFootprint(b);
|
|
199
263
|
if (fb.size === 0) return false;
|
|
200
264
|
for (const path of fa) {
|
|
201
265
|
if (isGlobPath(path)) return true;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Message contract — see lib/cpu-pool.js:
|
|
26
26
|
* IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
|
|
27
|
-
* coverageEntry: object | null } }
|
|
27
|
+
* coverageAvailable?: boolean, coverageEntry: object | null } }
|
|
28
28
|
* { exit: true }
|
|
29
29
|
* OUT : { ok: true, result: {
|
|
30
30
|
* relPath,
|
|
@@ -88,7 +88,7 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
88
88
|
},
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
const { abs, relPath, requireCoverage } = item;
|
|
91
|
+
const { abs, relPath, requireCoverage, coverageAvailable = true } = item;
|
|
92
92
|
const prepare = deps.prepare ?? prepareSourceForScoring;
|
|
93
93
|
const analyze = deps.analyze ?? analyzeOnce;
|
|
94
94
|
|
|
@@ -150,7 +150,10 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
|
|
153
|
-
finalizeMethodRows(rawCrapRows, {
|
|
153
|
+
finalizeMethodRows(rawCrapRows, {
|
|
154
|
+
requireCoverage,
|
|
155
|
+
coverageAvailable,
|
|
156
|
+
});
|
|
154
157
|
return reply({
|
|
155
158
|
miScore,
|
|
156
159
|
crapRows: rows,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Message contract — see lib/cpu-pool.js:
|
|
13
13
|
* IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
|
|
14
|
-
* coverageEntry: object | null } }
|
|
14
|
+
* coverageAvailable?: boolean, coverageEntry: object | null } }
|
|
15
15
|
* { exit: true }
|
|
16
16
|
* OUT : { ok: true, result: {
|
|
17
17
|
* relPath,
|
|
@@ -80,7 +80,7 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
|
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
-
const { abs, relPath, requireCoverage } = item;
|
|
83
|
+
const { abs, relPath, requireCoverage, coverageAvailable = true } = item;
|
|
84
84
|
|
|
85
85
|
// Coverage entry is pre-resolved on the host and attached to the item.
|
|
86
86
|
// `item.coverageEntry` may be explicitly `null` when the file has no
|
|
@@ -143,7 +143,10 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
|
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
const finalized = finalizeMethodRows(methodRows, {
|
|
146
|
+
const finalized = finalizeMethodRows(methodRows, {
|
|
147
|
+
requireCoverage,
|
|
148
|
+
coverageAvailable,
|
|
149
|
+
});
|
|
147
150
|
return {
|
|
148
151
|
kind: 'reply',
|
|
149
152
|
message: {
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* Message contract — see lib/cpu-pool.js:
|
|
8
8
|
* IN : { item: string } — absolute file path to score
|
|
9
9
|
* { exit: true } — drain & terminate
|
|
10
|
-
* OUT : { ok: true, result: { filePath, score
|
|
10
|
+
* OUT : { ok: true, result: { filePath, score, unscorable?, reason? } }
|
|
11
11
|
*
|
|
12
12
|
* `score` is `null` only when the file genuinely cannot be read (ENOENT
|
|
13
|
-
* or other I/O error).
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* or other I/O error).
|
|
14
|
+
*
|
|
15
|
+
* A file the kernel cannot analyse comes back as `unscorable: true` with the
|
|
16
|
+
* kernel's own `reason`, rather than as a bare `0`. The `0` is still carried in
|
|
17
|
+
* `score` for wire compatibility, but it is no longer the only signal — the
|
|
18
|
+
* point of the flag is that the caller can *report* the file instead of
|
|
19
|
+
* silently dropping it (see `maintainability-engine.js`'s `UNSCORABLE`).
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { parentPort } from 'node:worker_threads';
|
|
19
|
-
import {
|
|
23
|
+
import { scoreFile } from '../maintainability-engine.js';
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Pure handler for a single inbound worker message. Exported so unit
|
|
@@ -24,7 +28,7 @@ import { calculateForFile } from '../maintainability-engine.js';
|
|
|
24
28
|
* without spawning a real `Worker` thread.
|
|
25
29
|
*
|
|
26
30
|
* @param {unknown} msg
|
|
27
|
-
* @param {{ score?: (filePath: string) => number |
|
|
31
|
+
* @param {{ score?: (filePath: string) => { score: number, unscorable: boolean, reason: string|null } }} [deps]
|
|
28
32
|
* @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
|
|
29
33
|
*/
|
|
30
34
|
export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
|
|
@@ -40,12 +44,13 @@ export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
|
|
|
40
44
|
};
|
|
41
45
|
}
|
|
42
46
|
const filePath = msg.item;
|
|
43
|
-
const scoreFn = deps.score ??
|
|
47
|
+
const scoreFn = deps.score ?? scoreFile;
|
|
44
48
|
try {
|
|
45
|
-
|
|
49
|
+
// `scoreFn` returns `{ score, unscorable, reason }` — spread so the flag
|
|
50
|
+
// and its reason reach the pool caller intact.
|
|
46
51
|
return {
|
|
47
52
|
kind: 'reply',
|
|
48
|
-
message: { ok: true, result: { filePath,
|
|
53
|
+
message: { ok: true, result: { filePath, ...scoreFn(filePath) } },
|
|
49
54
|
};
|
|
50
55
|
} catch (err) {
|
|
51
56
|
// I/O or other unexpected error — surface as a per-item null score
|