create-agent-rig 0.10.1 → 1.0.1

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 (44) hide show
  1. package/CHANGELOG.md +170 -4
  2. package/README.md +8 -8
  3. package/package.json +2 -2
  4. package/packages/cli/dist/commands/upgrade.js +22 -9
  5. package/packages/cli/dist/index.js +9 -2
  6. package/packages/cli/dist/integrations/doctor-guards.js +7 -3
  7. package/templates/agent-os/subagent-routing.json +9 -5
  8. package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
  9. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
  10. package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
  11. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  12. package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
  13. package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
  14. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +5 -1
  15. package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
  16. package/templates/agent-os/universal/.claude/agents/security-scanner.md +1 -1
  17. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
  18. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +142 -14
  19. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +5 -1
  20. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +168 -17
  21. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
  22. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
  24. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
  25. package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
  26. package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
  27. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
  28. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +157 -10
  29. package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
  31. package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
  32. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  33. package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
  34. package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
  35. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
  36. package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
  37. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  38. package/templates/agent-os/universal/AGENTS.md +11 -5
  39. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +1 -1
  40. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
  41. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
  42. package/templates/agent-os/universal/layers.json +12 -0
  43. package/templates/hash-history.json +118 -22
  44. package/templates/release-ledger.json +3 -1
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: skill-authoring
3
+ description: Use when authoring or editing a skill in this rig — a new SKILL.md under .claude/skills/, or a change to an existing one.
4
+ allowed-tools: Read, Grep, Glob
5
+ ---
6
+
7
+ # Authoring a skill
8
+
9
+ No check in this rig runs on a skill you write here — the rules below are
10
+ applied by the author, and by a reviewer reading this file, never by a
11
+ mechanism. On the skills this rig itself ships, the generator's own
12
+ `test/template/skill-authoring.test.ts` (absent in a generated rig) checks
13
+ only the first rule, that a `description` is present, and that the skills and
14
+ roles a skill names by name exist.
15
+
16
+ - **The frontmatter `name` equals the directory name.** A skill loaded from
17
+ `.claude/skills/foo/` is named `foo`, not something else.
18
+ - **The `description` says when to use the skill**, not just what it does.
19
+ - **Keep it short, and point at existing rules or scripts rather than
20
+ restating them.** A skill that copies a rule's wording is a second copy
21
+ that goes stale the day the rule changes; link to `.claude/rules/` or a
22
+ script instead.
23
+ - **Every sentence describing how a mechanism behaves either points at the
24
+ test that proves it, or is deleted.** `.claude/rules/invariants.md`,
25
+ "State the limits — and test them", is the norm; the form is `see <file>
26
+ (absent in a generated rig) › "<exact test name>"`.
27
+ - **A mention of a workflow-layer skill or script in a Core document is
28
+ qualified "opt-in workflow layer"** — `loop` and `pr-ship` are examples,
29
+ not this skill's own concern.
30
+ - **Both copies ship and must stay identical**: the Claude skill under
31
+ `.claude/skills/` and the Codex repository skill under `.agents/skills/`.
32
+ That mirror is checked in `test/template/codex.test.ts` (absent in a
33
+ generated rig) › "publishes every shared skill through the Codex
34
+ repository skill location" — not repeated here.
35
+
36
+ ## Out of scope
37
+
38
+ This is guidance to apply by reading, not an evaluator: the skill itself has
39
+ no procedure and checks nothing.
@@ -2,7 +2,7 @@
2
2
  name: code-reviewer
3
3
  description: Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR that reaches the `model` lane — everything the two cheap lanes do not claim (code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated), decided by `decision-router.mjs` where the opt-in workflow layer is installed, or by the same rule applied by hand otherwise. Blocking findings must be resolved, not argued with.
4
4
  tools: Read, Grep, Glob, Bash
5
- model: claude-opus-5
5
+ model: claude-opus-5-5
6
6
  effort: high
7
7
  ---
8
8
 
@@ -38,6 +38,10 @@ references, and you classify every finding as **blocking** or **advisory**.
38
38
  rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).
39
39
  "Item not supplied, item 6 not checked" is a useful line in a report; a
40
40
  guess dressed as a verdict is worse than the silence it replaces.
41
+ 7. **Independent oracle** — a test of a security, ownership or governance
42
+ mechanism whose expected result is derived from the same production
43
+ mechanism it checks. See the independent-oracle invariant in
44
+ `.claude/rules/invariants.md`.
41
45
 
42
46
  ## Advisory findings
43
47
 
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: failure-diagnostician
3
+ description: Use when a check is red or a run crashed and the cause is not obvious, or to reproduce a claimed defect/historical finding on the current default branch before work is planned on it.
4
+ tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5-5
6
+ effort: high
7
+ ---
8
+
9
+ You diagnose. You take one of two input kinds — a red check or a crashed run,
10
+ or a claimed defect / historical finding to reproduce on the current default
11
+ branch — and answer with evidence, not a guess dressed as one.
12
+
13
+ ## Hard limits
14
+
15
+ - **You make no repository edits.** A throwaway reproduction file goes
16
+ outside the repository, never inside it — you are not the Green step, and a
17
+ fix is not your answer. You do not commit, push, merge or open a pull
18
+ request.
19
+ - **Never re-run a check until it goes green.** A flaky-looking result is a
20
+ defect to report, not a thing to retry — the same stop rule that applies
21
+ everywhere else in this rulebook (`.claude/rules/autonomy.md`, "Stop rules —
22
+ by work-state, not by feelings").
23
+ - **On Claude Code, no hook enforces the no-edit limit above.** `tools: Read,
24
+ Grep, Glob, Bash` carries no Write or Edit, which stops the ordinary path,
25
+ but nothing refuses a shell redirect the way a guard would. On Codex the
26
+ equivalent profile is `sandbox_mode = "read-only"`, enforced by the runtime
27
+ itself. Either way, a sandbox that blocks the reproduction you need is not
28
+ something to work around: answer `INCONCLUSIVE` or `INSUFFICIENT_EVIDENCE`
29
+ and name the sandbox as the missing evidence.
30
+
31
+ ## Method
32
+
33
+ 1. **Reproduce.** A failure input (red check, crashed run) reproduces at the
34
+ commit or branch where it failed — the PR head, or the commit the caller
35
+ names. A claimed defect or historical finding reproduces on the current
36
+ default branch. No reproduction, no diagnosis.
37
+ 2. **Isolate.** Narrow to the smallest change (input, config, code path) that
38
+ flips the result.
39
+ 3. **Hypothesize.** State the mechanism you think is responsible, in one or
40
+ two sentences.
41
+ 4. **Confirm with evidence.** Show the command and its output, or the
42
+ file:line the mechanism lives at. A hypothesis nothing confirms is
43
+ `INCONCLUSIVE`, not `ROOT_CAUSE`.
44
+
45
+ For a failure input that reaches `ROOT_CAUSE`, classify it: `product` (the
46
+ code is wrong), `test` (the test's premise or fixture is wrong),
47
+ `infrastructure` (CI, network, environment — not the code under test), or
48
+ `upstream` (a dependency or external service).
49
+
50
+ ## Optional evidence (opt-in workflow layer)
51
+
52
+ Where this repository has installed the opt-in workflow layer, the run
53
+ journal and `run-state.mjs`'s recorded verdict may already carry evidence
54
+ worth reading before you reproduce anything by hand — a prior `REGRESSION`,
55
+ or an earlier run's own trace. Their absence is the normal Core path, not a
56
+ gap: read them when present, reproduce directly when not.
57
+
58
+ ## The answer
59
+
60
+ End your report with **exactly one** fenced `json` block of the shared shape
61
+ (`.claude/scripts/lib/verdict.mjs`), and nothing after it.
62
+
63
+ - **A failure input** (red check, crash) answers `ROOT_CAUSE` or
64
+ `INCONCLUSIVE`.
65
+ - **A claim or historical finding** answers `STILL_LIVE`, `ALREADY_FIXED`,
66
+ `OBSOLETE` or `INSUFFICIENT_EVIDENCE`.
67
+ - `ROOT_CAUSE`, `INCONCLUSIVE`, `STILL_LIVE` and `INSUFFICIENT_EVIDENCE` are
68
+ blocking and must name at least one blocker: for `ROOT_CAUSE` and
69
+ `STILL_LIVE` the blocker is the cause, with `file`/`line` where there is
70
+ one; for `INCONCLUSIVE` and `INSUFFICIENT_EVIDENCE` it is what evidence
71
+ would decide the question.
72
+ - `ALREADY_FIXED` and `OBSOLETE` carry no blockers — the fixing commit or the
73
+ superseding mechanism goes in `evidence` instead.
74
+ - `classification` is required on `ROOT_CAUSE`, allowed but optional on
75
+ `STILL_LIVE`, and refused on every other word.
76
+ - `node .claude/scripts/verdict.mjs check <report> failure-diagnostician` is
77
+ what refuses a malformed answer before anyone reads it as one.
78
+
79
+ ```json
80
+ {
81
+ "gate": "failure-diagnostician",
82
+ "verdict": "ROOT_CAUSE",
83
+ "blockers": [
84
+ {
85
+ "file": "src/example.ts",
86
+ "line": 42,
87
+ "rule": "reproduced failure",
88
+ "note": "the function reads the value before the guard that handles the missing case — reproduced on the PR head with the fixture the failing test supplies"
89
+ }
90
+ ],
91
+ "advisories": [],
92
+ "evidence": ["reproduced with the failing test on the PR head", "the stack trace from that run names the file:line above"],
93
+ "classification": "product"
94
+ }
95
+ ```
96
+
97
+ ```json
98
+ {
99
+ "gate": "failure-diagnostician",
100
+ "verdict": "ALREADY_FIXED",
101
+ "blockers": [],
102
+ "advisories": [],
103
+ "evidence": ["the commit that added the missing guard fixes exactly this report", "re-ran the original repro on the current default branch; it now passes"]
104
+ }
105
+ ```
106
+
107
+ ## Not a merge gate
108
+
109
+ You are never a routed reviewer: no `decision-router` lane names you and no
110
+ `pr-ship` coverage check (opt-in workflow layer) expects your answer. You
111
+ diagnose on request; you never implement the fix, and a report with no
112
+ unbacked behaviour claim or invented figure is the only kind you write.
@@ -2,7 +2,7 @@
2
2
  name: security-scanner
3
3
  description: Scans a change for security issues. MUST be used when a change touches authentication, authorization, secrets or configuration, input parsing, file handling, or any new outbound call. Findings gate the PR.
4
4
  tools: Read, Grep, Glob, Bash
5
- model: claude-opus-5
5
+ model: claude-opus-5-5
6
6
  effort: high
7
7
  ---
8
8
 
@@ -193,9 +193,13 @@ function main() {
193
193
  // repo" and waves through, running the whole Definition-of-Done suite
194
194
  // against a tree it never managed to read.
195
195
  //
196
- // 🔴 Limit: only THIS command is sanitised. The Definition-of-Done checks
197
- // below run with the environment as given, because they are the project's
198
- // own commands and their environment is the project's business.
196
+ // 🔴 Limit: only THIS command is sanitised beyond `RIG_RUN_DIR`. The
197
+ // Definition-of-Done checks below run with the environment as given,
198
+ // because they are the project's own commands and their environment is
199
+ // the project's business — except `RIG_RUN_DIR`, which names the
200
+ // directory of the workflow run executing this very hook and belongs to
201
+ // the run, not to the project's own commands.
202
+ // see hooks.test.ts (absent in a generated rig) › "never lets a Definition-of-Done check see RIG_RUN_DIR, even though the hook itself is given one"
199
203
  // `timeout` below is the preamble's own leash: this is the one call that
200
204
  // runs before the budget's clock starts. A repository slow enough to exceed
201
205
  // it throws into the catch and the checks run anyway — the safe direction,
@@ -268,6 +272,12 @@ function main() {
268
272
  // lengthen nor shorten the budget.
269
273
  const deadline = performance.now() + budget.ms;
270
274
 
275
+ // Computed once, not per check: `RIG_RUN_DIR` names the workflow run
276
+ // directory of the run executing this hook, and it is the one variable the
277
+ // project's own commands never get — see the 🔴 limit above.
278
+ const envForChecks = { ...process.env };
279
+ delete envForChecks.RIG_RUN_DIR;
280
+
271
281
  for (const command of usable) {
272
282
  // A 1 ms floor rather than a branch for "the budget is already gone": the
273
283
  // check then times out through the ordinary path, which names the command
@@ -276,6 +286,7 @@ function main() {
276
286
  const result = spawnSync(command, {
277
287
  cwd: PROJECT_ROOT,
278
288
  shell: true,
289
+ env: envForChecks,
279
290
  encoding: 'utf8',
280
291
  stdio: ['ignore', 'pipe', 'pipe'],
281
292
  timeout: Math.max(1, Math.ceil(deadline - performance.now())),
@@ -5,8 +5,10 @@
5
5
  // The rulebook is the set of files that decide what this session may do — hooks
6
6
  // and settings wiring, the queue config and selector, all scripts, rules, skills
7
7
  // and agents, the `.codex/` configuration, the integrity manifest,
8
- // `.claude/doctor-exemptions.json`, `AGENTS.md` and `CLAUDE.md`. Every one of
9
- // them was editable by the run it governs: a
8
+ // `.claude/doctor-exemptions.json`, the revalidation detection contract
9
+ // `.rig/revalidation.json` but not the claim records beside it under
10
+ // `.rig/claims/`, which a SELECT has to write — `AGENTS.md` and `CLAUDE.md`.
11
+ // Every one of them was editable by the run it governs: a
10
12
  // Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
11
13
  // off, and nothing refused it until the merge sweep, long after.
12
14
  //
@@ -26,7 +28,8 @@
26
28
  // names the flag; an edit outside the rulebook still passes. Refusing to
27
29
  // inspect is not allowing (`.claude/rules/invariants.md`).
28
30
  //
29
- // Limits — each stated here and each measured in the generator's
31
+ // Limits — each stated here and, unless it is marked untested, measured in
32
+ // the generator's
30
33
  // `test/template/guard-rulebook.test.ts` (absent in a generated rig), by the
31
34
  // test named beside it:
32
35
  // - it sees one edit at a time, as text, before it lands — a rulebook file
@@ -49,22 +52,57 @@
49
52
  // checkout root before guarding a canonical payload path", › "blocks when
50
53
  // the checkout root and payload use the same symlink spelling", and
51
54
  // › "blocks an existing rulebook file when only the payload path uses a symlink spelling";
55
+ // - an `apply_patch` destination keeps the lexical spelling it named even when
56
+ // a guarded prefix (`.claude/hooks`, say) is itself a symlink/junction to
57
+ // somewhere else inside the checkout — `edit-input.mjs`'s `repositoryPatchPath`
58
+ // carries that spelling alongside the realpath-resolved one, RP-60 — ›
59
+ // "refuses an apply_patch through a guarded prefix junctioned to a target
60
+ // inside the checkout";
52
61
  // - an `allow` prefix is a string prefix of the repo-relative path and may
53
62
  // not widen the rulebook — an entry that is itself a prefix of a rulebook
54
63
  // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
55
64
  // and the guard refuses — › "a flag whose allow-list widens the rulebook is
56
65
  // unreadable, so `--allow .` cannot disarm it";
66
+ // - a miscased payload path is judged against the rulebook's CANONICAL
67
+ // spelling (`unattended-flag.mjs`'s `canonicalRulebookPath`), but an
68
+ // `allow` entry never is — a miscased entry authorizes nothing, including
69
+ // the one prefix deliberately withheld as an allow root
70
+ // (`.claude/scripts/`) and the board selector, refused regardless of case
71
+ // — › "guard-rulebook: an allow-list entry is judged by its literal
72
+ // spelling, not the one the payload folds to (RP-215 round 2)";
57
73
  // - fail-open on its own errors and on a payload it cannot parse — › "allows
58
74
  // an empty payload object" and › "allows non-JSON stdin" — and fail-closed
59
75
  // on a flag it cannot read — › "blocks a rulebook edit when the flag exists
60
76
  // but cannot be read, and names the file": the guard targets drift, not an
61
- // adversary.
77
+ // adversary;
78
+ // - a `//`-prefixed normalised path is refused rather than resolved while
79
+ // armed exactly when it does not relativise under any comparison root —
80
+ // a repository root spelled as a plain path can never strip a UNC admin
81
+ // share or a device path with no drive letter (`\\?\Volume{GUID}\…`), but
82
+ // a root that is itself UNC-spelled (`CLAUDE_PROJECT_DIR` as
83
+ // `\\server\share\repo`) strips a payload path genuinely under it, and
84
+ // such a path is judged normally instead — › "guard-rulebook: an
85
+ // unjudgeable UNC/device-namespace path is refused, not silently allowed
86
+ // (RP-244 round 2)" and › "guard-rulebook: a `//`-prefixed path is
87
+ // refused only when it resolves under no repository root (RP-244
88
+ // round 3)". The same refusal covers a MultiEdit past the fragment cap
89
+ // (the `appliesToAll` global refusal) aimed at such a path, not only the
90
+ // ordinary per-fragment case — › "guard-rulebook: a MultiEdit global
91
+ // refusal is not exempt from the `//`-prefix refusal (RP-244 round 3)";
92
+ // - the reverse direction is untested and left as a design limit: when the
93
+ // repository root is itself spelled as a UNC admin share
94
+ // (`\\host\X$\…`), the LOCAL DRIVE spelling of the same file (`X:\…`) is
95
+ // never placed against it — `relativeTo` strips a `//`-prefixed payload
96
+ // path from a `//`-rooted comparison root, not a drive-letter one from
97
+ // it — so such a payload path is judged normally rather than refused as
98
+ // unjudgeable, and a rulebook edit reaching the guard that way is not
99
+ // caught. Tracked as RP-246.
62
100
  //
63
101
  // The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
64
102
  import { realpathSync } from 'node:fs';
65
103
  import { basename, dirname, join, resolve } from 'node:path';
66
104
  import { editFragments } from './lib/edit-input.mjs';
67
- import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
105
+ import { RULEBOOK_PREFIXES, canonicalRulebookPath, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
68
106
  import { readHookInput } from './lib/hook-input.mjs';
69
107
 
70
108
  export { RULEBOOK_PREFIXES, isRulebookPath };
@@ -119,10 +157,45 @@ export const relativeTo = (root, filePath) => {
119
157
  export const isAllowed = (rel, allow) =>
120
158
  (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
121
159
 
122
- const protectedRelative = (roots, filePath) =>
123
- [...new Set([filePath, canonicalPath(filePath)])]
124
- .flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)))
125
- .find(isRulebookPath);
160
+ // RP-60: `rawFilePath` is the lexical spelling `apply_patch` fragments carry
161
+ // alongside the realpath-resolved `filePath` (`edit-input.mjs`,
162
+ // `repositoryPatchPath`) a guarded prefix that is itself a symlink/junction
163
+ // to somewhere else inside the checkout resolves away the rulebook spelling
164
+ // otherwise, the same way `canonicalRoot`/`comparisonRoots` above seed both
165
+ // spellings of the checkout root. Every other edit surface never sets it, so
166
+ // this is a no-op for them.
167
+ //
168
+ // RP-215 round 2: the return value is the CANONICAL rulebook spelling
169
+ // (`canonicalRulebookPath`), not the candidate that matched it. `isAllowed`
170
+ // and the `.claude/queue.board` carve-out below both compare this result
171
+ // against literal allow-list entries, so a miscased entry (`.Claude/`,
172
+ // `.claude/Scripts/`) never matches — only the path is canonicalised, never
173
+ // the allow-list. See the generator's `test/template/guard-rulebook.test.ts` (absent in a generated rig) ›
174
+ // "guard-rulebook: an allow-list entry is judged by its literal spelling,
175
+ // not the one the payload folds to (RP-215 round 2)".
176
+ const protectedRelative = (roots, filePath, rawFilePath) => {
177
+ const candidates = [...new Set([filePath, rawFilePath, canonicalPath(filePath)].filter((spelling) => typeof spelling === 'string' && spelling !== ''))]
178
+ .flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)));
179
+ for (const candidate of candidates) {
180
+ const canonical = canonicalRulebookPath(candidate);
181
+ if (canonical !== undefined) return canonical;
182
+ }
183
+ return undefined;
184
+ };
185
+
186
+ // RP-244 round 3: a `//`-prefixed normalised path is undecidable ONLY when it
187
+ // does not relativise under any comparison root — reusing `relativeTo`, the
188
+ // same comparison `protectedRelative` above already makes, rather than a
189
+ // second implementation. A UNC-spelled repository root (`CLAUDE_PROJECT_DIR`
190
+ // itself a `\\server\share\repo` spelling) maps to the same `//`-prefixed
191
+ // shape a payload path under it normalises to, so `relativeTo` CAN strip it —
192
+ // and a path that strips cleanly is judged normally, not refused as
193
+ // unjudgeable, whether or not it lands inside the rulebook.
194
+ const isUnjudgeablePath = (roots, filePath) => {
195
+ if (typeof filePath !== 'string' || !filePath.startsWith('//')) return false;
196
+ const file = toPosix(filePath);
197
+ return !roots.some((root) => relativeTo(root, filePath) !== file);
198
+ };
126
199
 
127
200
  function main() {
128
201
  const input = readHookInput();
@@ -139,8 +212,28 @@ function main() {
139
212
  );
140
213
  if (globalRefusal) {
141
214
  if (globalRefusal.filePath) {
215
+ // No `rawFilePath` argument here: the only `appliesToAll` refusal that
216
+ // carries a non-empty `filePath` at all is the MultiEdit fragment-cap
217
+ // refusal (`edit-input.mjs`), and it never sets `rawFilePath` — so
218
+ // there is nothing a third argument would add for this call site.
142
219
  const rel = protectedRelative(comparisonRoots, globalRefusal.filePath);
143
- if (rel === undefined) return 0;
220
+ if (rel === undefined) {
221
+ // RP-244 round 3: `rel === undefined` used to read as "outside the
222
+ // rulebook, never judged" unconditionally — exactly the reading the
223
+ // per-fragment block below already proved wrong for a `//`-prefixed
224
+ // path that resolves under no comparison root. A global refusal
225
+ // carrying such a path is refused the same way, instead of exiting 0
226
+ // before `isUnjudgeablePath` is ever consulted.
227
+ if (!isUnjudgeablePath(comparisonRoots, globalRefusal.filePath)) return 0;
228
+ const mode = readUnattended(unattendedEnv);
229
+ if (!mode.on) return 0;
230
+ process.stderr.write(
231
+ `BLOCKED — "${globalRefusal.filePath}" could not be resolved against the repository root: ` +
232
+ 'a UNC or device-namespace path is refused rather than judged while unattended. ' +
233
+ 'Write through the repository path instead.\n',
234
+ );
235
+ return 2;
236
+ }
144
237
  }
145
238
  const mode = readUnattended(unattendedEnv);
146
239
  if (!mode.on) return 0;
@@ -150,25 +243,60 @@ function main() {
150
243
  );
151
244
  return 2;
152
245
  }
246
+ // RP-244 round 2: `normalisePath` (`edit-input.mjs`) maps a UNC or other
247
+ // device-namespace spelling to a `//`-prefixed result — `relativeTo` can
248
+ // only strip a path that starts with the literal repository root, and a
249
+ // `//`-prefixed path never does, on any platform or root. That is not
250
+ // "outside the rulebook": it is undecidable, and undecidable is refused,
251
+ // not allowed, while armed (`.claude/rules/invariants.md`, "The remedy
252
+ // belongs to the refusal"). RP-244 round 3: "does not relativise under any
253
+ // comparison root" replaces the plain `startsWith('//')` check — a
254
+ // UNC-spelled repository root makes a payload path under it decidable, so
255
+ // it is judged normally rather than refused as unjudgeable.
256
+ const unjudgeable = fragments.find(({ filePath }) => isUnjudgeablePath(comparisonRoots, filePath));
257
+
153
258
  const paths = [];
154
- for (const { filePath } of fragments) {
259
+ for (const { filePath, rawFilePath } of fragments) {
155
260
  if (typeof filePath !== 'string' || filePath === '') continue;
156
- const rel = protectedRelative(comparisonRoots, filePath);
261
+ const rel = protectedRelative(comparisonRoots, filePath, rawFilePath);
157
262
  if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
158
263
  }
159
- if (paths.length === 0) return 0; // nothing under the rulebook: never judged
264
+ if (paths.length === 0 && !unjudgeable) return 0; // nothing under the rulebook: never judged
160
265
 
161
266
  const mode = readUnattended(unattendedEnv);
162
267
  if (!mode.on) return 0; // attended session
163
268
 
164
269
  if (mode.unreadable) {
270
+ // RP-244 round 3: `paths[0] ?? unjudgeable.filePath` used to print "is
271
+ // part of the rulebook" even when `paths` was empty and only an
272
+ // unjudgeable `//`-prefixed path put this branch on the table — a claim
273
+ // this guard never established. An unjudgeable path gets the same
274
+ // "could not be resolved" reason it gets everywhere else in this file.
275
+ const target = paths[0];
276
+ if (target === undefined) {
277
+ process.stderr.write(
278
+ `BLOCKED — "${unjudgeable.filePath}" could not be resolved against the repository root, ` +
279
+ `and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
280
+ 'Refusing to inspect is not allowing: fix it, or clear this checkout with `node .claude/scripts/unattended-flag.mjs off --root "$PWD"`, then retry.\n',
281
+ );
282
+ return 2;
283
+ }
165
284
  process.stderr.write(
166
- `BLOCKED — "${paths[0]}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
285
+ `BLOCKED — "${target}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
167
286
  'Refusing to inspect is not allowing: fix it, or clear this checkout with `node .claude/scripts/unattended-flag.mjs off --root "$PWD"`, then retry.\n',
168
287
  );
169
288
  return 2;
170
289
  }
171
290
 
291
+ if (unjudgeable) {
292
+ process.stderr.write(
293
+ `BLOCKED — "${unjudgeable.filePath}" could not be resolved against the repository root: ` +
294
+ 'a UNC or device-namespace path is refused rather than judged while unattended. ' +
295
+ 'Write through the repository path instead.\n',
296
+ );
297
+ return 2;
298
+ }
299
+
172
300
  const refused = paths.filter(
173
301
  (rel) => rel === '.claude/queue.board' || !isAllowed(rel, mode.allow),
174
302
  );
@@ -99,7 +99,11 @@ function main() {
99
99
  const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
100
100
  if (!editTools.has(input?.tool_name)) return 0;
101
101
 
102
- const fragments = editFragments(input);
102
+ // RP-214: `editFragments` also reports a removal (a Delete File section, or
103
+ // the source half of a Move) as its own fragment, `removes: true` — deleting
104
+ // a credential file is not writing one, so this guard never judges those,
105
+ // including an inspection refusal attached to one.
106
+ const fragments = editFragments(input).filter(({ removes }) => !removes);
103
107
  const globalRefusal = fragments.find(
104
108
  ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
105
109
  );