yadflow 3.0.0 → 3.1.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 +9 -9
- package/bin/yad.mjs +30 -5
- package/cli/companion.mjs +61 -0
- package/cli/doctor.mjs +11 -0
- package/cli/epic-state.mjs +13 -3
- package/cli/gate.mjs +87 -5
- package/cli/manifest.mjs +1 -0
- package/cli/openpr.mjs +5 -1
- package/cli/plan.mjs +10 -4
- package/cli/platform.mjs +149 -23
- package/cli/reconcile.mjs +3 -3
- package/cli/review.mjs +145 -0
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +8 -0
- package/skills/sdlc/module-help.csv +1 -0
- package/skills/yad-connect-repos/references/hub-config.md +10 -0
- package/skills/yad-docs-overview/references/pipeline-model.md +1 -0
- package/skills/yad-engineer-review/SKILL.md +13 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +15 -2
- package/skills/yad-hub-bridge/references/bridge.md +34 -0
- package/skills/yad-open-pr/SKILL.md +10 -0
- package/skills/yad-pr-template/templates/checks/pr-template.sh +9 -0
- package/skills/yad-review-companion/SKILL.md +89 -0
- package/skills/yad-review-gate/SKILL.md +5 -1
- package/skills/yad-review-gate/references/gating.md +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
# [3.
|
|
1
|
+
# [3.1.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.0.0...v3.1.0) (2026-06-30)
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
### Features
|
|
4
|
+
### Bug Fixes
|
|
8
5
|
|
|
9
|
-
* **
|
|
6
|
+
* **bridge:** harden reviewer routing on GitHub + GitLab ([8d9cf24](https://github.com/abdelrahmannasr/yadflow/commit/8d9cf24c10adf1403959fa44a30fd13f7b362fb9))
|
|
7
|
+
* **review:** address PR [#89](https://github.com/abdelrahmannasr/yadflow/issues/89) code review (bridge/companion robustness) ([4864fae](https://github.com/abdelrahmannasr/yadflow/commit/4864fae0cb7131948509dc6786eae9eb18c80497)), closes [#15](https://github.com/abdelrahmannasr/yadflow/issues/15)
|
|
10
8
|
|
|
11
9
|
|
|
12
|
-
###
|
|
10
|
+
### Features
|
|
13
11
|
|
|
14
|
-
*
|
|
15
|
-
|
|
12
|
+
* **cli:** install newly-added skills on `yad update` ([872b92c](https://github.com/abdelrahmannasr/yadflow/commit/872b92ce1ce2ff5e8154add48b6ebdfef0d87cd4))
|
|
13
|
+
* **review:** add the Review Companion (front half) ([d45bf23](https://github.com/abdelrahmannasr/yadflow/commit/d45bf239fa27a7b84cba409a7fdc23f64f99753d))
|
|
14
|
+
* **review:** config switch, pr-template tolerance, and docs for the companion ([13aafcd](https://github.com/abdelrahmannasr/yadflow/commit/13aafcd6f359d6a0c7c39c53f01fd75357ba6aeb))
|
|
15
|
+
* **review:** extend the companion + bridge to the back half (code PRs) ([1711ca9](https://github.com/abdelrahmannasr/yadflow/commit/1711ca9753f5eb67fad3fa5c828733e45ca9686f))
|
|
16
16
|
|
|
17
17
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
18
18
|
|
package/bin/yad.mjs
CHANGED
|
@@ -4,10 +4,11 @@ import { VERSION } from '../cli/manifest.mjs';
|
|
|
4
4
|
import { c, log, closePrompts } from '../cli/lib.mjs';
|
|
5
5
|
import { runSetup } from '../cli/setup.mjs';
|
|
6
6
|
import { reconcile } from '../cli/reconcile.mjs';
|
|
7
|
-
import { gateOpen, gateSync, gateComments, gateStatus, gateCi } from '../cli/gate.mjs';
|
|
7
|
+
import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer } from '../cli/gate.mjs';
|
|
8
8
|
import { isValidEpicId } from '../cli/epic-state.mjs';
|
|
9
9
|
import { runCommit } from '../cli/commit.mjs';
|
|
10
10
|
import { runOpenPr } from '../cli/openpr.mjs';
|
|
11
|
+
import { reviewTrailer, reviewContext, reviewNudge, reviewReconcile } from '../cli/review.mjs';
|
|
11
12
|
import { runShip } from '../cli/ship.mjs';
|
|
12
13
|
import { runRepo } from '../cli/repo.mjs';
|
|
13
14
|
import { runRoster } from '../cli/roster.mjs';
|
|
@@ -26,7 +27,8 @@ ${c.bold('Setup & maintenance')}
|
|
|
26
27
|
yad check Report what is missing / drifted / stale / legacy (read-only)
|
|
27
28
|
yad check --fix Reconcile: fill what is missing, update what changed
|
|
28
29
|
yad update Apply drift only (alias for: check --fix --scope=changed);
|
|
29
|
-
|
|
30
|
+
installs newly-added skills, updates changed skills + gate scripts,
|
|
31
|
+
and migrates pre-2.0 sdlc-* installs to the yad-* names
|
|
30
32
|
yad doctor [--json] Environment + state health: tools/auth, config files,
|
|
31
33
|
repo paths, epic ledgers (exit 1 on any failure)
|
|
32
34
|
yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
|
|
@@ -51,6 +53,10 @@ ${c.bold('Review gate (front half)')}
|
|
|
51
53
|
yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
|
|
52
54
|
yad gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
53
55
|
yad gate status <epic> Show each review step + approvals
|
|
56
|
+
yad gate review <epic> [artifact] Print the grounding bundle for the review companion
|
|
57
|
+
(artifact + risk + contract + PR + code-maps) — fun, easy review
|
|
58
|
+
yad gate trailer <epic> [artifact] --body <text> [--pr <n>]
|
|
59
|
+
Upsert the companion's 60-sec briefing into the PR/MR description
|
|
54
60
|
yad gate ci [--branch <head>] [--pr <n>] [--merged]
|
|
55
61
|
CI entry (hub workflow): pre-merge is read-only (nothing pushed);
|
|
56
62
|
--merged advances the step + flips artifact status on the default branch
|
|
@@ -61,6 +67,10 @@ ${c.bold('Build helpers')}
|
|
|
61
67
|
branch opens the front-half artifact-review PR (delegates to
|
|
62
68
|
gate open), any other hub branch uses the code-task template
|
|
63
69
|
yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
|
|
70
|
+
yad review trailer --repo <r> --pr <n> --body <text> Post the companion's 60-sec briefing to a code PR/MR
|
|
71
|
+
yad review context --repo <r> --pr <n> Print the grounding bundle for cards/chat
|
|
72
|
+
yad review nudge --repo <r> --pr <n> Friendly @-mention on a bare code-PR approve
|
|
73
|
+
yad review reconcile --epic <id> --repo <r> --pr <n> Bridge: stamp engagement onto the build-log ship
|
|
64
74
|
yad repo list Show connected repos (fresh / stale)
|
|
65
75
|
yad repo refresh [name] Re-pack a stale repo (a human decision)
|
|
66
76
|
|
|
@@ -97,7 +107,7 @@ ${c.bold('Options')}
|
|
|
97
107
|
-h, --help Show this help
|
|
98
108
|
-v, --version Print version`;
|
|
99
109
|
|
|
100
|
-
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team']);
|
|
110
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body']);
|
|
101
111
|
|
|
102
112
|
function parseArgs(argv) {
|
|
103
113
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -184,7 +194,7 @@ async function main() {
|
|
|
184
194
|
const [, action, epic, artifact] = o._;
|
|
185
195
|
// `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
|
|
186
196
|
if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
|
|
187
|
-
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
197
|
+
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|review|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
188
198
|
// The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
|
|
189
199
|
if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
190
200
|
// In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
|
|
@@ -194,7 +204,22 @@ async function main() {
|
|
|
194
204
|
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
|
|
195
205
|
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
196
206
|
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
197
|
-
else
|
|
207
|
+
else if (action === 'review') await gateReview(o.dir, { epic, artifact });
|
|
208
|
+
else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
|
|
209
|
+
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|review|trailer|ci)`)); process.exitCode = 1; }
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'review': {
|
|
213
|
+
const [, action] = o._;
|
|
214
|
+
if (action === 'trailer') await reviewTrailer(o.dir, { repo: o.repo, pr: o.pr, body: o.body || o.message });
|
|
215
|
+
else if (action === 'context' || action === 'chat' || action === 'cards') await reviewContext(o.dir, { repo: o.repo, pr: o.pr });
|
|
216
|
+
else if (action === 'nudge') await reviewNudge(o.dir, { repo: o.repo, pr: o.pr });
|
|
217
|
+
else if (action === 'reconcile') {
|
|
218
|
+
// The epic becomes a path segment under epics/ — reject anything but EP-<slug> (no `../` escape).
|
|
219
|
+
if (!o.epic || !isValidEpicId(o.epic)) { log(c.red(`invalid or missing --epic: ${o.epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
220
|
+
await reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, pr: o.pr });
|
|
221
|
+
}
|
|
222
|
+
else { log(c.red('usage: yad review <trailer|context|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]')); process.exitCode = 1; }
|
|
198
223
|
break;
|
|
199
224
|
}
|
|
200
225
|
case 'commit':
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// cli/companion.mjs — pure, perspective-neutral helpers for the Review Companion, shared by the front
|
|
2
|
+
// half (yad gate …) and the back half (yad review …). The CLI never calls an LLM — generation happens
|
|
3
|
+
// in the skill/harness layer (like yad-learn/yad-docs). This module owns the platform MARKERS, the
|
|
4
|
+
// engagement parsing, the trailer-block upsert, and the message text the skill and the gate share.
|
|
5
|
+
//
|
|
6
|
+
// Design philosophy ("visible, not impossible"): the engagement signal is GAMEABLE by design. It makes
|
|
7
|
+
// review quality VISIBLE (verified vs none + a friendly public nudge); it never claims to prove a human
|
|
8
|
+
// read anything. All markers ride in PLATFORM data (PR/MR bodies + comment threads), never in a ledger
|
|
9
|
+
// file — so the ledger-guard check never sees them and the reviewer never writes the ledger.
|
|
10
|
+
|
|
11
|
+
export const TRAILER_BEGIN = '<!-- yad:trailer -->';
|
|
12
|
+
export const TRAILER_END = '<!-- /yad:trailer -->';
|
|
13
|
+
// A companion- or nudge-posted comment carries this so the gate EXCLUDES it from the thread-resolution
|
|
14
|
+
// blocking check. Such threads are deliberately left unresolved as a permanent PR/MR history trail.
|
|
15
|
+
export const NOBLOCK_MARK = '<!-- yad:noblock -->';
|
|
16
|
+
const ENGAGEMENT_RE = /<!--\s*yad:engagement\s+(verified|none)\s*-->/i;
|
|
17
|
+
|
|
18
|
+
// True when a comment/thread body is companion scaffolding (card deck, chat log, nudge) — never blocks.
|
|
19
|
+
export function isNoBlock(body) {
|
|
20
|
+
return typeof body === 'string' && body.includes(NOBLOCK_MARK);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The engagement signal a reviewer's APPROVE carries in its review body: a companion-driven approve
|
|
24
|
+
// embeds `<!-- yad:engagement verified -->`; a bare UI click has no marker → 'none'.
|
|
25
|
+
export function parseEngagement(body) {
|
|
26
|
+
const m = ENGAGEMENT_RE.exec(typeof body === 'string' ? body : '');
|
|
27
|
+
return m ? m[1].toLowerCase() : 'none';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Body for a companion approval review — embeds the engagement marker the gate reads back.
|
|
31
|
+
export function engagementBody(kind = 'verified', note = '') {
|
|
32
|
+
const k = kind === 'verified' ? 'verified' : 'none';
|
|
33
|
+
return `${note ? note + '\n\n' : ''}<!-- yad:engagement ${k} -->`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Tag a companion comment so the gate never blocks on it (and it persists as PR/MR history).
|
|
37
|
+
export function noBlock(body = '') {
|
|
38
|
+
return `${body}\n\n${NOBLOCK_MARK}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Idempotently insert/replace the trailer block in a PR/MR description. String-based (no regex over the
|
|
42
|
+
// markers) so regenerating on every artifact change never duplicates or mangles the block.
|
|
43
|
+
export function upsertTrailerBlock(description = '', trailer = '') {
|
|
44
|
+
const desc = typeof description === 'string' ? description : '';
|
|
45
|
+
const block = `${TRAILER_BEGIN}\n${trailer}\n${TRAILER_END}`;
|
|
46
|
+
const s = desc.indexOf(TRAILER_BEGIN);
|
|
47
|
+
// Find the END marker AFTER the BEGIN, so an earlier quoted `<!-- /yad:trailer -->` in the body
|
|
48
|
+
// can't break idempotency (otherwise a second block gets prepended instead of replacing).
|
|
49
|
+
const e = s === -1 ? -1 : desc.indexOf(TRAILER_END, s + TRAILER_BEGIN.length);
|
|
50
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
51
|
+
return desc.slice(0, s) + block + desc.slice(e + TRAILER_END.length);
|
|
52
|
+
}
|
|
53
|
+
return desc ? `${block}\n\n${desc}` : block;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The friendly, public nudge for a bare approve (engagement: none). Warm, short, names the reviewer,
|
|
57
|
+
// and carries the noblock marker so it never holds the gate.
|
|
58
|
+
export function nudgeMessage(login, cmd = 'yad gate review') {
|
|
59
|
+
const who = login ? `@${login}` : 'there';
|
|
60
|
+
return noBlock(`Thanks ${who} for the quick approval 🙏 — mind running \`${cmd}\`? It won't take long, and a real read has way more impact than the few minutes it costs 💛`);
|
|
61
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -94,6 +94,17 @@ export function projectChecks(checks, root) {
|
|
|
94
94
|
}
|
|
95
95
|
if (bad.length) check(checks, 'roster', 'project', 'warn', `roster login(s) not found on ${hub.platform}: ${bad.join(', ')}`, 'fix the login or re-run `yad setup` (they cannot satisfy a gate)');
|
|
96
96
|
else check(checks, 'roster', 'project', 'ok', `roster: ${(hub.roster || []).length} member(s) validated on ${hub.platform}`);
|
|
97
|
+
// GitLab API reachability: the gate reads MR state via `glab api …` (approvals, discussions).
|
|
98
|
+
// A present+authenticated glab whose token lacks api scope would still break readPrGitLab, so
|
|
99
|
+
// probe a cheap api call (warn-only) to surface it before a sync silently holds the gate.
|
|
100
|
+
if (hub.platform === 'gitlab') {
|
|
101
|
+
// Scope the probe to the hub's own host (like the auth check above) so a multi-instance
|
|
102
|
+
// setup doesn't hit the wrong GitLab and emit a misleading warning.
|
|
103
|
+
const apiArgs = host ? ['api', 'version', '--hostname', host] : ['api', 'version'];
|
|
104
|
+
if (!run('glab', apiArgs).ok) {
|
|
105
|
+
check(checks, 'gitlab-api', 'project', 'warn', `glab is authenticated but \`glab api\` failed${host ? ` for ${host}` : ''} [YAD-ENV-002]`, 'ensure the token has `api` scope — the gate reads MR approvals/discussions via the API');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
97
108
|
// Solo + GitHub: a branch that "requires approvals" would block the solo dev's own merge
|
|
98
109
|
// (they can't approve their own PR). Best-effort probe; a 404 (no protection) is fine.
|
|
99
110
|
if (isSolo(hub) && hub.platform === 'github') {
|
package/cli/epic-state.mjs
CHANGED
|
@@ -189,6 +189,7 @@ export function gatePredicate({
|
|
|
189
189
|
threadsResolved = true,
|
|
190
190
|
merged = true,
|
|
191
191
|
solo = false,
|
|
192
|
+
requireEngagement = false,
|
|
192
193
|
}) {
|
|
193
194
|
// Phase 6: an INHERITED step (a change-epic carrying a parent artifact by reference) is satisfied
|
|
194
195
|
// without re-review — its approval lives upstream in the thread, recorded as an `inherited` provenance
|
|
@@ -210,9 +211,14 @@ export function gatePredicate({
|
|
|
210
211
|
const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
|
|
211
212
|
const live = forStep.filter((a) => !stale.includes(a));
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
// requireEngagement (config `hub.review.requireEngagement`, soft-off by default): only an approval
|
|
215
|
+
// carrying a verified engagement signal counts. The signal is gameable by design — this raises the
|
|
216
|
+
// cost of a bare rubber-stamp, it does not claim to prove a human read the artifact.
|
|
217
|
+
const counted = requireEngagement ? live.filter((a) => a.engagement === 'verified') : live;
|
|
218
|
+
const unengaged = requireEngagement ? live.filter((a) => a.engagement !== 'verified').length : 0;
|
|
219
|
+
const owners = uniqueBy(counted.filter((a) => a.role === 'owner'), 'approver');
|
|
220
|
+
const reviewers = uniqueBy(counted.filter((a) => a.role === 'reviewer'), 'approver');
|
|
221
|
+
const domainOwners = counted.filter((a) => a.role === 'domain-owner');
|
|
216
222
|
|
|
217
223
|
const escalate = isEscalated(step);
|
|
218
224
|
const missing = [];
|
|
@@ -230,6 +236,10 @@ export function gatePredicate({
|
|
|
230
236
|
}
|
|
231
237
|
}
|
|
232
238
|
const approvalsSatisfied = missing.length === 0;
|
|
239
|
+
// Surface engagement-gated approvals that did not count (only when requireEngagement holds the gate).
|
|
240
|
+
if (!solo && requireEngagement && !approvalsSatisfied && unengaged) {
|
|
241
|
+
missing.push(`${unengaged} approval(s) without verified engagement — run \`yad gate review\` so they count`);
|
|
242
|
+
}
|
|
233
243
|
// A stale approval only matters when approvals are required (team mode); in solo they are moot.
|
|
234
244
|
if (!solo && stale.length) missing.unshift(`${stale.length} approval(s) revoked — artifact changed; re-approve`);
|
|
235
245
|
if (!threadsResolved) missing.push('unresolved review comments');
|
package/cli/gate.mjs
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
14
|
upsertHubPr, DISCOVERY_FILES,
|
|
15
15
|
} from './epic-state.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
|
|
18
|
+
getPrBody, editPrBody, postComment,
|
|
19
|
+
} from './platform.mjs';
|
|
20
|
+
import { isNoBlock, upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
|
|
17
21
|
import { syncStatuses } from './artifact-status.mjs';
|
|
18
22
|
import { err } from './errors.mjs';
|
|
19
23
|
|
|
@@ -112,6 +116,11 @@ const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo ==
|
|
|
112
116
|
// or reviews could never advance. Mirrors plan.mjs hubActions.
|
|
113
117
|
const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
|
|
114
118
|
|
|
119
|
+
// requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
|
|
120
|
+
// approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
|
|
121
|
+
// but is recorded `engagement: none` and draws the friendly nudge.
|
|
122
|
+
const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
|
|
123
|
+
|
|
115
124
|
// Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
|
|
116
125
|
// revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
|
|
117
126
|
// reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
|
|
@@ -142,6 +151,7 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today }) {
|
|
|
142
151
|
...(r.domain ? { domain: r.domain } : {}),
|
|
143
152
|
status: 'approved', date: today, source: 'bridge',
|
|
144
153
|
artifactHash: artHash, approvedAt,
|
|
154
|
+
engagement: r.engagement === 'verified' ? 'verified' : 'none',
|
|
145
155
|
...(r.unverified ? { unverified: true } : {}),
|
|
146
156
|
});
|
|
147
157
|
}
|
|
@@ -187,6 +197,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
187
197
|
const roster = hub.roster || [];
|
|
188
198
|
const defaultReviewers = 1;
|
|
189
199
|
const solo = isSolo(hub);
|
|
200
|
+
const reqEng = requireEngagement(hub);
|
|
190
201
|
// Local invocation in bridge mode is ADVISORY: CI is the sole ledger writer, so a human run reads
|
|
191
202
|
// the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
|
|
192
203
|
// Without the bridge (platform but no gate-sync CI) the local command stays the writer.
|
|
@@ -222,7 +233,10 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
222
233
|
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
|
|
223
234
|
|
|
224
235
|
const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
225
|
-
|
|
236
|
+
// 2f: companion scaffolding + nudge threads carry the noblock marker and are EXCLUDED from the
|
|
237
|
+
// blocking check — they stay unresolved as a permanent PR/MR history trail but never hold the gate.
|
|
238
|
+
// Only genuine (unflagged) unresolved threads block.
|
|
239
|
+
const unresolved = (pull.threads || []).filter((t) => !t.resolved && !isNoBlock(t.body));
|
|
226
240
|
const threadsResolved = unresolved.length === 0 && changeRequested.length === 0;
|
|
227
241
|
const blocking = [
|
|
228
242
|
...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
|
|
@@ -232,9 +246,21 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
|
|
|
232
246
|
if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
|
|
233
247
|
comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
|
|
234
248
|
|
|
249
|
+
// Social nudge: a bare APPROVE (no verified engagement) still counts (soft default), but the bot
|
|
250
|
+
// posts a friendly public @-mention inviting the reviewer to run the companion. Idempotent via
|
|
251
|
+
// pr.nudged; only on the writer path (a platform comment, not a ledger write).
|
|
252
|
+
if (!readOnly) {
|
|
253
|
+
const nudged = new Set(pr.nudged || []);
|
|
254
|
+
for (const rv of pull.reviews) {
|
|
255
|
+
if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login || nudged.has(rv.login)) continue;
|
|
256
|
+
if (postComment(platform, pr.number, nudgeMessage(rv.login), { cwd: root }).ok) nudged.add(rv.login);
|
|
257
|
+
}
|
|
258
|
+
pr.nudged = [...nudged];
|
|
259
|
+
}
|
|
260
|
+
|
|
235
261
|
const pred = gatePredicate({
|
|
236
262
|
step, approvals, currentHash: curHash, touchedDomains: domains,
|
|
237
|
-
defaultReviewers, threadsResolved, merged: pull.merged, solo,
|
|
263
|
+
defaultReviewers, threadsResolved, merged: pull.merged, solo, requireEngagement: reqEng,
|
|
238
264
|
});
|
|
239
265
|
|
|
240
266
|
log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
|
|
@@ -470,7 +496,7 @@ export async function gateStatus(root, { epic } = {}) {
|
|
|
470
496
|
// the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
|
|
471
497
|
// the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
|
|
472
498
|
export async function gateOpen(root, { epic, artifact, head, creator = createPr } = {}) {
|
|
473
|
-
const { hub } = loadHub(root);
|
|
499
|
+
const { hub, repos } = loadHub(root);
|
|
474
500
|
const epicDir = epicRoot(root, epic);
|
|
475
501
|
const ledger = loadLedger(epicDir);
|
|
476
502
|
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
@@ -504,12 +530,16 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
|
|
|
504
530
|
// domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
|
|
505
531
|
// to review their own artifact). Scope is the hub plus every touched domain.
|
|
506
532
|
const committer = resolveCommitterLogin(root, hub.roster || []);
|
|
507
|
-
const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer });
|
|
533
|
+
const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer, repos });
|
|
508
534
|
const assignees = committer ? [committer] : [];
|
|
509
535
|
const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
|
|
510
536
|
info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
|
|
511
537
|
const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
|
|
512
538
|
if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
|
|
539
|
+
// Surface routing: who was assigned as a reviewer, who was @-mentioned (GitLab field cap), and any
|
|
540
|
+
// login the platform could not add (dropped) so a partial roster is visible, not silent.
|
|
541
|
+
if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
|
|
542
|
+
if (r.dropped?.length) warn(`could not request as reviewer (unknown/non-collaborator login): ${r.dropped.join(', ')}`);
|
|
513
543
|
|
|
514
544
|
if (!bridge) {
|
|
515
545
|
ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null, url: r.url, branch, lastSyncedAt: null });
|
|
@@ -522,6 +552,58 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
|
|
|
522
552
|
return { url: r.url };
|
|
523
553
|
}
|
|
524
554
|
|
|
555
|
+
// `yad gate review <epic> [artifact]` — assemble + print the grounding bundle the companion skill uses
|
|
556
|
+
// to generate the 60-sec trailer / swipe cards and to run the grounded chat (artifact + risk tags +
|
|
557
|
+
// contract + PR + repo code-maps). The CLI never calls an LLM; the skill (yad-review-companion)
|
|
558
|
+
// consumes this JSON, generates, and posts back via the platform (trailer/comments/approval).
|
|
559
|
+
export async function gateReview(root, { epic, artifact } = {}) {
|
|
560
|
+
const { hub, repos } = loadHub(root);
|
|
561
|
+
const epicDir = epicRoot(root, epic);
|
|
562
|
+
const ledger = loadLedger(epicDir);
|
|
563
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
564
|
+
const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
|
|
565
|
+
const art = artifact || pr?.artifact || null;
|
|
566
|
+
const step = art ? findReviewStep(ledger.state, art) : null;
|
|
567
|
+
const bundle = {
|
|
568
|
+
epic,
|
|
569
|
+
artifact: art,
|
|
570
|
+
platform: hub?.platform || null,
|
|
571
|
+
pr: pr ? { number: pr.number, url: pr.url } : null,
|
|
572
|
+
step: step ? { id: step.id, riskTags: step.risk_tags || [], escalated: isEscalated(step) } : null,
|
|
573
|
+
artifactPath: art ? path.join(epicDir, art) : null,
|
|
574
|
+
contractPath: art && base(art) === 'architecture' ? path.join(epicDir, 'contract.md') : null,
|
|
575
|
+
touchedDomains: step ? touchedDomains(epicDir, step) : [],
|
|
576
|
+
repos: (repos || []).map((r) => ({
|
|
577
|
+
name: r.name,
|
|
578
|
+
codeMap: r.name ? path.join(root, '.sdlc/code-context', r.name, 'code-map.md') : null,
|
|
579
|
+
})),
|
|
580
|
+
requireEngagement: requireEngagement(hub),
|
|
581
|
+
markers: { trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->', engagementVerified: '<!-- yad:engagement verified -->' },
|
|
582
|
+
};
|
|
583
|
+
log(JSON.stringify(bundle, null, 2));
|
|
584
|
+
return bundle;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// `yad gate trailer <epic> [artifact] --body <text> [--pr <n>]` — the skill generates the 60-second
|
|
588
|
+
// briefing text and passes it here; this upserts it idempotently into the review PR/MR description as a
|
|
589
|
+
// delimited block, so regenerating on every artifact change never duplicates it. A platform write only.
|
|
590
|
+
export async function gateTrailer(root, { epic, artifact, body, number, getBody = getPrBody, editBody = editPrBody } = {}) {
|
|
591
|
+
const { hub } = loadHub(root);
|
|
592
|
+
if (!hub?.platform) { warn('no hub platform configured — the trailer posts to the PR/MR (file-only has none)'); return; }
|
|
593
|
+
if (!body || !String(body).trim()) { fail('trailer body is required: `yad gate trailer <epic> <artifact> --body <text>` (the companion generates it)'); process.exitCode = 1; return; }
|
|
594
|
+
const epicDir = epicRoot(root, epic);
|
|
595
|
+
const ledger = loadLedger(epicDir);
|
|
596
|
+
const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
|
|
597
|
+
const n = number || pr?.number;
|
|
598
|
+
if (!n) { warn('no PR number — pass `--pr <n>` (in bridge mode the PR is recorded in the ledger only at merge)'); return; }
|
|
599
|
+
const cur = getBody(hub.platform, n, { cwd: root });
|
|
600
|
+
if (!cur.ok) { fail(`could not read PR #${n} description: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
601
|
+
const r = editBody(hub.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
|
|
602
|
+
if (!r.ok) { fail(`could not update PR #${n}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
603
|
+
ok(`trailer posted to PR #${n}`);
|
|
604
|
+
return { number: n };
|
|
605
|
+
}
|
|
606
|
+
|
|
525
607
|
// ---- helpers ------------------------------------------------------------------------------------
|
|
526
608
|
const base = (artifact) => artifactBase(artifact);
|
|
527
609
|
|
package/cli/manifest.mjs
CHANGED
package/cli/openpr.mjs
CHANGED
|
@@ -136,12 +136,16 @@ export async function runOpenPr(root, opts = {}) {
|
|
|
136
136
|
const roster = hub.roster || [];
|
|
137
137
|
const committer = resolveCommitterLogin(repoRoot, roster);
|
|
138
138
|
const scope = meta?.name ? [meta.name] : [];
|
|
139
|
-
|
|
139
|
+
// Pass the repo registry entry so a domain owner declared only in repos.json (not the roster roles
|
|
140
|
+
// map) is still requested as a reviewer (BUG-1).
|
|
141
|
+
const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer, repos: meta ? [meta] : [] });
|
|
140
142
|
const assignees = committer ? [committer] : [];
|
|
141
143
|
|
|
142
144
|
const r = createPr(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
|
|
143
145
|
if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
144
146
|
ok(`opened ${r.url}`);
|
|
147
|
+
if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
|
|
148
|
+
if (r.dropped?.length) hand(`could not request as reviewer (unknown/non-collaborator login): ${r.dropped.join(', ')}`);
|
|
145
149
|
if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` for required reviewers');
|
|
146
150
|
return { url: r.url };
|
|
147
151
|
}
|
package/cli/plan.mjs
CHANGED
|
@@ -34,25 +34,31 @@ export function ideTargetsFor(root) {
|
|
|
34
34
|
return present.length ? present : ['.claude'];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// A brand-new first-party skill is `missing` on every existing install. Relabel that to status `'new'`
|
|
38
|
+
// so it rides `yad update` (--scope=changed) — like 'legacy'/'removed', the `changed` filter only
|
|
39
|
+
// excludes literal 'missing', so 'new' survives. Scoped to SKILL installs ONLY: repo/hub wiring and
|
|
40
|
+
// _bmad files stay 'missing' (excluded from update), so `update` never does one-time setup.
|
|
41
|
+
const asNewSkill = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a);
|
|
42
|
+
|
|
37
43
|
// Module = skills installed into each IDE target + the _bmad/sdlc registration.
|
|
38
44
|
export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
39
45
|
const actions = [];
|
|
40
46
|
for (const ide of ideTargets) {
|
|
41
47
|
if (ide === '.opencode') {
|
|
42
48
|
for (const s of SKILLS) {
|
|
43
|
-
actions.push(fileAction(
|
|
49
|
+
actions.push(asNewSkill(fileAction(
|
|
44
50
|
ide, s,
|
|
45
51
|
asset('skills', s, 'SKILL.md'),
|
|
46
52
|
path.join(root, IDE_OPENCODE_DIR, `${s}.md`),
|
|
47
|
-
));
|
|
53
|
+
)));
|
|
48
54
|
}
|
|
49
55
|
} else {
|
|
50
56
|
for (const s of SKILLS) {
|
|
51
|
-
actions.push(dirAction(
|
|
57
|
+
actions.push(asNewSkill(dirAction(
|
|
52
58
|
ide, s,
|
|
53
59
|
asset('skills', s),
|
|
54
60
|
path.join(root, ide, 'skills', s),
|
|
55
|
-
));
|
|
61
|
+
)));
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
}
|