yadflow 3.16.2 → 3.17.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/CHANGELOG.md +31 -0
- package/README.md +4 -0
- package/bin/yad.mjs +33 -5
- package/cli/doctor.mjs +55 -1
- package/cli/gate.mjs +5 -6
- package/cli/hook.mjs +224 -0
- package/cli/manifest.mjs +40 -0
- package/cli/openpr.mjs +36 -7
- package/cli/plan.mjs +160 -5
- package/cli/platform.mjs +88 -0
- package/cli/reconcile.mjs +2 -2
- package/cli/review.mjs +10 -5
- package/cli/setup.mjs +10 -1
- package/package.json +1 -1
- package/skills/sdlc/module-help.csv +2 -2
- package/skills/yad-checks/SKILL.md +42 -0
- package/skills/yad-checks/references/check-gates.md +106 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +69 -0
- package/skills/yad-hub-bridge/SKILL.md +3 -1
- package/skills/yad-open-pr/SKILL.md +24 -2
- package/skills/yad-review-gate/SKILL.md +3 -1
- package/skills/yad-ship/SKILL.md +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
# [3.17.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.3...v3.17.0) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **check:** leave an already-wired settings.json byte-identical ([1d50ed4](https://github.com/abdelrahmannasr/yadflow/commit/1d50ed4b173f7de2350344fc5facb1de19bd9c61))
|
|
7
|
+
* **check:** stop the hook wiring from damaging a file the team owns ([a348c99](https://github.com/abdelrahmannasr/yadflow/commit/a348c997ba3b87406addef4e5868bdd14406fa49))
|
|
8
|
+
* **doctor:** give an unparseable settings file its own advice ([0862875](https://github.com/abdelrahmannasr/yadflow/commit/08628750a3c8e8642075db0fc0712d217dacdeb4))
|
|
9
|
+
* **doctor:** report the ledger guard against what actually arms it ([a1ec4ba](https://github.com/abdelrahmannasr/yadflow/commit/a1ec4bafec8441b6bf8c0515bf4f1bd68cb00ef7))
|
|
10
|
+
* **hook:** read the seeded set the way the CI gate reads it ([38cd206](https://github.com/abdelrahmannasr/yadflow/commit/38cd206fc94256782a074a309fe6dbc2ae3ce135)), closes [#171](https://github.com/abdelrahmannasr/yadflow/issues/171)
|
|
11
|
+
* **hook:** resolve the command before suppressing the update notice ([44d8768](https://github.com/abdelrahmannasr/yadflow/commit/44d87683abbc8acff02b011b6ce47d140e8075d3))
|
|
12
|
+
* **hook:** survive an empty command array on bash 3.2 ([7fd6984](https://github.com/abdelrahmannasr/yadflow/commit/7fd698412d8229f312f62c2a21f299dad445700b))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **check:** install and report the agent ledger guardrail ([8251d9f](https://github.com/abdelrahmannasr/yadflow/commit/8251d9f92740690ca7b3e27a0280f999593793f3))
|
|
18
|
+
* **hook:** refuse an agent the CI-owned ledger write, at the edit ([15291ce](https://github.com/abdelrahmannasr/yadflow/commit/15291ce2d73167cebeb5aba52920bdb078d6d0aa))
|
|
19
|
+
|
|
20
|
+
## [3.16.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.2...v3.16.3) (2026-08-12)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **open-pr:** base the task PR on the repo default branch, not main ([63da011](https://github.com/abdelrahmannasr/yadflow/commit/63da011fd2c4b6e81940c646879cc692b634a877)), closes [#168](https://github.com/abdelrahmannasr/yadflow/issues/168)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Performance Improvements
|
|
29
|
+
|
|
30
|
+
* **review:** stop probing the platform for an already-configured base ([0fc3de3](https://github.com/abdelrahmannasr/yadflow/commit/0fc3de34c7a522ec3463953778787f247ab28071)), closes [#191](https://github.com/abdelrahmannasr/yadflow/issues/191)
|
|
31
|
+
|
|
1
32
|
## [3.16.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.1...v3.16.2) (2026-08-12)
|
|
2
33
|
|
|
3
34
|
|
package/README.md
CHANGED
|
@@ -89,6 +89,10 @@ In one pass it produces:
|
|
|
89
89
|
push-on-main **`yad-update-guard`** (which re-checks any direct-to-default commit — e.g. from
|
|
90
90
|
`yad update --push` — with just `verified-commits` + `commit-message`), shipped as CI-agnostic bash
|
|
91
91
|
under `checks/`.
|
|
92
|
+
- **An agent guardrail** on a bridge hub — `hooks/ledger-guard.sh`, a harness hook that refuses an
|
|
93
|
+
agent the CI-owned gate-ledger write at the moment it tries it and names the command that owns the
|
|
94
|
+
transition, instead of letting it surface as a CI failure twenty minutes later. Harness-agnostic
|
|
95
|
+
(stdin payload, exit 0 allows / 2 denies) and fails open — the CI gate stays the authority.
|
|
92
96
|
- **PR/MR templates** and an opt-in CodeRabbit config.
|
|
93
97
|
|
|
94
98
|
Your first `yad-epic` seeds the `epics/EP-<slug>/` ledger — state, approvals, and the contract lock —
|
package/bin/yad.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { syncStatuses } from '../cli/artifact-status.mjs';
|
|
|
22
22
|
import { runThread, runReconcile } from '../cli/thread.mjs';
|
|
23
23
|
import { runReport } from '../cli/report.mjs';
|
|
24
24
|
import { runUsage } from '../cli/usage.mjs';
|
|
25
|
+
import { runLedgerGuardHook } from '../cli/hook.mjs';
|
|
25
26
|
import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
|
|
26
27
|
|
|
27
28
|
const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
@@ -51,6 +52,11 @@ ${c.bold('Setup & maintenance')}
|
|
|
51
52
|
yad report [-m <text>] File a bug in the yadflow repo with auto-scrubbed diagnostics
|
|
52
53
|
(no paths/hosts/repo names/logins/flag values). Also offered
|
|
53
54
|
automatically after an unexpected failure. YAD_NO_REPORT=1 disables.
|
|
55
|
+
yad hook ledger-guard ${c.dim('harness-invoked, not typed')} — refuse an agent's edit to the
|
|
56
|
+
CI-owned gate ledger in bridge mode and name the command that owns
|
|
57
|
+
the transition. Reads a tool-call payload on stdin (or --path <p>);
|
|
58
|
+
exit 0 allows, exit 2 denies with the reason on stderr. Wired into
|
|
59
|
+
.claude/settings.json by setup / check --fix. YAD_HOOK_DISABLE=1 skips.
|
|
54
60
|
|
|
55
61
|
${c.bold('Reviewer roster')}
|
|
56
62
|
yad roster list Show every member + their roles per scope (hub + each repo)
|
|
@@ -103,9 +109,11 @@ ${c.bold('Review gate (front half)')}
|
|
|
103
109
|
|
|
104
110
|
${c.bold('Build helpers')}
|
|
105
111
|
yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
|
|
106
|
-
yad open-pr [--repo <name>] Open a task PR/MR
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
yad open-pr [--repo <name>] Open a task PR/MR against the repo's DEFAULT branch (never a
|
|
113
|
+
hardcoded main; --base overrides) — stage-aware on the hub: a
|
|
114
|
+
review/EP-* branch opens the front-half artifact-review PR
|
|
115
|
+
(delegates to gate open), any other hub branch uses the
|
|
116
|
+
code-task template
|
|
109
117
|
yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
|
|
110
118
|
yad checkpoint [--push] Commit the machine-written back-half hub state
|
|
111
119
|
(trust-log/build-log/build-state) — plus any story
|
|
@@ -153,6 +161,10 @@ ${c.bold('Options')}
|
|
|
153
161
|
--contract-change commit/open-pr: mark the contract surface touched
|
|
154
162
|
--risk <level> open-pr: low|medium|high (default low)
|
|
155
163
|
--repo <name> open-pr: target a registered repo by name
|
|
164
|
+
--base <branch> open-pr: override the PR/MR base — default is the repo's own default
|
|
165
|
+
branch (repos.json default_branch, else hub.json default_branch for a PR
|
|
166
|
+
on the hub itself, else the platform, else origin/HEAD, else main); a
|
|
167
|
+
non-default base loses the AI first pass (warns, never blocks)
|
|
156
168
|
--epic <id> docs: target one epic's site (EP-<slug>)
|
|
157
169
|
--overview docs: target the project SDLC-overview site
|
|
158
170
|
--check/--refresh/--wire docs sync: report stale / rebuild / install Pages CI
|
|
@@ -173,7 +185,7 @@ ${c.bold('Environment')}
|
|
|
173
185
|
YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
|
|
174
186
|
YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
|
|
175
187
|
|
|
176
|
-
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--retro-ship', '--merge-commit']);
|
|
188
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--retro-ship', '--merge-commit', '--path']);
|
|
177
189
|
|
|
178
190
|
function parseArgs(argv) {
|
|
179
191
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -248,6 +260,18 @@ async function main() {
|
|
|
248
260
|
case 'doctor':
|
|
249
261
|
await runDoctor(o.dir, { json: o.json });
|
|
250
262
|
break;
|
|
263
|
+
// Harness-invoked, not typed by a human: a tool-call payload arrives on stdin and the exit code
|
|
264
|
+
// is the verdict (0 allow, 2 deny). See cli/hook.mjs for the contract.
|
|
265
|
+
case 'hook': {
|
|
266
|
+
const [, action] = o._;
|
|
267
|
+
if (action !== 'ledger-guard') {
|
|
268
|
+
log(c.red(`unknown hook: ${action ?? '(none)'} (ledger-guard)`));
|
|
269
|
+
process.exitCode = 1;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
runLedgerGuardHook({ paths: o.path ? [o.path] : [] });
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
251
275
|
case 'report':
|
|
252
276
|
await runReport(o.dir, { message: o.message });
|
|
253
277
|
break;
|
|
@@ -409,7 +433,11 @@ main()
|
|
|
409
433
|
// the readline handle open so the process never exits.
|
|
410
434
|
.finally(async () => {
|
|
411
435
|
try {
|
|
412
|
-
|
|
436
|
+
// Never on `yad hook`: it runs on every agent tool call, and its stderr is the channel the
|
|
437
|
+
// block reason travels on — an update banner there would land in front of a model.
|
|
438
|
+
// Resolved the way main() resolves it, NOT from argv[2]: that is the first raw argument, so
|
|
439
|
+
// `yad --dir <path> hook ledger-guard` puts `--dir` there and the banner slips through.
|
|
440
|
+
if (parseArgs(process.argv.slice(2))._[0] !== 'hook') await maybeNotifyUpdate();
|
|
413
441
|
} catch { /* the notice is never worth failing or hanging a command over */ } finally {
|
|
414
442
|
closePrompts();
|
|
415
443
|
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
|
|
8
|
-
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
8
|
+
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_SETTINGS, HOOK_TOOL_MATCHER, isBridgeHub } from './manifest.mjs';
|
|
9
|
+
import { mergeHookSettings, hookMatcherFires, ideTargetsFor } from './plan.mjs';
|
|
9
10
|
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash, artifactHash } from './epic-state.mjs';
|
|
10
11
|
import { loadDebt } from './thread.mjs';
|
|
11
12
|
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
@@ -150,6 +151,59 @@ export function projectChecks(checks, root) {
|
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
// The harness ledger guard (#171). Only meaningful in bridge mode: there the ledger is CI-owned and
|
|
155
|
+
// an agent's hand-edit is always rejected later by `ledger-guard`, so the local hook that refuses it
|
|
156
|
+
// up front should be installed. Without the bridge the ledger is locally owned and the hand-edit the
|
|
157
|
+
// authoring skills describe is correct — nothing to report, so the check is silent rather than `ok`.
|
|
158
|
+
const hubForHooks = readJSON(hubPath, null);
|
|
159
|
+
if (isBridgeHub(hubForHooks)) {
|
|
160
|
+
const unwired = [];
|
|
161
|
+
const broken = [];
|
|
162
|
+
if (!exists(path.join(root, 'hooks', 'ledger-guard.sh'))) unwired.push('hooks/ledger-guard.sh');
|
|
163
|
+
// The SAME target list `hookActions` wires — the persisted `ideTargets`, not "does the directory
|
|
164
|
+
// exist". Keyed on the directory, a project whose targets are `['.agents']` but which also has a
|
|
165
|
+
// stray `.claude/` would be told to run `yad check --fix` forever, while that command builds no
|
|
166
|
+
// action for `.claude` and correctly reports "already up to date". Never name a remedy that
|
|
167
|
+
// cannot reach the thing being reported.
|
|
168
|
+
const unreadable = [];
|
|
169
|
+
for (const ide of ideTargetsFor(root)) {
|
|
170
|
+
const relDest = HOOK_SETTINGS[ide];
|
|
171
|
+
if (!relDest) continue;
|
|
172
|
+
const settingsPath = path.join(root, relDest);
|
|
173
|
+
// A file that exists but does not parse is its OWN report. `readJSON` returns null for both
|
|
174
|
+
// "absent" and "broken", and null merges as "not wired" — which would send the human to
|
|
175
|
+
// `yad check --fix`, a command that (correctly) refuses to rewrite a settings file it cannot
|
|
176
|
+
// parse. The warning would then repeat forever with advice that can never apply.
|
|
177
|
+
// Read ONCE and reuse: parsing the same file twice lets `unreadable` and the merge check
|
|
178
|
+
// describe different content if it changes in between.
|
|
179
|
+
let settings = null;
|
|
180
|
+
if (exists(settingsPath)) {
|
|
181
|
+
let parsed;
|
|
182
|
+
try { parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { /* reported below */ }
|
|
183
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { unreadable.push(relDest); continue; }
|
|
184
|
+
settings = parsed;
|
|
185
|
+
}
|
|
186
|
+
if (mergeHookSettings(settings).changed) { unwired.push(relDest); continue; }
|
|
187
|
+
// Present is not the same as armed. The entry's matcher is the team's to narrow (the merge
|
|
188
|
+
// deliberately leaves it alone), but one that no longer selects any file-editing tool means
|
|
189
|
+
// nothing is intercepted — and reporting that as `ok` is how a disarmed guard passes for
|
|
190
|
+
// healthy until a ledger edit fails in CI.
|
|
191
|
+
if (!hookMatcherFires(settings)) broken.push(relDest);
|
|
192
|
+
}
|
|
193
|
+
if (unreadable.length) {
|
|
194
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard cannot be wired — ${unreadable.join(', ')} does not parse [YAD-STATE-001]`,
|
|
195
|
+
'fix the JSON by hand, then run `yad check --fix` — yad never rewrites a settings file it cannot parse, so nothing else can clear this');
|
|
196
|
+
} else if (unwired.length) {
|
|
197
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard not wired: ${unwired.join(', ')}`,
|
|
198
|
+
'run `yad check --fix` — until then an agent can hand-edit the CI-owned ledger and only find out when the review PR/MR fails');
|
|
199
|
+
} else if (broken.length) {
|
|
200
|
+
check(checks, 'hooks', 'project', 'warn', `agent ledger guard installed but its matcher no longer selects file edits: ${broken.join(', ')}`,
|
|
201
|
+
`restore the matcher to \`${HOOK_TOOL_MATCHER}\` — as it stands the hook is wired but never fires`);
|
|
202
|
+
} else {
|
|
203
|
+
check(checks, 'hooks', 'project', 'ok', 'agent ledger guard wired (hooks/ledger-guard.sh)');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
153
207
|
// design.json: parse + shape + tool + MCP confirmation (absent is the normal markdown-only default —
|
|
154
208
|
// pre-feature projects have none, so silence rather than warn when the file does not exist).
|
|
155
209
|
const designPath = path.join(root, PROJECT_FILES.designConfig);
|
package/cli/gate.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import {
|
|
8
8
|
c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run, pushWithRebase,
|
|
9
9
|
} from './lib.mjs';
|
|
10
|
-
import { PROJECT_FILES } from './manifest.mjs';
|
|
10
|
+
import { PROJECT_FILES, isBridgeHub } from './manifest.mjs';
|
|
11
11
|
import {
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
@@ -113,11 +113,10 @@ export function loadHub(root) {
|
|
|
113
113
|
// merge advances the step). Recorded per-project in hub.json by `yad setup`.
|
|
114
114
|
const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
115
115
|
|
|
116
|
-
// Bridge mode:
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
|
|
116
|
+
// Bridge mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
|
|
117
|
+
// defined once in manifest.mjs (`isBridgeHub`) and shared with plan.mjs's wiring and the ledger
|
|
118
|
+
// hook, so no two readers can disagree about who owns the ledger (#186).
|
|
119
|
+
const isBridge = isBridgeHub;
|
|
121
120
|
|
|
122
121
|
// requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
|
|
123
122
|
// approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
|
package/cli/hook.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// `yad hook ledger-guard` — the harness-side half of the ledger rule (#171).
|
|
2
|
+
//
|
|
3
|
+
// In BRIDGE mode the gate ledger is CI-owned: `templates/checks/ledger-guard.sh` rejects any non-bot
|
|
4
|
+
// commit that changes `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`.
|
|
5
|
+
// That gate is the authority, but it only speaks at CI time — an agent that hand-edits `state.json`
|
|
6
|
+
// learns twenty minutes later, from a failed pipeline with nothing connecting cause to effect. This
|
|
7
|
+
// hook says the same thing at the moment of the edit, and names the command that owns the transition.
|
|
8
|
+
//
|
|
9
|
+
// HARNESS-AGNOSTIC CONTRACT — the reason this is a `yad` subcommand and not Claude-Code-shaped code:
|
|
10
|
+
// stdin a harness tool-call payload as JSON (optional; `--path <p>` works instead)
|
|
11
|
+
// exit 0 allow
|
|
12
|
+
// exit 2 deny — the reason is on stderr, for the agent to read
|
|
13
|
+
// Claude Code's PreToolUse protocol is exactly that (exit 2 blocks the call and feeds stderr back to
|
|
14
|
+
// the model), so `hooks/ledger-guard.sh` wires it with no adapter logic; another harness needs only
|
|
15
|
+
// the same two exit codes.
|
|
16
|
+
//
|
|
17
|
+
// FAIL-OPEN, deliberately. No hub, unreadable config, an unparseable payload, no git — every one of
|
|
18
|
+
// those ALLOWS, with a note on stderr. This is a local guardrail, and one that failed closed would
|
|
19
|
+
// brick an agent's ability to edit anything the moment a config went sideways. The asymmetry is the
|
|
20
|
+
// design: `ledger-guard` in CI fails closed and is what actually protects the ledger.
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { note, readJSON, run } from './lib.mjs';
|
|
24
|
+
import { PROJECT_FILES, isBridgeHub } from './manifest.mjs';
|
|
25
|
+
|
|
26
|
+
// The CI-owned files, exactly as `templates/checks/ledger-guard.sh` lists them. NOT `contract-lock.json`
|
|
27
|
+
// (artifact-side: the architect commits it with the architecture) and NOT `change.json` — both are a
|
|
28
|
+
// human's to write, and both are exempt in the gate too.
|
|
29
|
+
const LEDGER_FILES = new Set(['state.json', 'approvals.json', 'comments.json', 'hub-prs.json']);
|
|
30
|
+
|
|
31
|
+
// `epics/<epic>/.sdlc/<ledger>.json` or `epics/<epic>/reviews/<name>.md` → { epic, rel }; else null.
|
|
32
|
+
// Takes a hub-relative POSIX path.
|
|
33
|
+
//
|
|
34
|
+
// Depth is matched the way the CI gate matches it, not more strictly. Its arms are bash `case`
|
|
35
|
+
// globs — `epics/*/.sdlc/state.json` — and a bash glob's `*` spans `/`, so the gate ALSO rejects
|
|
36
|
+
// `epics/EP-a/nested/.sdlc/state.json`. Requiring exactly four segments here would have let a path
|
|
37
|
+
// through locally that CI blocks, in a guard whose whole claim is that its scope is the gate's.
|
|
38
|
+
// The slug is the second segment either way (the gate's `${f#epics/}` / `${_slug%%/*}`).
|
|
39
|
+
export function protectedLedgerPath(rel) {
|
|
40
|
+
if (!rel.startsWith('epics/')) return null;
|
|
41
|
+
const epic = rel.slice('epics/'.length).split('/')[0];
|
|
42
|
+
if (!epic || epic === '.' || epic === '..') return null;
|
|
43
|
+
const ledgers = [...LEDGER_FILES].map((f) => f.replace('.', '\\.')).join('|');
|
|
44
|
+
if (new RegExp(`/\\.sdlc/(?:${ledgers})$`).test(rel)) return { epic, rel, kind: 'state' };
|
|
45
|
+
if (/\/reviews\/.*\.md$/.test(rel)) return { epic, rel, kind: 'review' };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Every path a tool call would write. Covers the shapes harnesses actually send: a single
|
|
50
|
+
// `file_path` (Edit/Write), `notebook_path` (NotebookEdit), and a `MultiEdit`-style `edits[]` array.
|
|
51
|
+
// An unrecognised payload yields nothing, which allows — see the fail-open note above.
|
|
52
|
+
export function payloadPaths(payload) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const input = payload?.tool_input;
|
|
55
|
+
if (!input || typeof input !== 'object') return out;
|
|
56
|
+
for (const key of ['file_path', 'notebook_path', 'path']) {
|
|
57
|
+
if (typeof input[key] === 'string' && input[key]) out.push(input[key]);
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(input.edits)) {
|
|
60
|
+
for (const edit of input.edits) if (typeof edit?.file_path === 'string' && edit.file_path) out.push(edit.file_path);
|
|
61
|
+
}
|
|
62
|
+
return [...new Set(out)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The hub a path belongs to: the nearest ancestor holding `.sdlc/hub.json`.
|
|
66
|
+
//
|
|
67
|
+
// Resolved from the PATH, never from the session. The documented layout puts code repos BESIDE the
|
|
68
|
+
// hub (`project/{product,backend,mobile}` — see `insideWorkspace` in setup.mjs), so a session opened
|
|
69
|
+
// at the workspace has no `hub.json` under its root, and a session-rooted lookup would find nothing
|
|
70
|
+
// and silently allow a mutation inside `project/product/epics/…` — the multi-repo, parallel-agent
|
|
71
|
+
// setup this hook was reported from.
|
|
72
|
+
export function hubRootFor(abs) {
|
|
73
|
+
let dir = path.dirname(path.resolve(abs));
|
|
74
|
+
for (;;) {
|
|
75
|
+
if (fs.existsSync(path.join(dir, PROJECT_FILES.hubConfig))) return dir;
|
|
76
|
+
const parent = path.dirname(dir);
|
|
77
|
+
if (parent === dir) return null;
|
|
78
|
+
dir = parent;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Where a RELATIVE path in the payload is anchored. Only used to make such a path absolute, so the
|
|
83
|
+
// hub walk-up above has somewhere to start.
|
|
84
|
+
export function baseDirFor(env = process.env, runner = run) {
|
|
85
|
+
if (env.CLAUDE_PROJECT_DIR) return env.CLAUDE_PROJECT_DIR;
|
|
86
|
+
const top = runner('git', ['rev-parse', '--show-toplevel']);
|
|
87
|
+
return top.ok && top.stdout ? top.stdout : process.cwd();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Case-folded, exactly as the CI gate folds (`tr '[:upper:]' '[:lower:]'`).
|
|
91
|
+
const fold = (s) => s.toLowerCase();
|
|
92
|
+
|
|
93
|
+
// The base ref, resolved in the CI gate's own order: `origin/<default_branch>`, then the remote's
|
|
94
|
+
// published default, then `origin/main`. Returns null when none resolves.
|
|
95
|
+
//
|
|
96
|
+
// ORIGIN refs only — never a bare local branch. A local trunk is whatever the developer last pulled,
|
|
97
|
+
// and `git fetch` never fast-forwards it, so probing `main` would report an epic whose review PR has
|
|
98
|
+
// already merged as absent from the base and wave a real mutation straight through. That is the
|
|
99
|
+
// stale-clone case, and it is the common one, not an edge.
|
|
100
|
+
export function resolveHookBase(hubRoot, hub, runner = run) {
|
|
101
|
+
const cfg = hub?.default_branch || '';
|
|
102
|
+
const head = runner('git', ['-C', hubRoot, 'symbolic-ref', '--short', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
103
|
+
for (const base of [cfg ? `origin/${cfg}` : '', head.ok ? head.stdout : '', 'origin/main']) {
|
|
104
|
+
if (!base || base === 'origin/') continue;
|
|
105
|
+
if (runner('git', ['-C', hubRoot, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return base;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Every epic whose ledger is already on the base ref, case-folded. The #162 carve-out, mirrored from
|
|
111
|
+
// the gate's `is_seeding`: no CI path can CREATE a ledger (`gate ci` only advances an existing chain,
|
|
112
|
+
// at merge, on the default branch), so a brand-new epic's seed is legitimately a human's write and
|
|
113
|
+
// rides the first review PR/MR. Mutating a ledger that is already on the base is what only the bot
|
|
114
|
+
// may do.
|
|
115
|
+
//
|
|
116
|
+
// Read with `ls-tree` from the hub, never with a `<rev>:<path>` probe: a rev:path spec is always
|
|
117
|
+
// resolved from the repository TOP LEVEL and `-C` does not re-anchor it, so a hub sitting in a
|
|
118
|
+
// subdirectory of its repo (a monorepo, or a workspace that is itself a repo) would miss on every
|
|
119
|
+
// probe and the guard would allow everything, silently. `ls-tree` run with `-C hubRoot` takes a
|
|
120
|
+
// cwd-relative pathspec and prints cwd-relative paths, so both halves stay hub-relative.
|
|
121
|
+
//
|
|
122
|
+
// Slugs are FOLDED because the gate folds them: on a case-insensitive filesystem `epics/ep-x/…` and
|
|
123
|
+
// `epics/EP-X/…` are the same file, so a byte-exact compare lets a mutation be laundered as a
|
|
124
|
+
// creation — the vector the gate's own header names.
|
|
125
|
+
//
|
|
126
|
+
// null means the base could not be read at all — "unknown", which ALLOWS. The working tree cannot
|
|
127
|
+
// stand in for the base ref: a seed writes `state.json` first, so using that as proof would deny
|
|
128
|
+
// every remaining file of the same seed.
|
|
129
|
+
export function seededSlugs(hubRoot, hub, runner = run) {
|
|
130
|
+
const base = resolveHookBase(hubRoot, hub, runner);
|
|
131
|
+
if (!base) return null;
|
|
132
|
+
const tree = runner('git', [
|
|
133
|
+
'-C', hubRoot, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', '-z', base, '--', 'epics',
|
|
134
|
+
]);
|
|
135
|
+
if (!tree.ok) return null;
|
|
136
|
+
const slugs = new Set();
|
|
137
|
+
for (const p of tree.stdout.split('\0')) {
|
|
138
|
+
const m = /^epics\/([^/]+)\/\.sdlc\/state\.json$/.exec(p);
|
|
139
|
+
if (m) slugs.add(fold(m[1]));
|
|
140
|
+
}
|
|
141
|
+
return slugs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
// What the agent is told when the edit is refused. Names the command that owns each transition —
|
|
146
|
+
// the whole point of #171 was that the ledger write had no command behind it.
|
|
147
|
+
export function denyMessage({ epic, rel, hubRoot }) {
|
|
148
|
+
return [
|
|
149
|
+
`[yad] Blocked: ${rel} is CI-owned gate state.`,
|
|
150
|
+
'',
|
|
151
|
+
'This hub runs in bridge mode, where CI is the sole writer of the gate ledger. The `ledger-guard`',
|
|
152
|
+
'check rejects any non-bot commit that changes it, so this edit cannot reach the default branch —',
|
|
153
|
+
'it would fail the review PR/MR and have to be reverted.',
|
|
154
|
+
'',
|
|
155
|
+
'Use the command that owns the transition instead:',
|
|
156
|
+
` author step done → review opened yad gate open ${epic} <artifact>`,
|
|
157
|
+
' the full advance at merge CI runs `yad gate ci --merged` — nothing to do locally',
|
|
158
|
+
` a genuinely broken ledger yad gate repair ${epic}`,
|
|
159
|
+
'',
|
|
160
|
+
'Commit the ARTIFACT only (the .md you authored) and hand off to `yad-review-gate`; the ledger',
|
|
161
|
+
'follows on merge.',
|
|
162
|
+
'',
|
|
163
|
+
`hub: ${hubRoot} · override for one command: YAD_HOOK_DISABLE=1`,
|
|
164
|
+
].join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The decision, with git injectable so the tests can drive every branch. Returns
|
|
168
|
+
// `{ allow: true }` or `{ allow: false, message, epic, rel }`.
|
|
169
|
+
export function ledgerGuardDecision(paths, { env = process.env, runner = run } = {}) {
|
|
170
|
+
if (env.YAD_HOOK_DISABLE) return { allow: true, skipped: 'YAD_HOOK_DISABLE' };
|
|
171
|
+
if (!paths.length) return { allow: true };
|
|
172
|
+
const base = baseDirFor(env, runner);
|
|
173
|
+
// One `ls-tree` per hub, not one per candidate path: a MultiEdit carries many paths and this runs
|
|
174
|
+
// inside the agent's tool loop.
|
|
175
|
+
const seededByHub = new Map();
|
|
176
|
+
for (const candidate of paths) {
|
|
177
|
+
const abs = path.resolve(base, candidate);
|
|
178
|
+
const hubRoot = hubRootFor(abs);
|
|
179
|
+
if (!hubRoot) continue;
|
|
180
|
+
// Non-strict on purpose: a hub.json that does not parse is a real problem, but refusing every
|
|
181
|
+
// edit in the repo is not this hook's way of reporting it (`yad doctor` says so properly).
|
|
182
|
+
const hub = readJSON(path.join(hubRoot, PROJECT_FILES.hubConfig), null);
|
|
183
|
+
if (!isBridgeHub(hub)) continue;
|
|
184
|
+
const rel = path.relative(hubRoot, abs).split(path.sep).join('/');
|
|
185
|
+
const hit = protectedLedgerPath(rel);
|
|
186
|
+
if (!hit) continue;
|
|
187
|
+
if (!seededByHub.has(hubRoot)) seededByHub.set(hubRoot, seededSlugs(hubRoot, hub, runner));
|
|
188
|
+
const seeded = seededByHub.get(hubRoot);
|
|
189
|
+
if (seeded === null) continue; // base unreadable — unknown allows
|
|
190
|
+
if (!seeded.has(fold(hit.epic))) continue; // creation, not mutation (#162)
|
|
191
|
+
return { allow: false, epic: hit.epic, rel, message: denyMessage({ epic: hit.epic, rel, hubRoot }) };
|
|
192
|
+
}
|
|
193
|
+
return { allow: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Read the harness payload off stdin. Absent, empty, or unparseable all mean "nothing to inspect" —
|
|
197
|
+
// never an error, and never a block.
|
|
198
|
+
function readPayload() {
|
|
199
|
+
try {
|
|
200
|
+
if (process.stdin.isTTY) return null;
|
|
201
|
+
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
202
|
+
if (!raw) return null;
|
|
203
|
+
return JSON.parse(raw);
|
|
204
|
+
} catch {
|
|
205
|
+
note('yad hook: could not read a JSON tool payload on stdin — allowing');
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The `yad hook ledger-guard` entry point. `paths` (from `--path`) is additive to the payload, so
|
|
211
|
+
// a harness with no JSON contract can call the guard directly.
|
|
212
|
+
export function runLedgerGuardHook({ paths = [] } = {}) {
|
|
213
|
+
// Only read stdin when there is nothing else to go on. `readFileSync(0)` blocks until EOF, and a
|
|
214
|
+
// caller that passes `--path` (the documented stdin-free alternative) may well have inherited an
|
|
215
|
+
// open pipe from a long-lived parent — `isTTY` is false there, so the TTY guard does not trip and
|
|
216
|
+
// the hook would hang forever. A hang is strictly worse for an agent than a block.
|
|
217
|
+
const payload = paths.length ? null : readPayload();
|
|
218
|
+
const all = [...new Set([...payloadPaths(payload), ...paths])];
|
|
219
|
+
const verdict = ledgerGuardDecision(all);
|
|
220
|
+
if (verdict.allow) return 0;
|
|
221
|
+
console.error(verdict.message);
|
|
222
|
+
process.exitCode = 2;
|
|
223
|
+
return 2;
|
|
224
|
+
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -158,6 +158,18 @@ export const PROJECT_FILES = {
|
|
|
158
158
|
version: '.sdlc/cli-version.json',
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
+
// Bridge mode: a platform AND the gate-sync CI explicitly enabled (the canonical `bridge_enabled`,
|
|
162
|
+
// or the older `bridge`). ONLY then is CI the sole ledger writer — so `gate open`/`sync` stay
|
|
163
|
+
// hands-off, `hubActions` wires the hub CI, and the ledger guards (the `ledger-guard` check gate and
|
|
164
|
+
// the `yad hook ledger-guard` harness hook) are live. A platform without the bridge keeps the local
|
|
165
|
+
// write path, or reviews could never advance.
|
|
166
|
+
//
|
|
167
|
+
// ONE definition, imported by every JS caller. Copies that drift are how #186 happened — a hub that
|
|
168
|
+
// one reader called bridge and another called file-only had no permitted ledger writer at all.
|
|
169
|
+
// `templates/checks/ledger-guard.sh` re-implements it in bash because the check gates are standalone
|
|
170
|
+
// by design; that copy is the only one, and its header says so.
|
|
171
|
+
export const isBridgeHub = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
|
|
172
|
+
|
|
161
173
|
// ---- `yad commit` conventions (mirror skills/sdlc/config.yaml `build`) ----
|
|
162
174
|
// Conventional-commit types (config.yaml commit_subject_style).
|
|
163
175
|
export const COMMIT_TYPES = ['feat', 'fix', 'docs', 'refactor', 'test', 'perf', 'build', 'ci', 'chore', 'revert'];
|
|
@@ -275,3 +287,31 @@ export const HUB_WIRING = {
|
|
|
275
287
|
{ src: 'skills/yad-checks/templates/gitlab/yad-update-guard.gitlab-ci.yml', dest: '.gitlab/ci/yad-update-guard.yml' },
|
|
276
288
|
],
|
|
277
289
|
};
|
|
290
|
+
|
|
291
|
+
// Harness hooks: the LOCAL half of the ledger rule, installed on the hub beside the CI gates and
|
|
292
|
+
// active under the same bridge predicate (#171). Kept out of `HUB_WIRING` because a hook is not a
|
|
293
|
+
// CI gate — it is advisory, fails open, and its adapter (below) is per-harness, not per-platform.
|
|
294
|
+
export const HOOK_WIRING = [
|
|
295
|
+
{ src: 'skills/yad-checks/templates/hooks/ledger-guard.sh', dest: 'hooks/ledger-guard.sh', exec: true },
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
// Per-harness adapter config: which IDE target gets a hook entry written, and where.
|
|
299
|
+
// `.claude` alone — it is the only supported target with a defined hook protocol (`.agents`,
|
|
300
|
+
// `.zencoder` and `.opencode` carry skills only). The others simply get the script and no wiring;
|
|
301
|
+
// the contract in the script header is what they would wire by hand.
|
|
302
|
+
export const HOOK_SETTINGS = { '.claude': '.claude/settings.json' };
|
|
303
|
+
// The tools that can write a file. A `Bash` call (`sed -i epics/…`) is deliberately NOT matched:
|
|
304
|
+
// matching it would mean parsing shell, and CI's ledger-guard already fails closed on the result.
|
|
305
|
+
export const HOOK_TOOL_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
|
|
306
|
+
// `$CLAUDE_PROJECT_DIR` so the entry works whatever the harness's working directory is — QUOTED,
|
|
307
|
+
// because the harness runs this through a shell: unquoted, a project path containing a space
|
|
308
|
+
// word-splits, the command is not found, and the guard is silently off while `check` and `doctor`
|
|
309
|
+
// both still report it wired.
|
|
310
|
+
export const HOOK_COMMAND = '"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"';
|
|
311
|
+
// Spellings a previous yadflow wrote for the SAME hook. An installed entry matching one of these is
|
|
312
|
+
// ours to normalise; anything else is the team's, even if it names a similar path. Never widen this
|
|
313
|
+
// to a substring test — a team keeping its own wrapper at `.claude/hooks/ledger-guard.sh` would have
|
|
314
|
+
// their hook silently rewritten to ours.
|
|
315
|
+
export const HOOK_COMMAND_LEGACY = Object.freeze([
|
|
316
|
+
'$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh', // 3.16.x, pre-quoting
|
|
317
|
+
]);
|
package/cli/openpr.mjs
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// on the product hub.
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
-
import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
|
|
7
|
+
import { c, log, ok, info, warn, hand, fail, run, exists, readJSON } from './lib.mjs';
|
|
8
8
|
import { PROJECT_FILES } from './manifest.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin, resolveBaseBranch,
|
|
11
|
+
} from './platform.mjs';
|
|
10
12
|
import { taskFromBranch } from './commit.mjs';
|
|
11
13
|
import { parseReviewBranch, artifactFromBase } from './epic-state.mjs';
|
|
12
14
|
import { gateOpen } from './gate.mjs';
|
|
@@ -104,9 +106,6 @@ export async function runOpenPr(root, opts = {}) {
|
|
|
104
106
|
if (!platform) { fail('could not detect platform (github/gitlab) — pass --platform'); process.exitCode = 1; return; }
|
|
105
107
|
|
|
106
108
|
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot }).stdout;
|
|
107
|
-
const baseBranch = opts.base || meta?.default_branch || 'main';
|
|
108
|
-
if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
|
|
109
|
-
|
|
110
109
|
const stage = detectStage(root, repoRoot, branch, meta);
|
|
111
110
|
|
|
112
111
|
// hub-front: this is a front-half artifact-review PR (review/EP-*/<artifact> head on the hub). The
|
|
@@ -129,6 +128,34 @@ export async function runOpenPr(root, opts = {}) {
|
|
|
129
128
|
return res;
|
|
130
129
|
}
|
|
131
130
|
|
|
131
|
+
// The hub roster + its default_branch. The latter only applies when the PR targets the hub ITSELF
|
|
132
|
+
// (a hub-tooling branch) — for a connected code repo the hub's trunk belongs to a different repo and
|
|
133
|
+
// must never leak in. Resolved AFTER the hub-front hand-off above, which delegates its own base to
|
|
134
|
+
// `yad gate open`: resolving before it would spend a platform round-trip and print a base that the
|
|
135
|
+
// delegated path then ignores.
|
|
136
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
|
|
137
|
+
|
|
138
|
+
// Resolve the base rather than assume it (#168). Hardcoding 'main' mis-based every PR on a repo
|
|
139
|
+
// whose trunk is something else — and CodeRabbit decides auto-review eligibility from the base at
|
|
140
|
+
// PR-OPEN time, so those PRs silently got no AI first pass at all.
|
|
141
|
+
const { base: baseBranch, source: baseSource, platformDefault } = resolveBaseBranch(platform, {
|
|
142
|
+
cwd: repoRoot, explicit: opts.base, meta, hub: stage === 'code-repo' ? null : hub, runner: opts.runner,
|
|
143
|
+
});
|
|
144
|
+
if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
|
|
145
|
+
info(`base ${baseBranch} ${c.dim(`(from ${baseSource})`)}`);
|
|
146
|
+
// A base that is not the remote's default is legitimate (stacked PRs, release branches) but it costs
|
|
147
|
+
// the AI first pass, invisibly and irreversibly — so it is never silent. The REMEDY depends on where
|
|
148
|
+
// the base came from: telling someone to override a `default_branch` they deliberately configured
|
|
149
|
+
// would mean contradicting their own committed config on every PR, forever.
|
|
150
|
+
if (platformDefault && platformDefault !== baseBranch) {
|
|
151
|
+
warn(`base '${baseBranch}' is not the repo default '${platformDefault}' — CodeRabbit skips auto-review on a non-default base unless .coderabbit.yaml lists it under reviews.base_branches, and retargeting later does NOT undo the skip`);
|
|
152
|
+
if (baseSource === 'registry' || baseSource === 'hub') {
|
|
153
|
+
hand(`the configured default_branch (${baseSource === 'hub' ? '.sdlc/hub.json' : '.sdlc/repos.json'}) disagrees with the platform — reconcile them, or allow '${baseBranch}' in .coderabbit.yaml`);
|
|
154
|
+
} else {
|
|
155
|
+
hand(`open against '${platformDefault}' (or pass --base ${platformDefault}) unless you meant to stack this PR`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
132
159
|
// Push the branch (sets upstream) using the user's own auth. Abort on failure — creating a PR for a
|
|
133
160
|
// branch that is not on the remote just fails with a more confusing error.
|
|
134
161
|
info(`pushing ${branch} …`);
|
|
@@ -155,7 +182,6 @@ export async function runOpenPr(root, opts = {}) {
|
|
|
155
182
|
// Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
|
|
156
183
|
// local git identity), reviewers = the repo's reviewers + domain-owners, minus the committer.
|
|
157
184
|
// Degrades cleanly when there is no roster / the committer is unmapped (gh self-assigns via @me).
|
|
158
|
-
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
|
|
159
185
|
const roster = hub.roster || [];
|
|
160
186
|
const committer = resolveCommitterLogin(repoRoot, roster);
|
|
161
187
|
const scope = meta?.name ? [meta.name] : [];
|
|
@@ -164,7 +190,10 @@ export async function runOpenPr(root, opts = {}) {
|
|
|
164
190
|
const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer, repos: meta ? [meta] : [] });
|
|
165
191
|
const assignees = committer ? [committer] : [];
|
|
166
192
|
|
|
167
|
-
|
|
193
|
+
// `creator` is injectable (mirrors gateOpen's) so a test can assert the base that reaches the
|
|
194
|
+
// platform CLI without shelling out to gh/glab.
|
|
195
|
+
const creator = opts.creator || createPr;
|
|
196
|
+
const r = creator(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
|
|
168
197
|
if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
169
198
|
ok(`opened ${r.url}`);
|
|
170
199
|
if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
|