yadflow 3.15.2 → 3.15.4
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 -0
- package/cli/artifact-status.mjs +9 -3
- package/cli/epic-state.mjs +37 -1
- package/cli/gate.mjs +88 -19
- package/cli/lib.mjs +7 -0
- package/package.json +1 -1
- package/skills/yad-architecture/SKILL.md +16 -3
- package/skills/yad-backfill/SKILL.md +6 -0
- package/skills/yad-epic/SKILL.md +25 -8
- package/skills/yad-hub-bridge/SKILL.md +4 -0
- package/skills/yad-hub-bridge/references/bridge.md +30 -0
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +32 -12
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +15 -4
- package/skills/yad-review-gate/SKILL.md +18 -6
- package/skills/yad-stories/SKILL.md +18 -4
- package/skills/yad-test-cases/SKILL.md +18 -3
- package/skills/yad-ui/SKILL.md +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## [3.15.4](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.3...v3.15.4) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
## [3.15.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.2...v3.15.3) (2026-08-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **bridge:** pass the PR head ref through env, not into the run script ([306f49f](https://github.com/abdelrahmannasr/yadflow/commit/306f49f569c5398bbfb36ce9aa3fb38d8341336c))
|
|
9
|
+
* **gate:** make the reconcile sweep converge instead of committing forever ([6bcb8fd](https://github.com/abdelrahmannasr/yadflow/commit/6bcb8fd8cd296b669bcf11226013fcd2e107f650)), closes [#163](https://github.com/abdelrahmannasr/yadflow/issues/163)
|
|
10
|
+
* **gate:** stage the merge commit from an allowlist, not the whole epic dir ([8142ddc](https://github.com/abdelrahmannasr/yadflow/commit/8142ddcce0f889505a29558c6664e42548d9d671))
|
|
11
|
+
|
|
1
12
|
## [3.15.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.1...v3.15.2) (2026-08-11)
|
|
2
13
|
|
|
3
14
|
|
package/cli/artifact-status.mjs
CHANGED
|
@@ -59,9 +59,15 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
59
59
|
const epics = epic
|
|
60
60
|
? [epic]
|
|
61
61
|
: (fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).filter((e) => fs.statSync(path.join(epicsDir, e)).isDirectory()).sort() : []);
|
|
62
|
-
if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0 }; }
|
|
62
|
+
if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0, files: [] }; }
|
|
63
63
|
|
|
64
64
|
let changed = 0;
|
|
65
|
+
// The root-relative paths actually rewritten. `gate ci` stages its merge commit from an explicit
|
|
66
|
+
// allowlist — the ledger, the generated reviews, and exactly these artifacts — so a run on a dirty
|
|
67
|
+
// checkout (the documented manual recovery) can never sweep an unrelated file onto the default
|
|
68
|
+
// branch. Reporting the paths is what lets the caller be that specific. Named `written`, not
|
|
69
|
+
// `files`: the per-epic loop below already binds a local `files` (its candidate list).
|
|
70
|
+
const written = [];
|
|
65
71
|
for (const e of epics) {
|
|
66
72
|
const dir = epicRoot(root, e);
|
|
67
73
|
const state = readJSONStrict(epicFiles(dir).state, null);
|
|
@@ -98,10 +104,10 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
98
104
|
continue;
|
|
99
105
|
}
|
|
100
106
|
const prev = setFrontmatterStatus(file, want);
|
|
101
|
-
if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; }
|
|
107
|
+
if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; written.push(path.relative(root, file)); }
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
if (!changed) info(dryRun ? 'no status changes needed' : 'all artifact statuses already in sync');
|
|
105
111
|
else if (!dryRun) ok(`updated ${changed} artifact status(es)`);
|
|
106
|
-
return { changed };
|
|
112
|
+
return { changed, files: written };
|
|
107
113
|
}
|
package/cli/epic-state.mjs
CHANGED
|
@@ -52,9 +52,45 @@ export function artifactPaths(base) {
|
|
|
52
52
|
return [`${base}.md`];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// ---- canonical ledger order ---------------------------------------------------------------------
|
|
56
|
+
// Every epic-ledger upsert in this codebase is drop-and-re-append: the records being refreshed are
|
|
57
|
+
// filtered out of the array and pushed back at the TAIL. That makes the file's bytes depend on WHICH
|
|
58
|
+
// step was synced last, not on what the ledger holds — and the wired sweep drives one `gate ci` per
|
|
59
|
+
// merged PR/MR, so a pass over N merged reviews ROTATES the array:
|
|
60
|
+
//
|
|
61
|
+
// [A,B,C,D,E] -sync A-> [B,C,D,E,A] -sync B-> [C,D,E,A,B] -> … -sync E-> [A,B,C,D,E]
|
|
62
|
+
//
|
|
63
|
+
// Every hop is a non-empty diff, so every hop commits and pushes, and the pass lands back where it
|
|
64
|
+
// started — an unbounded commit loop with zero semantic change (issue #163). Sorting the array on
|
|
65
|
+
// write makes the bytes a pure function of the record SET, so an unchanged re-sync is byte-identical
|
|
66
|
+
// and `gate ci`'s existing "nothing staged -> nothing to commit" guard finally holds.
|
|
67
|
+
//
|
|
68
|
+
// The per-record JSON.stringify tiebreak is what makes the order TOTAL. `Array#sort` is stable, so
|
|
69
|
+
// records tying on the tuple key would keep their (rotating) insertion order — and a manual,
|
|
70
|
+
// skill-written approval can tie with a bridge one on (step, approver, role, domain). The tiebreak is
|
|
71
|
+
// position-independent, so ties resolve by content instead.
|
|
72
|
+
//
|
|
73
|
+
// Compared by CODE UNIT (`<`), not localeCompare: the ledger is written by CI and read/re-written by
|
|
74
|
+
// every teammate's machine, and localeCompare's order depends on the host locale and ICU build. Two
|
|
75
|
+
// machines disagreeing on where one record sorts would reintroduce exactly the churn this prevents.
|
|
76
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
77
|
+
const canonical = (list, keyOf) => [...list].sort(
|
|
78
|
+
(x, y) => cmp(keyOf(x), keyOf(y)) || cmp(JSON.stringify(x), JSON.stringify(y)),
|
|
79
|
+
);
|
|
80
|
+
const field = (v) => (v == null ? '' : String(v));
|
|
81
|
+
|
|
82
|
+
export const canonicalApprovals = (approvals = []) => canonical(approvals, (a) =>
|
|
83
|
+
[a.step, a.artifact, a.role, a.domain, a.approver, a.source, a.approvedAt, a.date].map(field).join('|'));
|
|
84
|
+
|
|
85
|
+
export const canonicalComments = (comments = []) => canonical(comments, (cm) =>
|
|
86
|
+
[cm.step, String(cm.round ?? '').padStart(6, '0'), cm.commenter, cm.role, cm.date].map(field).join('|'));
|
|
87
|
+
|
|
88
|
+
export const canonicalHubPrs = (hubPrs = []) => canonical(hubPrs, (p) =>
|
|
89
|
+
[p.artifact, p.step].map(field).join('|'));
|
|
90
|
+
|
|
55
91
|
// Replace-not-append upsert into hub-prs.json, keyed by artifact (one live review PR per artifact).
|
|
56
92
|
export function upsertHubPr(hubPrs = [], rec) {
|
|
57
|
-
return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
|
|
93
|
+
return canonicalHubPrs([...hubPrs.filter((p) => p.artifact !== rec.artifact), rec]);
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
// SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
|
package/cli/gate.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
14
|
upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
|
|
15
|
+
canonicalApprovals, canonicalComments, canonicalHubPrs,
|
|
15
16
|
} from './epic-state.mjs';
|
|
16
17
|
import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
17
18
|
import {
|
|
@@ -179,7 +180,11 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNum
|
|
|
179
180
|
...(r.unverified ? { unverified: true } : {}),
|
|
180
181
|
});
|
|
181
182
|
}
|
|
182
|
-
|
|
183
|
+
// Canonical order, not insertion order: this function re-appends at the tail, so without it the
|
|
184
|
+
// bytes depend on which step was synced last and the sweep rotates the file forever (issue #163 —
|
|
185
|
+
// see canonicalApprovals). Sorting here also makes the in-memory before/after comparison below
|
|
186
|
+
// (`approvalsBefore`) mean what it says, so an unchanged re-sync no longer re-stamps lastSyncedAt.
|
|
187
|
+
return canonicalApprovals(kept);
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
// Mutates in place, returns how many it stamped. Backfill `pr` on this step's bridge approvals that
|
|
@@ -216,14 +221,28 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
|
|
|
216
221
|
if (!blocking.length) return comments;
|
|
217
222
|
const byName = (login) => (roster.find((r) => r.login === login)?.name) || login || 'reviewer';
|
|
218
223
|
const roleOf = (login) => (roster.find((r) => r.login === login)?.role) || 'reviewer';
|
|
219
|
-
const round = (comments.filter((cm) => cm.step === stepId).reduce((m, cm) => Math.max(m, cm.round || 0), 0)) + 1;
|
|
220
224
|
const counts = new Map();
|
|
221
225
|
for (const t of blocking) counts.set(t.login, (counts.get(t.login) || 0) + 1);
|
|
226
|
+
// A round is a CHANGE in the thread state, not a sync. Allocating max+1 on every call made an
|
|
227
|
+
// unchanged re-read append a whole new record set each pass — and the sweep re-reads a merged review
|
|
228
|
+
// every 15 minutes for a week. A step whose gate never passes (one unresolved thread, or a missing
|
|
229
|
+
// approval) is never `alreadyDone`, so it kept reaching here: ~96 ledger commits a day per stuck
|
|
230
|
+
// review, the same unbounded loop as #163 from the other side. So when the latest recorded round
|
|
231
|
+
// already describes exactly these commenters and counts, REWRITE it in place instead.
|
|
232
|
+
const rounds = comments.filter((cm) => cm.step === stepId);
|
|
233
|
+
const latest = rounds.reduce((m, cm) => Math.max(m, cm.round || 0), 0);
|
|
234
|
+
const prior = rounds.filter((cm) => cm.round === latest);
|
|
235
|
+
const now = new Map([...counts].map(([login, count]) => [byName(login), count]));
|
|
236
|
+
const same = latest > 0 && prior.length === now.size && prior.every((cm) => now.get(cm.commenter) === cm.count);
|
|
237
|
+
const round = same ? latest : latest + 1;
|
|
222
238
|
const kept = comments.filter((cm) => !(cm.step === stepId && cm.round === round));
|
|
223
239
|
for (const [login, count] of counts) {
|
|
224
|
-
|
|
240
|
+
// An unchanged round keeps its original date, so re-syncing it is byte-identical rather than a
|
|
241
|
+
// daily one-line churn (the same rule upsertBridge applies to an unchanged approval).
|
|
242
|
+
const was = prior.find((cm) => cm.commenter === byName(login));
|
|
243
|
+
kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: (same && was?.date) || today });
|
|
225
244
|
}
|
|
226
|
-
return kept;
|
|
245
|
+
return canonicalComments(kept); // same drop-and-re-append churn as approvals — see canonicalApprovals
|
|
227
246
|
}
|
|
228
247
|
|
|
229
248
|
// ---- actions ------------------------------------------------------------------------------------
|
|
@@ -306,7 +325,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
306
325
|
if (s) stampLegacyPr(approvals, s.id, p.number);
|
|
307
326
|
}
|
|
308
327
|
const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
|
|
309
|
-
|
|
328
|
+
// Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
|
|
329
|
+
// it closes, so syncing two passing gates out of chain order rewinds the epic: closing
|
|
330
|
+
// architecture-review first (next: ui-design) and epic-review second (next: architecture) reopens the
|
|
331
|
+
// already-done `architecture` author step and points currentStep backward — the YAD-STATE-005 chain
|
|
332
|
+
// inconsistency `yad gate repair` exists to undo. This used to hold only by accident, because
|
|
333
|
+
// hub-prs.json happened to be in insertion order; now that the file is written sorted by artifact
|
|
334
|
+
// (see canonicalHubPrs) the accident is gone, so make the ordering explicit. `gate ci` is unaffected
|
|
335
|
+
// either way — it always names a single artifact.
|
|
336
|
+
const stepIndex = (p) => {
|
|
337
|
+
const s = findReviewStep(state, p.artifact);
|
|
338
|
+
return s ? state.steps.indexOf(s) : Number.MAX_SAFE_INTEGER;
|
|
339
|
+
};
|
|
340
|
+
const targets = [...resolved.targets].sort((a, b) => stepIndex(a) - stepIndex(b));
|
|
310
341
|
if (!targets.length) {
|
|
311
342
|
warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
|
|
312
343
|
hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
|
|
@@ -417,11 +448,17 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
417
448
|
info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
|
|
418
449
|
return { synced, advanced };
|
|
419
450
|
}
|
|
451
|
+
// Belt-and-braces: the upserts above already return canonical order, but a ledger this run only
|
|
452
|
+
// READ (no matching target, or a pre-canonical file written by an older release) still gets sorted
|
|
453
|
+
// here, so the first sweep after the upgrade converges the file once and never churns it again.
|
|
454
|
+
approvals = canonicalApprovals(approvals);
|
|
455
|
+
comments = canonicalComments(comments);
|
|
456
|
+
hubPrs = canonicalHubPrs(hubPrs);
|
|
420
457
|
writeJSON(ledger.files.approvals, approvals);
|
|
421
458
|
writeJSON(ledger.files.comments, comments);
|
|
422
459
|
writeJSON(ledger.files.hubPrs, hubPrs);
|
|
423
460
|
writeJSON(ledger.files.state, state);
|
|
424
|
-
refreshRoster(epicDir, open, approvals, today);
|
|
461
|
+
refreshRoster(epicDir, open, approvals, today); // the dated side file lists them in the same order
|
|
425
462
|
return { synced, advanced };
|
|
426
463
|
}
|
|
427
464
|
|
|
@@ -482,6 +519,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
482
519
|
let synced = 0;
|
|
483
520
|
const touched = new Set();
|
|
484
521
|
const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
|
|
522
|
+
const statusFiles = new Map(); // epic -> the artifact files syncStatuses rewrote (staging allowlist)
|
|
485
523
|
for (const job of jobs) {
|
|
486
524
|
const epicDir = epicRoot(root, job.epic);
|
|
487
525
|
// Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
|
|
@@ -508,10 +546,18 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
508
546
|
// Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
|
|
509
547
|
// recorded before replacing it, or a re-review on the replacement PR can never be told from a
|
|
510
548
|
// re-read of the old one and stays permanently stale.
|
|
511
|
-
|
|
549
|
+
//
|
|
550
|
+
// MERGE PHASE ONLY. This is the one approvals.json write gateCi itself performs, and pre-merge the
|
|
551
|
+
// run persists nothing and commits nothing — so a stamp there would be a working-tree edit with no
|
|
552
|
+
// purpose, left behind for `ledger-guard` to reject (and a `git checkout` broad enough to undo it
|
|
553
|
+
// would also revert approvals this run never wrote, e.g. a human's uncommitted manual record). The
|
|
554
|
+
// stamp loses nothing by waiting: pre-merge writes nothing, so the OLD pointer is still on disk when
|
|
555
|
+
// the merge event arrives and re-runs this. In sweep mode `job.pr` comes from the ledger itself, so
|
|
556
|
+
// `number === existing.number` and the condition is false regardless.
|
|
557
|
+
if (merged && existing?.number != null && number !== existing.number) {
|
|
512
558
|
const stamped = stampLegacyPr(ledger.approvals, step.id, existing.number);
|
|
513
559
|
if (stamped) {
|
|
514
|
-
writeJSON(ledger.files.approvals, ledger.approvals);
|
|
560
|
+
writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
|
|
515
561
|
info(`${job.epic}: recorded PR #${existing.number} on ${stamped} approval(s) that predate PR provenance`);
|
|
516
562
|
}
|
|
517
563
|
}
|
|
@@ -535,7 +581,13 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
535
581
|
// reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
|
|
536
582
|
// --merged flag, so the GitLab scheduled sweep also flips status on a merge it catches. Never
|
|
537
583
|
// on a held step: CI must not touch the artifact while the owner is editing it pre-merge.
|
|
538
|
-
if (r.advanced > 0) {
|
|
584
|
+
if (r.advanced > 0) {
|
|
585
|
+
advancedEpics.add(job.epic);
|
|
586
|
+
const st = await syncStatuses(root, { epic: job.epic });
|
|
587
|
+
// Remember exactly which artifacts were rewritten — that, and nothing else, is what the
|
|
588
|
+
// commit below may stage outside the ledger (see the staging allowlist).
|
|
589
|
+
statusFiles.set(job.epic, [...(statusFiles.get(job.epic) || []), ...(st.files || [])]);
|
|
590
|
+
}
|
|
539
591
|
} catch (err) {
|
|
540
592
|
if (branch) throw err; // event mode: one epic — surface the failure
|
|
541
593
|
warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
|
|
@@ -554,10 +606,18 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
554
606
|
// unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
|
|
555
607
|
const advancedAny = advancedEpics.size > 0;
|
|
556
608
|
if (!merged && !advancedAny) {
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
// so a local `yad gate ci --branch` cannot disturb unrelated files.
|
|
609
|
+
// EVENT mode (--branch) pre-merge is read-only (Path B): the gate was evaluated with a dry sync
|
|
610
|
+
// that persists nothing, so the one working-tree write is the hub-prs.json seed above (which let
|
|
611
|
+
// the dry sync find the PR). Restore exactly that file per epic so the checkout stays clean —
|
|
612
|
+
// never touching anything else, so a local `yad gate ci --branch` cannot disturb unrelated files.
|
|
613
|
+
// The stampLegacyPr backfill is gated on `merged` above precisely so there is no second file to
|
|
614
|
+
// undo: a restore wide enough to cover approvals.json would also revert records this run never
|
|
615
|
+
// wrote — a human's uncommitted manual approval, or an untracked ledger seed `git clean` deletes.
|
|
616
|
+
//
|
|
617
|
+
// SWEEP mode (no --branch) reaches here too, and its sync was NOT dry, so state.json/comments.json
|
|
618
|
+
// and reviews/*.md may be modified and are deliberately left alone: reverting a sync that genuinely
|
|
619
|
+
// ran would discard platform state the run just recorded. A bare `yad gate ci` that advances
|
|
620
|
+
// nothing therefore leaves those files dirty for the operator to inspect and commit (or discard).
|
|
561
621
|
for (const e of touched) {
|
|
562
622
|
const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
|
|
563
623
|
git('checkout', '-q', '--', hp); // restore it if it was tracked
|
|
@@ -568,13 +628,22 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
568
628
|
}
|
|
569
629
|
const target = defaultBranch; // CI only ever commits the ledger to the default branch
|
|
570
630
|
|
|
571
|
-
// Stage what this merge-phase run owns, per epic
|
|
572
|
-
//
|
|
573
|
-
// -
|
|
574
|
-
//
|
|
631
|
+
// Stage what this merge-phase run owns, per epic, by an EXPLICIT ALLOWLIST — never `git add -A`
|
|
632
|
+
// over the whole epic directory:
|
|
633
|
+
// - always → the ledger (.sdlc) + the generated reviews/ summaries.
|
|
634
|
+
// - advanced → plus exactly the artifact files syncStatuses rewrote (the draft → approved flip).
|
|
635
|
+
// The owner's artifact is otherwise theirs, and is left untouched.
|
|
636
|
+
//
|
|
637
|
+
// `git add -A -- epics/<e>` would also sweep up anything else sitting in that directory. In CI the
|
|
638
|
+
// checkout is fresh, so it is invisible there — but `yad gate ci … --merged` on the default branch
|
|
639
|
+
// is the DOCUMENTED manual recovery for a stuck gate, and a human's checkout is rarely pristine.
|
|
640
|
+
// Half-finished edits to another artifact, or a stray untracked file, would be committed and pushed
|
|
641
|
+
// straight to the default branch under a `chore(gate)` subject with [skip ci] — unreviewed, and
|
|
642
|
+
// contradicting the "CI commits only the ledger" contract every doc in this repo states.
|
|
575
643
|
for (const e of touched) {
|
|
576
|
-
|
|
577
|
-
|
|
644
|
+
git('add', '-A', '--', path.join('epics', e, '.sdlc'));
|
|
645
|
+
git('add', '-A', '--', path.join('epics', e, 'reviews'));
|
|
646
|
+
for (const f of statusFiles.get(e) || []) git('add', '--', f);
|
|
578
647
|
}
|
|
579
648
|
if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
|
|
580
649
|
// [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
|
package/cli/lib.mjs
CHANGED
|
@@ -123,6 +123,13 @@ export function readJSONStrict(p, def = null) {
|
|
|
123
123
|
// file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
|
|
124
124
|
export function writeJSON(p, obj) {
|
|
125
125
|
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
126
|
+
// Byte-identical content is not a write. The ledger writers are unconditional — they re-serialize
|
|
127
|
+
// whether or not anything changed — so this keeps an unchanged sync from touching the file at all
|
|
128
|
+
// (no mtime churn, nothing for a watcher or a `git add -A` to notice). A backstop, not the fix: the
|
|
129
|
+
// load-bearing guarantee is that the serialized bytes are canonically ordered (see #163).
|
|
130
|
+
try {
|
|
131
|
+
if (fs.readFileSync(p, 'utf8') === data) return;
|
|
132
|
+
} catch { /* missing or unreadable — fall through and write it */ }
|
|
126
133
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
127
134
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
128
135
|
fs.writeFileSync(tmp, data);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.15.
|
|
3
|
+
"version": "3.15.4",
|
|
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 + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -178,9 +178,22 @@ awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
|
|
|
178
178
|
> of hash-binding.
|
|
179
179
|
|
|
180
180
|
### Step 6 — Advance the authoring step (NOT the gate)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
**Check the mode first — the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
|
|
182
|
+
**bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
|
|
183
|
+
|
|
184
|
+
**Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
|
|
185
|
+
any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
186
|
+
`epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
|
|
187
|
+
`yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
|
|
188
|
+
fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
|
|
189
|
+
is pushed around the gate. Commit the artifact set — **`architecture.md`, `contract.md`, and
|
|
190
|
+
`.sdlc/contract-lock.json`** (artifact-side, not ledger) — then hand off to `yad-review-gate`.
|
|
191
|
+
|
|
192
|
+
**Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
|
|
193
|
+
`architecture.status: "done"`, set `architecture-review.status: "in_review"`, and set
|
|
194
|
+
`currentStep: "architecture-review"`. Write `state.json`. Do **not** touch `approvals.json` — only
|
|
195
|
+
real reviewers approve, through the gate. On this branch `yad gate open` makes the same edit, so it
|
|
196
|
+
is a no-op once the gate has run.
|
|
184
197
|
|
|
185
198
|
### Step 7 — Stop at the gate (do NOT advance)
|
|
186
199
|
Report: the paths to `architecture.md`, `contract.md`, and `contract-lock.json`; the contract hash;
|
|
@@ -99,6 +99,12 @@ thread off it, `promote` is what makes that anchor real — run it once the feat
|
|
|
99
99
|
"wake" the state chain — set `currentStep: "epic"`, `epic` step `status: "in_progress"` — then run
|
|
100
100
|
`yad-epic` → `yad-architecture` → … the normal way. This re-locks a contract that subsequent thread
|
|
101
101
|
changes will inherit.
|
|
102
|
+
- **Bridge mode — promote is not wired.** The `state.json` edits above mutate an epic whose ledger is
|
|
103
|
+
already on the base ref, so the `#162` seed exemption does not apply and `ledger-guard` rejects the
|
|
104
|
+
commit; unlike the authoring steps there is no `yad backfill` CLI and no `gate ci` path that performs
|
|
105
|
+
the promotion instead. On a bridge hub, STOP and report this — the promotion needs the gate bot (or a
|
|
106
|
+
maintainer landing it out of band). Only the `epic.md` half is safe to commit. Tracked as a gap; do
|
|
107
|
+
**not** push the ledger edit around the guard.
|
|
102
108
|
- Never auto-advances; a human confirms the promotion.
|
|
103
109
|
|
|
104
110
|
### Step 7 — Stop (no auto-advance)
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -169,14 +169,31 @@ Notes:
|
|
|
169
169
|
|
|
170
170
|
### Step 5b — Advance the authoring step — analysis-ran only
|
|
171
171
|
*(Only when analysis ran — `state.json` already exists from `yad-analysis`.)*
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
172
|
+
**Check the mode first — the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
|
|
173
|
+
**bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
|
|
174
|
+
|
|
175
|
+
**Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
|
|
176
|
+
any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
177
|
+
`epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
|
|
178
|
+
`yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
|
|
179
|
+
fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
|
|
180
|
+
is pushed around the gate. Commit **`epic.md` only** — nothing else under `.sdlc/` — then hand off
|
|
181
|
+
to `yad-review-gate`.
|
|
182
|
+
|
|
183
|
+
> **This is not the Step 5 seed exemption.** `ledger-guard` exempts a *brand-new* epic's ledger
|
|
184
|
+
> (creation, not mutation, #162). On this path `state.json` already exists from `yad-analysis` and
|
|
185
|
+
> reached the base ref through the analysis review — so the guard is absolute here.
|
|
186
|
+
|
|
187
|
+
**Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
|
|
188
|
+
`epic.status: "done"`, set `epic-review.status: "in_review"`, and set `currentStep: "epic-review"`.
|
|
189
|
+
Write `state.json`. Do **not** re-seed and do **not** touch `approvals.json` — only real reviewers
|
|
190
|
+
approve, through the gate.
|
|
191
|
+
|
|
192
|
+
> **File-only branch only.** Since 3.11 the CLI closes the authoring step itself whenever its review
|
|
193
|
+
> gate opens or advances (`yad gate open` / `sync`), so this edit is a no-op when the gate has already
|
|
194
|
+
> run. It keeps `state.json` truthful before the gate opens, but it is no longer load-bearing: an epic
|
|
195
|
+
> whose author step is left `in_progress` used to strand forever (`YAD-STATE-005`). In bridge mode
|
|
196
|
+
> `gate open` writes nothing and local `gate sync` is advisory — `gate ci` closes the step at merge.
|
|
180
197
|
|
|
181
198
|
### Step 6 — Stop at the gate (do NOT advance)
|
|
182
199
|
Report: epic ID, the path to `epic.md`, and that the next action is **review** via
|
|
@@ -103,6 +103,10 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
|
|
|
103
103
|
- GitLab → `.gitlab/ci/yad-gate-sync.yml` (from `templates/gitlab/yad-gate-sync.gitlab-ci.yml`)
|
|
104
104
|
- plus the hub-side **verified-commits** gate (`checks/verified-commits.sh` + its workflow/fragment,
|
|
105
105
|
owned by `yad-checks`) so review PRs accept only signed commits from roster-known authors
|
|
106
|
+
- the wired job runs `yadflow@${YAD_VERSION}`, defaulting to the floating major `3` so a published
|
|
107
|
+
fix reaches the schedule on its own. To pin an exact version, set a `YAD_VERSION` CI/CD (GitLab) or
|
|
108
|
+
Actions (GitHub) **variable** — never edit it into the wired file, which `yad check --fix` rewrites
|
|
109
|
+
from the template. See `references/bridge.md`.
|
|
106
110
|
2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
|
|
107
111
|
- add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
|
|
108
112
|
`templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
|
|
@@ -95,9 +95,23 @@ login and requested too — otherwise an escalated step is structurally unsatisf
|
|
|
95
95
|
- Update the step's `hub-prs.json` `lastSyncedAt` when the sync **learned something** — every sync on
|
|
96
96
|
an open step, and on a closed one only when the approval record actually changed (a re-opened review
|
|
97
97
|
that was re-approved). An identical re-sync leaves it alone, so the ledger does not churn.
|
|
98
|
+
- **Write the ledgers in a canonical order.** `approvals.json`, `comments.json` and `hub-prs.json` are
|
|
99
|
+
sorted on write, so the bytes are a function of the record *set* and never of which step was synced
|
|
100
|
+
last. Without this the upsert above — which drops the records it refreshes and re-appends them at the
|
|
101
|
+
tail — makes the file depend on sync order, and the wired sweep (one `gate ci --branch <ref>
|
|
102
|
+
--merged` per merged PR/MR, every 15 minutes) walks a rotation:
|
|
103
|
+
`[A,B,C] → sync A → [B,C,A] → sync B → [C,A,B] → sync C → [A,B,C]`. Every hop is a non-empty diff, so
|
|
104
|
+
every hop commits and pushes, and the pass ends where it began — an unbounded commit loop with zero
|
|
105
|
+
semantic change. That is issue #163: ~1,800 bot commits/day on the hub that reported it. Sorting is
|
|
106
|
+
what makes the "nothing staged → nothing to commit" guard in `gate ci` actually hold.
|
|
98
107
|
- Running `sync` twice with no platform change is a no-op on the ledger — byte-identical, including
|
|
99
108
|
`comments.json` and the dated `reviews/*.md` side files.
|
|
100
109
|
|
|
110
|
+
**Upgrading past #163.** The first sweep on a yadflow carrying the fix writes one canonicalizing
|
|
111
|
+
reorder commit per epic (the records are the same; only their order changes), then converges
|
|
112
|
+
permanently. On an older yadflow the workaround is to disable the pipeline schedule — merges still
|
|
113
|
+
advance gates via the push path; only the catch-up for squash merges and bare approvals is lost.
|
|
114
|
+
|
|
101
115
|
## Contract re-lock invalidates prior platform approvals too
|
|
102
116
|
|
|
103
117
|
For the **architecture+contract** review, the gate already drops approvals when the contract-surface hash
|
|
@@ -165,6 +179,22 @@ other way — up through its first review PR/MR; see "the seed of a new epic" be
|
|
|
165
179
|
| PR/MR closed **and merged** (the human act) | merge | `gate ci --branch <head> --pr <n> --merged` → re-read approvals, advance the step + flip the artifact `status:` **on the default branch** |
|
|
166
180
|
| Schedule (`*/15`) | reconcile | Safety net: enumerate recently-**merged** `review/EP-*` PRs/MRs via the API and advance any not yet `done` (idempotent). Recovers a merge whose merge-time run failed transiently, and on GitLab also picks up a squash merge whose commit dropped the branch name (and a bare approval — GitLab fires no pipeline on one). **GitHub:** a scheduled workflow, automatic once committed. **GitLab:** a pipeline schedule with `SDLC_GATE_SYNC=true` (one-time setup) |
|
|
167
181
|
|
|
182
|
+
**Which yadflow the wired job runs.** Both fragments resolve the version from a `YAD_VERSION` variable
|
|
183
|
+
and fall back to `3`:
|
|
184
|
+
|
|
185
|
+
| Platform | Where the job reads it | Where you set the pin |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| GitHub | workflow `env: YAD_VERSION: ${{ vars.YAD_VERSION \|\| '3' }}` | Settings → Secrets and variables → Actions → **Variables** |
|
|
188
|
+
| GitLab | `npx -y -p "yadflow@${YAD_VERSION:-3}"` | Settings → CI/CD → **Variables** (beside `SDLC_GATE_TOKEN`) |
|
|
189
|
+
|
|
190
|
+
The default `3` floats on the major, so a published fix reaches a scheduled job on its next pass with
|
|
191
|
+
nobody in the loop — which is how you want a correctness or gate-churn fix to arrive (this page's own
|
|
192
|
+
issue #163 is the example). To adopt releases deliberately instead, set `YAD_VERSION` to an exact
|
|
193
|
+
version.
|
|
194
|
+
The pin lives in **platform config, not in the wired file**: `yad` owns that file and `yad check --fix`
|
|
195
|
+
rewrites it byte-for-byte from the template, so a version edited into it would be silently reverted on
|
|
196
|
+
the next sync. A hub that pins then owns its own upgrade decision — including for fixes.
|
|
197
|
+
|
|
168
198
|
**Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
|
|
169
199
|
never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
|
|
170
200
|
Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
|
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
# RECONCILE (schedule, every 15 min): the safety net. The merge job's `closed` event fires once and
|
|
18
18
|
# never repeats, so a transient API/GraphQL/push failure (or a fail-closed degraded approval read)
|
|
19
19
|
# would otherwise strand a merged review until someone reran it by hand. This periodic job discovers
|
|
20
|
-
# recently-merged review PRs from the API and advances any not yet done. Idempotent
|
|
21
|
-
# already `done` is never re-advanced (the chain is one-way), and re-syncing it
|
|
22
|
-
#
|
|
23
|
-
#
|
|
20
|
+
# recently-merged review PRs from the API and advances any not yet done. Idempotent DOWN TO THE
|
|
21
|
+
# BYTES: a step that is already `done` is never re-advanced (the chain is one-way), and re-syncing it
|
|
22
|
+
# rewrites the ledger in a canonical order, so an unchanged approval record produces an unchanged file
|
|
23
|
+
# and nothing is committed — re-visiting a merged review for a week costs one no-op read per pass. That
|
|
24
|
+
# last part is load-bearing: before the #163 fix the re-sync re-appended each step's approvals at the
|
|
25
|
+
# tail, so this job rotated approvals.json and committed the reorder every 15 minutes, forever (issue
|
|
26
|
+
# #163). If you pin YAD_VERSION (below) to something older, disable the schedule.
|
|
27
|
+
# On GitHub a scheduled workflow runs automatically once committed (no setup).
|
|
24
28
|
#
|
|
25
29
|
# CI never approves and never merges — the merge click is the human approval act.
|
|
26
30
|
#
|
|
@@ -40,6 +44,15 @@ permissions:
|
|
|
40
44
|
contents: write # push the merge-advance ledger commit to the default branch
|
|
41
45
|
pull-requests: read # gh pr view + reviewThreads GraphQL
|
|
42
46
|
|
|
47
|
+
env:
|
|
48
|
+
# Which yadflow these jobs run. The default `3` floats on the major, so a published fix reaches the
|
|
49
|
+
# schedule on its next pass with nobody in the loop — which is how you want a correctness or
|
|
50
|
+
# gate-churn fix to arrive. To adopt releases deliberately instead, pin an exact version by setting a
|
|
51
|
+
# repository (or organization) Actions **variable** named YAD_VERSION to e.g. `3.15.2`:
|
|
52
|
+
# Settings → Secrets and variables → Actions → Variables. It lives there, deliberately NOT in this
|
|
53
|
+
# file, which `yad` owns and rewrites on every sync. A pinned hub then owns its own upgrade decision.
|
|
54
|
+
YAD_VERSION: ${{ vars.YAD_VERSION || '3' }}
|
|
55
|
+
|
|
43
56
|
jobs:
|
|
44
57
|
mergesync:
|
|
45
58
|
# The human merge of a review branch: advance the step + flip the artifact status on the default
|
|
@@ -57,20 +70,27 @@ jobs:
|
|
|
57
70
|
GH_TOKEN: ${{ github.token }}
|
|
58
71
|
steps:
|
|
59
72
|
# Check out the default branch — the merge already landed the artifact here.
|
|
60
|
-
- uses: actions/checkout@
|
|
73
|
+
- uses: actions/checkout@v7
|
|
61
74
|
with:
|
|
62
75
|
ref: ${{ github.event.pull_request.base.ref }}
|
|
63
76
|
fetch-depth: 0
|
|
64
|
-
- uses: actions/setup-node@
|
|
77
|
+
- uses: actions/setup-node@v7
|
|
65
78
|
with:
|
|
66
79
|
node-version: "20"
|
|
67
80
|
- name: Advance the gate on merge
|
|
81
|
+
# The head ref reaches the shell through `env:`, never through `${{ }}` inside `run:`. A
|
|
82
|
+
# branch name may legally contain `$`, backticks and parentheses, so a PR opened from
|
|
83
|
+
# `review/EP-x$(curl …)` would otherwise execute on a runner holding `contents: write` — and
|
|
84
|
+
# the `if:` guard above does not stop it, since that name still starts with `review/EP-`.
|
|
85
|
+
env:
|
|
86
|
+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
|
87
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
68
88
|
run: |
|
|
69
89
|
git config user.name "yad-gate-sync[bot]"
|
|
70
90
|
git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
|
|
71
|
-
npx -y -p yadflow
|
|
72
|
-
--branch "$
|
|
73
|
-
--pr "$
|
|
91
|
+
npx -y -p "yadflow@${YAD_VERSION}" yad gate ci \
|
|
92
|
+
--branch "$HEAD_REF" \
|
|
93
|
+
--pr "$PR_NUMBER" \
|
|
74
94
|
--merged
|
|
75
95
|
|
|
76
96
|
reconcile:
|
|
@@ -85,11 +105,11 @@ jobs:
|
|
|
85
105
|
env:
|
|
86
106
|
GH_TOKEN: ${{ github.token }}
|
|
87
107
|
steps:
|
|
88
|
-
- uses: actions/checkout@
|
|
108
|
+
- uses: actions/checkout@v7
|
|
89
109
|
with:
|
|
90
110
|
ref: ${{ github.event.repository.default_branch }}
|
|
91
111
|
fetch-depth: 0
|
|
92
|
-
- uses: actions/setup-node@
|
|
112
|
+
- uses: actions/setup-node@v7
|
|
93
113
|
with:
|
|
94
114
|
node-version: "20"
|
|
95
115
|
- name: Reconcile recently-merged review PRs
|
|
@@ -110,7 +130,7 @@ jobs:
|
|
|
110
130
|
[ -n "$N" ] || continue
|
|
111
131
|
REF="$(gh pr view "$N" --json headRefName --jq '.headRefName' 2>/dev/null)" || rc=1
|
|
112
132
|
case "$REF" in
|
|
113
|
-
review/EP-*) npx -y -p yadflow
|
|
133
|
+
review/EP-*) npx -y -p "yadflow@${YAD_VERSION}" yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
|
|
114
134
|
esac
|
|
115
135
|
done < /tmp/yad-merged-prs
|
|
116
136
|
exit $rc
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
# SDLC_GATE_TOKEN. Without it the job fails visibly; recover by setting the token, then re-run the
|
|
36
36
|
# pipeline or run `yad gate ci --branch <review-branch> --pr <iid> --merged` locally on the default
|
|
37
37
|
# branch (advisory `yad gate sync` is read-only in bridge mode and cannot recover a stuck gate).
|
|
38
|
+
#
|
|
39
|
+
# Which yadflow this job runs: `npx -y -p "yadflow@${YAD_VERSION:-3}"`. The default `3` floats on the
|
|
40
|
+
# major, so a published fix reaches this job on its next run with nobody in the loop — which is how you
|
|
41
|
+
# want a correctness or gate-churn fix to arrive. To adopt releases deliberately instead, pin an exact
|
|
42
|
+
# version by setting a CI/CD variable YAD_VERSION (e.g. `3.15.2`) in project Settings → CI/CD →
|
|
43
|
+
# Variables — the same place SDLC_GATE_TOKEN lives, and deliberately NOT in this file, which `yad`
|
|
44
|
+
# owns and rewrites on every sync. A pinned hub then owns its own upgrade decision.
|
|
38
45
|
variables:
|
|
39
46
|
GIT_DEPTH: "0" # full history: gate ci pushes the advance to the default branch
|
|
40
47
|
|
|
@@ -79,8 +86,12 @@ yad-gate-sync:
|
|
|
79
86
|
rc=0
|
|
80
87
|
if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
|
|
81
88
|
# SCHEDULED SWEEP — discover merged review MRs from the platform (Path B keeps no per-branch
|
|
82
|
-
# ledger), then advance each. `gate ci` is idempotent: an already-`done` step
|
|
83
|
-
# re-advanced, and re-syncing it
|
|
89
|
+
# ledger), then advance each. `gate ci` is idempotent DOWN TO THE BYTES: an already-`done` step
|
|
90
|
+
# is never re-advanced, and re-syncing it rewrites the ledger in a canonical order, so an
|
|
91
|
+
# unchanged approval record produces an unchanged file and nothing is committed. That last part
|
|
92
|
+
# is load-bearing — before the #163 fix the re-sync re-appended each step's approvals at the
|
|
93
|
+
# tail, so this loop rotated approvals.json and committed the reorder on every pass, forever
|
|
94
|
+
# (issue #163). If you are on an older yadflow, disable this schedule.
|
|
84
95
|
# A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
|
|
85
96
|
# push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
|
|
86
97
|
# — this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
|
|
@@ -94,7 +105,7 @@ yad-gate-sync:
|
|
|
94
105
|
while read -r IID REF; do
|
|
95
106
|
[ -n "$IID" ] || continue
|
|
96
107
|
git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
|
|
97
|
-
npx -y -p yadflow
|
|
108
|
+
npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
|
|
98
109
|
done < /tmp/yad-merged-mrs
|
|
99
110
|
else
|
|
100
111
|
# MERGE push to the default branch whose commit names a review branch: resolve the merged MR's
|
|
@@ -106,7 +117,7 @@ yad-gate-sync:
|
|
|
106
117
|
IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" 2>/dev/null | jq -r '.[0].iid // empty' || true)"
|
|
107
118
|
if [ -n "$IID" ]; then
|
|
108
119
|
# Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
|
|
109
|
-
npx -y -p yadflow
|
|
120
|
+
npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
|
|
110
121
|
else
|
|
111
122
|
# Without the IID, gate ci cannot re-read approvals — fail visibly (the scheduled sweep
|
|
112
123
|
# retries) rather than running a green no-op that silently leaves the gate unadvanced.
|
|
@@ -54,16 +54,28 @@ touched `repos` — never a forked or copied gate.
|
|
|
54
54
|
|
|
55
55
|
### Step 2 — Dispatch on `action`
|
|
56
56
|
|
|
57
|
+
> **Check the mode first — in bridge mode you write nothing to the ledger.** Read `.sdlc/hub.json`:
|
|
58
|
+
> **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`. Under the bridge
|
|
59
|
+
> the ledger is CI-owned — `ledger-guard` rejects any non-bot commit touching
|
|
60
|
+
> `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`, local `yad gate
|
|
61
|
+
> sync` is advisory, and `yad gate ci --merged` writes the whole transition when the review PR merges.
|
|
62
|
+
> So every "set / append / write" instruction below is the **file-only, or a platform with no
|
|
63
|
+
> gate-sync CI** path. In bridge mode do the human-facing half — present the artifact, route the
|
|
64
|
+
> required reviewers, help the owner address comments — and let the platform PR/MR carry the review
|
|
65
|
+
> state; the approvals, comments, review records and the advance all land through CI at merge.
|
|
66
|
+
|
|
57
67
|
**`open`** — Present the artifact for review. Summarise what changed, list the required reviewers per
|
|
58
68
|
the rule above, and tell reviewers how to comment/approve. Set the step `status` to `in_review` and
|
|
59
69
|
`currentStep` to this step in `state.json` if not already. Do not advance.
|
|
60
70
|
|
|
61
|
-
If `.sdlc/hub.json` has a non-null `platform
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
(
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
If `.sdlc/hub.json` has a non-null `platform` and `bridge_enabled: true` (or legacy `bridge: true` —
|
|
72
|
+
`.sdlc/hub.json` is the only source the CLI reads, see `isBridge` in `cli/gate.mjs`), and `gh`/`glab`
|
|
73
|
+
is authenticated, **also open a review PR/MR on the hub** by invoking `yad-hub-bridge action: open`
|
|
74
|
+
(epic + artifact), and report the URL + required reviewers. **CI records the PR** in
|
|
75
|
+
`epics/<epic>/.sdlc/hub-prs.json` (`{step, artifact, platform, number, url, branch, lastSyncedAt}`) —
|
|
76
|
+
write that file yourself only on the file-only path. Otherwise (no platform / disabled / no CLI)
|
|
77
|
+
proceed **file-only** exactly as before — no error. Opening the PR records no approvals and never
|
|
78
|
+
advances.
|
|
67
79
|
|
|
68
80
|
**`comment`** — Capture reviewer feedback. Append/create a review file
|
|
69
81
|
`reviews/<artifact-base>--<YYYY-MM-DD>--comments.md` with a heading per reviewer:
|
|
@@ -101,12 +101,26 @@ As a <role>, I want <capability>, so that <outcome>.
|
|
|
101
101
|
`repos` is the field the later build phase reads to know where to scaffold specs — set it precisely.
|
|
102
102
|
|
|
103
103
|
### Step 6 — Advance the authoring step (NOT the gate)
|
|
104
|
-
|
|
104
|
+
**Check the mode first — the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
|
|
105
|
+
**bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
|
|
106
|
+
|
|
107
|
+
**Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
|
|
108
|
+
any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
109
|
+
`epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
|
|
110
|
+
`yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
|
|
111
|
+
fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
|
|
112
|
+
is pushed around the gate. Commit **the story files under `stories/` only** — nothing else
|
|
113
|
+
under `.sdlc/` — then hand off to `yad-review-gate`.
|
|
114
|
+
|
|
115
|
+
**Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
|
|
116
|
+
`stories.status: "done"`, set `stories-review.status: "in_review"`, and set
|
|
105
117
|
`currentStep: "stories-review"`. Write `state.json`. Do **not** touch `approvals.json`.
|
|
106
118
|
|
|
107
|
-
> Since 3.11 the CLI also closes the authoring step when its review gate
|
|
108
|
-
> edit is a no-op once `yad gate open` has run. A `stories` step left
|
|
109
|
-
> `stories-review` used to block the parallel `test-cases` track
|
|
119
|
+
> **File-only branch only.** Since 3.11 the CLI also closes the authoring step when its review gate
|
|
120
|
+
> opens or advances, so this edit is a no-op once `yad gate open` has run. A `stories` step left
|
|
121
|
+
> `in_progress` behind a passed `stories-review` used to block the parallel `test-cases` track
|
|
122
|
+
> (`YAD-STATE-005`). In bridge mode `gate open` writes nothing and local `gate sync` is advisory —
|
|
123
|
+
> `gate ci` closes the step at merge.
|
|
110
124
|
|
|
111
125
|
### Step 7 — Stop at the gate (do NOT advance)
|
|
112
126
|
Report: the story IDs created, the repos each touches, and that the next action is **review** via
|
|
@@ -162,9 +162,24 @@ Keep the `## Automation (<tool>)` section of `test-cases.md` in step with this f
|
|
|
162
162
|
degraded (`testing: none`), do **not** write `test-links.json`.
|
|
163
163
|
|
|
164
164
|
### Step 5 — Advance the authoring step (NOT the gate)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
**Check the mode first — the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
|
|
166
|
+
**bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
|
|
167
|
+
|
|
168
|
+
**Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
|
|
169
|
+
any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
170
|
+
`epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
|
|
171
|
+
`yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
|
|
172
|
+
fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
|
|
173
|
+
is pushed around the gate. Commit the artifact set — **`test-cases.md` and, when a testing
|
|
174
|
+
tool was used, `.sdlc/test-links.json`** (artifact-side, not ledger; generated tests live in their
|
|
175
|
+
own code repo, not here) — then hand off to `yad-review-gate`.
|
|
176
|
+
|
|
177
|
+
**Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
|
|
178
|
+
`test-cases.status: "done"` and set `test-cases-review.status: "in_review"`. **Leave `currentStep` at
|
|
179
|
+
`ready-for-build`** — this is the parallel track; moving `currentStep` would pull it back from the
|
|
180
|
+
build half. Write `state.json`. Do **not** touch `approvals.json`. On this branch `yad gate open`
|
|
181
|
+
makes the same edit — `markInReview` leaves `currentStep` alone once it is `ready-for-build`
|
|
182
|
+
(`cli/epic-state.mjs`) — so it is a no-op once the gate has run.
|
|
168
183
|
|
|
169
184
|
### Step 6 — Stop at the gate (do NOT advance)
|
|
170
185
|
Report: the path to `test-cases.md`, the connected testing tool and what it produced (e.g. "Playwright —
|
package/skills/yad-ui/SKILL.md
CHANGED
|
@@ -171,8 +171,22 @@ Keep the `## Design (<tool>)` section of `ui-design.md` in step with this file.
|
|
|
171
171
|
(`design: none`), do **not** write `design-links.json`.
|
|
172
172
|
|
|
173
173
|
### Step 5 — Advance the authoring step (NOT the gate)
|
|
174
|
-
|
|
175
|
-
`
|
|
174
|
+
**Check the mode first — the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
|
|
175
|
+
**bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
|
|
176
|
+
|
|
177
|
+
**Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
|
|
178
|
+
any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
179
|
+
`epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
|
|
180
|
+
`yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
|
|
181
|
+
fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
|
|
182
|
+
is pushed around the gate. Commit the artifact set — **`ui-design.md`, `DESIGN.md`, and, when a
|
|
183
|
+
design tool was used, `.sdlc/design-links.json`** (artifact-side, not ledger) — then hand off to
|
|
184
|
+
`yad-review-gate`.
|
|
185
|
+
|
|
186
|
+
**Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
|
|
187
|
+
`ui-design.status: "done"`, set `ui-design-review.status: "in_review"`, and set
|
|
188
|
+
`currentStep: "ui-design-review"`. Write `state.json`. Do **not** touch `approvals.json`. On this
|
|
189
|
+
branch `yad gate open` makes the same edit, so it is a no-op once the gate has run.
|
|
176
190
|
|
|
177
191
|
### Step 6 — Stop at the gate (do NOT advance)
|
|
178
192
|
Report: the paths to `ui-design.md` and `DESIGN.md`, whether Impeccable was used, the connected design
|