@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,358 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-start — the probe that hands a resumed session its own state back.
4
+ *
5
+ * A conductor who reopens a repo two days later has no memory of it, and the
6
+ * state that would tell them is spread across a journal, two projections, a
7
+ * doctor run and a lock directory. This injects the two-kilobyte version:
8
+ * where we are, what to do next, what is still open, and who is still
9
+ * holding what.
10
+ *
11
+ * It is a PROBE, not a gate, and the distinction is enforced in the runtime
12
+ * rather than in this comment: `SessionStart` has no way to refuse anything
13
+ * (ADR-22), so nothing here may be a check. Every failure — no state, an
14
+ * unreadable journal, a doctor that will not run — degrades to a shorter
15
+ * message or to nothing at all, and the session starts anyway. Costing a
16
+ * user their session to report that a summary was unavailable would be a
17
+ * worse bug than the missing summary.
18
+ */
19
+ import { execFileSync } from 'node:child_process';
20
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
21
+ import { cpus, totalmem } from 'node:os';
22
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ import { checkHooks } from '../../scripts/hooks-check.mjs';
26
+ import { readJournal } from '../../scripts/journal.mjs';
27
+ import { fold, inlinePlain, progressLine } from '../../scripts/project.mjs';
28
+ import { field, main, runProbe } from './hook-io.mjs';
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+ const DOCTOR = resolve(HERE, '..', '..', 'scripts', 'doctor.mjs');
32
+
33
+ /**
34
+ * This probe's own budget, deliberately far below the `timeout` its
35
+ * hooks.json entry declares (ADR-22 point 2). A unit test reads both numbers
36
+ * and refuses to let them cross; the relation is not maintained by anyone
37
+ * remembering it.
38
+ */
39
+ export const DEADLINE_MS = 4000;
40
+
41
+ /** Doctor gets the smaller half: it is one of several things that can hang. */
42
+ export const DOCTOR_TIMEOUT_MS = 2000;
43
+
44
+ /** Working target for the injection. The platform's hard ceiling is 10 000. */
45
+ export const CONTEXT_BUDGET = 2000;
46
+
47
+ /** Keep the summary skimmable; long lists are what makes context unread. */
48
+ const MAX_ROWS = 5;
49
+ const MAX_STEPS = 3;
50
+
51
+ /**
52
+ * Which directory the host repo lives in.
53
+ *
54
+ * `cwd` comes from the platform, but it is still input: it is used to build
55
+ * filesystem paths and a child-process argument, so it is checked for shape
56
+ * (absolute, existing, a directory) instead of trusted. It never reaches a
57
+ * shell — the doctor call below uses execFile with an argument vector, so
58
+ * even a path full of shell metacharacters is inert.
59
+ */
60
+ export function resolveRepoRoot(input, env = process.env, cwd = process.cwd()) {
61
+ const candidates = [field(input, 'cwd'), env.CLAUDE_PROJECT_DIR, cwd];
62
+ for (const candidate of candidates) {
63
+ if (typeof candidate !== 'string' || candidate === '' || !isAbsolute(candidate)) continue;
64
+ try {
65
+ if (statSync(candidate).isDirectory()) return candidate;
66
+ } catch {
67
+ /* try the next candidate */
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** Cores and memory, so the conductor sizes its team to the machine it is on. */
74
+ export function hardwareLine() {
75
+ const cores = cpus().length;
76
+ const gib = Math.round(totalmem() / 1024 ** 3);
77
+ // A rough ceiling, not a promise: heavy agents are memory-bound long
78
+ // before they are core-bound, so both numbers are shown and the smaller
79
+ // of the two derived limits wins.
80
+ const ceiling = Math.max(1, Math.min(Math.floor(cores / 2), Math.floor(gib / 6)));
81
+ return `${cores} cores · ${gib} GiB RAM · suggested parallel heavy agents: ${ceiling}`;
82
+ }
83
+
84
+ /**
85
+ * Doctor's verdict for this repo.
86
+ *
87
+ * `--now` is passed on purpose and is load-bearing. Without it doctor takes
88
+ * its reference clock from each journal's own last event, so an agent that
89
+ * died three days ago is compared against the timestamp of its own spawn and
90
+ * is never stale — the dead-agent check would exist and never fire. A unit
91
+ * test pins the flag into the argument vector for exactly that reason.
92
+ *
93
+ * Exit 1 means "findings", which is the normal case, not an error.
94
+ */
95
+ export function runDoctor(stateDir, nowIso, { exec = execFileSync } = {}) {
96
+ const args = [DOCTOR, '--state', '--json', '--dir', stateDir, '--now', nowIso];
97
+ let stdout;
98
+ try {
99
+ stdout = exec(process.execPath, args, {
100
+ encoding: 'utf8',
101
+ timeout: DOCTOR_TIMEOUT_MS,
102
+ maxBuffer: 8 * 1024 * 1024,
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ });
105
+ } catch (err) {
106
+ // status 1 is "doctor found something", and its report is on stdout.
107
+ if (err && err.status === 1 && typeof err.stdout === 'string') stdout = err.stdout;
108
+ else return { available: false, reason: err?.message ?? 'doctor did not run' };
109
+ }
110
+ try {
111
+ const parsed = JSON.parse(stdout);
112
+ return { available: true, counts: parsed.counts, findings: parsed.findings ?? [] };
113
+ } catch (err) {
114
+ return { available: false, reason: `doctor output was not JSON: ${err.message}` };
115
+ }
116
+ }
117
+
118
+ /** Every initiative under `.tyran/state`, folded, worst failures swallowed. */
119
+ export function readInitiatives(stateDir) {
120
+ const root = join(stateDir, 'state');
121
+ let names;
122
+ try {
123
+ names = readdirSync(root).sort();
124
+ } catch {
125
+ return [];
126
+ }
127
+ const out = [];
128
+ for (const name of names) {
129
+ const journal = join(root, name, 'journal.jsonl');
130
+ try {
131
+ if (!statSync(join(root, name)).isDirectory()) continue;
132
+ const read = readJournal(journal);
133
+ out.push({ name, state: fold(read), events: read.events.length });
134
+ } catch (err) {
135
+ out.push({ name, state: null, error: err.message });
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function rows(list, render) {
142
+ const lines = list.slice(0, MAX_ROWS).map(render);
143
+ if (list.length > MAX_ROWS) lines.push(`- ... and ${list.length - MAX_ROWS} more`);
144
+ return lines;
145
+ }
146
+
147
+ /**
148
+ * Render the summary. Pure, so the whole shape of the injected context is
149
+ * testable without a filesystem, a clock or a child process.
150
+ *
151
+ * EVERY journal-derived value goes through `inlinePlain`. This text is
152
+ * injected straight into the conductor's context — it is the LAST step of the
153
+ * attack path ADR-19 describes (foreign repo -> subagent report -> journal ->
154
+ * projection -> conductor), so it is the place where a raw byte costs the most.
155
+ *
156
+ * It did not do this until a security review measured it: 400 hostile trees
157
+ * put 35 819 invisible codepoints into this function's output, 400 of 400
158
+ * leaking, with "IGNORE PRIOR" reconstructable from the TAG characters. The
159
+ * process was safe only because hook-io sanitizes one floor up — and that is
160
+ * caller discipline, the mechanism class this project distrusts on principle
161
+ * and rejects by name in journal.mjs and doctor.mjs. Worse than redundant
162
+ * defence: there was ZERO here and ONE at the consumer, and no test at this
163
+ * level held any of it.
164
+ *
165
+ * Sanitizing here also restores the budget below. `fitBudget` measured the
166
+ * text BEFORE hook-io expanded it, so a hostile journal produced 9 880
167
+ * characters of injected context against a 2 000-character budget — 4.9x over,
168
+ * 120 characters under the platform's hard ceiling. Escaping first means the
169
+ * length fitBudget sees is the length that ships.
170
+ */
171
+ export function renderContext({ repoRoot, initiatives, doctor, hardware, nowIso }) {
172
+ if (initiatives.length === 0) return '';
173
+ const lines = [
174
+ '## Tyran state (injected by the session-start probe)',
175
+ '',
176
+ `Repo: ${inlinePlain(repoRoot)} · as of ${inlinePlain(nowIso)}`,
177
+ `Machine: ${inlinePlain(hardware)}`,
178
+ '',
179
+ ];
180
+
181
+ for (const { name, state, error } of initiatives) {
182
+ lines.push(`### Initiative \`${inlinePlain(name)}\``);
183
+ if (state === null) {
184
+ lines.push(`- journal unreadable: ${inlinePlain(error)}`);
185
+ lines.push('');
186
+ continue;
187
+ }
188
+ lines.push(`- ${progressLine(state)}`);
189
+ if (state.checkpoint) {
190
+ lines.push(
191
+ `- Checkpoint: ${inlinePlain(state.checkpoint.phase ?? '(no phase)')} at ${inlinePlain(state.checkpoint.ts)} by ${inlinePlain(state.checkpoint.actor)}`,
192
+ );
193
+ const steps = Array.isArray(state.checkpoint.nextSteps) ? state.checkpoint.nextSteps : [];
194
+ if (steps.length > 0) {
195
+ lines.push(`- First ${Math.min(MAX_STEPS, steps.length)} step(s) on resume:`);
196
+ steps.slice(0, MAX_STEPS).forEach((step, i) => lines.push(` ${i + 1}. ${inlinePlain(step)}`));
197
+ }
198
+ } else {
199
+ lines.push('- No checkpoint yet — this initiative has never been paused deliberately.');
200
+ }
201
+
202
+ const openGates = state.openGates ?? [];
203
+ if (openGates.length > 0) {
204
+ lines.push(`- Open gates (${openGates.length}):`);
205
+ lines.push(...rows(openGates, (g) => ` - ${inlinePlain(g.kind)}: ${inlinePlain(g.result ?? 'open')} (${inlinePlain(g.ts)})`));
206
+ }
207
+
208
+ const leases = [...(state.leases?.values() ?? [])];
209
+ if (leases.length > 0) {
210
+ lines.push(`- Open leases (${leases.length}) — do NOT touch these resources:`);
211
+ lines.push(...rows(leases, (l) => ` - ${inlinePlain(l.resource)} held by ${inlinePlain(l.holder ?? '(unknown)')} since ${inlinePlain(l.ts)}`));
212
+ }
213
+
214
+ const working = (state.agents ?? []).filter((a) => a.status === 'running');
215
+ if (working.length > 0) {
216
+ lines.push(`- Agents the journal still believes are working (${working.length}):`);
217
+ lines.push(
218
+ ...rows(working, (a) => ` - ${inlinePlain(a.agent)} (${inlinePlain(a.role ?? 'no role')}) since ${inlinePlain(a.spawnTs ?? '?')}`),
219
+ );
220
+ }
221
+ lines.push('');
222
+ }
223
+
224
+ if (doctor.available) {
225
+ const c = doctor.counts ?? { error: 0, warning: 0, info: 0 };
226
+ lines.push(`### Doctor: ${c.error} error(s) · ${c.warning} warning(s) · ${c.info} info`);
227
+ const loud = (doctor.findings ?? []).filter((f) => f.severity !== 'info');
228
+ lines.push(...rows(loud, (f) => `- [${inlinePlain(f.code)}] ${inlinePlain(f.where)}`));
229
+ if (loud.length === 0) lines.push('- nothing above info level');
230
+ } else {
231
+ lines.push(`### Doctor did not run: ${inlinePlain(doctor.reason)}`);
232
+ lines.push('- run `node scripts/doctor.mjs --state --now "$(date -u +%FT%TZ)"` by hand');
233
+ }
234
+ lines.push('');
235
+ lines.push('Run `/tyran` to resume, or read `.tyran/state/<init>/STATE.md` in full.');
236
+ return lines.join('\n');
237
+ }
238
+
239
+ /**
240
+ * The plugin's own gates, checked at session start — the ONE thing this probe
241
+ * says even in a repository that has no Tyran state at all.
242
+ *
243
+ * Silent when healthy, and that is the whole design. A note on every session
244
+ * saying "your gates are fine" is noise that trains the reader to skip the
245
+ * section, and this text has to be read on the one day it is not fine.
246
+ *
247
+ * It goes FIRST in the injected context on purpose: `fitBudget` drops whole
248
+ * sections from the END, so anything placed after the initiative summaries
249
+ * would be the first thing lost in exactly the repositories that have the
250
+ * most state — and a warning that disappears when the report gets long is a
251
+ * warning that is absent when it matters.
252
+ *
253
+ * This is DETECTION, not enforcement, and the text says so to the reader as
254
+ * well as to us: `SessionStart` has no refusal channel (ADR-22), so the probe
255
+ * cannot stop a session whose gates are dead. It can only make sure that the
256
+ * person and the agent both know.
257
+ */
258
+ export function renderHookWarning(result) {
259
+ if (result === null) return '';
260
+ const loud = result.findings.filter((f) => f.severity !== 'info');
261
+ if (loud.length === 0) return '';
262
+ const lines = [
263
+ '### WARNING: this plugin has gates that cannot fire',
264
+ '',
265
+ `\`tyran doctor --hooks\` finds ${result.counts.error} error(s) and ${result.counts.warning} warning(s) ` +
266
+ 'in the hook registration. A hook whose file is missing, is not executable, or whose matcher can ' +
267
+ 'never match is not a weaker control — the platform fails OPEN, so the action it was meant to ' +
268
+ 'check simply proceeds, with nothing printed anywhere.',
269
+ '',
270
+ ];
271
+ lines.push(...rows(loud, (f) => `- [${inlinePlain(f.code)}] ${inlinePlain(f.where)}`));
272
+ lines.push('');
273
+ lines.push('Run `node scripts/doctor.mjs --hooks` for the full reason and the fix for each one.');
274
+ lines.push(
275
+ 'This probe can only REPORT it: SessionStart has no way to refuse, so nothing here stops a session ' +
276
+ 'whose gates are dead.',
277
+ );
278
+ lines.push('');
279
+ return lines.join('\n');
280
+ }
281
+
282
+ /** Never let a broken check break a session — it is a probe (ADR-22). */
283
+ export function hookHealth({ check = checkHooks } = {}) {
284
+ try {
285
+ return check();
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Trim to the working budget on a SECTION boundary, keeping the header and
293
+ * saying what was dropped. The runtime enforces the platform's hard 10 000
294
+ * ceiling on top of this; this one exists so the injection stays short
295
+ * enough to actually be read.
296
+ */
297
+ export function fitBudget(text, budget = CONTEXT_BUDGET) {
298
+ if (text.length <= budget) return text;
299
+ const sections = text.split('\n### ');
300
+ let out = sections[0];
301
+ let kept = 0;
302
+ for (const section of sections.slice(1)) {
303
+ const candidate = `${out}\n### ${section}`;
304
+ if (candidate.length > budget) break;
305
+ out = candidate;
306
+ kept++;
307
+ }
308
+ const dropped = sections.length - 1 - kept;
309
+ return `${out}\n\n[tyran session-start: ${dropped} further section(s) omitted to stay inside the ${budget}-character working budget; read .tyran/state/ for the rest]`;
310
+ }
311
+
312
+ export async function buildContext({ input, now = new Date(), env = process.env, health = hookHealth } = {}) {
313
+ // Said even in a repository that has nothing to do with Tyran: the claim
314
+ // being corrected is about the PLUGIN the user installed, not about this
315
+ // project's state, and a user whose gates are dead needs to hear it in the
316
+ // session where they are relying on them.
317
+ const warning = renderHookWarning(health());
318
+ const repoRoot = resolveRepoRoot(input, env);
319
+ if (repoRoot === null) return fitBudget(warning);
320
+ const stateDir = join(repoRoot, '.tyran');
321
+ if (!existsSync(stateDir)) return fitBudget(warning); // not a Tyran repo — but a dead gate still counts
322
+ const nowIso = now.toISOString();
323
+ return fitBudget(
324
+ warning +
325
+ renderContext({
326
+ repoRoot,
327
+ initiatives: readInitiatives(stateDir),
328
+ doctor: runDoctor(stateDir, nowIso),
329
+ hardware: hardwareLine(),
330
+ nowIso,
331
+ }),
332
+ );
333
+ }
334
+
335
+ /** See journal.mjs — both sides canonicalized, or a symlinked path no-ops. */
336
+ function canonicalPath(path) {
337
+ const abs = resolve(path);
338
+ try {
339
+ return realpathSync(abs);
340
+ } catch {
341
+ return abs;
342
+ }
343
+ }
344
+
345
+ function isMainModule(moduleUrl) {
346
+ if (!process.argv[1]) return false;
347
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
348
+ }
349
+
350
+ if (isMainModule(import.meta.url)) {
351
+ await main(() =>
352
+ runProbe({
353
+ event: 'SessionStart',
354
+ deadlineMs: DEADLINE_MS,
355
+ handler: ({ input }) => buildContext({ input }),
356
+ }),
357
+ );
358
+ }