mandrel 2.54.0 → 2.56.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/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -145,6 +145,7 @@ export async function adoptContainerEpic({
|
|
|
145
145
|
provider,
|
|
146
146
|
epicNumber: target.id,
|
|
147
147
|
childIds,
|
|
148
|
+
created: all,
|
|
148
149
|
});
|
|
149
150
|
|
|
150
151
|
Logger.info(
|
|
@@ -164,6 +165,24 @@ export async function adoptContainerEpic({
|
|
|
164
165
|
/**
|
|
165
166
|
* Write the appended checklist back to the Epic body.
|
|
166
167
|
*
|
|
168
|
+
* **The body is re-read `fresh` immediately before the append.** `target.body`
|
|
169
|
+
* was captured by `resolveAdoptionTarget` before the first Story was created,
|
|
170
|
+
* which on a cohort of any size is many seconds and several writes ago. An
|
|
171
|
+
* append computed against that snapshot and PATCHed wholesale silently drops
|
|
172
|
+
* every checklist row another writer added in between — a concurrent persist
|
|
173
|
+
* run adopting the same Epic, or an operator ticking a child off by hand. The
|
|
174
|
+
* body is a full-document write, so a stale base is not a merge conflict; it
|
|
175
|
+
* is a silent revert.
|
|
176
|
+
*
|
|
177
|
+
* This **narrows** the read-then-PATCH window; it does not close it. Nothing
|
|
178
|
+
* here is atomic, and GitHub's issue API offers no compare-and-swap, so a
|
|
179
|
+
* write landing between this read and this PATCH is still lost. Narrowing it
|
|
180
|
+
* from "the whole create phase" to "one round-trip" is the available fix; a
|
|
181
|
+
* real one needs an API that does not exist.
|
|
182
|
+
*
|
|
183
|
+
* The PATCH is skipped when the append changes nothing, so re-running an
|
|
184
|
+
* adoption that already landed writes nothing at all.
|
|
185
|
+
*
|
|
167
186
|
* Non-fatal: the Stories are already live, and the native sub-issue edges
|
|
168
187
|
* written next are the other half of the linkage. Losing the checklist costs
|
|
169
188
|
* the body-only fallback path, not the grouping.
|
|
@@ -179,8 +198,9 @@ async function appendChecklist({ provider, target, childIds }) {
|
|
|
179
198
|
);
|
|
180
199
|
return;
|
|
181
200
|
}
|
|
182
|
-
const
|
|
183
|
-
|
|
201
|
+
const base = await readFreshEpicBody({ provider, target });
|
|
202
|
+
const next = appendEpicChildIds(base, childIds);
|
|
203
|
+
if (next === base) return;
|
|
184
204
|
try {
|
|
185
205
|
await provider.updateTicket(target.id, { body: next });
|
|
186
206
|
} catch (err) {
|
|
@@ -190,3 +210,30 @@ async function appendChecklist({ provider, target, childIds }) {
|
|
|
190
210
|
);
|
|
191
211
|
}
|
|
192
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Re-read the Epic's body, bypassing any provider cache.
|
|
216
|
+
*
|
|
217
|
+
* `{ fresh: true }` is the whole point: the adoption path already read this
|
|
218
|
+
* issue once, so a cached read would hand back the very snapshot this function
|
|
219
|
+
* exists to replace. Falls back to the snapshot when the re-read fails or the
|
|
220
|
+
* provider has no `getTicket` — a stale base still appends the run's own
|
|
221
|
+
* children, which beats not linking them.
|
|
222
|
+
*
|
|
223
|
+
* @param {{ provider: object, target: { id: number, body: string } }} opts
|
|
224
|
+
* @returns {Promise<string>}
|
|
225
|
+
*/
|
|
226
|
+
async function readFreshEpicBody({ provider, target }) {
|
|
227
|
+
if (typeof provider?.getTicket !== 'function') return target.body;
|
|
228
|
+
try {
|
|
229
|
+
const fresh = await provider.getTicket(target.id, { fresh: true });
|
|
230
|
+
if (typeof fresh?.body === 'string') return fresh.body;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
Logger.warn(
|
|
233
|
+
`[plan-persist] could not re-read Epic #${target.id} before appending its ` +
|
|
234
|
+
`checklist (${err?.message ?? err}); appending to the body read earlier. ` +
|
|
235
|
+
'A child linked by another writer since then may be dropped.',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return target.body;
|
|
239
|
+
}
|
|
@@ -123,10 +123,10 @@ async function ensureEpicLabel({ provider }) {
|
|
|
123
123
|
* @returns {Promise<{ id: number, url?: string }|null>}
|
|
124
124
|
*/
|
|
125
125
|
async function findExistingEpic({ provider, fingerprint }) {
|
|
126
|
-
if (typeof provider?.
|
|
126
|
+
if (typeof provider?.listTicketsByLabel !== 'function') return null;
|
|
127
127
|
try {
|
|
128
128
|
const marker = epicFingerprintMarker(fingerprint);
|
|
129
|
-
const found = await provider.
|
|
129
|
+
const found = await provider.listTicketsByLabel({
|
|
130
130
|
state: 'open',
|
|
131
131
|
labels: TYPE_LABELS.EPIC,
|
|
132
132
|
});
|
|
@@ -134,9 +134,12 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
134
134
|
String(issue?.body ?? '').includes(marker),
|
|
135
135
|
);
|
|
136
136
|
if (!hit) return null;
|
|
137
|
-
|
|
137
|
+
// The declared ticket shape: `id` is the issue number. The `number`-then-
|
|
138
|
+
// `id` fallback this replaced would have adopted the resumed container by
|
|
139
|
+
// database id — a number that exists, resolves to nothing, fails no guard.
|
|
140
|
+
const id = Number(hit.id);
|
|
138
141
|
if (!Number.isInteger(id) || id <= 0) return null;
|
|
139
|
-
return { id, url: hit.
|
|
142
|
+
return { id, url: hit.url ?? undefined };
|
|
140
143
|
} catch (err) {
|
|
141
144
|
Logger.warn(
|
|
142
145
|
`[plan-persist] Epic resume lookup failed (${err.message}); creating a new container.`,
|
|
@@ -145,6 +148,26 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Index a cohort's already-known database ids by issue number.
|
|
153
|
+
*
|
|
154
|
+
* A resumed run's adopted Stories carry no `internalId` — they were found by
|
|
155
|
+
* listing, not created — so they are simply absent from the map and fall
|
|
156
|
+
* through to the lookup. Absence means "not known here", never "has none".
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<{ id?: number, internalId?: number }>|undefined} created
|
|
159
|
+
* @returns {Map<number, number>}
|
|
160
|
+
*/
|
|
161
|
+
function internalIdsFrom(created) {
|
|
162
|
+
const map = new Map();
|
|
163
|
+
for (const story of Array.isArray(created) ? created : []) {
|
|
164
|
+
if (Number.isInteger(story?.id) && typeof story?.internalId === 'number') {
|
|
165
|
+
map.set(story.id, story.internalId);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return map;
|
|
169
|
+
}
|
|
170
|
+
|
|
148
171
|
/**
|
|
149
172
|
* Link the created Stories under the Epic as native sub-issue edges.
|
|
150
173
|
*
|
|
@@ -156,10 +179,18 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
156
179
|
* children exactly the way creation does — one mirroring rule, not two that
|
|
157
180
|
* drift.
|
|
158
181
|
*
|
|
159
|
-
*
|
|
182
|
+
* `created` is optional and carries the cohort's `createIssue` responses, so
|
|
183
|
+
* the linker can skip the id lookup for every child this run made itself.
|
|
184
|
+
*
|
|
185
|
+
* @param {{ provider: object, epicNumber: number, childIds: number[], created?: Array<{ id: number, internalId?: number }> }} opts
|
|
160
186
|
* @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
|
|
161
187
|
*/
|
|
162
|
-
export async function mirrorSubIssueEdges({
|
|
188
|
+
export async function mirrorSubIssueEdges({
|
|
189
|
+
provider,
|
|
190
|
+
epicNumber,
|
|
191
|
+
childIds,
|
|
192
|
+
created = [],
|
|
193
|
+
}) {
|
|
163
194
|
if (
|
|
164
195
|
typeof provider?.getDependencyWriteContext !== 'function' ||
|
|
165
196
|
typeof provider?.getTicket !== 'function'
|
|
@@ -176,6 +207,7 @@ export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
|
|
|
176
207
|
const summary = await linkStoriesToEpic({
|
|
177
208
|
epicNumber,
|
|
178
209
|
childIssueNumbers: childIds,
|
|
210
|
+
knownInternalIds: internalIdsFrom(created),
|
|
179
211
|
getTicket: (issueNumber) => provider.getTicket(issueNumber),
|
|
180
212
|
owner,
|
|
181
213
|
repo,
|
|
@@ -285,6 +317,7 @@ export async function createContainerEpic({
|
|
|
285
317
|
provider,
|
|
286
318
|
epicNumber: existing.id,
|
|
287
319
|
childIds,
|
|
320
|
+
created,
|
|
288
321
|
});
|
|
289
322
|
return {
|
|
290
323
|
id: existing.id,
|
|
@@ -303,11 +336,14 @@ export async function createContainerEpic({
|
|
|
303
336
|
labels: [TYPE_LABELS.EPIC],
|
|
304
337
|
});
|
|
305
338
|
|
|
306
|
-
|
|
339
|
+
// `createIssue` declares both `id` and `number` and sets them to the same
|
|
340
|
+
// issue number; reading one of them is the whole contract.
|
|
341
|
+
const epicNumber = result.id;
|
|
307
342
|
const edges = await mirrorSubIssueEdges({
|
|
308
343
|
provider,
|
|
309
344
|
epicNumber,
|
|
310
345
|
childIds,
|
|
346
|
+
created,
|
|
311
347
|
});
|
|
312
348
|
|
|
313
349
|
Logger.info(
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { rm } from 'node:fs/promises';
|
|
41
|
-
import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
|
|
41
|
+
import { getLimits, getPaths, PROJECT_ROOT } from '../../config-resolver.js';
|
|
42
42
|
import { gitSpawn } from '../../git-utils.js';
|
|
43
43
|
import { Logger } from '../../Logger.js';
|
|
44
44
|
import { sweepTempRetention } from '../../temp-retention.js';
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
renderHardConflictError,
|
|
69
69
|
} from '../ticket-validator-conflicts.js';
|
|
70
70
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
71
|
+
import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
|
|
71
72
|
import {
|
|
72
73
|
resolveContainerEpic,
|
|
73
74
|
resolveCrossPlanLinks,
|
|
@@ -88,6 +89,7 @@ import {
|
|
|
88
89
|
PLAN_SUMMARY_COMMENT_TYPE,
|
|
89
90
|
} from './summary.js';
|
|
90
91
|
import { closeSupersededTickets } from './supersede-ops.js';
|
|
92
|
+
import { predictWaveSerialisation } from './wave-serialisation.js';
|
|
91
93
|
|
|
92
94
|
/** Checkpoint schema version written on each Story's story-plan-state. */
|
|
93
95
|
const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
|
|
@@ -212,6 +214,7 @@ async function runSupersedePhase(args) {
|
|
|
212
214
|
reason: `phase-error: ${err.message}`,
|
|
213
215
|
closed: [],
|
|
214
216
|
planned: [],
|
|
217
|
+
epicRollup: { closed: [], pending: [] },
|
|
215
218
|
skipped: [],
|
|
216
219
|
failed: (args.sourceTicketIds ?? []).map((ticket) => ({
|
|
217
220
|
ticket,
|
|
@@ -778,7 +781,8 @@ export async function runPlanPersist({
|
|
|
778
781
|
});
|
|
779
782
|
|
|
780
783
|
// Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
|
|
781
|
-
const
|
|
784
|
+
const seedContent = planContextEnvelope?.seed?.content ?? '';
|
|
785
|
+
const { stories: assembled } = assemblePlanStories(rawStories, {
|
|
782
786
|
sharedSpec: techSpecContent,
|
|
783
787
|
planAcceptance: planAcceptance ?? undefined,
|
|
784
788
|
sourceTicketIds,
|
|
@@ -789,9 +793,16 @@ export async function runPlanPersist({
|
|
|
789
793
|
// is the **fallback** — it is carried onto every Story that did not
|
|
790
794
|
// attribute its own `provenance`, which keeps an un-attributed plan exactly
|
|
791
795
|
// as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
|
|
792
|
-
provenanceSource:
|
|
796
|
+
provenanceSource: seedContent,
|
|
793
797
|
});
|
|
794
798
|
|
|
799
|
+
// Stamp the `audit::*` labels the dedup corpus is listed by. Without them a
|
|
800
|
+
// Story this path files is absent from the pool an indexed sweep matches
|
|
801
|
+
// against, and an indexed run answers exact lookups from that pool without
|
|
802
|
+
// ever reaching the provider — so the provenance footers alone leave it
|
|
803
|
+
// invisible (Story #5307). A non-audit seed carries none: a no-op there.
|
|
804
|
+
const stories = withAuditLabels(assembled, seedContent);
|
|
805
|
+
|
|
795
806
|
// Story #5045: the cross-Story conflict passes re-run over the assembled,
|
|
796
807
|
// footer-stamped bodies — the artifact persist actually writes — before any
|
|
797
808
|
// GitHub call, so a policy upgrade still refuses the plan pre-creation.
|
|
@@ -822,6 +833,10 @@ export async function runPlanPersist({
|
|
|
822
833
|
opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
|
|
823
834
|
});
|
|
824
835
|
|
|
836
|
+
// What this run filed, recorded where the next audit sweep reads it
|
|
837
|
+
// (Story #5307). A dry run created nothing, and the call knows it.
|
|
838
|
+
recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
|
|
839
|
+
|
|
825
840
|
const primary = created[0];
|
|
826
841
|
const waveTable = buildWaveTable(
|
|
827
842
|
stories.map((s) => ({
|
|
@@ -831,6 +846,17 @@ export async function runPlanPersist({
|
|
|
831
846
|
})),
|
|
832
847
|
);
|
|
833
848
|
|
|
849
|
+
// Story #5265: the table says which Stories share an order; the dispatcher
|
|
850
|
+
// decides which of those actually run together, and it decides on the
|
|
851
|
+
// evidence-widened footprint. Run its own predicate over the assembled
|
|
852
|
+
// bodies — the exact artifact the tick will read back off GitHub — so the
|
|
853
|
+
// comment names the serialisation instead of promising parallelism the
|
|
854
|
+
// next tick refuses. `tempRoot` is threaded for the same reason the tick
|
|
855
|
+
// threads it: the scrape must ignore this project's scratch root.
|
|
856
|
+
const waveCollisions = predictWaveSerialisation(waveTable, stories, {
|
|
857
|
+
tempRoot: getPaths(config).tempRoot,
|
|
858
|
+
});
|
|
859
|
+
|
|
834
860
|
// Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
|
|
835
861
|
// called with `config` first, so the ledger path resolver received the
|
|
836
862
|
// config object as an `epicId` and threw its guard on every single run —
|
|
@@ -860,6 +886,7 @@ export async function runPlanPersist({
|
|
|
860
886
|
// collisions the conflict passes found belong on the same surface, or the
|
|
861
887
|
// promise is the only half anyone reads.
|
|
862
888
|
conflictFindings: assembledConflicts,
|
|
889
|
+
waveCollisions,
|
|
863
890
|
});
|
|
864
891
|
|
|
865
892
|
if (!dryRun) {
|
|
@@ -890,6 +917,10 @@ export async function runPlanPersist({
|
|
|
890
917
|
stories,
|
|
891
918
|
created,
|
|
892
919
|
sourceTicketIds,
|
|
920
|
+
// Story #5280 — closing a source ticket is a child state change, so the
|
|
921
|
+
// phase re-derives the container above it. It needs the config the rollup
|
|
922
|
+
// reads its board and operator handle from.
|
|
923
|
+
config,
|
|
893
924
|
dryRun,
|
|
894
925
|
closeSuperseded,
|
|
895
926
|
});
|
|
@@ -915,6 +946,11 @@ export async function runPlanPersist({
|
|
|
915
946
|
reachability,
|
|
916
947
|
freshness,
|
|
917
948
|
waveTable,
|
|
949
|
+
// Story #5265 AC-2/AC-4: both halves of what persist concluded but used
|
|
950
|
+
// to keep to itself — the `refactors-existing` declarations it rewrote,
|
|
951
|
+
// and the same-order pairs the dispatcher will serialize.
|
|
952
|
+
assumptionNormalizations: validated.normalizations ?? [],
|
|
953
|
+
waveCollisions,
|
|
918
954
|
supersede,
|
|
919
955
|
epic: containerEpic,
|
|
920
956
|
};
|
|
@@ -1098,6 +1098,11 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
1098
1098
|
id,
|
|
1099
1099
|
title: story.title,
|
|
1100
1100
|
url: result.url,
|
|
1101
|
+
// Kept, not discarded (Story #5280). `createIssue` hands back the
|
|
1102
|
+
// database id, which is the only identifier the native sub-issue write
|
|
1103
|
+
// accepts — and dropping it here is what made the Epic linker re-read
|
|
1104
|
+
// every Story this run had just created to recover it.
|
|
1105
|
+
internalId: result.internalId,
|
|
1101
1106
|
// True when the provider's retry probe adopted an issue a lost-response
|
|
1102
1107
|
// first attempt had already filed — pre-existing either way.
|
|
1103
1108
|
adopted: result.adopted === true,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { computeStoryWaves } from '../dependency-analyzer.js';
|
|
17
|
+
import { renderPredictedSerialisationLines } from './wave-serialisation.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Structured-comment type for the persist summary.
|
|
@@ -129,6 +130,7 @@ export function buildPlanSummaryCommentBody({
|
|
|
129
130
|
planMetricsLine = null,
|
|
130
131
|
stories = null,
|
|
131
132
|
conflictFindings = null,
|
|
133
|
+
waveCollisions = null,
|
|
132
134
|
// legacy unused knobs kept so older test call sites don't crash mid-migration
|
|
133
135
|
single = null,
|
|
134
136
|
amend = null,
|
|
@@ -180,6 +182,7 @@ export function buildPlanSummaryCommentBody({
|
|
|
180
182
|
'#### Delivery order (`depends_on`)',
|
|
181
183
|
'',
|
|
182
184
|
...renderWaveTableLines(waveTable),
|
|
185
|
+
...renderPredictedSerialisationLines(waveCollisions),
|
|
183
186
|
...renderSharedEditorLines(conflictFindings),
|
|
184
187
|
'',
|
|
185
188
|
`_Deliver with \`${deliverCommand}\` — \`/mandrel-deliver\` resolves the dependency graph from live state, so edges may point at Stories from earlier plan runs._`,
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
concurrentMap,
|
|
51
51
|
FANOUT_CONCURRENCY,
|
|
52
52
|
} from '../../util/concurrent-map.js';
|
|
53
|
+
import { rollUpEpicForStory } from '../epic-rollup.js';
|
|
53
54
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
54
55
|
|
|
55
56
|
/** Structured-comment type marking a source issue as superseded. */
|
|
@@ -444,6 +445,9 @@ async function closeOneSupersededTicket({
|
|
|
444
445
|
* slug is the only identifier that means anything before the writes land.
|
|
445
446
|
* @property {Array<{ ticket: number, reason: string }>} skipped
|
|
446
447
|
* @property {Array<{ ticket: number, reason: string }>} failed
|
|
448
|
+
* @property {{ closed: number[], pending: number[] }} epicRollup Container
|
|
449
|
+
* Epics this phase's closes resolved. Empty on a dry run and on a phase that
|
|
450
|
+
* closed nothing.
|
|
447
451
|
*/
|
|
448
452
|
|
|
449
453
|
function emptyReport(overrides) {
|
|
@@ -455,10 +459,53 @@ function emptyReport(overrides) {
|
|
|
455
459
|
planned: [],
|
|
456
460
|
skipped: [],
|
|
457
461
|
failed: [],
|
|
462
|
+
epicRollup: { closed: [], pending: [] },
|
|
458
463
|
...overrides,
|
|
459
464
|
};
|
|
460
465
|
}
|
|
461
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Re-derive the container Epic above every ticket this phase just closed.
|
|
469
|
+
*
|
|
470
|
+
* Closing a source ticket is a child state change like any other, and it was
|
|
471
|
+
* the one edge with no rollup behind it. Superseding a cohort therefore left
|
|
472
|
+
* its container open indefinitely: every child was closed, so no delivery
|
|
473
|
+
* would ever run and no land tail would ever fire the derivation. The Epic sat
|
|
474
|
+
* open above finished work until someone noticed.
|
|
475
|
+
*
|
|
476
|
+
* Runs **after** the closes land, never alongside them: the rollup reads each
|
|
477
|
+
* child's state back, so racing it against the writes it is meant to observe
|
|
478
|
+
* would derive from a tree half of which has not been written yet.
|
|
479
|
+
*
|
|
480
|
+
* Sequential, sharing one `skipEpicIds` set, because siblings share a
|
|
481
|
+
* container — without it the second ticket re-derives, and re-closes, the Epic
|
|
482
|
+
* the first already closed. `rollUpEpicForStory` never throws, so no guard is
|
|
483
|
+
* needed here beyond the phase-level one the caller already holds.
|
|
484
|
+
*
|
|
485
|
+
* @param {{ closedIds: number[], provider: object, config?: object }} opts
|
|
486
|
+
* @returns {Promise<{ closed: number[], pending: number[] }>}
|
|
487
|
+
*/
|
|
488
|
+
async function rollUpContainersFor({ closedIds, provider, config }) {
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
const closed = new Set();
|
|
491
|
+
const pending = new Set();
|
|
492
|
+
for (const storyId of closedIds) {
|
|
493
|
+
const outcome = await rollUpEpicForStory({
|
|
494
|
+
storyId,
|
|
495
|
+
provider,
|
|
496
|
+
config,
|
|
497
|
+
skipEpicIds: seen,
|
|
498
|
+
});
|
|
499
|
+
for (const epic of outcome.epics) seen.add(epic.epicId);
|
|
500
|
+
for (const epicId of outcome.closed) closed.add(epicId);
|
|
501
|
+
for (const epicId of outcome.pending) pending.add(epicId);
|
|
502
|
+
}
|
|
503
|
+
// A container reported pending by one ticket's rollup and closed by a later
|
|
504
|
+
// one is closed: the last answer saw the whole cohort.
|
|
505
|
+
for (const epicId of closed) pending.delete(epicId);
|
|
506
|
+
return { closed: [...closed], pending: [...pending] };
|
|
507
|
+
}
|
|
508
|
+
|
|
462
509
|
/**
|
|
463
510
|
* Comment on and close every superseded source ticket.
|
|
464
511
|
*
|
|
@@ -471,6 +518,7 @@ function emptyReport(overrides) {
|
|
|
471
518
|
* @param {Array<{ slug: string, supersedes: Array<{ id: number, note: string|null }> }>} args.stories
|
|
472
519
|
* @param {Array<{ slug: string, id: number, title: string }>} args.created
|
|
473
520
|
* @param {number[]} args.sourceTicketIds
|
|
521
|
+
* @param {object} [args.config] Resolved `.agentrc.json`, for the Epic rollup.
|
|
474
522
|
* @param {boolean} [args.dryRun=false]
|
|
475
523
|
* @param {boolean} [args.closeSuperseded=true]
|
|
476
524
|
* @returns {Promise<SupersedeReport>}
|
|
@@ -480,6 +528,7 @@ export async function closeSupersededTickets({
|
|
|
480
528
|
stories,
|
|
481
529
|
created,
|
|
482
530
|
sourceTicketIds,
|
|
531
|
+
config,
|
|
483
532
|
dryRun = false,
|
|
484
533
|
closeSuperseded = true,
|
|
485
534
|
}) {
|
|
@@ -532,6 +581,14 @@ export async function closeSupersededTickets({
|
|
|
532
581
|
recordSupersedeOutcome(report, units[index], result);
|
|
533
582
|
});
|
|
534
583
|
|
|
584
|
+
if (report.closed.length > 0) {
|
|
585
|
+
report.epicRollup = await rollUpContainersFor({
|
|
586
|
+
closedIds: report.closed,
|
|
587
|
+
provider,
|
|
588
|
+
config,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
535
592
|
logSupersedeReport(report);
|
|
536
593
|
return report;
|
|
537
594
|
}
|
|
@@ -613,4 +670,10 @@ function logSupersedeReport(report) {
|
|
|
613
670
|
'close it by hand.',
|
|
614
671
|
);
|
|
615
672
|
}
|
|
673
|
+
if (report.epicRollup.closed.length > 0) {
|
|
674
|
+
Logger.info(
|
|
675
|
+
`[plan-persist] container Epic(s) closed by the supersede rollup: ` +
|
|
676
|
+
`${report.epicRollup.closed.map((id) => `#${id}`).join(', ')}.`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
616
679
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wave-serialisation.js — what the dispatcher will actually do with the wave
|
|
3
|
+
* table plan-persist prints (Story #5265).
|
|
4
|
+
*
|
|
5
|
+
* Kept out of `summary.js` because it answers a different question. The
|
|
6
|
+
* summary module renders receipts persist already computed; this one runs the
|
|
7
|
+
* *runtime's* collision predicate over the assembled Story bodies to work out
|
|
8
|
+
* which of the table's promises the next tick will refuse to keep.
|
|
9
|
+
*
|
|
10
|
+
* @module lib/orchestration/plan-persist/wave-serialisation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
detectCollision,
|
|
15
|
+
OVERLAP_SOURCES,
|
|
16
|
+
renderScrapeAttribution,
|
|
17
|
+
} from '../../wave-runner/footprint.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Predict which same-wave pairs the dispatch guard will actually refuse to
|
|
21
|
+
* co-dispatch (Story #5265).
|
|
22
|
+
*
|
|
23
|
+
* The wave table answers a `depends_on` question, and the runtime answers a
|
|
24
|
+
* different one: `stories-wave-tick.js` withholds on {@link detectCollision},
|
|
25
|
+
* whose footprint is the declared `changes[]` **plus** every path scraped out
|
|
26
|
+
* of the Story's title, spec and serialized body — and that body carries
|
|
27
|
+
* `## Verify`, so two Stories that merely run the same gate script share a
|
|
28
|
+
* path neither will edit. The table therefore promised parallelism the very
|
|
29
|
+
* next tick refused, with nothing anywhere reconciling the two.
|
|
30
|
+
*
|
|
31
|
+
* This runs the runtime's own exported predicate — not a reimplementation of
|
|
32
|
+
* it — pairwise within each wave, so the prediction cannot drift from the
|
|
33
|
+
* behaviour it predicts.
|
|
34
|
+
*
|
|
35
|
+
* @param {ReturnType<typeof buildWaveTable>} waveTable
|
|
36
|
+
* @param {Array<{ slug: string, title?: string, body?: string, spec?: string, changes?: Array }>} stories
|
|
37
|
+
* @param {{ tempRoot?: string }} [options]
|
|
38
|
+
* @returns {Array<{ wave: number, slugs: [string, string], paths: string[], source: string, attribution: object[] }>}
|
|
39
|
+
*/
|
|
40
|
+
export function predictWaveSerialisation(waveTable, stories, options = {}) {
|
|
41
|
+
const bySlug = new Map(
|
|
42
|
+
(Array.isArray(stories) ? stories : []).map((s) => [s.slug, s]),
|
|
43
|
+
);
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const { wave, stories: members } of Array.isArray(waveTable)
|
|
46
|
+
? waveTable
|
|
47
|
+
: []) {
|
|
48
|
+
for (let i = 0; i < members.length; i += 1) {
|
|
49
|
+
for (let j = i + 1; j < members.length; j += 1) {
|
|
50
|
+
const a = bySlug.get(members[i].slug);
|
|
51
|
+
const b = bySlug.get(members[j].slug);
|
|
52
|
+
if (!a || !b) continue;
|
|
53
|
+
const collision = detectCollision(a, b, options);
|
|
54
|
+
if (collision) {
|
|
55
|
+
out.push({
|
|
56
|
+
wave,
|
|
57
|
+
slugs: [members[i].slug, members[j].slug],
|
|
58
|
+
...collision,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render the predicted serialisation beside the wave table (Story #5265).
|
|
69
|
+
*
|
|
70
|
+
* Same shape as {@link renderSharedEditorLines} on purpose: both are caveats
|
|
71
|
+
* against the same promise, and an operator should read them the same way.
|
|
72
|
+
* The difference is what they know — the shared-editor pass names paths two
|
|
73
|
+
* Stories both *write*, this one names every pair the dispatcher will refuse
|
|
74
|
+
* to run together whatever the reason, including the pairs whose only shared
|
|
75
|
+
* path was scraped out of a `## Verify` line.
|
|
76
|
+
*
|
|
77
|
+
* @param {ReturnType<typeof predictWaveSerialisation>} collisions
|
|
78
|
+
* @returns {string[]}
|
|
79
|
+
*/
|
|
80
|
+
export function renderPredictedSerialisationLines(collisions) {
|
|
81
|
+
const list = Array.isArray(collisions) ? collisions : [];
|
|
82
|
+
if (list.length === 0) return [];
|
|
83
|
+
const rows = list.map((c) => {
|
|
84
|
+
const scraped = renderScrapeAttribution(c.attribution);
|
|
85
|
+
return `| \`${c.slugs[0]}\` + \`${c.slugs[1]}\` | ${c.paths
|
|
86
|
+
.map((p) => `\`${p}\``)
|
|
87
|
+
.join(', ')} | ${c.source} | ${scraped ? `\`${scraped}\`` : '—'} |`;
|
|
88
|
+
});
|
|
89
|
+
const scrapedOnly = list.filter(
|
|
90
|
+
(c) => c.source === OVERLAP_SOURCES.SCRAPED,
|
|
91
|
+
).length;
|
|
92
|
+
return [
|
|
93
|
+
'',
|
|
94
|
+
`#### ⚠️ Predicted serialisation (${list.length} same-order pair(s))`,
|
|
95
|
+
'',
|
|
96
|
+
'| Stories | Colliding paths | Overlap source | Scraped from |',
|
|
97
|
+
'| --- | --- | --- | --- |',
|
|
98
|
+
...rows,
|
|
99
|
+
'',
|
|
100
|
+
'_The dispatcher compares the **evidence-widened** footprint — declared ' +
|
|
101
|
+
'`changes[]` plus every path named in the title, `## Spec` and the rest ' +
|
|
102
|
+
'of the body — so these pairs are shown in one order above but will be ' +
|
|
103
|
+
`dispatched one at a time. ${scrapedOnly} pair(s) collide only on ` +
|
|
104
|
+
'scraped paths; the "Scraped from" column names the field each such ' +
|
|
105
|
+
'path was read out of, so a shared `## Verify` command is ' +
|
|
106
|
+
'distinguishable from a genuine unpredicted edit target. The guard is ' +
|
|
107
|
+
'deliberately not narrowed to `changes[]`: the declaration is a lower ' +
|
|
108
|
+
'bound (Story #4875) and under-serialising is the worse failure._',
|
|
109
|
+
];
|
|
110
|
+
}
|