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.
- package/CHANGELOG.md +371 -0
- package/README.md +160 -22
- package/bin/commands.mjs +46 -0
- package/bin/yad.mjs +506 -117
- package/cli/artifact-status.mjs +30 -11
- package/cli/capture.mjs +417 -0
- package/cli/checkpoint.mjs +47 -27
- package/cli/claims.mjs +165 -0
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1682 -90
- package/cli/epic-state.mjs +3460 -211
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +3 -0
- package/cli/fold.mjs +247 -0
- package/cli/gate-local.mjs +296 -0
- package/cli/gate.mjs +977 -183
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +264 -53
- package/cli/hubcommit.mjs +9 -19
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +54 -4
- package/cli/lib.mjs +161 -9
- package/cli/manifest.mjs +376 -38
- package/cli/migrate.mjs +683 -52
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +365 -86
- package/cli/openpr.mjs +193 -34
- package/cli/owners.mjs +266 -0
- package/cli/people.mjs +657 -0
- package/cli/plan.mjs +466 -125
- package/cli/platform.mjs +109 -128
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +40 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +24 -14
- package/cli/review.mjs +19 -22
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +127 -279
- package/cli/ship.mjs +6 -5
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +41 -16
- package/cli/tidy.mjs +18 -18
- package/cli/update-commit.mjs +21 -21
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +149 -70
- package/skills/sdlc/module-help.csv +24 -24
- package/skills/yad-analysis/SKILL.md +121 -59
- package/skills/yad-architecture/SKILL.md +30 -18
- package/skills/yad-architecture/references/contract-format.md +8 -5
- package/skills/yad-backfill/SKILL.md +10 -4
- package/skills/yad-backfill/references/backfill.md +8 -1
- package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
- package/skills/yad-change/SKILL.md +119 -42
- package/skills/yad-change/references/triage.md +42 -27
- package/skills/yad-checks/SKILL.md +68 -35
- package/skills/yad-checks/references/check-gates.md +310 -85
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
- package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
- package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
- package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
- package/skills/yad-commit/SKILL.md +4 -4
- package/skills/yad-connect-design/SKILL.md +5 -5
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +11 -11
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +66 -45
- package/skills/yad-connect-repos/references/code-context.md +4 -4
- package/skills/yad-connect-repos/references/hub-config.md +47 -51
- package/skills/yad-connect-repos/references/repos-registry.md +8 -7
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +5 -5
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +7 -7
- package/skills/yad-discovery/SKILL.md +131 -94
- package/skills/yad-discovery/references/discovery-schema.md +22 -6
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +13 -8
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +30 -15
- package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +81 -17
- package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
- package/skills/yad-epic/SKILL.md +173 -92
- package/skills/yad-epic/references/state-schema.md +610 -94
- package/skills/yad-hub-bridge/SKILL.md +57 -38
- package/skills/yad-hub-bridge/references/bridge.md +94 -67
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
- package/skills/yad-implement/SKILL.md +27 -13
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +61 -26
- package/skills/yad-pair-review/SKILL.md +7 -5
- package/skills/yad-pair-review/references/session-state.md +1 -1
- package/skills/yad-pr-template/SKILL.md +47 -26
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
- package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +6 -3
- package/skills/yad-review-gate/SKILL.md +203 -93
- package/skills/yad-review-gate/references/gating.md +232 -54
- package/skills/yad-run/SKILL.md +81 -51
- package/skills/yad-run/references/run-loop.md +61 -39
- package/skills/yad-ship/SKILL.md +15 -11
- package/skills/yad-spec/SKILL.md +21 -6
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +115 -50
- package/skills/yad-stories/SKILL.md +37 -21
- package/skills/yad-stories/references/story-schema.md +7 -6
- package/skills/yad-stub/SKILL.md +58 -47
- package/skills/yad-sync-repos/SKILL.md +2 -2
- package/skills/yad-test-cases/SKILL.md +32 -23
- package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +41 -18
- package/cli/roster.mjs +0 -164
- 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
|
|
15
|
-
| epic-open | the `Task:` trailer → `link.md` → the
|
|
16
|
-
| reconcile-debt | the `Task:` trailer → `link.md` → the
|
|
17
|
-
| verified-commits | each commit's platform signature-verification status
|
|
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 `
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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;
|
|
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
|
|
213
|
-
Conventional-Commits subject). This is what lets a PR that changes the
|
|
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
|
|
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
|
|
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 **
|
|
258
|
-
PR), each degrades to a **PASS-with-note** — the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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`)
|
|
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
|
|
537
|
+
## Wiring the Product (`repo: hub`)
|
|
379
538
|
|
|
380
|
-
The
|
|
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 **
|
|
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
|
|
389
|
-
in `.sdlc/approvals.json
|
|
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
|
|
392
|
-
the
|
|
393
|
-
gates. Author the
|
|
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
|
|
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:
|
|
565
|
+
Shape review PRs are held to the same rule as code-repo PRs: platform-Verified signatures only.
|
|
401
566
|
|
|
402
|
-
The
|
|
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
|
|
406
|
-
a tooling/code change to the
|
|
407
|
-
title + the code task template), so a PR that changes the
|
|
408
|
-
`yad check --fix` installs the same `checks/*.sh` scripts plus a standalone
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
472
|
-
session opened at the *workspace* (`project/`, with the
|
|
473
|
-
|
|
474
|
-
resolves the
|
|
475
|
-
entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need
|
|
476
|
-
|
|
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`,
|
|
702
|
+
**Wiring** (installed by `yad setup` / `yad check --fix`, verified Products only):
|
|
479
703
|
|
|
480
704
|
| Path | Owner |
|
|
481
705
|
|---|---|
|
|
482
|
-
| `<
|
|
483
|
-
| `<
|
|
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`
|
|
506
|
-
targets get the script, and the contract
|
|
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
|
|
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
|
|