mandrel 2.3.0 → 2.5.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/audit-checklists/accessibility.md +29 -0
- package/.agents/audit-checklists/architecture.md +4 -5
- package/.agents/audit-checklists/clean-code.md +10 -0
- package/.agents/audit-checklists/data-model.md +22 -0
- package/.agents/audit-checklists/dependencies.md +11 -2
- package/.agents/audit-checklists/devops.md +4 -0
- package/.agents/audit-checklists/navigability.md +3 -0
- package/.agents/audit-checklists/performance.md +8 -11
- package/.agents/audit-checklists/privacy.md +3 -4
- package/.agents/audit-checklists/quality.md +2 -0
- package/.agents/audit-checklists/security.md +4 -5
- package/.agents/audit-checklists/seo.md +7 -1
- package/.agents/audit-checklists/sre.md +14 -12
- package/.agents/audit-checklists/ux-ui.md +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/docs/workflows.md +4 -3
- package/.agents/schemas/agentrc.schema.json +17 -0
- package/.agents/schemas/audit-rules.json +134 -19
- package/.agents/schemas/audit-rules.schema.json +6 -2
- package/.agents/scripts/audit-labels-bootstrap.js +4 -4
- package/.agents/scripts/audit-to-stories.js +244 -19
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
- package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
- package/.agents/scripts/lib/audit-suite/index.js +1 -0
- package/.agents/scripts/lib/audit-suite/selector.js +290 -14
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
- package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
- package/.agents/scripts/lib/command-header.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
- package/.agents/scripts/lib/findings/route-finding.js +108 -10
- package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
- package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
- package/.agents/scripts/nav-registry-diff.js +449 -0
- package/.agents/scripts/single-story-init.js +189 -51
- package/.agents/scripts/stories-wave-tick.js +39 -2
- package/.agents/workflows/audit-accessibility.md +243 -0
- package/.agents/workflows/audit-architecture.md +89 -71
- package/.agents/workflows/audit-clean-code.md +87 -53
- package/.agents/workflows/audit-data-model.md +198 -0
- package/.agents/workflows/audit-dependencies.md +143 -28
- package/.agents/workflows/audit-devops.md +109 -18
- package/.agents/workflows/audit-documentation.md +25 -53
- package/.agents/workflows/audit-navigability.md +78 -22
- package/.agents/workflows/audit-performance.md +207 -103
- package/.agents/workflows/audit-privacy.md +51 -13
- package/.agents/workflows/audit-quality.md +71 -61
- package/.agents/workflows/audit-security.md +94 -71
- package/.agents/workflows/audit-seo.md +80 -25
- package/.agents/workflows/audit-sre.md +99 -66
- package/.agents/workflows/audit-to-stories.md +44 -5
- package/.agents/workflows/audit-ux-ui.md +71 -17
- package/.agents/workflows/deliver.md +32 -8
- package/.agents/workflows/helpers/audit-dual-path.md +59 -0
- package/.agents/workflows/helpers/audit-self-check.md +70 -0
- package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
- package/.agents/workflows/helpers/deliver-story.md +25 -0
- package/docs/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/.agents/audit-checklists/lighthouse.md +0 -15
- package/.agents/schemas/audit-results.schema.json +0 -69
- package/.agents/workflows/audit-lighthouse.md +0 -269
|
@@ -32,7 +32,12 @@ import crypto from 'node:crypto';
|
|
|
32
32
|
|
|
33
33
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
34
34
|
const MARKER = 'audit-fingerprints:';
|
|
35
|
+
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
35
36
|
const SHA1_RE = /^[0-9a-f]{40}$/;
|
|
37
|
+
// A semantic key round-trips through a comma-joined footer, so it must not
|
|
38
|
+
// carry a comma or a `>` (which would truncate the HTML comment). Both are
|
|
39
|
+
// stripped when the key is built, so this guard is defence-in-depth.
|
|
40
|
+
const SEMANTIC_KEY_RE = /^[^,>]+$/;
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
43
|
* Normalise a single scalar identity field to a stable string.
|
|
@@ -92,6 +97,67 @@ export function fingerprintFinding(finding) {
|
|
|
92
97
|
return { short: full.slice(0, 12), full, components };
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Compute the **location-based semantic key** for a finding. Unlike the
|
|
102
|
+
* fingerprint (which folds in the title, so any prose rewording mints a fresh
|
|
103
|
+
* sha), the semantic key is stable across a reworded title and a re-severitied
|
|
104
|
+
* finding: it is derived solely from the finding's identity *location* —
|
|
105
|
+
* `area` (the audit dimension) plus `primaryFile`. Two scans that describe the
|
|
106
|
+
* same problem at the same location produce the same semantic key even when
|
|
107
|
+
* their titles diverge, so a reworded finding still confirms against the Issue
|
|
108
|
+
* that already tracks that location.
|
|
109
|
+
*
|
|
110
|
+
* Returns the empty string when the location is unknown (no `area` and no
|
|
111
|
+
* `primaryFile`) — an empty key never confirms a match, exactly as an absent
|
|
112
|
+
* fingerprint footer never does.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} finding — canonical finding ({ area, primaryFile, ... }).
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function semanticKeyFor(finding) {
|
|
118
|
+
const area = normaliseField(finding?.area);
|
|
119
|
+
const primaryFile = normaliseField(finding?.primaryFile);
|
|
120
|
+
if (!area && !primaryFile) return '';
|
|
121
|
+
const key = `${area}${SEP}${primaryFile}`;
|
|
122
|
+
return SEMANTIC_KEY_RE.test(key) ? key : key.replace(/[,>]/g, ' ').trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render the machine-readable semantic-key footer for one or more keys
|
|
127
|
+
* (`<!-- audit-semantic-keys: key,key,... -->`). Stamped alongside the
|
|
128
|
+
* fingerprint footer by the audit filers so a later reworded finding can
|
|
129
|
+
* confirm identity by location when its fingerprint has drifted. Round-trips
|
|
130
|
+
* through {@link parseSemanticKeyFooter}. Empty keys are dropped.
|
|
131
|
+
*
|
|
132
|
+
* @param {string | string[]} keys — one semantic key or an array of them.
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function semanticKeyFooter(keys) {
|
|
136
|
+
const list = (Array.isArray(keys) ? keys : [keys])
|
|
137
|
+
.filter((k) => typeof k === 'string' && k.length > 0)
|
|
138
|
+
.map((k) => k.replace(/[,>]/g, ' ').trim())
|
|
139
|
+
.filter((k) => k.length > 0);
|
|
140
|
+
return `<!-- ${SEMANTIC_MARKER} ${list.join(',')} -->`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract semantic keys from an Issue body carrying the semantic-key footer.
|
|
145
|
+
* Internal — the audit filers stamp the footer via {@link semanticKeyFooter};
|
|
146
|
+
* only the confirmation path here reads it back.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} body
|
|
149
|
+
* @returns {string[]}
|
|
150
|
+
*/
|
|
151
|
+
function parseSemanticKeyFooter(body) {
|
|
152
|
+
if (typeof body !== 'string') return [];
|
|
153
|
+
const match = body.match(/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/);
|
|
154
|
+
if (!match) return [];
|
|
155
|
+
return match[1]
|
|
156
|
+
.split(',')
|
|
157
|
+
.map((s) => s.trim())
|
|
158
|
+
.filter((s) => s.length > 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
95
161
|
/**
|
|
96
162
|
* Render the machine-readable fingerprint footer for one or more shas.
|
|
97
163
|
*
|
|
@@ -147,6 +213,21 @@ function issueCarriesFingerprint(issue, sha) {
|
|
|
147
213
|
return parseFingerprintFooter(issue.body).includes(sha);
|
|
148
214
|
}
|
|
149
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Confirm an issue body's footer carries the target semantic key. Unlike
|
|
218
|
+
* {@link issueCarriesFingerprint}, this is strict on a missing body — a
|
|
219
|
+
* location match is only meaningful when the issue actually carries a
|
|
220
|
+
* semantic-key footer to compare against.
|
|
221
|
+
*
|
|
222
|
+
* @param {{ body?: string }} issue
|
|
223
|
+
* @param {string} key
|
|
224
|
+
* @returns {boolean}
|
|
225
|
+
*/
|
|
226
|
+
function issueCarriesSemanticKey(issue, key) {
|
|
227
|
+
if (!key || typeof issue?.body !== 'string') return false;
|
|
228
|
+
return parseSemanticKeyFooter(issue.body).includes(key);
|
|
229
|
+
}
|
|
230
|
+
|
|
150
231
|
/**
|
|
151
232
|
* Decide the route decision from a confirmed matched issue's state.
|
|
152
233
|
* @param {{ state?: string }} issue
|
|
@@ -194,23 +275,29 @@ function decideFromConfirmed(confirmed, sha) {
|
|
|
194
275
|
}
|
|
195
276
|
|
|
196
277
|
/**
|
|
197
|
-
* Keep only the issue records that have the right wire shape AND carry
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
278
|
+
* Keep only the issue records that have the right wire shape AND carry a
|
|
279
|
+
* confirming footer. Confirmation is by the exact **fingerprint** footer and,
|
|
280
|
+
* when a `semanticKey` is supplied (audit dedup opts in via
|
|
281
|
+
* `options.semanticKeyConfirm`), ALSO by the location-based **semantic-key**
|
|
282
|
+
* footer. A semantic candidate that merely *looks* similar but carries neither
|
|
283
|
+
* footer is dropped here — semantic similarity widens the net; a deterministic
|
|
284
|
+
* footer (fingerprint or semantic key) is what confirms identity. The semantic
|
|
285
|
+
* key catches a reworded finding whose fingerprint has drifted but whose
|
|
286
|
+
* location is unchanged.
|
|
201
287
|
*
|
|
202
288
|
* @param {Array<unknown>} hits
|
|
203
|
-
* @param {string}
|
|
289
|
+
* @param {{ sha: string, semanticKey?: string }} identity
|
|
204
290
|
* @returns {Array<{ number: number, state: string }>}
|
|
205
291
|
*/
|
|
206
|
-
function
|
|
292
|
+
function confirmCandidates(hits, { sha, semanticKey = '' }) {
|
|
207
293
|
if (!Array.isArray(hits)) return [];
|
|
208
294
|
return hits.filter(
|
|
209
295
|
(h) =>
|
|
210
296
|
h &&
|
|
211
297
|
typeof h.number === 'number' &&
|
|
212
298
|
typeof h.state === 'string' &&
|
|
213
|
-
issueCarriesFingerprint(h, sha)
|
|
299
|
+
(issueCarriesFingerprint(h, sha) ||
|
|
300
|
+
issueCarriesSemanticKey(h, semanticKey)),
|
|
214
301
|
);
|
|
215
302
|
}
|
|
216
303
|
|
|
@@ -245,11 +332,18 @@ function confirmFingerprint(hits, sha) {
|
|
|
245
332
|
* Meaning-first candidate search over open+closed issues (and Epic
|
|
246
333
|
* sub-issues). When supplied, runs FIRST; its candidates are then
|
|
247
334
|
* fingerprint-confirmed.
|
|
335
|
+
* @param {object} [options]
|
|
336
|
+
* @param {boolean} [options.semanticKeyConfirm=false] — also confirm a
|
|
337
|
+
* candidate by the location-based semantic-key footer, not the fingerprint
|
|
338
|
+
* alone. Opt-in so the audit dedup path catches a reworded finding at an
|
|
339
|
+
* unchanged location while the qa-explore path (which does not stamp
|
|
340
|
+
* semantic-key footers) stays fingerprint-exact and byte-identical.
|
|
248
341
|
* @returns {Promise<{ decision: 'new'|'update-existing'|'duplicate'|'regression-of-closed', matchedIssue: object|null, fingerprint: string }>}
|
|
249
342
|
*/
|
|
250
343
|
export async function routeFinding(
|
|
251
344
|
finding,
|
|
252
345
|
{ searchIssues, searchCandidates } = {},
|
|
346
|
+
options = {},
|
|
253
347
|
) {
|
|
254
348
|
if (
|
|
255
349
|
typeof searchCandidates !== 'function' &&
|
|
@@ -261,6 +355,7 @@ export async function routeFinding(
|
|
|
261
355
|
}
|
|
262
356
|
|
|
263
357
|
const { full: sha } = fingerprintFinding(finding);
|
|
358
|
+
const semanticKey = options.semanticKeyConfirm ? semanticKeyFor(finding) : '';
|
|
264
359
|
|
|
265
360
|
// Stage 1: semantic candidate pass first (when wired); else fingerprint
|
|
266
361
|
// lookup. Both yield a candidate pool drawn from open AND closed issues.
|
|
@@ -269,15 +364,18 @@ export async function routeFinding(
|
|
|
269
364
|
? await searchCandidates(finding)
|
|
270
365
|
: await searchIssues(sha);
|
|
271
366
|
|
|
272
|
-
// Stage 2: confirm identity by fingerprint footer
|
|
273
|
-
|
|
367
|
+
// Stage 2: confirm identity by fingerprint footer (and, when opted in, the
|
|
368
|
+
// location-based semantic-key footer) over the candidate pool.
|
|
369
|
+
const confirmed = confirmCandidates(hits, { sha, semanticKey });
|
|
274
370
|
|
|
275
371
|
return decideFromConfirmed(confirmed, sha);
|
|
276
372
|
}
|
|
277
373
|
|
|
278
374
|
export const __testing = {
|
|
279
375
|
MARKER,
|
|
376
|
+
SEMANTIC_MARKER,
|
|
280
377
|
SEP,
|
|
281
|
-
|
|
378
|
+
confirmCandidates,
|
|
282
379
|
decideFromConfirmed,
|
|
380
|
+
issueCarriesSemanticKey,
|
|
283
381
|
};
|
|
@@ -159,6 +159,68 @@ export async function emitRuntimeFriction({
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Emit the recovery counterpart of a `story-blocked` record when a Story
|
|
164
|
+
* leaves `agent::blocked` for an active state (Story #4622).
|
|
165
|
+
*
|
|
166
|
+
* A transient block that self-resolves — lease contention or a stale label
|
|
167
|
+
* read under concurrent shared-checkout pressure (swarm-os friction #581) —
|
|
168
|
+
* still fired a `story-blocked` record at the block flip, which the retro
|
|
169
|
+
* composer counts toward the `story-blocked` recurrence total exactly like a
|
|
170
|
+
* terminal block. This emits a companion `story-blocked` record carrying the
|
|
171
|
+
* `details.recovered: true` discriminator, so the composer can net the whole
|
|
172
|
+
* incident out (see `retro-proposals.js`). The category is deliberately kept
|
|
173
|
+
* as `story-blocked` rather than a new bucket: a distinct category would
|
|
174
|
+
* itself aggregate into a routable proposal, re-introducing the noise.
|
|
175
|
+
*
|
|
176
|
+
* Best-effort; never throws.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} args
|
|
179
|
+
* @param {number} args.storyId
|
|
180
|
+
* @param {string} [args.fromState] The state parked at (`agent::blocked`).
|
|
181
|
+
* @param {string} [args.toState] The active state recovered into.
|
|
182
|
+
* @param {object} [args.config]
|
|
183
|
+
* @returns {Promise<boolean>} true when a record was appended.
|
|
184
|
+
*/
|
|
185
|
+
export async function emitBlockRecoveredFriction({
|
|
186
|
+
storyId,
|
|
187
|
+
fromState,
|
|
188
|
+
toState,
|
|
189
|
+
config,
|
|
190
|
+
} = {}) {
|
|
191
|
+
return emitRuntimeFriction({
|
|
192
|
+
storyId,
|
|
193
|
+
category: RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED,
|
|
194
|
+
tool: 'transitionTicketState',
|
|
195
|
+
details: {
|
|
196
|
+
recovered: true,
|
|
197
|
+
fromState: fromState ?? null,
|
|
198
|
+
toState: toState ?? null,
|
|
199
|
+
},
|
|
200
|
+
config,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pure predicate: is this signal a recovery-marked `story-blocked` record?
|
|
206
|
+
* Shared with the retro composer so the "recovered" discriminator is read
|
|
207
|
+
* from one place. A record is a recovery marker when its category is
|
|
208
|
+
* `story-blocked` and `details.recovered === true`.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} signal
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
export function isRecoveredBlockSignal(signal) {
|
|
214
|
+
return (
|
|
215
|
+
signal !== null &&
|
|
216
|
+
typeof signal === 'object' &&
|
|
217
|
+
signal.category === RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED &&
|
|
218
|
+
signal.details !== null &&
|
|
219
|
+
typeof signal.details === 'object' &&
|
|
220
|
+
signal.details.recovered === true
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
162
224
|
/**
|
|
163
225
|
* Decide whether a `story-deliver-terminal` envelope is worth a friction
|
|
164
226
|
* record, and describe it. **Pure** — no I/O — so the (interesting) policy
|
|
@@ -62,7 +62,7 @@ function normalizeIssueLabels(issue) {
|
|
|
62
62
|
*
|
|
63
63
|
* @param {object} issue
|
|
64
64
|
* @param {number} [requestedId] The id the operator asked for, for error text.
|
|
65
|
-
* @returns {{ id, title, body, url, labels, state }}
|
|
65
|
+
* @returns {{ id, title, body, url, labels, state, assignees }}
|
|
66
66
|
*/
|
|
67
67
|
export function toStoryRecord(issue, requestedId) {
|
|
68
68
|
const id = Number(issue?.number ?? issue?.id ?? requestedId);
|
|
@@ -93,6 +93,14 @@ export function toStoryRecord(issue, requestedId) {
|
|
|
93
93
|
url: issue?.html_url ?? issue?.url ?? null,
|
|
94
94
|
labels,
|
|
95
95
|
state: String(issue?.state ?? 'open').toLowerCase(),
|
|
96
|
+
// The assignee list carries the Story lease (`ticket-lease.js`): its sole
|
|
97
|
+
// assignee is the operator that owns the in-flight run. The probe reads it
|
|
98
|
+
// to withhold a Story another operator holds (`live-probe.js`), so it is
|
|
99
|
+
// threaded onto the record here rather than dropped. `issueToTicket`
|
|
100
|
+
// already reduces assignees to bare login strings; keep only those.
|
|
101
|
+
assignees: Array.isArray(issue?.assignees)
|
|
102
|
+
? issue.assignees.filter((a) => typeof a === 'string' && a.length > 0)
|
|
103
|
+
: [],
|
|
96
104
|
};
|
|
97
105
|
}
|
|
98
106
|
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
* @typedef {Object} FrictionSignal
|
|
35
35
|
* @property {string} category Free-form bucket (e.g. `"lint-loop"`).
|
|
36
36
|
* @property {"framework"|"consumer"} source
|
|
37
|
+
* @property {number} [storyId] Emitting Story id (used to net out recovered
|
|
38
|
+
* `story-blocked` incidents — Story #4622).
|
|
39
|
+
* @property {object} [details] Kind-specific payload; a `story-blocked`
|
|
40
|
+
* record with `details.recovered === true` is a
|
|
41
|
+
* recovery marker.
|
|
37
42
|
*
|
|
38
43
|
* @typedef {Object} BlockedEvent
|
|
39
44
|
* @property {number} ticketId
|
|
@@ -68,6 +73,11 @@
|
|
|
68
73
|
* @property {DiscardedItem[]} discarded
|
|
69
74
|
*/
|
|
70
75
|
|
|
76
|
+
import {
|
|
77
|
+
isRecoveredBlockSignal,
|
|
78
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
79
|
+
} from '../observability/runtime-friction.js';
|
|
80
|
+
|
|
71
81
|
/**
|
|
72
82
|
* Empty result helper — returned for zero-input callers so the consumer
|
|
73
83
|
* never needs to defensively spread undefineds.
|
|
@@ -89,6 +99,44 @@ function asString(value) {
|
|
|
89
99
|
return value.trim();
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Net transient (self-resolved) blocks out of the signal stream before it is
|
|
104
|
+
* aggregated (Story #4622).
|
|
105
|
+
*
|
|
106
|
+
* A `blocked → active` recovery emits a `story-blocked` record carrying
|
|
107
|
+
* `details.recovered === true`. When a Story has such a marker, its block was
|
|
108
|
+
* transient — lease contention or a stale label read under concurrent
|
|
109
|
+
* shared-checkout pressure (swarm-os friction #581) that cleared on a later
|
|
110
|
+
* beat — not a terminal HITL pause. This drops **every** `story-blocked`
|
|
111
|
+
* record for such a Story (both the original block and its recovery marker),
|
|
112
|
+
* so the retro counts only Stories still parked at `agent::blocked`.
|
|
113
|
+
*
|
|
114
|
+
* The netting is by `storyId`, not 1:1 pairing: a Story that ever recovered
|
|
115
|
+
* from a block in the run is treated as non-terminal for the whole run. That
|
|
116
|
+
* is a deliberate coarsening — the aggregate is a routing heuristic, not an
|
|
117
|
+
* incident ledger, and the signal stream carries no reliable ordering to
|
|
118
|
+
* reconstruct interleaved block/recover cycles. Non-`story-blocked` records
|
|
119
|
+
* and Stories with no recovery marker pass through untouched.
|
|
120
|
+
*
|
|
121
|
+
* @param {FrictionSignal[]} signals
|
|
122
|
+
* @returns {FrictionSignal[]}
|
|
123
|
+
*/
|
|
124
|
+
function netOutRecoveredBlocks(signals) {
|
|
125
|
+
const recoveredStoryIds = new Set();
|
|
126
|
+
for (const sig of signals) {
|
|
127
|
+
if (isRecoveredBlockSignal(sig) && Number.isInteger(sig.storyId)) {
|
|
128
|
+
recoveredStoryIds.add(sig.storyId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (recoveredStoryIds.size === 0) return signals;
|
|
132
|
+
return signals.filter((sig) => {
|
|
133
|
+
if (sig === null || typeof sig !== 'object') return true;
|
|
134
|
+
const isBlocked =
|
|
135
|
+
sig.category === RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED;
|
|
136
|
+
return !(isBlocked && recoveredStoryIds.has(sig.storyId));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
/**
|
|
93
141
|
* Aggregate friction signals by `category`, tracking per-source counts and
|
|
94
142
|
* arrival order so we can pick a dominant source deterministically.
|
|
@@ -403,7 +451,7 @@ export function composeRoutedProposals(input) {
|
|
|
403
451
|
} = normalised;
|
|
404
452
|
|
|
405
453
|
return routeCategoryBuckets({
|
|
406
|
-
byCategory: aggregateByCategory(signals),
|
|
454
|
+
byCategory: aggregateByCategory(netOutRecoveredBlocks(signals)),
|
|
407
455
|
blockedForceActionable: blockedForceMap(unresolvedBlockedEvents),
|
|
408
456
|
anchorId,
|
|
409
457
|
anchorKind,
|
|
@@ -29,8 +29,11 @@
|
|
|
29
29
|
* is best-effort and records its own reason.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
32
34
|
import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
|
|
33
35
|
import { Logger } from '../../../Logger.js';
|
|
36
|
+
import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
|
|
34
37
|
import {
|
|
35
38
|
executeFastForward as defaultExecuteFastForward,
|
|
36
39
|
planFastForward as defaultPlanFastForward,
|
|
@@ -38,6 +41,21 @@ import {
|
|
|
38
41
|
import { reassertStatusColumn as defaultReassertStatusColumn } from '../../reassert-status-column.js';
|
|
39
42
|
import { captureStoryFollowUps as defaultCaptureStoryFollowUps } from '../../story-follow-ups.js';
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Lockfile that serializes the local-checkout git mutations of the land tail
|
|
46
|
+
* across concurrent closes. Keyed on the **main checkout** (never a
|
|
47
|
+
* worktree): every concurrent `single-story-close` runs its tail against the
|
|
48
|
+
* same `cwd`, so anchoring the lock under that checkout's `.git` directory
|
|
49
|
+
* makes them all contend on one file. `.git` is always present, is one per
|
|
50
|
+
* checkout, and is never itself tracked, so it is a safe rendezvous home.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} cwd Main checkout root.
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function postLandLockPath(cwd) {
|
|
56
|
+
return path.join(cwd, '.git', 'mandrel-post-land-tail.lock');
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/**
|
|
42
60
|
* Run one tail step, converting any throw into a `false` + reason. Keeps
|
|
43
61
|
* each step's own body free of defensive boilerplate while guaranteeing the
|
|
@@ -210,6 +228,19 @@ async function stepBaseFastForward({
|
|
|
210
228
|
* after the ref reap so `git branch -D` is not fighting a checkout that just
|
|
211
229
|
* moved HEAD.
|
|
212
230
|
*
|
|
231
|
+
* **Cross-process serialization (Story #4622).** The two local-checkout
|
|
232
|
+
* mutations — `stepRefCleanup` (`git branch -D`) and `stepBaseFastForward`
|
|
233
|
+
* (fast-forward `baseBranch`) — run inside a best-effort cross-process lock
|
|
234
|
+
* keyed on the main checkout. Under concurrent delivery (multiple
|
|
235
|
+
* story-workers closing against one shared checkout + per-Story worktrees),
|
|
236
|
+
* an unserialized tail races on the `main` ref and the worktree registry —
|
|
237
|
+
* the `refCleanup:false` ("used by worktree") / `baseFastForward:false`
|
|
238
|
+
* ("not-fast-forward") signature reported in swarm-os friction #579. The
|
|
239
|
+
* GitHub-touching steps stay OUTSIDE the lock so a contended checkout never
|
|
240
|
+
* delays them. The lock is never load-bearing: on sustained contention the
|
|
241
|
+
* bounded wait expires and the mutations run anyway (proceeding is the same
|
|
242
|
+
* best-effort contract every tail step already has).
|
|
243
|
+
*
|
|
213
244
|
* @param {object} args
|
|
214
245
|
* @param {number} args.storyId
|
|
215
246
|
* @param {string} args.storyBranch
|
|
@@ -223,6 +254,7 @@ async function stepBaseFastForward({
|
|
|
223
254
|
* @param {Function} [args.gitSpawnFn] Test seam.
|
|
224
255
|
* @param {Function} [args.planFastForwardFn] Test seam.
|
|
225
256
|
* @param {Function} [args.executeFastForwardFn] Test seam.
|
|
257
|
+
* @param {Function} [args.acquireLockWithWaitFn] Test seam.
|
|
226
258
|
* @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, details: Record<string, string|null> }>}
|
|
227
259
|
*/
|
|
228
260
|
export async function runPostLandTail({
|
|
@@ -238,6 +270,7 @@ export async function runPostLandTail({
|
|
|
238
270
|
gitSpawnFn = defaultGitSpawn,
|
|
239
271
|
planFastForwardFn = defaultPlanFastForward,
|
|
240
272
|
executeFastForwardFn = defaultExecuteFastForward,
|
|
273
|
+
acquireLockWithWaitFn = defaultAcquireLockWithWait,
|
|
241
274
|
}) {
|
|
242
275
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
243
276
|
|
|
@@ -264,21 +297,46 @@ export async function runPostLandTail({
|
|
|
264
297
|
}),
|
|
265
298
|
{ name: 'status-column resync', progress },
|
|
266
299
|
);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
// Local-checkout mutations: serialized behind a best-effort cross-process
|
|
301
|
+
// lock (Story #4622). Acquire once, run both steps, release in `finally`.
|
|
302
|
+
const lockCfg = config?.delivery?.postLandLock ?? {};
|
|
303
|
+
const lock = await acquireLockWithWaitFn({
|
|
304
|
+
lockPath: postLandLockPath(cwd),
|
|
305
|
+
waitMs: lockCfg.waitMs,
|
|
306
|
+
pollMs: lockCfg.pollMs,
|
|
307
|
+
timeoutMs: lockCfg.timeoutMs,
|
|
308
|
+
ownerId: `post-land-${storyId}`,
|
|
309
|
+
});
|
|
310
|
+
if (!lock.acquired) {
|
|
311
|
+
// Never load-bearing: proceed anyway. The bounded wait already gave the
|
|
312
|
+
// concurrent holder its window; blocking the land on a lock we could not
|
|
313
|
+
// take would turn a best-effort damper into a false negative.
|
|
314
|
+
progress?.(
|
|
315
|
+
'POST-LAND',
|
|
316
|
+
`⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
let refCleanup;
|
|
320
|
+
let baseFastForward;
|
|
321
|
+
try {
|
|
322
|
+
refCleanup = await step(
|
|
323
|
+
() => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
|
|
324
|
+
{ name: 'local ref cleanup', progress },
|
|
325
|
+
);
|
|
326
|
+
baseFastForward = await step(
|
|
327
|
+
() =>
|
|
328
|
+
stepBaseFastForward({
|
|
329
|
+
cwd,
|
|
330
|
+
baseBranch,
|
|
331
|
+
progress,
|
|
332
|
+
planFastForwardFn,
|
|
333
|
+
executeFastForwardFn,
|
|
334
|
+
}),
|
|
335
|
+
{ name: 'base fast-forward', progress },
|
|
336
|
+
);
|
|
337
|
+
} finally {
|
|
338
|
+
if (lock.acquired) lock.release();
|
|
339
|
+
}
|
|
282
340
|
|
|
283
341
|
const tail = {
|
|
284
342
|
followUps: followUps.ok,
|
|
@@ -30,6 +30,58 @@ import { computeChangeSet } from '../../change-set.js';
|
|
|
30
30
|
*/
|
|
31
31
|
const STORY_SCOPE_LENS_DEPTH = 'light';
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Render the host-MUST-walk roster of materialized lens-prompt artifacts
|
|
35
|
+
* (Story #4627). The Story-scope lens pass materializes each matched lens's
|
|
36
|
+
* substituted prompt body to a scoped artifact file; the default review
|
|
37
|
+
* provider is a mechanical sweep that never reads them, so the artifacts are
|
|
38
|
+
* inert unless the close's stdout tells the host to walk them. This mirrors
|
|
39
|
+
* the plan-run audit-roster comment's "host MUST walk each" contract
|
|
40
|
+
* (`run-epilogue.js`): the close names each artifact path and the host reads
|
|
41
|
+
* each one against the diff.
|
|
42
|
+
*
|
|
43
|
+
* Pure: derives the block from the `runAuditSuite` envelope's `workflows[]`,
|
|
44
|
+
* keeping only entries that actually wrote an artifact. Returns `null` when no
|
|
45
|
+
* artifact was written (nothing to walk), so the caller emits nothing.
|
|
46
|
+
*
|
|
47
|
+
* Module-local: an implementation detail of {@link runLocalLensReview}, whose
|
|
48
|
+
* host-MUST-walk output rides out on the `progress` stream. Exercised through
|
|
49
|
+
* that public entry point (assert the progress lines name each artifact path)
|
|
50
|
+
* rather than imported directly, so it adds no production-dead public export.
|
|
51
|
+
*
|
|
52
|
+
* @param {object|null} materialized the `runAuditSuite` result envelope.
|
|
53
|
+
* @returns {string|null} the roster block, or `null` when there is nothing to walk.
|
|
54
|
+
*/
|
|
55
|
+
function renderLensArtifactRoster(materialized) {
|
|
56
|
+
const paths = (materialized?.workflows ?? [])
|
|
57
|
+
.map((w) => w?.artifactPath)
|
|
58
|
+
.filter((p) => typeof p === 'string' && p.length > 0);
|
|
59
|
+
if (paths.length === 0) return null;
|
|
60
|
+
return [
|
|
61
|
+
`Lens prompts materialized (host MUST read/walk each against the Story diff):`,
|
|
62
|
+
...paths.map((p) => ` - ${p}`),
|
|
63
|
+
].join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the `runAuditSuite` substitutions for the Story-scope lens pass
|
|
68
|
+
* (Story #4627). Resolves the `{{changedFiles}}` token from the actual Story
|
|
69
|
+
* diff (newline-joined, the shape the lens templates' `## Scope` block reads)
|
|
70
|
+
* and the `{{ticketId}}` token from the Story id when known. Both are built-in
|
|
71
|
+
* substitution keys (`substitutions.js#BUILT_IN_SUBSTITUTION_KEYS`), so the
|
|
72
|
+
* runner accepts them without a per-lens `substitutionKeys` declaration.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ changedFiles: string[], storyId?: number|string|null }} args
|
|
75
|
+
* @returns {Record<string, string>}
|
|
76
|
+
*/
|
|
77
|
+
function buildLensSubstitutions({ changedFiles, storyId }) {
|
|
78
|
+
const substitutions = { changedFiles: changedFiles.join('\n') };
|
|
79
|
+
if (storyId != null && `${storyId}`.length > 0) {
|
|
80
|
+
substitutions.ticketId = String(storyId);
|
|
81
|
+
}
|
|
82
|
+
return substitutions;
|
|
83
|
+
}
|
|
84
|
+
|
|
33
85
|
/**
|
|
34
86
|
* Enumerate the files changed in the `baseRef...headRef` diff. Thin adapter over
|
|
35
87
|
* the shared {@link computeChangeSet} enumerator (Story #4593) that flattens its
|
|
@@ -120,10 +172,20 @@ function resolveLensChangeSet({
|
|
|
120
172
|
* for standalone callers that supply no list; see {@link resolveLensChangeSet}
|
|
121
173
|
* for the three-state contract.
|
|
122
174
|
*
|
|
175
|
+
* Story #4627 — the pass now delivers lens **content** to a reader. It threads
|
|
176
|
+
* `{{changedFiles}}` / `{{ticketId}}` substitutions and an `artifactPrefix`
|
|
177
|
+
* into `runAuditSuite` so each matched lens's substituted prompt body is
|
|
178
|
+
* written to a scoped artifact under the run's audit output dir, then emits a
|
|
179
|
+
* host-MUST-walk roster of those artifact paths to the close's stdout. Before
|
|
180
|
+
* this the default review provider dropped the materialized envelope, so the
|
|
181
|
+
* pass was a progress log line with no reader.
|
|
182
|
+
*
|
|
123
183
|
* @param {{
|
|
124
184
|
* baseRef: string,
|
|
125
185
|
* headRef: string,
|
|
126
186
|
* changedFiles?: string[]|null,
|
|
187
|
+
* storyId?: number|string|null,
|
|
188
|
+
* artifactPrefix?: string,
|
|
127
189
|
* progress: (tag: string, msg: string) => void,
|
|
128
190
|
* progressTag?: string,
|
|
129
191
|
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
@@ -135,12 +197,15 @@ function resolveLensChangeSet({
|
|
|
135
197
|
* lenses: string[],
|
|
136
198
|
* skipped: boolean,
|
|
137
199
|
* materialized: object|null,
|
|
200
|
+
* artifactPaths: string[],
|
|
138
201
|
* }>}
|
|
139
202
|
*/
|
|
140
203
|
export async function runLocalLensReview({
|
|
141
204
|
baseRef,
|
|
142
205
|
headRef,
|
|
143
206
|
changedFiles: injectedChangedFiles,
|
|
207
|
+
storyId,
|
|
208
|
+
artifactPrefix,
|
|
144
209
|
progress,
|
|
145
210
|
progressTag = 'CODE-REVIEW',
|
|
146
211
|
gitSpawnFn = gitSpawn,
|
|
@@ -152,6 +217,7 @@ export async function runLocalLensReview({
|
|
|
152
217
|
lenses: [],
|
|
153
218
|
skipped: true,
|
|
154
219
|
materialized: null,
|
|
220
|
+
artifactPaths: [],
|
|
155
221
|
};
|
|
156
222
|
try {
|
|
157
223
|
const changedFiles = resolveLensChangeSet({
|
|
@@ -168,16 +234,30 @@ export async function runLocalLensReview({
|
|
|
168
234
|
);
|
|
169
235
|
return empty;
|
|
170
236
|
}
|
|
171
|
-
|
|
237
|
+
// Scope the artifact filenames to this Story so concurrent closes on a
|
|
238
|
+
// shared audit output dir cannot clobber each other's prompts.
|
|
239
|
+
const effectivePrefix =
|
|
240
|
+
artifactPrefix ?? (storyId != null ? `story-${storyId}` : 'story-scope');
|
|
241
|
+
const materialized = await runAuditSuiteFn({
|
|
242
|
+
auditWorkflows: lenses,
|
|
243
|
+
substitutions: buildLensSubstitutions({ changedFiles, storyId }),
|
|
244
|
+
artifactPrefix: effectivePrefix,
|
|
245
|
+
});
|
|
172
246
|
progress(
|
|
173
247
|
progressTag,
|
|
174
248
|
`Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
|
|
175
249
|
);
|
|
250
|
+
const roster = renderLensArtifactRoster(materialized);
|
|
251
|
+
if (roster) progress(progressTag, roster);
|
|
252
|
+
const artifactPaths = (materialized?.workflows ?? [])
|
|
253
|
+
.map((w) => w?.artifactPath)
|
|
254
|
+
.filter((p) => typeof p === 'string' && p.length > 0);
|
|
176
255
|
return {
|
|
177
256
|
depth: STORY_SCOPE_LENS_DEPTH,
|
|
178
257
|
lenses,
|
|
179
258
|
skipped: false,
|
|
180
259
|
materialized,
|
|
260
|
+
artifactPaths,
|
|
181
261
|
};
|
|
182
262
|
} catch (err) {
|
|
183
263
|
// The lens pass is advisory: a git or materialization failure must not
|