yadflow 3.19.0-next.2 → 4.0.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +160 -22
  3. package/bin/commands.mjs +46 -0
  4. package/bin/yad.mjs +506 -117
  5. package/cli/artifact-status.mjs +30 -11
  6. package/cli/capture.mjs +417 -0
  7. package/cli/checkpoint.mjs +47 -27
  8. package/cli/claims.mjs +165 -0
  9. package/cli/codeowners-command.mjs +170 -0
  10. package/cli/codeowners.mjs +397 -0
  11. package/cli/commit.mjs +13 -9
  12. package/cli/dial.mjs +183 -0
  13. package/cli/docs.mjs +88 -32
  14. package/cli/doctor.mjs +1682 -90
  15. package/cli/epic-state.mjs +3460 -211
  16. package/cli/epic.mjs +506 -0
  17. package/cli/errors.mjs +3 -0
  18. package/cli/fold.mjs +247 -0
  19. package/cli/gate-local.mjs +296 -0
  20. package/cli/gate.mjs +977 -183
  21. package/cli/history.mjs +556 -0
  22. package/cli/hook.mjs +264 -53
  23. package/cli/hubcommit.mjs +9 -19
  24. package/cli/index-command.mjs +87 -0
  25. package/cli/ledger.mjs +54 -4
  26. package/cli/lib.mjs +161 -9
  27. package/cli/manifest.mjs +376 -38
  28. package/cli/migrate.mjs +683 -52
  29. package/cli/mode.mjs +170 -0
  30. package/cli/next.mjs +365 -86
  31. package/cli/openpr.mjs +193 -34
  32. package/cli/owners.mjs +266 -0
  33. package/cli/people.mjs +657 -0
  34. package/cli/plan.mjs +466 -125
  35. package/cli/platform.mjs +109 -128
  36. package/cli/product-index.mjs +287 -0
  37. package/cli/protection.mjs +706 -0
  38. package/cli/reconcile.mjs +40 -12
  39. package/cli/repo-publish.mjs +24 -26
  40. package/cli/repo.mjs +23 -14
  41. package/cli/report.mjs +24 -14
  42. package/cli/review.mjs +19 -22
  43. package/cli/riskmap-command.mjs +289 -0
  44. package/cli/riskmap.mjs +373 -0
  45. package/cli/setup.mjs +127 -279
  46. package/cli/ship.mjs +6 -5
  47. package/cli/skill.mjs +180 -0
  48. package/cli/skip.mjs +211 -30
  49. package/cli/thread.mjs +41 -16
  50. package/cli/tidy.mjs +18 -18
  51. package/cli/update-commit.mjs +21 -21
  52. package/cli/usage.mjs +115 -109
  53. package/package.json +3 -3
  54. package/skills/sdlc/config.yaml +149 -70
  55. package/skills/sdlc/module-help.csv +24 -24
  56. package/skills/yad-analysis/SKILL.md +121 -59
  57. package/skills/yad-architecture/SKILL.md +30 -18
  58. package/skills/yad-architecture/references/contract-format.md +8 -5
  59. package/skills/yad-backfill/SKILL.md +10 -4
  60. package/skills/yad-backfill/references/backfill.md +8 -1
  61. package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
  62. package/skills/yad-change/SKILL.md +119 -42
  63. package/skills/yad-change/references/triage.md +42 -27
  64. package/skills/yad-checks/SKILL.md +68 -35
  65. package/skills/yad-checks/references/check-gates.md +310 -85
  66. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  67. package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
  68. package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
  69. package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
  70. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  71. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
  72. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  73. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  74. package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
  75. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  76. package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
  77. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  78. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
  79. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  80. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
  81. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  82. package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
  83. package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
  84. package/skills/yad-commit/SKILL.md +4 -4
  85. package/skills/yad-connect-design/SKILL.md +5 -5
  86. package/skills/yad-connect-design/references/design-context.md +1 -1
  87. package/skills/yad-connect-design/references/design-registry.md +2 -2
  88. package/skills/yad-connect-docs/SKILL.md +11 -11
  89. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  90. package/skills/yad-connect-learning/SKILL.md +5 -5
  91. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  92. package/skills/yad-connect-repos/SKILL.md +66 -45
  93. package/skills/yad-connect-repos/references/code-context.md +4 -4
  94. package/skills/yad-connect-repos/references/hub-config.md +47 -51
  95. package/skills/yad-connect-repos/references/repos-registry.md +8 -7
  96. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  97. package/skills/yad-connect-testing/SKILL.md +5 -5
  98. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  99. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  100. package/skills/yad-defects/SKILL.md +7 -7
  101. package/skills/yad-discovery/SKILL.md +131 -94
  102. package/skills/yad-discovery/references/discovery-schema.md +22 -6
  103. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  104. package/skills/yad-docs/SKILL.md +13 -8
  105. package/skills/yad-docs/references/data-mapping.md +9 -7
  106. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  107. package/skills/yad-docs-overview/SKILL.md +30 -15
  108. package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
  109. package/skills/yad-docs-sync/SKILL.md +10 -5
  110. package/skills/yad-docs-sync/references/staleness.md +8 -7
  111. package/skills/yad-engineer-review/SKILL.md +81 -17
  112. package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
  113. package/skills/yad-epic/SKILL.md +173 -92
  114. package/skills/yad-epic/references/state-schema.md +610 -94
  115. package/skills/yad-hub-bridge/SKILL.md +57 -38
  116. package/skills/yad-hub-bridge/references/bridge.md +94 -67
  117. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  118. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  119. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  120. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  121. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
  122. package/skills/yad-implement/SKILL.md +27 -13
  123. package/skills/yad-implement/references/implement-conventions.md +2 -2
  124. package/skills/yad-learn/SKILL.md +9 -9
  125. package/skills/yad-learn/references/learning-state.md +2 -2
  126. package/skills/yad-open-pr/SKILL.md +61 -26
  127. package/skills/yad-pair-review/SKILL.md +7 -5
  128. package/skills/yad-pair-review/references/session-state.md +1 -1
  129. package/skills/yad-pr-template/SKILL.md +47 -26
  130. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  131. package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
  132. package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
  133. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  134. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  135. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  136. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
  137. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
  138. package/skills/yad-reconcile/SKILL.md +3 -3
  139. package/skills/yad-report/SKILL.md +5 -5
  140. package/skills/yad-review-companion/SKILL.md +6 -3
  141. package/skills/yad-review-gate/SKILL.md +203 -93
  142. package/skills/yad-review-gate/references/gating.md +232 -54
  143. package/skills/yad-run/SKILL.md +81 -51
  144. package/skills/yad-run/references/run-loop.md +61 -39
  145. package/skills/yad-ship/SKILL.md +15 -11
  146. package/skills/yad-spec/SKILL.md +21 -6
  147. package/skills/yad-spec/references/spec-handoff.md +17 -5
  148. package/skills/yad-status/SKILL.md +115 -50
  149. package/skills/yad-stories/SKILL.md +37 -21
  150. package/skills/yad-stories/references/story-schema.md +7 -6
  151. package/skills/yad-stub/SKILL.md +58 -47
  152. package/skills/yad-sync-repos/SKILL.md +2 -2
  153. package/skills/yad-test-cases/SKILL.md +32 -23
  154. package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
  155. package/skills/yad-timeline/SKILL.md +8 -7
  156. package/skills/yad-ui/SKILL.md +41 -18
  157. package/cli/roster.mjs +0 -164
  158. package/skills/sdlc/install.sh +0 -68
@@ -7,9 +7,9 @@
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, default code): the subject rule is identical on the product hub and on
10
+ # Profiles (--profile code|hub|product, default code): 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
- # that on code repos; hub commits are not task-scoped) — this gate only checks SHAPE and ORDER.
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
@@ -23,7 +23,21 @@ while [ $# -gt 0 ]; do
23
23
  *) ARGS+=("$1"); shift ;;
24
24
  esac
25
25
  done
26
- case "$PROFILE" in code|hub) ;; *) echo "FAIL [commit-message]: unknown --profile '$PROFILE' (code|hub)."; exit 1 ;; esac
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.
28
+ #
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.
34
+ #
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`
37
+ # branch is skipped, taking the whole review-branch arm with it — including the guard that stops a
38
+ # plain code title carrying an artifact change past its review.
39
+ [ "$PROFILE" = product ] && PROFILE=hub
40
+
27
41
 
28
42
  # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
29
43
  # --- is duplicated, not sourced) ---
@@ -7,6 +7,11 @@
7
7
  # (link.md's pinned hash must match the product lock). Otherwise FAIL and route back to the
8
8
  # architecture gate. Normal implementation that only CONSUMES the contract passes untouched.
9
9
  set -euo pipefail
10
+ # Bytes, not characters (E114). The changed list below carries raw path bytes, and a path that is not
11
+ # valid UTF-8 read under a UTF-8 locale is one GNU grep and sed may skip or leave unmatched — one such
12
+ # file in a diff would hide every other path from the patterns (Linux CI). On macOS, `tr` and `sed` stop
13
+ # on it with "Illegal byte sequence" instead, so the gate fails with a message that names nothing.
14
+ export LC_ALL=C
10
15
 
11
16
  # --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
12
17
  # --- is duplicated, not sourced) ---
@@ -41,14 +46,89 @@ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
41
46
  fi
42
47
  RANGE="${BASE}..HEAD"
43
48
 
44
- # core.quotePath=false: with the default ON, git wraps any path holding a non-ASCII byte in quotes
45
- # and octal-escapes it, so a slice like specs/EP-démo-S01/contracts/api.md never matches the pattern
46
- # below — the surface change would be invisible to the gate it exists to stop.
47
- changed="$(git -c core.quotePath=false diff --name-only "$RANGE")"
48
- surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts/' || true)"
49
+ # No symlink and no submodule anywhere under specs/ (E115). The gate reads PATHS: a link lets the
50
+ # content live somewhere the paths below never name. A symlink at `specs`, `specs/<story>` or
51
+ # `specs/<story>/contracts` hid the slice from every rule here, and so did every later edit to the
52
+ # link's target; a symlinked slice file was seen once, when added, and never again. A link.md behind a
53
+ # link redirects the lock pin the fidelity check reads. yad-spec writes real files, so nothing
54
+ # legitimate lives there as a link.
55
+ #
56
+ # Read from the TREE at HEAD, not from the diff, and before the "no surface" PASS below: a link merged
57
+ # before this check existed fails every PR until one PR removes it — no diff would ever show it again.
58
+ # 120000 is a symlink, 160000 a submodule; `-r` lists `specs` itself when `specs` is the link.
59
+ #
60
+ # The WHOLE tree, from the repo root (--full-tree), matched without case: on macOS and Windows `Specs/`
61
+ # IS `specs/`, and git's path filter matches exact bytes only (`:(icase)` is refused by ls-tree). So a
62
+ # link under `Specs/` got past a `-- specs` read, and a plain file at `Specs/<story>/contracts/…` was
63
+ # never on the surface below, which is spelled in lowercase. A top folder spelled any other way than
64
+ # `specs` is refused too, once, by its own name. The name printed is everything after the first tab.
65
+ # `tolower` under LC_ALL=C folds ASCII only, so the one other letter the file systems fold into "specs"
66
+ # is mapped by hand: `ſ` (long s, U+017F, bytes 305 277) — APFS reads `ſpecs/` as `specs/`. That is
67
+ # `fold` below.
68
+ #
69
+ # The same holds one and two folders down (review 3): `specs/<story>/Contracts/` IS `contracts/` on a
70
+ # Mac, and `specs/EP-x-S01/` and `specs/ep-x-s01/` are one folder there, while every rule below reads
71
+ # exact bytes. So a `contracts` spelled any other way is refused, and so is a second spelling of a
72
+ # story folder — each once, by name. So is a FILE named `specs`, where the folder has to go. `fold`
73
+ # knows ASCII case and the long s only: `EP-démo` beside `EP-DÉMO` (or an NFC/NFD twin) is not caught.
74
+ # That costs a skipped lock check at most — the surface grep takes any story spelling — and refusing
75
+ # every non-ASCII story name would refuse real repos.
76
+ links="$(git ls-tree -r -z --full-tree HEAD | tr '\0' '\n' | awk '
77
+ function fold(x) { x = tolower(x); gsub(/\305\277/, "s", x); return x }
78
+ function once(k, msg) { if (!(k in said)) { said[k] = 1; print " " msg } }
79
+ { t = index($0, "\t"); p = (t ? substr($0, t + 1) : $0); m = (t ? substr($0, 1, t - 1) : ""); lp = fold(p) }
80
+ lp !~ /^specs(\/|$)/ { next }
81
+ m ~ /^120000 / { print " " p " (symlink)"; next }
82
+ m ~ /^160000 / { print " " p " (submodule)"; next }
83
+ p !~ /^specs(\/|$)/ { top = p; sub(/\/.*/, "", top); once(top, (top == p ? top " (a file" : top "/ (a folder") " spelled other than specs — the same name on macOS and Windows)"); next }
84
+ p == "specs" { print " specs (a file, where the specs/ folder goes)"; next }
85
+ { n = split(p, c, "/"); fs = fold(c[2]) }
86
+ (fs in story) && story[fs] != c[2] { once("s/" c[2], "specs/" story[fs] (n == 2 ? " and specs/" c[2] " (one file" : "/ and specs/" c[2] "/ (one folder") " on macOS and Windows)") }
87
+ !(fs in story) { story[fs] = c[2] }
88
+ n >= 3 && c[3] != "contracts" && fold(c[3]) == "contracts" { once("c/" c[2] "/" c[3], "specs/" c[2] "/" c[3] (n == 3 ? " (a file" : "/ (a folder") " spelled other than contracts — the same name on macOS and Windows)") }
89
+ ')" || { echo "FAIL [contract-check]: could not read the tree at HEAD — the links under specs/ cannot be checked."; exit 1; }
90
+ if [ -n "$links" ]; then
91
+ echo "FAIL [contract-check]: specs/ holds a symlink, a submodule or a second spelling — this gate cannot see what it points at:"
92
+ printf '%s\n' "$links"
93
+ echo " -> replace each link with the real files (yad-spec writes real files), and keep ONE spelling"
94
+ echo " of each folder: specs/, specs/<story>/, specs/<story>/contracts/. Until then every PR in this"
95
+ echo " repo fails here, because the contract surface cannot be checked. Removing a link or merging"
96
+ echo " a spelling is itself a change to specs/: under contracts/ it needs 'Contract-Change: yes'."
97
+ exit 1
98
+ fi
99
+
100
+ # How the changed list is built, and why each flag is there (E114):
101
+ # --no-renames a rename is listed by BOTH paths, as a delete and an add. Without it git names a
102
+ # rename by its NEW path only, so `git mv specs/S1/contracts/api.md docs/api.md`
103
+ # took the slice off the surface and the gate never saw it — no trailer needed.
104
+ # -z | tr NUL-separated, so git never quotes a path. By default git wraps a path holding a
105
+ # non-ASCII byte in quotes and octal-escapes it, and it still quotes one holding a `"`
106
+ # or a tab with core.quotePath off — either way a slice like
107
+ # specs/EP-démo-S01/contracts/api.md never matched the pattern below.
108
+ # Both lists (this one and `deleted` below) are built the SAME way: the no-lock escape hatch compares
109
+ # them line for line.
110
+ changed="$(git diff --no-renames --name-only -z "$RANGE" | tr '\0' '\n')"
111
+ surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts(/|$)' || true)"
112
+
113
+ # Slice paths this diff DELETES. `--name-only` above lists a deleted file exactly like a changed one,
114
+ # which is right for the trailer rule — removing an agreed endpoint IS a surface change — but it
115
+ # creates a dead end for the one epic that should never have had a slice in the first place.
116
+ #
117
+ # A short-lane epic has no lock and never will (E40). Once a `specs/<story>/contracts/` file exists
118
+ # under one, the unflagged rule below fails the commit, the no-lock rule further down fails the
119
+ # flagged one, and the cleanup commit that DELETES the file fails too — so there is no diff that
120
+ # passes, including the correct one. An escape hatch is not optional there.
121
+ #
122
+ # Deleting your own copy of a surface that nothing upstream has locked cannot widen it: with no lock
123
+ # there is no agreed shape for the deletion to contradict. So a delete-only diff is allowed through
124
+ # the no-lock branch (and only that branch — where a lock EXISTS, a deletion is still a real surface
125
+ # change and every rule below applies to it unchanged).
126
+ # --no-renames matters here too: a slice MOVED out of contracts/ is a delete of the old path, and
127
+ # without it git reports an `R`, which --diff-filter=D never matches — the hatch would refuse the move.
128
+ deleted="$(git diff --no-renames --diff-filter=D --name-only -z "$RANGE" | tr '\0' '\n' | grep -E '^specs/[^/]+/contracts(/|$)' || true)"
49
129
 
50
130
  if [ -z "$surface" ]; then
51
- echo "PASS [contract-check]: diff does not touch the contract surface (specs/*/contracts/**)."
131
+ echo "PASS [contract-check]: diff does not touch the contract surface (specs/*/contracts and everything under it)."
52
132
  exit 0
53
133
  fi
54
134
 
@@ -59,6 +139,8 @@ cc="$(git log "$RANGE" --format='%(trailers:key=Contract-Change,valueonly)' | se
59
139
  if ! printf '%s\n' "$cc" | grep -qx 'yes'; then
60
140
  echo "FAIL [contract-check]: contract surface changed without a 'Contract-Change: yes' trailer."
61
141
  echo " -> Route back to the architecture gate: update + re-lock contract.md in the product repo,"
142
+ echo " or, if this epic is on a short lane (chore/spike) and has no architecture gate, move the"
143
+ echo " surface change to a new epic on the classic route,"
62
144
  echo " re-run yad-spec, then implement with Contract-Change: yes. The surface is never widened"
63
145
  echo " from inside a code repo."
64
146
  exit 1
@@ -74,7 +156,7 @@ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f &&
74
156
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
75
157
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
76
158
  # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
77
- # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
159
+ # from fm_val: Product artifacts (epic.md, stories/*.md) stay bounded to their first block.
78
160
  link_val() {
79
161
  _v="$(fm_val "$1" "$2")"
80
162
  [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
@@ -105,7 +187,7 @@ resolve_product() {
105
187
  # link.md deferred the whole check before the stale one was ever read (issue #161). Failures are
106
188
  # AGGREGATED — every story reports, so one clean-or-deferred story never masks another's stale pin
107
189
  # (the same rule spec-link applies per commit).
108
- stories="$(printf '%s\n' "$surface" | sed -E 's#^specs/([^/]+)/contracts/.*#\1#' | sort -u)"
190
+ stories="$(printf '%s\n' "$surface" | sed -E 's#^specs/([^/]+)/contracts(/.*)?$#\1#' | sort -u)"
109
191
  rc=0
110
192
  while IFS= read -r story; do
111
193
  [ -z "$story" ] && continue
@@ -146,6 +228,45 @@ while IFS= read -r story; do
146
228
  continue
147
229
  fi
148
230
  echo "note [contract-check]: ${link} hash matches the product lock (${current:0:12}…)."
231
+ elif [ -n "$prod" ] && [ -d "${prod}/epics/${epic}/.sdlc" ]; then
232
+ # THE PRODUCT REPO RESOLVED AND THERE IS STILL NO LOCK. That is a different fact from "not
233
+ # reachable", and it must not share its answer. A `Contract-Change: yes` is being claimed against
234
+ # an epic that has no locked surface at all, so there is nothing the claim could be true of.
235
+ #
236
+ # This is the steady state of the short lanes (E40): `chore` and `spike` carry no architecture
237
+ # step, so `contract.md` and `contract-lock.json` never exist on them. Deferring here meant every
238
+ # short-lane epic could move the shared cross-repo surface forever, with a note that reads like a
239
+ # pass. The guard used to be prose in the authoring skills; this is the gate.
240
+ #
241
+ # Narrow on purpose, and the guard is the EPIC'S LEDGER DIRECTORY, not the product path — because
242
+ # `resolve_product` ALWAYS returns some path and never verifies that the one it picked exists. (Its
243
+ # own two `-d` tests only choose BETWEEN the story-relative and repo-root joins; neither rejects a
244
+ # miss.) Requiring `epics/<epic>/.sdlc/` to exist is what separates "this epic is real and has no
245
+ # lock" from "nothing is checked out here". A CI job that does not check the
246
+ # product repo out still defers below, exactly as before — that case proves nothing either way,
247
+ # and failing it would break every setup that has always run this way.
248
+ # The escape hatch described at the top: with no lock upstream there is no agreed shape for a
249
+ # deletion to contradict, so REMOVING a slice is always allowed here. This is what keeps a
250
+ # short-lane story that should never have had a `contracts/` folder from being unfixable.
251
+ story_surface="$(printf '%s\n' "$surface" | grep -E "^specs/${story}/contracts(/|$)" || true)"
252
+ story_deleted="$(printf '%s\n' "$deleted" | grep -E "^specs/${story}/contracts(/|$)" || true)"
253
+ if [ -n "$story_surface" ] && [ "$story_surface" = "$story_deleted" ]; then
254
+ echo "note [contract-check]: ${story} only REMOVES contract slice files and ${epic} has no lock —"
255
+ echo " nothing upstream is being contradicted, so the removal is allowed."
256
+ continue
257
+ fi
258
+ echo "FAIL [contract-check]: Contract-Change claimed, but ${epic} has no contract lock at all."
259
+ echo " Expected ${lock} — the epic's ledger directory is there and the lock file is not."
260
+ echo " Three ways to be here, with three different fixes:"
261
+ echo " - a SHORT LANE (chore/spike): it has no architecture step and never locks a surface, so it"
262
+ echo " carries no specs/<story>/contracts/ at all. DELETE the slice; a delete-only diff passes."
263
+ echo " A real surface change belongs to a new epic on the classic route."
264
+ echo " - a STUB / lightly-promoted brownfield epic: the feature was never documented, so there is"
265
+ echo " nothing to lock yet. Run yad-backfill and promote it fully (which locks contract.md)."
266
+ echo " - a CLASSIC epic that has not reached its architecture gate: author and lock contract.md"
267
+ echo " first (yad-architecture Step 5)."
268
+ rc=1
269
+ continue
149
270
  else
150
271
  # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
151
272
  # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
@@ -156,7 +277,7 @@ $stories
156
277
  EOF
157
278
 
158
279
  if [ "$rc" != 0 ]; then
159
- echo "FAIL [contract-check]: a changed slice pins a stale contract lock (see above) — the surface was not re-locked upstream for every story in this diff."
280
+ echo "FAIL [contract-check]: a changed slice pins a stale or absent contract lock (see above) — the surface was not re-locked upstream for every story in this diff."
160
281
  exit 1
161
282
  fi
162
283
 
@@ -53,7 +53,7 @@ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f &&
53
53
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
54
54
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
55
55
  # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
56
- # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
56
+ # from fm_val: Product artifacts (epic.md, stories/*.md) stay bounded to their first block.
57
57
  link_val() {
58
58
  _v="$(fm_val "$1" "$2")"
59
59
  [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
@@ -142,7 +142,7 @@ while IFS= read -r sha; do
142
142
  if epic_sealed "$ep_dir"; then
143
143
  echo "FAIL [epic-open]: ${short} ${task} targets SEALED epic ${epic} (all stories shipped)."
144
144
  echo " -> New behaviour cannot mutate a shipped epic. Open a threaded change-epic with yad-change"
145
- echo " (kind: change|defect|hotfix, parent: ${epic}) and implement against ITS stories instead."
145
+ echo " (type change|defect|hotfix, parent: ${epic}) and implement against ITS stories instead."
146
146
  rc=1
147
147
  continue
148
148
  fi
@@ -6,8 +6,21 @@
6
6
  # the ledger" a mechanical guarantee instead of a convention.
7
7
  #
8
8
  # Protected (gate-state, machine-written):
9
- # epics/*/.sdlc/state.json, approvals.json, comments.json, hub-prs.json
9
+ # epics/*/.sdlc/state.json, approvals.json, comments.json, product-prs.json, hub-prs.json
10
+ # foundation/.sdlc/… and foundation/reviews/*.md — the same files for the Product level (E75), whose
11
+ # ledger lives in its own top-level folder. A copy of this script from before that release does NOT
12
+ # guard them; `yad doctor` says so on a verified Product that has a Foundation, and `yad update`
13
+ # refreshes this file.
14
+ #
15
+ # BOTH names of the PR ledger are guarded. It was renamed `hub-prs.json` -> `product-prs.json`, and
16
+ # the engine writes both for one major so that a copy of THIS script which predates the rename still
17
+ # finds the file it knows. Guarding only one name would leave the other open to a hand-edit on a
18
+ # verified product — the exact thing this gate exists to refuse.
10
19
  # epics/*/reviews/*.md
20
+ # .sdlc/index.json — the Product index (E19): derived from the ledgers above, and rebuilt by CI in the
21
+ # same commit that records a merge. Exactly that one path: the files beside it (product.json, hub.json,
22
+ # repos.json…) are a person's to edit, and a wider arm would refuse `yad mode` and `yad setup` commits.
23
+ # It is never a seed, so no carve-out applies to it.
11
24
  # NOT protected:
12
25
  # epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
13
26
  # `gate open`, so a human legitimately commits it alongside the architecture artifact.
@@ -23,7 +36,7 @@
23
36
  # Scope: enforced ONLY when the ledger is verified — hub.json carries BOTH a `platform` and either
24
37
  # `ledger: "verified"` or, on a project that has not run `yad migrate` yet, `bridge_enabled` (or the
25
38
  # legacy `bridge`) true. That is the same predicate `isVerifiedLedger` (cli/manifest.mjs) and
26
- # `hubActions` (cli/plan.mjs) apply. With a local ledger — or a platform-less hub — humans
39
+ # `productActions` (cli/plan.mjs) apply. With a local ledger — or a platform-less Product — humans
27
40
  # legitimately write the ledger themselves, so the gate is a no-op.
28
41
  #
29
42
  # Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
@@ -32,8 +45,8 @@ set -euo pipefail
32
45
 
33
46
  # ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
34
47
  # The predicate is BOTH a platform and the verified ledger flag, exactly as `isVerifiedLedger` (`cli/manifest.mjs`) and
35
- # `hubActions` (cli/plan.mjs) define it. Requiring the flag alone put this gate out of step with every
36
- # other ledger reader (issue #186): a hub carrying `bridge_enabled: true` with no `platform` would
48
+ # `productActions` (cli/plan.mjs) define it. Requiring the flag alone put this gate out of step with every
49
+ # other ledger reader (issue #186): a Product carrying `bridge_enabled: true` with no `platform` would
37
50
  # have its human ledger commits rejected here while the CLI, reading the same file, called it
38
51
  # local and kept the LOCAL write path — no CI writer and no permitted human writer, so no gate
39
52
  # could advance. Reachable through a stale install (platform set, script wired, platform later
@@ -45,7 +58,7 @@ set -euo pipefail
45
58
  #
46
59
  # Matched at the ROOT LEVEL only. The shared `default_branch` read below is depth-blind, and that is
47
60
  # survivable there — a false match yields a bogus branch name and the gate fails loudly. Here it is
48
- # not: a nested `"bridge": true` (say under `review`) would silently ENABLE this gate on a hub whose
61
+ # not: a nested `"bridge": true` (say under `review`) would silently ENABLE this gate on a Product whose
49
62
  # `isVerifiedLedger` is false, recreating the exact no-writer deadlock #186 is about, from the other side. So
50
63
  # the nesting is stripped rather than ignored: peel the outermost braces, then delete innermost
51
64
  # objects/arrays until none remain, leaving only root-level pairs to match against. Not a JSON parser
@@ -229,15 +242,32 @@ is_seeding() { # $1 = epic slug; 0 when that epic has no le
229
242
  _s="${_p#epics/}"; _s="${_s%%/*}"
230
243
  base_slugs[${#base_slugs[@]}]="$(fold "$_s")"
231
244
  ;;
245
+ # The Product level (E75) has ONE ledger, in `foundation/`, under the fixed id EP-foundation.
246
+ foundation/.sdlc/state.json)
247
+ base_slugs[${#base_slugs[@]}]="ep-foundation"
248
+ ;;
232
249
  esac
233
- done < <(git -c core.quotePath=false ls-tree -r --name-only -z "${BASE}" -- epics 2>/dev/null || true)
250
+ done < <(git -c core.quotePath=false ls-tree -r --name-only -z "${BASE}" -- epics foundation 2>/dev/null || true)
251
+ # A product has ONE product level. When its OLD spelling (epics/EP-discovery) is already on BASE, a
252
+ # Foundation ledger is not a new product level being created — it is a second one replacing a
253
+ # CI-owned ledger, and on a verified Product no CI path writes one. So it counts as seeded, and a
254
+ # human commit adding it is a mutation like any other.
255
+ if in_list "ep-discovery" "${base_slugs[@]}"; then
256
+ base_slugs[${#base_slugs[@]}]="ep-foundation"
257
+ fi
258
+ # …and the mirror: with a Foundation on BASE, a fresh epics/EP-discovery ledger is a second product
259
+ # level too, not a new epic.
260
+ if in_list "ep-foundation" "${base_slugs[@]}"; then
261
+ base_slugs[${#base_slugs[@]}]="ep-discovery"
262
+ fi
234
263
  base_slugs_loaded=1
235
264
  fi
236
265
  _f="$(fold "$1")"
237
266
  in_list "$_f" "${base_slugs[@]}" && return 1
238
267
  in_list "$_f" "${noted_slugs[@]}" && return 0
239
268
  noted_slugs[${#noted_slugs[@]}]="$_f"
240
- echo "note [ledger-guard]: epics/$1 has no ledger on ${BASE} — new epic, its seed is exempt (creation, not mutation)."
269
+ if [ "$_f" = "ep-foundation" ]; then _where="foundation"; else _where="epics/$1"; fi
270
+ echo "note [ledger-guard]: ${_where} has no ledger on ${BASE} — new epic, its seed is exempt (creation, not mutation)."
241
271
  return 0
242
272
  }
243
273
 
@@ -250,13 +280,30 @@ for sha in $commits; do
250
280
  while IFS= read -r -d '' f; do
251
281
  [ -n "$f" ] || continue
252
282
  case "$f" in
283
+ .sdlc/index.json) # the Product index (E19) — derived, CI-written, never a seed
284
+ touches_ledger=1
285
+ echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
286
+ ;;
253
287
  epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
254
- epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
288
+ epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/product-prs.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
255
289
  _slug="${f#epics/}"; _slug="${_slug%%/*}"
256
290
  is_seeding "$_slug" && continue # a new epic's seed — not a mutation of a CI-owned ledger
257
291
  touches_ledger=1
258
292
  echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
259
293
  ;;
294
+ # The Foundation's ledger (E75) — the same files, in the Product level's own folder, under the fixed
295
+ # id EP-foundation. Matched on the path BELOW `foundation/` with a leading `/` put back, so the
296
+ # same `*/.sdlc/<file>` test covers the ledger itself and a nested copy (the epic arms' `*` spans
297
+ # `/` too) without also catching a section file that merely ends in `.sdlc` or `reviews`.
298
+ foundation/*)
299
+ case "/${f#foundation/}" in
300
+ */.sdlc/state.json|*/.sdlc/approvals.json|*/.sdlc/comments.json|*/.sdlc/product-prs.json|*/.sdlc/hub-prs.json|*/reviews/*.md)
301
+ is_seeding "EP-foundation" && continue
302
+ touches_ledger=1
303
+ echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
304
+ ;;
305
+ esac
306
+ ;;
260
307
  esac
261
308
  done < <(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -z "$sha")
262
309
  if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
@@ -52,7 +52,7 @@ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f &&
52
52
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
53
53
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
54
54
  # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
55
- # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
55
+ # from fm_val: Product artifacts (epic.md, stories/*.md) stay bounded to their first block.
56
56
  link_val() {
57
57
  _v="$(fm_val "$1" "$2")"
58
58
  [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
@@ -113,7 +113,7 @@ while IFS= read -r sha; do
113
113
  fi
114
114
  prod="$(resolve_product "$product_rel" "$story")"
115
115
  epicmd="${prod}/epics/${epic}/epic.md"
116
- # Defer ONLY when the product checkout itself is unreachable. A reachable hub whose epic is missing is
116
+ # Defer ONLY when the product checkout itself is unreachable. A reachable Product whose epic is missing is
117
117
  # an orphaned story link — FAIL, do not pass it off as "not reachable".
118
118
  if [ -z "$product_rel" ] || [ ! -d "$prod" ]; then
119
119
  echo "PASS [lineage-check]: ${short} ${task} -> epic ${epic} (product repo not reachable — lineage check deferred)."
@@ -124,25 +124,39 @@ while IFS= read -r sha; do
124
124
  rc=1
125
125
  continue
126
126
  fi
127
- kind="$(fm_val kind "$epicmd")"
128
- [ -z "$kind" ] && kind="feature"
129
- if [ "$kind" = "feature" ]; then
130
- echo "PASS [lineage-check]: ${short} ${task} -> ${epic} (genesis feature epic)."
127
+ # The work-item TYPE. Two names are alive: `kind:` is the original and is still the one that
128
+ # counts; `type:` is the name from shape 5 on, written beside it. READ ORDER IS OLD FIRST, and it
129
+ # is the same order `workItemType` uses in cli/epic-state.mjs — cli/test-checks.mjs runs a table of
130
+ # frontmatter through both and asserts they agree, because two readers is two ways to drift.
131
+ # Absent from both means `feature`: an epic authored before types existed is its own thread root.
132
+ wtype="$(fm_val kind "$epicmd")"
133
+ [ -z "$wtype" ] && wtype="$(fm_val type "$epicmd")"
134
+ [ -z "$wtype" ] && wtype="feature"
135
+ # `feature` and `chore` may stand alone. Upkeep — a dependency bump, a CI move — usually has no
136
+ # feature to hang off, and an invented parent is worse than none: every thread rollup walks
137
+ # `parent:` and would file the upkeep under a feature it has nothing to do with.
138
+ #
139
+ # NOTE the asymmetry, which `feature` has always had: this PASSES before it looks at `parent:` at
140
+ # all, so a genesis type that DOES declare a dangling parent is not checked here. `yad doctor`
141
+ # catches that one (threadChecks skips only the parent-LESS case), so it is covered — just not by
142
+ # this gate.
143
+ if [ "$wtype" = "feature" ] || [ "$wtype" = "chore" ]; then
144
+ echo "PASS [lineage-check]: ${short} ${task} -> ${epic} (genesis ${wtype} epic)."
131
145
  continue
132
146
  fi
133
147
  # A change/defect/hotfix epic MUST thread to a real parent.
134
148
  parent="$(fm_val parent "$epicmd")"
135
149
  if [ -z "$parent" ]; then
136
- echo "FAIL [lineage-check]: ${short} ${task} -> ${epic} is kind:${kind} but declares no 'parent:' — a change-epic must thread to its predecessor."
150
+ echo "FAIL [lineage-check]: ${short} ${task} -> ${epic} is type:${wtype} but declares no 'parent:' — a change-epic must thread to its predecessor."
137
151
  rc=1
138
152
  continue
139
153
  fi
140
154
  if [ ! -f "${prod}/epics/${parent}/epic.md" ]; then
141
- echo "FAIL [lineage-check]: ${short} ${task} -> ${epic} threads to '${parent}', but epics/${parent}/ does not exist in the hub (orphan thread)."
155
+ echo "FAIL [lineage-check]: ${short} ${task} -> ${epic} threads to '${parent}', but epics/${parent}/ does not exist in the Product (orphan thread)."
142
156
  rc=1
143
157
  continue
144
158
  fi
145
- echo "PASS [lineage-check]: ${short} ${task} -> ${epic} (kind:${kind} threaded to ${parent})."
159
+ echo "PASS [lineage-check]: ${short} ${task} -> ${epic} (type:${wtype} threaded to ${parent})."
146
160
  done <<EOF
147
161
  $commits
148
162
  EOF
@@ -52,7 +52,7 @@ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f &&
52
52
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
53
53
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
54
54
  # rather than silently reading an empty value and skipping the check it guards. Deliberately separate
55
- # from fm_val: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
55
+ # from fm_val: Product artifacts (epic.md, stories/*.md) stay bounded to their first block.
56
56
  link_val() {
57
57
  _v="$(fm_val "$1" "$2")"
58
58
  [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"