yadflow 2.16.0 → 2.16.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.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,9 @@
1
- # [2.16.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.15.0...v2.16.0) (2026-06-25)
1
+ ## [2.16.1](https://github.com/abdelrahmannasr/yadflow/compare/v2.16.0...v2.16.1) (2026-06-25)
2
2
 
3
3
 
4
- ### Features
4
+ ### Bug Fixes
5
5
 
6
- * make hub pr-title/pr-template gates branch-aware so tooling PRs pass ([#79](https://github.com/abdelrahmannasr/yadflow/issues/79)) ([68050e0](https://github.com/abdelrahmannasr/yadflow/commit/68050e000010b4d98f304a7e5e1f6a39bc0c229c))
6
+ * **open-pr:** make build helpers stage-aware on the hub (closes [#80](https://github.com/abdelrahmannasr/yadflow/issues/80)) ([#81](https://github.com/abdelrahmannasr/yadflow/issues/81)) ([8d74e3d](https://github.com/abdelrahmannasr/yadflow/commit/8d74e3d267d4c056992d3bc6f5a2a7a15a66b431))
7
7
 
8
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
9
 
package/README.md CHANGED
@@ -98,8 +98,8 @@ with `npx` from your **product hub** repo — no clone needed.
98
98
  | `yad gate status <epic>` | Show each review step and its recorded approvals. |
99
99
  | `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`). |
100
100
  | `yad commit --type <t> -m <subject>` | Commit by the SDLC convention — Conventional subject, `Task`/`Contract-Change`/`Co-Authored-By` trailers, atomic-file guard. |
101
- | `yad open-pr [--repo <name>]` | Open a code-repo **task** PR/MR from the repo's platform template (build half). |
102
- | `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`). |
101
+ | `yad open-pr [--repo <name>]` | Open a **task** PR/MR from the platform template (build half). **Stage-aware on the hub:** a `review/EP-*` branch opens the front-half artifact-review PR (delegates to `yad gate open`); any other hub branch uses the code-task template (so hub tooling PRs pass the `pr-template` gate). |
102
+ | `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`) — stage-aware, same as `open-pr`. |
103
103
  | `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. |
104
104
  | `yad repo sync [name]` | Switch every connected repo to its **default branch** and fast-forward it from origin (one or all). Dirty repos are skipped, never overwritten; fast-forward only. |
105
105
  | `npx yadflow --version` | Print the installed CLI version. |
package/bin/yad.mjs CHANGED
@@ -56,8 +56,10 @@ ${c.bold('Review gate (front half)')}
56
56
 
57
57
  ${c.bold('Build helpers')}
58
58
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
59
- yad open-pr [--repo <name>] Open a code-repo task PR/MR from the template
60
- yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step
59
+ yad open-pr [--repo <name>] Open a task PR/MR stage-aware on the hub: a review/EP-*
60
+ branch opens the front-half artifact-review PR (delegates to
61
+ gate open), any other hub branch uses the code-task template
62
+ yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
61
63
  yad repo list Show connected repos (fresh / stale)
62
64
  yad repo refresh [name] Re-pack a stale repo (a human decision)
63
65
 
@@ -181,7 +183,7 @@ async function main() {
181
183
  // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
182
184
  // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
183
185
  // CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
184
- if (action === 'open') await gateOpen(o.dir, { epic, artifact, today });
186
+ if (action === 'open') await gateOpen(o.dir, { epic, artifact });
185
187
  else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
186
188
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
187
189
  else if (action === 'status') await gateStatus(o.dir, { epic });
package/cli/commit.mjs CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, fail, run, exists } from './lib.mjs';
8
8
  import {
9
9
  COMMIT_TYPES, AI_COAUTHORS, ATOMIC_FILE_LIMIT,
10
- TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER,
10
+ TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER, PROJECT_FILES,
11
11
  } from './manifest.mjs';
12
12
 
13
13
  // PURE — unit tested directly. Build the full commit message text.
@@ -51,7 +51,14 @@ export async function runCommit(root, opts = {}) {
51
51
 
52
52
  const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root }).stdout;
53
53
  const task = opts.task || taskFromBranch(branch);
54
- if (!task) warn('no Task trailer (none given and branch has no -S0N-T0N) — spec-link gate will fail on a code repo');
54
+ if (!task) {
55
+ // spec-link is a code-repo gate (REPO_WIRING.common), not a hub gate — so a missing Task trailer
56
+ // is expected on a hub PR (front-half artifact review or hub tooling) and only matters on a repo.
57
+ const onHub = exists(path.join(root, PROJECT_FILES.hubConfig));
58
+ warn(onHub
59
+ ? 'no Task trailer (none given and branch has no -S0N-T0N) — fine for a hub PR; required on code-repo tasks (spec-link gate)'
60
+ : 'no Task trailer (none given and branch has no -S0N-T0N) — spec-link gate will fail on a code repo');
61
+ }
55
62
 
56
63
  let message;
57
64
  try {
package/cli/gate.mjs CHANGED
@@ -449,7 +449,11 @@ export async function gateStatus(root, { epic } = {}) {
449
449
  }
450
450
  }
451
451
 
452
- export async function gateOpen(root, { epic, artifact } = {}) {
452
+ // `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
453
+ // the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
454
+ // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
455
+ // the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
456
+ export async function gateOpen(root, { epic, artifact, head, creator = createPr } = {}) {
453
457
  const { hub } = loadHub(root);
454
458
  const epicDir = epicRoot(root, epic);
455
459
  const ledger = loadLedger(epicDir);
@@ -458,7 +462,7 @@ export async function gateOpen(root, { epic, artifact } = {}) {
458
462
  const step = findReviewStep(ledger.state, artifact);
459
463
  if (!step) { fail(`no review step for ${artifact}`); process.exitCode = 1; return; }
460
464
  const b = base(artifact);
461
- const branch = `review/${epic}/${b}`;
465
+ const branch = head || `review/${epic}/${b}`;
462
466
  const domains = touchedDomains(epicDir, step);
463
467
  warnUnlockedContract(epicDir, artifact);
464
468
 
@@ -487,7 +491,7 @@ export async function gateOpen(root, { epic, artifact } = {}) {
487
491
  const assignees = committer ? [committer] : [];
488
492
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
489
493
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
490
- const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
494
+ const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
491
495
  if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
492
496
 
493
497
  if (!bridge) {
@@ -498,6 +502,7 @@ export async function gateOpen(root, { epic, artifact } = {}) {
498
502
  hand(bridge
499
503
  ? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
500
504
  : `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
505
+ return { url: r.url };
501
506
  }
502
507
 
503
508
  // ---- helpers ------------------------------------------------------------------------------------
package/cli/openpr.mjs CHANGED
@@ -8,6 +8,8 @@ import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
8
8
  import { PROJECT_FILES } from './manifest.mjs';
9
9
  import { detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
10
10
  import { taskFromBranch } from './commit.mjs';
11
+ import { parseReviewBranch, artifactFromBase } from './epic-state.mjs';
12
+ import { gateOpen } from './gate.mjs';
11
13
 
12
14
  // Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
13
15
  function resolveRepo(root, { repo, dir }) {
@@ -19,11 +21,59 @@ function resolveRepo(root, { repo, dir }) {
19
21
  return { repoRoot: path.resolve(root, dir || '.'), meta: null };
20
22
  }
21
23
 
22
- function templateBody(repoRoot, platform, { task, risk, contract, domains }) {
23
- const tplPath = platform === 'gitlab'
24
- ? path.join(repoRoot, '.gitlab/merge_request_templates/Default.md')
25
- : path.join(repoRoot, '.github/pull_request_template.md');
26
- const base = exists(tplPath) ? fs.readFileSync(tplPath, 'utf8') : '## Summary\n\n## Impact & Risk\n';
24
+ // Which SDLC stage is this PR? The hub serves two vehicles; a code repo only one. Mirrors the
25
+ // `--head` split the hub pattern gates (pr-title.sh/pr-template.sh) already apply:
26
+ // code-repo NOT the product hub (a registry repo via --repo, or root is not a hub).
27
+ // hub-front the hub itself AND head is a review/EP-* branch (artifact-review PR).
28
+ // hub-tooling the hub itself AND head is anything else (a tooling/CI change to the hub).
29
+ // `meta` (truthy when resolved from the repos registry via --repo) is a connected code repo, so it is
30
+ // never the hub regardless of its path. Otherwise "is the hub" = repoRoot resolves to root AND root
31
+ // carries .sdlc/hub.json. path.resolve normalises `--dir .` / trailing slashes.
32
+ export function detectStage(root, repoRoot, head, meta) {
33
+ if (meta) return 'code-repo';
34
+ const isHub = path.resolve(repoRoot) === path.resolve(root)
35
+ && exists(path.join(root, PROJECT_FILES.hubConfig));
36
+ if (!isHub) return 'code-repo';
37
+ return /^review\/EP-[a-z0-9-]+\//.test(head || '') ? 'hub-front' : 'hub-tooling';
38
+ }
39
+
40
+ // The bundled code-task template — the same file `REPO_WIRING` installs into code repos, resolved
41
+ // from the package (mirrors how manifest.mjs reads ../package.json). Used for a hub-tooling PR, whose
42
+ // `.github/pull_request_template.md` is the ARTIFACT-REVIEW template (wrong shape for the code-task
43
+ // hub gate). Falls back to a minimal body that still carries every section the gate requires.
44
+ function codeTaskTemplate(platform) {
45
+ const rel = platform === 'gitlab'
46
+ ? '../skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md'
47
+ : '../skills/yad-pr-template/templates/github/pull_request_template.md';
48
+ try {
49
+ return fs.readFileSync(new URL(rel, import.meta.url), 'utf8');
50
+ } catch {
51
+ return [
52
+ '## Summary', '',
53
+ '## Impact & Risk',
54
+ '- **Domains / repos touched:** <repo>',
55
+ '- **Contract surface touched:** no',
56
+ '- **Risk level:** low',
57
+ '',
58
+ '## Checklist',
59
+ '- [ ] Lint, build, and tests pass (build/test/lint gate)',
60
+ '',
61
+ ].join('\n');
62
+ }
63
+ }
64
+
65
+ export function templateBody(repoRoot, platform, { task, risk, contract, domains, stage }) {
66
+ // hub-tooling: the hub's own template is artifact-review — use the bundled code-task template so the
67
+ // body matches the shape the hub `pr-template` gate demands for a non-review head.
68
+ let base;
69
+ if (stage === 'hub-tooling') {
70
+ base = codeTaskTemplate(platform);
71
+ } else {
72
+ const tplPath = platform === 'gitlab'
73
+ ? path.join(repoRoot, '.gitlab/merge_request_templates/Default.md')
74
+ : path.join(repoRoot, '.github/pull_request_template.md');
75
+ base = exists(tplPath) ? fs.readFileSync(tplPath, 'utf8') : codeTaskTemplate(platform);
76
+ }
27
77
  // Fill the obvious fields; leave the rest of the committed template intact for the author.
28
78
  return base
29
79
  .replace(/EP-<slug>-S0N-T0N/g, task || 'EP-<slug>-S0N-T0N')
@@ -45,6 +95,28 @@ export async function runOpenPr(root, opts = {}) {
45
95
  const baseBranch = opts.base || meta?.default_branch || 'main';
46
96
  if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
47
97
 
98
+ const stage = detectStage(root, repoRoot, branch, meta);
99
+
100
+ // hub-front: this is a front-half artifact-review PR (review/EP-*/<artifact> head on the hub). The
101
+ // artifact-review title, body, and ledger bookkeeping all live in `yad gate open` — delegate to it
102
+ // rather than emit the code-task shape (which the hub gate would reject). Push first (gateOpen does
103
+ // not push), then hand off; any --title/--message is dropped (gateOpen sets `review: …`).
104
+ if (stage === 'hub-front') {
105
+ const parsed = parseReviewBranch(branch);
106
+ if (!parsed) { fail(`could not parse review branch '${branch}' (expected review/EP-<slug>/<artifact>)`); process.exitCode = 1; return; }
107
+ info(`pushing ${branch} …`);
108
+ const fpush = run('git', ['push', '-u', 'origin', branch], { cwd: repoRoot });
109
+ if (!fpush.ok) { fail(`git push failed — ${fpush.stderr.split('\n')[0] || 'unknown'}`); process.exitCode = 1; return; }
110
+ // Pass the branch we just pushed as the head so gateOpen opens the PR against it (its own
111
+ // recompute would collapse a per-story base). gateOpen signals failure by returning no url —
112
+ // mirror open-pr's own error contract so `ship` sees the non-zero exit and never reports success.
113
+ // (On a platform-less hub gateOpen marks the step in_review file-only and returns no url; open-pr's
114
+ // job is to open a PR, so "no PR opened" is a non-zero outcome here, unlike `yad gate open`.)
115
+ const res = await gateOpen(root, { epic: parsed.epic, artifact: artifactFromBase(parsed.base), head: branch });
116
+ if (!res?.url) process.exitCode = 1;
117
+ return res;
118
+ }
119
+
48
120
  // Push the branch (sets upstream) using the user's own auth. Abort on failure — creating a PR for a
49
121
  // branch that is not on the remote just fails with a more confusing error.
50
122
  info(`pushing ${branch} …`);
@@ -54,7 +126,7 @@ export async function runOpenPr(root, opts = {}) {
54
126
  const task = opts.task || taskFromBranch(branch);
55
127
  const title = opts.title || run('git', ['log', '-1', '--format=%s'], { cwd: repoRoot }).stdout || `task ${task || branch}`;
56
128
  const body = templateBody(repoRoot, platform, {
57
- task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name,
129
+ task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name, stage,
58
130
  });
59
131
 
60
132
  // Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.16.0",
3
+ "version": "2.16.1",
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). A BMAD module + 30 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -9,11 +9,17 @@
9
9
  name: yad-hub-checks
10
10
  on:
11
11
  pull_request:
12
+ # `edited` (beyond the opened/synchronize/reopened defaults) so a PR title/body correction
13
+ # re-runs the pattern gates without a close/reopen — e.g. fixing a body the pr-template gate held.
14
+ types: [opened, synchronize, reopened, edited]
12
15
  branches: ["**"]
13
16
 
14
17
  jobs:
18
+ # commit-message + ledger-guard read the commit range, not the PR title/body — skip them on a bare
19
+ # `edited` event (only pr-title/pr-template need to re-check a title/body fix).
15
20
  commit-message:
16
21
  runs-on: ubuntu-latest
22
+ if: github.event.action != 'edited'
17
23
  steps:
18
24
  - uses: actions/checkout@v4
19
25
  with: { fetch-depth: 0 }
@@ -52,6 +58,7 @@ jobs:
52
58
  # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
53
59
  ledger-guard:
54
60
  runs-on: ubuntu-latest
61
+ if: github.event.action != 'edited'
55
62
  steps:
56
63
  - uses: actions/checkout@v4
57
64
  with: { fetch-depth: 0 }
@@ -21,7 +21,8 @@ and `yad-ship` use. It **never auto-advances**; it just commits.
21
21
  `feat|fix|docs|refactor|test|perf|build|ci|chore|revert`; proper nouns/acronyms keep their case.
22
22
  - **Task trailer** — required on a code repo (anchors the `spec-link` + `commit-message` gates). Given
23
23
  with `--task`, else derived from the branch (`feat/<story>-<task>-…`). Hub commits are not
24
- task-scoped, so the trailer is optional there.
24
+ task-scoped, so the trailer is optional there — a missing-Task warning is informational on the hub
25
+ (`spec-link` is a code-repo gate) and only flags a real gate failure in a code repo.
25
26
  - **Contract-Change trailer** — `--contract-change` only when the diff touches the locked contract
26
27
  surface; it routes the change back to the architecture gate.
27
28
  - **AI co-author footer — OFF by default.** No `Co-Authored-By` trailer is written unless `--ai <id>`
@@ -22,6 +22,14 @@ the product hub.
22
22
  `.gitlab/merge_request_templates/Default.md`) with `Task:`, `Risk level:`, `Contract surface
23
23
  touched:`, and `Domains` prefilled; the rest is left for the author. This satisfies the `pr-template`
24
24
  gate.
25
+ - **Stage-aware on the product hub** — `open-pr` mirrors the `--head` split the hub gates apply:
26
+ - a **`review/EP-*/<artifact>`** branch is a front-half artifact-review PR → it **delegates to
27
+ `yad gate open`** (artifact-review title `review: <artifact> (EP-<slug>)`, the hub artifact-review
28
+ body, and the gate ledger bookkeeping all in one place). Any `--title`/`-m` is ignored here.
29
+ - any **other hub branch** is a tooling/CI change → it uses the bundled **code-task** template
30
+ (`## Summary` / `Risk level:` / `## Checklist`) instead of the hub's artifact-review
31
+ `pull_request_template.md`, so the hub `pr-template` gate passes.
32
+ In a code repo nothing changes — it reads the repo's own committed code-task template.
25
33
  - **Auto-assign** — from the hub roster scoped to this repo: assignee = the committer (resolved from
26
34
  the local git identity), reviewers = the repo's `reviewer`/`domain-owner` logins minus the committer.
27
35
  Degrades cleanly when there is no roster.
@@ -22,6 +22,10 @@ its own and **never merges**. The engineer review + merge are Step E (`yad-engin
22
22
  roster auto-assign, risk routing (`../yad-open-pr/SKILL.md`).
23
23
  - **Order matters:** the PR/MR is opened **only if the commit lands**. A failed commit, a tripped
24
24
  atomic guard, or `--dry-run` stops the step before anything is pushed.
25
+ - **Stage-aware on the product hub** (via `yad-open-pr`): on a `review/EP-*` branch `ship` opens the
26
+ front-half **artifact-review** PR (delegating to `yad gate open` — `--title` is ignored); on any
27
+ other hub branch it opens the **code-task** PR from the bundled code-task template. In a code repo
28
+ it is unchanged.
25
29
 
26
30
  ## Inputs
27
31