yadflow 1.0.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +559 -0
  4. package/bin/sdlc.mjs +135 -0
  5. package/cli/commit.mjs +81 -0
  6. package/cli/epic-state.mjs +220 -0
  7. package/cli/gate.mjs +456 -0
  8. package/cli/lib.mjs +142 -0
  9. package/cli/manifest.mjs +119 -0
  10. package/cli/openpr.mjs +65 -0
  11. package/cli/plan.mjs +127 -0
  12. package/cli/platform.mjs +151 -0
  13. package/cli/reconcile.mjs +83 -0
  14. package/cli/repo.mjs +61 -0
  15. package/cli/setup.mjs +208 -0
  16. package/package.json +51 -0
  17. package/skills/sdlc/config.yaml +156 -0
  18. package/skills/sdlc/install.sh +51 -0
  19. package/skills/sdlc/module-help.csv +17 -0
  20. package/skills/sdlc-author-analysis/SKILL.md +136 -0
  21. package/skills/sdlc-author-architecture/SKILL.md +180 -0
  22. package/skills/sdlc-author-architecture/references/contract-format.md +72 -0
  23. package/skills/sdlc-author-epic/SKILL.md +154 -0
  24. package/skills/sdlc-author-epic/references/state-schema.md +187 -0
  25. package/skills/sdlc-author-stories/SKILL.md +109 -0
  26. package/skills/sdlc-author-stories/references/story-schema.md +46 -0
  27. package/skills/sdlc-author-ui/SKILL.md +113 -0
  28. package/skills/sdlc-backfill/SKILL.md +91 -0
  29. package/skills/sdlc-backfill/references/backfill.md +66 -0
  30. package/skills/sdlc-backfill/templates/checks/backfill-check.sh +42 -0
  31. package/skills/sdlc-checks/SKILL.md +138 -0
  32. package/skills/sdlc-checks/references/check-gates.md +168 -0
  33. package/skills/sdlc-checks/templates/checks/build-test-lint.sh +14 -0
  34. package/skills/sdlc-checks/templates/checks/contract-check.sh +62 -0
  35. package/skills/sdlc-checks/templates/checks/spec-link.sh +38 -0
  36. package/skills/sdlc-checks/templates/checks/verified-commits.sh +120 -0
  37. package/skills/sdlc-checks/templates/github/sdlc-checks.yml +45 -0
  38. package/skills/sdlc-checks/templates/github/sdlc-verified-commits.yml +22 -0
  39. package/skills/sdlc-checks/templates/gitlab/.gitlab-ci.yml +40 -0
  40. package/skills/sdlc-checks/templates/gitlab/gitlab-ci.include-root.yml +7 -0
  41. package/skills/sdlc-checks/templates/gitlab/sdlc-checks.gitlab-ci.yml +47 -0
  42. package/skills/sdlc-checks/templates/gitlab/sdlc-verified-commits.gitlab-ci.yml +21 -0
  43. package/skills/sdlc-connect-repos/SKILL.md +159 -0
  44. package/skills/sdlc-connect-repos/references/code-context.md +92 -0
  45. package/skills/sdlc-connect-repos/references/hub-config.md +77 -0
  46. package/skills/sdlc-connect-repos/references/repos-registry.md +62 -0
  47. package/skills/sdlc-hub-bridge/SKILL.md +119 -0
  48. package/skills/sdlc-hub-bridge/references/bridge.md +136 -0
  49. package/skills/sdlc-hub-bridge/references/login-roster.md +42 -0
  50. package/skills/sdlc-hub-bridge/templates/checks/hub-route.sh +50 -0
  51. package/skills/sdlc-hub-bridge/templates/github/sdlc-gate-sync.yml +63 -0
  52. package/skills/sdlc-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +7 -0
  53. package/skills/sdlc-hub-bridge/templates/gitlab/sdlc-gate-sync.gitlab-ci.yml +64 -0
  54. package/skills/sdlc-implement/SKILL.md +143 -0
  55. package/skills/sdlc-implement/references/implement-conventions.md +103 -0
  56. package/skills/sdlc-implement/templates/.gitmessage +17 -0
  57. package/skills/sdlc-pr-template/SKILL.md +86 -0
  58. package/skills/sdlc-pr-template/references/risk-routing.md +54 -0
  59. package/skills/sdlc-pr-template/templates/checks/risk-route.sh +44 -0
  60. package/skills/sdlc-pr-template/templates/github/pull_request_template.md +30 -0
  61. package/skills/sdlc-pr-template/templates/gitlab/merge_request_templates/Default.md +32 -0
  62. package/skills/sdlc-pr-template/templates/hub/github/pull_request_template.md +36 -0
  63. package/skills/sdlc-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +37 -0
  64. package/skills/sdlc-review-comments/SKILL.md +63 -0
  65. package/skills/sdlc-review-comments/references/comment-conventions.md +55 -0
  66. package/skills/sdlc-review-comments/templates/github/REVIEW_COMMENTS.md +49 -0
  67. package/skills/sdlc-review-comments/templates/gitlab/REVIEW_COMMENTS.md +49 -0
  68. package/skills/sdlc-review-gate/SKILL.md +196 -0
  69. package/skills/sdlc-review-gate/references/gating.md +79 -0
  70. package/skills/sdlc-run/SKILL.md +109 -0
  71. package/skills/sdlc-run/references/run-loop.md +121 -0
  72. package/skills/sdlc-ship/SKILL.md +86 -0
  73. package/skills/sdlc-ship/references/ship-and-record.md +67 -0
  74. package/skills/sdlc-ship/templates/.coderabbit.yaml +19 -0
  75. package/skills/sdlc-spec/SKILL.md +119 -0
  76. package/skills/sdlc-spec/references/spec-handoff.md +101 -0
  77. package/skills/sdlc-status/SKILL.md +92 -0
package/cli/gate.mjs ADDED
@@ -0,0 +1,456 @@
1
+ // `sdlc gate open|sync|comments|status` — the PR/MR-driven front-half review gate.
2
+ // The platform PR/MR is the review UI; this command syncs its state into the file ledger and, when
3
+ // the gate passes (approvals satisfied + all comment threads resolved + PR merged), auto-advances the
4
+ // step. The merge click is the human approval act, so front steps still never machine_advance.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ c, log, ok, info, warn, hand, fail, readJSONStrict, writeJSON, run,
9
+ } from './lib.mjs';
10
+ import { PROJECT_FILES } from './manifest.mjs';
11
+ import {
12
+ epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
13
+ advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
+ artifactPaths, upsertHubPr,
15
+ } from './epic-state.mjs';
16
+ import { readPr, mapApprovers, createPr } from './platform.mjs';
17
+
18
+ // ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
19
+ function frontmatter(file) {
20
+ if (!fs.existsSync(file)) return {};
21
+ const m = fs.readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---/);
22
+ if (!m) return {};
23
+ const out = {};
24
+ for (const line of m[1].split('\n')) {
25
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
26
+ if (!kv) continue;
27
+ const [, k, v] = kv;
28
+ out[k] = /^\[.*\]$/.test(v) ? v.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean) : v.trim();
29
+ }
30
+ return out;
31
+ }
32
+
33
+ // Touched domains, resolved from files (gating.md): architecture => epic.repos; stories => union of
34
+ // every story's repos; otherwise none.
35
+ export function touchedDomains(epicDir, step) {
36
+ if (!isEscalated(step)) return [];
37
+ if (step.id === 'stories-review') {
38
+ const dir = path.join(epicDir, 'stories');
39
+ if (!fs.existsSync(dir)) return [];
40
+ const set = new Set();
41
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
42
+ for (const r of (frontmatter(path.join(dir, f)).repos || [])) set.add(r);
43
+ }
44
+ return [...set];
45
+ }
46
+ return frontmatter(path.join(epicDir, 'epic.md')).repos || [];
47
+ }
48
+
49
+ const ownerOf = (epicDir) => frontmatter(path.join(epicDir, 'epic.md')).owner || '<owner>';
50
+
51
+ // A null architecture hash with a BEGIN marker present means the surface block is malformed
52
+ // (no END, or empty) — approvals would not be hash-bound, so make that visible.
53
+ function warnUnlockedContract(epicDir, artifact) {
54
+ if (artifactBase(artifact) !== 'architecture') return;
55
+ if (artifactHash(epicDir, artifact) !== null) return;
56
+ const f = path.join(epicDir, 'contract.md');
57
+ if (fs.existsSync(f) && /CONTRACT-SURFACE:BEGIN/.test(fs.readFileSync(f, 'utf8'))) {
58
+ warn('contract.md has CONTRACT-SURFACE:BEGIN without a matching END (or an empty block) — surface not locked, approvals will not be hash-bound');
59
+ }
60
+ }
61
+
62
+ // Fail fast on a corrupt or wrong-shape hub config: a silently-defaulted hub.json would degrade
63
+ // every gate to file-only without anyone noticing, and a typo'd platform would read as "no bridge".
64
+ function loadHub(root) {
65
+ const hubFile = path.join(root, PROJECT_FILES.hubConfig);
66
+ const regFile = path.join(root, PROJECT_FILES.reposRegistry);
67
+ const hub = readJSONStrict(hubFile, null);
68
+ if (hub !== null) {
69
+ if (typeof hub !== 'object' || Array.isArray(hub)) throw new Error(`${hubFile}: expected a JSON object`);
70
+ if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) {
71
+ throw new Error(`${hubFile}: unknown platform '${hub.platform}' — expected github, gitlab, or null`);
72
+ }
73
+ if (hub.roster !== undefined && !Array.isArray(hub.roster)) {
74
+ throw new Error(`${hubFile}: expected \`roster\` to be an array`);
75
+ }
76
+ }
77
+ const registry = readJSONStrict(regFile, { repos: [] });
78
+ if (!Array.isArray(registry?.repos)) throw new Error(`${regFile}: expected a \`repos\` array`);
79
+ return { hub, repos: registry.repos };
80
+ }
81
+
82
+ // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
83
+ // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
84
+ // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
85
+ // makes "revoke only when the artifact changed" work.
86
+ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today }) {
87
+ const keyOf = (name, role, domain) => `${stepId}|${name}|${role}|${domain || ''}`;
88
+ const prior = new Map(
89
+ approvals.filter((a) => a.step === stepId && a.source === 'bridge')
90
+ .map((a) => [keyOf(a.approver, a.role, a.domain), a]),
91
+ );
92
+ const kept = approvals.filter((a) => !(a.step === stepId && a.source === 'bridge'));
93
+ for (const r of recs) {
94
+ const was = prior.get(keyOf(r.name, r.role, r.domain));
95
+ let artHash = curHash; // first time we see this approval => bind to current content
96
+ let approvedAt = r.submittedAt || today;
97
+ if (was) {
98
+ // We only adopt the new hash when the platform PROVES a genuinely newer review (a later
99
+ // submittedAt). Otherwise — same review, or a platform that gives no timestamp (GitLab) — we
100
+ // KEEP the hash they originally approved, so a later artifact change still revokes the approval.
101
+ const genuinelyNewer = r.submittedAt && was.approvedAt && r.submittedAt > was.approvedAt;
102
+ if (!genuinelyNewer) {
103
+ artHash = was.artifactHash ?? curHash;
104
+ approvedAt = was.approvedAt ?? approvedAt;
105
+ }
106
+ }
107
+ kept.push({
108
+ artifact, step: stepId, approver: r.name, role: r.role,
109
+ ...(r.domain ? { domain: r.domain } : {}),
110
+ status: 'approved', date: today, source: 'bridge',
111
+ artifactHash: artHash, approvedAt,
112
+ ...(r.unverified ? { unverified: true } : {}),
113
+ });
114
+ }
115
+ return kept;
116
+ }
117
+
118
+ function writeComments(epicDir, base, today, blocking) {
119
+ if (!blocking.length) return;
120
+ const file = path.join(epicDir, 'reviews', `${base}--${today}--comments.md`);
121
+ fs.mkdirSync(path.dirname(file), { recursive: true });
122
+ const lines = [`# Review comments — ${base} — ${today}`, ''];
123
+ for (const t of blocking) {
124
+ lines.push(`## ${t.login || 'reviewer'} ${t.changesRequested ? '(changes requested — **blocking**)' : '(unresolved)'}`);
125
+ lines.push(`- ${(t.body || '').split('\n')[0] || '(no text)'}`);
126
+ lines.push('');
127
+ }
128
+ fs.writeFileSync(file, lines.join('\n') + '\n');
129
+ }
130
+
131
+ // Upsert machine-readable participation records into the comments ledger (the counterpart to the
132
+ // markdown side file) so the ledger — not just reviews/*.md — reflects platform thread state. One
133
+ // record per (step, commenter, round); `round` is the count of prior synced rounds for the step.
134
+ function recordComments(comments, { artifact, stepId, today, roster, repos, blocking }) {
135
+ if (!blocking.length) return comments;
136
+ const byName = (login) => (roster.find((r) => r.login === login)?.name) || login || 'reviewer';
137
+ const roleOf = (login) => (roster.find((r) => r.login === login)?.role) || 'reviewer';
138
+ const round = (comments.filter((cm) => cm.step === stepId).reduce((m, cm) => Math.max(m, cm.round || 0), 0)) + 1;
139
+ const counts = new Map();
140
+ for (const t of blocking) counts.set(t.login, (counts.get(t.login) || 0) + 1);
141
+ const kept = comments.filter((cm) => !(cm.step === stepId && cm.round === round));
142
+ for (const [login, count] of counts) {
143
+ kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: today });
144
+ }
145
+ return kept;
146
+ }
147
+
148
+ // ---- actions ------------------------------------------------------------------------------------
149
+
150
+ export async function gateSync(root, { epic, artifact, today, reader = readPr } = {}) {
151
+ const { hub, repos } = loadHub(root);
152
+ if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
153
+ const platform = hub.platform;
154
+ const roster = hub.roster || [];
155
+ const defaultReviewers = 1;
156
+ const epicDir = epicRoot(root, epic);
157
+ const ledger = loadLedger(epicDir);
158
+ if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
159
+
160
+ let { approvals, comments, hubPrs, state } = ledger;
161
+ const targets = hubPrs.filter((p) => !artifact || p.artifact === artifact);
162
+ if (!targets.length) { warn(`no open review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''} (run \`sdlc gate open\` first)`); return { synced: 0 }; }
163
+
164
+ let synced = 0;
165
+ for (const pr of targets) {
166
+ const step = findReviewStep(state, pr.artifact);
167
+ if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
168
+ // Already advanced: a re-sync must not re-run advance (it would reset the next step's status /
169
+ // currentStep backward). The gate is one-way per step.
170
+ if (step.status === 'done') { info(`${pr.artifact}: ${step.id} already done — skipping`); continue; }
171
+ const domains = touchedDomains(epicDir, step);
172
+ const pull = reader(platform, pr.number, { cwd: root });
173
+ if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason} — skipping (file-only)`); continue; }
174
+
175
+ const curHash = artifactHash(epicDir, pr.artifact);
176
+ warnUnlockedContract(epicDir, pr.artifact);
177
+ const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains });
178
+ approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
179
+
180
+ const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
181
+ const unresolved = (pull.threads || []).filter((t) => !t.resolved);
182
+ const threadsResolved = unresolved.length === 0 && changeRequested.length === 0;
183
+ const blocking = [
184
+ ...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
185
+ ...unresolved,
186
+ ];
187
+ writeComments(epicDir, base(pr.artifact), today, blocking);
188
+ comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
189
+
190
+ const pred = gatePredicate({
191
+ step, approvals, currentHash: curHash, touchedDomains: domains,
192
+ defaultReviewers, threadsResolved, merged: pull.merged,
193
+ });
194
+
195
+ log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
196
+ if (pred.passed) {
197
+ state = advanceState(state, step);
198
+ ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
199
+ } else {
200
+ state = markInReview(state, step);
201
+ for (const m of pred.missing) hand(`still needed: ${m}`);
202
+ }
203
+ pr.lastSyncedAt = today;
204
+ synced++;
205
+ }
206
+
207
+ writeJSON(ledger.files.approvals, approvals);
208
+ writeJSON(ledger.files.comments, comments);
209
+ writeJSON(ledger.files.hubPrs, hubPrs);
210
+ writeJSON(ledger.files.state, state);
211
+ refreshRoster(epicDir, targets, approvals, today);
212
+ return { synced };
213
+ }
214
+
215
+ // `sdlc gate ci` — the self-sufficient entry point hub CI calls on platform events (review
216
+ // submitted/dismissed, PR synchronize, PR merged) and on the GitLab schedule. Event mode derives
217
+ // epic/artifact from the `review/EP-<slug>/<base>` head branch (so it works even when the author
218
+ // never committed hub-prs.json); sweep mode (no --branch) re-syncs every open review PR. Either way
219
+ // it runs the unchanged gateSync, then commits ONLY the ledger files to the hub default branch —
220
+ // the artifact itself lands on main via the human merge, never via CI.
221
+ export async function gateCi(root, { branch, pr, today, push = true, reader = readPr } = {}) {
222
+ const { hub } = loadHub(root);
223
+ if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0 }; }
224
+ const git = (...args) => run('git', args, { cwd: root });
225
+ // Push target: an explicit hub.default_branch wins; else the branch CI actually checked out (the
226
+ // workflow checks out the PR base / $CI_DEFAULT_BRANCH — hub.json from `sdlc setup` has no
227
+ // default_branch field, so the checkout is the truth); 'main' only as the last resort.
228
+ const head = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
229
+ const target = hub.default_branch || (head && head !== 'HEAD' ? head : 'main');
230
+
231
+ // Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
232
+ const jobs = [];
233
+ if (branch) {
234
+ const parsed = parseReviewBranch(branch);
235
+ if (!parsed) { warn(`${branch} is not a review/EP-*/<artifact> branch — nothing to sync`); return { synced: 0 }; }
236
+ jobs.push({ epic: parsed.epic, base: parsed.base, artifact: artifactFromBase(parsed.base), branch, pr });
237
+ } else {
238
+ const epicsDir = path.join(root, 'epics');
239
+ for (const e of fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).sort() : []) {
240
+ // Sweep mode isolates per-epic failures: one corrupt ledger must not block the other epics'
241
+ // syncs in an unattended CI run. The run still exits non-zero so the bad file gets fixed.
242
+ let ledger;
243
+ try {
244
+ ledger = loadLedger(epicRoot(root, e));
245
+ } catch (err) {
246
+ warn(`${e}: ${err.message} — skipping this epic`);
247
+ process.exitCode = 1;
248
+ continue;
249
+ }
250
+ if (!ledger.state) continue;
251
+ for (const p of ledger.hubPrs || []) {
252
+ const step = findReviewStep(ledger.state, p.artifact);
253
+ if (!step || step.status === 'done') continue;
254
+ jobs.push({ epic: e, base: base(p.artifact), artifact: p.artifact, branch: p.branch, pr: p.number });
255
+ }
256
+ }
257
+ if (!jobs.length) { info('no open review PRs to sync'); return { synced: 0 }; }
258
+ }
259
+
260
+ let synced = 0;
261
+ const touched = new Set();
262
+ for (const job of jobs) {
263
+ const epicDir = epicRoot(root, job.epic);
264
+ // Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
265
+ let ledger;
266
+ try {
267
+ ledger = loadLedger(epicDir);
268
+ } catch (err) {
269
+ if (branch) throw err;
270
+ warn(`${job.epic}: ${err.message} — skipping this epic`);
271
+ process.exitCode = 1;
272
+ continue;
273
+ }
274
+ if (!ledger.state) {
275
+ warn(`${job.epic}: no epic state on ${target} — commit epics/${job.epic}/.sdlc to ${target} first`);
276
+ continue;
277
+ }
278
+ const step = findReviewStep(ledger.state, job.artifact);
279
+ if (!step) { warn(`${job.epic}: no review step for ${job.artifact} — skipping`); continue; }
280
+
281
+ // The event may fire before the author ever committed hub-prs.json — build the entry from the
282
+ // event itself, so the first CI commit lands it on the default branch and the views converge.
283
+ const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
284
+ const number = Number(job.pr) || existing?.number || null;
285
+ if (!existing || existing.number !== number || existing.branch !== job.branch) {
286
+ ledger.hubPrs = upsertHubPr(ledger.hubPrs, {
287
+ step: step.id, artifact: job.artifact, platform: hub.platform, number,
288
+ url: existing?.url ?? null, branch: job.branch, lastSyncedAt: existing?.lastSyncedAt ?? null,
289
+ });
290
+ writeJSON(ledger.files.hubPrs, ledger.hubPrs);
291
+ }
292
+
293
+ // Overlay the artifact from the review branch so artifactHash binds approvals to what the
294
+ // reviewers actually approved (pre-merge, the default branch does not have it yet). A failed
295
+ // fetch (branch deleted after merge) is fine — the artifact already landed via the merge.
296
+ const overlay = artifactPaths(job.base).map((p) => path.join('epics', job.epic, p));
297
+ const fetched = job.branch ? git('fetch', 'origin', job.branch).ok : false;
298
+ if (fetched) for (const p of overlay) git('checkout', 'FETCH_HEAD', '--', p);
299
+
300
+ let failed = false;
301
+ try {
302
+ const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader });
303
+ synced += r.synced;
304
+ } catch (err) {
305
+ if (branch) throw err; // event mode: one epic — surface the failure
306
+ warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
307
+ process.exitCode = 1;
308
+ failed = true;
309
+ } finally {
310
+ // Drop the overlay — even when sync throws: only the ledger may reach the default branch via CI.
311
+ if (fetched) {
312
+ for (const p of overlay) {
313
+ git('checkout', 'HEAD', '--', p); // tracked files back to HEAD (no-op fail if not in HEAD)
314
+ git('clean', '-fd', '--', p); // files new on the branch: remove
315
+ }
316
+ }
317
+ }
318
+ if (failed) continue; // a failed epic's partial state must not be committed by this run
319
+ touched.add(job.epic);
320
+ }
321
+ if (!touched.size) return { synced };
322
+
323
+ // Commit the ledger only, then push with a rebase-retry (ledger commits across epics touch
324
+ // disjoint files; same-repo runs are serialized by the CI concurrency group).
325
+ for (const e of touched) {
326
+ // Separate adds: a pathspec with no match (reviews/ not created yet) must not abort the other.
327
+ git('add', '-A', '--', path.join('epics', e, '.sdlc'));
328
+ git('add', '-A', '--', path.join('epics', e, 'reviews'));
329
+ }
330
+ if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
331
+ const subject = branch
332
+ ? `chore(gate): sync ${jobs[0].epic}/${jobs[0].base} via CI [skip ci]`
333
+ : 'chore(gate): scheduled gate sync [skip ci]';
334
+ const cm = git('commit', '-m', subject);
335
+ if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced }; }
336
+ ok(`committed ledger update: ${c.dim(subject)}`);
337
+ if (!push) return { synced };
338
+
339
+ for (let attempt = 1; attempt <= 3; attempt++) {
340
+ if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed ledger to origin/${target}`); return { synced }; }
341
+ if (attempt < 3) {
342
+ info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
343
+ if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
344
+ }
345
+ }
346
+ fail(`could not push the ledger to origin/${target} — protected branch? allow the CI actor to push (see sdlc-hub-bridge references/bridge.md) or run \`sdlc gate sync\` locally`);
347
+ process.exitCode = 1;
348
+ return { synced };
349
+ }
350
+
351
+ export async function gateComments(root, { epic, artifact, today, reader = readPr } = {}) {
352
+ const { hub } = loadHub(root);
353
+ if (!hub?.platform) { warn('no hub platform configured — nothing to fetch'); return; }
354
+ const epicDir = epicRoot(root, epic);
355
+ const ledger = loadLedger(epicDir);
356
+ const targets = (ledger.hubPrs || []).filter((p) => !artifact || p.artifact === artifact);
357
+ if (!targets.length) { warn('no review PR recorded — run `sdlc gate open` first'); return; }
358
+ for (const pr of targets) {
359
+ const pull = reader(hub.platform, pr.number, { cwd: root });
360
+ if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); continue; }
361
+ const cr = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
362
+ const unresolved = (pull.threads || []).filter((t) => !t.resolved);
363
+ log(`\n ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number})`)}`);
364
+ if (!cr.length && !unresolved.length) { ok('no unresolved comments — clear to approve/merge'); continue; }
365
+ for (const r of cr) hand(`${r.login}: changes requested ${c.red('(blocking)')}`);
366
+ for (const t of unresolved) info(`${t.login || 'reviewer'}: ${(t.body || '').split('\n')[0]}`);
367
+ writeComments(epicDir, base(pr.artifact), today, [
368
+ ...cr.map((r) => ({ login: r.login, changesRequested: true })),
369
+ ...unresolved,
370
+ ]);
371
+ hand('address them in the artifact, reply on the PR, then ask reviewers to resolve their threads');
372
+ }
373
+ }
374
+
375
+ export async function gateStatus(root, { epic } = {}) {
376
+ const epicDir = epicRoot(root, epic);
377
+ const ledger = loadLedger(epicDir);
378
+ if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
379
+ log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}`)}`);
380
+ for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
381
+ const cur = artifactHash(epicDir, s.artifact);
382
+ const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !(a.artifactHash && cur && a.artifactHash !== cur));
383
+ const stale = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && a.artifactHash && cur && a.artifactHash !== cur).length;
384
+ const tags = `${isEscalated(s) ? ', escalated' : ''}${stale ? `, ${stale} stale (revoked)` : ''}`;
385
+ log(` ${s.status === 'done' ? c.green('✓') : c.yellow('•')} ${s.id} ${c.dim(`— ${s.status}, ${live.length} approval(s)${tags}`)}`);
386
+ }
387
+ }
388
+
389
+ export async function gateOpen(root, { epic, artifact, today } = {}) {
390
+ const { hub, repos } = loadHub(root);
391
+ const epicDir = epicRoot(root, epic);
392
+ const ledger = loadLedger(epicDir);
393
+ if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
394
+ if (!artifact) { fail('artifact is required: `sdlc gate open <epic> <artifact>`'); process.exitCode = 1; return; }
395
+ const step = findReviewStep(ledger.state, artifact);
396
+ if (!step) { fail(`no review step for ${artifact}`); process.exitCode = 1; return; }
397
+ const b = base(artifact);
398
+ const branch = `review/${epic}/${b}`;
399
+ const domains = touchedDomains(epicDir, step);
400
+ warnUnlockedContract(epicDir, artifact);
401
+
402
+ // Mark in-review in the ledger regardless of platform (file-only still works).
403
+ ledger.state = markInReview(ledger.state, step);
404
+ writeJSON(ledger.files.state, ledger.state);
405
+
406
+ if (!hub?.platform) { warn('no hub platform — marked in_review file-only (no PR opened)'); ok(`${step.id} → in_review`); return; }
407
+
408
+ const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
409
+ const reviewers = (hub.roster || []).filter((r) => r.role !== 'owner').map((r) => r.login);
410
+ const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
411
+ info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
412
+ const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, labels, cwd: root });
413
+ if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
414
+
415
+ const number = Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null;
416
+ ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number, url: r.url, branch, lastSyncedAt: null });
417
+ writeJSON(ledger.files.hubPrs, ledger.hubPrs);
418
+ ok(`opened ${r.url}`);
419
+ hand(`reviewers approve/comment there; then run \`sdlc gate sync ${epic} ${artifact}\``);
420
+ }
421
+
422
+ // ---- helpers ------------------------------------------------------------------------------------
423
+ const base = (artifact) => artifactBase(artifact);
424
+
425
+ function fillHubTemplate({ epic, artifact, step, owner, domains }) {
426
+ return [
427
+ '## Artifact under review',
428
+ `- Epic: \`${epic}\``,
429
+ `- Artifact: \`${artifact}\``,
430
+ `- Gate step: \`${step.id}\``,
431
+ `- Owner: \`${owner}\``,
432
+ '',
433
+ '## Impact & Risk (front-half)',
434
+ `- **Domains / repos touched:** ${domains.join(', ') || 'n/a'}`,
435
+ `- **Risk tags:** ${(step.risk_tags || []).join(', ') || 'none'}`,
436
+ '',
437
+ '## How to review (this drives the gate)',
438
+ '- **Approve** to record your approval; **comment / request changes** to hold the gate.',
439
+ '- This step advances when approvals are satisfied, all threads are resolved, and this PR is merged.',
440
+ ].join('\n');
441
+ }
442
+
443
+ function refreshRoster(epicDir, targets, approvals, today) {
444
+ for (const pr of targets) {
445
+ const stepApprovals = approvals.filter((a) => a.step === pr.step && a.status === 'approved');
446
+ const file = path.join(epicDir, 'reviews', `${base(pr.artifact)}--${today}--approved.md`);
447
+ fs.mkdirSync(path.dirname(file), { recursive: true });
448
+ const lines = [
449
+ `# Approval record — ${pr.artifact} — ${today}`, '',
450
+ '## Approved by',
451
+ ...stepApprovals.map((a) => `- ${a.approver} — ${a.role}${a.domain ? ` (${a.domain})` : ''} — approved ${a.date}${a.source ? ` (${a.source})` : ''}`),
452
+ '',
453
+ ];
454
+ fs.writeFileSync(file, lines.join('\n') + '\n');
455
+ }
456
+ }
package/cli/lib.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // Shared helpers for the `sdlc` CLI. Node >=18 built-ins only — no dependencies.
2
+ import { createHash } from 'node:crypto';
3
+ import { spawnSync } from 'node:child_process';
4
+ import * as readline from 'node:readline/promises';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ // Package root = one level up from this cli/ dir. Asset paths (skills/, etc.)
11
+ // resolve from HERE, never from the user's cwd.
12
+ export const PKG_ROOT = fileURLToPath(new URL('../', import.meta.url));
13
+
14
+ // ---- output -------------------------------------------------------------
15
+ const useColor = output.isTTY && !process.env.NO_COLOR;
16
+ const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
17
+ export const c = {
18
+ bold: (s) => paint('1', s),
19
+ dim: (s) => paint('2', s),
20
+ green: (s) => paint('32', s),
21
+ yellow: (s) => paint('33', s),
22
+ red: (s) => paint('31', s),
23
+ cyan: (s) => paint('36', s),
24
+ };
25
+ export const log = (s = '') => console.log(s);
26
+ export const step = (n, total, title) => log(`\n${c.cyan(`[${n}/${total}]`)} ${c.bold(title)}`);
27
+ export const ok = (s) => log(` ${c.green('✓')} ${s}`);
28
+ export const info = (s) => log(` ${c.dim('•')} ${s}`);
29
+ export const warn = (s) => log(` ${c.yellow('!')} ${s}`);
30
+ export const fail = (s) => log(` ${c.red('✗')} ${s}`);
31
+ export const hand = (s) => log(` ${c.yellow('→')} ${s}`);
32
+
33
+ // ---- prompts ------------------------------------------------------------
34
+ let rl;
35
+ const getRl = () => (rl ??= readline.createInterface({ input, output }));
36
+ export function closePrompts() {
37
+ rl?.close();
38
+ rl = undefined;
39
+ }
40
+ export async function ask(question, def = '') {
41
+ if (process.env.SDLC_NONINTERACTIVE) return def;
42
+ const suffix = def ? c.dim(` (${def})`) : '';
43
+ const a = (await getRl().question(` ${question}${suffix}: `)).trim();
44
+ return a || def;
45
+ }
46
+ export async function askYesNo(question, def = true) {
47
+ if (process.env.SDLC_NONINTERACTIVE) return def;
48
+ const hint = def ? 'Y/n' : 'y/N';
49
+ const a = (await getRl().question(` ${question} ${c.dim(`(${hint})`)} `)).trim().toLowerCase();
50
+ if (!a) return def;
51
+ return a.startsWith('y');
52
+ }
53
+
54
+ // ---- filesystem ---------------------------------------------------------
55
+ export const asset = (...p) => path.join(PKG_ROOT, ...p);
56
+ export const exists = (p) => fs.existsSync(p);
57
+
58
+ export function fileSha(p) {
59
+ if (!fs.existsSync(p)) return null;
60
+ return 'sha256:' + createHash('sha256').update(fs.readFileSync(p)).digest('hex');
61
+ }
62
+ // True when dest exists and its bytes match src exactly.
63
+ export function sameContent(src, dest) {
64
+ const a = fileSha(src);
65
+ const b = fileSha(dest);
66
+ return a !== null && a === b;
67
+ }
68
+
69
+ export function copyFile(src, dest, { exec = false } = {}) {
70
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
71
+ fs.copyFileSync(src, dest);
72
+ if (exec) fs.chmodSync(dest, 0o755);
73
+ }
74
+ export function copyDir(src, dest) {
75
+ fs.rmSync(dest, { recursive: true, force: true });
76
+ fs.cpSync(src, dest, { recursive: true });
77
+ }
78
+ // Recursive list of file paths relative to `dir`.
79
+ export function listFiles(dir, base = dir) {
80
+ if (!fs.existsSync(dir)) return [];
81
+ const out = [];
82
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
83
+ const full = path.join(dir, e.name);
84
+ if (e.isDirectory()) out.push(...listFiles(full, base));
85
+ else out.push(path.relative(base, full));
86
+ }
87
+ return out;
88
+ }
89
+ // True only if every file under src exists in dest with identical bytes.
90
+ export function dirMatches(src, dest) {
91
+ const files = listFiles(src);
92
+ if (files.length === 0) return false;
93
+ return files.every((rel) => sameContent(path.join(src, rel), path.join(dest, rel)));
94
+ }
95
+
96
+ export function readJSON(p, def = null) {
97
+ try {
98
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
99
+ } catch {
100
+ return def;
101
+ }
102
+ }
103
+ // Strict variant for ledger files (the source of truth): a missing file is a normal state and
104
+ // returns `def`, but a file that exists and fails to parse must throw — silently defaulting a
105
+ // corrupt approvals.json to [] would let the next sync rewrite it and permanently lose approvals.
106
+ export function readJSONStrict(p, def = null) {
107
+ if (!fs.existsSync(p)) return def;
108
+ try {
109
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
110
+ } catch (e) {
111
+ throw new Error(`corrupt JSON in ${p}: ${e.message} — fix or delete the file`);
112
+ }
113
+ }
114
+ // Atomic: serialize first, write a sibling tmp file (same dir = same filesystem),
115
+ // then rename over the target. A killed process can never leave a truncated ledger
116
+ // file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
117
+ export function writeJSON(p, obj) {
118
+ const data = JSON.stringify(obj, null, 2) + '\n';
119
+ fs.mkdirSync(path.dirname(p), { recursive: true });
120
+ const tmp = `${p}.${process.pid}.tmp`;
121
+ fs.writeFileSync(tmp, data);
122
+ try {
123
+ fs.renameSync(tmp, p);
124
+ } catch (e) {
125
+ fs.rmSync(tmp, { force: true });
126
+ throw e;
127
+ }
128
+ }
129
+
130
+ // ---- subprocess ---------------------------------------------------------
131
+ // Returns { ok, stdout, stderr, code }. Never throws on non-zero exit.
132
+ export function run(cmd, args = [], opts = {}) {
133
+ const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
134
+ return {
135
+ ok: r.status === 0,
136
+ code: r.status,
137
+ stdout: (r.stdout || '').trim(),
138
+ stderr: (r.stderr || '').trim(),
139
+ error: r.error,
140
+ };
141
+ }
142
+ export const has = (cmd) => run(process.platform === 'win32' ? 'where' : 'which', [cmd]).ok;