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.
Files changed (128) hide show
  1. package/CHANGELOG.md +699 -0
  2. package/README.md +85 -22
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +183 -29
  5. package/cli/capture.mjs +4 -2
  6. package/cli/checkpoint.mjs +10 -10
  7. package/cli/claims.mjs +4 -4
  8. package/cli/commit.mjs +48 -12
  9. package/cli/docs.mjs +15 -13
  10. package/cli/doctor.mjs +557 -141
  11. package/cli/epic-state.mjs +41 -18
  12. package/cli/errors.mjs +3 -2
  13. package/cli/find-product.mjs +197 -0
  14. package/cli/fold.mjs +4 -4
  15. package/cli/gate-local.mjs +13 -13
  16. package/cli/gate.mjs +111 -103
  17. package/cli/history.mjs +13 -13
  18. package/cli/hook.mjs +201 -30
  19. package/cli/index-command.mjs +6 -6
  20. package/cli/lib.mjs +91 -29
  21. package/cli/manifest.mjs +171 -104
  22. package/cli/migrate.mjs +208 -17
  23. package/cli/mode.mjs +22 -22
  24. package/cli/next.mjs +6 -6
  25. package/cli/openpr.mjs +32 -24
  26. package/cli/people.mjs +22 -10
  27. package/cli/plan.mjs +718 -45
  28. package/cli/platform.mjs +13 -7
  29. package/cli/product-index.mjs +4 -4
  30. package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
  31. package/cli/protection.mjs +19 -9
  32. package/cli/reconcile.mjs +98 -23
  33. package/cli/repo-publish.mjs +22 -16
  34. package/cli/repo.mjs +128 -33
  35. package/cli/report.mjs +4 -2
  36. package/cli/review.mjs +26 -19
  37. package/cli/setup.mjs +105 -36
  38. package/cli/ship.mjs +1 -1
  39. package/cli/skip.mjs +4 -4
  40. package/cli/tidy.mjs +7 -7
  41. package/cli/update-commit.mjs +22 -19
  42. package/cli/workspace.mjs +495 -0
  43. package/package.json +7 -4
  44. package/skills/sdlc/config.yaml +14 -13
  45. package/skills/sdlc/module-help.csv +8 -8
  46. package/skills/yad-analysis/SKILL.md +6 -4
  47. package/skills/yad-architecture/SKILL.md +4 -3
  48. package/skills/yad-backfill/SKILL.md +11 -5
  49. package/skills/yad-backfill/references/backfill.md +33 -3
  50. package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
  51. package/skills/yad-change/SKILL.md +1 -1
  52. package/skills/yad-checks/SKILL.md +48 -27
  53. package/skills/yad-checks/references/check-gates.md +213 -53
  54. package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
  55. package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
  56. package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
  57. package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
  58. package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
  59. package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
  60. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
  61. package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
  62. package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
  63. package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
  64. package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
  65. package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
  66. package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
  67. package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
  68. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
  69. package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
  70. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
  71. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
  72. package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
  73. package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
  74. package/skills/yad-commit/SKILL.md +18 -7
  75. package/skills/yad-connect-design/SKILL.md +1 -1
  76. package/skills/yad-connect-design/references/design-registry.md +1 -1
  77. package/skills/yad-connect-docs/SKILL.md +16 -11
  78. package/skills/yad-connect-docs/references/docs-registry.md +8 -8
  79. package/skills/yad-connect-learning/SKILL.md +1 -1
  80. package/skills/yad-connect-learning/references/learning-registry.md +1 -1
  81. package/skills/yad-connect-repos/SKILL.md +31 -8
  82. package/skills/yad-connect-repos/references/code-context.md +1 -1
  83. package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
  84. package/skills/yad-connect-repos/references/repos-registry.md +1 -1
  85. package/skills/yad-connect-testing/SKILL.md +1 -1
  86. package/skills/yad-connect-testing/references/testing-registry.md +1 -1
  87. package/skills/yad-discovery/SKILL.md +2 -2
  88. package/skills/yad-discovery/references/discovery-schema.md +1 -1
  89. package/skills/yad-docs/SKILL.md +1 -1
  90. package/skills/yad-docs/references/data-mapping.md +1 -1
  91. package/skills/yad-docs/templates/app/package-lock.json +8 -8
  92. package/skills/yad-docs/templates/app/package.json +1 -1
  93. package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
  94. package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
  95. package/skills/yad-engineer-review/SKILL.md +2 -2
  96. package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
  97. package/skills/yad-epic/SKILL.md +6 -4
  98. package/skills/yad-epic/references/state-schema.md +13 -12
  99. package/skills/yad-open-pr/SKILL.md +1 -1
  100. package/skills/yad-pair-review/SKILL.md +5 -5
  101. package/skills/yad-pair-review/references/session-state.md +2 -2
  102. package/skills/yad-pr-template/SKILL.md +14 -12
  103. package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
  104. package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
  105. package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
  106. package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
  107. package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
  108. package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
  109. package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
  110. package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
  111. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
  112. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
  113. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
  114. package/skills/yad-report/SKILL.md +1 -1
  115. package/skills/yad-review-companion/SKILL.md +3 -3
  116. package/skills/yad-review-gate/SKILL.md +22 -23
  117. package/skills/yad-review-gate/references/gating.md +13 -13
  118. package/skills/yad-run/SKILL.md +2 -2
  119. package/skills/yad-run/references/run-loop.md +2 -2
  120. package/skills/yad-spec/SKILL.md +2 -1
  121. package/skills/yad-spec/references/spec-handoff.md +19 -10
  122. package/skills/yad-stories/SKILL.md +2 -2
  123. package/skills/yad-sync-repos/SKILL.md +19 -3
  124. package/skills/yad-test-cases/SKILL.md +2 -2
  125. package/skills/yad-ui/SKILL.md +2 -2
  126. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
  127. package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
  128. 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` + `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) |
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` (`.sdlc/hub.json`, or `SDLC_HUB_CONFIG`), else the remote's
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/hubcommit.mjs`, `cli/repo.mjs`), so a gate never diffs a
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>` and requires `specs/<story>/link.md` to
58
- exist. **FAIL** if missing.
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 differ only in ASCII case or the long s
82
- (`specs/EP-x-S01/` and `specs/ep-x-s01/`) — one folder on a Mac, two to rules that read exact bytes.
83
- Other letters are **not** caught: `specs/EP-démo-S01/` beside `specs/EP-DÉMO-S01/`, or the same name in
84
- NFC and NFD form, is one folder on a Mac and two here. The surface rule still matches both, so the
85
- trailer is still required; at worst the lock check reads no `link.md` and is deferred — something a
86
- PR can already do by leaving `link.md` out (E117).
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 hub.json's
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 → Co-Authored-By`.
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|product`): the subject rule is identical on both; the gate never requires
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 hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
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 hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
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/hub/gitlab/…`) carry
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`. An **open** entry the current epic does not own
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: hub`)
676
+ ## Wiring the Product (`repo: product`)
538
677
 
539
- The Product is itself a repo on a platform (recorded in `.sdlc/hub.json` by
540
- `yad-connect-repos action: detect-hub`). `wire repo: hub` targets `{project-root}` and uses the same
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-hub-checks`, `verified-commits` in its own workflow,
544
- and the `yad-update-guard`. The three below are **not shipped** — they are scripts a team writes itself if
545
- it wants them:
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/hub.json` has a platform with a verified ledger. So the
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 hub`. The pattern gates split by the PR/MR **head branch** (passed via `--head`): a
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-hub-checks.yml` → `.github/workflows/yad-hub-checks.yml`, or the GitLab fragment
575
- `templates/gitlab/yad-hub-checks.gitlab-ci.yml` → `.gitlab/ci/yad-hub-checks.yml` + its one include
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.sh` + `yad hook 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.sh"` |
599
- | Cursor | `.cursor/hooks.json` | `preToolUse` | `hooks/ledger-guard-cursor.sh` |
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
- Any other harness that can run a command and read those two exit codes can use `ledger-guard.sh` by
602
- hand.
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.sh` prints **nothing** when it allows — and empty
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.sh`, installed only for a project whose targets include
612
- `.cursor`. It calls `yad hook ledger-guard --format cursor` through the shared script and guarantees a
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 of the shared script (no `yad` on
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: `ledger-guard.sh`
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, so
642
- its entry uses `$CLAUDE_PROJECT_DIR` and is quoted against a project path containing a space. Cursor
643
- documents that a project hook runs **from the project root** but not whether the command goes through
644
- a shell — so its entry is a relative path with no variable and no quotes, the one spelling that works
645
- either way. Every failure mode here is silent and fails open, which would leave `yad doctor`
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.sh` is only the adapter: it locates `yad` (`$YAD_BIN` → the Product's
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`) instead of living in bash.
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.sh` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
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 command is quoted** (`"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"`) because the harness runs
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|hub|product, default code): the subject rule is identical on the Product and on
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|product)."; exit 1 ;; esac
27
- # `product` is the new name for the `hub` profile and BOTH are accepted.
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
- # Not because the two sides update separately — they do not: this script and the workflow that passes
30
- # the flag are both in PRODUCT_WIRING (cli/manifest.mjs) and land together on one `yad update`. It is
31
- # the plain add-before-remove ladder instead: accept the new spelling now, switch the workflow
32
- # templates to emit it in a later release, drop the old one after that. Every shipped template still
33
- # passes `--profile hub` today, so this arm is dead weight until that switch — which is the point.
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 `hub` immediately, so nothing below has to know there are two spellings. That is
36
- # load-bearing in pr-title.sh and pr-template.sh: leave `$PROFILE` as `product` and the `= hub`
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" = product ] && PROFILE=hub
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/hubcommit.mjs,
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' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
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