@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,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tiers — resolve a ROLE to a MODEL, in one place.
|
|
4
|
+
*
|
|
5
|
+
* The point of this file is that no other file in the plugin contains a model
|
|
6
|
+
* name. Skills, agents and policies are written in role names; `.tyran/
|
|
7
|
+
* config.yaml` maps four capability tiers to model aliases; this script is
|
|
8
|
+
* the only thing that joins them. A model deprecation is then a one-line edit
|
|
9
|
+
* in one file rather than a sweep through every prompt in the repo — and a
|
|
10
|
+
* sweep through prompts is exactly the kind of change that gets 90% done.
|
|
11
|
+
*
|
|
12
|
+
* The default routing puts everyday work on `work` and reserves `deep` and
|
|
13
|
+
* `top` for calls where being wrong is both expensive and hard to notice. A
|
|
14
|
+
* cost mode that routes everything to the strongest model is not a cost mode;
|
|
15
|
+
* a cost mode that routes a security review to the cheapest one is worse.
|
|
16
|
+
*
|
|
17
|
+
* The table is a STARTING POINT, not a prediction of every task. The
|
|
18
|
+
* conductor may override the tier or the effort for one subtask when it can
|
|
19
|
+
* see that the default does not fit — that is the intended use, not an
|
|
20
|
+
* escape. The one thing an override cannot do is go below a role floor, and
|
|
21
|
+
* when a floor corrects an override the CLI says so out loud rather than
|
|
22
|
+
* quietly handing back something other than what was asked for.
|
|
23
|
+
*
|
|
24
|
+
* CLI:
|
|
25
|
+
* node tiers.mjs [--config <path>] [--profile P] [--role R] [--risk L]
|
|
26
|
+
* [--tier T] [--effort E] [--field model|tier|effort|json]
|
|
27
|
+
* node tiers.mjs --role reviewer --risk high # -> a model alias
|
|
28
|
+
* node tiers.mjs --role implementer --effort high # same model, think harder
|
|
29
|
+
* node tiers.mjs --role reviewer --field json # -> {tier, model, effort}
|
|
30
|
+
* node tiers.mjs # -> the whole map, JSON
|
|
31
|
+
* Exit: 0 resolved · 2 usage/IO/config error
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
34
|
+
import { dirname, join, resolve } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { parse } from './yaml-lite.mjs';
|
|
37
|
+
import { validateConfig, PROFILES, TIER_KEYS } from './schema.mjs';
|
|
38
|
+
import { escapeInvisible } from './invisible.mjs';
|
|
39
|
+
|
|
40
|
+
/** Capability tiers, cheapest first. Index order is the escalation ladder. */
|
|
41
|
+
export const TIER_ORDER = TIER_KEYS;
|
|
42
|
+
|
|
43
|
+
export const RISK_LEVELS = Object.freeze(['low', 'normal', 'high']);
|
|
44
|
+
|
|
45
|
+
/** Reasoning effort, cheapest first. Same ladder semantics as the tiers. */
|
|
46
|
+
export const EFFORT_ORDER = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default effort for a tier.
|
|
50
|
+
*
|
|
51
|
+
* Effort and model are separate dials on purpose. Most of the time raising
|
|
52
|
+
* one without the other is the right move: a mechanical sweep on a strong
|
|
53
|
+
* model still does not need deep reasoning, and a subtle diagnosis on the
|
|
54
|
+
* middle model often does. Collapsing them into a single "power" setting is
|
|
55
|
+
* what makes cost modes blunt enough to be ignored.
|
|
56
|
+
*/
|
|
57
|
+
export const EFFORT_BY_TIER = Object.freeze({
|
|
58
|
+
cheap: 'low',
|
|
59
|
+
work: 'medium',
|
|
60
|
+
deep: 'high',
|
|
61
|
+
top: 'xhigh',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Roles whose effort may never drop below this, whatever the tier says.
|
|
66
|
+
*
|
|
67
|
+
* The reasoning here is the same as the tier floor and it is worth stating
|
|
68
|
+
* twice: a security review that misses a hole is not corrected downstream,
|
|
69
|
+
* because everything downstream trusts it.
|
|
70
|
+
*/
|
|
71
|
+
export const ROLE_EFFORT_FLOOR = Object.freeze({
|
|
72
|
+
'security-review': 'max',
|
|
73
|
+
arbitration: 'high',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Role -> tier, per cost profile.
|
|
78
|
+
*
|
|
79
|
+
* Read the table as a claim about where model strength CHANGES an outcome.
|
|
80
|
+
* A scout reports what a file says; a stronger model does not make the file
|
|
81
|
+
* say something else. A security reviewer decides whether a hole is real,
|
|
82
|
+
* and a miss there survives every downstream check, because everything
|
|
83
|
+
* downstream trusts it.
|
|
84
|
+
*/
|
|
85
|
+
export const ROLE_TIERS = Object.freeze({
|
|
86
|
+
scout: { eco: 'cheap', balanced: 'cheap', full: 'work' },
|
|
87
|
+
implementer: { eco: 'work', balanced: 'work', full: 'deep' },
|
|
88
|
+
reviewer: { eco: 'work', balanced: 'work', full: 'deep' },
|
|
89
|
+
'security-review': { eco: 'top', balanced: 'top', full: 'top' },
|
|
90
|
+
arbitration: { eco: 'top', balanced: 'top', full: 'top' },
|
|
91
|
+
acceptance: { eco: 'deep', balanced: 'top', full: 'top' },
|
|
92
|
+
retro: { eco: 'work', balanced: 'work', full: 'deep' },
|
|
93
|
+
bookkeeping: { eco: 'cheap', balanced: 'cheap', full: 'cheap' },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Roles that may never be routed BELOW this tier, whatever the profile or the
|
|
98
|
+
* risk argument says.
|
|
99
|
+
*
|
|
100
|
+
* Without the floor, `--profile eco --risk low` is a one-flag downgrade of the
|
|
101
|
+
* two judgements the whole design leans on. The floor is applied last, after
|
|
102
|
+
* the risk shift, so it cannot be shifted out of.
|
|
103
|
+
*/
|
|
104
|
+
export const ROLE_FLOOR = Object.freeze({
|
|
105
|
+
'security-review': 'top',
|
|
106
|
+
arbitration: 'top',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const ROLES = Object.freeze(Object.keys(ROLE_TIERS));
|
|
110
|
+
|
|
111
|
+
function validateInputs(role, profile, risk) {
|
|
112
|
+
if (!Object.hasOwn(ROLE_TIERS, role)) {
|
|
113
|
+
throw new Error(`unknown role "${escapeInvisible(role)}" (known: ${ROLES.join(', ')})`);
|
|
114
|
+
}
|
|
115
|
+
if (!PROFILES.includes(profile)) {
|
|
116
|
+
throw new Error(`unknown profile "${escapeInvisible(profile)}" (known: ${PROFILES.join(', ')})`);
|
|
117
|
+
}
|
|
118
|
+
if (!RISK_LEVELS.includes(risk)) {
|
|
119
|
+
throw new Error(`unknown risk "${escapeInvisible(risk)}" (known: ${RISK_LEVELS.join(', ')})`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve role + profile + risk to a tier KEY (no model names involved).
|
|
125
|
+
*
|
|
126
|
+
* `override.tier` is the conductor deciding, in the moment, that this
|
|
127
|
+
* particular subtask needs something the table did not anticipate. That is a
|
|
128
|
+
* legitimate and expected move — the table is a starting point, not a
|
|
129
|
+
* prediction of every task. What the override may NOT do is go below a role
|
|
130
|
+
* floor, which is applied last, after both the risk shift and the override.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveTier(role, profile, risk = 'normal', override = {}) {
|
|
133
|
+
validateInputs(role, profile, risk);
|
|
134
|
+
|
|
135
|
+
let tier;
|
|
136
|
+
if (override.tier !== undefined && override.tier !== null) {
|
|
137
|
+
if (!TIER_ORDER.includes(override.tier)) {
|
|
138
|
+
throw new Error(`unknown tier override "${escapeInvisible(override.tier)}" (known: ${TIER_ORDER.join(', ')})`);
|
|
139
|
+
}
|
|
140
|
+
tier = override.tier;
|
|
141
|
+
} else {
|
|
142
|
+
const base = ROLE_TIERS[role][profile];
|
|
143
|
+
const shift = risk === 'high' ? 1 : risk === 'low' ? -1 : 0;
|
|
144
|
+
tier = TIER_ORDER[clamp(TIER_ORDER.indexOf(base) + shift, 0, TIER_ORDER.length - 1)];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return raiseTo(TIER_ORDER, tier, ROLE_FLOOR[role]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve reasoning effort the same way, on its own ladder.
|
|
152
|
+
*
|
|
153
|
+
* Effort follows the tier by default and is separately overridable, because
|
|
154
|
+
* "this needs more thinking" and "this needs a stronger model" are different
|
|
155
|
+
* judgements and the conductor routinely has one without the other.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveEffort(role, profile, risk = 'normal', override = {}) {
|
|
158
|
+
validateInputs(role, profile, risk);
|
|
159
|
+
|
|
160
|
+
let effort;
|
|
161
|
+
if (override.effort !== undefined && override.effort !== null) {
|
|
162
|
+
if (!EFFORT_ORDER.includes(override.effort)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`unknown effort override "${escapeInvisible(override.effort)}" (known: ${EFFORT_ORDER.join(', ')})`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
effort = override.effort;
|
|
168
|
+
} else {
|
|
169
|
+
const tier = resolveTier(role, profile, risk, override);
|
|
170
|
+
const base = EFFORT_BY_TIER[tier];
|
|
171
|
+
// The risk shift applies to effort even when the tier was pinned by an
|
|
172
|
+
// override: "same model, think harder" is the single most common thing
|
|
173
|
+
// the conductor actually wants.
|
|
174
|
+
const shift = risk === 'high' ? 1 : risk === 'low' ? -1 : 0;
|
|
175
|
+
effort = EFFORT_ORDER[clamp(EFFORT_ORDER.indexOf(base) + shift, 0, EFFORT_ORDER.length - 1)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return raiseTo(EFFORT_ORDER, effort, ROLE_EFFORT_FLOOR[role]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Raise `value` to `floor` when a floor exists and sits above it. */
|
|
182
|
+
function raiseTo(ladder, value, floor) {
|
|
183
|
+
if (floor && ladder.indexOf(value) < ladder.indexOf(floor)) return floor;
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function clamp(value, min, max) {
|
|
188
|
+
return Math.min(max, Math.max(min, value));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve to the model alias the config maps that tier to.
|
|
193
|
+
*
|
|
194
|
+
* Throws rather than returning undefined on a missing alias. A spawn with
|
|
195
|
+
* `model: undefined` silently runs on the session default, which is the
|
|
196
|
+
* failure this file exists to prevent: the routing appears to work and is
|
|
197
|
+
* not applied.
|
|
198
|
+
*/
|
|
199
|
+
export function resolveModel(config, role, profile, risk = 'normal', override = {}) {
|
|
200
|
+
const tier = resolveTier(role, profile, risk, override);
|
|
201
|
+
const effort = resolveEffort(role, profile, risk, override);
|
|
202
|
+
const alias = config?.tiers?.[tier];
|
|
203
|
+
if (typeof alias !== 'string' || alias.length === 0) {
|
|
204
|
+
throw new Error(`config has no model alias for tier "${tier}" — fix tiers in .tyran/config.yaml`);
|
|
205
|
+
}
|
|
206
|
+
const floored =
|
|
207
|
+
(ROLE_FLOOR[role] !== undefined && override.tier !== undefined && override.tier !== ROLE_FLOOR[role]) ||
|
|
208
|
+
(ROLE_EFFORT_FLOOR[role] !== undefined && override.effort !== undefined && override.effort !== effort);
|
|
209
|
+
return { tier, model: alias, effort, floored };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The whole routing map for one profile — what the conductor reads once. */
|
|
213
|
+
export function resolveAll(config, profile, risk = 'normal') {
|
|
214
|
+
const out = {};
|
|
215
|
+
for (const role of ROLES) out[role] = resolveModel(config, role, profile, risk);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Load a config document, falling back to the plugin's shipped template.
|
|
221
|
+
*
|
|
222
|
+
* The fallback is LOUD on purpose. A repo that has not run setup yet still
|
|
223
|
+
* gets working routing, but the operator is told which file the numbers came
|
|
224
|
+
* from — a silent fallback would let a repo believe it had adopted a policy
|
|
225
|
+
* it never wrote.
|
|
226
|
+
*/
|
|
227
|
+
export function loadConfig(configPath, pluginRoot, warn = () => {}) {
|
|
228
|
+
let path = configPath;
|
|
229
|
+
if (!existsSync(path)) {
|
|
230
|
+
const fallback = join(pluginRoot, 'templates', 'config.yaml');
|
|
231
|
+
if (!existsSync(fallback)) throw new Error(`no config at ${escapeInvisible(path)} and no shipped template`);
|
|
232
|
+
warn(`tiers: ${escapeInvisible(path)} not found — falling back to the shipped template ${escapeInvisible(fallback)}`);
|
|
233
|
+
path = fallback;
|
|
234
|
+
}
|
|
235
|
+
const doc = parse(readFileSync(path, 'utf8'));
|
|
236
|
+
const errors = validateConfig(doc);
|
|
237
|
+
if (errors.length > 0) {
|
|
238
|
+
throw new Error(`invalid config at ${escapeInvisible(path)}:\n ${errors.join('\n ')}`);
|
|
239
|
+
}
|
|
240
|
+
return { doc, path };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `profile` may carry provenance (`{value, source, ...}`) — unwrap either shape. */
|
|
244
|
+
export function readProfile(doc) {
|
|
245
|
+
const raw = doc?.profile;
|
|
246
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw.value : raw;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main() {
|
|
250
|
+
const args = process.argv.slice(2);
|
|
251
|
+
const flag = (name, fallback) => {
|
|
252
|
+
const i = args.indexOf(`--${name}`);
|
|
253
|
+
return i === -1 ? fallback : args[i + 1];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const pluginRoot = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
257
|
+
const configPath = resolve(flag('config', join(process.cwd(), '.tyran', 'config.yaml')));
|
|
258
|
+
|
|
259
|
+
let doc;
|
|
260
|
+
let path;
|
|
261
|
+
try {
|
|
262
|
+
({ doc, path } = loadConfig(configPath, pluginRoot, (m) => console.error(m)));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.error(`tiers: ${error.message}`);
|
|
265
|
+
process.exit(2);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const profile = flag('profile', readProfile(doc));
|
|
269
|
+
const risk = flag('risk', 'normal');
|
|
270
|
+
const role = flag('role', null);
|
|
271
|
+
const field = flag('field', 'model');
|
|
272
|
+
const override = { tier: flag('tier', undefined), effort: flag('effort', undefined) };
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
if (role === null) {
|
|
276
|
+
console.log(JSON.stringify({ config: path, profile, risk, roles: resolveAll(doc, profile, risk) }, null, 2));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const resolved = resolveModel(doc, role, profile, risk, override);
|
|
280
|
+
const asked = override.tier ?? override.effort;
|
|
281
|
+
// A floor that silently corrected an explicit override would teach the
|
|
282
|
+
// conductor that its overrides work when they did not. Say it.
|
|
283
|
+
if (resolved.floored) {
|
|
284
|
+
console.error(
|
|
285
|
+
`tiers: override "${escapeInvisible(String(asked))}" RAISED to ${resolved.tier}/${resolved.effort} — ` +
|
|
286
|
+
`"${escapeInvisible(role)}" has a floor that cannot be lowered`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
console.error(`tiers: ${role} @ ${profile}/${risk} -> ${resolved.tier} · effort ${resolved.effort}`);
|
|
290
|
+
if (field === 'json') console.log(JSON.stringify(resolved));
|
|
291
|
+
else if (Object.hasOwn(resolved, field)) console.log(resolved[field]);
|
|
292
|
+
else {
|
|
293
|
+
console.error(`tiers: unknown --field "${escapeInvisible(field)}" (known: model, tier, effort, json)`);
|
|
294
|
+
process.exit(2);
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error(`tiers: ${error.message}`);
|
|
298
|
+
process.exit(2);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* BOTH sides must be symlink-resolved. `import.meta.url` already names the
|
|
304
|
+
* real file; `process.argv[1]` is whatever the caller typed. Comparing them
|
|
305
|
+
* raw turns every invocation through a symlinked path into a silent no-op
|
|
306
|
+
* under exit 0 — and `/tmp` and `/var` are symlinks on macOS, so plugin
|
|
307
|
+
* installs reach `scripts/` through one as the ordinary case. This bug was
|
|
308
|
+
* found in desc-budget.mjs; the fix belongs everywhere the pattern is used.
|
|
309
|
+
*/
|
|
310
|
+
function canonicalPath(path) {
|
|
311
|
+
const abs = resolve(path);
|
|
312
|
+
try {
|
|
313
|
+
return realpathSync(abs);
|
|
314
|
+
} catch {
|
|
315
|
+
return abs;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isMainModule(moduleUrl) {
|
|
320
|
+
if (!process.argv[1]) return false;
|
|
321
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (isMainModule(import.meta.url)) main();
|