@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,383 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yaml-lite — a deliberately small YAML subset parser/serializer.
4
+ *
5
+ * Tyran's core has zero dependencies (see CONTRIBUTING). Rather than ship a
6
+ * full YAML implementation, we support the strict subset our own config,
7
+ * knowledge and policy files are allowed to use — and REJECT everything
8
+ * else loudly, so a file that would parse differently under a real YAML
9
+ * engine can never silently mean something else here.
10
+ *
11
+ * Supported
12
+ * key: value scalars: string, number, bool, null
13
+ * key: nested mappings (2-space indent)
14
+ * nested: value
15
+ * key: block sequences of scalars or mappings
16
+ * - item
17
+ * - key: v
18
+ * key2: v
19
+ * key: [a, b] inline flow sequences of scalars
20
+ * 'single' / "double" quoted strings · # comments · --- document start
21
+ *
22
+ * Rejected loudly (with a line number): anchors/aliases (& *), tags (! !!),
23
+ * multi-line scalars (| >), flow mappings ({}), nested flow sequences,
24
+ * tabs for indentation, duplicate keys, multiple documents.
25
+ */
26
+ import { formatCodePoint, invisibleProblem } from './invisible.mjs';
27
+
28
+ // Prototype-free lookup: `constructor`/`__proto__` must be data, not
29
+ // inherited members (review E2S2-R2).
30
+ const BOOL = Object.assign(Object.create(null), {
31
+ true: true,
32
+ false: false,
33
+ yes: true,
34
+ no: false,
35
+ on: true,
36
+ off: false,
37
+ });
38
+
39
+ export class YamlLiteError extends Error {
40
+ constructor(message, line) {
41
+ super(line ? `line ${line}: ${message}` : message);
42
+ this.line = line ?? null;
43
+ }
44
+ }
45
+
46
+ /** Guards shared by keys and values — the subset boundary in one place. */
47
+ function rejectUnsupported(value, lineNo, what) {
48
+ if (value[0] === '&' || value[0] === '*') {
49
+ throw new YamlLiteError(`anchors and aliases are not supported (in ${what})`, lineNo);
50
+ }
51
+ if (value[0] === '!') throw new YamlLiteError(`tags are not supported (in ${what})`, lineNo);
52
+ if (value[0] === '|' || value[0] === '>') {
53
+ throw new YamlLiteError(`multi-line block scalars are not supported (in ${what})`, lineNo);
54
+ }
55
+ if (value[0] === '{') throw new YamlLiteError(`flow mappings are not supported (in ${what})`, lineNo);
56
+ }
57
+
58
+ /** Split a flow sequence body on commas that sit outside quotes. */
59
+ function splitFlow(inner, lineNo) {
60
+ const parts = [];
61
+ let current = '';
62
+ let inSingle = false;
63
+ let inDouble = false;
64
+ for (const c of inner) {
65
+ if (c === "'" && !inDouble) inSingle = !inSingle;
66
+ else if (c === '"' && !inSingle) inDouble = !inDouble;
67
+ if (c === ',' && !inSingle && !inDouble) {
68
+ parts.push(current);
69
+ current = '';
70
+ } else current += c;
71
+ }
72
+ if (inSingle || inDouble) throw new YamlLiteError('unterminated quoted string in flow sequence', lineNo);
73
+ parts.push(current);
74
+ return parts;
75
+ }
76
+
77
+ function unquote(value, lineNo) {
78
+ // Single-quoted YAML escapes a quote by doubling it — undo that, so
79
+ // stringify→parse is symmetric (review E2S2-R3).
80
+ if (value[0] === "'" && value.at(-1) === "'") return value.slice(1, -1).replace(/''/g, "'");
81
+ const inner = value.slice(1, -1);
82
+ // Backslash escapes inside double quotes are real YAML but not part of
83
+ // this subset — decoding them half-way would mean something different
84
+ // than a real loader (review E2S2 note 2).
85
+ if (inner.includes('\\')) {
86
+ throw new YamlLiteError('backslash escapes inside double quotes are not supported — use single quotes', lineNo);
87
+ }
88
+ return inner;
89
+ }
90
+
91
+ function parseScalar(raw, lineNo, { allowFlow = true } = {}) {
92
+ const value = raw.trim();
93
+ if (value === '') return '';
94
+ rejectUnsupported(value, lineNo, 'value');
95
+ if (value.startsWith('[')) {
96
+ if (!allowFlow) throw new YamlLiteError('nested flow sequences are not supported', lineNo);
97
+ const close = value.lastIndexOf(']');
98
+ if (close === -1) throw new YamlLiteError('unterminated flow sequence', lineNo);
99
+ if (value.slice(close + 1).trim() !== '') {
100
+ throw new YamlLiteError('unexpected content after flow sequence', lineNo);
101
+ }
102
+ const inner = value.slice(1, close).trim();
103
+ if (inner === '') return [];
104
+ return splitFlow(inner, lineNo).map((part) => parseScalar(part, lineNo, { allowFlow: false }));
105
+ }
106
+ if ((value[0] === '"' && value.at(-1) === '"' && value.length > 1) ||
107
+ (value[0] === "'" && value.at(-1) === "'" && value.length > 1)) {
108
+ return unquote(value, lineNo);
109
+ }
110
+ if (value === 'null' || value === '~') return null;
111
+ const lower = value.toLowerCase();
112
+ if (Object.hasOwn(BOOL, lower)) return BOOL[lower];
113
+ if (/^-?\d+$/.test(value)) return Number(value);
114
+ if (/^-?\d*\.\d+$/.test(value)) return Number(value);
115
+ return value;
116
+ }
117
+
118
+ function stripComment(line, lineNo) {
119
+ // Comments start at ` #` outside quotes; a leading `#` is a full-line comment.
120
+ let inSingle = false;
121
+ let inDouble = false;
122
+ let cut = -1;
123
+ for (let i = 0; i < line.length; i++) {
124
+ const c = line[i];
125
+ if (c === "'" && !inDouble) inSingle = !inSingle;
126
+ else if (c === '"' && !inSingle) inDouble = !inDouble;
127
+ else if (c === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
128
+ cut = i;
129
+ break; // the comment body is not YAML — stop tracking quotes there
130
+ }
131
+ }
132
+ // An unbalanced quote means our comment detection disagreed with a real
133
+ // YAML loader (`a: don't ask # why`) — refuse rather than guess
134
+ // (review E2S2 note 1).
135
+ if (inSingle || inDouble) {
136
+ throw new YamlLiteError('unbalanced quote — quote the whole value if it contains an apostrophe', lineNo);
137
+ }
138
+ return cut === -1 ? line : line.slice(0, cut);
139
+ }
140
+
141
+ /** Parse a YAML-subset document into a plain JS object. */
142
+ export function parse(text) {
143
+ const rawLines = text.split('\n');
144
+ const lines = [];
145
+ let sawDocStart = false;
146
+ rawLines.forEach((raw, i) => {
147
+ const lineNo = i + 1;
148
+ const beforeContent = raw.slice(0, raw.search(/\S|$/));
149
+ if (beforeContent.includes('\t')) {
150
+ throw new YamlLiteError('tabs are not allowed for indentation', lineNo);
151
+ }
152
+ const line = stripComment(raw, lineNo).replace(/\s+$/, '');
153
+ if (line.trim() === '') return;
154
+ if (line.trim() === '---' || line.trim() === '...') {
155
+ // Only a leading document marker is allowed: a second one would mean
156
+ // a multi-document stream, where a real YAML loader takes only the
157
+ // first document (review E2S2-R4).
158
+ if (sawDocStart || lines.length > 0) {
159
+ throw new YamlLiteError('multiple documents are not supported', lineNo);
160
+ }
161
+ sawDocStart = true;
162
+ return;
163
+ }
164
+ const indent = line.length - line.trimStart().length;
165
+ if (indent % 2 !== 0) throw new YamlLiteError('indentation must be a multiple of 2 spaces', lineNo);
166
+ lines.push({ indent, text: line.trim(), lineNo });
167
+ });
168
+
169
+ let pos = 0;
170
+
171
+ function parseBlock(indent) {
172
+ // Sequence?
173
+ if (pos < lines.length && lines[pos].indent === indent && lines[pos].text.startsWith('- ')) {
174
+ const items = [];
175
+ while (pos < lines.length && lines[pos].indent === indent && lines[pos].text.startsWith('- ')) {
176
+ const { text, lineNo } = lines[pos];
177
+ const rest = text.slice(2).trim();
178
+ const colon = keyColonIndex(rest);
179
+ if (colon === -1) {
180
+ items.push(parseScalar(rest, lineNo));
181
+ pos++;
182
+ } else {
183
+ // Mapping item: first pair sits on the dash line, siblings follow indented.
184
+ const map = Object.create(null);
185
+ const key = unquoteKey(rest.slice(0, colon), lineNo);
186
+ const inlineValue = rest.slice(colon + 1).trim();
187
+ pos++;
188
+ if (inlineValue === '') {
189
+ const next = lines[pos];
190
+ map[key] = next && next.indent > indent + 2 ? parseBlock(next.indent) : null;
191
+ } else map[key] = parseScalar(inlineValue, lineNo);
192
+ const childIndent = indent + 2;
193
+ while (pos < lines.length && lines[pos].indent === childIndent && !lines[pos].text.startsWith('- ')) {
194
+ const child = lines[pos];
195
+ const c = keyColonIndex(child.text);
196
+ if (c === -1) throw new YamlLiteError('expected "key: value" inside sequence item', child.lineNo);
197
+ const k = unquoteKey(child.text.slice(0, c), child.lineNo);
198
+ if (Object.hasOwn(map, k)) throw new YamlLiteError(`duplicate key "${k}"`, child.lineNo);
199
+ const v = child.text.slice(c + 1).trim();
200
+ pos++;
201
+ if (v === '') {
202
+ const next = lines[pos];
203
+ map[k] = next && next.indent > childIndent ? parseBlock(next.indent) : null;
204
+ } else map[k] = parseScalar(v, child.lineNo);
205
+ }
206
+ items.push({ ...map });
207
+ }
208
+ }
209
+ return items;
210
+ }
211
+
212
+ // Mapping
213
+ const map = Object.create(null);
214
+ while (pos < lines.length && lines[pos].indent === indent) {
215
+ const { text, lineNo } = lines[pos];
216
+ if (text.startsWith('- ')) break;
217
+ const colon = keyColonIndex(text);
218
+ if (colon === -1) throw new YamlLiteError(`expected "key: value", got "${text}"`, lineNo);
219
+ const key = unquoteKey(text.slice(0, colon), lineNo);
220
+ if (Object.hasOwn(map, key)) throw new YamlLiteError(`duplicate key "${key}"`, lineNo);
221
+ const value = text.slice(colon + 1).trim();
222
+ pos++;
223
+ if (value === '') {
224
+ const next = lines[pos];
225
+ if (!next || next.indent <= indent) map[key] = null;
226
+ else map[key] = parseBlock(next.indent);
227
+ } else {
228
+ map[key] = parseScalar(value, lineNo);
229
+ }
230
+ }
231
+ return { ...map }; // hand back an ordinary object, prototype-free during construction
232
+ }
233
+
234
+ const result = lines.length === 0 ? {} : parseBlock(lines[0].indent);
235
+ if (pos < lines.length) {
236
+ throw new YamlLiteError(`unexpected indentation`, lines[pos].lineNo);
237
+ }
238
+ return result;
239
+ }
240
+
241
+ function keyColonIndex(text) {
242
+ let inSingle = false;
243
+ let inDouble = false;
244
+ for (let i = 0; i < text.length; i++) {
245
+ const c = text[i];
246
+ if (c === "'" && !inDouble) inSingle = !inSingle;
247
+ else if (c === '"' && !inSingle) inDouble = !inDouble;
248
+ else if (c === ':' && !inSingle && !inDouble && (i + 1 === text.length || /\s/.test(text[i + 1]))) {
249
+ return i;
250
+ }
251
+ }
252
+ return -1;
253
+ }
254
+
255
+ function unquoteKey(raw, lineNo) {
256
+ const key = raw.trim();
257
+ if (key === '') throw new YamlLiteError('empty key', lineNo);
258
+ if ((key[0] === '"' && key.at(-1) === '"' && key.length > 1) ||
259
+ (key[0] === "'" && key.at(-1) === "'" && key.length > 1)) {
260
+ return unquote(key, lineNo);
261
+ }
262
+ // Keys go through the same subset guards as values (review E2S2-R4):
263
+ // `&anchor key:` and `!!tag key:` must not be swallowed into the key name.
264
+ rejectUnsupported(key, lineNo, 'key');
265
+ return key;
266
+ }
267
+
268
+ /** Serialize a plain object back into the same subset (stable key order). */
269
+ export function stringify(value, indent = 0) {
270
+ const pad = ' '.repeat(indent);
271
+ if (Array.isArray(value)) {
272
+ if (value.length === 0) return `${pad}[]\n`;
273
+ return value
274
+ .map((item) => {
275
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
276
+ const body = stringify(item, indent + 2);
277
+ return `${pad}- ${body.slice(indent + 2)}`;
278
+ }
279
+ return `${pad}- ${formatScalar(item)}\n`;
280
+ })
281
+ .join('');
282
+ }
283
+ if (value !== null && typeof value === 'object') {
284
+ return Object.entries(value)
285
+ .map(([k, v]) => {
286
+ if (v !== null && typeof v === 'object' && (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0)) {
287
+ return `${pad}${formatKey(k)}:\n${stringify(v, indent + 2)}`;
288
+ }
289
+ if (v !== null && typeof v === 'object') return `${pad}${formatKey(k)}: ${Array.isArray(v) ? '[]' : 'null'}\n`;
290
+ return `${pad}${formatKey(k)}: ${formatScalar(v)}\n`;
291
+ })
292
+ .join('');
293
+ }
294
+ return `${pad}${formatScalar(value)}\n`;
295
+ }
296
+
297
+ /**
298
+ * Refuse to SERIALIZE an invisible codepoint, the same way this file already
299
+ * refuses a newline and for the same reason.
300
+ *
301
+ * This subset has no lossless way to carry one. Double-quoted \uXXXX escapes
302
+ * are real YAML but explicitly out of the subset — `unquote` rejects a
303
+ * backslash rather than decode it half-way — so the only representations
304
+ * available are RAW (a Trojan Source payload in a config file) or VISIBLY
305
+ * ESCAPED (which parses back as different data and breaks the round trip this
306
+ * file guarantees). Neither is acceptable, so the answer is the one
307
+ * `formatScalar` already gives for newlines: fail loudly at serialization
308
+ * time rather than write a file that reads back as something else.
309
+ *
310
+ * Refusing is the strongest fix available TO A SERIALIZER, which is a narrower
311
+ * claim than the one that stood here first. The worst case is a poisoned value
312
+ * PERSISTED to a config file: it would re-enter the conductor's context at
313
+ * every session start, through a path where none of the runtime layers is
314
+ * looking. This guard stops THIS WRITER from authoring such a file.
315
+ *
316
+ * It does NOT make the file impossible, and the earlier wording here said it
317
+ * did — corrected after a review disproved it by measurement. `parse` is
318
+ * untouched and reads a hand-written hostile file without complaint (measured:
319
+ * 7 invisible codepoints straight into a value), and the file can arrive by an
320
+ * editor, an agent's write tool, or a template copied from elsewhere. The
321
+ * honest guarantee is "tyran will not author one", not "one cannot exist".
322
+ * Closing the READ side is a separate decision with a live consumer question
323
+ * behind it — `schema.mjs` is the only importer today and its parsed values do
324
+ * not leave the validating function — so it is deliberately not done here.
325
+ *
326
+ * Measured before choosing: `stringify` has ZERO production consumers today —
327
+ * only its own unit test imports it, and `schema.mjs` imports `parse` alone.
328
+ * So the persisted-poison case is not reachable in this repository right now,
329
+ * and this guard is prophylactic. It is still worth having, because "no caller
330
+ * yet" is a fact about today and this module is published API.
331
+ */
332
+ function rejectInvisible(s, what) {
333
+ for (const ch of s) {
334
+ const problem = invisibleProblem(ch.codePointAt(0));
335
+ if (problem !== null) {
336
+ throw new YamlLiteError(
337
+ `cannot serialize a ${what} containing ${formatCodePoint(ch.codePointAt(0))} — ${problem}. ` +
338
+ 'This subset has no escape for it, so writing it would either hide it in the file ' +
339
+ 'or change the value on the way back.',
340
+ );
341
+ }
342
+ }
343
+ }
344
+
345
+ function formatKey(key) {
346
+ const s = String(key);
347
+ if (s.includes('\n')) {
348
+ // Mirrors the guard in formatScalar: a newline has no representation in
349
+ // this subset, so refuse at serialization time instead of emitting a file
350
+ // our own parser rejects with a confusing "unbalanced quote" (review
351
+ // E2S2-R11, note 1).
352
+ throw new YamlLiteError('cannot serialize a key containing a newline (no block scalars in this subset)');
353
+ }
354
+ rejectInvisible(s, 'key');
355
+ if (s === '' || /[\s:#'"&*!|>[\]{},]/.test(s)) return `'${s.replace(/'/g, "''")}'`;
356
+ return s;
357
+ }
358
+
359
+ function formatScalar(value) {
360
+ if (value === null || value === undefined) return 'null';
361
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
362
+ const s = String(value);
363
+ if (s.includes('\n')) {
364
+ // The subset has no block scalars, so a newline cannot be represented.
365
+ // Failing loudly beats writing a file that reads back as different data
366
+ // (review E2S2-R1).
367
+ throw new YamlLiteError('cannot serialize a string containing a newline (no block scalars in this subset)');
368
+ }
369
+ rejectInvisible(s, 'string');
370
+ const needsQuotes =
371
+ s === '' ||
372
+ /^\s|\s$/.test(s) ||
373
+ /^[-?:,[\]{}#&*!|>'"%@`]/.test(s) ||
374
+ /:\s/.test(s) ||
375
+ /\s#/.test(s) || // ` #` would be read back as a comment (review E2S2-R1)
376
+ s.includes("'") ||
377
+ s.includes('"') || // an unquoted apostrophe now fails to parse (review E2S2-R11)
378
+ Object.hasOwn(BOOL, s.toLowerCase()) ||
379
+ s === 'null' ||
380
+ s === '~' ||
381
+ /^-?\d+(\.\d+)?$/.test(s);
382
+ return needsQuotes ? `'${s.replace(/'/g, "''")}'` : s;
383
+ }
@@ -0,0 +1,118 @@
1
+ ---
2
+ description: Drive a real browser and come back with a MEASUREMENT rather than an impression - deterministic waits instead of sleeps, console errors and >=400 responses as counts, computed styles dumped to JSON when appearance is disputed. Use when work touches UI, when a review must verify one, or when fidelity-gate asks for its measurement.
3
+ ---
4
+
5
+ # Browser check — proving a UI works, in numbers
6
+
7
+ > Three places in this plugin order a browser pass — the conductor's quality
8
+ > gates, the reviewer, the implementer — and `fidelity-gate` asks for computed
9
+ > styles on top. None of them said how. This is the how, and it exists so that
10
+ > "I checked it in the browser" stops being a sentence and starts being a
11
+ > counter someone else can re-run.
12
+
13
+ **A browser pass is a measurement.** It returns numbers: pages visited, links
14
+ resolved, console errors, failed responses. `Looks fine`, `renders correctly`
15
+ and `no obvious issues` are REJECTED by the evidence contract exactly like a
16
+ test report with no output, and for the same reason — nothing in them can be
17
+ wrong.
18
+
19
+ ## Before you promise a run
20
+
21
+ 1. **The browser exists.** `npx playwright --version`, and install only
22
+ chromium (`npx playwright install chromium`) — a full browser install is
23
+ several GB for two extra engines nobody asked for.
24
+ 2. **The server is up and SERVING WHAT YOU THINK.** Fetch one known URL and
25
+ check the status before automating anything. A dev server that is still
26
+ compiling, or is serving a stale build on a port you forgot to kill,
27
+ produces a page of failures that have nothing to do with your change.
28
+ 3. **Build first when the target is a static site.** Testing the dev server
29
+ and shipping the build tests two different programs.
30
+
31
+ ## Waits are deterministic — never a sleep
32
+
33
+ Wait for a *condition*: a response, a selector, a network-idle state, a font
34
+ ready promise. `waitForTimeout` is a guess that is simultaneously too long on
35
+ your machine and too short in CI, and the failures it produces are
36
+ indistinguishable from real ones. The only defensible fixed wait is settling an
37
+ animation you cannot observe, and it says so in a comment.
38
+
39
+ **Warm the routes before a batch run.** The first request to a route compiles
40
+ it; a cold compile times out and reads as a defect in the page. Hitting every
41
+ route once before measuring costs one pass and removes a whole class of false
42
+ finding.
43
+
44
+ ## What you collect, and what you return
45
+
46
+ Subscribe before you navigate — listeners attached after the first `goto` miss
47
+ everything that page already did:
48
+
49
+ - `console` events of type `error`
50
+ - `pageerror` — uncaught exceptions, which are NOT console errors and are
51
+ routinely missed by checking only the first
52
+ - `response` with status `>= 400`, including the ones fired by the page's own
53
+ fetches, which no amount of clicking will reveal
54
+ - every in-page link, resolved once and cached by URL
55
+
56
+ Return them as **counts plus the first few offenders**, then a single verdict
57
+ line. A pass with `0` console errors is a fact; "the console was clean" is a
58
+ recollection.
59
+
60
+ ## Selectors that do not go blind
61
+
62
+ Anchor on what the user sees — a role, a label, the text — not on a container
63
+ class or an nth-child. This is not a style preference. A selector scoped to the
64
+ markup it was written against keeps passing while silently checking LESS of the
65
+ page as the page grows, and it reports the narrowed number as success. That has
66
+ already happened in this repo: a link check named two regions of one framework's
67
+ chrome, and went from checking every link to checking half of them on the day a
68
+ page was added outside that chrome — with no failure anywhere.
69
+
70
+ So: assert the COUNT you expected as well as the condition. `24 links checked,
71
+ 0 broken` catches the regression that `0 broken` cannot.
72
+
73
+ ## When the disagreement is about appearance
74
+
75
+ Settle it with `getComputedStyle`, dumped to JSON, compared against the
76
+ inventory. Never by looking, and never by asking a model to look:
77
+
78
+ ```js
79
+ await page.$$eval(SELECTORS, (els) => els.map((el) => {
80
+ const s = getComputedStyle(el);
81
+ return { text: el.textContent.trim(), fontSize: s.fontSize, fontWeight: s.fontWeight, color: s.color };
82
+ }));
83
+ ```
84
+
85
+ Two rules that keep this a measurement:
86
+
87
+ - **Assert the element's text alongside its geometry.** A structural selector
88
+ with no text assertion measures the wrong element when the markup is
89
+ renumbered, and reports a confident wrong number instead of a loud miss.
90
+ - **A zero-size box is a failure, not a pass.** An element that renders with no
91
+ width, or a glyph that inherits `fill: none`, is present in the DOM and
92
+ invisible on the screen. Check the bounding box whenever you check anything
93
+ visual, or the check passes on a page nobody can read.
94
+
95
+ For a frozen reference, this step belongs to `fidelity-gate`, which owns the
96
+ inventory, the relics list and the verdict format. Do not invent a second one.
97
+
98
+ ## Artefacts and cleanup
99
+
100
+ Screenshots, JSON dumps and logs go under `.tyran/`, never `/tmp` — they are
101
+ evidence for a report, and evidence a colleague cannot open is not evidence.
102
+ Name them for the run, not `screenshot.png`.
103
+
104
+ End the way you started: close the browser, and stop the server you started
105
+ with SIGTERM so it can release its port. `kill -9` leaves the port held and the
106
+ next run fails for a reason that has nothing to do with the code.
107
+
108
+ ## The report
109
+
110
+ ```
111
+ PAGES 13 · LINKS 24/24 · CONSOLE ERRORS 0 · PAGE ERRORS 0 · HTTP>=400 0
112
+ BROWSER PASS: OK
113
+ ```
114
+
115
+ Then the failures, if any, with the URL that produced each. State plainly what
116
+ you did NOT drive — the viewport you skipped, the flow behind a login, the
117
+ browser you do not have. An unchecked area named in the report is a decision
118
+ for the conductor; an unchecked area left out of it is a claim.
@@ -0,0 +1,83 @@
1
+ ---
2
+ description: The depth half of a review - the dimensions a diff is read against (correctness, boundaries, concurrency, failure paths, secrets, test quality, lifecycle) and the rule that a finding is refuted before it is reported. The verdict itself stays with the reviewer agent. Use when reviewing a diff or a pull request.
3
+ ---
4
+
5
+ # Code review — reading depth
6
+
7
+ > This is HOW a diff is read. WHAT the verdict looks like belongs to
8
+ > `tyran:reviewer` — binary APPROVE or CHANGES-REQUESTED, numbered executable
9
+ > counterexamples, a re-review that first checks the previous round's findings
10
+ > are pinned as tests, and a section naming what was not checked. Do not
11
+ > restate any of that here; two definitions of "reviewed" drift apart, and the
12
+ > drift only shows up when they disagree in front of someone.
13
+
14
+ ## Read the diff twice, for different things
15
+
16
+ **First pass — does it do what the story says?** Against the acceptance
17
+ criteria, not against your idea of the feature. A correct implementation of the
18
+ wrong thing is the most expensive defect on this list, and it is the one a
19
+ dimension sweep never catches.
20
+
21
+ **Second pass — the sweep below.** Every dimension gets looked at explicitly.
22
+ Skipping one is a decision that belongs in the "did not check" section, not a
23
+ gap nobody notices.
24
+
25
+ ## The dimensions
26
+
27
+ - **Correctness at the edges.** Empty, one, many. Zero, negative, overflow. The
28
+ first and last iteration. Null versus absent versus empty-string — three
29
+ different states that most code conflates and most tests exercise as one.
30
+ - **Boundaries and shared zones.** Does the change reach outside its story's
31
+ scope? An API shape, a schema, a shared file, a generated artefact, a
32
+ published type. Those are the conductor's to authorise, and a review that
33
+ waves one through has spent authority it does not have.
34
+ - **Concurrency and ordering.** Two of these running at once: what is read
35
+ after being written elsewhere, what assumes it is alone, what holds a lock
36
+ and what forgot to release one on the failure path.
37
+ - **Failure paths.** Follow every error to where it is handled. An error that
38
+ is caught and logged is not handled; an error swallowed to keep a pipeline
39
+ green is a silent failure with a good story. Check the *partial* failure —
40
+ the write that succeeded before the one that did not.
41
+ - **Secrets and untrusted input.** Anything reaching a shell, a query, a path,
42
+ a template or a rendered page. Values from the environment or a fixture that
43
+ look like real credentials. This dimension has a floor: it is reviewed
44
+ properly or the review is not finished.
45
+ - **Test quality, not test count.** Does each new test fail when the behaviour
46
+ is broken? Delete a line of the implementation in your head and ask which
47
+ test goes red — if the answer is none, the test asserts nothing. Watch for a
48
+ test that pins the CURRENT output rather than the CORRECT one, which converts
49
+ a bug into a requirement.
50
+ - **Resource lifecycle.** What is opened, started, spawned or leased, and where
51
+ it is closed — including on the path where an exception is thrown.
52
+ - **The gate itself.** If the change touches a check, ask what that check can
53
+ no longer see. A loosened tolerance, a narrowed selector, a broadened
54
+ try/catch and a skipped test all keep the run green while removing its
55
+ meaning.
56
+
57
+ ## Refute before you report
58
+
59
+ **Try to kill your own finding first.** Re-read the surrounding code, look for
60
+ the guard you may have missed, and where it is cheap, run the case. Report what
61
+ survives that attempt.
62
+
63
+ This is not politeness, it is arithmetic: a review that reports six findings of
64
+ which two are wrong costs more than one that reports four, because every wrong
65
+ finding is a round trip plus an argument, and it trains the conductor to
66
+ discount the other four.
67
+
68
+ - **A finding you cannot state as an input and an expected result is not
69
+ ready.** That form is what makes it pinnable as a test, and what the
70
+ reviewer's verdict requires.
71
+ - **Rank by severity, not by reading order.** Wrong behaviour, then a boundary
72
+ crossed, then a missing test, then everything else. A list in the order you
73
+ happened to notice things makes the reader do the triage you were asked to do.
74
+ - **Say plainly when you found nothing.** Manufacturing a finding to look
75
+ thorough is the cheapest way to become ignorable.
76
+
77
+ ## Before you fix N things, ask whether they are one thing
78
+
79
+ Several findings that all point at the same wrong baseline, the same missing
80
+ guard or the same misread contract are ONE finding. Fixing them individually
81
+ leaves the cause in place, and the next change re-derives them. Say so
82
+ explicitly when it happens — the conductor is deciding how to schedule the
83
+ work, and "six issues" and "one cause with six symptoms" are different plans.
@@ -0,0 +1,97 @@
1
+ ---
2
+ description: The optimization pass, defined - delete before you add, one smell class per pass, behaviour pinned by a test that ran BEFORE the edit and again after. Lints a SKILL.md and prose by the same instinct. Use for the per-story optimization pass, before promoting a skill, or when code has grown noisy without growing capable.
3
+ ---
4
+
5
+ # Deslop — the pass that removes rather than adds
6
+
7
+ > Every story here owes an optimization pass, and until this file existed the
8
+ > pass was whatever the agent felt like that afternoon. This is what one is.
9
+
10
+ **The default action is deletion.** A pass that ends with more lines than it
11
+ started with was a refactor, and a refactor is a different piece of work with a
12
+ different risk profile. If you cannot delete anything, say so and stop — that is
13
+ a complete and respectable outcome.
14
+
15
+ ## The order, and why it is an order
16
+
17
+ 1. **Pin the behaviour BEFORE you touch anything.** Run the relevant tests and
18
+ keep the output. A green run *after* an edit proves nothing on its own: you
19
+ do not know it was green before. If no test covers the area, write the
20
+ narrowest one that does, and it must go red when you break the behaviour on
21
+ purpose — otherwise it is not a net, it is decoration.
22
+ 2. **One smell class per pass**, verified between passes. Dead code, then
23
+ duplication, then naming and error handling, then tests. Bundling them
24
+ produces a diff nobody can review and a bisect that cannot isolate the
25
+ change that broke something.
26
+ 3. **Re-read the diff as the reviewer will.** Anything in it that is not the
27
+ smell you set out to remove comes back out.
28
+
29
+ ## What counts as slop
30
+
31
+ - **Dead code** — unreachable branches, unused exports, stale flags, debugging
32
+ left in place, backwards-compatibility shims for something nothing calls.
33
+ Verify it is unused before deleting; "I could not find a caller" and "there is
34
+ no caller" are different statements and only one of them is a fact.
35
+ - **Duplication that has diverged.** Two copies that agree are a cost; two
36
+ copies that quietly disagree are a defect.
37
+ - **Pass-through wrappers and single-use helpers.** Indirection that costs a
38
+ jump and buys nothing. **Three similar lines beat a premature abstraction** —
39
+ the abstraction commits you to a shape you have seen once.
40
+ - **Defensive handling for states that cannot occur** on a trusted internal
41
+ path. It reads as care and works as camouflage: the `catch` that cannot fire
42
+ today is the one that swallows the real error next year.
43
+ - **Casts that exist to silence a type error** rather than to state a truth the
44
+ compiler cannot see.
45
+ - **Work nobody asked for** — an improvement, a rename, a docstring, a
46
+ reformat on code the story never touched. It inflates the diff, hides the real
47
+ change, and puts an unreviewed decision in someone else's file.
48
+ - **Comments restating the line below them.** Keep the comment that explains
49
+ WHY, delete the one that narrates WHAT.
50
+
51
+ ## What is not slop, and stays
52
+
53
+ Behaviour. A guard on genuinely untrusted input. A comment carrying a reason.
54
+ An abstraction with three real callers. A test that looks redundant but pins a
55
+ past bug — check the history before deleting a test; the second time a bug is
56
+ fixed, the deleted test is the reason.
57
+
58
+ **Keep behaviour unchanged unless you are fixing a defect you can name.** If the
59
+ pass uncovers a real bug, that is a finding for the report and usually a
60
+ separate change, not something to quietly correct inside a cleanup.
61
+
62
+ ## Skill-file mode
63
+
64
+ The same instinct, applied to a `SKILL.md`. Run it before promoting a skill:
65
+
66
+ - **Stale lines** — guidance written for a version of the skill that no longer
67
+ exists. Cut it.
68
+ - **Bloat** — detail that belongs in the caller, in a reference, or nowhere.
69
+ - **Dead sentences** — a line that changes nothing if deleted.
70
+ - **Duplication** — the same rule stated in two files, which will drift.
71
+ - **Premature stop** — the method ends before the work does: it asks the
72
+ question but never records the answer, cleans but never verifies.
73
+ - **Weak anchor** — no single idea the skill turns on.
74
+ - **A missing assumption** — the skill does not say what has to be true for it
75
+ to apply. See `skill-writing`.
76
+
77
+ ## Prose mode
78
+
79
+ For a README, a report, an error message or a commit body:
80
+
81
+ - **Lead with the outcome.** The first sentence is the line a reader would ask
82
+ for if they said "just tell me".
83
+ - **Name the number, the file, the command.** "Faster" is weaker than "40s to
84
+ 9s". "Fixed the config bug" is weaker than the path.
85
+ - **Cut the filler** — *just*, *simply*, *basically*, *it is worth noting*. If
86
+ a sentence changes nothing when removed, remove it.
87
+ - **No dead-end error messages.** Every failure names what happened and what to
88
+ do next.
89
+ - Do not achieve brevity by compressing sentences into fragments. Achieve it by
90
+ dropping what the reader does not need.
91
+
92
+ ## The report
93
+
94
+ Files touched, what class of slop came out of each, the test output from before
95
+ and after, and one line on what you deliberately left. The last part matters: a
96
+ cleanup that silently declined to touch the worst file in the area looks
97
+ identical to one that found nothing there.