@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,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();