mandrel 1.89.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.
Files changed (26) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +4 -0
  3. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  4. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  5. package/.agents/scripts/coverage-capture.js +17 -0
  6. package/.agents/scripts/epic-deliver-preflight.js +37 -1
  7. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  8. package/.agents/scripts/lib/config/ci.js +12 -1
  9. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  10. package/.agents/scripts/lib/npm-scripts.js +55 -0
  11. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
  12. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
  13. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
  14. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
  16. package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
  17. package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  19. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
  20. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  21. package/.agents/scripts/single-story-init.js +22 -0
  22. package/.agents/workflows/deliver.md +8 -0
  23. package/.agents/workflows/helpers/deliver-epic.md +11 -0
  24. package/.agents/workflows/helpers/single-story-deliver.md +8 -0
  25. package/docs/CHANGELOG.md +14 -0
  26. package/package.json +1 -1
@@ -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
+ }
@@ -125,7 +125,11 @@ export async function runCloseValidationPhase({
125
125
  const validation = await runCloseValidation({
126
126
  cwd,
127
127
  worktreePath,
128
- gates: buildDefaultGates({ config, epicBranch: baseBranch }),
128
+ gates: buildDefaultGates({
129
+ config,
130
+ epicBranch: baseBranch,
131
+ cwd: worktreePath || cwd,
132
+ }),
129
133
  log: (m) => Logger.info(m),
130
134
  storyId,
131
135
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
@@ -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
@@ -133,7 +133,14 @@ export async function runPreMergeGates({
133
133
  // `buildDefaultGates` reads the canonical resolved config directly:
134
134
  // gate commands resolve from `project.commands` and the CRAP toggle
135
135
  // from `delivery.quality.gates.crap.enabled`.
136
- const gates = buildDefaultGates({ config, epicBranch });
136
+ // Probe the coverage script from the gate execution directory (the Story
137
+ // worktree when present) so coverage-capture is only registered when the
138
+ // consumer ships `test:coverage` (#4473).
139
+ const gates = buildDefaultGates({
140
+ config,
141
+ epicBranch,
142
+ cwd: worktreePath || cwd,
143
+ });
137
144
  const gateCount = Array.isArray(gates) ? gates.length : 0;
138
145
  // Story #2250 — emit `close-validate.start` only when both an epicId
139
146
  // and a storyId are present; the schema requires both, and unit
@@ -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,20 @@
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
+
12
+ ## [1.90.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.89.0...mandrel-v1.90.0) (2026-07-12)
13
+
14
+
15
+ ### Fixed
16
+
17
+ * **delivery:** land headless PRs in checks-less repos + degrade coverage gate without test:coverage ([#4480](https://github.com/dsj1984/mandrel/issues/4480)) ([9836f1e](https://github.com/dsj1984/mandrel/commit/9836f1ec9beb4b2798bd72e869214e5705296522)), closes [#4472](https://github.com/dsj1984/mandrel/issues/4472) [#4473](https://github.com/dsj1984/mandrel/issues/4473)
18
+
5
19
  ## [1.89.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.88.0...mandrel-v1.89.0) (2026-07-11)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.89.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/",