yadflow 2.13.0 → 2.15.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 +2 -2
- package/README.md +28 -16
- package/bin/yad.mjs +55 -9
- package/cli/artifact-status.mjs +102 -0
- package/cli/doctor.mjs +35 -2
- package/cli/epic-state.mjs +86 -11
- package/cli/gate.mjs +130 -60
- package/cli/lib.mjs +3 -0
- package/cli/manifest.mjs +2 -0
- package/cli/next.mjs +123 -0
- package/cli/platform.mjs +44 -6
- package/cli/repo.mjs +8 -4
- package/cli/setup.mjs +215 -80
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +8 -0
- package/skills/yad-analysis/SKILL.md +6 -3
- package/skills/yad-architecture/SKILL.md +8 -3
- package/skills/yad-checks/SKILL.md +7 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +117 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +9 -1
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +8 -0
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +7 -0
- package/skills/yad-epic/SKILL.md +4 -1
- package/skills/yad-hub-bridge/SKILL.md +41 -14
- package/skills/yad-hub-bridge/references/bridge.md +93 -51
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +85 -35
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +63 -32
- package/skills/yad-review-gate/SKILL.md +12 -10
- package/skills/yad-stories/SKILL.md +8 -3
- package/skills/yad-test-cases/SKILL.md +10 -5
- package/skills/yad-ui/SKILL.md +8 -3
package/cli/gate.mjs
CHANGED
|
@@ -11,9 +11,10 @@ import { PROJECT_FILES } from './manifest.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
|
-
|
|
14
|
+
upsertHubPr,
|
|
15
15
|
} from './epic-state.mjs';
|
|
16
16
|
import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
|
|
17
|
+
import { syncStatuses } from './artifact-status.mjs';
|
|
17
18
|
import { err } from './errors.mjs';
|
|
18
19
|
|
|
19
20
|
// ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
|
|
@@ -85,6 +86,17 @@ function loadHub(root) {
|
|
|
85
86
|
return { hub, repos: registry.repos };
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// Solo mode (a lone developer): waive the approval requirement — on GitHub you cannot approve your own
|
|
90
|
+
// PR, so an approval gate would deadlock. The review PR/MR and its merge stay (CI runs on the PR; the
|
|
91
|
+
// merge advances the step). Recorded per-project in hub.json by `yad setup`.
|
|
92
|
+
const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
93
|
+
|
|
94
|
+
// Bridge mode: a platform AND the gate-sync CI explicitly enabled (the canonical `bridge_enabled`,
|
|
95
|
+
// or the older `bridge`). ONLY then is CI the sole ledger writer — so `gate open`/`sync` stay
|
|
96
|
+
// hands-off. A platform without the bridge (no gate-sync CI installed) keeps the local write path,
|
|
97
|
+
// or reviews could never advance. Mirrors plan.mjs hubActions.
|
|
98
|
+
const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
|
|
99
|
+
|
|
88
100
|
// Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
|
|
89
101
|
// revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
|
|
90
102
|
// reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
|
|
@@ -153,12 +165,19 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
|
|
|
153
165
|
|
|
154
166
|
// ---- actions ------------------------------------------------------------------------------------
|
|
155
167
|
|
|
156
|
-
export async function gateSync(root, { epic, artifact, today, reader = readPr } = {}) {
|
|
168
|
+
export async function gateSync(root, { epic, artifact, today, reader = readPr, local = false, dryRun = false } = {}) {
|
|
157
169
|
const { hub, repos } = loadHub(root);
|
|
158
170
|
if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
|
|
159
171
|
const platform = hub.platform;
|
|
160
172
|
const roster = hub.roster || [];
|
|
161
173
|
const defaultReviewers = 1;
|
|
174
|
+
const solo = isSolo(hub);
|
|
175
|
+
// Local invocation in bridge mode is ADVISORY: CI is the sole ledger writer, so a human run reads
|
|
176
|
+
// the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
|
|
177
|
+
// Without the bridge (platform but no gate-sync CI) the local command stays the writer.
|
|
178
|
+
// dryRun forces the same read-only behavior regardless of bridge — used for the Path B pre-merge
|
|
179
|
+
// evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
|
|
180
|
+
const readOnly = (local && isBridge(hub)) || dryRun;
|
|
162
181
|
const epicDir = epicRoot(root, epic);
|
|
163
182
|
const ledger = loadLedger(epicDir);
|
|
164
183
|
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
|
|
@@ -168,6 +187,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
168
187
|
if (!targets.length) { warn(`no open review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''} (run \`yad gate open\` first)`); return { synced: 0 }; }
|
|
169
188
|
|
|
170
189
|
let synced = 0;
|
|
190
|
+
let advanced = 0;
|
|
171
191
|
for (const pr of targets) {
|
|
172
192
|
const step = findReviewStep(state, pr.artifact);
|
|
173
193
|
if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
|
|
@@ -176,11 +196,13 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
176
196
|
if (step.status === 'done') { info(`${pr.artifact}: ${step.id} already done — skipping`); continue; }
|
|
177
197
|
const domains = touchedDomains(epicDir, step);
|
|
178
198
|
const pull = reader(platform, pr.number, { cwd: root });
|
|
179
|
-
|
|
199
|
+
// A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
|
|
200
|
+
// (the wired workflow's reconcile/sweep aggregates this exit) instead of silently not advancing.
|
|
201
|
+
if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason} — skipping (file-only)`); process.exitCode = 1; continue; }
|
|
180
202
|
|
|
181
203
|
const curHash = artifactHash(epicDir, pr.artifact);
|
|
182
204
|
warnUnlockedContract(epicDir, pr.artifact);
|
|
183
|
-
const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains });
|
|
205
|
+
const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
|
|
184
206
|
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
|
|
185
207
|
|
|
186
208
|
const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
@@ -190,17 +212,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
190
212
|
...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
|
|
191
213
|
...unresolved,
|
|
192
214
|
];
|
|
193
|
-
|
|
215
|
+
// Advisory (read-only) sync must not touch the working tree — defer the reviews/*.md write.
|
|
216
|
+
if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
|
|
194
217
|
comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
|
|
195
218
|
|
|
196
219
|
const pred = gatePredicate({
|
|
197
220
|
step, approvals, currentHash: curHash, touchedDomains: domains,
|
|
198
|
-
defaultReviewers, threadsResolved, merged: pull.merged,
|
|
221
|
+
defaultReviewers, threadsResolved, merged: pull.merged, solo,
|
|
199
222
|
});
|
|
200
223
|
|
|
201
224
|
log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
|
|
202
225
|
if (pred.passed) {
|
|
203
226
|
state = advanceState(state, step);
|
|
227
|
+
advanced++;
|
|
204
228
|
ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
|
|
205
229
|
} else {
|
|
206
230
|
state = markInReview(state, step);
|
|
@@ -210,29 +234,42 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
210
234
|
synced++;
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
if (readOnly) {
|
|
238
|
+
info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
|
|
239
|
+
return { synced, advanced };
|
|
240
|
+
}
|
|
213
241
|
writeJSON(ledger.files.approvals, approvals);
|
|
214
242
|
writeJSON(ledger.files.comments, comments);
|
|
215
243
|
writeJSON(ledger.files.hubPrs, hubPrs);
|
|
216
244
|
writeJSON(ledger.files.state, state);
|
|
217
245
|
refreshRoster(epicDir, targets, approvals, today);
|
|
218
|
-
return { synced };
|
|
246
|
+
return { synced, advanced };
|
|
219
247
|
}
|
|
220
248
|
|
|
221
|
-
// `yad gate ci` — the self-sufficient entry point hub CI calls on platform events
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
249
|
+
// `yad gate ci` — the self-sufficient entry point hub CI calls on platform events. Path B: CI
|
|
250
|
+
// never writes the ledger to the review branch — during review the platform PR/MR is the source of
|
|
251
|
+
// truth, and the ledger is reconciled onto the default branch at merge.
|
|
252
|
+
//
|
|
253
|
+
// PRE-MERGE (a held step, no --merged and nothing advanced): READ-ONLY. The predicate is
|
|
254
|
+
// evaluated for visibility, but nothing is committed or pushed — so an in-flight approval is never
|
|
255
|
+
// dismissed and the PR's required checks never strand on a CI commit.
|
|
256
|
+
//
|
|
257
|
+
// MERGE (--merged, PR/MR closed+merged): the artifact reached the default branch via the human
|
|
258
|
+
// merge; the workflow checks out the default branch. CI runs the sync — the PR reads merged=true,
|
|
259
|
+
// so the predicate ADVANCES the step, flips the artifact `status:` to approved (syncStatuses), and
|
|
260
|
+
// commits the advance to the default branch. CI re-reads approvals fresh from the platform, so it
|
|
261
|
+
// needs no ledger pre-seeded on the branch.
|
|
262
|
+
//
|
|
263
|
+
// CI is the SOLE writer of the ledger and only ever commits to the default branch; humans never
|
|
264
|
+
// commit gate-state files (enforced by the ledger-guard check). Sweep mode (no --branch) advances
|
|
265
|
+
// merged-but-stuck reviews found in the locally checked-out default-branch ledgers.
|
|
266
|
+
export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
|
|
228
267
|
const { hub } = loadHub(root);
|
|
229
268
|
if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0 }; }
|
|
230
269
|
const git = (...args) => run('git', args, { cwd: root });
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
const head = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
235
|
-
const target = hub.default_branch || (head && head !== 'HEAD' ? head : 'main');
|
|
270
|
+
const defaultBranch = hub.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
|
|
271
|
+
// Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
|
|
272
|
+
// not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
|
|
236
273
|
|
|
237
274
|
// Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
|
|
238
275
|
const jobs = [];
|
|
@@ -265,6 +302,7 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
|
|
|
265
302
|
|
|
266
303
|
let synced = 0;
|
|
267
304
|
const touched = new Set();
|
|
305
|
+
const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
|
|
268
306
|
for (const job of jobs) {
|
|
269
307
|
const epicDir = epicRoot(root, job.epic);
|
|
270
308
|
// Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
|
|
@@ -278,14 +316,14 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
|
|
|
278
316
|
continue;
|
|
279
317
|
}
|
|
280
318
|
if (!ledger.state) {
|
|
281
|
-
warn(`${job.epic}: no epic state on
|
|
319
|
+
warn(`${job.epic}: no epic state on the checked-out branch — the review branch is cut from the default branch, so it should carry it`);
|
|
282
320
|
continue;
|
|
283
321
|
}
|
|
284
322
|
const step = findReviewStep(ledger.state, job.artifact);
|
|
285
323
|
if (!step) { warn(`${job.epic}: no review step for ${job.artifact} — skipping`); continue; }
|
|
286
324
|
|
|
287
|
-
// The event may fire before
|
|
288
|
-
// event itself
|
|
325
|
+
// The merge event may fire before any hub-prs record exists (Path B never wrote one pre-merge) —
|
|
326
|
+
// build the entry from the event itself so the advance commit carries it onto the default branch.
|
|
289
327
|
const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
|
|
290
328
|
const number = Number(job.pr) || existing?.number || null;
|
|
291
329
|
if (!existing || existing.number !== number || existing.branch !== job.branch) {
|
|
@@ -296,60 +334,78 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
|
|
|
296
334
|
writeJSON(ledger.files.hubPrs, ledger.hubPrs);
|
|
297
335
|
}
|
|
298
336
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
// fetch (branch deleted after merge) is fine — the artifact already landed via the merge.
|
|
302
|
-
const overlay = artifactPaths(job.base).map((p) => path.join('epics', job.epic, p));
|
|
303
|
-
const fetched = job.branch ? git('fetch', 'origin', job.branch).ok : false;
|
|
304
|
-
if (fetched) for (const p of overlay) git('checkout', 'FETCH_HEAD', '--', p);
|
|
305
|
-
|
|
337
|
+
// No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
|
|
338
|
+
// binds to the reviewed content directly when CI re-reads the platform.
|
|
306
339
|
let failed = false;
|
|
307
340
|
try {
|
|
308
|
-
|
|
341
|
+
// A branch event that is not a merge can never advance (the predicate requires merged), so it
|
|
342
|
+
// is read-only under Path B — run it as a dry sync that persists nothing to the working tree.
|
|
343
|
+
const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged });
|
|
309
344
|
synced += r.synced;
|
|
345
|
+
// When the step actually ADVANCED (the merge phase, or a swept merge the schedule observed),
|
|
346
|
+
// reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
|
|
347
|
+
// --merged flag, so the GitLab scheduled sweep also flips status on a merge it catches. Never
|
|
348
|
+
// on a held step: CI must not touch the artifact while the owner is editing it pre-merge.
|
|
349
|
+
if (r.advanced > 0) { advancedEpics.add(job.epic); await syncStatuses(root, { epic: job.epic }); }
|
|
310
350
|
} catch (err) {
|
|
311
351
|
if (branch) throw err; // event mode: one epic — surface the failure
|
|
312
352
|
warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
|
|
313
353
|
process.exitCode = 1;
|
|
314
354
|
failed = true;
|
|
315
|
-
} finally {
|
|
316
|
-
// Drop the overlay — even when sync throws: only the ledger may reach the default branch via CI.
|
|
317
|
-
if (fetched) {
|
|
318
|
-
for (const p of overlay) {
|
|
319
|
-
git('checkout', 'HEAD', '--', p); // tracked files back to HEAD (no-op fail if not in HEAD)
|
|
320
|
-
git('clean', '-fd', '--', p); // files new on the branch: remove
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
355
|
}
|
|
324
356
|
if (failed) continue; // a failed epic's partial state must not be committed by this run
|
|
325
357
|
touched.add(job.epic);
|
|
326
358
|
}
|
|
327
359
|
if (!touched.size) return { synced };
|
|
328
360
|
|
|
329
|
-
//
|
|
330
|
-
//
|
|
361
|
+
// Path B: CI never writes the ledger to the review branch. A held step that did not advance is
|
|
362
|
+
// read-only here — during review the platform PR/MR is the source of truth (native approvals/
|
|
363
|
+
// threads); the ledger is reconciled onto the default branch at merge. Keeping CI off the PR head
|
|
364
|
+
// is what stops an approval from being dismissed and required checks from stranding. Correctness is
|
|
365
|
+
// unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
|
|
366
|
+
const advancedAny = advancedEpics.size > 0;
|
|
367
|
+
if (!merged && !advancedAny) {
|
|
368
|
+
// Pre-merge is read-only (Path B): the gate was evaluated with a dry sync that persists nothing.
|
|
369
|
+
// The one working-tree write is the hub-prs.json seed above (so the dry sync could find the PR);
|
|
370
|
+
// restore exactly that file per epic so the checkout stays clean — never touching anything else,
|
|
371
|
+
// so a local `yad gate ci --branch` cannot disturb unrelated files.
|
|
372
|
+
for (const e of touched) {
|
|
373
|
+
const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
|
|
374
|
+
git('checkout', '-q', '--', hp); // restore it if it was tracked
|
|
375
|
+
git('clean', '-fq', '--', hp); // remove it if the event first-seeded it (untracked)
|
|
376
|
+
}
|
|
377
|
+
info('pre-merge: gate evaluated; the ledger reconciles on the default branch at merge — nothing pushed');
|
|
378
|
+
return { synced };
|
|
379
|
+
}
|
|
380
|
+
const target = defaultBranch; // CI only ever commits the ledger to the default branch
|
|
381
|
+
|
|
382
|
+
// Stage what this merge-phase run owns, per epic (everything lands on the default branch):
|
|
383
|
+
// - advanced → the whole epic (ledger advance + the status flip syncStatuses wrote into the .md).
|
|
384
|
+
// - merged but not advanced (merged before the rule passed) → the ledger (.sdlc) + the generated
|
|
385
|
+
// reviews/ summaries only; the artifact is the owner's, left untouched.
|
|
331
386
|
for (const e of touched) {
|
|
332
|
-
|
|
333
|
-
git('add', '-A', '--', path.join('epics', e, '.sdlc'));
|
|
334
|
-
git('add', '-A', '--', path.join('epics', e, 'reviews'));
|
|
387
|
+
if (advancedEpics.has(e)) git('add', '-A', '--', path.join('epics', e));
|
|
388
|
+
else { git('add', '-A', '--', path.join('epics', e, '.sdlc')); git('add', '-A', '--', path.join('epics', e, 'reviews')); }
|
|
335
389
|
}
|
|
336
390
|
if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
391
|
+
// [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
|
|
392
|
+
// sibling workflows. CI never pushes the review branch (Path B), so there is no synchronize loop.
|
|
393
|
+
const subject = !branch
|
|
394
|
+
? 'chore(gate): scheduled gate sync [skip ci]' // sweep is a batch; one subject for the run
|
|
395
|
+
: `chore(gate): advance ${jobs[0].epic}/${jobs[0].base} on merge [skip ci]`;
|
|
340
396
|
const cm = git('commit', '-m', subject);
|
|
341
397
|
if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced }; }
|
|
342
|
-
ok(`committed
|
|
398
|
+
ok(`committed gate update: ${c.dim(subject)}`);
|
|
343
399
|
if (!push) return { synced };
|
|
344
400
|
|
|
345
401
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
346
|
-
if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed
|
|
402
|
+
if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed to origin/${target}`); return { synced }; }
|
|
347
403
|
if (attempt < 3) {
|
|
348
404
|
info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
|
|
349
405
|
if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
|
|
350
406
|
}
|
|
351
407
|
}
|
|
352
|
-
fail(`could not push
|
|
408
|
+
fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
|
|
353
409
|
process.exitCode = 1;
|
|
354
410
|
return { synced };
|
|
355
411
|
}
|
|
@@ -382,7 +438,8 @@ export async function gateStatus(root, { epic } = {}) {
|
|
|
382
438
|
const epicDir = epicRoot(root, epic);
|
|
383
439
|
const ledger = loadLedger(epicDir);
|
|
384
440
|
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
385
|
-
|
|
441
|
+
const solo = isSolo(loadHub(root).hub);
|
|
442
|
+
log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
|
|
386
443
|
for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
|
|
387
444
|
const cur = artifactHash(epicDir, s.artifact);
|
|
388
445
|
const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !(a.artifactHash && cur && a.artifactHash !== cur));
|
|
@@ -405,12 +462,22 @@ export async function gateOpen(root, { epic, artifact } = {}) {
|
|
|
405
462
|
const domains = touchedDomains(epicDir, step);
|
|
406
463
|
warnUnlockedContract(epicDir, artifact);
|
|
407
464
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
465
|
+
const bridge = isBridge(hub);
|
|
466
|
+
// Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the
|
|
467
|
+
// ledger, so the local command marks the step in_review. In bridge mode CI is the sole writer.
|
|
468
|
+
if (!bridge) {
|
|
469
|
+
ledger.state = markInReview(ledger.state, step);
|
|
470
|
+
writeJSON(ledger.files.state, ledger.state);
|
|
471
|
+
}
|
|
472
|
+
if (!hub?.platform) {
|
|
473
|
+
warn('no hub platform — marked in_review file-only (no PR opened)');
|
|
474
|
+
ok(`${step.id} → in_review`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
413
477
|
|
|
478
|
+
// Open the PR. In bridge mode CI records the hub-prs entry (and advances) on the default branch at
|
|
479
|
+
// merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
|
|
480
|
+
// CI writes nothing pre-merge. Without the bridge, the local command records the PR itself (no CI will).
|
|
414
481
|
const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
|
|
415
482
|
// Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
|
|
416
483
|
// domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
|
|
@@ -421,13 +488,16 @@ export async function gateOpen(root, { epic, artifact } = {}) {
|
|
|
421
488
|
const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
|
|
422
489
|
info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
|
|
423
490
|
const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
|
|
424
|
-
if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
|
|
491
|
+
if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
|
|
425
492
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
493
|
+
if (!bridge) {
|
|
494
|
+
ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null, url: r.url, branch, lastSyncedAt: null });
|
|
495
|
+
writeJSON(ledger.files.hubPrs, ledger.hubPrs);
|
|
496
|
+
}
|
|
429
497
|
ok(`opened ${r.url}`);
|
|
430
|
-
hand(
|
|
498
|
+
hand(bridge
|
|
499
|
+
? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
|
|
500
|
+
: `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
|
|
431
501
|
}
|
|
432
502
|
|
|
433
503
|
// ---- helpers ------------------------------------------------------------------------------------
|
package/cli/lib.mjs
CHANGED
|
@@ -30,6 +30,9 @@ export const info = (s) => log(` ${c.dim('•')} ${s}`);
|
|
|
30
30
|
export const warn = (s) => log(` ${c.yellow('!')} ${s}`);
|
|
31
31
|
export const fail = (s) => log(` ${c.red('✗')} ${s}`);
|
|
32
32
|
export const hand = (s) => log(` ${c.yellow('→')} ${s}`);
|
|
33
|
+
// Dimmed, indented guidance under a step — what it does / why / what to enter / what skipping means.
|
|
34
|
+
// Accepts a string or an array of lines so a knowledgeable user can skim past it.
|
|
35
|
+
export const guide = (lines) => { for (const l of (Array.isArray(lines) ? lines : [lines])) log(` ${c.dim(l)}`); };
|
|
33
36
|
|
|
34
37
|
// ---- prompts ------------------------------------------------------------
|
|
35
38
|
let rl;
|
package/cli/manifest.mjs
CHANGED
|
@@ -195,6 +195,8 @@ export const wiringFor = (platform) => [
|
|
|
195
195
|
export const HUB_WIRING = {
|
|
196
196
|
common: [
|
|
197
197
|
{ src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
|
|
198
|
+
// The ledger is CI-owned: block non-bot commits to gate-state files on hub review PRs.
|
|
199
|
+
{ src: 'skills/yad-checks/templates/checks/ledger-guard.sh', dest: 'checks/ledger-guard.sh', exec: true },
|
|
198
200
|
// Pattern gates run on the hub too (profile: hub) — commit subject + PR title + PR body.
|
|
199
201
|
{ src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
|
|
200
202
|
{ src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
|
package/cli/next.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `yad next` — the unified next-step driver. Read-only: it never writes state or acts. It reads the
|
|
2
|
+
// file ledger and prints the ONE concrete, copy-pasteable next action (and a one-line why), so a user
|
|
3
|
+
// never has to remember which of the 30 skills / gate commands comes next. "Guide, don't act" — the
|
|
4
|
+
// front half still never auto-advances.
|
|
5
|
+
//
|
|
6
|
+
// yad next general orientation across the whole project
|
|
7
|
+
// yad next <epic> the single next action for one epic
|
|
8
|
+
// yad next <epic> --check <step> exit 0 if <step> is runnable now, else 1 (the precondition guard)
|
|
9
|
+
// yad next --all every active epic's next action at once
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
|
|
13
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
14
|
+
import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId } from './epic-state.mjs';
|
|
15
|
+
|
|
16
|
+
// Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
|
|
17
|
+
// missing/old hub.json never breaks the driver.
|
|
18
|
+
function isSolo(root) {
|
|
19
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
|
|
20
|
+
return !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
21
|
+
}
|
|
22
|
+
// The setup profile recorded by `yad setup` (codebase / repo_layout / team_size), or null.
|
|
23
|
+
const profileOf = (root) => readJSON(path.join(root, PROJECT_FILES.hubConfig), null)?.profile || null;
|
|
24
|
+
// Has `yad setup` run here? True once the version stamp or hub config exists.
|
|
25
|
+
const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(path.join(root, PROJECT_FILES.hubConfig));
|
|
26
|
+
|
|
27
|
+
// Every epic that has a state ledger, in directory order.
|
|
28
|
+
function listEpics(root) {
|
|
29
|
+
const dir = path.join(root, 'epics');
|
|
30
|
+
if (!exists(dir)) return [];
|
|
31
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
32
|
+
.filter((e) => e.isDirectory() && isValidEpicId(e.name))
|
|
33
|
+
.map((e) => e.name)
|
|
34
|
+
.filter((id) => exists(path.join(dir, id, '.sdlc', 'state.json')))
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A short, copy-pasteable line for one action — the `▸` line a user can act on directly.
|
|
39
|
+
function actionLine(a, { solo } = {}) {
|
|
40
|
+
switch (a.kind) {
|
|
41
|
+
case 'new':
|
|
42
|
+
return `invoke the ${c.bold(a.skill)} skill ${c.dim('(author the epic)')}`;
|
|
43
|
+
case 'author':
|
|
44
|
+
return `invoke the ${c.bold(a.skill || ('yad-' + a.step))} skill ${c.dim(`(author ${a.artifact})`)}`;
|
|
45
|
+
case 'review-open':
|
|
46
|
+
case 'review-sync':
|
|
47
|
+
return `${c.bold(a.command)}${solo ? c.dim(' (solo: no approval needed — just merge your own PR)') : ''}`;
|
|
48
|
+
case 'build':
|
|
49
|
+
return `${c.bold('yad-run')} ${c.dim('(or per story: yad-spec → yad-implement → yad ship → yad-engineer-review)')}`;
|
|
50
|
+
default:
|
|
51
|
+
return c.dim('nothing to do');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Full, friendly printout for a single epic.
|
|
56
|
+
function printAction(a, { solo } = {}) {
|
|
57
|
+
log(`\n ${c.bold(a.epicId || '(epic)')} ${c.dim(`— ${a.why}`)}`);
|
|
58
|
+
hand(actionLine(a, { solo }));
|
|
59
|
+
if (a.kind === 'review-sync') info(c.dim(`unresolved comments? ${c.bold(`yad gate comments ${a.epicId} ${a.artifact}`)}`));
|
|
60
|
+
if (a.parallel) hand(`parallel track: invoke the ${c.bold(a.parallel.skill)} skill ${c.dim(`(author ${a.parallel.artifact})`)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `yad next` with no epic: orient across the whole project, always ending on ONE thing to do.
|
|
64
|
+
function generalNext(root, { all } = {}) {
|
|
65
|
+
if (!isSetUp(root)) {
|
|
66
|
+
log(`\n ${c.bold('Project not set up yet.')}`);
|
|
67
|
+
hand(`run ${c.bold('yad setup')} ${c.dim('(then come back to `yad next`)')}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const epics = listEpics(root);
|
|
71
|
+
if (!epics.length) {
|
|
72
|
+
const brownfield = profileOf(root)?.codebase === 'brownfield';
|
|
73
|
+
log(`\n ${c.bold('Set up — no epics yet.')}`);
|
|
74
|
+
if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
|
|
75
|
+
hand(`start your first epic: invoke the ${c.bold('yad-epic')} skill`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const solo = isSolo(root);
|
|
79
|
+
const actions = epics.map((id) => nextAction(loadLedger(epicRoot(root, id)), { epic: id }));
|
|
80
|
+
|
|
81
|
+
if (epics.length === 1 || all) {
|
|
82
|
+
for (const a of actions) printAction(a, { solo });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Several epics — list each with a one-liner, then point at the per-epic / --all views.
|
|
86
|
+
log(`\n ${c.bold(`${epics.length} epics`)} ${c.dim('— next action each:')}`);
|
|
87
|
+
for (const a of actions) log(` ${c.cyan(a.epicId)} ${actionLine(a, { solo })}`);
|
|
88
|
+
info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `yad next <epic> --check <step>`: the precondition guard. Exit 0 if runnable now, 1 otherwise.
|
|
92
|
+
function checkPrecondition(root, epic, stepId) {
|
|
93
|
+
const ledger = loadLedger(epicRoot(root, epic));
|
|
94
|
+
const res = preconditionsMet(ledger.state, stepId);
|
|
95
|
+
if (res.ok) {
|
|
96
|
+
ok(`${epic}: ${stepId} is ready to run`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
fail(`${epic}: ${stepId} is blocked — ${res.reason}`);
|
|
100
|
+
hand(`see what to do now: ${c.bold(`yad next ${epic}`)}`);
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Entry point for the `next` command: route to the precondition check, a single epic's action, or the
|
|
105
|
+
// project-wide general view. Validates the epic id first.
|
|
106
|
+
export async function runNext(root, { epic, check, all } = {}) {
|
|
107
|
+
if (epic && !isValidEpicId(epic)) {
|
|
108
|
+
fail(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (epic && check) return checkPrecondition(root, epic, check);
|
|
113
|
+
if (!epic) return generalNext(root, { all });
|
|
114
|
+
|
|
115
|
+
const epicDir = epicRoot(root, epic);
|
|
116
|
+
if (!exists(path.join(epicDir, '.sdlc', 'state.json'))) {
|
|
117
|
+
warn(`no epic state at ${epicDir}/.sdlc/state.json`);
|
|
118
|
+
hand(`is the id right? list project status with ${c.bold('yad next')}`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
printAction(nextAction(loadLedger(epicDir), { epic }), { solo: isSolo(root) });
|
|
123
|
+
}
|
package/cli/platform.mjs
CHANGED
|
@@ -141,10 +141,21 @@ export function resolveLogin(login, roster = [], repos = [], touchedDomains = []
|
|
|
141
141
|
|
|
142
142
|
// Normalized PR reviews -> approval records (only APPROVED states count). `submittedAt` rides along
|
|
143
143
|
// so the gate can tell a fresh re-approval from a stale one (revoke-on-change).
|
|
144
|
-
export function mapApprovers(reviews = [], { roster, repos, touchedDomains }) {
|
|
144
|
+
export function mapApprovers(reviews = [], { roster, repos, touchedDomains, headOid } = {}) {
|
|
145
145
|
const out = [];
|
|
146
146
|
for (const r of reviews) {
|
|
147
147
|
if (r.state !== 'APPROVED') continue;
|
|
148
|
+
// Revoke-on-change, enforced in code where the platform binds an approval to a commit. The reader
|
|
149
|
+
// sets `commit` to the review's SHA (GitHub), to `null` when that read DEGRADED, or leaves it
|
|
150
|
+
// ABSENT when the platform exposes no per-approval SHA (GitLab):
|
|
151
|
+
// - `null` (degraded read) → FAIL CLOSED → drop, independently of headOid: we cannot prove the
|
|
152
|
+
// approval is for the merged content, so a transient failure holds
|
|
153
|
+
// the gate rather than advancing on unverifiable approvals;
|
|
154
|
+
// - a known SHA ≠ head → the approval is stale (artifact moved) → drop;
|
|
155
|
+
// - absent (GitLab) → keep: revoke-on-change is the platform's "remove approvals on new
|
|
156
|
+
// commits" setting.
|
|
157
|
+
if (r.commit === null) continue;
|
|
158
|
+
if (headOid && r.commit !== undefined && r.commit !== headOid) continue;
|
|
148
159
|
for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
|
|
149
160
|
out.push({ ...rec, submittedAt: r.submittedAt || null });
|
|
150
161
|
}
|
|
@@ -157,17 +168,35 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
157
168
|
const view = run('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,headRefOid'], { cwd });
|
|
158
169
|
if (!view.ok) return { ok: false, reason: view.stderr || 'gh pr view failed' };
|
|
159
170
|
const meta = JSON.parse(view.stdout);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const reviews = rev.ok
|
|
163
|
-
? (JSON.parse(rev.stdout).latestReviews || []).map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt }))
|
|
164
|
-
: [];
|
|
171
|
+
let reviews = [];
|
|
172
|
+
let reviewsOk = false;
|
|
165
173
|
// Review-thread resolution via GraphQL (REST does not expose isResolved). Paginate so a PR with
|
|
166
174
|
// >100 threads is not mistakenly read as "all resolved".
|
|
167
175
|
let threads = [];
|
|
168
176
|
const nwo = run('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
|
|
169
177
|
if (nwo.ok) {
|
|
170
178
|
const { owner, name } = JSON.parse(nwo.stdout);
|
|
179
|
+
// latestReviews collapses a reviewer's superseded reviews to their current one; commit.oid binds
|
|
180
|
+
// each approval to the revision it was made on, so an approval on an older commit than the merged
|
|
181
|
+
// head is dropped as stale (revoke-on-change in code — see mapApprovers). `gh pr view --json
|
|
182
|
+
// latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
|
|
183
|
+
// reviewers never silently omits one; any page failure aborts to the commitless fallback below,
|
|
184
|
+
// 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}}}}}}`;
|
|
186
|
+
let rcursor = null;
|
|
187
|
+
reviewsOk = true;
|
|
188
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
189
|
+
const args = ['api', 'graphql', '-f', `query=${rq}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
|
|
190
|
+
if (rcursor) args.push('-F', `c=${rcursor}`);
|
|
191
|
+
const rg = run('gh', args, { cwd });
|
|
192
|
+
if (!rg.ok) { reviewsOk = false; reviews = []; break; }
|
|
193
|
+
const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
|
|
194
|
+
for (const x of conn?.nodes || []) {
|
|
195
|
+
reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: x.commit?.oid || null });
|
|
196
|
+
}
|
|
197
|
+
if (!conn?.pageInfo?.hasNextPage) break;
|
|
198
|
+
rcursor = conn.pageInfo.endCursor;
|
|
199
|
+
}
|
|
171
200
|
const q = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{isResolved comments(first:1){nodes{author{login} body}}}}}}}`;
|
|
172
201
|
let cursor = null;
|
|
173
202
|
for (let guard = 0; guard < 50; guard++) {
|
|
@@ -188,6 +217,15 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
188
217
|
cursor = page.pageInfo.endCursor;
|
|
189
218
|
}
|
|
190
219
|
}
|
|
220
|
+
// Fallback if the GraphQL reviews read failed (no nwo / API hiccup): take the plain JSON view with
|
|
221
|
+
// commit=null. Approvals then FAIL CLOSED in mapApprovers (a degraded read cannot prove an approval
|
|
222
|
+
// is for the merged content), while CHANGES_REQUESTED is still honored — so a transient failure
|
|
223
|
+
// holds the gate, never advances it.
|
|
224
|
+
if (!reviewsOk) {
|
|
225
|
+
const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
|
|
226
|
+
if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
|
|
227
|
+
.map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: null }));
|
|
228
|
+
}
|
|
191
229
|
return {
|
|
192
230
|
ok: true,
|
|
193
231
|
state: meta.state,
|
package/cli/repo.mjs
CHANGED
|
@@ -13,11 +13,14 @@ function load(root) {
|
|
|
13
13
|
return { regPath, registry: readJSON(regPath, { repos: [] }) };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha).
|
|
16
|
+
// HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha). A repo that has a HEAD but
|
|
17
|
+
// no syncedHead was registered without a pack (the greenfield path) — it needs an initial pack, which is
|
|
18
|
+
// also a "run `yad repo refresh`" state, kept distinct from HEAD-moved staleness.
|
|
17
19
|
function staleness(root, repo) {
|
|
18
20
|
const head = gitHead(path.resolve(root, repo.path));
|
|
21
|
+
const neverPacked = !!head && !repo.syncedHead;
|
|
19
22
|
const stale = head && repo.syncedHead && head !== repo.syncedHead;
|
|
20
|
-
return { head, stale: !!stale, unknown: !head };
|
|
23
|
+
return { head, stale: !!stale, unknown: !head, neverPacked };
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// ---- git helpers for `sync` (local-user auth only — never embed credentials) ----
|
|
@@ -40,9 +43,10 @@ export async function runRepo(root, { action = 'list', name, today } = {}) {
|
|
|
40
43
|
log(c.bold('\nconnected repos'));
|
|
41
44
|
let staleCount = 0;
|
|
42
45
|
for (const repo of registry.repos) {
|
|
43
|
-
const { stale, unknown } = staleness(root, repo);
|
|
46
|
+
const { stale, unknown, neverPacked } = staleness(root, repo);
|
|
44
47
|
if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
|
|
45
|
-
if (
|
|
48
|
+
if (neverPacked) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
|
|
49
|
+
else if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
|
|
46
50
|
else ok(`${repo.name} ${c.dim('— fresh')}`);
|
|
47
51
|
}
|
|
48
52
|
if (staleCount) hand(`refresh with \`yad repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
|