@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,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pre-compact — the state survives the compaction that is about to erase it.
4
+ *
5
+ * ## The product question, and the argument for the answer
6
+ *
7
+ * `PreCompact` CAN refuse: it is one of the events whose `decision: "block"`
8
+ * the platform honours. So the question is real — should a compaction with no
9
+ * recorded checkpoint be REFUSED, or should the hook WRITE the checkpoint and
10
+ * let it through? The answer here is **write and pass**, and it is not a
11
+ * default. Four reasons, in the order that decided it.
12
+ *
13
+ * **1. Compaction is not a dangerous act. It is a LOSSY one.** Every other
14
+ * gate in this plugin stands in front of something irreversible done to the
15
+ * outside world — a secret published, a report accepted without evidence. A
16
+ * compaction damages only the session's own memory of itself. The correct
17
+ * response to lossy is to PERSIST FIRST, not to forbid; refusing would leave
18
+ * the state exactly as unsaved as it was.
19
+ *
20
+ * **2. Refusing an automatic compaction wedges the session.** Measured: the
21
+ * input carries `trigger: "manual" | "auto"`. An `auto` compaction fires
22
+ * because the context is full — it is the platform's own memory management,
23
+ * not a user's choice. Blocking it does not buy time to save anything; it
24
+ * removes the mechanism that lets the conversation continue at all, and the
25
+ * user's remaining options are to abandon the session or to uninstall the
26
+ * plugin. ADR-19's rule applies exactly: a refusal with no reachable way
27
+ * forward produces an agent — or a person — who looks for a way around.
28
+ *
29
+ * **3. Writing CLOSES the loop; refusing breaks it.** The checkpoint written
30
+ * here is read back automatically: after the compaction the platform raises
31
+ * `SessionStart` with `source: "compact"`, which this plugin already matches
32
+ * (`startup|resume|compact`), and the session-start probe injects the state
33
+ * back into the fresh context. Persisting is not a consolation prize for not
34
+ * blocking — it is the mechanism that makes compaction survivable, and it is
35
+ * strictly better than a block, which would preserve nothing.
36
+ *
37
+ * **4. The block is kept for the ONE case where passing is worse.** If a
38
+ * journal exists and the checkpoint CANNOT be written, state is about to be
39
+ * lost silently — the failure this whole project is built to remove. There
40
+ * the hook refuses, but only on `trigger: "manual"`: the user asked for the
41
+ * compaction, is present, and can act on the reason. On `auto` it never
42
+ * refuses, for reason 2, and says so loudly on stderr instead. Asymmetric on
43
+ * purpose, and the asymmetry is the argument, not an omission.
44
+ *
45
+ * When there is no journal to write to at all — not a Tyran repository, or no
46
+ * initiative yet — it passes in silence. There is nothing to lose, and a gate
47
+ * that fires where it has no business is a gate that gets switched off.
48
+ *
49
+ * ## The output shape, which is the way this gate dies if it is wrong
50
+ *
51
+ * `PreCompact` has NO `hookSpecificOutput` variant (measured: the union is
52
+ * keyed on `hookEventName` and there is no member for it). Emitting one fails
53
+ * the platform's schema, the WHOLE output is discarded, and the refusal
54
+ * becomes an approval. This gate therefore refuses through top-level
55
+ * `decision` + `reason`, which `hook-io.refusalPayload` selects from its event
56
+ * table — this file never builds the payload itself, and a test asserts that
57
+ * no `hookSpecificOutput` appears on the wire.
58
+ */
59
+ import { realpathSync } from 'node:fs';
60
+ import { resolve } from 'node:path';
61
+ import { fileURLToPath } from 'node:url';
62
+
63
+ import { append } from '../../scripts/journal.mjs';
64
+ import { forJournal, locateJournal } from './evidence-gate.mjs';
65
+ import { resolveRepoRoot } from './session-start.mjs';
66
+ import { PASS, field, main, runGate } from './hook-io.mjs';
67
+
68
+ /**
69
+ * This gate's own budget, below the `timeout` its hooks.json entry declares.
70
+ *
71
+ * The number is inherited from a measured constraint rather than chosen:
72
+ * `journal.append` takes a cross-process mutex that waits up to 5 000 ms for a
73
+ * contended lock, and it waits SYNCHRONOUSLY (`Atomics.wait`). Nothing on this
74
+ * thread runs during that wait, so the runtime's deadline timer cannot fire —
75
+ * the one case `hook-io` documents as unenforceable. The budget is therefore
76
+ * sized to CONTAIN the worst case the lock itself bounds, not to be woken by a
77
+ * timer that cannot run. Same reasoning, and the same 8 000 ms, as the
78
+ * evidence gate, which reached it first.
79
+ *
80
+ * The contention is real here rather than hypothetical: hooks matched to one
81
+ * event run in parallel, and every other journal writer in this plugin takes
82
+ * the same lock.
83
+ */
84
+ export const DEADLINE_MS = 8000;
85
+
86
+ /** What the resumed session is told to do first. Short: long lists go unread. */
87
+ const NEXT_STEPS = Object.freeze([
88
+ 're-read .tyran/state/<initiative>/STATE.md — the context before this point is gone',
89
+ 'check open leases and open spawns before starting anything new',
90
+ 'continue from the last recorded checkpoint, not from memory of this conversation',
91
+ ]);
92
+
93
+ /**
94
+ * The `checkpoint` event for this compaction.
95
+ *
96
+ * `checkpoint` is an existing member of the closed event set and it fits
97
+ * without stretching: it means "a deliberate pause", and a compaction is
98
+ * exactly that — the session stops carrying its own history. No new event type
99
+ * is invented here; that would be an escalation, not a decision.
100
+ *
101
+ * `trigger` and `custom_instructions` come from the platform and are recorded
102
+ * through `forJournal`: `custom_instructions` is free text a USER typed into
103
+ * `/compact`, so it is foreign text on its way into our own state file and it
104
+ * gets the same bounding and escaping as any other.
105
+ */
106
+ export function checkpointEvent(init, trigger, customInstructions) {
107
+ const data = {
108
+ phase: 'context-compaction',
109
+ next_steps: [...NEXT_STEPS],
110
+ trigger: forJournal(trigger ?? 'unknown'),
111
+ written_by: 'pre-compact hook',
112
+ };
113
+ if (typeof customInstructions === 'string' && customInstructions !== '') {
114
+ data.custom_instructions = forJournal(customInstructions);
115
+ }
116
+ return { ev: 'checkpoint', init, actor: 'pre-compact-hook', data };
117
+ }
118
+
119
+ /**
120
+ * A trigger this gate will treat as user-initiated.
121
+ *
122
+ * Anything it does not recognise — including the field being absent, which the
123
+ * schema says cannot happen but which costs nothing to survive — is treated as
124
+ * `auto`. That is the direction that cannot wedge a session, and choosing the
125
+ * safe direction for an unknown value is the opposite of the fail-open habit
126
+ * this project fights: here the DANGEROUS action is refusing, not passing.
127
+ */
128
+ export function isManual(trigger) {
129
+ return trigger === 'manual';
130
+ }
131
+
132
+ /**
133
+ * The verdict, with every edge injected so the whole decision is testable.
134
+ *
135
+ * `write` throws on failure; that is the signal the checkpoint did not land.
136
+ */
137
+ export function decide(input, { locate = locateJournal, write = append, warn = () => {} } = {}) {
138
+ const trigger = field(input, 'trigger');
139
+ const repoRoot = resolveRepoRoot(input);
140
+ const journal = locate(repoRoot ?? '');
141
+
142
+ if (journal.file === null) {
143
+ // Nothing to lose. Silence is correct: a gate that speaks where it has no
144
+ // business is a gate the user turns off before it ever matters.
145
+ return PASS;
146
+ }
147
+
148
+ try {
149
+ write(journal.file, checkpointEvent(journal.init, trigger, field(input, 'custom_instructions')));
150
+ return PASS;
151
+ } catch (err) {
152
+ const detail = String(err?.message ?? err);
153
+ // Contention and damage need OPPOSITE advice, and telling a user to
154
+ // validate a healthy journal is a false remedy — the failure class this
155
+ // project calls "a refusal with no reachable way forward". journal.mjs
156
+ // throws a distinct message when the cross-process mutex times out, which
157
+ // means another writer holds it right now and the file itself is fine.
158
+ const contended = /journal lock timeout/.test(detail);
159
+ if (!isManual(trigger)) {
160
+ // Never refuse an automatic compaction — see reason 2 in the header.
161
+ // Loud on stderr, which reaches the transcript, instead of silent.
162
+ warn(
163
+ `tyran pre-compact: could not write a checkpoint before an automatic compaction ` +
164
+ `(${detail}). The compaction is going ahead because refusing an automatic one would ` +
165
+ `stop the session entirely. State recorded up to the last successful write survives; ` +
166
+ `anything since then does not.`,
167
+ );
168
+ return PASS;
169
+ }
170
+ return {
171
+ decision: 'deny',
172
+ reason:
173
+ 'tyran pre-compact: refusing this /compact because the checkpoint could not be written ' +
174
+ `(${detail}).\n\n` +
175
+ 'Compacting now would discard the part of this session that is not in the journal, and ' +
176
+ 'nothing would say that it happened. You asked for this compaction, so you can act on it.\n\n' +
177
+ (contended
178
+ ? ' Another agent holds the journal lock right now. The journal is HEALTHY — it is busy.\n' +
179
+ ' Wait a moment and run /compact again; there is nothing to repair.\n'
180
+ : ` node scripts/journal.mjs validate ${journal.file}\n`) +
181
+ '\nAn AUTOMATIC compaction is never refused for this reason — stopping one would end the ' +
182
+ 'session rather than protect it.',
183
+ };
184
+ }
185
+ }
186
+
187
+ /** See journal.mjs — both sides canonicalized, or a symlinked path no-ops. */
188
+ function canonicalPath(path) {
189
+ const abs = resolve(path);
190
+ try {
191
+ return realpathSync(abs);
192
+ } catch {
193
+ return abs;
194
+ }
195
+ }
196
+
197
+ function isMainModule(moduleUrl) {
198
+ if (!process.argv[1]) return false;
199
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
200
+ }
201
+
202
+ if (isMainModule(import.meta.url)) {
203
+ await main(() =>
204
+ runGate({
205
+ event: 'PreCompact',
206
+ deadlineMs: DEADLINE_MS,
207
+ handler: ({ input }) =>
208
+ decide(input, { warn: (text) => process.stderr.write(text + '\n') }),
209
+ }),
210
+ );
211
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * retro-gate — the self-improvement loop stops depending on anyone remembering.
4
+ *
5
+ * The retrospective is the only mechanism by which Tyran gets better at YOUR
6
+ * repo instead of repeating the same mistake every initiative. It is also,
7
+ * for exactly that reason, the step most likely to be skipped: it happens
8
+ * after the work is done, after the merge, when the interesting part is over
9
+ * and the operator has already read the final report. A rule in prose saying
10
+ * "close an initiative by running the retro" is precisely the shape of rule
11
+ * this project exists because it distrusts.
12
+ *
13
+ * So this gate makes the ending mechanical. On `Stop`, if the journal shows
14
+ * an initiative whose tickets are ALL merged and no retrospective has been
15
+ * recorded since the last merge, the stop is refused once, with a reason that
16
+ * names the initiative and what to run.
17
+ *
18
+ * ## Why this cannot loop
19
+ *
20
+ * The platform sets `stop_hook_active` when a Stop hook has already caused
21
+ * the model to continue. This gate returns PASS the moment it sees that flag,
22
+ * so the worst case is exactly ONE extra turn. An agent that declines to run
23
+ * the retro stops normally on the next attempt. A gate that could hold a
24
+ * session open indefinitely would be worse than the problem it solves — and
25
+ * it would be switched off, taking the other five gates with it.
26
+ *
27
+ * ## Why it is a nudge and not a wall
28
+ *
29
+ * It FAILS OPEN on everything: no journal, an unreadable journal, a corrupt
30
+ * line, an initiative it cannot interpret. Being unable to prove a retro is
31
+ * owed is not evidence that one is owed. The cost matrix is lopsided: a
32
+ * missed nudge costs one deferred retrospective, while a false refusal
33
+ * blocks a completed initiative from ending and teaches the operator to
34
+ * distrust every gate in the plugin.
35
+ */
36
+ import { realpathSync } from 'node:fs';
37
+ import { resolve } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ import { readJournal } from '../../scripts/journal.mjs';
41
+ import { escapeInvisible } from '../../scripts/invisible.mjs';
42
+ import { locateJournal, resolveRepoRoot, forJournal } from './evidence-gate.mjs';
43
+ import { PASS, field, main, runGate } from './hook-io.mjs';
44
+
45
+ /**
46
+ * Well under the 10s declared in hooks.json. The gate reads one bounded file
47
+ * and folds it; anything slower than this means the journal is not the shape
48
+ * we think it is, and a late verdict is discarded anyway (ADR-22).
49
+ */
50
+ export const DEADLINE_MS = 4000;
51
+
52
+ /**
53
+ * Ceiling on how many initiatives are examined.
54
+ *
55
+ * A repo with hundreds of closed initiatives must not turn every `Stop` into
56
+ * a linear scan of all of them. Past this count the gate declines to judge —
57
+ * failing open, like every other uncertainty here.
58
+ */
59
+ export const MAX_INITIATIVES_CONSIDERED = 50;
60
+
61
+ /**
62
+ * Decide whether a retrospective is owed, from journal events alone.
63
+ *
64
+ * Returns `{owed: false, why}` or `{owed: true, init, tickets, lastMerge}`.
65
+ *
66
+ * An initiative owes a retro when ALL of these hold:
67
+ * - at least one ticket was created (an initiative with no tickets is a
68
+ * conversation, not a body of work worth retrospecting);
69
+ * - every created ticket has a `merge` event (the work is actually done —
70
+ * an abandoned initiative must not nag);
71
+ * - no `retro.entry` was recorded at or after the last merge. Anchoring on
72
+ * the LAST MERGE rather than on "any retro ever" is what makes this work
73
+ * for a repo that runs many initiatives: an old retrospective must not
74
+ * satisfy a new initiative.
75
+ */
76
+ export function judgeRetro(events) {
77
+ if (!Array.isArray(events) || events.length === 0) return { owed: false, why: 'no-events' };
78
+
79
+ const byInit = new Map();
80
+ for (const e of events) {
81
+ if (typeof e?.init !== 'string' || e.init.length === 0) continue;
82
+ if (!byInit.has(e.init)) {
83
+ if (byInit.size >= MAX_INITIATIVES_CONSIDERED) return { owed: false, why: 'too-many-initiatives' };
84
+ byInit.set(e.init, { created: new Set(), merged: new Set(), lastMerge: null, lastRetro: null });
85
+ }
86
+ const bucket = byInit.get(e.init);
87
+ const ts = typeof e.ts === 'string' ? e.ts : null;
88
+ if (e.ev === 'ticket.created' && typeof e.data?.id === 'string') bucket.created.add(e.data.id);
89
+ else if (e.ev === 'merge' && typeof e.data?.ticket === 'string') {
90
+ bucket.merged.add(e.data.ticket);
91
+ if (ts !== null && (bucket.lastMerge === null || ts > bucket.lastMerge)) bucket.lastMerge = ts;
92
+ } else if (e.ev === 'retro.entry' && ts !== null && (bucket.lastRetro === null || ts > bucket.lastRetro)) {
93
+ bucket.lastRetro = ts;
94
+ }
95
+ }
96
+
97
+ // Most recently merged initiative first: if several are owed, nagging about
98
+ // the freshest one is the only ordering that is not arbitrary.
99
+ const candidates = [...byInit.entries()]
100
+ .filter(([, b]) => b.created.size > 0 && b.lastMerge !== null)
101
+ .filter(([, b]) => [...b.created].every((id) => b.merged.has(id)))
102
+ .filter(([, b]) => b.lastRetro === null || b.lastRetro < b.lastMerge)
103
+ .sort((a, b) => (a[1].lastMerge < b[1].lastMerge ? 1 : -1));
104
+
105
+ if (candidates.length === 0) return { owed: false, why: 'nothing-owed' };
106
+ const [init, bucket] = candidates[0];
107
+ return { owed: true, init, tickets: bucket.created.size, lastMerge: bucket.lastMerge };
108
+ }
109
+
110
+ /** The refusal text. Names the initiative, the run, and how to decline. */
111
+ export function buildReason({ init, tickets }) {
112
+ return (
113
+ `tyran: initiative "${escapeInvisible(init)}" has all ${tickets} of its tickets merged and no ` +
114
+ 'retrospective recorded since the last merge.\n' +
115
+ '\n' +
116
+ 'Run it before you finish: spawn the `tyran:retro` agent over this ' +
117
+ 'initiative. It reads the ledger, the notes and the agents reports, and ' +
118
+ 'improves Tyran itself — never product code.\n' +
119
+ '\n' +
120
+ 'This is the only loop through which Tyran learns from an initiative ' +
121
+ 'instead of repeating it, and it is the step most easily skipped, which ' +
122
+ 'is why it is a gate rather than a sentence in a skill.\n' +
123
+ '\n' +
124
+ 'Deciding NOT to run one is a legitimate answer — a retro that finds ' +
125
+ 'nothing worth changing is a correct outcome, and so is judging the work ' +
126
+ 'too small to be worth one. Record that decision in the journal as a ' +
127
+ '`retro.entry` with kind "skipped" and the reason, and this will not ask ' +
128
+ 'again. You will not be blocked twice either way.'
129
+ );
130
+ }
131
+
132
+ export function apply(input, { locate = locateJournal, read = readJournal } = {}) {
133
+ // The loop guard comes FIRST, before any filesystem work: whatever else is
134
+ // true, a second consecutive block is not allowed to happen.
135
+ if (field(input, 'stop_hook_active') === true) return PASS;
136
+
137
+ const repoRoot = resolveRepoRoot(input);
138
+ if (repoRoot === null) return PASS;
139
+
140
+ let journal;
141
+ try {
142
+ journal = locate(repoRoot);
143
+ } catch {
144
+ return PASS;
145
+ }
146
+ if (journal?.file === null || journal?.file === undefined) return PASS;
147
+
148
+ let events;
149
+ try {
150
+ events = read(journal.file);
151
+ } catch {
152
+ return PASS;
153
+ }
154
+ // `readJournal` returns a report object in this codebase, not a bare array.
155
+ const list = Array.isArray(events) ? events : (events?.events ?? []);
156
+
157
+ let verdict;
158
+ try {
159
+ verdict = judgeRetro(list);
160
+ } catch {
161
+ return PASS;
162
+ }
163
+ if (!verdict.owed) return PASS;
164
+
165
+ return { decision: 'deny', reason: buildReason({ init: forJournal(verdict.init), tickets: verdict.tickets }) };
166
+ }
167
+
168
+ /** See journal.mjs — both sides canonicalized, or a symlinked path no-ops. */
169
+ function canonicalPath(path) {
170
+ const abs = resolve(path);
171
+ try {
172
+ return realpathSync(abs);
173
+ } catch {
174
+ return abs;
175
+ }
176
+ }
177
+
178
+ function isMainModule(moduleUrl) {
179
+ if (!process.argv[1]) return false;
180
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
181
+ }
182
+
183
+ if (isMainModule(import.meta.url)) {
184
+ await main(() =>
185
+ runGate({
186
+ event: 'Stop',
187
+ deadlineMs: DEADLINE_MS,
188
+ handler: ({ input }) => apply(input),
189
+ }),
190
+ );
191
+ }