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/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
@@ -12,7 +12,7 @@ import {
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'), removed: c.yellow('removed'), 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)}`));
@@ -51,7 +51,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
51
51
  if (!byScope.has(a.scope)) byScope.set(a.scope, []);
52
52
  byScope.get(a.scope).push(a);
53
53
  }
54
- const counts = { missing: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
54
+ const counts = { missing: 0, new: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
55
55
  for (const [scopeName, items] of byScope) {
56
56
  const notOk = items.filter((i) => i.status !== 'ok');
57
57
  items.forEach((i) => counts[i.status]++);
@@ -65,7 +65,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
65
65
  a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
66
66
  );
67
67
  log('');
68
- log(c.dim(`summary: ${counts.missing} missing, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${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`));
69
69
 
70
70
  if (!fix) {
71
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
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",
@@ -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
@@ -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
@@ -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,6 +56,7 @@ 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
61
 
61
62
  ### Path: Build half (`phase: 3-build`)
@@ -39,11 +39,22 @@ a **second set of eyes, never the authority**: it cannot approve or merge. Where
39
39
  (no remote), run an equivalent AI first-pass by hand and capture its notes. Record that the AI review
40
40
  ran; surface its findings to the engineer. Do **not** treat AI approval as a gate.
41
41
 
42
+ Also run the **Review Companion** so the human review is easy and fun: post the 60-sec trailer
43
+ (`yad review trailer --repo <r> --pr <n> --body "<text>"`) and deal the swipe cards / open the grounded
44
+ chat from the bundle (`yad review context --repo <r> --pr <n>` → [yad-review-companion](../yad-review-companion/SKILL.md)).
45
+ Companion comments carry `<!-- yad:noblock -->` (history-only, never block); genuine concerns are posted
46
+ unflagged and block normally.
47
+
42
48
  ### Step 2 — `approve` (the engineer review — the human gate)
43
49
  A human engineer reads the diff **against the spec** (`specs/<story>/`) and the acceptance criteria,
44
50
  and records an approval. Determine the rule from the PR's Impact & Risk block (run
45
51
  `../yad-pr-template/templates/checks/risk-route.sh` on the PR body): base, or escalated to a
46
52
  domain-owner per touched domain. Record each approval; re-evaluate whether the rule is satisfied.
53
+ Record `engagement: verified` when the engineer reviewed through the companion (else `none` for a bare
54
+ approve); `yad review reconcile --epic <id> --repo <r> --pr <n>` stamps it onto the ship record from the
55
+ platform. Soft by default (both count; a bare approve draws `yad review nudge`); only gates when
56
+ `hub.review.requireEngagement: true`. The signal is gameable by design and sits **beside** the CI gates,
57
+ never above them.
47
58
  Recording an approval does **not** ship — shipping is a separate, explicit step. Front-half discipline:
48
59
  the gate talks only through files; refuse to treat AI review as a human approval.
49
60
 
@@ -55,8 +66,8 @@ engineer-review rule is satisfied (Step 2). Then:
55
66
  ```json
56
67
  { "story": "<story>", "task": "<task>", "repo": "<repo>", "branch": "feat/<story>-<task>-…",
57
68
  "pr": "<url|#>", "mergeCommit": "<sha>", "gates": ["spec-link","contract-check","build-test-lint"],
58
- "ai_review": "coderabbit (advisory)", "engineer_review": [{"approver":"<name>","role":"<role>","domain":"<opt>"}],
59
- "risk": "<low|medium|high>", "shippedAt": "<YYYY-MM-DD>" }
69
+ "ai_review": "coderabbit (advisory)", "engineer_review": [{"approver":"<name>","role":"<role>","domain":"<opt>","engagement":"<verified|none>"}],
70
+ "companion": {"trailer":true,"cards":true,"chat":false}, "risk": "<low|medium|high>", "shippedAt": "<YYYY-MM-DD>" }
60
71
  ```
61
72
  - **Update the story state** — when **every** task in `specs/<story>/tasks.md` has a ship record, set
62
73
  the story frontmatter `status: shipped`; otherwise `status: in-build`. The chain
@@ -33,9 +33,10 @@ Append-only. One record per shipped task:
33
33
  "gates": ["spec-link", "contract-check", "build-test-lint"],
34
34
  "ai_review": "coderabbit (advisory)",
35
35
  "engineer_review": [
36
- { "approver": "amelia", "role": "owner" },
37
- { "approver": "carol", "role": "reviewer" }
36
+ { "approver": "amelia", "role": "owner", "engagement": "verified" },
37
+ { "approver": "carol", "role": "reviewer", "engagement": "none" }
38
38
  ],
39
+ "companion": { "trailer": true, "cards": true, "chat": false },
39
40
  "risk": "low",
40
41
  "shippedAt": "2026-06-06"
41
42
  }
@@ -46,6 +47,18 @@ Append-only. One record per shipped task:
46
47
  This is the back-half analogue of the front half's `approvals.json` — files only, no hidden state, so a
47
48
  future service can drive ship by writing the same records.
48
49
 
50
+ **Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
51
+ — `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
52
+ (`yad review trailer/context/nudge`, a real trailer/cards/chat session over the diff), `none` for a bare
53
+ approve. The optional `companion` block records which faces ran. It is **soft by default** (both count;
54
+ a bare approve draws a friendly `yad review nudge`); it only gates ship when
55
+ `hub.review.requireEngagement: true`. `yad review reconcile --epic <id> --repo <r> --pr <n>` reads the
56
+ code PR's approvals (with the engagement signal) and stamps them onto the matching ship record — the
57
+ back-half **bridge**, the analogue of `yad gate sync`. The signal is gameable by design ("visible, not
58
+ impossible"): it makes engineer-review quality visible, it does not prove a human read the diff. It sits
59
+ **beside** the CI gates (build/test/lint/contract/verified-commits) — never above them; CI still
60
+ decides machine safety, the merge is still the human act.
61
+
49
62
  ## Story state
50
63
 
51
64
  The story frontmatter `status` reflects build progress:
@@ -35,6 +35,40 @@ glab api projects/:id/merge_requests/:iid/notes # discussion notes (com
35
35
  All commands run as the **local user**; the bridge stores no tokens. If the CLI is missing/unauthenticated
36
36
  or the remote is unreachable, the bridge stops and the gate falls back to file-only (no error).
37
37
 
38
+ > **GitLab read parity (GAP-6).** `readPrGitLab` reads approvals (`approved_by[]`) and discussions but
39
+ > does **not** map a "Request changes" reviewer state to `CHANGES_REQUESTED` — on GitLab the blocking
40
+ > signal is an **unresolved discussion**. So on GitLab the gate is held by unresolved threads, not by a
41
+ > reviewer state. (GitHub maps both.) If you need GitLab "Request changes" honored, read
42
+ > `reviewers[].state` from the MR and map it to `CHANGES_REQUESTED`.
43
+
44
+ ## Open recipes (request the reviewers — used by `yad gate open` / `yad open-pr`)
45
+
46
+ Opening the review PR/MR must **request the required reviewers**, or an escalated gate is opened with
47
+ nobody asked. The CLI (`createPr` in `cli/platform.mjs`) does this; an agent opening a PR by hand uses:
48
+
49
+ **GitHub** — create, then add each reviewer (a bad/non-collaborator login WARNS instead of aborting the
50
+ whole create):
51
+ ```bash
52
+ gh pr create --title "review: <artifact> (<epic>)" --body <body> --base <default> --head <branch> \
53
+ --assignee @me --label domain:<repo>
54
+ gh pr edit <n> --add-reviewer <login> # once per required reviewer
55
+ ```
56
+
57
+ **GitLab** — a Free/Core MR carries a **single** reviewer field (multiple reviewers is Premium), so
58
+ assign the first required reviewer and **@-mention the rest in a note** so they are still notified/routed:
59
+ ```bash
60
+ glab mr create --title "review: <artifact> (<epic>)" --description <body> \
61
+ --target-branch <default> --source-branch <branch> --reviewer <first-login> --label domain:<repo> --yes
62
+ glab mr note <iid> -m "Review requested (owner + reviewer rule): @<l2> @<l3> — please review and approve/comment on this MR (this drives the gate)."
63
+ ```
64
+ The read side counts a mentioned reviewer normally: their eventual **approval** still appears in
65
+ `…/approvals → approved_by[]`, and their **note** in `…/discussions` — so the single-reviewer-field cap
66
+ loses only the native "Reviewers" UI chip, not the gate routing.
67
+
68
+ Required reviewers = the hub's `reviewer`/`domain-owner` roster logins for the touched scopes, PLUS any
69
+ repo whose ownership lives only in `repos.json` `domain_owner`/`domain_owners` (those are resolved to a
70
+ login and requested too — otherwise an escalated step is structurally unsatisfiable through routing).
71
+
38
72
  ## Login → role resolution (order)
39
73
 
40
74
  1. Roster (`.sdlc/hub.json`) maps `login` → `name` + base `role` (owner/reviewer).
@@ -62,6 +62,16 @@ PR/MR with the auto-assigned assignee + reviewers.
62
62
  On `high` risk or a contract touch, run `bash checks/risk-route.sh <pr-body>` to print the required
63
63
  domain-owner reviewers — the same escalation `yad-engineer-review` enforces.
64
64
 
65
+ ### Step 3b — Post the review trailer (optional, recommended)
66
+ Make the reviewer's job easy: generate the 60-sec briefing and post it to the new PR/MR so it greets
67
+ every reviewer in the UI (idempotent; safe to re-run after a push):
68
+ ```bash
69
+ yad review trailer --repo <name> --pr <n> --body "<companion-generated briefing>"
70
+ ```
71
+ The full fun-review flow (cards + grounded chat + engagement) is driven by the
72
+ [Review Companion](../yad-review-companion/SKILL.md) during `yad-engineer-review`. Non-blocking by
73
+ design — companion comments carry `<!-- yad:noblock -->`.
74
+
65
75
  ### Step 4 — Stop (no merge)
66
76
  Report the PR/MR URL and the requested reviewers. The PR now runs the check gates (Step C); the human
67
77
  engineer review and merge happen in `yad-engineer-review` (Step E).