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.
- package/.agents/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +4 -0
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/epic-deliver-preflight.js +37 -1
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
- package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
- package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/single-story-init.js +22 -0
- package/.agents/workflows/deliver.md +8 -0
- package/.agents/workflows/helpers/deliver-epic.md +11 -0
- package/.agents/workflows/helpers/single-story-deliver.md +8 -0
- package/docs/CHANGELOG.md +14 -0
- 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({
|
|
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
|
-
|
|
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