@intentsolutionsio/contributing-clanker 0.1.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 (69) hide show
  1. package/README.md +173 -0
  2. package/hooks/.gitkeep +0 -0
  3. package/hooks/install.sh +115 -0
  4. package/hooks/uninstall.sh +70 -0
  5. package/package.json +42 -0
  6. package/skills/contribute/SKILL.md +457 -0
  7. package/skills/contribute/agents/draft-writer.md +110 -0
  8. package/skills/contribute/agents/repo-analyzer.md +68 -0
  9. package/skills/contribute/agents/researcher.md +246 -0
  10. package/skills/contribute/agents/scout.md +182 -0
  11. package/skills/contribute/agents/test-runner.md +70 -0
  12. package/skills/contribute/assets/claim-template.md +22 -0
  13. package/skills/contribute/assets/evidence-template.md +32 -0
  14. package/skills/contribute/assets/pr-template.md +49 -0
  15. package/skills/contribute/references/candidate-file-format.md +259 -0
  16. package/skills/contribute/references/workflow-guide.md +153 -0
  17. package/skills/contribute/scripts/audit-overrides.sh +180 -0
  18. package/skills/contribute/scripts/catalog-coverage.sh +99 -0
  19. package/skills/contribute/scripts/gate-runner.sh +191 -0
  20. package/skills/contribute/scripts/gates/a01-already-assigned.sh +24 -0
  21. package/skills/contribute/scripts/gates/a02-already-shipped.sh +31 -0
  22. package/skills/contribute/scripts/gates/a03-duplicate-flagged.sh +22 -0
  23. package/skills/contribute/scripts/gates/a04-issue-age.sh +80 -0
  24. package/skills/contribute/scripts/gates/a05-issue-still-open.sh +33 -0
  25. package/skills/contribute/scripts/gates/a06-claim-etiquette-required.sh +36 -0
  26. package/skills/contribute/scripts/gates/a09-mention-routing.sh +63 -0
  27. package/skills/contribute/scripts/gates/b01-base-branch.sh +29 -0
  28. package/skills/contribute/scripts/gates/b02-branch-naming.sh +34 -0
  29. package/skills/contribute/scripts/gates/b03-clone-fresh.sh +33 -0
  30. package/skills/contribute/scripts/gates/b05-dco-signoff.sh +50 -0
  31. package/skills/contribute/scripts/gates/b06-commit-format.sh +44 -0
  32. package/skills/contribute/scripts/gates/b07-scope-files.sh +68 -0
  33. package/skills/contribute/scripts/gates/b12-new-deps.sh +40 -0
  34. package/skills/contribute/scripts/gates/b14-local-checks.sh +55 -0
  35. package/skills/contribute/scripts/gates/b16-local-check-allowlist.sh +32 -0
  36. package/skills/contribute/scripts/gates/c01-draft-first.sh +23 -0
  37. package/skills/contribute/scripts/gates/c02-pr-title-format.sh +36 -0
  38. package/skills/contribute/scripts/gates/c03-pr-body-sections.sh +58 -0
  39. package/skills/contribute/scripts/gates/c04-ui-screenshots.sh +38 -0
  40. package/skills/contribute/scripts/gates/c05-test-evidence.sh +31 -0
  41. package/skills/contribute/scripts/gates/c07-coauthor-banned.sh +30 -0
  42. package/skills/contribute/scripts/gates/c09-issue-link.sh +31 -0
  43. package/skills/contribute/scripts/gates/c11-no-force-push.sh +32 -0
  44. package/skills/contribute/scripts/gates/c12-ci-green.sh +29 -0
  45. package/skills/contribute/scripts/gates/c13-bots-passed.sh +62 -0
  46. package/skills/contribute/scripts/gates/c16-no-self-merge.sh +24 -0
  47. package/skills/contribute/scripts/gates/c19-body-claim-vs-diff.sh +64 -0
  48. package/skills/contribute/scripts/gates/d02-no-ai-bug-reports.sh +48 -0
  49. package/skills/contribute/scripts/gates/d03-no-ai-pr-reviews.sh +42 -0
  50. package/skills/contribute/scripts/gates/d05-no-reopen.sh +25 -0
  51. package/skills/contribute/scripts/gates/e02-ai-strike-track.sh +57 -0
  52. package/skills/contribute/scripts/gates/e04-fork-target.sh +39 -0
  53. package/skills/contribute/scripts/gates/f01-license-compat.sh +92 -0
  54. package/skills/contribute/scripts/gates/f03-fixtures-clean.sh +30 -0
  55. package/skills/contribute/scripts/gates/f04-override-disclosure.sh +49 -0
  56. package/skills/contribute/scripts/gates/g01-no-vendored-edits.sh +52 -0
  57. package/skills/contribute/scripts/gates/g02-protected-paths.sh +30 -0
  58. package/skills/contribute/scripts/gates/g03-no-changelog-edits.sh +37 -0
  59. package/skills/contribute/scripts/gates/g04-no-version-bump.sh +36 -0
  60. package/skills/contribute/scripts/gates/g06-override-rate-limit.sh +31 -0
  61. package/skills/contribute/scripts/gates/lib/preamble.sh +105 -0
  62. package/skills/contribute/scripts/lint-candidate.sh +149 -0
  63. package/skills/contribute/scripts/researcher-build.sh +456 -0
  64. package/skills/contribute/scripts/test-known-traps.sh +142 -0
  65. package/skills/contribute/scripts/test-override-audit.sh +102 -0
  66. package/skills/contribute/scripts/test-plug-in.sh +113 -0
  67. package/skills/contribute/scripts/test-scout-refresh.sh +157 -0
  68. package/skills/contribute/scripts/test-stale-dossier-refresh.sh +96 -0
  69. package/skills/contribute/scripts/transition.sh +260 -0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: G3 — manual CHANGELOG edits in a repo that auto-generates the changelog
3
+ # Mitigates: clobbers release tooling, gets reverted on next release, looks careless.
4
+ source "$(dirname "$0")/lib/preamble.sh"
5
+
6
+ gate_read_input
7
+
8
+ if [[ -z "$GATE_DOSSIER_PATH" || ! -f "$GATE_DOSSIER_PATH" ]]; then
9
+ gate_skip "no dossier — cannot determine auto_changelog policy"
10
+ fi
11
+
12
+ AUTO=$(fm_field "$GATE_DOSSIER_PATH" "auto_changelog")
13
+ if [[ "$AUTO" != "true" ]]; then
14
+ gate_skip "dossier auto_changelog is not true"
15
+ fi
16
+
17
+ REPO_NAME="${GATE_REPO##*/}"
18
+ CLONE="$HOME/000-projects/contributing-clanker/$REPO_NAME"
19
+
20
+ if [[ ! -d "$CLONE/.git" ]]; then
21
+ gate_skip "no local clone at $CLONE"
22
+ fi
23
+
24
+ DEFAULT_BRANCH=$(fm_field "$GATE_DOSSIER_PATH" "default_branch")
25
+ [[ -z "$DEFAULT_BRANCH" ]] && DEFAULT_BRANCH="main"
26
+
27
+ CHANGED=$(/usr/bin/git -C "$CLONE" diff "$DEFAULT_BRANCH..HEAD" --name-only 2>/dev/null || /usr/bin/echo "")
28
+
29
+ # Match CHANGELOG.md at root or in any subdir (case-sensitive)
30
+ HITS=$(/usr/bin/printf '%s\n' "$CHANGED" | /usr/bin/grep -E '(^|/)CHANGELOG\.md$' || /usr/bin/echo "")
31
+
32
+ if [[ -z "$HITS" ]]; then
33
+ gate_pass "no CHANGELOG.md edits in diff"
34
+ fi
35
+
36
+ LIST=$(/usr/bin/printf '%s' "$HITS" | /usr/bin/tr '\n' ',' | /usr/bin/sed 's/,$//')
37
+ gate_warn "diff edits CHANGELOG.md ($LIST) but repo auto-generates changelog" "this repo auto-generates CHANGELOG; let the release tooling write it"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: G4 — manual version bump in a repo that uses semantic-release / changesets
3
+ # Mitigates: clobbers release tooling, breaks semver, signals unfamiliarity with workflow.
4
+ source "$(dirname "$0")/lib/preamble.sh"
5
+
6
+ gate_read_input
7
+
8
+ if [[ -z "$GATE_DOSSIER_PATH" || ! -f "$GATE_DOSSIER_PATH" ]]; then
9
+ gate_skip "no dossier — cannot determine auto_version policy"
10
+ fi
11
+
12
+ AUTO=$(fm_field "$GATE_DOSSIER_PATH" "auto_version")
13
+ if [[ "$AUTO" != "true" ]]; then
14
+ gate_skip "dossier auto_version is not true"
15
+ fi
16
+
17
+ REPO_NAME="${GATE_REPO##*/}"
18
+ CLONE="$HOME/000-projects/contributing-clanker/$REPO_NAME"
19
+
20
+ if [[ ! -d "$CLONE/.git" ]]; then
21
+ gate_skip "no local clone at $CLONE"
22
+ fi
23
+
24
+ DEFAULT_BRANCH=$(fm_field "$GATE_DOSSIER_PATH" "default_branch")
25
+ [[ -z "$DEFAULT_BRANCH" ]] && DEFAULT_BRANCH="main"
26
+
27
+ DIFF=$(/usr/bin/git -C "$CLONE" diff "$DEFAULT_BRANCH..HEAD" -- package.json Cargo.toml pyproject.toml 2>/dev/null || /usr/bin/echo "")
28
+
29
+ # Look for added lines that look like version field updates
30
+ HITS=$(/usr/bin/printf '%s\n' "$DIFF" | /usr/bin/grep -E '^\+[[:space:]]*("version"[[:space:]]*:|version[[:space:]]*=)' || /usr/bin/echo "")
31
+
32
+ if [[ -z "$HITS" ]]; then
33
+ gate_pass "no manual version field changes detected"
34
+ fi
35
+
36
+ gate_warn "diff appears to bump a version field (package.json / Cargo.toml / pyproject.toml)" "this repo uses semantic-release / changesets; don't bump versions manually"
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: G6 — Override rate limit (anti-habituation)
3
+ # Mitigates: Round-1 security GAP-2 — under "just ship it" pressure, an agent
4
+ # learns to invoke overrides reflexively. Without a rate limit, the override
5
+ # mechanism becomes a slop escape valve. ≥3 overrides at one repo in 30d =
6
+ # the system is telling you something; pause and reflect.
7
+ source "$(dirname "$0")/lib/preamble.sh"
8
+
9
+ gate_read_input
10
+
11
+ if [[ -z "$GATE_REPO" ]]; then
12
+ gate_skip "no repo in candidate"
13
+ fi
14
+
15
+ LOG="$HOME/.contribute-system/log.jsonl"
16
+ [[ ! -f "$LOG" ]] && gate_pass "no log.jsonl yet"
17
+
18
+ # Count override events in last 30 days at this repo
19
+ THIRTY_AGO=$(/usr/bin/date -u -d '30 days ago' +%s)
20
+
21
+ OVERRIDE_COUNT=$(jq -r --arg cutoff "$THIRTY_AGO" --arg repo "$GATE_REPO" '
22
+ select(.event == "gate_override")
23
+ | select(.details.repo == $repo)
24
+ | select((.ts | fromdateiso8601) >= ($cutoff | tonumber))
25
+ ' "$LOG" 2>/dev/null | jq -s 'length' 2>/dev/null || /usr/bin/echo 0)
26
+
27
+ if [[ "${OVERRIDE_COUNT:-0}" -ge 3 ]]; then
28
+ gate_block "$OVERRIDE_COUNT gate overrides at $GATE_REPO in last 30 days (rate limit: 3)" "the system is telling you something. Either your scope at this repo is wrong, your dossier is out of date, or you're forcing through patterns that will eventually trigger an AI-policy closure. To override THIS rate limit: edit ~/.contribute-system/g06-rate-limit-reset && touch it. Don't just shrug it off."
29
+ fi
30
+
31
+ gate_pass "$OVERRIDE_COUNT/3 overrides at $GATE_REPO in last 30 days"
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # Shared preamble for every gate script. Sourced as the first line of every
3
+ # gate. Eliminates the per-script bug surface that Slice 1's scout-discover.sh
4
+ # already showed (empty-array failures under set -u, jq parse errors on
5
+ # malformed gh output, etc.).
6
+ #
7
+ # Per-script gate authors: source this file FIRST, then implement the gate.
8
+ # Use the helpers below for output. NEVER `echo "{...}"` your verdict directly
9
+ # — use gate_pass / gate_warn / gate_block / gate_inform.
10
+
11
+ set -euo pipefail
12
+
13
+ # Global error trap — any uncaught error dumps a BLOCK verdict + exits 0.
14
+ # (Per gate contract: exit code is always 0; runner reads stdout JSON.)
15
+ _GATE_ID="${0##*/}"
16
+ _GATE_ID="${_GATE_ID%.sh}"
17
+
18
+ _gate_err_trap() {
19
+ local exit_code=$?
20
+ local line_no=$1
21
+ /usr/bin/cat <<EOF
22
+ {"severity":"BLOCK","gate":"$_GATE_ID","reason":"gate $_GATE_ID crashed at line $line_no (exit $exit_code) — fail-closed","fix_hint":"check ~/.contribute-system/check-runs/gate-debug.log; this is a bug in the gate itself"}
23
+ EOF
24
+ exit 0
25
+ }
26
+ trap '_gate_err_trap $LINENO' ERR
27
+
28
+ # Emit a verdict and exit. Use these — never raw echo.
29
+ #
30
+ # Reason / fix_hint are JSON-escaped via jq -nc. Earlier versions used
31
+ # printf '%s' which dropped raw text into the JSON body and produced
32
+ # invalid output whenever any message contained a literal `"`. Caught by
33
+ # the e02-ai-strike-track unit tests on 2026-05-03.
34
+ gate_pass() { jq -nc --arg g "$_GATE_ID" --arg r "${1:-ok}" '{severity:"PASS",gate:$g,reason:$r}'; exit 0; }
35
+ gate_warn() { jq -nc --arg g "$_GATE_ID" --arg r "${1:-warning}" --arg f "${2:-}" '{severity:"WARN",gate:$g,reason:$r,fix_hint:$f}'; exit 0; }
36
+ gate_block() { jq -nc --arg g "$_GATE_ID" --arg r "${1:-blocked}" --arg f "${2:-}" '{severity:"BLOCK",gate:$g,reason:$r,fix_hint:$f}'; exit 0; }
37
+ gate_inform() { jq -nc --arg g "$_GATE_ID" --arg r "${1:-noted}" '{severity:"INFORM",gate:$g,reason:$r}'; exit 0; }
38
+ gate_skip() { jq -nc --arg g "$_GATE_ID" --arg r "${1:-not applicable}" '{severity:"SKIP",gate:$g,reason:$r}'; exit 0; }
39
+
40
+ # Read stdin JSON contract. Sets:
41
+ # GATE_CANDIDATE_PATH — path to candidate .md file
42
+ # GATE_DOSSIER_PATH — path to dossier .md (or empty if no dossier)
43
+ # GATE_ACTION — transition name (e.g., "shortlist→claimed")
44
+ # GATE_REPO — owner/repo
45
+ # GATE_INPUT_JSON — raw stdin for any extra fields
46
+ gate_read_input() {
47
+ GATE_INPUT_JSON=$(/usr/bin/cat)
48
+ GATE_CANDIDATE_PATH=$(/usr/bin/printf '%s' "$GATE_INPUT_JSON" | jq -r '.candidate // ""')
49
+ GATE_DOSSIER_PATH=$(/usr/bin/printf '%s' "$GATE_INPUT_JSON" | jq -r '.dossier // ""')
50
+ GATE_ACTION=$(/usr/bin/printf '%s' "$GATE_INPUT_JSON" | jq -r '.action // ""')
51
+ GATE_REPO=$(/usr/bin/printf '%s' "$GATE_INPUT_JSON" | jq -r '.env.repo // ""')
52
+ export GATE_INPUT_JSON GATE_CANDIDATE_PATH GATE_DOSSIER_PATH GATE_ACTION GATE_REPO
53
+ }
54
+
55
+ # Read a frontmatter field from a markdown file. Returns empty if missing.
56
+ # Usage: val=$(fm_field /path/to/file.md "key")
57
+ fm_field() {
58
+ local file="$1" key="$2"
59
+ [[ -f "$file" ]] || { /usr/bin/printf ''; return; }
60
+ /usr/bin/awk -v k="$key" '
61
+ /^---$/ { fm = !fm ? 1 : 2; next }
62
+ fm == 1 && $0 ~ "^"k":" {
63
+ sub("^"k":[[:space:]]*", "")
64
+ gsub(/^"|"$/, "")
65
+ print
66
+ exit
67
+ }
68
+ ' "$file"
69
+ }
70
+
71
+ # gh wrapper with bounded retry.
72
+ #
73
+ # Constraint: gate-runner enforces a 10s wall-clock timeout per gate. gh_safe
74
+ # must finish well under that to leave room for jq/awk downstream.
75
+ # Tuning: 2 attempts, 0.5s sleep between, 4s per-call timeout.
76
+ # Worst case ≈ 4 + 0.5 + 4 = 8.5s — leaves headroom.
77
+ #
78
+ # Permanent 404s (issue/repo doesn't exist) fail on the first call's HTTP error
79
+ # and don't usefully retry. Transient blips get one retry. That's enough for
80
+ # the read-only gh queries gates make.
81
+ #
82
+ # On final failure returns non-zero with empty stdout — caller decides what
83
+ # missing data means (usually `gate_pass`/`gate_inform`/`gate_skip`).
84
+ gh_safe() {
85
+ local attempt=1 max=2
86
+ while (( attempt <= max )); do
87
+ if /usr/bin/timeout 4 gh "$@" 2>/dev/null; then return 0; fi
88
+ /usr/bin/sleep 0.5
89
+ attempt=$(( attempt + 1 ))
90
+ done
91
+ return 1
92
+ }
93
+
94
+ # Helper: log a gate run for observability. Append-only.
95
+ gate_log_run() {
96
+ local verdict="$1"
97
+ /usr/bin/printf '%s\n' "$(jq -nc \
98
+ --arg ts "$(/usr/bin/date -u +%Y-%m-%dT%H:%M:%SZ)" \
99
+ --arg gate "$_GATE_ID" \
100
+ --arg action "$GATE_ACTION" \
101
+ --arg repo "$GATE_REPO" \
102
+ --arg verdict "$verdict" \
103
+ '{ts: $ts, event: "gate_run", details: {gate: $gate, action: $action, repo: $repo, verdict: $verdict}}')" \
104
+ >> ~/.contribute-system/log.jsonl 2>/dev/null || true
105
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bash
2
+ # lint-candidate.sh — report missing required body sections in candidate files.
3
+ #
4
+ # Sibling to audit-overrides.sh and catalog-coverage.sh: a read-only reporter
5
+ # that walks ~/.contribute-system/candidates/, reads each candidate's status,
6
+ # and surfaces missing required sections per the matrix in
7
+ # skills/contribute/references/candidate-file-format.md.
8
+ #
9
+ # Required-sections matrix (matches the spec + transition.sh):
10
+ # shortlist → ## Scope, ## Files to touch
11
+ # claimed → ## Scope, ## Files to touch, ## Claim comment draft
12
+ # working → same as claimed
13
+ # submitted → ## PR title, ## PR body, ## Test results
14
+ # merged → same as submitted
15
+ # open → no body requirements
16
+ # dropped → no body requirements
17
+ #
18
+ # Usage:
19
+ # lint-candidate.sh # all candidates, table output
20
+ # lint-candidate.sh --status=submitted # filter to one status
21
+ # lint-candidate.sh --missing-only # only candidates with missing sections
22
+ # lint-candidate.sh --json # JSON output (one object per candidate)
23
+ # lint-candidate.sh --candidates-dir=X # override the candidate dir
24
+ #
25
+ # Exit codes:
26
+ # 0 — no missing sections across the filtered set (clean)
27
+ # 1 — at least one candidate has missing required sections
28
+ # 64 — bad arguments
29
+
30
+ set -uo pipefail
31
+
32
+ CAND_DIR="${HOME}/.contribute-system/candidates"
33
+ STATUS_FILTER=""
34
+ MISSING_ONLY=0
35
+ JSON_OUT=0
36
+
37
+ while [[ $# -gt 0 ]]; do
38
+ case "$1" in
39
+ --status=*) STATUS_FILTER="${1#*=}" ;;
40
+ --missing-only) MISSING_ONLY=1 ;;
41
+ --json) JSON_OUT=1 ;;
42
+ --candidates-dir=*) CAND_DIR="${1#*=}" ;;
43
+ -h|--help) /usr/bin/sed -n '2,28p' "$0" | /usr/bin/sed 's/^# \{0,1\}//'; exit 0 ;;
44
+ *) /usr/bin/echo "unknown arg: $1" >&2; exit 64 ;;
45
+ esac
46
+ shift
47
+ done
48
+
49
+ if [[ ! -d "$CAND_DIR" ]]; then
50
+ /usr/bin/echo "candidate dir not found: $CAND_DIR" >&2
51
+ exit 64
52
+ fi
53
+
54
+ # Required-sections per status. Pipe-delimited for compatibility with the
55
+ # same scheme transition.sh uses.
56
+ required_for() {
57
+ case "$1" in
58
+ shortlist) /usr/bin/echo '## Scope|## Files to touch' ;;
59
+ claimed) /usr/bin/echo '## Scope|## Files to touch|## Claim comment draft' ;;
60
+ working) /usr/bin/echo '## Scope|## Files to touch|## Claim comment draft' ;;
61
+ submitted) /usr/bin/echo '## PR title|## PR body|## Test results' ;;
62
+ merged) /usr/bin/echo '## PR title|## PR body|## Test results' ;;
63
+ *) /usr/bin/echo '' ;; # open, dropped, unknown
64
+ esac
65
+ }
66
+
67
+ # Per-candidate evaluation. Echoes a JSON object per candidate so the table
68
+ # pass and the JSON pass can both consume it.
69
+ evaluate_one() {
70
+ local file="$1"
71
+ local basename
72
+ basename=$(/usr/bin/basename "$file")
73
+ local status
74
+ status=$(/usr/bin/awk '/^---$/{fm=!fm?1:2;next} fm==1 && /^status:/{sub(/^status:[[:space:]]*/,""); print; exit}' "$file" 2>/dev/null)
75
+ status="${status:-unknown}"
76
+
77
+ local req
78
+ req=$(required_for "$status")
79
+
80
+ local missing=()
81
+ if [[ -n "$req" ]]; then
82
+ IFS='|' read -ra SECTIONS <<< "$req"
83
+ for sec in "${SECTIONS[@]}"; do
84
+ if ! /usr/bin/grep -qE "^${sec}\b" "$file" 2>/dev/null; then
85
+ missing+=("$sec")
86
+ fi
87
+ done
88
+ fi
89
+
90
+ local missing_json
91
+ if [[ "${#missing[@]}" -eq 0 ]]; then
92
+ missing_json='[]'
93
+ else
94
+ missing_json=$(/usr/bin/printf '%s\n' "${missing[@]}" | jq -Rsc 'split("\n") | map(select(. != ""))')
95
+ fi
96
+
97
+ jq -nc --arg cand "$basename" --arg status "$status" --argjson missing "$missing_json" \
98
+ '{candidate: $cand, status: $status, missing: $missing, missing_count: ($missing | length)}'
99
+ }
100
+
101
+ # Walk all candidates, evaluate each, capture rows.
102
+ ROWS=()
103
+ EXIT_CODE=0
104
+ shopt -s nullglob
105
+ for f in "$CAND_DIR"/*.md; do
106
+ row=$(evaluate_one "$f")
107
+ STATUS=$(/usr/bin/echo "$row" | jq -r .status)
108
+ MISSING_COUNT=$(/usr/bin/echo "$row" | jq -r .missing_count)
109
+
110
+ # Apply --status filter
111
+ if [[ -n "$STATUS_FILTER" && "$STATUS" != "$STATUS_FILTER" ]]; then
112
+ continue
113
+ fi
114
+ # Apply --missing-only filter
115
+ if [[ "$MISSING_ONLY" -eq 1 && "$MISSING_COUNT" -eq 0 ]]; then
116
+ continue
117
+ fi
118
+
119
+ ROWS+=("$row")
120
+ if [[ "$MISSING_COUNT" -gt 0 ]]; then
121
+ EXIT_CODE=1
122
+ fi
123
+ done
124
+ shopt -u nullglob
125
+
126
+ # Emit
127
+ if [[ "$JSON_OUT" -eq 1 ]]; then
128
+ /usr/bin/printf '%s\n' "${ROWS[@]}" | jq -s '.'
129
+ else
130
+ /usr/bin/printf '%-55s %-10s %s\n' 'candidate' 'status' 'missing'
131
+ /usr/bin/printf '%-55s %-10s %s\n' '---------' '------' '-------'
132
+ if [[ "${#ROWS[@]}" -eq 0 ]]; then
133
+ /usr/bin/printf '(no candidates matched filter)\n'
134
+ else
135
+ for row in "${ROWS[@]}"; do
136
+ cand=$(/usr/bin/echo "$row" | jq -r .candidate)
137
+ status=$(/usr/bin/echo "$row" | jq -r .status)
138
+ missing_str=$(/usr/bin/echo "$row" | jq -r '.missing | if length == 0 then "(none)" else join(", ") end')
139
+ /usr/bin/printf '%-55s %-10s %s\n' "$cand" "$status" "$missing_str"
140
+ done
141
+
142
+ # Summary footer
143
+ TOTAL=${#ROWS[@]}
144
+ DIRTY=$(/usr/bin/printf '%s\n' "${ROWS[@]}" | jq -s '[.[] | select(.missing_count > 0)] | length')
145
+ /usr/bin/printf '\n%d candidate(s) shown · %d with missing sections\n' "$TOTAL" "$DIRTY"
146
+ fi
147
+ fi
148
+
149
+ exit "$EXIT_CODE"