yadflow 3.15.0 → 3.15.2

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,3 +1,19 @@
1
+ ## [3.15.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.1...v3.15.2) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checks:** exempt a new epic's ledger seed from ledger-guard ([ba923c2](https://github.com/abdelrahmannasr/yadflow/commit/ba923c2a3823e8bf17b2fc59b41f0160a3a11a19)), closes [#162](https://github.com/abdelrahmannasr/yadflow/issues/162)
7
+
8
+ ## [3.15.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.0...v3.15.1) (2026-08-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **checks:** close the silent-PASS holes the [#161](https://github.com/abdelrahmannasr/yadflow/issues/161) fix left open ([0f180ab](https://github.com/abdelrahmannasr/yadflow/commit/0f180abc72618fcd793ef6717fe1e128aa5ccfa5))
14
+ * **checks:** pin every changed contract slice, not just the first ([a79a946](https://github.com/abdelrahmannasr/yadflow/commit/a79a946aa37c08615741082bc638d006f7e80b76)), closes [#161](https://github.com/abdelrahmannasr/yadflow/issues/161)
15
+ * **checks:** read hub.json and the contract lock across line breaks ([43a618d](https://github.com/abdelrahmannasr/yadflow/commit/43a618d0822ed8576b9a164889bc53a52bbdf713)), closes [#161](https://github.com/abdelrahmannasr/yadflow/issues/161)
16
+
1
17
  # [3.15.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.14.0...v3.15.0) (2026-08-10)
2
18
 
3
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.15.0",
3
+ "version": "3.15.2",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -138,6 +138,10 @@ Notes:
138
138
  - Also create an empty approvals ledger `{project-root}/epics/EP-<slug>/.sdlc/approvals.json`
139
139
  and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
140
140
  `[]`, and the `reviews/` directory.
141
+ - Commit the seed on the `analysis/EP-<slug>` branch, and cut `review/EP-<slug>/analysis` from it so the
142
+ epic's **first** review PR/MR carries the ledger to the default branch. In bridge mode `ledger-guard`
143
+ exempts a new epic's ledger (creation, not mutation, #162); every later change to it is CI's. See
144
+ `../yad-epic/references/state-schema.md`, "Authoring branches".
141
145
 
142
146
  ### Step 7 — Stop at the gate (do NOT advance)
143
147
  Report: epic ID, the path to `analysis.md`, and that the next action is **review** via
@@ -72,8 +72,9 @@ the approver(s) + date. Only a `verified: true` backfill spec counts as real.
72
72
 
73
73
  ### Step 5 — `gate` (block changes per touched feature)
74
74
  `bash checks/backfill-check.sh <base>` blocks a change that touches a feature being backfilled until
75
- that feature's spec is `verified: true`. It is **per touched feature** a change touching feature A is
76
- not blocked by an unverified feature B. Forward-spec'd features (those with their own `specs/<story>/`)
75
+ that feature's spec is `verified: true`. `<base>` is optional and resolves like every other gate's
76
+ see "Resolving `<base>`" in `../yad-checks/references/check-gates.md`. It is **per touched feature**
77
+ a change touching feature A is not blocked by an unverified feature B. Forward-spec'd features (those with their own `specs/<story>/`)
77
78
  are not this gate's concern.
78
79
 
79
80
  ### Step 6 — `promote` (flip a stub epic → real, once its spec is approved)
@@ -5,13 +5,37 @@
5
5
  # forward-spec'd (their own specs/<story>/) or not yet being backfilled are not this gate's concern.
6
6
  set -euo pipefail
7
7
 
8
- BASE="${1:-${SDLC_BASE:-origin/main}}"
8
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
9
+ # --- is duplicated, not sourced) ---
10
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
11
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
12
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
13
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
14
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
15
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
16
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
17
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
18
+ # command substitution in an assignment aborts the script.
19
+ resolve_base() {
20
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
21
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
22
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
23
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
24
+ case "$_c" in ''|origin/) continue ;; esac
25
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
26
+ done
27
+ printf '%s' origin/main
28
+ }
29
+
30
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
31
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [backfill]: no base given — diffing against '${BASE}'."
9
32
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
10
33
  echo "FAIL [backfill]: base ref '${BASE}' not found — fetch full history / check the base branch."
11
34
  exit 1
12
35
  fi
13
36
 
14
- changed="$(git diff --name-only "${BASE}..HEAD")"
37
+ # quotePath off so a non-ASCII feature directory still matches src/<feature>/ below.
38
+ changed="$(git -c core.quotePath=false diff --name-only "${BASE}..HEAD")"
15
39
  # Feature = a directory under src/ (src/<feature>/...). Top-level src/*.js files are deliberately NOT
16
40
  # gated here (they belong to no single feature); only src/<feature>/ changes are checked.
17
41
  feats="$(printf '%s\n' "$changed" | sed -nE 's#^src/([^/]+)/.*#\1#p' | sort -u)"
@@ -134,6 +134,12 @@ Seed `.sdlc/approvals.json` with one **provenance** record per inherited gate (N
134
134
  `{ "artifact": "<art>", "step": "<…-review>", "status": "inherited", "from": "<epic>", "boundHash": "<hash>", "date": "<today>" }`.
135
135
  Seed `.sdlc/comments.json` = `[]` and create `reviews/`.
136
136
 
137
+ Commit the seed on the `change/EP-<slug>` branch. It reaches the hub's default branch through this
138
+ change-epic's **first** review PR/MR — cut the `review/EP-<slug>/<artifact>` branch from `change/…` so
139
+ it carries the seed. In bridge mode `ledger-guard` exempts a new epic's ledger (creation, not mutation,
140
+ #162), so no direct push to a protected default branch is needed; every later change to that ledger is
141
+ CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
142
+
137
143
  When `architecture` is **inherited**, materialize the **pointer-lock** `.sdlc/contract-lock.json`:
138
144
  `{ "artifact": "contract.md", "hash": "<parent surface hash, verbatim>", "lockedAt": "<today>", "inheritedFrom": "<epic>", "ref": "../../<epic>/.sdlc/contract-lock.json" }`.
139
145
  There is no `contract.md` in the change-epic, so the surface cannot drift, and `contract-check` passes
@@ -15,6 +15,7 @@ in CI on every PR/MR and must pass before merge (build plan §C). Each is a smal
15
15
  `specs/<story>/contracts/`) without a `Contract-Change: yes` trailer **and** an updated, re-locked
16
16
  contract upstream, it **FAILS and routes back to the architecture gate**. The shared surface is
17
17
  never widened from inside a code repo (Phase 2 contract representation: delimited block + SHA-256 lock).
18
+ Every story whose slice the diff touches is checked, not just the first — see `references/check-gates.md`.
18
19
  3. **build/test/lint** — standard quality stage; tests must actually exercise new behavior, not just pass.
19
20
  The CI job sets `YAD_TEST_MAX_WORKERS` (default `2`); the gate caps jest/vitest test concurrency at
20
21
  that and is a no-op for other runners (see `references/check-gates.md`).
@@ -54,7 +55,11 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
54
55
  when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
55
56
  CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
56
57
  is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
57
- spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. Runs in `yad-hub-checks`
58
+ spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. So is a **new epic's seed**:
59
+ no CI path can create a ledger (`gate ci` only *advances* an existing chain, at merge, on the
60
+ default branch), so an epic whose `.sdlc/state.json` is absent from the base ref may be created by
61
+ a human on its first review PR/MR — **creation, not mutation** (#162). Once the ledger is on the
62
+ default branch the guard is absolute again. Runs in `yad-hub-checks`
58
63
  alongside `verified-commits` (which waives the allowlist for the bot but still requires its
59
64
  signature). See `yad-hub-bridge`.
60
65
  - `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
@@ -141,6 +146,10 @@ bash checks/spec-link.sh "<base>"
141
146
  bash checks/contract-check.sh "<base>"
142
147
  bash checks/build-test-lint.sh
143
148
  ```
149
+ `<base>` is optional for the gates that take one (`build-test-lint` takes none) — omitted, the gate
150
+ resolves the trunk (configured `default_branch`, else `origin/HEAD`, else `origin/main`) and prints
151
+ the base it chose; pass it (or `SDLC_BASE`) when the PR/MR targets another branch.
152
+
144
153
  A non-zero exit is a FAIL. Summarize which gates passed and, for any failure, the exact remediation
145
154
  (spec-link: add the `Task:` trailer / spec; contract-check: route back to the architecture gate and
146
155
  re-lock the contract; build/test/lint: fix the failing lint/test).
@@ -19,6 +19,27 @@ repo uses. Each reads conventions established by earlier steps — it invents no
19
19
  | pr-title | the PR/MR title (from the CI event payload) | `yad-pr-template` (`config.yaml build.pr_title_style`) |
20
20
  | pr-template | the PR/MR body (from the CI event payload) | `yad-pr-template` (the committed PR/MR template) |
21
21
 
22
+ ## Resolving `<base>` (every gate that takes one)
23
+
24
+ The `<base>` argument is **optional**. The order is: the **argument**, else `SDLC_BASE`, else the
25
+ **configured** `default_branch` (`.sdlc/hub.json`, or `SDLC_HUB_CONFIG`), else the remote's
26
+ **published default branch** (`git symbolic-ref refs/remotes/origin/HEAD`), else `origin/main` —
27
+ the same order the CLI resolves (`cli/hubcommit.mjs`, `cli/repo.mjs`), so a gate never diffs a
28
+ different range than the `yad` commands run beside it. Each candidate must actually **resolve**
29
+ before it is used, so a *dangling* `origin/HEAD` (trunk renamed, the old remote-tracking ref pruned)
30
+ falls through instead of failing the gate on a fully-fetched repo. CI always passes the base
31
+ explicitly (`origin/<PR base>` / `origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`), so this governs
32
+ local runs.
33
+
34
+ Hardcoding `origin/main` diffed the wrong range on a repo whose trunk is `develop`/`master` — either
35
+ failing closed for the wrong reason, or, where a stale `main` still existed, silently diffing a range
36
+ that both mis-reports the surface and drags unrelated stories into contract-check's per-story fidelity
37
+ pass (issue #161). An auto-resolved base is **printed as a note**, so the range a local run gated is
38
+ never implicit. Like the `product-repo` block below, this one is duplicated verbatim across the
39
+ scripts (they are standalone by design) and pinned byte-identical by a test — which covers every
40
+ base-taking gate, including `yad-backfill`'s `backfill-check.sh` and the installed copies this repo's
41
+ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
42
+
22
43
  ## 1. spec-link (`templates/checks/spec-link.sh`)
23
44
 
24
45
  - Checks every non-merge commit in `<base>..HEAD` **per commit** (not aggregated across the range),
@@ -38,11 +59,13 @@ repo uses. Each reads conventions established by earlier steps — it invents no
38
59
  - An empty range (no non-merge commits) **PASSes**.
39
60
  - Portable across bash 3.2 (macOS) and 4+ (no `mapfile`).
40
61
  - **Fails closed** when `<base>` can't be resolved (so a shallow clone / wrong base never PASSes blind).
62
+ `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
41
63
 
42
64
  ## 2. contract-check (`templates/checks/contract-check.sh`)
43
65
 
44
66
  - **Fails closed** if `<base>` can't be resolved — an undiffable range must never report "no surface
45
- change" and silently green-light a bypass.
67
+ change" and silently green-light a bypass. `<base>` is optional — see
68
+ [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
46
69
  - Computes the changed files in `<base>..HEAD`.
47
70
  - If **nothing** under `specs/*/contracts/**` changed → **PASS** (normal implementation only *consumes*
48
71
  the contract).
@@ -52,6 +75,19 @@ repo uses. Each reads conventions established by earlier steps — it invents no
52
75
  require `link.md`'s pinned `contract-lock` hash to match the product repo's current
53
76
  `contract-lock.json`. A claimed change that still pins the **old** lock **FAILS** — re-run
54
77
  `yad-spec` so the slice matches the re-locked contract.
78
+ - The fidelity check runs for **every story whose slice the diff touches**, and **aggregates**: each
79
+ story reports (matched / stale / deferred), and any stale pin fails the gate. `git diff
80
+ --name-only` is path-sorted, so reading one story off the first changed path validated whichever
81
+ story sorted first and left the rest unpinned — a later story pinning a stale hash passed, and a
82
+ first story with no `link.md` deferred the whole check before the stale one was ever read
83
+ (issue #161). One clean-or-deferred story never masks another's stale pin, the same rule spec-link
84
+ applies per commit.
85
+ - A lock the gate can **read but not parse** (truncated, half-written, or a changed schema — no
86
+ `"hash": "sha256:…"`) **FAILS**. It used to short-circuit the comparison into the "hash matches"
87
+ note, i.e. the gate affirmatively reported a match it never made.
88
+ - The changed-file list is read with `core.quotePath=false`. With git's default, a path holding a
89
+ non-ASCII byte comes back quoted and octal-escaped, so a slice like `specs/EP-démo-S01/contracts/…`
90
+ never matched the surface pattern and an undeclared widening passed untouched.
55
91
  - This enforces the Phase 2 rule: the shared surface is owned upstream and is never widened from inside
56
92
  a code repo. The hash recipe is in `../yad-architecture/references/contract-format.md`.
57
93
 
@@ -129,6 +165,7 @@ non-merge commit in `<base>..HEAD`:
129
165
  - **Profiles** (`--profile code|hub`): the subject rule is identical on both; the gate never requires
130
166
  the `Task:` trailer (spec-link owns that on code repos; hub commits are not task-scoped).
131
167
  - **Fails closed** when `<base>` can't be resolved.
168
+ `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
132
169
 
133
170
  ## 6. pr-title (`templates/checks/pr-title.sh`)
134
171
 
@@ -319,9 +356,13 @@ line). Code repos run the same three with `--profile code` inside the main `yad-
319
356
 
320
357
  ## Running by hand (Phase 3 is manual)
321
358
 
322
- From inside the code repo, against the PR/MR base (e.g. `master`):
359
+ From inside the code repo, against the PR/MR base (e.g. `master`). For the gates that take one, the
360
+ base argument is optional — omit it and the gate resolves the trunk in the order above (configured
361
+ `default_branch`, else `origin/HEAD`, else `origin/main`), printing the base it chose; pass it (or
362
+ `export SDLC_BASE=…`) whenever the PR targets something else. `build-test-lint` takes no base at all.
323
363
 
324
364
  ```bash
365
+ bash checks/spec-link.sh # -> diffs the resolved trunk, and says which one
325
366
  bash checks/spec-link.sh master
326
367
  bash checks/contract-check.sh master
327
368
  bash checks/build-test-lint.sh
@@ -25,7 +25,30 @@ while [ $# -gt 0 ]; do
25
25
  done
26
26
  case "$PROFILE" in code|hub) ;; *) echo "FAIL [commit-message]: unknown --profile '$PROFILE' (code|hub)."; exit 1 ;; esac
27
27
 
28
- BASE="${ARGS[0]:-${SDLC_BASE:-origin/main}}"
28
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
29
+ # --- is duplicated, not sourced) ---
30
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
31
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
32
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
33
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
34
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
35
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
36
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
37
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
38
+ # command substitution in an assignment aborts the script.
39
+ resolve_base() {
40
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
41
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
42
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
43
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
44
+ case "$_c" in ''|origin/) continue ;; esac
45
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
46
+ done
47
+ printf '%s' origin/main
48
+ }
49
+
50
+ BASE="${ARGS[0]:-${SDLC_BASE:-$(resolve_base)}}"
51
+ [ -n "${ARGS[0]:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [commit-message]: no base given — diffing against '${BASE}'."
29
52
 
30
53
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
31
54
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -8,7 +8,30 @@
8
8
  # architecture gate. Normal implementation that only CONSUMES the contract passes untouched.
9
9
  set -euo pipefail
10
10
 
11
- BASE="${1:-${SDLC_BASE:-origin/main}}"
11
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
12
+ # --- is duplicated, not sourced) ---
13
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
14
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
15
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
16
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
17
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
18
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
19
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
20
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
21
+ # command substitution in an assignment aborts the script.
22
+ resolve_base() {
23
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
24
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
25
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
26
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
27
+ case "$_c" in ''|origin/) continue ;; esac
28
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
29
+ done
30
+ printf '%s' origin/main
31
+ }
32
+
33
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
34
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [contract-check]: no base given — diffing against '${BASE}'."
12
35
 
13
36
  # Fail CLOSED if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
14
37
  # Never let an undiffable range silently report "no surface change" — that would green-light a bypass.
@@ -18,7 +41,10 @@ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
18
41
  fi
19
42
  RANGE="${BASE}..HEAD"
20
43
 
21
- changed="$(git diff --name-only "$RANGE")"
44
+ # core.quotePath=false: with the default ON, git wraps any path holding a non-ASCII byte in quotes
45
+ # and octal-escapes it, so a slice like specs/EP-démo-S01/contracts/api.md never matches the pattern
46
+ # below — the surface change would be invisible to the gate it exists to stop.
47
+ changed="$(git -c core.quotePath=false diff --name-only "$RANGE")"
22
48
  surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts/' || true)"
23
49
 
24
50
  if [ -z "$surface" ]; then
@@ -72,9 +98,22 @@ resolve_product() {
72
98
 
73
99
  # Fidelity check (best-effort): when the product repo is reachable, the story's link.md must pin the
74
100
  # CURRENT product lock — proof the contract was actually updated/re-locked upstream, not just flagged.
75
- story="$(printf '%s\n' "$surface" | head -1 | sed -E 's#^specs/([^/]+)/contracts/.*#\1#')"
76
- link="specs/${story}/link.md"
77
- if [ -f "$link" ]; then
101
+ #
102
+ # Checked for EVERY story whose slice the diff touches, not just the first one. `git diff --name-only`
103
+ # is path-sorted, so reading a single story off `head -1` validated whichever story sorted first and
104
+ # left the rest unpinned: a second story pinning a STALE hash passed, and a first story with no
105
+ # link.md deferred the whole check before the stale one was ever read (issue #161). Failures are
106
+ # AGGREGATED — every story reports, so one clean-or-deferred story never masks another's stale pin
107
+ # (the same rule spec-link applies per commit).
108
+ stories="$(printf '%s\n' "$surface" | sed -E 's#^specs/([^/]+)/contracts/.*#\1#' | sort -u)"
109
+ rc=0
110
+ while IFS= read -r story; do
111
+ [ -z "$story" ] && continue
112
+ link="specs/${story}/link.md"
113
+ if [ ! -f "$link" ]; then
114
+ echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
115
+ continue
116
+ fi
78
117
  product_rel="$(link_val product-repo "$link")"
79
118
  pinned="$(printf '%s' "$(link_val contract-lock "$link")" | sed -E 's/^sha256:([0-9a-f]+).*$/\1/')"
80
119
  epic="$(printf '%s' "$story" | sed -E 's/-S[0-9]+$//')" # story EP-<slug>-S0N -> epic EP-<slug>
@@ -85,20 +124,40 @@ if [ -f "$link" ]; then
85
124
  lock=""
86
125
  [ -n "$prod" ] && lock="${prod}/epics/${epic}/.sdlc/contract-lock.json"
87
126
  if [ -n "$product_rel" ] && [ -f "$lock" ]; then
88
- current="$(sed -nE 's/.*"hash":[[:space:]]*"sha256:([0-9a-f]+)".*/\1/p' "$lock" | head -1)"
89
- if [ -n "$current" ] && [ "$current" != "$pinned" ]; then
127
+ # Newline-tolerant, first-match: `"hash":` and its value may legally sit on separate lines, and an
128
+ # unparseable lock is a FAIL below so a formatting choice must not become a gate failure.
129
+ # `|| current=""` is load-bearing: under `pipefail` a no-match grep fails the whole pipeline, which
130
+ # under `set -e` would abort the gate instead of reaching the unparseable-lock FAIL below.
131
+ current="$(tr '\n' ' ' < "$lock" | grep -oE '"hash"[[:space:]]*:[[:space:]]*"sha256:[0-9a-f]+"' | head -1 | sed -E 's/.*sha256:([0-9a-f]+)"$/\1/')" || current=""
132
+ # A lock we can READ but cannot PARSE proves nothing, and an empty `current` used to short-circuit
133
+ # the comparison below straight into the "hash matches" note — the gate affirmatively reporting a
134
+ # match it never made. Fail closed instead: a truncated, half-written or schema-changed lock is a
135
+ # broken lock, and a Contract-Change is being claimed against it.
136
+ if [ -z "$current" ]; then
137
+ echo "FAIL [contract-check]: ${lock} has no readable \"hash\": \"sha256:…\" value —"
138
+ echo " the lock cannot prove ${link}'s pin. Re-lock the contract upstream (yad-architecture Step 5)."
139
+ rc=1
140
+ continue
141
+ fi
142
+ if [ "$current" != "$pinned" ]; then
90
143
  echo "FAIL [contract-check]: Contract-Change claimed, but ${link} still pins ${pinned:0:12}…"
91
144
  echo " while the product lock is ${current:0:12}… — re-run yad-spec so the slice matches the re-locked contract."
92
- exit 1
145
+ rc=1
146
+ continue
93
147
  fi
94
- echo "note [contract-check]: link.md hash matches the product lock (${current:0:12}…)."
148
+ echo "note [contract-check]: ${link} hash matches the product lock (${current:0:12}…)."
95
149
  else
96
150
  # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
97
151
  # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
98
152
  echo "note [contract-check]: product lock not reachable at ${lock:-<no product-repo in link.md>} — fidelity check deferred."
99
153
  fi
100
- else
101
- echo "note [contract-check]: no ${link} — fidelity check deferred (spec-link gates the link itself)."
154
+ done <<EOF
155
+ $stories
156
+ EOF
157
+
158
+ if [ "$rc" != 0 ]; then
159
+ echo "FAIL [contract-check]: a changed slice pins a stale contract lock (see above) — the surface was not re-locked upstream for every story in this diff."
160
+ exit 1
102
161
  fi
103
162
 
104
163
  echo "PASS [contract-check]: surface change accompanied by Contract-Change: yes (and an updated contract)."
@@ -11,7 +11,30 @@
11
11
  # Fails CLOSED on an unresolvable base.
12
12
  set -euo pipefail
13
13
 
14
- BASE="${1:-${SDLC_BASE:-origin/main}}"
14
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
15
+ # --- is duplicated, not sourced) ---
16
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
17
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
18
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
19
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
20
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
21
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
22
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
23
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
24
+ # command substitution in an assignment aborts the script.
25
+ resolve_base() {
26
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
27
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
28
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
29
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
30
+ case "$_c" in ''|origin/) continue ;; esac
31
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
32
+ done
33
+ printf '%s' origin/main
34
+ }
35
+
36
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
37
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [epic-open]: no base given — diffing against '${BASE}'."
15
38
 
16
39
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
17
40
  echo "FAIL [epic-open]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -11,6 +11,8 @@
11
11
  # NOT protected:
12
12
  # epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
13
13
  # `gate open`, so a human legitimately commits it alongside the architecture artifact.
14
+ # A brand-new epic's ledger — CREATION is not mutation (#162). No CI path can seed one, so the
15
+ # seed rides the first review PR/MR; see the carve-out below. Mutation stays bot-only.
14
16
  #
15
17
  # A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
16
18
  # platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
@@ -32,7 +34,30 @@ if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space
32
34
  exit 0
33
35
  fi
34
36
 
35
- BASE="${1:-${SDLC_BASE:-origin/main}}"
37
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
38
+ # --- is duplicated, not sourced) ---
39
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
40
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
41
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
42
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
43
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
44
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
45
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
46
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
47
+ # command substitution in an assignment aborts the script.
48
+ resolve_base() {
49
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
50
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
51
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
52
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
53
+ case "$_c" in ''|origin/) continue ;; esac
54
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
55
+ done
56
+ printf '%s' origin/main
57
+ }
58
+
59
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
60
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [ledger-guard]: no base given — diffing against '${BASE}'."
36
61
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
37
62
  echo "FAIL [ledger-guard]: base ref '${BASE}' not found — fetch full history / check the base branch."
38
63
  exit 1
@@ -91,19 +116,81 @@ trusted_bot() {
91
116
  signature_verified "$1"
92
117
  }
93
118
 
119
+ # ---- seeding carve-out: creation is not mutation (#162) ---------------------------------------
120
+ # A brand-new epic's ledger has no CI author. `gate ci` only ADVANCES an existing chain — it bails on
121
+ # a missing state.json ("the review branch is cut from the default branch, so it should carry it") and
122
+ # writes only at merge, on the default branch — and the engine itself reads a missing state.json as
123
+ # "not seeded yet". `gate open` writes nothing in bridge mode, and `checkpoint` stages back-half
124
+ # ledgers only. So the seed the authoring skills write (yad-epic / yad-change / yad-analysis /
125
+ # yad-discovery / yad-stub) can reach the trunk ONLY through the first review PR/MR — the one place
126
+ # this gate runs. Guarding it there makes the documented flow unshippable on a protected trunk, so an
127
+ # epic whose ledger is absent from the BASE ref is exempt: that ledger is human-authored by
128
+ # construction and the reviewer sees the whole of it in the diff. The moment its state.json is on the
129
+ # base ref the guard is absolute again.
130
+ #
131
+ # Anchored on state.json — the ledger root, the same "is this epic seeded?" question the engine asks —
132
+ # NOT on the individual file: adding hub-prs.json to an epic that IS on the base ref still FAILs.
133
+ # Anchored on the BASE ref, not the parent commit, so delete-then-re-add cannot reset the exemption
134
+ # (the deletion is itself a guarded change).
135
+ #
136
+ # The slug match is CASE-FOLDED on purpose. Git paths are byte-exact, but macOS/Windows checkouts are
137
+ # not: seeding `epics/ep-x/.sdlc/state.json` beside an on-base `epics/EP-X/` would probe as a brand-new
138
+ # epic, pass, and then land ON TOP of the real ledger in every case-insensitive clone — a mutation
139
+ # laundered as a creation. So the base's seeded slugs are read once (one ls-tree, not one probe per
140
+ # path) and compared folded.
141
+ #
142
+ # Slugs are held in ARRAYS read from NUL-delimited git output, never in a space- or newline-delimited
143
+ # string: git permits a newline inside a path, and a `EP-<newline>x` slug split across two records
144
+ # would drop the real epic out of the on-base list and let a mutation through as a "creation". Both
145
+ # arrays carry one empty sentinel element so `"${a[@]}"` is safe under `set -u` on bash 3.2 (macOS),
146
+ # which has no associative arrays; a slug is never empty, so the sentinel can never match.
147
+ base_slugs=("") # every epic with a ledger on BASE, lowercased
148
+ base_slugs_loaded=0
149
+ noted_slugs=("") # slugs already announced, so the note prints once each
150
+ fold() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
151
+ in_list() { # $1 = needle, $2… = haystack
152
+ _needle="$1"; shift
153
+ for _item in "$@"; do [ "$_item" = "$_needle" ] && return 0; done
154
+ return 1
155
+ }
156
+ is_seeding() { # $1 = epic slug; 0 when that epic has no ledger on BASE
157
+ if [ "$base_slugs_loaded" = 0 ]; then
158
+ while IFS= read -r -d '' _p; do
159
+ case "$_p" in
160
+ epics/*/.sdlc/state.json)
161
+ _s="${_p#epics/}"; _s="${_s%%/*}"
162
+ base_slugs[${#base_slugs[@]}]="$(fold "$_s")"
163
+ ;;
164
+ esac
165
+ done < <(git -c core.quotePath=false ls-tree -r --name-only -z "${BASE}" -- epics 2>/dev/null || true)
166
+ base_slugs_loaded=1
167
+ fi
168
+ _f="$(fold "$1")"
169
+ in_list "$_f" "${base_slugs[@]}" && return 1
170
+ in_list "$_f" "${noted_slugs[@]}" && return 0
171
+ noted_slugs[${#noted_slugs[@]}]="$_f"
172
+ echo "note [ledger-guard]: epics/$1 has no ledger on ${BASE} — new epic, its seed is exempt (creation, not mutation)."
173
+ return 0
174
+ }
175
+
94
176
  violations=0
95
177
  for sha in $commits; do
96
178
  touches_ledger=0
97
- while IFS= read -r f; do
179
+ # quotePath=false + -z on both git reads: a path git chose to escape ("epics/EP-caf\303\251/…") and a
180
+ # path holding a newline both match no arm below, so the gate would fail OPEN on exactly the paths it
181
+ # is meant to guard. NUL is the only byte a git path cannot contain.
182
+ while IFS= read -r -d '' f; do
98
183
  [ -n "$f" ] || continue
99
184
  case "$f" in
100
185
  epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
101
186
  epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
187
+ _slug="${f#epics/}"; _slug="${_slug%%/*}"
188
+ is_seeding "$_slug" && continue # a new epic's seed — not a mutation of a CI-owned ledger
102
189
  touches_ledger=1
103
190
  echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
104
191
  ;;
105
192
  esac
106
- done < <(git diff-tree --no-commit-id --name-only -r "$sha")
193
+ done < <(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -z "$sha")
107
194
  if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
108
195
  violations=$((violations + 1))
109
196
  fi
@@ -111,7 +198,18 @@ done
111
198
 
112
199
  if [ "$violations" -gt 0 ]; then
113
200
  echo "FAIL [ledger-guard]: ${violations} commit(s) change CI-owned gate files without a verified gate-bot signature. The ledger is CI-owned — let CI sync the gate; do not commit .sdlc/*.json or reviews/*.md yourself."
201
+ # An epic's seed is exempt only while its ledger is off the base ref. Once the first review PR merges
202
+ # (squashed or rebased, so the SHAs differ), those same seed commits still sitting on a sibling
203
+ # authoring branch read as mutations — the author did nothing wrong and the remedy is a rebase, so
204
+ # name it rather than leaving them with "do not commit the ledger yourself".
205
+ echo "hint [ledger-guard]: if these are seed commits from an already-merged review PR, rebase this branch onto the updated ${BASE} — the seed carve-out applies only until the ledger is on the base ref."
114
206
  exit 1
115
207
  fi
116
- echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
208
+ # Say WHICH rule passed the range: claiming "every change is a bot commit" would be false on a range
209
+ # whose only ledger change was a human seed the carve-out let through.
210
+ if [ "${#noted_slugs[@]}" -gt 1 ]; then # >1: the sentinel element is always there
211
+ echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit or a new epic's seed."
212
+ else
213
+ echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
214
+ fi
117
215
  exit 0
@@ -10,7 +10,30 @@
10
10
  # best-effort: the commit PASSes with a note (spec-link already proved the story link).
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [lineage-check]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
16
39
  echo "FAIL [lineage-check]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -10,7 +10,30 @@
10
10
  # ci/chore/build/test exempt. Fails CLOSED on an unresolvable base.
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [reconcile-debt]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
16
39
  echo "FAIL [reconcile-debt]: base ref '${BASE}' not found — fetch full history / check the base branch."
@@ -10,7 +10,30 @@
10
10
  # range), so the report names every offending commit.
11
11
  set -euo pipefail
12
12
 
13
- BASE="${1:-${SDLC_BASE:-origin/main}}"
13
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
+ # --- is duplicated, not sourced) ---
15
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
18
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
21
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
22
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
23
+ # command substitution in an assignment aborts the script.
24
+ resolve_base() {
25
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
+ case "$_c" in ''|origin/) continue ;; esac
30
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
31
+ done
32
+ printf '%s' origin/main
33
+ }
34
+
35
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
36
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [spec-link]: no base given — diffing against '${BASE}'."
14
37
 
15
38
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
16
39
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -26,7 +26,30 @@
26
26
  # GITLAB_TOKEN (or SDLC_API_TOKEN) with read_api scope; see the pipeline fragment header.
27
27
  set -euo pipefail
28
28
 
29
- BASE="${1:-${SDLC_BASE:-origin/main}}"
29
+ # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
30
+ # --- is duplicated, not sourced) ---
31
+ # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
32
+ # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
33
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
34
+ # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
35
+ # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
36
+ # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
37
+ # candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
38
+ # so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
39
+ # command substitution in an assignment aborts the script.
40
+ resolve_base() {
41
+ # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
42
+ _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
43
+ _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
44
+ for _c in "origin/${_cfg}" "${_head}" origin/main; do
45
+ case "$_c" in ''|origin/) continue ;; esac
46
+ if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
47
+ done
48
+ printf '%s' origin/main
49
+ }
50
+
51
+ BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
52
+ [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [verified-commits]: no base given — diffing against '${BASE}'."
30
53
 
31
54
  # Fail closed if the base ref can't be resolved (shallow clone / wrong base branch / unfetched ref).
32
55
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
@@ -55,7 +55,9 @@ jobs:
55
55
  body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
56
56
  bash checks/pr-template.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$body"
57
57
 
58
- # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
58
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/{state,approvals,comments,hub-prs}.json
59
+ # or reviews/*.md (.sdlc/contract-lock.json is artifact-side and allowed). The one other exception is a
60
+ # brand-new epic's seed, which no CI path can write, so it rides this first review PR (#162).
59
61
  ledger-guard:
60
62
  runs-on: ubuntu-latest
61
63
  if: github.event.action != 'edited'
@@ -44,7 +44,9 @@ yad-hub-pr-template:
44
44
  - body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
45
45
  - bash checks/pr-template.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$body"
46
46
 
47
- # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
47
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/{state,approvals,comments,hub-prs}.json
48
+ # or reviews/*.md (.sdlc/contract-lock.json is artifact-side and allowed). The one other exception is a
49
+ # brand-new epic's seed, which no CI path can write, so it rides this first review MR (#162).
48
50
  yad-hub-ledger-guard:
49
51
  extends: .yad_hub_mr_only
50
52
  needs: []
@@ -112,6 +112,10 @@ Notes:
112
112
  never escalates to domain owners (no contract surface is touched yet).
113
113
  - Also create an empty approvals ledger `.sdlc/approvals.json` and comments ledger
114
114
  `.sdlc/comments.json`, each containing `[]`, and the `reviews/` directory.
115
+ - Commit the seed on the `discovery/EP-discovery` branch, and cut `review/EP-discovery/discovery` from
116
+ it so the **first** review PR/MR carries the ledger to the default branch. In bridge mode
117
+ `ledger-guard` exempts a new epic's ledger (creation, not mutation, #162); every later change to it
118
+ is CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
115
119
 
116
120
  ### Step 6 — Stop at the gate (do NOT advance)
117
121
  Report: the path to the discovery set, and that the next action is **review** via `yad-review-gate`
@@ -2382,9 +2382,9 @@
2382
2382
  "license": "MIT"
2383
2383
  },
2384
2384
  "node_modules/js-yaml": {
2385
- "version": "4.3.0",
2386
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
2387
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
2385
+ "version": "4.3.1",
2386
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
2387
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
2388
2388
  "dev": true,
2389
2389
  "funding": [
2390
2390
  {
@@ -152,6 +152,10 @@ Notes:
152
152
  - `test-cases` / `test-cases-review` are a **parallel, non-blocking track**: they seed `blocked` and open
153
153
  when `stories-review` passes — at which point the epic is already `ready-for-build`, so the build half
154
154
  runs alongside the tester. They never gate `ready-for-build` (see `references/state-schema.md`).
155
+ - Commit the seed on this step's authoring branch. It reaches the hub's default branch through the
156
+ epic's **first** review PR/MR — cut `review/EP-<slug>/epic` from the authoring branch so it carries
157
+ the seed. In bridge mode `ledger-guard` exempts a new epic's ledger (creation, not mutation, #162);
158
+ every later change to it is CI's. See `references/state-schema.md`, "Authoring branches".
155
159
  - Also create an empty approvals ledger `{project-root}/epics/EP-<slug>/.sdlc/approvals.json`
156
160
  and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
157
161
  `[]`, and the `reviews/` directory. (`comments.json` is the machine-readable counterpart to the
@@ -94,6 +94,16 @@ The shared procedure (run once the `EP-<slug>` is known):
94
94
  hub's default branch (`git checkout -b <step>/EP-<slug>`).
95
95
  3. Author and commit the step's artifact(s) on that branch. The bridge's `review/…` branch is created
96
96
  separately at review time and is untouched by this step.
97
+
98
+ **How the seed reaches the default branch.** The `.sdlc/` ledger is seeded once, by hand, on the
99
+ **entry** step's authoring branch (`analysis/…`, `epic/…`, `change/…`, `discovery/…`) — no CLI or CI
100
+ path creates one (`yad gate ci` only *advances* an existing chain, at merge, on the default branch).
101
+ So for the **first** gate of an epic, cut `review/EP-<slug>/<artifact-base>` from that authoring
102
+ branch: the review PR/MR then carries the seed alongside the artifact, and the ledger lands on the
103
+ default branch when it merges. In bridge mode `ledger-guard` exempts exactly this case — **creation,
104
+ not mutation** (#162) — so no direct push to a protected default branch is needed. For every **later**
105
+ gate the ledger is already on the default branch: cut the review branch from there, commit the
106
+ artifact only, and leave `.sdlc/{state,approvals,comments,hub-prs}.json` and `reviews/*.md` to CI.
97
107
  | `type` | `author` \| `review+approve` | Authoring step or a team review gate. |
98
108
  | `artifact` | filename or folder | The file/folder this step produces or gates. |
99
109
  | `assistance` | `none` \| `review` \| `heavy` | Dial 1 — how much AI helps (build plan §2). |
@@ -52,7 +52,10 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
52
52
  ### Step 2 — `open` (create the review PR/MR)
53
53
  1. From the hub default branch, create `review/EP-<slug>/<artifact-base>` and ensure the artifact file
54
54
  (and, for architecture, `contract.md` + `.sdlc/contract-lock.json`) is committed on it. Push as the
55
- local user.
55
+ local user. **First gate of a new epic:** cut the review branch from the **authoring** branch
56
+ (`epic/…`, `change/…`, `analysis/…`, `discovery/…`) instead, so it carries the `.sdlc/` **seed** —
57
+ that PR/MR is the only way a new epic's ledger reaches the default branch, and `ledger-guard`
58
+ exempts it (see step 4).
56
59
  2. Open the PR/MR with `gh`/`glab` using the hub body template (`yad-pr-template` `templates/hub/…`),
57
60
  filled with the epic, artifact, gate step, owner, `epic.repos`, and the step's risk tags.
58
61
  3. **Request the required reviewers** (their logins) and add a `domain:<repo>` label per touched repo so
@@ -68,7 +71,9 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
68
71
  A human commit touching the gate-state files (`.sdlc/{state,approvals,comments,hub-prs}.json` or
69
72
  `reviews/*.md`; `.sdlc/contract-lock.json` is artifact-side and allowed) on a review PR is rejected
70
73
  by the `ledger-guard` check. (The `yad gate open` CLI behaves the same: in bridge mode it opens the
71
- PR only and writes no ledger.)
74
+ PR only and writes no ledger.) The **one** exception is a brand-new epic's **seed** — no CI path can
75
+ create a ledger, so an epic whose `.sdlc/state.json` is absent from the base ref may be created by a
76
+ human on this first PR/MR (#162). Every later change to it is CI's alone.
72
77
  5. Report the PR/MR URL and the required reviewers. **Do not** record approvals or advance — reviewers
73
78
  act on the platform; CI (`yad gate ci`) reconciles it onto the default branch at merge.
74
79
 
@@ -143,7 +148,8 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
143
148
  - **Protect the hub default branch.** Require that `epics/**` artifacts change only through a review
144
149
  PR/MR (branch protection). This keeps revoke-on-change sound: it removes the only window where a
145
150
  delayed reconcile could advance on an out-of-band post-merge artifact change (see `references/bridge.md`,
146
- "Known limitation").
151
+ "Known limitation"). Safe to require: a new epic's `.sdlc/` seed rides its first review PR/MR, so
152
+ nobody needs a direct push to the default branch to start an epic (#162).
147
153
  - **Degrade gracefully.** No platform / disabled bridge / no CLI → the gate runs file-only with no error.
148
154
 
149
155
  ## Reference
@@ -155,7 +155,9 @@ During review CI writes nothing: the platform PR/MR is the source of truth (nati
155
155
  threads). The CLI is self-sufficient at merge: it derives the epic + artifact from the
156
156
  `review/EP-<slug>/<artifact-base>` head branch, takes the PR/MR number from the event (GitHub) or
157
157
  resolves it from the platform (GitLab), upserts the `hub-prs.json` entry itself, and **re-reads
158
- approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch.
158
+ approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch. (It only
159
+ *advances* a chain, though: it cannot **create** one. A brand-new epic's seed therefore travels the
160
+ other way — up through its first review PR/MR; see "the seed of a new epic" below.)
159
161
 
160
162
  | Platform event | Phase | CI action |
161
163
  |---|---|---|
@@ -174,13 +176,26 @@ commit — the advance plus the `draft → approved` status flip — lands on th
174
176
  check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
175
177
  .json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). Under Path B **no
176
178
  CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
177
- edit — which it rejects. (The `verified-commits` gate still vets every commit's signature + author;
179
+ edit — which it rejects, with one carve-out for a new epic's seed (below). (The `verified-commits`
180
+ gate still vets every commit's signature + author;
178
181
  its gate-bot exemption is now vestigial in-PR because CI no longer commits there.) `yad gate open`
179
182
  opens the PR only; local `yad gate sync` is advisory in bridge mode (writes nothing). After a merge,
180
183
  everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
181
184
  these guards are no-ops.)
182
185
 
183
- **The one sanctioned human ledger write: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
186
+ **The one sanctioned human ledger write *in a review PR*: the seed of a new epic.** `gate ci` only
187
+ **advances** an existing chain — it bails on a missing `state.json`, and the engine reads that absence
188
+ as "not seeded yet" — so no CI path can ever create a ledger. The seed the authoring skills write
189
+ (`yad-epic`, `yad-change`, `yad-analysis`, `yad-discovery`, `yad-stub`) can reach the default branch
190
+ only through the epic's **first** review PR/MR, which is exactly where `ledger-guard` runs. So the gate
191
+ exempts **creation**: an epic whose `.sdlc/state.json` is absent from the PR's base ref may have its
192
+ ledger written by a human there (#162). It stays a narrow carve-out — the probe is against the base
193
+ ref, not the parent commit, so deleting an on-trunk `state.json` to "re-seed" it is itself a rejected
194
+ mutation, and the moment the ledger is on the default branch every further change is CI's alone. Cut
195
+ that first review branch from the authoring branch (`epic/…`, `change/…`) so it carries the seed; no
196
+ direct push to a protected default branch is needed.
197
+
198
+ **The one sanctioned human ledger write *on the default branch*: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
184
199
  authoring step stranded behind a review gate that already advanced) by writing `state.json` alone. This
185
200
  is not a `ledger-guard` gap: the repair commits to the **default branch**, where `ledger-guard` — which
186
201
  only inspects review PRs — never runs, and where the `yad-update-guard` (platform-Verified signature +
@@ -176,7 +176,9 @@ PR only — against the `review/<epic>/<artifact>` branch, which must already ex
176
176
  records this skill describes. The skill's
177
177
  job is the human half: presenting the artifact, helping the owner address comments, and narrating the
178
178
  gate. Local `yad gate sync` is advisory in bridge mode (reads the platform, prints status, writes
179
- nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it).
179
+ nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it). The single
180
+ exception is an epic's **seed** — no CI path can create a ledger, so a brand-new epic's `.sdlc/` rides
181
+ its **first** review PR/MR, cut from the authoring branch (creation, not mutation, #162).
180
182
 
181
183
  Under that CLI the gate **advances on merge**: a review PR/MR whose reviewer rule is satisfied, whose
182
184
  comment threads are **all resolved**, and which has been **merged** auto-marks the step `done` and
@@ -115,6 +115,11 @@ into normal authoring with zero re-seeding.
115
115
  Also create the empty ledgers `{.sdlc/approvals.json}` and `{.sdlc/comments.json}` (each `[]`) and the
116
116
  `reviews/` directory. **Do NOT** write a `contract-lock.json` — a stub has no locked surface yet.
117
117
 
118
+ Commit the seed on this step's authoring branch; it reaches the hub's default branch through the
119
+ epic's **first** review PR/MR (or, for a stub, the PR that carries the stub itself). In bridge mode
120
+ `ledger-guard` exempts a new epic's ledger — creation, not mutation (#162) — while every later change
121
+ to it stays CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
122
+
118
123
  ### Step 6 — Stop; hand off (NO auto-advance)
119
124
  Report the new `EP-<slug>`, that it is a **stub (backfill pending)**, and the two next moves:
120
125
  - **File bugs now:** `yad-change` (`--parent EP-<slug>`, `kind: defect|change`) — the defect threads off