@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,747 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* journal — the append-only source of truth for a Tyran initiative.
|
|
4
|
+
*
|
|
5
|
+
* One line = one JSON event. Design rules (see docs/architecture.md):
|
|
6
|
+
* - append-only: a crash mid-write can at worst truncate the FINAL line,
|
|
7
|
+
* which readers detect and discard;
|
|
8
|
+
* - closed event set: unknown event types are rejected loudly;
|
|
9
|
+
* - IDs come from the journal, never from anyone's memory;
|
|
10
|
+
* - zero dependencies, plain Node >= 22.
|
|
11
|
+
*
|
|
12
|
+
* CLI:
|
|
13
|
+
* node journal.mjs append <file> <ev> <init> [--actor A] [--data JSON]
|
|
14
|
+
* node journal.mjs query <file> [--ev E] [--init I] [--ticket T] [--limit N]
|
|
15
|
+
* node journal.mjs validate <file>
|
|
16
|
+
* node journal.mjs next-id <file> <prefix> # e.g. prefix D -> D-7
|
|
17
|
+
* node journal.mjs tail <file> # last checkpoint + open items
|
|
18
|
+
* node journal.mjs open-spawns <file> # agents with no report yet
|
|
19
|
+
* node journal.mjs close-spawn <file> <init> <agent> --reason R [--verdict V]
|
|
20
|
+
* Exit: 0 ok · 1 validation/finding error · 2 usage/IO error
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
appendFileSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
rmdirSync,
|
|
28
|
+
statSync,
|
|
29
|
+
openSync,
|
|
30
|
+
readSync,
|
|
31
|
+
closeSync,
|
|
32
|
+
realpathSync,
|
|
33
|
+
} from 'node:fs';
|
|
34
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { escapeInvisible, invisibleProblem, jsonEscapeInvisible, whitespaceProblem } from './invisible.mjs';
|
|
37
|
+
|
|
38
|
+
/** Closed set of event types. Extending it is a reviewed core change. */
|
|
39
|
+
export const EVENT_TYPES = Object.freeze([
|
|
40
|
+
'init.created',
|
|
41
|
+
'plan.accepted',
|
|
42
|
+
'ticket.created',
|
|
43
|
+
'spawn',
|
|
44
|
+
'report',
|
|
45
|
+
'gate',
|
|
46
|
+
'review',
|
|
47
|
+
'merge',
|
|
48
|
+
'decision',
|
|
49
|
+
'lease.acquired',
|
|
50
|
+
'lease.released',
|
|
51
|
+
'checkpoint',
|
|
52
|
+
'retro.entry',
|
|
53
|
+
'error',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Per-type required keys inside `data` (data may always carry extra keys). */
|
|
57
|
+
const DATA_REQUIRED = Object.freeze({
|
|
58
|
+
'ticket.created': ['id'],
|
|
59
|
+
spawn: ['agent', 'role'],
|
|
60
|
+
report: ['agent', 'verdict'],
|
|
61
|
+
gate: ['kind', 'result'],
|
|
62
|
+
review: ['ticket', 'verdict', 'by'],
|
|
63
|
+
merge: ['ticket', 'sha'],
|
|
64
|
+
decision: ['id', 'text'],
|
|
65
|
+
'lease.acquired': ['resource', 'holder'],
|
|
66
|
+
'lease.released': ['resource', 'holder'],
|
|
67
|
+
checkpoint: ['phase', 'next_steps'],
|
|
68
|
+
'retro.entry': ['kind', 'target'],
|
|
69
|
+
error: ['class'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A journal-derived value on its way into a HUMAN-READABLE MESSAGE.
|
|
74
|
+
*
|
|
75
|
+
* Messages built here travel further than this file: `validateJournal`'s
|
|
76
|
+
* errors and warnings are rendered by `doctor.mjs` into a report an operator
|
|
77
|
+
* reads. A 400-tree fuzz of doctor measured 137 leaks in its text output and
|
|
78
|
+
* 118 in its JSON, and every one of them traced back to a message built RIGHT
|
|
79
|
+
* HERE with a raw `ev` or agent name in it — not to doctor's own sanitizer.
|
|
80
|
+
*
|
|
81
|
+
* The module that BUILDS a message owns making it safe. Leaving it to each
|
|
82
|
+
* consumer is caller discipline, which is the mechanism class this project
|
|
83
|
+
* exists because it distrusts: one consumer forgets, and the forgetting is
|
|
84
|
+
* invisible.
|
|
85
|
+
*/
|
|
86
|
+
const q = (value) => escapeInvisible(String(value));
|
|
87
|
+
|
|
88
|
+
/** Validate a single event object. Returns [] when valid, else error strings. */
|
|
89
|
+
export function validateEvent(event) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
if (typeof event !== 'object' || event === null || Array.isArray(event)) {
|
|
92
|
+
return ['event must be a JSON object'];
|
|
93
|
+
}
|
|
94
|
+
if (typeof event.ts !== 'string' || Number.isNaN(Date.parse(event.ts))) {
|
|
95
|
+
errors.push('ts must be an ISO-8601 timestamp string');
|
|
96
|
+
}
|
|
97
|
+
if (!EVENT_TYPES.includes(event.ev)) {
|
|
98
|
+
errors.push(`ev "${q(event.ev)}" is not in the closed event set`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof event.init !== 'string' || event.init.length === 0) {
|
|
101
|
+
errors.push('init (initiative slug) must be a non-empty string');
|
|
102
|
+
}
|
|
103
|
+
if (typeof event.actor !== 'string' || event.actor.length === 0) {
|
|
104
|
+
errors.push('actor must be a non-empty string');
|
|
105
|
+
}
|
|
106
|
+
if (typeof event.data !== 'object' || event.data === null || Array.isArray(event.data)) {
|
|
107
|
+
errors.push('data must be a JSON object (may be empty)');
|
|
108
|
+
} else if (DATA_REQUIRED[event.ev]) {
|
|
109
|
+
for (const key of DATA_REQUIRED[event.ev]) {
|
|
110
|
+
if (!(key in event.data)) errors.push(`data.${q(key)} is required for ev "${q(event.ev)}"`);
|
|
111
|
+
else if (key === 'id' && typeof event.data.id !== 'string') {
|
|
112
|
+
errors.push(`data.id must be a string for ev "${q(event.ev)}" (got ${typeof event.data.id})`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return errors;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -------------------------------------------------- spawn ↔ report pairing
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Agent names are identifiers, not prose: they address an agent at platform
|
|
123
|
+
* level and they are the ONLY correlator between `spawn` and `report`
|
|
124
|
+
* (ADR-18). A name that merely LOOKS like another one would silently defeat
|
|
125
|
+
* the uniqueness guard, so non-canonical names are refused on write.
|
|
126
|
+
* Case is deliberately significant — folding it here would make the guard
|
|
127
|
+
* disagree with the exact-name addressing every consumer uses.
|
|
128
|
+
*
|
|
129
|
+
* The invisibility test is NOT spelled out here any more. It used to read
|
|
130
|
+
* `\p{Cc}\p{Cf}`, which is a third spelling of a rule the CI scanner and the
|
|
131
|
+
* projection sanitizer also carried — measured over the whole of Unicode, the
|
|
132
|
+
* three disagreed on 456 codepoints. `\p{Cf}` in particular does not cover
|
|
133
|
+
* Hangul fillers (U+3164, U+FFA0) or most of the TAG block, so a name could be
|
|
134
|
+
* refused by the repo gate and accepted here. One question, one function
|
|
135
|
+
* (ADR-19 correction 1 point 4, ADR-21).
|
|
136
|
+
*/
|
|
137
|
+
export function agentNameProblem(name) {
|
|
138
|
+
if (typeof name !== 'string') return `must be a string (got ${typeof name})`;
|
|
139
|
+
if (name.length === 0) return 'must not be empty';
|
|
140
|
+
// Invisibility is checked BEFORE normalization on purpose. A few invisible
|
|
141
|
+
// codepoints are also not NFC-stable, and reporting "must be Unicode
|
|
142
|
+
// NFC-normalized" for a name carrying a zero-width joiner is a true sentence
|
|
143
|
+
// that sends the reader to fix the wrong thing.
|
|
144
|
+
for (const ch of name) {
|
|
145
|
+
if (invisibleProblem(ch.codePointAt(0)) !== null) {
|
|
146
|
+
return 'must not contain control or invisible formatting characters';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (name !== name.normalize('NFC')) return 'must be Unicode NFC-normalized';
|
|
150
|
+
if (name !== name.trim()) return 'must not have leading/trailing whitespace';
|
|
151
|
+
// TAB and LF are visible text, so they are not an answer to "is this
|
|
152
|
+
// invisible" — but they wreck a name, which is printed in tables, shell
|
|
153
|
+
// hints and projections. Separate, disjoint rule; see scripts/invisible.mjs.
|
|
154
|
+
for (const ch of name) {
|
|
155
|
+
if (whitespaceProblem(ch.codePointAt(0)) !== null) {
|
|
156
|
+
return 'must not contain a tab or a newline';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pair `spawn` events with `report` events by agent name, in file order:
|
|
164
|
+
* a report closes the OLDEST still-open spawn of that name (FIFO). This is
|
|
165
|
+
* the one and only pairing rule — `append` enforces that it can never be
|
|
166
|
+
* ambiguous (ADR-18: at most one open spawn per name), so consumers such as
|
|
167
|
+
* the projection generator implement a guarantee, not a heuristic.
|
|
168
|
+
*
|
|
169
|
+
* Operates on the events a reader can actually see. Corrupt or truncated
|
|
170
|
+
* lines are invisible here for exactly the same reason they are invisible to
|
|
171
|
+
* every consumer, so writer and readers can never disagree about who is open.
|
|
172
|
+
*
|
|
173
|
+
* Returns, additively (ADR-21): `pairs` names WHICH report closed WHICH spawn,
|
|
174
|
+
* and `unusable` carries the EVENTS whose agent name is not a usable
|
|
175
|
+
* correlator. Both exist so that the projection generator can render this
|
|
176
|
+
* function's answer instead of computing a second one of its own — which is
|
|
177
|
+
* what it used to do, with a different rule, giving the operator two
|
|
178
|
+
* contradictory pictures of who was still working.
|
|
179
|
+
*/
|
|
180
|
+
export function pairSpawns(events) {
|
|
181
|
+
const open = new Map(); // agent -> spawn events, oldest first
|
|
182
|
+
const orphanReports = [];
|
|
183
|
+
const badNames = new Map(); // raw name (as JSON) -> problem
|
|
184
|
+
const unusable = []; // the EVENTS behind badNames, so a consumer can show them
|
|
185
|
+
const pairs = []; // {spawn, report}, in report order
|
|
186
|
+
/**
|
|
187
|
+
* Names that were EVER open more than once at the same time. Deliberately
|
|
188
|
+
* not "names open more than once right now": by the time a report has closed
|
|
189
|
+
* one of two simultaneous spawns, the final map holds a single entry and the
|
|
190
|
+
* ambiguity is invisible — yet that report already had to choose between two
|
|
191
|
+
* spawns, and the choice it made is the thing a reader must be told about.
|
|
192
|
+
*/
|
|
193
|
+
const ambiguous = new Map(); // agent -> the largest number of spawns open at once
|
|
194
|
+
for (const e of events) {
|
|
195
|
+
if (e?.ev !== 'spawn' && e?.ev !== 'report') continue;
|
|
196
|
+
const agent = e.data?.agent;
|
|
197
|
+
const problem = agentNameProblem(agent);
|
|
198
|
+
if (problem) {
|
|
199
|
+
badNames.set(JSON.stringify(agent ?? null), problem);
|
|
200
|
+
unusable.push({ event: e, problem });
|
|
201
|
+
continue; // unusable as a correlator; validate() reports it
|
|
202
|
+
}
|
|
203
|
+
if (e.ev === 'spawn') {
|
|
204
|
+
if (!open.has(agent)) open.set(agent, []);
|
|
205
|
+
open.get(agent).push(e);
|
|
206
|
+
const depth = open.get(agent).length;
|
|
207
|
+
if (depth > 1 && depth > (ambiguous.get(agent) ?? 0)) ambiguous.set(agent, depth);
|
|
208
|
+
} else {
|
|
209
|
+
const queue = open.get(agent);
|
|
210
|
+
if (queue?.length) {
|
|
211
|
+
const spawn = queue.shift();
|
|
212
|
+
pairs.push({ spawn, report: e });
|
|
213
|
+
if (queue.length === 0) open.delete(agent);
|
|
214
|
+
} else orphanReports.push(e);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { open, orphanReports, badNames, pairs, unusable, ambiguous };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Agents whose `spawn` has no matching `report` yet — the "still working" set. */
|
|
221
|
+
export function openSpawns(file) {
|
|
222
|
+
const { open } = pairSpawns(readJournal(file).events);
|
|
223
|
+
return [...open.values()].flat().map((e) => ({
|
|
224
|
+
agent: e.data.agent,
|
|
225
|
+
since: e.ts,
|
|
226
|
+
by: e.actor,
|
|
227
|
+
role: e.data.role ?? null,
|
|
228
|
+
ticket: e.data.ticket ?? null,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* POSIX single-quoting. Agent names and journal paths may legally contain
|
|
234
|
+
* spaces and apostrophes; a hint that has to be edited before it runs is a
|
|
235
|
+
* hint an autonomous caller will run anyway and then fight the fallout.
|
|
236
|
+
*/
|
|
237
|
+
function sq(value) {
|
|
238
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The rejection message is the user interface of this guard — spell it out. */
|
|
242
|
+
function duplicateSpawnMessage(file, init, agent, previous) {
|
|
243
|
+
const p = previous[0];
|
|
244
|
+
const where = p.data.ticket ? ` on ticket ${p.data.ticket}` : '';
|
|
245
|
+
const reportData = JSON.stringify({ agent, verdict: '<verdict>' });
|
|
246
|
+
// A name starting with "-" would be eaten as a flag; put it after the
|
|
247
|
+
// POSIX end-of-options separator, which means flags must come first.
|
|
248
|
+
const closeCmd = agent.startsWith('-')
|
|
249
|
+
? `close-spawn ${sq(file)} --reason "<why>" ${sq(init)} -- ${sq(agent)}`
|
|
250
|
+
: `close-spawn ${sq(file)} ${sq(init)} ${sq(agent)} --reason "<why>"`;
|
|
251
|
+
return (
|
|
252
|
+
`spawn rejected: agent "${agent}" already has an open spawn in this journal.\n` +
|
|
253
|
+
` previous spawn: ${p.ts} by ${p.actor}${where} (role: ${p.data.role ?? '?'})\n` +
|
|
254
|
+
' Two open spawns for one name would make spawn↔report pairing ambiguous\n' +
|
|
255
|
+
' (ADR-18), so the state is refused at write time rather than guessed later.\n' +
|
|
256
|
+
' Fix it with ONE of:\n' +
|
|
257
|
+
' - the agent finished: record its report\n' +
|
|
258
|
+
` node scripts/journal.mjs append ${sq(file)} report ${sq(init)} \\\n` +
|
|
259
|
+
` --data ${sq(reportData)}\n` +
|
|
260
|
+
' - the agent died without reporting: close it explicitly\n' +
|
|
261
|
+
` node scripts/journal.mjs ${closeCmd}\n` +
|
|
262
|
+
' - both agents are meant to run at once: give each a distinct name\n' +
|
|
263
|
+
` — "${agent}" can address only ONE live agent at a time.\n` +
|
|
264
|
+
` See what is open: node scripts/journal.mjs open-spawns ${sq(file)}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Cross-process mutex via atomic mkdir. Steals locks older than 10s
|
|
270
|
+
* (crashed holder); times out loudly after 5s of contention.
|
|
271
|
+
*
|
|
272
|
+
* Trade-off (review E2S1 note 3): a LIVE holder that keeps the lock >10s
|
|
273
|
+
* can have it stolen, and its `finally` would then remove the new holder's
|
|
274
|
+
* lock. Acceptable while the critical section is a single read+append
|
|
275
|
+
* (milliseconds); if the section ever grows, switch to an owner-token file
|
|
276
|
+
* inside the lock dir before extending it. A process suspended inside the
|
|
277
|
+
* section (SIGSTOP, laptop sleep) breaks that assumption — known gap,
|
|
278
|
+
* documented in docs/journal.md, fixed by the owner-token change, not here.
|
|
279
|
+
*
|
|
280
|
+
* The lock is keyed by the CANONICAL path, so two callers reaching one
|
|
281
|
+
* journal through different symlinks share one lock. Hard links still alias
|
|
282
|
+
* (same inode, different canonical paths) — that needs (dev, ino) keying.
|
|
283
|
+
*/
|
|
284
|
+
function lockDirFor(file) {
|
|
285
|
+
const abs = resolve(file);
|
|
286
|
+
try {
|
|
287
|
+
return realpathSync(abs) + '.lock';
|
|
288
|
+
} catch {
|
|
289
|
+
// journal not created yet: canonicalize the directory it will live in
|
|
290
|
+
try {
|
|
291
|
+
return join(realpathSync(dirname(abs)), basename(abs)) + '.lock';
|
|
292
|
+
} catch {
|
|
293
|
+
return abs + '.lock';
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function withLock(file, fn) {
|
|
299
|
+
const lockDir = lockDirFor(file);
|
|
300
|
+
const deadline = Date.now() + 5000;
|
|
301
|
+
for (;;) {
|
|
302
|
+
try {
|
|
303
|
+
mkdirSync(lockDir, { recursive: false });
|
|
304
|
+
break;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err.code !== 'EEXIST') throw err;
|
|
307
|
+
try {
|
|
308
|
+
if (Date.now() - statSync(lockDir).mtimeMs > 10_000) {
|
|
309
|
+
rmdirSync(lockDir);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
continue; // lock vanished between checks — retry immediately
|
|
314
|
+
}
|
|
315
|
+
if (Date.now() > deadline) {
|
|
316
|
+
throw new Error(`journal lock timeout (held by a live writer?): ${lockDir}`);
|
|
317
|
+
}
|
|
318
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return fn();
|
|
323
|
+
} finally {
|
|
324
|
+
rmdirSync(lockDir);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* True when the file is empty/absent or its last byte is a newline. A crash
|
|
330
|
+
* can leave a final line without one; appending straight onto it would FUSE
|
|
331
|
+
* the truncated line with the new event, destroying the new event silently
|
|
332
|
+
* (and, for spawns, hiding it from the uniqueness guard). Cheap on purpose:
|
|
333
|
+
* one byte, so appends that need no full read stay O(1).
|
|
334
|
+
*/
|
|
335
|
+
function endsWithNewline(file) {
|
|
336
|
+
let fd;
|
|
337
|
+
try {
|
|
338
|
+
fd = openSync(file, 'r');
|
|
339
|
+
} catch (err) {
|
|
340
|
+
if (err.code === 'ENOENT') return true;
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const size = statSync(file).size;
|
|
345
|
+
if (size === 0) return true;
|
|
346
|
+
const buf = Buffer.alloc(1);
|
|
347
|
+
readSync(fd, buf, 0, 1, size - 1);
|
|
348
|
+
return buf[0] === 0x0a;
|
|
349
|
+
} finally {
|
|
350
|
+
closeSync(fd);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The body of `append`, already holding the lock. `read()` memoizes the one
|
|
356
|
+
* full read this call is allowed: the ts clamp and the spawn guard share it.
|
|
357
|
+
* A caller that already read the journal under this same lock passes its
|
|
358
|
+
* snapshot in, so no path reads the file twice.
|
|
359
|
+
*/
|
|
360
|
+
function appendUnderLock(file, event, preread = null) {
|
|
361
|
+
let snapshot = preread;
|
|
362
|
+
const read = () => (snapshot ??= readJournal(file));
|
|
363
|
+
|
|
364
|
+
let ts = event.ts;
|
|
365
|
+
if (ts == null) {
|
|
366
|
+
ts = new Date().toISOString();
|
|
367
|
+
const lastTs = read().events.at(-1)?.ts;
|
|
368
|
+
if (lastTs && Date.parse(ts) < Date.parse(lastTs)) ts = lastTs;
|
|
369
|
+
}
|
|
370
|
+
const stamped = { ...event, ts };
|
|
371
|
+
const errors = validateEvent(stamped);
|
|
372
|
+
if (errors.length > 0) {
|
|
373
|
+
throw new Error('invalid event: ' + errors.join('; '));
|
|
374
|
+
}
|
|
375
|
+
if (stamped.ev === 'spawn' || stamped.ev === 'report') {
|
|
376
|
+
const problem = agentNameProblem(stamped.data.agent);
|
|
377
|
+
if (problem) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`invalid event: data.agent ${problem} — it is the only correlator ` +
|
|
380
|
+
`between spawn and report (got ${JSON.stringify(stamped.data.agent)})`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (stamped.ev === 'spawn') {
|
|
385
|
+
// ADR-18: refuse a second OPEN spawn for one agent name. Same lock as the
|
|
386
|
+
// write, same read as the clamp — a check outside the lock would let two
|
|
387
|
+
// concurrent writers both pass it and both append.
|
|
388
|
+
const previous = pairSpawns(read().events).open.get(stamped.data.agent);
|
|
389
|
+
if (previous?.length) {
|
|
390
|
+
throw new Error(duplicateSpawnMessage(file, stamped.init, stamped.data.agent, previous));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const heal = endsWithNewline(file) ? '' : '\n';
|
|
394
|
+
appendFileSync(file, heal + JSON.stringify(stamped) + '\n', 'utf8');
|
|
395
|
+
return stamped;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Append one event under a cross-process lock. Validates first; stamps ts
|
|
400
|
+
* when absent, CLAMPED to be >= the journal's last timestamp — concurrent
|
|
401
|
+
* writers therefore can never produce a ts regression (validateJournal
|
|
402
|
+
* stays a hard error, and it holds by construction). An EXPLICIT ts is
|
|
403
|
+
* written as given: the caller owns it and validate will flag regressions.
|
|
404
|
+
*
|
|
405
|
+
* `spawn` additionally must not duplicate an agent name that is still open
|
|
406
|
+
* (ADR-18) — see `duplicateSpawnMessage` for how to get unstuck.
|
|
407
|
+
*/
|
|
408
|
+
export function append(file, event) {
|
|
409
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
410
|
+
return withLock(file, () => appendUnderLock(file, event));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Close an orphaned spawn — the agent died, or was killed, without reporting.
|
|
415
|
+
* This is NOT a bypass: it writes an ordinary `report` event through the
|
|
416
|
+
* ordinary path, so the journal stays a plain, honest event log. It refuses
|
|
417
|
+
* when there is nothing open to close, and it demands a reason, so a forced
|
|
418
|
+
* closure is always attributable. Check + write share one lock, so two
|
|
419
|
+
* simultaneous closures cannot both write a report for the same spawn.
|
|
420
|
+
*/
|
|
421
|
+
export function closeSpawn(file, { init, agent, actor = 'conductor', verdict = 'abandoned', reason }) {
|
|
422
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
423
|
+
throw new Error('close-spawn requires a non-empty --reason (why was the spawn abandoned?)');
|
|
424
|
+
}
|
|
425
|
+
mkdirSync(dirname(resolve(file)), { recursive: true });
|
|
426
|
+
return withLock(file, () => {
|
|
427
|
+
const snapshot = readJournal(file);
|
|
428
|
+
const { open } = pairSpawns(snapshot.events);
|
|
429
|
+
const previous = open.get(agent);
|
|
430
|
+
if (!previous?.length) {
|
|
431
|
+
const others = [...open.keys()];
|
|
432
|
+
throw new Error(
|
|
433
|
+
`no open spawn for agent "${q(agent)}" in ${q(file)} — nothing to close.\n` +
|
|
434
|
+
` open spawns: ${others.length ? others.map(q).join(', ') : '(none)'}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return appendUnderLock(
|
|
438
|
+
file,
|
|
439
|
+
{ ev: 'report', init, actor, data: { agent, verdict, reason, closed_by: 'close-spawn' } },
|
|
440
|
+
snapshot,
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Read all events. A truncated (crash-interrupted) FINAL line is discarded
|
|
447
|
+
* and reported via `truncatedTail`; a malformed line anywhere else is a
|
|
448
|
+
* validation error, not silent data loss.
|
|
449
|
+
*/
|
|
450
|
+
export function readJournal(file) {
|
|
451
|
+
if (!existsSync(file)) return { events: [], truncatedTail: false, badLines: [] };
|
|
452
|
+
let raw = readFileSync(file, 'utf8');
|
|
453
|
+
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // strip BOM
|
|
454
|
+
const lines = raw.split('\n');
|
|
455
|
+
if (lines.at(-1) === '') lines.pop();
|
|
456
|
+
const events = [];
|
|
457
|
+
const badLines = [];
|
|
458
|
+
let truncatedTail = false;
|
|
459
|
+
lines.forEach((line, i) => {
|
|
460
|
+
try {
|
|
461
|
+
events.push(JSON.parse(line));
|
|
462
|
+
} catch {
|
|
463
|
+
if (i === lines.length - 1 && !raw.endsWith('\n')) truncatedTail = true;
|
|
464
|
+
else badLines.push(i + 1);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
return { events, truncatedTail, badLines };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Filter events by ev / init / data.ticket (or data.id for ticket events). */
|
|
471
|
+
export function query(file, { ev, init, ticket, limit } = {}) {
|
|
472
|
+
let { events } = readJournal(file);
|
|
473
|
+
if (ev) events = events.filter((e) => e.ev === ev);
|
|
474
|
+
if (init) events = events.filter((e) => e.init === init);
|
|
475
|
+
if (ticket) {
|
|
476
|
+
events = events.filter(
|
|
477
|
+
(e) => e.data?.ticket === ticket || (e.ev === 'ticket.created' && e.data?.id === ticket),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (limit) events = events.slice(-limit);
|
|
481
|
+
return events;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Full-journal validation: per-event schema + non-decreasing timestamps.
|
|
486
|
+
*
|
|
487
|
+
* Spawn-pairing findings are WARNINGS, not errors: journals written before
|
|
488
|
+
* the ADR-18 guard existed may legitimately contain duplicates, and the
|
|
489
|
+
* append-only rule forbids rewriting history. They must not stay invisible
|
|
490
|
+
* either — a projection built on such a file cannot be trusted.
|
|
491
|
+
*/
|
|
492
|
+
export function validateJournal(file) {
|
|
493
|
+
const { events, truncatedTail, badLines } = readJournal(file);
|
|
494
|
+
const errors = badLines.map((n) => `line ${n}: not valid JSON (mid-file corruption)`);
|
|
495
|
+
let prevTs = null;
|
|
496
|
+
events.forEach((e, i) => {
|
|
497
|
+
for (const err of validateEvent(e)) errors.push(`event ${i + 1}: ${err}`);
|
|
498
|
+
if (prevTs !== null && Date.parse(e.ts) < Date.parse(prevTs)) {
|
|
499
|
+
errors.push(`event ${i + 1}: ts ${q(e.ts)} is earlier than previous ${q(prevTs)}`);
|
|
500
|
+
}
|
|
501
|
+
prevTs = e.ts;
|
|
502
|
+
});
|
|
503
|
+
const warnings = [];
|
|
504
|
+
const { open, orphanReports, badNames } = pairSpawns(events);
|
|
505
|
+
for (const [agent, spawns] of open) {
|
|
506
|
+
if (spawns.length > 1) {
|
|
507
|
+
warnings.push(
|
|
508
|
+
`agent "${q(agent)}" has ${spawns.length} open spawns (since ${spawns
|
|
509
|
+
.map((s) => q(s.ts))
|
|
510
|
+
.join(', ')}) — written before the ADR-18 guard or edited by hand; ` +
|
|
511
|
+
'spawn↔report pairing for this agent is ambiguous',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const r of orphanReports) {
|
|
516
|
+
warnings.push(`report for agent "${q(r.data.agent)}" at ${q(r.ts)} closes no open spawn`);
|
|
517
|
+
}
|
|
518
|
+
for (const [raw, problem] of badNames) {
|
|
519
|
+
warnings.push(`unusable data.agent ${q(raw)}: ${problem} — excluded from spawn↔report pairing`);
|
|
520
|
+
}
|
|
521
|
+
return { ok: errors.length === 0, errors, warnings, count: events.length, truncatedTail };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Next free ID for a prefix, derived from the journal — never from memory.
|
|
526
|
+
* Scans data.id fields shaped `<PREFIX>-<number>` and returns max+1.
|
|
527
|
+
*/
|
|
528
|
+
export function nextId(file, prefix) {
|
|
529
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(prefix)) {
|
|
530
|
+
throw new Error(`invalid id prefix "${prefix}" — use letters/digits, starting with a letter`);
|
|
531
|
+
}
|
|
532
|
+
const { events } = readJournal(file);
|
|
533
|
+
let max = 0;
|
|
534
|
+
const re = new RegExp(`^${prefix}-(\\d+)$`);
|
|
535
|
+
for (const e of events) {
|
|
536
|
+
const m = typeof e.data?.id === 'string' && e.data.id.match(re);
|
|
537
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
538
|
+
}
|
|
539
|
+
return `${prefix}-${max + 1}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Latest checkpoint + still-open leases — the resume surface. */
|
|
543
|
+
export function tail(file) {
|
|
544
|
+
const { events } = readJournal(file);
|
|
545
|
+
const checkpoint = [...events].reverse().find((e) => e.ev === 'checkpoint') ?? null;
|
|
546
|
+
const open = new Map();
|
|
547
|
+
const mismatchedReleases = [];
|
|
548
|
+
for (const e of events) {
|
|
549
|
+
if (e.ev === 'lease.acquired') open.set(e.data.resource, e.data.holder);
|
|
550
|
+
if (e.ev === 'lease.released') {
|
|
551
|
+
// A release only counts when it comes from the current holder —
|
|
552
|
+
// anything else is a protocol violation and must stay visible.
|
|
553
|
+
if (open.get(e.data.resource) === e.data.holder) open.delete(e.data.resource);
|
|
554
|
+
else mismatchedReleases.push({ resource: e.data.resource, by: e.data.holder, holder: open.get(e.data.resource) ?? null });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
checkpoint,
|
|
559
|
+
openLeases: [...open.entries()].map(([resource, holder]) => ({ resource, holder })),
|
|
560
|
+
mismatchedReleases,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------- CLI
|
|
565
|
+
|
|
566
|
+
function parseFlags(args, allowed) {
|
|
567
|
+
const flags = {};
|
|
568
|
+
const rest = [];
|
|
569
|
+
for (let i = 0; i < args.length; i++) {
|
|
570
|
+
if (args[i] === '--') {
|
|
571
|
+
// POSIX end of options: the rest is positional, verbatim. Without it
|
|
572
|
+
// an agent named "--reason" could be spawned but never closed.
|
|
573
|
+
rest.push(...args.slice(i + 1));
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
if (args[i].startsWith('--')) {
|
|
577
|
+
const name = args[i].slice(2);
|
|
578
|
+
if (!allowed.includes(name)) throw new Error(`unknown flag --${name}`);
|
|
579
|
+
const value = args[++i];
|
|
580
|
+
if (value === undefined || value.startsWith('--')) {
|
|
581
|
+
throw new Error(`flag --${name} requires a value`);
|
|
582
|
+
}
|
|
583
|
+
flags[name] = value;
|
|
584
|
+
} else rest.push(args[i]);
|
|
585
|
+
}
|
|
586
|
+
return { flags, rest };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const CMD_FLAGS = {
|
|
590
|
+
append: ['actor', 'data'],
|
|
591
|
+
query: ['ev', 'init', 'ticket', 'limit'],
|
|
592
|
+
validate: [],
|
|
593
|
+
'next-id': [],
|
|
594
|
+
tail: [],
|
|
595
|
+
'open-spawns': [],
|
|
596
|
+
'close-spawn': ['actor', 'verdict', 'reason'],
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The ONE way this CLI writes a value to a terminal.
|
|
601
|
+
*
|
|
602
|
+
* `JSON.stringify` escapes C0 controls and stops there: bidi overrides, TAG
|
|
603
|
+
* characters and zero-width marks come out RAW. Every subcommand here prints
|
|
604
|
+
* journal content — `query`, `tail`, `validate`, `open-spawns`, `append`,
|
|
605
|
+
* `close-spawn`, `next-id` — so each was a way for a foreign repository's text
|
|
606
|
+
* to reach the operator's screen invisibly, exactly as it reached it through
|
|
607
|
+
* project.mjs's warnings until a security review demonstrated that one.
|
|
608
|
+
*
|
|
609
|
+
* The escape notation is JSON's own, not `<U+202E>`, because this output is
|
|
610
|
+
* MACHINE-READABLE and this repo parses it back. `JSON.parse` of the result is
|
|
611
|
+
* deep-equal to the input, so safety costs no fidelity here — a test asserts
|
|
612
|
+
* the round trip rather than trusting the claim.
|
|
613
|
+
*
|
|
614
|
+
* The round-trip guarantee is about the JSON subcommands, and `next-id` is not
|
|
615
|
+
* one of them: it prints a bare identifier, so there is nothing to parse back.
|
|
616
|
+
* It still goes through here rather than being the one sink that does not,
|
|
617
|
+
* because "this one is safe by construction" is how a sink gets forgotten when
|
|
618
|
+
* the construction changes. Its own safety is separately guaranteed: `nextId`
|
|
619
|
+
* rejects any prefix outside `[A-Za-z][A-Za-z0-9]*` before it builds a value.
|
|
620
|
+
*/
|
|
621
|
+
function emit(value, indent) {
|
|
622
|
+
return jsonEscapeInvisible(typeof value === 'string' ? value : JSON.stringify(value, null, indent));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function main() {
|
|
626
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
627
|
+
try {
|
|
628
|
+
if (!(cmd in CMD_FLAGS)) throw new UsageError();
|
|
629
|
+
const { flags, rest } = parseFlags(args, CMD_FLAGS[cmd]);
|
|
630
|
+
if (flags.limit !== undefined && !Number.isInteger(Number(flags.limit))) {
|
|
631
|
+
throw new Error(`--limit must be an integer (got "${flags.limit}")`);
|
|
632
|
+
}
|
|
633
|
+
switch (cmd) {
|
|
634
|
+
case 'append': {
|
|
635
|
+
const [file, ev, init] = rest;
|
|
636
|
+
if (!file || !ev || !init) throw new UsageError();
|
|
637
|
+
const data = flags.data ? JSON.parse(flags.data) : {};
|
|
638
|
+
const written = append(file, { ev, init, actor: flags.actor ?? 'conductor', data });
|
|
639
|
+
console.log(emit(written));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
case 'query': {
|
|
643
|
+
const [file] = rest;
|
|
644
|
+
if (!file) throw new UsageError();
|
|
645
|
+
for (const e of query(file, { ev: flags.ev, init: flags.init, ticket: flags.ticket, limit: flags.limit && Number(flags.limit) })) {
|
|
646
|
+
console.log(emit(e));
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
case 'validate': {
|
|
651
|
+
const [file] = rest;
|
|
652
|
+
if (!file) throw new UsageError();
|
|
653
|
+
const result = validateJournal(file);
|
|
654
|
+
console.log(emit(result, 2));
|
|
655
|
+
if (!result.ok) process.exit(1);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
case 'next-id': {
|
|
659
|
+
const [file, prefix] = rest;
|
|
660
|
+
if (!file || !prefix) throw new UsageError();
|
|
661
|
+
console.log(emit(nextId(file, prefix)));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
case 'tail': {
|
|
665
|
+
const [file] = rest;
|
|
666
|
+
if (!file) throw new UsageError();
|
|
667
|
+
console.log(emit(tail(file), 2));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
case 'open-spawns': {
|
|
671
|
+
const [file] = rest;
|
|
672
|
+
if (!file) throw new UsageError();
|
|
673
|
+
console.log(emit(openSpawns(file), 2));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
case 'close-spawn': {
|
|
677
|
+
const [file, init, agent] = rest;
|
|
678
|
+
if (!file || !init || !agent) throw new UsageError();
|
|
679
|
+
// a missing --reason gets closeSpawn's specific message, not a usage dump
|
|
680
|
+
const written = closeSpawn(file, {
|
|
681
|
+
init,
|
|
682
|
+
agent,
|
|
683
|
+
actor: flags.actor ?? 'conductor',
|
|
684
|
+
verdict: flags.verdict ?? 'abandoned',
|
|
685
|
+
reason: flags.reason ?? '',
|
|
686
|
+
});
|
|
687
|
+
console.log(emit(written));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
if (err instanceof UsageError) {
|
|
693
|
+
console.error(
|
|
694
|
+
'usage: journal.mjs append <file> <ev> <init> [--actor A] [--data JSON]\n' +
|
|
695
|
+
' journal.mjs query <file> [--ev E] [--init I] [--ticket T] [--limit N]\n' +
|
|
696
|
+
' journal.mjs validate <file> · next-id <file> <prefix> · tail <file>\n' +
|
|
697
|
+
' journal.mjs open-spawns <file>\n' +
|
|
698
|
+
' journal.mjs close-spawn <file> <init> <agent> --reason R [--verdict V] [--actor A]',
|
|
699
|
+
);
|
|
700
|
+
process.exit(2);
|
|
701
|
+
}
|
|
702
|
+
console.error(`journal: ${err.message}`);
|
|
703
|
+
process.exit(1);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
class UsageError extends Error {}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Canonical absolute path, falling back to the merely-resolved one when the
|
|
711
|
+
* path cannot be canonicalized.
|
|
712
|
+
*
|
|
713
|
+
* The fallback is load-bearing, and NOT for `node --eval`: there argv[1] is
|
|
714
|
+
* undefined and the caller returns before ever reaching this. It is for the
|
|
715
|
+
* cases where argv[1] names something realpath cannot follow — the script
|
|
716
|
+
* directory was renamed or deleted after launch, a parent component is an
|
|
717
|
+
* unreadable directory (EACCES), or a launcher/shim rewrote argv[1] to a
|
|
718
|
+
* logical name that was never a real file. Without the fallback the guard
|
|
719
|
+
* throws ENOENT out of module scope and the tool dies at startup, which is a
|
|
720
|
+
* different bug, not a fix.
|
|
721
|
+
*/
|
|
722
|
+
function canonicalPath(path) {
|
|
723
|
+
const abs = resolve(path);
|
|
724
|
+
try {
|
|
725
|
+
return realpathSync(abs);
|
|
726
|
+
} catch {
|
|
727
|
+
return abs;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* True when this module is the program's entry point.
|
|
733
|
+
*
|
|
734
|
+
* BOTH sides must be canonicalized. `import.meta.url` already names the real
|
|
735
|
+
* file — Node resolves module specifiers through symlinks — while
|
|
736
|
+
* `process.argv[1]` is whatever the caller typed. Comparing them raw turned
|
|
737
|
+
* every invocation through a symlinked path into a SILENT no-op under exit 0:
|
|
738
|
+
* `main()` never ran, nothing was written, nothing said so. That is not
|
|
739
|
+
* theoretical — `/tmp` and `/var` are symlinks on macOS, and plugin installs
|
|
740
|
+
* routinely reach `scripts/` through one.
|
|
741
|
+
*/
|
|
742
|
+
function isMainModule(moduleUrl) {
|
|
743
|
+
if (!process.argv[1]) return false;
|
|
744
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (isMainModule(import.meta.url)) main();
|