yadflow 4.0.0-next.3 → 4.0.0
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 +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
|
@@ -11,9 +11,9 @@ 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
|
|
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) |
|
|
14
|
+
| lineage-check | the `Task:` trailer → `link.md` (`epic`, which must be the story ID's prefix, + `product-repo`, read from the base — §8); 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` (`epic`, the story ID's prefix; `product-repo`, from the base — §8) → 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` (`epic`, the story ID's prefix; `product-repo`, from the base — §8) → the Product epic's `thread`; every thread epic's `reconcile-debt.json` | `yad-change` (opens hotfix debt) |
|
|
17
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`) |
|
|
@@ -22,9 +22,9 @@ repo uses. Each reads conventions established by earlier steps — it invents no
|
|
|
22
22
|
## Resolving `<base>` (every gate that takes one)
|
|
23
23
|
|
|
24
24
|
The `<base>` argument is **optional**. The order is: the **argument**, else `SDLC_BASE`, else the
|
|
25
|
-
**configured** `default_branch` (
|
|
25
|
+
**configured** `default_branch` (from the Product settings file — see the next section), else the remote's
|
|
26
26
|
**published default branch** (`git symbolic-ref refs/remotes/origin/HEAD`), else `origin/main` —
|
|
27
|
-
the same order the CLI resolves (`cli/
|
|
27
|
+
the same order the CLI resolves (`cli/productcommit.mjs`, `cli/repo.mjs`), so a gate never diffs a
|
|
28
28
|
different range than the `yad` commands run beside it. Each candidate must actually **resolve**
|
|
29
29
|
before it is used, so a *dangling* `origin/HEAD` (trunk renamed, the old remote-tracking ref pruned)
|
|
30
30
|
falls through instead of failing the gate on a fully-fetched repo. CI always passes the base
|
|
@@ -40,6 +40,30 @@ scripts (they are standalone by design) and pinned byte-identical by a test —
|
|
|
40
40
|
base-taking gate, including `yad-backfill`'s `backfill-check.sh` and the installed copies this repo's
|
|
41
41
|
own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
|
|
42
42
|
|
|
43
|
+
## Reading the Product settings (every gate that reads them)
|
|
44
|
+
|
|
45
|
+
The Product's settings live under two names until v5 (E122): `.sdlc/product.json` (read first) and
|
|
46
|
+
`.sdlc/hub.json` (the older name, which a gate from an older yadflow reads by path). yadflow writes both
|
|
47
|
+
with identical bytes. A gate picks the file in this order:
|
|
48
|
+
|
|
49
|
+
1. `SDLC_PRODUCT_CONFIG` — the environment variable to use.
|
|
50
|
+
2. `SDLC_HUB_CONFIG` — the older name, still honoured.
|
|
51
|
+
3. `.sdlc/product.json`.
|
|
52
|
+
4. `.sdlc/hub.json` — an older Product that has only that name.
|
|
53
|
+
|
|
54
|
+
The gate **FAILs**, and names both, when the two disagree:
|
|
55
|
+
|
|
56
|
+
- both variables are set and name files with different contents:
|
|
57
|
+
`FAIL [product-settings]: SDLC_PRODUCT_CONFIG (…) and SDLC_HUB_CONFIG (…) name files that say different things. Set only SDLC_PRODUCT_CONFIG.`
|
|
58
|
+
- neither is set, and `.sdlc/product.json` and `.sdlc/hub.json` both exist with different bytes:
|
|
59
|
+
`FAIL [product-settings]: .sdlc/product.json and .sdlc/hub.json say different things …`. Run
|
|
60
|
+
`yad migrate --apply` in the Product (or `--apply --keep product` / `--keep hub`) to choose the copy
|
|
61
|
+
to keep, and commit both. The CLI refuses the same case with `YAD-STATE-008`.
|
|
62
|
+
|
|
63
|
+
The `product_config` function is duplicated verbatim across the gates, like the base resolution above.
|
|
64
|
+
Every gate stops on its failure except **risk-map**, which is advisory: there a disagreement is a
|
|
65
|
+
`note [risk-map]: …` line, neither file is read, and the gate still passes (§10).
|
|
66
|
+
|
|
43
67
|
## 1. spec-link (`templates/checks/spec-link.sh`)
|
|
44
68
|
|
|
45
69
|
- Checks every non-merge commit in `<base>..HEAD` **per commit** (not aggregated across the range),
|
|
@@ -54,8 +78,12 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
|
|
|
54
78
|
- For every other commit, requires a `Task: <story>-<task>` trailer. **FAIL** if absent.
|
|
55
79
|
- The trailer must be a well-formed `<story>-T<NN>` id. **FAIL** on a malformed trailer (e.g.
|
|
56
80
|
`EP-demo-S01` with no `-T<NN>`) rather than letting it slip through the suffix-strip.
|
|
57
|
-
- Strips the `-T<NN>` suffix from the task to get `<story
|
|
58
|
-
|
|
81
|
+
- Strips the `-T<NN>` suffix from the task to get `<story>`, which must be a story ID —
|
|
82
|
+
`EP-<slug>-S<n>`, the slug in lowercase letters, digits and dashes (what `yad-stories` writes).
|
|
83
|
+
**FAIL** by name otherwise (E117 review 1): a trailer of `EP-x-S01/.-T1` named
|
|
84
|
+
`specs/EP-x-S01/./link.md`, which exists, and walked past the Product-reading gates. The four
|
|
85
|
+
Product-reading gates apply the same pattern.
|
|
86
|
+
- Requires `specs/<story>/link.md` to exist. **FAIL** if missing.
|
|
59
87
|
- An empty range (no non-merge commits) **PASSes**.
|
|
60
88
|
- Portable across bash 3.2 (macOS) and 4+ (no `mapfile`).
|
|
61
89
|
- **Fails closed** when `<base>` can't be resolved (so a shallow clone / wrong base never PASSes blind).
|
|
@@ -78,12 +106,16 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
|
|
|
78
106
|
is refused too, even when it holds plain files — the surface rules below are spelled in lowercase and
|
|
79
107
|
would never see them. `ſpecs` (long s) counts as a second spelling: APFS folds it into `specs`.
|
|
80
108
|
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
|
|
82
|
-
(`specs/EP-x-S01/` and `specs/ep-x-s01/`) — one folder
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
`contractſ/`) is refused, and so are two story folders that are one name on a Mac
|
|
110
|
+
(`specs/EP-x-S01/` and `specs/ep-x-s01/`) — one folder there, two to rules that read exact bytes.
|
|
111
|
+
Names are folded as backfill-check folds them (E121): ASCII case plus the 13 characters a Mac folds
|
|
112
|
+
into ASCII alone (U+00DF ß and U+1E9E ẞ as `ss`, U+017F ſ as `s`, U+212A Kelvin sign as `k`, the
|
|
113
|
+
ligatures U+FB00–U+FB06 as their letters, U+037E and U+1FEF as `;` and `` ` ``), so
|
|
114
|
+
`specs/EP-<Kelvin>iosk-S01/` beside `specs/EP-kiosk-S01/` is refused. Other letters are **not**
|
|
115
|
+
caught: `specs/EP-démo-S01/` beside `specs/EP-DÉMO-S01/`, or the same name in NFC and NFD form, is one
|
|
116
|
+
folder on a Mac and two here. Such a twin that holds a slice fails anyway: its folder is not a story
|
|
117
|
+
ID (E117). The tree is read one record per line, with a newline inside a name written as `?`, so an
|
|
118
|
+
odd file name cannot add a record.
|
|
87
119
|
A file named exactly `specs` is refused too: the folder has to go there.
|
|
88
120
|
**So a code repo that already has a `Specs/` folder or submodule (an iOS test folder, say), or a
|
|
89
121
|
top-level file named `specs` in any case, fails every PR until it is renamed** — on a Mac checkout,
|
|
@@ -103,14 +135,25 @@ own CI runs, plus an assertion that each one actually *assigns* `BASE` from it.
|
|
|
103
135
|
- Best-effort fidelity: when the product repo is reachable (via `link.md`'s `product-repo` path),
|
|
104
136
|
require `link.md`'s pinned `contract-lock` hash to match the product repo's current
|
|
105
137
|
`contract-lock.json`. A claimed change that still pins the **old** lock **FAILS** — re-run
|
|
106
|
-
`yad-spec` so the slice matches the re-locked contract.
|
|
138
|
+
`yad-spec` so the slice matches the re-locked contract. **Where** the lock is read from is not the
|
|
139
|
+
PR's to choose (E117) — see "Resolving `product-repo`" in §8.
|
|
140
|
+
- Each story's slice must sit under a story ID (`specs/EP-<slug>-S<n>/contracts/`) and come with its
|
|
141
|
+
`specs/<story>/link.md` — `yad-spec` writes the two together. A slice that is **added or changed**
|
|
142
|
+
without them **FAILS** by name (E117 review 2): deleting `link.md` used to drop a stale pin, and a
|
|
143
|
+
Spec Kit folder such as `specs/001-name/contracts/` can be tied to no lock. A non-story folder
|
|
144
|
+
whose changed files are all **deletions** passes, so it can always be cleaned up. A story may drop
|
|
145
|
+
its `link.md` only when its **whole** slice is gone at HEAD (review 3): deleting `link.md` with part
|
|
146
|
+
of a slice left the rest with nothing pinning it. This closes the deferral E115 stated as a cost.
|
|
147
|
+
- When the Product folder **was reached** and has no `epics/<epic>/`, the slice is an **orphan** and
|
|
148
|
+
**FAILS** — it used to say "not reachable" and defer. Removing an orphan's slice (deletions only)
|
|
149
|
+
passes: no lock exists for it to contradict.
|
|
107
150
|
- The fidelity check runs for **every story whose slice the diff touches**, and **aggregates**: each
|
|
108
151
|
story reports (matched / stale / deferred), and any stale pin fails the gate. `git diff
|
|
109
152
|
--name-only` is path-sorted, so reading one story off the first changed path validated whichever
|
|
110
153
|
story sorted first and left the rest unpinned — a later story pinning a stale hash passed, and a
|
|
111
154
|
first story with no `link.md` deferred the whole check before the stale one was ever read
|
|
112
155
|
(issue #161). One clean-or-deferred story never masks another's stale pin, the same rule spec-link
|
|
113
|
-
applies per commit.
|
|
156
|
+
applies per commit. (A story with no `link.md` now FAILs rather than defers — see above.)
|
|
114
157
|
- A lock the gate can **read but not parse** (truncated, half-written, or a changed schema — no
|
|
115
158
|
`"hash": "sha256:…"`) **FAILS**. It used to short-circuit the comparison into the "hash matches"
|
|
116
159
|
note, i.e. the gate affirmatively reported a match it never made.
|
|
@@ -199,7 +242,7 @@ No unsigned commits reach merge — on the Product and on every connected repo.
|
|
|
199
242
|
|
|
200
243
|
**There is no author allowlist** (E62). yadflow keeps no list of people: write access to the repo
|
|
201
244
|
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
|
|
245
|
+
and `yad setup` no longer generate `.sdlc/verified-authors`, and no gate reads it or the Product settings'
|
|
203
246
|
`verified_authors` any more (`SDLC_VERIFIED_AUTHORS` is gone too). When an old `.sdlc/verified-authors`
|
|
204
247
|
file is still on disk, the gate prints:
|
|
205
248
|
|
|
@@ -240,9 +283,10 @@ non-merge commit in `<base>..HEAD`:
|
|
|
240
283
|
- **Subject** must be `<type>: <description>` with `<type>` a known Conventional-Commits type
|
|
241
284
|
(`feat|fix|docs|refactor|test|perf|build|ci|chore|revert` — keep in sync with `cli/manifest.mjs`
|
|
242
285
|
`COMMIT_TYPES`) and **no trailing period** — mirroring `cli/commit.mjs` `buildCommitMessage`.
|
|
243
|
-
- **Trailers**, when present, appear in the fixed order `Task → Contract-Change →
|
|
286
|
+
- **Trailers**, when present, appear in the fixed order `Task → Contract-Change → Ledger-Override →
|
|
287
|
+
Co-Authored-By` (`Ledger-Override` only from `yad commit --manual --reason`; this gate does not check it).
|
|
244
288
|
- Merge/squash commits (2+ parents) are skipped — their platform-generated subjects are not authored.
|
|
245
|
-
- **Profiles** (`--profile code|hub
|
|
289
|
+
- **Profiles** (`--profile code|product`; `hub`, the old name for `product`, still works): the subject rule is identical on both; the gate never requires
|
|
246
290
|
the `Task:` trailer (spec-link owns that on code repos; Product commits are not task-scoped).
|
|
247
291
|
- **Fails closed** when `<base>` can't be resolved.
|
|
248
292
|
`<base>` is optional — see [Resolving `<base>`](#resolving-base-every-gate-that-takes-one).
|
|
@@ -255,7 +299,7 @@ from the event payload):
|
|
|
255
299
|
- `--profile code` (default) → a Conventional-Commits subject `<type>: <description>`, no trailing
|
|
256
300
|
period (`config.yaml build.pr_title_style: same_as_commit_subject` — one task = one PR, the title is
|
|
257
301
|
the squash-merge subject).
|
|
258
|
-
- `--profile
|
|
302
|
+
- `--profile product` (old name `hub`, still accepted) → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
|
|
259
303
|
- `review/EP-*` head (or no `--head` — stays strict) → a Shape artifact-review title
|
|
260
304
|
`review: <artifact> (EP-<slug>)`, the shape `yad gate open` creates.
|
|
261
305
|
- any other head → a tooling/code change to the Product itself, so it follows the `code` convention (a
|
|
@@ -280,7 +324,7 @@ catches a free-form description that bypassed it:
|
|
|
280
324
|
|
|
281
325
|
- `--profile code` (default) → requires `## Summary`, `## Impact & Risk`, `## Checklist`, and a filled
|
|
282
326
|
`Risk level:` (`low|medium|high`).
|
|
283
|
-
- `--profile
|
|
327
|
+
- `--profile product` (old name `hub`, still accepted) → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
|
|
284
328
|
- `review/EP-*` head (or no `--head`) → requires the artifact-review template: `## Artifact under
|
|
285
329
|
review`, `## Impact & Risk (front-half)` (or `(Shape)`), `## Checklist`, and a `Risk tags:` line.
|
|
286
330
|
- any other head → a Product tooling PR, so it requires the `code` task template (`## Summary`,
|
|
@@ -295,7 +339,7 @@ the author then reads "does not use the template" while looking at a description
|
|
|
295
339
|
it (#164). GitHub is unaffected (`github.event.pull_request.body` is not truncated). Two mitigations,
|
|
296
340
|
both shipped:
|
|
297
341
|
|
|
298
|
-
- the GitLab MR templates (`yad-pr-template` `templates/gitlab/…` and `templates/
|
|
342
|
+
- the GitLab MR templates (`yad-pr-template` `templates/gitlab/…` and `templates/product/gitlab/…`) carry
|
|
299
343
|
the constraint as a comment and keep every required section early, so a truncated body still passes;
|
|
300
344
|
- when a required section is missing **and** the body it read is ≥ 2700 characters, the gate prints a
|
|
301
345
|
`NOTE` naming the truncation and the fix — reorder the required sections above the cutoff and push
|
|
@@ -324,6 +368,50 @@ gate now **prints that note**, so a deferred check is never mistaken for a passe
|
|
|
324
368
|
duplicated verbatim across the four scripts (they are standalone by design) and a test asserts the
|
|
325
369
|
four copies stay byte-identical.
|
|
326
370
|
|
|
371
|
+
**The PR under check does not choose where the Product is read from (E117).** `link.md` is a file the
|
|
372
|
+
PR's own author writes. Before E117 a PR could point `product-repo` at a path that does not exist — the
|
|
373
|
+
gate called the Product "not reachable" and passed — or at a folder it committed itself, holding a
|
|
374
|
+
hand-made `contract-lock.json` with the hash it pins. Two rules close both:
|
|
375
|
+
|
|
376
|
+
| Rule | What the gate does | Why |
|
|
377
|
+
| --- | --- | --- |
|
|
378
|
+
| The repo's **record** comes first (E120) | `.sdlc/product-link.json` in the code repo — `git_url`, `path` (from the repo root: where CI checks the Product out, `.yad/product` by default) and `default_branch` — is written by `yad check --fix` / `yad update` from the Product and merged on its own. Read from the **base**, its `path` wins over every `link.md`: no gate takes where the Product lives from the PR. A record the PR adds or changes counts once it merges (a note says so). A `link.md` whose own `product-repo` reaches a **different** folder here gets a note; one that reaches nothing (a path on its author's machine) is passed over in silence. When the record's path reaches nothing — a developer's own run, where nobody cloned into it — the base's `link.md` order below is used if **it** reaches a folder (still the base's, never the PR's); otherwise the record stands and the gate **defers** (CI has no Product checkout) — it is not the PR's choice, so E119's first-spec FAIL does not apply. With no record on the base, the order below is kept. | A record is one reviewed fact for the whole repo, where `link.md` is one per story and written by each PR's author. |
|
|
379
|
+
| `product-repo` comes from the **base** | Where the Product lives is a fact about the whole repo, so the value is, in order: `link.md` as it stands on the base; else a sibling `specs/<story>/link.md` on the base (a story new in this PR, or a `link.md` that never had a value) — one of the same epic first, and the first whose value reaches a folder here, so one stale `link.md` cannot hand every new story a deferral; else — the repo's first spec — the PR's value. A PR whose value differs gets a note (`… names product-repo 'Y', but specs/<other>/link.md on main says 'X' — the Product is read from the base value; a new one counts once it merges`). | Only `product-repo` is read from the base. `contract-lock` is read as the PR leaves it — a re-spec PR must update the pin. |
|
|
380
|
+
| A **kept** Product is read from the **base commit** | Whether the Product is kept in this repo (a monorepo: the Product and the code in one git repo) is asked of the **base**, from the path's text alone, before anything on disk: the base tracks `<path>/.sdlc/product.json` or `<path>/.sdlc/hub.json` (both, until v5), which every Product commits. The disk is the PR's — a PR that deleted or moved the folder read as untracked, and deferred. Its `epics/` is then written out from the base commit through a throwaway index (`git read-tree` + `git checkout-index`), with the work tree pointed at an empty temp folder, and read from there. Not `git archive`, which drops whatever `.gitattributes` marks `export-ignore`; and not with the real work tree, whose `.gitattributes` is the PR's — `*.md text eol=crlf` or `working-tree-encoding=UTF-16` there wrote the base's files unreadable. A symlink or submodule inside the base's `epics/` is **FAIL** by name: a link written out still points where it pointed (an absolute one into the PR's working tree), and a submodule comes out as an empty folder. Any other tracked file under a Product path — a planted folder, or one file force-added under an ignored checkout — **FAILs** by name, with the fix (`git rm -r --cached`). A path that runs through a tracked symlink, submodule or file: **FAIL** by name. The path is walked twice — part by part as written, and again from where it really lands, resolved in one step from the gate's own shell — so an alias such as Linux's `/proc/self/cwd`, which means a different folder to each process, cannot lead the gate into the PR's own tree (review 5). A folder that is there but cannot be entered to find where it really is **FAILs** rather than being read unchecked (review 6). A path whose real location is inside the repo's own git folder also **FAILs**: git never lists `.git/` as tracked, yet branch names shape the folders in it (review 7). The git folder is compared as a folder, not a spelling, so `.GIT` counts on a disk that ignores case (review 8). | "Inside the code repo" is not the test. CI can only check a second repo out inside the workspace (`actions/checkout` `path:`), and an untracked checkout there is not the PR's. |
|
|
381
|
+
| One story ID shape | A story must be `EP-<slug>-S<n>` (lowercase slug), in all four gates and spec-link. **FAIL** by name otherwise. | A story written as a path (`EP-x-S01/.`) named a `link.md` the base read never found. |
|
|
382
|
+
|
|
383
|
+
The costs, stated: in a monorepo the Product is read from the base, so a PR that re-locks the contract
|
|
384
|
+
**and** changes the slice fails the pin check — merge the re-lock first, the rule the gate already states
|
|
385
|
+
("re-locked upstream first"); and a PR that adds a new epic or story **and** its code fails
|
|
386
|
+
contract-check, lineage-check, epic-open and reconcile-debt as an orphan — merge the Product change
|
|
387
|
+
first. When `product-repo` reaches **nothing** — neither a kept Product by its text nor any folder on
|
|
388
|
+
disk — a Product the base keeps (a tracked `.sdlc/product.json` or `.sdlc/hub.json` with an `epics/` folder beside it) that holds
|
|
389
|
+
the story's epic is the one read: a value spelled past the repo's own folder name, or another machine's
|
|
390
|
+
absolute path, cannot be folded to it, and a PR that deleted or moved it left nothing on disk to walk
|
|
391
|
+
(review 3). Only then, and only one holding the epic (review 4): a kept Product used to win over the
|
|
392
|
+
real checkout, so a merged test fixture shaped like a Product was read instead. Two that hold it
|
|
393
|
+
**FAIL**. In a two-repo setup whose CI checks nothing out, a fixture that does hold the epic is read
|
|
394
|
+
where the gate used to defer — no weaker than that deferral. A kept Product's `.gitattributes` entries
|
|
395
|
+
are dropped before it is written out. Frontmatter is read with CR
|
|
396
|
+
stripped before the `---` fences are matched, so a CRLF Product reads the same as an LF one. The case of a path is folded only where git folds it
|
|
397
|
+
(`core.ignorecase`, a Mac checkout), and in ASCII only. A Product absent from CI still defers with a note, as
|
|
398
|
+
before — with one exception in contract-check (E119): on a repo's **first spec**, where no `link.md` on the
|
|
399
|
+
base names a Product and `product-repo` is the PR's own value, a diff that changes a slice and whose
|
|
400
|
+
`product-repo` reaches nothing **FAILs** by name ("… is this repo's first spec …", then "… reaches
|
|
401
|
+
nothing", or "… names no product-repo" when `link.md` has none). A Product that IS reached — checked out, or
|
|
402
|
+
kept in this repo and read from the base — but holds the epic's folder with no ledger in it still defers:
|
|
403
|
+
it is not "nowhere", and the PR cannot shape a base copy. A first
|
|
404
|
+
spec with no slice is unchanged (the surface is read from paths alone), and so is one that only removes a
|
|
405
|
+
slice. **Cost:** a repo whose CI does not check out the Product cannot merge a first spec that carries a
|
|
406
|
+
slice until it does. The other three gates judge the epic, not the surface, and still defer there (E120). `yad doctor` warns `checks:product-path-blind` for an older copy of any of the four.
|
|
407
|
+
|
|
408
|
+
**The PR does not choose the epic either (E118).** lineage-check, epic-open and reconcile-debt-check
|
|
409
|
+
read `epic:` from `link.md`. It must equal the story ID's prefix — story `EP-checkout-S01` belongs to
|
|
410
|
+
epic `EP-checkout`, which is what `yad-spec` writes. Any other value — an empty or missing one
|
|
411
|
+
included — **FAILs** by name. Before E118 a
|
|
412
|
+
story of a sealed epic could name an open one and pass epic-open. contract-check has always worked the
|
|
413
|
+
epic out from the story ID.
|
|
414
|
+
|
|
327
415
|
- **lineage-check** — reads the Product epic's work-item type and `parent` frontmatter. The type has
|
|
328
416
|
two names and the gate reads `kind:` first, then `type:` — the same order the CLI uses, asserted by a
|
|
329
417
|
table test in `cli/test-checks.mjs`. A `feature` or `chore` (genesis) epic
|
|
@@ -334,7 +422,8 @@ four copies stay byte-identical.
|
|
|
334
422
|
`shipped`. A commit whose owning epic is sealed **FAILS**: new behaviour cannot mutate a shipped epic;
|
|
335
423
|
it must land in a new threaded change-epic. This is what stops the Shape artifacts from going stale.
|
|
336
424
|
- **reconcile-debt** — resolves the epic's `thread` (its `thread:` frontmatter, else the epic id) and
|
|
337
|
-
scans every thread epic's `reconcile-debt.json`.
|
|
425
|
+
scans every thread epic's `reconcile-debt.json`. A Product that was reached with no such epic **FAILs** as an orphan story
|
|
426
|
+
link, as lineage-check and epic-open do (E117 review 2). An **open** entry the current epic does not own
|
|
338
427
|
**FAILS** the change (the thread is frozen until the hotfix debt is paid: artifacts updated + a
|
|
339
428
|
regression test added, then `status: paid`). Thread-scoped — only the affected thread freezes.
|
|
340
429
|
|
|
@@ -493,6 +582,15 @@ The gates run identically under either CI; the config just invokes the scripts w
|
|
|
493
582
|
Dependencies are cached with `actions/cache` (npm's `~/.npm`, pnpm's store, and the Corepack home
|
|
494
583
|
holding the pinned manager, keyed on the lockfiles and package.json) rather than setup-node's
|
|
495
584
|
npm-only `cache:`, which must name the manager before package.json has been read.
|
|
585
|
+
- **Pinned actions, read-only token.** Every `uses:` in the GitHub templates names a commit SHA, with
|
|
586
|
+
the version after it in a comment (`actions/checkout@<sha> # v4.4.0`). A tag such as `@v4` can be
|
|
587
|
+
moved to other code; a SHA cannot. Each workflow also sets a top-level `permissions:` block that is
|
|
588
|
+
read-only, so no job gets write access unless it asks for it. If your own Dependabot bumps a SHA in
|
|
589
|
+
an installed copy that `.sdlc/managed.json` records, `yad check` then reports that file as
|
|
590
|
+
`modified`, and `yad update` keeps your copy rather than overwriting it — so that file stops
|
|
591
|
+
receiving template changes. (`--overwrite-local` replaces it after saving `<file>.yad-orig`; a copy
|
|
592
|
+
with no record reads as `outdated`, and a plain `yad update` replaces it, saving `<file>.yad-orig`
|
|
593
|
+
first.) To stay on the template, leave the pins to yadflow releases.
|
|
496
594
|
- **GitLab CI** — `templates/gitlab/yad-checks.gitlab-ci.yml` → `.gitlab/ci/yad-checks.yml`, pulled in
|
|
497
595
|
by the root `.gitlab-ci.yml`'s `include:`. The jobs run on `merge_request_event` with `GIT_DEPTH: 0`,
|
|
498
596
|
passing `origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`; the pattern jobs read `$CI_MERGE_REQUEST_TITLE`
|
|
@@ -505,6 +603,47 @@ The gates run identically under either CI; the config just invokes the scripts w
|
|
|
505
603
|
(`templates/gitlab/.gitlab-ci.yml`) carries the same Node default, exact Nx range, and dependency
|
|
506
604
|
installer and test-worker-cap semantics. All `--profile code`.
|
|
507
605
|
|
|
606
|
+
### Checking the Product out in CI (E120)
|
|
607
|
+
|
|
608
|
+
The four Product-reading gates can only read a Product that is on disk. Before each of them, both
|
|
609
|
+
workflows run `checks/product-checkout.sh` (wired into every code repo). It reads
|
|
610
|
+
`.sdlc/product-link.json` **from the base** — never as the PR leaves it — and, when the
|
|
611
|
+
`YAD_PRODUCT_TOKEN` secret is set, clones the Product's `default_branch` (depth 1; the remote's own default when the record names
|
|
612
|
+
none) into the record's `path`. To turn it on:
|
|
613
|
+
|
|
614
|
+
1. From the Product, run `yad check --fix --push`: it writes `.sdlc/product-link.json` into each
|
|
615
|
+
connected code repo and commits it to the default branch. Set the Product's `git_url` in its
|
|
616
|
+
`.sdlc/product.json` first (`yad setup` fills it from the `origin` remote). On a Product that has only
|
|
617
|
+
`.sdlc/hub.json`, set it there, and never create a `product.json` beside it by hand; when both exist,
|
|
618
|
+
run `yad migrate --apply --keep product` after the edit.
|
|
619
|
+
2. Make a token that can **read** the Product repository — on GitHub a fine-grained personal access
|
|
620
|
+
token or a GitHub App token with `contents: read` on that one repo (the workflow's own `GITHUB_TOKEN`
|
|
621
|
+
cannot read another private repo); on GitLab a project or group access token with `read_repository`.
|
|
622
|
+
3. Add it to the **code repo** as the `YAD_PRODUCT_TOKEN` secret (GitHub: Settings → Secrets and
|
|
623
|
+
variables → Actions; GitLab: Settings → CI/CD → Variables, **masked**).
|
|
624
|
+
|
|
625
|
+
**Who can read the token.** The workflow runs on the PR's own copy of `checks/product-checkout.sh`, so
|
|
626
|
+
anyone who can push a branch to the code repo can read `YAD_PRODUCT_TOKEN` — edit the script, open a
|
|
627
|
+
PR. (A PR from a fork gets no secret, and defers.) So give it **read** access to the Product repository
|
|
628
|
+
and nothing else. On GitLab, a variable marked **protected** is not handed to pipelines of unprotected
|
|
629
|
+
branches, so merge requests from feature branches would defer: leave it unprotected, masked, and
|
|
630
|
+
read-only.
|
|
631
|
+
|
|
632
|
+
What it does in each case:
|
|
633
|
+
|
|
634
|
+
| Case | Result |
|
|
635
|
+
| --- | --- |
|
|
636
|
+
| No record on the base, no `YAD_PRODUCT_TOKEN` (every PR from a fork), or no `git_url` in the record | A note; nothing is cloned, and the gates defer as before. |
|
|
637
|
+
| The record's `path` is outside the repo, `.`, absolute, or under `.git` | **FAIL** — CI can only check a second repo out inside the workspace. |
|
|
638
|
+
| A folder on the way to `path` is a symlink (one the PR tracks, say) | **FAIL** — git would clone through it, to wherever it points. |
|
|
639
|
+
| The `path` holds files this repo tracks | **FAIL** — the gates refuse a tracked Product path anyway. |
|
|
640
|
+
| The folder is already there | Left as it is. |
|
|
641
|
+
| The clone fails with a token set | **FAIL**, naming the URL and branch — a broken setup must not read like a deferral. |
|
|
642
|
+
|
|
643
|
+
The token is handed to git through its environment (`GIT_CONFIG_COUNT`, git 2.31+) as an HTTP header,
|
|
644
|
+
never on a command line or in a URL; on GitHub the encoded form is masked too. An `ssh` `git_url`
|
|
645
|
+
(`git@host:org/repo.git`) is cloned as the same repo over `https`.
|
|
646
|
+
|
|
508
647
|
## Sync with existing CI (merge, never clobber)
|
|
509
648
|
|
|
510
649
|
`wire` is **additive**: it brings the SDLC gates into a repo that may already have CI, without ever
|
|
@@ -534,15 +673,23 @@ break nor reorder them; job names are `yad-`prefixed to avoid collisions.
|
|
|
534
673
|
**Idempotent.** The two markers plus the include-entry check make a re-run a no-op. This is how a repo
|
|
535
674
|
that already had its own pipeline keeps it and still gains the gates.
|
|
536
675
|
|
|
537
|
-
## Wiring the Product (`repo:
|
|
676
|
+
## Wiring the Product (`repo: product`)
|
|
538
677
|
|
|
539
|
-
The Product is itself a repo on a platform (recorded in `.sdlc/
|
|
540
|
-
`yad-connect-repos action: detect-
|
|
678
|
+
The Product is itself a repo on a platform (recorded in `.sdlc/product.json` by
|
|
679
|
+
`yad-connect-repos action: detect-product`). `wire repo: product` targets `{project-root}` and uses the same
|
|
541
680
|
merge-not-clobber logic, with a **Product-flavored gate set** appropriate to a "thinking" repo (it has no
|
|
542
681
|
`specs/` or `package.json` build). **What yadflow wires today** (`PRODUCT_WIRING`): `commit-message`,
|
|
543
|
-
`pr-title`, `pr-template` and `ledger-guard` in `yad-
|
|
544
|
-
and the `yad-update-guard`.
|
|
545
|
-
|
|
682
|
+
`pr-title`, `pr-template` and `ledger-guard` in `yad-product-checks`, `verified-commits` in its own workflow,
|
|
683
|
+
and the `yad-update-guard`. On GitHub the `ledger-guard` job is given **no** `GH_TOKEN` on purpose: its
|
|
684
|
+
bot exemption checks the author text and the Verified badge, and the badge proves only that the
|
|
685
|
+
*committer* signed, so any contributor who signs could claim the bot's name. Without a token the badge
|
|
686
|
+
lookup fails and every bot-attributed commit that changes a ledger file is refused — safe, because the
|
|
687
|
+
gate-sync bot pushes only to the review PR's base (normally the default branch), never to a PR. This
|
|
688
|
+
holds on GitHub-hosted runners; a self-hosted runner with a stored `gh auth login` would answer the
|
|
689
|
+
lookup anyway. (On GitLab a project CI/CD variable reaches every job, so
|
|
690
|
+
the `GITLAB_TOKEN` / `SDLC_API_TOKEN` that verified-commits needs reaches ledger-guard too, and the gap is
|
|
691
|
+
open there.) The three below are **not shipped** — they are scripts a team
|
|
692
|
+
writes itself if it wants them:
|
|
546
693
|
- **owner-set** — every `epic.md` (and forward artifact) under `epics/EP-*/` carries an `owner`.
|
|
547
694
|
- **contract-locked** — where an epic has a `contract.md`, its surface hash matches
|
|
548
695
|
`.sdlc/contract-lock.json` (reuse the recipe in
|
|
@@ -561,21 +708,21 @@ The Product **does** run the verified-commits gate — `yad check --fix` install
|
|
|
561
708
|
plus a standalone workflow (`templates/github/yad-verified-commits.yml` →
|
|
562
709
|
`.github/workflows/yad-verified-commits.yml`, or the GitLab fragment
|
|
563
710
|
`templates/gitlab/yad-verified-commits.gitlab-ci.yml` → `.gitlab/ci/yad-verified-commits.yml` +
|
|
564
|
-
its one include line) whenever `.sdlc/
|
|
711
|
+
its one include line) whenever `.sdlc/product.json` has a platform with a verified ledger. So the
|
|
565
712
|
Shape review PRs are held to the same rule as code-repo PRs: platform-Verified signatures only.
|
|
566
713
|
|
|
567
714
|
The Product **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
|
|
568
|
-
`--profile
|
|
715
|
+
`--profile product`. The pattern gates split by the PR/MR **head branch** (passed via `--head`): a
|
|
569
716
|
`review/EP-*` head is a Shape review PR — Conventional-Commits commit subjects, a
|
|
570
717
|
`review: <artifact> (EP-<slug>)` title, and the Product artifact-review template body; **any other head is
|
|
571
718
|
a tooling/code change to the Product itself** and follows the `code` convention (a Conventional-Commits
|
|
572
719
|
title + the code task template), so a PR that changes the Product's own workflows/checks can pass.
|
|
573
720
|
`yad check --fix` installs the same `checks/*.sh` scripts plus a standalone Product workflow
|
|
574
|
-
(`templates/github/yad-
|
|
575
|
-
`templates/gitlab/yad-
|
|
721
|
+
(`templates/github/yad-product-checks.yml` → `.github/workflows/yad-product-checks.yml`, or the GitLab fragment
|
|
722
|
+
`templates/gitlab/yad-product-checks.gitlab-ci.yml` → `.gitlab/ci/yad-product-checks.yml` + its one include
|
|
576
723
|
line). Code repos run the same three with `--profile code` inside the main `yad-checks` workflow.
|
|
577
724
|
|
|
578
|
-
## The agent guardrail (`templates/hooks/ledger-guard.
|
|
725
|
+
## The agent guardrail (`templates/hooks/ledger-guard.mjs` + `yad hook ledger-guard`)
|
|
579
726
|
|
|
580
727
|
Not a CI gate — a **harness hook**, and the only piece of yadflow that runs *inside* an agent's tool
|
|
581
728
|
loop. It exists because of the gap #171 reported: `checks/ledger-guard.sh` is correct and blocking,
|
|
@@ -595,21 +742,29 @@ Two harnesses match that contract, and `yad check --fix` wires both:
|
|
|
595
742
|
|
|
596
743
|
| Harness | File | Event | Command |
|
|
597
744
|
|---|---|---|---|
|
|
598
|
-
| Claude Code | `.claude/settings.json` | `PreToolUse` | `"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.
|
|
599
|
-
| Cursor | `.cursor/hooks.json` | `preToolUse` | `hooks/ledger-guard-cursor.
|
|
745
|
+
| Claude Code | `.claude/settings.json` | `PreToolUse` | `node "$CLAUDE_PROJECT_DIR/hooks/ledger-guard.mjs"` |
|
|
746
|
+
| Cursor | `.cursor/hooks.json` | `preToolUse` | `node hooks/ledger-guard-cursor.mjs` |
|
|
747
|
+
|
|
748
|
+
Any other harness that can run a command and read those two exit codes can use
|
|
749
|
+
`node <product>/hooks/ledger-guard.mjs` by hand.
|
|
600
750
|
|
|
601
|
-
|
|
602
|
-
|
|
751
|
+
**Node scripts, not shell scripts (E113).** Every hook was a bash script until E113; each is now a
|
|
752
|
+
dependency-free Node script, so it runs on Windows as it does on macOS and Linux, and needs no execute
|
|
753
|
+
bit. Each finds the Product from its own location, never from a harness variable. On Windows, Claude
|
|
754
|
+
Code runs hook commands in Git Bash (PowerShell without it, where the entry does not run), and Cursor
|
|
755
|
+
runs them through PowerShell, which runs `node hooks/<script>.mjs` as it is. An entry naming an old
|
|
756
|
+
`hooks/*.sh` is rewritten by `yad check --fix`, and the old script is deleted when `.sdlc/managed.json`
|
|
757
|
+
proves yad wrote it unedited and no settings file still runs it.
|
|
603
758
|
|
|
604
759
|
**Cursor needs a second protocol, and this is the trap.** Its `preToolUse` is a *permission hook*:
|
|
605
760
|
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.
|
|
761
|
+
hook's schema blocks the action". `ledger-guard.mjs` prints **nothing** when it allows — and empty
|
|
607
762
|
stdout is invalid JSON. Wiring it straight into Cursor would therefore have blocked **every** file
|
|
608
763
|
write in a verified project: fail-closed on everything, the opposite of this guard's whole design, and
|
|
609
764
|
invisible to any test that only checks that a deny denies.
|
|
610
765
|
|
|
611
|
-
So Cursor gets `hooks/ledger-guard-cursor.
|
|
612
|
-
`.cursor`. It calls `yad hook ledger-guard --format cursor`
|
|
766
|
+
So Cursor gets `hooks/ledger-guard-cursor.mjs`, installed only for a project whose targets include
|
|
767
|
+
`.cursor`. It calls `yad hook ledger-guard --format cursor` itself and guarantees a
|
|
613
768
|
permission answer on stdout, whatever happens:
|
|
614
769
|
|
|
615
770
|
| | stdout | exit |
|
|
@@ -631,18 +786,18 @@ as the code for "no JSON to read", so it would discard the reason — and naming
|
|
|
631
786
|
the transition is the entire point of speaking at edit time. The reason also goes to stderr, where
|
|
632
787
|
Cursor logs it.
|
|
633
788
|
|
|
634
|
-
The wrapper takes **no arguments**, and every fail-open branch
|
|
789
|
+
The wrapper takes **no arguments**, and every fail-open branch (no `yad` on
|
|
635
790
|
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:
|
|
791
|
+
empty stdout that would block. **Exit 2 is converted into a deny**, not an allow: the wrapper
|
|
637
792
|
resolves `yad` from the Product's own `node_modules/yadflow` before `PATH`, so a project pinned to a
|
|
638
793
|
yadflow older than `--format` answers in the exit protocol — exit 2 with empty stdout — and treating
|
|
639
794
|
that as "no verdict" would turn a real refusal into a permitted write.
|
|
640
795
|
|
|
641
|
-
The two commands are spelled differently on purpose. Claude Code runs the string through a shell
|
|
642
|
-
its entry uses `$CLAUDE_PROJECT_DIR` and is quoted against a project path
|
|
643
|
-
documents that a project hook runs **from the project root** but not
|
|
644
|
-
|
|
645
|
-
|
|
796
|
+
The two commands are spelled differently on purpose. Claude Code runs the string through a shell (Git
|
|
797
|
+
Bash on Windows), so its entry uses `$CLAUDE_PROJECT_DIR` and is quoted against a project path
|
|
798
|
+
containing a space. Cursor documents that a project hook runs **from the project root** but not which
|
|
799
|
+
shell runs the command — PowerShell on Windows — so its entry is `node` and a relative path, with no
|
|
800
|
+
variable and no quotes: PowerShell reads neither. Every failure mode here is silent and fails open, which would leave `yad doctor`
|
|
646
801
|
truthfully reporting an entry that never refuses anything.
|
|
647
802
|
|
|
648
803
|
Cursor does not document the field names inside `tool_input`, so `payloadPaths` reads any key *named*
|
|
@@ -654,14 +809,14 @@ a guard that fails open by design.
|
|
|
654
809
|
Cursor's wiring follows Cursor's published protocol and has not yet been exercised against a live
|
|
655
810
|
Cursor session.
|
|
656
811
|
|
|
657
|
-
**Layering.** `hooks/ledger-guard.
|
|
812
|
+
**Layering.** `hooks/ledger-guard.mjs` is only the adapter: it locates `yad` (`$YAD_BIN` → the Product's
|
|
658
813
|
`node_modules/yadflow` → `PATH` → `npx --no-install`) and passes the payload to `yad hook
|
|
659
814
|
ledger-guard`, which holds the decision. So the wiring never hard-codes an install path, and the
|
|
660
|
-
logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs
|
|
815
|
+
logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`; the scripts in `cli/test-hooks.mjs`, on Windows too).
|
|
661
816
|
|
|
662
817
|
**Scope — identical to the CI gate, on purpose**, down to the details that decide the hard cases:
|
|
663
818
|
|
|
664
|
-
- Guarded: `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` and `epics/*/reviews/*.md`.
|
|
819
|
+
- Guarded: `epics/*/.sdlc/{state,approvals,comments,product-prs,hub-prs}.json` and `epics/*/reviews/*.md`.
|
|
665
820
|
Exempt: `contract-lock.json` (artifact-side), `change.json`, every artifact.
|
|
666
821
|
- **Depth matches the gate's globs.** Its arms are bash `case` patterns, and a bash `*` spans `/`, so
|
|
667
822
|
`epics/EP-a/nested/.sdlc/state.json` is guarded there — and here. Being stricter locally would let
|
|
@@ -683,10 +838,15 @@ logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
|
|
|
683
838
|
nothing is wired or blocked.
|
|
684
839
|
|
|
685
840
|
**It fails OPEN, and that asymmetry is the design.** No `yad` on PATH, no Product above the edited path,
|
|
686
|
-
an unreadable `hub.json`, an unparseable payload, a `yad` that errors — every one of them ALLOWS,
|
|
841
|
+
an unreadable `product.json` / `hub.json`, an unparseable payload, a `yad` that errors — every one of them ALLOWS,
|
|
687
842
|
with a note on stderr. A local guardrail that failed closed would brick an agent's ability to edit
|
|
688
843
|
anything the moment an install went sideways. The CI gate fails **closed** and is what actually
|
|
689
844
|
protects the ledger; this only shortens the feedback loop. `YAD_HOOK_DISABLE=1` skips one command.
|
|
845
|
+
The door for a PERSON is `yad commit --manual --reason "<why>"` (E49): it skips the local hook for that
|
|
846
|
+
one commit and records the reason as a `Ledger-Override:` trailer. `ledger-guard` in CI still FAILS the
|
|
847
|
+
commit — anyone can type a trailer — and prints the reason under the FAIL. Each line starts with the commit's sha, and every control character (a
|
|
848
|
+
carriage return, an escape) becomes a space, so an author's text never starts a line GitHub Actions would
|
|
849
|
+
read as a workflow command (indenting alone would not do it: the runner skips leading spaces). The author names and file paths it prints get the same cleaning (a newline in a path becomes a space too).
|
|
690
850
|
|
|
691
851
|
**Known gaps** — both fall through to the CI gate, which is why it stays the authority:
|
|
692
852
|
|
|
@@ -703,7 +863,7 @@ protects the ledger; this only shortens the feedback loop. `YAD_HOOK_DISABLE=1`
|
|
|
703
863
|
|
|
704
864
|
| Path | Owner |
|
|
705
865
|
|---|---|
|
|
706
|
-
| `<product>/hooks/ledger-guard.
|
|
866
|
+
| `<product>/hooks/ledger-guard.mjs` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
|
|
707
867
|
| `<product>/.claude/settings.json` | **one entry**, merged additively into `hooks.PreToolUse`. See below. |
|
|
708
868
|
|
|
709
869
|
The settings file is the team's, so the rules around that one entry are deliberately conservative:
|
|
@@ -712,7 +872,7 @@ The settings file is the team's, so the rules around that one entry are delibera
|
|
|
712
872
|
substring. A team keeping its own wrapper at `.claude/hooks/ledger-guard.sh` would otherwise have
|
|
713
873
|
their hook silently rewritten to ours, on the `outdated` path that takes no backup. Matching
|
|
714
874
|
exactly means the worst case is a second entry (the guard runs twice, harmlessly).
|
|
715
|
-
- **The
|
|
875
|
+
- **The path is quoted** (`node "$CLAUDE_PROJECT_DIR/hooks/ledger-guard.mjs"`) because the harness runs
|
|
716
876
|
it through a shell: unquoted, a project path containing a space word-splits and the guard is
|
|
717
877
|
silently off while `check` and `doctor` still call it wired.
|
|
718
878
|
- **A file that does not parse is never rewritten** — not even by `--overwrite-local`. For a managed
|
|
@@ -7,13 +7,37 @@
|
|
|
7
7
|
# - any trailers appear in the fixed order Task -> Contract-Change -> Co-Authored-By.
|
|
8
8
|
# Keep the type list in sync with cli/manifest.mjs COMMIT_TYPES and config.yaml build.commit_subject_style.
|
|
9
9
|
#
|
|
10
|
-
# Profiles (--profile code|
|
|
10
|
+
# Profiles (--profile code|product, default code; `hub` is the old name for product): the subject rule is identical on the Product and on
|
|
11
11
|
# code repos (both follow CONTRIBUTING). The Task trailer is NOT required here (the spec-link gate owns
|
|
12
12
|
# that on code repos; Product commits are not task-scoped) — this gate only checks SHAPE and ORDER.
|
|
13
13
|
#
|
|
14
14
|
# Merge/squash commits (2+ parents) are skipped: their platform-generated subjects are not authored.
|
|
15
15
|
set -euo pipefail
|
|
16
16
|
|
|
17
|
+
# --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
|
|
18
|
+
# --- it is duplicated, not sourced) ---
|
|
19
|
+
# The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
|
|
20
|
+
# `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
|
|
21
|
+
# instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
|
|
22
|
+
# never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
|
|
23
|
+
# names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
|
|
24
|
+
# only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
|
|
25
|
+
product_config() {
|
|
26
|
+
if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
|
|
27
|
+
&& [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
|
|
28
|
+
echo "FAIL [product-settings]: SDLC_PRODUCT_CONFIG (${SDLC_PRODUCT_CONFIG}) and SDLC_HUB_CONFIG (${SDLC_HUB_CONFIG}) name files that say different things. Set only SDLC_PRODUCT_CONFIG." >&2
|
|
29
|
+
return 1
|
|
30
|
+
fi
|
|
31
|
+
if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
|
|
32
|
+
if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
|
|
33
|
+
if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
|
|
34
|
+
echo "FAIL [product-settings]: .sdlc/product.json and .sdlc/hub.json say different things — they are one file under two names until v5. Run \`yad migrate\` in the Product to choose the copy to keep, and commit both." >&2
|
|
35
|
+
return 1
|
|
36
|
+
fi
|
|
37
|
+
if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
|
|
38
|
+
}
|
|
39
|
+
PRODUCT_CONFIG="$(product_config)" || exit 1
|
|
40
|
+
|
|
17
41
|
PROFILE=code
|
|
18
42
|
ARGS=()
|
|
19
43
|
while [ $# -gt 0 ]; do
|
|
@@ -23,27 +47,29 @@ while [ $# -gt 0 ]; do
|
|
|
23
47
|
*) ARGS+=("$1"); shift ;;
|
|
24
48
|
esac
|
|
25
49
|
done
|
|
26
|
-
case "$PROFILE" in code|hub|product) ;; *) echo "FAIL [commit-message]: unknown --profile '$PROFILE' (code|hub
|
|
27
|
-
# `product` is the
|
|
50
|
+
case "$PROFILE" in code|hub|product) ;; *) echo "FAIL [commit-message]: unknown --profile '$PROFILE' (code|product, or hub, the old name for product)."; exit 1 ;; esac
|
|
51
|
+
# `product` is the Product's profile. `hub` is its old name, and BOTH are accepted.
|
|
28
52
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
53
|
+
# This script and the workflow that passes the flag are both in PRODUCT_WIRING (cli/manifest.mjs) and
|
|
54
|
+
# normally land together on one `yad update` — but not always: a copy the team edited is kept. So the
|
|
55
|
+
# order is add-before-remove: the new spelling was accepted first, the workflow templates switched to
|
|
56
|
+
# emit it in E123 (`yad-product-checks.yml`), and the old one is dropped after that. `hub` stays
|
|
57
|
+
# accepted meanwhile, for a workflow of the team's own or one they edited. The other direction — an
|
|
58
|
+
# edited copy of THIS script from before 4.0, which rejects `product`, beside the new workflow — fails
|
|
59
|
+
# every Product PR; `yad update` and `yad doctor` (`profile:`) say so.
|
|
34
60
|
#
|
|
35
|
-
# Normalised to `
|
|
36
|
-
# load-bearing in pr-title.sh and pr-template.sh: leave `$PROFILE` as `
|
|
61
|
+
# Normalised to `product` immediately, so nothing below has to know there are two spellings. That is
|
|
62
|
+
# load-bearing in pr-title.sh and pr-template.sh: leave `$PROFILE` as `hub` and the `= product`
|
|
37
63
|
# branch is skipped, taking the whole review-branch arm with it — including the guard that stops a
|
|
38
64
|
# plain code title carrying an artifact change past its review.
|
|
39
|
-
[ "$PROFILE" =
|
|
65
|
+
[ "$PROFILE" = hub ] && PROFILE=product
|
|
40
66
|
|
|
41
67
|
|
|
42
68
|
# --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
|
|
43
69
|
# --- is duplicated, not sourced) ---
|
|
44
70
|
# With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
|
|
45
71
|
# whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
|
|
46
|
-
# exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/
|
|
72
|
+
# exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
|
|
47
73
|
# cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
|
|
48
74
|
# (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
|
|
49
75
|
# DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
|
|
@@ -52,7 +78,7 @@ case "$PROFILE" in code|hub|product) ;; *) echo "FAIL [commit-message]: unknown
|
|
|
52
78
|
# command substitution in an assignment aborts the script.
|
|
53
79
|
resolve_base() {
|
|
54
80
|
# tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
|
|
55
|
-
_cfg="$(tr -d '\n' < "$
|
|
81
|
+
_cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
|
|
56
82
|
_head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
|
|
57
83
|
for _c in "origin/${_cfg}" "${_head}" origin/main; do
|
|
58
84
|
case "$_c" in ''|origin/) continue ;; esac
|