housebroken-cli 0.1.0__py3-none-any.whl
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.
- housebroken/__init__.py +1 -0
- housebroken/cli.py +92 -0
- housebroken/scripts/check-ai-policy.sh +109 -0
- housebroken/scripts/comment-census.sh +83 -0
- housebroken/scripts/distinct-outside.sh +85 -0
- housebroken/scripts/file-pr.sh +118 -0
- housebroken/scripts/fork-hygiene.sh +82 -0
- housebroken/scripts/housebroken +110 -0
- housebroken/scripts/pr-sweep.sh +75 -0
- housebroken/scripts/prior-art.sh +149 -0
- housebroken/scripts/verify-filed-pr.sh +90 -0
- housebroken/skill/SKILL.md +144 -0
- housebroken_cli-0.1.0.dist-info/METADATA +175 -0
- housebroken_cli-0.1.0.dist-info/RECORD +17 -0
- housebroken_cli-0.1.0.dist-info/WHEEL +4 -0
- housebroken_cli-0.1.0.dist-info/entry_points.txt +2 -0
- housebroken_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
housebroken/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
housebroken/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Console entry point: run the packaged bash dispatcher.
|
|
2
|
+
|
|
3
|
+
The gate scripts are bash and stay bash. This module only finds a bash, points
|
|
4
|
+
HOUSEBROKEN_SCRIPTS at the copy of the scripts inside the installed package,
|
|
5
|
+
and hands every argument to bin/housebroken unchanged. Three subcommands are
|
|
6
|
+
answered in Python because they are about the package, not about a gate:
|
|
7
|
+
version, skill-path and install-skill.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from importlib import metadata, resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DISTRIBUTION = "housebroken-cli"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_bash():
|
|
22
|
+
bash = shutil.which("bash")
|
|
23
|
+
if bash:
|
|
24
|
+
return bash
|
|
25
|
+
if os.name == "nt":
|
|
26
|
+
git = shutil.which("git")
|
|
27
|
+
if git:
|
|
28
|
+
git_root = Path(git).resolve().parent.parent
|
|
29
|
+
for candidate in (
|
|
30
|
+
git_root / "bin" / "bash.exe",
|
|
31
|
+
git_root / "usr" / "bin" / "bash.exe",
|
|
32
|
+
):
|
|
33
|
+
if candidate.is_file():
|
|
34
|
+
return str(candidate)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def package_version():
|
|
39
|
+
try:
|
|
40
|
+
return metadata.version(DISTRIBUTION)
|
|
41
|
+
except metadata.PackageNotFoundError:
|
|
42
|
+
from housebroken import __version__
|
|
43
|
+
|
|
44
|
+
return __version__
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_skill(skill_md):
|
|
48
|
+
target = Path(os.path.expanduser("~")) / ".claude" / "skills" / "housebroken" / "SKILL.md"
|
|
49
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
shutil.copyfile(skill_md, target)
|
|
51
|
+
print(f"installed {target}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
args = sys.argv[1:]
|
|
56
|
+
cmd = args[0] if args else "help"
|
|
57
|
+
|
|
58
|
+
if cmd == "version" or cmd == "--version":
|
|
59
|
+
print(f"housebroken {package_version()}")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
with contextlib.ExitStack() as stack:
|
|
63
|
+
root = stack.enter_context(resources.as_file(resources.files("housebroken")))
|
|
64
|
+
|
|
65
|
+
if cmd == "skill-path":
|
|
66
|
+
print(root / "skill" / "SKILL.md")
|
|
67
|
+
return 0
|
|
68
|
+
if cmd == "install-skill":
|
|
69
|
+
install_skill(root / "skill" / "SKILL.md")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
bash = find_bash()
|
|
73
|
+
if bash is None:
|
|
74
|
+
print(
|
|
75
|
+
"housebroken: no bash found. Install Git Bash on Windows "
|
|
76
|
+
"(https://git-scm.com/download/win) or bash on Unix, and make "
|
|
77
|
+
"sure bash is on PATH.",
|
|
78
|
+
file=sys.stderr,
|
|
79
|
+
)
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
scripts = root / "scripts"
|
|
83
|
+
env = dict(os.environ)
|
|
84
|
+
env["HOUSEBROKEN_SCRIPTS"] = str(scripts)
|
|
85
|
+
completed = subprocess.run(
|
|
86
|
+
[bash, str(scripts / "housebroken"), *args], env=env
|
|
87
|
+
)
|
|
88
|
+
return completed.returncode
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
sys.exit(main())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# check-ai-policy.sh - independently verify a repo's stance on AI-assisted
|
|
3
|
+
# contributions. Scouts report a policy; this reads it. Never adopt a target on
|
|
4
|
+
# a scout's description of its policy (cold-cohort-2026-08-09 lesson, applied to
|
|
5
|
+
# a new criterion).
|
|
6
|
+
#
|
|
7
|
+
# Checks a branch of the real repo, not a memory of it, and then the
|
|
8
|
+
# organization's .github repository, where an org-wide policy often lives.
|
|
9
|
+
# Prints every hit with its file so the operator reads the sentence, not a
|
|
10
|
+
# verdict. The branch is overridable because some projects keep the policy on a
|
|
11
|
+
# development branch and not on the default branch.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# ./check-ai-policy.sh [--branch <name>] owner/repo [owner/repo ...]
|
|
15
|
+
# ./check-ai-policy.sh --help
|
|
16
|
+
#
|
|
17
|
+
# Environment:
|
|
18
|
+
# HOUSEBROKEN_HOME work directory, default $HOME/.housebroken. Not written
|
|
19
|
+
# by this script; it prints to stdout only.
|
|
20
|
+
#
|
|
21
|
+
# Assumes gh is installed and authenticated. Read-only: GET calls only.
|
|
22
|
+
set -u
|
|
23
|
+
|
|
24
|
+
usage() {
|
|
25
|
+
cat <<'EOF'
|
|
26
|
+
usage: check-ai-policy.sh [--branch <name>] owner/repo [owner/repo ...]
|
|
27
|
+
check-ai-policy.sh --help
|
|
28
|
+
|
|
29
|
+
--branch <name> read the policy files from this branch instead of the
|
|
30
|
+
repository default branch; some projects keep the policy
|
|
31
|
+
on a development branch
|
|
32
|
+
|
|
33
|
+
environment:
|
|
34
|
+
HOUSEBROKEN_HOME work directory, default $HOME/.housebroken (unused here)
|
|
35
|
+
EOF
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
FILES="CONTRIBUTING.md CONTRIBUTING.rst CONTRIBUTING .github/CONTRIBUTING.md
|
|
39
|
+
docs/CONTRIBUTING.md docs/contributing.md CODE_OF_CONDUCT.md
|
|
40
|
+
.github/CODE_OF_CONDUCT.md README.md AI.md AI_POLICY.md POLICY.md
|
|
41
|
+
.github/AI_POLICY.md .github/AI.md .github/POLICY.md .github/PULL_REQUEST_TEMPLATE.md .github/pull_request_template.md
|
|
42
|
+
.github/ISSUE_TEMPLATE/config.yml CLAUDE.md AGENTS.md .cursorrules"
|
|
43
|
+
|
|
44
|
+
PATTERN='[Aa][Ii]-generated|[Aa]rtificial [Ii]ntelligence|\bLLM|[Ll]arge [Ll]anguage [Mm]odel|Copilot|ChatGPT|[Cc]laude|[Gg]enerative|AI assistance|AI-assisted|AI tool|AI slop|machine-generated'
|
|
45
|
+
|
|
46
|
+
branch_opt=""
|
|
47
|
+
repos=()
|
|
48
|
+
while [ "$#" -gt 0 ]; do
|
|
49
|
+
case "$1" in
|
|
50
|
+
--help|-h) usage; exit 0 ;;
|
|
51
|
+
--branch) shift; [ "$#" -gt 0 ] || { echo "check-ai-policy.sh: --branch needs a name" >&2; exit 1; }
|
|
52
|
+
branch_opt="$1" ;;
|
|
53
|
+
--branch=*) branch_opt="${1#--branch=}" ;;
|
|
54
|
+
--*) usage >&2; echo "check-ai-policy.sh: unknown option $1" >&2; exit 1 ;;
|
|
55
|
+
*/*) repos+=("$1") ;;
|
|
56
|
+
*) usage >&2; echo "check-ai-policy.sh: '$1' is not owner/repo" >&2; exit 1 ;;
|
|
57
|
+
esac
|
|
58
|
+
shift
|
|
59
|
+
done
|
|
60
|
+
[ "${#repos[@]}" -gt 0 ] || { usage >&2; exit 1; }
|
|
61
|
+
|
|
62
|
+
command -v gh >/dev/null 2>&1 || { echo "check-ai-policy.sh: gh is not on PATH" >&2; exit 1; }
|
|
63
|
+
|
|
64
|
+
# print every policy hit in one repo at one ref; returns 1 when nothing matched
|
|
65
|
+
scan() {
|
|
66
|
+
local target="$1" ref="$2" label="$3" found=0 f body hits
|
|
67
|
+
for f in $FILES; do
|
|
68
|
+
body=$(gh api "repos/$target/contents/$f?ref=$ref" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null)
|
|
69
|
+
[ -z "$body" ] && continue
|
|
70
|
+
hits=$(printf '%s' "$body" | grep -nEi "$PATTERN" | head -12)
|
|
71
|
+
if [ -n "$hits" ]; then
|
|
72
|
+
found=1
|
|
73
|
+
echo " --- $label$f ---"
|
|
74
|
+
printf '%s\n' "$hits" | cut -c1-300 | sed 's/^/ /'
|
|
75
|
+
else
|
|
76
|
+
echo " (clean) $label$f"
|
|
77
|
+
fi
|
|
78
|
+
done
|
|
79
|
+
[ "$found" -eq 1 ]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
status=0
|
|
83
|
+
for repo in "${repos[@]}"; do
|
|
84
|
+
echo "=================================================================="
|
|
85
|
+
echo "$repo"
|
|
86
|
+
branch="$branch_opt"
|
|
87
|
+
if [ -z "$branch" ]; then
|
|
88
|
+
branch=$(gh api "repos/$repo" --jq .default_branch 2>/dev/null) || branch=""
|
|
89
|
+
if [ -z "$branch" ]; then
|
|
90
|
+
echo "check-ai-policy.sh: cannot read $repo from GitHub" >&2
|
|
91
|
+
status=1
|
|
92
|
+
continue
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
echo " branch: $branch"
|
|
96
|
+
found_any=0
|
|
97
|
+
scan "$repo" "$branch" "" && found_any=1
|
|
98
|
+
|
|
99
|
+
# an org-wide policy often lives in the organization's .github repository
|
|
100
|
+
org="${repo%%/*}"
|
|
101
|
+
org_branch=$(gh api "repos/$org/.github" --jq .default_branch 2>/dev/null) || org_branch=""
|
|
102
|
+
if [ -n "$org_branch" ]; then
|
|
103
|
+
echo " org repo: $org/.github ($org_branch)"
|
|
104
|
+
scan "$org/.github" "$org_branch" "$org/.github: " && found_any=1
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
[ "$found_any" -eq 0 ] && echo " RESULT: no AI/LLM language in any policy file checked"
|
|
108
|
+
done
|
|
109
|
+
exit "$status"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# comment-census.sh - added code lines against added comment lines, per branch.
|
|
3
|
+
#
|
|
4
|
+
# A patch that carries a paragraph of comments into a file that has none reads
|
|
5
|
+
# as machine-written and gets closed. This counts, for every branch in every
|
|
6
|
+
# clone under a directory, the added non-comment lines and the added comment
|
|
7
|
+
# lines, so a pull request can be made to match the comment density of the file
|
|
8
|
+
# it lands in. Three maintainers asked for exactly this, in three separate
|
|
9
|
+
# reviews, before it became a script.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# bash comment-census.sh [CLONE_ROOT]
|
|
13
|
+
# bash comment-census.sh --help
|
|
14
|
+
#
|
|
15
|
+
# CLONE_ROOT is any directory whose immediate subdirectories are git clones.
|
|
16
|
+
# Defaults to $HOUSEBROKEN_CLONES, then to $HOUSEBROKEN_HOME/clones.
|
|
17
|
+
#
|
|
18
|
+
# Environment:
|
|
19
|
+
# HOUSEBROKEN_CLONES default clone root
|
|
20
|
+
# HOUSEBROKEN_HOME workshop root, default $HOME/.housebroken
|
|
21
|
+
#
|
|
22
|
+
# Assumes: each clone has an origin remote whose default branch is discoverable
|
|
23
|
+
# (origin/HEAD, else origin/main, else origin/master); every other local branch
|
|
24
|
+
# is a candidate patch. Tests, markdown and lock files are excluded from the
|
|
25
|
+
# code and comment counts. Comment detection covers //, /* */, * and #.
|
|
26
|
+
set -u
|
|
27
|
+
|
|
28
|
+
usage() {
|
|
29
|
+
sed -n '2,/^set -u$/p' "$0" | sed 's/^# \{0,1\}//; /^set -u$/d'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
case "${1:-}" in
|
|
33
|
+
--help|-h) usage; exit 0 ;;
|
|
34
|
+
esac
|
|
35
|
+
|
|
36
|
+
HOUSEBROKEN_HOME="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
|
|
37
|
+
root="${1:-${HOUSEBROKEN_CLONES:-$HOUSEBROKEN_HOME/clones}}"
|
|
38
|
+
|
|
39
|
+
if [ ! -d "$root" ]; then
|
|
40
|
+
echo "comment-census.sh: not a directory: $root" >&2
|
|
41
|
+
echo "give a clone root as an argument or set HOUSEBROKEN_CLONES" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
printf '%-28s %-36s %5s %5s %5s\n' repo branch code cmnt tests
|
|
46
|
+
|
|
47
|
+
found=0
|
|
48
|
+
for d in "$root"/*/; do
|
|
49
|
+
[ -d "$d/.git" ] || continue
|
|
50
|
+
found=1
|
|
51
|
+
name=$(basename "$d")
|
|
52
|
+
base=$(git -C "$d" symbolic-ref -q --short refs/remotes/origin/HEAD || true)
|
|
53
|
+
if [ -z "$base" ]; then
|
|
54
|
+
if git -C "$d" rev-parse --verify -q origin/main >/dev/null; then
|
|
55
|
+
base=origin/main
|
|
56
|
+
elif git -C "$d" rev-parse --verify -q origin/master >/dev/null; then
|
|
57
|
+
base=origin/master
|
|
58
|
+
else
|
|
59
|
+
echo "comment-census.sh: no default branch in $d, skipped" >&2
|
|
60
|
+
continue
|
|
61
|
+
fi
|
|
62
|
+
fi
|
|
63
|
+
default_branch="${base#origin/}"
|
|
64
|
+
while read -r br; do
|
|
65
|
+
[ -n "$br" ] || continue
|
|
66
|
+
[ "$br" = "$default_branch" ] && continue
|
|
67
|
+
[ "$(git -C "$d" rev-list --count "$base..$br")" = "0" ] && continue
|
|
68
|
+
added=$(git -C "$d" diff "$base..$br" -- . ':(exclude)*test*' ':(exclude)*Test*' ':(exclude)*_test.go' ':(exclude)Tests/*' ':(exclude)*.md' ':(exclude)*.sum' | grep '^+' | grep -v '^+++' || true)
|
|
69
|
+
if [ -z "$added" ]; then
|
|
70
|
+
code=0; cmnt=0
|
|
71
|
+
else
|
|
72
|
+
code=$(printf '%s\n' "$added" | grep -v -E '^\+\s*(//|/\*|\*|#|///|\*/)' | grep -c -v -E '^\+\s*$' || true)
|
|
73
|
+
cmnt=$(printf '%s\n' "$added" | grep -c -E '^\+\s*(//|/\*|\*|///|\*/)' || true)
|
|
74
|
+
fi
|
|
75
|
+
tests=$(git -C "$d" diff "$base..$br" --numstat | awk '/[Tt]est/{s+=$1} END{print s+0}')
|
|
76
|
+
printf '%-28s %-36s %5s %5s %5s\n' "$name" "$br" "$code" "$cmnt" "$tests"
|
|
77
|
+
done < <(git -C "$d" for-each-ref --format='%(refname:short)' refs/heads/)
|
|
78
|
+
done
|
|
79
|
+
|
|
80
|
+
if [ "$found" = 0 ]; then
|
|
81
|
+
echo "comment-census.sh: no git clones found under $root" >&2
|
|
82
|
+
exit 1
|
|
83
|
+
fi
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# distinct-outside.sh - distinct outside humans whose PRs were merged in the
|
|
3
|
+
# last N days. A project that has merged none is closed to outsiders whatever
|
|
4
|
+
# its README says, so this number is read before a target is adopted.
|
|
5
|
+
#
|
|
6
|
+
# Outside means the PR's author_association is CONTRIBUTOR, FIRST_TIMER,
|
|
7
|
+
# FIRST_TIME_CONTRIBUTOR or NONE; everything else is counted as team. Known bot
|
|
8
|
+
# accounts are dropped. Only the two most recently updated pages of closed PRs
|
|
9
|
+
# are read, so a very busy repo is a lower bound on a long window.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# bash distinct-outside.sh [--days N] owner/repo [owner/repo ...]
|
|
13
|
+
# bash distinct-outside.sh --help
|
|
14
|
+
#
|
|
15
|
+
# Environment:
|
|
16
|
+
# HOUSEBROKEN_HOME work directory, default $HOME/.housebroken. The PR pages
|
|
17
|
+
# are staged in its cache/ subdirectory, created if missing.
|
|
18
|
+
#
|
|
19
|
+
# Assumes gh is installed and authenticated, and jq is on PATH. Read-only: GET
|
|
20
|
+
# calls only.
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
usage() {
|
|
24
|
+
cat <<'EOF'
|
|
25
|
+
usage: distinct-outside.sh [--days N] owner/repo [owner/repo ...]
|
|
26
|
+
distinct-outside.sh --help
|
|
27
|
+
|
|
28
|
+
--days N window in days, default 120
|
|
29
|
+
|
|
30
|
+
environment:
|
|
31
|
+
HOUSEBROKEN_HOME work directory, default $HOME/.housebroken
|
|
32
|
+
EOF
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
days=120
|
|
36
|
+
repos=()
|
|
37
|
+
while [ "$#" -gt 0 ]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--help|-h) usage; exit 0 ;;
|
|
40
|
+
--days) shift; [ "$#" -gt 0 ] || { echo "distinct-outside.sh: --days needs a number" >&2; exit 1; }
|
|
41
|
+
days="$1" ;;
|
|
42
|
+
--days=*) days="${1#--days=}" ;;
|
|
43
|
+
--*) usage >&2; echo "distinct-outside.sh: unknown option $1" >&2; exit 1 ;;
|
|
44
|
+
*/*) repos+=("$1") ;;
|
|
45
|
+
*) usage >&2; echo "distinct-outside.sh: '$1' is not owner/repo" >&2; exit 1 ;;
|
|
46
|
+
esac
|
|
47
|
+
shift
|
|
48
|
+
done
|
|
49
|
+
[ "${#repos[@]}" -gt 0 ] || { usage >&2; exit 1; }
|
|
50
|
+
case "$days" in
|
|
51
|
+
''|*[!0-9]*) echo "distinct-outside.sh: --days must be a whole number, got '$days'" >&2; exit 1 ;;
|
|
52
|
+
esac
|
|
53
|
+
|
|
54
|
+
command -v gh >/dev/null 2>&1 || { echo "distinct-outside.sh: gh is not on PATH" >&2; exit 1; }
|
|
55
|
+
command -v jq >/dev/null 2>&1 || { echo "distinct-outside.sh: jq is not on PATH" >&2; exit 1; }
|
|
56
|
+
|
|
57
|
+
home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
|
|
58
|
+
cache="$home/cache"
|
|
59
|
+
mkdir -p "$cache" || { echo "distinct-outside.sh: cannot create $cache" >&2; exit 1; }
|
|
60
|
+
|
|
61
|
+
cutoff=$(date -u -d "$days days ago" '+%Y-%m-%dT%H:%M:%SZ') ||
|
|
62
|
+
{ echo "distinct-outside.sh: cannot compute the cutoff date" >&2; exit 1; }
|
|
63
|
+
|
|
64
|
+
for repo in "${repos[@]}"; do
|
|
65
|
+
json="$cache/prs-${repo%%/*}-${repo##*/}.json"
|
|
66
|
+
: >"$json"
|
|
67
|
+
for page in 1 2; do
|
|
68
|
+
gh api "repos/$repo/pulls?state=closed&per_page=100&sort=updated&direction=desc&page=$page" >>"$json" ||
|
|
69
|
+
{ echo "distinct-outside.sh: cannot list closed PRs for $repo" >&2; exit 1; }
|
|
70
|
+
done
|
|
71
|
+
jq -s -r --arg repo "$repo" --arg cutoff "$cutoff" --arg days "$days" '
|
|
72
|
+
def bot: ascii_downcase | test("dependabot|renovate|github-actions|pre-commit-ci|allcontributors|snyk|\\[bot\\]");
|
|
73
|
+
def tally: reduce .[] as $l ({}; .[$l] += 1);
|
|
74
|
+
(add // [])
|
|
75
|
+
| map(select(.merged_at != null and .merged_at >= $cutoff))
|
|
76
|
+
| map(select((.user.login // "") | bot | not))
|
|
77
|
+
| (map(select(.author_association as $a
|
|
78
|
+
| ["CONTRIBUTOR","FIRST_TIME_CONTRIBUTOR","FIRST_TIMER","NONE"] | index($a)))
|
|
79
|
+
| map(.user.login) | tally) as $out
|
|
80
|
+
| (map(select(.author_association as $a
|
|
81
|
+
| ["CONTRIBUTOR","FIRST_TIME_CONTRIBUTOR","FIRST_TIMER","NONE"] | index($a) | not))
|
|
82
|
+
| map(.user.login) | tally) as $team
|
|
83
|
+
| "\($repo): \($out | length) distinct outside humans in \($days)d -> \($out | tojson) | team merges \($team | tojson)"
|
|
84
|
+
' "$json" || { echo "distinct-outside.sh: cannot parse the PR list for $repo" >&2; exit 1; }
|
|
85
|
+
done
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# file-pr.sh - the only way a PR gets filed: gh pr create behind two gates.
|
|
3
|
+
#
|
|
4
|
+
# Gate one is prior art. typer #1946 (closed 2026-08-31) duplicated an open PR
|
|
5
|
+
# and swift-http-types #153 (closed 2026-09-04) reargued a closed issue, both
|
|
6
|
+
# because the check was a memory rather than a file. This refuses unless
|
|
7
|
+
# prior-art-<owner>-<repo>.md exists under $HOUSEBROKEN_HOME/prior-art/ and is
|
|
8
|
+
# under 24 hours old.
|
|
9
|
+
# Gate two is the prose. Tool footers, session trailers and em or en dashes have
|
|
10
|
+
# no place in somebody else's repository, so a body carrying one is refused.
|
|
11
|
+
# Written 2026-09-07.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# bash file-pr.sh owner/repo <gh pr create args...>
|
|
15
|
+
# FILE_PR_DRY=1 bash file-pr.sh owner/repo ... # print the command, run nothing
|
|
16
|
+
# bash file-pr.sh --help
|
|
17
|
+
#
|
|
18
|
+
# Environment:
|
|
19
|
+
# HOUSEBROKEN_HOME work directory, default $HOME/.housebroken. The prior art
|
|
20
|
+
# is read from its prior-art/ subdirectory, where
|
|
21
|
+
# prior-art.sh --out puts it.
|
|
22
|
+
# FILE_PR_DRY set to 1 to print the gh command instead of running it.
|
|
23
|
+
#
|
|
24
|
+
# Assumes gh is installed and authenticated. This is the one script here that
|
|
25
|
+
# writes to GitHub, and only after both gates pass and only when FILE_PR_DRY
|
|
26
|
+
# is not 1.
|
|
27
|
+
set -u
|
|
28
|
+
|
|
29
|
+
usage() {
|
|
30
|
+
cat <<'EOF'
|
|
31
|
+
usage: file-pr.sh owner/repo <gh pr create args...>
|
|
32
|
+
file-pr.sh --help
|
|
33
|
+
|
|
34
|
+
owner/repo the upstream repository the PR is filed against
|
|
35
|
+
the remaining arguments are passed to gh pr create unchanged
|
|
36
|
+
|
|
37
|
+
gates:
|
|
38
|
+
prior art $HOUSEBROKEN_HOME/prior-art/prior-art-<owner>-<repo>.md must
|
|
39
|
+
exist and be under 24 hours old
|
|
40
|
+
prose no tool footer, session trailer, co-author trailer, em dash or
|
|
41
|
+
en dash in --body or --body-file
|
|
42
|
+
|
|
43
|
+
environment:
|
|
44
|
+
HOUSEBROKEN_HOME work directory, default $HOME/.housebroken
|
|
45
|
+
FILE_PR_DRY 1 prints the gh command and files nothing
|
|
46
|
+
EOF
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if [ "$#" -eq 0 ] || [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
|
|
50
|
+
usage
|
|
51
|
+
[ "$#" -eq 0 ] && exit 2
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
command -v gh >/dev/null 2>&1 || { echo "REFUSED: gh is not on PATH" >&2; exit 2; }
|
|
56
|
+
|
|
57
|
+
repo="$1"
|
|
58
|
+
shift
|
|
59
|
+
case "$repo" in
|
|
60
|
+
*/*) : ;;
|
|
61
|
+
*) echo "REFUSED: first argument must be owner/repo" >&2; exit 2 ;;
|
|
62
|
+
esac
|
|
63
|
+
[ "$#" -gt 0 ] || { echo "REFUSED: no gh pr create arguments given" >&2; exit 2; }
|
|
64
|
+
|
|
65
|
+
home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
|
|
66
|
+
art="$home/prior-art/prior-art-${repo%%/*}-${repo##*/}.md"
|
|
67
|
+
if [ ! -f "$art" ]; then
|
|
68
|
+
echo "REFUSED: no prior art at $art - run prior-art.sh $repo <terms> --out first" >&2
|
|
69
|
+
exit 2
|
|
70
|
+
fi
|
|
71
|
+
now=$(date +%s)
|
|
72
|
+
made=$(date -r "$art" +%s) || { echo "REFUSED: cannot read the age of $art" >&2; exit 2; }
|
|
73
|
+
if [ $((now - made)) -gt 86400 ]; then
|
|
74
|
+
echo "REFUSED: prior art $art is $(( (now - made) / 3600 ))h old - rerun prior-art.sh --out" >&2
|
|
75
|
+
exit 2
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
em=$(printf '\xe2\x80\x94') # em dash U+2014, built at runtime to keep this file ASCII
|
|
79
|
+
en=$(printf '\xe2\x80\x93') # en dash U+2013
|
|
80
|
+
banned_re="Generated with|Claude-Session|Co-Authored-By|$em|$en"
|
|
81
|
+
check_text() {
|
|
82
|
+
local what="$1" text="$2" hit
|
|
83
|
+
hit=$(printf '%s' "$text" | grep -oE "$banned_re" | head -1) || true
|
|
84
|
+
if [ -n "$hit" ]; then
|
|
85
|
+
echo "REFUSED: $what contains '$hit' - strip it before filing" >&2
|
|
86
|
+
exit 2
|
|
87
|
+
fi
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
prev=""
|
|
91
|
+
for arg in "$@"; do
|
|
92
|
+
case "$prev" in
|
|
93
|
+
--body-file|-F)
|
|
94
|
+
[ -f "$arg" ] || { echo "REFUSED: body file $arg does not exist" >&2; exit 2; }
|
|
95
|
+
check_text "body file $arg" "$(cat "$arg")"
|
|
96
|
+
;;
|
|
97
|
+
--body|-b)
|
|
98
|
+
check_text "--body" "$arg"
|
|
99
|
+
;;
|
|
100
|
+
esac
|
|
101
|
+
case "$arg" in
|
|
102
|
+
--body-file=*)
|
|
103
|
+
f="${arg#--body-file=}"
|
|
104
|
+
[ -f "$f" ] || { echo "REFUSED: body file $f does not exist" >&2; exit 2; }
|
|
105
|
+
check_text "body file $f" "$(cat "$f")"
|
|
106
|
+
;;
|
|
107
|
+
--body=*) check_text "--body" "${arg#--body=}" ;;
|
|
108
|
+
esac
|
|
109
|
+
prev="$arg"
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
if [ "${FILE_PR_DRY:-0}" = "1" ]; then
|
|
113
|
+
printf 'DRY RUN, not filed:\ngh pr create -R %s' "$repo"
|
|
114
|
+
for arg in "$@"; do printf ' %q' "$arg"; done
|
|
115
|
+
printf '\n'
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
gh pr create -R "$repo" "$@"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# fork-hygiene.sh - clean up after ourselves on GitHub (rule adopted 2026-09-06):
|
|
3
|
+
# delete the fork branch of every upstream PR that is merged or closed, and list
|
|
4
|
+
# the forks that carry no PR at all, which are deleted by hand when the work is
|
|
5
|
+
# over. Never touches the branch of an open PR, never touches our own repositories.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# bash fork-hygiene.sh # deletes the branches it names
|
|
9
|
+
# bash fork-hygiene.sh --dry-run # prints what it would delete, deletes nothing
|
|
10
|
+
# bash fork-hygiene.sh --help
|
|
11
|
+
#
|
|
12
|
+
# Environment:
|
|
13
|
+
# HOUSEBROKEN_USER GitHub login whose forks are cleaned, default the
|
|
14
|
+
# authenticated user
|
|
15
|
+
#
|
|
16
|
+
# Assumes: gh is installed and authenticated with delete access to the fork,
|
|
17
|
+
# that our forks live under that login, and that a closed or merged PR's head
|
|
18
|
+
# branch has no other use. -n is accepted as a synonym for --dry-run.
|
|
19
|
+
set -u
|
|
20
|
+
|
|
21
|
+
usage() {
|
|
22
|
+
sed -n '2,/^set -u$/p' "$0" | sed 's/^# \{0,1\}//; /^set -u$/d'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
DRY=0
|
|
26
|
+
case "${1:-}" in
|
|
27
|
+
--help|-h) usage; exit 0 ;;
|
|
28
|
+
--dry-run|-n) DRY=1 ;;
|
|
29
|
+
"") ;;
|
|
30
|
+
*) echo "fork-hygiene.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
ME="${HOUSEBROKEN_USER:-$(gh api user --jq .login)}"
|
|
34
|
+
if [ -z "$ME" ]; then
|
|
35
|
+
echo "fork-hygiene.sh: cannot determine the GitHub login; set HOUSEBROKEN_USER" >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
if ! closed="$(gh search prs --author "$ME" --state closed --json repository,number --limit 100 \
|
|
40
|
+
--jq '.[] | "\(.repository.nameWithOwner) \(.number)"')"; then
|
|
41
|
+
echo "fork-hygiene.sh: gh search prs failed for author $ME" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
while read -r repo n; do
|
|
46
|
+
[ -n "$repo" ] || continue
|
|
47
|
+
case "$repo" in "$ME"/*) continue ;; esac
|
|
48
|
+
info=$(gh pr view "$n" --repo "$repo" --json headRefName,headRepository,headRepositoryOwner,state \
|
|
49
|
+
--jq '"\(.headRepositoryOwner.login)|\(.headRepository.name)|\(.headRefName)|\(.state)"' 2>/dev/null) || {
|
|
50
|
+
echo "fork-hygiene.sh: gh pr view $repo#$n failed" >&2
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
IFS='|' read -r owner fork branch state <<< "$info"
|
|
54
|
+
if [ "$owner" != "$ME" ] || [ -z "$branch" ]; then continue; fi
|
|
55
|
+
gh api "repos/$ME/$fork/git/refs/heads/$branch" >/dev/null 2>&1 || continue
|
|
56
|
+
if [ "$DRY" = 1 ]; then
|
|
57
|
+
echo "would delete $ME/$fork $branch ($state $repo#$n)"
|
|
58
|
+
continue
|
|
59
|
+
fi
|
|
60
|
+
if gh api -X DELETE "repos/$ME/$fork/git/refs/heads/$branch" >/dev/null 2>&1; then
|
|
61
|
+
echo "deleted $ME/$fork $branch ($state $repo#$n)"
|
|
62
|
+
else
|
|
63
|
+
echo "FAILED $ME/$fork $branch" >&2
|
|
64
|
+
fi
|
|
65
|
+
done <<< "$closed"
|
|
66
|
+
|
|
67
|
+
echo "--- forks with no PR (delete by hand when a wave is over):"
|
|
68
|
+
forks=$(mktemp) || { echo "fork-hygiene.sh: mktemp failed" >&2; exit 1; }
|
|
69
|
+
prrepos=$(mktemp) || { echo "fork-hygiene.sh: mktemp failed" >&2; exit 1; }
|
|
70
|
+
trap 'rm -f "$forks" "$prrepos"' EXIT
|
|
71
|
+
|
|
72
|
+
if ! gh repo list "$ME" --fork --limit 200 --json name,parent \
|
|
73
|
+
--jq '.[] | "\(.parent.owner.login)/\(.parent.name)"' | sort -u > "$forks"; then
|
|
74
|
+
echo "fork-hygiene.sh: gh repo list failed for $ME" >&2
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
if ! gh search prs --author "$ME" --json repository --limit 200 \
|
|
78
|
+
--jq '.[].repository.nameWithOwner' | sort -u > "$prrepos"; then
|
|
79
|
+
echo "fork-hygiene.sh: gh search prs failed for author $ME" >&2
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
comm -23 "$forks" "$prrepos" | tr '\n' ' '; echo
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# housebroken - one command name for the eight gate scripts.
|
|
3
|
+
#
|
|
4
|
+
# The skill file, and any future adapter for another agent host, must be able
|
|
5
|
+
# to say `housebroken prior-art owner/repo term` without knowing where the
|
|
6
|
+
# scripts live. This dispatcher is that stable name: it finds the scripts
|
|
7
|
+
# directory, maps a short subcommand to a script, and hands over every
|
|
8
|
+
# remaining argument unchanged. Nothing else. No gate logic lives here.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# housebroken <subcommand> [args...]
|
|
12
|
+
# housebroken help
|
|
13
|
+
# housebroken version
|
|
14
|
+
#
|
|
15
|
+
# Environment:
|
|
16
|
+
# HOUSEBROKEN_SCRIPTS directory holding the eight *.sh gate scripts. When
|
|
17
|
+
# unset, the scripts directory next to this file
|
|
18
|
+
# (../scripts), then this file's own directory, then
|
|
19
|
+
# $HOUSEBROKEN_HOME/scripts are tried in that order.
|
|
20
|
+
# HOUSEBROKEN_HOME workshop root, default $HOME/.housebroken. The gate
|
|
21
|
+
# scripts read it too; it is passed through untouched.
|
|
22
|
+
#
|
|
23
|
+
# Assumes bash. The scripts themselves assume gh and jq.
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
self_dir=$(cd "$(dirname "$0")" && pwd)
|
|
27
|
+
HOUSEBROKEN_HOME="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
|
|
28
|
+
|
|
29
|
+
candidates=()
|
|
30
|
+
if [ -n "${HOUSEBROKEN_SCRIPTS:-}" ]; then
|
|
31
|
+
candidates+=("$HOUSEBROKEN_SCRIPTS")
|
|
32
|
+
fi
|
|
33
|
+
candidates+=("$self_dir/../scripts" "$self_dir" "$HOUSEBROKEN_HOME/scripts")
|
|
34
|
+
|
|
35
|
+
scripts_dir=""
|
|
36
|
+
for d in "${candidates[@]}"; do
|
|
37
|
+
if [ -f "$d/prior-art.sh" ]; then
|
|
38
|
+
scripts_dir=$(cd "$d" && pwd)
|
|
39
|
+
break
|
|
40
|
+
fi
|
|
41
|
+
done
|
|
42
|
+
|
|
43
|
+
door() {
|
|
44
|
+
cat <<'EOF'
|
|
45
|
+
usage: housebroken <subcommand> [args...]
|
|
46
|
+
|
|
47
|
+
1 policy read the repository's AI-contribution policy where it lives
|
|
48
|
+
1 outside count outside contributors merged in the last 120 days
|
|
49
|
+
2 prior-art issues and pull requests on the touched files, rulings quoted
|
|
50
|
+
6 census added code lines against added comment lines, per branch
|
|
51
|
+
9 file the only way a pull request gets filed
|
|
52
|
+
10 verify the filed pull request is what was meant, and CI settled
|
|
53
|
+
10 sweep every open pull request where the ball is in your court
|
|
54
|
+
11 hygiene delete fork branches after merge or close, list orphan forks
|
|
55
|
+
|
|
56
|
+
help this list
|
|
57
|
+
version the installed housebroken version
|
|
58
|
+
|
|
59
|
+
Step numbers are the steps of "How it works" in the README. Every argument
|
|
60
|
+
after the subcommand is passed to the script unchanged.
|
|
61
|
+
EOF
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
version() {
|
|
65
|
+
vpaths=("$self_dir/../VERSION" "$self_dir/VERSION")
|
|
66
|
+
if [ -n "$scripts_dir" ]; then
|
|
67
|
+
vpaths=("$scripts_dir/../VERSION" "$scripts_dir/VERSION" "${vpaths[@]}")
|
|
68
|
+
fi
|
|
69
|
+
for v in "${vpaths[@]}"; do
|
|
70
|
+
if [ -f "$v" ]; then
|
|
71
|
+
head -1 "$v"
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
done
|
|
75
|
+
echo "housebroken (unversioned)"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
cmd="${1:-help}"
|
|
79
|
+
case "$cmd" in
|
|
80
|
+
help|--help|-h) door; exit 0 ;;
|
|
81
|
+
version|--version) version; exit 0 ;;
|
|
82
|
+
esac
|
|
83
|
+
shift
|
|
84
|
+
|
|
85
|
+
case "$cmd" in
|
|
86
|
+
policy) script=check-ai-policy.sh ;;
|
|
87
|
+
outside) script=distinct-outside.sh ;;
|
|
88
|
+
prior-art) script=prior-art.sh ;;
|
|
89
|
+
census) script=comment-census.sh ;;
|
|
90
|
+
file) script=file-pr.sh ;;
|
|
91
|
+
verify) script=verify-filed-pr.sh ;;
|
|
92
|
+
sweep) script=pr-sweep.sh ;;
|
|
93
|
+
hygiene) script=fork-hygiene.sh ;;
|
|
94
|
+
*)
|
|
95
|
+
echo "housebroken: unknown subcommand: $cmd (try: housebroken help)" >&2
|
|
96
|
+
exit 2
|
|
97
|
+
;;
|
|
98
|
+
esac
|
|
99
|
+
|
|
100
|
+
if [ -z "$scripts_dir" ] || [ ! -f "$scripts_dir/$script" ]; then
|
|
101
|
+
{
|
|
102
|
+
echo "housebroken: cannot find $script. Looked in:"
|
|
103
|
+
for d in "${candidates[@]}"; do echo " $d"; done
|
|
104
|
+
echo "Set HOUSEBROKEN_SCRIPTS to the directory holding the gate scripts."
|
|
105
|
+
} >&2
|
|
106
|
+
exit 2
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
export HOUSEBROKEN_HOME
|
|
110
|
+
exec bash "$scripts_dir/$script" "$@"
|