cleaner-crew 0.1.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.
- cleaner_crew-0.1.0/.github/workflows/python-publish.yml +70 -0
- cleaner_crew-0.1.0/.gitignore +4 -0
- cleaner_crew-0.1.0/PKG-INFO +148 -0
- cleaner_crew-0.1.0/README.md +137 -0
- cleaner_crew-0.1.0/pyproject.toml +28 -0
- cleaner_crew-0.1.0/src/cleaner_crew/__init__.py +4 -0
- cleaner_crew-0.1.0/src/cleaner_crew/__main__.py +3 -0
- cleaner_crew-0.1.0/src/cleaner_crew/adapters/__init__.py +32 -0
- cleaner_crew-0.1.0/src/cleaner_crew/adapters/base.py +109 -0
- cleaner_crew-0.1.0/src/cleaner_crew/adapters/github.py +97 -0
- cleaner_crew-0.1.0/src/cleaner_crew/adapters/gitlab.py +77 -0
- cleaner_crew-0.1.0/src/cleaner_crew/adapters/jira.py +140 -0
- cleaner_crew-0.1.0/src/cleaner_crew/adapters/linear.py +147 -0
- cleaner_crew-0.1.0/src/cleaner_crew/claude.py +130 -0
- cleaner_crew-0.1.0/src/cleaner_crew/cli.py +166 -0
- cleaner_crew-0.1.0/src/cleaner_crew/config.py +124 -0
- cleaner_crew-0.1.0/src/cleaner_crew/detect.py +72 -0
- cleaner_crew-0.1.0/src/cleaner_crew/gitutil.py +114 -0
- cleaner_crew-0.1.0/src/cleaner_crew/hooks/__init__.py +0 -0
- cleaner_crew-0.1.0/src/cleaner_crew/hooks/guard.py +111 -0
- cleaner_crew-0.1.0/src/cleaner_crew/installer.py +320 -0
- cleaner_crew-0.1.0/src/cleaner_crew/models.py +145 -0
- cleaner_crew-0.1.0/src/cleaner_crew/mutation.py +150 -0
- cleaner_crew-0.1.0/src/cleaner_crew/orchestrator.py +362 -0
- cleaner_crew-0.1.0/src/cleaner_crew/policy.py +268 -0
- cleaner_crew-0.1.0/src/cleaner_crew/schemas.py +60 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/__init__.py +0 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/__init__.py +0 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/hooded.md +24 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/inspector.md +23 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/janitor.md +23 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/manager.md +37 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/scout.md +29 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/__init__.py +0 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/github-verify.yml +30 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/github.yml +49 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/gitlab.yml +38 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/policy.yml +71 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/systemd/__init__.py +0 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/systemd/cleaner-crew.service +9 -0
- cleaner_crew-0.1.0/src/cleaner_crew/templates/systemd/cleaner-crew.timer +10 -0
- cleaner_crew-0.1.0/src/cleaner_crew/trust.py +62 -0
- cleaner_crew-0.1.0/src/cleaner_crew/verify.py +54 -0
- cleaner_crew-0.1.0/tests/test_guard.py +73 -0
- cleaner_crew-0.1.0/tests/test_models_and_git.py +58 -0
- cleaner_crew-0.1.0/tests/test_orchestrator.py +249 -0
- cleaner_crew-0.1.0/tests/test_policy.py +96 -0
- cleaner_crew-0.1.0/tests/test_trust_mutation_verify.py +169 -0
- cleaner_crew-0.1.0/uv.lock +306 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# This workflow will upload a Python Package to PyPI when a release is created
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
|
3
|
+
|
|
4
|
+
# This workflow uses actions that are not certified by GitHub.
|
|
5
|
+
# They are provided by a third-party and are governed by
|
|
6
|
+
# separate terms of service, privacy policy, and support
|
|
7
|
+
# documentation.
|
|
8
|
+
|
|
9
|
+
name: Upload Python Package
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
release:
|
|
13
|
+
types: [published]
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
release-build:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.x"
|
|
28
|
+
|
|
29
|
+
- name: Build release distributions
|
|
30
|
+
run: |
|
|
31
|
+
# NOTE: put your own distribution build steps here.
|
|
32
|
+
python -m pip install build
|
|
33
|
+
python -m build
|
|
34
|
+
|
|
35
|
+
- name: Upload distributions
|
|
36
|
+
uses: actions/upload-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: release-dists
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
pypi-publish:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
needs:
|
|
44
|
+
- release-build
|
|
45
|
+
permissions:
|
|
46
|
+
# IMPORTANT: this permission is mandatory for trusted publishing
|
|
47
|
+
id-token: write
|
|
48
|
+
|
|
49
|
+
# Dedicated environments with protections for publishing are strongly recommended.
|
|
50
|
+
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
|
|
51
|
+
environment:
|
|
52
|
+
name: pypi
|
|
53
|
+
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
|
|
54
|
+
# url: https://pypi.org/p/YOURPROJECT
|
|
55
|
+
#
|
|
56
|
+
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
|
|
57
|
+
# ALTERNATIVE: exactly, uncomment the following line instead:
|
|
58
|
+
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- name: Retrieve release distributions
|
|
62
|
+
uses: actions/download-artifact@v4
|
|
63
|
+
with:
|
|
64
|
+
name: release-dists
|
|
65
|
+
path: dist/
|
|
66
|
+
|
|
67
|
+
- name: Publish release distributions to PyPI
|
|
68
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
69
|
+
with:
|
|
70
|
+
packages-dir: dist/
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cleaner-crew
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autonomous crew of Claude Code agents that picks up small, safe tasks and opens MRs.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: pyyaml>=6
|
|
8
|
+
Requires-Dist: rich>=13
|
|
9
|
+
Requires-Dist: typer>=0.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Cleaner Crew 🧹
|
|
13
|
+
|
|
14
|
+
A crew of Claude Code agents that picks up small, safe tasks from your tracker
|
|
15
|
+
(Linear, Jira), fixes them in isolation, and opens merge requests on GitHub or GitLab.
|
|
16
|
+
It only ships a change when a deterministic policy gate agrees, it never merges, and it
|
|
17
|
+
earns more autonomy per category only as humans accept its work.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
scout ──► proposed tickets (cleaner-crew:proposed)
|
|
21
|
+
│ a human adds the `cleaner-crew` label to the ones worth doing
|
|
22
|
+
candidate tickets (cleaner-crew)
|
|
23
|
+
│
|
|
24
|
+
manager ──► claim ──► triage + plan
|
|
25
|
+
│
|
|
26
|
+
├─► inspector write a failing test that reproduces the bug (bugfix)
|
|
27
|
+
├─► janitor implement the plan (cannot edit tests)
|
|
28
|
+
├─► inspector cover the change with tests (can only edit tests)
|
|
29
|
+
├─► tests + lint + diff + mutation testing, run by the orchestrator
|
|
30
|
+
└─► hooded read-only security review, can veto
|
|
31
|
+
│
|
|
32
|
+
policy gate + manager verdict + trust level ──► MR │ draft MR │ shadow │ escalate │ reject
|
|
33
|
+
│
|
|
34
|
+
target repo CI: cleaner-crew-verify (required check) + human approval ──► merge
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install into a repository
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
cd path/to/your/cloned/repo
|
|
41
|
+
uvx cleaner-crew init # or: uv tool install cleaner-crew && cleaner-crew init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`init` walks you through:
|
|
45
|
+
|
|
46
|
+
1. **Code host.** It detects GitHub/GitLab from `origin`, checks the token (or an existing
|
|
47
|
+
`gh` login) and verifies that it has push and MR permissions.
|
|
48
|
+
2. **Task manager.** You choose Linear or Jira, paste a token if one is missing, pick the
|
|
49
|
+
team/project, and map the todo / in progress / in review statuses.
|
|
50
|
+
3. **Tests.** It detects the stack, confirms the test and lint commands, and runs the suite
|
|
51
|
+
once. If the baseline is red, the crew is installed disabled.
|
|
52
|
+
4. **Files.** It writes `.cleaner-crew/config.yml`, `policy.yml` and `.claude/agents/cleaner-crew-*.md`.
|
|
53
|
+
5. **Runner.** Choose a scheduled CI job (GitHub Actions / GitLab schedule), a local
|
|
54
|
+
daemon (loop or systemd user timer), or both.
|
|
55
|
+
6. **Protection.** It adds the `cleaner-crew-verify` CI check, checks branch protection on
|
|
56
|
+
the default branch and tells you exactly what to turn on.
|
|
57
|
+
|
|
58
|
+
Secrets go to `.cleaner-crew/secrets.env` (gitignored, mode 600) or your CI secrets.
|
|
59
|
+
`config.yml` holds only the variable names.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
| command | what it does |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `cleaner-crew init` | install and connect |
|
|
66
|
+
| `cleaner-crew doctor` | re-check connections, CLI, test baseline, kill switch |
|
|
67
|
+
| `cleaner-crew run --dry-run` | scout and plan only; nothing is claimed, filed or pushed |
|
|
68
|
+
| `cleaner-crew run` | one run: up to `max_tasks_per_run` tickets |
|
|
69
|
+
| `cleaner-crew trust` | each category's earned trust level and acceptance rate |
|
|
70
|
+
| `cleaner-crew verify --base main` | CI check re-applying the base branch's policy to a crew branch |
|
|
71
|
+
| `cleaner-crew daemon` | run in a loop on this machine |
|
|
72
|
+
| `cleaner-crew stop` / `resume` | kill switch (commit `.cleaner-crew/STOP` to stop CI too) |
|
|
73
|
+
|
|
74
|
+
## How it earns trust
|
|
75
|
+
|
|
76
|
+
**Humans choose the work.** The scout only *proposes* (`cleaner-crew:proposed`). Adding
|
|
77
|
+
the `cleaner-crew` label takes about ten seconds and is far cheaper than reviewing a
|
|
78
|
+
pointless MR. At most `max_open_proposals` proposals are open at once.
|
|
79
|
+
|
|
80
|
+
**Autonomy per category is measured, not assumed.** An MR counts as *accepted* if it was
|
|
81
|
+
merged with no human commits pushed to its branch (crew commits carry `Cleaner-Crew-*`
|
|
82
|
+
git trailers). Over the last `trust.window` closed MRs per category:
|
|
83
|
+
|
|
84
|
+
| record | level | behaviour |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| fewer than `min_samples` | draft | MRs are opened as drafts |
|
|
87
|
+
| acceptance ≥ `promote_at` (80%) | ready | MRs are opened ready for review |
|
|
88
|
+
| acceptance < `demote_below` (50%) | shadow | the plan is posted on the ticket; nothing changes |
|
|
89
|
+
| otherwise | draft | |
|
|
90
|
+
|
|
91
|
+
The earned level is capped by each category's `max_level` in `policy.yml`. After fixing
|
|
92
|
+
whatever caused a demotion, set `trust_since` to today's date to start a fresh record.
|
|
93
|
+
History is read from the code host, so CI and local runners agree.
|
|
94
|
+
|
|
95
|
+
**Tests must actually test the change.** After tests pass, the crew mutates the changed
|
|
96
|
+
lines (`==`→`!=`, `<`→`<=`, `n`→`n+1`, `and`→`or`, ...) and re-runs the tests. If
|
|
97
|
+
fewer than `mutation.min_score` of the mutants are caught, the MR becomes a draft, and
|
|
98
|
+
the surviving mutants are listed in the MR.
|
|
99
|
+
|
|
100
|
+
## Protecting the target repo
|
|
101
|
+
|
|
102
|
+
The crew never merges. The repository's own protections are the real safety net:
|
|
103
|
+
|
|
104
|
+
- **Branch protection** on the default branch: require at least one approving review,
|
|
105
|
+
dismiss stale approvals, and require `cleaner-crew-verify` plus your test workflow.
|
|
106
|
+
- **`cleaner-crew-verify`** re-checks every `cleaner-crew/*` MR against the policy *from
|
|
107
|
+
the base branch*. It checks forbidden paths (including the crew's own config), size
|
|
108
|
+
limits, required tests, category enabled, and crew trailers present. On GitHub it runs
|
|
109
|
+
as `pull_request_target`, so an MR can't edit the check that judges it, and it never
|
|
110
|
+
executes the MR's code.
|
|
111
|
+
- **CODEOWNERS** for `/.cleaner-crew/`, `/.claude/` and CI config.
|
|
112
|
+
|
|
113
|
+
`cleaner-crew doctor` reports whether the default branch is adequately protected.
|
|
114
|
+
|
|
115
|
+
## Safety model
|
|
116
|
+
|
|
117
|
+
- **The policy gate is code, not a prompt.** `policy.yml` sets size limits, forbidden
|
|
118
|
+
paths, required tests and security approval. The manager's verdict can only be made
|
|
119
|
+
stricter by the policy.
|
|
120
|
+
- **Separate contexts.** Each role is a separate `claude -p` process. The inspector and
|
|
121
|
+
hooded agent see the plan and diff, never the janitor's reasoning.
|
|
122
|
+
- **Separation of duties.** Janitors cannot edit tests, and inspectors can only edit tests.
|
|
123
|
+
Scout, manager and hooded agents are read-only.
|
|
124
|
+
- **Three layers of tool restriction:** `--allowedTools` per role, the agent's `tools:`
|
|
125
|
+
frontmatter, and a PreToolUse guard hook (`cleaner_crew.hooks.guard`). The guard blocks
|
|
126
|
+
the network, git history commands, secrets files, anything outside the task worktree,
|
|
127
|
+
and the crew's own config.
|
|
128
|
+
- **Agents hold no credentials.** Tracker and code host tokens are stripped from agent
|
|
129
|
+
environments. Only the orchestrator claims tickets, commits, pushes and opens MRs.
|
|
130
|
+
- **Ticket text is untrusted.** It is wrapped as data. The hooded agent checks the diff
|
|
131
|
+
against the plan and flags suspected prompt injection, which blocks the change.
|
|
132
|
+
- **Limits:** max open crew MRs, a per-task cost cap, a green baseline requirement, and a
|
|
133
|
+
kill switch.
|
|
134
|
+
- **Audit trail:** every agent transcript, the test log and the outcome are kept under
|
|
135
|
+
`.cleaner-crew/runs/` (uploaded as a CI artifact).
|
|
136
|
+
|
|
137
|
+
## Extending
|
|
138
|
+
|
|
139
|
+
Add a tracker or code host by implementing `TaskSource` or `CodeHost` in
|
|
140
|
+
`src/cleaner_crew/adapters/base.py` and registering it in `adapters/__init__.py`.
|
|
141
|
+
Customise agent behaviour by editing `.claude/agents/cleaner-crew-*.md` in the target repo.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
uv sync
|
|
147
|
+
uv run pytest
|
|
148
|
+
```
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Cleaner Crew 🧹
|
|
2
|
+
|
|
3
|
+
A crew of Claude Code agents that picks up small, safe tasks from your tracker
|
|
4
|
+
(Linear, Jira), fixes them in isolation, and opens merge requests on GitHub or GitLab.
|
|
5
|
+
It only ships a change when a deterministic policy gate agrees, it never merges, and it
|
|
6
|
+
earns more autonomy per category only as humans accept its work.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
scout ──► proposed tickets (cleaner-crew:proposed)
|
|
10
|
+
│ a human adds the `cleaner-crew` label to the ones worth doing
|
|
11
|
+
candidate tickets (cleaner-crew)
|
|
12
|
+
│
|
|
13
|
+
manager ──► claim ──► triage + plan
|
|
14
|
+
│
|
|
15
|
+
├─► inspector write a failing test that reproduces the bug (bugfix)
|
|
16
|
+
├─► janitor implement the plan (cannot edit tests)
|
|
17
|
+
├─► inspector cover the change with tests (can only edit tests)
|
|
18
|
+
├─► tests + lint + diff + mutation testing, run by the orchestrator
|
|
19
|
+
└─► hooded read-only security review, can veto
|
|
20
|
+
│
|
|
21
|
+
policy gate + manager verdict + trust level ──► MR │ draft MR │ shadow │ escalate │ reject
|
|
22
|
+
│
|
|
23
|
+
target repo CI: cleaner-crew-verify (required check) + human approval ──► merge
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install into a repository
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd path/to/your/cloned/repo
|
|
30
|
+
uvx cleaner-crew init # or: uv tool install cleaner-crew && cleaner-crew init
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`init` walks you through:
|
|
34
|
+
|
|
35
|
+
1. **Code host.** It detects GitHub/GitLab from `origin`, checks the token (or an existing
|
|
36
|
+
`gh` login) and verifies that it has push and MR permissions.
|
|
37
|
+
2. **Task manager.** You choose Linear or Jira, paste a token if one is missing, pick the
|
|
38
|
+
team/project, and map the todo / in progress / in review statuses.
|
|
39
|
+
3. **Tests.** It detects the stack, confirms the test and lint commands, and runs the suite
|
|
40
|
+
once. If the baseline is red, the crew is installed disabled.
|
|
41
|
+
4. **Files.** It writes `.cleaner-crew/config.yml`, `policy.yml` and `.claude/agents/cleaner-crew-*.md`.
|
|
42
|
+
5. **Runner.** Choose a scheduled CI job (GitHub Actions / GitLab schedule), a local
|
|
43
|
+
daemon (loop or systemd user timer), or both.
|
|
44
|
+
6. **Protection.** It adds the `cleaner-crew-verify` CI check, checks branch protection on
|
|
45
|
+
the default branch and tells you exactly what to turn on.
|
|
46
|
+
|
|
47
|
+
Secrets go to `.cleaner-crew/secrets.env` (gitignored, mode 600) or your CI secrets.
|
|
48
|
+
`config.yml` holds only the variable names.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| command | what it does |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `cleaner-crew init` | install and connect |
|
|
55
|
+
| `cleaner-crew doctor` | re-check connections, CLI, test baseline, kill switch |
|
|
56
|
+
| `cleaner-crew run --dry-run` | scout and plan only; nothing is claimed, filed or pushed |
|
|
57
|
+
| `cleaner-crew run` | one run: up to `max_tasks_per_run` tickets |
|
|
58
|
+
| `cleaner-crew trust` | each category's earned trust level and acceptance rate |
|
|
59
|
+
| `cleaner-crew verify --base main` | CI check re-applying the base branch's policy to a crew branch |
|
|
60
|
+
| `cleaner-crew daemon` | run in a loop on this machine |
|
|
61
|
+
| `cleaner-crew stop` / `resume` | kill switch (commit `.cleaner-crew/STOP` to stop CI too) |
|
|
62
|
+
|
|
63
|
+
## How it earns trust
|
|
64
|
+
|
|
65
|
+
**Humans choose the work.** The scout only *proposes* (`cleaner-crew:proposed`). Adding
|
|
66
|
+
the `cleaner-crew` label takes about ten seconds and is far cheaper than reviewing a
|
|
67
|
+
pointless MR. At most `max_open_proposals` proposals are open at once.
|
|
68
|
+
|
|
69
|
+
**Autonomy per category is measured, not assumed.** An MR counts as *accepted* if it was
|
|
70
|
+
merged with no human commits pushed to its branch (crew commits carry `Cleaner-Crew-*`
|
|
71
|
+
git trailers). Over the last `trust.window` closed MRs per category:
|
|
72
|
+
|
|
73
|
+
| record | level | behaviour |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| fewer than `min_samples` | draft | MRs are opened as drafts |
|
|
76
|
+
| acceptance ≥ `promote_at` (80%) | ready | MRs are opened ready for review |
|
|
77
|
+
| acceptance < `demote_below` (50%) | shadow | the plan is posted on the ticket; nothing changes |
|
|
78
|
+
| otherwise | draft | |
|
|
79
|
+
|
|
80
|
+
The earned level is capped by each category's `max_level` in `policy.yml`. After fixing
|
|
81
|
+
whatever caused a demotion, set `trust_since` to today's date to start a fresh record.
|
|
82
|
+
History is read from the code host, so CI and local runners agree.
|
|
83
|
+
|
|
84
|
+
**Tests must actually test the change.** After tests pass, the crew mutates the changed
|
|
85
|
+
lines (`==`→`!=`, `<`→`<=`, `n`→`n+1`, `and`→`or`, ...) and re-runs the tests. If
|
|
86
|
+
fewer than `mutation.min_score` of the mutants are caught, the MR becomes a draft, and
|
|
87
|
+
the surviving mutants are listed in the MR.
|
|
88
|
+
|
|
89
|
+
## Protecting the target repo
|
|
90
|
+
|
|
91
|
+
The crew never merges. The repository's own protections are the real safety net:
|
|
92
|
+
|
|
93
|
+
- **Branch protection** on the default branch: require at least one approving review,
|
|
94
|
+
dismiss stale approvals, and require `cleaner-crew-verify` plus your test workflow.
|
|
95
|
+
- **`cleaner-crew-verify`** re-checks every `cleaner-crew/*` MR against the policy *from
|
|
96
|
+
the base branch*. It checks forbidden paths (including the crew's own config), size
|
|
97
|
+
limits, required tests, category enabled, and crew trailers present. On GitHub it runs
|
|
98
|
+
as `pull_request_target`, so an MR can't edit the check that judges it, and it never
|
|
99
|
+
executes the MR's code.
|
|
100
|
+
- **CODEOWNERS** for `/.cleaner-crew/`, `/.claude/` and CI config.
|
|
101
|
+
|
|
102
|
+
`cleaner-crew doctor` reports whether the default branch is adequately protected.
|
|
103
|
+
|
|
104
|
+
## Safety model
|
|
105
|
+
|
|
106
|
+
- **The policy gate is code, not a prompt.** `policy.yml` sets size limits, forbidden
|
|
107
|
+
paths, required tests and security approval. The manager's verdict can only be made
|
|
108
|
+
stricter by the policy.
|
|
109
|
+
- **Separate contexts.** Each role is a separate `claude -p` process. The inspector and
|
|
110
|
+
hooded agent see the plan and diff, never the janitor's reasoning.
|
|
111
|
+
- **Separation of duties.** Janitors cannot edit tests, and inspectors can only edit tests.
|
|
112
|
+
Scout, manager and hooded agents are read-only.
|
|
113
|
+
- **Three layers of tool restriction:** `--allowedTools` per role, the agent's `tools:`
|
|
114
|
+
frontmatter, and a PreToolUse guard hook (`cleaner_crew.hooks.guard`). The guard blocks
|
|
115
|
+
the network, git history commands, secrets files, anything outside the task worktree,
|
|
116
|
+
and the crew's own config.
|
|
117
|
+
- **Agents hold no credentials.** Tracker and code host tokens are stripped from agent
|
|
118
|
+
environments. Only the orchestrator claims tickets, commits, pushes and opens MRs.
|
|
119
|
+
- **Ticket text is untrusted.** It is wrapped as data. The hooded agent checks the diff
|
|
120
|
+
against the plan and flags suspected prompt injection, which blocks the change.
|
|
121
|
+
- **Limits:** max open crew MRs, a per-task cost cap, a green baseline requirement, and a
|
|
122
|
+
kill switch.
|
|
123
|
+
- **Audit trail:** every agent transcript, the test log and the outcome are kept under
|
|
124
|
+
`.cleaner-crew/runs/` (uploaded as a CI artifact).
|
|
125
|
+
|
|
126
|
+
## Extending
|
|
127
|
+
|
|
128
|
+
Add a tracker or code host by implementing `TaskSource` or `CodeHost` in
|
|
129
|
+
`src/cleaner_crew/adapters/base.py` and registering it in `adapters/__init__.py`.
|
|
130
|
+
Customise agent behaviour by editing `.claude/agents/cleaner-crew-*.md` in the target repo.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uv sync
|
|
136
|
+
uv run pytest
|
|
137
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cleaner-crew"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Autonomous crew of Claude Code agents that picks up small, safe tasks and opens MRs."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.12",
|
|
9
|
+
"rich>=13",
|
|
10
|
+
"pyyaml>=6",
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
cleaner-crew = "cleaner_crew.cli:app"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["pytest>=8", "respx>=0.21"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/cleaner_crew"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..config import Config, env
|
|
4
|
+
from .base import CodeHost, TaskSource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_tracker(cfg: Config) -> TaskSource:
|
|
8
|
+
t = cfg.tracker
|
|
9
|
+
if t.kind == "linear":
|
|
10
|
+
from .linear import Linear
|
|
11
|
+
return Linear(t, env(t.token_env))
|
|
12
|
+
if t.kind == "jira":
|
|
13
|
+
from .jira import Jira
|
|
14
|
+
return Jira(t, env(t.email_env), env(t.token_env))
|
|
15
|
+
raise ValueError(f"unknown tracker kind: {t.kind}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_code_host(cfg: Config) -> CodeHost:
|
|
19
|
+
c = cfg.code_host
|
|
20
|
+
if c.kind == "github":
|
|
21
|
+
from .github import GitHub, github_token
|
|
22
|
+
tok = github_token(c.token_env)
|
|
23
|
+
if not tok:
|
|
24
|
+
raise RuntimeError(f"no GitHub token: set {c.token_env} or run `gh auth login`")
|
|
25
|
+
return GitHub(c.repo, tok, c.api_url)
|
|
26
|
+
if c.kind == "gitlab":
|
|
27
|
+
from .gitlab import GitLab
|
|
28
|
+
return GitLab(c.repo, env(c.token_env), c.api_url)
|
|
29
|
+
raise ValueError(f"unknown code host kind: {c.kind}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["CodeHost", "TaskSource", "make_code_host", "make_tracker"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Adapter interfaces. Add a new tracker or code host by implementing one of these."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from ..config import TrackerConfig
|
|
8
|
+
from ..models import ConnectionReport, Finding, MrRecord, Task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskSource(ABC):
|
|
12
|
+
"""Jira, Linear, ... The tracker is the source of truth for what the crew works on."""
|
|
13
|
+
|
|
14
|
+
cfg: TrackerConfig
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def check(self) -> ConnectionReport:
|
|
18
|
+
"""Verify credentials and that the configured project exists."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def list_projects(self) -> list[tuple[str, str]]:
|
|
22
|
+
"""(key, name) pairs, for the installer."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def list_statuses(self) -> list[str]:
|
|
26
|
+
"""Workflow status names of the configured project, for the installer."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def fetch_candidates(self, limit: int = 20) -> list[Task]:
|
|
30
|
+
"""Open tickets a human labelled as candidates, not yet claimed or settled by the crew."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def find_issues(self, labels: list[str], open_only: bool, limit: int = 100) -> list[Task]:
|
|
34
|
+
"""Tickets carrying any of `labels`."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def create_finding(self, finding: Finding) -> Task:
|
|
38
|
+
"""File a scout finding as a *proposed* ticket. Only a human makes it a candidate."""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def claim(self, task: Task) -> None:
|
|
42
|
+
"""Mark in progress so no other run (CI or daemon) picks it up."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def comment(self, task: Task, body: str) -> None: ...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def mark_in_review(self, task: Task, mr_url: str) -> None: ...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def release(self, task: Task, label: str, comment: str) -> None:
|
|
52
|
+
"""Drop the claim, return the ticket to todo, add `label` and comment."""
|
|
53
|
+
|
|
54
|
+
def reject(self, task: Task, reason: str) -> None:
|
|
55
|
+
self.release(task, self.cfg.rejected_label, f"Cleaner crew: not shipping this.\n\n{reason}")
|
|
56
|
+
|
|
57
|
+
def escalate(self, task: Task, reason: str) -> None:
|
|
58
|
+
self.release(task, self.cfg.escalated_label,
|
|
59
|
+
f"Cleaner crew: this needs a human.\n\n{reason}")
|
|
60
|
+
|
|
61
|
+
def shadow(self, task: Task, plan: str) -> None:
|
|
62
|
+
self.release(task, self.cfg.shadow_label,
|
|
63
|
+
"Cleaner crew (shadow mode): this is what I would have done. "
|
|
64
|
+
f"Nothing was changed.\n\n{plan}")
|
|
65
|
+
|
|
66
|
+
def blocked_labels(self) -> set[str]:
|
|
67
|
+
c = self.cfg
|
|
68
|
+
return {c.in_progress_label, c.rejected_label, c.escalated_label, c.shadow_label}
|
|
69
|
+
|
|
70
|
+
def all_crew_labels(self) -> list[str]:
|
|
71
|
+
c = self.cfg
|
|
72
|
+
return [c.candidate_label, c.proposed_label, *sorted(self.blocked_labels())]
|
|
73
|
+
|
|
74
|
+
def find_by_fingerprint(self, fp: str) -> Task | None:
|
|
75
|
+
"""Any ticket the crew ever filed or touched, open or closed, so nothing is re-filed."""
|
|
76
|
+
issues = self.find_issues(self.all_crew_labels(), open_only=False, limit=250)
|
|
77
|
+
return next((t for t in issues if t.fingerprint == fp), None)
|
|
78
|
+
|
|
79
|
+
def count_open_proposals(self) -> int:
|
|
80
|
+
issues = self.find_issues([self.cfg.proposed_label], open_only=True)
|
|
81
|
+
return sum(1 for t in issues if self.cfg.candidate_label not in t.labels)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CodeHost(ABC):
|
|
85
|
+
"""GitHub, GitLab, ..."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def check(self) -> ConnectionReport:
|
|
89
|
+
"""Verify credentials and push/MR permissions on the repository."""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def count_open_mrs(self, branch_prefix: str) -> int: ...
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def open_mr(self, branch: str, base: str, title: str, body: str, draft: bool,
|
|
96
|
+
labels: list[str]) -> str:
|
|
97
|
+
"""Open a merge/pull request and return its URL."""
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def crew_mr_history(self, branch_prefix: str, limit: int = 100) -> list[MrRecord]:
|
|
101
|
+
"""Recently closed crew MRs with their category, merge state and human changes."""
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def branch_protection(self, branch: str) -> tuple[bool | None, str]:
|
|
105
|
+
"""(adequately protected?, detail). None when it can't be determined.
|
|
106
|
+
|
|
107
|
+
Adequate means humans must approve before merge and, where the host exposes it,
|
|
108
|
+
the cleaner-crew-verify check is required.
|
|
109
|
+
"""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ..models import ConnectionReport, MrRecord, category_from_labels, is_crew_commit
|
|
9
|
+
from .base import CodeHost
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def github_token(token_env: str) -> str:
|
|
13
|
+
tok = os.environ.get(token_env, "")
|
|
14
|
+
if tok:
|
|
15
|
+
return tok
|
|
16
|
+
# Fall back to an existing gh CLI login for local use.
|
|
17
|
+
try:
|
|
18
|
+
return subprocess.run(["gh", "auth", "token"], capture_output=True, text=True,
|
|
19
|
+
timeout=10).stdout.strip()
|
|
20
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitHub(CodeHost):
|
|
25
|
+
def __init__(self, repo: str, token: str, api_url: str = "https://api.github.com"):
|
|
26
|
+
self.repo = repo
|
|
27
|
+
self.http = httpx.Client(
|
|
28
|
+
base_url=api_url,
|
|
29
|
+
headers={"Authorization": f"Bearer {token}",
|
|
30
|
+
"Accept": "application/vnd.github+json",
|
|
31
|
+
"X-GitHub-Api-Version": "2022-11-28"},
|
|
32
|
+
timeout=30,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def check(self) -> ConnectionReport:
|
|
36
|
+
r = self.http.get("/user")
|
|
37
|
+
if r.status_code != 200:
|
|
38
|
+
return ConnectionReport(False, "github", detail=f"auth failed ({r.status_code})")
|
|
39
|
+
login = r.json()["login"]
|
|
40
|
+
r = self.http.get(f"/repos/{self.repo}")
|
|
41
|
+
if r.status_code != 200:
|
|
42
|
+
return ConnectionReport(False, "github", login,
|
|
43
|
+
f"repo {self.repo} not accessible ({r.status_code})")
|
|
44
|
+
perms = r.json().get("permissions", {})
|
|
45
|
+
missing = [] if perms.get("push") else ["push access (contents: write, pull_requests: write)"]
|
|
46
|
+
return ConnectionReport(not missing, "github", login, f"repo {self.repo}", missing)
|
|
47
|
+
|
|
48
|
+
def count_open_mrs(self, branch_prefix: str) -> int:
|
|
49
|
+
r = self.http.get(f"/repos/{self.repo}/pulls", params={"state": "open", "per_page": 100})
|
|
50
|
+
r.raise_for_status()
|
|
51
|
+
return sum(1 for pr in r.json() if pr["head"]["ref"].startswith(branch_prefix))
|
|
52
|
+
|
|
53
|
+
def open_mr(self, branch: str, base: str, title: str, body: str, draft: bool,
|
|
54
|
+
labels: list[str]) -> str:
|
|
55
|
+
r = self.http.post(f"/repos/{self.repo}/pulls", json={
|
|
56
|
+
"title": title, "head": branch, "base": base, "body": body, "draft": draft})
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
pr = r.json()
|
|
59
|
+
self.http.post(f"/repos/{self.repo}/issues/{pr['number']}/labels",
|
|
60
|
+
json={"labels": labels}).raise_for_status()
|
|
61
|
+
return pr["html_url"]
|
|
62
|
+
|
|
63
|
+
def crew_mr_history(self, branch_prefix: str, limit: int = 100) -> list[MrRecord]:
|
|
64
|
+
r = self.http.get(f"/repos/{self.repo}/pulls", params={
|
|
65
|
+
"state": "closed", "sort": "updated", "direction": "desc", "per_page": 100})
|
|
66
|
+
r.raise_for_status()
|
|
67
|
+
out = []
|
|
68
|
+
for pr in r.json():
|
|
69
|
+
if not pr["head"]["ref"].startswith(branch_prefix) or len(out) >= limit:
|
|
70
|
+
continue
|
|
71
|
+
cat = category_from_labels([l["name"] for l in pr.get("labels", [])])
|
|
72
|
+
if cat is None:
|
|
73
|
+
continue
|
|
74
|
+
commits = self.http.get(f"/repos/{self.repo}/pulls/{pr['number']}/commits",
|
|
75
|
+
params={"per_page": 100})
|
|
76
|
+
commits.raise_for_status()
|
|
77
|
+
human = any(not is_crew_commit(c["commit"]["message"]) for c in commits.json())
|
|
78
|
+
out.append(MrRecord(cat, merged=pr.get("merged_at") is not None,
|
|
79
|
+
changed_by_human=human, closed_at=pr["closed_at"]))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
def branch_protection(self, branch: str) -> tuple[bool | None, str]:
|
|
83
|
+
r = self.http.get(f"/repos/{self.repo}/branches/{branch}")
|
|
84
|
+
if r.status_code != 200:
|
|
85
|
+
return None, f"cannot read branch {branch} ({r.status_code})"
|
|
86
|
+
if not r.json().get("protected"):
|
|
87
|
+
return False, f"{branch} is not protected"
|
|
88
|
+
r = self.http.get(f"/repos/{self.repo}/branches/{branch}/protection")
|
|
89
|
+
if r.status_code != 200:
|
|
90
|
+
return True, f"{branch} is protected (details need admin rights)"
|
|
91
|
+
p = r.json()
|
|
92
|
+
checks = (p.get("required_status_checks") or {}).get("contexts", [])
|
|
93
|
+
reviews = (p.get("required_pull_request_reviews") or {}).get(
|
|
94
|
+
"required_approving_review_count", 0)
|
|
95
|
+
detail = f"{reviews} required review(s); required checks: {', '.join(checks) or 'none'}"
|
|
96
|
+
return bool(reviews) and any("cleaner-crew" in c for c in checks), detail
|
|
97
|
+
|