create-agent-rig 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +192 -6
  2. package/README.md +40 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/create.js +40 -10
  5. package/packages/cli/dist/commands/init.js +41 -3
  6. package/packages/cli/dist/commands/upgrade.js +300 -0
  7. package/packages/cli/dist/index.js +100 -13
  8. package/packages/cli/dist/lib/copy-tree.js +9 -1
  9. package/packages/cli/dist/lib/git-env.js +48 -0
  10. package/packages/cli/dist/lib/history.js +49 -0
  11. package/packages/cli/dist/lib/install-set.js +46 -0
  12. package/packages/cli/dist/lib/manifest.js +99 -0
  13. package/packages/cli/dist/lib/prompts.js +20 -0
  14. package/packages/cli/dist/lib/safe-path.js +41 -0
  15. package/packages/cli/dist/lib/substitute.js +32 -0
  16. package/packages/cli/dist/lib/targets.js +15 -1
  17. package/packages/cli/dist/lib/version.js +15 -0
  18. package/scripts/prepare.mjs +54 -17
  19. package/templates/agent-os/init/CLAUDE.md +11 -5
  20. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +15 -0
  21. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +104 -0
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -0
  23. package/templates/agent-os/universal/.claude/rules/workflow.md +4 -0
  24. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +32 -4
  25. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +34 -1
  26. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +125 -0
  27. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +6 -0
  28. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +3 -0
  29. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +6 -0
  30. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +125 -0
  31. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +36 -9
  32. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +12 -2
  33. package/templates/agent-os/universal/CLAUDE.md +12 -3
  34. package/templates/agent-os/universal/PLAN.md +14 -3
  35. package/templates/agent-os/universal/layers.json +2 -0
  36. package/templates/hash-history.json +263 -0
@@ -0,0 +1,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { isSafeSegment } from './safe-path.js';
5
+ /**
6
+ * The install manifest: what this rig installed, at which version, and the
7
+ * hash each file had when it was written.
8
+ *
9
+ * It exists to answer the one hard question an upgrade has — *did the user
10
+ * edit this file?* — with evidence instead of a guess. It is **evidence, not a
11
+ * command**: a file the manifest names but the disk no longer has is reported,
12
+ * never silently restored.
13
+ *
14
+ * It is meant to be committed. Without it in the repository, an upgrade run on
15
+ * CI or on a colleague's machine is blind and falls back to the hash history.
16
+ */
17
+ export const MANIFEST_REL = '.claude/.rig-manifest.json';
18
+ export function sha256(data) {
19
+ return createHash('sha256').update(data).digest('hex');
20
+ }
21
+ function isStringRecord(value) {
22
+ return (typeof value === 'object' &&
23
+ value !== null &&
24
+ !Array.isArray(value) &&
25
+ Object.values(value).every((v) => typeof v === 'string'));
26
+ }
27
+ /**
28
+ * A manifest, or `null` when there is nothing trustworthy to read.
29
+ *
30
+ * The distinction matters: `null` means "no evidence", which sends the upgrade
31
+ * to the hash history. A half-parsed manifest treated as an empty one would
32
+ * claim every file on disk belongs to the user, and upgrade nothing at all.
33
+ */
34
+ export function parseManifest(raw) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
43
+ return null;
44
+ const m = parsed;
45
+ if (typeof m.version !== 'string')
46
+ return null;
47
+ if (m.kind !== 'create' && m.kind !== 'init')
48
+ return null;
49
+ const project = m.project;
50
+ if (typeof project !== 'object' ||
51
+ project === null ||
52
+ typeof project.name !== 'string' ||
53
+ typeof project.scope !== 'string' ||
54
+ typeof project.region !== 'string') {
55
+ return null;
56
+ }
57
+ // Values, not just types. These are substituted into file names and joined
58
+ // into paths, and this file is committed — it reaches a maintainer's disk
59
+ // through a pull request. A name of `../..` would send every write out of
60
+ // the repository, so an unsafe value invalidates the whole manifest rather
61
+ // than being quietly corrected into something plausible.
62
+ if (!isSafeSegment(project.name) || !isSafeSegment(project.scope))
63
+ return null;
64
+ if (project.region !== '' && !isSafeSegment(project.region))
65
+ return null;
66
+ if (!Array.isArray(m.stacks) || m.stacks.some((s) => typeof s !== 'string'))
67
+ return null;
68
+ if (m.stacks.some((s) => !isSafeSegment(s)))
69
+ return null;
70
+ if (!isStringRecord(m.files))
71
+ return null;
72
+ return {
73
+ version: m.version,
74
+ kind: m.kind,
75
+ project: { name: project.name, scope: project.scope, region: project.region },
76
+ stacks: [...m.stacks],
77
+ files: { ...m.files },
78
+ };
79
+ }
80
+ /** Stable bytes: sorted paths, so a re-run produces no diff of its own. */
81
+ export function serializeManifest(manifest) {
82
+ const files = {};
83
+ for (const rel of Object.keys(manifest.files).sort())
84
+ files[rel] = manifest.files[rel];
85
+ return `${JSON.stringify({ ...manifest, files }, null, 2)}\n`;
86
+ }
87
+ export async function readManifest(repoDir) {
88
+ try {
89
+ return parseManifest(await readFile(path.join(repoDir, ...MANIFEST_REL.split('/')), 'utf8'));
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ export async function writeManifest(repoDir, manifest) {
96
+ const dest = path.join(repoDir, ...MANIFEST_REL.split('/'));
97
+ await mkdir(path.dirname(dest), { recursive: true });
98
+ await writeFile(dest, serializeManifest(manifest));
99
+ }
@@ -1,4 +1,24 @@
1
1
  import { createInterface } from 'node:readline';
2
+ /**
3
+ * A yes/no gate before something irreversible. **The default is no**, and a
4
+ * non-interactive caller gets `false` without being asked — the same rule the
5
+ * rest of this CLI follows: never guess for a run that cannot answer.
6
+ *
7
+ * Unlike the target prompt, an unrecognised answer is *not* forgiving: the
8
+ * question is asked before rewriting files in somebody's repository, and "I
9
+ * did not understand you" must not resolve to "go ahead".
10
+ */
11
+ export function promptConfirm(question, streams) {
12
+ if (!streams.isInteractive)
13
+ return Promise.resolve(false);
14
+ const rl = createInterface({ input: streams.input, output: streams.output });
15
+ return new Promise((resolve) => {
16
+ rl.question(`${question} [y/N] `, (answer) => {
17
+ rl.close();
18
+ resolve(/^y(es)?$/i.test(answer.trim()));
19
+ });
20
+ });
21
+ }
2
22
  /**
3
23
  * Pick a target interactively: by number, by name, or Enter for the default.
4
24
  * Anything unrecognised falls back to the default — generation should never
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Path safety for values that came from **outside the CLI** — the install
4
+ * manifest is committed to a repository, so it arrives in pull requests like
5
+ * any other file, and its values are substituted into paths.
6
+ *
7
+ * One module owns both halves so they cannot disagree: what may become a path
8
+ * segment, and where a resolved path is allowed to land.
9
+ */
10
+ /** A value that can be substituted into a path without steering it. */
11
+ export function isSafeSegment(value) {
12
+ return (value !== '' &&
13
+ value !== '.' &&
14
+ value !== '..' &&
15
+ !value.includes('/') &&
16
+ !value.includes('\\') &&
17
+ !value.includes('\0'));
18
+ }
19
+ /**
20
+ * `rel` resolved under `root`, or `null` when it would land anywhere else —
21
+ * including an absolute path, an empty path, and the classic sibling
22
+ * (`/tmp/rig` must not contain `/tmp/rig-evil`).
23
+ *
24
+ * This is the containment behind every write an upgrade makes. It is deliberate
25
+ * belt-and-braces: the values that build `rel` are validated where they are
26
+ * parsed, and this refuses the write anyway.
27
+ */
28
+ export function resolveInside(root, rel) {
29
+ if (rel === '' || path.isAbsolute(rel))
30
+ return null;
31
+ const segments = rel.split('/');
32
+ // Refused, not repaired: joining an absolute or `..`-bearing path onto the
33
+ // root would silently turn hostile input into a plausible-looking write.
34
+ if (segments.some((segment) => !isSafeSegment(segment)))
35
+ return null;
36
+ const base = path.resolve(root);
37
+ const dest = path.resolve(base, ...segments);
38
+ if (dest === base)
39
+ return null;
40
+ return dest.startsWith(base + path.sep) ? dest : null;
41
+ }
@@ -5,6 +5,38 @@ export function substituteContent(content, ctx) {
5
5
  .replaceAll('__REGION__', ctx.region)
6
6
  .replaceAll('@app/', `@${ctx.projectScope}/`);
7
7
  }
8
+ /**
9
+ * The inverse of {@link substituteContent}, used **only to recognise** an
10
+ * installed file as a released version of its template: the released bytes
11
+ * carry tokens, the installed bytes carry the project's own name, and without
12
+ * this every token-carrying file (the kill switch, the loop skill, `CLAUDE.md`)
13
+ * would be a permanent conflict on every rig.
14
+ *
15
+ * 🔴 Limits, and both of them fail in the safe direction — an unrecognised file
16
+ * is reported and left alone, never overwritten:
17
+ *
18
+ * - `__PROJECT_SCOPE__` is not reversed. It substitutes to the same string as
19
+ * `__PROJECT_NAME__`, so the two are indistinguishable after the fact; the
20
+ * agent-os layer (the only layer an upgrade touches) uses neither the scope
21
+ * token nor `@app/` in prose, which a template test pins.
22
+ * - A template that contains the project's name as a *literal* reverses into a
23
+ * token that was never there. That costs nothing on its own — recognition
24
+ * offers the untouched bytes as a candidate too, and those still match. It
25
+ * bites only on a file carrying **both** a token and the literal, which then
26
+ * reads as a conflict for that one project.
27
+ */
28
+ export function detokenizeContent(content, ctx) {
29
+ let out = content;
30
+ // scope first: reversing the name first would rewrite `@name/` into
31
+ // `@__PROJECT_NAME__/` and the scope form could never match afterwards
32
+ if (ctx.projectScope !== '')
33
+ out = out.replaceAll(`@${ctx.projectScope}/`, '@app/');
34
+ if (ctx.region !== '')
35
+ out = out.replaceAll(ctx.region, '__REGION__');
36
+ if (ctx.projectName !== '')
37
+ out = out.replaceAll(ctx.projectName, '__PROJECT_NAME__');
38
+ return out;
39
+ }
8
40
  /**
9
41
  * Files that must exist in the generated project under a dotted name, but are
10
42
  * stored un-dotted in the template because `npm publish` strips the dotted
@@ -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';
@@ -0,0 +1,15 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { templatesRoot } from '../templates.js';
4
+ /**
5
+ * The version of the rig doing the installing — stamped into every manifest
6
+ * and printed by `--version`.
7
+ *
8
+ * Resolved through {@link templatesRoot} so there is one walk from this file
9
+ * to the package root, valid in the repo, the tarball and a git install alike.
10
+ */
11
+ export async function packageVersion() {
12
+ const pkgPath = path.join(templatesRoot(), '..', 'package.json');
13
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
14
+ return pkg.version;
15
+ }
@@ -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 = () => {