@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,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
|
+
}
|