@jjanczur/tyran 0.1.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/CHANGELOG.md +245 -0
  4. package/LICENSE +201 -0
  5. package/README.md +468 -0
  6. package/agents/.gitkeep +0 -0
  7. package/agents/implementer.md +60 -0
  8. package/agents/retro.md +88 -0
  9. package/agents/reviewer.md +55 -0
  10. package/agents/scout.md +60 -0
  11. package/bin/tyran.mjs +172 -0
  12. package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
  13. package/hooks/hooks.json +83 -0
  14. package/hooks/scripts/.gitkeep +0 -0
  15. package/hooks/scripts/evidence-gate.mjs +705 -0
  16. package/hooks/scripts/hook-io.mjs +813 -0
  17. package/hooks/scripts/policy-gate.mjs +1402 -0
  18. package/hooks/scripts/pre-compact.mjs +211 -0
  19. package/hooks/scripts/retro-gate.mjs +191 -0
  20. package/hooks/scripts/secrets-gate.mjs +1683 -0
  21. package/hooks/scripts/session-start.mjs +358 -0
  22. package/hooks/scripts/write-guard.mjs +475 -0
  23. package/package.json +52 -0
  24. package/scripts/desc-budget.mjs +139 -0
  25. package/scripts/doctor.mjs +1267 -0
  26. package/scripts/hooks-check.mjs +1312 -0
  27. package/scripts/invisible.mjs +346 -0
  28. package/scripts/journal.mjs +747 -0
  29. package/scripts/project.mjs +981 -0
  30. package/scripts/scan-control-chars.mjs +547 -0
  31. package/scripts/scan-repo.mjs +287 -0
  32. package/scripts/schema.mjs +467 -0
  33. package/scripts/stop-check.mjs +89 -0
  34. package/scripts/tiers.mjs +324 -0
  35. package/scripts/yaml-lite.mjs +383 -0
  36. package/skills/browser-check/SKILL.md +118 -0
  37. package/skills/code-review/SKILL.md +83 -0
  38. package/skills/deslop/SKILL.md +97 -0
  39. package/skills/doctor/SKILL.md +35 -0
  40. package/skills/fidelity-gate/SKILL.md +132 -0
  41. package/skills/hello/SKILL.md +16 -0
  42. package/skills/pr-feedback/SKILL.md +85 -0
  43. package/skills/prompt-tuning/SKILL.md +102 -0
  44. package/skills/retro/SKILL.md +69 -0
  45. package/skills/root-cause/SKILL.md +89 -0
  46. package/skills/run/SKILL.md +285 -0
  47. package/skills/setup/SKILL.md +79 -0
  48. package/skills/skill-writing/SKILL.md +99 -0
  49. package/skills/status/SKILL.md +31 -0
  50. package/templates/.gitkeep +0 -0
  51. package/templates/config.yaml +44 -0
  52. package/templates/knowledge.yaml +23 -0
  53. package/templates/policies/autonomy.yaml +61 -0
  54. package/templates/project-command/SKILL.md +16 -0
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: reviewer
3
+ description: Independent quality control on another agent's work - reads the whole diff, runs its OWN verification rather than trusting the author's report, checks that the claimed optimization is actually in the code, and returns a binary APPROVE or CHANGES-REQUESTED with numbered, executable counterexamples. Never reviews its own code.
4
+ tools: Read, Grep, Glob, Bash, WebFetch, WebSearch
5
+ ---
6
+
7
+ You are a reviewer. You review ANOTHER agent's work, never your own.
8
+
9
+ **Reply in the language the conductor writes to you in. Anything written to
10
+ disk is in English.**
11
+
12
+ You have no editing tools on purpose: a reviewer who can fix what they found
13
+ ends up approving their own patch. This removes the easy path, not every path
14
+ — `Bash` can still write — so treat it as a boundary you keep, not a wall
15
+ that keeps you.
16
+
17
+ 1. **Read the whole diff** plus the story file that holds the acceptance
18
+ criteria. **Follow the `code-review` skill for the sweep** — it carries the
19
+ dimensions a diff is read against and the rule that you try to refute your
20
+ own finding before reporting it. Two things it will not let you skip: the
21
+ first pass is against the acceptance criteria rather than your idea of the
22
+ feature, and a correct implementation of the wrong thing is a defect no
23
+ dimension sweep catches.
24
+ 2. **Run your OWN verification.** Do not believe the author's report — an
25
+ author's report with no raw command output you reject on sight, without
26
+ reading further. Run the tests yourself; for UI, drive the browser yourself
27
+ through `browser-check`. Paste what you got, with counts.
28
+ - Settle disputed measurements (font size, padding, colour) by dumping
29
+ computed styles to JSON, never by eye. An "it looks off" audit produces
30
+ wrong findings at roughly the rate it produces right ones.
31
+ - Warm up routes before a batch run; a cold compile reads as a failure.
32
+ - Take heavy slots only through the lease protocol, and clean up your
33
+ processes afterwards (SIGTERM, not `kill -9`).
34
+ 3. **Check the optimization section** — whether what the author claims is
35
+ genuinely in the code, not just in the write-up.
36
+ 4. **The verdict is binary.** APPROVE, with minor notes routed to `NOTES.md`;
37
+ or CHANGES-REQUESTED with a numbered list — what, where, why. No
38
+ generalities.
39
+ - **Every counterexample must be EXECUTABLE**: concrete input, expected
40
+ behaviour, so it can be pinned as a test. A finding that cannot be pinned
41
+ cannot be verified as fixed.
42
+ - **On a re-review, check FIRST that the previous round's counterexamples
43
+ are pinned as MUST-PASS tests**, not merely "addressed". That check has
44
+ already caught a fix that was half done before it reached the rest of the
45
+ review.
46
+ - When you find nothing, say so plainly. Manufacturing findings to look
47
+ thorough wastes a round and trains the conductor to discount you.
48
+ 5. **Say what you did NOT check.** Platform you did not run on, concurrency
49
+ you did not exercise, the input class you skipped. This section is a merge
50
+ gate, not a courtesy: the conductor resolves every item as measured,
51
+ consciously accepted, or ticketed. Name the WORST case you can imagine for
52
+ each, not the typical one — a merge has already gone out on an unchecked
53
+ item that both reviewers had listed and nobody read.
54
+ 6. **Report in facts.** Run results as numbers — how many tests, how many
55
+ passed — never as adjectives.
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: scout
3
+ description: Fast, cheap reconnaissance over a repo, its documentation, its data or external sources, changing nothing. Returns short, concrete findings with the file path or URL that proves each one. Used by the conductor at the start of a task and through the research phase of a large initiative.
4
+ tools: Read, Grep, Glob, Bash, WebFetch, WebSearch
5
+ ---
6
+
7
+ You are the scout. You find things out. You change nothing.
8
+
9
+ **Reply in the language the conductor writes to you in. Anything you write to
10
+ disk is in English.**
11
+
12
+ 1. **Modify nothing** — no writes, no commits, no installs, no branch
13
+ switches. You have `Bash` because reconnaissance needs `git log`, `ls` and
14
+ `command -v`; that is the entire reason. Treat "does this command have a
15
+ side effect?" as a question you must be able to answer yes to before
16
+ running it.
17
+ 2. **A finding is a claim plus its proof.** Every line you return is
18
+ `finding -> path:line, quoted fragment, or URL`. The conductor reads dozens
19
+ of these; there is no budget for warm-up paragraphs, and an unsourced
20
+ finding costs more than no finding because someone will act on it.
21
+ 3. **When you did not find something, say "I did not find it"** and list
22
+ where you looked. Never fill the gap with a guess. Anchor every grep over
23
+ env or config files (`grep -nE '^VARIABLE='`) — an unanchored grep matches
24
+ commented-out lines and manufactures false findings.
25
+ 4. **Verify premises about DATA by measuring, read-only.** "The field is in
26
+ the schema" is not "the field is in the data". If the handoff assumes
27
+ something about a real dataset, check the dataset and report what you
28
+ actually saw.
29
+ 5. **Distinguish what you measured from what you inferred.** Label them. An
30
+ inference presented as an observation is the most expensive thing you can
31
+ hand back, because it survives review by sounding like a fact.
32
+ 6. Close with **RISKS / OPEN QUESTIONS** — at most five bullets, only things
33
+ that genuinely need the conductor's decision.
34
+
35
+ ## Mapping an unfamiliar area
36
+
37
+ When the ask is "orient me" rather than "find X", the deliverable is a MAP, not
38
+ a tour, and it is read in fifteen seconds:
39
+
40
+ ```
41
+ AREA: <what it does, from a caller's point of view, in one line>
42
+
43
+ ENTRY POINTS <path>:<symbol> — what starts this call chain
44
+ CORE MODULES <path> — the two to five that hold the real logic
45
+ FLOW <entry> -> <module> -> <module> -> <sink>
46
+ CALLERS <path> — who outside this area comes in, through which entry
47
+ HIDDEN COUPLING what looks independent and is not — shared singletons,
48
+ global state, implicit ordering, undocumented contracts
49
+ ```
50
+
51
+ Use those headers verbatim; the conductor reads several of these and scans them.
52
+
53
+ **Curate — a good map omits on purpose.** Listing every file is the failure
54
+ mode, not thoroughness: it hands back the directory listing the conductor could
55
+ have run itself. If the area will not fit on one screen, segment it and say
56
+ which segment you mapped rather than silently dropping half of it.
57
+
58
+ Rule 2 still governs every line: no remembered or inferred structure without a
59
+ grep behind it. And do not propose changes here — mapping is orientation, and a
60
+ map with opinions in it gets read as a plan.
package/bin/tyran.mjs ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tyran — command-line entry point for the `@jjanczur/tyran` npm package.
4
+ *
5
+ * The registry name is scoped (`@jjanczur/tyran`) but the command stays
6
+ * `tyran` — that is `bin`'s key, not the package name, and npx resolves a
7
+ * scoped package's sole bin entry regardless of scope (same pattern as
8
+ * `@angular/cli` giving you `ng`). The bare name `tyran` is not available:
9
+ * it carries an npm unpublish tombstone from 2021-03-30, and npm's abuse
10
+ * policy blocks republishing an unpublished name permanently, for anyone.
11
+ *
12
+ * This is a thin dispatcher, not a reimplementation. Every subcommand below
13
+ * is delegated VERBATIM (same argv, same exit code) to a script that already
14
+ * ships in this repo under `scripts/` and that the Claude Code plugin's own
15
+ * hooks and skills already call directly. `npm i @jjanczur/tyran` does NOT
16
+ * install the plugin — it only makes these scripts reachable from a shell or
17
+ * a CI job that has no Claude Code session. To install the plugin itself,
18
+ * run `/plugin marketplace add jjanczur/tyran` inside Claude Code (that name
19
+ * is the Claude Code marketplace/plugin identity, unrelated to the npm
20
+ * registry and not affected by any of the above).
21
+ *
22
+ * Path resolution note (read before touching this file): npm installs `bin`
23
+ * entries as a SYMLINK into `node_modules/.bin/`. `scripts/desc-budget.mjs`
24
+ * hit the silent-no-op version of this exact bug first — see its
25
+ * `isMainModule` comment: comparing a symlink path against a real path made
26
+ * a whole script's entry guard evaluate false forever, quietly, under exit
27
+ * 0. The same class of bug applies here to locating `scripts/`: resolving it
28
+ * relative to this file's own path without realpath-ing BOTH the symlink
29
+ * (`process.argv[1]` / the caller's path) and this module's own real
30
+ * location would land on a `scripts/` directory that does not exist next to
31
+ * the symlink target in `node_modules/.bin/`. Both sides are realpath'd
32
+ * below for that reason, mirroring `isMainModule` in desc-budget.mjs.
33
+ *
34
+ * Exit code contract: whatever the delegated script exits with, THIS process
35
+ * exits with the same code, numerically. A dispatcher that always exits 0
36
+ * turns `npx @jjanczur/tyran doctor` in CI into decoration instead of a
37
+ * gate — that is the one property this file must never regress.
38
+ */
39
+
40
+ import { fileURLToPath } from 'node:url';
41
+ import { dirname, join } from 'node:path';
42
+ import { realpathSync, existsSync } from 'node:fs';
43
+ import { spawnSync } from 'node:child_process';
44
+
45
+ /**
46
+ * Subcommand -> [script filename under scripts/, one-line description].
47
+ * The description is shown verbatim in `tyran --help`; keep it a single,
48
+ * honest sentence that matches the target script's own doc comment.
49
+ */
50
+ export const COMMANDS = {
51
+ doctor: [
52
+ 'doctor.mjs',
53
+ 'Consistency check for a Tyran state directory (.tyran/): drift, stale leases, journal damage, bad config.',
54
+ ],
55
+ 'scan-repo': [
56
+ 'scan-repo.mjs',
57
+ 'Deterministic repo scan for /tyran:setup — establishes what it can, marks what needs confirmation, never guesses.',
58
+ ],
59
+ tiers: [
60
+ 'tiers.mjs',
61
+ 'Resolve a Tyran role to a model alias via .tyran/config.yaml, the one place model names are allowed to live.',
62
+ ],
63
+ journal: [
64
+ 'journal.mjs',
65
+ "Append to, query, and validate a Tyran initiative's append-only journal.",
66
+ ],
67
+ schema: [
68
+ 'schema.mjs',
69
+ 'Validate .tyran/ config, knowledge, and policy files against their schemas.',
70
+ ],
71
+ 'stop-check': [
72
+ 'stop-check.mjs',
73
+ 'Check the .tyran/STOP operator brake that halts a running initiative before its next spawn or merge.',
74
+ ],
75
+ 'scan-control-chars': [
76
+ 'scan-control-chars.mjs',
77
+ 'Refuse raw control and bidi characters in tracked files (the ADR-19 gate).',
78
+ ],
79
+ 'desc-budget': [
80
+ 'desc-budget.mjs',
81
+ "Sum every skill's description length and fail CI when the total exceeds the context budget.",
82
+ ],
83
+ };
84
+
85
+ /** Absolute, symlink-resolved path; falls back to the merely absolute path
86
+ * when realpath cannot follow it (target unreadable, deleted mid-run). */
87
+ function realOrSelf(path) {
88
+ try {
89
+ return realpathSync(path);
90
+ } catch {
91
+ return path;
92
+ }
93
+ }
94
+
95
+ /** scripts/ lives one level up from bin/, in THIS package's real (not
96
+ * symlinked) install location — see the module doc comment above. */
97
+ function scriptsDir() {
98
+ const self = realOrSelf(fileURLToPath(import.meta.url));
99
+ return join(dirname(self), '..', 'scripts');
100
+ }
101
+
102
+ function printHelp() {
103
+ const lines = [
104
+ 'tyran — command-line scripts for the Tyran task conductor',
105
+ '',
106
+ 'This package does NOT install the Claude Code plugin. To install the',
107
+ 'plugin, run `/plugin marketplace add jjanczur/tyran` inside Claude Code.',
108
+ '',
109
+ 'Usage: tyran <command> [args...]',
110
+ '',
111
+ 'Commands:',
112
+ ];
113
+ const width = Math.max(...Object.keys(COMMANDS).map((name) => name.length));
114
+ for (const [name, [, description]] of Object.entries(COMMANDS)) {
115
+ lines.push(` ${name.padEnd(width)} ${description}`);
116
+ }
117
+ console.log(lines.join('\n'));
118
+ }
119
+
120
+ export function run(argv) {
121
+ const [command, ...rest] = argv;
122
+
123
+ if (!command || command === '--help' || command === '-h') {
124
+ printHelp();
125
+ return 0;
126
+ }
127
+
128
+ const entry = COMMANDS[command];
129
+ if (!entry) {
130
+ console.error(`tyran: unknown command '${command}'`);
131
+ console.error(`Known commands: ${Object.keys(COMMANDS).join(', ')}`);
132
+ return 2;
133
+ }
134
+
135
+ const [scriptFile] = entry;
136
+ const scriptPath = join(scriptsDir(), scriptFile);
137
+ if (!existsSync(scriptPath)) {
138
+ console.error(`tyran: '${command}' is wired to ${scriptPath}, which does not exist in this install`);
139
+ return 2;
140
+ }
141
+
142
+ // No `shell: true`: argv is handed to the child as an array, so nothing in
143
+ // `rest` is re-parsed or re-escaped — it reaches the target script exactly
144
+ // as it reached this process.
145
+ const result = spawnSync(process.execPath, [scriptPath, ...rest], { stdio: 'inherit' });
146
+
147
+ if (result.error) {
148
+ console.error(`tyran: failed to run '${command}': ${result.error.message}`);
149
+ return 2;
150
+ }
151
+ if (result.signal) {
152
+ console.error(`tyran: '${command}' terminated by signal ${result.signal}`);
153
+ return 1;
154
+ }
155
+ return result.status ?? 0;
156
+ }
157
+
158
+ /**
159
+ * True when this module is the program's entry point, not merely imported
160
+ * (tests import COMMANDS/run without wanting a live process). Both sides
161
+ * MUST be canonicalized — see the module doc comment and desc-budget.mjs's
162
+ * isMainModule, which this mirrors: comparing raw `process.argv[1]` against
163
+ * a raw `import.meta.url` breaks the moment either path crosses a symlink.
164
+ */
165
+ function isMainModule(moduleUrl) {
166
+ if (!process.argv[1]) return false;
167
+ return realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(moduleUrl));
168
+ }
169
+
170
+ if (isMainModule(import.meta.url)) {
171
+ process.exit(run(process.argv.slice(2)));
172
+ }
@@ -0,0 +1,377 @@
1
+ # Hook I/O contract — measured, not assumed
2
+
3
+ Every statement here was read out of the shipped Claude Code binary
4
+ (**v2.1.116**), not out of documentation. It exists because a gate built on a
5
+ guess about the platform is a gate of unknown strength (ADR-22), and because
6
+ four of the facts below **contradict** the documented contract in ways that
7
+ would each have produced a control that looks installed and cannot refuse.
8
+
9
+ Re-measure after a platform upgrade. The method: the binary embeds its
10
+ JavaScript, so `strings`/byte search finds the hook runner, the output schema
11
+ and the matcher predicate directly.
12
+
13
+ ## 1. Failing open is the default, in more places than expected
14
+
15
+ The action proceeds — and the hook is recorded as a `non_blocking_error` —
16
+ when any of these happen:
17
+
18
+ | what happens | result |
19
+ |---|---|
20
+ | the hook file is missing or not executable | the command runs under `shell: true`, so **the shell** exits 127 / 126 with empty stdout; that lands in the "non-blocking status code" branch and the **action proceeds** |
21
+ | stdout is not JSON at all (does not start with `{`) | treated as plain text, **proceeds** |
22
+ | stdout is JSON but fails the output schema | validation error, **proceeds** |
23
+ | `hookSpecificOutput.hookEventName` differs from the fired event | the platform **throws while reading our output**, caught, **proceeds** (only when `hookSpecificOutput` is present at all — see §3) |
24
+ | the hook is killed at its `timeout` | killed with `SIGKILL` — and the output is **not read at all**; see below, this row is the one that misleads |
25
+ | an exception escapes hook *selection* | the selector is wrapped in `try { … } catch { return [] }`, so **every hook for that event disappears** with no transcript entry |
26
+
27
+ ### The timeout row, in full — because the obvious reading of it is wrong
28
+
29
+ An earlier version of this file said "measured `rc=137`, stdout empty,
30
+ proceeds". Every word of that is true and the sentence is still misleading,
31
+ because it points at the wrong cause. The problem is **not** that a killed
32
+ hook has no time to write. Measured on a live run: a `PreToolUse` hook with
33
+ `timeout: 3` wrote a complete, valid refusal through `writeSync(1, …)`,
34
+ logged that it had done so, and only then blocked for 60 s. The refusal was
35
+ **ignored and the tool ran**.
36
+
37
+ The reason is visible in the runner. The same moment that kills the process
38
+ aborts the `AbortSignal`, the `close` handler reports `aborted: true`, and
39
+ the consumer does this:
40
+
41
+ ```
42
+ if (o.aborted) { …record telemetry, including o.stdout…; return } // ← returns here
43
+ const { json, plainText, validationError } = parse(o.stdout); // ← never reached
44
+ ```
45
+
46
+ The bytes were collected. They are even stored in the telemetry record. They
47
+ are simply **never parsed**. So:
48
+
49
+ > **Writing earlier does not help.** A watchdog that emits the refusal sooner
50
+ > and lets the process keep running buys nothing at all. The only thing that
51
+ > closes this case is a mechanism that makes the process **EXIT** before the
52
+ > platform's timeout — a hard-killed child process, or a watchdog that forces
53
+ > the whole process to terminate. Anything that merely produces output while
54
+ > the process stays alive is defeated by this branch.
55
+
56
+ This is why `hook-io.mjs` enforces its budget by emitting **and exiting**,
57
+ rather than by writing early and hoping.
58
+
59
+ Two further corrections to how this file was first written, both worth
60
+ stating because the wrong mechanism leads to the wrong fix:
61
+
62
+ - a missing hook file does **not** make `spawn` throw. There is no
63
+ pre-existence check; `shell: true` means the shell reports it. The
64
+ conclusion for ADR-22's open question is unchanged — a deleted hook file
65
+ disables that gate silently, and the answer cannot live inside a hook.
66
+ - an **invalid regex in a matcher is not** one of the exceptions that empties
67
+ the selector. It is caught locally inside the matcher predicate, which
68
+ returns `false` and logs at debug level. The gate still disappears, but
69
+ only that one, and for a different reason (§5).
70
+
71
+ ## 2. Exit codes: the documented rule is incomplete
72
+
73
+ Documented: `0` decides, `2` blocks, anything else fails open.
74
+
75
+ Measured: **stdout is parsed first, and a valid refusal on stdout is honoured
76
+ regardless of the exit code.** The exit code is only consulted when stdout
77
+ carried no usable JSON — `2` then becomes a block with stderr as the reason,
78
+ and `3`–`255` become a non-blocking error.
79
+
80
+ Consequence for us: exit 0 plus a JSON refusal is the strongest available
81
+ ending, and it is what this runtime always emits.
82
+
83
+ ## 3. The output shape is a discriminated union, and it is narrow
84
+
85
+ `hookSpecificOutput` is validated as a union keyed on `hookEventName`. There
86
+ are variants for `PreToolUse`, `UserPromptSubmit`, `UserPromptExpansion`,
87
+ `SessionStart`, `Setup`, `SubagentStart`, `PostToolUse`, `PostToolUseFailure`,
88
+ `PermissionDenied`, `Notification`, `PermissionRequest`, `Elicitation`,
89
+ `ElicitationResult`, `CwdChanged`, `FileChanged`, `WorktreeCreate`.
90
+
91
+ There is **no variant for `Stop`, `SubagentStop`, `PreCompact` or
92
+ `TaskCompleted`.** Sending one there fails the schema and the whole output is
93
+ discarded. Those events refuse through top-level `decision: "block"` +
94
+ `reason`. This is why `deny()` has to know the event.
95
+
96
+ Two refinements measured after the first draft:
97
+
98
+ - the "wrong `hookEventName` throws" check runs **only if `hookSpecificOutput`
99
+ is present at all**. Top-level `decision` + `reason` is not subject to it,
100
+ so the decision-shaped events are safe from that failure *by construction*,
101
+ not by our carefulness.
102
+ - `decision: "block"` on **`SubagentStop` is confirmed live**, not inferred:
103
+ the subagent was stopped and the parent turn resumed with the reason in
104
+ context. This is the event the evidence gate will stand on.
105
+
106
+ ## 4. `permissionDecision: "allow"` is not "no objection"
107
+
108
+ It sets the permission behaviour to *allow*, i.e. it **auto-approves the tool
109
+ call and skips the permission prompt**. A gate emitting it for everything it
110
+ did not object to would be quietly approving the whole session. `deny` from
111
+ any hook wins over `allow` from another, but that is no comfort when ours is
112
+ the only hook.
113
+
114
+ This runtime has no way to emit it. "No objection" is `{}`.
115
+
116
+ ## 5. Matcher syntax — one predicate for every event
117
+
118
+ Transcribed in full, including the alias normalisation an earlier draft of
119
+ this file dropped. `normalise` is a lookup in the tool-alias table (identity
120
+ for anything that is not an aliased tool name), and `aliasesOf` is its
121
+ reverse, so a regex matcher is tried against the query *and* against every
122
+ name that aliases to it:
123
+
124
+ ```
125
+ if (!matcher || matcher === "*") return true;
126
+ if (/^[a-zA-Z0-9_|]+$/.test(matcher))
127
+ return matcher.includes("|")
128
+ ? matcher.split("|").map(k => normalise(k.trim())).includes(query)
129
+ : query === normalise(matcher);
130
+ try {
131
+ const re = new RegExp(matcher); // NOT anchored
132
+ if (re.test(query)) return true;
133
+ for (const alias of aliasesOf(query)) if (re.test(alias)) return true;
134
+ return false;
135
+ } catch { return false }
136
+ ```
137
+
138
+ Consequences the documented table does not state:
139
+
140
+ 1. **`SessionStart` accepts alternation.** `startup|resume|compact` is a
141
+ single valid entry; three separate entries are unnecessary.
142
+ 2. The exact-list character class is `[a-zA-Z0-9_|]` — **no spaces, hyphens
143
+ or commas.** Anything containing them silently becomes a *regex*, and that
144
+ produces **two opposite failure modes**, not one. Measured:
145
+
146
+ | matcher | query | result | |
147
+ |---|---|---|---|
148
+ | `tyran-implementer` | `evil-tyran-implementer-nope` | `true` | matches **too much** — regexes are unanchored |
149
+ | `Edit, Write` | `Write` | `false` | matches **nothing at all** |
150
+ | `Edit, Write` | `Edit` | `false` | matches **nothing at all** |
151
+ | `Edit\|Write` | `Write` | `true` | the list syntax, which is what was meant |
152
+ | `[unclosed` | anything | `false` | invalid regex, caught locally |
153
+
154
+ The second mode is the dangerous one and it is the one that looks
155
+ harmless: a comma-and-space list is valid JSON, reads like a list, appears
156
+ in the manifest, validates — and **never fires**. A gate that silently
157
+ matches nothing is indistinguishable from a gate that is installed.
158
+ 3. An invalid regex matches **nothing** and only writes a debug line. It does
159
+ not disable other hooks; the failure is local to that matcher. A typo
160
+ removes exactly one gate, invisibly.
161
+
162
+ ## 5a. Which events actually fire, and when
163
+
164
+ - **`TaskCompleted` fires only in TEAM mode.** The platform raises it for the
165
+ in-progress tasks of the current teammate. Under plain subagent
166
+ orchestration it never fires, so a check placed only there is an **absent**
167
+ control, not a weak one. `hook-io.mjs` marks it `teamModeOnly: true` in
168
+ `EVENTS` and a test pins the flag; it stays available because it does
169
+ refuse when it does fire.
170
+ - **`SubagentStop` with an empty `agent_type` bypasses matcher filtering
171
+ entirely** and runs every hook registered for the event. A matcher there is
172
+ a narrowing that cannot be relied on — treat it as a hint, and re-check the
173
+ agent identity inside the gate.
174
+
175
+ ## 6. Sizes, timeouts, execution
176
+
177
+ - The 10 000-character cap is applied as `text.length` — **UTF-16 code
178
+ units**. It covers hook stdout, `additionalContext`, `systemMessage` and
179
+ `initialUserMessage`. Oversize content is not rejected: it is written to a
180
+ file and replaced by a reference, so injected context silently stops being
181
+ context.
182
+ - `timeout` in `hooks.json` is in **seconds**; the default when it is absent
183
+ is **600 s** (`SessionEnd` is the exception, 1.5 s).
184
+ - The command is spawned with `shell: true`, so the substituted
185
+ `${CLAUDE_PLUGIN_ROOT}` **must be quoted** or a path with a space breaks
186
+ the invocation. Input arrives on stdin as one JSON object followed by a
187
+ newline.
188
+ - Hooks matched to one event run **in parallel**, deduplicated by
189
+ `(pluginRoot, command)`. No hook may assume another has already written
190
+ anything.
191
+ - `permissionDecision: "defer"` is print-mode only; in an interactive session
192
+ it is ignored with a warning.
193
+
194
+ ---
195
+
196
+ # Second pass (S-E3-5, 2026-07-27, still v2.1.116)
197
+
198
+ Read out of the same binary while building `scripts/hooks-check.mjs`. Nothing
199
+ below contradicts the sections above; it fills in the parts that were marked
200
+ unknown, and one of them changes what a matcher means on five events.
201
+
202
+ ## 7. The matcher is not consulted at all when the event has no match query
203
+
204
+ The dispatcher builds a query per event with a `switch`, then filters:
205
+
206
+ ```
207
+ let z = ($ ? T.filter(k => !k.matcher || UB5($, k.matcher)) : T)
208
+ ```
209
+
210
+ The ternary is the whole story, and it has **two** consequences that are
211
+ documented nowhere:
212
+
213
+ 1. On `Stop`, `UserPromptSubmit`, `TaskCreated`, `TaskCompleted` and
214
+ `TeammateIdle` the switch assigns nothing, so `$` is `undefined` and the
215
+ matcher list is **not filtered**. A matcher on those events is inert: the
216
+ hook fires for every occurrence, whatever the matcher says.
217
+ 2. It is also the mechanism behind the empty-`agent_type` case S-E3-2 had to
218
+ work around. An empty string is falsy, so a `SubagentStop` carrying
219
+ `agent_type: ""` takes the same branch and runs **every** hook registered
220
+ for the event. It is not a special case for agents; it is one ternary.
221
+
222
+ Note also `!k.matcher` inside the filter: an absent or empty matcher always
223
+ matches, independently of `UB5`.
224
+
225
+ ## 8. The match query, per event
226
+
227
+ | event | field used as the match query |
228
+ |---|---|
229
+ | `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | `tool_name` |
230
+ | `SessionStart`, `ConfigChange` | `source` |
231
+ | `Setup`, `PreCompact`, `PostCompact` | `trigger` |
232
+ | `SubagentStart`, `SubagentStop` | `agent_type` |
233
+ | `UserPromptExpansion` | `command_name` |
234
+ | `Notification` | `notification_type` |
235
+ | `SessionEnd` | `reason` |
236
+ | `StopFailure` | `error` |
237
+ | `Elicitation`, `ElicitationResult` | `mcp_server_name` |
238
+ | `InstructionsLoaded` | `load_reason` |
239
+ | `FileChanged` | `basename(file_path)` |
240
+ | `Stop`, `UserPromptSubmit`, `TaskCreated`, `TaskCompleted`, `TeammateIdle` | **none — see §7** |
241
+
242
+ ## 9. Input schemas that S-E3-5 needed
243
+
244
+ - **`PreCompact`**: `{ hook_event_name, trigger: "manual" | "auto",
245
+ custom_instructions: string | null }`. The trigger is a closed enum, which
246
+ is what lets `doctor --hooks` call a matcher on this event dead rather than
247
+ merely unrecognised. `PostCompact` adds `compact_summary`.
248
+ - **`SubagentStart`**: `{ hook_event_name, agent_id, agent_type }` — both
249
+ strings, both required.
250
+ - **`SubagentStop`**: `{ hook_event_name, stop_hook_active, agent_id,
251
+ agent_transcript_path, agent_type, last_assistant_message? }`.
252
+ - **`PreToolUse`**: `{ hook_event_name, tool_name, tool_input, tool_use_id }`,
253
+ where `tool_input` is typed **`h.unknown()`**. The platform does not validate
254
+ its shape at all, so a hook that reads three known field names out of it is
255
+ correct for exactly today's tools. This is why `write-guard.mjs` walks every
256
+ string in the payload instead.
257
+
258
+ ## 10. The tools that write file content, as the platform enumerates them
259
+
260
+ For its own diff statistics the binary keeps a set of the tools whose input
261
+ ADDS content, and reads a different field from each:
262
+
263
+ ```
264
+ if (tool === Edit) return { added: count(q.new_string), removed: count(q.old_string) }
265
+ if (tool === Write) return { added: count(q.content), removed: 0 }
266
+ if (tool === NotebookEdit) return { added: count(q.new_source), removed: 0 }
267
+ ```
268
+
269
+ with the three constants resolving to `"Edit"`, `"Write"` and
270
+ `"NotebookEdit"`. That set — **not a judgement call, and not a list we
271
+ maintain** — is `FILE_WRITING_TOOLS` in `scripts/hooks-check.mjs`.
272
+
273
+ `MultiEdit` survives only in a legacy display-name table next to
274
+ `FileWriteTool` and `FileEditTool`; it is not a live tool in this build.
275
+
276
+ Related: `filePatternTools` is `["Read","Write","Edit","Glob","NotebookRead",
277
+ "NotebookEdit"]` and `bashPrefixTools` is `["Bash"]`.
278
+
279
+ ## 11. The tool alias table, and which way it is applied
280
+
281
+ ```
282
+ aliases = { Task: "Agent", KillShell: "TaskStop",
283
+ AgentOutputTool: "TaskOutput", BashOutputTool: "TaskOutput" }
284
+ ```
285
+
286
+ `normalise` is applied to the **matcher** and compared against the **raw
287
+ query**, so a `hooks.json` written against the old name `Task` still fires for
288
+ a query of `Agent`. In the regex branch the expression is additionally retried
289
+ against every alias **of the query**. Getting the direction backwards makes a
290
+ check report a live gate as dead, so both directions are pinned by a test.
291
+
292
+ ## 12. Deduplication is by (pluginRoot, command)
293
+
294
+ Two entries carrying the identical command string on one event run **once** —
295
+ including the second entry's matcher, which is what usually makes this a
296
+ mistake rather than a harmless redundancy.
297
+
298
+ ## 13. Keys on a hook ENTRY that disarm a gate (S-E3-5 round 2)
299
+
300
+ The entry schema, transcribed:
301
+
302
+ ```
303
+ h.object({
304
+ type: h.literal("command"),
305
+ command: h.string(),
306
+ if: <condition>,
307
+ shell: h.enum(["bash","powershell"]).optional(), // default bash
308
+ timeout: h.number().positive().optional(), // SECONDS
309
+ statusMessage: h.string().optional(),
310
+ once: h.boolean().optional(), // "runs once and is removed after execution"
311
+ async: h.boolean().optional(), // "runs in background without blocking"
312
+ asyncRewake: h.boolean().optional(), // "…wakes the model on exit code 2. Implies async."
313
+ rewakeMessage: h.string().min(1).optional(),
314
+ })
315
+ ```
316
+
317
+ Verified live, same payload, one key changed each time, on a BLOCKING event:
318
+
319
+ | entry | result |
320
+ |---|---|
321
+ | bare | refused; the file was never written |
322
+ | `+ "async": true` | **passed** — raw TAG characters landed on disk |
323
+ | `+ "if": "Bash(git *)"` | **passed** |
324
+ | `+ "shell": "powershell"` | **passed** |
325
+
326
+ In every case a logger registered on the same matcher fired normally, so this
327
+ is neither a matcher nor a dispatch failure — it is the entry. Every other
328
+ liveness property (file present, executable, shebang, matcher correct, event
329
+ real) still holds, which is what makes this the hardest variant to see.
330
+
331
+ ## 14. Correction to §11 — the alias table has FIVE rows, not four
332
+
333
+ ```
334
+ { Task: "Agent", KillShell: "TaskStop", AgentOutputTool: "TaskOutput",
335
+ BashOutputTool: "TaskOutput", ...(BRIEF_TOOL_NAME ? { Brief: BRIEF_TOOL_NAME } : {}) }
336
+ ```
337
+
338
+ `BRIEF_TOOL_NAME` is an imported constant equal to `"SendUserMessage"`, not a
339
+ runtime flag, so the spread is always active. The first transcription dropped
340
+ the row, and the consequence ran in the dangerous direction: a matcher of
341
+ `Brief` fires on the live platform while a check built on the four-row table
342
+ called it dead. **A live gate reported as dead** — in a file whose only value
343
+ is fidelity.
344
+
345
+ ## 15. The regex branch retries against the query's ALIASES, and it widens matchers
346
+
347
+ Because of the `for (const alias of aliasesOf(query))` retry, an UNANCHORED
348
+ alternation matches more than it reads as. Measured: `Write|Edit|NotebookEdit|Bash|mcp__.*`
349
+ also matches `TaskOutput` — whose alias `BashOutputTool` contains `Bash` — and
350
+ `WriteSomething`. Anchoring the whole alternation, `^(…)$`, removes both at no
351
+ cost. Any matcher meant to be exact should be anchored even when it looks like
352
+ a plain list, because one non-alphanumeric character anywhere in it moves the
353
+ whole string into this branch.
354
+
355
+ ## 16. A refused tool call teaches the model the next route (S-E3-5 round 3)
356
+
357
+ Not a platform fact, but a behavioural one measured on the live product, and
358
+ it changes how a gate has to be scoped.
359
+
360
+ A `PreToolUse` refusal on `Write` was followed, **in the same turn and with no
361
+ prompting**, by the model offering:
362
+
363
+ > "Would you like me to: 1. Use Bash (`printf` or `echo`) to write the file
364
+ > instead, which would bypass this guard?"
365
+
366
+ That route was then measured and it worked, because the command text was pure
367
+ ASCII and the forbidden character only existed after the shell expanded the
368
+ escape (`od -c` showed `363 240 201 201`, UTF-8 for U+E0041).
369
+
370
+ Two consequences worth carrying into every later gate:
371
+
372
+ 1. **The first thing tried after a refusal is another entrance.** Scoping a
373
+ gate to one tool is not a partial control; it is an advertisement.
374
+ 2. **The text of a command is not the effect of a command.** Any gate reading
375
+ `Bash` input is reading a program, and a program's output cannot be known
376
+ without running it. Escape decoding closes the obvious spelling; the class
377
+ stays open by construction and must be declared, not implied.