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/CHANGELOG.md +11 -2
- package/README.md +1 -1
- package/bin/yad.mjs +30 -5
- package/cli/companion.mjs +61 -0
- package/cli/doctor.mjs +11 -0
- package/cli/epic-state.mjs +13 -3
- package/cli/gate.mjs +87 -5
- package/cli/manifest.mjs +13 -4
- package/cli/openpr.mjs +5 -1
- package/cli/plan.mjs +45 -6
- package/cli/platform.mjs +149 -23
- package/cli/reconcile.mjs +7 -6
- package/cli/review.mjs +145 -0
- package/cli/setup.mjs +17 -2
- package/package.json +2 -2
- package/skills/sdlc/config.yaml +8 -0
- package/skills/sdlc/install.sh +18 -1
- package/skills/sdlc/module-help.csv +1 -1
- package/skills/yad-connect-repos/references/hub-config.md +10 -0
- package/skills/yad-docs-overview/references/pipeline-model.md +1 -1
- package/skills/yad-engineer-review/SKILL.md +13 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +15 -2
- package/skills/yad-hub-bridge/references/bridge.md +34 -0
- package/skills/yad-open-pr/SKILL.md +10 -0
- package/skills/yad-pr-template/templates/checks/pr-template.sh +9 -0
- package/skills/yad-review-companion/SKILL.md +89 -0
- package/skills/yad-review-gate/SKILL.md +5 -1
- package/skills/yad-review-gate/references/gating.md +18 -0
- package/skills/yad-review-comments/SKILL.md +0 -63
- package/skills/yad-review-comments/references/comment-conventions.md +0 -55
- package/skills/yad-review-comments/templates/github/REVIEW_COMMENTS.md +0 -49
- package/skills/yad-review-comments/templates/gitlab/REVIEW_COMMENTS.md +0 -49
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
|
-
|
|
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 (
|
|
75
|
-
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
|
299
|
-
|
|
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,
|
|
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": "
|
|
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 +
|
|
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",
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -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
|
package/skills/sdlc/install.sh
CHANGED
|
@@ -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-
|
|
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.
|