@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,705 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * evidence-gate — the evidence contract stops being a request.
4
+ *
5
+ * In v1 the contract was a sentence in prose: "a report MUST contain raw
6
+ * command output". An agent that had not read it, or had read it and moved
7
+ * on, handed back "tests are green, everything works" and that went through.
8
+ * This gate makes that specific ending mechanical: a `SubagentStop` with no
9
+ * raw output in the report is REFUSED, and the refusal names what to add.
10
+ *
11
+ * ## The honest boundary, stated first because it is the important part
12
+ *
13
+ * **This gate blocks SILENCE, not FORGERY.** An agent that invents the text
14
+ * "232 passed / 0 failed" walks straight through it. What the gate changes is
15
+ * the price of a hollow report: it can no longer be produced by omission, it
16
+ * has to be fabricated deliberately. That is a real difference and it is the
17
+ * whole of the difference. Any wider claim in a README would be the same
18
+ * defect this repository exists to remove, only printed on the box.
19
+ *
20
+ * ## What it costs on real work — measured, not assumed
21
+ *
22
+ * Calibrated against 128 real subagent final messages recovered from this
23
+ * machine's Claude Code transcripts (the same string the platform hands this
24
+ * hook as `last_assistant_message`), of which 55 came from the two enforced
25
+ * roles:
26
+ *
27
+ * tyran-implementer 32/33 carry evidence
28
+ * tyran-reviewer 21/22 carry evidence
29
+ * ------------------ ---------------------
30
+ * enforced total 53/55 (96%)
31
+ *
32
+ * Both misses were inspected by hand and neither is a report: one is a
33
+ * fragment of a system prompt that leaked into a text block, the other is the
34
+ * platform's "you've hit your weekly limit" notice. On the 53 messages that
35
+ * are actually reports the criterion is 53/53.
36
+ *
37
+ * The exempt roles are the other half of the argument, and they are why role
38
+ * scope is not decoration: tyran-scout carries evidence in 2/15 messages,
39
+ * Explore in 1/20, general-purpose in 0/16. Enforcing on them would bounce
40
+ * correct work eight times out of ten, and a gate that bounces correct work
41
+ * is switched off within a week and then protects nothing.
42
+ *
43
+ * ## Why the criterion is a raw pattern and not a judgement
44
+ *
45
+ * Every signal below requires a NUMBER next to a keyword, because that is the
46
+ * shape of machine output and it is not the shape of a summary. "All tests
47
+ * pass" does not match. "12 passed" does. The line between them is the entire
48
+ * point, and it has to be drawable by a regex or it is not enforceable.
49
+ */
50
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
51
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+
54
+ import { append } from '../../scripts/journal.mjs';
55
+ import { escapeInvisible } from '../../scripts/invisible.mjs';
56
+ import { PASS, field, main, runGate } from './hook-io.mjs';
57
+
58
+ /**
59
+ * This gate's own budget, well under the `timeout` its hooks.json entry
60
+ * declares (ADR-22 point 2); a test reads both numbers and refuses to let
61
+ * them cross.
62
+ *
63
+ * It is deliberately larger than the session-start probe's 4 s, for one
64
+ * measured reason: the journal's cross-process mutex waits up to 5 s for a
65
+ * contended lock, and it waits SYNCHRONOUSLY (`Atomics.wait`). Nothing on
66
+ * this thread runs during that wait, so the deadline timer cannot fire — the
67
+ * third row of the deadline table in docs/hooks.md. The budget is therefore
68
+ * sized to contain the worst case that the lock itself bounds, rather than to
69
+ * be woken by a timer that cannot run.
70
+ */
71
+ export const DEADLINE_MS = 8000;
72
+
73
+ /**
74
+ * The largest journal this gate will touch, checked with `statSync` BEFORE
75
+ * anything reads it.
76
+ *
77
+ * `journal.append` reads the whole file to clamp its timestamp, and an
78
+ * unbounded synchronous read inside a gate is the one failure the runtime
79
+ * cannot rescue: the platform kills the process and never reads the refusal
80
+ * it had already written (ADR-22 correction 2). So the size check is not
81
+ * caution, it is the only available mechanism.
82
+ */
83
+ export const MAX_JOURNAL_BYTES = 16 * 1024 * 1024;
84
+
85
+ /** Refuse to hunt through an absurd `.tyran/state`; that is not a repo we know. */
86
+ export const MAX_INITIATIVES = 64;
87
+
88
+ /** Code points of foreign text this gate is willing to copy into the journal. */
89
+ export const MAX_RECORDED_POINTS = 200;
90
+
91
+ /**
92
+ * How much reason the `none-required` hatch has to carry. Short enough that a
93
+ * real sentence always clears it, long enough that "x" does not.
94
+ */
95
+ export const MIN_HATCH_REASON = 10;
96
+
97
+ // ---------------------------------------------------------------- criterion
98
+
99
+ /**
100
+ * The evidence signals, as raw patterns.
101
+ *
102
+ * Each is anchored on a DIGIT adjacent to a keyword. That is the whole
103
+ * criterion and it is deliberately wide rather than narrow: the failure mode
104
+ * of a strict gate is a user who switches it off, and a gate that is off
105
+ * protects nothing at all.
106
+ */
107
+ export const SIGNALS = Object.freeze([
108
+ // `EXIT=0`, `exit code 0`, `exit status 1`, `exit: 137`
109
+ Object.freeze({ name: 'exit-code', re: /\bexit(?:[ _-]?(?:code|status))?\s*[:=]?\s*\d{1,3}\b/i }),
110
+ // `12 passed`, `0 failed`, `3 skipped` — vitest, jest, pytest, playwright
111
+ Object.freeze({ name: 'test-count', re: /\b\d+\s+(?:passed|failed|passing|failing|skipped|pending)\b/i }),
112
+ // `# pass 343`, `# fail 0`, `# tests 343` — node --test's TAP summary
113
+ Object.freeze({ name: 'tap-count', re: /^[\s#]*(?:pass|fail|tests|suites)\s+\d+\s*$/im }),
114
+ // `ok 7 - name`, `not ok 3 - name` — TAP body
115
+ Object.freeze({ name: 'tap-line', re: /^\s*(?:not )?ok\s+\d+\b/im }),
116
+ // `Tests: 28`, `Suites: 4`, `assertions = 12`
117
+ Object.freeze({ name: 'labelled-count', re: /\b(?:tests?|suites?|assertions?|checks?)\s*[:=]\s*\d+/i }),
118
+ // `6 / 6 passed`, `18/20 passing`
119
+ Object.freeze({ name: 'ratio', re: /\b\d+\s*\/\s*\d+\s+(?:passed|passing|ok|green)\b/i }),
120
+ // an explicit block, but never the hatch — that is counted separately
121
+ Object.freeze({ name: 'evidence-block', re: /^[\s>*-]*EVIDENCE\s*:(?!\s*none-required\b)\s*\S/im }),
122
+ ]);
123
+
124
+ /**
125
+ * A CLAIMED hatch: the keyword at the very start of a line.
126
+ *
127
+ * Column zero, no leading whitespace, no `>` and no list bullet. The first
128
+ * version allowed `[\s>*-]*` in front and it was a hole big enough to walk
129
+ * through: a review found four natural reports that were granted an exemption
130
+ * they never asked for — the hatch quoted in a blockquote, shown in a code
131
+ * block, listed as a bullet, and a report that merely DOCUMENTED the syntax.
132
+ * A hatch is a declaration the agent makes about its own work, so it has to
133
+ * look like one and not like a mention of one.
134
+ */
135
+ const HATCH_LINE_RE = /^EVIDENCE:[ \t]*none-required\b[ \t]*(.*)$/i;
136
+
137
+ /** A fenced code block delimiter, in either Markdown spelling. */
138
+ const FENCE_RE = /^ {0,3}(?:```|~~~)/;
139
+
140
+ /** `<why there was nothing to run>` and friends — shape, not content. */
141
+ const PLACEHOLDER_RE = /<[^<>]*>/g;
142
+
143
+ /** Which signals `text` carries. Empty array means "no evidence". */
144
+ export function findEvidence(text) {
145
+ const signals = [];
146
+ for (const s of SIGNALS) if (s.re.test(text)) signals.push(s.name);
147
+ return signals;
148
+ }
149
+
150
+ /**
151
+ * The hatch, if the report DECLARES it.
152
+ *
153
+ * `present` and `reason` are separate answers on purpose: a hatch with no
154
+ * usable reason is a different situation from no hatch at all, and it gets a
155
+ * different refusal, because "you forgot the why" and "you forgot the
156
+ * evidence" are different mistakes and a gate that conflates them teaches the
157
+ * wrong lesson.
158
+ *
159
+ * Three conditions, each of which a review demonstrated to be load-bearing:
160
+ *
161
+ * 1. **column zero** — see `HATCH_LINE_RE`;
162
+ * 2. **not inside a fenced code block** — a report showing the syntax to a
163
+ * reader is documentation, not a claim about its own work;
164
+ * 3. **the reason may not be a placeholder.** This one is the sharpest,
165
+ * because the trigger was text THIS GATE INJECTS: the refusal shows the
166
+ * hatch with `<why there was nothing to run>` in it, 30 characters, which
167
+ * cleared a bare length check. Pasting the refusal back therefore granted
168
+ * an exemption — the gate handing out the key along with the lock. Angle
169
+ * bracketed spans are removed before the length is measured, so what has
170
+ * to be long enough is what the agent actually wrote.
171
+ */
172
+ export function findHatch(text) {
173
+ let inFence = false;
174
+ for (const line of String(text).split('\n')) {
175
+ if (FENCE_RE.test(line)) {
176
+ inFence = !inFence;
177
+ continue;
178
+ }
179
+ if (inFence) continue;
180
+ const m = HATCH_LINE_RE.exec(line);
181
+ if (m === null) continue;
182
+ const reason = m[1].trim();
183
+ const written = reason.replace(PLACEHOLDER_RE, ' ').trim();
184
+ return { present: true, reason: written.length >= MIN_HATCH_REASON ? reason : null };
185
+ }
186
+ return { present: false, reason: null };
187
+ }
188
+
189
+ // -------------------------------------------------------------- role scope
190
+
191
+ /**
192
+ * Who the contract binds, keyed on `agent_type` — a field written by the
193
+ * PLATFORM, never by the model.
194
+ *
195
+ * That distinction is the reason this table exists at all. A criterion
196
+ * derived from the report's CONTENT can be defeated by the report's content:
197
+ * if "I am only a scout" exempted an agent, every agent would write it. This
198
+ * initiative measured that lesson three times before it wrote it down.
199
+ *
200
+ * Exact strings, never a substring test: an unanchored match on `implementer`
201
+ * would also bind an agent called `evil-tyran-implementer-nope`.
202
+ *
203
+ * The namespaced half of the table is BUILT FROM THE MANIFEST, not written
204
+ * out. A plugin agent's `agent_type` is `<plugin.json name>:<agent name>`
205
+ * (measured), so a literal `tyran:` here is a copy of the manifest kept in
206
+ * sync by nobody. A review measured what that costs: renaming the plugin to
207
+ * `tyran-conductor` left the whole suite green at 398/398 while the gate
208
+ * stopped enforcing anything — an implementer reported *"all tests are green
209
+ * and everything works"* and the journal recorded zero lines. That is the
210
+ * canonical failure this story is named after, passing in silence, triggered
211
+ * by one word in a manifest that the truth file warns about by name.
212
+ */
213
+ export const ROLE_BY_NAME = Object.freeze(
214
+ Object.assign(Object.create(null), {
215
+ implementer: 'enforce',
216
+ reviewer: 'enforce',
217
+ scout: 'exempt',
218
+ retro: 'exempt',
219
+ }),
220
+ );
221
+
222
+ /**
223
+ * The v1 spellings, which are NOT derived from the manifest and must not be.
224
+ *
225
+ * These are project-level agents in `.claude/agents/`, whose `agent_type` is
226
+ * the frontmatter `name`. The plugin does not own those names, so renaming the
227
+ * plugin cannot rename them, and a repo mid-migration runs both kinds.
228
+ */
229
+ export const LEGACY_V1_AGENTS = Object.freeze({
230
+ 'tyran-implementer': 'enforce',
231
+ 'tyran-reviewer': 'enforce',
232
+ 'tyran-scout': 'exempt',
233
+ 'tyran-retro': 'exempt',
234
+ });
235
+
236
+ /** The scope table for a given plugin namespace. Pure, so a rename is testable. */
237
+ export function buildRoleScope(namespace) {
238
+ const scope = Object.create(null);
239
+ for (const [name, treatment] of Object.entries(ROLE_BY_NAME)) {
240
+ scope[`${namespace}:${name}`] = treatment;
241
+ }
242
+ for (const [name, treatment] of Object.entries(LEGACY_V1_AGENTS)) scope[name] = treatment;
243
+ return Object.freeze(scope);
244
+ }
245
+
246
+ /** Bounded read of the plugin manifest: it is tiny, and a gate reads nothing unbounded. */
247
+ export const MAX_MANIFEST_BYTES = 64 * 1024;
248
+
249
+ /**
250
+ * The plugin's own name, straight out of the manifest that defines it.
251
+ *
252
+ * `fallback` is what happens when the manifest cannot be read at all — a
253
+ * partial install, a permission error. It keeps today's behaviour rather than
254
+ * classifying every plugin agent as out-of-scope, which would be the silent
255
+ * disarm this whole change exists to remove. A test asserts the real manifest
256
+ * IS readable and IS the name the shipped table was built from, so the
257
+ * fallback cannot become the quiet normal case.
258
+ */
259
+ export function readPluginName(root, fallback = 'tyran') {
260
+ const file = join(root, '.claude-plugin', 'plugin.json');
261
+ try {
262
+ if (statSync(file).size > MAX_MANIFEST_BYTES) return fallback;
263
+ const name = JSON.parse(readFileSync(file, 'utf8'))?.name;
264
+ return typeof name === 'string' && name !== '' ? name : fallback;
265
+ } catch {
266
+ return fallback;
267
+ }
268
+ }
269
+
270
+ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
271
+
272
+ /** The namespace every plugin agent's `agent_type` is prefixed with. */
273
+ export const PLUGIN_NAMESPACE = readPluginName(PLUGIN_ROOT);
274
+
275
+ export const ROLE_SCOPE = buildRoleScope(PLUGIN_NAMESPACE);
276
+
277
+ /**
278
+ * `enforce`, `exempt`, or `out-of-scope`.
279
+ *
280
+ * The third answer is not a weaker second one, and the difference is
281
+ * load-bearing for ADR-19. An `exempt` agent is one of ours that the contract
282
+ * deliberately releases — that is an exclusion, so it is counted in the
283
+ * journal. An `out-of-scope` agent (`Explore`, `general-purpose`, another
284
+ * plugin's agent, or the empty string the platform is documented to be able
285
+ * to send) was never bound by this contract in the first place; recording an
286
+ * exemption for it would bury the real exemptions in noise.
287
+ *
288
+ * The empty string matters more than it looks. Measured: when `agent_type` is
289
+ * empty the platform SKIPS matcher filtering entirely and runs every hook
290
+ * registered for the event. A gate that trusted its matcher would then be
291
+ * enforcing the implementer contract on every agent in the system. This
292
+ * function is why the matcher in hooks.json is deliberately a catch-all: the
293
+ * decision lives here, where an empty type is a case rather than an accident.
294
+ */
295
+ export function classifyAgent(agentType, scope = ROLE_SCOPE) {
296
+ if (typeof agentType !== 'string' || agentType === '') return 'out-of-scope';
297
+ return Object.hasOwn(scope, agentType) ? scope[agentType] : 'out-of-scope';
298
+ }
299
+
300
+ // ------------------------------------------------------------------ verdict
301
+
302
+ /**
303
+ * Why a report was refused. Codes, not sentences, so that the SENTENCE is
304
+ * chosen from a closed set at the edge — see `REFUSALS`.
305
+ */
306
+ export const DENY = Object.freeze({
307
+ NO_EVIDENCE: 'no-evidence',
308
+ HATCH_WITHOUT_REASON: 'hatch-without-reason',
309
+ HATCH_NOT_RECORDABLE: 'hatch-not-recordable',
310
+ REPORT_NOT_A_STRING: 'report-not-a-string',
311
+ });
312
+
313
+ /**
314
+ * The whole decision, as a pure function of the hook's input.
315
+ *
316
+ * Pure on purpose: the journal write and the refusal text are edges, and a
317
+ * verdict that can be tested without a filesystem is a verdict that gets
318
+ * tested for the cases that matter (an interrupted agent, an empty agent
319
+ * type, a second bounce) rather than only for the easy one.
320
+ */
321
+ export function assessReport(input) {
322
+ // An interrupted agent has no report to judge.
323
+ //
324
+ // Measured: on the abort path `last_assistant_message` is ABSENT, not empty
325
+ // (`permission_mode` is missing too). A gate that reads "absent" as "no
326
+ // evidence" refuses an agent that cannot possibly comply, and the platform
327
+ // hands it another turn — which it also cannot use. That is the loop the
328
+ // fuse exists to survive, entered on purpose by our own code.
329
+ const report = field(input, 'last_assistant_message');
330
+ if (report === undefined || report === null) {
331
+ return { outcome: 'exempt-interrupted', signals: [], reason: null };
332
+ }
333
+ if (typeof report !== 'string') {
334
+ return { outcome: 'deny', signals: [], reason: null, code: DENY.REPORT_NOT_A_STRING };
335
+ }
336
+
337
+ // The declared hatch wins over incidental evidence, deliberately.
338
+ //
339
+ // If an agent says "there was nothing to run", that is the thing to count,
340
+ // even when the prose happens to contain a number that looks like output.
341
+ // The alternative silently reclassifies a declared exemption as a pass and
342
+ // the count of "how often did someone use the hatch" stops being true.
343
+ const hatch = findHatch(report);
344
+ const signals = findEvidence(report);
345
+ if (hatch.present && hatch.reason !== null) {
346
+ return { outcome: 'exempt-hatch', signals, reason: hatch.reason };
347
+ }
348
+ if (signals.length > 0) return { outcome: 'pass', signals, reason: null };
349
+ if (hatch.present) {
350
+ return { outcome: 'deny', signals, reason: null, code: DENY.HATCH_WITHOUT_REASON };
351
+ }
352
+ return { outcome: 'deny', signals, reason: null, code: DENY.NO_EVIDENCE };
353
+ }
354
+
355
+ export function judge(input) {
356
+ // 1. Scope first, so that an agent this contract never bound produces no
357
+ // verdict to record. Out-of-scope is not a quieter exemption: it is the
358
+ // absence of an obligation, and recording one `gate` event per Explore
359
+ // call would bury the exemptions that do mean something.
360
+ const agentType = field(input, 'agent_type');
361
+ const scope = classifyAgent(agentType);
362
+ if (scope === 'out-of-scope') return { outcome: 'out-of-scope', role: null, signals: [], reason: null };
363
+
364
+ const assessed =
365
+ scope === 'exempt' ? { outcome: 'exempt-role', signals: [], reason: null } : assessReport(input);
366
+
367
+ // 2. The anti-loop fuse. It overrides the verdict, and only the verdict.
368
+ //
369
+ // Measured: `stop_hook_active` is true from the SECOND SubagentStop for the
370
+ // same agent onward, i.e. it is exactly "you already bounced this one".
371
+ // This is what caps the gate's cost at one extra turn per agent. A gate that
372
+ // can bounce forever is worse than no gate: the user kills the run, and then
373
+ // removes the gate. It also covers cases we do not control — an agent
374
+ // stopped by ANOTHER hook, or one that hit a rate limit and whose next
375
+ // report will be identical to this one.
376
+ //
377
+ // The report is still ASSESSED, and the answer travels as `wouldBe`. The
378
+ // first live run of this gate is why: the agent was refused, came back with
379
+ // a correct `EVIDENCE: none-required`, and the journal recorded only "fuse"
380
+ // — so a legitimate hatch use vanished from the count the hatch exists to
381
+ // feed. Releasing an agent and forgetting what it did are two different
382
+ // things, and only the first one is the fuse's job.
383
+ if (field(input, 'stop_hook_active') === true) {
384
+ return { ...assessed, outcome: 'fuse', role: agentType, wouldBe: assessed.outcome };
385
+ }
386
+
387
+ return { ...assessed, role: agentType };
388
+ }
389
+
390
+ // ------------------------------------------------------------------ journal
391
+
392
+ /**
393
+ * Where this repo's initiative journal is, or why there is none.
394
+ *
395
+ * Every filesystem answer here is bounded before it is taken: the number of
396
+ * initiative directories, and the size of the journal itself. See
397
+ * `MAX_JOURNAL_BYTES` for why that is a mechanism rather than tidiness.
398
+ */
399
+ export function locateJournal(repoRoot, { maxBytes = MAX_JOURNAL_BYTES } = {}) {
400
+ if (typeof repoRoot !== 'string' || repoRoot === '' || !isAbsolute(repoRoot)) {
401
+ return { file: null, why: 'no-repo-root' };
402
+ }
403
+ const root = join(repoRoot, '.tyran', 'state');
404
+ let entries;
405
+ try {
406
+ entries = readdirSync(root, { withFileTypes: true });
407
+ } catch {
408
+ return { file: null, why: 'no-state-dir' };
409
+ }
410
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
411
+ if (dirs.length === 0) return { file: null, why: 'no-initiative' };
412
+ if (dirs.length > MAX_INITIATIVES) return { file: null, why: 'too-many-initiatives' };
413
+
414
+ let best = null;
415
+ let oversized = 0;
416
+ for (const name of dirs) {
417
+ const file = join(root, name, 'journal.jsonl');
418
+ let mtimeMs;
419
+ try {
420
+ const st = statSync(file);
421
+ if (!st.isFile()) continue;
422
+ if (st.size > maxBytes) {
423
+ oversized++;
424
+ continue;
425
+ }
426
+ mtimeMs = st.mtimeMs;
427
+ } catch {
428
+ // No journal yet: a fresh initiative directory is still a valid target,
429
+ // ranked by the directory's own mtime. `append` creates the file.
430
+ try {
431
+ mtimeMs = statSync(join(root, name)).mtimeMs;
432
+ } catch {
433
+ continue;
434
+ }
435
+ }
436
+ if (best === null || mtimeMs > best.mtimeMs) best = { file, init: name, mtimeMs };
437
+ }
438
+ if (best === null) {
439
+ return { file: null, why: oversized > 0 ? 'journal-too-large' : 'no-initiative' };
440
+ }
441
+ // More than one candidate means this gate GUESSED which initiative the
442
+ // agent belonged to. The guess is written into the event rather than
443
+ // hidden, so a reader of the journal can see that the attribution is
444
+ // inferred (ADR-19: an exclusion — or an assumption — is never silent).
445
+ return { file: best.file, init: best.init, inferred: dirs.length > 1 ? dirs.length : 0 };
446
+ }
447
+
448
+ /**
449
+ * Foreign text on its way into our own state file.
450
+ *
451
+ * Bounded and escaped AT THE SOURCE rather than at each reader. The journal's
452
+ * CLI escapes on print and the projection generator escapes on render, so
453
+ * removing this would leave every test green — which is exactly the shape
454
+ * ADR-20 correction 1 calls "survived through redundancy of defence", i.e. a
455
+ * guarantee resting on every future consumer remembering. The reason string
456
+ * comes from an agent's report, which routinely quotes foreign repositories.
457
+ *
458
+ * EVERY sink of foreign text in this gate goes through here. Enumerated
459
+ * rather than remembered, because the last time a hole in this repo was
460
+ * patched, the identical hole two lines away survived:
461
+ *
462
+ * data.reason <- the hatch reason, written by the model
463
+ * data.agent_type <- written by the platform, still not ours
464
+ * data.agent_id <- ditto
465
+ * init <- a DIRECTORY NAME off the filesystem, and the one that
466
+ * was missed on the first pass: a directory called
467
+ * `demo<U+202E>` would have put a raw bidi override into
468
+ * every event this gate writes
469
+ *
470
+ * The remaining fields are this file's own constants (`kind`, `result`,
471
+ * `would_be`, `code`, `signals`, `actor`) or an integer, and constants are not
472
+ * a sink.
473
+ */
474
+ export function forJournal(text) {
475
+ return escapeInvisible([...String(text)].slice(0, MAX_RECORDED_POINTS).join(''));
476
+ }
477
+
478
+ /**
479
+ * Record the verdict as a `gate` event.
480
+ *
481
+ * `gate` and not `report`, and this is a DELIBERATE correction to the story's
482
+ * wording rather than an oversight. `report` is half of the spawn-report
483
+ * pairing (ADR-18) and its only correlator is the agent NAME the conductor
484
+ * chose when it spawned the agent. A hook knows `agent_id` (a platform UUID)
485
+ * and `agent_type`; it does not know that name and cannot invent it. Writing
486
+ * `report` here would therefore either produce an orphan report on every
487
+ * subagent stop — a permanent warning in `journal validate` and in doctor —
488
+ * or, on an accidental name collision, close a spawn the conductor was still
489
+ * tracking. `gate` carries `{kind, result}`, which is exactly this event, and
490
+ * it has no pairing semantics to corrupt.
491
+ *
492
+ * Returns true when the event was written. Callers act on that answer; see
493
+ * `apply` for which exemptions require it and which do not.
494
+ */
495
+ export function recordGate(journal, event, { write = append } = {}) {
496
+ if (journal.file === null) return false;
497
+ try {
498
+ write(journal.file, event);
499
+ return true;
500
+ } catch {
501
+ // An unwritable or corrupt journal is a state failure, not a verdict.
502
+ // What it means for the verdict is decided in `apply`, once, where the
503
+ // asymmetry can be read and argued.
504
+ return false;
505
+ }
506
+ }
507
+
508
+ // ------------------------------------------------------------------ refusal
509
+
510
+ /**
511
+ * The refusal texts, in full, as a closed set.
512
+ *
513
+ * NOTHING from the report reaches them. Not a quoted line, not a length, not
514
+ * the agent's own name for itself. A gate's denial is injected straight into
515
+ * a model's context, and this initiative has already measured what happens
516
+ * when a gate echoes its input: a rule NAME in a gate's output was enough to
517
+ * plant an instruction. The runtime sanitizes on top of this; that is a
518
+ * second layer, not this one's excuse.
519
+ *
520
+ * A second property, and a test pins it: none of these texts SATISFIES the
521
+ * criterion. The examples are written with placeholders (`EXIT=<code>`,
522
+ * `<N> passed`) which carry no digits, so an agent that pastes the refusal
523
+ * back verbatim is refused again rather than let through by its own citation.
524
+ */
525
+ export const REFUSALS = Object.freeze(
526
+ Object.assign(Object.create(null), {
527
+ [DENY.NO_EVIDENCE]:
528
+ 'REFUSED by the tyran evidence gate: this report carries no raw command output.\n' +
529
+ '\n' +
530
+ 'The evidence contract is mechanical, not stylistic. "tests are green" is\n' +
531
+ 'not evidence; the unedited output of a command you actually ran is. Add at\n' +
532
+ 'least one line of it. Any ONE of these forms is enough:\n' +
533
+ '\n' +
534
+ ' exit code EXIT=<code> or "exit code <code>", "exit status <code>"\n' +
535
+ ' test counter <N> passed or "<N> failed", "# pass <N>", "ok <N>"\n' +
536
+ ' labelled count Tests: <N> or "Suites: <N>", "checks = <N>"\n' +
537
+ '\n' +
538
+ 'If there was genuinely nothing to measure - a scouting pass, an analysis,\n' +
539
+ 'an answer to a question - say so, with a reason, on a line that starts at\n' +
540
+ 'the FIRST COLUMN and is not inside a code block:\n' +
541
+ '\n' +
542
+ 'EVIDENCE: none-required <why there was nothing to run>\n' +
543
+ '\n' +
544
+ 'Replace the angle brackets with your own words; a line that is still all\n' +
545
+ 'placeholder does not count as a reason. That line is not a formality\n' +
546
+ 'either: every use of it is recorded in the initiative journal, so\n' +
547
+ 'exemptions are counted rather than assumed.\n' +
548
+ '\n' +
549
+ 'What this gate does NOT do: it cannot tell real output from invented\n' +
550
+ 'output. It blocks silence, not forgery.',
551
+ [DENY.HATCH_WITHOUT_REASON]:
552
+ 'REFUSED by the tyran evidence gate: the "none-required" exemption was\n' +
553
+ 'claimed without a reason.\n' +
554
+ '\n' +
555
+ 'The exemption is legitimate and it is recorded. What is recorded has to\n' +
556
+ 'say something, or the record answers no question later. Write it at the\n' +
557
+ 'first column of a line of its own, outside any code block:\n' +
558
+ '\n' +
559
+ 'EVIDENCE: none-required <why there was nothing to run>\n' +
560
+ '\n' +
561
+ 'A phrase is enough - "read-only reconnaissance, no code changed" - but it\n' +
562
+ 'has to be YOUR phrase. The angle-bracketed placeholder above is not one,\n' +
563
+ 'which is why pasting this message back verbatim lands you here again.\n' +
564
+ '\n' +
565
+ 'If something WAS run, paste its raw output instead and drop the exemption.',
566
+ [DENY.HATCH_NOT_RECORDABLE]:
567
+ 'REFUSED by the tyran evidence gate: the "none-required" exemption could\n' +
568
+ 'not be recorded, so it cannot be granted.\n' +
569
+ '\n' +
570
+ 'This gate does not keep the exemption in its head - it writes it to the\n' +
571
+ 'initiative journal, and that write failed or had nowhere to go (no\n' +
572
+ '.tyran/state/<initiative>/journal.jsonl under this repository, or the\n' +
573
+ 'journal is unwritable or larger than this gate will read).\n' +
574
+ '\n' +
575
+ 'An exemption nobody can count is a silent exclusion, which is the exact\n' +
576
+ 'failure this project refuses to ship. Two ways forward:\n' +
577
+ '\n' +
578
+ ' - paste the raw output of something you ran, and drop the exemption; or\n' +
579
+ ' - run inside a Tyran initiative, whose journal can hold the record.\n' +
580
+ '\n' +
581
+ 'Exemptions the PLATFORM imposes - a scout role, an interrupted agent - are\n' +
582
+ 'never blocked this way. Only the one an agent claims for itself is.',
583
+ [DENY.REPORT_NOT_A_STRING]:
584
+ 'REFUSED by the tyran evidence gate: the platform supplied a report field\n' +
585
+ 'that is not text, so there is nothing this gate can read.\n' +
586
+ '\n' +
587
+ 'This is a platform or configuration fault rather than a fault in the\n' +
588
+ 'work. It is a refusal and not a pass because a check that cannot run must\n' +
589
+ 'never read as a check that succeeded. Re-running the agent is the usual\n' +
590
+ 'fix; if it recurs, the hook input shape has changed and hooks/scripts/\n' +
591
+ 'evidence-gate.mjs needs to be looked at.',
592
+ }),
593
+ );
594
+
595
+ // -------------------------------------------------------------------- edges
596
+
597
+ /**
598
+ * The repository this stop happened in.
599
+ *
600
+ * Deliberately its own small function rather than an import of the
601
+ * session-start probe's `resolveRepoRoot`: pulling that in would drag the
602
+ * projection generator (a thousand lines) into the startup of a hook that
603
+ * runs on every subagent stop, to reuse eight lines of path validation. The
604
+ * "one answer" rule (ADR-21) is about a RULE that two callers could answer
605
+ * differently — the invisibility set, the pairing rule. "Is this string an
606
+ * absolute path to a directory" is not that; it has one answer in the
607
+ * standard library.
608
+ */
609
+ export function resolveRepoRoot(input, env = process.env, cwd = process.cwd()) {
610
+ for (const candidate of [field(input, 'cwd'), env.CLAUDE_PROJECT_DIR, cwd]) {
611
+ if (typeof candidate !== 'string' || candidate === '' || !isAbsolute(candidate)) continue;
612
+ try {
613
+ if (statSync(candidate).isDirectory()) return candidate;
614
+ } catch {
615
+ /* try the next candidate */
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+
621
+ /**
622
+ * The verdict, its record, and the asymmetry between them.
623
+ *
624
+ * ## Does a broken journal block a correct report?
625
+ *
626
+ * The story asks for the argument, not the answer, so here it is. The two
627
+ * failures are not symmetrical:
628
+ *
629
+ * - refusing a report that HAS evidence because the bookkeeping failed costs
630
+ * the agent a turn for something it cannot fix and did not cause. The
631
+ * evidence is already in the transcript; the journal line is an audit
632
+ * convenience. Bouncing on it teaches users that the gate fires for
633
+ * reasons unrelated to their work, which is how a gate gets disabled.
634
+ * - granting an exemption that could not be recorded costs the SYSTEM its
635
+ * only trace of it. "How many times did someone opt out of the evidence
636
+ * contract in this initiative" is a question the journal is supposed to
637
+ * answer, and an unrecorded exemption makes the answer quietly wrong.
638
+ *
639
+ * So the record gates the exemption an AGENT CHOOSES for itself, and nothing
640
+ * else. Role exemptions and interrupted agents are imposed by the platform,
641
+ * cannot be claimed by writing a sentence, and pass whether or not the
642
+ * journal accepted the note. The worst case for the one gated exemption is a
643
+ * single extra turn, because the fuse releases the second stop.
644
+ */
645
+ export function apply(input, { locate = locateJournal, record = recordGate } = {}) {
646
+ const verdict = judge(input);
647
+ const repoRoot = resolveRepoRoot(input);
648
+ const journal = repoRoot === null ? { file: null, why: 'no-repo-root' } : locate(repoRoot);
649
+
650
+ // No timestamp is supplied: `append` stamps it inside the lock and CLAMPS it
651
+ // to the journal's last event. Hooks matched to one event run in PARALLEL
652
+ // (ADR-22), so two gates stamping their own `new Date()` outside the lock
653
+ // could write them out of order and turn a warning into a hard validation
654
+ // error in a file nobody is allowed to rewrite.
655
+ const written =
656
+ verdict.outcome === 'out-of-scope'
657
+ ? false
658
+ : record(journal, {
659
+ ev: 'gate',
660
+ init: forJournal(journal.init ?? 'unknown'),
661
+ actor: 'evidence-gate',
662
+ data: {
663
+ kind: 'evidence',
664
+ result: verdict.outcome,
665
+ agent_type: forJournal(field(input, 'agent_type') ?? ''),
666
+ agent_id: forJournal(field(input, 'agent_id') ?? ''),
667
+ signals: verdict.signals,
668
+ ...(verdict.reason === null ? {} : { reason: forJournal(verdict.reason) }),
669
+ ...(verdict.code === undefined ? {} : { code: verdict.code }),
670
+ ...(verdict.wouldBe === undefined ? {} : { would_be: verdict.wouldBe }),
671
+ ...(journal.inferred ? { initiative_inferred_from: journal.inferred } : {}),
672
+ },
673
+ });
674
+
675
+ if (verdict.outcome === 'exempt-hatch' && !written) {
676
+ return { decision: 'deny', reason: REFUSALS[DENY.HATCH_NOT_RECORDABLE] };
677
+ }
678
+ if (verdict.outcome === 'deny') return { decision: 'deny', reason: REFUSALS[verdict.code] };
679
+ return PASS;
680
+ }
681
+
682
+ /** See journal.mjs — both sides canonicalized, or a symlinked path no-ops. */
683
+ function canonicalPath(path) {
684
+ const abs = resolve(path);
685
+ try {
686
+ return realpathSync(abs);
687
+ } catch {
688
+ return abs;
689
+ }
690
+ }
691
+
692
+ function isMainModule(moduleUrl) {
693
+ if (!process.argv[1]) return false;
694
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
695
+ }
696
+
697
+ if (isMainModule(import.meta.url)) {
698
+ await main(() =>
699
+ runGate({
700
+ event: 'SubagentStop',
701
+ deadlineMs: DEADLINE_MS,
702
+ handler: ({ input }) => apply(input),
703
+ }),
704
+ );
705
+ }