create-agent-rig 0.7.1 → 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 (56) hide show
  1. package/CHANGELOG.md +184 -2
  2. package/README.md +93 -4
  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/adapter.js +18 -0
  11. package/packages/cli/dist/policy/core/coverage.js +253 -0
  12. package/packages/cli/dist/policy/core/decision-record.js +287 -0
  13. package/packages/cli/dist/policy/core/declaration.js +127 -0
  14. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  15. package/packages/cli/dist/policy/core/probe.js +442 -0
  16. package/packages/cli/dist/policy/core/registry.js +115 -0
  17. package/packages/cli/dist/policy/core/validation.js +275 -0
  18. package/packages/cli/dist/policy/core/vocabulary.js +123 -0
  19. package/packages/cli/dist/policy/harness/claude.js +47 -0
  20. package/packages/cli/dist/policy/harness/codex.js +87 -0
  21. package/packages/cli/dist/policy/harness/index.js +15 -0
  22. package/packages/cli/dist/policy/harness/shared-hooks.js +28 -0
  23. package/packages/cli/dist/policy/index.js +17 -0
  24. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  25. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  26. package/templates/agent-os/subagent-routing.json +32 -0
  27. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +48 -7
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  31. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  32. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  33. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  34. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  35. package/templates/agent-os/universal/.claude/rules/autonomy.md +17 -7
  36. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  37. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  38. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  40. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  41. package/templates/agent-os/universal/.claude/settings.json +16 -0
  42. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +48 -7
  43. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  44. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  45. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  46. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  47. package/templates/agent-os/universal/.codex/config.toml +3 -0
  48. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  49. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  50. package/templates/agent-os/universal/layers.json +4 -0
  51. package/templates/hash-history.json +15 -7
  52. package/templates/release-ledger.json +3 -1
  53. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  54. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  55. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  56. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The capability evidence matrix (RP-36, absorbing the discovery component of
3
+ * the item it supersedes): one row per concrete harness surface, saying what
4
+ * was observed, on exactly which version, when, and where the proof is.
5
+ *
6
+ * The rule that gives the matrix its value is that its validator refuses an
7
+ * incomplete row: a row without an exact version or an observed
8
+ * date-time reads like evidence and is not one, because neither "which build
9
+ * was this" nor "was this before or after the change" can be answered from it.
10
+ * This module owns validation only; a caller that persists rows owns storage.
11
+ * Both halves are checked — `packages/cli/test/policy-coverage.test.ts` ›
12
+ * "refuses the harness version %j, because it names a range or a moving target
13
+ * rather than a build" and › "refuses an observedAt that is not an ISO-8601
14
+ * date-time with a zone (%j)". Documentation claims are not rows; a row records
15
+ * live behaviour that was seen.
16
+ *
17
+ * A row whose status is anything but `SUPPORTED` must say why, and a
18
+ * `SUPPORTED` row must not — the shape is closed in both directions, per ›
19
+ * "refuses a %s row that does not say why it is not supported".
20
+ *
21
+ * Every field of the shape is checked and every problem is reported at once,
22
+ * the way `./declaration.ts` does it, so a caller fixes a row in one pass: ›
23
+ * "reports every problem at once rather than stopping at the first".
24
+ *
25
+ * Why the row is shaped this way, and what it deliberately does not check, is
26
+ * `docs/decisions/capability-coverage.md`.
27
+ */
28
+ import { CAPABILITY_STATES } from './vocabulary.js';
29
+ import { carriesField, exactVersion, ISO_8601, isRecord, matching, member, nonBlankString, ownField, unknownKeys, } from './validation.js';
30
+ const KEYS = [
31
+ 'harness',
32
+ 'surface',
33
+ 'harnessVersion',
34
+ 'os',
35
+ 'observedAt',
36
+ 'mechanism',
37
+ 'observableSignal',
38
+ 'status',
39
+ 'downgradeReason',
40
+ 'evidencePointer',
41
+ ];
42
+ const REQUIRED_TEXT = [
43
+ 'harness',
44
+ 'surface',
45
+ 'harnessVersion',
46
+ 'os',
47
+ 'mechanism',
48
+ 'observableSignal',
49
+ 'evidencePointer',
50
+ ];
51
+ /** Validate an unknown value as one matrix row, reporting every problem at once. */
52
+ export function validateEvidenceRow(input) {
53
+ if (!isRecord(input)) {
54
+ return { ok: false, problems: [{ field: '', message: 'an evidence row is an object' }] };
55
+ }
56
+ const problems = [];
57
+ unknownKeys(problems, input, KEYS);
58
+ // EVERY field is read through `ownField`, not off the record directly. A row
59
+ // is evidence only insofar as it CARRIES what it was validated on: a value
60
+ // reached through the prototype chain, or held in a non-enumerable own
61
+ // property, validates and then serialises to nothing — "a row that reads like
62
+ // evidence and is not one", which is the shape this module exists to refuse.
63
+ // `unknownKeys` already judges the row by `Object.keys`, and `ownField` is
64
+ // that same notion, so the closed-shape check and the field reads cannot
65
+ // disagree about what the row contains.
66
+ for (const field of REQUIRED_TEXT)
67
+ nonBlankString(problems, field, ownField(input, field));
68
+ exactVersion(problems, 'harnessVersion', ownField(input, 'harnessVersion'));
69
+ matching(problems, 'observedAt', ownField(input, 'observedAt'), ISO_8601, 'an ISO-8601 date-time with a zone');
70
+ const status = ownField(input, 'status');
71
+ const known = member(problems, 'status', status, CAPABILITY_STATES);
72
+ const carriesReason = carriesField(input, 'downgradeReason');
73
+ if (known && status !== 'SUPPORTED') {
74
+ if (carriesReason)
75
+ nonBlankString(problems, 'downgradeReason', ownField(input, 'downgradeReason'));
76
+ else
77
+ problems.push({ field: 'downgradeReason', message: 'must be a non-blank string' });
78
+ }
79
+ // The shape is closed in both directions: a supported row has no reason to
80
+ // give, so a `downgradeReason` on one is refused rather than ignored. Left
81
+ // unchecked, the field went unvalidated on that branch and the narrowing
82
+ // below handed back a value typed `string` that was not one.
83
+ if (known && status === 'SUPPORTED' && carriesReason) {
84
+ problems.push({
85
+ field: 'downgradeReason',
86
+ message: 'a SUPPORTED row has nothing to explain, so it carries no downgrade reason',
87
+ });
88
+ }
89
+ if (problems.length > 0)
90
+ return { ok: false, problems };
91
+ // Every field above was checked against the shape, so the narrowing is the
92
+ // type the checks just established rather than an assertion over them.
93
+ return { ok: true, value: input };
94
+ }
@@ -0,0 +1,442 @@
1
+ /**
2
+ * The capability probe (RP-36): given a declaration, a harness adapter and
3
+ * that surface's hook-wiring snapshot, what can this surface actually enforce?
4
+ *
5
+ * ONE active read of a snapshot the caller supplies. This module cannot fetch
6
+ * one, which bounds what IT does — it does not, on its own, stop a caller
7
+ * looping it, and the header of an earlier draft claimed otherwise. What
8
+ * records the occasion of a probe is `./coverage.ts`, whose `coverageFromProbe`
9
+ * requires a `ProbeTrigger` and refuses a word outside that vocabulary:
10
+ * `policy-coverage.test.ts` › "refuses the trigger %j, because the coverage
11
+ * contract accepts only a declared surface-change trigger".
12
+ *
13
+ * The four answers, each pinned in `packages/cli/test/policy-coverage.test.ts`:
14
+ *
15
+ * | input | answer | pinned by |
16
+ * | --- | --- | --- |
17
+ * | a group naming exactly the declared tools and RUNNING the hook | `SUPPORTED` | › "reads the real %s command as running the hook, because it is what the rig really ships" |
18
+ * | the hook run under that event, but by no group naming exactly those tools | `DEGRADED` | › "reads a wiring that drops a declared tool as DEGRADED, naming the tool that is missing" |
19
+ * | a readable snapshot that runs the hook nowhere under that event | `UNSUPPORTED` | › "reads a hook wired nowhere under the policy event as UNSUPPORTED, naming the hook path" |
20
+ * | a snapshot, or any level of wiring under it, in a shape this module cannot read | `INTEGRATION-FAILED` | › "reports INTEGRATION-FAILED when %s, naming the event it could not read" |
21
+ *
22
+ * 🔴 The last two rows are a distinction, not a duplicate, and collapsing them
23
+ * costs something in each direction. `rules/invariants.md` states the rule
24
+ * under "Refusing to inspect is a third outcome": a field that is simply
25
+ * ABSENT leaves nothing to judge, while a field PRESENT in an unreadable shape
26
+ * is exactly the case worth reporting. Read as one, either an honest absence
27
+ * becomes a false alarm or an unreadable surface becomes a quiet, uncounted
28
+ * zero — the second being the shape a past guard here actually shipped. The
29
+ * costly case is an element BESIDE a valid group whose STRUCTURE cannot be
30
+ * read, held by › "does not report SUPPORTED when an unreadable element sits
31
+ * beside a valid group, because that is a partial read". ⚠ That is the
32
+ * structural level only. A readable command that merely names the hook in an
33
+ * unverified spelling does NOT outrank a group carrying the generated command:
34
+ * a hook list is conjunctive, so an entry the probe could not verify cannot
35
+ * un-run one it did verify, and the surface reads `SUPPORTED`.
36
+ *
37
+ * Neither weak answer passes silently on the path this module's own callers
38
+ * take: `./coverage.ts` › `qualifierFor` maps both to `UNVERIFIABLE`, and
39
+ * `./decision-record.ts` refuses a record carrying either state with an
40
+ * unqualified verdict — › "refuses the silent pass an unwired surface would
41
+ * otherwise produce, and accepts it once qualifierFor speaks".
42
+ *
43
+ * ⚠ That used to be a claim about records built the ordinary way, narrower than
44
+ * "by construction", because `./decision-record.ts` decided whether a verdict
45
+ * carries a qualifier with `in` — a verdict object INHERITING one satisfied the
46
+ * check and then serialised without it. RP-153 brought that reader, and
47
+ * `./declaration.ts`, onto the same own-and-enumerable rule this module and
48
+ * `./evidence-matrix.ts` already used, so the gap this paragraph named is
49
+ * closed. `docs/decisions/capability-coverage.md` records the limits that
50
+ * remain — an accessor read twice (RP-157), and an array hole that serialises
51
+ * as `null` and is read by nothing (RP-161). A contract that claims cover it
52
+ * does not have is worse than one that names the gap, which is why this
53
+ * paragraph is corrected rather than deleted.
54
+ *
55
+ * The rationale, including what this deliberately does not do, is
56
+ * `docs/decisions/capability-coverage.md`.
57
+ */
58
+ import { carriesField, isRecord, ownField, quote } from './validation.js';
59
+ /**
60
+ * The longest hook command this module will parse.
61
+ *
62
+ * A command comes off a file on disk, and every line of work done on untrusted
63
+ * input in a module whose callers fail open is a line that can be made
64
+ * expensive (`rules/invariants.md`, "A guard that fails open must do provably
65
+ * bounded work"). Past the cap the command is not read at all, and the refusal
66
+ * reads `INTEGRATION-FAILED` — not `UNSUPPORTED`, because a command nobody
67
+ * looked at is not evidence that the mechanism is absent. An earlier version of
68
+ * this sentence said the opposite of the code, which is the sentence a
69
+ * maintainer would read before "simplifying" the branch back into the silent
70
+ * absence this design removed: › "refuses a command one byte over the cap
71
+ * rather than reading it, even though it names no hook".
72
+ *
73
+ * The margin, measured rather than asserted over every wiring snapshot this
74
+ * rig ships: the longest command in any of them is 131 bytes, so 4096 is ~31x
75
+ * the longest. An earlier draft of
76
+ * this comment claimed "two orders of magnitude", which was wrong by 3x and
77
+ * which nothing checked — the cited test pins that the cap ADMITS the shipped
78
+ * commands, not any ratio: › "states its command-length cap as a whole number,
79
+ * and one that admits the commands the rig ships".
80
+ */
81
+ export const MAX_HOOK_COMMAND_LENGTH = 4096;
82
+ /**
83
+ * How many tool names a reason may list before it says "and N more".
84
+ *
85
+ * The names come out of the snapshot's own matcher, so an unbounded join is
86
+ * both unbounded work and an unbounded string in an operator's terminal: ›
87
+ * "keeps the reason short when a thousand tools are added, and says how many it
88
+ * did not name".
89
+ */
90
+ export const MAX_NAMED_TOOLS_IN_REASON = 5;
91
+ /**
92
+ * How much STRUCTURE this module will read before it refuses to read any.
93
+ *
94
+ * 🔴 Capping each command's length bounded the wrong axis alone. A snapshot is
95
+ * outside data, and its cardinalities were the caller's to choose: a hundred
96
+ * thousand groups, each carrying a hundred thousand hooks, is a snapshot whose
97
+ * every command is inside the length cap and whose total work is bounded by
98
+ * nothing. `rules/invariants.md` ("A guard that fails open must do provably
99
+ * bounded work") makes that the whole test — not "is it fast on realistic
100
+ * input" but "can any input make it do unbounded work at all" — and a consumer
101
+ * of this answer reads an absent verdict as no finding, so exhausting this
102
+ * module is a way to be reported on by nobody.
103
+ *
104
+ * Each cap is checked BEFORE the level it bounds is walked, and each refusal is
105
+ * `INTEGRATION-FAILED` naming the number crossed — never `UNSUPPORTED`, because
106
+ * a level nobody walked is not evidence that the mechanism is absent. That is
107
+ * the rule the over-long command already follows.
108
+ *
109
+ * A cap must admit the wiring this rig really ships, and that is asserted where
110
+ * the shipped file is actually opened rather than described here. Two tests, and
111
+ * the difference between them is the point: `test/template/policy-coverage.test.ts`
112
+ * (absent in a generated rig) › "keeps every level of the %s wiring this rig
113
+ * ships inside the caps the probe will read" reads each adapter's own surface
114
+ * file and measures its group, hook and matcher sizes against these three
115
+ * numbers, so a shipped surface file that grows past one of them goes red;
116
+ * `packages/cli/test/policy-coverage.test.ts` › "admits every level of the %s
117
+ * wiring this rig really ships, so no cap refuses honest work" makes the same
118
+ * assertion over the REGISTRY-DERIVED fixture, which is narrower and cannot
119
+ * stand in for the file. An earlier version of this paragraph quoted the three
120
+ * measured sizes and cited only the second test — a figure nothing regenerates,
121
+ * pointing at a test that never opens the thing it described.
122
+ *
123
+ * This module cannot name a surface file itself, and
124
+ * `test/template/policy-declaration.test.ts` › "no file under src/policy/core
125
+ * mentions a harness, a vendor, a native tool or a native path" is what stops it
126
+ * trying. The refusal direction is held in the unit file,
127
+ * `packages/cli/test/policy-coverage.test.ts` › "refuses a snapshot carrying
128
+ * one group more than it will read, naming the limit it crossed".
129
+ *
130
+ * ⚠ `MAX_MATCHER_LENGTH` is NOT set from the longest matcher this rig ships,
131
+ * which is far shorter. A cap is a promise about what
132
+ * this module still reads, and the contract had already made a wider one: ›
133
+ * "keeps the reason short when a thousand tools are added, and says how many it
134
+ * did not name" hands the probe a 4,935-character matcher and requires a
135
+ * `DEGRADED` answer with a bounded reason. Setting the cap below that would
136
+ * have turned an answer the suite already demands into a refusal — a cap
137
+ * chosen from the typical size rather than from the promise, which is how a
138
+ * bound quietly becomes a behaviour change. 8192 is the first power of two
139
+ * above what is already read.
140
+ *
141
+ * The work that admits is still bounded, and the product is what matters rather
142
+ * than any one factor: at most `MAX_HOOK_GROUPS` × `MAX_HOOKS_PER_GROUP` hook
143
+ * entries are classified, each against a fixed number of adapter fields capped
144
+ * at `MAX_HOOK_COMMAND_LENGTH`, and each group's matcher yields at most
145
+ * `MAX_MATCHER_LENGTH / 2` tools compared against a declaration of a handful.
146
+ */
147
+ export const MAX_HOOK_GROUPS = 64;
148
+ export const MAX_HOOKS_PER_GROUP = 64;
149
+ export const MAX_MATCHER_LENGTH = 8192;
150
+ /** One horizontal tab, named so no source line carries an invisible one. */
151
+ const TAB = String.fromCharCode(9);
152
+ /**
153
+ * The command as a shell would word it, for comparison only.
154
+ *
155
+ * Runs of spaces and tabs collapse to one space, and a single leading or
156
+ * trailing space is then dropped. Those two are the whole tolerance — stated
157
+ * together because an omitted one is as misleading as an invented one, and the
158
+ * trim was missing from the first version of this paragraph. Space and tab are
159
+ * the only default IFS
160
+ * characters that separate WORDS without separating COMMANDS. A newline does
161
+ * separate commands — `node` on one line and the hook path on the next is two
162
+ * commands, the second executing the hook file directly — so collapsing it
163
+ * would manufacture a match against the generated string and hand back the one
164
+ * answer this module must never give without evidence. The same holds for
165
+ * carriage return, vertical tab, form feed and a non-breaking space, none of
166
+ * which a shell splits words on at all.
167
+ *
168
+ * Pinned in `packages/cli/test/policy-coverage.test.ts` (absent in a generated
169
+ * rig) › "still reads a %s wiring with %s as SUPPORTED, because a shell
170
+ * separates words on both" and, in the other direction, › "refuses a %s wiring
171
+ * whose spaces became %s, because a shell does not separate words on it".
172
+ */
173
+ const normalise = (command) =>
174
+ // The tab is split out rather than matched: a literal tab inside a character
175
+ // class is a control character to a regular expression, which `no-control-regex`
176
+ // refuses - and rightly, because it is invisible to a reader.
177
+ command.split(TAB).join(' ').replace(/ +/g, ' ').replace(/^ | $/g, '');
178
+ /**
179
+ * Classify one hook entry by comparing every command the harness generates for
180
+ * this hook against the field it belongs to — never by parsing any of them.
181
+ *
182
+ * 🔴 This replaced a partial shell parser, and the reason is worth keeping
183
+ * because it was expensive to learn. Three gate rounds tried to decide "does
184
+ * this command execute the hook?" by reading shell syntax. Each round closed a
185
+ * class of false `SUPPORTED` and opened a new one: a `.bak` neighbour, then a
186
+ * conditional `&&` segment, then a quoted mention the splitter cut through,
187
+ * then an unterminated quote, a mismatched brace, a backgrounding `&` on a
188
+ * later segment, and finally an assignment whose command substitution can fail
189
+ * — which cannot be refused, because it is the shape this rig's own derived
190
+ * command uses. The input surface was the whole shell grammar and the error
191
+ * was asymmetric, so a partial parser could not win.
192
+ *
193
+ * The rig GENERATES its own wiring, so the probe compares against what it
194
+ * would generate. There is no grammar left to lose to.
195
+ *
196
+ * EVERY declared field must match, because a surface can run a different
197
+ * spelling per platform: reading only the first left a guard that had been
198
+ * replaced on one platform reading as enforced on all of them.
199
+ *
200
+ * The substring test survives, and its FAILURE DIRECTION is what makes that
201
+ * safe: it now only chooses between `INTEGRATION-FAILED` and `UNSUPPORTED` —
202
+ * two non-passing answers — so a false positive can no longer reach
203
+ * `SUPPORTED`. That inversion is the design: › "never reaches SUPPORTED from a
204
+ * mere mention of the hook path, whichever spelling the mention takes".
205
+ *
206
+ * ⚠ What it costs, stated because it is a real loss: a hand-written wiring
207
+ * that genuinely runs the hook — a bare `node <hookPath>`, a flag before the
208
+ * path, a different spelling of the same variable — is `INTEGRATION-FAILED`
209
+ * rather than `SUPPORTED`. That is "I cannot verify this" in place of a
210
+ * confident answer, which is the direction this contract is required to err
211
+ * in, but a rig wiring its hooks by hand will read as unverifiable.
212
+ *
213
+ * Over the length cap a command is not read at all: refusing to inspect is a
214
+ * third outcome, and a guard that did not look may not report that it found
215
+ * nothing (`rules/invariants.md`, "Refusing to inspect is a third outcome, not
216
+ * a match and not an error"). An adapter that generates NO command is the same
217
+ * outcome for the same reason — there is nothing to compare against, so there
218
+ * is nothing verified — but it carries its OWN cause and its own sentence,
219
+ * because the fault is in the adapter rather than in the surface being read: ›
220
+ * "refuses %s under a matcher that matches the declaration exactly, because
221
+ * there was nothing to compare it against", with the two refusals held apart
222
+ * by › "does not borrow the sentence of a wiring that really did name the
223
+ * hook, which still gets it".
224
+ */
225
+ const classify = (entry, surface) => {
226
+ const fields = Object.keys(surface.commands);
227
+ // An adapter that generates no command names nothing to compare against, and
228
+ // `matched` starts true — so without this, EVERY hook entry, including an
229
+ // empty one, ran the loop zero times and came back as running the hook. A
230
+ // surface whose adapter says nothing about what it would generate is one this
231
+ // module can verify nothing about, which is `INTEGRATION-FAILED`, never a
232
+ // pass. The two shipped adapters both declare commands, so nothing today
233
+ // takes this branch; the type admits `{}` and adding an adapter is a
234
+ // documented extension point, which is how a new adapter would have been
235
+ // handed "every policy enforced" for free.
236
+ //
237
+ // 🔴 Its own cause, not a borrowed one. The first version returned
238
+ // `'spelling'`, which made the refusal say "something under <event> names
239
+ // <hookPath>" for a hook entry of `{}` — nothing named anything, and
240
+ // `mentions` had not even been computed. That is the failure this union
241
+ // exists to prevent, stated above and again at the ladder below, and both
242
+ // reviewers found it in the same round. The defect is also in the ADAPTER
243
+ // rather than the surface, and the sentence has to say so, or an operator
244
+ // goes looking through a wiring file that is fine.
245
+ if (fields.length === 0)
246
+ return { kind: 'unreadable', cause: 'nothing-generated' };
247
+ let matched = true;
248
+ let mentions = false;
249
+ for (const field of fields) {
250
+ const wired = entry[field] ?? '';
251
+ if (wired.length > MAX_HOOK_COMMAND_LENGTH)
252
+ return { kind: 'unreadable', cause: 'oversize' };
253
+ const generated = surface.commands[field] ?? [];
254
+ if (!generated.some((spelling) => normalise(spelling) === normalise(wired)))
255
+ matched = false;
256
+ if (wired.includes(surface.hookPath))
257
+ mentions = true;
258
+ }
259
+ if (matched)
260
+ return { kind: 'runs' };
261
+ return mentions ? { kind: 'unreadable', cause: 'spelling' } : { kind: 'unrelated' };
262
+ };
263
+ const toolsOf = (matcher) => matcher.split('|').filter((tool) => tool !== '');
264
+ const sameTools = (declared, wired) => declared.length === wired.length && declared.every((tool) => wired.includes(tool));
265
+ /** What the declaration names and the wiring does not, and the reverse. */
266
+ const differenceOf = (declared, wired) => ({
267
+ missing: declared.filter((tool) => !wired.includes(tool)),
268
+ extra: wired.filter((tool) => !declared.includes(tool)),
269
+ });
270
+ /**
271
+ * Name a bounded number of tools, each escaped.
272
+ *
273
+ * Both properties are required of a string assembled from a matcher that came
274
+ * off disk: bounded, or one probe produces a megabyte of diagnostic; escaped,
275
+ * or a segment carrying a newline and an ANSI sequence forges a line of the
276
+ * report it appears in. Held by › "escapes a tool name carrying a newline and
277
+ * an ANSI sequence, so a matcher cannot forge a line of the report".
278
+ */
279
+ const names = (tools) => {
280
+ const shown = tools.slice(0, MAX_NAMED_TOOLS_IN_REASON).map(quote).join(', ');
281
+ const unnamed = tools.length - MAX_NAMED_TOOLS_IN_REASON;
282
+ return unnamed > 0 ? `${shown}, and ${String(unnamed)} more` : shown;
283
+ };
284
+ const describeDifference = (missing, extra) => {
285
+ const parts = [];
286
+ if (missing.length > 0)
287
+ parts.push(`does not cover ${names(missing)}`);
288
+ if (extra.length > 0)
289
+ parts.push(`also covers ${names(extra)}, which the policy does not`);
290
+ return parts.join('; ');
291
+ };
292
+ const unreadable = (what) => ({
293
+ state: 'INTEGRATION-FAILED',
294
+ reason: `the surface snapshot could not be read: ${what}`,
295
+ });
296
+ /**
297
+ * Probe one policy against one surface's snapshot. Pure: the snapshot is the
298
+ * caller's to obtain, and `snapshot` is `unknown` on purpose — the shape is
299
+ * checked at every level rather than trusted, because a surface file is
300
+ * outside data and a level silently skipped is a partial read reported as a
301
+ * whole one.
302
+ */
303
+ export function probePolicy(policy, adapter, snapshot) {
304
+ if (!isRecord(snapshot)) {
305
+ return unreadable('it is not an object carrying a hooks field');
306
+ }
307
+ // Every level below is read through `carriesField`/`ownField` rather than
308
+ // with `in` and a bracket read. A snapshot is outside data, and the question
309
+ // this module answers is what the SURFACE wires — so a value the surface's
310
+ // own serialisation would not carry is not wiring, whether it sits on a
311
+ // prototype or in a non-enumerable own property. Reading one as wiring
312
+ // reported a hook that serialises as `{}` as running the generated command,
313
+ // which is the false SUPPORTED this contract exists to make impossible.
314
+ if (carriesField(snapshot, 'hooks') && !isRecord(ownField(snapshot, 'hooks'))) {
315
+ return unreadable('its hooks field is not an object of event names');
316
+ }
317
+ const surface = adapter.nativeSurfaceOf(policy);
318
+ const hooksField = ownField(snapshot, 'hooks');
319
+ const wiring = isRecord(hooksField) ? hooksField : {};
320
+ // An event that is ABSENT contributes no groups and is not a finding; an
321
+ // event PRESENT in a shape this cannot read is the finding. An event key the
322
+ // record does not itself carry is the first of those, not the second.
323
+ const groups = [];
324
+ if (carriesField(wiring, surface.event)) {
325
+ const under = ownField(wiring, surface.event);
326
+ if (!Array.isArray(under)) {
327
+ return unreadable(`the value under ${surface.event} is not a list of groups`);
328
+ }
329
+ if (under.length > MAX_HOOK_GROUPS) {
330
+ return unreadable(`${surface.event} carries more than ${String(MAX_HOOK_GROUPS)} groups, so none of them was read`);
331
+ }
332
+ for (const group of under) {
333
+ if (!isRecord(group)) {
334
+ return unreadable(`a group under ${surface.event} is not an object`);
335
+ }
336
+ const groupHooks = ownField(group, 'hooks');
337
+ if (!Array.isArray(groupHooks)) {
338
+ return unreadable(`the hooks of a group under ${surface.event} are not a list`);
339
+ }
340
+ if (groupHooks.length > MAX_HOOKS_PER_GROUP) {
341
+ return unreadable(`a group under ${surface.event} carries more than ${String(MAX_HOOKS_PER_GROUP)} hooks, so none of them was read`);
342
+ }
343
+ const hooks = [];
344
+ for (const hook of groupHooks) {
345
+ if (!isRecord(hook)) {
346
+ return unreadable(`a hook under ${surface.event} is not an object`);
347
+ }
348
+ // Every field this harness generates a command for has to be there and
349
+ // be a string. An absent one is not an honest absence: the harness
350
+ // writes them all, so a hook missing one is a wiring this module
351
+ // cannot vouch for on the platform that field serves.
352
+ const entry = {};
353
+ for (const field of Object.keys(surface.commands)) {
354
+ const wired = ownField(hook, field);
355
+ if (typeof wired !== 'string') {
356
+ return unreadable(`a hook under ${surface.event} has no readable ${field}`);
357
+ }
358
+ entry[field] = wired;
359
+ }
360
+ hooks.push(entry);
361
+ }
362
+ // The matcher is a level like any other, and it is the level the
363
+ // SUPPORTED/DEGRADED decision is read from. Coercing a present-but-
364
+ // unreadable one to `undefined` made it the EMPTY matcher, so an
365
+ // unreadable group answered DEGRADED — a level skipped, reported as a
366
+ // whole read. Absent stays absent; present-and-unreadable is a finding.
367
+ const carriedMatcher = ownField(group, 'matcher');
368
+ if (carriesField(group, 'matcher') && typeof carriedMatcher !== 'string') {
369
+ return unreadable(`the matcher of a group under ${surface.event} is not a string`);
370
+ }
371
+ if (typeof carriedMatcher === 'string' && carriedMatcher.length > MAX_MATCHER_LENGTH) {
372
+ return unreadable(`the matcher of a group under ${surface.event} is longer than ${String(MAX_MATCHER_LENGTH)} characters and was not read`);
373
+ }
374
+ groups.push({
375
+ matcher: typeof carriedMatcher === 'string' ? carriedMatcher : undefined,
376
+ hooks,
377
+ });
378
+ }
379
+ }
380
+ const declared = toolsOf(surface.matcher);
381
+ // One pass, three kinds. Whether anything merely NAMED the hook is carried
382
+ // across groups, because that is what separates "nothing here wires this"
383
+ // from "something here names it and I cannot verify that it runs" — the
384
+ // ABSENT/UNREADABLE pair this module keeps at every other level.
385
+ const running = [];
386
+ const refusals = new Set();
387
+ for (const group of groups) {
388
+ let runsHere = false;
389
+ for (const hook of group.hooks) {
390
+ const kind = classify(hook, surface);
391
+ if (kind.kind === 'runs')
392
+ runsHere = true;
393
+ else if (kind.kind === 'unreadable')
394
+ refusals.add(kind.cause);
395
+ }
396
+ if (runsHere)
397
+ running.push(group);
398
+ }
399
+ if (running.length === 0) {
400
+ // A cause that really happened, chosen from what was observed. Three of
401
+ // them now, and each has its own sentence for the same reason: a refusal
402
+ // that borrows another's cause sends an operator to the wrong file.
403
+ // `nothing-generated` is answered first because it is a fact about the
404
+ // ADAPTER — every entry under the event was classified by it, so no
405
+ // per-entry cause it might sit beside is informative.
406
+ if (refusals.has('nothing-generated')) {
407
+ return {
408
+ state: 'INTEGRATION-FAILED',
409
+ reason: `the ${adapter.harness} adapter generates no command for this policy, so nothing under ${surface.event} could be compared against one and whether ${surface.hookPath} runs cannot be verified`,
410
+ };
411
+ }
412
+ if (refusals.has('spelling')) {
413
+ return {
414
+ state: 'INTEGRATION-FAILED',
415
+ reason: `something under ${surface.event} names ${surface.hookPath}, but in no command this harness generates, so whether the hook runs cannot be verified from this surface`,
416
+ };
417
+ }
418
+ if (refusals.has('oversize')) {
419
+ return {
420
+ state: 'INTEGRATION-FAILED',
421
+ reason: `a command under ${surface.event} is longer than ${String(MAX_HOOK_COMMAND_LENGTH)} characters and was not read, so whether ${surface.hookPath} runs cannot be verified from this surface`,
422
+ };
423
+ }
424
+ return {
425
+ state: 'UNSUPPORTED',
426
+ reason: `no group under ${surface.event} runs ${surface.hookPath}, so the mechanism is absent on this surface`,
427
+ };
428
+ }
429
+ if (running.some((group) => sameTools(declared, toolsOf(group.matcher ?? '')))) {
430
+ return { state: 'SUPPORTED' };
431
+ }
432
+ // Every group running the hook differs from the declaration; report the
433
+ // closest one, so the reason names a real discrepancy rather than a union of
434
+ // several. "Closest" is the fewest tools out of place.
435
+ const differences = running.map((group) => differenceOf(declared, toolsOf(group.matcher ?? '')));
436
+ const sizeOf = (d) => d.missing.length + d.extra.length;
437
+ const closest = differences.reduce((best, d) => (sizeOf(d) < sizeOf(best) ? d : best));
438
+ return {
439
+ state: 'DEGRADED',
440
+ reason: `${surface.hookPath} runs under ${surface.event}, but the matcher ${describeDifference(closest.missing, closest.extra)}`,
441
+ };
442
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The policy registry (RP-76): the declarations of the policies this rig
3
+ * enforces, and the compatibility rule a decision record is read against.
4
+ *
5
+ * The three entries are the guards the rulebook already ships; declaring them
6
+ * changes nothing about how they run. Each field below was read off the
7
+ * guard's own header and its tests rather than inferred from its name, and the
8
+ * correspondence between a declaration and the hook wiring both harnesses
9
+ * carry is pinned in `test/template/policy-declaration.test.ts` › "every
10
+ * registered policy is wired in the %s snapshot under its event, matcher and
11
+ * hook path". Which hook file a mechanism name resolves to is the adapter's
12
+ * business (`../harness/`), not this file's.
13
+ */
14
+ import { definePolicy } from './declaration.js';
15
+ import { POLICY_VERSION } from './declaration.js';
16
+ const NEVER_TIER = 'rules/autonomy.md#never';
17
+ /**
18
+ * What the three share, stated once: each is a pre-operation hook of the
19
+ * Never tier that can allow, block, or refuse to inspect; each fails open on
20
+ * its own error and closed on input it can see but cannot read
21
+ * (`rules/invariants.md`, "Fail closed on a match, fail open on an error" and
22
+ * "Refusing to inspect is a third outcome"). The one evidence every outcome
23
+ * carries is the exit code; a refusal also prints a diagnostic line, an allow
24
+ * prints nothing, so `diagnostic-text` is not required of every record.
25
+ */
26
+ const guard = (declaration) => definePolicy({
27
+ policyVersion: '1.0',
28
+ lifecycle: 'active',
29
+ tier: 'never',
30
+ timing: 'before-operation',
31
+ requiredCapability: 'pre-operation-hook',
32
+ outcomes: ['allow', 'block', 'refuse-to-inspect'],
33
+ onInternalError: 'fail-open',
34
+ onUnreadableInput: 'fail-closed',
35
+ requiredEvidence: ['exit-code'],
36
+ statedIn: NEVER_TIER,
37
+ ...declaration,
38
+ });
39
+ /**
40
+ * Secret-write refusal. The guard refuses an edit that names a credential file
41
+ * or carries a credential value; the value arm never prints what it matched —
42
+ * `test/template/guard-secret-file.test.ts` › "never prints the credential it
43
+ * found, nor a fragment of it" — which is what `omit-matched-values` records.
44
+ */
45
+ const secretWriteRefusal = guard({
46
+ policyId: 'secret-write-refusal',
47
+ invariant: 'A credential never enters the repository through an edit.',
48
+ operations: ['file-edit'],
49
+ mechanism: 'guard-secret-file',
50
+ redaction: 'omit-matched-values',
51
+ });
52
+ /**
53
+ * No-verify refusal. The guard refuses a shell command that bypasses the
54
+ * pre-commit gate — `test/template/shell-tools.test.ts` › "refuses a
55
+ * pre-commit bypass through %s" pins the block on every shell tool. The
56
+ * refuse-to-inspect outcome and the fail-open on an unparseable payload are
57
+ * `test/template/hook-command-shape.test.ts` › "%s does not tell the caller to
58
+ * split and retry" and › "allows a malformed payload it cannot parse at all".
59
+ * ⚠ One limit of `onInternalError: 'fail-open'` for this guard: it has no
60
+ * try/catch, so an internal throw exits 1, which the harness reads as allow —
61
+ * a property of the harness that no test here pins.
62
+ */
63
+ const noVerifyRefusal = guard({
64
+ policyId: 'no-verify-refusal',
65
+ invariant: 'The pre-commit gate is never bypassed.',
66
+ operations: ['shell-command'],
67
+ mechanism: 'block-no-verify',
68
+ redaction: 'none',
69
+ });
70
+ /**
71
+ * Rulebook-mutation restriction. In an unattended run the guard refuses an
72
+ * edit under a rulebook prefix outside the current item's allow-list; in an
73
+ * attended session it does nothing — `test/template/guard-rulebook.test.ts` ›
74
+ * "blocks an edit to a rulebook path the allow-list does not name" and ›
75
+ * "allows a hook edit when no unattended flag exists".
76
+ */
77
+ const rulebookMutationRestriction = guard({
78
+ policyId: 'rulebook-mutation-restriction',
79
+ invariant: "In an unattended run, the rulebook is never edited outside the current item's allow-list.",
80
+ operations: ['file-edit'],
81
+ mechanism: 'guard-rulebook',
82
+ redaction: 'none',
83
+ });
84
+ /** Every declared policy, in declaration order. */
85
+ export const POLICIES = Object.freeze([
86
+ secretWriteRefusal,
87
+ noVerifyRefusal,
88
+ rulebookMutationRestriction,
89
+ ]);
90
+ export function findPolicy(policyId) {
91
+ return POLICIES.find((policy) => policy.policyId === policyId) ?? null;
92
+ }
93
+ export function policyIds() {
94
+ return POLICIES.map((policy) => policy.policyId);
95
+ }
96
+ /** The policies the registry still offers — everything not retired. */
97
+ export function activePolicies() {
98
+ return POLICIES.filter((policy) => policy.lifecycle !== 'retired');
99
+ }
100
+ const majorOf = (version) => Number(version.split('.')[0]);
101
+ /**
102
+ * Can a decision record naming `policyVersion` be read against the registered
103
+ * policy? Same MAJOR: yes, whatever the MINOR — a MINOR bump is additive.
104
+ * Different MAJOR: no, the semantics that produced the verdict are not these.
105
+ * A version that is not `MAJOR.MINOR` is refused as malformed rather than
106
+ * parsed for a MAJOR it might have meant.
107
+ */
108
+ export function compatibilityOf(policyId, policyVersion) {
109
+ const policy = findPolicy(policyId);
110
+ if (policy === null)
111
+ return 'unknown-policy';
112
+ if (!POLICY_VERSION.test(policyVersion))
113
+ return 'malformed-version';
114
+ return majorOf(policyVersion) === majorOf(policy.policyVersion) ? 'compatible' : 'incompatible';
115
+ }