yadflow 2.18.1 → 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/cli/platform.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // no tokens are stored. Pure mapping fns (resolveLogin/mapApprovers) are exported for unit tests;
4
4
  // readPr is injectable so the gate can be tested with a fake.
5
5
  import { run, has } from './lib.mjs';
6
+ import { parseEngagement } from './companion.mjs';
6
7
 
7
8
  // github | gitlab | null, from a repo/remote.
8
9
  export function detectPlatform(remoteUrl = '') {
@@ -68,11 +69,22 @@ export function hasAnyRole(entry, scopes = [], wanted = []) {
68
69
 
69
70
  // Platform logins to auto-request as reviewers for the given scopes: everyone holding a `reviewer`
70
71
  // or `domain-owner` role in any scope, minus `excludeLogin` (you don't review your own PR), deduped.
71
- export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null } = {}) {
72
+ // `repos` (the registry) is consulted so a repo whose domain ownership lives ONLY in the legacy
73
+ // `repos.json` `domain_owner`/`domain_owners` field — not the roster roles map — is still requested
74
+ // as a reviewer for any scope that is its repo name. Without this the read side credits that login as
75
+ // a domain-owner (resolveLogin's legacy fallback) but the open side never asks them, so an escalated
76
+ // gate becomes structurally unsatisfiable through platform routing (BUG-1).
77
+ export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null, repos = [] } = {}) {
72
78
  const out = [];
79
+ const add = (login) => { if (login && login !== excludeLogin && !out.includes(login)) out.push(login); };
73
80
  for (const entry of roster) {
74
- if (!entry.login || entry.login === excludeLogin) continue;
75
- if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner']) && !out.includes(entry.login)) out.push(entry.login);
81
+ if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner'])) add(entry.login);
82
+ }
83
+ for (const scope of scopes) {
84
+ const repo = repos.find((r) => r.name === scope);
85
+ if (!repo) continue;
86
+ const names = repo.domain_owners || (repo.domain_owner ? [repo.domain_owner] : []);
87
+ for (const name of names) add(roster.find((r) => r.name === name)?.login);
76
88
  }
77
89
  return out;
78
90
  }
@@ -127,8 +139,11 @@ export function resolveLogin(login, roster = [], repos = [], touchedDomains = []
127
139
  for (const role of rolesForScope(entry, d)) {
128
140
  push(role === 'domain-owner' ? { name: entry.name, role, domain: d } : { name: entry.name, role });
129
141
  }
130
- // Legacy fallback: a repo whose domain_owner is this name confers domain-owner for that domain.
131
- const legacy = repos.find((repo) => repo.name === d && repo.domain_owner === entry.name);
142
+ // Legacy fallback: a repo whose domain_owner / domain_owners[] includes this name confers
143
+ // domain-owner for that domain. Both spellings are honored symmetric with reviewersForScopes,
144
+ // which REQUESTS from both, so a person routed as a domain owner is also credited as one.
145
+ const legacy = repos.find((repo) => repo.name === d
146
+ && (repo.domain_owner === entry.name || (Array.isArray(repo.domain_owners) && repo.domain_owners.includes(entry.name))));
132
147
  if (legacy) push({ name: entry.name, role: 'domain-owner', domain: d });
133
148
  }
134
149
  // An identity-only entry (no roles map and no legacy `role`) still contributes a base reviewer
@@ -156,8 +171,11 @@ export function mapApprovers(reviews = [], { roster, repos, touchedDomains, head
156
171
  // commits" setting.
157
172
  if (r.commit === null) continue;
158
173
  if (headOid && r.commit !== undefined && r.commit !== headOid) continue;
174
+ // engagement rides in the APPROVE review body (`<!-- yad:engagement verified -->`); a bare UI
175
+ // click has no marker → 'none'. Gameable by design (it makes review quality visible, not provable).
176
+ const engagement = parseEngagement(r.body);
159
177
  for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
160
- out.push({ ...rec, submittedAt: r.submittedAt || null });
178
+ out.push({ ...rec, submittedAt: r.submittedAt || null, engagement });
161
179
  }
162
180
  }
163
181
  return out;
@@ -182,7 +200,7 @@ function readPrGitHub(n, { cwd } = {}) {
182
200
  // latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
183
201
  // reviewers never silently omits one; any page failure aborts to the commitless fallback below,
184
202
  // which fails closed rather than advancing on a partial read.
185
- const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{author{login} state submittedAt commit{oid}}}}}}`;
203
+ const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{author{login} state submittedAt body commit{oid}}}}}}`;
186
204
  let rcursor = null;
187
205
  reviewsOk = true;
188
206
  for (let guard = 0; guard < 50; guard++) {
@@ -192,7 +210,7 @@ function readPrGitHub(n, { cwd } = {}) {
192
210
  if (!rg.ok) { reviewsOk = false; reviews = []; break; }
193
211
  const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
194
212
  for (const x of conn?.nodes || []) {
195
- reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: x.commit?.oid || null });
213
+ reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: x.commit?.oid || null });
196
214
  }
197
215
  if (!conn?.pageInfo?.hasNextPage) break;
198
216
  rcursor = conn.pageInfo.endCursor;
@@ -224,7 +242,7 @@ function readPrGitHub(n, { cwd } = {}) {
224
242
  if (!reviewsOk) {
225
243
  const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
226
244
  if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
227
- .map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: null }));
245
+ .map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: null }));
228
246
  }
229
247
  return {
230
248
  ok: true,
@@ -243,19 +261,26 @@ function readPrGitLab(n, { cwd } = {}) {
243
261
  const mr = JSON.parse(view.stdout);
244
262
  const approvals = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/approvals`], { cwd });
245
263
  const approvedBy = approvals.ok ? (JSON.parse(approvals.stdout).approved_by || []) : [];
246
- const reviews = approvedBy.map((a) => ({ login: a.user?.username, state: 'APPROVED' }));
247
264
  const disc = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/discussions`], { cwd });
248
- let threads = [];
249
- if (disc.ok) {
250
- threads = (JSON.parse(disc.stdout) || [])
251
- .filter((d) => d.notes?.some((nt) => nt.resolvable))
252
- .map((d, i) => ({
253
- id: d.id || `disc-${i}`,
254
- resolved: !!d.notes.find((nt) => nt.resolvable)?.resolved,
255
- login: d.notes[0]?.author?.username,
256
- body: d.notes[0]?.body,
257
- }));
265
+ const discussions = disc.ok ? (JSON.parse(disc.stdout) || []) : [];
266
+ // A GitLab approval carries no body, so the companion's engagement marker rides in a NOTE the
267
+ // reviewer posts; attach the latest engagement-bearing note per username to their approval so
268
+ // mapApprovers reads engagement uniformly with GitHub.
269
+ const engagementByUser = new Map();
270
+ for (const d of discussions) {
271
+ for (const nt of d.notes || []) {
272
+ if (/<!--\s*yad:engagement\s+\w+\s*-->/i.test(nt.body || '')) engagementByUser.set(nt.author?.username, nt.body);
273
+ }
258
274
  }
275
+ const reviews = approvedBy.map((a) => ({ login: a.user?.username, state: 'APPROVED', body: engagementByUser.get(a.user?.username) }));
276
+ const threads = discussions
277
+ .filter((d) => d.notes?.some((nt) => nt.resolvable))
278
+ .map((d, i) => ({
279
+ id: d.id || `disc-${i}`,
280
+ resolved: !!d.notes.find((nt) => nt.resolvable)?.resolved,
281
+ login: d.notes[0]?.author?.username,
282
+ body: d.notes[0]?.body,
283
+ }));
259
284
  return {
260
285
  ok: true,
261
286
  state: mr.state,
@@ -281,7 +306,9 @@ export function readPr(platform, n, opts = {}) {
281
306
  export function buildPrArgs(platform, { title, body, base, head, reviewers = [], labels = [], assignees = [] } = {}) {
282
307
  if (platform === 'gitlab') {
283
308
  const args = ['mr', 'create', '--title', title, '--description', body, '--target-branch', base, '--source-branch', head, '--yes'];
284
- if (reviewers.length) args.push('--reviewer', reviewers.join(','));
309
+ // A Free/Core GitLab MR carries a SINGLE reviewer field (multiple reviewers is a Premium feature),
310
+ // so only the first reviewer goes in the field; createPr @-mentions the rest in a note (BUG-2).
311
+ if (reviewers.length) args.push('--reviewer', reviewers[0]);
285
312
  if (assignees.length) args.push('--assignee', assignees.join(','));
286
313
  if (labels.length) args.push('--label', labels.join(','));
287
314
  return args;
@@ -293,8 +320,107 @@ export function buildPrArgs(platform, { title, body, base, head, reviewers = [],
293
320
  return args;
294
321
  }
295
322
 
323
+ // Number/IID from a PR/MR URL (…/pull/123, …/pulls/123, …/-/merge_requests/45). Anchored to the
324
+ // PR/MR path segment so a numeric group/org/repo earlier in the URL is never mistaken for it; falls
325
+ // back to a trailing number for non-standard URLs. null when unparsable.
326
+ export function prNumberFromUrl(url = '') {
327
+ const s = String(url);
328
+ const m = s.match(/\/(?:pull|pulls|merge_requests)\/(\d+)/);
329
+ if (m) return m[1];
330
+ const tail = s.match(/\/(\d+)(?:[/?#]|$)/);
331
+ return tail ? tail[1] : null;
332
+ }
333
+
334
+ // Create a PR/MR and route the required reviewers, resiliently, on both platforms:
335
+ // GitHub — create WITHOUT reviewers, then add each via `gh pr edit --add-reviewer`. A bad/
336
+ // non-collaborator login then WARNS (dropped) instead of aborting the whole create (BUG-4).
337
+ // GitLab — assign the first reviewer to the MR field; @-mention the remaining required reviewers in
338
+ // an MR note so they are still notified/routed despite the single-reviewer-field cap (BUG-2).
339
+ // Returns { ok, url, reviewers (assigned), mentioned, dropped }.
340
+ // ---- post back to the platform (companion write helpers) ----------------------------------------
341
+ // The reviewer/companion writes to the PLATFORM (PR/MR body + comments + approval), never the ledger —
342
+ // so the ledger-guard check is never tripped. Each returns { ok, ... } and never throws.
343
+
344
+ // Current PR/MR description (for idempotent trailer-block upsert). null when unreadable.
345
+ export function getPrBody(platform, n, { cwd } = {}) {
346
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
347
+ if (platform === 'github') {
348
+ const r = run('gh', ['pr', 'view', String(n), '--json', 'body', '-q', '.body'], { cwd });
349
+ return { ok: r.ok, body: r.ok ? r.stdout : '', reason: r.stderr };
350
+ }
351
+ const r = run('glab', ['mr', 'view', String(n), '-F', 'json'], { cwd });
352
+ if (!r.ok) return { ok: false, body: '', reason: r.stderr };
353
+ try { return { ok: true, body: JSON.parse(r.stdout).description || '' }; } catch { return { ok: false, body: '', reason: 'unparseable mr json' }; }
354
+ }
355
+
356
+ // Replace the PR/MR description (used to upsert the trailer block).
357
+ export function editPrBody(platform, n, body, { cwd } = {}) {
358
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
359
+ const r = platform === 'github'
360
+ ? run('gh', ['pr', 'edit', String(n), '--body', body], { cwd })
361
+ : run('glab', ['mr', 'update', String(n), '--description', body], { cwd });
362
+ return { ok: r.ok, reason: r.stderr };
363
+ }
364
+
365
+ // Post a top-level comment/note (companion card deck, chat log, nudge — pass a noBlock()-tagged body).
366
+ export function postComment(platform, n, body, { cwd } = {}) {
367
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
368
+ const r = platform === 'github'
369
+ ? run('gh', ['pr', 'comment', String(n), '--body', body], { cwd })
370
+ : run('glab', ['mr', 'note', String(n), '-m', body], { cwd });
371
+ return { ok: r.ok, reason: r.stderr };
372
+ }
373
+
374
+ // Submit an APPROVE carrying the engagement marker. On GitLab an approval has no body, so the marker
375
+ // is posted as a note (readPrGitLab attaches it to the approval); on GitHub it rides in the review body.
376
+ export function submitApproval(platform, n, body = '', { cwd } = {}) {
377
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
378
+ if (platform === 'github') {
379
+ const r = run('gh', ['pr', 'review', String(n), '--approve', '--body', body], { cwd });
380
+ return { ok: r.ok, reason: r.stderr };
381
+ }
382
+ const a = run('glab', ['mr', 'approve', String(n)], { cwd });
383
+ if (!a.ok) return { ok: false, reason: a.stderr };
384
+ if (body) {
385
+ // The engagement marker rides in this note (GitLab approvals carry no body). If it fails to post,
386
+ // the approval landed but the engagement signal is lost — report failure so the caller can retry.
387
+ const note = run('glab', ['mr', 'note', String(n), '-m', body], { cwd });
388
+ if (!note.ok) return { ok: false, reason: `approved, but failed to post the engagement note: ${note.stderr || 'unknown'}` };
389
+ }
390
+ return { ok: true };
391
+ }
392
+
296
393
  export function createPr(platform, opts = {}) {
297
394
  if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
298
- const r = run(cliFor(platform), buildPrArgs(platform, opts), { cwd: opts.cwd });
299
- return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
395
+ const reviewers = opts.reviewers || [];
396
+ if (platform === 'github') {
397
+ const r = run('gh', buildPrArgs('github', { ...opts, reviewers: [] }), { cwd: opts.cwd });
398
+ if (!r.ok) return { ok: false, reason: r.stderr };
399
+ const url = r.stdout.split('\n').pop();
400
+ const number = prNumberFromUrl(url);
401
+ const added = []; const dropped = [];
402
+ if (number) {
403
+ for (const rv of reviewers) {
404
+ (run('gh', ['pr', 'edit', number, '--add-reviewer', rv], { cwd: opts.cwd }).ok ? added : dropped).push(rv);
405
+ }
406
+ }
407
+ return { ok: true, url, reviewers: added, mentioned: [], dropped };
408
+ }
409
+ // gitlab
410
+ const r = run('glab', buildPrArgs('gitlab', opts), { cwd: opts.cwd });
411
+ if (!r.ok) return { ok: false, reason: r.stderr };
412
+ const url = r.stdout.split('\n').pop();
413
+ const iid = prNumberFromUrl(url);
414
+ const rest = reviewers.slice(1);
415
+ // Only report a reviewer as `mentioned` if the @-mention note actually posted; otherwise they were
416
+ // neither assigned (single-field cap) nor notified — surface them as `dropped` so the caller warns.
417
+ let mentioned = []; let dropped = [];
418
+ if (rest.length && iid) {
419
+ const ats = rest.map((m) => `@${m}`).join(' ');
420
+ const note = run('glab', ['mr', 'note', iid, '-m', `Review requested (owner + reviewer rule): ${ats} — please review and approve/comment on this MR (this drives the gate).`], { cwd: opts.cwd });
421
+ if (note.ok) mentioned = rest; else dropped = rest;
422
+ } else if (rest.length) {
423
+ dropped = rest; // could not parse the IID to post the note
424
+ }
425
+ return { ok: true, url, reviewers: reviewers.slice(0, 1), mentioned, dropped };
300
426
  }
package/cli/reconcile.mjs CHANGED
@@ -8,11 +8,11 @@ import {
8
8
  import { VERSION, PROJECT_FILES } from './manifest.mjs';
9
9
  import {
10
10
  moduleActions, repoActions, hubActions, authorsActions,
11
- legacyModuleActions, legacyRepoActions, legacyHubActions,
11
+ legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
12
12
  } from './plan.mjs';
13
13
  import { gitHead, packRepo } from './setup.mjs';
14
14
 
15
- const MARK = { missing: c.red('missing'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), ok: c.green('ok') };
15
+ const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
16
16
 
17
17
  export async function reconcile(root, { fix = false, scope = 'all', force = false } = {}) {
18
18
  log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
@@ -26,9 +26,10 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
26
26
  if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
27
27
 
28
28
  // --- deterministic file actions (module + hub CI + author allowlists + every registered repo),
29
- // plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place) ---
29
+ // plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place)
30
+ // and purge of skills removed in a later release ('removed': delete the lingering install) ---
30
31
  const actions = [
31
- ...moduleActions(root), ...legacyModuleActions(root),
32
+ ...moduleActions(root), ...legacyModuleActions(root), ...removedModuleActions(root),
32
33
  ...hubActions(root), ...legacyHubActions(root),
33
34
  ...authorsActions(root, registry.repos),
34
35
  ];
@@ -50,7 +51,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
50
51
  if (!byScope.has(a.scope)) byScope.set(a.scope, []);
51
52
  byScope.get(a.scope).push(a);
52
53
  }
53
- const counts = { missing: 0, outdated: 0, stale: 0, legacy: 0, ok: 0 };
54
+ const counts = { missing: 0, new: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
54
55
  for (const [scopeName, items] of byScope) {
55
56
  const notOk = items.filter((i) => i.status !== 'ok');
56
57
  items.forEach((i) => counts[i.status]++);
@@ -64,7 +65,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
64
65
  a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
65
66
  );
66
67
  log('');
67
- log(c.dim(`summary: ${counts.missing} missing, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.ok} ok`));
68
+ log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
68
69
 
69
70
  if (!fix) {
70
71
  if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
package/cli/review.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // `yad review trailer|context|nudge|reconcile` — the BACK-HALF Review Companion + bridge for code
2
+ // PR/MRs (the analogue of `yad gate …` for the front half). The fun process (trailer/cards/chat/nudge)
3
+ // makes the engineer review easy and visible; the bridge process (reconcile) maps the code PR's review
4
+ // state — including the engagement signal — into the build ledger (build-log.json) at merge.
5
+ //
6
+ // The CLI never calls an LLM: the skill (yad-review-companion / yad-engineer-review) generates the
7
+ // trailer/cards/chat text and posts it via these primitives, all to the PLATFORM (never a ledger file).
8
+ import path from 'node:path';
9
+ import { log, ok, info, warn, fail, run, readJSON, writeJSON } from './lib.mjs';
10
+ import { PROJECT_FILES, epicFiles } from './manifest.mjs';
11
+ import { epicRoot } from './epic-state.mjs';
12
+ import {
13
+ detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
14
+ } from './platform.mjs';
15
+ import { upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
16
+
17
+ const NUDGE_CMD = 'yad review chat';
18
+
19
+ // Resolve the target code repo: --repo <name> from the registry (platform + path + roles), else cwd.
20
+ // An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
21
+ // would operate on the wrong repo). Returns { error } in that case for the caller to surface.
22
+ function resolveRepo(root, { repo, dir }) {
23
+ if (repo) {
24
+ const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
25
+ const found = (reg.repos || []).find((r) => r.name === repo);
26
+ if (!found) return { error: `repo '${repo}' is not in .sdlc/repos.json — connect it first (yad-connect-repos)` };
27
+ return { repoRoot: path.resolve(root, found.path), meta: found };
28
+ }
29
+ return { repoRoot: path.resolve(root, dir || '.'), meta: null };
30
+ }
31
+
32
+ function platformOf(root, repoRoot, meta) {
33
+ if (meta?.platform) return meta.platform;
34
+ const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
35
+ return detectPlatform(remote) || readJSON(path.join(root, PROJECT_FILES.hubConfig), {}).platform || null;
36
+ }
37
+
38
+ // `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
39
+ // the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
40
+ export async function reviewContext(root, { repo, dir, pr } = {}) {
41
+ const rr = resolveRepo(root, { repo, dir });
42
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
43
+ const { repoRoot, meta } = rr;
44
+ const platform = platformOf(root, repoRoot, meta);
45
+ const base = meta?.default_branch || 'main';
46
+ const bundle = {
47
+ repo: meta?.name || null,
48
+ repoRoot,
49
+ platform,
50
+ pr: pr || null,
51
+ base,
52
+ diffCmd: `git -C ${repoRoot} diff ${base}...HEAD`,
53
+ codeMap: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'code-map.md') : null,
54
+ pack: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'pack.md') : null,
55
+ markers: { trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->', engagementVerified: '<!-- yad:engagement verified -->' },
56
+ };
57
+ log(JSON.stringify(bundle, null, 2));
58
+ return bundle;
59
+ }
60
+
61
+ // `yad review trailer --repo <r> --pr <n> --body <text>` — idempotently upsert the 60-sec briefing into
62
+ // the code PR/MR description (delimited block; safe to re-run after a push).
63
+ export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
64
+ if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
65
+ if (!body || !String(body).trim()) { fail('trailer body is required: `yad review trailer --repo <r> --pr <n> --body <text>`'); process.exitCode = 1; return; }
66
+ const rr = resolveRepo(root, { repo, dir });
67
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
68
+ const { repoRoot, meta } = rr;
69
+ const platform = platformOf(root, repoRoot, meta);
70
+ if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
71
+ const cur = getBody(platform, pr, { cwd: repoRoot });
72
+ if (!cur.ok) { fail(`could not read PR #${pr}: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
73
+ const r = editBody(platform, pr, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: repoRoot });
74
+ if (!r.ok) { fail(`could not update PR #${pr}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
75
+ ok(`trailer posted to ${platform === 'gitlab' ? 'MR' : 'PR'} #${pr}`);
76
+ return { number: pr };
77
+ }
78
+
79
+ // `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
80
+ // of a code PR. A platform comment (carries the noblock marker so it never blocks); call once per PR.
81
+ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster = postComment } = {}) {
82
+ if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
83
+ const rr = resolveRepo(root, { repo, dir });
84
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
85
+ const { repoRoot, meta } = rr;
86
+ const platform = platformOf(root, repoRoot, meta);
87
+ if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
88
+ const pull = reader(platform, pr, { cwd: repoRoot });
89
+ if (!pull.ok) { warn(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
90
+ let nudged = 0;
91
+ for (const rv of pull.reviews) {
92
+ if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login) continue;
93
+ if (poster(platform, pr, nudgeMessage(rv.login, NUDGE_CMD), { cwd: repoRoot }).ok) nudged++;
94
+ }
95
+ if (nudged) info(`nudged ${nudged} bare approval(s) — invited to run \`${NUDGE_CMD}\``);
96
+ else ok('no un-engaged approvals to nudge');
97
+ return { nudged };
98
+ }
99
+
100
+ // `yad review reconcile --epic <id> --repo <r> --pr <n>` — the back-half BRIDGE: read the code PR's
101
+ // approvals (with the engagement signal) and stamp them onto the matching build-log.json ship record,
102
+ // so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
103
+ // ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
104
+ // for the engineer/CI to attach at ship time (we never fabricate a story/task).
105
+ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = readPr } = {}) {
106
+ if (!epic) { fail('--epic <id> is required'); process.exitCode = 1; return; }
107
+ if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
108
+ const rr = resolveRepo(root, { repo, dir });
109
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
110
+ const { repoRoot, meta } = rr;
111
+ const platform = platformOf(root, repoRoot, meta);
112
+ if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
113
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
114
+ const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
115
+ const domain = meta?.name ? [meta.name] : [];
116
+ const pull = reader(platform, pr, { cwd: repoRoot });
117
+ if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
118
+
119
+ // Map platform approvals → engineer_review entries, deduped by (approver, role, domain), carrying the
120
+ // engagement signal read from the approve body/note.
121
+ const recs = mapApprovers(pull.reviews, { roster: hub.roster || [], repos: registry.repos || [], touchedDomains: domain, headOid: pull.headOid });
122
+ const seen = new Set();
123
+ const engineerReview = [];
124
+ for (const r of recs) {
125
+ const key = `${r.name}|${r.role}|${r.domain || ''}`;
126
+ if (seen.has(key)) continue;
127
+ seen.add(key);
128
+ engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
129
+ }
130
+
131
+ const file = epicFiles(epicRoot(root, epic)).buildLog;
132
+ const ledger = readJSON(file, null);
133
+ // Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
134
+ const ship = ledger?.ships?.find((s) => s.pr != null
135
+ && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)));
136
+ if (!ship) {
137
+ warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
138
+ log(JSON.stringify({ engineer_review: engineerReview }, null, 2));
139
+ return { engineerReview, written: false };
140
+ }
141
+ ship.engineer_review = engineerReview;
142
+ writeJSON(file, ledger);
143
+ ok(`stamped engagement onto ${epic} ship ${ship.story || ''}${ship.task ? '/' + ship.task : ''} (PR #${pr})`);
144
+ return { engineerReview, written: true };
145
+ }
package/cli/setup.mjs CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  import { VERSION, IDE_FOLDER_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
10
10
  import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
- legacyModuleActions, legacyRepoActions, legacyHubActions,
12
+ legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
13
13
  } from './plan.mjs';
14
14
  import { validateLogin, rolesForScope } from './platform.mjs';
15
15
 
@@ -399,6 +399,9 @@ export async function runSetup(root, opts = {}) {
399
399
  // IDE targets and install their yad-* renames. Without this, setup only ADDED yad-* and left
400
400
  // stale sdlc-* sitting next to them (the rename only ran under `yad update` / `yad check --fix`).
401
401
  applyActions(legacyModuleActions(root, ideTargets), { force: true });
402
+ // Purge any skill removed in a later release (REMOVED_SKILLS) that a prior install left behind —
403
+ // setup only ADDS current skills, so without this a breaking removal would linger next to them.
404
+ applyActions(removedModuleActions(root, ideTargets), { force: true });
402
405
  ok(`module installed into: ${ideTargets.join(', ')}`);
403
406
 
404
407
  // Global leftovers: a pre-2.0 install may have put sdlc-* skills in the user's global
@@ -416,6 +419,18 @@ export async function runSetup(root, opts = {}) {
416
419
  }
417
420
  }
418
421
 
422
+ // Same opt-in pass for skills removed in a later release (REMOVED_SKILLS) that linger in the
423
+ // global ~/.claude/skills — purge them so a global install also drops the dead command.
424
+ const globalRemoved = process.env.SDLC_NONINTERACTIVE ? [] : removedModuleActions(os.homedir(), ['.claude']);
425
+ if (globalRemoved.length) {
426
+ if (await askYesNo(`Found ${globalRemoved.length} removed skill(s) in your global ~/.claude/skills. Delete them?`, true)) {
427
+ applyActions(globalRemoved, { force: true });
428
+ ok('purged removed skill(s) from global ~/.claude/skills');
429
+ } else {
430
+ info('left global ~/.claude/skills untouched — re-run `yad setup` or purge later with `yad update`');
431
+ }
432
+ }
433
+
419
434
  // Detect hub platform + roster
420
435
  S(solo ? 'Hub platform (solo — no roster)' : 'Hub platform & reviewer roster');
421
436
  guide(solo
@@ -619,7 +634,7 @@ export async function runSetup(root, opts = {}) {
619
634
  }
620
635
 
621
636
  // Wire each connected repo + the hub itself
622
- S('Wire connected repos + the hub (CI gates, PR template, comment scaffold, gate-sync)');
637
+ S('Wire connected repos + the hub (CI gates, PR template, gate-sync)');
623
638
  guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
624
639
  if (registry.repos.length === 0) info('no repos to wire');
625
640
  for (const repo of registry.repos) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.18.1",
4
- "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 35 yad-* skills.",
3
+ "version": "3.1.0",
4
+ "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 34 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
7
7
  "license": "MIT",
@@ -63,6 +63,14 @@ review_gate:
63
63
  revoke_on: artifact-change # re-hash the artifact (contract surface for architecture); a changed
64
64
  # hash drops the bound approvals so reviewers re-approve. NOT per-commit.
65
65
  block_on_unresolved_comments: true # any unresolved thread / CHANGES_REQUESTED holds it in_review
66
+ # Review Companion engagement (yad-review-companion). Each approval records engagement: verified|none
67
+ # — verified when reviewed through the companion (trailer/cards/chat), none for a bare click. SOFT by
68
+ # default: both count, a bare approve still passes but draws a friendly public @-mention nudge so
69
+ # review quality is VISIBLE without blocking anyone ("visible, not impossible"). Flip to true to make
70
+ # only verified approvals count (the signal is gameable by design — it raises the cost of a rubber-
71
+ # stamp, it does not prove a human read the artifact). Persisted per-project in hub.json as
72
+ # `review.requireEngagement`; companion comments carry `<!-- yad:noblock -->` so they never block.
73
+ require_engagement: false
66
74
 
67
75
  # Build half (Phase 3). Code repos are SEPARATE git repos (one .git each), not subfolders
68
76
  # of the product repo — faithful to "per-repo specs in each code repo, contract singular in the
@@ -11,10 +11,27 @@ set -euo pipefail
11
11
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
12
  cd "$ROOT"
13
13
 
14
- SKILLS=(yad-discovery yad-analysis yad-epic yad-architecture yad-ui yad-stories yad-test-cases yad-connect-repos yad-sync-repos yad-connect-design yad-connect-testing yad-connect-learning yad-connect-docs yad-docs yad-docs-overview yad-docs-sync yad-learn yad-spec yad-implement yad-checks yad-pr-template yad-review-comments yad-hub-bridge yad-commit yad-open-pr yad-ship yad-engineer-review yad-backfill yad-run yad-review-gate yad-status yad-change yad-timeline yad-defects yad-reconcile)
14
+ SKILLS=(yad-discovery yad-analysis yad-epic yad-architecture yad-ui yad-stories yad-test-cases yad-connect-repos yad-sync-repos yad-connect-design yad-connect-testing yad-connect-learning yad-connect-docs yad-docs yad-docs-overview yad-docs-sync yad-learn yad-spec yad-implement yad-checks yad-pr-template yad-hub-bridge yad-commit yad-open-pr yad-ship yad-engineer-review yad-backfill yad-run yad-review-gate yad-status yad-change yad-timeline yad-defects yad-reconcile)
15
+
16
+ # Skills removed in a later release: this installer only refreshes names still in SKILLS, so a
17
+ # rerun would otherwise leave a dropped skill sitting in the IDE dirs. Purge any lingering copy
18
+ # (current name + any pre-rename alias) so the breaking removal takes effect for existing installs.
19
+ # Mirrors the CLI's REMOVED_SKILLS purge in `yad update`.
20
+ REMOVED_SKILLS=(yad-review-comments sdlc-review-comments)
15
21
 
16
22
  echo "Installing sdlc module from $ROOT/skills ..."
17
23
 
24
+ for ide in .claude .agents .zencoder; do
25
+ for s in "${REMOVED_SKILLS[@]}"; do
26
+ rm -rf "$ide/skills/$s"
27
+ done
28
+ done
29
+ if [ -d ".opencode/commands" ]; then
30
+ for s in "${REMOVED_SKILLS[@]}"; do
31
+ rm -f ".opencode/commands/$s.md"
32
+ done
33
+ fi
34
+
18
35
  # 1. Folder-per-skill IDEs: .claude, .agents, .zencoder
19
36
  for ide in .claude .agents .zencoder; do
20
37
  for s in "${SKILLS[@]}"; do
@@ -3,6 +3,7 @@ SDLC Workflow,yad-discovery,Project Discovery,DI,"Optional front-zero (once per
3
3
  SDLC Workflow,yad-analysis,Author Analysis,AN,"Optional front state: with the analyst pressure-test a feature idea and write the discovery brief into analysis.md. Assigns the EP-<slug> ID and seeds .sdlc state (the chain that puts analysis before epic). If skipped, the epic step does this shaping inline. Never auto-advances.",,{idea: one-line feature idea},1-front,,yad-review-gate,false,epics/EP-<slug>/,analysis.md state.json
4
4
  SDLC Workflow,yad-epic,Author Epic,AE,"Front state 1: shape an idea with analyst then pm into epic.md; assign EP-<slug> ID and seed .sdlc state. Never auto-advances.",,{idea: one-line feature idea},1-front,,yad-review-gate,true,epics/EP-<slug>/,epic.md state.json
5
5
  SDLC Workflow,yad-review-gate,Team Review Gate,RG,"Reusable review+approve gate for all five reviews. Shares an artifact for review, records comments and approvals as files, enforces owner + 1 reviewer (escalates on contract/auth/payments; per-repo routing for stories), advances state only when approved.",,{artifact: file under the epic} {action: open|comment|approve|advance},1-front,,,true,epics/EP-<slug>/reviews/,reviews/*.md approvals.json state.json
6
+ SDLC Workflow,yad-review-companion,Review Companion,RC,"Fun, easy, transparent review companion for the review gates (front gate AND back-half code PR). Generates a 60-sec AI trailer of what changed and where the risk is, deals swipe-through review cards, and runs a grounded chat where a reviewer's questions become the review record — then records an engagement signal on the approval (verified vs none) and posts a friendly public @-mention nudge on a bare rubber-stamp. Companion comments carry a noblock marker so they never hold the gate. Soft by default (visible, not impossible); gates only when hub.review.requireEngagement. Never auto-advances.",,{epic: EP-<slug>} {artifact} | {repo} {pr} {action: trailer|cards|chat|nudge},1-front,,,false,epics/EP-<slug>/reviews/ | code PR,trailer/cards/chat (platform) approvals.json engagement
6
7
  SDLC Workflow,yad-architecture,Author Architecture,AA,"Front state 3: with the architect author architecture.md and the locked contract.md; hash-lock the contract surface. Never auto-advances.",,{epic: EP-<slug>},1-front,yad-review-gate,yad-review-gate,true,epics/EP-<slug>/,architecture.md contract.md contract-lock.json state.json
7
8
  SDLC Workflow,yad-ui,Author UI Design,AU,"Front state 5: with the ux-designer author ui-design.md and DESIGN.md, driving Impeccable slash-commands when installed. Never auto-advances.",,{epic: EP-<slug>},1-front,yad-review-gate,yad-review-gate,true,epics/EP-<slug>/,ui-design.md DESIGN.md state.json
8
9
  SDLC Workflow,yad-stories,Author Stories,AS,"Front state 7: with the pm break the epic into repo-tagged stories with stable EP-<slug>-S0N IDs, one file each under stories/. Never auto-advances.",,{epic: EP-<slug>},1-front,yad-review-gate,yad-review-gate,true,epics/EP-<slug>/stories/,stories/EP-<slug>-S0N.md state.json
@@ -20,7 +21,6 @@ SDLC Workflow,yad-pr-template,PR/MR Template,PT,"Build-half Step D: detect a cod
20
21
  SDLC Workflow,yad-commit,Commit by Convention,CM,"Build-half helper: commit ONE staged atomic change by the conventions — a Conventional-Commits subject, the fixed trailer block (Task -> Contract-Change -> Co-Authored-By), and the <=3-file atomic guard. The human git author owns the commit; an assisting AI is recorded only as a Co-Authored-By footer chosen per-commit with --ai (claude|copilot|cursor|coderabbit|none, default none). Drives the yad commit CLI. Never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {task: <id>} {contract-change: true|false},3-build,yad-implement,,false,<repo>/,one commit
21
22
  SDLC Workflow,yad-open-pr,Open PR/MR,OP,"Build-half helper: open a code-repo task PR/MR from the committed platform template — detect GitHub/GitLab, push the task branch, create the PR/MR with the body prefilled (Summary / Story-task / Impact & Risk) and the title defaulting to the commit subject. Auto-assigns from the hub roster (assignee = committer, reviewers = repo reviewers + domain-owners); high risk / contract surface routes to domain owners (risk-route.sh). Drives the yad open-pr CLI. Never merges; never auto-advances.",,{repo: <name>} {risk: low|medium|high} {contract-change: true|false},3-build,yad-commit,,false,<repo>/,one PR/MR
22
23
  SDLC Workflow,yad-ship,Commit + Open PR/MR,SP2,"Build-half helper: commit AND open the task PR/MR in one step — a thin orchestration over yad-commit then yad-open-pr. Commits the staged atomic change by the conventions, then pushes the branch and opens the PR/MR from the committed template with the roster auto-assigned. The PR step runs ONLY if the commit lands (a failed commit, tripped guard, or --dry-run stops before pushing). Drives the yad ship CLI. Never merges; never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {repo: <name>} {risk: low|medium|high} {contract-change: true|false},3-build,yad-pr-template,,false,<repo>/,one commit + one PR/MR
23
- SDLC Workflow,yad-review-comments,Review Comment Templates,RC,"Install platform-matched PR/MR review-comment scaffolds (committed REVIEW_COMMENTS.md) into a code repo or the product hub, so reviewers leave structured, attributable feedback whose **name (role)** headers map cleanly into the SDLC file ledger. GitHub Saved Replies / GitLab comment templates are per-user, so a committed doc is the repo-level mechanism. Never auto-advances.",,{repo: <one of an epic's repos | hub>} {action: wire},3-build,,,false,<repo>/.github|.gitlab/,REVIEW_COMMENTS.md
24
24
  SDLC Workflow,yad-hub-bridge,Hub Review Bridge,HB,"The templated PR/MR bridge for the front-half review gate: when the product hub has a platform (.sdlc/hub.json), open a review PR/MR on the hub for an authored artifact, set required reviewers/labels from the routing rule, and provide the read-only gh/glab recipes yad-review-gate's sync uses to pull platform comments + approvals into the file ledger. Local-user auth, no stored tokens; file ledger stays the source of truth; degrades to file-only when no platform/CLI. Never auto-advances.",,{epic: EP-<slug>} {artifact: epic.md|architecture.md|ui-design.md|stories/} {action: open|route},1-front,yad-review-gate,yad-review-gate,false,epics/EP-<slug>/.sdlc/,hub-prs.json
25
25
  SDLC Workflow,yad-engineer-review,Engineer Review & Merge,ER,"Build-half Step E: wire an advisory AI first-pass (CodeRabbit) on the PR, record the human engineer review with the same human_approve discipline as the front gates (owner + 1 reviewer, escalating to domain owners on high risk / contract / auth / payments), and on merge record the ship in epics/<epic>/.sdlc/build-log.json and update the story state. AI review is advisory, never the authority; the human owns the merge. Never auto-advances.",,{epic: EP-<slug>} {story: EP-<slug>-S0N} {task: T0N} {repo: <repo>} {action: ai-review|approve|ship},3-build,yad-ship,,false,epics/EP-<slug>/.sdlc/,build-log.json story-status
26
26
  SDLC Workflow,yad-backfill,Backfill Specs,BF,"Build-half Step G: generate specs for already-built features in an existing repo. Confirm Repomix (npx repomix CLI), pack ONE feature (compress + git logs, secret-scan), feed to AI with a 'describe what exists, do not invent' prompt, write a DRAFT spec marked verified: false. Human approval (reuse yad-review-gate) makes it real. Boundary auto-proposed and human-confirmed. A change is blocked only until the features it touches have approved specs. Never auto-advances.",,{repo: <repo>} {feature: <name + globs>} {action: pack|draft|approve|gate},3-build,,,false,demo-repos/<repo>/specs/backfill/<feature>/,spec.md backfill-check.sh
@@ -19,6 +19,7 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
19
19
  "git_url": "https://github.com/abdelrahmannasr/yadflow.git",
20
20
  "default_branch": "main",
21
21
  "bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
22
+ "review": { "requireEngagement": false }, // Review Companion: false (soft) counts bare approves but nudges; true counts only verified-engagement approvals
22
23
  "detectedAt": "2026-06-08", // last detect-hub run (YYYY-MM-DD)
23
24
  "roster": [
24
25
  { "login": "abdelrahmannasr", "name": "alice", "email": "alice@example.com",
@@ -76,6 +77,15 @@ as the registry). `detect-hub` upserts `hub.json` in place — it is idempotent
76
77
  existing **file-only** flow with no error. The file ledger is the source of truth in both modes.
77
78
  - The master switch `config.yaml` `hub.bridge: false` disables the bridge globally regardless of `hub.json`.
78
79
 
80
+ ## Review Companion engagement (`review.requireEngagement`)
81
+
82
+ `review.requireEngagement` (default `false`) controls the [Review Companion](../../yad-review-companion/SKILL.md)
83
+ engagement gate. Each approval records `engagement: verified | none`. **Soft (`false`):** both count — a
84
+ bare approve still passes but draws a friendly public nudge, so review *quality* is visible without
85
+ blocking. **Strict (`true`):** the predicate counts only `verified` approvals. The signal is gameable by
86
+ design ("visible, not impossible") — it raises the cost of a rubber-stamp, it does not prove a human
87
+ read the artifact. Applies to both the front gate and the back-half engineer review.
88
+
79
89
  ## Git tracking
80
90
 
81
91
  Commit `hub.json` — it is small, reviewable, and carries no secrets (logins and names only, never tokens).
@@ -56,8 +56,8 @@ The gated authoring chain + the reusable review gate (10 steps, or 12 with the o
56
56
  | `yad-stories` | → `stories-review` (per-repo routing) | `stories/EP-<slug>-S0N.md` |
57
57
  | `yad-test-cases` | → `test-cases-review` (parallel, non-blocking) | `test-cases.md`, `test-links.json` |
58
58
  | `yad-review-gate` | the shared gate | `reviews/*.md`, `approvals.json`, `comments.json` |
59
+ | `yad-review-companion` | the fun/visible review layer (trailer/cards/chat + engagement) | `approvals.json` `engagement`, platform trailer/cards |
59
60
  | `yad-hub-bridge` | the platform PR/MR bridge | `hub-prs.json` |
60
- | `yad-review-comments` | comment scaffolds | repo comment templates |
61
61
 
62
62
  ### Path: Build half (`phase: 3-build`)
63
63
  Per-story, per-repo: `spec → tasks → implement → checks → engineer-review`, plus the commit/PR helpers.