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
@@ -28,10 +28,12 @@
28
28
  # seed rides the first review PR/MR; see the carve-out below. Mutation stays bot-only.
29
29
  #
30
30
  # A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
31
- # platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
32
- # Verified signature (a key the contributor cannot forge under the bot identity) is what actually
33
- # distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
34
- # human edit and rejected.
31
+ # platform-VERIFIED. A spoofed-author commit that is not Verified is treated as a human edit and
32
+ # rejected. Known gap: the Verified badge proves only that the COMMITTER signed with their own key, not
33
+ # that the bot did, so a contributor who signs can still write the bot's name as the author. The
34
+ # GitHub workflow therefore gives this job no token: the badge lookup fails and every bot-attributed
35
+ # ledger commit is refused. On GitLab the project token reaches this job, so the gap is still open there
36
+ # (see references/check-gates.md).
35
37
  #
36
38
  # Scope: enforced ONLY when the ledger is verified — hub.json carries BOTH a `platform` and either
37
39
  # `ledger: "verified"` or, on a project that has not run `yad migrate` yet, `bridge_enabled` (or the
@@ -43,6 +45,30 @@
43
45
  # badge) WARNs and waives the signature half — the same stance verified-commits takes.
44
46
  set -euo pipefail
45
47
 
48
+ # --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
49
+ # --- it is duplicated, not sourced) ---
50
+ # The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
51
+ # `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
52
+ # instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
53
+ # never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
54
+ # names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
55
+ # only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
56
+ product_config() {
57
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
58
+ && [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
59
+ 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
60
+ return 1
61
+ fi
62
+ if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
63
+ if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
64
+ if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
65
+ 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
66
+ return 1
67
+ fi
68
+ if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
69
+ }
70
+ PRODUCT_CONFIG="$(product_config)" || exit 1
71
+
46
72
  # ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
47
73
  # The predicate is BOTH a platform and the verified ledger flag, exactly as `isVerifiedLedger` (`cli/manifest.mjs`) and
48
74
  # `productActions` (cli/plan.mjs) define it. Requiring the flag alone put this gate out of step with every
@@ -68,22 +94,22 @@ set -euo pipefail
68
94
  # Flattened ONCE into a variable and matched with here-strings, never `tr … | grep -q`: under the
69
95
  # `pipefail` set above, `grep -q` exits at the first match and can SIGPIPE `tr`, which would make a
70
96
  # MATCHING pipeline report failure. Reading from a here-string has no upstream process to kill.
71
- HUB="${SDLC_HUB_CONFIG:-.sdlc/hub.json}"
72
- HUB_FLAT="$(tr -d '\n' < "$HUB" 2>/dev/null || true)"
73
- HUB_ROOT="${HUB_FLAT#*\{}"
74
- HUB_ROOT="${HUB_ROOT%\}*}"
97
+ SETTINGS="$PRODUCT_CONFIG"
98
+ SETTINGS_FLAT="$({ tr -d '\n' < "$SETTINGS"; } 2>/dev/null || true)"
99
+ SETTINGS_ROOT="${SETTINGS_FLAT#*\{}"
100
+ SETTINGS_ROOT="${SETTINGS_ROOT%\}*}"
75
101
  while :; do
76
- _stripped="$(sed -E 's/\{[^{}]*\}//g; s/\[[^][]*\]//g' <<< "$HUB_ROOT")"
77
- [ "$_stripped" = "$HUB_ROOT" ] && break
78
- HUB_ROOT="$_stripped"
102
+ _stripped="$(sed -E 's/\{[^{}]*\}//g; s/\[[^][]*\]//g' <<< "$SETTINGS_ROOT")"
103
+ [ "$_stripped" = "$SETTINGS_ROOT" ] && break
104
+ SETTINGS_ROOT="$_stripped"
79
105
  done
80
106
  # One line in, so `sed` emits at most one line out — no `head` needed (which would re-introduce the
81
107
  # SIGPIPE-under-pipefail problem this avoids).
82
- hub_str() { sed -nE "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p" <<< "$HUB_ROOT"; }
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
108
+ setting_str() { sed -nE "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p" <<< "$SETTINGS_ROOT"; }
109
+ setting_true() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*true" <<< "$SETTINGS_ROOT"; }
110
+ # Is the key present with a STRING value at all, empty included? `setting_str` cannot answer this: it
85
111
  # returns nothing both for a missing key and for an empty one.
86
- hub_has_str() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*\"" <<< "$HUB_ROOT"; }
112
+ setting_has_str() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*\"" <<< "$SETTINGS_ROOT"; }
87
113
 
88
114
  # READ ORDER, identical to `isVerifiedLedger` (cli/manifest.mjs) — the two must never disagree:
89
115
  # 1. `ledger`, if hub.json carries it — shape 2 and later. "verified" and nothing else.
@@ -104,13 +130,13 @@ hub_has_str() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*\"" <<< "$HUB_ROOT"; }
104
130
  # OPPOSITE answers — the guard rejecting human writes while the CLI kept the local path open, which
105
131
  # is the no-writer deadlock of #186 reached from a third direction.
106
132
  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
133
+ if setting_has_str ledger; then
134
+ if [ "$(setting_str ledger)" = "verified" ]; then verified=yes; fi
135
+ elif setting_true bridge_enabled || setting_true bridge; then
110
136
  verified=yes
111
137
  fi
112
138
 
113
- if [ ! -f "$HUB" ] || [ -z "$(hub_str platform)" ] || [ "$verified" != yes ]; then
139
+ if [ ! -f "$SETTINGS" ] || [ -z "$(setting_str platform)" ] || [ "$verified" != yes ]; then
114
140
  echo "PASS [ledger-guard]: the ledger is locally owned (ledger: local) — nothing to guard."
115
141
  exit 0
116
142
  fi
@@ -119,7 +145,7 @@ fi
119
145
  # --- is duplicated, not sourced) ---
120
146
  # With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
121
147
  # whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
122
- # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
148
+ # exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
123
149
  # cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
124
150
  # (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
125
151
  # DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
@@ -128,7 +154,7 @@ fi
128
154
  # command substitution in an assignment aborts the script.
129
155
  resolve_base() {
130
156
  # tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
131
- _cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
157
+ _cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
132
158
  _head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
133
159
  for _c in "origin/${_cfg}" "${_head}" origin/main; do
134
160
  case "$_c" in ''|origin/) continue ;; esac
@@ -197,6 +223,15 @@ trusted_bot() {
197
223
  signature_verified "$1"
198
224
  }
199
225
 
226
+ # Text a commit's author chose — a name, a path, a Ledger-Override reason — is printed into the CI log.
227
+ # The GitHub runner ends a log line at a lone carriage return too, so every control character (CR, ESC,
228
+ # …) becomes a space first; otherwise `\r::error::…` inside a path or a reason would start a line
229
+ # GitHub Actions reads as a workflow command.
230
+ # `printable` keeps newlines, for a list read line by line; `oneline` is for one value (a name, a path),
231
+ # where a newline would start a line of its own too.
232
+ printable() { LC_ALL=C tr '\000-\011\013-\037\177' ' '; }
233
+ oneline() { printf '%s' "$1" | LC_ALL=C tr '\000-\037\177' ' '; }
234
+
200
235
  # ---- seeding carve-out: creation is not mutation (#162) ---------------------------------------
201
236
  # A brand-new epic's ledger has no CI author. `gate ci` only ADVANCES an existing chain — it bails on
202
237
  # a missing state.json ("the review branch is cut from the default branch, so it should carry it") and
@@ -266,12 +301,20 @@ is_seeding() { # $1 = epic slug; 0 when that epic has no le
266
301
  in_list "$_f" "${base_slugs[@]}" && return 1
267
302
  in_list "$_f" "${noted_slugs[@]}" && return 0
268
303
  noted_slugs[${#noted_slugs[@]}]="$_f"
269
- if [ "$_f" = "ep-foundation" ]; then _where="foundation"; else _where="epics/$1"; fi
304
+ if [ "$_f" = "ep-foundation" ]; then _where="foundation"; else _where="epics/$(oneline "$1")"; fi
270
305
  echo "note [ledger-guard]: ${_where} has no ledger on ${BASE} — new epic, its seed is exempt (creation, not mutation)."
271
306
  return 0
272
307
  }
273
308
 
274
309
  violations=0
310
+ # E49: `yad commit --manual --reason` records why a person committed past the local ledger hook, as a
311
+ # `Ledger-Override:` trailer. Anyone can type a trailer, so it changes NOTHING about the verdict — the
312
+ # failing commit still fails. The reason is quoted under the FAIL so the reviewer sees why it was done.
313
+ # Every quoted line goes through `printable` (above) and starts with the commit's sha: a reason is the
314
+ # commit author's text, and a line whose first non-blank characters were `::` would be read by GitHub
315
+ # Actions as a workflow command. Indenting alone would not stop that — the runner skips leading spaces —
316
+ # so every line that prints author text starts with text of ours (the sha, `note [ledger-guard]:`).
317
+ overrides=()
275
318
  for sha in $commits; do
276
319
  touches_ledger=0
277
320
  # quotePath=false + -z on both git reads: a path git chose to escape ("epics/EP-caf\303\251/…") and a
@@ -282,14 +325,14 @@ for sha in $commits; do
282
325
  case "$f" in
283
326
  .sdlc/index.json) # the Product index (E19) — derived, CI-written, never a seed
284
327
  touches_ledger=1
285
- echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
328
+ echo " ${sha} (author $(oneline "$(git show -s --format='%an' "$sha")")) → $(oneline "$f")"
286
329
  ;;
287
330
  epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
288
331
  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)
289
332
  _slug="${f#epics/}"; _slug="${_slug%%/*}"
290
333
  is_seeding "$_slug" && continue # a new epic's seed — not a mutation of a CI-owned ledger
291
334
  touches_ledger=1
292
- echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
335
+ echo " ${sha} (author $(oneline "$(git show -s --format='%an' "$sha")")) → $(oneline "$f")"
293
336
  ;;
294
337
  # The Foundation's ledger (E75) — the same files, in the Product level's own folder, under the fixed
295
338
  # id EP-foundation. Matched on the path BELOW `foundation/` with a leading `/` put back, so the
@@ -300,7 +343,7 @@ for sha in $commits; do
300
343
  */.sdlc/state.json|*/.sdlc/approvals.json|*/.sdlc/comments.json|*/.sdlc/product-prs.json|*/.sdlc/hub-prs.json|*/reviews/*.md)
301
344
  is_seeding "EP-foundation" && continue
302
345
  touches_ledger=1
303
- echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
346
+ echo " ${sha} (author $(oneline "$(git show -s --format='%an' "$sha")")) → $(oneline "$f")"
304
347
  ;;
305
348
  esac
306
349
  ;;
@@ -308,11 +351,18 @@ for sha in $commits; do
308
351
  done < <(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -z "$sha")
309
352
  if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
310
353
  violations=$((violations + 1))
354
+ while IFS= read -r _why; do
355
+ [ -n "$_why" ] && overrides[${#overrides[@]}]=" ${sha} Ledger-Override: ${_why}"
356
+ done < <(git show -s --format='%(trailers:key=Ledger-Override,valueonly,unfold)' "$sha" 2>/dev/null | printable || true)
311
357
  fi
312
358
  done
313
359
 
314
360
  if [ "$violations" -gt 0 ]; then
315
361
  echo "FAIL [ledger-guard]: ${violations} commit(s) change CI-owned gate files without a verified gate-bot signature. The ledger is CI-owned — let CI sync the gate; do not commit .sdlc/*.json or reviews/*.md yourself."
362
+ if [ "${#overrides[@]}" -gt 0 ]; then
363
+ echo "note [ledger-guard]: these commits carry a Ledger-Override trailer — the reason a person gave for committing past the local hook (\`yad commit --manual\` writes it). It is quoted for the reviewer and does not change the verdict:"
364
+ printf '%s\n' "${overrides[@]}"
365
+ fi
316
366
  # An epic's seed is exempt only while its ledger is off the base ref. Once the first review PR merges
317
367
  # (squashed or rebased, so the SHAs differ), those same seed commits still sitting on a sibling
318
368
  # authoring branch read as mutations — the author did nothing wrong and the remedy is a rebase, so