yadflow 3.18.1 → 4.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
@@ -1,6 +1,6 @@
1
1
  # Check gates — definitions, scripts, CI wiring, convention map
2
2
 
3
- The gates are the production-safety core of the build half (Phase 3 build plan §C). They are
3
+ The gates are the production-safety core of Build (Phase 3 build plan §C). They are
4
4
  deliberately small, separate, and CI-agnostic: plain bash in `checks/`, invoked by whatever CI the
5
5
  repo uses. Each reads conventions established by earlier steps — it invents nothing.
6
6
 
@@ -10,11 +10,11 @@ repo uses. Each reads conventions established by earlier steps — it invents no
10
10
  |------|-------|-------------|
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
- | build/test/lint | the repo's `npm run lint` / `npm run build` / `npm 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) |
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 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) |
@@ -93,13 +93,44 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
93
93
 
94
94
  ## 3. build/test/lint (`templates/checks/build-test-lint.sh`)
95
95
 
96
- - Runs `npm run lint`, `npm run build`, `npm test` in order; any non-zero exit fails the gate.
96
+ - Reads the standard `package.json#packageManager` field and runs `lint`, `build`, and `test` through
97
+ that manager in order; any non-zero exit fails the gate. `npm` and `pnpm` are supported; another
98
+ manager (yarn, bun) fails closed unless an npm lockfile is present, in which case the repo stays on
99
+ the historical npm path with a warning — a yarn-locally, npm-in-CI repo was green before this
100
+ field was read and must not go red on upgrade. A repo
101
+ without the field retains npm behavior unless it carries `pnpm-lock.yaml` and no npm lockfile
102
+ (`package-lock.json` / `npm-shrinkwrap.json`) — a repo with both keeps the npm path rather than
103
+ silently changing toolchains.
104
+ - CI runs `install-deps.sh` first. A declared npm or pnpm manager must use a full, exact semantic
105
+ version with either no build metadata or Corepack's integrity suffix,
106
+ `+<sha1|sha224|sha256|sha384|sha512>.<lowercase hex digest>` at that algorithm's digest length
107
+ (Corepack hashes the download with the named algorithm and compares the lowercase hex string).
108
+ Other build metadata, digest algorithms, digest lengths, uppercase digests, or trailing
109
+ identifiers fail closed. The installer activates the declared version through
110
+ Corepack. Pinned npm is dispatched through Corepack — `corepack npm ci` here and `corepack npm run`
111
+ in the gate, since Corepack activates npm but never shims it — so Node's ambient npm cannot
112
+ override the declaration anywhere; packageManager-absent npm preserves the historical `npm ci` path. pnpm uses
113
+ `pnpm install --frozen-lockfile`. Partial versions, ranges, and tags also fail closed instead of
114
+ floating to a different toolchain.
115
+ - The build/test/lint job defaults to Node 22. Set the GitHub repository variable or GitLab
116
+ project/group CI/CD variable `YAD_NODE_VERSION` when the repo requires another supported Node
117
+ release; generated files remain managed instead of accumulating consumer-specific edits. The
118
+ variable is read by the code repo's `yad-checks` workflow only (on GitHub the build/test/lint job;
119
+ in the GitLab fragment it sets the `node:` image of every `yad-*` gate job, which all share one
120
+ anchor) — the Product-side workflows run the `yad` CLI, not the repo's toolchain, and keep their own
121
+ pinned Node. A declared `packageManager` needs
122
+ Corepack, which ships with Node 18 through 24 (Node 25+ dropped it) and, before 18.20.7 / 20.19 /
123
+ 22.14, carries registry keys too old to verify anything published since 2025 — so the override
124
+ should stay on a current 20, 22 or 24 line (or install a current Corepack first). The installer
125
+ checks for the binary and wraps `corepack prepare`, so both an absent and a stale Corepack fail
126
+ with that guidance instead of a bare "command not found" or "Cannot find matching keyid".
97
127
  - Tests must actually exercise behavior (build plan §C) — an empty or trivially-passing suite does not
98
128
  satisfy the gate's intent.
99
129
  - **Test worker cap.** When the CI job sets `YAD_TEST_MAX_WORKERS` (the templates default it to `2`)
100
130
  and the repo's `test` script is jest/vitest, the gate forwards `--maxWorkers=<n>` to bound CI
101
- concurrency. For any other runner (`node --test`, mocha, …) it is a no-op — the flag is never
102
- passed, so the gate cannot break on an unknown option. Override it per repo via the
131
+ concurrency (as `-- --maxWorkers=<n>` under npm, which consumes the separator, and bare under pnpm,
132
+ which would forward a literal `--` to the script). For any other runner (`node --test`, mocha, …)
133
+ it is a no-op — the flag is never passed, so the gate cannot break on an unknown option. Override it per repo via the
103
134
  `YAD_TEST_MAX_WORKERS` CI variable, or unset it to remove the cap.
104
135
 
105
136
  ### Canonical `package.json` scripts (Node demo)
@@ -119,30 +150,39 @@ runner. Real repos substitute their own eslint/tsc/jest — the gate only calls
119
150
 
120
151
  ## 4. verified-commits (`templates/checks/verified-commits.sh`)
121
152
 
122
- No unverified commits from unverified users reach merge — on the product hub and on every connected
123
- repo. For each commit in `<base>..HEAD`, two independent checks:
153
+ No unsigned commits reach merge — on the Product and on every connected repo. For each commit in
154
+ `<base>..HEAD`, one check:
124
155
 
125
156
  - **Verified signature** — the platform must mark the commit's signature verified (the GitHub/GitLab
126
157
  "Verified" badge: signed with a GPG/SSH key registered to the account owning the author email).
127
158
  Read via `gh api repos/{owner}/{repo}/commits/<sha>` (GitHub) or the commits/signature API (GitLab).
128
- - **Known author** — the commit's **author email** must appear in `.sdlc/verified-authors`, generated
129
- by `yad check --fix` from the hub roster's `email`/`emails` fields plus hub.json's
130
- `verified_authors` list (edit hub.json, never the generated file). Only the author is checked:
131
- platform-generated squash commits keep the PR author (who is on the roster). Two identities are
132
- **allowlist-waived but still signature-covered**: the `yad-gate-sync` bot, and any **merge commit**
133
- (2+ parents) — a merge's author is whoever pressed merge (often a platform noreply), not a roster
134
- human, and its content already passed the PR gate suite. This waiver matters for the push-on-default
135
- `yad-update-guard` (§9), which — unlike this PR-triggered gate — sees merge commits.
136
- A merge commit is **additionally signature-waived when it introduces no content of its own** (its
137
- combined diff — `git diff-tree --cc` — is empty, i.e. no conflict-resolution or evil-merge hunks):
138
- every change it carries already lives in an individually author+signature-checked parent, so there
139
- is nothing to protect. This unblocks **self-hosted GitLab**, which does not sign UI-created merge
140
- commits (the signature API returns 404) — without it every routine merge would red the branch. A
141
- merge that *does* introduce content of its own still requires a verified signature (fail-closed),
142
- so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
143
-
144
- Degradation is explicit, never silent: a missing allowlist SKIPs the author check with a warning
145
- (configure roster emails, re-wire); no GitHub/GitLab remote SKIPs the signature check (the badge is a
159
+
160
+ **There is no author allowlist** (E62). yadflow keeps no list of people: write access to the repo
161
+ decides who can author, and the signature proves which platform account made the commit. `yad check --fix`
162
+ and `yad setup` no longer generate `.sdlc/verified-authors`, and no gate reads it or hub.json's
163
+ `verified_authors` any more (`SDLC_VERIFIED_AUTHORS` is gone too). When an old `.sdlc/verified-authors`
164
+ file is still on disk, the gate prints:
165
+
166
+ ```
167
+ note [verified-commits]: .sdlc/verified-authors is no longer read — write access to the repo decides who can author; the signature is still required.
168
+ ```
169
+
170
+ `yad doctor` warns `people:verified-authors-unused` for the same leftover data; nothing deletes it.
171
+ The old allowlist waivers for the `yad-gate-sync` bot and for merge commits are gone too, because there
172
+ is no allowlist left to waive. A bot commit inside the checked range needs a Verified signature like any
173
+ other commit.
174
+
175
+ **Content-free merge exemption** (unchanged). A merge commit (2+ parents) is **signature-waived when it
176
+ introduces no content of its own** (its combined diff — `git diff-tree --cc` — is empty, i.e. no
177
+ conflict-resolution or evil-merge hunks): every change it carries already lives in an individually
178
+ signature-checked parent, so there is nothing to protect. This unblocks **self-hosted GitLab**, which
179
+ does not sign UI-created merge commits (the signature API returns 404) — without it every routine merge
180
+ would red the branch. A merge that *does* introduce content of its own still requires a verified
181
+ signature (fail-closed), so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
182
+ This matters for the push-on-default `yad-update-guard` (§9), which — unlike this PR-triggered gate —
183
+ sees merge commits.
184
+
185
+ Degradation is explicit, never silent: no GitHub/GitLab remote SKIPs the signature check (the badge is a
146
186
  platform concept — this keeps local runs and tests meaningful); an unreachable platform API **fails
147
187
  closed** with guidance. GitLab CI needs a `GITLAB_TOKEN`/`SDLC_API_TOKEN` variable with `read_api` —
148
188
  `CI_JOB_TOKEN` cannot read the signature API.
@@ -162,8 +202,8 @@ non-merge commit in `<base>..HEAD`:
162
202
  `COMMIT_TYPES`) and **no trailing period** — mirroring `cli/commit.mjs` `buildCommitMessage`.
163
203
  - **Trailers**, when present, appear in the fixed order `Task → Contract-Change → Co-Authored-By`.
164
204
  - Merge/squash commits (2+ parents) are skipped — their platform-generated subjects are not authored.
165
- - **Profiles** (`--profile code|hub`): the subject rule is identical on both; the gate never requires
166
- the `Task:` trailer (spec-link owns that on code repos; hub commits are not task-scoped).
205
+ - **Profiles** (`--profile code|hub|product`): the subject rule is identical on both; the gate never requires
206
+ the `Task:` trailer (spec-link owns that on code repos; Product commits are not task-scoped).
167
207
  - **Fails closed** when `<base>` can't be resolved.
168
208
  `<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
169
209
 
@@ -176,13 +216,13 @@ from the event payload):
176
216
  period (`config.yaml build.pr_title_style: same_as_commit_subject` — one task = one PR, the title is
177
217
  the squash-merge subject).
178
218
  - `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
179
- - `review/EP-*` head (or no `--head` — stays strict) → a front-half artifact-review title
219
+ - `review/EP-*` head (or no `--head` — stays strict) → a Shape artifact-review title
180
220
  `review: <artifact> (EP-<slug>)`, the shape `yad gate open` creates.
181
- - any other head → a tooling/code change to the hub itself, so it follows the `code` convention (a
182
- Conventional-Commits subject). This is what lets a PR that changes the hub's own workflows/checks
221
+ - any other head → a tooling/code change to the Product itself, so it follows the `code` convention (a
222
+ Conventional-Commits subject). This is what lets a PR that changes the Product's own workflows/checks
183
223
  pass — it has no EP artifact to review.
184
224
  - **Anti-bypass guard.** The branch name alone is not trusted: a non-review head that actually
185
- changes front-half artifacts (any path under `epics/**`) **FAILS** — those changes must go through
225
+ changes Shape artifacts (any path under `epics/**`) **FAILS** — those changes must go through
186
226
  a `review/EP-*` PR and the artifact-review workflow. CI passes the PR's changed paths via
187
227
  `--changed <file>` (computed from the diff against the base ref); without that list (a direct
188
228
  by-hand caller) the guard is inert and the branch split alone applies.
@@ -196,10 +236,10 @@ catches a free-form description that bypassed it:
196
236
  `Risk level:` (`low|medium|high`).
197
237
  - `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
198
238
  - `review/EP-*` head (or no `--head`) → requires the artifact-review template: `## Artifact under
199
- review`, `## Impact & Risk (front-half)`, `## Checklist`, and a `Risk tags:` line.
200
- - any other head → a hub tooling PR, so it requires the `code` task template (`## Summary`,
239
+ review`, `## Impact & Risk (front-half)` (or `(Shape)`), `## Checklist`, and a `Risk tags:` line.
240
+ - any other head → a Product tooling PR, so it requires the `code` task template (`## Summary`,
201
241
  `## Impact & Risk`, `## Checklist`, filled `Risk level:`).
202
- - **Anti-bypass guard** (same as pr-title): a non-review head that changes front-half artifacts
242
+ - **Anti-bypass guard** (same as pr-title): a non-review head that changes Shape artifacts
203
243
  (`epics/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
204
244
  must go through a `review/EP-*` PR.
205
245
 
@@ -220,14 +260,14 @@ both shipped:
220
260
  After the contract locks and code ships, a change must not mutate a locked artifact — it becomes a new
221
261
  epic threaded to its parent (`config.yaml` `change:`). These three gates keep that discipline. All three
222
262
  resolve the owning epic the same way: `Task:` trailer → `specs/<story>/link.md` (`epic` + `product-repo`)
223
- → the hub epic. All **fail closed** on an unresolvable base; all are **per commit**; `ci|chore|build|test`
263
+ → the Product epic. All **fail closed** on an unresolvable base; all are **per commit**; `ci|chore|build|test`
224
264
  commits **with no `Task:` trailer** are exempt — like spec-link, the exemption waives the requirement
225
265
  for an owning epic, never the validity of one that is claimed, so a maintenance subject cannot buy a
226
- 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
227
- PR), each degrades to a **PASS-with-note** — the hub-side check (`yad doctor` / `yad reconcile`) covers
266
+ 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
267
+ PR), each degrades to a **PASS-with-note** — the Product-side check (`yad doctor` / `yad reconcile`) covers
228
268
  that path, and spec-link still proves the story link.
229
269
 
230
- **Resolving `product-repo` (shared by all four hub-reading gates, contract-check included).** An
270
+ **Resolving `product-repo` (shared by all four Product-reading gates, contract-check included).** An
231
271
  **absolute** value is used as-is; a **relative** value is joined to the `link.md`'s own directory,
232
272
  `specs/<story>/`, falling back to a repo-root reading when only that resolves (what contract-check
233
273
  historically did, so `link.md` files written for it keep working). The `link.md` itself is read from
@@ -238,13 +278,15 @@ gate now **prints that note**, so a deferred check is never mistaken for a passe
238
278
  duplicated verbatim across the four scripts (they are standalone by design) and a test asserts the
239
279
  four copies stay byte-identical.
240
280
 
241
- - **lineage-check** — reads the hub epic's `kind`/`parent` frontmatter. A `feature` (genesis) epic
281
+ - **lineage-check** — reads the Product epic's work-item type and `parent` frontmatter. The type has
282
+ two names and the gate reads `kind:` first, then `type:` — the same order the CLI uses, asserted by a
283
+ table test in `cli/test-checks.mjs`. A `feature` or `chore` (genesis) epic
242
284
  passes. A `change`/`defect`/`hotfix` epic **FAILS** unless it declares a `parent:` that resolves to a
243
- real `epics/<parent>/` in the hub (no orphan threads). This is the "every code change has an owning
285
+ real `epics/<parent>/` in the Product (no orphan threads). This is the "every code change has an owning
244
286
  epic in a thread" enforcement, layered on spec-link.
245
287
  - **epic-open** — an epic is **sealed** iff it has ≥1 story and **every** `stories/*.md` `status:` is
246
288
  `shipped`. A commit whose owning epic is sealed **FAILS**: new behaviour cannot mutate a shipped epic;
247
- it must land in a new threaded change-epic. This is what stops the front artifacts from going stale.
289
+ it must land in a new threaded change-epic. This is what stops the Shape artifacts from going stale.
248
290
  - **reconcile-debt** — resolves the epic's `thread` (its `thread:` frontmatter, else the epic id) and
249
291
  scans every thread epic's `reconcile-debt.json`. An **open** entry the current epic does not own
250
292
  **FAILS** the change (the thread is frozen until the hotfix debt is paid: artifacts updated + a
@@ -253,7 +295,7 @@ four copies stay byte-identical.
253
295
  ## 9. yad-update-guard (`templates/github/yad-update-guard.yml`, `templates/gitlab/yad-update-guard.gitlab-ci.yml`)
254
296
 
255
297
  The **integrity gate for direct pushes to the default branch**. `yad update --push` (`cli/update-commit.mjs`)
256
- commits the applied SDLC drift (skills, gate scripts, CI wiring, `verified-authors`) and pushes it
298
+ commits the applied SDLC drift (skills, gate scripts, CI wiring) and pushes it
257
299
  **straight to the default branch with no PR/MR** — so the `pull_request`/`merge_request` gate suite never
258
300
  fires. This workflow is the "skipped from CI **except** verified-commits + the pattern gate" contract: on a
259
301
  **push** to the default branch it runs **only** `verified-commits` and `commit-message` over the pushed
@@ -264,7 +306,7 @@ strictly-good invariant. Normal PR merges sail through — merge commits are pla
264
306
  `commit-message` skips merges. The `yad update --push` commit itself carries **no `[skip ci]`** (unlike the
265
307
  machine-state `yad checkpoint`/`gate ci` commits) precisely so this guard runs on it.
266
308
 
267
- Wired into **every connected repo and the hub** (`REPO_WIRING`/`HUB_WIRING` in `cli/manifest.mjs`). On
309
+ Wired into **every connected repo and the Product** (`REPO_WIRING`/`PRODUCT_WIRING` in `cli/manifest.mjs`). On
268
310
  GitHub it is a self-contained workflow (`.github/workflows/yad-update-guard.yml`, marker `# yad-managed:
269
311
  yad-checks`), gated to the default branch by a job-level `if: github.ref_name ==
270
312
  github.event.repository.default_branch`. On GitLab it is an includable fragment
@@ -276,12 +318,114 @@ include line exists** — `yad update --push` warns when it pushes to a gitlab r
276
318
  lacks it.
277
319
 
278
320
  **Prerequisites / caveats.** Direct pushes to the default branch must be permitted for the committer
279
- (adjust branch protection). The commits `yad update --push` creates must be **signed** and their author
280
- **allowlisted**, or this guard rejects them — `yad update --push` runs a pre-flight that warns when local
281
- commit signing is unset or the operator's git email is not in `.sdlc/verified-authors`. Ordinary PR merges
282
- pass (merge commits are allowlist-waived + Verified), but a **rebase-merge** recreates the PR commits
321
+ (adjust branch protection). The commits `yad update --push` creates must be **signed**, or this guard
322
+ rejects them — `yad update --push` runs a pre-flight that warns when local commit signing is off. Ordinary
323
+ PR merges pass (merge commits are Verified, or content-free and signature-waived), but a **rebase-merge**
324
+ recreates the PR commits
283
325
  without the platform signature, so a rebase-merge team should sign commits or not wire this guard.
284
326
 
327
+ ## 10. risk-map (`templates/checks/risk-map-check.sh`) — advisory
328
+
329
+ Reads the code repo's **risk map**, `.sdlc/risk-map`: one line per directory giving it a level (`high`,
330
+ `medium`, `low`, or `unset`), marked `guessed` (an AI agent filled it) or `confirmed` (a person checked
331
+ it), and **no names** (E65). The format, the rubric and who may change what:
332
+ `../yad-connect-repos/references/risk-map.md`. The rules have a twin in `cli/riskmap.mjs`
333
+ (`yad risk-map check`, `yad doctor`); a test runs both over the same repos and compares the output.
334
+
335
+ It **always exits 0**: the count it prints is reported, never enforced — the platform's branch
336
+ protection holds a Build merge. It cannot apply the capacity cap (E72) either: the cap needs the
337
+ Product's count of active people, which a code repo's CI does not have; `yad open-pr` shows the capped
338
+ count. So a stale map must never block a merge. It prints `WARN [risk-map] <code> <target>: …` for:
339
+
340
+ | Code | When |
341
+ |---|---|
342
+ | `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 |
343
+ | `dead` | a line whose directory holds no file (`./`: no file at the root) — on every change, until fixed |
344
+ | `unset` / `guessed` | a line this change touches that has no level yet, or whose level is still a guess |
345
+ | `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 |
346
+ | `header` / `version` | no `# yad-risk-map v1` first line (read as v1), or a newer version (nothing is read) |
347
+ | `map-edited` | the change edits the map itself, which decides how much review later changes need |
348
+
349
+ 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
350
+ where the branch left the base, so commits the base gained since are never blamed on this change (the
351
+ blocking gates use two dots; this one only warns about what the change touches). A deleted file is never
352
+ asked about. The repo is `git ls-files -z`. Both lists are read NUL-separated, so a path holding `"`, `\`
353
+ 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:
354
+ the map's own lines are still checked. A repo with no map gets one note and passes — **unless this change deleted
355
+ 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
356
+ prints a note and exits 0. **Known limits:** a path holding a newline reads differently here than in
357
+ `yad risk-map check` — git's NUL-separated list has to become lines for macOS awk — and such a directory
358
+ cannot have a line anyway. A path whose bytes are not valid UTF-8 differs too: this check compares bytes
359
+ (`LC_ALL=C`), while the CLI decodes bad bytes as U+FFFD. **The map file is not wired** — it is the
360
+ team's, so `yad update` never owns or overwrites it; only the check is.
361
+
362
+ **The count (E66).** Before the warnings it prints one line saying how many approvers the change asks for:
363
+
364
+ ```
365
+ 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.
366
+ COUNT [risk-map]: 1 approver = base 1 — nothing this change touches is high on origin/main.
367
+ ```
368
+
369
+ - The map is read from the **base branch's tip** (`git show <base>:.sdlc/risk-map`), never from the
370
+ change's copy — so a change cannot lower its own count by editing or deleting the map. The warnings
371
+ above still read the change's copy, because they are about keeping that copy true.
372
+ - Only a real file on the base is read. A symlink or a folder with that name is treated as no map.
373
+ - The change's files for the count are `git diff --name-only -z --no-renames <base>...HEAD`, **with no
374
+ filter**: a deleted file, and a file moved away, count where they were. Deleting code in a `high`
375
+ directory is a `high` change. (The warnings keep `--diff-filter=ACMRT`: a deleted file cannot be
376
+ uncovered.)
377
+ - A `guessed` level counts as a `confirmed` one. `medium` is printed on its own line and adds nothing.
378
+ An `unset` line, and a file no line covers, add nothing.
379
+ - 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,
380
+ shares no history with HEAD, or holds a map for a newer version: **no COUNT line**, and a note that the
381
+ count is unknown, not zero.
382
+
383
+ **Who can meet the ask (E67).** Under the count, the check names the people who have committed in the
384
+ last 30 days in the `high` directories this change touches. With two such directories it is ONE list:
385
+ someone who has worked in any of them meets the ask, and the output never says who worked where.
386
+
387
+ ```
388
+ ask one of these (committed there in the last 30 days, this change's own authors left out): Alice (@alice)
389
+ nobody else has committed there in the last 30 days — the count above still stands.
390
+ who has worked there lately: not read — this is a shallow clone — it does not hold the history of those directories.
391
+ ```
392
+
393
+ - The history is the **base branch's** (`git log <base> --since='30 days ago' --no-merges`), so a change
394
+ cannot add its own; git filters on the **committer** date. The change's own authors are left out,
395
+ because an approval has to come from someone else, and a robot (`…[bot]`) is never listed.
396
+ - **Git applies the map, and no file name is ever read back.** One query runs per `high` directory, in
397
+ map order, carrying pathspecs that say what the map means by it: the directory, minus every listed
398
+ directory below it — the deepest line decides, and each of those answers for itself in its own query
399
+ (an exclude beats a later include, so they cannot share one). `./` is the files AT the root
400
+ (`:(glob)*`, because `*` never spans `/`). Git then returns author records only. Reading a list of
401
+ file names back was what let a quoted path, a name holding a newline, or a merge's simplified path
402
+ list name the wrong person; there is no list to misread now.
403
+ - A person is their git **name**, plus `(@login)` only when the commit address is a platform `noreply`
404
+ one. No e-mail address is ever printed. A name holding an `@` is replaced: by the bare login when
405
+ there is one (`WHO carol @carol`), else by `a name that is an e-mail address` or `a name written like a
406
+ login` — so a printed `@word` is always a real login.
407
+ - A **shallow clone** says "not read", never "nobody": it holds only the newest commits, and reading
408
+ that as "nobody has worked here" would drop the ask instead of raising it. Wired CI checks out the
409
+ full history (`fetch-depth: 0`, `GIT_DEPTH: 0`), so this is a local or host-overridden case.
410
+ - A side branch whose merge kept the other side still counts (`--full-history`): git otherwise simplifies
411
+ a path-filtered log and hides it.
412
+ - **Known limit:** git's date-limited walk stops at the first commit older than the window on a chain,
413
+ so a repo whose commit dates run out of order (a wrong clock, an imported history) can hide people
414
+ behind that commit. It under-lists, so the printed people are always real ones.
415
+
416
+ `risk-map-check.sh --level [<base>]` prints the same result as machine lines (`BASE`, `UNKNOWN`, `NOMAP`,
417
+ `FILES`, `LEVEL`, `DIR <dir> <level> <state>`, `WHO <login|-> <name>`, `HISTNONE` — the history was read
418
+ and held nobody — and `HISTUNKNOWN <why>`) for `checks/risk-route.sh`, which joins it with the PR
419
+ body. One awk program serves both the warnings and the count. Its twin is `changeLevel` in
420
+ `cli/riskmap.mjs`, and a parity test compares the two. **Known limits:** like every check here, the
421
+ script runs from the PR's own checkout, so a PR can edit the script itself; that is harmless only because nothing
422
+ 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
423
+ split in two here but kept whole by `changeLevel`, so the bash count can read its second half as a file
424
+ at the root; the error can only raise the count, never lower it. (The E67 history is free of that class:
425
+ it reads no file names at all.) If git itself fails, each step of the
426
+ count says "not counted"; the E65 warnings half is not guarded the same way, so a failing `git diff` or
427
+ `git ls-files` there can still end the script with git's exit code.
428
+
285
429
  ## CI wiring (both platforms)
286
430
 
287
431
  The gates run identically under either CI; the config just invokes the scripts with the PR/MR base.
@@ -295,12 +439,25 @@ The gates run identically under either CI; the config just invokes the scripts w
295
439
  re-runs `pr-title`/`pr-template`, not the whole suite. The pattern jobs
296
440
  read the title/body from the event payload: `pr-title` takes `${{ github.event.pull_request.title }}`
297
441
  and `pr-template` writes `${{ github.event.pull_request.body }}` to a temp file. All `--profile code`.
298
- The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) run as their own jobs with
299
- `fetch-depth: 0`, the same `origin/${{ github.base_ref }}` base.
442
+ The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) and the advisory `risk-map`
443
+ check run as their own jobs with `fetch-depth: 0`, the same `origin/${{ github.base_ref }}` base. The build/test/lint checkout also
444
+ uses `filter: blob:none`; its installer follows `package.json#packageManager`, and `NX_BASE` /
445
+ `NX_HEAD` carry the exact base/head SHAs so Nx affected commands evaluate the PR rather than a
446
+ stale default. `YAD_NODE_VERSION` is read from GitHub repository variables with `22` as the default.
447
+ Dependencies are cached with `actions/cache` (npm's `~/.npm`, pnpm's store, and the Corepack home
448
+ holding the pinned manager, keyed on the lockfiles and package.json) rather than setup-node's
449
+ npm-only `cache:`, which must name the manager before package.json has been read.
300
450
  - **GitLab CI** — `templates/gitlab/yad-checks.gitlab-ci.yml` → `.gitlab/ci/yad-checks.yml`, pulled in
301
451
  by the root `.gitlab-ci.yml`'s `include:`. The jobs run on `merge_request_event` with `GIT_DEPTH: 0`,
302
452
  passing `origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`; the pattern jobs read `$CI_MERGE_REQUEST_TITLE`
303
- and `$CI_MERGE_REQUEST_DESCRIPTION`. All `--profile code`.
453
+ and `$CI_MERGE_REQUEST_DESCRIPTION`. The quality job uses the same package-manager-aware installer;
454
+ `NX_BASE=$CI_MERGE_REQUEST_DIFF_BASE_SHA` and `NX_HEAD=$CI_COMMIT_SHA` provide the equivalent Nx
455
+ range. Its `node:${YAD_NODE_VERSION}` image defaults to `22` and a project/group CI/CD variable may
456
+ override it without editing the managed fragment. Those three variables sit on the `.sdlc_mr_only`
457
+ anchor, not the fragment's top-level `variables:` — an included top-level block merges into the host
458
+ pipeline's globals, where `NX_BASE`/`NX_HEAD` could collide with a host's own Nx setup. The retained greenfield standalone template
459
+ (`templates/gitlab/.gitlab-ci.yml`) carries the same Node default, exact Nx range, and dependency
460
+ installer and test-worker-cap semantics. All `--profile code`.
304
461
 
305
462
  ## Sync with existing CI (merge, never clobber)
306
463
 
@@ -331,37 +488,43 @@ break nor reorder them; job names are `yad-`prefixed to avoid collisions.
331
488
  **Idempotent.** The two markers plus the include-entry check make a re-run a no-op. This is how a repo
332
489
  that already had its own pipeline keeps it and still gains the gates.
333
490
 
334
- ## Wiring the hub (`repo: hub`)
491
+ ## Wiring the Product (`repo: hub`)
335
492
 
336
- The product hub is itself a repo on a platform (recorded in `.sdlc/hub.json` by
493
+ The Product is itself a repo on a platform (recorded in `.sdlc/hub.json` by
337
494
  `yad-connect-repos action: detect-hub`). `wire repo: hub` targets `{project-root}` and uses the same
338
- merge-not-clobber logic, with a **hub-flavored gate set** appropriate to a "thinking" repo (it has no
339
- `specs/` or `package.json` build):
495
+ merge-not-clobber logic, with a **Product-flavored gate set** appropriate to a "thinking" repo (it has no
496
+ `specs/` or `package.json` build). **What yadflow wires today** (`PRODUCT_WIRING`): `commit-message`,
497
+ `pr-title`, `pr-template` and `ledger-guard` in `yad-hub-checks`, `verified-commits` in its own workflow,
498
+ and the `yad-update-guard`. The three below are **not shipped** — they are scripts a team writes itself if
499
+ it wants them:
340
500
  - **owner-set** — every `epic.md` (and forward artifact) under `epics/EP-*/` carries an `owner`.
341
501
  - **contract-locked** — where an epic has a `contract.md`, its surface hash matches
342
502
  `.sdlc/contract-lock.json` (reuse the recipe in
343
503
  `../yad-architecture/references/contract-format.md`).
344
- - **approvals-present** — an epic at `ready-for-build` has the approvals its gate rule requires recorded
345
- in `.sdlc/approvals.json` (the same predicate `yad-review-gate` enforces).
504
+ - **approvals-present** — an epic at `ready-for-build` has the approvals the gate rule requires recorded
505
+ in `.sdlc/approvals.json`: at least 1 approver (the author is not checked here — the platform's own
506
+ rules stop self-approval on GitHub, and on GitLab when its settings say so; the same predicate
507
+ `yad-review-gate` enforces; the risk step of the full count, capped by the active people (E72), is
508
+ reported beside it and gates nothing).
346
509
 
347
- These are advisory checks on the hub's own PRs (the front-half review PRs the bridge opens); they keep
348
- the hub's artifacts internally consistent. The hub never runs the code-repo `spec-link`/`build-test-lint`
349
- gates. Author the hub gate scripts under the hub's `checks/` following the same CI-agnostic-bash pattern.
510
+ These are advisory checks on the Product's own PRs (the Shape review PRs the verified ledger opens); they keep
511
+ the Product's artifacts internally consistent. The Product never runs the code-repo `spec-link`/`build-test-lint`
512
+ gates. Author the Product gate scripts under the Product's `checks/` following the same CI-agnostic-bash pattern.
350
513
 
351
- The hub **does** run the verified-commits gate — `yad check --fix` installs `checks/verified-commits.sh`
514
+ The Product **does** run the verified-commits gate — `yad check --fix` installs `checks/verified-commits.sh`
352
515
  plus a standalone workflow (`templates/github/yad-verified-commits.yml` →
353
516
  `.github/workflows/yad-verified-commits.yml`, or the GitLab fragment
354
517
  `templates/gitlab/yad-verified-commits.gitlab-ci.yml` → `.gitlab/ci/yad-verified-commits.yml` +
355
- its one include line) whenever `.sdlc/hub.json` has a platform with the bridge enabled. So the
356
- front-half review PRs are held to the same rule as code-repo PRs: signed, known authors only.
518
+ its one include line) whenever `.sdlc/hub.json` has a platform with a verified ledger. So the
519
+ Shape review PRs are held to the same rule as code-repo PRs: platform-Verified signatures only.
357
520
 
358
- The hub **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
521
+ The Product **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
359
522
  `--profile hub`. The pattern gates split by the PR/MR **head branch** (passed via `--head`): a
360
- `review/EP-*` head is a front-half review PR — Conventional-Commits commit subjects, a
361
- `review: <artifact> (EP-<slug>)` title, and the hub artifact-review template body; **any other head is
362
- a tooling/code change to the hub itself** and follows the `code` convention (a Conventional-Commits
363
- title + the code task template), so a PR that changes the hub's own workflows/checks can pass.
364
- `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone hub workflow
523
+ `review/EP-*` head is a Shape review PR — Conventional-Commits commit subjects, a
524
+ `review: <artifact> (EP-<slug>)` title, and the Product artifact-review template body; **any other head is
525
+ a tooling/code change to the Product itself** and follows the `code` convention (a Conventional-Commits
526
+ title + the code task template), so a PR that changes the Product's own workflows/checks can pass.
527
+ `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone Product workflow
365
528
  (`templates/github/yad-hub-checks.yml` → `.github/workflows/yad-hub-checks.yml`, or the GitLab fragment
366
529
  `templates/gitlab/yad-hub-checks.gitlab-ci.yml` → `.gitlab/ci/yad-hub-checks.yml` + its one include
367
530
  line). Code repos run the same three with `--profile code` inside the main `yad-checks` workflow.
@@ -370,7 +533,7 @@ line). Code repos run the same three with `--profile code` inside the main `yad-
370
533
 
371
534
  Not a CI gate — a **harness hook**, and the only piece of yadflow that runs *inside* an agent's tool
372
535
  loop. It exists because of the gap #171 reported: `checks/ledger-guard.sh` is correct and blocking,
373
- but it speaks at CI time. An agent that hand-edits `epics/*/.sdlc/state.json` in bridge mode learns
536
+ but it speaks at CI time. An agent that hand-edits `epics/*/.sdlc/state.json` in verified mode learns
374
537
  twenty minutes later, from a FAIL with nothing connecting cause to effect, and by then the write must
375
538
  be reverted before the review PR/MR can go green.
376
539
 
@@ -382,11 +545,70 @@ be reverted before the review PR/MR can go green.
382
545
  | exit 0 | allow |
383
546
  | exit 2 | deny — the reason is on stderr, for the agent to read |
384
547
 
385
- Claude Code's `PreToolUse` protocol is exactly that (exit 2 blocks the call and feeds stderr back to
386
- the model), so `.claude/settings.json` wires it with no adapter logic. Any harness that can run a
387
- command and read those two exit codes can use the same script.
548
+ Two harnesses match that contract, and `yad check --fix` wires both:
388
549
 
389
- **Layering.** `hooks/ledger-guard.sh` is only the adapter: it locates `yad` (`$YAD_BIN` → the hub's
550
+ | Harness | File | Event | Command |
551
+ |---|---|---|---|
552
+ | Claude Code | `.claude/settings.json` | `PreToolUse` | `"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"` |
553
+ | Cursor | `.cursor/hooks.json` | `preToolUse` | `hooks/ledger-guard-cursor.sh` |
554
+
555
+ Any other harness that can run a command and read those two exit codes can use `ledger-guard.sh` by
556
+ hand.
557
+
558
+ **Cursor needs a second protocol, and this is the trap.** Its `preToolUse` is a *permission hook*:
559
+ Cursor's docs say that for a permission hook, "invalid JSON or a response that doesn't match the
560
+ hook's schema blocks the action". `ledger-guard.sh` prints **nothing** when it allows — and empty
561
+ stdout is invalid JSON. Wiring it straight into Cursor would therefore have blocked **every** file
562
+ write in a verified project: fail-closed on everything, the opposite of this guard's whole design, and
563
+ invisible to any test that only checks that a deny denies.
564
+
565
+ So Cursor gets `hooks/ledger-guard-cursor.sh`, installed only for a project whose targets include
566
+ `.cursor`. It calls `yad hook ledger-guard --format cursor` through the shared script and guarantees a
567
+ permission answer on stdout, whatever happens:
568
+
569
+ | | stdout | exit |
570
+ |---|---|---|
571
+ | allow | `{"permission":"allow"}` | 0 |
572
+ | deny | `{"permission":"deny","user_message":"<reason>","agent_message":"<reason>"}` | 0 |
573
+
574
+ The field names are Cursor's documented permission-hook schema, all snake_case. Getting that wrong is
575
+ quiet in the worst way: an off-schema response still blocks, so the write is refused and any test that
576
+ checks "a deny denies" passes — while the text naming `yad gate open` is discarded and the agent is
577
+ told only "no".
578
+
579
+ The allow response is a fixed literal with nothing interpolated into it, because a malformed *allow*
580
+ is a block. The deny response carries the reason, and if that field were ever rejected as off-schema
581
+ the response is invalid — which blocks, which is what a deny wanted anyway. Both failure directions
582
+ are safe, in opposite ways, on purpose. A deny exits **0**, not 2, because the JSON is the
583
+ authoritative answer and exit 0 is what tells Cursor to read it; exit 2 blocks too but is documented
584
+ as the code for "no JSON to read", so it would discard the reason — and naming the command that owns
585
+ the transition is the entire point of speaking at edit time. The reason also goes to stderr, where
586
+ Cursor logs it.
587
+
588
+ The wrapper takes **no arguments**, and every fail-open branch of the shared script (no `yad` on
589
+ PATH, an install it cannot resolve) is converted into an explicit `allow` answer rather than the
590
+ empty stdout that would block. **Exit 2 is converted into a deny**, not an allow: `ledger-guard.sh`
591
+ resolves `yad` from the Product's own `node_modules/yadflow` before `PATH`, so a project pinned to a
592
+ yadflow older than `--format` answers in the exit protocol — exit 2 with empty stdout — and treating
593
+ that as "no verdict" would turn a real refusal into a permitted write.
594
+
595
+ The two commands are spelled differently on purpose. Claude Code runs the string through a shell, so
596
+ its entry uses `$CLAUDE_PROJECT_DIR` and is quoted against a project path containing a space. Cursor
597
+ documents that a project hook runs **from the project root** but not whether the command goes through
598
+ a shell — so its entry is a relative path with no variable and no quotes, the one spelling that works
599
+ either way. Every failure mode here is silent and fails open, which would leave `yad doctor`
600
+ truthfully reporting an entry that never refuses anything.
601
+
602
+ Cursor does not document the field names inside `tool_input`, so `payloadPaths` reads any key *named*
603
+ like a path (`file_path`, `target_file`, `filePath`, `paths`) rather than guessing a vendor spelling.
604
+ It matches the key and never the value: scanning values for something shaped like a ledger path would
605
+ refuse an ordinary edit to a document that merely quotes one, and a false deny is worse than a miss in
606
+ a guard that fails open by design.
607
+
608
+ Cursor's wiring follows Cursor's published protocol and has not yet been exercised against a live
609
+ Cursor session.
610
+
611
+ **Layering.** `hooks/ledger-guard.sh` is only the adapter: it locates `yad` (`$YAD_BIN` → the Product's
390
612
  `node_modules/yadflow` → `PATH` → `npx --no-install`) and passes the payload to `yad hook
391
613
  ledger-guard`, which holds the decision. So the wiring never hard-codes an install path, and the
392
614
  logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
@@ -401,7 +623,7 @@ logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
401
623
  - **The seed carve-out reads the base ref, not the working tree** (#162): the epics whose
402
624
  `state.json` the base carries are listed once with `ls-tree`, and an epic absent from that list is
403
625
  a creation. Never a `<rev>:<path>` probe — that spec resolves from the repository top level and
404
- `-C` does not re-anchor it, so a hub in a subdirectory of its repo would miss every time and the
626
+ `-C` does not re-anchor it, so a Product in a subdirectory of its repo would miss every time and the
405
627
  guard would allow everything, silently.
406
628
  - **The base is an `origin/` ref** — `origin/<default_branch>`, then the remote's published default,
407
629
  then `origin/main`, the gate's own order. Never a bare local branch: `git fetch` does not
@@ -410,11 +632,11 @@ logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
410
632
  - **Slugs are case-folded**, as the gate folds them. On a case-insensitive filesystem `epics/ep-x/…`
411
633
  and `epics/EP-X/…` are the same file, so a byte-exact compare would let a mutation be laundered as
412
634
  a creation.
413
- - Bridge-gated by the same `isBridgeHub` predicate the CLI and the wiring read (#186): without the
635
+ - Verified-only by the same `isVerifiedLedger` predicate the CLI and the wiring read (#186): without the
414
636
  bridge the ledger is locally owned, the hand-edit the authoring skills describe is correct, and
415
637
  nothing is wired or blocked.
416
638
 
417
- **It fails OPEN, and that asymmetry is the design.** No `yad` on PATH, no hub above the edited path,
639
+ **It fails OPEN, and that asymmetry is the design.** No `yad` on PATH, no Product above the edited path,
418
640
  an unreadable `hub.json`, an unparseable payload, a `yad` that errors — every one of them ALLOWS,
419
641
  with a note on stderr. A local guardrail that failed closed would brick an agent's ability to edit
420
642
  anything the moment an install went sideways. The CI gate fails **closed** and is what actually
@@ -424,19 +646,19 @@ protects the ledger; this only shortens the feedback loop. `YAD_HOOK_DISABLE=1`
424
646
 
425
647
  - A `Bash` tool call (`sed -i epics/…`) is not intercepted; matching it would mean parsing shell for
426
648
  write intent.
427
- - The hook arms sessions **rooted at the hub**. A harness loads hooks from its own project root, so a
428
- session opened at the *workspace* (`project/`, with the hub at `project/product/`) never reads the
429
- hub's `.claude/settings.json` and the guard does not fire there — even though the decision itself
430
- resolves the hub correctly from any path. In that layout, open the session at the hub, or copy the
431
- entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need the
432
- hub-relative path).
649
+ - The hook arms sessions **rooted at the Product**. A harness loads hooks from its own project root, so a
650
+ session opened at the *workspace* (`project/`, with the Product at `project/product/`) never reads the
651
+ Product's `.claude/settings.json` and the guard does not fire there — even though the decision itself
652
+ resolves the Product correctly from any path. In that layout, open the session at the Product, or copy the
653
+ entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need
654
+ the Product-relative path).
433
655
 
434
- **Wiring** (installed by `yad setup` / `yad check --fix`, bridge hubs only):
656
+ **Wiring** (installed by `yad setup` / `yad check --fix`, verified Products only):
435
657
 
436
658
  | Path | Owner |
437
659
  |---|---|
438
- | `<hub>/hooks/ledger-guard.sh` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
439
- | `<hub>/.claude/settings.json` | **one entry**, merged additively into `hooks.PreToolUse`. See below. |
660
+ | `<product>/hooks/ledger-guard.sh` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
661
+ | `<product>/.claude/settings.json` | **one entry**, merged additively into `hooks.PreToolUse`. See below. |
440
662
 
441
663
  The settings file is the team's, so the rules around that one entry are deliberately conservative:
442
664
 
@@ -458,10 +680,11 @@ The settings file is the team's, so the rules around that one entry are delibera
458
680
  - **Both halves land together.** The script and the entry ride `yad update` as one: applying the
459
681
  entry without the script it points at would fire a missing command on every file edit.
460
682
 
461
- `.claude` is the only IDE target wired: it is the only one with a defined hook protocol. Other
462
- targets get the script, and the contract above is what they would wire by hand.
683
+ `.claude` and `.cursor` are the only IDE targets wired (`.cursor` through its own wrapper, above): they
684
+ are the only ones with a hook protocol yadflow has read. Other targets get the script, and the contract
685
+ above is what they would wire by hand.
463
686
 
464
- `yad doctor` reports the guard on a bridge hub, and distinguishes the three states that matter — it
687
+ `yad doctor` reports the guard on a verified Product, and distinguishes the three states that matter — it
465
688
  reads the same persisted `ideTargets` the wiring reads, so every gap it names is one the command it
466
689
  names can actually close:
467
690