@ccoalm/ccl-skills 0.3.0 → 0.5.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 (79) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/classify_envelope.py +34 -3
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_abort_leak_state_helpers.sh +148 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_classify_envelope.sh +28 -0
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_review_json.sh +7 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +13 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate_abort_leak.sh +271 -34
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-data-acquisition.md +3 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-disclosure-channels.md +2 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +12 -12
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +8 -0
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +7 -8
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +12 -0
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +3 -2
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +7 -6
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/recurring-anti-patterns-checklist.md +18 -0
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +47 -2
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +28 -29
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +165 -1
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-health.rb +23 -10
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +303 -1
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +222 -91
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +12 -0
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +85 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +123 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_dateless_host.sh +6 -1
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_verdict_differential.sh +1 -1
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_round_attribution.sh +12 -12
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +455 -0
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_source_refuted.sh +20 -20
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_liveness_predicate_gate.sh +288 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +4 -4
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/deliverable-doc-genre-skeletons.md +133 -0
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/doc-charter-first.md +2 -0
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/figure-and-table-craft.md +318 -0
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/AGENTS.md +46 -0
  37. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/doc-lint.py +246 -0
  38. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/figure-lint.py +1092 -0
  39. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/mutation_probe.sh +100 -0
  40. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_figure_and_doc_lint.sh +375 -0
  41. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/control.md +10 -0
  42. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/empty-header.md +6 -0
  43. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fake-header.md +13 -0
  44. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fenced-noise.md +14 -0
  45. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-dangling.md +5 -0
  46. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-orphan-captioned.md +11 -0
  47. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-orphan.md +9 -0
  48. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig.png +0 -0
  49. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/imbalance.md +41 -0
  50. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/no-unit.md +8 -0
  51. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/should-be-chart.md +11 -0
  52. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/tables-only-clean.md +35 -0
  53. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/unfilled.md +7 -0
  54. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/wide-table.md +5 -0
  55. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/bad-viewbox.svg +9 -0
  56. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/blackmarker.svg +9 -0
  57. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/control.svg +12 -0
  58. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/crossings.svg +12 -0
  59. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/cvd-confusable.svg +9 -0
  60. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/decorative-line.svg +10 -0
  61. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/edge-no-arrow.svg +11 -0
  62. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/edge-vague.svg +12 -0
  63. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/figure-contract.json +21 -0
  64. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/figure-is-a-list.svg +12 -0
  65. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/flow-mixed.svg +13 -0
  66. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/low-contrast.svg +12 -0
  67. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/malformed.svg +1 -0
  68. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-aria.svg +9 -0
  69. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-group.svg +10 -0
  70. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-legend.svg +9 -0
  71. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-title.svg +12 -0
  72. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-viewbox.svg +9 -0
  73. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/offcontract-shape.svg +13 -0
  74. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/overflow.svg +13 -0
  75. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/transformed.svg +9 -0
  76. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/ungrouped-card.svg +12 -0
  77. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unlabeled-edge.svg +13 -0
  78. package/dist/assets/release.json +276 -31
  79. package/package.json +1 -1
@@ -270,7 +270,18 @@ else
270
270
  end
271
271
  ' "$root"
272
272
 
273
- if leak_output="$(rg -n 'code\.[[:alnum:].-]+|figma\.com/files|[0-9]{8,}|\x{6559}\x{5e08}|\x{5b66}\x{751f}|\x{8003}\x{8bd5}|\x{5b66}\x{6821}|\x{9605}\x{5377}|\x{51fa}\x{5377}|\x{626b}\x{63cf}|\x{5b66}\x{60c5}' "$root"/skills/*/SKILL.md "$root"/skills/*/references/*.md 2>/dev/null)"; then
273
+ # Public DOMAIN word list: a weak-proxy FALLBACK, not the authoritative R0 gate.
274
+ # The real invariant — "this public text identifies one specific organization or
275
+ # project" — is not decidable by a public regex, which is why the private alias
276
+ # audit (ALIAS_AUDIT_CMD -> r0_status=private-ok) is authoritative and this list
277
+ # only covers environments without it. Consequence, stated rather than fought:
278
+ # these terms are ANOTHER domain's ordinary vocabulary, so they decay — a term
279
+ # outlives the project it proxied for and then only produces false positives.
280
+ # Maintain by RETIREMENT, not by growth: when a term no longer proxies anything
281
+ # live, delete it (quantify current hits first, and keep a retained-term control
282
+ # so the scan is proven still able to fail). Do not add generic technical words.
283
+ # Both directions are pinned by test_entrypoint_domain_scan_terms.sh.
284
+ if leak_output="$(rg -n 'code\.[[:alnum:].-]+|figma\.com/files|[0-9]{8,}|\x{6559}\x{5e08}|\x{5b66}\x{751f}|\x{8003}\x{8bd5}|\x{5b66}\x{6821}|\x{9605}\x{5377}|\x{51fa}\x{5377}|\x{5b66}\x{60c5}' "$root"/skills/*/SKILL.md "$root"/skills/*/references/*.md 2>/dev/null)"; then
274
285
  echo "$leak_output"
275
286
  echo "entrypoint_or_reference_domain_scan_failed" >&2
276
287
  exit 1
@@ -514,6 +525,159 @@ if [[ -n "${gid_hits//[$'\n']/}" ]]; then
514
525
  fi
515
526
  echo "git_identity_predicate_scan_ok"
516
527
 
528
+ # Anti-pattern 28 — process liveness decided by an EXISTENCE test that a corpse answers
529
+ # (see references/recurring-anti-patterns-checklist.md). A pid whose process has exited
530
+ # but has not been reaped is still in the process table: it answers `kill -0`, and its
531
+ # ppid still reads — as 1 once it is reparented. A check that concludes "alive" or
532
+ # "orphaned and alive" from existence alone therefore says yes about a corpse, while the
533
+ # verdict scans in the same suite exclude zombies and say gone — one process state, two
534
+ # answers, and a probe that reds while the code under test is behaving.
535
+ # Promoted from checklist to gate because the class recurred three times in CI on the
536
+ # abort-leak probe, each time on a different assertion, each time asserting the probe's
537
+ # environment rather than the suite.
538
+ # Scope: `test_*.sh` / `test.sh` under the repo — the surface where such a predicate is a
539
+ # TEST VERDICT rather than a signalling guard. Excluded on purpose: non-test shell (a
540
+ # `kill -0` before signalling asks about existence, which is the right question there),
541
+ # and WHOLE-LINE comments (this comment block names the banned spelling).
542
+ # The predicate is the orphan-oracle shape specifically: a `ps -o ppid=` read compared
543
+ # against init's pid on the same line. That shape has exactly one meaning — "has this
544
+ # been reparented, i.e. is it a live orphan" — so precision is high; a ppid read used to
545
+ # IDENTIFY a parent (the common use) is untouched. A process-state consult (`stat=` or a
546
+ # helper whose name ends in `_state`) within the window clears the line, which is the fix.
547
+ # Recall limits — stated so the contract is not overclaimed, per the checklist's
548
+ # promotion guidance (high precision plus a documented recall limit beats broad matching
549
+ # for a BLOCKING gate):
550
+ # 1. `while kill -0 "$pid"` watchdog loops are NOT flagged. Two exist here; both wait on
551
+ # a direct child and `wait` for it immediately after, so the shell reaps it and the
552
+ # loop ends — a narrower risk than the shape above, and forcing churn on them would
553
+ # trade a real false-positive cost for an unproven gain.
554
+ # 2. a liveness branch spelled with a bare `kill -0` inside a loop BODY.
555
+ # 3. any dynamic or indirect spelling.
556
+ # Those stay checklist plus adversarial-challenge checks.
557
+ liveness_hits=""
558
+ liveness_list=$(mktemp "${TMPDIR:-/tmp}/ccl-skills-liveness.XXXXXX") || {
559
+ echo "liveness_predicate_scan_error: mktemp failed" >&2; exit 1; }
560
+ # Guarded rather than the `${var:-/dev/null}` idiom: this trap is installed
561
+ # unconditionally, so an unset var would make the cleanup `rm -f /dev/null` — a no-op for
562
+ # an unprivileged user and a deleted device node for a root container. Never name a path
563
+ # in a removal that a fallback can turn into someone else's file.
564
+ liveness_scan_cleanup() {
565
+ [ -n "${liveness_list:-}" ] && [ -e "${liveness_list:-}" ] && rm -f "$liveness_list"
566
+ [ -n "${gid_list:-}" ] && [ -e "${gid_list:-}" ] && rm -f "$gid_list"
567
+ return 0
568
+ }
569
+ trap liveness_scan_cleanup EXIT
570
+ # Same traversal hardening as Anti-pattern 27, and for the same reason: every leg here
571
+ # exists because its absence makes the gate print "ok" for a scan that did not happen.
572
+ if find -L "$root" -name '*.sh' -type f -print0 > "$liveness_list" 2>/dev/null; then
573
+ liveness_find_rc=0
574
+ else
575
+ liveness_find_rc=$?
576
+ fi
577
+ if [[ "$liveness_find_rc" -ne 0 ]]; then
578
+ rm -f "$liveness_list"
579
+ echo "liveness_predicate_scan_error: find exited $liveness_find_rc — the walk is INCOMPLETE, which is not the same as clean" >&2
580
+ exit 1
581
+ fi
582
+ while IFS= read -r -d '' liveness_file; do
583
+ [[ -n "$liveness_file" ]] || continue
584
+ case "${liveness_file##*/}" in test_*.sh|test.sh) : ;; *) continue ;; esac
585
+ # `[^=]*[^!=]=` rather than `.*=`: an unrestricted `.*` swallows the `!` of a `!=`, so
586
+ # the negated assertion `[ "$(ps -o ppid= -p "$pid")" != "1" ]` — which checks a process
587
+ # is NOT reparented, the opposite of the banned oracle — was reported as a violation.
588
+ # A false positive on a blocking gate is the failure this checklist's promotion
589
+ # guidance weighs heaviest, because it is what gets a gate loosened until it catches
590
+ # nothing.
591
+ if liveness_out="$(grep -nE 'ps[[:space:]]+-o[[:space:]]+ppid=[^=]*[^!=]=[[:space:]]*"?1"?[[:space:]]*\]' "$liveness_file" 2>/dev/null)"; then
592
+ liveness_rc=0
593
+ else
594
+ liveness_rc=$?
595
+ fi
596
+ if [[ "$liveness_rc" -gt 1 ]]; then
597
+ rm -f "$liveness_list"
598
+ echo "liveness_predicate_scan_error: grep exited $liveness_rc while scanning ${liveness_file#"$root"/} — unreadable or I/O failure means the result is UNKNOWN, not clean" >&2
599
+ exit 1
600
+ fi
601
+ [[ "$liveness_rc" -eq 0 ]] || continue
602
+ while IFS= read -r liveness_line; do
603
+ [[ -n "$liveness_line" ]] || continue
604
+ liveness_no="${liveness_line%%:*}"
605
+ liveness_text="${liveness_line#*:}"
606
+ [[ "$liveness_text" =~ ^[[:space:]]*# ]] && continue
607
+ # Window clear: a process-state consult within two lines either side is the fix, so
608
+ # a corrected site stops being reported without needing a waiver marker.
609
+ liveness_lo=$(( liveness_no > 2 ? liveness_no - 2 : 1 ))
610
+ liveness_hi=$(( liveness_no + 2 ))
611
+ # Whole-line comments are dropped before the window is inspected: a prose mention
612
+ # such as a TODO naming the helper would otherwise clear a real violation beside it,
613
+ # and the gate would print ok for a line it had actually found. The waiver must be
614
+ # CODE that consults process state, not a note saying someone should.
615
+ # Only WHOLE-LINE comments are dropped, matching how the hit line itself is filtered.
616
+ # Stripping trailing comments would need to decide whether a `#` opens a comment or
617
+ # belongs to `${var#prefix}`, which needs a shell parser — the same call Anti-pattern
618
+ # 27 makes above. Recall limit: a mention in a TRAILING comment can still clear the
619
+ # window; the checklist row records it.
620
+ liveness_win="$(sed -n "${liveness_lo},${liveness_hi}p" "$liveness_file" 2>/dev/null |
621
+ grep -vE '^[[:space:]]*#' || true)"
622
+ # A direct `ps -o stat=` in the window is the consult itself. Anchored on `-o stat=`
623
+ # rather than bare `stat=`: an unrelated assignment such as `stat=unknown` is not a
624
+ # process-state read, and accepting it was the fourth way this waiver was found to
625
+ # clear a real hit.
626
+ if printf '%s' "$liveness_win" | grep -qE '\-o[[:space:]]*stat='; then
627
+ continue
628
+ fi
629
+ # Otherwise the window may CALL a state helper — but only one this file actually
630
+ # defines and which itself consults process state. Accepting any `*_state` token
631
+ # made an unrelated `record_state "$pid"` read as remediation and skipped a real
632
+ # hit, which is a hole rather than a stated recall limit.
633
+ # The file must also actually consult process state somewhere, so a file with no
634
+ # `stat=` at all cannot be cleared by a bookkeeping helper that merely ends in
635
+ # `_state`. Recall limit: a file that separately defines such a helper AND reads
636
+ # process state elsewhere clears the window without proving the two are connected —
637
+ # tying a call to its definition needs a shell parser, the same call made above.
638
+ # Deliberately POSIX-portable: `\b` and BSD-sed `\?` are not, and a silently failing
639
+ # match here would loosen the gate on exactly the host that runs it most.
640
+ # The named helper's OWN BODY must reach a process-state read. Checking "the file
641
+ # defines it" and "the file reads state somewhere" as two independent facts let a
642
+ # hollow helper plus an unrelated `ps -o stat=` elsewhere clear a real hit — the two
643
+ # conditions were never tied to each other. awk rather than sed: BSD sed lacks `\?`
644
+ # and a silently failing match here loosens the gate on the host that runs it most.
645
+ liveness_helper_ok=0
646
+ while IFS= read -r liveness_helper; do
647
+ [[ -n "$liveness_helper" ]] || continue
648
+ if awk -v fn="$liveness_helper" '
649
+ $0 ~ "^[[:space:]]*(function[[:space:]]+)?" fn "[[:space:]]*\\(\\)" {
650
+ # The declaration line is part of the body: a one-line definition both opens
651
+ # and closes here. Skipping it with a bare `next` left `inbody` set through
652
+ # `fn() { echo live; }` and credited the NEXT function\047s state read to this
653
+ # hollow one.
654
+ if ($0 ~ /-o[[:space:]]*stat=/) { found = 1; exit }
655
+ if ($0 ~ /}/) { exit }
656
+ inbody = 1
657
+ next
658
+ }
659
+ inbody && /-o[[:space:]]*stat=/ { found = 1; exit }
660
+ inbody && /^[[:space:]]*}/ { exit }
661
+ END { exit !found }
662
+ ' "$liveness_file" 2>/dev/null; then
663
+ liveness_helper_ok=1
664
+ break
665
+ fi
666
+ done < <(printf '%s\n' "$liveness_win" | grep -oE '[A-Za-z_][A-Za-z0-9_]*_state' | sort -u)
667
+ [[ "$liveness_helper_ok" -eq 1 ]] && continue
668
+ liveness_hits+="${liveness_file#"$root"/}:$liveness_line"$'\n'
669
+ done <<< "$liveness_out"
670
+ done < "$liveness_list"
671
+ rm -f "$liveness_list"
672
+ if [[ -n "${liveness_hits//[$'\n']/}" ]]; then
673
+ # Trailing newline restored explicitly: command substitution strips it, which ran the
674
+ # last hit and the diagnosis together on one line.
675
+ printf '%s\n' "$(printf '%s' "$liveness_hits" | sort)"
676
+ echo "liveness_predicate_scan_failed: the lines above decide that a process is a LIVE orphan from a ppid read alone, which an unreaped corpse answers the same way — the check says alive while the suite's own verdict scans exclude zombies and say gone. Consult process STATE (\`ps -o stat=\`, or a helper that returns live/zombie/absent) before concluding liveness. See skill-extraction-workflow/references/recurring-anti-patterns-checklist.md (Anti-pattern 28)." >&2
677
+ exit 1
678
+ fi
679
+ echo "liveness_predicate_scan_ok"
680
+
517
681
  # Evidence-card leak PREFLIGHT (best-effort, NOT the gate of record): catches obvious
518
682
  # card-shaped markdown accidentally committed under skills/** (L0 storage rule, see
519
683
  # l0-l1-l2-routing.md). Delegated to a dedicated, self-tested detector. The gate runs the
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # F4 health roll-up — advisory composite score (0-10) + trend over time.
4
+ # F4 signal dashboard — advisory display roll-up (0-10) + same-stick change.
5
5
  #
6
- # Rolls the F4 signals up into ONE weighted 0-10 number plus a trend, so the
7
- # question "is the skill repo trending healthier or worse?" has an answer. This
8
- # is the pattern OpenSSF Scorecard uses for repos (per-check 0-10 -> risk-weighted
9
- # aggregate 0-10, tracked over time); gstack-health uses the same shape for code
10
- # repos. We adapt it to F4 skill-eval signals — we do NOT score code tools.
6
+ # Shows the F4 dimensions together and retains a weighted 0-10 display value plus
7
+ # a same-corpus/dimensions delta for compatibility with existing reports. The
8
+ # shape resembles OpenSSF Scorecard, but the value is not a verdict that the
9
+ # skill repository is globally better or worse: these dimensions have different
10
+ # semantics, and a quality or safety failure is never averaged away.
11
11
  #
12
12
  # Dimensions (each scored 0-10, risk-weighted OpenSSF-style):
13
13
  # structural weight 10 (Critical) — validate-skill.sh: skills load / no leakage
@@ -29,7 +29,8 @@
29
29
  # (structural validation + Tier-1 blocking findings) remain the source of truth
30
30
  # and block independently of this score. Per Goodhart's law ("when a measure
31
31
  # becomes a target, it ceases to be a good measure"), a composite that became a
32
- # merge gate would just get gamed; it stays a lens, not a gate. The history file
32
+ # merge gate would just get gamed; it stays a navigation lens, not a gate or an
33
+ # acceptance grade. The history file
33
34
  # is git-ignored for the same reason — a committed number invites tuning the
34
35
  # number instead of the repo.
35
36
  #
@@ -289,7 +290,14 @@ unless no_write
289
290
  end
290
291
 
291
292
  report = entry.merge(
293
+ # `band` keeps its name and values for existing report consumers, but the
294
+ # machine surface must not be the one place the withdrawal does not reach: a
295
+ # consumer reads this JSON, not the header line, and CLEAN / WARNING / NEEDS
296
+ # WORK / CRITICAL is grade vocabulary that gets quoted as an acceptance verdict.
297
+ # The qualifier therefore travels as its own field rather than only in `puts`.
292
298
  "band" => band(composite),
299
+ "band_semantics" => "navigation-only display label; not an acceptance grade or an overall-quality verdict",
300
+ "score_semantics" => "weighted display roll-up over the dims present in this run; comparable only against the same corpus and dims",
293
301
  "weights_present" => present.to_h { |k, _| [k.to_s, RISK[k]] },
294
302
  "details" => dims.to_h { |k, d| [k.to_s, d[:detail]] },
295
303
  "trend" => trend,
@@ -306,13 +314,18 @@ if out_json
306
314
  end
307
315
 
308
316
  unless quiet
309
- puts "F4 HEALTH ROLL-UP (advisory — not a gate)"
317
+ puts "F4 SIGNAL DASHBOARD (advisory — not a gate or overall-quality verdict)"
310
318
  puts " repo=#{repo_sha} corpus=#{corpus} dims=#{present_dims.join(',')}"
311
319
  dims.each do |k, d|
312
320
  s = d[:score].nil? ? " - " : format("%2d/10", d[:score])
313
321
  puts " #{k.to_s.ljust(15)} #{s} #{d[:detail]}"
314
322
  end
315
- puts " COMPOSITE #{format('%.1f', composite)}/10 #{band(composite)}"
323
+ # The band label carries grade vocabulary (CLEAN / WARNING / NEEDS WORK /
324
+ # CRITICAL) that reads as an acceptance verdict and gets quoted downstream as
325
+ # one. The header withdrew that claim; printing the bare label next to the
326
+ # number would have left the executable surface contradicting the withdrawal,
327
+ # so the qualifier travels with the label rather than only with the header.
328
+ puts " DISPLAY ROLL-UP #{format('%.1f', composite)}/10 #{band(composite)} (navigation only, not an acceptance grade)"
316
329
  if prior_comparable
317
330
  arrow = trend.positive? ? "IMPROVING +#{trend}" : (trend.negative? ? "DECLINING #{trend}" : "FLAT")
318
331
  puts " trend vs #{prior_comparable['repo_sha']} (same corpus+dims): #{arrow}"
@@ -321,7 +334,7 @@ unless quiet
321
334
  else
322
335
  puts " trend: first run — no history yet"
323
336
  end
324
- puts " (binary gates still block independently of this number; history=#{history}, git-ignored)"
337
+ puts " (inspect dimensions and task evidence; gates block independently; history=#{history}, git-ignored)"
325
338
  end
326
339
 
327
340
  exit 0
@@ -299,7 +299,11 @@ end
299
299
  # changed owner at all, so the entire row evaluation was skipped and the
300
300
  # surviving row was never even looked at. With no ledger change there are no
301
301
  # added rows, so the skip stays exactly as cheap as before for unrelated diffs.
302
- if upstream.any? || changed_paths.include?(LEDGER_PATH)
302
+ # The routing task bank joins the entry condition (045): a commit that moves only
303
+ # `eval/routing-tasks.jsonl` changes no owner package and need not touch the
304
+ # ledger, so without this the whole block — including the bank-evidence
305
+ # obligation written for exactly that change — was never entered.
306
+ if upstream.any? || changed_paths.include?(LEDGER_PATH) || changed_paths.include?("eval/routing-tasks.jsonl")
303
307
  # Rows are collected PER ROUND and carry the scope they were authored against,
304
308
  # so the classifiers below judge a row against its own round's diff. Reading the
305
309
  # cumulative register diff instead would re-judge every earlier round's rows
@@ -1338,4 +1342,302 @@ if upstream.any? || changed_paths.include?(LEDGER_PATH)
1338
1342
  behavior_failures.each { |path| warn " incomplete: #{path}" }
1339
1343
  exit 1
1340
1344
  end
1345
+
1346
+ # ---- 045: an obligation whose skip leaves no artifact is not enforced ------
1347
+ # Round 044 withdrew several overclaims and, in the same move, demoted the
1348
+ # obligations riding on them into advisories. Five consecutive challenge rounds
1349
+ # returned one shape: the entry condition is stated by the party the obligation
1350
+ # constrains, and SKIPPING IT PRODUCES NOTHING for a reviewer to notice missing.
1351
+ # A reviewer cannot refuse an absent claim.
1352
+ #
1353
+ # Both checks below key on facts of the DIFF — which file changed, which key a
1354
+ # row carries — never on prose semantics. That is deliberate and is the whole
1355
+ # difference from the proxy predicates this repository has repeatedly watched
1356
+ # get walked through: a proxy asks "does this text look like it did the thing",
1357
+ # an invariant asks "is the artifact there". Skipping either obligation now
1358
+ # leaves a detectable absence, which is the half the demotion removed.
1359
+ #
1360
+ # Scope limit, stated here so a later round does not read more into it: these
1361
+ # close OMISSION, not MISCLASSIFICATION. `result-class` is still chosen by the
1362
+ # author, and a bank-evidence locator is not proof the bank actually ran. What
1363
+ # changes is that a silent skip becomes a visible claim the review can accept
1364
+ # or refuse — which is exactly what round 044's reviewer said was missing.
1365
+ fragment_value = lambda do |text, key|
1366
+ text.to_s.split(";").each do |fragment|
1367
+ m = fragment.match(/\A\s*#{Regexp.escape(key)}:\s*(.+?)\s*\z/i)
1368
+ return m[1] if m
1369
+ end
1370
+ nil
1371
+ end
1372
+ declaring_row = lambda do |row|
1373
+ row[:behavior].to_s.split(";").any? { |f| f.match?(/\A\s*behavioral-evidence:/i) }
1374
+ end
1375
+
1376
+ result_classes = %w[failure stable-success insufficient-evidence]
1377
+ # A round is held only to the grammar ITS OWN HEAD declares. Adding a required
1378
+ # field would otherwise refuse every historical round the moment anyone replays
1379
+ # it — which the verdict-differential suite correctly reports as a regression,
1380
+ # and which would make the row grammar permanently unextendable. This is not a
1381
+ # date or commit-id grandfather clause (those are proxies that drift): the
1382
+ # predicate is whether the ledger at that round's head defines the field, so a
1383
+ # round that was never told cannot be refused for not knowing.
1384
+ grammar_marker = "result-class: failure|stable-success|insufficient-evidence"
1385
+ grammar_declared_cache = {}
1386
+ grammar_declared_at = lambda do |ref|
1387
+ grammar_declared_cache[ref] ||= begin
1388
+ bytes = raw_blob_at.call(ref, LEDGER_PATH)
1389
+ next false unless bytes
1390
+ # The DECLARATION is prose, and only prose declares. Scanning the whole blob
1391
+ # let a round delete the declaring paragraph while dropping the same literal
1392
+ # into an ordinary ledger ROW: both sides then read as declared, the
1393
+ # withdrawal check stays empty, and every later round keeps being judged by
1394
+ # a grammar the authority no longer states. Rows quote and describe; they do
1395
+ # not legislate, so a marker sitting inside one is not a declaration.
1396
+ bytes.dup.force_encoding(Encoding::UTF_8).each_line.any? do |line|
1397
+ !line.lstrip.start_with?("|") && line.include?(grammar_marker)
1398
+ end
1399
+ end
1400
+ end
1401
+ # Scoped to rows that resolve to an owner THIS round changed — the same subject
1402
+ # set every other per-row check uses. Walking `rows` instead swept in every
1403
+ # historical ledger line whenever the round's base did not already contain it
1404
+ # (a shallow fixture base, a branch cut far back), turning an append-only ledger
1405
+ # into something that fails on its own past. The obligation is on the round
1406
+ # being judged, not on the file it appends to.
1407
+ # Declared -> undeclared is not a legitimate history, it is the off switch.
1408
+ # Without this, deleting or rewording the paragraph makes both checks skip and
1409
+ # the round is indistinguishable from one that predates them — a silent
1410
+ # disarm wearing the shape of the very non-retroactivity rule that permits it.
1411
+ # Withdrawing the grammar deliberately is still possible; it just cannot be
1412
+ # done quietly, which is the whole subject of this round.
1413
+ grammar_withdrawn = round_bounds.select do |span_base, span_head|
1414
+ grammar_declared_at.call(span_base) && !grammar_declared_at.call(span_head)
1415
+ end
1416
+ unless grammar_withdrawn.empty?
1417
+ warn "impact_chain_grammar_withdrawn: a round removed the ledger's declaration-fragment paragraph, which would disable `result-class` and `bank-evidence` for every later round"
1418
+ warn " note: the checks are gated on the head-declared grammar so history is not judged by rules it was never told. That gate reads an ABSENT declaration as `before the rule`; a declaration that was present and is now gone is a withdrawal, and it has to be argued rather than committed"
1419
+ # Name only a remediation this gate actually implements. An earlier version of
1420
+ # this line offered "land the withdrawal with a named risk owner", and no code
1421
+ # here parses any such artifact — the refusal was unconditional, so the
1422
+ # message promised a way through that did not exist and the round had none.
1423
+ # The escape is deliberately NOT built: a data-side waiver token for a
1424
+ # mechanical obligation is the self-adjudication surface this round removes.
1425
+ # Withdrawing the obligation means removing the triggers, which is a reviewable
1426
+ # code diff rather than a line someone writes about themselves.
1427
+ warn " fix: restore the `result-class: failure|stable-success|insufficient-evidence` definition in #{LEDGER_PATH}. There is no data-side waiver: to retire the obligation, remove the checks in this file in their own reviewed change"
1428
+ grammar_withdrawn.each { |base, head| warn " round: #{base}..#{head}" }
1429
+ exit 1
1430
+ end
1431
+
1432
+ # EVERY declaring row of a round whose head declares the grammar, not only rows
1433
+ # that resolve to a changed owner. Scoping by owner was the first attempt at
1434
+ # non-retroactivity, and it left its own hole: a round that changes only the
1435
+ # routing bank has no changed owner, so a declaring row with no `result-class`
1436
+ # was never examined at all. Non-retroactivity is the grammar gate's job and it
1437
+ # does that job alone; making the owner set do it too bought nothing and cost a
1438
+ # false green. A row that declares an impact chain owes its class, whatever the
1439
+ # round happened to touch.
1440
+ result_class_failures = rows.select do |row|
1441
+ next false unless declaring_row.call(row)
1442
+ next false unless grammar_declared_at.call(row[:scope].head)
1443
+ value = fragment_value.call(row[:behavior], "result-class")
1444
+ !(value && result_classes.include?(value.downcase))
1445
+ end.uniq { |row| row[:line] }
1446
+ unless result_class_failures.empty?
1447
+ warn "impact_chain_result_class_missing: an added row declaring behavioral-evidence carries no usable `result-class`"
1448
+ warn " note: omitting the cell was the cheapest of the three bypasses review found — static checks pass, nothing emits `interim`, and the reviewer has no field to accept or refuse. The value stays the author's call; its ABSENCE no longer does"
1449
+ warn " fix: add `result-class: #{result_classes.join('|')}` to the row's declaration fragments"
1450
+ result_class_failures.each { |row| warn " row: #{row[:line].to_s.sub(/^\+/, '').strip}" }
1451
+ exit 1
1452
+ end
1453
+
1454
+ # Trigger A: a change to the routing surface owes bank evidence.
1455
+ bank_relative = "eval/routing-tasks.jsonl"
1456
+ description_entry_at = lambda do |ref, relative|
1457
+ bytes = raw_blob_at.call(ref, relative)
1458
+ next nil if bytes.nil?
1459
+ parts = split_frontmatter.call(bytes.dup.force_encoding(Encoding::UTF_8))
1460
+ next nil unless parts
1461
+ entry = split_description_entry.call(parts[0])
1462
+ entry && entry[1]
1463
+ end
1464
+ # `upstream` keys are owner-relative (`<owner>/SKILL.md`) while changed_paths and
1465
+ # blob lookups are repo-relative; the same `skills/` prefix the evidence-file
1466
+ # check applies. Without it the description branch silently never fired and the
1467
+ # two cases that DO carry evidence passed for the wrong reason — a false green
1468
+ # that only the paired no-evidence cases could expose.
1469
+ # Owner-scoped ONLY. An earlier version also returned true here whenever the bank
1470
+ # had changed, on the reasoning that the bank is everyone's routing surface. In a
1471
+ # multi-round range that conflated two different subjects: the round that moved
1472
+ # the bank then demanded an owner-specific row from every owner the RANGE had
1473
+ # touched, including owners changed in some other round for unrelated reasons —
1474
+ # so adding a later, unrelated commit changed the verdict on an earlier one. The
1475
+ # bank is its own subject and is checked as one; this predicate answers only
1476
+ # "did THIS owner move ITS OWN description in THIS round".
1477
+ description_touched = lambda do |scope, path|
1478
+ relative = "skills/#{path}"
1479
+ next false unless scope.changed_paths.include?(relative)
1480
+ base_entry = description_entry_at.call(scope.base, relative)
1481
+ head_entry = description_entry_at.call(scope.head, relative)
1482
+ # "No head entry" is two different things and only one of them is exempt.
1483
+ # The FILE being gone (deleted, renamed away) is exempt: its description did
1484
+ # not move, the file did, and the rename machinery owns that case. The file
1485
+ # still being there with its description entry REMOVED is the opposite — the
1486
+ # skill stops advertising what reaches it, which is a routing-surface change
1487
+ # as much as rewording one. Collapsing both into `head_entry.nil?` let a
1488
+ # description be deleted in place with no evidence and no refusal.
1489
+ if head_entry.nil?
1490
+ next false if raw_blob_at.call(scope.head, relative).nil?
1491
+ next !base_entry.nil?
1492
+ end
1493
+ # A path with a head entry and NO base entry is a routing surface being
1494
+ # CREATED — a first description decides which requests reach a new skill, so
1495
+ # it is the largest routing-surface change there is, not an exemption. An
1496
+ # earlier version skipped it as "conservative"; review named that as the hole
1497
+ # it is. The one creation that is not a new routing surface is a rename's
1498
+ # target, where the same description arrives under a new path — git proves
1499
+ # that pairing, so it is excluded on evidence rather than on shape.
1500
+ if base_entry.nil?
1501
+ pair = scope.rename_pairs.find { |_from, to| "#{to}/SKILL.md" == path }
1502
+ if pair
1503
+ # A rename is excluded because the SAME description arrived under a new
1504
+ # path. Excluding the target without checking that made rename+reword in
1505
+ # one commit a free routing change: the target is exempt as "just a
1506
+ # rename" and the source is exempt for having no head entry, so the edit
1507
+ # rides in between them. Compare across the pair — the exemption is for
1508
+ # a description that did not change, not for a path that moved.
1509
+ from_entry = description_entry_at.call(scope.base, "skills/#{pair[0]}/SKILL.md")
1510
+ next true if from_entry.nil?
1511
+ next from_entry != head_entry
1512
+ end
1513
+ next true
1514
+ end
1515
+ base_entry != head_entry
1516
+ end
1517
+ # A downscope is allowed — skipping the measurement is sometimes right — but it
1518
+ # must land somewhere a reader reaches. Requiring the token in a changed spec
1519
+ # keeps `downscoped:` from becoming the next self-adjudicated exit: the author
1520
+ # still decides, and still has to write the decision down where review sees it.
1521
+ downscope_recorded = lambda do |scope, token|
1522
+ # The token has to be a TOKEN. `\S+` accepted `-`, and a substring search then
1523
+ # found it in the bullet punctuation of any changed spec line — so
1524
+ # `downscoped:-` discharged the obligation against a record that says nothing.
1525
+ # A downscope is a decision someone has to be able to look up; a character
1526
+ # that occurs incidentally in prose cannot be that. Eight or more, starting
1527
+ # alphanumeric, and no whitespace: long enough not to collide by accident,
1528
+ # loose enough not to dictate a naming scheme.
1529
+ next false unless token.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{7,}\z/)
1530
+ # The spec must record the DECLARATION, verbatim — `downscoped:<token>` — not
1531
+ # merely contain the token somewhere. Searching for the bare token let a
1532
+ # common word discharge the obligation against a line that happens to use it
1533
+ # ("Bank evidence anchor …" satisfied `downscoped:evidence`), which is the
1534
+ # silent self-adjudication this gate exists to close, rebuilt out of substring
1535
+ # matching. Requiring the full spelling makes the recorded line say what it is.
1536
+ # Bounded, not substring. `include?("downscoped:TOK")` also matched a spec that
1537
+ # records `downscoped:TOKLONGER` — a strict prefix of somebody else's token
1538
+ # discharging this row — and matched `not-downscoped:TOK` too, which records
1539
+ # the opposite. The declaration has to end where the row's token ends and to
1540
+ # start at the keyword.
1541
+ needle = /(?<![A-Za-z0-9._-])downscoped:#{Regexp.escape(token)}(?![A-Za-z0-9._-])/
1542
+ scope.changed_paths.any? do |relative|
1543
+ next false unless relative.start_with?("specs/") && relative.end_with?(".md")
1544
+ added_lines_for.call(scope, relative).any? { |line| line.match?(needle) }
1545
+ end
1546
+ end
1547
+
1548
+ # Evaluated PER ROUND, not per owner. Two defects independent review found in
1549
+ # the per-owner shape, both of which let the obligation evaporate:
1550
+ # - a diff that changes ONLY `eval/routing-tasks.jsonl` has no changed owner,
1551
+ # so the per-owner loop was never entered and the routing bank could move
1552
+ # with no evidence at all. The first fixture missed it by also touching an
1553
+ # owner, which is how a hole hides behind a green case.
1554
+ # - `triggered` and `satisfied` each scanned every row of the owner across ALL
1555
+ # rounds, so one round's valid locator satisfied a different round's naked
1556
+ # routing-surface change. The obligation belongs to the round that incurred
1557
+ # it; the row that discharges it has to sit in the same round.
1558
+ bank_evidence_failures = []
1559
+ # Iterating the ROUNDS, not the rows: grouping by row scope meant a round that
1560
+ # moved the bank and appended no ledger row had no scope to inspect, so the one
1561
+ # change that needs no owner and no row escaped entirely. The first fixture for
1562
+ # this hid it by also appending a row — twice now a green case has stood in
1563
+ # front of the hole it was written to cover.
1564
+ round_bounds.each do |span_base, span_head|
1565
+ scope = scope_at.call(span_base, span_head)
1566
+ scope_rows = rows.select { |row| row[:scope].head == span_head }
1567
+ # Same head-declared-grammar rule as `result-class` above, and for the same
1568
+ # reason: the paragraph that defines `bank-evidence` also dates it.
1569
+ next unless grammar_declared_at.call(scope.head)
1570
+ bank_changed = scope.changed_paths.include?(bank_relative)
1571
+ triggered_owners = (upstream + lineage_extra.keys).uniq.select do |path|
1572
+ description_touched.call(scope, path)
1573
+ end
1574
+ # A package that did not exist at the round's base is not in the owner set —
1575
+ # the set is built from CHANGED owners, and a brand-new skill has nothing to
1576
+ # have changed. Its first description is still a routing surface being
1577
+ # created, so it is picked up from the changed paths directly rather than
1578
+ # being silently outside the trigger because of how the owner set is built.
1579
+ created_surfaces = scope.changed_paths.filter_map do |relative|
1580
+ next unless relative.start_with?("skills/") && relative.end_with?("/SKILL.md")
1581
+ path = relative.sub(%r{\Askills/}, "")
1582
+ next if triggered_owners.include?(path)
1583
+ next unless description_touched.call(scope, path)
1584
+ path
1585
+ end
1586
+ triggered_owners |= created_surfaces
1587
+ next if !bank_changed && triggered_owners.empty?
1588
+ # Discharged PER SUBJECT, not per round. Scanning the whole round meant that
1589
+ # when two owners changed their descriptions, one owner's valid locator
1590
+ # marked the round satisfied and the other could omit its evidence entirely —
1591
+ # the obligation is incurred by each owner that moved its routing surface, so
1592
+ # each has to carry its own.
1593
+ row_satisfies = lambda do |row, self_owner|
1594
+ value = fragment_value.call(row[:behavior], "bank-evidence")
1595
+ next false unless value
1596
+ if (m = value.match(/\Adownscoped:(\S+)\z/i))
1597
+ downscope_recorded.call(scope, m[1])
1598
+ else
1599
+ # A locator pointing back into the owner's own package is the change, not
1600
+ # evidence about it. Without this a description edit satisfies its own
1601
+ # bank obligation by citing the very line it just wrote — an artifact that
1602
+ # exists and proves nothing, which is the shape this whole round removes.
1603
+ # Checked on the PATH after stripping the kind prefix, not on the `file:`
1604
+ # spelling: the first version matched `file:skills/<owner>/` only, so an
1605
+ # owner that also touched a script in its own package could cite it as
1606
+ # `command:skills/<owner>/scripts/…` and validate the change as evidence
1607
+ # about itself. Both locator forms name a path; the ban is about the path.
1608
+ if self_owner
1609
+ located = value.sub(/\A(?:command|file):/i, "").sub(/#.*\z/m, "")
1610
+ # Normalize before the prefix test. A raw `start_with?` compares one
1611
+ # spelling of a path, and `./skills/<owner>/…` or a `a/../` detour names
1612
+ # the same file while missing the check — the ban is on the LOCATION, so
1613
+ # it has to be decided on a canonical form of it.
1614
+ segments = []
1615
+ located.split("/").each do |seg|
1616
+ next if seg.empty? || seg == "."
1617
+ seg == ".." ? segments.pop : segments << seg
1618
+ end
1619
+ next false if segments.take(2) == ["skills", self_owner]
1620
+ end
1621
+ locator_valid.call(scope, value)
1622
+ end
1623
+ end
1624
+ triggered_owners.each do |path|
1625
+ owner = path.sub(%r{/SKILL\.md\z}, "")
1626
+ owner_scope_rows = rows_by_upstream_path[path].select { |row| row[:scope].head == scope.head }
1627
+ bank_evidence_failures << path unless owner_scope_rows.any? { |row| row_satisfies.call(row, owner) }
1628
+ end
1629
+ if bank_changed
1630
+ # The bank is nobody's package, so any row of this round may discharge it;
1631
+ # what it may not be is absent.
1632
+ bank_evidence_failures << bank_relative unless scope_rows.any? { |row| row_satisfies.call(row, nil) }
1633
+ end
1634
+ end
1635
+ bank_evidence_failures.uniq!
1636
+ unless bank_evidence_failures.empty?
1637
+ warn "impact_chain_bank_evidence_missing: a round that changed the routing surface carries no bank evidence for that owner"
1638
+ warn " note: the routing surface is the SKILL.md frontmatter `description` entry and `#{bank_relative}`. The measurement protocol says it is mandatory, but nothing produced or consumed it, so not running it left no absence to detect — the reviewer saw a candidate, not a gap"
1639
+ warn " fix: add `bank-evidence: command:<changed executable>` or `bank-evidence: file:<changed markdown>#<unique anchor>` to that owner's row; to skip the run deliberately use `bank-evidence: downscoped:<token>` and record the same token in this round's spec, so the downscope is itself an artifact"
1640
+ bank_evidence_failures.each { |path| warn " owes bank evidence: #{path}" }
1641
+ exit 1
1642
+ end
1341
1643
  end