yadflow 3.19.0-next.2 → 4.0.0-next.3

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 (158) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +160 -22
  3. package/bin/commands.mjs +46 -0
  4. package/bin/yad.mjs +506 -117
  5. package/cli/artifact-status.mjs +30 -11
  6. package/cli/capture.mjs +417 -0
  7. package/cli/checkpoint.mjs +47 -27
  8. package/cli/claims.mjs +165 -0
  9. package/cli/codeowners-command.mjs +170 -0
  10. package/cli/codeowners.mjs +397 -0
  11. package/cli/commit.mjs +13 -9
  12. package/cli/dial.mjs +183 -0
  13. package/cli/docs.mjs +88 -32
  14. package/cli/doctor.mjs +1682 -90
  15. package/cli/epic-state.mjs +3460 -211
  16. package/cli/epic.mjs +506 -0
  17. package/cli/errors.mjs +3 -0
  18. package/cli/fold.mjs +247 -0
  19. package/cli/gate-local.mjs +296 -0
  20. package/cli/gate.mjs +977 -183
  21. package/cli/history.mjs +556 -0
  22. package/cli/hook.mjs +264 -53
  23. package/cli/hubcommit.mjs +9 -19
  24. package/cli/index-command.mjs +87 -0
  25. package/cli/ledger.mjs +54 -4
  26. package/cli/lib.mjs +161 -9
  27. package/cli/manifest.mjs +376 -38
  28. package/cli/migrate.mjs +683 -52
  29. package/cli/mode.mjs +170 -0
  30. package/cli/next.mjs +365 -86
  31. package/cli/openpr.mjs +193 -34
  32. package/cli/owners.mjs +266 -0
  33. package/cli/people.mjs +657 -0
  34. package/cli/plan.mjs +466 -125
  35. package/cli/platform.mjs +109 -128
  36. package/cli/product-index.mjs +287 -0
  37. package/cli/protection.mjs +706 -0
  38. package/cli/reconcile.mjs +40 -12
  39. package/cli/repo-publish.mjs +24 -26
  40. package/cli/repo.mjs +23 -14
  41. package/cli/report.mjs +24 -14
  42. package/cli/review.mjs +19 -22
  43. package/cli/riskmap-command.mjs +289 -0
  44. package/cli/riskmap.mjs +373 -0
  45. package/cli/setup.mjs +127 -279
  46. package/cli/ship.mjs +6 -5
  47. package/cli/skill.mjs +180 -0
  48. package/cli/skip.mjs +211 -30
  49. package/cli/thread.mjs +41 -16
  50. package/cli/tidy.mjs +18 -18
  51. package/cli/update-commit.mjs +21 -21
  52. package/cli/usage.mjs +115 -109
  53. package/package.json +3 -3
  54. package/skills/sdlc/config.yaml +149 -70
  55. package/skills/sdlc/module-help.csv +24 -24
  56. package/skills/yad-analysis/SKILL.md +121 -59
  57. package/skills/yad-architecture/SKILL.md +30 -18
  58. package/skills/yad-architecture/references/contract-format.md +8 -5
  59. package/skills/yad-backfill/SKILL.md +10 -4
  60. package/skills/yad-backfill/references/backfill.md +8 -1
  61. package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
  62. package/skills/yad-change/SKILL.md +119 -42
  63. package/skills/yad-change/references/triage.md +42 -27
  64. package/skills/yad-checks/SKILL.md +68 -35
  65. package/skills/yad-checks/references/check-gates.md +310 -85
  66. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  67. package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
  68. package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
  69. package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
  70. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  71. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
  72. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  73. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  74. package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
  75. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  76. package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
  77. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  78. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
  79. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  80. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
  81. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  82. package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
  83. package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
  84. package/skills/yad-commit/SKILL.md +4 -4
  85. package/skills/yad-connect-design/SKILL.md +5 -5
  86. package/skills/yad-connect-design/references/design-context.md +1 -1
  87. package/skills/yad-connect-design/references/design-registry.md +2 -2
  88. package/skills/yad-connect-docs/SKILL.md +11 -11
  89. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  90. package/skills/yad-connect-learning/SKILL.md +5 -5
  91. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  92. package/skills/yad-connect-repos/SKILL.md +66 -45
  93. package/skills/yad-connect-repos/references/code-context.md +4 -4
  94. package/skills/yad-connect-repos/references/hub-config.md +47 -51
  95. package/skills/yad-connect-repos/references/repos-registry.md +8 -7
  96. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  97. package/skills/yad-connect-testing/SKILL.md +5 -5
  98. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  99. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  100. package/skills/yad-defects/SKILL.md +7 -7
  101. package/skills/yad-discovery/SKILL.md +131 -94
  102. package/skills/yad-discovery/references/discovery-schema.md +22 -6
  103. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  104. package/skills/yad-docs/SKILL.md +13 -8
  105. package/skills/yad-docs/references/data-mapping.md +9 -7
  106. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  107. package/skills/yad-docs-overview/SKILL.md +30 -15
  108. package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
  109. package/skills/yad-docs-sync/SKILL.md +10 -5
  110. package/skills/yad-docs-sync/references/staleness.md +8 -7
  111. package/skills/yad-engineer-review/SKILL.md +81 -17
  112. package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
  113. package/skills/yad-epic/SKILL.md +173 -92
  114. package/skills/yad-epic/references/state-schema.md +610 -94
  115. package/skills/yad-hub-bridge/SKILL.md +57 -38
  116. package/skills/yad-hub-bridge/references/bridge.md +94 -67
  117. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  118. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  119. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  120. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  121. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
  122. package/skills/yad-implement/SKILL.md +27 -13
  123. package/skills/yad-implement/references/implement-conventions.md +2 -2
  124. package/skills/yad-learn/SKILL.md +9 -9
  125. package/skills/yad-learn/references/learning-state.md +2 -2
  126. package/skills/yad-open-pr/SKILL.md +61 -26
  127. package/skills/yad-pair-review/SKILL.md +7 -5
  128. package/skills/yad-pair-review/references/session-state.md +1 -1
  129. package/skills/yad-pr-template/SKILL.md +47 -26
  130. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  131. package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
  132. package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
  133. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  134. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  135. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  136. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
  137. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
  138. package/skills/yad-reconcile/SKILL.md +3 -3
  139. package/skills/yad-report/SKILL.md +5 -5
  140. package/skills/yad-review-companion/SKILL.md +6 -3
  141. package/skills/yad-review-gate/SKILL.md +203 -93
  142. package/skills/yad-review-gate/references/gating.md +232 -54
  143. package/skills/yad-run/SKILL.md +81 -51
  144. package/skills/yad-run/references/run-loop.md +61 -39
  145. package/skills/yad-ship/SKILL.md +15 -11
  146. package/skills/yad-spec/SKILL.md +21 -6
  147. package/skills/yad-spec/references/spec-handoff.md +17 -5
  148. package/skills/yad-status/SKILL.md +115 -50
  149. package/skills/yad-stories/SKILL.md +37 -21
  150. package/skills/yad-stories/references/story-schema.md +7 -6
  151. package/skills/yad-stub/SKILL.md +58 -47
  152. package/skills/yad-sync-repos/SKILL.md +2 -2
  153. package/skills/yad-test-cases/SKILL.md +32 -23
  154. package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
  155. package/skills/yad-timeline/SKILL.md +8 -7
  156. package/skills/yad-ui/SKILL.md +41 -18
  157. package/cli/roster.mjs +0 -164
  158. package/skills/sdlc/install.sh +0 -68
@@ -11,10 +11,10 @@ repo uses. Each reads conventions established by earlier steps — it invents no
11
11
  | spec-link | the `Task: <story>-<task>` commit trailer; `specs/<story>/link.md` | `yad-implement` (trailer), `yad-spec` (link.md) |
12
12
  | contract-check | changed files under `specs/<story>/contracts/`; the `Contract-Change: yes` trailer; `link.md`'s pinned `contract-lock`; the product repo's `contract-lock.json` | `yad-architecture` (lock), `yad-spec` (slice + link), `yad-implement` (trailer) |
13
13
  | build/test/lint | the repo's configured package manager running `lint` / `build` / `test` | the repo |
14
- | lineage-check | the `Task:` trailer → `link.md` (`epic` + `product-repo`); the owning epic's `kind`/`parent` frontmatter in the hub | `yad-spec` (link.md), `yad-change` (lineage frontmatter) |
15
- | epic-open | the `Task:` trailer → `link.md` → the hub epic's `stories/*.md` `status:` (sealed = all `shipped`) | `yad-engineer-review` (story status), `yad-change` (the change-epic) |
16
- | reconcile-debt | the `Task:` trailer → `link.md` → the hub epic's `thread`; every thread epic's `reconcile-debt.json` | `yad-change` (opens hotfix debt) |
17
- | verified-commits | each commit's platform signature-verification status; the author email vs `.sdlc/verified-authors` | hub roster `email` fields (`yad check --fix` generates the allowlist) |
14
+ | lineage-check | the `Task:` trailer → `link.md` (`epic` + `product-repo`); the owning epic's work-item type (`kind:`, then `type:`) and `parent` frontmatter in the Product | `yad-spec` (link.md), `yad-change` (lineage frontmatter) |
15
+ | epic-open | the `Task:` trailer → `link.md` → the Product epic's `stories/*.md` `status:` (sealed = all `shipped`) | `yad-engineer-review` (story status), `yad-change` (the change-epic) |
16
+ | reconcile-debt | the `Task:` trailer → `link.md` → the Product epic's `thread`; every thread epic's `reconcile-debt.json` | `yad-change` (opens hotfix debt) |
17
+ | verified-commits | each commit's platform signature-verification status | the platform (GitHub/GitLab "Verified"); no author allowlist since E62 |
18
18
  | commit-message | each non-merge commit's subject + trailer block | `yad-commit` / `CONTRIBUTING.md` (`config.yaml build.commit_subject_style`) |
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) |
@@ -66,7 +66,36 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
66
66
  - **Fails closed** if `<base>` can't be resolved — an undiffable range must never report "no surface
67
67
  change" and silently green-light a bypass. `<base>` is optional — see
68
68
  [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
69
+ - **Refuses any symlink or submodule under `specs/`** (E115), right after the base check and before
70
+ any surface rule. The gate reads
71
+ paths, and a link lets the content live where no path under `specs/` names it: a symlink at
72
+ `specs`, `specs/<story>` or `specs/<story>/contracts` hid the slice from every rule, and so did every
73
+ later edit to the link's target. A symlinked `link.md` redirects the lock pin the fidelity check
74
+ reads. The check reads the **tree at HEAD** (`git ls-tree -r -z --full-tree HEAD`), not the diff, so
75
+ a link merged before this check existed fails **every PR** in the repo until one PR removes it. It
76
+ matches `specs` **without case**: on macOS and Windows `Specs/` is the same folder, and git's path
77
+ filter matches exact bytes only. A top folder spelled any other way than `specs` (`Specs/`, `SPECS/`)
78
+ is refused too, even when it holds plain files — the surface rules below are spelled in lowercase and
79
+ would never see them. `ſpecs` (long s) counts as a second spelling: APFS folds it into `specs`.
80
+ The same holds further down: a `contracts` folder spelled any other way (`Contracts/`, `CONTRACTS/`,
81
+ `contractſ/`) is refused, and so are two story folders that differ only in ASCII case or the long s
82
+ (`specs/EP-x-S01/` and `specs/ep-x-s01/`) — one folder on a Mac, two to rules that read exact bytes.
83
+ Other letters are **not** caught: `specs/EP-démo-S01/` beside `specs/EP-DÉMO-S01/`, or the same name in
84
+ NFC and NFD form, is one folder on a Mac and two here. The surface rule still matches both, so the
85
+ trailer is still required; at worst the lock check reads no `link.md` and is deferred — something a
86
+ PR can already do by leaving `link.md` out (E117).
87
+ A file named exactly `specs` is refused too: the folder has to go there.
88
+ **So a code repo that already has a `Specs/` folder or submodule (an iOS test folder, say), or a
89
+ top-level file named `specs` in any case, fails every PR until it is renamed** — on a Mac checkout,
90
+ `yad-spec` writing `specs/<story>/` would land inside it. `SPECS.md`, `Specs-old/` and a nested
91
+ `foo/specs/` are not that folder and pass. A git that cannot read the tree fails the gate with a line
92
+ that says so.
93
+ `yad-spec` writes real files, so nothing legitimate lives there as a link. Removing a link under
94
+ `contracts/` is a change to the surface and needs `Contract-Change: yes`; on a lockless epic it takes
95
+ the removal hatch below. `yad doctor` warns `checks:symlink-blind` for an older copy without this check.
69
96
  - Computes the changed files in `<base>..HEAD`.
97
+ - The surface is `specs/<story>/contracts` **and** everything under it: a path that IS
98
+ `specs/<story>/contracts` (a link being removed) counts too.
70
99
  - If **nothing** under `specs/*/contracts/**` changed → **PASS** (normal implementation only *consumes*
71
100
  the contract).
72
101
  - If the surface slice changed:
@@ -85,9 +114,20 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
85
114
  - A lock the gate can **read but not parse** (truncated, half-written, or a changed schema — no
86
115
  `"hash": "sha256:…"`) **FAILS**. It used to short-circuit the comparison into the "hash matches"
87
116
  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.
117
+ - The changed-file list is read with `git diff --no-renames --name-only -z` (E114):
118
+ - `--no-renames` lists a rename by **both** paths — a delete of the old one and an add of the new.
119
+ Without it git names a rename by its new path only, so `git mv specs/S1/contracts/api.md
120
+ docs/api.md` took the slice off the surface and passed with no `Contract-Change` trailer.
121
+ - `-z` (NUL-separated) means git never quotes a path. By default git quotes a path holding a
122
+ non-ASCII byte, and it quotes one holding a `"` or a tab even with `core.quotePath=false` — so a
123
+ slice like `specs/EP-démo-S01/contracts/…` never matched the surface pattern.
124
+ - The script runs with `LC_ALL=C` (bytes, not characters), so a file name that is not valid UTF-8
125
+ cannot make GNU grep drop the list on Linux CI.
126
+ - The list of **deleted** slice paths (the lockless escape hatch above) is built the same way, so
127
+ moving a slice out of `contracts/` under a lockless epic is still a removal.
128
+ - `yad doctor` warns `checks:rename-blind` when an installed `checks/contract-check.sh` (or
129
+ `checks/backfill-check.sh`) in the Product or a connected repo still lists a rename by one path —
130
+ a copy changed by hand, which `yad check --fix` keeps.
91
131
  - This enforces the Phase 2 rule: the shared surface is owned upstream and is never widened from inside
92
132
  a code repo. The hash recipe is in `../yad-architecture/references/contract-format.md`.
93
133
 
@@ -117,7 +157,7 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
117
157
  release; generated files remain managed instead of accumulating consumer-specific edits. The
118
158
  variable is read by the code repo's `yad-checks` workflow only (on GitHub the build/test/lint job;
119
159
  in the GitLab fragment it sets the `node:` image of every `yad-*` gate job, which all share one
120
- anchor) — the hub-side workflows run the `yad` CLI, not the repo's toolchain, and keep their own
160
+ anchor) — the Product-side workflows run the `yad` CLI, not the repo's toolchain, and keep their own
121
161
  pinned Node. A declared `packageManager` needs
122
162
  Corepack, which ships with Node 18 through 24 (Node 25+ dropped it) and, before 18.20.7 / 20.19 /
123
163
  22.14, carries registry keys too old to verify anything published since 2025 — so the override
@@ -150,30 +190,39 @@ runner. Real repos substitute their own eslint/tsc/jest — the gate only calls
150
190
 
151
191
  ## 4. verified-commits (`templates/checks/verified-commits.sh`)
152
192
 
153
- No unverified commits from unverified users reach merge — on the product hub and on every connected
154
- repo. For each commit in `<base>..HEAD`, two independent checks:
193
+ No unsigned commits reach merge — on the Product and on every connected repo. For each commit in
194
+ `<base>..HEAD`, one check:
155
195
 
156
196
  - **Verified signature** — the platform must mark the commit's signature verified (the GitHub/GitLab
157
197
  "Verified" badge: signed with a GPG/SSH key registered to the account owning the author email).
158
198
  Read via `gh api repos/{owner}/{repo}/commits/<sha>` (GitHub) or the commits/signature API (GitLab).
159
- - **Known author** — the commit's **author email** must appear in `.sdlc/verified-authors`, generated
160
- by `yad check --fix` from the hub roster's `email`/`emails` fields plus hub.json's
161
- `verified_authors` list (edit hub.json, never the generated file). Only the author is checked:
162
- platform-generated squash commits keep the PR author (who is on the roster). Two identities are
163
- **allowlist-waived but still signature-covered**: the `yad-gate-sync` bot, and any **merge commit**
164
- (2+ parents) — a merge's author is whoever pressed merge (often a platform noreply), not a roster
165
- human, and its content already passed the PR gate suite. This waiver matters for the push-on-default
166
- `yad-update-guard` (§9), which — unlike this PR-triggered gate — sees merge commits.
167
- A merge commit is **additionally signature-waived when it introduces no content of its own** (its
168
- combined diff — `git diff-tree --cc` — is empty, i.e. no conflict-resolution or evil-merge hunks):
169
- every change it carries already lives in an individually author+signature-checked parent, so there
170
- is nothing to protect. This unblocks **self-hosted GitLab**, which does not sign UI-created merge
171
- commits (the signature API returns 404) — without it every routine merge would red the branch. A
172
- merge that *does* introduce content of its own still requires a verified signature (fail-closed),
173
- so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
174
-
175
- Degradation is explicit, never silent: a missing allowlist SKIPs the author check with a warning
176
- (configure roster emails, re-wire); no GitHub/GitLab remote SKIPs the signature check (the badge is a
199
+
200
+ **There is no author allowlist** (E62). yadflow keeps no list of people: write access to the repo
201
+ decides who can author, and the signature proves which platform account made the commit. `yad check --fix`
202
+ and `yad setup` no longer generate `.sdlc/verified-authors`, and no gate reads it or hub.json's
203
+ `verified_authors` any more (`SDLC_VERIFIED_AUTHORS` is gone too). When an old `.sdlc/verified-authors`
204
+ file is still on disk, the gate prints:
205
+
206
+ ```
207
+ note [verified-commits]: .sdlc/verified-authors is no longer read — write access to the repo decides who can author; the signature is still required.
208
+ ```
209
+
210
+ `yad doctor` warns `people:verified-authors-unused` for the same leftover data; nothing deletes it.
211
+ The old allowlist waivers for the `yad-gate-sync` bot and for merge commits are gone too, because there
212
+ is no allowlist left to waive. A bot commit inside the checked range needs a Verified signature like any
213
+ other commit.
214
+
215
+ **Content-free merge exemption** (unchanged). A merge commit (2+ parents) is **signature-waived when it
216
+ introduces no content of its own** (its combined diff — `git diff-tree --cc` — is empty, i.e. no
217
+ conflict-resolution or evil-merge hunks): every change it carries already lives in an individually
218
+ signature-checked parent, so there is nothing to protect. This unblocks **self-hosted GitLab**, which
219
+ does not sign UI-created merge commits (the signature API returns 404) — without it every routine merge
220
+ would red the branch. A merge that *does* introduce content of its own still requires a verified
221
+ signature (fail-closed), so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
222
+ This matters for the push-on-default `yad-update-guard` (§9), which — unlike this PR-triggered gate —
223
+ sees merge commits.
224
+
225
+ Degradation is explicit, never silent: no GitHub/GitLab remote SKIPs the signature check (the badge is a
177
226
  platform concept — this keeps local runs and tests meaningful); an unreachable platform API **fails
178
227
  closed** with guidance. GitLab CI needs a `GITLAB_TOKEN`/`SDLC_API_TOKEN` variable with `read_api` —
179
228
  `CI_JOB_TOKEN` cannot read the signature API.
@@ -193,8 +242,8 @@ non-merge commit in `<base>..HEAD`:
193
242
  `COMMIT_TYPES`) and **no trailing period** — mirroring `cli/commit.mjs` `buildCommitMessage`.
194
243
  - **Trailers**, when present, appear in the fixed order `Task → Contract-Change → Co-Authored-By`.
195
244
  - Merge/squash commits (2+ parents) are skipped — their platform-generated subjects are not authored.
196
- - **Profiles** (`--profile code|hub`): the subject rule is identical on both; the gate never requires
197
- the `Task:` trailer (spec-link owns that on code repos; hub commits are not task-scoped).
245
+ - **Profiles** (`--profile code|hub|product`): the subject rule is identical on both; the gate never requires
246
+ the `Task:` trailer (spec-link owns that on code repos; Product commits are not task-scoped).
198
247
  - **Fails closed** when `<base>` can't be resolved.
199
248
  `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
200
249
 
@@ -209,14 +258,20 @@ from the event payload):
209
258
  - `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
210
259
  - `review/EP-*` head (or no `--head` — stays strict) → a Shape artifact-review title
211
260
  `review: <artifact> (EP-<slug>)`, the shape `yad gate open` creates.
212
- - any other head → a tooling/code change to the hub itself, so it follows the `code` convention (a
213
- Conventional-Commits subject). This is what lets a PR that changes the hub's own workflows/checks
261
+ - any other head → a tooling/code change to the Product itself, so it follows the `code` convention (a
262
+ Conventional-Commits subject). This is what lets a PR that changes the Product's own workflows/checks
214
263
  pass — it has no EP artifact to review.
215
264
  - **Anti-bypass guard.** The branch name alone is not trusted: a non-review head that actually
216
- changes Shape artifacts (any path under `epics/**`) **FAILS** — those changes must go through
265
+ changes Shape artifacts (any path under `epics/**` or `foundation/**`) **FAILS** — those changes must go through
217
266
  a `review/EP-*` PR and the artifact-review workflow. CI passes the PR's changed paths via
218
267
  `--changed <file>` (computed from the diff against the base ref); without that list (a direct
219
- by-hand caller) the guard is inert and the branch split alone applies.
268
+ by-hand caller) the guard is inert and the branch split alone applies. A step owner file
269
+ (`epics/<epic>/.sdlc/owners/<step>.json`, `foundation/.sdlc/owners/…`, written by `yad assign`,
270
+ E47) is not a Shape artifact: a PR that changes only owner files passes on any branch. The
271
+ workflow builds the list with `--no-renames` (and `core.quotePath=false`), so a rename INTO
272
+ `owners/` still lists the artifact it removed. A path git still C-quotes (a `"`, `\`, tab or
273
+ newline in its name) counts as an artifact change and never as an owner file. Both greps run with
274
+ `LC_ALL=C grep -a`, so a path with bytes that are not valid UTF-8 is never dropped from the list.
220
275
 
221
276
  ## 7. pr-template (`templates/checks/pr-template.sh`)
222
277
 
@@ -228,11 +283,11 @@ catches a free-form description that bypassed it:
228
283
  - `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
229
284
  - `review/EP-*` head (or no `--head`) → requires the artifact-review template: `## Artifact under
230
285
  review`, `## Impact & Risk (front-half)` (or `(Shape)`), `## Checklist`, and a `Risk tags:` line.
231
- - any other head → a hub tooling PR, so it requires the `code` task template (`## Summary`,
286
+ - any other head → a Product tooling PR, so it requires the `code` task template (`## Summary`,
232
287
  `## Impact & Risk`, `## Checklist`, filled `Risk level:`).
233
288
  - **Anti-bypass guard** (same as pr-title): a non-review head that changes Shape artifacts
234
- (`epics/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
235
- must go through a `review/EP-*` PR.
289
+ (`epics/**` or `foundation/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
290
+ must go through a `review/EP-*` PR. Step owner files alone do not count (E47).
236
291
 
237
292
  **GitLab truncates the description this gate reads.** `$CI_MERGE_REQUEST_DESCRIPTION` stops at **2700
238
293
  characters**, so a long but perfectly valid MR can lose a required section *before the gate sees it* —
@@ -251,14 +306,14 @@ both shipped:
251
306
  After the contract locks and code ships, a change must not mutate a locked artifact — it becomes a new
252
307
  epic threaded to its parent (`config.yaml` `change:`). These three gates keep that discipline. All three
253
308
  resolve the owning epic the same way: `Task:` trailer → `specs/<story>/link.md` (`epic` + `product-repo`)
254
- → the hub epic. All **fail closed** on an unresolvable base; all are **per commit**; `ci|chore|build|test`
309
+ → the Product epic. All **fail closed** on an unresolvable base; all are **per commit**; `ci|chore|build|test`
255
310
  commits **with no `Task:` trailer** are exempt — like spec-link, the exemption waives the requirement
256
311
  for an owning epic, never the validity of one that is claimed, so a maintenance subject cannot buy a
257
- pass past the sealed-epic / orphan-thread / frozen-thread checks. When the **product hub is not reachable** from CI (the usual case for a code-repo
258
- PR), each degrades to a **PASS-with-note** — the hub-side check (`yad doctor` / `yad reconcile`) covers
312
+ pass past the sealed-epic / orphan-thread / frozen-thread checks. When the **Product is not reachable** from CI (the usual case for a code-repo
313
+ PR), each degrades to a **PASS-with-note** — the Product-side check (`yad doctor` / `yad reconcile`) covers
259
314
  that path, and spec-link still proves the story link.
260
315
 
261
- **Resolving `product-repo` (shared by all four hub-reading gates, contract-check included).** An
316
+ **Resolving `product-repo` (shared by all four Product-reading gates, contract-check included).** An
262
317
  **absolute** value is used as-is; a **relative** value is joined to the `link.md`'s own directory,
263
318
  `specs/<story>/`, falling back to a repo-root reading when only that resolves (what contract-check
264
319
  historically did, so `link.md` files written for it keep working). The `link.md` itself is read from
@@ -269,9 +324,11 @@ gate now **prints that note**, so a deferred check is never mistaken for a passe
269
324
  duplicated verbatim across the four scripts (they are standalone by design) and a test asserts the
270
325
  four copies stay byte-identical.
271
326
 
272
- - **lineage-check** — reads the hub epic's `kind`/`parent` frontmatter. A `feature` (genesis) epic
327
+ - **lineage-check** — reads the Product epic's work-item type and `parent` frontmatter. The type has
328
+ two names and the gate reads `kind:` first, then `type:` — the same order the CLI uses, asserted by a
329
+ table test in `cli/test-checks.mjs`. A `feature` or `chore` (genesis) epic
273
330
  passes. A `change`/`defect`/`hotfix` epic **FAILS** unless it declares a `parent:` that resolves to a
274
- real `epics/<parent>/` in the hub (no orphan threads). This is the "every code change has an owning
331
+ real `epics/<parent>/` in the Product (no orphan threads). This is the "every code change has an owning
275
332
  epic in a thread" enforcement, layered on spec-link.
276
333
  - **epic-open** — an epic is **sealed** iff it has ≥1 story and **every** `stories/*.md` `status:` is
277
334
  `shipped`. A commit whose owning epic is sealed **FAILS**: new behaviour cannot mutate a shipped epic;
@@ -284,7 +341,7 @@ four copies stay byte-identical.
284
341
  ## 9. yad-update-guard (`templates/github/yad-update-guard.yml`, `templates/gitlab/yad-update-guard.gitlab-ci.yml`)
285
342
 
286
343
  The **integrity gate for direct pushes to the default branch**. `yad update --push` (`cli/update-commit.mjs`)
287
- commits the applied SDLC drift (skills, gate scripts, CI wiring, `verified-authors`) and pushes it
344
+ commits the applied SDLC drift (skills, gate scripts, CI wiring) and pushes it
288
345
  **straight to the default branch with no PR/MR** — so the `pull_request`/`merge_request` gate suite never
289
346
  fires. This workflow is the "skipped from CI **except** verified-commits + the pattern gate" contract: on a
290
347
  **push** to the default branch it runs **only** `verified-commits` and `commit-message` over the pushed
@@ -295,7 +352,7 @@ strictly-good invariant. Normal PR merges sail through — merge commits are pla
295
352
  `commit-message` skips merges. The `yad update --push` commit itself carries **no `[skip ci]`** (unlike the
296
353
  machine-state `yad checkpoint`/`gate ci` commits) precisely so this guard runs on it.
297
354
 
298
- Wired into **every connected repo and the hub** (`REPO_WIRING`/`HUB_WIRING` in `cli/manifest.mjs`). On
355
+ Wired into **every connected repo and the Product** (`REPO_WIRING`/`PRODUCT_WIRING` in `cli/manifest.mjs`). On
299
356
  GitHub it is a self-contained workflow (`.github/workflows/yad-update-guard.yml`, marker `# yad-managed:
300
357
  yad-checks`), gated to the default branch by a job-level `if: github.ref_name ==
301
358
  github.event.repository.default_branch`. On GitLab it is an includable fragment
@@ -307,12 +364,114 @@ include line exists** — `yad update --push` warns when it pushes to a gitlab r
307
364
  lacks it.
308
365
 
309
366
  **Prerequisites / caveats.** Direct pushes to the default branch must be permitted for the committer
310
- (adjust branch protection). The commits `yad update --push` creates must be **signed** and their author
311
- **allowlisted**, or this guard rejects them — `yad update --push` runs a pre-flight that warns when local
312
- commit signing is unset or the operator's git email is not in `.sdlc/verified-authors`. Ordinary PR merges
313
- pass (merge commits are allowlist-waived + Verified), but a **rebase-merge** recreates the PR commits
367
+ (adjust branch protection). The commits `yad update --push` creates must be **signed**, or this guard
368
+ rejects them — `yad update --push` runs a pre-flight that warns when local commit signing is off. Ordinary
369
+ PR merges pass (merge commits are Verified, or content-free and signature-waived), but a **rebase-merge**
370
+ recreates the PR commits
314
371
  without the platform signature, so a rebase-merge team should sign commits or not wire this guard.
315
372
 
373
+ ## 10. risk-map (`templates/checks/risk-map-check.sh`) — advisory
374
+
375
+ Reads the code repo's **risk map**, `.sdlc/risk-map`: one line per directory giving it a level (`high`,
376
+ `medium`, `low`, or `unset`), marked `guessed` (an AI agent filled it) or `confirmed` (a person checked
377
+ it), and **no names** (E65). The format, the rubric and who may change what:
378
+ `../yad-connect-repos/references/risk-map.md`. The rules have a twin in `cli/riskmap.mjs`
379
+ (`yad risk-map check`, `yad doctor`); a test runs both over the same repos and compares the output.
380
+
381
+ It **always exits 0**: the count it prints is reported, never enforced — the platform's branch
382
+ protection holds a Build merge. It cannot apply the capacity cap (E72) either: the cap needs the
383
+ Product's count of active people, which a code repo's CI does not have; `yad open-pr` shows the capped
384
+ count. So a stale map must never block a merge. It prints `WARN [risk-map] <code> <target>: …` for:
385
+
386
+ | Code | When |
387
+ |---|---|
388
+ | `uncovered` | a file this change adds or edits that no line covers — names the directory to add. A directory a line cannot hold (`app/[slug]/`) names its parent instead; a top-level one (`my docs/`) says to rename it |
389
+ | `dead` | a line whose directory holds no file (`./`: no file at the root) — on every change, until fixed |
390
+ | `unset` / `guessed` | a line this change touches that has no level yet, or whose level is still a guess |
391
+ | `unreadable` / `names` / `duplicate` | a line that is not `<dir>/ <level> <state>`, names a person (a word starting `@`, `@org/team` included — a word ending in `/`, like `@types/`, is a directory), or repeats a directory |
392
+ | `header` / `version` | no `# yad-risk-map v1` first line (read as v1), or a newer version (nothing is read) |
393
+ | `map-edited` | the change edits the map itself, which decides how much review later changes need |
394
+
395
+ The change is `git diff --name-only -z --diff-filter=ACMRT <base>...HEAD` (T: a file turned into a symlink is an edit) — **three dots**, measured from
396
+ where the branch left the base, so commits the base gained since are never blamed on this change (the
397
+ blocking gates use two dots; this one only warns about what the change touches). A deleted file is never
398
+ asked about. The repo is `git ls-files -z`. Both lists are read NUL-separated, so a path holding `"`, `\`
399
+ or a tab arrives as it is, never quoted by git. A base that does not resolve, or shares no history with HEAD (a shallow clone), is a note, not a failure:
400
+ the map's own lines are still checked. A repo with no map gets one note and passes — **unless this change deleted
401
+ the map**, which warns `map-edited`: a rename away from `.sdlc/risk-map`, and the map replaced by a folder or by a symlink that leads nowhere or to a folder, count as a delete. A symlink to a real file is not — `[ -f ]` follows it, so the check reads the link's target as the map and says `edits`. Deleting some other file under a *folder* named `.sdlc/risk-map/` gets the plain no-map note, not `map-edited`. Outside a git repo it
402
+ prints a note and exits 0. **Known limits:** a path holding a newline reads differently here than in
403
+ `yad risk-map check` — git's NUL-separated list has to become lines for macOS awk — and such a directory
404
+ cannot have a line anyway. A path whose bytes are not valid UTF-8 differs too: this check compares bytes
405
+ (`LC_ALL=C`), while the CLI decodes bad bytes as U+FFFD. **The map file is not wired** — it is the
406
+ team's, so `yad update` never owns or overwrites it; only the check is.
407
+
408
+ **The count (E66).** Before the warnings it prints one line saying how many approvers the change asks for:
409
+
410
+ ```
411
+ COUNT [risk-map]: 2 approvers = base 1 + high risk 1 (high on origin/main: src/payments/ (guessed)) — reported, not enforced: branch protection holds the merge.
412
+ COUNT [risk-map]: 1 approver = base 1 — nothing this change touches is high on origin/main.
413
+ ```
414
+
415
+ - The map is read from the **base branch's tip** (`git show <base>:.sdlc/risk-map`), never from the
416
+ change's copy — so a change cannot lower its own count by editing or deleting the map. The warnings
417
+ above still read the change's copy, because they are about keeping that copy true.
418
+ - Only a real file on the base is read. A symlink or a folder with that name is treated as no map.
419
+ - The change's files for the count are `git diff --name-only -z --no-renames <base>...HEAD`, **with no
420
+ filter**: a deleted file, and a file moved away, count where they were. Deleting code in a `high`
421
+ directory is a `high` change. (The warnings keep `--diff-filter=ACMRT`: a deleted file cannot be
422
+ uncovered.)
423
+ - A `guessed` level counts as a `confirmed` one. `medium` is printed on its own line and adds nothing.
424
+ An `unset` line, and a file no line covers, add nothing.
425
+ - The base has no map: `1 approver = base 1 — '<base>' has no .sdlc/risk-map, so no directory adds a step.` The base does not resolve,
426
+ shares no history with HEAD, or holds a map for a newer version: **no COUNT line**, and a note that the
427
+ count is unknown, not zero.
428
+
429
+ **Who can meet the ask (E67).** Under the count, the check names the people who have committed in the
430
+ last 30 days in the `high` directories this change touches. With two such directories it is ONE list:
431
+ someone who has worked in any of them meets the ask, and the output never says who worked where.
432
+
433
+ ```
434
+ ask one of these (committed there in the last 30 days, this change's own authors left out): Alice (@alice)
435
+ nobody else has committed there in the last 30 days — the count above still stands.
436
+ who has worked there lately: not read — this is a shallow clone — it does not hold the history of those directories.
437
+ ```
438
+
439
+ - The history is the **base branch's** (`git log <base> --since='30 days ago' --no-merges`), so a change
440
+ cannot add its own; git filters on the **committer** date. The change's own authors are left out,
441
+ because an approval has to come from someone else, and a robot (`…[bot]`) is never listed.
442
+ - **Git applies the map, and no file name is ever read back.** One query runs per `high` directory, in
443
+ map order, carrying pathspecs that say what the map means by it: the directory, minus every listed
444
+ directory below it — the deepest line decides, and each of those answers for itself in its own query
445
+ (an exclude beats a later include, so they cannot share one). `./` is the files AT the root
446
+ (`:(glob)*`, because `*` never spans `/`). Git then returns author records only. Reading a list of
447
+ file names back was what let a quoted path, a name holding a newline, or a merge's simplified path
448
+ list name the wrong person; there is no list to misread now.
449
+ - A person is their git **name**, plus `(@login)` only when the commit address is a platform `noreply`
450
+ one. No e-mail address is ever printed. A name holding an `@` is replaced: by the bare login when
451
+ there is one (`WHO carol @carol`), else by `a name that is an e-mail address` or `a name written like a
452
+ login` — so a printed `@word` is always a real login.
453
+ - A **shallow clone** says "not read", never "nobody": it holds only the newest commits, and reading
454
+ that as "nobody has worked here" would drop the ask instead of raising it. Wired CI checks out the
455
+ full history (`fetch-depth: 0`, `GIT_DEPTH: 0`), so this is a local or host-overridden case.
456
+ - A side branch whose merge kept the other side still counts (`--full-history`): git otherwise simplifies
457
+ a path-filtered log and hides it.
458
+ - **Known limit:** git's date-limited walk stops at the first commit older than the window on a chain,
459
+ so a repo whose commit dates run out of order (a wrong clock, an imported history) can hide people
460
+ behind that commit. It under-lists, so the printed people are always real ones.
461
+
462
+ `risk-map-check.sh --level [<base>]` prints the same result as machine lines (`BASE`, `UNKNOWN`, `NOMAP`,
463
+ `FILES`, `LEVEL`, `DIR <dir> <level> <state>`, `WHO <login|-> <name>`, `HISTNONE` — the history was read
464
+ and held nobody — and `HISTUNKNOWN <why>`) for `checks/risk-route.sh`, which joins it with the PR
465
+ body. One awk program serves both the warnings and the count. Its twin is `changeLevel` in
466
+ `cli/riskmap.mjs`, and a parity test compares the two. **Known limits:** like every check here, the
467
+ script runs from the PR's own checkout, so a PR can edit the script itself; that is harmless only because nothing
468
+ blocks on the count — the Build count stays reported, and branch protection holds the merge (E72). And, as for the warnings, a path holding a newline is
469
+ split in two here but kept whole by `changeLevel`, so the bash count can read its second half as a file
470
+ at the root; the error can only raise the count, never lower it. (The E67 history is free of that class:
471
+ it reads no file names at all.) If git itself fails, each step of the
472
+ count says "not counted"; the E65 warnings half is not guarded the same way, so a failing `git diff` or
473
+ `git ls-files` there can still end the script with git's exit code.
474
+
316
475
  ## CI wiring (both platforms)
317
476
 
318
477
  The gates run identically under either CI; the config just invokes the scripts with the PR/MR base.
@@ -326,8 +485,8 @@ The gates run identically under either CI; the config just invokes the scripts w
326
485
  re-runs `pr-title`/`pr-template`, not the whole suite. The pattern jobs
327
486
  read the title/body from the event payload: `pr-title` takes `${{ github.event.pull_request.title }}`
328
487
  and `pr-template` writes `${{ github.event.pull_request.body }}` to a temp file. All `--profile code`.
329
- The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) run as their own jobs with
330
- `fetch-depth: 0`, the same `origin/${{ github.base_ref }}` base. The build/test/lint checkout also
488
+ The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) and the advisory `risk-map`
489
+ check run as their own jobs with `fetch-depth: 0`, the same `origin/${{ github.base_ref }}` base. The build/test/lint checkout also
331
490
  uses `filter: blob:none`; its installer follows `package.json#packageManager`, and `NX_BASE` /
332
491
  `NX_HEAD` carry the exact base/head SHAs so Nx affected commands evaluate the PR rather than a
333
492
  stale default. `YAD_NODE_VERSION` is read from GitHub repository variables with `22` as the default.
@@ -375,37 +534,43 @@ break nor reorder them; job names are `yad-`prefixed to avoid collisions.
375
534
  **Idempotent.** The two markers plus the include-entry check make a re-run a no-op. This is how a repo
376
535
  that already had its own pipeline keeps it and still gains the gates.
377
536
 
378
- ## Wiring the hub (`repo: hub`)
537
+ ## Wiring the Product (`repo: hub`)
379
538
 
380
- The product hub is itself a repo on a platform (recorded in `.sdlc/hub.json` by
539
+ The Product is itself a repo on a platform (recorded in `.sdlc/hub.json` by
381
540
  `yad-connect-repos action: detect-hub`). `wire repo: hub` targets `{project-root}` and uses the same
382
- merge-not-clobber logic, with a **hub-flavored gate set** appropriate to a "thinking" repo (it has no
383
- `specs/` or `package.json` build):
541
+ merge-not-clobber logic, with a **Product-flavored gate set** appropriate to a "thinking" repo (it has no
542
+ `specs/` or `package.json` build). **What yadflow wires today** (`PRODUCT_WIRING`): `commit-message`,
543
+ `pr-title`, `pr-template` and `ledger-guard` in `yad-hub-checks`, `verified-commits` in its own workflow,
544
+ and the `yad-update-guard`. The three below are **not shipped** — they are scripts a team writes itself if
545
+ it wants them:
384
546
  - **owner-set** — every `epic.md` (and forward artifact) under `epics/EP-*/` carries an `owner`.
385
547
  - **contract-locked** — where an epic has a `contract.md`, its surface hash matches
386
548
  `.sdlc/contract-lock.json` (reuse the recipe in
387
549
  `../yad-architecture/references/contract-format.md`).
388
- - **approvals-present** — an epic at `ready-for-build` has the approvals its gate rule requires recorded
389
- in `.sdlc/approvals.json` (the same predicate `yad-review-gate` enforces).
550
+ - **approvals-present** — an epic at `ready-for-build` has the approvals the gate rule requires recorded
551
+ in `.sdlc/approvals.json`: at least 1 approver (the author is not checked here — the platform's own
552
+ rules stop self-approval on GitHub, and on GitLab when its settings say so; the same predicate
553
+ `yad-review-gate` enforces; the risk step of the full count, capped by the active people (E72), is
554
+ reported beside it and gates nothing).
390
555
 
391
- These are advisory checks on the hub's own PRs (the Shape review PRs the verified ledger opens); they keep
392
- the hub's artifacts internally consistent. The hub never runs the code-repo `spec-link`/`build-test-lint`
393
- gates. Author the hub gate scripts under the hub's `checks/` following the same CI-agnostic-bash pattern.
556
+ These are advisory checks on the Product's own PRs (the Shape review PRs the verified ledger opens); they keep
557
+ the Product's artifacts internally consistent. The Product never runs the code-repo `spec-link`/`build-test-lint`
558
+ gates. Author the Product gate scripts under the Product's `checks/` following the same CI-agnostic-bash pattern.
394
559
 
395
- The hub **does** run the verified-commits gate — `yad check --fix` installs `checks/verified-commits.sh`
560
+ The Product **does** run the verified-commits gate — `yad check --fix` installs `checks/verified-commits.sh`
396
561
  plus a standalone workflow (`templates/github/yad-verified-commits.yml` →
397
562
  `.github/workflows/yad-verified-commits.yml`, or the GitLab fragment
398
563
  `templates/gitlab/yad-verified-commits.gitlab-ci.yml` → `.gitlab/ci/yad-verified-commits.yml` +
399
564
  its one include line) whenever `.sdlc/hub.json` has a platform with a verified ledger. So the
400
- Shape review PRs are held to the same rule as code-repo PRs: signed, known authors only.
565
+ Shape review PRs are held to the same rule as code-repo PRs: platform-Verified signatures only.
401
566
 
402
- The hub **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
567
+ The Product **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
403
568
  `--profile hub`. The pattern gates split by the PR/MR **head branch** (passed via `--head`): a
404
569
  `review/EP-*` head is a Shape review PR — Conventional-Commits commit subjects, a
405
- `review: <artifact> (EP-<slug>)` title, and the hub artifact-review template body; **any other head is
406
- a tooling/code change to the hub itself** and follows the `code` convention (a Conventional-Commits
407
- title + the code task template), so a PR that changes the hub's own workflows/checks can pass.
408
- `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone hub workflow
570
+ `review: <artifact> (EP-<slug>)` title, and the Product artifact-review template body; **any other head is
571
+ a tooling/code change to the Product itself** and follows the `code` convention (a Conventional-Commits
572
+ title + the code task template), so a PR that changes the Product's own workflows/checks can pass.
573
+ `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone Product workflow
409
574
  (`templates/github/yad-hub-checks.yml` → `.github/workflows/yad-hub-checks.yml`, or the GitLab fragment
410
575
  `templates/gitlab/yad-hub-checks.gitlab-ci.yml` → `.gitlab/ci/yad-hub-checks.yml` + its one include
411
576
  line). Code repos run the same three with `--profile code` inside the main `yad-checks` workflow.
@@ -426,11 +591,70 @@ be reverted before the review PR/MR can go green.
426
591
  | exit 0 | allow |
427
592
  | exit 2 | deny — the reason is on stderr, for the agent to read |
428
593
 
429
- Claude Code's `PreToolUse` protocol is exactly that (exit 2 blocks the call and feeds stderr back to
430
- the model), so `.claude/settings.json` wires it with no adapter logic. Any harness that can run a
431
- command and read those two exit codes can use the same script.
594
+ Two harnesses match that contract, and `yad check --fix` wires both:
432
595
 
433
- **Layering.** `hooks/ledger-guard.sh` is only the adapter: it locates `yad` (`$YAD_BIN` → the hub's
596
+ | Harness | File | Event | Command |
597
+ |---|---|---|---|
598
+ | Claude Code | `.claude/settings.json` | `PreToolUse` | `"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"` |
599
+ | Cursor | `.cursor/hooks.json` | `preToolUse` | `hooks/ledger-guard-cursor.sh` |
600
+
601
+ Any other harness that can run a command and read those two exit codes can use `ledger-guard.sh` by
602
+ hand.
603
+
604
+ **Cursor needs a second protocol, and this is the trap.** Its `preToolUse` is a *permission hook*:
605
+ Cursor's docs say that for a permission hook, "invalid JSON or a response that doesn't match the
606
+ hook's schema blocks the action". `ledger-guard.sh` prints **nothing** when it allows — and empty
607
+ stdout is invalid JSON. Wiring it straight into Cursor would therefore have blocked **every** file
608
+ write in a verified project: fail-closed on everything, the opposite of this guard's whole design, and
609
+ invisible to any test that only checks that a deny denies.
610
+
611
+ So Cursor gets `hooks/ledger-guard-cursor.sh`, installed only for a project whose targets include
612
+ `.cursor`. It calls `yad hook ledger-guard --format cursor` through the shared script and guarantees a
613
+ permission answer on stdout, whatever happens:
614
+
615
+ | | stdout | exit |
616
+ |---|---|---|
617
+ | allow | `{"permission":"allow"}` | 0 |
618
+ | deny | `{"permission":"deny","user_message":"<reason>","agent_message":"<reason>"}` | 0 |
619
+
620
+ The field names are Cursor's documented permission-hook schema, all snake_case. Getting that wrong is
621
+ quiet in the worst way: an off-schema response still blocks, so the write is refused and any test that
622
+ checks "a deny denies" passes — while the text naming `yad gate open` is discarded and the agent is
623
+ told only "no".
624
+
625
+ The allow response is a fixed literal with nothing interpolated into it, because a malformed *allow*
626
+ is a block. The deny response carries the reason, and if that field were ever rejected as off-schema
627
+ the response is invalid — which blocks, which is what a deny wanted anyway. Both failure directions
628
+ are safe, in opposite ways, on purpose. A deny exits **0**, not 2, because the JSON is the
629
+ authoritative answer and exit 0 is what tells Cursor to read it; exit 2 blocks too but is documented
630
+ as the code for "no JSON to read", so it would discard the reason — and naming the command that owns
631
+ the transition is the entire point of speaking at edit time. The reason also goes to stderr, where
632
+ Cursor logs it.
633
+
634
+ The wrapper takes **no arguments**, and every fail-open branch of the shared script (no `yad` on
635
+ PATH, an install it cannot resolve) is converted into an explicit `allow` answer rather than the
636
+ empty stdout that would block. **Exit 2 is converted into a deny**, not an allow: `ledger-guard.sh`
637
+ resolves `yad` from the Product's own `node_modules/yadflow` before `PATH`, so a project pinned to a
638
+ yadflow older than `--format` answers in the exit protocol — exit 2 with empty stdout — and treating
639
+ that as "no verdict" would turn a real refusal into a permitted write.
640
+
641
+ The two commands are spelled differently on purpose. Claude Code runs the string through a shell, so
642
+ its entry uses `$CLAUDE_PROJECT_DIR` and is quoted against a project path containing a space. Cursor
643
+ documents that a project hook runs **from the project root** but not whether the command goes through
644
+ a shell — so its entry is a relative path with no variable and no quotes, the one spelling that works
645
+ either way. Every failure mode here is silent and fails open, which would leave `yad doctor`
646
+ truthfully reporting an entry that never refuses anything.
647
+
648
+ Cursor does not document the field names inside `tool_input`, so `payloadPaths` reads any key *named*
649
+ like a path (`file_path`, `target_file`, `filePath`, `paths`) rather than guessing a vendor spelling.
650
+ It matches the key and never the value: scanning values for something shaped like a ledger path would
651
+ refuse an ordinary edit to a document that merely quotes one, and a false deny is worse than a miss in
652
+ a guard that fails open by design.
653
+
654
+ Cursor's wiring follows Cursor's published protocol and has not yet been exercised against a live
655
+ Cursor session.
656
+
657
+ **Layering.** `hooks/ledger-guard.sh` is only the adapter: it locates `yad` (`$YAD_BIN` → the Product's
434
658
  `node_modules/yadflow` → `PATH` → `npx --no-install`) and passes the payload to `yad hook
435
659
  ledger-guard`, which holds the decision. So the wiring never hard-codes an install path, and the
436
660
  logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
@@ -445,7 +669,7 @@ logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
445
669
  - **The seed carve-out reads the base ref, not the working tree** (#162): the epics whose
446
670
  `state.json` the base carries are listed once with `ls-tree`, and an epic absent from that list is
447
671
  a creation. Never a `<rev>:<path>` probe — that spec resolves from the repository top level and
448
- `-C` does not re-anchor it, so a hub in a subdirectory of its repo would miss every time and the
672
+ `-C` does not re-anchor it, so a Product in a subdirectory of its repo would miss every time and the
449
673
  guard would allow everything, silently.
450
674
  - **The base is an `origin/` ref** — `origin/<default_branch>`, then the remote's published default,
451
675
  then `origin/main`, the gate's own order. Never a bare local branch: `git fetch` does not
@@ -458,7 +682,7 @@ logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
458
682
  bridge the ledger is locally owned, the hand-edit the authoring skills describe is correct, and
459
683
  nothing is wired or blocked.
460
684
 
461
- **It fails OPEN, and that asymmetry is the design.** No `yad` on PATH, no hub above the edited path,
685
+ **It fails OPEN, and that asymmetry is the design.** No `yad` on PATH, no Product above the edited path,
462
686
  an unreadable `hub.json`, an unparseable payload, a `yad` that errors — every one of them ALLOWS,
463
687
  with a note on stderr. A local guardrail that failed closed would brick an agent's ability to edit
464
688
  anything the moment an install went sideways. The CI gate fails **closed** and is what actually
@@ -468,19 +692,19 @@ protects the ledger; this only shortens the feedback loop. `YAD_HOOK_DISABLE=1`
468
692
 
469
693
  - A `Bash` tool call (`sed -i epics/…`) is not intercepted; matching it would mean parsing shell for
470
694
  write intent.
471
- - The hook arms sessions **rooted at the hub**. A harness loads hooks from its own project root, so a
472
- session opened at the *workspace* (`project/`, with the hub at `project/product/`) never reads the
473
- hub's `.claude/settings.json` and the guard does not fire there — even though the decision itself
474
- resolves the hub correctly from any path. In that layout, open the session at the hub, or copy the
475
- entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need the
476
- hub-relative path).
695
+ - The hook arms sessions **rooted at the Product**. A harness loads hooks from its own project root, so a
696
+ session opened at the *workspace* (`project/`, with the Product at `project/product/`) never reads the
697
+ Product's `.claude/settings.json` and the guard does not fire there — even though the decision itself
698
+ resolves the Product correctly from any path. In that layout, open the session at the Product, or copy the
699
+ entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need
700
+ the Product-relative path).
477
701
 
478
- **Wiring** (installed by `yad setup` / `yad check --fix`, bridge hubs only):
702
+ **Wiring** (installed by `yad setup` / `yad check --fix`, verified Products only):
479
703
 
480
704
  | Path | Owner |
481
705
  |---|---|
482
- | `<hub>/hooks/ledger-guard.sh` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
483
- | `<hub>/.claude/settings.json` | **one entry**, merged additively into `hooks.PreToolUse`. See below. |
706
+ | `<product>/hooks/ledger-guard.sh` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
707
+ | `<product>/.claude/settings.json` | **one entry**, merged additively into `hooks.PreToolUse`. See below. |
484
708
 
485
709
  The settings file is the team's, so the rules around that one entry are deliberately conservative:
486
710
 
@@ -502,10 +726,11 @@ The settings file is the team's, so the rules around that one entry are delibera
502
726
  - **Both halves land together.** The script and the entry ride `yad update` as one: applying the
503
727
  entry without the script it points at would fire a missing command on every file edit.
504
728
 
505
- `.claude` is the only IDE target wired: it is the only one with a defined hook protocol. Other
506
- targets get the script, and the contract above is what they would wire by hand.
729
+ `.claude` and `.cursor` are the only IDE targets wired (`.cursor` through its own wrapper, above): they
730
+ are the only ones with a hook protocol yadflow has read. Other targets get the script, and the contract
731
+ above is what they would wire by hand.
507
732
 
508
- `yad doctor` reports the guard on a verified hub, and distinguishes the three states that matter — it
733
+ `yad doctor` reports the guard on a verified Product, and distinguishes the three states that matter — it
509
734
  reads the same persisted `ideTargets` the wiring reads, so every gap it names is one the command it
510
735
  names can actually close:
511
736