housebroken-cli 0.6.9__tar.gz → 0.7.0__tar.gz

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 (28) hide show
  1. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/PKG-INFO +13 -10
  2. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/README.md +12 -9
  3. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/bin/housebroken +2 -0
  4. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/pyproject.toml +1 -1
  5. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/branch-check.sh +111 -12
  6. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/check-ai-policy.sh +80 -19
  7. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/file-issue.sh +10 -2
  8. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/file-pr.sh +80 -2
  9. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/fork-hygiene.sh +11 -0
  10. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/prior-art.sh +43 -10
  11. housebroken_cli-0.7.0/scripts/ref-check.sh +114 -0
  12. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/review.sh +16 -1
  13. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/verify-filed-pr.sh +30 -0
  14. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/skills/housebroken/SKILL.md +47 -21
  15. housebroken_cli-0.7.0/src/housebroken/__init__.py +1 -0
  16. housebroken_cli-0.6.9/src/housebroken/__init__.py +0 -1
  17. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/.gitignore +0 -0
  18. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/LICENSE +0 -0
  19. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/hatch_build.py +0 -0
  20. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/claim-check.sh +0 -0
  21. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/comment-census.sh +0 -0
  22. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/comment-check.sh +0 -0
  23. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/diff-defaults.sh +0 -0
  24. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/distinct-outside.sh +0 -0
  25. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/inbox.sh +0 -0
  26. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/notes.sh +0 -0
  27. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/scripts/pr-sweep.sh +0 -0
  28. {housebroken_cli-0.6.9 → housebroken_cli-0.7.0}/src/housebroken/cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: housebroken-cli
3
- Version: 0.6.9
3
+ Version: 0.7.0
4
4
  Summary: Agent-written pull requests that do not make a mess in someone else's house.
5
5
  Project-URL: Repository, https://github.com/lenamonj/housebroken
6
6
  License-Expression: MIT
@@ -52,10 +52,10 @@ the CLA known before filing, the SECURITY.md route taken when it applies
52
52
  A pull request passes through the door in order, and stops at the first rule it fails. Each rule is a script where a script can hold it. The maintainers' own verdicts that shaped them are kept separately, in [docs/lessons.md](https://github.com/lenamonj/housebroken/blob/main/docs/lessons.md).
53
53
 
54
54
  **1. Read the house rules before knocking.**
55
- `check-ai-policy.sh` reads every contributing guide, code of conduct, template and agent instruction file on the repository's branch and in the organization's `.github` repository, matched by name without regard to case, and prints each sentence that names AI with its reading: BAN, DISCLOSE, MENTION or CLEAN. A project that bans AI-written contributions never gets one. A project that asks for disclosure gets one truthful sentence in the body. `distinct-outside.sh` counts outside contributors merged in the last 120 days; a project that has merged none is closed to outsiders whatever its README says.
55
+ `check-ai-policy.sh` reads every contributing guide, code of conduct, template and agent instruction file on the repository's branch and in the organization's `.github` repository, matched by name without regard to case (READMEs capped at 40, and the cap printed), and prints each sentence that names AI with its reading: BAN, DISCLOSE, MENTION or CLEAN. A file GitHub would not give, a throttled call or a truncated listing reads UNREAD, never CLEAN, and nothing files on it. A project that bans AI-written contributions never gets one. A project that asks for disclosure gets one truthful sentence in the body. `distinct-outside.sh` counts outside contributors merged in the last 120 days; a project that has merged none is closed to outsiders whatever its README says.
56
56
 
57
57
  **2. Is it already on the table?**
58
- `prior-art.sh` lists every issue and pull request, open, closed and merged, that touches the file or symbol, with the type taken from the API and the closing ruling quoted for every closed item. An open pull request that fixes the same thing, or a closed issue that ruled the behaviour intended, ends the work. A ruling is never argued with.
58
+ `prior-art.sh` lists the issues and pull requests, open, closed and merged, that touch the file or symbol, with the type taken from the API and the closing ruling quoted for every closed item. A search that matched more than it returned says so, and such a printout is saved only when marked partial. The filing gate requires every file the branch changes to be one of its terms. An open pull request that fixes the same thing, or a closed issue that ruled the behaviour intended, ends the work. A ruling is never argued with.
59
59
 
60
60
  **3. Is it a fix or an opinion?**
61
61
  A change that rejects an input the project tolerated, changes a default, or narrows what the project publishes is the maintainer's call. It is sent with the call stated in the body: what breaks for existing users, and an offer to narrow or close it. A finding whose only evidence is a measurement is a hold, not a pull request.
@@ -67,7 +67,7 @@ On a fresh clone of the upstream default branch, the new test fails; with the pa
67
67
  The project's own test target on the fresh clone, and every gate its pull request workflow runs: lint, format, mutation, API check. A gate not run is a red check the maintainer sees first.
68
68
 
69
69
  **6. Match the house style.**
70
- `comment-check.sh` refuses any comment the branch adds to code, test files included; an existing comment may be reworded, a new file may open with the header its neighbours carry, and a maintainer's explicit request for documentation is the one exception. `comment-census.sh` measures density. The body is under 120 words, in the project's template, and says what was wrong, what changed and how it was verified, every sentence reproduced on the fresh clone. No tool footer, no session link, no co-author trailer, no typographic dash. `claim-check.sh` lists every counted or absolute claim in the body so each is re-derived against the exact revision it names.
70
+ `comment-check.sh` refuses any comment the branch adds to code, test files included; an existing comment may be reworded, a new file may open with the header its neighbours carry, and a maintainer's explicit request for documentation is the one exception. `comment-census.sh` measures density. The body is under 120 words, in the project's template, and says what was wrong, what changed and how it was verified, every sentence reproduced on the fresh clone. No tool footer, no session link, no co-author trailer, no typographic dash. `claim-check.sh` lists every counted or absolute claim in the body so each is re-derived against the exact revision it names, and `ref-check.sh` looks up every issue number, link, test name, file and commit the body names, refusing one that does not exist.
71
71
 
72
72
  **7. Know the paperwork.**
73
73
  Before the first pull request to an organization: CLA, DCO, signed-commit requirement, disclosure trailer, and the identity clause. Author, committer and sign-off carry the contributor's real name.
@@ -76,16 +76,16 @@ Before the first pull request to an organization: CLA, DCO, signed-commit requir
76
76
  A memory-safety, remote-abort, injection or path-escape finding goes by the project's SECURITY.md route, privately, and never becomes a public pull request until the project answers.
77
77
 
78
78
  **9. File through the gate.**
79
- `branch-check.sh` refuses a dirty working tree, a file whose mode disagrees with its siblings, a file the repository's own `.gitignore` excludes, a working artifact, a commit carrying a tool trailer, a session link or a tool identity, a sign-off naming a username, a branch not on top of its base, any comment added to code, a warning suppression no build log shows firing, and reflection added to a .NET project that declares AOT or trim support without an AOT publish log; it prints every absolute claim in added prose for falsification, and stamps the head it passed.
80
- `review.sh` names the adversarial reviewer: a different model from the one that wrote the change, one tier down, never below Sonnet. The reviewer gets a fresh context, hard read-only limits, and before it reads the diff it writes its own fix from the bug alone, so it is not anchored on ours; the report names that fix and the gate refuses one that does not. Its attack list starts with every claim the prose makes and includes the form of the code: the reviewer writes the changed block the way the strongest engineer in that language would, in the repository's own idioms, and a smaller or clearer version is a finding; every finding carries evidence it produced itself, and its report is bound to the head and the text it read. A fix is a new head and gets a new review.
81
- `file-pr.sh` is the only way a pull request is opened. It refuses without a prior-art printout under a day old, a policy printout under a day old that does not read BAN, a body that discloses when the policy asks, a branch stamp for the exact head, and a POST AS IS review of that head and that body.
79
+ `branch-check.sh` refuses a dirty working tree, a file whose mode disagrees with its siblings, a file the repository's own `.gitignore` excludes, a working artifact, a commit carrying a tool trailer, a session link or a tool identity, a sign-off naming a username, a branch not on top of its base, any comment added to code, a warning suppression that does not name what it hides or that no build log shows firing, reflection added to a .NET project that declares AOT or trim support without an AOT publish log of it, a credential or a path under your own home, a lockfile, generated file or binary nobody named, an agent's working file, and a file rewritten for its line endings; it notes a base the upstream has moved past, a user path that may be a fixture and every absolute claim in added prose, and stamps the head it passed.
80
+ `review.sh` names the adversarial reviewer: a different model from the one that wrote the change, one tier down, never below Sonnet. The reviewer gets a fresh context, hard read-only limits, and before it reads the diff it writes its own fix from the bug alone, so it is not anchored on ours; the report names that fix as a diff and the gate refuses one that does not, a POST AS IS over an open BLOCKING or SHOULD FIX finding, and a POST AS IS over items the reviewer skipped. Its attack list starts with every claim the prose makes and includes the form of the code: the reviewer writes the changed block the way the strongest engineer in that language would, in the repository's own idioms, and a smaller or clearer version is a finding; every finding carries evidence it produced itself, and its report is bound to the head and the text it read. A fix is a new head and gets a new review.
81
+ `file-pr.sh` is the only way a pull request is opened. It refuses without a prior-art printout under a day old that searched every file the branch changes, a policy printout under a day old that reads neither BAN nor UNREAD, a body that discloses when the policy asks and runs under 120 words of its own beside the project's template, a branch stamp for the exact head whose base the upstream has not since changed under the branch's files, a POST AS IS review of that head and that body, fewer than three open pull requests of yours on the repository, and ten minutes since the last.
82
82
  `file-issue.sh` is the only way an issue is opened. It refuses without the same prior-art and policy printouts, refuses the same prose, and refuses a body that mentions a fix, a patch or a pull request without a GitHub link to the branch or commit, so work that exists is attributable to whoever did it.
83
83
 
84
84
  **10. Watch it land.**
85
- `verify-filed-pr.sh` re-derives from GitHub that the head is the intended commit, the diff is exactly the intended files, and CI settled. `inbox.sh` prints every event on your threads since the last acknowledged cursor, bodies included, with a census of failing, blocked and conflicting checks. Every maintainer comment gets a same-day answer in the contributor's voice, checked by `claim-check.sh` and, when it concedes, disagrees or rides on a code change, by the adversarial review. Replies to one repository are spaced ten minutes apart. When the maintainer is right, concede in one sentence. A silent maintainer is never nudged.
85
+ `verify-filed-pr.sh` re-derives from GitHub that the head is the intended commit, the files are the ones the branch stamp lists, the body is the one that was filed, and CI settled. `inbox.sh` prints every event on your threads since the last acknowledged cursor, bodies included, with a census of failing, blocked and conflicting checks. Every maintainer comment gets a same-day answer in the contributor's voice, checked by `claim-check.sh` and, when it concedes, disagrees or rides on a code change, by the adversarial review. Replies to one repository are spaced ten minutes apart. When the maintainer is right, concede in one sentence. A silent maintainer is never nudged.
86
86
 
87
87
  **11. Clean up.**
88
- `fork-hygiene.sh` deletes the fork branch of every merged or closed pull request and lists forks with no pull request left. No planning file, agent directory, journal or build output ever enters a diff.
88
+ `fork-hygiene.sh` deletes the fork branch of every merged or closed pull request, unless an open one still uses it, and lists forks with no pull request left. No planning file, agent directory, journal or build output ever enters a diff.
89
89
 
90
90
  **12. Three per repository, one finding each.**
91
91
  A repository gets at most three open pull requests, each one finding, each meeting every rule above on its own, filed at least ten minutes apart.
@@ -121,7 +121,8 @@ uv tool install git+https://github.com/lenamonj/housebroken
121
121
  | `scripts/comment-census.sh` | added code versus added comments, per branch |
122
122
  | `scripts/diff-defaults.sh` | default arguments and one-line wrappers added to keep call sites untouched |
123
123
  | `scripts/claim-check.sh` | every falsifiable claim in a body or a reply, listed to re-derive |
124
- | `scripts/branch-check.sh` | the branch's mechanical facts: clean tree, modes, artifacts, trailers, sign-offs, comments, absolute claims |
124
+ | `scripts/ref-check.sh` | every issue, link, test, file and commit a body names, looked up |
125
+ | `scripts/branch-check.sh` | the branch's mechanical facts: clean tree, live base, modes, artifacts, secrets, generated files, trailers, sign-offs, comments, suppressions, absolute claims |
125
126
  | `scripts/review.sh` | the adversarial review: a different model attacks the change, and the filing is held to exactly what it read |
126
127
  | `scripts/file-pr.sh` | the only way a pull request gets filed: five gates in front of `gh pr create` |
127
128
  | `scripts/file-issue.sh` | the only way an issue gets filed: the same printouts and prose, and a fix offered is a fix linked |
@@ -137,6 +138,8 @@ The scripts are bash and need `gh` and `jq`. Each script's header says what it a
137
138
 
138
139
  housebroken does not find bugs and does not write patches. Any agent that produces a change can use it. It governs what leaves your machine and how it behaves once it arrives.
139
140
 
141
+ The gates stop carelessness, not deceit. A stamp, a review report or a blind fix is a file the agent can write, and no script on the same machine can tell an honest one from a forged one. What the gates guarantee is that nothing files by accident: a step skipped, a check that failed, a base that moved, a reference to nothing.
142
+
140
143
  ## FAQ
141
144
 
142
145
  **Is this only for AI-written pull requests?**
@@ -40,10 +40,10 @@ the CLA known before filing, the SECURITY.md route taken when it applies
40
40
  A pull request passes through the door in order, and stops at the first rule it fails. Each rule is a script where a script can hold it. The maintainers' own verdicts that shaped them are kept separately, in [docs/lessons.md](docs/lessons.md).
41
41
 
42
42
  **1. Read the house rules before knocking.**
43
- `check-ai-policy.sh` reads every contributing guide, code of conduct, template and agent instruction file on the repository's branch and in the organization's `.github` repository, matched by name without regard to case, and prints each sentence that names AI with its reading: BAN, DISCLOSE, MENTION or CLEAN. A project that bans AI-written contributions never gets one. A project that asks for disclosure gets one truthful sentence in the body. `distinct-outside.sh` counts outside contributors merged in the last 120 days; a project that has merged none is closed to outsiders whatever its README says.
43
+ `check-ai-policy.sh` reads every contributing guide, code of conduct, template and agent instruction file on the repository's branch and in the organization's `.github` repository, matched by name without regard to case (READMEs capped at 40, and the cap printed), and prints each sentence that names AI with its reading: BAN, DISCLOSE, MENTION or CLEAN. A file GitHub would not give, a throttled call or a truncated listing reads UNREAD, never CLEAN, and nothing files on it. A project that bans AI-written contributions never gets one. A project that asks for disclosure gets one truthful sentence in the body. `distinct-outside.sh` counts outside contributors merged in the last 120 days; a project that has merged none is closed to outsiders whatever its README says.
44
44
 
45
45
  **2. Is it already on the table?**
46
- `prior-art.sh` lists every issue and pull request, open, closed and merged, that touches the file or symbol, with the type taken from the API and the closing ruling quoted for every closed item. An open pull request that fixes the same thing, or a closed issue that ruled the behaviour intended, ends the work. A ruling is never argued with.
46
+ `prior-art.sh` lists the issues and pull requests, open, closed and merged, that touch the file or symbol, with the type taken from the API and the closing ruling quoted for every closed item. A search that matched more than it returned says so, and such a printout is saved only when marked partial. The filing gate requires every file the branch changes to be one of its terms. An open pull request that fixes the same thing, or a closed issue that ruled the behaviour intended, ends the work. A ruling is never argued with.
47
47
 
48
48
  **3. Is it a fix or an opinion?**
49
49
  A change that rejects an input the project tolerated, changes a default, or narrows what the project publishes is the maintainer's call. It is sent with the call stated in the body: what breaks for existing users, and an offer to narrow or close it. A finding whose only evidence is a measurement is a hold, not a pull request.
@@ -55,7 +55,7 @@ On a fresh clone of the upstream default branch, the new test fails; with the pa
55
55
  The project's own test target on the fresh clone, and every gate its pull request workflow runs: lint, format, mutation, API check. A gate not run is a red check the maintainer sees first.
56
56
 
57
57
  **6. Match the house style.**
58
- `comment-check.sh` refuses any comment the branch adds to code, test files included; an existing comment may be reworded, a new file may open with the header its neighbours carry, and a maintainer's explicit request for documentation is the one exception. `comment-census.sh` measures density. The body is under 120 words, in the project's template, and says what was wrong, what changed and how it was verified, every sentence reproduced on the fresh clone. No tool footer, no session link, no co-author trailer, no typographic dash. `claim-check.sh` lists every counted or absolute claim in the body so each is re-derived against the exact revision it names.
58
+ `comment-check.sh` refuses any comment the branch adds to code, test files included; an existing comment may be reworded, a new file may open with the header its neighbours carry, and a maintainer's explicit request for documentation is the one exception. `comment-census.sh` measures density. The body is under 120 words, in the project's template, and says what was wrong, what changed and how it was verified, every sentence reproduced on the fresh clone. No tool footer, no session link, no co-author trailer, no typographic dash. `claim-check.sh` lists every counted or absolute claim in the body so each is re-derived against the exact revision it names, and `ref-check.sh` looks up every issue number, link, test name, file and commit the body names, refusing one that does not exist.
59
59
 
60
60
  **7. Know the paperwork.**
61
61
  Before the first pull request to an organization: CLA, DCO, signed-commit requirement, disclosure trailer, and the identity clause. Author, committer and sign-off carry the contributor's real name.
@@ -64,16 +64,16 @@ Before the first pull request to an organization: CLA, DCO, signed-commit requir
64
64
  A memory-safety, remote-abort, injection or path-escape finding goes by the project's SECURITY.md route, privately, and never becomes a public pull request until the project answers.
65
65
 
66
66
  **9. File through the gate.**
67
- `branch-check.sh` refuses a dirty working tree, a file whose mode disagrees with its siblings, a file the repository's own `.gitignore` excludes, a working artifact, a commit carrying a tool trailer, a session link or a tool identity, a sign-off naming a username, a branch not on top of its base, any comment added to code, a warning suppression no build log shows firing, and reflection added to a .NET project that declares AOT or trim support without an AOT publish log; it prints every absolute claim in added prose for falsification, and stamps the head it passed.
68
- `review.sh` names the adversarial reviewer: a different model from the one that wrote the change, one tier down, never below Sonnet. The reviewer gets a fresh context, hard read-only limits, and before it reads the diff it writes its own fix from the bug alone, so it is not anchored on ours; the report names that fix and the gate refuses one that does not. Its attack list starts with every claim the prose makes and includes the form of the code: the reviewer writes the changed block the way the strongest engineer in that language would, in the repository's own idioms, and a smaller or clearer version is a finding; every finding carries evidence it produced itself, and its report is bound to the head and the text it read. A fix is a new head and gets a new review.
69
- `file-pr.sh` is the only way a pull request is opened. It refuses without a prior-art printout under a day old, a policy printout under a day old that does not read BAN, a body that discloses when the policy asks, a branch stamp for the exact head, and a POST AS IS review of that head and that body.
67
+ `branch-check.sh` refuses a dirty working tree, a file whose mode disagrees with its siblings, a file the repository's own `.gitignore` excludes, a working artifact, a commit carrying a tool trailer, a session link or a tool identity, a sign-off naming a username, a branch not on top of its base, any comment added to code, a warning suppression that does not name what it hides or that no build log shows firing, reflection added to a .NET project that declares AOT or trim support without an AOT publish log of it, a credential or a path under your own home, a lockfile, generated file or binary nobody named, an agent's working file, and a file rewritten for its line endings; it notes a base the upstream has moved past, a user path that may be a fixture and every absolute claim in added prose, and stamps the head it passed.
68
+ `review.sh` names the adversarial reviewer: a different model from the one that wrote the change, one tier down, never below Sonnet. The reviewer gets a fresh context, hard read-only limits, and before it reads the diff it writes its own fix from the bug alone, so it is not anchored on ours; the report names that fix as a diff and the gate refuses one that does not, a POST AS IS over an open BLOCKING or SHOULD FIX finding, and a POST AS IS over items the reviewer skipped. Its attack list starts with every claim the prose makes and includes the form of the code: the reviewer writes the changed block the way the strongest engineer in that language would, in the repository's own idioms, and a smaller or clearer version is a finding; every finding carries evidence it produced itself, and its report is bound to the head and the text it read. A fix is a new head and gets a new review.
69
+ `file-pr.sh` is the only way a pull request is opened. It refuses without a prior-art printout under a day old that searched every file the branch changes, a policy printout under a day old that reads neither BAN nor UNREAD, a body that discloses when the policy asks and runs under 120 words of its own beside the project's template, a branch stamp for the exact head whose base the upstream has not since changed under the branch's files, a POST AS IS review of that head and that body, fewer than three open pull requests of yours on the repository, and ten minutes since the last.
70
70
  `file-issue.sh` is the only way an issue is opened. It refuses without the same prior-art and policy printouts, refuses the same prose, and refuses a body that mentions a fix, a patch or a pull request without a GitHub link to the branch or commit, so work that exists is attributable to whoever did it.
71
71
 
72
72
  **10. Watch it land.**
73
- `verify-filed-pr.sh` re-derives from GitHub that the head is the intended commit, the diff is exactly the intended files, and CI settled. `inbox.sh` prints every event on your threads since the last acknowledged cursor, bodies included, with a census of failing, blocked and conflicting checks. Every maintainer comment gets a same-day answer in the contributor's voice, checked by `claim-check.sh` and, when it concedes, disagrees or rides on a code change, by the adversarial review. Replies to one repository are spaced ten minutes apart. When the maintainer is right, concede in one sentence. A silent maintainer is never nudged.
73
+ `verify-filed-pr.sh` re-derives from GitHub that the head is the intended commit, the files are the ones the branch stamp lists, the body is the one that was filed, and CI settled. `inbox.sh` prints every event on your threads since the last acknowledged cursor, bodies included, with a census of failing, blocked and conflicting checks. Every maintainer comment gets a same-day answer in the contributor's voice, checked by `claim-check.sh` and, when it concedes, disagrees or rides on a code change, by the adversarial review. Replies to one repository are spaced ten minutes apart. When the maintainer is right, concede in one sentence. A silent maintainer is never nudged.
74
74
 
75
75
  **11. Clean up.**
76
- `fork-hygiene.sh` deletes the fork branch of every merged or closed pull request and lists forks with no pull request left. No planning file, agent directory, journal or build output ever enters a diff.
76
+ `fork-hygiene.sh` deletes the fork branch of every merged or closed pull request, unless an open one still uses it, and lists forks with no pull request left. No planning file, agent directory, journal or build output ever enters a diff.
77
77
 
78
78
  **12. Three per repository, one finding each.**
79
79
  A repository gets at most three open pull requests, each one finding, each meeting every rule above on its own, filed at least ten minutes apart.
@@ -109,7 +109,8 @@ uv tool install git+https://github.com/lenamonj/housebroken
109
109
  | `scripts/comment-census.sh` | added code versus added comments, per branch |
110
110
  | `scripts/diff-defaults.sh` | default arguments and one-line wrappers added to keep call sites untouched |
111
111
  | `scripts/claim-check.sh` | every falsifiable claim in a body or a reply, listed to re-derive |
112
- | `scripts/branch-check.sh` | the branch's mechanical facts: clean tree, modes, artifacts, trailers, sign-offs, comments, absolute claims |
112
+ | `scripts/ref-check.sh` | every issue, link, test, file and commit a body names, looked up |
113
+ | `scripts/branch-check.sh` | the branch's mechanical facts: clean tree, live base, modes, artifacts, secrets, generated files, trailers, sign-offs, comments, suppressions, absolute claims |
113
114
  | `scripts/review.sh` | the adversarial review: a different model attacks the change, and the filing is held to exactly what it read |
114
115
  | `scripts/file-pr.sh` | the only way a pull request gets filed: five gates in front of `gh pr create` |
115
116
  | `scripts/file-issue.sh` | the only way an issue gets filed: the same printouts and prose, and a fix offered is a fix linked |
@@ -125,6 +126,8 @@ The scripts are bash and need `gh` and `jq`. Each script's header says what it a
125
126
 
126
127
  housebroken does not find bugs and does not write patches. Any agent that produces a change can use it. It governs what leaves your machine and how it behaves once it arrives.
127
128
 
129
+ The gates stop carelessness, not deceit. A stamp, a review report or a blind fix is a file the agent can write, and no script on the same machine can tell an honest one from a forged one. What the gates guarantee is that nothing files by accident: a step skipped, a check that failed, a base that moved, a reference to nothing.
130
+
128
131
  ## FAQ
129
132
 
130
133
  **Is this only for AI-written pull requests?**
@@ -50,6 +50,7 @@ usage: housebroken <subcommand> [args...]
50
50
  6 census added code lines against added comment lines, per branch
51
51
  6 comments refuse a comment added to code nobody asked for
52
52
  6 claims every falsifiable claim in outbound text, listed to re-derive
53
+ 6 refs every issue, link, test, file and commit the text names, looked up
53
54
  9 branch the mechanical facts about the branch: tree, modes, artifacts, claims
54
55
  9 review a different model attacks the change before it is filed
55
56
  9 file the only way a pull request gets filed
@@ -96,6 +97,7 @@ case "$cmd" in
96
97
  census) script=comment-census.sh ;;
97
98
  comments) script=comment-check.sh ;;
98
99
  claims) script=claim-check.sh ;;
100
+ refs) script=ref-check.sh ;;
99
101
  branch) script=branch-check.sh ;;
100
102
  review) script=review.sh ;;
101
103
  file) script=file-pr.sh ;;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "housebroken-cli"
7
- version = "0.6.9"
7
+ version = "0.7.0"
8
8
  description = "Agent-written pull requests that do not make a mess in someone else's house."
9
9
  dynamic = ["readme"]
10
10
  license = "MIT"
@@ -43,6 +43,7 @@ base=""
43
43
  asked=""
44
44
  warnlog=""
45
45
  aotlog=""
46
+ allows=()
46
47
  while [ "$#" -gt 0 ]; do
47
48
  case "$1" in
48
49
  --help|-h) usage; exit 0 ;;
@@ -68,6 +69,11 @@ while [ "$#" -gt 0 ]; do
68
69
  aotlog="${1:-}"
69
70
  [ -f "$aotlog" ] || { echo "REFUSED: --aot-log needs an AOT publish log file" >&2; exit 2; }
70
71
  ;;
72
+ --allow)
73
+ shift
74
+ [ -n "${1:-}" ] || { echo "REFUSED: --allow needs a path" >&2; exit 2; }
75
+ allows+=("$1")
76
+ ;;
71
77
  -*) echo "REFUSED: unknown option $1" >&2; exit 2 ;;
72
78
  *) clone="$1" ;;
73
79
  esac
@@ -87,6 +93,9 @@ git rev-parse --verify -q "$base" >/dev/null 2>&1 || { echo "REFUSED: base $base
87
93
 
88
94
  blocked=0
89
95
  block() { echo "REFUSED: $*"; blocked=1; }
96
+ # Whether the base already carries a path. "rev:path" is not used because Git
97
+ # for Windows' shell rewrites an argument shaped like "a/b:.c/d" as a path list.
98
+ in_base() { [ -n "$(git ls-tree --name-only "$base" -- "$1" 2>/dev/null)" ]; }
90
99
 
91
100
  echo "== branch check: $(git rev-parse --abbrev-ref HEAD) against $base =="
92
101
 
@@ -97,6 +106,21 @@ if [ -n "$dirty" ]; then
97
106
  printf '%s\n' "$dirty" | sed 's/^/ /'
98
107
  fi
99
108
 
109
+ # A base nobody fetched is the upstream as it was, not as it is: ask the remote.
110
+ full=$(git rev-parse --symbolic-full-name "$base" 2>/dev/null)
111
+ case "$full" in
112
+ refs/remotes/*/*)
113
+ rest="${full#refs/remotes/}"; remote="${rest%%/*}"; rbranch="${rest#*/}"
114
+ live=$(git ls-remote "$remote" "refs/heads/$rbranch" 2>/dev/null | awk '{print $1}')
115
+ if [ -z "$live" ]; then
116
+ block "cannot read $rbranch from $remote, so whether $base is current is unknown"
117
+ elif [ "$live" != "$(git rev-parse "$base")" ]; then
118
+ echo "CHECK: the base moved: $remote has $rbranch at ${live:0:12}, $base is $(git rev-parse --short=12 "$base") - file-pr.sh refuses when the new commits touch this branch's files"
119
+ fi
120
+ ;;
121
+ *) block "base $base is not a remote-tracking ref, so whether it is current cannot be checked - pass --base <remote>/<branch>" ;;
122
+ esac
123
+
100
124
  if ! git merge-base --is-ancestor "$base" HEAD 2>/dev/null; then
101
125
  block "HEAD is not on top of $base - rebase before filing"
102
126
  fi
@@ -136,16 +160,22 @@ while IFS= read -r f; do
136
160
  # forced. NVIDIA/go-nvml ignores "dl" for a built binary, and that pattern also
137
161
  # matches the directory pkg/dl, so forcing the check called two upstream source
138
162
  # files build output.
139
- if ! git cat-file -e "$base:$f" 2>/dev/null; then
163
+ if ! in_base "$f"; then
140
164
  if git check-ignore -q --no-index -- "$f" 2>/dev/null; then
141
165
  block "$f is added by this branch and the repository's .gitignore excludes it"
142
166
  fi
143
167
  fi
144
168
 
145
169
  case "$f" in
146
- .jeffy/*|*/.jeffy/*|PLAN.md|*/PLAN.md|JOURNAL.md|*/JOURNAL.md|*.orig|*.rej)
170
+ .jeffy/*|*/.jeffy/*|PLAN.md|*/PLAN.md|JOURNAL.md|*/JOURNAL.md|*.orig|*.rej|*.bak|*.swp|.DS_Store|*/.DS_Store|Thumbs.db|*/Thumbs.db)
147
171
  block "$f is a working artifact and has no place in the diff" ;;
148
172
  esac
173
+ # An agent's instruction or scratch file is an artifact when this branch adds
174
+ # it; a project that already keeps one may have it edited.
175
+ case "$f" in
176
+ .claude/*|*/.claude/*|.cursor/*|*/.cursor/*|CLAUDE.md|*/CLAUDE.md|AGENTS.md|*/AGENTS.md|GEMINI.md|*/GEMINI.md|STATUS.md|*/STATUS.md|NOTES.md|*/NOTES.md|TODO.md|*/TODO.md)
177
+ in_base "$f" || block "$f is added by this branch and reads as an agent's working file" ;;
178
+ esac
149
179
  done <<EOF
150
180
  $files
151
181
  EOF
@@ -187,15 +217,27 @@ printf '%s\n' "$comments" | grep -E '^CHECK: ' | sed 's/^/ /'
187
217
  # that is wrong in a way the analyzer cannot see.
188
218
  added=$(git diff -U0 "$base..HEAD" -- . ':(exclude)*.md' ':(exclude)*.rst' ':(exclude)*.txt' 2>/dev/null \
189
219
  | grep '^+' | grep -v '^+++' | sed 's/^+//')
190
- suppressions=$(printf '%s\n' "$added" | grep -E 'UnconditionalSuppressMessage|SuppressMessage\(|#pragma warning disable|@SuppressWarnings|@Suppress\(|#!?\[allow\(|nolint|NOLINT|noqa|eslint-disable|@ts-ignore|@ts-expect-error|type: *ignore|pylint: *disable|rubocop:disable|swiftlint:disable')
220
+ suppressions=$(printf '%s\n' "$added" | grep -E 'UnconditionalSuppressMessage|SuppressMessage\(|#pragma warning disable|pragma warning *\( *disable|<NoWarn>|@SuppressWarnings|@SuppressLint|@Suppress\(|#!?\[allow\(|nolint|NOLINT|NOSONAR|noqa|eslint-disable|@ts-ignore|@ts-expect-error|type: *ignore|pylint: *disable|rubocop:disable|swiftlint:disable|phpstan-ignore|psalm-suppress')
191
221
  if [ -n "$suppressions" ]; then
192
- ids=$(printf '%s\n' "$suppressions" | grep -oE '\b[A-Z]{1,4}[0-9]{3,5}\b' | sort -u)
222
+ # Each suppression names what it hides: a code such as IL2075 or C4996, else
223
+ # the rule names after its keyword (errcheck, no-unused-vars). One that names
224
+ # nothing hides every warning on its line, and no log can show what it covers.
193
225
  unshown=""
194
- if [ -z "$warnlog" ]; then
195
- unshown="no --warning-log given"
196
- else
197
- for id in $ids; do grep -q "$id" "$warnlog" || unshown="$unshown $id"; done
198
- fi
226
+ while IFS= read -r line; do
227
+ [ -n "$line" ] || continue
228
+ ids=$(printf '%s\n' "$line" | grep -oE '\b([A-Za-z]{1,6}[0-9]{3,5}|[0-9]{4})\b' | sort -u)
229
+ if [ -z "$ids" ]; then
230
+ ids=$(printf '%s\n' "$line" | sed -E 's/ -- .*//; s/.*(nolint|NOLINT|NOSONAR|noqa|eslint-disable(-next-line|-line)?|pylint: *disable|rubocop:disable|swiftlint:disable(:next|:this)?|phpstan-ignore(-next-line|-line)?|psalm-suppress|#!?\[allow|@Suppress(Warnings|Lint)?|SuppressMessage|ts-ignore|ts-expect-error|type: *ignore)//; s/ *(\/\/|#).*//' \
231
+ | grep -oE '[A-Za-z][A-Za-z0-9_-]{2,}' | grep -vxE 'disable|enable|next|line|this' | sort -u)
232
+ fi
233
+ if [ -z "$ids" ]; then
234
+ unshown="$unshown [names no warning: $(printf '%s' "$line" | sed 's/^[[:space:]]*//' | cut -c1-60)]"
235
+ elif [ -z "$warnlog" ]; then
236
+ unshown="$unshown $(printf '%s' "$ids" | tr '\n' ' ')(no --warning-log given)"
237
+ else
238
+ for id in $ids; do grep -qF -- "$id" "$warnlog" || unshown="$unshown $id"; done
239
+ fi
240
+ done <<< "$suppressions"
199
241
  if [ -n "$unshown" ]; then
200
242
  block "the branch adds a warning suppression, and no build log shows the warning firing ($unshown)"
201
243
  printf '%s\n' "$suppressions" | sed 's/^/ /'
@@ -209,12 +251,67 @@ aotdecl=$(git grep -lE '<(IsAotCompatible|IsTrimmable|PublishAot|EnableTrimAnaly
209
251
  if [ -n "$aotdecl" ]; then
210
252
  reflection=$(git diff -U0 "$base..HEAD" -- '*.cs' 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//' \
211
253
  | grep -E '\.Get(Property|Properties|Method|Methods|Field|Fields|Member|Members|Interfaces)\(|Activator\.CreateInstance|MakeGenericType|MakeGenericMethod|Type\.GetType\(|\.InvokeMember\(')
212
- if [ -n "$reflection" ] && ! { [ -n "$aotlog" ] && grep -q "Generating native code" "$aotlog"; }; then
213
- block "the branch adds reflection to a project that declares AOT or trim support (${aotdecl% }), and no --aot-log shows an AOT publish"
254
+ # The log has to be a publish of this repository: it names, as a word, a
255
+ # declaring project, or any project here when the declaration is in a .props.
256
+ published=0
257
+ if [ -n "$aotlog" ] && grep -q "Generating native code" "$aotlog"; then
258
+ for p in $aotdecl; do
259
+ case "$p" in
260
+ *.csproj) projs=$(basename "$p" .csproj) ;;
261
+ *) projs=$(git ls-files '*.csproj' | sed 's#.*/##; s/\.csproj$//') ;;
262
+ esac
263
+ for proj in $projs; do grep -qwF -- "$proj" "$aotlog" && published=1; done
264
+ done
265
+ fi
266
+ if [ -n "$reflection" ] && [ "$published" -eq 0 ]; then
267
+ block "the branch adds reflection to a project that declares AOT or trim support (${aotdecl% }), and no --aot-log shows an AOT publish of it"
214
268
  printf '%s\n' "$reflection" | sed 's/^/ /'
215
269
  fi
216
270
  fi
217
271
 
272
+ # What embarrasses a contributor first, file by file: a secret or a personal
273
+ # path in an added line, a lockfile or generated file, a binary, and a file
274
+ # rewritten for its line endings or whitespace. --allow PATH names a file the
275
+ # change really is about, such as a dependency bump's lockfile.
276
+ allowed() { local a; for a in ${allows[@]+"${allows[@]}"}; do [ "$1" = "$a" ] && return 0; done; return 1; }
277
+ # A credential anywhere is refused, and so is a path under this user's own
278
+ # home; a path under another user's is a fixture or an example, and is noted.
279
+ me=$(basename "${HOME:-/nobody}")
280
+ leak_re="gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9_-]{20,}|[A-Za-z]:[\\\\/]Users[\\\\/]$me([\\\\/ ]|\$)|/home/$me/|/Users/$me/|/mnt/[a-z]/Users/$me/"
281
+ path_re='[A-Za-z]:[\\/]Users[\\/][^\\/ ]+|/home/[a-z][a-z0-9_-]*/|/Users/[A-Za-z][^/ ]*/|/mnt/[a-z]/Users/'
282
+ nfiles=0; nadded=0
283
+ while IFS= read -r f; do
284
+ [ -n "$f" ] || continue
285
+ nfiles=$((nfiles + 1))
286
+ stat=$(git diff --numstat "$base..HEAD" -- "$f" | head -1)
287
+ case "$f" in *test*|*spec*) ;; *) nadded=$((nadded + $(printf '%s' "$stat" | awk '{print ($1 == "-") ? 0 : $1}'))) ;; esac
288
+ allowed "$f" && continue
289
+ addedf=$(git diff -U0 "$base..HEAD" -- "$f" | grep '^+' | grep -v '^+++')
290
+ leaks=$(printf '%s\n' "$addedf" | grep -oE "$leak_re" | sort -u)
291
+ [ -z "$leaks" ] || block "$f adds a personal path or a credential: $(printf '%s' "$leaks" | tr '\n' ' ')"
292
+ paths=$(printf '%s\n' "$addedf" | grep -oE "$path_re" | sort -u)
293
+ [ -z "$paths" ] || echo "CHECK: $f adds a user path, a fixture or an example unless it is yours: $(printf '%s' "$paths" | tr '\n' ' ')"
294
+ case "$f" in
295
+ package-lock.json|*/package-lock.json|yarn.lock|*/yarn.lock|pnpm-lock.yaml|*/pnpm-lock.yaml|Cargo.lock|*/Cargo.lock|go.sum|*/go.sum|poetry.lock|*/poetry.lock|uv.lock|*/uv.lock|Gemfile.lock|*/Gemfile.lock|composer.lock|*/composer.lock|packages.lock.json|*/packages.lock.json|vendor/*|*/vendor/*|node_modules/*|*/node_modules/*|*.min.js|*.pb.go|*_generated.*|*.g.cs|*.designer.cs|*.Designer.cs)
296
+ block "$f is a lockfile, vendored or generated file - a build rewrote it, or pass --allow $f if the change is about it" ;;
297
+ esac
298
+ case "$stat" in
299
+ -*) block "$f is binary - pass --allow $f if the change needs it" ;;
300
+ *)
301
+ total=$(printf '%s' "$stat" | awk '{print $1 + $2}')
302
+ real=$(git diff --numstat --ignore-space-at-eol --ignore-cr-at-eol "$base..HEAD" -- "$f" | awk '{print $1 + $2}')
303
+ if [ "$total" -ge 10 ] && [ $((${real:-0} * 2)) -lt "$total" ]; then
304
+ block "$f changes $total lines, of which ${real:-0} differ beyond whitespace and line endings - restore the file's own endings"
305
+ fi
306
+ ;;
307
+ esac
308
+ done <<EOF
309
+ $files
310
+ EOF
311
+ if [ "$nfiles" -gt 4 ] || [ "$nadded" -gt 150 ]; then
312
+ echo "CHECK: $nfiles files and $nadded added lines outside tests - one finding, the smallest diff that fixes it"
313
+ fi
314
+
218
315
  # Absolutes in added prose are the claims a maintainer can falsify in one grep.
219
316
  claims=$(git diff -U0 "$base..HEAD" -- '*.md' '*.mdx' '*.rst' '*.txt' 2>/dev/null \
220
317
  | grep '^+' | grep -v '^+++' | sed 's/^+//' \
@@ -235,9 +332,11 @@ if [ "$blocked" -ne 0 ]; then
235
332
  echo "branch-check: refused. Fix the causes above; a refusal is a result."
236
333
  exit 1
237
334
  fi
238
- # The stamp file-pr.sh reads: this head passed, against this base, at this time.
335
+ # The stamp file-pr.sh reads: this head passed, against this base, at this
336
+ # time, changing these files.
239
337
  head=$(git rev-parse HEAD)
240
338
  mkdir -p "$home/branch-checks"
241
339
  printf 'head: %s\nbase: %s\nbase-ref: %s\nchecked: %s\n' "$head" "$(git rev-parse "$base")" "$base" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$home/branch-checks/$head"
340
+ printf '%s\n' "$files" | sed '/^$/d; s/^/file: /' >> "$home/branch-checks/$head"
242
341
  echo
243
342
  echo "branch-check: nothing mechanical blocks this branch."
@@ -12,21 +12,29 @@
12
12
  # repository.
13
13
  #
14
14
  # Usage:
15
- # bash check-ai-policy.sh [--branch <name>] [--out] owner/repo [owner/repo ...]
15
+ # bash check-ai-policy.sh [--branch <name>] [--out] [--accept-truncated] owner/repo [owner/repo ...]
16
16
  # bash check-ai-policy.sh --help
17
17
  #
18
18
  # --branch <name> read this branch instead of the default; some projects
19
19
  # keep the policy on a development branch
20
20
  # --out also save the printout to $HOUSEBROKEN_HOME/policy/
21
- # policy-<owner>-<repo>.md, which file-pr.sh reads
21
+ # policy-<owner>-<repo>.md, which file-pr.sh reads, and
22
+ # the pull request template to template-<owner>-<repo>.md,
23
+ # whose lines file-pr.sh leaves out of the body's word count
24
+ # --accept-truncated read a repository whose tree GitHub truncates (over
25
+ # 100,000 entries) after its policy was read by hand; the
26
+ # printout records that it was
22
27
  #
23
28
  # Every hit is printed with its file and line so the sentence is read, not a
24
29
  # verdict. The RESULT line is the reading: BAN when a sentence forbids or
25
30
  # closes AI-written contributions, DISCLOSE when a sentence requires that AI
26
31
  # use be disclosed, MENTION when AI is named without either, CLEAN when no
27
- # policy file names it. Exit 0 for CLEAN or MENTION, 3 for DISCLOSE, 4 for BAN,
28
- # 1 when the repository cannot be read. With several repositories the exit is
29
- # the highest.
32
+ # policy file names it, UNREAD when GitHub refused, throttled or truncated any
33
+ # listing or file, so a failure is never read as silence. Every policy-named
34
+ # file is read; READMEs are capped at 40 per repository and the cap is printed.
35
+ # Exit 0 for CLEAN or MENTION, 1 for UNREAD or when the repository cannot be
36
+ # read, 3 for DISCLOSE, 4 for BAN. With several repositories the exit is the
37
+ # highest.
30
38
  #
31
39
  # Environment:
32
40
  # HOUSEBROKEN_HOME work directory, default $HOME/.housebroken; --out writes
@@ -41,7 +49,7 @@ usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; }
41
49
  # regard to case, so CONTRIBUTING.rst, contributing.md and Contributing.md
42
50
  # are all read.
43
51
  NAME_RE='(^|/)(contributing([._-][a-z]+)?\.(md|rst|txt|adoc)|contributing|code[_-]?of[_-]?conduct\.(md|rst|txt)|readme\.(md|rst|txt|adoc)|ai([_-]policy)?\.md|policy\.md|agents?\.md|claude\.md|gemini\.md|copilot-instructions\.md|\.cursorrules|\.cursor/rules/[^/]+|pull_request_template(\.md)?|pull_request_template/[^/]+\.md|issue_template/[^/]+\.(md|yml|yaml)|governance\.md|maintainers\.md)$'
44
- MAX_FILES=40
52
+ MAX_READMES=40
45
53
 
46
54
  # The word itself, plus its usual spellings and the tools by name.
47
55
  PATTERN='\bAI\b|\bA\.I\.|[Aa]rtificial [Ii]ntelligence|\bLLMs?\b|[Ll]arge [Ll]anguage [Mm]odels?|Copilot|ChatGPT|[Cc]laude|[Gg]enerative|[Mm]achine-generated|[Cc]odex\b|[Cc]ursor\b|[Aa]gentic'
@@ -50,6 +58,7 @@ DISCLOSE_RE='disclos|disclaimer|declare|must (state|say|note|indicate|mention)|b
50
58
 
51
59
  branch_opt=""
52
60
  out=0
61
+ accept_truncated=0
53
62
  repos=()
54
63
  while [ "$#" -gt 0 ]; do
55
64
  case "$1" in
@@ -58,6 +67,7 @@ while [ "$#" -gt 0 ]; do
58
67
  branch_opt="$1" ;;
59
68
  --branch=*) branch_opt="${1#--branch=}" ;;
60
69
  --out) out=1 ;;
70
+ --accept-truncated) accept_truncated=1 ;;
61
71
  --*) usage >&2; echo "check-ai-policy.sh: unknown option $1" >&2; exit 1 ;;
62
72
  */*) repos+=("$1") ;;
63
73
  *) usage >&2; echo "check-ai-policy.sh: '$1' is not owner/repo" >&2; exit 1 ;;
@@ -68,20 +78,50 @@ done
68
78
 
69
79
  command -v gh >/dev/null 2>&1 || { echo "check-ai-policy.sh: gh is not on PATH" >&2; exit 1; }
70
80
  home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
81
+ errfile=$(mktemp)
82
+ tmpl_repo=$(mktemp)
83
+ tmpl_org=$(mktemp)
84
+ trap 'rm -f "$errfile" "$tmpl_repo" "$tmpl_org"' EXIT
71
85
 
72
- # List the policy files at one ref of one repository, from its tree listing.
73
- policy_files() {
74
- gh api "repos/$1/git/trees/$2?recursive=1" --jq '.tree[] | select(.type == "blob") | .path' 2>/dev/null \
75
- | grep -Ei "$NAME_RE" | head -"$MAX_FILES"
76
- }
77
-
78
- # Print every hit in one repository at one ref; sets found, ban, disclose.
86
+ # Print every hit in one repository at one ref; sets found, ban, disclose,
87
+ # and counts what GitHub would not give us in unread and truncated, so a
88
+ # failed or throttled call is never read as a clean file.
79
89
  scan() {
80
- local target="$1" ref="$2" label="$3" f body hits
90
+ local target="$1" ref="$2" label="$3" f body hits tree listed files readmes
91
+ if ! tree=$(gh api "repos/$target/git/trees/$ref?recursive=1" 2>"$errfile"); then
92
+ # An empty repository answers 409: it has no files, so no policy to read.
93
+ if grep -q "409" "$errfile"; then
94
+ echo " (empty) $label$target"
95
+ return
96
+ fi
97
+ echo " UNREAD: the file listing of $target@$ref"
98
+ unread=$((unread + 1))
99
+ return
100
+ fi
101
+ if [ "$(printf '%s' "$tree" | jq -r '.truncated')" = "true" ]; then
102
+ echo " TRUNCATED: GitHub listed only part of the tree of $target@$ref"
103
+ truncated=1
104
+ fi
105
+ listed=$(printf '%s' "$tree" | jq -r '.tree[] | select(.type == "blob") | .path' | grep -Ei "$NAME_RE")
106
+ # Every policy-named file is read; only READMEs, which monorepos carry by the
107
+ # hundred, are capped, and the cap is printed.
108
+ readmes=$(printf '%s\n' "$listed" | grep -iE '(^|/)readme\.[a-z]+$')
109
+ files=$( { printf '%s\n' "$listed" | grep -viE '(^|/)readme\.[a-z]+$'
110
+ printf '%s\n' "$readmes" | head -"$MAX_READMES"; } | grep -v '^$')
111
+ if [ "$(printf '%s\n' "$readmes" | grep -c .)" -gt "$MAX_READMES" ]; then
112
+ echo " (only the first $MAX_READMES of $(printf '%s\n' "$readmes" | grep -c .) READMEs in $label${target} were read)"
113
+ fi
81
114
  while IFS= read -r f; do
82
115
  [ -n "$f" ] || continue
83
- body=$(gh api "repos/$target/contents/$f?ref=$ref" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null)
84
- [ -n "$body" ] || continue
116
+ if ! body=$(gh api "repos/$target/contents/$f?ref=$ref" --jq '.content' 2>/dev/null); then
117
+ echo " UNREAD: $label$f"
118
+ unread=$((unread + 1))
119
+ continue
120
+ fi
121
+ body=$(printf '%s' "$body" | base64 -d 2>/dev/null)
122
+ if printf '%s' "$f" | grep -qiE '(^|/)pull_request_template'; then
123
+ printf '%s\n' "$body" >> "$tmplout"
124
+ fi
85
125
  hits=$(printf '%s' "$body" | grep -nE "$PATTERN" | head -20)
86
126
  if [ -n "$hits" ]; then
87
127
  found=1
@@ -92,12 +132,13 @@ scan() {
92
132
  else
93
133
  echo " (clean) $label$f"
94
134
  fi
95
- done < <(policy_files "$target" "$ref")
135
+ done <<< "$files"
96
136
  }
97
137
 
98
138
  status=0
99
139
  for repo in "${repos[@]}"; do
100
- found=0; ban=0; disclose=0
140
+ found=0; ban=0; disclose=0; unread=0; truncated=0
141
+ : > "$tmpl_repo"; : > "$tmpl_org"
101
142
  report=$(
102
143
  echo "## AI policy: $repo"
103
144
  echo "read: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -110,14 +151,26 @@ for repo in "${repos[@]}"; do
110
151
  exit 1
111
152
  fi
112
153
  echo "branch: $branch"
154
+ tmplout="$tmpl_repo"
113
155
  scan "$repo" "$branch" ""
114
156
  org="${repo%%/*}"
115
- org_branch=$(gh api "repos/$org/.github" --jq .default_branch 2>/dev/null) || org_branch=""
157
+ # A missing .github repository is a 404; any other failure is a policy we did not read.
158
+ if ! org_branch=$(gh api "repos/$org/.github" --jq .default_branch 2>"$errfile"); then
159
+ org_branch=""
160
+ grep -q "404" "$errfile" || { echo " UNREAD: $org/.github"; unread=$((unread + 1)); }
161
+ fi
116
162
  if [ -n "$org_branch" ]; then
117
163
  echo "org repo: $org/.github ($org_branch)"
164
+ tmplout="$tmpl_org"
118
165
  scan "$org/.github" "$org_branch" "$org/.github: "
119
166
  fi
167
+ if [ "$truncated" -eq 1 ] && [ "$accept_truncated" -eq 1 ]; then
168
+ echo "truncated tree accepted by --accept-truncated: files GitHub did not list were not read"
169
+ truncated=0
170
+ fi
120
171
  if [ "$ban" -eq 1 ]; then echo "RESULT: BAN (a policy file forbids or closes AI-written contributions; never file here)"
172
+ elif [ "$unread" -gt 0 ]; then echo "RESULT: UNREAD ($unread policy file(s) or listing(s) could not be fetched; rerun when GitHub answers)"
173
+ elif [ "$truncated" -eq 1 ]; then echo "RESULT: UNREAD (GitHub truncated the file listing; read the policy by hand, then rerun with --accept-truncated)"
121
174
  elif [ "$disclose" -eq 1 ]; then echo "RESULT: DISCLOSE (a policy file requires AI use to be disclosed; say so in the body)"
122
175
  elif [ "$found" -eq 1 ]; then echo "RESULT: MENTION (a policy file names AI; read the lines above and decide)"
123
176
  else echo "RESULT: CLEAN (no policy file names AI)"
@@ -129,11 +182,19 @@ for repo in "${repos[@]}"; do
129
182
  case "$report" in
130
183
  *"RESULT: BAN"*) [ "$status" -lt 4 ] && status=4 ;;
131
184
  *"RESULT: DISCLOSE"*) [ "$status" -lt 3 ] && status=3 ;;
185
+ *"RESULT: UNREAD"*) [ "$status" -lt 1 ] && status=1 ;;
132
186
  esac
133
187
  if [ "$out" -eq 1 ]; then
134
188
  mkdir -p "$home/policy"
135
189
  printf '%s\n' "$report" > "$home/policy/policy-${repo%%/*}-${repo##*/}.md"
136
190
  echo "saved: $home/policy/policy-${repo%%/*}-${repo##*/}.md"
191
+ # The pull request template, the repository's own or else its
192
+ # organization's, is what file-pr.sh leaves out of the body's word count.
193
+ template="$home/policy/template-${repo%%/*}-${repo##*/}.md"
194
+ if [ -s "$tmpl_repo" ]; then cp "$tmpl_repo" "$template"
195
+ elif [ -s "$tmpl_org" ]; then cp "$tmpl_org" "$template"
196
+ else rm -f "$template"
197
+ fi
137
198
  fi
138
199
  done
139
200
  exit "$status"
@@ -101,6 +101,7 @@ policy_result=$(grep -m1 '^RESULT: ' "$policy" | awk '{print $2}')
101
101
  case "$policy_result" in
102
102
  BAN) echo "REFUSED: $repo forbids AI-written contributions ($policy); never file here" >&2; exit 2 ;;
103
103
  DISCLOSE|MENTION|CLEAN) ;;
104
+ UNREAD) echo "REFUSED: $policy reads UNREAD - GitHub did not give every policy file; rerun check-ai-policy.sh --out" >&2; exit 2 ;;
104
105
  *) echo "REFUSED: $policy has no RESULT line - rerun check-ai-policy.sh --out" >&2; exit 2 ;;
105
106
  esac
106
107
 
@@ -150,9 +151,16 @@ if [ "$policy_result" = "DISCLOSE" ] && ! grep -qiE '\bAI\b|LLM|Claude|Copilot|C
150
151
  echo "REFUSED: $repo requires AI use to be disclosed ($policy) and the body does not name it" >&2
151
152
  exit 2
152
153
  fi
154
+ if ! refs=$(bash "$(dirname "$0")/ref-check.sh" "$repo" "$bodyfile" 2>&1); then
155
+ echo "REFUSED: the body names something that does not exist:" >&2
156
+ printf '%s\n' "$refs" | grep -E 'NOT FOUND|REFUSED' >&2
157
+ exit 2
158
+ fi
153
159
 
154
- offer_re='\b(fix|fixed|fixes|patch|patched|patches|pull request|PR)\b'
155
- link_re='https://github\.com/[^/[:space:]]+/[^/[:space:]]+/(tree|commit|compare|blob)/[^[:space:])]+'
160
+ # An offer is a first-person promise of work, whatever noun it uses; "patch
161
+ # release" in a bug report is not one. The link may be the filed pull request.
162
+ offer_re='\b(I|we) (have|had|can|could|will|would|am|are)( be happy to| be glad to)? (send|open|submit|provide|offer|push|put up|prepare|write)\b|\b(I|we) (have|wrote|made) (a|the) (fix|patch|change)\b|\bsay the word\b|\bif you (want|would like|are interested)\b|\bhappy to (send|open|submit|provide)\b|\b(fix|fixes|patch|pull request|PR|change) (is |are )?(ready|available|done)\b'
163
+ link_re='https://github\.com/[^/[:space:]]+/[^/[:space:]]+/(tree|commit|compare|blob|pull)/[^[:space:])]+'
156
164
  offer=$(printf '%s' "$body" | grep -ioE "$offer_re" | head -1) || true
157
165
  if [ -n "$offer" ] && ! printf '%s' "$body" | grep -qE "$link_re"; then
158
166
  echo "REFUSED: the body says '$offer' and links no branch or commit - push the work to your fork and link it, or take it out of the body" >&2
@@ -106,6 +106,7 @@ policy_result=$(grep -m1 '^RESULT: ' "$policy" | awk '{print $2}')
106
106
  case "$policy_result" in
107
107
  BAN) echo "REFUSED: $repo forbids AI-written contributions ($policy); never file here" >&2; exit 2 ;;
108
108
  DISCLOSE|MENTION|CLEAN) ;;
109
+ UNREAD) echo "REFUSED: $policy reads UNREAD - GitHub did not give every policy file; rerun check-ai-policy.sh --out" >&2; exit 2 ;;
109
110
  *) echo "REFUSED: $policy has no RESULT line - rerun check-ai-policy.sh --out" >&2; exit 2 ;;
110
111
  esac
111
112
 
@@ -157,21 +158,53 @@ if [ "$bodyfile" = "-" ] || [ ! -f "$bodyfile" ]; then
157
158
  fi
158
159
  check_text "body file $bodyfile" "$(cat "$bodyfile")"
159
160
  check_text "--title" "$title"
160
- words=$(wc -w < "$bodyfile" | tr -d ' ')
161
+ # The project's own template text is kept verbatim and is not ours to count:
162
+ # HTML comments and every line the saved template carries, a ticked box read
163
+ # as its unticked line, are left out before the words are counted.
164
+ template="$home/policy/template-${repo%%/*}-${repo##*/}.md"
165
+ [ -f "$template" ] || template=/dev/null
166
+ words=$(awk -v T="$template" '
167
+ function norm(s) { gsub(/\[[xX]\]/, "[ ]", s); gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s }
168
+ BEGIN { while ((getline l < T) > 0) { l = norm(l); if (l != "") t[l] = 1 } }
169
+ { l = norm($0); if (l in t) next
170
+ for (k in t) if (index(l, k) == 1) { l = substr(l, length(k) + 1); break }
171
+ print l }' "$bodyfile" | wc -w | tr -d ' ')
161
172
  if [ "$words" -gt 120 ]; then
162
- echo "REFUSED: the body is $words words - say what was wrong, what the change does and how it was verified in 120 or fewer" >&2
173
+ echo "REFUSED: the body is $words words outside the project's template - say what was wrong, what the change does and how it was verified in 120 or fewer" >&2
163
174
  exit 2
164
175
  fi
165
176
  if [ "$policy_result" = "DISCLOSE" ] && ! grep -qiE '\bAI\b|LLM|Claude|Copilot|ChatGPT|language model|assist' "$bodyfile"; then
166
177
  echo "REFUSED: $repo requires AI use to be disclosed ($policy) and the body does not name it" >&2
167
178
  exit 2
168
179
  fi
180
+ if ! refs=$(bash "$(dirname "$0")/ref-check.sh" "$repo" "$bodyfile" --clone . 2>&1); then
181
+ echo "REFUSED: the body names something that does not exist:" >&2
182
+ printf '%s\n' "$refs" | grep -E 'NOT FOUND|REFUSED' >&2
183
+ exit 2
184
+ fi
169
185
 
170
186
  head=$(git rev-parse HEAD 2>/dev/null) || { echo "REFUSED: run file-pr.sh from inside the clone being filed" >&2; exit 2; }
171
187
  if [ ! -f "$home/branch-checks/$head" ]; then
172
188
  echo "REFUSED: branch-check.sh has not passed head ${head:0:12} - run it from this clone first" >&2
173
189
  exit 2
174
190
  fi
191
+ # The prior art has to be about this change: every file the branch changes,
192
+ # tests aside, is a term in the printout, by path or by name. One printout per
193
+ # repository means another branch's run replaces it.
194
+ searched=$(sed -n 's/^### //p' "$art" | tr -d '\r')
195
+ ours=$(sed -n 's/^file: //p' "$home/branch-checks/$head" | tr -d '\r')
196
+ [ -n "$ours" ] || { echo "REFUSED: the branch-check stamp for head ${head:0:12} lists no files - rerun branch-check.sh" >&2; exit 2; }
197
+ unsearched=""
198
+ while IFS= read -r f; do
199
+ case "$f" in ""|*test*|*Test*|*spec*) continue ;; esac
200
+ printf '%s\n' "$searched" | grep -qxF -e "$f" -e "$(basename "$f")" || unsearched="$unsearched $f"
201
+ done <<EOF
202
+ $ours
203
+ EOF
204
+ if [ -n "$unsearched" ]; then
205
+ echo "REFUSED: the prior art at $art never searched files this branch changes:$unsearched - rerun prior-art.sh $repo with them --out" >&2
206
+ exit 2
207
+ fi
175
208
  # Every diff gate read the diff against the stamp's base; it must be the branch this files into.
176
209
  checked=$(sed -n 's/^base-ref: //p' "$home/branch-checks/$head" | tr -d '\r')
177
210
  if [ -z "$checked" ]; then
@@ -188,6 +221,48 @@ case "$checked" in
188
221
  *) echo "REFUSED: branch-check.sh measured this head against $checked, but it is filed into $target - rerun branch-check.sh --base <remote>/$target" >&2
189
222
  exit 2 ;;
190
223
  esac
224
+ # The upstream may have moved since the check. A move that touches none of the
225
+ # branch's files is reported; one that touches them means the branch and its
226
+ # review were measured against code that is no longer there.
227
+ stamped=$(sed -n 's/^base: //p' "$home/branch-checks/$head" | tr -d '\r')
228
+ upstream=$(gh api "repos/$repo/branches/$target" --jq .commit.sha 2>/dev/null | tr -d '\r')
229
+ [ -n "$upstream" ] || { echo "REFUSED: cannot read $target of $repo to confirm the base is current" >&2; exit 2; }
230
+ if [ "$stamped" != "$upstream" ]; then
231
+ if ! cmp=$(gh api "repos/$repo/compare/$stamped...$upstream" 2>/dev/null); then
232
+ echo "REFUSED: the checked base ${stamped:0:12} is not on $repo $target - fetch, rebase and rerun branch-check.sh" >&2
233
+ exit 2
234
+ fi
235
+ # GitHub lists at most 300 files here; past that the overlap cannot be known.
236
+ if [ "$(printf '%s' "$cmp" | jq '.files | length')" -ge 300 ]; then
237
+ echo "REFUSED: $target moved by 300 files or more since branch-check (${stamped:0:12} to ${upstream:0:12}) - fetch, rebase, rerun branch-check.sh and the review" >&2
238
+ exit 2
239
+ fi
240
+ moved=$(printf '%s' "$cmp" | jq -r '.files[].filename')
241
+ overlap=$(printf '%s\n' "$moved" | grep -Fx -f <(printf '%s\n' "$ours"))
242
+ if [ -n "$overlap" ]; then
243
+ echo "REFUSED: $target moved since branch-check (${stamped:0:12} to ${upstream:0:12}) and changed files this branch changes - fetch, rebase, rerun branch-check.sh and the review:" >&2
244
+ printf '%s\n' "$overlap" | sed 's/^/ /' >&2
245
+ exit 2
246
+ fi
247
+ echo "note: $target moved since branch-check (${stamped:0:12} to ${upstream:0:12}); none of its new commits touch this branch's files" >&2
248
+ fi
249
+ # Three open pull requests per repository, and ten minutes between filings:
250
+ # GitHub lets maintainers cap a non-collaborator's open pull requests, and two
251
+ # stamped the same minute read as a bot.
252
+ if ! mine=$(gh pr list -R "$repo" --author @me --state open --json createdAt --jq '[.[].createdAt] | sort' 2>/dev/null); then
253
+ echo "REFUSED: cannot read your open pull requests on $repo" >&2
254
+ exit 2
255
+ fi
256
+ open_count=$(printf '%s' "$mine" | jq 'length')
257
+ if [ "$open_count" -ge 3 ]; then
258
+ echo "REFUSED: you have $open_count open pull requests on $repo - three is the most a repository gets" >&2
259
+ exit 2
260
+ fi
261
+ since=$(printf '%s' "$mine" | jq -r --argjson now "$(date -u +%s)" 'if length > 0 then $now - (last | fromdateiso8601) else 99999 end')
262
+ if [ "$since" -lt 600 ]; then
263
+ echo "REFUSED: your last pull request on $repo was filed ${since}s ago - wait until ten minutes have passed" >&2
264
+ exit 2
265
+ fi
191
266
  review="$home/reviews/review-${repo%%/*}-${repo##*/}-${head:0:12}.md"
192
267
  if [ ! -f "$review" ]; then
193
268
  echo "REFUSED: no adversarial review at $review - run review.sh brief and have a different model attack this head" >&2
@@ -201,4 +276,7 @@ if [ "${FILE_PR_DRY:-0}" = "1" ]; then
201
276
  printf '\n'
202
277
  exit 0
203
278
  fi
279
+ # What was filed, kept so verify-filed-pr.sh can compare GitHub's copy to it.
280
+ mkdir -p "$home/filed"
281
+ cp "$bodyfile" "$home/filed/${repo%%/*}-${repo##*/}-$head.md"
204
282
  gh pr create -R "$repo" "${pass[@]}"
@@ -53,6 +53,17 @@ while read -r repo n; do
53
53
  IFS='|' read -r owner fork branch state <<< "$info"
54
54
  if [ "$owner" != "$ME" ] || [ -z "$branch" ]; then continue; fi
55
55
  gh api "repos/$ME/$fork/git/refs/heads/$branch" >/dev/null 2>&1 || continue
56
+ # A closed pull request refiled from the same branch still needs it, and
57
+ # deleting the branch would close the open one.
58
+ if ! users=$(gh pr list --repo "$repo" --head "$branch" --state open --json number,headRepositoryOwner \
59
+ --jq "[.[] | select(.headRepositoryOwner.login == \"$ME\") | .number] | join(\" \")" 2>/dev/null); then
60
+ echo "fork-hygiene.sh: cannot list open pull requests on $repo; $branch kept" >&2
61
+ continue
62
+ fi
63
+ if [ -n "$users" ]; then
64
+ echo "kept $ME/$fork $branch: open pull request $repo#$users still uses it"
65
+ continue
66
+ fi
56
67
  if [ "$DRY" = 1 ]; then
57
68
  echo "would delete $ME/$fork $branch ($state $repo#$n)"
58
69
  continue
@@ -31,14 +31,16 @@ set -u
31
31
 
32
32
  usage() {
33
33
  cat <<'EOF'
34
- usage: prior-art.sh owner/repo <term>... [--out]
34
+ usage: prior-art.sh owner/repo <term>... [--out [--partial]]
35
35
  prior-art.sh --help
36
36
 
37
37
  owner/repo the upstream repository to search
38
38
  <term> a symbol, phrase or file path; a term with a slash or a dot is
39
39
  also treated as a path: its commit history is scanned and
40
40
  every open PR that changes it is listed
41
- --out save the printout under $HOUSEBROKEN_HOME/prior-art/
41
+ --out save the printout under $HOUSEBROKEN_HOME/prior-art/; a printout
42
+ whose searches matched more than they returned is not saved
43
+ --partial save a partial printout anyway, marked as such
42
44
 
43
45
  environment:
44
46
  HOUSEBROKEN_HOME work directory, default $HOME/.housebroken
@@ -54,10 +56,12 @@ command -v jq >/dev/null 2>&1 || { echo "prior-art.sh: jq is not on PATH" >&2; e
54
56
 
55
57
  repo=""
56
58
  out=0
59
+ accept_partial=0
57
60
  terms=()
58
61
  for arg in "$@"; do
59
62
  case "$arg" in
60
63
  --out) out=1 ;;
64
+ --partial) accept_partial=1 ;;
61
65
  --*) usage >&2; echo "prior-art.sh: unknown option $arg" >&2; exit 1 ;;
62
66
  */*) if [ -z "$repo" ]; then repo="$arg"; else terms+=("$arg"); fi ;;
63
67
  *) if [ -z "$repo" ]; then usage >&2; exit 1; fi
@@ -70,6 +74,8 @@ fi
70
74
 
71
75
  home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
72
76
  calls=0
77
+ partial=0
78
+ notes=""
73
79
  items=0
74
80
  tmp=$(mktemp) || { echo "prior-art.sh: cannot create temp file" >&2; exit 1; }
75
81
  row=$(mktemp) || { echo "prior-art.sh: cannot create temp file" >&2; exit 1; }
@@ -84,10 +90,20 @@ last_word() {
84
90
  printf '%s' "$body" | tr '\n\r\t|' ' ' | tr -s ' ' | cut -c1-160
85
91
  }
86
92
 
87
- emit() {
88
- local kind num state date author title word=""
93
+ emit() { # label for the counts: issues or pull requests
94
+ local label="$1" kind num state date author title word=""
89
95
  while IFS=$'\t' read -r kind num state date author title; do
90
96
  [ -n "$num" ] || continue
97
+ # The search's own count against the rows it returned: a term matching more
98
+ # than one page is a printout that has not read everything.
99
+ if [ "$kind" = "TOTAL" ]; then
100
+ if [ "$num" -gt "$state" ]; then
101
+ notes="${notes}showing $state of $num $label for '$term'; narrow the term
102
+ "
103
+ partial=1
104
+ fi
105
+ continue
106
+ fi
91
107
  word=""
92
108
  if [ "$state" = "closed" ]; then word=$(last_word "$num" "$author"); calls=$((calls+1)); fi
93
109
  printf '| %s | #%s | %s | %s | %s | %s | %s |\n' \
@@ -106,20 +122,22 @@ for term in "${terms[@]}"; do
106
122
  } >>"$tmp"
107
123
 
108
124
  gh api -X GET search/issues -f q="repo:$repo type:issue \"$term\"" -F per_page=10 \
109
- --jq '.items[] | ["issue", (.number|tostring), .state, ((.closed_at // .created_at)[0:10]),
110
- .user.login, (.title|gsub("[|]";"/")|gsub("[\n\r\t]";" "))] | @tsv' >"$row" ||
125
+ --jq '(["TOTAL", (.total_count|tostring), (if .incomplete_results then "0" else (.items|length|tostring) end)] | @tsv),
126
+ (.items[] | ["issue", (.number|tostring), .state, ((.closed_at // .created_at)[0:10]),
127
+ .user.login, (.title|gsub("[|]";"/")|gsub("[\n\r\t]";" "))] | @tsv)' >"$row" ||
111
128
  { echo "prior-art.sh: issue search failed for $repo term '$term'" >&2; exit 1; }
112
129
  calls=$((calls+1))
113
- emit
130
+ emit issues
114
131
 
115
132
  gh api -X GET search/issues -f q="repo:$repo type:pr \"$term\"" -F per_page=10 \
116
- --jq '.items[] | ["PR", (.number|tostring),
133
+ --jq '(["TOTAL", (.total_count|tostring), (if .incomplete_results then "0" else (.items|length|tostring) end)] | @tsv),
134
+ (.items[] | ["PR", (.number|tostring),
117
135
  (if .pull_request.merged_at then "merged" else .state end),
118
136
  ((.pull_request.merged_at // .closed_at // .created_at)[0:10]),
119
- .user.login, (.title|gsub("[|]";"/")|gsub("[\n\r\t]";" "))] | @tsv' >"$row" ||
137
+ .user.login, (.title|gsub("[|]";"/")|gsub("[\n\r\t]";" "))] | @tsv)' >"$row" ||
120
138
  { echo "prior-art.sh: PR search failed for $repo term '$term'" >&2; exit 1; }
121
139
  calls=$((calls+1))
122
- emit
140
+ emit "pull requests"
123
141
 
124
142
  # a path term also gets the PRs whose merge commits touched that path
125
143
  case "$term" in
@@ -146,6 +164,13 @@ for term in "${terms[@]}"; do
146
164
  ($p.title|gsub("[|]";"/")|gsub("[\n\r\t]";" ")), .] | @tsv' >"$openfiles" ||
147
165
  { echo "prior-art.sh: open PR listing failed for $repo" >&2; exit 1; }
148
166
  calls=$((calls+1))
167
+ open_total=$(gh api -X GET search/issues -f q="repo:$repo is:pr is:open" --jq .total_count 2>/dev/null) || open_total=""
168
+ calls=$((calls+1))
169
+ if [ -z "$open_total" ] || [ "$open_total" -gt 200 ]; then
170
+ notes="${notes}OPEN PR LIST INCOMPLETE: 200 of ${open_total:-an unknown number of} open pull requests read; one changing these paths may be missing
171
+ "
172
+ partial=1
173
+ fi
149
174
  # GitHub returns at most 100 files per PR here; a larger PR is read in full
150
175
  awk -F'\t' '$5 == "MORE"' "$openfiles" | while IFS=$'\t' read -r num date author title _; do
151
176
  gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" --jq '.[].filename' |
@@ -167,8 +192,16 @@ done
167
192
  printf '\n%d items across %d terms. Read every closed item before filing; this script lists, it does not judge.\n' \
168
193
  "$items" "${#terms[@]}" >>"$tmp"
169
194
  printf 'gh calls: %d\n' "$calls" >>"$tmp"
195
+ if [ "$partial" -eq 1 ]; then
196
+ printf '\nPARTIAL: these searches returned less than they matched:\n%s' "$notes" >>"$tmp"
197
+ [ "$accept_partial" -eq 1 ] && printf 'saved with --partial: the rest was not read\n' >>"$tmp"
198
+ fi
170
199
 
171
200
  cat "$tmp"
201
+ if [ "$out" -eq 1 ] && [ "$partial" -eq 1 ] && [ "$accept_partial" -eq 0 ]; then
202
+ echo "prior-art.sh: not saved - the printout is partial; narrow the terms, or pass --partial to save it as read" >&2
203
+ exit 1
204
+ fi
172
205
  if [ "$out" -eq 1 ]; then
173
206
  dir="$home/prior-art"
174
207
  mkdir -p "$dir" || { echo "prior-art.sh: cannot create $dir" >&2; exit 1; }
@@ -0,0 +1,114 @@
1
+ #!/bin/bash
2
+ # ref-check.sh - every reference in outbound text points at something that
3
+ # exists.
4
+ #
5
+ # A model writes "Fixes #412", a link, a test name or a commit hash with the
6
+ # same fluency whether the thing exists or not, and a maintainer who follows
7
+ # one to nothing stops reading. Each reference is looked up: an issue or pull
8
+ # request number on GitHub (its type and state printed, so "Fixes" naming a
9
+ # merged pull request is visible), a github.com link, and, with --clone, a
10
+ # file path, a test name or a commit hash named in backticks. Fenced code and
11
+ # quoted lines are evidence, not references, and are skipped.
12
+ #
13
+ # Usage:
14
+ # bash ref-check.sh owner/repo FILE [--clone DIR]
15
+ # bash ref-check.sh --help
16
+ #
17
+ # Exits 0 when every reference was found, 1 when any was not, 2 on a usage
18
+ # error. A lookup GitHub refuses reads NOT FOUND: unconfirmed is not found.
19
+ #
20
+ # Assumes gh is installed and authenticated. Read-only: GET calls only.
21
+ set -u
22
+
23
+ usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; }
24
+
25
+ case "${1:-}" in
26
+ --help|-h) usage; exit 0 ;;
27
+ esac
28
+ repo="${1:-}"; file="${2:-}"
29
+ case "$repo" in */*) ;; *) echo "REFUSED: name the repository as owner/repo" >&2; exit 2 ;; esac
30
+ [ -f "$file" ] || { echo "REFUSED: $file does not exist" >&2; exit 2; }
31
+ shift 2
32
+ clone=""
33
+ while [ "$#" -gt 0 ]; do
34
+ case "$1" in
35
+ --clone) clone="${2:-}"; [ -d "$clone" ] || { echo "REFUSED: --clone needs a directory" >&2; exit 2; }; shift ;;
36
+ *) echo "REFUSED: unknown argument $1" >&2; exit 2 ;;
37
+ esac
38
+ shift
39
+ done
40
+
41
+ text=$(awk '/^(```|~~~)/ {fence = !fence; next} !fence && !/^ *>/ && !/^ / {print}' "$file")
42
+ found() { echo " found $1"; }
43
+ lost() { echo " NOT FOUND $1"; }
44
+
45
+ # owner/repo#N and bare #N, the bare ones in this repository
46
+ issue() { # owner/repo number label
47
+ local got
48
+ if got=$(gh api "repos/$1/issues/$2" --jq '(if .pull_request then "pull request" else "issue" end) + " " + .state + ": " + .title' 2>/dev/null); then
49
+ found "$3 ($got)"
50
+ else
51
+ lost "$3"
52
+ fi
53
+ }
54
+ out=$(
55
+ printf '%s\n' "$text" | grep -oE '\b[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+\b' | sort -u | while IFS= read -r ref; do
56
+ issue "${ref%#*}" "${ref##*#}" "$ref"
57
+ done
58
+ printf '%s\n' "$text" | sed -E 's/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+//g' | grep -oE '(^|[^A-Za-z0-9&/])#[0-9]+\b' \
59
+ | grep -oE '[0-9]+' | sort -un | while IFS= read -r n; do
60
+ issue "$repo" "$n" "#$n"
61
+ done
62
+ printf '%s\n' "$text" | grep -oE 'https://github\.com/[^ )>"]+' | sed 's/[.,;:]$//' | sort -u | while IFS= read -r url; do
63
+ path="${url#https://github.com/}"
64
+ case "$path" in
65
+ */*/pull/[0-9]*|*/*/issues/[0-9]*)
66
+ r=$(printf '%s' "$path" | cut -d/ -f1-2); n=$(printf '%s' "$path" | cut -d/ -f4 | grep -oE '^[0-9]+')
67
+ issue "$r" "$n" "$url" ;;
68
+ */*/commit/*)
69
+ r=$(printf '%s' "$path" | cut -d/ -f1-2); c=$(printf '%s' "$path" | cut -d/ -f4)
70
+ if gh api "repos/$r/commits/$c" --jq .sha >/dev/null 2>&1; then found "$url"; else lost "$url"; fi ;;
71
+ */*/blob/*|*/*/tree/*)
72
+ r=$(printf '%s' "$path" | cut -d/ -f1-2); ref=$(printf '%s' "$path" | cut -d/ -f4); p=$(printf '%s' "$path" | cut -d/ -f5- | sed 's/#.*//')
73
+ if gh api "repos/$r/contents/$p?ref=$ref" --jq 'if type == "array" then "dir" else .path end' >/dev/null 2>&1; then found "$url"; else lost "$url"; fi ;;
74
+ esac
75
+ done
76
+ )
77
+
78
+ # Names in backticks, checked against the clone: a path, a test, a commit.
79
+ if [ -n "$clone" ]; then
80
+ # shellcheck disable=SC2016 # the backticks are the markdown being matched
81
+ names=$(printf '%s\n' "$text" | grep -oE '`[^`]+`' | tr -d '`' | sort -u)
82
+ local_out=$(printf '%s\n' "$names" | while IFS= read -r n; do
83
+ [ -n "$n" ] || continue
84
+ if printf '%s' "$n" | grep -qE '^[0-9a-f]{7,40}$'; then
85
+ if git -C "$clone" cat-file -e "$n^{commit}" 2>/dev/null; then found "commit $n"; else lost "commit $n"; fi
86
+ elif printf '%s' "$n" | grep -qE '^[A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,6}$'; then
87
+ # A dotted name is a file when the clone has it; without a slash it may
88
+ # be an identifier (os.path, t.Run, v1.2.3), so only a path is NOT FOUND.
89
+ if git -C "$clone" ls-files --error-unmatch -- "$n" >/dev/null 2>&1 \
90
+ || git -C "$clone" ls-files | grep -qE "(^|/)$(printf '%s' "$n" | sed 's/[.[\*^$]/\\&/g')$"; then
91
+ found "file $n"
92
+ elif printf '%s' "$n" | grep -q /; then
93
+ lost "file $n"
94
+ fi
95
+ elif printf '%s' "$n" | grep -qE '^(Test[A-Z][A-Za-z0-9_]*|test_[A-Za-z0-9_]+|[A-Za-z0-9_]+_test|[A-Z][A-Za-z0-9_]*Tests?)$'; then
96
+ if git -C "$clone" grep -qw -- "$n" 2>/dev/null; then found "test $n"; else lost "test $n"; fi
97
+ fi
98
+ done)
99
+ out=$(printf '%s\n%s\n' "$out" "$local_out")
100
+ fi
101
+ out=$(printf '%s\n' "$out" | sed '/^$/d')
102
+
103
+ if [ -z "$out" ]; then
104
+ echo "ref-check: no references in $file"
105
+ exit 0
106
+ fi
107
+ echo "ref-check: every reference in $file, looked up:"
108
+ printf '%s\n' "$out"
109
+ if printf '%s\n' "$out" | grep -q 'NOT FOUND'; then
110
+ echo
111
+ echo "ref-check: a reference points at nothing; fix it or drop it."
112
+ exit 1
113
+ fi
114
+ exit 0
@@ -216,9 +216,10 @@ EOF
216
216
  done
217
217
  printf 'author-model: %s\nreviewer-model: %s\n' "$author" "$reviewer"
218
218
  [ -n "$head" ] && printf 'blind-fix: %s\n' "$blind"
219
+ printf 'skipped: none | <the item numbers you did not attack, each with why>\n'
219
220
  printf 'verdict: POST AS IS | POST WITH CHANGES | DO NOT POST\n\n'
220
221
  cat <<'EOF'
221
- POST AS IS means nothing BLOCKING or SHOULD FIX remains. POST WITH CHANGES means the author fixes what you found and the fixed version comes back for another review. DO NOT POST means it should not go at all. After the header, number your findings, each with a severity (BLOCKING, SHOULD FIX or NIT), the evidence, and the exact fix, with replacement text in full for any wording. A report with no findings says what was checked and how.
222
+ POST AS IS means nothing BLOCKING or SHOULD FIX remains and nothing was skipped. POST WITH CHANGES means the author fixes what you found and the fixed version comes back for another review. DO NOT POST means it should not go at all. An item that does not apply to this change is not skipped: say in its place why it does not apply. A skipped item is one you did not do, for time, tools or access, and the gate refuses POST AS IS over it. After the header, number your findings, each on its own line as "N. SEVERITY: title" with SEVERITY one of BLOCKING, SHOULD FIX or NIT, then the evidence, and the exact fix, with replacement text in full for any wording. The gate reads those lines: a BLOCKING or SHOULD FIX line under POST AS IS is refused, so a later round that lists an earlier round's findings as fixed renumbers them as NIT or drops the severity. A report with no findings says what was checked and how. The blind fix is a unified diff with its hunks.
222
223
  EOF
223
224
  }
224
225
 
@@ -261,8 +262,22 @@ cmd_check() {
261
262
  blind=$(field blind-fix)
262
263
  [ -n "$blind" ] || refuse "$report reviews code at ${head:0:12} and names no blind-fix: the reviewer's own fix, written before reading ours"
263
264
  [ -f "$blind" ] || refuse "the blind fix $blind named in $report does not exist"
265
+ if ! grep -qE '^(diff --git |--- )' "$blind" || ! grep -q '^@@' "$blind"; then
266
+ refuse "the blind fix $blind is not a diff: it must carry the reviewer's own change, hunks and all"
267
+ fi
264
268
  fi
265
269
 
270
+ # The verdict is the header; the findings are the body. A POST AS IS over an
271
+ # open BLOCKING or SHOULD FIX finding, or over items the reviewer never
272
+ # attacked, is a header that disagrees with its own report.
273
+ local skipped open
274
+ skipped=$(field skipped)
275
+ [ -n "$skipped" ] || refuse "$report has no skipped: line in its header - 'none', or the items not attacked"
276
+ printf '%s' "$skipped" | grep -qiE '^none\.?$' ||
277
+ refuse "the skipped: line must read exactly none for POST AS IS, or list the items not attacked ($skipped): attack them or review again"
278
+ open=$(grep -iE '^[[:space:]#*]*([0-9]+[.)]|[-*])[[:space:]*[]*(BLOCKING|SHOULD FIX)\b|^[[:space:]*-]*severity:[[:space:]*]*(BLOCKING|SHOULD FIX)\b' "$report")
279
+ [ -z "$open" ] || refuse "the verdict is POST AS IS but the report lists open findings: $(printf '%s' "$open" | head -1 | cut -c1-120)"
280
+
266
281
  if [ -n "$clone" ]; then
267
282
  now=$(git -C "$clone" rev-parse HEAD 2>/dev/null) || bad_usage "$clone is not a git clone"
268
283
  [ -n "$head" ] || refuse "$report records no head, so it cannot clear a branch"
@@ -66,6 +66,36 @@ echo ""
66
66
  echo "-- files in the PR (what a reviewer sees)"
67
67
  view files '.files[] | " " + .path + " +" + (.additions|tostring) + "/-" + (.deletions|tostring)'
68
68
 
69
+ # The intended files are the branch-check stamp's, and the intended body is the
70
+ # copy file-pr.sh kept; GitHub's versions are compared to both.
71
+ home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
72
+ stamp="$home/branch-checks/$head"
73
+ intended=""
74
+ [ -f "$stamp" ] && intended=$(sed -n 's/^file: //p' "$stamp" | tr -d '\r' | sort)
75
+ if [ -n "$intended" ]; then
76
+ filed=$(view files '.files[].path' | sort)
77
+ if [ "$intended" = "$filed" ]; then
78
+ note "files match the stamp" "yes"
79
+ else
80
+ note "FILES DIFFER FROM STAMP" "$(diff <(printf '%s\n' "$intended") <(printf '%s\n' "$filed") | grep '^[<>]' | tr '\n' ' ')"
81
+ fail=1
82
+ fi
83
+ else
84
+ note "files" "no branch-check stamp listing files for ${head:0:12}; not compared"
85
+ fi
86
+ kept="$home/filed/${repo%%/*}-${repo##*/}-$head.md"
87
+ if [ -f "$kept" ]; then
88
+ norm() { tr -d '\r' | sed -e 's/[[:space:]]*$//' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'; }
89
+ if [ "$(view body .body | norm)" = "$(norm < "$kept")" ]; then
90
+ note "body matches what was filed" "yes"
91
+ else
92
+ note "BODY DIFFERS" "from $kept; read the diff, a maintainer may have edited it"
93
+ diff <(norm < "$kept") <(view body .body | norm) | head -20 | sed 's/^/ /'
94
+ fi
95
+ else
96
+ note "body" "no filed copy for ${head:0:12}; not compared"
97
+ fi
98
+
69
99
  echo ""
70
100
  echo "-- checks (waits until every check has settled)"
71
101
  gh pr checks "$pr" --repo "$repo" --watch --interval 20 >/dev/null 2>&1 || true
@@ -43,7 +43,10 @@ report it in one line and do not proceed to the next step.
43
43
  patch is worth. DISCLOSE means the body says, in one truthful sentence
44
44
  and in the template's own words where it has them, that AI was used and
45
45
  which tool. MENTION means read the lines and decide which of the two it
46
- is. The printout is required by step 9. If the project accepts pull
46
+ is. UNREAD means GitHub did not give every policy file: wait and run it
47
+ again, never file on it; a repository too large for GitHub to list in
48
+ full reads UNREAD until you have read its policy by hand and passed
49
+ `--accept-truncated`. The printout is required by step 9. If the project accepts pull
47
50
  requests only for labelled issues, the deliverable is an issue with the
48
51
  fix offered, not a pull request. `housebroken outside owner/repo` counts
49
52
  outside contributors merged in the last 120 days. Zero means the project
@@ -56,8 +59,11 @@ report it in one line and do not proceed to the next step.
56
59
  every closed item. Read every closed item. An open pull request that fixes
57
60
  the same thing means you stop and say so. A closed issue that ruled the
58
61
  behavior intended means you stop and say so; you never argue with a
59
- ruling. The printout is required by step 9; write it before the branch
60
- exists.
62
+ ruling. A term that matches more than one page is printed as "showing N
63
+ of M" and the printout is not saved: narrow the term, or pass `--partial`
64
+ when you have read enough to say so. The printout is required by step 9;
65
+ write it before the branch exists, with every file the patch changes
66
+ among its terms, because the filing gate checks them against the branch.
61
67
 
62
68
  3. **Is it a fix or an opinion?**
63
69
  A change that rejects an input the project tolerated, changes a default,
@@ -136,7 +142,10 @@ report it in one line and do not proceed to the next step.
136
142
  the body and re-measure every line it lists, now, against the exact
137
143
  revision that sentence names. A claim that names its revision can still
138
144
  be wrong about it, which is why the listing is the whole gate: the
139
- re-derivation is yours to do.
145
+ re-derivation is yours to do. Run `housebroken refs owner/repo <file>
146
+ --clone <clone>` too: every issue number, link, test name, file and
147
+ commit the text names is looked up, and one that does not exist stops
148
+ the filing.
140
149
 
141
150
  7. **Know the paperwork.**
142
151
  Before the first pull request to an organization, find its agreement: CLA,
@@ -157,11 +166,19 @@ report it in one line and do not proceed to the next step.
157
166
  whose mode disagrees with its own siblings, a file the branch adds that the
158
167
  repository ignores, a commit carrying a tool trailer, a session link or a
159
168
  tool identity, a sign-off that names a username, a branch that is not on
160
- top of its base, any comment added to code (`--asked <URL>` when the
161
- maintainer asked for it), a warning suppression the branch adds until
162
- `--warning-log` shows each suppressed warning firing, and reflection added
163
- to a .NET project that declares AOT or trim support until `--aot-log`
164
- shows an AOT publish you ran the changed path under; it prints every absolute claim your added prose
169
+ top of its base, a base that is not a remote-tracking ref (it notes one the
170
+ upstream has moved past; the filing refuses it when the move touches your
171
+ files), any comment added to code
172
+ (`--asked <URL>` when the maintainer asked for it), a warning suppression
173
+ that names no warning or whose warning `--warning-log` does not show
174
+ firing, reflection added to a .NET project that declares AOT or trim
175
+ support until `--aot-log` shows an AOT publish of that project you ran the
176
+ changed path under, a credential or a path under your own home in an
177
+ added line, a
178
+ lockfile, vendored, generated or binary file (`--allow <path>` when the
179
+ change is about it), an agent's working file such as an added CLAUDE.md
180
+ or `.claude/`, and a file rewritten for its line endings or whitespace;
181
+ it prints every absolute claim your added prose
165
182
  makes so you can falsify each one against the code, and it stamps the
166
183
  head it passed, which the filing requires. Then the adversarial review:
167
184
  `housebroken review brief --author <your model> --repo owner/repo --clone <clone> --text <body file>`
@@ -180,26 +197,34 @@ report it in one line and do not proceed to the next step.
180
197
  the changed block the way the strongest engineer in that language would,
181
198
  and a smaller or clearer version is a finding you fix before filing. It
182
199
  writes its report where the brief says, with its own evidence behind
183
- every finding. Every fix it asks for is a new head,
200
+ every finding, each on its own line as "N. SEVERITY: title", and a
201
+ `skipped:` header line that reads `none` or lists what it did not attack.
202
+ The gate refuses a blind fix that is not a diff, a POST AS IS over a
203
+ BLOCKING or SHOULD FIX line, and a POST AS IS over skipped items.
204
+ Every fix it asks for is a new head,
184
205
  and a new head is reviewed again. When it passes, re-read every claim it
185
206
  did not re-derive itself: a passed review is not a proof. Then
186
207
  `housebroken file owner/repo --title ... --body-file ...`, the only way a
187
208
  pull request is opened. Never run the pull request creation command
188
209
  directly. The gate refuses when the prior-art printout for that repository
189
- is missing or older than a day, when the policy printout is missing, older
190
- than a day or reads BAN, when the policy reads DISCLOSE and the body does
191
- not disclose, when the body carries a footer, a trailer or a dash
192
- character or runs past 120 words, when `housebroken branch` has not
193
- stamped the exact head against the branch it is filed into (`--base`
194
- to both when the project takes pull requests on a branch other than its
195
- default), or when there is no POST AS IS review of the exact head and
196
- body being filed.
210
+ is missing, older than a day or never searched a file the branch changes,
211
+ when the policy printout is missing, older than a day or reads BAN or
212
+ UNREAD, when the policy reads DISCLOSE and the body does not disclose,
213
+ when the body carries a footer, a trailer or a dash character, runs past
214
+ 120 words of its own beside the project's template, or names an issue,
215
+ link, test, file or commit that does not exist, when `housebroken branch`
216
+ has not stamped the exact head against the branch it is filed into
217
+ (`--base` to both when the project takes pull requests on a branch other
218
+ than its default), when the upstream has since changed a file the branch
219
+ changes, when there is no POST AS IS review of the exact head and body
220
+ being filed, when you already have three open pull requests on the
221
+ repository, and when your last one there is under ten minutes old.
197
222
  A refusal is a result; fix the cause.
198
223
 
199
224
  10. **Watch it land.**
200
225
  `housebroken verify owner/repo PR` re-derives from GitHub that the head is
201
- the intended commit, the diff is exactly the intended files, and CI
202
- settled. `housebroken sweep` lists every open pull request where the ball
226
+ the intended commit, the files are the ones the branch stamp lists, the
227
+ body is the one that was filed, and CI settled. `housebroken sweep` lists every open pull request where the ball
203
228
  is in your court, and prints a `TIDY` line for every merged or closed
204
229
  pull request whose fork branch still exists, on every run until
205
230
  `housebroken hygiene` has deleted it. `housebroken inbox` is the first thing a session does
@@ -237,7 +262,8 @@ report it in one line and do not proceed to the next step.
237
262
 
238
263
  11. **Clean up.**
239
264
  `housebroken hygiene` deletes the fork branch of every merged or closed
240
- pull request and lists forks with no pull request left; delete those when
265
+ pull request, unless an open one still uses it, and lists forks with no
266
+ pull request left; delete those when
241
267
  the work is over. No planning file, agent directory, journal or build
242
268
  output ever enters a diff.
243
269
 
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -1 +0,0 @@
1
- __version__ = "0.6.9"
File without changes