yadflow 3.18.1 → 4.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
@@ -1,26 +1,44 @@
1
1
  #!/usr/bin/env bash
2
2
  # build / test / lint gate (Phase 3 build plan §C).
3
3
  # Standard quality stage: lint, build, and tests that actually exercise behavior (not just pass).
4
- # Delegates to the repo's npm scripts so each repo owns the specifics.
4
+ # Delegates to the package manager named by package.json so each repo owns the specifics.
5
5
  set -euo pipefail
6
6
 
7
+ YAD_CHECKS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
8
+ readonly YAD_CHECKS_DIR
9
+ # shellcheck source-path=SCRIPTDIR
10
+ # shellcheck source=package-manager.sh
11
+ source "$YAD_CHECKS_DIR/package-manager.sh"
12
+ # Two assignments on purpose: nested inside another $(...), a failing yad_package_manager_spec would
13
+ # be swallowed, and the gate would fall back to lockfile detection and PASS on a package.json it
14
+ # had just rejected. Each step fails the gate on its own under `set -e`.
15
+ package_manager_spec="$(yad_package_manager_spec)"
16
+ package_manager="$(yad_detect_package_manager "$package_manager_spec")"
17
+ # `npm`, `pnpm`, or `corepack npm` for a pinned npm — see yad_package_manager_command.
18
+ run_with_words="$(yad_package_manager_command "$package_manager_spec" "$package_manager")"
19
+ read -r -a run_with <<< "$run_with_words"
20
+
7
21
  echo "[build/test/lint] lint…"
8
- npm run --silent lint
22
+ "${run_with[@]}" run --silent lint
9
23
  echo "[build/test/lint] build…"
10
- npm run --silent build
24
+ "${run_with[@]}" run --silent build
11
25
 
12
26
  # Worker cap: when YAD_TEST_MAX_WORKERS is set AND the repo's test script is jest/vitest (the
13
27
  # runners that accept --maxWorkers), forward it to bound CI test concurrency. For any other runner
14
28
  # (node --test, mocha, …) it is a deliberate no-op so the gate never breaks on an unknown flag.
29
+ # npm needs the `--` separator to hand the flag to the script; pnpm (7+) forwards a literal `--` to
30
+ # the script instead, where jest reads `--maxWorkers=N` as a test-path pattern and finds no tests.
15
31
  extra=""
16
32
  if [ -n "${YAD_TEST_MAX_WORKERS:-}" ]; then
17
- case "$(npm pkg get scripts.test 2>/dev/null || true)" in
18
- *jest*|*vitest*) extra="-- --maxWorkers=${YAD_TEST_MAX_WORKERS}" ;;
33
+ case "$(node --input-type=module -e 'import fs from "node:fs"; const p=JSON.parse(fs.readFileSync("package.json", "utf8").replace(/^\uFEFF/, "")); process.stdout.write(p.scripts?.test || "")' 2>/dev/null || true)" in
34
+ *jest*|*vitest*)
35
+ if [ "$package_manager" = "pnpm" ]; then extra="--maxWorkers=${YAD_TEST_MAX_WORKERS}"; else extra="-- --maxWorkers=${YAD_TEST_MAX_WORKERS}"; fi
36
+ ;;
19
37
  esac
20
38
  fi
21
39
  echo "[build/test/lint] test…"
22
- # Intentional word-splitting: $extra is either empty or `-- --maxWorkers=N`.
40
+ # Intentional word-splitting: $extra is empty, `-- --maxWorkers=N` (npm), or `--maxWorkers=N` (pnpm).
23
41
  # shellcheck disable=SC2086
24
- npm run --silent test $extra
42
+ "${run_with[@]}" run --silent test $extra
25
43
 
26
44
  echo "PASS [build/test/lint]: lint, build, and tests all green."
@@ -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) ---
@@ -47,6 +47,21 @@ RANGE="${BASE}..HEAD"
47
47
  changed="$(git -c core.quotePath=false diff --name-only "$RANGE")"
48
48
  surface="$(printf '%s\n' "$changed" | grep -E '^specs/[^/]+/contracts/' || true)"
49
49
 
50
+ # Slice paths this diff DELETES. `--name-only` above lists a deleted file exactly like a changed one,
51
+ # which is right for the trailer rule — removing an agreed endpoint IS a surface change — but it
52
+ # creates a dead end for the one epic that should never have had a slice in the first place.
53
+ #
54
+ # A short-lane epic has no lock and never will (E40). Once a `specs/<story>/contracts/` file exists
55
+ # under one, the unflagged rule below fails the commit, the no-lock rule further down fails the
56
+ # flagged one, and the cleanup commit that DELETES the file fails too — so there is no diff that
57
+ # passes, including the correct one. An escape hatch is not optional there.
58
+ #
59
+ # Deleting your own copy of a surface that nothing upstream has locked cannot widen it: with no lock
60
+ # there is no agreed shape for the deletion to contradict. So a delete-only diff is allowed through
61
+ # the no-lock branch (and only that branch — where a lock EXISTS, a deletion is still a real surface
62
+ # change and every rule below applies to it unchanged).
63
+ deleted="$(git -c core.quotePath=false diff --diff-filter=D --name-only "$RANGE" | grep -E '^specs/[^/]+/contracts/' || true)"
64
+
50
65
  if [ -z "$surface" ]; then
51
66
  echo "PASS [contract-check]: diff does not touch the contract surface (specs/*/contracts/**)."
52
67
  exit 0
@@ -59,6 +74,8 @@ cc="$(git log "$RANGE" --format='%(trailers:key=Contract-Change,valueonly)' | se
59
74
  if ! printf '%s\n' "$cc" | grep -qx 'yes'; then
60
75
  echo "FAIL [contract-check]: contract surface changed without a 'Contract-Change: yes' trailer."
61
76
  echo " -> Route back to the architecture gate: update + re-lock contract.md in the product repo,"
77
+ echo " or, if this epic is on a short lane (chore/spike) and has no architecture gate, move the"
78
+ echo " surface change to a new epic on the classic route,"
62
79
  echo " re-run yad-spec, then implement with Contract-Change: yes. The surface is never widened"
63
80
  echo " from inside a code repo."
64
81
  exit 1
@@ -74,7 +91,7 @@ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f &&
74
91
  # Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
75
92
  # pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
76
93
  # 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.
94
+ # from fm_val: Product artifacts (epic.md, stories/*.md) stay bounded to their first block.
78
95
  link_val() {
79
96
  _v="$(fm_val "$1" "$2")"
80
97
  [ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
@@ -146,6 +163,45 @@ while IFS= read -r story; do
146
163
  continue
147
164
  fi
148
165
  echo "note [contract-check]: ${link} hash matches the product lock (${current:0:12}…)."
166
+ elif [ -n "$prod" ] && [ -d "${prod}/epics/${epic}/.sdlc" ]; then
167
+ # THE PRODUCT REPO RESOLVED AND THERE IS STILL NO LOCK. That is a different fact from "not
168
+ # reachable", and it must not share its answer. A `Contract-Change: yes` is being claimed against
169
+ # an epic that has no locked surface at all, so there is nothing the claim could be true of.
170
+ #
171
+ # This is the steady state of the short lanes (E40): `chore` and `spike` carry no architecture
172
+ # step, so `contract.md` and `contract-lock.json` never exist on them. Deferring here meant every
173
+ # short-lane epic could move the shared cross-repo surface forever, with a note that reads like a
174
+ # pass. The guard used to be prose in the authoring skills; this is the gate.
175
+ #
176
+ # Narrow on purpose, and the guard is the EPIC'S LEDGER DIRECTORY, not the product path — because
177
+ # `resolve_product` ALWAYS returns some path and never verifies that the one it picked exists. (Its
178
+ # own two `-d` tests only choose BETWEEN the story-relative and repo-root joins; neither rejects a
179
+ # miss.) Requiring `epics/<epic>/.sdlc/` to exist is what separates "this epic is real and has no
180
+ # lock" from "nothing is checked out here". A CI job that does not check the
181
+ # product repo out still defers below, exactly as before — that case proves nothing either way,
182
+ # and failing it would break every setup that has always run this way.
183
+ # The escape hatch described at the top: with no lock upstream there is no agreed shape for a
184
+ # deletion to contradict, so REMOVING a slice is always allowed here. This is what keeps a
185
+ # short-lane story that should never have had a `contracts/` folder from being unfixable.
186
+ story_surface="$(printf '%s\n' "$surface" | grep -E "^specs/${story}/contracts/" || true)"
187
+ story_deleted="$(printf '%s\n' "$deleted" | grep -E "^specs/${story}/contracts/" || true)"
188
+ if [ -n "$story_surface" ] && [ "$story_surface" = "$story_deleted" ]; then
189
+ echo "note [contract-check]: ${story} only REMOVES contract slice files and ${epic} has no lock —"
190
+ echo " nothing upstream is being contradicted, so the removal is allowed."
191
+ continue
192
+ fi
193
+ echo "FAIL [contract-check]: Contract-Change claimed, but ${epic} has no contract lock at all."
194
+ echo " Expected ${lock} — the epic's ledger directory is there and the lock file is not."
195
+ echo " Three ways to be here, with three different fixes:"
196
+ echo " - a SHORT LANE (chore/spike): it has no architecture step and never locks a surface, so it"
197
+ echo " carries no specs/<story>/contracts/ at all. DELETE the slice; a delete-only diff passes."
198
+ echo " A real surface change belongs to a new epic on the classic route."
199
+ echo " - a STUB / lightly-promoted brownfield epic: the feature was never documented, so there is"
200
+ echo " nothing to lock yet. Run yad-backfill and promote it fully (which locks contract.md)."
201
+ echo " - a CLASSIC epic that has not reached its architecture gate: author and lock contract.md"
202
+ echo " first (yad-architecture Step 5)."
203
+ rc=1
204
+ continue
149
205
  else
150
206
  # Say so. A skipped fidelity check used to be indistinguishable from a passed one, which is how a
151
207
  # mis-resolved product-repo could turn a stale-pin FAIL into a silent PASS (issue #149).
@@ -156,7 +212,7 @@ $stories
156
212
  EOF
157
213
 
158
214
  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."
215
+ 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
216
  exit 1
161
217
  fi
162
218
 
@@ -3,7 +3,7 @@
3
3
  # is `shipped`. A SEALED epic's artifacts are the final, approved description of shipped behaviour — so
4
4
  # new behaviour must NOT be added to it; it belongs in a NEW threaded change-epic whose re-authored
5
5
  # stories/test-cases describe the change. This gate FAILs any non-maintenance commit whose owning epic
6
- # is sealed, forcing the front half to stay current (staleness becomes unshippable).
6
+ # is sealed, forcing Shape to stay current (staleness becomes unshippable).
7
7
  #
8
8
  # The owning epic lives in the PRODUCT repo (via specs/<story>/link.md `product-repo`). When it is not
9
9
  # reachable from CI, the seal cannot be read, so the commit PASSes with a note (degraded, fail-open here
@@ -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
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # Deterministic CI dependency installation selected by package.json#packageManager.
3
+ set -euo pipefail
4
+
5
+ YAD_CHECKS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
6
+ readonly YAD_CHECKS_DIR
7
+ # shellcheck source-path=SCRIPTDIR
8
+ # shellcheck source=package-manager.sh
9
+ source "$YAD_CHECKS_DIR/package-manager.sh"
10
+
11
+ package_manager_spec="$(yad_package_manager_spec)"
12
+ package_manager="$(yad_detect_package_manager "$package_manager_spec")"
13
+
14
+ case "$package_manager" in
15
+ npm)
16
+ if [ ! -f "$YAD_NPM_LOCKFILE" ] && [ ! -f "$YAD_NPM_SHRINKWRAP" ]; then
17
+ echo "FAIL [install-deps]: npm requires package-lock.json or npm-shrinkwrap.json." >&2
18
+ exit 1
19
+ fi
20
+ if [[ "$package_manager_spec" == npm@* ]]; then
21
+ yad_require_corepack
22
+ corepack enable
23
+ yad_corepack_prepare "$package_manager_spec"
24
+ # npm's shim is not enabled by Corepack, so dispatch it explicitly through Corepack — the same
25
+ # `corepack npm` the gate uses for lint/build/test, so package.json#packageManager selects the
26
+ # npm version end to end instead of the Node image's ambient npm binary.
27
+ corepack npm ci
28
+ else
29
+ npm ci
30
+ fi
31
+ ;;
32
+ pnpm)
33
+ if [ ! -f "$YAD_PNPM_LOCKFILE" ]; then
34
+ echo "FAIL [install-deps]: pnpm requires pnpm-lock.yaml." >&2
35
+ exit 1
36
+ fi
37
+ if [[ "$package_manager_spec" != pnpm@* ]]; then
38
+ echo "FAIL [install-deps]: pnpm CI requires an exact package.json#packageManager value (for example pnpm@9.15.0)." >&2
39
+ exit 1
40
+ fi
41
+ yad_require_corepack
42
+ corepack enable
43
+ yad_corepack_prepare "$package_manager_spec"
44
+ pnpm install --frozen-lockfile
45
+ ;;
46
+ esac
@@ -1,13 +1,26 @@
1
1
  #!/usr/bin/env bash
2
2
  # ledger-guard gate.
3
- # In BRIDGE mode the gate ledger is CI-owned: only the yad gate-sync bot may change the
3
+ # In verified mode the gate ledger is CI-owned: only the yad gate-sync bot may change the
4
4
  # machine-written gate-state files. A commit on a review PR by anyone else that modifies them is
5
5
  # rejected — the human keeps the artifact, CI keeps the ledger. This makes "CI is the sole writer of
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.
@@ -20,21 +33,22 @@
20
33
  # distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
21
34
  # human edit and rejected.
22
35
  #
23
- # Scope: enforced ONLY when the bridge is enabled — hub.json carries BOTH a `platform` and
24
- # `bridge_enabled` (or the legacy `bridge`) true, the same predicate `isBridge` (cli/gate.mjs) and
25
- # `hubActions` (cli/plan.mjs) apply. Without the bridge (file-only / non-bridge, or a platform-less
26
- # hub) humans legitimately write the ledger locally, so the gate is a no-op.
36
+ # Scope: enforced ONLY when the ledger is verified — hub.json carries BOTH a `platform` and either
37
+ # `ledger: "verified"` or, on a project that has not run `yad migrate` yet, `bridge_enabled` (or the
38
+ # legacy `bridge`) true. That is the same predicate `isVerifiedLedger` (cli/manifest.mjs) and
39
+ # `productActions` (cli/plan.mjs) apply. With a local ledger — or a platform-less Product — humans
40
+ # legitimately write the ledger themselves, so the gate is a no-op.
27
41
  #
28
42
  # Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
29
43
  # badge) WARNs and waives the signature half — the same stance verified-commits takes.
30
44
  set -euo pipefail
31
45
 
32
46
  # ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
33
- # The predicate is BOTH a platform and the bridge flag, exactly as `isBridge` (cli/gate.mjs) and
34
- # `hubActions` (cli/plan.mjs) define it. Requiring the flag alone put this gate out of step with every
35
- # other bridge detector (issue #186): a hub carrying `bridge_enabled: true` with no `platform` would
47
+ # The predicate is BOTH a platform and the verified ledger flag, exactly as `isVerifiedLedger` (`cli/manifest.mjs`) and
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
36
50
  # have its human ledger commits rejected here while the CLI, reading the same file, called it
37
- # file-only and kept the LOCAL write path — no CI writer and no permitted human writer, so no gate
51
+ # local and kept the LOCAL write path — no CI writer and no permitted human writer, so no gate
38
52
  # could advance. Reachable through a stale install (platform set, script wired, platform later
39
53
  # nulled), not through `yad setup`, which derives both from one value.
40
54
  #
@@ -44,8 +58,8 @@ set -euo pipefail
44
58
  #
45
59
  # Matched at the ROOT LEVEL only. The shared `default_branch` read below is depth-blind, and that is
46
60
  # survivable there — a false match yields a bogus branch name and the gate fails loudly. Here it is
47
- # not: a nested `"bridge": true` (say under `review`) would silently ENABLE this gate on a hub whose
48
- # `isBridge` is false, recreating the exact no-writer deadlock #186 is about, from the other side. So
61
+ # not: a nested `"bridge": true` (say under `review`) would silently ENABLE this gate on a Product whose
62
+ # `isVerifiedLedger` is false, recreating the exact no-writer deadlock #186 is about, from the other side. So
49
63
  # the nesting is stripped rather than ignored: peel the outermost braces, then delete innermost
50
64
  # objects/arrays until none remain, leaving only root-level pairs to match against. Not a JSON parser
51
65
  # — a value containing a literal brace would confuse it — but hub.json is machine-written and the
@@ -67,9 +81,37 @@ done
67
81
  # SIGPIPE-under-pipefail problem this avoids).
68
82
  hub_str() { sed -nE "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p" <<< "$HUB_ROOT"; }
69
83
  hub_true() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*true" <<< "$HUB_ROOT"; }
84
+ # Is the key present with a STRING value at all, empty included? `hub_str` cannot answer this: it
85
+ # returns nothing both for a missing key and for an empty one.
86
+ hub_has_str() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*\"" <<< "$HUB_ROOT"; }
70
87
 
71
- if [ ! -f "$HUB" ] || [ -z "$(hub_str platform)" ] || { ! hub_true bridge_enabled && ! hub_true bridge; }; then
72
- echo "PASS [ledger-guard]: bridge not enabled — the ledger is locally owned, nothing to guard."
88
+ # READ ORDER, identical to `isVerifiedLedger` (cli/manifest.mjs) — the two must never disagree:
89
+ # 1. `ledger`, if hub.json carries it — shape 2 and later. "verified" and nothing else.
90
+ # 2. otherwise the old booleans `bridge_enabled` (canonical) or `bridge` (older still).
91
+ # A platform is required either way, for the reason in the header.
92
+ #
93
+ # Step 2 is NOT dead weight. This script is committed inside the user's repo and refreshed by
94
+ # `yad update`, which is a separate act from `yad migrate` — so an un-migrated hub.json (no `ledger`
95
+ # key at all) will be read by this version of the script, and it has to keep saying "verified".
96
+ # cli/test-checks.mjs runs a table of hub.json variants through this script AND through the JS
97
+ # reader and asserts they agree on every row.
98
+ # Written as if/else rather than `cmd; verified=$?`: under the `set -e` above, a bare failing test
99
+ # would EXIT the script instead of recording a false — and exiting mid-guard is indistinguishable
100
+ # from passing, so the gate would silently stop guarding.
101
+ # PRESENCE of the key decides which branch runs, not whether its value is non-empty. `"ledger": ""`
102
+ # is a present key with a string value: the JS reader stops there and answers "local", so this must
103
+ # too. Testing `-n` instead sent an empty value down to the old booleans and the two readers gave
104
+ # OPPOSITE answers — the guard rejecting human writes while the CLI kept the local path open, which
105
+ # is the no-writer deadlock of #186 reached from a third direction.
106
+ verified=no
107
+ if hub_has_str ledger; then
108
+ if [ "$(hub_str ledger)" = "verified" ]; then verified=yes; fi
109
+ elif hub_true bridge_enabled || hub_true bridge; then
110
+ verified=yes
111
+ fi
112
+
113
+ if [ ! -f "$HUB" ] || [ -z "$(hub_str platform)" ] || [ "$verified" != yes ]; then
114
+ echo "PASS [ledger-guard]: the ledger is locally owned (ledger: local) — nothing to guard."
73
115
  exit 0
74
116
  fi
75
117
 
@@ -159,7 +201,7 @@ trusted_bot() {
159
201
  # A brand-new epic's ledger has no CI author. `gate ci` only ADVANCES an existing chain — it bails on
160
202
  # a missing state.json ("the review branch is cut from the default branch, so it should carry it") and
161
203
  # writes only at merge, on the default branch — and the engine itself reads a missing state.json as
162
- # "not seeded yet". `gate open` writes nothing in bridge mode, and `checkpoint` stages back-half
204
+ # "not seeded yet". `gate open` writes nothing in verified mode, and `checkpoint` stages Build
163
205
  # ledgers only. So the seed the authoring skills write (yad-epic / yad-change / yad-analysis /
164
206
  # yad-discovery / yad-stub) can reach the trunk ONLY through the first review PR/MR — the one place
165
207
  # this gate runs. Guarding it there makes the documented flow unshippable on a protected trunk, so an
@@ -200,15 +242,32 @@ is_seeding() { # $1 = epic slug; 0 when that epic has no le
200
242
  _s="${_p#epics/}"; _s="${_s%%/*}"
201
243
  base_slugs[${#base_slugs[@]}]="$(fold "$_s")"
202
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
+ ;;
203
249
  esac
204
- 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
205
263
  base_slugs_loaded=1
206
264
  fi
207
265
  _f="$(fold "$1")"
208
266
  in_list "$_f" "${base_slugs[@]}" && return 1
209
267
  in_list "$_f" "${noted_slugs[@]}" && return 0
210
268
  noted_slugs[${#noted_slugs[@]}]="$_f"
211
- 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)."
212
271
  return 0
213
272
  }
214
273
 
@@ -221,13 +280,30 @@ for sha in $commits; do
221
280
  while IFS= read -r -d '' f; do
222
281
  [ -n "$f" ] || continue
223
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
+ ;;
224
287
  epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
225
- 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)
226
289
  _slug="${f#epics/}"; _slug="${_slug%%/*}"
227
290
  is_seeding "$_slug" && continue # a new epic's seed — not a mutation of a CI-owned ledger
228
291
  touches_ledger=1
229
292
  echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
230
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
+ ;;
231
307
  esac
232
308
  done < <(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -z "$sha")
233
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