yadflow 3.0.0 → 3.2.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 CHANGED
@@ -1,18 +1,14 @@
1
- # [3.0.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.18.1...v3.0.0) (2026-06-29)
1
+ # [3.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.1.0...v3.2.0) (2026-06-30)
2
2
 
3
3
 
4
- * feat!: remove unused yad-review-comments skill ([da6ad60](https://github.com/abdelrahmannasr/yadflow/commit/da6ad608bedf86a58ad09c2aefbf5e6367a41ae4))
5
-
6
-
7
- ### Features
4
+ ### Bug Fixes
8
5
 
9
- * **cli:** purge removed skills from existing installs ([8887d6d](https://github.com/abdelrahmannasr/yadflow/commit/8887d6d5598775986729c77f1500ac6773e4591e))
6
+ * **review:** keep walkthrough STDOUT pure JSON (diagnostics to stderr) ([0d46455](https://github.com/abdelrahmannasr/yadflow/commit/0d46455919997ebcaa9b1f9929f5265023d05c5a))
10
7
 
11
8
 
12
- ### BREAKING CHANGES
9
+ ### Features
13
10
 
14
- * the yad-review-comments skill and its REVIEW_COMMENTS.md repo
15
- wiring are removed; `npx yadflow setup` now installs 34 skills instead of 35.
11
+ * **review:** add yad-pair-review — guided two-way teaching walkthrough ([337cf9a](https://github.com/abdelrahmannasr/yadflow/commit/337cf9a0814f79f411db13a59af9afbdb64cf57d))
16
12
 
17
13
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
18
14
 
package/README.md CHANGED
@@ -66,6 +66,19 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
66
66
  - **Everything is files.** State, approvals, the contract lock, the build log — all plain files under
67
67
  `epics/EP-<slug>/`. No database. The audit trail *is* the repo.
68
68
 
69
+ ## Review, made a pairing — and a lesson
70
+
71
+ Reviewing AI-generated code is where governance lives or dies, so Yadflow makes the honest review the
72
+ *easiest* path — and, optionally, a **teaching** one. The **Review Companion** turns a PR/MR into a
73
+ 60-second trailer, swipe-through cards, and a grounded chat. On top of it, **Pair Review**
74
+ (`yad pair-review`) runs a guided, two-way walkthrough: the AI walks the engineer through the change
75
+ **one risk-ordered stop at a time**, explains each change in depth, then **asks them about it**; the
76
+ engineer answers and asks back, until **both are satisfied**. The session doubles as a lesson — it
77
+ demonstrates a transferable review method, scores the engineer against it, and records their review-skill
78
+ growth in a **private, local-only** learning log (`yad status` rolls it up). It's **soft and additive**:
79
+ it never blocks a merge, it rides the same `engagement: verified` signal, and any genuine concern it
80
+ surfaces blocks like a normal review comment.
81
+
69
82
  ## Who it's for
70
83
 
71
84
  Tech leads and engineering managers who want their team to move fast with AI **without** giving up
@@ -78,7 +91,7 @@ development, not another code generator.
78
91
  - **[Terminology & workflow report](https://abdelrahmannasr.github.io/yadflow/)** — every term, artifact, gate, and skill on one illustrated page.
79
92
  - **[TEAM-GUIDE.md](TEAM-GUIDE.md)** — the short, plain-language version for a developer team.
80
93
  - **[docs/CLI.md](docs/CLI.md)** — the full `yad` command reference, the PR-driven gate, and `yad doctor` codes.
81
- - **[docs/SKILLS.md](docs/SKILLS.md)** — the catalogue of all 34 agent skills.
94
+ - **[docs/SKILLS.md](docs/SKILLS.md)** — the catalogue of all 36 agent skills.
82
95
  - **[docs/WALKTHROUGH.md](docs/WALKTHROUGH.md)** — the by-hand, end-to-end path through every phase.
83
96
  - **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[RESEARCH-NOTES.md](RESEARCH-NOTES.md)** · **[RELEASING.md](RELEASING.md)**
84
97
 
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, gateWalkthrough } 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, reviewWalkthrough } 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
- also migrates pre-2.0 sdlc-* installs to the yad-* names
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,12 @@ ${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 walkthrough <epic> [artifact] Grounding bundle + ordered risk-tagged stops for the
59
+ pair-review walkthrough (yad-pair-review) — guided, teaching review
60
+ yad gate trailer <epic> [artifact] --body <text> [--pr <n>]
61
+ Upsert the companion's 60-sec briefing into the PR/MR description
54
62
  yad gate ci [--branch <head>] [--pr <n>] [--merged]
55
63
  CI entry (hub workflow): pre-merge is read-only (nothing pushed);
56
64
  --merged advances the step + flips artifact status on the default branch
@@ -61,6 +69,12 @@ ${c.bold('Build helpers')}
61
69
  branch opens the front-half artifact-review PR (delegates to
62
70
  gate open), any other hub branch uses the code-task template
63
71
  yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
72
+ yad review trailer --repo <r> --pr <n> --body <text> Post the companion's 60-sec briefing to a code PR/MR
73
+ yad review context --repo <r> --pr <n> Print the grounding bundle for cards/chat
74
+ yad review walkthrough --repo <r> --pr <n> Bundle + ordered risk-tagged stops for the
75
+ pair-review walkthrough (yad-pair-review)
76
+ yad review nudge --repo <r> --pr <n> Friendly @-mention on a bare code-PR approve
77
+ yad review reconcile --epic <id> --repo <r> --pr <n> Bridge: stamp engagement onto the build-log ship
64
78
  yad repo list Show connected repos (fresh / stale)
65
79
  yad repo refresh [name] Re-pack a stale repo (a human decision)
66
80
 
@@ -97,7 +111,7 @@ ${c.bold('Options')}
97
111
  -h, --help Show this help
98
112
  -v, --version Print version`;
99
113
 
100
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team']);
114
+ 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
115
 
102
116
  function parseArgs(argv) {
103
117
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -184,7 +198,7 @@ async function main() {
184
198
  const [, action, epic, artifact] = o._;
185
199
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
186
200
  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; }
201
+ if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
188
202
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
189
203
  if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
190
204
  // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
@@ -194,7 +208,24 @@ async function main() {
194
208
  else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
195
209
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
196
210
  else if (action === 'status') await gateStatus(o.dir, { epic });
197
- else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|ci)`)); process.exitCode = 1; }
211
+ else if (action === 'review') await gateReview(o.dir, { epic, artifact });
212
+ else if (action === 'walkthrough') await gateWalkthrough(o.dir, { epic, artifact });
213
+ else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
214
+ else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
215
+ break;
216
+ }
217
+ case 'review': {
218
+ const [, action] = o._;
219
+ if (action === 'trailer') await reviewTrailer(o.dir, { repo: o.repo, pr: o.pr, body: o.body || o.message });
220
+ else if (action === 'context' || action === 'chat' || action === 'cards') await reviewContext(o.dir, { repo: o.repo, pr: o.pr });
221
+ else if (action === 'walkthrough') await reviewWalkthrough(o.dir, { repo: o.repo, pr: o.pr });
222
+ else if (action === 'nudge') await reviewNudge(o.dir, { repo: o.repo, pr: o.pr });
223
+ else if (action === 'reconcile') {
224
+ // The epic becomes a path segment under epics/ — reject anything but EP-<slug> (no `../` escape).
225
+ 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; }
226
+ await reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, pr: o.pr });
227
+ }
228
+ else { log(c.red('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]')); process.exitCode = 1; }
198
229
  break;
199
230
  }
200
231
  case 'commit':
@@ -0,0 +1,90 @@
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
+ }
62
+
63
+ // ---- pair review (yad-pair-review) --------------------------------------------------------------
64
+ // The pair-review walkthrough records its session as a PERMANENT PR/MR comment: the transcript summary,
65
+ // the review-skill scorecard, and BOTH sign-offs. It carries this marker so a recorded pair session is
66
+ // IDENTIFIABLE in platform history (the skill / `yad status` can recognise + count paired reviews for the
67
+ // 🏆 roll-up), AND the noblock marker so the session thread never holds the gate. The human's actual
68
+ // approval still rides the existing `<!-- yad:engagement verified -->` mark — the session comment NEVER
69
+ // carries an engagement marker (it is history, not the approval). `isPair` is the marker's public reader,
70
+ // symmetrical with `isNoBlock`; the engagement roll-up itself lives in the local yad-learn ledger.
71
+ export const PAIR_MARK = '<!-- yad:pair -->';
72
+
73
+ // True when a comment is a recorded pair-review session (countable, but never blocking).
74
+ export function isPair(body) {
75
+ return typeof body === 'string' && body.includes(PAIR_MARK);
76
+ }
77
+
78
+ // Render the pair-review session-record comment. The skill generates each prose section; this composes
79
+ // them into one comment carrying both the pair marker (countable) and the noblock marker (never blocks).
80
+ export function pairSessionBody({ summary = '', scorecard = '', verdict = '', humanSignoff = '', aiSignoff = '' } = {}) {
81
+ const parts = [
82
+ PAIR_MARK,
83
+ '## 🤝 Pair review session',
84
+ summary && summary.trim(),
85
+ scorecard && `### Review-skill scorecard\n${scorecard.trim()}`,
86
+ verdict && `### AI verdict\n${verdict.trim()}`,
87
+ (humanSignoff || aiSignoff) && `### Sign-off\n- 🧑 Human: ${humanSignoff || '—'}\n- 🤖 AI: ${aiSignoff || '—'}`,
88
+ ].filter(Boolean);
89
+ return noBlock(parts.join('\n\n'));
90
+ }
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') {
@@ -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
- const owners = uniqueBy(live.filter((a) => a.role === 'owner'), 'approver');
214
- const reviewers = uniqueBy(live.filter((a) => a.role === 'reviewer'), 'approver');
215
- const domainOwners = live.filter((a) => a.role === 'domain-owner');
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
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import {
8
- c, log, ok, info, warn, hand, fail, readJSONStrict, writeJSON, run,
8
+ c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run,
9
9
  } from './lib.mjs';
10
10
  import { PROJECT_FILES } from './manifest.mjs';
11
11
  import {
@@ -13,7 +13,12 @@ import {
13
13
  advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
14
  upsertHubPr, DISCOVERY_FILES,
15
15
  } from './epic-state.mjs';
16
- import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
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';
21
+ import { sequenceDiff } from './walkthrough.mjs';
17
22
  import { syncStatuses } from './artifact-status.mjs';
18
23
  import { err } from './errors.mjs';
19
24
 
@@ -112,6 +117,11 @@ const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo ==
112
117
  // or reviews could never advance. Mirrors plan.mjs hubActions.
113
118
  const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
114
119
 
120
+ // requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
121
+ // approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
122
+ // but is recorded `engagement: none` and draws the friendly nudge.
123
+ const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
124
+
115
125
  // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
116
126
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
117
127
  // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
@@ -142,6 +152,7 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today }) {
142
152
  ...(r.domain ? { domain: r.domain } : {}),
143
153
  status: 'approved', date: today, source: 'bridge',
144
154
  artifactHash: artHash, approvedAt,
155
+ engagement: r.engagement === 'verified' ? 'verified' : 'none',
145
156
  ...(r.unverified ? { unverified: true } : {}),
146
157
  });
147
158
  }
@@ -187,6 +198,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
187
198
  const roster = hub.roster || [];
188
199
  const defaultReviewers = 1;
189
200
  const solo = isSolo(hub);
201
+ const reqEng = requireEngagement(hub);
190
202
  // Local invocation in bridge mode is ADVISORY: CI is the sole ledger writer, so a human run reads
191
203
  // the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
192
204
  // Without the bridge (platform but no gate-sync CI) the local command stays the writer.
@@ -222,7 +234,10 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
222
234
  approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
223
235
 
224
236
  const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
225
- const unresolved = (pull.threads || []).filter((t) => !t.resolved);
237
+ // 2f: companion scaffolding + nudge threads carry the noblock marker and are EXCLUDED from the
238
+ // blocking check — they stay unresolved as a permanent PR/MR history trail but never hold the gate.
239
+ // Only genuine (unflagged) unresolved threads block.
240
+ const unresolved = (pull.threads || []).filter((t) => !t.resolved && !isNoBlock(t.body));
226
241
  const threadsResolved = unresolved.length === 0 && changeRequested.length === 0;
227
242
  const blocking = [
228
243
  ...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
@@ -232,9 +247,21 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
232
247
  if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
233
248
  comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
234
249
 
250
+ // Social nudge: a bare APPROVE (no verified engagement) still counts (soft default), but the bot
251
+ // posts a friendly public @-mention inviting the reviewer to run the companion. Idempotent via
252
+ // pr.nudged; only on the writer path (a platform comment, not a ledger write).
253
+ if (!readOnly) {
254
+ const nudged = new Set(pr.nudged || []);
255
+ for (const rv of pull.reviews) {
256
+ if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login || nudged.has(rv.login)) continue;
257
+ if (postComment(platform, pr.number, nudgeMessage(rv.login), { cwd: root }).ok) nudged.add(rv.login);
258
+ }
259
+ pr.nudged = [...nudged];
260
+ }
261
+
235
262
  const pred = gatePredicate({
236
263
  step, approvals, currentHash: curHash, touchedDomains: domains,
237
- defaultReviewers, threadsResolved, merged: pull.merged, solo,
264
+ defaultReviewers, threadsResolved, merged: pull.merged, solo, requireEngagement: reqEng,
238
265
  });
239
266
 
240
267
  log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
@@ -470,7 +497,7 @@ export async function gateStatus(root, { epic } = {}) {
470
497
  // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
471
498
  // the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
472
499
  export async function gateOpen(root, { epic, artifact, head, creator = createPr } = {}) {
473
- const { hub } = loadHub(root);
500
+ const { hub, repos } = loadHub(root);
474
501
  const epicDir = epicRoot(root, epic);
475
502
  const ledger = loadLedger(epicDir);
476
503
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
@@ -504,12 +531,16 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
504
531
  // domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
505
532
  // to review their own artifact). Scope is the hub plus every touched domain.
506
533
  const committer = resolveCommitterLogin(root, hub.roster || []);
507
- const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer });
534
+ const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer, repos });
508
535
  const assignees = committer ? [committer] : [];
509
536
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
510
537
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
511
538
  const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
512
539
  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; }
540
+ // Surface routing: who was assigned as a reviewer, who was @-mentioned (GitLab field cap), and any
541
+ // login the platform could not add (dropped) so a partial roster is visible, not silent.
542
+ if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
543
+ if (r.dropped?.length) warn(`could not request as reviewer (unknown/non-collaborator login): ${r.dropped.join(', ')}`);
513
544
 
514
545
  if (!bridge) {
515
546
  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 +553,95 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
522
553
  return { url: r.url };
523
554
  }
524
555
 
556
+ // `yad gate review <epic> [artifact]` — assemble + print the grounding bundle the companion skill uses
557
+ // to generate the 60-sec trailer / swipe cards and to run the grounded chat (artifact + risk tags +
558
+ // contract + PR + repo code-maps). The CLI never calls an LLM; the skill (yad-review-companion)
559
+ // consumes this JSON, generates, and posts back via the platform (trailer/comments/approval).
560
+ // Build (but don't print) the front-half grounding bundle. Shared by `review` and `walkthrough` so the
561
+ // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
562
+ // Returns { error } when there is no epic state, else { bundle, epicDir, hub }.
563
+ function reviewBundle(root, { epic, artifact } = {}) {
564
+ const { hub, repos } = loadHub(root);
565
+ const epicDir = epicRoot(root, epic);
566
+ const ledger = loadLedger(epicDir);
567
+ if (!ledger.state) return { error: `no epic state at ${epicDir}` };
568
+ const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
569
+ const art = artifact || pr?.artifact || null;
570
+ const step = art ? findReviewStep(ledger.state, art) : null;
571
+ const bundle = {
572
+ epic,
573
+ artifact: art,
574
+ platform: hub?.platform || null,
575
+ pr: pr ? { number: pr.number, url: pr.url } : null,
576
+ step: step ? { id: step.id, riskTags: step.risk_tags || [], escalated: isEscalated(step) } : null,
577
+ artifactPath: art ? path.join(epicDir, art) : null,
578
+ contractPath: art && base(art) === 'architecture' ? path.join(epicDir, 'contract.md') : null,
579
+ touchedDomains: step ? touchedDomains(epicDir, step) : [],
580
+ repos: (repos || []).map((r) => ({
581
+ name: r.name,
582
+ codeMap: r.name ? path.join(root, '.sdlc/code-context', r.name, 'code-map.md') : null,
583
+ })),
584
+ requireEngagement: requireEngagement(hub),
585
+ markers: {
586
+ trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->',
587
+ engagementVerified: '<!-- yad:engagement verified -->', pair: '<!-- yad:pair -->',
588
+ },
589
+ };
590
+ return { bundle, epicDir, hub };
591
+ }
592
+
593
+ export async function gateReview(root, { epic, artifact } = {}) {
594
+ const r = reviewBundle(root, { epic, artifact });
595
+ if (r.error) { fail(r.error); process.exitCode = 1; return; }
596
+ log(JSON.stringify(r.bundle, null, 2));
597
+ return r.bundle;
598
+ }
599
+
600
+ // `yad gate walkthrough <epic> [artifact]` — the front-half pair-review grounding: the same bundle PLUS
601
+ // an ordered `stops[]` from the artifact's review diff (highest-risk first). The skill (yad-pair-review)
602
+ // walks the stops and runs the two-way teaching session. Deterministic sequencing only — no LLM here.
603
+ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {}) {
604
+ const r = reviewBundle(root, { epic, artifact });
605
+ if (r.error) { fail(r.error); process.exitCode = 1; return; }
606
+ const { bundle, hub } = r;
607
+ const defaultBranch = hub?.default_branch || 'main';
608
+ let stops = [];
609
+ if (bundle.artifactPath) {
610
+ const rel = path.relative(root, bundle.artifactPath) || bundle.artifact;
611
+ const diff = runner('git', ['-C', root, 'diff', `${defaultBranch}...HEAD`, '--', rel]);
612
+ if (diff.ok && diff.stdout.trim()) {
613
+ stops = sequenceDiff(diff.stdout, { contractPath: bundle.contractPath });
614
+ } else if (!diff.ok) {
615
+ note(`could not read the artifact diff (${defaultBranch}...HEAD) in ${root} — is the review branch checked out and the base correct?`);
616
+ }
617
+ }
618
+ const out = { ...bundle, stops };
619
+ log(JSON.stringify(out, null, 2));
620
+ // Diagnostics to STDERR so STDOUT stays pure JSON (the skill / e2e parse it).
621
+ if (!stops.length) note('no stops from the artifact diff — walk the artifact by section (see yad-pair-review)');
622
+ return out;
623
+ }
624
+
625
+ // `yad gate trailer <epic> [artifact] --body <text> [--pr <n>]` — the skill generates the 60-second
626
+ // briefing text and passes it here; this upserts it idempotently into the review PR/MR description as a
627
+ // delimited block, so regenerating on every artifact change never duplicates it. A platform write only.
628
+ export async function gateTrailer(root, { epic, artifact, body, number, getBody = getPrBody, editBody = editPrBody } = {}) {
629
+ const { hub } = loadHub(root);
630
+ if (!hub?.platform) { warn('no hub platform configured — the trailer posts to the PR/MR (file-only has none)'); return; }
631
+ 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; }
632
+ const epicDir = epicRoot(root, epic);
633
+ const ledger = loadLedger(epicDir);
634
+ const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
635
+ const n = number || pr?.number;
636
+ if (!n) { warn('no PR number — pass `--pr <n>` (in bridge mode the PR is recorded in the ledger only at merge)'); return; }
637
+ const cur = getBody(hub.platform, n, { cwd: root });
638
+ if (!cur.ok) { fail(`could not read PR #${n} description: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
639
+ const r = editBody(hub.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
640
+ if (!r.ok) { fail(`could not update PR #${n}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
641
+ ok(`trailer posted to PR #${n}`);
642
+ return { number: n };
643
+ }
644
+
525
645
  // ---- helpers ------------------------------------------------------------------------------------
526
646
  const base = (artifact) => artifactBase(artifact);
527
647
 
package/cli/lib.mjs CHANGED
@@ -30,6 +30,9 @@ export const info = (s) => log(` ${c.dim('•')} ${s}`);
30
30
  export const warn = (s) => log(` ${c.yellow('!')} ${s}`);
31
31
  export const fail = (s) => log(` ${c.red('✗')} ${s}`);
32
32
  export const hand = (s) => log(` ${c.yellow('→')} ${s}`);
33
+ // Like `info`, but to STDERR — for diagnostics emitted by commands whose STDOUT must stay pure (e.g. a
34
+ // JSON bundle a tool parses). Keeps the human hint visible without corrupting machine-readable output.
35
+ export const note = (s) => console.error(` ${c.dim('•')} ${s}`);
33
36
  // Dimmed, indented guidance under a step — what it does / why / what to enter / what skipping means.
34
37
  // Accepts a string or an array of lines so a knowledgeable user can skim past it.
35
38
  export const guide = (lines) => { for (const l of (Array.isArray(lines) ? lines : [lines])) log(` ${c.dim(l)}`); };
package/cli/manifest.mjs CHANGED
@@ -41,6 +41,8 @@ export const SKILLS = [
41
41
  'yad-backfill',
42
42
  'yad-run',
43
43
  'yad-review-gate',
44
+ 'yad-review-companion',
45
+ 'yad-pair-review',
44
46
  'yad-status',
45
47
  'yad-change',
46
48
  'yad-timeline',
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
- const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer });
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
  }