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/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,178 @@
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, note, 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
+ import { sequenceDiff } from './walkthrough.mjs';
17
+
18
+ const NUDGE_CMD = 'yad review chat';
19
+
20
+ // Resolve the target code repo: --repo <name> from the registry (platform + path + roles), else cwd.
21
+ // An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
22
+ // would operate on the wrong repo). Returns { error } in that case for the caller to surface.
23
+ function resolveRepo(root, { repo, dir }) {
24
+ if (repo) {
25
+ const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
26
+ const found = (reg.repos || []).find((r) => r.name === repo);
27
+ if (!found) return { error: `repo '${repo}' is not in .sdlc/repos.json — connect it first (yad-connect-repos)` };
28
+ return { repoRoot: path.resolve(root, found.path), meta: found };
29
+ }
30
+ return { repoRoot: path.resolve(root, dir || '.'), meta: null };
31
+ }
32
+
33
+ function platformOf(root, repoRoot, meta) {
34
+ if (meta?.platform) return meta.platform;
35
+ const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
36
+ return detectPlatform(remote) || readJSON(path.join(root, PROJECT_FILES.hubConfig), {}).platform || null;
37
+ }
38
+
39
+ // Build (but don't print) the back-half grounding bundle. Shared by `context` and `walkthrough` so the
40
+ // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
41
+ // Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
42
+ function contextBundle(root, { repo, dir, pr } = {}) {
43
+ const rr = resolveRepo(root, { repo, dir });
44
+ if (rr.error) return { error: rr.error };
45
+ const { repoRoot, meta } = rr;
46
+ const platform = platformOf(root, repoRoot, meta);
47
+ const base = meta?.default_branch || 'main';
48
+ const bundle = {
49
+ repo: meta?.name || null,
50
+ repoRoot,
51
+ platform,
52
+ pr: pr || null,
53
+ base,
54
+ diffCmd: `git -C ${repoRoot} diff ${base}...HEAD`,
55
+ codeMap: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'code-map.md') : null,
56
+ pack: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'pack.md') : null,
57
+ contract: meta?.contract || null,
58
+ markers: {
59
+ trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->',
60
+ engagementVerified: '<!-- yad:engagement verified -->', pair: '<!-- yad:pair -->',
61
+ },
62
+ };
63
+ return { bundle, repoRoot, base };
64
+ }
65
+
66
+ // `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
67
+ // the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
68
+ export async function reviewContext(root, { repo, dir, pr } = {}) {
69
+ const r = contextBundle(root, { repo, dir, pr });
70
+ if (r.error) { fail(r.error); process.exitCode = 1; return; }
71
+ log(JSON.stringify(r.bundle, null, 2));
72
+ return r.bundle;
73
+ }
74
+
75
+ // `yad review walkthrough --repo <r> --pr <n>` — the pair-review grounding: the same bundle PLUS an
76
+ // ordered `stops[]` (the code diff parsed into hunk-anchored, risk-tagged review stops, highest-risk
77
+ // first). The CLI sequences deterministically; the skill (yad-pair-review) walks the stops, generates
78
+ // the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
79
+ export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
80
+ const r = contextBundle(root, { repo, dir, pr });
81
+ if (r.error) { fail(r.error); process.exitCode = 1; return; }
82
+ const { bundle, repoRoot, base } = r;
83
+ const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
84
+ // Diagnostics go to STDERR so STDOUT stays pure JSON (the skill / e2e parse it). The empty `stops: []`
85
+ // in the bundle already signals "nothing to walk".
86
+ if (!diff.ok) note(`could not read the diff (${base}...HEAD) in ${repoRoot} — is the branch pushed and the base correct?`);
87
+ const stops = sequenceDiff(diff.ok ? diff.stdout : '', { contractPath: bundle.contract });
88
+ const out = { ...bundle, stops };
89
+ log(JSON.stringify(out, null, 2));
90
+ if (!stops.length) note('no stops — the diff is empty (nothing to walk through)');
91
+ return out;
92
+ }
93
+
94
+ // `yad review trailer --repo <r> --pr <n> --body <text>` — idempotently upsert the 60-sec briefing into
95
+ // the code PR/MR description (delimited block; safe to re-run after a push).
96
+ export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
97
+ if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
98
+ if (!body || !String(body).trim()) { fail('trailer body is required: `yad review trailer --repo <r> --pr <n> --body <text>`'); process.exitCode = 1; return; }
99
+ const rr = resolveRepo(root, { repo, dir });
100
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
101
+ const { repoRoot, meta } = rr;
102
+ const platform = platformOf(root, repoRoot, meta);
103
+ if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
104
+ const cur = getBody(platform, pr, { cwd: repoRoot });
105
+ if (!cur.ok) { fail(`could not read PR #${pr}: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
106
+ const r = editBody(platform, pr, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: repoRoot });
107
+ if (!r.ok) { fail(`could not update PR #${pr}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
108
+ ok(`trailer posted to ${platform === 'gitlab' ? 'MR' : 'PR'} #${pr}`);
109
+ return { number: pr };
110
+ }
111
+
112
+ // `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
113
+ // of a code PR. A platform comment (carries the noblock marker so it never blocks); call once per PR.
114
+ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster = postComment } = {}) {
115
+ if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
116
+ const rr = resolveRepo(root, { repo, dir });
117
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
118
+ const { repoRoot, meta } = rr;
119
+ const platform = platformOf(root, repoRoot, meta);
120
+ if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
121
+ const pull = reader(platform, pr, { cwd: repoRoot });
122
+ if (!pull.ok) { warn(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
123
+ let nudged = 0;
124
+ for (const rv of pull.reviews) {
125
+ if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login) continue;
126
+ if (poster(platform, pr, nudgeMessage(rv.login, NUDGE_CMD), { cwd: repoRoot }).ok) nudged++;
127
+ }
128
+ if (nudged) info(`nudged ${nudged} bare approval(s) — invited to run \`${NUDGE_CMD}\``);
129
+ else ok('no un-engaged approvals to nudge');
130
+ return { nudged };
131
+ }
132
+
133
+ // `yad review reconcile --epic <id> --repo <r> --pr <n>` — the back-half BRIDGE: read the code PR's
134
+ // approvals (with the engagement signal) and stamp them onto the matching build-log.json ship record,
135
+ // so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
136
+ // ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
137
+ // for the engineer/CI to attach at ship time (we never fabricate a story/task).
138
+ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = readPr } = {}) {
139
+ if (!epic) { fail('--epic <id> is required'); process.exitCode = 1; return; }
140
+ if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
141
+ const rr = resolveRepo(root, { repo, dir });
142
+ if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
143
+ const { repoRoot, meta } = rr;
144
+ const platform = platformOf(root, repoRoot, meta);
145
+ if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
146
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
147
+ const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
148
+ const domain = meta?.name ? [meta.name] : [];
149
+ const pull = reader(platform, pr, { cwd: repoRoot });
150
+ if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
151
+
152
+ // Map platform approvals → engineer_review entries, deduped by (approver, role, domain), carrying the
153
+ // engagement signal read from the approve body/note.
154
+ const recs = mapApprovers(pull.reviews, { roster: hub.roster || [], repos: registry.repos || [], touchedDomains: domain, headOid: pull.headOid });
155
+ const seen = new Set();
156
+ const engineerReview = [];
157
+ for (const r of recs) {
158
+ const key = `${r.name}|${r.role}|${r.domain || ''}`;
159
+ if (seen.has(key)) continue;
160
+ seen.add(key);
161
+ engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
162
+ }
163
+
164
+ const file = epicFiles(epicRoot(root, epic)).buildLog;
165
+ const ledger = readJSON(file, null);
166
+ // Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
167
+ const ship = ledger?.ships?.find((s) => s.pr != null
168
+ && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)));
169
+ if (!ship) {
170
+ warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
171
+ log(JSON.stringify({ engineer_review: engineerReview }, null, 2));
172
+ return { engineerReview, written: false };
173
+ }
174
+ ship.engineer_review = engineerReview;
175
+ writeJSON(file, ledger);
176
+ ok(`stamped engagement onto ${epic} ship ${ship.story || ''}${ship.task ? '/' + ship.task : ''} (PR #${pr})`);
177
+ return { engineerReview, written: true };
178
+ }
@@ -0,0 +1,115 @@
1
+ // cli/walkthrough.mjs — the deterministic diff SEQUENCER for the pair-review walkthrough
2
+ // (yad-pair-review). Pure, no I/O, no LLM: it parses a unified `git diff` into ordered, hunk-anchored,
3
+ // risk-tagged "stops" so the skill walks the change one stop at a time, highest-risk first, the same way
4
+ // every time. The harness generates the prose for each stop; this only decides WHAT and IN WHICH ORDER.
5
+ //
6
+ // Risk tags reuse the same vocabulary as the review gate's escalation (contract / auth / payments — see
7
+ // cli/epic-state.mjs isEscalated), plus a `tests` tag so the walkthrough can check that tests cover the
8
+ // change (rubric step 5). The signal is a heuristic over file paths + hunk size — advisory, never a gate.
9
+
10
+ // Path heuristics for the escalation domains + tests. Order matters only for readability; a path can
11
+ // carry several tags.
12
+ const RISK_PATTERNS = [
13
+ ['auth', /(^|[/_.-])(auth|authn|authz|login|logout|session|token|jwt|oauth|saml|password|passwd|credential|secret|permission|rbac|acl)([/_.-]|$)/i],
14
+ ['payments', /(^|[/_.-])(pay|payment|payout|billing|invoice|charge|stripe|paypal|checkout|wallet|refund|subscription|price|pricing|ledger)([/_.-]|$)/i],
15
+ ['contract', /(^|[/_.-])(contract|openapi|swagger|proto|graphql|schema|migration|migrations)([/_.-]|$)|\.(proto|sql|graphql|gql)$|(^|[/])(api|contracts?)[/]/i],
16
+ ['tests', /(^|[/_.-])(test|tests|spec|specs|__tests__|e2e|fixtures?)([/_.-]|$)|\.(test|spec)\.[a-z]+$/i],
17
+ ];
18
+
19
+ // Relative weight used to order stops — the higher, the earlier it is walked. `tests` is intentionally
20
+ // weightless (it's informative, not risky on its own).
21
+ const RISK_WEIGHT = { contract: 4, auth: 3, payments: 3, tests: 0 };
22
+
23
+ // The risk tags a file path carries. `contractPath` (when known) force-tags the locked contract surface
24
+ // even if its path doesn't match the generic heuristics.
25
+ export function riskTagsForPath(file, { contractPath } = {}) {
26
+ const f = String(file || '');
27
+ const tags = [];
28
+ for (const [tag, re] of RISK_PATTERNS) {
29
+ if (re.test(f)) tags.push(tag);
30
+ }
31
+ if (contractPath && (f === contractPath || f.endsWith(`/${contractPath}`) || contractPath.endsWith(`/${f}`))) {
32
+ if (!tags.includes('contract')) tags.unshift('contract');
33
+ }
34
+ return tags;
35
+ }
36
+
37
+ // The maximum risk weight across a stop's tags (0 when none) — the primary sort key.
38
+ function weightOf(tags) {
39
+ return (tags || []).reduce((m, t) => Math.max(m, RISK_WEIGHT[t] || 0), 0);
40
+ }
41
+
42
+ // Parse the `+c,d` side of a hunk header `@@ -a,b +c,d @@`. `d` defaults to 1 when omitted (a one-line
43
+ // hunk). Returns { startLine, endLine } in the NEW file, or null when the header doesn't parse.
44
+ function parseHunkRange(header) {
45
+ const m = /@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(header || '');
46
+ if (!m) return null;
47
+ const start = Number(m[1]);
48
+ const count = m[2] == null ? 1 : Number(m[2]);
49
+ return { startLine: start, endLine: count > 0 ? start + count - 1 : start };
50
+ }
51
+
52
+ // The file a `diff --git a/x b/y` line names. Use the new-side path (`b/`) so a rename is anchored to its
53
+ // destination; fall back to the old side for a pure deletion.
54
+ function fileFromDiffHeader(line) {
55
+ const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
56
+ if (!m) return null;
57
+ return m[2] || m[1];
58
+ }
59
+
60
+ // sequenceDiff(diffText, { contractPath }) -> ordered stops[].
61
+ // Each stop: { file, hunkHeader, startLine, endLine, added, removed, riskTags[], order }.
62
+ // A file with no hunks (binary, pure rename, mode-only change) still yields ONE stop so it's never
63
+ // skipped silently. `order` is the 1-based position AFTER sorting (high-risk, then larger, first).
64
+ export function sequenceDiff(diffText, { contractPath } = {}) {
65
+ const lines = String(diffText || '').split('\n');
66
+ const files = []; // { file, hunks: [{ header, startLine, endLine, added, removed }] }
67
+ let cur = null;
68
+ let hunk = null;
69
+
70
+ const closeHunk = () => { if (cur && hunk) cur.hunks.push(hunk); hunk = null; };
71
+
72
+ for (const line of lines) {
73
+ if (line.startsWith('diff --git ')) {
74
+ closeHunk();
75
+ const file = fileFromDiffHeader(line);
76
+ cur = { file: file || '(unknown)', hunks: [] };
77
+ files.push(cur);
78
+ continue;
79
+ }
80
+ if (!cur) continue; // preamble before the first file header
81
+ if (line.startsWith('@@')) {
82
+ closeHunk();
83
+ const range = parseHunkRange(line) || { startLine: null, endLine: null };
84
+ hunk = { header: line.trim(), startLine: range.startLine, endLine: range.endLine, added: 0, removed: 0 };
85
+ continue;
86
+ }
87
+ if (!hunk) continue; // ---/+++/index/rename lines between header and first @@
88
+ // Inside a hunk every +/- line is CONTENT — the `+++ b/file` / `--- a/file` headers appear before
89
+ // the first @@ and are already skipped by the !hunk guard, so count on the marker char alone (a
90
+ // content line like `--flag` or `++i` must not be dropped).
91
+ if (line[0] === '+') hunk.added++;
92
+ else if (line[0] === '-') hunk.removed++;
93
+ }
94
+ closeHunk();
95
+
96
+ // Flatten to stops; a file with zero hunks becomes one zero-size stop.
97
+ const stops = [];
98
+ for (const f of files) {
99
+ const tags = riskTagsForPath(f.file, { contractPath });
100
+ if (f.hunks.length === 0) {
101
+ stops.push({ file: f.file, hunkHeader: null, startLine: null, endLine: null, added: 0, removed: 0, riskTags: tags });
102
+ continue;
103
+ }
104
+ for (const h of f.hunks) {
105
+ stops.push({ file: f.file, hunkHeader: h.header, startLine: h.startLine, endLine: h.endLine, added: h.added, removed: h.removed, riskTags: tags });
106
+ }
107
+ }
108
+
109
+ // Stable sort: highest risk weight first, then the larger change, then original order (index) so the
110
+ // result is deterministic for identical input.
111
+ return stops
112
+ .map((s, i) => ({ s, i, w: weightOf(s.riskTags), size: s.added + s.removed }))
113
+ .sort((a, b) => (b.w - a.w) || (b.size - a.size) || (a.i - b.i))
114
+ .map(({ s }, idx) => ({ ...s, order: idx + 1 }));
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.0.0",
3
+ "version": "3.2.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,26 @@ 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
74
+ # Pair Review (yad-pair-review) — the guided, two-way, TEACHING walkthrough; the AI-driven 5th
75
+ # companion face. The AI walks the human through the change one risk-ordered stop at a time, asks
76
+ # Socratic questions, and both sign off when satisfied; the session doubles as a learning session and
77
+ # records the engineer's review-skill growth in the local-only yad-learn ledger. SOFT and additive —
78
+ # it NEVER blocks a merge or gate (no strict switch); it rides the same `engagement: verified` signal
79
+ # as the companion and surfaces genuine concerns as normal blocking comments. `mode: optional` is the
80
+ # default posture (offered, never required). The quiz/comprehension signal reuses learning.capabilities.
81
+ pair_review:
82
+ enabled: true
83
+ mode: optional # optional | encouraged — never `required` (it can never gate)
84
+ never_blocks: true # invariant: a pair session is advisory; the gate predicate is untouched
85
+ rubric: review-rubric.md # the transferable review method (skills/yad-pair-review/references/)
66
86
 
67
87
  # Build half (Phase 3). Code repos are SEPARATE git repos (one .git each), not subfolders
68
88
  # of the product repo — faithful to "per-repo specs in each code repo, contract singular in the
@@ -11,7 +11,7 @@ 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-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-review-companion yad-pair-review yad-status yad-change yad-timeline yad-defects yad-reconcile)
15
15
 
16
16
  # Skills removed in a later release: this installer only refreshes names still in SKILLS, so a
17
17
  # rerun would otherwise leave a dropped skill sitting in the IDE dirs. Purge any lingering copy
@@ -3,6 +3,8 @@ 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
7
+ SDLC Workflow,yad-pair-review,Pair Review,PV,"The guided, two-way, teaching pair-review walkthrough — the AI-driven 5th companion face (front gate AND back-half code PR). The human opens a PR/MR with an AI session and the AI walks them through the change one stop at a time (highest-risk first), gives comprehensive context per change, then asks a Socratic question; the human answers and asks back until BOTH declare satisfied. Doubles as a learning session: demonstrates a transferable review method, scores the engineer, and records review-skill growth in the local-only yad-learn ledger (rolled up by yad status). Soft and additive — NEVER blocks; rides the engagement signal and surfaces genuine concerns as normal blocking comments. Never auto-advances.",,{epic: EP-<slug>} {artifact} | {repo} {pr} {member} {action: walkthrough|record|rubric},3-build,yad-review-companion,,false,epics/EP-<slug>/reviews/ | code PR | epics/EP-<slug>/learning/ (local-only),pair session (platform) learning-records.json learning/<member>--review-<pr>.md
6
8
  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
9
  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
10
  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