@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,99 @@
1
+ #!/usr/bin/env bash
2
+ # catalog-coverage.sh — report which catalog modes have gate implementations.
3
+ #
4
+ # Closes contributing-clanker-p5q.3.
5
+ #
6
+ # The 62-failure-mode catalog lives at:
7
+ # <repo>/000-docs/007-DR-CATG-failure-mode-catalog.md
8
+ # Each row maps a mode ID (e.g. A1, B12, C5) to a "Gate" column with either:
9
+ # - a real gate filename (e.g. "a01-already-assigned")
10
+ # - "(planned)" for unimplemented modes
11
+ # - "(see ...)" for modes covered by a different gate via reference
12
+ #
13
+ # Coverage = real-gate count / total catalog count.
14
+ # Output: per-phase table + overall %.
15
+
16
+ set -uo pipefail
17
+
18
+ CATALOG="${1:-${HOME}/000-projects/contributing-clanker/000-docs/007-DR-CATG-failure-mode-catalog.md}"
19
+ GATES_DIR="${HOME}/.claude/skills/contribute/scripts/gates"
20
+
21
+ if [[ ! -f "$CATALOG" ]]; then
22
+ /usr/bin/printf 'catalog not found: %s\n' "$CATALOG" >&2
23
+ /usr/bin/printf 'usage: %s [path-to-catalog.md]\n' "$0" >&2
24
+ exit 2
25
+ fi
26
+
27
+ if [[ ! -d "$GATES_DIR" ]]; then
28
+ /usr/bin/printf 'gates dir not found: %s\n' "$GATES_DIR" >&2
29
+ exit 2
30
+ fi
31
+
32
+ # Extract catalog rows. Format: | A1 | description | trigger | gate-name |
33
+ # We only care about rows where col 1 is a single-letter+digit ID and col 4
34
+ # (last col) is the gate ref.
35
+ #
36
+ # Each markdown table row: | id | failure | trigger | gate |
37
+ # We want id (col 2 after leading |) and gate (col 5 after leading |).
38
+ parse_catalog() {
39
+ /usr/bin/awk -F'|' '
40
+ /^\| *[A-G][0-9]+ *\|/ {
41
+ gsub(/^[ \t]+|[ \t]+$/, "", $2) # mode id
42
+ gsub(/^[ \t]+|[ \t]+$/, "", $5) # gate ref
43
+ print $2 "\t" $5
44
+ }
45
+ ' "$CATALOG"
46
+ }
47
+
48
+ # Aggregate per phase (A,B,C,D,E,F,G)
49
+ declare -A phase_total phase_planned phase_implemented
50
+ total=0
51
+ implemented=0
52
+ planned=0
53
+
54
+ while IFS=$'\t' read -r id gate; do
55
+ phase="${id:0:1}"
56
+ phase_total[$phase]=$(( ${phase_total[$phase]:-0} + 1 ))
57
+ total=$(( total + 1 ))
58
+ case "$gate" in
59
+ *planned*)
60
+ phase_planned[$phase]=$(( ${phase_planned[$phase]:-0} + 1 ))
61
+ planned=$(( planned + 1 ))
62
+ ;;
63
+ *)
64
+ # Real gate ref. Confirm a script file actually exists for it.
65
+ # Gate refs in the catalog look like "a01-already-assigned" — match by
66
+ # prefix lower-case + leading digits.
67
+ gate_id_normalized=$(/usr/bin/printf '%s' "$gate" | /usr/bin/tr '[:upper:]' '[:lower:]' | /usr/bin/sed 's/[^a-z0-9].*//')
68
+ if /usr/bin/find "$GATES_DIR" -maxdepth 1 -name "${gate_id_normalized}*.sh" -print -quit 2>/dev/null | /usr/bin/grep -q .; then
69
+ phase_implemented[$phase]=$(( ${phase_implemented[$phase]:-0} + 1 ))
70
+ implemented=$(( implemented + 1 ))
71
+ else
72
+ phase_planned[$phase]=$(( ${phase_planned[$phase]:-0} + 1 ))
73
+ planned=$(( planned + 1 ))
74
+ fi
75
+ ;;
76
+ esac
77
+ done < <(parse_catalog)
78
+
79
+ # Render
80
+ /usr/bin/printf '\nCatalog → Gate coverage\n'
81
+ /usr/bin/printf '═════════════════════════════════════════\n'
82
+ /usr/bin/printf '%-8s %10s %12s %10s\n' "PHASE" "TOTAL" "IMPLEMENTED" "COVERAGE"
83
+ /usr/bin/printf '─────────────────────────────────────────\n'
84
+ for phase in A B C D E F G; do
85
+ t=${phase_total[$phase]:-0}
86
+ i=${phase_implemented[$phase]:-0}
87
+ if [[ $t -gt 0 ]]; then
88
+ pct=$(( i * 100 / t ))
89
+ /usr/bin/printf '%-8s %10s %12s %9s%%\n' "$phase" "$t" "$i" "$pct"
90
+ fi
91
+ done
92
+ /usr/bin/printf '─────────────────────────────────────────\n'
93
+ overall_pct=$(( implemented * 100 / total ))
94
+ /usr/bin/printf '%-8s %10s %12s %9s%%\n' "TOTAL" "$total" "$implemented" "$overall_pct"
95
+ /usr/bin/printf '═════════════════════════════════════════\n\n'
96
+
97
+ if [[ $planned -gt 0 ]]; then
98
+ /usr/bin/printf ' %d catalog mode(s) still planned (no gate file).\n\n' "$planned"
99
+ fi
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ # gate-runner.sh — the orchestrator that runs gates for a lifecycle transition.
3
+ #
4
+ # Usage: gate-runner.sh <action> <candidate-path> [<dossier-path>]
5
+ # action: e.g., "shortlist→claimed", "working→submitted", "open-pr", "post-comment"
6
+ #
7
+ # Discovers gates by glob from ~/.contribute-system/gates/<phase>*.sh,
8
+ # filters by which gates apply to this action (per the gate's filename phase
9
+ # letter and the action's lifecycle stage), runs each in turn with a 10-second
10
+ # timeout, aggregates verdicts.
11
+ #
12
+ # Output: one JSON per gate to stderr (for human-readable progress);
13
+ # final aggregated verdict to stdout.
14
+ #
15
+ # Exit code: 0 if all PASS/WARN/INFORM/SKIP; 1 if any BLOCK (unless every
16
+ # BLOCK was overridden via the candidate's overrides: frontmatter).
17
+
18
+ set -euo pipefail
19
+
20
+ ACTION="${1:-}"
21
+ CANDIDATE="${2:-}"
22
+ DOSSIER="${3:-}"
23
+
24
+ if [[ -z "$ACTION" || -z "$CANDIDATE" ]]; then
25
+ echo "usage: $0 <action> <candidate-path> [<dossier-path>]" >&2
26
+ exit 64
27
+ fi
28
+
29
+ # Discover gates from two dirs, in priority order:
30
+ # 1. Bundled canonical set at skill/scripts/gates/ (discovered via script location)
31
+ # 2. User-override gates at ~/.contribute-system/gates/ (personal additions / overrides)
32
+ # This split means the bundled set is always present (distributable) and users can
33
+ # add custom gates or fork existing ones without modifying the skill package.
34
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
+ BUNDLED_GATE_DIR="${SCRIPT_DIR}/gates"
36
+ USER_GATE_DIR="$HOME/.contribute-system/gates"
37
+ LOG="$HOME/.contribute-system/log.jsonl"
38
+ NOW=$(/usr/bin/date -u +%Y-%m-%dT%H:%M:%SZ)
39
+
40
+ # Map action → relevant gate phases. Gates are filtered by phase letter
41
+ # (first char of filename). An action runs all gates in its applicable phases.
42
+ case "$ACTION" in
43
+ "open→shortlist") PHASES="A" ;;
44
+ "shortlist→claimed") PHASES="A E" ;;
45
+ "claimed→working") PHASES="A B" ;;
46
+ "working→submitted") PHASES="B C E F G" ;;
47
+ "open-pr") PHASES="C E" ;;
48
+ "flip-to-ready") PHASES="C" ;;
49
+ "post-comment") PHASES="D" ;;
50
+ "open-issue") PHASES="D" ;;
51
+ *) PHASES="A B C D E F G" ;; # unknown: run everything
52
+ esac
53
+
54
+ # Read disabled_gates from dossier (per-repo opt-out)
55
+ DISABLED=""
56
+ if [[ -n "$DOSSIER" && -f "$DOSSIER" ]]; then
57
+ DISABLED=$(/usr/bin/awk '/^---$/{fm=!fm?1:2;next} fm==1 && /^disabled_gates:/{
58
+ sub(/^disabled_gates:[[:space:]]*\[/,""); sub(/\][[:space:]]*$/,""); gsub(/[[:space:]]/,""); print; exit
59
+ }' "$DOSSIER" 2>/dev/null || /usr/bin/echo "")
60
+ fi
61
+
62
+ # Read repo + branch from candidate frontmatter
63
+ REPO=$(/usr/bin/awk '/^---$/{fm=!fm?1:2;next} fm==1 && /^repo:/{sub(/^repo:[[:space:]]*/,""); print; exit}' "$CANDIDATE" 2>/dev/null || /usr/bin/echo "")
64
+ BRANCH=$(/usr/bin/awk '/^---$/{fm=!fm?1:2;next} fm==1 && /^branch:/{sub(/^branch:[[:space:]]*/,""); print; exit}' "$CANDIDATE" 2>/dev/null || /usr/bin/echo "")
65
+
66
+ # Build the input JSON once (passed to every gate)
67
+ INPUT_JSON=$(jq -nc \
68
+ --arg candidate "$CANDIDATE" \
69
+ --arg dossier "$DOSSIER" \
70
+ --arg action "$ACTION" \
71
+ --arg repo "$REPO" \
72
+ --arg branch "$BRANCH" \
73
+ '{candidate: $candidate, dossier: $dossier, action: $action, env: {repo: $repo, branch: $branch}}')
74
+
75
+ # Discover gate scripts for the relevant phases.
76
+ # Bundled gates first, then user-override dir.
77
+ # A user gate with the same basename shadows the bundled one (by position in GATES array).
78
+ # gate-runner runs all discovered gates; SKIP is cheap.
79
+ declare -A SEEN_GATES=()
80
+ GATES=()
81
+ for DIR in "$BUNDLED_GATE_DIR" "$USER_GATE_DIR" ; do
82
+ [[ -d "$DIR" ]] || continue
83
+ for PHASE in $PHASES ; do
84
+ for GATE in "$DIR"/${PHASE,,}*.sh ; do
85
+ [[ -f "$GATE" && -x "$GATE" ]] || continue
86
+ BN=$(/usr/bin/basename "$GATE")
87
+ # User-override dir wins — if a gate with same name was already queued from
88
+ # bundled dir, replace it. Simple: add all, let user-dir overwrite.
89
+ [[ -z "${SEEN_GATES[$BN]:-}" ]] && GATES+=("$GATE") || true
90
+ SEEN_GATES[$BN]="$GATE"
91
+ done
92
+ done
93
+ done
94
+
95
+ if [[ "${#GATES[@]}" -eq 0 ]]; then
96
+ /usr/bin/echo '{"verdict":"PASS","gates_run":0,"reason":"no gates applicable for this action"}'
97
+ exit 0
98
+ fi
99
+
100
+ # Run each gate. Aggregate.
101
+ PASS_COUNT=0
102
+ WARN_COUNT=0
103
+ BLOCK_COUNT=0
104
+ INFORM_COUNT=0
105
+ SKIP_COUNT=0
106
+ BLOCKERS=()
107
+ WARNINGS=()
108
+
109
+ /usr/bin/printf '\n=== gate-runner: %s — %d gates ===\n' "$ACTION" "${#GATES[@]}" >&2
110
+
111
+ for GATE in "${GATES[@]}" ; do
112
+ GATE_NAME=$(/usr/bin/basename "$GATE" .sh)
113
+ GATE_ID=$(/usr/bin/printf '%s' "$GATE_NAME" | /usr/bin/cut -d- -f1 | /usr/bin/tr 'a-z' 'A-Z')
114
+
115
+ # Per-repo opt-out check
116
+ if /usr/bin/echo ",$DISABLED," | /usr/bin/grep -qi ",$GATE_ID,"; then
117
+ /usr/bin/printf ' [%s] SKIP — disabled per dossier\n' "$GATE_ID" >&2
118
+ SKIP_COUNT=$(( SKIP_COUNT + 1 ))
119
+ continue
120
+ fi
121
+
122
+ # Run the gate with timeout. Capture stdout. Exit non-zero = treat as BLOCK.
123
+ if VERDICT_JSON=$(/usr/bin/timeout 10 bash -c "/usr/bin/printf '%s' '$INPUT_JSON' | '$GATE'" 2>/dev/null); then
124
+ : # ok, parse verdict
125
+ else
126
+ VERDICT_JSON=$(jq -nc --arg gid "$GATE_ID" '{severity:"BLOCK", gate:$gid, reason:"gate timed out or crashed (>10s or non-zero exit) — fail-closed", fix_hint:"check the gate script for bugs; preamble.sh should have caught it"}')
127
+ fi
128
+
129
+ # Defensive parse: if the gate returned malformed JSON, treat as BLOCK
130
+ if ! /usr/bin/printf '%s' "$VERDICT_JSON" | jq -e . >/dev/null 2>&1; then
131
+ VERDICT_JSON=$(jq -nc --arg gid "$GATE_ID" --arg raw "$VERDICT_JSON" '{severity:"BLOCK", gate:$gid, reason:"gate returned malformed JSON — fail-closed", fix_hint:("raw stdout: " + ($raw | tostring))}')
132
+ fi
133
+
134
+ SEV=$(/usr/bin/printf '%s' "$VERDICT_JSON" | jq -r '.severity')
135
+ REASON=$(/usr/bin/printf '%s' "$VERDICT_JSON" | jq -r '.reason')
136
+ FIX=$(/usr/bin/printf '%s' "$VERDICT_JSON" | jq -r '.fix_hint // ""')
137
+
138
+ case "$SEV" in
139
+ PASS) PASS_COUNT=$((PASS_COUNT+1)); /usr/bin/printf ' [%s] \033[32mPASS\033[0m — %s\n' "$GATE_ID" "$REASON" >&2 ;;
140
+ WARN) WARN_COUNT=$((WARN_COUNT+1)); WARNINGS+=("$GATE_ID: $REASON ($FIX)") ; /usr/bin/printf ' [%s] \033[33mWARN\033[0m — %s\n' "$GATE_ID" "$REASON" >&2 ;;
141
+ BLOCK) BLOCK_COUNT=$((BLOCK_COUNT+1)); BLOCKERS+=("$GATE_ID: $REASON ($FIX)") ; /usr/bin/printf ' [%s] \033[31mBLOCK\033[0m — %s\n fix: %s\n' "$GATE_ID" "$REASON" "$FIX" >&2 ;;
142
+ INFORM) INFORM_COUNT=$((INFORM_COUNT+1)); /usr/bin/printf ' [%s] INFO — %s\n' "$GATE_ID" "$REASON" >&2 ;;
143
+ SKIP) SKIP_COUNT=$((SKIP_COUNT+1)); /usr/bin/printf ' [%s] SKIP — %s\n' "$GATE_ID" "$REASON" >&2 ;;
144
+ *) BLOCK_COUNT=$((BLOCK_COUNT+1)); BLOCKERS+=("$GATE_ID: unknown severity '$SEV'") ; /usr/bin/printf ' [%s] BLOCK — unknown severity: %s\n' "$GATE_ID" "$SEV" >&2 ;;
145
+ esac
146
+
147
+ # Append run to log
148
+ /usr/bin/printf '%s\n' "$(jq -nc \
149
+ --arg ts "$NOW" \
150
+ --arg gate "$GATE_ID" \
151
+ --arg action "$ACTION" \
152
+ --arg repo "$REPO" \
153
+ --arg sev "$SEV" \
154
+ --arg reason "$REASON" \
155
+ '{ts: $ts, event: "gate_run", details: {gate: $gate, action: $action, repo: $repo, severity: $sev, reason: $reason}}')" >> "$LOG" 2>/dev/null || true
156
+ done
157
+
158
+ # Check overrides on the candidate (any BLOCK gate the user explicitly waived).
159
+ OVERRIDDEN=()
160
+ if [[ -f "$CANDIDATE" ]] && /usr/bin/grep -q '^overrides:' "$CANDIDATE" 2>/dev/null; then
161
+ while IFS= read -r OG; do
162
+ OVERRIDDEN+=("$OG")
163
+ done < <(/usr/bin/awk '/^overrides:/{flag=1;next} /^[a-z_]+:/{flag=0} flag && /gate:/{sub(/.*gate:[[:space:]]*/,"");sub(/[[:space:]]*,.*$/,"");print}' "$CANDIDATE" 2>/dev/null)
164
+ fi
165
+
166
+ # Filter BLOCKERS against OVERRIDDEN
167
+ EFFECTIVE_BLOCKS=()
168
+ for B in "${BLOCKERS[@]}"; do
169
+ BID="${B%%:*}"
170
+ IS_OVERRIDDEN=0
171
+ for O in "${OVERRIDDEN[@]}"; do
172
+ [[ "$O" == "$BID" ]] && { IS_OVERRIDDEN=1; break; }
173
+ done
174
+ if [[ "$IS_OVERRIDDEN" -eq 0 ]]; then
175
+ EFFECTIVE_BLOCKS+=("$B")
176
+ fi
177
+ done
178
+
179
+ # Final verdict
180
+ TOTAL=${#GATES[@]}
181
+ EFFECTIVE_BLOCK_COUNT=${#EFFECTIVE_BLOCKS[@]}
182
+ /usr/bin/printf '\n=== summary: %d gates · %d PASS · %d WARN · %d BLOCK (%d after overrides) · %d INFORM · %d SKIP ===\n\n' \
183
+ "$TOTAL" "$PASS_COUNT" "$WARN_COUNT" "$BLOCK_COUNT" "$EFFECTIVE_BLOCK_COUNT" "$INFORM_COUNT" "$SKIP_COUNT" >&2
184
+
185
+ if [[ "$EFFECTIVE_BLOCK_COUNT" -gt 0 ]]; then
186
+ /usr/bin/echo "$(jq -nc --argjson b "$(printf '%s\n' "${EFFECTIVE_BLOCKS[@]}" | jq -R . | jq -s .)" --argjson w "$(printf '%s\n' "${WARNINGS[@]}" | jq -R . | jq -s .)" --argjson n "$EFFECTIVE_BLOCK_COUNT" '{verdict: "BLOCK", effective_blocks: $n, blockers: $b, warnings: $w}')"
187
+ exit 1
188
+ else
189
+ /usr/bin/echo "$(jq -nc --argjson w "$(printf '%s\n' "${WARNINGS[@]}" | jq -R . | jq -s .)" --argjson p "$PASS_COUNT" '{verdict: "PASS", gates_passed: $p, warnings: $w}')"
190
+ exit 0
191
+ fi
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A1 — Claim already-assigned issue
3
+ # Mitigates: Tracer-Cloud opensre #1129 trap (2026-05-02) — issue assigned to
4
+ # unKnownNG day before our scout run; he was actively asking maintainer
5
+ # clarifying questions in comments. We almost claim-jumped real human work.
6
+ source "$(dirname "$0")/lib/preamble.sh"
7
+
8
+ gate_read_input
9
+
10
+ # Pull issue number from candidate frontmatter
11
+ ISSUE_NUM=$(fm_field "$GATE_CANDIDATE_PATH" "issue_number")
12
+ if [[ -z "$ISSUE_NUM" || -z "$GATE_REPO" ]]; then
13
+ gate_skip "no issue_number or repo in candidate"
14
+ fi
15
+
16
+ # Live check (this is one of the few gates that MUST be live — assignment
17
+ # state changes too fast to trust the dossier)
18
+ ASSIGNEES=$(gh_safe issue view "$ISSUE_NUM" --repo "$GATE_REPO" --json assignees --jq '[.assignees[].login] | join(",")' || /usr/bin/echo "")
19
+
20
+ if [[ -n "$ASSIGNEES" ]]; then
21
+ gate_block "issue is assigned to [$ASSIGNEES]" "wait for them to drop it, or pick a different candidate. Override only if you've coordinated with the assignee in a comment."
22
+ fi
23
+
24
+ gate_pass "no assignees on issue"
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A2 — Claim work that's already merged in another PR
3
+ # Mitigates: PostHog #55412 trap (2026-05-02) — issue was open but PR #57145
4
+ # had already merged the principled fix. Without this check we'd have
5
+ # submitted a duplicate and burned an AI policy strike.
6
+ source "$(dirname "$0")/lib/preamble.sh"
7
+
8
+ gate_read_input
9
+
10
+ ISSUE_NUM=$(fm_field "$GATE_CANDIDATE_PATH" "issue_number")
11
+ if [[ -z "$ISSUE_NUM" || -z "$GATE_REPO" ]]; then
12
+ gate_skip "no issue_number or repo in candidate"
13
+ fi
14
+
15
+ # Check each of GitHub's three auto-close keywords. NB: gh search uses --merged
16
+ # (NOT --state=merged), and "X OR Y OR Z" is treated as a literal phrase, so
17
+ # we must run separate calls. (Lessons logged in scout's MEMORY.md.)
18
+ SHIPPED_PR=""
19
+ for KW in closes fixes resolves ; do
20
+ HIT=$(gh_safe search prs --repo="$GATE_REPO" "$KW #$ISSUE_NUM" --merged --limit 1 --json url --jq '.[0].url // empty' || /usr/bin/echo "")
21
+ if [[ -n "$HIT" ]]; then
22
+ SHIPPED_PR="$HIT"
23
+ break
24
+ fi
25
+ done
26
+
27
+ if [[ -n "$SHIPPED_PR" ]]; then
28
+ gate_block "issue already shipped in $SHIPPED_PR" "the issue is open but its fix has already merged. Post a 'safe to close?' comment on the issue (good citizen move) and pick a different candidate."
29
+ fi
30
+
31
+ gate_pass "no merged PR claims to close this issue"
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A3 — Issue flagged as duplicate in body or comments
3
+ source "$(dirname "$0")/lib/preamble.sh"
4
+
5
+ gate_read_input
6
+
7
+ ISSUE_NUM=$(fm_field "$GATE_CANDIDATE_PATH" "issue_number")
8
+ if [[ -z "$ISSUE_NUM" || -z "$GATE_REPO" ]]; then
9
+ gate_skip "no issue_number or repo in candidate"
10
+ fi
11
+
12
+ TEXT=$(gh_safe issue view "$ISSUE_NUM" --repo "$GATE_REPO" --json body,comments --jq '.body + " " + (.comments | map(.body) | join(" "))' || /usr/bin/echo "")
13
+
14
+ if [[ -z "$TEXT" ]]; then
15
+ gate_inform "could not fetch issue body/comments"
16
+ fi
17
+
18
+ if /usr/bin/printf '%s' "$TEXT" | /usr/bin/grep -qiE "(duplicate of|dupe of|see)[[:space:]]+#[0-9]+"; then
19
+ gate_warn "issue body or comments mention being a duplicate" "verify with the maintainer before claiming; the underlying issue may be elsewhere"
20
+ fi
21
+
22
+ gate_pass "no duplicate references found"
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A4 — Issue is too old (stale, abandoned, or contested)
3
+ # Mitigates: stale issues are a real anti-signal — either the maintainer
4
+ # decided not to fix it, the underlying code changed and the issue's premise
5
+ # no longer holds, or someone has been silently working on it for months
6
+ # without progress. None of those are good for our merge probability.
7
+ #
8
+ # Thresholds (configurable via dossier `max_issue_age_days:`):
9
+ # issue_age <= 90 days → PASS
10
+ # issue_age 91-180 days → WARN ("aging — verify it's still actionable")
11
+ # issue_age 181-365 days → BLOCK ("stale; recommend pick a fresher target")
12
+ # issue_age > 365 days → BLOCK + harder ("zombie issue; fix likely landed elsewhere")
13
+ #
14
+ # A repo with `max_issue_age_days: N` in dossier overrides the default 180.
15
+ source "$(dirname "$0")/lib/preamble.sh"
16
+
17
+ gate_read_input
18
+
19
+ ISSUE_NUM=$(fm_field "$GATE_CANDIDATE_PATH" "issue_number")
20
+ if [[ -z "$ISSUE_NUM" || -z "$GATE_REPO" ]]; then
21
+ gate_skip "no issue_number or repo in candidate"
22
+ fi
23
+
24
+ # Read max_issue_age from dossier, default 180
25
+ MAX_AGE_DAYS=180
26
+ if [[ -n "$GATE_DOSSIER_PATH" && -f "$GATE_DOSSIER_PATH" ]]; then
27
+ V=$(fm_field "$GATE_DOSSIER_PATH" "max_issue_age_days")
28
+ [[ -n "$V" && "$V" =~ ^[0-9]+$ ]] && MAX_AGE_DAYS="$V"
29
+ fi
30
+
31
+ # Live fetch — issue createdAt + last activity (updatedAt + last comment)
32
+ META_JSON=$(gh_safe issue view "$ISSUE_NUM" --repo "$GATE_REPO" --json createdAt,updatedAt,comments --jq '{createdAt, updatedAt, last_comment_at: (.comments | sort_by(.createdAt) | .[-1].createdAt // null)}' || /usr/bin/echo "")
33
+
34
+ if [[ -z "$META_JSON" ]] ; then
35
+ gate_skip "couldn't fetch issue metadata (gh failure?)"
36
+ fi
37
+
38
+ CREATED=$(/usr/bin/printf '%s' "$META_JSON" | jq -r '.createdAt // ""')
39
+ UPDATED=$(/usr/bin/printf '%s' "$META_JSON" | jq -r '.updatedAt // ""')
40
+ LAST_COMMENT=$(/usr/bin/printf '%s' "$META_JSON" | jq -r '.last_comment_at // ""')
41
+
42
+ if [[ -z "$CREATED" ]] ; then
43
+ gate_skip "issue createdAt missing in API response"
44
+ fi
45
+
46
+ NOW_EPOCH=$(/usr/bin/date -u +%s)
47
+ CREATED_EPOCH=$(/usr/bin/date -u -d "$CREATED" +%s 2>/dev/null || echo 0)
48
+ AGE_DAYS=$(( (NOW_EPOCH - CREATED_EPOCH) / 86400 ))
49
+
50
+ # Activity recency — most recent of updatedAt or last comment
51
+ LATEST_ACT_EPOCH=0
52
+ if [[ -n "$UPDATED" ]] ; then
53
+ V=$(/usr/bin/date -u -d "$UPDATED" +%s 2>/dev/null || echo 0)
54
+ [[ "$V" -gt "$LATEST_ACT_EPOCH" ]] && LATEST_ACT_EPOCH="$V"
55
+ fi
56
+ if [[ -n "$LAST_COMMENT" && "$LAST_COMMENT" != "null" ]] ; then
57
+ V=$(/usr/bin/date -u -d "$LAST_COMMENT" +%s 2>/dev/null || echo 0)
58
+ [[ "$V" -gt "$LATEST_ACT_EPOCH" ]] && LATEST_ACT_EPOCH="$V"
59
+ fi
60
+ SILENCE_DAYS=$(( (NOW_EPOCH - LATEST_ACT_EPOCH) / 86400 ))
61
+
62
+ # Verdicts
63
+ if [[ "$AGE_DAYS" -gt 365 ]] ; then
64
+ gate_block "issue is ${AGE_DAYS}d old (>365d zombie threshold)" "1+ year-old issues are usually either fixed elsewhere, abandoned, or have premises that no longer apply. Pick a fresher target. Override only with a written rationale referencing what's changed."
65
+ fi
66
+
67
+ if [[ "$AGE_DAYS" -gt "$MAX_AGE_DAYS" ]] ; then
68
+ gate_block "issue is ${AGE_DAYS}d old (max for this repo: ${MAX_AGE_DAYS}d)" "stale issue. Maintainer engagement likely dropped. Pick a fresher target — or override with rationale if you have direct maintainer signal that it's still wanted."
69
+ fi
70
+
71
+ # Activity-based check — even fresh issues with long silence are suspect
72
+ if [[ "$SILENCE_DAYS" -gt 90 ]] ; then
73
+ gate_warn "issue is fresh (${AGE_DAYS}d old) but no activity in ${SILENCE_DAYS}d" "comment first to ping for current relevance before investing time"
74
+ fi
75
+
76
+ if [[ "$AGE_DAYS" -gt 90 ]] ; then
77
+ gate_warn "issue is ${AGE_DAYS}d old (aging — under the ${MAX_AGE_DAYS}d block threshold but past the 90d sweet spot)" "verify the underlying premise still holds in current code; verify maintainer still wants this fix"
78
+ fi
79
+
80
+ gate_pass "issue is ${AGE_DAYS}d old, last activity ${SILENCE_DAYS}d ago"
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A5 — Issue is still OPEN right now (not closed since dossier built)
3
+ # Mitigates: lingdojo/kana-dojo #15441 trap (2026-05-03) — issue was OPEN at
4
+ # 04:54Z when scout shortlisted it; CLOSED at 05:00Z (NOT_PLANNED) by
5
+ # maintainer killing a bot-generated cron issue. Our dossier and the existing
6
+ # A1/A2 gates wouldn't have caught it because they check assignees and
7
+ # already-shipped, not state. This is the simplest gate to write and the
8
+ # highest-value catch — bot-generated issues at active repos can close
9
+ # minutes after discovery.
10
+ source "$(dirname "$0")/lib/preamble.sh"
11
+
12
+ gate_read_input
13
+
14
+ ISSUE_NUM=$(fm_field "$GATE_CANDIDATE_PATH" "issue_number")
15
+ if [[ -z "$ISSUE_NUM" || -z "$GATE_REPO" ]]; then
16
+ gate_skip "no issue_number or repo in candidate"
17
+ fi
18
+
19
+ STATE_JSON=$(gh_safe issue view "$ISSUE_NUM" --repo "$GATE_REPO" --json state,stateReason,closedAt --jq '{state, stateReason: (.stateReason // ""), closedAt: (.closedAt // "")}' || /usr/bin/echo "")
20
+
21
+ if [[ -z "$STATE_JSON" ]] ; then
22
+ gate_skip "couldn't fetch issue state"
23
+ fi
24
+
25
+ STATE=$(/usr/bin/printf '%s' "$STATE_JSON" | jq -r '.state')
26
+ REASON=$(/usr/bin/printf '%s' "$STATE_JSON" | jq -r '.stateReason')
27
+ CLOSED_AT=$(/usr/bin/printf '%s' "$STATE_JSON" | jq -r '.closedAt' | /usr/bin/cut -c1-19)
28
+
29
+ if [[ "$STATE" == "CLOSED" ]] ; then
30
+ gate_block "issue is CLOSED (reason: ${REASON:-unspecified}, closed at ${CLOSED_AT:-unknown})" "issue closed since shortlist. If reason is COMPLETED, fix already shipped — pick a different target. If NOT_PLANNED, maintainer rejected the work — drop the candidate."
31
+ fi
32
+
33
+ gate_pass "issue state = OPEN"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A6 — Repo requires claim-etiquette comment before working
3
+ source "$(dirname "$0")/lib/preamble.sh"
4
+
5
+ gate_read_input
6
+
7
+ if [[ -z "$GATE_DOSSIER_PATH" || ! -f "$GATE_DOSSIER_PATH" ]]; then
8
+ gate_skip "no dossier"
9
+ fi
10
+
11
+ REQUIRED=$(fm_field "$GATE_DOSSIER_PATH" "etiquette_comment_required")
12
+ if [[ "$REQUIRED" != "true" ]]; then
13
+ gate_skip "etiquette_comment_required is not true"
14
+ fi
15
+
16
+ ISSUE_NUM=$(fm_field "$GATE_CANDIDATE_PATH" "issue_number")
17
+ if [[ -z "$ISSUE_NUM" || -z "$GATE_REPO" ]]; then
18
+ gate_skip "no issue_number or repo in candidate"
19
+ fi
20
+
21
+ LOGIN=$(gh_safe api user --jq .login || /usr/bin/echo "")
22
+ if [[ -z "$LOGIN" ]]; then
23
+ gate_inform "could not resolve gh user login"
24
+ fi
25
+
26
+ COMMENTS=$(gh_safe issue view "$ISSUE_NUM" --repo "$GATE_REPO" --json comments --jq "[.comments[] | select(.author.login == \"$LOGIN\") | .body] | join(\"\n---\n\")" || /usr/bin/echo "")
27
+
28
+ if [[ -z "$COMMENTS" ]]; then
29
+ gate_block "no claim comment from $LOGIN found on issue #$ISSUE_NUM" "this repo wants you to comment on the issue before working on it; post a claim comment and re-run the transition"
30
+ fi
31
+
32
+ if /usr/bin/printf '%s' "$COMMENTS" | /usr/bin/grep -qiE "(i'?d like to take this|i'?ll take this|happy to take this|working on this|claiming this|would like to work on|let me know if i can take|i'?d be happy to work)"; then
33
+ gate_pass "found claim-shaped comment from $LOGIN"
34
+ fi
35
+
36
+ gate_block "no claim-shaped comment from $LOGIN on issue #$ISSUE_NUM" "this repo wants you to comment on the issue before working on it; post a claim comment and re-run the transition"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: A9 — @-mentions in claim/PR text when CODEOWNERS routing exists
3
+ source "$(dirname "$0")/lib/preamble.sh"
4
+
5
+ gate_read_input
6
+
7
+ if [[ -z "$GATE_DOSSIER_PATH" || ! -f "$GATE_DOSSIER_PATH" ]]; then
8
+ gate_skip "no dossier"
9
+ fi
10
+
11
+ # Check for CODEOWNERS in dossier — frontmatter policy_files OR Policy file inventory section
12
+ HAS_CODEOWNERS=""
13
+ POLICY_FM=$(fm_field "$GATE_DOSSIER_PATH" "policy_files")
14
+ if /usr/bin/printf '%s' "$POLICY_FM" | /usr/bin/grep -qi "CODEOWNERS"; then
15
+ HAS_CODEOWNERS=1
16
+ fi
17
+ if [[ -z "$HAS_CODEOWNERS" ]]; then
18
+ if /usr/bin/awk '/^## Policy file inventory/{flag=1;next} /^## /{flag=0} flag' "$GATE_DOSSIER_PATH" 2>/dev/null | /usr/bin/grep -qi "\*\*CODEOWNERS\*\*"; then
19
+ HAS_CODEOWNERS=1
20
+ fi
21
+ fi
22
+
23
+ if [[ -z "$HAS_CODEOWNERS" ]]; then
24
+ gate_skip "no CODEOWNERS in dossier policy inventory"
25
+ fi
26
+
27
+ # Pick the right draft section based on action
28
+ SECTION=""
29
+ case "$GATE_ACTION" in
30
+ *post-comment*|*claim*) SECTION="## Claim comment draft" ;;
31
+ *open-pr*|*pr*) SECTION="## PR body" ;;
32
+ *) SECTION="## Claim comment draft" ;;
33
+ esac
34
+
35
+ DRAFT=$(/usr/bin/awk -v s="$SECTION" 'BEGIN{flag=0} $0==s{flag=1;next} /^## /{flag=0} flag' "$GATE_CANDIDATE_PATH" 2>/dev/null || /usr/bin/echo "")
36
+
37
+ if [[ -z "$DRAFT" ]]; then
38
+ gate_skip "no draft section found in candidate"
39
+ fi
40
+
41
+ # Count @-mentions, excluding @me, @everyone, @channel.
42
+ # Use awk (single pass, no pipeline-failure surface) instead of a chained
43
+ # grep | grep | wc — under set -uo pipefail, an empty `grep -oE` returns
44
+ # exit 1, killing the whole pipeline, even though "no mentions" is the
45
+ # correct/expected answer for most drafts.
46
+ MENTIONS=$(/usr/bin/printf '%s' "$DRAFT" | /usr/bin/awk '
47
+ {
48
+ for (i = 1; i <= NF; i++) {
49
+ tok = $i
50
+ gsub(/[,.;:!?)\]"\x27]+$/, "", tok)
51
+ if (tok ~ /^@[a-zA-Z0-9-]{2,}$/ && tok !~ /^@(me|everyone|channel)$/) {
52
+ c++
53
+ }
54
+ }
55
+ }
56
+ END { print c + 0 }
57
+ ')
58
+
59
+ if [[ "${MENTIONS:-0}" -ge 1 ]]; then
60
+ gate_warn "$MENTIONS @-mention(s) in draft despite CODEOWNERS routing" "this repo uses CODEOWNERS; explicit @-mentions are usually noise. Drop them unless you have a specific reason to ping someone."
61
+ fi
62
+
63
+ gate_pass "no @-mentions in draft"
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: B1 — Local branch is based on a non-default upstream branch
3
+ source "$(dirname "$0")/lib/preamble.sh"
4
+
5
+ gate_read_input
6
+
7
+ if [[ -z "$GATE_DOSSIER_PATH" || ! -f "$GATE_DOSSIER_PATH" ]]; then
8
+ gate_skip "no dossier — cannot determine default_branch"
9
+ fi
10
+
11
+ DEFAULT_BRANCH=$(fm_field "$GATE_DOSSIER_PATH" "default_branch")
12
+ if [[ -z "$DEFAULT_BRANCH" ]]; then
13
+ gate_skip "no default_branch in dossier"
14
+ fi
15
+
16
+ REPO_NAME="${GATE_REPO##*/}"
17
+ CLONE_DIR="$HOME/000-projects/contributing-clanker/$REPO_NAME"
18
+
19
+ if [[ ! -d "$CLONE_DIR/.git" ]]; then
20
+ gate_skip "no local clone at $CLONE_DIR"
21
+ fi
22
+
23
+ cd "$CLONE_DIR" || gate_skip "cannot cd to clone dir"
24
+
25
+ if /usr/bin/git merge-base --is-ancestor "origin/$DEFAULT_BRANCH" HEAD 2>/dev/null; then
26
+ gate_pass "HEAD descends from origin/$DEFAULT_BRANCH"
27
+ fi
28
+
29
+ gate_block "HEAD is not a descendant of origin/$DEFAULT_BRANCH" "rebase onto the default branch: git fetch origin $DEFAULT_BRANCH && git rebase origin/$DEFAULT_BRANCH"
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # Catalog: B2 — Local branch name violates project's branch_convention regex
3
+ source "$(dirname "$0")/lib/preamble.sh"
4
+
5
+ gate_read_input
6
+
7
+ if [[ -z "$GATE_DOSSIER_PATH" || ! -f "$GATE_DOSSIER_PATH" ]]; then
8
+ gate_skip "no dossier"
9
+ fi
10
+
11
+ CONVENTION=$(fm_field "$GATE_DOSSIER_PATH" "branch_convention")
12
+ if [[ -z "$CONVENTION" ]]; then
13
+ gate_skip "no branch_convention in dossier"
14
+ fi
15
+
16
+ REPO_NAME="${GATE_REPO##*/}"
17
+ CLONE_DIR="$HOME/000-projects/contributing-clanker/$REPO_NAME"
18
+
19
+ if [[ ! -d "$CLONE_DIR/.git" ]]; then
20
+ gate_skip "no local clone at $CLONE_DIR"
21
+ fi
22
+
23
+ cd "$CLONE_DIR" || gate_skip "cannot cd to clone dir"
24
+
25
+ BRANCH=$(/usr/bin/git rev-parse --abbrev-ref HEAD 2>/dev/null || /usr/bin/echo "")
26
+ if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then
27
+ gate_skip "detached HEAD or unknown branch"
28
+ fi
29
+
30
+ if /usr/bin/printf '%s' "$BRANCH" | /usr/bin/grep -qE "$CONVENTION"; then
31
+ gate_pass "branch '$BRANCH' matches convention"
32
+ fi
33
+
34
+ gate_block "branch '$BRANCH' does not match convention /$CONVENTION/" "rename the branch: git branch -m <new-name-matching-convention>"