mandrel 1.90.0 → 1.92.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/README.md +5 -5
- package/.agents/docs/SDLC.md +75 -107
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/docs/workflows.md +4 -10
- package/.agents/instructions.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/lifecycle/README.md +2 -1
- package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
- package/.agents/schemas/risk-verdict.schema.json +14 -1
- package/.agents/scripts/README.md +0 -46
- package/.agents/scripts/analyze-execution.js +76 -1
- package/.agents/scripts/check-doc-links.js +3 -0
- package/.agents/scripts/check-workflow-cli-lint.js +208 -0
- package/.agents/scripts/epic-audit-recheck.js +1 -1
- package/.agents/scripts/epic-deliver-preflight.js +37 -1
- package/.agents/scripts/epic-plan-clarity.js +16 -6
- package/.agents/scripts/epic-plan-decompose.js +37 -59
- package/.agents/scripts/epic-plan-healthcheck.js +39 -6
- package/.agents/scripts/epic-plan-spec.js +41 -168
- package/.agents/scripts/epic-reconcile.js +7 -1
- package/.agents/scripts/lib/Logger.js +4 -4
- package/.agents/scripts/lib/audit-suite/index.js +4 -5
- package/.agents/scripts/lib/audit-suite/runner.js +6 -4
- package/.agents/scripts/lib/audit-suite/selector.js +2 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/command-header.js +20 -0
- package/.agents/scripts/lib/config/github.js +1 -3
- package/.agents/scripts/lib/config-settings-schema.js +4 -3
- package/.agents/scripts/lib/degraded-mode.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -3
- package/.agents/scripts/lib/label-taxonomy.js +14 -3
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
- package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
- package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +510 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
- package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
- package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
- package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +978 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +191 -0
- package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
- package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
- package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
- package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
- package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
- package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
- package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
- package/.agents/scripts/plan-context.js +186 -0
- package/.agents/scripts/plan-critics.js +227 -0
- package/.agents/scripts/plan-persist.js +383 -0
- package/.agents/scripts/pr-watch-with-update.js +8 -7
- package/.agents/scripts/run-lint.js +10 -11
- package/.agents/scripts/single-story-init.js +22 -0
- package/.agents/scripts/story-plan.js +19 -7
- package/.agents/scripts/sync-claude-commands.js +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
- package/.agents/workflows/audit-documentation.md +5 -7
- package/.agents/workflows/audit-lighthouse.md +1 -0
- package/.agents/workflows/audit-security.md +1 -0
- package/.agents/workflows/audit-to-stories.md +16 -5
- package/.agents/workflows/deliver.md +8 -0
- package/.agents/workflows/git-deliver.md +76 -243
- package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
- package/.agents/workflows/helpers/code-review.md +7 -6
- package/.agents/workflows/helpers/deliver-epic.md +16 -4
- package/.agents/workflows/helpers/plan-epic-reference.md +109 -96
- package/.agents/workflows/helpers/plan-epic.md +246 -999
- package/.agents/workflows/helpers/plan-story.md +14 -14
- package/.agents/workflows/helpers/scope-triage-gate.md +6 -4
- package/.agents/workflows/helpers/single-story-deliver.md +13 -4
- package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
- package/.agents/workflows/mandrel-update.md +144 -466
- package/.agents/workflows/plan.md +73 -143
- package/docs/CHANGELOG.md +20 -0
- package/lib/cli/registry.js +21 -3
- package/package.json +1 -1
- package/.agents/schemas/loop-unit.schema.json +0 -70
- package/.agents/scripts/assert-branch.js +0 -81
- package/.agents/scripts/check-loop-units.js +0 -204
- package/.agents/scripts/detect-merges.js +0 -111
- package/.agents/scripts/git-pr-quality-gate.js +0 -205
- package/.agents/scripts/git-rebase-and-resolve.js +0 -234
- package/.agents/scripts/hierarchy-gate.js +0 -192
- package/.agents/scripts/hydrate-context.js +0 -179
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
- package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
- package/.agents/scripts/loc-delta.js +0 -205
- package/.agents/scripts/render-manifest.js +0 -143
- package/.agents/scripts/run-audit-suite.js +0 -97
- package/.agents/scripts/select-audits.js +0 -155
- package/.agents/scripts/update-mutation-baseline.js +0 -189
- package/.agents/workflows/explain.md +0 -118
- package/.agents/workflows/git-merge-pr.md +0 -377
- package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
- package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
- package/.agents/workflows/loops/README.md +0 -65
- package/.agents/workflows/loops/fix-failing-tests.md +0 -74
- package/.agents/workflows/loops/nightly-audit.md +0 -81
- package/.agents/workflows/loops/watch-ci.md +0 -68
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* Delivery Slicing section, an unparseable "Independent?" cell — resolves to
|
|
20
20
|
* `dispatch: true`. This gate can only ever *save* a dispatch when it is
|
|
21
21
|
* confident the critic has nothing to do; it never disables the critic's
|
|
22
|
-
* ability to catch a real divergence.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* ability to catch a real divergence. Since Epic #4474 PR6 this precondition
|
|
23
|
+
* is one input to the risk/size-conditional dispatch layer
|
|
24
|
+
* (`plan-critic-conditions.js`): reachability (8.4) is a deterministic
|
|
25
|
+
* persist-side check (`plan-reachability.js`) and the pre-mortem critic
|
|
26
|
+
* (8.5) is risk/size-gated; the deterministic ticket validator remains
|
|
27
|
+
* unconditional.
|
|
25
28
|
*
|
|
26
29
|
* Pure, synchronous, no I/O — callers own reading `tickets.json` and the
|
|
27
30
|
* Epic body off disk / the GitHub API.
|
|
@@ -146,10 +149,14 @@ function isBddScaffoldStory(story) {
|
|
|
146
149
|
* top-level `slug` / `depends_on` / `body` (serialized string).
|
|
147
150
|
* @param {string} input.epicBody - The Epic body carrying the folded Tech
|
|
148
151
|
* Spec sections (`## Delivery Slicing` onward).
|
|
149
|
-
* @returns {{ dispatch: boolean, reasons: string[] }}
|
|
150
|
-
* when the draft matches the Delivery Slicing table
|
|
151
|
-
* dependency shape; `dispatch: true` (with `reasons`)
|
|
152
|
-
* every fail-open case.
|
|
152
|
+
* @returns {{ dispatch: boolean, cause: 'match'|'divergence'|'fail-open', reasons: string[] }}
|
|
153
|
+
* `dispatch: false` only when the draft matches the Delivery Slicing table
|
|
154
|
+
* 1:1 in count and dependency shape; `dispatch: true` (with `reasons`)
|
|
155
|
+
* otherwise, including every fail-open case. `cause` distinguishes a
|
|
156
|
+
* **confirmed** divergence (count or dependency-shape mismatch) from the
|
|
157
|
+
* fail-open ambiguity (missing/unparseable table) — the #4474 PR6
|
|
158
|
+
* conditional-dispatch layer treats only the former as a firing condition
|
|
159
|
+
* on small drafts.
|
|
153
160
|
*/
|
|
154
161
|
export function evaluateConsolidationPrecondition({ draftStories, epicBody }) {
|
|
155
162
|
if (!Array.isArray(draftStories)) {
|
|
@@ -162,6 +169,7 @@ export function evaluateConsolidationPrecondition({ draftStories, epicBody }) {
|
|
|
162
169
|
if (!slicing) {
|
|
163
170
|
return {
|
|
164
171
|
dispatch: true,
|
|
172
|
+
cause: 'fail-open',
|
|
165
173
|
reasons: [
|
|
166
174
|
'Delivery Slicing section is missing or unparseable — fail-open to the critic.',
|
|
167
175
|
],
|
|
@@ -175,6 +183,7 @@ export function evaluateConsolidationPrecondition({ draftStories, epicBody }) {
|
|
|
175
183
|
if (slicedStories.length !== slicing.length) {
|
|
176
184
|
return {
|
|
177
185
|
dispatch: true,
|
|
186
|
+
cause: 'divergence',
|
|
178
187
|
reasons: [
|
|
179
188
|
`Story count diverges from Delivery Slicing: ${slicing.length} proposed slice(s) vs ${slicedStories.length} non-scaffold draft Story(ies).`,
|
|
180
189
|
],
|
|
@@ -201,11 +210,12 @@ export function evaluateConsolidationPrecondition({ draftStories, epicBody }) {
|
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
if (reasons.length > 0) {
|
|
204
|
-
return { dispatch: true, reasons };
|
|
213
|
+
return { dispatch: true, cause: 'divergence', reasons };
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
return {
|
|
208
217
|
dispatch: false,
|
|
218
|
+
cause: 'match',
|
|
209
219
|
reasons: [
|
|
210
220
|
`Draft matches Delivery Slicing 1:1 in count and dependency shape (${slicing.length} slice(s)) — skipping the 8.3 consolidation dispatch.`,
|
|
211
221
|
],
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* file I/O decisions, or process.exit(). All I/O choices are delegated
|
|
10
10
|
* to the caller.
|
|
11
11
|
*
|
|
12
|
-
* Consumers
|
|
13
|
-
*
|
|
14
|
-
* (imports hydrateContext; `--emit prompt` writes the raw hydrated prompt)
|
|
12
|
+
* Consumers import `hydrateContext` from this module directly — the former
|
|
13
|
+
* `hydrate-context.js` CLI wrapper was retired (#4482) with no live consumer.
|
|
15
14
|
*
|
|
16
15
|
* @see .agents/scripts/lib/ITicketingProvider.js
|
|
17
16
|
*/
|
|
@@ -153,7 +152,7 @@ function getVersion() {
|
|
|
153
152
|
* @param {string} body
|
|
154
153
|
* @returns {Record<string, number>}
|
|
155
154
|
*/
|
|
156
|
-
|
|
155
|
+
function parseHierarchy(body) {
|
|
157
156
|
const result = {};
|
|
158
157
|
if (!body) return result;
|
|
159
158
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Story #4433 extends this module with {@link ensureDocsDigest}, a shared
|
|
17
17
|
* generate-and-write export so the planner-context surface
|
|
18
|
-
* (`
|
|
18
|
+
* (`plan-context.js` / `authoring-context.js`) can produce a session docs
|
|
19
19
|
* digest without duplicating the mkdir+writeFile plumbing
|
|
20
20
|
* `epic-deliver-prepare.js` already owns for the delivery-children digest.
|
|
21
21
|
*/
|
|
@@ -146,7 +146,7 @@ export async function buildDocsDigest({ docsContextFiles, docsRoot } = {}) {
|
|
|
146
146
|
* write) when there is nothing to digest. This is the single shared
|
|
147
147
|
* generate-and-persist export both digest producers call: the per-Epic
|
|
148
148
|
* delivery-children digest (`epic-deliver-prepare.js`) and the planner-
|
|
149
|
-
* context digest (`
|
|
149
|
+
* context digest (`plan-context.js` → `authoring-context.js`, Story
|
|
150
150
|
* #4433). Callers own path construction (temp-root layout, epic id, etc.)
|
|
151
151
|
* so both surfaces can keep — or deliberately share — their own convention;
|
|
152
152
|
* this function only owns "build digest, ensure parent dir, write file".
|
|
@@ -104,6 +104,10 @@ export function validateTickets(tickets, config, opts = {}) {
|
|
|
104
104
|
const validated = validateAndNormalizeTickets(tickets, {
|
|
105
105
|
baseBranchRef,
|
|
106
106
|
conflictPolicy,
|
|
107
|
+
// Thread the repo cwd into the AC-freshness / file-assumption git
|
|
108
|
+
// probes (#4474 PR7) — without it they silently ran against
|
|
109
|
+
// process.cwd(), which is only the repo root by coincidence.
|
|
110
|
+
cwd: opts.cwd,
|
|
107
111
|
});
|
|
108
112
|
validateTaskBodies(validated);
|
|
109
113
|
return validated;
|
|
@@ -240,13 +240,17 @@ export async function runDecomposePhase(
|
|
|
240
240
|
* @param {typeof defaultRunPlanHealthcheck} args.runHealthcheckFn
|
|
241
241
|
* @returns {Promise<{ ok: boolean, waived?: boolean, reason?: string|null }>}
|
|
242
242
|
*/
|
|
243
|
-
function enforceFanOutGate(
|
|
243
|
+
export function enforceFanOutGate(
|
|
244
|
+
findings,
|
|
245
|
+
allowLargeFanOut,
|
|
246
|
+
tag = 'epic-plan-decompose',
|
|
247
|
+
) {
|
|
244
248
|
const fanOut = (findings ?? []).filter((f) => f.kind === 'fan-out-warning');
|
|
245
249
|
if (fanOut.length === 0) return;
|
|
246
250
|
if (allowLargeFanOut) {
|
|
247
251
|
for (const f of fanOut) {
|
|
248
252
|
Logger.warn(
|
|
249
|
-
`[
|
|
253
|
+
`[${tag}] Persisting a large-fan-out deletion: ` +
|
|
250
254
|
`Task "${f.taskSlug}" deletes "${f.path}" with ${f.callSiteCount} ` +
|
|
251
255
|
`call site(s) (threshold ${f.threshold}). Operator override --allow-large-fan-out.`,
|
|
252
256
|
);
|
|
@@ -260,7 +264,7 @@ function enforceFanOutGate(findings, allowLargeFanOut) {
|
|
|
260
264
|
)
|
|
261
265
|
.join('\n');
|
|
262
266
|
throw new Error(
|
|
263
|
-
`[
|
|
267
|
+
`[${tag}] ${fanOut.length} Task(s) declare large-fan-out deletions:\n${lines}\n\n` +
|
|
264
268
|
`Split each deletion into a subsystem-by-subsystem migration across multiple Stories, ` +
|
|
265
269
|
`or rerun --allow-large-fan-out after confirming the deletion is intentional.`,
|
|
266
270
|
);
|
|
@@ -284,24 +288,30 @@ function enforceFanOutGate(findings, allowLargeFanOut) {
|
|
|
284
288
|
*
|
|
285
289
|
* @param {object[]} findings
|
|
286
290
|
*/
|
|
287
|
-
function surfaceSoftConflictFindings(
|
|
291
|
+
export function surfaceSoftConflictFindings(
|
|
292
|
+
findings,
|
|
293
|
+
tag = 'epic-plan-decompose',
|
|
294
|
+
) {
|
|
288
295
|
const soft = (findings ?? []).filter(
|
|
289
296
|
(f) => f?.severity === 'soft' && f?.kind !== 'fan-out-warning',
|
|
290
297
|
);
|
|
291
298
|
if (soft.length === 0) return;
|
|
292
299
|
Logger.warn(
|
|
293
|
-
`[
|
|
300
|
+
`[${tag}] ${soft.length} soft cross-Story conflict finding(s) — review before approving the plan:`,
|
|
294
301
|
);
|
|
295
302
|
for (const finding of soft) {
|
|
296
|
-
Logger.warn(
|
|
297
|
-
`[epic-plan-decompose] soft conflict: ${renderHardConflictError(finding)}`,
|
|
298
|
-
);
|
|
303
|
+
Logger.warn(`[${tag}] soft conflict: ${renderHardConflictError(finding)}`);
|
|
299
304
|
}
|
|
300
305
|
}
|
|
301
306
|
|
|
302
|
-
async function runHealthcheckGate({
|
|
307
|
+
export async function runHealthcheckGate({
|
|
308
|
+
epicId,
|
|
309
|
+
epic,
|
|
310
|
+
runHealthcheckFn,
|
|
311
|
+
tag = 'epic-plan-decompose',
|
|
312
|
+
}) {
|
|
303
313
|
Logger.info(
|
|
304
|
-
`[
|
|
314
|
+
`[${tag}] Running post-plan readiness healthcheck for Epic #${epicId}...`,
|
|
305
315
|
);
|
|
306
316
|
let result;
|
|
307
317
|
try {
|
|
@@ -321,13 +331,13 @@ async function runHealthcheckGate({ epicId, epic, runHealthcheckFn }) {
|
|
|
321
331
|
const waived = labels.includes(PLANNING_HEALTHCHECK_WAIVED);
|
|
322
332
|
if (waived) {
|
|
323
333
|
Logger.warn(
|
|
324
|
-
`[
|
|
334
|
+
`[${tag}] Healthcheck failed for Epic #${epicId} but '${PLANNING_HEALTHCHECK_WAIVED}' is applied — proceeding with agent::ready handoff. Reason: ${result?.reason ?? '(no reason reported)'}`,
|
|
325
335
|
);
|
|
326
336
|
return { ok: false, waived: true, reason: result?.reason ?? null };
|
|
327
337
|
}
|
|
328
338
|
|
|
329
339
|
throw new Error(
|
|
330
|
-
`[
|
|
340
|
+
`[${tag}] Refusing agent::ready handoff for Epic #${epicId}: ` +
|
|
331
341
|
`post-plan healthcheck failed (${result?.reason ?? '(no reason reported)'}). ` +
|
|
332
342
|
`Resolve the failing check(s), or apply the '${PLANNING_HEALTHCHECK_WAIVED}' ` +
|
|
333
343
|
`label to the Epic to override and rerun the persist phase.`,
|
|
@@ -113,7 +113,12 @@ export async function buildAuthoringContext(
|
|
|
113
113
|
settings = {},
|
|
114
114
|
opts = {},
|
|
115
115
|
) {
|
|
116
|
-
|
|
116
|
+
// Epic #4474 (M3 PR2): `opts.epic` is an optional prefetched Epic object
|
|
117
|
+
// so a caller that already holds the issue (the folded `plan-context.js`
|
|
118
|
+
// envelope build, which also needs the raw body for clarity scoring and
|
|
119
|
+
// re-plan detection) does not pay a second provider fetch. Absent, the
|
|
120
|
+
// fetch behaviour is unchanged.
|
|
121
|
+
const epic = opts.epic ?? (await provider.getEpic(epicId));
|
|
117
122
|
if (!epic) {
|
|
118
123
|
throw new Error(`Epic #${epicId} not found.`);
|
|
119
124
|
}
|
|
@@ -568,7 +568,20 @@ export function diff({ spec, state, ghState } = {}) {
|
|
|
568
568
|
|
|
569
569
|
for (const entity of flattenSpec(spec)) {
|
|
570
570
|
seenSpecSlugs.add(entity.slug);
|
|
571
|
-
|
|
571
|
+
// Epic #4474 (PR3) — a mapping entry without a numeric `issueNumber`
|
|
572
|
+
// is a partial-failure tombstone: the apply's state writer projects an
|
|
573
|
+
// entry per spec slug, but only completed creations carry a number
|
|
574
|
+
// (`buildState` seeds new slugs with `issueNumber: null`). Treating
|
|
575
|
+
// such an entry as "mapped" made a `--resume` after a rate-limit crash
|
|
576
|
+
// silently skip every failed creation (no Create emitted; the content
|
|
577
|
+
// hash matches, so no Update either — a lossy resume). A numberless
|
|
578
|
+
// entry is therefore unmapped here → Create. No duplicate risk: any
|
|
579
|
+
// live same-title issue was already reseeded onto the slug (with its
|
|
580
|
+
// number) by `reseedMappingFromGh` before the diff runs.
|
|
581
|
+
const mappedEntry = mapping[entity.slug];
|
|
582
|
+
const mapped = Number.isInteger(mappedEntry?.issueNumber)
|
|
583
|
+
? mappedEntry
|
|
584
|
+
: undefined;
|
|
572
585
|
|
|
573
586
|
if (!mapped) {
|
|
574
587
|
plan.creates.push(
|
|
@@ -630,9 +643,12 @@ export function diff({ spec, state, ghState } = {}) {
|
|
|
630
643
|
}
|
|
631
644
|
}
|
|
632
645
|
|
|
633
|
-
// Closes — anything in mapping not seen in spec.
|
|
646
|
+
// Closes — anything in mapping not seen in spec. A numberless
|
|
647
|
+
// partial-failure tombstone (see above) has no live issue to close;
|
|
648
|
+
// dropping it from the spec simply drops the entry.
|
|
634
649
|
for (const [slug, mapped] of Object.entries(mapping)) {
|
|
635
650
|
if (seenSpecSlugs.has(slug)) continue;
|
|
651
|
+
if (!Number.isInteger(mapped?.issueNumber)) continue;
|
|
636
652
|
plan.closes.push(
|
|
637
653
|
closeOp({
|
|
638
654
|
slug,
|
|
@@ -1,35 +1,22 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* node:coverage ignore file */
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* ledger-diff.js — structural-diff and ordering-assertion helpers over a
|
|
3
|
+
* lifecycle ledger (`temp/epic-<id>/lifecycle.ndjson`) for the
|
|
4
|
+
* repeatability invariants.
|
|
8
5
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* by epic.merge.ready (same seqId
|
|
14
|
-
* chain; armed.seqId > ready.seqId).
|
|
15
|
-
* reconcile-ordering — pr.created must be preceded by
|
|
16
|
-
* acceptance.reconcile.ok.
|
|
17
|
-
* — exits 0 on pass; exits 1 with a structured message on fail.
|
|
6
|
+
* Relocated from the retired `.agents/scripts/lifecycle-diff.js` CLI
|
|
7
|
+
* (#4482): the assertion helpers are the live surface — they pin the
|
|
8
|
+
* cross-listener ordering contracts in the lifecycle invariant tests —
|
|
9
|
+
* while the CLI wrapper had no workflow, script, or CI consumer.
|
|
18
10
|
*
|
|
19
11
|
* Invariants are derived from Tech Spec #2189 § Repeatability Acceptance
|
|
20
12
|
* Criteria.
|
|
21
13
|
*/
|
|
22
14
|
|
|
23
|
-
import { readFileSync } from 'node:fs';
|
|
24
|
-
import { parseArgs } from 'node:util';
|
|
25
|
-
|
|
26
|
-
import { runAsCli } from './lib/cli-utils.js';
|
|
27
|
-
|
|
28
15
|
/**
|
|
29
16
|
* Parse an NDJSON lifecycle ledger into an array of records. Blank
|
|
30
17
|
* lines tolerated; malformed JSON throws with line number. Duplicated
|
|
31
|
-
* from `
|
|
32
|
-
*
|
|
18
|
+
* from `trace-logger.js` to keep the diff surface decoupled from the
|
|
19
|
+
* listener surface.
|
|
33
20
|
*/
|
|
34
21
|
export function parseLedgerText(text) {
|
|
35
22
|
const out = [];
|
|
@@ -41,7 +28,7 @@ export function parseLedgerText(text) {
|
|
|
41
28
|
out.push(JSON.parse(line));
|
|
42
29
|
} catch (_err) {
|
|
43
30
|
throw new Error(
|
|
44
|
-
`
|
|
31
|
+
`ledger-diff: malformed JSON in ledger on line ${i + 1}: ${line.slice(0, 80)}`,
|
|
45
32
|
);
|
|
46
33
|
}
|
|
47
34
|
}
|
|
@@ -151,56 +138,3 @@ export function assertReconcileOrdering(records) {
|
|
|
151
138
|
}
|
|
152
139
|
return { ok: true };
|
|
153
140
|
}
|
|
154
|
-
|
|
155
|
-
const ASSERTIONS = new Map([
|
|
156
|
-
['merge-gate-ordering', assertMergeGateOrdering],
|
|
157
|
-
['reconcile-ordering', assertReconcileOrdering],
|
|
158
|
-
]);
|
|
159
|
-
|
|
160
|
-
function loadLedger(p) {
|
|
161
|
-
return parseLedgerText(readFileSync(p, 'utf8'));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async function main() {
|
|
165
|
-
const { values, positionals } = parseArgs({
|
|
166
|
-
options: {
|
|
167
|
-
assert: { type: 'string' },
|
|
168
|
-
},
|
|
169
|
-
allowPositionals: true,
|
|
170
|
-
args: process.argv.slice(2),
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// --assert <mode> <ledger>
|
|
174
|
-
if (!values.assert) {
|
|
175
|
-
process.stderr.write('Usage: lifecycle-diff --assert <mode> <ledger>\n');
|
|
176
|
-
return 2;
|
|
177
|
-
}
|
|
178
|
-
if (positionals.length !== 1) {
|
|
179
|
-
process.stderr.write(
|
|
180
|
-
'lifecycle-diff --assert <mode> requires exactly one positional ledger path\n',
|
|
181
|
-
);
|
|
182
|
-
return 2;
|
|
183
|
-
}
|
|
184
|
-
const assertion = ASSERTIONS.get(values.assert);
|
|
185
|
-
if (!assertion) {
|
|
186
|
-
process.stderr.write(
|
|
187
|
-
`lifecycle-diff: unknown --assert mode "${values.assert}". Valid: ${[...ASSERTIONS.keys()].join(', ')}\n`,
|
|
188
|
-
);
|
|
189
|
-
return 2;
|
|
190
|
-
}
|
|
191
|
-
const records = loadLedger(positionals[0]);
|
|
192
|
-
const result = assertion(records);
|
|
193
|
-
if (result.ok) {
|
|
194
|
-
process.stdout.write(`[lifecycle-diff] PASS ${values.assert}\n`);
|
|
195
|
-
return 0;
|
|
196
|
-
}
|
|
197
|
-
process.stderr.write(
|
|
198
|
-
`[lifecycle-diff] FAIL ${values.assert}: ${result.reason}\n`,
|
|
199
|
-
);
|
|
200
|
-
return 1;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
await runAsCli(import.meta.url, main, {
|
|
204
|
-
source: 'lifecycle-diff',
|
|
205
|
-
propagateExitCode: true,
|
|
206
|
-
});
|
|
@@ -199,7 +199,7 @@ export class AcceptanceReconciler {
|
|
|
199
199
|
? epicIdFromPayload
|
|
200
200
|
: this.epicId;
|
|
201
201
|
|
|
202
|
-
// 1. Start emit. The downstream
|
|
202
|
+
// 1. Start emit. The downstream ledger-diff `reconcile-ordering`
|
|
203
203
|
// assert checks for `acceptance.reconcile.ok` before `pr.created`,
|
|
204
204
|
// but operators want to see the `.start` marker in the trace too.
|
|
205
205
|
try {
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
openOrLocatePr as defaultOpenOrLocatePr,
|
|
83
83
|
} from '../../finalize/open-or-locate-pr.js';
|
|
84
84
|
import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/post-handoff-comment.js';
|
|
85
|
+
import { probeRemoteBranch as defaultProbeRemoteBranch } from '../../remote-verifier.js';
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* Build the production default `runFinalizeFn` that composes the
|
|
@@ -103,12 +104,22 @@ import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/
|
|
|
103
104
|
* contract is identical and `markPrReady` is a no-op on an already-ready
|
|
104
105
|
* PR, so replay stays idempotent.
|
|
105
106
|
*
|
|
107
|
+
* Issue #4483 — deterministic land-or-block backstop. Before opening (or
|
|
108
|
+
* readying) the PR, finalize asserts the delivery branch `epic/<id>`
|
|
109
|
+
* actually exists on origin. A delivery that was never pushed — e.g. an
|
|
110
|
+
* agent that built the Epic inline on local `main` and skipped the
|
|
111
|
+
* orchestration — MUST surface as an explicit
|
|
112
|
+
* `delivery-branch-missing-on-origin` blocker (which keeps the Epic at
|
|
113
|
+
* `agent::blocked`), never a declared success. The probe is bounded
|
|
114
|
+
* (timeout + SIGKILL) so a hung remote degrades to a blocker too.
|
|
115
|
+
*
|
|
106
116
|
* @param {{
|
|
107
117
|
* provider?: object|null,
|
|
108
118
|
* earlyPr?: boolean,
|
|
109
119
|
* openOrLocatePrFn?: typeof defaultOpenOrLocatePr,
|
|
110
120
|
* markPrReadyFn?: typeof defaultMarkPrReady,
|
|
111
121
|
* postHandoffCommentFn?: typeof defaultPostHandoffComment,
|
|
122
|
+
* probeRemoteBranchFn?: typeof defaultProbeRemoteBranch,
|
|
112
123
|
* }} deps
|
|
113
124
|
*/
|
|
114
125
|
export function composeBusOwnedFinalize(deps = {}) {
|
|
@@ -116,6 +127,8 @@ export function composeBusOwnedFinalize(deps = {}) {
|
|
|
116
127
|
const markPrReadyFn = deps.markPrReadyFn ?? defaultMarkPrReady;
|
|
117
128
|
const postHandoffCommentFn =
|
|
118
129
|
deps.postHandoffCommentFn ?? defaultPostHandoffComment;
|
|
130
|
+
const probeRemoteBranchFn =
|
|
131
|
+
deps.probeRemoteBranchFn ?? defaultProbeRemoteBranch;
|
|
119
132
|
const provider = deps.provider ?? null;
|
|
120
133
|
const earlyPr = deps.earlyPr !== false;
|
|
121
134
|
|
|
@@ -128,6 +141,25 @@ export function composeBusOwnedFinalize(deps = {}) {
|
|
|
128
141
|
},
|
|
129
142
|
};
|
|
130
143
|
}
|
|
144
|
+
|
|
145
|
+
// Issue #4483 backstop — the delivery branch MUST be on origin before
|
|
146
|
+
// finalize declares any success. A never-pushed branch is the silent
|
|
147
|
+
// local-main failure shape; block explicitly with the probe detail.
|
|
148
|
+
let branchProbe;
|
|
149
|
+
try {
|
|
150
|
+
branchProbe = probeRemoteBranchFn({ branch: `epic/${epicId}`, cwd });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
branchProbe = { exists: false, detail: err?.message ?? String(err) };
|
|
153
|
+
}
|
|
154
|
+
if (!branchProbe.exists) {
|
|
155
|
+
return {
|
|
156
|
+
blocker: {
|
|
157
|
+
reason: 'delivery-branch-missing-on-origin',
|
|
158
|
+
detail: `epic/${epicId} is not on origin — the delivery was never pushed; refusing to finalize (issue #4483). Probe: ${branchProbe.detail}`,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
131
163
|
let openResult;
|
|
132
164
|
try {
|
|
133
165
|
openResult = await openOrLocatePrFn({
|