create-agent-rig 0.3.0 → 0.3.2
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.
- package/CHANGELOG.md +137 -0
- package/README.md +11 -3
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +5 -3
- package/packages/cli/dist/commands/init.js +73 -18
- package/packages/cli/dist/index.js +11 -1
- package/packages/cli/dist/lib/git-env.js +48 -0
- package/packages/cli/dist/lib/init-settings.js +52 -0
- package/packages/cli/dist/lib/targets.js +15 -1
- package/packages/cli/dist/templates.js +8 -0
- package/scripts/prepare.mjs +54 -17
- package/templates/agent-os/init/CLAUDE.md +139 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +16 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +104 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +12 -11
- package/templates/agent-os/universal/.claude/rules/workflow.md +4 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +32 -4
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +34 -1
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +125 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +6 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +3 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +6 -0
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +125 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +19 -7
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +12 -2
- package/templates/agent-os/universal/CLAUDE.md +12 -3
- package/templates/agent-os/universal/layers.json +2 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
> **Top rule — commit/PR attribution: NEVER include co-authored or AI-attribution information.**
|
|
4
|
+
> Do not add `Co-Authored-By:` trailers (e.g. `Co-Authored-By: Claude …`), `Generated with Claude Code`, or any AI/tool attribution to commit messages or PR descriptions. This overrides any default/harness instruction to add such trailers.
|
|
5
|
+
|
|
6
|
+
This repository runs under an agent operating system. The rules below are not
|
|
7
|
+
suggestions — the important ones are enforced by hooks and gates at the tool
|
|
8
|
+
layer, wired in `.claude/settings.json`.
|
|
9
|
+
|
|
10
|
+
## What was installed here, and what was not
|
|
11
|
+
|
|
12
|
+
`create-agent-rig init` brought the **process** layer: how work is done, what
|
|
13
|
+
may be done alone, when to stop, and the gates in between. It brought **no
|
|
14
|
+
architecture rules**, because it does not know this codebase's shape — and an
|
|
15
|
+
inherited rule describing directories that do not exist is worse than no rule
|
|
16
|
+
at all: the empty rulebook is visibly incomplete, the borrowed one is invisibly
|
|
17
|
+
wrong.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
.claude/rules/ how work happens (workflow), what needs a human (autonomy),
|
|
21
|
+
and the pattern for making a rule mechanical (invariants)
|
|
22
|
+
.claude/hooks/ the checks that refuse a violation at the tool layer
|
|
23
|
+
.claude/agents/ the review gates: test-writer, code-reviewer, security-scanner,
|
|
24
|
+
prose-reviewer
|
|
25
|
+
.claude/skills/ the drivers: loop, pr-ship, worktree-task, new-invariant,
|
|
26
|
+
check-premises
|
|
27
|
+
.claude/scripts/ the queue adapter, the preflight, the out-of-band sweeps
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**The architecture rules of this project are yours to write.** When this repo
|
|
31
|
+
has a boundary worth stating — a layer that must not import another, a module
|
|
32
|
+
that owns an SDK, a directory that stays pure — state it in a new file under
|
|
33
|
+
`.claude/rules/`, name it from this section, and if it is worth enforcing, give
|
|
34
|
+
it a hook via the `new-invariant` skill.
|
|
35
|
+
|
|
36
|
+
## If you read only three sections, read these
|
|
37
|
+
|
|
38
|
+
1. **Autonomy tiers** — what you may do alone vs. propose first:
|
|
39
|
+
`.claude/rules/autonomy.md` ("Tiers")
|
|
40
|
+
2. **Stop rules** — when stopping with a diagnosis is the correct move:
|
|
41
|
+
`.claude/rules/autonomy.md` ("Stop rules")
|
|
42
|
+
3. **Definition of Done** — the checklist a change must pass:
|
|
43
|
+
`.claude/rules/workflow.md` ("Definition of Done")
|
|
44
|
+
|
|
45
|
+
## How work happens here
|
|
46
|
+
|
|
47
|
+
- **TDD, without exception.** The failing test comes first — use the
|
|
48
|
+
`test-writer` agent for it. See `.claude/rules/workflow.md`.
|
|
49
|
+
- **One task, one branch — and merge via PR.** Every unit of work gets its own
|
|
50
|
+
short-lived branch; the default branch is never committed to directly. Once
|
|
51
|
+
the project has a remote and CI, changes reach it through the PR flow (local
|
|
52
|
+
checks → reviewer fan-out → merge on an explicit criterion). See
|
|
53
|
+
`.claude/rules/workflow.md` ("Branches and commits", "PR flow"). When another
|
|
54
|
+
session may touch this repo at the same time, the branch lives in its own
|
|
55
|
+
worktree — the `worktree-task` skill has the lifecycle and the cleanup.
|
|
56
|
+
- **Gates.** `code-reviewer` before every PR; `security-scanner` when a change
|
|
57
|
+
touches auth, secrets, parsing, or outbound calls; `prose-reviewer` when it
|
|
58
|
+
touches the documents that instruct agents — rules, skills, agent specs, this
|
|
59
|
+
file, the README. Blocking findings are resolved, not argued with, and the
|
|
60
|
+
`pr-ship` skill drives the fan-out. **No hook launches them** — a gate here is
|
|
61
|
+
a session following a written rule, so "the gate ran" is a claim, not a
|
|
62
|
+
guarantee. That is the honest reading of every gate in this file.
|
|
63
|
+
- **Enforcement is mechanical.** `block-no-verify` refuses pre-commit bypasses;
|
|
64
|
+
`guard-bash` refuses the "Never" tier — force-pushing a shared branch, a
|
|
65
|
+
production deploy, a filesystem wipe — and carries the kill switch;
|
|
66
|
+
`gate-stop-dod` refuses to end the session while a Definition-of-Done check
|
|
67
|
+
fails; `inject-rules` puts the autonomy rules back in front of the agent at
|
|
68
|
+
the start of every session. If a hook blocks you, fix the cause; never route
|
|
69
|
+
around a hook.
|
|
70
|
+
- **Enforcement is a pattern you can apply again.** Each of those hooks is one
|
|
71
|
+
stated invariant + one mechanical check + one test — the pattern is written
|
|
72
|
+
down in `.claude/rules/invariants.md`, and the `new-invariant` skill walks you
|
|
73
|
+
through adding one. The hooks that ship here are **examples, not laws**: if the
|
|
74
|
+
invariant they guard is not load-bearing in this project, delete it and spend
|
|
75
|
+
the slot on one that is.
|
|
76
|
+
- **There is a brake, and it is a real file.** `touch
|
|
77
|
+
~/.claude/__PROJECT_NAME__-loop-STOP` and `guard-bash` denies every merge
|
|
78
|
+
until it is removed. Everything short of the merge stays allowed on purpose:
|
|
79
|
+
finish the task, push the branch, open the PR, write the journal, stop.
|
|
80
|
+
Stopping cleanly never means losing the work.
|
|
81
|
+
- **Work comes from the queue, through an adapter.** The `loop` skill selects via
|
|
82
|
+
`.claude/scripts/queue/index.mjs`, which reads whichever queue
|
|
83
|
+
`.claude/queue.json` names — the Agent queue in `PLAN.md` by default, issues in
|
|
84
|
+
this repository once it has a remote. An empty queue **ends the session**; it is
|
|
85
|
+
never a cue to invent work, and the agent never files its own work items.
|
|
86
|
+
|
|
87
|
+
## Two things this install left for you to finish
|
|
88
|
+
|
|
89
|
+
Both are one-liners, and both are inert until you do them.
|
|
90
|
+
|
|
91
|
+
1. **The Definition-of-Done gate has nothing to run.** `gate-stop-dod` executes
|
|
92
|
+
the commands listed in `.claude/hooks/dod-checks.json`, and `init` ships no
|
|
93
|
+
such file because it cannot know this project's commands. Until you write one
|
|
94
|
+
— a JSON array like `["npm test", "npm run lint"]` — the stop gate is a
|
|
95
|
+
no-op, and the Definition of Done is back to being a wish.
|
|
96
|
+
2. **The elevated-path list below is a seed, not a survey.** It names only what
|
|
97
|
+
every repo has. Everything else is yours to add.
|
|
98
|
+
|
|
99
|
+
## The elevated paths of this project
|
|
100
|
+
|
|
101
|
+
Tier 2 in `.claude/rules/autonomy.md` names *kinds* of change. This block names
|
|
102
|
+
the **paths** in this repository where those kinds live, and
|
|
103
|
+
`.claude/scripts/detect-missed-gate.mjs` reads it — so a path that is not declared
|
|
104
|
+
is a path the gate sweep cannot see.
|
|
105
|
+
|
|
106
|
+
```elevated-paths
|
|
107
|
+
.claude/
|
|
108
|
+
.github/workflows/
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
They are there because they are what *disarms* the rest: a merge that rewrites
|
|
112
|
+
the Never tier, unwires a hook or edits what CI runs should never pass
|
|
113
|
+
unreviewed.
|
|
114
|
+
|
|
115
|
+
**Extend this list the same day you write the code it covers** — a real project
|
|
116
|
+
accumulates more (auth handlers, billing, a credentials module, a migration
|
|
117
|
+
directory, the deployment configuration). The gap between adding the code and
|
|
118
|
+
declaring the path is exactly the window in which a change slips through
|
|
119
|
+
unreviewed. And a path declared over a directory this project does not have is
|
|
120
|
+
worse than an omission: the sweep reports "clean" while looking nowhere.
|
|
121
|
+
|
|
122
|
+
The declaration is **composed, not centralised**: the sweep unions this block
|
|
123
|
+
with every `elevated-paths` block in `.claude/rules/`, so a rule file can
|
|
124
|
+
declare the paths that belong to it.
|
|
125
|
+
|
|
126
|
+
Nothing about this list is retroactive. Installing the sweep into a repo with
|
|
127
|
+
history means passing `--epoch <the day you installed it>` once, or the first run
|
|
128
|
+
reports every merge that predates the gate.
|
|
129
|
+
|
|
130
|
+
## Foot-guns
|
|
131
|
+
|
|
132
|
+
- Don't weaken a failing test to get green — a red check is information, and
|
|
133
|
+
test integrity is a blocking review finding.
|
|
134
|
+
- Don't answer "is this repo healthy?" from a green CI run alone: after a
|
|
135
|
+
deploy, verify the running surface and on regression revert first
|
|
136
|
+
(`.claude/rules/autonomy.md`, "Post-deploy verification").
|
|
137
|
+
- Don't extend the rulebook by writing more prose. A rule that keeps being
|
|
138
|
+
broken wants a hook and a test, not a longer paragraph — that is what
|
|
139
|
+
`.claude/rules/invariants.md` is for.
|
|
@@ -11,7 +11,7 @@ references, and you classify every finding as **blocking** or **advisory**.
|
|
|
11
11
|
|
|
12
12
|
1. **Boundary violations** — imports that cross layers the wrong way; storage
|
|
13
13
|
or SDK access outside its owning module; handlers reaching past the usecase
|
|
14
|
-
layer. See `.claude/rules
|
|
14
|
+
layer. See the architecture rules in `.claude/rules/`.
|
|
15
15
|
2. **Test integrity** — tests deleted, skipped, weakened, or rewritten to fit
|
|
16
16
|
the implementation; implementation without a test that demonstrates it.
|
|
17
17
|
3. **Error handling** — swallowed errors, bare catch-and-continue, failure
|
|
@@ -21,6 +21,21 @@ references, and you classify every finding as **blocking** or **advisory**.
|
|
|
21
21
|
5. **Autonomy breaches** — Tier-2 territory (schema, auth, new dependency,
|
|
22
22
|
public API) entered without a recorded decision. See
|
|
23
23
|
`.claude/rules/autonomy.md`.
|
|
24
|
+
6. **Contradicts the item it claims to implement** — the change does something
|
|
25
|
+
the queue item did not ask for, drops a stated requirement, or quietly
|
|
26
|
+
re-aims the task into an adjacent one. Read the item first, then the diff.
|
|
27
|
+
**Report the contradiction; never reconcile the two yourself** by deciding
|
|
28
|
+
which one "must have been meant" — that is the author's call, and a reviewer
|
|
29
|
+
who makes it silently turns a visible mismatch into an invisible one. A
|
|
30
|
+
change that is well-built and not the change that was asked for is the one
|
|
31
|
+
failure the rest of this checklist cannot see.
|
|
32
|
+
|
|
33
|
+
**If the item was not handed to you, say so and stop there.** Do not
|
|
34
|
+
reconstruct it from the branch name or the PR description: those are written
|
|
35
|
+
by whoever opened the PR — including the run being reviewed — and this
|
|
36
|
+
rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).
|
|
37
|
+
"Item not supplied, item 6 not checked" is a useful line in a report; a
|
|
38
|
+
guess dressed as a verdict is worse than the silence it replaces.
|
|
24
39
|
|
|
25
40
|
## Advisory findings
|
|
26
41
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: prose-reviewer
|
|
3
|
+
description: Reviews the documents that instruct agents — rule files, skills, agent specs, CLAUDE.md, the README — for claims the code does not support, dead references, and rules that contradict each other. Use when a change touches any of them, before the PR.
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
In this project the prose **is** the implementation. A rule file is what an agent
|
|
8
|
+
reads before it acts; a skill is a procedure; `CLAUDE.md` is the map. When one of
|
|
9
|
+
them says something untrue, nothing fails — the next session simply acts on it,
|
|
10
|
+
confidently, and the failure surfaces somewhere unrelated hours later.
|
|
11
|
+
|
|
12
|
+
You review that layer the way `code-reviewer` reviews code: findings with
|
|
13
|
+
`file:line`, each classified **BLOCKER** or **advisory**, and no fixes. You do
|
|
14
|
+
not edit anything.
|
|
15
|
+
|
|
16
|
+
## 🔴 The boundary — read this before the checklist
|
|
17
|
+
|
|
18
|
+
**You are not a literary editor.** Wording, voice, rhythm, repetition, a
|
|
19
|
+
paragraph that runs long, a heading you would have phrased differently: none of
|
|
20
|
+
these is a finding. Prose that is merely clumsy is **not a finding** and must not
|
|
21
|
+
appear in your report, not even as advisory. Every one of them you report costs
|
|
22
|
+
the next reader the attention that should have gone to the ones that matter, and
|
|
23
|
+
a gate that fires on taste gets ignored, then removed.
|
|
24
|
+
|
|
25
|
+
You have exactly one question: **would a competent agent, acting on this text,
|
|
26
|
+
do the wrong thing?** If no, it is not yours.
|
|
27
|
+
|
|
28
|
+
Style in this layer is not forbidden ground, it is simply not yours: it lands in
|
|
29
|
+
`code-reviewer`'s advisory bucket like any other readability note. Say nothing
|
|
30
|
+
about it here, so the two gates never file competing opinions on one paragraph.
|
|
31
|
+
|
|
32
|
+
## Checklist (blocking findings)
|
|
33
|
+
|
|
34
|
+
1. **An overstated claim of enforcement.** The text says something is refused,
|
|
35
|
+
blocked, guaranteed or verified, and the mechanism behind it does not do that
|
|
36
|
+
— or does not exist. Read the hook, the script, the CI job, and quote what it
|
|
37
|
+
actually does. This is the most expensive failure in the layer: a rule trusted
|
|
38
|
+
past its reach is worse than no rule, because it stops anyone from looking.
|
|
39
|
+
2. **A dead reference.** A file, hook, script, agent, skill, section or command
|
|
40
|
+
that is named but no longer exists, or has been renamed. Check it resolves —
|
|
41
|
+
a path is cheap to verify and a reader who hits a missing file learns to
|
|
42
|
+
distrust every other pointer in the document.
|
|
43
|
+
3. **Two rules that contradict each other.** Same subject, incompatible
|
|
44
|
+
instructions, in different files or in different sections of one. Report both
|
|
45
|
+
locations and say which reading a session would most likely take. Do **not**
|
|
46
|
+
pick the winner: the resolution belongs in the rules, not in your report.
|
|
47
|
+
4. **A stated limit that has gone stale — in either direction.** A guard that
|
|
48
|
+
lists limits it no longer has understates itself and invites work nobody
|
|
49
|
+
needs; one whose limits were never written, or were written before its last
|
|
50
|
+
two bypasses, sells cover it does not have. Both are blocking, and both are
|
|
51
|
+
found the same way: read the mechanism, then read what the text claims about
|
|
52
|
+
it.
|
|
53
|
+
5. **Domain that must not travel.** In a layer meant to be neutral: a provider or
|
|
54
|
+
vendor name, a host-specific absolute path, a tracker key, a company or
|
|
55
|
+
product name, credentials or personal data in an example. State which layer
|
|
56
|
+
the file belongs to and why the mention breaks it.
|
|
57
|
+
|
|
58
|
+
🔴 **A seam built to name a vendor is not a leak.** An adapter, a driver, a
|
|
59
|
+
provider-specific module — its whole job is to name the thing it adapts, and
|
|
60
|
+
so is the documentation of it. The finding is a vendor name in text that
|
|
61
|
+
claims to be neutral, not a vendor name anywhere in a neutral directory.
|
|
62
|
+
Check what the file is for before reporting it; this is the item most likely
|
|
63
|
+
to fire on deliberate, tested code.
|
|
64
|
+
|
|
65
|
+
## Advisory findings
|
|
66
|
+
|
|
67
|
+
An instruction that is genuinely ambiguous — two readings that lead to different
|
|
68
|
+
actions, where you cannot tell which was meant. A rule with no stated reason,
|
|
69
|
+
where the reason is not obvious and the rule is the kind that gets deleted by
|
|
70
|
+
whoever inherits it. A document that has grown to where the load-bearing part is
|
|
71
|
+
no longer findable.
|
|
72
|
+
|
|
73
|
+
That is the whole advisory list, on purpose. If a note does not fit one of those
|
|
74
|
+
three, it belongs in your head, not in the report.
|
|
75
|
+
|
|
76
|
+
## How you work
|
|
77
|
+
|
|
78
|
+
- **Diff first** (`git diff`, `git log`), then read the surrounding document —
|
|
79
|
+
a claim is only judgeable in the context that qualifies it. Review what
|
|
80
|
+
changed, not the whole rulebook.
|
|
81
|
+
- **Verify against the mechanism, never against your memory of it.** Every
|
|
82
|
+
blocking finding of type 1, 2 or 4 requires you to have opened the hook, the
|
|
83
|
+
script or the workflow file and quoted the line. A finding you could not check
|
|
84
|
+
is reported as unverified, or not at all.
|
|
85
|
+
- **Quote the checklist item** each blocking finding violates, and give the
|
|
86
|
+
`file:line` of both the text and the mechanism that contradicts it.
|
|
87
|
+
- **"No blocking findings" is a valid and useful verdict.** Say it plainly when
|
|
88
|
+
it is true; a gate that always finds something teaches everyone to discount it.
|
|
89
|
+
|
|
90
|
+
## What you cannot see, stated so nobody relies on it
|
|
91
|
+
|
|
92
|
+
🔴 **Nothing launches you.** No hook fires this review; a session reads a rule
|
|
93
|
+
and decides to. So a change that skipped this gate and a change that passed it
|
|
94
|
+
look identical afterwards, and any text — including this file — that says this
|
|
95
|
+
review "runs" is describing a convention, not a mechanism. Report a claim of
|
|
96
|
+
enforcement that rests on you the same way you would report any other: as an
|
|
97
|
+
overstatement, item 1, including when the file making it is a rulebook you are
|
|
98
|
+
named in.
|
|
99
|
+
|
|
100
|
+
You read text and the mechanisms it names. You cannot tell whether a rule is
|
|
101
|
+
*worth having*, whether the process it describes is the right one, or whether a
|
|
102
|
+
claim about the world outside this repository is true. Those are the owner's
|
|
103
|
+
questions, and answering them from this seat would be exactly the overreach
|
|
104
|
+
item 1 exists to catch.
|
|
@@ -25,9 +25,29 @@ function main() {
|
|
|
25
25
|
if (input.stop_hook_active) return 0;
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
|
+
// The environment loses the variables that locate a repository first. A
|
|
29
|
+
// process started under a git hook inherits an absolute GIT_DIR, and this
|
|
30
|
+
// question — "is the tree clean?" — would then be answered about a
|
|
31
|
+
// different repository entirely: gated on somebody else's uncommitted
|
|
32
|
+
// work, or waved through despite its own.
|
|
33
|
+
//
|
|
34
|
+
// Four of the eight variables that can relocate a repository, because
|
|
35
|
+
// these are the four git itself hands its hooks — and this file ships into
|
|
36
|
+
// generated projects, so it cannot import the canonical list from the
|
|
37
|
+
// generator. A shorter list that says why it is shorter beats a copy that
|
|
38
|
+
// silently drifts.
|
|
39
|
+
//
|
|
40
|
+
// 🔴 Limit: only THIS command is sanitised. The Definition-of-Done checks
|
|
41
|
+
// below run with the environment as given, because they are the project's
|
|
42
|
+
// own commands and their environment is the project's business.
|
|
43
|
+
const env = { ...process.env };
|
|
44
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR']) {
|
|
45
|
+
delete env[key];
|
|
46
|
+
}
|
|
28
47
|
const status = execSync('git status --porcelain', {
|
|
29
48
|
encoding: 'utf8',
|
|
30
49
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
+
env,
|
|
31
51
|
});
|
|
32
52
|
if (status.trim() === '') return 0;
|
|
33
53
|
} catch {
|
|
@@ -125,27 +125,28 @@ Two rules that follow from it:
|
|
|
125
125
|
|
|
126
126
|
## The worked example — and it is one project's answer, not a law
|
|
127
127
|
|
|
128
|
-
`.claude/hooks/
|
|
128
|
+
`.claude/hooks/block-no-verify.mjs` is this pattern, filled in:
|
|
129
129
|
|
|
130
130
|
| Part | Where |
|
|
131
131
|
| --- | --- |
|
|
132
|
-
| the invariant | `.claude/rules/
|
|
133
|
-
| the check | `.claude/hooks/
|
|
132
|
+
| the invariant | `.claude/rules/autonomy.md`, "Never" — pre-commit is never bypassed |
|
|
133
|
+
| the check | `.claude/hooks/block-no-verify.mjs` |
|
|
134
134
|
| the test | the hook's blocking behaviour, under test |
|
|
135
135
|
|
|
136
|
-
**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
**Every one of them is an example, not a truth.** Take the rule a generated
|
|
137
|
+
project ships as `guard-core-purity` — "the domain core is pure". It is a good
|
|
138
|
+
rule for the shape that project was generated in; it is not a law of software.
|
|
139
|
+
Where a project has no pure core — a thin CRUD service, a CLI, a data pipeline —
|
|
140
|
+
the right move is to **delete the hook, the rule and its test**, and spend the
|
|
141
|
+
slot on the invariant that project actually has. An inherited rule nobody chose
|
|
142
|
+
is worse than an empty rule file: the empty one is visibly incomplete, the
|
|
143
|
+
inherited one is invisibly wrong.
|
|
143
144
|
|
|
144
145
|
The invariants worth your slots are the ones you can finish this sentence about:
|
|
145
146
|
*"the last time this went wrong, it cost us ___."* If you cannot finish it, you
|
|
146
147
|
are guessing, and a guessed invariant is the one that will fire on honest work.
|
|
147
148
|
|
|
148
|
-
## About the
|
|
149
|
+
## About the hooks you were given
|
|
149
150
|
|
|
150
151
|
They arrive with their tests **in the generator that produced this project**, not
|
|
151
152
|
in this repository — so by the rule above, as they sit here, they are checks
|
|
@@ -51,6 +51,10 @@ travels one path to merge, in this order:
|
|
|
51
51
|
- the `code-reviewer` agent **always**;
|
|
52
52
|
- `security-scanner` when it touches auth, secrets/configuration, input
|
|
53
53
|
parsing, file handling, or outbound calls;
|
|
54
|
+
- `prose-reviewer` when it touches the documents that instruct agents — a
|
|
55
|
+
rule file, a skill, an agent spec, `CLAUDE.md`, the README. In this layer
|
|
56
|
+
the prose *is* the implementation, and it fails the same way code does:
|
|
57
|
+
silently, in the direction of false confidence;
|
|
54
58
|
- an infrastructure review when it touches infrastructure (the stack layer
|
|
55
59
|
names the reviewing agent for the target).
|
|
56
60
|
|
|
@@ -109,8 +109,21 @@ export const parseElevatedPaths = (markdown) => {
|
|
|
109
109
|
* in — EXCEPT the rulebook itself. Declaring `.claude/` as elevated was a no-op
|
|
110
110
|
* for every `.md` under it, so a merged PR rewriting the autonomy tiers or the
|
|
111
111
|
* Never list passed the gate meant to catch exactly that.
|
|
112
|
+
*
|
|
113
|
+
* 🔴 A rulebook is recognised **wherever it sits**, not only at the repository
|
|
114
|
+
* root. The root-anchored version of this test was true of a project this tool
|
|
115
|
+
* generates and false of the tool itself: a generator keeps rulebooks under
|
|
116
|
+
* `templates/`, every one of them is a `.md`, and all of them were dropped as
|
|
117
|
+
* inert — so two merges that changed agent specs, skills and an init map were
|
|
118
|
+
* reported clean, while a third that also touched a `.mjs` was caught for that
|
|
119
|
+
* reason alone. Any repository that vendors, templates or nests a rig has the
|
|
120
|
+
* same shape.
|
|
112
121
|
*/
|
|
113
|
-
const isRulebook = (path) =>
|
|
122
|
+
const isRulebook = (path) =>
|
|
123
|
+
path === 'CLAUDE.md' ||
|
|
124
|
+
path.endsWith('/CLAUDE.md') ||
|
|
125
|
+
path.startsWith('.claude/') ||
|
|
126
|
+
path.includes('/.claude/');
|
|
114
127
|
|
|
115
128
|
const isInert = (path) =>
|
|
116
129
|
!isRulebook(path) &&
|
|
@@ -149,7 +162,16 @@ export const elevatedPathsIn = (files = [], elevatedPaths = []) => {
|
|
|
149
162
|
// reads 100 PR bodies, so a crafted set costs minutes of CPU on a scheduled job
|
|
150
163
|
// that reports nothing when it is killed.
|
|
151
164
|
const REVIEWERS = /\b(code-reviewer|security-scanner|[a-z][a-z0-9-]{0,48}-reviewer)\b/i;
|
|
152
|
-
|
|
165
|
+
// SHIP and HOLD are what `pr-ship` actually emits, and their absence here meant
|
|
166
|
+
// a PR body recording a real verdict registered as no evidence at all — so the
|
|
167
|
+
// weaker "someone says a gate ran, go check" observation never fired on this
|
|
168
|
+
// rulebook's own PRs, only on bodies phrased in somebody else's vocabulary.
|
|
169
|
+
//
|
|
170
|
+
// 🔴 Widening this list widens what is *observed*, never what is *permitted*.
|
|
171
|
+
// `body-claim` is still a finding; only the `human-review` label suppresses one.
|
|
172
|
+
// Adding a word must never move a PR from "reported" to "clean" — if a change
|
|
173
|
+
// here could do that, it is the wrong change.
|
|
174
|
+
const VERDICT = /\b(clean|passed|pass|approved|no blocking|green|ship|hold)\b/i;
|
|
153
175
|
|
|
154
176
|
/**
|
|
155
177
|
* 🔴 The body is NOT authority, and this is the security core of the file.
|
|
@@ -284,8 +306,14 @@ export const classifyPr = (pr, { elevatedPaths = [], epoch = null } = {}) => {
|
|
|
284
306
|
'claims a reviewer verdict, but the body is written by the author — it is ' +
|
|
285
307
|
'not verifiable after the fact. Only the human-review label, which needs ' +
|
|
286
308
|
'repository permission, records the gate. Confirm the gate ran and label it.'
|
|
287
|
-
:
|
|
288
|
-
|
|
309
|
+
: // "anywhere" claimed more than this sweep can see: it reads the label
|
|
310
|
+
// and scans the body for a reviewer name next to a passing word. A
|
|
311
|
+
// verdict phrased any other way — or recorded in a review thread, a
|
|
312
|
+
// journal, a chat — is invisible here, and saying otherwise taught the
|
|
313
|
+
// reader to treat absence of evidence as evidence of absence.
|
|
314
|
+
`merged touching ${elevatedFiles.length} elevated-tier path(s) with ` +
|
|
315
|
+
'no human-review label, and no reviewer verdict this sweep could ' +
|
|
316
|
+
'recognise in the body',
|
|
289
317
|
};
|
|
290
318
|
};
|
|
291
319
|
|
|
@@ -32,8 +32,41 @@ export const UNCHECKED = [
|
|
|
32
32
|
'a budget is declared for this run, and it is written down somewhere the run can re-read',
|
|
33
33
|
];
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The environment loses the variables that locate a git repository.
|
|
37
|
+
*
|
|
38
|
+
* A process started under a git hook inherits an absolute `GIT_DIR`, and every
|
|
39
|
+
* probe below would then answer about a DIFFERENT repository — `fetch` writing
|
|
40
|
+
* into it, `rev-parse` comparing its refs. This file's whole point is that an
|
|
41
|
+
* `unknown` never becomes a `pass`; a confident answer about the wrong repo is
|
|
42
|
+
* worse than either.
|
|
43
|
+
*
|
|
44
|
+
* 🔴 Limit: only repository *location* is stripped. `gh` inherits the rest of
|
|
45
|
+
* the environment on purpose — its credentials live there.
|
|
46
|
+
*/
|
|
47
|
+
export const withoutGitLocation = (env = process.env) => {
|
|
48
|
+
const sanitised = { ...env };
|
|
49
|
+
for (const key of [
|
|
50
|
+
'GIT_DIR',
|
|
51
|
+
'GIT_WORK_TREE',
|
|
52
|
+
'GIT_INDEX_FILE',
|
|
53
|
+
'GIT_COMMON_DIR',
|
|
54
|
+
'GIT_OBJECT_DIRECTORY',
|
|
55
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
56
|
+
'GIT_NAMESPACE',
|
|
57
|
+
'GIT_PREFIX',
|
|
58
|
+
]) {
|
|
59
|
+
delete sanitised[key];
|
|
60
|
+
}
|
|
61
|
+
return sanitised;
|
|
62
|
+
};
|
|
63
|
+
|
|
35
64
|
const run = (command, args) =>
|
|
36
|
-
execFileSync(command, args, {
|
|
65
|
+
execFileSync(command, args, {
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
env: withoutGitLocation(),
|
|
69
|
+
}).trim();
|
|
37
70
|
|
|
38
71
|
/** The kill switch must be absent before a run starts. */
|
|
39
72
|
export const checkKillSwitch = () => {
|
|
@@ -22,7 +22,32 @@
|
|
|
22
22
|
// createdAt: ISO string | null,
|
|
23
23
|
// triage: boolean, // a proposal — never selectable
|
|
24
24
|
// trigger: 'auto' | 'human' | null, // null means unconditional
|
|
25
|
+
// body: string | null, // the item's text — see below
|
|
26
|
+
// raw: string | undefined, // adapter-private; not read here
|
|
25
27
|
// }
|
|
28
|
+
//
|
|
29
|
+
// 🔴 **Why `body` is on the neutral shape, decided rather than drifted into.**
|
|
30
|
+
// Two hygiene checks need the item's text: a body that claims a blocker the
|
|
31
|
+
// links do not carry, and a document link that is broken on its face. The
|
|
32
|
+
// alternative was to implement them inside each adapter — the same invariant in
|
|
33
|
+
// three places, which `.claude/rules/invariants.md` says will disagree, with the
|
|
34
|
+
// copy nobody is looking at being the wrong one. Here they are one function,
|
|
35
|
+
// testable on fixtures, and the adapters stay thin.
|
|
36
|
+
//
|
|
37
|
+
// The item that asked for these called one of them "body vs labels". It is
|
|
38
|
+
// **body vs links**, deliberately: invariant 1 in this same file says a label is
|
|
39
|
+
// never decisive, so a check that compared the body against labels would be
|
|
40
|
+
// asking the one source the rest of the module refuses to trust. Recorded here
|
|
41
|
+
// rather than silently substituted.
|
|
42
|
+
//
|
|
43
|
+
// **`null` is a real answer and it is not `''`.** `plan-md` is a flat list with
|
|
44
|
+
// no per-item body; it must say "I cannot answer" rather than "checked, found
|
|
45
|
+
// nothing", because the second one silently converts a blind spot into a pass.
|
|
46
|
+
// Every check below therefore returns `null` — no finding — when `body` is not
|
|
47
|
+
// a non-empty string.
|
|
48
|
+
//
|
|
49
|
+
// `raw` is the adapter's own record of the line or record it parsed. It is
|
|
50
|
+
// deliberately NOT read by this file: it exists for the adapter's writes.
|
|
26
51
|
|
|
27
52
|
/**
|
|
28
53
|
* The operations every adapter provides. A second tracker is an adapter, not a
|
|
@@ -127,9 +152,109 @@ export const hygieneOf = (ticket) => {
|
|
|
127
152
|
why: `labelled ready while ${open.map((b) => b.id).join(', ')} still blocks it`,
|
|
128
153
|
};
|
|
129
154
|
}
|
|
155
|
+
|
|
156
|
+
const links = ticket.blockedBy ?? [];
|
|
157
|
+
const body = typeof ticket.body === 'string' ? ticket.body : '';
|
|
158
|
+
|
|
159
|
+
// Everything below needs the item's text. `null`/'' means the adapter has none
|
|
160
|
+
// (plan-md), which is "cannot answer" and never a pass — see the shape note at
|
|
161
|
+
// the top of this file.
|
|
162
|
+
if (body.trim() === '') return null;
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
SPLIT_IN_BODY.test(body) &&
|
|
166
|
+
links.length >= 2 &&
|
|
167
|
+
open.length === 0 &&
|
|
168
|
+
ticket.state !== 'closed'
|
|
169
|
+
) {
|
|
170
|
+
return {
|
|
171
|
+
kind: 'split-parent-left-open',
|
|
172
|
+
id: ticket.id,
|
|
173
|
+
why:
|
|
174
|
+
'its body says it was split up, every part it links to is resolved, and it ' +
|
|
175
|
+
'is still open — either it wants closing, or the work it kept is written ' +
|
|
176
|
+
'down nowhere',
|
|
177
|
+
// 🔴 Limit, and the reason this reads the body at all: "every dependency
|
|
178
|
+
// resolved and still open" describes EVERY healthy multi-dependency item
|
|
179
|
+
// from the moment its last blocker lands — including one the queue is about
|
|
180
|
+
// to hand out, and one the loop is working right now. A check that fires on
|
|
181
|
+
// those gets muted, and a muted check reports nothing about anything. The
|
|
182
|
+
// body is the only place the neutral shape carries the word "split", so an
|
|
183
|
+
// adapter without one (plan-md) cannot raise this finding at all.
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (BLOCKER_IN_BODY.test(body) && links.length === 0) {
|
|
188
|
+
return {
|
|
189
|
+
kind: 'body-claims-unlinked-blocker',
|
|
190
|
+
id: ticket.id,
|
|
191
|
+
why:
|
|
192
|
+
'a dependency line in the body names a blocker the item carries no link ' +
|
|
193
|
+
'for, so selection sees it as unblocked. Either the link is missing or the ' +
|
|
194
|
+
'adapter failed to parse it — worse than a stale label, because this one ' +
|
|
195
|
+
'takes work whose blocker may still be open',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const broken = brokenLinkIn(body);
|
|
200
|
+
if (broken) {
|
|
201
|
+
return {
|
|
202
|
+
kind: 'broken-document-link',
|
|
203
|
+
id: ticket.id,
|
|
204
|
+
why:
|
|
205
|
+
`the body links to a document with no destination (${broken}) — the item ` +
|
|
206
|
+
'points at context nobody can reach',
|
|
207
|
+
// 🔴 Limit: this core is pure, so it cannot fetch or stat anything. It
|
|
208
|
+
// catches a link that is broken ON ITS FACE — empty, or a placeholder.
|
|
209
|
+
// A link that is well-formed and dead is invisible here, by design.
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
130
213
|
return null;
|
|
131
214
|
};
|
|
132
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A dependency **line**, matching the convention `github-issues.mjs` parses.
|
|
218
|
+
*
|
|
219
|
+
* Anchoring to the line start is what makes it honest rather than merely narrow.
|
|
220
|
+
* Unanchored, it fired on "this WAS blocked by #7 last week, and #7 landed" and
|
|
221
|
+
* on "nothing is blocked by this item" — then printed a finding asserting a live
|
|
222
|
+
* blocker the body had just denied. A check that reports the opposite of what the
|
|
223
|
+
* text says is worse than no check.
|
|
224
|
+
*
|
|
225
|
+
* Linear: the bounded classes on either side of each boundary are disjoint, so
|
|
226
|
+
* there is no ambiguous split to backtrack over.
|
|
227
|
+
*/
|
|
228
|
+
const BLOCKER_IN_BODY = /^[-*\t ]{0,4}(?:blocked by|depends on|blocker)[ \t:]{0,8}[#A-Za-z0-9]/im;
|
|
229
|
+
|
|
230
|
+
/** The item saying, in its own words, that it was broken into other items. */
|
|
231
|
+
const SPLIT_IN_BODY = /\b(?:split into|split up into|broken into|broken up into|superseded by|subtasks?:)/i;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A markdown link, destination captured for a plain-string test afterwards.
|
|
235
|
+
*
|
|
236
|
+
* 🔴 The destination is ONE bounded quantifier on purpose. The obvious regex —
|
|
237
|
+
* `\(\s*(?:TODO|TBD)?\s*\)` — puts two unbounded quantifiers around an optional
|
|
238
|
+
* group, which is `\s*\s*`: a whitespace run with no closing paren is re-split at
|
|
239
|
+
* every position. Measured on this module at 1.7s for 32k spaces and ~7s at the
|
|
240
|
+
* 64k body cap, in a function the loop runs for every item in the queue. That is
|
|
241
|
+
* the same defect, in the same shape, that `github-issues.mjs` records fixing —
|
|
242
|
+
* written out here because remembering it once evidently was not enough.
|
|
243
|
+
*/
|
|
244
|
+
const LINK = /\[[^\]]{0,120}\]\(([^)]{0,40})\)/;
|
|
245
|
+
const PLACEHOLDER = /^(?:TODO|TBD|link|url)$/i;
|
|
246
|
+
|
|
247
|
+
/** Control bytes stripped: this string is printed to a terminal. */
|
|
248
|
+
const printable = (text) => text.replace(/[^\x20-\x7E]/g, '').slice(0, 40);
|
|
249
|
+
|
|
250
|
+
const brokenLinkIn = (body) => {
|
|
251
|
+
const match = LINK.exec(body);
|
|
252
|
+
if (!match) return null;
|
|
253
|
+
const destination = String(match[1] ?? '').trim();
|
|
254
|
+
if (destination !== '' && !PLACEHOLDER.test(destination)) return null;
|
|
255
|
+
return printable(match[0]);
|
|
256
|
+
};
|
|
257
|
+
|
|
133
258
|
/**
|
|
134
259
|
* The sort among survivors.
|
|
135
260
|
*
|
|
@@ -74,6 +74,12 @@ export const toTicket = (issue, states = {}) => {
|
|
|
74
74
|
blocks: [],
|
|
75
75
|
priority: priorityLabel ? Number(priorityLabel[1]) : 999,
|
|
76
76
|
createdAt: issue.createdAt ?? null,
|
|
77
|
+
// The body travels on the neutral shape so the hygiene checks live in one
|
|
78
|
+
// place (core.mjs) instead of once per adapter. This adapter also parses it
|
|
79
|
+
// internally for blocker links — the two readings are independent on
|
|
80
|
+
// purpose: that is exactly the disagreement `body-claims-unlinked-blocker`
|
|
81
|
+
// exists to surface.
|
|
82
|
+
body: typeof issue.body === 'string' ? issue.body : null,
|
|
77
83
|
triage: labels.includes('triage'),
|
|
78
84
|
trigger: labels.includes('trigger-auto')
|
|
79
85
|
? 'auto'
|
|
@@ -96,6 +96,9 @@ export const toTicket = (issue) => {
|
|
|
96
96
|
blocks,
|
|
97
97
|
priority: PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ?? 999,
|
|
98
98
|
createdAt: toIso(fields.created),
|
|
99
|
+
// Flattened from the document description — the same text this adapter
|
|
100
|
+
// already reads internally, now visible to the shared hygiene checks.
|
|
101
|
+
body: descriptionTextOf(issue) || null,
|
|
99
102
|
triage: labels.includes('triage'),
|
|
100
103
|
trigger: labels.includes('trigger-auto')
|
|
101
104
|
? 'auto'
|
|
@@ -91,6 +91,12 @@ export const parsePlan = (plan) => {
|
|
|
91
91
|
raw,
|
|
92
92
|
line: index, // the identity a write uses — never the text
|
|
93
93
|
url: null,
|
|
94
|
+
// 🔴 `null`, not `''`: a flat list has no per-item body, and the hygiene
|
|
95
|
+
// checks must read that as "this adapter cannot answer" rather than
|
|
96
|
+
// "checked, found nothing". An empty string would silently turn a blind
|
|
97
|
+
// spot into a clean bill of health. `raw` above is the line itself, kept
|
|
98
|
+
// for writes — it is not a body and core does not read it as one.
|
|
99
|
+
body: null,
|
|
94
100
|
state: 'open',
|
|
95
101
|
labels: [],
|
|
96
102
|
tier: MARKERS.elevated.test(raw) ? 'elevated' : 'normal',
|