@jjanczur/tyran 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CHANGELOG.md +245 -0
- package/LICENSE +201 -0
- package/README.md +468 -0
- package/agents/.gitkeep +0 -0
- package/agents/implementer.md +60 -0
- package/agents/retro.md +88 -0
- package/agents/reviewer.md +55 -0
- package/agents/scout.md +60 -0
- package/bin/tyran.mjs +172 -0
- package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
- package/hooks/hooks.json +83 -0
- package/hooks/scripts/.gitkeep +0 -0
- package/hooks/scripts/evidence-gate.mjs +705 -0
- package/hooks/scripts/hook-io.mjs +813 -0
- package/hooks/scripts/policy-gate.mjs +1402 -0
- package/hooks/scripts/pre-compact.mjs +211 -0
- package/hooks/scripts/retro-gate.mjs +191 -0
- package/hooks/scripts/secrets-gate.mjs +1683 -0
- package/hooks/scripts/session-start.mjs +358 -0
- package/hooks/scripts/write-guard.mjs +475 -0
- package/package.json +52 -0
- package/scripts/desc-budget.mjs +139 -0
- package/scripts/doctor.mjs +1267 -0
- package/scripts/hooks-check.mjs +1312 -0
- package/scripts/invisible.mjs +346 -0
- package/scripts/journal.mjs +747 -0
- package/scripts/project.mjs +981 -0
- package/scripts/scan-control-chars.mjs +547 -0
- package/scripts/scan-repo.mjs +287 -0
- package/scripts/schema.mjs +467 -0
- package/scripts/stop-check.mjs +89 -0
- package/scripts/tiers.mjs +324 -0
- package/scripts/yaml-lite.mjs +383 -0
- package/skills/browser-check/SKILL.md +118 -0
- package/skills/code-review/SKILL.md +83 -0
- package/skills/deslop/SKILL.md +97 -0
- package/skills/doctor/SKILL.md +35 -0
- package/skills/fidelity-gate/SKILL.md +132 -0
- package/skills/hello/SKILL.md +16 -0
- package/skills/pr-feedback/SKILL.md +85 -0
- package/skills/prompt-tuning/SKILL.md +102 -0
- package/skills/retro/SKILL.md +69 -0
- package/skills/root-cause/SKILL.md +89 -0
- package/skills/run/SKILL.md +285 -0
- package/skills/setup/SKILL.md +79 -0
- package/skills/skill-writing/SKILL.md +99 -0
- package/skills/status/SKILL.md +31 -0
- package/templates/.gitkeep +0 -0
- package/templates/config.yaml +44 -0
- package/templates/knowledge.yaml +23 -0
- package/templates/policies/autonomy.yaml +61 -0
- package/templates/project-command/SKILL.md +16 -0
|
@@ -0,0 +1,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.
|