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.
- package/CHANGELOG.md +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- 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 +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- 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 +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- 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 +12 -12
- 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 +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- 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 +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- 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 +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- 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 +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- 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 +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- 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 +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- 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 +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- 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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
102
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
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
|
|
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
|
|
182
|
-
Conventional-Commits subject). This is what lets a PR that changes the
|
|
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
|
|
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)
|
|
200
|
-
- any other head → a
|
|
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
|
|
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
|
|
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 **
|
|
227
|
-
PR), each degrades to a **PASS-with-note** — the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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`)
|
|
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`.
|
|
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
|
|
491
|
+
## Wiring the Product (`repo: hub`)
|
|
335
492
|
|
|
336
|
-
The
|
|
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 **
|
|
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
|
|
345
|
-
in `.sdlc/approvals.json
|
|
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
|
|
348
|
-
the
|
|
349
|
-
gates. Author the
|
|
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
|
|
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
|
|
356
|
-
|
|
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
|
|
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
|
|
361
|
-
`review: <artifact> (EP-<slug>)` title, and the
|
|
362
|
-
a tooling/code change to the
|
|
363
|
-
title + the code task template), so a PR that changes the
|
|
364
|
-
`yad check --fix` installs the same `checks/*.sh` scripts plus a standalone
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
428
|
-
session opened at the *workspace* (`project/`, with the
|
|
429
|
-
|
|
430
|
-
resolves the
|
|
431
|
-
entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need
|
|
432
|
-
|
|
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`,
|
|
656
|
+
**Wiring** (installed by `yad setup` / `yad check --fix`, verified Products only):
|
|
435
657
|
|
|
436
658
|
| Path | Owner |
|
|
437
659
|
|---|---|
|
|
438
|
-
| `<
|
|
439
|
-
| `<
|
|
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`
|
|
462
|
-
targets get the script, and the contract
|
|
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
|
|
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
|
|