create-agent-rig 0.8.0 → 0.9.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 (52) hide show
  1. package/CHANGELOG.md +105 -1
  2. package/README.md +92 -3
  3. package/package.json +4 -3
  4. package/packages/cli/dist/commands/memory.js +123 -0
  5. package/packages/cli/dist/commands/setup.js +45 -0
  6. package/packages/cli/dist/index.js +107 -3
  7. package/packages/cli/dist/lib/subsystems.js +269 -0
  8. package/packages/cli/dist/lib/version.js +15 -0
  9. package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
  10. package/packages/cli/dist/policy/core/coverage.js +253 -0
  11. package/packages/cli/dist/policy/core/decision-record.js +130 -44
  12. package/packages/cli/dist/policy/core/declaration.js +58 -17
  13. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  14. package/packages/cli/dist/policy/core/probe.js +442 -0
  15. package/packages/cli/dist/policy/core/validation.js +194 -1
  16. package/packages/cli/dist/policy/core/vocabulary.js +70 -3
  17. package/packages/cli/dist/policy/harness/claude.js +9 -1
  18. package/packages/cli/dist/policy/harness/codex.js +48 -1
  19. package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
  20. package/packages/cli/dist/policy/index.js +9 -2
  21. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  22. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  23. package/templates/agent-os/subagent-routing.json +32 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
  25. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  26. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  27. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  28. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  29. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  30. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  31. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  32. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  33. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  34. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  35. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  36. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  37. package/templates/agent-os/universal/.claude/settings.json +16 -0
  38. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
  39. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  40. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  41. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  42. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  43. package/templates/agent-os/universal/.codex/config.toml +3 -0
  44. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  45. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  46. package/templates/agent-os/universal/layers.json +4 -0
  47. package/templates/hash-history.json +8 -4
  48. package/templates/release-ledger.json +2 -1
  49. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  50. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  51. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  52. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
@@ -9,7 +9,54 @@
9
9
  * and not the word leaves the caller guessing which of two spellings it sent.
10
10
  */
11
11
  export const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
12
- const quote = (value) => {
12
+ /**
13
+ * Whether an outside record CARRIES a field — own and enumerable, which is
14
+ * exactly the set `Object.keys` walks, and which `JSON.stringify` writes out
15
+ * whenever the value is serialisable.
16
+ *
17
+ * ⚠ The second half is a "whenever", not an equivalence, and the earlier
18
+ * wording claimed the equivalence. `{ downgradeReason: undefined }` is own and
19
+ * enumerable, so this returns `true`, while `JSON.stringify` drops it — which
20
+ * is why a `SUPPORTED` row written that way is refused for carrying a reason
21
+ * even though its serialisation carries none. The refusal is the conservative
22
+ * direction, but a reader who took "the rule is what `JSON.stringify` sees"
23
+ * literally would predict acceptance. `Object.keys` is the set this actually
24
+ * implements; that is the half to reason from.
25
+ *
26
+ * 🔴 The two halves are one rule, and each was a real false pass. A field found
27
+ * on the PROTOTYPE let a hook serialising as `{}` be read as running the
28
+ * generated command, and let an evidence row own nothing and still validate; a
29
+ * field that is own but NOT ENUMERABLE let a row validate and then serialise
30
+ * without the pointer that made it pass. Both are the same defect stated twice:
31
+ * something was accepted as evidence that no serialisation of the value
32
+ * carries. `unknownKeys` already judges a record by `Object.keys`, so reading
33
+ * by any wider notion made the closed-shape check and the field reads disagree
34
+ * about what the record even contains — and the reads were the wider of the
35
+ * two.
36
+ *
37
+ * Held over both readers at once: `packages/cli/test/policy-coverage.test.ts`
38
+ * (absent in a generated rig) › "refuses a hook entry whose command is only
39
+ * inherited, because the entry itself carries no command" and › "refuses a row
40
+ * whose evidence pointer is own but not enumerable, because the rule is what
41
+ * JSON.stringify sees".
42
+ */
43
+ export const carriesField = (input, field) => Object.prototype.propertyIsEnumerable.call(input, field);
44
+ /**
45
+ * Read one field the way the record's own serialisation would carry it, or
46
+ * `undefined` when the record does not carry it at all.
47
+ *
48
+ * Presence and value travel through the same predicate on purpose: a caller
49
+ * that tested presence one way and read the value another is how the two
50
+ * came apart the first time.
51
+ */
52
+ export const ownField = (input, field) => carriesField(input, field) ? input[field] : undefined;
53
+ /**
54
+ * A value as it appeared, escaped, for a message a person reads. Exported
55
+ * because every module here that puts OUTSIDE data into a diagnostic must put
56
+ * it through the same escaping — a raw newline or ANSI sequence in a matcher
57
+ * can otherwise forge a line of the report it lands in (`./probe.ts`).
58
+ */
59
+ export const quote = (value) => {
13
60
  try {
14
61
  return JSON.stringify(value) ?? String(value);
15
62
  }
@@ -38,6 +85,67 @@ export const nonEmptyString = (problems, field, value) => {
38
85
  }
39
86
  return true;
40
87
  };
88
+ /**
89
+ * Refuse a string that is absent, not a string, or has no non-space character.
90
+ *
91
+ * Stricter than `nonEmptyString` in exactly one place — a value of whitespace
92
+ * only — and a separate helper rather than a tightening of that one, because
93
+ * the shapes already validated by it are not in this change's scope. Where a
94
+ * field is a fact a later reader has to act on (an exact version, a pointer to
95
+ * evidence), a blank is the same defect as an absence and is refused as one.
96
+ */
97
+ export const nonBlankString = (problems, field, value) => {
98
+ if (typeof value !== 'string' || value.trim() === '') {
99
+ problems.push({ field, message: `must be a non-blank string, got ${quote(value)}` });
100
+ return false;
101
+ }
102
+ return true;
103
+ };
104
+ /**
105
+ * A real calendar date, `T`, time to the second (fractions allowed), and an
106
+ * explicit zone.
107
+ *
108
+ * One spelling of one fact (`rules/invariants.md`, "One mechanism, one
109
+ * implementation"). Its three readers are `./decision-record.ts`
110
+ * (`recordedAt`), `./evidence-matrix.ts` (`observedAt`) and `./coverage.ts`
111
+ * (`verifiedAt`, through `requireTimestamp`), so a bare date is refused the
112
+ * same way whichever of them is validating — including lexically shaped but
113
+ * impossible dates — `packages/cli/test/policy-coverage.test.ts`
114
+ * › "refuses the probe timestamp %j, which is exactly what the shared ISO-8601
115
+ * pattern refuses" imports this pattern rather than restating it, so the two
116
+ * sides cannot drift apart. A timestamp is always supplied by the caller —
117
+ * nothing under this directory reads a clock.
118
+ */
119
+ const ISO_8601_SHAPE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/;
120
+ /**
121
+ * A date-time shape whose `test` also proves the named calendar instant exists.
122
+ * Keeping the semantic check behind the same exported predicate prevents the
123
+ * coverage, decision-record and evidence-row validators from drifting apart.
124
+ */
125
+ export const ISO_8601 = {
126
+ test(value) {
127
+ const match = ISO_8601_SHAPE.exec(value);
128
+ if (match === null)
129
+ return false;
130
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, zoneHour, zoneMinute] = match;
131
+ const year = Number(yearText);
132
+ const month = Number(monthText);
133
+ const day = Number(dayText);
134
+ const hour = Number(hourText);
135
+ const minute = Number(minuteText);
136
+ const second = Number(secondText);
137
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
138
+ const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
139
+ return (month >= 1 &&
140
+ month <= 12 &&
141
+ day >= 1 &&
142
+ day <= daysInMonth[month - 1] &&
143
+ hour <= 23 &&
144
+ minute <= 59 &&
145
+ second <= 59 &&
146
+ (zoneHour === undefined || (Number(zoneHour) <= 23 && Number(zoneMinute) <= 59)));
147
+ },
148
+ };
41
149
  /** Refuse a string outside a closed vocabulary, quoting the offending value. */
42
150
  export const member = (problems, field, value, vocabulary) => {
43
151
  if (typeof value === 'string' && vocabulary.includes(value))
@@ -80,3 +188,88 @@ export const matching = (problems, field, value, pattern, expected) => {
80
188
  problems.push({ field, message: `must be ${expected}, got ${quote(value)}` });
81
189
  return false;
82
190
  };
191
+ /**
192
+ * A version that names one build rather than a set of them.
193
+ *
194
+ * One spelling of one fact: `./evidence-matrix.ts` refuses a matrix row on it
195
+ * and `./coverage.ts` refuses a surface identity on it, so "the exact version
196
+ * observed" means the same thing wherever it is written.
197
+ *
198
+ * 🔴 This is an ALLOWLIST, and it replaced a denylist that could not be
199
+ * finished. The denylist refused five vague words, the range operator
200
+ * characters, a wildcard component and two npm range spellings — and accepted
201
+ * `main`, `master`, `stable`, `next`, `nightly`, `dev`, `edge`, `canary` and
202
+ * `1.2.3 or 2.0.0`, because none of them is any of those things. A moving label
203
+ * is not a shape you can enumerate: every branch name a harness ever publishes
204
+ * from is a new entry, added by whoever notices, which is nobody. So the check
205
+ * asks what a build identifier LOOKS LIKE instead of what a moving target is
206
+ * called, and a word the grammar does not describe is refused whether or not
207
+ * anyone anticipated it.
208
+ *
209
+ * Two shapes are accepted, and they are the two this rig actually reads:
210
+ *
211
+ * - a build NUMBER — a dotted numeric version, optionally `v`-prefixed, with an
212
+ * optional pre-release or build-metadata suffix after `-` or `+`. That covers
213
+ * `2.0.14`, `v2.0.14`, `1.104.2`, a date build id like `2026-09-05`, a plain
214
+ * build id like `20260904.3`, and a suffix carrying any letter at all,
215
+ * including `1.0.0-X` and `0.0.0-fixture`;
216
+ * - a build ID — 7 to 64 hex characters, which is a git object id at every
217
+ * length git itself abbreviates to.
218
+ *
219
+ * The distinction that costs the most to get wrong is the one between a bare
220
+ * channel word and a suffix: `beta` names whatever is on that channel today and
221
+ * is refused, while `1.0.0-beta.2` names one build and is accepted. The grammar
222
+ * draws that line by requiring the number first — a suffix cannot stand alone.
223
+ *
224
+ * The value is matched AS GIVEN, with no trim. An earlier version validated
225
+ * `value.trim()` while both callers stored the value verbatim, so `" 2.0.14 "`,
226
+ * `"2.0.14\r\n"` and a BOM-prefixed form were accepted and then persisted with
227
+ * their padding: two rows for one build that compare unequal, and a version
228
+ * carrying a newline sitting in a field a report will one day render. What is
229
+ * checked and what is stored are now the same string: ›
230
+ * "refuses an evidence row whose harness version carries %s, because the row
231
+ * would store what it was not validated on" and › "refuses to probe against a
232
+ * harness version carrying %s, so two maps of one build cannot compare
233
+ * unequal", with › "still accepts the same build once %s is gone, because it is
234
+ * the padding that is refused and not the version" holding the other side.
235
+ *
236
+ * Refused, and now by construction rather than by enumeration: the vague words,
237
+ * every moving branch label, range OPERATORS, wildcard components, both npm
238
+ * range spellings, and any text carrying whitespace or a comma — which is what
239
+ * `1.2.3 or 2.0.0` and `1.2.3, 2.0.0` are. Both readers are pinned in
240
+ * `packages/cli/test/policy-coverage.test.ts` (absent in a generated rig) ›
241
+ * "refuses the harness version %j, because it names a moving label or more than
242
+ * one build" and › "refuses to probe against the harness version %j, because it
243
+ * names a moving label or more than one build", with the other direction held
244
+ * so the grammar cannot swallow a real build id: › "still accepts the harness
245
+ * version %j, because it names one build" and › "still probes against the
246
+ * harness version %j, because it names one build".
247
+ */
248
+ const BUILD_NUMBER = /^v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z][0-9A-Za-z.+-]*)?$/;
249
+ const BUILD_ID = /^[0-9a-fA-F]{7,64}$/;
250
+ /**
251
+ * What a refusal says is expected — one spelling, read by this module and by
252
+ * `./coverage.ts`, so the two cannot come to describe different grammars.
253
+ */
254
+ export const EXACT_VERSION_EXPECTED = 'must name one immutable build: a version number like 2.0.14, ' +
255
+ 'optionally v-prefixed and optionally carrying a -pre-release or +build suffix, ' +
256
+ 'or a 7-to-64-character hex build id';
257
+ export const isExactVersion = (value) => BUILD_NUMBER.test(value) || BUILD_ID.test(value);
258
+ /**
259
+ * Refuse a version the grammar does not describe, quoting the value and naming
260
+ * the two shapes that are accepted.
261
+ *
262
+ * The message says what would be accepted rather than what was wrong, because
263
+ * the check is an allowlist: it also refuses `1.0.0.beta` and `2026_09_05`,
264
+ * which are neither a range nor a moving target, and the earlier message told
265
+ * their author they had written one. `./probe.ts` states the principle this
266
+ * trips over — a refusal naming a cause that did not occur sends an operator
267
+ * looking for something that is not there.
268
+ */
269
+ export const exactVersion = (problems, field, value) => {
270
+ if (typeof value !== 'string' || value.trim() === '')
271
+ return;
272
+ if (!isExactVersion(value)) {
273
+ problems.push({ field, message: `${EXACT_VERSION_EXPECTED}; got ${quote(value)}` });
274
+ }
275
+ };
@@ -16,9 +16,13 @@
16
16
  const closed = (values) => Object.freeze(values);
17
17
  /**
18
18
  * Whether a declared policy can actually be enforced on a given harness
19
- * surface. `UNSUPPORTED` and `INTEGRATION-FAILED` never yield a silent pass:
20
- * a decision record carrying either must qualify its verdict `UNVERIFIABLE`
21
- * (`./decision-record.ts`). The four states are defined here.
19
+ * surface. `UNSUPPORTED` and `INTEGRATION-FAILED` must never yield a silent
20
+ * pass: a decision record carrying either has to qualify its verdict
21
+ * `UNVERIFIABLE` (`./decision-record.ts`). The four states are defined here.
22
+ *
23
+ * ⚠ That is the rule, and the enforcement behind it is narrower than the rule
24
+ * — see `UNENFORCEABLE_STATES` below, which states the gap once for every
25
+ * reader of this file.
22
26
  */
23
27
  export const CAPABILITY_STATES = closed([
24
28
  'SUPPORTED',
@@ -26,6 +30,69 @@ export const CAPABILITY_STATES = closed([
26
30
  'UNSUPPORTED',
27
31
  'INTEGRATION-FAILED',
28
32
  ]);
33
+ /**
34
+ * The states under which no question was actually put to a working mechanism.
35
+ *
36
+ * One spelling of one fact (`rules/invariants.md`, "One mechanism, one
37
+ * implementation"): `./decision-record.ts` refuses an unqualified verdict
38
+ * carrying one of these, and `./coverage.ts` › `qualifierFor` returns
39
+ * `UNVERIFIABLE` for exactly the same set. Two copies would disagree, and the
40
+ * one nobody is looking at would be the one that let a silent pass through.
41
+ *
42
+ * ⚠ **"Refuses" now holds against a hand-built prototype too** — this note used
43
+ * to say the opposite, and this is the file a reader auditing that question
44
+ * lands on first, so it is corrected here rather than only where the fix landed.
45
+ * `./decision-record.ts` used to decide whether a verdict carries a qualifier
46
+ * with the `in` operator, so a verdict INHERITING one satisfied the check and
47
+ * then serialised without it. RP-153 closed that: every field of both
48
+ * `./decision-record.ts` and `./declaration.ts` is read through
49
+ * `carriesField`/`ownField`, the way `./probe.ts` and `./evidence-matrix.ts`
50
+ * already read theirs, and both uncarried shapes are pinned in
51
+ * `packages/cli/test/policy-declaration.test.ts` › "refuses an UNSUPPORTED
52
+ * record whose verdict qualifier is %s, because what it writes out is a silent
53
+ * pass" — `%s` as the `it.each` case declares it, so one grep lands on it.
54
+ *
55
+ * What remains is narrower and is NOT this sentence's subject: a value carrying
56
+ * a live accessor is validated on one read and serialised from another (RP-157),
57
+ * and an array HOLE serialises as `null` while the two `forEach` loops in
58
+ * `./decision-record.ts` skip it (RP-161) — those two only, since `members` in
59
+ * `./validation.ts` and `./probe.ts` iterate with `for…of`, which sees a hole as
60
+ * `undefined` and refuses it. `docs/decisions/capability-coverage.md`, "What
61
+ * this does NOT do", carries the current limits at length.
62
+ *
63
+ * It is deliberately NOT derived from a rank or an ordering. `coverage.ts`
64
+ * carries an enforcement ordering for deciding what counts as a downgrade;
65
+ * keying verdict qualification off that would mean a future re-rank silently
66
+ * changed which verdicts are unverifiable.
67
+ */
68
+ export const UNENFORCEABLE_STATES = closed(['UNSUPPORTED', 'INTEGRATION-FAILED']);
69
+ /**
70
+ * The two ways a capability status is established, and the only two.
71
+ *
72
+ * `probe` is one active read of the surface's own wiring, taken when the
73
+ * surface changes (`PROBE_TRIGGERS`). `traffic` is passive: an operation that
74
+ * was expected to produce an observable signal, and what it actually produced.
75
+ * There is deliberately no third source meaning "time passed" — silence is
76
+ * absence of evidence, not evidence of absence.
77
+ */
78
+ export const VERIFICATION_SOURCES = closed(['probe', 'traffic']);
79
+ /**
80
+ * The occasions on which a surface is probed. Every member is an event on the
81
+ * surface; none of them is an interval.
82
+ *
83
+ * What this vocabulary does, exactly: `./coverage.ts` › `coverageFromProbe`
84
+ * takes a trigger as a required argument and refuses a word outside this list,
85
+ * so a probe must NAME its occasion and cannot name a schedule — ›
86
+ * "refuses the trigger %j, because the coverage contract accepts only a
87
+ * declared surface-change trigger".
88
+ *
89
+ * ⚠ What it does NOT do, stated because an earlier draft of this comment
90
+ * claimed it: nothing here stops a caller passing `'upgrade'` on a timer. The
91
+ * check refuses a LABEL outside the vocabulary, not the practice of probing
92
+ * periodically. `docs/decisions/capability-coverage.md` §1 says the same, and
93
+ * this file used to contradict it.
94
+ */
95
+ export const PROBE_TRIGGERS = closed(['install', 'upgrade', 'registration', 'reconnect']);
29
96
  /** The autonomy tiers of `rules/autonomy.md`; `never` is the tier the guards enforce. */
30
97
  export const AUTONOMY_TIERS = closed(['tier-0', 'tier-1', 'tier-2', 'never']);
31
98
  /** The operations a policy can apply to, named by what the agent does, not by a tool. */
@@ -19,7 +19,7 @@
19
19
  * shipped scripts, and `test/template/shell-tools.test.ts` holds that
20
20
  * correspondence.
21
21
  */
22
- import { SHARED_HOOKS_DIR } from './shared-hooks.js';
22
+ import { SHARED_HOOK_ROOT_ENV, SHARED_HOOKS_DIR } from './shared-hooks.js';
23
23
  const EVENT_OF = {
24
24
  'before-operation': 'PreToolUse',
25
25
  };
@@ -31,6 +31,14 @@ export const nativeSurfaceOf = (policy) => ({
31
31
  event: EVENT_OF[policy.timing],
32
32
  matcher: policy.operations.map((operation) => MATCHER_OF[operation]).join('|'),
33
33
  hookPath: `${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`,
34
+ // The exact command this harness generates for a hook. The probe compares
35
+ // against this rather than parsing what it finds, so this string and the one
36
+ // in the shipped snapshot must agree — pinned in both directions by
37
+ // `test/template/policy-coverage.test.ts` (absent in a generated rig) ›
38
+ // "the %s snapshot wires %s with exactly the command that adapter generates".
39
+ commands: {
40
+ command: [`node "$${SHARED_HOOK_ROOT_ENV}/${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs"`],
41
+ },
34
42
  });
35
43
  export const claudeAdapter = Object.freeze({
36
44
  harness: 'claude',
@@ -20,7 +20,7 @@
20
20
  * restated, for the same reason the snapshot is derived rather than
21
21
  * hand-written: one spelling of one fact.
22
22
  */
23
- import { SHARED_HOOKS_DIR } from './shared-hooks.js';
23
+ import { SHARED_HOOK_ROOT_ENV, SHARED_HOOKS_DIR } from './shared-hooks.js';
24
24
  const EVENT_OF = {
25
25
  'before-operation': 'PreToolUse',
26
26
  };
@@ -28,10 +28,57 @@ const MATCHER_OF = {
28
28
  'file-edit': 'Write|Edit|MultiEdit|NotebookEdit|apply_patch',
29
29
  'shell-command': 'Bash|PowerShell',
30
30
  };
31
+ /**
32
+ * The Windows spelling this harness generates for a hook.
33
+ *
34
+ * ⚠ A SECOND implementation of the wrapper that `scripts/sync-codex-adapter.mjs`
35
+ * writes into the shipped file, and deliberately so: that script is a build tool
36
+ * outside the published package, and this module may not import it. The two are
37
+ * held equal by a correspondence check that goes red in BOTH directions —
38
+ * `test/template/policy-coverage.test.ts` (absent in a generated rig) › "the %s
39
+ * snapshot wires %s with exactly the spelling that adapter generates, in every
40
+ * field it generates one for" — which is what `rules/invariants.md` ("One
41
+ * mechanism, one implementation") requires of a copy that has to stay.
42
+ *
43
+ * The wrapper exists because PowerShell owns its stdin, so the hook would
44
+ * receive an empty stream; it copies the original bytes into the child instead
45
+ * of re-encoding them.
46
+ */
47
+ const windowsCommand = (hookPath) => {
48
+ const script = [
49
+ "$ErrorActionPreference = 'Stop'",
50
+ '$repoRoot = git rev-parse --show-toplevel',
51
+ 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }',
52
+ `$env:${SHARED_HOOK_ROOT_ENV} = $repoRoot`,
53
+ `$hookPath = Join-Path $repoRoot '${hookPath}'`,
54
+ '$startInfo = New-Object System.Diagnostics.ProcessStartInfo',
55
+ "$startInfo.FileName = 'node'",
56
+ "$startInfo.Arguments = '\"' + $hookPath + '\"'",
57
+ '$startInfo.UseShellExecute = $false',
58
+ '$startInfo.RedirectStandardInput = $true',
59
+ '$child = [System.Diagnostics.Process]::Start($startInfo)',
60
+ '[Console]::OpenStandardInput().CopyTo($child.StandardInput.BaseStream)',
61
+ '$child.StandardInput.Close()',
62
+ '$child.WaitForExit()',
63
+ 'exit $child.ExitCode',
64
+ ].join('; ');
65
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
66
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
67
+ };
31
68
  export const nativeSurfaceOf = (policy) => ({
32
69
  event: EVENT_OF[policy.timing],
33
70
  matcher: policy.operations.map((operation) => MATCHER_OF[operation]).join('|'),
34
71
  hookPath: `${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`,
72
+ // Both commands this harness generates. It runs the first on POSIX and the
73
+ // second on Windows, so the probe has to know both: replacing only the
74
+ // Windows spelling in the shipped file used to leave every policy reading
75
+ // SUPPORTED while the guard no longer ran there.
76
+ commands: {
77
+ command: [
78
+ `repoRoot="$(git rev-parse --show-toplevel)" && ${SHARED_HOOK_ROOT_ENV}="$repoRoot" node "$repoRoot/${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs"`,
79
+ ],
80
+ commandWindows: [windowsCommand(`${SHARED_HOOKS_DIR}/${policy.mechanism}.mjs`)],
81
+ },
35
82
  });
36
83
  export const codexAdapter = Object.freeze({
37
84
  harness: 'codex',
@@ -8,3 +8,21 @@
8
8
  * implementation").
9
9
  */
10
10
  export const SHARED_HOOKS_DIR = '.claude/hooks';
11
+ /**
12
+ * The environment variable a shared hook reads when it needs the repository
13
+ * root, and therefore the one each harness sets when it runs one.
14
+ *
15
+ * Not every hook needs it: of the eight this rig ships, `guard-rulebook` and
16
+ * `guard-secret-file` read it, both falling back to the working directory.
17
+ * The variable is still part of the wiring contract, because the harness sets
18
+ * it for whichever hook it runs.
19
+ *
20
+ * It carries a harness's name for the same historical reason `.claude/hooks`
21
+ * does — the hooks are shared, so both harnesses speak this one variable — and
22
+ * this module is the one adapter-side file allowed to spell that name for both
23
+ * (`test/template/policy-declaration.test.ts` › "claude is named only by its
24
+ * own adapter, the shared hooks directory and the adapter index"). Stating it
25
+ * here is what lets the other harness's adapter build its generated command
26
+ * without naming a harness that is not its own.
27
+ */
28
+ export const SHARED_HOOK_ROOT_ENV = 'CLAUDE_PROJECT_DIR';
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * The policy declaration, registry, decision-record schema and harness
3
- * adapters (RP-76). Library surface only nothing here is reached by the CLI
4
- * commands yet; emitting decision records at runtime is a separate task.
3
+ * adapters (RP-76), plus the capability & degradation contract that says what
4
+ * each of them is worth on a given surface (RP-36).
5
+ *
6
+ * Library surface only — nothing here is reached by the CLI commands yet;
7
+ * emitting decision records at runtime, and rendering the coverage report in
8
+ * `doctor`, are separate tasks.
5
9
  */
6
10
  export * from './core/vocabulary.js';
7
11
  export * from './core/declaration.js';
8
12
  export * from './core/registry.js';
9
13
  export * from './core/decision-record.js';
14
+ export * from './core/probe.js';
15
+ export * from './core/coverage.js';
16
+ export * from './core/evidence-matrix.js';
10
17
  export * from './harness/index.js';
@@ -2,6 +2,8 @@
2
2
  name: cdk-diff-reviewer
3
3
  description: Reviews an infrastructure change via `cdk diff` BEFORE any deploy. MUST run on every change under infra/ — a deploy without this review is a Never-tier action. Read-only; findings gate the deploy.
4
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You review what a deploy would actually do to running infrastructure. Your
@@ -1,4 +1,6 @@
1
1
  name = "cdk-diff-reviewer"
2
2
  description = "Reviews an infrastructure change via `cdk diff` BEFORE any deploy. MUST run on every change under infra/ — a deploy without this review is a Never-tier action. Read-only; findings gate the deploy."
3
+ model = "gpt-5.6-sol"
4
+ model_reasoning_effort = "high"
3
5
  sandbox_mode = "read-only"
4
6
  developer_instructions = "You review what a deploy would actually do to running infrastructure. Your\ninput is the change under `infra/` **and** the synthesized diff (`cdk diff`,\nrun it yourself); your output is a verdict. You never fix and never deploy.\n\n## How you work\n\n1. Run `cdk diff` (and read the changed `infra/` sources for intent). The diff\n is the truth: review what CloudFormation will do, not what the TypeScript\n looks like it does.\n2. Walk every resource change and flag it **by named rule** (below). Findings\n come as **BLOCKERS first, then nits** — one list each, with the resource\n and the rule it violates.\n3. Your message IS the review, not a summary of it: every finding carries the\n resource, the change, the rule, and the smallest fix. End with the verdict\n block below — `SHIP` where `DEPLOY: OK` used to be, `HOLD` where\n `DEPLOY: BLOCKED` did.\n\n## Named rules — blockers\n\n- **IAM broadening.** Any policy gaining actions, resources widening to `*`,\n or a grant that outruns what a usecase does today. Least privilege is added\n in the same PR as the need, never \"for later\".\n- **Data loss paths.** A stateful resource (table, bucket, queue) being\n replaced, deleted, or flipping its RemovalPolicy toward DESTROY.\n Logical-id renames on stateful resources are replacements in disguise.\n- **Safety-net removal.** A DLQ detached, an alarm deleted or loosened, a\n retry budget widened to infinity, a dead-letter retention shortened.\n- **Blast-radius growth.** New public surface (endpoints, permissions to\n external principals), broadened network access, cross-stack exports that\n make future changes harder to reverse.\n- **Cost-relevant flips.** On-demand → provisioned capacity, log retention to\n \"forever\", memory/timeout jumps with no stated reason.\n\n## Nits (report, do not block)\n\nNaming drift, missing descriptions, constructs that could use the narrower\ngrant helper, duplication between stacks.\n\n## Boundaries\n\n- Read-only: you run `cdk diff` and read code; you never run `cdk deploy`,\n never edit files, never mutate AWS state.\n- An empty diff is a real finding too — say \"no infrastructure change\" and\n return `SHIP`, so the gate leaves a trace either way.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. It is what the calling gate reads.\n\n```json\n{\n \"gate\": \"cdk-diff-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"infra/lib/api-stack.ts\",\n \"line\": 88,\n \"rule\": \"data loss\",\n \"note\": \"the table's RemovalPolicy went to DESTROY — replacement drops it\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"cdk diff against the deployed stage\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP` (nothing blocking, including an empty diff), `HOLD`, or\n `NOT_APPLICABLE` when the change touches no infrastructure at all.\n- Every blocker names the `rule` it violates, with `file` and `line` when it has\n a location and neither when it does not.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> cdk-diff-reviewer` is what\n refuses them.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."
@@ -0,0 +1,32 @@
1
+ {
2
+ "claudeModels": {
3
+ "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
4
+ "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"]
5
+ },
6
+ "unnamed": {
7
+ "claude": { "model": "claude-sonnet-5" },
8
+ "codex": { "model": "gpt-5.6-terra", "effort": "medium" }
9
+ },
10
+ "roles": {
11
+ "test-writer": {
12
+ "claude": { "model": "claude-sonnet-5", "effort": "high" },
13
+ "codex": { "model": "gpt-5.6-terra", "effort": "high" }
14
+ },
15
+ "prose-reviewer": {
16
+ "claude": { "model": "claude-sonnet-5", "effort": "high" },
17
+ "codex": { "model": "gpt-5.6-terra", "effort": "high" }
18
+ },
19
+ "code-reviewer": {
20
+ "claude": { "model": "claude-opus-5", "effort": "high" },
21
+ "codex": { "model": "gpt-5.6-sol", "effort": "high" }
22
+ },
23
+ "security-scanner": {
24
+ "claude": { "model": "claude-opus-5", "effort": "high" },
25
+ "codex": { "model": "gpt-5.6-sol", "effort": "high" }
26
+ },
27
+ "cdk-diff-reviewer": {
28
+ "claude": { "model": "claude-opus-5", "effort": "high" },
29
+ "codex": { "model": "gpt-5.6-sol", "effort": "high" }
30
+ }
31
+ }
32
+ }
@@ -79,16 +79,23 @@ state-vs-queue split exists to prevent.
79
79
  node .claude/scripts/preflight.mjs
80
80
  ```
81
81
 
82
- Five items are scripted (kill switch absent · `RIG_RUN_DIR` not already
83
- exported · the versioned revalidation detection contract is supported · local
84
- default branch matches the remote · the last deploy concluded successfully)
82
+ Scripted checks cover the kill switch, inherited `RIG_RUN_DIR`, the versioned
83
+ revalidation detection contract, queue readability through its configured adapter,
84
+ default-branch freshness, and the last deploy result,
85
85
  and the script **prints the ones it did not check, every time**. Paste the block into the journal: a checklist that
86
86
  leaves no record cannot tell you it was skipped.
87
87
 
88
88
  Verdicts: **STOP** → do not start, deal with the cause. **CAUTION** → start,
89
- knowing which ground is soft. **GO** → the scripted five are clean; the rest are
89
+ knowing which ground is soft. **GO** → the scripted checks are clean; the rest are
90
90
  still yours.
91
91
 
92
+ The queue probe reads one adapter listing without selecting or claiming an item.
93
+ A readable empty queue passes this probe; a configuration, adapter, or queue-read
94
+ failure produces **STOP**. See the generator's `test/template/preflight-queue.test.ts`
95
+ (absent in a generated rig) › "reads exactly one adapter listing without selecting, claiming, or writing queue and run files",
96
+ › "passes a readable empty queue without changing the other preflight verdict or queue state",
97
+ and › "stops when %s cannot be read".
98
+
92
99
  **An `unknown` never becomes a `pass`.** A probe that could not run tells you
93
100
  nothing.
94
101
 
@@ -149,8 +156,38 @@ What a hook CAN see is a file, so the unattended signal is one:
149
156
  # at claim time, from the paths the item names (repo-relative prefixes, with
150
157
  # their trailing slash); the guard refuses every other rulebook edit while it is on
151
158
  node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
159
+
160
+ # 🔴 THEN READ IT BACK, and stop the run if it is not armed. Not optional.
161
+ node .claude/scripts/unattended-flag.mjs verify --root "$PWD" --item <item-id>
152
162
  ```
153
163
 
164
+ 🔴 **The second command is the one that makes the first one's failure
165
+ visible, and skipping it inverts the whole mechanism.** `on` refuses an allow
166
+ entry that *widens* the rulebook — and the refusal leaves **no flag on disk**
167
+ (pinned in the generator's `test/template/unattended-flag.test.ts`, absent in a
168
+ generated rig, › "does not change `on`: a widening --allow still exits 1 and
169
+ still writes no flag"), while `guard-rulebook` reads an absent flag as an
170
+ attended session and refuses nothing. So a run that armed with a widening entry and did not check is the
171
+ **least** constrained run this project can produce: every rule, hook, skill and
172
+ settings path editable, with nothing downstream saying so. The failure is loud
173
+ for one line at claim time and silent for the rest of the session.
174
+
175
+ `verify` exits non-zero when no usable flag is armed for this item — absent,
176
+ unreadable, or naming a different item — and its message says the run is
177
+ unguarded rather than merely that a file is missing. **A non-zero exit here ends
178
+ the run**; it does not get retried with a wider allow-list. The natural way to
179
+ hit this is not exotic: an item touching the queue adapter invites `--allow
180
+ .claude/scripts/`, and that entry is refused outright. Pinned in the
181
+ generator's `test/template/unattended-flag.test.ts` (absent in a generated rig)
182
+ › "refuses when no flag is armed, naming the item and the unguarded rulebook"
183
+ and › "refuses when the armed flag names a different item, naming both".
184
+
185
+ ⚠ **What this does not close.** `verify` is mechanical where it runs; that it
186
+ runs is this sentence. It removes the silence, not the possibility that a run
187
+ ignores an exit status — and the hook-enforced version is not available, because
188
+ `guard-rulebook` cannot tell "attended" from "unattended but unarmed": absence of
189
+ a flag is all it sees.
190
+
154
191
  `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
155
192
  on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
156
193
  rulebook is refused unless its
@@ -2,6 +2,8 @@
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 the decision-router puts on its `model` lane, which is everything its two cheap lanes did not claim — code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated. Blocking findings must be resolved, not argued with.
4
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-opus-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You review changes. You do not fix them — you report, with file:line
@@ -2,6 +2,8 @@
2
2
  name: prose-reviewer
3
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
4
  tools: Read, Grep, Glob, Bash
5
+ model: claude-sonnet-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  In this project the prose **is** the implementation. A rule file is what an agent
@@ -2,6 +2,8 @@
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
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You are the security gate. You run on changes in sensitive territory and your
@@ -2,6 +2,8 @@
2
2
  name: test-writer
3
3
  description: Writes the failing test BEFORE any implementation exists. Use at the start of every feature, bug fix, or behavior change — the Red step of TDD. Also use to reproduce a reported bug as a test.
4
4
  tools: Read, Grep, Glob, Write, Edit, Bash
5
+ model: claude-sonnet-5
6
+ effort: high
5
7
  ---
6
8
 
7
9
  You write tests that define behavior which does not exist yet. You are the Red