yadflow 2.4.2 → 2.6.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/README.md +44 -22
  3. package/bin/yad.mjs +5 -0
  4. package/cli/doctor.mjs +14 -2
  5. package/cli/gate.mjs +8 -3
  6. package/cli/manifest.mjs +18 -2
  7. package/cli/openpr.mjs +12 -2
  8. package/cli/platform.mjs +105 -11
  9. package/cli/setup.mjs +61 -7
  10. package/cli/ship.mjs +37 -0
  11. package/docs/index.html +47 -16
  12. package/package.json +2 -2
  13. package/skills/sdlc/config.yaml +7 -2
  14. package/skills/sdlc/install.sh +1 -1
  15. package/skills/sdlc/module-help.csv +7 -4
  16. package/skills/yad-checks/references/check-gates.md +58 -2
  17. package/skills/yad-checks/templates/checks/commit-message.sh +82 -0
  18. package/skills/yad-checks/templates/github/yad-checks.yml +27 -0
  19. package/skills/yad-checks/templates/github/yad-hub-checks.yml +36 -0
  20. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +20 -0
  21. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +39 -0
  22. package/skills/yad-commit/SKILL.md +66 -0
  23. package/skills/yad-connect-repos/SKILL.md +14 -7
  24. package/skills/yad-connect-repos/references/hub-config.md +20 -11
  25. package/skills/yad-connect-repos/references/repos-registry.md +2 -1
  26. package/skills/yad-engineer-review/SKILL.md +86 -0
  27. package/skills/{yad-ship → yad-engineer-review}/references/ship-and-record.md +2 -2
  28. package/skills/{yad-ship → yad-engineer-review}/templates/.coderabbit.yaml +1 -1
  29. package/skills/yad-epic/references/state-schema.md +1 -1
  30. package/skills/yad-hub-bridge/references/login-roster.md +32 -5
  31. package/skills/yad-implement/SKILL.md +1 -1
  32. package/skills/yad-implement/references/implement-conventions.md +1 -1
  33. package/skills/yad-open-pr/SKILL.md +72 -0
  34. package/skills/yad-pr-template/SKILL.md +5 -0
  35. package/skills/yad-pr-template/templates/checks/pr-template.sh +62 -0
  36. package/skills/yad-pr-template/templates/checks/pr-title.sh +51 -0
  37. package/skills/yad-review-gate/references/gating.md +8 -2
  38. package/skills/yad-run/SKILL.md +2 -2
  39. package/skills/yad-run/references/run-loop.md +4 -4
  40. package/skills/yad-ship/SKILL.md +44 -66
  41. package/skills/yad-spec/SKILL.md +1 -1
package/CHANGELOG.md CHANGED
@@ -1,9 +1,18 @@
1
- ## [2.4.2](https://github.com/abdelrahmannasr/yadflow/compare/v2.4.1...v2.4.2) (2026-06-14)
1
+ # [2.6.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.5.0...v2.6.0) (2026-06-14)
2
2
 
3
3
 
4
4
  ### Bug Fixes
5
5
 
6
- * route GitLab CI gate jobs to tag-locked runners via $YAD_RUNNER_TAGS ([a0311c5](https://github.com/abdelrahmannasr/yadflow/commit/a0311c5af647f63968f3f34e8e6e6fa48b7423d8)), closes [#50](https://github.com/abdelrahmannasr/yadflow/issues/50)
6
+ * allow scoped/breaking commit subjects + titles; parse only the trailer block ([63444c0](https://github.com/abdelrahmannasr/yadflow/commit/63444c08c1e33b4151c7389eb5e87f6ae682aee6))
7
+ * harden pattern-gate CI — pass PR title via env, write body to mktemp ([2415397](https://github.com/abdelrahmannasr/yadflow/commit/2415397f81280f459785b8fa9ca29007b473561b))
8
+ * let `yad ship` derive the PR title from the committed subject ([a5adba3](https://github.com/abdelrahmannasr/yadflow/commit/a5adba3212b236ffc5a2470b9ea50bf97c6c4138))
9
+
10
+
11
+ ### Features
12
+
13
+ * add `yad ship` CLI to commit and open a PR/MR in one step ([c493e93](https://github.com/abdelrahmannasr/yadflow/commit/c493e93e5626eee590cd061c9e7dbc8047e78718))
14
+ * add commit-message/pr-title/pr-template pattern gates (code + hub) ([6658837](https://github.com/abdelrahmannasr/yadflow/commit/6658837d7685b826884b665a5e7661fd6ae99828))
15
+ * add yad-commit/yad-open-pr/yad-ship skills; rename Step E to yad-engineer-review ([c566567](https://github.com/abdelrahmannasr/yadflow/commit/c5665679e45f24ea53c682aca3a78eb52c9f984f))
7
16
 
8
17
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
18
 
package/README.md CHANGED
@@ -60,7 +60,10 @@ human**. Detailed walkthroughs for each phase follow below.
60
60
  | `skills/yad-pr-template/` | Build Step D: install the platform PR/MR template + risk routing (code repos **and** the hub). |
61
61
  | `skills/yad-review-comments/` | Install platform-matched PR/MR review-comment scaffolds (code repos and the hub). |
62
62
  | `skills/yad-hub-bridge/` | The templated PR/MR **review bridge**: open a review PR/MR on the hub and sync platform approvals/comments into the file ledger. |
63
- | `skills/yad-ship/` | Build Step E: AI review (advisory) engineer review ship + record in the build log. |
63
+ | `skills/yad-commit/` | Build helper: commit ONE staged atomic change by the conventions (Conventional subject, trailers, `--ai` footer, ≤3-file guard). |
64
+ | `skills/yad-open-pr/` | Build helper: open a code-repo task PR/MR from the committed template (push, prefill, roster auto-assign). |
65
+ | `skills/yad-ship/` | Build helper: commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`). |
66
+ | `skills/yad-engineer-review/` | Build Step E: AI review (advisory) → engineer review → merge + record in the build log. |
64
67
  | `skills/yad-backfill/` | Generate a human-verified spec for already-built code (Repomix), gated per touched feature. |
65
68
  | `skills/yad-run/` | Phase 4 orchestrator: drive a story's back half on the `automation` dial; kill switch. |
66
69
  | `skills/yad-status/` | Read-only view: front chain, build-half dials, trust record, fleet roll-up. |
@@ -94,13 +97,15 @@ with `npx` from your **product hub** repo — no clone needed.
94
97
  | `yad gate ci [--branch <head>] [--pr <n>]` | The CI entry the hub workflow calls on review/merge events: derive the epic/artifact from the `review/EP-*` branch, run the same sync, and commit **only the ledger** to the hub default branch (sweep every open review PR when no `--branch`). |
95
98
  | `yad commit --type <t> -m <subject>` | Commit by the SDLC convention — Conventional subject, `Task`/`Contract-Change`/`Co-Authored-By` trailers, atomic-file guard. |
96
99
  | `yad open-pr [--repo <name>]` | Open a code-repo **task** PR/MR from the repo's platform template (build half). |
100
+ | `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`). |
97
101
  | `yad repo list` / `yad repo refresh [name]` | List connected repos as **fresh / stale**, and re-pack a stale one — staleness is now an explicit human decision, never an automatic skill side-effect. |
98
102
  | `npx yadflow --version` | Print the installed CLI version. |
99
103
 
100
104
  Flags: `--dir <path>` targets a project other than the cwd; `--force` re-copies unchanged files (or
101
105
  bypasses the commit atomic guard). Commit flags: `--type`, `-m/--message`, `--task`, `--ai
102
106
  <claude\|copilot\|cursor\|coderabbit\|none>`, `--contract-change`, `--dry-run`. `open-pr` flags:
103
- `--repo`, `--risk <low\|medium\|high>`, `--contract-change`.
107
+ `--repo`, `--risk <low\|medium\|high>`, `--contract-change`. `ship` takes the union of the `commit`
108
+ and `open-pr` flags (it runs `open-pr` only if the commit lands).
104
109
 
105
110
  ### The PR-driven review gate
106
111
 
@@ -128,7 +133,7 @@ simultaneous advancements can be lost; the next event or scheduled sweep re-sync
128
133
  ### What `setup` walks you through (10 steps)
129
134
 
130
135
  1. **Preflight** — confirm the hub is a git repo (offers `git init`); check `git`/`node`/`npx`.
131
- 2. **Install the module** — copy all 22 `yad-*` skills into the IDE skill dirs you pick
136
+ 2. **Install the module** — copy all 25 `yad-*` skills into the IDE skill dirs you pick
132
137
  (`.claude/`, `.agents/`, `.zencoder/`, `.opencode/`) and register `_bmad/sdlc/`.
133
138
  3. **Hub platform & roster** — detect GitHub/GitLab from the remote; record reviewers → `.sdlc/hub.json`.
134
139
  4. **Connect a design tool** — record the design tool (Figma / pencil / none) → `.sdlc/design.json` so
@@ -184,7 +189,7 @@ with a fix-it hint per finding. Failures carry stable, greppable codes, also pri
184
189
 
185
190
  Filing a bug? Attach `yad doctor --json` — it contains no secrets (names, paths, and check results only).
186
191
 
187
- ## Agent skills (all 22)
192
+ ## Agent skills (all 25)
188
193
 
189
194
  The CLI **installs and wires** the module; the skills below are the **agents you invoke by name** in your
190
195
  AI IDE (e.g. *“run `yad-epic`”*) to actually do the work. State lives in files you can also edit
@@ -276,15 +281,25 @@ directly. Each skill stops at a gate and never auto-advances unless a step has *
276
281
  (≤3 files) on its own branch. The diff stays inside the files the task declared (flag and STOP if it
277
282
  would grow). Commit ends with the task ID; `Contract-Change: yes` only if it touches the locked
278
283
  contract surface.
279
- - **`yad-checks`** — Step C, the production-safety gates. Wire and run three CI gates: **spec-link**
284
+ - **`yad-checks`** — Step C, the production-safety gates. Wire and run the CI gates: **spec-link**
280
285
  (every change links a real story/spec), **contract-check** (a contract-surface diff without a
281
- re-locked contract FAILS), and **build/test/lint**. CI-agnostic bash for GitHub Actions and GitLab CI.
286
+ re-locked contract FAILS), **build/test/lint**, **verified-commits** (signed + roster-known authors),
287
+ and the **pattern gates** — **commit-message** (Conventional subject + trailer order), **pr-title**,
288
+ and **pr-template** (the PR/MR body uses the template). Profile-aware (`code`|`hub`), so they run on
289
+ both code repos and the product hub. CI-agnostic bash for GitHub Actions and GitLab CI.
282
290
  - **`yad-pr-template`** — Step D. Detect the repo's platform and commit the matching PR/MR template with
283
291
  an Impact & Risk block; high risk (or a contract/auth/payments surface) routes the review to domain
284
- owners. Includes `risk-route.sh`.
285
- - **`yad-ship`** — Step E. AI review (CodeRabbit, advisory) engineer review (the human gate, owner +
286
- 1 reviewer with the same escalation) on merge, record the ship in the epic build-log and update the
287
- story state so the epic → story → task → PR chain stays traceable.
292
+ owners. Includes `risk-route.sh` plus the `pr-title.sh` / `pr-template.sh` gate scripts.
293
+ - **`yad-commit`** — build helper. Commit ONE staged atomic change by the conventions (Conventional
294
+ subject, `Task Contract-ChangeCo-Authored-By` trailers, the `--ai` co-author footer, the ≤3-file
295
+ atomic guard). Drives `yad commit`.
296
+ - **`yad-open-pr`** — build helper. Open a code-repo task PR/MR from the committed template: push the
297
+ branch, prefill the body, auto-assign the repo-scoped roster. Drives `yad open-pr`.
298
+ - **`yad-ship`** — build helper. Commit **and** open the task PR/MR in one step (`yad commit` then
299
+ `yad open-pr`; the PR step runs only if the commit lands). Drives `yad ship`.
300
+ - **`yad-engineer-review`** — Step E. AI review (CodeRabbit, advisory) → engineer review (the human gate,
301
+ owner + 1 reviewer with the same escalation) → on merge, record the ship in the epic build-log and
302
+ update the story state so the epic → story → task → PR chain stays traceable.
288
303
  - **`yad-backfill`** — Step G. Generate specs for already-built features in an existing repo so new work
289
304
  doesn't break them: pack one feature at a time with Repomix, write a DRAFT spec, require human approval
290
305
  before it counts. A change is blocked only until the features it touches have approved specs.
@@ -382,12 +397,16 @@ build half by hand”** below.
382
397
  11. `yad-implement story:<id> repo:<repo> task:<T0N>` → one atomic task = one branch = one commit
383
398
  (repeat per task). Commit by convention with **`yad commit --type <t> -m <subject> [--ai <tool>]`**
384
399
  (Task/Contract-Change/Co-Authored-By trailers, atomic-file guard).
385
- 12. `yad-checks repo:<repo> action: run` → spec-link, contract-check, build/test/lint, and
386
- verified-commits (platform-Verified signature + roster-allowlisted author) must pass.
387
- 13. Open the PR/MR from the wired template with **`yad open-pr --repo <repo> [--risk <level>]`**;
388
- `yad-pr-template repo:<repo> action: route` prints the required reviewers from the Impact & Risk block.
389
- 14. `yad-ship` `ai-review` (advisory) `approve` (the human engineer gate) `ship` (merge, record
390
- in `build-log.json`, update story status to `in-build`/`shipped`).
400
+ 12. `yad-checks repo:<repo> action: run` → spec-link, contract-check, build/test/lint, verified-commits
401
+ (platform-Verified signature + roster-allowlisted author), and commit-message must pass. (The
402
+ `pr-title` / `pr-template` gates need the PR title + body, so they run in CI once the PR exists —
403
+ step 13.)
404
+ 13. Open the PR/MR from the wired template with **`yad open-pr --repo <repo> [--risk <level>]`** (or do
405
+ 12+13 in one step with **`yad ship --type <t> -m <subject> --repo <repo>`**). The PR's CI now also
406
+ runs the `pr-title` and `pr-template` gates; `yad-pr-template repo:<repo> action: route` prints the
407
+ required reviewers from the Impact & Risk block.
408
+ 14. `yad-engineer-review` → `ai-review` (advisory) → `approve` (the human engineer gate) → `ship` (merge,
409
+ record in `build-log.json`, update story status to `in-build`/`shipped`).
391
410
  - **Multi-repo:** repeat 10–14 in each repo, all from the **one** locked contract.
392
411
  - **Existing code:** `yad-backfill` first, to produce a human-verified spec for a built feature.
393
412
 
@@ -514,16 +533,19 @@ the product repo. Code repos are **separate git repos** under `demo-repos/<repo>
514
533
  (`feat/<story>-<task>-…`) = one PR. The diff stays inside the files the task declared. Commit with
515
534
  **`yad commit`** — it builds the conventional subject, derives the `Task:` trailer from the branch
516
535
  (add `--contract-change` only if the locked surface is touched), appends an optional `--ai` co-author,
517
- and refuses a non-atomic stage. Open the PR with **`yad open-pr --repo <repo>`** (template prefilled).
518
- 3. **Check gates** `yad-checks` wires three CI gates (GitHub + GitLab) that must pass before merge:
536
+ and refuses a non-atomic stage. Open the PR with **`yad open-pr --repo <repo>`** (template prefilled),
537
+ or do both in one step with **`yad ship`** (commit then open-pr).
538
+ 3. **Check gates** — `yad-checks` wires the CI gates (GitHub + GitLab) that must pass before merge:
519
539
  **spec-link** (links a real story/spec), **contract-check** (a contract-surface change without
520
540
  `Contract-Change` + a re-locked contract FAILS, routing back to the architecture gate),
521
- **build/test/lint**. They fail closed on a bad base ref.
541
+ **build/test/lint**, **verified-commits**, and the **pattern gates** **commit-message** / **pr-title**
542
+ / **pr-template** (profile-aware `code`|`hub`, so they also run on the product hub). They fail closed
543
+ on a bad base ref.
522
544
  4. **PR/MR template + risk routing** — `yad-pr-template` drops the platform-matched template with an
523
545
  Impact & Risk block; `high` risk (or a contract/auth/payments surface) routes the review to domain
524
546
  owners (`risk-route.sh`), the same escalation as the gate.
525
- 5. **AI review → engineer review → ship** — `yad-ship`: CodeRabbit is an advisory first pass (never
526
- the authority); a human engineer approves (owner + 1 reviewer, escalating to domain owners); on
547
+ 5. **AI review → engineer review → merge** — `yad-engineer-review`: CodeRabbit is an advisory first pass
548
+ (never the authority); a human engineer approves (owner + 1 reviewer, escalating to domain owners); on
527
549
  merge the ship is recorded in `.sdlc/build-log.json` and the story state becomes `in-build` →
528
550
  `shipped`. The epic → story → task → PR → mergeCommit chain is traceable both ways.
529
551
 
@@ -551,7 +573,7 @@ with their dials, per repo) and `trust-log.json` (every run's verdict). See
551
573
  - **Drive a story's back half:** `yad-run {story} {repo}` walks `spec → tasks → implement → checks`,
552
574
  reading each step's dial. On `machine_advance` it advances on its own; on `human_approve` it stops
553
575
  for a human; on any FAIL, scope overrun, or contract-surface touch it **halts and pulls in a human**.
554
- It always stops at the engineer review (`yad-ship`), which is never automated.
576
+ It always stops at the engineer review (`yad-engineer-review`), which is never automated.
555
577
  - **Read the trust log:** `yad-status {epic}` shows each back step's dial, status, and trust record —
556
578
  runs, % `approved-unchanged`, and whether that clears the threshold (`automation.trust_threshold` in
557
579
  `config.yaml`, default ≥5 runs and ≥80% unchanged). The engineer review records each run's verdict
package/bin/yad.mjs CHANGED
@@ -8,6 +8,7 @@ import { gateOpen, gateSync, gateComments, gateStatus, gateCi } from '../cli/gat
8
8
  import { isValidEpicId } from '../cli/epic-state.mjs';
9
9
  import { runCommit } from '../cli/commit.mjs';
10
10
  import { runOpenPr } from '../cli/openpr.mjs';
11
+ import { runShip } from '../cli/ship.mjs';
11
12
  import { runRepo } from '../cli/repo.mjs';
12
13
  import { runDoctor } from '../cli/doctor.mjs';
13
14
 
@@ -34,6 +35,7 @@ ${c.bold('Review gate (front half)')}
34
35
  ${c.bold('Build helpers')}
35
36
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
36
37
  yad open-pr [--repo <name>] Open a code-repo task PR/MR from the template
38
+ yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step
37
39
  yad repo list Show connected repos (fresh / stale)
38
40
  yad repo refresh [name] Re-pack a stale repo (a human decision)
39
41
 
@@ -123,6 +125,9 @@ async function main() {
123
125
  case 'open-pr':
124
126
  await runOpenPr(o.dir, { repo: o.repo, platform: o.platform, base: o.base, title: o.title || o.message, task: o.task, risk: o.risk, contractChange: o.contractChange });
125
127
  break;
128
+ case 'ship':
129
+ await runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
130
+ break;
126
131
  case 'repo': {
127
132
  const [, action, name] = o._;
128
133
  await runRepo(o.dir, { action: action || 'list', name, today });
package/cli/doctor.mjs CHANGED
@@ -8,7 +8,7 @@ import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSO
8
8
  import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
9
9
  import { loadLedger, epicRoot } from './epic-state.mjs';
10
10
  import { gitHead } from './setup.mjs';
11
- import { cliFor } from './platform.mjs';
11
+ import { cliFor, validateLogin } from './platform.mjs';
12
12
 
13
13
  const MIN_NODE = 18;
14
14
 
@@ -70,7 +70,18 @@ export function projectChecks(checks, root) {
70
70
  if (cli) {
71
71
  if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
72
72
  else if (!run(cli, ['auth', 'status']).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated [YAD-ENV-002]`, `run \`${cli} auth login\``);
73
- else check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
73
+ else {
74
+ check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
75
+ // Re-validate each roster login against the hub (warn-only). Skips when a login is already
76
+ // flagged unverified by setup; reports any that no longer resolve.
77
+ const bad = [];
78
+ for (const e of hub.roster || []) {
79
+ const v = validateLogin(hub.platform, e.login);
80
+ if (v.checked && !v.exists) bad.push(e.login);
81
+ }
82
+ if (bad.length) check(checks, 'roster', 'project', 'warn', `roster login(s) not found on ${hub.platform}: ${bad.join(', ')}`, 'fix the login or re-run `yad setup` (they cannot satisfy a gate)');
83
+ else check(checks, 'roster', 'project', 'ok', `roster: ${(hub.roster || []).length} member(s) validated on ${hub.platform}`);
84
+ }
74
85
  }
75
86
  }
76
87
  }
@@ -184,6 +195,7 @@ export function ciTagsChecks(checks, root, hub, registry) {
184
195
  fragments.push(
185
196
  { scope: 'hub', file: '.gitlab/ci/yad-gate-sync.yml', path: path.join(root, '.gitlab/ci/yad-gate-sync.yml') },
186
197
  { scope: 'hub', file: '.gitlab/ci/yad-verified-commits.yml', path: path.join(root, '.gitlab/ci/yad-verified-commits.yml') },
198
+ { scope: 'hub', file: '.gitlab/ci/yad-hub-checks.yml', path: path.join(root, '.gitlab/ci/yad-hub-checks.yml') },
187
199
  );
188
200
  }
189
201
  for (const repo of registry?.repos || []) {
package/cli/gate.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
14
  artifactPaths, upsertHubPr,
15
15
  } from './epic-state.mjs';
16
- import { readPr, mapApprovers, createPr } from './platform.mjs';
16
+ import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
17
17
  import { err } from './errors.mjs';
18
18
 
19
19
  // ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
@@ -412,10 +412,15 @@ export async function gateOpen(root, { epic, artifact } = {}) {
412
412
  if (!hub?.platform) { warn('no hub platform — marked in_review file-only (no PR opened)'); ok(`${step.id} → in_review`); return; }
413
413
 
414
414
  const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
415
- const reviewers = (hub.roster || []).filter((r) => r.role !== 'owner').map((r) => r.login);
415
+ // Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
416
+ // domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
417
+ // to review their own artifact). Scope is the hub plus every touched domain.
418
+ const committer = resolveCommitterLogin(root, hub.roster || []);
419
+ const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer });
420
+ const assignees = committer ? [committer] : [];
416
421
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
417
422
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
418
- const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, labels, cwd: root });
423
+ const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
419
424
  if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
420
425
 
421
426
  const number = Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null;
package/cli/manifest.mjs CHANGED
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
11
  export const VERSION = version;
12
12
 
13
- // The 22 hand-authored yad-* skills (mirrors skills/sdlc/install.sh).
13
+ // The 25 hand-authored yad-* skills (mirrors skills/sdlc/install.sh).
14
14
  export const SKILLS = [
15
15
  'yad-analysis',
16
16
  'yad-epic',
@@ -29,7 +29,10 @@ export const SKILLS = [
29
29
  'yad-pr-template',
30
30
  'yad-review-comments',
31
31
  'yad-hub-bridge',
32
+ 'yad-commit',
33
+ 'yad-open-pr',
32
34
  'yad-ship',
35
+ 'yad-engineer-review',
33
36
  'yad-backfill',
34
37
  'yad-run',
35
38
  'yad-review-gate',
@@ -51,7 +54,11 @@ export const LEGACY_SKILLS = {
51
54
  'yad-pr-template': 'sdlc-pr-template',
52
55
  'yad-review-comments': 'sdlc-review-comments',
53
56
  'yad-hub-bridge': 'sdlc-hub-bridge',
54
- 'yad-ship': 'sdlc-ship',
57
+ // Step E ("ship") was renamed to yad-engineer-review (the yad-ship name now belongs to the new
58
+ // commit+open-PR combined skill). Pre-2.0 installs carry sdlc-ship → migrate it to yad-engineer-review.
59
+ // A 2.x install carrying yad-ship-as-Step-E is simply overwritten with the new combined content on
60
+ // `yad update` (same name, fresh copy), and yad-engineer-review is installed as a new skill.
61
+ 'yad-engineer-review': 'sdlc-ship',
55
62
  'yad-backfill': 'sdlc-backfill',
56
63
  'yad-run': 'sdlc-run',
57
64
  'yad-review-gate': 'sdlc-review-gate',
@@ -153,7 +160,10 @@ export const REPO_WIRING = {
153
160
  { src: 'skills/yad-checks/templates/checks/contract-check.sh', dest: 'checks/contract-check.sh', exec: true },
154
161
  { src: 'skills/yad-checks/templates/checks/build-test-lint.sh', dest: 'checks/build-test-lint.sh', exec: true },
155
162
  { src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
163
+ { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
156
164
  { src: 'skills/yad-pr-template/templates/checks/risk-route.sh', dest: 'checks/risk-route.sh', exec: true },
165
+ { src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
166
+ { src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
157
167
  ],
158
168
  github: [
159
169
  { src: 'skills/yad-checks/templates/github/yad-checks.yml', dest: '.github/workflows/yad-checks.yml' },
@@ -179,13 +189,19 @@ export const wiringFor = (platform) => [
179
189
  export const HUB_WIRING = {
180
190
  common: [
181
191
  { src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
192
+ // Pattern gates run on the hub too (profile: hub) — commit subject + PR title + PR body.
193
+ { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
194
+ { src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
195
+ { src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
182
196
  ],
183
197
  github: [
184
198
  { src: 'skills/yad-hub-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },
185
199
  { src: 'skills/yad-checks/templates/github/yad-verified-commits.yml', dest: '.github/workflows/yad-verified-commits.yml' },
200
+ { src: 'skills/yad-checks/templates/github/yad-hub-checks.yml', dest: '.github/workflows/yad-hub-checks.yml' },
186
201
  ],
187
202
  gitlab: [
188
203
  { src: 'skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml', dest: '.gitlab/ci/yad-gate-sync.yml' },
189
204
  { src: 'skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml', dest: '.gitlab/ci/yad-verified-commits.yml' },
205
+ { src: 'skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml', dest: '.gitlab/ci/yad-hub-checks.yml' },
190
206
  ],
191
207
  };
package/cli/openpr.mjs CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
8
8
  import { PROJECT_FILES } from './manifest.mjs';
9
- import { detectPlatform, createPr } from './platform.mjs';
9
+ import { detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
10
10
  import { taskFromBranch } from './commit.mjs';
11
11
 
12
12
  // Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
@@ -57,7 +57,17 @@ export async function runOpenPr(root, opts = {}) {
57
57
  task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name,
58
58
  });
59
59
 
60
- const r = createPr(platform, { title, body, base: baseBranch, head: branch, cwd: repoRoot });
60
+ // Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
61
+ // local git identity), reviewers = the repo's reviewers + domain-owners, minus the committer.
62
+ // Degrades cleanly when there is no roster / the committer is unmapped (gh self-assigns via @me).
63
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
64
+ const roster = hub.roster || [];
65
+ const committer = resolveCommitterLogin(repoRoot, roster);
66
+ const scope = meta?.name ? [meta.name] : [];
67
+ const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer });
68
+ const assignees = committer ? [committer] : [];
69
+
70
+ const r = createPr(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
61
71
  if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
62
72
  ok(`opened ${r.url}`);
63
73
  if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` for required reviewers');
package/cli/platform.mjs CHANGED
@@ -23,18 +23,102 @@ export function platformReady(platform) {
23
23
  return !!cli && has(cli);
24
24
  }
25
25
 
26
+ // ---- roster role model (per-scope map, with legacy back-compat) ---------------------------------
27
+ // A roster entry's roles live in a per-scope map: `roles: { hub: ["owner","reviewer"], <repo>: [...] }`.
28
+ // `rolesForScope` normalizes the three shapes a roster entry can take on disk:
29
+ // 1. new object map — `entry.roles = { hub: [...], backend: [...] }`
30
+ // 2. flat array variant — `entry.roles = ["owner","reviewer"]` (treated as hub roles)
31
+ // 3. legacy single role — `entry.role = "owner"` (a hub role; pre per-scope schema)
32
+ // The legacy `repos.json` `domain_owner` field is handled separately by resolveLogin's fallback.
33
+ export function rolesForScope(entry, scope) {
34
+ if (!entry) return [];
35
+ const r = entry.roles;
36
+ if (r && typeof r === 'object' && !Array.isArray(r)) return Array.isArray(r[scope]) ? r[scope] : [];
37
+ if (Array.isArray(r)) return scope === 'hub' ? r : [];
38
+ if (typeof entry.role === 'string' && entry.role) return scope === 'hub' ? [entry.role] : [];
39
+ return [];
40
+ }
41
+
42
+ // True when the entry holds any of `wanted` roles across any of `scopes`.
43
+ export function hasAnyRole(entry, scopes = [], wanted = []) {
44
+ for (const scope of scopes) {
45
+ for (const role of rolesForScope(entry, scope)) {
46
+ if (wanted.includes(role)) return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // Platform logins to auto-request as reviewers for the given scopes: everyone holding a `reviewer`
53
+ // or `domain-owner` role in any scope, minus `excludeLogin` (you don't review your own PR), deduped.
54
+ export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null } = {}) {
55
+ const out = [];
56
+ for (const entry of roster) {
57
+ if (!entry.login || entry.login === excludeLogin) continue;
58
+ if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner']) && !out.includes(entry.login)) out.push(entry.login);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // The committer/PR-opener's platform login, resolved from local git identity through the roster.
64
+ // Match on commit email first (the stable key), then fall back to name/login. null when unresolved.
65
+ export function resolveCommitterLogin(cwd, roster = []) {
66
+ const email = (run('git', ['config', 'user.email'], { cwd }).stdout || '').trim().toLowerCase();
67
+ const name = (run('git', ['config', 'user.name'], { cwd }).stdout || '').trim();
68
+ if (email) {
69
+ const byEmail = roster.find((r) => (r.email || '').toLowerCase() === email);
70
+ if (byEmail) return byEmail.login || null;
71
+ }
72
+ if (name) {
73
+ const byName = roster.find((r) => r.name === name || r.login === name);
74
+ if (byName) return byName.login || null;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // Does this platform login exist on the hub? Warn-only (never throws): `checked:false` when the CLI
80
+ // is absent/unauthenticated so callers can distinguish "not a user" from "couldn't check".
81
+ export function validateLogin(platform, login) {
82
+ const cli = cliFor(platform);
83
+ if (!cli || !has(cli) || !login) return { ok: false, exists: false, checked: false };
84
+ if (platform === 'github') {
85
+ const r = run('gh', ['api', `users/${login}`]);
86
+ return { ok: r.ok, exists: r.ok, checked: true };
87
+ }
88
+ // gitlab: users?username=<login> returns an array; empty array => no such user.
89
+ const r = run('glab', ['api', `users?username=${encodeURIComponent(login)}`]);
90
+ if (!r.ok) return { ok: false, exists: false, checked: true };
91
+ let exists = false;
92
+ try { exists = Array.isArray(JSON.parse(r.stdout)) && JSON.parse(r.stdout).length > 0; } catch { exists = false; }
93
+ return { ok: exists, exists, checked: true };
94
+ }
95
+
26
96
  // ---- login -> yad identity (roster + derived domain-owner) -------------------------------------
27
- // Returns the records this login's APPROVED review contributes. A roster reviewer who owns a touched
28
- // repo's domain contributes BOTH a base record and a domain-owner record (bridge.md "Login -> role").
97
+ // Returns the records this login's APPROVED review contributes. Roles are read from the per-scope
98
+ // map: hub roles plus, for each touched domain, that repo's scoped roles (domain-owner carries the
99
+ // `domain` tag). The legacy `repos.json` `domain_owner === name` mapping is kept as a fallback so
100
+ // pre per-scope projects still resolve domain owners.
29
101
  export function resolveLogin(login, roster = [], repos = [], touchedDomains = []) {
30
102
  const entry = roster.find((r) => r.login === login);
31
103
  if (!entry) return [{ name: login, role: 'reviewer', unverified: true }];
32
- const records = [{ name: entry.name, role: entry.role }];
33
- for (const repo of repos) {
34
- if (repo.domain_owner === entry.name && touchedDomains.includes(repo.name)) {
35
- records.push({ name: entry.name, role: 'domain-owner', domain: repo.name });
104
+ const records = [];
105
+ const push = (rec) => {
106
+ if (!records.some((x) => x.name === rec.name && x.role === rec.role && x.domain === rec.domain)) records.push(rec);
107
+ };
108
+ for (const role of rolesForScope(entry, 'hub')) push({ name: entry.name, role });
109
+ for (const d of touchedDomains) {
110
+ for (const role of rolesForScope(entry, d)) {
111
+ push(role === 'domain-owner' ? { name: entry.name, role, domain: d } : { name: entry.name, role });
36
112
  }
113
+ // Legacy fallback: a repo whose domain_owner is this name confers domain-owner for that domain.
114
+ const legacy = repos.find((repo) => repo.name === d && repo.domain_owner === entry.name);
115
+ if (legacy) push({ name: entry.name, role: 'domain-owner', domain: d });
37
116
  }
117
+ // An identity-only entry (no roles map and no legacy `role`) still contributes a base reviewer
118
+ // record so an approval from a known person is never silently dropped. An entry that DOES declare
119
+ // roles but none apply to these scopes contributes nothing here (it is scoped elsewhere).
120
+ const hasNoRoleInfo = !entry.role && !(entry.roles && (Array.isArray(entry.roles) ? entry.roles.length : Object.keys(entry.roles).length));
121
+ if (!records.length && hasNoRoleInfo) push({ name: entry.name, role: 'reviewer' });
38
122
  return records;
39
123
  }
40
124
 
@@ -134,18 +218,28 @@ export function readPr(platform, n, opts = {}) {
134
218
  }
135
219
 
136
220
  // ---- create a PR/MR -----------------------------------------------------------------------------
137
- export function createPr(platform, { title, body, base, head, reviewers = [], labels = [], cwd } = {}) {
138
- if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
221
+ // `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
222
+ // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
223
+ // empty assignee list falls back to `@me` so the opener still self-assigns even without a roster.
224
+ // Pure argv builder for the create command — exported so the reviewer/assignee/label wiring is
225
+ // unit-testable without shelling out. gh always self-assigns (@me) when no assignee resolved.
226
+ export function buildPrArgs(platform, { title, body, base, head, reviewers = [], labels = [], assignees = [] } = {}) {
139
227
  if (platform === 'gitlab') {
140
228
  const args = ['mr', 'create', '--title', title, '--description', body, '--target-branch', base, '--source-branch', head, '--yes'];
141
229
  if (reviewers.length) args.push('--reviewer', reviewers.join(','));
230
+ if (assignees.length) args.push('--assignee', assignees.join(','));
142
231
  if (labels.length) args.push('--label', labels.join(','));
143
- const r = run('glab', args, { cwd });
144
- return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
232
+ return args;
145
233
  }
146
234
  const args = ['pr', 'create', '--title', title, '--body', body, '--base', base, '--head', head];
147
235
  if (reviewers.length) args.push('--reviewer', reviewers.join(','));
236
+ args.push('--assignee', assignees.length ? assignees.join(',') : '@me');
148
237
  if (labels.length) args.push('--label', labels.join(','));
149
- const r = run('gh', args, { cwd });
238
+ return args;
239
+ }
240
+
241
+ export function createPr(platform, opts = {}) {
242
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
243
+ const r = run(cliFor(platform), buildPrArgs(platform, opts), { cwd: opts.cwd });
150
244
  return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
151
245
  }
package/cli/setup.mjs CHANGED
@@ -11,6 +11,12 @@ import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
12
  legacyModuleActions, legacyRepoActions, legacyHubActions,
13
13
  } from './plan.mjs';
14
+ import { validateLogin } from './platform.mjs';
15
+
16
+ // Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
17
+ function parseList(s) {
18
+ return [...new Set((s || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean))];
19
+ }
14
20
 
15
21
  const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
16
22
 
@@ -34,7 +40,35 @@ export function insideRoot(root, rpath) {
34
40
  // Validate + record one code repo into the registry (the testable half of the connect loop).
35
41
  // A path that is not a git repository is rejected and NOTHING is written — a registry entry with
36
42
  // syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
37
- export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', default_branch = 'main', today = null }) {
43
+ // Grant per-repo roles to roster members by writing into each person's `roles[<repo>]` array in
44
+ // hub.json. `grants` maps a role -> the yad names that hold it for this repo. A name that is not in
45
+ // the roster is warned about and skipped (the roster is the source of identity). Idempotent.
46
+ export function addRepoRoles(root, repo, grants = {}) {
47
+ const hubPath = path.join(root, PROJECT_FILES.hubConfig);
48
+ const hub = readJSON(hubPath, null);
49
+ if (!hub || !Array.isArray(hub.roster)) return;
50
+ const byName = new Map(hub.roster.map((e) => [e.name, e]));
51
+ let touched = false;
52
+ for (const [role, names] of Object.entries(grants)) {
53
+ for (const nm of names) {
54
+ const entry = byName.get(nm);
55
+ if (!entry) { warn(`'${nm}' is not in the roster — skipped ${role} for ${repo}`); continue; }
56
+ // Normalize to the per-scope map, migrating the legacy shapes: a flat array or a single
57
+ // `role` string both become hub roles so nothing is lost.
58
+ if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
59
+ const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
60
+ entry.roles = hub.length ? { hub } : {};
61
+ delete entry.role;
62
+ }
63
+ const list = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
64
+ if (!list.includes(role)) { list.push(role); touched = true; }
65
+ entry.roles[repo] = list;
66
+ }
67
+ }
68
+ if (touched) writeJSON(hubPath, hub);
69
+ }
70
+
71
+ export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null }) {
38
72
  if (!insideRoot(root, rpath)) {
39
73
  warn(`${rpath} resolves outside the project root — skipped`);
40
74
  return null;
@@ -49,8 +83,12 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
49
83
  if (plat) warn(`unknown platform '${platform}' — using ${detected}`);
50
84
  plat = detected;
51
85
  }
86
+ // A repo can have multiple domain owners. `domain_owners` is the array of record; `domain_owner`
87
+ // is kept as the first element so anything still reading the legacy single-owner field works.
88
+ const owners = (domain_owners && domain_owners.length) ? domain_owners : (domain_owner ? [domain_owner] : []);
52
89
  const repo = {
53
- name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat, domain_owner, default_branch,
90
+ name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
91
+ domain_owner: owners[0] || '', domain_owners: owners, default_branch,
54
92
  connectedAt: today, lastSyncedAt: today,
55
93
  syncedHead: head,
56
94
  contextPack: `.sdlc/code-context/${name}/pack.md`,
@@ -226,9 +264,20 @@ export async function runSetup(root, opts = {}) {
226
264
  const login = await ask(' reviewer platform login (blank to finish)', '');
227
265
  if (!login) break;
228
266
  const name = await ask(' yad name', login);
229
- const role = await ask(' role (owner/reviewer/domain-owner)', 'reviewer');
230
- const email = await ask(' commit email (verified-commits gate; blank to skip)', '');
231
- roster.push({ login, name, role, ...(email ? { email } : {}) });
267
+ const email = await ask(' commit email (committer→login lookup + verified-commits gate; blank to skip)', '');
268
+ // Per-scope roles: capture the hub roles here; per-repo roles are added in step 7 when the
269
+ // repo is connected. A person can hold several roles (owner reviewer) at once.
270
+ const hubRoles = parseList(await ask(' hub roles (owner/reviewer, space-separated)', 'reviewer'));
271
+ const entry = { login, name, ...(email ? { email } : {}), roles: { hub: hubRoles } };
272
+ // Validate the login exists on the hub — warn-only (fail-open): a miss is flagged unverified
273
+ // but still saved. `checked:false` (no CLI/auth) skips the check silently.
274
+ if (platform !== 'none') {
275
+ const v = validateLogin(platform, login);
276
+ if (v.checked && !v.exists) { warn(`'${login}' not found on ${platform} — saved as unverified`); entry.unverified = true; }
277
+ else if (v.checked && v.exists) ok(`verified ${login} on ${platform}`);
278
+ }
279
+ if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
280
+ roster.push(entry);
232
281
  }
233
282
  }
234
283
  const default_branch = platform === 'none' ? 'main' : await ask('Hub default branch', 'main');
@@ -306,10 +355,15 @@ export async function runSetup(root, opts = {}) {
306
355
  if (!insideRoot(root, rpath)) { warn(`${rpath} resolves outside the project root — skipped`); continue; }
307
356
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
308
357
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
309
- const domain_owner = await ask(' domain owner', '');
358
+ // A repo can have one or more domain owners; reviewers/owners can also be scoped to it. Names
359
+ // refer to roster `name`s; each grant is written into that person's per-scope roles map.
360
+ const domain_owners = parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
361
+ const repoReviewers = parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
362
+ const repoOwners = parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
310
363
  const default_branch = await ask(' default branch', 'main');
311
- const repo = registerRepo(root, registry, { name, rpath, platform, domain_owner, default_branch, today: opts.today ?? null });
364
+ const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null });
312
365
  if (!repo) continue;
366
+ addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
313
367
  known.add(name);
314
368
  ok(`registered ${name}`);
315
369
  packRepo(root, repo);