@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,287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scan-repo — everything about a repository that can be established WITHOUT
|
|
4
|
+
* asking, and an honest list of what cannot.
|
|
5
|
+
*
|
|
6
|
+
* This is the deterministic half of `/tyran:setup`. Keeping it in a script
|
|
7
|
+
* rather than in the skill's prose matters for one reason: the same repo must
|
|
8
|
+
* produce the same answer every time. A model asked to "work out the
|
|
9
|
+
* validation commands" produces a plausible answer, and a plausible answer to
|
|
10
|
+
* "how do I know this repo is green" is worse than no answer, because it is
|
|
11
|
+
* believed once and then never re-examined.
|
|
12
|
+
*
|
|
13
|
+
* Every value it emits carries PROVENANCE — the fact that produced it and a
|
|
14
|
+
* confidence. A field it could not establish is marked `needs_confirmation`,
|
|
15
|
+
* which is the only thing setup is allowed to ask the operator about. The
|
|
16
|
+
* point of the provenance is auditability months later: "why does this repo
|
|
17
|
+
* think it is P2" has an answer in the file itself.
|
|
18
|
+
*
|
|
19
|
+
* ## The one thing this deliberately will not do
|
|
20
|
+
*
|
|
21
|
+
* It never infers `P3`. Autonomy class P3 means an agent merges to main and
|
|
22
|
+
* deploys to production; no arrangement of files is evidence that a human
|
|
23
|
+
* intended to grant that. P1 is the default, P2 requires positive evidence,
|
|
24
|
+
* and P3 is a decision a person makes in words.
|
|
25
|
+
*
|
|
26
|
+
* CLI:
|
|
27
|
+
* node scan-repo.mjs [--dir <repo>] [--write <path>] [--json]
|
|
28
|
+
* Exit: 0 scanned · 2 usage/IO error
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { join, resolve, dirname, extname } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { stringify } from './yaml-lite.mjs';
|
|
35
|
+
import { escapeInvisible } from './invisible.mjs';
|
|
36
|
+
|
|
37
|
+
/** Lockfile -> package manager. Order matters only for reporting stability. */
|
|
38
|
+
export const LOCKFILES = Object.freeze({
|
|
39
|
+
'pnpm-lock.yaml': 'pnpm',
|
|
40
|
+
'bun.lockb': 'bun',
|
|
41
|
+
'yarn.lock': 'yarn',
|
|
42
|
+
'package-lock.json': 'npm',
|
|
43
|
+
'poetry.lock': 'poetry',
|
|
44
|
+
'uv.lock': 'uv',
|
|
45
|
+
'Cargo.lock': 'cargo',
|
|
46
|
+
'go.sum': 'go',
|
|
47
|
+
'Gemfile.lock': 'bundler',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Extension -> language, for the "what is this repo written in" summary. */
|
|
51
|
+
export const LANGUAGES = Object.freeze({
|
|
52
|
+
'.ts': 'TypeScript',
|
|
53
|
+
'.tsx': 'TypeScript',
|
|
54
|
+
'.js': 'JavaScript',
|
|
55
|
+
'.jsx': 'JavaScript',
|
|
56
|
+
'.mjs': 'JavaScript',
|
|
57
|
+
'.py': 'Python',
|
|
58
|
+
'.rs': 'Rust',
|
|
59
|
+
'.go': 'Go',
|
|
60
|
+
'.rb': 'Ruby',
|
|
61
|
+
'.java': 'Java',
|
|
62
|
+
'.kt': 'Kotlin',
|
|
63
|
+
'.swift': 'Swift',
|
|
64
|
+
'.php': 'PHP',
|
|
65
|
+
'.cs': 'C#',
|
|
66
|
+
'.sh': 'Shell',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Script names worth running before calling something done, most valuable
|
|
71
|
+
* first. `build` is deliberately absent: it is slow, and on most repos the
|
|
72
|
+
* type check already covers what a build would catch.
|
|
73
|
+
*/
|
|
74
|
+
export const VALIDATION_SCRIPTS = Object.freeze(['lint', 'typecheck', 'types', 'test']);
|
|
75
|
+
|
|
76
|
+
const RUN_PREFIX = Object.freeze({ npm: 'npm run', pnpm: 'pnpm', yarn: 'yarn', bun: 'bun run' });
|
|
77
|
+
|
|
78
|
+
function provenance(value, source, confidence, needsConfirmation = false) {
|
|
79
|
+
return { value, source, confidence, needs_confirmation: needsConfirmation };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run a git command, returning '' when git is absent or the call fails. */
|
|
83
|
+
export function gitRunner(dir) {
|
|
84
|
+
return (args) => {
|
|
85
|
+
try {
|
|
86
|
+
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
87
|
+
} catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function detectPackageManager(dir) {
|
|
94
|
+
for (const [file, manager] of Object.entries(LOCKFILES)) {
|
|
95
|
+
if (existsSync(join(dir, file))) return provenance(manager, `lockfile: ${file}`, 0.95);
|
|
96
|
+
}
|
|
97
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
98
|
+
return provenance('npm', 'package.json with no lockfile — npm assumed', 0.5, true);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readPackageJson(dir) {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
106
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validation commands, taken from what the repo ACTUALLY declares.
|
|
114
|
+
*
|
|
115
|
+
* A guessed command is worse than none: it fails for a reason unrelated to
|
|
116
|
+
* the change, the agent spends a round on it, and the operator learns the
|
|
117
|
+
* gate is noise. When nothing can be read, the list comes back empty and
|
|
118
|
+
* flagged rather than filled in with `npm test`.
|
|
119
|
+
*/
|
|
120
|
+
export function detectValidation(dir, pkg, manager) {
|
|
121
|
+
const scripts = pkg?.scripts;
|
|
122
|
+
if (scripts && typeof scripts === 'object') {
|
|
123
|
+
const prefix = RUN_PREFIX[manager] ?? 'npm run';
|
|
124
|
+
const found = VALIDATION_SCRIPTS.filter((name) => typeof scripts[name] === 'string').map((n) => `${prefix} ${n}`);
|
|
125
|
+
if (found.length > 0) return provenance(found, `package.json scripts: ${found.length} of the usual names`, 0.9);
|
|
126
|
+
}
|
|
127
|
+
if (existsSync(join(dir, 'Makefile'))) {
|
|
128
|
+
try {
|
|
129
|
+
const text = readFileSync(join(dir, 'Makefile'), 'utf8');
|
|
130
|
+
const targets = ['lint', 'test', 'check'].filter((t) => new RegExp(`^${t}:`, 'm').test(text));
|
|
131
|
+
if (targets.length > 0) {
|
|
132
|
+
return provenance(targets.map((t) => `make ${t}`), `Makefile targets: ${targets.join(', ')}`, 0.8);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
/* fall through to the honest empty answer */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return provenance([], 'no scripts or Makefile targets recognised — fill these in yourself', 0.2, true);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function detectLanguages(dir, run = gitRunner(dir)) {
|
|
142
|
+
const listing = run(['ls-files']);
|
|
143
|
+
if (listing === '') return [];
|
|
144
|
+
const counts = new Map();
|
|
145
|
+
for (const file of listing.split('\n')) {
|
|
146
|
+
const lang = LANGUAGES[extname(file)];
|
|
147
|
+
if (lang) counts.set(lang, (counts.get(lang) ?? 0) + 1);
|
|
148
|
+
}
|
|
149
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([lang]) => lang);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Infer the deployment autonomy class from how the repository is actually
|
|
154
|
+
* worked, not from what a document claims.
|
|
155
|
+
*
|
|
156
|
+
* P3 is never inferred. See the header: no arrangement of files is evidence
|
|
157
|
+
* that a human meant to let an agent deploy to production.
|
|
158
|
+
*/
|
|
159
|
+
export function detectAutonomy(dir, run = gitRunner(dir)) {
|
|
160
|
+
const head = run(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
161
|
+
if (head === '') return provenance('P1', 'not a git repository — the safest class', 0.4, true);
|
|
162
|
+
|
|
163
|
+
const log = run(['log', '--first-parent', '-50', '--pretty=%p|%s']).trim();
|
|
164
|
+
if (log === '') return provenance('P1', 'no commit history to judge from — the safest class', 0.4, true);
|
|
165
|
+
|
|
166
|
+
const commits = log.split('\n');
|
|
167
|
+
const merges = commits.filter((line) => {
|
|
168
|
+
const [parents, subject = ''] = line.split('|');
|
|
169
|
+
return parents.trim().split(/\s+/).length > 1 || /^Merge pull request|^Merge branch/.test(subject);
|
|
170
|
+
}).length;
|
|
171
|
+
const share = merges / commits.length;
|
|
172
|
+
|
|
173
|
+
const branches = run(['branch', '-a', '--format=%(refname:short)']);
|
|
174
|
+
const hasStaging = /(^|\/)(staging|testing|develop|preprod)$/m.test(branches);
|
|
175
|
+
const hasCi = existsSync(join(dir, '.github', 'workflows')) || existsSync(join(dir, '.gitlab-ci.yml'));
|
|
176
|
+
|
|
177
|
+
if (share >= 0.6) {
|
|
178
|
+
return provenance(
|
|
179
|
+
'P1',
|
|
180
|
+
`git log: ${merges} of the last ${commits.length} first-parent commits arrived as merges — main is PR-driven`,
|
|
181
|
+
0.85,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (hasStaging && hasCi) {
|
|
185
|
+
return provenance(
|
|
186
|
+
'P2',
|
|
187
|
+
`git log: ${merges}/${commits.length} merges, plus a staging-class branch and CI — direct pushes look normal here`,
|
|
188
|
+
0.6,
|
|
189
|
+
true,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return provenance(
|
|
193
|
+
'P1',
|
|
194
|
+
`git log: ${merges}/${commits.length} merges, no staging branch found — defaulting to the safest class`,
|
|
195
|
+
0.5,
|
|
196
|
+
true,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The whole scan. Returns `{config, languages, questions}`. */
|
|
201
|
+
export function scanRepo(dir, { run = gitRunner(dir) } = {}) {
|
|
202
|
+
const pkg = readPackageJson(dir);
|
|
203
|
+
const manager = detectPackageManager(dir);
|
|
204
|
+
const validation = detectValidation(dir, pkg, manager?.value);
|
|
205
|
+
const autonomy = detectAutonomy(dir, run);
|
|
206
|
+
const languages = detectLanguages(dir, run);
|
|
207
|
+
|
|
208
|
+
const config = {
|
|
209
|
+
profile: 'balanced',
|
|
210
|
+
autonomy,
|
|
211
|
+
tiers: { cheap: 'haiku', work: 'sonnet', deep: 'opus', top: 'fable' },
|
|
212
|
+
validation,
|
|
213
|
+
shared_zones: [],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const questions = [];
|
|
217
|
+
if (autonomy.needs_confirmation) questions.push({ field: 'autonomy', asked: autonomy.source });
|
|
218
|
+
if (validation.needs_confirmation) questions.push({ field: 'validation', asked: validation.source });
|
|
219
|
+
if (manager?.needs_confirmation) questions.push({ field: 'package manager', asked: manager.source });
|
|
220
|
+
|
|
221
|
+
return { config, languages, packageManager: manager, questions };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Render the config to YAML.
|
|
226
|
+
*
|
|
227
|
+
* `stringify` is the repo's own emitter, which the parser round-trips — a
|
|
228
|
+
* hand-rolled writer here would reintroduce the quoting bug that has already
|
|
229
|
+
* cost this project one red CI run (an apostrophe inside an unquoted scalar).
|
|
230
|
+
*/
|
|
231
|
+
export function renderConfig(config) {
|
|
232
|
+
return (
|
|
233
|
+
'# Tyran configuration for this repository.\n' +
|
|
234
|
+
'#\n' +
|
|
235
|
+
'# Written by /tyran:setup. Inferred values carry provenance so you can\n' +
|
|
236
|
+
'# audit where each one came from. Edit freely — your edits win.\n' +
|
|
237
|
+
'#\n' +
|
|
238
|
+
'# Validate: node scripts/schema.mjs validate config .tyran/config.yaml\n' +
|
|
239
|
+
'# Resolve a role to a model: node scripts/tiers.mjs --role reviewer\n' +
|
|
240
|
+
'\n' +
|
|
241
|
+
stringify(config)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function main() {
|
|
246
|
+
const args = process.argv.slice(2);
|
|
247
|
+
const flag = (name, fallback) => {
|
|
248
|
+
const i = args.indexOf(`--${name}`);
|
|
249
|
+
return i === -1 ? fallback : args[i + 1];
|
|
250
|
+
};
|
|
251
|
+
const dir = resolve(flag('dir', process.cwd()));
|
|
252
|
+
if (!existsSync(dir)) {
|
|
253
|
+
console.error(`scan-repo: no such directory ${escapeInvisible(dir)}`);
|
|
254
|
+
process.exit(2);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const result = scanRepo(dir);
|
|
258
|
+
const target = flag('write', null);
|
|
259
|
+
if (target !== null) {
|
|
260
|
+
const path = resolve(target);
|
|
261
|
+
try {
|
|
262
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
263
|
+
writeFileSync(path, renderConfig(result.config));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error(`scan-repo: could not write ${escapeInvisible(path)}: ${error.message}`);
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
console.error(`scan-repo: wrote ${escapeInvisible(path)}`);
|
|
269
|
+
}
|
|
270
|
+
console.log(JSON.stringify(result, null, 2));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function canonicalPath(path) {
|
|
274
|
+
const abs = resolve(path);
|
|
275
|
+
try {
|
|
276
|
+
return realpathSync(abs);
|
|
277
|
+
} catch {
|
|
278
|
+
return abs;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isMainModule(moduleUrl) {
|
|
283
|
+
if (!process.argv[1]) return false;
|
|
284
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (isMainModule(import.meta.url)) main();
|