mandrel 1.90.0 → 1.91.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.
@@ -11,7 +11,12 @@
11
11
  * `storyCount`, `installCostSeconds`, `dependencyDepth`,
12
12
  * `githubApiRequests`, `claudeQuotaTokens`, plus `breaches`
13
13
  * (the non-empty subset of `delivery.preflight.max*` thresholds the
14
- * estimate exceeds).
14
+ * estimate exceeds), plus `remoteVerified` / `remoteProbe` — the
15
+ * issue #4483 deterministic remote evidence (`git remote get-url
16
+ * origin` + bounded `git ls-remote origin HEAD`). On
17
+ * `remoteVerified: false` the workflow MUST flip the Epic to
18
+ * `agent::blocked` quoting `remoteProbe.detail` — inline delivery to
19
+ * local `main` is never a sanctioned fallback.
15
20
  * 2. When `--post` is set (and `--dry-run` is not), an upserted
16
21
  * `delivery-preflight` structured comment on the Epic ticket so
17
22
  * reviewers reading the Epic discover the same numbers without
@@ -66,6 +71,7 @@ import {
66
71
  computeBaseSha,
67
72
  writePreflightCache,
68
73
  } from './lib/orchestration/preflight-cache.js';
74
+ import { verifyRemote } from './lib/orchestration/remote-verifier.js';
69
75
  import { upsertStructuredComment } from './lib/orchestration/ticketing.js';
70
76
  import { createProvider } from './lib/provider-factory.js';
71
77
 
@@ -218,10 +224,23 @@ export function renderPreflightBody({
218
224
  estimate,
219
225
  breaches,
220
226
  thresholds,
227
+ remote,
221
228
  }) {
222
229
  const lines = [];
223
230
  lines.push(`### 🛫 Delivery preflight — Epic #${epicId}`);
224
231
  lines.push('');
232
+ // Issue #4483 — verified remote evidence at entry. Rendered before the
233
+ // metric table so a reviewer (and the orchestrating agent) sees the
234
+ // land-or-block fact first. Omitted when the caller has no probe result
235
+ // (legacy callers / tests that only exercise the estimate math).
236
+ if (remote) {
237
+ lines.push(
238
+ remote.remoteVerified
239
+ ? `✅ **remoteVerified: true** — ${remote.detail}`
240
+ : `⛔ **remoteVerified: false** — ${remote.detail} — \`/deliver\` MUST transition the Epic to \`agent::blocked\` quoting this probe; inline delivery to local \`main\` is forbidden.`,
241
+ );
242
+ lines.push('');
243
+ }
225
244
  lines.push('| Metric | Estimate | Threshold |');
226
245
  lines.push('| --- | ---: | ---: |');
227
246
  const rows = [
@@ -276,6 +295,7 @@ export function renderPreflightBody({
276
295
  * perStoryClaudeTokens?: number,
277
296
  * injectedProvider?: object,
278
297
  * injectedConfig?: object,
298
+ * verifyRemoteFn?: typeof verifyRemote,
279
299
  * }} args
280
300
  */
281
301
  export async function runPreflight({
@@ -288,6 +308,7 @@ export async function runPreflight({
288
308
  perStoryClaudeTokens,
289
309
  injectedProvider,
290
310
  injectedConfig,
311
+ verifyRemoteFn = verifyRemote,
291
312
  }) {
292
313
  if (!Number.isInteger(epicId) || epicId <= 0) {
293
314
  throw new TypeError('runPreflight: --epic must be a positive integer');
@@ -297,6 +318,13 @@ export async function runPreflight({
297
318
  const provider = injectedProvider ?? createProvider(config);
298
319
  const thresholds = getPreflight(config);
299
320
 
321
+ // Issue #4483 — deterministic remote evidence at entry. Probe BEFORE any
322
+ // provider work so the envelope always carries verified fact (not the
323
+ // agent's perception) about whether a live, pushable `origin` exists.
324
+ // The CLI records; the `/deliver` workflow owns the `agent::blocked`
325
+ // transition on `remoteVerified: false` (same split as breach handling).
326
+ const remote = verifyRemoteFn({ cwd });
327
+
300
328
  // Compose the same two phases /deliver Phase 1 runs so the
301
329
  // preflight numbers match the actual dispatch plan.
302
330
  const ctx = { epicId, provider };
@@ -348,6 +376,13 @@ export async function runPreflight({
348
376
  thresholds,
349
377
  baseSha,
350
378
  cacheWritten,
379
+ // Issue #4483 — verified remote evidence. `remoteVerified: false`
380
+ // REQUIRES the workflow to block explicitly (never build inline).
381
+ remoteVerified: remote.remoteVerified,
382
+ remoteProbe: {
383
+ remoteUrl: remote.remoteUrl,
384
+ detail: remote.detail,
385
+ },
351
386
  };
352
387
 
353
388
  if (post && !dryRun) {
@@ -356,6 +391,7 @@ export async function runPreflight({
356
391
  estimate,
357
392
  breaches,
358
393
  thresholds,
394
+ remote,
359
395
  });
360
396
  await upsertStructuredComment(provider, epicId, 'delivery-preflight', body);
361
397
  envelope.commentUpserted = true;
@@ -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({
@@ -0,0 +1,165 @@
1
+ // .agents/scripts/lib/orchestration/remote-verifier.js
2
+ /**
3
+ * remote-verifier.js — deterministic "is there a live, pushable remote?"
4
+ * evidence for the delivery entry seams. Issue #4483.
5
+ *
6
+ * `/deliver` could silently shortcut the entire orchestration — building
7
+ * the delivery inline and committing to local `main` without pushing —
8
+ * when the driving agent *perceived* the environment had no live GitHub
9
+ * remote. The judgment was vibes, not fact. This module gives the entry
10
+ * seams (`epic-deliver-preflight.js`, `single-story-init.js`) a verified
11
+ * probe result to record in their envelopes so the workflow can branch on
12
+ * `remoteVerified: true|false` deterministically: use the remote, or
13
+ * transition to `agent::blocked` quoting the probe output — never a
14
+ * silent local build.
15
+ *
16
+ * Two probes, both bounded (a hung git spawn must not park the entry
17
+ * seam — mirrors the `ghPrListHead` timeout contract in `finalizer.js`):
18
+ *
19
+ * 1. `git remote get-url origin` — is an `origin` remote configured?
20
+ * 2. `git ls-remote origin HEAD` — is it reachable with current auth?
21
+ *
22
+ * `remoteVerified` is true only when BOTH succeed. The CLI callers do
23
+ * NOT flip labels on a false result — the workflow owns the
24
+ * `agent::blocked` transition (same division of labour as the preflight
25
+ * breach handling).
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process';
29
+
30
+ /**
31
+ * Bounded timeout for each git probe. `ls-remote` is a network call;
32
+ * SIGKILL at the bound so an unreachable or hanging remote degrades to a
33
+ * deterministic `remoteVerified: false` instead of a stuck entry seam.
34
+ */
35
+ export const REMOTE_PROBE_TIMEOUT_MS = 30_000;
36
+
37
+ function runProbe({ args, cwd, spawnFn, timeoutMs }) {
38
+ const result = spawnFn('git', args, {
39
+ cwd,
40
+ encoding: 'utf-8',
41
+ shell: false,
42
+ timeout: timeoutMs,
43
+ killSignal: 'SIGKILL',
44
+ });
45
+ return {
46
+ args: ['git', ...args].join(' '),
47
+ status: result.status ?? 1,
48
+ stdout: (result.stdout ?? '').trim(),
49
+ stderr: (result.stderr ?? '').trim(),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Probe the `origin` remote for existence + reachability.
55
+ *
56
+ * @param {{
57
+ * cwd?: string,
58
+ * spawnFn?: typeof spawnSync,
59
+ * timeoutMs?: number,
60
+ * }} [opts]
61
+ * @returns {{
62
+ * remoteVerified: boolean,
63
+ * remoteUrl: string|null,
64
+ * detail: string,
65
+ * probes: {
66
+ * getUrl: { args: string, status: number, stdout: string, stderr: string },
67
+ * lsRemote: { args: string, status: number, stdout: string, stderr: string }|null,
68
+ * },
69
+ * }}
70
+ */
71
+ export function verifyRemote({
72
+ cwd = process.cwd(),
73
+ spawnFn = spawnSync,
74
+ timeoutMs = REMOTE_PROBE_TIMEOUT_MS,
75
+ } = {}) {
76
+ const getUrl = runProbe({
77
+ args: ['remote', 'get-url', 'origin'],
78
+ cwd,
79
+ spawnFn,
80
+ timeoutMs,
81
+ });
82
+ if (getUrl.status !== 0) {
83
+ return {
84
+ remoteVerified: false,
85
+ remoteUrl: null,
86
+ detail: `no 'origin' remote configured — \`${getUrl.args}\` exited ${getUrl.status}: ${getUrl.stderr || '(no stderr)'}`,
87
+ probes: { getUrl, lsRemote: null },
88
+ };
89
+ }
90
+ const remoteUrl = getUrl.stdout;
91
+
92
+ const lsRemote = runProbe({
93
+ args: ['ls-remote', 'origin', 'HEAD'],
94
+ cwd,
95
+ spawnFn,
96
+ timeoutMs,
97
+ });
98
+ if (lsRemote.status !== 0 || lsRemote.stdout.length === 0) {
99
+ return {
100
+ remoteVerified: false,
101
+ remoteUrl,
102
+ detail: `'origin' (${remoteUrl}) is unreachable — \`${lsRemote.args}\` exited ${lsRemote.status}: ${lsRemote.stderr || '(empty ls-remote output)'}`,
103
+ probes: { getUrl, lsRemote },
104
+ };
105
+ }
106
+
107
+ return {
108
+ remoteVerified: true,
109
+ remoteUrl,
110
+ detail: `origin verified (${remoteUrl}); ls-remote HEAD → ${lsRemote.stdout.split(/\s+/)[0]}`,
111
+ probes: { getUrl, lsRemote },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Probe whether a specific branch exists on `origin` — the deterministic
117
+ * finalize backstop (issue #4483 fix direction 3): a delivery branch that
118
+ * was never pushed MUST fail finalize with an explicit blocker rather
119
+ * than let the run declare success.
120
+ *
121
+ * Distinct from `git-branch-lifecycle.js#branchExistsRemotely` in two
122
+ * load-bearing ways: the spawn is bounded (timeout + SIGKILL, so a hung
123
+ * remote cannot park the finalize seam) and the result carries the probe
124
+ * detail for the blocker envelope instead of a bare boolean.
125
+ *
126
+ * @param {{
127
+ * branch: string,
128
+ * cwd?: string,
129
+ * spawnFn?: typeof spawnSync,
130
+ * timeoutMs?: number,
131
+ * }} opts
132
+ * @returns {{ exists: boolean, detail: string }}
133
+ */
134
+ export function probeRemoteBranch({
135
+ branch,
136
+ cwd = process.cwd(),
137
+ spawnFn = spawnSync,
138
+ timeoutMs = REMOTE_PROBE_TIMEOUT_MS,
139
+ }) {
140
+ if (typeof branch !== 'string' || branch.length === 0) {
141
+ throw new TypeError('probeRemoteBranch: branch must be a non-empty string');
142
+ }
143
+ const probe = runProbe({
144
+ args: ['ls-remote', '--heads', 'origin', branch],
145
+ cwd,
146
+ spawnFn,
147
+ timeoutMs,
148
+ });
149
+ if (probe.status !== 0) {
150
+ return {
151
+ exists: false,
152
+ detail: `\`${probe.args}\` exited ${probe.status}: ${probe.stderr || '(no stderr)'}`,
153
+ };
154
+ }
155
+ if (probe.stdout.length === 0) {
156
+ return {
157
+ exists: false,
158
+ detail: `\`${probe.args}\` found no ref — ${branch} was never pushed to origin`,
159
+ };
160
+ }
161
+ return {
162
+ exists: true,
163
+ detail: `${branch} on origin at ${probe.stdout.split(/\s+/)[0]}`,
164
+ };
165
+ }
@@ -5,6 +5,16 @@
5
5
  * subsequent fetches are cheap. A push failure raises so the caller
6
6
  * fails non-zero — the operator must resolve before retrying.
7
7
  *
8
+ * Issue #4483 — this phase IS the standalone path's deterministic
9
+ * land-or-block backstop (the counterpart to the Epic finalize seam's
10
+ * `delivery-branch-missing-on-origin` blocker). `git push` exits 0 only
11
+ * after origin has accepted the ref, so the throw below is the origin
12
+ * assertion: every later close phase (PR open, auto-merge, the
13
+ * `closeResult` success envelope) is unreachable unless the Story branch
14
+ * verifiably landed on origin. No post-push `ls-remote` re-probe is
15
+ * added because it would re-ask a question the push exit code already
16
+ * answered authoritatively.
17
+ *
8
18
  * `gitSync` is accepted as an injected dependency rather than statically
9
19
  * imported so the caller's (cache-busted) binding wins. The
10
20
  * `single-story-close.js` orchestrator owns the static import; test
@@ -59,6 +59,7 @@ import {
59
59
  executeFastForward,
60
60
  planFastForward,
61
61
  } from './lib/orchestration/git-cleanup/phases/fast-forward.js';
62
+ import { verifyRemote } from './lib/orchestration/remote-verifier.js';
62
63
  import { acquireStoryLease } from './lib/orchestration/single-story-lease-guard.js';
63
64
  import {
64
65
  STATE_LABELS,
@@ -446,6 +447,7 @@ export async function runSingleStoryInit({
446
447
  injectedAcquireLease,
447
448
  steal = false,
448
449
  leaseNow,
450
+ injectedVerifyRemote,
449
451
  } = {}) {
450
452
  const parsed =
451
453
  storyIdParam !== undefined
@@ -487,6 +489,20 @@ export async function runSingleStoryInit({
487
489
  );
488
490
  progress('INIT', `Initializing standalone Story #${storyId}...`);
489
491
 
492
+ // Issue #4483 — deterministic remote evidence at the standalone entry
493
+ // seam (the counterpart to `epic-deliver-preflight.js`'s probe). The
494
+ // probe is read-only, so it runs under --dry-run too. The CLI records
495
+ // the fact; the workflow owns the `agent::blocked` transition on
496
+ // `remoteVerified: false` — inline delivery to local `main` is never a
497
+ // sanctioned fallback.
498
+ const remote = (injectedVerifyRemote ?? verifyRemote)({ cwd });
499
+ progress(
500
+ 'REMOTE',
501
+ remote.remoteVerified
502
+ ? `✅ remoteVerified=true — ${remote.detail}`
503
+ : `⛔ remoteVerified=false — ${remote.detail}`,
504
+ );
505
+
490
506
  const story = await provider.getTicket(storyId);
491
507
  assertDeliverableStory(story, storyId);
492
508
 
@@ -563,6 +579,9 @@ export async function runSingleStoryInit({
563
579
  dependenciesInstalled,
564
580
  installFailed: installStatus.status === 'failed',
565
581
  dryRun,
582
+ // Issue #4483 — verified remote evidence for the orchestrating agent.
583
+ remoteVerified: remote.remoteVerified,
584
+ remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
566
585
  };
567
586
 
568
587
  // Upsert the `story-init` structured comment + flip Story to executing.
@@ -632,6 +651,8 @@ export function renderSingleStoryInitComment(result) {
632
651
  worktreeCreated: result.worktreeCreated,
633
652
  dependenciesInstalled: result.dependenciesInstalled,
634
653
  installStatus: result.installStatus,
654
+ remoteVerified: result.remoteVerified,
655
+ remoteProbe: result.remoteProbe,
635
656
  };
636
657
  return [
637
658
  '## Story init (standalone)',
@@ -641,6 +662,7 @@ export function renderSingleStoryInitComment(result) {
641
662
  `- **baseBranch:** \`${result.baseBranch}\``,
642
663
  `- **workCwd:** \`${result.workCwd}\``,
643
664
  `- **worktreeEnabled:** \`${result.worktreeEnabled}\``,
665
+ `- **remoteVerified:** \`${result.remoteVerified}\``,
644
666
  `- **dependenciesInstalled:** \`${result.dependenciesInstalled}\``,
645
667
  '',
646
668
  '```json',
@@ -131,6 +131,14 @@ the standalone segment; segments themselves remain strictly sequential.
131
131
 
132
132
  ## Constraints
133
133
 
134
+ - **Land or block — never a silent local build (issue #4483).** The
135
+ helpers' orchestration path (worktrees, `story-<id>`/`epic/<id>` branches,
136
+ close-validation, PR) is the ONLY sanctioned delivery mechanism.
137
+ Executing story slices inline in this session and/or committing the
138
+ delivery to local `main` is expressly forbidden, regardless of how the
139
+ environment looks. Each path surfaces verified remote evidence
140
+ (`remoteVerified`) at entry; on `false`, transition the ticket to
141
+ `agent::blocked` quoting `remoteProbe.detail` and halt.
134
142
  - `/deliver` requires planned tickets: Epics at `agent::ready` (the
135
143
  Epic helper's preflight enforces this, per segment) or well-formed
136
144
  standalone Stories. Planning happens in [`/plan`](plan.md); the
@@ -181,6 +181,17 @@ Threshold defaults live in `delivery.preflight.*` in `.agentrc.json`
181
181
  (all keys default to "no cap" — the gate is opt-in until an operator
182
182
  configures `maxStories` etc.).
183
183
 
184
+ **Remote evidence — land or block (issue #4483).** The envelope also
185
+ carries `remoteVerified` + `remoteProbe` (deterministic probes:
186
+ `git remote get-url origin`, bounded `git ls-remote origin HEAD`). When
187
+ `remoteVerified` is `false`, flip the Epic to `agent::blocked`, post a
188
+ friction comment quoting `remoteProbe.detail`, and halt — the same
189
+ explicit-block shape as #4425/#4480. NEVER fall back to executing Stories
190
+ inline in this session or committing the delivery to local `main`; the
191
+ worktree/branch/PR path below is the only sanctioned mechanism. Phase 7's
192
+ finalize additionally refuses with a `delivery-branch-missing-on-origin`
193
+ blocker when `epic/<epicId>` never reached origin.
194
+
184
195
  ### Phase 1 main — Seed the wave plan
185
196
 
186
197
  ```bash
@@ -97,6 +97,14 @@ Capture `workCwd` from the result envelope. Add `--dry-run` to inspect
97
97
  the planned actions without git or ticket mutations (dry-run also skips
98
98
  the lease and the sweep).
99
99
 
100
+ **Remote evidence — land or block (issue #4483).** The envelope also
101
+ carries `remoteVerified` + `remoteProbe` (`git remote get-url origin` +
102
+ bounded `git ls-remote origin HEAD`). When `remoteVerified` is `false`,
103
+ transition the Story to `agent::blocked` quoting `remoteProbe.detail` and
104
+ stop. Implementing the Story inline outside the worktree/branch/PR path
105
+ and/or committing it to local `main` is expressly forbidden — the close
106
+ pipeline's push is the only sanctioned landing.
107
+
100
108
  ### Step 0.5 — `cd` into the workCwd
101
109
 
102
110
  ```bash
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.91.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.90.0...mandrel-v1.91.0) (2026-07-12)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * **deliver:** verify the remote at entry and assert the delivery branch on origin (refs [#4483](https://github.com/dsj1984/mandrel/issues/4483)) ([#4484](https://github.com/dsj1984/mandrel/issues/4484)) ([a99ca2f](https://github.com/dsj1984/mandrel/commit/a99ca2fc4c74f7c397041dc4c703d845fae640c2))
11
+
5
12
  ## [1.90.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.89.0...mandrel-v1.90.0) (2026-07-12)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.90.0",
3
+ "version": "1.91.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",