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
@@ -10,11 +10,35 @@
10
10
  # ci/chore/build/test exempt. Fails CLOSED on an unresolvable base.
11
11
  set -euo pipefail
12
12
 
13
+ # --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
14
+ # --- it is duplicated, not sourced) ---
15
+ # The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
16
+ # `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
17
+ # instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
18
+ # never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
19
+ # names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
20
+ # only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
21
+ product_config() {
22
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
23
+ && [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
24
+ 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
25
+ return 1
26
+ fi
27
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
28
+ if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
29
+ if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
30
+ 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
31
+ return 1
32
+ fi
33
+ if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
34
+ }
35
+ PRODUCT_CONFIG="$(product_config)" || exit 1
36
+
13
37
  # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
38
  # --- is duplicated, not sourced) ---
15
39
  # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
40
  # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
- # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
41
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
18
42
  # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
43
  # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
44
  # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
@@ -23,7 +47,7 @@ set -euo pipefail
23
47
  # command substitution in an assignment aborts the script.
24
48
  resolve_base() {
25
49
  # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
- _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
50
+ _cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
51
  _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
52
  for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
53
  case "$_c" in ''|origin/) continue ;; esac
@@ -46,8 +70,9 @@ EXEMPT='ci|chore|build|test'
46
70
  # --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
47
71
  # Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
48
72
  # at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
49
- # scalars only; trailing spaces/CR are stripped so they never become part of a path.
50
- fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
73
+ # scalars only; trailing spaces/CR are stripped so they never become part of a path. The CR goes BEFORE
74
+ # the fences are matched: a CRLF file's `---\r` is no fence, and every key in it read as empty (E117 r2).
75
+ fm_val() { awk -v k="$1" '{ sub(/\r$/, "") } NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
51
76
 
52
77
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
53
78
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
@@ -59,6 +84,349 @@ link_val() {
59
84
  printf '%s' "$_v"
60
85
  }
61
86
 
87
+ # E117. WHERE the Product is read from must not be something the PR under check chose. link.md is a file
88
+ # the PR's own author writes, so two things are read around it:
89
+ # - `product-repo` comes from the BASE. Where the Product lives is a fact about the whole repo, not
90
+ # about one story, so: link.md as it stands on the base when that has a value; else a sibling
91
+ # `specs/<story>/link.md` on the base — one of the same epic first, and the first whose value reaches
92
+ # a folder here, so one stale link.md cannot hand every new story a deferral; else — the repo's
93
+ # first spec — this PR's value. Otherwise a PR could point it at a path that does not exist —
94
+ # "not reachable", a deferral, a PASS — or at a folder of its own. A value that differs is printed
95
+ # and counts once it merges. Only `product-repo` comes from the base: `contract-lock` is read as the
96
+ # PR leaves it — a re-spec PR updates the pin, and must.
97
+ # - a Product this repo KEEPS (a monorepo: the Product and the code in one git repo) is read as it
98
+ # stands on the base, never as the PR leaves it. In order: (1) the path's TEXT names a folder whose
99
+ # `.sdlc/product.json` or `hub.json` the base tracks (every Product commits one) — asked of the base before the disk,
100
+ # which is the PR's: a PR that deleted or moved that folder read as untracked, and deferred; (2) the
101
+ # path is walked on disk: a tracked symlink, submodule or file on the way is refused (what is behind
102
+ # it is the PR's to choose), and so is a tracked folder that is not a kept Product, so one force-added
103
+ # file cannot switch the gates to a partial Product; the walk is repeated from where the path really
104
+ # lands (an alias like /proc/self/cwd means another folder to each process); an untracked folder
105
+ # that is there is CI's checkout, and is read — unless it is inside the repo's own git folder,
106
+ # which branch names shape (review 7); (3) only when the path reaches NOTHING, a Product the base keeps that holds
107
+ # this story's epic is the one meant. "Inside the code repo" is NOT the test: CI can only check a
108
+ # second repo out INSIDE the workspace, and an untracked checkout there is not the PR's.
109
+ #
110
+ # E120. Before any of that, the repo's own record: `.sdlc/product-link.json`, written by `yad check --fix`
111
+ # / `yad update` and merged on its own. Its `path` (from the repo root; where CI checks the Product out)
112
+ # is read from the BASE and wins over every link.md — so no gate takes where the Product lives from the
113
+ # PR. A record the PR adds or changes counts once it merges (a note says so). A link.md whose own
114
+ # product-repo reaches a DIFFERENT folder here gets a note; one that reaches nothing (a path on its
115
+ # author's machine) is passed over in silence. When the record's path reaches NOTHING — a developer's
116
+ # own run, where nobody cloned into it — the base's link.md order below is used if IT reaches a folder
117
+ # (still the base's, never the PR's); otherwise the record stands, and the gate defers (review 1).
118
+ # With no record on the base, the order above is kept.
119
+ # Sets product_rel, prod (the folder to read), prod_note and prod_fail (text; the caller prints them).
120
+ _yad_tmp=""
121
+ _arch_key=""
122
+ _arch_dir=""
123
+ yad_tmp() { [ -n "$_yad_tmp" ] || { _yad_tmp="$(mktemp -d)"; trap 'rm -rf "$_yad_tmp"' EXIT; }; }
124
+ # `product-repo` as link.md $1 has it on the base (empty when it is not there or names none).
125
+ base_product_rel() {
126
+ git cat-file -e "${BASE}:$1" 2>/dev/null || return 0
127
+ yad_tmp
128
+ git show "${BASE}:$1" > "$_yad_tmp/base-link.md" 2>/dev/null || : > "$_yad_tmp/base-link.md"
129
+ link_val product-repo "$_yad_tmp/base-link.md"
130
+ }
131
+ # The `path` of `.sdlc/product-link.json` at $1 (a commit), or empty when there is none or it names none.
132
+ record_path() {
133
+ git cat-file -e "$1:.sdlc/product-link.json" 2>/dev/null || return 0
134
+ git show "$1:.sdlc/product-link.json" 2>/dev/null | tr -d '\n\r' | sed -nE 's/.*"path"[[:space:]]*:[[:space:]]*"([^"\\]*)".*/\1/p' || true
135
+ }
136
+ product_for() {
137
+ _link="specs/$1/link.md"
138
+ prod=""; prod_note=""; prod_fail=""
139
+ yad_tmp # here, in this shell: a $(…) below would set it, and its EXIT trap, in a subshell only
140
+ product_rel="$(link_val product-repo "$_link")"
141
+ _from="$_link"
142
+ _rec="$(record_path "$BASE")"
143
+ _rec_head="$(record_path HEAD)"
144
+ if [ "$_rec_head" != "$_rec" ]; then
145
+ if [ -n "$_rec" ]; then _w="changes in this PR"; else _w="is new in this PR"; fi
146
+ prod_note=".sdlc/product-link.json ${_w} — where the Product lives is read from ${BASE}, not from this PR; the new one counts once it merges."
147
+ fi
148
+ _base_rel="$(base_product_rel "$_link")"
149
+ if [ -z "$_base_rel" ]; then
150
+ # A sibling's value. Every link.md sits at specs/<story>/, so a relative value means the same there.
151
+ _sibs="$(git ls-tree -r -z --name-only "$BASE" -- specs 2>/dev/null | tr '\n\0' '?\n' | grep -E '^specs/[^/]+/link\.md$' || true)"
152
+ _first=""; _first_from=""
153
+ for _pass in same other; do
154
+ while IFS= read -r _sib; do
155
+ [ -n "$_sib" ] && [ "$_sib" != "$_link" ] || continue
156
+ _s="${_sib#specs/}"; _s="${_s%/link.md}"
157
+ story_ok "$_s" || continue
158
+ if [ "$(story_epic "$_s")" = "$(story_epic "$1")" ]; then [ "$_pass" = same ] || continue; else [ "$_pass" = other ] || continue; fi
159
+ _v="$(base_product_rel "$_sib")"
160
+ [ -n "$_v" ] || continue
161
+ if [ -z "$_first" ]; then _first="$_v"; _first_from="$_sib"; fi
162
+ if [ -d "$(resolve_product "$_v" "$1")" ]; then _base_rel="$_v"; _from="$_sib"; break 2; fi
163
+ done <<SIBLINGS
164
+ $_sibs
165
+ SIBLINGS
166
+ done
167
+ if [ -z "$_base_rel" ]; then _base_rel="$_first"; _from="$_first_from"; fi
168
+ fi
169
+ _use_rec=""
170
+ if [ -n "$_rec" ]; then
171
+ # Relative to the repo root; link.md values are relative to specs/<story>/, so join it that way.
172
+ case "$_rec" in /*) _rv="$_rec" ;; *) _rv="../../$_rec" ;; esac
173
+ if [ -d "$(resolve_product "$_rv" "$1")" ] || [ -z "$_base_rel" ] || [ ! -d "$(resolve_product "$_base_rel" "$1")" ]; then
174
+ _use_rec=1; _base_rel="$_rv"; _from=".sdlc/product-link.json"
175
+ fi
176
+ fi
177
+ if [ -n "$_use_rec" ]; then
178
+ # The record wins. Say so only when this link.md's own value reaches a different folder here.
179
+ _mine="$(resolve_product "$product_rel" "$1")"
180
+ _theirs="$(resolve_product "$_base_rel" "$1")"
181
+ if [ -n "$product_rel" ] && [ -d "$_mine" ] && ! [ "$_mine" -ef "$_theirs" ]; then
182
+ prod_note="${prod_note:+$prod_note
183
+ }${_link} names product-repo '${product_rel}', but .sdlc/product-link.json on ${BASE} says '${_rec}' — the record is read."
184
+ fi
185
+ product_rel="$_base_rel"
186
+ elif [ -n "$_base_rel" ] && [ "$_base_rel" != "$product_rel" ]; then
187
+ prod_note="${prod_note:+$prod_note
188
+ }${_link} names product-repo '${product_rel}', but ${_from} on ${BASE} says '${_base_rel}' — the Product is read from the base value; a new one counts once it merges."
189
+ product_rel="$_base_rel"
190
+ fi
191
+ # A Product this repo keeps, asked of the base first (see above). Both joins resolve_product may pick.
192
+ case "$product_rel" in
193
+ '') _cands="" ;;
194
+ /*|'~'*|'$'*) _cands="$product_rel" ;;
195
+ *) _cands="specs/$1/$product_rel
196
+ $product_rel" ;;
197
+ esac
198
+ while IFS= read -r _cand; do
199
+ [ -n "$_cand" ] || continue
200
+ _lr="$(lex_rel "$_cand")"
201
+ [ -n "$_lr" ] || continue
202
+ [ "$_lr" != . ] || _lr=""
203
+ if git cat-file -e "${BASE}:${_lr:+$_lr/}.sdlc/product.json" 2>/dev/null || git cat-file -e "${BASE}:${_lr:+$_lr/}.sdlc/hub.json" 2>/dev/null; then base_product "$_lr"; return 0; fi
204
+ done <<CANDS
205
+ $_cands
206
+ CANDS
207
+ if [ -n "$product_rel" ]; then
208
+ prod="$(resolve_product "$product_rel" "$1")"
209
+ ! tracked_verdict "$(product_tracked "$prod")" || return 0
210
+ if [ -d "$prod" ]; then
211
+ # Walked once more, from where it really lands, resolved in ONE step from this shell (review 5).
212
+ # The walk above goes one part at a time in a subshell, and a Linux magic link such as
213
+ # /proc/self/cwd means a different folder to each process: there it pointed at the walk's own
214
+ # subshell and found nothing, while this shell read the PR's own copy of a kept Product through it.
215
+ # `./` before a relative path, so a value starting with `-` is not read by `cd` as an option
216
+ # (review 6); and a folder that is there but cannot be entered is refused, not read unchecked.
217
+ case "$prod" in /*) _pc="$prod" ;; *) _pc="./$prod" ;; esac
218
+ _phys="$(cd -P "$_pc" 2>/dev/null && pwd -P)" || _phys=""
219
+ if [ -z "$_phys" ]; then
220
+ prod_fail="product-repo resolves to '${prod}', a folder the gate cannot enter to check where it really is — so it is not read."
221
+ return 0
222
+ fi
223
+ # Not inside this repo's own git folder (review 7). git never lists `.git/` as tracked, yet a PR
224
+ # shapes it: a branch named `epics/EP-x/y` makes the folders `.git/refs/…/epics/EP-x/` when CI
225
+ # fetches, and that read as an untracked checkout holding an open epic with no lock.
226
+ # Compared as FOLDERS (`-ef`: the same one on disk), not as spellings: on a disk that ignores case
227
+ # (macOS, Windows) `.GIT/refs` is `.git/refs`, and `pwd -P` keeps the case it was typed in (review 8).
228
+ for _gd in "$(git rev-parse --absolute-git-dir 2>/dev/null)" "$(git rev-parse --git-common-dir 2>/dev/null)"; do
229
+ [ -n "$_gd" ] && [ -d "$_gd" ] || continue
230
+ _p="$_phys"
231
+ while :; do
232
+ if [ "$_p" -ef "$_gd" ]; then
233
+ prod_fail="product-repo resolves to '${prod}', inside this repo's own git folder — what is there is git's, shaped by branch names, not a Product."
234
+ return 0
235
+ fi
236
+ case "$_p" in /|'') break ;; esac
237
+ _p="${_p%/*}"; [ -n "$_p" ] || _p=/
238
+ done
239
+ done
240
+ ! tracked_verdict "$(product_tracked "$_phys")" || return 0
241
+ # An untracked folder that is there is the Product CI checked out: a PR cannot make one. Read it.
242
+ return 0
243
+ fi
244
+ fi
245
+ # product-repo reaches NOTHING. Then a Product the base keeps that holds this story's epic is the one
246
+ # meant (E117 review 3): a monorepo value spelled past the repo's own folder name, another machine's
247
+ # absolute path, or a first spec pointing nowhere cannot be folded to it by text, and a PR that deleted
248
+ # or moved it leaves no disk to walk. Only when nothing is reached (review 4): a kept Product used to
249
+ # win over the real checkout, so a merged test fixture shaped like a Product was read instead of it.
250
+ # And only one holding the epic, so such a fixture is not read for stories it knows nothing about.
251
+ # (In a two-repo setup whose CI checks nothing out, a fixture that DOES hold the epic is read where
252
+ # the gate used to defer — no weaker than the deferral it replaces.)
253
+ kept_products
254
+ _epic_of="$(story_epic "$1")"
255
+ _kept_here=""
256
+ while IFS= read -r _k; do
257
+ [ -n "$_k" ] || continue
258
+ _kd="$_k"; [ "$_kd" != . ] || _kd=""
259
+ if [ "$(git cat-file -t "${BASE}:${_kd:+$_kd/}epics/${_epic_of}" 2>/dev/null)" = tree ]; then _kept_here="${_kept_here}${_k}
260
+ "; fi
261
+ done <<KEPT
262
+ $_kept
263
+ KEPT
264
+ [ -n "$_kept_here" ] || return 0
265
+ if [ -n "$product_rel" ]; then _what="product-repo '${product_rel}' reaches nothing"; else _what="link.md names no product-repo"; fi
266
+ if [ "$(printf '%s' "$_kept_here" | grep -c .)" -gt 1 ]; then
267
+ prod_fail="${_what}, and ${BASE} keeps more than one Product holding ${_epic_of} ($(printf '%s' "$_kept_here" | tr '\n' ' ' | sed -E 's/ +$//')) — the gate cannot tell which is meant."
268
+ return 0
269
+ fi
270
+ _k="$(printf '%s' "$_kept_here" | head -1)"
271
+ prod_note="${prod_note:+$prod_note
272
+ }${_what} here; the Product this repo keeps at '${_k}' on ${BASE} holds ${_epic_of}, so that one is read."
273
+ product_rel="$_k"
274
+ [ "$_k" != . ] || _k=""
275
+ base_product "$_k"
276
+ return 0
277
+ }
278
+
279
+ # Act on product_tracked's answer $1: a tracked entry on the way is refused; a tracked folder is read from
280
+ # the base when the base keeps a Product there, and refused otherwise. Returns 1 when there was nothing to
281
+ # act on (the path is not the repo's), so the caller goes on.
282
+ tracked_verdict() {
283
+ case "$1" in
284
+ entry:*)
285
+ prod_fail="product-repo reaches the Product through '${1#*:}', a symlink, submodule or file this repo tracks — what is behind it is this PR's to choose. Point product-repo at the Product itself."
286
+ return 0 ;;
287
+ tree:*)
288
+ _r="${1#*:}"
289
+ if git cat-file -e "${BASE}:${_r:+$_r/}.sdlc/product.json" 2>/dev/null || git cat-file -e "${BASE}:${_r:+$_r/}.sdlc/hub.json" 2>/dev/null; then base_product "$_r"; return 0; fi
290
+ prod_fail="product-repo resolves to '${_r:-.}', which holds files this repo tracks, but ${BASE} does not track '${_r:+$_r/}.sdlc/product.json' (or the older hub.json) there, so it is not a Product kept in this repo. Untrack those files (git rm -r --cached '${_r:-.}') or point product-repo at the Product."
291
+ return 0 ;;
292
+ esac
293
+ return 1
294
+ }
295
+
296
+ # Every Product the base keeps: a tracked `.sdlc/product.json` or `.sdlc/hub.json` (both, until v5) with
297
+ # an `epics/` folder beside it (a code repo may track one of its own; only a Product has epics). One line
298
+ # each, `.` for the root, the two names counted once; read once.
299
+ _kept=""
300
+ _kept_read=""
301
+ kept_products() {
302
+ [ -z "$_kept_read" ] || return 0
303
+ _kept_read=1
304
+ while IFS= read -r _h; do
305
+ [ -n "$_h" ] || continue
306
+ _d="${_h%.sdlc/*.json}"; _d="${_d%/}"
307
+ case "
308
+ ${_kept}" in *"
309
+ ${_d:-.}
310
+ "*) continue ;; esac
311
+ if [ "$(git cat-file -t "${BASE}:${_d:+$_d/}epics" 2>/dev/null)" = tree ]; then _kept="${_kept}${_d:-.}
312
+ "; fi
313
+ done <<KEPT
314
+ $(git ls-tree -r -z --name-only "$BASE" 2>/dev/null | tr '\n\0' '?\n' | grep -E '(^|/)\.sdlc/(product|hub)\.json$' || true)
315
+ KEPT
316
+ }
317
+
318
+ # Read the Product this repo keeps at $1 (from the repo root; "" is the root) as it stands on the base:
319
+ # its `epics/` written out under the temp folder. Sets prod, or prod_fail.
320
+ base_product() {
321
+ _r="$1"; _e="${_r:+$_r/}epics"
322
+ if [ "$(git cat-file -t "${BASE}:${_e}" 2>/dev/null)" != tree ]; then
323
+ prod_fail="product-repo resolves to '${_r:-.}', a Product this repo keeps, and ${BASE} has no '${_e}' folder — there is nothing on the base to read."
324
+ return 0
325
+ fi
326
+ # (awk reads to the end rather than stopping at the first hit: stopping left `tr` writing into a
327
+ # closed pipe on a large epics/, and under pipefail the gate died with no message — review 6.)
328
+ # A symlink or submodule inside it is refused by name, as under specs/ (E115): a link written out
329
+ # still points where it pointed — an absolute one into this PR's working tree — and a submodule comes
330
+ # out as an empty folder, which reads as an epic with no stories.
331
+ _bad="$(git ls-tree -r -z "${BASE}:${_e}" 2>/dev/null | tr '\n\0' '?\n' | awk '!f && ($1 == "120000" || $1 == "160000") { sub(/^[^\t]*\t/, ""); print; f = 1 }')"
332
+ if [ -n "$_bad" ]; then
333
+ prod_fail="'${_e}/${_bad}' on ${BASE} is a symlink or a submodule, so the Product this repo keeps cannot be read from the base. Replace it with the files themselves."
334
+ return 0
335
+ fi
336
+ # Keyed as "x<path>": the repo root is the path "", which an empty key would take as done.
337
+ if [ "$_arch_key" != "x$_r" ]; then
338
+ _arch_dir="$(mktemp -d "$_yad_tmp/product.XXXXXX")"
339
+ # A throwaway index holding the base's epics/, written out with the WORK TREE pointed at the empty
340
+ # temp folder. Not `git archive`: it drops whatever the base's .gitattributes marks `export-ignore`.
341
+ # And not with the real work tree: `git checkout-index` takes attributes from the work tree, which
342
+ # is the PR's — a `.gitattributes` of `*.md text eol=crlf` or `working-tree-encoding=UTF-16` in the
343
+ # PR wrote the base's files unreadable, and every gate passed. Pointed here, it finds none but the
344
+ # base's own.
345
+ # The index's own `.gitattributes` entries are dropped before it is written out, for the same reason
346
+ # (review 3): one merged into the base's epics/ applied `working-tree-encoding=UTF-16` to every file.
347
+ if ! { mkdir -p "${_arch_dir}/${_e}" &&
348
+ GIT_INDEX_FILE="${_arch_dir}.idx" git read-tree "${BASE}:${_e}" &&
349
+ GIT_INDEX_FILE="${_arch_dir}.idx" git ls-files -z -- ':(glob)**/.gitattributes' |
350
+ GIT_INDEX_FILE="${_arch_dir}.idx" xargs -0 git update-index --force-remove -- &&
351
+ GIT_WORK_TREE="${_arch_dir}/${_e}" GIT_INDEX_FILE="${_arch_dir}.idx" git checkout-index -a -f; } >/dev/null 2>&1; then
352
+ prod_fail="product-repo resolves to '${_r:-.}', a Product this repo keeps, and '${_e}' could not be read from ${BASE}."
353
+ return 0
354
+ fi
355
+ _arch_key="x$_r"
356
+ fi
357
+ prod="${_arch_dir}${_r:+/$_r}"
358
+ prod_note="${prod_note:+$prod_note
359
+ }the Product at '${_r:-.}' is kept in this repo, so it is read as it stands on ${BASE}, not as this PR leaves it."
360
+ return 0
361
+ }
362
+
363
+ # The path $1 from the repo root, `.` and `..` folded by its text alone — no disk. Prints `.` for the
364
+ # root and nothing when it leaves the repo. An absolute path counts when it starts with the repo's path
365
+ # as the shell spells it or as the disk does (`pwd`, `pwd -P`; the gates run from the root).
366
+ lex_rel() (
367
+ _p="$1"
368
+ case "$_p" in
369
+ /*)
370
+ _in=""
371
+ for _top in "$(pwd)" "$(pwd -P)"; do
372
+ case "$_p/" in "$_top"/*) _p="${_p#"$_top"}"; _in=1; break ;; esac
373
+ done
374
+ [ -n "$_in" ] || exit 0 ;;
375
+ esac
376
+ _out=""
377
+ set -f; IFS=/
378
+ for _c in $_p; do
379
+ case "$_c" in
380
+ ''|.) ;;
381
+ ..) [ -n "$_out" ] || exit 0
382
+ case "$_out" in */*) _out="${_out%/*}" ;; *) _out="" ;; esac ;;
383
+ *) _out="${_out:+$_out/}$_c" ;;
384
+ esac
385
+ done
386
+ printf '%s' "${_out:-.}"
387
+ )
388
+
389
+ # Is the path $1 — as the shell would walk it, `..` and links included — something this repo tracks?
390
+ # Prints `entry:<path>` when a part of the way is a tracked entry itself (a symlink, a submodule, a
391
+ # file), `tree:<path>` when it ends in a folder with tracked files at or under it, and nothing when
392
+ # it is the repo's to neither. <path> is from the repo root, as the disk spells it (`pwd -P`). The case
393
+ # is folded where git folds it (core.ignorecase — a Mac checkout), and in ASCII only.
394
+ product_tracked() (
395
+ _top="$(git rev-parse --show-toplevel 2>/dev/null)" && _top="$(cd -P "$_top" 2>/dev/null && pwd -P)" || exit 0
396
+ _ps='literal'; [ "$(git config --bool core.ignorecase 2>/dev/null)" = true ] && _ps='literal,icase'
397
+ _in() { case "$1/" in "$_top"/*) _r="${1#"$_top"}"; _r="${_r#/}"; return 0 ;; esac; return 1; }
398
+ _hit() { git -C "$_top" ls-files -z -- ":(${_ps})$1" 2>/dev/null | tr '\n\0' '?\n'; }
399
+ case "$1" in /*) cd / ;; esac
400
+ _rest="$1"
401
+ while [ -n "$_rest" ]; do
402
+ _c="${_rest%%/*}"
403
+ case "$_rest" in */*) _rest="${_rest#*/}" ;; *) _rest="" ;; esac
404
+ case "$_c" in ''|.) continue ;; esac
405
+ if [ "$_c" != .. ] && _in "$(pwd -P)/$_c" && [ -n "$_r" ] &&
406
+ _hit "$_r" | awk -v p="$_r" -v i="$_ps" '{ a = $0; b = p; if (i ~ /icase/) { a = tolower(a); b = tolower(b) } } a == b { f = 1 } END { exit !f }'; then
407
+ printf 'entry:%s' "$_r"; exit 0
408
+ fi
409
+ cd -P "./$_c" 2>/dev/null || exit 0 # ./ — a part named `-p` is not an option
410
+ done
411
+ _in "$(pwd -P)" || exit 0
412
+ if [ -n "$(_hit "${_r:-.}" | head -1)" ]; then printf 'tree:%s' "$_r"; fi
413
+ exit 0
414
+ )
415
+
416
+ # E118. A story's epic is its ID's prefix (EP-<slug>-S0N -> EP-<slug>): yad-spec always writes link.md's
417
+ # `epic:` so, and contract-check never read the field. The field is the PR's to write, so a gate that took
418
+ # it as it stands gated whichever epic the PR named — a story of a SEALED epic could name an open one and
419
+ # pass epic-open, or step off a thread frozen for hotfix debt. The gates that read `epic:` require it to
420
+ # agree with the story ID.
421
+ story_epic() { printf '%s' "$1" | sed -E 's/-S[0-9]+$//'; }
422
+
423
+ # One story ID shape for every gate (E117 review 1): EP-<slug>-S<n>, what yad-stories writes. Anything
424
+ # else is refused by name — a Task trailer of `EP-x-S01/.-T1` read `specs/EP-x-S01/./link.md`, a path
425
+ # the base read never found, and its `epic:` of `EP-x-S01/.` agreed with story_epic. The whole string
426
+ # must match (bash `=~`, not a line-wise grep), and the letters are spelled out so no locale widens them.
427
+ _story_re='^EP-[abcdefghijklmnopqrstuvwxyz0123456789-]+-S[0123456789]+$'
428
+ story_ok() { [[ $1 =~ $_story_re ]]; }
429
+
62
430
  # Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
63
431
  # RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
64
432
  # form — but contract-check historically read it from the repo root, so a link.md authored against that
@@ -118,7 +486,7 @@ while IFS= read -r sha; do
118
486
  [ -z "$sha" ] && continue
119
487
  short="$(git log -1 --format=%h "$sha")"
120
488
  subject="$(git log -1 --format=%s "$sha")"
121
- task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
489
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | awk '!f && length($0) { print; f = 1 }')"
122
490
  # The type exemption waives the REQUIREMENT for an owning epic, not the VALIDITY of one that is
123
491
  # claimed — same rule spec-link applies. Exempting on the subject alone would let `chore(x): …` plus
124
492
  # a Task trailer ship a change onto a thread that is frozen for open hotfix debt.
@@ -127,16 +495,39 @@ while IFS= read -r sha; do
127
495
  fi
128
496
  printf '%s' "$task" | grep -qE '.+-T[0-9]+$' || continue
129
497
  story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
498
+ if ! story_ok "$story"; then
499
+ echo "FAIL [reconcile-debt]: ${short} ${task} — '${story}' is not a story ID (expected EP-<slug>-S<n>, the slug in lowercase letters, digits and dashes)."
500
+ rc=1
501
+ continue
502
+ fi
130
503
  link="specs/${story}/link.md"
131
504
  [ -f "$link" ] || continue
132
- product_rel="$(link_val product-repo "$link")"
133
505
  epic="$(link_val epic "$link")"
134
- prod="$(resolve_product "$product_rel" "$story")"
506
+ if [ "$epic" != "$(story_epic "$story")" ]; then
507
+ echo "FAIL [reconcile-debt]: ${short} ${task} — ${link} says epic: ${epic:-<none>}, but ${story} is a story of $(story_epic "$story")."
508
+ echo " A story's epic is the prefix of its ID; the gate will not read another. Fix epic: in ${link}."
509
+ rc=1
510
+ continue
511
+ fi
512
+ product_for "$story"
513
+ if [ -n "$prod_note" ]; then printf '%s\n' "$prod_note" | sed "s/^/note [reconcile-debt]: ${short} /"; fi
514
+ if [ -n "$prod_fail" ]; then
515
+ echo "FAIL [reconcile-debt]: ${short} ${task} — ${prod_fail}"
516
+ rc=1
517
+ continue
518
+ fi
135
519
  ep_dir="${prod}/epics/${epic}"
136
- if [ -z "$product_rel" ] || [ ! -d "$ep_dir" ]; then
520
+ if [ -z "$product_rel" ] || [ ! -d "$prod" ]; then
137
521
  echo "PASS [reconcile-debt]: ${short} ${task} -> ${epic} (product repo not reachable — debt check deferred)."
138
522
  continue
139
523
  fi
524
+ # A Product that WAS reached with no such epic is an orphan story link, as lineage-check and epic-open
525
+ # say — not "not reachable" (E117 review 2).
526
+ if [ ! -d "$ep_dir" ]; then
527
+ echo "FAIL [reconcile-debt]: ${short} ${task} -> epic ${epic} does not exist in the product repo (orphan story link)."
528
+ rc=1
529
+ continue
530
+ fi
140
531
  thread="$(thread_root "$prod" "$epic")"
141
532
  # De-dup by (thread, epic) — NOT thread alone. open_debt_on_thread excludes the CURRENT epic, so the
142
533
  # blocker set depends on both; caching by thread would make later commits on a different epic in the
@@ -57,7 +57,7 @@ unset GIT_LITERAL_PATHSPECS GIT_GLOB_PATHSPECS GIT_NOGLOB_PATHSPECS GIT_ICASE_PA
57
57
  # --- is duplicated, not sourced) ---
58
58
  # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
59
59
  # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
60
- # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
60
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
61
61
  # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
62
62
  # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
63
63
  # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
@@ -66,7 +66,7 @@ unset GIT_LITERAL_PATHSPECS GIT_GLOB_PATHSPECS GIT_NOGLOB_PATHSPECS GIT_ICASE_PA
66
66
  # command substitution in an assignment aborts the script.
67
67
  resolve_base() {
68
68
  # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
69
- _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
69
+ _cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
70
70
  _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
71
71
  for _c in "origin/${_cfg}" "${_head}" origin/main; do
72
72
  case "$_c" in ''|origin/) continue ;; esac
@@ -90,10 +90,42 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
90
90
  exit 0
91
91
  fi
92
92
 
93
- # Every path below is from the repo root — the map, the file list, `.sdlc/hub.json` — so a run from a
93
+ # Every path below is from the repo root — the map, the file list, the Product settings — so a run from a
94
94
  # subfolder reads the same repo as CI does, which always runs at the root.
95
95
  cd "$(git rev-parse --show-toplevel)"
96
96
 
97
+ # --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
98
+ # --- it is duplicated, not sourced) ---
99
+ # The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
100
+ # `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
101
+ # instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
102
+ # never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
103
+ # names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
104
+ # only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
105
+ product_config() {
106
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
107
+ && [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
108
+ 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
109
+ return 1
110
+ fi
111
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
112
+ if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
113
+ if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
114
+ 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
115
+ return 1
116
+ fi
117
+ if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
118
+ }
119
+ # Read here, at the repo root, not where the run started. And advisory to the end: two settings files
120
+ # that disagree fail the other gates, but here they are a note, and neither is read.
121
+ # `--level` answers in one line on stdout, so there the note goes to stderr.
122
+ if ! PRODUCT_CONFIG="$(product_config 2>/dev/null)"; then
123
+ _drift="note [risk-map]: .sdlc/product.json and .sdlc/hub.json say different things (or SDLC_PRODUCT_CONFIG and SDLC_HUB_CONFIG do) — neither is read; run \`yad migrate\` in the Product."
124
+ if [ "$LEVEL_ONLY" = 1 ]; then echo "$_drift" >&2; else echo "$_drift"; fi
125
+ # An empty file, so `resolve_base` reads no default_branch and falls back like a repo with no settings.
126
+ PRODUCT_CONFIG=/dev/null
127
+ fi
128
+
97
129
  BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
98
130
  [ "$LEVEL_ONLY" = 1 ] || [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [risk-map]: no base given — diffing against '${BASE}'."
99
131
  base_ok=0
@@ -10,11 +10,35 @@
10
10
  # range), so the report names every offending commit.
11
11
  set -euo pipefail
12
12
 
13
+ # --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
14
+ # --- it is duplicated, not sourced) ---
15
+ # The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
16
+ # `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
17
+ # instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
18
+ # never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
19
+ # names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
20
+ # only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
21
+ product_config() {
22
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
23
+ && [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
24
+ 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
25
+ return 1
26
+ fi
27
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
28
+ if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
29
+ if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
30
+ 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
31
+ return 1
32
+ fi
33
+ if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
34
+ }
35
+ PRODUCT_CONFIG="$(product_config)" || exit 1
36
+
13
37
  # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
14
38
  # --- is duplicated, not sourced) ---
15
39
  # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
16
40
  # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
17
- # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
41
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
18
42
  # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
19
43
  # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
20
44
  # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
@@ -23,7 +47,7 @@ set -euo pipefail
23
47
  # command substitution in an assignment aborts the script.
24
48
  resolve_base() {
25
49
  # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
26
- _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
50
+ _cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
27
51
  _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
28
52
  for _c in "origin/${_cfg}" "${_head}" origin/main; do
29
53
  case "$_c" in ''|origin/) continue ;; esac
@@ -44,6 +68,8 @@ RANGE="${BASE}..HEAD"
44
68
 
45
69
  # Conventional-Commits types exempt from the spec-link requirement (optional (scope) and breaking !).
46
70
  EXEMPT='ci|chore|build|test'
71
+ # The letters are spelled out so no locale widens them; matched whole with bash `=~`.
72
+ STORY_RE='^EP-[abcdefghijklmnopqrstuvwxyz0123456789-]+-S[0123456789]+$'
47
73
 
48
74
  # Portable across bash 3.2 (macOS) and 4+ — no mapfile; feed the loop via heredoc (not a pipe) so
49
75
  # the failure count survives the loop body.
@@ -58,7 +84,7 @@ while IFS= read -r sha; do
58
84
  [ -z "$sha" ] && continue
59
85
  short="$(git log -1 --format=%h "$sha")"
60
86
  subject="$(git log -1 --format=%s "$sha")"
61
- task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | sed '/^$/d' | head -1)"
87
+ task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | awk '!f && length($0) { print; f = 1 }')"
62
88
  exempt=0
63
89
  note=''
64
90
  if printf '%s' "$subject" | grep -qE "^(${EXEMPT})(\([a-z0-9._-]+\))?!?: "; then
@@ -86,6 +112,13 @@ while IFS= read -r sha; do
86
112
  continue
87
113
  fi
88
114
  story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
115
+ # One story ID shape, the same as the Product-reading gates (E117 review 1): EP-<slug>-S<n>. A trailer
116
+ # of `EP-x-S01/.-T1` names `specs/EP-x-S01/./link.md`, which exists, and walked past the others.
117
+ if ! [[ $story =~ $STORY_RE ]]; then
118
+ echo "FAIL [spec-link]: ${short} ${task} — '${story}' is not a story ID (expected EP-<slug>-S<n>, the slug in lowercase letters, digits and dashes)."
119
+ rc=1
120
+ continue
121
+ fi
89
122
  if [ -f "specs/${story}/link.md" ]; then
90
123
  echo "PASS [spec-link]: ${short} ${task} -> specs/${story}/link.md${note}"
91
124
  else