create-agent-rig 0.3.1 → 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 +101 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +5 -3
- package/packages/cli/dist/lib/git-env.js +48 -0
- package/packages/cli/dist/lib/targets.js +15 -1
- package/scripts/prepare.mjs +54 -17
- package/templates/agent-os/init/CLAUDE.md +11 -5
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +15 -0
- 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/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
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,107 @@ the generator.
|
|
|
7
7
|
Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/package/create-agent-rig);
|
|
8
8
|
`npx github:serhii-baksheiev/create-agent-rig` keeps working for either path.
|
|
9
9
|
|
|
10
|
+
## 0.3.2
|
|
11
|
+
|
|
12
|
+
Numbered as a patch by the owner's call; the content below is additive, so
|
|
13
|
+
nothing that shipped in 0.3.1 changed shape.
|
|
14
|
+
|
|
15
|
+
A generated project gains two review gates it did not have — one before the work
|
|
16
|
+
starts, one over the prose that instructs it — and three more queue-hygiene
|
|
17
|
+
checks.
|
|
18
|
+
|
|
19
|
+
**Upgrading an existing rig: `init` alone is not enough, and here is exactly
|
|
20
|
+
why.** `create-agent-rig init` installs files that are not there and **keeps
|
|
21
|
+
every file that is** — `--force` replaces `CLAUDE.md` and nothing else
|
|
22
|
+
(`packages/cli/src/commands/init.ts`). Re-running it on a 0.3.1 rig therefore
|
|
23
|
+
delivers the two new files, `.claude/agents/prose-reviewer.md` and
|
|
24
|
+
`.claude/skills/check-premises/SKILL.md`, and **none of their wiring**: the
|
|
25
|
+
skill arrives with nothing calling it, and the agent arrives with `pr-ship`
|
|
26
|
+
never launching it. Six files below changed rather than appeared, and `init`
|
|
27
|
+
will not touch them:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
.claude/agents/code-reviewer.md # the sixth blocking item
|
|
31
|
+
.claude/skills/loop/SKILL.md # calls check-premises, and §3/§6/§8
|
|
32
|
+
.claude/skills/pr-ship/SKILL.md # fans out prose-reviewer, passes the item
|
|
33
|
+
.claude/scripts/queue/core.mjs # the three hygiene checks + Ticket.body
|
|
34
|
+
.claude/scripts/detect-missed-gate.mjs # sees a rulebook outside the repo root
|
|
35
|
+
.claude/hooks/gate-stop-dod.mjs # judges the tree it is in
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Delete those six and re-run `init`, or copy them across by hand. A proper
|
|
39
|
+
upgrade command is queued, not shipped — and until it exists this note tells you
|
|
40
|
+
the manual steps rather than an easy sentence that leaves half the release
|
|
41
|
+
inert. That failure mode is the whole subject of 0.3.1, immediately below.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **`check-premises` skill** — a queue item is a _claim about the code_, written
|
|
46
|
+
by someone who was not reading the code at the time, and nothing downstream
|
|
47
|
+
re-checks it: the failing test is written against the item, the implementation
|
|
48
|
+
against the test, and the reviewer compares the diff to the item. A false
|
|
49
|
+
premise therefore produces work that is correct, tested, reviewed and useless.
|
|
50
|
+
The skill runs between taking the item and the Red step, is read-only by
|
|
51
|
+
frontmatter so it cannot start implementing, and returns `PREMISES HOLD` /
|
|
52
|
+
`PREMISE FALSE` / `UNVERIFIABLE`. Its two boundaries are the point: a false
|
|
53
|
+
load-bearing premise is **stop and report**, never a silent re-aim of the task,
|
|
54
|
+
and only load-bearing claims are checked — an audit is what makes the step
|
|
55
|
+
expensive enough to skip. The `loop` skill calls it, and treats `PREMISE FALSE`
|
|
56
|
+
as a per-task escalation rather than a licence to rewrite the item.
|
|
57
|
+
- **`prose-reviewer` agent** — a fourth gate, read-only. In this layer the prose
|
|
58
|
+
_is_ the implementation: a rule that overstates its own enforcement fails
|
|
59
|
+
exactly like broken code, silently and in the direction of false confidence. It
|
|
60
|
+
blocks on five things — enforcement claimed beyond the mechanism, a dead
|
|
61
|
+
reference, two rules that contradict each other, stated limits gone stale in
|
|
62
|
+
either direction, and domain that must not travel (a vendor name, a host path,
|
|
63
|
+
a tracker key or a credential in a layer meant to be neutral) — and its
|
|
64
|
+
boundary comes before its checklist: it is **not
|
|
65
|
+
a literary editor**, and prose that is merely clumsy is not a finding. Wired
|
|
66
|
+
into the `pr-ship` fan-out and named in both maps.
|
|
67
|
+
- **A sixth blocking item for `code-reviewer`** — a change that contradicts the
|
|
68
|
+
queue item it claims to implement. The instruction is to report the mismatch,
|
|
69
|
+
never to decide which side "must have been meant": a reviewer who reconciles
|
|
70
|
+
the two silently turns a visible mismatch into an invisible one. Where no item
|
|
71
|
+
was supplied, it says so rather than reconstructing one from the PR body —
|
|
72
|
+
which is evidence `autonomy.md` refuses by name. `pr-ship` now passes the item.
|
|
73
|
+
- **Three queue-hygiene checks** — a parent that says it was split up and is
|
|
74
|
+
still open; a dependency line naming a blocker no link carries (worse than a
|
|
75
|
+
stale label: selection reads the item as unblocked); and a document link that
|
|
76
|
+
is broken on its face. The neutral `Ticket` shape gains a **nullable `body`**
|
|
77
|
+
so these live in one pure function instead of once per adapter — and `null`
|
|
78
|
+
means "this adapter cannot answer", never "checked, found nothing".
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- **The baseline commit of a generated project could land in the caller's
|
|
83
|
+
repository.** Git hands its hooks an absolute `GIT_DIR`, and the CLI spawned
|
|
84
|
+
git with the environment intact — so `git init` re-initialised the caller's
|
|
85
|
+
repo, `add -A` staged its tree, and the commit landed on whatever branch it had
|
|
86
|
+
checked out, while the generated project got no `.git` at all. A redirected
|
|
87
|
+
`git init` can also flip the caller's repository to `core.bare=true`. The path
|
|
88
|
+
that triggers it is a pre-commit hook running a suite that generates projects —
|
|
89
|
+
which is what made the `worktree-task` skill unusable. Every git call site now
|
|
90
|
+
strips the variables that locate a repository, including the shipped
|
|
91
|
+
`gate-stop-dod` hook (which asked git whether _which_ tree was clean) and
|
|
92
|
+
`preflight`.
|
|
93
|
+
- **The Tier-2 gate sweep could not see a rulebook outside the repository root.**
|
|
94
|
+
`detect-missed-gate` exempts the rulebook from its inert-file rule so a merge
|
|
95
|
+
rewriting the autonomy tiers cannot pass as "just prose" — but the exemption
|
|
96
|
+
was anchored at `CLAUDE.md` / `.claude/`. Any project that vendors, templates
|
|
97
|
+
or nests a rig keeps its rulebook elsewhere, and every `.md` there was dropped
|
|
98
|
+
before the elevated-path test ran. It is now recognised wherever it sits, and
|
|
99
|
+
the sweep's verdict vocabulary knows the words `pr-ship` actually emits.
|
|
100
|
+
|
|
101
|
+
### Deferred, and on what condition
|
|
102
|
+
|
|
103
|
+
Two pieces of the source brief did **not** travel, because shipping an unproven
|
|
104
|
+
gate into other people's projects is worse than not having one:
|
|
105
|
+
|
|
106
|
+
- the queue-closing discipline for blocked dependents — enters when it has been
|
|
107
|
+
merged and used in the project it came from;
|
|
108
|
+
- the clarify-gate (`C-0…C-2`) — enters once that gate has fired at least once
|
|
109
|
+
anywhere. Until then there is nothing to copy but an intention.
|
|
110
|
+
|
|
10
111
|
## 0.3.1
|
|
11
112
|
|
|
12
113
|
`create-agent-rig init` shipped a rig that looked installed and enforced
|
package/README.md
CHANGED
|
@@ -83,8 +83,8 @@ never labels**, and **the agent never files its own work items**.
|
|
|
83
83
|
|
|
84
84
|
Around all of it: **autonomy tiers** (what an agent does alone / after review /
|
|
85
85
|
never), **stop rules** (three strikes, flaky ≠ retry, session staleness),
|
|
86
|
-
**subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`,
|
|
87
|
-
`cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
|
|
86
|
+
**subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`,
|
|
87
|
+
`prose-reviewer`, and `cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
|
|
88
88
|
`loop` queue driver; `worktree-task` for concurrent sessions; `new-invariant`, a
|
|
89
89
|
generator for the invariant→hook→test pattern; `post-deploy-verify` and
|
|
90
90
|
`ro-debug` on the AWS target), and a one-page `CLAUDE.md` map a fresh session
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import { copyTree, listTree } from '../lib/copy-tree.js';
|
|
6
6
|
import { ALLOWED_OVERWRITES, detectCollisions } from '../lib/composition.js';
|
|
7
7
|
import { substituteContent, substituteFileName } from '../lib/substitute.js';
|
|
8
|
+
import { gitEnv } from '../lib/git-env.js';
|
|
8
9
|
import { DEFAULT_TARGET, TARGETS, TARGET_NAMES } from '../lib/targets.js';
|
|
9
10
|
import { agentOsStackDir, agentOsUniversalDir, skeletonDir } from '../templates.js';
|
|
10
11
|
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
@@ -72,9 +73,10 @@ async function initGitBaseline(projectDir) {
|
|
|
72
73
|
// .git/objects/pack after we return — a non-deterministic tail that races any
|
|
73
74
|
// caller cleaning up the directory, and pointless work on a one-commit repo.
|
|
74
75
|
const quiet = ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false'];
|
|
76
|
+
const where = { cwd: projectDir, env: gitEnv() };
|
|
75
77
|
try {
|
|
76
|
-
await run('git', [...quiet, 'init', '--quiet'],
|
|
77
|
-
await run('git', [...quiet, 'add', '-A'],
|
|
78
|
+
await run('git', [...quiet, 'init', '--quiet'], where);
|
|
79
|
+
await run('git', [...quiet, 'add', '-A'], where);
|
|
78
80
|
// Explicit identity: the baseline must commit even where git has no
|
|
79
81
|
// global user configured (fresh machines, CI). --no-verify here shields
|
|
80
82
|
// the baseline from the USER'S global hooks only — the generated
|
|
@@ -90,7 +92,7 @@ async function initGitBaseline(projectDir) {
|
|
|
90
92
|
'--no-verify',
|
|
91
93
|
'-m',
|
|
92
94
|
'Pristine template (create-agent-rig)',
|
|
93
|
-
],
|
|
95
|
+
], where);
|
|
94
96
|
}
|
|
95
97
|
catch {
|
|
96
98
|
// git missing or unusable — generation never fails on this.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variables that point git at a repository other than the one at `cwd`.
|
|
3
|
+
*
|
|
4
|
+
* Inherited, they silently redirect a git command into the CALLER's repository:
|
|
5
|
+
* `git init` re-initialises it, `add -A` stages the caller's tree, and a commit
|
|
6
|
+
* lands on whatever branch the caller has checked out — while the directory the
|
|
7
|
+
* command was aimed at ends up with no `.git` at all.
|
|
8
|
+
*
|
|
9
|
+
* This is not hypothetical. Git sets `GIT_DIR` and `GIT_INDEX_FILE` — absolute —
|
|
10
|
+
* for the hooks it runs, so a `git commit` from a linked worktree whose
|
|
11
|
+
* pre-commit runs a suite that shells out to git writes one junk commit per
|
|
12
|
+
* invocation onto the branch being committed. Observed twice in this repo, from
|
|
13
|
+
* two different call sites, which is why this lives in one module: a second copy
|
|
14
|
+
* of this list is a second chance to fix one and forget the other.
|
|
15
|
+
*
|
|
16
|
+
* The list is explicit rather than a `GIT_*` sweep on purpose — `GIT_SSH_COMMAND`
|
|
17
|
+
* or `GIT_TERMINAL_PROMPT` are the caller's environment and none of our business.
|
|
18
|
+
* Only repository *location* is stripped.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 Limit, stated: this strips repository *location*, not every way git can be
|
|
21
|
+
* redirected. The config-injection family (`GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n`,
|
|
22
|
+
* `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_GLOBAL`/`_SYSTEM`) can carry `core.worktree`
|
|
23
|
+
* or `core.bare` and is deliberately out of scope: git never sets those for the
|
|
24
|
+
* hooks that cause this problem, and stripping a caller's deliberate config
|
|
25
|
+
* overrides would be its own surprise.
|
|
26
|
+
*
|
|
27
|
+
* 🔴 And it protects exactly the call sites that use it. It does not make git
|
|
28
|
+
* safe to call from a hook-invoked process in general, and a spawn that forgets
|
|
29
|
+
* `gitEnv()` is unprotected — nothing in this module can detect that. The sweep
|
|
30
|
+
* in `test/template/git-env.test.ts` is what watches for it.
|
|
31
|
+
*/
|
|
32
|
+
export const GIT_LOCATION_VARS = [
|
|
33
|
+
'GIT_DIR',
|
|
34
|
+
'GIT_WORK_TREE',
|
|
35
|
+
'GIT_INDEX_FILE',
|
|
36
|
+
'GIT_COMMON_DIR',
|
|
37
|
+
'GIT_OBJECT_DIRECTORY',
|
|
38
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
39
|
+
'GIT_NAMESPACE',
|
|
40
|
+
'GIT_PREFIX',
|
|
41
|
+
];
|
|
42
|
+
/** The caller's environment minus anything that re-points git at another repo. */
|
|
43
|
+
export function gitEnv(env = process.env) {
|
|
44
|
+
const sanitised = { ...env };
|
|
45
|
+
for (const key of GIT_LOCATION_VARS)
|
|
46
|
+
delete sanitised[key];
|
|
47
|
+
return sanitised;
|
|
48
|
+
}
|
|
@@ -10,5 +10,19 @@ export const TARGETS = {
|
|
|
10
10
|
},
|
|
11
11
|
};
|
|
12
12
|
export const TARGET_NAMES = Object.keys(TARGETS);
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The pre-selected entry in the interactive menu, and the API-level default.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **Not the fallback for a non-interactive run.** A run with no TTY and no
|
|
17
|
+
* `--target` is *refused* (`index.ts`) — never prompt into a pipe, and never
|
|
18
|
+
* guess a whole project shape for a script that did not say. This constant is
|
|
19
|
+
* what `Enter`, an out-of-range number or an unrecognised name resolve to at the
|
|
20
|
+
* prompt (`prompts.ts`), plus what `createProject` uses when called as a library
|
|
21
|
+
* with no target.
|
|
22
|
+
*
|
|
23
|
+
* The comment this replaces said "one implicit target", which was stale from
|
|
24
|
+
* when there was one. Its first correction claimed the CLI defaults silently in
|
|
25
|
+
* CI — the opposite of what it does, in the file a maintainer would open to
|
|
26
|
+
* check exactly that.
|
|
27
|
+
*/
|
|
14
28
|
export const DEFAULT_TARGET = 'aws-serverless';
|
package/scripts/prepare.mjs
CHANGED
|
@@ -5,25 +5,62 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
const root = path.dirname(fileURLToPath(import.meta.url)) + '/..';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The environment `git config` runs under, minus anything that could point it
|
|
14
|
+
* at another repository's config file.
|
|
15
|
+
*
|
|
16
|
+
* This script runs from `pnpm install`, and a pre-commit hook can reach an
|
|
17
|
+
* install — a hook-started process inherits an absolute `GIT_DIR`, and
|
|
18
|
+
* `core.hooksPath` would then be written into somebody ELSE's repository.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ The canonical list lives in `packages/cli/src/lib/git-env.ts` and this is a
|
|
21
|
+
* deliberate second copy, because `prepare` step 1 runs *before* step 2 builds
|
|
22
|
+
* the TypeScript that would make it importable. The subset is not a smaller
|
|
23
|
+
* opinion about that list: `git config --local` resolves its target file only
|
|
24
|
+
* through `GIT_DIR`/`GIT_COMMON_DIR`, and `GIT_CONFIG` names a config file
|
|
25
|
+
* outright. `GIT_WORK_TREE` and `GIT_INDEX_FILE` cannot move it, so stripping
|
|
26
|
+
* them here would be noise.
|
|
27
|
+
*
|
|
28
|
+
* Exported so the behaviour is testable — importing this module must not build
|
|
29
|
+
* anything, hence the entry-point guard at the bottom.
|
|
30
|
+
*/
|
|
31
|
+
export const gitConfigEnv = (env = process.env) => {
|
|
32
|
+
const sanitised = { ...env };
|
|
33
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_CONFIG']) delete sanitised[key];
|
|
34
|
+
return sanitised;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function main() {
|
|
38
|
+
// 1. Wire up the pre-commit hook when working inside the git checkout.
|
|
39
|
+
if (existsSync(path.join(root, '.git'))) {
|
|
40
|
+
spawnSync('git', ['config', 'core.hooksPath', '.husky'], {
|
|
41
|
+
cwd: root,
|
|
42
|
+
env: gitConfigEnv(),
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
|
|
48
|
+
const require = createRequire(import.meta.url);
|
|
49
|
+
const tscPath = path.join(
|
|
50
|
+
path.dirname(require.resolve('typescript/package.json')),
|
|
51
|
+
'lib',
|
|
52
|
+
'tsc.js',
|
|
53
|
+
);
|
|
54
|
+
const result = spawnSync(
|
|
55
|
+
process.execPath,
|
|
56
|
+
[tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
|
|
57
|
+
{ cwd: root, stdio: 'inherit' },
|
|
58
|
+
);
|
|
59
|
+
process.exit(result.status ?? 1);
|
|
15
60
|
}
|
|
16
61
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'tsc.js',
|
|
23
|
-
);
|
|
24
|
-
const result = spawnSync(
|
|
25
|
-
process.execPath,
|
|
26
|
-
[tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
|
|
27
|
-
{ cwd: root, stdio: 'inherit' },
|
|
28
|
-
);
|
|
29
|
-
process.exit(result.status ?? 1);
|
|
62
|
+
// Run only when executed, never when imported: a test that imports this module
|
|
63
|
+
// to check one exported function must not trigger a build or touch git config.
|
|
64
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
65
|
+
main();
|
|
66
|
+
}
|
|
@@ -20,8 +20,10 @@ wrong.
|
|
|
20
20
|
.claude/rules/ how work happens (workflow), what needs a human (autonomy),
|
|
21
21
|
and the pattern for making a rule mechanical (invariants)
|
|
22
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
|
-
|
|
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
|
|
25
27
|
.claude/scripts/ the queue adapter, the preflight, the out-of-band sweeps
|
|
26
28
|
```
|
|
27
29
|
|
|
@@ -51,9 +53,13 @@ it a hook via the `new-invariant` skill.
|
|
|
51
53
|
`.claude/rules/workflow.md` ("Branches and commits", "PR flow"). When another
|
|
52
54
|
session may touch this repo at the same time, the branch lives in its own
|
|
53
55
|
worktree — the `worktree-task` skill has the lifecycle and the cleanup.
|
|
54
|
-
- **Gates.** `code-reviewer`
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
57
63
|
- **Enforcement is mechanical.** `block-no-verify` refuses pre-commit bypasses;
|
|
58
64
|
`guard-bash` refuses the "Never" tier — force-pushing a shared branch, a
|
|
59
65
|
production deploy, a filesystem wipe — and carries the kill switch;
|
|
@@ -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 {
|
|
@@ -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',
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-premises
|
|
3
|
+
description: Check a queue item's claims about the code before building on them. Use immediately after taking an item and before the failing test — whenever the item asserts that something exists, is missing, is broken, or works a particular way.
|
|
4
|
+
context: fork
|
|
5
|
+
allowed-tools: Read, Grep, Glob, Bash
|
|
6
|
+
argument-hint: <the queue item's text>
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
A queue item is a **claim about the code**, written by someone who was not
|
|
10
|
+
reading the code at the time. "The retry path swallows the error", "there is no
|
|
11
|
+
validation on that field", "the worker never gets the second message" — each of
|
|
12
|
+
those is a premise, and the work that follows is only worth doing if it is true.
|
|
13
|
+
|
|
14
|
+
This skill checks the premises. It runs **after selection, before the Red step**,
|
|
15
|
+
and it produces one of three verdicts. It writes nothing.
|
|
16
|
+
|
|
17
|
+
## Why it sits here and not in review
|
|
18
|
+
|
|
19
|
+
A false premise is not caught later. Review reads the diff against the item, and
|
|
20
|
+
both are wrong in the same direction: the item said the validation was missing,
|
|
21
|
+
the diff adds validation, the reviewer sees a diff that does what the item asked.
|
|
22
|
+
Nobody re-reads the file that had the validation all along. The cost lands as a
|
|
23
|
+
duplicate implementation, a "fix" for a bug that was somewhere else entirely, or
|
|
24
|
+
a refactor of a path that no caller reaches — all of it green, reviewed, merged.
|
|
25
|
+
|
|
26
|
+
The check is cheap because it is narrow, and the next section is that narrowness.
|
|
27
|
+
|
|
28
|
+
## 1. Write out the claims — as claims
|
|
29
|
+
|
|
30
|
+
List what the item asserts about the code as it exists **now**. Two to five
|
|
31
|
+
lines. Keep them in the item's own terms; do not repair them while transcribing
|
|
32
|
+
— a claim you have already improved is one you will not test.
|
|
33
|
+
|
|
34
|
+
Separate the claims from the request. "Add a `GET /notes/:id` route" asserts
|
|
35
|
+
nothing; "the route handler bypasses the usecase layer" does.
|
|
36
|
+
|
|
37
|
+
An item that asserts nothing is done here: verdict `PREMISES HOLD`, one line
|
|
38
|
+
saying there were none. That is a common and perfectly good outcome.
|
|
39
|
+
|
|
40
|
+
## 2. Mark the load-bearing ones
|
|
41
|
+
|
|
42
|
+
🔴 **A claim is load-bearing when its falsity changes what gets built.** Only
|
|
43
|
+
those get verified. **This is not an audit** of the item, the file, or the
|
|
44
|
+
codebase — the moment it becomes one, it stops being cheap, gets skipped under
|
|
45
|
+
time pressure, and the whole step is lost.
|
|
46
|
+
|
|
47
|
+
| Load-bearing | Not |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| "there is no X" — if X exists, the task is already done | a stale line number in the item's description |
|
|
50
|
+
| "X is called from Y" — if it is not, the fix goes in the wrong place | a misspelled symbol you can resolve at a glance |
|
|
51
|
+
| "X handles the empty case by Z" — the fix is designed against Z | a claim about a file this task will not touch |
|
|
52
|
+
| "nothing enforces X" — the whole task is the enforcement | a claim the task's own failing test would immediately expose |
|
|
53
|
+
|
|
54
|
+
That last row is the one worth internalising: a premise the Red step would
|
|
55
|
+
falsify in the next five minutes does not need checking here. This step exists
|
|
56
|
+
for the premises a passing test **would not** catch — the ones about code the
|
|
57
|
+
task never touches.
|
|
58
|
+
|
|
59
|
+
## 3. Verify each, against the code, with a citation
|
|
60
|
+
|
|
61
|
+
Read the code. Not the tests, not the docs, not another queue item — those are
|
|
62
|
+
claims too. Each verified premise gets a `file:line` citation; a premise you
|
|
63
|
+
believe but cannot cite is not verified, it is remembered.
|
|
64
|
+
|
|
65
|
+
## 4. The verdict
|
|
66
|
+
|
|
67
|
+
| Verdict | When | What happens next |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `PREMISES HOLD` | every load-bearing claim checked out, or there were none | proceed to the Red step |
|
|
70
|
+
| `PREMISE FALSE` | a load-bearing claim is contradicted by the code | **stop and report** |
|
|
71
|
+
| `UNVERIFIABLE` | a load-bearing claim could not be decided from the code | report it as unverifiable, name what would decide it, and proceed only under a **labelled assumption** |
|
|
72
|
+
|
|
73
|
+
🔴 **On `PREMISE FALSE` the answer is stop and report — never quietly work around
|
|
74
|
+
the false premise by building something adjacent that seems useful.** Write what
|
|
75
|
+
the item claimed, what the code actually says with its citation, and what the
|
|
76
|
+
task might become instead. Then let a human re-aim it. The item is wrong, and an
|
|
77
|
+
agent that silently repairs a wrong item produces work nobody asked for, in a
|
|
78
|
+
branch named after a task that does not exist.
|
|
79
|
+
|
|
80
|
+
`UNVERIFIABLE` is not a soft pass. A probe that could not run tells you nothing —
|
|
81
|
+
so the assumption travels in the open, in the item and in the PR description,
|
|
82
|
+
where the next reader can see which part of the work rests on it.
|
|
83
|
+
|
|
84
|
+
## Examples — the three shapes this actually catches
|
|
85
|
+
|
|
86
|
+
**The thing already exists.** Item: "the payload schema does not reject an empty
|
|
87
|
+
title". The schema does reject it, three lines into the validator; the reported
|
|
88
|
+
bug came from a caller that never invoked the validator. Building "the missing
|
|
89
|
+
check" would have added a second, divergent rule and left the real defect —
|
|
90
|
+
the caller — in place. Verdict `PREMISE FALSE`; the task becomes a caller fix.
|
|
91
|
+
|
|
92
|
+
**The thing is somewhere else.** Item: "the worker retries forever because the
|
|
93
|
+
retry budget is not applied". The budget is applied, and correctly; the message
|
|
94
|
+
returns to the queue from a path above it that never consumed the budget at all.
|
|
95
|
+
The fix designed against the item would have been written in a file that was not
|
|
96
|
+
the problem. Verdict `PREMISE FALSE`.
|
|
97
|
+
|
|
98
|
+
**Nothing enforces it — except something does.** Item: "nothing stops a handler
|
|
99
|
+
importing the storage layer directly". A hook does exactly that, and has since
|
|
100
|
+
before the item was filed. Two hours of building a second enforcement mechanism,
|
|
101
|
+
which would then have disagreed with the first. Verdict `PREMISE FALSE`.
|
|
102
|
+
|
|
103
|
+
Note what all three have in common: the resulting work would have been correct,
|
|
104
|
+
tested, reviewable, and useless. That is the failure mode this catches, and it
|
|
105
|
+
is invisible to every gate downstream.
|
|
106
|
+
|
|
107
|
+
## Limits — stated, because a check trusted past its reach is worse than none
|
|
108
|
+
|
|
109
|
+
- **It reads the code, so it only catches what the code can contradict.** A claim
|
|
110
|
+
about runtime behaviour ("this times out in production"), about intent, or
|
|
111
|
+
about a system this repository does not contain is `UNVERIFIABLE` here, not
|
|
112
|
+
false — say so rather than guessing.
|
|
113
|
+
- **It is one pass, before the work.** A premise that becomes false while the
|
|
114
|
+
task runs (a merge lands, a dependency moves) is a staleness stop rule
|
|
115
|
+
(`.claude/rules/autonomy.md`), not this skill.
|
|
116
|
+
- **It has no opinion on whether the task is worth doing.** True premises and a
|
|
117
|
+
pointless task is a perfectly consistent state, and it belongs to whoever fills
|
|
118
|
+
the queue.
|
|
119
|
+
- 🔴 **Nothing makes this run, and the verdict is a self-report.** No hook fires
|
|
120
|
+
when a task starts building on an unchecked claim, and no artifact outlives the
|
|
121
|
+
step — so a run that skipped it and a run that passed it look identical
|
|
122
|
+
afterwards. That is the honest description of every rule of this shape here
|
|
123
|
+
(the `loop` skill says the same about its own no-hand-feeding rule), and it is
|
|
124
|
+
why the citation matters: a `file:line` in the report is the one part of this a
|
|
125
|
+
later reader can re-check.
|
|
@@ -10,9 +10,9 @@ boundaries; the **queue** holds the work; `PLAN.md` holds state, standing
|
|
|
10
10
|
decisions and the journal. This skill is the driver in between: what gets picked,
|
|
11
11
|
what keeps the loop going, what stops it, and where the report goes.
|
|
12
12
|
|
|
13
|
-
Per-task procedure is unchanged: (worktree if another session may run) →
|
|
14
|
-
test first → implement → `pr-ship` → merge on the
|
|
15
|
-
deployed surface if one changed.
|
|
13
|
+
Per-task procedure is unchanged: (worktree if another session may run) →
|
|
14
|
+
`check-premises` → failing test first → implement → `pr-ship` → merge on the
|
|
15
|
+
named criterion → verify the deployed surface if one changed.
|
|
16
16
|
|
|
17
17
|
## 0. The queue is behind an adapter
|
|
18
18
|
|
|
@@ -102,11 +102,18 @@ and the work turns out to touch an elevated path (`CLAUDE.md` →
|
|
|
102
102
|
`elevated-paths`), run the gate anyway, record the verdict on the PR, and treat it
|
|
103
103
|
as this run's elevated item for spacing.
|
|
104
104
|
|
|
105
|
+
**Then, before the Red step: `check-premises`.** The item was written by someone
|
|
106
|
+
who was not reading the code at the time, and everything downstream — the failing
|
|
107
|
+
test, the implementation, the reviewer comparing diff to item — inherits its
|
|
108
|
+
claims rather than checking them. On `PREMISE FALSE` the item is escalated (§6),
|
|
109
|
+
not repaired in place: a run that silently re-aims its own task has authored work
|
|
110
|
+
for itself, which is the one thing this loop does not do (§8).
|
|
111
|
+
|
|
105
112
|
## 3. What keeps the loop running, and what stops it
|
|
106
113
|
|
|
107
114
|
Per-task stops (three strikes, attempt budget, invariant conflict, a blocking
|
|
108
|
-
reviewer verdict) **do not end the run**:
|
|
109
|
-
next one.
|
|
115
|
+
reviewer verdict, a false premise in the item itself) **do not end the run**:
|
|
116
|
+
escalate that item (§5) and take the next one.
|
|
110
117
|
|
|
111
118
|
The run-level conditions are in `stopConditionOf` in `core.mjs`, checked in
|
|
112
119
|
severity order: **queue unreadable** · **runtime regression** · **kill switch** ·
|
|
@@ -204,10 +211,14 @@ mechanises fully (`missed`, `.claude/rules/autonomy.md`) needs no self-report.
|
|
|
204
211
|
## 6. Escalation — two channels, by scope
|
|
205
212
|
|
|
206
213
|
**Task-scoped — the item is the home, and the loop continues.** Three strikes, the
|
|
207
|
-
attempt budget, an invariant conflict,
|
|
214
|
+
attempt budget, an invariant conflict, a blocking reviewer verdict, or a
|
|
215
|
+
`PREMISE FALSE` verdict from `check-premises` — the last one is a
|
|
216
|
+
`documented-stall` (§5), and its diagnosis is already written: what the item
|
|
217
|
+
claimed, what the code says, and the citation:
|
|
208
218
|
|
|
209
219
|
1. Comment the diagnosis on the queue item: what fails, what was tried, the
|
|
210
|
-
current hypothesis, links to the PR and the failing run
|
|
220
|
+
current hypothesis, and links to the PR and the failing run where they exist
|
|
221
|
+
— a premise stop has neither, and its citation stands in for both. **Name the outcome
|
|
211
222
|
state in the same comment** — `incomplete` if the diagnosis cannot say which
|
|
212
223
|
stage needed what. Writing `incomplete` on your own task is uncomfortable and
|
|
213
224
|
is the point: the run that produced it is the only witness.
|
|
@@ -274,6 +285,7 @@ three poisons the only channel by which this project learns.
|
|
|
274
285
|
| Does not | Why |
|
|
275
286
|
| --- | --- |
|
|
276
287
|
| **Create its own work items** | The queue is human-filled. Self-authored work drifts scope, and unattended it drifts unwatched |
|
|
288
|
+
| **Re-aim an item whose premise turned out false** | Same rule wearing a disguise: an item silently rewritten into "what it should have said" is a work item the agent authored. Escalate it (§6) |
|
|
277
289
|
| Take items needing a human decision | It cannot unblock itself; those wait in the Operator queue |
|
|
278
290
|
| Take a `trigger-human` item | It would build for scale that does not exist |
|
|
279
291
|
| Take two elevated items back to back | One unreviewed schema/permissions change is recoverable; a chain overnight is not |
|
|
@@ -19,10 +19,20 @@ blockers.
|
|
|
19
19
|
(see its README / package scripts). Any failure is an instant HOLD — never
|
|
20
20
|
argue with a red check, never rerun flakiness to green
|
|
21
21
|
(`.claude/rules/workflow.md`).
|
|
22
|
-
3. **Reviewer fan-out.** Launch the `code-reviewer` agent on the diff — always
|
|
22
|
+
3. **Reviewer fan-out.** Launch the `code-reviewer` agent on the diff — always,
|
|
23
|
+
and **pass it the text of the queue item this branch implements**. Its
|
|
24
|
+
checklist blocks on a change that contradicts its item, and a reviewer given
|
|
25
|
+
only a diff cannot run that check: a cold context has no way to know what was
|
|
26
|
+
asked, and reconstructing it from the PR description would mean trusting the
|
|
27
|
+
run under review. If there is no item — owner-directed work, a hotfix — say
|
|
28
|
+
so when launching, and the reviewer skips that item openly instead of
|
|
29
|
+
guessing at it.
|
|
23
30
|
Launch `security-scanner` as well when the diff touches its triggers: auth,
|
|
24
31
|
secrets or configuration, input parsing, file handling, new outbound calls,
|
|
25
|
-
dependency changes.
|
|
32
|
+
dependency changes. Launch `prose-reviewer` when the diff touches a rule
|
|
33
|
+
file, a skill, an agent spec, `CLAUDE.md` or the README — a rulebook that
|
|
34
|
+
overstates its own enforcement fails silently and in the direction of false
|
|
35
|
+
confidence. Run them as subagents, in parallel — a fresh context
|
|
26
36
|
reviews better than the session that wrote the code (see
|
|
27
37
|
`.claude/rules/workflow.md`, "Review-context isolation").
|
|
28
38
|
4. **DoD walk.** Check the Definition of Done list in
|
|
@@ -42,6 +42,10 @@ them all; they are one rulebook.
|
|
|
42
42
|
|
|
43
43
|
- **TDD, without exception.** The failing test comes first — use the
|
|
44
44
|
`test-writer` agent for it. See `.claude/rules/workflow.md`.
|
|
45
|
+
- **Check the task's premises before the test.** A queue item is a claim about
|
|
46
|
+
the code, and nothing downstream re-reads the file it was wrong about — the
|
|
47
|
+
`check-premises` skill runs between taking the item and the failing test, and
|
|
48
|
+
a false load-bearing claim stops the task instead of quietly re-aiming it.
|
|
45
49
|
- **One task, one branch — and merge via PR.** Every unit of work gets its own
|
|
46
50
|
short-lived branch; the default branch is never committed to directly. Once
|
|
47
51
|
the project has a remote and CI, changes reach it through the PR flow (local
|
|
@@ -49,9 +53,14 @@ them all; they are one rulebook.
|
|
|
49
53
|
`.claude/rules/workflow.md` ("Branches and commits", "PR flow"). When another
|
|
50
54
|
session may touch this repo at the same time, the branch lives in its own
|
|
51
55
|
worktree — the `worktree-task` skill has the lifecycle and the cleanup.
|
|
52
|
-
- **Gates.** `code-reviewer`
|
|
53
|
-
|
|
54
|
-
|
|
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. The mechanical enforcement below is a different thing, and the
|
|
63
|
+
difference is worth keeping straight.
|
|
55
64
|
- **Enforcement is mechanical.** `guard-core-purity` catches an impure edit to
|
|
56
65
|
the core the moment it lands; `guard-web-boundary` keeps the frontend off the
|
|
57
66
|
backend; `block-no-verify` refuses pre-commit bypasses; `guard-bash` refuses
|
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
".claude/agents/test-writer.md",
|
|
10
10
|
".claude/agents/code-reviewer.md",
|
|
11
11
|
".claude/agents/security-scanner.md",
|
|
12
|
+
".claude/agents/prose-reviewer.md",
|
|
12
13
|
".claude/hooks/block-no-verify.mjs",
|
|
13
14
|
".claude/hooks/guard-bash.mjs",
|
|
14
15
|
".claude/hooks/gate-stop-dod.mjs",
|
|
15
16
|
".claude/hooks/inject-rules.mjs",
|
|
16
17
|
".claude/skills/pr-ship/SKILL.md",
|
|
17
18
|
".claude/skills/loop/SKILL.md",
|
|
19
|
+
".claude/skills/check-premises/SKILL.md",
|
|
18
20
|
".claude/skills/worktree-task/SKILL.md",
|
|
19
21
|
".claude/scripts/detect-missed-gate.mjs",
|
|
20
22
|
".claude/scripts/reconcile-external-prs.mjs",
|