@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,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();