mandrel 2.32.0 → 2.33.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/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +1 -0
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -12
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +57 -0
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +16 -0
- package/package.json +3 -3
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* structured contract.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import path from 'node:path';
|
|
25
26
|
import { AGENT_LABELS, RISK_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
26
27
|
import { serialize } from '../story-body/story-body.js';
|
|
27
28
|
import { definesAuditLabel } from './audit-label-taxonomy.js';
|
|
@@ -116,10 +117,7 @@ function acceptanceCriteriaFromGroup(group) {
|
|
|
116
117
|
|
|
117
118
|
/**
|
|
118
119
|
* Resolve the `edges[]` sequencing anchored on this group. Each edge whose
|
|
119
|
-
* `fromGroupKey` matches this group's key contributes its `toGroupKey`.
|
|
120
|
-
* keys are the only stable identifier available at emit time — issues are not
|
|
121
|
-
* numbered yet — so the relationship is preserved as machine-readable keys the
|
|
122
|
-
* operator can resolve.
|
|
120
|
+
* `fromGroupKey` matches this group's key contributes its `toGroupKey`.
|
|
123
121
|
*
|
|
124
122
|
* @param {object} group
|
|
125
123
|
* @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges
|
|
@@ -135,22 +133,38 @@ function sequencingDepsForGroup(group, edges) {
|
|
|
135
133
|
}
|
|
136
134
|
|
|
137
135
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* issue refs (`blocked by #123`), which do not exist before the issues are
|
|
141
|
-
* opened; rendering the group-key sequencing as its own informational section
|
|
142
|
-
* keeps the signal in the body (not discarded — Story #4270) and survives
|
|
143
|
-
* `parse()` / `serialize()` round-tripping (it is preamble/extended content,
|
|
144
|
-
* not a structured section). Returns the empty string when there is no
|
|
145
|
-
* sequencing to surface.
|
|
136
|
+
* Resolve this group's sequencing to canonical `#N` issue refs, or `[]` when
|
|
137
|
+
* the caller has no issue numbers yet (Story #5044).
|
|
146
138
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
139
|
+
* Group keys are the only identifier that exists at *emit* time — the issues
|
|
140
|
+
* are not numbered — which is why this used to render as a prose
|
|
141
|
+
* `## Sequencing` block that nothing could act on. Standalone audit Stories
|
|
142
|
+
* therefore hardcoded `depends_on: []`, and their only actual serializer was
|
|
143
|
+
* an accident: their shared provenance footers collided under the delivery
|
|
144
|
+
* footprint guard. Narrowing that scrape removes the accident, so the ordering
|
|
145
|
+
* has to become real in the same change.
|
|
146
|
+
*
|
|
147
|
+
* The resolution is the two-pass shape `plan-persist` already uses: create every
|
|
148
|
+
* issue first, then re-render each body with the now-known numbers and mirror
|
|
149
|
+
* the same edges as native `blocked_by` relations. An edge whose target was not
|
|
150
|
+
* created (deduped against an existing Issue, suppressed by the ledger) simply
|
|
151
|
+
* drops — a `blocked by #undefined` would be worse than an absent edge.
|
|
152
|
+
*
|
|
153
|
+
* A **plain object**, deliberately, not a `Map`: the same map is handed to
|
|
154
|
+
* `applyBlockedByDependencies`, which indexes it with property access, so a
|
|
155
|
+
* `Map` there would silently resolve every lookup to `undefined`, skip every
|
|
156
|
+
* edge, and report success having written nothing. One shape, both halves.
|
|
157
|
+
*
|
|
158
|
+
* @param {string[]} deps Group keys this group depends on.
|
|
159
|
+
* @param {Record<string, number>|null} issueByGroupKey
|
|
160
|
+
* @returns {string[]} `#N` refs, in `deps` order.
|
|
149
161
|
*/
|
|
150
|
-
function
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
162
|
+
function dependencyRefs(deps, issueByGroupKey) {
|
|
163
|
+
if (!issueByGroupKey) return [];
|
|
164
|
+
return deps
|
|
165
|
+
.map((key) => issueByGroupKey[key])
|
|
166
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
167
|
+
.map((n) => `#${n}`);
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
function agentPromptsSection(group) {
|
|
@@ -162,6 +176,21 @@ function agentPromptsSection(group) {
|
|
|
162
176
|
return blocks.join('\n\n') || '_(no copy-pasteable prompts captured)_';
|
|
163
177
|
}
|
|
164
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Link each source audit report **once**.
|
|
181
|
+
*
|
|
182
|
+
* This used to render `- [\`path\`](path)` — the same
|
|
183
|
+
* `temp/audits/audit-<lens>-results.md` in the link text and again in the URL,
|
|
184
|
+
* byte-identical across every Story of a same-lens sweep. That doubled a token
|
|
185
|
+
* the delivery footprint guard scraped as edit intent, so a lens's whole cohort
|
|
186
|
+
* serialized on a report none of them would ever write to (Story #5044). The
|
|
187
|
+
* guard now ignores markdown-link URLs and temp-root paths, but rendering the
|
|
188
|
+
* path twice was never useful to a reader either: the file name is the label,
|
|
189
|
+
* the path is the target.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} group
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
165
194
|
function contextLinksFromGroup(group) {
|
|
166
195
|
const reports = uniq(
|
|
167
196
|
(group.findings ?? [])
|
|
@@ -169,7 +198,11 @@ function contextLinksFromGroup(group) {
|
|
|
169
198
|
.filter((s) => typeof s === 'string'),
|
|
170
199
|
);
|
|
171
200
|
if (reports.length === 0) return '_(no source audit reports captured)_';
|
|
172
|
-
|
|
201
|
+
// `path.basename` rather than `split('/')`: on win32 it splits on both
|
|
202
|
+
// separators, so an absolute Windows path yields the file name instead of
|
|
203
|
+
// the whole path — which would render the path twice in one link and
|
|
204
|
+
// re-create the very duplication this function exists to remove.
|
|
205
|
+
return reports.map((r) => `- [${path.basename(r)}](${r})`).join('\n');
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
function labelsForGroup(group) {
|
|
@@ -226,20 +259,27 @@ function assertLabelsInTaxonomy(labels) {
|
|
|
226
259
|
* — the dependency `edges[]` emitted by `groupFindings`. Edges anchored on
|
|
227
260
|
* this group are carried through to `depends_on[]`; omit when no sequencing
|
|
228
261
|
* is known.
|
|
229
|
-
* @
|
|
262
|
+
* @param {Record<string, number>|null} [params.issueByGroupKey]
|
|
263
|
+
* — group key → opened issue number. Supplied on the **second** pass, once
|
|
264
|
+
* the issues exist, so this group's edges render as canonical
|
|
265
|
+
* `blocked by #N` footers (Story #5044). Omit on the first pass.
|
|
266
|
+
* @returns {{ title: string, body: string, labels: string[], groupKey: string, dependsOn: string[] }}
|
|
267
|
+
* `groupKey` and `dependsOn` are the caller's handle on the second pass:
|
|
268
|
+
* they name this Story and the groups it must follow, so the caller can map
|
|
269
|
+
* both onto issue numbers without re-deriving the grouping.
|
|
230
270
|
*/
|
|
231
|
-
export function buildStoryBody({ group, edges = [] }) {
|
|
271
|
+
export function buildStoryBody({ group, edges = [], issueByGroupKey = null }) {
|
|
232
272
|
if (!group || !Array.isArray(group.findings)) {
|
|
233
273
|
throw new Error('buildStoryBody: group with findings[] is required');
|
|
234
274
|
}
|
|
235
275
|
const title = group.title;
|
|
276
|
+
const dependsOn = sequencingDepsForGroup(group, edges);
|
|
236
277
|
|
|
237
278
|
// Build the canonical StoryBody object from the audit group data. The
|
|
238
279
|
// acceptance + verify arrays are populated so the body clears the
|
|
239
|
-
// inline-contract bar; changes[] carries the file footprint.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
// canonical depends_on footer.
|
|
280
|
+
// inline-contract bar; changes[] carries the file footprint. `depends_on`
|
|
281
|
+
// is empty on the first pass (the blockers have no issue numbers yet) and
|
|
282
|
+
// carries real `#N` refs on the second — see dependencyRefs.
|
|
243
283
|
const storyBody = {
|
|
244
284
|
goal: goalFromGroup(group),
|
|
245
285
|
changes: changesFromGroup(group),
|
|
@@ -248,20 +288,21 @@ export function buildStoryBody({ group, edges = [] }) {
|
|
|
248
288
|
references: [],
|
|
249
289
|
wide: null,
|
|
250
290
|
reason_to_exist: null,
|
|
251
|
-
depends_on:
|
|
291
|
+
depends_on: dependencyRefs(dependsOn, issueByGroupKey),
|
|
252
292
|
};
|
|
253
293
|
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
294
|
+
// The `---` / `blocked by #N` footer is the canonical serializer's own, so
|
|
295
|
+
// the body round-trips through `parse()` and `/deliver`'s resolver reads the
|
|
296
|
+
// ordering from the same place it reads every other Story's.
|
|
297
|
+
const canonicalSections = serialize(storyBody, {
|
|
298
|
+
includeFooter: storyBody.depends_on.length > 0,
|
|
299
|
+
});
|
|
257
300
|
|
|
258
|
-
// Append audit-specific extended sections (
|
|
259
|
-
//
|
|
260
|
-
// shape.
|
|
301
|
+
// Append audit-specific extended sections (agent prompts, context links,
|
|
302
|
+
// provenance footers) that are not part of the canonical shape.
|
|
261
303
|
const body = [
|
|
262
304
|
canonicalSections,
|
|
263
305
|
'',
|
|
264
|
-
...(sequencing ? [sequencing] : []),
|
|
265
306
|
'## Agent Prompts',
|
|
266
307
|
'',
|
|
267
308
|
agentPromptsSection(group),
|
|
@@ -276,5 +317,11 @@ export function buildStoryBody({ group, edges = [] }) {
|
|
|
276
317
|
renderSemanticKeyFooter(group.findings),
|
|
277
318
|
].join('\n');
|
|
278
319
|
|
|
279
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
title,
|
|
322
|
+
body,
|
|
323
|
+
labels: labelsForGroup(group),
|
|
324
|
+
groupKey: group.groupKey,
|
|
325
|
+
dependsOn,
|
|
326
|
+
};
|
|
280
327
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/wire-dependencies.js — turn a standalone audit cohort's
|
|
3
|
+
* detected group edges into declared ordering, once the issues exist.
|
|
4
|
+
*
|
|
5
|
+
* `groupFindings` detects `edges[]` between finding groups, but at emit time
|
|
6
|
+
* the groups have no issue numbers, so the ordering could only be rendered as
|
|
7
|
+
* prose. Every standalone audit Story therefore shipped with `depends_on: []`,
|
|
8
|
+
* and what actually kept a cohort from being co-dispatched onto colliding
|
|
9
|
+
* branches was an **accident**: siblings shared the sweep-wide audit provenance
|
|
10
|
+
* footers `plan-persist` stamps, and the delivery footprint guard scraped
|
|
11
|
+
* path-shaped tokens out of them. Story #5044 narrows that scrape, which is why
|
|
12
|
+
* this module lands with it — removing the accidental serializer without giving
|
|
13
|
+
* the cohort a real one would leave it less ordered than before.
|
|
14
|
+
*
|
|
15
|
+
* The shape is `plan-persist`'s two-pass crossing (`plan-persist/story-ops.js`),
|
|
16
|
+
* because it is the same problem: **create every issue first, then mirror the
|
|
17
|
+
* edges**. Both halves are written:
|
|
18
|
+
*
|
|
19
|
+
* 1. The **body footer** (`---` / `blocked by #N`) — canonical, parsed by
|
|
20
|
+
* `/deliver`'s resolver, and the fallback when the dependencies API is
|
|
21
|
+
* unavailable.
|
|
22
|
+
* 2. The **native `blocked_by` relation** — visible in the GitHub UI,
|
|
23
|
+
* readable without parsing markdown, and settable by an operator later.
|
|
24
|
+
*
|
|
25
|
+
* Native mirroring is **non-fatal by design**, matching `plan-persist`: the
|
|
26
|
+
* footer has already been written by the time it runs, so a dependencies API
|
|
27
|
+
* that says no costs visibility, not ordering.
|
|
28
|
+
*
|
|
29
|
+
* @module lib/audit-to-stories/wire-dependencies
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { applyBlockedByDependencies } from '../../providers/github/blocked-by-add.js';
|
|
33
|
+
import { Logger } from '../Logger.js';
|
|
34
|
+
import { buildStoryBody } from './build-story-body.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Re-render each created Story's body with its blockers resolved to `#N`, and
|
|
38
|
+
* mirror the same edges as native `blocked_by` relations.
|
|
39
|
+
*
|
|
40
|
+
* Groups whose issue was not created — deduped against an existing Issue,
|
|
41
|
+
* suppressed by the ledger, or simply not in `issueByGroupKey` — are skipped
|
|
42
|
+
* rather than guessed at, and an edge pointing at one drops with them
|
|
43
|
+
* (`dependencyRefs` filters it). A `blocked by #undefined` would gate a Story
|
|
44
|
+
* on nothing forever, which is strictly worse than the un-ordered cohort this
|
|
45
|
+
* replaces.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} args
|
|
48
|
+
* @param {Array<object>} args.groups The `create`-eligible groups, in
|
|
49
|
+
* the order their issues were opened.
|
|
50
|
+
* @param {Array<{ fromGroupKey: string, toGroupKey: string }>} [args.edges]
|
|
51
|
+
* @param {Record<string, number>} args.issueByGroupKey Group key → issue number.
|
|
52
|
+
* @param {(issueNumber: number, body: string) => Promise<unknown>} args.updateBody
|
|
53
|
+
* Persist a re-rendered body. Injected so the caller owns the provider call.
|
|
54
|
+
* @param {object|null} [args.provider] Provider for native edge mirroring.
|
|
55
|
+
* Omit (or pass one without the dependency ports) to write footers only.
|
|
56
|
+
* @returns {Promise<{
|
|
57
|
+
* storiesWired: number,
|
|
58
|
+
* bodiesUpdated: number,
|
|
59
|
+
* edgesDeclared: number,
|
|
60
|
+
* native: { edgesAdded: number, edgesSkipped: number, edgesFailed: number }|null
|
|
61
|
+
* }>}
|
|
62
|
+
*/
|
|
63
|
+
export async function wireAuditStoryEdges({
|
|
64
|
+
groups,
|
|
65
|
+
edges = [],
|
|
66
|
+
issueByGroupKey,
|
|
67
|
+
updateBody,
|
|
68
|
+
provider = null,
|
|
69
|
+
}) {
|
|
70
|
+
const wired = collectWiredStories({ groups, edges, issueByGroupKey });
|
|
71
|
+
if (wired.length === 0) {
|
|
72
|
+
return {
|
|
73
|
+
storiesWired: 0,
|
|
74
|
+
bodiesUpdated: 0,
|
|
75
|
+
edgesDeclared: 0,
|
|
76
|
+
native: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let bodiesUpdated = 0;
|
|
81
|
+
for (const story of wired) {
|
|
82
|
+
await updateBody(story.issueNumber, story.body);
|
|
83
|
+
bodiesUpdated++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
storiesWired: wired.length,
|
|
88
|
+
bodiesUpdated,
|
|
89
|
+
edgesDeclared: wired.reduce((n, s) => n + s.blockerKeys.length, 0),
|
|
90
|
+
native: await mirrorNativeEdges({ provider, wired, issueByGroupKey }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Re-render every group that both has an issue **and** has at least one blocker
|
|
96
|
+
* whose issue also exists.
|
|
97
|
+
*
|
|
98
|
+
* A group with no resolvable blocker is deliberately left alone rather than
|
|
99
|
+
* rewritten to an identical body: an issue-body update is a mutation and a
|
|
100
|
+
* notification, and doing it for a no-op edit is noise on every Story of every
|
|
101
|
+
* sweep.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} args
|
|
104
|
+
* @returns {Array<{ groupKey: string, issueNumber: number, body: string, blockerKeys: string[] }>}
|
|
105
|
+
*/
|
|
106
|
+
function collectWiredStories({ groups, edges, issueByGroupKey }) {
|
|
107
|
+
const wired = [];
|
|
108
|
+
for (const group of groups ?? []) {
|
|
109
|
+
const issueNumber = issueByGroupKey?.[group?.groupKey];
|
|
110
|
+
if (!Number.isInteger(issueNumber)) continue;
|
|
111
|
+
const rendered = buildStoryBody({ group, edges, issueByGroupKey });
|
|
112
|
+
const blockerKeys = rendered.dependsOn.filter((key) =>
|
|
113
|
+
Number.isInteger(issueByGroupKey[key]),
|
|
114
|
+
);
|
|
115
|
+
if (blockerKeys.length === 0) continue;
|
|
116
|
+
wired.push({
|
|
117
|
+
groupKey: rendered.groupKey,
|
|
118
|
+
issueNumber,
|
|
119
|
+
body: rendered.body,
|
|
120
|
+
blockerKeys,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return wired;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Mirror the declared edges as native GitHub `blocked_by` relations.
|
|
128
|
+
*
|
|
129
|
+
* Two shape hazards this crossing inherits from `plan-persist`, both silent if
|
|
130
|
+
* missed: `applyBlockedByDependencies` indexes `slugToIssueNumber` with plain
|
|
131
|
+
* property access (so it must be a plain object, never a `Map` — a `Map` yields
|
|
132
|
+
* `undefined` for every lookup, skips every edge, and reports success having
|
|
133
|
+
* written nothing), and it reads `dependsOn`, not `depends_on`.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} args
|
|
136
|
+
* @returns {Promise<{ edgesAdded: number, edgesSkipped: number, edgesFailed: number }|null>}
|
|
137
|
+
* `null` when there is no interface to mirror through.
|
|
138
|
+
*/
|
|
139
|
+
async function mirrorNativeEdges({ provider, wired, issueByGroupKey }) {
|
|
140
|
+
if (
|
|
141
|
+
typeof provider?.getDependencyWriteContext !== 'function' ||
|
|
142
|
+
typeof provider?.getTicket !== 'function'
|
|
143
|
+
) {
|
|
144
|
+
Logger.warn(
|
|
145
|
+
'[audit-to-stories] provider exposes no getDependencyWriteContext/getTicket — ' +
|
|
146
|
+
'skipping native blocked_by edges. Ordering survives in the ' +
|
|
147
|
+
'`blocked by #N` body footers just written.',
|
|
148
|
+
);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const { gh, owner, repo } = provider.getDependencyWriteContext();
|
|
153
|
+
const summary = await applyBlockedByDependencies({
|
|
154
|
+
// The group key IS the slug here — it is the stable identifier both
|
|
155
|
+
// sides of an edge are keyed by — so `issueByGroupKey` is already the
|
|
156
|
+
// slug→number map the helper wants.
|
|
157
|
+
stories: wired.map((s) => ({
|
|
158
|
+
slug: s.groupKey,
|
|
159
|
+
dependsOn: s.blockerKeys,
|
|
160
|
+
})),
|
|
161
|
+
slugToIssueNumber: issueByGroupKey,
|
|
162
|
+
getTicket: (issueNumber) => provider.getTicket(issueNumber),
|
|
163
|
+
owner,
|
|
164
|
+
repo,
|
|
165
|
+
gh,
|
|
166
|
+
});
|
|
167
|
+
if (summary.edgesFailed > 0) {
|
|
168
|
+
Logger.warn(
|
|
169
|
+
`[audit-to-stories] ${summary.edgesFailed} native blocked_by edge(s) could ` +
|
|
170
|
+
'not be written. Ordering survives in the `blocked by #N` body footers.',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
edgesAdded: summary.edgesAdded,
|
|
175
|
+
edgesSkipped: summary.edgesSkipped,
|
|
176
|
+
edgesFailed: summary.edgesFailed,
|
|
177
|
+
};
|
|
178
|
+
} catch (err) {
|
|
179
|
+
Logger.warn(
|
|
180
|
+
`[audit-to-stories] native blocked_by mirroring failed (${err.message}) — ` +
|
|
181
|
+
'ordering survives in the `blocked by #N` body footers.',
|
|
182
|
+
);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -30,9 +30,19 @@ export const DEFAULT_DECOMPOSER = Object.freeze({
|
|
|
30
30
|
* `verifyWaveResults` loop it claimed to bound never existed in the tree, and
|
|
31
31
|
* its only reader was the retired execution-analysis CLI, which echoed the
|
|
32
32
|
* number into a report rather than bounding anything.
|
|
33
|
+
*
|
|
34
|
+
* **Serialization tradeoff — `footprintGuard`.** `enforce` is the default and
|
|
35
|
+
* stays it: the file-overlap guard encodes delivery-time-only knowledge (which
|
|
36
|
+
* implementation windows are open, which Stories a foreign lease holds) that no
|
|
37
|
+
* plan-time `depends_on` edge can carry, so demoting it by default would trade
|
|
38
|
+
* a real merge-conflict class for throughput nobody asked for. `advisory`
|
|
39
|
+
* detects collisions and reports every would-be withhold but lets dispatch
|
|
40
|
+
* follow the declared edges alone — for runs whose ordering is fully declared
|
|
41
|
+
* (Story #5044).
|
|
33
42
|
*/
|
|
34
43
|
const DEFAULT_DELIVER_RUNNER = Object.freeze({
|
|
35
44
|
concurrencyCap: 3,
|
|
45
|
+
footprintGuard: 'enforce',
|
|
36
46
|
});
|
|
37
47
|
|
|
38
48
|
/**
|
|
@@ -51,28 +61,40 @@ export const DEFAULT_CODE_REVIEW = Object.freeze({
|
|
|
51
61
|
*
|
|
52
62
|
* @param {object | null | undefined} config
|
|
53
63
|
* @returns {{
|
|
54
|
-
* deliverRunner: { concurrencyCap: number },
|
|
64
|
+
* deliverRunner: { concurrencyCap: number, footprintGuard: 'enforce'|'advisory' },
|
|
55
65
|
* codeReview: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
|
|
56
66
|
* decomposer: { concurrencyCap: number },
|
|
57
67
|
* }}
|
|
58
68
|
*/
|
|
59
69
|
export function getRunners(config) {
|
|
60
|
-
const deliverRunnerUser = config?.delivery?.deliverRunner ?? {};
|
|
61
|
-
const codeReviewUser = config?.delivery?.codeReview ?? {};
|
|
62
70
|
return {
|
|
63
|
-
deliverRunner:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
codeReview: {
|
|
69
|
-
maxFixAttempts:
|
|
70
|
-
codeReviewUser.maxFixAttempts ?? DEFAULT_CODE_REVIEW.maxFixAttempts,
|
|
71
|
-
maxFixScopeFiles:
|
|
72
|
-
codeReviewUser.maxFixScopeFiles ?? DEFAULT_CODE_REVIEW.maxFixScopeFiles,
|
|
73
|
-
autoFixSeverity:
|
|
74
|
-
codeReviewUser.autoFixSeverity ?? DEFAULT_CODE_REVIEW.autoFixSeverity,
|
|
75
|
-
},
|
|
71
|
+
deliverRunner: withDefaults(
|
|
72
|
+
DEFAULT_DELIVER_RUNNER,
|
|
73
|
+
config?.delivery?.deliverRunner,
|
|
74
|
+
),
|
|
75
|
+
codeReview: withDefaults(DEFAULT_CODE_REVIEW, config?.delivery?.codeReview),
|
|
76
76
|
decomposer: DEFAULT_DECOMPOSER,
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Overlay an operator's block onto the framework defaults — the per-key `??`
|
|
82
|
+
* fallback these accessors have always applied, written once.
|
|
83
|
+
*
|
|
84
|
+
* Iterating the **defaults'** keys rather than the user's is what keeps the
|
|
85
|
+
* returned shape closed: a key the framework does not define cannot reach a
|
|
86
|
+
* consumer through here even if one somehow survived AJV, so a typo degrades to
|
|
87
|
+
* the default rather than to an undefined a caller would read as configuration.
|
|
88
|
+
*
|
|
89
|
+
* @template {Record<string, unknown>} T
|
|
90
|
+
* @param {T} defaults Frozen framework defaults.
|
|
91
|
+
* @param {object|null|undefined} user Operator block from `.agentrc`.
|
|
92
|
+
* @returns {T} A fresh object; the frozen defaults are never mutated.
|
|
93
|
+
*/
|
|
94
|
+
function withDefaults(defaults, user) {
|
|
95
|
+
const out = { ...defaults };
|
|
96
|
+
for (const key of Object.keys(defaults)) {
|
|
97
|
+
if (user?.[key] != null) out[key] = user[key];
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -73,10 +73,18 @@ const DELIVER_RUNNER_SCHEMA = {
|
|
|
73
73
|
minimum: 1,
|
|
74
74
|
description:
|
|
75
75
|
'Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff.',
|
|
76
|
-
// getRunners() resolves this
|
|
77
|
-
// constant; the
|
|
76
|
+
// getRunners() resolves this from its own DEFAULT_DELIVER_RUNNER
|
|
77
|
+
// constant, not from this annotation; the parity suite asserts the two
|
|
78
|
+
// agree.
|
|
78
79
|
default: 3,
|
|
79
80
|
},
|
|
81
|
+
footprintGuard: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
enum: ['enforce', 'advisory'],
|
|
84
|
+
description:
|
|
85
|
+
"How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md.",
|
|
86
|
+
default: 'enforce',
|
|
87
|
+
},
|
|
80
88
|
},
|
|
81
89
|
additionalProperties: false,
|
|
82
90
|
};
|
|
@@ -7,18 +7,31 @@
|
|
|
7
7
|
* lib/story-adjacency.js, lib/branch-name-guard.js).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { parseFooterBlockedByIds } from './story-body/footer-block.js';
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
|
-
* Parse
|
|
12
|
-
*
|
|
13
|
+
* Parse a body's declared blocker issue numbers — **footer-scoped and
|
|
14
|
+
* strict**.
|
|
15
|
+
*
|
|
16
|
+
* Only a `blocked by #N` line standing alone inside the `---` footer block
|
|
17
|
+
* declares an edge. The unanchored predecessor scanned the whole body for
|
|
18
|
+
* `blocked by|depends on #N` anywhere, so a Story whose prose merely mentioned
|
|
19
|
+
* a blocker — an example, a changelog note, an acceptance criterion describing
|
|
20
|
+
* this very defect — minted a real dispatch gate that withheld the Story until
|
|
21
|
+
* an unrelated issue closed.
|
|
22
|
+
*
|
|
23
|
+
* The behaviour change is deliberate and user-visible: prose-only mentions
|
|
24
|
+
* outside the footer no longer gate. Every machine-authored body already
|
|
25
|
+
* carries the canonical footer form (`plan-persist` has always serialized it),
|
|
26
|
+
* so only hand-written prose edges are affected — those must be moved into the
|
|
27
|
+
* footer block to keep gating. The grammar itself lives in
|
|
28
|
+
* `lib/story-body/footer-block.js`, shared with the body parser.
|
|
13
29
|
*
|
|
14
30
|
* @param {string} body - Issue body or freeform text.
|
|
15
|
-
* @returns {number[]} Array of issue numbers this
|
|
31
|
+
* @returns {number[]} Array of issue numbers this body declares as blockers.
|
|
16
32
|
*/
|
|
17
33
|
export function parseBlockedBy(body) {
|
|
18
|
-
|
|
19
|
-
const re = /(?:blocked\s+by|depends\s+on):?\s+#(\d+)/gi;
|
|
20
|
-
const ids = [...body.matchAll(re)].map((m) => Number.parseInt(m[1], 10));
|
|
21
|
-
return [...new Set(ids)];
|
|
34
|
+
return parseFooterBlockedByIds(body);
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
/**
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/findings/provenance-field.js — the per-Story `provenance` field.
|
|
3
|
+
*
|
|
4
|
+
* An audit-seeded plan carries dedup identities forward so the next sweep
|
|
5
|
+
* recognises what it already planned. The optional top-level `provenance`
|
|
6
|
+
* field on a `stories.json` entry says **which of them that Story owns**:
|
|
7
|
+
*
|
|
8
|
+
* ```jsonc
|
|
9
|
+
* { "fingerprints": ["<40-char sha1>"], "semanticKeys": ["architecture␟lib/a.js"] }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Two callers, deliberately split from
|
|
13
|
+
* [`route-finding.js`](route-finding.js): the ticket validator shape-checks
|
|
14
|
+
* the authored field, and plan-persist's assembly renders the owned identities
|
|
15
|
+
* into the footer source it stamps. Neither is dedup *routing*, which is what
|
|
16
|
+
* `route-finding.js` is for — this module reads its identity vocabulary
|
|
17
|
+
* (`SHA1_RE`, `SEMANTIC_KEY_RE`, and the two footer renderers) from there so
|
|
18
|
+
* there is exactly one definition of what a fingerprint or a semantic key
|
|
19
|
+
* looks like.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/findings/provenance-field
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
fingerprintFooter,
|
|
26
|
+
SEMANTIC_KEY_RE,
|
|
27
|
+
SHA1_RE,
|
|
28
|
+
semanticKeyFooter,
|
|
29
|
+
} from './route-finding.js';
|
|
30
|
+
|
|
31
|
+
/** Human-readable rendering of the `provenance` field's two lists. */
|
|
32
|
+
const PROVENANCE_SHAPE = 'fingerprints[] / semanticKeys[]';
|
|
33
|
+
|
|
34
|
+
/** What each `provenance` list accepts, and how to say so when it does not. */
|
|
35
|
+
const PROVENANCE_FIELDS = Object.freeze({
|
|
36
|
+
fingerprints: { pattern: SHA1_RE, expected: 'a 40-char sha1 hex string' },
|
|
37
|
+
semanticKeys: {
|
|
38
|
+
pattern: SEMANTIC_KEY_RE,
|
|
39
|
+
expected: 'a non-empty key carrying no comma or ">"',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate one authored `provenance` list into its normalized form.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} list
|
|
47
|
+
* @param {{ where: string, field: string, pattern: RegExp, expected: string }} spec
|
|
48
|
+
* @returns {string[]} Trimmed, de-duplicated, first-seen order.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeList(list, { where, field, pattern, expected }) {
|
|
51
|
+
if (list === null || list === undefined) return [];
|
|
52
|
+
if (!Array.isArray(list)) {
|
|
53
|
+
throw new Error(`${where}: ${field} must be an array of strings`);
|
|
54
|
+
}
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const entry of list) {
|
|
57
|
+
const value = typeof entry === 'string' ? entry.trim() : '';
|
|
58
|
+
if (!pattern.test(value)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${where}: ${field} entry ${JSON.stringify(entry)} is not ${expected}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!out.includes(value)) out.push(value);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize the optional per-Story `provenance` field a plan may author —
|
|
70
|
+
* the identities of the findings **that Story owns**.
|
|
71
|
+
*
|
|
72
|
+
* Absence is meaningful and must stay cheap: `undefined` / `null` returns
|
|
73
|
+
* `null`, which is the caller's signal to fall back to the whole-seed union
|
|
74
|
+
* carry. That fallback is not vestigial — leaving the authoring agent to
|
|
75
|
+
* hand-carry provenance out of the seed's HTML comments was measured to fail,
|
|
76
|
+
* and the mechanical union is what closed it. Attribution is **additive**: a
|
|
77
|
+
* plan that attributes gets exact stamping, a plan that does not keeps recall.
|
|
78
|
+
*
|
|
79
|
+
* An empty object is therefore *not* the same as an absent field: it means
|
|
80
|
+
* "this Story owns nothing", and stamps nothing.
|
|
81
|
+
*
|
|
82
|
+
* Present-but-malformed is a hard error rather than a silent drop, because a
|
|
83
|
+
* dropped identity is invisible until the next sweep re-files work that was
|
|
84
|
+
* already planned.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} raw
|
|
87
|
+
* @param {string} [label] Identifier for the error message (a Story slug).
|
|
88
|
+
* @returns {{ fingerprints: string[], semanticKeys: string[] }|null}
|
|
89
|
+
* @throws {Error} On any shape the stamper cannot honour exactly.
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeOwnedProvenance(raw, label = 'story') {
|
|
92
|
+
if (raw === undefined || raw === null) return null;
|
|
93
|
+
const where = `provenance on "${label}"`;
|
|
94
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
95
|
+
throw new Error(`${where} must be an object of ${PROVENANCE_SHAPE}`);
|
|
96
|
+
}
|
|
97
|
+
const out = { fingerprints: [], semanticKeys: [] };
|
|
98
|
+
for (const [field, list] of Object.entries(raw)) {
|
|
99
|
+
const spec = PROVENANCE_FIELDS[field];
|
|
100
|
+
if (!spec) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${where} carries an unknown field: ${field} — only ${PROVENANCE_SHAPE} are stamped`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
out[field] = normalizeList(list, { where, field, ...spec });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render the provenance **source document** for a set of owned identities, in
|
|
112
|
+
* the same footer vocabulary `carryProvenanceFooters` harvests from an audit
|
|
113
|
+
* seed. That reuse is the point: attribution changes *which* identities reach
|
|
114
|
+
* a Story body, never how they are stamped, so the carry stays additive,
|
|
115
|
+
* union-preserving and idempotent for an attributed plan exactly as it is for
|
|
116
|
+
* an un-attributed one.
|
|
117
|
+
*
|
|
118
|
+
* An empty (or absent) set renders the empty string, which the carry treats as
|
|
119
|
+
* nothing-to-do — so a Story that owns no findings is stamped with none rather
|
|
120
|
+
* than inheriting its siblings'.
|
|
121
|
+
*
|
|
122
|
+
* Expects the normalized shape {@link normalizeOwnedProvenance} returns; the
|
|
123
|
+
* validator runs first on every production path.
|
|
124
|
+
*
|
|
125
|
+
* @param {{ fingerprints?: string[], semanticKeys?: string[] }|null} [provenance]
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function ownedProvenanceSource(provenance) {
|
|
129
|
+
const shas = provenance?.fingerprints ?? [];
|
|
130
|
+
const keys = provenance?.semanticKeys ?? [];
|
|
131
|
+
const parts = [];
|
|
132
|
+
if (shas.length > 0) parts.push(fingerprintFooter(shas));
|
|
133
|
+
if (keys.length > 0) parts.push(semanticKeyFooter(keys));
|
|
134
|
+
return parts.join('\n');
|
|
135
|
+
}
|