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 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`, and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -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'], { cwd: projectDir });
77
- await run('git', [...quiet, 'add', '-A'], { cwd: projectDir });
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
- ], { cwd: projectDir });
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
- /** Zero options at the personal stage: one implicit target (PLAN.md §6). */
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';
@@ -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
- // 1. Wire up the pre-commit hook when working inside the git checkout.
13
- if (existsSync(path.join(root, '.git'))) {
14
- spawnSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: root, stdio: 'inherit' });
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
- // 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
18
- const require = createRequire(import.meta.url);
19
- const tscPath = path.join(
20
- path.dirname(require.resolve('typescript/package.json')),
21
- 'lib',
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
- .claude/skills/ the drivers: loop, pr-ship, worktree-task, new-invariant
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` runs before every PR; `security-scanner` runs when
55
- a change touches auth, secrets, parsing, or outbound calls. Blocking findings
56
- are resolved, not argued with. The `pr-ship` skill drives the gate.
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) => path === 'CLAUDE.md' || path.startsWith('.claude/');
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
- const VERDICT = /\b(clean|passed|pass|approved|no blocking|green)\b/i;
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
- : `merged touching ${elevatedFiles.length} elevated-tier path(s) with ` +
288
- 'no human-review label and no reviewer verdict recorded anywhere',
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, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
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) → failing
14
- test first → implement → `pr-ship` → merge on the named criterion → verify 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**: escalate that item (§5) and take the
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, or a blocking reviewer verdict:
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. **Name the outcome
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. Run them as subagents, in parallel a fresh context
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` runs before every PR; `security-scanner` runs when
53
- a change touches auth, secrets, parsing, or outbound calls. Blocking findings
54
- are resolved, not argued with. The `pr-ship` skill drives the gate.
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",