@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,456 @@
1
+ #!/usr/bin/env bash
2
+ # researcher-build.sh — build a per-repo dossier of contribution rules.
3
+ #
4
+ # Usage:
5
+ # researcher-build.sh <owner>/<repo> [--no-link-follow] [--stdout]
6
+ #
7
+ # Default behavior: writes the dossier to
8
+ # ~/.contribute-system/research/<owner>__<repo>.md
9
+ # and prints "→ wrote dossier to <path>" to stderr so the caller has feedback.
10
+ #
11
+ # Override the output path via CONTRIBUTE_RESEARCH_DIR env var (the dossier
12
+ # lands at $CONTRIBUTE_RESEARCH_DIR/<owner>__<repo>.md).
13
+ #
14
+ # Pass --stdout to write the dossier to stdout instead — useful for piping
15
+ # (jq, less, diff against an existing dossier).
16
+ #
17
+ # What it pulls:
18
+ # - Repo metadata (stars, default branch, archived, push activity)
19
+ # - Policy file inventory (CONTRIBUTING, CLA, DCO, AI_POLICY, SECURITY,
20
+ # CODEOWNERS, PR template, code of conduct, governance)
21
+ # - Raw CONTRIBUTING.md (with key excerpts)
22
+ # - Depth-1 follow of links inside CONTRIBUTING.md (handbook, AI policy,
23
+ # review guide) — saved with fetched-at timestamps
24
+ # - External merge friendliness (last 90 days)
25
+ # - Bots that auto-review on this repo (sampled from a recent PR)
26
+ # - Convention detection: commit format, branch naming, sign-off, CLA, AI
27
+ #
28
+ # Design notes:
29
+ # - Read-only against GitHub. Never writes to upstream.
30
+ # - Uses temp dir for intermediate files; cleans up on exit.
31
+ # - All gh / curl failures degrade gracefully — partial dossier > nothing.
32
+
33
+ set -uo pipefail
34
+
35
+ REPO=""
36
+ NO_LINK_FOLLOW=""
37
+ TO_STDOUT=0
38
+ for arg in "$@"; do
39
+ case "$arg" in
40
+ --no-link-follow) NO_LINK_FOLLOW="--no-link-follow" ;;
41
+ --stdout) TO_STDOUT=1 ;;
42
+ -h|--help) /usr/bin/sed -n '2,16p' "$0" | /usr/bin/sed 's/^# \{0,1\}//'; exit 0 ;;
43
+ -*) /usr/bin/echo "unknown flag: $arg" >&2; exit 64 ;;
44
+ *) if [[ -z "$REPO" ]]; then REPO="$arg"; else /usr/bin/echo "extra arg: $arg" >&2; exit 64; fi ;;
45
+ esac
46
+ done
47
+
48
+ if [[ -z "$REPO" || "$REPO" != */* ]]; then
49
+ /usr/bin/echo "usage: $0 <owner>/<repo> [--no-link-follow] [--stdout]" >&2
50
+ exit 64
51
+ fi
52
+
53
+ if ! gh auth status >/dev/null 2>&1; then
54
+ echo "gh: not authenticated" >&2
55
+ exit 65
56
+ fi
57
+
58
+ TMPDIR=$(/usr/bin/mktemp -d)
59
+ trap 'rm -rf "$TMPDIR"' EXIT
60
+ NOW=$(/usr/bin/date -u +%Y-%m-%dT%H:%M:%SZ)
61
+ NINETY_AGO=$(/usr/bin/date -u -d '90 days ago' +%s)
62
+
63
+ log() { /usr/bin/echo "researcher-build: $*" >&2; }
64
+
65
+ # ---- 1. Repo metadata ----
66
+ log "[1/8] fetching repo metadata for $REPO"
67
+ META_FILE="$TMPDIR/meta.json"
68
+ gh api "repos/$REPO" > "$META_FILE" 2>/dev/null || { log "ERROR: repo $REPO not accessible"; exit 66; }
69
+ STARS=$(jq -r '.stargazers_count // 0' < "$META_FILE")
70
+ DEFAULT_BRANCH=$(jq -r '.default_branch // "main"' < "$META_FILE")
71
+ ARCHIVED=$(jq -r '.archived // false' < "$META_FILE")
72
+ PUSHED_AT=$(jq -r '.pushed_at // ""' < "$META_FILE")
73
+ LICENSE=$(jq -r '.license.spdx_id // "UNKNOWN"' < "$META_FILE")
74
+ LANG=$(jq -r '.language // "unknown"' < "$META_FILE")
75
+ DESC=$(jq -r '.description // ""' < "$META_FILE" | /usr/bin/head -c 200)
76
+
77
+ # ---- 2. Policy file inventory ----
78
+ log "[2/8] inventorying policy files"
79
+ declare -A POLICY_FILES
80
+ # Map: filename → display name. Try multiple paths per file (root + .github/).
81
+ for entry in \
82
+ "CONTRIBUTING.md:CONTRIBUTING" \
83
+ ".github/CONTRIBUTING.md:CONTRIBUTING" \
84
+ "CODE_OF_CONDUCT.md:CODE_OF_CONDUCT" \
85
+ ".github/CODE_OF_CONDUCT.md:CODE_OF_CONDUCT" \
86
+ "SECURITY.md:SECURITY" \
87
+ ".github/SECURITY.md:SECURITY" \
88
+ "CLA.md:CLA" \
89
+ "CLA.txt:CLA" \
90
+ "DCO.md:DCO" \
91
+ "DCO.txt:DCO" \
92
+ "AI_POLICY.md:AI_POLICY" \
93
+ "GOVERNANCE.md:GOVERNANCE" \
94
+ "SETUP.md:SETUP" \
95
+ "DEVELOPMENT.md:DEVELOPMENT" \
96
+ ".github/CODEOWNERS:CODEOWNERS" \
97
+ "CODEOWNERS:CODEOWNERS" \
98
+ ".github/PULL_REQUEST_TEMPLATE.md:PR_TEMPLATE" \
99
+ ".github/pull_request_template.md:PR_TEMPLATE" \
100
+ "PULL_REQUEST_TEMPLATE.md:PR_TEMPLATE" \
101
+ ".github/ISSUE_TEMPLATE.md:ISSUE_TEMPLATE_LEGACY" \
102
+ ".github/issue_template.md:ISSUE_TEMPLATE_LEGACY" \
103
+ "ISSUE_TEMPLATE.md:ISSUE_TEMPLATE_LEGACY"
104
+ do
105
+ PATH_PART="${entry%:*}"
106
+ KEY="${entry##*:}"
107
+ # Skip if we already found this kind of file
108
+ [[ -n "${POLICY_FILES[$KEY]:-}" ]] && continue
109
+
110
+ # Probe by exit code, not by output. Earlier implementation captured stdout
111
+ # and tested for non-empty — but `gh api` on 404 prints the error-shaped
112
+ # JSON body ("{\"message\":\"Not Found\",...}") to stdout BEFORE jq runs,
113
+ # and the existing `2>/dev/null` only silences stderr. Net effect: EXISTS
114
+ # was non-empty for every probe, so every candidate file was claimed to
115
+ # exist regardless of reality. Fix: redirect stdout to /dev/null too and
116
+ # rely on the gh exit code as the existence signal.
117
+ if gh api "repos/$REPO/contents/$PATH_PART" >/dev/null 2>&1 ; then
118
+ POLICY_FILES[$KEY]="$PATH_PART"
119
+ fi
120
+ done
121
+
122
+ # Also probe `docs/` subdir for projects (like secureblue) that house policy
123
+ # docs there instead of at the repo root. Only fills in slots that are still
124
+ # empty after the root + .github/ probes above.
125
+ for entry in \
126
+ "docs/CONTRIBUTING.md:CONTRIBUTING" \
127
+ "docs/CODE_OF_CONDUCT.md:CODE_OF_CONDUCT" \
128
+ "docs/SECURITY.md:SECURITY" \
129
+ "docs/CLA.md:CLA" \
130
+ "docs/DCO.md:DCO" \
131
+ "docs/AI_POLICY.md:AI_POLICY" \
132
+ "docs/GOVERNANCE.md:GOVERNANCE" \
133
+ "docs/SETUP.md:SETUP" \
134
+ "docs/DEVELOPMENT.md:DEVELOPMENT" \
135
+ "docs/PULL_REQUEST_TEMPLATE.md:PR_TEMPLATE"
136
+ do
137
+ PATH_PART="${entry%:*}"
138
+ KEY="${entry##*:}"
139
+ [[ -n "${POLICY_FILES[$KEY]:-}" ]] && continue
140
+ if gh api "repos/$REPO/contents/$PATH_PART" >/dev/null 2>&1 ; then
141
+ POLICY_FILES[$KEY]="$PATH_PART"
142
+ fi
143
+ done
144
+
145
+ # ---- 2b. Issue template directory inventory (.github/ISSUE_TEMPLATE/) ----
146
+ # A repo can have a single legacy template (handled above) OR a directory of
147
+ # templates (the modern pattern: bug-report.md, feature-request.md, design.md, etc.).
148
+ # We list every .md file in that dir so SKILL.md can pick the right one when
149
+ # drafting a Design Issue or feature request.
150
+ log "[2b/8] inventorying issue template directory"
151
+ declare -a ISSUE_TEMPLATES=()
152
+ ISSUE_TEMPLATE_DIR_LISTING=$(gh api "repos/$REPO/contents/.github/ISSUE_TEMPLATE" 2>/dev/null \
153
+ | jq -r 'if type == "array" then .[] | select(.type == "file" and (.name | endswith(".md") or endswith(".yml") or endswith(".yaml"))) | .name else empty end' 2>/dev/null \
154
+ || /usr/bin/echo "")
155
+ if [[ -n "$ISSUE_TEMPLATE_DIR_LISTING" ]] ; then
156
+ while read -r TPL ; do
157
+ [[ -n "$TPL" ]] && ISSUE_TEMPLATES+=("$TPL")
158
+ done <<< "$ISSUE_TEMPLATE_DIR_LISTING"
159
+ fi
160
+
161
+ # ---- 3. Fetch CONTRIBUTING raw + extract links ----
162
+ log "[3/8] fetching + parsing CONTRIBUTING"
163
+ CONTRIB_RAW="$TMPDIR/contributing.md"
164
+ CONTRIB_PATH="${POLICY_FILES[CONTRIBUTING]:-}"
165
+ if [[ -n "$CONTRIB_PATH" ]] ; then
166
+ gh api "repos/$REPO/contents/$CONTRIB_PATH" --jq '.content' 2>/dev/null \
167
+ | /usr/bin/base64 -d > "$CONTRIB_RAW" 2>/dev/null \
168
+ || /usr/bin/printf '' > "$CONTRIB_RAW"
169
+ fi
170
+ CONTRIB_BYTES=$(/usr/bin/wc -c < "$CONTRIB_RAW" 2>/dev/null || echo 0)
171
+
172
+ # Extract http(s) links from CONTRIBUTING — for depth-1 follow
173
+ LINKS_FILE="$TMPDIR/links.txt"
174
+ if [[ -s "$CONTRIB_RAW" ]] ; then
175
+ /usr/bin/grep -oE 'https?://[A-Za-z0-9._/?&=#%~+:-]+' "$CONTRIB_RAW" \
176
+ | /usr/bin/sed 's/[).,;:!]*$//' \
177
+ | /usr/bin/sort -u > "$LINKS_FILE"
178
+ else
179
+ : > "$LINKS_FILE"
180
+ fi
181
+
182
+ # ---- 4. Follow depth-1 links — aggressively (per user direction 2026-05-02) ----
183
+ # Init array empty (set -u trip otherwise on `${#FOLLOWED_LINKS[@]}`).
184
+ declare -a FOLLOWED_LINKS=()
185
+ declare -A LINK_TITLES=()
186
+ if [[ "$NO_LINK_FOLLOW" != "--no-link-follow" && -s "$LINKS_FILE" ]] ; then
187
+ log "[4/8] depth-1 follow on links (skip social/external)"
188
+ while read -r URL ; do
189
+ # Skip social/external (twitter, x, discord, mailto, slack, youtube, linkedin, etc.)
190
+ if /usr/bin/echo "$URL" | /usr/bin/grep -qiE 'twitter\.com|x\.com|discord\.gg|discord\.com|mailto:|slack\.com|youtube\.com|youtu\.be|linkedin\.com|facebook\.com|instagram\.com'; then
191
+ continue
192
+ fi
193
+ # Skip GitHub anchor-only URLs (already covered by repo file scan)
194
+ [[ "$URL" == *"#"* ]] && continue
195
+ # Cap at 15 follows to keep cost bounded (was 5; user wanted aggressive)
196
+ [[ "${#FOLLOWED_LINKS[@]}" -ge 15 ]] && break
197
+ BODY=$(/usr/bin/curl -sSL --max-time 10 -A 'researcher-build/1.0' "$URL" 2>/dev/null | /usr/bin/head -c 50000)
198
+ if [[ -n "$BODY" && "$BODY" != *"404: Not Found"* ]] ; then
199
+ FOLLOWED_LINKS+=("$URL")
200
+ # Extract <title> tag (case-insensitive, strip whitespace, cap length).
201
+ # Falls back to the URL when no title element present.
202
+ TITLE=$(/usr/bin/printf '%s' "$BODY" \
203
+ | /usr/bin/grep -ioE '<title[^>]*>[^<]*</title>' \
204
+ | /usr/bin/head -1 \
205
+ | /usr/bin/sed -E 's|<title[^>]*>||I; s|</title>||I; s/^[[:space:]]+//; s/[[:space:]]+$//' \
206
+ | /usr/bin/cut -c1-120)
207
+ LINK_TITLES[$URL]="${TITLE:-$URL}"
208
+ /usr/bin/printf '%s' "$BODY" > "$TMPDIR/link-$(/usr/bin/echo "$URL" | /usr/bin/md5sum | /usr/bin/cut -c1-8).html"
209
+ fi
210
+ done < "$LINKS_FILE"
211
+ else
212
+ log "[4/8] link follow disabled"
213
+ fi
214
+
215
+ # ---- 5. External merge friendliness (last 90d) ----
216
+ log "[5/8] computing merge friendliness"
217
+ PRS_FILE="$TMPDIR/prs.json"
218
+ gh api "repos/$REPO/pulls?state=closed&sort=updated&direction=desc&per_page=100" > "$PRS_FILE" 2>/dev/null \
219
+ || /usr/bin/printf '[]' > "$PRS_FILE"
220
+ EXT_COUNT=$(jq --arg cutoff "$NINETY_AGO" '
221
+ [.[]
222
+ | select(.merged_at != null)
223
+ | select((.merged_at|fromdateiso8601) >= ($cutoff|tonumber))
224
+ | select(.author_association == "CONTRIBUTOR" or .author_association == "FIRST_TIME_CONTRIBUTOR" or .author_association == "FIRST_TIMER" or .author_association == "NONE")
225
+ | select(.user.type != "Bot")
226
+ ] | length' < "$PRS_FILE" 2>/dev/null || echo 0)
227
+ LAST_EXT=$(jq -r '
228
+ [.[]
229
+ | select(.merged_at != null)
230
+ | select(.author_association == "CONTRIBUTOR" or .author_association == "FIRST_TIME_CONTRIBUTOR" or .author_association == "NONE")
231
+ | select(.user.type != "Bot")
232
+ ] | sort_by(.merged_at) | reverse | .[0].merged_at // ""
233
+ | if . == "" then "(none)" else .[0:10] end' < "$PRS_FILE" 2>/dev/null || echo "(err)")
234
+
235
+ # ---- 6. Bot detection (sample most-recently-updated merged PR) ----
236
+ log "[6/8] sampling review bots from a recent merged PR"
237
+ RECENT_PR=$(jq -r '[.[] | select(.merged_at != null)] | .[0].number // empty' < "$PRS_FILE" 2>/dev/null)
238
+ declare -a REVIEW_BOTS=() # init empty (set -u)
239
+ if [[ -n "$RECENT_PR" ]] ; then
240
+ REVIEWERS=$(gh pr view "$RECENT_PR" --repo "$REPO" --json reviews,comments \
241
+ --jq '([.reviews[].author.login] + [.comments[].author.login]) | unique' 2>/dev/null)
242
+ if [[ -n "$REVIEWERS" ]] ; then
243
+ while read -r LOGIN ; do
244
+ # Bot heuristic: ends in -bot, starts with app/, contains "bot" or "copilot" or "greptile" or "coderabbit" or "renovate" or "dependabot"
245
+ if /usr/bin/echo "$LOGIN" | /usr/bin/grep -qiE 'bot$|^app/|copilot|greptile|coderabbit|renovate|dependabot|github-actions|deploy-status' ; then
246
+ REVIEW_BOTS+=("$LOGIN")
247
+ fi
248
+ done < <(/usr/bin/echo "$REVIEWERS" | jq -r '.[]')
249
+ fi
250
+ fi
251
+
252
+ # ---- 7. Convention detection ----
253
+ log "[7/8] detecting conventions from CONTRIBUTING + linked pages"
254
+ ALL_TEXT="$TMPDIR/all-text.txt"
255
+ { /usr/bin/cat "$CONTRIB_RAW" 2>/dev/null ; /usr/bin/cat "$TMPDIR"/link-*.html 2>/dev/null ; } > "$ALL_TEXT"
256
+
257
+ detect() { /usr/bin/grep -qiE "$1" "$ALL_TEXT" 2>/dev/null && echo "true" || echo "false" ; }
258
+
259
+ CLA_REQUIRED=$(detect '\b(CLA|contributor license agreement)\b')
260
+ DCO_REQUIRED=$(detect '\b(DCO|developer certificate of origin|signed-off-by|sign-off)\b')
261
+ AI_DISCLOSURE=$(detect '\b(AI[-_ ]?(generated|assisted|policy|disclos)|Claude|Copilot|ChatGPT|LLM)\b')
262
+ CONVENTIONAL_COMMITS=$(detect '\bconventional commits?\b|^[a-z]+\([a-z-]+\): ')
263
+ ETIQUETTE_REQUIRED=$(detect 'comment on the issue|request assignment|let.{0,20}know you.{0,5}working|don.{0,3}t.{0,10}assign')
264
+ # Try to grab a test command pattern
265
+ TEST_CMD=$(/usr/bin/grep -ioE '(make|cargo|pnpm|yarn|npm|pytest|sbt|go) (test|test-cov|lint|format-check|typecheck|check)[^`\n]*' "$ALL_TEXT" 2>/dev/null | /usr/bin/head -1 | /usr/bin/sed 's/[`"]//g' | /usr/bin/cut -c1-100)
266
+
267
+ # ---- 8. Emit the dossier ----
268
+ log "[8/8] emitting dossier ($CONTRIB_BYTES bytes CONTRIBUTING, ${#FOLLOWED_LINKS[@]} links followed, $EXT_COUNT ext merges)"
269
+
270
+ policy_list_yaml() {
271
+ for KEY in "${!POLICY_FILES[@]}" ; do
272
+ /usr/bin/printf ' - %s: %s\n' "$KEY" "${POLICY_FILES[$KEY]}"
273
+ done | /usr/bin/sort
274
+ }
275
+
276
+ bot_list_yaml() {
277
+ if [[ "${#REVIEW_BOTS[@]}" -eq 0 ]] ; then
278
+ /usr/bin/printf ' - (none detected)\n'
279
+ else
280
+ for B in "${REVIEW_BOTS[@]}" ; do
281
+ /usr/bin/printf ' - %s\n' "$B"
282
+ done | /usr/bin/sort -u
283
+ fi
284
+ }
285
+
286
+ issue_templates_section() {
287
+ if [[ "${#ISSUE_TEMPLATES[@]}" -eq 0 ]] ; then
288
+ if [[ -n "${POLICY_FILES[ISSUE_TEMPLATE_LEGACY]:-}" ]] ; then
289
+ local TPL="${POLICY_FILES[ISSUE_TEMPLATE_LEGACY]}"
290
+ /usr/bin/printf -- '- Legacy single-template at `%s` ([view](https://github.com/%s/blob/%s/%s)) — fetch this and fill it in.\n' \
291
+ "$TPL" "$REPO" "$DEFAULT_BRANCH" "$TPL"
292
+ else
293
+ /usr/bin/echo "_No issue templates detected. The repo accepts free-form issue bodies. Fall back to a generic Design MD shape (problem / proposal / diff preview / test results)._"
294
+ fi
295
+ else
296
+ /usr/bin/echo "_When opening a Design Issue / bug / feature request, **fetch the matching template first** and fill in its sections — do NOT replace the structure. The repo's reviewers expect this shape._"
297
+ /usr/bin/echo
298
+ for T in "${ISSUE_TEMPLATES[@]}" ; do
299
+ /usr/bin/printf -- '- `%s` — [view](https://github.com/%s/blob/%s/.github/ISSUE_TEMPLATE/%s)\n' \
300
+ "$T" "$REPO" "$DEFAULT_BRANCH" "$T"
301
+ done
302
+ /usr/bin/echo
303
+ /usr/bin/echo "**To fetch a template body for filling in:**"
304
+ /usr/bin/echo
305
+ /usr/bin/echo '```bash'
306
+ /usr/bin/printf 'gh api "repos/%s/contents/.github/ISSUE_TEMPLATE/<name>" --jq .content | base64 -d\n' "$REPO"
307
+ /usr/bin/echo '```'
308
+ fi
309
+ }
310
+
311
+ linked_sources_yaml() {
312
+ if [[ "${#FOLLOWED_LINKS[@]}" -eq 0 ]] ; then
313
+ /usr/bin/printf ' - (none followed)\n'
314
+ else
315
+ for U in "${FOLLOWED_LINKS[@]}" ; do
316
+ T="${LINK_TITLES[$U]:-$U}"
317
+ # Escape double quotes for YAML safety.
318
+ T_ESC="${T//\"/\\\"}"
319
+ /usr/bin/printf ' - { url: "%s", title: "%s", fetched_at: "%s" }\n' "$U" "$T_ESC" "$NOW"
320
+ done
321
+ fi
322
+ }
323
+
324
+ # Resolve the output path. Default: ~/.contribute-system/research/<owner>__<repo>.md.
325
+ # Override via CONTRIBUTE_RESEARCH_DIR. The path is stable per repo so refresh
326
+ # overwrites the previous dossier — engineer-curated sections (## Pet peeves,
327
+ # ## Failure log, ## Notes) survive because the agent layer copies them
328
+ # forward; this script does not preserve them across refresh.
329
+ RESEARCH_DIR="${CONTRIBUTE_RESEARCH_DIR:-$HOME/.contribute-system/research}"
330
+ OUTPUT_FILE="$RESEARCH_DIR/$(/usr/bin/echo "$REPO" | /usr/bin/sed 's|/|__|').md"
331
+
332
+ # When not in --stdout mode, redirect all subsequent stdout to the dossier
333
+ # file. The two heredocs (DOSSIER and TAIL) and the awk excerpt block in
334
+ # between all write to stdout — `exec` here points stdout at the file once,
335
+ # so every subsequent emission lands in the right place.
336
+ if [[ "$TO_STDOUT" -eq 0 ]]; then
337
+ /usr/bin/mkdir -p "$RESEARCH_DIR"
338
+ exec > "$OUTPUT_FILE"
339
+ fi
340
+
341
+ /usr/bin/cat <<DOSSIER
342
+ ---
343
+ repo: $REPO
344
+ last_refreshed: $NOW
345
+ default_branch: $DEFAULT_BRANCH
346
+ archived: $ARCHIVED
347
+ stars: $STARS
348
+ language: $LANG
349
+ license: $LICENSE
350
+ last_pushed_at: $PUSHED_AT
351
+ external_merge_rate_90d: $EXT_COUNT
352
+ last_external_merge_at: $LAST_EXT
353
+ cla_required: $CLA_REQUIRED
354
+ dco_required: $DCO_REQUIRED
355
+ ai_disclosure_required: $AI_DISCLOSURE
356
+ conventional_commits: $CONVENTIONAL_COMMITS
357
+ etiquette_comment_required: $ETIQUETTE_REQUIRED
358
+ local_check_command: "${TEST_CMD:-(not detected)}"
359
+ policy_files:
360
+ $(policy_list_yaml)
361
+ issue_templates:
362
+ $(if [[ "${#ISSUE_TEMPLATES[@]}" -eq 0 ]] ; then
363
+ /usr/bin/printf ' - (none — repo has no .github/ISSUE_TEMPLATE/ dir)\n'
364
+ else
365
+ for T in "${ISSUE_TEMPLATES[@]}" ; do
366
+ /usr/bin/printf -- ' - { name: "%s", url: "https://github.com/%s/blob/%s/.github/ISSUE_TEMPLATE/%s" }\n' "$T" "$REPO" "$DEFAULT_BRANCH" "$T"
367
+ done
368
+ fi)
369
+ review_bots:
370
+ $(bot_list_yaml)
371
+ linked_sources:
372
+ $(linked_sources_yaml)
373
+ ---
374
+
375
+ # $REPO — rules of engagement
376
+
377
+ > Auto-generated by researcher-build.sh on $NOW. Refresh with \`@researcher refresh $REPO\` (or any time CONTRIBUTING changes upstream).
378
+
379
+ ## TL;DR
380
+
381
+ - $STARS ★ · $LANG · license: $LICENSE · default branch: \`$DEFAULT_BRANCH\`
382
+ - External merge velocity: **$EXT_COUNT** PRs in last 90d (last: $LAST_EXT)
383
+ - CLA: **$CLA_REQUIRED** · DCO: **$DCO_REQUIRED** · AI disclosure required: **$AI_DISCLOSURE**
384
+ - Etiquette comment required before claiming: **$ETIQUETTE_REQUIRED**
385
+ - Conventional Commits style: **$CONVENTIONAL_COMMITS**
386
+ - Local pre-PR command (detected): \`${TEST_CMD:-(none — read CONTRIBUTING)}\`
387
+
388
+ ## Description
389
+
390
+ $DESC
391
+
392
+ ## Policy file inventory
393
+
394
+ $(for KEY in "${!POLICY_FILES[@]}" ; do /usr/bin/printf -- '- **%s** → \`%s\` ([view](https://github.com/%s/blob/%s/%s))\n' "$KEY" "${POLICY_FILES[$KEY]}" "$REPO" "$DEFAULT_BRANCH" "${POLICY_FILES[$KEY]}" ; done | /usr/bin/sort)
395
+
396
+ ## CONTRIBUTING.md — key excerpts
397
+
398
+ DOSSIER
399
+
400
+ # Excerpt the most actionable headed sections from CONTRIBUTING
401
+ if [[ -s "$CONTRIB_RAW" ]] ; then
402
+ # Pull sections with names that hint at "things you must do"
403
+ /usr/bin/awk '
404
+ /^##+ / { keep = 0 }
405
+ /^##+ .*([Cc]hecklist|[Pp]re-PR|[Bb]efore|[Tt]esting|[Tt]est|[Ww]orkflow|[Pp]ull [Rr]equest|[Bb]ranch|[Cc]ommit|[Ss]tyle|[Ff]ormat|[Aa]I|[Cc]ode [Qq]uality|[Cc]onvention|[Hh]ow to [Cc]ontribute|[Rr]eview|[Cc]ontribution [Ff]low|[Rr]ules)/ { keep = 1 }
406
+ keep { print }
407
+ ' "$CONTRIB_RAW" | /usr/bin/head -150
408
+ /usr/bin/echo
409
+ /usr/bin/echo "_(excerpt only — full file: https://github.com/$REPO/blob/$DEFAULT_BRANCH/$CONTRIB_PATH)_"
410
+ else
411
+ /usr/bin/echo "_No CONTRIBUTING.md found at the repo root or .github/. Read CODE_OF_CONDUCT.md and the PR template instead._"
412
+ fi
413
+
414
+ /usr/bin/cat <<TAIL
415
+
416
+ ## Linked sources (depth-1 follow)
417
+
418
+ $(if [[ "${#FOLLOWED_LINKS[@]}" -eq 0 ]] ; then
419
+ /usr/bin/echo "_No links followed (none found in CONTRIBUTING)._"
420
+ else
421
+ for U in "${FOLLOWED_LINKS[@]}" ; do
422
+ T="${LINK_TITLES[$U]:-$U}"
423
+ /usr/bin/printf -- '- [%s](%s) — fetched %s\n' "$T" "$U" "$NOW"
424
+ done
425
+ fi)
426
+
427
+ ## Issue templates (use the matching one when opening a Design Issue or bug)
428
+
429
+ $(issue_templates_section)
430
+
431
+ ## Bots that auto-review on this repo (sampled from PR #${RECENT_PR:-?})
432
+
433
+ $(bot_list_yaml | /usr/bin/sed 's/^ - /- /')
434
+
435
+ ## Pet peeves & known triggers
436
+
437
+ _Specific things that get PRs closed at THIS repo. Manually + LLM-curated. Survives refresh — researcher does NOT auto-populate this section. Add an entry every time you observe a pet peeve in the wild._
438
+
439
+ - _(no entries yet — populate as observations land)_
440
+
441
+ ## Failure log
442
+
443
+ _Chronological record of past closures with reasons at this repo. Auto-appended on \`status: dropped\` transitions; never overwritten on refresh._
444
+
445
+ ## Notes
446
+
447
+ _Free-form area for the human to leave per-repo intuition. Survives refresh._
448
+
449
+ TAIL
450
+
451
+ # Success message goes to stderr so it's visible when stdout was redirected
452
+ # to the file. In --stdout mode, the user sees it inline before the dossier
453
+ # content (no — actually after, since heredocs flush before the script exits).
454
+ if [[ "$TO_STDOUT" -eq 0 ]]; then
455
+ log "→ wrote dossier to $OUTPUT_FILE"
456
+ fi
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # test-known-traps.sh — regression tests for the failure modes that motivated
3
+ # the gate system. Each test constructs a synthetic candidate file matching a
4
+ # known real-world trap and asserts the expected gate fires with the expected
5
+ # severity.
6
+ #
7
+ # Usage: test-known-traps.sh [--verbose]
8
+ # Exit 0: all tests pass. Exit 1: any test fails.
9
+ #
10
+ # Tests:
11
+ # 1. PostHog #55412 — already-shipped issue (gate A02 must BLOCK)
12
+ # 2. opensre #1129 — assigned issue (gate A01 must BLOCK)
13
+ # 3. closed issue — closed-not-planned (gate A05 must BLOCK)
14
+ # 4. clean candidate — no traps (transition must PASS or only SKIP)
15
+
16
+ set -uo pipefail
17
+
18
+ VERBOSE="${1:-}"
19
+ SYS="$HOME/.contribute-system"
20
+ TMPDIR=$(/usr/bin/mktemp -d)
21
+ trap 'rm -rf "$TMPDIR"' EXIT
22
+
23
+ PASS=0
24
+ FAIL=0
25
+ RESULTS=()
26
+
27
+ red() { /usr/bin/printf '\033[31m%s\033[0m' "$1"; }
28
+ green() { /usr/bin/printf '\033[32m%s\033[0m' "$1"; }
29
+ yellow() { /usr/bin/printf '\033[33m%s\033[0m' "$1"; }
30
+
31
+ # Helper: run transition.sh in dry-run mode against a candidate.
32
+ # Returns the verdict JSON on stdout.
33
+ run_transition() {
34
+ local action="$1" candidate="$2"
35
+ "$SYS/bin/transition.sh" "$action" "$candidate" --dry-run 2>/dev/null \
36
+ | /usr/bin/grep -E '^\{.*"verdict"' \
37
+ | /usr/bin/tail -1
38
+ }
39
+
40
+ # Helper: assert a specific gate ID appears with a specific severity in the
41
+ # verbose stderr output of transition.sh.
42
+ gate_fired_with_severity() {
43
+ local action="$1" candidate="$2" gate_id="$3" expected_sev="$4"
44
+ local stderr_output
45
+ stderr_output=$("$SYS/bin/transition.sh" "$action" "$candidate" --dry-run 2>&1 >/dev/null)
46
+ if [[ "$VERBOSE" == "--verbose" ]]; then
47
+ /usr/bin/printf '\n--- transition output for %s on %s ---\n%s\n---\n' \
48
+ "$action" "$(/usr/bin/basename "$candidate")" "$stderr_output" >&2
49
+ fi
50
+ /usr/bin/echo "$stderr_output" | /usr/bin/grep -qE "\[$gate_id\].*$expected_sev"
51
+ }
52
+
53
+ assert_gate() {
54
+ local name="$1" action="$2" candidate="$3" gate_id="$4" expected_sev="$5"
55
+ /usr/bin/printf ' %-50s ' "$name"
56
+ if gate_fired_with_severity "$action" "$candidate" "$gate_id" "$expected_sev"; then
57
+ green "PASS"; /usr/bin/echo
58
+ PASS=$((PASS + 1))
59
+ RESULTS+=("PASS: $name")
60
+ else
61
+ red "FAIL"; /usr/bin/echo
62
+ /usr/bin/printf ' expected gate %s severity %s — not found\n' "$gate_id" "$expected_sev" >&2
63
+ FAIL=$((FAIL + 1))
64
+ RESULTS+=("FAIL: $name (expected $gate_id $expected_sev)")
65
+ fi
66
+ }
67
+
68
+ # Build a synthetic candidate file. The frontmatter is what gates read.
69
+ # Body is unused for these tests.
70
+ make_candidate() {
71
+ local repo="$1" issue="$2" status="${3:-open}" path
72
+ path="$TMPDIR/synth_$(/usr/bin/echo "$repo" | /usr/bin/tr '/' '_')_$issue.md"
73
+ /usr/bin/cat > "$path" <<EOF
74
+ ---
75
+ discovered_at: 2026-05-03T00:00:00Z
76
+ repo: $repo
77
+ issue_number: $issue
78
+ issue_url: https://github.com/$repo/issues/$issue
79
+ star_tier: mainstream
80
+ star_count: 1000
81
+ repo_lang: TypeScript
82
+ competing_prs: 0
83
+ primary_label: bug
84
+ scout_score: 0.5
85
+ status: $status
86
+ last_refreshed: 2026-05-03T00:00:00Z
87
+ ---
88
+
89
+ # $repo #$issue — synthetic regression test candidate
90
+ EOF
91
+ /usr/bin/echo "$path"
92
+ }
93
+
94
+ /usr/bin/printf '\n=== contributing-clanker regression tests — known traps ===\n\n'
95
+
96
+ # ---- TEST 1: PostHog #55412 trap (already-shipped) ----
97
+ # The plan documents this as the originating trap for gate A02.
98
+ # Issue #55412 was closed by merged PR #57145. We need A02 to BLOCK.
99
+ /usr/bin/printf 'Test 1: PostHog #55412 already-shipped trap\n'
100
+ T1=$(make_candidate "PostHog/posthog" 55412)
101
+ assert_gate " A02 already-shipped MUST BLOCK" \
102
+ "shortlist→claimed" "$T1" "A02" "BLOCK"
103
+
104
+ # ---- TEST 2: Tracer-Cloud opensre #1129 trap (assigned) ----
105
+ # This issue was assigned to unKnownNG when scout found it. A01 must BLOCK.
106
+ /usr/bin/printf 'Test 2: Tracer-Cloud opensre #1129 assigned trap\n'
107
+ T2=$(make_candidate "Tracer-Cloud/opensre" 1129)
108
+ assert_gate " A01 already-assigned MUST BLOCK" \
109
+ "shortlist→claimed" "$T2" "A01" "BLOCK"
110
+
111
+ # ---- TEST 3: lingdojo/kana-dojo #15441 (closed not_planned) ----
112
+ # Verified empirically 2026-05-03 — A05 BLOCKs on closed issues.
113
+ /usr/bin/printf 'Test 3: lingdojo/kana-dojo #15441 closed-issue trap\n'
114
+ T3=$(make_candidate "lingdojo/kana-dojo" 15441)
115
+ assert_gate " A05 issue-still-open MUST BLOCK" \
116
+ "shortlist→claimed" "$T3" "A05" "BLOCK"
117
+
118
+ # ---- TEST 4: clean candidate (no traps) ----
119
+ # Pick a repo+issue that's currently OPEN and unassigned. We use a synthetic
120
+ # very-high issue number that won't match any real shipped PR.
121
+ # Note: this test runs against a real repo via gh; if the issue doesn't exist,
122
+ # the gates will produce ambiguous results. We pick something neutral.
123
+ /usr/bin/printf 'Test 4: clean candidate (open, unassigned, unshipped)\n'
124
+ T4=$(make_candidate "lingdojo/kana-dojo" 99999999)
125
+ # A01 should PASS or A03/A04/A05 — we just want NO unexpected BLOCKs from A1
126
+ # (which is the most common false-positive risk).
127
+ assert_gate " A01 should NOT block (issue 99999999 nonexistent)" \
128
+ "shortlist→claimed" "$T4" "A01" "(PASS|SKIP)"
129
+
130
+ /usr/bin/echo
131
+ /usr/bin/printf '=== summary: %s passed · %s failed ===\n\n' \
132
+ "$(green "$PASS")" "$([ "$FAIL" -gt 0 ] && red "$FAIL" || /usr/bin/echo 0)"
133
+
134
+ if [[ "$FAIL" -gt 0 ]]; then
135
+ /usr/bin/printf 'Failures:\n'
136
+ for R in "${RESULTS[@]}"; do
137
+ [[ "$R" == FAIL:* ]] && /usr/bin/printf ' %s\n' "$R"
138
+ done
139
+ exit 1
140
+ fi
141
+
142
+ exit 0