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.
Files changed (115) hide show
  1. package/.agents/README.md +5 -5
  2. package/.agents/docs/SDLC.md +75 -107
  3. package/.agents/docs/configuration.md +1 -1
  4. package/.agents/docs/quality-gates.md +8 -7
  5. package/.agents/docs/workflows.md +4 -10
  6. package/.agents/instructions.md +2 -2
  7. package/.agents/schemas/agentrc.schema.json +1 -1
  8. package/.agents/schemas/lifecycle/README.md +2 -1
  9. package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
  10. package/.agents/schemas/risk-verdict.schema.json +14 -1
  11. package/.agents/scripts/README.md +0 -46
  12. package/.agents/scripts/analyze-execution.js +76 -1
  13. package/.agents/scripts/check-doc-links.js +3 -0
  14. package/.agents/scripts/check-workflow-cli-lint.js +208 -0
  15. package/.agents/scripts/epic-audit-recheck.js +1 -1
  16. package/.agents/scripts/epic-deliver-preflight.js +37 -1
  17. package/.agents/scripts/epic-plan-clarity.js +16 -6
  18. package/.agents/scripts/epic-plan-decompose.js +37 -59
  19. package/.agents/scripts/epic-plan-healthcheck.js +39 -6
  20. package/.agents/scripts/epic-plan-spec.js +41 -168
  21. package/.agents/scripts/epic-reconcile.js +7 -1
  22. package/.agents/scripts/lib/Logger.js +4 -4
  23. package/.agents/scripts/lib/audit-suite/index.js +4 -5
  24. package/.agents/scripts/lib/audit-suite/runner.js +6 -4
  25. package/.agents/scripts/lib/audit-suite/selector.js +2 -3
  26. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  27. package/.agents/scripts/lib/command-header.js +20 -0
  28. package/.agents/scripts/lib/config/github.js +1 -3
  29. package/.agents/scripts/lib/config-settings-schema.js +4 -3
  30. package/.agents/scripts/lib/degraded-mode.js +1 -1
  31. package/.agents/scripts/lib/label-constants.js +18 -3
  32. package/.agents/scripts/lib/label-taxonomy.js +14 -3
  33. package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
  34. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
  35. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
  36. package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
  37. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
  38. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
  39. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
  40. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
  41. package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
  42. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
  43. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
  44. package/.agents/scripts/lib/orchestration/plan-context.js +510 -0
  45. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
  46. package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
  47. package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
  48. package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +978 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +191 -0
  51. package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
  52. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
  53. package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
  54. package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
  56. package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
  57. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
  58. package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
  59. package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
  60. package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
  61. package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
  62. package/.agents/scripts/plan-context.js +186 -0
  63. package/.agents/scripts/plan-critics.js +227 -0
  64. package/.agents/scripts/plan-persist.js +383 -0
  65. package/.agents/scripts/pr-watch-with-update.js +8 -7
  66. package/.agents/scripts/run-lint.js +10 -11
  67. package/.agents/scripts/single-story-init.js +22 -0
  68. package/.agents/scripts/story-plan.js +19 -7
  69. package/.agents/scripts/sync-claude-commands.js +10 -5
  70. package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
  71. package/.agents/workflows/audit-documentation.md +5 -7
  72. package/.agents/workflows/audit-lighthouse.md +1 -0
  73. package/.agents/workflows/audit-security.md +1 -0
  74. package/.agents/workflows/audit-to-stories.md +16 -5
  75. package/.agents/workflows/deliver.md +8 -0
  76. package/.agents/workflows/git-deliver.md +76 -243
  77. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  78. package/.agents/workflows/helpers/code-review.md +7 -6
  79. package/.agents/workflows/helpers/deliver-epic.md +16 -4
  80. package/.agents/workflows/helpers/plan-epic-reference.md +109 -96
  81. package/.agents/workflows/helpers/plan-epic.md +246 -999
  82. package/.agents/workflows/helpers/plan-story.md +14 -14
  83. package/.agents/workflows/helpers/scope-triage-gate.md +6 -4
  84. package/.agents/workflows/helpers/single-story-deliver.md +13 -4
  85. package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
  86. package/.agents/workflows/mandrel-update.md +144 -466
  87. package/.agents/workflows/plan.md +73 -143
  88. package/docs/CHANGELOG.md +20 -0
  89. package/lib/cli/registry.js +21 -3
  90. package/package.json +1 -1
  91. package/.agents/schemas/loop-unit.schema.json +0 -70
  92. package/.agents/scripts/assert-branch.js +0 -81
  93. package/.agents/scripts/check-loop-units.js +0 -204
  94. package/.agents/scripts/detect-merges.js +0 -111
  95. package/.agents/scripts/git-pr-quality-gate.js +0 -205
  96. package/.agents/scripts/git-rebase-and-resolve.js +0 -234
  97. package/.agents/scripts/hierarchy-gate.js +0 -192
  98. package/.agents/scripts/hydrate-context.js +0 -179
  99. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
  100. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
  101. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
  102. package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
  103. package/.agents/scripts/loc-delta.js +0 -205
  104. package/.agents/scripts/render-manifest.js +0 -143
  105. package/.agents/scripts/run-audit-suite.js +0 -97
  106. package/.agents/scripts/select-audits.js +0 -155
  107. package/.agents/scripts/update-mutation-baseline.js +0 -189
  108. package/.agents/workflows/explain.md +0 -118
  109. package/.agents/workflows/git-merge-pr.md +0 -377
  110. package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
  111. package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
  112. package/.agents/workflows/loops/README.md +0 -65
  113. package/.agents/workflows/loops/fix-failing-tests.md +0 -74
  114. package/.agents/workflows/loops/nightly-audit.md +0 -81
  115. 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. Phase 8.4 (reachability critic), 8.5
23
- * (pre-mortem critic), and the deterministic ticket validator are
24
- * unconditional this precondition governs only the 8.3 dispatch.
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[] }} `dispatch: false` only
150
- * when the draft matches the Delivery Slicing table 1:1 in count and
151
- * dependency shape; `dispatch: true` (with `reasons`) otherwise, including
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
- * - `.agents/scripts/hydrate-context.js` the only supported CLI wrapper
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
- export function parseHierarchy(body) {
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
- * (`epic-plan-spec.js` / `authoring-context.js`) can produce a session docs
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 (`epic-plan-spec.js` → `authoring-context.js`, Story
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(findings, allowLargeFanOut) {
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
- `[epic-plan-decompose] Persisting a large-fan-out deletion: ` +
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
- `[epic-plan-decompose] ${fanOut.length} Task(s) declare large-fan-out deletions:\n${lines}\n\n` +
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(findings) {
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
- `[epic-plan-decompose] ${soft.length} soft cross-Story conflict finding(s) — review before approving the plan:`,
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({ epicId, epic, runHealthcheckFn }) {
307
+ export async function runHealthcheckGate({
308
+ epicId,
309
+ epic,
310
+ runHealthcheckFn,
311
+ tag = 'epic-plan-decompose',
312
+ }) {
303
313
  Logger.info(
304
- `[epic-plan-decompose] Running post-plan readiness healthcheck for Epic #${epicId}...`,
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
- `[epic-plan-decompose] Healthcheck failed for Epic #${epicId} but '${PLANNING_HEALTHCHECK_WAIVED}' is applied — proceeding with agent::ready handoff. Reason: ${result?.reason ?? '(no reason reported)'}`,
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
- `[epic-plan-decompose] Refusing agent::ready handoff for Epic #${epicId}: ` +
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
- const epic = await provider.getEpic(epicId);
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
- const mapped = mapping[entity.slug];
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
- * lifecycle-diff.js — assertion modes over a lifecycle ledger file for
6
- * the repeatability invariants. The structural-`diff` helpers below are
7
- * exported for unit tests that pin the diff contract.
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
- * Usage:
10
- * node .agents/scripts/lifecycle-diff.js --assert <mode> <ledger>
11
- * `mode` is one of:
12
- * merge-gate-ordering — epic.merge.armed must be preceded
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 `lib/orchestration/lifecycle/trace-logger.js` to avoid coupling
32
- * the CLI to the listener surface.
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
- `lifecycle-diff: malformed JSON in ledger on line ${i + 1}: ${line.slice(0, 80)}`,
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 lifecycle-diff `reconcile-ordering`
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({