@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,346 @@
1
+ /**
2
+ * invisible — the ONE answer to "is this codepoint invisible?".
3
+ *
4
+ * Why this file exists (ADR-19 correction 1 point 4, ADR-21): the rule had
5
+ * THREE spellings, and they were measurably inconsistent. Measured over all
6
+ * 1 112 064 codepoints at ca06c67, the three layers disagreed on 456 of them
7
+ * in 37 contiguous ranges — and the layering was INVERTED. The strictest
8
+ * spelling guarded our own repository in CI; the weakest guarded `STATE.md`,
9
+ * the document an agent reads to decide what to do next, whose content
10
+ * travels from subagent reports about FOREIGN repositories. The whole TAG
11
+ * block passed the layer closest to the victim while the layer closest to us
12
+ * blocked it.
13
+ *
14
+ * So the point of this module is not code reuse. It is that there must be one
15
+ * ANSWER, not one function: every consumer asks `invisibleProblem` and none of
16
+ * them passes options, because an option here would be a second semantics
17
+ * wearing the word "additive" (ADR-21).
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * SHAPE: why a Unicode PROPERTY, and not a longer hand-written list
21
+ * ---------------------------------------------------------------------------
22
+ * ADR-19 correction 1 concluded that any enumeration will be incomplete
23
+ * because Unicode grows and we do not, and it named an ALLOWLIST as the
24
+ * candidate direction. That hypothesis was measured before this module was
25
+ * written, and the measurement REFUTED it for this rule:
26
+ *
27
+ * a letters/numbers/punctuation/symbols/marks/spaces allowlist — the widest
28
+ * repertoire a Markdown table cell could plausibly need — still admits 267
29
+ * DEFAULT-IGNORABLE codepoints, among them the whole of U+E0100..U+E01EF,
30
+ * the same smuggling channel the denylist already had to name by hand.
31
+ *
32
+ * An allowlist would therefore still need a subtractive carve-out for exactly
33
+ * the characters that motivated the work: it moves the problem, it does not
34
+ * change its shape. What DOES change the shape is asking Unicode itself.
35
+ * `\p{Default_Ignorable_Code_Point}` is the property whose definition is
36
+ * literally "renders as nothing", it ships inside V8, it costs zero
37
+ * dependencies, and it is maintained by the people who add the characters.
38
+ * Measured: it is a strict SUPERSET of the hand-written list — 0 codepoints
39
+ * of the old list fall outside the property rule — and it closes every gap
40
+ * the previous measurements had found the hard way (Arabic `Cf` U+0600..0605,
41
+ * Egyptian controls U+13430..1343F, U+034F, Mongolian free variation
42
+ * selectors, and 3 844 codepoints in total).
43
+ *
44
+ * The enumerated list stays, and is consulted FIRST, for one reason: a range
45
+ * in it carries a sentence a human can act on. "TAG character (invisible
46
+ * ASCII)" tells a reader what happened; "default-ignorable" does not. The
47
+ * list is the vocabulary, the property is the boundary.
48
+ *
49
+ * Cost accepted deliberately: the property set follows the Unicode version
50
+ * bundled with Node, so a Node upgrade may widen it. That is the intended
51
+ * direction of drift — wider, never narrower — and the pinning test asserts
52
+ * shape and specific members rather than an exact cardinality, so an upgrade
53
+ * cannot silently NARROW the rule without turning a test red.
54
+ */
55
+
56
+ /**
57
+ * Named forbidden ranges, as numbers rather than string escapes — on purpose.
58
+ * A regex literal written with escape notation is one careless "helpful"
59
+ * rewrite away from becoming the very character it bans, and the file would
60
+ * then fail its own scan. Numbers cannot be mangled that way, and they double
61
+ * as the reporting vocabulary.
62
+ */
63
+ export const FORBIDDEN = Object.freeze([
64
+ // C0 controls, except TAB (0x09) and LF (0x0A) which are legal text.
65
+ // CR (0x0D) is deliberately IN: this repo normalizes to LF (.gitattributes).
66
+ Object.freeze({ lo: 0x00, hi: 0x08, what: 'C0 control character' }),
67
+ Object.freeze({ lo: 0x0b, hi: 0x1f, what: 'C0 control character' }),
68
+ Object.freeze({ lo: 0x7f, hi: 0x9f, what: 'DEL / C1 control character' }),
69
+ Object.freeze({ lo: 0x00ad, hi: 0x00ad, what: 'invisible formatting character (SOFT HYPHEN)' }),
70
+ Object.freeze({ lo: 0x061c, hi: 0x061c, what: 'bidi mark (ARABIC LETTER MARK)' }),
71
+ Object.freeze({ lo: 0x115f, hi: 0x1160, what: 'invisible filler that renders as nothing' }),
72
+ Object.freeze({ lo: 0x180e, hi: 0x180e, what: 'invisible separator (MONGOLIAN VOWEL SEPARATOR)' }),
73
+ Object.freeze({ lo: 0x200b, hi: 0x200f, what: 'zero-width or directional mark' }),
74
+ Object.freeze({ lo: 0x202a, hi: 0x202e, what: 'bidi embedding or override' }),
75
+ Object.freeze({ lo: 0x2060, hi: 0x2064, what: 'word joiner or invisible operator' }),
76
+ Object.freeze({ lo: 0x2066, hi: 0x2069, what: 'bidi isolate' }),
77
+ Object.freeze({ lo: 0x206a, hi: 0x206f, what: 'deprecated formatting character' }),
78
+ Object.freeze({ lo: 0x3164, hi: 0x3164, what: 'invisible filler that renders as nothing' }),
79
+ Object.freeze({ lo: 0xffa0, hi: 0xffa0, what: 'invisible filler that renders as nothing' }),
80
+ Object.freeze({ lo: 0xfeff, hi: 0xfeff, what: 'byte order mark / zero-width no-break space' }),
81
+ Object.freeze({ lo: 0xfff9, hi: 0xfffb, what: 'interlinear annotation character' }),
82
+ Object.freeze({ lo: 0x1d173, hi: 0x1d17a, what: 'invisible musical formatting character' }),
83
+ // The one that matters most, and the one the old test could not even see:
84
+ // U+E0001..U+E007E map ONE-TO-ONE onto ASCII and render as nothing at all.
85
+ // Projections (STATE.md, PROGRESS.md) are read by AGENTS, and their content
86
+ // travels from subagent reports about foreign repositories — so invisible
87
+ // text in a projection is prompt injection aimed at our own team, not an
88
+ // aesthetic complaint. The block is astral, which is why the pinning test
89
+ // stopping at U+FFFF hid it (ADR-19 correction 1).
90
+ Object.freeze({ lo: 0xe0000, hi: 0xe007f, what: 'TAG character (invisible ASCII)' }),
91
+ // Variation Selectors Supplement. Same smuggling channel as the TAG block —
92
+ // a sequence of them encodes arbitrary bytes onto a visible carrier — and
93
+ // zero occurrences in this repo, so banning them costs nothing here. Their
94
+ // BMP counterparts are deliberately NOT banned; see below.
95
+ Object.freeze({ lo: 0xe0100, hi: 0xe01ef, what: 'variation selector (supplement)' }),
96
+ ]);
97
+
98
+ /**
99
+ * DELIBERATE GAP: U+FE00..U+FE0F (variation selectors 1-16) are NOT banned.
100
+ *
101
+ * U+FE0F is the emoji presentation selector and occurs 24 times in this repo's
102
+ * README today; U+FE0E is its text-presentation twin. Banning the range would
103
+ * turn CI red on a file nobody touched, and ADR-19 is explicit that a gate
104
+ * which cries wolf gets switched off and never restored — which costs more
105
+ * than the gap.
106
+ *
107
+ * The gap is real and stated rather than hidden: 16 codepoints still carry
108
+ * four bits each, so a determined smuggler can encode data with them. It is
109
+ * announced on every run of the scanner, clean runs included, and it is the
110
+ * ONE place where this module says "allowed" to something Unicode calls
111
+ * default-ignorable — which is why it is a list, checked first, and not a
112
+ * condition buried inside the predicate.
113
+ */
114
+ export const DELIBERATELY_ALLOWED = Object.freeze([
115
+ Object.freeze({ lo: 0xfe00, hi: 0xfe0f, why: 'variation selectors: U+FE0F is legal emoji presentation' }),
116
+ ]);
117
+
118
+ /**
119
+ * TAB and LF are legal text and must never be answered as "invisible". They
120
+ * are visible whitespace with a layout meaning, and files are full of them.
121
+ * Consumers for which they are nonetheless illegal — a file NAME, an agent
122
+ * name — ask `whitespaceProblem`, a DISJOINT rule; see below.
123
+ */
124
+ const LEGAL_TEXT_CONTROLS = Object.freeze([0x09, 0x0a]);
125
+
126
+ /**
127
+ * The boundary of the rule, as ONE character class — the union of the four
128
+ * properties, which is exactly what four separate tests computed.
129
+ *
130
+ * It began as four regexes and was collapsed after measuring: a codepoint that
131
+ * is ORDINARY has to fail every test before the answer is "no", and "no" is the
132
+ * answer for 99.6% of Unicode and for essentially all real text. Four misses
133
+ * per character made `inline()` 15x slower than the old hand-written class on
134
+ * astral input (5 000 emoji: 1.19 ms vs 0.08 ms). One class is one miss.
135
+ *
136
+ * The `^...$` anchors are defensive habit, not a load-bearing guarantee: the
137
+ * only caller builds the subject with `String.fromCodePoint`, so it is always
138
+ * exactly one character and the anchors cannot change the verdict. Removing
139
+ * them is an EQUIVALENT mutant here, and the sentence says so rather than
140
+ * claiming a protection no test in this repo can demonstrate. They earn their
141
+ * keep only if someone later passes a longer string — which the type of the
142
+ * parameter already forbids.
143
+ */
144
+ const IS_INVISIBLE =
145
+ /^[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\p{Noncharacter_Code_Point}]$/u;
146
+
147
+ const inRange = (cp, ranges) => {
148
+ for (const r of ranges) if (cp >= r.lo && cp <= r.hi) return r;
149
+ return null;
150
+ };
151
+
152
+ /**
153
+ * A 64 KiB lookup for the BMP, filled lazily: 0 unknown, 1 allowed, 2 banned.
154
+ * `inline()` runs on every cell of every projection, so the property test must
155
+ * not be paid per character per render. Above the BMP the properties are
156
+ * evaluated directly — those codepoints are rare in real text and the
157
+ * exhaustive test that walks all of them is not a hot path.
158
+ */
159
+ const bmp = new Uint8Array(0x10000);
160
+
161
+ /**
162
+ * The same idea above the BMP, where a flat array would cost 1 MiB and a
163
+ * regex test costs ~13x what the old hand-written character class did.
164
+ * Measured on 5 000 astral codepoints: 0.08 ms before this module, 1.08 ms
165
+ * with the property test, 0.09 ms with this cache.
166
+ *
167
+ * Bounded, and cleared rather than evicted: an exhaustive sweep over Unicode
168
+ * (this repo has three of them, in tests) would otherwise grow it to a million
169
+ * entries. Clearing is correct because the map is a pure memo — losing it
170
+ * costs time, never accuracy — and a cheap clear beats an LRU whose only
171
+ * purpose is to be more elegant about the same guarantee.
172
+ */
173
+ const astral = new Map();
174
+ const ASTRAL_CACHE_LIMIT = 1 << 16;
175
+
176
+ /**
177
+ * How many entries the astral memo currently holds, and its ceiling.
178
+ *
179
+ * Exported ONLY so the boundedness guarantee can be tested. Without it the
180
+ * test could assert that answers survive a recycle but not that a recycle ever
181
+ * happens — and a test whose NAME promises boundedness while its body checks
182
+ * only correctness is the "documented guarantee with no guard" shape this
183
+ * story exists to remove. Removing the `clear()` used to leave the whole suite
184
+ * green; now it does not.
185
+ */
186
+ export function astralMemoStats() {
187
+ return { size: astral.size, limit: ASTRAL_CACHE_LIMIT };
188
+ }
189
+
190
+ /** The label a codepoint gets when only the property rule caught it. */
191
+ const PROPERTY_LABEL = 'invisible or non-printing character (Unicode default-ignorable / control / format)';
192
+
193
+ /**
194
+ * The problem with this codepoint, or null when it is ordinary text.
195
+ *
196
+ * This is the single answer. It takes no options on purpose: an option would
197
+ * let two callers ask the same question and be told different things, which is
198
+ * the defect this module was created to remove (ADR-21 — one ANSWER, not one
199
+ * function).
200
+ */
201
+ export function invisibleProblem(cp) {
202
+ // Not `return null`. This is a security predicate, and "null" is its word for
203
+ // CLEAN — so answering it for a value that is not a codepoint at all is
204
+ // fail-open, the shape ADR-19 correction 1 catalogues as the way a gate gets
205
+ // walked past. Today every caller passes `ch.codePointAt(0)` and the branch
206
+ // is unreachable, which is exactly why it must not sit here quietly deciding
207
+ // that garbage is fine: the next caller is the one that makes it reachable.
208
+ if (!Number.isInteger(cp) || cp < 0 || cp > 0x10ffff) {
209
+ throw new TypeError(`invisibleProblem expects a Unicode code point, got ${JSON.stringify(cp)}`);
210
+ }
211
+ if (cp < 0x10000) {
212
+ const cached = bmp[cp];
213
+ if (cached !== 0) return cached === 1 ? null : labelFor(cp);
214
+ const problem = compute(cp);
215
+ bmp[cp] = problem === null ? 1 : 2;
216
+ return problem;
217
+ }
218
+ const memo = astral.get(cp);
219
+ if (memo !== undefined) return memo;
220
+ const problem = compute(cp);
221
+ if (astral.size >= ASTRAL_CACHE_LIMIT) astral.clear();
222
+ astral.set(cp, problem);
223
+ return problem;
224
+ }
225
+
226
+ /** Named ranges win, so the reader gets a sentence instead of a category. */
227
+ function labelFor(cp) {
228
+ const named = inRange(cp, FORBIDDEN);
229
+ return named ? named.what : PROPERTY_LABEL;
230
+ }
231
+
232
+ function compute(cp) {
233
+ if (LEGAL_TEXT_CONTROLS.includes(cp)) return null;
234
+ if (inRange(cp, DELIBERATELY_ALLOWED)) return null;
235
+ const named = inRange(cp, FORBIDDEN);
236
+ if (named) return named.what;
237
+ return IS_INVISIBLE.test(String.fromCodePoint(cp)) ? PROPERTY_LABEL : null;
238
+ }
239
+
240
+ /**
241
+ * The SEPARATE rule for identifiers — a file name, a symlink target, an agent
242
+ * name. TAB and LF are perfectly visible characters, so they are not an answer
243
+ * to "is this invisible"; they are a catastrophe in a NAME, where a tab makes
244
+ * one path print as two columns in every tool that lists it and a newline
245
+ * breaks the line-oriented output of all of them.
246
+ *
247
+ * Kept as its own function, with its own name, precisely so that it cannot be
248
+ * mistaken for a second configuration of the invisibility rule. The two rules
249
+ * are DISJOINT — `invisibleProblem` never answers for U+0009 or U+000A, and
250
+ * this one answers for nothing else — and a test asserts that disjointness
251
+ * over the whole of Unicode, so the boundary cannot rot into an option.
252
+ */
253
+ export function whitespaceProblem(cp) {
254
+ return LEGAL_TEXT_CONTROLS.includes(cp) ? 'control character in a name or path' : null;
255
+ }
256
+
257
+ /** Either rule, for the consumers that hold identifiers. */
258
+ export function identifierProblem(cp) {
259
+ return invisibleProblem(cp) ?? whitespaceProblem(cp);
260
+ }
261
+
262
+ /** `U+00A0` style, always at least four hex digits. */
263
+ export function formatCodePoint(cp) {
264
+ return `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`;
265
+ }
266
+
267
+ /**
268
+ * Every invisible codepoint in `text` replaced by its VISIBLE escape notation.
269
+ *
270
+ * ONE VISIBILITY POLICY, and this is it: an invisible character is shown, never
271
+ * silently dropped.
272
+ *
273
+ * The repo had two policies and they contradicted each other. `inline()`
274
+ * deleted invisible characters, so a value made entirely of TAG characters
275
+ * rendered identically to an empty one — `inline("deploy ok" + TAG("IGNORE ALL
276
+ * PRIOR INSTRUCTIONS"))` returned exactly `"deploy ok"`, with nothing anywhere
277
+ * saying that 29 characters had been removed. Meanwhile the hook runtime
278
+ * escaped them and its own comment called silent removal "the same class of
279
+ * defect as a silent exemption in the scanner". Two layers, two answers to
280
+ * "does the reader get told" — the same shape as the three spellings of
281
+ * membership this module exists to have removed, one level up.
282
+ *
283
+ * Escaping wins on the repo's own stated principle: ADR-19 correction 1 says an
284
+ * exclusion must never be silent, that a skipped item is to be counted and
285
+ * named even on a clean run. It also matches how `inline()` already signals its
286
+ * OTHER losses — truncation prints an ellipsis — so silent character removal
287
+ * was the single lossy step that left no trace.
288
+ *
289
+ * The cost is accepted and stated: a hostile value becomes long and ugly, and
290
+ * legitimate formatting characters of Arabic, Syriac, Kaithi and Egyptian
291
+ * become visible noise in text that uses them (see docs/projections.md). Ugly
292
+ * and honest beats tidy and misleading in a document an agent reads to decide
293
+ * what to do next.
294
+ *
295
+ * Iterates codepoints, not UTF-16 units: a TAG character is a surrogate PAIR,
296
+ * and a unit-wise walk sees 0xDB40/0xDC01 — neither of which is invisible on
297
+ * its own — and lets the character through while reporting success.
298
+ */
299
+ export function escapeInvisible(text) {
300
+ let out = '';
301
+ let changed = false;
302
+ for (const ch of text) {
303
+ const cp = ch.codePointAt(0);
304
+ if (invisibleProblem(cp) === null) out += ch;
305
+ else {
306
+ out += `<${formatCodePoint(cp)}>`;
307
+ changed = true;
308
+ }
309
+ }
310
+ return changed ? out : text;
311
+ }
312
+
313
+ /**
314
+ * The same rule applied to a JSON document, as JSON's own `\uXXXX` escapes.
315
+ *
316
+ * `JSON.stringify` escapes C0 controls and nothing else: bidi overrides, TAG
317
+ * characters and zero-width marks come out RAW. Every `journal.mjs` subcommand
318
+ * prints stringified events to a terminal, so `query`, `tail`, `validate`,
319
+ * `open-spawns`, `append` and `close-spawn` were all handing an attacker the
320
+ * operator's screen.
321
+ *
322
+ * `<U+202E>` would have been wrong here: that output is machine-readable, and
323
+ * this repo's own tooling parses it back. JSON escapes are SAFE ON A TERMINAL
324
+ * AND LOSSLESS — `JSON.parse` of the result is deep-equal to the original, and
325
+ * a test asserts exactly that. Fidelity and safety were not in tension; they
326
+ * only looked like it while the escape notation was the wrong one.
327
+ */
328
+ export function jsonEscapeInvisible(json) {
329
+ let out = '';
330
+ for (const ch of json) {
331
+ const cp = ch.codePointAt(0);
332
+ if (invisibleProblem(cp) === null) {
333
+ out += ch;
334
+ continue;
335
+ }
336
+ // Astral codepoints need their surrogate PAIR spelled out: JSON has no
337
+ // \u{...} form, so each UTF-16 unit is escaped separately.
338
+ for (let i = 0; i < ch.length; i++) {
339
+ out += BS_LITERAL + 'u' + ch.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0');
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+
345
+ /** A single backslash, built from its code point so no tool can rewrite it. */
346
+ const BS_LITERAL = String.fromCharCode(92);