@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.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CHANGELOG.md +245 -0
- package/LICENSE +201 -0
- package/README.md +468 -0
- package/agents/.gitkeep +0 -0
- package/agents/implementer.md +60 -0
- package/agents/retro.md +88 -0
- package/agents/reviewer.md +55 -0
- package/agents/scout.md +60 -0
- package/bin/tyran.mjs +172 -0
- package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
- package/hooks/hooks.json +83 -0
- package/hooks/scripts/.gitkeep +0 -0
- package/hooks/scripts/evidence-gate.mjs +705 -0
- package/hooks/scripts/hook-io.mjs +813 -0
- package/hooks/scripts/policy-gate.mjs +1402 -0
- package/hooks/scripts/pre-compact.mjs +211 -0
- package/hooks/scripts/retro-gate.mjs +191 -0
- package/hooks/scripts/secrets-gate.mjs +1683 -0
- package/hooks/scripts/session-start.mjs +358 -0
- package/hooks/scripts/write-guard.mjs +475 -0
- package/package.json +52 -0
- package/scripts/desc-budget.mjs +139 -0
- package/scripts/doctor.mjs +1267 -0
- package/scripts/hooks-check.mjs +1312 -0
- package/scripts/invisible.mjs +346 -0
- package/scripts/journal.mjs +747 -0
- package/scripts/project.mjs +981 -0
- package/scripts/scan-control-chars.mjs +547 -0
- package/scripts/scan-repo.mjs +287 -0
- package/scripts/schema.mjs +467 -0
- package/scripts/stop-check.mjs +89 -0
- package/scripts/tiers.mjs +324 -0
- package/scripts/yaml-lite.mjs +383 -0
- package/skills/browser-check/SKILL.md +118 -0
- package/skills/code-review/SKILL.md +83 -0
- package/skills/deslop/SKILL.md +97 -0
- package/skills/doctor/SKILL.md +35 -0
- package/skills/fidelity-gate/SKILL.md +132 -0
- package/skills/hello/SKILL.md +16 -0
- package/skills/pr-feedback/SKILL.md +85 -0
- package/skills/prompt-tuning/SKILL.md +102 -0
- package/skills/retro/SKILL.md +69 -0
- package/skills/root-cause/SKILL.md +89 -0
- package/skills/run/SKILL.md +285 -0
- package/skills/setup/SKILL.md +79 -0
- package/skills/skill-writing/SKILL.md +99 -0
- package/skills/status/SKILL.md +31 -0
- package/templates/.gitkeep +0 -0
- package/templates/config.yaml +44 -0
- package/templates/knowledge.yaml +23 -0
- package/templates/policies/autonomy.yaml +61 -0
- 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.
|
package/agents/scout.md
ADDED
|
@@ -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.
|